From 9f526e5d1e641fdd278e0fef4b0aebfccf621862 Mon Sep 17 00:00:00 2001 From: Justin Searle Date: Thu, 1 Oct 2026 16:35:32 +0000 Subject: [PATCH 1/7] feat: add optional asyncio browser frontend --- .github/workflows/publish.yml | 9 +- .github/workflows/test.yml | 9 +- pyproject.toml | 3 + src/ctui/application.py | 91 +++- src/ctui/completion.py | 7 +- src/ctui/dialogs.py | 31 +- src/ctui/web.py | 606 +++++++++++++++++++++++++++ src/ctui/web_assets/app.js | 224 ++++++++++ src/ctui/web_assets/index.html | 21 + src/ctui/web_assets/style.css | 43 ++ src/ctui/web_layout.py | 171 ++++++++ src/ctui/widgets.py | 36 +- tests/browser_smoke.py | 98 +++++ tests/fixtures/README.md | 3 + tests/fixtures/web-test-cert.pem | 19 + tests/fixtures/web-test-key.pem | 28 ++ tests/smoke_test.py | 3 + tests/test_web.py | 416 ++++++++++++++++++ tests/web_smoke.py | 51 +++ uv.lock | 698 +++++++++++++++++++++++++++++++ 20 files changed, 2547 insertions(+), 20 deletions(-) create mode 100644 src/ctui/web.py create mode 100644 src/ctui/web_assets/app.js create mode 100644 src/ctui/web_assets/index.html create mode 100644 src/ctui/web_assets/style.css create mode 100644 src/ctui/web_layout.py create mode 100644 tests/browser_smoke.py create mode 100644 tests/fixtures/README.md create mode 100644 tests/fixtures/web-test-cert.pem create mode 100644 tests/fixtures/web-test-key.pem create mode 100644 tests/test_web.py create mode 100644 tests/web_smoke.py diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index aa6869d..9315bc0 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -69,11 +69,11 @@ jobs: enable-cache: false - name: Install dependencies - run: uv sync --locked --python ${{ matrix.python-version }} + run: uv sync --locked --extra web --python ${{ matrix.python-version }} - name: Run tests run: >- - uv run --python ${{ matrix.python-version }} + uv run --extra web --python ${{ matrix.python-version }} python -m unittest discover -s tests -v build: @@ -111,6 +111,11 @@ jobs: - name: Smoke test source distribution run: uv run --isolated --no-project --with dist/*.tar.gz tests/smoke_test.py + - name: Smoke-test installed web frontend + run: | + uv run --isolated --no-project --with dist/*.whl --with aiohttp tests/web_smoke.py + uv run --isolated --no-project --with dist/*.tar.gz --with aiohttp tests/web_smoke.py + - name: Upload distributions as artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 54bde4f..4041c4b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -43,11 +43,11 @@ jobs: run: uv python install ${{ matrix.python-version }} - name: Install dependencies - run: uv sync --locked --python ${{ matrix.python-version }} + run: uv sync --locked --extra web --python ${{ matrix.python-version }} - name: Run tests run: >- - uv run --python ${{ matrix.python-version }} + uv run --extra web --python ${{ matrix.python-version }} python -m unittest discover -s tests -v quality: @@ -99,6 +99,11 @@ jobs: - name: Smoke-test source distribution run: uv run --isolated --no-project --with dist/*.tar.gz tests/smoke_test.py + - name: Smoke-test installed web frontend + run: | + uv run --isolated --no-project --with dist/*.whl --with aiohttp tests/web_smoke.py + uv run --isolated --no-project --with dist/*.tar.gz --with aiohttp tests/web_smoke.py + - name: Upload distributions uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: diff --git a/pyproject.toml b/pyproject.toml index 1d7b0a1..927a35c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,9 @@ dependencies = [ "Pygments>=2.14.0,<3", ] +[project.optional-dependencies] +web = ["aiohttp>=3.14.3,<4"] + [project.urls] Homepage = "https://www.controlthings.io" Repository = "https://github.com/ControlThings-io/ctui" diff --git a/src/ctui/application.py b/src/ctui/application.py index 7f18db9..6eff3c2 100644 --- a/src/ctui/application.py +++ b/src/ctui/application.py @@ -42,8 +42,9 @@ class CtuiApp: remain replaceable. Construction registers commands but does not open databases or create terminal resources. - run() automatically selects full-screen or CLI mode. Call run_async() or - run_cli() inside an existing event loop, or dispatch() for headless execution. + run() selects full-screen, CLI, or explicit --web mode. Call run_async(), + run_web(), or run_cli() inside an existing event loop, or dispatch() for + headless execution. Override sync or async lifecycle hooks for resources owned by your application. Commands must return str or CommandResult; use CommandResult.success() when there is no output. Sync handlers execute on the calling event loop, so move @@ -222,7 +223,7 @@ async def on_start(self): async def on_ready(self): """Initialize UI-dependent resources after layout and app are constructed. - Called only in full-screen mode, before the prompt-toolkit run loop begins. + Called in full-screen or web mode after widgets and the runtime exist. May be sync or async; CLI execution does not create widgets or call this hook. """ pass @@ -288,10 +289,17 @@ def format_cli_help(self, program=None, target=""): f"Usage: {program} [help | -h | --help]", f" {program} [-c COMMAND | --command COMMAND] ...", f" {program} [-f FILE | --file FILE] ...", + f" {program} --web [--web-port PORT] [--web-host HOST] ...", "", self.cli_help_intro, "", - "Terminal options:", + "Interface options:", + " --web Start browser-only mode (requires ctui[web]).", + " --web-host HOST Bind address (default: 127.0.0.1).", + " --web-port PORT Listen port (default: 8080; 0 selects a free port).", + " --web-cert FILE TLS certificate; required for remote access.", + " --web-key FILE TLS private key; use with --web-cert.", + " --web-token-env NAME Read session token from an environment variable.", " -c, --command COMMAND Run a command; may be repeated.", " -f, --file FILE Run nonblank commands from a file in order.", " -h, --help Print this help page.", @@ -449,6 +457,69 @@ async def run_async(self): finally: await self._close_runtime(backend_opened) + async def run_web( + self, + *, + host="127.0.0.1", + port=8080, + cert=None, + key=None, + token=None, + stdout=None, + ): + """Run an optional browser-only session in the caller's asyncio loop. + + Each invocation owns one app session; all browser connections share it. + Open services and call on_start, compose widgets, start the server, then + call on_ready. No terminal Application or renderer is started. self.app + supports invalidate(), exit(), and create_background_task() in this mode. + Cancel pending commands/background tasks before on_stop and service cleanup. + Loopback HTTP is the default; other bind addresses require TLS certificate + and key paths. A random token is printed in the startup URL fragment unless + supplied explicitly. Treat that URL as a credential. port=0 selects a free + port. Closing browser tabs leaves the process running; exit() stops it. + """ + from ctui.web import WebSession + + session = WebSession( + self, host=host, port=port, cert=cert, key=key, token=token, stdout=stdout + ) + backend_opened = started = False + try: + if self.backend is not None: + backend_opened = True + await self.backend.open() + await self._hook(self.on_start) + started = True + await session.start() + await self._hook(self.on_ready) + session.invalidate() + await session.stopped.wait() + finally: + try: + await session.close() + finally: + try: + if started: + await self._hook(self.on_stop) + finally: + await self._close_runtime(backend_opened) + + async def _run_web_arguments(self, arguments): + """Translate web startup failures into ordinary CLI exit statuses.""" + from ctui.web import parse_web_options + + try: + options = parse_web_options(arguments) + await self.run_web(**options) + except CommandError as error: + print(f"Error: {error}", file=sys.stderr) + return 2 + except OSError as error: + print(f"Error starting web server: {error}", file=sys.stderr) + return 1 + return 0 + @staticmethod def _parse_cli_operations(arguments): """Parse ordered command and file operations without losing their order.""" @@ -562,10 +633,10 @@ async def run_cli( await self._close_runtime(backend_opened) def run(self, argv=None): - """Open the UI without arguments, otherwise run terminal operations. + """Open the UI without arguments, or select CLI operations or --web. Use sys.argv[1:] when argv is omitted. This synchronous entry point owns an - event loop via asyncio.run; use run_async/run_cli inside an existing loop. + event loop via asyncio.run; use run_async/run_cli/run_web in an existing loop. UI mode returns the terminal result; CLI mode raises SystemExit with its status so application scripts need no separate argument parser. """ @@ -574,9 +645,15 @@ def run(self, argv=None): arguments = list(sys.argv[1:] if argv is None else argv) if not arguments: return asyncio.run(self.run_async()) + if "--web" in arguments: + try: + status = asyncio.run(self._run_web_arguments(arguments)) + except KeyboardInterrupt: + status = 0 + raise SystemExit(status) raise SystemExit(asyncio.run(self.run_cli(arguments))) def exit(self): - """Request termination when the terminal application is running.""" + """Request termination of the active terminal or web session.""" if hasattr(self, "app"): self.app.exit() diff --git a/src/ctui/completion.py b/src/ctui/completion.py index d5af42d..80ca057 100644 --- a/src/ctui/completion.py +++ b/src/ctui/completion.py @@ -134,8 +134,11 @@ async def get_completions_async(self, document, complete_event): yield item except Exception as error: self.type_hint = None - if self.app is not None and getattr( - getattr(self.app, "app", None), "is_running", False + from ctui.web import web_client + + if self.app is not None and ( + web_client.get() is not None + or getattr(getattr(self.app, "app", None), "is_running", False) ): from ctui.dialogs import message_dialog diff --git a/src/ctui/dialogs.py b/src/ctui/dialogs.py index f819153..376474b 100644 --- a/src/ctui/dialogs.py +++ b/src/ctui/dialogs.py @@ -84,6 +84,7 @@ def __init__( ): """Construct a confirmation dialog and its result future.""" self.future = Future() + self._web_dialog = (title, text, [yes_text, no_text], None) def yes_handler(): """Resolve the dialog with an affirmative result.""" @@ -143,6 +144,7 @@ def __init__( ): """Construct a text-input dialog and its result future.""" self.future = Future() + self._web_dialog = (title, text, [ok_text, cancel_text], {"password": password}) def accept_text(buf): """Move focus to confirmation after accepting the input buffer.""" @@ -209,6 +211,7 @@ def __init__( """Construct a message dialog sized to its content.""" self.future = Future() self.text = text + self._web_dialog = (title, text, [ok_text], None) def set_done(): """Resolve the dialog result after acknowledgement.""" @@ -220,6 +223,10 @@ def dynamic_vertical_scrollbar(): text = fragment_list_to_text(text_fragments) if text: text_height = len(self.text.splitlines()) + from ctui.web import web_client + + if web_client.get() is not None: + return True max_text_height = get_app().renderer.output.get_size().rows - 6 if text_height > max_text_height: return True @@ -255,12 +262,18 @@ def __pt_container__(self): async def show_dialog(dialog): """Insert a modal float, await its result, then restore prior focus. - Require a running prompt-toolkit app whose root container exposes floats. + In web mode, route to the browser view that initiated the command/callback. + Otherwise require a running prompt-toolkit app whose root exposes floats. The dialog must provide a future and a prompt-toolkit container. Always remove the float and restore focus on completion or cancellation. Request redraws explicitly: callers may resume after asynchronous work, after the input event's redraw has already finished. """ + from ctui.web import web_client + + client = web_client.get() + if client is not None: + return await client.show_dialog(dialog) app = get_app() float_ = Float(content=dialog) app.layout.container.floats.insert(0, float_) @@ -279,6 +292,16 @@ async def show_dialog(dialog): # Functions that use dialog classes and return results +def _schedule(coroutine): + """Track web convenience dialogs for runtime cancellation, retaining results.""" + from ctui.web import web_client + + client = web_client.get() + if client is not None: + return client.session.create_background_task(coroutine) + return ensure_future(coroutine) + + def func_pass(): """Provide a no-operation default dialog callback.""" @@ -308,7 +331,7 @@ async def coroutine(): else: no_func() - return ensure_future(coroutine()) + return _schedule(coroutine()) # def button_dialog(title='', text='', buttons=[], style=None): @@ -345,7 +368,7 @@ async def coroutine(): ) return await show_dialog(open_dialog) - return ensure_future(coroutine()) + return _schedule(coroutine()) def message_dialog( @@ -377,7 +400,7 @@ async def coroutine(): ) await show_dialog(dialog) - return ensure_future(coroutine()) + return _schedule(coroutine()) # def radiolist_dialog(title='', text='', ok_text='Ok', cancel_text='Cancel', diff --git a/src/ctui/web.py b/src/ctui/web.py new file mode 100644 index 0000000..a8fadfd --- /dev/null +++ b/src/ctui/web.py @@ -0,0 +1,606 @@ +"""Optional asyncio HTTP/WebSocket frontend for one live CtuiApp session. + +Every connection shares widgets, commands and services; draft input, completion, +scrolling and dialogs are per-view. Concurrent commands preserve terminal mode's +completion-order behavior. Bounded outgoing queues coalesce UI snapshots, while +slow connections time out. Submitted commands survive view disconnection; +unanswered per-view dialogs are cancelled. Disconnection does not stop the session. Session exit cancels all work before +on_stop closes application resources. +""" + +from __future__ import annotations + +import asyncio +import contextvars +import inspect +import ipaddress +import json +import logging +import os +import secrets +import ssl +import sys +import traceback +from pathlib import Path +from urllib.parse import quote + +from prompt_toolkit.completion import CompleteEvent +from prompt_toolkit.document import Document + +from ctui.commands import CommandError, _HelpResult +from ctui.layout import CtuiLayout +from ctui.web_layout import WebLayout, plain + +# Context propagates into tasks created by existing dialog convenience wrappers. +web_client = contextvars.ContextVar("ctui_web_client", default=None) +ASSETS = Path(__file__).with_name("web_assets") + + +def parse_web_options(arguments): + """Parse web-only startup options; reject CLI batches and incomplete TLS. + + --web-host defaults to 127.0.0.1, --web-port to 8080 (0 selects a free port). + Non-loopback binds require a certificate and key. --web-token-env selects an + environment variable rather than exposing a supplied token in process args. + Without it a random session token is generated and printed in the URL fragment. + """ + options = {"host": "127.0.0.1", "port": 8080} + values = { + "--web-host": "host", + "--web-port": "port", + "--web-cert": "cert", + "--web-key": "key", + "--web-token-env": "token_env", + } + index = 0 + seen = set() + while index < len(arguments): + option, equals, value = arguments[index].partition("=") + if option in seen: + raise CommandError(f"Repeated web option: {option}") + seen.add(option) + if option == "--web" and not equals: + index += 1 + continue + if option not in values: + raise CommandError( + f"Invalid web option: {option}; web and CLI modes cannot be combined" + ) + if not equals: + index += 1 + if index >= len(arguments) or arguments[index].startswith("--"): + raise CommandError(f"{option} requires a value") + value = arguments[index] + options[values[option]] = value + index += 1 + try: + options["port"] = int(options["port"]) + if not 0 <= options["port"] <= 65535: + raise ValueError + except ValueError as error: + raise CommandError("--web-port must be between 0 and 65535") from error + if not options["host"]: + raise CommandError("--web-host cannot be empty") + if bool(options.get("cert")) != bool(options.get("key")): + raise CommandError("--web-cert and --web-key must be supplied together") + if not is_loopback(options["host"]) and not options.get("cert"): + raise CommandError("Remote web access requires --web-cert and --web-key") + if "token_env" in options: + token = os.environ.get(options.pop("token_env"), "") + if len(token) < 24: + raise CommandError( + "The web token environment variable must contain at least 24 characters" + ) + options["token"] = token + return options + + +def is_loopback(host): + """Recognize literal loopback addresses and localhost without DNS lookups.""" + if host.lower() == "localhost": + return True + try: + return ipaddress.ip_address(host).is_loopback + except ValueError: + return False + + +class WebClient: + """Own one view's bounded sender, pending dialogs and command tasks.""" + + def __init__(self, session, socket): + self.session, self.socket = session, socket + self.updates = asyncio.Queue(maxsize=1) + self.dialogs = {} + self.tasks = set() + self.send_lock = asyncio.Lock() + self.completion_task = None + + async def send(self, message): + """Serialize socket writes and detach clients that cannot keep up.""" + if self.socket.closed: + return + async with self.send_lock: + await asyncio.wait_for(self.socket.send_json(message), timeout=10) + + def update(self, snapshot): + """Replace stale queued state with the current complete snapshot.""" + if self.updates.full(): + self.updates.get_nowait() + self.updates.put_nowait(snapshot) + + async def sender(self): + """Stream coalesced snapshots independently of other views.""" + try: + while True: + await self.send(await self.updates.get()) + finally: + await self.socket.close() + + async def dialog(self, title, text, buttons, *, input_field=None): + """Await an answer only from the requesting authenticated browser view.""" + if self.socket.closed: + raise asyncio.CancelledError + identifier = secrets.token_hex(12) + future = asyncio.get_running_loop().create_future() + self.dialogs[identifier] = future + try: + await self.send( + { + "type": "dialog", + "id": identifier, + "title": title, + "text": text, + "buttons": buttons, + "input": input_field, + } + ) + return await future + finally: + self.dialogs.pop(identifier, None) + + async def show_dialog(self, dialog): + """Adapt CTUI's existing reusable message, confirmation and input dialogs.""" + from ctui.dialogs import TextInputDialog, YesNoDialog + + title, text, buttons, input_field = dialog._web_dialog + value = await self.dialog( + plain(title), plain(text), buttons, input_field=input_field + ) + if isinstance(dialog, TextInputDialog): + result = value.get("text", "") if value.get("button") == 0 else None + elif isinstance(dialog, YesNoDialog): + result = value.get("button") == 0 + else: + result = None + if not dialog.future.done(): + dialog.future.set_result(result) + return result + + def spawn(self, coroutine, *, persistent=False): + """Track work and contain background transport/application failures.""" + + context = contextvars.copy_context() + context.run(web_client.set, self) + task = asyncio.create_task(coroutine, context=context) + owned = self.session.actions if persistent else self.tasks + owned.add(task) + + def done(completed): + owned.discard(completed) + if not completed.cancelled() and completed.exception() is not None: + error = completed.exception() + logging.getLogger(__name__).error( + "Web view task failed", + exc_info=(type(error), error, error.__traceback__), + ) + if not self.socket.closed: + self.session.create_background_task(self.socket.close()) + + task.add_done_callback(done) + return task + + async def command(self, text, identifier): + """Dispatch with per-view confirmation and broadcast accepted results.""" + try: + self.session.ctui.output_text = self.session.ctui.layout.output_field.text + + async def confirm(message): + value = await self.dialog("Confirm", message, ["Yes", "No"]) + return value.get("button") == 0 + + result = await self.session.ctui.dispatch(text, confirm_callback=confirm) + if not result.accepted: + await self.send({"type": "rejected", "id": identifier, "text": text}) + return + if isinstance(result, _HelpResult): + await self.dialog( + "Help", self.session.ctui.format_ui_help(result.target), ["OK"] + ) + else: + self.session.apply_result(result) + await self.send({"type": "finished", "id": identifier}) + except Exception as error: + await self.send( + { + "type": "error", + "id": identifier, + "text": text, + "position": getattr(error, "position", len(text)), + "message": ( + str(error) + if isinstance(error, CommandError) + else traceback.format_exc() + ), + } + ) + finally: + self.session.invalidate() + + async def complete(self, message): + """Use the same async providers and quote-aware replacements as the TUI.""" + text = message["text"] + cursor = message.get("cursor", len(text)) + if not isinstance(cursor, int) or not 0 <= cursor <= len(text): + return + items = [] + async for item in self.session.ctui.layout.completer.get_completions_async( + Document(text, cursor), CompleteEvent(completion_requested=True) + ): + items.append( + { + "text": item.text, + "start": item.start_position, + "display": plain(item.display), + "help": plain(item.display_meta), + } + ) + if len(items) >= 100: + break + await self.send({"type": "completion", "id": message.get("id"), "items": items}) + + async def handle(self, message): + """Validate incoming actions before touching shared state.""" + if not isinstance(message, dict): + raise ValueError("Messages must be objects") + kind = message.get("type") + if kind in ("command", "complete"): + text = message.get("text") + if not isinstance(text, str) or len(text) > 16384: + raise ValueError("Command text must be at most 16384 characters") + if kind == "command" and text.strip(): + if len(self.session.actions) >= 32: + await self.send( + { + "type": "error", + "id": message.get("id"), + "text": text, + "message": "Too many pending actions; wait for a command to finish.", + } + ) + else: + self.spawn(self.command(text, message.get("id")), persistent=True) + elif kind == "complete": + if self.completion_task: + self.completion_task.cancel() + self.completion_task = self.spawn(self.complete(message)) + elif kind == "answer": + future = self.dialogs.get(message.get("id")) + button = message.get("button") + if ( + future + and not future.done() + and isinstance(button, int) + and 0 <= button < 2 + ): + text = message.get("text", "") + if not isinstance(text, str) or len(text) > 16384: + raise ValueError("Invalid dialog input") + future.set_result({"button": button, "text": text}) + elif kind == "clear": + self.session.ctui.layout.set_output("") + self.session.invalidate() + elif kind == "exit": + self.session.exit() + elif kind == "shortcut": + if len(self.session.actions) >= 32: + return + keys = message.get("keys") + for registered, handler, _ in self.session.ctui.shortcuts: + if list(registered) == keys: + self.spawn(self.session.invoke(handler), persistent=True) + break + elif kind in ("button", "edit"): + if len(self.session.actions) >= 32: + return + control = self.session.layout.controls.get(message.get("id")) + if kind == "button" and control is not None and hasattr(control, "handler"): + if control.handler: + self.spawn(self.session.invoke(control.handler), persistent=True) + elif ( + kind == "edit" + and control is not None + and hasattr(control, "set_document") + ): + text = message.get("text") + if isinstance(text, str) and len(text) <= 16384: + control.text = text + self.session.invalidate() + + +class WebSession: + """Async runtime facade supporting existing invalidate/exit/task calls. + + Not a running prompt-toolkit Application. on_ready runs after layout and this + facade exist. Existing widgets remain available through CtuiApp.layout; + custom code that accesses prompt-toolkit renderer internals is terminal-only. + """ + + is_running = False + + def __init__( + self, + ctui, + *, + host="127.0.0.1", + port=8080, + cert=None, + key=None, + token=None, + stdout=None, + ): + self.ctui, self.host, self.port = ctui, host, port + self.cert, self.key = cert, key + self.access_token = token or secrets.token_urlsafe(32) + self.cookie = secrets.token_urlsafe(32) + self.cookie_name = "ctui_" + secrets.token_hex(8) + self.stdout = stdout or sys.stdout + self.clients, self.tasks, self.actions = set(), set(), set() + self.dirty, self.stopped = asyncio.Event(), asyncio.Event() + self.loop = asyncio.get_running_loop() + self.layout = None + self.url = None + self.runner = None + + def invalidate(self): + """Request a coalesced redraw, including from a worker thread.""" + self.loop.call_soon_threadsafe(self.dirty.set) + + def exit(self, *args, **kwargs): + """Request graceful session shutdown for every attached view.""" + self.loop.call_soon_threadsafe(self.stopped.set) + + def create_background_task(self, coroutine): + """Track application-owned background work for cancellation on shutdown.""" + task = asyncio.create_task(coroutine) + self.tasks.add(task) + + def done(completed): + self.tasks.discard(completed) + if not completed.cancelled() and completed.exception() is not None: + error = completed.exception() + logging.getLogger(__name__).error( + "Web background task failed", + exc_info=(type(error), error, error.__traceback__), + ) + for client in tuple(self.clients): + client.update( + { + "type": "notice", + "message": f"Background task failed: {error}", + } + ) + + task.add_done_callback(done) + return task + + def apply_result(self, result): + """Apply output at completion time, matching terminal append semantics.""" + if result.clear_output: + self.ctui.layout.set_output("") + elif result.output is not None: + output = result.output + current = self.ctui.layout.output_field.text + if result.append_output and current: + output = f"{current.rstrip()}\n{output}" + self.ctui.layout.set_output(output) + if result.exit_requested: + self.exit() + self.invalidate() + + async def invoke(self, handler): + """Run widget/shortcut callbacks with async support and error containment.""" + try: + result = handler() + if inspect.isawaitable(result): + await result + except Exception as error: + client = web_client.get() + if client: + await client.dialog( + "Error", + ( + str(error) + if isinstance(error, CommandError) + else traceback.format_exc() + ), + ["OK"], + ) + else: + raise + finally: + self.invalidate() + + def snapshot(self): + """Produce a complete state for new/reconnecting views and invalidations.""" + return { + "type": "state", + "name": self.ctui.name, + "theme": self.ctui.theme, + "layout": self.layout.snapshot(), + "shortcuts": [list(keys) for keys, _, _ in self.ctui.shortcuts], + } + + async def redraw(self): + """Broadcast snapshots without blocking on individual socket writes.""" + while True: + await self.dirty.wait() + self.dirty.clear() + await asyncio.sleep(0.02) + try: + snapshot = self.snapshot() + except Exception: + snapshot = {"type": "notice", "message": traceback.format_exc()} + for client in tuple(self.clients): + client.update(snapshot) + + async def start(self): + """Build the shared layout and start the authenticated optional server.""" + try: + from aiohttp import WSMsgType, web + except ImportError as error: + raise CommandError( + 'Web mode requires the optional dependency: pip install "ctui[web]"' + ) from error + if bool(self.cert) != bool(self.key): + raise CommandError("Web TLS requires both certificate and key") + if not is_loopback(self.host) and not self.cert: + raise CommandError("Remote web access requires a certificate and key") + if len(self.access_token) < 24: + raise CommandError("Web access tokens must contain at least 24 characters") + context = None + if self.cert: + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.minimum_version = ssl.TLSVersion.TLSv1_2 + context.load_cert_chain(self.cert, self.key) + self.ctui.layout = CtuiLayout(self.ctui) + root = self.ctui.compose() or self.ctui.layout.root_container + self.layout = WebLayout(self.ctui, root) + self.ctui.app = self + self.snapshot() # Fail unsupported custom layouts before opening a port. + + @web.middleware + async def security(request, handler): + if is_loopback(self.host) and not is_loopback(request.url.host): + raise web.HTTPForbidden(text="Invalid host") + origin = request.headers.get("Origin") + if origin and origin != f"{request.scheme}://{request.host}": + raise web.HTTPForbidden(text="Invalid origin") + response = await handler(request) + if not isinstance(response, web.WebSocketResponse): + response.headers.update( + { + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "no-referrer", + "Content-Security-Policy": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'", + } + ) + return response + + async def asset(request): + name = request.match_info.get("asset", "index.html") + if name not in ("index.html", "app.js", "style.css"): + raise web.HTTPNotFound() + return web.Response( + body=(ASSETS / name).read_bytes(), + content_type={ + "index.html": "text/html", + "app.js": "application/javascript", + "style.css": "text/css", + }[name], + ) + + async def login(request): + try: + data = await request.json() + except (ValueError, UnicodeError): + raise web.HTTPBadRequest(text="Invalid JSON") from None + token = data.get("token") if isinstance(data, dict) else None + if not isinstance(token, str) or not secrets.compare_digest( + token.encode(), self.access_token.encode() + ): + raise web.HTTPUnauthorized(text="Invalid session token") + response = web.json_response({"ok": True}) + response.set_cookie( + self.cookie_name, + self.cookie, + httponly=True, + secure=bool(context), + samesite="Strict", + ) + return response + + async def websocket(request): + cookie = request.cookies.get(self.cookie_name, "") + if not secrets.compare_digest(cookie.encode(), self.cookie.encode()): + raise web.HTTPUnauthorized() + socket = web.WebSocketResponse(heartbeat=30, max_msg_size=65536) + await socket.prepare(request) + client = WebClient(self, socket) + self.clients.add(client) + sender = asyncio.create_task(client.sender()) + client.update(self.snapshot()) + try: + async for message in socket: + if message.type == WSMsgType.TEXT: + try: + await client.handle(json.loads(message.data)) + except (ValueError, TypeError, KeyError): + await socket.close(code=1008, message=b"Invalid message") + break + finally: + self.clients.discard(client) + for future in tuple(client.dialogs.values()): + future.cancel() + pending = [sender, *client.tasks] + for task in pending: + task.cancel() + await asyncio.gather(*pending, return_exceptions=True) + return socket + + server = web.Application(middlewares=[security], client_max_size=65536) + server.router.add_get("/", asset) + server.router.add_get("/{asset:app.js|style.css}", asset) + server.router.add_post("/login", login) + server.router.add_get("/ws", websocket) + self.runner = web.AppRunner(server, access_log=None) + await self.runner.setup() + bind_host = "127.0.0.1" if self.host.lower() == "localhost" else self.host + site = web.TCPSite(self.runner, bind_host, self.port, ssl_context=context) + await site.start() + self.port = self.runner.addresses[0][1] + address = self.host + if address in ("0.0.0.0", "::"): + address = "localhost" + if ":" in address: + address = f"[{address}]" + self.url = f"{'https' if context else 'http'}://{address}:{self.port}/" + self.create_background_task(self.redraw()) + print( + f"{self.ctui.name} web session: {self.url}#token={quote(self.access_token, safe='')}", + file=self.stdout, + flush=True, + ) + print( + "Open this address in a browser. Ctrl-C stops the session.", + file=self.stdout, + flush=True, + ) + + async def close(self): + """Disconnect views and cancel runtime work before application cleanup.""" + for client in tuple(self.clients): + for task in tuple(client.tasks): + task.cancel() + await client.socket.close(code=1001, message=b"Session stopped") + pending = (*self.tasks, *self.actions) + for task in pending: + task.cancel() + await asyncio.gather(*pending, return_exceptions=True) + if self.runner: + await self.runner.cleanup() + if self.layout: + self.layout.close() + if getattr(self.ctui, "app", None) is self: + del self.ctui.app diff --git a/src/ctui/web_assets/app.js b/src/ctui/web_assets/app.js new file mode 100644 index 0000000..fb79f2d --- /dev/null +++ b/src/ctui/web_assets/app.js @@ -0,0 +1,224 @@ +/* One shared app session; draft input, history, selection and scrolling stay local. */ +'use strict'; +const workspace = document.querySelector('#workspace'); +const status = document.querySelector('#connection'); +const login = document.querySelector('#login'); +const suggestions = document.querySelector('#suggestions'); +let socket, retry, stopped = false, sequence = 0, latestCommand = 0; +let completeSequence = 0, completeTimer, completionItems = [], completionIndex = 0; +let commandInput, shortcuts = [], draft = '', draftCursor = 0; +const elements = new Map(), history = []; +let historyIndex = 0; +const dialogs = []; + +function send(message) { + if (socket?.readyState === WebSocket.OPEN) { socket.send(JSON.stringify(message)); return true; } + return false; +} +function notice(text) { status.textContent = text; status.classList.remove('connected'); } +function styleText(element, style) { + // Only interpret known prompt-toolkit tokens; never accept arbitrary CSS/HTML. + const colors = {ansired:'#c44', ansigreen:'#4c4', ansiyellow:'#cc4', ansiblue:'#44c', ansimagenta:'#c4c', ansicyan:'#4cc', ansiwhite:'#ddd', ansiblack:'#111', ansibrightcyan:'#4ff', ansibrightyellow:'#ff4'}; + for (const token of (style || '').split(/\s+/)) { + if (token === 'bold') element.style.fontWeight = 'bold'; + else if (token === 'italic') element.style.fontStyle = 'italic'; + else if (token === 'underline') element.style.textDecoration = 'underline'; + else if (colors[token]) element.style.color = colors[token]; + else if (/^#[0-9a-f]{6}$/i.test(token)) element.style.color = token; + else if (/^bg:#[0-9a-f]{6}$/i.test(token)) element.style.backgroundColor = token.slice(3); + else if (token.startsWith('class:')) { + for (const name of token.slice(6).split(',')) if (/^[\w-]+$/.test(name)) element.classList.add(name); + } + } +} +function rememberDraft() { + if (commandInput) { draft = commandInput.value; draftCursor = commandInput.selectionStart; } +} +function requestCompletion() { + clearTimeout(completeTimer); + const id = ++completeSequence; + completeTimer = setTimeout(() => send({type:'complete', id, text:commandInput.value, cursor:commandInput.selectionStart}), 90); +} +function applyCompletion(item) { + if (!item?.text || !commandInput) return; + const cursor = commandInput.selectionStart; + commandInput.setRangeText(item.text, Math.max(0, cursor + item.start), cursor, 'end'); + rememberDraft(); hideCompletions(); commandInput.focus(); requestCompletion(); +} +function hideCompletions() { suggestions.hidden = true; completionItems = []; } +function showCompletions(items) { + completionItems = items; completionIndex = 0; suggestions.replaceChildren(); + for (const [index,item] of items.entries()) { + const element = document.createElement(item.text ? 'button' : 'div'); + element.textContent = `${item.display || item.text} ${item.help || ''}`; + element.className = item.text ? (index === 0 ? 'selected' : '') : 'hint'; + element.addEventListener('mousedown', event => event.preventDefault()); + if (item.text) element.addEventListener('click', () => applyCompletion(item)); + suggestions.append(element); + } + if (commandInput) { + const rect = commandInput.getBoundingClientRect(); + suggestions.style.top = `${rect.bottom}px`; suggestions.style.left = `${rect.left}px`; + } + suggestions.hidden = !items.length; +} +function submitCommand() { + const text = commandInput.value; + if (!text.trim()) return; + const id = ++sequence; + if (!send({type:'command', id, text})) return; + latestCommand = id; history.push(text); historyIndex = history.length; + commandInput.value = ''; rememberDraft(); ++completeSequence; hideCompletions(); +} +function render(node, parentAxis = 'height') { + let element = elements.get(node.id); + if (!element || element.dataset.kind !== node.kind) { + const tag = {text:'pre', label:'div', frame:'fieldset', button:'button', editor:'textarea', separator:'div'}[node.kind] || 'div'; + element = document.createElement(tag); element.dataset.kind = node.kind; elements.set(node.id, element); + if (node.kind === 'input') { + const prompt = document.createElement('span'); const input = document.createElement('input'); + input.type = 'text'; input.setAttribute('aria-label','Command'); input.autocomplete = 'off'; input.spellcheck = false; + input.value = draft; + input.addEventListener('input', () => { rememberDraft(); requestCompletion(); }); + input.addEventListener('keydown', inputKey); element.append(prompt,input); + } else if (node.kind === 'button') element.addEventListener('click', () => send({type:'button',id:node.id})); + else if (node.kind === 'editor') element.addEventListener('input', () => send({type:'edit', id:node.id, text:element.value})); + else if (node.kind === 'frame') element.append(document.createElement('legend')); + else if (node.kind === 'progress') { const bar=document.createElement('progress'); bar.max=100; element.append(bar,document.createElement('span')); } + } + element.className = `node ${node.kind}`; element.removeAttribute('style'); styleText(element,node.style); + for (const axis of ['width','height']) { + const size=node[axis]; if (!size || node.kind==='separator') continue; + const unit=axis==='width'?'ch':'lh'; + if (size.max < 1000000000 && size.min === size.max) { + element.style[axis]=`${size.min}${unit}`; + if (axis===parentAxis) element.style.flex=`0 0 ${size.min}${unit}`; + } else { + if (size.min) element.style[axis==='width'?'minWidth':'minHeight']=`${size.min}${unit}`; + if (size.max < 1000000000) element.style[axis==='width'?'maxWidth':'maxHeight']=`${size.max}${unit}`; + if (axis===parentAxis && node.kind!=='label') element.style.flex=`${size.weight || 1} 1 ${size.preferred ? size.preferred+unit : '0'}`; + } + } + if (node.kind === 'input') { element.firstChild.textContent=node.prompt; commandInput=element.lastChild; } + else if (node.kind === 'text') { + element.classList.toggle('wrap',node.wrap); + if (element.textContent !== node.text) { + const bottom=element.scrollHeight-element.scrollTop-element.clientHeight < 30; + element.textContent=node.text; + if (bottom) requestAnimationFrame(() => {element.scrollTop=element.scrollHeight;}); + } + } else if (node.kind === 'label') { + const content=JSON.stringify(node.fragments); + if (element.dataset.content!==content) { + element.dataset.content=content; element.replaceChildren(); + for (const [style,text] of node.fragments) {const span=document.createElement('span');span.textContent=text;styleText(span,style);element.append(span);} + } + } else if (node.kind === 'editor' && document.activeElement!==element) element.value=node.text; + else if (node.kind === 'button') element.textContent=node.text; + else if (node.kind === 'progress') {element.firstChild.value=node.value;element.lastChild.textContent=`${node.value}%`;} + else if (node.kind === 'frame') element.firstChild.textContent=node.title; + if (node.children) { + const wanted=node.children.map(child => render(child,node.kind==='horizontal'?'width':'height')); + if (node.kind==='frame') wanted.unshift(element.firstChild); + // Retain nodes in place to preserve native focus, selection, and scroll state. + wanted.forEach((child,index) => {if (element.children[index]!==child) element.insertBefore(child,element.children[index] || null);}); + for (const child of [...element.children]) if (!wanted.includes(child)) child.remove(); + } + return element; +} +function inputKey(event) { + if (event.key==='Enter') {event.preventDefault();submitCommand();} + else if (event.key==='Tab') {event.preventDefault();if (!suggestions.hidden) applyCompletion(completionItems[completionIndex]);else requestCompletion();} + else if (event.key==='Escape') hideCompletions(); + else if ((event.key==='ArrowUp'||event.key==='ArrowDown')&&!event.ctrlKey) { + event.preventDefault(); const direction=event.key==='ArrowUp'?-1:1; + if (!suggestions.hidden && completionItems.some(item=>item.text)) { + completionIndex=(completionIndex+direction+completionItems.length)%completionItems.length; + [...suggestions.children].forEach((child,index)=>child.classList.toggle('selected',index===completionIndex)); + } else {historyIndex=Math.max(0,Math.min(history.length,historyIndex+direction));commandInput.value=history[historyIndex]||'';rememberDraft();} + } else if (event.ctrlKey && ['c','l','a','e','u','k','d'].includes(event.key.toLowerCase())) { + event.preventDefault();const start=commandInput.selectionStart,end=commandInput.selectionEnd; + switch(event.key.toLowerCase()) { + case 'c':commandInput.value='';hideCompletions();break; + case 'l':send({type:'clear'});break; + case 'a':commandInput.setSelectionRange(0,0);break; + case 'e':commandInput.setSelectionRange(commandInput.value.length,commandInput.value.length);break; + case 'u':commandInput.setRangeText('',0,start,'start');break; + case 'k':commandInput.setRangeText('',end,commandInput.value.length,'end');break; + case 'd':if (!commandInput.value) send({type:'exit'});else commandInput.setRangeText('',start,Math.min(commandInput.value.length,start+1),'start');break; + } + rememberDraft(); + } else if (['PageUp','PageDown','Home','End'].includes(event.key) || (event.ctrlKey&&['ArrowUp','ArrowDown'].includes(event.key))) { + event.preventDefault();const output=workspace.querySelector('.output_field');if (!output) return; + const amount=event.key==='Home'?-output.scrollHeight:event.key==='End'?output.scrollHeight:(event.key==='PageUp'?-1:event.key==='PageDown'?1:event.key==='ArrowUp'?-0.05:0.05)*output.clientHeight; + output.scrollTop+=amount; + } +} +function openDialog(message) { + dialogs.push(message);if (dialogs.length===1) displayDialog(); +} +function displayDialog() { + const message=dialogs[0];if (!message) return; + const dialog=document.createElement('dialog'),title=document.createElement('h2'),text=document.createElement('pre'),actions=document.createElement('div'); + title.textContent=message.title;text.textContent=message.text;actions.className='actions';dialog.append(title,text); + let input; + if (message.input) {input=document.createElement('input');input.type=message.input.password?'password':'text';input.setAttribute('aria-label',message.title);dialog.append(input);} + function answer(button) {send({type:'answer',id:message.id,button,text:input?.value||''});dialog.close();dialog.remove();dialogs.shift();displayDialog();if(!dialogs.length)commandInput?.focus();} + message.buttons.forEach((label,index)=>{const button=document.createElement('button');button.textContent=label;button.addEventListener('click',()=>answer(index));actions.append(button);}); + dialog.addEventListener('keydown',event=>{ + if(event.target===input){if(event.key==='Enter'){event.preventDefault();actions.firstChild.focus();}return;} + const scroll={ArrowUp:-1,ArrowDown:1,PageUp:-text.clientHeight/21,PageDown:text.clientHeight/21}; + if(event.key in scroll){event.preventDefault();text.scrollTop+=scroll[event.key]*21;} + else if(event.key==='ArrowLeft'||event.key==='ArrowRight'){ + event.preventDefault();const buttons=[...actions.children],index=buttons.indexOf(document.activeElement); + buttons[(index+(event.key==='ArrowLeft'?-1:1)+buttons.length)%buttons.length].focus(); + } + }); + dialog.append(actions);document.querySelector('#dialogs').append(dialog);dialog.addEventListener('cancel',event=>{event.preventDefault();answer(message.buttons.length-1);});dialog.showModal();(input||actions.firstChild).focus(); +} +function connect() { + clearTimeout(retry);socket=new WebSocket(`${location.protocol==='https:'?'wss':'ws'}://${location.host}/ws`); + socket.onopen=()=>{login.hidden=true;workspace.hidden=false;status.classList.add('connected');}; + socket.onmessage=event=>{ + const message=JSON.parse(event.data); + if(message.type==='state') { + rememberDraft();document.title=message.name;shortcuts=message.shortcuts; + const focused=document.activeElement;const root=render(message.layout); + if(workspace.firstChild!==root)workspace.replaceChildren(root); + if(!focused||focused===document.body)commandInput?.focus(); + // Prune detached widgets when dynamic layouts change. + for(const [id,element] of elements)if(!workspace.contains(element))elements.delete(id); + } else if(message.type==='completion'&&message.id===completeSequence) showCompletions(message.items); + else if(message.type==='dialog')openDialog(message); + else if(message.type==='error'||message.type==='rejected') { + if(message.id===latestCommand&&commandInput&&!commandInput.value){commandInput.value=message.text;const pos=message.position??message.text.length;commandInput.setSelectionRange(pos,pos);rememberDraft();} + if(message.message)openDialog({title:'Error',text:message.message,buttons:['OK']}); + } else if(message.type==='notice')notice(message.message); + }; + socket.onclose=event=>{ + for(const dialog of document.querySelectorAll('dialog'))dialog.remove();dialogs.length=0;hideCompletions(); + if(event.code===1001){stopped=true;notice('Session stopped.');} + else {notice('Disconnected. Reconnecting…');if(!stopped)retry=setTimeout(connect,1500);} + }; +} +document.addEventListener('keydown',event=>{ + if(document.querySelector('dialog'))return; + if(event.ctrlKey&&event.key.toLowerCase()==='q'){event.preventDefault();send({type:'exit'});return;} + const key=event.key.length===1?event.key.toLowerCase():event.key.toLowerCase().replace('arrow',''); + const name=(event.ctrlKey?'c-':'')+(event.altKey?'escape,':'')+key; + // Single keys and sequences are mapped to prompt-toolkit names. + keySequence.push(name);if(keySequence.length>8)keySequence.shift();clearTimeout(sequenceTimer);sequenceTimer=setTimeout(()=>keySequence.length=0,1000); + for(const keys of shortcuts)if(keys.join('|')===keySequence.slice(-keys.length).join('|')){event.preventDefault();event.stopPropagation();send({type:'shortcut',keys});keySequence.length=0;break;} +},true); +const keySequence=[];let sequenceTimer; +async function authenticate(token) { + try { + const response=await fetch('/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token})}); + if(!response.ok)throw new Error('Invalid session token'); + document.querySelector('#token').value='';stopped=false;connect(); + } catch(error){notice(error.message);login.hidden=false;} +} +document.querySelector('#login-form').addEventListener('submit',event=>{event.preventDefault();authenticate(document.querySelector('#token').value);}); +const token=new URLSearchParams(location.hash.slice(1)).get('token'); +if(token){window.history.replaceState(null,'',location.pathname);authenticate(token);} +else connect(); diff --git a/src/ctui/web_assets/index.html b/src/ctui/web_assets/index.html new file mode 100644 index 0000000..3a932b7 --- /dev/null +++ b/src/ctui/web_assets/index.html @@ -0,0 +1,21 @@ + + + + + + CTUI + + + + +
+

CTUI

+

Enter the session token printed in your terminal.

+
+
+
Connecting…
+
+ +
+ + diff --git a/src/ctui/web_assets/style.css b/src/ctui/web_assets/style.css new file mode 100644 index 0000000..1558036 --- /dev/null +++ b/src/ctui/web_assets/style.css @@ -0,0 +1,43 @@ +:root { color-scheme: dark; font: 15px/1.4 ui-monospace, SFMono-Regular, Consolas, monospace; background: #202020; color: #ddd; } +* { box-sizing: border-box; } +body { margin: 0; height: 100dvh; display: flex; flex-direction: column; } +[hidden] { display: none !important; } +button, input, textarea { font: inherit; color: inherit; background: #101010; border: 1px solid #555; } +button { cursor: pointer; padding: .25rem .65rem; } +button:hover, button:focus-visible { border-color: #4dd; } +input:focus, textarea:focus { outline: 1px solid #4dd; } +#login { margin: auto; padding: 2rem; max-width: 40rem; } +#login label { display: flex; gap: 1rem; align-items: center; } +#login input { flex: 1; min-width: 0; } +#connection { background: #333; padding: .1rem .5rem; font-size: .8rem; flex: 0 0 auto; } +#connection.connected { display: none; } +#workspace { flex: 1; min-height: 0; display: flex; } +.node { min-width: 0; min-height: 0; flex: 1 1 0; overflow: hidden; } +.vertical, .horizontal { display: flex; gap: 0; } +.vertical { flex-direction: column; } +.horizontal { flex-direction: row; } +.frame { display: flex; flex-direction: column; border: 1px solid #777; margin: 0; padding: 0 .35rem .35rem; } +.frame legend { padding: 0 .5rem; } +.frame > .node { width: 100%; } +.input { display: flex; gap: .3rem; align-items: center; background: #101010; flex: 0 0 auto; padding: .25rem .5rem; } +.input { min-height: calc(1lh + .5rem); } +.input input { flex: 1; min-width: 0; border: 0; outline: none; background: transparent; } +.text, .label { white-space: pre; margin: 0; padding: 0 .4rem; overflow: auto; } +.text { color: #4cc; } +.wrap { white-space: pre-wrap; overflow-wrap: anywhere; } +.label { flex: 0 0 auto; } +.statusbar { background: #aaa; color: #111; } +.separator { border-top: 1px solid #4dd; height: 1px; flex: 0 0 1px; } +.editor { width: 100%; height: 100%; resize: none; } +.progress { display: flex; align-items: center; gap: .5rem; } +progress { width: 100%; accent-color: #4dd; } +#suggestions { position: fixed; z-index: 5; top: 2.2rem; left: .5rem; max-height: 40vh; max-width: calc(100vw - 1rem); overflow: auto; border: 1px solid #888; background: #181818; box-shadow: 0 .4rem 1rem #0008; } +#suggestions button { display: block; width: 100%; text-align: left; border: 0; } +#suggestions .selected { background: #37545a; } +.hint { padding: .3rem .6rem; color: #aaa; } +dialog { color: #eee; background: #181818; border: 1px solid #4dd; width: min(90vw, 70rem); max-height: 85dvh; } +dialog::backdrop { background: #0009; } +dialog h2 { margin: 0 0 .6rem; font-size: 1rem; } +dialog pre { white-space: pre-wrap; overflow: auto; max-height: 60dvh; } +dialog .actions { display: flex; justify-content: end; gap: .5rem; } +dialog input { width: 100%; margin-bottom: .8rem; } diff --git a/src/ctui/web_layout.py b/src/ctui/web_layout.py new file mode 100644 index 0000000..eae306b --- /dev/null +++ b/src/ctui/web_layout.py @@ -0,0 +1,171 @@ +"""Translate the supported compose() widgets into browser layout snapshots. + +The existing prompt-toolkit tree remains the source of truth. No terminal is +started: buffer text, formatted labels, dimensions, visibility, and callable +status text are read on invalidation. Arbitrary third-party controls are rejected +explicitly rather than silently dropping application UI. +""" + +from prompt_toolkit.formatted_text import to_formatted_text +from prompt_toolkit.formatted_text.utils import fragment_list_to_text +from prompt_toolkit.layout.containers import ( + ConditionalContainer, + DynamicContainer, + FloatContainer, + HSplit, + VSplit, + Window, + to_container, +) +from prompt_toolkit.layout.controls import ( + BufferControl, + DummyControl, + FormattedTextControl, +) +from prompt_toolkit.layout.dimension import to_dimension +from prompt_toolkit.layout.menus import CompletionsMenu +from prompt_toolkit.widgets import Button, Frame, ProgressBar + + +def plain(value): + """Resolve callable/formatted text without interpreting browser markup.""" + return fragment_list_to_text(to_formatted_text(value)) + + +class WebLayout: + """Snapshot shared widgets and retain IDs for interactive controls. + + Sizes are expressed in character cells; CSS handles flexible space. Native + browser selection, focus, and scroll position belong to each view. Custom + floats and arbitrary UIControl implementations are outside this adapter's + supported surface; use ctui's reusable dialogs for modal interactions. + """ + + def __init__(self, ctui, root): + self.ctui, self.root = ctui, root + self.controls = {} + self._ids = {} + self._buffers = set() + + def _changed(self, buffer): + self.ctui.app.invalidate() + + def close(self): + """Detach redraw listeners when the session ends.""" + for buffer in self._buffers: + buffer.on_text_changed -= self._changed + self._buffers.clear() + + def _id(self, obj): + key = id(obj) + if key not in self._ids: + self._ids[key] = str(len(self._ids)) + return self._ids[key] + + def snapshot(self): + """Resolve a fresh tree, including dynamic visibility and content.""" + self.controls = {} + return self._node(self.root) + + def _node(self, value): + container = to_container(value) + widget = getattr(container, "_ctui_widget", value) + node = {"id": self._id(container)} + if isinstance(widget, Frame): + node.update(kind="frame", title=plain(widget.title)) + node["children"] = [self._node(widget.body)] + elif isinstance(widget, ProgressBar): + node.update(kind="progress", value=widget.percentage) + elif isinstance(widget, Button): + node.update(kind="button", text=plain(widget.text)) + self.controls[node["id"]] = widget + elif isinstance(container, FloatContainer): + if any( + not isinstance(float_.content, CompletionsMenu) + for float_ in container.floats + ): + raise TypeError( + "Web mode does not support custom floats; use CTUI dialogs" + ) + return self._node(container.content) + elif isinstance(container, ConditionalContainer): + selected = ( + container.content + if container.filter() + else container.alternative_content + ) + return self._node(selected) if selected else dict(node, kind="empty") + elif isinstance(container, DynamicContainer): + return self._node(container.get_container()) + elif isinstance(container, (HSplit, VSplit)): + node.update( + kind="vertical" if isinstance(container, HSplit) else "horizontal", + children=[self._node(child) for child in container.children], + ) + elif isinstance(container, Window): + if container is self.ctui.layout.input_field.window: + node.update(kind="input", prompt=plain(self.ctui.prompt)) + elif container is self.ctui.layout.header_field: + node.update(kind="separator") + elif isinstance(container.content, BufferControl): + buffer = container.content.buffer + if buffer not in self._buffers: + buffer.on_text_changed += self._changed + self._buffers.add(buffer) + node.update( + kind="text" if buffer.read_only() else "editor", + text=buffer.text, + wrap=container.wrap_lines(), + ) + if node["kind"] == "editor": + self.controls[node["id"]] = buffer + elif isinstance(container.content, FormattedTextControl): + node.update( + kind="label", + fragments=[ + [fragment[0], fragment[1]] + for fragment in to_formatted_text(container.content.text) + if not fragment[0].startswith("[ZeroWidthEscape]") + ], + ) + elif isinstance(container.content, DummyControl): + node.update(kind="label", fragments=[["", container.char or ""]]) + else: + raise TypeError( + f"Web mode does not support {type(container.content).__name__}" + ) + else: + raise TypeError(f"Web mode does not support {type(container).__name__}") + for axis in ("width", "height"): + size = getattr(container, axis, None) + size = size() if callable(size) else size + if size is not None: + dimension = to_dimension(size) + node[axis] = { + name: getattr(dimension, name) + for name in ("min", "max", "preferred", "weight") + } + if node["kind"] == "label" and container.dont_extend_height(): + current = node.get("height", {}) + if current.get("max", 10**30) > 10**9: + lines = ( + max(1, plain(container.content.text).count("\n") + 1) + if isinstance(container.content, FormattedTextControl) + else 1 + ) + node["height"] = dict(min=lines, max=lines, preferred=lines, weight=1) + elif node["kind"] == "progress" and "height" not in node: + node["height"] = dict(min=1, max=1, preferred=1, weight=1) + elif node["kind"] in ("horizontal", "vertical") and "height" not in node: + heights = [child.get("height", {}) for child in node["children"]] + if heights and all( + size.get("max", 10**30) == size.get("min", 0) for size in heights + ): + combine = max if node["kind"] == "horizontal" else sum + height = combine(size["min"] for size in heights) + node["height"] = dict( + min=height, max=height, preferred=height, weight=1 + ) + style = getattr(container, "style", "") + node["style"] = style() if callable(style) else style + return node diff --git a/src/ctui/widgets.py b/src/ctui/widgets.py index 027f83d..b2f23ae 100644 --- a/src/ctui/widgets.py +++ b/src/ctui/widgets.py @@ -1,8 +1,9 @@ """Supported prompt-toolkit building blocks for CtuiApp.compose(). Vertical aliases HSplit (children stacked top to bottom); Horizontal aliases -VSplit (children placed side by side). The remaining exports are the upstream -Button, Frame, Label, ProgressBar, TextArea, and Window classes, not wrappers. +VSplit (children placed side by side). Label, TextArea, and Window are upstream +classes. Button, Frame, and ProgressBar +are compatible subclasses retaining widget identity for browser rendering. Reuse the application's command input in custom layouts so automatic focus and bindings work. These exported aliases form part of ctui's supported 1.x surface; internal layout and dialog modules do not gain that compatibility promise. @@ -11,7 +12,36 @@ from prompt_toolkit.layout.containers import HSplit as Vertical from prompt_toolkit.layout.containers import VSplit as Horizontal from prompt_toolkit.layout.containers import Window -from prompt_toolkit.widgets import Button, Frame, Label, ProgressBar, TextArea +from prompt_toolkit.widgets import Button as _Button +from prompt_toolkit.widgets import Frame as _Frame +from prompt_toolkit.widgets import Label +from prompt_toolkit.widgets import ProgressBar as _ProgressBar +from prompt_toolkit.widgets import TextArea + + +class Frame(_Frame): + """Upstream frame retaining its identity for the browser layout adapter.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.container._ctui_widget = self + + +class Button(_Button): + """Upstream button with the same callback in terminal and browser views.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.window._ctui_widget = self + + +class ProgressBar(_ProgressBar): + """Upstream progress bar exposing its percentage to both renderers.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.container._ctui_widget = self + __all__ = [ "Button", diff --git a/tests/browser_smoke.py b/tests/browser_smoke.py new file mode 100644 index 0000000..120cb61 --- /dev/null +++ b/tests/browser_smoke.py @@ -0,0 +1,98 @@ +"""Optional real-browser check (requires Playwright and a Chromium installation). + +Run: uv run --extra web --with playwright tests/browser_smoke.py +Set CTUI_CHROMIUM_EXECUTABLE to use a system Chromium, or install Playwright's +browser with: uv run --with playwright python -m playwright install chromium +This check is separate from the portable unittest suite. +""" + +import asyncio +import io +import os + +from playwright.async_api import async_playwright +from test_web import BrowserApp + +from ctui.web import WebSession + + +async def main(): + session = WebSession(BrowserApp(), port=0, stdout=io.StringIO()) + await session.start() + try: + async with async_playwright() as playwright: + options = {"headless": True} + executable = os.environ.get("CTUI_CHROMIUM_EXECUTABLE") + if executable: + options["executable_path"] = executable + browser = await playwright.chromium.launch(**options) + try: + context = await browser.new_context() + first = await context.new_page() + errors = [] + first.on("pageerror", lambda error: errors.append(str(error))) + await first.goto(session.url + "#token=" + session.access_token) + await first.locator("#workspace").wait_for(state="visible") + second = await context.new_page() + await second.goto(session.url) + await second.locator("#workspace").wait_for(state="visible") + first_input = first.get_by_role("textbox", name="Command", exact=True) + second_input = second.get_by_role("textbox", name="Command", exact=True) + await second_input.fill("unfinished local draft") + await first_input.fill("echo preserved") + await first_input.press("Enter") + await second.wait_for_function( + "() => document.querySelector('.output_field').textContent === 'preserved'" + ) + assert await second_input.input_value() == "unfinished local draft" + for text in ("help", "help echo"): + await first_input.fill(text) + await first_input.press("Enter") + dialog = first.get_by_role("dialog") + await dialog.wait_for(state="visible") + assert await dialog.locator("h2").text_content() == "Help" + if text == "help": + await dialog.get_by_role("button", name="OK", exact=True).press( + "ArrowDown" + ) + assert await dialog.locator("pre").evaluate( + "el => el.scrollTop > 0" + ) + assert ( + await first.locator(".output_field").text_content() + == "preserved" + ) + assert await second.locator("dialog").count() == 0 + await dialog.get_by_role("button", name="OK", exact=True).click() + await dialog.wait_for(state="detached") + assert await first_input.evaluate( + "el => document.activeElement === el" + ) + await first.get_by_role("button", name="Increment", exact=True).click() + await second.wait_for_function( + "() => document.querySelector('.statusbar').textContent === 'Clicks: 1'" + ) + await first_input.press("F2") + await second.wait_for_function( + "() => document.querySelector('.statusbar').textContent === 'Clicks: 2'" + ) + await first_input.fill("ec") + await first.locator("#suggestions").wait_for(state="visible") + await first_input.press("Tab") + assert await first_input.input_value() == "echo" + await first.set_viewport_size({"width": 390, "height": 844}) + assert await first.locator(".output_field").evaluate( + "el => el.clientHeight > 400" + ) + assert not errors, errors + print( + "Chromium: multi-tab output, local drafts, help popup, focus, buttons, completion and mobile layout passed" + ) + finally: + await browser.close() + finally: + await session.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md new file mode 100644 index 0000000..63872cc --- /dev/null +++ b/tests/fixtures/README.md @@ -0,0 +1,3 @@ +The certificate and private key in this directory are public test fixtures for +loopback HTTPS integration tests. They are self-signed for localhost/127.0.0.1 +and must never be used as deployment credentials. diff --git a/tests/fixtures/web-test-cert.pem b/tests/fixtures/web-test-cert.pem new file mode 100644 index 0000000..c1b329f --- /dev/null +++ b/tests/fixtures/web-test-cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDJTCCAg2gAwIBAgIUUWfhuWy4t1P+uOB8JpoWa6nWkBAwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MTAwMTE2MTkxNVoXDTM2MDky +ODE2MTkxNVowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAmYFupl1w5YTKL/91qvH7pumfINChHe0i9MjrdVzlJOoa +NA+b1OcryDDJFRBRLehEJFh5bvs43sUl+IUV819SDLlZwihwI/wdjat1LRYhFjPf +4K8SHr2FDTIgiYOJuYm7dAb9n5OQKW06YnziDWhuzLiU3DDr8EiYcN+I3kWLSsia +9joo9Wmgs3pofbN6y+OLy8KHZnutEMpYzM2IvN9Cs6o3WXos8RP+d51vqJtp05SO +Ytsh9a6cjSAQlIgu/P/AeGppnVsYOTdz2rjUYJTpfvAEIIfmBzRyySmOgvLW9dKS +Aq9FjGH9V+76eiuPwLX+JgkSCC6FCBkXc5IUhEMQQQIDAQABo28wbTAdBgNVHQ4E +FgQURliq+yuMiwfJblEf+coMAbvxvrAwHwYDVR0jBBgwFoAURliq+yuMiwfJblEf ++coMAbvxvrAwDwYDVR0TAQH/BAUwAwEB/zAaBgNVHREEEzARgglsb2NhbGhvc3SH +BH8AAAEwDQYJKoZIhvcNAQELBQADggEBAJB9tRBAnhH8aL1oBuiYbPwB4IQT9lkF +G2mihlt4TbBQp7yovDv7s7cMRjfAxyi0oFP4C+oJnuQaRcs5oF8uUQj7YkTae+Hh +35Z2UpmZGjL2aOKzD+FqCCJN7XvpuQSczVvDOwZGajISpBMBTvDWI190G2pXZjru +CoU8IBtasx2idA31SOPwqvtdLH7N9WJCDu4dCm1nieH+zDAoMYn3cPStq81iuzxl +C2FHdrpjfKt2a1vNJyYXvkxnAuVOB2XzO07acSoN5O8n+5xjtSbFR7J/Ao/p/I+G +Il1kkA1t6q1bbbZQS7wMyoWJxU7s1Jx9T/f47iy9UjvotuKkMQ9RxG8= +-----END CERTIFICATE----- diff --git a/tests/fixtures/web-test-key.pem b/tests/fixtures/web-test-key.pem new file mode 100644 index 0000000..23f6b19 --- /dev/null +++ b/tests/fixtures/web-test-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCZgW6mXXDlhMov +/3Wq8fum6Z8g0KEd7SL0yOt1XOUk6ho0D5vU5yvIMMkVEFEt6EQkWHlu+zjexSX4 +hRXzX1IMuVnCKHAj/B2Nq3UtFiEWM9/grxIevYUNMiCJg4m5ibt0Bv2fk5ApbTpi +fOINaG7MuJTcMOvwSJhw34jeRYtKyJr2Oij1aaCzemh9s3rL44vLwodme60QyljM +zYi830KzqjdZeizxE/53nW+om2nTlI5i2yH1rpyNIBCUiC78/8B4ammdWxg5N3Pa +uNRglOl+8AQgh+YHNHLJKY6C8tb10pICr0WMYf1X7vp6K4/Atf4mCRIILoUIGRdz +khSEQxBBAgMBAAECggEAAkfINUanlfKe+foP99oQQeaavicjRu7I1BKaXtwNAmX2 +PYZAK3hDWqi7iql7Tz+HUvPmhUB2/pDhXhuvW7YeNwFXj5kVW2TUKpCHaoP3j10g +wGAmwy1PvpuwnI0HR38/4+VzsJfFrpEzFWyjWLglBzu0aGtTTKFDdHp5oyBXhU+c +3t1fR0wrbgXWfr8UVgDTFTa2Xp5DaMOqYi1HHNSe9UC1Fx+i2zczEjanNIJL4BAV +2w2oYuOgJCI3uxsXxDl3+0MBZKfeEVBtEl3iA0eyFX16oj8uTvg76rb34slGcRum +eizISOXvEJiRVtBDaUyWHzYH/fX4qTdJTLuQksS7LQKBgQDRsMKiZkbtq71wjrx6 +GJelWis64ib0E8BfBYFg8HsvndvvwBBSq4MYgP05KPEF02LDnkoeXKkNV0SmE8DL +0WU8XN1hDHTOr28zMHBQo9MZkmjKwPUv/iTGVySu8pqMOebODVWBr/9F/NFLqZTm +HyS5KIp5BBXsBknwx1xFi7pN3QKBgQC7aCfhiaYzRoMMxY2a+/V6UacvaxM4SnqW +XE09ncm8pyZMYHKwQuDHypEz2Thj3Mt2MbUeiZINZnOcHWNCqCgDurPX0Bdr3uQL +msJr/EqaFKefHjEqWXe+E4UkhTFs7QYaeEX9LfN0tpB/czGcOfICtx28fnEA0lFC +7zWeU2lftQKBgEv3rNvnD+VFJfGkVJYrWK04GJdRnWkpyrTiKrgzB339w9N9EqzQ +pEL5YEQYGBHTPmG+c/3B9iD2L9G3ME7nArU2s6FYQO0sU64I63fbkjwedcaW131W +pn4QziJvlBwfUgXugL7mQyZY7H5r5pzkRYqrLFehakvDD4CiyUQM1iclAoGAJbBg +tN716cH4gQ0J2W1qhNr/AG5MQznXRX3ymr2s4J/M9/c9mW0Zv5Rhd0fsK0kwMej1 +P9ry4HcGMRNH7xPaWEWn/XMAAtWkjyDkkMsARdR5kWqFALtVdYh/ckTHF9zjKVee +27fuCj7c+IS2KXEN1rX06P+UwlGJvihVZC3qNzECgYEAq1Sg9v9FpCbAclw5uRsr +oyunymKTWFeBCy+7Oc/8gltjsc0/dp16/NUYLQ5DpYCWLoeySockj+k2zeEz48a0 +zTl04VHrfHqn692NlelwZXj/UMUSwqTS36+uzejPKtFRP+9GZ8hC393jnMxDaPi2 +O775BsVMsO3KSxXBnYC6cS4= +-----END PRIVATE KEY----- diff --git a/tests/smoke_test.py b/tests/smoke_test.py index 4c1d799..cf060a9 100644 --- a/tests/smoke_test.py +++ b/tests/smoke_test.py @@ -10,6 +10,7 @@ import asyncio import tempfile from importlib.metadata import version +from importlib.resources import files from pathlib import Path from ctui import CtuiApp, FuzzyHexPattern, HexBytes, __version__, command @@ -31,6 +32,8 @@ async def smoke_test() -> None: Assertion failures make the script fail for use in build/release workflows. """ assert __version__ == version("ctui") + for name in ("index.html", "app.js", "style.css"): + assert files("ctui").joinpath("web_assets", name).is_file(), name assert HexBytes("be:ef") == b"\xbe\xef" assert list(FuzzyHexPattern("f[0-1]").expand()) == [b"\xf0", b"\xf1"] diff --git a/tests/test_web.py b/tests/test_web.py new file mode 100644 index 0000000..8de866e --- /dev/null +++ b/tests/test_web.py @@ -0,0 +1,416 @@ +"""Real HTTP/WebSocket regressions for shared sessions, security and lifecycle. + +The network tests require the optional web extra. Always bind ephemeral loopback +ports, capture the credential URL, and close sockets/tasks. No external service +or terminal is required. CI installs the extra so these tests cannot silently skip. +""" + +import asyncio +import io +import ssl +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +try: + from aiohttp import ClientSession, CookieJar, WSServerHandshakeError +except ImportError: + ClientSession = None + +from ctui import CommandError, CommandResult, CtuiApp, command +from ctui.dialogs import MessageDialog, TextInputDialog, show_dialog +from ctui.web import WebClient, WebSession, parse_web_options +from ctui.widgets import Button, Frame, Horizontal, Label, ProgressBar, Vertical + + +def nodes(tree): + yield tree + for child in tree.get("children", []): + yield from nodes(child) + + +class BrowserApp(CtuiApp): + """A reusable existing-style app exercising custom widgets and async commands.""" + + def __init__(self): + super().__init__() + self.progress = ProgressBar() + self.clicks = 0 + self.hooks = [] + self.cancelled = asyncio.Event() + self.statusbar = lambda: f"Clicks: {self.clicks}" + self.add_shortcut("f2", handler=self.increment, description="Increment counter") + + def compose(self): + return Vertical( + [ + Label(lambda: "Dashboard"), + Frame(self.layout.body, title="Commands"), + Horizontal( + [self.progress, Button("Increment", handler=self.increment)] + ), + ] + ) + + def increment(self): + self.clicks += 1 + self.app.invalidate() + + async def on_start(self): + self.hooks.append("start") + + async def on_ready(self): + self.hooks.append("ready") + + async def on_stop(self): + self.hooks.append("stop") + + @command + async def echo(self, text: str) -> CommandResult: + """Append text.""" + await asyncio.sleep(0) + return CommandResult.append(text) + + @command(confirmation="Erase data?") + def erase(self) -> str: + """Exercise explicit confirmation.""" + return "erased" + + @command + async def ask(self) -> str: + """Exercise an existing text-input dialog.""" + return ( + await show_dialog( + TextInputDialog(title="Name", text="Your name?", password=True) + ) + or "cancelled" + ) + + @command + async def message(self) -> str: + """Exercise an existing message dialog.""" + await show_dialog(MessageDialog(title="Notice", text="Hello")) + return "acknowledged" + + @command + async def wait(self) -> str: + """Wait until cancellation.""" + try: + await asyncio.Event().wait() + finally: + self.cancelled.set() + return "never" + + +class WebOptionsTests(unittest.TestCase): + def test_defaults_and_explicit_port(self): + self.assertEqual( + parse_web_options(["--web"]), {"host": "127.0.0.1", "port": 8080} + ) + self.assertEqual(parse_web_options(["--web", "--web-port=0"])["port"], 0) + + def test_invalid_and_mixed_options(self): + for arguments in ( + ["--web", "-c", "help"], + ["--web", "--web-port=-1"], + ["--web", "--web-cert", "cert"], + ["--web", "--web-host", "0.0.0.0"], + ["--web", "--web-port"], + ["--web", "--web-port=1", "--web-port=2"], + ): + with self.subTest(arguments=arguments), self.assertRaises(CommandError): + parse_web_options(arguments) + + def test_web_route_and_default_cli_are_exclusive(self): + app = CtuiApp() + with patch("asyncio.run", return_value=0) as run: + with self.assertRaises(SystemExit): + app.run(["--web", "--web-port", "0"]) + coroutine = run.call_args.args[0] + self.assertEqual(coroutine.cr_code.co_name, "_run_web_arguments") + coroutine.close() + + +@unittest.skipUnless(ClientSession, "Install ctui[web] for network integration tests") +class WebTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.app = BrowserApp() + self.session = WebSession(self.app, port=0, stdout=io.StringIO()) + await self.session.start() + self.http = ClientSession(cookie_jar=CookieJar(unsafe=True)) + self.sockets = [] + + async def asyncTearDown(self): + for socket in self.sockets: + await socket.close() + await self.http.close() + await self.session.close() + + async def login(self): + async with self.http.post( + self.session.url + "login", json={"token": self.session.access_token} + ) as response: + self.assertEqual(response.status, 200) + self.assertIn("HttpOnly", response.headers["Set-Cookie"]) + self.assertIn("SameSite=Strict", response.headers["Set-Cookie"]) + + async def connect(self): + socket = await self.http.ws_connect(self.session.url + "ws") + self.sockets.append(socket) + state = await self.receive(socket, "state") + return socket, state + + async def receive(self, socket, kind, predicate=lambda message: True): + async with asyncio.timeout(3): + while True: + message = await socket.receive_json() + if message.get("type") == kind and predicate(message): + return message + + async def output(self, socket, expected): + return await self.receive( + socket, + "state", + lambda m: any(n.get("text") == expected for n in nodes(m["layout"])), + ) + + async def test_static_assets_and_authentication_boundaries(self): + for name in ("", "app.js", "style.css"): + async with self.http.get(self.session.url + name) as response: + self.assertEqual(response.status, 200) + self.assertIn( + "frame-ancestors 'none'", + response.headers["Content-Security-Policy"], + ) + with self.assertRaises(WSServerHandshakeError): + await self.http.ws_connect(self.session.url + "ws") + async with self.http.post( + self.session.url + "login", json={"token": "wrong"} + ) as response: + self.assertEqual(response.status, 401) + async with self.http.post( + self.session.url + "login", + json={"token": self.session.access_token}, + headers={"Origin": "https://other.example"}, + ) as response: + self.assertEqual(response.status, 403) + async with self.http.get( + self.session.url, headers={"Host": "attacker.example"} + ) as response: + self.assertEqual(response.status, 403) + await self.login() + with self.assertRaises(WSServerHandshakeError): + await self.http.ws_connect( + self.session.url + "ws", headers={"Origin": "https://other.example"} + ) + + async def test_two_tabs_share_output_and_reconnect_snapshot(self): + await self.login() + first, state = await self.connect() + second, _ = await self.connect() + self.assertTrue( + any( + n["kind"] == "frame" and n["title"] == "Commands" + for n in nodes(state["layout"]) + ) + ) + await first.send_json({"type": "command", "text": "echo one", "id": 1}) + await self.output(first, "one") + await self.output(second, "one") + await second.send_json({"type": "command", "text": "echo two", "id": 2}) + await self.output(first, "one\ntwo") + await self.output(second, "one\ntwo") + await first.close() + third, state = await self.connect() + self.assertTrue( + any(n.get("text") == "one\ntwo" for n in nodes(state["layout"])) + ) + await third.send_json({"type": "clear"}) + await self.output(second, "") + + async def test_completion_errors_and_help_preserve_output(self): + await self.login() + socket, _ = await self.connect() + await socket.send_json({"type": "complete", "text": "ec", "id": 3}) + completion = await self.receive(socket, "completion") + self.assertEqual(completion["items"][0]["text"], "echo") + await socket.send_json({"type": "command", "text": "echo preserved", "id": 4}) + await self.output(socket, "preserved") + await socket.send_json({"type": "command", "text": "unknown", "id": 5}) + error = await self.receive(socket, "error") + self.assertEqual(error["id"], 5) + await socket.send_json({"type": "command", "text": "help echo", "id": 6}) + dialog = await self.receive(socket, "dialog") + self.assertEqual(dialog["title"], "Help") + await socket.send_json({"type": "answer", "id": dialog["id"], "button": 0}) + await self.receive(socket, "finished") + self.assertEqual(self.app.layout.output_field.text, "preserved") + + async def test_confirmation_and_existing_dialogs(self): + await self.login() + socket, _ = await self.connect() + await socket.send_json({"type": "command", "text": "erase", "id": 1}) + dialog = await self.receive(socket, "dialog") + self.assertEqual(dialog["text"], "Erase data?") + await socket.send_json({"type": "answer", "id": dialog["id"], "button": 1}) + await self.receive(socket, "rejected") + self.assertEqual(self.app.layout.output_field.text, "") + await socket.send_json({"type": "command", "text": "erase", "id": 2}) + dialog = await self.receive(socket, "dialog") + await socket.send_json({"type": "answer", "id": dialog["id"], "button": 0}) + await self.output(socket, "erased") + for command_text, expected in (("ask", "Alice"), ("message", "acknowledged")): + await socket.send_json({"type": "command", "text": command_text, "id": 3}) + dialog = await self.receive(socket, "dialog") + if command_text == "ask": + self.assertTrue(dialog["input"]["password"]) + await socket.send_json( + {"type": "answer", "id": dialog["id"], "button": 0, "text": "Alice"} + ) + await self.output(socket, expected) + + async def test_custom_button_progress_and_invalidation(self): + await self.login() + socket, state = await self.connect() + button = next(n for n in nodes(state["layout"]) if n["kind"] == "button") + await socket.send_json({"type": "button", "id": button["id"]}) + await self.receive( + socket, + "state", + lambda m: any( + n.get("fragments") == [["", "Clicks: 1"]] for n in nodes(m["layout"]) + ), + ) + self.app.progress.percentage = 37 + self.app.app.invalidate() + await self.receive( + socket, + "state", + lambda m: any(n.get("value") == 37 for n in nodes(m["layout"])), + ) + + async def test_disconnect_keeps_commands_running_until_session_shutdown(self): + await self.login() + socket, _ = await self.connect() + await socket.send_json({"type": "command", "text": "wait", "id": 1}) + await asyncio.sleep(0.05) + await socket.close() + await asyncio.sleep(0.05) + self.assertFalse(self.app.cancelled.is_set()) + self.assertFalse(self.session.stopped.is_set()) + await self.connect() + await self.session.close() + await asyncio.wait_for(self.app.cancelled.wait(), 3) + + async def test_disconnected_command_result_reaches_other_tab(self): + release = asyncio.Event() + started = asyncio.Event() + + @self.app.commands.register + async def delayed(): + started.set() + await release.wait() + return CommandResult.append("completed after disconnect") + + await self.login() + first, _ = await self.connect() + second, _ = await self.connect() + await first.send_json({"type": "command", "text": "delayed", "id": 1}) + await asyncio.wait_for(started.wait(), 3) + await first.close() + release.set() + await self.output(second, "completed after disconnect") + + async def test_disconnection_cancels_unanswered_confirmation(self): + await self.login() + socket, _ = await self.connect() + await socket.send_json({"type": "command", "text": "erase", "id": 1}) + await self.receive(socket, "dialog") + await socket.close() + await asyncio.sleep(0.05) + self.assertEqual(self.app.layout.output_field.text, "") + self.assertFalse(self.session.actions) + + async def test_outgoing_state_queue_is_bounded_and_latest_wins(self): + client = WebClient(self.session, None) + for index in range(100): + client.update({"index": index}) + self.assertEqual(client.updates.qsize(), 1) + self.assertEqual(await client.updates.get(), {"index": 99}) + + async def test_malformed_message_disconnects_only_its_view(self): + await self.login() + socket, _ = await self.connect() + await socket.send_json(["invalid"]) + await asyncio.wait_for(socket.receive(), 3) + self.assertFalse(self.session.stopped.is_set()) + await self.connect() + + async def test_second_process_style_session_has_independent_state(self): + other = WebSession(CtuiApp(), port=0, stdout=io.StringIO()) + try: + await other.start() + self.assertNotEqual(other.port, self.session.port) + self.assertNotEqual(other.cookie, self.session.cookie) + self.app.layout.set_output("private") + self.assertEqual(other.ctui.layout.output_field.text, "") + finally: + await other.close() + + async def test_https_and_secure_websocket_with_certificate(self): + fixtures = Path(__file__).with_name("fixtures") + certificate = fixtures / "web-test-cert.pem" + secure = WebSession( + CtuiApp(), + host="0.0.0.0", + port=0, + cert=certificate, + key=fixtures / "web-test-key.pem", + stdout=io.StringIO(), + ) + try: + await secure.start() + url = f"https://127.0.0.1:{secure.port}/" + context = ssl.create_default_context(cafile=str(certificate)) + async with ClientSession(cookie_jar=CookieJar(unsafe=True)) as http: + async with http.post( + url + "login", json={"token": secure.access_token}, ssl=context + ) as response: + self.assertEqual(response.status, 200) + self.assertTrue(response.cookies[secure.cookie_name]["secure"]) + async with http.ws_connect(url + "ws", ssl=context) as socket: + self.assertEqual((await socket.receive_json())["type"], "state") + finally: + await secure.close() + + async def test_authentication_cookies_do_not_collide_across_ports(self): + other = WebSession(CtuiApp(), port=0, stdout=io.StringIO()) + try: + await other.start() + await self.login() + async with self.http.post( + other.url + "login", json={"token": other.access_token} + ) as response: + self.assertEqual(response.status, 200) + async with self.http.ws_connect(other.url + "ws") as socket: + self.assertEqual((await socket.receive_json())["type"], "state") + await self.connect() + finally: + await other.close() + + async def test_run_web_lifecycle_and_startup_failure_cleanup(self): + app = BrowserApp() + + async def ready(): + app.hooks.append("ready") + app.exit() + + app.on_ready = ready + await asyncio.wait_for(app.run_web(port=0, stdout=io.StringIO()), 3) + self.assertEqual(app.hooks, ["start", "ready", "stop"]) + self.assertFalse(hasattr(app, "app")) + failing = BrowserApp() + with self.assertRaises(CommandError): + await failing.run_web(host="0.0.0.0", stdout=io.StringIO()) + self.assertEqual(failing.hooks, ["start", "stop"]) diff --git a/tests/web_smoke.py b/tests/web_smoke.py new file mode 100644 index 0000000..48729d2 --- /dev/null +++ b/tests/web_smoke.py @@ -0,0 +1,51 @@ +"""Verify optional web mode from an installed wheel/sdist with its web extra. + +Use uv --isolated --no-project --with 'dist/ARTIFACT[web]' tests/web_smoke.py. +This binds one ephemeral loopback port and tests assets plus command dispatch. +""" + +import asyncio +import io + +from aiohttp import ClientSession, CookieJar + +from ctui import CtuiApp, command +from ctui.web import WebSession + + +class WebSmokeApp(CtuiApp): + @command + def echo(self, text: str) -> str: + """Return text.""" + return text + + +async def main(): + app = WebSmokeApp() + session = WebSession(app, port=0, stdout=io.StringIO()) + try: + await session.start() + async with ClientSession(cookie_jar=CookieJar(unsafe=True)) as client: + for asset in ("", "app.js", "style.css"): + async with client.get(session.url + asset) as response: + assert response.status == 200 + assert await response.read() + async with client.post( + session.url + "login", json={"token": session.access_token} + ) as response: + assert response.status == 200 + async with client.ws_connect(session.url + "ws") as socket: + assert (await socket.receive_json())["type"] == "state" + await socket.send_json( + {"type": "command", "text": "echo installed", "id": 1} + ) + async with asyncio.timeout(3): + while app.layout.output_field.text != "installed": + await socket.receive_json() + print("Installed web assets and WebSocket command dispatch passed") + finally: + await session.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/uv.lock b/uv.lock index a78cc1b..8dd8290 100644 --- a/uv.lock +++ b/uv.lock @@ -6,6 +6,146 @@ resolution-markers = [ "python_full_version < '3.12'", ] +[[package]] +name = "aiohappyeyeballs" +version = "2.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/5c/b3e4ff8ad43a8afef9602c5e90285936da1beaea8b029016b793891f03c3/aiohttp-3.14.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e568e14940c09955aa51f4e645b6daa18a581c5dcfcd73744dcc86a856e3ced3", size = 764250, upload-time = "2026-07-23T01:52:48.525Z" }, + { url = "https://files.pythonhosted.org/packages/0e/da/f1b384465e51449d844056b75070461da03a9a23e6c1747003695bf4172a/aiohttp-3.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:54cfcdee2770dac994417cbb0ee1f3eb0e7cb6b30c79bf44f2c02ff79ec5124a", size = 516281, upload-time = "2026-07-23T01:52:51.047Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3f/01264f820ee2e3712a827892b1cd6ff80f3300c1fcbffbb45714a915d47a/aiohttp-3.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:21c016079415ed3fd676963e9793700a566d85dbbd6bfc564b9b2d209147dcc8", size = 514742, upload-time = "2026-07-23T01:52:53.779Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8d/a71c6f2db52ac1ed142b133f7feddaa6b70539c3f4de24d7e226c95b794c/aiohttp-3.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6088ec9894113802bddb3c09e974929aed2c7b3a8c456219b8aab4481f1a239", size = 1780613, upload-time = "2026-07-23T01:52:56.948Z" }, + { url = "https://files.pythonhosted.org/packages/a5/11/3dd9b3fb3a170f6ec9011b5291d876a6fab4086714c9e158600edf01b4fd/aiohttp-3.14.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:16ea7e24c309fb7c0bbd505d149abe4fe4dccfb8db911db7dbec0921bc889a6f", size = 1737688, upload-time = "2026-07-23T01:52:59.294Z" }, + { url = "https://files.pythonhosted.org/packages/6d/3e/834c26918be7d88068822b40e0db30fca50b5f4fe79104aa16a93f1d74e6/aiohttp-3.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56f355e79f71aef2a85c80305cc915f894b170dba76de5fe84f6351939b83c06", size = 1845742, upload-time = "2026-07-23T01:53:01.641Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c9/49ab8572df7d66bc13d11e31f781292badb04180dd87ba98733066c6aed7/aiohttp-3.14.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18c441d0a8fca6de8d1f546849b9f0ab20d435993e2c5b59562b2fae6be2f929", size = 1928412, upload-time = "2026-07-23T01:53:04.018Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b9/2b8f0c0ce09c87a1daf80fd483431b56b1435d3f62789bc86f572e1245de/aiohttp-3.14.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53e7b4ce82b54a8bcc71b3b67a5cbd177ca1d7f592cbc92cd38b7349f73482db", size = 1786220, upload-time = "2026-07-23T01:53:06.481Z" }, + { url = "https://files.pythonhosted.org/packages/85/00/9c45f81de11710460edfa1dc81317b6e882703b160926c879a9d20da9fcc/aiohttp-3.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f55119f7bf25f49ed210f6096090715da24f2943c62102448915fde3c62877ce", size = 1637231, upload-time = "2026-07-23T01:53:10.258Z" }, + { url = "https://files.pythonhosted.org/packages/19/ce/967d628e910756f3539c6107cb7844a1b69440dcb3029a5ee7871b09ab63/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9aa6e61fdf20105c4144e755bd586008ff450791d67b1c8146fdc15959c4d51c", size = 1753161, upload-time = "2026-07-23T01:53:13.817Z" }, + { url = "https://files.pythonhosted.org/packages/11/b2/0c3d4114f0aee4f580f5b3b4eb71b24d7a23b834ea506a4dfebe76513f35/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ccd4893707b3e2a13e39c90d43cf80edf2e4d0457935bcc103bf2346214c3f15", size = 1756356, upload-time = "2026-07-23T01:53:16.211Z" }, + { url = "https://files.pythonhosted.org/packages/63/5d/99e7d91c82f1399d1ae2a854e080bd1493fbc31e5e959dbc4ec33dac3bec/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2466434105a4e03113c36ec775cc2ebe6676b62eae326fa670bb607ef788c1c", size = 1819846, upload-time = "2026-07-23T01:53:18.289Z" }, + { url = "https://files.pythonhosted.org/packages/ad/05/d5e1cb6480eeffd3f901d40a2c5e2d1e7effdc797837da3b490272699f13/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ba59d59aba08ac02fc03b0c8983ccd5ee39a199d0552ce9e6d2b4845b34d59ae", size = 1628531, upload-time = "2026-07-23T01:53:23.86Z" }, + { url = "https://files.pythonhosted.org/packages/c9/90/b934682bcaefae18a9e04f3dff5b68522ba810906358ae5029b68110ea3b/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ed099d105449c4f9e84f24af203cd131349d4761d8813fa7e02c32e7128cd910", size = 1832712, upload-time = "2026-07-23T01:53:27.551Z" }, + { url = "https://files.pythonhosted.org/packages/21/df/6061679faaf81fac746e7307c7adb71e858071a5d34c27583afefc64f543/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:152516815ef926786a0b6ae2b8f1fd2e0c71582dee0b435636865316fd4891b7", size = 1775014, upload-time = "2026-07-23T01:53:30.223Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1d/f854878bbc69b88faefe924b619a34a6f59ec05fd387c77690667eaa75eb/aiohttp-3.14.3-cp311-cp311-win32.whl", hash = "sha256:a4af35c443e0b1a1bd6a8af3f3485d7fda15c142751a00f3ff8090f0b93346fa", size = 456006, upload-time = "2026-07-23T01:53:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/73/0c/2af9d1674baccd1dbd47282a93d660a22e57ef6167c856deb24b4214fbab/aiohttp-3.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:e1e74298bab6ee0d6e749ed4fd1901c7e604bdda32c03d787a2cc71c46d0433d", size = 481069, upload-time = "2026-07-23T01:53:39.673Z" }, + { url = "https://files.pythonhosted.org/packages/8e/76/88401ff3fc95e85c5fc38d588f36f55e61ecb64343b2bc8d69326f453cc0/aiohttp-3.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:03cd2bde3d7f085b64e549c985f4bb928cad7e8ecf5323bfca320db548d81b39", size = 453021, upload-time = "2026-07-23T01:53:43.749Z" }, + { url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" }, + { url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" }, + { url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" }, + { url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" }, + { url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" }, + { url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" }, + { url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" }, + { url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" }, + { url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" }, + { url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" }, + { url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" }, + { url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" }, + { url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" }, + { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" }, + { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" }, + { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" }, + { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" }, + { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" }, + { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" }, + { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" }, + { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" }, + { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" }, + { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" }, + { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" }, + { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" }, + { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" }, + { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" }, + { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" }, + { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" }, + { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" }, + { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" }, + { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" }, + { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" }, + { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" }, + { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" }, + { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" }, + { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" }, + { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" }, + { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" }, + { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" }, +] + +[[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 = "aiosqlite" version = "0.22.1" @@ -24,6 +164,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b0/cf/1c5f42b110e57bc5502eb80dbc3b03d256926062519224835ef08134f1f9/astroid-4.0.4-py3-none-any.whl", hash = "sha256:52f39653876c7dec3e3afd4c2696920e05c83832b9737afc21928f2d2eb7a753", size = 276445, upload-time = "2026-02-07T23:35:05.344Z" }, ] +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + [[package]] name = "black" version = "26.5.1" @@ -93,6 +242,11 @@ dependencies = [ { name = "pygments" }, ] +[package.optional-dependencies] +web = [ + { name = "aiohttp" }, +] + [package.dev-dependencies] dev = [ { name = "black" }, @@ -102,11 +256,13 @@ dev = [ [package.metadata] requires-dist = [ + { name = "aiohttp", marker = "extra == 'web'", specifier = ">=3.14.3,<4" }, { name = "aiosqlite", specifier = ">=0.22,<0.23" }, { name = "platformdirs", specifier = ">=4,<5" }, { name = "prompt-toolkit", specifier = ">=3.0.36,<4" }, { name = "pygments", specifier = ">=2.14.0,<3" }, ] +provides-extras = ["web"] [package.metadata.requires-dev] dev = [ @@ -124,6 +280,120 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" }, ] +[[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/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, + { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, + { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, + { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, + { 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 = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + [[package]] name = "isort" version = "8.0.1" @@ -142,6 +412,164 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, ] +[[package]] +name = "multidict" +version = "6.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/99/1d4d69c3512d0ddbfa3a1b69cfd9a151012ab2eb4eabbb096201b1f0b7d8/multidict-6.9.1.tar.gz", hash = "sha256:0f06e60fa190aa7abd0914c2a766736fdc8e9f34878c4346338534b73d1b20e2", size = 182404, upload-time = "2026-09-21T17:59:05.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/24/3823efc330630a1f132bcd0a9b182ddec3c53f452f551c8e8d3aaf5a2d20/multidict-6.9.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:910d4260512660484c0dc1588a316fbb35a40c081c36fc51d1225351af17cfe4", size = 96739, upload-time = "2026-09-21T17:54:42.815Z" }, + { url = "https://files.pythonhosted.org/packages/6a/69/36331fe1d3aeb0c525a9972d6cf82791075a395aa96f026f060009d691ae/multidict-6.9.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:33fa55b990f81c2927e01399ace0d18926c69d69baa8cdaa819424132fb97987", size = 58514, upload-time = "2026-09-21T17:54:44.064Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ca/5860651f782078ef13f2761c2593f4ba066d76f626b2f30f23a7f838b775/multidict-6.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:369b5aa01b241cd3fea6890bdbb11a1425d87bf1515831500d518f4223e9d72c", size = 57303, upload-time = "2026-09-21T17:54:45.304Z" }, + { url = "https://files.pythonhosted.org/packages/c7/9f/c56e2fa223cb5d182660e1a09eec38880f96b7cd22769582f74447c60418/multidict-6.9.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:803f8b575a71b1b299d677c28db0653459c79b5308874efec813f17b7457c7f1", size = 320076, upload-time = "2026-09-21T17:54:46.826Z" }, + { url = "https://files.pythonhosted.org/packages/59/97/eda4fe0cad51f96096363ed32d3e0f8df28dab00beff4d92adc9db517726/multidict-6.9.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8a65621b98984a62e59403009591b8a5a7736273aefe1cab64cfb85b365cc07", size = 315489, upload-time = "2026-09-21T17:54:48.25Z" }, + { url = "https://files.pythonhosted.org/packages/24/14/10b1ded0a4085a51c3054125f854da4f47ce4fec724a2d2503231b83f3db/multidict-6.9.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:13849a1d4f54c3809ae721e9e83ab28f5ea602f33660cb84eb6ef261eac706c1", size = 296716, upload-time = "2026-09-21T17:54:49.749Z" }, + { url = "https://files.pythonhosted.org/packages/dc/14/04d155dd18528443cb9009f3a9f35f7417773797d072c95104ff17b8b9b7/multidict-6.9.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dd9a137a4a9becda3094f3831cd026380f75f6855e051eefe4c73ade524f1cc3", size = 330244, upload-time = "2026-09-21T17:54:51.26Z" }, + { url = "https://files.pythonhosted.org/packages/8b/90/284d10b3a9e5b312a8ec5b7a175fc7d560eefdc99886320449891a197992/multidict-6.9.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9ae9614c317c50836689ce2dfde07c05fe0b16378562e2221746c3913ede3c80", size = 331288, upload-time = "2026-09-21T17:54:52.595Z" }, + { url = "https://files.pythonhosted.org/packages/27/50/f420de3683f9b047fc5588064d84043ef57a7152ef451b8373dfc7068d22/multidict-6.9.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8f1e362c9352b50ed120f001046fdbb80810c9d56580f4c3fc13bbe30823387", size = 320663, upload-time = "2026-09-21T17:54:53.91Z" }, + { url = "https://files.pythonhosted.org/packages/01/ab/0120a650d7ce4fe167299c13a13cc4ee2dc72458a9c0e9e9330b824b73c9/multidict-6.9.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dd655518f136febd96c05131a76a863e32fc2a1d7acd4e3c959e3ceb77d8345", size = 291139, upload-time = "2026-09-21T17:54:55.313Z" }, + { url = "https://files.pythonhosted.org/packages/b3/3f/c66342f73ee5cd2c9b1dda6cfce89b5f348c48921b8c8b6e59aafbbfe31a/multidict-6.9.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c5d675da8f1cb5650271c8ad5e95c0a3e5a183c105e72d953b12877b1c8d0fd", size = 310044, upload-time = "2026-09-21T17:54:56.745Z" }, + { url = "https://files.pythonhosted.org/packages/05/0d/e44b90d9e77e44ec3473c4c7ac7ea95764d7e8fbf6d5a085ef9bff7ce0c2/multidict-6.9.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:658f90f49cf5af2441cad0a2b801c3ef520471989a1ec55bcb25b255b2ca8d2f", size = 303279, upload-time = "2026-09-21T17:54:58.178Z" }, + { url = "https://files.pythonhosted.org/packages/97/8a/7741f7c23c211fae31ab546f0ba41569dae7671cf7ca30d5afb59e9d92aa/multidict-6.9.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:43124fe172ada86d03ac3c8dc8179091341f6724d5e5d5b160e1587e4cd3761b", size = 326177, upload-time = "2026-09-21T17:54:59.825Z" }, + { url = "https://files.pythonhosted.org/packages/e8/63/0cdbfbbe36316b2ef4b8e011afaca2cf291a73fe4fabf34dd0b22e3ee48a/multidict-6.9.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2f0adc22a4eb31e545221d93fc73a0f6a8cc2379d0f4309f71d1d17ba938b82", size = 323526, upload-time = "2026-09-21T17:55:01.43Z" }, + { url = "https://files.pythonhosted.org/packages/98/05/0c86e9678e78f4596df0ed7f59d2aa755107da3f2a92f1b7fa150e94cbb8/multidict-6.9.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7fc59e9ba821b220944ccfe0f89c9dc4745f6d097569992356eb03869f21e953", size = 290278, upload-time = "2026-09-21T17:55:02.993Z" }, + { url = "https://files.pythonhosted.org/packages/95/8e/71bd7f43c4883cba6c5f96db88bd2311d52e74ad7680be5915938490d56f/multidict-6.9.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:87cc632c88ee5dc80e12681047839304d98ee5c9a708d686505767001c9b8b9a", size = 321142, upload-time = "2026-09-21T17:55:04.48Z" }, + { url = "https://files.pythonhosted.org/packages/2d/00/bf59d6bb22a4c152e4039574c29490caf3c65107715121807b4bd7cde244/multidict-6.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b828cf64d62dc09ac183f03c1aeedd164ade96a2ce4934109452edf29de1dd13", size = 319045, upload-time = "2026-09-21T17:55:06.119Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3c/f27f4f045198bf1c4bcd6eb3b6cf06be2dceaceb6e67222b903fce7a6749/multidict-6.9.1-cp311-cp311-win32.whl", hash = "sha256:2c1aeb92eea59d824f004341b26d5e4b47a8a441cf9726769b9a90abf9d0e08f", size = 51309, upload-time = "2026-09-21T17:55:07.531Z" }, + { url = "https://files.pythonhosted.org/packages/91/17/c2064aef64efc007bc89a571f843e5772f27946eb9bc018731bf8016e319/multidict-6.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:5f89dad732280e7a10b74d40b91364f88e13c3f2c08c2ef83a8cd42f7a61af2e", size = 59148, upload-time = "2026-09-21T17:55:08.936Z" }, + { url = "https://files.pythonhosted.org/packages/45/a1/3bab1827edd813dfb90712af5cbb7fc1473ed4d9c871d103df4f4bb950c2/multidict-6.9.1-cp311-cp311-win_arm64.whl", hash = "sha256:5800368526647146978389dfaa46da3356291e9f0fff9a4ef12e8c2bef964a0d", size = 54258, upload-time = "2026-09-21T17:55:10.191Z" }, + { url = "https://files.pythonhosted.org/packages/d9/0d/4b5afb6d3e545c9af0cdfe2db8f6f4c6664568c23d863d888674e447e6a4/multidict-6.9.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:29138fef49828542e859828107e42e50d0e587c513b7eb4b2d92bade2b0860fe", size = 98360, upload-time = "2026-09-21T17:55:11.508Z" }, + { url = "https://files.pythonhosted.org/packages/89/ab/1b9ca66251899981b21138b87da9d5a9c2c81af12b1ea7d19466972f7fe2/multidict-6.9.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:19e31815d41cefc365489e591d105d2baceb2f65aa75d29471fbdbda8651e006", size = 59979, upload-time = "2026-09-21T17:55:12.866Z" }, + { url = "https://files.pythonhosted.org/packages/36/eb/6ae44062466c26c8469ef43f2481a6a48d8cea0587b2d54514ec92e2adfd/multidict-6.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6ed30be8918e18c8bed0a2e8b70639ecf02feb61ed00ca2e41cfcb2a50fa3f42", size = 57736, upload-time = "2026-09-21T17:55:14.223Z" }, + { url = "https://files.pythonhosted.org/packages/b9/5c/a67817593019257a4ac8b0d1b4c426030e637c047b0692ef405439ecea7a/multidict-6.9.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:637f4ae36264bd7b8d9a60193acddc1d735ad52e8ed53a19931ea6d921fea8e5", size = 333097, upload-time = "2026-09-21T17:55:15.591Z" }, + { url = "https://files.pythonhosted.org/packages/90/bf/599ae2e6222822d88a247a8a7ae82fe6fd25d5700757b79603d5edafe6a0/multidict-6.9.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35fc236507fb1b3138f0af5ecd5f94ed752d4d6d826248eae425f86204013eea", size = 334196, upload-time = "2026-09-21T17:55:17.015Z" }, + { url = "https://files.pythonhosted.org/packages/15/10/d8aac5acacbe7f5c117866c776ec26d5f15a868759b6f37ad8e7ed3b5b02/multidict-6.9.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e58952f04772f59f11c6e007471449809a30165188669bca8fdb19dde40a8f24", size = 317465, upload-time = "2026-09-21T17:55:18.412Z" }, + { url = "https://files.pythonhosted.org/packages/19/0a/714f796f7293a8b1c5c3f465a26996231d5450c54e85d64ef1258c091134/multidict-6.9.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d35a4f1c63f07fbb8c8f9946dea98b21eddf6c57421585f71d91864be3ba2a24", size = 345914, upload-time = "2026-09-21T17:55:20.306Z" }, + { url = "https://files.pythonhosted.org/packages/da/b1/e37fbf769c567be277bcf32df6234035a4384677fcc1bd852752be3d6b93/multidict-6.9.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b5f8771aaaed7f80e84a4e471d2f29ab6721e4595075e54d03ae1ed951b2000a", size = 351163, upload-time = "2026-09-21T17:55:22.21Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5b/db08419c1e1f7c9d60cfd2787b2b517d7ae4ebbda8281b48a33eb5141467/multidict-6.9.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:976fd7689d69ec78d67d31d38d396d8adb562f7e8368279f76aed4aa451fa06d", size = 336881, upload-time = "2026-09-21T17:55:23.699Z" }, + { url = "https://files.pythonhosted.org/packages/9b/07/cc9bc8a62651d2d53ab93ee4993b3a71b7cb78eb8ebfc9c757a5b6698617/multidict-6.9.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:95052e8777a86bae87c0bd0b5ab22d809e3d1d02bf69e3e66ddda5ba75a05805", size = 303405, upload-time = "2026-09-21T17:55:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/b6/0c/8e912afafa70e944dbb8bec4b66ca6e008511395278c0d3dd0e89567536a/multidict-6.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1a8adfcaf96f587ab138476eaddef95f29b8a2a8a9afbfea8d2fd62180995d02", size = 324265, upload-time = "2026-09-21T17:55:26.989Z" }, + { url = "https://files.pythonhosted.org/packages/66/6a/62c2af80fb085e6234805017af857b8e913dfac7a55e9c1349c27768c58a/multidict-6.9.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1a53de2772cfb74559df2eb4456ec4eeb908435ec55a84b69370d9d745d62aa8", size = 322085, upload-time = "2026-09-21T17:55:28.732Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6b/35bf801b336fd960811207203ffdcdc24acc3558b3e7ff2e1c914b141e70/multidict-6.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f2ce963299d42fa3f22a90adc0fdf174792ffef5ff4c7ffb68260548fb05580", size = 338107, upload-time = "2026-09-21T17:55:30.303Z" }, + { url = "https://files.pythonhosted.org/packages/80/41/495ef65bf5bba29d142d81b3fe1b8154b919bed70e96491b1e93c3c26f0f/multidict-6.9.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5b30ddf7234e611ca877575b62840e6af5977f92f1f9d532eedbb05a44ff8004", size = 339631, upload-time = "2026-09-21T17:55:32.012Z" }, + { url = "https://files.pythonhosted.org/packages/19/bd/057fdff5f4e04dcd40a960e38f77d19d3c4b67dd243ffa5f43718edb29fc/multidict-6.9.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:63ada7ee2e9345695f9e9bc4c65d72222253f07b1ac94fd0e37555cc6f3c7f60", size = 302080, upload-time = "2026-09-21T17:55:33.817Z" }, + { url = "https://files.pythonhosted.org/packages/3f/de/9ace933ee8dad808632523726f42255b09600087219e3d4ead7369820910/multidict-6.9.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3c95601ed98fad3f6e2f8fe809c3b526b0fab31ef525e00a155e227f3d17f58a", size = 341107, upload-time = "2026-09-21T17:55:35.471Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ac/7c1204406097bfc5c283d4a3287d61166807d189917567df4c1318484fc4/multidict-6.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c148e8b596000dd3e4bfe206e70f3e666be18d72032e0012555f2373c52e35d6", size = 333482, upload-time = "2026-09-21T17:55:37.002Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c0/a70c32ea3299ebe00f44533740cb46905717c51faf29bbd3ce8bb5886d9d/multidict-6.9.1-cp312-cp312-win32.whl", hash = "sha256:f9dad513626a33670f17cddc6078e30e311f444c957e8dbfc5b2b4603c8b4edb", size = 52548, upload-time = "2026-09-21T17:55:38.529Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2e/8c9c2591df01e5692ad1bf92febb082a17b1c6c3a19aa8b7ff49988fd989/multidict-6.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:a16a1dc8529f9e734a41c3b856f3eae7ebacdc061dde3f8a844e0c7889c97203", size = 59622, upload-time = "2026-09-21T17:55:39.86Z" }, + { url = "https://files.pythonhosted.org/packages/d0/95/59f4472ec512bc180fd207899594c7803ece96262ff9aaa3a4b6d7da6940/multidict-6.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:361f7206cf341ba94fb015688f5c8b480f8e63bd58a4c14a48aeca7851a241cc", size = 54930, upload-time = "2026-09-21T17:55:41.155Z" }, + { url = "https://files.pythonhosted.org/packages/eb/45/ddf7c76860f5f553a23ad3ca38463ccf5247081618977742c8dc4625355e/multidict-6.9.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:d7bf9e43282d69561618e8a0ea33368d532ebef42f15c096f427090521dd74f3", size = 63266, upload-time = "2026-09-21T17:55:42.676Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d3/f4ae5945ea2de597eaeb4eca3a74c59783ace54482c9b49435442b63efa8/multidict-6.9.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:03d47df72f084f757c1cb771188d5f4e3a805e4abc4d67e32509272343ae9382", size = 55771, upload-time = "2026-09-21T17:55:44.016Z" }, + { url = "https://files.pythonhosted.org/packages/93/7d/15468239920040d01c686e5ae669e6382f7bf31fc3e5e0a6b8f1c3b32d3b/multidict-6.9.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6bc94fe17c3c56e5418f79515b786b101845f70609b0d19d0c1ba13448e5633a", size = 57116, upload-time = "2026-09-21T17:55:45.315Z" }, + { url = "https://files.pythonhosted.org/packages/17/1b/b958f06aac2d8b1e485eb1c105b1b159cbf3868249e5142142451577dad2/multidict-6.9.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8f2973bbd2bebd9d2e0cd6394c1292a1a19ccd56bdcbe1e174059f1a39be5b40", size = 97692, upload-time = "2026-09-21T17:55:46.674Z" }, + { url = "https://files.pythonhosted.org/packages/93/6c/d6cfe18e61010166d7237d7527c775eb9843e7078feadea45b7628751b60/multidict-6.9.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de7738b8c0bb74c4cc16bbd7fb49fc2bcf6430dba11b3432cd52768ae40933e8", size = 59556, upload-time = "2026-09-21T17:55:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/46/46/9b4c1127cece0289fb207d04aae5116382ddfcb2a4dc8e0cb49a33f3c7b3/multidict-6.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e5ccad4b7bac125722f48d6f862bed3b514d8526deea06316bb72f152cd30a7c", size = 57357, upload-time = "2026-09-21T17:55:49.998Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fc/c18b07100a6064573e49e538d13d47eb2b5221f48080512df78aef54ab04/multidict-6.9.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a49ff5cdb33654cb7d6a3c377aa2a83ddefaa1db31eb10bcf3c180aa84f9af8a", size = 330433, upload-time = "2026-09-21T17:55:51.435Z" }, + { url = "https://files.pythonhosted.org/packages/62/ff/52a0082adeb69656b634609d4fb0ae65456deaae4cca3d5a12656626dc50/multidict-6.9.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b22ff30006a2f28f8bff878fb93413cbe3a4d1fd517c28081d848c90e9cfd2c8", size = 332487, upload-time = "2026-09-21T17:55:52.937Z" }, + { url = "https://files.pythonhosted.org/packages/39/3e/80bd4729635a7af371ff4dcc312594cf96e73f98854491be8740792de430/multidict-6.9.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3adf06c66041aa21eeb8a71e82379b74773298c8e6d3d839b151aae441a99b94", size = 316911, upload-time = "2026-09-21T17:55:54.751Z" }, + { url = "https://files.pythonhosted.org/packages/15/f4/7ea4a907e053e924e2e1694d90560f79f72056504bfd5e8483695f9527d2/multidict-6.9.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:963a8d8f97057082679523d0fd4c53a38f86bc58cabe4556faef682ae53fa2fa", size = 344950, upload-time = "2026-09-21T17:55:56.5Z" }, + { url = "https://files.pythonhosted.org/packages/0d/16/9642ae41fbfbdc546aa481c2b07ae1bc087a94ab5f5020b4d9ab77da401f/multidict-6.9.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b66ccc5c2cdd26e74fa5d4c29ffae424cc6148bf93ce574821783fb3b6d452c5", size = 350264, upload-time = "2026-09-21T17:55:58.073Z" }, + { url = "https://files.pythonhosted.org/packages/93/f2/e06c8e42074d0a4b8419bfe92afbd1d262190b69549ecbcd2dffa4f103c2/multidict-6.9.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fd79c521f6290c69125fa2b85fa65d9e657e6a8ffaf722dc881b926bef4aa5c", size = 336213, upload-time = "2026-09-21T17:55:59.723Z" }, + { url = "https://files.pythonhosted.org/packages/98/43/d7cf9ef4700e7c25244590d1b4f20cf12cbed86beba6c17be4d9e48c1099/multidict-6.9.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d804e4caf5d5da37d6dac1325da5629ebef1e27a294c2b568b295814aa36c7b", size = 302555, upload-time = "2026-09-21T17:56:01.666Z" }, + { url = "https://files.pythonhosted.org/packages/7d/3a/54209920324bc3928f5f2ba28b01a6dd957a9d48c3aaff77e38faaf5668d/multidict-6.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bb0d664505f4b112f384cffeee82e91e3f6448d8e574989479db4439b68cba05", size = 322247, upload-time = "2026-09-21T17:56:03.265Z" }, + { url = "https://files.pythonhosted.org/packages/33/50/df96f961b178b621ab0adbf9221d4fd21534e1064fd9f5e85c1fa27fea55/multidict-6.9.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9e14d17773b1b3c758ff153659a1824608a0cb562c45f484b5ed8a433428444a", size = 321787, upload-time = "2026-09-21T17:56:04.894Z" }, + { url = "https://files.pythonhosted.org/packages/a7/9b/37f354562a8f82f9c1f63d3a94300fc82126d50320175f0702bbd544f87c/multidict-6.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:083735b7f395894e43adb278d5dae901448883a835ff8f1977e285fefdb10418", size = 335498, upload-time = "2026-09-21T17:56:06.783Z" }, + { url = "https://files.pythonhosted.org/packages/1e/44/78e366efc6c004185295cc9eb9711c10f13eadf3b547603b9c5526329976/multidict-6.9.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b65121091567847a8cb520d364ab22ba90e00d3cc55fa9eb34bb439f0684bcd1", size = 338244, upload-time = "2026-09-21T17:56:09.162Z" }, + { url = "https://files.pythonhosted.org/packages/ad/2b/ab5bd3964691abe4d14b43bdbdb8a621b77cea2ca352dc93159ec0a4575b/multidict-6.9.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ea999ae6e80e66ad5eea287860951b033d0104ca34d6d87c7b5125ebe0e12721", size = 301641, upload-time = "2026-09-21T17:56:11.227Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a8/f6bc899f5aafaba755edc8e4bb934bc00b1e405f8d3fbc1dd10283738074/multidict-6.9.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:095d900c242e00fbe5f321ee072e7278b4153e78c5ce9c1efde167d62c1e4771", size = 340162, upload-time = "2026-09-21T17:56:13.076Z" }, + { url = "https://files.pythonhosted.org/packages/eb/03/a8fc809ef364b8c231c065ea2ea2f97ae86d4f9a0e36b5721759fd606778/multidict-6.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6441cc837aea58be7d9baef1b2383eb8311ab9303f500f99ac90b584cd78bb14", size = 332826, upload-time = "2026-09-21T17:56:15.175Z" }, + { url = "https://files.pythonhosted.org/packages/f2/27/32dde80245e2024e9bb7a6ca3c2fb7c695dc74014cba557cc35be6f91e24/multidict-6.9.1-cp313-cp313-win32.whl", hash = "sha256:9c4880d017555d70dea367dd49271830842d48e3891c2da97da7ce8c4abcee40", size = 52378, upload-time = "2026-09-21T17:56:16.959Z" }, + { url = "https://files.pythonhosted.org/packages/be/78/1bac2987edac273a6b27fa50e9204bc3466c94a7b4f6a44828d730f8810f/multidict-6.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:ac51cd64bae51c462ea58ad2492c9b8209667a4ef60c45c4a304518b67598d5d", size = 59449, upload-time = "2026-09-21T17:56:18.336Z" }, + { url = "https://files.pythonhosted.org/packages/e1/32/2a77ce19eea48cb9b3521a202b513ba3349aafa702c8317be0b645ffb74b/multidict-6.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:37a9ebe00c698279213d56e6c64e1962ab1e092918270649b397cac3dc196ca4", size = 54713, upload-time = "2026-09-21T17:56:19.774Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0f/c6041015aa2cdc11e1dff0cae58898ae5525d19e35efd65a4ecf3c6ea235/multidict-6.9.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:fc0dcb22fa9aeabfe3fa4e0430099acff985ec5d77a851382f76cc6146e780e5", size = 62336, upload-time = "2026-09-21T17:56:21.181Z" }, + { url = "https://files.pythonhosted.org/packages/23/db/95bd0afb130a0149c955f1f066143528d60864e64f41ff5a1e1313609361/multidict-6.9.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:024123f0ab402ab33828e24eb80fa8f25167d0d3783ba5f287e39ed741e6abf9", size = 54892, upload-time = "2026-09-21T17:56:22.63Z" }, + { url = "https://files.pythonhosted.org/packages/94/cc/8aa34ae8d09498e8da003f485c03371b55cf1bc6d56cff861b3a7de61251/multidict-6.9.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:af1b5a92315048c3e36bbebfa7d4760a9c3e910bc4166f11b20d77d20d6bcfca", size = 56251, upload-time = "2026-09-21T17:56:24.36Z" }, + { url = "https://files.pythonhosted.org/packages/52/48/22baa3b95375096369b04c4348af14139570f302f951773d2c68bf0ccf2e/multidict-6.9.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b2483da477932ad1983d1d33c18bc3771c6fb00cfbaaed70a875fd547ef8e840", size = 96345, upload-time = "2026-09-21T17:56:25.777Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3e/b96779dcac28ec6d491fd821112a0156b519b6701bba186cdf6f6df737f4/multidict-6.9.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:db4d697b18b6ef5528b1f36bfa25072cd2a421869f5963bc0e92c8a34b9e2800", size = 58901, upload-time = "2026-09-21T17:56:27.364Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b4/8e6d950f02ca6ecc00ecf671f2ddb5dab7017671a8d197326f75621f5786/multidict-6.9.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:854fd2f1bc6e8a56b89910b5cd7261a8b40f13ebb31572da985ce59c7da0886d", size = 56491, upload-time = "2026-09-21T17:56:28.901Z" }, + { url = "https://files.pythonhosted.org/packages/ed/8f/212ed7e03282c7bc271e3796bf985b53d5727c2827361e3dd9c5ee6b093f/multidict-6.9.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:03731f6fc036180700c9dc2308205a48e5ca6f3ff03087739ab746f294022201", size = 332297, upload-time = "2026-09-21T17:56:30.495Z" }, + { url = "https://files.pythonhosted.org/packages/a0/97/555aab000e03ecba85c0a1837fd5674d8bf10949490df7800da3a40660ce/multidict-6.9.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989261c5f1735a165f2e4e87cf6d5f17ab734fa18f9ad0383d5adfcdaefce701", size = 329197, upload-time = "2026-09-21T17:56:32.652Z" }, + { url = "https://files.pythonhosted.org/packages/3d/25/f3539b5bdc147beba66015795dfe589eedcc580348fb1a8c69b1df4196b5/multidict-6.9.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4c9e5d05b126b267ac048a89a0e2d9b48b1b648add62d4872906304a8590610", size = 308456, upload-time = "2026-09-21T17:56:34.657Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7d/4665cad8fc326787d218879be879d86dfe32f006c1f88ae47b04d3b52fd5/multidict-6.9.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9d0a21cf76153de8f2d96a877991d6bc59b9ab5180b949e50db73cc193b4a694", size = 343382, upload-time = "2026-09-21T17:56:36.606Z" }, + { url = "https://files.pythonhosted.org/packages/41/01/e1e9abe27f492b22dede492bc423015597412b14ce074f7475f4ecd80185/multidict-6.9.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7386aa18d98d6b8af44b92173654ec469237fda35f8e8523e43b581a86f476a", size = 346848, upload-time = "2026-09-21T17:56:38.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/8d54118bc228e64e1087f1729647b93bbea225eda4a3f914663a6f410bca/multidict-6.9.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b69651732c64afb691e50cdc3387cae305e0eeff8804fe3e3ce203876494932a", size = 333612, upload-time = "2026-09-21T17:56:40.041Z" }, + { url = "https://files.pythonhosted.org/packages/76/e1/9509dafc1fc68948e75842a74533ddfa64af57f2b17810e9cacfa68a829b/multidict-6.9.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1fee9a16d88a1c4865610de31ef5c666671020d7a81d1f510eaf3c97d00ebeba", size = 304246, upload-time = "2026-09-21T17:56:42.313Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b6/96b93808e1bec0b51d49d89859e49b82a94f7b2e58eca0fc44ffcafd3ae5/multidict-6.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7814bbee202acd3bd240204c17d8b87a4c48c81064fd8674dbe527d94d5a4290", size = 321983, upload-time = "2026-09-21T17:56:44.399Z" }, + { url = "https://files.pythonhosted.org/packages/d9/1c/08e5184df026b9d8e05a6dce5da22628e46bc4a7c7f03b8bf3c6939a8c46/multidict-6.9.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:95f273bea318a194f656527ee2ed19494327bc500b8e87d9358e3222579aa28d", size = 315927, upload-time = "2026-09-21T17:56:46.208Z" }, + { url = "https://files.pythonhosted.org/packages/d7/5d/9ff39e7387f6fcc9a81af5dbf055fde08149849a04f8df10ca5b086b0d39/multidict-6.9.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:73bcafa21a78d0776b3ee7cd2a63c66f968eb7db8e8d594e32d7329950f6e828", size = 336827, upload-time = "2026-09-21T17:56:48.021Z" }, + { url = "https://files.pythonhosted.org/packages/5a/be/30fbb2cab22203128928066de94be247dc9a3230bf2bfc54437ac3b5f9f7/multidict-6.9.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1a938761c77e0e6edb0c93d02f4e988d44a69e5195e5a3b893e5553311347132", size = 336317, upload-time = "2026-09-21T17:56:49.778Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f0/2480aef6b7d8e7ab89b064ae14f12ec069570dab3a5c13e0155912293247/multidict-6.9.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:37245ca4105386194dd1d292a6f2aae09bfe1bd7ac6f9ec25093e3cf8e9b143b", size = 303258, upload-time = "2026-09-21T17:56:51.829Z" }, + { url = "https://files.pythonhosted.org/packages/4c/04/dbe59cf4ea3778600e8e70e30b31aa9118ad6da2844bc9f717804cbaa000/multidict-6.9.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f0700527dd5bfa8b7204b08330542f4f388899d3c14d885d8a368992e0eb562d", size = 337232, upload-time = "2026-09-21T17:56:53.537Z" }, + { url = "https://files.pythonhosted.org/packages/f4/8c/abe55548f06f12878588f08eb803c5a391283f01243f784cb00adac79231/multidict-6.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6b7e54fd883671d1a8810704851c044b7173287c552b9f5c3d9e0eb9f00ae194", size = 330261, upload-time = "2026-09-21T17:56:55.335Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f1/ba67f668478f152bdfa18abe72e7588bd078bf8b42c172fcf542fcf45826/multidict-6.9.1-cp314-cp314-win32.whl", hash = "sha256:e81ae656b9935ac4528a71f96bb7a14d949778ed1897c573d3e7ebb9187f8841", size = 51715, upload-time = "2026-09-21T17:56:57.048Z" }, + { url = "https://files.pythonhosted.org/packages/33/81/014bb2128aedc8157d2848f0ec8a0209c6b822fb88deea012834e1c4a4e8/multidict-6.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:acddcac38adc8342ba48aba98896faa7928854bebb62542362138655b5367ee3", size = 58621, upload-time = "2026-09-21T17:56:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/66/34/8f43c03c2d8821a2320b8c560d8a49d0be2920627edfac3f546922f91969/multidict-6.9.1-cp314-cp314-win_arm64.whl", hash = "sha256:32217133dddc58c927805cf6c0731d8144584176b768042ee886d51e71860bc9", size = 53959, upload-time = "2026-09-21T17:57:00.15Z" }, + { url = "https://files.pythonhosted.org/packages/08/64/4f2a5eeea10b6d42634ba168ec6efc732ef218772e57b8239cd485c202f6/multidict-6.9.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0b2fb8c349d1103863750b5d8cb5ace766917f4b35f3d883c8f778853eaa9f76", size = 107694, upload-time = "2026-09-21T17:57:01.785Z" }, + { url = "https://files.pythonhosted.org/packages/32/ce/c68d08ac2096c1528ff20d77fd10f7e401db3e534ac5a1c7726e36a77c2f/multidict-6.9.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:56d834b74c993a7d7cb2b8ab33a0d55c3e0d4a3d2f2da2808a4ad3d79189711b", size = 64766, upload-time = "2026-09-21T17:57:03.52Z" }, + { url = "https://files.pythonhosted.org/packages/3a/9a/9f5c270c88fe289b4876fe28f9932a70e015a7916aff1e5b0325f7597b11/multidict-6.9.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:783ba7d845d79ce976afd9c1e91a4e5714671defa198ee789e8b23316083a485", size = 62723, upload-time = "2026-09-21T17:57:05.104Z" }, + { url = "https://files.pythonhosted.org/packages/7c/63/c9a0b131354dd949426166cef712d708f436a8cf261114f8d5d2aec440ca/multidict-6.9.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d718fa1b5f0d0dd75e86fbbc5b0c93ea3a5d65216c85c61cd5d7cdddfe08455", size = 290445, upload-time = "2026-09-21T17:57:06.687Z" }, + { url = "https://files.pythonhosted.org/packages/9d/41/ed27d1d20ba91f4e13e8f7dcfaa592614ae0e1d5f45e03171d23c3b098ac/multidict-6.9.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a313bad717dde740959d50850c315b75fd4eb0c5e6b4dc8535db0f1c369be125", size = 304375, upload-time = "2026-09-21T17:57:08.717Z" }, + { url = "https://files.pythonhosted.org/packages/66/6f/a7b26167173c3b73d6e5ebb0856c5154ad005156c3a2fa4062329921819c/multidict-6.9.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ef01d29fca550ab871fd99154f82c6472aacf8e7def272dfb07b460123850390", size = 281746, upload-time = "2026-09-21T17:57:10.713Z" }, + { url = "https://files.pythonhosted.org/packages/34/1d/da27bdd49b0cb84263288f5f48dbd915f2d17fd228fee2078ca607e80277/multidict-6.9.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f3bd290711c6e9486173a6ee7cd4e7f00c3971c7908c1ec1b6e5437c5e4c6f9", size = 311499, upload-time = "2026-09-21T17:57:12.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/36/ab0299a7abf53e38ff75d96c64480f1e2ff8edde985f93f7a1ad5e08bd94/multidict-6.9.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:81cdc537e0a42e3c0170752fcadbe450246d5c3b4b7231d6eb9672456605ac94", size = 313553, upload-time = "2026-09-21T17:57:14.548Z" }, + { url = "https://files.pythonhosted.org/packages/5a/44/e21be702efc9c9ba097c8be874c68860b1cb6cf9c85611263039bf95b37e/multidict-6.9.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8b193bf7a443c97d81c47052f60c486071a4bdef4a573fa2514f920089414d45", size = 299898, upload-time = "2026-09-21T17:57:16.192Z" }, + { url = "https://files.pythonhosted.org/packages/00/8d/e93bc0dc947c7f872e502cfaa7ad9d98f001e1c93cfa6f4940457a6e187a/multidict-6.9.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7a0c98f6a636adf0d7edd60c61589eaf52d149239af754d0bfeb0effedba53a8", size = 273054, upload-time = "2026-09-21T17:57:17.793Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c9/945b44493fbe7af26ffe1e43c506b376daeab070fc1a0923bef9bbf572eb/multidict-6.9.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6e6b7e3a1520c39a2772f414cd9ddf5995f77e4e823dfa1522af383addd465a3", size = 294751, upload-time = "2026-09-21T17:57:19.631Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/0969a9b38b814e5fa3af1b0a57708c49b3d9b3d55c208bac6e98b87bb028/multidict-6.9.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ca65cced0d67a9039e93bcd98a369920e499bf98296844ff56ead01c9085321f", size = 284523, upload-time = "2026-09-21T17:57:21.313Z" }, + { url = "https://files.pythonhosted.org/packages/1d/1e/c32ddf53234c228b1a374ddabca19f542ac7f32752f5155982abe2304833/multidict-6.9.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:372c063f37480f62c1ae32dc3a1a0a5942b180c883f99789075a8a8ec3c4e709", size = 290275, upload-time = "2026-09-21T17:57:23.206Z" }, + { url = "https://files.pythonhosted.org/packages/5d/09/7a99a3daafd987330abb865a2d36e432fd8bf18e790ddda55c5de15eb98f/multidict-6.9.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:827c92145b3b976b39430129c89d213b250dacbe5fce678e9a03940e6e848983", size = 303350, upload-time = "2026-09-21T17:57:25.086Z" }, + { url = "https://files.pythonhosted.org/packages/2e/09/7e6ebaecc4e226d7fe27973c271d58b75c14c24e84c23736d21dc39997dd/multidict-6.9.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9b0124b9c17e9890f0819b2e7a5f65ec9a2f5aabd6c8e7ad090c1675cf70dee6", size = 272232, upload-time = "2026-09-21T17:57:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/1f/6e/420e9e879b21cbdb5036214ee238efabcfab7bf262d3fc124132050f79ff/multidict-6.9.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:23136f5a564654eb61061ec6d5620a4c1ea32c8f552b65e9982a12b72bff601b", size = 300525, upload-time = "2026-09-21T17:57:29.004Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7f/c6b0896850b3ceb190ca302760d63148843371f027c5dde3d1d732343a08/multidict-6.9.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f2524ec55b3e65cbe235a8b3c36e2af3b635be02ed05e20c94e70c5c943c009e", size = 295933, upload-time = "2026-09-21T17:57:30.837Z" }, + { url = "https://files.pythonhosted.org/packages/29/f2/f0ff3384a756227741c72f4ee94cadf1f9900df0ae961e48639f96115b1c/multidict-6.9.1-cp314-cp314t-win32.whl", hash = "sha256:35ba0263bae5dd3ad5aad767cc9afc01a8598c7dae30f1b3b2de98b1b32c28bd", size = 57776, upload-time = "2026-09-21T17:57:32.835Z" }, + { url = "https://files.pythonhosted.org/packages/8d/87/013e1eed30ad46bec1eb68947bc9caf55ba2fd0d6aed04fe964e3b44c23b/multidict-6.9.1-cp314-cp314t-win_amd64.whl", hash = "sha256:7c8d5882ba25ac0282258be435d8a05aa0cbcacfce15799154a338f847f159b9", size = 64881, upload-time = "2026-09-21T17:57:34.489Z" }, + { url = "https://files.pythonhosted.org/packages/7f/95/64dd049bb4c53426e2fee7afb874daaa8b4426c41878785883a94511ecf8/multidict-6.9.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ac348379cf4de5538a0a213be1532d289aa801ec5d267c5909446b9ea2f8e2c3", size = 58582, upload-time = "2026-09-21T17:57:36.19Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2c/26bc1723592cfe965b575652a7749026474b0625b04b04564e1fba265dc5/multidict-6.9.1-cp315-cp315-android_24_x86_64.whl", hash = "sha256:f04551dce5a7db8c9659f2e4245494c182d0663b83661803e08d46bfcae5eda1", size = 62339, upload-time = "2026-09-21T17:57:37.967Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c5/614d2eb9995232d066aae7f17d1d454060b6b5d882b22fbaf6150b05ef1d/multidict-6.9.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:c0c88085affd35c33e124e36930c5ad96aff9294ce195eaa0fd9cec962b64a82", size = 55009, upload-time = "2026-09-21T17:57:39.746Z" }, + { url = "https://files.pythonhosted.org/packages/7f/24/f7179ecdf9cdd3581ace3292c3b883a1a500a374aa4e619bf6eb521c62e5/multidict-6.9.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a8b75dd3d3638d9a19f23e84af4ffab3b8940422c0df2da2a77005ef5aa3d7ea", size = 56273, upload-time = "2026-09-21T17:57:41.544Z" }, + { url = "https://files.pythonhosted.org/packages/ac/72/c9dffcecccd1f8b9a58f960ec4eaecab62c4edb08d5bb984d4de67fe3fdb/multidict-6.9.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:006c4478de0a1876f4834e14255776286f09b9846b505fe63f67f9d173a9487c", size = 96390, upload-time = "2026-09-21T17:57:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3f/76542360e655cecdb7c158c7af88c69c4db0d2aac3421659d41214927229/multidict-6.9.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2784090c30a586d5b45197bd9c32f87fb927216cde302f6cfd76d76577e90f08", size = 58900, upload-time = "2026-09-21T17:57:45.105Z" }, + { url = "https://files.pythonhosted.org/packages/04/15/f28c71af461dd4ff15653a165cfd8ddaf01f3387ff80d2dd0de057d527d0/multidict-6.9.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:7c708566da8014b120a64b1eb6d200c6c0c8cb36296383723cdb6fc82038270b", size = 56540, upload-time = "2026-09-21T17:57:46.913Z" }, + { url = "https://files.pythonhosted.org/packages/19/de/5ee4050c3ddad88c605a89fc1cd18e89abd40a64f183e6fb5276b6eb7f52/multidict-6.9.1-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:042fb0196047e786936a730bd302de83143950da45f2c16078da8f35e1cf7919", size = 331818, upload-time = "2026-09-21T17:57:48.825Z" }, + { url = "https://files.pythonhosted.org/packages/70/e2/2d4e3c3ee70f583334ff236fe6a3babf7a05ce56169a15df65453af647c0/multidict-6.9.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2212a0c842c723d919ea4a22a9296cb6b244b386e4e6ea92adfc7fbf3095519", size = 329800, upload-time = "2026-09-21T17:57:50.91Z" }, + { url = "https://files.pythonhosted.org/packages/d6/14/f64bd05667343a7250cdd99f81bda2270e3959f00832441f10c7f904e1c1/multidict-6.9.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd882aa29bf402b62bf1fd7c19fd5df4b6528cf468a908864b39368572b662a9", size = 312048, upload-time = "2026-09-21T17:57:53.053Z" }, + { url = "https://files.pythonhosted.org/packages/64/bf/a91256ce00199e7544e3eda79c4e9544eb7388c38fa22f11638c2e357ccb/multidict-6.9.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c57541034d12b215ab0a2bfa371d1a8a198da18176d0426c27105b9161a6862d", size = 343676, upload-time = "2026-09-21T17:57:55.069Z" }, + { url = "https://files.pythonhosted.org/packages/14/48/628b978413518159228c2b2ec38645362ceb6427e935d83d716e26490a49/multidict-6.9.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d73fed4e37158ff00cd271871170b79e40138db51e625fd352fa17c6acb34f67", size = 347028, upload-time = "2026-09-21T17:57:57.157Z" }, + { url = "https://files.pythonhosted.org/packages/8c/73/5f9dc2b5ac7dd7a82458ae9cd283b99c185834f23442ace295362dcc5e45/multidict-6.9.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:79da2491348b30810728050b4a8ec0416f85884125c2fd44655b3d01150d9c3e", size = 335670, upload-time = "2026-09-21T17:57:59.126Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/6f319ee000f5ab3fa0055b194ad706fddd552c60c76a21d581c87db1d46c/multidict-6.9.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b4da208a63434d21a3df64d29758e650fc4aa8cb05848554b76949c296539cca", size = 307804, upload-time = "2026-09-21T17:58:01.2Z" }, + { url = "https://files.pythonhosted.org/packages/3f/24/61be5fe1616d033532e8d63dc789c0f35b9793917834713eefe050589ba9/multidict-6.9.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:877ca17fdcfdf5c397493a71e5ff97a87bb181417fe717fdadc77c08c09301ac", size = 323409, upload-time = "2026-09-21T17:58:03.383Z" }, + { url = "https://files.pythonhosted.org/packages/22/a6/eab8aaf59892169a0f4e8e78a5516a6caa6abd8e96e6410111258f4ef0ef/multidict-6.9.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:40aec5299e1ed71fbb988389059da381c3c1a60e0c649acceb2a35d9b128848e", size = 315746, upload-time = "2026-09-21T17:58:05.372Z" }, + { url = "https://files.pythonhosted.org/packages/42/b1/d1e14bab80a74babf6e2ac971a878b84fdcf3a972f3ec3400ca12260fac5/multidict-6.9.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:4a409ccc42aefec904038695d5d7fd6d8f3af2721b7b6401d55135ec7d6d298e", size = 334529, upload-time = "2026-09-21T17:58:07.35Z" }, + { url = "https://files.pythonhosted.org/packages/f5/50/a05b1d36f6d3363a5a334cebbf11624c9c7a69d66fd23345eb53b1d43b7e/multidict-6.9.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:0a5769559e3312dd96731fbe15b4abb6033368ac1cad5a98dadd21946a4c7d6c", size = 336455, upload-time = "2026-09-21T17:58:09.112Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0d/bf33cb097380252b08e1177b5ae0cab822c47ce80d236f3f724953c76721/multidict-6.9.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:03fac50ddfd8302175b77863a015eccfae76767cda5506eef86df559ba861e1f", size = 305865, upload-time = "2026-09-21T17:58:10.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ee/a2bd133391204b1cec5a5caf327dc9a06710439b7403220860a8fb164560/multidict-6.9.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:5ecace251ccfa705bf3d7d35c5032cf750f5c629809740405697bce5c118c4a4", size = 337531, upload-time = "2026-09-21T17:58:12.846Z" }, + { url = "https://files.pythonhosted.org/packages/a8/32/c0972486f81a06c3bbb23063c85afaac53c51608f7e35bcb101a1006408b/multidict-6.9.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:ecc89dbd4155b2f8a47f4bbd89242a35ed15e8e0ec581cab5ac65fa38407329d", size = 332464, upload-time = "2026-09-21T17:58:14.673Z" }, + { url = "https://files.pythonhosted.org/packages/69/4f/5cbbe852b45d8a7f0ab3ed042386e4894e463010eb230603596cb15d0fee/multidict-6.9.1-cp315-cp315-win32.whl", hash = "sha256:a3ffe881246d28a862f1985824f484cb7361f44d6b99c4c620436ef56462f38d", size = 51714, upload-time = "2026-09-21T17:58:16.409Z" }, + { url = "https://files.pythonhosted.org/packages/d4/77/1fa118386deeae4e7a48870fa5cb073e748b0ac5265a6990651cefc48068/multidict-6.9.1-cp315-cp315-win_amd64.whl", hash = "sha256:59c123d0e948d760a5f930f316cfefa07e8d632ab84327c0693ee6a88171154f", size = 58619, upload-time = "2026-09-21T17:58:18.369Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ee/51c45748acfce9cb69d89dcfcd09c874b385407fd6993e885d6905659865/multidict-6.9.1-cp315-cp315-win_arm64.whl", hash = "sha256:31199204b3ced121ff5407a2c342326a5d27e3870abbf94bd80dbd2451b7bc8f", size = 53969, upload-time = "2026-09-21T17:58:20.102Z" }, + { url = "https://files.pythonhosted.org/packages/aa/57/89d2d3dedfae2558853c1770c7cbcdfd5211eb4c4090b1e8cb570544a490/multidict-6.9.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:8879510a76940670517ea1cb589978da44b86e286ec3e50d664ef330817afce7", size = 107679, upload-time = "2026-09-21T17:58:21.98Z" }, + { url = "https://files.pythonhosted.org/packages/c8/df/0c2a28870181c1762f90952b897f6db46c934ce073efe0442604cbc276db/multidict-6.9.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:aec65b53a07f580606593f877eefbb29a45939bfc0d3fe6e6d9f42b41b749f68", size = 64753, upload-time = "2026-09-21T17:58:23.622Z" }, + { url = "https://files.pythonhosted.org/packages/43/7e/9c6a3e7619459407d8958f7e56ab3dda7f0738f5aed9bb4ad48076e8de83/multidict-6.9.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:14c56f73e78faa1f68bbb826197cd5871994e70e841b8590829c35912ece5c64", size = 62732, upload-time = "2026-09-21T17:58:25.46Z" }, + { url = "https://files.pythonhosted.org/packages/38/eb/fd56c9d83ba0cdc3cf66f9acb278b24ef1f043b620b96d145685a3b57338/multidict-6.9.1-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:38c9986f9ce50c459b10de216a05f4bd7ed5ac63887d56e500a52bb464b861ce", size = 288648, upload-time = "2026-09-21T17:58:27.115Z" }, + { url = "https://files.pythonhosted.org/packages/df/ef/51e79c2442b0f56ac4ac580108d713078b31297e028d468fc220971a305d/multidict-6.9.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15db6a102cbaf1949cf028ecf080aac76d20bcd29ad4e092574db6c6b7af78a5", size = 304919, upload-time = "2026-09-21T17:58:29.143Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e4/774700cc5739353fe88a7204ed30e78a9403d98d4bb2496cc91f096d2e71/multidict-6.9.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd82c4977196681a499bb6ca9e462afbc5c91c1c15b6a991dfbd72733fea4dd2", size = 289094, upload-time = "2026-09-21T17:58:30.938Z" }, + { url = "https://files.pythonhosted.org/packages/ca/41/0bec327bc3a8271825376b6d8a469d1f80e3ba1e2e730e019836e29663c3/multidict-6.9.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bb58ba73a3f96f9a3e46b1fab69929d7edbbddb3133ee74b5c3c54074a53c4f1", size = 312007, upload-time = "2026-09-21T17:58:32.964Z" }, + { url = "https://files.pythonhosted.org/packages/c3/7a/9906bf5f1fe20e8e755affccc1a69ecd8aebb079ac65386e5ffc8eefbe75/multidict-6.9.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c6eecfab7ce4cd9487ff8ba936fe38cdfd68c04faf3d5c710363c6a8e695659", size = 314657, upload-time = "2026-09-21T17:58:35.285Z" }, + { url = "https://files.pythonhosted.org/packages/33/92/cf6d665a45663bbe216420fe632da10e9f4f842b07ce02bc3ddf8eb882b8/multidict-6.9.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c62e71e6289d78c0108d8aeb495f8bd3cad4bc1632fedddc9297ddf287ecc20", size = 301563, upload-time = "2026-09-21T17:58:37.472Z" }, + { url = "https://files.pythonhosted.org/packages/97/4c/9f1b7945526905d58b4b2d422e2ea81326dda71f9bdc9c32c8002639234a/multidict-6.9.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:539c2cd5fed0947c135cd7eabaaac55f48300dfa1de0f3ca4edb5efa6606f471", size = 278540, upload-time = "2026-09-21T17:58:39.551Z" }, + { url = "https://files.pythonhosted.org/packages/5c/d9/133aa472fc1be181de29a0c2e1c4cd778280882f532b404395f646742e46/multidict-6.9.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:3100d169ceb7bc8f05f89a6db11d1b21f119975fc26f29dc45a472e3569f0879", size = 294858, upload-time = "2026-09-21T17:58:41.791Z" }, + { url = "https://files.pythonhosted.org/packages/bd/38/21f2305dd20ac5038fa581b5d7e0d8dbbd70dc6b50231c22ec173dacddda/multidict-6.9.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:696477ad71385c4795e3b8e4cf10b0d2c28c2a1ca6a955e031cb1e62993e9ee3", size = 285461, upload-time = "2026-09-21T17:58:43.844Z" }, + { url = "https://files.pythonhosted.org/packages/ad/21/465a43c214d1d7e2d0c974e727d4b5935f8a0868838b5df9d0984c83c1dc/multidict-6.9.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:f5844e7befc707367807586f550fa97e23dcfef0728f02b98c7bc498a961a5df", size = 288964, upload-time = "2026-09-21T17:58:46.16Z" }, + { url = "https://files.pythonhosted.org/packages/2d/58/72a3e8d56c1e05146d2d1841dbaacf8986375eded1e5a58d30eef191a580/multidict-6.9.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:abc7c2e4b47bfe6a9aea434d3fdebb9597ee636e914e92ba352f2068b9142f4c", size = 303924, upload-time = "2026-09-21T17:58:48.395Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b9/911084c04530a682a54536b4f738d3992a593b5ff6fea1d6a1f02f3acc6b/multidict-6.9.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:7ab379f95caee071a37cbd8be86d4c65accc651d391f9f97748fef006f38769c", size = 278479, upload-time = "2026-09-21T17:58:50.494Z" }, + { url = "https://files.pythonhosted.org/packages/45/eb/9a333346c02c928a9e82534638bce85697c10c46b4901fcd70af19357356/multidict-6.9.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:557a4e1708df428ebe6c3081c83a273d275dad2446ce0d81fc648ac71afdb18d", size = 301650, upload-time = "2026-09-21T17:58:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/67/2f/1d936c4e45c8199db6082b6836e9182e31aa393de0de652637790831ca4b/multidict-6.9.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:475d04d5192eba487a3e2f935976340baa24529046e9c1c9c7a3b7bf80445ae1", size = 298024, upload-time = "2026-09-21T17:58:55.03Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0c/ca62ae2882b89a4cfeb0bbca873a4afdb5fc32994c4f8a37a7cb52ede7a0/multidict-6.9.1-cp315-cp315t-win32.whl", hash = "sha256:10083a8a0f4e1b26b599889e90b9802504ce5d3f7722f925bbb7ca47dd22a7c1", size = 57810, upload-time = "2026-09-21T17:58:57.141Z" }, + { url = "https://files.pythonhosted.org/packages/02/8b/b2c2805c3eb6fa4cdf5512fb95ac6174dde71e34dd601c5be52f4df5dc82/multidict-6.9.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e96ca64383efa107262ee3949f047af5ee4f1845ba09463466c04d35a83bd3ec", size = 64984, upload-time = "2026-09-21T17:58:59.446Z" }, + { url = "https://files.pythonhosted.org/packages/fd/34/03f1d204d698c408f4754c969882609b6b7a902c0e3d16a7033f219cdeda/multidict-6.9.1-cp315-cp315t-win_arm64.whl", hash = "sha256:501ed8b02a5990c67a91c732843609d43a6be1f7576fcdfc867331239f37fbd3", size = 58742, upload-time = "2026-09-21T17:59:01.726Z" }, + { url = "https://files.pythonhosted.org/packages/be/59/e26cb779be4c591d1a910f59d29aca9fba4de70349840a833beba2652371/multidict-6.9.1-py3-none-any.whl", hash = "sha256:7bf6478188f4e47bf5686e8a33da4ae28bf43b1b2528d9ee144d28492bfac60b", size = 19176, upload-time = "2026-09-21T17:59:03.501Z" }, +] + [[package]] name = "mypy-extensions" version = "1.1.0" @@ -190,6 +618,134 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/6f/84908cad2d6aa5144abcf7b42709fe4fdb459bc640ec7ac5786e7693dabc/prompt_toolkit-3.0.53-py3-none-any.whl", hash = "sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2", size = 392288, upload-time = "2026-07-26T20:56:12.512Z" }, ] +[[package]] +name = "propcache" +version = "0.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/9a/9fbf4e4ec0c2d7f1c32519fff782ef467859b8faa9fbc5331a96f6395d43/propcache-0.5.4.tar.gz", hash = "sha256:ff6b113f50bc066a698db5d944d2c6dc7507168dd3341e255a8892fd0715a558", size = 61545, upload-time = "2026-09-16T00:17:14.386Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/40/14b21e505b7921617466576423f188a5c9caddfdaa1cf4b2b8a83d8fe216/propcache-0.5.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:897d1ddf6716e8f47200f7aad9a0efa6cc7586df66c6defa572f9eab379c078e", size = 86393, upload-time = "2026-09-16T00:14:06.9Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4b/5a52e1a7b43563f7d408814194bb23cc8bf214eb6b86639b667a33a8d0d0/propcache-0.5.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9cbfff4423eef4cc6cafc021469641a2b835f610b2647a6c5281903e21b8670d", size = 50431, upload-time = "2026-09-16T00:14:08.025Z" }, + { url = "https://files.pythonhosted.org/packages/05/cf/b5248180bf056cc76acc60c9c6e8c0ebbfdbd1c6cffd31fd14996927b7c8/propcache-0.5.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3fc24f209c1b7f7f688b66b98293954f5504279760999b58920ee12dd8471c1d", size = 52116, upload-time = "2026-09-16T00:14:09.114Z" }, + { url = "https://files.pythonhosted.org/packages/86/a8/7c6cd6bfead1a11f2e411e688640e6d26574cb0bde7dcaa7423b0b65ed7a/propcache-0.5.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:62530ca89187827e4a4fe733f971abe81a7542eeea48ff61995f19b64d7199c8", size = 238729, upload-time = "2026-09-16T00:14:10.357Z" }, + { url = "https://files.pythonhosted.org/packages/5c/b4/442715b2e980df51be52d203549279e027728f24c80b00b5e525e31cd5ea/propcache-0.5.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fc3f7599528db40b1efa0889a620116e2704144495273d66066e8164e45838", size = 246121, upload-time = "2026-09-16T00:14:11.735Z" }, + { url = "https://files.pythonhosted.org/packages/bc/5d/df0684fc2b1732a01a7bec26d7897369022712422d25b09c37ce7dbc88a2/propcache-0.5.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f2d880ff60f45898f4acfa152aac8d04e3ee627d90ff4003491bf92239d5757", size = 251735, upload-time = "2026-09-16T00:14:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/c7/06/519a5ebb48b6f94beb48396e55c905f12246a25c3a3608a7ec7bceabf50e/propcache-0.5.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e9368e87a3efc285e559131092c5db643eb8e56de4ee42064d5baec22ef2bb5", size = 235381, upload-time = "2026-09-16T00:14:14.398Z" }, + { url = "https://files.pythonhosted.org/packages/3c/07/1e0a9bb310830f2245edbd5cd3c6d24a783c053c4efd8e08e386e513c940/propcache-0.5.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:004e685b315646c410771836e72a44f143bbe624f29653a42687815069a303d5", size = 208973, upload-time = "2026-09-16T00:14:15.715Z" }, + { url = "https://files.pythonhosted.org/packages/62/5c/9324fab27d6088eecc47fe4332bf7aaf8c1ded93c36f558391e8a06d41a7/propcache-0.5.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:594eb4c6ec35e7179b058481f4e9f02521b56de16fa577c4b85c76fb1bf8a9f8", size = 233897, upload-time = "2026-09-16T00:14:17.25Z" }, + { url = "https://files.pythonhosted.org/packages/9c/a3/570d92fc952eae93b676f3a1568f4b89264102abd3c982ab6a9ebec58dcf/propcache-0.5.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:2dba2f02d2d5c09ef8a0e6c1a42aeaa451f4be9898cb00b04fe98717da2eb23b", size = 223512, upload-time = "2026-09-16T00:14:18.87Z" }, + { url = "https://files.pythonhosted.org/packages/65/47/26810d889d89bba31db397e6a88f8984af775f5ed6bad0a29dce84324cff/propcache-0.5.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c3ef2818d63bc86071e9d2989ae75a1bc32b8f7059cfd9f5abbbee70c32e2ed6", size = 239043, upload-time = "2026-09-16T00:14:20.366Z" }, + { url = "https://files.pythonhosted.org/packages/89/2d/f9c47691aa024c8299a3afacd78d22a01ab57eb627b481b6089708e71017/propcache-0.5.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:dd2ac8f5b643454c2cc6b6118b13da16e88f4a6434fc3ba61aca384029f04f36", size = 208218, upload-time = "2026-09-16T00:14:21.801Z" }, + { url = "https://files.pythonhosted.org/packages/74/6b/d510c0c378cabbf9d0ac7b663af6d00f2e9074073d93b20c85f24aa5c071/propcache-0.5.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:4054acf80d40456a0537f2913b349718649d8d6458a14ab7f48d0ce28c30869d", size = 240301, upload-time = "2026-09-16T00:14:23.121Z" }, + { url = "https://files.pythonhosted.org/packages/3d/80/c80f6adaaa1e51f0db2dce8c9b3714d94ec45e358a21f9a1910b10b40a80/propcache-0.5.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:40e94adb1e7d39ff28a8bd8d8b8fbd1df6b9f40976dbe379134f1ce058e532dd", size = 230785, upload-time = "2026-09-16T00:14:24.458Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e2/c32a7df3f39caa7f11b2eb37ea5b6960a6946f2bc7c4b8ff97bbdf6d6b6e/propcache-0.5.4-cp311-cp311-win32.whl", hash = "sha256:9f86f7259efe2c951f43e57d471c9b41daa5bfc7db9f67189059cf1ae6d77fd9", size = 42747, upload-time = "2026-09-16T00:14:25.715Z" }, + { url = "https://files.pythonhosted.org/packages/0a/8a/3db6a3543d8101263b4c52978b6276a04ead2caff2c5ab880d934f47bd89/propcache-0.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:e904d4d01f36bd6e197590be1533c44e06058771e0746dd073a8ebb3ef880858", size = 46268, upload-time = "2026-09-16T00:14:26.996Z" }, + { url = "https://files.pythonhosted.org/packages/41/07/5222e2665bbf6e45847492ecbf3b9f3e4975a0ae300e5fd465df7d48ce55/propcache-0.5.4-cp311-cp311-win_arm64.whl", hash = "sha256:d42a9a856a4a6e2f6c10f1318c07e7daa498d6593abe745c71dae4521a26ca39", size = 43547, upload-time = "2026-09-16T00:14:28.143Z" }, + { url = "https://files.pythonhosted.org/packages/71/cd/348d58f142aebc4873345c6b31087629182ca6e0f2b3caeaa528cf882eba/propcache-0.5.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b28f41fa3b8c6900457f858ec5b03998f3a6d535fbc1bb2edec5961ea05ec429", size = 87285, upload-time = "2026-09-16T00:14:29.362Z" }, + { url = "https://files.pythonhosted.org/packages/df/f4/f3ffaee281b276da854ac1d7a6a506d26cbc62ea2e623756f1d0a4a1ba1a/propcache-0.5.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:dcbf346a318a5e30063f547630b02bb787ce2f45b6368d5da143660b6a3835d8", size = 50984, upload-time = "2026-09-16T00:14:30.473Z" }, + { url = "https://files.pythonhosted.org/packages/25/88/1d7df7201750b37765ef2b23bc1c526c028dadde80afa0f57a118fc01182/propcache-0.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:87a3caecf8095e48dc72f84bfa42e23a848cf410cc9cc13031fba4869b706a21", size = 52460, upload-time = "2026-09-16T00:14:31.692Z" }, + { url = "https://files.pythonhosted.org/packages/83/4f/48865bd02a16ee5236bc46166b2946f37b93e07b0eae355dac0be0b216ca/propcache-0.5.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60a64cbccaa11b7760ce705a14ada17ba459e7ca9f23ba587eb013821032d7ef", size = 251768, upload-time = "2026-09-16T00:14:32.908Z" }, + { url = "https://files.pythonhosted.org/packages/b0/19/3742a5eed62317b03b4002ee865dc9fd720308bdd0da1f29a5786c630311/propcache-0.5.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a74bfa37147cc08fb29df10bd9c16f40fa7f860cd3a6d2fff853323a94f6e17f", size = 257723, upload-time = "2026-09-16T00:14:34.267Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d5/ee6350fb0be9122bb6c67082a876d34b90d980d100c106af4b81023e04f4/propcache-0.5.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a4d7a54719b67338a305dca2ce6aafe366817df94ddfd4b5514374356f5ca546", size = 265597, upload-time = "2026-09-16T00:14:35.56Z" }, + { url = "https://files.pythonhosted.org/packages/85/9f/83a07b6ec0e043c050cfdd35fb0cf1b7897b91d554d6eea293740309afe7/propcache-0.5.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2814ecd8e818f487bee4b0f921bc4d1c176cc5fc71ac0f072d0fa67eda4ac14b", size = 250424, upload-time = "2026-09-16T00:14:36.894Z" }, + { url = "https://files.pythonhosted.org/packages/33/2c/a763a8251f50fba042af0fb1f02bfec4b31381e40aff760db2be7b2e1f84/propcache-0.5.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6af4693716bfb03f1752ef1b30faa593db2c01d5272e9b8564a1549452a979ab", size = 216748, upload-time = "2026-09-16T00:14:38.369Z" }, + { url = "https://files.pythonhosted.org/packages/6a/e2/4d11bea8fd6a777149c6c20645f873952eab5de3a2497aa11648ec9ab6ab/propcache-0.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4fbc1a15dc8cd1689508758d626b372b1f09d28d9577667feaf9e6bfcd8efcbc", size = 246533, upload-time = "2026-09-16T00:14:39.82Z" }, + { url = "https://files.pythonhosted.org/packages/9f/36/6683597de4907e70c717e3588c541202c66086a72ff3db58be49de66e72c/propcache-0.5.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cdee8205a44d0be91bbac4c41b95d86641b72dfc7aef1279400e4fda3f26a937", size = 238173, upload-time = "2026-09-16T00:14:41.259Z" }, + { url = "https://files.pythonhosted.org/packages/85/84/cb08d79f1762daafeb2b030c470cd0c725c97b8ad67412457c6f35c53e9d/propcache-0.5.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9a2a8a50a93dee0268a860a07fa3b4bd968f8ce4dbd794957da772f395368526", size = 251128, upload-time = "2026-09-16T00:14:42.652Z" }, + { url = "https://files.pythonhosted.org/packages/c2/0d/41b848036db6621370c1f2e5471a7da8149c730f8552a5257567721f4576/propcache-0.5.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7ffafcbfc7b549ab940047e505c831eabac5e67de53e1bc174adbc5285c55944", size = 214821, upload-time = "2026-09-16T00:14:44.112Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/adfae4bf9c63bccf12e2d9690a175c6579047a6eec3b5a6a5f51428c15e2/propcache-0.5.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d1f5a500bfcbb2c0ab85e98a0dcd70f5899d34efe365a0187700369a79603031", size = 254793, upload-time = "2026-09-16T00:14:45.429Z" }, + { url = "https://files.pythonhosted.org/packages/51/6f/eeca9647245d5f92e87d53e5f14335bb42fce1a7e6842c8045b364eded8b/propcache-0.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8a235f73d6e020855dc29dff012d920c02ee0feab8d73a24185a7569f4be1161", size = 247134, upload-time = "2026-09-16T00:14:46.976Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a9/424e38838793d37160b4379c702f61c74c598fc6cd17204adbe3c554f7a8/propcache-0.5.4-cp312-cp312-win32.whl", hash = "sha256:b3083bfe87f95c756e610bd8025f26cbd1cd4aaa03a422f2d65efb7a97cd53d8", size = 43073, upload-time = "2026-09-16T00:14:48.338Z" }, + { url = "https://files.pythonhosted.org/packages/58/7b/6e8ef26f6d510a7916064fec68d55fcbfbdf7eb01e377480d66a122152d8/propcache-0.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:98914de2c4d7f0f9f4a8c6ea4bf05841f4175796941e3ef7d47eb718f22311fb", size = 46190, upload-time = "2026-09-16T00:14:49.99Z" }, + { url = "https://files.pythonhosted.org/packages/08/b9/72028c5b56ced97f456de6aefa79435ca64d7f77af78ea8cf3c76fc5195f/propcache-0.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:8876b39961e33d912afe3c1bee18ee564fdad0206f873cc15d522756b7f50737", size = 43075, upload-time = "2026-09-16T00:14:51.155Z" }, + { url = "https://files.pythonhosted.org/packages/78/4c/3b1365d58a667689e067e13d055fcd92bdf8d9a2fca3d9201b47ed5b3631/propcache-0.5.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:36c0d9db44b523ef93d03341b1c42d69ff01d673c053d1b1c6c3a363bcaa39ba", size = 85290, upload-time = "2026-09-16T00:14:52.342Z" }, + { url = "https://files.pythonhosted.org/packages/8f/61/5f9c29c3aa67c30238c4eadf95149b1d983a48f69b86b0cff927a7d6df13/propcache-0.5.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1d52a05dc417279f7e5c7618c5dfbbc29923aaf9bc0a5c1802ddcebf54c61a0", size = 50027, upload-time = "2026-09-16T00:14:53.67Z" }, + { url = "https://files.pythonhosted.org/packages/25/7d/c1ab1ef09e9d4d835be5d58c0a32a1e1de8397abaa4e502a9d4141328cad/propcache-0.5.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44149f46500a0a41b95b4d99c2e586a77319539730607b9892974a092788b111", size = 51425, upload-time = "2026-09-16T00:14:54.826Z" }, + { url = "https://files.pythonhosted.org/packages/73/36/0093091ebb270fcd1bc1f6e095f93b2e0ed7f1011c28837dc2dbe5f96b99/propcache-0.5.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbab5f5ff6897c81f355d079010cdae85b02e5a0b518b5251523b8ad8ae9ac3c", size = 233595, upload-time = "2026-09-16T00:14:56.09Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/0de9d4c8e05ce0be71b436919a216bd7fc5cc6e2691c0602295efb22b9ed/propcache-0.5.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c3e98c55bde2bcf7db3c70d1aed7ae9aa8aebbf19a250c66645cde44cdb8b867", size = 240318, upload-time = "2026-09-16T00:14:57.674Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/2b35e91455209b85ee98f7859583e0814fab57d3af0f2381aaee34c37304/propcache-0.5.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:db3ae52ccc150dbc84704e9d642743897f3e1c54742ff34cacb661e52e3818a9", size = 246649, upload-time = "2026-09-16T00:14:59.352Z" }, + { url = "https://files.pythonhosted.org/packages/ed/74/08e6c1faf26ee2732023a3828787ba535557122774f4a386b1f715cbd8e0/propcache-0.5.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f85915e00dcb1cd9f2f890ead064ed40a27df06f0db65be427b29482ae357572", size = 234316, upload-time = "2026-09-16T00:15:00.696Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/08385733c9321c9bb78039d3ff31045e4fca962d9665023c4eb70f998819/propcache-0.5.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c2ba30a89035b57b73e00475de948521602f543d79ce01db10b04b36c4c76fc8", size = 204666, upload-time = "2026-09-16T00:15:02.019Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f4/e87bc7629af9a14a752b218764a78742d73c2c563ac58315da6841f0cbe4/propcache-0.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ae58f361bd5dae942717c65d3413b478c70aea9c462599e7b9adad3731db3894", size = 225900, upload-time = "2026-09-16T00:15:03.394Z" }, + { url = "https://files.pythonhosted.org/packages/d9/6d/11014938d3fe9bea2ea2dcf930f26ed565bfb2f5be3c756362ea48c92636/propcache-0.5.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:96f7c5c15656040ddcbc51e56dc59b58aa25999d743c126abd425b9766ab43e9", size = 219988, upload-time = "2026-09-16T00:15:04.811Z" }, + { url = "https://files.pythonhosted.org/packages/dc/72/fbf17c589f92c0b3bbf6709a425661f8ef2ed0d46b38985a7d7b5a0f6b91/propcache-0.5.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7cc528e760a8af06f2b13e9b9f362cd90c7c718ea61228a96dbd31ba16ed7f47", size = 233611, upload-time = "2026-09-16T00:15:06.498Z" }, + { url = "https://files.pythonhosted.org/packages/55/7e/dbd637572a279692e5518d117274a9331bf5faac59f191d30e82521a3ec7/propcache-0.5.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:425f8cc86ab5018b4b8d4a23bc8e74d964bd3d757c3702e301aa79be76c53f6c", size = 204333, upload-time = "2026-09-16T00:15:07.961Z" }, + { url = "https://files.pythonhosted.org/packages/ba/5a/f99c92068f1e0f5c886899ce0e4a619db376ca98c5279d93f95bd86906af/propcache-0.5.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a5793c7698a53f56f4a1889a4737c7eeb1b7ad0842fa6b1abca22913ff79c8c1", size = 235177, upload-time = "2026-09-16T00:15:09.334Z" }, + { url = "https://files.pythonhosted.org/packages/ee/28/95456fabd2daf6be89049a13fbf03341756014d2959c83d12957d4c49694/propcache-0.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c02c0e570c5c7e077b0181a9f3cdb7d4c3617d1cda6b5c95bd5d34022923d82c", size = 228982, upload-time = "2026-09-16T00:15:10.729Z" }, + { url = "https://files.pythonhosted.org/packages/b1/bb/df90f62c9cf7c93ea235f6f9405143bba802914607317266dd81fc8d737e/propcache-0.5.4-cp313-cp313-win32.whl", hash = "sha256:3e413d7a4a9b4866b7a761d6060d434b64d23cd35122eda3b026a0bbe8196b25", size = 42611, upload-time = "2026-09-16T00:15:12.111Z" }, + { url = "https://files.pythonhosted.org/packages/01/bc/e0a7b84af04ec02d73a48aa71f091e1e4a2107e3074b7ce12195b66901f4/propcache-0.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:0c889f6fa84957bc7e8b4eab71fd16a0455068d5045e3aa40c733071d2b2fd77", size = 45342, upload-time = "2026-09-16T00:15:13.519Z" }, + { url = "https://files.pythonhosted.org/packages/9a/70/50b031cafe72a5c1878b903ee87303f71313345566bf3d6ec202e5ddc9ec/propcache-0.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:69fc35c0779522da366c563e5faf203ffc1f8ff0021d5b1337fa4efa5be73177", size = 42408, upload-time = "2026-09-16T00:15:14.788Z" }, + { url = "https://files.pythonhosted.org/packages/33/c9/07e227b930c8ae513b8ef1aae3793499be097bffcdf7aee4fb8b33db4cd1/propcache-0.5.4-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e6720ba44ad7e72174314d0e1fb0172494cff5c73a3a8a2159c3d2402ff15565", size = 85933, upload-time = "2026-09-16T00:15:16.073Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e1/6710bb44510c4e4a8e0f004bbaf3cecfd048141309c77bae56d4e5a6ebc1/propcache-0.5.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4cfe0a92ae30151869e67a4b5f5e105e4e03ad30b3f38e5211b5bf77d0881993", size = 50179, upload-time = "2026-09-16T00:15:17.377Z" }, + { url = "https://files.pythonhosted.org/packages/e2/22/b533b493d7025456f44518b33e53e000021a20fe7c27b88cf3d341df7186/propcache-0.5.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d759d05634f1b038fb625a66662a8c85e5a8fec912da381b5149ddac107482b", size = 51942, upload-time = "2026-09-16T00:15:18.589Z" }, + { url = "https://files.pythonhosted.org/packages/f1/74/70ac8430e28f21e442c7bcb964eb46c4363f6881ade4aa0e978bfd8d503a/propcache-0.5.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:251c63dd46a0659bb875cb254dc4c1e79ee91a847c737cd62373295afc2235dc", size = 232647, upload-time = "2026-09-16T00:15:19.905Z" }, + { url = "https://files.pythonhosted.org/packages/72/95/f222f13b6fe623310be0eb61a673bf26df439ce27e563ca8e422d0818777/propcache-0.5.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a8d5ff04eb1f85698a78d20c62a14676e7b960dcafde09a388d60ad377d355d", size = 241541, upload-time = "2026-09-16T00:15:21.3Z" }, + { url = "https://files.pythonhosted.org/packages/a2/3e/763e370340db16115c5e63ad46e21ef0770a7f06928b3d3b62d8f8edfca4/propcache-0.5.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b9100a93b372418d8688f3f2a3e5b45c64d70ca4d6176e121aca1e3bfc1e32f", size = 245332, upload-time = "2026-09-16T00:15:22.802Z" }, + { url = "https://files.pythonhosted.org/packages/96/d3/e97cd6f5de2176bd90ed4076c7a9b5e09d0f0b9687d00a576507988bb62c/propcache-0.5.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc07876cfb079b6f6f36d21ce75784ad6c2c6b563eeac0ed26c2fa2669b85df9", size = 232757, upload-time = "2026-09-16T00:15:24.374Z" }, + { url = "https://files.pythonhosted.org/packages/f9/4c/6766e5f60bcda26d244333aa71d0a702c1c9b21b251d543c7af5953d1eee/propcache-0.5.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0951315a6b3142ee2167404d707743f0157c110091342b1aa0accac5cf0e4acf", size = 204389, upload-time = "2026-09-16T00:15:25.667Z" }, + { url = "https://files.pythonhosted.org/packages/b8/5e/ec4bb09a70b26ea99d76a8292c3383b960b296de2b347ac9986678f1761c/propcache-0.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bee7d3aed13d56f54e681df38c3a23031bc9e3863f687d9d598825c9146acd7d", size = 228217, upload-time = "2026-09-16T00:15:27.11Z" }, + { url = "https://files.pythonhosted.org/packages/e1/7d/b53922ba7d9e5bf797324e63aa05906ec240871899f779628df068743e2d/propcache-0.5.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4e985382be6d15da8d0c2710a6fa7b9070fc9ecdeefb7f580e88373984ec8be3", size = 216947, upload-time = "2026-09-16T00:15:28.532Z" }, + { url = "https://files.pythonhosted.org/packages/ff/39/b62eee45e5ea4de094a258cbb3b01c1e856ca51ddfd95b43135c5effd1eb/propcache-0.5.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9e9ab13760aa8b6d0881ae7cb04fd891d8d490cd2554ea8e79bb278399169bcc", size = 233457, upload-time = "2026-09-16T00:15:29.977Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a9/feec61ed296d993db9dd097e0f6723e3f576a647722367547495e4c5b05c/propcache-0.5.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1b2f3bec4261a94019575481c726c29850f72e27907773c75b1de421e20e9f9d", size = 204131, upload-time = "2026-09-16T00:15:31.74Z" }, + { url = "https://files.pythonhosted.org/packages/92/4d/411ef380cddad28dc001f1c6d75ec72c76cd3817030f68ec1ccfba0ec6c1/propcache-0.5.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:720cf832eb2d0b0dfee129cb3335a26f6ce3cc45ee1187e8f0731758caa16792", size = 234820, upload-time = "2026-09-16T00:15:33.087Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/c988229753629ef1cfd5198337a83e624780ea2b3787efe9e747c05aad2d/propcache-0.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fb0a5be8d9aa213150e8d8148a42aca4984b285bcad1e69587dc4298edd929b", size = 228350, upload-time = "2026-09-16T00:15:34.533Z" }, + { url = "https://files.pythonhosted.org/packages/12/49/5ef1c5cf98591da3c5b952b39e6a298084cc1ce353bc70f85e82397a5036/propcache-0.5.4-cp314-cp314-win32.whl", hash = "sha256:30cc1cebaf9aef49db06357a50398323ae04d70460c0491837d026ab7d6452ea", size = 43578, upload-time = "2026-09-16T00:15:35.957Z" }, + { url = "https://files.pythonhosted.org/packages/1e/9e/a0ac821a2229186af5e2e3c3635a78abb23cfddca57f38513ab5d70420f3/propcache-0.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:0a095db8e15a6020db149ecbed6461939fe74f6acaa3ae8b702a1fe8c38cd983", size = 46304, upload-time = "2026-09-16T00:15:37.655Z" }, + { url = "https://files.pythonhosted.org/packages/a1/19/c8d0d36a9d16cba5dcee67d389c9333b988c8986a653a61c00a451817a46/propcache-0.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:45488d1a5f9ab5bd90aaa1ca20f50fe1922b8ffad71a2009d2adf41355897aac", size = 43440, upload-time = "2026-09-16T00:15:39.091Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e9/42f1da77cacfc184e6ec929557ef653b7961bbf6f1da460b9221273948b3/propcache-0.5.4-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:53eaa697c4d0422ff4cb714d00231b43352064d97b944033b30c1d57cc506ec0", size = 90672, upload-time = "2026-09-16T00:15:40.306Z" }, + { url = "https://files.pythonhosted.org/packages/cf/2f/4b79940908c6ab8c795097c102999d7bc1f7e0b8604dfd1c232f9d99d67a/propcache-0.5.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:886b59c4d28ca97dd23b025fdfc50a0356be934efbbbca89ad26230067f86fe5", size = 52586, upload-time = "2026-09-16T00:15:41.575Z" }, + { url = "https://files.pythonhosted.org/packages/eb/07/02196ae6320c110235bb343f90dbd34be41f8b8964a3ee30db84ec12579e/propcache-0.5.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3fa15757fea1dfcd5b7745cad9f4638929605531bd4018ab2adff7955f1a403d", size = 54335, upload-time = "2026-09-16T00:15:43.027Z" }, + { url = "https://files.pythonhosted.org/packages/6f/44/f48b9a131985659924df5fa5093f68fe72c7ee375329802989ba3126efc6/propcache-0.5.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6f0093ac3e9daada202c2082439d414a625c57184727a46e112a3fb2a81cb788", size = 297567, upload-time = "2026-09-16T00:15:44.373Z" }, + { url = "https://files.pythonhosted.org/packages/04/a1/418d956d2735139f77fc35262179f1f52c23aa666de5a8ab3819c1ae7854/propcache-0.5.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3cd3a7edb6b95b9b33998135ebfa18d709da82290fb8f27c858970b5a12c8b56", size = 297477, upload-time = "2026-09-16T00:15:46.048Z" }, + { url = "https://files.pythonhosted.org/packages/69/fd/ff811fdb6d3d3e67fd9bbfb75881675d34a42d0ef29a45d33e3e233dde07/propcache-0.5.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c174bfd1c48a1b51a3078e95586dde718374bac79719ab3541ec9e74aec40574", size = 302669, upload-time = "2026-09-16T00:15:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/fc/57/527910c455b5ec62f6871bef45d4f79fea16cb8c966ba0d4a07f0339ddc4/propcache-0.5.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a219f0ac59817a9114dd2aa57c13180f993e819ba658c7ddab4b66ed1ee0d370", size = 287908, upload-time = "2026-09-16T00:15:48.99Z" }, + { url = "https://files.pythonhosted.org/packages/1d/86/f69ab82707534a0cb2057bdca04f9200a71214c7551800f9d34d6ac39e4f/propcache-0.5.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:17a7400cec0256f0a71ae71f9da398f9894c956ff6668a1c9d317b3367316320", size = 249804, upload-time = "2026-09-16T00:15:50.486Z" }, + { url = "https://files.pythonhosted.org/packages/27/19/60677af50d93be4256213de7cd487f056944c048b9c0b6f2e45b3a30f666/propcache-0.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:978f28401afbc76cdc3df9e1717b4229a06b626a1dcc75db4e1f2beb3884c3e9", size = 282344, upload-time = "2026-09-16T00:15:52.029Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f7/a0057808a91fb3b6a5f3602b528f0cdcb3d53e0ff8315d73fabdfdf8fec4/propcache-0.5.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4a1f4f5ffa55dce6307631f3cb2948e117e665966ea512e0d502b16c24f567e7", size = 270167, upload-time = "2026-09-16T00:15:53.466Z" }, + { url = "https://files.pythonhosted.org/packages/83/c8/f4a865490df0dc0c8531d4e59ac411cb6dc24bb255d2396a6f1c60a368f4/propcache-0.5.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:213bb68d9ced5cf2bf717b1071bf2b09b4b04c426256f9fe6d054c60318424c4", size = 286551, upload-time = "2026-09-16T00:15:54.995Z" }, + { url = "https://files.pythonhosted.org/packages/b0/67/b4faebde9da4e8173d0e5a30e8cd31335914af7ef350b988f27fec588cfd/propcache-0.5.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:286867fb156488c251a3721766e380ac4495e4fd6b51aaa1403d89ce7f4359d9", size = 249595, upload-time = "2026-09-16T00:15:56.505Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/52e1dd5636e9f5a27f6b5a4b4e2f33c322fd72afe956c397d82523ec4a80/propcache-0.5.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:445ee3bfb46e85838387fb3c536a73cc0b994dc192b004e40e170adc54aa2a7e", size = 286700, upload-time = "2026-09-16T00:15:57.985Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0e/30b2b324b93ff31a0bab539c102aae59e84e444031b2742150a7646aa1bb/propcache-0.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:48cb48c5346a97de792254af77715aa2529c2a1ebc5f586aa0aae44a02f1fe57", size = 280500, upload-time = "2026-09-16T00:15:59.487Z" }, + { url = "https://files.pythonhosted.org/packages/64/36/721bb59f682ff060d0c8df64274fca8cd0521b1a54506c2eedaef795b7f5/propcache-0.5.4-cp314-cp314t-win32.whl", hash = "sha256:03b229037d25b801e7af53fd52b9fc49d9439b036fca1e087e02780631adfa97", size = 46121, upload-time = "2026-09-16T00:16:01.349Z" }, + { url = "https://files.pythonhosted.org/packages/c1/86/0b1b80fa1ac3a0aac44e2922a6964fbe9cd52af5eab8fa933bf9e90b030c/propcache-0.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:8a1fc236528c457cd739c88abe823da851b7ab645d72792f88658114cc340c12", size = 49154, upload-time = "2026-09-16T00:16:02.901Z" }, + { url = "https://files.pythonhosted.org/packages/69/4f/9fe6f05a47cb550c823155052116f710064b6be5c6e8ec4e9faae7e18115/propcache-0.5.4-cp314-cp314t-win_arm64.whl", hash = "sha256:135036c5cfc93864affb0f9af9a27e5d7a71cb7bd745e7b6dbfc2d56cc30e827", size = 46005, upload-time = "2026-09-16T00:16:04.266Z" }, + { url = "https://files.pythonhosted.org/packages/58/25/895a11d1e4c5c2acc6d816e2bece34e02d9dc92f2182ae276cd819e9e804/propcache-0.5.4-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:45bf2e730ab8905d0527fe05a86500f406e64305c34cc81ebe64b4617cab9760", size = 85634, upload-time = "2026-09-16T00:16:05.599Z" }, + { url = "https://files.pythonhosted.org/packages/58/41/c0acd69271de7a1cf439e77d5d60c18575fd09bad56e798b95fa23458ea4/propcache-0.5.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:31eb43ba2edc704ab2ec27815315dd8a19def0fb16215be4cfe8d32fe78ffd51", size = 50084, upload-time = "2026-09-16T00:16:07.384Z" }, + { url = "https://files.pythonhosted.org/packages/a5/1a/ad561f99f90884089e6403b76c220610809429ba868a81a2e7ce115d32e0/propcache-0.5.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:174507f82d3594622acb1dd2dafecf2d899d6d506335494e7107767bf05f3aae", size = 51692, upload-time = "2026-09-16T00:16:08.956Z" }, + { url = "https://files.pythonhosted.org/packages/e9/07/057bdd3a9609ffad59b06239cceee784b047f6c720247bfaa36d2103e138/propcache-0.5.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e337653721d20ead710da33bf44487fbe8a0db8782714b60306481e9f95b51", size = 232947, upload-time = "2026-09-16T00:16:10.466Z" }, + { url = "https://files.pythonhosted.org/packages/fa/dd/d36ad35986718530498a65e45e3713f9f0e6a580f192ef02d2ef7cae9b52/propcache-0.5.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d21d0d2c82bbfeb1677a9711f38df968f9837576102bb4add1bd449d28d88f1", size = 241250, upload-time = "2026-09-16T00:16:12.056Z" }, + { url = "https://files.pythonhosted.org/packages/fb/81/f1459415cdb6c10d46942779de39bb59a77b38e5a76bb1def9227962eb45/propcache-0.5.4-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccf4f7a79e26bb7efb06ecd50c177833b71df05cbc748701372325e6bcc17f6f", size = 245150, upload-time = "2026-09-16T00:16:13.596Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4e/58b9b1460afc97a4c0b17ee89af701c4011d4d7f46470eba3aaff76a8069/propcache-0.5.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23278f808cd81d5ada7184a76606b925fb3389c60e1077b2cd7da7b1fcf0553c", size = 232166, upload-time = "2026-09-16T00:16:15.126Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c6/5a79e0eda3e7b6987d03d8c622ff6d52a42165a12e8418eb37694b9cc4b4/propcache-0.5.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e738ab81179510ce79b2eac9a6ecf47feffd9e76d1c72e403005dddb6e36c06c", size = 206085, upload-time = "2026-09-16T00:16:16.713Z" }, + { url = "https://files.pythonhosted.org/packages/4e/72/940aed42c73f9da345ca2de0f6e835c726498159abca5f1ef14fb0a2af8a/propcache-0.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:a419ee85e654927baabda3929c03c0cc1112bf472ff0dfd6142f4e3a81ca4162", size = 228460, upload-time = "2026-09-16T00:16:18.352Z" }, + { url = "https://files.pythonhosted.org/packages/85/71/3f54e1535c8f323d91ba566044d7c2b39ff6f6a2f1d0bd9071779d07b9b3/propcache-0.5.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:b61805357d966680acf68b3b6d49772631ed9df44ebece10ff1460e117a7da8a", size = 218350, upload-time = "2026-09-16T00:16:20.064Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f4/025890cc389ac3ec485ecec607d4a7ca47e15bfa2a465746ab98af602536/propcache-0.5.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:58134228927cee6c047d626c08e60a81be604a20578a12ce752cc5c9a84d4826", size = 233156, upload-time = "2026-09-16T00:16:21.624Z" }, + { url = "https://files.pythonhosted.org/packages/04/29/b39cae08c87c140d3d274f0a2c058cb5588e836175c3309e260b230ab07d/propcache-0.5.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:350b272b2279f4135a64fc0c304a5d08e28a137c9573442c606152446638a831", size = 206206, upload-time = "2026-09-16T00:16:23.204Z" }, + { url = "https://files.pythonhosted.org/packages/18/61/e16462ef18a87247dc9ebbd5c606f46d5ce67e708bd9cc734dd0d9222564/propcache-0.5.4-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:45bebbe252550fec975ba3b62bc6f931643cfd3b5464ef47619cf3fef154e01c", size = 234469, upload-time = "2026-09-16T00:16:24.841Z" }, + { url = "https://files.pythonhosted.org/packages/9f/84/b6a1490922427204fc47df920ed002eec709621de6b79b11592bf45c623a/propcache-0.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:ada748108a43d29b7c328ba7db3755327cd94f028bcc1a7ee3f0addcfacd9c38", size = 227311, upload-time = "2026-09-16T00:16:26.549Z" }, + { url = "https://files.pythonhosted.org/packages/ff/5c/5a59527582e9bcb694b2f08b9894134b65a0f5f79dbff174f054f5f74ed0/propcache-0.5.4-cp315-cp315-win32.whl", hash = "sha256:ee19113bce2f3acd46432050688b70f61acd6857d75abb9ec96341b7e9ced123", size = 43512, upload-time = "2026-09-16T00:16:28.313Z" }, + { url = "https://files.pythonhosted.org/packages/26/07/93cf699ed363681e754d7c3fad587fb09ef6b65618ee193332ad16a68d7b/propcache-0.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:ceb3e879afac028f93d272c957814695dc5569e4904262dbee92f6c41bd5e4a3", size = 46264, upload-time = "2026-09-16T00:16:29.751Z" }, + { url = "https://files.pythonhosted.org/packages/65/10/fef04fbdcd44a4a163cb5ff5674599c6d6fdefd64a5a459438f9ad2ba042/propcache-0.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:c83acbce9f2b5e3f5f5eda9e53d2001fed22fcdfef81274a9e02d8fd53b70a30", size = 43395, upload-time = "2026-09-16T00:16:31.5Z" }, + { url = "https://files.pythonhosted.org/packages/70/f6/7e2f4dab0b92ab46111bd48cee9ee1e5f519514c44e3779ede5358d7ada0/propcache-0.5.4-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:a5e8ef588c109725dc713ba69aadcac00a1ef90c2ce9c0a8c7075128f569f47f", size = 89825, upload-time = "2026-09-16T00:16:43.115Z" }, + { url = "https://files.pythonhosted.org/packages/9f/8b/dfeff925cb6ced97ede701d5c6a99998da963c6f2e06abbf879c9dac5b54/propcache-0.5.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:4d86476a935c88963d9b8e1a9a0d38188790e9622169bfbafa173046846709d3", size = 52159, upload-time = "2026-09-16T00:16:44.754Z" }, + { url = "https://files.pythonhosted.org/packages/24/6c/924c810be5b7cf218ef47e707cf06d34adb4e3f3a31e3c24c55c6d945a88/propcache-0.5.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:f5470694918830da62fac9e69133b53d23b736d7070e587b27a4a2be37e08e68", size = 53956, upload-time = "2026-09-16T00:16:46.762Z" }, + { url = "https://files.pythonhosted.org/packages/3f/b6/9ed0a5c939b58b6bed740a05b5d0f919f0b318d03284b4b6d81a0fe8a29a/propcache-0.5.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10ef33a68a61ce317e095fd2e202a592ea92392b90944a78c993f0d9a73ab06c", size = 295235, upload-time = "2026-09-16T00:16:48.577Z" }, + { url = "https://files.pythonhosted.org/packages/5a/eb/5ce886e902a2e781dddf110993d5329458a9b1a8626b876c65e5e25bf413/propcache-0.5.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5cacf3c9efd09df409dc33654dd077e1c245ba8fb747b0f0236ef41b7c49b589", size = 294463, upload-time = "2026-09-16T00:16:50.539Z" }, + { url = "https://files.pythonhosted.org/packages/f2/88/c98f49183ecd3e5b204a556f0ca47baa02c2206a500fe8c7ec1726297b0a/propcache-0.5.4-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:770e8209d018175fc0063936fa9583b6d27e88c5ad31543f3383d66080efdd62", size = 300081, upload-time = "2026-09-16T00:16:52.423Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/c5090f9e6f67cbc30a2b744c7bb0f8006dcba5ec1b0d82f866ae1cc7c5c4/propcache-0.5.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03969626faf0783a592dfa17e28eac06018bd0b44dafae6943d53b92421a7f72", size = 285360, upload-time = "2026-09-16T00:16:54.141Z" }, + { url = "https://files.pythonhosted.org/packages/ac/9c/34a55396910583ed07926669ab309dde2213a2dec05a7e946bb90ad66908/propcache-0.5.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ef3b928d9c984322b5c44e6964d8dbc653da87d2d8ee1647fa6da43072e650a9", size = 248014, upload-time = "2026-09-16T00:16:56.062Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b5/c0a142b656093ca397039dd3fe166cbb87c945712b534546514a24cd2611/propcache-0.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7177c43eddf10a0893c4fec52ebb408fdcd7f7d63962caace9180d8f81b14ece", size = 280662, upload-time = "2026-09-16T00:16:58.044Z" }, + { url = "https://files.pythonhosted.org/packages/54/28/fab2809c2e337fe26becea9648e84d5cef46075c91b826acb13e4f9dd04e/propcache-0.5.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:420162a77f94eb1cf5ef7893f500016dabd548e73de956785a1dd899cc73006a", size = 266149, upload-time = "2026-09-16T00:16:59.702Z" }, + { url = "https://files.pythonhosted.org/packages/3a/11/7ddf336288b2678a5f054f8da2e2bd1a719f5d4b7de714d9c6bd588a2313/propcache-0.5.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:3eb2e820e8e2101407da93f17c57cbb7d225461955fc60105daaba14cd421ee2", size = 283097, upload-time = "2026-09-16T00:17:01.459Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ae/351b1a5225f5473c411d9a612a229ae147cf0cf65c72ad838b87219ea8e8/propcache-0.5.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:13e52b6e0bde97dee98ab66552dbff2931649c96f1ac432eac299fe689ec373b", size = 248160, upload-time = "2026-09-16T00:17:03.298Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d0/7f79f061e30d135bb615c9782c94a74652033d00b49254edbbf35a9165a8/propcache-0.5.4-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:12682126712ddc19b70ff819debbd279e58adf1f0c8f8f8138c18ade2044b284", size = 283036, upload-time = "2026-09-16T00:17:05.238Z" }, + { url = "https://files.pythonhosted.org/packages/53/3c/016f1cad8bf4c428d748cf399b2bac603026fbfd6966e6a5579b5c5b6956/propcache-0.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:3af0c8642b2da4815d86e631232ac8286e17644fad907c19508aa8e7cb4ba8ad", size = 279350, upload-time = "2026-09-16T00:17:06.881Z" }, + { url = "https://files.pythonhosted.org/packages/ea/60/d8f72cb24b412487ed4c397f539117d3b74c3c33dd32020e91fe00a958a8/propcache-0.5.4-cp315-cp315t-win32.whl", hash = "sha256:1df8d8561b21465c5dd56110a01caf897e026d065b4b84e98a488209094272ec", size = 45874, upload-time = "2026-09-16T00:17:08.567Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ef/8bae0a316d406644450522f2f3d44a4e19632f5f3bb60d1d0e6c53842616/propcache-0.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:02c0a34f16889cf800f10f0247a564d8ce6eeab6ffcd7c87198f769067eb8432", size = 48574, upload-time = "2026-09-16T00:17:10.077Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/bcc053f66a97355683884b448198e79580fae8e8fa4d96b9bb01614e9913/propcache-0.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:dc4242ca653c9b30ab51c5f8193323e7bc0928f897ee9103201e59a43abcb72e", size = 45625, upload-time = "2026-09-16T00:17:11.377Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cd/785c64ed382f3f04201870267b02783f63b4678c2acfddc177a3ebcc2727/propcache-0.5.4-py3-none-any.whl", hash = "sha256:62c60aec739ed00124573cce1178138fd690c7676352d67a37328c1cf51d7468", size = 16338, upload-time = "2026-09-16T00:17:13.106Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -260,6 +816,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl", hash = "sha256:177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304", size = 49449, upload-time = "2026-07-17T01:48:05.728Z" }, ] +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + [[package]] name = "wcwidth" version = "0.8.2" @@ -268,3 +833,136 @@ sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288be wheels = [ { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" }, ] + +[[package]] +name = "yarl" +version = "1.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/75/16/e8be8e2fb175bbf41a0680381a319f1199fae256588241a2ac8677eafb49/yarl-1.25.1.tar.gz", hash = "sha256:03dd38de09bc213e9a8b29761eec33ee1d5318dac0e49d8af36e4d27830e23a7", size = 246245, upload-time = "2026-09-15T19:35:02.264Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/b3/2cea721d495ca57f8f414aa4867ee263f486b274e07463158cf52f02ba7f/yarl-1.25.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9d693bf4bf534e9ba3ae2780cfd577f5135629f7b5ac653490859d0b77864865", size = 143794, upload-time = "2026-09-15T19:30:26.946Z" }, + { url = "https://files.pythonhosted.org/packages/55/e6/cd145cff8e5cf60b8b3c41fbecfa2a45028a8dec3fbc52bec03595ff3d3b/yarl-1.25.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ab2054c5531af2a9ba7b69b8ec91e4f884420e83a8c5e579b013084cb57e5e5d", size = 103993, upload-time = "2026-09-15T19:30:28.661Z" }, + { url = "https://files.pythonhosted.org/packages/ce/7c/fbb40fe2d53747c40aa36a9e2bd2178a202f942bda0b670f3306d4aefbce/yarl-1.25.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:564fdc7085d2245ab84f88882fdb1d6ac0723124bff6ded35bfb1c00f812630d", size = 104010, upload-time = "2026-09-15T19:30:31.069Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0b/5a516f70641092283f57cf3670bdb75e7327bcc0dcb5038697e4dfbfd569/yarl-1.25.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acae6b45d1ace09b6ba3876da43b88366ef368f73b988c7f57e14231753d4420", size = 116444, upload-time = "2026-09-15T19:30:32.894Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/d1a627f827b0a404ae0f5647cab0534959081cde13b0756a783469fb3b5e/yarl-1.25.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1fb2a01ba8cd9c5d2c5dc1ec35e0fc951d04b4f037541d4ac090c993ce58b3d7", size = 107565, upload-time = "2026-09-15T19:30:35.188Z" }, + { url = "https://files.pythonhosted.org/packages/aa/cf/c8e0aaec886840a6c4480fe44eaec7cd4319f79a563b79321473be79c56f/yarl-1.25.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e92b6bcc741b86d67606c40d3cb9c7cc8e6c737f81e31f4a94efc204456c92e3", size = 125006, upload-time = "2026-09-15T19:30:37.1Z" }, + { url = "https://files.pythonhosted.org/packages/50/26/0cce366d54a93cdc8342965dc7663385e4db161625cc1e8b18e786753d24/yarl-1.25.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:72c34ac7ad4314c19362d5ce27626dcc8429bd30bbf8c179f4234078851f9492", size = 128717, upload-time = "2026-09-15T19:30:38.904Z" }, + { url = "https://files.pythonhosted.org/packages/95/0c/a71501bbc1a674ff72c4d6c2b75f4d9a5af819f5244c3a7558080a8802c5/yarl-1.25.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5add7b4ca7afeea91d52e4d4e4db3b1fe9885b71f07054560d8c4296b7441a2", size = 117728, upload-time = "2026-09-15T19:30:40.809Z" }, + { url = "https://files.pythonhosted.org/packages/e7/6f/c3267ca01defeed9ed9c4ff9b17bd54915432c405945233265b707475d1c/yarl-1.25.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:def538065f9e4d4cf1ae164bd59aba00dfa84f03923e0de4c3788f252d6bcd17", size = 116224, upload-time = "2026-09-15T19:30:42.818Z" }, + { url = "https://files.pythonhosted.org/packages/8f/69/fad57ee52d648431718ee0f1f68966a99c1352c3924688ffbdcc9d3fe51a/yarl-1.25.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a191bfdb30a79b98e5d175d75285f9fcb78bf0e46ba5efda042e1c72071a0de", size = 116299, upload-time = "2026-09-15T19:30:44.966Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d4/6a8c1e29f33338687ca278cba0a8fbf6525a322c2c02a9a500ccbe041152/yarl-1.25.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:71f42c5b9a948c113bbdebfa544598321431d064ff959d32e99b1feb61d68345", size = 108625, upload-time = "2026-09-15T19:30:46.874Z" }, + { url = "https://files.pythonhosted.org/packages/e8/d2/3a35ae791c9cb6522c106923ff25c3230d999091e5e65511bca23bbd9914/yarl-1.25.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:72849d892954be4d09e569b8b831ac39ce58417fedc767d4308a0fe542018a40", size = 124515, upload-time = "2026-09-15T19:30:49.082Z" }, + { url = "https://files.pythonhosted.org/packages/be/fd/2b022109a6b4af0f7dc371cf7500af380b0d4f034010243e1b0ce218dc93/yarl-1.25.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:efb01a106f971cb3752856bca2318bbdf7f01bd8823779c461586cbe5ffd5258", size = 115711, upload-time = "2026-09-15T19:30:51.208Z" }, + { url = "https://files.pythonhosted.org/packages/18/59/f7586271136c3ddb0126bbfe661844699369b76b555fe38c4efe86870b2e/yarl-1.25.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a1daf47cd95a7c3a63456336bc5aaa8c86dd3a47d07ed3d0e76132ae4666a5a1", size = 122751, upload-time = "2026-09-15T19:30:53.535Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0b/07f7a2d881f7e16c385b47fc1753382600847cad305dcc7fa0c25828acf8/yarl-1.25.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9489e6abf47ba37f332075a91444c7cfedb03e6ce99fbb2f116bfe1ce810da3b", size = 117983, upload-time = "2026-09-15T19:30:55.277Z" }, + { url = "https://files.pythonhosted.org/packages/aa/9d/8cdceec66a9b940700cb45931741403f045b162afd79bcb93c41cadd0972/yarl-1.25.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7306dee25b8a0e737363f347362b875094b4dc4e367311470656ae420fdbf8e", size = 102894, upload-time = "2026-09-15T19:30:57.606Z" }, + { url = "https://files.pythonhosted.org/packages/17/f1/7ec357db1d3ad2863542d71e8fe64a126bbec17b134cd7d30951196809a5/yarl-1.25.1-cp311-cp311-win_arm64.whl", hash = "sha256:abb1384477f5901d436b5d2e5465954de46ea6098f59163d243660b5c4461d35", size = 98609, upload-time = "2026-09-15T19:30:59.705Z" }, + { url = "https://files.pythonhosted.org/packages/75/b3/cd32ac66ae622b854c2df0ac52106dda220d361b65a64fde7d5b3684aa3f/yarl-1.25.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:94d7aa6debf92a1dd14cb5280b083a764169a13cfb23a452111160274ed989f4", size = 144798, upload-time = "2026-09-15T19:31:01.821Z" }, + { url = "https://files.pythonhosted.org/packages/61/fb/a2c52a8007c2051ba74662afb112ecf3d00346af4c25e33df9d80fd14fb8/yarl-1.25.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:83d4a37e4b95da4d8bda930d6d35b75b4cdadbacbb4980cae290ea3100b5d51d", size = 104583, upload-time = "2026-09-15T19:31:04.05Z" }, + { url = "https://files.pythonhosted.org/packages/be/dd/ee38aec8e09fdf957e50d4085453fbe202f56c6c3b4cf07b81cdb4f09ee9/yarl-1.25.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e029648f9c951db30e98a7d7ec90835db88ec4b32820efe2a9bdc2287e032eb6", size = 104325, upload-time = "2026-09-15T19:31:06.338Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b3/058dbfb1857b484c9cf9cc135659f50b85ce66e03c99e44dc2f7b6161f55/yarl-1.25.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d781294bb815ecb5ea57ff6bbf8038e0a31a95fdf3e1788f66e0dc100d64b58", size = 115358, upload-time = "2026-09-15T19:31:08.593Z" }, + { url = "https://files.pythonhosted.org/packages/db/39/29693446cf0cf6b15a0e2f75a5d40f93c56819b05b0622196f45e95b5cc0/yarl-1.25.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e12c538e00e7c1b286a07061046b90e8124e6a9793efae2c70db6a4aad07faad", size = 107658, upload-time = "2026-09-15T19:31:10.802Z" }, + { url = "https://files.pythonhosted.org/packages/86/b3/3c4dd7e1af43b931fba95e0a722737f2ea94a6d199c802585282831d7abd/yarl-1.25.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e4de3ac4adbad3d0bc7c6f4360a7dbff5de2f15e3b723be3198074e17fd9c40", size = 122660, upload-time = "2026-09-15T19:31:12.84Z" }, + { url = "https://files.pythonhosted.org/packages/bd/b5/1b60dbc3cfc9c5712b15148c206748f2bc93953ffdbe25ea75b63dfc89c9/yarl-1.25.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:419f392a1da624877975709e3864dfe833af6cc7671b39318086d456e288380c", size = 126506, upload-time = "2026-09-15T19:31:15.088Z" }, + { url = "https://files.pythonhosted.org/packages/bc/7b/ca212cbe170ac8b96e45317ecbcf9c3c3ecf0cdec98d5b088a9c4088929b/yarl-1.25.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6f117789d22dce188e5754e8bc65b7e6ebf8cb73963b9fa761f672a5883769d", size = 117050, upload-time = "2026-09-15T19:31:17.241Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c3/72b4938cdbe619ad71ac156182faef4908846b84dc3ca4dbb4c4e6f84014/yarl-1.25.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80e47012e730da131c9f059c80936783f9659aae22dc31c03c0595590d11ed54", size = 114174, upload-time = "2026-09-15T19:31:19.294Z" }, + { url = "https://files.pythonhosted.org/packages/e8/43/268717870f9ba0cc9701a95181587f6dc8c5f387aab4aeecc83158f38a79/yarl-1.25.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e80f557716fd765439577131e526b8942ffc2c07bdbc5e39fa62f660ba1e963f", size = 114944, upload-time = "2026-09-15T19:31:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/da/84/baa5bf504d51fe062c4bcaf62936da97fffb43285978d0b39984824231fd/yarl-1.25.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f61964f235a43738bfac50da46fc4254943a7eea3051aeb0b6fc7c992c29fadc", size = 108263, upload-time = "2026-09-15T19:31:23.388Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/779a2ed9e0152a601a27039bed9aead3f0b79797a67e2c44bfa444622dd8/yarl-1.25.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e546fe1d4a93ebc2910f0d768baff19faa09843ab3f2036a67ed6e69fae4419d", size = 122184, upload-time = "2026-09-15T19:31:25.343Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1f/118e9e5b8f07694d63fd3222e801d7782270003f1a222aa798df3f8d5933/yarl-1.25.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cce0727fd5ac04d372fa9bbfde9febc2bcf209aadfcf0468e45dec72719895d1", size = 114001, upload-time = "2026-09-15T19:31:27.465Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/a4cf1cf372313734b17996d4007f9f73596e7a178b9485802e5494ecf484/yarl-1.25.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af4ea5b37403ef4e30f3927eaed540db942bde01d8d3ff083527c0704d1c9c68", size = 120565, upload-time = "2026-09-15T19:31:29.47Z" }, + { url = "https://files.pythonhosted.org/packages/05/79/ad94f93ca731bc9e44d321833ab96b82a4f9f5f63cf773f81a4aeea5ecc1/yarl-1.25.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:68782fdb4027b8d1eee25ec35e9a6db05e863b899eb0310b3a33b6c3fef55707", size = 117060, upload-time = "2026-09-15T19:31:31.367Z" }, + { url = "https://files.pythonhosted.org/packages/bb/cc/51a7b4abf4ac593b8e7eb3794b28e5a35ae26eed8bc04787628d215af82f/yarl-1.25.1-cp312-cp312-win_amd64.whl", hash = "sha256:7d575b54cb3863ef9bc290ea4b009999d55dc237326131e4853cf33e888fee03", size = 102593, upload-time = "2026-09-15T19:31:33.329Z" }, + { url = "https://files.pythonhosted.org/packages/9d/21/0941a6b93a58b59a1ec75e5333bf06929b671309c43c0cd201c172d9c39f/yarl-1.25.1-cp312-cp312-win_arm64.whl", hash = "sha256:bc3ac7bf569f6b64dad04dd7808c7872dae8a97df657856eac05e9b7e3614a85", size = 97697, upload-time = "2026-09-15T19:31:35.855Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/2f3129bbcc9a5c8ba12cc2b29d8060a3bab9c8043c456cfd4b5ca3188890/yarl-1.25.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:25868beca8b6765f8f7d0e11fe6dd7c66dd4b0793b9500286d20cc92352126a5", size = 143623, upload-time = "2026-09-15T19:31:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/f1bc3390fdca352826676b531d0712736f156919090206700421d46b2c37/yarl-1.25.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:10b2fd95332f0d716d5eee3c9fb2ce8eada19082de7fee83d32e37992fd75c26", size = 104011, upload-time = "2026-09-15T19:31:40.25Z" }, + { url = "https://files.pythonhosted.org/packages/a8/aa/50acc5c3e5da04172ae3c281c75405af4d2ca911e16120ab0563f4dffb66/yarl-1.25.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f12afda4eea8c8994a76d4df1875c765194f5fbe8a9d197929ea303caee29ec", size = 103677, upload-time = "2026-09-15T19:31:42.46Z" }, + { url = "https://files.pythonhosted.org/packages/30/d2/7d1e0ab9f8390e1fbcede5a6dbf70d23c96ad09b8c5567f3a514d1ddb0e2/yarl-1.25.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:14b79a30a93a3ce2e8832603fd0ab780ada281b0ba5110b519a634f2d7d7d1fc", size = 115392, upload-time = "2026-09-15T19:31:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/71/e1/5ba1e3a2a22139213655e760919038e8ed7e2d4a99826d0bbddb3beb96e5/yarl-1.25.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bd6340d20ae2c7ca719b87b426e808e90743b676d05d4c26c4fb5ca71f41184", size = 107493, upload-time = "2026-09-15T19:31:46.273Z" }, + { url = "https://files.pythonhosted.org/packages/f5/53/780653d5e0f73831f467cf13548912e5eec97f21dc49fc8daf21da027df4/yarl-1.25.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:126a2533570c554719ca40a1288fdee1700b6bc82e7131aa69fa85252d92e651", size = 122537, upload-time = "2026-09-15T19:31:48.654Z" }, + { url = "https://files.pythonhosted.org/packages/03/92/d54fa70236c6036271c9c9c09fd978df5cbe3ef49ef6c46e9b833476d215/yarl-1.25.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a3faadac7d812ddac258feb57b9846b60c1b437c4f4b9ad42595c6f6fe4390df", size = 126170, upload-time = "2026-09-15T19:31:50.872Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b7/a82a49bf88340b837ef6972b508a1604ae377b9e6904b46b10cf5f1cf925/yarl-1.25.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be80550d9bfe83d9b62398a37081a90434e6df2d978ec345c3d2820de6beddab", size = 117012, upload-time = "2026-09-15T19:31:53.189Z" }, + { url = "https://files.pythonhosted.org/packages/ef/78/5d684b411e3f3602464ee9b538db48205038f8605872985f61efb809ced0/yarl-1.25.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e07595c7d6f4db270ceede356a1bd1c07a34f1c26f958d1ed0cd7b48e0d2bba3", size = 114950, upload-time = "2026-09-15T19:31:55.694Z" }, + { url = "https://files.pythonhosted.org/packages/2f/11/51d82b852c64f7fad0fc7a7ff3031517204887e874c722bbca839c0b23ac/yarl-1.25.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eb96ed1ae6c7d072d60840c0434aef07a2df611812810807fbc54263a6053e9a", size = 115428, upload-time = "2026-09-15T19:31:57.966Z" }, + { url = "https://files.pythonhosted.org/packages/e4/49/9d1978049bf646b9ea918313926453c6901b71c92f097467777d47d36a88/yarl-1.25.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3feb99222553a8cbedfa52c2f59dd84c3f50d5b582c728d522caf8d72769a54b", size = 108428, upload-time = "2026-09-15T19:32:00.048Z" }, + { url = "https://files.pythonhosted.org/packages/43/35/7b8f1ebb45d7ec3dda7d1909bf44f458de41ef91e2937f107733582a5166/yarl-1.25.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a2ed0ba415ccdf08f14bf544cb78346d0f76086707ffee24921a2c84dbf1305a", size = 121961, upload-time = "2026-09-15T19:32:02.436Z" }, + { url = "https://files.pythonhosted.org/packages/63/d6/d8b689ab7ca26edeb85f6ff28812aac7a25376eefc1780e303a7bfbaceff/yarl-1.25.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2b49375d22299b0a834c2bca72f39aaecc270d96fb24c30424899676f487b22a", size = 114961, upload-time = "2026-09-15T19:32:04.456Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d5/1a1798ea4dc6b7ee3260010a27907ebc697c95dae99817d817ed446d24aa/yarl-1.25.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ef74070ac553c59eb4f04258722066d6c6135b7baa03b2e9f2da65c096e96d98", size = 120036, upload-time = "2026-09-15T19:32:06.5Z" }, + { url = "https://files.pythonhosted.org/packages/91/8d/b1b35ed7903da6669b1d367cb2c09436acd4ff508029b4f39a0c0c2058fc/yarl-1.25.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0a66db89ea473abeac4b70523cafd94db3772380e565f9d28af7a179b7af71fa", size = 117276, upload-time = "2026-09-15T19:32:09.401Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8f/4db01cef62caff0d7a4593ed694fb8a41a27a11158cab80d290221f13e57/yarl-1.25.1-cp313-cp313-win_amd64.whl", hash = "sha256:1f51020b2eb8a003c84925638ec63c21a750a4bddd3a22ec8eac6a742dadf1b9", size = 101945, upload-time = "2026-09-15T19:32:11.545Z" }, + { url = "https://files.pythonhosted.org/packages/c0/5e/3ce00497c5c0babb74d4130c10c3828ccd215b4819d12020c42429f991ac/yarl-1.25.1-cp313-cp313-win_arm64.whl", hash = "sha256:b10dd0557ba422715b5206b3743192135a6022acca8baec51aa127d0a75db8fe", size = 97270, upload-time = "2026-09-15T19:32:14.127Z" }, + { url = "https://files.pythonhosted.org/packages/80/cf/54023edfab7aa773b860503db0c56e962ccab0922803ee97988c176ea090/yarl-1.25.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:a9ca696eb02e5c02a8afd872ada510eba9b7fe6e68b9572c2e9a9b1941e31e2e", size = 143975, upload-time = "2026-09-15T19:32:16.416Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a8/e6c1be0e6761d0f2d10bbf33a3e1e02b99dc83874d92945d7b461a72481e/yarl-1.25.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a5877f2255aab518ebe528289037699201d5dc5f045f2396cb30aa02db22f57f", size = 104018, upload-time = "2026-09-15T19:32:18.364Z" }, + { url = "https://files.pythonhosted.org/packages/6e/bb/dda344765ffd3430afe1a1c66c866a57fae67786537d4f14607df6505ac1/yarl-1.25.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7a5c3115595995779ee21f2567035793911c3802a43c74f3fbb0314929ec67ac", size = 104156, upload-time = "2026-09-15T19:32:20.459Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5f/ed1538bcd06009fe990d6d283dd7667f639e62a81e35c6d8c6ef6c08fb3c/yarl-1.25.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77e5099b99b37f3cf79c246998ca9f7313a78054cd1809ec46bc1afad47e1c4c", size = 116025, upload-time = "2026-09-15T19:32:22.766Z" }, + { url = "https://files.pythonhosted.org/packages/a2/af/2185daf56b99830d3356ecfada46faaa49945de6626e842b7728088d4980/yarl-1.25.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6efaf45df6a849cef613a03a94c845647456662f85438c886bb67a9c027c8c2c", size = 106985, upload-time = "2026-09-15T19:32:24.749Z" }, + { url = "https://files.pythonhosted.org/packages/c1/65/bc1ae564fb4b04a30b6a8f250e787772581c57e4c3d5cf07ac3359de3103/yarl-1.25.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5f90e44653c4e0f78501ed9bb7d3fce835a8d62b7c6ed0cb16557534087e743", size = 123030, upload-time = "2026-09-15T19:32:27.084Z" }, + { url = "https://files.pythonhosted.org/packages/6a/3e/e2afcde10d74e53b3fa889960991efb3019beda2b1682a01de720a302056/yarl-1.25.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:632da579b2d879f6bad20f2cfa35ded1efe2f4f77f8abb26a6234a5b236acd2f", size = 126765, upload-time = "2026-09-15T19:32:29.332Z" }, + { url = "https://files.pythonhosted.org/packages/a2/be/415b00c0fe5a0615b062a456b26623d7ec91c2bee20faea1a14045aa0469/yarl-1.25.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:30eec96e8a91bd588ce897c9543f6d5d8d34b28fbcba28a4dedf20ebeae9fe57", size = 117199, upload-time = "2026-09-15T19:32:31.49Z" }, + { url = "https://files.pythonhosted.org/packages/97/27/3d8c63ddd3e8bcfd033748ab93876678ce59bacd66e4cb1ed851c9c5b37e/yarl-1.25.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:12b6bc4906e11f5e1a1cdcb12296e7afbd366c783cc8073403cd2fb74334e453", size = 115187, upload-time = "2026-09-15T19:32:34.137Z" }, + { url = "https://files.pythonhosted.org/packages/39/b7/7a81d0be1a502a26a0d4326c6f2ecb736c824f570ea1c6529f2b0b227b50/yarl-1.25.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9d6ed3d17bccce4c05343e1ca8da13bc5c02c812a4e7282ddd05e8769322d3fc", size = 116085, upload-time = "2026-09-15T19:32:36.438Z" }, + { url = "https://files.pythonhosted.org/packages/f0/69/39fff459916aa0fab42215dc47b759586fd80f94aa56dfc4a7c15ba6e0dc/yarl-1.25.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:f38a70074041d3b7e138e452799f5174198bae5bd5ab2000917badf403908c5f", size = 107996, upload-time = "2026-09-15T19:32:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/c0/39/80b9a55a3335590451d9ecf3eb593a8c635351f4c905ef056d7e8a8fd9e7/yarl-1.25.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca89e4e21854ed27ec753297dde84b16c9f8e53b14a4866fb44457d643c19f8", size = 122549, upload-time = "2026-09-15T19:32:41.151Z" }, + { url = "https://files.pythonhosted.org/packages/42/7d/a179c6757818bb59372a4adafd09f7f26a3b4a0f04c3ae404b544c0b0c82/yarl-1.25.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1ab7618921a93767387a4b83776f751588f5b5ae9bb5bc96620e2e2e00bca868", size = 115107, upload-time = "2026-09-15T19:32:43.072Z" }, + { url = "https://files.pythonhosted.org/packages/32/2b/a773ac867e4ab53a98ed98e5cefe3bae31e6f550252ca9d1de266f1a40c5/yarl-1.25.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0ae12ff2b805fa02c4dab838005caef735e39986322698c48588d3beacb65c62", size = 120666, upload-time = "2026-09-15T19:32:45.061Z" }, + { url = "https://files.pythonhosted.org/packages/bc/41/52be6505e85b0f76b4f85b01b5de7e06a0512201abc2c95e14e099549174/yarl-1.25.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:90c30ed53546da833c700115c0064c22120d1b1560f474699fd31f22dd668233", size = 117505, upload-time = "2026-09-15T19:32:47.177Z" }, + { url = "https://files.pythonhosted.org/packages/f5/01/349c0386caedbbe488d519f252df54efac8a1459282d466c474bdd84a620/yarl-1.25.1-cp314-cp314-win_amd64.whl", hash = "sha256:acfa7e22aa6c6e7a5996a41d275bfa01efa7ea56ab890590280e9063e2cf5c1b", size = 103446, upload-time = "2026-09-15T19:32:49.615Z" }, + { url = "https://files.pythonhosted.org/packages/5c/f0/8ec63180f77912f0dc4e5a42760cb8c08d20da1d5ace3578a01b84d1f3d8/yarl-1.25.1-cp314-cp314-win_arm64.whl", hash = "sha256:8e7d98cdbb6d71e726f7d525952867096053d1f290dd4e3c50d7d313a136f414", size = 99159, upload-time = "2026-09-15T19:32:51.686Z" }, + { url = "https://files.pythonhosted.org/packages/47/7d/92d2220d6886b70ab1ed8579533ac2af2dfac716d5d929001daff7986df9/yarl-1.25.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d21f0fa80a02d05299207eeaafef345d812ace96d5306e4ef265e1d419a615fa", size = 150071, upload-time = "2026-09-15T19:32:53.911Z" }, + { url = "https://files.pythonhosted.org/packages/64/fc/b245e448124bcda9340df38e3553fa222b50260fca027a84095e9bd8642d/yarl-1.25.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17c9877a89fb6e2bca6f9087eb24cd7fb434653946ef5075e470d23d49b52287", size = 106780, upload-time = "2026-09-15T19:32:56.443Z" }, + { url = "https://files.pythonhosted.org/packages/51/e2/9a6ce2e334ebf218a30335ae76fb1696459430d42f733b8cb0d7d65b84d3/yarl-1.25.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:29273edf1530e397bd07cb784db1fbe0d2590b77569f2e24679a9c0a2d763b94", size = 107361, upload-time = "2026-09-15T19:32:58.827Z" }, + { url = "https://files.pythonhosted.org/packages/ed/70/66e8c76b569b450d16e190f15071c916c3df70b0e33927e415ac497cf0c2/yarl-1.25.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7abffdf37af1cec6a2ad69b827aa84320db5894791bc8ed932dc93fb274b7e9", size = 114396, upload-time = "2026-09-15T19:33:02.24Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/0d82838a05c57fdc05bc8b66e8c92dcc0df15e27463a5f163142d521c682/yarl-1.25.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2239a02249d9326655419e0168a28ca9008938eaab31dc29fc875c217927a6c0", size = 104882, upload-time = "2026-09-15T19:33:04.494Z" }, + { url = "https://files.pythonhosted.org/packages/86/d4/ea08615c4edaa6049a13a2f1128944d068d1893abda7d708d4d7ea01599a/yarl-1.25.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:664ec6a520b74a1df2810666eb67695fcb77fa663e6ea0a25aaf2e529cb24dfa", size = 119485, upload-time = "2026-09-15T19:33:06.583Z" }, + { url = "https://files.pythonhosted.org/packages/1a/82/0898bdce9b1ae403b308b9c733d0d24af4a3464270c2c081f457b16c3e0d/yarl-1.25.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f1c91f5a5980a937ff8e238e98e6897e1ad74a4b1e2c0d68c73b5ffbb3f5c0b", size = 122490, upload-time = "2026-09-15T19:33:08.653Z" }, + { url = "https://files.pythonhosted.org/packages/d1/38/97d79b81c342b78246cfedb74809e68841f3198d21653e10d3232bd9c622/yarl-1.25.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c88edaec8c349ad4c5ad4c486a3defcc4b80ceb2f074436ffa0a87caf5e76a6", size = 115336, upload-time = "2026-09-15T19:33:11.056Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9d/2577896554cd310dc470adb6da0b7dd0b435cb63e2565204a7ac240e504c/yarl-1.25.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:35dcbea443fafb3eece757ad4e514560ddeb6c34cfae1582c620d7b293d7feee", size = 111825, upload-time = "2026-09-15T19:33:13.204Z" }, + { url = "https://files.pythonhosted.org/packages/29/6b/7ac49d8ba84a5c4bd73415a4c949d22c749cb3762579b3d50e48019a78aa/yarl-1.25.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:882569ff613758cac762a457a5d72d6e211b28d4bcfea89d1d71ea942b02eac0", size = 114655, upload-time = "2026-09-15T19:33:15.553Z" }, + { url = "https://files.pythonhosted.org/packages/e5/18/e5942a16723f5b72f9b1297fd5a85a54f6300cd15c0dcb5005b90cd89156/yarl-1.25.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d0f1489233a254bb3643d2f05de7d59019254d81daeca6b9162fe9edef57e0c7", size = 106395, upload-time = "2026-09-15T19:33:17.599Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2d/549fa46240781513ebc47ae7eb418df428a163a2a3d644cc9cbb3ecb7846/yarl-1.25.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f41753a76f4f63927d03a0d8ba8f5ce0f2083bec29a8cfaccc55371b1564b96b", size = 119277, upload-time = "2026-09-15T19:33:19.973Z" }, + { url = "https://files.pythonhosted.org/packages/76/16/4763f78dcdc0b3b9fb3842b04afe72b9320857c6a69300c62a0eab03d119/yarl-1.25.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8fb0eb4955adf0579001581f2f71a126e8781ba61bcd120f127b0401163c6c2d", size = 112504, upload-time = "2026-09-15T19:33:22.464Z" }, + { url = "https://files.pythonhosted.org/packages/ae/b4/974e3edfe0d188393ce1cb9de400111c63fe61f4eb3b772a500d84c970d1/yarl-1.25.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a1e32763e641a1566507d90a8d3b19bfc3cc04a9d4e5ae3e32189874ed4b58a3", size = 116243, upload-time = "2026-09-15T19:33:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/b0/aa/157b940428da80c104ca09666a740e51c94963df65d5b112e06b52e4d7a8/yarl-1.25.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65b5b2066651b7432d389e9799d979c703bcc6ef44266bb8153ef54e91e4aab3", size = 115822, upload-time = "2026-09-15T19:33:26.886Z" }, + { url = "https://files.pythonhosted.org/packages/7e/af/19fbdce41412e1b96825544cc52cd7029d3724655d0988237972f078bd29/yarl-1.25.1-cp314-cp314t-win_amd64.whl", hash = "sha256:734f6e5400352ac4254456003d462866c684703570929cff7a7bde015d0cb371", size = 107386, upload-time = "2026-09-15T19:33:29.009Z" }, + { url = "https://files.pythonhosted.org/packages/2a/99/f6431c8968e89be608d74b28ae2d024521b2953f27dd44e0dece5e04f67a/yarl-1.25.1-cp314-cp314t-win_arm64.whl", hash = "sha256:287e99ff5aa4dc1c7630bfc683ded6f106d756c99dec432a2d7f197a784f51c6", size = 102094, upload-time = "2026-09-15T19:33:31.151Z" }, + { url = "https://files.pythonhosted.org/packages/c7/3b/4f51eab40c2eabea6c3d5b121dff4b8988dc35087732ffede12d2be8b8dd/yarl-1.25.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9b1bdaae98bc016825dd3c9d8ee1832f829b3341f9cc6ebd1a1b0a7fef7367cc", size = 143875, upload-time = "2026-09-15T19:33:33.52Z" }, + { url = "https://files.pythonhosted.org/packages/41/05/bbd58fc063f5f299a883f810760b265ca26c8167c91cb9a494d0fe2387e1/yarl-1.25.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:e7011b8fb8c4054bf0c12e5edc6cd83778b0028e99ce59b18586ed036f92cfdc", size = 104028, upload-time = "2026-09-15T19:33:36.22Z" }, + { url = "https://files.pythonhosted.org/packages/12/ee/2fba0aecb52e7020e189f684148783aa0b9cfa3b3bfb0b400646eef70ad4/yarl-1.25.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:f074e8d4aa0a5798920ddb6de3d08b228c614ff3724c3e8bd7577f4bafea867b", size = 104041, upload-time = "2026-09-15T19:33:38.86Z" }, + { url = "https://files.pythonhosted.org/packages/38/35/884beab53ed88c7247d1671972b5ef116f7351fe0c7e6de8c3558372cb16/yarl-1.25.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d42e7e3ca399555578b4d617e3a6ecf13371b3743a115995fa010c7bf341459", size = 116018, upload-time = "2026-09-15T19:33:44.265Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/1a91b685afb55cc18608443ace95280e96e263a97565811732b6788d3269/yarl-1.25.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aa4ed3dd308548f9e707d9caaf005d2d7f8c1e7868f858dfeb47fe76e16b391d", size = 107033, upload-time = "2026-09-15T19:33:46.45Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c1/58b379fcb1d68d907b7fcf75200c44321896509b2a6a74abbb4b19d864d2/yarl-1.25.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42a66563d8cc056ee32e6191e05097a7b2b3bc302e0bc3133daf8710eb18bd26", size = 123257, upload-time = "2026-09-15T19:33:48.631Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2d/1fe96cf5c2aeab10095e48f38585cf5a8451fb7253234822398e52aa5336/yarl-1.25.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:98d370568f393215d605304cdb77b3d5539bd192c75b623c7304c42c8d6d8273", size = 126745, upload-time = "2026-09-15T19:33:50.999Z" }, + { url = "https://files.pythonhosted.org/packages/ad/60/8674394ce43f4dadae573a1d6f451716438e9eab7d7fe8d643c673a32d85/yarl-1.25.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23bf5b403c879a54964e0feac7285688e04bb220074878d737d331522da0a5bf", size = 117255, upload-time = "2026-09-15T19:33:53.456Z" }, + { url = "https://files.pythonhosted.org/packages/2f/72/0faa30e02605d56127d42bb987dcc97da3863b7bf70b9bfbf5f739c05e30/yarl-1.25.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d45673badd08456d0340e9364eddafe1c53a9d2896424294de4d7dd71ad3ee57", size = 115166, upload-time = "2026-09-15T19:33:55.669Z" }, + { url = "https://files.pythonhosted.org/packages/8c/90/9a46eac564c437e128285c5c1d7bb385d268394e9209d84f6bf4a14471ef/yarl-1.25.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:0136d640dfa9b0523853e411430a99f8a91eca85774c6420285a33b755bc6de3", size = 116082, upload-time = "2026-09-15T19:33:57.78Z" }, + { url = "https://files.pythonhosted.org/packages/ad/38/4b1a686a3758878f93d2f1ea943f5a165f3555769cd16e761cfd0efdca17/yarl-1.25.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:59ba3a6e1aa8cfe5adf4bd270fd965db21955401b7ca6f1696010c55ed4daec2", size = 108048, upload-time = "2026-09-15T19:34:00.25Z" }, + { url = "https://files.pythonhosted.org/packages/da/14/f348eb967f31a58348612a2b93bd8a2ab664548e2b5e879cac7f592f7201/yarl-1.25.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:87796fedc3ba97ec14fab55acb48584276e6c1e4c1e89c422bda62c838e754a9", size = 122775, upload-time = "2026-09-15T19:34:02.455Z" }, + { url = "https://files.pythonhosted.org/packages/ab/e9/4f7b79700f88cb9e8bb66f8b54f9bce1844c013a2c39fdc47112e9334c95/yarl-1.25.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:bd0912757081f89b107d6c00b2ff8a194401b0b87eadcf4481de2b865a8fd44f", size = 115096, upload-time = "2026-09-15T19:34:05.283Z" }, + { url = "https://files.pythonhosted.org/packages/cf/37/f9cb020331997d3eb887bd28d5410ecfd3d80bf163c23d7cec490d78dade/yarl-1.25.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:b51c159a9794633f5e0db7ecec7b2b6e3734eca1f5d17dc989ff3552a43ff78b", size = 120655, upload-time = "2026-09-15T19:34:07.382Z" }, + { url = "https://files.pythonhosted.org/packages/12/83/52fceb22891a41f168db7ec22fd1d81e06b6a0b8d9f70921bd3e785defd0/yarl-1.25.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:319e070a01db9920fb63761843f96a104c8e2b9427266731810dc1e22595b17c", size = 117488, upload-time = "2026-09-15T19:34:09.988Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5c/ce6c4ff1247fcbe4b33d462c23a097106d909b173fde7042bc52290466e2/yarl-1.25.1-cp315-cp315-win_amd64.whl", hash = "sha256:a2059a2d891bd156bc5184e7ab7a56e78a84dfcfdeac8c501b552533ad1c36ee", size = 103434, upload-time = "2026-09-15T19:34:12.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/766a906b0704fb26d52b19dc22bed48a8ba0544203b70dcf44da350e8194/yarl-1.25.1-cp315-cp315-win_arm64.whl", hash = "sha256:a78b50b4f7918a3de71105d5c0b93bbc57bb8339a4d03a9dfd449f9068e76f3d", size = 99155, upload-time = "2026-09-15T19:34:15.132Z" }, + { url = "https://files.pythonhosted.org/packages/bd/d3/a1d09b32cb6ab14f66b44939f5b4255b8b9e747aef3974af1d5d80ccc2fd/yarl-1.25.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:b5402a340723fa7da00b5cff987ddab61276be6d11251ea71ae02bcac54890d8", size = 149284, upload-time = "2026-09-15T19:34:17.452Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3b/fe554d879692650bca70bfbc0df124e82e4d2bb7456f698c7756f1279a96/yarl-1.25.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:eda19ea5ee88742f47a2340816e6f2d40b53bed3ab5b69794769f36af9f35bb4", size = 106399, upload-time = "2026-09-15T19:34:21.474Z" }, + { url = "https://files.pythonhosted.org/packages/e1/4b/e7af56177ac8d40094c82d7728224c0b8472157d50d362e5fb3b014b2bc8/yarl-1.25.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:75baa6cf9b6d1c52f3e111a130e202fd8cf0a5b3a066c3f73d615e885092e4ec", size = 106968, upload-time = "2026-09-15T19:34:23.619Z" }, + { url = "https://files.pythonhosted.org/packages/da/4f/2df41fd738d46f23ef829ae8b4468d94bb6070038fc6dfab6165ed44fea8/yarl-1.25.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbcef5a9119ef653653132cccaf999b30a0af6f33bb0a4ba80bec30056868487", size = 114717, upload-time = "2026-09-15T19:34:25.879Z" }, + { url = "https://files.pythonhosted.org/packages/69/ea/002b66df53bbd1aed1c23358ff99c9bdc744fe3f4d2740e1b2fdd7192885/yarl-1.25.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7efc9f082dfed77c316edffa9deb52888e1bc6789171887cc1f68e06d65465c8", size = 105198, upload-time = "2026-09-15T19:34:28.204Z" }, + { url = "https://files.pythonhosted.org/packages/b1/7c/95c8bc0c8f97d71e59c94525ad60d76f5c57d3f2820f08137ca8b9f0542a/yarl-1.25.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fe01645169a2112aa1d4ebc3e4c5f029c5c8f97adfc32e5d37c993b39a994d75", size = 120271, upload-time = "2026-09-15T19:34:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7a/6fe9da56ec77927baa669fd86c39c567ce6205bab53d581082c6744c8ae7/yarl-1.25.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1c557dfd5e3db046053a0bdc72261ade790ebe8e2c7a41b36b0ca1f14cb95f3", size = 123572, upload-time = "2026-09-15T19:34:32.73Z" }, + { url = "https://files.pythonhosted.org/packages/8b/83/35f222d17fa70a14c7c74fdf112ccf5515e0c2a87082b1f9b99f7693bf57/yarl-1.25.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ce4d6ccafb33d39bd78444612d14938ead674c25702ded2ee9c54a47735d225", size = 115228, upload-time = "2026-09-15T19:34:35.344Z" }, + { url = "https://files.pythonhosted.org/packages/da/6f/fbaaf619423578a7d898d0f226ae47bc1906c293865c416d83c17b828b0e/yarl-1.25.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80a063f8297fc796296f00f100be520f209b23dc98f93ce8eba6ee7122598209", size = 111618, upload-time = "2026-09-15T19:34:37.656Z" }, + { url = "https://files.pythonhosted.org/packages/ba/78/7383278f1b3cf8e0496bd95b3281a7b09b89217b6b428db24c6b99b3deca/yarl-1.25.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7cb414a73e21a7ab58254926073f2930cb22f5b4314ea4260a687e2b3fd4dce3", size = 114839, upload-time = "2026-09-15T19:34:40.099Z" }, + { url = "https://files.pythonhosted.org/packages/62/49/5506e5b6d29aab91bd845cc9016d88c3d3f81b81bc242b8100bdd5737825/yarl-1.25.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:85a18376073f8a39aa07be34f9fc77e2869aa72c55c441efdd2cf79a0407504d", size = 106212, upload-time = "2026-09-15T19:34:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/46/8a/19877c193b7c5929f4b07118c18bbe390f3fadd0f59dd98c0cd12b31fa5c/yarl-1.25.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:77716e245c90f058466a05e6a465bb8600f767a8f4b18b4d40f3aff958e5f73c", size = 119985, upload-time = "2026-09-15T19:34:44.976Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6c/0a46fbbf9ecbcbd0cc20d2193394254b9e19817f22c814aab60f99847400/yarl-1.25.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:1e80dcf1446e1b080b1932b0d103c464a04112f5bc31f0f983ad418172063cde", size = 112081, upload-time = "2026-09-15T19:34:47.45Z" }, + { url = "https://files.pythonhosted.org/packages/ef/30/93f5d471230c74ccd06255d0842739f86551f937f9e63a5e947853c6244a/yarl-1.25.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:bdc8d8b8c22e9e43ac68316b5e6cf083dec537f4ec213cb4aa967b583bc3fa64", size = 116995, upload-time = "2026-09-15T19:34:49.972Z" }, + { url = "https://files.pythonhosted.org/packages/2b/80/c386593035ee3f9c6c6af0847b5578f2830c674794a9d7701b744a3ebd42/yarl-1.25.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dfbf531053a0935f2e871bcd4753f90313688772ff8c017f5ea402e315a78c1f", size = 115570, upload-time = "2026-09-15T19:34:52.628Z" }, + { url = "https://files.pythonhosted.org/packages/38/02/eef443559563ef8f2e10469387b8b1e97cb5efee95b288a56da60801f7ee/yarl-1.25.1-cp315-cp315t-win_amd64.whl", hash = "sha256:b13b88747769537f3d32e89e3a735da10c0a9e35d7322928c701b5f93d3afffd", size = 106811, upload-time = "2026-09-15T19:34:54.935Z" }, + { url = "https://files.pythonhosted.org/packages/88/91/41e284ca2cf5211e05dae031d126a3668aea88fa759df56e7e35c6ad25ba/yarl-1.25.1-cp315-cp315t-win_arm64.whl", hash = "sha256:783dd1467083f4d3f7722ad6a313f24c173e7571372738fcb7a6e6d1ba48df25", size = 101804, upload-time = "2026-09-15T19:34:57.231Z" }, + { url = "https://files.pythonhosted.org/packages/54/22/318c7980066769c6bcd9221ed2248294f5698811da099013098c670565ed/yarl-1.25.1-py3-none-any.whl", hash = "sha256:681c758b0490f9e96b78e5fa8e8dc6e648e9185bb6eaebe73183c33ea0c445f3", size = 63617, upload-time = "2026-09-15T19:34:59.616Z" }, +] From 35eacdc5d5fdd2a936739d44ce9b2f707a38aec0 Mon Sep 17 00:00:00 2001 From: Justin Searle Date: Thu, 1 Oct 2026 16:35:32 +0000 Subject: [PATCH 2/7] docs: explain web sessions and add browser layout tutorial --- CHANGELOG.md | 12 +++++ README.md | 87 ++++++++++++++++++++++++++++++- docs/DECISIONS.md | 29 +++++++++++ docs/STATUS.md | 101 ++++++++++++++++-------------------- examples/15_web_frontend.py | 59 +++++++++++++++++++++ examples/README.md | 14 ++++- 6 files changed, 243 insertions(+), 59 deletions(-) create mode 100644 examples/15_web_frontend.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ec41d2..802489a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to ctui are documented in this file. The project follows [Semantic Versioning](https://semver.org/). Dates use the ISO 8601 format. +## [Unreleased] + +### Added + +- Optional asyncio browser frontend via `ctui[web]`, `--web`, and + `CtuiApp.run_web()`, with packaged HTML/CSS/JavaScript and WebSockets. +- Shared browser views of one app session, existing custom-widget rendering, + completion, per-view help/confirmation/input popups, and optional authenticated + HTTPS access using supplied certificates. +- Browser-session integration tests, installed web artifact checks, and a + runnable custom-layout web tutorial. + ## [1.0.0] - 2026-09-13 ctui 1.0 establishes the first stable public API for building typed command diff --git a/README.md b/README.md index 80178c2..5804a37 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # ControlThings User Interface `ctui` is an event-driven Python framework for command tools that work both as -full-screen terminal interfaces and traditional command-line programs. Write +full-screen terminal interfaces, traditional command-line programs, and optional +browser interfaces. Write ordinary typed functions; ctui supplies parsing, validation, async execution, completion, history, layout, and automatic CLI routing. @@ -46,7 +47,7 @@ an event loop can use `await app.run_async()`. Test without a terminal using ## Automatic command-line mode -Every `CtuiApp` supports both interfaces without application-specific argument +Every `CtuiApp` supports these terminal interfaces without application-specific argument parsing. Calling the program without arguments opens the full-screen UI: ```bash @@ -89,6 +90,88 @@ Error: first must be float: 'wrong' In the full-screen UI, an invalid command is restored to the input field and the cursor moves to the beginning of the argument that needs correction. +## Optional browser frontend + +Install the web extra (aiohttp) to serve the same application's commands and +supported custom widgets in a browser: + +```bash +python -m pip install "ctui[web]" +python my_tool.py --web +python my_tool.py --web --web-port 0 +``` + +No flags still opens the full-screen terminal interface. `-c`/`--command` and +`-f`/`--file` still select CLI execution. `--web` selects browser-only mode and +cannot be combined with CLI batches. The terminal prints the browser address +and waits; Ctrl-C stops the server. Port 8080 is the default; port 0 chooses an +available port. Use different ports to start independent application sessions. + +The startup URL includes a random session token in its fragment. Open that URL +to authenticate; the browser removes the fragment after exchanging it for an +HttpOnly session cookie. Treat the startup URL as a credential. Additional tabs +at the same address share app state and output. Draft commands, completion, +scrolling, focus, and confirmation/help dialogs belong to each tab. `help` opens +a scrollable popup and preserves the output window. `exit` or Ctrl-Q stops the +whole session. Closing all tabs leaves the server and submitted commands running; +other views receive completed results. Unanswered dialogs are cancelled when their +requesting tab disconnects. + +The browser uses packaged HTML, CSS, and JavaScript with no frontend build step, +external assets, or JavaScript framework. WebSocket updates and commands run on +the same asyncio loop as app lifecycle hooks. Existing async commands can overlap; +append results use current output when each command finishes. Each view has a +bounded, coalescing update queue so a slow browser does not hold up other views. +Reconnecting views receive the current layout and output. + +### Custom layouts and lifecycle + +Reuse `compose()` and `ctui.widgets`: Vertical, Horizontal, Frame, Label, TextArea, +Window, Button, and ProgressBar. Shared buffer text, frame titles, callable +labels/footer text, visibility, and dimensions are translated into browser +widgets. Buttons invoke the same handlers; editable custom text areas update +shared state. Call `self.app.invalidate()` after changing application-owned +labels or progress. Text-buffer changes trigger web redraws automatically. + +Web mode runs `on_start`, builds widgets and the runtime, then calls `on_ready`. +`self.app` supports `invalidate()`, `exit()`, and `create_background_task()`; +terminal renderer internals are unavailable. Shutdown cancels pending work +before `on_stop` and service cleanup. In an existing event loop, use +`await app.run_web(port=8080)` instead of `run()`. + +CTUI's message, confirmation, and text-input dialogs work from browser-triggered +commands and callbacks. Native browser keyboard reservations and text selection +still apply. Arbitrary third-party prompt-toolkit controls, custom floating +containers, and renderer-specific extensions need a browser adapter; unsupported +controls produce an explicit startup error rather than a partial interface. + +Try the complete example: + +```bash +uv run --extra web examples/15_web_frontend.py --web --web-port 0 +``` + +### Optional remote access and HTTPS + +Loopback HTTP is the default. To listen on other interfaces, explicitly specify +a bind address and supply a certificate and private key: + +```bash +python my_tool.py --web --web-host 0.0.0.0 --web-port 8443 \ + --web-cert server-cert.pem --web-key server-key.pem +``` + +Open the printed address using the server's reachable hostname/IP in place of +localhost when binding all interfaces. Use a certificate trusted by the browser +and valid for that hostname/IP. HTTPS automatically uses secure WebSockets; +remote plaintext binds and incomplete certificate/key pairs are rejected. +Automatic certificate issuance and reverse-proxy configuration are not included. + +To reuse a supplied token, set an environment variable containing at least 24 +characters and pass `--web-token-env VARIABLE_NAME`. All connections still +require authentication. Browser origins are checked and loopback hosts are +validated. TLS encrypts traffic; the session token authorizes app access. + ## Help and interface guidance In the full-screen UI, `help` opens a scrollable popup and preserves the main diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index eed8345..93476ab 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -326,3 +326,32 @@ relevant entries per task, broadening for architectural work. Preserve decision rationale and supersession history instead of compressing away API constraints. This refines D11's instruction placement and startup reading, not its requirement for durable project continuity. + +## D15 — Optional asyncio browser frontend + +Accepted, October 1, during web frontend development on `feat/web-frontend`. + +One CtuiApp definition supplies three mutually exclusive startup modes: no +arguments opens the existing terminal UI, -c/--command and -f/--file run CLI +operations, and --web starts only the browser frontend. Print a connection URL +and keep the process running. A process owns one live session; separate +processes/ports own separate sessions. Multiple tabs at the same address share +app state/output while drafts, focus, scrolling and dialogs stay per-view. +Submitted commands continue after their tab disconnects; unanswered confirmation +or input dialogs cancel on disconnect, preserving explicit approval. + +Use packaged HTML/CSS and small JavaScript modules/functions with no frontend +framework or build step. aiohttp is an optional `web` extra for HTTP/1.1 and +WebSockets on the existing asyncio loop. Loopback HTTP is the default; explicit +remote binds require supplied TLS certificate/key files and authenticated access. +HTTPS uses secure WebSockets. Automatic certificate issuance is deferred. + +Preserve the existing supported compose() API using a browser adapter over its +prompt-toolkit tree. Compatible Button/Frame/ProgressBar subclasses retain +widget identity through upstream container conversion. This avoids forcing +existing apps to define a second layout or migrating the supported terminal API. +An independent declarative widget model is not introduced in this change. +Arbitrary third-party controls and custom floats require future adapters and +fail explicitly. Implementation/lifecycle, queue bounds, session credentials, +and limitations live in the application/web/layout/dialog docstrings and README. +Help remains a per-view scrollable popup that preserves the main output. diff --git a/docs/STATUS.md b/docs/STATUS.md index 64f56f2..4d79ac9 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,67 +1,56 @@ # Project status -Last reconciled: 2026-09-21, `main` at `cd848e7`; working tree was clean before -the current path-completion work. Remote refs and live CI were not refreshed. +Last reconciled: 2026-10-01, web frontend work on `feat/web-frontend`, based on +`main` at `fdda194`. Main was fetched from GitHub at workspace setup; no live +remote CI or publication checks are claimed. ## Current state -- Package metadata and lockfile remain `1.0.0rc1`. The owner confirmed RC1 - publication and basic functionality on September 18. RC2 is next; stable 1.0 - still requires additional changes and acceptance testing (decision D10). -- Since RC1: OS classifiers, hierarchical help/dialog scrolling, completion, - documentation, error containment, and protocol argument types were improved. - Latest committed change: dialog redraw fix in `cd848e7`, confirmed working - by the owner. -- Mixed byte input requires explicit `0x`/`0b`/`0o`/`0d` prefixes; fuzzy patterns - support bounded mixed-radix byte choices. See [types.py](../src/ctui/types.py) - and the latest entries in [DECISIONS.md](DECISIONS.md). - -## Current work - -- Added public, opt-in `PathCompleter` with threaded directory listing, directory - navigation, home expansion, file filters, and directory-only mode. -- Applied it to project/config import and export, history export, and the - filesystem example; new export names remain valid without a suggestion. -- Completion insertion now replaces raw quoted/escaped token spans and quotes - selected values for parsing, including inline named options. -- Updated public API documentation and regressions. Changes remain uncommitted. +- Metadata remains `1.0.0rc1`; RC2 is next, pending owner acceptance and the + release checks in [RELEASE_CHECKLIST.md](../RELEASE_CHECKLIST.md). +- Main includes reusable path completion (`fdda194`), dialog redraw, command + error containment, and the post-RC1 help/type improvements. +- Added optional `ctui[web]` support with aiohttp. `--web` runs a browser-only + session; default full-screen UI and command/file CLI behavior remain intact. +- Existing supported compose() widgets render in plain HTML/CSS/JavaScript. + One app process/port owns a shared session; tabs keep local drafts/scrolling. + Submitted commands survive disconnect; unanswered dialogs cancel. Help opens + a popup and preserves output. See D15 and the README for the accepted design. +- Remote binds require supplied TLS cert/key files. Session token authentication, + origin/host checks, bounded update queues, and independent per-process cookies + are implemented. Web dependencies remain optional for terminal-only users. ## Validation evidence -- All 135 tests passed on Python 3.11. Full Black/isort, lockfile, and - whitespace checks passed. New regressions cover provider filtering, filesystem - errors, relative/home paths, quoted insertion, and built-in wiring. -- Owner confirmed the preceding dialog fix works. No new remote CI or artifact - checks claimed; repeat release checks for RC2. - -## Next steps: RC2 - -- Manually check help scrolling and focus restoration in real terminals, - including narrow windows and application-specific shortcuts. -- Capture further owner-requested RC2 changes and acceptance tests. Record - platform, Python version, package/revision, and results for acceptance runs. -- Reconcile release documentation: CHANGELOG currently labels `[1.0.0]` stable - while metadata remains RC1. Confirm intended labels/date and readiness - classifier; RC note fallback alone is not a workflow failure. -- Prepare `1.0.0rc2` after remaining changes/testing, including post-RC1 fixes. - Follow [RELEASE_CHECKLIST.md](../RELEASE_CHECKLIST.md) for that revision, - including full source/artifact checks, remote CI, matching tag, publication, - and release/attestation links. Do not reuse RC1 validation as RC2 evidence. -- Resolve D07's outstanding `history clear` versus `project reset history` - discrepancy with the owner before changing that behavior. - -## Optional follow-ups, not release commitments - -- Background-job service and `py.typed` remain explicitly optional (D10). -- Assess existing tests before adding cancellation/database, large-transfer, - or repeated-lifecycle stress coverage; reduce duplicate CI runs if warranted. -- Previously documented limits: example 13's custom root lacks modal float - support; theme palettes are identical; dialogs have a horizontal-scrollbar - TODO. Cancellation cleanup was subsequently fixed in `be050b8`. +- All 152 unittest tests passed on Python 3.11.16 and 3.14.7 on Linux x86-64. + The web tests exercise real HTTP/WebSocket connections, independent sessions, + multi-tab output, reconnect, completion, dialogs, custom controls, TLS, malformed + input, disconnect behavior, and startup/shutdown cleanup. +- Chromium checked shared output, local drafts, both main and targeted help + popups, preserved output, focus restoration, buttons, completion, and narrow + browser layout. The reusable check is [browser_smoke.py](../tests/browser_smoke.py). +- Wheel and source distribution built and passed isolated base and optional-web + smoke tests, including bundled static assets and WebSocket dispatch. +- Black/isort, lockfile, JavaScript syntax and whitespace checks passed. CI now + installs the web extra for platform tests and checks installed web artifacts. + Remote CI has not been run from this workspace. + +## Next steps + +- Owner acceptance: try actual downstream tools in terminal, CLI and --web modes, + including custom layouts, background progress and remote trusted certificates. +- Confirm browser behavior in Firefox/Safari and the cross-platform CI matrix. + Arbitrary third-party prompt-toolkit controls, custom floats and renderer + internals require dedicated browser adapters; these are documented limitations. +- Reconcile release labels: CHANGELOG says stable 1.0 while package metadata is + RC1. Prepare RC2 only after acceptance, complete release verification and + matching tag/publication checks. No release/version change made here. +- Resolve D07's outstanding history-clear naming discrepancy with the owner + before changing that behavior. Existing palette definitions remain identical; + no theme redesign is included in this work. ## Handoff -No implementation blocker identified. Remaining validation is listed above. -Repository notes transfer with the branch only after explicitly authorized -commit/push, followed by a pull on the receiving laptop. Copy the global -`~/.codex/AGENTS.md` separately; it is not tracked by this repository. +Development is isolated on `feat/web-frontend`. Check its Git history and status +for the latest local commits. Push/pull that branch to transfer it to another +machine; main is unchanged. A merge and package publication are separate steps. diff --git a/examples/15_web_frontend.py b/examples/15_web_frontend.py new file mode 100644 index 0000000..c1d2b83 --- /dev/null +++ b/examples/15_web_frontend.py @@ -0,0 +1,59 @@ +"""Run one application as terminal UI, CLI commands, or a browser session. + +Run: uv run --extra web examples/15_web_frontend.py --web --web-port 0 +Open the credential URL printed in the terminal. Open another tab at the same +address to share output; draft input and scrolling remain local to each tab. +Try: echo hello, help echo, and progress 50. Click Increment to update the footer. + +Without --web the same app opens its terminal UI; -c "echo hello" runs the CLI. +A custom frame, side-by-side panes, button and progress bar reuse compose(). +""" + +from ctui import Argument, CommandResult, CtuiApp, command +from ctui.widgets import Button, Frame, Horizontal, ProgressBar, Vertical + + +class WebTool(CtuiApp): + """Demonstrate an unchanged app definition across three frontends.""" + + name = "CTUI browser demonstration" + + def __init__(self): + super().__init__() + self.clicks = 0 + self.progress_bar = ProgressBar() + self.statusbar = lambda: f"Button clicks: {self.clicks}" + + def compose(self): + return Vertical( + [ + Frame(self.layout.body, title="Commands and output"), + Horizontal( + [ + self.progress_bar, + Button("Increment", handler=self.increment), + ], + height=1, + ), + ] + ) + + def increment(self): + self.clicks += 1 + self.app.invalidate() + + @command(arguments={"text": Argument(help="Text to append to shared output")}) + def echo(self, text: str) -> CommandResult: + """Append text to the output window.""" + return CommandResult.append(text) + + @command(arguments={"percent": Argument(help="Progress percentage, from 0 to 100")}) + def progress(self, percent: int) -> CommandResult: + """Set the progress bar percentage.""" + self.progress_bar.percentage = max(0, min(100, percent)) + self.app.invalidate() + return CommandResult.success() + + +if __name__ == "__main__": + WebTool().run() diff --git a/examples/README.md b/examples/README.md index d4d6ecf..7404402 100644 --- a/examples/README.md +++ b/examples/README.md @@ -23,7 +23,9 @@ this order: 12. `12_keyboard_shortcuts.py` — register an application-wide shortcut. 13. `13_custom_layout.py` — compose a layout from reusable widgets. 14. `14_lifecycle_and_storage.py` — persist projects, configs, and records. -15. `filesystem.py` — browse with `ls [PATH] [-l|--long]` and `cd DIRECTORY`, +15. `15_web_frontend.py` — serve the same custom layout in a browser with + optional asyncio HTTP/WebSocket support. +16. `filesystem.py` — browse with `ls [PATH] [-l|--long]` and `cd DIRECTORY`, with file/directory completion and detailed listings. Run an example from the repository root: @@ -40,3 +42,13 @@ uv run examples/08_automatic_cli.py --help uv run examples/08_automatic_cli.py -c "add 12 30" uv run examples/08_automatic_cli.py --file examples/commands.txt ``` + +Install the optional web extra to try browser mode with any UI example: + +```bash +uv run --extra web examples/15_web_frontend.py --web --web-port 0 +``` + +Open the printed URL. `help` opens a popup without replacing output; multiple +tabs at the same address view one session. Without `--web`, the example opens +the terminal UI. From 3cc2af3184cca8b576523d965e99609c2308ab4a Mon Sep 17 00:00:00 2001 From: Justin Searle Date: Thu, 1 Oct 2026 16:42:13 +0000 Subject: [PATCH 3/7] fix: support progress tutorial commands in CLI mode --- examples/15_web_frontend.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/15_web_frontend.py b/examples/15_web_frontend.py index c1d2b83..610366a 100644 --- a/examples/15_web_frontend.py +++ b/examples/15_web_frontend.py @@ -51,7 +51,8 @@ def echo(self, text: str) -> CommandResult: def progress(self, percent: int) -> CommandResult: """Set the progress bar percentage.""" self.progress_bar.percentage = max(0, min(100, percent)) - self.app.invalidate() + if hasattr(self, "app"): + self.app.invalidate() return CommandResult.success() From e96bdba8dabd359fd14737199359f9f6246ab008 Mon Sep 17 00:00:00 2001 From: Justin Searle Date: Thu, 1 Oct 2026 16:43:18 +0000 Subject: [PATCH 4/7] test: verify Firefox browser behavior across all tutorials --- README.md | 17 +++ docs/STATUS.md | 16 ++- tests/browser_smoke.py | 11 +- tests/browser_tutorials.py | 209 +++++++++++++++++++++++++++++++++++++ 4 files changed, 245 insertions(+), 8 deletions(-) create mode 100644 tests/browser_tutorials.py diff --git a/README.md b/README.md index 5804a37..596ab74 100644 --- a/README.md +++ b/README.md @@ -506,3 +506,20 @@ uv sync uv run python -m unittest discover -s tests -v uv run examples/filesystem.py ``` + +## Browser verification for contributors + +The portable unittest suite includes real HTTP/WebSocket integration tests when +the web extra is installed. Additional Playwright checks exercise browser behavior +and all 16 tutorial apps through their actual `--web` entry points: + +```bash +uv run --extra web --with playwright python -m playwright install firefox +CTUI_BROWSER=firefox uv run --extra web --with playwright tests/browser_smoke.py +CTUI_BROWSER=firefox uv run --extra web --with playwright tests/browser_tutorials.py +``` + +Use `CTUI_BROWSER=chromium` for Chromium; `CTUI_CHROMIUM_EXECUTABLE` can select +a system installation. To repeat one tutorial, append its filename, such as +`15_web_frontend.py`, to `browser_tutorials.py`. These checks use temporary +application storage and close their servers; they do not require real devices. diff --git a/docs/STATUS.md b/docs/STATUS.md index 4d79ac9..2c6f172 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -26,9 +26,17 @@ remote CI or publication checks are claimed. The web tests exercise real HTTP/WebSocket connections, independent sessions, multi-tab output, reconnect, completion, dialogs, custom controls, TLS, malformed input, disconnect behavior, and startup/shutdown cleanup. -- Chromium checked shared output, local drafts, both main and targeted help - popups, preserved output, focus restoration, buttons, completion, and narrow - browser layout. The reusable check is [browser_smoke.py](../tests/browser_smoke.py). +- Chromium and Firefox checked shared output, local drafts, both main and targeted + help popups, preserved output, keyboard popup scrolling, focus restoration, + buttons, F2 shortcuts, completion, and narrow browser layout. The reusable check + is [browser_smoke.py](../tests/browser_smoke.py). +- All 16 tutorial applications passed in Firefox through real --web startup, + including async downloads, progress, storage, custom layouts, filesystem/path + completion and per-app help popups. A test timing issue in tutorial 15 reproduced + in Chromium; awaiting its progress update fixed the check in both browsers. + [browser_tutorials.py](../tests/browser_tutorials.py) supports individual repeats. + Tutorial 15's progress command was also checked in CLI mode after guarding UI + invalidation when no frontend exists. - Wheel and source distribution built and passed isolated base and optional-web smoke tests, including bundled static assets and WebSocket dispatch. - Black/isort, lockfile, JavaScript syntax and whitespace checks passed. CI now @@ -39,7 +47,7 @@ remote CI or publication checks are claimed. - Owner acceptance: try actual downstream tools in terminal, CLI and --web modes, including custom layouts, background progress and remote trusted certificates. -- Confirm browser behavior in Firefox/Safari and the cross-platform CI matrix. +- Confirm Safari behavior and the cross-platform CI matrix. Arbitrary third-party prompt-toolkit controls, custom floats and renderer internals require dedicated browser adapters; these are documented limitations. - Reconcile release labels: CHANGELOG says stable 1.0 while package metadata is diff --git a/tests/browser_smoke.py b/tests/browser_smoke.py index 120cb61..9ce745c 100644 --- a/tests/browser_smoke.py +++ b/tests/browser_smoke.py @@ -1,8 +1,9 @@ -"""Optional real-browser check (requires Playwright and a Chromium installation). +"""Optional real-browser check (requires Playwright and Chromium or Firefox). Run: uv run --extra web --with playwright tests/browser_smoke.py Set CTUI_CHROMIUM_EXECUTABLE to use a system Chromium, or install Playwright's browser with: uv run --with playwright python -m playwright install chromium +Set CTUI_BROWSER=firefox and install Firefox to run the same checks there. This check is separate from the portable unittest suite. """ @@ -22,10 +23,12 @@ async def main(): try: async with async_playwright() as playwright: options = {"headless": True} + browser_name = os.environ.get("CTUI_BROWSER", "chromium") executable = os.environ.get("CTUI_CHROMIUM_EXECUTABLE") - if executable: + if executable and browser_name == "chromium": options["executable_path"] = executable - browser = await playwright.chromium.launch(**options) + browser_type = getattr(playwright, browser_name) + browser = await browser_type.launch(**options) try: context = await browser.new_context() first = await context.new_page() @@ -86,7 +89,7 @@ async def main(): ) assert not errors, errors print( - "Chromium: multi-tab output, local drafts, help popup, focus, buttons, completion and mobile layout passed" + f"{browser_name}: multi-tab output, local drafts, help popup, focus, buttons, completion and mobile layout passed" ) finally: await browser.close() diff --git a/tests/browser_tutorials.py b/tests/browser_tutorials.py new file mode 100644 index 0000000..7e21498 --- /dev/null +++ b/tests/browser_tutorials.py @@ -0,0 +1,209 @@ +"""Exercise every runnable tutorial through its real --web entry point. + +Run: CTUI_BROWSER=firefox uv run --extra web --with playwright tests/browser_tutorials.py +For Chromium, set CTUI_BROWSER=chromium and optionally CTUI_CHROMIUM_EXECUTABLE. +Install a bundled browser with Playwright's install command first. Application +storage is redirected into a temporary test directory. Tutorial processes exit +through the UI and are forcibly cleaned up on any failure. +""" + +import asyncio +import os +import sys +import tempfile +from pathlib import Path + +from playwright.async_api import async_playwright + +ROOT = Path(__file__).resolve().parents[1] +CASES = { + "01_first_command.py": [("hello Ada", "Hello, Ada!")], + "02_class_application.py": [ + ("add 2 3", "2 + 3 = 5"), + ("multiply 4 5", "4 * 5 = 20"), + ], + "03_types_and_options.py": [ + ("greet Justin", "Hello, Justin."), + ('greet "Justin Searle" excited true', "HELLO, JUSTIN SEARLE!"), + ], + "04_named_arguments.py": [ + ( + "deploy api -e prod -r 3 --verbose", + "Deploying 3 api replica(s) to production with verbose logging.", + ) + ], + "05_hex_bytes.py": [ + ("inspect deadbeef", "de ad be ef (4 bytes)"), + ('inspect "0xbe 0b10101100 0xef 0d10 0o377"', "be ac ef 0a ff (5 bytes)"), + ], + "06_fuzzy_patterns.py": [ + ("hex expand 56ffff07f[0-2]01", "56ffff07f201"), + ("text expand {admin,user}-[1-2]", "user-2"), + ], + "07_integer_ranges.py": [ + ("inspect 0-5,9", "7 unique values"), + ("expand 0-5,9", "0, 1, 2, 3, 4, 5, 9"), + ], + "08_automatic_cli.py": [ + ("greet Ada true", "Hello, Ada!"), + ("add 12 30", "42.0"), + ("count-words 'hello world'", "2 words"), + ], + "09_completion_and_validation.py": [ + ("dep dev dev-1", "Deploying to dev-1 in development") + ], + "10_async_and_events.py": [], + "11_statusbar_progress.py": [], + "12_keyboard_shortcuts.py": [], + "13_custom_layout.py": [("system status", "Controller: online")], + "14_lifecycle_and_storage.py": [ + ("configs show local", "127.0.0.1"), + ("profile save lab 10.0.0.20 502", "Saved profile 'lab'."), + ("traffic record sent 010300000001", "Recorded 6 bytes."), + ], + "15_web_frontend.py": [("echo hello", "hello")], + "filesystem.py": [ + ("ls examples --long", "01_first_command.py"), + ("cd examples", "Changed to"), + ("ls", "15_web_frontend.py"), + ], +} + + +async def check_tutorial(browser, name, commands, directory): + token = "temporary-tutorial-browser-token-123456789" + environment = dict( + os.environ, CTUI_TUTORIAL_TOKEN=token, XDG_DATA_HOME=str(directory) + ) + process = await asyncio.create_subprocess_exec( + sys.executable, + str(ROOT / "examples" / name), + "--web", + "--web-port", + "0", + "--web-token-env", + "CTUI_TUTORIAL_TOKEN", + cwd=ROOT, + env=environment, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + context = await browser.new_context() + try: + line = (await asyncio.wait_for(process.stdout.readline(), 10)).decode().strip() + if " web session: " not in line: + errors = (await process.stderr.read()).decode() + raise AssertionError(f"{name} failed to start: {errors}") + address = line.split(" web session: ", 1)[1] + page = await context.new_page() + page.set_default_timeout(15000) + errors = [] + page.on("pageerror", lambda error: errors.append(str(error))) + await page.goto(address) + await page.locator("#workspace").wait_for(state="visible") + command_input = page.get_by_role("textbox", name="Command", exact=True) + output = page.locator(".output_field") + + async def submit(text, expected=None): + await command_input.fill(text) + await command_input.press("Enter") + if expected is not None: + await page.wait_for_function( + "value => document.querySelector('.output_field').textContent.includes(value)", + arg=expected, + ) + + for text, expected in commands: + await submit(text, expected) + if name == "09_completion_and_validation.py": + await command_input.fill("deploy development dev-") + await page.locator("#suggestions").wait_for(state="visible") + assert "dev-1" in await page.locator("#suggestions").text_content() + previous = await output.text_content() + await submit("deploy development invalid") + dialog = page.get_by_role("dialog") + await dialog.wait_for(state="visible") + assert "Server names must end" in await dialog.text_content() + await dialog.get_by_role("button", name="OK", exact=True).click() + assert await output.text_content() == previous + elif name in ("10_async_and_events.py", "11_statusbar_progress.py"): + await submit("download report.csv") + if name.startswith("10"): + await page.wait_for_function( + "() => document.querySelector('.output_field').textContent.includes('Started report.csv')" + ) + else: + await page.wait_for_function( + "() => document.querySelector('.statusbar').textContent.includes('report.csv:')" + ) + await submit("download photo.jpg") + await page.wait_for_function( + "() => document.querySelector('.output_field').textContent.includes('Completed report.csv') && document.querySelector('.output_field').textContent.includes('Completed photo.jpg')" + ) + if name.startswith("11"): + assert await page.locator(".statusbar").text_content() == "Ready" + elif name == "12_keyboard_shortcuts.py": + await command_input.press("F2") + await page.wait_for_function( + "() => document.querySelector('.statusbar').textContent === 'F2 presses: 1'" + ) + elif name == "15_web_frontend.py": + await submit("progress 50") + await page.wait_for_function( + "() => document.querySelector('progress').value === 50" + ) + await page.get_by_role("button", name="Increment", exact=True).click() + await page.wait_for_function( + "() => document.querySelector('.statusbar').textContent === 'Button clicks: 1'" + ) + elif name == "filesystem.py": + await command_input.fill("ls 01") + await page.locator("#suggestions").wait_for(state="visible") + assert ( + "01_first_command.py" + in await page.locator("#suggestions").text_content() + ) + + previous = await output.text_content() + await submit("help") + dialog = page.get_by_role("dialog") + await dialog.wait_for(state="visible") + assert await dialog.locator("h2").text_content() == "Help" + assert await output.text_content() == previous + await dialog.get_by_role("button", name="OK", exact=True).click() + await submit("exit") + await asyncio.wait_for(process.wait(), 5) + assert process.returncode == 0, (await process.stderr.read()).decode() + assert not errors, errors + print(f"PASS {name}", flush=True) + finally: + await context.close() + if process.returncode is None: + process.kill() + await process.wait() + + +async def main(): + browser_name = os.environ.get("CTUI_BROWSER", "firefox") + async with async_playwright() as playwright: + options = {"headless": True} + if browser_name == "chromium" and os.environ.get("CTUI_CHROMIUM_EXECUTABLE"): + options["executable_path"] = os.environ["CTUI_CHROMIUM_EXECUTABLE"] + browser = await getattr(playwright, browser_name).launch(**options) + try: + with tempfile.TemporaryDirectory(prefix="ctui-tutorials-") as directory: + selected = sys.argv[1:] or CASES + for name in selected: + await check_tutorial( + browser, name, CASES[name], Path(directory) / name + ) + print( + f"{browser_name}: {len(selected)} tutorial applications passed", + flush=True, + ) + finally: + await browser.close() + + +if __name__ == "__main__": + asyncio.run(main()) From 39e272358b87a4e0f6b99c3db1f6c90ba5f95877 Mon Sep 17 00:00:00 2001 From: Justin Searle Date: Mon, 5 Oct 2026 11:25:25 -0600 Subject: [PATCH 5/7] fix: include aiohttp in standard installations --- .github/workflows/publish.yml | 8 ++++---- .github/workflows/test.yml | 8 ++++---- CHANGELOG.md | 2 +- README.md | 19 +++++++++---------- RELEASE_CHECKLIST.md | 2 ++ docs/DECISIONS.md | 9 ++++++--- docs/STATUS.md | 17 ++++++++++++----- examples/15_web_frontend.py | 2 +- examples/README.md | 4 ++-- pyproject.toml | 4 +--- src/ctui/application.py | 2 +- src/ctui/web.py | 12 ++++-------- tests/browser_smoke.py | 2 +- tests/browser_tutorials.py | 2 +- tests/test_web.py | 10 +++------- tests/web_smoke.py | 4 ++-- uv.lock | 9 ++------- 17 files changed, 56 insertions(+), 60 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9315bc0..ee8866e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -69,11 +69,11 @@ jobs: enable-cache: false - name: Install dependencies - run: uv sync --locked --extra web --python ${{ matrix.python-version }} + run: uv sync --locked --python ${{ matrix.python-version }} - name: Run tests run: >- - uv run --extra web --python ${{ matrix.python-version }} + uv run --python ${{ matrix.python-version }} python -m unittest discover -s tests -v build: @@ -113,8 +113,8 @@ jobs: - name: Smoke-test installed web frontend run: | - uv run --isolated --no-project --with dist/*.whl --with aiohttp tests/web_smoke.py - uv run --isolated --no-project --with dist/*.tar.gz --with aiohttp tests/web_smoke.py + uv run --isolated --no-project --with dist/*.whl tests/web_smoke.py + uv run --isolated --no-project --with dist/*.tar.gz tests/web_smoke.py - name: Upload distributions as artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4041c4b..c0207b7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -43,11 +43,11 @@ jobs: run: uv python install ${{ matrix.python-version }} - name: Install dependencies - run: uv sync --locked --extra web --python ${{ matrix.python-version }} + run: uv sync --locked --python ${{ matrix.python-version }} - name: Run tests run: >- - uv run --extra web --python ${{ matrix.python-version }} + uv run --python ${{ matrix.python-version }} python -m unittest discover -s tests -v quality: @@ -101,8 +101,8 @@ jobs: - name: Smoke-test installed web frontend run: | - uv run --isolated --no-project --with dist/*.whl --with aiohttp tests/web_smoke.py - uv run --isolated --no-project --with dist/*.tar.gz --with aiohttp tests/web_smoke.py + uv run --isolated --no-project --with dist/*.whl tests/web_smoke.py + uv run --isolated --no-project --with dist/*.tar.gz tests/web_smoke.py - name: Upload distributions uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 802489a..64008c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ ISO 8601 format. ### Added -- Optional asyncio browser frontend via `ctui[web]`, `--web`, and +- Asyncio browser frontend included in standard installs via `--web` and `CtuiApp.run_web()`, with packaged HTML/CSS/JavaScript and WebSockets. - Shared browser views of one app session, existing custom-widget rendering, completion, per-view help/confirmation/input popups, and optional authenticated diff --git a/README.md b/README.md index 596ab74..50ca660 100644 --- a/README.md +++ b/README.md @@ -90,13 +90,13 @@ Error: first must be float: 'wrong' In the full-screen UI, an invalid command is restored to the input field and the cursor moves to the beginning of the argument that needs correction. -## Optional browser frontend +## Browser frontend -Install the web extra (aiohttp) to serve the same application's commands and -supported custom widgets in a browser: +Browser support is included in the standard installation. Serve the same +application's commands and supported custom widgets in a browser: ```bash -python -m pip install "ctui[web]" +python -m pip install ctui python my_tool.py --web python my_tool.py --web --web-port 0 ``` @@ -148,7 +148,7 @@ controls produce an explicit startup error rather than a partial interface. Try the complete example: ```bash -uv run --extra web examples/15_web_frontend.py --web --web-port 0 +uv run examples/15_web_frontend.py --web --web-port 0 ``` ### Optional remote access and HTTPS @@ -509,14 +509,13 @@ uv run examples/filesystem.py ## Browser verification for contributors -The portable unittest suite includes real HTTP/WebSocket integration tests when -the web extra is installed. Additional Playwright checks exercise browser behavior +The portable unittest suite includes real HTTP/WebSocket integration tests. Additional Playwright checks exercise browser behavior and all 16 tutorial apps through their actual `--web` entry points: ```bash -uv run --extra web --with playwright python -m playwright install firefox -CTUI_BROWSER=firefox uv run --extra web --with playwright tests/browser_smoke.py -CTUI_BROWSER=firefox uv run --extra web --with playwright tests/browser_tutorials.py +uv run --with playwright python -m playwright install firefox +CTUI_BROWSER=firefox uv run --with playwright tests/browser_smoke.py +CTUI_BROWSER=firefox uv run --with playwright tests/browser_tutorials.py ``` Use `CTUI_BROWSER=chromium` for Chromium; `CTUI_CHROMIUM_EXECUTABLE` can select diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index e35d172..7a0eff4 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -35,6 +35,8 @@ Remove or move any previous `dist/` directory, then run: uv build uv run --isolated --no-project --with dist/*.whl tests/smoke_test.py uv run --isolated --no-project --with dist/*.tar.gz tests/smoke_test.py +uv run --isolated --no-project --with dist/*.whl tests/web_smoke.py +uv run --isolated --no-project --with dist/*.tar.gz tests/web_smoke.py ``` Inspect both archives. They should contain the license; the source archive diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 93476ab..848ceb7 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -327,9 +327,12 @@ rationale and supersession history instead of compressing away API constraints. This refines D11's instruction placement and startup reading, not its requirement for durable project continuity. -## D15 — Optional asyncio browser frontend +## D15 — Asyncio browser frontend -Accepted, October 1, during web frontend development on `feat/web-frontend`. +Frontend design recorded October 1 during development on `feat/web-frontend`. +The optional-dependency choice was made without owner approval and is superseded +by the owner's October 5 direction: aiohttp is a required dependency so a standard +installation supports browser mode without extra installation flags. One CtuiApp definition supplies three mutually exclusive startup modes: no arguments opens the existing terminal UI, -c/--command and -f/--file run CLI @@ -341,7 +344,7 @@ Submitted commands continue after their tab disconnects; unanswered confirmation or input dialogs cancel on disconnect, preserving explicit approval. Use packaged HTML/CSS and small JavaScript modules/functions with no frontend -framework or build step. aiohttp is an optional `web` extra for HTTP/1.1 and +framework or build step. aiohttp is a required dependency for HTTP/1.1 and WebSockets on the existing asyncio loop. Loopback HTTP is the default; explicit remote binds require supplied TLS certificate/key files and authenticated access. HTTPS uses secure WebSockets. Automatic certificate issuance is deferred. diff --git a/docs/STATUS.md b/docs/STATUS.md index 2c6f172..adb1aa3 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,6 +1,6 @@ # Project status -Last reconciled: 2026-10-01, web frontend work on `feat/web-frontend`, based on +Last reconciled: 2026-10-05, web frontend work on `feat/web-frontend`, based on `main` at `fdda194`. Main was fetched from GitHub at workspace setup; no live remote CI or publication checks are claimed. @@ -10,7 +10,7 @@ remote CI or publication checks are claimed. release checks in [RELEASE_CHECKLIST.md](../RELEASE_CHECKLIST.md). - Main includes reusable path completion (`fdda194`), dialog redraw, command error containment, and the post-RC1 help/type improvements. -- Added optional `ctui[web]` support with aiohttp. `--web` runs a browser-only +- Browser support uses required aiohttp; standard installs include it. `--web` runs a browser-only session; default full-screen UI and command/file CLI behavior remain intact. - Existing supported compose() widgets render in plain HTML/CSS/JavaScript. One app process/port owns a shared session; tabs keep local drafts/scrolling. @@ -18,10 +18,17 @@ remote CI or publication checks are claimed. a popup and preserves output. See D15 and the README for the accepted design. - Remote binds require supplied TLS cert/key files. Session token authentication, origin/host checks, bounded update queues, and independent per-process cookies - are implemented. Web dependencies remain optional for terminal-only users. + are implemented. The web extra was removed at the owner's direction (D15). ## Validation evidence +- October 5 dependency change: standard locked sync installed aiohttp without + extras; all 152 tests passed on Python 3.11.16, Linux x86-64. Black/isort, + lockfile and whitespace checks passed. Built wheel and source distribution + passed isolated base and web smoke checks without extra dependency flags. + Remote CI and other Python/platform checks remain pending for this change. +- The following broader frontend evidence is from October 1, before this change. + - All 152 unittest tests passed on Python 3.11.16 and 3.14.7 on Linux x86-64. The web tests exercise real HTTP/WebSocket connections, independent sessions, multi-tab output, reconnect, completion, dialogs, custom controls, TLS, malformed @@ -37,10 +44,10 @@ remote CI or publication checks are claimed. [browser_tutorials.py](../tests/browser_tutorials.py) supports individual repeats. Tutorial 15's progress command was also checked in CLI mode after guarding UI invalidation when no frontend exists. -- Wheel and source distribution built and passed isolated base and optional-web +- Wheel and source distribution built and passed isolated base and web smoke tests, including bundled static assets and WebSocket dispatch. - Black/isort, lockfile, JavaScript syntax and whitespace checks passed. CI now - installs the web extra for platform tests and checks installed web artifacts. + now uses standard installs for platform tests and installed web artifacts. Remote CI has not been run from this workspace. ## Next steps diff --git a/examples/15_web_frontend.py b/examples/15_web_frontend.py index 610366a..4ce17c1 100644 --- a/examples/15_web_frontend.py +++ b/examples/15_web_frontend.py @@ -1,6 +1,6 @@ """Run one application as terminal UI, CLI commands, or a browser session. -Run: uv run --extra web examples/15_web_frontend.py --web --web-port 0 +Run: uv run examples/15_web_frontend.py --web --web-port 0 Open the credential URL printed in the terminal. Open another tab at the same address to share output; draft input and scrolling remain local to each tab. Try: echo hello, help echo, and progress 50. Click Increment to update the footer. diff --git a/examples/README.md b/examples/README.md index 7404402..6104923 100644 --- a/examples/README.md +++ b/examples/README.md @@ -43,10 +43,10 @@ uv run examples/08_automatic_cli.py -c "add 12 30" uv run examples/08_automatic_cli.py --file examples/commands.txt ``` -Install the optional web extra to try browser mode with any UI example: +Try browser mode with any UI example: ```bash -uv run --extra web examples/15_web_frontend.py --web --web-port 0 +uv run examples/15_web_frontend.py --web --web-port 0 ``` Open the printed URL. `help` opens a popup without replacing output; multiple diff --git a/pyproject.toml b/pyproject.toml index 927a35c..ee55fcb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,15 +27,13 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ + "aiohttp>=3.14.3,<4", "aiosqlite>=0.22,<0.23", "platformdirs>=4,<5", "prompt-toolkit>=3.0.36,<4", "Pygments>=2.14.0,<3", ] -[project.optional-dependencies] -web = ["aiohttp>=3.14.3,<4"] - [project.urls] Homepage = "https://www.controlthings.io" Repository = "https://github.com/ControlThings-io/ctui" diff --git a/src/ctui/application.py b/src/ctui/application.py index 6eff3c2..54c6c23 100644 --- a/src/ctui/application.py +++ b/src/ctui/application.py @@ -294,7 +294,7 @@ def format_cli_help(self, program=None, target=""): self.cli_help_intro, "", "Interface options:", - " --web Start browser-only mode (requires ctui[web]).", + " --web Start browser-only mode.", " --web-host HOST Bind address (default: 127.0.0.1).", " --web-port PORT Listen port (default: 8080; 0 selects a free port).", " --web-cert FILE TLS certificate; required for remote access.", diff --git a/src/ctui/web.py b/src/ctui/web.py index a8fadfd..ffebc5a 100644 --- a/src/ctui/web.py +++ b/src/ctui/web.py @@ -1,4 +1,4 @@ -"""Optional asyncio HTTP/WebSocket frontend for one live CtuiApp session. +"""Asyncio HTTP/WebSocket frontend for one live CtuiApp session. Every connection shares widgets, commands and services; draft input, completion, scrolling and dialogs are per-view. Concurrent commands preserve terminal mode's @@ -455,13 +455,9 @@ async def redraw(self): client.update(snapshot) async def start(self): - """Build the shared layout and start the authenticated optional server.""" - try: - from aiohttp import WSMsgType, web - except ImportError as error: - raise CommandError( - 'Web mode requires the optional dependency: pip install "ctui[web]"' - ) from error + """Build the shared layout and start the authenticated server.""" + from aiohttp import WSMsgType, web + if bool(self.cert) != bool(self.key): raise CommandError("Web TLS requires both certificate and key") if not is_loopback(self.host) and not self.cert: diff --git a/tests/browser_smoke.py b/tests/browser_smoke.py index 9ce745c..0abfdeb 100644 --- a/tests/browser_smoke.py +++ b/tests/browser_smoke.py @@ -1,6 +1,6 @@ """Optional real-browser check (requires Playwright and Chromium or Firefox). -Run: uv run --extra web --with playwright tests/browser_smoke.py +Run: uv run --with playwright tests/browser_smoke.py Set CTUI_CHROMIUM_EXECUTABLE to use a system Chromium, or install Playwright's browser with: uv run --with playwright python -m playwright install chromium Set CTUI_BROWSER=firefox and install Firefox to run the same checks there. diff --git a/tests/browser_tutorials.py b/tests/browser_tutorials.py index 7e21498..f2b1109 100644 --- a/tests/browser_tutorials.py +++ b/tests/browser_tutorials.py @@ -1,6 +1,6 @@ """Exercise every runnable tutorial through its real --web entry point. -Run: CTUI_BROWSER=firefox uv run --extra web --with playwright tests/browser_tutorials.py +Run: CTUI_BROWSER=firefox uv run --with playwright tests/browser_tutorials.py For Chromium, set CTUI_BROWSER=chromium and optionally CTUI_CHROMIUM_EXECUTABLE. Install a bundled browser with Playwright's install command first. Application storage is redirected into a temporary test directory. Tutorial processes exit diff --git a/tests/test_web.py b/tests/test_web.py index 8de866e..4bc5f2a 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -1,8 +1,8 @@ """Real HTTP/WebSocket regressions for shared sessions, security and lifecycle. -The network tests require the optional web extra. Always bind ephemeral loopback +Always bind ephemeral loopback ports, capture the credential URL, and close sockets/tasks. No external service -or terminal is required. CI installs the extra so these tests cannot silently skip. +or terminal is required. aiohttp is a required package dependency. """ import asyncio @@ -13,10 +13,7 @@ from pathlib import Path from unittest.mock import patch -try: - from aiohttp import ClientSession, CookieJar, WSServerHandshakeError -except ImportError: - ClientSession = None +from aiohttp import ClientSession, CookieJar, WSServerHandshakeError from ctui import CommandError, CommandResult, CtuiApp, command from ctui.dialogs import MessageDialog, TextInputDialog, show_dialog @@ -132,7 +129,6 @@ def test_web_route_and_default_cli_are_exclusive(self): coroutine.close() -@unittest.skipUnless(ClientSession, "Install ctui[web] for network integration tests") class WebTests(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): self.app = BrowserApp() diff --git a/tests/web_smoke.py b/tests/web_smoke.py index 48729d2..1b192e5 100644 --- a/tests/web_smoke.py +++ b/tests/web_smoke.py @@ -1,6 +1,6 @@ -"""Verify optional web mode from an installed wheel/sdist with its web extra. +"""Verify web mode from a standard installed wheel/sdist. -Use uv --isolated --no-project --with 'dist/ARTIFACT[web]' tests/web_smoke.py. +Use uv --isolated --no-project --with 'dist/ARTIFACT' tests/web_smoke.py. This binds one ephemeral loopback port and tests assets plus command dispatch. """ diff --git a/uv.lock b/uv.lock index 8dd8290..acd758e 100644 --- a/uv.lock +++ b/uv.lock @@ -236,17 +236,13 @@ name = "ctui" version = "1.0.0rc1" source = { editable = "." } dependencies = [ + { name = "aiohttp" }, { name = "aiosqlite" }, { name = "platformdirs" }, { name = "prompt-toolkit" }, { name = "pygments" }, ] -[package.optional-dependencies] -web = [ - { name = "aiohttp" }, -] - [package.dev-dependencies] dev = [ { name = "black" }, @@ -256,13 +252,12 @@ dev = [ [package.metadata] requires-dist = [ - { name = "aiohttp", marker = "extra == 'web'", specifier = ">=3.14.3,<4" }, + { name = "aiohttp", specifier = ">=3.14.3,<4" }, { name = "aiosqlite", specifier = ">=0.22,<0.23" }, { name = "platformdirs", specifier = ">=4,<5" }, { name = "prompt-toolkit", specifier = ">=3.0.36,<4" }, { name = "pygments", specifier = ">=2.14.0,<3" }, ] -provides-extras = ["web"] [package.metadata.requires-dev] dev = [ From 68c6038dab1bc81a26061f09b7914166b89ba781 Mon Sep 17 00:00:00 2001 From: Justin Searle Date: Mon, 5 Oct 2026 11:51:14 -0600 Subject: [PATCH 6/7] fix: confirm interactive exit and close web tab when allowed --- README.md | 5 +++++ docs/DECISIONS.md | 5 +++++ docs/STATUS.md | 11 ++++++++++- src/ctui/commands.py | 4 ++-- src/ctui/web.py | 8 +++++++- src/ctui/web_assets/app.js | 6 +++++- tests/browser_tutorials.py | 2 ++ tests/test_application.py | 10 ++++++++++ tests/test_web.py | 22 ++++++++++++++++++++++ 9 files changed, 68 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 50ca660..49553fd 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,11 @@ append results use current output when each command finishes. Each view has a bounded, coalescing update queue so a slow browser does not hold up other views. Reconnecting views receive the current layout and output. +Typing `exit` asks for confirmation in both terminal and browser UIs. An approved +exit stops the shared application session. In browser mode, the submitting tab +attempts to close; if browser policy blocks it, a message tells you to close it +manually. Other tabs disconnect. Noninteractive CLI use requires `exit confirm`. + ### Custom layouts and lifecycle Reuse `compose()` and `ctui.widgets`: Vertical, Horizontal, Frame, Label, TextArea, diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 848ceb7..4a08b49 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -340,6 +340,11 @@ operations, and --web starts only the browser frontend. Print a connection URL and keep the process running. A process owns one live session; separate processes/ports own separate sessions. Multiple tabs at the same address share app state/output while drafts, focus, scrolling and dialogs stay per-view. +Owner direction, October 5: typed `exit` requires confirmation in both interactive +frontends. On approval the shared session stops; the submitting browser tab +attempts to close, with a manual-close message if browser policy prevents it. +Existing explicit confirmation tokens remain available to noninteractive callers. + Submitted commands continue after their tab disconnects; unanswered confirmation or input dialogs cancel on disconnect, preserving explicit approval. diff --git a/docs/STATUS.md b/docs/STATUS.md index adb1aa3..dd4f071 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -20,8 +20,17 @@ remote CI or publication checks are claimed. origin/host checks, bounded update queues, and independent per-process cookies are implemented. The web extra was removed at the owner's direction (D15). +- Typed `exit` now uses the shared confirmation flow in both interactive UIs. + Approved web exit stops the shared session and asks only the submitting tab + to close, with a manual-close message when browser policy blocks closure. + ## Validation evidence +- October 5 exit change: all 154 unittest tests passed on Python 3.11.16, + Linux x86-64, including rejection/approval and per-tab shutdown notification. + Black/isort and whitespace checks passed. Manual terminal and real-browser + tab-close acceptance remain pending; browser tutorial checks now approve exit. + - October 5 dependency change: standard locked sync installed aiohttp without extras; all 152 tests passed on Python 3.11.16, Linux x86-64. Black/isort, lockfile and whitespace checks passed. Built wheel and source distribution @@ -47,7 +56,7 @@ remote CI or publication checks are claimed. - Wheel and source distribution built and passed isolated base and web smoke tests, including bundled static assets and WebSocket dispatch. - Black/isort, lockfile, JavaScript syntax and whitespace checks passed. CI now - now uses standard installs for platform tests and installed web artifacts. + uses standard installs for platform tests and installed web artifacts. Remote CI has not been run from this workspace. ## Next steps diff --git a/src/ctui/commands.py b/src/ctui/commands.py index bba94ee..51f9abf 100644 --- a/src/ctui/commands.py +++ b/src/ctui/commands.py @@ -969,7 +969,7 @@ async def history_clear(): await app.history.clear() return CommandResult.success("Cleared command history.") - @app.commands.register + @app.commands.register(confirmation="Exit the application?") async def exit(): - """Exit the application.""" + """Exit after approval; noninteractive callers must supply confirmation.""" return CommandResult(exit_requested=True) diff --git a/src/ctui/web.py b/src/ctui/web.py index ffebc5a..f406ca7 100644 --- a/src/ctui/web.py +++ b/src/ctui/web.py @@ -201,7 +201,11 @@ def done(completed): return task async def command(self, text, identifier): - """Dispatch with per-view confirmation and broadcast accepted results.""" + """Confirm per view; notify the exiting view before stopping the session. + + The browser attempts to close only the tab submitting an accepted exit. + Other views disconnect when the shared session shuts down. + """ try: self.session.ctui.output_text = self.session.ctui.layout.output_field.text @@ -218,6 +222,8 @@ async def confirm(message): "Help", self.session.ctui.format_ui_help(result.target), ["OK"] ) else: + if result.exit_requested: + await self.send({"type": "session-ended", "close_tab": True}) self.session.apply_result(result) await self.send({"type": "finished", "id": identifier}) except Exception as error: diff --git a/src/ctui/web_assets/app.js b/src/ctui/web_assets/app.js index fb79f2d..0509624 100644 --- a/src/ctui/web_assets/app.js +++ b/src/ctui/web_assets/app.js @@ -193,11 +193,15 @@ function connect() { else if(message.type==='error'||message.type==='rejected') { if(message.id===latestCommand&&commandInput&&!commandInput.value){commandInput.value=message.text;const pos=message.position??message.text.length;commandInput.setSelectionRange(pos,pos);rememberDraft();} if(message.message)openDialog({title:'Error',text:message.message,buttons:['OK']}); + } else if(message.type==='session-ended') { + stopped=true;clearTimeout(retry); + notice('Session stopped. You can close this tab.'); + if(message.close_tab)window.close(); } else if(message.type==='notice')notice(message.message); }; socket.onclose=event=>{ for(const dialog of document.querySelectorAll('dialog'))dialog.remove();dialogs.length=0;hideCompletions(); - if(event.code===1001){stopped=true;notice('Session stopped.');} + if(event.code===1001||stopped){stopped=true;notice('Session stopped. You can close this tab.');} else {notice('Disconnected. Reconnecting…');if(!stopped)retry=setTimeout(connect,1500);} }; } diff --git a/tests/browser_tutorials.py b/tests/browser_tutorials.py index f2b1109..1cf06f6 100644 --- a/tests/browser_tutorials.py +++ b/tests/browser_tutorials.py @@ -172,6 +172,8 @@ async def submit(text, expected=None): assert await output.text_content() == previous await dialog.get_by_role("button", name="OK", exact=True).click() await submit("exit") + await dialog.wait_for(state="visible") + await dialog.get_by_role("button", name="Yes", exact=True).click() await asyncio.wait_for(process.wait(), 5) assert process.returncode == 0, (await process.stderr.read()).decode() assert not errors, errors diff --git a/tests/test_application.py b/tests/test_application.py index 620a554..aea3c32 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -48,6 +48,16 @@ async def on_stop(self): class ApplicationTests(unittest.IsolatedAsyncioTestCase): """Verify dispatcher policy independently of a terminal run loop.""" + async def test_builtin_exit_requires_confirmation(self): + app = CtuiApp() + with self.assertRaises(ConfirmationRequired): + await app.dispatch("exit") + rejected = await app.dispatch("exit", confirm_callback=lambda _: False) + self.assertFalse(rejected.accepted) + self.assertFalse(rejected.exit_requested) + accepted = await app.dispatch("exit", confirm_callback=lambda _: True) + self.assertTrue(accepted.exit_requested) + async def test_class_commands_dispatch_history_and_events(self): history, seen = MemoryHistory(), [] app = Demo(history=history) diff --git a/tests/test_web.py b/tests/test_web.py index 4bc5f2a..3c19cc0 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -243,6 +243,28 @@ async def test_completion_errors_and_help_preserve_output(self): await self.receive(socket, "finished") self.assertEqual(self.app.layout.output_field.text, "preserved") + async def test_exit_confirms_and_notifies_only_submitting_tab(self): + await self.login() + socket, _ = await self.connect() + other, _ = await self.connect() + await socket.send_json({"type": "command", "text": "exit", "id": 1}) + dialog = await self.receive(socket, "dialog") + self.assertEqual(dialog["text"], "Exit the application?") + await socket.send_json({"type": "answer", "id": dialog["id"], "button": 1}) + await self.receive(socket, "rejected") + self.assertFalse(self.session.stopped.is_set()) + await socket.send_json({"type": "command", "text": "exit", "id": 2}) + dialog = await self.receive(socket, "dialog") + await socket.send_json({"type": "answer", "id": dialog["id"], "button": 0}) + ended = await self.receive(socket, "session-ended") + self.assertTrue(ended["close_tab"]) + await self.receive(socket, "finished") + await asyncio.wait_for(self.session.stopped.wait(), 1) + await self.session.close() + async for message in other: + if message.type.name == "TEXT": + self.assertNotEqual(message.json()["type"], "session-ended") + async def test_confirmation_and_existing_dialogs(self): await self.login() socket, _ = await self.connect() From 38b3ac8fda6bae97b4417f48e9749c5a5a3c8a9f Mon Sep 17 00:00:00 2001 From: Justin Searle Date: Mon, 5 Oct 2026 11:59:51 -0600 Subject: [PATCH 7/7] fix: separate web completion labels from help --- docs/STATUS.md | 8 ++++++++ src/ctui/web_assets/app.js | 7 ++++++- src/ctui/web_assets/style.css | 12 ++++++++++-- tests/browser_smoke.py | 13 +++++++++++++ 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/docs/STATUS.md b/docs/STATUS.md index dd4f071..8f1f832 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -24,8 +24,16 @@ remote CI or publication checks are claimed. Approved web exit stops the shared session and asks only the submitting tab to close, with a manual-close message when browser policy blocks closure. +- Web completion rows separate bright suggestion labels from muted help with + aligned columns and a subtle divider; narrow screens stack indented help. + ## Validation evidence +- October 5 completion layout: Chromium browser smoke check passed, including + separate label/help columns, stacked mobile help, Tab insertion, shared output, + local drafts, help popups, focus and controls. Black/isort and whitespace checks + passed. Firefox/Safari and remote CI remain unchecked for this layout change. + - October 5 exit change: all 154 unittest tests passed on Python 3.11.16, Linux x86-64, including rejection/approval and per-tab shutdown notification. Black/isort and whitespace checks passed. Manual terminal and real-browser diff --git a/src/ctui/web_assets/app.js b/src/ctui/web_assets/app.js index 0509624..3f34709 100644 --- a/src/ctui/web_assets/app.js +++ b/src/ctui/web_assets/app.js @@ -46,11 +46,16 @@ function applyCompletion(item) { rememberDraft(); hideCompletions(); commandInput.focus(); requestCompletion(); } function hideCompletions() { suggestions.hidden = true; completionItems = []; } +// Shared columns separate insertion text from help; hints remain non-inserting. function showCompletions(items) { completionItems = items; completionIndex = 0; suggestions.replaceChildren(); for (const [index,item] of items.entries()) { const element = document.createElement(item.text ? 'button' : 'div'); - element.textContent = `${item.display || item.text} ${item.help || ''}`; + const label = document.createElement('span'); + label.className = 'completion-label';label.textContent = item.display || item.text || ''; + const help = document.createElement('span'); + help.className = 'completion-help';help.textContent = item.help || ''; + element.append(label, help); element.className = item.text ? (index === 0 ? 'selected' : '') : 'hint'; element.addEventListener('mousedown', event => event.preventDefault()); if (item.text) element.addEventListener('click', () => applyCompletion(item)); diff --git a/src/ctui/web_assets/style.css b/src/ctui/web_assets/style.css index 1558036..39b3367 100644 --- a/src/ctui/web_assets/style.css +++ b/src/ctui/web_assets/style.css @@ -31,8 +31,16 @@ input:focus, textarea:focus { outline: 1px solid #4dd; } .editor { width: 100%; height: 100%; resize: none; } .progress { display: flex; align-items: center; gap: .5rem; } progress { width: 100%; accent-color: #4dd; } -#suggestions { position: fixed; z-index: 5; top: 2.2rem; left: .5rem; max-height: 40vh; max-width: calc(100vw - 1rem); overflow: auto; border: 1px solid #888; background: #181818; box-shadow: 0 .4rem 1rem #0008; } -#suggestions button { display: block; width: 100%; text-align: left; border: 0; } +#suggestions { display: grid; grid-template-columns: fit-content(40%) minmax(0, 1fr); width: max-content; position: fixed; z-index: 5; top: 2.2rem; left: .5rem; max-height: 40vh; max-width: calc(100vw - 1rem); overflow: auto; border: 1px solid #888; background: #181818; box-shadow: 0 .4rem 1rem #0008; } +#suggestions > button, #suggestions > .hint { display: grid; grid-template-columns: subgrid; grid-column: 1 / -1; padding: 0; text-align: left; border: 0; } +#suggestions .completion-label { padding: .3rem .6rem; color: #eee; overflow-wrap: anywhere; } +#suggestions .completion-help { padding: .3rem .6rem; border-left: 1px solid #666; color: #bbb; white-space: normal; overflow-wrap: anywhere; } +#suggestions .completion-help:empty { border-left: 0; } +@media (max-width: 600px) { + #suggestions { grid-template-columns: minmax(0, 1fr); } + #suggestions .completion-help { border-left: 0; padding: 0 .6rem .3rem 1.2rem; } + #suggestions .completion-help:empty { display: none; } +} #suggestions .selected { background: #37545a; } .hint { padding: .3rem .6rem; color: #aaa; } dialog { color: #eee; background: #181818; border: 1px solid #4dd; width: min(90vw, 70rem); max-height: 85dvh; } diff --git a/tests/browser_smoke.py b/tests/browser_smoke.py index 0abfdeb..f61a164 100644 --- a/tests/browser_smoke.py +++ b/tests/browser_smoke.py @@ -81,12 +81,25 @@ async def main(): ) await first_input.fill("ec") await first.locator("#suggestions").wait_for(state="visible") + row = first.locator("#suggestions button").first + assert await row.locator(".completion-label").text_content() == "echo" + assert await row.locator(".completion-help").text_content() + assert await row.evaluate( + "el => el.children[1].getBoundingClientRect().left > " + "el.children[0].getBoundingClientRect().left" + ) await first_input.press("Tab") assert await first_input.input_value() == "echo" await first.set_viewport_size({"width": 390, "height": 844}) assert await first.locator(".output_field").evaluate( "el => el.clientHeight > 400" ) + await first_input.fill("ec") + await first.locator("#suggestions").wait_for(state="visible") + assert await row.evaluate( + "el => el.children[1].getBoundingClientRect().top >= " + "el.children[0].getBoundingClientRect().bottom" + ) assert not errors, errors print( f"{browser_name}: multi-tab output, local drafts, help popup, focus, buttons, completion and mobile layout passed"