From 117567ee344f468649c07ee3e431d246c9a78fba Mon Sep 17 00:00:00 2001 From: Aleksandra Ovchinnikova Date: Mon, 10 Nov 2025 20:11:29 -0500 Subject: [PATCH 1/3] MPT-15211 mrok web inspector for agent sidecar --- mrok/agent/sidecar/app.py | 38 +- mrok/agent/sidecar/inspector.py | 170 ++++ mrok/agent/sidecar/main.py | 12 +- mrok/agent/sidecar/store.py | 28 + mrok/cli/commands/agent/run/sidecar.py | 98 ++- mrok/cli/commands/agent/utils.py | 114 +++ mrok/conf.py | 6 + mrok/http/forwarder.py | 98 ++- mrok/logging.py | 22 + pyproject.toml | 3 + snapshot_report.html | 807 ++++++++++++++++++ .../test_inspector/test_inspector_app.svg | 200 +++++ .../test_inspector_app_empty_card.svg | 208 +++++ ...t_inspector_app_filed_store_connection.svg | 201 +++++ .../test_inspector_app_open_card.svg | 211 +++++ tests/agent/sidecar/test_app.py | 28 + tests/agent/sidecar/test_inspector.py | 146 ++++ tests/agent/sidecar/test_main.py | 3 +- tests/agent/sidecar/test_store.py | 25 + tests/cli/agent/test_run.py | 36 + tests/cli/agent/test_utils.py | 51 ++ tests/conftest.py | 8 + tests/types.py | 16 + uv.lock | 600 +++++++++++++ 24 files changed, 3104 insertions(+), 25 deletions(-) create mode 100644 mrok/agent/sidecar/inspector.py create mode 100644 mrok/agent/sidecar/store.py create mode 100644 mrok/cli/commands/agent/utils.py create mode 100644 snapshot_report.html create mode 100644 tests/agent/sidecar/__snapshots__/test_inspector/test_inspector_app.svg create mode 100644 tests/agent/sidecar/__snapshots__/test_inspector/test_inspector_app_empty_card.svg create mode 100644 tests/agent/sidecar/__snapshots__/test_inspector/test_inspector_app_filed_store_connection.svg create mode 100644 tests/agent/sidecar/__snapshots__/test_inspector/test_inspector_app_open_card.svg create mode 100644 tests/agent/sidecar/test_inspector.py create mode 100644 tests/agent/sidecar/test_store.py create mode 100644 tests/cli/agent/test_utils.py create mode 100644 tests/types.py diff --git a/mrok/agent/sidecar/app.py b/mrok/agent/sidecar/app.py index 57e39bd..3d12a9a 100644 --- a/mrok/agent/sidecar/app.py +++ b/mrok/agent/sidecar/app.py @@ -1,9 +1,12 @@ import asyncio +import datetime import logging +import time from collections.abc import Awaitable, Callable from pathlib import Path from typing import Any +from mrok.agent.sidecar.store import RequestStore from mrok.http.forwarder import ForwardAppBase logger = logging.getLogger("mrok.proxy") @@ -15,10 +18,15 @@ class ForwardApp(ForwardAppBase): def __init__( - self, target_address: str | Path | tuple[str, int], read_chunk_size: int = 65536 + self, + target_address: str | Path | tuple[str, int], + read_chunk_size: int = 65536, + store: RequestStore | None = None, ) -> None: super().__init__(read_chunk_size=read_chunk_size) self._target_address = target_address + self._store = store + self.capture_body = True async def select_backend( self, @@ -28,3 +36,31 @@ async def select_backend( if isinstance(self._target_address, tuple): return await asyncio.open_connection(*self._target_address) return await asyncio.open_unix_connection(str(self._target_address)) + + async def on_response_complete( + self, + scope: Scope, + status: int, + headers: list[tuple[bytes, bytes]], + headers_out: list[tuple[bytes, bytes]], + start_time: float, + request_buffer: bytearray, + response_buffer: bytearray, + ) -> None: + if self._store is None: + return + + request_data = { + "method": scope["method"], + "path": scope["path"], + "raw_path": scope["raw_path"], + "query_string": scope["query_string"], + "request_body": request_buffer, + "request_headers": headers, + "response_body": response_buffer, + "response_headers": headers_out, + "status": status, + "start": datetime.datetime.fromtimestamp(start_time), + "duration": time.time() - start_time, + } + self._store.add(request_data) diff --git a/mrok/agent/sidecar/inspector.py b/mrok/agent/sidecar/inspector.py new file mode 100644 index 0000000..991bfc8 --- /dev/null +++ b/mrok/agent/sidecar/inspector.py @@ -0,0 +1,170 @@ +import httpx +import typer +from rich.panel import Panel +from rich.syntax import Syntax +from rich.text import Text +from textual.app import App, ComposeResult +from textual.containers import Container, Horizontal, Vertical, VerticalScroll +from textual.reactive import reactive +from textual.widget import Widget +from textual.widgets import DataTable, Footer, Header, Static, TabbedContent, TabPane + +from mrok.conf import get_settings + + +def color_for_status(status: int) -> str: + if 200 <= status < 300: + return "green" + return "red" + + +class RequestDetailCard(Widget): + request = reactive(None, recompose=True) + + def compose(self): + with Vertical(): + if self.request: + yield from self._build_card() + else: + yield Static("Select a request to view details.", classes="summary") + + def _build_card(self): + r = self.request or {} # type: ignore + method = r.get("method", "") + path = r.get("path", "") + status = int(r.get("status", 0)) + duration = int(r.get("duration", 0) * 1000) + parameters = r.get("query_string") + req_headers = r.get("request_headers", []) + res_headers = r.get("response_headers", []) + req_body = r.get("request_body") + res_body = r.get("response_body") + + summary_text = Text() + summary_text.append(f"{method} ", style="bold cyan") + summary_text.append(f"{path}\n", style="bold") + summary_text.append("Status: ", style="bold") + summary_text.append(f"{status} ", style=f"bold {color_for_status(status)}") + summary_text.append(f"• Duration: {duration} ms", style="dim") + req_headers_formatted = "\n".join(f"[yellow]{k}[/]: {v}" for k, v in req_headers) + res_headers_formatted = "\n".join(f"[yellow]{k}[/]: {v}" for k, v in res_headers) + + yield Static(Panel(summary_text, title="Request Summary", expand=False)) + with TabbedContent(initial="response"): + with TabPane("Response body", id="response"): + if res_body: + yield VerticalScroll( + Static(Syntax(res_body, "json", theme="monokai", word_wrap=True)) + ) + else: + yield Static("No body") + with TabPane("Request headers", id="req_headers"): + yield VerticalScroll( + Static(Panel(req_headers_formatted or "", title="Headers")) + ) + with TabPane("Response headers", id="res_headers"): + yield VerticalScroll( + Static(Panel(res_headers_formatted or "", title="Headers")) + ) + with TabPane("Request body", id="request"): + if req_body: + yield VerticalScroll( + Static(Syntax(req_body, "json", theme="monokai", word_wrap=True)) + ) + else: + yield Static("No body") + with TabPane("Request parameters", id="params"): + yield VerticalScroll(Static(parameters or "No parameters")) + + +class InspectorApp(App): + TITLE = "mrok Agent Web Inspection Interface" + BINDINGS = [("d", "toggle_dark", "Toggle dark mode")] + CSS = """ + #app-grid { + layout: grid; + grid-size: 2; + grid-columns: 1fr 2fr; + grid-rows: 1fr; + height: 100%; + width: 100%; + } + + #left-pane { + background: $panel; + padding: 1; + border: round $accent; + height: 100%; + } + + #right-pane { + background: $boost; + padding: 1; + border: round $accent-darken-1; + height: 100%; + overflow: auto; + } + + #right-pane > Static { + background: $surface; + height: 100%; + overflow: auto; + padding: 1; + } + """ + + def __init__(self): + super().__init__() + self.table = DataTable(zebra_stripes=True, cursor_type="row") + self.detail_card = RequestDetailCard() + self.refresh_interval = 10.0 + self.requests = {} + self.last_request = 0 + self.settings = get_settings() + self.store_port = self.settings.sidecar.store_port + + def compose(self) -> ComposeResult: + yield Header() + with Container(id="app-grid"): + with VerticalScroll(id="left-pane"): + yield self.table + with Horizontal(id="right-pane"): + yield self.detail_card + yield Footer() + + async def on_mount(self): + self.table.add_columns(("ID", "id"), "Method", "Status", "Path", "Duration (ms)") + await self.refresh_data() + self.set_interval(self.refresh_interval, self.refresh_data) + + async def refresh_data(self): + try: + async with httpx.AsyncClient() as client: + query = f"?offset={self.last_request}" if self.last_request else "" + resp = await client.get(f"http://127.0.0.1:{self.store_port}/requests/{query}") + requests = resp.json() + except Exception as e: + typer.echo(f"[red]Refresh_data failed:[/red] {e}") + return + + for r in requests: + self.table.add_row( + r["id"], + r.get("method", ""), + r.get("status", ""), + r.get("path", ""), + int((r.get("duration", 0)) * 1000), + key=str(r["id"]), + ) + self.requests[str(r["id"])] = r + self.last_request = max(self.last_request, r["id"]) + self.table.sort("id", reverse=True) + + def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + req_id = event.row_key.value + self.detail_card.request = self.requests.get(req_id) + + +if __name__ == "__main__": # pragma: no cover + app = InspectorApp() + app.run() diff --git a/mrok/agent/sidecar/main.py b/mrok/agent/sidecar/main.py index 001c049..020bdb6 100644 --- a/mrok/agent/sidecar/main.py +++ b/mrok/agent/sidecar/main.py @@ -4,13 +4,18 @@ from pathlib import Path from mrok.agent.sidecar.app import ForwardApp +from mrok.agent.sidecar.store import RequestStore from mrok.http.config import MrokBackendConfig from mrok.http.master import Master from mrok.http.server import MrokServer -def run_sidecar(identity_file: str, target_addr: str | Path | tuple[str, int]): - config = MrokBackendConfig(ForwardApp(target_addr), identity_file) +def run_sidecar( + identity_file: str, + target_addr: str | Path | tuple[str, int], + store: RequestStore | None = None, +): + config = MrokBackendConfig(ForwardApp(target_addr, store=store), identity_file) server = MrokServer(config) with contextlib.suppress(KeyboardInterrupt, asyncio.CancelledError): server.run() @@ -21,7 +26,8 @@ def run( target_addr: str | Path | tuple[str, int], workers=4, reload=False, + store: RequestStore | None = None, ): - start_fn = partial(run_sidecar, identity_file, target_addr) + start_fn = partial(run_sidecar, identity_file, target_addr, store) master = Master(start_fn, workers=workers, reload=reload) master.run() diff --git a/mrok/agent/sidecar/store.py b/mrok/agent/sidecar/store.py new file mode 100644 index 0000000..4859ea6 --- /dev/null +++ b/mrok/agent/sidecar/store.py @@ -0,0 +1,28 @@ +from collections import deque +from multiprocessing import Lock +from multiprocessing.managers import BaseManager +from typing import Any + +from mrok.conf import get_settings + + +class RequestStore: + def __init__(self): + settings = get_settings() + self._requests = deque(maxlen=settings.sidecar.store_size) + self._lock = Lock() + self._request_counter = 1 + + def add(self, request: dict[str, Any]) -> None: + with self._lock: + request["id"] = self._request_counter + self._requests.appendleft(request) + self._request_counter += 1 + + def get_all(self, offset: int = 0) -> list[dict]: + with self._lock: + return [request for request in self._requests if request["id"] > offset] + + +class RequestStoreManager(BaseManager): + pass diff --git a/mrok/cli/commands/agent/run/sidecar.py b/mrok/cli/commands/agent/run/sidecar.py index ca3e4eb..6dd9a99 100644 --- a/mrok/cli/commands/agent/run/sidecar.py +++ b/mrok/cli/commands/agent/run/sidecar.py @@ -3,15 +3,24 @@ from typing import Annotated import typer +from rich.panel import Panel from mrok.agent import sidecar - - -def number_of_workers(): - return (multiprocessing.cpu_count() * 2) + 1 - +from mrok.agent.sidecar.store import RequestStore, RequestStoreManager +from mrok.cli.commands.agent.utils import ( + get_textual_command, + inspector_port, + number_of_workers, + run_inspect_api, + run_textual, + store_api_port, +) +from mrok.cli.rich import get_console +from mrok.conf import get_settings default_workers = number_of_workers() +default_inspector_port = inspector_port() +default_store_port = store_api_port() def register(app: typer.Typer) -> None: @@ -43,6 +52,32 @@ def run_sidecar( show_default=True, ), ] = False, + inspect: Annotated[ + bool, + typer.Option( + "--inspect", + "-i", + help="Enable inspection. Default: False", + show_default=True, + ), + ] = False, + textual_port: Annotated[ + int, + typer.Option( + "--inspector-port", + help=f"Port for Web inspector. Default: {default_inspector_port}", + show_default=True, + ), + ] = default_inspector_port, + console_mode: Annotated[ + bool, + typer.Option( + "--console", + "-c", + help="Enable inspector console mode. Default: False", + show_default=True, + ), + ] = False, ): """Run a Sidecar Proxy to expose a web application through OpenZiti.""" if ":" in str(target): @@ -51,4 +86,55 @@ def run_sidecar( else: target_addr = str(target) # type: ignore - sidecar.run(str(identity_file), target_addr, workers=workers, reload=reload) + if inspect: + settings = get_settings() + console = get_console() + + RequestStoreManager.register("RequestStore", RequestStore) + with RequestStoreManager() as manager: + request_store = manager.RequestStore() # type: ignore + + if console_mode: + inspector_proc = multiprocessing.Process( + target=run_inspect_api, + args=(request_store, settings.sidecar.store_port), + daemon=True, + ) + inspector_proc.start() + console.print( + Panel( + f"[bold yellow]To open inspector, run in a new terminal:" + f"[/bold yellow]\n[bold green]{get_textual_command()}[/bold green]", + title="mrok Inspector", + border_style="cyan", + ) + ) + else: + inspector_proc = multiprocessing.Process( + target=run_textual, + args=(textual_port, settings.sidecar.store_port, request_store), + daemon=True, + ) + inspector_proc.start() + console.print( + Panel( + f"Web inspector running at http://localhost:{textual_port}", + title="mrok Web Inspector", + border_style="cyan", + ) + ) + + try: + sidecar.run( + str(identity_file), + target_addr, + workers=workers, + reload=reload, + store=request_store, + ) + finally: + if inspector_proc: + inspector_proc.terminate() + console.print("mrok Inspector stopped") + else: + sidecar.run(str(identity_file), target_addr, workers=workers, reload=reload) diff --git a/mrok/cli/commands/agent/utils.py b/mrok/cli/commands/agent/utils.py new file mode 100644 index 0000000..585764a --- /dev/null +++ b/mrok/cli/commands/agent/utils.py @@ -0,0 +1,114 @@ +import asyncio +import multiprocessing +import signal + +import uvicorn +from aiohttp import web +from fastapi import FastAPI +from textual_serve.server import Server + +from mrok.agent.sidecar.store import RequestStore +from mrok.conf import get_settings +from mrok.logging import setup_inspector_logging + + +def number_of_workers() -> int: + return (multiprocessing.cpu_count() * 2) + 1 + + +def inspector_port() -> int: + settings = get_settings() + return settings.sidecar.textual_port + + +def store_api_port() -> int: + settings = get_settings() + return settings.sidecar.store_port + + +def get_textual_command() -> str: + settings = get_settings() + return settings.sidecar.textual_command + + +def create_inspection_server(store: RequestStore) -> FastAPI: + inspect_api = FastAPI() + + @inspect_api.get("/requests/") + async def list_requests(offset: int = 0) -> list[dict]: + return store.get_all(offset=offset) + + return inspect_api + + +def run_inspect_api(store: RequestStore, port: int, log_level: str = "warning") -> None: + inspect_api = create_inspection_server(store) + uvicorn.run(inspect_api, port=port, log_level=log_level) + + +class MServer(Server): + def __init__(self, command: str, port: int, api_port: int, store: RequestStore): + super().__init__(command, port=port, title="MROK Web inspector") + self.api_port = api_port + self.store = store + self.store_api_runner = None + + def initialize_logging(self) -> None: + setup_inspector_logging(self.console) + + def serve(self, debug: bool = False) -> None: # pragma: no cover + self.debug = debug + self.initialize_logging() + + try: + loop = asyncio.get_event_loop() + except Exception: + loop = asyncio.new_event_loop() + + loop.add_signal_handler(signal.SIGINT, self.request_exit) + loop.add_signal_handler(signal.SIGTERM, self.request_exit) + + loop.run_until_complete(self._serve(loop)) + + async def _serve(self, loop: asyncio.AbstractEventLoop): # pragma: no cover + textual_app = await self._make_app() + api_app = create_inspection_server(self.store) + + textual_runner = web.AppRunner(textual_app) + await textual_runner.setup() + textual_site = web.TCPSite(textual_runner, self.host, self.port) + await textual_site.start() + + stop_event = asyncio.Event() + + def stop() -> None: + stop_event.set() + + loop.add_signal_handler(signal.SIGINT, stop) + loop.add_signal_handler(signal.SIGTERM, stop) + + config = uvicorn.Config( + api_app, + host=self.host, + port=self.api_port, + loop="asyncio", + log_level="info", + ) + api_server = uvicorn.Server(config) + + async def start_fastapi(): + await api_server.serve() + + fastapi_task = asyncio.create_task(start_fastapi()) + + try: + await stop_event.wait() + finally: + api_server.should_exit = True + await fastapi_task + await textual_runner.cleanup() + + +def run_textual(port: int, api_port: int, store: RequestStore) -> None: + server = MServer(get_textual_command(), port=port, api_port=api_port, store=store) + server.serve() diff --git a/mrok/conf.py b/mrok/conf.py index 30e629e..eccf589 100644 --- a/mrok/conf.py +++ b/mrok/conf.py @@ -15,6 +15,12 @@ "ssl_verify": False, }, "PAGINATION": {"limit": 50}, + "SIDECAR": { + "textual_port": 4040, + "store_port": 5051, + "store_size": 1000, + "textual_command": "python mrok/agent/sidecar/inspector.py", + }, } _settings = None diff --git a/mrok/http/forwarder.py b/mrok/http/forwarder.py index 2b1f1a5..46bb00a 100644 --- a/mrok/http/forwarder.py +++ b/mrok/http/forwarder.py @@ -1,6 +1,7 @@ import abc import asyncio import logging +import time from collections.abc import Awaitable, Callable from typing import Any @@ -27,6 +28,7 @@ class ForwardAppBase(abc.ABC): def __init__(self, read_chunk_size: int = 65536) -> None: # number of bytes to read per iteration when streaming bodies self._read_chunk_size: int = int(read_chunk_size) + self.capture_body = False @abc.abstractmethod async def select_backend( @@ -42,6 +44,10 @@ async def __call__(self, scope: Scope, receive: ASGIReceive, send: ASGISend) -> Delegates to smaller helper methods for readability. Subclasses only need to implement backend selection. """ + start_time = time.time() + request_buffer = bytearray() if self.capture_body else None + response_buffer = bytearray() if self.capture_body else None + if scope.get("type") != "http": await send({"type": "http.response.start", "status": 500, "headers": []}) await send({"type": "http.response.body", "body": b"Unsupported"}) @@ -65,7 +71,7 @@ async def __call__(self, scope: Scope, receive: ASGIReceive, send: ASGISend) -> await self.write_request_line_and_headers(writer, method, path_qs, headers) - await self.stream_request_body(receive, writer, use_chunked) + await self.stream_request_body(receive, writer, use_chunked, request_buffer) status_line = await reader.readline() if not status_line: @@ -79,7 +85,17 @@ async def __call__(self, scope: Scope, receive: ASGIReceive, send: ASGISend) -> await send({"type": "http.response.start", "status": status, "headers": headers_out}) - await self.stream_response_body(reader, send, raw_headers) + await self.stream_response_body(reader, send, raw_headers, response_buffer) + + await self.on_response_complete( + scope, + status, + headers, + headers_out, + start_time, + request_buffer, + response_buffer, + ) writer.close() await writer.wait_closed() @@ -130,16 +146,23 @@ async def write_request_line_and_headers( await writer.drain() async def stream_request_body( - self, receive: ASGIReceive, writer: asyncio.StreamWriter, use_chunked: bool + self, + receive: ASGIReceive, + writer: asyncio.StreamWriter, + use_chunked: bool, + request_buffer: bytearray | None = None, ) -> None: if use_chunked: - await self.stream_request_chunked(receive, writer) + await self.stream_request_chunked(receive, writer, request_buffer) return - await self.stream_request_until_end(receive, writer) + await self.stream_request_until_end(receive, writer, request_buffer) async def stream_request_chunked( - self, receive: ASGIReceive, writer: asyncio.StreamWriter + self, + receive: ASGIReceive, + writer: asyncio.StreamWriter, + request_buffer: bytearray | None = None, ) -> None: """Send request body to backend using HTTP/1.1 chunked encoding.""" while True: @@ -151,6 +174,9 @@ async def stream_request_chunked( writer.write(body) writer.write(b"\r\n") await writer.drain() + + if request_buffer is not None: + request_buffer.extend(body) if not event.get("more_body", False): break elif event["type"] == "http.disconnect": @@ -161,7 +187,10 @@ async def stream_request_chunked( await writer.drain() async def stream_request_until_end( - self, receive: ASGIReceive, writer: asyncio.StreamWriter + self, + receive: ASGIReceive, + writer: asyncio.StreamWriter, + request_buffer: bytearray | None = None, ) -> None: """Send request body to backend when content length/transfer-encoding already provided (no chunking). @@ -173,6 +202,9 @@ async def stream_request_until_end( if body: writer.write(body) await writer.drain() + + if request_buffer is not None: + request_buffer.extend(body) if not event.get("more_body", False): break elif event["type"] == "http.disconnect": @@ -224,7 +256,12 @@ async def drain_trailers(self, reader: asyncio.StreamReader) -> None: if trailer in (b"\r\n", b"\n", b""): break - async def stream_response_chunked(self, reader: asyncio.StreamReader, send: ASGISend) -> None: + async def stream_response_chunked( + self, + reader: asyncio.StreamReader, + send: ASGISend, + response_buffer: bytearray | None = None, + ) -> None: """Read chunked-encoded response from reader, decode and forward to ASGI send.""" while True: size_line = await reader.readline() @@ -249,11 +286,17 @@ async def stream_response_chunked(self, reader: asyncio.StreamReader, send: ASGI except Exception: logger.warning("failed to read CRLF after chunk from backend") await send({"type": "http.response.body", "body": chunk, "more_body": True}) + if response_buffer is not None: + response_buffer.extend(chunk) await send({"type": "http.response.body", "body": b"", "more_body": False}) async def stream_response_with_content_length( - self, reader: asyncio.StreamReader, send: ASGISend, content_length: int + self, + reader: asyncio.StreamReader, + send: ASGISend, + content_length: int, + response_buffer: bytearray | None = None, ) -> None: """Read exactly content_length bytes and forward to ASGI send events.""" remaining = content_length @@ -266,34 +309,61 @@ async def stream_response_with_content_length( remaining -= len(chunk) more = remaining > 0 await send({"type": "http.response.body", "body": chunk, "more_body": more}) + if response_buffer is not None: + response_buffer.extend(chunk) if not more: sent_final = True if not sent_final: await send({"type": "http.response.body", "body": b"", "more_body": False}) - async def stream_response_until_eof(self, reader: asyncio.StreamReader, send: ASGISend) -> None: + async def stream_response_until_eof( + self, + reader: asyncio.StreamReader, + send: ASGISend, + response_buffer: bytearray | None = None, + ) -> None: """Read from reader until EOF and forward chunks to ASGI send events.""" while True: chunk = await reader.read(self._read_chunk_size) if not chunk: break await send({"type": "http.response.body", "body": chunk, "more_body": True}) + if response_buffer is not None: + response_buffer.extend(chunk) await send({"type": "http.response.body", "body": b"", "more_body": False}) async def stream_response_body( - self, reader: asyncio.StreamReader, send: ASGISend, raw_headers: dict[bytes, bytes] + self, + reader: asyncio.StreamReader, + send: ASGISend, + raw_headers: dict[bytes, bytes], + response_buffer: bytearray | None = None, ) -> None: te = raw_headers.get(b"transfer-encoding", b"").lower() cl = raw_headers.get(b"content-length") if self.is_chunked(te): - await self.stream_response_chunked(reader, send) + await self.stream_response_chunked(reader, send, response_buffer) return content_length = self.parse_content_length(cl) if content_length is not None: - await self.stream_response_with_content_length(reader, send, content_length) + await self.stream_response_with_content_length( + reader, send, content_length, response_buffer + ) return - await self.stream_response_until_eof(reader, send) + await self.stream_response_until_eof(reader, send, response_buffer) + + async def on_response_complete( + self, + scope: Scope, + status: int, + raw_headers: list[tuple[bytes, bytes]], + headers_out: list[tuple[bytes, bytes]], + start_time: float, + request_buffer: bytearray | None, + response_buffer: bytearray | None, + ) -> None: + return diff --git a/mrok/logging.py b/mrok/logging.py index 3474f09..903e777 100644 --- a/mrok/logging.py +++ b/mrok/logging.py @@ -1,5 +1,9 @@ import logging.config +from rich.console import Console +from rich.logging import RichHandler +from textual_serve.server import LogHighlighter + from mrok.conf import Settings @@ -74,3 +78,21 @@ def get_logging_config(settings: Settings, cli_mode: bool = False) -> dict: def setup_logging(settings: Settings, cli_mode: bool = False) -> None: logging_config = get_logging_config(settings, cli_mode) logging.config.dictConfig(logging_config) + + +def setup_inspector_logging(console: Console) -> None: + logging.basicConfig( + level="WARNING", + format="%(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + handlers=[ + RichHandler( + show_path=False, + show_time=True, + rich_tracebacks=True, + tracebacks_show_locals=True, + highlighter=LogHighlighter(), + console=console, + ) + ], + ) diff --git a/pyproject.toml b/pyproject.toml index 8e47537..a392192 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,8 +21,11 @@ dependencies = [ "pydantic>=2.11.7,<3.0.0", "pyfiglet>=1.0.4,<2.0.0", "pyjwt>=2.10.1,<3.0.0", + "pytest-textual-snapshot>=1.1.0,<2.0.0", "pyyaml>=6.0.2,<7.0.0", "rich>=14.1.0,<15.0.0", + "textual>=6.5.0,<7.0.0", + "textual-serve>=1.1.3,<2.0.0", "typer>=0.19.2,<0.20.0", "uvicorn-worker>=0.4.0,<0.5.0", ] diff --git a/snapshot_report.html b/snapshot_report.html new file mode 100644 index 0000000..c9c4475 --- /dev/null +++ b/snapshot_report.html @@ -0,0 +1,807 @@ + + + + + + Textual Snapshot Test Report + + + + + +
+
+
+ +
+
+
+ + 1 snapshots changed + + + 1 snapshots matched + +
+
+
+ + +
+
+
+
+ + test_inspector_app + + /Users/aleksandraovchinnikova/PycharmProjects/mrok/tests/agent/sidecar/test_inspector.py:11 + + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + mrok Agent Web Inspection Interface + + + + + + + + + + mrok Agent Web Inspection Interface +╭──────────────────────────────────────╮╭──────────────────────────────────────────────────────────────────────────────╮ + + ID  Method  Status  Path           Select a request to view details. + 3   GET     401     /extensions/EXT + 2   GET     200     /extensions/ext + + + + + + + + + + + + + + + + + + + + + + + + + + + +╰──────────────────────────────────────╯╰──────────────────────────────────────────────────────────────────────────────╯ + d Toggle dark mode ^p palette + + + + + +
+
+
+ +
+
+ + +
+
+

No history for this test

+

If you're happy with the content on the left, + save it to disk by running pytest with the --snapshot-update flag.

+
Unexpected?
+

+ Snapshots are named after the name of the test you call snap_compare in by default. +
+ If you've renamed a test, the association between the snapshot and the test is lost, + and you'll need to run with --snapshot-update to associate the snapshot + with the new test name. +

+
+
+ +
+ +
+
+
+
+ + + +
+
+ + +
+
+
+
+

If you're happy with the test output, run pytest with the --snapshot-update flag to update the snapshot. +

+
+
+
+
+ +
+
+
+

Report generated at UTC 2025-11-17 02:30:55.331895.

+
+
+
+ +
+ + + + + + + + \ No newline at end of file diff --git a/tests/agent/sidecar/__snapshots__/test_inspector/test_inspector_app.svg b/tests/agent/sidecar/__snapshots__/test_inspector/test_inspector_app.svg new file mode 100644 index 0000000..389e5ec --- /dev/null +++ b/tests/agent/sidecar/__snapshots__/test_inspector/test_inspector_app.svg @@ -0,0 +1,200 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + mrok Agent Web Inspection Interface + + + + + + + + + + mrok Agent Web Inspection Interface +╭──────────────────────────────────────╮╭──────────────────────────────────────────────────────────────────────────────╮ + + ID  Method  Status  Path           Select a request to view details. + 3   GET     401     /extensions/EXT + 2   GET     200     /extensions/ext + + + + + + + + + + + + + + + + + + + + + + + + + + + +╰──────────────────────────────────────╯╰──────────────────────────────────────────────────────────────────────────────╯ + d Toggle dark mode ^p palette + + + diff --git a/tests/agent/sidecar/__snapshots__/test_inspector/test_inspector_app_empty_card.svg b/tests/agent/sidecar/__snapshots__/test_inspector/test_inspector_app_empty_card.svg new file mode 100644 index 0000000..4c87e43 --- /dev/null +++ b/tests/agent/sidecar/__snapshots__/test_inspector/test_inspector_app_empty_card.svg @@ -0,0 +1,208 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + mrok Agent Web Inspection Interface + + + + + + + + + + mrok Agent Web Inspection Interface +╭──────────────────────────────────────╮╭──────────────────────────────────────────────────────────────────────────────╮ + + ID  Method  Status  Path  Duration ╭───── Request Summary ──────╮ + 1   0        │                           │ +│ Status: • Duration: 0 ms │ +╰────────────────────────────╯ +Response bodyRequest headersResponse headersRequest bodyRequest par +━━━━━━━━━━━━━╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +No body + + + + + + + + + + + + + + + + + + + + + + + +╰──────────────────────────────────────╯╰──────────────────────────────────────────────────────────────────────────────╯ + d Toggle dark mode ^p palette + + + diff --git a/tests/agent/sidecar/__snapshots__/test_inspector/test_inspector_app_filed_store_connection.svg b/tests/agent/sidecar/__snapshots__/test_inspector/test_inspector_app_filed_store_connection.svg new file mode 100644 index 0000000..4a13cc4 --- /dev/null +++ b/tests/agent/sidecar/__snapshots__/test_inspector/test_inspector_app_filed_store_connection.svg @@ -0,0 +1,201 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + mrok Agent Web Inspection Interface + + + + + + + + + + mrok Agent Web Inspection Interface +╭──────────────────────────────────────╮╭──────────────────────────────────────────────────────────────────────────────╮ + + ID  Method  Status  Path  Duration Select a request to view details. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +╰──────────────────────────────────────╯╰──────────────────────────────────────────────────────────────────────────────╯ + d Toggle dark mode ^p palette + + + diff --git a/tests/agent/sidecar/__snapshots__/test_inspector/test_inspector_app_open_card.svg b/tests/agent/sidecar/__snapshots__/test_inspector/test_inspector_app_open_card.svg new file mode 100644 index 0000000..09b936d --- /dev/null +++ b/tests/agent/sidecar/__snapshots__/test_inspector/test_inspector_app_open_card.svg @@ -0,0 +1,211 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + mrok Agent Web Inspection Interface + + + + + + + + + + mrok Agent Web Inspection Interface +╭──────────────────────────────────────╮╭──────────────────────────────────────────────────────────────────────────────╮ + + ID  Method  Status  Path           ╭────────── Request Summary ──────────╮ + 3   POST    201     /extensions/EXT│ POST /extensions/EXT-1234/instances │ +│ Status: 201 • Duration: 6 ms        │ +╰─────────────────────────────────────╯ +Response bodyRequest headersResponse headersRequest bodyRequest par +━━━━━━━━━━━━━╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +{"id":"inst-1234-0001"} + + + + + + + + + + + + + + + + + + + + + + + +╰──────────────────────────────────────╯╰──────────────────────────────────────────────────────────────────────────────╯ + d Toggle dark mode ^p palette + + + diff --git a/tests/agent/sidecar/test_app.py b/tests/agent/sidecar/test_app.py index 3221cd9..5c0973f 100644 --- a/tests/agent/sidecar/test_app.py +++ b/tests/agent/sidecar/test_app.py @@ -5,6 +5,7 @@ from pytest_mock import MockerFixture from mrok.agent.sidecar.app import ASGIReceive, ASGISend, ForwardApp +from mrok.agent.sidecar.store import RequestStore class FakeReader: @@ -111,3 +112,30 @@ async def fake_open_unix_connection(path): app_unix = ForwardApp("/tmp/sock") r2, w2 = await app_unix.select_backend({}, {}) assert isinstance(r2, FakeReader) + + +@pytest.mark.asyncio +async def test_on_response_complete(): + data = { + "scope": { + "method": "GET", + "path": "/", + "raw_path": "/", + "query_string": b"", + }, + "request_buffer": bytearray(), + "response_buffer": bytearray(), + "status": 200, + "headers": [], + "headers_out": [], + "start_time": 123, + } + + app = ForwardApp(("127.0.0.1", 9000), store=RequestStore()) + assert app._store is not None + assert len(app._store.get_all()) == 0 + await app.on_response_complete(**data) + + app = ForwardApp(("127.0.0.1", 9000)) + await app.on_response_complete(**data) + assert app._store is None diff --git a/tests/agent/sidecar/test_inspector.py b/tests/agent/sidecar/test_inspector.py new file mode 100644 index 0000000..2a0599c --- /dev/null +++ b/tests/agent/sidecar/test_inspector.py @@ -0,0 +1,146 @@ +from pytest_httpx import HTTPXMock +from pytest_mock import MockerFixture +from textual.pilot import Pilot +from textual.widgets import DataTable + +from mrok.agent.sidecar.inspector import InspectorApp +from tests.conftest import SettingsFactory +from tests.types import SnapCompare + + +def test_inspector_app( + mocker: MockerFixture, + snap_compare: SnapCompare, + settings_factory: SettingsFactory, + httpx_mock: HTTPXMock, +): + settings = settings_factory() + httpx_mock.add_response( + method="GET", + url=f"http://127.0.0.1:{settings.sidecar.store_port}/requests/", + json=[ + { + "method": "GET", + "path": "/extensions/EXT-1234/instances/INS-1234-0001", + "raw_path": "/extensions/EXT-1234/instances/INS-1234-0001", + "query_string": "", + "request_body": "", + "request_headers": [["host", "ext-0000-0000.ext.s1.today"]], + "response_body": '{"detail":"Unauthorized."}', + "response_headers": [ + ["date", "Fri, 14 Nov 2025 18:24:42 GMT"], + ["server", "uvicorn"], + ["content-length", "26"], + ["content-type", "application/json"], + ], + "status": 401, + "start": "2025-11-14T13:24:42.439943", + "duration": 0.00645613670349121, + "id": 3, + }, + { + "method": "GET", + "path": "/extensions/ext-0000-0000/instances", + "raw_path": "/extensions/ext-0000-0000/instances", + "query_string": "limit=50&offset=0", + "request_body": "", + "request_headers": [["host", "ext-0000-0000.ext.s1.today"]], + "response_body": '[{"id":"inst1"},{"id":"inst2"}]', + "response_headers": [ + ["date", "Fri, 14 Nov 2025 18:24:34 GMT"], + ["server", "uvicorn"], + ["content-length", "30"], + ["content-type", "application/json"], + ], + "status": 200, + "start": "2025-11-14T13:24:34.838441", + "duration": 0.00776505470275879, + "id": 2, + }, + ], + ) + mocker.patch("mrok.cli.main.get_settings", return_value=settings) + assert snap_compare(InspectorApp(), terminal_size=(120, 35)) + + +def test_inspector_app_open_card( + mocker: MockerFixture, + snap_compare: SnapCompare, + settings_factory: SettingsFactory, + httpx_mock: HTTPXMock, +): + settings = settings_factory() + httpx_mock.add_response( + method="GET", + url=f"http://127.0.0.1:{settings.sidecar.store_port}/requests/", + json=[ + { + "method": "POST", + "path": "/extensions/EXT-1234/instances", + "raw_path": "/extensions/EXT-1234/instances", + "query_string": "redirect=False", + "request_body": '{"detail":"Unauthorized."}', + "request_headers": [["host", "ext-0000-0000.ext.s1.today"]], + "response_body": '{"id":"inst-1234-0001"}', + "response_headers": [ + ["date", "Fri, 14 Nov 2025 18:24:42 GMT"], + ["server", "uvicorn"], + ["content-length", "23"], + ["content-type", "application/json"], + ], + "status": 201, + "start": "2025-11-14T13:24:42.439943", + "duration": 0.00645613670349121, + "id": 3, + }, + ], + ) + mocker.patch("mrok.cli.main.get_settings", return_value=settings) + + async def run_before(pilot: Pilot): + await pilot.pause(0.5) + table = pilot.app.query_one(DataTable) + await pilot.click(table) + await pilot.press("enter") + + assert snap_compare(InspectorApp(), terminal_size=(120, 35), run_before=run_before) + + +def test_inspector_app_empty_card( + mocker: MockerFixture, + snap_compare: SnapCompare, + settings_factory: SettingsFactory, + httpx_mock: HTTPXMock, +): + settings = settings_factory() + httpx_mock.add_response( + method="GET", + url=f"http://127.0.0.1:{settings.sidecar.store_port}/requests/", + json=[{"id": 1}], + ) + mocker.patch("mrok.cli.main.get_settings", return_value=settings) + + async def run_before(pilot: Pilot): + await pilot.pause(0.5) + table = pilot.app.query_one(DataTable) + await pilot.click(table) + await pilot.press("enter") + + assert snap_compare(InspectorApp(), terminal_size=(120, 35), run_before=run_before) + + +def test_inspector_app_filed_store_connection( + mocker: MockerFixture, + snap_compare: SnapCompare, + settings_factory: SettingsFactory, + httpx_mock: HTTPXMock, +): + settings = settings_factory() + httpx_mock.add_response( + method="GET", + url=f"http://127.0.0.1:{settings.sidecar.store_port}/requests/", + status_code=500, + ) + mocker.patch("mrok.cli.main.get_settings", return_value=settings) + + assert snap_compare(InspectorApp(), terminal_size=(120, 35)) diff --git a/tests/agent/sidecar/test_main.py b/tests/agent/sidecar/test_main.py index aed2785..9655b70 100644 --- a/tests/agent/sidecar/test_main.py +++ b/tests/agent/sidecar/test_main.py @@ -27,7 +27,7 @@ def test_run_sidecar(mocker: MockerFixture, target_addr: str | tuple[str, int]): main.run_sidecar("ziti-identity.json", target_addr) mocked_config_ctor.assert_called_once_with(mocked_app, "ziti-identity.json") - mocked_app_ctor.assert_called_once_with(target_addr) + mocked_app_ctor.assert_called_once_with(target_addr, store=None) mocked_server_ctor.assert_called_once_with(mocked_config) mocked_server.run.assert_called_once() @@ -48,6 +48,7 @@ def test_run(mocker: MockerFixture): main.run_sidecar, "ziti-identity.json", "target-addr", + None, ) mocked_master_ctor.assert_called_once_with(mocked_start_fn, workers=10, reload=True) mocked_master.run.assert_called_once() diff --git a/tests/agent/sidecar/test_store.py b/tests/agent/sidecar/test_store.py new file mode 100644 index 0000000..b311b7a --- /dev/null +++ b/tests/agent/sidecar/test_store.py @@ -0,0 +1,25 @@ +from pytest_mock import MockerFixture + +from mrok.agent.sidecar.store import RequestStore +from tests.conftest import SettingsFactory + + +def test_request_store( + settings_factory: SettingsFactory, + mocker: MockerFixture, +): + settings = settings_factory(sidecar={"store_size": 2}) + mocker.patch("mrok.agent.sidecar.store.get_settings", return_value=settings) + store = RequestStore() + + assert len(store.get_all()) == 0 + + store.add({"id": 1}) + store.add({"id": 2}) + + assert len(store.get_all()) == 2 + + store.add({"id": 3}) + + assert len(store.get_all()) == 2 + assert len(store.get_all(offset=3)) == 0 diff --git a/tests/cli/agent/test_run.py b/tests/cli/agent/test_run.py index 6eb2a8f..8d0e9a1 100644 --- a/tests/cli/agent/test_run.py +++ b/tests/cli/agent/test_run.py @@ -51,3 +51,39 @@ def test_run_sidecar( mocked_sidecar.assert_called_once_with( "ins-1234-5678-0001.json", expected_target_addr, workers=2, reload=True ) + + +@pytest.mark.parametrize( + "console_mode", + [ + " -c", + "", + ], +) +def test_run_sidecar_with_inspector( + mocker: MockerFixture, + console_mode: str, +): + mocked_run = mocker.patch("mrok.cli.commands.agent.run.sidecar.sidecar.run") + + mocked_proc = mocker.MagicMock() + mocker.patch( + "mrok.cli.commands.agent.run.sidecar.multiprocessing.Process", return_value=mocked_proc + ) + + runner = CliRunner() + + # Run the command + result = runner.invoke( + app, + shlex.split( + f"agent run sidecar ins-1234-5678-0001.json :8000 -w 1 -i {console_mode}", + ), + ) + assert result.exit_code == 0 + + mocked_proc.start.assert_called_once() + mocked_proc.terminate.assert_called_once() + + # check RequestStore isn't empty + assert mocked_run.call_args[1]["store"] is not None diff --git a/tests/cli/agent/test_utils.py b/tests/cli/agent/test_utils.py new file mode 100644 index 0000000..a6af130 --- /dev/null +++ b/tests/cli/agent/test_utils.py @@ -0,0 +1,51 @@ +from fastapi import FastAPI +from fastapi.testclient import TestClient +from pytest_mock import MockerFixture + +from mrok.agent.sidecar.store import RequestStore +from mrok.cli.commands.agent.utils import create_inspection_server, run_inspect_api, run_textual + + +def test_run_inspect_api( + mocker: MockerFixture, +): + mocked_uvicorn = mocker.patch("mrok.cli.commands.agent.utils.uvicorn.run") + + inspect_api = mocker.MagicMock(spec=FastAPI) + mocked_create_server = mocker.patch( + "mrok.cli.commands.agent.utils.create_inspection_server", return_value=inspect_api + ) + + store = RequestStore() + + run_inspect_api(store, 5051, "warning") + + mocked_uvicorn.assert_called_once_with(inspect_api, port=5051, log_level="warning") + mocked_create_server.assert_called_once_with(store) + + +def test_run_textual(mocker: MockerFixture): + mocked_serve = mocker.patch("mrok.cli.commands.agent.utils.MServer.serve") + store = RequestStore() + run_textual(4040, 5051, store) + + mocked_serve.assert_called_once() + + +def test_create_inspection_server_handler(): + store = RequestStore() + store.add({"id": 1}) + store.add({"id": 2}) + store.add({"id": 3}) + + app = create_inspection_server(store) + client = TestClient(app) + + resp = client.get("/requests/") + resp_with_offset = client.get("/requests/?offset=1") + + assert resp.status_code == 200 + assert len(resp.json()) == 3 + + assert resp_with_offset.status_code == 200 + assert len(resp_with_offset.json()) == 2 diff --git a/tests/conftest.py b/tests/conftest.py index 0db44ff..89f0bbc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -25,6 +25,7 @@ def _get_settings( pagination: dict | None = None, proxy: dict | None = None, auth: dict | None = None, + sidecar: dict | None = None, ) -> Settings: ziti = ziti or { "api": { @@ -50,6 +51,12 @@ def _get_settings( "mode": "zrok", "domain": "exts.s1.today", } + sidecar = sidecar or { + "textual_port": 4040, + "store_port": 5051, + "store_size": 1000, + "textual_command": "python mrok/agent/sidecar/inspector.py", + } settings = Dynaconf( environments=True, settings_files=[], @@ -59,6 +66,7 @@ def _get_settings( PAGINATION=pagination, PROXY=proxy, AUTH=auth, + SIDECAR=sidecar, ) return settings diff --git a/tests/types.py b/tests/types.py new file mode 100644 index 0000000..8bde734 --- /dev/null +++ b/tests/types.py @@ -0,0 +1,16 @@ +from collections.abc import Awaitable, Callable, Iterable +from pathlib import PurePath +from typing import Protocol + +from textual.app import App +from textual.pilot import Pilot + + +class SnapCompare(Protocol): + def __call__( + self, + app: str | PurePath | App, + press: Iterable[str] = (), + terminal_size: tuple[int, int] = (80, 24), + run_before: Callable[[Pilot], Awaitable[None] | None] | None = None, + ) -> bool: ... diff --git a/uv.lock b/uv.lock index 1a91fd3..39370f9 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,126 @@ version = 1 revision = 3 requires-python = ">=3.12, <4" +[[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/29/9b/01f00e9856d0a73260e86dd8ed0c2234a466c5c1712ce1c281548df39777/aiohttp-3.13.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b1e56bab2e12b2b9ed300218c351ee2a3d8c8fdab5b1ec6193e11a817767e47b", size = 737623, upload-time = "2025-10-28T20:56:30.797Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1b/4be39c445e2b2bd0aab4ba736deb649fabf14f6757f405f0c9685019b9e9/aiohttp-3.13.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:364e25edaabd3d37b1db1f0cbcee8c73c9a3727bfa262b83e5e4cf3489a2a9dc", size = 492664, upload-time = "2025-10-28T20:56:32.708Z" }, + { url = "https://files.pythonhosted.org/packages/28/66/d35dcfea8050e131cdd731dff36434390479b4045a8d0b9d7111b0a968f1/aiohttp-3.13.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c5c94825f744694c4b8db20b71dba9a257cd2ba8e010a803042123f3a25d50d7", size = 491808, upload-time = "2025-10-28T20:56:34.57Z" }, + { url = "https://files.pythonhosted.org/packages/00/29/8e4609b93e10a853b65f8291e64985de66d4f5848c5637cddc70e98f01f8/aiohttp-3.13.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba2715d842ffa787be87cbfce150d5e88c87a98e0b62e0f5aa489169a393dbbb", size = 1738863, upload-time = "2025-10-28T20:56:36.377Z" }, + { url = "https://files.pythonhosted.org/packages/9d/fa/4ebdf4adcc0def75ced1a0d2d227577cd7b1b85beb7edad85fcc87693c75/aiohttp-3.13.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:585542825c4bc662221fb257889e011a5aa00f1ae4d75d1d246a5225289183e3", size = 1700586, upload-time = "2025-10-28T20:56:38.034Z" }, + { url = "https://files.pythonhosted.org/packages/da/04/73f5f02ff348a3558763ff6abe99c223381b0bace05cd4530a0258e52597/aiohttp-3.13.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:39d02cb6025fe1aabca329c5632f48c9532a3dabccd859e7e2f110668972331f", size = 1768625, upload-time = "2025-10-28T20:56:39.75Z" }, + { url = "https://files.pythonhosted.org/packages/f8/49/a825b79ffec124317265ca7d2344a86bcffeb960743487cb11988ffb3494/aiohttp-3.13.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e67446b19e014d37342f7195f592a2a948141d15a312fe0e700c2fd2f03124f6", size = 1867281, upload-time = "2025-10-28T20:56:41.471Z" }, + { url = "https://files.pythonhosted.org/packages/b9/48/adf56e05f81eac31edcfae45c90928f4ad50ef2e3ea72cb8376162a368f8/aiohttp-3.13.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4356474ad6333e41ccefd39eae869ba15a6c5299c9c01dfdcfdd5c107be4363e", size = 1752431, upload-time = "2025-10-28T20:56:43.162Z" }, + { url = "https://files.pythonhosted.org/packages/30/ab/593855356eead019a74e862f21523db09c27f12fd24af72dbc3555b9bfd9/aiohttp-3.13.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeacf451c99b4525f700f078becff32c32ec327b10dcf31306a8a52d78166de7", size = 1562846, upload-time = "2025-10-28T20:56:44.85Z" }, + { url = "https://files.pythonhosted.org/packages/39/0f/9f3d32271aa8dc35036e9668e31870a9d3b9542dd6b3e2c8a30931cb27ae/aiohttp-3.13.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8a9b889aeabd7a4e9af0b7f4ab5ad94d42e7ff679aaec6d0db21e3b639ad58d", size = 1699606, upload-time = "2025-10-28T20:56:46.519Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3c/52d2658c5699b6ef7692a3f7128b2d2d4d9775f2a68093f74bca06cf01e1/aiohttp-3.13.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:fa89cb11bc71a63b69568d5b8a25c3ca25b6d54c15f907ca1c130d72f320b76b", size = 1720663, upload-time = "2025-10-28T20:56:48.528Z" }, + { url = "https://files.pythonhosted.org/packages/9b/d4/8f8f3ff1fb7fb9e3f04fcad4e89d8a1cd8fc7d05de67e3de5b15b33008ff/aiohttp-3.13.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8aa7c807df234f693fed0ecd507192fc97692e61fee5702cdc11155d2e5cadc8", size = 1737939, upload-time = "2025-10-28T20:56:50.77Z" }, + { url = "https://files.pythonhosted.org/packages/03/d3/ddd348f8a27a634daae39a1b8e291ff19c77867af438af844bf8b7e3231b/aiohttp-3.13.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9eb3e33fdbe43f88c3c75fa608c25e7c47bbd80f48d012763cb67c47f39a7e16", size = 1555132, upload-time = "2025-10-28T20:56:52.568Z" }, + { url = "https://files.pythonhosted.org/packages/39/b8/46790692dc46218406f94374903ba47552f2f9f90dad554eed61bfb7b64c/aiohttp-3.13.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9434bc0d80076138ea986833156c5a48c9c7a8abb0c96039ddbb4afc93184169", size = 1764802, upload-time = "2025-10-28T20:56:54.292Z" }, + { url = "https://files.pythonhosted.org/packages/ba/e4/19ce547b58ab2a385e5f0b8aa3db38674785085abcf79b6e0edd1632b12f/aiohttp-3.13.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ff15c147b2ad66da1f2cbb0622313f2242d8e6e8f9b79b5206c84523a4473248", size = 1719512, upload-time = "2025-10-28T20:56:56.428Z" }, + { url = "https://files.pythonhosted.org/packages/70/30/6355a737fed29dcb6dfdd48682d5790cb5eab050f7b4e01f49b121d3acad/aiohttp-3.13.2-cp312-cp312-win32.whl", hash = "sha256:27e569eb9d9e95dbd55c0fc3ec3a9335defbf1d8bc1d20171a49f3c4c607b93e", size = 426690, upload-time = "2025-10-28T20:56:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0d/b10ac09069973d112de6ef980c1f6bb31cb7dcd0bc363acbdad58f927873/aiohttp-3.13.2-cp312-cp312-win_amd64.whl", hash = "sha256:8709a0f05d59a71f33fd05c17fc11fcb8c30140506e13c2f5e8ee1b8964e1b45", size = 453465, upload-time = "2025-10-28T20:57:00.795Z" }, + { 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]] +name = "aiohttp-jinja2" +version = "1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "jinja2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/39/da5a94dd89b1af7241fb7fc99ae4e73505b5f898b540b6aba6dc7afe600e/aiohttp-jinja2-1.6.tar.gz", hash = "sha256:a3a7ff5264e5bca52e8ae547bbfd0761b72495230d438d05b6c0915be619b0e2", size = 53057, upload-time = "2023-11-18T15:30:52.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/90/65238d4246307195411b87a07d03539049819b022c01bcc773826f600138/aiohttp_jinja2-1.6-py3-none-any.whl", hash = "sha256:0df405ee6ad1b58e5a068a105407dc7dcc1704544c559f1938babde954f945c7", size = 11736, upload-time = "2023-11-18T15:30:50.743Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +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-types" version = "0.7.0" @@ -55,6 +175,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2", size = 26918, upload-time = "2024-11-30T04:30:10.946Z" }, ] +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +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/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 = "bandit" version = "1.8.6" @@ -418,6 +547,95 @@ wheels = [ { 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.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/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { 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]] name = "gunicorn" version = "23.0.0" @@ -580,6 +798,18 @@ 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 = "linkify-it-py" +version = "2.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/ae/bb56c6828e4797ba5a4821eec7c43b8bf40f69cda4d4f5f8c8a2810ec96a/linkify-it-py-2.0.3.tar.gz", hash = "sha256:68cda27e162e9215c17d786649d1da0021a451bdc436ef9e0fa0ba5234b9b048", size = 27946, upload-time = "2024-02-04T14:48:04.179Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/1e/b832de447dee8b582cac175871d2f6c3d5077cc56d5575cadba1fd1cccfa/linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79", size = 19820, upload-time = "2024-02-04T14:48:02.496Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -592,6 +822,11 @@ 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.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -667,6 +902,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/8e/9ad090d3553c280a8060fbf6e24dc1c0c29704ee7d1c372f0c174aa59285/matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca", size = 9899, upload-time = "2024-04-15T13:44:43.265Z" }, ] +[[package]] +name = "mdit-py-plugins" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -693,8 +940,11 @@ dependencies = [ { name = "pydantic" }, { name = "pyfiglet" }, { name = "pyjwt" }, + { name = "pytest-textual-snapshot" }, { name = "pyyaml" }, { name = "rich" }, + { name = "textual" }, + { name = "textual-serve" }, { name = "typer" }, { name = "uvicorn-worker" }, ] @@ -730,8 +980,11 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.11.7,<3.0.0" }, { name = "pyfiglet", specifier = ">=1.0.4,<2.0.0" }, { name = "pyjwt", specifier = ">=2.10.1,<3.0.0" }, + { name = "pytest-textual-snapshot", specifier = ">=1.1.0,<2.0.0" }, { name = "pyyaml", specifier = ">=6.0.2,<7.0.0" }, { name = "rich", specifier = ">=14.1.0,<15.0.0" }, + { name = "textual", specifier = ">=6.5.0,<7.0.0" }, + { name = "textual-serve", specifier = ">=1.1.3,<2.0.0" }, { name = "typer", specifier = ">=0.19.2,<0.20.0" }, { name = "uvicorn-worker", specifier = ">=0.4.0,<0.5.0" }, ] @@ -753,6 +1006,105 @@ dev = [ { name = "types-pyyaml", specifier = ">=6.0.12.20250915,<7.0.0.0" }, ] +[[package]] +name = "multidict" +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/c2/9e/9f61ac18d9c8b475889f32ccfa91c9f59363480613fc807b6e3023d6f60b/multidict-6.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8a3862568a36d26e650a19bb5cbbba14b71789032aebc0423f8cc5f150730184", size = 76877, upload-time = "2025-10-06T14:49:20.884Z" }, + { url = "https://files.pythonhosted.org/packages/38/6f/614f09a04e6184f8824268fce4bc925e9849edfa654ddd59f0b64508c595/multidict-6.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:960c60b5849b9b4f9dcc9bea6e3626143c252c74113df2c1540aebce70209b45", size = 45467, upload-time = "2025-10-06T14:49:22.054Z" }, + { url = "https://files.pythonhosted.org/packages/b3/93/c4f67a436dd026f2e780c433277fff72be79152894d9fc36f44569cab1a6/multidict-6.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2049be98fb57a31b4ccf870bf377af2504d4ae35646a19037ec271e4c07998aa", size = 43834, upload-time = "2025-10-06T14:49:23.566Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f5/013798161ca665e4a422afbc5e2d9e4070142a9ff8905e482139cd09e4d0/multidict-6.7.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0934f3843a1860dd465d38895c17fce1f1cb37295149ab05cd1b9a03afacb2a7", size = 250545, upload-time = "2025-10-06T14:49:24.882Z" }, + { url = "https://files.pythonhosted.org/packages/71/2f/91dbac13e0ba94669ea5119ba267c9a832f0cb65419aca75549fcf09a3dc/multidict-6.7.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3e34f3a1b8131ba06f1a73adab24f30934d148afcd5f5de9a73565a4404384e", size = 258305, upload-time = "2025-10-06T14:49:26.778Z" }, + { url = "https://files.pythonhosted.org/packages/ef/b0/754038b26f6e04488b48ac621f779c341338d78503fb45403755af2df477/multidict-6.7.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:efbb54e98446892590dc2458c19c10344ee9a883a79b5cec4bc34d6656e8d546", size = 242363, upload-time = "2025-10-06T14:49:28.562Z" }, + { url = "https://files.pythonhosted.org/packages/87/15/9da40b9336a7c9fa606c4cf2ed80a649dffeb42b905d4f63a1d7eb17d746/multidict-6.7.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a35c5fc61d4f51eb045061e7967cfe3123d622cd500e8868e7c0c592a09fedc4", size = 268375, upload-time = "2025-10-06T14:49:29.96Z" }, + { url = "https://files.pythonhosted.org/packages/82/72/c53fcade0cc94dfaad583105fd92b3a783af2091eddcb41a6d5a52474000/multidict-6.7.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29fe6740ebccba4175af1b9b87bf553e9c15cd5868ee967e010efcf94e4fd0f1", size = 269346, upload-time = "2025-10-06T14:49:31.404Z" }, + { url = "https://files.pythonhosted.org/packages/0d/e2/9baffdae21a76f77ef8447f1a05a96ec4bc0a24dae08767abc0a2fe680b8/multidict-6.7.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:123e2a72e20537add2f33a79e605f6191fba2afda4cbb876e35c1a7074298a7d", size = 256107, upload-time = "2025-10-06T14:49:32.974Z" }, + { url = "https://files.pythonhosted.org/packages/3c/06/3f06f611087dc60d65ef775f1fb5aca7c6d61c6db4990e7cda0cef9b1651/multidict-6.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b284e319754366c1aee2267a2036248b24eeb17ecd5dc16022095e747f2f4304", size = 253592, upload-time = "2025-10-06T14:49:34.52Z" }, + { url = "https://files.pythonhosted.org/packages/20/24/54e804ec7945b6023b340c412ce9c3f81e91b3bf5fa5ce65558740141bee/multidict-6.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:803d685de7be4303b5a657b76e2f6d1240e7e0a8aa2968ad5811fa2285553a12", size = 251024, upload-time = "2025-10-06T14:49:35.956Z" }, + { url = "https://files.pythonhosted.org/packages/14/48/011cba467ea0b17ceb938315d219391d3e421dfd35928e5dbdc3f4ae76ef/multidict-6.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c04a328260dfd5db8c39538f999f02779012268f54614902d0afc775d44e0a62", size = 251484, upload-time = "2025-10-06T14:49:37.631Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2f/919258b43bb35b99fa127435cfb2d91798eb3a943396631ef43e3720dcf4/multidict-6.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8a19cdb57cd3df4cd865849d93ee14920fb97224300c88501f16ecfa2604b4e0", size = 263579, upload-time = "2025-10-06T14:49:39.502Z" }, + { url = "https://files.pythonhosted.org/packages/31/22/a0e884d86b5242b5a74cf08e876bdf299e413016b66e55511f7a804a366e/multidict-6.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9b2fd74c52accced7e75de26023b7dccee62511a600e62311b918ec5c168fc2a", size = 259654, upload-time = "2025-10-06T14:49:41.32Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e5/17e10e1b5c5f5a40f2fcbb45953c9b215f8a4098003915e46a93f5fcaa8f/multidict-6.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3e8bfdd0e487acf992407a140d2589fe598238eaeffa3da8448d63a63cd363f8", size = 251511, upload-time = "2025-10-06T14:49:46.021Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9a/201bb1e17e7af53139597069c375e7b0dcbd47594604f65c2d5359508566/multidict-6.7.0-cp312-cp312-win32.whl", hash = "sha256:dd32a49400a2c3d52088e120ee00c1e3576cbff7e10b98467962c74fdb762ed4", size = 41895, upload-time = "2025-10-06T14:49:48.718Z" }, + { url = "https://files.pythonhosted.org/packages/46/e2/348cd32faad84eaf1d20cce80e2bb0ef8d312c55bca1f7fa9865e7770aaf/multidict-6.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:92abb658ef2d7ef22ac9f8bb88e8b6c3e571671534e029359b6d9e845923eb1b", size = 46073, upload-time = "2025-10-06T14:49:50.28Z" }, + { url = "https://files.pythonhosted.org/packages/25/ec/aad2613c1910dce907480e0c3aa306905830f25df2e54ccc9dea450cb5aa/multidict-6.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:490dab541a6a642ce1a9d61a4781656b346a55c13038f0b1244653828e3a83ec", size = 43226, upload-time = "2025-10-06T14:49:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/d2/86/33272a544eeb36d66e4d9a920602d1a2f57d4ebea4ef3cdfe5a912574c95/multidict-6.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bee7c0588aa0076ce77c0ea5d19a68d76ad81fcd9fe8501003b9a24f9d4000f6", size = 76135, upload-time = "2025-10-06T14:49:54.26Z" }, + { url = "https://files.pythonhosted.org/packages/91/1c/eb97db117a1ebe46d457a3d235a7b9d2e6dcab174f42d1b67663dd9e5371/multidict-6.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7ef6b61cad77091056ce0e7ce69814ef72afacb150b7ac6a3e9470def2198159", size = 45117, upload-time = "2025-10-06T14:49:55.82Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d8/6c3442322e41fb1dd4de8bd67bfd11cd72352ac131f6368315617de752f1/multidict-6.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c0359b1ec12b1d6849c59f9d319610b7f20ef990a6d454ab151aa0e3b9f78ca", size = 43472, upload-time = "2025-10-06T14:49:57.048Z" }, + { url = "https://files.pythonhosted.org/packages/75/3f/e2639e80325af0b6c6febdf8e57cc07043ff15f57fa1ef808f4ccb5ac4cd/multidict-6.7.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cd240939f71c64bd658f186330603aac1a9a81bf6273f523fca63673cb7378a8", size = 249342, upload-time = "2025-10-06T14:49:58.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/cc/84e0585f805cbeaa9cbdaa95f9a3d6aed745b9d25700623ac89a6ecff400/multidict-6.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60a4d75718a5efa473ebd5ab685786ba0c67b8381f781d1be14da49f1a2dc60", size = 257082, upload-time = "2025-10-06T14:49:59.89Z" }, + { url = "https://files.pythonhosted.org/packages/b0/9c/ac851c107c92289acbbf5cfb485694084690c1b17e555f44952c26ddc5bd/multidict-6.7.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53a42d364f323275126aff81fb67c5ca1b7a04fda0546245730a55c8c5f24bc4", size = 240704, upload-time = "2025-10-06T14:50:01.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/cc/5f93e99427248c09da95b62d64b25748a5f5c98c7c2ab09825a1d6af0e15/multidict-6.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3b29b980d0ddbecb736735ee5bef69bb2ddca56eff603c86f3f29a1128299b4f", size = 266355, upload-time = "2025-10-06T14:50:02.955Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0c/2ec1d883ceb79c6f7f6d7ad90c919c898f5d1c6ea96d322751420211e072/multidict-6.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f8a93b1c0ed2d04b97a5e9336fd2d33371b9a6e29ab7dd6503d63407c20ffbaf", size = 267259, upload-time = "2025-10-06T14:50:04.446Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2d/f0b184fa88d6630aa267680bdb8623fb69cb0d024b8c6f0d23f9a0f406d3/multidict-6.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff96e8815eecacc6645da76c413eb3b3d34cfca256c70b16b286a687d013c32", size = 254903, upload-time = "2025-10-06T14:50:05.98Z" }, + { url = "https://files.pythonhosted.org/packages/06/c9/11ea263ad0df7dfabcad404feb3c0dd40b131bc7f232d5537f2fb1356951/multidict-6.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7516c579652f6a6be0e266aec0acd0db80829ca305c3d771ed898538804c2036", size = 252365, upload-time = "2025-10-06T14:50:07.511Z" }, + { url = "https://files.pythonhosted.org/packages/41/88/d714b86ee2c17d6e09850c70c9d310abac3d808ab49dfa16b43aba9d53fd/multidict-6.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:040f393368e63fb0f3330e70c26bfd336656bed925e5cbe17c9da839a6ab13ec", size = 250062, upload-time = "2025-10-06T14:50:09.074Z" }, + { url = "https://files.pythonhosted.org/packages/15/fe/ad407bb9e818c2b31383f6131ca19ea7e35ce93cf1310fce69f12e89de75/multidict-6.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b3bc26a951007b1057a1c543af845f1c7e3e71cc240ed1ace7bf4484aa99196e", size = 249683, upload-time = "2025-10-06T14:50:10.714Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a4/a89abdb0229e533fb925e7c6e5c40201c2873efebc9abaf14046a4536ee6/multidict-6.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7b022717c748dd1992a83e219587aabe45980d88969f01b316e78683e6285f64", size = 261254, upload-time = "2025-10-06T14:50:12.28Z" }, + { url = "https://files.pythonhosted.org/packages/8d/aa/0e2b27bd88b40a4fb8dc53dd74eecac70edaa4c1dd0707eb2164da3675b3/multidict-6.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:9600082733859f00d79dee64effc7aef1beb26adb297416a4ad2116fd61374bd", size = 257967, upload-time = "2025-10-06T14:50:14.16Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8e/0c67b7120d5d5f6d874ed85a085f9dc770a7f9d8813e80f44a9fec820bb7/multidict-6.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:94218fcec4d72bc61df51c198d098ce2b378e0ccbac41ddbed5ef44092913288", size = 250085, upload-time = "2025-10-06T14:50:15.639Z" }, + { url = "https://files.pythonhosted.org/packages/ba/55/b73e1d624ea4b8fd4dd07a3bb70f6e4c7c6c5d9d640a41c6ffe5cdbd2a55/multidict-6.7.0-cp313-cp313-win32.whl", hash = "sha256:a37bd74c3fa9d00be2d7b8eca074dc56bd8077ddd2917a839bd989612671ed17", size = 41713, upload-time = "2025-10-06T14:50:17.066Z" }, + { url = "https://files.pythonhosted.org/packages/32/31/75c59e7d3b4205075b4c183fa4ca398a2daf2303ddf616b04ae6ef55cffe/multidict-6.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:30d193c6cc6d559db42b6bcec8a5d395d34d60c9877a0b71ecd7c204fcf15390", size = 45915, upload-time = "2025-10-06T14:50:18.264Z" }, + { url = "https://files.pythonhosted.org/packages/31/2a/8987831e811f1184c22bc2e45844934385363ee61c0a2dcfa8f71b87e608/multidict-6.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:ea3334cabe4d41b7ccd01e4d349828678794edbc2d3ae97fc162a3312095092e", size = 43077, upload-time = "2025-10-06T14:50:19.853Z" }, + { url = "https://files.pythonhosted.org/packages/e8/68/7b3a5170a382a340147337b300b9eb25a9ddb573bcdfff19c0fa3f31ffba/multidict-6.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ad9ce259f50abd98a1ca0aa6e490b58c316a0fce0617f609723e40804add2c00", size = 83114, upload-time = "2025-10-06T14:50:21.223Z" }, + { url = "https://files.pythonhosted.org/packages/55/5c/3fa2d07c84df4e302060f555bbf539310980362236ad49f50eeb0a1c1eb9/multidict-6.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07f5594ac6d084cbb5de2df218d78baf55ef150b91f0ff8a21cc7a2e3a5a58eb", size = 48442, upload-time = "2025-10-06T14:50:22.871Z" }, + { url = "https://files.pythonhosted.org/packages/fc/56/67212d33239797f9bd91962bb899d72bb0f4c35a8652dcdb8ed049bef878/multidict-6.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0591b48acf279821a579282444814a2d8d0af624ae0bc600aa4d1b920b6e924b", size = 46885, upload-time = "2025-10-06T14:50:24.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/d1/908f896224290350721597a61a69cd19b89ad8ee0ae1f38b3f5cd12ea2ac/multidict-6.7.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:749a72584761531d2b9467cfbdfd29487ee21124c304c4b6cb760d8777b27f9c", size = 242588, upload-time = "2025-10-06T14:50:25.716Z" }, + { url = "https://files.pythonhosted.org/packages/ab/67/8604288bbd68680eee0ab568fdcb56171d8b23a01bcd5cb0c8fedf6e5d99/multidict-6.7.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b4c3d199f953acd5b446bf7c0de1fe25d94e09e79086f8dc2f48a11a129cdf1", size = 249966, upload-time = "2025-10-06T14:50:28.192Z" }, + { url = "https://files.pythonhosted.org/packages/20/33/9228d76339f1ba51e3efef7da3ebd91964d3006217aae13211653193c3ff/multidict-6.7.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9fb0211dfc3b51efea2f349ec92c114d7754dd62c01f81c3e32b765b70c45c9b", size = 228618, upload-time = "2025-10-06T14:50:29.82Z" }, + { url = "https://files.pythonhosted.org/packages/f8/2d/25d9b566d10cab1c42b3b9e5b11ef79c9111eaf4463b8c257a3bd89e0ead/multidict-6.7.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a027ec240fe73a8d6281872690b988eed307cd7d91b23998ff35ff577ca688b5", size = 257539, upload-time = "2025-10-06T14:50:31.731Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b1/8d1a965e6637fc33de3c0d8f414485c2b7e4af00f42cab3d84e7b955c222/multidict-6.7.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1d964afecdf3a8288789df2f5751dc0a8261138c3768d9af117ed384e538fad", size = 256345, upload-time = "2025-10-06T14:50:33.26Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0c/06b5a8adbdeedada6f4fb8d8f193d44a347223b11939b42953eeb6530b6b/multidict-6.7.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caf53b15b1b7df9fbd0709aa01409000a2b4dd03a5f6f5cc548183c7c8f8b63c", size = 247934, upload-time = "2025-10-06T14:50:34.808Z" }, + { url = "https://files.pythonhosted.org/packages/8f/31/b2491b5fe167ca044c6eb4b8f2c9f3b8a00b24c432c365358eadac5d7625/multidict-6.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:654030da3197d927f05a536a66186070e98765aa5142794c9904555d3a9d8fb5", size = 245243, upload-time = "2025-10-06T14:50:36.436Z" }, + { url = "https://files.pythonhosted.org/packages/61/1a/982913957cb90406c8c94f53001abd9eafc271cb3e70ff6371590bec478e/multidict-6.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:2090d3718829d1e484706a2f525e50c892237b2bf9b17a79b059cb98cddc2f10", size = 235878, upload-time = "2025-10-06T14:50:37.953Z" }, + { url = "https://files.pythonhosted.org/packages/be/c0/21435d804c1a1cf7a2608593f4d19bca5bcbd7a81a70b253fdd1c12af9c0/multidict-6.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2d2cfeec3f6f45651b3d408c4acec0ebf3daa9bc8a112a084206f5db5d05b754", size = 243452, upload-time = "2025-10-06T14:50:39.574Z" }, + { url = "https://files.pythonhosted.org/packages/54/0a/4349d540d4a883863191be6eb9a928846d4ec0ea007d3dcd36323bb058ac/multidict-6.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ef089f985b8c194d341eb2c24ae6e7408c9a0e2e5658699c92f497437d88c3c", size = 252312, upload-time = "2025-10-06T14:50:41.612Z" }, + { url = "https://files.pythonhosted.org/packages/26/64/d5416038dbda1488daf16b676e4dbfd9674dde10a0cc8f4fc2b502d8125d/multidict-6.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e93a0617cd16998784bf4414c7e40f17a35d2350e5c6f0bd900d3a8e02bd3762", size = 246935, upload-time = "2025-10-06T14:50:43.972Z" }, + { url = "https://files.pythonhosted.org/packages/9f/8c/8290c50d14e49f35e0bd4abc25e1bc7711149ca9588ab7d04f886cdf03d9/multidict-6.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0feece2ef8ebc42ed9e2e8c78fc4aa3cf455733b507c09ef7406364c94376c6", size = 243385, upload-time = "2025-10-06T14:50:45.648Z" }, + { url = "https://files.pythonhosted.org/packages/ef/a0/f83ae75e42d694b3fbad3e047670e511c138be747bc713cf1b10d5096416/multidict-6.7.0-cp313-cp313t-win32.whl", hash = "sha256:19a1d55338ec1be74ef62440ca9e04a2f001a04d0cc49a4983dc320ff0f3212d", size = 47777, upload-time = "2025-10-06T14:50:47.154Z" }, + { url = "https://files.pythonhosted.org/packages/dc/80/9b174a92814a3830b7357307a792300f42c9e94664b01dee8e457551fa66/multidict-6.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3da4fb467498df97e986af166b12d01f05d2e04f978a9c1c680ea1988e0bc4b6", size = 53104, upload-time = "2025-10-06T14:50:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/cc/28/04baeaf0428d95bb7a7bea0e691ba2f31394338ba424fb0679a9ed0f4c09/multidict-6.7.0-cp313-cp313t-win_arm64.whl", hash = "sha256:b4121773c49a0776461f4a904cdf6264c88e42218aaa8407e803ca8025872792", size = 45503, upload-time = "2025-10-06T14:50:50.16Z" }, + { 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]] name = "mypy" version = "1.18.2" @@ -894,6 +1246,90 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, ] +[[package]] +name = "propcache" +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/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" }, + { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" }, + { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" }, + { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" }, + { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" }, + { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" }, + { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" }, + { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" }, + { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" }, + { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" }, + { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" }, + { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" }, + { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" }, + { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" }, + { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" }, + { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" }, + { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" }, + { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" }, + { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" }, + { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" }, + { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" }, + { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" }, + { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" }, + { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" }, + { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" }, + { 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 = "ptyprocess" version = "0.7.0" @@ -1115,6 +1551,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/3e/a4a9227807b56869790aad3e24472a554b585974fe7e551ea350f50897ae/pytest_randomly-4.0.1-py3-none-any.whl", hash = "sha256:e0dfad2fd4f35e07beff1e47c17fbafcf98f9bf4531fd369d9260e2f858bfcb7", size = 8304, upload-time = "2025-09-12T15:22:58.946Z" }, ] +[[package]] +name = "pytest-textual-snapshot" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "pytest" }, + { name = "rich" }, + { name = "syrupy" }, + { name = "textual" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3e/7f/4135f87e12c1c46376971fec5ebfe71f7f8b15ac20f887c90932dedd6e78/pytest_textual_snapshot-1.1.0.tar.gz", hash = "sha256:96d48ab01306852a3b4ae165f008d5fdd7fda777e91e9d2c3ea0f7d7458544eb", size = 11391, upload-time = "2025-01-23T16:12:00.537Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/30/c31d800f8d40d663fc84d83548b26aecf613c9c39bd6985c813d623d7b84/pytest_textual_snapshot-1.1.0-py3-none-any.whl", hash = "sha256:fdf7727d2bc444f947554308da1b08df7a45215fe49d0621cbbc24c33e8f7b8d", size = 11451, upload-time = "2025-01-23T16:11:59.389Z" }, +] + [[package]] name = "python-dotenv" version = "1.1.1" @@ -1349,6 +1801,51 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/c5/0c06759b95747882bb50abda18f5fb48c3e9b0fbfc6ebc0e23550b52415d/stevedore-5.5.0-py3-none-any.whl", hash = "sha256:18363d4d268181e8e8452e71a38cd77630f345b2ef6b4a8d5614dac5ee0d18cf", size = 49518, upload-time = "2025-08-25T12:54:25.445Z" }, ] +[[package]] +name = "syrupy" +version = "4.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/54/07f40c1e9355c0eb6909b83abd8ea2c523a1e05b8257a575bbaa42df28de/syrupy-4.8.0.tar.gz", hash = "sha256:648f0e9303aaa8387c8365d7314784c09a6bab0a407455c6a01d6a4f5c6a8ede", size = 49526, upload-time = "2024-11-23T23:34:36.399Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/c7/8cd6b5fa8cc4a5c025d3d36a014dff44436fda8b3003a471253931c77f1b/syrupy-4.8.0-py3-none-any.whl", hash = "sha256:544f4ec6306f4b1c460fdab48fd60b2c7fe54a6c0a8243aeea15f9ad9c638c3f", size = 49530, upload-time = "2024-11-23T23:34:34.697Z" }, +] + +[[package]] +name = "textual" +version = "6.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", extra = ["linkify"] }, + { name = "mdit-py-plugins" }, + { name = "platformdirs" }, + { name = "pygments" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/90/59757aa887ddcea61428820274f1a2d1f986feb7880374a5420ab5d37132/textual-6.5.0.tar.gz", hash = "sha256:e5f152cdd47db48a635d23b839721bae4d0e8b6d855e3fede7285218289294e3", size = 1574116, upload-time = "2025-10-31T17:21:53.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/37/1deba011782a49ea249c73adcf703a39b0249ac9b0e17d1a2e4074df8d57/textual-6.5.0-py3-none-any.whl", hash = "sha256:c5505be7fe606b8054fb88431279885f88352bddca64832f6acd293ef7d9b54f", size = 711848, upload-time = "2025-10-31T17:21:51.134Z" }, +] + +[[package]] +name = "textual-serve" +version = "1.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "aiohttp-jinja2" }, + { name = "jinja2" }, + { name = "rich" }, + { name = "textual" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/7e/62fecc552853ec6a178cb1faa2d6f73b34d5512924770e7b08b58ff14148/textual_serve-1.1.3.tar.gz", hash = "sha256:f8f636ae2f5fd651b79d965473c3e9383d3521cdf896f9bc289709185da3f683", size = 448340, upload-time = "2025-11-01T16:22:36.723Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/fe/108e7773349d500cf363328c3d0b7123e03feda51e310a3a5b136ac8ca71/textual_serve-1.1.3-py3-none-any.whl", hash = "sha256:207a472bc6604e725b1adab4ab8bf12f4c4dc25b04eea31e4d04731d8bf30f18", size = 447339, upload-time = "2025-11-01T16:22:35.209Z" }, +] + [[package]] name = "traitlets" version = "5.14.3" @@ -1403,6 +1900,15 @@ 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 = "uc-micro-py" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/7a/146a99696aee0609e3712f2b44c6274566bc368dfe8375191278045186b8/uc-micro-py-1.0.3.tar.gz", hash = "sha256:d321b92cff673ec58027c04015fcaa8bb1e005478643ff4a500882eaab88c48a", size = 6043, upload-time = "2024-02-09T16:52:01.654Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/87/1f677586e8ac487e29672e4b17455758fce261de06a0d086167bb760361a/uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5", size = 6229, upload-time = "2024-02-09T16:52:00.371Z" }, +] + [[package]] name = "urllib3" version = "2.5.0" @@ -1589,3 +2095,97 @@ wheels = [ { 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.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +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/75/ff/46736024fee3429b80a165a732e38e5d5a238721e634ab41b040d49f8738/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f", size = 142000, upload-time = "2025-10-06T14:09:44.631Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9a/b312ed670df903145598914770eb12de1bac44599549b3360acc96878df8/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2", size = 94338, upload-time = "2025-10-06T14:09:46.372Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f5/0601483296f09c3c65e303d60c070a5c19fcdbc72daa061e96170785bc7d/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74", size = 94909, upload-time = "2025-10-06T14:09:48.648Z" }, + { url = "https://files.pythonhosted.org/packages/60/41/9a1fe0b73dbcefce72e46cf149b0e0a67612d60bfc90fb59c2b2efdfbd86/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df", size = 372940, upload-time = "2025-10-06T14:09:50.089Z" }, + { url = "https://files.pythonhosted.org/packages/17/7a/795cb6dfee561961c30b800f0ed616b923a2ec6258b5def2a00bf8231334/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb", size = 345825, upload-time = "2025-10-06T14:09:52.142Z" }, + { url = "https://files.pythonhosted.org/packages/d7/93/a58f4d596d2be2ae7bab1a5846c4d270b894958845753b2c606d666744d3/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2", size = 386705, upload-time = "2025-10-06T14:09:54.128Z" }, + { url = "https://files.pythonhosted.org/packages/61/92/682279d0e099d0e14d7fd2e176bd04f48de1484f56546a3e1313cd6c8e7c/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82", size = 396518, upload-time = "2025-10-06T14:09:55.762Z" }, + { url = "https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a", size = 377267, upload-time = "2025-10-06T14:09:57.958Z" }, + { url = "https://files.pythonhosted.org/packages/22/42/d2685e35908cbeaa6532c1fc73e89e7f2efb5d8a7df3959ea8e37177c5a3/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124", size = 365797, upload-time = "2025-10-06T14:09:59.527Z" }, + { url = "https://files.pythonhosted.org/packages/a2/83/cf8c7bcc6355631762f7d8bdab920ad09b82efa6b722999dfb05afa6cfac/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa", size = 365535, upload-time = "2025-10-06T14:10:01.139Z" }, + { url = "https://files.pythonhosted.org/packages/25/e1/5302ff9b28f0c59cac913b91fe3f16c59a033887e57ce9ca5d41a3a94737/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7", size = 382324, upload-time = "2025-10-06T14:10:02.756Z" }, + { url = "https://files.pythonhosted.org/packages/bf/cd/4617eb60f032f19ae3a688dc990d8f0d89ee0ea378b61cac81ede3e52fae/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d", size = 383803, upload-time = "2025-10-06T14:10:04.552Z" }, + { url = "https://files.pythonhosted.org/packages/59/65/afc6e62bb506a319ea67b694551dab4a7e6fb7bf604e9bd9f3e11d575fec/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520", size = 374220, upload-time = "2025-10-06T14:10:06.489Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3d/68bf18d50dc674b942daec86a9ba922d3113d8399b0e52b9897530442da2/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8", size = 81589, upload-time = "2025-10-06T14:10:09.254Z" }, + { url = "https://files.pythonhosted.org/packages/c8/9a/6ad1a9b37c2f72874f93e691b2e7ecb6137fb2b899983125db4204e47575/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c", size = 87213, upload-time = "2025-10-06T14:10:11.369Z" }, + { url = "https://files.pythonhosted.org/packages/44/c5/c21b562d1680a77634d748e30c653c3ca918beb35555cff24986fff54598/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74", size = 81330, upload-time = "2025-10-06T14:10:13.112Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload-time = "2025-10-06T14:10:14.601Z" }, + { url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload-time = "2025-10-06T14:10:16.115Z" }, + { url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload-time = "2025-10-06T14:10:17.993Z" }, + { url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload-time = "2025-10-06T14:10:19.44Z" }, + { url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload-time = "2025-10-06T14:10:21.124Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload-time = "2025-10-06T14:10:22.902Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload-time = "2025-10-06T14:10:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload-time = "2025-10-06T14:10:26.406Z" }, + { url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload-time = "2025-10-06T14:10:28.461Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload-time = "2025-10-06T14:10:30.541Z" }, + { url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" }, + { url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" }, + { url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" }, + { url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" }, + { url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" }, + { url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload-time = "2025-10-06T14:10:49.997Z" }, + { url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload-time = "2025-10-06T14:10:52.004Z" }, + { url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload-time = "2025-10-06T14:10:54.078Z" }, + { url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload-time = "2025-10-06T14:10:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload-time = "2025-10-06T14:10:57.985Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload-time = "2025-10-06T14:10:59.633Z" }, + { url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload-time = "2025-10-06T14:11:01.454Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" }, + { url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" }, + { url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" }, + { url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" }, + { 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 ac0bfc10cf5d8a91f4ecc66f4c4a4f5c8a631213 Mon Sep 17 00:00:00 2001 From: Francesco Faraone Date: Thu, 27 Nov 2025 17:42:12 +0100 Subject: [PATCH 2/3] WIP dev console --- mrok/agent/sidecar/app.py | 46 ++++---- mrok/agent/sidecar/inspector.py | 59 +++++----- mrok/agent/sidecar/main.py | 73 +++++++++++-- mrok/agent/sidecar/store.py | 28 ----- mrok/cli/commands/agent/run/sidecar.py | 102 +---------------- mrok/cli/commands/agent/utils.py | 145 +++++++++++-------------- mrok/http/config.py | 5 +- mrok/http/constants.py | 22 ++++ mrok/http/datastructures.py | 65 +++++++++++ mrok/http/forwarder.py | 123 +++++++++++++-------- mrok/http/master.py | 38 ++++--- mrok/http/utils.py | 90 +++++++++++++++ pyproject.toml | 1 + uv.lock | 63 +++++++++++ 14 files changed, 537 insertions(+), 323 deletions(-) mode change 100644 => 100755 mrok/agent/sidecar/inspector.py delete mode 100644 mrok/agent/sidecar/store.py create mode 100644 mrok/http/constants.py create mode 100644 mrok/http/datastructures.py create mode 100644 mrok/http/utils.py diff --git a/mrok/agent/sidecar/app.py b/mrok/agent/sidecar/app.py index 3d12a9a..8b252a1 100644 --- a/mrok/agent/sidecar/app.py +++ b/mrok/agent/sidecar/app.py @@ -1,12 +1,13 @@ import asyncio -import datetime import logging import time from collections.abc import Awaitable, Callable +from datetime import datetime from pathlib import Path +from queue import Queue from typing import Any -from mrok.agent.sidecar.store import RequestStore +from mrok.http.datastructures import Response from mrok.http.forwarder import ForwardAppBase logger = logging.getLogger("mrok.proxy") @@ -21,12 +22,14 @@ def __init__( self, target_address: str | Path | tuple[str, int], read_chunk_size: int = 65536, - store: RequestStore | None = None, + store: Queue | None = None, ) -> None: - super().__init__(read_chunk_size=read_chunk_size) + super().__init__( + read_chunk_size=read_chunk_size, + on_response_complete=self.on_response_complete, + ) self._target_address = target_address self._store = store - self.capture_body = True async def select_backend( self, @@ -39,28 +42,23 @@ async def select_backend( async def on_response_complete( self, - scope: Scope, - status: int, - headers: list[tuple[bytes, bytes]], - headers_out: list[tuple[bytes, bytes]], - start_time: float, - request_buffer: bytearray, - response_buffer: bytearray, + response: Response, ) -> None: if self._store is None: return request_data = { - "method": scope["method"], - "path": scope["path"], - "raw_path": scope["raw_path"], - "query_string": scope["query_string"], - "request_body": request_buffer, - "request_headers": headers, - "response_body": response_buffer, - "response_headers": headers_out, - "status": status, - "start": datetime.datetime.fromtimestamp(start_time), - "duration": time.time() - start_time, + "id": time.monotonic_ns(), + "method": response.request.method, + "path": response.request.url, + "query_string": response.request.query_string, + "request_body": response.request.body, + "request_headers": response.request.headers, + "response_body": response.body, + "response_headers": response.headers, + "status": response.status, + "start": datetime.fromtimestamp(response.request.start_time), + "duration": response.duration, } - self._store.add(request_data) + logger.info(f"request appended to store: {request_data}") + self._store.put(request_data) diff --git a/mrok/agent/sidecar/inspector.py b/mrok/agent/sidecar/inspector.py old mode 100644 new mode 100755 index 991bfc8..010c8c5 --- a/mrok/agent/sidecar/inspector.py +++ b/mrok/agent/sidecar/inspector.py @@ -1,8 +1,11 @@ -import httpx -import typer +#!/usr/bin/env python3 +from multiprocessing.managers import BaseManager +from queue import Empty, Queue + from rich.panel import Panel from rich.syntax import Syntax from rich.text import Text +from textual import log from textual.app import App, ComposeResult from textual.containers import Container, Horizontal, Vertical, VerticalScroll from textual.reactive import reactive @@ -113,13 +116,24 @@ class InspectorApp(App): } """ - def __init__(self): + def __init__(self, store: Queue | None = None): super().__init__() + if store: + self.store = store + else: + + class RequestManager(BaseManager): + pass + + RequestManager.register("get_queue") + self.manager = RequestManager(("", 5000), authkey=b"whatever") + self.manager.connect() + self.store = self.manager.get_queue() + self.table = DataTable(zebra_stripes=True, cursor_type="row") self.detail_card = RequestDetailCard() self.refresh_interval = 10.0 self.requests = {} - self.last_request = 0 self.settings = get_settings() self.store_port = self.settings.sidecar.store_port @@ -134,31 +148,24 @@ def compose(self) -> ComposeResult: async def on_mount(self): self.table.add_columns(("ID", "id"), "Method", "Status", "Path", "Duration (ms)") - await self.refresh_data() - self.set_interval(self.refresh_interval, self.refresh_data) + self.set_interval(1, self.refresh_data) - async def refresh_data(self): + def refresh_data(self): + log("getting item from store") try: - async with httpx.AsyncClient() as client: - query = f"?offset={self.last_request}" if self.last_request else "" - resp = await client.get(f"http://127.0.0.1:{self.store_port}/requests/{query}") - requests = resp.json() - except Exception as e: - typer.echo(f"[red]Refresh_data failed:[/red] {e}") + r = self.store.get(timeout=0.5) + except Empty: return - - for r in requests: - self.table.add_row( - r["id"], - r.get("method", ""), - r.get("status", ""), - r.get("path", ""), - int((r.get("duration", 0)) * 1000), - key=str(r["id"]), - ) - self.requests[str(r["id"])] = r - self.last_request = max(self.last_request, r["id"]) - self.table.sort("id", reverse=True) + log(f"add item {r} to table") + self.table.add_row( + r["id"], + r.get("method", ""), + r.get("status", ""), + r.get("path", ""), + int((r.get("duration", 0)) * 1000), + key=str(r["id"]), + ) + self.requests[str(r["id"])] = r def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: req_id = event.row_key.value diff --git a/mrok/agent/sidecar/main.py b/mrok/agent/sidecar/main.py index 020bdb6..0f88c7f 100644 --- a/mrok/agent/sidecar/main.py +++ b/mrok/agent/sidecar/main.py @@ -1,33 +1,88 @@ import asyncio import contextlib from functools import partial +from multiprocessing.managers import BaseManager from pathlib import Path +from queue import Queue +from typing import Literal + +from textual_serve.server import Server from mrok.agent.sidecar.app import ForwardApp -from mrok.agent.sidecar.store import RequestStore +from mrok.agent.sidecar.inspector import InspectorApp from mrok.http.config import MrokBackendConfig from mrok.http.master import Master from mrok.http.server import MrokServer +queue: Queue = Queue() + + +class RequestManager(BaseManager): + pass + + +RequestManager.register("get_queue", lambda: queue) + def run_sidecar( identity_file: str, target_addr: str | Path | tuple[str, int], - store: RequestStore | None = None, + store: Queue | None = None, + suppress_logs: bool | None = None, ): - config = MrokBackendConfig(ForwardApp(target_addr, store=store), identity_file) + config = MrokBackendConfig( + ForwardApp(target_addr, store=store), identity_file, suppress_logs=suppress_logs + ) server = MrokServer(config) with contextlib.suppress(KeyboardInterrupt, asyncio.CancelledError): server.run() +def run_web( + identity_file: str, + target_addr: str | Path | tuple[str, int], + workers: int = 4, +): + with RequestManager(("", 5000), authkey=b"whatever") as manager: + store = manager.get_queue() + start_fn = partial(run_sidecar, identity_file, target_addr, store, True) + master = Master(start_fn, workers=workers, reload=False) + master.start() + server = Server( + str(Path(__file__).resolve().parent / "inspector.py"), + port=5555, + ) + server.serve() + master.stop() + + +def run_console( + identity_file: str, + target_addr: str | Path | tuple[str, int], + workers: int = 4, +): + with RequestManager(("", 5000)) as manager: + store = manager.get_queue() + app = InspectorApp(store) + start_fn = partial(run_sidecar, identity_file, target_addr, store, True) + master = Master(start_fn, workers=workers, reload=False) + master.start() + app.run() + master.stop() + + def run( identity_file: str, target_addr: str | Path | tuple[str, int], - workers=4, - reload=False, - store: RequestStore | None = None, + workers: int = 4, + dev_console: Literal["web", "console"] | None = None, ): - start_fn = partial(run_sidecar, identity_file, target_addr, store) - master = Master(start_fn, workers=workers, reload=reload) - master.run() + match dev_console: + case "console": + run_console(identity_file, target_addr, workers=workers) + case "web": + run_web(identity_file, target_addr, workers=workers) + case _: + start_fn = partial(run_sidecar, identity_file, target_addr) + master = Master(start_fn, workers=workers, reload=False) + master.run() diff --git a/mrok/agent/sidecar/store.py b/mrok/agent/sidecar/store.py deleted file mode 100644 index 4859ea6..0000000 --- a/mrok/agent/sidecar/store.py +++ /dev/null @@ -1,28 +0,0 @@ -from collections import deque -from multiprocessing import Lock -from multiprocessing.managers import BaseManager -from typing import Any - -from mrok.conf import get_settings - - -class RequestStore: - def __init__(self): - settings = get_settings() - self._requests = deque(maxlen=settings.sidecar.store_size) - self._lock = Lock() - self._request_counter = 1 - - def add(self, request: dict[str, Any]) -> None: - with self._lock: - request["id"] = self._request_counter - self._requests.appendleft(request) - self._request_counter += 1 - - def get_all(self, offset: int = 0) -> list[dict]: - with self._lock: - return [request for request in self._requests if request["id"] > offset] - - -class RequestStoreManager(BaseManager): - pass diff --git a/mrok/cli/commands/agent/run/sidecar.py b/mrok/cli/commands/agent/run/sidecar.py index 6dd9a99..d529d88 100644 --- a/mrok/cli/commands/agent/run/sidecar.py +++ b/mrok/cli/commands/agent/run/sidecar.py @@ -1,26 +1,14 @@ -import multiprocessing from pathlib import Path -from typing import Annotated +from typing import Annotated, Literal import typer -from rich.panel import Panel from mrok.agent import sidecar -from mrok.agent.sidecar.store import RequestStore, RequestStoreManager from mrok.cli.commands.agent.utils import ( - get_textual_command, - inspector_port, number_of_workers, - run_inspect_api, - run_textual, - store_api_port, ) -from mrok.cli.rich import get_console -from mrok.conf import get_settings default_workers = number_of_workers() -default_inspector_port = inspector_port() -default_store_port = store_api_port() def register(app: typer.Typer) -> None: @@ -43,41 +31,14 @@ def run_sidecar( show_default=True, ), ] = default_workers, - reload: Annotated[ - bool, + dev_console: Annotated[ + Literal["web", "console"] | None, typer.Option( - "--reload", - "-r", - help="Enable auto-reload. Default: False", - show_default=True, - ), - ] = False, - inspect: Annotated[ - bool, - typer.Option( - "--inspect", - "-i", + "--dev-console", help="Enable inspection. Default: False", show_default=True, ), - ] = False, - textual_port: Annotated[ - int, - typer.Option( - "--inspector-port", - help=f"Port for Web inspector. Default: {default_inspector_port}", - show_default=True, - ), - ] = default_inspector_port, - console_mode: Annotated[ - bool, - typer.Option( - "--console", - "-c", - help="Enable inspector console mode. Default: False", - show_default=True, - ), - ] = False, + ] = None, ): """Run a Sidecar Proxy to expose a web application through OpenZiti.""" if ":" in str(target): @@ -86,55 +47,4 @@ def run_sidecar( else: target_addr = str(target) # type: ignore - if inspect: - settings = get_settings() - console = get_console() - - RequestStoreManager.register("RequestStore", RequestStore) - with RequestStoreManager() as manager: - request_store = manager.RequestStore() # type: ignore - - if console_mode: - inspector_proc = multiprocessing.Process( - target=run_inspect_api, - args=(request_store, settings.sidecar.store_port), - daemon=True, - ) - inspector_proc.start() - console.print( - Panel( - f"[bold yellow]To open inspector, run in a new terminal:" - f"[/bold yellow]\n[bold green]{get_textual_command()}[/bold green]", - title="mrok Inspector", - border_style="cyan", - ) - ) - else: - inspector_proc = multiprocessing.Process( - target=run_textual, - args=(textual_port, settings.sidecar.store_port, request_store), - daemon=True, - ) - inspector_proc.start() - console.print( - Panel( - f"Web inspector running at http://localhost:{textual_port}", - title="mrok Web Inspector", - border_style="cyan", - ) - ) - - try: - sidecar.run( - str(identity_file), - target_addr, - workers=workers, - reload=reload, - store=request_store, - ) - finally: - if inspector_proc: - inspector_proc.terminate() - console.print("mrok Inspector stopped") - else: - sidecar.run(str(identity_file), target_addr, workers=workers, reload=reload) + sidecar.run(str(identity_file), target_addr, workers=workers, dev_console=dev_console) diff --git a/mrok/cli/commands/agent/utils.py b/mrok/cli/commands/agent/utils.py index 585764a..317628d 100644 --- a/mrok/cli/commands/agent/utils.py +++ b/mrok/cli/commands/agent/utils.py @@ -1,114 +1,95 @@ -import asyncio import multiprocessing -import signal -import uvicorn -from aiohttp import web -from fastapi import FastAPI -from textual_serve.server import Server - -from mrok.agent.sidecar.store import RequestStore -from mrok.conf import get_settings -from mrok.logging import setup_inspector_logging +# from mrok.agent.sidecar.store import RequestStore def number_of_workers() -> int: return (multiprocessing.cpu_count() * 2) + 1 -def inspector_port() -> int: - settings = get_settings() - return settings.sidecar.textual_port - - -def store_api_port() -> int: - settings = get_settings() - return settings.sidecar.store_port - - -def get_textual_command() -> str: - settings = get_settings() - return settings.sidecar.textual_command +# def get_textual_command() -> str: +# settings = get_settings() +# return settings.sidecar.textual_command -def create_inspection_server(store: RequestStore) -> FastAPI: - inspect_api = FastAPI() +# def create_inspection_server(store: RequestStore) -> FastAPI: +# inspect_api = FastAPI() - @inspect_api.get("/requests/") - async def list_requests(offset: int = 0) -> list[dict]: - return store.get_all(offset=offset) +# @inspect_api.get("/requests/") +# async def list_requests(offset: int = 0) -> list[dict]: +# return store.get_all(offset=offset) - return inspect_api +# return inspect_api -def run_inspect_api(store: RequestStore, port: int, log_level: str = "warning") -> None: - inspect_api = create_inspection_server(store) - uvicorn.run(inspect_api, port=port, log_level=log_level) +# def run_inspect_api(store: RequestStore, port: int, log_level: str = "warning") -> None: +# inspect_api = create_inspection_server(store) +# uvicorn.run(inspect_api, port=port, log_level=log_level) -class MServer(Server): - def __init__(self, command: str, port: int, api_port: int, store: RequestStore): - super().__init__(command, port=port, title="MROK Web inspector") - self.api_port = api_port - self.store = store - self.store_api_runner = None +# class MServer(Server): +# def __init__(self, command: str, port: int, api_port: int, store: RequestStore): +# super().__init__(command, port=port, title="MROK Web inspector") +# self.api_port = api_port +# self.store = store +# self.store_api_runner = None - def initialize_logging(self) -> None: - setup_inspector_logging(self.console) +# def initialize_logging(self) -> None: +# setup_inspector_logging(self.console) - def serve(self, debug: bool = False) -> None: # pragma: no cover - self.debug = debug - self.initialize_logging() +# def serve(self, debug: bool = False) -> None: # pragma: no cover +# self.debug = debug +# self.initialize_logging() - try: - loop = asyncio.get_event_loop() - except Exception: - loop = asyncio.new_event_loop() +# try: +# loop = asyncio.get_event_loop() +# except Exception: +# loop = asyncio.new_event_loop() - loop.add_signal_handler(signal.SIGINT, self.request_exit) - loop.add_signal_handler(signal.SIGTERM, self.request_exit) +# loop.add_signal_handler(signal.SIGINT, self.request_exit) +# loop.add_signal_handler(signal.SIGTERM, self.request_exit) - loop.run_until_complete(self._serve(loop)) +# loop.run_until_complete(self._serve(loop)) - async def _serve(self, loop: asyncio.AbstractEventLoop): # pragma: no cover - textual_app = await self._make_app() - api_app = create_inspection_server(self.store) +# async def _serve(self, loop: asyncio.AbstractEventLoop): # pragma: no cover +# textual_app = await self._make_app() +# api_app = create_inspection_server(self.store) - textual_runner = web.AppRunner(textual_app) - await textual_runner.setup() - textual_site = web.TCPSite(textual_runner, self.host, self.port) - await textual_site.start() +# textual_runner = web.AppRunner(textual_app) +# await textual_runner.setup() +# textual_site = web.TCPSite(textual_runner, self.host, self.port) +# await textual_site.start() - stop_event = asyncio.Event() +# stop_event = asyncio.Event() - def stop() -> None: - stop_event.set() +# def stop() -> None: +# stop_event.set() - loop.add_signal_handler(signal.SIGINT, stop) - loop.add_signal_handler(signal.SIGTERM, stop) +# loop.add_signal_handler(signal.SIGINT, stop) +# loop.add_signal_handler(signal.SIGTERM, stop) - config = uvicorn.Config( - api_app, - host=self.host, - port=self.api_port, - loop="asyncio", - log_level="info", - ) - api_server = uvicorn.Server(config) +# config = uvicorn.Config( +# api_app, +# host=self.host, +# port=self.api_port, +# loop="asyncio", +# log_level="info", +# ) +# api_server = uvicorn.Server(config) - async def start_fastapi(): - await api_server.serve() +# async def start_fastapi(): +# await api_server.serve() - fastapi_task = asyncio.create_task(start_fastapi()) +# fastapi_task = asyncio.create_task(start_fastapi()) - try: - await stop_event.wait() - finally: - api_server.should_exit = True - await fastapi_task - await textual_runner.cleanup() +# try: +# await stop_event.wait() +# finally: +# api_server.should_exit = True +# await fastapi_task +# await textual_runner.cleanup() -def run_textual(port: int, api_port: int, store: RequestStore) -> None: - server = MServer(get_textual_command(), port=port, api_port=api_port, store=store) - server.serve() +# def run_textual(port: int, api_port: int, store: RequestStore) -> None: +# server = MServer(get_textual_command(), port=port, api_port=api_port, store=store) +# server.serve() diff --git a/mrok/http/config.py b/mrok/http/config.py index 3719d4b..5fc4e70 100644 --- a/mrok/http/config.py +++ b/mrok/http/config.py @@ -26,12 +26,14 @@ def __init__( identity_file: str | Path, ziti_load_timeout_ms: int = 5000, backlog: int = 2048, + suppress_logs: bool | None = None, ): self.identity_file = identity_file self.ziti_load_timeout_ms = ziti_load_timeout_ms self.service_name, self.identity_name, self.instance_id = self.get_identity_info( identity_file ) + self.suppress_logs = suppress_logs super().__init__( app, loop="asyncio", @@ -62,4 +64,5 @@ def bind_socket(self) -> socket.socket: return sock def configure_logging(self) -> None: - setup_logging(get_settings()) + if not self.suppress_logs: + setup_logging(get_settings()) diff --git a/mrok/http/constants.py b/mrok/http/constants.py new file mode 100644 index 0000000..e79ab05 --- /dev/null +++ b/mrok/http/constants.py @@ -0,0 +1,22 @@ +MAX_REQUEST_BODY_BYTES = 2 * 1024 * 1024 +MAX_RESPONSE_BODY_BYTES = 5 * 1024 * 1024 + +BINARY_CONTENT_TYPES = { + "application/octet-stream", + "application/pdf", +} + +BINARY_PREFIXES = ( + "image/", + "video/", + "audio/", +) + +TEXTUAL_CONTENT_TYPES = { + "application/json", + "application/xml", + "application/javascript", + "application/x-www-form-urlencoded", +} + +TEXTUAL_PREFIXES = ("text/",) diff --git a/mrok/http/datastructures.py b/mrok/http/datastructures.py new file mode 100644 index 0000000..74c0b80 --- /dev/null +++ b/mrok/http/datastructures.py @@ -0,0 +1,65 @@ +from dataclasses import dataclass + + +class HTTPHeaders: + def __init__(self, data: list[tuple[bytes, bytes]]): + self._store = {k[0].decode().lower(): k[1].decode() for k in data} + + def __getitem__(self, key): + return self._store[key.lower()] + + def __len__(self): + return len(self._store) + + def __iter__(self): + return iter(self._store) + + +@dataclass +class Request: + method: str + url: str + headers: HTTPHeaders + query_string: bytes + start_time: float + body: bytes | None = None + body_truncated: bool | None = None + + +@dataclass +class Response: + request: Request + status: int + headers: HTTPHeaders + duration: float + body: bytes | None = None + body_truncated: bool | None = None + + +class FixedSizeByteBuffer: + def __init__(self, max_size: int): + self._max_size = max_size + self._buf = bytearray() + self.overflow = False + + def write(self, data: bytes) -> None: + if not data: + return + + remaining = self._max_size - len(self._buf) + if remaining <= 0: + self.overflow = True + return + + if len(data) > remaining: + self._buf.extend(data[:remaining]) + self.overflow = True + else: + self._buf.extend(data) + + def getvalue(self) -> bytes: + return bytes(self._buf) + + def clear(self) -> None: + self._buf.clear() + self.overflow = False diff --git a/mrok/http/forwarder.py b/mrok/http/forwarder.py index 46bb00a..6ad053b 100644 --- a/mrok/http/forwarder.py +++ b/mrok/http/forwarder.py @@ -1,15 +1,22 @@ import abc import asyncio +import inspect import logging import time -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Coroutine from typing import Any +from mrok.http.constants import MAX_REQUEST_BODY_BYTES, MAX_RESPONSE_BODY_BYTES +from mrok.http.datastructures import FixedSizeByteBuffer, HTTPHeaders, Request, Response +from mrok.http.utils import must_capture_request, must_capture_response + logger = logging.getLogger("mrok.proxy") Scope = dict[str, Any] ASGIReceive = Callable[[], Awaitable[dict[str, Any]]] ASGISend = Callable[[dict[str, Any]], Awaitable[None]] +RequestCompleteCallback = Callable[[Request], Coroutine[Any, Any, None] | None] +ResponseCompleteCallback = Callable[[Response], Coroutine[Any, Any, None] | None] class BackendNotFoundError(Exception): @@ -25,16 +32,22 @@ class ForwardAppBase(abc.ABC): and streaming logic (requests and responses). """ - def __init__(self, read_chunk_size: int = 65536) -> None: + def __init__( + self, + read_chunk_size: int = 65536, + on_request_complete: RequestCompleteCallback | None = None, + on_response_complete: ResponseCompleteCallback | None = None, + ) -> None: # number of bytes to read per iteration when streaming bodies self._read_chunk_size: int = int(read_chunk_size) - self.capture_body = False + self._on_request_complete = on_request_complete + self._on_response_complete = on_response_complete @abc.abstractmethod async def select_backend( self, scope: Scope, - headers: dict[str, str], + headers: dict, ) -> tuple[asyncio.StreamReader, asyncio.StreamWriter] | tuple[None, None]: """Return (reader, writer) connected to the target backend.""" @@ -44,9 +57,9 @@ async def __call__(self, scope: Scope, receive: ASGIReceive, send: ASGISend) -> Delegates to smaller helper methods for readability. Subclasses only need to implement backend selection. """ - start_time = time.time() - request_buffer = bytearray() if self.capture_body else None - response_buffer = bytearray() if self.capture_body else None + start_time = time.monotonic() + request_buffer = None + response_buffer = None if scope.get("type") != "http": await send({"type": "http.response.start", "status": 500, "headers": []}) @@ -56,12 +69,17 @@ async def __call__(self, scope: Scope, receive: ASGIReceive, send: ASGISend) -> method = scope.get("method", "GET") path_qs = self.format_path(scope) - headers = list(scope.get("headers", [])) - headers = self.ensure_host_header(headers, scope) + headers = self.ensure_host_header(list(scope.get("headers", [])), scope) + request_headers_dict = {k[0].decode().lower(): k[1].decode() for k in headers} reader, writer = await self.select_backend( - scope, {k[0].decode().lower(): k[1].decode() for k in headers} + scope, + request_headers_dict, ) + if self._on_request_complete and must_capture_request(method, request_headers_dict): + request_buffer = FixedSizeByteBuffer(MAX_REQUEST_BODY_BYTES) + + # TODO what we should do here ? if not (reader and writer): await send({"type": "http.response.start", "status": 502, "headers": []}) await send({"type": "http.response.body", "body": b"Bad Gateway"}) @@ -73,6 +91,22 @@ async def __call__(self, scope: Scope, receive: ASGIReceive, send: ASGISend) -> await self.stream_request_body(receive, writer, use_chunked, request_buffer) + request = None + + if self._on_request_complete or self._on_response_complete: + request = Request( + method=scope["method"], + url=scope["path"], + headers=HTTPHeaders(headers), + query_string=scope["query_string"], + start_time=start_time, + ) + if request_buffer: + request.body = request_buffer.getvalue() + request.body_truncated = request_buffer.overflow + + await self.handle_callback(self._on_request_complete, request) + status_line = await reader.readline() if not status_line: await send({"type": "http.response.start", "status": 502, "headers": []}) @@ -83,23 +117,31 @@ async def __call__(self, scope: Scope, receive: ASGIReceive, send: ASGISend) -> status, headers_out, raw_headers = await self.read_status_and_headers(reader, status_line) + response_headers_dict = {k[0].decode().lower(): k[1].decode() for k in headers_out} + + if self._on_response_complete and must_capture_response(response_headers_dict): + response_buffer = FixedSizeByteBuffer(MAX_RESPONSE_BODY_BYTES) + await send({"type": "http.response.start", "status": status, "headers": headers_out}) await self.stream_response_body(reader, send, raw_headers, response_buffer) - await self.on_response_complete( - scope, - status, - headers, - headers_out, - start_time, - request_buffer, - response_buffer, - ) - writer.close() await writer.wait_closed() + if self._on_response_complete: + response = Response( + request=request, # type: ignore + status=status, + headers=HTTPHeaders(headers_out), + duration=time.monotonic() - start_time, + ) + if response_buffer: + response.body = response_buffer.getvalue() + response.body_truncated = response_buffer.overflow + + await self.handle_callback(self._on_response_complete, response) + def format_path(self, scope: Scope) -> str: raw_path = scope.get("raw_path") if raw_path: @@ -150,7 +192,7 @@ async def stream_request_body( receive: ASGIReceive, writer: asyncio.StreamWriter, use_chunked: bool, - request_buffer: bytearray | None = None, + request_buffer: FixedSizeByteBuffer | None = None, ) -> None: if use_chunked: await self.stream_request_chunked(receive, writer, request_buffer) @@ -162,7 +204,7 @@ async def stream_request_chunked( self, receive: ASGIReceive, writer: asyncio.StreamWriter, - request_buffer: bytearray | None = None, + request_buffer: FixedSizeByteBuffer | None = None, ) -> None: """Send request body to backend using HTTP/1.1 chunked encoding.""" while True: @@ -176,7 +218,7 @@ async def stream_request_chunked( await writer.drain() if request_buffer is not None: - request_buffer.extend(body) + request_buffer.write(body) if not event.get("more_body", False): break elif event["type"] == "http.disconnect": @@ -190,7 +232,7 @@ async def stream_request_until_end( self, receive: ASGIReceive, writer: asyncio.StreamWriter, - request_buffer: bytearray | None = None, + request_buffer: FixedSizeByteBuffer | None = None, ) -> None: """Send request body to backend when content length/transfer-encoding already provided (no chunking). @@ -204,7 +246,7 @@ async def stream_request_until_end( await writer.drain() if request_buffer is not None: - request_buffer.extend(body) + request_buffer.write(body) if not event.get("more_body", False): break elif event["type"] == "http.disconnect": @@ -260,7 +302,7 @@ async def stream_response_chunked( self, reader: asyncio.StreamReader, send: ASGISend, - response_buffer: bytearray | None = None, + response_buffer: FixedSizeByteBuffer | None = None, ) -> None: """Read chunked-encoded response from reader, decode and forward to ASGI send.""" while True: @@ -287,7 +329,7 @@ async def stream_response_chunked( logger.warning("failed to read CRLF after chunk from backend") await send({"type": "http.response.body", "body": chunk, "more_body": True}) if response_buffer is not None: - response_buffer.extend(chunk) + response_buffer.write(chunk) await send({"type": "http.response.body", "body": b"", "more_body": False}) @@ -296,7 +338,7 @@ async def stream_response_with_content_length( reader: asyncio.StreamReader, send: ASGISend, content_length: int, - response_buffer: bytearray | None = None, + response_buffer: FixedSizeByteBuffer | None = None, ) -> None: """Read exactly content_length bytes and forward to ASGI send events.""" remaining = content_length @@ -310,7 +352,7 @@ async def stream_response_with_content_length( more = remaining > 0 await send({"type": "http.response.body", "body": chunk, "more_body": more}) if response_buffer is not None: - response_buffer.extend(chunk) + response_buffer.write(chunk) if not more: sent_final = True @@ -321,7 +363,7 @@ async def stream_response_until_eof( self, reader: asyncio.StreamReader, send: ASGISend, - response_buffer: bytearray | None = None, + response_buffer: FixedSizeByteBuffer | None = None, ) -> None: """Read from reader until EOF and forward chunks to ASGI send events.""" while True: @@ -330,7 +372,7 @@ async def stream_response_until_eof( break await send({"type": "http.response.body", "body": chunk, "more_body": True}) if response_buffer is not None: - response_buffer.extend(chunk) + response_buffer.write(chunk) await send({"type": "http.response.body", "body": b"", "more_body": False}) async def stream_response_body( @@ -338,7 +380,7 @@ async def stream_response_body( reader: asyncio.StreamReader, send: ASGISend, raw_headers: dict[bytes, bytes], - response_buffer: bytearray | None = None, + response_buffer: FixedSizeByteBuffer | None = None, ) -> None: te = raw_headers.get(b"transfer-encoding", b"").lower() cl = raw_headers.get(b"content-length") @@ -356,14 +398,9 @@ async def stream_response_body( await self.stream_response_until_eof(reader, send, response_buffer) - async def on_response_complete( - self, - scope: Scope, - status: int, - raw_headers: list[tuple[bytes, bytes]], - headers_out: list[tuple[bytes, bytes]], - start_time: float, - request_buffer: bytearray | None, - response_buffer: bytearray | None, - ) -> None: - return + async def handle_callback(self, callback, *args, **kwargs): + if callback: + if inspect.iscoroutinefunction(callback): + await callback(*args, **kwargs) + else: + await asyncio.get_running_loop().run_in_executor(None, callback, *args, **kwargs) diff --git a/mrok/http/master.py b/mrok/http/master.py index fd4e10b..b9e6493 100644 --- a/mrok/http/master.py +++ b/mrok/http/master.py @@ -65,18 +65,37 @@ def start_worker(self, worker_id: int): return p def start(self): + self.start_child_processes() + self.start_child_processes_monitor() + + def stop(self): + self.stop_child_processes_monitor() + self.stop_child_processes() + + def start_child_processes(self): for i in range(self.workers): p = self.start_worker(i) self.worker_processes[i] = p - def stop(self): + def stop_child_processes(self): for process in self.worker_processes.values(): process.stop(sigint_timeout=5, sigkill_timeout=1) self.worker_processes.clear() - def restart(self): - self.stop() - self.start() + def restart_child_processes(self): + self.stop_child_processes() + self.start_child_processes() + + def start_child_processes_monitor(self): + # Start worker monitoring thread + self.monitor_thread = threading.Thread(target=self.monitor_workers, daemon=True) + self.monitor_thread.start() + logger.debug("Worker monitoring thread started") + + def stop_child_processes_monitor(self): + if self.monitor_thread and self.monitor_thread.is_alive(): # pragma: no cover + logger.debug("Wait for monitor worker to exit") + self.monitor_thread.join(timeout=MONITOR_THREAD_JOIN_TIMEOUT) def monitor_workers(self): while not self.stop_event.is_set(): @@ -109,12 +128,6 @@ def __next__(self): def run(self): self.start() logger.info(f"Master process started: {os.getpid()}") - - # Start worker monitoring thread - self.monitor_thread = threading.Thread(target=self.monitor_workers, daemon=True) - self.monitor_thread.start() - logger.debug("Worker monitoring thread started") - try: if self.reload: for files_changed in self: @@ -122,11 +135,8 @@ def run(self): logger.warning( f"{', '.join(map(print_path, files_changed))} changed, reloading...", ) - self.restart() + self.restart_child_processes() else: self.stop_event.wait() finally: - if self.monitor_thread and self.monitor_thread.is_alive(): # pragma: no cover - logger.debug("Wait for monitor worker to exit") - self.monitor_thread.join(timeout=MONITOR_THREAD_JOIN_TIMEOUT) self.stop() diff --git a/mrok/http/utils.py b/mrok/http/utils.py new file mode 100644 index 0000000..33f747f --- /dev/null +++ b/mrok/http/utils.py @@ -0,0 +1,90 @@ +from collections.abc import Mapping + +from mrok.http.constants import ( + BINARY_CONTENT_TYPES, + BINARY_PREFIXES, + MAX_REQUEST_BODY_BYTES, + MAX_RESPONSE_BODY_BYTES, + TEXTUAL_CONTENT_TYPES, + TEXTUAL_PREFIXES, +) + + +def _is_binary(content_type: str) -> bool: + ct = content_type.lower() + if ct in BINARY_CONTENT_TYPES: + return True + if any(ct.startswith(p) for p in BINARY_PREFIXES): + return True + return False + + +def _is_textual(content_type: str) -> bool: + ct = content_type.lower() + if ct in TEXTUAL_CONTENT_TYPES: + return True + if any(ct.startswith(p) for p in TEXTUAL_PREFIXES): + return True + return False + + +def must_capture_request( + method: str, + headers: Mapping, +) -> bool: + method = method.upper() + + # No body expected + if method in ("GET", "HEAD", "OPTIONS", "TRACE"): + return False + + content_type = headers.get("content-type", "").lower() + + content_length = None + if "content-length" in headers: + content_length = int(headers["content-length"]) + + if _is_binary(content_type): + return False + + if content_type.startswith("multipart/form-data"): + return False + + if content_length is not None and content_length > MAX_REQUEST_BODY_BYTES: + return False + + if _is_textual(content_type): + return True + + if content_length is None: + return True + + return content_length <= MAX_REQUEST_BODY_BYTES + + +def must_capture_response( + headers: dict, +) -> bool: + content_type = headers.get("content-type", "").lower() + disposition = headers.get("content-disposition", "").lower() + + content_length = None + if "content-length" in headers: + content_length = int(headers["content-length"]) + + if "attachment" in disposition: + return False + + if _is_binary(content_type): + return False + + if content_length is not None and content_length > MAX_RESPONSE_BODY_BYTES: + return False + + if _is_textual(content_type): + return True + + if content_length is None: + return True + + return content_length <= MAX_RESPONSE_BODY_BYTES diff --git a/pyproject.toml b/pyproject.toml index a392192..40c8cd0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ dev = [ "pytest-mock>=3.15.1,<4.0.0", "pytest-randomly>=4.0.1,<5.0.0", "ruff>=0.14.0,<0.15.0", + "textual-dev>=1.8.0,<2.0.0", "types-pyyaml>=6.0.12.20250915,<7.0.0.0", ] diff --git a/uv.lock b/uv.lock index 39370f9..86aa28c 100644 --- a/uv.lock +++ b/uv.lock @@ -963,6 +963,7 @@ dev = [ { name = "pytest-mock" }, { name = "pytest-randomly" }, { name = "ruff" }, + { name = "textual-dev" }, { name = "types-pyyaml" }, ] @@ -1003,9 +1004,54 @@ dev = [ { name = "pytest-mock", specifier = ">=3.15.1,<4.0.0" }, { name = "pytest-randomly", specifier = ">=4.0.1,<5.0.0" }, { name = "ruff", specifier = ">=0.14.0,<0.15.0" }, + { name = "textual-dev", specifier = ">=1.8.0,<2.0.0" }, { name = "types-pyyaml", specifier = ">=6.0.12.20250915,<7.0.0.0" }, ] +[[package]] +name = "msgpack" +version = "1.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa", size = 81939, upload-time = "2025-10-08T09:15:01.472Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb", size = 85064, upload-time = "2025-10-08T09:15:03.764Z" }, + { url = "https://files.pythonhosted.org/packages/f2/60/a064b0345fc36c4c3d2c743c82d9100c40388d77f0b48b2f04d6041dbec1/msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f", size = 417131, upload-time = "2025-10-08T09:15:05.136Z" }, + { url = "https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42", size = 427556, upload-time = "2025-10-08T09:15:06.837Z" }, + { url = "https://files.pythonhosted.org/packages/f5/87/ffe21d1bf7d9991354ad93949286f643b2bb6ddbeab66373922b44c3b8cc/msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9", size = 404920, upload-time = "2025-10-08T09:15:08.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/41/8543ed2b8604f7c0d89ce066f42007faac1eaa7d79a81555f206a5cdb889/msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620", size = 415013, upload-time = "2025-10-08T09:15:09.83Z" }, + { url = "https://files.pythonhosted.org/packages/41/0d/2ddfaa8b7e1cee6c490d46cb0a39742b19e2481600a7a0e96537e9c22f43/msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029", size = 65096, upload-time = "2025-10-08T09:15:11.11Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b", size = 72708, upload-time = "2025-10-08T09:15:12.554Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/5b1a1f70eb0e87d1678e9624908f86317787b536060641d6798e3cf70ace/msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69", size = 64119, upload-time = "2025-10-08T09:15:13.589Z" }, + { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" }, + { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" }, + { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" }, + { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" }, + { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" }, + { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" }, + { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" }, + { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" }, + { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" }, + { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" }, + { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" }, + { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" }, + { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" }, + { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" }, + { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" }, + { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" }, + { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" }, + { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" }, + { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" }, +] + [[package]] name = "multidict" version = "6.7.0" @@ -1830,6 +1876,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/42/37/1deba011782a49ea249c73adcf703a39b0249ac9b0e17d1a2e4074df8d57/textual-6.5.0-py3-none-any.whl", hash = "sha256:c5505be7fe606b8054fb88431279885f88352bddca64832f6acd293ef7d9b54f", size = 711848, upload-time = "2025-10-31T17:21:51.134Z" }, ] +[[package]] +name = "textual-dev" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "click" }, + { name = "msgpack" }, + { name = "textual" }, + { name = "textual-serve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/fd/fd5ad9527b536c306a5a860a33a45e13983a59804b48b91ea52b42ba030a/textual_dev-1.8.0.tar.gz", hash = "sha256:7e56867b0341405a95e938cac0647e6d2763d38d0df08710469ad6b6a8db76df", size = 25026, upload-time = "2025-10-11T09:47:01.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/c6/1dc08ceee6d0bdf219c500cb2fbd605b681b63507c4ff27a6477f12f8245/textual_dev-1.8.0-py3-none-any.whl", hash = "sha256:227b6d24a485fbbc77e302aa21f4fdf3083beb57eb45cd95bae082c81cbeddeb", size = 27541, upload-time = "2025-10-11T09:47:00.531Z" }, +] + [[package]] name = "textual-serve" version = "1.1.3" From 6576d8d9384178474f25abd04a4845c3b0458597 Mon Sep 17 00:00:00 2001 From: Francesco Faraone Date: Mon, 1 Dec 2025 18:44:39 +0100 Subject: [PATCH 3/3] with pubsub --- mrok/agent/devtools/__init__.py | 0 mrok/agent/{sidecar => devtools}/inspector.py | 110 +++++++++++------- mrok/agent/devtools/ipc.py | 52 +++++++++ mrok/agent/sidecar/app.py | 36 +----- mrok/agent/sidecar/main.py | 79 +++---------- mrok/cli/commands/agent/__init__.py | 2 + mrok/cli/commands/agent/dev/__init__.py | 7 ++ mrok/cli/commands/agent/dev/console.py | 10 ++ mrok/cli/commands/agent/dev/web.py | 18 +++ mrok/cli/commands/agent/run/sidecar.py | 12 +- mrok/http/config.py | 5 +- 11 files changed, 184 insertions(+), 147 deletions(-) create mode 100644 mrok/agent/devtools/__init__.py rename mrok/agent/{sidecar => devtools}/inspector.py (65%) create mode 100644 mrok/agent/devtools/ipc.py create mode 100644 mrok/cli/commands/agent/dev/__init__.py create mode 100644 mrok/cli/commands/agent/dev/console.py create mode 100644 mrok/cli/commands/agent/dev/web.py diff --git a/mrok/agent/devtools/__init__.py b/mrok/agent/devtools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mrok/agent/sidecar/inspector.py b/mrok/agent/devtools/inspector.py similarity index 65% rename from mrok/agent/sidecar/inspector.py rename to mrok/agent/devtools/inspector.py index 010c8c5..97a3c18 100755 --- a/mrok/agent/sidecar/inspector.py +++ b/mrok/agent/devtools/inspector.py @@ -1,18 +1,20 @@ #!/usr/bin/env python3 -from multiprocessing.managers import BaseManager -from queue import Empty, Queue +import os +import time from rich.panel import Panel from rich.syntax import Syntax from rich.text import Text -from textual import log +from textual import work from textual.app import App, ComposeResult from textual.containers import Container, Horizontal, Vertical, VerticalScroll from textual.reactive import reactive from textual.widget import Widget from textual.widgets import DataTable, Footer, Header, Static, TabbedContent, TabPane +from mrok.agent.devtools.ipc import PubSubManager from mrok.conf import get_settings +from mrok.http.datastructures import Response def color_for_status(status: int) -> str: @@ -116,60 +118,84 @@ class InspectorApp(App): } """ - def __init__(self, store: Queue | None = None): + def __init__(self): super().__init__() - if store: - self.store = store - else: - - class RequestManager(BaseManager): - pass - - RequestManager.register("get_queue") - self.manager = RequestManager(("", 5000), authkey=b"whatever") - self.manager.connect() - self.store = self.manager.get_queue() - - self.table = DataTable(zebra_stripes=True, cursor_type="row") self.detail_card = RequestDetailCard() self.refresh_interval = 10.0 self.requests = {} self.settings = get_settings() - self.store_port = self.settings.sidecar.store_port + self.manager = PubSubManager(address=("localhost", 50000), authkey=b"secret") + self.consumer_name = f"mrok_devtools_{os.getpid()}" + self.queue = None def compose(self) -> ComposeResult: yield Header() with Container(id="app-grid"): with VerticalScroll(id="left-pane"): - yield self.table + yield DataTable(id="requests", zebra_stripes=True, cursor_type="row") with Horizontal(id="right-pane"): yield self.detail_card yield Footer() async def on_mount(self): - self.table.add_columns(("ID", "id"), "Method", "Status", "Path", "Duration (ms)") - self.set_interval(1, self.refresh_data) - - def refresh_data(self): - log("getting item from store") - try: - r = self.store.get(timeout=0.5) - except Empty: - return - log(f"add item {r} to table") - self.table.add_row( - r["id"], - r.get("method", ""), - r.get("status", ""), - r.get("path", ""), - int((r.get("duration", 0)) * 1000), - key=str(r["id"]), - ) - self.requests[str(r["id"])] = r - - def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: - req_id = event.row_key.value - self.detail_card.request = self.requests.get(req_id) + table = self.query_one("#requests", DataTable) + table.add_columns(("ID", "id"), "Method", "Status", "Path", "Duration (ms)") + self.manager.connect() + self.queue = self.manager.register_subscriber(self.consumer_name) + self.heartbeat() + self.consumer() + + @work(exclusive=True, thread=True) + def heartbeat(self): + while True: + try: + self.manager.send_heartbeat(self.consumer_name) + except Exception: + return + time.sleep(1) + + @work(exclusive=True, thread=True) + def consumer(self): + table = self.query_one("#requests", DataTable) + while True: + try: + response: Response = self.queue.get() + req_id = time.monotonic_ns() + self.requests[req_id] = response + table.add_row( + req_id, + response.request.method, + response.status, + response.request.url, + response.duration, + key=req_id, + ) + except Exception: + return + time.sleep(1) + + # # self.set_interval(1, self.refresh_data) + + # def refresh_data(self): + # log("getting item from store") + # try: + # r = self.store.get(timeout=0.5) + # except Empty: + # return + # log(f"add item {r} to table") + # self.table.add_row( + # r["id"], + # r.get("method", ""), + # r.get("status", ""), + # r.get("path", ""), + # int((r.get("duration", 0)) * 1000), + # key=str(r["id"]), + # ) + # self.requests[str(r["id"])] = r + + # def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + # req_id = event.row_key.value + # self.detail_card.request = self.requests.get(req_id) if __name__ == "__main__": # pragma: no cover diff --git a/mrok/agent/devtools/ipc.py b/mrok/agent/devtools/ipc.py new file mode 100644 index 0000000..b82fdad --- /dev/null +++ b/mrok/agent/devtools/ipc.py @@ -0,0 +1,52 @@ +import time +from multiprocessing.managers import BaseManager, ListProxy +from queue import Queue + +subscribers = {} +heartbeats = {} +HEARTBEAT_TIMEOUT = 5 * 60 # five mins + + +def register_subscriber(name): + q = Queue() + subscribers[name] = q + heartbeats[name] = time.time() + return q + + +def send_heartbeat(name): + heartbeats[name] = time.time() + + +def unsubscribe(name): + subscribers.pop(name, None) + heartbeats.pop(name, None) + + +def get_subscriber(name): + return subscribers[name] + + +def get_subscribers(): + return list(subscribers.keys()) + + +class PubSubManager(BaseManager): + pass + + +PubSubManager.register("register_subscriber", callable=register_subscriber) +PubSubManager.register("send_heartbeat", callable=send_heartbeat) +PubSubManager.register("unsubscribe", callable=unsubscribe) +PubSubManager.register("get_subscribers", callable=get_subscribers, proxytype=ListProxy) +PubSubManager.register("get_subscriber", callable=get_subscriber) + + +def cleanup_thread(): + while True: + now = time.time() + dead = [n for n, ts in heartbeats.items() if now - ts > HEARTBEAT_TIMEOUT] + for name in dead: + subscribers.pop(name, None) + heartbeats.pop(name, None) + time.sleep(2) diff --git a/mrok/agent/sidecar/app.py b/mrok/agent/sidecar/app.py index 8b252a1..0e829b9 100644 --- a/mrok/agent/sidecar/app.py +++ b/mrok/agent/sidecar/app.py @@ -1,14 +1,10 @@ import asyncio import logging -import time from collections.abc import Awaitable, Callable -from datetime import datetime from pathlib import Path -from queue import Queue from typing import Any -from mrok.http.datastructures import Response -from mrok.http.forwarder import ForwardAppBase +from mrok.http.forwarder import ForwardAppBase, RequestCompleteCallback, ResponseCompleteCallback logger = logging.getLogger("mrok.proxy") @@ -22,14 +18,15 @@ def __init__( self, target_address: str | Path | tuple[str, int], read_chunk_size: int = 65536, - store: Queue | None = None, + on_request_complete: RequestCompleteCallback | None = None, + on_response_complete: ResponseCompleteCallback | None = None, ) -> None: super().__init__( read_chunk_size=read_chunk_size, - on_response_complete=self.on_response_complete, + on_request_complete=on_request_complete, + on_response_complete=on_response_complete, ) self._target_address = target_address - self._store = store async def select_backend( self, @@ -39,26 +36,3 @@ async def select_backend( if isinstance(self._target_address, tuple): return await asyncio.open_connection(*self._target_address) return await asyncio.open_unix_connection(str(self._target_address)) - - async def on_response_complete( - self, - response: Response, - ) -> None: - if self._store is None: - return - - request_data = { - "id": time.monotonic_ns(), - "method": response.request.method, - "path": response.request.url, - "query_string": response.request.query_string, - "request_body": response.request.body, - "request_headers": response.request.headers, - "response_body": response.body, - "response_headers": response.headers, - "status": response.status, - "start": datetime.fromtimestamp(response.request.start_time), - "duration": response.duration, - } - logger.info(f"request appended to store: {request_data}") - self._store.put(request_data) diff --git a/mrok/agent/sidecar/main.py b/mrok/agent/sidecar/main.py index 0f88c7f..8e3c3cc 100644 --- a/mrok/agent/sidecar/main.py +++ b/mrok/agent/sidecar/main.py @@ -1,88 +1,47 @@ import asyncio import contextlib +import logging from functools import partial -from multiprocessing.managers import BaseManager from pathlib import Path -from queue import Queue -from typing import Literal - -from textual_serve.server import Server +from threading import Thread +from mrok.agent.devtools.ipc import PubSubManager, cleanup_thread from mrok.agent.sidecar.app import ForwardApp -from mrok.agent.sidecar.inspector import InspectorApp from mrok.http.config import MrokBackendConfig +from mrok.http.datastructures import Response from mrok.http.master import Master from mrok.http.server import MrokServer -queue: Queue = Queue() - - -class RequestManager(BaseManager): - pass - - -RequestManager.register("get_queue", lambda: queue) +logger = logging.getLogger("mrok.proxy") def run_sidecar( identity_file: str, target_addr: str | Path | tuple[str, int], - store: Queue | None = None, - suppress_logs: bool | None = None, ): + mgr = PubSubManager(address=("localhost", 50000), authkey=b"secret") + mgr.connect() + + def on_response_complete(response: Response): + for subscriber in mgr.get_subscribers(): # type: ignore + queue = mgr.get_subscriber(subscriber) # type: ignore + queue.put(response) + config = MrokBackendConfig( - ForwardApp(target_addr, store=store), identity_file, suppress_logs=suppress_logs + ForwardApp(target_addr, on_response_complete=on_response_complete), identity_file ) server = MrokServer(config) with contextlib.suppress(KeyboardInterrupt, asyncio.CancelledError): server.run() -def run_web( - identity_file: str, - target_addr: str | Path | tuple[str, int], - workers: int = 4, -): - with RequestManager(("", 5000), authkey=b"whatever") as manager: - store = manager.get_queue() - start_fn = partial(run_sidecar, identity_file, target_addr, store, True) - master = Master(start_fn, workers=workers, reload=False) - master.start() - server = Server( - str(Path(__file__).resolve().parent / "inspector.py"), - port=5555, - ) - server.serve() - master.stop() - - -def run_console( - identity_file: str, - target_addr: str | Path | tuple[str, int], - workers: int = 4, -): - with RequestManager(("", 5000)) as manager: - store = manager.get_queue() - app = InspectorApp(store) - start_fn = partial(run_sidecar, identity_file, target_addr, store, True) - master = Master(start_fn, workers=workers, reload=False) - master.start() - app.run() - master.stop() - - def run( identity_file: str, target_addr: str | Path | tuple[str, int], workers: int = 4, - dev_console: Literal["web", "console"] | None = None, ): - match dev_console: - case "console": - run_console(identity_file, target_addr, workers=workers) - case "web": - run_web(identity_file, target_addr, workers=workers) - case _: - start_fn = partial(run_sidecar, identity_file, target_addr) - master = Master(start_fn, workers=workers, reload=False) - master.run() + Thread(target=cleanup_thread, daemon=True).start() + with PubSubManager(address=("localhost", 50000), authkey=b"secret"): + start_fn = partial(run_sidecar, identity_file, target_addr) + master = Master(start_fn, workers=workers, reload=False) + master.run() diff --git a/mrok/cli/commands/agent/__init__.py b/mrok/cli/commands/agent/__init__.py index a363cc8..14db009 100644 --- a/mrok/cli/commands/agent/__init__.py +++ b/mrok/cli/commands/agent/__init__.py @@ -1,6 +1,8 @@ import typer +from mrok.cli.commands.agent.dev import app as dev_app from mrok.cli.commands.agent.run import app as run_app app = typer.Typer(help="mrok agent commands.") app.add_typer(run_app) +app.add_typer(dev_app) diff --git a/mrok/cli/commands/agent/dev/__init__.py b/mrok/cli/commands/agent/dev/__init__.py new file mode 100644 index 0000000..7b424bc --- /dev/null +++ b/mrok/cli/commands/agent/dev/__init__.py @@ -0,0 +1,7 @@ +import typer + +from mrok.cli.commands.agent.dev import console, web + +app = typer.Typer(name="dev", help="Dev tools for mrok agent.") +console.register(app) +web.register(app) diff --git a/mrok/cli/commands/agent/dev/console.py b/mrok/cli/commands/agent/dev/console.py new file mode 100644 index 0000000..5b1d4e3 --- /dev/null +++ b/mrok/cli/commands/agent/dev/console.py @@ -0,0 +1,10 @@ +import typer + +from mrok.agent.devtools.inspector import InspectorApp + + +def register(app: typer.Typer) -> None: + @app.command("console") + def run_dev_console(): + app = InspectorApp() + app.run() diff --git a/mrok/cli/commands/agent/dev/web.py b/mrok/cli/commands/agent/dev/web.py new file mode 100644 index 0000000..2aba89c --- /dev/null +++ b/mrok/cli/commands/agent/dev/web.py @@ -0,0 +1,18 @@ +import pathlib + +import typer +from textual_serve.server import Server + + +def register(app: typer.Typer) -> None: + @app.command("web") + def run_web_console(): + script = ( + pathlib.Path(__file__).parent.parent.parent.parent.parent + / "agent/devtools/inspector.py" + ) + server = Server( + str(script), + port=8787, + ) + server.serve() diff --git a/mrok/cli/commands/agent/run/sidecar.py b/mrok/cli/commands/agent/run/sidecar.py index d529d88..9ce73cc 100644 --- a/mrok/cli/commands/agent/run/sidecar.py +++ b/mrok/cli/commands/agent/run/sidecar.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Annotated, Literal +from typing import Annotated import typer @@ -31,14 +31,6 @@ def run_sidecar( show_default=True, ), ] = default_workers, - dev_console: Annotated[ - Literal["web", "console"] | None, - typer.Option( - "--dev-console", - help="Enable inspection. Default: False", - show_default=True, - ), - ] = None, ): """Run a Sidecar Proxy to expose a web application through OpenZiti.""" if ":" in str(target): @@ -47,4 +39,4 @@ def run_sidecar( else: target_addr = str(target) # type: ignore - sidecar.run(str(identity_file), target_addr, workers=workers, dev_console=dev_console) + sidecar.run(str(identity_file), target_addr, workers=workers) diff --git a/mrok/http/config.py b/mrok/http/config.py index 5fc4e70..3719d4b 100644 --- a/mrok/http/config.py +++ b/mrok/http/config.py @@ -26,14 +26,12 @@ def __init__( identity_file: str | Path, ziti_load_timeout_ms: int = 5000, backlog: int = 2048, - suppress_logs: bool | None = None, ): self.identity_file = identity_file self.ziti_load_timeout_ms = ziti_load_timeout_ms self.service_name, self.identity_name, self.instance_id = self.get_identity_info( identity_file ) - self.suppress_logs = suppress_logs super().__init__( app, loop="asyncio", @@ -64,5 +62,4 @@ def bind_socket(self) -> socket.socket: return sock def configure_logging(self) -> None: - if not self.suppress_logs: - setup_logging(get_settings()) + setup_logging(get_settings())