From 180c458b145020c8dc8fea845f3a25208b8162ef Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Tue, 11 Nov 2025 22:49:56 +0100 Subject: [PATCH 01/44] feat: Create server and bot --- .gitignore | 6 + .pre-commit-config.yaml | 20 + github_id_to_discord_id_mapping.yaml | 18 + pyproject.toml | 49 ++ src/__init__.py | 0 src/bot.py | 128 +++ src/main.py | 14 + src/server.py | 142 ++++ src/utils/__init__.py | 0 src/utils/data_types.py | 88 +++ src/utils/error.py | 2 + src/utils/logging.py | 34 + src/utils/utils.py | 210 +++++ uv.lock | 1089 ++++++++++++++++++++++++++ 14 files changed, 1800 insertions(+) create mode 100644 .pre-commit-config.yaml create mode 100644 github_id_to_discord_id_mapping.yaml create mode 100644 pyproject.toml create mode 100644 src/__init__.py create mode 100644 src/bot.py create mode 100644 src/main.py create mode 100644 src/server.py create mode 100644 src/utils/__init__.py create mode 100644 src/utils/data_types.py create mode 100644 src/utils/error.py create mode 100644 src/utils/logging.py create mode 100644 src/utils/utils.py create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore index b7faf40..3ea6a6a 100644 --- a/.gitignore +++ b/.gitignore @@ -205,3 +205,9 @@ cython_debug/ marimo/_static/ marimo/_lsp/ __marimo__/ + +.idea/ + +# Shelves databases +item_name_to_node_id.db +post_id.db \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..64a4baf --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,20 @@ +repos: + # Run lint + - repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.9.9 + hooks: + # Run the linter. + - id: ruff + # Run the formatter. + - id: ruff-format + # Run the tests. + - repo: local + hooks: + - id: pytest + name: pytest + entry: ./.venv/bin/pytest + language: system + types: [python] + pass_filenames: false + always_run: true \ No newline at end of file diff --git a/github_id_to_discord_id_mapping.yaml b/github_id_to_discord_id_mapping.yaml new file mode 100644 index 0000000..c30854e --- /dev/null +++ b/github_id_to_discord_id_mapping.yaml @@ -0,0 +1,18 @@ +# Wikipop +MDQ6VXNlcjY2NTE0ODg1: "393756120952602625" +# Norbiros +MDQ6VXNlcjg5ODM3NzI0: "770620808644919307" +# Kubaryt +MDQ6VXNlcjg4MjY4MDYz: "786956683871387698" +# goteusz-maszyk +MDQ6VXNlcjQ1OTQ3Mzc0: "786956683871387698" +# toriishia +U_kgDOCXHesg: "803926455251763210" +# WitaLisek +U_kgDOCc8HIw: "770570853003034625" +# wojpo +U_kgDOB7Ogsw: "637608945409851392" +# sal +U_kgDOCM6eiA: "734838842548289567" +# balwanek +U_kgDOCMhQCg: "1401647878828589309" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..14314af --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,49 @@ +[project] +name = "github-project-discord-bot" +version = "1.0.0" +description = "Create post on forum channel for github project cards" +requires-python = ">=3.13,<3.14" +dependencies = [ + "aiohttp>=3.13.2", + "dotenv>=0.9.9", + "fastapi[standard]>=0.121.1", + "hikari>=2.5.0", + "pre-commit>=4.4.0", + "pytest>=9.0.0", + "pytest-asyncio>=1.3.0", + "ruff>=0.14.4", + "setuptools>=80.9.0", +] + +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = ["src"] + +[project.optional-dependencies] +test = ["pytest"] + +[tool.pytest.ini_options] +testpaths = ["src/tests"] +asyncio_mode = "auto" + +[project.scripts] +start-app = "src.main:main" + +[tool.ruff] +line-length = 120 +preview = true + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade (modern Python syntax) + "N", # PEP8 Naming +] diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/bot.py b/src/bot.py new file mode 100644 index 0000000..f410f37 --- /dev/null +++ b/src/bot.py @@ -0,0 +1,128 @@ +import asyncio +import os + +from hikari import ForumTag, GuildForumChannel, GuildPublicThread, RESTApp, TokenType +from hikari.impl import RESTClientImpl + +from src.utils.data_types import ( + ProjectItemEditedAssignees, + ProjectItemEditedBody, + ProjectItemEditedSingleSelect, + ProjectItemEditedTitle, + ProjectItemEvent, + SimpleProjectItemEvent, +) +from src.utils.error import ForumChannelNotFound +from src.utils.logging import bot_info +from src.utils.utils import fetch_forum_channel, get_new_tag, get_post_id, retrieve_discord_id + + +async def run(state: asyncio.Queue[ProjectItemEvent], stop_after_one_event: bool = False): + discord_rest = RESTApp() + await discord_rest.start() + + async with discord_rest.acquire(os.getenv("DISCORD_BOT_TOKEN"), token_type=TokenType.BOT) as client: + bot_info("Discord client acquired.") + forum_channel_id = int(os.getenv("FORUM_CHANNEL_ID")) + discord_guild_id = int(os.getenv("DISCORD_GUILD_ID")) + forum_channel = await fetch_forum_channel(client, forum_channel_id) + if forum_channel is None: + raise ForumChannelNotFound(f"Forum channel with ID {forum_channel_id} not found.") + + while True: + await process_update(client, forum_channel_id, discord_guild_id, forum_channel, state) + if stop_after_one_event: + break + + +async def process_update( + client: RESTClientImpl, + forum_channel_id: int, + discord_guild_id: int, + forum_channel: GuildForumChannel, + state: asyncio.Queue[ProjectItemEvent], +): + event = await state.get() + bot_info(f"Processing event for item: {event.name}") + post_id = await get_post_id(event.name, discord_guild_id, forum_channel_id, client) + author_discord_id = retrieve_discord_id(event.sender) + if post_id is None: + bot_info(f"Post not found, creating new post for item: {event.name}") + # todo: Handle author_discord_id being None + message = f"Nowy task stworzony {event.name} przez <@{author_discord_id}>" + post: GuildPublicThread = await client.create_forum_post( + forum_channel, + event.name, + message, + auto_archive_duration=10080, + user_mentions=[author_discord_id], + ) + else: + post = await client.fetch_channel(post_id) + + if not isinstance(post, GuildPublicThread): + return + + if isinstance(event, SimpleProjectItemEvent): + match event.event_type.value: + case "archived": + message = f"Task zarchiwizowany przez <@{author_discord_id}>." + await client.create_message(post.id, message, user_mentions=[author_discord_id]) + await client.edit_channel(post.id, archived=True) + bot_info(f"Post {event.name} archived.") + case "restored": + message = f"Task przywrócony przez <@{author_discord_id}>." + await client.create_message(post.id, message, user_mentions=[author_discord_id]) + await client.edit_channel(post.id, archived=False) + bot_info(f"Post {event.name} restored.") + case "deleted": + await client.delete_channel(post.id) + bot_info(f"Post {event.name} deleted.") + elif isinstance(event, ProjectItemEditedAssignees): + assignee_mentions: list[str] = [] + assignee_discord_ids: list[int] = [] + if event.new_assignees: + for assignee in event.new_assignees: + discord_id = retrieve_discord_id(assignee) + assignee_discord_ids.append(int(discord_id)) if discord_id else None + if discord_id: + assignee_mentions.append(f"<@{discord_id}>") + else: + assignee_mentions.append("Brak przypisanych osób") + + message = f"Osoby przypisane do taska edytowane, aktualni przypisani: {', '.join(assignee_mentions)}" + await client.create_message(post.id, message, user_mentions=assignee_discord_ids) + bot_info(f"Post {event.name} assignees updated.") + elif isinstance(event, ProjectItemEditedBody): + message = f"Opis taska zaktualizowany przez <@{author_discord_id}>. Nowy opis: \n{event.new_body}" + user_mentions = [author_discord_id] if author_discord_id else [] + await client.create_message(post.id, message, user_mentions=user_mentions) + bot_info(f"Post {event.name} body updated.") + elif isinstance(event, ProjectItemEditedTitle): + await client.edit_channel(post.id, name=event.new_title) + elif isinstance(event, ProjectItemEditedSingleSelect): + available_tags = list(forum_channel.available_tags) + current_tag_ids = list(post.applied_tag_ids) + + for tag in available_tags: + if tag.id in current_tag_ids and tag.name.startswith(f"{event.value_type.value}: "): + current_tag_ids.remove(tag.id) + + new_tag_name = f"{event.value_type.value}: {event.new_value}"[:48] + new_tag = get_new_tag(new_tag_name, available_tags) + + if new_tag is None: + bot_info(f"Tag {new_tag_name} not found, creating new tag.") + new_tag = ForumTag(name=new_tag_name) + available_tags.append(new_tag) + await client.edit_channel(forum_channel.id, available_tags=available_tags) + forum_channel = await fetch_forum_channel(client, forum_channel_id) + if forum_channel is None: + raise ForumChannelNotFound(f"Forum channel with ID {forum_channel_id} not found.") + available_tags = list(forum_channel.available_tags) + new_tag = get_new_tag(new_tag_name, available_tags) + + current_tag_ids.append(new_tag.id) + + await client.edit_channel(post.id, applied_tags=current_tag_ids) + bot_info(f"Post {event.name} label updated.") diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..0ecbab5 --- /dev/null +++ b/src/main.py @@ -0,0 +1,14 @@ +import os + +import dotenv +import uvicorn + + +def main(): + dotenv.load_dotenv() + host, port = os.getenv("IP_ADDRESS", "0.0.0.0:8000").split(":") + uvicorn.run("src.server:app", host=host, port=int(port), reload=True) + + +if __name__ == "__main__": + main() diff --git a/src/server.py b/src/server.py new file mode 100644 index 0000000..c814561 --- /dev/null +++ b/src/server.py @@ -0,0 +1,142 @@ +import asyncio +import json +import os +from contextlib import asynccontextmanager +from typing import Any + +from fastapi import FastAPI, Request +from starlette.responses import JSONResponse + +from src.bot import run +from src.utils.data_types import ( + ProjectItemEdited, + ProjectItemEditedAssignees, + ProjectItemEditedBody, + ProjectItemEditedSingleSelect, + ProjectItemEditedTitle, + ProjectItemEvent, + SingleSelectType, + simple_project_item_from_action_type, + single_select_type_from_field_name, +) +from src.utils.logging import server_error, server_info, server_warning +from src.utils.utils import fetch_assignees, fetch_item_name, fetch_single_select_value, get_item_name, verify_secret + +update_queue: asyncio.Queue[ProjectItemEvent] = asyncio.Queue() + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # startup + task = asyncio.create_task(run(update_queue)) + yield + # shutdown + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + +app = FastAPI(lifespan=lifespan) + + +@app.post("/webhook_endpoint") +async def webhook_endpoint(request: Request) -> JSONResponse: + body_bytes = await request.body() + if not body_bytes: + server_error("Received empty body in webhook request.") + return JSONResponse(status_code=400, content={"detail": "Missing request body."}) + signature = request.headers.get("X-Hub-Signature-256") + if signature: + correct_signature = verify_secret(os.getenv("GITHUB_WEBHOOK_SECRET", ""), body_bytes, signature) + if not correct_signature: + server_error("Invalid signature in webhook request.") + return JSONResponse(status_code=401, content={"detail": "Invalid signature"}) + elif os.getenv("GITHUB_WEBHOOK_SECRET", ""): + server_error("Missing signature in webhook request.") + return JSONResponse(status_code=401, content={"detail": "Missing signature"}) + else: + server_warning( + "Signature verification is disabled. To enable it set the 'GITHUB_WEBHOOK_SECRET' environment variable." + ) + try: + body: dict[str, Any] = json.loads(body_bytes) + except json.JSONDecodeError: + server_error("Invalid JSON data in webhook request.") + return JSONResponse(status_code=400, content={"detail": "Invalid JSON data."}) + projects_v2_item: dict[str, Any] = body.get("projects_v2_item", {}) + if not projects_v2_item: + server_error("Missing projects_v2_item in webhook payload.") + return JSONResponse(status_code=400, content={"detail": "Missing projects_v2_item in payload."}) + project_node_id: str | None = projects_v2_item.get("project_node_id", None) + if project_node_id is None or project_node_id != os.getenv("GITHUB_PROJECT_NODE_ID"): + server_error("Invalid project_node_id in webhook payload.") + return JSONResponse(status_code=400, content={"detail": "Invalid project_node_id."}) + + item_node_id: str | None = projects_v2_item.get("node_id", None) + if item_node_id is None: + server_error("Missing item_node_id in webhook payload.") + return JSONResponse(status_code=400, content={"detail": "Missing item_node_id in payload."}) + item_name = await get_item_name(item_node_id) + if item_name is None: + server_error("Could not fetch item name.") + return JSONResponse(status_code=500, content={"detail": "Could not fetch item name."}) + + if body.get("action") == "edited": + project_item_event = await process_edition(body, item_name) + elif body.get("action") is not None: + project_item_event = simple_project_item_from_action_type( + body["action"], item_name, body.get("sender", {}).get("node_id", "Unknown") + ) + else: + server_error("Missing action in webhook payload.") + return JSONResponse(status_code=400, content={"detail": "Missing action in payload."}) + + if project_item_event is not None: + await update_queue.put(project_item_event) + + server_info(f"Received webhook for item: {item_name}") + return JSONResponse(content={"detail": "Successfully received webhook data"}) + + +async def process_edition(body: dict[str, Any], item_name: str) -> ProjectItemEdited | None: + editor: str = body.get("sender", {}).get("node_id", "Unknown") + body_changed: dict[str, Any] | None = body.get("changes", {}).get("body", None) + + if body_changed is not None: + new_body = body_changed.get("to", "") + project_item_edited = ProjectItemEditedBody(item_name, editor, new_body) + return project_item_edited + + field_changed: dict[str, Any] | None = body.get("changes", {}).get("field_value", None) + + if field_changed is None: + return None + + match field_changed["field_type"]: + case "assignees": + new_assignees = await fetch_assignees(body.get("projects_v2_item", {}).get("node_id", None)) + project_item_edited = ProjectItemEditedAssignees(item_name, editor, new_assignees) + return project_item_edited + case "title": + new_title = await fetch_item_name(body.get("projects_v2_item", {}).get("node_id", None)) + project_item_edited = ProjectItemEditedTitle(item_name, editor, new_title) + return project_item_edited + case "single_select": + new_value: str | None = field_changed.get("to", {}).get("name", None) + if new_value is None: + new_value = await fetch_single_select_value( + body.get("projects_v2_item", {}).get("node_id", None), field_changed.get("field_name", None) + ) + value_type = single_select_type_from_field_name(field_changed.get("field_name", None)) + project_item_edited = ProjectItemEditedSingleSelect(item_name, editor, new_value, value_type) + return project_item_edited + case "iteration": + new_value = field_changed.get("to", {}).get("title", None) + project_item_edited = ProjectItemEditedSingleSelect( + item_name, editor, new_value, SingleSelectType.ITERATION + ) + return project_item_edited + + return None diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/data_types.py b/src/utils/data_types.py new file mode 100644 index 0000000..747cee5 --- /dev/null +++ b/src/utils/data_types.py @@ -0,0 +1,88 @@ +from dataclasses import dataclass +from enum import Enum + + +class SimpleProjectItemEventType(Enum): + CREATED = "created" + ARCHIVED = "archived" + RESTORED = "restored" + DELETED = "deleted" + + +class SingleSelectType(Enum): + STATUS = "Status" + PRIORITY = "Priority" + SIZE = "Size" + ITERATION = "Iteration" + SECTION = "Section" + + +def single_select_type_from_field_name(field_name: str | None) -> SingleSelectType | None: + match field_name: + case "Status": + return SingleSelectType.STATUS + case "Priority": + return SingleSelectType.PRIORITY + case "Size": + return SingleSelectType.SIZE + case "Iteration": + return SingleSelectType.ITERATION + case "Section": + return SingleSelectType.SECTION + case _: + return None + + +@dataclass +class ProjectItemEvent: + name: str + sender: str + + +class SimpleProjectItemEvent(ProjectItemEvent): + def __init__(self, name: str, sender: str, event_type: SimpleProjectItemEventType): + super().__init__(name, sender) + self.event_type = event_type + + +def simple_project_item_from_action_type(action_type: str, name: str, sender: str): + match action_type: + case "created": + return SimpleProjectItemEvent(name, sender, SimpleProjectItemEventType.CREATED) + case "archived": + return SimpleProjectItemEvent(name, sender, SimpleProjectItemEventType.ARCHIVED) + case "restored": + return SimpleProjectItemEvent(name, sender, SimpleProjectItemEventType.RESTORED) + case "deleted": + return SimpleProjectItemEvent(name, sender, SimpleProjectItemEventType.DELETED) + case _: + raise ValueError(f"Unknown action type: {action_type}") + + +class ProjectItemEdited(ProjectItemEvent): + pass + + +class ProjectItemEditedBody(ProjectItemEdited): + def __init__(self, name: str, editor: str, new_body: str): + super().__init__(name, editor) + self.new_body = new_body + + +class ProjectItemEditedAssignees(ProjectItemEdited): + def __init__(self, name: str, editor: str, new_assignees: list[str]): + super().__init__(name, editor) + self.new_assignees = new_assignees + + +class ProjectItemEditedTitle(ProjectItemEdited): + def __init__(self, name: str, editor: str, new_name: str): + super().__init__(name, editor) + self.new_title = new_name + + +class ProjectItemEditedSingleSelect(ProjectItemEdited): + def __init__(self, name: str, editor: str, new_value: str, value_type: SingleSelectType): + super().__init__(name, editor) + self.new_value = new_value + self.value_type = value_type diff --git a/src/utils/error.py b/src/utils/error.py new file mode 100644 index 0000000..ff33dca --- /dev/null +++ b/src/utils/error.py @@ -0,0 +1,2 @@ +class ForumChannelNotFound(SystemExit): + pass diff --git a/src/utils/logging.py b/src/utils/logging.py new file mode 100644 index 0000000..c32c8f2 --- /dev/null +++ b/src/utils/logging.py @@ -0,0 +1,34 @@ +from rich import print as rich_print + + +def bot(text: str): + rich_print("[bold blue]BOT: [/bold blue]" + text) + + +def bot_info(text: str): + bot(f"[bold green]INFO:[/bold green] {text}") + + +# Currently unused: +# def bot_warning(text: str): +# bot(f"[bold yellow]WARNING:[/bold yellow] {text}") + + +def bot_error(text: str): + bot(f"[bold red]ERROR:[/bold red] {text}") + + +def server(text: str): + rich_print("[bold purple]SERVER: [/bold purple]" + text) + + +def server_info(text: str): + server(f"[bold green]INFO:[/bold green] {text}") + + +def server_warning(text: str): + server(f"[bold yellow]WARNING:[/bold yellow] {text}") + + +def server_error(text: str): + server(f"[bold red]ERROR:[/bold red] {text}") diff --git a/src/utils/utils.py b/src/utils/utils.py new file mode 100644 index 0000000..1c08edb --- /dev/null +++ b/src/utils/utils.py @@ -0,0 +1,210 @@ +import hashlib +import hmac +import os +import shelve + +import aiohttp +import yaml +from hikari import ForumTag, GuildForumChannel +from hikari.impl import RESTClientImpl + + +async def get_item_name(item_node_id: str) -> str | None: + with shelve.open("item_name_to_node_id.db") as db: + try: + item_name: str = db[item_node_id] + except KeyError: + item_name = await fetch_item_name(item_node_id) + if item_name is None: + return None + db[item_node_id] = item_name + + return item_name + + +async def fetch_item_name(item_node_id: str | None) -> str | None: + if item_node_id is None: + return None + + query = """ + query ($id: ID!) { + node(id: $id) { + ... on ProjectV2Item { + content { + ... on DraftIssue { + title + } + ... on Issue { + title + } + ... on PullRequest { + title + } + } + } + } + } + """ + + variables = {"id": item_node_id} + + async with aiohttp.ClientSession() as session: + async with session.post( + "https://api.github.com/graphql", + json={"query": query, "variables": variables}, + headers={"Authorization": f"Bearer {os.getenv('GITHUB_TOKEN')}"}, + ) as response: + response_body = await response.json() + + try: + item_name: str | None = response_body["data"]["node"]["content"]["title"] + except (KeyError, AttributeError): + return None + + return item_name + + +async def fetch_assignees(item_node_id: str | None) -> list[str]: + if item_node_id is None: + return [] + + query = """ + query ($id: ID!) { + node(id: $id) { + ... on ProjectV2Item { + content { + ... on DraftIssue { + assignees(first: 10) { + nodes { + id + } + } + } + ... on Issue { + assignees(first: 10) { + nodes { + id + } + } + } + ... on PullRequest { + assignees(first: 10) { + nodes { + id + } + } + } + } + } + } + } + """ + + variables = {"id": item_node_id} + + async with aiohttp.ClientSession() as session: + async with session.post( + "https://api.github.com/graphql", + json={"query": query, "variables": variables}, + headers={"Authorization": f"Bearer {os.getenv('GITHUB_TOKEN')}"}, + ) as response: + response_body = await response.json() + try: + assignees_data = response_body["data"]["node"]["content"]["assignees"]["nodes"] + except (KeyError, AttributeError): + return [] + assignees = [assignee.get("id", None) for assignee in assignees_data] + + return assignees + + +async def fetch_single_select_value(item_node_id: str | None, field_name: str | None) -> str | None: + if item_node_id is None or field_name is None: + return None + + query = """ + query ($id: ID!, $field_type: String!) { + node(id: $id) { + ... on ProjectV2Item { + fieldValueByName(name: $field_type) { + ... on ProjectV2ItemFieldSingleSelectValue { + name + } + } + } + } + } + """ + + variables = {"id": item_node_id, "field_type": field_name} + + async with aiohttp.ClientSession() as session: + async with session.post( + "https://api.github.com/graphql", + json={"query": query, "variables": variables}, + headers={"Authorization": f"Bearer {os.getenv('GITHUB_TOKEN')}"}, + ) as response: + response_body = await response.json() + + try: + name: str | None = response_body["data"]["node"]["fieldValueByName"]["name"] + except (KeyError, AttributeError): + return None + + return name + + +async def get_post_id( + name: str, discord_guild_id: int, forum_channel_id: int, rest_client: RESTClientImpl +) -> int | None: + with shelve.open("post_id.db") as db: + try: + post_id: str = db[name] + return int(post_id) + except KeyError: + pass + # todo: return post if found in active threads or in archived threads + for thread in await rest_client.fetch_active_threads(discord_guild_id): + if thread.name == name: + db[name] = thread.id + return thread.id + for thread in await rest_client.fetch_public_archived_threads(forum_channel_id): + if thread.name == name: + db[name] = thread.id + return thread.id + + return None + + +def retrieve_discord_id(node_id: str) -> str | None: + with open(os.getenv("GITHUB_ID_TO_DISCORD_ID_MAPPING_PATH", "github_id_to_discord_id_mapping.yaml")) as file: + mapping: dict[str, str] = yaml.load("".join(file.readlines()), Loader=yaml.Loader) + + if mapping is None: + return None + + return mapping.get(node_id, None) + + +async def fetch_forum_channel(client: RESTClientImpl, forum_channel_id: int) -> GuildForumChannel | None: + forum_channel = await client.fetch_channel(forum_channel_id) + if forum_channel is None or not isinstance(forum_channel, GuildForumChannel): + return None + return forum_channel + + +def get_new_tag(new_tag_name: str, available_tags: list[ForumTag]) -> ForumTag | None: + new_tag = next((tag for tag in available_tags if tag.name == new_tag_name), None) + return new_tag + + +def generate_signature(secret: str, payload: bytes) -> str: + hash_object = hmac.new(secret.encode("utf-8"), msg=payload, digestmod=hashlib.sha256) + return f"sha256={hash_object.hexdigest()}" + + +def verify_secret(secret: str, payload: bytes, signature_header: str) -> bool: + if not secret: + return True + expected_signature = generate_signature(secret, payload) + return hmac.compare_digest(expected_signature, signature_header) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..1ec96a2 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1089 @@ +version = 1 +revision = 3 +requires-python = "==3.13.*" + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.13.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/ce/3b83ebba6b3207a7135e5fcaba49706f8a4b6008153b4e30540c982fae26/aiohttp-3.13.2.tar.gz", hash = "sha256:40176a52c186aefef6eb3cad2cdd30cd06e3afbe88fe8ab2af9c0b90f228daca", size = 7837994, upload-time = "2025-10-28T20:59:39.937Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/78/7e90ca79e5aa39f9694dcfd74f4720782d3c6828113bb1f3197f7e7c4a56/aiohttp-3.13.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7519bdc7dfc1940d201651b52bf5e03f5503bda45ad6eacf64dda98be5b2b6be", size = 732139, upload-time = "2025-10-28T20:57:02.455Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/1f59215ab6853fbaa5c8495fa6cbc39edfc93553426152b75d82a5f32b76/aiohttp-3.13.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:088912a78b4d4f547a1f19c099d5a506df17eacec3c6f4375e2831ec1d995742", size = 490082, upload-time = "2025-10-28T20:57:04.784Z" }, + { url = "https://files.pythonhosted.org/packages/68/7b/fe0fe0f5e05e13629d893c760465173a15ad0039c0a5b0d0040995c8075e/aiohttp-3.13.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5276807b9de9092af38ed23ce120539ab0ac955547b38563a9ba4f5b07b95293", size = 489035, upload-time = "2025-10-28T20:57:06.894Z" }, + { url = "https://files.pythonhosted.org/packages/d2/04/db5279e38471b7ac801d7d36a57d1230feeee130bbe2a74f72731b23c2b1/aiohttp-3.13.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1237c1375eaef0db4dcd7c2559f42e8af7b87ea7d295b118c60c36a6e61cb811", size = 1720387, upload-time = "2025-10-28T20:57:08.685Z" }, + { url = "https://files.pythonhosted.org/packages/31/07/8ea4326bd7dae2bd59828f69d7fdc6e04523caa55e4a70f4a8725a7e4ed2/aiohttp-3.13.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:96581619c57419c3d7d78703d5b78c1e5e5fc0172d60f555bdebaced82ded19a", size = 1688314, upload-time = "2025-10-28T20:57:10.693Z" }, + { url = "https://files.pythonhosted.org/packages/48/ab/3d98007b5b87ffd519d065225438cc3b668b2f245572a8cb53da5dd2b1bc/aiohttp-3.13.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2713a95b47374169409d18103366de1050fe0ea73db358fc7a7acb2880422d4", size = 1756317, upload-time = "2025-10-28T20:57:12.563Z" }, + { url = "https://files.pythonhosted.org/packages/97/3d/801ca172b3d857fafb7b50c7c03f91b72b867a13abca982ed6b3081774ef/aiohttp-3.13.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:228a1cd556b3caca590e9511a89444925da87d35219a49ab5da0c36d2d943a6a", size = 1858539, upload-time = "2025-10-28T20:57:14.623Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0d/4764669bdf47bd472899b3d3db91fffbe925c8e3038ec591a2fd2ad6a14d/aiohttp-3.13.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac6cde5fba8d7d8c6ac963dbb0256a9854e9fafff52fbcc58fdf819357892c3e", size = 1739597, upload-time = "2025-10-28T20:57:16.399Z" }, + { url = "https://files.pythonhosted.org/packages/c4/52/7bd3c6693da58ba16e657eb904a5b6decfc48ecd06e9ac098591653b1566/aiohttp-3.13.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2bef8237544f4e42878c61cef4e2839fee6346dc60f5739f876a9c50be7fcdb", size = 1555006, upload-time = "2025-10-28T20:57:18.288Z" }, + { url = "https://files.pythonhosted.org/packages/48/30/9586667acec5993b6f41d2ebcf96e97a1255a85f62f3c653110a5de4d346/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:16f15a4eac3bc2d76c45f7ebdd48a65d41b242eb6c31c2245463b40b34584ded", size = 1683220, upload-time = "2025-10-28T20:57:20.241Z" }, + { url = "https://files.pythonhosted.org/packages/71/01/3afe4c96854cfd7b30d78333852e8e851dceaec1c40fd00fec90c6402dd2/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:bb7fb776645af5cc58ab804c58d7eba545a97e047254a52ce89c157b5af6cd0b", size = 1712570, upload-time = "2025-10-28T20:57:22.253Z" }, + { url = "https://files.pythonhosted.org/packages/11/2c/22799d8e720f4697a9e66fd9c02479e40a49de3de2f0bbe7f9f78a987808/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e1b4951125ec10c70802f2cb09736c895861cd39fd9dcb35107b4dc8ae6220b8", size = 1733407, upload-time = "2025-10-28T20:57:24.37Z" }, + { url = "https://files.pythonhosted.org/packages/34/cb/90f15dd029f07cebbd91f8238a8b363978b530cd128488085b5703683594/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:550bf765101ae721ee1d37d8095f47b1f220650f85fe1af37a90ce75bab89d04", size = 1550093, upload-time = "2025-10-28T20:57:26.257Z" }, + { url = "https://files.pythonhosted.org/packages/69/46/12dce9be9d3303ecbf4d30ad45a7683dc63d90733c2d9fe512be6716cd40/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fe91b87fc295973096251e2d25a811388e7d8adf3bd2b97ef6ae78bc4ac6c476", size = 1758084, upload-time = "2025-10-28T20:57:28.349Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c8/0932b558da0c302ffd639fc6362a313b98fdf235dc417bc2493da8394df7/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0c8e31cfcc4592cb200160344b2fb6ae0f9e4effe06c644b5a125d4ae5ebe23", size = 1716987, upload-time = "2025-10-28T20:57:30.233Z" }, + { url = "https://files.pythonhosted.org/packages/5d/8b/f5bd1a75003daed099baec373aed678f2e9b34f2ad40d85baa1368556396/aiohttp-3.13.2-cp313-cp313-win32.whl", hash = "sha256:0740f31a60848d6edb296a0df827473eede90c689b8f9f2a4cdde74889eb2254", size = 425859, upload-time = "2025-10-28T20:57:32.105Z" }, + { url = "https://files.pythonhosted.org/packages/5d/28/a8a9fc6957b2cee8902414e41816b5ab5536ecf43c3b1843c10e82c559b2/aiohttp-3.13.2-cp313-cp313-win_amd64.whl", hash = "sha256:a88d13e7ca367394908f8a276b89d04a3652044612b9a408a0bb22a5ed976a1a", size = 452192, upload-time = "2025-10-28T20:57:34.166Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6", size = 213252, upload-time = "2025-08-04T08:54:26.451Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" }, +] + +[[package]] +name = "attrs" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, +] + +[[package]] +name = "certifi" +version = "2025.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" }, +] + +[[package]] +name = "cfgv" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, +] + +[[package]] +name = "click" +version = "8.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" }, +] + +[[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 = "colorlog" +version = "6.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/7a/359f4d5df2353f26172b3cc39ea32daa39af8de522205f512f458923e677/colorlog-6.9.0.tar.gz", hash = "sha256:bfba54a1b93b94f54e1f4fe48395725a3d92fd2a4af702f6bd70946bdc0c6ac2", size = 16624, upload-time = "2024-10-29T18:34:51.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/51/9b208e85196941db2f0654ad0357ca6388ab3ed67efdbfc799f35d1f83aa/colorlog-6.9.0-py3-none-any.whl", hash = "sha256:5906e71acd67cb07a71e779c47c4bcb45fb8c2993eebe9e5adcd6a6f1b283eff", size = 11424, upload-time = "2024-10-29T18:34:49.815Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "dotenv" +version = "0.9.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dotenv" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892, upload-time = "2025-02-19T22:15:01.647Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + +[[package]] +name = "fastapi" +version = "0.121.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6b/a4/29e1b861fc9017488ed02ff1052feffa40940cb355ed632a8845df84ce84/fastapi-0.121.1.tar.gz", hash = "sha256:b6dba0538fd15dab6fe4d3e5493c3957d8a9e1e9257f56446b5859af66f32441", size = 342523, upload-time = "2025-11-08T21:48:14.068Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/fd/2e6f7d706899cc08690c5f6641e2ffbfffe019e8f16ce77104caa5730910/fastapi-0.121.1-py3-none-any.whl", hash = "sha256:2c5c7028bc3a58d8f5f09aecd3fd88a000ccc0c5ad627693264181a3c33aa1fc", size = 109192, upload-time = "2025-11-08T21:48:12.458Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "email-validator" }, + { name = "fastapi-cli", extra = ["standard"] }, + { name = "httpx" }, + { name = "jinja2" }, + { name = "python-multipart" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[[package]] +name = "fastapi-cli" +version = "0.0.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "rich-toolkit" }, + { name = "typer" }, + { name = "uvicorn", extra = ["standard"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/08/0af729f6231ebdc17a0356397f966838cbe2efa38529951e24017c7435d5/fastapi_cli-0.0.11.tar.gz", hash = "sha256:4f01d751c14d3d2760339cca0f45e81d816218cae8174d1dc757b5375868cde5", size = 17550, upload-time = "2025-09-09T12:50:38.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/8f/9e3ad391d1c4183de55c256b481899bbd7bbd06d389e4986741bb289fe94/fastapi_cli-0.0.11-py3-none-any.whl", hash = "sha256:bcdd1123c6077c7466452b9490ca47821f00eb784d58496674793003f9f8e33a", size = 11095, upload-time = "2025-09-09T12:50:37.658Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "fastapi-cloud-cli" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[[package]] +name = "fastapi-cloud-cli" +version = "0.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pydantic", extra = ["email"] }, + { name = "rich-toolkit" }, + { name = "rignore" }, + { name = "sentry-sdk" }, + { name = "typer" }, + { name = "uvicorn", extra = ["standard"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/2e/3b6e5016affc310e5109bc580f760586eabecea0c8a7ab067611cd849ac0/fastapi_cloud_cli-0.1.5.tar.gz", hash = "sha256:341ee585eb731a6d3c3656cb91ad38e5f39809bf1a16d41de1333e38635a7937", size = 22710, upload-time = "2025-07-28T13:30:48.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/a6/5aa862489a2918a096166fd98d9fe86b7fd53c607678b3fa9d8c432d88d5/fastapi_cloud_cli-0.1.5-py3-none-any.whl", hash = "sha256:d80525fb9c0e8af122370891f9fa83cf5d496e4ad47a8dd26c0496a6c85a012a", size = 18992, upload-time = "2025-07-28T13:30:47.427Z" }, +] + +[[package]] +name = "filelock" +version = "3.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/bb/0ab3e58d22305b6f5440629d20683af28959bf793d98d11950e305c1c326/filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58", size = 17687, upload-time = "2025-08-14T16:56:03.016Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/b1/b64018016eeb087db503b038296fd782586432b9c077fc5c7839e9cb6ef6/frozenlist-1.7.0.tar.gz", hash = "sha256:2e310d81923c2437ea8670467121cc3e9b0f76d3043cc1d2331d56c7fb7a3a8f", size = 45078, upload-time = "2025-06-09T23:02:35.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/90/6b2cebdabdbd50367273c20ff6b57a3dfa89bd0762de02c3a1eb42cb6462/frozenlist-1.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee80eeda5e2a4e660651370ebffd1286542b67e268aa1ac8d6dbe973120ef7ee", size = 79791, upload-time = "2025-06-09T23:01:09.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/2e/5b70b6a3325363293fe5fc3ae74cdcbc3e996c2a11dde2fd9f1fb0776d19/frozenlist-1.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d1a81c85417b914139e3a9b995d4a1c84559afc839a93cf2cb7f15e6e5f6ed2d", size = 47165, upload-time = "2025-06-09T23:01:10.653Z" }, + { url = "https://files.pythonhosted.org/packages/f4/25/a0895c99270ca6966110f4ad98e87e5662eab416a17e7fd53c364bf8b954/frozenlist-1.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cbb65198a9132ebc334f237d7b0df163e4de83fb4f2bdfe46c1e654bdb0c5d43", size = 45881, upload-time = "2025-06-09T23:01:12.296Z" }, + { url = "https://files.pythonhosted.org/packages/19/7c/71bb0bbe0832793c601fff68cd0cf6143753d0c667f9aec93d3c323f4b55/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dab46c723eeb2c255a64f9dc05b8dd601fde66d6b19cdb82b2e09cc6ff8d8b5d", size = 232409, upload-time = "2025-06-09T23:01:13.641Z" }, + { url = "https://files.pythonhosted.org/packages/c0/45/ed2798718910fe6eb3ba574082aaceff4528e6323f9a8570be0f7028d8e9/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6aeac207a759d0dedd2e40745575ae32ab30926ff4fa49b1635def65806fddee", size = 225132, upload-time = "2025-06-09T23:01:15.264Z" }, + { url = "https://files.pythonhosted.org/packages/ba/e2/8417ae0f8eacb1d071d4950f32f229aa6bf68ab69aab797b72a07ea68d4f/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd8c4e58ad14b4fa7802b8be49d47993182fdd4023393899632c88fd8cd994eb", size = 237638, upload-time = "2025-06-09T23:01:16.752Z" }, + { url = "https://files.pythonhosted.org/packages/f8/b7/2ace5450ce85f2af05a871b8c8719b341294775a0a6c5585d5e6170f2ce7/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04fb24d104f425da3540ed83cbfc31388a586a7696142004c577fa61c6298c3f", size = 233539, upload-time = "2025-06-09T23:01:18.202Z" }, + { url = "https://files.pythonhosted.org/packages/46/b9/6989292c5539553dba63f3c83dc4598186ab2888f67c0dc1d917e6887db6/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a5c505156368e4ea6b53b5ac23c92d7edc864537ff911d2fb24c140bb175e60", size = 215646, upload-time = "2025-06-09T23:01:19.649Z" }, + { url = "https://files.pythonhosted.org/packages/72/31/bc8c5c99c7818293458fe745dab4fd5730ff49697ccc82b554eb69f16a24/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bd7eb96a675f18aa5c553eb7ddc24a43c8c18f22e1f9925528128c052cdbe00", size = 232233, upload-time = "2025-06-09T23:01:21.175Z" }, + { url = "https://files.pythonhosted.org/packages/59/52/460db4d7ba0811b9ccb85af996019f5d70831f2f5f255f7cc61f86199795/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:05579bf020096fe05a764f1f84cd104a12f78eaab68842d036772dc6d4870b4b", size = 227996, upload-time = "2025-06-09T23:01:23.098Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c9/f4b39e904c03927b7ecf891804fd3b4df3db29b9e487c6418e37988d6e9d/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:376b6222d114e97eeec13d46c486facd41d4f43bab626b7c3f6a8b4e81a5192c", size = 242280, upload-time = "2025-06-09T23:01:24.808Z" }, + { url = "https://files.pythonhosted.org/packages/b8/33/3f8d6ced42f162d743e3517781566b8481322be321b486d9d262adf70bfb/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0aa7e176ebe115379b5b1c95b4096fb1c17cce0847402e227e712c27bdb5a949", size = 217717, upload-time = "2025-06-09T23:01:26.28Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e8/ad683e75da6ccef50d0ab0c2b2324b32f84fc88ceee778ed79b8e2d2fe2e/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3fbba20e662b9c2130dc771e332a99eff5da078b2b2648153a40669a6d0e36ca", size = 236644, upload-time = "2025-06-09T23:01:27.887Z" }, + { url = "https://files.pythonhosted.org/packages/b2/14/8d19ccdd3799310722195a72ac94ddc677541fb4bef4091d8e7775752360/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f3f4410a0a601d349dd406b5713fec59b4cee7e71678d5b17edda7f4655a940b", size = 238879, upload-time = "2025-06-09T23:01:29.524Z" }, + { url = "https://files.pythonhosted.org/packages/ce/13/c12bf657494c2fd1079a48b2db49fa4196325909249a52d8f09bc9123fd7/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e2cdfaaec6a2f9327bf43c933c0319a7c429058e8537c508964a133dffee412e", size = 232502, upload-time = "2025-06-09T23:01:31.287Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8b/e7f9dfde869825489382bc0d512c15e96d3964180c9499efcec72e85db7e/frozenlist-1.7.0-cp313-cp313-win32.whl", hash = "sha256:5fc4df05a6591c7768459caba1b342d9ec23fa16195e744939ba5914596ae3e1", size = 39169, upload-time = "2025-06-09T23:01:35.503Z" }, + { url = "https://files.pythonhosted.org/packages/35/89/a487a98d94205d85745080a37860ff5744b9820a2c9acbcdd9440bfddf98/frozenlist-1.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:52109052b9791a3e6b5d1b65f4b909703984b770694d3eb64fad124c835d7cba", size = 43219, upload-time = "2025-06-09T23:01:36.784Z" }, + { url = "https://files.pythonhosted.org/packages/56/d5/5c4cf2319a49eddd9dd7145e66c4866bdc6f3dbc67ca3d59685149c11e0d/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a6f86e4193bb0e235ef6ce3dde5cbabed887e0b11f516ce8a0f4d3b33078ec2d", size = 84345, upload-time = "2025-06-09T23:01:38.295Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/ec2c1e1dc16b85bc9d526009961953df9cec8481b6886debb36ec9107799/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:82d664628865abeb32d90ae497fb93df398a69bb3434463d172b80fc25b0dd7d", size = 48880, upload-time = "2025-06-09T23:01:39.887Z" }, + { url = "https://files.pythonhosted.org/packages/69/86/f9596807b03de126e11e7d42ac91e3d0b19a6599c714a1989a4e85eeefc4/frozenlist-1.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:912a7e8375a1c9a68325a902f3953191b7b292aa3c3fb0d71a216221deca460b", size = 48498, upload-time = "2025-06-09T23:01:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/5e/cb/df6de220f5036001005f2d726b789b2c0b65f2363b104bbc16f5be8084f8/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9537c2777167488d539bc5de2ad262efc44388230e5118868e172dd4a552b146", size = 292296, upload-time = "2025-06-09T23:01:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/83/1f/de84c642f17c8f851a2905cee2dae401e5e0daca9b5ef121e120e19aa825/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f34560fb1b4c3e30ba35fa9a13894ba39e5acfc5f60f57d8accde65f46cc5e74", size = 273103, upload-time = "2025-06-09T23:01:44.166Z" }, + { url = "https://files.pythonhosted.org/packages/88/3c/c840bfa474ba3fa13c772b93070893c6e9d5c0350885760376cbe3b6c1b3/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acd03d224b0175f5a850edc104ac19040d35419eddad04e7cf2d5986d98427f1", size = 292869, upload-time = "2025-06-09T23:01:45.681Z" }, + { url = "https://files.pythonhosted.org/packages/a6/1c/3efa6e7d5a39a1d5ef0abeb51c48fb657765794a46cf124e5aca2c7a592c/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2038310bc582f3d6a09b3816ab01737d60bf7b1ec70f5356b09e84fb7408ab1", size = 291467, upload-time = "2025-06-09T23:01:47.234Z" }, + { url = "https://files.pythonhosted.org/packages/4f/00/d5c5e09d4922c395e2f2f6b79b9a20dab4b67daaf78ab92e7729341f61f6/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8c05e4c8e5f36e5e088caa1bf78a687528f83c043706640a92cb76cd6999384", size = 266028, upload-time = "2025-06-09T23:01:48.819Z" }, + { url = "https://files.pythonhosted.org/packages/4e/27/72765be905619dfde25a7f33813ac0341eb6b076abede17a2e3fbfade0cb/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:765bb588c86e47d0b68f23c1bee323d4b703218037765dcf3f25c838c6fecceb", size = 284294, upload-time = "2025-06-09T23:01:50.394Z" }, + { url = "https://files.pythonhosted.org/packages/88/67/c94103a23001b17808eb7dd1200c156bb69fb68e63fcf0693dde4cd6228c/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:32dc2e08c67d86d0969714dd484fd60ff08ff81d1a1e40a77dd34a387e6ebc0c", size = 281898, upload-time = "2025-06-09T23:01:52.234Z" }, + { url = "https://files.pythonhosted.org/packages/42/34/a3e2c00c00f9e2a9db5653bca3fec306349e71aff14ae45ecc6d0951dd24/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:c0303e597eb5a5321b4de9c68e9845ac8f290d2ab3f3e2c864437d3c5a30cd65", size = 290465, upload-time = "2025-06-09T23:01:53.788Z" }, + { url = "https://files.pythonhosted.org/packages/bb/73/f89b7fbce8b0b0c095d82b008afd0590f71ccb3dee6eee41791cf8cd25fd/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:a47f2abb4e29b3a8d0b530f7c3598badc6b134562b1a5caee867f7c62fee51e3", size = 266385, upload-time = "2025-06-09T23:01:55.769Z" }, + { url = "https://files.pythonhosted.org/packages/cd/45/e365fdb554159462ca12df54bc59bfa7a9a273ecc21e99e72e597564d1ae/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:3d688126c242a6fabbd92e02633414d40f50bb6002fa4cf995a1d18051525657", size = 288771, upload-time = "2025-06-09T23:01:57.4Z" }, + { url = "https://files.pythonhosted.org/packages/00/11/47b6117002a0e904f004d70ec5194fe9144f117c33c851e3d51c765962d0/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4e7e9652b3d367c7bd449a727dc79d5043f48b88d0cbfd4f9f1060cf2b414104", size = 288206, upload-time = "2025-06-09T23:01:58.936Z" }, + { url = "https://files.pythonhosted.org/packages/40/37/5f9f3c3fd7f7746082ec67bcdc204db72dad081f4f83a503d33220a92973/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1a85e345b4c43db8b842cab1feb41be5cc0b10a1830e6295b69d7310f99becaf", size = 282620, upload-time = "2025-06-09T23:02:00.493Z" }, + { url = "https://files.pythonhosted.org/packages/0b/31/8fbc5af2d183bff20f21aa743b4088eac4445d2bb1cdece449ae80e4e2d1/frozenlist-1.7.0-cp313-cp313t-win32.whl", hash = "sha256:3a14027124ddb70dfcee5148979998066897e79f89f64b13328595c4bdf77c81", size = 43059, upload-time = "2025-06-09T23:02:02.072Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ed/41956f52105b8dbc26e457c5705340c67c8cc2b79f394b79bffc09d0e938/frozenlist-1.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3bf8010d71d4507775f658e9823210b7427be36625b387221642725b515dcf3e", size = 47516, upload-time = "2025-06-09T23:02:03.779Z" }, + { url = "https://files.pythonhosted.org/packages/ee/45/b82e3c16be2182bff01179db177fe144d58b5dc787a7d4492c6ed8b9317f/frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e", size = 13106, upload-time = "2025-06-09T23:02:34.204Z" }, +] + +[[package]] +name = "github-project-discord-bot" +version = "1.0.0" +source = { editable = "." } +dependencies = [ + { name = "aiohttp" }, + { name = "dotenv" }, + { name = "fastapi", extra = ["standard"] }, + { name = "hikari" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, + { name = "setuptools" }, +] + +[package.optional-dependencies] +test = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiohttp", specifier = ">=3.13.2" }, + { name = "dotenv", specifier = ">=0.9.9" }, + { name = "fastapi", extras = ["standard"], specifier = ">=0.121.1" }, + { name = "hikari", specifier = ">=2.5.0" }, + { name = "pre-commit", specifier = ">=4.4.0" }, + { name = "pytest", specifier = ">=9.0.0" }, + { name = "pytest", marker = "extra == 'test'" }, + { name = "pytest-asyncio", specifier = ">=1.3.0" }, + { name = "ruff", specifier = ">=0.14.4" }, + { name = "setuptools", specifier = ">=80.9.0" }, +] +provides-extras = ["test"] + +[[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 = "hikari" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "attrs" }, + { name = "colorlog" }, + { name = "multidict" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/bd/e4bd16c662fe7614bb6f38aba40a095aef998af0602674fe24740d3814cd/hikari-2.5.0.tar.gz", hash = "sha256:b8fe7e5cd5adfedcdcc488a86781bc946002fb79d984a473736624dd8201993b", size = 1924829, upload-time = "2025-10-31T12:34:27.838Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/c5/256c7d498cbe8e67edfd9bb2971740eadb662effcf9b9c1aff8e6d21e90d/hikari-2.5.0-py3-none-any.whl", hash = "sha256:fd108b51c73f46b562ac9e7cd48d4f74655224083ae37a319a417c30418a40ec", size = 580206, upload-time = "2025-10-31T12:34:25.633Z" }, +] + +[[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 = "httptools" +version = "0.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/9a/ce5e1f7e131522e6d3426e8e7a490b3a01f39a6696602e1c4f33f9e94277/httptools-0.6.4.tar.gz", hash = "sha256:4e93eee4add6493b59a5c514da98c939b244fce4a0d8879cd3f466562f4b7d5c", size = 240639, upload-time = "2024-10-16T19:45:08.902Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/a3/9fe9ad23fd35f7de6b91eeb60848986058bd8b5a5c1e256f5860a160cc3e/httptools-0.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ade273d7e767d5fae13fa637f4d53b6e961fb7fd93c7797562663f0171c26660", size = 197214, upload-time = "2024-10-16T19:44:38.738Z" }, + { url = "https://files.pythonhosted.org/packages/ea/d9/82d5e68bab783b632023f2fa31db20bebb4e89dfc4d2293945fd68484ee4/httptools-0.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:856f4bc0478ae143bad54a4242fccb1f3f86a6e1be5548fecfd4102061b3a083", size = 102431, upload-time = "2024-10-16T19:44:39.818Z" }, + { url = "https://files.pythonhosted.org/packages/96/c1/cb499655cbdbfb57b577734fde02f6fa0bbc3fe9fb4d87b742b512908dff/httptools-0.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:322d20ea9cdd1fa98bd6a74b77e2ec5b818abdc3d36695ab402a0de8ef2865a3", size = 473121, upload-time = "2024-10-16T19:44:41.189Z" }, + { url = "https://files.pythonhosted.org/packages/af/71/ee32fd358f8a3bb199b03261f10921716990808a675d8160b5383487a317/httptools-0.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4d87b29bd4486c0093fc64dea80231f7c7f7eb4dc70ae394d70a495ab8436071", size = 473805, upload-time = "2024-10-16T19:44:42.384Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0a/0d4df132bfca1507114198b766f1737d57580c9ad1cf93c1ff673e3387be/httptools-0.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:342dd6946aa6bda4b8f18c734576106b8a31f2fe31492881a9a160ec84ff4bd5", size = 448858, upload-time = "2024-10-16T19:44:43.959Z" }, + { url = "https://files.pythonhosted.org/packages/1e/6a/787004fdef2cabea27bad1073bf6a33f2437b4dbd3b6fb4a9d71172b1c7c/httptools-0.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b36913ba52008249223042dca46e69967985fb4051951f94357ea681e1f5dc0", size = 452042, upload-time = "2024-10-16T19:44:45.071Z" }, + { url = "https://files.pythonhosted.org/packages/4d/dc/7decab5c404d1d2cdc1bb330b1bf70e83d6af0396fd4fc76fc60c0d522bf/httptools-0.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:28908df1b9bb8187393d5b5db91435ccc9c8e891657f9cbb42a2541b44c82fc8", size = 87682, upload-time = "2024-10-16T19:44:46.46Z" }, +] + +[[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.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/c4/62963f25a678f6a050fb0505a65e9e726996171e6dbe1547f79619eefb15/identify-2.6.14.tar.gz", hash = "sha256:663494103b4f717cb26921c52f8751363dc89db64364cd836a9bf1535f53cd6a", size = 99283, upload-time = "2025-09-06T19:30:52.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/ae/2ad30f4652712c82f1c23423d79136fbce338932ad166d70c1efb86a5998/identify-2.6.14-py2.py3-none-any.whl", hash = "sha256:11a073da82212c6646b1f39bb20d4483bfb9543bd5566fec60053c4bb309bf2e", size = 99172, upload-time = "2025-09-06T19:30:51.759Z" }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "multidict" +version = "6.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/7f/0652e6ed47ab288e3756ea9c0df8b14950781184d4bd7883f4d87dd41245/multidict-6.6.4.tar.gz", hash = "sha256:d2d4e4787672911b48350df02ed3fa3fffdc2f2e8ca06dd6afdf34189b76a9dd", size = 101843, upload-time = "2025-08-11T12:08:48.217Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/5d/e1db626f64f60008320aab00fbe4f23fc3300d75892a3381275b3d284580/multidict-6.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f46a6e8597f9bd71b31cc708195d42b634c8527fecbcf93febf1052cacc1f16e", size = 75848, upload-time = "2025-08-11T12:07:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/4c/aa/8b6f548d839b6c13887253af4e29c939af22a18591bfb5d0ee6f1931dae8/multidict-6.6.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:22e38b2bc176c5eb9c0a0e379f9d188ae4cd8b28c0f53b52bce7ab0a9e534657", size = 45060, upload-time = "2025-08-11T12:07:21.163Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c6/f5e97e5d99a729bc2aa58eb3ebfa9f1e56a9b517cc38c60537c81834a73f/multidict-6.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5df8afd26f162da59e218ac0eefaa01b01b2e6cd606cffa46608f699539246da", size = 43269, upload-time = "2025-08-11T12:07:22.392Z" }, + { url = "https://files.pythonhosted.org/packages/dc/31/d54eb0c62516776f36fe67f84a732f97e0b0e12f98d5685bebcc6d396910/multidict-6.6.4-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:49517449b58d043023720aa58e62b2f74ce9b28f740a0b5d33971149553d72aa", size = 237158, upload-time = "2025-08-11T12:07:23.636Z" }, + { url = "https://files.pythonhosted.org/packages/c4/1c/8a10c1c25b23156e63b12165a929d8eb49a6ed769fdbefb06e6f07c1e50d/multidict-6.6.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9408439537c5afdca05edd128a63f56a62680f4b3c234301055d7a2000220f", size = 257076, upload-time = "2025-08-11T12:07:25.049Z" }, + { url = "https://files.pythonhosted.org/packages/ad/86/90e20b5771d6805a119e483fd3d1e8393e745a11511aebca41f0da38c3e2/multidict-6.6.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:87a32d20759dc52a9e850fe1061b6e41ab28e2998d44168a8a341b99ded1dba0", size = 240694, upload-time = "2025-08-11T12:07:26.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/49/484d3e6b535bc0555b52a0a26ba86e4d8d03fd5587d4936dc59ba7583221/multidict-6.6.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:52e3c8d43cdfff587ceedce9deb25e6ae77daba560b626e97a56ddcad3756879", size = 266350, upload-time = "2025-08-11T12:07:27.94Z" }, + { url = "https://files.pythonhosted.org/packages/bf/b4/aa4c5c379b11895083d50021e229e90c408d7d875471cb3abf721e4670d6/multidict-6.6.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ad8850921d3a8d8ff6fbef790e773cecfc260bbfa0566998980d3fa8f520bc4a", size = 267250, upload-time = "2025-08-11T12:07:29.303Z" }, + { url = "https://files.pythonhosted.org/packages/80/e5/5e22c5bf96a64bdd43518b1834c6d95a4922cc2066b7d8e467dae9b6cee6/multidict-6.6.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:497a2954adc25c08daff36f795077f63ad33e13f19bfff7736e72c785391534f", size = 254900, upload-time = "2025-08-11T12:07:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/17/38/58b27fed927c07035abc02befacab42491e7388ca105e087e6e0215ead64/multidict-6.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:024ce601f92d780ca1617ad4be5ac15b501cc2414970ffa2bb2bbc2bd5a68fa5", size = 252355, upload-time = "2025-08-11T12:07:32.205Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a1/dad75d23a90c29c02b5d6f3d7c10ab36c3197613be5d07ec49c7791e186c/multidict-6.6.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a693fc5ed9bdd1c9e898013e0da4dcc640de7963a371c0bd458e50e046bf6438", size = 250061, upload-time = "2025-08-11T12:07:33.623Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1a/ac2216b61c7f116edab6dc3378cca6c70dc019c9a457ff0d754067c58b20/multidict-6.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:190766dac95aab54cae5b152a56520fd99298f32a1266d66d27fdd1b5ac00f4e", size = 249675, upload-time = "2025-08-11T12:07:34.958Z" }, + { url = "https://files.pythonhosted.org/packages/d4/79/1916af833b800d13883e452e8e0977c065c4ee3ab7a26941fbfdebc11895/multidict-6.6.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:34d8f2a5ffdceab9dcd97c7a016deb2308531d5f0fced2bb0c9e1df45b3363d7", size = 261247, upload-time = "2025-08-11T12:07:36.588Z" }, + { url = "https://files.pythonhosted.org/packages/c5/65/d1f84fe08ac44a5fc7391cbc20a7cedc433ea616b266284413fd86062f8c/multidict-6.6.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:59e8d40ab1f5a8597abcef00d04845155a5693b5da00d2c93dbe88f2050f2812", size = 257960, upload-time = "2025-08-11T12:07:39.735Z" }, + { url = "https://files.pythonhosted.org/packages/13/b5/29ec78057d377b195ac2c5248c773703a6b602e132a763e20ec0457e7440/multidict-6.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:467fe64138cfac771f0e949b938c2e1ada2b5af22f39692aa9258715e9ea613a", size = 250078, upload-time = "2025-08-11T12:07:41.525Z" }, + { url = "https://files.pythonhosted.org/packages/c4/0e/7e79d38f70a872cae32e29b0d77024bef7834b0afb406ddae6558d9e2414/multidict-6.6.4-cp313-cp313-win32.whl", hash = "sha256:14616a30fe6d0a48d0a48d1a633ab3b8bec4cf293aac65f32ed116f620adfd69", size = 41708, upload-time = "2025-08-11T12:07:43.405Z" }, + { url = "https://files.pythonhosted.org/packages/9d/34/746696dffff742e97cd6a23da953e55d0ea51fa601fa2ff387b3edcfaa2c/multidict-6.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:40cd05eaeb39e2bc8939451f033e57feaa2ac99e07dbca8afe2be450a4a3b6cf", size = 45912, upload-time = "2025-08-11T12:07:45.082Z" }, + { url = "https://files.pythonhosted.org/packages/c7/87/3bac136181e271e29170d8d71929cdeddeb77f3e8b6a0c08da3a8e9da114/multidict-6.6.4-cp313-cp313-win_arm64.whl", hash = "sha256:f6eb37d511bfae9e13e82cb4d1af36b91150466f24d9b2b8a9785816deb16605", size = 43076, upload-time = "2025-08-11T12:07:46.746Z" }, + { url = "https://files.pythonhosted.org/packages/64/94/0a8e63e36c049b571c9ae41ee301ada29c3fee9643d9c2548d7d558a1d99/multidict-6.6.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:6c84378acd4f37d1b507dfa0d459b449e2321b3ba5f2338f9b085cf7a7ba95eb", size = 82812, upload-time = "2025-08-11T12:07:48.402Z" }, + { url = "https://files.pythonhosted.org/packages/25/1a/be8e369dfcd260d2070a67e65dd3990dd635cbd735b98da31e00ea84cd4e/multidict-6.6.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0e0558693063c75f3d952abf645c78f3c5dfdd825a41d8c4d8156fc0b0da6e7e", size = 48313, upload-time = "2025-08-11T12:07:49.679Z" }, + { url = "https://files.pythonhosted.org/packages/26/5a/dd4ade298674b2f9a7b06a32c94ffbc0497354df8285f27317c66433ce3b/multidict-6.6.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3f8e2384cb83ebd23fd07e9eada8ba64afc4c759cd94817433ab8c81ee4b403f", size = 46777, upload-time = "2025-08-11T12:07:51.318Z" }, + { url = "https://files.pythonhosted.org/packages/89/db/98aa28bc7e071bfba611ac2ae803c24e96dd3a452b4118c587d3d872c64c/multidict-6.6.4-cp313-cp313t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f996b87b420995a9174b2a7c1a8daf7db4750be6848b03eb5e639674f7963773", size = 229321, upload-time = "2025-08-11T12:07:52.965Z" }, + { url = "https://files.pythonhosted.org/packages/c7/bc/01ddda2a73dd9d167bd85d0e8ef4293836a8f82b786c63fb1a429bc3e678/multidict-6.6.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc356250cffd6e78416cf5b40dc6a74f1edf3be8e834cf8862d9ed5265cf9b0e", size = 249954, upload-time = "2025-08-11T12:07:54.423Z" }, + { url = "https://files.pythonhosted.org/packages/06/78/6b7c0f020f9aa0acf66d0ab4eb9f08375bac9a50ff5e3edb1c4ccd59eafc/multidict-6.6.4-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:dadf95aa862714ea468a49ad1e09fe00fcc9ec67d122f6596a8d40caf6cec7d0", size = 228612, upload-time = "2025-08-11T12:07:55.914Z" }, + { url = "https://files.pythonhosted.org/packages/00/44/3faa416f89b2d5d76e9d447296a81521e1c832ad6e40b92f990697b43192/multidict-6.6.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dd57515bebffd8ebd714d101d4c434063322e4fe24042e90ced41f18b6d3395", size = 257528, upload-time = "2025-08-11T12:07:57.371Z" }, + { url = "https://files.pythonhosted.org/packages/05/5f/77c03b89af0fcb16f018f668207768191fb9dcfb5e3361a5e706a11db2c9/multidict-6.6.4-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:967af5f238ebc2eb1da4e77af5492219fbd9b4b812347da39a7b5f5c72c0fa45", size = 256329, upload-time = "2025-08-11T12:07:58.844Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e9/ed750a2a9afb4f8dc6f13dc5b67b514832101b95714f1211cd42e0aafc26/multidict-6.6.4-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a4c6875c37aae9794308ec43e3530e4aa0d36579ce38d89979bbf89582002bb", size = 247928, upload-time = "2025-08-11T12:08:01.037Z" }, + { url = "https://files.pythonhosted.org/packages/1f/b5/e0571bc13cda277db7e6e8a532791d4403dacc9850006cb66d2556e649c0/multidict-6.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7f683a551e92bdb7fac545b9c6f9fa2aebdeefa61d607510b3533286fcab67f5", size = 245228, upload-time = "2025-08-11T12:08:02.96Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a3/69a84b0eccb9824491f06368f5b86e72e4af54c3067c37c39099b6687109/multidict-6.6.4-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:3ba5aaf600edaf2a868a391779f7a85d93bed147854925f34edd24cc70a3e141", size = 235869, upload-time = "2025-08-11T12:08:04.746Z" }, + { url = "https://files.pythonhosted.org/packages/a9/9d/28802e8f9121a6a0804fa009debf4e753d0a59969ea9f70be5f5fdfcb18f/multidict-6.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:580b643b7fd2c295d83cad90d78419081f53fd532d1f1eb67ceb7060f61cff0d", size = 243446, upload-time = "2025-08-11T12:08:06.332Z" }, + { url = "https://files.pythonhosted.org/packages/38/ea/6c98add069b4878c1d66428a5f5149ddb6d32b1f9836a826ac764b9940be/multidict-6.6.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:37b7187197da6af3ee0b044dbc9625afd0c885f2800815b228a0e70f9a7f473d", size = 252299, upload-time = "2025-08-11T12:08:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/3a/09/8fe02d204473e14c0af3affd50af9078839dfca1742f025cca765435d6b4/multidict-6.6.4-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e1b93790ed0bc26feb72e2f08299691ceb6da5e9e14a0d13cc74f1869af327a0", size = 246926, upload-time = "2025-08-11T12:08:09.467Z" }, + { url = "https://files.pythonhosted.org/packages/37/3d/7b1e10d774a6df5175ecd3c92bff069e77bed9ec2a927fdd4ff5fe182f67/multidict-6.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a506a77ddee1efcca81ecbeae27ade3e09cdf21a8ae854d766c2bb4f14053f92", size = 243383, upload-time = "2025-08-11T12:08:10.981Z" }, + { url = "https://files.pythonhosted.org/packages/50/b0/a6fae46071b645ae98786ab738447de1ef53742eaad949f27e960864bb49/multidict-6.6.4-cp313-cp313t-win32.whl", hash = "sha256:f93b2b2279883d1d0a9e1bd01f312d6fc315c5e4c1f09e112e4736e2f650bc4e", size = 47775, upload-time = "2025-08-11T12:08:12.439Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0a/2436550b1520091af0600dff547913cb2d66fbac27a8c33bc1b1bccd8d98/multidict-6.6.4-cp313-cp313t-win_amd64.whl", hash = "sha256:6d46a180acdf6e87cc41dc15d8f5c2986e1e8739dc25dbb7dac826731ef381a4", size = 53100, upload-time = "2025-08-11T12:08:13.823Z" }, + { url = "https://files.pythonhosted.org/packages/97/ea/43ac51faff934086db9c072a94d327d71b7d8b40cd5dcb47311330929ef0/multidict-6.6.4-cp313-cp313t-win_arm64.whl", hash = "sha256:756989334015e3335d087a27331659820d53ba432befdef6a718398b0a8493ad", size = 45501, upload-time = "2025-08-11T12:08:15.173Z" }, + { url = "https://files.pythonhosted.org/packages/fd/69/b547032297c7e63ba2af494edba695d781af8a0c6e89e4d06cf848b21d80/multidict-6.6.4-py3-none-any.whl", hash = "sha256:27d8f8e125c07cb954e54d75d04905a9bba8a439c1d84aca94949d4d03d8601c", size = 12313, upload-time = "2025-08-11T12:08:46.891Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" }, +] + +[[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.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/49/7845c2d7bf6474efd8e27905b51b11e6ce411708c91e829b93f324de9929/pre_commit-4.4.0.tar.gz", hash = "sha256:f0233ebab440e9f17cabbb558706eb173d19ace965c68cdce2c081042b4fab15", size = 197501, upload-time = "2025-11-08T21:12:11.607Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/11/574fe7d13acf30bfd0a8dd7fa1647040f2b8064f13f43e8c963b1e65093b/pre_commit-4.4.0-py2.py3-none-any.whl", hash = "sha256:b35ea52957cbf83dcc5d8ee636cbead8624e3a15fbfa61a370e42158ac8a5813", size = 226049, upload-time = "2025-11-08T21:12:10.228Z" }, +] + +[[package]] +name = "propcache" +version = "0.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/16/43264e4a779dd8588c21a70f0709665ee8f611211bdd2c87d952cfa7c776/propcache-0.3.2.tar.gz", hash = "sha256:20d7d62e4e7ef05f221e0db2856b979540686342e7dd9973b815599c7057e168", size = 44139, upload-time = "2025-06-09T22:56:06.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/d1/8c747fafa558c603c4ca19d8e20b288aa0c7cda74e9402f50f31eb65267e/propcache-0.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ca592ed634a73ca002967458187109265e980422116c0a107cf93d81f95af945", size = 71286, upload-time = "2025-06-09T22:54:54.369Z" }, + { url = "https://files.pythonhosted.org/packages/61/99/d606cb7986b60d89c36de8a85d58764323b3a5ff07770a99d8e993b3fa73/propcache-0.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ecb0aad4020e275652ba3975740f241bd12a61f1a784df044cf7477a02bc252", size = 42425, upload-time = "2025-06-09T22:54:55.642Z" }, + { url = "https://files.pythonhosted.org/packages/8c/96/ef98f91bbb42b79e9bb82bdd348b255eb9d65f14dbbe3b1594644c4073f7/propcache-0.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7f08f1cc28bd2eade7a8a3d2954ccc673bb02062e3e7da09bc75d843386b342f", size = 41846, upload-time = "2025-06-09T22:54:57.246Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ad/3f0f9a705fb630d175146cd7b1d2bf5555c9beaed54e94132b21aac098a6/propcache-0.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1a342c834734edb4be5ecb1e9fb48cb64b1e2320fccbd8c54bf8da8f2a84c33", size = 208871, upload-time = "2025-06-09T22:54:58.975Z" }, + { url = "https://files.pythonhosted.org/packages/3a/38/2085cda93d2c8b6ec3e92af2c89489a36a5886b712a34ab25de9fbca7992/propcache-0.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a544caaae1ac73f1fecfae70ded3e93728831affebd017d53449e3ac052ac1e", size = 215720, upload-time = "2025-06-09T22:55:00.471Z" }, + { url = "https://files.pythonhosted.org/packages/61/c1/d72ea2dc83ac7f2c8e182786ab0fc2c7bd123a1ff9b7975bee671866fe5f/propcache-0.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:310d11aa44635298397db47a3ebce7db99a4cc4b9bbdfcf6c98a60c8d5261cf1", size = 215203, upload-time = "2025-06-09T22:55:01.834Z" }, + { url = "https://files.pythonhosted.org/packages/af/81/b324c44ae60c56ef12007105f1460d5c304b0626ab0cc6b07c8f2a9aa0b8/propcache-0.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c1396592321ac83157ac03a2023aa6cc4a3cc3cfdecb71090054c09e5a7cce3", size = 206365, upload-time = "2025-06-09T22:55:03.199Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/88549128bb89e66d2aff242488f62869014ae092db63ccea53c1cc75a81d/propcache-0.3.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cabf5b5902272565e78197edb682017d21cf3b550ba0460ee473753f28d23c1", size = 196016, upload-time = "2025-06-09T22:55:04.518Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3f/3bdd14e737d145114a5eb83cb172903afba7242f67c5877f9909a20d948d/propcache-0.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0a2f2235ac46a7aa25bdeb03a9e7060f6ecbd213b1f9101c43b3090ffb971ef6", size = 205596, upload-time = "2025-06-09T22:55:05.942Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ca/2f4aa819c357d3107c3763d7ef42c03980f9ed5c48c82e01e25945d437c1/propcache-0.3.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:92b69e12e34869a6970fd2f3da91669899994b47c98f5d430b781c26f1d9f387", size = 200977, upload-time = "2025-06-09T22:55:07.792Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4a/e65276c7477533c59085251ae88505caf6831c0e85ff8b2e31ebcbb949b1/propcache-0.3.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:54e02207c79968ebbdffc169591009f4474dde3b4679e16634d34c9363ff56b4", size = 197220, upload-time = "2025-06-09T22:55:09.173Z" }, + { url = "https://files.pythonhosted.org/packages/7c/54/fc7152e517cf5578278b242396ce4d4b36795423988ef39bb8cd5bf274c8/propcache-0.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4adfb44cb588001f68c5466579d3f1157ca07f7504fc91ec87862e2b8e556b88", size = 210642, upload-time = "2025-06-09T22:55:10.62Z" }, + { url = "https://files.pythonhosted.org/packages/b9/80/abeb4a896d2767bf5f1ea7b92eb7be6a5330645bd7fb844049c0e4045d9d/propcache-0.3.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fd3e6019dc1261cd0291ee8919dd91fbab7b169bb76aeef6c716833a3f65d206", size = 212789, upload-time = "2025-06-09T22:55:12.029Z" }, + { url = "https://files.pythonhosted.org/packages/b3/db/ea12a49aa7b2b6d68a5da8293dcf50068d48d088100ac016ad92a6a780e6/propcache-0.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4c181cad81158d71c41a2bce88edce078458e2dd5ffee7eddd6b05da85079f43", size = 205880, upload-time = "2025-06-09T22:55:13.45Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e5/9076a0bbbfb65d1198007059c65639dfd56266cf8e477a9707e4b1999ff4/propcache-0.3.2-cp313-cp313-win32.whl", hash = "sha256:8a08154613f2249519e549de2330cf8e2071c2887309a7b07fb56098f5170a02", size = 37220, upload-time = "2025-06-09T22:55:15.284Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f5/b369e026b09a26cd77aa88d8fffd69141d2ae00a2abaaf5380d2603f4b7f/propcache-0.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e41671f1594fc4ab0a6dec1351864713cb3a279910ae8b58f884a88a0a632c05", size = 40678, upload-time = "2025-06-09T22:55:16.445Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3a/6ece377b55544941a08d03581c7bc400a3c8cd3c2865900a68d5de79e21f/propcache-0.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9a3cf035bbaf035f109987d9d55dc90e4b0e36e04bbbb95af3055ef17194057b", size = 76560, upload-time = "2025-06-09T22:55:17.598Z" }, + { url = "https://files.pythonhosted.org/packages/0c/da/64a2bb16418740fa634b0e9c3d29edff1db07f56d3546ca2d86ddf0305e1/propcache-0.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:156c03d07dc1323d8dacaa221fbe028c5c70d16709cdd63502778e6c3ccca1b0", size = 44676, upload-time = "2025-06-09T22:55:18.922Z" }, + { url = "https://files.pythonhosted.org/packages/36/7b/f025e06ea51cb72c52fb87e9b395cced02786610b60a3ed51da8af017170/propcache-0.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74413c0ba02ba86f55cf60d18daab219f7e531620c15f1e23d95563f505efe7e", size = 44701, upload-time = "2025-06-09T22:55:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/a4/00/faa1b1b7c3b74fc277f8642f32a4c72ba1d7b2de36d7cdfb676db7f4303e/propcache-0.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f066b437bb3fa39c58ff97ab2ca351db465157d68ed0440abecb21715eb24b28", size = 276934, upload-time = "2025-06-09T22:55:21.5Z" }, + { url = "https://files.pythonhosted.org/packages/74/ab/935beb6f1756e0476a4d5938ff44bf0d13a055fed880caf93859b4f1baf4/propcache-0.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1304b085c83067914721e7e9d9917d41ad87696bf70f0bc7dee450e9c71ad0a", size = 278316, upload-time = "2025-06-09T22:55:22.918Z" }, + { url = "https://files.pythonhosted.org/packages/f8/9d/994a5c1ce4389610838d1caec74bdf0e98b306c70314d46dbe4fcf21a3e2/propcache-0.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab50cef01b372763a13333b4e54021bdcb291fc9a8e2ccb9c2df98be51bcde6c", size = 282619, upload-time = "2025-06-09T22:55:24.651Z" }, + { url = "https://files.pythonhosted.org/packages/2b/00/a10afce3d1ed0287cef2e09506d3be9822513f2c1e96457ee369adb9a6cd/propcache-0.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fad3b2a085ec259ad2c2842666b2a0a49dea8463579c606426128925af1ed725", size = 265896, upload-time = "2025-06-09T22:55:26.049Z" }, + { url = "https://files.pythonhosted.org/packages/2e/a8/2aa6716ffa566ca57c749edb909ad27884680887d68517e4be41b02299f3/propcache-0.3.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:261fa020c1c14deafd54c76b014956e2f86991af198c51139faf41c4d5e83892", size = 252111, upload-time = "2025-06-09T22:55:27.381Z" }, + { url = "https://files.pythonhosted.org/packages/36/4f/345ca9183b85ac29c8694b0941f7484bf419c7f0fea2d1e386b4f7893eed/propcache-0.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:46d7f8aa79c927e5f987ee3a80205c987717d3659f035c85cf0c3680526bdb44", size = 268334, upload-time = "2025-06-09T22:55:28.747Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ca/fcd54f78b59e3f97b3b9715501e3147f5340167733d27db423aa321e7148/propcache-0.3.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:6d8f3f0eebf73e3c0ff0e7853f68be638b4043c65a70517bb575eff54edd8dbe", size = 255026, upload-time = "2025-06-09T22:55:30.184Z" }, + { url = "https://files.pythonhosted.org/packages/8b/95/8e6a6bbbd78ac89c30c225210a5c687790e532ba4088afb8c0445b77ef37/propcache-0.3.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:03c89c1b14a5452cf15403e291c0ccd7751d5b9736ecb2c5bab977ad6c5bcd81", size = 250724, upload-time = "2025-06-09T22:55:31.646Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b0/0dd03616142baba28e8b2d14ce5df6631b4673850a3d4f9c0f9dd714a404/propcache-0.3.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:0cc17efde71e12bbaad086d679ce575268d70bc123a5a71ea7ad76f70ba30bba", size = 268868, upload-time = "2025-06-09T22:55:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/c5/98/2c12407a7e4fbacd94ddd32f3b1e3d5231e77c30ef7162b12a60e2dd5ce3/propcache-0.3.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:acdf05d00696bc0447e278bb53cb04ca72354e562cf88ea6f9107df8e7fd9770", size = 271322, upload-time = "2025-06-09T22:55:35.065Z" }, + { url = "https://files.pythonhosted.org/packages/35/91/9cb56efbb428b006bb85db28591e40b7736847b8331d43fe335acf95f6c8/propcache-0.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4445542398bd0b5d32df908031cb1b30d43ac848e20470a878b770ec2dcc6330", size = 265778, upload-time = "2025-06-09T22:55:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4c/b0fe775a2bdd01e176b14b574be679d84fc83958335790f7c9a686c1f468/propcache-0.3.2-cp313-cp313t-win32.whl", hash = "sha256:f86e5d7cd03afb3a1db8e9f9f6eff15794e79e791350ac48a8c924e6f439f394", size = 41175, upload-time = "2025-06-09T22:55:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ff/47f08595e3d9b5e149c150f88d9714574f1a7cbd89fe2817158a952674bf/propcache-0.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9704bedf6e7cbe3c65eca4379a9b53ee6a83749f047808cbb5044d40d7d72198", size = 44857, upload-time = "2025-06-09T22:55:39.687Z" }, + { url = "https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size = 12663, upload-time = "2025-06-09T22:56:04.484Z" }, +] + +[[package]] +name = "pydantic" +version = "2.11.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/5d/09a551ba512d7ca404d785072700d3f6727a02f6f3c24ecfd081c7cf0aa8/pydantic-2.11.9.tar.gz", hash = "sha256:6b8ffda597a14812a7975c90b82a8a2e777d9257aba3453f973acd3c032a18e2", size = 788495, upload-time = "2025-09-13T11:26:39.325Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/d3/108f2006987c58e76691d5ae5d200dd3e0f532cb4e5fa3560751c3a1feba/pydantic-2.11.9-py3-none-any.whl", hash = "sha256:c42dd626f5cfc1c6950ce6205ea58c93efa406da65f479dcb4029d5934857da2", size = 444855, upload-time = "2025-09-13T11:26:36.909Z" }, +] + +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, + { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, + { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, + { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, + { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, + { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.0" +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/da/1d/eb34f286b164c5e431a810a38697409cca1112cee04b287bb56ac486730b/pytest-9.0.0.tar.gz", hash = "sha256:8f44522eafe4137b0f35c9ce3072931a788a21ee40a2ed279e817d3cc16ed21e", size = 1562764, upload-time = "2025-11-08T17:25:33.34Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/99/cafef234114a3b6d9f3aaed0723b437c40c57bdb7b3e4c3a575bc4890052/pytest-9.0.0-py3-none-any.whl", hash = "sha256:e5ccdf10b0bac554970ee88fc1a4ad0ee5d221f8ef22321f9b7e4584e19d7f96", size = 373364, upload-time = "2025-11-08T17:25:31.811Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, +] + +[[package]] +name = "rich" +version = "14.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/75/af448d8e52bf1d8fa6a9d089ca6c07ff4453d86c65c145d0a300bb073b9b/rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8", size = 224441, upload-time = "2025-07-25T07:32:58.125Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/30/3c4d035596d3cf444529e0b2953ad0466f6049528a879d27534700580395/rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f", size = 243368, upload-time = "2025-07-25T07:32:56.73Z" }, +] + +[[package]] +name = "rich-toolkit" +version = "0.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/33/1a18839aaa8feef7983590c05c22c9c09d245ada6017d118325bbfcc7651/rich_toolkit-0.15.1.tar.gz", hash = "sha256:6f9630eb29f3843d19d48c3bd5706a086d36d62016687f9d0efa027ddc2dd08a", size = 115322, upload-time = "2025-09-04T09:28:11.789Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/49/42821d55ead7b5a87c8d121edf323cb393d8579f63e933002ade900b784f/rich_toolkit-0.15.1-py3-none-any.whl", hash = "sha256:36a0b1d9a135d26776e4b78f1d5c2655da6e0ef432380b5c6b523c8d8ab97478", size = 29412, upload-time = "2025-09-04T09:28:10.587Z" }, +] + +[[package]] +name = "rignore" +version = "0.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/73/46/05a94dc55ac03cf931d18e43b86ecee5ee054cb88b7853fffd741e35009c/rignore-0.6.4.tar.gz", hash = "sha256:e893fdd2d7fdcfa9407d0b7600ef2c2e2df97f55e1c45d4a8f54364829ddb0ab", size = 11633, upload-time = "2025-07-19T19:24:46.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/a3/edd7d0d5cc0720de132b6651cef95ee080ce5fca11c77d8a47db848e5f90/rignore-0.6.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:2b3b1e266ce45189240d14dfa1057f8013ea34b9bc8b3b44125ec8d25fdb3985", size = 885304, upload-time = "2025-07-19T19:23:54.268Z" }, + { url = "https://files.pythonhosted.org/packages/93/a1/d8d2fb97a6548307507d049b7e93885d4a0dfa1c907af5983fd9f9362a21/rignore-0.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45fe803628cc14714df10e8d6cdc23950a47eb9eb37dfea9a4779f4c672d2aa0", size = 818799, upload-time = "2025-07-19T19:23:47.544Z" }, + { url = "https://files.pythonhosted.org/packages/b1/cd/949981fcc180ad5ba7b31c52e78b74b2dea6b7bf744ad4c0c4b212f6da78/rignore-0.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e439f034277a947a4126e2da79dbb43e33d73d7c09d3d72a927e02f8a16f59aa", size = 892024, upload-time = "2025-07-19T19:22:36.18Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d3/9042d701a8062d9c88f87760bbc2695ee2c23b3f002d34486b72a85f8efe/rignore-0.6.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:84b5121650ae24621154c7bdba8b8970b0739d8146505c9f38e0cda9385d1004", size = 871430, upload-time = "2025-07-19T19:22:49.62Z" }, + { url = "https://files.pythonhosted.org/packages/eb/50/3370249b984212b7355f3d9241aa6d02e706067c6d194a2614dfbc0f5b27/rignore-0.6.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:52b0957b585ab48a445cf8ac1dbc33a272ab060835e583b4f95aa8c67c23fb2b", size = 1160559, upload-time = "2025-07-19T19:23:01.629Z" }, + { url = "https://files.pythonhosted.org/packages/6c/6f/2ad7f925838091d065524f30a8abda846d1813eee93328febf262b5cda21/rignore-0.6.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:50359e0d5287b5e2743bd2f2fbf05df619c8282fd3af12f6628ff97b9675551d", size = 939947, upload-time = "2025-07-19T19:23:14.608Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/626ec94d62475ae7ef8b00ef98cea61cbea52a389a666703c97c4673d406/rignore-0.6.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efe18096dcb1596757dfe0b412aab6d32564473ae7ee58dea0a8b4be5b1a2e3b", size = 949471, upload-time = "2025-07-19T19:23:37.521Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c3/699c4f03b3c46f4b5c02f17a0a339225da65aad547daa5b03001e7c6a382/rignore-0.6.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b79c212d9990a273ad91e8d9765e1766ef6ecedd3be65375d786a252762ba385", size = 974912, upload-time = "2025-07-19T19:23:27.13Z" }, + { url = "https://files.pythonhosted.org/packages/cd/35/04626c12f9f92a9fc789afc2be32838a5d9b23b6fa8b2ad4a8625638d15b/rignore-0.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c6ffa7f2a8894c65aa5dc4e8ac8bbdf39a326c0c6589efd27686cfbb48f0197d", size = 1067281, upload-time = "2025-07-19T19:24:01.016Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9c/8f17baf3b984afea151cb9094716f6f1fb8e8737db97fc6eb6d494bd0780/rignore-0.6.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a63f5720dffc8d8fb0a4d02fafb8370a4031ebf3f99a4e79f334a91e905b7349", size = 1134414, upload-time = "2025-07-19T19:24:13.534Z" }, + { url = "https://files.pythonhosted.org/packages/10/88/ef84ffa916a96437c12cefcc39d474122da9626d75e3a2ebe09ec5d32f1b/rignore-0.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ce33982da47ac5dc09d19b04fa8d7c9aa6292fc0bd1ecf33076989faa8886094", size = 1109330, upload-time = "2025-07-19T19:24:25.303Z" }, + { url = "https://files.pythonhosted.org/packages/27/43/2ada5a2ec03b82e903610a1c483f516f78e47700ee6db9823f739e08b3af/rignore-0.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d899621867aa266824fbd9150e298f19d25b93903ef0133c09f70c65a3416eca", size = 1120381, upload-time = "2025-07-19T19:24:37.798Z" }, + { url = "https://files.pythonhosted.org/packages/3b/99/e7bcc643085131cb14dbea772def72bf1f6fe9037171ebe177c4f228abc8/rignore-0.6.4-cp313-cp313-win32.whl", hash = "sha256:d0615a6bf4890ec5a90b5fb83666822088fbd4e8fcd740c386fcce51e2f6feea", size = 641761, upload-time = "2025-07-19T19:24:58.096Z" }, + { url = "https://files.pythonhosted.org/packages/d9/25/7798908044f27dea1a8abdc75c14523e33770137651e5f775a15143f4218/rignore-0.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:145177f0e32716dc2f220b07b3cde2385b994b7ea28d5c96fbec32639e9eac6f", size = 719876, upload-time = "2025-07-19T19:24:51.125Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e3/ae1e30b045bf004ad77bbd1679b9afff2be8edb166520921c6f29420516a/rignore-0.6.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e55bf8f9bbd186f58ab646b4a08718c77131d28a9004e477612b0cbbd5202db2", size = 891776, upload-time = "2025-07-19T19:22:37.78Z" }, + { url = "https://files.pythonhosted.org/packages/45/a9/1193e3bc23ca0e6eb4f17cf4b99971237f97cfa6f241d98366dff90a6d09/rignore-0.6.4-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2521f7bf3ee1f2ab22a100a3a4eed39a97b025804e5afe4323528e9ce8f084a5", size = 871442, upload-time = "2025-07-19T19:22:50.972Z" }, + { url = "https://files.pythonhosted.org/packages/20/83/4c52ae429a0b2e1ce667e35b480e9a6846f9468c443baeaed5d775af9485/rignore-0.6.4-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cc35773a8a9c119359ef974d0856988d4601d4daa6f532c05f66b4587cf35bc", size = 1159844, upload-time = "2025-07-19T19:23:02.751Z" }, + { url = "https://files.pythonhosted.org/packages/c1/2f/c740f5751f464c937bfe252dc15a024ae081352cfe80d94aa16d6a617482/rignore-0.6.4-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b665b1ea14457d7b49e834baabc635a3b8c10cfb5cca5c21161fabdbfc2b850e", size = 939456, upload-time = "2025-07-19T19:23:15.72Z" }, + { url = "https://files.pythonhosted.org/packages/fc/dd/68dbb08ac0edabf44dd144ff546a3fb0253c5af708e066847df39fc9188f/rignore-0.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c7fd339f344a8548724f289495b835bed7b81174a0bc1c28c6497854bd8855db", size = 1067070, upload-time = "2025-07-19T19:24:02.803Z" }, + { url = "https://files.pythonhosted.org/packages/3b/3a/7e7ea6f0d31d3f5beb0f2cf2c4c362672f5f7f125714458673fc579e2bed/rignore-0.6.4-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:91dc94b1cc5af8d6d25ce6edd29e7351830f19b0a03b75cb3adf1f76d00f3007", size = 1134598, upload-time = "2025-07-19T19:24:15.039Z" }, + { url = "https://files.pythonhosted.org/packages/7e/06/1b3307f6437d29bede5a95738aa89e6d910ba68d4054175c9f60d8e2c6b1/rignore-0.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:4d1918221a249e5342b60fd5fa513bf3d6bf272a8738e66023799f0c82ecd788", size = 1108862, upload-time = "2025-07-19T19:24:26.765Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d5/b37c82519f335f2c472a63fc6215c6f4c51063ecf3166e3acf508011afbd/rignore-0.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:240777332b859dc89dcba59ab6e3f1e062bc8e862ffa3e5f456e93f7fd5cb415", size = 1120002, upload-time = "2025-07-19T19:24:38.952Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/55/cccfca45157a2031dcbb5a462a67f7cf27f8b37d4b3b1cd7438f0f5c1df6/ruff-0.14.4.tar.gz", hash = "sha256:f459a49fe1085a749f15414ca76f61595f1a2cc8778ed7c279b6ca2e1fd19df3", size = 5587844, upload-time = "2025-11-06T22:07:45.033Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/b9/67240254166ae1eaa38dec32265e9153ac53645a6c6670ed36ad00722af8/ruff-0.14.4-py3-none-linux_armv6l.whl", hash = "sha256:e6604613ffbcf2297cd5dcba0e0ac9bd0c11dc026442dfbb614504e87c349518", size = 12606781, upload-time = "2025-11-06T22:07:01.841Z" }, + { url = "https://files.pythonhosted.org/packages/46/c8/09b3ab245d8652eafe5256ab59718641429f68681ee713ff06c5c549f156/ruff-0.14.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d99c0b52b6f0598acede45ee78288e5e9b4409d1ce7f661f0fa36d4cbeadf9a4", size = 12946765, upload-time = "2025-11-06T22:07:05.858Z" }, + { url = "https://files.pythonhosted.org/packages/14/bb/1564b000219144bf5eed2359edc94c3590dd49d510751dad26202c18a17d/ruff-0.14.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9358d490ec030f1b51d048a7fd6ead418ed0826daf6149e95e30aa67c168af33", size = 11928120, upload-time = "2025-11-06T22:07:08.023Z" }, + { url = "https://files.pythonhosted.org/packages/a3/92/d5f1770e9988cc0742fefaa351e840d9aef04ec24ae1be36f333f96d5704/ruff-0.14.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81b40d27924f1f02dfa827b9c0712a13c0e4b108421665322218fc38caf615c2", size = 12370877, upload-time = "2025-11-06T22:07:10.015Z" }, + { url = "https://files.pythonhosted.org/packages/e2/29/e9282efa55f1973d109faf839a63235575519c8ad278cc87a182a366810e/ruff-0.14.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f5e649052a294fe00818650712083cddc6cc02744afaf37202c65df9ea52efa5", size = 12408538, upload-time = "2025-11-06T22:07:13.085Z" }, + { url = "https://files.pythonhosted.org/packages/8e/01/930ed6ecfce130144b32d77d8d69f5c610e6d23e6857927150adf5d7379a/ruff-0.14.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa082a8f878deeba955531f975881828fd6afd90dfa757c2b0808aadb437136e", size = 13141942, upload-time = "2025-11-06T22:07:15.386Z" }, + { url = "https://files.pythonhosted.org/packages/6a/46/a9c89b42b231a9f487233f17a89cbef9d5acd538d9488687a02ad288fa6b/ruff-0.14.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1043c6811c2419e39011890f14d0a30470f19d47d197c4858b2787dfa698f6c8", size = 14544306, upload-time = "2025-11-06T22:07:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/78/96/9c6cf86491f2a6d52758b830b89b78c2ae61e8ca66b86bf5a20af73d20e6/ruff-0.14.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a9f3a936ac27fb7c2a93e4f4b943a662775879ac579a433291a6f69428722649", size = 14210427, upload-time = "2025-11-06T22:07:19.832Z" }, + { url = "https://files.pythonhosted.org/packages/71/f4/0666fe7769a54f63e66404e8ff698de1dcde733e12e2fd1c9c6efb689cb5/ruff-0.14.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:95643ffd209ce78bc113266b88fba3d39e0461f0cbc8b55fb92505030fb4a850", size = 13658488, upload-time = "2025-11-06T22:07:22.32Z" }, + { url = "https://files.pythonhosted.org/packages/ee/79/6ad4dda2cfd55e41ac9ed6d73ef9ab9475b1eef69f3a85957210c74ba12c/ruff-0.14.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:456daa2fa1021bc86ca857f43fe29d5d8b3f0e55e9f90c58c317c1dcc2afc7b5", size = 13354908, upload-time = "2025-11-06T22:07:24.347Z" }, + { url = "https://files.pythonhosted.org/packages/b5/60/f0b6990f740bb15c1588601d19d21bcc1bd5de4330a07222041678a8e04f/ruff-0.14.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:f911bba769e4a9f51af6e70037bb72b70b45a16db5ce73e1f72aefe6f6d62132", size = 13587803, upload-time = "2025-11-06T22:07:26.327Z" }, + { url = "https://files.pythonhosted.org/packages/c9/da/eaaada586f80068728338e0ef7f29ab3e4a08a692f92eb901a4f06bbff24/ruff-0.14.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:76158a7369b3979fa878612c623a7e5430c18b2fd1c73b214945c2d06337db67", size = 12279654, upload-time = "2025-11-06T22:07:28.46Z" }, + { url = "https://files.pythonhosted.org/packages/66/d4/b1d0e82cf9bf8aed10a6d45be47b3f402730aa2c438164424783ac88c0ed/ruff-0.14.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f3b8f3b442d2b14c246e7aeca2e75915159e06a3540e2f4bed9f50d062d24469", size = 12357520, upload-time = "2025-11-06T22:07:31.468Z" }, + { url = "https://files.pythonhosted.org/packages/04/f4/53e2b42cc82804617e5c7950b7079d79996c27e99c4652131c6a1100657f/ruff-0.14.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c62da9a06779deecf4d17ed04939ae8b31b517643b26370c3be1d26f3ef7dbde", size = 12719431, upload-time = "2025-11-06T22:07:33.831Z" }, + { url = "https://files.pythonhosted.org/packages/a2/94/80e3d74ed9a72d64e94a7b7706b1c1ebaa315ef2076fd33581f6a1cd2f95/ruff-0.14.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5a443a83a1506c684e98acb8cb55abaf3ef725078be40237463dae4463366349", size = 13464394, upload-time = "2025-11-06T22:07:35.905Z" }, + { url = "https://files.pythonhosted.org/packages/54/1a/a49f071f04c42345c793d22f6cf5e0920095e286119ee53a64a3a3004825/ruff-0.14.4-py3-none-win32.whl", hash = "sha256:643b69cb63cd996f1fc7229da726d07ac307eae442dd8974dbc7cf22c1e18fff", size = 12493429, upload-time = "2025-11-06T22:07:38.43Z" }, + { url = "https://files.pythonhosted.org/packages/bc/22/e58c43e641145a2b670328fb98bc384e20679b5774258b1e540207580266/ruff-0.14.4-py3-none-win_amd64.whl", hash = "sha256:26673da283b96fe35fa0c939bf8411abec47111644aa9f7cfbd3c573fb125d2c", size = 13635380, upload-time = "2025-11-06T22:07:40.496Z" }, + { url = "https://files.pythonhosted.org/packages/30/bd/4168a751ddbbf43e86544b4de8b5c3b7be8d7167a2a5cb977d274e04f0a1/ruff-0.14.4-py3-none-win_arm64.whl", hash = "sha256:dd09c292479596b0e6fec8cd95c65c3a6dc68e9ad17b8f2382130f87ff6a75bb", size = 12663065, upload-time = "2025-11-06T22:07:42.603Z" }, +] + +[[package]] +name = "sentry-sdk" +version = "2.38.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/22/60fd703b34d94d216b2387e048ac82de3e86b63bc28869fb076f8bb0204a/sentry_sdk-2.38.0.tar.gz", hash = "sha256:792d2af45e167e2f8a3347143f525b9b6bac6f058fb2014720b40b84ccbeb985", size = 348116, upload-time = "2025-09-15T15:00:37.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/84/bde4c4bbb269b71bc09316af8eb00da91f67814d40337cc12ef9c8742541/sentry_sdk-2.38.0-py2.py3-none-any.whl", hash = "sha256:2324aea8573a3fa1576df7fb4d65c4eb8d9929c8fa5939647397a07179eef8d0", size = 370346, upload-time = "2025-09-15T15:00:35.821Z" }, +] + +[[package]] +name = "setuptools" +version = "80.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "starlette" +version = "0.48.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/a5/d6f429d43394057b67a6b5bbe6eae2f77a6bf7459d961fdb224bf206eee6/starlette-0.48.0.tar.gz", hash = "sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46", size = 2652949, upload-time = "2025-09-13T08:41:05.699Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/72/2db2f49247d0a18b4f1bb9a5a39a0162869acf235f3a96418363947b3d46/starlette-0.48.0-py3-none-any.whl", hash = "sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659", size = 73736, upload-time = "2025-09-13T08:41:03.869Z" }, +] + +[[package]] +name = "typer" +version = "0.17.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/e8/2a73ccf9874ec4c7638f172efc8972ceab13a0e3480b389d6ed822f7a822/typer-0.17.4.tar.gz", hash = "sha256:b77dc07d849312fd2bb5e7f20a7af8985c7ec360c45b051ed5412f64d8dc1580", size = 103734, upload-time = "2025-09-05T18:14:40.746Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/72/6b3e70d32e89a5cbb6a4513726c1ae8762165b027af569289e19ec08edd8/typer-0.17.4-py3-none-any.whl", hash = "sha256:015534a6edaa450e7007eba705d5c18c3349dcea50a6ad79a5ed530967575824", size = 46643, upload-time = "2025-09-05T18:14:39.166Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.35.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/42/e0e305207bb88c6b8d3061399c6a961ffe5fbb7e2aa63c9234df7259e9cd/uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01", size = 78473, upload-time = "2025-06-28T16:15:46.058Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406, upload-time = "2025-06-28T16:15:44.816Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/c0/854216d09d33c543f12a44b393c402e89a920b1a0a7dc634c42de91b9cf6/uvloop-0.21.0.tar.gz", hash = "sha256:3bf12b0fda68447806a7ad847bfa591613177275d35b6724b1ee573faa3704e3", size = 2492741, upload-time = "2024-10-14T23:38:35.489Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/8d/2cbef610ca21539f0f36e2b34da49302029e7c9f09acef0b1c3b5839412b/uvloop-0.21.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bfd55dfcc2a512316e65f16e503e9e450cab148ef11df4e4e679b5e8253a5281", size = 1468123, upload-time = "2024-10-14T23:38:00.688Z" }, + { url = "https://files.pythonhosted.org/packages/93/0d/b0038d5a469f94ed8f2b2fce2434a18396d8fbfb5da85a0a9781ebbdec14/uvloop-0.21.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787ae31ad8a2856fc4e7c095341cccc7209bd657d0e71ad0dc2ea83c4a6fa8af", size = 819325, upload-time = "2024-10-14T23:38:02.309Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/0a687f39e78c4c1e02e3272c6b2ccdb4e0085fda3b8352fecd0410ccf915/uvloop-0.21.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ee4d4ef48036ff6e5cfffb09dd192c7a5027153948d85b8da7ff705065bacc6", size = 4582806, upload-time = "2024-10-14T23:38:04.711Z" }, + { url = "https://files.pythonhosted.org/packages/d2/19/f5b78616566ea68edd42aacaf645adbf71fbd83fc52281fba555dc27e3f1/uvloop-0.21.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3df876acd7ec037a3d005b3ab85a7e4110422e4d9c1571d4fc89b0fc41b6816", size = 4701068, upload-time = "2024-10-14T23:38:06.385Z" }, + { url = "https://files.pythonhosted.org/packages/47/57/66f061ee118f413cd22a656de622925097170b9380b30091b78ea0c6ea75/uvloop-0.21.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd53ecc9a0f3d87ab847503c2e1552b690362e005ab54e8a48ba97da3924c0dc", size = 4454428, upload-time = "2024-10-14T23:38:08.416Z" }, + { url = "https://files.pythonhosted.org/packages/63/9a/0962b05b308494e3202d3f794a6e85abe471fe3cafdbcf95c2e8c713aabd/uvloop-0.21.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5c39f217ab3c663dc699c04cbd50c13813e31d917642d459fdcec07555cc553", size = 4660018, upload-time = "2024-10-14T23:38:10.888Z" }, +] + +[[package]] +name = "virtualenv" +version = "20.34.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/14/37fcdba2808a6c615681cd216fecae00413c9dab44fb2e57805ecf3eaee3/virtualenv-20.34.0.tar.gz", hash = "sha256:44815b2c9dee7ed86e387b842a84f20b93f7f417f95886ca1996a72a4138eb1a", size = 6003808, upload-time = "2025-08-13T14:24:07.464Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/06/04c8e804f813cf972e3262f3f8584c232de64f0cde9f703b46cf53a45090/virtualenv-20.34.0-py3-none-any.whl", hash = "sha256:341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026", size = 5983279, upload-time = "2025-08-13T14:24:05.111Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/9a/d451fcc97d029f5812e898fd30a53fd8c15c7bbd058fd75cfc6beb9bd761/watchfiles-1.1.0.tar.gz", hash = "sha256:693ed7ec72cbfcee399e92c895362b6e66d63dac6b91e2c11ae03d10d503e575", size = 94406, upload-time = "2025-06-15T19:06:59.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/42/fae874df96595556a9089ade83be34a2e04f0f11eb53a8dbf8a8a5e562b4/watchfiles-1.1.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5007f860c7f1f8df471e4e04aaa8c43673429047d63205d1630880f7637bca30", size = 402004, upload-time = "2025-06-15T19:05:38.499Z" }, + { url = "https://files.pythonhosted.org/packages/fa/55/a77e533e59c3003d9803c09c44c3651224067cbe7fb5d574ddbaa31e11ca/watchfiles-1.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:20ecc8abbd957046f1fe9562757903f5eaf57c3bce70929fda6c7711bb58074a", size = 393671, upload-time = "2025-06-15T19:05:39.52Z" }, + { url = "https://files.pythonhosted.org/packages/05/68/b0afb3f79c8e832e6571022611adbdc36e35a44e14f129ba09709aa4bb7a/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2f0498b7d2a3c072766dba3274fe22a183dbea1f99d188f1c6c72209a1063dc", size = 449772, upload-time = "2025-06-15T19:05:40.897Z" }, + { url = "https://files.pythonhosted.org/packages/ff/05/46dd1f6879bc40e1e74c6c39a1b9ab9e790bf1f5a2fe6c08b463d9a807f4/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:239736577e848678e13b201bba14e89718f5c2133dfd6b1f7846fa1b58a8532b", size = 456789, upload-time = "2025-06-15T19:05:42.045Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ca/0eeb2c06227ca7f12e50a47a3679df0cd1ba487ea19cf844a905920f8e95/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eff4b8d89f444f7e49136dc695599a591ff769300734446c0a86cba2eb2f9895", size = 482551, upload-time = "2025-06-15T19:05:43.781Z" }, + { url = "https://files.pythonhosted.org/packages/31/47/2cecbd8694095647406645f822781008cc524320466ea393f55fe70eed3b/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12b0a02a91762c08f7264e2e79542f76870c3040bbc847fb67410ab81474932a", size = 597420, upload-time = "2025-06-15T19:05:45.244Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7e/82abc4240e0806846548559d70f0b1a6dfdca75c1b4f9fa62b504ae9b083/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29e7bc2eee15cbb339c68445959108803dc14ee0c7b4eea556400131a8de462b", size = 477950, upload-time = "2025-06-15T19:05:46.332Z" }, + { url = "https://files.pythonhosted.org/packages/25/0d/4d564798a49bf5482a4fa9416dea6b6c0733a3b5700cb8a5a503c4b15853/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d9481174d3ed982e269c090f780122fb59cee6c3796f74efe74e70f7780ed94c", size = 451706, upload-time = "2025-06-15T19:05:47.459Z" }, + { url = "https://files.pythonhosted.org/packages/81/b5/5516cf46b033192d544102ea07c65b6f770f10ed1d0a6d388f5d3874f6e4/watchfiles-1.1.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:80f811146831c8c86ab17b640801c25dc0a88c630e855e2bef3568f30434d52b", size = 625814, upload-time = "2025-06-15T19:05:48.654Z" }, + { url = "https://files.pythonhosted.org/packages/0c/dd/7c1331f902f30669ac3e754680b6edb9a0dd06dea5438e61128111fadd2c/watchfiles-1.1.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:60022527e71d1d1fda67a33150ee42869042bce3d0fcc9cc49be009a9cded3fb", size = 622820, upload-time = "2025-06-15T19:05:50.088Z" }, + { url = "https://files.pythonhosted.org/packages/1b/14/36d7a8e27cd128d7b1009e7715a7c02f6c131be9d4ce1e5c3b73d0e342d8/watchfiles-1.1.0-cp313-cp313-win32.whl", hash = "sha256:32d6d4e583593cb8576e129879ea0991660b935177c0f93c6681359b3654bfa9", size = 279194, upload-time = "2025-06-15T19:05:51.186Z" }, + { url = "https://files.pythonhosted.org/packages/25/41/2dd88054b849aa546dbeef5696019c58f8e0774f4d1c42123273304cdb2e/watchfiles-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:f21af781a4a6fbad54f03c598ab620e3a77032c5878f3d780448421a6e1818c7", size = 292349, upload-time = "2025-06-15T19:05:52.201Z" }, + { url = "https://files.pythonhosted.org/packages/c8/cf/421d659de88285eb13941cf11a81f875c176f76a6d99342599be88e08d03/watchfiles-1.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:5366164391873ed76bfdf618818c82084c9db7fac82b64a20c44d335eec9ced5", size = 283836, upload-time = "2025-06-15T19:05:53.265Z" }, + { url = "https://files.pythonhosted.org/packages/45/10/6faf6858d527e3599cc50ec9fcae73590fbddc1420bd4fdccfebffeedbc6/watchfiles-1.1.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:17ab167cca6339c2b830b744eaf10803d2a5b6683be4d79d8475d88b4a8a4be1", size = 400343, upload-time = "2025-06-15T19:05:54.252Z" }, + { url = "https://files.pythonhosted.org/packages/03/20/5cb7d3966f5e8c718006d0e97dfe379a82f16fecd3caa7810f634412047a/watchfiles-1.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:328dbc9bff7205c215a7807da7c18dce37da7da718e798356212d22696404339", size = 392916, upload-time = "2025-06-15T19:05:55.264Z" }, + { url = "https://files.pythonhosted.org/packages/8c/07/d8f1176328fa9e9581b6f120b017e286d2a2d22ae3f554efd9515c8e1b49/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7208ab6e009c627b7557ce55c465c98967e8caa8b11833531fdf95799372633", size = 449582, upload-time = "2025-06-15T19:05:56.317Z" }, + { url = "https://files.pythonhosted.org/packages/66/e8/80a14a453cf6038e81d072a86c05276692a1826471fef91df7537dba8b46/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a8f6f72974a19efead54195bc9bed4d850fc047bb7aa971268fd9a8387c89011", size = 456752, upload-time = "2025-06-15T19:05:57.359Z" }, + { url = "https://files.pythonhosted.org/packages/5a/25/0853b3fe0e3c2f5af9ea60eb2e781eade939760239a72c2d38fc4cc335f6/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d181ef50923c29cf0450c3cd47e2f0557b62218c50b2ab8ce2ecaa02bd97e670", size = 481436, upload-time = "2025-06-15T19:05:58.447Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9e/4af0056c258b861fbb29dcb36258de1e2b857be4a9509e6298abcf31e5c9/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adb4167043d3a78280d5d05ce0ba22055c266cf8655ce942f2fb881262ff3cdf", size = 596016, upload-time = "2025-06-15T19:05:59.59Z" }, + { url = "https://files.pythonhosted.org/packages/c5/fa/95d604b58aa375e781daf350897aaaa089cff59d84147e9ccff2447c8294/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5701dc474b041e2934a26d31d39f90fac8a3dee2322b39f7729867f932b1d4", size = 476727, upload-time = "2025-06-15T19:06:01.086Z" }, + { url = "https://files.pythonhosted.org/packages/65/95/fe479b2664f19be4cf5ceeb21be05afd491d95f142e72d26a42f41b7c4f8/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b067915e3c3936966a8607f6fe5487df0c9c4afb85226613b520890049deea20", size = 451864, upload-time = "2025-06-15T19:06:02.144Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8a/3c4af14b93a15ce55901cd7a92e1a4701910f1768c78fb30f61d2b79785b/watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:9c733cda03b6d636b4219625a4acb5c6ffb10803338e437fb614fef9516825ef", size = 625626, upload-time = "2025-06-15T19:06:03.578Z" }, + { url = "https://files.pythonhosted.org/packages/da/f5/cf6aa047d4d9e128f4b7cde615236a915673775ef171ff85971d698f3c2c/watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:cc08ef8b90d78bfac66f0def80240b0197008e4852c9f285907377b2947ffdcb", size = 622744, upload-time = "2025-06-15T19:06:05.066Z" }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] + +[[package]] +name = "yarl" +version = "1.20.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/fb/efaa23fa4e45537b827620f04cf8f3cd658b76642205162e072703a5b963/yarl-1.20.1.tar.gz", hash = "sha256:d017a4997ee50c91fd5466cef416231bb82177b93b029906cefc542ce14c35ac", size = 186428, upload-time = "2025-06-10T00:46:09.923Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/e1/2411b6d7f769a07687acee88a062af5833cf1966b7266f3d8dfb3d3dc7d3/yarl-1.20.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0b5ff0fbb7c9f1b1b5ab53330acbfc5247893069e7716840c8e7d5bb7355038a", size = 131811, upload-time = "2025-06-10T00:44:18.933Z" }, + { url = "https://files.pythonhosted.org/packages/b2/27/584394e1cb76fb771371770eccad35de400e7b434ce3142c2dd27392c968/yarl-1.20.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:14f326acd845c2b2e2eb38fb1346c94f7f3b01a4f5c788f8144f9b630bfff9a3", size = 90078, upload-time = "2025-06-10T00:44:20.635Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9a/3246ae92d4049099f52d9b0fe3486e3b500e29b7ea872d0f152966fc209d/yarl-1.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f60e4ad5db23f0b96e49c018596707c3ae89f5d0bd97f0ad3684bcbad899f1e7", size = 88748, upload-time = "2025-06-10T00:44:22.34Z" }, + { url = "https://files.pythonhosted.org/packages/a3/25/35afe384e31115a1a801fbcf84012d7a066d89035befae7c5d4284df1e03/yarl-1.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:49bdd1b8e00ce57e68ba51916e4bb04461746e794e7c4d4bbc42ba2f18297691", size = 349595, upload-time = "2025-06-10T00:44:24.314Z" }, + { url = "https://files.pythonhosted.org/packages/28/2d/8aca6cb2cabc8f12efcb82749b9cefecbccfc7b0384e56cd71058ccee433/yarl-1.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:66252d780b45189975abfed839616e8fd2dbacbdc262105ad7742c6ae58f3e31", size = 342616, upload-time = "2025-06-10T00:44:26.167Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/1312633d16b31acf0098d30440ca855e3492d66623dafb8e25b03d00c3da/yarl-1.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59174e7332f5d153d8f7452a102b103e2e74035ad085f404df2e40e663a22b28", size = 361324, upload-time = "2025-06-10T00:44:27.915Z" }, + { url = "https://files.pythonhosted.org/packages/bc/a0/688cc99463f12f7669eec7c8acc71ef56a1521b99eab7cd3abb75af887b0/yarl-1.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e3968ec7d92a0c0f9ac34d5ecfd03869ec0cab0697c91a45db3fbbd95fe1b653", size = 359676, upload-time = "2025-06-10T00:44:30.041Z" }, + { url = "https://files.pythonhosted.org/packages/af/44/46407d7f7a56e9a85a4c207724c9f2c545c060380718eea9088f222ba697/yarl-1.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1a4fbb50e14396ba3d375f68bfe02215d8e7bc3ec49da8341fe3157f59d2ff5", size = 352614, upload-time = "2025-06-10T00:44:32.171Z" }, + { url = "https://files.pythonhosted.org/packages/b1/91/31163295e82b8d5485d31d9cf7754d973d41915cadce070491778d9c9825/yarl-1.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11a62c839c3a8eac2410e951301309426f368388ff2f33799052787035793b02", size = 336766, upload-time = "2025-06-10T00:44:34.494Z" }, + { url = "https://files.pythonhosted.org/packages/b4/8e/c41a5bc482121f51c083c4c2bcd16b9e01e1cf8729e380273a952513a21f/yarl-1.20.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:041eaa14f73ff5a8986b4388ac6bb43a77f2ea09bf1913df7a35d4646db69e53", size = 364615, upload-time = "2025-06-10T00:44:36.856Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5b/61a3b054238d33d70ea06ebba7e58597891b71c699e247df35cc984ab393/yarl-1.20.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:377fae2fef158e8fd9d60b4c8751387b8d1fb121d3d0b8e9b0be07d1b41e83dc", size = 360982, upload-time = "2025-06-10T00:44:39.141Z" }, + { url = "https://files.pythonhosted.org/packages/df/a3/6a72fb83f8d478cb201d14927bc8040af901811a88e0ff2da7842dd0ed19/yarl-1.20.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1c92f4390e407513f619d49319023664643d3339bd5e5a56a3bebe01bc67ec04", size = 369792, upload-time = "2025-06-10T00:44:40.934Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/4cc3c36dfc7c077f8dedb561eb21f69e1e9f2456b91b593882b0b18c19dc/yarl-1.20.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d25ddcf954df1754ab0f86bb696af765c5bfaba39b74095f27eececa049ef9a4", size = 382049, upload-time = "2025-06-10T00:44:42.854Z" }, + { url = "https://files.pythonhosted.org/packages/19/3a/e54e2c4752160115183a66dc9ee75a153f81f3ab2ba4bf79c3c53b33de34/yarl-1.20.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:909313577e9619dcff8c31a0ea2aa0a2a828341d92673015456b3ae492e7317b", size = 384774, upload-time = "2025-06-10T00:44:45.275Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/200ae86dabfca89060ec6447649f219b4cbd94531e425e50d57e5f5ac330/yarl-1.20.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:793fd0580cb9664548c6b83c63b43c477212c0260891ddf86809e1c06c8b08f1", size = 374252, upload-time = "2025-06-10T00:44:47.31Z" }, + { url = "https://files.pythonhosted.org/packages/83/75/11ee332f2f516b3d094e89448da73d557687f7d137d5a0f48c40ff211487/yarl-1.20.1-cp313-cp313-win32.whl", hash = "sha256:468f6e40285de5a5b3c44981ca3a319a4b208ccc07d526b20b12aeedcfa654b7", size = 81198, upload-time = "2025-06-10T00:44:49.164Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ba/39b1ecbf51620b40ab402b0fc817f0ff750f6d92712b44689c2c215be89d/yarl-1.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:495b4ef2fea40596bfc0affe3837411d6aa3371abcf31aac0ccc4bdd64d4ef5c", size = 86346, upload-time = "2025-06-10T00:44:51.182Z" }, + { url = "https://files.pythonhosted.org/packages/43/c7/669c52519dca4c95153c8ad96dd123c79f354a376346b198f438e56ffeb4/yarl-1.20.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f60233b98423aab21d249a30eb27c389c14929f47be8430efa7dbd91493a729d", size = 138826, upload-time = "2025-06-10T00:44:52.883Z" }, + { url = "https://files.pythonhosted.org/packages/6a/42/fc0053719b44f6ad04a75d7f05e0e9674d45ef62f2d9ad2c1163e5c05827/yarl-1.20.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6f3eff4cc3f03d650d8755c6eefc844edde99d641d0dcf4da3ab27141a5f8ddf", size = 93217, upload-time = "2025-06-10T00:44:54.658Z" }, + { url = "https://files.pythonhosted.org/packages/4f/7f/fa59c4c27e2a076bba0d959386e26eba77eb52ea4a0aac48e3515c186b4c/yarl-1.20.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:69ff8439d8ba832d6bed88af2c2b3445977eba9a4588b787b32945871c2444e3", size = 92700, upload-time = "2025-06-10T00:44:56.784Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d4/062b2f48e7c93481e88eff97a6312dca15ea200e959f23e96d8ab898c5b8/yarl-1.20.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cf34efa60eb81dd2645a2e13e00bb98b76c35ab5061a3989c7a70f78c85006d", size = 347644, upload-time = "2025-06-10T00:44:59.071Z" }, + { url = "https://files.pythonhosted.org/packages/89/47/78b7f40d13c8f62b499cc702fdf69e090455518ae544c00a3bf4afc9fc77/yarl-1.20.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8e0fe9364ad0fddab2688ce72cb7a8e61ea42eff3c7caeeb83874a5d479c896c", size = 323452, upload-time = "2025-06-10T00:45:01.605Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2b/490d3b2dc66f52987d4ee0d3090a147ea67732ce6b4d61e362c1846d0d32/yarl-1.20.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f64fbf81878ba914562c672024089e3401974a39767747691c65080a67b18c1", size = 346378, upload-time = "2025-06-10T00:45:03.946Z" }, + { url = "https://files.pythonhosted.org/packages/66/ad/775da9c8a94ce925d1537f939a4f17d782efef1f973039d821cbe4bcc211/yarl-1.20.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6342d643bf9a1de97e512e45e4b9560a043347e779a173250824f8b254bd5ce", size = 353261, upload-time = "2025-06-10T00:45:05.992Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/0ed0922b47a4f5c6eb9065d5ff1e459747226ddce5c6a4c111e728c9f701/yarl-1.20.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56dac5f452ed25eef0f6e3c6a066c6ab68971d96a9fb441791cad0efba6140d3", size = 335987, upload-time = "2025-06-10T00:45:08.227Z" }, + { url = "https://files.pythonhosted.org/packages/3e/49/bc728a7fe7d0e9336e2b78f0958a2d6b288ba89f25a1762407a222bf53c3/yarl-1.20.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7d7f497126d65e2cad8dc5f97d34c27b19199b6414a40cb36b52f41b79014be", size = 329361, upload-time = "2025-06-10T00:45:10.11Z" }, + { url = "https://files.pythonhosted.org/packages/93/8f/b811b9d1f617c83c907e7082a76e2b92b655400e61730cd61a1f67178393/yarl-1.20.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:67e708dfb8e78d8a19169818eeb5c7a80717562de9051bf2413aca8e3696bf16", size = 346460, upload-time = "2025-06-10T00:45:12.055Z" }, + { url = "https://files.pythonhosted.org/packages/70/fd/af94f04f275f95da2c3b8b5e1d49e3e79f1ed8b6ceb0f1664cbd902773ff/yarl-1.20.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:595c07bc79af2494365cc96ddeb772f76272364ef7c80fb892ef9d0649586513", size = 334486, upload-time = "2025-06-10T00:45:13.995Z" }, + { url = "https://files.pythonhosted.org/packages/84/65/04c62e82704e7dd0a9b3f61dbaa8447f8507655fd16c51da0637b39b2910/yarl-1.20.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7bdd2f80f4a7df852ab9ab49484a4dee8030023aa536df41f2d922fd57bf023f", size = 342219, upload-time = "2025-06-10T00:45:16.479Z" }, + { url = "https://files.pythonhosted.org/packages/91/95/459ca62eb958381b342d94ab9a4b6aec1ddec1f7057c487e926f03c06d30/yarl-1.20.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c03bfebc4ae8d862f853a9757199677ab74ec25424d0ebd68a0027e9c639a390", size = 350693, upload-time = "2025-06-10T00:45:18.399Z" }, + { url = "https://files.pythonhosted.org/packages/a6/00/d393e82dd955ad20617abc546a8f1aee40534d599ff555ea053d0ec9bf03/yarl-1.20.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:344d1103e9c1523f32a5ed704d576172d2cabed3122ea90b1d4e11fe17c66458", size = 355803, upload-time = "2025-06-10T00:45:20.677Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ed/c5fb04869b99b717985e244fd93029c7a8e8febdfcffa06093e32d7d44e7/yarl-1.20.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:88cab98aa4e13e1ade8c141daeedd300a4603b7132819c484841bb7af3edce9e", size = 341709, upload-time = "2025-06-10T00:45:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/24/fd/725b8e73ac2a50e78a4534ac43c6addf5c1c2d65380dd48a9169cc6739a9/yarl-1.20.1-cp313-cp313t-win32.whl", hash = "sha256:b121ff6a7cbd4abc28985b6028235491941b9fe8fe226e6fdc539c977ea1739d", size = 86591, upload-time = "2025-06-10T00:45:25.793Z" }, + { url = "https://files.pythonhosted.org/packages/94/c3/b2e9f38bc3e11191981d57ea08cab2166e74ea770024a646617c9cddd9f6/yarl-1.20.1-cp313-cp313t-win_amd64.whl", hash = "sha256:541d050a355bbbc27e55d906bc91cb6fe42f96c01413dd0f4ed5a5240513874f", size = 93003, upload-time = "2025-06-10T00:45:27.752Z" }, + { url = "https://files.pythonhosted.org/packages/b4/2d/2345fce04cfd4bee161bf1e7d9cdc702e3e16109021035dbb24db654a622/yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77", size = 46542, upload-time = "2025-06-10T00:46:07.521Z" }, +] From 68a4e3e14da2bee799435ddbca2c9c71944ceeb2 Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Tue, 11 Nov 2025 22:50:47 +0100 Subject: [PATCH 02/44] feat: Write unit, integration and e2e tests --- src/tests/__init__.py | 0 src/tests/test_e2e.py | 89 +++++++ src/tests/test_integration/__init__.py | 0 src/tests/test_integration/test_bot.py | 75 ++++++ src/tests/test_integration/test_server.py | 167 ++++++++++++ src/tests/test_unit/__init__.py | 0 src/tests/test_unit/test_bot.py | 294 ++++++++++++++++++++++ src/tests/test_unit/test_server.py | 68 +++++ src/tests/test_unit/test_utils.py | 262 +++++++++++++++++++ 9 files changed, 955 insertions(+) create mode 100644 src/tests/__init__.py create mode 100644 src/tests/test_e2e.py create mode 100644 src/tests/test_integration/__init__.py create mode 100644 src/tests/test_integration/test_bot.py create mode 100644 src/tests/test_integration/test_server.py create mode 100644 src/tests/test_unit/__init__.py create mode 100644 src/tests/test_unit/test_bot.py create mode 100644 src/tests/test_unit/test_server.py create mode 100644 src/tests/test_unit/test_utils.py diff --git a/src/tests/__init__.py b/src/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/test_e2e.py b/src/tests/test_e2e.py new file mode 100644 index 0000000..7a916bc --- /dev/null +++ b/src/tests/test_e2e.py @@ -0,0 +1,89 @@ +# ruff: noqa: F811 +import asyncio +import json +from unittest.mock import AsyncMock, mock_open, patch + +import aiohttp +import pytest +from hikari import RESTApp +from hikari.impl import RESTClientImpl +from uvicorn import Config, Server + +from src.server import app +from src.tests.test_integration.test_bot import RestClientContextManagerMock +from src.tests.test_unit.test_bot import post_mock # noqa: F401 +from src.tests.test_unit.test_utils import MockShelf, forum_channel_mock, rest_client_mock # noqa: F401 +from src.utils.utils import generate_signature + + +@patch.object(RESTClientImpl, "create_message", new_callable=AsyncMock) +@patch("builtins.open", new_callable=mock_open, read_data="") +@patch.object(RESTClientImpl, "fetch_active_threads", new_callable=AsyncMock) +@patch("shelve.open") +@patch("os.getenv") +@patch.object(RESTClientImpl, "fetch_channel", new_callable=AsyncMock) +@patch.object(RESTApp, "acquire") +@patch.object(RESTApp, "start", new_callable=AsyncMock) +async def test_e2e( + _mock_restapp_start, + mock_restapp_acquire, + mock_fetch_channel, + mock_getenv, + mock_shelve_open, + mock_fetch_active_threads, + _mock_open, + mock_create_message, + rest_client_mock, + forum_channel_mock, + post_mock, + capfd, +): + mock_restapp_acquire.return_value = RestClientContextManagerMock(rest_client_mock) + mock_fetch_channel.side_effect = [forum_channel_mock, post_mock] + mock_getenv.side_effect = ["some_token", 1, 2, "some_secret", "fake_project_id", "meow.yaml"] + post_id_shelf = MockShelf({}) + mock_shelve_open.side_effect = [MockShelf({"item123": "audacity4"}), post_id_shelf] + mock_fetch_active_threads.return_value = [post_mock] + config = Config(app=app, host="127.0.0.1", port=8000, log_level="critical") + server = Server(config=config) + + server_task = asyncio.create_task(server.serve()) + for _ in range(100): + try: + _, writer = await asyncio.open_connection("127.0.0.1", 8000) + writer.close() + await writer.wait_closed() + break + except ConnectionRefusedError: + await asyncio.sleep(0.01) + else: + pytest.fail("Server did not start in time") + + payload = { + "action": "edited", + "sender": {"node_id": "github_user"}, + "projects_v2_item": {"node_id": "item123", "project_node_id": "fake_project_id"}, + "changes": {"body": {"to": "Updated description"}}, + } + signature = generate_signature("some_secret", json.dumps(payload).encode()) + async with aiohttp.ClientSession() as client: + resp = await client.post( + "http://127.0.0.1:8000/webhook_endpoint", json=payload, headers={"X-Hub-Signature-256": signature} + ) + + assert resp.status == 200 + + for _ in range(500): # up to ~5 seconds total + out, _ = capfd.readouterr() + if "body updated" in out: + break + await asyncio.sleep(0.01) + else: + pytest.fail("Expected log 'body updated' not found in output") + assert post_id_shelf.get("audacity4") == 621 + mock_create_message.assert_called_with( + 621, "Opis taska zaktualizowany przez <@None>. Nowy opis: \nUpdated description", user_mentions=[] + ) + + server.should_exit = True + await server_task diff --git a/src/tests/test_integration/__init__.py b/src/tests/test_integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/test_integration/test_bot.py b/src/tests/test_integration/test_bot.py new file mode 100644 index 0000000..2ffd896 --- /dev/null +++ b/src/tests/test_integration/test_bot.py @@ -0,0 +1,75 @@ +# ruff: noqa: F811 +import asyncio +from unittest.mock import AsyncMock, mock_open, patch + +import pytest +from hikari import RESTApp +from hikari.impl import RESTClientImpl + +from src.bot import run +from src.tests.test_unit.test_utils import MockShelf, forum_channel_mock, rest_client_mock # noqa: F401 +from src.utils.data_types import ProjectItemEvent +from src.utils.error import ForumChannelNotFound + + +class RestClientContextManagerMock: + rest_client_mock: RESTClientImpl + + def __init__(self, rest_client_mock): + self.rest_client_mock = rest_client_mock + + async def __aenter__(self): + return self.rest_client_mock + + async def __aexit__(self, exc_type, exc_val, exc_tb): + pass + + +@patch.object(RESTClientImpl, "fetch_channel", new_callable=AsyncMock) +@patch.object(RESTApp, "acquire") +@patch.object(RESTApp, "start", new_callable=AsyncMock) +@patch("os.getenv") +async def test_forum_channel_not_found( + mock_os_getenv, _mock_restapp_start, mock_restapp_acquire, mock_fetch_channel, rest_client_mock +): + mock_os_getenv.side_effect = ["some_token", 1, 2] + mock_restapp_acquire.return_value = RestClientContextManagerMock(rest_client_mock) + mock_fetch_channel.return_value = None + with pytest.raises(ForumChannelNotFound): + update_queue = asyncio.Queue() + await run(update_queue) + + +@patch("builtins.open", new_callable=mock_open, read_data="") +@patch("shelve.open") +@patch.object(RESTClientImpl, "create_forum_post", new_callable=AsyncMock) +@patch.object(RESTClientImpl, "fetch_public_archived_threads", new_callable=AsyncMock) +@patch.object(RESTClientImpl, "fetch_active_threads", new_callable=AsyncMock) +@patch.object(RESTClientImpl, "fetch_channel", new_callable=AsyncMock) +@patch.object(RESTApp, "acquire") +@patch.object(RESTApp, "start", new_callable=AsyncMock) +@patch("os.getenv") +async def test_basic_event_only_creation( + mock_os_getenv, + _mock_restapp_start, + mock_restapp_acquire, + mock_fetch_channel, + mock_fetch_active_threads, + mock_fetch_public_archived_threads, + mock_create_forum_post, + mock_shelve_open, + _mock_open, + rest_client_mock, + forum_channel_mock, +): + mock_os_getenv.side_effect = ["some_token", 1, 2, "some_path"] + mock_restapp_acquire.return_value = RestClientContextManagerMock(rest_client_mock) + mock_fetch_channel.return_value = forum_channel_mock + mock_fetch_active_threads.return_value = [] + mock_fetch_public_archived_threads.return_value = [] + mock_create_forum_post.return_value = None + mock_shelve_open.return_value = MockShelf({}) + update_queue = asyncio.Queue() + await update_queue.put(ProjectItemEvent(name="Test Item", sender="test_sender")) + await run(update_queue, stop_after_one_event=True) + mock_create_forum_post.assert_called() diff --git a/src/tests/test_integration/test_server.py b/src/tests/test_integration/test_server.py new file mode 100644 index 0000000..7cee915 --- /dev/null +++ b/src/tests/test_integration/test_server.py @@ -0,0 +1,167 @@ +import json +from typing import Any +from unittest.mock import patch + +from aiohttp import ClientSession +from fastapi.testclient import TestClient + +from src.server import app +from src.tests.test_unit.test_utils import MockResponse, MockShelf +from src.utils.utils import generate_signature + +test_client = TestClient(app) + + +def test_missing_body(): + response = test_client.post("/webhook_endpoint", data=None) + assert response.status_code == 400 + assert response.json() == {"detail": "Missing request body."} + + +@patch("os.getenv") +def test_invalid_signature(mock_os_getenv): + mock_os_getenv.return_value = "some_secret" + + response = test_client.post( + "/webhook_endpoint", data={"mreow": "nya"}, headers={"X-Hub-Signature-256": "invalid_signature"} + ) + + assert response.status_code == 401 + assert response.json() == {"detail": "Invalid signature"} + + +@patch("os.getenv") +def test_missing_signature(mock_os_getenv): + mock_os_getenv.return_value = "some_secret" + + response = test_client.post( + "/webhook_endpoint", + data={"mreow": "nya"}, + ) + + assert response.status_code == 401 + assert response.json() == {"detail": "Missing signature"} + + +@patch("os.getenv") +def test_invalid_json(mock_os_getenv): + mock_os_getenv.return_value = "some_secret" + signature = generate_signature("some_secret", b"invalid_json") + response = test_client.post( + "/webhook_endpoint", + content="invalid_json", + headers={"X-Hub-Signature-256": signature}, + ) + assert response.status_code == 400 + assert response.json() == {"detail": "Invalid JSON data."} + + +@patch("os.getenv") +def test_missing_projects_v2_item(mock_os_getenv): + mock_os_getenv.return_value = "some_secret" + signature = generate_signature("some_secret", b'{"not_projects_v2_item": "data"}') + response = test_client.post( + "/webhook_endpoint", + content='{"not_projects_v2_item": "data"}', + headers={"X-Hub-Signature-256": signature}, + ) + assert response.status_code == 400 + assert response.json() == {"detail": "Missing projects_v2_item in payload."} + + +@patch("os.getenv") +def test_missing_project_node_id(mock_os_getenv): + mock_os_getenv.return_value = "some_secret" + signature = generate_signature("some_secret", b'{"projects_v2_item": {"skibidi": true}}') + response = test_client.post( + "/webhook_endpoint", + content='{"projects_v2_item": {"skibidi": true}}', + headers={"X-Hub-Signature-256": signature}, + ) + assert response.status_code == 400 + assert response.json() == {"detail": "Invalid project_node_id."} + + +@patch("os.getenv") +def test_invalid_project_node_id(mock_os_getenv): + mock_os_getenv.side_effect = ["some_secret", 33] + signature = generate_signature("some_secret", b'{"projects_v2_item": {"project_node_id": 123}}') + response = test_client.post( + "/webhook_endpoint", + content='{"projects_v2_item": {"project_node_id": 123}}', + headers={"X-Hub-Signature-256": signature}, + ) + assert response.status_code == 400 + assert response.json() == {"detail": "Invalid project_node_id."} + + +@patch("os.getenv") +def test_missing_item_node_id(mock_os_getenv): + mock_os_getenv.side_effect = ["some_secret", 123] + signature = generate_signature("some_secret", b'{"projects_v2_item": {"project_node_id": 123}}') + response = test_client.post( + "/webhook_endpoint", + content='{"projects_v2_item": {"project_node_id": 123}}', + headers={"X-Hub-Signature-256": signature}, + ) + assert response.status_code == 400 + assert response.json() == {"detail": "Missing item_node_id in payload."} + + +@patch.object(ClientSession, "post") +@patch("shelve.open") +@patch("os.getenv") +def test_could_not_fetch_item_name(mock_os_getenv, mock_shelve_open, mock_post_request): + mock_os_getenv.side_effect = ["some_secret", 123, "some_token"] + mock_shelve_open.return_value = MockShelf({}) + mock_post_request.return_value = MockResponse({}) + signature = generate_signature("some_secret", b'{"projects_v2_item": {"project_node_id": 123, "node_id": "123"}}') + response = test_client.post( + "/webhook_endpoint", + content='{"projects_v2_item": {"project_node_id": 123, "node_id": "123"}}', + headers={"X-Hub-Signature-256": signature}, + ) + assert response.status_code == 500 + assert response.json() == {"detail": "Could not fetch item name."} + + +@patch("shelve.open") +@patch("os.getenv") +def test_missing_action(mock_os_getenv, mock_shelve_open): + mock_os_getenv.side_effect = ["some_secret", 123] + mock_shelve_open.return_value = MockShelf({"123": "Meow"}) + signature = generate_signature("some_secret", b'{"projects_v2_item": {"project_node_id": 123, "node_id": "123"}}') + response = test_client.post( + "/webhook_endpoint", + content='{"projects_v2_item": {"project_node_id": 123, "node_id": "123"}}', + headers={"X-Hub-Signature-256": signature}, + ) + assert response.status_code == 400 + assert response.json() == {"detail": "Missing action in payload."} + + +@patch.object(ClientSession, "post") +@patch("shelve.open") +@patch("os.getenv") +def test_edited_action(mock_os_getenv, mock_shelve_open, mock_post_request): + payload: dict[str, Any] = { + "projects_v2_item": {"project_node_id": 123, "node_id": "123"}, + "action": "edited", + "changes": {"field_value": {"field_type": "title"}}, + } + payload: str = json.dumps(payload) + mock_os_getenv.side_effect = ["some_secret", 123, "some_token"] + mock_shelve_open.return_value = MockShelf({"123": "Meow"}) + mock_post_request.return_value = MockResponse({}) + signature = generate_signature( + "some_secret", + payload.encode("utf-8"), + ) + response = test_client.post( + "/webhook_endpoint", + content=payload, + headers={"X-Hub-Signature-256": signature}, + ) + assert response.json() == {"detail": "Successfully received webhook data"} + assert response.status_code == 200 + mock_post_request.assert_called() diff --git a/src/tests/test_unit/__init__.py b/src/tests/test_unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/test_unit/test_bot.py b/src/tests/test_unit/test_bot.py new file mode 100644 index 0000000..963018b --- /dev/null +++ b/src/tests/test_unit/test_bot.py @@ -0,0 +1,294 @@ +# ruff: noqa: F811 + +import asyncio +import datetime +from unittest.mock import AsyncMock, patch + +import pytest +from hikari import ChannelFlag, ForumTag, GuildPublicThread, RESTAware, Snowflake, ThreadMetadata +from hikari.impl import RESTClientImpl + +from src.bot import process_update +from src.utils.data_types import ( + ProjectItemEditedAssignees, + ProjectItemEditedBody, + ProjectItemEditedSingleSelect, + ProjectItemEditedTitle, + SimpleProjectItemEvent, + SimpleProjectItemEventType, + SingleSelectType, +) + +from .test_utils import forum_channel_mock, rest_client_mock # noqa: F401 + + +@pytest.fixture +def post_mock(): + mock_timedelta = datetime.timedelta(seconds=0) + mock_datetime = datetime.datetime.now() + mock_metadata = ThreadMetadata( + is_archived=False, + archive_timestamp=mock_datetime, + auto_archive_duration=mock_timedelta, + is_invitable=False, + is_locked=False, + created_at=mock_datetime, + ) + return GuildPublicThread( + app=RESTAware, + id=Snowflake(621), + name="audacity4", + type=0, + guild_id=Snowflake(0), + last_message_id=None, + last_pin_timestamp=None, + rate_limit_per_user=mock_timedelta, + approximate_message_count=0, + approximate_member_count=0, + member=None, + owner_id=Snowflake(0), + parent_id=Snowflake(0), + metadata=mock_metadata, + applied_tag_ids=[Snowflake(1)], + flags=ChannelFlag(0), + ) + + +@patch.object(RESTClientImpl, "create_forum_post", new_callable=AsyncMock) +@patch("src.bot.retrieve_discord_id") +@patch("src.bot.get_post_id", new_callable=AsyncMock) +async def test_process_update_created_success( + mock_get_post_id, mock_retrieve_discord_id, mock_create_forum_post, forum_channel_mock, rest_client_mock +): + state = asyncio.Queue() + await state.put(SimpleProjectItemEvent("mmmocking", "norbiros", SimpleProjectItemEventType.CREATED)) + mock_get_post_id.return_value = None + mock_retrieve_discord_id.return_value = "niepodam@norbiros.dev" + + await process_update(rest_client_mock, 1, 1, forum_channel_mock, state) + assert mock_create_forum_post.called + + +@patch.object(RESTClientImpl, "fetch_channel", new_callable=AsyncMock) +@patch("src.bot.retrieve_discord_id") +@patch("src.bot.get_post_id", new_callable=AsyncMock) +async def test_process_update_already_exists( + mock_get_post_id, mock_retrieve_discord_id, mock_fetch_channel, forum_channel_mock, rest_client_mock +): + state = asyncio.Queue() + await state.put(SimpleProjectItemEvent("mmmocking", "norbiros", SimpleProjectItemEventType.CREATED)) + mock_get_post_id.return_value = "1" + mock_retrieve_discord_id.return_value = "niepodam@norbiros.dev" + + await process_update(rest_client_mock, 1, 1, forum_channel_mock, state) + assert mock_fetch_channel.called + + +@patch.object(RESTClientImpl, "edit_channel", new_callable=AsyncMock) +@patch.object(RESTClientImpl, "create_message", new_callable=AsyncMock) +@patch.object(RESTClientImpl, "fetch_channel", new_callable=AsyncMock) +@patch("src.bot.retrieve_discord_id") +@patch("src.bot.get_post_id", new_callable=AsyncMock) +async def test_process_update_archived( + mock_get_post_id, + mock_retrieve_discord_id, + mock_fetch_channel, + _mock_create_message, + mock_edit_channel, + forum_channel_mock, + rest_client_mock, + post_mock, +): + state = asyncio.Queue() + await state.put(SimpleProjectItemEvent("audacity4", "norbiros", SimpleProjectItemEventType.ARCHIVED)) + mock_get_post_id.return_value = "621" + mock_retrieve_discord_id.return_value = "niepodam@norbiros.dev" + mock_fetch_channel.return_value = post_mock + + await process_update(rest_client_mock, 1, 1, forum_channel_mock, state) + mock_edit_channel.assert_called_with(post_mock.id, archived=True) + + +@patch.object(RESTClientImpl, "edit_channel", new_callable=AsyncMock) +@patch.object(RESTClientImpl, "create_message", new_callable=AsyncMock) +@patch.object(RESTClientImpl, "fetch_channel", new_callable=AsyncMock) +@patch("src.bot.retrieve_discord_id") +@patch("src.bot.get_post_id", new_callable=AsyncMock) +async def test_process_update_restored( + mock_get_post_id, + mock_retrieve_discord_id, + mock_fetch_channel, + _mock_create_message, + mock_edit_channel, + forum_channel_mock, + rest_client_mock, + post_mock, +): + state = asyncio.Queue() + await state.put(SimpleProjectItemEvent("audacity4", "norbiros", SimpleProjectItemEventType.RESTORED)) + mock_get_post_id.return_value = "621" + mock_retrieve_discord_id.return_value = "niepodam@norbiros.dev" + mock_fetch_channel.return_value = post_mock + + await process_update(rest_client_mock, 1, 1, forum_channel_mock, state) + mock_edit_channel.assert_called_with(post_mock.id, archived=False) + + +@patch.object(RESTClientImpl, "delete_channel", new_callable=AsyncMock) +@patch.object(RESTClientImpl, "fetch_channel", new_callable=AsyncMock) +@patch("src.bot.retrieve_discord_id") +@patch("src.bot.get_post_id", new_callable=AsyncMock) +async def test_process_update_deleted( + mock_get_post_id, + mock_retrieve_discord_id, + mock_fetch_channel, + mock_delete_channel, + forum_channel_mock, + rest_client_mock, + post_mock, +): + state = asyncio.Queue() + await state.put(SimpleProjectItemEvent("audacity4", "norbiros", SimpleProjectItemEventType.DELETED)) + mock_get_post_id.return_value = "621" + mock_retrieve_discord_id.return_value = "niepodam@norbiros.dev" + mock_fetch_channel.return_value = post_mock + + await process_update(rest_client_mock, 1, 1, forum_channel_mock, state) + mock_delete_channel.assert_called_with(post_mock.id) + + +@patch.object(RESTClientImpl, "create_message", new_callable=AsyncMock) +@patch.object(RESTClientImpl, "fetch_channel", new_callable=AsyncMock) +@patch("src.bot.retrieve_discord_id") +@patch("src.bot.get_post_id", new_callable=AsyncMock) +async def test_process_update_assignees( + mock_get_post_id, + mock_retrieve_discord_id, + mock_fetch_channel, + mock_create_message, + forum_channel_mock, + rest_client_mock, + post_mock, +): + state = asyncio.Queue() + await state.put(ProjectItemEditedAssignees("audacity4", "norbiros", ["norbiros"])) + mock_get_post_id.return_value = "621" + user_id = 2137696742041 + mock_retrieve_discord_id.return_value = user_id + mock_fetch_channel.return_value = post_mock + message = f"Osoby przypisane do taska edytowane, aktualni przypisani: <@{user_id}>" + + await process_update(rest_client_mock, 1, 1, forum_channel_mock, state) + mock_create_message.assert_called_with(post_mock.id, message, user_mentions=[user_id]) + + +@patch.object(RESTClientImpl, "create_message", new_callable=AsyncMock) +@patch.object(RESTClientImpl, "fetch_channel", new_callable=AsyncMock) +@patch("src.bot.retrieve_discord_id") +@patch("src.bot.get_post_id", new_callable=AsyncMock) +async def test_process_update_body( + mock_get_post_id, + mock_retrieve_discord_id, + mock_fetch_channel, + mock_create_message, + forum_channel_mock, + rest_client_mock, + post_mock, +): + state = asyncio.Queue() + new_body = "Nowy opis taska" + await state.put(ProjectItemEditedBody("audacity4", "norbiros", new_body)) + mock_get_post_id.return_value = "621" + user_id = 2137696742041 + mock_retrieve_discord_id.return_value = user_id + mock_fetch_channel.return_value = post_mock + message = f"Opis taska zaktualizowany przez <@{user_id}>. Nowy opis: \n{new_body}" + + await process_update(rest_client_mock, 1, 1, forum_channel_mock, state) + mock_create_message.assert_called_with(post_mock.id, message, user_mentions=[user_id]) + + +@patch.object(RESTClientImpl, "edit_channel", new_callable=AsyncMock) +@patch.object(RESTClientImpl, "fetch_channel", new_callable=AsyncMock) +@patch("src.bot.retrieve_discord_id") +@patch("src.bot.get_post_id", new_callable=AsyncMock) +async def test_process_update_title( + mock_get_post_id, + mock_retrieve_discord_id, + mock_fetch_channel, + mock_edit_channel, + forum_channel_mock, + rest_client_mock, + post_mock, +): + state = asyncio.Queue() + new_title = "Nowy opis taska" + await state.put(ProjectItemEditedTitle("audacity4", "norbiros", new_title)) + mock_get_post_id.return_value = "621" + user_id = 2137696742041 + mock_retrieve_discord_id.return_value = user_id + mock_fetch_channel.return_value = post_mock + + await process_update(rest_client_mock, 1, 1, forum_channel_mock, state) + mock_edit_channel.assert_called_with(post_mock.id, name=new_title) + + +@patch("src.bot.get_new_tag") +@patch.object(RESTClientImpl, "edit_channel", new_callable=AsyncMock) +@patch.object(RESTClientImpl, "fetch_channel", new_callable=AsyncMock) +@patch("src.bot.retrieve_discord_id") +@patch("src.bot.get_post_id", new_callable=AsyncMock) +async def test_process_update_single_select( + mock_get_post_id, + mock_retrieve_discord_id, + mock_fetch_channel, + mock_edit_channel, + mock_get_new_tag, + forum_channel_mock, + rest_client_mock, + post_mock, +): + state = asyncio.Queue() + await state.put(ProjectItemEditedSingleSelect("audacity4", "norbiros", "big", SingleSelectType.SIZE)) + mock_get_post_id.return_value = "621" + user_id = 2137696742041 + mock_retrieve_discord_id.return_value = user_id + mock_fetch_channel.return_value = post_mock + mock_get_new_tag.return_value = ForumTag(id=Snowflake(2), name="Size: big", moderated=False) + + await process_update(rest_client_mock, 1, 1, forum_channel_mock, state) + mock_edit_channel.assert_called_with(post_mock.id, applied_tags=[Snowflake(2)]) + + +@patch("src.bot.fetch_forum_channel", new_callable=AsyncMock) +@patch("src.bot.get_new_tag") +@patch.object(RESTClientImpl, "edit_channel", new_callable=AsyncMock) +@patch.object(RESTClientImpl, "fetch_channel", new_callable=AsyncMock) +@patch("src.bot.retrieve_discord_id") +@patch("src.bot.get_post_id", new_callable=AsyncMock) +async def test_process_update_single_select_tag_unavailable( + mock_get_post_id, + mock_retrieve_discord_id, + mock_fetch_channel, + mock_edit_channel, + mock_get_new_tag, + mock_fetch_forum_channel, + forum_channel_mock, + rest_client_mock, + post_mock, +): + state = asyncio.Queue() + await state.put(ProjectItemEditedSingleSelect("audacity4", "norbiros", "big", SingleSelectType.SIZE)) + mock_get_post_id.return_value = "621" + user_id = 2137696742041 + mock_retrieve_discord_id.return_value = user_id + mock_fetch_channel.return_value = post_mock + mock_fetch_forum_channel.return_value = forum_channel_mock + new_tag = ForumTag(id=Snowflake(0), name="Size: big") + mock_get_new_tag.side_effect = [None, new_tag] + + await process_update(rest_client_mock, 1, 1, forum_channel_mock, state) + mock_edit_channel.assert_any_call( + forum_channel_mock.id, available_tags=forum_channel_mock.available_tags + [new_tag] + ) + mock_edit_channel.assert_called_with(post_mock.id, applied_tags=[Snowflake(0)]) diff --git a/src/tests/test_unit/test_server.py b/src/tests/test_unit/test_server.py new file mode 100644 index 0000000..6d6b0df --- /dev/null +++ b/src/tests/test_unit/test_server.py @@ -0,0 +1,68 @@ +from unittest.mock import patch + +from src.server import process_edition +from src.utils.data_types import ( + ProjectItemEditedAssignees, + ProjectItemEditedBody, + ProjectItemEditedSingleSelect, + ProjectItemEditedTitle, + SingleSelectType, +) + + +async def test_process_edition_body_changes(): + body = {"changes": {"body": {"to": "We need to pet more cats"}}} + item_name = "PetSomeCats" + expected_object = ProjectItemEditedBody(item_name, "Unknown", "We need to pet more cats") + + assert await process_edition(body, item_name) == expected_object + + +async def test_process_edition_body_no_changes(): + body = {} + item_name = "Idk" + + assert await process_edition(body, item_name) is None + + +@patch("src.server.fetch_assignees") +async def test_process_edition_assignees_changed(mock_fetch_assignees): + body = {"changes": {"field_value": {"field_type": "assignees"}}} + item_name = "YouKnowIntegrationTestsAreNextDontYou?" + new_assignees = ["Kubaryt", "Salieri", "Aniela"] + mock_fetch_assignees.return_value = new_assignees + expected_object = ProjectItemEditedAssignees(item_name, "Unknown", new_assignees) + + assert await process_edition(body, item_name) == expected_object + + +@patch("src.server.fetch_item_name") +async def test_process_edition_title_changed(mock_fetch_item_name): + body = {"changes": {"field_value": {"field_type": "title"}}} + item_name = "ImagineMockingDiscordSoMuchFun" + new_item_name = "ActuallyNotFunAtAll" + mock_fetch_item_name.return_value = new_item_name + expected_object = ProjectItemEditedTitle(item_name, "Unknown", new_item_name) + + assert await process_edition(body, item_name) == expected_object + + +async def test_process_edition_single_select_changed(): + body = { + "changes": { + "field_value": {"field_type": "single_select", "field_name": "Size", "to": {"name": "Smol like lil kitten"}} + } + } + item_name = "Lil puppy" + expected_object = ProjectItemEditedSingleSelect(item_name, "Unknown", "Smol like lil kitten", SingleSelectType.SIZE) + + assert await process_edition(body, item_name) == expected_object + + +async def test_process_edition_iteration_changed(): + new_title = "1.0.0 - FinallyWeShipItAfter25Years" + body = {"changes": {"field_value": {"field_type": "iteration", "to": {"title": new_title}}}} + item_name = "Create Dockerfile for production" + expected_object = ProjectItemEditedSingleSelect(item_name, "Unknown", new_title, SingleSelectType.ITERATION) + + assert await process_edition(body, item_name) == expected_object diff --git a/src/tests/test_unit/test_utils.py b/src/tests/test_unit/test_utils.py new file mode 100644 index 0000000..79b6860 --- /dev/null +++ b/src/tests/test_unit/test_utils.py @@ -0,0 +1,262 @@ +import datetime +from unittest.mock import AsyncMock, mock_open, patch + +import pytest +from aiohttp import ClientSession +from hikari import ( + ChannelFlag, + ForumLayoutType, + ForumSortOrderType, + ForumTag, + GuildForumChannel, + PartialChannel, + RESTAware, + Snowflake, +) +from hikari.impl import EntityFactoryImpl, HTTPSettings, ProxySettings, RESTClientImpl + +from src.utils import utils + + +class MockShelf(dict): + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + pass + + +class MockResponse(dict): + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + pass + + async def json(self): + return self + + +@pytest.fixture +def rest_client_mock(): + entity_factory = EntityFactoryImpl(app=RESTAware) + http_settings = HTTPSettings() + proxy_settings = ProxySettings() + return RESTClientImpl( + cache=None, + entity_factory=entity_factory, + executor=None, + http_settings=http_settings, + proxy_settings=proxy_settings, + token=None, + token_type=None, + rest_url=None, + ) + + +@pytest.fixture() +def post_mock(): + return PartialChannel(app=RESTAware, id=Snowflake(621), name="audacity4", type=0) + + +@pytest.fixture() +def forum_channel_mock(): + mock_timedelta = datetime.timedelta(seconds=0) + return GuildForumChannel( + app=RESTAware, + id=Snowflake(67), + name="forum-channel", + topic="A forum channel", + is_nsfw=False, + default_auto_archive_duration=mock_timedelta, + available_tags=[ForumTag(id=Snowflake(1), name="Size: smol", moderated=False)], + type=0, + guild_id=Snowflake(41), + parent_id=None, + position=0, + permission_overwrites={}, + last_thread_id=None, + rate_limit_per_user=mock_timedelta, + default_thread_rate_limit_per_user=mock_timedelta, + flags=ChannelFlag(0), + default_sort_order=ForumSortOrderType.CREATION_DATE, + default_layout=ForumLayoutType.GALLERY_VIEW, + default_reaction_emoji_id=None, + default_reaction_emoji_name=None, + ) + + +@patch("shelve.open") +async def test_get_item_name_exist_in_db(mock_shelve_open): + mock_db = {"O_kgDOCUX8Wg": "crabcraft"} + mock_shelve_open.return_value = MockShelf(mock_db) + + assert await utils.get_item_name("O_kgDOCUX8Wg") == "crabcraft" + + +@patch("shelve.open") +@patch("src.utils.utils.fetch_item_name") +async def test_get_item_name_doesnt_exist_in_db(mock_fetch_item_name, mock_shelve_open): + mock_shelf = MockShelf({}) + mock_shelve_open.return_value = mock_shelf + mock_fetch_item_name.return_value = "crabcraft" + + assert await utils.get_item_name("O_kgDOCUX8Wg") == "crabcraft" + assert mock_shelf.get("O_kgDOCUX8Wg") == "crabcraft" + + +@patch.object(ClientSession, "post") +async def test_fetch_item_name_success(mock_post_request): + mock_response = {"data": {"node": {"content": {"title": "42"}}}} + mock_post_request.return_value = MockResponse(mock_response) + + assert await utils.fetch_item_name("") == "42" + + +@patch.object(ClientSession, "post") +async def test_fetch_item_name_none(mock_post_request): + mock_post_request.return_value = MockResponse({}) + + assert await utils.fetch_item_name("") is None + + +@patch.object(ClientSession, "post") +async def test_fetch_assignees_success(mock_post_request): + mock_response = { + "data": { + "node": { + "content": {"assignees": {"nodes": [{"id": "MDQ6VXNlcjg4MjY4MDYz"}, {"id": "MDQ6VXNlcjg5ODM3NzI0"}]}} + } + } + } + mock_post_request.return_value = MockResponse(mock_response) + + assert await utils.fetch_assignees("") == ["MDQ6VXNlcjg4MjY4MDYz", "MDQ6VXNlcjg5ODM3NzI0"] + + +@patch.object(ClientSession, "post") +async def test_fetch_assignees_none(mock_post_request): + mock_post_request.return_value = MockResponse({}) + + assert await utils.fetch_assignees("") == [] + + +@patch.object(ClientSession, "post") +async def test_fetch_single_select_value_success(mock_post_request): + mock_response = {"data": {"node": {"fieldValueByName": {"name": "Dziengiel"}}}} + mock_post_request.return_value = MockResponse(mock_response) + + assert await utils.fetch_single_select_value("", "Salieri") == "Dziengiel" + + +@patch.object(ClientSession, "post") +async def test_fetch_single_select_value_none(mock_post_request): + mock_response = {} + mock_post_request.return_value = MockResponse(mock_response) + + assert await utils.fetch_single_select_value("", "Salieri") is None + + +@patch("shelve.open") +async def test_get_post_id_exist_in_db(mock_shelve_open, rest_client_mock): + mock_db = {"audacity4": 621} + mock_shelve_open.return_value = MockShelf(mock_db) + + assert await utils.get_post_id("audacity4", 1, 1, rest_client_mock) == 621 + + +@patch.object(RESTClientImpl, "fetch_active_threads", new_callable=AsyncMock) +@patch("shelve.open") +async def test_get_post_id_active_thread(mock_shelve_open, mock_fetch_active_threads, rest_client_mock, post_mock): + mock_shelf = MockShelf({}) + mock_shelve_open.return_value = mock_shelf + mock_fetch_active_threads.return_value = [post_mock] + + assert await utils.get_post_id("audacity4", 1, 1, rest_client_mock) == 621 + assert mock_shelf.get("audacity4") == 621 + + +@patch.object(RESTClientImpl, "fetch_public_archived_threads", new_callable=AsyncMock) +@patch.object(RESTClientImpl, "fetch_active_threads", new_callable=AsyncMock) +@patch("shelve.open") +async def test_get_post_id_archived_thread( + mock_shelve_open, mock_fetch_active_threads, mock_fetch_public_archived_threads, rest_client_mock, post_mock +): + mock_shelf = MockShelf({}) + mock_shelve_open.return_value = mock_shelf + mock_fetch_active_threads.return_value = [] + mock_fetch_public_archived_threads.return_value = [post_mock] + + assert await utils.get_post_id("audacity4", 1, 1, rest_client_mock) == 621 + assert mock_shelf.get("audacity4") == 621 + + +@patch("builtins.open", new_callable=mock_open, read_data='MDQ6VXNlcjY2NTE0ODg1: "393756120952602625"') +@patch("yaml.load") +def test_retrieve_discord_id_present_id(mock_yaml_load, _mock_open_file): + mock_yaml_load.return_value = {"MDQ6VXNlcjY2NTE0ODg1": "393756120952602625"} + + assert utils.retrieve_discord_id("MDQ6VXNlcjY2NTE0ODg1") == "393756120952602625" + + +@patch("builtins.open", new_callable=mock_open, read_data="") +def test_retrieve_discord_id_absent_id(_mock_open_file): + assert utils.retrieve_discord_id("") is None + + +@patch.object(RESTClientImpl, "fetch_channel", new_callable=AsyncMock) +async def test_fetch_forum_channel_success(mock_fetch_channel, rest_client_mock, forum_channel_mock): + mock_fetch_channel.return_value = forum_channel_mock + + assert await utils.fetch_forum_channel(rest_client_mock, 67) == forum_channel_mock + + +@patch.object(RESTClientImpl, "fetch_channel", new_callable=AsyncMock) +async def test_fetch_forum_channel_none(mock_fetch_channel, rest_client_mock): + mock_fetch_channel.return_value = None + + assert await utils.fetch_forum_channel(rest_client_mock, 67) is None + + +@patch.object(RESTClientImpl, "fetch_channel", new_callable=AsyncMock) +async def test_fetch_forum_channel_not_forum_channel(mock_fetch_channel, rest_client_mock, post_mock): + mock_fetch_channel.return_value = post_mock + + assert await utils.fetch_forum_channel(rest_client_mock, 67) is None + + +def test_get_new_tag_success(): + tag1 = ForumTag(id=Snowflake(1), name="enchantment", moderated=False) + available_tags = [tag1] + + assert utils.get_new_tag("enchantment", available_tags) == tag1 + + +def test_get_new_tag_none(): + tag1 = ForumTag(id=Snowflake(1), name="enchantment", moderated=False) + available_tags = [tag1] + + assert utils.get_new_tag("build", available_tags) is None + + +def test_generate_signature(): + expected_signature = "sha256=9b40ac77c0653bed6e678ebd8db8b8d96a7c8ea8983b1a77577797d0a43b97c6" + signature = utils.generate_signature("H-letter", b"I freaking love H letter") + + assert signature == expected_signature + + +def test_verify_secret_correct(): + secret = "H-letter" + payload = b"I freaking love H letter" + signature_header = "sha256=9b40ac77c0653bed6e678ebd8db8b8d96a7c8ea8983b1a77577797d0a43b97c6" + + assert utils.verify_secret(secret, payload, signature_header) + + +def test_verify_secret_incorrect(): + secret = "H-letter" + payload = b"malicious" + signature_header = "sha256=9b40ac77c0653bed6e678ebd8db8b8d96a7c8ea8983b1a77577797d0a43b97c6" + + assert not utils.verify_secret(secret, payload, signature_header) From 83a8fbe8297980a2ae74dc3fbf6b471a2c86f90c Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Tue, 11 Nov 2025 22:51:05 +0100 Subject: [PATCH 03/44] feat: Create files for deployment --- Dockerfile | 10 ++++++++++ docker-compose.yaml | 11 +++++++++++ 2 files changed, 21 insertions(+) create mode 100644 Dockerfile create mode 100644 docker-compose.yaml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8d58bd8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.13-slim-trixie +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ + +ADD . /app + +WORKDIR /app +RUN uv sync --locked + +EXPOSE 8000 +CMD ".venv/bin/start-app" diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..7be470a --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,11 @@ +services: + app: + container_name: app + build: + context: . + dockerfile: Dockerfile + restart: on-failure + ports: + - "8000:8000" + env_file: + - .env \ No newline at end of file From acaa8ae29a057ad8c541c9e3e3e88fa7af94ba8f Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Tue, 11 Nov 2025 22:52:38 +0100 Subject: [PATCH 04/44] docs: Create `README.md` and `.env.example` files --- .env.example | 7 +++++++ README.md | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 .env.example create mode 100644 README.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b4fdcb0 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +DISCORD_BOT_TOKEN=your-discord-bot-token +FORUM_CHANNEL_ID=your-forum-channel-id +GITHUB_TOKEN=your-github-token +GITHUB_PROJECT_NODE_ID=your-github-project-node-id +GITHUB_ID_TO_DISCORD_ID_MAPPING_PATH=path-to-github-username-to-discord-id-mapping.json +IP_ADDRESS=0.0.0.0:8000 +GITHUB_WEBHOOK_SECRET=your-github-webhook-secret \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..98baafa --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +## GitHub Projects Discord Bot + +This repository provides an integration between [Discord](https://discord.com/) and [GitHub Projects](https://docs.github.com/en/issues/planning-and-tracking-with-projects/learning-about-projects/about-projects), +allowing users to discuss GitHub Projects directly from Discord. Whenever a new issue, pull request or draft issue is added +to the project, bot creates a new thread in the specified Discord channel. Every update to the project item is then communicated +in the thread, allowing users to stay up-to-date with the latest changes. + +## 🚜 Development + +This repository contains two main components: +- [`server.py`](/src/server.py): listens for GitHub webhook events and processes them +- [`bot.py`](/src/bot.py): a Discord bot that creates threads and posts updates + +For local development, you can copy the `.env.example` file to `.env` and fill in the first three environment variables. +Then run the following command to install dependencies: + +```bash +uv sync +``` + +To run the server and bot locally, you can use the following command: + +```bash +uv run start-app +``` + +To run all tests, including unit tests, integration tests and e2e tests, use the following command: + +```bash +uv run pytest +``` + +## 🚀 Deployment + +For deployment follow these steps: +- set all environment variables accordingly, +- update your `github_id_to_discord_id_mapping.json`, +- set up a webhook in your GitHub repository to point to your server's `/webhook_endpoint` endpoint, +- use Dockerfile to build the image. + +## ⚒️ How it works + +1. GitHub sends a webhook event to the server when an issue, pull request or draft issue is added or updated in the project. +2. The server processes the event and extracts relevant information, such as the issue title, description +3. The server updates shared state with the new information which is then used by the bot to post updates. +4. The bot creates a new thread in the specified Discord channel for new issues, pull requests or draft issues. +5. The bot posts updates in the thread whenever the issue, pull request or draft issue is updated. +6. The bot uses the `github_usernames_to_discord_id_mapping.json` file to map GitHub usernames to Discord user IDs, + allowing it to mention users in the thread. From 76db50cf5b6b617217dc45235e50f01031b6e682 Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Tue, 11 Nov 2025 23:14:43 +0100 Subject: [PATCH 05/44] refactor: Handle missing user_id and remove code duplication from creating message --- src/bot.py | 25 +++++++++++++++---------- src/tests/test_e2e.py | 2 +- src/tests/test_unit/test_bot.py | 22 +++++++++++++++------- 3 files changed, 31 insertions(+), 18 deletions(-) diff --git a/src/bot.py b/src/bot.py index f410f37..67dffba 100644 --- a/src/bot.py +++ b/src/bot.py @@ -46,16 +46,17 @@ async def process_update( bot_info(f"Processing event for item: {event.name}") post_id = await get_post_id(event.name, discord_guild_id, forum_channel_id, client) author_discord_id = retrieve_discord_id(event.sender) + user_mentions = [author_discord_id] if author_discord_id else [] + user_text_mention = f"<@{author_discord_id}>" if author_discord_id else "nieznany użytkownik" if post_id is None: bot_info(f"Post not found, creating new post for item: {event.name}") - # todo: Handle author_discord_id being None - message = f"Nowy task stworzony {event.name} przez <@{author_discord_id}>" + message = f"Nowy task stworzony {event.name} przez: {user_text_mention}.>" post: GuildPublicThread = await client.create_forum_post( forum_channel, event.name, message, auto_archive_duration=10080, - user_mentions=[author_discord_id], + user_mentions=user_mentions, ) else: post = await client.fetch_channel(post_id) @@ -66,18 +67,17 @@ async def process_update( if isinstance(event, SimpleProjectItemEvent): match event.event_type.value: case "archived": - message = f"Task zarchiwizowany przez <@{author_discord_id}>." - await client.create_message(post.id, message, user_mentions=[author_discord_id]) + message = f"Task zarchiwizowany przez: {user_text_mention}." await client.edit_channel(post.id, archived=True) bot_info(f"Post {event.name} archived.") case "restored": - message = f"Task przywrócony przez <@{author_discord_id}>." - await client.create_message(post.id, message, user_mentions=[author_discord_id]) + message = f"Task przywrócony przez: {user_text_mention}." await client.edit_channel(post.id, archived=False) bot_info(f"Post {event.name} restored.") case "deleted": await client.delete_channel(post.id) bot_info(f"Post {event.name} deleted.") + return elif isinstance(event, ProjectItemEditedAssignees): assignee_mentions: list[str] = [] assignee_discord_ids: list[int] = [] @@ -93,13 +93,13 @@ async def process_update( message = f"Osoby przypisane do taska edytowane, aktualni przypisani: {', '.join(assignee_mentions)}" await client.create_message(post.id, message, user_mentions=assignee_discord_ids) bot_info(f"Post {event.name} assignees updated.") + return elif isinstance(event, ProjectItemEditedBody): - message = f"Opis taska zaktualizowany przez <@{author_discord_id}>. Nowy opis: \n{event.new_body}" - user_mentions = [author_discord_id] if author_discord_id else [] - await client.create_message(post.id, message, user_mentions=user_mentions) + message = f"Opis taska zaktualizowany przez: {user_text_mention}. Nowy opis: \n{event.new_body}" bot_info(f"Post {event.name} body updated.") elif isinstance(event, ProjectItemEditedTitle): await client.edit_channel(post.id, name=event.new_title) + return elif isinstance(event, ProjectItemEditedSingleSelect): available_tags = list(forum_channel.available_tags) current_tag_ids = list(post.applied_tag_ids) @@ -126,3 +126,8 @@ async def process_update( await client.edit_channel(post.id, applied_tags=current_tag_ids) bot_info(f"Post {event.name} label updated.") + return + else: + return + + await client.create_message(post.id, message, user_mentions=user_mentions) diff --git a/src/tests/test_e2e.py b/src/tests/test_e2e.py index 7a916bc..791857b 100644 --- a/src/tests/test_e2e.py +++ b/src/tests/test_e2e.py @@ -82,7 +82,7 @@ async def test_e2e( pytest.fail("Expected log 'body updated' not found in output") assert post_id_shelf.get("audacity4") == 621 mock_create_message.assert_called_with( - 621, "Opis taska zaktualizowany przez <@None>. Nowy opis: \nUpdated description", user_mentions=[] + 621, "Opis taska zaktualizowany przez: nieznany użytkownik. Nowy opis: \nUpdated description", user_mentions=[] ) server.should_exit = True diff --git a/src/tests/test_unit/test_bot.py b/src/tests/test_unit/test_bot.py index 963018b..c4fd54d 100644 --- a/src/tests/test_unit/test_bot.py +++ b/src/tests/test_unit/test_bot.py @@ -63,7 +63,7 @@ async def test_process_update_created_success( state = asyncio.Queue() await state.put(SimpleProjectItemEvent("mmmocking", "norbiros", SimpleProjectItemEventType.CREATED)) mock_get_post_id.return_value = None - mock_retrieve_discord_id.return_value = "niepodam@norbiros.dev" + mock_retrieve_discord_id.return_value = 2137696742041 await process_update(rest_client_mock, 1, 1, forum_channel_mock, state) assert mock_create_forum_post.called @@ -78,7 +78,7 @@ async def test_process_update_already_exists( state = asyncio.Queue() await state.put(SimpleProjectItemEvent("mmmocking", "norbiros", SimpleProjectItemEventType.CREATED)) mock_get_post_id.return_value = "1" - mock_retrieve_discord_id.return_value = "niepodam@norbiros.dev" + mock_retrieve_discord_id.return_value = "2137696742041" await process_update(rest_client_mock, 1, 1, forum_channel_mock, state) assert mock_fetch_channel.called @@ -93,7 +93,7 @@ async def test_process_update_archived( mock_get_post_id, mock_retrieve_discord_id, mock_fetch_channel, - _mock_create_message, + mock_create_message, mock_edit_channel, forum_channel_mock, rest_client_mock, @@ -102,10 +102,14 @@ async def test_process_update_archived( state = asyncio.Queue() await state.put(SimpleProjectItemEvent("audacity4", "norbiros", SimpleProjectItemEventType.ARCHIVED)) mock_get_post_id.return_value = "621" - mock_retrieve_discord_id.return_value = "niepodam@norbiros.dev" + user_id = 2137696742041 + mock_retrieve_discord_id.return_value = user_id mock_fetch_channel.return_value = post_mock await process_update(rest_client_mock, 1, 1, forum_channel_mock, state) + mock_create_message.assert_called_with( + post_mock.id, f"Task zarchiwizowany przez: <@{user_id}>.", user_mentions=[user_id] + ) mock_edit_channel.assert_called_with(post_mock.id, archived=True) @@ -118,7 +122,7 @@ async def test_process_update_restored( mock_get_post_id, mock_retrieve_discord_id, mock_fetch_channel, - _mock_create_message, + mock_create_message, mock_edit_channel, forum_channel_mock, rest_client_mock, @@ -127,10 +131,14 @@ async def test_process_update_restored( state = asyncio.Queue() await state.put(SimpleProjectItemEvent("audacity4", "norbiros", SimpleProjectItemEventType.RESTORED)) mock_get_post_id.return_value = "621" - mock_retrieve_discord_id.return_value = "niepodam@norbiros.dev" + user_id = 2137696742041 + mock_retrieve_discord_id.return_value = user_id mock_fetch_channel.return_value = post_mock await process_update(rest_client_mock, 1, 1, forum_channel_mock, state) + mock_create_message.assert_called_with( + post_mock.id, f"Task przywrócony przez: <@{user_id}>.", user_mentions=[user_id] + ) mock_edit_channel.assert_called_with(post_mock.id, archived=False) @@ -202,7 +210,7 @@ async def test_process_update_body( user_id = 2137696742041 mock_retrieve_discord_id.return_value = user_id mock_fetch_channel.return_value = post_mock - message = f"Opis taska zaktualizowany przez <@{user_id}>. Nowy opis: \n{new_body}" + message = f"Opis taska zaktualizowany przez: <@{user_id}>. Nowy opis: \n{new_body}" await process_update(rest_client_mock, 1, 1, forum_channel_mock, state) mock_create_message.assert_called_with(post_mock.id, message, user_mentions=[user_id]) From 4fb37fc2b4e4f2c701f7ac6c543788f2c005a73d Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Tue, 11 Nov 2025 23:27:31 +0100 Subject: [PATCH 06/44] refactor: Return post if found via fetching active or archived threads --- src/bot.py | 14 ++++++++++---- src/tests/test_integration/test_bot.py | 1 + src/tests/test_unit/test_bot.py | 18 +++++++++--------- src/tests/test_unit/test_utils.py | 4 ++-- src/utils/utils.py | 9 ++++----- 5 files changed, 26 insertions(+), 20 deletions(-) diff --git a/src/bot.py b/src/bot.py index 67dffba..1ef00d9 100644 --- a/src/bot.py +++ b/src/bot.py @@ -13,7 +13,7 @@ SimpleProjectItemEvent, ) from src.utils.error import ForumChannelNotFound -from src.utils.logging import bot_info +from src.utils.logging import bot_error, bot_info from src.utils.utils import fetch_forum_channel, get_new_tag, get_post_id, retrieve_discord_id @@ -44,11 +44,11 @@ async def process_update( ): event = await state.get() bot_info(f"Processing event for item: {event.name}") - post_id = await get_post_id(event.name, discord_guild_id, forum_channel_id, client) + post_id_or_post = await get_post_id(event.name, discord_guild_id, forum_channel_id, client) author_discord_id = retrieve_discord_id(event.sender) user_mentions = [author_discord_id] if author_discord_id else [] user_text_mention = f"<@{author_discord_id}>" if author_discord_id else "nieznany użytkownik" - if post_id is None: + if post_id_or_post is None: bot_info(f"Post not found, creating new post for item: {event.name}") message = f"Nowy task stworzony {event.name} przez: {user_text_mention}.>" post: GuildPublicThread = await client.create_forum_post( @@ -58,10 +58,16 @@ async def process_update( auto_archive_duration=10080, user_mentions=user_mentions, ) + elif isinstance(post_id_or_post, int): + post = await client.fetch_channel(post_id_or_post) else: - post = await client.fetch_channel(post_id) + post = post_id_or_post if not isinstance(post, GuildPublicThread): + try: + bot_error(f"Post with ID {post.id} is not a GuildPublicThread.") + except AttributeError: + bot_error(f"Post with ID {post_id_or_post} is not a Discord channel object.") return if isinstance(event, SimpleProjectItemEvent): diff --git a/src/tests/test_integration/test_bot.py b/src/tests/test_integration/test_bot.py index 2ffd896..567d78a 100644 --- a/src/tests/test_integration/test_bot.py +++ b/src/tests/test_integration/test_bot.py @@ -69,6 +69,7 @@ async def test_basic_event_only_creation( mock_fetch_public_archived_threads.return_value = [] mock_create_forum_post.return_value = None mock_shelve_open.return_value = MockShelf({}) + mock_create_forum_post.return_value = "created_forum_post" update_queue = asyncio.Queue() await update_queue.put(ProjectItemEvent(name="Test Item", sender="test_sender")) await run(update_queue, stop_after_one_event=True) diff --git a/src/tests/test_unit/test_bot.py b/src/tests/test_unit/test_bot.py index c4fd54d..6cb769e 100644 --- a/src/tests/test_unit/test_bot.py +++ b/src/tests/test_unit/test_bot.py @@ -77,7 +77,7 @@ async def test_process_update_already_exists( ): state = asyncio.Queue() await state.put(SimpleProjectItemEvent("mmmocking", "norbiros", SimpleProjectItemEventType.CREATED)) - mock_get_post_id.return_value = "1" + mock_get_post_id.return_value = 1 mock_retrieve_discord_id.return_value = "2137696742041" await process_update(rest_client_mock, 1, 1, forum_channel_mock, state) @@ -101,7 +101,7 @@ async def test_process_update_archived( ): state = asyncio.Queue() await state.put(SimpleProjectItemEvent("audacity4", "norbiros", SimpleProjectItemEventType.ARCHIVED)) - mock_get_post_id.return_value = "621" + mock_get_post_id.return_value = 621 user_id = 2137696742041 mock_retrieve_discord_id.return_value = user_id mock_fetch_channel.return_value = post_mock @@ -130,7 +130,7 @@ async def test_process_update_restored( ): state = asyncio.Queue() await state.put(SimpleProjectItemEvent("audacity4", "norbiros", SimpleProjectItemEventType.RESTORED)) - mock_get_post_id.return_value = "621" + mock_get_post_id.return_value = 621 user_id = 2137696742041 mock_retrieve_discord_id.return_value = user_id mock_fetch_channel.return_value = post_mock @@ -157,7 +157,7 @@ async def test_process_update_deleted( ): state = asyncio.Queue() await state.put(SimpleProjectItemEvent("audacity4", "norbiros", SimpleProjectItemEventType.DELETED)) - mock_get_post_id.return_value = "621" + mock_get_post_id.return_value = 621 mock_retrieve_discord_id.return_value = "niepodam@norbiros.dev" mock_fetch_channel.return_value = post_mock @@ -180,7 +180,7 @@ async def test_process_update_assignees( ): state = asyncio.Queue() await state.put(ProjectItemEditedAssignees("audacity4", "norbiros", ["norbiros"])) - mock_get_post_id.return_value = "621" + mock_get_post_id.return_value = 621 user_id = 2137696742041 mock_retrieve_discord_id.return_value = user_id mock_fetch_channel.return_value = post_mock @@ -206,7 +206,7 @@ async def test_process_update_body( state = asyncio.Queue() new_body = "Nowy opis taska" await state.put(ProjectItemEditedBody("audacity4", "norbiros", new_body)) - mock_get_post_id.return_value = "621" + mock_get_post_id.return_value = 621 user_id = 2137696742041 mock_retrieve_discord_id.return_value = user_id mock_fetch_channel.return_value = post_mock @@ -232,7 +232,7 @@ async def test_process_update_title( state = asyncio.Queue() new_title = "Nowy opis taska" await state.put(ProjectItemEditedTitle("audacity4", "norbiros", new_title)) - mock_get_post_id.return_value = "621" + mock_get_post_id.return_value = 621 user_id = 2137696742041 mock_retrieve_discord_id.return_value = user_id mock_fetch_channel.return_value = post_mock @@ -258,7 +258,7 @@ async def test_process_update_single_select( ): state = asyncio.Queue() await state.put(ProjectItemEditedSingleSelect("audacity4", "norbiros", "big", SingleSelectType.SIZE)) - mock_get_post_id.return_value = "621" + mock_get_post_id.return_value = 621 user_id = 2137696742041 mock_retrieve_discord_id.return_value = user_id mock_fetch_channel.return_value = post_mock @@ -287,7 +287,7 @@ async def test_process_update_single_select_tag_unavailable( ): state = asyncio.Queue() await state.put(ProjectItemEditedSingleSelect("audacity4", "norbiros", "big", SingleSelectType.SIZE)) - mock_get_post_id.return_value = "621" + mock_get_post_id.return_value = 621 user_id = 2137696742041 mock_retrieve_discord_id.return_value = user_id mock_fetch_channel.return_value = post_mock diff --git a/src/tests/test_unit/test_utils.py b/src/tests/test_unit/test_utils.py index 79b6860..faab820 100644 --- a/src/tests/test_unit/test_utils.py +++ b/src/tests/test_unit/test_utils.py @@ -172,7 +172,7 @@ async def test_get_post_id_active_thread(mock_shelve_open, mock_fetch_active_thr mock_shelve_open.return_value = mock_shelf mock_fetch_active_threads.return_value = [post_mock] - assert await utils.get_post_id("audacity4", 1, 1, rest_client_mock) == 621 + assert await utils.get_post_id("audacity4", 1, 1, rest_client_mock) == post_mock assert mock_shelf.get("audacity4") == 621 @@ -187,7 +187,7 @@ async def test_get_post_id_archived_thread( mock_fetch_active_threads.return_value = [] mock_fetch_public_archived_threads.return_value = [post_mock] - assert await utils.get_post_id("audacity4", 1, 1, rest_client_mock) == 621 + assert await utils.get_post_id("audacity4", 1, 1, rest_client_mock) == post_mock assert mock_shelf.get("audacity4") == 621 diff --git a/src/utils/utils.py b/src/utils/utils.py index 1c08edb..2ed5e87 100644 --- a/src/utils/utils.py +++ b/src/utils/utils.py @@ -5,7 +5,7 @@ import aiohttp import yaml -from hikari import ForumTag, GuildForumChannel +from hikari import ForumTag, GuildForumChannel, GuildThreadChannel from hikari.impl import RESTClientImpl @@ -156,22 +156,21 @@ async def fetch_single_select_value(item_node_id: str | None, field_name: str | async def get_post_id( name: str, discord_guild_id: int, forum_channel_id: int, rest_client: RESTClientImpl -) -> int | None: +) -> int | GuildThreadChannel | None: with shelve.open("post_id.db") as db: try: post_id: str = db[name] return int(post_id) except KeyError: pass - # todo: return post if found in active threads or in archived threads for thread in await rest_client.fetch_active_threads(discord_guild_id): if thread.name == name: db[name] = thread.id - return thread.id + return thread for thread in await rest_client.fetch_public_archived_threads(forum_channel_id): if thread.name == name: db[name] = thread.id - return thread.id + return thread return None From b6aac0a2e498df7701da89557c11b1a5dee89fd8 Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Wed, 12 Nov 2025 09:16:28 +0100 Subject: [PATCH 07/44] feat: Add CI checks --- .github/workflows/ci.yaml | 41 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .github/workflows/ci.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..aabe828 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,41 @@ +name: CI +on: + push: + branches: [main] + paths: + - .github/workflows/ci.yaml + - pyproject.toml + - uv.lock + - src/** + pull_request: + branches: [main] + paths: + - .github/workflows/ci.yaml + - pyproject.toml + - uv.lock + - src/** + workflow_dispatch: + +jobs: + tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + + - name: Set up Python + run: uv python install + + - name: Install all dependencies + run: uv sync --all-groups + + - name: Run Tests + run: uv run pytest + + - name: Run Lint + run: uv run ruff check + + - name: Run Formatter + run: uv run ruff format --check From 5d3991fda2cf55356bcceffd36fd0100ed64a525 Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Fri, 14 Nov 2025 11:17:52 +0100 Subject: [PATCH 08/44] refactor: Split utils --- src/bot.py | 3 +- src/server.py | 3 +- src/tests/test_e2e.py | 2 +- src/tests/test_integration/test_server.py | 2 +- src/tests/test_unit/test_utils.py | 29 ++++----- src/utils/__init__.py | 43 +++++++++++++ src/utils/discord_rest_client.py | 37 ++++++++++++ src/utils/{utils.py => github_api.py} | 74 ----------------------- 8 files changed, 101 insertions(+), 92 deletions(-) create mode 100644 src/utils/discord_rest_client.py rename src/utils/{utils.py => github_api.py} (57%) diff --git a/src/bot.py b/src/bot.py index 1ef00d9..5be5237 100644 --- a/src/bot.py +++ b/src/bot.py @@ -4,6 +4,7 @@ from hikari import ForumTag, GuildForumChannel, GuildPublicThread, RESTApp, TokenType from hikari.impl import RESTClientImpl +from src.utils import retrieve_discord_id from src.utils.data_types import ( ProjectItemEditedAssignees, ProjectItemEditedBody, @@ -12,9 +13,9 @@ ProjectItemEvent, SimpleProjectItemEvent, ) +from src.utils.discord_rest_client import fetch_forum_channel, get_new_tag, get_post_id from src.utils.error import ForumChannelNotFound from src.utils.logging import bot_error, bot_info -from src.utils.utils import fetch_forum_channel, get_new_tag, get_post_id, retrieve_discord_id async def run(state: asyncio.Queue[ProjectItemEvent], stop_after_one_event: bool = False): diff --git a/src/server.py b/src/server.py index c814561..3a559cf 100644 --- a/src/server.py +++ b/src/server.py @@ -8,6 +8,7 @@ from starlette.responses import JSONResponse from src.bot import run +from src.utils import get_item_name, verify_secret from src.utils.data_types import ( ProjectItemEdited, ProjectItemEditedAssignees, @@ -19,8 +20,8 @@ simple_project_item_from_action_type, single_select_type_from_field_name, ) +from src.utils.github_api import fetch_assignees, fetch_item_name, fetch_single_select_value from src.utils.logging import server_error, server_info, server_warning -from src.utils.utils import fetch_assignees, fetch_item_name, fetch_single_select_value, get_item_name, verify_secret update_queue: asyncio.Queue[ProjectItemEvent] = asyncio.Queue() diff --git a/src/tests/test_e2e.py b/src/tests/test_e2e.py index 791857b..4b8326e 100644 --- a/src/tests/test_e2e.py +++ b/src/tests/test_e2e.py @@ -13,7 +13,7 @@ from src.tests.test_integration.test_bot import RestClientContextManagerMock from src.tests.test_unit.test_bot import post_mock # noqa: F401 from src.tests.test_unit.test_utils import MockShelf, forum_channel_mock, rest_client_mock # noqa: F401 -from src.utils.utils import generate_signature +from src.utils import generate_signature @patch.object(RESTClientImpl, "create_message", new_callable=AsyncMock) diff --git a/src/tests/test_integration/test_server.py b/src/tests/test_integration/test_server.py index 7cee915..98bfe35 100644 --- a/src/tests/test_integration/test_server.py +++ b/src/tests/test_integration/test_server.py @@ -7,7 +7,7 @@ from src.server import app from src.tests.test_unit.test_utils import MockResponse, MockShelf -from src.utils.utils import generate_signature +from src.utils import generate_signature test_client = TestClient(app) diff --git a/src/tests/test_unit/test_utils.py b/src/tests/test_unit/test_utils.py index faab820..bdc6e3f 100644 --- a/src/tests/test_unit/test_utils.py +++ b/src/tests/test_unit/test_utils.py @@ -15,7 +15,8 @@ ) from hikari.impl import EntityFactoryImpl, HTTPSettings, ProxySettings, RESTClientImpl -from src.utils import utils +from src import utils +from src.utils import discord_rest_client, github_api class MockShelf(dict): @@ -95,7 +96,7 @@ async def test_get_item_name_exist_in_db(mock_shelve_open): @patch("shelve.open") -@patch("src.utils.utils.fetch_item_name") +@patch("src.utils.fetch_item_name", new_callable=AsyncMock) async def test_get_item_name_doesnt_exist_in_db(mock_fetch_item_name, mock_shelve_open): mock_shelf = MockShelf({}) mock_shelve_open.return_value = mock_shelf @@ -131,14 +132,14 @@ async def test_fetch_assignees_success(mock_post_request): } mock_post_request.return_value = MockResponse(mock_response) - assert await utils.fetch_assignees("") == ["MDQ6VXNlcjg4MjY4MDYz", "MDQ6VXNlcjg5ODM3NzI0"] + assert await github_api.fetch_assignees("") == ["MDQ6VXNlcjg4MjY4MDYz", "MDQ6VXNlcjg5ODM3NzI0"] @patch.object(ClientSession, "post") async def test_fetch_assignees_none(mock_post_request): mock_post_request.return_value = MockResponse({}) - assert await utils.fetch_assignees("") == [] + assert await github_api.fetch_assignees("") == [] @patch.object(ClientSession, "post") @@ -146,7 +147,7 @@ async def test_fetch_single_select_value_success(mock_post_request): mock_response = {"data": {"node": {"fieldValueByName": {"name": "Dziengiel"}}}} mock_post_request.return_value = MockResponse(mock_response) - assert await utils.fetch_single_select_value("", "Salieri") == "Dziengiel" + assert await github_api.fetch_single_select_value("", "Salieri") == "Dziengiel" @patch.object(ClientSession, "post") @@ -154,7 +155,7 @@ async def test_fetch_single_select_value_none(mock_post_request): mock_response = {} mock_post_request.return_value = MockResponse(mock_response) - assert await utils.fetch_single_select_value("", "Salieri") is None + assert await github_api.fetch_single_select_value("", "Salieri") is None @patch("shelve.open") @@ -162,7 +163,7 @@ async def test_get_post_id_exist_in_db(mock_shelve_open, rest_client_mock): mock_db = {"audacity4": 621} mock_shelve_open.return_value = MockShelf(mock_db) - assert await utils.get_post_id("audacity4", 1, 1, rest_client_mock) == 621 + assert await discord_rest_client.get_post_id("audacity4", 1, 1, rest_client_mock) == 621 @patch.object(RESTClientImpl, "fetch_active_threads", new_callable=AsyncMock) @@ -172,7 +173,7 @@ async def test_get_post_id_active_thread(mock_shelve_open, mock_fetch_active_thr mock_shelve_open.return_value = mock_shelf mock_fetch_active_threads.return_value = [post_mock] - assert await utils.get_post_id("audacity4", 1, 1, rest_client_mock) == post_mock + assert await discord_rest_client.get_post_id("audacity4", 1, 1, rest_client_mock) == post_mock assert mock_shelf.get("audacity4") == 621 @@ -187,7 +188,7 @@ async def test_get_post_id_archived_thread( mock_fetch_active_threads.return_value = [] mock_fetch_public_archived_threads.return_value = [post_mock] - assert await utils.get_post_id("audacity4", 1, 1, rest_client_mock) == post_mock + assert await discord_rest_client.get_post_id("audacity4", 1, 1, rest_client_mock) == post_mock assert mock_shelf.get("audacity4") == 621 @@ -208,35 +209,35 @@ def test_retrieve_discord_id_absent_id(_mock_open_file): async def test_fetch_forum_channel_success(mock_fetch_channel, rest_client_mock, forum_channel_mock): mock_fetch_channel.return_value = forum_channel_mock - assert await utils.fetch_forum_channel(rest_client_mock, 67) == forum_channel_mock + assert await discord_rest_client.fetch_forum_channel(rest_client_mock, 67) == forum_channel_mock @patch.object(RESTClientImpl, "fetch_channel", new_callable=AsyncMock) async def test_fetch_forum_channel_none(mock_fetch_channel, rest_client_mock): mock_fetch_channel.return_value = None - assert await utils.fetch_forum_channel(rest_client_mock, 67) is None + assert await discord_rest_client.fetch_forum_channel(rest_client_mock, 67) is None @patch.object(RESTClientImpl, "fetch_channel", new_callable=AsyncMock) async def test_fetch_forum_channel_not_forum_channel(mock_fetch_channel, rest_client_mock, post_mock): mock_fetch_channel.return_value = post_mock - assert await utils.fetch_forum_channel(rest_client_mock, 67) is None + assert await discord_rest_client.fetch_forum_channel(rest_client_mock, 67) is None def test_get_new_tag_success(): tag1 = ForumTag(id=Snowflake(1), name="enchantment", moderated=False) available_tags = [tag1] - assert utils.get_new_tag("enchantment", available_tags) == tag1 + assert discord_rest_client.get_new_tag("enchantment", available_tags) == tag1 def test_get_new_tag_none(): tag1 = ForumTag(id=Snowflake(1), name="enchantment", moderated=False) available_tags = [tag1] - assert utils.get_new_tag("build", available_tags) is None + assert discord_rest_client.get_new_tag("build", available_tags) is None def test_generate_signature(): diff --git a/src/utils/__init__.py b/src/utils/__init__.py index e69de29..b5b3214 100644 --- a/src/utils/__init__.py +++ b/src/utils/__init__.py @@ -0,0 +1,43 @@ +import hashlib +import hmac +import os +import shelve + +import yaml + +from src.utils.github_api import fetch_item_name + + +async def get_item_name(item_node_id: str) -> str | None: + with shelve.open("item_name_to_node_id.db") as db: + try: + item_name: str = db[item_node_id] + except KeyError: + item_name = await fetch_item_name(item_node_id) + if item_name is None: + return None + db[item_node_id] = item_name + + return item_name + + +def retrieve_discord_id(node_id: str) -> str | None: + with open(os.getenv("GITHUB_ID_TO_DISCORD_ID_MAPPING_PATH", "github_id_to_discord_id_mapping.yaml")) as file: + mapping: dict[str, str] = yaml.load("".join(file.readlines()), Loader=yaml.Loader) + + if mapping is None: + return None + + return mapping.get(node_id, None) + + +def generate_signature(secret: str, payload: bytes) -> str: + hash_object = hmac.new(secret.encode("utf-8"), msg=payload, digestmod=hashlib.sha256) + return f"sha256={hash_object.hexdigest()}" + + +def verify_secret(secret: str, payload: bytes, signature_header: str) -> bool: + if not secret: + return True + expected_signature = generate_signature(secret, payload) + return hmac.compare_digest(expected_signature, signature_header) diff --git a/src/utils/discord_rest_client.py b/src/utils/discord_rest_client.py new file mode 100644 index 0000000..2aa0fde --- /dev/null +++ b/src/utils/discord_rest_client.py @@ -0,0 +1,37 @@ +import shelve + +from hikari import ForumTag, GuildForumChannel, GuildThreadChannel +from hikari.impl import RESTClientImpl + + +async def fetch_forum_channel(client: RESTClientImpl, forum_channel_id: int) -> GuildForumChannel | None: + forum_channel = await client.fetch_channel(forum_channel_id) + if forum_channel is None or not isinstance(forum_channel, GuildForumChannel): + return None + return forum_channel + + +def get_new_tag(new_tag_name: str, available_tags: list[ForumTag]) -> ForumTag | None: + new_tag = next((tag for tag in available_tags if tag.name == new_tag_name), None) + return new_tag + + +async def get_post_id( + name: str, discord_guild_id: int, forum_channel_id: int, rest_client: RESTClientImpl +) -> int | GuildThreadChannel | None: + with shelve.open("post_id.db") as db: + try: + post_id: str = db[name] + return int(post_id) + except KeyError: + pass + for thread in await rest_client.fetch_active_threads(discord_guild_id): + if thread.name == name: + db[name] = thread.id + return thread + for thread in await rest_client.fetch_public_archived_threads(forum_channel_id): + if thread.name == name: + db[name] = thread.id + return thread + + return None diff --git a/src/utils/utils.py b/src/utils/github_api.py similarity index 57% rename from src/utils/utils.py rename to src/utils/github_api.py index 2ed5e87..6932ec7 100644 --- a/src/utils/utils.py +++ b/src/utils/github_api.py @@ -1,25 +1,6 @@ -import hashlib -import hmac import os -import shelve import aiohttp -import yaml -from hikari import ForumTag, GuildForumChannel, GuildThreadChannel -from hikari.impl import RESTClientImpl - - -async def get_item_name(item_node_id: str) -> str | None: - with shelve.open("item_name_to_node_id.db") as db: - try: - item_name: str = db[item_node_id] - except KeyError: - item_name = await fetch_item_name(item_node_id) - if item_name is None: - return None - db[item_node_id] = item_name - - return item_name async def fetch_item_name(item_node_id: str | None) -> str | None: @@ -152,58 +133,3 @@ async def fetch_single_select_value(item_node_id: str | None, field_name: str | return None return name - - -async def get_post_id( - name: str, discord_guild_id: int, forum_channel_id: int, rest_client: RESTClientImpl -) -> int | GuildThreadChannel | None: - with shelve.open("post_id.db") as db: - try: - post_id: str = db[name] - return int(post_id) - except KeyError: - pass - for thread in await rest_client.fetch_active_threads(discord_guild_id): - if thread.name == name: - db[name] = thread.id - return thread - for thread in await rest_client.fetch_public_archived_threads(forum_channel_id): - if thread.name == name: - db[name] = thread.id - return thread - - return None - - -def retrieve_discord_id(node_id: str) -> str | None: - with open(os.getenv("GITHUB_ID_TO_DISCORD_ID_MAPPING_PATH", "github_id_to_discord_id_mapping.yaml")) as file: - mapping: dict[str, str] = yaml.load("".join(file.readlines()), Loader=yaml.Loader) - - if mapping is None: - return None - - return mapping.get(node_id, None) - - -async def fetch_forum_channel(client: RESTClientImpl, forum_channel_id: int) -> GuildForumChannel | None: - forum_channel = await client.fetch_channel(forum_channel_id) - if forum_channel is None or not isinstance(forum_channel, GuildForumChannel): - return None - return forum_channel - - -def get_new_tag(new_tag_name: str, available_tags: list[ForumTag]) -> ForumTag | None: - new_tag = next((tag for tag in available_tags if tag.name == new_tag_name), None) - return new_tag - - -def generate_signature(secret: str, payload: bytes) -> str: - hash_object = hmac.new(secret.encode("utf-8"), msg=payload, digestmod=hashlib.sha256) - return f"sha256={hash_object.hexdigest()}" - - -def verify_secret(secret: str, payload: bytes, signature_header: str) -> bool: - if not secret: - return True - expected_signature = generate_signature(secret, payload) - return hmac.compare_digest(expected_signature, signature_header) From 5e6c4b6f00f26eedffe0d050506806f8fdf92e5a Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Sat, 15 Nov 2025 16:32:10 +0100 Subject: [PATCH 09/44] refactor: Change main branch to `master` --- .github/workflows/ci.yaml | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index aabe828..46f4015 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,19 +1,9 @@ name: CI on: push: - branches: [main] - paths: - - .github/workflows/ci.yaml - - pyproject.toml - - uv.lock - - src/** + branches: [master] pull_request: - branches: [main] - paths: - - .github/workflows/ci.yaml - - pyproject.toml - - uv.lock - - src/** + branches: [master] workflow_dispatch: jobs: From fa78dad6b0ae7f746923a0c7b0e2a2f5430812aa Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Sat, 15 Nov 2025 16:48:16 +0100 Subject: [PATCH 10/44] chore(deps): Update all dependencies and python to version 3.14 --- .pre-commit-config.yaml | 2 +- Dockerfile | 2 +- pyproject.toml | 8 +- src/utils/github_api.py | 6 +- uv.lock | 844 +++++++++++++++++++++------------------- 5 files changed, 449 insertions(+), 413 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 64a4baf..4eb5653 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,7 +2,7 @@ repos: # Run lint - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.9.9 + rev: v0.14.5 hooks: # Run the linter. - id: ruff diff --git a/Dockerfile b/Dockerfile index 8d58bd8..3e34673 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.13-slim-trixie +FROM python:3.14-slim-trixie COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ ADD . /app diff --git a/pyproject.toml b/pyproject.toml index 14314af..42dab67 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,16 +2,16 @@ name = "github-project-discord-bot" version = "1.0.0" description = "Create post on forum channel for github project cards" -requires-python = ">=3.13,<3.14" +requires-python = ">=3.14" dependencies = [ "aiohttp>=3.13.2", "dotenv>=0.9.9", - "fastapi[standard]>=0.121.1", + "fastapi[standard]>=0.121.2", "hikari>=2.5.0", "pre-commit>=4.4.0", - "pytest>=9.0.0", + "pytest>=9.0.1", "pytest-asyncio>=1.3.0", - "ruff>=0.14.4", + "ruff>=0.14.5", "setuptools>=80.9.0", ] diff --git a/src/utils/github_api.py b/src/utils/github_api.py index 6932ec7..1fc862f 100644 --- a/src/utils/github_api.py +++ b/src/utils/github_api.py @@ -39,7 +39,7 @@ async def fetch_item_name(item_node_id: str | None) -> str | None: try: item_name: str | None = response_body["data"]["node"]["content"]["title"] - except (KeyError, AttributeError): + except KeyError, AttributeError: return None return item_name @@ -92,7 +92,7 @@ async def fetch_assignees(item_node_id: str | None) -> list[str]: response_body = await response.json() try: assignees_data = response_body["data"]["node"]["content"]["assignees"]["nodes"] - except (KeyError, AttributeError): + except KeyError, AttributeError: return [] assignees = [assignee.get("id", None) for assignee in assignees_data] @@ -129,7 +129,7 @@ async def fetch_single_select_value(item_node_id: str | None, field_name: str | try: name: str | None = response_body["data"]["node"]["fieldValueByName"]["name"] - except (KeyError, AttributeError): + except KeyError, AttributeError: return None return name diff --git a/uv.lock b/uv.lock index 1ec96a2..087fe79 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = "==3.13.*" +requires-python = ">=3.14" [[package]] name = "aiohappyeyeballs" @@ -26,23 +26,40 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/1c/ce/3b83ebba6b3207a7135e5fcaba49706f8a4b6008153b4e30540c982fae26/aiohttp-3.13.2.tar.gz", hash = "sha256:40176a52c186aefef6eb3cad2cdd30cd06e3afbe88fe8ab2af9c0b90f228daca", size = 7837994, upload-time = "2025-10-28T20:59:39.937Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/78/7e90ca79e5aa39f9694dcfd74f4720782d3c6828113bb1f3197f7e7c4a56/aiohttp-3.13.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7519bdc7dfc1940d201651b52bf5e03f5503bda45ad6eacf64dda98be5b2b6be", size = 732139, upload-time = "2025-10-28T20:57:02.455Z" }, - { url = "https://files.pythonhosted.org/packages/db/ed/1f59215ab6853fbaa5c8495fa6cbc39edfc93553426152b75d82a5f32b76/aiohttp-3.13.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:088912a78b4d4f547a1f19c099d5a506df17eacec3c6f4375e2831ec1d995742", size = 490082, upload-time = "2025-10-28T20:57:04.784Z" }, - { url = "https://files.pythonhosted.org/packages/68/7b/fe0fe0f5e05e13629d893c760465173a15ad0039c0a5b0d0040995c8075e/aiohttp-3.13.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5276807b9de9092af38ed23ce120539ab0ac955547b38563a9ba4f5b07b95293", size = 489035, upload-time = "2025-10-28T20:57:06.894Z" }, - { url = "https://files.pythonhosted.org/packages/d2/04/db5279e38471b7ac801d7d36a57d1230feeee130bbe2a74f72731b23c2b1/aiohttp-3.13.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1237c1375eaef0db4dcd7c2559f42e8af7b87ea7d295b118c60c36a6e61cb811", size = 1720387, upload-time = "2025-10-28T20:57:08.685Z" }, - { url = "https://files.pythonhosted.org/packages/31/07/8ea4326bd7dae2bd59828f69d7fdc6e04523caa55e4a70f4a8725a7e4ed2/aiohttp-3.13.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:96581619c57419c3d7d78703d5b78c1e5e5fc0172d60f555bdebaced82ded19a", size = 1688314, upload-time = "2025-10-28T20:57:10.693Z" }, - { url = "https://files.pythonhosted.org/packages/48/ab/3d98007b5b87ffd519d065225438cc3b668b2f245572a8cb53da5dd2b1bc/aiohttp-3.13.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2713a95b47374169409d18103366de1050fe0ea73db358fc7a7acb2880422d4", size = 1756317, upload-time = "2025-10-28T20:57:12.563Z" }, - { url = "https://files.pythonhosted.org/packages/97/3d/801ca172b3d857fafb7b50c7c03f91b72b867a13abca982ed6b3081774ef/aiohttp-3.13.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:228a1cd556b3caca590e9511a89444925da87d35219a49ab5da0c36d2d943a6a", size = 1858539, upload-time = "2025-10-28T20:57:14.623Z" }, - { url = "https://files.pythonhosted.org/packages/f7/0d/4764669bdf47bd472899b3d3db91fffbe925c8e3038ec591a2fd2ad6a14d/aiohttp-3.13.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac6cde5fba8d7d8c6ac963dbb0256a9854e9fafff52fbcc58fdf819357892c3e", size = 1739597, upload-time = "2025-10-28T20:57:16.399Z" }, - { url = "https://files.pythonhosted.org/packages/c4/52/7bd3c6693da58ba16e657eb904a5b6decfc48ecd06e9ac098591653b1566/aiohttp-3.13.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2bef8237544f4e42878c61cef4e2839fee6346dc60f5739f876a9c50be7fcdb", size = 1555006, upload-time = "2025-10-28T20:57:18.288Z" }, - { url = "https://files.pythonhosted.org/packages/48/30/9586667acec5993b6f41d2ebcf96e97a1255a85f62f3c653110a5de4d346/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:16f15a4eac3bc2d76c45f7ebdd48a65d41b242eb6c31c2245463b40b34584ded", size = 1683220, upload-time = "2025-10-28T20:57:20.241Z" }, - { url = "https://files.pythonhosted.org/packages/71/01/3afe4c96854cfd7b30d78333852e8e851dceaec1c40fd00fec90c6402dd2/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:bb7fb776645af5cc58ab804c58d7eba545a97e047254a52ce89c157b5af6cd0b", size = 1712570, upload-time = "2025-10-28T20:57:22.253Z" }, - { url = "https://files.pythonhosted.org/packages/11/2c/22799d8e720f4697a9e66fd9c02479e40a49de3de2f0bbe7f9f78a987808/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e1b4951125ec10c70802f2cb09736c895861cd39fd9dcb35107b4dc8ae6220b8", size = 1733407, upload-time = "2025-10-28T20:57:24.37Z" }, - { url = "https://files.pythonhosted.org/packages/34/cb/90f15dd029f07cebbd91f8238a8b363978b530cd128488085b5703683594/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:550bf765101ae721ee1d37d8095f47b1f220650f85fe1af37a90ce75bab89d04", size = 1550093, upload-time = "2025-10-28T20:57:26.257Z" }, - { url = "https://files.pythonhosted.org/packages/69/46/12dce9be9d3303ecbf4d30ad45a7683dc63d90733c2d9fe512be6716cd40/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fe91b87fc295973096251e2d25a811388e7d8adf3bd2b97ef6ae78bc4ac6c476", size = 1758084, upload-time = "2025-10-28T20:57:28.349Z" }, - { url = "https://files.pythonhosted.org/packages/f9/c8/0932b558da0c302ffd639fc6362a313b98fdf235dc417bc2493da8394df7/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0c8e31cfcc4592cb200160344b2fb6ae0f9e4effe06c644b5a125d4ae5ebe23", size = 1716987, upload-time = "2025-10-28T20:57:30.233Z" }, - { url = "https://files.pythonhosted.org/packages/5d/8b/f5bd1a75003daed099baec373aed678f2e9b34f2ad40d85baa1368556396/aiohttp-3.13.2-cp313-cp313-win32.whl", hash = "sha256:0740f31a60848d6edb296a0df827473eede90c689b8f9f2a4cdde74889eb2254", size = 425859, upload-time = "2025-10-28T20:57:32.105Z" }, - { url = "https://files.pythonhosted.org/packages/5d/28/a8a9fc6957b2cee8902414e41816b5ab5536ecf43c3b1843c10e82c559b2/aiohttp-3.13.2-cp313-cp313-win_amd64.whl", hash = "sha256:a88d13e7ca367394908f8a276b89d04a3652044612b9a408a0bb22a5ed976a1a", size = 452192, upload-time = "2025-10-28T20:57:34.166Z" }, + { url = "https://files.pythonhosted.org/packages/9b/36/e2abae1bd815f01c957cbf7be817b3043304e1c87bad526292a0410fdcf9/aiohttp-3.13.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:2475391c29230e063ef53a66669b7b691c9bfc3f1426a0f7bcdf1216bdbac38b", size = 735234, upload-time = "2025-10-28T20:57:36.415Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e3/1ee62dde9b335e4ed41db6bba02613295a0d5b41f74a783c142745a12763/aiohttp-3.13.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:f33c8748abef4d8717bb20e8fb1b3e07c6adacb7fd6beaae971a764cf5f30d61", size = 490733, upload-time = "2025-10-28T20:57:38.205Z" }, + { url = "https://files.pythonhosted.org/packages/1a/aa/7a451b1d6a04e8d15a362af3e9b897de71d86feac3babf8894545d08d537/aiohttp-3.13.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ae32f24bbfb7dbb485a24b30b1149e2f200be94777232aeadba3eecece4d0aa4", size = 491303, upload-time = "2025-10-28T20:57:40.122Z" }, + { url = "https://files.pythonhosted.org/packages/57/1e/209958dbb9b01174870f6a7538cd1f3f28274fdbc88a750c238e2c456295/aiohttp-3.13.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d7f02042c1f009ffb70067326ef183a047425bb2ff3bc434ead4dd4a4a66a2b", size = 1717965, upload-time = "2025-10-28T20:57:42.28Z" }, + { url = "https://files.pythonhosted.org/packages/08/aa/6a01848d6432f241416bc4866cae8dc03f05a5a884d2311280f6a09c73d6/aiohttp-3.13.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93655083005d71cd6c072cdab54c886e6570ad2c4592139c3fb967bfc19e4694", size = 1667221, upload-time = "2025-10-28T20:57:44.869Z" }, + { url = "https://files.pythonhosted.org/packages/87/4f/36c1992432d31bbc789fa0b93c768d2e9047ec8c7177e5cd84ea85155f36/aiohttp-3.13.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0db1e24b852f5f664cd728db140cf11ea0e82450471232a394b3d1a540b0f906", size = 1757178, upload-time = "2025-10-28T20:57:47.216Z" }, + { url = "https://files.pythonhosted.org/packages/ac/b4/8e940dfb03b7e0f68a82b88fd182b9be0a65cb3f35612fe38c038c3112cf/aiohttp-3.13.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b009194665bcd128e23eaddef362e745601afa4641930848af4c8559e88f18f9", size = 1838001, upload-time = "2025-10-28T20:57:49.337Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ef/39f3448795499c440ab66084a9db7d20ca7662e94305f175a80f5b7e0072/aiohttp-3.13.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c038a8fdc8103cd51dbd986ecdce141473ffd9775a7a8057a6ed9c3653478011", size = 1716325, upload-time = "2025-10-28T20:57:51.327Z" }, + { url = "https://files.pythonhosted.org/packages/d7/51/b311500ffc860b181c05d91c59a1313bdd05c82960fdd4035a15740d431e/aiohttp-3.13.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66bac29b95a00db411cd758fea0e4b9bdba6d549dfe333f9a945430f5f2cc5a6", size = 1547978, upload-time = "2025-10-28T20:57:53.554Z" }, + { url = "https://files.pythonhosted.org/packages/31/64/b9d733296ef79815226dab8c586ff9e3df41c6aff2e16c06697b2d2e6775/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4ebf9cfc9ba24a74cf0718f04aac2a3bbe745902cc7c5ebc55c0f3b5777ef213", size = 1682042, upload-time = "2025-10-28T20:57:55.617Z" }, + { url = "https://files.pythonhosted.org/packages/3f/30/43d3e0f9d6473a6db7d472104c4eff4417b1e9df01774cb930338806d36b/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a4b88ebe35ce54205c7074f7302bd08a4cb83256a3e0870c72d6f68a3aaf8e49", size = 1680085, upload-time = "2025-10-28T20:57:57.59Z" }, + { url = "https://files.pythonhosted.org/packages/16/51/c709f352c911b1864cfd1087577760ced64b3e5bee2aa88b8c0c8e2e4972/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:98c4fb90bb82b70a4ed79ca35f656f4281885be076f3f970ce315402b53099ae", size = 1728238, upload-time = "2025-10-28T20:57:59.525Z" }, + { url = "https://files.pythonhosted.org/packages/19/e2/19bd4c547092b773caeb48ff5ae4b1ae86756a0ee76c16727fcfd281404b/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:ec7534e63ae0f3759df3a1ed4fa6bc8f75082a924b590619c0dd2f76d7043caa", size = 1544395, upload-time = "2025-10-28T20:58:01.914Z" }, + { url = "https://files.pythonhosted.org/packages/cf/87/860f2803b27dfc5ed7be532832a3498e4919da61299b4a1f8eb89b8ff44d/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5b927cf9b935a13e33644cbed6c8c4b2d0f25b713d838743f8fe7191b33829c4", size = 1742965, upload-time = "2025-10-28T20:58:03.972Z" }, + { url = "https://files.pythonhosted.org/packages/67/7f/db2fc7618925e8c7a601094d5cbe539f732df4fb570740be88ed9e40e99a/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:88d6c017966a78c5265d996c19cdb79235be5e6412268d7e2ce7dee339471b7a", size = 1697585, upload-time = "2025-10-28T20:58:06.189Z" }, + { url = "https://files.pythonhosted.org/packages/0c/07/9127916cb09bb38284db5036036042b7b2c514c8ebaeee79da550c43a6d6/aiohttp-3.13.2-cp314-cp314-win32.whl", hash = "sha256:f7c183e786e299b5d6c49fb43a769f8eb8e04a2726a2bd5887b98b5cc2d67940", size = 431621, upload-time = "2025-10-28T20:58:08.636Z" }, + { url = "https://files.pythonhosted.org/packages/fb/41/554a8a380df6d3a2bba8a7726429a23f4ac62aaf38de43bb6d6cde7b4d4d/aiohttp-3.13.2-cp314-cp314-win_amd64.whl", hash = "sha256:fe242cd381e0fb65758faf5ad96c2e460df6ee5b2de1072fe97e4127927e00b4", size = 457627, upload-time = "2025-10-28T20:58:11Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8e/3824ef98c039d3951cb65b9205a96dd2b20f22241ee17d89c5701557c826/aiohttp-3.13.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:f10d9c0b0188fe85398c61147bbd2a657d616c876863bfeff43376e0e3134673", size = 767360, upload-time = "2025-10-28T20:58:13.358Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0f/6a03e3fc7595421274fa34122c973bde2d89344f8a881b728fa8c774e4f1/aiohttp-3.13.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:e7c952aefdf2460f4ae55c5e9c3e80aa72f706a6317e06020f80e96253b1accd", size = 504616, upload-time = "2025-10-28T20:58:15.339Z" }, + { url = "https://files.pythonhosted.org/packages/c6/aa/ed341b670f1bc8a6f2c6a718353d13b9546e2cef3544f573c6a1ff0da711/aiohttp-3.13.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c20423ce14771d98353d2e25e83591fa75dfa90a3c1848f3d7c68243b4fbded3", size = 509131, upload-time = "2025-10-28T20:58:17.693Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f0/c68dac234189dae5c4bbccc0f96ce0cc16b76632cfc3a08fff180045cfa4/aiohttp-3.13.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e96eb1a34396e9430c19d8338d2ec33015e4a87ef2b4449db94c22412e25ccdf", size = 1864168, upload-time = "2025-10-28T20:58:20.113Z" }, + { url = "https://files.pythonhosted.org/packages/8f/65/75a9a76db8364b5d0e52a0c20eabc5d52297385d9af9c35335b924fafdee/aiohttp-3.13.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:23fb0783bc1a33640036465019d3bba069942616a6a2353c6907d7fe1ccdaf4e", size = 1719200, upload-time = "2025-10-28T20:58:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/f5/55/8df2ed78d7f41d232f6bd3ff866b6f617026551aa1d07e2f03458f964575/aiohttp-3.13.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e1a9bea6244a1d05a4e57c295d69e159a5c50d8ef16aa390948ee873478d9a5", size = 1843497, upload-time = "2025-10-28T20:58:24.672Z" }, + { url = "https://files.pythonhosted.org/packages/e9/e0/94d7215e405c5a02ccb6a35c7a3a6cfff242f457a00196496935f700cde5/aiohttp-3.13.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0a3d54e822688b56e9f6b5816fb3de3a3a64660efac64e4c2dc435230ad23bad", size = 1935703, upload-time = "2025-10-28T20:58:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/0b/78/1eeb63c3f9b2d1015a4c02788fb543141aad0a03ae3f7a7b669b2483f8d4/aiohttp-3.13.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7a653d872afe9f33497215745da7a943d1dc15b728a9c8da1c3ac423af35178e", size = 1792738, upload-time = "2025-10-28T20:58:29.787Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/aaf1eea4c188e51538c04cc568040e3082db263a57086ea74a7d38c39e42/aiohttp-3.13.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:56d36e80d2003fa3fc0207fac644216d8532e9504a785ef9a8fd013f84a42c61", size = 1624061, upload-time = "2025-10-28T20:58:32.529Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c2/3b6034de81fbcc43de8aeb209073a2286dfb50b86e927b4efd81cf848197/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:78cd586d8331fb8e241c2dd6b2f4061778cc69e150514b39a9e28dd050475661", size = 1789201, upload-time = "2025-10-28T20:58:34.618Z" }, + { url = "https://files.pythonhosted.org/packages/c9/38/c15dcf6d4d890217dae79d7213988f4e5fe6183d43893a9cf2fe9e84ca8d/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:20b10bbfbff766294fe99987f7bb3b74fdd2f1a2905f2562132641ad434dcf98", size = 1776868, upload-time = "2025-10-28T20:58:38.835Z" }, + { url = "https://files.pythonhosted.org/packages/04/75/f74fd178ac81adf4f283a74847807ade5150e48feda6aef024403716c30c/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9ec49dff7e2b3c85cdeaa412e9d438f0ecd71676fde61ec57027dd392f00c693", size = 1790660, upload-time = "2025-10-28T20:58:41.507Z" }, + { url = "https://files.pythonhosted.org/packages/e7/80/7368bd0d06b16b3aba358c16b919e9c46cf11587dc572091031b0e9e3ef0/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:94f05348c4406450f9d73d38efb41d669ad6cd90c7ee194810d0eefbfa875a7a", size = 1617548, upload-time = "2025-10-28T20:58:43.674Z" }, + { url = "https://files.pythonhosted.org/packages/7d/4b/a6212790c50483cb3212e507378fbe26b5086d73941e1ec4b56a30439688/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:fa4dcb605c6f82a80c7f95713c2b11c3b8e9893b3ebd2bc9bde93165ed6107be", size = 1817240, upload-time = "2025-10-28T20:58:45.787Z" }, + { url = "https://files.pythonhosted.org/packages/ff/f7/ba5f0ba4ea8d8f3c32850912944532b933acbf0f3a75546b89269b9b7dde/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf00e5db968c3f67eccd2778574cf64d8b27d95b237770aa32400bd7a1ca4f6c", size = 1762334, upload-time = "2025-10-28T20:58:47.936Z" }, + { url = "https://files.pythonhosted.org/packages/7e/83/1a5a1856574588b1cad63609ea9ad75b32a8353ac995d830bf5da9357364/aiohttp-3.13.2-cp314-cp314t-win32.whl", hash = "sha256:d23b5fe492b0805a50d3371e8a728a9134d8de5447dce4c885f5587294750734", size = 464685, upload-time = "2025-10-28T20:58:50.642Z" }, + { url = "https://files.pythonhosted.org/packages/9f/4d/d22668674122c08f4d56972297c51a624e64b3ed1efaa40187607a7cb66e/aiohttp-3.13.2-cp314-cp314t-win_amd64.whl", hash = "sha256:ff0a7b0a82a7ab905cbda74006318d1b12e37c797eb1b0d4eb3e316cf47f658f", size = 498093, upload-time = "2025-10-28T20:58:52.782Z" }, ] [[package]] @@ -77,33 +94,33 @@ wheels = [ [[package]] name = "anyio" -version = "4.10.0" +version = "4.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "sniffio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6", size = 213252, upload-time = "2025-08-04T08:54:26.451Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" }, + { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, ] [[package]] name = "attrs" -version = "25.3.0" +version = "25.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, ] [[package]] name = "certifi" -version = "2025.8.3" +version = "2025.11.12" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" }, + { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, ] [[package]] @@ -117,14 +134,14 @@ wheels = [ [[package]] name = "click" -version = "8.2.1" +version = "8.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" } +sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" }, + { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, ] [[package]] @@ -138,14 +155,14 @@ wheels = [ [[package]] name = "colorlog" -version = "6.9.0" +version = "6.10.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d3/7a/359f4d5df2353f26172b3cc39ea32daa39af8de522205f512f458923e677/colorlog-6.9.0.tar.gz", hash = "sha256:bfba54a1b93b94f54e1f4fe48395725a3d92fd2a4af702f6bd70946bdc0c6ac2", size = 16624, upload-time = "2024-10-29T18:34:51.011Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/61/f083b5ac52e505dfc1c624eafbf8c7589a0d7f32daa398d2e7590efa5fda/colorlog-6.10.1.tar.gz", hash = "sha256:eb4ae5cb65fe7fec7773c2306061a8e63e02efc2c72eba9d27b0fa23c94f1321", size = 17162, upload-time = "2025-10-16T16:14:11.978Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/51/9b208e85196941db2f0654ad0357ca6388ab3ed67efdbfc799f35d1f83aa/colorlog-6.9.0-py3-none-any.whl", hash = "sha256:5906e71acd67cb07a71e779c47c4bcb45fb8c2993eebe9e5adcd6a6f1b283eff", size = 11424, upload-time = "2024-10-29T18:34:49.815Z" }, + { url = "https://files.pythonhosted.org/packages/6d/c1/e419ef3723a074172b68aaa89c9f3de486ed4c2399e2dbd8113a4fdcaf9e/colorlog-6.10.1-py3-none-any.whl", hash = "sha256:2d7e8348291948af66122cff006c9f8da6255d224e7cf8e37d8de2df3bad8c9c", size = 11743, upload-time = "2025-10-16T16:14:10.512Z" }, ] [[package]] @@ -192,7 +209,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.121.1" +version = "0.121.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -200,9 +217,9 @@ dependencies = [ { name = "starlette" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6b/a4/29e1b861fc9017488ed02ff1052feffa40940cb355ed632a8845df84ce84/fastapi-0.121.1.tar.gz", hash = "sha256:b6dba0538fd15dab6fe4d3e5493c3957d8a9e1e9257f56446b5859af66f32441", size = 342523, upload-time = "2025-11-08T21:48:14.068Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/48/f08f264da34cf160db82c62ffb335e838b1fc16cbcc905f474c7d4c815db/fastapi-0.121.2.tar.gz", hash = "sha256:ca8e932b2b823ec1721c641e3669472c855ad9564a2854c9899d904c2848b8b9", size = 342944, upload-time = "2025-11-13T17:05:54.692Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/fd/2e6f7d706899cc08690c5f6641e2ffbfffe019e8f16ce77104caa5730910/fastapi-0.121.1-py3-none-any.whl", hash = "sha256:2c5c7028bc3a58d8f5f09aecd3fd88a000ccc0c5ad627693264181a3c33aa1fc", size = 109192, upload-time = "2025-11-08T21:48:12.458Z" }, + { url = "https://files.pythonhosted.org/packages/eb/23/dfb161e91db7c92727db505dc72a384ee79681fe0603f706f9f9f52c2901/fastapi-0.121.2-py3-none-any.whl", hash = "sha256:f2d80b49a86a846b70cc3a03eb5ea6ad2939298bf6a7fe377aa9cd3dd079d358", size = 109201, upload-time = "2025-11-13T17:05:52.718Z" }, ] [package.optional-dependencies] @@ -217,16 +234,16 @@ standard = [ [[package]] name = "fastapi-cli" -version = "0.0.11" +version = "0.0.16" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "rich-toolkit" }, { name = "typer" }, { name = "uvicorn", extra = ["standard"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/23/08/0af729f6231ebdc17a0356397f966838cbe2efa38529951e24017c7435d5/fastapi_cli-0.0.11.tar.gz", hash = "sha256:4f01d751c14d3d2760339cca0f45e81d816218cae8174d1dc757b5375868cde5", size = 17550, upload-time = "2025-09-09T12:50:38.917Z" } +sdist = { url = "https://files.pythonhosted.org/packages/99/75/9407a6b452be4c988feacec9c9d2f58d8f315162a6c7258d5a649d933ebe/fastapi_cli-0.0.16.tar.gz", hash = "sha256:e8a2a1ecf7a4e062e3b2eec63ae34387d1e142d4849181d936b23c4bdfe29073", size = 19447, upload-time = "2025-11-10T19:01:07.856Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/8f/9e3ad391d1c4183de55c256b481899bbd7bbd06d389e4986741bb289fe94/fastapi_cli-0.0.11-py3-none-any.whl", hash = "sha256:bcdd1123c6077c7466452b9490ca47821f00eb784d58496674793003f9f8e33a", size = 11095, upload-time = "2025-09-09T12:50:37.658Z" }, + { url = "https://files.pythonhosted.org/packages/55/43/678528c19318394320ee43757648d5e0a8070cf391b31f69d931e5c840d2/fastapi_cli-0.0.16-py3-none-any.whl", hash = "sha256:addcb6d130b5b9c91adbbf3f2947fe115991495fdb442fe3e51b5fc6327df9f4", size = 12312, upload-time = "2025-11-10T19:01:06.728Z" }, ] [package.optional-dependencies] @@ -237,7 +254,7 @@ standard = [ [[package]] name = "fastapi-cloud-cli" -version = "0.1.5" +version = "0.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -248,61 +265,59 @@ dependencies = [ { name = "typer" }, { name = "uvicorn", extra = ["standard"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a9/2e/3b6e5016affc310e5109bc580f760586eabecea0c8a7ab067611cd849ac0/fastapi_cloud_cli-0.1.5.tar.gz", hash = "sha256:341ee585eb731a6d3c3656cb91ad38e5f39809bf1a16d41de1333e38635a7937", size = 22710, upload-time = "2025-07-28T13:30:48.216Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/48/0f14d8555b750dc8c04382804e4214f1d7f55298127f3a0237ba566e69dd/fastapi_cloud_cli-0.3.1.tar.gz", hash = "sha256:8c7226c36e92e92d0c89827e8f56dbf164ab2de4444bd33aa26b6c3f7675db69", size = 24080, upload-time = "2025-10-09T11:32:58.174Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/a6/5aa862489a2918a096166fd98d9fe86b7fd53c607678b3fa9d8c432d88d5/fastapi_cloud_cli-0.1.5-py3-none-any.whl", hash = "sha256:d80525fb9c0e8af122370891f9fa83cf5d496e4ad47a8dd26c0496a6c85a012a", size = 18992, upload-time = "2025-07-28T13:30:47.427Z" }, + { url = "https://files.pythonhosted.org/packages/68/79/7f5a5e5513e6a737e5fb089d9c59c74d4d24dc24d581d3aa519b326bedda/fastapi_cloud_cli-0.3.1-py3-none-any.whl", hash = "sha256:7d1a98a77791a9d0757886b2ffbf11bcc6b3be93210dd15064be10b216bf7e00", size = 19711, upload-time = "2025-10-09T11:32:57.118Z" }, ] [[package]] name = "filelock" -version = "3.19.1" +version = "3.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/40/bb/0ab3e58d22305b6f5440629d20683af28959bf793d98d11950e305c1c326/filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58", size = 17687, upload-time = "2025-08-14T16:56:03.016Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/46/0028a82567109b5ef6e4d2a1f04a583fb513e6cf9527fcdd09afd817deeb/filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4", size = 18922, upload-time = "2025-10-08T18:03:50.056Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" }, + { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" }, ] [[package]] name = "frozenlist" -version = "1.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/79/b1/b64018016eeb087db503b038296fd782586432b9c077fc5c7839e9cb6ef6/frozenlist-1.7.0.tar.gz", hash = "sha256:2e310d81923c2437ea8670467121cc3e9b0f76d3043cc1d2331d56c7fb7a3a8f", size = 45078, upload-time = "2025-06-09T23:02:35.538Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/24/90/6b2cebdabdbd50367273c20ff6b57a3dfa89bd0762de02c3a1eb42cb6462/frozenlist-1.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee80eeda5e2a4e660651370ebffd1286542b67e268aa1ac8d6dbe973120ef7ee", size = 79791, upload-time = "2025-06-09T23:01:09.368Z" }, - { url = "https://files.pythonhosted.org/packages/83/2e/5b70b6a3325363293fe5fc3ae74cdcbc3e996c2a11dde2fd9f1fb0776d19/frozenlist-1.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d1a81c85417b914139e3a9b995d4a1c84559afc839a93cf2cb7f15e6e5f6ed2d", size = 47165, upload-time = "2025-06-09T23:01:10.653Z" }, - { url = "https://files.pythonhosted.org/packages/f4/25/a0895c99270ca6966110f4ad98e87e5662eab416a17e7fd53c364bf8b954/frozenlist-1.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cbb65198a9132ebc334f237d7b0df163e4de83fb4f2bdfe46c1e654bdb0c5d43", size = 45881, upload-time = "2025-06-09T23:01:12.296Z" }, - { url = "https://files.pythonhosted.org/packages/19/7c/71bb0bbe0832793c601fff68cd0cf6143753d0c667f9aec93d3c323f4b55/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dab46c723eeb2c255a64f9dc05b8dd601fde66d6b19cdb82b2e09cc6ff8d8b5d", size = 232409, upload-time = "2025-06-09T23:01:13.641Z" }, - { url = "https://files.pythonhosted.org/packages/c0/45/ed2798718910fe6eb3ba574082aaceff4528e6323f9a8570be0f7028d8e9/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6aeac207a759d0dedd2e40745575ae32ab30926ff4fa49b1635def65806fddee", size = 225132, upload-time = "2025-06-09T23:01:15.264Z" }, - { url = "https://files.pythonhosted.org/packages/ba/e2/8417ae0f8eacb1d071d4950f32f229aa6bf68ab69aab797b72a07ea68d4f/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd8c4e58ad14b4fa7802b8be49d47993182fdd4023393899632c88fd8cd994eb", size = 237638, upload-time = "2025-06-09T23:01:16.752Z" }, - { url = "https://files.pythonhosted.org/packages/f8/b7/2ace5450ce85f2af05a871b8c8719b341294775a0a6c5585d5e6170f2ce7/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04fb24d104f425da3540ed83cbfc31388a586a7696142004c577fa61c6298c3f", size = 233539, upload-time = "2025-06-09T23:01:18.202Z" }, - { url = "https://files.pythonhosted.org/packages/46/b9/6989292c5539553dba63f3c83dc4598186ab2888f67c0dc1d917e6887db6/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a5c505156368e4ea6b53b5ac23c92d7edc864537ff911d2fb24c140bb175e60", size = 215646, upload-time = "2025-06-09T23:01:19.649Z" }, - { url = "https://files.pythonhosted.org/packages/72/31/bc8c5c99c7818293458fe745dab4fd5730ff49697ccc82b554eb69f16a24/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bd7eb96a675f18aa5c553eb7ddc24a43c8c18f22e1f9925528128c052cdbe00", size = 232233, upload-time = "2025-06-09T23:01:21.175Z" }, - { url = "https://files.pythonhosted.org/packages/59/52/460db4d7ba0811b9ccb85af996019f5d70831f2f5f255f7cc61f86199795/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:05579bf020096fe05a764f1f84cd104a12f78eaab68842d036772dc6d4870b4b", size = 227996, upload-time = "2025-06-09T23:01:23.098Z" }, - { url = "https://files.pythonhosted.org/packages/ba/c9/f4b39e904c03927b7ecf891804fd3b4df3db29b9e487c6418e37988d6e9d/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:376b6222d114e97eeec13d46c486facd41d4f43bab626b7c3f6a8b4e81a5192c", size = 242280, upload-time = "2025-06-09T23:01:24.808Z" }, - { url = "https://files.pythonhosted.org/packages/b8/33/3f8d6ced42f162d743e3517781566b8481322be321b486d9d262adf70bfb/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0aa7e176ebe115379b5b1c95b4096fb1c17cce0847402e227e712c27bdb5a949", size = 217717, upload-time = "2025-06-09T23:01:26.28Z" }, - { url = "https://files.pythonhosted.org/packages/3e/e8/ad683e75da6ccef50d0ab0c2b2324b32f84fc88ceee778ed79b8e2d2fe2e/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3fbba20e662b9c2130dc771e332a99eff5da078b2b2648153a40669a6d0e36ca", size = 236644, upload-time = "2025-06-09T23:01:27.887Z" }, - { url = "https://files.pythonhosted.org/packages/b2/14/8d19ccdd3799310722195a72ac94ddc677541fb4bef4091d8e7775752360/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f3f4410a0a601d349dd406b5713fec59b4cee7e71678d5b17edda7f4655a940b", size = 238879, upload-time = "2025-06-09T23:01:29.524Z" }, - { url = "https://files.pythonhosted.org/packages/ce/13/c12bf657494c2fd1079a48b2db49fa4196325909249a52d8f09bc9123fd7/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e2cdfaaec6a2f9327bf43c933c0319a7c429058e8537c508964a133dffee412e", size = 232502, upload-time = "2025-06-09T23:01:31.287Z" }, - { url = "https://files.pythonhosted.org/packages/d7/8b/e7f9dfde869825489382bc0d512c15e96d3964180c9499efcec72e85db7e/frozenlist-1.7.0-cp313-cp313-win32.whl", hash = "sha256:5fc4df05a6591c7768459caba1b342d9ec23fa16195e744939ba5914596ae3e1", size = 39169, upload-time = "2025-06-09T23:01:35.503Z" }, - { url = "https://files.pythonhosted.org/packages/35/89/a487a98d94205d85745080a37860ff5744b9820a2c9acbcdd9440bfddf98/frozenlist-1.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:52109052b9791a3e6b5d1b65f4b909703984b770694d3eb64fad124c835d7cba", size = 43219, upload-time = "2025-06-09T23:01:36.784Z" }, - { url = "https://files.pythonhosted.org/packages/56/d5/5c4cf2319a49eddd9dd7145e66c4866bdc6f3dbc67ca3d59685149c11e0d/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a6f86e4193bb0e235ef6ce3dde5cbabed887e0b11f516ce8a0f4d3b33078ec2d", size = 84345, upload-time = "2025-06-09T23:01:38.295Z" }, - { url = "https://files.pythonhosted.org/packages/a4/7d/ec2c1e1dc16b85bc9d526009961953df9cec8481b6886debb36ec9107799/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:82d664628865abeb32d90ae497fb93df398a69bb3434463d172b80fc25b0dd7d", size = 48880, upload-time = "2025-06-09T23:01:39.887Z" }, - { url = "https://files.pythonhosted.org/packages/69/86/f9596807b03de126e11e7d42ac91e3d0b19a6599c714a1989a4e85eeefc4/frozenlist-1.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:912a7e8375a1c9a68325a902f3953191b7b292aa3c3fb0d71a216221deca460b", size = 48498, upload-time = "2025-06-09T23:01:41.318Z" }, - { url = "https://files.pythonhosted.org/packages/5e/cb/df6de220f5036001005f2d726b789b2c0b65f2363b104bbc16f5be8084f8/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9537c2777167488d539bc5de2ad262efc44388230e5118868e172dd4a552b146", size = 292296, upload-time = "2025-06-09T23:01:42.685Z" }, - { url = "https://files.pythonhosted.org/packages/83/1f/de84c642f17c8f851a2905cee2dae401e5e0daca9b5ef121e120e19aa825/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f34560fb1b4c3e30ba35fa9a13894ba39e5acfc5f60f57d8accde65f46cc5e74", size = 273103, upload-time = "2025-06-09T23:01:44.166Z" }, - { url = "https://files.pythonhosted.org/packages/88/3c/c840bfa474ba3fa13c772b93070893c6e9d5c0350885760376cbe3b6c1b3/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acd03d224b0175f5a850edc104ac19040d35419eddad04e7cf2d5986d98427f1", size = 292869, upload-time = "2025-06-09T23:01:45.681Z" }, - { url = "https://files.pythonhosted.org/packages/a6/1c/3efa6e7d5a39a1d5ef0abeb51c48fb657765794a46cf124e5aca2c7a592c/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2038310bc582f3d6a09b3816ab01737d60bf7b1ec70f5356b09e84fb7408ab1", size = 291467, upload-time = "2025-06-09T23:01:47.234Z" }, - { url = "https://files.pythonhosted.org/packages/4f/00/d5c5e09d4922c395e2f2f6b79b9a20dab4b67daaf78ab92e7729341f61f6/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8c05e4c8e5f36e5e088caa1bf78a687528f83c043706640a92cb76cd6999384", size = 266028, upload-time = "2025-06-09T23:01:48.819Z" }, - { url = "https://files.pythonhosted.org/packages/4e/27/72765be905619dfde25a7f33813ac0341eb6b076abede17a2e3fbfade0cb/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:765bb588c86e47d0b68f23c1bee323d4b703218037765dcf3f25c838c6fecceb", size = 284294, upload-time = "2025-06-09T23:01:50.394Z" }, - { url = "https://files.pythonhosted.org/packages/88/67/c94103a23001b17808eb7dd1200c156bb69fb68e63fcf0693dde4cd6228c/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:32dc2e08c67d86d0969714dd484fd60ff08ff81d1a1e40a77dd34a387e6ebc0c", size = 281898, upload-time = "2025-06-09T23:01:52.234Z" }, - { url = "https://files.pythonhosted.org/packages/42/34/a3e2c00c00f9e2a9db5653bca3fec306349e71aff14ae45ecc6d0951dd24/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:c0303e597eb5a5321b4de9c68e9845ac8f290d2ab3f3e2c864437d3c5a30cd65", size = 290465, upload-time = "2025-06-09T23:01:53.788Z" }, - { url = "https://files.pythonhosted.org/packages/bb/73/f89b7fbce8b0b0c095d82b008afd0590f71ccb3dee6eee41791cf8cd25fd/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:a47f2abb4e29b3a8d0b530f7c3598badc6b134562b1a5caee867f7c62fee51e3", size = 266385, upload-time = "2025-06-09T23:01:55.769Z" }, - { url = "https://files.pythonhosted.org/packages/cd/45/e365fdb554159462ca12df54bc59bfa7a9a273ecc21e99e72e597564d1ae/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:3d688126c242a6fabbd92e02633414d40f50bb6002fa4cf995a1d18051525657", size = 288771, upload-time = "2025-06-09T23:01:57.4Z" }, - { url = "https://files.pythonhosted.org/packages/00/11/47b6117002a0e904f004d70ec5194fe9144f117c33c851e3d51c765962d0/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4e7e9652b3d367c7bd449a727dc79d5043f48b88d0cbfd4f9f1060cf2b414104", size = 288206, upload-time = "2025-06-09T23:01:58.936Z" }, - { url = "https://files.pythonhosted.org/packages/40/37/5f9f3c3fd7f7746082ec67bcdc204db72dad081f4f83a503d33220a92973/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1a85e345b4c43db8b842cab1feb41be5cc0b10a1830e6295b69d7310f99becaf", size = 282620, upload-time = "2025-06-09T23:02:00.493Z" }, - { url = "https://files.pythonhosted.org/packages/0b/31/8fbc5af2d183bff20f21aa743b4088eac4445d2bb1cdece449ae80e4e2d1/frozenlist-1.7.0-cp313-cp313t-win32.whl", hash = "sha256:3a14027124ddb70dfcee5148979998066897e79f89f64b13328595c4bdf77c81", size = 43059, upload-time = "2025-06-09T23:02:02.072Z" }, - { url = "https://files.pythonhosted.org/packages/bb/ed/41956f52105b8dbc26e457c5705340c67c8cc2b79f394b79bffc09d0e938/frozenlist-1.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3bf8010d71d4507775f658e9823210b7427be36625b387221642725b515dcf3e", size = 47516, upload-time = "2025-06-09T23:02:03.779Z" }, - { url = "https://files.pythonhosted.org/packages/ee/45/b82e3c16be2182bff01179db177fe144d58b5dc787a7d4492c6ed8b9317f/frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e", size = 13106, upload-time = "2025-06-09T23:02:34.204Z" }, +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] [[package]] @@ -330,13 +345,13 @@ test = [ requires-dist = [ { name = "aiohttp", specifier = ">=3.13.2" }, { name = "dotenv", specifier = ">=0.9.9" }, - { name = "fastapi", extras = ["standard"], specifier = ">=0.121.1" }, + { name = "fastapi", extras = ["standard"], specifier = ">=0.121.2" }, { name = "hikari", specifier = ">=2.5.0" }, { name = "pre-commit", specifier = ">=4.4.0" }, - { name = "pytest", specifier = ">=9.0.0" }, + { name = "pytest", specifier = ">=9.0.1" }, { name = "pytest", marker = "extra == 'test'" }, { name = "pytest-asyncio", specifier = ">=1.3.0" }, - { name = "ruff", specifier = ">=0.14.4" }, + { name = "ruff", specifier = ">=0.14.5" }, { name = "setuptools", specifier = ">=80.9.0" }, ] provides-extras = ["test"] @@ -380,17 +395,17 @@ wheels = [ [[package]] name = "httptools" -version = "0.6.4" +version = "0.7.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a7/9a/ce5e1f7e131522e6d3426e8e7a490b3a01f39a6696602e1c4f33f9e94277/httptools-0.6.4.tar.gz", hash = "sha256:4e93eee4add6493b59a5c514da98c939b244fce4a0d8879cd3f466562f4b7d5c", size = 240639, upload-time = "2024-10-16T19:45:08.902Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/a3/9fe9ad23fd35f7de6b91eeb60848986058bd8b5a5c1e256f5860a160cc3e/httptools-0.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ade273d7e767d5fae13fa637f4d53b6e961fb7fd93c7797562663f0171c26660", size = 197214, upload-time = "2024-10-16T19:44:38.738Z" }, - { url = "https://files.pythonhosted.org/packages/ea/d9/82d5e68bab783b632023f2fa31db20bebb4e89dfc4d2293945fd68484ee4/httptools-0.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:856f4bc0478ae143bad54a4242fccb1f3f86a6e1be5548fecfd4102061b3a083", size = 102431, upload-time = "2024-10-16T19:44:39.818Z" }, - { url = "https://files.pythonhosted.org/packages/96/c1/cb499655cbdbfb57b577734fde02f6fa0bbc3fe9fb4d87b742b512908dff/httptools-0.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:322d20ea9cdd1fa98bd6a74b77e2ec5b818abdc3d36695ab402a0de8ef2865a3", size = 473121, upload-time = "2024-10-16T19:44:41.189Z" }, - { url = "https://files.pythonhosted.org/packages/af/71/ee32fd358f8a3bb199b03261f10921716990808a675d8160b5383487a317/httptools-0.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4d87b29bd4486c0093fc64dea80231f7c7f7eb4dc70ae394d70a495ab8436071", size = 473805, upload-time = "2024-10-16T19:44:42.384Z" }, - { url = "https://files.pythonhosted.org/packages/8a/0a/0d4df132bfca1507114198b766f1737d57580c9ad1cf93c1ff673e3387be/httptools-0.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:342dd6946aa6bda4b8f18c734576106b8a31f2fe31492881a9a160ec84ff4bd5", size = 448858, upload-time = "2024-10-16T19:44:43.959Z" }, - { url = "https://files.pythonhosted.org/packages/1e/6a/787004fdef2cabea27bad1073bf6a33f2437b4dbd3b6fb4a9d71172b1c7c/httptools-0.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b36913ba52008249223042dca46e69967985fb4051951f94357ea681e1f5dc0", size = 452042, upload-time = "2024-10-16T19:44:45.071Z" }, - { url = "https://files.pythonhosted.org/packages/4d/dc/7decab5c404d1d2cdc1bb330b1bf70e83d6af0396fd4fc76fc60c0d522bf/httptools-0.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:28908df1b9bb8187393d5b5db91435ccc9c8e891657f9cbb42a2541b44c82fc8", size = 87682, upload-time = "2024-10-16T19:44:46.46Z" }, + { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, + { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, + { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, ] [[package]] @@ -410,29 +425,29 @@ wheels = [ [[package]] name = "identify" -version = "2.6.14" +version = "2.6.15" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/52/c4/62963f25a678f6a050fb0505a65e9e726996171e6dbe1547f79619eefb15/identify-2.6.14.tar.gz", hash = "sha256:663494103b4f717cb26921c52f8751363dc89db64364cd836a9bf1535f53cd6a", size = 99283, upload-time = "2025-09-06T19:30:52.938Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/ae/2ad30f4652712c82f1c23423d79136fbce338932ad166d70c1efb86a5998/identify-2.6.14-py2.py3-none-any.whl", hash = "sha256:11a073da82212c6646b1f39bb20d4483bfb9543bd5566fec60053c4bb309bf2e", size = 99172, upload-time = "2025-09-06T19:30:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, ] [[package]] name = "idna" -version = "3.10" +version = "3.11" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] [[package]] name = "iniconfig" -version = "2.1.0" +version = "2.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +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/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, + { 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]] @@ -461,30 +476,32 @@ wheels = [ [[package]] name = "markupsafe" -version = "3.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" }, - { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" }, - { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" }, - { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" }, - { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" }, - { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" }, - { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" }, - { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" }, - { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" }, - { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" }, - { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" }, - { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" }, - { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" }, - { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" }, - { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" }, - { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" }, - { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" }, - { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" }, - { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" }, - { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] [[package]] @@ -498,47 +515,47 @@ wheels = [ [[package]] name = "multidict" -version = "6.6.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/69/7f/0652e6ed47ab288e3756ea9c0df8b14950781184d4bd7883f4d87dd41245/multidict-6.6.4.tar.gz", hash = "sha256:d2d4e4787672911b48350df02ed3fa3fffdc2f2e8ca06dd6afdf34189b76a9dd", size = 101843, upload-time = "2025-08-11T12:08:48.217Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/5d/e1db626f64f60008320aab00fbe4f23fc3300d75892a3381275b3d284580/multidict-6.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f46a6e8597f9bd71b31cc708195d42b634c8527fecbcf93febf1052cacc1f16e", size = 75848, upload-time = "2025-08-11T12:07:19.912Z" }, - { url = "https://files.pythonhosted.org/packages/4c/aa/8b6f548d839b6c13887253af4e29c939af22a18591bfb5d0ee6f1931dae8/multidict-6.6.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:22e38b2bc176c5eb9c0a0e379f9d188ae4cd8b28c0f53b52bce7ab0a9e534657", size = 45060, upload-time = "2025-08-11T12:07:21.163Z" }, - { url = "https://files.pythonhosted.org/packages/eb/c6/f5e97e5d99a729bc2aa58eb3ebfa9f1e56a9b517cc38c60537c81834a73f/multidict-6.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5df8afd26f162da59e218ac0eefaa01b01b2e6cd606cffa46608f699539246da", size = 43269, upload-time = "2025-08-11T12:07:22.392Z" }, - { url = "https://files.pythonhosted.org/packages/dc/31/d54eb0c62516776f36fe67f84a732f97e0b0e12f98d5685bebcc6d396910/multidict-6.6.4-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:49517449b58d043023720aa58e62b2f74ce9b28f740a0b5d33971149553d72aa", size = 237158, upload-time = "2025-08-11T12:07:23.636Z" }, - { url = "https://files.pythonhosted.org/packages/c4/1c/8a10c1c25b23156e63b12165a929d8eb49a6ed769fdbefb06e6f07c1e50d/multidict-6.6.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9408439537c5afdca05edd128a63f56a62680f4b3c234301055d7a2000220f", size = 257076, upload-time = "2025-08-11T12:07:25.049Z" }, - { url = "https://files.pythonhosted.org/packages/ad/86/90e20b5771d6805a119e483fd3d1e8393e745a11511aebca41f0da38c3e2/multidict-6.6.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:87a32d20759dc52a9e850fe1061b6e41ab28e2998d44168a8a341b99ded1dba0", size = 240694, upload-time = "2025-08-11T12:07:26.458Z" }, - { url = "https://files.pythonhosted.org/packages/e7/49/484d3e6b535bc0555b52a0a26ba86e4d8d03fd5587d4936dc59ba7583221/multidict-6.6.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:52e3c8d43cdfff587ceedce9deb25e6ae77daba560b626e97a56ddcad3756879", size = 266350, upload-time = "2025-08-11T12:07:27.94Z" }, - { url = "https://files.pythonhosted.org/packages/bf/b4/aa4c5c379b11895083d50021e229e90c408d7d875471cb3abf721e4670d6/multidict-6.6.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ad8850921d3a8d8ff6fbef790e773cecfc260bbfa0566998980d3fa8f520bc4a", size = 267250, upload-time = "2025-08-11T12:07:29.303Z" }, - { url = "https://files.pythonhosted.org/packages/80/e5/5e22c5bf96a64bdd43518b1834c6d95a4922cc2066b7d8e467dae9b6cee6/multidict-6.6.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:497a2954adc25c08daff36f795077f63ad33e13f19bfff7736e72c785391534f", size = 254900, upload-time = "2025-08-11T12:07:30.764Z" }, - { url = "https://files.pythonhosted.org/packages/17/38/58b27fed927c07035abc02befacab42491e7388ca105e087e6e0215ead64/multidict-6.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:024ce601f92d780ca1617ad4be5ac15b501cc2414970ffa2bb2bbc2bd5a68fa5", size = 252355, upload-time = "2025-08-11T12:07:32.205Z" }, - { url = "https://files.pythonhosted.org/packages/d0/a1/dad75d23a90c29c02b5d6f3d7c10ab36c3197613be5d07ec49c7791e186c/multidict-6.6.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a693fc5ed9bdd1c9e898013e0da4dcc640de7963a371c0bd458e50e046bf6438", size = 250061, upload-time = "2025-08-11T12:07:33.623Z" }, - { url = "https://files.pythonhosted.org/packages/b8/1a/ac2216b61c7f116edab6dc3378cca6c70dc019c9a457ff0d754067c58b20/multidict-6.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:190766dac95aab54cae5b152a56520fd99298f32a1266d66d27fdd1b5ac00f4e", size = 249675, upload-time = "2025-08-11T12:07:34.958Z" }, - { url = "https://files.pythonhosted.org/packages/d4/79/1916af833b800d13883e452e8e0977c065c4ee3ab7a26941fbfdebc11895/multidict-6.6.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:34d8f2a5ffdceab9dcd97c7a016deb2308531d5f0fced2bb0c9e1df45b3363d7", size = 261247, upload-time = "2025-08-11T12:07:36.588Z" }, - { url = "https://files.pythonhosted.org/packages/c5/65/d1f84fe08ac44a5fc7391cbc20a7cedc433ea616b266284413fd86062f8c/multidict-6.6.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:59e8d40ab1f5a8597abcef00d04845155a5693b5da00d2c93dbe88f2050f2812", size = 257960, upload-time = "2025-08-11T12:07:39.735Z" }, - { url = "https://files.pythonhosted.org/packages/13/b5/29ec78057d377b195ac2c5248c773703a6b602e132a763e20ec0457e7440/multidict-6.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:467fe64138cfac771f0e949b938c2e1ada2b5af22f39692aa9258715e9ea613a", size = 250078, upload-time = "2025-08-11T12:07:41.525Z" }, - { url = "https://files.pythonhosted.org/packages/c4/0e/7e79d38f70a872cae32e29b0d77024bef7834b0afb406ddae6558d9e2414/multidict-6.6.4-cp313-cp313-win32.whl", hash = "sha256:14616a30fe6d0a48d0a48d1a633ab3b8bec4cf293aac65f32ed116f620adfd69", size = 41708, upload-time = "2025-08-11T12:07:43.405Z" }, - { url = "https://files.pythonhosted.org/packages/9d/34/746696dffff742e97cd6a23da953e55d0ea51fa601fa2ff387b3edcfaa2c/multidict-6.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:40cd05eaeb39e2bc8939451f033e57feaa2ac99e07dbca8afe2be450a4a3b6cf", size = 45912, upload-time = "2025-08-11T12:07:45.082Z" }, - { url = "https://files.pythonhosted.org/packages/c7/87/3bac136181e271e29170d8d71929cdeddeb77f3e8b6a0c08da3a8e9da114/multidict-6.6.4-cp313-cp313-win_arm64.whl", hash = "sha256:f6eb37d511bfae9e13e82cb4d1af36b91150466f24d9b2b8a9785816deb16605", size = 43076, upload-time = "2025-08-11T12:07:46.746Z" }, - { url = "https://files.pythonhosted.org/packages/64/94/0a8e63e36c049b571c9ae41ee301ada29c3fee9643d9c2548d7d558a1d99/multidict-6.6.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:6c84378acd4f37d1b507dfa0d459b449e2321b3ba5f2338f9b085cf7a7ba95eb", size = 82812, upload-time = "2025-08-11T12:07:48.402Z" }, - { url = "https://files.pythonhosted.org/packages/25/1a/be8e369dfcd260d2070a67e65dd3990dd635cbd735b98da31e00ea84cd4e/multidict-6.6.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0e0558693063c75f3d952abf645c78f3c5dfdd825a41d8c4d8156fc0b0da6e7e", size = 48313, upload-time = "2025-08-11T12:07:49.679Z" }, - { url = "https://files.pythonhosted.org/packages/26/5a/dd4ade298674b2f9a7b06a32c94ffbc0497354df8285f27317c66433ce3b/multidict-6.6.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3f8e2384cb83ebd23fd07e9eada8ba64afc4c759cd94817433ab8c81ee4b403f", size = 46777, upload-time = "2025-08-11T12:07:51.318Z" }, - { url = "https://files.pythonhosted.org/packages/89/db/98aa28bc7e071bfba611ac2ae803c24e96dd3a452b4118c587d3d872c64c/multidict-6.6.4-cp313-cp313t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f996b87b420995a9174b2a7c1a8daf7db4750be6848b03eb5e639674f7963773", size = 229321, upload-time = "2025-08-11T12:07:52.965Z" }, - { url = "https://files.pythonhosted.org/packages/c7/bc/01ddda2a73dd9d167bd85d0e8ef4293836a8f82b786c63fb1a429bc3e678/multidict-6.6.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc356250cffd6e78416cf5b40dc6a74f1edf3be8e834cf8862d9ed5265cf9b0e", size = 249954, upload-time = "2025-08-11T12:07:54.423Z" }, - { url = "https://files.pythonhosted.org/packages/06/78/6b7c0f020f9aa0acf66d0ab4eb9f08375bac9a50ff5e3edb1c4ccd59eafc/multidict-6.6.4-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:dadf95aa862714ea468a49ad1e09fe00fcc9ec67d122f6596a8d40caf6cec7d0", size = 228612, upload-time = "2025-08-11T12:07:55.914Z" }, - { url = "https://files.pythonhosted.org/packages/00/44/3faa416f89b2d5d76e9d447296a81521e1c832ad6e40b92f990697b43192/multidict-6.6.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dd57515bebffd8ebd714d101d4c434063322e4fe24042e90ced41f18b6d3395", size = 257528, upload-time = "2025-08-11T12:07:57.371Z" }, - { url = "https://files.pythonhosted.org/packages/05/5f/77c03b89af0fcb16f018f668207768191fb9dcfb5e3361a5e706a11db2c9/multidict-6.6.4-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:967af5f238ebc2eb1da4e77af5492219fbd9b4b812347da39a7b5f5c72c0fa45", size = 256329, upload-time = "2025-08-11T12:07:58.844Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e9/ed750a2a9afb4f8dc6f13dc5b67b514832101b95714f1211cd42e0aafc26/multidict-6.6.4-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a4c6875c37aae9794308ec43e3530e4aa0d36579ce38d89979bbf89582002bb", size = 247928, upload-time = "2025-08-11T12:08:01.037Z" }, - { url = "https://files.pythonhosted.org/packages/1f/b5/e0571bc13cda277db7e6e8a532791d4403dacc9850006cb66d2556e649c0/multidict-6.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7f683a551e92bdb7fac545b9c6f9fa2aebdeefa61d607510b3533286fcab67f5", size = 245228, upload-time = "2025-08-11T12:08:02.96Z" }, - { url = "https://files.pythonhosted.org/packages/f3/a3/69a84b0eccb9824491f06368f5b86e72e4af54c3067c37c39099b6687109/multidict-6.6.4-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:3ba5aaf600edaf2a868a391779f7a85d93bed147854925f34edd24cc70a3e141", size = 235869, upload-time = "2025-08-11T12:08:04.746Z" }, - { url = "https://files.pythonhosted.org/packages/a9/9d/28802e8f9121a6a0804fa009debf4e753d0a59969ea9f70be5f5fdfcb18f/multidict-6.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:580b643b7fd2c295d83cad90d78419081f53fd532d1f1eb67ceb7060f61cff0d", size = 243446, upload-time = "2025-08-11T12:08:06.332Z" }, - { url = "https://files.pythonhosted.org/packages/38/ea/6c98add069b4878c1d66428a5f5149ddb6d32b1f9836a826ac764b9940be/multidict-6.6.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:37b7187197da6af3ee0b044dbc9625afd0c885f2800815b228a0e70f9a7f473d", size = 252299, upload-time = "2025-08-11T12:08:07.931Z" }, - { url = "https://files.pythonhosted.org/packages/3a/09/8fe02d204473e14c0af3affd50af9078839dfca1742f025cca765435d6b4/multidict-6.6.4-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e1b93790ed0bc26feb72e2f08299691ceb6da5e9e14a0d13cc74f1869af327a0", size = 246926, upload-time = "2025-08-11T12:08:09.467Z" }, - { url = "https://files.pythonhosted.org/packages/37/3d/7b1e10d774a6df5175ecd3c92bff069e77bed9ec2a927fdd4ff5fe182f67/multidict-6.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a506a77ddee1efcca81ecbeae27ade3e09cdf21a8ae854d766c2bb4f14053f92", size = 243383, upload-time = "2025-08-11T12:08:10.981Z" }, - { url = "https://files.pythonhosted.org/packages/50/b0/a6fae46071b645ae98786ab738447de1ef53742eaad949f27e960864bb49/multidict-6.6.4-cp313-cp313t-win32.whl", hash = "sha256:f93b2b2279883d1d0a9e1bd01f312d6fc315c5e4c1f09e112e4736e2f650bc4e", size = 47775, upload-time = "2025-08-11T12:08:12.439Z" }, - { url = "https://files.pythonhosted.org/packages/b2/0a/2436550b1520091af0600dff547913cb2d66fbac27a8c33bc1b1bccd8d98/multidict-6.6.4-cp313-cp313t-win_amd64.whl", hash = "sha256:6d46a180acdf6e87cc41dc15d8f5c2986e1e8739dc25dbb7dac826731ef381a4", size = 53100, upload-time = "2025-08-11T12:08:13.823Z" }, - { url = "https://files.pythonhosted.org/packages/97/ea/43ac51faff934086db9c072a94d327d71b7d8b40cd5dcb47311330929ef0/multidict-6.6.4-cp313-cp313t-win_arm64.whl", hash = "sha256:756989334015e3335d087a27331659820d53ba432befdef6a718398b0a8493ad", size = 45501, upload-time = "2025-08-11T12:08:15.173Z" }, - { url = "https://files.pythonhosted.org/packages/fd/69/b547032297c7e63ba2af494edba695d781af8a0c6e89e4d06cf848b21d80/multidict-6.6.4-py3-none-any.whl", hash = "sha256:27d8f8e125c07cb954e54d75d04905a9bba8a439c1d84aca94949d4d03d8601c", size = 12313, upload-time = "2025-08-11T12:08:46.891Z" }, +version = "6.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/80/1e/5492c365f222f907de1039b91f922b93fa4f764c713ee858d235495d8f50/multidict-6.7.0.tar.gz", hash = "sha256:c6e99d9a65ca282e578dfea819cfa9c0a62b2499d8677392e09feaf305e9e6f5", size = 101834, upload-time = "2025-10-06T14:52:30.657Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/b1/3da6934455dd4b261d4c72f897e3a5728eba81db59959f3a639245891baa/multidict-6.7.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3bab1e4aff7adaa34410f93b1f8e57c4b36b9af0426a76003f441ee1d3c7e842", size = 75128, upload-time = "2025-10-06T14:50:51.92Z" }, + { url = "https://files.pythonhosted.org/packages/14/2c/f069cab5b51d175a1a2cb4ccdf7a2c2dabd58aa5bd933fa036a8d15e2404/multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b8512bac933afc3e45fb2b18da8e59b78d4f408399a960339598374d4ae3b56b", size = 44410, upload-time = "2025-10-06T14:50:53.275Z" }, + { url = "https://files.pythonhosted.org/packages/42/e2/64bb41266427af6642b6b128e8774ed84c11b80a90702c13ac0a86bb10cc/multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:79dcf9e477bc65414ebfea98ffd013cb39552b5ecd62908752e0e413d6d06e38", size = 43205, upload-time = "2025-10-06T14:50:54.911Z" }, + { url = "https://files.pythonhosted.org/packages/02/68/6b086fef8a3f1a8541b9236c594f0c9245617c29841f2e0395d979485cde/multidict-6.7.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:31bae522710064b5cbeddaf2e9f32b1abab70ac6ac91d42572502299e9953128", size = 245084, upload-time = "2025-10-06T14:50:56.369Z" }, + { url = "https://files.pythonhosted.org/packages/15/ee/f524093232007cd7a75c1d132df70f235cfd590a7c9eaccd7ff422ef4ae8/multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a0df7ff02397bb63e2fd22af2c87dfa39e8c7f12947bc524dbdc528282c7e34", size = 252667, upload-time = "2025-10-06T14:50:57.991Z" }, + { url = "https://files.pythonhosted.org/packages/02/a5/eeb3f43ab45878f1895118c3ef157a480db58ede3f248e29b5354139c2c9/multidict-6.7.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a0222514e8e4c514660e182d5156a415c13ef0aabbd71682fc714e327b95e99", size = 233590, upload-time = "2025-10-06T14:50:59.589Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/76d02f8270b97269d7e3dbd45644b1785bda457b474315f8cf999525a193/multidict-6.7.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2397ab4daaf2698eb51a76721e98db21ce4f52339e535725de03ea962b5a3202", size = 264112, upload-time = "2025-10-06T14:51:01.183Z" }, + { url = "https://files.pythonhosted.org/packages/76/0b/c28a70ecb58963847c2a8efe334904cd254812b10e535aefb3bcce513918/multidict-6.7.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8891681594162635948a636c9fe0ff21746aeb3dd5463f6e25d9bea3a8a39ca1", size = 261194, upload-time = "2025-10-06T14:51:02.794Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/2ab26e4209773223159b83aa32721b4021ffb08102f8ac7d689c943fded1/multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18706cc31dbf402a7945916dd5cddf160251b6dab8a2c5f3d6d5a55949f676b3", size = 248510, upload-time = "2025-10-06T14:51:04.724Z" }, + { url = "https://files.pythonhosted.org/packages/93/cd/06c1fa8282af1d1c46fd55c10a7930af652afdce43999501d4d68664170c/multidict-6.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f844a1bbf1d207dd311a56f383f7eda2d0e134921d45751842d8235e7778965d", size = 248395, upload-time = "2025-10-06T14:51:06.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/ac/82cb419dd6b04ccf9e7e61befc00c77614fc8134362488b553402ecd55ce/multidict-6.7.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4393e3581e84e5645506923816b9cc81f5609a778c7e7534054091acc64d1c6", size = 239520, upload-time = "2025-10-06T14:51:08.091Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f3/a0f9bf09493421bd8716a362e0cd1d244f5a6550f5beffdd6b47e885b331/multidict-6.7.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fbd18dc82d7bf274b37aa48d664534330af744e03bccf696d6f4c6042e7d19e7", size = 245479, upload-time = "2025-10-06T14:51:10.365Z" }, + { url = "https://files.pythonhosted.org/packages/8d/01/476d38fc73a212843f43c852b0eee266b6971f0e28329c2184a8df90c376/multidict-6.7.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b6234e14f9314731ec45c42fc4554b88133ad53a09092cc48a88e771c125dadb", size = 258903, upload-time = "2025-10-06T14:51:12.466Z" }, + { url = "https://files.pythonhosted.org/packages/49/6d/23faeb0868adba613b817d0e69c5f15531b24d462af8012c4f6de4fa8dc3/multidict-6.7.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:08d4379f9744d8f78d98c8673c06e202ffa88296f009c71bbafe8a6bf847d01f", size = 252333, upload-time = "2025-10-06T14:51:14.48Z" }, + { url = "https://files.pythonhosted.org/packages/1e/cc/48d02ac22b30fa247f7dad82866e4b1015431092f4ba6ebc7e77596e0b18/multidict-6.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fe04da3f79387f450fd0061d4dd2e45a72749d31bf634aecc9e27f24fdc4b3f", size = 243411, upload-time = "2025-10-06T14:51:16.072Z" }, + { url = "https://files.pythonhosted.org/packages/4a/03/29a8bf5a18abf1fe34535c88adbdfa88c9fb869b5a3b120692c64abe8284/multidict-6.7.0-cp314-cp314-win32.whl", hash = "sha256:fbafe31d191dfa7c4c51f7a6149c9fb7e914dcf9ffead27dcfd9f1ae382b3885", size = 40940, upload-time = "2025-10-06T14:51:17.544Z" }, + { url = "https://files.pythonhosted.org/packages/82/16/7ed27b680791b939de138f906d5cf2b4657b0d45ca6f5dd6236fdddafb1a/multidict-6.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:2f67396ec0310764b9222a1728ced1ab638f61aadc6226f17a71dd9324f9a99c", size = 45087, upload-time = "2025-10-06T14:51:18.875Z" }, + { url = "https://files.pythonhosted.org/packages/cd/3c/e3e62eb35a1950292fe39315d3c89941e30a9d07d5d2df42965ab041da43/multidict-6.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:ba672b26069957ee369cfa7fc180dde1fc6f176eaf1e6beaf61fbebbd3d9c000", size = 42368, upload-time = "2025-10-06T14:51:20.225Z" }, + { url = "https://files.pythonhosted.org/packages/8b/40/cd499bd0dbc5f1136726db3153042a735fffd0d77268e2ee20d5f33c010f/multidict-6.7.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:c1dcc7524066fa918c6a27d61444d4ee7900ec635779058571f70d042d86ed63", size = 82326, upload-time = "2025-10-06T14:51:21.588Z" }, + { url = "https://files.pythonhosted.org/packages/13/8a/18e031eca251c8df76daf0288e6790561806e439f5ce99a170b4af30676b/multidict-6.7.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:27e0b36c2d388dc7b6ced3406671b401e84ad7eb0656b8f3a2f46ed0ce483718", size = 48065, upload-time = "2025-10-06T14:51:22.93Z" }, + { url = "https://files.pythonhosted.org/packages/40/71/5e6701277470a87d234e433fb0a3a7deaf3bcd92566e421e7ae9776319de/multidict-6.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a7baa46a22e77f0988e3b23d4ede5513ebec1929e34ee9495be535662c0dfe2", size = 46475, upload-time = "2025-10-06T14:51:24.352Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6a/bab00cbab6d9cfb57afe1663318f72ec28289ea03fd4e8236bb78429893a/multidict-6.7.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bf77f54997a9166a2f5675d1201520586439424c2511723a7312bdb4bcc034e", size = 239324, upload-time = "2025-10-06T14:51:25.822Z" }, + { url = "https://files.pythonhosted.org/packages/2a/5f/8de95f629fc22a7769ade8b41028e3e5a822c1f8904f618d175945a81ad3/multidict-6.7.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e011555abada53f1578d63389610ac8a5400fc70ce71156b0aa30d326f1a5064", size = 246877, upload-time = "2025-10-06T14:51:27.604Z" }, + { url = "https://files.pythonhosted.org/packages/23/b4/38881a960458f25b89e9f4a4fdcb02ac101cfa710190db6e5528841e67de/multidict-6.7.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:28b37063541b897fd6a318007373930a75ca6d6ac7c940dbe14731ffdd8d498e", size = 225824, upload-time = "2025-10-06T14:51:29.664Z" }, + { url = "https://files.pythonhosted.org/packages/1e/39/6566210c83f8a261575f18e7144736059f0c460b362e96e9cf797a24b8e7/multidict-6.7.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05047ada7a2fde2631a0ed706f1fd68b169a681dfe5e4cf0f8e4cb6618bbc2cd", size = 253558, upload-time = "2025-10-06T14:51:31.684Z" }, + { url = "https://files.pythonhosted.org/packages/00/a3/67f18315100f64c269f46e6c0319fa87ba68f0f64f2b8e7fd7c72b913a0b/multidict-6.7.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:716133f7d1d946a4e1b91b1756b23c088881e70ff180c24e864c26192ad7534a", size = 252339, upload-time = "2025-10-06T14:51:33.699Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2a/1cb77266afee2458d82f50da41beba02159b1d6b1f7973afc9a1cad1499b/multidict-6.7.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1bed1b467ef657f2a0ae62844a607909ef1c6889562de5e1d505f74457d0b96", size = 244895, upload-time = "2025-10-06T14:51:36.189Z" }, + { url = "https://files.pythonhosted.org/packages/dd/72/09fa7dd487f119b2eb9524946ddd36e2067c08510576d43ff68469563b3b/multidict-6.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca43bdfa5d37bd6aee89d85e1d0831fb86e25541be7e9d376ead1b28974f8e5e", size = 241862, upload-time = "2025-10-06T14:51:41.291Z" }, + { url = "https://files.pythonhosted.org/packages/65/92/bc1f8bd0853d8669300f732c801974dfc3702c3eeadae2f60cef54dc69d7/multidict-6.7.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:44b546bd3eb645fd26fb949e43c02a25a2e632e2ca21a35e2e132c8105dc8599", size = 232376, upload-time = "2025-10-06T14:51:43.55Z" }, + { url = "https://files.pythonhosted.org/packages/09/86/ac39399e5cb9d0c2ac8ef6e10a768e4d3bc933ac808d49c41f9dc23337eb/multidict-6.7.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a6ef16328011d3f468e7ebc326f24c1445f001ca1dec335b2f8e66bed3006394", size = 240272, upload-time = "2025-10-06T14:51:45.265Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b6/fed5ac6b8563ec72df6cb1ea8dac6d17f0a4a1f65045f66b6d3bf1497c02/multidict-6.7.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5aa873cbc8e593d361ae65c68f85faadd755c3295ea2c12040ee146802f23b38", size = 248774, upload-time = "2025-10-06T14:51:46.836Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8d/b954d8c0dc132b68f760aefd45870978deec6818897389dace00fcde32ff/multidict-6.7.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3d7b6ccce016e29df4b7ca819659f516f0bc7a4b3efa3bb2012ba06431b044f9", size = 242731, upload-time = "2025-10-06T14:51:48.541Z" }, + { url = "https://files.pythonhosted.org/packages/16/9d/a2dac7009125d3540c2f54e194829ea18ac53716c61b655d8ed300120b0f/multidict-6.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:171b73bd4ee683d307599b66793ac80981b06f069b62eea1c9e29c9241aa66b0", size = 240193, upload-time = "2025-10-06T14:51:50.355Z" }, + { url = "https://files.pythonhosted.org/packages/39/ca/c05f144128ea232ae2178b008d5011d4e2cea86e4ee8c85c2631b1b94802/multidict-6.7.0-cp314-cp314t-win32.whl", hash = "sha256:b2d7f80c4e1fd010b07cb26820aae86b7e73b681ee4889684fb8d2d4537aab13", size = 48023, upload-time = "2025-10-06T14:51:51.883Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8f/0a60e501584145588be1af5cc829265701ba3c35a64aec8e07cbb71d39bb/multidict-6.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:09929cab6fcb68122776d575e03c6cc64ee0b8fca48d17e135474b042ce515cd", size = 53507, upload-time = "2025-10-06T14:51:53.672Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ae/3148b988a9c6239903e786eac19c889fab607c31d6efa7fb2147e5680f23/multidict-6.7.0-cp314-cp314t-win_arm64.whl", hash = "sha256:cc41db090ed742f32bd2d2c721861725e6109681eddf835d0a82bd3a5c382827", size = 44804, upload-time = "2025-10-06T14:51:55.415Z" }, + { url = "https://files.pythonhosted.org/packages/b7/da/7d22601b625e241d4f23ef1ebff8acfc60da633c9e7e7922e24d10f592b3/multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3", size = 12317, upload-time = "2025-10-06T14:52:29.272Z" }, ] [[package]] @@ -561,11 +578,11 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.4.0" +version = "4.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" }, + { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, ] [[package]] @@ -595,48 +612,46 @@ wheels = [ [[package]] name = "propcache" -version = "0.3.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a6/16/43264e4a779dd8588c21a70f0709665ee8f611211bdd2c87d952cfa7c776/propcache-0.3.2.tar.gz", hash = "sha256:20d7d62e4e7ef05f221e0db2856b979540686342e7dd9973b815599c7057e168", size = 44139, upload-time = "2025-06-09T22:56:06.081Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/d1/8c747fafa558c603c4ca19d8e20b288aa0c7cda74e9402f50f31eb65267e/propcache-0.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ca592ed634a73ca002967458187109265e980422116c0a107cf93d81f95af945", size = 71286, upload-time = "2025-06-09T22:54:54.369Z" }, - { url = "https://files.pythonhosted.org/packages/61/99/d606cb7986b60d89c36de8a85d58764323b3a5ff07770a99d8e993b3fa73/propcache-0.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ecb0aad4020e275652ba3975740f241bd12a61f1a784df044cf7477a02bc252", size = 42425, upload-time = "2025-06-09T22:54:55.642Z" }, - { url = "https://files.pythonhosted.org/packages/8c/96/ef98f91bbb42b79e9bb82bdd348b255eb9d65f14dbbe3b1594644c4073f7/propcache-0.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7f08f1cc28bd2eade7a8a3d2954ccc673bb02062e3e7da09bc75d843386b342f", size = 41846, upload-time = "2025-06-09T22:54:57.246Z" }, - { url = "https://files.pythonhosted.org/packages/5b/ad/3f0f9a705fb630d175146cd7b1d2bf5555c9beaed54e94132b21aac098a6/propcache-0.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1a342c834734edb4be5ecb1e9fb48cb64b1e2320fccbd8c54bf8da8f2a84c33", size = 208871, upload-time = "2025-06-09T22:54:58.975Z" }, - { url = "https://files.pythonhosted.org/packages/3a/38/2085cda93d2c8b6ec3e92af2c89489a36a5886b712a34ab25de9fbca7992/propcache-0.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a544caaae1ac73f1fecfae70ded3e93728831affebd017d53449e3ac052ac1e", size = 215720, upload-time = "2025-06-09T22:55:00.471Z" }, - { url = "https://files.pythonhosted.org/packages/61/c1/d72ea2dc83ac7f2c8e182786ab0fc2c7bd123a1ff9b7975bee671866fe5f/propcache-0.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:310d11aa44635298397db47a3ebce7db99a4cc4b9bbdfcf6c98a60c8d5261cf1", size = 215203, upload-time = "2025-06-09T22:55:01.834Z" }, - { url = "https://files.pythonhosted.org/packages/af/81/b324c44ae60c56ef12007105f1460d5c304b0626ab0cc6b07c8f2a9aa0b8/propcache-0.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c1396592321ac83157ac03a2023aa6cc4a3cc3cfdecb71090054c09e5a7cce3", size = 206365, upload-time = "2025-06-09T22:55:03.199Z" }, - { url = "https://files.pythonhosted.org/packages/09/73/88549128bb89e66d2aff242488f62869014ae092db63ccea53c1cc75a81d/propcache-0.3.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cabf5b5902272565e78197edb682017d21cf3b550ba0460ee473753f28d23c1", size = 196016, upload-time = "2025-06-09T22:55:04.518Z" }, - { url = "https://files.pythonhosted.org/packages/b9/3f/3bdd14e737d145114a5eb83cb172903afba7242f67c5877f9909a20d948d/propcache-0.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0a2f2235ac46a7aa25bdeb03a9e7060f6ecbd213b1f9101c43b3090ffb971ef6", size = 205596, upload-time = "2025-06-09T22:55:05.942Z" }, - { url = "https://files.pythonhosted.org/packages/0f/ca/2f4aa819c357d3107c3763d7ef42c03980f9ed5c48c82e01e25945d437c1/propcache-0.3.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:92b69e12e34869a6970fd2f3da91669899994b47c98f5d430b781c26f1d9f387", size = 200977, upload-time = "2025-06-09T22:55:07.792Z" }, - { url = "https://files.pythonhosted.org/packages/cd/4a/e65276c7477533c59085251ae88505caf6831c0e85ff8b2e31ebcbb949b1/propcache-0.3.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:54e02207c79968ebbdffc169591009f4474dde3b4679e16634d34c9363ff56b4", size = 197220, upload-time = "2025-06-09T22:55:09.173Z" }, - { url = "https://files.pythonhosted.org/packages/7c/54/fc7152e517cf5578278b242396ce4d4b36795423988ef39bb8cd5bf274c8/propcache-0.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4adfb44cb588001f68c5466579d3f1157ca07f7504fc91ec87862e2b8e556b88", size = 210642, upload-time = "2025-06-09T22:55:10.62Z" }, - { url = "https://files.pythonhosted.org/packages/b9/80/abeb4a896d2767bf5f1ea7b92eb7be6a5330645bd7fb844049c0e4045d9d/propcache-0.3.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fd3e6019dc1261cd0291ee8919dd91fbab7b169bb76aeef6c716833a3f65d206", size = 212789, upload-time = "2025-06-09T22:55:12.029Z" }, - { url = "https://files.pythonhosted.org/packages/b3/db/ea12a49aa7b2b6d68a5da8293dcf50068d48d088100ac016ad92a6a780e6/propcache-0.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4c181cad81158d71c41a2bce88edce078458e2dd5ffee7eddd6b05da85079f43", size = 205880, upload-time = "2025-06-09T22:55:13.45Z" }, - { url = "https://files.pythonhosted.org/packages/d1/e5/9076a0bbbfb65d1198007059c65639dfd56266cf8e477a9707e4b1999ff4/propcache-0.3.2-cp313-cp313-win32.whl", hash = "sha256:8a08154613f2249519e549de2330cf8e2071c2887309a7b07fb56098f5170a02", size = 37220, upload-time = "2025-06-09T22:55:15.284Z" }, - { url = "https://files.pythonhosted.org/packages/d3/f5/b369e026b09a26cd77aa88d8fffd69141d2ae00a2abaaf5380d2603f4b7f/propcache-0.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e41671f1594fc4ab0a6dec1351864713cb3a279910ae8b58f884a88a0a632c05", size = 40678, upload-time = "2025-06-09T22:55:16.445Z" }, - { url = "https://files.pythonhosted.org/packages/a4/3a/6ece377b55544941a08d03581c7bc400a3c8cd3c2865900a68d5de79e21f/propcache-0.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9a3cf035bbaf035f109987d9d55dc90e4b0e36e04bbbb95af3055ef17194057b", size = 76560, upload-time = "2025-06-09T22:55:17.598Z" }, - { url = "https://files.pythonhosted.org/packages/0c/da/64a2bb16418740fa634b0e9c3d29edff1db07f56d3546ca2d86ddf0305e1/propcache-0.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:156c03d07dc1323d8dacaa221fbe028c5c70d16709cdd63502778e6c3ccca1b0", size = 44676, upload-time = "2025-06-09T22:55:18.922Z" }, - { url = "https://files.pythonhosted.org/packages/36/7b/f025e06ea51cb72c52fb87e9b395cced02786610b60a3ed51da8af017170/propcache-0.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74413c0ba02ba86f55cf60d18daab219f7e531620c15f1e23d95563f505efe7e", size = 44701, upload-time = "2025-06-09T22:55:20.106Z" }, - { url = "https://files.pythonhosted.org/packages/a4/00/faa1b1b7c3b74fc277f8642f32a4c72ba1d7b2de36d7cdfb676db7f4303e/propcache-0.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f066b437bb3fa39c58ff97ab2ca351db465157d68ed0440abecb21715eb24b28", size = 276934, upload-time = "2025-06-09T22:55:21.5Z" }, - { url = "https://files.pythonhosted.org/packages/74/ab/935beb6f1756e0476a4d5938ff44bf0d13a055fed880caf93859b4f1baf4/propcache-0.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1304b085c83067914721e7e9d9917d41ad87696bf70f0bc7dee450e9c71ad0a", size = 278316, upload-time = "2025-06-09T22:55:22.918Z" }, - { url = "https://files.pythonhosted.org/packages/f8/9d/994a5c1ce4389610838d1caec74bdf0e98b306c70314d46dbe4fcf21a3e2/propcache-0.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab50cef01b372763a13333b4e54021bdcb291fc9a8e2ccb9c2df98be51bcde6c", size = 282619, upload-time = "2025-06-09T22:55:24.651Z" }, - { url = "https://files.pythonhosted.org/packages/2b/00/a10afce3d1ed0287cef2e09506d3be9822513f2c1e96457ee369adb9a6cd/propcache-0.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fad3b2a085ec259ad2c2842666b2a0a49dea8463579c606426128925af1ed725", size = 265896, upload-time = "2025-06-09T22:55:26.049Z" }, - { url = "https://files.pythonhosted.org/packages/2e/a8/2aa6716ffa566ca57c749edb909ad27884680887d68517e4be41b02299f3/propcache-0.3.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:261fa020c1c14deafd54c76b014956e2f86991af198c51139faf41c4d5e83892", size = 252111, upload-time = "2025-06-09T22:55:27.381Z" }, - { url = "https://files.pythonhosted.org/packages/36/4f/345ca9183b85ac29c8694b0941f7484bf419c7f0fea2d1e386b4f7893eed/propcache-0.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:46d7f8aa79c927e5f987ee3a80205c987717d3659f035c85cf0c3680526bdb44", size = 268334, upload-time = "2025-06-09T22:55:28.747Z" }, - { url = "https://files.pythonhosted.org/packages/3e/ca/fcd54f78b59e3f97b3b9715501e3147f5340167733d27db423aa321e7148/propcache-0.3.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:6d8f3f0eebf73e3c0ff0e7853f68be638b4043c65a70517bb575eff54edd8dbe", size = 255026, upload-time = "2025-06-09T22:55:30.184Z" }, - { url = "https://files.pythonhosted.org/packages/8b/95/8e6a6bbbd78ac89c30c225210a5c687790e532ba4088afb8c0445b77ef37/propcache-0.3.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:03c89c1b14a5452cf15403e291c0ccd7751d5b9736ecb2c5bab977ad6c5bcd81", size = 250724, upload-time = "2025-06-09T22:55:31.646Z" }, - { url = "https://files.pythonhosted.org/packages/ee/b0/0dd03616142baba28e8b2d14ce5df6631b4673850a3d4f9c0f9dd714a404/propcache-0.3.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:0cc17efde71e12bbaad086d679ce575268d70bc123a5a71ea7ad76f70ba30bba", size = 268868, upload-time = "2025-06-09T22:55:33.209Z" }, - { url = "https://files.pythonhosted.org/packages/c5/98/2c12407a7e4fbacd94ddd32f3b1e3d5231e77c30ef7162b12a60e2dd5ce3/propcache-0.3.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:acdf05d00696bc0447e278bb53cb04ca72354e562cf88ea6f9107df8e7fd9770", size = 271322, upload-time = "2025-06-09T22:55:35.065Z" }, - { url = "https://files.pythonhosted.org/packages/35/91/9cb56efbb428b006bb85db28591e40b7736847b8331d43fe335acf95f6c8/propcache-0.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4445542398bd0b5d32df908031cb1b30d43ac848e20470a878b770ec2dcc6330", size = 265778, upload-time = "2025-06-09T22:55:36.45Z" }, - { url = "https://files.pythonhosted.org/packages/9a/4c/b0fe775a2bdd01e176b14b574be679d84fc83958335790f7c9a686c1f468/propcache-0.3.2-cp313-cp313t-win32.whl", hash = "sha256:f86e5d7cd03afb3a1db8e9f9f6eff15794e79e791350ac48a8c924e6f439f394", size = 41175, upload-time = "2025-06-09T22:55:38.436Z" }, - { url = "https://files.pythonhosted.org/packages/a4/ff/47f08595e3d9b5e149c150f88d9714574f1a7cbd89fe2817158a952674bf/propcache-0.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9704bedf6e7cbe3c65eca4379a9b53ee6a83749f047808cbb5044d40d7d72198", size = 44857, upload-time = "2025-06-09T22:55:39.687Z" }, - { url = "https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size = 12663, upload-time = "2025-06-09T22:56:04.484Z" }, +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, + { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, + { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, + { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, + { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, + { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, + { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, + { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, + { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, + { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, + { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, + { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, + { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, + { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, + { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, + { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] [[package]] name = "pydantic" -version = "2.11.9" +version = "2.12.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -644,9 +659,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ff/5d/09a551ba512d7ca404d785072700d3f6727a02f6f3c24ecfd081c7cf0aa8/pydantic-2.11.9.tar.gz", hash = "sha256:6b8ffda597a14812a7975c90b82a8a2e777d9257aba3453f973acd3c032a18e2", size = 788495, upload-time = "2025-09-13T11:26:39.325Z" } +sdist = { url = "https://files.pythonhosted.org/packages/96/ad/a17bc283d7d81837c061c49e3eaa27a45991759a1b7eae1031921c6bd924/pydantic-2.12.4.tar.gz", hash = "sha256:0f8cb9555000a4b5b617f66bfd2566264c4984b27589d3b845685983e8ea85ac", size = 821038, upload-time = "2025-11-05T10:50:08.59Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/d3/108f2006987c58e76691d5ae5d200dd3e0f532cb4e5fa3560751c3a1feba/pydantic-2.11.9-py3-none-any.whl", hash = "sha256:c42dd626f5cfc1c6950ce6205ea58c93efa406da65f479dcb4029d5934857da2", size = 444855, upload-time = "2025-09-13T11:26:36.909Z" }, + { url = "https://files.pythonhosted.org/packages/82/2f/e68750da9b04856e2a7ec56fc6f034a5a79775e9b9a81882252789873798/pydantic-2.12.4-py3-none-any.whl", hash = "sha256:92d3d202a745d46f9be6df459ac5a064fdaa3c1c4cd8adcfa332ccf3c05f871e", size = 463400, upload-time = "2025-11-05T10:50:06.732Z" }, ] [package.optional-dependencies] @@ -656,30 +671,41 @@ email = [ [[package]] name = "pydantic-core" -version = "2.33.2" +version = "2.41.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, - { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, - { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, - { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, - { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, - { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, - { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, - { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, - { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, - { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, - { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, - { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, - { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, - { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, - { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, - { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, - { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, ] [[package]] @@ -693,7 +719,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.0" +version = "9.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -702,9 +728,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/da/1d/eb34f286b164c5e431a810a38697409cca1112cee04b287bb56ac486730b/pytest-9.0.0.tar.gz", hash = "sha256:8f44522eafe4137b0f35c9ce3072931a788a21ee40a2ed279e817d3cc16ed21e", size = 1562764, upload-time = "2025-11-08T17:25:33.34Z" } +sdist = { url = "https://files.pythonhosted.org/packages/07/56/f013048ac4bc4c1d9be45afd4ab209ea62822fb1598f40687e6bf45dcea4/pytest-9.0.1.tar.gz", hash = "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8", size = 1564125, upload-time = "2025-11-12T13:05:09.333Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/72/99/cafef234114a3b6d9f3aaed0723b437c40c57bdb7b3e4c3a575bc4890052/pytest-9.0.0-py3-none-any.whl", hash = "sha256:e5ccdf10b0bac554970ee88fc1a4ad0ee5d221f8ef22321f9b7e4584e19d7f96", size = 373364, upload-time = "2025-11-08T17:25:31.811Z" }, + { url = "https://files.pythonhosted.org/packages/0b/8b/6300fb80f858cda1c51ffa17075df5d846757081d11ab4aa35cef9e6258b/pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad", size = 373668, upload-time = "2025-11-12T13:05:07.379Z" }, ] [[package]] @@ -721,11 +747,11 @@ wheels = [ [[package]] name = "python-dotenv" -version = "1.1.1" +version = "1.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, ] [[package]] @@ -739,32 +765,41 @@ wheels = [ [[package]] name = "pyyaml" -version = "6.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, - { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, - { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, - { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, - { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, - { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, - { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, - { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, - { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, +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/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 = "rich" -version = "14.1.0" +version = "14.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fe/75/af448d8e52bf1d8fa6a9d089ca6c07ff4453d86c65c145d0a300bb073b9b/rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8", size = 224441, upload-time = "2025-07-25T07:32:58.125Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/30/3c4d035596d3cf444529e0b2953ad0466f6049528a879d27534700580395/rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f", size = 243368, upload-time = "2025-07-25T07:32:56.73Z" }, + { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, ] [[package]] @@ -783,71 +818,79 @@ wheels = [ [[package]] name = "rignore" -version = "0.6.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/73/46/05a94dc55ac03cf931d18e43b86ecee5ee054cb88b7853fffd741e35009c/rignore-0.6.4.tar.gz", hash = "sha256:e893fdd2d7fdcfa9407d0b7600ef2c2e2df97f55e1c45d4a8f54364829ddb0ab", size = 11633, upload-time = "2025-07-19T19:24:46.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/db/a3/edd7d0d5cc0720de132b6651cef95ee080ce5fca11c77d8a47db848e5f90/rignore-0.6.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:2b3b1e266ce45189240d14dfa1057f8013ea34b9bc8b3b44125ec8d25fdb3985", size = 885304, upload-time = "2025-07-19T19:23:54.268Z" }, - { url = "https://files.pythonhosted.org/packages/93/a1/d8d2fb97a6548307507d049b7e93885d4a0dfa1c907af5983fd9f9362a21/rignore-0.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45fe803628cc14714df10e8d6cdc23950a47eb9eb37dfea9a4779f4c672d2aa0", size = 818799, upload-time = "2025-07-19T19:23:47.544Z" }, - { url = "https://files.pythonhosted.org/packages/b1/cd/949981fcc180ad5ba7b31c52e78b74b2dea6b7bf744ad4c0c4b212f6da78/rignore-0.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e439f034277a947a4126e2da79dbb43e33d73d7c09d3d72a927e02f8a16f59aa", size = 892024, upload-time = "2025-07-19T19:22:36.18Z" }, - { url = "https://files.pythonhosted.org/packages/b0/d3/9042d701a8062d9c88f87760bbc2695ee2c23b3f002d34486b72a85f8efe/rignore-0.6.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:84b5121650ae24621154c7bdba8b8970b0739d8146505c9f38e0cda9385d1004", size = 871430, upload-time = "2025-07-19T19:22:49.62Z" }, - { url = "https://files.pythonhosted.org/packages/eb/50/3370249b984212b7355f3d9241aa6d02e706067c6d194a2614dfbc0f5b27/rignore-0.6.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:52b0957b585ab48a445cf8ac1dbc33a272ab060835e583b4f95aa8c67c23fb2b", size = 1160559, upload-time = "2025-07-19T19:23:01.629Z" }, - { url = "https://files.pythonhosted.org/packages/6c/6f/2ad7f925838091d065524f30a8abda846d1813eee93328febf262b5cda21/rignore-0.6.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:50359e0d5287b5e2743bd2f2fbf05df619c8282fd3af12f6628ff97b9675551d", size = 939947, upload-time = "2025-07-19T19:23:14.608Z" }, - { url = "https://files.pythonhosted.org/packages/1f/01/626ec94d62475ae7ef8b00ef98cea61cbea52a389a666703c97c4673d406/rignore-0.6.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efe18096dcb1596757dfe0b412aab6d32564473ae7ee58dea0a8b4be5b1a2e3b", size = 949471, upload-time = "2025-07-19T19:23:37.521Z" }, - { url = "https://files.pythonhosted.org/packages/e8/c3/699c4f03b3c46f4b5c02f17a0a339225da65aad547daa5b03001e7c6a382/rignore-0.6.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b79c212d9990a273ad91e8d9765e1766ef6ecedd3be65375d786a252762ba385", size = 974912, upload-time = "2025-07-19T19:23:27.13Z" }, - { url = "https://files.pythonhosted.org/packages/cd/35/04626c12f9f92a9fc789afc2be32838a5d9b23b6fa8b2ad4a8625638d15b/rignore-0.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c6ffa7f2a8894c65aa5dc4e8ac8bbdf39a326c0c6589efd27686cfbb48f0197d", size = 1067281, upload-time = "2025-07-19T19:24:01.016Z" }, - { url = "https://files.pythonhosted.org/packages/fe/9c/8f17baf3b984afea151cb9094716f6f1fb8e8737db97fc6eb6d494bd0780/rignore-0.6.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a63f5720dffc8d8fb0a4d02fafb8370a4031ebf3f99a4e79f334a91e905b7349", size = 1134414, upload-time = "2025-07-19T19:24:13.534Z" }, - { url = "https://files.pythonhosted.org/packages/10/88/ef84ffa916a96437c12cefcc39d474122da9626d75e3a2ebe09ec5d32f1b/rignore-0.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ce33982da47ac5dc09d19b04fa8d7c9aa6292fc0bd1ecf33076989faa8886094", size = 1109330, upload-time = "2025-07-19T19:24:25.303Z" }, - { url = "https://files.pythonhosted.org/packages/27/43/2ada5a2ec03b82e903610a1c483f516f78e47700ee6db9823f739e08b3af/rignore-0.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d899621867aa266824fbd9150e298f19d25b93903ef0133c09f70c65a3416eca", size = 1120381, upload-time = "2025-07-19T19:24:37.798Z" }, - { url = "https://files.pythonhosted.org/packages/3b/99/e7bcc643085131cb14dbea772def72bf1f6fe9037171ebe177c4f228abc8/rignore-0.6.4-cp313-cp313-win32.whl", hash = "sha256:d0615a6bf4890ec5a90b5fb83666822088fbd4e8fcd740c386fcce51e2f6feea", size = 641761, upload-time = "2025-07-19T19:24:58.096Z" }, - { url = "https://files.pythonhosted.org/packages/d9/25/7798908044f27dea1a8abdc75c14523e33770137651e5f775a15143f4218/rignore-0.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:145177f0e32716dc2f220b07b3cde2385b994b7ea28d5c96fbec32639e9eac6f", size = 719876, upload-time = "2025-07-19T19:24:51.125Z" }, - { url = "https://files.pythonhosted.org/packages/b4/e3/ae1e30b045bf004ad77bbd1679b9afff2be8edb166520921c6f29420516a/rignore-0.6.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e55bf8f9bbd186f58ab646b4a08718c77131d28a9004e477612b0cbbd5202db2", size = 891776, upload-time = "2025-07-19T19:22:37.78Z" }, - { url = "https://files.pythonhosted.org/packages/45/a9/1193e3bc23ca0e6eb4f17cf4b99971237f97cfa6f241d98366dff90a6d09/rignore-0.6.4-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2521f7bf3ee1f2ab22a100a3a4eed39a97b025804e5afe4323528e9ce8f084a5", size = 871442, upload-time = "2025-07-19T19:22:50.972Z" }, - { url = "https://files.pythonhosted.org/packages/20/83/4c52ae429a0b2e1ce667e35b480e9a6846f9468c443baeaed5d775af9485/rignore-0.6.4-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cc35773a8a9c119359ef974d0856988d4601d4daa6f532c05f66b4587cf35bc", size = 1159844, upload-time = "2025-07-19T19:23:02.751Z" }, - { url = "https://files.pythonhosted.org/packages/c1/2f/c740f5751f464c937bfe252dc15a024ae081352cfe80d94aa16d6a617482/rignore-0.6.4-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b665b1ea14457d7b49e834baabc635a3b8c10cfb5cca5c21161fabdbfc2b850e", size = 939456, upload-time = "2025-07-19T19:23:15.72Z" }, - { url = "https://files.pythonhosted.org/packages/fc/dd/68dbb08ac0edabf44dd144ff546a3fb0253c5af708e066847df39fc9188f/rignore-0.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c7fd339f344a8548724f289495b835bed7b81174a0bc1c28c6497854bd8855db", size = 1067070, upload-time = "2025-07-19T19:24:02.803Z" }, - { url = "https://files.pythonhosted.org/packages/3b/3a/7e7ea6f0d31d3f5beb0f2cf2c4c362672f5f7f125714458673fc579e2bed/rignore-0.6.4-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:91dc94b1cc5af8d6d25ce6edd29e7351830f19b0a03b75cb3adf1f76d00f3007", size = 1134598, upload-time = "2025-07-19T19:24:15.039Z" }, - { url = "https://files.pythonhosted.org/packages/7e/06/1b3307f6437d29bede5a95738aa89e6d910ba68d4054175c9f60d8e2c6b1/rignore-0.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:4d1918221a249e5342b60fd5fa513bf3d6bf272a8738e66023799f0c82ecd788", size = 1108862, upload-time = "2025-07-19T19:24:26.765Z" }, - { url = "https://files.pythonhosted.org/packages/b0/d5/b37c82519f335f2c472a63fc6215c6f4c51063ecf3166e3acf508011afbd/rignore-0.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:240777332b859dc89dcba59ab6e3f1e062bc8e862ffa3e5f456e93f7fd5cb415", size = 1120002, upload-time = "2025-07-19T19:24:38.952Z" }, +version = "0.7.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/f5/8bed2310abe4ae04b67a38374a4d311dd85220f5d8da56f47ae9361be0b0/rignore-0.7.6.tar.gz", hash = "sha256:00d3546cd793c30cb17921ce674d2c8f3a4b00501cb0e3dd0e82217dbeba2671", size = 57140, upload-time = "2025-11-05T21:41:21.968Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/b9/1f5bd82b87e5550cd843ceb3768b4a8ef274eb63f29333cf2f29644b3d75/rignore-0.7.6-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:8e41be9fa8f2f47239ded8920cc283699a052ac4c371f77f5ac017ebeed75732", size = 882632, upload-time = "2025-11-05T20:42:44.063Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6b/07714a3efe4a8048864e8a5b7db311ba51b921e15268b17defaebf56d3db/rignore-0.7.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6dc1e171e52cefa6c20e60c05394a71165663b48bca6c7666dee4f778f2a7d90", size = 820760, upload-time = "2025-11-05T20:42:27.885Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0f/348c829ea2d8d596e856371b14b9092f8a5dfbb62674ec9b3f67e4939a9d/rignore-0.7.6-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ce2268837c3600f82ab8db58f5834009dc638ee17103582960da668963bebc5", size = 899044, upload-time = "2025-11-05T20:40:55.336Z" }, + { url = "https://files.pythonhosted.org/packages/f0/30/2e1841a19b4dd23878d73edd5d82e998a83d5ed9570a89675f140ca8b2ad/rignore-0.7.6-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:690a3e1b54bfe77e89c4bacb13f046e642f8baadafc61d68f5a726f324a76ab6", size = 874144, upload-time = "2025-11-05T20:41:10.195Z" }, + { url = "https://files.pythonhosted.org/packages/c2/bf/0ce9beb2e5f64c30e3580bef09f5829236889f01511a125f98b83169b993/rignore-0.7.6-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09d12ac7a0b6210c07bcd145007117ebd8abe99c8eeb383e9e4673910c2754b2", size = 1168062, upload-time = "2025-11-05T20:41:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/b9/8b/571c178414eb4014969865317da8a02ce4cf5241a41676ef91a59aab24de/rignore-0.7.6-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2a2b2b74a8c60203b08452479b90e5ce3dbe96a916214bc9eb2e5af0b6a9beb0", size = 942542, upload-time = "2025-11-05T20:41:41.838Z" }, + { url = "https://files.pythonhosted.org/packages/19/62/7a3cf601d5a45137a7e2b89d10c05b5b86499190c4b7ca5c3c47d79ee519/rignore-0.7.6-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8fc5a531ef02131e44359419a366bfac57f773ea58f5278c2cdd915f7d10ea94", size = 958739, upload-time = "2025-11-05T20:42:12.463Z" }, + { url = "https://files.pythonhosted.org/packages/5f/1f/4261f6a0d7caf2058a5cde2f5045f565ab91aa7badc972b57d19ce58b14e/rignore-0.7.6-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b7a1f77d9c4cd7e76229e252614d963442686bfe12c787a49f4fe481df49e7a9", size = 984138, upload-time = "2025-11-05T20:41:56.775Z" }, + { url = "https://files.pythonhosted.org/packages/2b/bf/628dfe19c75e8ce1f45f7c248f5148b17dfa89a817f8e3552ab74c3ae812/rignore-0.7.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ead81f728682ba72b5b1c3d5846b011d3e0174da978de87c61645f2ed36659a7", size = 1079299, upload-time = "2025-11-05T21:40:16.639Z" }, + { url = "https://files.pythonhosted.org/packages/af/a5/be29c50f5c0c25c637ed32db8758fdf5b901a99e08b608971cda8afb293b/rignore-0.7.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:12ffd50f520c22ffdabed8cd8bfb567d9ac165b2b854d3e679f4bcaef11a9441", size = 1139618, upload-time = "2025-11-05T21:40:34.507Z" }, + { url = "https://files.pythonhosted.org/packages/2a/40/3c46cd7ce4fa05c20b525fd60f599165e820af66e66f2c371cd50644558f/rignore-0.7.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e5a16890fbe3c894f8ca34b0fcacc2c200398d4d46ae654e03bc9b3dbf2a0a72", size = 1117626, upload-time = "2025-11-05T21:40:51.494Z" }, + { url = "https://files.pythonhosted.org/packages/8c/b9/aea926f263b8a29a23c75c2e0d8447965eb1879d3feb53cfcf84db67ed58/rignore-0.7.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3abab3bf99e8a77488ef6c7c9a799fac22224c28fe9f25cc21aa7cc2b72bfc0b", size = 1128144, upload-time = "2025-11-05T21:41:09.169Z" }, + { url = "https://files.pythonhosted.org/packages/a4/f6/0d6242f8d0df7f2ecbe91679fefc1f75e7cd2072cb4f497abaab3f0f8523/rignore-0.7.6-cp314-cp314-win32.whl", hash = "sha256:eeef421c1782953c4375aa32f06ecae470c1285c6381eee2a30d2e02a5633001", size = 646385, upload-time = "2025-11-05T21:41:55.105Z" }, + { url = "https://files.pythonhosted.org/packages/d5/38/c0dcd7b10064f084343d6af26fe9414e46e9619c5f3224b5272e8e5d9956/rignore-0.7.6-cp314-cp314-win_amd64.whl", hash = "sha256:6aeed503b3b3d5af939b21d72a82521701a4bd3b89cd761da1e7dc78621af304", size = 725738, upload-time = "2025-11-05T21:41:39.736Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7a/290f868296c1ece914d565757ab363b04730a728b544beb567ceb3b2d96f/rignore-0.7.6-cp314-cp314-win_arm64.whl", hash = "sha256:104f215b60b3c984c386c3e747d6ab4376d5656478694e22c7bd2f788ddd8304", size = 656008, upload-time = "2025-11-05T21:41:29.028Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d2/3c74e3cd81fe8ea08a8dcd2d755c09ac2e8ad8fe409508904557b58383d3/rignore-0.7.6-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:bb24a5b947656dd94cb9e41c4bc8b23cec0c435b58be0d74a874f63c259549e8", size = 882835, upload-time = "2025-11-05T20:42:45.443Z" }, + { url = "https://files.pythonhosted.org/packages/77/61/a772a34b6b63154877433ac2d048364815b24c2dd308f76b212c408101a2/rignore-0.7.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5b1e33c9501cefe24b70a1eafd9821acfd0ebf0b35c3a379430a14df089993e3", size = 820301, upload-time = "2025-11-05T20:42:29.226Z" }, + { url = "https://files.pythonhosted.org/packages/71/30/054880b09c0b1b61d17eeb15279d8bf729c0ba52b36c3ada52fb827cbb3c/rignore-0.7.6-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bec3994665a44454df86deb762061e05cd4b61e3772f5b07d1882a8a0d2748d5", size = 897611, upload-time = "2025-11-05T20:40:56.475Z" }, + { url = "https://files.pythonhosted.org/packages/1e/40/b2d1c169f833d69931bf232600eaa3c7998ba4f9a402e43a822dad2ea9f2/rignore-0.7.6-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26cba2edfe3cff1dfa72bddf65d316ddebf182f011f2f61538705d6dbaf54986", size = 873875, upload-time = "2025-11-05T20:41:11.561Z" }, + { url = "https://files.pythonhosted.org/packages/55/59/ca5ae93d83a1a60e44b21d87deb48b177a8db1b85e82fc8a9abb24a8986d/rignore-0.7.6-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ffa86694fec604c613696cb91e43892aa22e1fec5f9870e48f111c603e5ec4e9", size = 1167245, upload-time = "2025-11-05T20:41:28.29Z" }, + { url = "https://files.pythonhosted.org/packages/a5/52/cf3dce392ba2af806cba265aad6bcd9c48bb2a6cb5eee448d3319f6e505b/rignore-0.7.6-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48efe2ed95aa8104145004afb15cdfa02bea5cdde8b0344afeb0434f0d989aa2", size = 941750, upload-time = "2025-11-05T20:41:43.111Z" }, + { url = "https://files.pythonhosted.org/packages/ec/be/3f344c6218d779395e785091d05396dfd8b625f6aafbe502746fcd880af2/rignore-0.7.6-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dcae43eb44b7f2457fef7cc87f103f9a0013017a6f4e62182c565e924948f21", size = 958896, upload-time = "2025-11-05T20:42:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/c9/34/d3fa71938aed7d00dcad87f0f9bcb02ad66c85d6ffc83ba31078ce53646a/rignore-0.7.6-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2cd649a7091c0dad2f11ef65630d30c698d505cbe8660dd395268e7c099cc99f", size = 983992, upload-time = "2025-11-05T20:41:58.022Z" }, + { url = "https://files.pythonhosted.org/packages/24/a4/52a697158e9920705bdbd0748d59fa63e0f3233fb92e9df9a71afbead6ca/rignore-0.7.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42de84b0289d478d30ceb7ae59023f7b0527786a9a5b490830e080f0e4ea5aeb", size = 1078181, upload-time = "2025-11-05T21:40:18.151Z" }, + { url = "https://files.pythonhosted.org/packages/ac/65/aa76dbcdabf3787a6f0fd61b5cc8ed1e88580590556d6c0207960d2384bb/rignore-0.7.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:875a617e57b53b4acbc5a91de418233849711c02e29cc1f4f9febb2f928af013", size = 1139232, upload-time = "2025-11-05T21:40:35.966Z" }, + { url = "https://files.pythonhosted.org/packages/08/44/31b31a49b3233c6842acc1c0731aa1e7fb322a7170612acf30327f700b44/rignore-0.7.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8703998902771e96e49968105207719f22926e4431b108450f3f430b4e268b7c", size = 1117349, upload-time = "2025-11-05T21:40:53.013Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ae/1b199a2302c19c658cf74e5ee1427605234e8c91787cfba0015f2ace145b/rignore-0.7.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:602ef33f3e1b04c1e9a10a3c03f8bc3cef2d2383dcc250d309be42b49923cabc", size = 1127702, upload-time = "2025-11-05T21:41:10.881Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d3/18210222b37e87e36357f7b300b7d98c6dd62b133771e71ae27acba83a4f/rignore-0.7.6-cp314-cp314t-win32.whl", hash = "sha256:c1d8f117f7da0a4a96a8daef3da75bc090e3792d30b8b12cfadc240c631353f9", size = 647033, upload-time = "2025-11-05T21:42:00.095Z" }, + { url = "https://files.pythonhosted.org/packages/3e/87/033eebfbee3ec7d92b3bb1717d8f68c88e6fc7de54537040f3b3a405726f/rignore-0.7.6-cp314-cp314t-win_amd64.whl", hash = "sha256:ca36e59408bec81de75d307c568c2d0d410fb880b1769be43611472c61e85c96", size = 725647, upload-time = "2025-11-05T21:41:44.449Z" }, + { url = "https://files.pythonhosted.org/packages/79/62/b88e5879512c55b8ee979c666ee6902adc4ed05007226de266410ae27965/rignore-0.7.6-cp314-cp314t-win_arm64.whl", hash = "sha256:b83adabeb3e8cf662cabe1931b83e165b88c526fa6af6b3aa90429686e474896", size = 656035, upload-time = "2025-11-05T21:41:31.13Z" }, ] [[package]] name = "ruff" -version = "0.14.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/df/55/cccfca45157a2031dcbb5a462a67f7cf27f8b37d4b3b1cd7438f0f5c1df6/ruff-0.14.4.tar.gz", hash = "sha256:f459a49fe1085a749f15414ca76f61595f1a2cc8778ed7c279b6ca2e1fd19df3", size = 5587844, upload-time = "2025-11-06T22:07:45.033Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/17/b9/67240254166ae1eaa38dec32265e9153ac53645a6c6670ed36ad00722af8/ruff-0.14.4-py3-none-linux_armv6l.whl", hash = "sha256:e6604613ffbcf2297cd5dcba0e0ac9bd0c11dc026442dfbb614504e87c349518", size = 12606781, upload-time = "2025-11-06T22:07:01.841Z" }, - { url = "https://files.pythonhosted.org/packages/46/c8/09b3ab245d8652eafe5256ab59718641429f68681ee713ff06c5c549f156/ruff-0.14.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d99c0b52b6f0598acede45ee78288e5e9b4409d1ce7f661f0fa36d4cbeadf9a4", size = 12946765, upload-time = "2025-11-06T22:07:05.858Z" }, - { url = "https://files.pythonhosted.org/packages/14/bb/1564b000219144bf5eed2359edc94c3590dd49d510751dad26202c18a17d/ruff-0.14.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9358d490ec030f1b51d048a7fd6ead418ed0826daf6149e95e30aa67c168af33", size = 11928120, upload-time = "2025-11-06T22:07:08.023Z" }, - { url = "https://files.pythonhosted.org/packages/a3/92/d5f1770e9988cc0742fefaa351e840d9aef04ec24ae1be36f333f96d5704/ruff-0.14.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81b40d27924f1f02dfa827b9c0712a13c0e4b108421665322218fc38caf615c2", size = 12370877, upload-time = "2025-11-06T22:07:10.015Z" }, - { url = "https://files.pythonhosted.org/packages/e2/29/e9282efa55f1973d109faf839a63235575519c8ad278cc87a182a366810e/ruff-0.14.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f5e649052a294fe00818650712083cddc6cc02744afaf37202c65df9ea52efa5", size = 12408538, upload-time = "2025-11-06T22:07:13.085Z" }, - { url = "https://files.pythonhosted.org/packages/8e/01/930ed6ecfce130144b32d77d8d69f5c610e6d23e6857927150adf5d7379a/ruff-0.14.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa082a8f878deeba955531f975881828fd6afd90dfa757c2b0808aadb437136e", size = 13141942, upload-time = "2025-11-06T22:07:15.386Z" }, - { url = "https://files.pythonhosted.org/packages/6a/46/a9c89b42b231a9f487233f17a89cbef9d5acd538d9488687a02ad288fa6b/ruff-0.14.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1043c6811c2419e39011890f14d0a30470f19d47d197c4858b2787dfa698f6c8", size = 14544306, upload-time = "2025-11-06T22:07:17.631Z" }, - { url = "https://files.pythonhosted.org/packages/78/96/9c6cf86491f2a6d52758b830b89b78c2ae61e8ca66b86bf5a20af73d20e6/ruff-0.14.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a9f3a936ac27fb7c2a93e4f4b943a662775879ac579a433291a6f69428722649", size = 14210427, upload-time = "2025-11-06T22:07:19.832Z" }, - { url = "https://files.pythonhosted.org/packages/71/f4/0666fe7769a54f63e66404e8ff698de1dcde733e12e2fd1c9c6efb689cb5/ruff-0.14.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:95643ffd209ce78bc113266b88fba3d39e0461f0cbc8b55fb92505030fb4a850", size = 13658488, upload-time = "2025-11-06T22:07:22.32Z" }, - { url = "https://files.pythonhosted.org/packages/ee/79/6ad4dda2cfd55e41ac9ed6d73ef9ab9475b1eef69f3a85957210c74ba12c/ruff-0.14.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:456daa2fa1021bc86ca857f43fe29d5d8b3f0e55e9f90c58c317c1dcc2afc7b5", size = 13354908, upload-time = "2025-11-06T22:07:24.347Z" }, - { url = "https://files.pythonhosted.org/packages/b5/60/f0b6990f740bb15c1588601d19d21bcc1bd5de4330a07222041678a8e04f/ruff-0.14.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:f911bba769e4a9f51af6e70037bb72b70b45a16db5ce73e1f72aefe6f6d62132", size = 13587803, upload-time = "2025-11-06T22:07:26.327Z" }, - { url = "https://files.pythonhosted.org/packages/c9/da/eaaada586f80068728338e0ef7f29ab3e4a08a692f92eb901a4f06bbff24/ruff-0.14.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:76158a7369b3979fa878612c623a7e5430c18b2fd1c73b214945c2d06337db67", size = 12279654, upload-time = "2025-11-06T22:07:28.46Z" }, - { url = "https://files.pythonhosted.org/packages/66/d4/b1d0e82cf9bf8aed10a6d45be47b3f402730aa2c438164424783ac88c0ed/ruff-0.14.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f3b8f3b442d2b14c246e7aeca2e75915159e06a3540e2f4bed9f50d062d24469", size = 12357520, upload-time = "2025-11-06T22:07:31.468Z" }, - { url = "https://files.pythonhosted.org/packages/04/f4/53e2b42cc82804617e5c7950b7079d79996c27e99c4652131c6a1100657f/ruff-0.14.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c62da9a06779deecf4d17ed04939ae8b31b517643b26370c3be1d26f3ef7dbde", size = 12719431, upload-time = "2025-11-06T22:07:33.831Z" }, - { url = "https://files.pythonhosted.org/packages/a2/94/80e3d74ed9a72d64e94a7b7706b1c1ebaa315ef2076fd33581f6a1cd2f95/ruff-0.14.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5a443a83a1506c684e98acb8cb55abaf3ef725078be40237463dae4463366349", size = 13464394, upload-time = "2025-11-06T22:07:35.905Z" }, - { url = "https://files.pythonhosted.org/packages/54/1a/a49f071f04c42345c793d22f6cf5e0920095e286119ee53a64a3a3004825/ruff-0.14.4-py3-none-win32.whl", hash = "sha256:643b69cb63cd996f1fc7229da726d07ac307eae442dd8974dbc7cf22c1e18fff", size = 12493429, upload-time = "2025-11-06T22:07:38.43Z" }, - { url = "https://files.pythonhosted.org/packages/bc/22/e58c43e641145a2b670328fb98bc384e20679b5774258b1e540207580266/ruff-0.14.4-py3-none-win_amd64.whl", hash = "sha256:26673da283b96fe35fa0c939bf8411abec47111644aa9f7cfbd3c573fb125d2c", size = 13635380, upload-time = "2025-11-06T22:07:40.496Z" }, - { url = "https://files.pythonhosted.org/packages/30/bd/4168a751ddbbf43e86544b4de8b5c3b7be8d7167a2a5cb977d274e04f0a1/ruff-0.14.4-py3-none-win_arm64.whl", hash = "sha256:dd09c292479596b0e6fec8cd95c65c3a6dc68e9ad17b8f2382130f87ff6a75bb", size = 12663065, upload-time = "2025-11-06T22:07:42.603Z" }, +version = "0.14.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/fa/fbb67a5780ae0f704876cb8ac92d6d76da41da4dc72b7ed3565ab18f2f52/ruff-0.14.5.tar.gz", hash = "sha256:8d3b48d7d8aad423d3137af7ab6c8b1e38e4de104800f0d596990f6ada1a9fc1", size = 5615944, upload-time = "2025-11-13T19:58:51.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/31/c07e9c535248d10836a94e4f4e8c5a31a1beed6f169b31405b227872d4f4/ruff-0.14.5-py3-none-linux_armv6l.whl", hash = "sha256:f3b8248123b586de44a8018bcc9fefe31d23dda57a34e6f0e1e53bd51fd63594", size = 13171630, upload-time = "2025-11-13T19:57:54.894Z" }, + { url = "https://files.pythonhosted.org/packages/8e/5c/283c62516dca697cd604c2796d1487396b7a436b2f0ecc3fd412aca470e0/ruff-0.14.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f7a75236570318c7a30edd7f5491945f0169de738d945ca8784500b517163a72", size = 13413925, upload-time = "2025-11-13T19:57:59.181Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/aa319f4afc22cb6fcba2b9cdfc0f03bbf747e59ab7a8c5e90173857a1361/ruff-0.14.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6d146132d1ee115f8802356a2dc9a634dbf58184c51bff21f313e8cd1c74899a", size = 12574040, upload-time = "2025-11-13T19:58:02.056Z" }, + { url = "https://files.pythonhosted.org/packages/f9/7f/cb5845fcc7c7e88ed57f58670189fc2ff517fe2134c3821e77e29fd3b0c8/ruff-0.14.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2380596653dcd20b057794d55681571a257a42327da8894b93bbd6111aa801f", size = 13009755, upload-time = "2025-11-13T19:58:05.172Z" }, + { url = "https://files.pythonhosted.org/packages/21/d2/bcbedbb6bcb9253085981730687ddc0cc7b2e18e8dc13cf4453de905d7a0/ruff-0.14.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2d1fa985a42b1f075a098fa1ab9d472b712bdb17ad87a8ec86e45e7fa6273e68", size = 12937641, upload-time = "2025-11-13T19:58:08.345Z" }, + { url = "https://files.pythonhosted.org/packages/a4/58/e25de28a572bdd60ffc6bb71fc7fd25a94ec6a076942e372437649cbb02a/ruff-0.14.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88f0770d42b7fa02bbefddde15d235ca3aa24e2f0137388cc15b2dcbb1f7c7a7", size = 13610854, upload-time = "2025-11-13T19:58:11.419Z" }, + { url = "https://files.pythonhosted.org/packages/7d/24/43bb3fd23ecee9861970978ea1a7a63e12a204d319248a7e8af539984280/ruff-0.14.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3676cb02b9061fee7294661071c4709fa21419ea9176087cb77e64410926eb78", size = 15061088, upload-time = "2025-11-13T19:58:14.551Z" }, + { url = "https://files.pythonhosted.org/packages/23/44/a022f288d61c2f8c8645b24c364b719aee293ffc7d633a2ca4d116b9c716/ruff-0.14.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b595bedf6bc9cab647c4a173a61acf4f1ac5f2b545203ba82f30fcb10b0318fb", size = 14734717, upload-time = "2025-11-13T19:58:17.518Z" }, + { url = "https://files.pythonhosted.org/packages/58/81/5c6ba44de7e44c91f68073e0658109d8373b0590940efe5bd7753a2585a3/ruff-0.14.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f55382725ad0bdb2e8ee2babcbbfb16f124f5a59496a2f6a46f1d9d99d93e6e2", size = 14028812, upload-time = "2025-11-13T19:58:20.533Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ef/41a8b60f8462cb320f68615b00299ebb12660097c952c600c762078420f8/ruff-0.14.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7497d19dce23976bdaca24345ae131a1d38dcfe1b0850ad8e9e6e4fa321a6e19", size = 13825656, upload-time = "2025-11-13T19:58:23.345Z" }, + { url = "https://files.pythonhosted.org/packages/7c/00/207e5de737fdb59b39eb1fac806904fe05681981b46d6a6db9468501062e/ruff-0.14.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:410e781f1122d6be4f446981dd479470af86537fb0b8857f27a6e872f65a38e4", size = 13959922, upload-time = "2025-11-13T19:58:26.537Z" }, + { url = "https://files.pythonhosted.org/packages/bc/7e/fa1f5c2776db4be405040293618846a2dece5c70b050874c2d1f10f24776/ruff-0.14.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c01be527ef4c91a6d55e53b337bfe2c0f82af024cc1a33c44792d6844e2331e1", size = 12932501, upload-time = "2025-11-13T19:58:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/67/d8/d86bf784d693a764b59479a6bbdc9515ae42c340a5dc5ab1dabef847bfaa/ruff-0.14.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f66e9bb762e68d66e48550b59c74314168ebb46199886c5c5aa0b0fbcc81b151", size = 12927319, upload-time = "2025-11-13T19:58:32.923Z" }, + { url = "https://files.pythonhosted.org/packages/ac/de/ee0b304d450ae007ce0cb3e455fe24fbcaaedae4ebaad6c23831c6663651/ruff-0.14.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d93be8f1fa01022337f1f8f3bcaa7ffee2d0b03f00922c45c2207954f351f465", size = 13206209, upload-time = "2025-11-13T19:58:35.952Z" }, + { url = "https://files.pythonhosted.org/packages/33/aa/193ca7e3a92d74f17d9d5771a765965d2cf42c86e6f0fd95b13969115723/ruff-0.14.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c135d4b681f7401fe0e7312017e41aba9b3160861105726b76cfa14bc25aa367", size = 13953709, upload-time = "2025-11-13T19:58:39.002Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f1/7119e42aa1d3bf036ffc9478885c2e248812b7de9abea4eae89163d2929d/ruff-0.14.5-py3-none-win32.whl", hash = "sha256:c83642e6fccfb6dea8b785eb9f456800dcd6a63f362238af5fc0c83d027dd08b", size = 12925808, upload-time = "2025-11-13T19:58:42.779Z" }, + { url = "https://files.pythonhosted.org/packages/3b/9d/7c0a255d21e0912114784e4a96bf62af0618e2190cae468cd82b13625ad2/ruff-0.14.5-py3-none-win_amd64.whl", hash = "sha256:9d55d7af7166f143c94eae1db3312f9ea8f95a4defef1979ed516dbb38c27621", size = 14331546, upload-time = "2025-11-13T19:58:45.691Z" }, + { url = "https://files.pythonhosted.org/packages/e5/80/69756670caedcf3b9be597a6e12276a6cf6197076eb62aad0c608f8efce0/ruff-0.14.5-py3-none-win_arm64.whl", hash = "sha256:4b700459d4649e2594b31f20a9de33bc7c19976d4746d8d0798ad959621d64a4", size = 13433331, upload-time = "2025-11-13T19:58:48.434Z" }, ] [[package]] name = "sentry-sdk" -version = "2.38.0" +version = "2.44.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b2/22/60fd703b34d94d216b2387e048ac82de3e86b63bc28869fb076f8bb0204a/sentry_sdk-2.38.0.tar.gz", hash = "sha256:792d2af45e167e2f8a3347143f525b9b6bac6f058fb2014720b40b84ccbeb985", size = 348116, upload-time = "2025-09-15T15:00:37.846Z" } +sdist = { url = "https://files.pythonhosted.org/packages/62/26/ff7d93a14a0ec309021dca2fb7c62669d4f6f5654aa1baf60797a16681e0/sentry_sdk-2.44.0.tar.gz", hash = "sha256:5b1fe54dfafa332e900b07dd8f4dfe35753b64e78e7d9b1655a28fd3065e2493", size = 371464, upload-time = "2025-11-11T09:35:56.075Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/84/bde4c4bbb269b71bc09316af8eb00da91f67814d40337cc12ef9c8742541/sentry_sdk-2.38.0-py2.py3-none-any.whl", hash = "sha256:2324aea8573a3fa1576df7fb4d65c4eb8d9929c8fa5939647397a07179eef8d0", size = 370346, upload-time = "2025-09-15T15:00:35.821Z" }, + { url = "https://files.pythonhosted.org/packages/a8/56/c16bda4d53012c71fa1b588edde603c6b455bc8206bf6de7b83388fcce75/sentry_sdk-2.44.0-py2.py3-none-any.whl", hash = "sha256:9e36a0372b881e8f92fdbff4564764ce6cec4b7f25424d0a3a8d609c9e4651a7", size = 402352, upload-time = "2025-11-11T09:35:54.1Z" }, ] [[package]] @@ -879,19 +922,19 @@ wheels = [ [[package]] name = "starlette" -version = "0.48.0" +version = "0.49.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a7/a5/d6f429d43394057b67a6b5bbe6eae2f77a6bf7459d961fdb224bf206eee6/starlette-0.48.0.tar.gz", hash = "sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46", size = 2652949, upload-time = "2025-09-13T08:41:05.699Z" } +sdist = { url = "https://files.pythonhosted.org/packages/de/1a/608df0b10b53b0beb96a37854ee05864d182ddd4b1156a22f1ad3860425a/starlette-0.49.3.tar.gz", hash = "sha256:1c14546f299b5901a1ea0e34410575bc33bbd741377a10484a54445588d00284", size = 2655031, upload-time = "2025-11-01T15:12:26.13Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/72/2db2f49247d0a18b4f1bb9a5a39a0162869acf235f3a96418363947b3d46/starlette-0.48.0-py3-none-any.whl", hash = "sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659", size = 73736, upload-time = "2025-09-13T08:41:03.869Z" }, + { url = "https://files.pythonhosted.org/packages/a3/e0/021c772d6a662f43b63044ab481dc6ac7592447605b5b35a957785363122/starlette-0.49.3-py3-none-any.whl", hash = "sha256:b579b99715fdc2980cf88c8ec96d3bf1ce16f5a8051a7c2b84ef9b1cdecaea2f", size = 74340, upload-time = "2025-11-01T15:12:24.387Z" }, ] [[package]] name = "typer" -version = "0.17.4" +version = "0.20.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -899,9 +942,9 @@ dependencies = [ { name = "shellingham" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/92/e8/2a73ccf9874ec4c7638f172efc8972ceab13a0e3480b389d6ed822f7a822/typer-0.17.4.tar.gz", hash = "sha256:b77dc07d849312fd2bb5e7f20a7af8985c7ec360c45b051ed5412f64d8dc1580", size = 103734, upload-time = "2025-09-05T18:14:40.746Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8f/28/7c85c8032b91dbe79725b6f17d2fffc595dff06a35c7a30a37bef73a1ab4/typer-0.20.0.tar.gz", hash = "sha256:1aaf6494031793e4876fb0bacfa6a912b551cf43c1e63c800df8b1a866720c37", size = 106492, upload-time = "2025-10-20T17:03:49.445Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/93/72/6b3e70d32e89a5cbb6a4513726c1ae8762165b027af569289e19ec08edd8/typer-0.17.4-py3-none-any.whl", hash = "sha256:015534a6edaa450e7007eba705d5c18c3349dcea50a6ad79a5ed530967575824", size = 46643, upload-time = "2025-09-05T18:14:39.166Z" }, + { url = "https://files.pythonhosted.org/packages/78/64/7713ffe4b5983314e9d436a90d5bd4f63b6054e2aca783a3cfc44cb95bbf/typer-0.20.0-py3-none-any.whl", hash = "sha256:5b463df6793ec1dca6213a3cf4c0f03bc6e322ac5e16e13ddd622a889489784a", size = 47028, upload-time = "2025-10-20T17:03:47.617Z" }, ] [[package]] @@ -915,14 +958,14 @@ wheels = [ [[package]] name = "typing-inspection" -version = "0.4.1" +version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] [[package]] @@ -936,15 +979,15 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.35.0" +version = "0.38.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5e/42/e0e305207bb88c6b8d3061399c6a961ffe5fbb7e2aa63c9234df7259e9cd/uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01", size = 78473, upload-time = "2025-06-28T16:15:46.058Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406, upload-time = "2025-06-28T16:15:44.816Z" }, + { url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" }, ] [package.optional-dependencies] @@ -960,64 +1003,70 @@ standard = [ [[package]] name = "uvloop" -version = "0.21.0" +version = "0.22.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/c0/854216d09d33c543f12a44b393c402e89a920b1a0a7dc634c42de91b9cf6/uvloop-0.21.0.tar.gz", hash = "sha256:3bf12b0fda68447806a7ad847bfa591613177275d35b6724b1ee573faa3704e3", size = 2492741, upload-time = "2024-10-14T23:38:35.489Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/8d/2cbef610ca21539f0f36e2b34da49302029e7c9f09acef0b1c3b5839412b/uvloop-0.21.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bfd55dfcc2a512316e65f16e503e9e450cab148ef11df4e4e679b5e8253a5281", size = 1468123, upload-time = "2024-10-14T23:38:00.688Z" }, - { url = "https://files.pythonhosted.org/packages/93/0d/b0038d5a469f94ed8f2b2fce2434a18396d8fbfb5da85a0a9781ebbdec14/uvloop-0.21.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787ae31ad8a2856fc4e7c095341cccc7209bd657d0e71ad0dc2ea83c4a6fa8af", size = 819325, upload-time = "2024-10-14T23:38:02.309Z" }, - { url = "https://files.pythonhosted.org/packages/50/94/0a687f39e78c4c1e02e3272c6b2ccdb4e0085fda3b8352fecd0410ccf915/uvloop-0.21.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ee4d4ef48036ff6e5cfffb09dd192c7a5027153948d85b8da7ff705065bacc6", size = 4582806, upload-time = "2024-10-14T23:38:04.711Z" }, - { url = "https://files.pythonhosted.org/packages/d2/19/f5b78616566ea68edd42aacaf645adbf71fbd83fc52281fba555dc27e3f1/uvloop-0.21.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3df876acd7ec037a3d005b3ab85a7e4110422e4d9c1571d4fc89b0fc41b6816", size = 4701068, upload-time = "2024-10-14T23:38:06.385Z" }, - { url = "https://files.pythonhosted.org/packages/47/57/66f061ee118f413cd22a656de622925097170b9380b30091b78ea0c6ea75/uvloop-0.21.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd53ecc9a0f3d87ab847503c2e1552b690362e005ab54e8a48ba97da3924c0dc", size = 4454428, upload-time = "2024-10-14T23:38:08.416Z" }, - { url = "https://files.pythonhosted.org/packages/63/9a/0962b05b308494e3202d3f794a6e85abe471fe3cafdbcf95c2e8c713aabd/uvloop-0.21.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5c39f217ab3c663dc699c04cbd50c13813e31d917642d459fdcec07555cc553", size = 4660018, upload-time = "2024-10-14T23:38:10.888Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, ] [[package]] name = "virtualenv" -version = "20.34.0" +version = "20.35.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, { name = "filelock" }, { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1c/14/37fcdba2808a6c615681cd216fecae00413c9dab44fb2e57805ecf3eaee3/virtualenv-20.34.0.tar.gz", hash = "sha256:44815b2c9dee7ed86e387b842a84f20b93f7f417f95886ca1996a72a4138eb1a", size = 6003808, upload-time = "2025-08-13T14:24:07.464Z" } +sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799, upload-time = "2025-10-29T06:57:40.511Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/06/04c8e804f813cf972e3262f3f8584c232de64f0cde9f703b46cf53a45090/virtualenv-20.34.0-py3-none-any.whl", hash = "sha256:341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026", size = 5983279, upload-time = "2025-08-13T14:24:05.111Z" }, + { url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" }, ] [[package]] name = "watchfiles" -version = "1.1.0" +version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2a/9a/d451fcc97d029f5812e898fd30a53fd8c15c7bbd058fd75cfc6beb9bd761/watchfiles-1.1.0.tar.gz", hash = "sha256:693ed7ec72cbfcee399e92c895362b6e66d63dac6b91e2c11ae03d10d503e575", size = 94406, upload-time = "2025-06-15T19:06:59.42Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d3/42/fae874df96595556a9089ade83be34a2e04f0f11eb53a8dbf8a8a5e562b4/watchfiles-1.1.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5007f860c7f1f8df471e4e04aaa8c43673429047d63205d1630880f7637bca30", size = 402004, upload-time = "2025-06-15T19:05:38.499Z" }, - { url = "https://files.pythonhosted.org/packages/fa/55/a77e533e59c3003d9803c09c44c3651224067cbe7fb5d574ddbaa31e11ca/watchfiles-1.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:20ecc8abbd957046f1fe9562757903f5eaf57c3bce70929fda6c7711bb58074a", size = 393671, upload-time = "2025-06-15T19:05:39.52Z" }, - { url = "https://files.pythonhosted.org/packages/05/68/b0afb3f79c8e832e6571022611adbdc36e35a44e14f129ba09709aa4bb7a/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2f0498b7d2a3c072766dba3274fe22a183dbea1f99d188f1c6c72209a1063dc", size = 449772, upload-time = "2025-06-15T19:05:40.897Z" }, - { url = "https://files.pythonhosted.org/packages/ff/05/46dd1f6879bc40e1e74c6c39a1b9ab9e790bf1f5a2fe6c08b463d9a807f4/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:239736577e848678e13b201bba14e89718f5c2133dfd6b1f7846fa1b58a8532b", size = 456789, upload-time = "2025-06-15T19:05:42.045Z" }, - { url = "https://files.pythonhosted.org/packages/8b/ca/0eeb2c06227ca7f12e50a47a3679df0cd1ba487ea19cf844a905920f8e95/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eff4b8d89f444f7e49136dc695599a591ff769300734446c0a86cba2eb2f9895", size = 482551, upload-time = "2025-06-15T19:05:43.781Z" }, - { url = "https://files.pythonhosted.org/packages/31/47/2cecbd8694095647406645f822781008cc524320466ea393f55fe70eed3b/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12b0a02a91762c08f7264e2e79542f76870c3040bbc847fb67410ab81474932a", size = 597420, upload-time = "2025-06-15T19:05:45.244Z" }, - { url = "https://files.pythonhosted.org/packages/d9/7e/82abc4240e0806846548559d70f0b1a6dfdca75c1b4f9fa62b504ae9b083/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29e7bc2eee15cbb339c68445959108803dc14ee0c7b4eea556400131a8de462b", size = 477950, upload-time = "2025-06-15T19:05:46.332Z" }, - { url = "https://files.pythonhosted.org/packages/25/0d/4d564798a49bf5482a4fa9416dea6b6c0733a3b5700cb8a5a503c4b15853/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d9481174d3ed982e269c090f780122fb59cee6c3796f74efe74e70f7780ed94c", size = 451706, upload-time = "2025-06-15T19:05:47.459Z" }, - { url = "https://files.pythonhosted.org/packages/81/b5/5516cf46b033192d544102ea07c65b6f770f10ed1d0a6d388f5d3874f6e4/watchfiles-1.1.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:80f811146831c8c86ab17b640801c25dc0a88c630e855e2bef3568f30434d52b", size = 625814, upload-time = "2025-06-15T19:05:48.654Z" }, - { url = "https://files.pythonhosted.org/packages/0c/dd/7c1331f902f30669ac3e754680b6edb9a0dd06dea5438e61128111fadd2c/watchfiles-1.1.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:60022527e71d1d1fda67a33150ee42869042bce3d0fcc9cc49be009a9cded3fb", size = 622820, upload-time = "2025-06-15T19:05:50.088Z" }, - { url = "https://files.pythonhosted.org/packages/1b/14/36d7a8e27cd128d7b1009e7715a7c02f6c131be9d4ce1e5c3b73d0e342d8/watchfiles-1.1.0-cp313-cp313-win32.whl", hash = "sha256:32d6d4e583593cb8576e129879ea0991660b935177c0f93c6681359b3654bfa9", size = 279194, upload-time = "2025-06-15T19:05:51.186Z" }, - { url = "https://files.pythonhosted.org/packages/25/41/2dd88054b849aa546dbeef5696019c58f8e0774f4d1c42123273304cdb2e/watchfiles-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:f21af781a4a6fbad54f03c598ab620e3a77032c5878f3d780448421a6e1818c7", size = 292349, upload-time = "2025-06-15T19:05:52.201Z" }, - { url = "https://files.pythonhosted.org/packages/c8/cf/421d659de88285eb13941cf11a81f875c176f76a6d99342599be88e08d03/watchfiles-1.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:5366164391873ed76bfdf618818c82084c9db7fac82b64a20c44d335eec9ced5", size = 283836, upload-time = "2025-06-15T19:05:53.265Z" }, - { url = "https://files.pythonhosted.org/packages/45/10/6faf6858d527e3599cc50ec9fcae73590fbddc1420bd4fdccfebffeedbc6/watchfiles-1.1.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:17ab167cca6339c2b830b744eaf10803d2a5b6683be4d79d8475d88b4a8a4be1", size = 400343, upload-time = "2025-06-15T19:05:54.252Z" }, - { url = "https://files.pythonhosted.org/packages/03/20/5cb7d3966f5e8c718006d0e97dfe379a82f16fecd3caa7810f634412047a/watchfiles-1.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:328dbc9bff7205c215a7807da7c18dce37da7da718e798356212d22696404339", size = 392916, upload-time = "2025-06-15T19:05:55.264Z" }, - { url = "https://files.pythonhosted.org/packages/8c/07/d8f1176328fa9e9581b6f120b017e286d2a2d22ae3f554efd9515c8e1b49/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7208ab6e009c627b7557ce55c465c98967e8caa8b11833531fdf95799372633", size = 449582, upload-time = "2025-06-15T19:05:56.317Z" }, - { url = "https://files.pythonhosted.org/packages/66/e8/80a14a453cf6038e81d072a86c05276692a1826471fef91df7537dba8b46/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a8f6f72974a19efead54195bc9bed4d850fc047bb7aa971268fd9a8387c89011", size = 456752, upload-time = "2025-06-15T19:05:57.359Z" }, - { url = "https://files.pythonhosted.org/packages/5a/25/0853b3fe0e3c2f5af9ea60eb2e781eade939760239a72c2d38fc4cc335f6/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d181ef50923c29cf0450c3cd47e2f0557b62218c50b2ab8ce2ecaa02bd97e670", size = 481436, upload-time = "2025-06-15T19:05:58.447Z" }, - { url = "https://files.pythonhosted.org/packages/fe/9e/4af0056c258b861fbb29dcb36258de1e2b857be4a9509e6298abcf31e5c9/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adb4167043d3a78280d5d05ce0ba22055c266cf8655ce942f2fb881262ff3cdf", size = 596016, upload-time = "2025-06-15T19:05:59.59Z" }, - { url = "https://files.pythonhosted.org/packages/c5/fa/95d604b58aa375e781daf350897aaaa089cff59d84147e9ccff2447c8294/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5701dc474b041e2934a26d31d39f90fac8a3dee2322b39f7729867f932b1d4", size = 476727, upload-time = "2025-06-15T19:06:01.086Z" }, - { url = "https://files.pythonhosted.org/packages/65/95/fe479b2664f19be4cf5ceeb21be05afd491d95f142e72d26a42f41b7c4f8/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b067915e3c3936966a8607f6fe5487df0c9c4afb85226613b520890049deea20", size = 451864, upload-time = "2025-06-15T19:06:02.144Z" }, - { url = "https://files.pythonhosted.org/packages/d3/8a/3c4af14b93a15ce55901cd7a92e1a4701910f1768c78fb30f61d2b79785b/watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:9c733cda03b6d636b4219625a4acb5c6ffb10803338e437fb614fef9516825ef", size = 625626, upload-time = "2025-06-15T19:06:03.578Z" }, - { url = "https://files.pythonhosted.org/packages/da/f5/cf6aa047d4d9e128f4b7cde615236a915673775ef171ff85971d698f3c2c/watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:cc08ef8b90d78bfac66f0def80240b0197008e4852c9f285907377b2947ffdcb", size = 622744, upload-time = "2025-06-15T19:06:05.066Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, ] [[package]] @@ -1026,64 +1075,51 @@ version = "15.0.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, - { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, - { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, - { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, - { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, - { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, - { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, - { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, - { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, - { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] [[package]] name = "yarl" -version = "1.20.1" +version = "1.22.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "multidict" }, { name = "propcache" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3c/fb/efaa23fa4e45537b827620f04cf8f3cd658b76642205162e072703a5b963/yarl-1.20.1.tar.gz", hash = "sha256:d017a4997ee50c91fd5466cef416231bb82177b93b029906cefc542ce14c35ac", size = 186428, upload-time = "2025-06-10T00:46:09.923Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/e1/2411b6d7f769a07687acee88a062af5833cf1966b7266f3d8dfb3d3dc7d3/yarl-1.20.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0b5ff0fbb7c9f1b1b5ab53330acbfc5247893069e7716840c8e7d5bb7355038a", size = 131811, upload-time = "2025-06-10T00:44:18.933Z" }, - { url = "https://files.pythonhosted.org/packages/b2/27/584394e1cb76fb771371770eccad35de400e7b434ce3142c2dd27392c968/yarl-1.20.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:14f326acd845c2b2e2eb38fb1346c94f7f3b01a4f5c788f8144f9b630bfff9a3", size = 90078, upload-time = "2025-06-10T00:44:20.635Z" }, - { url = "https://files.pythonhosted.org/packages/bf/9a/3246ae92d4049099f52d9b0fe3486e3b500e29b7ea872d0f152966fc209d/yarl-1.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f60e4ad5db23f0b96e49c018596707c3ae89f5d0bd97f0ad3684bcbad899f1e7", size = 88748, upload-time = "2025-06-10T00:44:22.34Z" }, - { url = "https://files.pythonhosted.org/packages/a3/25/35afe384e31115a1a801fbcf84012d7a066d89035befae7c5d4284df1e03/yarl-1.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:49bdd1b8e00ce57e68ba51916e4bb04461746e794e7c4d4bbc42ba2f18297691", size = 349595, upload-time = "2025-06-10T00:44:24.314Z" }, - { url = "https://files.pythonhosted.org/packages/28/2d/8aca6cb2cabc8f12efcb82749b9cefecbccfc7b0384e56cd71058ccee433/yarl-1.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:66252d780b45189975abfed839616e8fd2dbacbdc262105ad7742c6ae58f3e31", size = 342616, upload-time = "2025-06-10T00:44:26.167Z" }, - { url = "https://files.pythonhosted.org/packages/0b/e9/1312633d16b31acf0098d30440ca855e3492d66623dafb8e25b03d00c3da/yarl-1.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59174e7332f5d153d8f7452a102b103e2e74035ad085f404df2e40e663a22b28", size = 361324, upload-time = "2025-06-10T00:44:27.915Z" }, - { url = "https://files.pythonhosted.org/packages/bc/a0/688cc99463f12f7669eec7c8acc71ef56a1521b99eab7cd3abb75af887b0/yarl-1.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e3968ec7d92a0c0f9ac34d5ecfd03869ec0cab0697c91a45db3fbbd95fe1b653", size = 359676, upload-time = "2025-06-10T00:44:30.041Z" }, - { url = "https://files.pythonhosted.org/packages/af/44/46407d7f7a56e9a85a4c207724c9f2c545c060380718eea9088f222ba697/yarl-1.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1a4fbb50e14396ba3d375f68bfe02215d8e7bc3ec49da8341fe3157f59d2ff5", size = 352614, upload-time = "2025-06-10T00:44:32.171Z" }, - { url = "https://files.pythonhosted.org/packages/b1/91/31163295e82b8d5485d31d9cf7754d973d41915cadce070491778d9c9825/yarl-1.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11a62c839c3a8eac2410e951301309426f368388ff2f33799052787035793b02", size = 336766, upload-time = "2025-06-10T00:44:34.494Z" }, - { url = "https://files.pythonhosted.org/packages/b4/8e/c41a5bc482121f51c083c4c2bcd16b9e01e1cf8729e380273a952513a21f/yarl-1.20.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:041eaa14f73ff5a8986b4388ac6bb43a77f2ea09bf1913df7a35d4646db69e53", size = 364615, upload-time = "2025-06-10T00:44:36.856Z" }, - { url = "https://files.pythonhosted.org/packages/e3/5b/61a3b054238d33d70ea06ebba7e58597891b71c699e247df35cc984ab393/yarl-1.20.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:377fae2fef158e8fd9d60b4c8751387b8d1fb121d3d0b8e9b0be07d1b41e83dc", size = 360982, upload-time = "2025-06-10T00:44:39.141Z" }, - { url = "https://files.pythonhosted.org/packages/df/a3/6a72fb83f8d478cb201d14927bc8040af901811a88e0ff2da7842dd0ed19/yarl-1.20.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1c92f4390e407513f619d49319023664643d3339bd5e5a56a3bebe01bc67ec04", size = 369792, upload-time = "2025-06-10T00:44:40.934Z" }, - { url = "https://files.pythonhosted.org/packages/7c/af/4cc3c36dfc7c077f8dedb561eb21f69e1e9f2456b91b593882b0b18c19dc/yarl-1.20.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d25ddcf954df1754ab0f86bb696af765c5bfaba39b74095f27eececa049ef9a4", size = 382049, upload-time = "2025-06-10T00:44:42.854Z" }, - { url = "https://files.pythonhosted.org/packages/19/3a/e54e2c4752160115183a66dc9ee75a153f81f3ab2ba4bf79c3c53b33de34/yarl-1.20.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:909313577e9619dcff8c31a0ea2aa0a2a828341d92673015456b3ae492e7317b", size = 384774, upload-time = "2025-06-10T00:44:45.275Z" }, - { url = "https://files.pythonhosted.org/packages/9c/20/200ae86dabfca89060ec6447649f219b4cbd94531e425e50d57e5f5ac330/yarl-1.20.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:793fd0580cb9664548c6b83c63b43c477212c0260891ddf86809e1c06c8b08f1", size = 374252, upload-time = "2025-06-10T00:44:47.31Z" }, - { url = "https://files.pythonhosted.org/packages/83/75/11ee332f2f516b3d094e89448da73d557687f7d137d5a0f48c40ff211487/yarl-1.20.1-cp313-cp313-win32.whl", hash = "sha256:468f6e40285de5a5b3c44981ca3a319a4b208ccc07d526b20b12aeedcfa654b7", size = 81198, upload-time = "2025-06-10T00:44:49.164Z" }, - { url = "https://files.pythonhosted.org/packages/ba/ba/39b1ecbf51620b40ab402b0fc817f0ff750f6d92712b44689c2c215be89d/yarl-1.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:495b4ef2fea40596bfc0affe3837411d6aa3371abcf31aac0ccc4bdd64d4ef5c", size = 86346, upload-time = "2025-06-10T00:44:51.182Z" }, - { url = "https://files.pythonhosted.org/packages/43/c7/669c52519dca4c95153c8ad96dd123c79f354a376346b198f438e56ffeb4/yarl-1.20.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f60233b98423aab21d249a30eb27c389c14929f47be8430efa7dbd91493a729d", size = 138826, upload-time = "2025-06-10T00:44:52.883Z" }, - { url = "https://files.pythonhosted.org/packages/6a/42/fc0053719b44f6ad04a75d7f05e0e9674d45ef62f2d9ad2c1163e5c05827/yarl-1.20.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6f3eff4cc3f03d650d8755c6eefc844edde99d641d0dcf4da3ab27141a5f8ddf", size = 93217, upload-time = "2025-06-10T00:44:54.658Z" }, - { url = "https://files.pythonhosted.org/packages/4f/7f/fa59c4c27e2a076bba0d959386e26eba77eb52ea4a0aac48e3515c186b4c/yarl-1.20.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:69ff8439d8ba832d6bed88af2c2b3445977eba9a4588b787b32945871c2444e3", size = 92700, upload-time = "2025-06-10T00:44:56.784Z" }, - { url = "https://files.pythonhosted.org/packages/2f/d4/062b2f48e7c93481e88eff97a6312dca15ea200e959f23e96d8ab898c5b8/yarl-1.20.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cf34efa60eb81dd2645a2e13e00bb98b76c35ab5061a3989c7a70f78c85006d", size = 347644, upload-time = "2025-06-10T00:44:59.071Z" }, - { url = "https://files.pythonhosted.org/packages/89/47/78b7f40d13c8f62b499cc702fdf69e090455518ae544c00a3bf4afc9fc77/yarl-1.20.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8e0fe9364ad0fddab2688ce72cb7a8e61ea42eff3c7caeeb83874a5d479c896c", size = 323452, upload-time = "2025-06-10T00:45:01.605Z" }, - { url = "https://files.pythonhosted.org/packages/eb/2b/490d3b2dc66f52987d4ee0d3090a147ea67732ce6b4d61e362c1846d0d32/yarl-1.20.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f64fbf81878ba914562c672024089e3401974a39767747691c65080a67b18c1", size = 346378, upload-time = "2025-06-10T00:45:03.946Z" }, - { url = "https://files.pythonhosted.org/packages/66/ad/775da9c8a94ce925d1537f939a4f17d782efef1f973039d821cbe4bcc211/yarl-1.20.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6342d643bf9a1de97e512e45e4b9560a043347e779a173250824f8b254bd5ce", size = 353261, upload-time = "2025-06-10T00:45:05.992Z" }, - { url = "https://files.pythonhosted.org/packages/4b/23/0ed0922b47a4f5c6eb9065d5ff1e459747226ddce5c6a4c111e728c9f701/yarl-1.20.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56dac5f452ed25eef0f6e3c6a066c6ab68971d96a9fb441791cad0efba6140d3", size = 335987, upload-time = "2025-06-10T00:45:08.227Z" }, - { url = "https://files.pythonhosted.org/packages/3e/49/bc728a7fe7d0e9336e2b78f0958a2d6b288ba89f25a1762407a222bf53c3/yarl-1.20.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7d7f497126d65e2cad8dc5f97d34c27b19199b6414a40cb36b52f41b79014be", size = 329361, upload-time = "2025-06-10T00:45:10.11Z" }, - { url = "https://files.pythonhosted.org/packages/93/8f/b811b9d1f617c83c907e7082a76e2b92b655400e61730cd61a1f67178393/yarl-1.20.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:67e708dfb8e78d8a19169818eeb5c7a80717562de9051bf2413aca8e3696bf16", size = 346460, upload-time = "2025-06-10T00:45:12.055Z" }, - { url = "https://files.pythonhosted.org/packages/70/fd/af94f04f275f95da2c3b8b5e1d49e3e79f1ed8b6ceb0f1664cbd902773ff/yarl-1.20.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:595c07bc79af2494365cc96ddeb772f76272364ef7c80fb892ef9d0649586513", size = 334486, upload-time = "2025-06-10T00:45:13.995Z" }, - { url = "https://files.pythonhosted.org/packages/84/65/04c62e82704e7dd0a9b3f61dbaa8447f8507655fd16c51da0637b39b2910/yarl-1.20.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7bdd2f80f4a7df852ab9ab49484a4dee8030023aa536df41f2d922fd57bf023f", size = 342219, upload-time = "2025-06-10T00:45:16.479Z" }, - { url = "https://files.pythonhosted.org/packages/91/95/459ca62eb958381b342d94ab9a4b6aec1ddec1f7057c487e926f03c06d30/yarl-1.20.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c03bfebc4ae8d862f853a9757199677ab74ec25424d0ebd68a0027e9c639a390", size = 350693, upload-time = "2025-06-10T00:45:18.399Z" }, - { url = "https://files.pythonhosted.org/packages/a6/00/d393e82dd955ad20617abc546a8f1aee40534d599ff555ea053d0ec9bf03/yarl-1.20.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:344d1103e9c1523f32a5ed704d576172d2cabed3122ea90b1d4e11fe17c66458", size = 355803, upload-time = "2025-06-10T00:45:20.677Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ed/c5fb04869b99b717985e244fd93029c7a8e8febdfcffa06093e32d7d44e7/yarl-1.20.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:88cab98aa4e13e1ade8c141daeedd300a4603b7132819c484841bb7af3edce9e", size = 341709, upload-time = "2025-06-10T00:45:23.221Z" }, - { url = "https://files.pythonhosted.org/packages/24/fd/725b8e73ac2a50e78a4534ac43c6addf5c1c2d65380dd48a9169cc6739a9/yarl-1.20.1-cp313-cp313t-win32.whl", hash = "sha256:b121ff6a7cbd4abc28985b6028235491941b9fe8fe226e6fdc539c977ea1739d", size = 86591, upload-time = "2025-06-10T00:45:25.793Z" }, - { url = "https://files.pythonhosted.org/packages/94/c3/b2e9f38bc3e11191981d57ea08cab2166e74ea770024a646617c9cddd9f6/yarl-1.20.1-cp313-cp313t-win_amd64.whl", hash = "sha256:541d050a355bbbc27e55d906bc91cb6fe42f96c01413dd0f4ed5a5240513874f", size = 93003, upload-time = "2025-06-10T00:45:27.752Z" }, - { url = "https://files.pythonhosted.org/packages/b4/2d/2345fce04cfd4bee161bf1e7d9cdc702e3e16109021035dbb24db654a622/yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77", size = 46542, upload-time = "2025-06-10T00:46:07.521Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520, upload-time = "2025-10-06T14:11:15.465Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504, upload-time = "2025-10-06T14:11:17.106Z" }, + { url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282, upload-time = "2025-10-06T14:11:19.064Z" }, + { url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080, upload-time = "2025-10-06T14:11:20.996Z" }, + { url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696, upload-time = "2025-10-06T14:11:22.847Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121, upload-time = "2025-10-06T14:11:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080, upload-time = "2025-10-06T14:11:27.307Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661, upload-time = "2025-10-06T14:11:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645, upload-time = "2025-10-06T14:11:31.423Z" }, + { url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361, upload-time = "2025-10-06T14:11:33.055Z" }, + { url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451, upload-time = "2025-10-06T14:11:35.136Z" }, + { url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814, upload-time = "2025-10-06T14:11:37.094Z" }, + { url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799, upload-time = "2025-10-06T14:11:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990, upload-time = "2025-10-06T14:11:40.624Z" }, + { url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292, upload-time = "2025-10-06T14:11:42.578Z" }, + { url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888, upload-time = "2025-10-06T14:11:44.863Z" }, + { url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223, upload-time = "2025-10-06T14:11:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981, upload-time = "2025-10-06T14:11:48.845Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303, upload-time = "2025-10-06T14:11:50.897Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820, upload-time = "2025-10-06T14:11:52.549Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203, upload-time = "2025-10-06T14:11:54.225Z" }, + { url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173, upload-time = "2025-10-06T14:11:56.069Z" }, + { url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562, upload-time = "2025-10-06T14:11:58.783Z" }, + { url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828, upload-time = "2025-10-06T14:12:00.686Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551, upload-time = "2025-10-06T14:12:02.628Z" }, + { url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512, upload-time = "2025-10-06T14:12:04.871Z" }, + { url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400, upload-time = "2025-10-06T14:12:06.624Z" }, + { url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140, upload-time = "2025-10-06T14:12:08.362Z" }, + { url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473, upload-time = "2025-10-06T14:12:10.994Z" }, + { url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056, upload-time = "2025-10-06T14:12:13.317Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292, upload-time = "2025-10-06T14:12:15.398Z" }, + { url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload-time = "2025-10-06T14:12:16.935Z" }, + { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, ] From 7d9b118316e634c9f7c3d485b3c8ea2aabff7f5c Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Mon, 17 Nov 2025 08:16:01 +0100 Subject: [PATCH 11/44] refactor: Move github id to discord id mapping file to volume --- README.md | 2 +- docker-compose.yaml | 7 ++++++- github_id_to_discord_id_mapping.yaml | 18 ------------------ 3 files changed, 7 insertions(+), 20 deletions(-) delete mode 100644 github_id_to_discord_id_mapping.yaml diff --git a/README.md b/README.md index 98baafa..e41dc78 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ uv run pytest For deployment follow these steps: - set all environment variables accordingly, -- update your `github_id_to_discord_id_mapping.json`, +- setup file_mount for `github_id_to_discord_id_mapping.json` on `/app/github_usernames_to_discord_id_mapping.json`, - set up a webhook in your GitHub repository to point to your server's `/webhook_endpoint` endpoint, - use Dockerfile to build the image. diff --git a/docker-compose.yaml b/docker-compose.yaml index 7be470a..6bf5fef 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -8,4 +8,9 @@ services: ports: - "8000:8000" env_file: - - .env \ No newline at end of file + - .env + volumes: + - github_id_to_discord_id_mapping:/app/github_id_to_discord_id_mapping.yaml + +volumes: + github_id_to_discord_id_mapping: diff --git a/github_id_to_discord_id_mapping.yaml b/github_id_to_discord_id_mapping.yaml deleted file mode 100644 index c30854e..0000000 --- a/github_id_to_discord_id_mapping.yaml +++ /dev/null @@ -1,18 +0,0 @@ -# Wikipop -MDQ6VXNlcjY2NTE0ODg1: "393756120952602625" -# Norbiros -MDQ6VXNlcjg5ODM3NzI0: "770620808644919307" -# Kubaryt -MDQ6VXNlcjg4MjY4MDYz: "786956683871387698" -# goteusz-maszyk -MDQ6VXNlcjQ1OTQ3Mzc0: "786956683871387698" -# toriishia -U_kgDOCXHesg: "803926455251763210" -# WitaLisek -U_kgDOCc8HIw: "770570853003034625" -# wojpo -U_kgDOB7Ogsw: "637608945409851392" -# sal -U_kgDOCM6eiA: "734838842548289567" -# balwanek -U_kgDOCMhQCg: "1401647878828589309" From 89e5e1f3ca1ce8b3f789e14e376f487722a26c1e Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Mon, 17 Nov 2025 19:38:03 +0100 Subject: [PATCH 12/44] fix: Make Docker service actually stop on SIGTERM --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 3e34673..c7c3b62 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,4 +7,4 @@ WORKDIR /app RUN uv sync --locked EXPOSE 8000 -CMD ".venv/bin/start-app" +CMD [".venv/bin/start-app"] From ebbba3e0960a64c82f61d5c1cda5dc8c76f9ca52 Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Mon, 17 Nov 2025 20:21:27 +0100 Subject: [PATCH 13/44] chore: Disable pre-commit changing files during commiting --- .pre-commit-config.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4eb5653..cf19c00 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -8,6 +8,7 @@ repos: - id: ruff # Run the formatter. - id: ruff-format + args: ["--check"] # Run the tests. - repo: local hooks: @@ -17,4 +18,4 @@ repos: language: system types: [python] pass_filenames: false - always_run: true \ No newline at end of file + always_run: true From 59bce28e2d694b25d59ea61f113d11ebe0272735 Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Mon, 17 Nov 2025 20:55:43 +0100 Subject: [PATCH 14/44] refactor: Make Dockerfile suited for production deployment --- .env.example | 3 ++- Dockerfile | 8 ++++++-- docker-compose.yaml | 2 +- pyproject.toml | 2 ++ src/main.py | 2 +- uv.lock | 16 ++++++++++++++++ 6 files changed, 28 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index b4fdcb0..c0e9e56 100644 --- a/.env.example +++ b/.env.example @@ -3,5 +3,6 @@ FORUM_CHANNEL_ID=your-forum-channel-id GITHUB_TOKEN=your-github-token GITHUB_PROJECT_NODE_ID=your-github-project-node-id GITHUB_ID_TO_DISCORD_ID_MAPPING_PATH=path-to-github-username-to-discord-id-mapping.json -IP_ADDRESS=0.0.0.0:8000 +IP_ADDRESS=0.0.0.0 +PORT=8000 GITHUB_WEBHOOK_SECRET=your-github-webhook-secret \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index c7c3b62..dd5f24c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,5 +6,9 @@ ADD . /app WORKDIR /app RUN uv sync --locked -EXPOSE 8000 -CMD [".venv/bin/start-app"] +EXPOSE $PORT +CMD exec .venv/bin/gunicorn src.server:app \ + --workers 1 \ + --worker-class uvicorn.workers.UvicornWorker \ + --bind "${IP_ADDRESS:-0.0.0.0}:${PORT:-8000}" \ + --log-level info diff --git a/docker-compose.yaml b/docker-compose.yaml index 6bf5fef..dd9e878 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -6,7 +6,7 @@ services: dockerfile: Dockerfile restart: on-failure ports: - - "8000:8000" + - "${PORT:-8000}:${PORT:-8000}" env_file: - .env volumes: diff --git a/pyproject.toml b/pyproject.toml index 42dab67..30f53c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,12 +7,14 @@ dependencies = [ "aiohttp>=3.13.2", "dotenv>=0.9.9", "fastapi[standard]>=0.121.2", + "gunicorn>=23.0.0", "hikari>=2.5.0", "pre-commit>=4.4.0", "pytest>=9.0.1", "pytest-asyncio>=1.3.0", "ruff>=0.14.5", "setuptools>=80.9.0", + "uvicorn[standard]>=0.38.0", ] [build-system] diff --git a/src/main.py b/src/main.py index 0ecbab5..5d98b31 100644 --- a/src/main.py +++ b/src/main.py @@ -6,7 +6,7 @@ def main(): dotenv.load_dotenv() - host, port = os.getenv("IP_ADDRESS", "0.0.0.0:8000").split(":") + host, port = os.getenv("IP_ADDRESS", "0.0.0.0"), os.getenv("PORT", "8000") uvicorn.run("src.server:app", host=host, port=int(port), reload=True) diff --git a/uv.lock b/uv.lock index 087fe79..0a9da47 100644 --- a/uv.lock +++ b/uv.lock @@ -328,12 +328,14 @@ dependencies = [ { name = "aiohttp" }, { name = "dotenv" }, { name = "fastapi", extra = ["standard"] }, + { name = "gunicorn" }, { name = "hikari" }, { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "ruff" }, { name = "setuptools" }, + { name = "uvicorn", extra = ["standard"] }, ] [package.optional-dependencies] @@ -346,6 +348,7 @@ requires-dist = [ { name = "aiohttp", specifier = ">=3.13.2" }, { name = "dotenv", specifier = ">=0.9.9" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.121.2" }, + { name = "gunicorn", specifier = ">=23.0.0" }, { name = "hikari", specifier = ">=2.5.0" }, { name = "pre-commit", specifier = ">=4.4.0" }, { name = "pytest", specifier = ">=9.0.1" }, @@ -353,9 +356,22 @@ requires-dist = [ { name = "pytest-asyncio", specifier = ">=1.3.0" }, { name = "ruff", specifier = ">=0.14.5" }, { name = "setuptools", specifier = ">=80.9.0" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.38.0" }, ] provides-extras = ["test"] +[[package]] +name = "gunicorn" +version = "23.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload-time = "2024-08-10T20:25:27.378Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029, upload-time = "2024-08-10T20:25:24.996Z" }, +] + [[package]] name = "h11" version = "0.16.0" From 9aacd60794c4a0406eed1760ad6c3fbd6f9807f2 Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Mon, 17 Nov 2025 21:30:42 +0100 Subject: [PATCH 15/44] chore: Add dependency groups and don't use dev dependencies on prod --- Dockerfile | 2 +- pyproject.toml | 26 +++++++++++++++++--------- uv.lock | 37 +++++++++++++++++++++---------------- 3 files changed, 39 insertions(+), 26 deletions(-) diff --git a/Dockerfile b/Dockerfile index dd5f24c..b014cd9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ ADD . /app WORKDIR /app -RUN uv sync --locked +RUN uv sync --locked --no-default-groups --group prod --compile-bytecode EXPOSE $PORT CMD exec .venv/bin/gunicorn src.server:app \ diff --git a/pyproject.toml b/pyproject.toml index 30f53c6..d6f546c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,15 +6,10 @@ requires-python = ">=3.14" dependencies = [ "aiohttp>=3.13.2", "dotenv>=0.9.9", - "fastapi[standard]>=0.121.2", - "gunicorn>=23.0.0", + "fastapi>=0.121.2", "hikari>=2.5.0", - "pre-commit>=4.4.0", - "pytest>=9.0.1", - "pytest-asyncio>=1.3.0", - "ruff>=0.14.5", "setuptools>=80.9.0", - "uvicorn[standard]>=0.38.0", + "uvicorn>=0.38.0", ] [build-system] @@ -24,8 +19,21 @@ build-backend = "setuptools.build_meta" [tool.setuptools] packages = ["src"] -[project.optional-dependencies] -test = ["pytest"] +[dependency-groups] +prod = [ + "gunicorn>=23.0.0", + "uvicorn[standard]>=0.38.0" +] +dev = [ + "pytest>=9.0.1", + "pytest-asyncio>=1.3.0", + "ruff>=0.14.5", + "pre-commit>=4.4.0", + "httpx>=0.28.1" # required for testing FastAPI endpoints +] + +[tool.uv] +default-groups = ["dev"] [tool.pytest.ini_options] testpaths = ["src/tests"] diff --git a/uv.lock b/uv.lock index 0a9da47..32899a9 100644 --- a/uv.lock +++ b/uv.lock @@ -134,14 +134,14 @@ wheels = [ [[package]] name = "click" -version = "8.3.0" +version = "8.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, ] [[package]] @@ -328,19 +328,20 @@ dependencies = [ { name = "aiohttp" }, { name = "dotenv" }, { name = "fastapi", extra = ["standard"] }, - { name = "gunicorn" }, { name = "hikari" }, - { name = "pre-commit" }, - { name = "pytest" }, - { name = "pytest-asyncio" }, - { name = "ruff" }, +] + +[package.dev-dependencies] +build = [ + { name = "gunicorn" }, { name = "setuptools" }, { name = "uvicorn", extra = ["standard"] }, ] - -[package.optional-dependencies] -test = [ +dev = [ + { name = "pre-commit" }, { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, ] [package.metadata] @@ -348,17 +349,21 @@ requires-dist = [ { name = "aiohttp", specifier = ">=3.13.2" }, { name = "dotenv", specifier = ">=0.9.9" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.121.2" }, - { name = "gunicorn", specifier = ">=23.0.0" }, { name = "hikari", specifier = ">=2.5.0" }, +] + +[package.metadata.requires-dev] +build = [ + { name = "gunicorn", specifier = ">=23.0.0" }, + { name = "setuptools", specifier = ">=80.9.0" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.38.0" }, +] +dev = [ { name = "pre-commit", specifier = ">=4.4.0" }, { name = "pytest", specifier = ">=9.0.1" }, - { name = "pytest", marker = "extra == 'test'" }, { name = "pytest-asyncio", specifier = ">=1.3.0" }, { name = "ruff", specifier = ">=0.14.5" }, - { name = "setuptools", specifier = ">=80.9.0" }, - { name = "uvicorn", extras = ["standard"], specifier = ">=0.38.0" }, ] -provides-extras = ["test"] [[package]] name = "gunicorn" From 61ff65f0d778cbf83cb18356cce9fcef629b51b9 Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Tue, 18 Nov 2025 09:56:57 +0100 Subject: [PATCH 16/44] fix: Expect TypeError while fetching Github graphql API --- src/tests/test_unit/test_utils.py | 24 ++++++++++++++++++++++++ src/utils/github_api.py | 6 +++--- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/tests/test_unit/test_utils.py b/src/tests/test_unit/test_utils.py index bdc6e3f..e84db68 100644 --- a/src/tests/test_unit/test_utils.py +++ b/src/tests/test_unit/test_utils.py @@ -114,6 +114,14 @@ async def test_fetch_item_name_success(mock_post_request): assert await utils.fetch_item_name("") == "42" +@patch.object(ClientSession, "post") +async def test_fetch_item_name_partial(mock_post_request): + mock_response = {"data": {"node": {"content": None}}} + mock_post_request.return_value = MockResponse(mock_response) + + assert await utils.fetch_item_name("") is None + + @patch.object(ClientSession, "post") async def test_fetch_item_name_none(mock_post_request): mock_post_request.return_value = MockResponse({}) @@ -135,6 +143,14 @@ async def test_fetch_assignees_success(mock_post_request): assert await github_api.fetch_assignees("") == ["MDQ6VXNlcjg4MjY4MDYz", "MDQ6VXNlcjg5ODM3NzI0"] +@patch.object(ClientSession, "post") +async def test_fetch_assignees_partial(mock_post_request): + mock_response = {"data": {"node": {"content": None}}} + mock_post_request.return_value = MockResponse(mock_response) + + assert await github_api.fetch_assignees("") == [] + + @patch.object(ClientSession, "post") async def test_fetch_assignees_none(mock_post_request): mock_post_request.return_value = MockResponse({}) @@ -150,6 +166,14 @@ async def test_fetch_single_select_value_success(mock_post_request): assert await github_api.fetch_single_select_value("", "Salieri") == "Dziengiel" +@patch.object(ClientSession, "post") +async def test_fetch_single_select_value_partial(mock_post_request): + mock_response = {"data": {"node": {"fieldValueByName": None}}} + mock_post_request.return_value = MockResponse(mock_response) + + assert await github_api.fetch_single_select_value("", "Salieri") is None + + @patch.object(ClientSession, "post") async def test_fetch_single_select_value_none(mock_post_request): mock_response = {} diff --git a/src/utils/github_api.py b/src/utils/github_api.py index 1fc862f..c41a2e4 100644 --- a/src/utils/github_api.py +++ b/src/utils/github_api.py @@ -39,7 +39,7 @@ async def fetch_item_name(item_node_id: str | None) -> str | None: try: item_name: str | None = response_body["data"]["node"]["content"]["title"] - except KeyError, AttributeError: + except TypeError, KeyError, AttributeError: return None return item_name @@ -92,7 +92,7 @@ async def fetch_assignees(item_node_id: str | None) -> list[str]: response_body = await response.json() try: assignees_data = response_body["data"]["node"]["content"]["assignees"]["nodes"] - except KeyError, AttributeError: + except TypeError, KeyError, AttributeError: return [] assignees = [assignee.get("id", None) for assignee in assignees_data] @@ -129,7 +129,7 @@ async def fetch_single_select_value(item_node_id: str | None, field_name: str | try: name: str | None = response_body["data"]["node"]["fieldValueByName"]["name"] - except KeyError, AttributeError: + except TypeError, KeyError, AttributeError: return None return name From 5c76c5376c555789d06d7bd99becb3bebc2a115a Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Tue, 18 Nov 2025 10:10:49 +0100 Subject: [PATCH 17/44] feat: Add error handling in bot task --- src/bot.py | 5 ++++- src/server.py | 13 ++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/bot.py b/src/bot.py index 5be5237..7f82f2e 100644 --- a/src/bot.py +++ b/src/bot.py @@ -31,7 +31,10 @@ async def run(state: asyncio.Queue[ProjectItemEvent], stop_after_one_event: bool raise ForumChannelNotFound(f"Forum channel with ID {forum_channel_id} not found.") while True: - await process_update(client, forum_channel_id, discord_guild_id, forum_channel, state) + try: + await process_update(client, forum_channel_id, discord_guild_id, forum_channel, state) + except Exception as error: + bot_error(f"Error processing update: {error}") if stop_after_one_event: break diff --git a/src/server.py b/src/server.py index 3a559cf..4aa080b 100644 --- a/src/server.py +++ b/src/server.py @@ -21,7 +21,7 @@ single_select_type_from_field_name, ) from src.utils.github_api import fetch_assignees, fetch_item_name, fetch_single_select_value -from src.utils.logging import server_error, server_info, server_warning +from src.utils.logging import bot_error, server_error, server_info, server_warning update_queue: asyncio.Queue[ProjectItemEvent] = asyncio.Queue() @@ -30,6 +30,7 @@ async def lifespan(app: FastAPI): # startup task = asyncio.create_task(run(update_queue)) + task.add_done_callback(handle_task_exception) yield # shutdown task.cancel() @@ -39,6 +40,16 @@ async def lifespan(app: FastAPI): pass +def handle_task_exception(task: asyncio.Task): + try: + exception = task.exception() + except asyncio.CancelledError: + return + + if exception: + bot_error(f"Bot task crashed: {exception}") + + app = FastAPI(lifespan=lifespan) From 1fd7e69b0e938b43669bd91e6f56da723ef146b3 Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Tue, 18 Nov 2025 10:25:20 +0100 Subject: [PATCH 18/44] fix: Use channel fetched after creating new tag, so tags aren't removed in next tag creation --- src/bot.py | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/bot.py b/src/bot.py index 7f82f2e..0dbe1b0 100644 --- a/src/bot.py +++ b/src/bot.py @@ -32,7 +32,9 @@ async def run(state: asyncio.Queue[ProjectItemEvent], stop_after_one_event: bool while True: try: - await process_update(client, forum_channel_id, discord_guild_id, forum_channel, state) + return_value = await process_update(client, forum_channel_id, discord_guild_id, forum_channel, state) + if return_value is not None: + forum_channel = return_value except Exception as error: bot_error(f"Error processing update: {error}") if stop_after_one_event: @@ -45,7 +47,7 @@ async def process_update( discord_guild_id: int, forum_channel: GuildForumChannel, state: asyncio.Queue[ProjectItemEvent], -): +) -> GuildForumChannel | None: event = await state.get() bot_info(f"Processing event for item: {event.name}") post_id_or_post = await get_post_id(event.name, discord_guild_id, forum_channel_id, client) @@ -72,7 +74,7 @@ async def process_update( bot_error(f"Post with ID {post.id} is not a GuildPublicThread.") except AttributeError: bot_error(f"Post with ID {post_id_or_post} is not a Discord channel object.") - return + return None if isinstance(event, SimpleProjectItemEvent): match event.event_type.value: @@ -87,7 +89,7 @@ async def process_update( case "deleted": await client.delete_channel(post.id) bot_info(f"Post {event.name} deleted.") - return + return None elif isinstance(event, ProjectItemEditedAssignees): assignee_mentions: list[str] = [] assignee_discord_ids: list[int] = [] @@ -103,13 +105,13 @@ async def process_update( message = f"Osoby przypisane do taska edytowane, aktualni przypisani: {', '.join(assignee_mentions)}" await client.create_message(post.id, message, user_mentions=assignee_discord_ids) bot_info(f"Post {event.name} assignees updated.") - return + return None elif isinstance(event, ProjectItemEditedBody): message = f"Opis taska zaktualizowany przez: {user_text_mention}. Nowy opis: \n{event.new_body}" bot_info(f"Post {event.name} body updated.") elif isinstance(event, ProjectItemEditedTitle): await client.edit_channel(post.id, name=event.new_title) - return + return None elif isinstance(event, ProjectItemEditedSingleSelect): available_tags = list(forum_channel.available_tags) current_tag_ids = list(post.applied_tag_ids) @@ -123,9 +125,7 @@ async def process_update( if new_tag is None: bot_info(f"Tag {new_tag_name} not found, creating new tag.") - new_tag = ForumTag(name=new_tag_name) - available_tags.append(new_tag) - await client.edit_channel(forum_channel.id, available_tags=available_tags) + await client.edit_channel(forum_channel.id, available_tags=[*available_tags, ForumTag(name=new_tag_name)]) forum_channel = await fetch_forum_channel(client, forum_channel_id) if forum_channel is None: raise ForumChannelNotFound(f"Forum channel with ID {forum_channel_id} not found.") @@ -136,8 +136,10 @@ async def process_update( await client.edit_channel(post.id, applied_tags=current_tag_ids) bot_info(f"Post {event.name} label updated.") - return + return forum_channel else: - return + return None await client.create_message(post.id, message, user_mentions=user_mentions) + + return None From 1c9270beae33675651a1118d2cafcbee36730809 Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Tue, 18 Nov 2025 17:13:13 +0100 Subject: [PATCH 19/44] fix: Remove trailing .> in task creation message --- src/bot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bot.py b/src/bot.py index 0dbe1b0..1eb1111 100644 --- a/src/bot.py +++ b/src/bot.py @@ -56,7 +56,7 @@ async def process_update( user_text_mention = f"<@{author_discord_id}>" if author_discord_id else "nieznany użytkownik" if post_id_or_post is None: bot_info(f"Post not found, creating new post for item: {event.name}") - message = f"Nowy task stworzony {event.name} przez: {user_text_mention}.>" + message = f"Nowy task stworzony {event.name} przez: {user_text_mention}" post: GuildPublicThread = await client.create_forum_post( forum_channel, event.name, From 017cf17be5a0bd792672afebe935229d01b73956 Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Thu, 20 Nov 2025 13:42:39 +0100 Subject: [PATCH 20/44] refactor: Better logging --- src/bot.py | 37 ++++---- src/server.py | 102 ++++++++++++---------- src/tests/test_e2e.py | 10 ++- src/tests/test_integration/test_bot.py | 8 +- src/tests/test_integration/test_server.py | 16 ++-- src/tests/test_unit/test_bot.py | 43 ++++++--- src/tests/test_unit/test_server.py | 20 +++-- src/tests/test_unit/test_utils.py | 12 ++- src/utils/__init__.py | 6 +- src/utils/data_types.py | 2 +- src/utils/github_api.py | 17 ++-- src/utils/logging.py | 34 -------- 12 files changed, 165 insertions(+), 142 deletions(-) delete mode 100644 src/utils/logging.py diff --git a/src/bot.py b/src/bot.py index 1eb1111..bacd1f3 100644 --- a/src/bot.py +++ b/src/bot.py @@ -1,10 +1,11 @@ import asyncio +import logging import os from hikari import ForumTag, GuildForumChannel, GuildPublicThread, RESTApp, TokenType from hikari.impl import RESTClientImpl -from src.utils import retrieve_discord_id +from src.utils import add_bot_log_prefix, retrieve_discord_id from src.utils.data_types import ( ProjectItemEditedAssignees, ProjectItemEditedBody, @@ -15,15 +16,14 @@ ) from src.utils.discord_rest_client import fetch_forum_channel, get_new_tag, get_post_id from src.utils.error import ForumChannelNotFound -from src.utils.logging import bot_error, bot_info -async def run(state: asyncio.Queue[ProjectItemEvent], stop_after_one_event: bool = False): +async def run(state: asyncio.Queue[ProjectItemEvent], logger: logging.Logger, stop_after_one_event: bool = False): discord_rest = RESTApp() await discord_rest.start() async with discord_rest.acquire(os.getenv("DISCORD_BOT_TOKEN"), token_type=TokenType.BOT) as client: - bot_info("Discord client acquired.") + logger.info(add_bot_log_prefix("Discord client acquired.")) forum_channel_id = int(os.getenv("FORUM_CHANNEL_ID")) discord_guild_id = int(os.getenv("DISCORD_GUILD_ID")) forum_channel = await fetch_forum_channel(client, forum_channel_id) @@ -32,11 +32,13 @@ async def run(state: asyncio.Queue[ProjectItemEvent], stop_after_one_event: bool while True: try: - return_value = await process_update(client, forum_channel_id, discord_guild_id, forum_channel, state) + return_value = await process_update( + client, forum_channel_id, discord_guild_id, forum_channel, state, logger + ) if return_value is not None: forum_channel = return_value except Exception as error: - bot_error(f"Error processing update: {error}") + logger.error(add_bot_log_prefix(f"Error processing update: {error}")) if stop_after_one_event: break @@ -47,15 +49,16 @@ async def process_update( discord_guild_id: int, forum_channel: GuildForumChannel, state: asyncio.Queue[ProjectItemEvent], + logger: logging.Logger, ) -> GuildForumChannel | None: event = await state.get() - bot_info(f"Processing event for item: {event.name}") + logger.info(add_bot_log_prefix(f"Processing event for item: {event.name}")) post_id_or_post = await get_post_id(event.name, discord_guild_id, forum_channel_id, client) author_discord_id = retrieve_discord_id(event.sender) user_mentions = [author_discord_id] if author_discord_id else [] user_text_mention = f"<@{author_discord_id}>" if author_discord_id else "nieznany użytkownik" if post_id_or_post is None: - bot_info(f"Post not found, creating new post for item: {event.name}") + logger.info(add_bot_log_prefix(f"Post not found, creating new post for item: {event.name}")) message = f"Nowy task stworzony {event.name} przez: {user_text_mention}" post: GuildPublicThread = await client.create_forum_post( forum_channel, @@ -71,9 +74,9 @@ async def process_update( if not isinstance(post, GuildPublicThread): try: - bot_error(f"Post with ID {post.id} is not a GuildPublicThread.") + logger.error(add_bot_log_prefix(f"Post with ID {post.id} is not a GuildPublicThread.")) except AttributeError: - bot_error(f"Post with ID {post_id_or_post} is not a Discord channel object.") + logger.error(add_bot_log_prefix(f"Post with ID {post_id_or_post} is not a GuildPublicThread.")) return None if isinstance(event, SimpleProjectItemEvent): @@ -81,14 +84,14 @@ async def process_update( case "archived": message = f"Task zarchiwizowany przez: {user_text_mention}." await client.edit_channel(post.id, archived=True) - bot_info(f"Post {event.name} archived.") + logger.info(add_bot_log_prefix(f"Post {event.name} archived.")) case "restored": message = f"Task przywrócony przez: {user_text_mention}." await client.edit_channel(post.id, archived=False) - bot_info(f"Post {event.name} restored.") + logger.info(add_bot_log_prefix(f"Post {event.name} restored.")) case "deleted": await client.delete_channel(post.id) - bot_info(f"Post {event.name} deleted.") + logger.info(add_bot_log_prefix(f"Post {event.name} deleted.")) return None elif isinstance(event, ProjectItemEditedAssignees): assignee_mentions: list[str] = [] @@ -104,11 +107,11 @@ async def process_update( message = f"Osoby przypisane do taska edytowane, aktualni przypisani: {', '.join(assignee_mentions)}" await client.create_message(post.id, message, user_mentions=assignee_discord_ids) - bot_info(f"Post {event.name} assignees updated.") + logger.info(add_bot_log_prefix(f"Post {event.name} assignees updated.")) return None elif isinstance(event, ProjectItemEditedBody): message = f"Opis taska zaktualizowany przez: {user_text_mention}. Nowy opis: \n{event.new_body}" - bot_info(f"Post {event.name} body updated.") + logger.info(add_bot_log_prefix(f"Post {event.name} body updated.")) elif isinstance(event, ProjectItemEditedTitle): await client.edit_channel(post.id, name=event.new_title) return None @@ -124,7 +127,7 @@ async def process_update( new_tag = get_new_tag(new_tag_name, available_tags) if new_tag is None: - bot_info(f"Tag {new_tag_name} not found, creating new tag.") + logger.info(add_bot_log_prefix(f"Tag {new_tag_name} not found, creating new tag.")) await client.edit_channel(forum_channel.id, available_tags=[*available_tags, ForumTag(name=new_tag_name)]) forum_channel = await fetch_forum_channel(client, forum_channel_id) if forum_channel is None: @@ -135,7 +138,7 @@ async def process_update( current_tag_ids.append(new_tag.id) await client.edit_channel(post.id, applied_tags=current_tag_ids) - bot_info(f"Post {event.name} label updated.") + logger.info(add_bot_log_prefix(f"Post {event.name} tag updated to {new_tag_name}.")) return forum_channel else: return None diff --git a/src/server.py b/src/server.py index 4aa080b..fc59c73 100644 --- a/src/server.py +++ b/src/server.py @@ -1,10 +1,13 @@ import asyncio import json +import logging import os from contextlib import asynccontextmanager +from json import JSONDecodeError from typing import Any -from fastapi import FastAPI, Request +from fastapi import FastAPI, HTTPException, Request +from starlette.exceptions import HTTPException as StarletteHttpException from starlette.responses import JSONResponse from src.bot import run @@ -21,7 +24,6 @@ single_select_type_from_field_name, ) from src.utils.github_api import fetch_assignees, fetch_item_name, fetch_single_select_value -from src.utils.logging import bot_error, server_error, server_info, server_warning update_queue: asyncio.Queue[ProjectItemEvent] = asyncio.Queue() @@ -29,7 +31,8 @@ @asynccontextmanager async def lifespan(app: FastAPI): # startup - task = asyncio.create_task(run(update_queue)) + app.logger = logging.getLogger("uvicorn.error") + task = asyncio.create_task(run(update_queue, app.logger)) task.add_done_callback(handle_task_exception) yield # shutdown @@ -47,53 +50,55 @@ def handle_task_exception(task: asyncio.Task): return if exception: - bot_error(f"Bot task crashed: {exception}") + app.logger.error(f"Bot task crashed: {exception}") app = FastAPI(lifespan=lifespan) +@app.exception_handler(StarletteHttpException) +async def http_exception_handler(_request: Request, exception: StarletteHttpException) -> JSONResponse: + app.logger.error(f"HTTP exception occurred: {exception.detail}") + return JSONResponse(status_code=exception.status_code, content={"detail": exception.detail}) + + +@app.exception_handler(KeyError) +async def key_error_exception_handler(_request: Request, exception: KeyError) -> JSONResponse: + app.logger.error(f"KeyError occurred: {str(exception)}") + return JSONResponse(status_code=400, content={"detail": f"Missing property in body: {str(exception)}"}) + + +@app.exception_handler(Exception) +async def default_exception_handler(_request: Request, exception: Exception) -> JSONResponse: + app.logger.error(f"Unhandled exception occurred: {str(exception)}") + return JSONResponse(status_code=500, content={"detail": "Internal server error."}) + + @app.post("/webhook_endpoint") async def webhook_endpoint(request: Request) -> JSONResponse: body_bytes = await request.body() if not body_bytes: - server_error("Received empty body in webhook request.") return JSONResponse(status_code=400, content={"detail": "Missing request body."}) signature = request.headers.get("X-Hub-Signature-256") if signature: correct_signature = verify_secret(os.getenv("GITHUB_WEBHOOK_SECRET", ""), body_bytes, signature) if not correct_signature: - server_error("Invalid signature in webhook request.") - return JSONResponse(status_code=401, content={"detail": "Invalid signature"}) + raise HTTPException(status_code=401, detail="Invalid signature.") elif os.getenv("GITHUB_WEBHOOK_SECRET", ""): - server_error("Missing signature in webhook request.") - return JSONResponse(status_code=401, content={"detail": "Missing signature"}) + raise HTTPException(status_code=401, detail="Missing signature.") else: - server_warning( - "Signature verification is disabled. To enable it set the 'GITHUB_WEBHOOK_SECRET' environment variable." - ) + app.logger.warning("No signature provided and no secret set; skipping verification.") try: body: dict[str, Any] = json.loads(body_bytes) - except json.JSONDecodeError: - server_error("Invalid JSON data in webhook request.") - return JSONResponse(status_code=400, content={"detail": "Invalid JSON data."}) - projects_v2_item: dict[str, Any] = body.get("projects_v2_item", {}) - if not projects_v2_item: - server_error("Missing projects_v2_item in webhook payload.") - return JSONResponse(status_code=400, content={"detail": "Missing projects_v2_item in payload."}) - project_node_id: str | None = projects_v2_item.get("project_node_id", None) - if project_node_id is None or project_node_id != os.getenv("GITHUB_PROJECT_NODE_ID"): - server_error("Invalid project_node_id in webhook payload.") - return JSONResponse(status_code=400, content={"detail": "Invalid project_node_id."}) - - item_node_id: str | None = projects_v2_item.get("node_id", None) - if item_node_id is None: - server_error("Missing item_node_id in webhook payload.") - return JSONResponse(status_code=400, content={"detail": "Missing item_node_id in payload."}) + except JSONDecodeError: + raise HTTPException(status_code=400, detail="Invalid JSON payload.") from None + projects_v2_item: dict[str, Any] = body["projects_v2_item"] + project_node_id: str | None = projects_v2_item["project_node_id"] + if project_node_id != os.getenv("GITHUB_PROJECT_NODE_ID"): + raise HTTPException(status_code=400, detail="Invalid project_node_id.") + + item_node_id: str | None = projects_v2_item["node_id"] item_name = await get_item_name(item_node_id) - if item_name is None: - server_error("Could not fetch item name.") - return JSONResponse(status_code=500, content={"detail": "Could not fetch item name."}) if body.get("action") == "edited": project_item_event = await process_edition(body, item_name) @@ -102,17 +107,15 @@ async def webhook_endpoint(request: Request) -> JSONResponse: body["action"], item_name, body.get("sender", {}).get("node_id", "Unknown") ) else: - server_error("Missing action in webhook payload.") - return JSONResponse(status_code=400, content={"detail": "Missing action in payload."}) + raise HTTPException(status_code=400, detail="Missing action in payload.") - if project_item_event is not None: - await update_queue.put(project_item_event) + await update_queue.put(project_item_event) - server_info(f"Received webhook for item: {item_name}") + app.logger.info(f"Received webhook event for item: {item_name}") return JSONResponse(content={"detail": "Successfully received webhook data"}) -async def process_edition(body: dict[str, Any], item_name: str) -> ProjectItemEdited | None: +async def process_edition(body: dict[str, Any], item_name: str) -> ProjectItemEdited: editor: str = body.get("sender", {}).get("node_id", "Unknown") body_changed: dict[str, Any] | None = body.get("changes", {}).get("body", None) @@ -124,31 +127,40 @@ async def process_edition(body: dict[str, Any], item_name: str) -> ProjectItemEd field_changed: dict[str, Any] | None = body.get("changes", {}).get("field_value", None) if field_changed is None: - return None + raise HTTPException(status_code=400, detail="Failed to recognize the edited event.") + + node_id: str | None = body.get("projects_v2_item", {}).get("node_id", None) + if node_id is None: + raise HTTPException(status_code=400, detail="Missing item node ID.") match field_changed["field_type"]: case "assignees": - new_assignees = await fetch_assignees(body.get("projects_v2_item", {}).get("node_id", None)) + new_assignees = await fetch_assignees(node_id) project_item_edited = ProjectItemEditedAssignees(item_name, editor, new_assignees) return project_item_edited case "title": - new_title = await fetch_item_name(body.get("projects_v2_item", {}).get("node_id", None)) + new_title = await fetch_item_name(node_id) project_item_edited = ProjectItemEditedTitle(item_name, editor, new_title) return project_item_edited case "single_select": new_value: str | None = field_changed.get("to", {}).get("name", None) + field_name: str | None = field_changed.get("field_name", None) + if field_name is None: + raise HTTPException(status_code=400, detail="Missing field name for single select field.") if new_value is None: - new_value = await fetch_single_select_value( - body.get("projects_v2_item", {}).get("node_id", None), field_changed.get("field_name", None) - ) - value_type = single_select_type_from_field_name(field_changed.get("field_name", None)) + new_value = await fetch_single_select_value(node_id, field_name) + value_type = single_select_type_from_field_name(field_name) + if value_type is None: + raise HTTPException(status_code=400, detail=f"Unknown single select field name: {field_name}") project_item_edited = ProjectItemEditedSingleSelect(item_name, editor, new_value, value_type) return project_item_edited case "iteration": new_value = field_changed.get("to", {}).get("title", None) + if new_value is None: + raise HTTPException(status_code=400, detail="Missing new value for iteration field.") project_item_edited = ProjectItemEditedSingleSelect( item_name, editor, new_value, SingleSelectType.ITERATION ) return project_item_edited - return None + raise HTTPException(status_code=400, detail=f"Unknown field type: {field_changed['field_type']}") diff --git a/src/tests/test_e2e.py b/src/tests/test_e2e.py index 4b8326e..0bad9e5 100644 --- a/src/tests/test_e2e.py +++ b/src/tests/test_e2e.py @@ -1,6 +1,7 @@ # ruff: noqa: F811 import asyncio import json +from logging import Logger from unittest.mock import AsyncMock, mock_open, patch import aiohttp @@ -16,6 +17,7 @@ from src.utils import generate_signature +@patch.object(Logger, "info") @patch.object(RESTClientImpl, "create_message", new_callable=AsyncMock) @patch("builtins.open", new_callable=mock_open, read_data="") @patch.object(RESTClientImpl, "fetch_active_threads", new_callable=AsyncMock) @@ -33,10 +35,10 @@ async def test_e2e( mock_fetch_active_threads, _mock_open, mock_create_message, + mock_logger, rest_client_mock, forum_channel_mock, post_mock, - capfd, ): mock_restapp_acquire.return_value = RestClientContextManagerMock(rest_client_mock) mock_fetch_channel.side_effect = [forum_channel_mock, post_mock] @@ -74,9 +76,11 @@ async def test_e2e( assert resp.status == 200 for _ in range(500): # up to ~5 seconds total - out, _ = capfd.readouterr() - if "body updated" in out: + try: + mock_logger.assert_any_call("[BOT] Post audacity4 body updated.") break + except AssertionError: + pass await asyncio.sleep(0.01) else: pytest.fail("Expected log 'body updated' not found in output") diff --git a/src/tests/test_integration/test_bot.py b/src/tests/test_integration/test_bot.py index 567d78a..3c028c5 100644 --- a/src/tests/test_integration/test_bot.py +++ b/src/tests/test_integration/test_bot.py @@ -7,6 +7,7 @@ from hikari.impl import RESTClientImpl from src.bot import run +from src.tests.test_unit.test_bot import logger_mock # noqa: F401 from src.tests.test_unit.test_utils import MockShelf, forum_channel_mock, rest_client_mock # noqa: F401 from src.utils.data_types import ProjectItemEvent from src.utils.error import ForumChannelNotFound @@ -30,14 +31,14 @@ async def __aexit__(self, exc_type, exc_val, exc_tb): @patch.object(RESTApp, "start", new_callable=AsyncMock) @patch("os.getenv") async def test_forum_channel_not_found( - mock_os_getenv, _mock_restapp_start, mock_restapp_acquire, mock_fetch_channel, rest_client_mock + mock_os_getenv, _mock_restapp_start, mock_restapp_acquire, mock_fetch_channel, rest_client_mock, logger_mock ): mock_os_getenv.side_effect = ["some_token", 1, 2] mock_restapp_acquire.return_value = RestClientContextManagerMock(rest_client_mock) mock_fetch_channel.return_value = None with pytest.raises(ForumChannelNotFound): update_queue = asyncio.Queue() - await run(update_queue) + await run(update_queue, logger_mock) @patch("builtins.open", new_callable=mock_open, read_data="") @@ -61,6 +62,7 @@ async def test_basic_event_only_creation( _mock_open, rest_client_mock, forum_channel_mock, + logger_mock, ): mock_os_getenv.side_effect = ["some_token", 1, 2, "some_path"] mock_restapp_acquire.return_value = RestClientContextManagerMock(rest_client_mock) @@ -72,5 +74,5 @@ async def test_basic_event_only_creation( mock_create_forum_post.return_value = "created_forum_post" update_queue = asyncio.Queue() await update_queue.put(ProjectItemEvent(name="Test Item", sender="test_sender")) - await run(update_queue, stop_after_one_event=True) + await run(update_queue, logger_mock, stop_after_one_event=True) mock_create_forum_post.assert_called() diff --git a/src/tests/test_integration/test_server.py b/src/tests/test_integration/test_server.py index 98bfe35..25a2674 100644 --- a/src/tests/test_integration/test_server.py +++ b/src/tests/test_integration/test_server.py @@ -1,4 +1,5 @@ import json +import logging from typing import Any from unittest.mock import patch @@ -10,6 +11,7 @@ from src.utils import generate_signature test_client = TestClient(app) +test_client.app.logger = logging.getLogger("uvicorn.error") def test_missing_body(): @@ -27,7 +29,7 @@ def test_invalid_signature(mock_os_getenv): ) assert response.status_code == 401 - assert response.json() == {"detail": "Invalid signature"} + assert response.json() == {"detail": "Invalid signature."} @patch("os.getenv") @@ -40,7 +42,7 @@ def test_missing_signature(mock_os_getenv): ) assert response.status_code == 401 - assert response.json() == {"detail": "Missing signature"} + assert response.json() == {"detail": "Missing signature."} @patch("os.getenv") @@ -53,7 +55,7 @@ def test_invalid_json(mock_os_getenv): headers={"X-Hub-Signature-256": signature}, ) assert response.status_code == 400 - assert response.json() == {"detail": "Invalid JSON data."} + assert response.json() == {"detail": "Invalid JSON payload."} @patch("os.getenv") @@ -66,7 +68,7 @@ def test_missing_projects_v2_item(mock_os_getenv): headers={"X-Hub-Signature-256": signature}, ) assert response.status_code == 400 - assert response.json() == {"detail": "Missing projects_v2_item in payload."} + assert response.json() == {"detail": "Missing property in body: 'projects_v2_item'"} @patch("os.getenv") @@ -79,7 +81,7 @@ def test_missing_project_node_id(mock_os_getenv): headers={"X-Hub-Signature-256": signature}, ) assert response.status_code == 400 - assert response.json() == {"detail": "Invalid project_node_id."} + assert response.json() == {"detail": "Missing property in body: 'project_node_id'"} @patch("os.getenv") @@ -105,7 +107,7 @@ def test_missing_item_node_id(mock_os_getenv): headers={"X-Hub-Signature-256": signature}, ) assert response.status_code == 400 - assert response.json() == {"detail": "Missing item_node_id in payload."} + assert response.json() == {"detail": "Missing property in body: 'node_id'"} @patch.object(ClientSession, "post") @@ -152,7 +154,7 @@ def test_edited_action(mock_os_getenv, mock_shelve_open, mock_post_request): payload: str = json.dumps(payload) mock_os_getenv.side_effect = ["some_secret", 123, "some_token"] mock_shelve_open.return_value = MockShelf({"123": "Meow"}) - mock_post_request.return_value = MockResponse({}) + mock_post_request.return_value = MockResponse({"data": {"node": {"content": {"title": "Meow"}}}}) signature = generate_signature( "some_secret", payload.encode("utf-8"), diff --git a/src/tests/test_unit/test_bot.py b/src/tests/test_unit/test_bot.py index 6cb769e..73a699e 100644 --- a/src/tests/test_unit/test_bot.py +++ b/src/tests/test_unit/test_bot.py @@ -2,6 +2,7 @@ import asyncio import datetime +import logging from unittest.mock import AsyncMock, patch import pytest @@ -54,18 +55,28 @@ def post_mock(): ) +@pytest.fixture +def logger_mock(): + return logging.getLogger("uvicorn.error") + + @patch.object(RESTClientImpl, "create_forum_post", new_callable=AsyncMock) @patch("src.bot.retrieve_discord_id") @patch("src.bot.get_post_id", new_callable=AsyncMock) async def test_process_update_created_success( - mock_get_post_id, mock_retrieve_discord_id, mock_create_forum_post, forum_channel_mock, rest_client_mock + mock_get_post_id, + mock_retrieve_discord_id, + mock_create_forum_post, + forum_channel_mock, + rest_client_mock, + logger_mock, ): state = asyncio.Queue() await state.put(SimpleProjectItemEvent("mmmocking", "norbiros", SimpleProjectItemEventType.CREATED)) mock_get_post_id.return_value = None mock_retrieve_discord_id.return_value = 2137696742041 - await process_update(rest_client_mock, 1, 1, forum_channel_mock, state) + await process_update(rest_client_mock, 1, 1, forum_channel_mock, state, logger_mock) assert mock_create_forum_post.called @@ -73,14 +84,14 @@ async def test_process_update_created_success( @patch("src.bot.retrieve_discord_id") @patch("src.bot.get_post_id", new_callable=AsyncMock) async def test_process_update_already_exists( - mock_get_post_id, mock_retrieve_discord_id, mock_fetch_channel, forum_channel_mock, rest_client_mock + mock_get_post_id, mock_retrieve_discord_id, mock_fetch_channel, forum_channel_mock, rest_client_mock, logger_mock ): state = asyncio.Queue() await state.put(SimpleProjectItemEvent("mmmocking", "norbiros", SimpleProjectItemEventType.CREATED)) mock_get_post_id.return_value = 1 mock_retrieve_discord_id.return_value = "2137696742041" - await process_update(rest_client_mock, 1, 1, forum_channel_mock, state) + await process_update(rest_client_mock, 1, 1, forum_channel_mock, state, logger_mock) assert mock_fetch_channel.called @@ -98,6 +109,7 @@ async def test_process_update_archived( forum_channel_mock, rest_client_mock, post_mock, + logger_mock, ): state = asyncio.Queue() await state.put(SimpleProjectItemEvent("audacity4", "norbiros", SimpleProjectItemEventType.ARCHIVED)) @@ -106,7 +118,7 @@ async def test_process_update_archived( mock_retrieve_discord_id.return_value = user_id mock_fetch_channel.return_value = post_mock - await process_update(rest_client_mock, 1, 1, forum_channel_mock, state) + await process_update(rest_client_mock, 1, 1, forum_channel_mock, state, logger_mock) mock_create_message.assert_called_with( post_mock.id, f"Task zarchiwizowany przez: <@{user_id}>.", user_mentions=[user_id] ) @@ -127,6 +139,7 @@ async def test_process_update_restored( forum_channel_mock, rest_client_mock, post_mock, + logger_mock, ): state = asyncio.Queue() await state.put(SimpleProjectItemEvent("audacity4", "norbiros", SimpleProjectItemEventType.RESTORED)) @@ -135,7 +148,7 @@ async def test_process_update_restored( mock_retrieve_discord_id.return_value = user_id mock_fetch_channel.return_value = post_mock - await process_update(rest_client_mock, 1, 1, forum_channel_mock, state) + await process_update(rest_client_mock, 1, 1, forum_channel_mock, state, logger_mock) mock_create_message.assert_called_with( post_mock.id, f"Task przywrócony przez: <@{user_id}>.", user_mentions=[user_id] ) @@ -154,6 +167,7 @@ async def test_process_update_deleted( forum_channel_mock, rest_client_mock, post_mock, + logger_mock, ): state = asyncio.Queue() await state.put(SimpleProjectItemEvent("audacity4", "norbiros", SimpleProjectItemEventType.DELETED)) @@ -161,7 +175,7 @@ async def test_process_update_deleted( mock_retrieve_discord_id.return_value = "niepodam@norbiros.dev" mock_fetch_channel.return_value = post_mock - await process_update(rest_client_mock, 1, 1, forum_channel_mock, state) + await process_update(rest_client_mock, 1, 1, forum_channel_mock, state, logger_mock) mock_delete_channel.assert_called_with(post_mock.id) @@ -177,6 +191,7 @@ async def test_process_update_assignees( forum_channel_mock, rest_client_mock, post_mock, + logger_mock, ): state = asyncio.Queue() await state.put(ProjectItemEditedAssignees("audacity4", "norbiros", ["norbiros"])) @@ -186,7 +201,7 @@ async def test_process_update_assignees( mock_fetch_channel.return_value = post_mock message = f"Osoby przypisane do taska edytowane, aktualni przypisani: <@{user_id}>" - await process_update(rest_client_mock, 1, 1, forum_channel_mock, state) + await process_update(rest_client_mock, 1, 1, forum_channel_mock, state, logger_mock) mock_create_message.assert_called_with(post_mock.id, message, user_mentions=[user_id]) @@ -202,6 +217,7 @@ async def test_process_update_body( forum_channel_mock, rest_client_mock, post_mock, + logger_mock, ): state = asyncio.Queue() new_body = "Nowy opis taska" @@ -212,7 +228,7 @@ async def test_process_update_body( mock_fetch_channel.return_value = post_mock message = f"Opis taska zaktualizowany przez: <@{user_id}>. Nowy opis: \n{new_body}" - await process_update(rest_client_mock, 1, 1, forum_channel_mock, state) + await process_update(rest_client_mock, 1, 1, forum_channel_mock, state, logger_mock) mock_create_message.assert_called_with(post_mock.id, message, user_mentions=[user_id]) @@ -228,6 +244,7 @@ async def test_process_update_title( forum_channel_mock, rest_client_mock, post_mock, + logger_mock, ): state = asyncio.Queue() new_title = "Nowy opis taska" @@ -237,7 +254,7 @@ async def test_process_update_title( mock_retrieve_discord_id.return_value = user_id mock_fetch_channel.return_value = post_mock - await process_update(rest_client_mock, 1, 1, forum_channel_mock, state) + await process_update(rest_client_mock, 1, 1, forum_channel_mock, state, logger_mock) mock_edit_channel.assert_called_with(post_mock.id, name=new_title) @@ -255,6 +272,7 @@ async def test_process_update_single_select( forum_channel_mock, rest_client_mock, post_mock, + logger_mock, ): state = asyncio.Queue() await state.put(ProjectItemEditedSingleSelect("audacity4", "norbiros", "big", SingleSelectType.SIZE)) @@ -264,7 +282,7 @@ async def test_process_update_single_select( mock_fetch_channel.return_value = post_mock mock_get_new_tag.return_value = ForumTag(id=Snowflake(2), name="Size: big", moderated=False) - await process_update(rest_client_mock, 1, 1, forum_channel_mock, state) + await process_update(rest_client_mock, 1, 1, forum_channel_mock, state, logger_mock) mock_edit_channel.assert_called_with(post_mock.id, applied_tags=[Snowflake(2)]) @@ -284,6 +302,7 @@ async def test_process_update_single_select_tag_unavailable( forum_channel_mock, rest_client_mock, post_mock, + logger_mock, ): state = asyncio.Queue() await state.put(ProjectItemEditedSingleSelect("audacity4", "norbiros", "big", SingleSelectType.SIZE)) @@ -295,7 +314,7 @@ async def test_process_update_single_select_tag_unavailable( new_tag = ForumTag(id=Snowflake(0), name="Size: big") mock_get_new_tag.side_effect = [None, new_tag] - await process_update(rest_client_mock, 1, 1, forum_channel_mock, state) + await process_update(rest_client_mock, 1, 1, forum_channel_mock, state, logger_mock) mock_edit_channel.assert_any_call( forum_channel_mock.id, available_tags=forum_channel_mock.available_tags + [new_tag] ) diff --git a/src/tests/test_unit/test_server.py b/src/tests/test_unit/test_server.py index 6d6b0df..640104c 100644 --- a/src/tests/test_unit/test_server.py +++ b/src/tests/test_unit/test_server.py @@ -1,5 +1,8 @@ from unittest.mock import patch +import pytest +from fastapi import HTTPException + from src.server import process_edition from src.utils.data_types import ( ProjectItemEditedAssignees, @@ -22,12 +25,15 @@ async def test_process_edition_body_no_changes(): body = {} item_name = "Idk" - assert await process_edition(body, item_name) is None + with pytest.raises(HTTPException) as exception: + await process_edition(body, item_name) + assert exception.value.status_code == 400 + assert exception.value.detail == "Failed to recognize the edited event." @patch("src.server.fetch_assignees") async def test_process_edition_assignees_changed(mock_fetch_assignees): - body = {"changes": {"field_value": {"field_type": "assignees"}}} + body = {"changes": {"field_value": {"field_type": "assignees"}}, "projects_v2_item": {"node_id": "node_id"}} item_name = "YouKnowIntegrationTestsAreNextDontYou?" new_assignees = ["Kubaryt", "Salieri", "Aniela"] mock_fetch_assignees.return_value = new_assignees @@ -38,7 +44,7 @@ async def test_process_edition_assignees_changed(mock_fetch_assignees): @patch("src.server.fetch_item_name") async def test_process_edition_title_changed(mock_fetch_item_name): - body = {"changes": {"field_value": {"field_type": "title"}}} + body = {"changes": {"field_value": {"field_type": "title"}}, "projects_v2_item": {"node_id": "node_id"}} item_name = "ImagineMockingDiscordSoMuchFun" new_item_name = "ActuallyNotFunAtAll" mock_fetch_item_name.return_value = new_item_name @@ -51,7 +57,8 @@ async def test_process_edition_single_select_changed(): body = { "changes": { "field_value": {"field_type": "single_select", "field_name": "Size", "to": {"name": "Smol like lil kitten"}} - } + }, + "projects_v2_item": {"node_id": "node_id"}, } item_name = "Lil puppy" expected_object = ProjectItemEditedSingleSelect(item_name, "Unknown", "Smol like lil kitten", SingleSelectType.SIZE) @@ -61,7 +68,10 @@ async def test_process_edition_single_select_changed(): async def test_process_edition_iteration_changed(): new_title = "1.0.0 - FinallyWeShipItAfter25Years" - body = {"changes": {"field_value": {"field_type": "iteration", "to": {"title": new_title}}}} + body = { + "changes": {"field_value": {"field_type": "iteration", "to": {"title": new_title}}}, + "projects_v2_item": {"node_id": "node_id"}, + } item_name = "Create Dockerfile for production" expected_object = ProjectItemEditedSingleSelect(item_name, "Unknown", new_title, SingleSelectType.ITERATION) diff --git a/src/tests/test_unit/test_utils.py b/src/tests/test_unit/test_utils.py index e84db68..51aa23b 100644 --- a/src/tests/test_unit/test_utils.py +++ b/src/tests/test_unit/test_utils.py @@ -3,6 +3,7 @@ import pytest from aiohttp import ClientSession +from fastapi import HTTPException from hikari import ( ChannelFlag, ForumLayoutType, @@ -119,14 +120,21 @@ async def test_fetch_item_name_partial(mock_post_request): mock_response = {"data": {"node": {"content": None}}} mock_post_request.return_value = MockResponse(mock_response) - assert await utils.fetch_item_name("") is None + with pytest.raises(HTTPException) as exception: + await utils.fetch_item_name("") + + assert exception.value.status_code == 500 + assert exception.value.detail == "Could not fetch item name." @patch.object(ClientSession, "post") async def test_fetch_item_name_none(mock_post_request): mock_post_request.return_value = MockResponse({}) - assert await utils.fetch_item_name("") is None + with pytest.raises(HTTPException) as exception: + await utils.fetch_item_name("") + assert exception.value.status_code == 500 + assert exception.value.detail == "Could not fetch item name." @patch.object(ClientSession, "post") diff --git a/src/utils/__init__.py b/src/utils/__init__.py index b5b3214..c3e99b0 100644 --- a/src/utils/__init__.py +++ b/src/utils/__init__.py @@ -14,8 +14,6 @@ async def get_item_name(item_node_id: str) -> str | None: item_name: str = db[item_node_id] except KeyError: item_name = await fetch_item_name(item_node_id) - if item_name is None: - return None db[item_node_id] = item_name return item_name @@ -41,3 +39,7 @@ def verify_secret(secret: str, payload: bytes, signature_header: str) -> bool: return True expected_signature = generate_signature(secret, payload) return hmac.compare_digest(expected_signature, signature_header) + + +def add_bot_log_prefix(text: str) -> str: + return f"[BOT] {text}" diff --git a/src/utils/data_types.py b/src/utils/data_types.py index 747cee5..d9f9a49 100644 --- a/src/utils/data_types.py +++ b/src/utils/data_types.py @@ -17,7 +17,7 @@ class SingleSelectType(Enum): SECTION = "Section" -def single_select_type_from_field_name(field_name: str | None) -> SingleSelectType | None: +def single_select_type_from_field_name(field_name: str) -> SingleSelectType | None: match field_name: case "Status": return SingleSelectType.STATUS diff --git a/src/utils/github_api.py b/src/utils/github_api.py index c41a2e4..46d99d7 100644 --- a/src/utils/github_api.py +++ b/src/utils/github_api.py @@ -1,12 +1,10 @@ import os import aiohttp +from fastapi import HTTPException -async def fetch_item_name(item_node_id: str | None) -> str | None: - if item_node_id is None: - return None - +async def fetch_item_name(item_node_id: str) -> str: query = """ query ($id: ID!) { node(id: $id) { @@ -40,15 +38,12 @@ async def fetch_item_name(item_node_id: str | None) -> str | None: try: item_name: str | None = response_body["data"]["node"]["content"]["title"] except TypeError, KeyError, AttributeError: - return None + raise HTTPException(status_code=500, detail="Could not fetch item name.") from None return item_name -async def fetch_assignees(item_node_id: str | None) -> list[str]: - if item_node_id is None: - return [] - +async def fetch_assignees(item_node_id: str) -> list[str]: query = """ query ($id: ID!) { node(id: $id) { @@ -99,8 +94,8 @@ async def fetch_assignees(item_node_id: str | None) -> list[str]: return assignees -async def fetch_single_select_value(item_node_id: str | None, field_name: str | None) -> str | None: - if item_node_id is None or field_name is None: +async def fetch_single_select_value(item_node_id: str, field_name: str) -> str | None: + if field_name is None: return None query = """ diff --git a/src/utils/logging.py b/src/utils/logging.py deleted file mode 100644 index c32c8f2..0000000 --- a/src/utils/logging.py +++ /dev/null @@ -1,34 +0,0 @@ -from rich import print as rich_print - - -def bot(text: str): - rich_print("[bold blue]BOT: [/bold blue]" + text) - - -def bot_info(text: str): - bot(f"[bold green]INFO:[/bold green] {text}") - - -# Currently unused: -# def bot_warning(text: str): -# bot(f"[bold yellow]WARNING:[/bold yellow] {text}") - - -def bot_error(text: str): - bot(f"[bold red]ERROR:[/bold red] {text}") - - -def server(text: str): - rich_print("[bold purple]SERVER: [/bold purple]" + text) - - -def server_info(text: str): - server(f"[bold green]INFO:[/bold green] {text}") - - -def server_warning(text: str): - server(f"[bold yellow]WARNING:[/bold yellow] {text}") - - -def server_error(text: str): - server(f"[bold red]ERROR:[/bold red] {text}") From 10f38af259e28730eb8784a1ba7c873a0e0fe780 Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Thu, 20 Nov 2025 13:58:59 +0100 Subject: [PATCH 21/44] refactor: Load shelve db paths from env --- .env.example | 5 ++++- README.md | 2 +- docker-compose.yaml | 2 +- src/tests/test_e2e.py | 11 ++++++++++- src/tests/test_integration/test_bot.py | 2 +- src/tests/test_integration/test_server.py | 6 +++--- src/utils/__init__.py | 2 +- src/utils/discord_rest_client.py | 3 ++- 8 files changed, 23 insertions(+), 10 deletions(-) diff --git a/.env.example b/.env.example index c0e9e56..474d326 100644 --- a/.env.example +++ b/.env.example @@ -5,4 +5,7 @@ GITHUB_PROJECT_NODE_ID=your-github-project-node-id GITHUB_ID_TO_DISCORD_ID_MAPPING_PATH=path-to-github-username-to-discord-id-mapping.json IP_ADDRESS=0.0.0.0 PORT=8000 -GITHUB_WEBHOOK_SECRET=your-github-webhook-secret \ No newline at end of file +GITHUB_WEBHOOK_SECRET=your-github-webhook-secret +# Shelve databases +ITEM_NAME_TO_NODE_ID_DB_PATH=path-to-item-name-to-node-id.db +POST_ID_DB_PATH=path-to-post-id.db \ No newline at end of file diff --git a/README.md b/README.md index e41dc78..487c715 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ uv run pytest For deployment follow these steps: - set all environment variables accordingly, -- setup file_mount for `github_id_to_discord_id_mapping.json` on `/app/github_usernames_to_discord_id_mapping.json`, +- setup file_mount for volumes specified in `docker-compose.yaml` on /app/path_set_in_env, - set up a webhook in your GitHub repository to point to your server's `/webhook_endpoint` endpoint, - use Dockerfile to build the image. diff --git a/docker-compose.yaml b/docker-compose.yaml index dd9e878..953723f 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -10,7 +10,7 @@ services: env_file: - .env volumes: - - github_id_to_discord_id_mapping:/app/github_id_to_discord_id_mapping.yaml + - github_id_to_discord_id_mapping:/app/${GITHUB_ID_TO_DISCORD_ID_MAPPING_PATH} volumes: github_id_to_discord_id_mapping: diff --git a/src/tests/test_e2e.py b/src/tests/test_e2e.py index 0bad9e5..7674a47 100644 --- a/src/tests/test_e2e.py +++ b/src/tests/test_e2e.py @@ -42,7 +42,16 @@ async def test_e2e( ): mock_restapp_acquire.return_value = RestClientContextManagerMock(rest_client_mock) mock_fetch_channel.side_effect = [forum_channel_mock, post_mock] - mock_getenv.side_effect = ["some_token", 1, 2, "some_secret", "fake_project_id", "meow.yaml"] + mock_getenv.side_effect = [ + "some_token", + 1, + 2, + "some_secret", + "fake_project_id", + "db-path.db", + "meow.yaml", + "db-path.db", + ] post_id_shelf = MockShelf({}) mock_shelve_open.side_effect = [MockShelf({"item123": "audacity4"}), post_id_shelf] mock_fetch_active_threads.return_value = [post_mock] diff --git a/src/tests/test_integration/test_bot.py b/src/tests/test_integration/test_bot.py index 3c028c5..ce5d45f 100644 --- a/src/tests/test_integration/test_bot.py +++ b/src/tests/test_integration/test_bot.py @@ -64,7 +64,7 @@ async def test_basic_event_only_creation( forum_channel_mock, logger_mock, ): - mock_os_getenv.side_effect = ["some_token", 1, 2, "some_path"] + mock_os_getenv.side_effect = ["some_token", 1, 2, "some_path", "db-path.db"] mock_restapp_acquire.return_value = RestClientContextManagerMock(rest_client_mock) mock_fetch_channel.return_value = forum_channel_mock mock_fetch_active_threads.return_value = [] diff --git a/src/tests/test_integration/test_server.py b/src/tests/test_integration/test_server.py index 25a2674..fe532e5 100644 --- a/src/tests/test_integration/test_server.py +++ b/src/tests/test_integration/test_server.py @@ -114,7 +114,7 @@ def test_missing_item_node_id(mock_os_getenv): @patch("shelve.open") @patch("os.getenv") def test_could_not_fetch_item_name(mock_os_getenv, mock_shelve_open, mock_post_request): - mock_os_getenv.side_effect = ["some_secret", 123, "some_token"] + mock_os_getenv.side_effect = ["some_secret", 123, "some_token", "db-path.db"] mock_shelve_open.return_value = MockShelf({}) mock_post_request.return_value = MockResponse({}) signature = generate_signature("some_secret", b'{"projects_v2_item": {"project_node_id": 123, "node_id": "123"}}') @@ -130,7 +130,7 @@ def test_could_not_fetch_item_name(mock_os_getenv, mock_shelve_open, mock_post_r @patch("shelve.open") @patch("os.getenv") def test_missing_action(mock_os_getenv, mock_shelve_open): - mock_os_getenv.side_effect = ["some_secret", 123] + mock_os_getenv.side_effect = ["some_secret", 123, "db-path.db"] mock_shelve_open.return_value = MockShelf({"123": "Meow"}) signature = generate_signature("some_secret", b'{"projects_v2_item": {"project_node_id": 123, "node_id": "123"}}') response = test_client.post( @@ -152,7 +152,7 @@ def test_edited_action(mock_os_getenv, mock_shelve_open, mock_post_request): "changes": {"field_value": {"field_type": "title"}}, } payload: str = json.dumps(payload) - mock_os_getenv.side_effect = ["some_secret", 123, "some_token"] + mock_os_getenv.side_effect = ["some_secret", 123, "some_token", "db-path.db"] mock_shelve_open.return_value = MockShelf({"123": "Meow"}) mock_post_request.return_value = MockResponse({"data": {"node": {"content": {"title": "Meow"}}}}) signature = generate_signature( diff --git a/src/utils/__init__.py b/src/utils/__init__.py index c3e99b0..a105a1a 100644 --- a/src/utils/__init__.py +++ b/src/utils/__init__.py @@ -9,7 +9,7 @@ async def get_item_name(item_node_id: str) -> str | None: - with shelve.open("item_name_to_node_id.db") as db: + with shelve.open(os.getenv("ITEM_NAME_TO_NODE_ID_DB_PATH", "item_name_to_node_id.db")) as db: try: item_name: str = db[item_node_id] except KeyError: diff --git a/src/utils/discord_rest_client.py b/src/utils/discord_rest_client.py index 2aa0fde..151399a 100644 --- a/src/utils/discord_rest_client.py +++ b/src/utils/discord_rest_client.py @@ -1,3 +1,4 @@ +import os import shelve from hikari import ForumTag, GuildForumChannel, GuildThreadChannel @@ -19,7 +20,7 @@ def get_new_tag(new_tag_name: str, available_tags: list[ForumTag]) -> ForumTag | async def get_post_id( name: str, discord_guild_id: int, forum_channel_id: int, rest_client: RESTClientImpl ) -> int | GuildThreadChannel | None: - with shelve.open("post_id.db") as db: + with shelve.open(os.getenv("POST_ID_DB_PATH", "post_id.db")) as db: try: post_id: str = db[name] return int(post_id) From 1acb7de0acc28e157b308c4e8a3d54fb9231bcc3 Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Thu, 20 Nov 2025 14:30:47 +0100 Subject: [PATCH 22/44] chore: Regenerate lockfile and add lockfile tests --- .github/workflows/ci.yaml | 2 +- uv.lock | 286 +++----------------------------------- 2 files changed, 17 insertions(+), 271 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 46f4015..8a122e7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -19,7 +19,7 @@ jobs: run: uv python install - name: Install all dependencies - run: uv sync --all-groups + run: uv sync --all-groups --locked - name: Run Tests run: uv run pytest diff --git a/uv.lock b/uv.lock index 32899a9..8893f71 100644 --- a/uv.lock +++ b/uv.lock @@ -174,15 +174,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, ] -[[package]] -name = "dnspython" -version = "2.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, -] - [[package]] name = "dotenv" version = "0.9.9" @@ -194,19 +185,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892, upload-time = "2025-02-19T22:15:01.647Z" }, ] -[[package]] -name = "email-validator" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "dnspython" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, -] - [[package]] name = "fastapi" version = "0.121.2" @@ -222,54 +200,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/23/dfb161e91db7c92727db505dc72a384ee79681fe0603f706f9f9f52c2901/fastapi-0.121.2-py3-none-any.whl", hash = "sha256:f2d80b49a86a846b70cc3a03eb5ea6ad2939298bf6a7fe377aa9cd3dd079d358", size = 109201, upload-time = "2025-11-13T17:05:52.718Z" }, ] -[package.optional-dependencies] -standard = [ - { name = "email-validator" }, - { name = "fastapi-cli", extra = ["standard"] }, - { name = "httpx" }, - { name = "jinja2" }, - { name = "python-multipart" }, - { name = "uvicorn", extra = ["standard"] }, -] - -[[package]] -name = "fastapi-cli" -version = "0.0.16" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "rich-toolkit" }, - { name = "typer" }, - { name = "uvicorn", extra = ["standard"] }, -] -sdist = { url = "https://files.pythonhosted.org/packages/99/75/9407a6b452be4c988feacec9c9d2f58d8f315162a6c7258d5a649d933ebe/fastapi_cli-0.0.16.tar.gz", hash = "sha256:e8a2a1ecf7a4e062e3b2eec63ae34387d1e142d4849181d936b23c4bdfe29073", size = 19447, upload-time = "2025-11-10T19:01:07.856Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/55/43/678528c19318394320ee43757648d5e0a8070cf391b31f69d931e5c840d2/fastapi_cli-0.0.16-py3-none-any.whl", hash = "sha256:addcb6d130b5b9c91adbbf3f2947fe115991495fdb442fe3e51b5fc6327df9f4", size = 12312, upload-time = "2025-11-10T19:01:06.728Z" }, -] - -[package.optional-dependencies] -standard = [ - { name = "fastapi-cloud-cli" }, - { name = "uvicorn", extra = ["standard"] }, -] - -[[package]] -name = "fastapi-cloud-cli" -version = "0.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "httpx" }, - { name = "pydantic", extra = ["email"] }, - { name = "rich-toolkit" }, - { name = "rignore" }, - { name = "sentry-sdk" }, - { name = "typer" }, - { name = "uvicorn", extra = ["standard"] }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f9/48/0f14d8555b750dc8c04382804e4214f1d7f55298127f3a0237ba566e69dd/fastapi_cloud_cli-0.3.1.tar.gz", hash = "sha256:8c7226c36e92e92d0c89827e8f56dbf164ab2de4444bd33aa26b6c3f7675db69", size = 24080, upload-time = "2025-10-09T11:32:58.174Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/68/79/7f5a5e5513e6a737e5fb089d9c59c74d4d24dc24d581d3aa519b326bedda/fastapi_cloud_cli-0.3.1-py3-none-any.whl", hash = "sha256:7d1a98a77791a9d0757886b2ffbf11bcc6b3be93210dd15064be10b216bf7e00", size = 19711, upload-time = "2025-10-09T11:32:57.118Z" }, -] - [[package]] name = "filelock" version = "3.20.0" @@ -327,43 +257,47 @@ source = { editable = "." } dependencies = [ { name = "aiohttp" }, { name = "dotenv" }, - { name = "fastapi", extra = ["standard"] }, + { name = "fastapi" }, { name = "hikari" }, + { name = "setuptools" }, + { name = "uvicorn" }, ] [package.dev-dependencies] -build = [ - { name = "gunicorn" }, - { name = "setuptools" }, - { name = "uvicorn", extra = ["standard"] }, -] dev = [ + { name = "httpx" }, { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "ruff" }, ] +prod = [ + { name = "gunicorn" }, + { name = "uvicorn", extra = ["standard"] }, +] [package.metadata] requires-dist = [ { name = "aiohttp", specifier = ">=3.13.2" }, { name = "dotenv", specifier = ">=0.9.9" }, - { name = "fastapi", extras = ["standard"], specifier = ">=0.121.2" }, + { name = "fastapi", specifier = ">=0.121.2" }, { name = "hikari", specifier = ">=2.5.0" }, + { name = "setuptools", specifier = ">=80.9.0" }, + { name = "uvicorn", specifier = ">=0.38.0" }, ] [package.metadata.requires-dev] -build = [ - { name = "gunicorn", specifier = ">=23.0.0" }, - { name = "setuptools", specifier = ">=80.9.0" }, - { name = "uvicorn", extras = ["standard"], specifier = ">=0.38.0" }, -] dev = [ + { name = "httpx", specifier = ">=0.28.1" }, { name = "pre-commit", specifier = ">=4.4.0" }, { name = "pytest", specifier = ">=9.0.1" }, { name = "pytest-asyncio", specifier = ">=1.3.0" }, { name = "ruff", specifier = ">=0.14.5" }, ] +prod = [ + { name = "gunicorn", specifier = ">=23.0.0" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.38.0" }, +] [[package]] name = "gunicorn" @@ -471,69 +405,6 @@ 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 = "jinja2" -version = "3.1.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markupsafe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, -] - -[[package]] -name = "markdown-it-py" -version = "4.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mdurl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, -] - -[[package]] -name = "markupsafe" -version = "3.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, - { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, - { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, - { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, - { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, - { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, - { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, - { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, - { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, - { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, - { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, - { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, - { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, - { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, - { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, - { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, - { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, -] - -[[package]] -name = "mdurl" -version = "0.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, -] - [[package]] name = "multidict" version = "6.7.0" @@ -685,11 +556,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/82/2f/e68750da9b04856e2a7ec56fc6f034a5a79775e9b9a81882252789873798/pydantic-2.12.4-py3-none-any.whl", hash = "sha256:92d3d202a745d46f9be6df459ac5a064fdaa3c1c4cd8adcfa332ccf3c05f871e", size = 463400, upload-time = "2025-11-05T10:50:06.732Z" }, ] -[package.optional-dependencies] -email = [ - { name = "email-validator" }, -] - [[package]] name = "pydantic-core" version = "2.41.5" @@ -775,15 +641,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, ] -[[package]] -name = "python-multipart" -version = "0.0.20" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, -] - [[package]] name = "pyyaml" version = "6.0.3" @@ -810,71 +667,6 @@ wheels = [ { 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 = "rich" -version = "14.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, -] - -[[package]] -name = "rich-toolkit" -version = "0.15.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "rich" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/67/33/1a18839aaa8feef7983590c05c22c9c09d245ada6017d118325bbfcc7651/rich_toolkit-0.15.1.tar.gz", hash = "sha256:6f9630eb29f3843d19d48c3bd5706a086d36d62016687f9d0efa027ddc2dd08a", size = 115322, upload-time = "2025-09-04T09:28:11.789Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/49/42821d55ead7b5a87c8d121edf323cb393d8579f63e933002ade900b784f/rich_toolkit-0.15.1-py3-none-any.whl", hash = "sha256:36a0b1d9a135d26776e4b78f1d5c2655da6e0ef432380b5c6b523c8d8ab97478", size = 29412, upload-time = "2025-09-04T09:28:10.587Z" }, -] - -[[package]] -name = "rignore" -version = "0.7.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e5/f5/8bed2310abe4ae04b67a38374a4d311dd85220f5d8da56f47ae9361be0b0/rignore-0.7.6.tar.gz", hash = "sha256:00d3546cd793c30cb17921ce674d2c8f3a4b00501cb0e3dd0e82217dbeba2671", size = 57140, upload-time = "2025-11-05T21:41:21.968Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/b9/1f5bd82b87e5550cd843ceb3768b4a8ef274eb63f29333cf2f29644b3d75/rignore-0.7.6-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:8e41be9fa8f2f47239ded8920cc283699a052ac4c371f77f5ac017ebeed75732", size = 882632, upload-time = "2025-11-05T20:42:44.063Z" }, - { url = "https://files.pythonhosted.org/packages/e9/6b/07714a3efe4a8048864e8a5b7db311ba51b921e15268b17defaebf56d3db/rignore-0.7.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6dc1e171e52cefa6c20e60c05394a71165663b48bca6c7666dee4f778f2a7d90", size = 820760, upload-time = "2025-11-05T20:42:27.885Z" }, - { url = "https://files.pythonhosted.org/packages/ac/0f/348c829ea2d8d596e856371b14b9092f8a5dfbb62674ec9b3f67e4939a9d/rignore-0.7.6-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ce2268837c3600f82ab8db58f5834009dc638ee17103582960da668963bebc5", size = 899044, upload-time = "2025-11-05T20:40:55.336Z" }, - { url = "https://files.pythonhosted.org/packages/f0/30/2e1841a19b4dd23878d73edd5d82e998a83d5ed9570a89675f140ca8b2ad/rignore-0.7.6-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:690a3e1b54bfe77e89c4bacb13f046e642f8baadafc61d68f5a726f324a76ab6", size = 874144, upload-time = "2025-11-05T20:41:10.195Z" }, - { url = "https://files.pythonhosted.org/packages/c2/bf/0ce9beb2e5f64c30e3580bef09f5829236889f01511a125f98b83169b993/rignore-0.7.6-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09d12ac7a0b6210c07bcd145007117ebd8abe99c8eeb383e9e4673910c2754b2", size = 1168062, upload-time = "2025-11-05T20:41:26.511Z" }, - { url = "https://files.pythonhosted.org/packages/b9/8b/571c178414eb4014969865317da8a02ce4cf5241a41676ef91a59aab24de/rignore-0.7.6-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2a2b2b74a8c60203b08452479b90e5ce3dbe96a916214bc9eb2e5af0b6a9beb0", size = 942542, upload-time = "2025-11-05T20:41:41.838Z" }, - { url = "https://files.pythonhosted.org/packages/19/62/7a3cf601d5a45137a7e2b89d10c05b5b86499190c4b7ca5c3c47d79ee519/rignore-0.7.6-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8fc5a531ef02131e44359419a366bfac57f773ea58f5278c2cdd915f7d10ea94", size = 958739, upload-time = "2025-11-05T20:42:12.463Z" }, - { url = "https://files.pythonhosted.org/packages/5f/1f/4261f6a0d7caf2058a5cde2f5045f565ab91aa7badc972b57d19ce58b14e/rignore-0.7.6-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b7a1f77d9c4cd7e76229e252614d963442686bfe12c787a49f4fe481df49e7a9", size = 984138, upload-time = "2025-11-05T20:41:56.775Z" }, - { url = "https://files.pythonhosted.org/packages/2b/bf/628dfe19c75e8ce1f45f7c248f5148b17dfa89a817f8e3552ab74c3ae812/rignore-0.7.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ead81f728682ba72b5b1c3d5846b011d3e0174da978de87c61645f2ed36659a7", size = 1079299, upload-time = "2025-11-05T21:40:16.639Z" }, - { url = "https://files.pythonhosted.org/packages/af/a5/be29c50f5c0c25c637ed32db8758fdf5b901a99e08b608971cda8afb293b/rignore-0.7.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:12ffd50f520c22ffdabed8cd8bfb567d9ac165b2b854d3e679f4bcaef11a9441", size = 1139618, upload-time = "2025-11-05T21:40:34.507Z" }, - { url = "https://files.pythonhosted.org/packages/2a/40/3c46cd7ce4fa05c20b525fd60f599165e820af66e66f2c371cd50644558f/rignore-0.7.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e5a16890fbe3c894f8ca34b0fcacc2c200398d4d46ae654e03bc9b3dbf2a0a72", size = 1117626, upload-time = "2025-11-05T21:40:51.494Z" }, - { url = "https://files.pythonhosted.org/packages/8c/b9/aea926f263b8a29a23c75c2e0d8447965eb1879d3feb53cfcf84db67ed58/rignore-0.7.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3abab3bf99e8a77488ef6c7c9a799fac22224c28fe9f25cc21aa7cc2b72bfc0b", size = 1128144, upload-time = "2025-11-05T21:41:09.169Z" }, - { url = "https://files.pythonhosted.org/packages/a4/f6/0d6242f8d0df7f2ecbe91679fefc1f75e7cd2072cb4f497abaab3f0f8523/rignore-0.7.6-cp314-cp314-win32.whl", hash = "sha256:eeef421c1782953c4375aa32f06ecae470c1285c6381eee2a30d2e02a5633001", size = 646385, upload-time = "2025-11-05T21:41:55.105Z" }, - { url = "https://files.pythonhosted.org/packages/d5/38/c0dcd7b10064f084343d6af26fe9414e46e9619c5f3224b5272e8e5d9956/rignore-0.7.6-cp314-cp314-win_amd64.whl", hash = "sha256:6aeed503b3b3d5af939b21d72a82521701a4bd3b89cd761da1e7dc78621af304", size = 725738, upload-time = "2025-11-05T21:41:39.736Z" }, - { url = "https://files.pythonhosted.org/packages/d9/7a/290f868296c1ece914d565757ab363b04730a728b544beb567ceb3b2d96f/rignore-0.7.6-cp314-cp314-win_arm64.whl", hash = "sha256:104f215b60b3c984c386c3e747d6ab4376d5656478694e22c7bd2f788ddd8304", size = 656008, upload-time = "2025-11-05T21:41:29.028Z" }, - { url = "https://files.pythonhosted.org/packages/ca/d2/3c74e3cd81fe8ea08a8dcd2d755c09ac2e8ad8fe409508904557b58383d3/rignore-0.7.6-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:bb24a5b947656dd94cb9e41c4bc8b23cec0c435b58be0d74a874f63c259549e8", size = 882835, upload-time = "2025-11-05T20:42:45.443Z" }, - { url = "https://files.pythonhosted.org/packages/77/61/a772a34b6b63154877433ac2d048364815b24c2dd308f76b212c408101a2/rignore-0.7.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5b1e33c9501cefe24b70a1eafd9821acfd0ebf0b35c3a379430a14df089993e3", size = 820301, upload-time = "2025-11-05T20:42:29.226Z" }, - { url = "https://files.pythonhosted.org/packages/71/30/054880b09c0b1b61d17eeb15279d8bf729c0ba52b36c3ada52fb827cbb3c/rignore-0.7.6-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bec3994665a44454df86deb762061e05cd4b61e3772f5b07d1882a8a0d2748d5", size = 897611, upload-time = "2025-11-05T20:40:56.475Z" }, - { url = "https://files.pythonhosted.org/packages/1e/40/b2d1c169f833d69931bf232600eaa3c7998ba4f9a402e43a822dad2ea9f2/rignore-0.7.6-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26cba2edfe3cff1dfa72bddf65d316ddebf182f011f2f61538705d6dbaf54986", size = 873875, upload-time = "2025-11-05T20:41:11.561Z" }, - { url = "https://files.pythonhosted.org/packages/55/59/ca5ae93d83a1a60e44b21d87deb48b177a8db1b85e82fc8a9abb24a8986d/rignore-0.7.6-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ffa86694fec604c613696cb91e43892aa22e1fec5f9870e48f111c603e5ec4e9", size = 1167245, upload-time = "2025-11-05T20:41:28.29Z" }, - { url = "https://files.pythonhosted.org/packages/a5/52/cf3dce392ba2af806cba265aad6bcd9c48bb2a6cb5eee448d3319f6e505b/rignore-0.7.6-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48efe2ed95aa8104145004afb15cdfa02bea5cdde8b0344afeb0434f0d989aa2", size = 941750, upload-time = "2025-11-05T20:41:43.111Z" }, - { url = "https://files.pythonhosted.org/packages/ec/be/3f344c6218d779395e785091d05396dfd8b625f6aafbe502746fcd880af2/rignore-0.7.6-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dcae43eb44b7f2457fef7cc87f103f9a0013017a6f4e62182c565e924948f21", size = 958896, upload-time = "2025-11-05T20:42:13.784Z" }, - { url = "https://files.pythonhosted.org/packages/c9/34/d3fa71938aed7d00dcad87f0f9bcb02ad66c85d6ffc83ba31078ce53646a/rignore-0.7.6-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2cd649a7091c0dad2f11ef65630d30c698d505cbe8660dd395268e7c099cc99f", size = 983992, upload-time = "2025-11-05T20:41:58.022Z" }, - { url = "https://files.pythonhosted.org/packages/24/a4/52a697158e9920705bdbd0748d59fa63e0f3233fb92e9df9a71afbead6ca/rignore-0.7.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42de84b0289d478d30ceb7ae59023f7b0527786a9a5b490830e080f0e4ea5aeb", size = 1078181, upload-time = "2025-11-05T21:40:18.151Z" }, - { url = "https://files.pythonhosted.org/packages/ac/65/aa76dbcdabf3787a6f0fd61b5cc8ed1e88580590556d6c0207960d2384bb/rignore-0.7.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:875a617e57b53b4acbc5a91de418233849711c02e29cc1f4f9febb2f928af013", size = 1139232, upload-time = "2025-11-05T21:40:35.966Z" }, - { url = "https://files.pythonhosted.org/packages/08/44/31b31a49b3233c6842acc1c0731aa1e7fb322a7170612acf30327f700b44/rignore-0.7.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8703998902771e96e49968105207719f22926e4431b108450f3f430b4e268b7c", size = 1117349, upload-time = "2025-11-05T21:40:53.013Z" }, - { url = "https://files.pythonhosted.org/packages/e9/ae/1b199a2302c19c658cf74e5ee1427605234e8c91787cfba0015f2ace145b/rignore-0.7.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:602ef33f3e1b04c1e9a10a3c03f8bc3cef2d2383dcc250d309be42b49923cabc", size = 1127702, upload-time = "2025-11-05T21:41:10.881Z" }, - { url = "https://files.pythonhosted.org/packages/fc/d3/18210222b37e87e36357f7b300b7d98c6dd62b133771e71ae27acba83a4f/rignore-0.7.6-cp314-cp314t-win32.whl", hash = "sha256:c1d8f117f7da0a4a96a8daef3da75bc090e3792d30b8b12cfadc240c631353f9", size = 647033, upload-time = "2025-11-05T21:42:00.095Z" }, - { url = "https://files.pythonhosted.org/packages/3e/87/033eebfbee3ec7d92b3bb1717d8f68c88e6fc7de54537040f3b3a405726f/rignore-0.7.6-cp314-cp314t-win_amd64.whl", hash = "sha256:ca36e59408bec81de75d307c568c2d0d410fb880b1769be43611472c61e85c96", size = 725647, upload-time = "2025-11-05T21:41:44.449Z" }, - { url = "https://files.pythonhosted.org/packages/79/62/b88e5879512c55b8ee979c666ee6902adc4ed05007226de266410ae27965/rignore-0.7.6-cp314-cp314t-win_arm64.whl", hash = "sha256:b83adabeb3e8cf662cabe1931b83e165b88c526fa6af6b3aa90429686e474896", size = 656035, upload-time = "2025-11-05T21:41:31.13Z" }, -] - [[package]] name = "ruff" version = "0.14.5" @@ -901,19 +693,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/80/69756670caedcf3b9be597a6e12276a6cf6197076eb62aad0c608f8efce0/ruff-0.14.5-py3-none-win_arm64.whl", hash = "sha256:4b700459d4649e2594b31f20a9de33bc7c19976d4746d8d0798ad959621d64a4", size = 13433331, upload-time = "2025-11-13T19:58:48.434Z" }, ] -[[package]] -name = "sentry-sdk" -version = "2.44.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/62/26/ff7d93a14a0ec309021dca2fb7c62669d4f6f5654aa1baf60797a16681e0/sentry_sdk-2.44.0.tar.gz", hash = "sha256:5b1fe54dfafa332e900b07dd8f4dfe35753b64e78e7d9b1655a28fd3065e2493", size = 371464, upload-time = "2025-11-11T09:35:56.075Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/56/c16bda4d53012c71fa1b588edde603c6b455bc8206bf6de7b83388fcce75/sentry_sdk-2.44.0-py2.py3-none-any.whl", hash = "sha256:9e36a0372b881e8f92fdbff4564764ce6cec4b7f25424d0a3a8d609c9e4651a7", size = 402352, upload-time = "2025-11-11T09:35:54.1Z" }, -] - [[package]] name = "setuptools" version = "80.9.0" @@ -923,15 +702,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, ] -[[package]] -name = "shellingham" -version = "1.5.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, -] - [[package]] name = "sniffio" version = "1.3.1" @@ -953,21 +723,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a3/e0/021c772d6a662f43b63044ab481dc6ac7592447605b5b35a957785363122/starlette-0.49.3-py3-none-any.whl", hash = "sha256:b579b99715fdc2980cf88c8ec96d3bf1ce16f5a8051a7c2b84ef9b1cdecaea2f", size = 74340, upload-time = "2025-11-01T15:12:24.387Z" }, ] -[[package]] -name = "typer" -version = "0.20.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "rich" }, - { name = "shellingham" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8f/28/7c85c8032b91dbe79725b6f17d2fffc595dff06a35c7a30a37bef73a1ab4/typer-0.20.0.tar.gz", hash = "sha256:1aaf6494031793e4876fb0bacfa6a912b551cf43c1e63c800df8b1a866720c37", size = 106492, upload-time = "2025-10-20T17:03:49.445Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/64/7713ffe4b5983314e9d436a90d5bd4f63b6054e2aca783a3cfc44cb95bbf/typer-0.20.0-py3-none-any.whl", hash = "sha256:5b463df6793ec1dca6213a3cf4c0f03bc6e322ac5e16e13ddd622a889489784a", size = 47028, upload-time = "2025-10-20T17:03:47.617Z" }, -] - [[package]] name = "typing-extensions" version = "4.15.0" @@ -989,15 +744,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] -[[package]] -name = "urllib3" -version = "2.5.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, -] - [[package]] name = "uvicorn" version = "0.38.0" From 29ae396e4ef800c127068a9d451e9d266071d859 Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Thu, 20 Nov 2025 14:49:41 +0100 Subject: [PATCH 23/44] refactor: Move update_queue declaration to lifespan event --- src/server.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/server.py b/src/server.py index fc59c73..25af284 100644 --- a/src/server.py +++ b/src/server.py @@ -18,21 +18,19 @@ ProjectItemEditedBody, ProjectItemEditedSingleSelect, ProjectItemEditedTitle, - ProjectItemEvent, SingleSelectType, simple_project_item_from_action_type, single_select_type_from_field_name, ) from src.utils.github_api import fetch_assignees, fetch_item_name, fetch_single_select_value -update_queue: asyncio.Queue[ProjectItemEvent] = asyncio.Queue() - @asynccontextmanager async def lifespan(app: FastAPI): # startup + app.update_queue = asyncio.Queue() app.logger = logging.getLogger("uvicorn.error") - task = asyncio.create_task(run(update_queue, app.logger)) + task = asyncio.create_task(run(app.update_queue, app.logger)) task.add_done_callback(handle_task_exception) yield # shutdown @@ -109,7 +107,7 @@ async def webhook_endpoint(request: Request) -> JSONResponse: else: raise HTTPException(status_code=400, detail="Missing action in payload.") - await update_queue.put(project_item_event) + await app.update_queue.put(project_item_event) app.logger.info(f"Received webhook event for item: {item_name}") return JSONResponse(content={"detail": "Successfully received webhook data"}) From 4c2e6aa013ee7b94c12fcbdcdd861d5b96477321 Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Fri, 21 Nov 2025 19:08:32 +0100 Subject: [PATCH 24/44] refactor: Use Pydantic for request validation --- src/server.py | 82 ++++++++---------- src/tests/test_integration/test_server.py | 100 +++------------------- src/tests/test_unit/test_server.py | 79 +++++++++-------- src/utils/data_types.py | 90 +++++++++++++++++++ 4 files changed, 181 insertions(+), 170 deletions(-) diff --git a/src/server.py b/src/server.py index 25af284..eca8f9a 100644 --- a/src/server.py +++ b/src/server.py @@ -1,12 +1,10 @@ import asyncio -import json import logging import os from contextlib import asynccontextmanager -from json import JSONDecodeError -from typing import Any from fastapi import FastAPI, HTTPException, Request +from pydantic import ValidationError from starlette.exceptions import HTTPException as StarletteHttpException from starlette.responses import JSONResponse @@ -19,6 +17,7 @@ ProjectItemEditedSingleSelect, ProjectItemEditedTitle, SingleSelectType, + WebhookRequest, simple_project_item_from_action_type, single_select_type_from_field_name, ) @@ -60,10 +59,22 @@ async def http_exception_handler(_request: Request, exception: StarletteHttpExce return JSONResponse(status_code=exception.status_code, content={"detail": exception.detail}) -@app.exception_handler(KeyError) -async def key_error_exception_handler(_request: Request, exception: KeyError) -> JSONResponse: - app.logger.error(f"KeyError occurred: {str(exception)}") - return JSONResponse(status_code=400, content={"detail": f"Missing property in body: {str(exception)}"}) +@app.exception_handler(ValidationError) +async def validation_exception_handler(_request: Request, exception: ValidationError) -> JSONResponse: + app.logger.error( + f"ValidationError occurred: {exception.errors(include_url=False, include_context=False, include_input=False)}" + ) + try: + return JSONResponse( + status_code=400, + content={ + "detail": "Invalid request body.", + "validation_errors": exception.errors(include_url=False, include_context=False, include_input=False), + }, + ) + except TypeError: + # Can happen when there is error in JSON parsing + return JSONResponse(status_code=400, content={"detail": "Invalid request body."}) @app.exception_handler(Exception) @@ -76,7 +87,7 @@ async def default_exception_handler(_request: Request, exception: Exception) -> async def webhook_endpoint(request: Request) -> JSONResponse: body_bytes = await request.body() if not body_bytes: - return JSONResponse(status_code=400, content={"detail": "Missing request body."}) + raise HTTPException(status_code=400, detail="Missing request body.") signature = request.headers.get("X-Hub-Signature-256") if signature: correct_signature = verify_secret(os.getenv("GITHUB_WEBHOOK_SECRET", ""), body_bytes, signature) @@ -86,26 +97,20 @@ async def webhook_endpoint(request: Request) -> JSONResponse: raise HTTPException(status_code=401, detail="Missing signature.") else: app.logger.warning("No signature provided and no secret set; skipping verification.") - try: - body: dict[str, Any] = json.loads(body_bytes) - except JSONDecodeError: - raise HTTPException(status_code=400, detail="Invalid JSON payload.") from None - projects_v2_item: dict[str, Any] = body["projects_v2_item"] - project_node_id: str | None = projects_v2_item["project_node_id"] - if project_node_id != os.getenv("GITHUB_PROJECT_NODE_ID"): + + body = WebhookRequest.model_validate_json(body_bytes) + + if body.projects_v2_item.project_node_id != os.getenv("GITHUB_PROJECT_NODE_ID"): raise HTTPException(status_code=400, detail="Invalid project_node_id.") - item_node_id: str | None = projects_v2_item["node_id"] - item_name = await get_item_name(item_node_id) + item_name = await get_item_name(body.projects_v2_item.node_id) - if body.get("action") == "edited": + if body.action == "edited": project_item_event = await process_edition(body, item_name) - elif body.get("action") is not None: + else: project_item_event = simple_project_item_from_action_type( body["action"], item_name, body.get("sender", {}).get("node_id", "Unknown") ) - else: - raise HTTPException(status_code=400, detail="Missing action in payload.") await app.update_queue.put(project_item_event) @@ -113,52 +118,41 @@ async def webhook_endpoint(request: Request) -> JSONResponse: return JSONResponse(content={"detail": "Successfully received webhook data"}) -async def process_edition(body: dict[str, Any], item_name: str) -> ProjectItemEdited: - editor: str = body.get("sender", {}).get("node_id", "Unknown") - body_changed: dict[str, Any] | None = body.get("changes", {}).get("body", None) +async def process_edition(body: WebhookRequest, item_name: str) -> ProjectItemEdited: + editor = body.sender.node_id + body_changed = body.changes.body if body_changed is not None: - new_body = body_changed.get("to", "") - project_item_edited = ProjectItemEditedBody(item_name, editor, new_body) + project_item_edited = ProjectItemEditedBody(item_name, editor, body_changed.to) return project_item_edited - field_changed: dict[str, Any] | None = body.get("changes", {}).get("field_value", None) + field_changed = body.changes.field_value if field_changed is None: raise HTTPException(status_code=400, detail="Failed to recognize the edited event.") - node_id: str | None = body.get("projects_v2_item", {}).get("node_id", None) - if node_id is None: - raise HTTPException(status_code=400, detail="Missing item node ID.") - - match field_changed["field_type"]: + match field_changed.field_type: case "assignees": - new_assignees = await fetch_assignees(node_id) + new_assignees = await fetch_assignees(body.projects_v2_item.node_id) project_item_edited = ProjectItemEditedAssignees(item_name, editor, new_assignees) return project_item_edited case "title": - new_title = await fetch_item_name(node_id) + new_title = await fetch_item_name(body.projects_v2_item.node_id) project_item_edited = ProjectItemEditedTitle(item_name, editor, new_title) return project_item_edited case "single_select": - new_value: str | None = field_changed.get("to", {}).get("name", None) - field_name: str | None = field_changed.get("field_name", None) - if field_name is None: - raise HTTPException(status_code=400, detail="Missing field name for single select field.") + new_value = field_changed.to.name + field_name = field_changed.field_name if new_value is None: - new_value = await fetch_single_select_value(node_id, field_name) + new_value = await fetch_single_select_value(body.projects_v2_item.node_id, field_name) value_type = single_select_type_from_field_name(field_name) if value_type is None: raise HTTPException(status_code=400, detail=f"Unknown single select field name: {field_name}") project_item_edited = ProjectItemEditedSingleSelect(item_name, editor, new_value, value_type) return project_item_edited case "iteration": - new_value = field_changed.get("to", {}).get("title", None) - if new_value is None: - raise HTTPException(status_code=400, detail="Missing new value for iteration field.") + new_value = field_changed.to.title project_item_edited = ProjectItemEditedSingleSelect( item_name, editor, new_value, SingleSelectType.ITERATION ) return project_item_edited - - raise HTTPException(status_code=400, detail=f"Unknown field type: {field_changed['field_type']}") diff --git a/src/tests/test_integration/test_server.py b/src/tests/test_integration/test_server.py index fe532e5..f97c1c8 100644 --- a/src/tests/test_integration/test_server.py +++ b/src/tests/test_integration/test_server.py @@ -1,3 +1,4 @@ +import asyncio import json import logging from typing import Any @@ -12,6 +13,7 @@ test_client = TestClient(app) test_client.app.logger = logging.getLogger("uvicorn.error") +test_client.app.update_queue = asyncio.Queue() def test_missing_body(): @@ -55,91 +57,12 @@ def test_invalid_json(mock_os_getenv): headers={"X-Hub-Signature-256": signature}, ) assert response.status_code == 400 - assert response.json() == {"detail": "Invalid JSON payload."} - - -@patch("os.getenv") -def test_missing_projects_v2_item(mock_os_getenv): - mock_os_getenv.return_value = "some_secret" - signature = generate_signature("some_secret", b'{"not_projects_v2_item": "data"}') - response = test_client.post( - "/webhook_endpoint", - content='{"not_projects_v2_item": "data"}', - headers={"X-Hub-Signature-256": signature}, - ) - assert response.status_code == 400 - assert response.json() == {"detail": "Missing property in body: 'projects_v2_item'"} - - -@patch("os.getenv") -def test_missing_project_node_id(mock_os_getenv): - mock_os_getenv.return_value = "some_secret" - signature = generate_signature("some_secret", b'{"projects_v2_item": {"skibidi": true}}') - response = test_client.post( - "/webhook_endpoint", - content='{"projects_v2_item": {"skibidi": true}}', - headers={"X-Hub-Signature-256": signature}, - ) - assert response.status_code == 400 - assert response.json() == {"detail": "Missing property in body: 'project_node_id'"} - - -@patch("os.getenv") -def test_invalid_project_node_id(mock_os_getenv): - mock_os_getenv.side_effect = ["some_secret", 33] - signature = generate_signature("some_secret", b'{"projects_v2_item": {"project_node_id": 123}}') - response = test_client.post( - "/webhook_endpoint", - content='{"projects_v2_item": {"project_node_id": 123}}', - headers={"X-Hub-Signature-256": signature}, - ) - assert response.status_code == 400 - assert response.json() == {"detail": "Invalid project_node_id."} - - -@patch("os.getenv") -def test_missing_item_node_id(mock_os_getenv): - mock_os_getenv.side_effect = ["some_secret", 123] - signature = generate_signature("some_secret", b'{"projects_v2_item": {"project_node_id": 123}}') - response = test_client.post( - "/webhook_endpoint", - content='{"projects_v2_item": {"project_node_id": 123}}', - headers={"X-Hub-Signature-256": signature}, - ) - assert response.status_code == 400 - assert response.json() == {"detail": "Missing property in body: 'node_id'"} - - -@patch.object(ClientSession, "post") -@patch("shelve.open") -@patch("os.getenv") -def test_could_not_fetch_item_name(mock_os_getenv, mock_shelve_open, mock_post_request): - mock_os_getenv.side_effect = ["some_secret", 123, "some_token", "db-path.db"] - mock_shelve_open.return_value = MockShelf({}) - mock_post_request.return_value = MockResponse({}) - signature = generate_signature("some_secret", b'{"projects_v2_item": {"project_node_id": 123, "node_id": "123"}}') - response = test_client.post( - "/webhook_endpoint", - content='{"projects_v2_item": {"project_node_id": 123, "node_id": "123"}}', - headers={"X-Hub-Signature-256": signature}, - ) - assert response.status_code == 500 - assert response.json() == {"detail": "Could not fetch item name."} - - -@patch("shelve.open") -@patch("os.getenv") -def test_missing_action(mock_os_getenv, mock_shelve_open): - mock_os_getenv.side_effect = ["some_secret", 123, "db-path.db"] - mock_shelve_open.return_value = MockShelf({"123": "Meow"}) - signature = generate_signature("some_secret", b'{"projects_v2_item": {"project_node_id": 123, "node_id": "123"}}') - response = test_client.post( - "/webhook_endpoint", - content='{"projects_v2_item": {"project_node_id": 123, "node_id": "123"}}', - headers={"X-Hub-Signature-256": signature}, - ) - assert response.status_code == 400 - assert response.json() == {"detail": "Missing action in payload."} + assert response.json() == { + "detail": "Invalid request body.", + "validation_errors": [ + {"type": "json_invalid", "loc": [], "msg": "Invalid JSON: expected value at line 1 column 1"} + ], + } @patch.object(ClientSession, "post") @@ -147,12 +70,13 @@ def test_missing_action(mock_os_getenv, mock_shelve_open): @patch("os.getenv") def test_edited_action(mock_os_getenv, mock_shelve_open, mock_post_request): payload: dict[str, Any] = { - "projects_v2_item": {"project_node_id": 123, "node_id": "123"}, + "projects_v2_item": {"project_node_id": "123", "node_id": "123"}, "action": "edited", - "changes": {"field_value": {"field_type": "title"}}, + "changes": {"field_value": {"field_type": "title", "field_name": "Title"}}, + "sender": {"node_id": "456"}, } payload: str = json.dumps(payload) - mock_os_getenv.side_effect = ["some_secret", 123, "some_token", "db-path.db"] + mock_os_getenv.side_effect = ["some_secret", "123", "some_token", "db-path.db"] mock_shelve_open.return_value = MockShelf({"123": "Meow"}) mock_post_request.return_value = MockResponse({"data": {"node": {"content": {"title": "Meow"}}}}) signature = generate_signature( diff --git a/src/tests/test_unit/test_server.py b/src/tests/test_unit/test_server.py index 640104c..b2315d9 100644 --- a/src/tests/test_unit/test_server.py +++ b/src/tests/test_unit/test_server.py @@ -1,78 +1,81 @@ from unittest.mock import patch import pytest -from fastapi import HTTPException from src.server import process_edition from src.utils.data_types import ( + Body, + Changes, + FieldValue, + FieldValueTo, ProjectItemEditedAssignees, ProjectItemEditedBody, ProjectItemEditedSingleSelect, ProjectItemEditedTitle, + ProjectV2Item, + Sender, SingleSelectType, + WebhookRequest, ) -async def test_process_edition_body_changes(): - body = {"changes": {"body": {"to": "We need to pet more cats"}}} - item_name = "PetSomeCats" - expected_object = ProjectItemEditedBody(item_name, "Unknown", "We need to pet more cats") - - assert await process_edition(body, item_name) == expected_object +@pytest.fixture +def mock_webhook_request_model(): + projects_v2_item = ProjectV2Item(project_node_id="node_id", node_id="node_id") + sender = Sender(node_id="node_id") + return WebhookRequest( + projects_v2_item=projects_v2_item, action="edited", sender=sender, changes=Changes(body=Body(to="placeholder")) + ) -async def test_process_edition_body_no_changes(): - body = {} - item_name = "Idk" +async def test_process_edition_body_changes(mock_webhook_request_model): + mock_webhook_request_model.changes = Changes(body=Body(to="We need to pet more cats")) + item_name = "PetSomeCats" + expected_object = ProjectItemEditedBody(item_name, "node_id", "We need to pet more cats") - with pytest.raises(HTTPException) as exception: - await process_edition(body, item_name) - assert exception.value.status_code == 400 - assert exception.value.detail == "Failed to recognize the edited event." + assert await process_edition(mock_webhook_request_model, item_name) == expected_object @patch("src.server.fetch_assignees") -async def test_process_edition_assignees_changed(mock_fetch_assignees): - body = {"changes": {"field_value": {"field_type": "assignees"}}, "projects_v2_item": {"node_id": "node_id"}} +async def test_process_edition_assignees_changed(mock_fetch_assignees, mock_webhook_request_model): + mock_webhook_request_model.changes = Changes(field_value=FieldValue(field_name="Assignees", field_type="assignees")) item_name = "YouKnowIntegrationTestsAreNextDontYou?" new_assignees = ["Kubaryt", "Salieri", "Aniela"] mock_fetch_assignees.return_value = new_assignees - expected_object = ProjectItemEditedAssignees(item_name, "Unknown", new_assignees) + expected_object = ProjectItemEditedAssignees(item_name, "node_id", new_assignees) - assert await process_edition(body, item_name) == expected_object + assert await process_edition(mock_webhook_request_model, item_name) == expected_object @patch("src.server.fetch_item_name") -async def test_process_edition_title_changed(mock_fetch_item_name): - body = {"changes": {"field_value": {"field_type": "title"}}, "projects_v2_item": {"node_id": "node_id"}} +async def test_process_edition_title_changed(mock_fetch_item_name, mock_webhook_request_model): + mock_webhook_request_model.changes = Changes(field_value=FieldValue(field_name="Title", field_type="title")) item_name = "ImagineMockingDiscordSoMuchFun" new_item_name = "ActuallyNotFunAtAll" mock_fetch_item_name.return_value = new_item_name - expected_object = ProjectItemEditedTitle(item_name, "Unknown", new_item_name) + expected_object = ProjectItemEditedTitle(item_name, "node_id", new_item_name) - assert await process_edition(body, item_name) == expected_object + assert await process_edition(mock_webhook_request_model, item_name) == expected_object -async def test_process_edition_single_select_changed(): - body = { - "changes": { - "field_value": {"field_type": "single_select", "field_name": "Size", "to": {"name": "Smol like lil kitten"}} - }, - "projects_v2_item": {"node_id": "node_id"}, - } +async def test_process_edition_single_select_changed(mock_webhook_request_model): + mock_webhook_request_model.changes = Changes( + field_value=FieldValue( + field_name="Size", field_type="single_select", to=FieldValueTo(name="Smol like lil kitten") + ) + ) item_name = "Lil puppy" - expected_object = ProjectItemEditedSingleSelect(item_name, "Unknown", "Smol like lil kitten", SingleSelectType.SIZE) + expected_object = ProjectItemEditedSingleSelect(item_name, "node_id", "Smol like lil kitten", SingleSelectType.SIZE) - assert await process_edition(body, item_name) == expected_object + assert await process_edition(mock_webhook_request_model, item_name) == expected_object -async def test_process_edition_iteration_changed(): +async def test_process_edition_iteration_changed(mock_webhook_request_model): new_title = "1.0.0 - FinallyWeShipItAfter25Years" - body = { - "changes": {"field_value": {"field_type": "iteration", "to": {"title": new_title}}}, - "projects_v2_item": {"node_id": "node_id"}, - } + mock_webhook_request_model.changes = Changes( + field_value=FieldValue(field_name="Iteration", field_type="iteration", to=FieldValueTo(title=new_title)) + ) item_name = "Create Dockerfile for production" - expected_object = ProjectItemEditedSingleSelect(item_name, "Unknown", new_title, SingleSelectType.ITERATION) + expected_object = ProjectItemEditedSingleSelect(item_name, "node_id", new_title, SingleSelectType.ITERATION) - assert await process_edition(body, item_name) == expected_object + assert await process_edition(mock_webhook_request_model, item_name) == expected_object diff --git a/src/utils/data_types.py b/src/utils/data_types.py index d9f9a49..90c688f 100644 --- a/src/utils/data_types.py +++ b/src/utils/data_types.py @@ -1,5 +1,9 @@ from dataclasses import dataclass from enum import Enum +from typing import Literal + +from pydantic import BaseModel, ConfigDict, model_validator +from pydantic_core import PydanticCustomError class SimpleProjectItemEventType(Enum): @@ -86,3 +90,89 @@ def __init__(self, name: str, editor: str, new_value: str, value_type: SingleSel super().__init__(name, editor) self.new_value = new_value self.value_type = value_type + + +class ProjectV2Item(BaseModel): + project_node_id: str + node_id: str + + model_config = ConfigDict(extra="allow") + + +class Sender(BaseModel): + node_id: str + + model_config = ConfigDict(extra="allow") + + +class Body(BaseModel): + to: str + + model_config = ConfigDict(extra="allow") + + +class FieldValueTo(BaseModel): + name: str | None = None + title: str | None = None + + model_config = ConfigDict(extra="allow") + + @model_validator(mode="after") + def check_name_or_title(self): + if self.name is None and self.title is None: + raise PydanticCustomError( + "missing_name_or_title", + "either 'name' or 'title' must be provided in field_value.to", + ) + return self + + +class FieldValue(BaseModel): + field_type: Literal["assignees", "title", "single_select", "iteration"] + to: FieldValueTo | None = None + field_name: str + + model_config = ConfigDict(extra="allow") + + @model_validator(mode="after") + def check_iteration_must_have_to(self): + if self.field_type == "iteration" and self.to is None: + raise PydanticCustomError( + "missing_to", + "'to' must be provided in field_value when field_type is 'single_select' or 'iteration'", + ) + return self + + +class Changes(BaseModel): + body: Body | None = None + field_value: FieldValue | None = None + + model_config = ConfigDict(extra="allow") + + @model_validator(mode="after") + def check_name_or_title(self): + if self.body is None and self.field_value is None: + raise PydanticCustomError( + "missing_name_or_title", + "either 'body' or 'field_value' must be provided in body.changes", + ) + return self + + +class WebhookRequest(BaseModel): + projects_v2_item: ProjectV2Item + action: str + sender: Sender + changes: Changes | None = None + + @model_validator(mode="after") + def changes_must_be_present_for_edited_action(self): + if self.action == "edited" and self.changes is None: + raise PydanticCustomError( + "missing_changes", + "'changes' must be provided in webhook request when action is 'edited'", + ) + return self + + model_config = ConfigDict(extra="allow") From 43afcc7e69f86ed2f795f090f9e5b9112101215e Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Sun, 23 Nov 2025 14:17:40 +0100 Subject: [PATCH 25/44] refactor: Merge simple_project_item_from_action_type with __init__ method for SimpleProjectItemEvent --- src/server.py | 6 ++---- src/tests/test_unit/test_bot.py | 11 +++++------ src/utils/data_types.py | 28 +++++++++++++--------------- 3 files changed, 20 insertions(+), 25 deletions(-) diff --git a/src/server.py b/src/server.py index eca8f9a..36d7ce5 100644 --- a/src/server.py +++ b/src/server.py @@ -16,9 +16,9 @@ ProjectItemEditedBody, ProjectItemEditedSingleSelect, ProjectItemEditedTitle, + SimpleProjectItemEvent, SingleSelectType, WebhookRequest, - simple_project_item_from_action_type, single_select_type_from_field_name, ) from src.utils.github_api import fetch_assignees, fetch_item_name, fetch_single_select_value @@ -108,9 +108,7 @@ async def webhook_endpoint(request: Request) -> JSONResponse: if body.action == "edited": project_item_event = await process_edition(body, item_name) else: - project_item_event = simple_project_item_from_action_type( - body["action"], item_name, body.get("sender", {}).get("node_id", "Unknown") - ) + project_item_event = SimpleProjectItemEvent(item_name, body.sender.node_id, body.action) await app.update_queue.put(project_item_event) diff --git a/src/tests/test_unit/test_bot.py b/src/tests/test_unit/test_bot.py index 73a699e..5e14568 100644 --- a/src/tests/test_unit/test_bot.py +++ b/src/tests/test_unit/test_bot.py @@ -16,7 +16,6 @@ ProjectItemEditedSingleSelect, ProjectItemEditedTitle, SimpleProjectItemEvent, - SimpleProjectItemEventType, SingleSelectType, ) @@ -72,7 +71,7 @@ async def test_process_update_created_success( logger_mock, ): state = asyncio.Queue() - await state.put(SimpleProjectItemEvent("mmmocking", "norbiros", SimpleProjectItemEventType.CREATED)) + await state.put(SimpleProjectItemEvent("mmmocking", "norbiros", "created")) mock_get_post_id.return_value = None mock_retrieve_discord_id.return_value = 2137696742041 @@ -87,7 +86,7 @@ async def test_process_update_already_exists( mock_get_post_id, mock_retrieve_discord_id, mock_fetch_channel, forum_channel_mock, rest_client_mock, logger_mock ): state = asyncio.Queue() - await state.put(SimpleProjectItemEvent("mmmocking", "norbiros", SimpleProjectItemEventType.CREATED)) + await state.put(SimpleProjectItemEvent("mmmocking", "norbiros", "created")) mock_get_post_id.return_value = 1 mock_retrieve_discord_id.return_value = "2137696742041" @@ -112,7 +111,7 @@ async def test_process_update_archived( logger_mock, ): state = asyncio.Queue() - await state.put(SimpleProjectItemEvent("audacity4", "norbiros", SimpleProjectItemEventType.ARCHIVED)) + await state.put(SimpleProjectItemEvent("audacity4", "norbiros", "archived")) mock_get_post_id.return_value = 621 user_id = 2137696742041 mock_retrieve_discord_id.return_value = user_id @@ -142,7 +141,7 @@ async def test_process_update_restored( logger_mock, ): state = asyncio.Queue() - await state.put(SimpleProjectItemEvent("audacity4", "norbiros", SimpleProjectItemEventType.RESTORED)) + await state.put(SimpleProjectItemEvent("audacity4", "norbiros", "restored")) mock_get_post_id.return_value = 621 user_id = 2137696742041 mock_retrieve_discord_id.return_value = user_id @@ -170,7 +169,7 @@ async def test_process_update_deleted( logger_mock, ): state = asyncio.Queue() - await state.put(SimpleProjectItemEvent("audacity4", "norbiros", SimpleProjectItemEventType.DELETED)) + await state.put(SimpleProjectItemEvent("audacity4", "norbiros", "deleted")) mock_get_post_id.return_value = 621 mock_retrieve_discord_id.return_value = "niepodam@norbiros.dev" mock_fetch_channel.return_value = post_mock diff --git a/src/utils/data_types.py b/src/utils/data_types.py index 90c688f..dd4eed5 100644 --- a/src/utils/data_types.py +++ b/src/utils/data_types.py @@ -2,6 +2,7 @@ from enum import Enum from typing import Literal +from fastapi import HTTPException from pydantic import BaseModel, ConfigDict, model_validator from pydantic_core import PydanticCustomError @@ -44,25 +45,22 @@ class ProjectItemEvent: class SimpleProjectItemEvent(ProjectItemEvent): - def __init__(self, name: str, sender: str, event_type: SimpleProjectItemEventType): + def __init__(self, name: str, sender: str, action_type: str): + match action_type: + case "created": + event_type = SimpleProjectItemEventType.CREATED + case "archived": + event_type = SimpleProjectItemEventType.ARCHIVED + case "restored": + event_type = SimpleProjectItemEventType.RESTORED + case "deleted": + event_type = SimpleProjectItemEventType.DELETED + case _: + raise HTTPException(status_code=400, detail=f"Unknown action type: {action_type}") super().__init__(name, sender) self.event_type = event_type -def simple_project_item_from_action_type(action_type: str, name: str, sender: str): - match action_type: - case "created": - return SimpleProjectItemEvent(name, sender, SimpleProjectItemEventType.CREATED) - case "archived": - return SimpleProjectItemEvent(name, sender, SimpleProjectItemEventType.ARCHIVED) - case "restored": - return SimpleProjectItemEvent(name, sender, SimpleProjectItemEventType.RESTORED) - case "deleted": - return SimpleProjectItemEvent(name, sender, SimpleProjectItemEventType.DELETED) - case _: - raise ValueError(f"Unknown action type: {action_type}") - - class ProjectItemEdited(ProjectItemEvent): pass From eef746c50dc01e5a88d0575c340c7617859f042e Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Sun, 23 Nov 2025 14:27:32 +0100 Subject: [PATCH 26/44] refactor: Merge `single_select_type_from_field_name` with `__init__` method for ProjectItemEditedSingleSelect --- src/server.py | 11 ++--------- src/tests/test_unit/test_bot.py | 5 ++--- src/tests/test_unit/test_server.py | 5 ++--- src/utils/data_types.py | 31 ++++++++++++++---------------- 4 files changed, 20 insertions(+), 32 deletions(-) diff --git a/src/server.py b/src/server.py index 36d7ce5..bf45805 100644 --- a/src/server.py +++ b/src/server.py @@ -17,9 +17,7 @@ ProjectItemEditedSingleSelect, ProjectItemEditedTitle, SimpleProjectItemEvent, - SingleSelectType, WebhookRequest, - single_select_type_from_field_name, ) from src.utils.github_api import fetch_assignees, fetch_item_name, fetch_single_select_value @@ -143,14 +141,9 @@ async def process_edition(body: WebhookRequest, item_name: str) -> ProjectItemEd field_name = field_changed.field_name if new_value is None: new_value = await fetch_single_select_value(body.projects_v2_item.node_id, field_name) - value_type = single_select_type_from_field_name(field_name) - if value_type is None: - raise HTTPException(status_code=400, detail=f"Unknown single select field name: {field_name}") - project_item_edited = ProjectItemEditedSingleSelect(item_name, editor, new_value, value_type) + project_item_edited = ProjectItemEditedSingleSelect(item_name, editor, new_value, field_name) return project_item_edited case "iteration": new_value = field_changed.to.title - project_item_edited = ProjectItemEditedSingleSelect( - item_name, editor, new_value, SingleSelectType.ITERATION - ) + project_item_edited = ProjectItemEditedSingleSelect(item_name, editor, new_value, "Iteration") return project_item_edited diff --git a/src/tests/test_unit/test_bot.py b/src/tests/test_unit/test_bot.py index 5e14568..2ad0382 100644 --- a/src/tests/test_unit/test_bot.py +++ b/src/tests/test_unit/test_bot.py @@ -16,7 +16,6 @@ ProjectItemEditedSingleSelect, ProjectItemEditedTitle, SimpleProjectItemEvent, - SingleSelectType, ) from .test_utils import forum_channel_mock, rest_client_mock # noqa: F401 @@ -274,7 +273,7 @@ async def test_process_update_single_select( logger_mock, ): state = asyncio.Queue() - await state.put(ProjectItemEditedSingleSelect("audacity4", "norbiros", "big", SingleSelectType.SIZE)) + await state.put(ProjectItemEditedSingleSelect("audacity4", "norbiros", "big", "Size")) mock_get_post_id.return_value = 621 user_id = 2137696742041 mock_retrieve_discord_id.return_value = user_id @@ -304,7 +303,7 @@ async def test_process_update_single_select_tag_unavailable( logger_mock, ): state = asyncio.Queue() - await state.put(ProjectItemEditedSingleSelect("audacity4", "norbiros", "big", SingleSelectType.SIZE)) + await state.put(ProjectItemEditedSingleSelect("audacity4", "norbiros", "big", "Size")) mock_get_post_id.return_value = 621 user_id = 2137696742041 mock_retrieve_discord_id.return_value = user_id diff --git a/src/tests/test_unit/test_server.py b/src/tests/test_unit/test_server.py index b2315d9..e007456 100644 --- a/src/tests/test_unit/test_server.py +++ b/src/tests/test_unit/test_server.py @@ -14,7 +14,6 @@ ProjectItemEditedTitle, ProjectV2Item, Sender, - SingleSelectType, WebhookRequest, ) @@ -65,7 +64,7 @@ async def test_process_edition_single_select_changed(mock_webhook_request_model) ) ) item_name = "Lil puppy" - expected_object = ProjectItemEditedSingleSelect(item_name, "node_id", "Smol like lil kitten", SingleSelectType.SIZE) + expected_object = ProjectItemEditedSingleSelect(item_name, "node_id", "Smol like lil kitten", "Size") assert await process_edition(mock_webhook_request_model, item_name) == expected_object @@ -76,6 +75,6 @@ async def test_process_edition_iteration_changed(mock_webhook_request_model): field_value=FieldValue(field_name="Iteration", field_type="iteration", to=FieldValueTo(title=new_title)) ) item_name = "Create Dockerfile for production" - expected_object = ProjectItemEditedSingleSelect(item_name, "node_id", new_title, SingleSelectType.ITERATION) + expected_object = ProjectItemEditedSingleSelect(item_name, "node_id", new_title, "Iteration") assert await process_edition(mock_webhook_request_model, item_name) == expected_object diff --git a/src/utils/data_types.py b/src/utils/data_types.py index dd4eed5..846a981 100644 --- a/src/utils/data_types.py +++ b/src/utils/data_types.py @@ -22,22 +22,6 @@ class SingleSelectType(Enum): SECTION = "Section" -def single_select_type_from_field_name(field_name: str) -> SingleSelectType | None: - match field_name: - case "Status": - return SingleSelectType.STATUS - case "Priority": - return SingleSelectType.PRIORITY - case "Size": - return SingleSelectType.SIZE - case "Iteration": - return SingleSelectType.ITERATION - case "Section": - return SingleSelectType.SECTION - case _: - return None - - @dataclass class ProjectItemEvent: name: str @@ -84,9 +68,22 @@ def __init__(self, name: str, editor: str, new_name: str): class ProjectItemEditedSingleSelect(ProjectItemEdited): - def __init__(self, name: str, editor: str, new_value: str, value_type: SingleSelectType): + def __init__(self, name: str, editor: str, new_value: str, field_name: str): super().__init__(name, editor) self.new_value = new_value + match field_name: + case "Status": + value_type = SingleSelectType.STATUS + case "Priority": + value_type = SingleSelectType.PRIORITY + case "Size": + value_type = SingleSelectType.SIZE + case "Iteration": + value_type = SingleSelectType.ITERATION + case "Section": + value_type = SingleSelectType.SECTION + case _: + raise HTTPException(status_code=400, detail=f"Unknown single select field name: {field_name}") self.value_type = value_type From 45e75746819001780e91798b33bb5cf466e2dfe6 Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Sun, 23 Nov 2025 17:21:32 +0100 Subject: [PATCH 27/44] refactor: Move process_edition logic into interface method for ProjectItemEvent - also add RWLock for forum channel --- pyproject.toml | 1 + src/bot.py | 115 ++++++--------------------- src/tests/test_unit/test_bot.py | 67 ++++++++++------ src/utils/__init__.py | 11 +++ src/utils/data_types.py | 135 ++++++++++++++++++++++++++++++++ uv.lock | 11 +++ 6 files changed, 222 insertions(+), 118 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d6f546c..e36e7f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,7 @@ description = "Create post on forum channel for github project cards" requires-python = ">=3.14" dependencies = [ "aiohttp>=3.13.2", + "aiorwlock>=1.5.0", "dotenv>=0.9.9", "fastapi>=0.121.2", "hikari>=2.5.0", diff --git a/src/bot.py b/src/bot.py index bacd1f3..683fbef 100644 --- a/src/bot.py +++ b/src/bot.py @@ -2,19 +2,12 @@ import logging import os -from hikari import ForumTag, GuildForumChannel, GuildPublicThread, RESTApp, TokenType +from hikari import GuildPublicThread, RESTApp, TokenType from hikari.impl import RESTClientImpl -from src.utils import add_bot_log_prefix, retrieve_discord_id -from src.utils.data_types import ( - ProjectItemEditedAssignees, - ProjectItemEditedBody, - ProjectItemEditedSingleSelect, - ProjectItemEditedTitle, - ProjectItemEvent, - SimpleProjectItemEvent, -) -from src.utils.discord_rest_client import fetch_forum_channel, get_new_tag, get_post_id +from src.utils import SharedForumChannel, add_bot_log_prefix, retrieve_discord_id +from src.utils.data_types import ProjectItemEvent +from src.utils.discord_rest_client import fetch_forum_channel, get_post_id from src.utils.error import ForumChannelNotFound @@ -29,14 +22,11 @@ async def run(state: asyncio.Queue[ProjectItemEvent], logger: logging.Logger, st forum_channel = await fetch_forum_channel(client, forum_channel_id) if forum_channel is None: raise ForumChannelNotFound(f"Forum channel with ID {forum_channel_id} not found.") + shared_forum_channel = SharedForumChannel(forum_channel) while True: try: - return_value = await process_update( - client, forum_channel_id, discord_guild_id, forum_channel, state, logger - ) - if return_value is not None: - forum_channel = return_value + await process_update(client, forum_channel_id, discord_guild_id, shared_forum_channel, state, logger) except Exception as error: logger.error(add_bot_log_prefix(f"Error processing update: {error}")) if stop_after_one_event: @@ -47,26 +37,29 @@ async def process_update( client: RESTClientImpl, forum_channel_id: int, discord_guild_id: int, - forum_channel: GuildForumChannel, + shared_forum_channel: SharedForumChannel, state: asyncio.Queue[ProjectItemEvent], logger: logging.Logger, -) -> GuildForumChannel | None: +): event = await state.get() logger.info(add_bot_log_prefix(f"Processing event for item: {event.name}")) + post_id_or_post = await get_post_id(event.name, discord_guild_id, forum_channel_id, client) author_discord_id = retrieve_discord_id(event.sender) user_mentions = [author_discord_id] if author_discord_id else [] user_text_mention = f"<@{author_discord_id}>" if author_discord_id else "nieznany użytkownik" + if post_id_or_post is None: logger.info(add_bot_log_prefix(f"Post not found, creating new post for item: {event.name}")) message = f"Nowy task stworzony {event.name} przez: {user_text_mention}" - post: GuildPublicThread = await client.create_forum_post( - forum_channel, - event.name, - message, - auto_archive_duration=10080, - user_mentions=user_mentions, - ) + async with shared_forum_channel.lock.reader_lock: + post: GuildPublicThread = await client.create_forum_post( + shared_forum_channel.forum_channel, + event.name, + message, + auto_archive_duration=10080, + user_mentions=user_mentions, + ) elif isinstance(post_id_or_post, int): post = await client.fetch_channel(post_id_or_post) else: @@ -77,72 +70,8 @@ async def process_update( logger.error(add_bot_log_prefix(f"Post with ID {post.id} is not a GuildPublicThread.")) except AttributeError: logger.error(add_bot_log_prefix(f"Post with ID {post_id_or_post} is not a GuildPublicThread.")) - return None - - if isinstance(event, SimpleProjectItemEvent): - match event.event_type.value: - case "archived": - message = f"Task zarchiwizowany przez: {user_text_mention}." - await client.edit_channel(post.id, archived=True) - logger.info(add_bot_log_prefix(f"Post {event.name} archived.")) - case "restored": - message = f"Task przywrócony przez: {user_text_mention}." - await client.edit_channel(post.id, archived=False) - logger.info(add_bot_log_prefix(f"Post {event.name} restored.")) - case "deleted": - await client.delete_channel(post.id) - logger.info(add_bot_log_prefix(f"Post {event.name} deleted.")) - return None - elif isinstance(event, ProjectItemEditedAssignees): - assignee_mentions: list[str] = [] - assignee_discord_ids: list[int] = [] - if event.new_assignees: - for assignee in event.new_assignees: - discord_id = retrieve_discord_id(assignee) - assignee_discord_ids.append(int(discord_id)) if discord_id else None - if discord_id: - assignee_mentions.append(f"<@{discord_id}>") - else: - assignee_mentions.append("Brak przypisanych osób") - - message = f"Osoby przypisane do taska edytowane, aktualni przypisani: {', '.join(assignee_mentions)}" - await client.create_message(post.id, message, user_mentions=assignee_discord_ids) - logger.info(add_bot_log_prefix(f"Post {event.name} assignees updated.")) - return None - elif isinstance(event, ProjectItemEditedBody): - message = f"Opis taska zaktualizowany przez: {user_text_mention}. Nowy opis: \n{event.new_body}" - logger.info(add_bot_log_prefix(f"Post {event.name} body updated.")) - elif isinstance(event, ProjectItemEditedTitle): - await client.edit_channel(post.id, name=event.new_title) - return None - elif isinstance(event, ProjectItemEditedSingleSelect): - available_tags = list(forum_channel.available_tags) - current_tag_ids = list(post.applied_tag_ids) - - for tag in available_tags: - if tag.id in current_tag_ids and tag.name.startswith(f"{event.value_type.value}: "): - current_tag_ids.remove(tag.id) - - new_tag_name = f"{event.value_type.value}: {event.new_value}"[:48] - new_tag = get_new_tag(new_tag_name, available_tags) - - if new_tag is None: - logger.info(add_bot_log_prefix(f"Tag {new_tag_name} not found, creating new tag.")) - await client.edit_channel(forum_channel.id, available_tags=[*available_tags, ForumTag(name=new_tag_name)]) - forum_channel = await fetch_forum_channel(client, forum_channel_id) - if forum_channel is None: - raise ForumChannelNotFound(f"Forum channel with ID {forum_channel_id} not found.") - available_tags = list(forum_channel.available_tags) - new_tag = get_new_tag(new_tag_name, available_tags) - - current_tag_ids.append(new_tag.id) - - await client.edit_channel(post.id, applied_tags=current_tag_ids) - logger.info(add_bot_log_prefix(f"Post {event.name} tag updated to {new_tag_name}.")) - return forum_channel - else: - return None - - await client.create_message(post.id, message, user_mentions=user_mentions) + return - return None + message = await event.process(user_text_mention, post, client, logger, shared_forum_channel, forum_channel_id) + if message: + await client.create_message(post.id, message, user_mentions=user_mentions) diff --git a/src/tests/test_unit/test_bot.py b/src/tests/test_unit/test_bot.py index 2ad0382..c7631fa 100644 --- a/src/tests/test_unit/test_bot.py +++ b/src/tests/test_unit/test_bot.py @@ -3,13 +3,14 @@ import asyncio import datetime import logging -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, mock_open, patch import pytest from hikari import ChannelFlag, ForumTag, GuildPublicThread, RESTAware, Snowflake, ThreadMetadata from hikari.impl import RESTClientImpl from src.bot import process_update +from src.utils import SharedForumChannel from src.utils.data_types import ( ProjectItemEditedAssignees, ProjectItemEditedBody, @@ -21,6 +22,11 @@ from .test_utils import forum_channel_mock, rest_client_mock # noqa: F401 +@pytest.fixture +def shared_forum_channel_mock(forum_channel_mock): + return SharedForumChannel(forum_channel_mock) + + @pytest.fixture def post_mock(): mock_timedelta = datetime.timedelta(seconds=0) @@ -65,7 +71,7 @@ async def test_process_update_created_success( mock_get_post_id, mock_retrieve_discord_id, mock_create_forum_post, - forum_channel_mock, + shared_forum_channel_mock, rest_client_mock, logger_mock, ): @@ -74,7 +80,7 @@ async def test_process_update_created_success( mock_get_post_id.return_value = None mock_retrieve_discord_id.return_value = 2137696742041 - await process_update(rest_client_mock, 1, 1, forum_channel_mock, state, logger_mock) + await process_update(rest_client_mock, 1, 1, shared_forum_channel_mock, state, logger_mock) assert mock_create_forum_post.called @@ -82,14 +88,19 @@ async def test_process_update_created_success( @patch("src.bot.retrieve_discord_id") @patch("src.bot.get_post_id", new_callable=AsyncMock) async def test_process_update_already_exists( - mock_get_post_id, mock_retrieve_discord_id, mock_fetch_channel, forum_channel_mock, rest_client_mock, logger_mock + mock_get_post_id, + mock_retrieve_discord_id, + mock_fetch_channel, + shared_forum_channel_mock, + rest_client_mock, + logger_mock, ): state = asyncio.Queue() await state.put(SimpleProjectItemEvent("mmmocking", "norbiros", "created")) mock_get_post_id.return_value = 1 mock_retrieve_discord_id.return_value = "2137696742041" - await process_update(rest_client_mock, 1, 1, forum_channel_mock, state, logger_mock) + await process_update(rest_client_mock, 1, 1, shared_forum_channel_mock, state, logger_mock) assert mock_fetch_channel.called @@ -104,7 +115,7 @@ async def test_process_update_archived( mock_fetch_channel, mock_create_message, mock_edit_channel, - forum_channel_mock, + shared_forum_channel_mock, rest_client_mock, post_mock, logger_mock, @@ -116,7 +127,7 @@ async def test_process_update_archived( mock_retrieve_discord_id.return_value = user_id mock_fetch_channel.return_value = post_mock - await process_update(rest_client_mock, 1, 1, forum_channel_mock, state, logger_mock) + await process_update(rest_client_mock, 1, 1, shared_forum_channel_mock, state, logger_mock) mock_create_message.assert_called_with( post_mock.id, f"Task zarchiwizowany przez: <@{user_id}>.", user_mentions=[user_id] ) @@ -134,7 +145,7 @@ async def test_process_update_restored( mock_fetch_channel, mock_create_message, mock_edit_channel, - forum_channel_mock, + shared_forum_channel_mock, rest_client_mock, post_mock, logger_mock, @@ -146,7 +157,7 @@ async def test_process_update_restored( mock_retrieve_discord_id.return_value = user_id mock_fetch_channel.return_value = post_mock - await process_update(rest_client_mock, 1, 1, forum_channel_mock, state, logger_mock) + await process_update(rest_client_mock, 1, 1, shared_forum_channel_mock, state, logger_mock) mock_create_message.assert_called_with( post_mock.id, f"Task przywrócony przez: <@{user_id}>.", user_mentions=[user_id] ) @@ -162,7 +173,7 @@ async def test_process_update_deleted( mock_retrieve_discord_id, mock_fetch_channel, mock_delete_channel, - forum_channel_mock, + shared_forum_channel_mock, rest_client_mock, post_mock, logger_mock, @@ -173,10 +184,11 @@ async def test_process_update_deleted( mock_retrieve_discord_id.return_value = "niepodam@norbiros.dev" mock_fetch_channel.return_value = post_mock - await process_update(rest_client_mock, 1, 1, forum_channel_mock, state, logger_mock) + await process_update(rest_client_mock, 1, 1, shared_forum_channel_mock, state, logger_mock) mock_delete_channel.assert_called_with(post_mock.id) +@patch("builtins.open", new_callable=mock_open, read_data="norbiros: 2137696742041") @patch.object(RESTClientImpl, "create_message", new_callable=AsyncMock) @patch.object(RESTClientImpl, "fetch_channel", new_callable=AsyncMock) @patch("src.bot.retrieve_discord_id") @@ -186,7 +198,8 @@ async def test_process_update_assignees( mock_retrieve_discord_id, mock_fetch_channel, mock_create_message, - forum_channel_mock, + _mock_open, + shared_forum_channel_mock, rest_client_mock, post_mock, logger_mock, @@ -199,7 +212,7 @@ async def test_process_update_assignees( mock_fetch_channel.return_value = post_mock message = f"Osoby przypisane do taska edytowane, aktualni przypisani: <@{user_id}>" - await process_update(rest_client_mock, 1, 1, forum_channel_mock, state, logger_mock) + await process_update(rest_client_mock, 1, 1, shared_forum_channel_mock, state, logger_mock) mock_create_message.assert_called_with(post_mock.id, message, user_mentions=[user_id]) @@ -212,7 +225,7 @@ async def test_process_update_body( mock_retrieve_discord_id, mock_fetch_channel, mock_create_message, - forum_channel_mock, + shared_forum_channel_mock, rest_client_mock, post_mock, logger_mock, @@ -226,7 +239,7 @@ async def test_process_update_body( mock_fetch_channel.return_value = post_mock message = f"Opis taska zaktualizowany przez: <@{user_id}>. Nowy opis: \n{new_body}" - await process_update(rest_client_mock, 1, 1, forum_channel_mock, state, logger_mock) + await process_update(rest_client_mock, 1, 1, shared_forum_channel_mock, state, logger_mock) mock_create_message.assert_called_with(post_mock.id, message, user_mentions=[user_id]) @@ -239,7 +252,7 @@ async def test_process_update_title( mock_retrieve_discord_id, mock_fetch_channel, mock_edit_channel, - forum_channel_mock, + shared_forum_channel_mock, rest_client_mock, post_mock, logger_mock, @@ -252,14 +265,15 @@ async def test_process_update_title( mock_retrieve_discord_id.return_value = user_id mock_fetch_channel.return_value = post_mock - await process_update(rest_client_mock, 1, 1, forum_channel_mock, state, logger_mock) + await process_update(rest_client_mock, 1, 1, shared_forum_channel_mock, state, logger_mock) mock_edit_channel.assert_called_with(post_mock.id, name=new_title) -@patch("src.bot.get_new_tag") +@patch("builtins.open", new_callable=mock_open, read_data="") +@patch("src.utils.data_types.get_new_tag") @patch.object(RESTClientImpl, "edit_channel", new_callable=AsyncMock) @patch.object(RESTClientImpl, "fetch_channel", new_callable=AsyncMock) -@patch("src.bot.retrieve_discord_id") +@patch("src.utils.data_types.retrieve_discord_id") @patch("src.bot.get_post_id", new_callable=AsyncMock) async def test_process_update_single_select( mock_get_post_id, @@ -267,7 +281,8 @@ async def test_process_update_single_select( mock_fetch_channel, mock_edit_channel, mock_get_new_tag, - forum_channel_mock, + _mock_open, + shared_forum_channel_mock, rest_client_mock, post_mock, logger_mock, @@ -280,12 +295,12 @@ async def test_process_update_single_select( mock_fetch_channel.return_value = post_mock mock_get_new_tag.return_value = ForumTag(id=Snowflake(2), name="Size: big", moderated=False) - await process_update(rest_client_mock, 1, 1, forum_channel_mock, state, logger_mock) + await process_update(rest_client_mock, 1, 1, shared_forum_channel_mock, state, logger_mock) mock_edit_channel.assert_called_with(post_mock.id, applied_tags=[Snowflake(2)]) -@patch("src.bot.fetch_forum_channel", new_callable=AsyncMock) -@patch("src.bot.get_new_tag") +@patch("src.utils.data_types.fetch_forum_channel", new_callable=AsyncMock) +@patch("src.utils.data_types.get_new_tag") @patch.object(RESTClientImpl, "edit_channel", new_callable=AsyncMock) @patch.object(RESTClientImpl, "fetch_channel", new_callable=AsyncMock) @patch("src.bot.retrieve_discord_id") @@ -297,6 +312,7 @@ async def test_process_update_single_select_tag_unavailable( mock_edit_channel, mock_get_new_tag, mock_fetch_forum_channel, + shared_forum_channel_mock, forum_channel_mock, rest_client_mock, post_mock, @@ -312,8 +328,9 @@ async def test_process_update_single_select_tag_unavailable( new_tag = ForumTag(id=Snowflake(0), name="Size: big") mock_get_new_tag.side_effect = [None, new_tag] - await process_update(rest_client_mock, 1, 1, forum_channel_mock, state, logger_mock) + await process_update(rest_client_mock, 1, 1, shared_forum_channel_mock, state, logger_mock) mock_edit_channel.assert_any_call( - forum_channel_mock.id, available_tags=forum_channel_mock.available_tags + [new_tag] + 1, + available_tags=shared_forum_channel_mock.forum_channel.available_tags + [new_tag], ) mock_edit_channel.assert_called_with(post_mock.id, applied_tags=[Snowflake(0)]) diff --git a/src/utils/__init__.py b/src/utils/__init__.py index a105a1a..89931dd 100644 --- a/src/utils/__init__.py +++ b/src/utils/__init__.py @@ -4,10 +4,21 @@ import shelve import yaml +from aiorwlock import RWLock +from hikari import GuildForumChannel from src.utils.github_api import fetch_item_name +class SharedForumChannel: + forum_channel: GuildForumChannel + lock: RWLock + + def __init__(self, forum_channel: GuildForumChannel): + self.forum_channel = forum_channel + self.lock = RWLock() + + async def get_item_name(item_node_id: str) -> str | None: with shelve.open(os.getenv("ITEM_NAME_TO_NODE_ID_DB_PATH", "item_name_to_node_id.db")) as db: try: diff --git a/src/utils/data_types.py b/src/utils/data_types.py index 846a981..07dde8d 100644 --- a/src/utils/data_types.py +++ b/src/utils/data_types.py @@ -1,11 +1,18 @@ from dataclasses import dataclass from enum import Enum +from logging import Logger from typing import Literal from fastapi import HTTPException +from hikari import ForumTag, GuildForumChannel, GuildPublicThread +from hikari.impl import RESTClientImpl from pydantic import BaseModel, ConfigDict, model_validator from pydantic_core import PydanticCustomError +from src.utils import SharedForumChannel, add_bot_log_prefix, retrieve_discord_id +from src.utils.discord_rest_client import fetch_forum_channel, get_new_tag +from src.utils.error import ForumChannelNotFound + class SimpleProjectItemEventType(Enum): CREATED = "created" @@ -27,6 +34,19 @@ class ProjectItemEvent: name: str sender: str + async def process( + self, + user_text_mention: str, + post: GuildPublicThread, + client: RESTClientImpl, + logger: Logger, + shared_forum_channel: SharedForumChannel, + forum_channel_id: int, + ) -> str | GuildForumChannel | None: + """ + Interface method to process the event and optionally return message to be posted in Discord. + """ + class SimpleProjectItemEvent(ProjectItemEvent): def __init__(self, name: str, sender: str, action_type: str): @@ -44,6 +64,34 @@ def __init__(self, name: str, sender: str, action_type: str): super().__init__(name, sender) self.event_type = event_type + async def process( + self, + user_text_mention: str, + post: GuildPublicThread, + client: RESTClientImpl, + logger: Logger, + _shared_forum_channel: SharedForumChannel, + _forum_channel_id: int, + ) -> str | None: + match self.event_type.value: + case "archived": + message = f"Task zarchiwizowany przez: {user_text_mention}." + await client.edit_channel(post.id, archived=True) + logger.info(add_bot_log_prefix(f"Post {self.name} archived.")) + + return message + case "restored": + message = f"Task przywrócony przez: {user_text_mention}." + await client.edit_channel(post.id, archived=False) + logger.info(add_bot_log_prefix(f"Post {self.name} restored.")) + return message + case "deleted": + await client.delete_channel(post.id) + logger.info(add_bot_log_prefix(f"Post {self.name} deleted.")) + return None + case _: + return None + class ProjectItemEdited(ProjectItemEvent): pass @@ -54,18 +102,68 @@ def __init__(self, name: str, editor: str, new_body: str): super().__init__(name, editor) self.new_body = new_body + async def process( + self, + user_text_mention: str, + _post: GuildPublicThread, + client: RESTClientImpl, + logger: Logger, + _shared_forum_channel: SharedForumChannel, + forum_channel_id: int, + ) -> str: + message = f"Opis taska zaktualizowany przez: {user_text_mention}. Nowy opis: \n{self.new_body}" + logger.info(add_bot_log_prefix(f"Post {self.name} body updated.")) + + return message + class ProjectItemEditedAssignees(ProjectItemEdited): def __init__(self, name: str, editor: str, new_assignees: list[str]): super().__init__(name, editor) self.new_assignees = new_assignees + async def process( + self, + user_text_mention: str, + post: GuildPublicThread, + client: RESTClientImpl, + logger: Logger, + _shared_forum_channel: SharedForumChannel, + forum_channel_id: int, + ) -> None: + assignee_mentions: list[str] = [] + assignee_discord_ids: list[int] = [] + if self.new_assignees: + for assignee in self.new_assignees: + discord_id = retrieve_discord_id(assignee) + assignee_discord_ids.append(int(discord_id)) if discord_id else None + if discord_id: + assignee_mentions.append(f"<@{discord_id}>") + else: + assignee_mentions.append("Brak przypisanych osób") + + message = f"Osoby przypisane do taska edytowane, aktualni przypisani: {', '.join(assignee_mentions)}" + await client.create_message(post.id, message, user_mentions=assignee_discord_ids) + logger.info(add_bot_log_prefix(f"Post {self.name} assignees updated.")) + class ProjectItemEditedTitle(ProjectItemEdited): def __init__(self, name: str, editor: str, new_name: str): super().__init__(name, editor) self.new_title = new_name + async def process( + self, + user_text_mention: str, + post: GuildPublicThread, + client: RESTClientImpl, + logger: Logger, + _shared_forum_channel: SharedForumChannel, + forum_channel_id: int, + ) -> None: + await client.edit_channel(post.id, name=self.new_title) + logger.info(add_bot_log_prefix(f"Post {self.name} title updated to {self.new_title}.")) + class ProjectItemEditedSingleSelect(ProjectItemEdited): def __init__(self, name: str, editor: str, new_value: str, field_name: str): @@ -86,6 +184,43 @@ def __init__(self, name: str, editor: str, new_value: str, field_name: str): raise HTTPException(status_code=400, detail=f"Unknown single select field name: {field_name}") self.value_type = value_type + async def process( + self, + user_text_mention: str, + post: GuildPublicThread, + client: RESTClientImpl, + logger: Logger, + shared_forum_channel: SharedForumChannel, + forum_channel_id: int, + ) -> None: + async with shared_forum_channel.lock.reader_lock: + available_tags = list(shared_forum_channel.forum_channel.available_tags) + current_tag_ids = list(post.applied_tag_ids) + + for tag in available_tags: + if tag.id in current_tag_ids and tag.name.startswith(f"{self.value_type.value}: "): + current_tag_ids.remove(tag.id) + + new_tag_name = f"{self.value_type.value}: {self.new_value}"[:48] + new_tag = get_new_tag(new_tag_name, available_tags) + + if new_tag is None: + logger.info(add_bot_log_prefix(f"Tag {new_tag_name} not found, creating new tag.")) + await client.edit_channel(forum_channel_id, available_tags=[*available_tags, ForumTag(name=new_tag_name)]) + forum_channel = await fetch_forum_channel(client, forum_channel_id) + if forum_channel is None: + raise ForumChannelNotFound(f"Forum channel with ID {forum_channel_id} not found.") + async with shared_forum_channel.lock.writer_lock: + shared_forum_channel.forum_channel = forum_channel + async with shared_forum_channel.lock.reader_lock: + available_tags = list(shared_forum_channel.forum_channel.available_tags) + new_tag = get_new_tag(new_tag_name, available_tags) + + current_tag_ids.append(new_tag.id) + + await client.edit_channel(post.id, applied_tags=current_tag_ids) + logger.info(add_bot_log_prefix(f"Post {self.name} tag updated to {new_tag_name}.")) + class ProjectV2Item(BaseModel): project_node_id: str diff --git a/uv.lock b/uv.lock index 8893f71..b19b0d3 100644 --- a/uv.lock +++ b/uv.lock @@ -62,6 +62,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/4d/d22668674122c08f4d56972297c51a624e64b3ed1efaa40187607a7cb66e/aiohttp-3.13.2-cp314-cp314t-win_amd64.whl", hash = "sha256:ff0a7b0a82a7ab905cbda74006318d1b12e37c797eb1b0d4eb3e316cf47f658f", size = 498093, upload-time = "2025-10-28T20:58:52.782Z" }, ] +[[package]] +name = "aiorwlock" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c5/bf/d1ddcd676be027a963b3b01fdf9915daf4590b4dfd03bf1c8c2858aac7e3/aiorwlock-1.5.0.tar.gz", hash = "sha256:b529da24da659bdedcf68faf216595bde00db228c905197ac554773620e7fd2f", size = 7315, upload-time = "2024-11-25T06:03:46.452Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/4c/072b4097b2d05dbc4739b12a073da27496ca8241dec044c1ebc611eacf25/aiorwlock-1.5.0-py3-none-any.whl", hash = "sha256:0010cd2d2c603eb84bfee1cfd06233a976618dab90ec7108191e936137a8420a", size = 7833, upload-time = "2024-11-25T06:03:44.88Z" }, +] + [[package]] name = "aiosignal" version = "1.4.0" @@ -256,6 +265,7 @@ version = "1.0.0" source = { editable = "." } dependencies = [ { name = "aiohttp" }, + { name = "aiorwlock" }, { name = "dotenv" }, { name = "fastapi" }, { name = "hikari" }, @@ -279,6 +289,7 @@ prod = [ [package.metadata] requires-dist = [ { name = "aiohttp", specifier = ">=3.13.2" }, + { name = "aiorwlock", specifier = ">=1.5.0" }, { name = "dotenv", specifier = ">=0.9.9" }, { name = "fastapi", specifier = ">=0.121.2" }, { name = "hikari", specifier = ">=2.5.0" }, From f2188ff84a32f50e71c3f03ad8f467697badae11 Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Sun, 23 Nov 2025 17:26:19 +0100 Subject: [PATCH 28/44] refactor: Move bot starting and bot exception handling logic to main.py --- src/main.py | 32 ++++++++++++++++++++++++++++++++ src/server.py | 32 +------------------------------- 2 files changed, 33 insertions(+), 31 deletions(-) diff --git a/src/main.py b/src/main.py index 5d98b31..40cf81b 100644 --- a/src/main.py +++ b/src/main.py @@ -1,7 +1,13 @@ +import asyncio +import logging import os +from contextlib import asynccontextmanager import dotenv import uvicorn +from fastapi import FastAPI + +from src.bot import run def main(): @@ -10,5 +16,31 @@ def main(): uvicorn.run("src.server:app", host=host, port=int(port), reload=True) +@asynccontextmanager +async def lifespan(app: FastAPI): + # startup + app.update_queue = asyncio.Queue() + app.logger = logging.getLogger("uvicorn.error") + task = asyncio.create_task(run(app.update_queue, app.logger)) + task.add_done_callback(lambda task: handle_task_exception(task, app)) + yield + # shutdown + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + +def handle_task_exception(task: asyncio.Task, app: FastAPI): + try: + exception = task.exception() + except asyncio.CancelledError: + return + + if exception: + app.logger.error(f"Bot task crashed: {exception}") + + if __name__ == "__main__": main() diff --git a/src/server.py b/src/server.py index bf45805..357e180 100644 --- a/src/server.py +++ b/src/server.py @@ -1,14 +1,11 @@ -import asyncio -import logging import os -from contextlib import asynccontextmanager from fastapi import FastAPI, HTTPException, Request from pydantic import ValidationError from starlette.exceptions import HTTPException as StarletteHttpException from starlette.responses import JSONResponse -from src.bot import run +from src.main import lifespan from src.utils import get_item_name, verify_secret from src.utils.data_types import ( ProjectItemEdited, @@ -21,33 +18,6 @@ ) from src.utils.github_api import fetch_assignees, fetch_item_name, fetch_single_select_value - -@asynccontextmanager -async def lifespan(app: FastAPI): - # startup - app.update_queue = asyncio.Queue() - app.logger = logging.getLogger("uvicorn.error") - task = asyncio.create_task(run(app.update_queue, app.logger)) - task.add_done_callback(handle_task_exception) - yield - # shutdown - task.cancel() - try: - await task - except asyncio.CancelledError: - pass - - -def handle_task_exception(task: asyncio.Task): - try: - exception = task.exception() - except asyncio.CancelledError: - return - - if exception: - app.logger.error(f"Bot task crashed: {exception}") - - app = FastAPI(lifespan=lifespan) From 7fb097875d23c294c56ad41fac24ef40b076d183 Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Sun, 23 Nov 2025 17:32:57 +0100 Subject: [PATCH 29/44] refactor: Move logic out of `src/utils/__init__.py` --- src/bot.py | 2 +- src/server.py | 2 +- src/tests/test_e2e.py | 2 +- src/tests/test_integration/test_server.py | 2 +- src/tests/test_unit/test_bot.py | 2 +- src/tests/test_unit/test_utils.py | 4 +- src/utils/__init__.py | 56 ----------------------- src/utils/data_types.py | 2 +- src/utils/misc.py | 56 +++++++++++++++++++++++ 9 files changed, 64 insertions(+), 64 deletions(-) create mode 100644 src/utils/misc.py diff --git a/src/bot.py b/src/bot.py index 683fbef..5b52f49 100644 --- a/src/bot.py +++ b/src/bot.py @@ -5,10 +5,10 @@ from hikari import GuildPublicThread, RESTApp, TokenType from hikari.impl import RESTClientImpl -from src.utils import SharedForumChannel, add_bot_log_prefix, retrieve_discord_id from src.utils.data_types import ProjectItemEvent from src.utils.discord_rest_client import fetch_forum_channel, get_post_id from src.utils.error import ForumChannelNotFound +from src.utils.misc import SharedForumChannel, add_bot_log_prefix, retrieve_discord_id async def run(state: asyncio.Queue[ProjectItemEvent], logger: logging.Logger, stop_after_one_event: bool = False): diff --git a/src/server.py b/src/server.py index 357e180..21bb379 100644 --- a/src/server.py +++ b/src/server.py @@ -6,7 +6,6 @@ from starlette.responses import JSONResponse from src.main import lifespan -from src.utils import get_item_name, verify_secret from src.utils.data_types import ( ProjectItemEdited, ProjectItemEditedAssignees, @@ -17,6 +16,7 @@ WebhookRequest, ) from src.utils.github_api import fetch_assignees, fetch_item_name, fetch_single_select_value +from src.utils.misc import get_item_name, verify_secret app = FastAPI(lifespan=lifespan) diff --git a/src/tests/test_e2e.py b/src/tests/test_e2e.py index 7674a47..408012e 100644 --- a/src/tests/test_e2e.py +++ b/src/tests/test_e2e.py @@ -14,7 +14,7 @@ from src.tests.test_integration.test_bot import RestClientContextManagerMock from src.tests.test_unit.test_bot import post_mock # noqa: F401 from src.tests.test_unit.test_utils import MockShelf, forum_channel_mock, rest_client_mock # noqa: F401 -from src.utils import generate_signature +from src.utils.misc import generate_signature @patch.object(Logger, "info") diff --git a/src/tests/test_integration/test_server.py b/src/tests/test_integration/test_server.py index f97c1c8..6c85e4d 100644 --- a/src/tests/test_integration/test_server.py +++ b/src/tests/test_integration/test_server.py @@ -9,7 +9,7 @@ from src.server import app from src.tests.test_unit.test_utils import MockResponse, MockShelf -from src.utils import generate_signature +from src.utils.misc import generate_signature test_client = TestClient(app) test_client.app.logger = logging.getLogger("uvicorn.error") diff --git a/src/tests/test_unit/test_bot.py b/src/tests/test_unit/test_bot.py index c7631fa..bf317ae 100644 --- a/src/tests/test_unit/test_bot.py +++ b/src/tests/test_unit/test_bot.py @@ -10,7 +10,6 @@ from hikari.impl import RESTClientImpl from src.bot import process_update -from src.utils import SharedForumChannel from src.utils.data_types import ( ProjectItemEditedAssignees, ProjectItemEditedBody, @@ -18,6 +17,7 @@ ProjectItemEditedTitle, SimpleProjectItemEvent, ) +from src.utils.misc import SharedForumChannel from .test_utils import forum_channel_mock, rest_client_mock # noqa: F401 diff --git a/src/tests/test_unit/test_utils.py b/src/tests/test_unit/test_utils.py index 51aa23b..a5b87db 100644 --- a/src/tests/test_unit/test_utils.py +++ b/src/tests/test_unit/test_utils.py @@ -16,8 +16,8 @@ ) from hikari.impl import EntityFactoryImpl, HTTPSettings, ProxySettings, RESTClientImpl -from src import utils from src.utils import discord_rest_client, github_api +from src.utils import misc as utils class MockShelf(dict): @@ -97,7 +97,7 @@ async def test_get_item_name_exist_in_db(mock_shelve_open): @patch("shelve.open") -@patch("src.utils.fetch_item_name", new_callable=AsyncMock) +@patch("src.utils.misc.fetch_item_name", new_callable=AsyncMock) async def test_get_item_name_doesnt_exist_in_db(mock_fetch_item_name, mock_shelve_open): mock_shelf = MockShelf({}) mock_shelve_open.return_value = mock_shelf diff --git a/src/utils/__init__.py b/src/utils/__init__.py index 89931dd..e69de29 100644 --- a/src/utils/__init__.py +++ b/src/utils/__init__.py @@ -1,56 +0,0 @@ -import hashlib -import hmac -import os -import shelve - -import yaml -from aiorwlock import RWLock -from hikari import GuildForumChannel - -from src.utils.github_api import fetch_item_name - - -class SharedForumChannel: - forum_channel: GuildForumChannel - lock: RWLock - - def __init__(self, forum_channel: GuildForumChannel): - self.forum_channel = forum_channel - self.lock = RWLock() - - -async def get_item_name(item_node_id: str) -> str | None: - with shelve.open(os.getenv("ITEM_NAME_TO_NODE_ID_DB_PATH", "item_name_to_node_id.db")) as db: - try: - item_name: str = db[item_node_id] - except KeyError: - item_name = await fetch_item_name(item_node_id) - db[item_node_id] = item_name - - return item_name - - -def retrieve_discord_id(node_id: str) -> str | None: - with open(os.getenv("GITHUB_ID_TO_DISCORD_ID_MAPPING_PATH", "github_id_to_discord_id_mapping.yaml")) as file: - mapping: dict[str, str] = yaml.load("".join(file.readlines()), Loader=yaml.Loader) - - if mapping is None: - return None - - return mapping.get(node_id, None) - - -def generate_signature(secret: str, payload: bytes) -> str: - hash_object = hmac.new(secret.encode("utf-8"), msg=payload, digestmod=hashlib.sha256) - return f"sha256={hash_object.hexdigest()}" - - -def verify_secret(secret: str, payload: bytes, signature_header: str) -> bool: - if not secret: - return True - expected_signature = generate_signature(secret, payload) - return hmac.compare_digest(expected_signature, signature_header) - - -def add_bot_log_prefix(text: str) -> str: - return f"[BOT] {text}" diff --git a/src/utils/data_types.py b/src/utils/data_types.py index 07dde8d..cac023b 100644 --- a/src/utils/data_types.py +++ b/src/utils/data_types.py @@ -9,9 +9,9 @@ from pydantic import BaseModel, ConfigDict, model_validator from pydantic_core import PydanticCustomError -from src.utils import SharedForumChannel, add_bot_log_prefix, retrieve_discord_id from src.utils.discord_rest_client import fetch_forum_channel, get_new_tag from src.utils.error import ForumChannelNotFound +from src.utils.misc import SharedForumChannel, add_bot_log_prefix, retrieve_discord_id class SimpleProjectItemEventType(Enum): diff --git a/src/utils/misc.py b/src/utils/misc.py new file mode 100644 index 0000000..89931dd --- /dev/null +++ b/src/utils/misc.py @@ -0,0 +1,56 @@ +import hashlib +import hmac +import os +import shelve + +import yaml +from aiorwlock import RWLock +from hikari import GuildForumChannel + +from src.utils.github_api import fetch_item_name + + +class SharedForumChannel: + forum_channel: GuildForumChannel + lock: RWLock + + def __init__(self, forum_channel: GuildForumChannel): + self.forum_channel = forum_channel + self.lock = RWLock() + + +async def get_item_name(item_node_id: str) -> str | None: + with shelve.open(os.getenv("ITEM_NAME_TO_NODE_ID_DB_PATH", "item_name_to_node_id.db")) as db: + try: + item_name: str = db[item_node_id] + except KeyError: + item_name = await fetch_item_name(item_node_id) + db[item_node_id] = item_name + + return item_name + + +def retrieve_discord_id(node_id: str) -> str | None: + with open(os.getenv("GITHUB_ID_TO_DISCORD_ID_MAPPING_PATH", "github_id_to_discord_id_mapping.yaml")) as file: + mapping: dict[str, str] = yaml.load("".join(file.readlines()), Loader=yaml.Loader) + + if mapping is None: + return None + + return mapping.get(node_id, None) + + +def generate_signature(secret: str, payload: bytes) -> str: + hash_object = hmac.new(secret.encode("utf-8"), msg=payload, digestmod=hashlib.sha256) + return f"sha256={hash_object.hexdigest()}" + + +def verify_secret(secret: str, payload: bytes, signature_header: str) -> bool: + if not secret: + return True + expected_signature = generate_signature(secret, payload) + return hmac.compare_digest(expected_signature, signature_header) + + +def add_bot_log_prefix(text: str) -> str: + return f"[BOT] {text}" From b1c060d1faa22a69e664bfd2fdd79a1251a85eba Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Sun, 23 Nov 2025 17:46:44 +0100 Subject: [PATCH 30/44] refactor: Remove `add_bot_log_prefix` function and add proper filter --- src/bot.py | 17 +++++++++-------- src/main.py | 2 +- src/tests/test_e2e.py | 2 +- src/tests/test_integration/test_bot.py | 2 +- src/utils/data_types.py | 18 +++++++++--------- src/utils/misc.py | 16 ++++++++++++++-- 6 files changed, 35 insertions(+), 22 deletions(-) diff --git a/src/bot.py b/src/bot.py index 5b52f49..c0d3a60 100644 --- a/src/bot.py +++ b/src/bot.py @@ -8,15 +8,16 @@ from src.utils.data_types import ProjectItemEvent from src.utils.discord_rest_client import fetch_forum_channel, get_post_id from src.utils.error import ForumChannelNotFound -from src.utils.misc import SharedForumChannel, add_bot_log_prefix, retrieve_discord_id +from src.utils.misc import SharedForumChannel, get_bot_logger, retrieve_discord_id -async def run(state: asyncio.Queue[ProjectItemEvent], logger: logging.Logger, stop_after_one_event: bool = False): +async def run(state: asyncio.Queue[ProjectItemEvent], stop_after_one_event: bool = False): discord_rest = RESTApp() await discord_rest.start() + logger = get_bot_logger() async with discord_rest.acquire(os.getenv("DISCORD_BOT_TOKEN"), token_type=TokenType.BOT) as client: - logger.info(add_bot_log_prefix("Discord client acquired.")) + logger.info("Discord client acquired.") forum_channel_id = int(os.getenv("FORUM_CHANNEL_ID")) discord_guild_id = int(os.getenv("DISCORD_GUILD_ID")) forum_channel = await fetch_forum_channel(client, forum_channel_id) @@ -28,7 +29,7 @@ async def run(state: asyncio.Queue[ProjectItemEvent], logger: logging.Logger, st try: await process_update(client, forum_channel_id, discord_guild_id, shared_forum_channel, state, logger) except Exception as error: - logger.error(add_bot_log_prefix(f"Error processing update: {error}")) + logger.error(f"Error processing update: {error}") if stop_after_one_event: break @@ -42,7 +43,7 @@ async def process_update( logger: logging.Logger, ): event = await state.get() - logger.info(add_bot_log_prefix(f"Processing event for item: {event.name}")) + logger.info(f"Processing event for item: {event.name}") post_id_or_post = await get_post_id(event.name, discord_guild_id, forum_channel_id, client) author_discord_id = retrieve_discord_id(event.sender) @@ -50,7 +51,7 @@ async def process_update( user_text_mention = f"<@{author_discord_id}>" if author_discord_id else "nieznany użytkownik" if post_id_or_post is None: - logger.info(add_bot_log_prefix(f"Post not found, creating new post for item: {event.name}")) + logger.info(f"Post not found, creating new post for item: {event.name}") message = f"Nowy task stworzony {event.name} przez: {user_text_mention}" async with shared_forum_channel.lock.reader_lock: post: GuildPublicThread = await client.create_forum_post( @@ -67,9 +68,9 @@ async def process_update( if not isinstance(post, GuildPublicThread): try: - logger.error(add_bot_log_prefix(f"Post with ID {post.id} is not a GuildPublicThread.")) + logger.error(f"Post with ID {post.id} is not a GuildPublicThread.") except AttributeError: - logger.error(add_bot_log_prefix(f"Post with ID {post_id_or_post} is not a GuildPublicThread.")) + logger.error(f"Post with ID {post_id_or_post} is not a GuildPublicThread.") return message = await event.process(user_text_mention, post, client, logger, shared_forum_channel, forum_channel_id) diff --git a/src/main.py b/src/main.py index 40cf81b..44b6838 100644 --- a/src/main.py +++ b/src/main.py @@ -21,7 +21,7 @@ async def lifespan(app: FastAPI): # startup app.update_queue = asyncio.Queue() app.logger = logging.getLogger("uvicorn.error") - task = asyncio.create_task(run(app.update_queue, app.logger)) + task = asyncio.create_task(run(app.update_queue)) task.add_done_callback(lambda task: handle_task_exception(task, app)) yield # shutdown diff --git a/src/tests/test_e2e.py b/src/tests/test_e2e.py index 408012e..aa80d15 100644 --- a/src/tests/test_e2e.py +++ b/src/tests/test_e2e.py @@ -86,7 +86,7 @@ async def test_e2e( for _ in range(500): # up to ~5 seconds total try: - mock_logger.assert_any_call("[BOT] Post audacity4 body updated.") + mock_logger.assert_any_call("Post audacity4 body updated.") break except AssertionError: pass diff --git a/src/tests/test_integration/test_bot.py b/src/tests/test_integration/test_bot.py index ce5d45f..a0543aa 100644 --- a/src/tests/test_integration/test_bot.py +++ b/src/tests/test_integration/test_bot.py @@ -74,5 +74,5 @@ async def test_basic_event_only_creation( mock_create_forum_post.return_value = "created_forum_post" update_queue = asyncio.Queue() await update_queue.put(ProjectItemEvent(name="Test Item", sender="test_sender")) - await run(update_queue, logger_mock, stop_after_one_event=True) + await run(update_queue, stop_after_one_event=True) mock_create_forum_post.assert_called() diff --git a/src/utils/data_types.py b/src/utils/data_types.py index cac023b..b6b059d 100644 --- a/src/utils/data_types.py +++ b/src/utils/data_types.py @@ -11,7 +11,7 @@ from src.utils.discord_rest_client import fetch_forum_channel, get_new_tag from src.utils.error import ForumChannelNotFound -from src.utils.misc import SharedForumChannel, add_bot_log_prefix, retrieve_discord_id +from src.utils.misc import SharedForumChannel, retrieve_discord_id class SimpleProjectItemEventType(Enum): @@ -77,17 +77,17 @@ async def process( case "archived": message = f"Task zarchiwizowany przez: {user_text_mention}." await client.edit_channel(post.id, archived=True) - logger.info(add_bot_log_prefix(f"Post {self.name} archived.")) + logger.info(f"Post {self.name} archived.") return message case "restored": message = f"Task przywrócony przez: {user_text_mention}." await client.edit_channel(post.id, archived=False) - logger.info(add_bot_log_prefix(f"Post {self.name} restored.")) + logger.info(f"Post {self.name} restored.") return message case "deleted": await client.delete_channel(post.id) - logger.info(add_bot_log_prefix(f"Post {self.name} deleted.")) + logger.info(f"Post {self.name} deleted.") return None case _: return None @@ -112,7 +112,7 @@ async def process( forum_channel_id: int, ) -> str: message = f"Opis taska zaktualizowany przez: {user_text_mention}. Nowy opis: \n{self.new_body}" - logger.info(add_bot_log_prefix(f"Post {self.name} body updated.")) + logger.info(f"Post {self.name} body updated.") return message @@ -144,7 +144,7 @@ async def process( message = f"Osoby przypisane do taska edytowane, aktualni przypisani: {', '.join(assignee_mentions)}" await client.create_message(post.id, message, user_mentions=assignee_discord_ids) - logger.info(add_bot_log_prefix(f"Post {self.name} assignees updated.")) + logger.info(f"Post {self.name} assignees updated.") class ProjectItemEditedTitle(ProjectItemEdited): @@ -162,7 +162,7 @@ async def process( forum_channel_id: int, ) -> None: await client.edit_channel(post.id, name=self.new_title) - logger.info(add_bot_log_prefix(f"Post {self.name} title updated to {self.new_title}.")) + logger.info(f"Post {self.name} title updated to {self.new_title}.") class ProjectItemEditedSingleSelect(ProjectItemEdited): @@ -205,7 +205,7 @@ async def process( new_tag = get_new_tag(new_tag_name, available_tags) if new_tag is None: - logger.info(add_bot_log_prefix(f"Tag {new_tag_name} not found, creating new tag.")) + logger.info(f"Tag {new_tag_name} not found, creating new tag.") await client.edit_channel(forum_channel_id, available_tags=[*available_tags, ForumTag(name=new_tag_name)]) forum_channel = await fetch_forum_channel(client, forum_channel_id) if forum_channel is None: @@ -219,7 +219,7 @@ async def process( current_tag_ids.append(new_tag.id) await client.edit_channel(post.id, applied_tags=current_tag_ids) - logger.info(add_bot_log_prefix(f"Post {self.name} tag updated to {new_tag_name}.")) + logger.info(f"Post {self.name} tag updated to {new_tag_name}.") class ProjectV2Item(BaseModel): diff --git a/src/utils/misc.py b/src/utils/misc.py index 89931dd..2c7ac89 100644 --- a/src/utils/misc.py +++ b/src/utils/misc.py @@ -1,5 +1,6 @@ import hashlib import hmac +import logging import os import shelve @@ -52,5 +53,16 @@ def verify_secret(secret: str, payload: bytes, signature_header: str) -> bool: return hmac.compare_digest(expected_signature, signature_header) -def add_bot_log_prefix(text: str) -> str: - return f"[BOT] {text}" +class BotPrefixFilter(logging.Filter): + def filter(self, record: logging.LogRecord) -> bool: + record.msg = f"[BOT] {record.msg}" + return True + + +def get_bot_logger() -> logging.Logger: + logger = logging.getLogger("uvicorn.error.bot") + + if not any(isinstance(f, BotPrefixFilter) for f in logger.filters): + logger.addFilter(BotPrefixFilter()) + + return logger From 464d1f5cb4bf73178441e8cc01b60219b57c7d6c Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Sun, 23 Nov 2025 20:52:28 +0100 Subject: [PATCH 31/44] refactor: Enforce single responsibility principle and move signature verification utils to separate file - create separate functions for tasks like creating post or verificating signature --- src/bot.py | 33 ++++++++++++++++-------- src/server.py | 40 +++++++++++++++++------------ src/utils/data_types.py | 34 ++++++++++++------------ src/utils/misc.py | 14 ---------- src/utils/signature_verification.py | 31 ++++++++++++++++++++++ 5 files changed, 94 insertions(+), 58 deletions(-) create mode 100644 src/utils/signature_verification.py diff --git a/src/bot.py b/src/bot.py index c0d3a60..fb08d7d 100644 --- a/src/bot.py +++ b/src/bot.py @@ -51,16 +51,7 @@ async def process_update( user_text_mention = f"<@{author_discord_id}>" if author_discord_id else "nieznany użytkownik" if post_id_or_post is None: - logger.info(f"Post not found, creating new post for item: {event.name}") - message = f"Nowy task stworzony {event.name} przez: {user_text_mention}" - async with shared_forum_channel.lock.reader_lock: - post: GuildPublicThread = await client.create_forum_post( - shared_forum_channel.forum_channel, - event.name, - message, - auto_archive_duration=10080, - user_mentions=user_mentions, - ) + post = await create_post(logger, event, user_text_mention, shared_forum_channel, client, user_mentions) elif isinstance(post_id_or_post, int): post = await client.fetch_channel(post_id_or_post) else: @@ -70,9 +61,29 @@ async def process_update( try: logger.error(f"Post with ID {post.id} is not a GuildPublicThread.") except AttributeError: - logger.error(f"Post with ID {post_id_or_post} is not a GuildPublicThread.") + logger.error(f"Post with name {event.name} is not a GuildPublicThread.") return message = await event.process(user_text_mention, post, client, logger, shared_forum_channel, forum_channel_id) if message: await client.create_message(post.id, message, user_mentions=user_mentions) + + +async def create_post( + logger: logging.Logger, + event: ProjectItemEvent, + user_text_mention: str, + shared_forum_channel: SharedForumChannel, + client: RESTClientImpl, + user_mentions: list[str], +) -> GuildPublicThread: + logger.info(f"Post not found, creating new post for item: {event.name}") + message = f"Nowy task stworzony {event.name} przez: {user_text_mention}" + async with shared_forum_channel.lock.reader_lock: + return await client.create_forum_post( + shared_forum_channel.forum_channel, + event.name, + message, + auto_archive_duration=10080, + user_mentions=user_mentions, + ) diff --git a/src/server.py b/src/server.py index 21bb379..5372b13 100644 --- a/src/server.py +++ b/src/server.py @@ -7,16 +7,17 @@ from src.main import lifespan from src.utils.data_types import ( - ProjectItemEdited, ProjectItemEditedAssignees, ProjectItemEditedBody, ProjectItemEditedSingleSelect, ProjectItemEditedTitle, + ProjectItemEvent, SimpleProjectItemEvent, WebhookRequest, ) from src.utils.github_api import fetch_assignees, fetch_item_name, fetch_single_select_value -from src.utils.misc import get_item_name, verify_secret +from src.utils.misc import get_item_name +from src.utils.signature_verification import verify_signature app = FastAPI(lifespan=lifespan) @@ -56,15 +57,9 @@ async def webhook_endpoint(request: Request) -> JSONResponse: body_bytes = await request.body() if not body_bytes: raise HTTPException(status_code=400, detail="Missing request body.") + signature = request.headers.get("X-Hub-Signature-256") - if signature: - correct_signature = verify_secret(os.getenv("GITHUB_WEBHOOK_SECRET", ""), body_bytes, signature) - if not correct_signature: - raise HTTPException(status_code=401, detail="Invalid signature.") - elif os.getenv("GITHUB_WEBHOOK_SECRET", ""): - raise HTTPException(status_code=401, detail="Missing signature.") - else: - app.logger.warning("No signature provided and no secret set; skipping verification.") + verify_signature(signature, body_bytes, app.logger) body = WebhookRequest.model_validate_json(body_bytes) @@ -73,18 +68,26 @@ async def webhook_endpoint(request: Request) -> JSONResponse: item_name = await get_item_name(body.projects_v2_item.node_id) - if body.action == "edited": - project_item_event = await process_edition(body, item_name) - else: - project_item_event = SimpleProjectItemEvent(item_name, body.sender.node_id, body.action) - + project_item_event = await process_action(body, item_name) await app.update_queue.put(project_item_event) app.logger.info(f"Received webhook event for item: {item_name}") return JSONResponse(content={"detail": "Successfully received webhook data"}) -async def process_edition(body: WebhookRequest, item_name: str) -> ProjectItemEdited: +async def process_action(body: WebhookRequest, item_name: str) -> ProjectItemEvent: + if body.action == "edited": + return await process_edition(body, item_name) + else: + try: + return SimpleProjectItemEvent(item_name, body.sender.node_id, body.action) + except ValueError as error: + raise HTTPException(status_code=400, detail="Unsupported action.") from error + + +async def process_edition( + body: WebhookRequest, item_name: str +) -> ProjectItemEditedBody | ProjectItemEditedTitle | ProjectItemEditedAssignees | ProjectItemEditedSingleSelect: editor = body.sender.node_id body_changed = body.changes.body @@ -111,7 +114,10 @@ async def process_edition(body: WebhookRequest, item_name: str) -> ProjectItemEd field_name = field_changed.field_name if new_value is None: new_value = await fetch_single_select_value(body.projects_v2_item.node_id, field_name) - project_item_edited = ProjectItemEditedSingleSelect(item_name, editor, new_value, field_name) + try: + project_item_edited = ProjectItemEditedSingleSelect(item_name, editor, new_value, field_name) + except ValueError as error: + raise HTTPException(status_code=400, detail="Unsupported single select field.") from error return project_item_edited case "iteration": new_value = field_changed.to.title diff --git a/src/utils/data_types.py b/src/utils/data_types.py index b6b059d..0f09df5 100644 --- a/src/utils/data_types.py +++ b/src/utils/data_types.py @@ -3,7 +3,6 @@ from logging import Logger from typing import Literal -from fastapi import HTTPException from hikari import ForumTag, GuildForumChannel, GuildPublicThread from hikari.impl import RESTClientImpl from pydantic import BaseModel, ConfigDict, model_validator @@ -50,6 +49,11 @@ async def process( class SimpleProjectItemEvent(ProjectItemEvent): def __init__(self, name: str, sender: str, action_type: str): + super().__init__(name, sender) + self.event_type = self.action_type_to_event_type(action_type) + + @staticmethod + def action_type_to_event_type(action_type: str) -> SimpleProjectItemEventType: match action_type: case "created": event_type = SimpleProjectItemEventType.CREATED @@ -60,9 +64,8 @@ def __init__(self, name: str, sender: str, action_type: str): case "deleted": event_type = SimpleProjectItemEventType.DELETED case _: - raise HTTPException(status_code=400, detail=f"Unknown action type: {action_type}") - super().__init__(name, sender) - self.event_type = event_type + raise ValueError(f"Unknown action type: {action_type}") + return event_type async def process( self, @@ -78,7 +81,6 @@ async def process( message = f"Task zarchiwizowany przez: {user_text_mention}." await client.edit_channel(post.id, archived=True) logger.info(f"Post {self.name} archived.") - return message case "restored": message = f"Task przywrócony przez: {user_text_mention}." @@ -93,11 +95,7 @@ async def process( return None -class ProjectItemEdited(ProjectItemEvent): - pass - - -class ProjectItemEditedBody(ProjectItemEdited): +class ProjectItemEditedBody(ProjectItemEvent): def __init__(self, name: str, editor: str, new_body: str): super().__init__(name, editor) self.new_body = new_body @@ -117,7 +115,7 @@ async def process( return message -class ProjectItemEditedAssignees(ProjectItemEdited): +class ProjectItemEditedAssignees(ProjectItemEvent): def __init__(self, name: str, editor: str, new_assignees: list[str]): super().__init__(name, editor) self.new_assignees = new_assignees @@ -136,9 +134,9 @@ async def process( if self.new_assignees: for assignee in self.new_assignees: discord_id = retrieve_discord_id(assignee) - assignee_discord_ids.append(int(discord_id)) if discord_id else None if discord_id: assignee_mentions.append(f"<@{discord_id}>") + assignee_discord_ids.append(int(discord_id)) else: assignee_mentions.append("Brak przypisanych osób") @@ -147,7 +145,7 @@ async def process( logger.info(f"Post {self.name} assignees updated.") -class ProjectItemEditedTitle(ProjectItemEdited): +class ProjectItemEditedTitle(ProjectItemEvent): def __init__(self, name: str, editor: str, new_name: str): super().__init__(name, editor) self.new_title = new_name @@ -165,10 +163,14 @@ async def process( logger.info(f"Post {self.name} title updated to {self.new_title}.") -class ProjectItemEditedSingleSelect(ProjectItemEdited): +class ProjectItemEditedSingleSelect(ProjectItemEvent): def __init__(self, name: str, editor: str, new_value: str, field_name: str): super().__init__(name, editor) self.new_value = new_value + self.value_type = self.field_name_to_value_type(field_name) + + @staticmethod + def field_name_to_value_type(field_name: str) -> SingleSelectType: match field_name: case "Status": value_type = SingleSelectType.STATUS @@ -181,8 +183,8 @@ def __init__(self, name: str, editor: str, new_value: str, field_name: str): case "Section": value_type = SingleSelectType.SECTION case _: - raise HTTPException(status_code=400, detail=f"Unknown single select field name: {field_name}") - self.value_type = value_type + raise ValueError(f"Unknown single select field name: {field_name}") + return value_type async def process( self, diff --git a/src/utils/misc.py b/src/utils/misc.py index 2c7ac89..8644503 100644 --- a/src/utils/misc.py +++ b/src/utils/misc.py @@ -1,5 +1,3 @@ -import hashlib -import hmac import logging import os import shelve @@ -41,18 +39,6 @@ def retrieve_discord_id(node_id: str) -> str | None: return mapping.get(node_id, None) -def generate_signature(secret: str, payload: bytes) -> str: - hash_object = hmac.new(secret.encode("utf-8"), msg=payload, digestmod=hashlib.sha256) - return f"sha256={hash_object.hexdigest()}" - - -def verify_secret(secret: str, payload: bytes, signature_header: str) -> bool: - if not secret: - return True - expected_signature = generate_signature(secret, payload) - return hmac.compare_digest(expected_signature, signature_header) - - class BotPrefixFilter(logging.Filter): def filter(self, record: logging.LogRecord) -> bool: record.msg = f"[BOT] {record.msg}" diff --git a/src/utils/signature_verification.py b/src/utils/signature_verification.py new file mode 100644 index 0000000..3598534 --- /dev/null +++ b/src/utils/signature_verification.py @@ -0,0 +1,31 @@ +import hashlib +import hmac +import os +from logging import Logger + +from fastapi import HTTPException + + +def verify_signature(signature: str | None, body_bytes: bytes, logger: Logger) -> None: + secret = os.getenv("GITHUB_WEBHOOK_SECRET", "") + if not secret: + logger.warning("GITHUB_WEBHOOK_SECRET is not set; skipping signature verification.") + return + if signature: + correct_signature = verify_secret(secret, body_bytes, signature) + if not correct_signature: + raise HTTPException(status_code=401, detail="Invalid signature.") + else: + raise HTTPException(status_code=401, detail="Missing signature.") + + +def generate_signature(secret: str, payload: bytes) -> str: + hash_object = hmac.new(secret.encode("utf-8"), msg=payload, digestmod=hashlib.sha256) + return f"sha256={hash_object.hexdigest()}" + + +def verify_secret(secret: str, payload: bytes, signature_header: str) -> bool: + if not secret: + return True + expected_signature = generate_signature(secret, payload) + return hmac.compare_digest(expected_signature, signature_header) From cc28524a80ee000a71381336c6c1e7b42479ca88 Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Sun, 23 Nov 2025 20:53:51 +0100 Subject: [PATCH 32/44] refactor: Tests overhaul - split unit tests for utils into separate files - remove integration tests which are duplicated with unit tests - in integration tests test only basic flow, without checking every possibility that may occur in every function - move all fixtures and mock classes into src/tests/utils - add tests for process method in classes inheriting from ProjectItemEvent interface in test_process --- src/tests/test_e2e.py | 11 +- src/tests/test_integration/test_bot.py | 32 +- src/tests/test_integration/test_server.py | 52 +-- src/tests/test_unit/test_bot.py | 414 ++++++++---------- src/tests/test_unit/test_process.py | 251 +++++++++++ src/tests/test_unit/test_server.py | 22 +- src/tests/test_unit/test_utils.py | 295 ------------- src/tests/test_unit/test_utils/__init__.py | 0 .../test_unit/test_utils/test_datatypes.py | 28 ++ .../test_utils/test_discord_rest_client.py | 92 ++++ .../test_unit/test_utils/test_github_api.py | 91 ++++ src/tests/test_unit/test_utils/test_misc.py | 110 +++++ .../test_utils/test_signature_verification.py | 79 ++++ src/tests/utils.py | 147 +++++++ 14 files changed, 1015 insertions(+), 609 deletions(-) create mode 100644 src/tests/test_unit/test_process.py delete mode 100644 src/tests/test_unit/test_utils.py create mode 100644 src/tests/test_unit/test_utils/__init__.py create mode 100644 src/tests/test_unit/test_utils/test_datatypes.py create mode 100644 src/tests/test_unit/test_utils/test_discord_rest_client.py create mode 100644 src/tests/test_unit/test_utils/test_github_api.py create mode 100644 src/tests/test_unit/test_utils/test_misc.py create mode 100644 src/tests/test_unit/test_utils/test_signature_verification.py create mode 100644 src/tests/utils.py diff --git a/src/tests/test_e2e.py b/src/tests/test_e2e.py index aa80d15..d3a0e31 100644 --- a/src/tests/test_e2e.py +++ b/src/tests/test_e2e.py @@ -12,9 +12,8 @@ from src.server import app from src.tests.test_integration.test_bot import RestClientContextManagerMock -from src.tests.test_unit.test_bot import post_mock # noqa: F401 -from src.tests.test_unit.test_utils import MockShelf, forum_channel_mock, rest_client_mock # noqa: F401 -from src.utils.misc import generate_signature +from src.tests.utils import MockShelf, forum_channel_mock, full_post_mock, rest_client_mock # noqa: F401 +from src.utils.signature_verification import generate_signature @patch.object(Logger, "info") @@ -38,10 +37,10 @@ async def test_e2e( mock_logger, rest_client_mock, forum_channel_mock, - post_mock, + full_post_mock, ): mock_restapp_acquire.return_value = RestClientContextManagerMock(rest_client_mock) - mock_fetch_channel.side_effect = [forum_channel_mock, post_mock] + mock_fetch_channel.side_effect = [forum_channel_mock, full_post_mock] mock_getenv.side_effect = [ "some_token", 1, @@ -54,7 +53,7 @@ async def test_e2e( ] post_id_shelf = MockShelf({}) mock_shelve_open.side_effect = [MockShelf({"item123": "audacity4"}), post_id_shelf] - mock_fetch_active_threads.return_value = [post_mock] + mock_fetch_active_threads.return_value = [full_post_mock] config = Config(app=app, host="127.0.0.1", port=8000, log_level="critical") server = Server(config=config) diff --git a/src/tests/test_integration/test_bot.py b/src/tests/test_integration/test_bot.py index a0543aa..b516c45 100644 --- a/src/tests/test_integration/test_bot.py +++ b/src/tests/test_integration/test_bot.py @@ -2,43 +2,13 @@ import asyncio from unittest.mock import AsyncMock, mock_open, patch -import pytest from hikari import RESTApp from hikari.impl import RESTClientImpl from src.bot import run from src.tests.test_unit.test_bot import logger_mock # noqa: F401 -from src.tests.test_unit.test_utils import MockShelf, forum_channel_mock, rest_client_mock # noqa: F401 +from src.tests.utils import MockShelf, RestClientContextManagerMock, forum_channel_mock, rest_client_mock # noqa: F401 from src.utils.data_types import ProjectItemEvent -from src.utils.error import ForumChannelNotFound - - -class RestClientContextManagerMock: - rest_client_mock: RESTClientImpl - - def __init__(self, rest_client_mock): - self.rest_client_mock = rest_client_mock - - async def __aenter__(self): - return self.rest_client_mock - - async def __aexit__(self, exc_type, exc_val, exc_tb): - pass - - -@patch.object(RESTClientImpl, "fetch_channel", new_callable=AsyncMock) -@patch.object(RESTApp, "acquire") -@patch.object(RESTApp, "start", new_callable=AsyncMock) -@patch("os.getenv") -async def test_forum_channel_not_found( - mock_os_getenv, _mock_restapp_start, mock_restapp_acquire, mock_fetch_channel, rest_client_mock, logger_mock -): - mock_os_getenv.side_effect = ["some_token", 1, 2] - mock_restapp_acquire.return_value = RestClientContextManagerMock(rest_client_mock) - mock_fetch_channel.return_value = None - with pytest.raises(ForumChannelNotFound): - update_queue = asyncio.Queue() - await run(update_queue, logger_mock) @patch("builtins.open", new_callable=mock_open, read_data="") diff --git a/src/tests/test_integration/test_server.py b/src/tests/test_integration/test_server.py index 6c85e4d..1c7eefa 100644 --- a/src/tests/test_integration/test_server.py +++ b/src/tests/test_integration/test_server.py @@ -8,8 +8,8 @@ from fastapi.testclient import TestClient from src.server import app -from src.tests.test_unit.test_utils import MockResponse, MockShelf -from src.utils.misc import generate_signature +from src.tests.utils import MockResponse, MockShelf +from src.utils.signature_verification import generate_signature test_client = TestClient(app) test_client.app.logger = logging.getLogger("uvicorn.error") @@ -22,47 +22,25 @@ def test_missing_body(): assert response.json() == {"detail": "Missing request body."} -@patch("os.getenv") -def test_invalid_signature(mock_os_getenv): - mock_os_getenv.return_value = "some_secret" - - response = test_client.post( - "/webhook_endpoint", data={"mreow": "nya"}, headers={"X-Hub-Signature-256": "invalid_signature"} - ) - - assert response.status_code == 401 - assert response.json() == {"detail": "Invalid signature."} - - -@patch("os.getenv") -def test_missing_signature(mock_os_getenv): - mock_os_getenv.return_value = "some_secret" - - response = test_client.post( - "/webhook_endpoint", - data={"mreow": "nya"}, +def test_github_project_node_id_mismatch(): + payload: dict[str, Any] = { + "projects_v2_item": {"project_node_id": "wrong_id", "node_id": "123"}, + "action": "edited", + "changes": {"field_value": {"field_type": "title", "field_name": "Title"}}, + "sender": {"node_id": "456"}, + } + payload: str = json.dumps(payload) + signature = generate_signature( + "some_secret", + payload.encode("utf-8"), ) - - assert response.status_code == 401 - assert response.json() == {"detail": "Missing signature."} - - -@patch("os.getenv") -def test_invalid_json(mock_os_getenv): - mock_os_getenv.return_value = "some_secret" - signature = generate_signature("some_secret", b"invalid_json") response = test_client.post( "/webhook_endpoint", - content="invalid_json", + content=payload, headers={"X-Hub-Signature-256": signature}, ) assert response.status_code == 400 - assert response.json() == { - "detail": "Invalid request body.", - "validation_errors": [ - {"type": "json_invalid", "loc": [], "msg": "Invalid JSON: expected value at line 1 column 1"} - ], - } + assert response.json() == {"detail": "Invalid project_node_id."} @patch.object(ClientSession, "post") diff --git a/src/tests/test_unit/test_bot.py b/src/tests/test_unit/test_bot.py index bf317ae..ffff409 100644 --- a/src/tests/test_unit/test_bot.py +++ b/src/tests/test_unit/test_bot.py @@ -1,336 +1,274 @@ # ruff: noqa: F811 - import asyncio -import datetime import logging -from unittest.mock import AsyncMock, mock_open, patch +from unittest.mock import ANY, AsyncMock, patch import pytest -from hikari import ChannelFlag, ForumTag, GuildPublicThread, RESTAware, Snowflake, ThreadMetadata +from hikari import RESTApp from hikari.impl import RESTClientImpl -from src.bot import process_update +from src import bot +from src.tests.utils import ( # noqa: F401 + RestClientContextManagerMock, + forum_channel_mock, + full_post_mock, + logger_mock, + post_mock, + rest_client_mock, + shared_forum_channel_mock, + user_text_mention, +) from src.utils.data_types import ( - ProjectItemEditedAssignees, - ProjectItemEditedBody, - ProjectItemEditedSingleSelect, - ProjectItemEditedTitle, SimpleProjectItemEvent, ) -from src.utils.misc import SharedForumChannel - -from .test_utils import forum_channel_mock, rest_client_mock # noqa: F401 - - -@pytest.fixture -def shared_forum_channel_mock(forum_channel_mock): - return SharedForumChannel(forum_channel_mock) - - -@pytest.fixture -def post_mock(): - mock_timedelta = datetime.timedelta(seconds=0) - mock_datetime = datetime.datetime.now() - mock_metadata = ThreadMetadata( - is_archived=False, - archive_timestamp=mock_datetime, - auto_archive_duration=mock_timedelta, - is_invitable=False, - is_locked=False, - created_at=mock_datetime, - ) - return GuildPublicThread( - app=RESTAware, - id=Snowflake(621), - name="audacity4", - type=0, - guild_id=Snowflake(0), - last_message_id=None, - last_pin_timestamp=None, - rate_limit_per_user=mock_timedelta, - approximate_message_count=0, - approximate_member_count=0, - member=None, - owner_id=Snowflake(0), - parent_id=Snowflake(0), - metadata=mock_metadata, - applied_tag_ids=[Snowflake(1)], - flags=ChannelFlag(0), - ) - - -@pytest.fixture -def logger_mock(): - return logging.getLogger("uvicorn.error") +from src.utils.error import ForumChannelNotFound @patch.object(RESTClientImpl, "create_forum_post", new_callable=AsyncMock) -@patch("src.bot.retrieve_discord_id") -@patch("src.bot.get_post_id", new_callable=AsyncMock) -async def test_process_update_created_success( - mock_get_post_id, - mock_retrieve_discord_id, +async def test_create_post( mock_create_forum_post, - shared_forum_channel_mock, - rest_client_mock, logger_mock, -): - state = asyncio.Queue() - await state.put(SimpleProjectItemEvent("mmmocking", "norbiros", "created")) - mock_get_post_id.return_value = None - mock_retrieve_discord_id.return_value = 2137696742041 - - await process_update(rest_client_mock, 1, 1, shared_forum_channel_mock, state, logger_mock) - assert mock_create_forum_post.called - - -@patch.object(RESTClientImpl, "fetch_channel", new_callable=AsyncMock) -@patch("src.bot.retrieve_discord_id") -@patch("src.bot.get_post_id", new_callable=AsyncMock) -async def test_process_update_already_exists( - mock_get_post_id, - mock_retrieve_discord_id, - mock_fetch_channel, - shared_forum_channel_mock, rest_client_mock, - logger_mock, + shared_forum_channel_mock, + user_text_mention, ): - state = asyncio.Queue() - await state.put(SimpleProjectItemEvent("mmmocking", "norbiros", "created")) - mock_get_post_id.return_value = 1 - mock_retrieve_discord_id.return_value = "2137696742041" - - await process_update(rest_client_mock, 1, 1, shared_forum_channel_mock, state, logger_mock) - assert mock_fetch_channel.called + message = f"Nowy task stworzony audacity4 przez: {user_text_mention}" + event = SimpleProjectItemEvent("audacity4", "norbiros", "created") + await bot.create_post(logger_mock, event, user_text_mention, shared_forum_channel_mock, rest_client_mock, []) + mock_create_forum_post.assert_called_with( + shared_forum_channel_mock.forum_channel, event.name, message, auto_archive_duration=10080, user_mentions=[] + ) -@patch.object(RESTClientImpl, "edit_channel", new_callable=AsyncMock) -@patch.object(RESTClientImpl, "create_message", new_callable=AsyncMock) -@patch.object(RESTClientImpl, "fetch_channel", new_callable=AsyncMock) +@patch("src.bot.create_post", new_callable=AsyncMock) @patch("src.bot.retrieve_discord_id") @patch("src.bot.get_post_id", new_callable=AsyncMock) -async def test_process_update_archived( +async def test_process_no_post( mock_get_post_id, mock_retrieve_discord_id, - mock_fetch_channel, - mock_create_message, - mock_edit_channel, - shared_forum_channel_mock, + mock_create_post, rest_client_mock, - post_mock, + shared_forum_channel_mock, logger_mock, + full_post_mock, + user_text_mention, ): + mock_get_post_id.return_value = None + mock_retrieve_discord_id.return_value = "123456789012345678" + mock_create_post.return_value = full_post_mock state = asyncio.Queue() - await state.put(SimpleProjectItemEvent("audacity4", "norbiros", "archived")) - mock_get_post_id.return_value = 621 - user_id = 2137696742041 - mock_retrieve_discord_id.return_value = user_id - mock_fetch_channel.return_value = post_mock + event = SimpleProjectItemEvent("audacity4", "norbiros", "created") + await state.put(event) + await bot.process_update( + rest_client_mock, + 1, + 1, + shared_forum_channel_mock, + state, + logger_mock, + ) - await process_update(rest_client_mock, 1, 1, shared_forum_channel_mock, state, logger_mock) - mock_create_message.assert_called_with( - post_mock.id, f"Task zarchiwizowany przez: <@{user_id}>.", user_mentions=[user_id] + assert state.empty() + mock_create_post.assert_called_with( + logger_mock, event, user_text_mention, shared_forum_channel_mock, rest_client_mock, ["123456789012345678"] ) - mock_edit_channel.assert_called_with(post_mock.id, archived=True) -@patch.object(RESTClientImpl, "edit_channel", new_callable=AsyncMock) -@patch.object(RESTClientImpl, "create_message", new_callable=AsyncMock) @patch.object(RESTClientImpl, "fetch_channel", new_callable=AsyncMock) @patch("src.bot.retrieve_discord_id") @patch("src.bot.get_post_id", new_callable=AsyncMock) -async def test_process_update_restored( +async def test_process_post_id_found( mock_get_post_id, mock_retrieve_discord_id, mock_fetch_channel, - mock_create_message, - mock_edit_channel, - shared_forum_channel_mock, rest_client_mock, - post_mock, + shared_forum_channel_mock, logger_mock, + full_post_mock, + user_text_mention, ): + mock_get_post_id.return_value = 67 + mock_retrieve_discord_id.return_value = "123456789012345678" + mock_fetch_channel.return_value = full_post_mock state = asyncio.Queue() - await state.put(SimpleProjectItemEvent("audacity4", "norbiros", "restored")) - mock_get_post_id.return_value = 621 - user_id = 2137696742041 - mock_retrieve_discord_id.return_value = user_id - mock_fetch_channel.return_value = post_mock - - await process_update(rest_client_mock, 1, 1, shared_forum_channel_mock, state, logger_mock) - mock_create_message.assert_called_with( - post_mock.id, f"Task przywrócony przez: <@{user_id}>.", user_mentions=[user_id] + event = SimpleProjectItemEvent("audacity4", "norbiros", "created") + await state.put(event) + await bot.process_update( + rest_client_mock, + 1, + 1, + shared_forum_channel_mock, + state, + logger_mock, ) - mock_edit_channel.assert_called_with(post_mock.id, archived=False) + + assert state.empty() + mock_fetch_channel.assert_called_with(67) -@patch.object(RESTClientImpl, "delete_channel", new_callable=AsyncMock) +@patch("src.bot.create_post", new_callable=AsyncMock) @patch.object(RESTClientImpl, "fetch_channel", new_callable=AsyncMock) @patch("src.bot.retrieve_discord_id") @patch("src.bot.get_post_id", new_callable=AsyncMock) -async def test_process_update_deleted( +async def test_process_post_fetched( mock_get_post_id, mock_retrieve_discord_id, mock_fetch_channel, - mock_delete_channel, - shared_forum_channel_mock, + mock_create_post, rest_client_mock, - post_mock, + shared_forum_channel_mock, logger_mock, + full_post_mock, + user_text_mention, ): + mock_get_post_id.return_value = full_post_mock + mock_retrieve_discord_id.return_value = "123456789012345678" state = asyncio.Queue() - await state.put(SimpleProjectItemEvent("audacity4", "norbiros", "deleted")) - mock_get_post_id.return_value = 621 - mock_retrieve_discord_id.return_value = "niepodam@norbiros.dev" - mock_fetch_channel.return_value = post_mock + event = SimpleProjectItemEvent("audacity4", "norbiros", "created") + await state.put(event) + await bot.process_update( + rest_client_mock, + 1, + 1, + shared_forum_channel_mock, + state, + logger_mock, + ) - await process_update(rest_client_mock, 1, 1, shared_forum_channel_mock, state, logger_mock) - mock_delete_channel.assert_called_with(post_mock.id) + assert state.empty() + mock_fetch_channel.assert_not_called() + mock_create_post.assert_not_called() -@patch("builtins.open", new_callable=mock_open, read_data="norbiros: 2137696742041") -@patch.object(RESTClientImpl, "create_message", new_callable=AsyncMock) -@patch.object(RESTClientImpl, "fetch_channel", new_callable=AsyncMock) +@patch.object(logging.Logger, "error") @patch("src.bot.retrieve_discord_id") @patch("src.bot.get_post_id", new_callable=AsyncMock) -async def test_process_update_assignees( +async def test_process_post_not_guild_public_thread( mock_get_post_id, mock_retrieve_discord_id, - mock_fetch_channel, - mock_create_message, - _mock_open, - shared_forum_channel_mock, + mock_logger_error, rest_client_mock, - post_mock, + shared_forum_channel_mock, logger_mock, + post_mock, + user_text_mention, ): + mock_get_post_id.return_value = post_mock + mock_retrieve_discord_id.return_value = "123456789012345678" state = asyncio.Queue() - await state.put(ProjectItemEditedAssignees("audacity4", "norbiros", ["norbiros"])) - mock_get_post_id.return_value = 621 - user_id = 2137696742041 - mock_retrieve_discord_id.return_value = user_id - mock_fetch_channel.return_value = post_mock - message = f"Osoby przypisane do taska edytowane, aktualni przypisani: <@{user_id}>" + event = SimpleProjectItemEvent("audacity4", "norbiros", "created") + await state.put(event) + await bot.process_update( + rest_client_mock, + 1, + 1, + shared_forum_channel_mock, + state, + logger_mock, + ) - await process_update(rest_client_mock, 1, 1, shared_forum_channel_mock, state, logger_mock) - mock_create_message.assert_called_with(post_mock.id, message, user_mentions=[user_id]) + assert state.empty() + mock_logger_error.assert_called_with("Post with ID 621 is not a GuildPublicThread.") +@patch.object(SimpleProjectItemEvent, "process", new_callable=AsyncMock) @patch.object(RESTClientImpl, "create_message", new_callable=AsyncMock) -@patch.object(RESTClientImpl, "fetch_channel", new_callable=AsyncMock) @patch("src.bot.retrieve_discord_id") @patch("src.bot.get_post_id", new_callable=AsyncMock) -async def test_process_update_body( +async def test_process_post_created_message( mock_get_post_id, mock_retrieve_discord_id, - mock_fetch_channel, mock_create_message, - shared_forum_channel_mock, + mock_event_process, rest_client_mock, - post_mock, + shared_forum_channel_mock, logger_mock, + full_post_mock, + user_text_mention, ): + mock_get_post_id.return_value = full_post_mock + mock_retrieve_discord_id.return_value = "123456789012345678" state = asyncio.Queue() - new_body = "Nowy opis taska" - await state.put(ProjectItemEditedBody("audacity4", "norbiros", new_body)) - mock_get_post_id.return_value = 621 - user_id = 2137696742041 - mock_retrieve_discord_id.return_value = user_id - mock_fetch_channel.return_value = post_mock - message = f"Opis taska zaktualizowany przez: <@{user_id}>. Nowy opis: \n{new_body}" + event = SimpleProjectItemEvent("audacity4", "norbiros", "archived") + mock_event_process.return_value = "Test message content" + await state.put(event) + await bot.process_update( + rest_client_mock, + 1, + 1, + shared_forum_channel_mock, + state, + logger_mock, + ) - await process_update(rest_client_mock, 1, 1, shared_forum_channel_mock, state, logger_mock) - mock_create_message.assert_called_with(post_mock.id, message, user_mentions=[user_id]) + assert state.empty() + mock_create_message.assert_called_with(621, "Test message content", user_mentions=["123456789012345678"]) -@patch.object(RESTClientImpl, "edit_channel", new_callable=AsyncMock) -@patch.object(RESTClientImpl, "fetch_channel", new_callable=AsyncMock) -@patch("src.bot.retrieve_discord_id") -@patch("src.bot.get_post_id", new_callable=AsyncMock) -async def test_process_update_title( - mock_get_post_id, - mock_retrieve_discord_id, - mock_fetch_channel, - mock_edit_channel, - shared_forum_channel_mock, +@patch("src.bot.process_update", new_callable=AsyncMock) +@patch("src.bot.fetch_forum_channel", new_callable=AsyncMock) +@patch.object(RESTApp, "acquire") +@patch.object(RESTApp, "start", new_callable=AsyncMock) +@patch("os.getenv") +async def test_bot_run( + mock_os_getenv, + _mock_restapp_start, + mock_restapp_acquire, + mock_fetch_forum_channel, + mock_process_update, rest_client_mock, - post_mock, - logger_mock, + forum_channel_mock, ): + mock_os_getenv.side_effect = ["some_token", 1, 2] + mock_restapp_acquire.return_value = RestClientContextManagerMock(rest_client_mock) + mock_fetch_forum_channel.return_value = forum_channel_mock state = asyncio.Queue() - new_title = "Nowy opis taska" - await state.put(ProjectItemEditedTitle("audacity4", "norbiros", new_title)) - mock_get_post_id.return_value = 621 - user_id = 2137696742041 - mock_retrieve_discord_id.return_value = user_id - mock_fetch_channel.return_value = post_mock - await process_update(rest_client_mock, 1, 1, shared_forum_channel_mock, state, logger_mock) - mock_edit_channel.assert_called_with(post_mock.id, name=new_title) + await bot.run(state, stop_after_one_event=True) + mock_process_update.assert_called_with(rest_client_mock, 1, 2, ANY, state, bot.get_bot_logger()) -@patch("builtins.open", new_callable=mock_open, read_data="") -@patch("src.utils.data_types.get_new_tag") -@patch.object(RESTClientImpl, "edit_channel", new_callable=AsyncMock) -@patch.object(RESTClientImpl, "fetch_channel", new_callable=AsyncMock) -@patch("src.utils.data_types.retrieve_discord_id") -@patch("src.bot.get_post_id", new_callable=AsyncMock) -async def test_process_update_single_select( - mock_get_post_id, - mock_retrieve_discord_id, - mock_fetch_channel, - mock_edit_channel, - mock_get_new_tag, - _mock_open, - shared_forum_channel_mock, +@patch("src.bot.fetch_forum_channel", new_callable=AsyncMock) +@patch.object(RESTApp, "acquire") +@patch.object(RESTApp, "start", new_callable=AsyncMock) +@patch("os.getenv") +async def test_bot_run_forum_channel_is_none( + mock_os_getenv, + _mock_restapp_start, + mock_restapp_acquire, + mock_fetch_forum_channel, rest_client_mock, - post_mock, - logger_mock, ): + mock_os_getenv.side_effect = ["some_token", 1, 2] + mock_restapp_acquire.return_value = RestClientContextManagerMock(rest_client_mock) + mock_fetch_forum_channel.return_value = None state = asyncio.Queue() - await state.put(ProjectItemEditedSingleSelect("audacity4", "norbiros", "big", "Size")) - mock_get_post_id.return_value = 621 - user_id = 2137696742041 - mock_retrieve_discord_id.return_value = user_id - mock_fetch_channel.return_value = post_mock - mock_get_new_tag.return_value = ForumTag(id=Snowflake(2), name="Size: big", moderated=False) - await process_update(rest_client_mock, 1, 1, shared_forum_channel_mock, state, logger_mock) - mock_edit_channel.assert_called_with(post_mock.id, applied_tags=[Snowflake(2)]) + with pytest.raises(ForumChannelNotFound): + await bot.run(state, stop_after_one_event=True) -@patch("src.utils.data_types.fetch_forum_channel", new_callable=AsyncMock) -@patch("src.utils.data_types.get_new_tag") -@patch.object(RESTClientImpl, "edit_channel", new_callable=AsyncMock) -@patch.object(RESTClientImpl, "fetch_channel", new_callable=AsyncMock) -@patch("src.bot.retrieve_discord_id") -@patch("src.bot.get_post_id", new_callable=AsyncMock) -async def test_process_update_single_select_tag_unavailable( - mock_get_post_id, - mock_retrieve_discord_id, - mock_fetch_channel, - mock_edit_channel, - mock_get_new_tag, +@patch.object(logging.Logger, "error") +@patch("src.bot.process_update", new_callable=AsyncMock) +@patch("src.bot.fetch_forum_channel", new_callable=AsyncMock) +@patch.object(RESTApp, "acquire") +@patch.object(RESTApp, "start", new_callable=AsyncMock) +@patch("os.getenv") +async def test_bot_run_exception_during_process( + mock_os_getenv, + _mock_restapp_start, + mock_restapp_acquire, mock_fetch_forum_channel, - shared_forum_channel_mock, - forum_channel_mock, + mock_process_update, + mock_logger_error, rest_client_mock, - post_mock, - logger_mock, + forum_channel_mock, ): - state = asyncio.Queue() - await state.put(ProjectItemEditedSingleSelect("audacity4", "norbiros", "big", "Size")) - mock_get_post_id.return_value = 621 - user_id = 2137696742041 - mock_retrieve_discord_id.return_value = user_id - mock_fetch_channel.return_value = post_mock + mock_os_getenv.side_effect = ["some_token", 1, 2] + mock_restapp_acquire.return_value = RestClientContextManagerMock(rest_client_mock) mock_fetch_forum_channel.return_value = forum_channel_mock - new_tag = ForumTag(id=Snowflake(0), name="Size: big") - mock_get_new_tag.side_effect = [None, new_tag] + mock_process_update.side_effect = Exception("Some error occurred") + state = asyncio.Queue() - await process_update(rest_client_mock, 1, 1, shared_forum_channel_mock, state, logger_mock) - mock_edit_channel.assert_any_call( - 1, - available_tags=shared_forum_channel_mock.forum_channel.available_tags + [new_tag], - ) - mock_edit_channel.assert_called_with(post_mock.id, applied_tags=[Snowflake(0)]) + await bot.run(state, stop_after_one_event=True) + mock_logger_error.assert_called_with("Error processing update: Some error occurred") diff --git a/src/tests/test_unit/test_process.py b/src/tests/test_unit/test_process.py new file mode 100644 index 0000000..8132ddb --- /dev/null +++ b/src/tests/test_unit/test_process.py @@ -0,0 +1,251 @@ +# ruff: noqa: F811 +from unittest.mock import AsyncMock, mock_open, patch + +from hikari import ForumTag, Snowflake +from hikari.impl import RESTClientImpl + +from src.tests.utils import ( # noqa: F401 + forum_channel_mock, + full_post_mock, + logger_mock, + post_mock, + rest_client_mock, + shared_forum_channel_mock, + user_text_mention, +) +from src.utils.data_types import ( + ProjectItemEditedAssignees, + ProjectItemEditedBody, + ProjectItemEditedSingleSelect, + ProjectItemEditedTitle, + SimpleProjectItemEvent, +) + + +@patch.object(RESTClientImpl, "edit_channel") +async def test_simple_project_item_event_process_archived( + mock_edit_channel, user_text_mention, post_mock, rest_client_mock, logger_mock, shared_forum_channel_mock +): + event = SimpleProjectItemEvent("audacity4", "norbiros", "archived") + assert ( + await event.process( + user_text_mention, + post_mock, + rest_client_mock, + logger_mock, + shared_forum_channel_mock, + shared_forum_channel_mock.forum_channel.id, + ) + == "Task zarchiwizowany przez: <@123456789012345678>." + ) + mock_edit_channel.assert_called_with(post_mock.id, archived=True) + + +@patch.object(RESTClientImpl, "edit_channel") +async def test_simple_project_item_event_process_restored( + mock_edit_channel, user_text_mention, post_mock, rest_client_mock, logger_mock, shared_forum_channel_mock +): + event = SimpleProjectItemEvent("audacity4", "norbiros", "restored") + assert ( + await event.process( + user_text_mention, + post_mock, + rest_client_mock, + logger_mock, + shared_forum_channel_mock, + shared_forum_channel_mock.forum_channel.id, + ) + == "Task przywrócony przez: <@123456789012345678>." + ) + mock_edit_channel.assert_called_with(post_mock.id, archived=False) + + +@patch.object(RESTClientImpl, "delete_channel") +async def test_simple_project_item_event_process_deleted( + mock_delete_channel, user_text_mention, post_mock, rest_client_mock, logger_mock, shared_forum_channel_mock +): + event = SimpleProjectItemEvent("audacity4", "norbiros", "deleted") + assert ( + await event.process( + user_text_mention, + post_mock, + rest_client_mock, + logger_mock, + shared_forum_channel_mock, + shared_forum_channel_mock.forum_channel.id, + ) + is None + ) + mock_delete_channel.assert_called_with(post_mock.id) + + +async def test_simple_project_item_event_process_created( + user_text_mention, post_mock, rest_client_mock, logger_mock, shared_forum_channel_mock +): + event = SimpleProjectItemEvent("audacity4", "norbiros", "created") + assert ( + await event.process( + user_text_mention, + post_mock, + rest_client_mock, + logger_mock, + shared_forum_channel_mock, + shared_forum_channel_mock.forum_channel.id, + ) + is None + ) + + +async def test_project_item_edited_body( + user_text_mention, post_mock, rest_client_mock, logger_mock, shared_forum_channel_mock +): + event = ProjectItemEditedBody("audacity4", "norbiros", "edited_body") + assert ( + await event.process( + user_text_mention, + post_mock, + rest_client_mock, + logger_mock, + shared_forum_channel_mock, + shared_forum_channel_mock.forum_channel.id, + ) + == "Opis taska zaktualizowany przez: <@123456789012345678>. Nowy opis: \nedited_body" + ) + + +@patch.object(RESTClientImpl, "create_message") +@patch("builtins.open", new_callable=mock_open, read_data="node_id1: 123\nnode_id2: 321\n") +async def test_project_item_edited_assignees( + user_text_mention, post_mock, rest_client_mock, logger_mock, shared_forum_channel_mock +): + event = ProjectItemEditedAssignees("audacity4", "norbiros", ["node_id1", "node_id2"]) + await event.process( + user_text_mention, + post_mock, + rest_client_mock, + logger_mock, + shared_forum_channel_mock, + shared_forum_channel_mock.forum_channel.id, + ) + + rest_client_mock.create_message.assert_called_with( + post_mock.id, + "Osoby przypisane do taska edytowane, aktualni przypisani: <@123>, <@321>", + user_mentions=[123, 321], + ) + + +@patch.object(RESTClientImpl, "create_message") +@patch("builtins.open", new_callable=mock_open, read_data="") +async def test_project_item_edited_assignees_not_in_mapping( + user_text_mention, post_mock, rest_client_mock, logger_mock, shared_forum_channel_mock +): + event = ProjectItemEditedAssignees("audacity4", "norbiros", ["node_id1", "node_id2"]) + await event.process( + user_text_mention, + post_mock, + rest_client_mock, + logger_mock, + shared_forum_channel_mock, + shared_forum_channel_mock.forum_channel.id, + ) + + rest_client_mock.create_message.assert_called_with( + post_mock.id, "Osoby przypisane do taska edytowane, aktualni przypisani: ", user_mentions=[] + ) + + +@patch.object(RESTClientImpl, "create_message") +@patch("builtins.open", new_callable=mock_open, read_data="") +async def test_project_item_edited_assignees_no_assignees( + user_text_mention, post_mock, rest_client_mock, logger_mock, shared_forum_channel_mock +): + event = ProjectItemEditedAssignees("audacity4", "norbiros", []) + await event.process( + user_text_mention, + post_mock, + rest_client_mock, + logger_mock, + shared_forum_channel_mock, + shared_forum_channel_mock.forum_channel.id, + ) + + rest_client_mock.create_message.assert_called_with( + post_mock.id, + "Osoby przypisane do taska edytowane, aktualni przypisani: Brak przypisanych osób", + user_mentions=[], + ) + + +@patch.object(RESTClientImpl, "edit_channel") +async def test_project_item_edited_title( + mock_edit_channel, user_text_mention, post_mock, rest_client_mock, logger_mock, shared_forum_channel_mock +): + event = ProjectItemEditedTitle("audacity4", "norbiros", "edited_title") + await event.process( + user_text_mention, + post_mock, + rest_client_mock, + logger_mock, + shared_forum_channel_mock, + shared_forum_channel_mock.forum_channel.id, + ) + mock_edit_channel.assert_called_with(post_mock.id, name="edited_title") + + +@patch.object(RESTClientImpl, "edit_channel") +async def test_project_item_edited_single_select_existing_tag( + mock_edit_channel, + user_text_mention, + full_post_mock, + rest_client_mock, + logger_mock, + shared_forum_channel_mock, + forum_channel_mock, +): + event = ProjectItemEditedSingleSelect("audacity4", "norbiros", "smol", "Size") + await event.process( + user_text_mention, + full_post_mock, + rest_client_mock, + logger_mock, + shared_forum_channel_mock, + forum_channel_mock.id, + ) + + mock_edit_channel.assert_called_with(full_post_mock.id, applied_tags=[Snowflake(1)]) + + +@patch("src.utils.data_types.get_new_tag") +@patch("src.utils.data_types.fetch_forum_channel", new_callable=AsyncMock) +@patch.object(RESTClientImpl, "edit_channel") +async def test_project_item_edited_single_select_tag_unavailable( + mock_edit_channel, + mock_fetch_forum_channel, + mock_get_new_tag, + user_text_mention, + full_post_mock, + rest_client_mock, + logger_mock, + shared_forum_channel_mock, + forum_channel_mock, +): + event = ProjectItemEditedSingleSelect("audacity4", "norbiros", "medium", "Size") + new_tag = ForumTag(id=Snowflake(0), name="Size: medium") + mock_fetch_forum_channel.return_value = forum_channel_mock + mock_get_new_tag.side_effect = [None, new_tag] + + await event.process( + user_text_mention, + full_post_mock, + rest_client_mock, + logger_mock, + shared_forum_channel_mock, + forum_channel_mock.id, + ) + + mock_edit_channel.assert_any_call( + 67, + available_tags=[*shared_forum_channel_mock.forum_channel.available_tags, new_tag], + ) + mock_edit_channel.assert_called_with(full_post_mock.id, applied_tags=[Snowflake(0)]) diff --git a/src/tests/test_unit/test_server.py b/src/tests/test_unit/test_server.py index e007456..e1421a5 100644 --- a/src/tests/test_unit/test_server.py +++ b/src/tests/test_unit/test_server.py @@ -1,8 +1,8 @@ -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest -from src.server import process_edition +from src.server import process_action, process_edition from src.utils.data_types import ( Body, Changes, @@ -14,6 +14,7 @@ ProjectItemEditedTitle, ProjectV2Item, Sender, + SimpleProjectItemEvent, WebhookRequest, ) @@ -78,3 +79,20 @@ async def test_process_edition_iteration_changed(mock_webhook_request_model): expected_object = ProjectItemEditedSingleSelect(item_name, "node_id", new_title, "Iteration") assert await process_edition(mock_webhook_request_model, item_name) == expected_object + + +@patch("src.server.process_edition", new_callable=AsyncMock) +async def test_process_action_process_edition(mock_process_edition, mock_webhook_request_model): + test_event = SimpleProjectItemEvent("Test Item", "node_id", "created") + mock_process_edition.return_value = test_event + + assert await process_action(mock_webhook_request_model, "Test Item") == test_event + + +@patch("src.server.process_edition", new_callable=AsyncMock) +async def test_process_action_simple_event(mock_process_edition, mock_webhook_request_model): + mock_webhook_request_model.action = "created" + test_event = SimpleProjectItemEvent("Test Item", "node_id", "created") + mock_process_edition.return_value = test_event + + assert await process_action(mock_webhook_request_model, "Test Item") == test_event diff --git a/src/tests/test_unit/test_utils.py b/src/tests/test_unit/test_utils.py deleted file mode 100644 index a5b87db..0000000 --- a/src/tests/test_unit/test_utils.py +++ /dev/null @@ -1,295 +0,0 @@ -import datetime -from unittest.mock import AsyncMock, mock_open, patch - -import pytest -from aiohttp import ClientSession -from fastapi import HTTPException -from hikari import ( - ChannelFlag, - ForumLayoutType, - ForumSortOrderType, - ForumTag, - GuildForumChannel, - PartialChannel, - RESTAware, - Snowflake, -) -from hikari.impl import EntityFactoryImpl, HTTPSettings, ProxySettings, RESTClientImpl - -from src.utils import discord_rest_client, github_api -from src.utils import misc as utils - - -class MockShelf(dict): - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - pass - - -class MockResponse(dict): - async def __aenter__(self): - return self - - async def __aexit__(self, exc_type, exc, tb): - pass - - async def json(self): - return self - - -@pytest.fixture -def rest_client_mock(): - entity_factory = EntityFactoryImpl(app=RESTAware) - http_settings = HTTPSettings() - proxy_settings = ProxySettings() - return RESTClientImpl( - cache=None, - entity_factory=entity_factory, - executor=None, - http_settings=http_settings, - proxy_settings=proxy_settings, - token=None, - token_type=None, - rest_url=None, - ) - - -@pytest.fixture() -def post_mock(): - return PartialChannel(app=RESTAware, id=Snowflake(621), name="audacity4", type=0) - - -@pytest.fixture() -def forum_channel_mock(): - mock_timedelta = datetime.timedelta(seconds=0) - return GuildForumChannel( - app=RESTAware, - id=Snowflake(67), - name="forum-channel", - topic="A forum channel", - is_nsfw=False, - default_auto_archive_duration=mock_timedelta, - available_tags=[ForumTag(id=Snowflake(1), name="Size: smol", moderated=False)], - type=0, - guild_id=Snowflake(41), - parent_id=None, - position=0, - permission_overwrites={}, - last_thread_id=None, - rate_limit_per_user=mock_timedelta, - default_thread_rate_limit_per_user=mock_timedelta, - flags=ChannelFlag(0), - default_sort_order=ForumSortOrderType.CREATION_DATE, - default_layout=ForumLayoutType.GALLERY_VIEW, - default_reaction_emoji_id=None, - default_reaction_emoji_name=None, - ) - - -@patch("shelve.open") -async def test_get_item_name_exist_in_db(mock_shelve_open): - mock_db = {"O_kgDOCUX8Wg": "crabcraft"} - mock_shelve_open.return_value = MockShelf(mock_db) - - assert await utils.get_item_name("O_kgDOCUX8Wg") == "crabcraft" - - -@patch("shelve.open") -@patch("src.utils.misc.fetch_item_name", new_callable=AsyncMock) -async def test_get_item_name_doesnt_exist_in_db(mock_fetch_item_name, mock_shelve_open): - mock_shelf = MockShelf({}) - mock_shelve_open.return_value = mock_shelf - mock_fetch_item_name.return_value = "crabcraft" - - assert await utils.get_item_name("O_kgDOCUX8Wg") == "crabcraft" - assert mock_shelf.get("O_kgDOCUX8Wg") == "crabcraft" - - -@patch.object(ClientSession, "post") -async def test_fetch_item_name_success(mock_post_request): - mock_response = {"data": {"node": {"content": {"title": "42"}}}} - mock_post_request.return_value = MockResponse(mock_response) - - assert await utils.fetch_item_name("") == "42" - - -@patch.object(ClientSession, "post") -async def test_fetch_item_name_partial(mock_post_request): - mock_response = {"data": {"node": {"content": None}}} - mock_post_request.return_value = MockResponse(mock_response) - - with pytest.raises(HTTPException) as exception: - await utils.fetch_item_name("") - - assert exception.value.status_code == 500 - assert exception.value.detail == "Could not fetch item name." - - -@patch.object(ClientSession, "post") -async def test_fetch_item_name_none(mock_post_request): - mock_post_request.return_value = MockResponse({}) - - with pytest.raises(HTTPException) as exception: - await utils.fetch_item_name("") - assert exception.value.status_code == 500 - assert exception.value.detail == "Could not fetch item name." - - -@patch.object(ClientSession, "post") -async def test_fetch_assignees_success(mock_post_request): - mock_response = { - "data": { - "node": { - "content": {"assignees": {"nodes": [{"id": "MDQ6VXNlcjg4MjY4MDYz"}, {"id": "MDQ6VXNlcjg5ODM3NzI0"}]}} - } - } - } - mock_post_request.return_value = MockResponse(mock_response) - - assert await github_api.fetch_assignees("") == ["MDQ6VXNlcjg4MjY4MDYz", "MDQ6VXNlcjg5ODM3NzI0"] - - -@patch.object(ClientSession, "post") -async def test_fetch_assignees_partial(mock_post_request): - mock_response = {"data": {"node": {"content": None}}} - mock_post_request.return_value = MockResponse(mock_response) - - assert await github_api.fetch_assignees("") == [] - - -@patch.object(ClientSession, "post") -async def test_fetch_assignees_none(mock_post_request): - mock_post_request.return_value = MockResponse({}) - - assert await github_api.fetch_assignees("") == [] - - -@patch.object(ClientSession, "post") -async def test_fetch_single_select_value_success(mock_post_request): - mock_response = {"data": {"node": {"fieldValueByName": {"name": "Dziengiel"}}}} - mock_post_request.return_value = MockResponse(mock_response) - - assert await github_api.fetch_single_select_value("", "Salieri") == "Dziengiel" - - -@patch.object(ClientSession, "post") -async def test_fetch_single_select_value_partial(mock_post_request): - mock_response = {"data": {"node": {"fieldValueByName": None}}} - mock_post_request.return_value = MockResponse(mock_response) - - assert await github_api.fetch_single_select_value("", "Salieri") is None - - -@patch.object(ClientSession, "post") -async def test_fetch_single_select_value_none(mock_post_request): - mock_response = {} - mock_post_request.return_value = MockResponse(mock_response) - - assert await github_api.fetch_single_select_value("", "Salieri") is None - - -@patch("shelve.open") -async def test_get_post_id_exist_in_db(mock_shelve_open, rest_client_mock): - mock_db = {"audacity4": 621} - mock_shelve_open.return_value = MockShelf(mock_db) - - assert await discord_rest_client.get_post_id("audacity4", 1, 1, rest_client_mock) == 621 - - -@patch.object(RESTClientImpl, "fetch_active_threads", new_callable=AsyncMock) -@patch("shelve.open") -async def test_get_post_id_active_thread(mock_shelve_open, mock_fetch_active_threads, rest_client_mock, post_mock): - mock_shelf = MockShelf({}) - mock_shelve_open.return_value = mock_shelf - mock_fetch_active_threads.return_value = [post_mock] - - assert await discord_rest_client.get_post_id("audacity4", 1, 1, rest_client_mock) == post_mock - assert mock_shelf.get("audacity4") == 621 - - -@patch.object(RESTClientImpl, "fetch_public_archived_threads", new_callable=AsyncMock) -@patch.object(RESTClientImpl, "fetch_active_threads", new_callable=AsyncMock) -@patch("shelve.open") -async def test_get_post_id_archived_thread( - mock_shelve_open, mock_fetch_active_threads, mock_fetch_public_archived_threads, rest_client_mock, post_mock -): - mock_shelf = MockShelf({}) - mock_shelve_open.return_value = mock_shelf - mock_fetch_active_threads.return_value = [] - mock_fetch_public_archived_threads.return_value = [post_mock] - - assert await discord_rest_client.get_post_id("audacity4", 1, 1, rest_client_mock) == post_mock - assert mock_shelf.get("audacity4") == 621 - - -@patch("builtins.open", new_callable=mock_open, read_data='MDQ6VXNlcjY2NTE0ODg1: "393756120952602625"') -@patch("yaml.load") -def test_retrieve_discord_id_present_id(mock_yaml_load, _mock_open_file): - mock_yaml_load.return_value = {"MDQ6VXNlcjY2NTE0ODg1": "393756120952602625"} - - assert utils.retrieve_discord_id("MDQ6VXNlcjY2NTE0ODg1") == "393756120952602625" - - -@patch("builtins.open", new_callable=mock_open, read_data="") -def test_retrieve_discord_id_absent_id(_mock_open_file): - assert utils.retrieve_discord_id("") is None - - -@patch.object(RESTClientImpl, "fetch_channel", new_callable=AsyncMock) -async def test_fetch_forum_channel_success(mock_fetch_channel, rest_client_mock, forum_channel_mock): - mock_fetch_channel.return_value = forum_channel_mock - - assert await discord_rest_client.fetch_forum_channel(rest_client_mock, 67) == forum_channel_mock - - -@patch.object(RESTClientImpl, "fetch_channel", new_callable=AsyncMock) -async def test_fetch_forum_channel_none(mock_fetch_channel, rest_client_mock): - mock_fetch_channel.return_value = None - - assert await discord_rest_client.fetch_forum_channel(rest_client_mock, 67) is None - - -@patch.object(RESTClientImpl, "fetch_channel", new_callable=AsyncMock) -async def test_fetch_forum_channel_not_forum_channel(mock_fetch_channel, rest_client_mock, post_mock): - mock_fetch_channel.return_value = post_mock - - assert await discord_rest_client.fetch_forum_channel(rest_client_mock, 67) is None - - -def test_get_new_tag_success(): - tag1 = ForumTag(id=Snowflake(1), name="enchantment", moderated=False) - available_tags = [tag1] - - assert discord_rest_client.get_new_tag("enchantment", available_tags) == tag1 - - -def test_get_new_tag_none(): - tag1 = ForumTag(id=Snowflake(1), name="enchantment", moderated=False) - available_tags = [tag1] - - assert discord_rest_client.get_new_tag("build", available_tags) is None - - -def test_generate_signature(): - expected_signature = "sha256=9b40ac77c0653bed6e678ebd8db8b8d96a7c8ea8983b1a77577797d0a43b97c6" - signature = utils.generate_signature("H-letter", b"I freaking love H letter") - - assert signature == expected_signature - - -def test_verify_secret_correct(): - secret = "H-letter" - payload = b"I freaking love H letter" - signature_header = "sha256=9b40ac77c0653bed6e678ebd8db8b8d96a7c8ea8983b1a77577797d0a43b97c6" - - assert utils.verify_secret(secret, payload, signature_header) - - -def test_verify_secret_incorrect(): - secret = "H-letter" - payload = b"malicious" - signature_header = "sha256=9b40ac77c0653bed6e678ebd8db8b8d96a7c8ea8983b1a77577797d0a43b97c6" - - assert not utils.verify_secret(secret, payload, signature_header) diff --git a/src/tests/test_unit/test_utils/__init__.py b/src/tests/test_unit/test_utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/test_unit/test_utils/test_datatypes.py b/src/tests/test_unit/test_utils/test_datatypes.py new file mode 100644 index 0000000..297757e --- /dev/null +++ b/src/tests/test_unit/test_utils/test_datatypes.py @@ -0,0 +1,28 @@ +import pytest + +from src.utils import data_types +from src.utils.data_types import SimpleProjectItemEventType, SingleSelectType + + +def test_action_type_to_event_type(): + assert data_types.SimpleProjectItemEvent.action_type_to_event_type("created") == SimpleProjectItemEventType.CREATED + assert ( + data_types.SimpleProjectItemEvent.action_type_to_event_type("archived") == SimpleProjectItemEventType.ARCHIVED + ) + assert ( + data_types.SimpleProjectItemEvent.action_type_to_event_type("restored") == SimpleProjectItemEventType.RESTORED + ) + assert data_types.SimpleProjectItemEvent.action_type_to_event_type("deleted") == SimpleProjectItemEventType.DELETED + with pytest.raises(ValueError): + data_types.SimpleProjectItemEvent.action_type_to_event_type("unknown") + + +def test_field_name_to_event_type(): + assert data_types.ProjectItemEditedSingleSelect.field_name_to_value_type("Status") == SingleSelectType.STATUS + assert data_types.ProjectItemEditedSingleSelect.field_name_to_value_type("Priority") == SingleSelectType.PRIORITY + assert data_types.ProjectItemEditedSingleSelect.field_name_to_value_type("Size") == SingleSelectType.SIZE + assert data_types.ProjectItemEditedSingleSelect.field_name_to_value_type("Iteration") == SingleSelectType.ITERATION + assert data_types.ProjectItemEditedSingleSelect.field_name_to_value_type("Section") == SingleSelectType.SECTION + + with pytest.raises(ValueError): + data_types.ProjectItemEditedSingleSelect.field_name_to_value_type("Unknown") diff --git a/src/tests/test_unit/test_utils/test_discord_rest_client.py b/src/tests/test_unit/test_utils/test_discord_rest_client.py new file mode 100644 index 0000000..9a72648 --- /dev/null +++ b/src/tests/test_unit/test_utils/test_discord_rest_client.py @@ -0,0 +1,92 @@ +# ruff: noqa: F811 +from unittest.mock import AsyncMock, patch + +from hikari import ForumTag, Snowflake +from hikari.impl import RESTClientImpl + +from src.tests.utils import MockShelf, forum_channel_mock, post_mock, rest_client_mock # noqa: F401 +from src.utils import discord_rest_client + + +@patch.object(RESTClientImpl, "fetch_channel", new_callable=AsyncMock) +async def test_fetch_forum_channel_success(mock_fetch_channel, rest_client_mock, forum_channel_mock): + mock_fetch_channel.return_value = forum_channel_mock + + assert await discord_rest_client.fetch_forum_channel(rest_client_mock, 67) == forum_channel_mock + + +@patch.object(RESTClientImpl, "fetch_channel", new_callable=AsyncMock) +async def test_fetch_forum_channel_none(mock_fetch_channel, rest_client_mock): + mock_fetch_channel.return_value = None + + assert await discord_rest_client.fetch_forum_channel(rest_client_mock, 67) is None + + +@patch.object(RESTClientImpl, "fetch_channel", new_callable=AsyncMock) +async def test_fetch_forum_channel_not_forum_channel(mock_fetch_channel, rest_client_mock, post_mock): + mock_fetch_channel.return_value = post_mock + + assert await discord_rest_client.fetch_forum_channel(rest_client_mock, 67) is None + + +def test_get_new_tag_success(): + tag1 = ForumTag(id=Snowflake(1), name="enchantment", moderated=False) + available_tags = [tag1] + + assert discord_rest_client.get_new_tag("enchantment", available_tags) == tag1 + + +def test_get_new_tag_none(): + tag1 = ForumTag(id=Snowflake(1), name="enchantment", moderated=False) + available_tags = [tag1] + + assert discord_rest_client.get_new_tag("build", available_tags) is None + + +@patch("shelve.open") +async def test_get_post_id_exist_in_db(mock_shelve_open, rest_client_mock): + mock_db = {"audacity4": 621} + mock_shelve_open.return_value = MockShelf(mock_db) + + assert await discord_rest_client.get_post_id("audacity4", 1, 1, rest_client_mock) == 621 + + +@patch.object(RESTClientImpl, "fetch_active_threads", new_callable=AsyncMock) +@patch("shelve.open") +async def test_get_post_id_active_thread(mock_shelve_open, mock_fetch_active_threads, rest_client_mock, post_mock): + mock_shelf = MockShelf({}) + mock_shelve_open.return_value = mock_shelf + mock_fetch_active_threads.return_value = [post_mock] + + assert await discord_rest_client.get_post_id("audacity4", 1, 1, rest_client_mock) == post_mock + assert mock_shelf.get("audacity4") == 621 + + +@patch.object(RESTClientImpl, "fetch_public_archived_threads", new_callable=AsyncMock) +@patch.object(RESTClientImpl, "fetch_active_threads", new_callable=AsyncMock) +@patch("shelve.open") +async def test_get_post_id_archived_thread( + mock_shelve_open, mock_fetch_active_threads, mock_fetch_public_archived_threads, rest_client_mock, post_mock +): + mock_shelf = MockShelf({}) + mock_shelve_open.return_value = mock_shelf + mock_fetch_active_threads.return_value = [] + mock_fetch_public_archived_threads.return_value = [post_mock] + + assert await discord_rest_client.get_post_id("audacity4", 1, 1, rest_client_mock) == post_mock + assert mock_shelf.get("audacity4") == 621 + + +@patch.object(RESTClientImpl, "fetch_public_archived_threads", new_callable=AsyncMock) +@patch.object(RESTClientImpl, "fetch_active_threads", new_callable=AsyncMock) +@patch("shelve.open") +async def test_get_post_id_none( + mock_shelve_open, mock_fetch_active_threads, mock_fetch_public_archived_threads, rest_client_mock, post_mock +): + mock_shelf = MockShelf({}) + mock_shelve_open.return_value = mock_shelf + mock_fetch_active_threads.return_value = [] + mock_fetch_public_archived_threads.return_value = [] + + assert await discord_rest_client.get_post_id("audacity4", 1, 1, rest_client_mock) is None + assert mock_shelf.get("audacity4") is None diff --git a/src/tests/test_unit/test_utils/test_github_api.py b/src/tests/test_unit/test_utils/test_github_api.py new file mode 100644 index 0000000..b54c828 --- /dev/null +++ b/src/tests/test_unit/test_utils/test_github_api.py @@ -0,0 +1,91 @@ +from unittest.mock import patch + +import pytest +from aiohttp import ClientSession +from fastapi import HTTPException + +from src.tests.utils import MockResponse +from src.utils import github_api + + +@patch.object(ClientSession, "post") +async def test_fetch_item_name_success(mock_post_request): + mock_response = {"data": {"node": {"content": {"title": "42"}}}} + mock_post_request.return_value = MockResponse(mock_response) + + assert await github_api.fetch_item_name("") == "42" + + +@patch.object(ClientSession, "post") +async def test_fetch_item_name_partial(mock_post_request): + mock_response = {"data": {"node": {"content": None}}} + mock_post_request.return_value = MockResponse(mock_response) + + with pytest.raises(HTTPException) as exception: + await github_api.fetch_item_name("") + + assert exception.value.status_code == 500 + assert exception.value.detail == "Could not fetch item name." + + +@patch.object(ClientSession, "post") +async def test_fetch_item_name_none(mock_post_request): + mock_post_request.return_value = MockResponse({}) + + with pytest.raises(HTTPException) as exception: + await github_api.fetch_item_name("") + assert exception.value.status_code == 500 + assert exception.value.detail == "Could not fetch item name." + + +@patch.object(ClientSession, "post") +async def test_fetch_assignees_success(mock_post_request): + mock_response = { + "data": { + "node": { + "content": {"assignees": {"nodes": [{"id": "MDQ6VXNlcjg4MjY4MDYz"}, {"id": "MDQ6VXNlcjg5ODM3NzI0"}]}} + } + } + } + mock_post_request.return_value = MockResponse(mock_response) + + assert await github_api.fetch_assignees("") == ["MDQ6VXNlcjg4MjY4MDYz", "MDQ6VXNlcjg5ODM3NzI0"] + + +@patch.object(ClientSession, "post") +async def test_fetch_assignees_partial(mock_post_request): + mock_response = {"data": {"node": {"content": None}}} + mock_post_request.return_value = MockResponse(mock_response) + + assert await github_api.fetch_assignees("") == [] + + +@patch.object(ClientSession, "post") +async def test_fetch_assignees_none(mock_post_request): + mock_post_request.return_value = MockResponse({}) + + assert await github_api.fetch_assignees("") == [] + + +@patch.object(ClientSession, "post") +async def test_fetch_single_select_value_success(mock_post_request): + mock_response = {"data": {"node": {"fieldValueByName": {"name": "Dziengiel"}}}} + mock_post_request.return_value = MockResponse(mock_response) + + assert await github_api.fetch_single_select_value("", "Salieri") == "Dziengiel" + + +@patch.object(ClientSession, "post") +async def test_fetch_single_select_value_partial(mock_post_request): + mock_response = {"data": {"node": {"fieldValueByName": None}}} + mock_post_request.return_value = MockResponse(mock_response) + + assert await github_api.fetch_single_select_value("", "Salieri") is None + + +@patch.object(ClientSession, "post") +async def test_fetch_single_select_value_none(mock_post_request): + mock_response = {} + mock_post_request.return_value = MockResponse(mock_response) + + assert await github_api.fetch_single_select_value("", "Salieri") is None diff --git a/src/tests/test_unit/test_utils/test_misc.py b/src/tests/test_unit/test_utils/test_misc.py new file mode 100644 index 0000000..cb2cf9f --- /dev/null +++ b/src/tests/test_unit/test_utils/test_misc.py @@ -0,0 +1,110 @@ +# ruff: noqa: F811 +import asyncio +import logging +from io import StringIO +from unittest.mock import AsyncMock, mock_open, patch + +from src.tests.utils import MockShelf, forum_channel_mock, shared_forum_channel_mock # noqa: F401 +from src.utils import misc + + +@patch("shelve.open") +async def test_get_item_name_exist_in_db(mock_shelve_open): + mock_db = {"O_kgDOCUX8Wg": "crabcraft"} + mock_shelve_open.return_value = MockShelf(mock_db) + + assert await misc.get_item_name("O_kgDOCUX8Wg") == "crabcraft" + + +@patch("shelve.open") +@patch("src.utils.misc.fetch_item_name", new_callable=AsyncMock) +async def test_get_item_name_doesnt_exist_in_db(mock_fetch_item_name, mock_shelve_open): + mock_shelf = MockShelf({}) + mock_shelve_open.return_value = mock_shelf + mock_fetch_item_name.return_value = "crabcraft" + + assert await misc.get_item_name("O_kgDOCUX8Wg") == "crabcraft" + assert mock_shelf.get("O_kgDOCUX8Wg") == "crabcraft" + + +@patch("builtins.open", new_callable=mock_open, read_data='MDQ6VXNlcjY2NTE0ODg1: "393756120952602625"') +@patch("yaml.load") +def test_retrieve_discord_id_present_id(mock_yaml_load, _mock_open_file): + mock_yaml_load.return_value = {"MDQ6VXNlcjY2NTE0ODg1": "393756120952602625"} + + assert misc.retrieve_discord_id("MDQ6VXNlcjY2NTE0ODg1") == "393756120952602625" + + +@patch("builtins.open", new_callable=mock_open, read_data="") +def test_retrieve_discord_id_absent_id(_mock_open_file): + assert misc.retrieve_discord_id("") is None + + +def test_bot_logger_prefix(): + stream = StringIO() + + logger = misc.get_bot_logger() + logger.setLevel(logging.INFO) + + handler = logging.StreamHandler(stream) + handler.setFormatter(logging.Formatter("%(levelname)s %(message)s")) + logger.handlers.clear() + logger.addHandler(handler) + + logger.info("hello") + + output = stream.getvalue().strip() + assert output == "INFO [BOT] hello" + + +async def test_shared_forum_channel_concurrent_readers(shared_forum_channel_mock): + started = asyncio.Event() + completed = [] + + async def reader(reader_id): + async with shared_forum_channel_mock.lock.reader_lock: + await started.wait() + completed.append(reader_id) + + tasks = [asyncio.create_task(reader(i)) for i in range(5)] + started.set() + await asyncio.gather(*tasks) + + assert sorted(completed) == [0, 1, 2, 3, 4] + + +async def test_shared_forum_channel_writer_blocks_readers(shared_forum_channel_mock): + enter_order = [] + + async def writer(): + async with shared_forum_channel_mock.lock.writer_lock: + enter_order.append("writer") + await asyncio.sleep(0.1) + + async def reader(): + async with shared_forum_channel_mock.lock.reader_lock: + enter_order.append("reader") + + writer_task = asyncio.create_task(writer()) + + reader_task = asyncio.create_task(reader()) + await asyncio.gather(writer_task, reader_task) + + assert enter_order == ["writer", "reader"] + + +async def test_shared_forum_channel_writers_are_exclusive(shared_forum_channel_mock): + execution = [] + + async def writer(writer_id): + async with shared_forum_channel_mock.lock.writer_lock: + execution.append(writer_id) + await asyncio.sleep(0.05) + + t1 = asyncio.create_task(writer(1)) + t2 = asyncio.create_task(writer(2)) + + await asyncio.gather(t1, t2) + + assert execution == [1, 2] or execution == [2, 1] + assert execution[0] != execution[1] diff --git a/src/tests/test_unit/test_utils/test_signature_verification.py b/src/tests/test_unit/test_utils/test_signature_verification.py new file mode 100644 index 0000000..2193ad9 --- /dev/null +++ b/src/tests/test_unit/test_utils/test_signature_verification.py @@ -0,0 +1,79 @@ +# ruff: noqa: F811 +from logging import Logger +from unittest.mock import patch + +import pytest +from fastapi import HTTPException + +from src.tests.utils import logger_mock # noqa: F401 +from src.utils import signature_verification + + +def test_generate_signature(): + expected_signature = "sha256=9b40ac77c0653bed6e678ebd8db8b8d96a7c8ea8983b1a77577797d0a43b97c6" + signature = signature_verification.generate_signature("H-letter", b"I freaking love H letter") + + assert signature == expected_signature + + +def test_verify_secret_correct(): + secret = "H-letter" + payload = b"I freaking love H letter" + signature_header = "sha256=9b40ac77c0653bed6e678ebd8db8b8d96a7c8ea8983b1a77577797d0a43b97c6" + + assert signature_verification.verify_secret(secret, payload, signature_header) + + +def test_verify_secret_incorrect(): + secret = "H-letter" + payload = b"malicious" + signature_header = "sha256=9b40ac77c0653bed6e678ebd8db8b8d96a7c8ea8983b1a77577797d0a43b97c6" + + assert not signature_verification.verify_secret(secret, payload, signature_header) + + +@patch.object(Logger, "warning") +@patch("os.getenv") +def test_verify_signature_correct(mock_getenv, mock_logger_warning, logger_mock): + signature = "sha256=9b40ac77c0653bed6e678ebd8db8b8d96a7c8ea8983b1a77577797d0a43b97c6" + body_bytes = b"I freaking love H letter" + mock_getenv.return_value = "H-letter" + + signature_verification.verify_signature(signature, body_bytes, logger_mock) + mock_logger_warning.assert_not_called() + + +@patch("os.getenv") +def test_verify_signature_incorrect(mock_getenv, logger_mock): + signature = "sha256=9b40ac77c0653bed6e678ebd8db8b8d96a7c8ea8983b1a77577797d0a43b97c6" + body_bytes = b"I freaking love H letter" + mock_getenv.return_value = "K-letter" + + with pytest.raises(HTTPException) as error: + signature_verification.verify_signature(signature, body_bytes, logger_mock) + + assert error.value.status_code == 401 + assert error.value.detail == "Invalid signature." + + +@patch("os.getenv") +def test_verify_signature_missing(mock_getenv, logger_mock): + body_bytes = b"I freaking love H letter" + mock_getenv.return_value = "K-letter" + + with pytest.raises(HTTPException) as error: + signature_verification.verify_signature("", body_bytes, logger_mock) + + assert error.value.status_code == 401 + assert error.value.detail == "Missing signature." + + +@patch.object(Logger, "warning") +@patch("os.getenv") +def test_verify_signature_not_set(mock_getenv, mock_logger_warning, logger_mock): + signature = "sha256=9b40ac77c0653bed6e678ebd8db8b8d96a7c8ea8983b1a77577797d0a43b97c6" + body_bytes = b"I freaking love H letter" + mock_getenv.return_value = "" + + signature_verification.verify_signature(signature, body_bytes, logger_mock) + mock_logger_warning.assert_called_with("GITHUB_WEBHOOK_SECRET is not set; skipping signature verification.") diff --git a/src/tests/utils.py b/src/tests/utils.py new file mode 100644 index 0000000..23d4a6f --- /dev/null +++ b/src/tests/utils.py @@ -0,0 +1,147 @@ +import datetime +import logging + +import pytest +from hikari import ( + ChannelFlag, + ForumLayoutType, + ForumSortOrderType, + ForumTag, + GuildForumChannel, + GuildPublicThread, + PartialChannel, + RESTAware, + Snowflake, + ThreadMetadata, +) +from hikari.impl import EntityFactoryImpl, HTTPSettings, ProxySettings, RESTClientImpl + +from src.utils.misc import SharedForumChannel + + +class MockShelf(dict): + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + pass + + +class MockResponse(dict): + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + pass + + async def json(self): + return self + + +class RestClientContextManagerMock: + rest_client_mock: RESTClientImpl + + def __init__(self, rest_client_mock): + self.rest_client_mock = rest_client_mock + + async def __aenter__(self): + return self.rest_client_mock + + async def __aexit__(self, exc_type, exc_val, exc_tb): + pass + + +@pytest.fixture +def post_mock(): + return PartialChannel(app=RESTAware, id=Snowflake(621), name="audacity4", type=0) + + +@pytest.fixture +def rest_client_mock(): + entity_factory = EntityFactoryImpl(app=RESTAware) + http_settings = HTTPSettings() + proxy_settings = ProxySettings() + return RESTClientImpl( + cache=None, + entity_factory=entity_factory, + executor=None, + http_settings=http_settings, + proxy_settings=proxy_settings, + token=None, + token_type=None, + rest_url=None, + ) + + +@pytest.fixture +def forum_channel_mock(): + mock_timedelta = datetime.timedelta(seconds=0) + return GuildForumChannel( + app=RESTAware, + id=Snowflake(67), + name="forum-channel", + topic="A forum channel", + is_nsfw=False, + default_auto_archive_duration=mock_timedelta, + available_tags=[ForumTag(id=Snowflake(1), name="Size: smol", moderated=False)], + type=0, + guild_id=Snowflake(41), + parent_id=None, + position=0, + permission_overwrites={}, + last_thread_id=None, + rate_limit_per_user=mock_timedelta, + default_thread_rate_limit_per_user=mock_timedelta, + flags=ChannelFlag(0), + default_sort_order=ForumSortOrderType.CREATION_DATE, + default_layout=ForumLayoutType.GALLERY_VIEW, + default_reaction_emoji_id=None, + default_reaction_emoji_name=None, + ) + + +@pytest.fixture +def logger_mock(): + return logging.getLogger("uvicorn.error") + + +@pytest.fixture +def shared_forum_channel_mock(forum_channel_mock): + return SharedForumChannel(forum_channel_mock) + + +@pytest.fixture +def full_post_mock(): + mock_timedelta = datetime.timedelta(seconds=0) + mock_datetime = datetime.datetime.now() + mock_metadata = ThreadMetadata( + is_archived=False, + archive_timestamp=mock_datetime, + auto_archive_duration=mock_timedelta, + is_invitable=False, + is_locked=False, + created_at=mock_datetime, + ) + return GuildPublicThread( + app=RESTAware, + id=Snowflake(621), + name="audacity4", + type=0, + guild_id=Snowflake(0), + last_message_id=None, + last_pin_timestamp=None, + rate_limit_per_user=mock_timedelta, + approximate_message_count=0, + approximate_member_count=0, + member=None, + owner_id=Snowflake(0), + parent_id=Snowflake(0), + metadata=mock_metadata, + applied_tag_ids=[Snowflake(1)], + flags=ChannelFlag(0), + ) + + +@pytest.fixture +def user_text_mention(): + return "<@123456789012345678>" From db280c5717111e8390673e4b7def79daf39dbd1c Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Mon, 24 Nov 2025 08:39:55 +0100 Subject: [PATCH 33/44] chore: Reformat blank lines --- src/server.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/server.py b/src/server.py index 5372b13..f5437c5 100644 --- a/src/server.py +++ b/src/server.py @@ -62,12 +62,10 @@ async def webhook_endpoint(request: Request) -> JSONResponse: verify_signature(signature, body_bytes, app.logger) body = WebhookRequest.model_validate_json(body_bytes) - if body.projects_v2_item.project_node_id != os.getenv("GITHUB_PROJECT_NODE_ID"): raise HTTPException(status_code=400, detail="Invalid project_node_id.") item_name = await get_item_name(body.projects_v2_item.node_id) - project_item_event = await process_action(body, item_name) await app.update_queue.put(project_item_event) From 93913cd1ebc93ab6c5a9129799bcfd5a49f5515e Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Mon, 24 Nov 2025 12:20:55 +0100 Subject: [PATCH 34/44] refactor: Map Github item node_id to Discord post id --- .env.example | 3 +- .gitignore | 3 +- src/bot.py | 14 ++++---- src/server.py | 25 +++++++------ src/tests/test_e2e.py | 7 ++-- src/tests/test_integration/test_bot.py | 8 ++++- src/tests/test_unit/test_bot.py | 7 ++-- src/tests/test_unit/test_server.py | 33 ++++++++--------- .../test_utils/test_discord_rest_client.py | 34 +++++++++++++----- src/tests/test_unit/test_utils/test_misc.py | 21 +---------- src/utils/data_types.py | 36 +++++++++---------- src/utils/discord_rest_client.py | 7 ++-- src/utils/misc.py | 14 -------- 13 files changed, 103 insertions(+), 109 deletions(-) diff --git a/.env.example b/.env.example index 474d326..ed90fcd 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,5 @@ GITHUB_ID_TO_DISCORD_ID_MAPPING_PATH=path-to-github-username-to-discord-id-mappi IP_ADDRESS=0.0.0.0 PORT=8000 GITHUB_WEBHOOK_SECRET=your-github-webhook-secret -# Shelve databases -ITEM_NAME_TO_NODE_ID_DB_PATH=path-to-item-name-to-node-id.db +# Shelve database POST_ID_DB_PATH=path-to-post-id.db \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3ea6a6a..1b9fe59 100644 --- a/.gitignore +++ b/.gitignore @@ -208,6 +208,5 @@ __marimo__/ .idea/ -# Shelves databases -item_name_to_node_id.db +# Shelves database post_id.db \ No newline at end of file diff --git a/src/bot.py b/src/bot.py index fb08d7d..aa1f6ce 100644 --- a/src/bot.py +++ b/src/bot.py @@ -8,6 +8,7 @@ from src.utils.data_types import ProjectItemEvent from src.utils.discord_rest_client import fetch_forum_channel, get_post_id from src.utils.error import ForumChannelNotFound +from src.utils.github_api import fetch_item_name from src.utils.misc import SharedForumChannel, get_bot_logger, retrieve_discord_id @@ -43,9 +44,9 @@ async def process_update( logger: logging.Logger, ): event = await state.get() - logger.info(f"Processing event for item: {event.name}") + logger.info(f"Processing event for item: {event.node_id}") - post_id_or_post = await get_post_id(event.name, discord_guild_id, forum_channel_id, client) + post_id_or_post = await get_post_id(event.node_id, discord_guild_id, forum_channel_id, client) author_discord_id = retrieve_discord_id(event.sender) user_mentions = [author_discord_id] if author_discord_id else [] user_text_mention = f"<@{author_discord_id}>" if author_discord_id else "nieznany użytkownik" @@ -61,7 +62,7 @@ async def process_update( try: logger.error(f"Post with ID {post.id} is not a GuildPublicThread.") except AttributeError: - logger.error(f"Post with name {event.name} is not a GuildPublicThread.") + logger.error(f"Post with node_id {event.node_id} is not a GuildPublicThread.") return message = await event.process(user_text_mention, post, client, logger, shared_forum_channel, forum_channel_id) @@ -77,12 +78,13 @@ async def create_post( client: RESTClientImpl, user_mentions: list[str], ) -> GuildPublicThread: - logger.info(f"Post not found, creating new post for item: {event.name}") - message = f"Nowy task stworzony {event.name} przez: {user_text_mention}" + logger.info(f"Post not found, creating new post for item: {event.node_id}") + item_name = await fetch_item_name(event.node_id) + message = f"Nowy task stworzony {item_name} przez: {user_text_mention}" async with shared_forum_channel.lock.reader_lock: return await client.create_forum_post( shared_forum_channel.forum_channel, - event.name, + item_name, message, auto_archive_duration=10080, user_mentions=user_mentions, diff --git a/src/server.py b/src/server.py index f5437c5..245de47 100644 --- a/src/server.py +++ b/src/server.py @@ -16,7 +16,6 @@ WebhookRequest, ) from src.utils.github_api import fetch_assignees, fetch_item_name, fetch_single_select_value -from src.utils.misc import get_item_name from src.utils.signature_verification import verify_signature app = FastAPI(lifespan=lifespan) @@ -65,32 +64,32 @@ async def webhook_endpoint(request: Request) -> JSONResponse: if body.projects_v2_item.project_node_id != os.getenv("GITHUB_PROJECT_NODE_ID"): raise HTTPException(status_code=400, detail="Invalid project_node_id.") - item_name = await get_item_name(body.projects_v2_item.node_id) - project_item_event = await process_action(body, item_name) + project_item_event = await process_action(body) await app.update_queue.put(project_item_event) - app.logger.info(f"Received webhook event for item: {item_name}") + app.logger.info(f"Received webhook event for item: {body.projects_v2_item.node_id}") return JSONResponse(content={"detail": "Successfully received webhook data"}) -async def process_action(body: WebhookRequest, item_name: str) -> ProjectItemEvent: +async def process_action(body: WebhookRequest) -> ProjectItemEvent: if body.action == "edited": - return await process_edition(body, item_name) + return await process_edition(body) else: try: - return SimpleProjectItemEvent(item_name, body.sender.node_id, body.action) + return SimpleProjectItemEvent(body.projects_v2_item.node_id, body.sender.node_id, body.action) except ValueError as error: raise HTTPException(status_code=400, detail="Unsupported action.") from error async def process_edition( - body: WebhookRequest, item_name: str + body: WebhookRequest, ) -> ProjectItemEditedBody | ProjectItemEditedTitle | ProjectItemEditedAssignees | ProjectItemEditedSingleSelect: editor = body.sender.node_id body_changed = body.changes.body + item_node_id = body.projects_v2_item.node_id if body_changed is not None: - project_item_edited = ProjectItemEditedBody(item_name, editor, body_changed.to) + project_item_edited = ProjectItemEditedBody(item_node_id, editor, body_changed.to) return project_item_edited field_changed = body.changes.field_value @@ -101,11 +100,11 @@ async def process_edition( match field_changed.field_type: case "assignees": new_assignees = await fetch_assignees(body.projects_v2_item.node_id) - project_item_edited = ProjectItemEditedAssignees(item_name, editor, new_assignees) + project_item_edited = ProjectItemEditedAssignees(item_node_id, editor, new_assignees) return project_item_edited case "title": new_title = await fetch_item_name(body.projects_v2_item.node_id) - project_item_edited = ProjectItemEditedTitle(item_name, editor, new_title) + project_item_edited = ProjectItemEditedTitle(item_node_id, editor, new_title) return project_item_edited case "single_select": new_value = field_changed.to.name @@ -113,11 +112,11 @@ async def process_edition( if new_value is None: new_value = await fetch_single_select_value(body.projects_v2_item.node_id, field_name) try: - project_item_edited = ProjectItemEditedSingleSelect(item_name, editor, new_value, field_name) + project_item_edited = ProjectItemEditedSingleSelect(item_node_id, editor, new_value, field_name) except ValueError as error: raise HTTPException(status_code=400, detail="Unsupported single select field.") from error return project_item_edited case "iteration": new_value = field_changed.to.title - project_item_edited = ProjectItemEditedSingleSelect(item_name, editor, new_value, "Iteration") + project_item_edited = ProjectItemEditedSingleSelect(item_node_id, editor, new_value, "Iteration") return project_item_edited diff --git a/src/tests/test_e2e.py b/src/tests/test_e2e.py index d3a0e31..c7f9a03 100644 --- a/src/tests/test_e2e.py +++ b/src/tests/test_e2e.py @@ -16,6 +16,7 @@ from src.utils.signature_verification import generate_signature +@patch("src.utils.discord_rest_client.fetch_item_name", new_callable=AsyncMock) @patch.object(Logger, "info") @patch.object(RESTClientImpl, "create_message", new_callable=AsyncMock) @patch("builtins.open", new_callable=mock_open, read_data="") @@ -35,6 +36,7 @@ async def test_e2e( _mock_open, mock_create_message, mock_logger, + mock_fetch_item_name, rest_client_mock, forum_channel_mock, full_post_mock, @@ -52,8 +54,9 @@ async def test_e2e( "db-path.db", ] post_id_shelf = MockShelf({}) - mock_shelve_open.side_effect = [MockShelf({"item123": "audacity4"}), post_id_shelf] + mock_shelve_open.return_value = post_id_shelf mock_fetch_active_threads.return_value = [full_post_mock] + mock_fetch_item_name.return_value = "audacity4" config = Config(app=app, host="127.0.0.1", port=8000, log_level="critical") server = Server(config=config) @@ -85,7 +88,7 @@ async def test_e2e( for _ in range(500): # up to ~5 seconds total try: - mock_logger.assert_any_call("Post audacity4 body updated.") + mock_logger.assert_any_call("Post item123 body updated.") break except AssertionError: pass diff --git a/src/tests/test_integration/test_bot.py b/src/tests/test_integration/test_bot.py index b516c45..f271f6e 100644 --- a/src/tests/test_integration/test_bot.py +++ b/src/tests/test_integration/test_bot.py @@ -11,6 +11,8 @@ from src.utils.data_types import ProjectItemEvent +@patch("src.bot.fetch_item_name", new_callable=AsyncMock) +@patch("src.utils.discord_rest_client.fetch_item_name", new_callable=AsyncMock) @patch("builtins.open", new_callable=mock_open, read_data="") @patch("shelve.open") @patch.object(RESTClientImpl, "create_forum_post", new_callable=AsyncMock) @@ -30,6 +32,8 @@ async def test_basic_event_only_creation( mock_create_forum_post, mock_shelve_open, _mock_open, + mock_fetch_item_name, + mock_fetch_item_name2, rest_client_mock, forum_channel_mock, logger_mock, @@ -42,7 +46,9 @@ async def test_basic_event_only_creation( mock_create_forum_post.return_value = None mock_shelve_open.return_value = MockShelf({}) mock_create_forum_post.return_value = "created_forum_post" + mock_fetch_item_name.return_value = "audacity4" + mock_fetch_item_name2.return_value = "audacity4" update_queue = asyncio.Queue() - await update_queue.put(ProjectItemEvent(name="Test Item", sender="test_sender")) + await update_queue.put(ProjectItemEvent(node_id="node_id", sender="test_sender")) await run(update_queue, stop_after_one_event=True) mock_create_forum_post.assert_called() diff --git a/src/tests/test_unit/test_bot.py b/src/tests/test_unit/test_bot.py index ffff409..c44db8f 100644 --- a/src/tests/test_unit/test_bot.py +++ b/src/tests/test_unit/test_bot.py @@ -24,19 +24,22 @@ from src.utils.error import ForumChannelNotFound +@patch("src.bot.fetch_item_name", new_callable=AsyncMock) @patch.object(RESTClientImpl, "create_forum_post", new_callable=AsyncMock) async def test_create_post( mock_create_forum_post, + mock_fetch_item_name, logger_mock, rest_client_mock, shared_forum_channel_mock, user_text_mention, ): + mock_fetch_item_name.return_value = "audacity4" message = f"Nowy task stworzony audacity4 przez: {user_text_mention}" event = SimpleProjectItemEvent("audacity4", "norbiros", "created") await bot.create_post(logger_mock, event, user_text_mention, shared_forum_channel_mock, rest_client_mock, []) mock_create_forum_post.assert_called_with( - shared_forum_channel_mock.forum_channel, event.name, message, auto_archive_duration=10080, user_mentions=[] + shared_forum_channel_mock.forum_channel, event.node_id, message, auto_archive_duration=10080, user_mentions=[] ) @@ -57,7 +60,7 @@ async def test_process_no_post( mock_retrieve_discord_id.return_value = "123456789012345678" mock_create_post.return_value = full_post_mock state = asyncio.Queue() - event = SimpleProjectItemEvent("audacity4", "norbiros", "created") + event = SimpleProjectItemEvent("node_id", "norbiros", "created") await state.put(event) await bot.process_update( rest_client_mock, diff --git a/src/tests/test_unit/test_server.py b/src/tests/test_unit/test_server.py index e1421a5..ae830e3 100644 --- a/src/tests/test_unit/test_server.py +++ b/src/tests/test_unit/test_server.py @@ -30,32 +30,29 @@ def mock_webhook_request_model(): async def test_process_edition_body_changes(mock_webhook_request_model): mock_webhook_request_model.changes = Changes(body=Body(to="We need to pet more cats")) - item_name = "PetSomeCats" - expected_object = ProjectItemEditedBody(item_name, "node_id", "We need to pet more cats") + expected_object = ProjectItemEditedBody("node_id", "node_id", "We need to pet more cats") - assert await process_edition(mock_webhook_request_model, item_name) == expected_object + assert await process_edition(mock_webhook_request_model) == expected_object @patch("src.server.fetch_assignees") async def test_process_edition_assignees_changed(mock_fetch_assignees, mock_webhook_request_model): mock_webhook_request_model.changes = Changes(field_value=FieldValue(field_name="Assignees", field_type="assignees")) - item_name = "YouKnowIntegrationTestsAreNextDontYou?" new_assignees = ["Kubaryt", "Salieri", "Aniela"] mock_fetch_assignees.return_value = new_assignees - expected_object = ProjectItemEditedAssignees(item_name, "node_id", new_assignees) + expected_object = ProjectItemEditedAssignees("node_id", "node_id", new_assignees) - assert await process_edition(mock_webhook_request_model, item_name) == expected_object + assert await process_edition(mock_webhook_request_model) == expected_object @patch("src.server.fetch_item_name") async def test_process_edition_title_changed(mock_fetch_item_name, mock_webhook_request_model): mock_webhook_request_model.changes = Changes(field_value=FieldValue(field_name="Title", field_type="title")) - item_name = "ImagineMockingDiscordSoMuchFun" new_item_name = "ActuallyNotFunAtAll" mock_fetch_item_name.return_value = new_item_name - expected_object = ProjectItemEditedTitle(item_name, "node_id", new_item_name) + expected_object = ProjectItemEditedTitle("node_id", "node_id", new_item_name) - assert await process_edition(mock_webhook_request_model, item_name) == expected_object + assert await process_edition(mock_webhook_request_model) == expected_object async def test_process_edition_single_select_changed(mock_webhook_request_model): @@ -64,10 +61,9 @@ async def test_process_edition_single_select_changed(mock_webhook_request_model) field_name="Size", field_type="single_select", to=FieldValueTo(name="Smol like lil kitten") ) ) - item_name = "Lil puppy" - expected_object = ProjectItemEditedSingleSelect(item_name, "node_id", "Smol like lil kitten", "Size") + expected_object = ProjectItemEditedSingleSelect("node_id", "node_id", "Smol like lil kitten", "Size") - assert await process_edition(mock_webhook_request_model, item_name) == expected_object + assert await process_edition(mock_webhook_request_model) == expected_object async def test_process_edition_iteration_changed(mock_webhook_request_model): @@ -75,24 +71,23 @@ async def test_process_edition_iteration_changed(mock_webhook_request_model): mock_webhook_request_model.changes = Changes( field_value=FieldValue(field_name="Iteration", field_type="iteration", to=FieldValueTo(title=new_title)) ) - item_name = "Create Dockerfile for production" - expected_object = ProjectItemEditedSingleSelect(item_name, "node_id", new_title, "Iteration") + expected_object = ProjectItemEditedSingleSelect("node_id", "node_id", new_title, "Iteration") - assert await process_edition(mock_webhook_request_model, item_name) == expected_object + assert await process_edition(mock_webhook_request_model) == expected_object @patch("src.server.process_edition", new_callable=AsyncMock) async def test_process_action_process_edition(mock_process_edition, mock_webhook_request_model): - test_event = SimpleProjectItemEvent("Test Item", "node_id", "created") + test_event = SimpleProjectItemEvent("node_id", "node_id", "created") mock_process_edition.return_value = test_event - assert await process_action(mock_webhook_request_model, "Test Item") == test_event + assert await process_action(mock_webhook_request_model) == test_event @patch("src.server.process_edition", new_callable=AsyncMock) async def test_process_action_simple_event(mock_process_edition, mock_webhook_request_model): mock_webhook_request_model.action = "created" - test_event = SimpleProjectItemEvent("Test Item", "node_id", "created") + test_event = SimpleProjectItemEvent("node_id", "node_id", "created") mock_process_edition.return_value = test_event - assert await process_action(mock_webhook_request_model, "Test Item") == test_event + assert await process_action(mock_webhook_request_model) == test_event diff --git a/src/tests/test_unit/test_utils/test_discord_rest_client.py b/src/tests/test_unit/test_utils/test_discord_rest_client.py index 9a72648..1cdd6e3 100644 --- a/src/tests/test_unit/test_utils/test_discord_rest_client.py +++ b/src/tests/test_unit/test_utils/test_discord_rest_client.py @@ -45,48 +45,66 @@ def test_get_new_tag_none(): @patch("shelve.open") async def test_get_post_id_exist_in_db(mock_shelve_open, rest_client_mock): - mock_db = {"audacity4": 621} + mock_db = {"node_id": 621} mock_shelve_open.return_value = MockShelf(mock_db) - assert await discord_rest_client.get_post_id("audacity4", 1, 1, rest_client_mock) == 621 + assert await discord_rest_client.get_post_id("node_id", 1, 1, rest_client_mock) == 621 +@patch("src.utils.discord_rest_client.fetch_item_name", new_callable=AsyncMock) @patch.object(RESTClientImpl, "fetch_active_threads", new_callable=AsyncMock) @patch("shelve.open") -async def test_get_post_id_active_thread(mock_shelve_open, mock_fetch_active_threads, rest_client_mock, post_mock): +async def test_get_post_id_active_thread( + mock_shelve_open, mock_fetch_active_threads, mock_fetch_item_name, rest_client_mock, post_mock +): mock_shelf = MockShelf({}) mock_shelve_open.return_value = mock_shelf mock_fetch_active_threads.return_value = [post_mock] + mock_fetch_item_name.return_value = "audacity4" - assert await discord_rest_client.get_post_id("audacity4", 1, 1, rest_client_mock) == post_mock + assert await discord_rest_client.get_post_id("node_id", 1, 1, rest_client_mock) == post_mock assert mock_shelf.get("audacity4") == 621 +@patch("src.utils.discord_rest_client.fetch_item_name", new_callable=AsyncMock) @patch.object(RESTClientImpl, "fetch_public_archived_threads", new_callable=AsyncMock) @patch.object(RESTClientImpl, "fetch_active_threads", new_callable=AsyncMock) @patch("shelve.open") async def test_get_post_id_archived_thread( - mock_shelve_open, mock_fetch_active_threads, mock_fetch_public_archived_threads, rest_client_mock, post_mock + mock_shelve_open, + mock_fetch_active_threads, + mock_fetch_public_archived_threads, + mock_fetch_item_name, + rest_client_mock, + post_mock, ): mock_shelf = MockShelf({}) mock_shelve_open.return_value = mock_shelf mock_fetch_active_threads.return_value = [] mock_fetch_public_archived_threads.return_value = [post_mock] + mock_fetch_item_name.return_value = "audacity4" - assert await discord_rest_client.get_post_id("audacity4", 1, 1, rest_client_mock) == post_mock + assert await discord_rest_client.get_post_id("node_id", 1, 1, rest_client_mock) == post_mock assert mock_shelf.get("audacity4") == 621 +@patch("src.utils.discord_rest_client.fetch_item_name", new_callable=AsyncMock) @patch.object(RESTClientImpl, "fetch_public_archived_threads", new_callable=AsyncMock) @patch.object(RESTClientImpl, "fetch_active_threads", new_callable=AsyncMock) @patch("shelve.open") async def test_get_post_id_none( - mock_shelve_open, mock_fetch_active_threads, mock_fetch_public_archived_threads, rest_client_mock, post_mock + mock_shelve_open, + mock_fetch_active_threads, + mock_fetch_public_archived_threads, + mock_fetch_item_name, + rest_client_mock, + post_mock, ): mock_shelf = MockShelf({}) mock_shelve_open.return_value = mock_shelf mock_fetch_active_threads.return_value = [] mock_fetch_public_archived_threads.return_value = [] + mock_fetch_item_name.return_value = "audacity4" - assert await discord_rest_client.get_post_id("audacity4", 1, 1, rest_client_mock) is None + assert await discord_rest_client.get_post_id("node_id", 1, 1, rest_client_mock) is None assert mock_shelf.get("audacity4") is None diff --git a/src/tests/test_unit/test_utils/test_misc.py b/src/tests/test_unit/test_utils/test_misc.py index cb2cf9f..cfa8de2 100644 --- a/src/tests/test_unit/test_utils/test_misc.py +++ b/src/tests/test_unit/test_utils/test_misc.py @@ -2,31 +2,12 @@ import asyncio import logging from io import StringIO -from unittest.mock import AsyncMock, mock_open, patch +from unittest.mock import mock_open, patch from src.tests.utils import MockShelf, forum_channel_mock, shared_forum_channel_mock # noqa: F401 from src.utils import misc -@patch("shelve.open") -async def test_get_item_name_exist_in_db(mock_shelve_open): - mock_db = {"O_kgDOCUX8Wg": "crabcraft"} - mock_shelve_open.return_value = MockShelf(mock_db) - - assert await misc.get_item_name("O_kgDOCUX8Wg") == "crabcraft" - - -@patch("shelve.open") -@patch("src.utils.misc.fetch_item_name", new_callable=AsyncMock) -async def test_get_item_name_doesnt_exist_in_db(mock_fetch_item_name, mock_shelve_open): - mock_shelf = MockShelf({}) - mock_shelve_open.return_value = mock_shelf - mock_fetch_item_name.return_value = "crabcraft" - - assert await misc.get_item_name("O_kgDOCUX8Wg") == "crabcraft" - assert mock_shelf.get("O_kgDOCUX8Wg") == "crabcraft" - - @patch("builtins.open", new_callable=mock_open, read_data='MDQ6VXNlcjY2NTE0ODg1: "393756120952602625"') @patch("yaml.load") def test_retrieve_discord_id_present_id(mock_yaml_load, _mock_open_file): diff --git a/src/utils/data_types.py b/src/utils/data_types.py index 0f09df5..e4b627e 100644 --- a/src/utils/data_types.py +++ b/src/utils/data_types.py @@ -30,7 +30,7 @@ class SingleSelectType(Enum): @dataclass class ProjectItemEvent: - name: str + node_id: str sender: str async def process( @@ -48,8 +48,8 @@ async def process( class SimpleProjectItemEvent(ProjectItemEvent): - def __init__(self, name: str, sender: str, action_type: str): - super().__init__(name, sender) + def __init__(self, node_id: str, sender: str, action_type: str): + super().__init__(node_id, sender) self.event_type = self.action_type_to_event_type(action_type) @staticmethod @@ -80,24 +80,24 @@ async def process( case "archived": message = f"Task zarchiwizowany przez: {user_text_mention}." await client.edit_channel(post.id, archived=True) - logger.info(f"Post {self.name} archived.") + logger.info(f"Post {self.node_id} archived.") return message case "restored": message = f"Task przywrócony przez: {user_text_mention}." await client.edit_channel(post.id, archived=False) - logger.info(f"Post {self.name} restored.") + logger.info(f"Post {self.node_id} restored.") return message case "deleted": await client.delete_channel(post.id) - logger.info(f"Post {self.name} deleted.") + logger.info(f"Post {self.node_id} deleted.") return None case _: return None class ProjectItemEditedBody(ProjectItemEvent): - def __init__(self, name: str, editor: str, new_body: str): - super().__init__(name, editor) + def __init__(self, node_id: str, editor: str, new_body: str): + super().__init__(node_id, editor) self.new_body = new_body async def process( @@ -110,14 +110,14 @@ async def process( forum_channel_id: int, ) -> str: message = f"Opis taska zaktualizowany przez: {user_text_mention}. Nowy opis: \n{self.new_body}" - logger.info(f"Post {self.name} body updated.") + logger.info(f"Post {self.node_id} body updated.") return message class ProjectItemEditedAssignees(ProjectItemEvent): - def __init__(self, name: str, editor: str, new_assignees: list[str]): - super().__init__(name, editor) + def __init__(self, node_id: str, editor: str, new_assignees: list[str]): + super().__init__(node_id, editor) self.new_assignees = new_assignees async def process( @@ -142,12 +142,12 @@ async def process( message = f"Osoby przypisane do taska edytowane, aktualni przypisani: {', '.join(assignee_mentions)}" await client.create_message(post.id, message, user_mentions=assignee_discord_ids) - logger.info(f"Post {self.name} assignees updated.") + logger.info(f"Post {self.node_id} assignees updated.") class ProjectItemEditedTitle(ProjectItemEvent): - def __init__(self, name: str, editor: str, new_name: str): - super().__init__(name, editor) + def __init__(self, node_id: str, editor: str, new_name: str): + super().__init__(node_id, editor) self.new_title = new_name async def process( @@ -160,12 +160,12 @@ async def process( forum_channel_id: int, ) -> None: await client.edit_channel(post.id, name=self.new_title) - logger.info(f"Post {self.name} title updated to {self.new_title}.") + logger.info(f"Post {self.node_id} title updated to {self.new_title}.") class ProjectItemEditedSingleSelect(ProjectItemEvent): - def __init__(self, name: str, editor: str, new_value: str, field_name: str): - super().__init__(name, editor) + def __init__(self, node_id: str, editor: str, new_value: str, field_name: str): + super().__init__(node_id, editor) self.new_value = new_value self.value_type = self.field_name_to_value_type(field_name) @@ -221,7 +221,7 @@ async def process( current_tag_ids.append(new_tag.id) await client.edit_channel(post.id, applied_tags=current_tag_ids) - logger.info(f"Post {self.name} tag updated to {new_tag_name}.") + logger.info(f"Post {self.node_id} tag updated to {new_tag_name}.") class ProjectV2Item(BaseModel): diff --git a/src/utils/discord_rest_client.py b/src/utils/discord_rest_client.py index 151399a..f01f41b 100644 --- a/src/utils/discord_rest_client.py +++ b/src/utils/discord_rest_client.py @@ -4,6 +4,8 @@ from hikari import ForumTag, GuildForumChannel, GuildThreadChannel from hikari.impl import RESTClientImpl +from src.utils.github_api import fetch_item_name + async def fetch_forum_channel(client: RESTClientImpl, forum_channel_id: int) -> GuildForumChannel | None: forum_channel = await client.fetch_channel(forum_channel_id) @@ -18,14 +20,15 @@ def get_new_tag(new_tag_name: str, available_tags: list[ForumTag]) -> ForumTag | async def get_post_id( - name: str, discord_guild_id: int, forum_channel_id: int, rest_client: RESTClientImpl + node_id: str, discord_guild_id: int, forum_channel_id: int, rest_client: RESTClientImpl ) -> int | GuildThreadChannel | None: with shelve.open(os.getenv("POST_ID_DB_PATH", "post_id.db")) as db: try: - post_id: str = db[name] + post_id: str = db[node_id] return int(post_id) except KeyError: pass + name = await fetch_item_name(node_id) for thread in await rest_client.fetch_active_threads(discord_guild_id): if thread.name == name: db[name] = thread.id diff --git a/src/utils/misc.py b/src/utils/misc.py index 8644503..bd0cce2 100644 --- a/src/utils/misc.py +++ b/src/utils/misc.py @@ -1,13 +1,10 @@ import logging import os -import shelve import yaml from aiorwlock import RWLock from hikari import GuildForumChannel -from src.utils.github_api import fetch_item_name - class SharedForumChannel: forum_channel: GuildForumChannel @@ -18,17 +15,6 @@ def __init__(self, forum_channel: GuildForumChannel): self.lock = RWLock() -async def get_item_name(item_node_id: str) -> str | None: - with shelve.open(os.getenv("ITEM_NAME_TO_NODE_ID_DB_PATH", "item_name_to_node_id.db")) as db: - try: - item_name: str = db[item_node_id] - except KeyError: - item_name = await fetch_item_name(item_node_id) - db[item_node_id] = item_name - - return item_name - - def retrieve_discord_id(node_id: str) -> str | None: with open(os.getenv("GITHUB_ID_TO_DISCORD_ID_MAPPING_PATH", "github_id_to_discord_id_mapping.yaml")) as file: mapping: dict[str, str] = yaml.load("".join(file.readlines()), Loader=yaml.Loader) From 1e75903dc0d932fa1a8df03e4105283d8d2db153 Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Mon, 24 Nov 2025 14:41:19 +0100 Subject: [PATCH 35/44] chore: Remove unused import --- src/tests/test_unit/test_utils/test_misc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tests/test_unit/test_utils/test_misc.py b/src/tests/test_unit/test_utils/test_misc.py index cfa8de2..0b0d4f2 100644 --- a/src/tests/test_unit/test_utils/test_misc.py +++ b/src/tests/test_unit/test_utils/test_misc.py @@ -4,7 +4,7 @@ from io import StringIO from unittest.mock import mock_open, patch -from src.tests.utils import MockShelf, forum_channel_mock, shared_forum_channel_mock # noqa: F401 +from src.tests.utils import forum_channel_mock, shared_forum_channel_mock # noqa: F401 from src.utils import misc From bd21e9bf8024fc0ac1cf9c1c35af487f33e508bc Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Mon, 24 Nov 2025 14:45:42 +0100 Subject: [PATCH 36/44] refactor: Proper enum instance creation --- .../test_unit/test_utils/test_datatypes.py | 27 ++++++-------- src/utils/data_types.py | 36 ++----------------- 2 files changed, 13 insertions(+), 50 deletions(-) diff --git a/src/tests/test_unit/test_utils/test_datatypes.py b/src/tests/test_unit/test_utils/test_datatypes.py index 297757e..931f238 100644 --- a/src/tests/test_unit/test_utils/test_datatypes.py +++ b/src/tests/test_unit/test_utils/test_datatypes.py @@ -1,28 +1,23 @@ import pytest -from src.utils import data_types from src.utils.data_types import SimpleProjectItemEventType, SingleSelectType def test_action_type_to_event_type(): - assert data_types.SimpleProjectItemEvent.action_type_to_event_type("created") == SimpleProjectItemEventType.CREATED - assert ( - data_types.SimpleProjectItemEvent.action_type_to_event_type("archived") == SimpleProjectItemEventType.ARCHIVED - ) - assert ( - data_types.SimpleProjectItemEvent.action_type_to_event_type("restored") == SimpleProjectItemEventType.RESTORED - ) - assert data_types.SimpleProjectItemEvent.action_type_to_event_type("deleted") == SimpleProjectItemEventType.DELETED + assert SimpleProjectItemEventType("created") == SimpleProjectItemEventType.CREATED + assert SimpleProjectItemEventType("archived") == SimpleProjectItemEventType.ARCHIVED + assert SimpleProjectItemEventType("restored") == SimpleProjectItemEventType.RESTORED + assert SimpleProjectItemEventType("deleted") == SimpleProjectItemEventType.DELETED with pytest.raises(ValueError): - data_types.SimpleProjectItemEvent.action_type_to_event_type("unknown") + SimpleProjectItemEventType("unknown") def test_field_name_to_event_type(): - assert data_types.ProjectItemEditedSingleSelect.field_name_to_value_type("Status") == SingleSelectType.STATUS - assert data_types.ProjectItemEditedSingleSelect.field_name_to_value_type("Priority") == SingleSelectType.PRIORITY - assert data_types.ProjectItemEditedSingleSelect.field_name_to_value_type("Size") == SingleSelectType.SIZE - assert data_types.ProjectItemEditedSingleSelect.field_name_to_value_type("Iteration") == SingleSelectType.ITERATION - assert data_types.ProjectItemEditedSingleSelect.field_name_to_value_type("Section") == SingleSelectType.SECTION + assert SingleSelectType("Status") == SingleSelectType.STATUS + assert SingleSelectType("Priority") == SingleSelectType.PRIORITY + assert SingleSelectType("Size") == SingleSelectType.SIZE + assert SingleSelectType("Iteration") == SingleSelectType.ITERATION + assert SingleSelectType("Section") == SingleSelectType.SECTION with pytest.raises(ValueError): - data_types.ProjectItemEditedSingleSelect.field_name_to_value_type("Unknown") + SingleSelectType("Unknown") diff --git a/src/utils/data_types.py b/src/utils/data_types.py index e4b627e..f7531c1 100644 --- a/src/utils/data_types.py +++ b/src/utils/data_types.py @@ -50,22 +50,7 @@ async def process( class SimpleProjectItemEvent(ProjectItemEvent): def __init__(self, node_id: str, sender: str, action_type: str): super().__init__(node_id, sender) - self.event_type = self.action_type_to_event_type(action_type) - - @staticmethod - def action_type_to_event_type(action_type: str) -> SimpleProjectItemEventType: - match action_type: - case "created": - event_type = SimpleProjectItemEventType.CREATED - case "archived": - event_type = SimpleProjectItemEventType.ARCHIVED - case "restored": - event_type = SimpleProjectItemEventType.RESTORED - case "deleted": - event_type = SimpleProjectItemEventType.DELETED - case _: - raise ValueError(f"Unknown action type: {action_type}") - return event_type + self.event_type = SimpleProjectItemEventType(action_type) async def process( self, @@ -167,24 +152,7 @@ class ProjectItemEditedSingleSelect(ProjectItemEvent): def __init__(self, node_id: str, editor: str, new_value: str, field_name: str): super().__init__(node_id, editor) self.new_value = new_value - self.value_type = self.field_name_to_value_type(field_name) - - @staticmethod - def field_name_to_value_type(field_name: str) -> SingleSelectType: - match field_name: - case "Status": - value_type = SingleSelectType.STATUS - case "Priority": - value_type = SingleSelectType.PRIORITY - case "Size": - value_type = SingleSelectType.SIZE - case "Iteration": - value_type = SingleSelectType.ITERATION - case "Section": - value_type = SingleSelectType.SECTION - case _: - raise ValueError(f"Unknown single select field name: {field_name}") - return value_type + self.value_type = SingleSelectType(field_name) async def process( self, From 4457c8fdc1901db4d2080d47010f8da41ab047df Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Wed, 26 Nov 2025 16:23:49 +0100 Subject: [PATCH 37/44] fix: Remove string matching when we use enum --- src/utils/data_types.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/utils/data_types.py b/src/utils/data_types.py index f7531c1..992487e 100644 --- a/src/utils/data_types.py +++ b/src/utils/data_types.py @@ -61,18 +61,18 @@ async def process( _shared_forum_channel: SharedForumChannel, _forum_channel_id: int, ) -> str | None: - match self.event_type.value: - case "archived": + match self.event_type: + case SimpleProjectItemEventType.ARCHIVED: message = f"Task zarchiwizowany przez: {user_text_mention}." await client.edit_channel(post.id, archived=True) logger.info(f"Post {self.node_id} archived.") return message - case "restored": + case SimpleProjectItemEventType.RESTORED: message = f"Task przywrócony przez: {user_text_mention}." await client.edit_channel(post.id, archived=False) logger.info(f"Post {self.node_id} restored.") return message - case "deleted": + case SimpleProjectItemEventType.DELETED: await client.delete_channel(post.id) logger.info(f"Post {self.node_id} deleted.") return None From ff9bb6fed88ff0c534172a19b045739945f3e996 Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Wed, 26 Nov 2025 16:27:04 +0100 Subject: [PATCH 38/44] chore: Add comments informing about reason for skipping ruff checks in tests --- src/tests/test_e2e.py | 9 +++++++-- src/tests/test_integration/test_bot.py | 11 ++++++++--- src/tests/test_unit/test_bot.py | 4 ++-- src/tests/test_unit/test_process.py | 4 ++-- .../test_unit/test_utils/test_discord_rest_client.py | 9 +++++++-- src/tests/test_unit/test_utils/test_misc.py | 7 +++++-- .../test_utils/test_signature_verification.py | 4 ++-- 7 files changed, 33 insertions(+), 15 deletions(-) diff --git a/src/tests/test_e2e.py b/src/tests/test_e2e.py index c7f9a03..b211e23 100644 --- a/src/tests/test_e2e.py +++ b/src/tests/test_e2e.py @@ -1,4 +1,4 @@ -# ruff: noqa: F811 +# ruff: noqa: F811 ruff recognizes fixture use as argument as redefinition import asyncio import json from logging import Logger @@ -12,7 +12,12 @@ from src.server import app from src.tests.test_integration.test_bot import RestClientContextManagerMock -from src.tests.utils import MockShelf, forum_channel_mock, full_post_mock, rest_client_mock # noqa: F401 +from src.tests.utils import ( # noqa: F401 ruff recognizes fixture import as unused + MockShelf, + forum_channel_mock, + full_post_mock, + rest_client_mock, +) from src.utils.signature_verification import generate_signature diff --git a/src/tests/test_integration/test_bot.py b/src/tests/test_integration/test_bot.py index f271f6e..59b0026 100644 --- a/src/tests/test_integration/test_bot.py +++ b/src/tests/test_integration/test_bot.py @@ -1,4 +1,4 @@ -# ruff: noqa: F811 +# ruff: noqa: F811 ruff recognizes fixture use as argument as redefinition import asyncio from unittest.mock import AsyncMock, mock_open, patch @@ -6,8 +6,13 @@ from hikari.impl import RESTClientImpl from src.bot import run -from src.tests.test_unit.test_bot import logger_mock # noqa: F401 -from src.tests.utils import MockShelf, RestClientContextManagerMock, forum_channel_mock, rest_client_mock # noqa: F401 +from src.tests.test_unit.test_bot import logger_mock # noqa: F401 ruff recognizes fixture import as unused +from src.tests.utils import ( # noqa: F401 ruff recognizes fixture import as unused + MockShelf, + RestClientContextManagerMock, + forum_channel_mock, + rest_client_mock, +) from src.utils.data_types import ProjectItemEvent diff --git a/src/tests/test_unit/test_bot.py b/src/tests/test_unit/test_bot.py index c44db8f..cff4b03 100644 --- a/src/tests/test_unit/test_bot.py +++ b/src/tests/test_unit/test_bot.py @@ -1,4 +1,4 @@ -# ruff: noqa: F811 +# ruff: noqa: F811 ruff recognizes fixture use as argument as redefinition import asyncio import logging from unittest.mock import ANY, AsyncMock, patch @@ -8,7 +8,7 @@ from hikari.impl import RESTClientImpl from src import bot -from src.tests.utils import ( # noqa: F401 +from src.tests.utils import ( # noqa: F401 ruff recognizes fixture import as unused RestClientContextManagerMock, forum_channel_mock, full_post_mock, diff --git a/src/tests/test_unit/test_process.py b/src/tests/test_unit/test_process.py index 8132ddb..73e35ce 100644 --- a/src/tests/test_unit/test_process.py +++ b/src/tests/test_unit/test_process.py @@ -1,10 +1,10 @@ -# ruff: noqa: F811 +# ruff: noqa: F811 ruff recognizes fixture use as argument as redefinition from unittest.mock import AsyncMock, mock_open, patch from hikari import ForumTag, Snowflake from hikari.impl import RESTClientImpl -from src.tests.utils import ( # noqa: F401 +from src.tests.utils import ( # noqa: F401 ruff recognizes fixture import as unused forum_channel_mock, full_post_mock, logger_mock, diff --git a/src/tests/test_unit/test_utils/test_discord_rest_client.py b/src/tests/test_unit/test_utils/test_discord_rest_client.py index 1cdd6e3..185c915 100644 --- a/src/tests/test_unit/test_utils/test_discord_rest_client.py +++ b/src/tests/test_unit/test_utils/test_discord_rest_client.py @@ -1,10 +1,15 @@ -# ruff: noqa: F811 +# ruff: noqa: F811 ruff recognizes fixture use as argument as redefinition from unittest.mock import AsyncMock, patch from hikari import ForumTag, Snowflake from hikari.impl import RESTClientImpl -from src.tests.utils import MockShelf, forum_channel_mock, post_mock, rest_client_mock # noqa: F401 +from src.tests.utils import ( # noqa: F401 ruff recognizes fixture import as unused + MockShelf, + forum_channel_mock, + post_mock, + rest_client_mock, +) from src.utils import discord_rest_client diff --git a/src/tests/test_unit/test_utils/test_misc.py b/src/tests/test_unit/test_utils/test_misc.py index 0b0d4f2..fbab50d 100644 --- a/src/tests/test_unit/test_utils/test_misc.py +++ b/src/tests/test_unit/test_utils/test_misc.py @@ -1,10 +1,13 @@ -# ruff: noqa: F811 +# ruff: noqa: F811 ruff recognizes fixture use as argument as redefinition import asyncio import logging from io import StringIO from unittest.mock import mock_open, patch -from src.tests.utils import forum_channel_mock, shared_forum_channel_mock # noqa: F401 +from src.tests.utils import ( # noqa: F401 ruff recognizes fixture import as unused + forum_channel_mock, + shared_forum_channel_mock, +) from src.utils import misc diff --git a/src/tests/test_unit/test_utils/test_signature_verification.py b/src/tests/test_unit/test_utils/test_signature_verification.py index 2193ad9..40bb1d7 100644 --- a/src/tests/test_unit/test_utils/test_signature_verification.py +++ b/src/tests/test_unit/test_utils/test_signature_verification.py @@ -1,11 +1,11 @@ -# ruff: noqa: F811 +# ruff: noqa: F811 ruff recognizes fixture use as argument as redefinition from logging import Logger from unittest.mock import patch import pytest from fastapi import HTTPException -from src.tests.utils import logger_mock # noqa: F401 +from src.tests.utils import logger_mock # noqa: F401 ruff recognizes fixture import as unused from src.utils import signature_verification From e9da762eadfb4d67c831e9d2573e184fa20a7b36 Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Wed, 26 Nov 2025 16:33:34 +0100 Subject: [PATCH 39/44] refactor: Remove code duplication from utils.github_api --- src/tests/test_e2e.py | 6 +- .../test_unit/test_utils/test_github_api.py | 65 ++++++++----------- src/utils/github_api.py | 35 ++++------ 3 files changed, 45 insertions(+), 61 deletions(-) diff --git a/src/tests/test_e2e.py b/src/tests/test_e2e.py index b211e23..5889111 100644 --- a/src/tests/test_e2e.py +++ b/src/tests/test_e2e.py @@ -21,7 +21,7 @@ from src.utils.signature_verification import generate_signature -@patch("src.utils.discord_rest_client.fetch_item_name", new_callable=AsyncMock) +@patch("src.utils.github_api.send_request", new_callable=AsyncMock) @patch.object(Logger, "info") @patch.object(RESTClientImpl, "create_message", new_callable=AsyncMock) @patch("builtins.open", new_callable=mock_open, read_data="") @@ -41,7 +41,7 @@ async def test_e2e( _mock_open, mock_create_message, mock_logger, - mock_fetch_item_name, + mock_send_request, rest_client_mock, forum_channel_mock, full_post_mock, @@ -61,7 +61,7 @@ async def test_e2e( post_id_shelf = MockShelf({}) mock_shelve_open.return_value = post_id_shelf mock_fetch_active_threads.return_value = [full_post_mock] - mock_fetch_item_name.return_value = "audacity4" + mock_send_request.return_value = {"data": {"node": {"content": {"title": "audacity4"}}}} config = Config(app=app, host="127.0.0.1", port=8000, log_level="critical") server = Server(config=config) diff --git a/src/tests/test_unit/test_utils/test_github_api.py b/src/tests/test_unit/test_utils/test_github_api.py index b54c828..d0a07eb 100644 --- a/src/tests/test_unit/test_utils/test_github_api.py +++ b/src/tests/test_unit/test_utils/test_github_api.py @@ -1,25 +1,21 @@ -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest -from aiohttp import ClientSession from fastapi import HTTPException -from src.tests.utils import MockResponse from src.utils import github_api -@patch.object(ClientSession, "post") -async def test_fetch_item_name_success(mock_post_request): - mock_response = {"data": {"node": {"content": {"title": "42"}}}} - mock_post_request.return_value = MockResponse(mock_response) +@patch("src.utils.github_api.send_request", new_callable=AsyncMock) +async def test_fetch_item_name_success(mock_send_request): + mock_send_request.return_value = {"data": {"node": {"content": {"title": "42"}}}} assert await github_api.fetch_item_name("") == "42" -@patch.object(ClientSession, "post") -async def test_fetch_item_name_partial(mock_post_request): - mock_response = {"data": {"node": {"content": None}}} - mock_post_request.return_value = MockResponse(mock_response) +@patch("src.utils.github_api.send_request", new_callable=AsyncMock) +async def test_fetch_item_name_partial(mock_send_request): + mock_send_request.return_value = {"data": {"node": {"content": None}}} with pytest.raises(HTTPException) as exception: await github_api.fetch_item_name("") @@ -28,9 +24,9 @@ async def test_fetch_item_name_partial(mock_post_request): assert exception.value.detail == "Could not fetch item name." -@patch.object(ClientSession, "post") -async def test_fetch_item_name_none(mock_post_request): - mock_post_request.return_value = MockResponse({}) +@patch("src.utils.github_api.send_request", new_callable=AsyncMock) +async def test_fetch_item_name_none(mock_send_request): + mock_send_request.return_value = {} with pytest.raises(HTTPException) as exception: await github_api.fetch_item_name("") @@ -38,54 +34,49 @@ async def test_fetch_item_name_none(mock_post_request): assert exception.value.detail == "Could not fetch item name." -@patch.object(ClientSession, "post") -async def test_fetch_assignees_success(mock_post_request): - mock_response = { +@patch("src.utils.github_api.send_request", new_callable=AsyncMock) +async def test_fetch_assignees_success(mock_send_request): + mock_send_request.return_value = { "data": { "node": { "content": {"assignees": {"nodes": [{"id": "MDQ6VXNlcjg4MjY4MDYz"}, {"id": "MDQ6VXNlcjg5ODM3NzI0"}]}} } } } - mock_post_request.return_value = MockResponse(mock_response) assert await github_api.fetch_assignees("") == ["MDQ6VXNlcjg4MjY4MDYz", "MDQ6VXNlcjg5ODM3NzI0"] -@patch.object(ClientSession, "post") -async def test_fetch_assignees_partial(mock_post_request): - mock_response = {"data": {"node": {"content": None}}} - mock_post_request.return_value = MockResponse(mock_response) +@patch("src.utils.github_api.send_request", new_callable=AsyncMock) +async def test_fetch_assignees_partial(mock_send_request): + mock_send_request.return_value = {"data": {"node": {"content": None}}} assert await github_api.fetch_assignees("") == [] -@patch.object(ClientSession, "post") -async def test_fetch_assignees_none(mock_post_request): - mock_post_request.return_value = MockResponse({}) +@patch("src.utils.github_api.send_request", new_callable=AsyncMock) +async def test_fetch_assignees_none(mock_send_request): + mock_send_request.return_value = {} assert await github_api.fetch_assignees("") == [] -@patch.object(ClientSession, "post") -async def test_fetch_single_select_value_success(mock_post_request): - mock_response = {"data": {"node": {"fieldValueByName": {"name": "Dziengiel"}}}} - mock_post_request.return_value = MockResponse(mock_response) +@patch("src.utils.github_api.send_request", new_callable=AsyncMock) +async def test_fetch_single_select_value_success(mock_send_request): + mock_send_request.return_value = {"data": {"node": {"fieldValueByName": {"name": "Dziengiel"}}}} assert await github_api.fetch_single_select_value("", "Salieri") == "Dziengiel" -@patch.object(ClientSession, "post") -async def test_fetch_single_select_value_partial(mock_post_request): - mock_response = {"data": {"node": {"fieldValueByName": None}}} - mock_post_request.return_value = MockResponse(mock_response) +@patch("src.utils.github_api.send_request", new_callable=AsyncMock) +async def test_fetch_single_select_value_partial(mock_send_request): + mock_send_request.return_value = {"data": {"node": {"fieldValueByName": None}}} assert await github_api.fetch_single_select_value("", "Salieri") is None -@patch.object(ClientSession, "post") -async def test_fetch_single_select_value_none(mock_post_request): - mock_response = {} - mock_post_request.return_value = MockResponse(mock_response) +@patch("src.utils.github_api.send_request", new_callable=AsyncMock) +async def test_fetch_single_select_value_none(mock_send_request): + mock_send_request.return_value = {} assert await github_api.fetch_single_select_value("", "Salieri") is None diff --git a/src/utils/github_api.py b/src/utils/github_api.py index 46d99d7..503eaba 100644 --- a/src/utils/github_api.py +++ b/src/utils/github_api.py @@ -4,6 +4,16 @@ from fastapi import HTTPException +async def send_request(query: str, variables: dict) -> dict: + async with aiohttp.ClientSession() as session: + async with session.post( + "https://api.github.com/graphql", + json={"query": query, "variables": variables}, + headers={"Authorization": f"Bearer {os.getenv('GITHUB_TOKEN')}"}, + ) as response: + return await response.json() + + async def fetch_item_name(item_node_id: str) -> str: query = """ query ($id: ID!) { @@ -27,13 +37,7 @@ async def fetch_item_name(item_node_id: str) -> str: variables = {"id": item_node_id} - async with aiohttp.ClientSession() as session: - async with session.post( - "https://api.github.com/graphql", - json={"query": query, "variables": variables}, - headers={"Authorization": f"Bearer {os.getenv('GITHUB_TOKEN')}"}, - ) as response: - response_body = await response.json() + response_body = await send_request(query, variables) try: item_name: str | None = response_body["data"]["node"]["content"]["title"] @@ -78,13 +82,8 @@ async def fetch_assignees(item_node_id: str) -> list[str]: variables = {"id": item_node_id} - async with aiohttp.ClientSession() as session: - async with session.post( - "https://api.github.com/graphql", - json={"query": query, "variables": variables}, - headers={"Authorization": f"Bearer {os.getenv('GITHUB_TOKEN')}"}, - ) as response: - response_body = await response.json() + response_body = await send_request(query, variables) + try: assignees_data = response_body["data"]["node"]["content"]["assignees"]["nodes"] except TypeError, KeyError, AttributeError: @@ -114,13 +113,7 @@ async def fetch_single_select_value(item_node_id: str, field_name: str) -> str | variables = {"id": item_node_id, "field_type": field_name} - async with aiohttp.ClientSession() as session: - async with session.post( - "https://api.github.com/graphql", - json={"query": query, "variables": variables}, - headers={"Authorization": f"Bearer {os.getenv('GITHUB_TOKEN')}"}, - ) as response: - response_body = await response.json() + response_body = await send_request(query, variables) try: name: str | None = response_body["data"]["node"]["fieldValueByName"]["name"] From eff874c5aeec7bfc92687efcc7948e3ebfb267d8 Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Wed, 26 Nov 2025 16:46:39 +0100 Subject: [PATCH 40/44] refactor: Use global logger variable instead of passing it each time --- src/bot.py | 24 ++++++------- src/main.py | 9 +++-- src/server.py | 11 +++--- src/tests/test_integration/test_bot.py | 2 -- src/tests/test_unit/test_bot.py | 18 ++-------- src/tests/test_unit/test_process.py | 36 +++++-------------- .../test_utils/test_signature_verification.py | 17 +++++---- src/tests/utils.py | 6 ---- src/utils/data_types.py | 25 +++++-------- src/utils/misc.py | 4 +++ src/utils/signature_verification.py | 7 ++-- 11 files changed, 57 insertions(+), 102 deletions(-) diff --git a/src/bot.py b/src/bot.py index aa1f6ce..fba588b 100644 --- a/src/bot.py +++ b/src/bot.py @@ -1,5 +1,4 @@ import asyncio -import logging import os from hikari import GuildPublicThread, RESTApp, TokenType @@ -9,16 +8,15 @@ from src.utils.discord_rest_client import fetch_forum_channel, get_post_id from src.utils.error import ForumChannelNotFound from src.utils.github_api import fetch_item_name -from src.utils.misc import SharedForumChannel, get_bot_logger, retrieve_discord_id +from src.utils.misc import SharedForumChannel, bot_logger, retrieve_discord_id async def run(state: asyncio.Queue[ProjectItemEvent], stop_after_one_event: bool = False): discord_rest = RESTApp() await discord_rest.start() - logger = get_bot_logger() async with discord_rest.acquire(os.getenv("DISCORD_BOT_TOKEN"), token_type=TokenType.BOT) as client: - logger.info("Discord client acquired.") + bot_logger.info("Discord client acquired.") forum_channel_id = int(os.getenv("FORUM_CHANNEL_ID")) discord_guild_id = int(os.getenv("DISCORD_GUILD_ID")) forum_channel = await fetch_forum_channel(client, forum_channel_id) @@ -28,9 +26,9 @@ async def run(state: asyncio.Queue[ProjectItemEvent], stop_after_one_event: bool while True: try: - await process_update(client, forum_channel_id, discord_guild_id, shared_forum_channel, state, logger) + await process_update(client, forum_channel_id, discord_guild_id, shared_forum_channel, state) except Exception as error: - logger.error(f"Error processing update: {error}") + bot_logger.error(f"Error processing update: {error}") if stop_after_one_event: break @@ -41,10 +39,9 @@ async def process_update( discord_guild_id: int, shared_forum_channel: SharedForumChannel, state: asyncio.Queue[ProjectItemEvent], - logger: logging.Logger, ): event = await state.get() - logger.info(f"Processing event for item: {event.node_id}") + bot_logger.info(f"Processing event for item: {event.node_id}") post_id_or_post = await get_post_id(event.node_id, discord_guild_id, forum_channel_id, client) author_discord_id = retrieve_discord_id(event.sender) @@ -52,7 +49,7 @@ async def process_update( user_text_mention = f"<@{author_discord_id}>" if author_discord_id else "nieznany użytkownik" if post_id_or_post is None: - post = await create_post(logger, event, user_text_mention, shared_forum_channel, client, user_mentions) + post = await create_post(event, user_text_mention, shared_forum_channel, client, user_mentions) elif isinstance(post_id_or_post, int): post = await client.fetch_channel(post_id_or_post) else: @@ -60,25 +57,24 @@ async def process_update( if not isinstance(post, GuildPublicThread): try: - logger.error(f"Post with ID {post.id} is not a GuildPublicThread.") + bot_logger.error(f"Post with ID {post.id} is not a GuildPublicThread.") except AttributeError: - logger.error(f"Post with node_id {event.node_id} is not a GuildPublicThread.") + bot_logger.error(f"Post with node_id {event.node_id} is not a GuildPublicThread.") return - message = await event.process(user_text_mention, post, client, logger, shared_forum_channel, forum_channel_id) + message = await event.process(user_text_mention, post, client, shared_forum_channel, forum_channel_id) if message: await client.create_message(post.id, message, user_mentions=user_mentions) async def create_post( - logger: logging.Logger, event: ProjectItemEvent, user_text_mention: str, shared_forum_channel: SharedForumChannel, client: RESTClientImpl, user_mentions: list[str], ) -> GuildPublicThread: - logger.info(f"Post not found, creating new post for item: {event.node_id}") + bot_logger.info(f"Post not found, creating new post for item: {event.node_id}") item_name = await fetch_item_name(event.node_id) message = f"Nowy task stworzony {item_name} przez: {user_text_mention}" async with shared_forum_channel.lock.reader_lock: diff --git a/src/main.py b/src/main.py index 44b6838..d8865c5 100644 --- a/src/main.py +++ b/src/main.py @@ -1,5 +1,4 @@ import asyncio -import logging import os from contextlib import asynccontextmanager @@ -8,6 +7,7 @@ from fastapi import FastAPI from src.bot import run +from src.utils.misc import server_logger def main(): @@ -20,9 +20,8 @@ def main(): async def lifespan(app: FastAPI): # startup app.update_queue = asyncio.Queue() - app.logger = logging.getLogger("uvicorn.error") task = asyncio.create_task(run(app.update_queue)) - task.add_done_callback(lambda task: handle_task_exception(task, app)) + task.add_done_callback(handle_task_exception) yield # shutdown task.cancel() @@ -32,14 +31,14 @@ async def lifespan(app: FastAPI): pass -def handle_task_exception(task: asyncio.Task, app: FastAPI): +def handle_task_exception(task: asyncio.Task): try: exception = task.exception() except asyncio.CancelledError: return if exception: - app.logger.error(f"Bot task crashed: {exception}") + server_logger.error(f"Bot task crashed: {exception}") if __name__ == "__main__": diff --git a/src/server.py b/src/server.py index 245de47..13aa99a 100644 --- a/src/server.py +++ b/src/server.py @@ -16,6 +16,7 @@ WebhookRequest, ) from src.utils.github_api import fetch_assignees, fetch_item_name, fetch_single_select_value +from src.utils.misc import server_logger from src.utils.signature_verification import verify_signature app = FastAPI(lifespan=lifespan) @@ -23,13 +24,13 @@ @app.exception_handler(StarletteHttpException) async def http_exception_handler(_request: Request, exception: StarletteHttpException) -> JSONResponse: - app.logger.error(f"HTTP exception occurred: {exception.detail}") + server_logger.error(f"HTTP exception occurred: {exception.detail}") return JSONResponse(status_code=exception.status_code, content={"detail": exception.detail}) @app.exception_handler(ValidationError) async def validation_exception_handler(_request: Request, exception: ValidationError) -> JSONResponse: - app.logger.error( + server_logger.error( f"ValidationError occurred: {exception.errors(include_url=False, include_context=False, include_input=False)}" ) try: @@ -47,7 +48,7 @@ async def validation_exception_handler(_request: Request, exception: ValidationE @app.exception_handler(Exception) async def default_exception_handler(_request: Request, exception: Exception) -> JSONResponse: - app.logger.error(f"Unhandled exception occurred: {str(exception)}") + server_logger.error(f"Unhandled exception occurred: {str(exception)}") return JSONResponse(status_code=500, content={"detail": "Internal server error."}) @@ -58,7 +59,7 @@ async def webhook_endpoint(request: Request) -> JSONResponse: raise HTTPException(status_code=400, detail="Missing request body.") signature = request.headers.get("X-Hub-Signature-256") - verify_signature(signature, body_bytes, app.logger) + verify_signature(signature, body_bytes) body = WebhookRequest.model_validate_json(body_bytes) if body.projects_v2_item.project_node_id != os.getenv("GITHUB_PROJECT_NODE_ID"): @@ -67,7 +68,7 @@ async def webhook_endpoint(request: Request) -> JSONResponse: project_item_event = await process_action(body) await app.update_queue.put(project_item_event) - app.logger.info(f"Received webhook event for item: {body.projects_v2_item.node_id}") + server_logger.info(f"Received webhook event for item: {body.projects_v2_item.node_id}") return JSONResponse(content={"detail": "Successfully received webhook data"}) diff --git a/src/tests/test_integration/test_bot.py b/src/tests/test_integration/test_bot.py index 59b0026..b96a6f6 100644 --- a/src/tests/test_integration/test_bot.py +++ b/src/tests/test_integration/test_bot.py @@ -6,7 +6,6 @@ from hikari.impl import RESTClientImpl from src.bot import run -from src.tests.test_unit.test_bot import logger_mock # noqa: F401 ruff recognizes fixture import as unused from src.tests.utils import ( # noqa: F401 ruff recognizes fixture import as unused MockShelf, RestClientContextManagerMock, @@ -41,7 +40,6 @@ async def test_basic_event_only_creation( mock_fetch_item_name2, rest_client_mock, forum_channel_mock, - logger_mock, ): mock_os_getenv.side_effect = ["some_token", 1, 2, "some_path", "db-path.db"] mock_restapp_acquire.return_value = RestClientContextManagerMock(rest_client_mock) diff --git a/src/tests/test_unit/test_bot.py b/src/tests/test_unit/test_bot.py index cff4b03..bd296e8 100644 --- a/src/tests/test_unit/test_bot.py +++ b/src/tests/test_unit/test_bot.py @@ -12,7 +12,6 @@ RestClientContextManagerMock, forum_channel_mock, full_post_mock, - logger_mock, post_mock, rest_client_mock, shared_forum_channel_mock, @@ -29,7 +28,6 @@ async def test_create_post( mock_create_forum_post, mock_fetch_item_name, - logger_mock, rest_client_mock, shared_forum_channel_mock, user_text_mention, @@ -37,7 +35,7 @@ async def test_create_post( mock_fetch_item_name.return_value = "audacity4" message = f"Nowy task stworzony audacity4 przez: {user_text_mention}" event = SimpleProjectItemEvent("audacity4", "norbiros", "created") - await bot.create_post(logger_mock, event, user_text_mention, shared_forum_channel_mock, rest_client_mock, []) + await bot.create_post(event, user_text_mention, shared_forum_channel_mock, rest_client_mock, []) mock_create_forum_post.assert_called_with( shared_forum_channel_mock.forum_channel, event.node_id, message, auto_archive_duration=10080, user_mentions=[] ) @@ -52,7 +50,6 @@ async def test_process_no_post( mock_create_post, rest_client_mock, shared_forum_channel_mock, - logger_mock, full_post_mock, user_text_mention, ): @@ -68,12 +65,11 @@ async def test_process_no_post( 1, shared_forum_channel_mock, state, - logger_mock, ) assert state.empty() mock_create_post.assert_called_with( - logger_mock, event, user_text_mention, shared_forum_channel_mock, rest_client_mock, ["123456789012345678"] + event, user_text_mention, shared_forum_channel_mock, rest_client_mock, ["123456789012345678"] ) @@ -86,7 +82,6 @@ async def test_process_post_id_found( mock_fetch_channel, rest_client_mock, shared_forum_channel_mock, - logger_mock, full_post_mock, user_text_mention, ): @@ -102,7 +97,6 @@ async def test_process_post_id_found( 1, shared_forum_channel_mock, state, - logger_mock, ) assert state.empty() @@ -120,7 +114,6 @@ async def test_process_post_fetched( mock_create_post, rest_client_mock, shared_forum_channel_mock, - logger_mock, full_post_mock, user_text_mention, ): @@ -135,7 +128,6 @@ async def test_process_post_fetched( 1, shared_forum_channel_mock, state, - logger_mock, ) assert state.empty() @@ -152,7 +144,6 @@ async def test_process_post_not_guild_public_thread( mock_logger_error, rest_client_mock, shared_forum_channel_mock, - logger_mock, post_mock, user_text_mention, ): @@ -167,7 +158,6 @@ async def test_process_post_not_guild_public_thread( 1, shared_forum_channel_mock, state, - logger_mock, ) assert state.empty() @@ -185,7 +175,6 @@ async def test_process_post_created_message( mock_event_process, rest_client_mock, shared_forum_channel_mock, - logger_mock, full_post_mock, user_text_mention, ): @@ -201,7 +190,6 @@ async def test_process_post_created_message( 1, shared_forum_channel_mock, state, - logger_mock, ) assert state.empty() @@ -228,7 +216,7 @@ async def test_bot_run( state = asyncio.Queue() await bot.run(state, stop_after_one_event=True) - mock_process_update.assert_called_with(rest_client_mock, 1, 2, ANY, state, bot.get_bot_logger()) + mock_process_update.assert_called_with(rest_client_mock, 1, 2, ANY, state) @patch("src.bot.fetch_forum_channel", new_callable=AsyncMock) diff --git a/src/tests/test_unit/test_process.py b/src/tests/test_unit/test_process.py index 73e35ce..5e9e02e 100644 --- a/src/tests/test_unit/test_process.py +++ b/src/tests/test_unit/test_process.py @@ -7,7 +7,6 @@ from src.tests.utils import ( # noqa: F401 ruff recognizes fixture import as unused forum_channel_mock, full_post_mock, - logger_mock, post_mock, rest_client_mock, shared_forum_channel_mock, @@ -24,7 +23,7 @@ @patch.object(RESTClientImpl, "edit_channel") async def test_simple_project_item_event_process_archived( - mock_edit_channel, user_text_mention, post_mock, rest_client_mock, logger_mock, shared_forum_channel_mock + mock_edit_channel, user_text_mention, post_mock, rest_client_mock, shared_forum_channel_mock ): event = SimpleProjectItemEvent("audacity4", "norbiros", "archived") assert ( @@ -32,7 +31,6 @@ async def test_simple_project_item_event_process_archived( user_text_mention, post_mock, rest_client_mock, - logger_mock, shared_forum_channel_mock, shared_forum_channel_mock.forum_channel.id, ) @@ -43,7 +41,7 @@ async def test_simple_project_item_event_process_archived( @patch.object(RESTClientImpl, "edit_channel") async def test_simple_project_item_event_process_restored( - mock_edit_channel, user_text_mention, post_mock, rest_client_mock, logger_mock, shared_forum_channel_mock + mock_edit_channel, user_text_mention, post_mock, rest_client_mock, shared_forum_channel_mock ): event = SimpleProjectItemEvent("audacity4", "norbiros", "restored") assert ( @@ -51,7 +49,6 @@ async def test_simple_project_item_event_process_restored( user_text_mention, post_mock, rest_client_mock, - logger_mock, shared_forum_channel_mock, shared_forum_channel_mock.forum_channel.id, ) @@ -62,7 +59,7 @@ async def test_simple_project_item_event_process_restored( @patch.object(RESTClientImpl, "delete_channel") async def test_simple_project_item_event_process_deleted( - mock_delete_channel, user_text_mention, post_mock, rest_client_mock, logger_mock, shared_forum_channel_mock + mock_delete_channel, user_text_mention, post_mock, rest_client_mock, shared_forum_channel_mock ): event = SimpleProjectItemEvent("audacity4", "norbiros", "deleted") assert ( @@ -70,7 +67,6 @@ async def test_simple_project_item_event_process_deleted( user_text_mention, post_mock, rest_client_mock, - logger_mock, shared_forum_channel_mock, shared_forum_channel_mock.forum_channel.id, ) @@ -80,7 +76,7 @@ async def test_simple_project_item_event_process_deleted( async def test_simple_project_item_event_process_created( - user_text_mention, post_mock, rest_client_mock, logger_mock, shared_forum_channel_mock + user_text_mention, post_mock, rest_client_mock, shared_forum_channel_mock ): event = SimpleProjectItemEvent("audacity4", "norbiros", "created") assert ( @@ -88,7 +84,6 @@ async def test_simple_project_item_event_process_created( user_text_mention, post_mock, rest_client_mock, - logger_mock, shared_forum_channel_mock, shared_forum_channel_mock.forum_channel.id, ) @@ -96,16 +91,13 @@ async def test_simple_project_item_event_process_created( ) -async def test_project_item_edited_body( - user_text_mention, post_mock, rest_client_mock, logger_mock, shared_forum_channel_mock -): +async def test_project_item_edited_body(user_text_mention, post_mock, rest_client_mock, shared_forum_channel_mock): event = ProjectItemEditedBody("audacity4", "norbiros", "edited_body") assert ( await event.process( user_text_mention, post_mock, rest_client_mock, - logger_mock, shared_forum_channel_mock, shared_forum_channel_mock.forum_channel.id, ) @@ -115,15 +107,12 @@ async def test_project_item_edited_body( @patch.object(RESTClientImpl, "create_message") @patch("builtins.open", new_callable=mock_open, read_data="node_id1: 123\nnode_id2: 321\n") -async def test_project_item_edited_assignees( - user_text_mention, post_mock, rest_client_mock, logger_mock, shared_forum_channel_mock -): +async def test_project_item_edited_assignees(user_text_mention, post_mock, rest_client_mock, shared_forum_channel_mock): event = ProjectItemEditedAssignees("audacity4", "norbiros", ["node_id1", "node_id2"]) await event.process( user_text_mention, post_mock, rest_client_mock, - logger_mock, shared_forum_channel_mock, shared_forum_channel_mock.forum_channel.id, ) @@ -138,14 +127,13 @@ async def test_project_item_edited_assignees( @patch.object(RESTClientImpl, "create_message") @patch("builtins.open", new_callable=mock_open, read_data="") async def test_project_item_edited_assignees_not_in_mapping( - user_text_mention, post_mock, rest_client_mock, logger_mock, shared_forum_channel_mock + user_text_mention, post_mock, rest_client_mock, shared_forum_channel_mock ): event = ProjectItemEditedAssignees("audacity4", "norbiros", ["node_id1", "node_id2"]) await event.process( user_text_mention, post_mock, rest_client_mock, - logger_mock, shared_forum_channel_mock, shared_forum_channel_mock.forum_channel.id, ) @@ -158,14 +146,13 @@ async def test_project_item_edited_assignees_not_in_mapping( @patch.object(RESTClientImpl, "create_message") @patch("builtins.open", new_callable=mock_open, read_data="") async def test_project_item_edited_assignees_no_assignees( - user_text_mention, post_mock, rest_client_mock, logger_mock, shared_forum_channel_mock + user_text_mention, post_mock, rest_client_mock, shared_forum_channel_mock ): event = ProjectItemEditedAssignees("audacity4", "norbiros", []) await event.process( user_text_mention, post_mock, rest_client_mock, - logger_mock, shared_forum_channel_mock, shared_forum_channel_mock.forum_channel.id, ) @@ -179,14 +166,13 @@ async def test_project_item_edited_assignees_no_assignees( @patch.object(RESTClientImpl, "edit_channel") async def test_project_item_edited_title( - mock_edit_channel, user_text_mention, post_mock, rest_client_mock, logger_mock, shared_forum_channel_mock + mock_edit_channel, user_text_mention, post_mock, rest_client_mock, shared_forum_channel_mock ): event = ProjectItemEditedTitle("audacity4", "norbiros", "edited_title") await event.process( user_text_mention, post_mock, rest_client_mock, - logger_mock, shared_forum_channel_mock, shared_forum_channel_mock.forum_channel.id, ) @@ -199,7 +185,6 @@ async def test_project_item_edited_single_select_existing_tag( user_text_mention, full_post_mock, rest_client_mock, - logger_mock, shared_forum_channel_mock, forum_channel_mock, ): @@ -208,7 +193,6 @@ async def test_project_item_edited_single_select_existing_tag( user_text_mention, full_post_mock, rest_client_mock, - logger_mock, shared_forum_channel_mock, forum_channel_mock.id, ) @@ -226,7 +210,6 @@ async def test_project_item_edited_single_select_tag_unavailable( user_text_mention, full_post_mock, rest_client_mock, - logger_mock, shared_forum_channel_mock, forum_channel_mock, ): @@ -239,7 +222,6 @@ async def test_project_item_edited_single_select_tag_unavailable( user_text_mention, full_post_mock, rest_client_mock, - logger_mock, shared_forum_channel_mock, forum_channel_mock.id, ) diff --git a/src/tests/test_unit/test_utils/test_signature_verification.py b/src/tests/test_unit/test_utils/test_signature_verification.py index 40bb1d7..107a19b 100644 --- a/src/tests/test_unit/test_utils/test_signature_verification.py +++ b/src/tests/test_unit/test_utils/test_signature_verification.py @@ -5,7 +5,6 @@ import pytest from fastapi import HTTPException -from src.tests.utils import logger_mock # noqa: F401 ruff recognizes fixture import as unused from src.utils import signature_verification @@ -34,35 +33,35 @@ def test_verify_secret_incorrect(): @patch.object(Logger, "warning") @patch("os.getenv") -def test_verify_signature_correct(mock_getenv, mock_logger_warning, logger_mock): +def test_verify_signature_correct(mock_getenv, mock_logger_warning): signature = "sha256=9b40ac77c0653bed6e678ebd8db8b8d96a7c8ea8983b1a77577797d0a43b97c6" body_bytes = b"I freaking love H letter" mock_getenv.return_value = "H-letter" - signature_verification.verify_signature(signature, body_bytes, logger_mock) + signature_verification.verify_signature(signature, body_bytes) mock_logger_warning.assert_not_called() @patch("os.getenv") -def test_verify_signature_incorrect(mock_getenv, logger_mock): +def test_verify_signature_incorrect(mock_getenv): signature = "sha256=9b40ac77c0653bed6e678ebd8db8b8d96a7c8ea8983b1a77577797d0a43b97c6" body_bytes = b"I freaking love H letter" mock_getenv.return_value = "K-letter" with pytest.raises(HTTPException) as error: - signature_verification.verify_signature(signature, body_bytes, logger_mock) + signature_verification.verify_signature(signature, body_bytes) assert error.value.status_code == 401 assert error.value.detail == "Invalid signature." @patch("os.getenv") -def test_verify_signature_missing(mock_getenv, logger_mock): +def test_verify_signature_missing(mock_getenv): body_bytes = b"I freaking love H letter" mock_getenv.return_value = "K-letter" with pytest.raises(HTTPException) as error: - signature_verification.verify_signature("", body_bytes, logger_mock) + signature_verification.verify_signature("", body_bytes) assert error.value.status_code == 401 assert error.value.detail == "Missing signature." @@ -70,10 +69,10 @@ def test_verify_signature_missing(mock_getenv, logger_mock): @patch.object(Logger, "warning") @patch("os.getenv") -def test_verify_signature_not_set(mock_getenv, mock_logger_warning, logger_mock): +def test_verify_signature_not_set(mock_getenv, mock_logger_warning): signature = "sha256=9b40ac77c0653bed6e678ebd8db8b8d96a7c8ea8983b1a77577797d0a43b97c6" body_bytes = b"I freaking love H letter" mock_getenv.return_value = "" - signature_verification.verify_signature(signature, body_bytes, logger_mock) + signature_verification.verify_signature(signature, body_bytes) mock_logger_warning.assert_called_with("GITHUB_WEBHOOK_SECRET is not set; skipping signature verification.") diff --git a/src/tests/utils.py b/src/tests/utils.py index 23d4a6f..1c5a1b5 100644 --- a/src/tests/utils.py +++ b/src/tests/utils.py @@ -1,5 +1,4 @@ import datetime -import logging import pytest from hikari import ( @@ -100,11 +99,6 @@ def forum_channel_mock(): ) -@pytest.fixture -def logger_mock(): - return logging.getLogger("uvicorn.error") - - @pytest.fixture def shared_forum_channel_mock(forum_channel_mock): return SharedForumChannel(forum_channel_mock) diff --git a/src/utils/data_types.py b/src/utils/data_types.py index 992487e..2306bf6 100644 --- a/src/utils/data_types.py +++ b/src/utils/data_types.py @@ -1,6 +1,5 @@ from dataclasses import dataclass from enum import Enum -from logging import Logger from typing import Literal from hikari import ForumTag, GuildForumChannel, GuildPublicThread @@ -10,7 +9,7 @@ from src.utils.discord_rest_client import fetch_forum_channel, get_new_tag from src.utils.error import ForumChannelNotFound -from src.utils.misc import SharedForumChannel, retrieve_discord_id +from src.utils.misc import SharedForumChannel, bot_logger, retrieve_discord_id class SimpleProjectItemEventType(Enum): @@ -38,7 +37,6 @@ async def process( user_text_mention: str, post: GuildPublicThread, client: RESTClientImpl, - logger: Logger, shared_forum_channel: SharedForumChannel, forum_channel_id: int, ) -> str | GuildForumChannel | None: @@ -57,7 +55,6 @@ async def process( user_text_mention: str, post: GuildPublicThread, client: RESTClientImpl, - logger: Logger, _shared_forum_channel: SharedForumChannel, _forum_channel_id: int, ) -> str | None: @@ -65,16 +62,16 @@ async def process( case SimpleProjectItemEventType.ARCHIVED: message = f"Task zarchiwizowany przez: {user_text_mention}." await client.edit_channel(post.id, archived=True) - logger.info(f"Post {self.node_id} archived.") + bot_logger.info(f"Post {self.node_id} archived.") return message case SimpleProjectItemEventType.RESTORED: message = f"Task przywrócony przez: {user_text_mention}." await client.edit_channel(post.id, archived=False) - logger.info(f"Post {self.node_id} restored.") + bot_logger.info(f"Post {self.node_id} restored.") return message case SimpleProjectItemEventType.DELETED: await client.delete_channel(post.id) - logger.info(f"Post {self.node_id} deleted.") + bot_logger.info(f"Post {self.node_id} deleted.") return None case _: return None @@ -90,12 +87,11 @@ async def process( user_text_mention: str, _post: GuildPublicThread, client: RESTClientImpl, - logger: Logger, _shared_forum_channel: SharedForumChannel, forum_channel_id: int, ) -> str: message = f"Opis taska zaktualizowany przez: {user_text_mention}. Nowy opis: \n{self.new_body}" - logger.info(f"Post {self.node_id} body updated.") + bot_logger.info(f"Post {self.node_id} body updated.") return message @@ -110,7 +106,6 @@ async def process( user_text_mention: str, post: GuildPublicThread, client: RESTClientImpl, - logger: Logger, _shared_forum_channel: SharedForumChannel, forum_channel_id: int, ) -> None: @@ -127,7 +122,7 @@ async def process( message = f"Osoby przypisane do taska edytowane, aktualni przypisani: {', '.join(assignee_mentions)}" await client.create_message(post.id, message, user_mentions=assignee_discord_ids) - logger.info(f"Post {self.node_id} assignees updated.") + bot_logger.info(f"Post {self.node_id} assignees updated.") class ProjectItemEditedTitle(ProjectItemEvent): @@ -140,12 +135,11 @@ async def process( user_text_mention: str, post: GuildPublicThread, client: RESTClientImpl, - logger: Logger, _shared_forum_channel: SharedForumChannel, forum_channel_id: int, ) -> None: await client.edit_channel(post.id, name=self.new_title) - logger.info(f"Post {self.node_id} title updated to {self.new_title}.") + bot_logger.info(f"Post {self.node_id} title updated to {self.new_title}.") class ProjectItemEditedSingleSelect(ProjectItemEvent): @@ -159,7 +153,6 @@ async def process( user_text_mention: str, post: GuildPublicThread, client: RESTClientImpl, - logger: Logger, shared_forum_channel: SharedForumChannel, forum_channel_id: int, ) -> None: @@ -175,7 +168,7 @@ async def process( new_tag = get_new_tag(new_tag_name, available_tags) if new_tag is None: - logger.info(f"Tag {new_tag_name} not found, creating new tag.") + bot_logger.info(f"Tag {new_tag_name} not found, creating new tag.") await client.edit_channel(forum_channel_id, available_tags=[*available_tags, ForumTag(name=new_tag_name)]) forum_channel = await fetch_forum_channel(client, forum_channel_id) if forum_channel is None: @@ -189,7 +182,7 @@ async def process( current_tag_ids.append(new_tag.id) await client.edit_channel(post.id, applied_tags=current_tag_ids) - logger.info(f"Post {self.node_id} tag updated to {new_tag_name}.") + bot_logger.info(f"Post {self.node_id} tag updated to {new_tag_name}.") class ProjectV2Item(BaseModel): diff --git a/src/utils/misc.py b/src/utils/misc.py index bd0cce2..01ca2e9 100644 --- a/src/utils/misc.py +++ b/src/utils/misc.py @@ -38,3 +38,7 @@ def get_bot_logger() -> logging.Logger: logger.addFilter(BotPrefixFilter()) return logger + + +server_logger = logging.getLogger("uvicorn.error") +bot_logger = get_bot_logger() diff --git a/src/utils/signature_verification.py b/src/utils/signature_verification.py index 3598534..ffe85e7 100644 --- a/src/utils/signature_verification.py +++ b/src/utils/signature_verification.py @@ -1,15 +1,16 @@ import hashlib import hmac import os -from logging import Logger from fastapi import HTTPException +from src.utils.misc import server_logger -def verify_signature(signature: str | None, body_bytes: bytes, logger: Logger) -> None: + +def verify_signature(signature: str | None, body_bytes: bytes) -> None: secret = os.getenv("GITHUB_WEBHOOK_SECRET", "") if not secret: - logger.warning("GITHUB_WEBHOOK_SECRET is not set; skipping signature verification.") + server_logger.warning("GITHUB_WEBHOOK_SECRET is not set; skipping signature verification.") return if signature: correct_signature = verify_secret(secret, body_bytes, signature) From c6a2789f88d33f2c1cbde74fa402df59fa98e33d Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Wed, 26 Nov 2025 18:12:48 +0100 Subject: [PATCH 41/44] feat: Append link to GitHub project card in Discord post --- .env.example | 2 ++ src/bot.py | 7 ++++-- src/server.py | 17 +++++++++----- src/tests/test_e2e.py | 2 +- src/tests/test_integration/test_bot.py | 4 ++-- src/tests/test_integration/test_server.py | 4 ++-- src/tests/test_unit/test_bot.py | 14 ++++++------ src/tests/test_unit/test_process.py | 22 +++++++++--------- src/tests/test_unit/test_server.py | 16 ++++++------- src/utils/data_types.py | 28 +++++++++++++---------- src/utils/misc.py | 6 +++++ 11 files changed, 71 insertions(+), 51 deletions(-) diff --git a/.env.example b/.env.example index ed90fcd..2ab2ab1 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,8 @@ DISCORD_BOT_TOKEN=your-discord-bot-token FORUM_CHANNEL_ID=your-forum-channel-id GITHUB_TOKEN=your-github-token GITHUB_PROJECT_NODE_ID=your-github-project-node-id +GITHUB_PROJECT_NUMBER=your-github-project-number +GITHUB_ORGANIZATION_NAME=your-github-organization-name GITHUB_ID_TO_DISCORD_ID_MAPPING_PATH=path-to-github-username-to-discord-id-mapping.json IP_ADDRESS=0.0.0.0 PORT=8000 diff --git a/src/bot.py b/src/bot.py index fba588b..2441915 100644 --- a/src/bot.py +++ b/src/bot.py @@ -8,7 +8,7 @@ from src.utils.discord_rest_client import fetch_forum_channel, get_post_id from src.utils.error import ForumChannelNotFound from src.utils.github_api import fetch_item_name -from src.utils.misc import SharedForumChannel, bot_logger, retrieve_discord_id +from src.utils.misc import SharedForumChannel, bot_logger, create_item_link, retrieve_discord_id async def run(state: asyncio.Queue[ProjectItemEvent], stop_after_one_event: bool = False): @@ -76,7 +76,10 @@ async def create_post( ) -> GuildPublicThread: bot_logger.info(f"Post not found, creating new post for item: {event.node_id}") item_name = await fetch_item_name(event.node_id) - message = f"Nowy task stworzony {item_name} przez: {user_text_mention}" + message = ( + f"Nowy task stworzony {item_name} przez: {user_text_mention}.\n" + f" Link do taska: {create_item_link(event.item_id)}" + ) async with shared_forum_channel.lock.reader_lock: return await client.create_forum_post( shared_forum_channel.forum_channel, diff --git a/src/server.py b/src/server.py index 13aa99a..68993e0 100644 --- a/src/server.py +++ b/src/server.py @@ -77,7 +77,9 @@ async def process_action(body: WebhookRequest) -> ProjectItemEvent: return await process_edition(body) else: try: - return SimpleProjectItemEvent(body.projects_v2_item.node_id, body.sender.node_id, body.action) + return SimpleProjectItemEvent( + body.projects_v2_item.item_id, body.projects_v2_item.node_id, body.sender.node_id, body.action + ) except ValueError as error: raise HTTPException(status_code=400, detail="Unsupported action.") from error @@ -88,9 +90,10 @@ async def process_edition( editor = body.sender.node_id body_changed = body.changes.body item_node_id = body.projects_v2_item.node_id + item_id = body.projects_v2_item.item_id if body_changed is not None: - project_item_edited = ProjectItemEditedBody(item_node_id, editor, body_changed.to) + project_item_edited = ProjectItemEditedBody(item_id, item_node_id, editor, body_changed.to) return project_item_edited field_changed = body.changes.field_value @@ -101,11 +104,11 @@ async def process_edition( match field_changed.field_type: case "assignees": new_assignees = await fetch_assignees(body.projects_v2_item.node_id) - project_item_edited = ProjectItemEditedAssignees(item_node_id, editor, new_assignees) + project_item_edited = ProjectItemEditedAssignees(item_id, item_node_id, editor, new_assignees) return project_item_edited case "title": new_title = await fetch_item_name(body.projects_v2_item.node_id) - project_item_edited = ProjectItemEditedTitle(item_node_id, editor, new_title) + project_item_edited = ProjectItemEditedTitle(item_id, item_node_id, editor, new_title) return project_item_edited case "single_select": new_value = field_changed.to.name @@ -113,11 +116,13 @@ async def process_edition( if new_value is None: new_value = await fetch_single_select_value(body.projects_v2_item.node_id, field_name) try: - project_item_edited = ProjectItemEditedSingleSelect(item_node_id, editor, new_value, field_name) + project_item_edited = ProjectItemEditedSingleSelect( + item_id, item_node_id, editor, new_value, field_name + ) except ValueError as error: raise HTTPException(status_code=400, detail="Unsupported single select field.") from error return project_item_edited case "iteration": new_value = field_changed.to.title - project_item_edited = ProjectItemEditedSingleSelect(item_node_id, editor, new_value, "Iteration") + project_item_edited = ProjectItemEditedSingleSelect(item_id, item_node_id, editor, new_value, "Iteration") return project_item_edited diff --git a/src/tests/test_e2e.py b/src/tests/test_e2e.py index 5889111..ac96f98 100644 --- a/src/tests/test_e2e.py +++ b/src/tests/test_e2e.py @@ -80,7 +80,7 @@ async def test_e2e( payload = { "action": "edited", "sender": {"node_id": "github_user"}, - "projects_v2_item": {"node_id": "item123", "project_node_id": "fake_project_id"}, + "projects_v2_item": {"id": 123, "node_id": "item123", "project_node_id": "fake_project_id"}, "changes": {"body": {"to": "Updated description"}}, } signature = generate_signature("some_secret", json.dumps(payload).encode()) diff --git a/src/tests/test_integration/test_bot.py b/src/tests/test_integration/test_bot.py index b96a6f6..b86966e 100644 --- a/src/tests/test_integration/test_bot.py +++ b/src/tests/test_integration/test_bot.py @@ -41,7 +41,7 @@ async def test_basic_event_only_creation( rest_client_mock, forum_channel_mock, ): - mock_os_getenv.side_effect = ["some_token", 1, 2, "some_path", "db-path.db"] + mock_os_getenv.side_effect = ["some_token", 1, 2, "some_path", "db-path.db", "my-org", "1"] mock_restapp_acquire.return_value = RestClientContextManagerMock(rest_client_mock) mock_fetch_channel.return_value = forum_channel_mock mock_fetch_active_threads.return_value = [] @@ -52,6 +52,6 @@ async def test_basic_event_only_creation( mock_fetch_item_name.return_value = "audacity4" mock_fetch_item_name2.return_value = "audacity4" update_queue = asyncio.Queue() - await update_queue.put(ProjectItemEvent(node_id="node_id", sender="test_sender")) + await update_queue.put(ProjectItemEvent(item_id=123, node_id="node_id", sender="test_sender")) await run(update_queue, stop_after_one_event=True) mock_create_forum_post.assert_called() diff --git a/src/tests/test_integration/test_server.py b/src/tests/test_integration/test_server.py index 1c7eefa..c87ac5f 100644 --- a/src/tests/test_integration/test_server.py +++ b/src/tests/test_integration/test_server.py @@ -24,7 +24,7 @@ def test_missing_body(): def test_github_project_node_id_mismatch(): payload: dict[str, Any] = { - "projects_v2_item": {"project_node_id": "wrong_id", "node_id": "123"}, + "projects_v2_item": {"id": 123, "project_node_id": "wrong_id", "node_id": "123"}, "action": "edited", "changes": {"field_value": {"field_type": "title", "field_name": "Title"}}, "sender": {"node_id": "456"}, @@ -48,7 +48,7 @@ def test_github_project_node_id_mismatch(): @patch("os.getenv") def test_edited_action(mock_os_getenv, mock_shelve_open, mock_post_request): payload: dict[str, Any] = { - "projects_v2_item": {"project_node_id": "123", "node_id": "123"}, + "projects_v2_item": {"id": 123, "project_node_id": "123", "node_id": "123"}, "action": "edited", "changes": {"field_value": {"field_type": "title", "field_name": "Title"}}, "sender": {"node_id": "456"}, diff --git a/src/tests/test_unit/test_bot.py b/src/tests/test_unit/test_bot.py index bd296e8..dbfe232 100644 --- a/src/tests/test_unit/test_bot.py +++ b/src/tests/test_unit/test_bot.py @@ -33,8 +33,8 @@ async def test_create_post( user_text_mention, ): mock_fetch_item_name.return_value = "audacity4" - message = f"Nowy task stworzony audacity4 przez: {user_text_mention}" - event = SimpleProjectItemEvent("audacity4", "norbiros", "created") + message = f"Nowy task stworzony audacity4 przez: {user_text_mention}.\n Link do taska: https://github.com/orgs/my-org/projects/1?pane=issue&item_id=1" + event = SimpleProjectItemEvent(1, "audacity4", "norbiros", "created") await bot.create_post(event, user_text_mention, shared_forum_channel_mock, rest_client_mock, []) mock_create_forum_post.assert_called_with( shared_forum_channel_mock.forum_channel, event.node_id, message, auto_archive_duration=10080, user_mentions=[] @@ -57,7 +57,7 @@ async def test_process_no_post( mock_retrieve_discord_id.return_value = "123456789012345678" mock_create_post.return_value = full_post_mock state = asyncio.Queue() - event = SimpleProjectItemEvent("node_id", "norbiros", "created") + event = SimpleProjectItemEvent(1, "node_id", "norbiros", "created") await state.put(event) await bot.process_update( rest_client_mock, @@ -89,7 +89,7 @@ async def test_process_post_id_found( mock_retrieve_discord_id.return_value = "123456789012345678" mock_fetch_channel.return_value = full_post_mock state = asyncio.Queue() - event = SimpleProjectItemEvent("audacity4", "norbiros", "created") + event = SimpleProjectItemEvent(1, "audacity4", "norbiros", "created") await state.put(event) await bot.process_update( rest_client_mock, @@ -120,7 +120,7 @@ async def test_process_post_fetched( mock_get_post_id.return_value = full_post_mock mock_retrieve_discord_id.return_value = "123456789012345678" state = asyncio.Queue() - event = SimpleProjectItemEvent("audacity4", "norbiros", "created") + event = SimpleProjectItemEvent(1, "audacity4", "norbiros", "created") await state.put(event) await bot.process_update( rest_client_mock, @@ -150,7 +150,7 @@ async def test_process_post_not_guild_public_thread( mock_get_post_id.return_value = post_mock mock_retrieve_discord_id.return_value = "123456789012345678" state = asyncio.Queue() - event = SimpleProjectItemEvent("audacity4", "norbiros", "created") + event = SimpleProjectItemEvent(1, "audacity4", "norbiros", "created") await state.put(event) await bot.process_update( rest_client_mock, @@ -181,7 +181,7 @@ async def test_process_post_created_message( mock_get_post_id.return_value = full_post_mock mock_retrieve_discord_id.return_value = "123456789012345678" state = asyncio.Queue() - event = SimpleProjectItemEvent("audacity4", "norbiros", "archived") + event = SimpleProjectItemEvent(1, "audacity4", "norbiros", "archived") mock_event_process.return_value = "Test message content" await state.put(event) await bot.process_update( diff --git a/src/tests/test_unit/test_process.py b/src/tests/test_unit/test_process.py index 5e9e02e..521854d 100644 --- a/src/tests/test_unit/test_process.py +++ b/src/tests/test_unit/test_process.py @@ -25,7 +25,7 @@ async def test_simple_project_item_event_process_archived( mock_edit_channel, user_text_mention, post_mock, rest_client_mock, shared_forum_channel_mock ): - event = SimpleProjectItemEvent("audacity4", "norbiros", "archived") + event = SimpleProjectItemEvent(1, "audacity4", "norbiros", "archived") assert ( await event.process( user_text_mention, @@ -43,7 +43,7 @@ async def test_simple_project_item_event_process_archived( async def test_simple_project_item_event_process_restored( mock_edit_channel, user_text_mention, post_mock, rest_client_mock, shared_forum_channel_mock ): - event = SimpleProjectItemEvent("audacity4", "norbiros", "restored") + event = SimpleProjectItemEvent(1, "audacity4", "norbiros", "restored") assert ( await event.process( user_text_mention, @@ -61,7 +61,7 @@ async def test_simple_project_item_event_process_restored( async def test_simple_project_item_event_process_deleted( mock_delete_channel, user_text_mention, post_mock, rest_client_mock, shared_forum_channel_mock ): - event = SimpleProjectItemEvent("audacity4", "norbiros", "deleted") + event = SimpleProjectItemEvent(1, "audacity4", "norbiros", "deleted") assert ( await event.process( user_text_mention, @@ -78,7 +78,7 @@ async def test_simple_project_item_event_process_deleted( async def test_simple_project_item_event_process_created( user_text_mention, post_mock, rest_client_mock, shared_forum_channel_mock ): - event = SimpleProjectItemEvent("audacity4", "norbiros", "created") + event = SimpleProjectItemEvent(1, "audacity4", "norbiros", "created") assert ( await event.process( user_text_mention, @@ -92,7 +92,7 @@ async def test_simple_project_item_event_process_created( async def test_project_item_edited_body(user_text_mention, post_mock, rest_client_mock, shared_forum_channel_mock): - event = ProjectItemEditedBody("audacity4", "norbiros", "edited_body") + event = ProjectItemEditedBody(1, "audacity4", "norbiros", "edited_body") assert ( await event.process( user_text_mention, @@ -108,7 +108,7 @@ async def test_project_item_edited_body(user_text_mention, post_mock, rest_clien @patch.object(RESTClientImpl, "create_message") @patch("builtins.open", new_callable=mock_open, read_data="node_id1: 123\nnode_id2: 321\n") async def test_project_item_edited_assignees(user_text_mention, post_mock, rest_client_mock, shared_forum_channel_mock): - event = ProjectItemEditedAssignees("audacity4", "norbiros", ["node_id1", "node_id2"]) + event = ProjectItemEditedAssignees(1, "audacity4", "norbiros", ["node_id1", "node_id2"]) await event.process( user_text_mention, post_mock, @@ -129,7 +129,7 @@ async def test_project_item_edited_assignees(user_text_mention, post_mock, rest_ async def test_project_item_edited_assignees_not_in_mapping( user_text_mention, post_mock, rest_client_mock, shared_forum_channel_mock ): - event = ProjectItemEditedAssignees("audacity4", "norbiros", ["node_id1", "node_id2"]) + event = ProjectItemEditedAssignees(1, "audacity4", "norbiros", ["node_id1", "node_id2"]) await event.process( user_text_mention, post_mock, @@ -148,7 +148,7 @@ async def test_project_item_edited_assignees_not_in_mapping( async def test_project_item_edited_assignees_no_assignees( user_text_mention, post_mock, rest_client_mock, shared_forum_channel_mock ): - event = ProjectItemEditedAssignees("audacity4", "norbiros", []) + event = ProjectItemEditedAssignees(1, "audacity4", "norbiros", []) await event.process( user_text_mention, post_mock, @@ -168,7 +168,7 @@ async def test_project_item_edited_assignees_no_assignees( async def test_project_item_edited_title( mock_edit_channel, user_text_mention, post_mock, rest_client_mock, shared_forum_channel_mock ): - event = ProjectItemEditedTitle("audacity4", "norbiros", "edited_title") + event = ProjectItemEditedTitle(1, "audacity4", "norbiros", "edited_title") await event.process( user_text_mention, post_mock, @@ -188,7 +188,7 @@ async def test_project_item_edited_single_select_existing_tag( shared_forum_channel_mock, forum_channel_mock, ): - event = ProjectItemEditedSingleSelect("audacity4", "norbiros", "smol", "Size") + event = ProjectItemEditedSingleSelect(1, "audacity4", "norbiros", "smol", "Size") await event.process( user_text_mention, full_post_mock, @@ -213,7 +213,7 @@ async def test_project_item_edited_single_select_tag_unavailable( shared_forum_channel_mock, forum_channel_mock, ): - event = ProjectItemEditedSingleSelect("audacity4", "norbiros", "medium", "Size") + event = ProjectItemEditedSingleSelect(1, "audacity4", "norbiros", "medium", "Size") new_tag = ForumTag(id=Snowflake(0), name="Size: medium") mock_fetch_forum_channel.return_value = forum_channel_mock mock_get_new_tag.side_effect = [None, new_tag] diff --git a/src/tests/test_unit/test_server.py b/src/tests/test_unit/test_server.py index ae830e3..61b8c3d 100644 --- a/src/tests/test_unit/test_server.py +++ b/src/tests/test_unit/test_server.py @@ -21,7 +21,7 @@ @pytest.fixture def mock_webhook_request_model(): - projects_v2_item = ProjectV2Item(project_node_id="node_id", node_id="node_id") + projects_v2_item = ProjectV2Item(id=1, project_node_id="node_id", node_id="node_id") sender = Sender(node_id="node_id") return WebhookRequest( projects_v2_item=projects_v2_item, action="edited", sender=sender, changes=Changes(body=Body(to="placeholder")) @@ -30,7 +30,7 @@ def mock_webhook_request_model(): async def test_process_edition_body_changes(mock_webhook_request_model): mock_webhook_request_model.changes = Changes(body=Body(to="We need to pet more cats")) - expected_object = ProjectItemEditedBody("node_id", "node_id", "We need to pet more cats") + expected_object = ProjectItemEditedBody(1, "node_id", "node_id", "We need to pet more cats") assert await process_edition(mock_webhook_request_model) == expected_object @@ -40,7 +40,7 @@ async def test_process_edition_assignees_changed(mock_fetch_assignees, mock_webh mock_webhook_request_model.changes = Changes(field_value=FieldValue(field_name="Assignees", field_type="assignees")) new_assignees = ["Kubaryt", "Salieri", "Aniela"] mock_fetch_assignees.return_value = new_assignees - expected_object = ProjectItemEditedAssignees("node_id", "node_id", new_assignees) + expected_object = ProjectItemEditedAssignees(1, "node_id", "node_id", new_assignees) assert await process_edition(mock_webhook_request_model) == expected_object @@ -50,7 +50,7 @@ async def test_process_edition_title_changed(mock_fetch_item_name, mock_webhook_ mock_webhook_request_model.changes = Changes(field_value=FieldValue(field_name="Title", field_type="title")) new_item_name = "ActuallyNotFunAtAll" mock_fetch_item_name.return_value = new_item_name - expected_object = ProjectItemEditedTitle("node_id", "node_id", new_item_name) + expected_object = ProjectItemEditedTitle(1, "node_id", "node_id", new_item_name) assert await process_edition(mock_webhook_request_model) == expected_object @@ -61,7 +61,7 @@ async def test_process_edition_single_select_changed(mock_webhook_request_model) field_name="Size", field_type="single_select", to=FieldValueTo(name="Smol like lil kitten") ) ) - expected_object = ProjectItemEditedSingleSelect("node_id", "node_id", "Smol like lil kitten", "Size") + expected_object = ProjectItemEditedSingleSelect(1, "node_id", "node_id", "Smol like lil kitten", "Size") assert await process_edition(mock_webhook_request_model) == expected_object @@ -71,14 +71,14 @@ async def test_process_edition_iteration_changed(mock_webhook_request_model): mock_webhook_request_model.changes = Changes( field_value=FieldValue(field_name="Iteration", field_type="iteration", to=FieldValueTo(title=new_title)) ) - expected_object = ProjectItemEditedSingleSelect("node_id", "node_id", new_title, "Iteration") + expected_object = ProjectItemEditedSingleSelect(1, "node_id", "node_id", new_title, "Iteration") assert await process_edition(mock_webhook_request_model) == expected_object @patch("src.server.process_edition", new_callable=AsyncMock) async def test_process_action_process_edition(mock_process_edition, mock_webhook_request_model): - test_event = SimpleProjectItemEvent("node_id", "node_id", "created") + test_event = SimpleProjectItemEvent(1, "node_id", "node_id", "created") mock_process_edition.return_value = test_event assert await process_action(mock_webhook_request_model) == test_event @@ -87,7 +87,7 @@ async def test_process_action_process_edition(mock_process_edition, mock_webhook @patch("src.server.process_edition", new_callable=AsyncMock) async def test_process_action_simple_event(mock_process_edition, mock_webhook_request_model): mock_webhook_request_model.action = "created" - test_event = SimpleProjectItemEvent("node_id", "node_id", "created") + test_event = SimpleProjectItemEvent(1, "node_id", "node_id", "created") mock_process_edition.return_value = test_event assert await process_action(mock_webhook_request_model) == test_event diff --git a/src/utils/data_types.py b/src/utils/data_types.py index 2306bf6..49d5c7e 100644 --- a/src/utils/data_types.py +++ b/src/utils/data_types.py @@ -4,7 +4,7 @@ from hikari import ForumTag, GuildForumChannel, GuildPublicThread from hikari.impl import RESTClientImpl -from pydantic import BaseModel, ConfigDict, model_validator +from pydantic import BaseModel, ConfigDict, Field, model_validator from pydantic_core import PydanticCustomError from src.utils.discord_rest_client import fetch_forum_channel, get_new_tag @@ -29,6 +29,9 @@ class SingleSelectType(Enum): @dataclass class ProjectItemEvent: + # Used for appending link to Discord post + item_id: int + # Used for request to GitHub API node_id: str sender: str @@ -46,8 +49,8 @@ async def process( class SimpleProjectItemEvent(ProjectItemEvent): - def __init__(self, node_id: str, sender: str, action_type: str): - super().__init__(node_id, sender) + def __init__(self, item_id: int, node_id: str, sender: str, action_type: str): + super().__init__(item_id, node_id, sender) self.event_type = SimpleProjectItemEventType(action_type) async def process( @@ -78,8 +81,8 @@ async def process( class ProjectItemEditedBody(ProjectItemEvent): - def __init__(self, node_id: str, editor: str, new_body: str): - super().__init__(node_id, editor) + def __init__(self, item_id: int, node_id: str, editor: str, new_body: str): + super().__init__(item_id, node_id, editor) self.new_body = new_body async def process( @@ -97,8 +100,8 @@ async def process( class ProjectItemEditedAssignees(ProjectItemEvent): - def __init__(self, node_id: str, editor: str, new_assignees: list[str]): - super().__init__(node_id, editor) + def __init__(self, item_id: int, node_id: str, editor: str, new_assignees: list[str]): + super().__init__(item_id, node_id, editor) self.new_assignees = new_assignees async def process( @@ -126,8 +129,8 @@ async def process( class ProjectItemEditedTitle(ProjectItemEvent): - def __init__(self, node_id: str, editor: str, new_name: str): - super().__init__(node_id, editor) + def __init__(self, item_id: int, node_id: str, editor: str, new_name: str): + super().__init__(item_id, node_id, editor) self.new_title = new_name async def process( @@ -143,8 +146,8 @@ async def process( class ProjectItemEditedSingleSelect(ProjectItemEvent): - def __init__(self, node_id: str, editor: str, new_value: str, field_name: str): - super().__init__(node_id, editor) + def __init__(self, item_id: int, node_id: str, editor: str, new_value: str, field_name: str): + super().__init__(item_id, node_id, editor) self.new_value = new_value self.value_type = SingleSelectType(field_name) @@ -186,8 +189,9 @@ async def process( class ProjectV2Item(BaseModel): - project_node_id: str + item_id: int = Field(alias="id") node_id: str + project_node_id: str model_config = ConfigDict(extra="allow") diff --git a/src/utils/misc.py b/src/utils/misc.py index 01ca2e9..800e68b 100644 --- a/src/utils/misc.py +++ b/src/utils/misc.py @@ -25,6 +25,12 @@ def retrieve_discord_id(node_id: str) -> str | None: return mapping.get(node_id, None) +def create_item_link(item_id: int) -> str: + organization_name = os.getenv("GITHUB_ORGANIZATION_NAME", "my-org") + project_number = os.getenv("GITHUB_PROJECT_NUMBER", "1") + return f"https://github.com/orgs/{organization_name}/projects/{project_number}?pane=issue&item_id={item_id}" + + class BotPrefixFilter(logging.Filter): def filter(self, record: logging.LogRecord) -> bool: record.msg = f"[BOT] {record.msg}" From 0e66bb651a8aadd8e1ee644ceeb4c8a64eb72665 Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Wed, 26 Nov 2025 19:19:43 +0100 Subject: [PATCH 42/44] refactor: Spawn task for process_update function in bot.py --- src/bot.py | 14 ++++----- src/main.py | 20 ++++--------- src/tests/test_integration/test_bot.py | 12 +++++++- src/tests/test_unit/test_bot.py | 41 +++++++++++--------------- src/utils/misc.py | 11 +++++++ 5 files changed, 52 insertions(+), 46 deletions(-) diff --git a/src/bot.py b/src/bot.py index 2441915..b598a8e 100644 --- a/src/bot.py +++ b/src/bot.py @@ -8,7 +8,7 @@ from src.utils.discord_rest_client import fetch_forum_channel, get_post_id from src.utils.error import ForumChannelNotFound from src.utils.github_api import fetch_item_name -from src.utils.misc import SharedForumChannel, bot_logger, create_item_link, retrieve_discord_id +from src.utils.misc import SharedForumChannel, bot_logger, create_item_link, handle_task_exception, retrieve_discord_id async def run(state: asyncio.Queue[ProjectItemEvent], stop_after_one_event: bool = False): @@ -25,10 +25,11 @@ async def run(state: asyncio.Queue[ProjectItemEvent], stop_after_one_event: bool shared_forum_channel = SharedForumChannel(forum_channel) while True: - try: - await process_update(client, forum_channel_id, discord_guild_id, shared_forum_channel, state) - except Exception as error: - bot_logger.error(f"Error processing update: {error}") + event = await state.get() + update_task = asyncio.create_task( + process_update(client, forum_channel_id, discord_guild_id, shared_forum_channel, event) + ) + update_task.add_done_callback(lambda task: handle_task_exception(task, "Error processing update:")) if stop_after_one_event: break @@ -38,9 +39,8 @@ async def process_update( forum_channel_id: int, discord_guild_id: int, shared_forum_channel: SharedForumChannel, - state: asyncio.Queue[ProjectItemEvent], + event: ProjectItemEvent, ): - event = await state.get() bot_logger.info(f"Processing event for item: {event.node_id}") post_id_or_post = await get_post_id(event.node_id, discord_guild_id, forum_channel_id, client) diff --git a/src/main.py b/src/main.py index d8865c5..e2a6f55 100644 --- a/src/main.py +++ b/src/main.py @@ -7,7 +7,7 @@ from fastapi import FastAPI from src.bot import run -from src.utils.misc import server_logger +from src.utils.misc import handle_task_exception def main(): @@ -20,26 +20,16 @@ def main(): async def lifespan(app: FastAPI): # startup app.update_queue = asyncio.Queue() - task = asyncio.create_task(run(app.update_queue)) - task.add_done_callback(handle_task_exception) + bot_task = asyncio.create_task(run(app.update_queue)) + bot_task.add_done_callback(lambda task: handle_task_exception(task, "Bot task crashed:")) yield # shutdown - task.cancel() + bot_task.cancel() try: - await task + await bot_task except asyncio.CancelledError: pass -def handle_task_exception(task: asyncio.Task): - try: - exception = task.exception() - except asyncio.CancelledError: - return - - if exception: - server_logger.error(f"Bot task crashed: {exception}") - - if __name__ == "__main__": main() diff --git a/src/tests/test_integration/test_bot.py b/src/tests/test_integration/test_bot.py index b86966e..93b92e5 100644 --- a/src/tests/test_integration/test_bot.py +++ b/src/tests/test_integration/test_bot.py @@ -53,5 +53,15 @@ async def test_basic_event_only_creation( mock_fetch_item_name2.return_value = "audacity4" update_queue = asyncio.Queue() await update_queue.put(ProjectItemEvent(item_id=123, node_id="node_id", sender="test_sender")) + await run(update_queue, stop_after_one_event=True) - mock_create_forum_post.assert_called() + + for _ in range(999): # up to ~1 seconds total + try: + mock_create_forum_post.assert_called() + break + except AssertionError: + pass + await asyncio.sleep(0.001) + else: + mock_create_forum_post.assert_called() diff --git a/src/tests/test_unit/test_bot.py b/src/tests/test_unit/test_bot.py index dbfe232..b5946d1 100644 --- a/src/tests/test_unit/test_bot.py +++ b/src/tests/test_unit/test_bot.py @@ -56,18 +56,15 @@ async def test_process_no_post( mock_get_post_id.return_value = None mock_retrieve_discord_id.return_value = "123456789012345678" mock_create_post.return_value = full_post_mock - state = asyncio.Queue() event = SimpleProjectItemEvent(1, "node_id", "norbiros", "created") - await state.put(event) await bot.process_update( rest_client_mock, 1, 1, shared_forum_channel_mock, - state, + event, ) - assert state.empty() mock_create_post.assert_called_with( event, user_text_mention, shared_forum_channel_mock, rest_client_mock, ["123456789012345678"] ) @@ -88,18 +85,15 @@ async def test_process_post_id_found( mock_get_post_id.return_value = 67 mock_retrieve_discord_id.return_value = "123456789012345678" mock_fetch_channel.return_value = full_post_mock - state = asyncio.Queue() event = SimpleProjectItemEvent(1, "audacity4", "norbiros", "created") - await state.put(event) await bot.process_update( rest_client_mock, 1, 1, shared_forum_channel_mock, - state, + event, ) - assert state.empty() mock_fetch_channel.assert_called_with(67) @@ -119,18 +113,15 @@ async def test_process_post_fetched( ): mock_get_post_id.return_value = full_post_mock mock_retrieve_discord_id.return_value = "123456789012345678" - state = asyncio.Queue() event = SimpleProjectItemEvent(1, "audacity4", "norbiros", "created") - await state.put(event) await bot.process_update( rest_client_mock, 1, 1, shared_forum_channel_mock, - state, + event, ) - assert state.empty() mock_fetch_channel.assert_not_called() mock_create_post.assert_not_called() @@ -149,18 +140,15 @@ async def test_process_post_not_guild_public_thread( ): mock_get_post_id.return_value = post_mock mock_retrieve_discord_id.return_value = "123456789012345678" - state = asyncio.Queue() event = SimpleProjectItemEvent(1, "audacity4", "norbiros", "created") - await state.put(event) await bot.process_update( rest_client_mock, 1, 1, shared_forum_channel_mock, - state, + event, ) - assert state.empty() mock_logger_error.assert_called_with("Post with ID 621 is not a GuildPublicThread.") @@ -180,19 +168,16 @@ async def test_process_post_created_message( ): mock_get_post_id.return_value = full_post_mock mock_retrieve_discord_id.return_value = "123456789012345678" - state = asyncio.Queue() event = SimpleProjectItemEvent(1, "audacity4", "norbiros", "archived") mock_event_process.return_value = "Test message content" - await state.put(event) await bot.process_update( rest_client_mock, 1, 1, shared_forum_channel_mock, - state, + event, ) - assert state.empty() mock_create_message.assert_called_with(621, "Test message content", user_mentions=["123456789012345678"]) @@ -214,9 +199,10 @@ async def test_bot_run( mock_restapp_acquire.return_value = RestClientContextManagerMock(rest_client_mock) mock_fetch_forum_channel.return_value = forum_channel_mock state = asyncio.Queue() + await state.put("event") await bot.run(state, stop_after_one_event=True) - mock_process_update.assert_called_with(rest_client_mock, 1, 2, ANY, state) + mock_process_update.assert_called_with(rest_client_mock, 1, 2, ANY, "event") @patch("src.bot.fetch_forum_channel", new_callable=AsyncMock) @@ -239,7 +225,7 @@ async def test_bot_run_forum_channel_is_none( await bot.run(state, stop_after_one_event=True) -@patch.object(logging.Logger, "error") +@patch("src.bot.bot_logger.error") @patch("src.bot.process_update", new_callable=AsyncMock) @patch("src.bot.fetch_forum_channel", new_callable=AsyncMock) @patch.object(RESTApp, "acquire") @@ -260,6 +246,15 @@ async def test_bot_run_exception_during_process( mock_fetch_forum_channel.return_value = forum_channel_mock mock_process_update.side_effect = Exception("Some error occurred") state = asyncio.Queue() + await state.put("event") await bot.run(state, stop_after_one_event=True) - mock_logger_error.assert_called_with("Error processing update: Some error occurred") + for _ in range(500): # up to ~0.5 seconds total + try: + mock_logger_error.assert_called_with("Error processing update: Some error occurred") + break + except AssertionError: + pass + await asyncio.sleep(0.001) + else: + pytest.fail("Expected log 'Error processing update: Some error occurred' not found in output") diff --git a/src/utils/misc.py b/src/utils/misc.py index 800e68b..f01539e 100644 --- a/src/utils/misc.py +++ b/src/utils/misc.py @@ -1,3 +1,4 @@ +import asyncio import logging import os @@ -31,6 +32,16 @@ def create_item_link(item_id: int) -> str: return f"https://github.com/orgs/{organization_name}/projects/{project_number}?pane=issue&item_id={item_id}" +def handle_task_exception(task: asyncio.Task, error_message: str): + try: + exception = task.exception() + except asyncio.CancelledError: + return + + if exception: + bot_logger.error(f"{error_message} {exception}") + + class BotPrefixFilter(logging.Filter): def filter(self, record: logging.LogRecord) -> bool: record.msg = f"[BOT] {record.msg}" From b9e87e737496dacd10f0f0b73cd688e4689a54ee Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Wed, 26 Nov 2025 19:24:58 +0100 Subject: [PATCH 43/44] chore: Rename get_post_id to get_post_id_or_post --- src/bot.py | 4 +-- src/tests/test_unit/test_bot.py | 30 +++++++++---------- .../test_utils/test_discord_rest_client.py | 8 ++--- src/utils/discord_rest_client.py | 2 +- 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/bot.py b/src/bot.py index b598a8e..126a184 100644 --- a/src/bot.py +++ b/src/bot.py @@ -5,7 +5,7 @@ from hikari.impl import RESTClientImpl from src.utils.data_types import ProjectItemEvent -from src.utils.discord_rest_client import fetch_forum_channel, get_post_id +from src.utils.discord_rest_client import fetch_forum_channel, get_post_id_or_post from src.utils.error import ForumChannelNotFound from src.utils.github_api import fetch_item_name from src.utils.misc import SharedForumChannel, bot_logger, create_item_link, handle_task_exception, retrieve_discord_id @@ -43,7 +43,7 @@ async def process_update( ): bot_logger.info(f"Processing event for item: {event.node_id}") - post_id_or_post = await get_post_id(event.node_id, discord_guild_id, forum_channel_id, client) + post_id_or_post = await get_post_id_or_post(event.node_id, discord_guild_id, forum_channel_id, client) author_discord_id = retrieve_discord_id(event.sender) user_mentions = [author_discord_id] if author_discord_id else [] user_text_mention = f"<@{author_discord_id}>" if author_discord_id else "nieznany użytkownik" diff --git a/src/tests/test_unit/test_bot.py b/src/tests/test_unit/test_bot.py index b5946d1..62a85d5 100644 --- a/src/tests/test_unit/test_bot.py +++ b/src/tests/test_unit/test_bot.py @@ -43,9 +43,9 @@ async def test_create_post( @patch("src.bot.create_post", new_callable=AsyncMock) @patch("src.bot.retrieve_discord_id") -@patch("src.bot.get_post_id", new_callable=AsyncMock) +@patch("src.bot.get_post_id_or_post", new_callable=AsyncMock) async def test_process_no_post( - mock_get_post_id, + mock_get_post_id_or_post, mock_retrieve_discord_id, mock_create_post, rest_client_mock, @@ -53,7 +53,7 @@ async def test_process_no_post( full_post_mock, user_text_mention, ): - mock_get_post_id.return_value = None + mock_get_post_id_or_post.return_value = None mock_retrieve_discord_id.return_value = "123456789012345678" mock_create_post.return_value = full_post_mock event = SimpleProjectItemEvent(1, "node_id", "norbiros", "created") @@ -72,9 +72,9 @@ async def test_process_no_post( @patch.object(RESTClientImpl, "fetch_channel", new_callable=AsyncMock) @patch("src.bot.retrieve_discord_id") -@patch("src.bot.get_post_id", new_callable=AsyncMock) +@patch("src.bot.get_post_id_or_post", new_callable=AsyncMock) async def test_process_post_id_found( - mock_get_post_id, + mock_get_post_id_or_post, mock_retrieve_discord_id, mock_fetch_channel, rest_client_mock, @@ -82,7 +82,7 @@ async def test_process_post_id_found( full_post_mock, user_text_mention, ): - mock_get_post_id.return_value = 67 + mock_get_post_id_or_post.return_value = 67 mock_retrieve_discord_id.return_value = "123456789012345678" mock_fetch_channel.return_value = full_post_mock event = SimpleProjectItemEvent(1, "audacity4", "norbiros", "created") @@ -100,9 +100,9 @@ async def test_process_post_id_found( @patch("src.bot.create_post", new_callable=AsyncMock) @patch.object(RESTClientImpl, "fetch_channel", new_callable=AsyncMock) @patch("src.bot.retrieve_discord_id") -@patch("src.bot.get_post_id", new_callable=AsyncMock) +@patch("src.bot.get_post_id_or_post", new_callable=AsyncMock) async def test_process_post_fetched( - mock_get_post_id, + mock_get_post_id_or_post, mock_retrieve_discord_id, mock_fetch_channel, mock_create_post, @@ -111,7 +111,7 @@ async def test_process_post_fetched( full_post_mock, user_text_mention, ): - mock_get_post_id.return_value = full_post_mock + mock_get_post_id_or_post.return_value = full_post_mock mock_retrieve_discord_id.return_value = "123456789012345678" event = SimpleProjectItemEvent(1, "audacity4", "norbiros", "created") await bot.process_update( @@ -128,9 +128,9 @@ async def test_process_post_fetched( @patch.object(logging.Logger, "error") @patch("src.bot.retrieve_discord_id") -@patch("src.bot.get_post_id", new_callable=AsyncMock) +@patch("src.bot.get_post_id_or_post", new_callable=AsyncMock) async def test_process_post_not_guild_public_thread( - mock_get_post_id, + mock_get_post_id_or_post, mock_retrieve_discord_id, mock_logger_error, rest_client_mock, @@ -138,7 +138,7 @@ async def test_process_post_not_guild_public_thread( post_mock, user_text_mention, ): - mock_get_post_id.return_value = post_mock + mock_get_post_id_or_post.return_value = post_mock mock_retrieve_discord_id.return_value = "123456789012345678" event = SimpleProjectItemEvent(1, "audacity4", "norbiros", "created") await bot.process_update( @@ -155,9 +155,9 @@ async def test_process_post_not_guild_public_thread( @patch.object(SimpleProjectItemEvent, "process", new_callable=AsyncMock) @patch.object(RESTClientImpl, "create_message", new_callable=AsyncMock) @patch("src.bot.retrieve_discord_id") -@patch("src.bot.get_post_id", new_callable=AsyncMock) +@patch("src.bot.get_post_id_or_post", new_callable=AsyncMock) async def test_process_post_created_message( - mock_get_post_id, + mock_get_post_id_or_post, mock_retrieve_discord_id, mock_create_message, mock_event_process, @@ -166,7 +166,7 @@ async def test_process_post_created_message( full_post_mock, user_text_mention, ): - mock_get_post_id.return_value = full_post_mock + mock_get_post_id_or_post.return_value = full_post_mock mock_retrieve_discord_id.return_value = "123456789012345678" event = SimpleProjectItemEvent(1, "audacity4", "norbiros", "archived") mock_event_process.return_value = "Test message content" diff --git a/src/tests/test_unit/test_utils/test_discord_rest_client.py b/src/tests/test_unit/test_utils/test_discord_rest_client.py index 185c915..baf8c95 100644 --- a/src/tests/test_unit/test_utils/test_discord_rest_client.py +++ b/src/tests/test_unit/test_utils/test_discord_rest_client.py @@ -53,7 +53,7 @@ async def test_get_post_id_exist_in_db(mock_shelve_open, rest_client_mock): mock_db = {"node_id": 621} mock_shelve_open.return_value = MockShelf(mock_db) - assert await discord_rest_client.get_post_id("node_id", 1, 1, rest_client_mock) == 621 + assert await discord_rest_client.get_post_id_or_post("node_id", 1, 1, rest_client_mock) == 621 @patch("src.utils.discord_rest_client.fetch_item_name", new_callable=AsyncMock) @@ -67,7 +67,7 @@ async def test_get_post_id_active_thread( mock_fetch_active_threads.return_value = [post_mock] mock_fetch_item_name.return_value = "audacity4" - assert await discord_rest_client.get_post_id("node_id", 1, 1, rest_client_mock) == post_mock + assert await discord_rest_client.get_post_id_or_post("node_id", 1, 1, rest_client_mock) == post_mock assert mock_shelf.get("audacity4") == 621 @@ -89,7 +89,7 @@ async def test_get_post_id_archived_thread( mock_fetch_public_archived_threads.return_value = [post_mock] mock_fetch_item_name.return_value = "audacity4" - assert await discord_rest_client.get_post_id("node_id", 1, 1, rest_client_mock) == post_mock + assert await discord_rest_client.get_post_id_or_post("node_id", 1, 1, rest_client_mock) == post_mock assert mock_shelf.get("audacity4") == 621 @@ -111,5 +111,5 @@ async def test_get_post_id_none( mock_fetch_public_archived_threads.return_value = [] mock_fetch_item_name.return_value = "audacity4" - assert await discord_rest_client.get_post_id("node_id", 1, 1, rest_client_mock) is None + assert await discord_rest_client.get_post_id_or_post("node_id", 1, 1, rest_client_mock) is None assert mock_shelf.get("audacity4") is None diff --git a/src/utils/discord_rest_client.py b/src/utils/discord_rest_client.py index f01f41b..90adee6 100644 --- a/src/utils/discord_rest_client.py +++ b/src/utils/discord_rest_client.py @@ -19,7 +19,7 @@ def get_new_tag(new_tag_name: str, available_tags: list[ForumTag]) -> ForumTag | return new_tag -async def get_post_id( +async def get_post_id_or_post( node_id: str, discord_guild_id: int, forum_channel_id: int, rest_client: RESTClientImpl ) -> int | GuildThreadChannel | None: with shelve.open(os.getenv("POST_ID_DB_PATH", "post_id.db")) as db: From dc9ac129abab4e4a45d605b80efa0ac7877af6bb Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Fri, 28 Nov 2025 19:28:47 +0100 Subject: [PATCH 44/44] refactor: Use `conftest.py` for defining fixtures --- src/tests/{utils.py => conftest.py} | 0 src/tests/test_e2e.py | 8 +------- src/tests/test_integration/test_bot.py | 8 +------- src/tests/test_integration/test_server.py | 2 +- src/tests/test_unit/test_bot.py | 15 ++------------- src/tests/test_unit/test_process.py | 9 --------- .../test_utils/test_discord_rest_client.py | 8 +------- src/tests/test_unit/test_utils/test_misc.py | 5 ----- .../test_utils/test_signature_verification.py | 1 - 9 files changed, 6 insertions(+), 50 deletions(-) rename src/tests/{utils.py => conftest.py} (100%) diff --git a/src/tests/utils.py b/src/tests/conftest.py similarity index 100% rename from src/tests/utils.py rename to src/tests/conftest.py diff --git a/src/tests/test_e2e.py b/src/tests/test_e2e.py index ac96f98..e27ab35 100644 --- a/src/tests/test_e2e.py +++ b/src/tests/test_e2e.py @@ -1,4 +1,3 @@ -# ruff: noqa: F811 ruff recognizes fixture use as argument as redefinition import asyncio import json from logging import Logger @@ -11,13 +10,8 @@ from uvicorn import Config, Server from src.server import app +from src.tests.conftest import MockShelf from src.tests.test_integration.test_bot import RestClientContextManagerMock -from src.tests.utils import ( # noqa: F401 ruff recognizes fixture import as unused - MockShelf, - forum_channel_mock, - full_post_mock, - rest_client_mock, -) from src.utils.signature_verification import generate_signature diff --git a/src/tests/test_integration/test_bot.py b/src/tests/test_integration/test_bot.py index 93b92e5..425a0d5 100644 --- a/src/tests/test_integration/test_bot.py +++ b/src/tests/test_integration/test_bot.py @@ -1,4 +1,3 @@ -# ruff: noqa: F811 ruff recognizes fixture use as argument as redefinition import asyncio from unittest.mock import AsyncMock, mock_open, patch @@ -6,12 +5,7 @@ from hikari.impl import RESTClientImpl from src.bot import run -from src.tests.utils import ( # noqa: F401 ruff recognizes fixture import as unused - MockShelf, - RestClientContextManagerMock, - forum_channel_mock, - rest_client_mock, -) +from src.tests.conftest import MockShelf, RestClientContextManagerMock from src.utils.data_types import ProjectItemEvent diff --git a/src/tests/test_integration/test_server.py b/src/tests/test_integration/test_server.py index c87ac5f..9d5d688 100644 --- a/src/tests/test_integration/test_server.py +++ b/src/tests/test_integration/test_server.py @@ -8,7 +8,7 @@ from fastapi.testclient import TestClient from src.server import app -from src.tests.utils import MockResponse, MockShelf +from src.tests.conftest import MockResponse, MockShelf from src.utils.signature_verification import generate_signature test_client = TestClient(app) diff --git a/src/tests/test_unit/test_bot.py b/src/tests/test_unit/test_bot.py index 62a85d5..e7c27bd 100644 --- a/src/tests/test_unit/test_bot.py +++ b/src/tests/test_unit/test_bot.py @@ -1,4 +1,3 @@ -# ruff: noqa: F811 ruff recognizes fixture use as argument as redefinition import asyncio import logging from unittest.mock import ANY, AsyncMock, patch @@ -8,18 +7,8 @@ from hikari.impl import RESTClientImpl from src import bot -from src.tests.utils import ( # noqa: F401 ruff recognizes fixture import as unused - RestClientContextManagerMock, - forum_channel_mock, - full_post_mock, - post_mock, - rest_client_mock, - shared_forum_channel_mock, - user_text_mention, -) -from src.utils.data_types import ( - SimpleProjectItemEvent, -) +from src.tests.conftest import RestClientContextManagerMock +from src.utils.data_types import SimpleProjectItemEvent from src.utils.error import ForumChannelNotFound diff --git a/src/tests/test_unit/test_process.py b/src/tests/test_unit/test_process.py index 521854d..50eb1a2 100644 --- a/src/tests/test_unit/test_process.py +++ b/src/tests/test_unit/test_process.py @@ -1,17 +1,8 @@ -# ruff: noqa: F811 ruff recognizes fixture use as argument as redefinition from unittest.mock import AsyncMock, mock_open, patch from hikari import ForumTag, Snowflake from hikari.impl import RESTClientImpl -from src.tests.utils import ( # noqa: F401 ruff recognizes fixture import as unused - forum_channel_mock, - full_post_mock, - post_mock, - rest_client_mock, - shared_forum_channel_mock, - user_text_mention, -) from src.utils.data_types import ( ProjectItemEditedAssignees, ProjectItemEditedBody, diff --git a/src/tests/test_unit/test_utils/test_discord_rest_client.py b/src/tests/test_unit/test_utils/test_discord_rest_client.py index baf8c95..a336fe0 100644 --- a/src/tests/test_unit/test_utils/test_discord_rest_client.py +++ b/src/tests/test_unit/test_utils/test_discord_rest_client.py @@ -1,15 +1,9 @@ -# ruff: noqa: F811 ruff recognizes fixture use as argument as redefinition from unittest.mock import AsyncMock, patch from hikari import ForumTag, Snowflake from hikari.impl import RESTClientImpl -from src.tests.utils import ( # noqa: F401 ruff recognizes fixture import as unused - MockShelf, - forum_channel_mock, - post_mock, - rest_client_mock, -) +from src.tests.conftest import MockShelf from src.utils import discord_rest_client diff --git a/src/tests/test_unit/test_utils/test_misc.py b/src/tests/test_unit/test_utils/test_misc.py index fbab50d..35c1857 100644 --- a/src/tests/test_unit/test_utils/test_misc.py +++ b/src/tests/test_unit/test_utils/test_misc.py @@ -1,13 +1,8 @@ -# ruff: noqa: F811 ruff recognizes fixture use as argument as redefinition import asyncio import logging from io import StringIO from unittest.mock import mock_open, patch -from src.tests.utils import ( # noqa: F401 ruff recognizes fixture import as unused - forum_channel_mock, - shared_forum_channel_mock, -) from src.utils import misc diff --git a/src/tests/test_unit/test_utils/test_signature_verification.py b/src/tests/test_unit/test_utils/test_signature_verification.py index 107a19b..9a622ba 100644 --- a/src/tests/test_unit/test_utils/test_signature_verification.py +++ b/src/tests/test_unit/test_utils/test_signature_verification.py @@ -1,4 +1,3 @@ -# ruff: noqa: F811 ruff recognizes fixture use as argument as redefinition from logging import Logger from unittest.mock import patch