From 52e7528b86102ae99464c60ccf83a907a5bcef67 Mon Sep 17 00:00:00 2001 From: h Date: Tue, 19 May 2026 11:18:37 +0200 Subject: [PATCH] feat: vibed out some slop over here --- .gitignore | 17 + .pre-commit-config.yaml | 18 + README.md | 109 +++ examples/basic_usage.py | 55 ++ examples/local_tool.py | 103 +++ examples/web_search.py | 53 ++ pyproject.toml | 46 ++ pyrightconfig.json | 10 + ruff.toml | 35 + src/raycast_api/__init__.py | 96 +++ src/raycast_api/ai/__init__.py | 40 ++ src/raycast_api/ai/chat.py | 534 ++++++++++++++ src/raycast_api/ai/files.py | 172 +++++ src/raycast_api/ai/me.py | 36 + src/raycast_api/ai/models.py | 173 +++++ src/raycast_api/ai/types.py | 397 +++++++++++ src/raycast_api/cli.py | 451 ++++++++++++ src/raycast_api/client/__init__.py | 21 + src/raycast_api/client/http.py | 436 ++++++++++++ src/raycast_api/client/retry.py | 85 +++ src/raycast_api/client/streaming.py | 171 +++++ src/raycast_api/config.py | 294 ++++++++ src/raycast_api/discovery/__init__.py | 5 + src/raycast_api/discovery/ast_parse.py | 380 ++++++++++ src/raycast_api/discovery/binary.py | 91 +++ src/raycast_api/discovery/bundle.py | 82 +++ src/raycast_api/discovery/cache.py | 84 +++ src/raycast_api/discovery/extractors.py | 302 ++++++++ src/raycast_api/errors.py | 82 +++ src/raycast_api/signing/__init__.py | 79 +++ src/raycast_api/signing/canonical.py | 29 + src/raycast_api/signing/hmac.py | 89 +++ src/raycast_api/signing/transforms.py | 45 ++ src/raycast_api/signing_spec.py | 119 ++++ tests/__init__.py | 0 tests/conftest.py | 112 +++ tests/fixtures/mock_binary.bin | Bin 0 -> 1535 bytes tests/fixtures/mock_bundle.mjs | 77 ++ tests/test_ast_parse.py | 112 +++ tests/test_binary.py | 41 ++ tests/test_bundle.py | 42 ++ tests/test_canonical.py | 35 + tests/test_chat.py | 378 ++++++++++ tests/test_chat_model_resolution.py | 215 ++++++ tests/test_cli.py | 415 +++++++++++ tests/test_config.py | 256 +++++++ tests/test_extractors.py | 74 ++ tests/test_files.py | 182 +++++ tests/test_hmac.py | 94 +++ tests/test_http.py | 555 +++++++++++++++ tests/test_live_app.py | 79 +++ tests/test_live_chat.py | 92 +++ tests/test_me.py | 67 ++ tests/test_models.py | 153 ++++ tests/test_retry.py | 135 ++++ tests/test_signer.py | 122 ++++ tests/test_streaming.py | 247 +++++++ tests/test_transforms.py | 58 ++ ty.toml | 5 + uv.lock | 891 ++++++++++++++++++++++++ 60 files changed, 9176 insertions(+) create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 README.md create mode 100644 examples/basic_usage.py create mode 100644 examples/local_tool.py create mode 100644 examples/web_search.py create mode 100644 pyproject.toml create mode 100644 pyrightconfig.json create mode 100644 ruff.toml create mode 100644 src/raycast_api/__init__.py create mode 100644 src/raycast_api/ai/__init__.py create mode 100644 src/raycast_api/ai/chat.py create mode 100644 src/raycast_api/ai/files.py create mode 100644 src/raycast_api/ai/me.py create mode 100644 src/raycast_api/ai/models.py create mode 100644 src/raycast_api/ai/types.py create mode 100644 src/raycast_api/cli.py create mode 100644 src/raycast_api/client/__init__.py create mode 100644 src/raycast_api/client/http.py create mode 100644 src/raycast_api/client/retry.py create mode 100644 src/raycast_api/client/streaming.py create mode 100644 src/raycast_api/config.py create mode 100644 src/raycast_api/discovery/__init__.py create mode 100644 src/raycast_api/discovery/ast_parse.py create mode 100644 src/raycast_api/discovery/binary.py create mode 100644 src/raycast_api/discovery/bundle.py create mode 100644 src/raycast_api/discovery/cache.py create mode 100644 src/raycast_api/discovery/extractors.py create mode 100644 src/raycast_api/errors.py create mode 100644 src/raycast_api/signing/__init__.py create mode 100644 src/raycast_api/signing/canonical.py create mode 100644 src/raycast_api/signing/hmac.py create mode 100644 src/raycast_api/signing/transforms.py create mode 100644 src/raycast_api/signing_spec.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/fixtures/mock_binary.bin create mode 100644 tests/fixtures/mock_bundle.mjs create mode 100644 tests/test_ast_parse.py create mode 100644 tests/test_binary.py create mode 100644 tests/test_bundle.py create mode 100644 tests/test_canonical.py create mode 100644 tests/test_chat.py create mode 100644 tests/test_chat_model_resolution.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_config.py create mode 100644 tests/test_extractors.py create mode 100644 tests/test_files.py create mode 100644 tests/test_hmac.py create mode 100644 tests/test_http.py create mode 100644 tests/test_live_app.py create mode 100644 tests/test_live_chat.py create mode 100644 tests/test_me.py create mode 100644 tests/test_models.py create mode 100644 tests/test_retry.py create mode 100644 tests/test_signer.py create mode 100644 tests/test_streaming.py create mode 100644 tests/test_transforms.py create mode 100644 ty.toml create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3784431 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv + +# Development and testing +.idea +t + +# Local credentials +config.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..6e51acb --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,18 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.13 + hooks: + - id: ruff-check + types_or: [python, pyi] + args: [--fix] + - id: ruff-format + types_or: [python, pyi] + + - repo: local + hooks: + - id: ty + name: ty check + entry: uvx ty check + language: python + types_or: [python, pyi] + pass_filenames: false diff --git a/README.md b/README.md new file mode 100644 index 0000000..4b7ea82 --- /dev/null +++ b/README.md @@ -0,0 +1,109 @@ +# raycast-api +[![AI Slop Inside](https://sladge.net/badge.svg)](https://sladge.net) + +Python client for `backend.raycast.com`. Bring-your-own-credentials: the +signing secret and algorithm are extracted from a local Raycast install at +setup time - nothing sensitive ships with the package. + +Not affiliated with Raycast. You need your own account, subscription, and a +copy of the desktop app on macOS to run discovery once. + +## Install + +As a CLI tool (pulls in the discovery extra): + +```bash +uv tool install "raycast-api[discovery] @ git+https://git.kotikot.com/beaver/raycast-api" +``` + +As a library inside another project (runtime only, no discovery deps): + +```bash +uv add "raycast-api @ git+https://git.kotikot.com/beaver/raycast-api" +``` + +The runtime needs only `aiohttp`. The `discovery` extra adds `esprima` (a JS +AST parser) and is required only for `init` / `refresh`. + +## Credentials + +```bash +raycast-api init # extract signing spec + secret from the local app → ./config.json +export RAYCAST_BEARER='rca_...' # your OAuth access token, sniffed from the desktop client +raycast-api ask --stream 'hello, raycast' +``` + +`init` writes `config.json` (chmod 600) containing the signing spec, the +launcher-derived secret, and bundle/launcher hashes used as a cache key. +Re-run `raycast-api refresh` after a Raycast update - the cache invalidates +automatically when either hash changes. + +## Use + +```python +import asyncio +from raycast_api import Client, Config, Message + +async def main() -> None: + async with Client( + config=Config.load("config.json"), + bearer_token="rca_...", + device_id="<64 hex>", # any stable per-install id + ) as client: + result = await client.chat.complete( + model="Claude Sonnet 4.6", + messages=[Message.user("hello, raycast")], + ) + print(result.text) + +asyncio.run(main()) +``` + +Streaming: + +```python +async for chunk in client.chat.stream(model="...", messages=[...]): + if chunk.text: + print(chunk.text, end="", flush=True) +``` + +Endpoints: `client.chat`, `client.models`, `client.me`, `client.files`. +Interrupted streams resume via `client.chat.resume(buffer_id=..., last_event_id=...)`. + +## CLI + +``` +raycast-api init [--app-path PATH] [--output config.json] [--force] [--no-cache] +raycast-api refresh [--app-path PATH] [--config config.json] +raycast-api inspect [--config config.json] [--verify | --app-path PATH] [--quiet] +raycast-api ask PROMPT [--bearer ...] [--device-id ...] [--model ...] [--stream] +``` + +- `init` - run discovery, write `config.json`. Cached by `(bundle_hash, launcher_hash)`. +- `refresh` - bypass the cache and re-derive. Use after a launcher rebuild + that didn't touch the JS bundle. +- `inspect` - print the saved config with the secret redacted. `--verify` + rechecks hashes against a live Raycast install; `--quiet` collapses to an + exit code (`0` current, `1` stale, `2` unverifiable) for scripting. +- `ask` - one-shot smoke test against `chat.complete`. + +## How the signing scheme is recovered + +Raycast signs every request with a rotated-alphabet HMAC over a canonical +string. `init` recovers this end-to-end from your local install, without +running any of Raycast's code: + +1. **Launcher.** Read the Mach-O binary, find the 64-hex `signature_secret` + by anchored byte-pattern. +2. **Bundle.** Locate `index.mjs` inside the app, hash it for the cache key. +3. **AST.** Parse the bundle with `esprima`. Walk for the signing function + by structural shape - it takes `(method, path, body, ts, key)`, calls + `crypto.createHmac`, and joins its inputs with a single character. From + its caller recover the rot transform: alphabet ranges and shift counts + are literals in the source. +4. **Spec.** Emit a portable `signing_spec` (ranges, join char, HMAC and + body-hash algorithms, key/output encodings). The runtime signer reads + only that - no version-specific code anywhere in the package. + +As long as Raycast keeps a rot-transform over an HMAC, a future build only +needs `raycast-api refresh`, not a release. diff --git a/examples/basic_usage.py b/examples/basic_usage.py new file mode 100644 index 0000000..4d304a4 --- /dev/null +++ b/examples/basic_usage.py @@ -0,0 +1,55 @@ +"""Minimal end-to-end example: load config, send a chat completion, stream one. + +Prerequisites: + 1. `raycast-api init` has been run (so `config.json` exists next to this file + or at the path you point `--config` to). See the README. + 2. `RAYCAST_BEARER` is exported (your Raycast OAuth access token, `rca_...`). + 3. `RAYCAST_DEVICE_ID` is exported (any stable 64-hex string; the CLI mints + one on first `ask` and persists it to `~/.config/raycast-api/device_id`, + so a one-liner is: `export RAYCAST_DEVICE_ID=$(cat ~/.config/raycast-api/device_id)`). + +Run: + python examples/basic_usage.py +""" + +from __future__ import annotations + +import asyncio +import os +import sys +from pathlib import Path + +from raycast_api import Client, Config, Message + +CONFIG_PATH = Path(os.environ.get("RAYCAST_CONFIG", "config.json")) +MODEL = os.environ.get("RAYCAST_MODEL", "Claude Sonnet 4.6") + + +async def main() -> None: + bearer = os.environ.get("RAYCAST_BEARER") + device_id = os.environ.get("RAYCAST_DEVICE_ID") + if not bearer or not device_id: + sys.exit("set RAYCAST_BEARER and RAYCAST_DEVICE_ID before running") + if not CONFIG_PATH.is_file(): + sys.exit(f"no config at {CONFIG_PATH} — run `raycast-api init` first") + + config = Config.load(CONFIG_PATH) + async with Client( + config=config, bearer_token=bearer, device_id=device_id + ) as client: + result = await client.chat.complete( + model=MODEL, messages=[Message.user("Reply with the single word: OK")] + ) + print(f"[complete] {result.text!r}") + + print("[stream] ", end="", flush=True) + async for chunk in client.chat.stream( + model=MODEL, messages=[Message.user("Write a one-line haiku about HMAC.")] + ): + if chunk.text: + print(chunk.text, end="", flush=True) + print() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/local_tool.py b/examples/local_tool.py new file mode 100644 index 0000000..264e91a --- /dev/null +++ b/examples/local_tool.py @@ -0,0 +1,103 @@ +"""Local tool-calling example with token-by-token streaming. + +Flow: + 1. Declare a `get_weather(city)` tool with a JSON Schema. + 2. First turn: stream the chat. Print every text delta as it arrives; + feed each chunk into `ChatResult.add(chunk)` so tool_calls merge + correctly across the chunked wire format. + 3. If the assistant called a tool, execute it locally and append a + `tool` message to history. + 4. Second turn: stream again — this time the text reply is what we want + to see token-by-token. + +Prerequisites: same as basic_usage.py (config.json, RAYCAST_BEARER, +RAYCAST_DEVICE_ID). + +Run: + python examples/local_tool.py +""" + +from __future__ import annotations + +import asyncio +import json +import os +import sys +from pathlib import Path + +from raycast_api import ChatResult, Client, Config, Message, Tool + +CONFIG_PATH = Path(os.environ.get("RAYCAST_CONFIG", "config.json")) +MODEL = os.environ.get("RAYCAST_MODEL", "Claude Sonnet 4.6") + +WEATHER_TOOL = Tool.local( + name="get_weather", + description="Look up the current weather for a city.", + parameters={ + "type": "object", + "properties": { + "city": {"type": "string", "description": "City name, e.g. 'Tokyo'."}, + }, + "required": ["city"], + }, +) + + +def get_weather(city: str) -> dict[str, str]: + return {"city": city, "temp_c": "18", "conditions": "light rain"} + + +async def stream_turn(client: Client, history: list[Message]) -> ChatResult: + """Stream one turn, printing text deltas live. Returns the merged result.""" + result = ChatResult() + async for chunk in client.chat.stream( + model=MODEL, messages=history, tools=[WEATHER_TOOL] + ): + if chunk.text: + print(chunk.text, end="", flush=True) + result.add(chunk) + print() + return result + + +async def main() -> None: + bearer = os.environ.get("RAYCAST_BEARER") + device_id = os.environ.get("RAYCAST_DEVICE_ID") + if not bearer or not device_id: + sys.exit("set RAYCAST_BEARER and RAYCAST_DEVICE_ID before running") + if not CONFIG_PATH.is_file(): + sys.exit(f"no config at {CONFIG_PATH} — run `raycast-api init` first") + + history: list[Message] = [ + Message.user("What's the weather like in Tokyo right now?") + ] + + async with Client( + config=Config.load(CONFIG_PATH), bearer_token=bearer, device_id=device_id + ) as client: + print("--- turn 1 ---") + first = await stream_turn(client, history) + + if not first.tool_calls: + return + + for tc in first.tool_calls: + print(f" → tool_call: {tc.name}({tc.arguments})") + + history.append(first.to_assistant_message()) + for call in first.tool_calls: + args = json.loads(call.arguments or "{}") + if call.name == "get_weather": + result: object = get_weather(**args) + else: + result = {"error": f"unknown tool: {call.name}"} + history.append( + Message.tool(tool_call_id=call.id, name=call.name, result=result) + ) + + print("--- turn 2 ---") + await stream_turn(client, history) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/web_search.py b/examples/web_search.py new file mode 100644 index 0000000..0eadce0 --- /dev/null +++ b/examples/web_search.py @@ -0,0 +1,53 @@ +"""Remote tool example — ask something that needs fresh info from the web. + +Raycast's `web_search` is server-routed: pass `RemoteTool.WEB_SEARCH` in +`tools=` and that's it. The model decides when to invoke it, the server +runs the search, and the assistant streams the synthesised answer back. +Nothing for the client to execute. + +Prerequisites: same as basic_usage.py (config.json, RAYCAST_BEARER, +RAYCAST_DEVICE_ID). + +Run: + python examples/web_search.py +""" + +from __future__ import annotations + +import asyncio +import os +import sys +from pathlib import Path + +from raycast_api import Client, Config, Message, RemoteTool + +CONFIG_PATH = Path(os.environ.get("RAYCAST_CONFIG", "config.json")) +MODEL = os.environ.get("RAYCAST_MODEL", "Claude Sonnet 4.6") + + +async def main() -> None: + bearer = os.environ.get("RAYCAST_BEARER") + device_id = os.environ.get("RAYCAST_DEVICE_ID") + if not bearer or not device_id: + sys.exit("set RAYCAST_BEARER and RAYCAST_DEVICE_ID before running") + if not CONFIG_PATH.is_file(): + sys.exit(f"no config at {CONFIG_PATH} — run `raycast-api init` first") + + async with Client( + config=Config.load(CONFIG_PATH), bearer_token=bearer, device_id=device_id + ) as client: + result = await client.chat.complete( + model=MODEL, + messages=[ + Message.user( + "What's the most recent stable Python release? " + "Reply with just the version number." + ) + ], + tools=[RemoteTool.WEB_SEARCH], + ) + print(result.text) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e0664fe --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,46 @@ +[project] +name = "raycast-api" +version = "0.1.0" +description = "Python client for the Raycast backend API." +requires-python = ">=3.11" +readme = "README.md" +authors = [ + { name = "h", email = "h@kotikot.com" } +] +dependencies = [ + "aiohttp>=3.13", +] + +[project.optional-dependencies] +discovery = [ + "esprima>=4.0", +] + +[project.scripts] +raycast-api = "raycast_api.cli:main" + +[build-system] +requires = ["uv_build>=0.11,<0.12"] +build-backend = "uv_build" + +[tool.uv.build-backend] +module-name = ["raycast_api"] + +[dependency-groups] +dev = [ + "pytest>=8", + "pytest-asyncio>=0.23", + "aioresponses>=0.7", + "pre-commit>=4.0", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +markers = [ + "live: hits real backend.raycast.com — needs RAYCAST_BEARER and RAYCAST_DEVICE_ID env vars", + "local_app: requires a local Raycast install at RAYCAST_APP_PATH or the default location", +] +filterwarnings = [ + "ignore:Possible nested set at position .*:FutureWarning:esprima.scanner", +] diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 0000000..d415444 --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,10 @@ +{ + "venvPath": ".", + "venv": ".venv", + "pythonVersion": "3.11", + "include": ["raycast_api", "tests"], + "exclude": [".venv", "**/__pycache__", ".pytest_cache"], + "extraPaths": ["."], + "reportMissingImports": "error", + "reportMissingTypeStubs": "none" +} diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..0d553ea --- /dev/null +++ b/ruff.toml @@ -0,0 +1,35 @@ +target-version = "py311" + +[lint] +select = ["ALL"] +ignore = [ + "D203", + "D212", + "COM812", + "T201", + "D1", + "PLC0415", + "ANN401", + "PLR0913", + "PLR2004", + "C901", + "PLR0911", + "PLR0912", +] +unfixable = ["F401"] + +[lint.per-file-ignores] +"tests/*" = ["ALL"] +"examples/*" = ["ALL"] +"t/*" = ["ALL"] +"*.lock" = ["ALL"] + +[lint.pydocstyle] +convention = "google" + +[lint.isort] +split-on-trailing-comma = false + +[format] +docstring-code-format = true +skip-magic-trailing-comma = true diff --git a/src/raycast_api/__init__.py b/src/raycast_api/__init__.py new file mode 100644 index 0000000..3b9e026 --- /dev/null +++ b/src/raycast_api/__init__.py @@ -0,0 +1,96 @@ +"""raycast-api — Python client for the Raycast backend API.""" + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from raycast_api.ai import ( + Attachment, + ChatResult, + ChatStreamChunk, + Message, + ModelInfo, + ModelsResponse, + RemoteTool, + Source, + Tool, + ToolCall, + UserPreferences, + ) + from raycast_api.client import Client, RetryPolicy, SSEEvent + from raycast_api.config import Config + from raycast_api.signing_spec import SigningSpec + + +__all__ = [ + "Attachment", + "ChatResult", + "ChatStreamChunk", + "Client", + "Config", + "Message", + "ModelInfo", + "ModelsResponse", + "RemoteTool", + "RetryPolicy", + "SSEEvent", + "SigningSpec", + "Source", + "Tool", + "ToolCall", + "UserPreferences", +] + + +def __getattr__(name: str) -> Any: + if name in {"Config", "SigningSpec"}: + from raycast_api.config import Config + from raycast_api.signing_spec import SigningSpec + + return {"Config": Config, "SigningSpec": SigningSpec}[name] + if name in {"Client", "RetryPolicy", "SSEEvent"}: + from raycast_api.client import Client, RetryPolicy, SSEEvent + + return {"Client": Client, "RetryPolicy": RetryPolicy, "SSEEvent": SSEEvent}[ + name + ] + if name in { + "Attachment", + "ChatResult", + "ChatStreamChunk", + "Message", + "ModelInfo", + "ModelsResponse", + "RemoteTool", + "Source", + "Tool", + "ToolCall", + "UserPreferences", + }: + from raycast_api.ai import ( + Attachment, + ChatResult, + ChatStreamChunk, + Message, + ModelInfo, + ModelsResponse, + RemoteTool, + Source, + Tool, + ToolCall, + UserPreferences, + ) + + return { + "Attachment": Attachment, + "ChatResult": ChatResult, + "ChatStreamChunk": ChatStreamChunk, + "Message": Message, + "ModelInfo": ModelInfo, + "ModelsResponse": ModelsResponse, + "RemoteTool": RemoteTool, + "Source": Source, + "Tool": Tool, + "ToolCall": ToolCall, + "UserPreferences": UserPreferences, + }[name] + raise AttributeError(name) diff --git a/src/raycast_api/ai/__init__.py b/src/raycast_api/ai/__init__.py new file mode 100644 index 0000000..efe9901 --- /dev/null +++ b/src/raycast_api/ai/__init__.py @@ -0,0 +1,40 @@ +"""AI endpoint wrappers (chat completions, models, files, me). + +Built on top of `raycast_api.client.Client`. The wrappers translate between +the wire shapes documented in `BUNDLE_NOTES.md` §3-§4 and ergonomic Python +dataclasses, and own the small amount of business logic the chat endpoint +needs (preamble injection, source-specific defaults, SSE → typed chunks). +""" + +from raycast_api.ai.chat import ChatAPI, ChatResult, ChatStreamChunk +from raycast_api.ai.files import FileMetadata, FilesAPI +from raycast_api.ai.me import MeAPI +from raycast_api.ai.models import ModelInfo, ModelsAPI, ModelsResponse +from raycast_api.ai.types import ( + Attachment, + Message, + RemoteTool, + Source, + Tool, + ToolCall, + UserPreferences, +) + +__all__ = [ + "Attachment", + "ChatAPI", + "ChatResult", + "ChatStreamChunk", + "FileMetadata", + "FilesAPI", + "MeAPI", + "Message", + "ModelInfo", + "ModelsAPI", + "ModelsResponse", + "RemoteTool", + "Source", + "Tool", + "ToolCall", + "UserPreferences", +] diff --git a/src/raycast_api/ai/chat.py b/src/raycast_api/ai/chat.py new file mode 100644 index 0000000..3bfaba5 --- /dev/null +++ b/src/raycast_api/ai/chat.py @@ -0,0 +1,534 @@ +"""`/api/v1/ai/chat_completions` — the heart of the library. + +Two surfaces: + + - `ChatAPI.stream(...)` — async generator yielding `ChatStreamChunk`s as + they arrive from the SSE stream. The caller decides what to do with + each chunk (collect text deltas, react to `tool_calls`, etc.). + - `ChatAPI.complete(...)` — convenience that consumes the stream and + returns a single `ChatResult` (accumulated text, final tool_calls, + usage, finish_reason). Use this when you don't need streaming. + +Both go through the canonical `_build_body` which produces the exact dict +the real Raycast client sends — same field set, same field order, same +defaults per `source`. That dict is serialised once (compact JSON, no +spaces) and the resulting bytes go to both `Signer.sign()` and the +network — so the signature always matches the bytes on the wire. + +Resume: the caller passes `on_last_event_id=lambda id: ...` to `stream(...)` +to checkpoint the latest SSE id. If the stream drops, they can call +`ChatAPI.resume(buffer_id, last_event_id)` to pick up from where they +were. The server is responsible for replaying everything after the given +event id, and may return 204 No Content if there's nothing left. +""" + +from __future__ import annotations + +import json +import uuid +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +from raycast_api.ai.types import ( + ChatStreamChunk, + Message, + RemoteTool, + Source, + Tool, + ToolCall, + UserPreferences, +) + +if TYPE_CHECKING: + from collections.abc import AsyncIterator, Callable + + from raycast_api.ai.models import ModelInfo + from raycast_api.client.http import Client + + +_SOURCE_DEFAULTS: dict[Source, dict[str, Any]] = { + Source.AI_CHAT: { + "system_instructions": "markdown", + "temperature": None, + }, + Source.QUICK_AI: {"system_instructions": "plain", "temperature": 0.2}, + Source.AI_COMMAND: {"system_instructions": "plain", "temperature": 0.2}, + Source.API: {"system_instructions": "plain", "temperature": 0.2}, +} + + +@dataclass +class ChatResult: + """The accumulated result of a chat completion. + + Use `ChatAPI.complete(...)` to get one fully populated, or feed chunks + from `ChatAPI.stream(...)` into `result.add(chunk)` yourself if you + want to print text deltas live while still ending up with correctly + merged tool_calls. + + `text` is the full assistant reply concatenated from every `text` delta. + `reasoning` is the chain-of-thought stream (empty for non-thinking + models). `tool_calls` is the final assembled tool_calls list (server + may stream `arguments` incrementally; we buffer them). `finish_reason` + is the last one observed — typically `"STOP"` for a clean finish, or + `"tool_calls"` if the response ends with a tool request. + """ + + text: str = "" + reasoning: str = "" + tool_calls: list[ToolCall] = field(default_factory=list) + finish_reason: str | None = None + usage: dict[str, int] | None = None + extra_content: dict[str, Any] | None = None + chunks: list[ChatStreamChunk] = field(default_factory=list) + + # Internal state for incremental tool_call merge (see `add()`). + _tool_buffers: dict[object, ToolCall] = field( + default_factory=dict, repr=False, compare=False + ) + _index_to_id: dict[int, str] = field( + default_factory=dict, repr=False, compare=False + ) + + def add(self, chunk: ChatStreamChunk) -> None: + """Merge one streamed chunk into this result. + + Appends text/reasoning deltas, tracks usage and finish_reason, and + — critically — handles the three-phase tool_call stream the server + emits (first with `id`+`index`, deltas with empty `id`, then a + final summary with `id` but no `index` and the FULL `arguments`). + Keying naively by either id or index alone produces phantom or + duplicated tool_calls; this method does it correctly. + """ + self.chunks.append(chunk) + if chunk.text: + self.text += chunk.text + if chunk.reasoning: + self.reasoning += chunk.reasoning + if chunk.finish_reason: + self.finish_reason = chunk.finish_reason + if chunk.usage: + self.usage = chunk.usage + if chunk.tool_calls: + self._merge_tool_calls(chunk) + self.tool_calls = list(self._tool_buffers.values()) + + def _merge_tool_calls(self, chunk: ChatStreamChunk) -> None: + is_final_summary = chunk.finish_reason is not None + raw_tcs = chunk.raw.get("tool_calls") or [] + merged_extra: dict[str, Any] = dict(self.extra_content or {}) + for i, tc in enumerate(chunk.tool_calls or []): + raw_tc = raw_tcs[i] if i < len(raw_tcs) else {} + idx_field = raw_tc.get("index") if isinstance(raw_tc, dict) else None + + key: object | None = None + if tc.id: + key = tc.id + if isinstance(idx_field, int): + self._index_to_id[idx_field] = tc.id + elif isinstance(idx_field, int) and idx_field in self._index_to_id: + key = self._index_to_id[idx_field] + elif isinstance(idx_field, int): + key = ("__idx__", idx_field) + else: + continue + + existing = self._tool_buffers.get(key) + if existing is None: + self._tool_buffers[key] = ToolCall( + id=tc.id, + name=tc.name, + arguments=tc.arguments, + extra_content=dict(tc.extra_content) if tc.extra_content else None, + ) + else: + if tc.id and not existing.id: + existing.id = tc.id + if tc.name and not existing.name: + existing.name = tc.name + if tc.arguments: + if is_final_summary: + existing.arguments = tc.arguments + else: + existing.arguments += tc.arguments + if tc.extra_content: + existing.extra_content = { + **(existing.extra_content or {}), + **tc.extra_content, + } + if tc.extra_content: + merged_extra.update(tc.extra_content) + if merged_extra: + self.extra_content = merged_extra + + def to_assistant_message(self) -> Message: + """Build the `assistant` message you'd add to history for the next turn. + + Includes tool_calls and merged extra_content (Google thought + signatures etc.). The `text` field is included even when empty, + because the real client always sends it. + """ + return Message.assistant( + text=self.text, + tool_calls=list(self.tool_calls) if self.tool_calls else None, + extra_content=self.extra_content, + ) + + +__all__ = ["ChatAPI", "ChatResult", "ChatStreamChunk"] + + +class ChatAPI: + """Wrapper around `POST /api/v1/ai/chat_completions` and its resume GET. + + Construction is implicit through `client.chat`; callers don't + instantiate this directly. + """ + + def __init__(self, client: Client) -> None: + self._client = client + + + async def _resolve_model( + self, model: str | ModelInfo, provider: str | None + ) -> tuple[str, str]: + """Map `model` to the `(wire_model, provider)` pair the chat body expects. + + Resolution rules (first match wins): + + 1. `model` is a `ModelInfo` → return `(model.model, model.provider)`. + The `provider=` kwarg is ignored when a `ModelInfo` is passed + (it already disambiguates). + 2. `model` is a string AND `provider` is given → pass through + verbatim: `(model, provider)`. No catalog lookup. This is the + escape hatch for models that aren't in the catalog yet, or for + callers who already know the wire id. + 3. `model` is a string AND `provider` is None → look up the + catalog (fetched once and cached on the Client): + a. Try `catalog.by_id(model)` — matches the prefixed catalog + id (e.g. `"google-gemini-3.1-pro-preview"`). + b. Else search for `info.model == model` — matches the bare + wire id (e.g. `"gemini-3.1-pro-preview"`). + c. Else search for `info.name == model` — matches the display + name (e.g. `"Claude Sonnet 4.6"`). + d. Else raise `ValueError`. + + The catalog fetch is shared across concurrent first-use callers and + cached for the lifetime of the Client. Invalidate it via + `client.invalidate_models_cache()` if the user's subscription changes. + """ + from raycast_api.ai.models import ModelInfo + + if isinstance(model, ModelInfo): + return model.model, model.provider + if not isinstance(model, str): + msg = f"model must be a str or ModelInfo, got {type(model).__name__}" + raise TypeError( + msg + ) + if provider is not None: + return model, provider + + catalog = await self._client._get_models_catalog() # noqa: SLF001 + info = catalog.by_id(model) + if info is None: + for candidate in catalog.models: + if candidate.model == model: + info = candidate + break + if info is None: + for candidate in catalog.models: + if candidate.name == model: + info = candidate + break + if info is None: + msg = ( + f"model {model!r} not found in catalog; " + "pass provider= to bypass lookup" + ) + raise ValueError(msg) + return info.model, info.provider + + + @staticmethod + def _normalize_tools( + tools: list[Tool | RemoteTool | str] | None, + ) -> list[dict[str, Any]] | None: + if not tools: + return None + out: list[dict[str, Any]] = [] + for t in tools: + if isinstance(t, Tool): + out.append(t.to_wire()) + elif isinstance(t, (RemoteTool, str)): + out.append(Tool.remote(t).to_wire()) + else: + msg = f"unsupported tool entry: {type(t).__name__}" + raise TypeError(msg) + return out + + @staticmethod + def _build_preamble( + preferences: UserPreferences | None, extra: str | None + ) -> str | None: + """Compose `additional_system_instructions` from a preferences block. + + Returns None if both are absent — `additional_system_instructions` + is then omitted from the body entirely. + """ + parts: list[str] = [] + if preferences is not None: + parts.append(preferences.render()) + if extra: + parts.append(extra) + if not parts: + return None + return "\n".join(parts) + + def _build_body( + self, + *, + model: str, + provider: str, + messages: list[Message], + source: Source, + buffer_id: str, + message_id: str, + locale: str, + current_date: str | None, + system_instructions: str | None, + additional_system_instructions: str | None, + temperature: float | None, + reasoning_effort: str | None, + tools: list[dict[str, Any]] | None, + tool_choice: str | None, + resume_from: dict[str, str] | None, + ) -> dict[str, Any]: + """Compose the chat_completions request body. + + Field order matches the real-client capture in + `_extracted/captures/request_simple.curl.txt`: + + system_instructions, additional_system_instructions, locale, + temperature, current_date, message_id, reasoning_effort, + messages, tools, tool_choice, source, model, provider, buffer_id + + The server doesn't care about field order — but for max stealth + we emit the same order the WebView sends, so a byte-fingerprint of + the request looks identical to a real Raycast chat. + """ + body: dict[str, Any] = {} + if system_instructions is not None: + body["system_instructions"] = system_instructions + if additional_system_instructions is not None: + body["additional_system_instructions"] = additional_system_instructions + body["locale"] = locale + if temperature is not None: + body["temperature"] = temperature + if current_date is not None: + body["current_date"] = current_date + body["message_id"] = message_id + if reasoning_effort is not None: + body["reasoning_effort"] = reasoning_effort + body["messages"] = [m.to_wire() for m in messages] + if tools is not None: + body["tools"] = tools + body["tool_choice"] = tool_choice or "auto" + body["source"] = source.value + body["model"] = model + body["provider"] = provider + if resume_from is not None: + body["resume_from"] = resume_from + body["buffer_id"] = buffer_id + return body + + + async def stream( + self, + *, + model: str | ModelInfo, + provider: str | None = None, + messages: list[Message], + source: Source = Source.AI_CHAT, + buffer_id: str | None = None, + message_id: str | None = None, + system_instructions: str | None = None, + additional_system_instructions: str | None = None, + user_preferences: UserPreferences | None | bool = True, + temperature: float | None = None, + reasoning_effort: str | None = None, + tools: list[Tool | RemoteTool | str] | None = None, + tool_choice: str | None = None, + current_date: str | None = None, + on_last_event_id: Callable[[str], None] | None = None, + ) -> AsyncIterator[ChatStreamChunk]: + """Stream a chat completion from the server. + + Yields one `ChatStreamChunk` per SSE event. Empty keepalive chunks + are included — callers should check `chunk.is_empty` if they want + to skip them. The terminator (`event: complete`) does NOT produce + a yielded chunk — the iterator just stops. + + `user_preferences`: + - `True` (default) → auto-generated from host locale/timezone/date + - a `UserPreferences` instance → used verbatim + - `False` / `None` → no `` block + + `buffer_id` / `message_id` default to fresh UUIDv4s. Hold onto the + `buffer_id` if you might want to resume; it's required for the + resume GET. + + `tools` accepts `Tool` instances, bare `RemoteTool` enum values, + or raw strings (treated as remote tool names). + + `model` accepts either a string (catalog id, wire id, or display + name — resolved via the cached `/ai/models` catalog) or a + `ModelInfo` instance. `provider` is only consulted when `model` + is a string AND given explicitly, in which case it's passed + through verbatim (escape hatch for models not in the catalog). + """ + wire_model, wire_provider = await self._resolve_model(model, provider) + prefs = self._coerce_preferences(user_preferences) + preamble = self._build_preamble(prefs, additional_system_instructions) + + defaults = _SOURCE_DEFAULTS.get(source, {}) + sys_inst = ( + system_instructions + if system_instructions is not None + else defaults.get("system_instructions") + ) + temp = temperature if temperature is not None else defaults.get("temperature") + + body = self._build_body( + model=wire_model, + provider=wire_provider, + messages=messages, + source=source, + buffer_id=buffer_id or str(uuid.uuid4()), + message_id=message_id or str(uuid.uuid4()), + locale=self._client.locale, + current_date=current_date or self._today_iso(), + system_instructions=sys_inst, + additional_system_instructions=preamble, + temperature=temp, + reasoning_effort=reasoning_effort, + tools=self._normalize_tools(tools), + tool_choice=tool_choice, + resume_from=None, + ) + body_bytes = json.dumps(body, separators=(",", ":"), ensure_ascii=False).encode( + "utf-8" + ) + + async for evt in self._client.stream( + "POST", + "/api/v1/ai/chat_completions", + body=body_bytes, + sign=True, + on_last_event_id=on_last_event_id, + ): + if evt.is_terminal: + return + if not evt.data: + continue + try: + data = json.loads(evt.data) + except json.JSONDecodeError: + continue + if not isinstance(data, dict): + continue + yield ChatStreamChunk.from_wire(data, event_id=evt.id) + + async def resume( + self, *, buffer_id: str, last_event_id: str + ) -> AsyncIterator[ChatStreamChunk]: + """Resume a previously-interrupted chat stream. + + Sends `GET /api/v1/ai/chat_completions/resume?buffer_id=` with + `Last-Event-ID: ` and an empty signed body. The + server replays everything emitted after `last_event_id`. + + Per the BUNDLE_NOTES (§3 "Resume mechanism"), a 204 response means + "nothing left to resume" — in that case this iterator yields + nothing and stops cleanly. + """ + async for evt in self._client.stream( + "GET", + "/api/v1/ai/chat_completions/resume", + params={"buffer_id": buffer_id}, + sign=True, + is_resume=True, + last_event_id=last_event_id, + ): + if evt.is_terminal: + return + if not evt.data: + continue + try: + data = json.loads(evt.data) + except json.JSONDecodeError: + continue + if not isinstance(data, dict): + continue + yield ChatStreamChunk.from_wire(data, event_id=evt.id) + + async def complete( + self, + *, + model: str | ModelInfo, + provider: str | None = None, + messages: list[Message], + source: Source = Source.AI_CHAT, + system_instructions: str | None = None, + additional_system_instructions: str | None = None, + user_preferences: UserPreferences | None | bool = True, + temperature: float | None = None, + reasoning_effort: str | None = None, + tools: list[Tool | RemoteTool | str] | None = None, + tool_choice: str | None = None, + current_date: str | None = None, + ) -> ChatResult: + """Run a chat completion and return the accumulated result. + + Equivalent to consuming `stream(...)` and merging the chunks. Use + this when you don't care about token-by-token streaming. + + Equivalent to consuming `stream(...)` and feeding each chunk into + `ChatResult.add()`. Use this when you don't care about + token-by-token streaming; otherwise iterate `stream(...)` directly + and call `result.add(chunk)` yourself. + """ + result = ChatResult() + async for chunk in self.stream( + model=model, + provider=provider, + messages=messages, + source=source, + system_instructions=system_instructions, + additional_system_instructions=additional_system_instructions, + user_preferences=user_preferences, + temperature=temperature, + reasoning_effort=reasoning_effort, + tools=tools, + tool_choice=tool_choice, + current_date=current_date, + ): + result.add(chunk) + return result + + + @staticmethod + def _coerce_preferences( + value: UserPreferences | None | bool, # noqa: FBT001 + ) -> UserPreferences | None: + if value is True: + return UserPreferences.auto() + if value is False or value is None: + return None + return value + + @staticmethod + def _today_iso() -> str: + import datetime + + return datetime.date.today().isoformat() # noqa: DTZ011 — local date is intended diff --git a/src/raycast_api/ai/files.py b/src/raycast_api/ai/files.py new file mode 100644 index 0000000..e39b9a6 --- /dev/null +++ b/src/raycast_api/ai/files.py @@ -0,0 +1,172 @@ +"""`/api/v1/ai/files` — chat attachment uploads. + +Three calls, all signed (BUNDLE_NOTES §1b): + + - `POST /ai/files` — register an upload. Signs the JSON body, returns + `{id, direct_upload:{url, headers}}`. Caller PUTs the blob to that + URL (unsigned, off-Raycast — usually S3-presigned). + - `GET /ai/files/{id}` — fetch a previously-uploaded file. ⚠ signs the + literal two-byte string `"{}"` even though the GET sends no body + over the wire. This is a Raycast-side oddity (`uV` @ 118609); the + server validates the signature against `"{}"`, so we must match. + - `DELETE /ai/files` — bulk-delete files for a list of chat_ids. + Sends a JSON body on a DELETE. aiohttp supports this if we pass + `data=` explicitly. + +`FilesAPI.upload(path, chat_id)` orchestrates both halves of the upload +(register + PUT) and returns the `FileMetadata` ready to drop into an +`Attachment`. The PUT is done via `aiohttp.ClientSession.put(...)` on +the same session as the rest of the client (so connection pooling +works) but with no Raycast headers — the presigned URL carries its own +auth. +""" + +from __future__ import annotations + +import hashlib +import json +import mimetypes +from dataclasses import dataclass, field +from pathlib import Path +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from raycast_api.client.http import Client + + +@dataclass +class FileMetadata: + """Result of a `POST /ai/files` upload, post-PUT. + + `file_id` is the server-side id you'll reference in subsequent chat + requests (via `Attachment.file_id`). Everything else is bookkeeping + the caller usually doesn't need. + """ + + file_id: str + filename: str + size: int + content_type: str + checksum: str + raw: dict[str, Any] = field(default_factory=dict) + + +class FilesAPI: + """Wrapper around the three `/ai/files` endpoints.""" + + def __init__(self, client: Client) -> None: + self._client = client + + async def upload( + self, + *, + path: str | Path, + chat_id: str, + filename: str | None = None, + content_type: str | None = None, + ) -> FileMetadata: + """Upload a file to Raycast's blob store. + + Steps: + 1. Read the file (or accept caller-provided bytes via the future + `data=` overload — not implemented yet). + 2. Compute SHA-256 checksum. + 3. `POST /ai/files` with metadata. Server returns `direct_upload`. + 4. PUT the file bytes to `direct_upload.url` with the provided + headers. No Raycast signing on the PUT. + """ + p = Path(path) + data = p.read_bytes() # noqa: ASYNC240 — sync read; aiofiles would be overkill + return await self._upload_bytes( + data=data, + chat_id=chat_id, + filename=filename or p.name, + content_type=content_type or _guess_content_type(p), + ) + + async def _upload_bytes( + self, *, data: bytes, chat_id: str, filename: str, content_type: str + ) -> FileMetadata: + checksum = hashlib.sha256(data).hexdigest() + body = { + "chat_id": chat_id, + "blob": { + "filename": filename, + "byte_size": len(data), + "content_type": content_type, + "checksum": checksum, + }, + } + + async with self._client.request( + "POST", "/api/v1/ai/files", json_body=body, sign=True + ) as resp: + registration = await resp.json() + + upload_info = registration.get("direct_upload") or {} + upload_url = upload_info.get("url") + upload_headers = upload_info.get("headers") or {} + if not isinstance(upload_url, str) or not upload_url: + msg = "POST /ai/files succeeded but response had no direct_upload.url" + raise RuntimeError( + msg + ) + + session = self._client._require_session() # noqa: SLF001 — same package + async with session.put( + upload_url, data=data, headers=upload_headers + ) as put_resp: + if put_resp.status >= 400: + text = await put_resp.text() + msg = f"direct_upload PUT failed: HTTP {put_resp.status} {text[:200]}" + raise RuntimeError( + msg + ) + + return FileMetadata( + file_id=str(registration.get("id", "")), + filename=filename, + size=len(data), + content_type=content_type, + checksum=checksum, + raw=registration, + ) + + async def get(self, file_id: str) -> bytes: + """Download a previously-uploaded file by its id. + + ⚠ Signs the literal string `"{}"` (two bytes) as the body, per + the `uV` caller's behaviour. The server validates the signature + against that, NOT against an empty string — sending `b""` here + produces a 401. + """ + async with self._client.request( + "GET", f"/api/v1/ai/files/{file_id}", body=b"{}", sign=True + ) as resp: + return await resp.read() + + async def delete(self, *, chat_ids: list[str]) -> None: + """Delete all files associated with one or more chat ids. + + Sends a JSON body on the DELETE method (`oge` @ 119406). aiohttp + forwards bodies on DELETE when `data=` is explicit, so this works + end-to-end. Server returns 2xx with no body of interest. + """ + body = {"chat_ids": list(chat_ids)} + body_bytes = json.dumps(body, separators=(",", ":"), ensure_ascii=False).encode( + "utf-8" + ) + async with self._client.request( + "DELETE", "/api/v1/ai/files", body=body_bytes, sign=True + ) as resp: + await resp.read() + + +def _guess_content_type(p: Path) -> str: + """Best-effort MIME lookup for `Content-Type` of the registered blob. + + Falls back to `application/octet-stream`. The server is permissive + about this field — it's metadata for the model, not transport. + """ + guess, _ = mimetypes.guess_type(p.name) + return guess or "application/octet-stream" diff --git a/src/raycast_api/ai/me.py b/src/raycast_api/ai/me.py new file mode 100644 index 0000000..cdc302d --- /dev/null +++ b/src/raycast_api/ai/me.py @@ -0,0 +1,36 @@ +"""`/api/v1/me` wrapper — bearer-token sanity probe. + +The smallest possible round-trip against the backend. Used as a probe to +verify the Bearer token is valid before doing anything more expensive (a +401 here is unambiguous: the token is wrong, not a signing bug). + +Unsigned; returns a raw dict because the `me` response shape includes a +LOT of subscription/feature-flag fields that aren't worth modelling +exhaustively — callers are typically interested in `email` / +`raycast_subscription` / `has_pro_features` and nothing else. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from raycast_api.client.http import Client + + +class MeAPI: + """`GET /api/v1/me`.""" + + def __init__(self, client: Client) -> None: + self._client = client + + async def get(self) -> dict[str, Any]: + """Fetch the current user's profile. + + Returns the raw JSON dict — we deliberately don't model the shape + because it has dozens of subscription/feature-flag fields that + Raycast adds to over time. If a caller needs typed access to a + specific field, they should pick it out of this dict. + """ + async with self._client.request("GET", "/api/v1/me", sign=False) as resp: + return await resp.json() diff --git a/src/raycast_api/ai/models.py b/src/raycast_api/ai/models.py new file mode 100644 index 0000000..bc3f897 --- /dev/null +++ b/src/raycast_api/ai/models.py @@ -0,0 +1,173 @@ +"""`/api/v1/ai/models` wrapper. + +Single endpoint: GET, unsigned (Bearer-only), with the +`X-Raycast-Experimental: autoModels` header attached — same as every +signed-surface caller, but here it's the only Raycast-specific header +on the request (signing is omitted per BUNDLE_NOTES §1c). + +The response is the catalog of models the user's subscription has access +to, plus the server-side defaults for each Raycast surface (chat, quick_ai, +commands, …). Callers will typically `await client.models.list()` once at +startup, cache it, and pass `model=info.id` / `provider=info.provider` to +`client.chat.stream(...)`. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from raycast_api.client.http import Client + + +@dataclass +class ModelInfo: + """One entry from `/ai/models`. + + Most fields are passed straight through from the server; the only + transformations are the snake_case key names (the server already uses + snake_case) and consistent typing on the optional fields. + + Three id-like fields that are easy to mix up: + + - `id` — catalog id with provider prefix, e.g. `"google-gemini-3.1-pro-preview"`. + Use this to look entries up (`models.by_id(...)`); do NOT send it + as the request `model` field. + - `model` — bare wire identifier the server forwards to the provider, + e.g. `"gemini-3.1-pro-preview"`. THIS is what goes in the chat + request body's `model` field. Confirmed against captured requests + (see `_extracted/captures/request_*.curl.txt`). + - `provider` — provider key, e.g. `"google"`. Goes in the request + body's `provider` field alongside `model`. + + Correct usage: + + info = models.by_id("google-gemini-3.1-pro-preview") + await client.chat.complete(model=info.model, provider=info.provider, ...) + """ + + id: str + name: str + model: str + provider: str + provider_name: str = "" + provider_brand: str = "" + description: str = "" + context: int = -1 + status: str = "" + availability: str = "" + features: list[str] = field(default_factory=list) + capabilities: dict[str, Any] = field(default_factory=dict) + abilities: dict[str, Any] = field(default_factory=dict) + in_better_ai_subscription: bool = False + allowed_subscription_types: list[str] = field(default_factory=list) + requires_better_ai: bool = False + suggestions: list[Any] = field(default_factory=list) + raw: dict[str, Any] = field(default_factory=dict) + + @classmethod + def from_wire(cls, d: dict[str, Any]) -> ModelInfo: + return cls( + id=str(d.get("id", "")), + name=str(d.get("name", "")), + model=str(d.get("model", "")), + provider=str(d.get("provider", "")), + provider_name=str(d.get("provider_name", "")), + provider_brand=str(d.get("provider_brand", "")), + description=str(d.get("description", "")), + context=int(d.get("context", -1)), + status=str(d.get("status", "")), + availability=str(d.get("availability", "")), + features=list(d.get("features") or []), + capabilities=dict(d.get("capabilities") or {}), + abilities=dict(d.get("abilities") or {}), + in_better_ai_subscription=bool(d.get("in_better_ai_subscription", False)), + allowed_subscription_types=list(d.get("allowed_subscription_types") or []), + requires_better_ai=bool(d.get("requires_better_ai", False)), + suggestions=list(d.get("suggestions") or []), + raw=d, + ) + + @property + def supports_temperature(self) -> bool: + node = self.abilities.get("temperature") + return bool(node and node.get("supported")) + + @property + def supports_reasoning_effort(self) -> bool: + node = self.abilities.get("reasoning_effort") + return bool(node and node.get("supported")) + + @property + def reasoning_effort_options(self) -> list[str]: + node = self.abilities.get("reasoning_effort") or {} + opts = node.get("options") + return [str(o) for o in opts] if isinstance(opts, list) else [] + + +@dataclass +class ModelsResponse: + """The complete `/ai/models` payload. + + `default_models` is the server's per-surface default mapping; callers + can look up `default_models.get("chat")` to find the model id Raycast + itself would pick for the AI Chat window. + + `free_model_ids` is the list of model ids available on the free tier — + the server actually emits this as `free_models: ["id1", "id2", ...]` + (a list of strings, not full ModelInfo objects, despite what BUNDLE_NOTES + inferred). To get a `ModelInfo` for one, look it up via `.by_id(id)`. + """ + + models: list[ModelInfo] + default_models: dict[str, str] = field(default_factory=dict) + free_model_ids: list[str] = field(default_factory=list) + raw: dict[str, Any] = field(default_factory=dict) + + @classmethod + def from_wire(cls, d: dict[str, Any]) -> ModelsResponse: + free_raw = d.get("free_models") or [] + free_ids: list[str] = [] + for item in free_raw: + if isinstance(item, str): + free_ids.append(item) + elif isinstance(item, dict) and "id" in item: + free_ids.append(str(item["id"])) + return cls( + models=[ModelInfo.from_wire(m) for m in d.get("models") or []], + default_models={ + str(k): str(v) for k, v in (d.get("default_models") or {}).items() + }, + free_model_ids=free_ids, + raw=d, + ) + + def by_id(self, model_id: str) -> ModelInfo | None: + for m in self.models: + if m.id == model_id: + return m + return None + + +class ModelsAPI: + """Thin wrapper around `GET /api/v1/ai/models`.""" + + def __init__(self, client: Client) -> None: + self._client = client + + async def list(self) -> ModelsResponse: + """Fetch the full model catalog. + + Sent unsigned (Bearer-only) but still carries the + `X-Raycast-Experimental: autoModels` header that the real client + attaches — without it the response shape changes (older catalog). + """ + async with self._client.request( + "GET", + "/api/v1/ai/models", + sign=False, + headers={"X-Raycast-Experimental": self._client.config.experimental_header}, + ) as resp: + data = await resp.json() + return ModelsResponse.from_wire(data) diff --git a/src/raycast_api/ai/types.py b/src/raycast_api/ai/types.py new file mode 100644 index 0000000..c102289 --- /dev/null +++ b/src/raycast_api/ai/types.py @@ -0,0 +1,397 @@ +"""Typed shapes for chat completions and adjacent endpoints. + +These dataclasses sit between Raycast's wire JSON (per BUNDLE_NOTES.md §3) +and the Python caller. Every type exposes either `.to_wire()` to produce +the dict that goes into the request body, or `.from_wire(d)` to parse what +the server returned. We intentionally keep these as plain dataclasses — +not pydantic — for consistency with the rest of the package. + +Field order in `to_wire()` for `ChatRequest` mirrors what the real client +sends (observed in `_extracted/captures/request_*.curl.txt`). The server +doesn't care about ordering itself, but the bytes we sign must equal the +bytes we send, so we serialise once via `to_wire()` and the same dict goes +to both `Signer.sign(...)` and `aiohttp.session.post(data=...)`. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import StrEnum +from typing import Any, ClassVar + + +class Source(StrEnum): + """`source` discriminator on chat_completions. + + The library defaults to `AI_CHAT` (matches the main Raycast chat window + fingerprint). `API` is what the Extension API path sends; the other two + are observable but less useful for a non-Raycast caller. + """ + + AI_CHAT = "ai_chat" + QUICK_AI = "quick_ai" + AI_COMMAND = "ai_command" + API = "api" + + +class RemoteTool(StrEnum): + """The three generic remote tools the library exposes by name. + + Per BUNDLE_NOTES decision: only the three model-agnostic web tools. + Other Raycast-extension remote tools (calendar, location, etc.) are + out of scope. Pass these to `ChatAPI.stream(tools=...)` as-is and the + library wraps them as `{"type":"remote_tool","name":}`. + """ + + WEB_SEARCH = "web_search" + SEARCH_IMAGES = "search_images" + READ_PAGE = "read_page" + + +@dataclass +class Tool: + """One tool definition for the `tools` field of a chat request. + + Use `Tool.local(...)` for a function-calling tool the caller will + execute and feed back as a `tool` message; use `Tool.remote(...)` + (or pass a `RemoteTool` enum) for server-routed remote tools. + """ + + type: str + name: str + description: str | None = None + parameters: dict[str, Any] | None = None + + @classmethod + def local( + cls, name: str, description: str = "", parameters: dict[str, Any] | None = None + ) -> Tool: + return cls( + type="local_tool", + name=name, + description=description, + parameters=parameters if parameters is not None else {}, + ) + + @classmethod + def remote(cls, name: str | RemoteTool) -> Tool: + return cls( + type="remote_tool", + name=name.value if isinstance(name, RemoteTool) else name, + ) + + def to_wire(self) -> dict[str, Any]: + if self.type == "remote_tool": + return {"type": "remote_tool", "name": self.name} + fn: dict[str, Any] = {"name": self.name} + if self.description is not None: + fn["description"] = self.description + if self.parameters is not None: + fn["parameters"] = self.parameters + return {"type": "local_tool", "function": fn} + + +@dataclass +class ToolCall: + """One function call emitted by the assistant. + + `arguments` is the JSON-encoded argument string (NOT a dict) — that's + what Raycast / OpenAI-style backends emit and what the server expects + back when we echo it in history. + """ + + id: str + name: str + arguments: str + extra_content: dict[str, dict[str, str]] | None = None + + def to_wire(self) -> dict[str, Any]: + return { + "id": self.id, + "type": "function", + "function": {"name": self.name, "arguments": self.arguments}, + } + + @classmethod + def from_wire(cls, d: dict[str, Any]) -> ToolCall: + if "function" in d: + fn = d["function"] + return cls( + id=str(d["id"]), + name=str(fn.get("name", "")), + arguments=str(fn.get("arguments", "")), + extra_content=d.get("extra_content"), + ) + return cls( + id=str(d.get("id", "")), + name=str(d.get("name", "")), + arguments=str(d.get("arguments", "")), + extra_content=d.get("extra_content"), + ) + + +@dataclass +class Attachment: + """Metadata for a file uploaded via `POST /ai/files`. + + Most users build this through `FilesAPI.upload(...)` which fills in + `fileId` from the server response. The other fields are local UI + bookkeeping the real client sends verbatim — we mirror them so the + request body matches what the server expects. + """ + + id: str + path: str + filename: str + size: int + file_id: str + type: str = "file" + source: str = "file" + content_type: str = "application/octet-stream" + status: str = "completed" + url: str = "" + is_over_context_limit: bool = False + extra: dict[str, Any] = field(default_factory=dict) + + def to_wire(self) -> dict[str, Any]: + out: dict[str, Any] = { + "id": self.id, + "path": self.path, + "filename": self.filename, + "size": self.size, + "fileId": self.file_id, + "type": self.type, + "source": self.source, + "contentType": self.content_type, + "status": self.status, + "url": self.url, + "isOverContextLimit": self.is_over_context_limit, + } + out.update(self.extra) + return out + + +@dataclass +class Message: + """One message in a chat history. + + Use the factory classmethods rather than constructing directly — they + enforce the role/field combinations the server expects (see + BUNDLE_NOTES §3, "Message types"). + """ + + role: str + content: dict[str, Any] = field(default_factory=dict) + tool_calls: list[ToolCall] | None = None + extra_content: dict[str, dict[str, str]] | None = None + name: str | None = None + tool_call_id: str | None = None + + @classmethod + def user(cls, text: str, attachments: list[Attachment] | None = None) -> Message: + content: dict[str, Any] = {"text": text} + if attachments is not None: + content["attachments"] = [a.to_wire() for a in attachments] + return cls(role="user", content=content) + + @classmethod + def assistant( + cls, + text: str = "", + tool_calls: list[ToolCall] | None = None, + extra_content: dict[str, dict[str, str]] | None = None, + ) -> Message: + return cls( + role="assistant", + content={"text": text}, + tool_calls=list(tool_calls) if tool_calls else None, + extra_content=extra_content, + ) + + @classmethod + def tool(cls, *, tool_call_id: str, name: str, result: Any) -> Message: + """Build a tool-result message from any Python value. + + The server expects `content.text` to be a JSON-encoded MCP-style + list of content blocks (e.g. `[{"type":"text","text":"..."}]`) — + anything else produces `unknown_api_error` upstream. + + Behaviour: + - `str` → wrapped as a single text block. + - already a list of `{"type": ...}` blocks → passed through. + - any other value (dict, list, number, ...) → JSON-serialised + into a single text block. + """ + import json + + def _is_block_list(x: object) -> bool: + return isinstance(x, list) and all( + isinstance(b, dict) and "type" in b for b in x + ) + + if isinstance(result, str): + payload: list[dict[str, Any]] = [{"type": "text", "text": result}] + elif _is_block_list(result): + payload = result # type: ignore[assignment] + else: + payload = [ + { + "type": "text", + "text": json.dumps( + result, separators=(",", ":"), ensure_ascii=False + ), + } + ] + return cls( + role="tool", + content={ + "text": json.dumps(payload, separators=(",", ":"), ensure_ascii=False) + }, + name=name, + tool_call_id=tool_call_id, + ) + + def to_wire(self) -> dict[str, Any]: + out: dict[str, Any] = {"role": self.role, "content": dict(self.content)} + if self.tool_calls: + out["tool_calls"] = [tc.to_wire() for tc in self.tool_calls] + if self.extra_content is not None: + out["extra_content"] = self.extra_content + if self.name is not None: + out["name"] = self.name + if self.tool_call_id is not None: + out["tool_call_id"] = self.tool_call_id + return out + + +@dataclass +class UserPreferences: + """Renders the `` block of `additional_system_instructions`. + + This is the only personalisation block the library produces by default + (per the Phase 1 decision: profile / memory / extensions / skills are + out of scope). The block matches the real client's wording byte-for- + byte so the model can use it identically to a real chat. + + `current_date` should be a `YYYY-MM-DD` string (the real client uses + `Intl.DateTimeFormat`'s short-locale form — we just normalise to ISO). + """ + + locale: str + timezone: str + current_date: str + + _TEMPLATE: ClassVar[str] = ( + "\n" + " The user has the following system preferences:\n" + " - Locale: {locale}\n" + " - Timezone: {timezone}\n" + " - Current Date: {date}\n" + " - Use the system preferences to format your answers accordingly\n" + "" + ) + + def render(self) -> str: + return self._TEMPLATE.format( + locale=self.locale, timezone=self.timezone, date=self.current_date + ) + + @classmethod + def auto(cls, locale: str | None = None) -> UserPreferences: + """Build preferences from the host's locale/timezone and today's date. + + Used as the default in `ChatAPI.stream(...)` when the caller doesn't + pass an explicit `user_preferences=` arg. The locale arg defaults + to the client's `locale` (which itself defaults to `en-US`). + """ + import datetime + import time + + tz: str + if time.daylight and time.localtime().tm_isdst > 0: + tz = time.tzname[1] or time.tzname[0] or "UTC" + else: + tz = time.tzname[0] or "UTC" + return cls( + locale=locale or "en-US", + timezone=tz, + current_date=datetime.date.today().isoformat(), # noqa: DTZ011 — local date + ) + + + + +@dataclass +class ChatStreamChunk: + """One parsed JSON chunk from the chat_completions SSE stream. + + Fields here are a 1:1 mirror of `vkt`'s consumer (BUNDLE_NOTES §3, + "Per-chunk JSON shape"). All fields are optional; callers should + check which fields are present. + + `event_id` is the SSE `id:` value (used for resume). `raw` is the + underlying dict in case the caller needs a field we didn't model + (e.g. forward-compat with a new chunk kind). + """ + + text: str | None = None + reasoning: str | None = None + tool_calls: list[ToolCall] | None = None + finish_reason: str | None = None + usage: dict[str, int] | None = None + notification: str | None = None + notification_type: str | None = None + references: list[dict[str, Any]] | None = None + image: str | None = None + extra_content: dict[str, Any] | None = None + event_id: str | None = None + raw: dict[str, Any] = field(default_factory=dict) + + @classmethod + def from_wire( + cls, data: dict[str, Any], *, event_id: str | None = None + ) -> ChatStreamChunk: + tc_wire = data.get("tool_calls") + tool_calls: list[ToolCall] | None = None + if isinstance(tc_wire, list): + tool_calls = [ToolCall.from_wire(d) for d in tc_wire if isinstance(d, dict)] + return cls( + text=data.get("text") if isinstance(data.get("text"), str) else None, + reasoning=data.get("reasoning") + if isinstance(data.get("reasoning"), str) + else None, + tool_calls=tool_calls, + finish_reason=data.get("finish_reason") + if isinstance(data.get("finish_reason"), str) + else None, + usage=data.get("usage") if isinstance(data.get("usage"), dict) else None, + notification=data.get("notification") + if isinstance(data.get("notification"), str) + else None, + notification_type=data.get("notification_type") + if isinstance(data.get("notification_type"), str) + else None, + references=data.get("references") + if isinstance(data.get("references"), list) + else None, + image=data.get("image") if isinstance(data.get("image"), str) else None, + extra_content=data.get("extra_content") + if isinstance(data.get("extra_content"), dict) + else None, + event_id=event_id, + raw=data, + ) + + @property + def is_empty(self) -> bool: + """True for the keepalive `{"text":""}` chunks the server interleaves.""" + return ( + not self.text + and not self.reasoning + and not self.tool_calls + and not self.finish_reason + and not self.usage + and not self.notification + and not self.image + and not self.references + ) diff --git a/src/raycast_api/cli.py b/src/raycast_api/cli.py new file mode 100644 index 0000000..d33a9ed --- /dev/null +++ b/src/raycast_api/cli.py @@ -0,0 +1,451 @@ +"""Command-line interface for `raycast-api`. + +Four verbs: + + - ``raycast-api init`` — run discovery against a local Raycast install, + write the result to ``config.json``. + - ``raycast-api refresh`` — same as ``init`` but always overwrites and + bypasses the discovery cache (the bundle + may have changed even at the same hash if + cache was hand-edited). + - ``raycast-api inspect`` — print a summary of a saved config. No + network calls. + - ``raycast-api ask`` — minimal smoke-test command: send one prompt, + print the reply. Reads Bearer + device id + from env / flags. The device id is auto- + generated and persisted on first use. + +The CLI is intentionally small — the library is the product, this is a +thin convenience for users who don't want to write a Python script just to +verify their config works. +""" + +from __future__ import annotations + +import argparse +import asyncio +import contextlib +import os +import secrets +import sys +from pathlib import Path +from typing import TYPE_CHECKING + +from raycast_api.config import Config, ConfigComparison +from raycast_api.errors import ConfigError, DiscoveryError, RaycastApiError + +if TYPE_CHECKING: + from collections.abc import Sequence + +DEFAULT_CONFIG_PATH = Path("config.json") +DEFAULT_DEVICE_ID_PATH = Path.home() / ".config" / "raycast-api" / "device_id" + + +_DEFAULT_APP_PATHS: tuple[Path, ...] = ( + Path("/Applications/Raycast Beta.app"), + Path("/Applications/Raycast.app"), + Path.cwd() / "Raycast Beta.app", + Path.cwd() / "Raycast.app", +) + + +def _resolve_app_path(explicit: str | None) -> Path: + if explicit is not None: + return Path(explicit).expanduser().resolve() + for candidate in _DEFAULT_APP_PATHS: + if candidate.is_dir(): + return candidate + msg = "could not find a Raycast install; pass --app-path " + raise SystemExit( + msg + ) + + +def _try_resolve_app_path(explicit: str | None) -> Path | None: + """Non-raising variant for `inspect`: return None if no app is findable.""" + if explicit is not None: + candidate = Path(explicit).expanduser().resolve() + return candidate if candidate.is_dir() else None + for candidate in _DEFAULT_APP_PATHS: + if candidate.is_dir(): + return candidate + return None + + + + +def _generate_device_id() -> str: + """Mint a fresh 64-char hex device id.""" + return secrets.token_hex(32) + + +def _load_or_create_device_id(path: Path = DEFAULT_DEVICE_ID_PATH) -> str: + """Read the persisted device id, generating + saving one if missing. + + Stored in `~/.config/raycast-api/device_id` (chmod 0o600). The same id + is reused across CLI invocations so a single user looks like a single + install to the backend. + """ + if path.exists(): + existing = path.read_text(encoding="ascii").strip() + if len(existing) == 64 and all(c in "0123456789abcdefABCDEF" for c in existing): + return existing.lower() + path.parent.mkdir(parents=True, exist_ok=True) + fresh = _generate_device_id() + path.write_text(fresh + "\n", encoding="ascii") + with contextlib.suppress(OSError): + path.chmod(0o600) + return fresh + + + + +def _cmd_init(args: argparse.Namespace) -> int: + app_path = _resolve_app_path(args.app_path) + output = Path(args.output).expanduser() + if output.exists() and not args.force: + print(f"!! {output} already exists; pass --force to overwrite", file=sys.stderr) + return 1 + try: + config = Config.discover_from_app(app_path, use_cache=not args.no_cache) + except DiscoveryError as e: + print(f"!! discovery failed: {e}", file=sys.stderr) + return 1 + config.save(output) + print(f"·· wrote {output}") + print(f" app version : {config.app_version}") + print(f" secret : {config.redacted_secret()}") + print(f" bundle hash : {config.bundle_hash[:12]}…") + print(f" cache key : {config.cache_key()[:12]}…") + return 0 + + +def _cmd_refresh(args: argparse.Namespace) -> int: + """Re-discover and overwrite an existing config. + + Bypasses the discovery cache so a launcher rebuild that changed the + secret without changing the bundle still gets picked up. + """ + app_path = _resolve_app_path(args.app_path) + output = Path(args.config).expanduser() + try: + config = Config.discover_from_app(app_path, use_cache=False) + except DiscoveryError as e: + print(f"!! discovery failed: {e}", file=sys.stderr) + return 1 + config.save(output) + print(f"·· refreshed {output}") + print(f" app version : {config.app_version}") + print(f" secret : {config.redacted_secret()}") + print(f" bundle hash : {config.bundle_hash[:12]}…") + return 0 + + +def _cmd_inspect(args: argparse.Namespace) -> int: + """Print a saved config; optionally verify freshness against a local app. + + Verification is opt-in via `--verify`, `--app-path`, or `--quiet` (which + implies verification). Without any of those, this is a pure offline dump + of the config file — same behavior as the original `inspect`. + + Exit codes: + 0 — config loaded; verified current OR no verification requested. + 1 — config missing / invalid (legacy), OR verification reports stale. + 2 — verification was requested but the local app is unreachable + (explicit `--app-path` missing, or `--quiet` without an + autodetectable app). + + `--quiet` suppresses output and is meant for shell scripts: + `raycast-api inspect --quiet || raycast-api refresh`. + """ + verify_requested = args.verify or args.quiet or args.app_path is not None + path = Path(args.config).expanduser() + if not path.exists(): + if not args.quiet: + print(f"!! no config at {path}", file=sys.stderr) + return 1 + try: + config = Config.load(path) + except ConfigError as e: + if not args.quiet: + print(f"!! config invalid: {e}", file=sys.stderr) + return 1 + + app_path: Path | None = None + if verify_requested: + if args.app_path is not None: + candidate = Path(args.app_path).expanduser().resolve() + if not candidate.is_dir(): + if not args.quiet: + print(f"!! app path not found: {candidate}", file=sys.stderr) + return 2 + app_path = candidate + else: + app_path = _try_resolve_app_path(None) + if app_path is None and args.quiet: + return 2 + + comparison: ConfigComparison | None = None + compare_error: str | None = None + if app_path is not None: + try: + comparison = config.compare_with_app(app_path) + except DiscoveryError as e: + compare_error = str(e) + if args.quiet: + return 2 + + if args.quiet: + return 0 if (comparison is not None and comparison.is_current) else 1 + + spec = config.signing_spec + print(f"config_path : {path}") + print(f"app_version : {config.app_version}") + print(f"user_agent : {config.user_agent}") + print(f"backend_url : {config.backend_url}") + print(f"secret : {config.redacted_secret()}") + print(f"bundle_hash : {config.bundle_hash}") + print(f"launcher_hash : {config.launcher_hash}") + print(f"cache_key : {config.cache_key()}") + print(f"experimental : {config.experimental_header}") + print(f"signing_spec : rot={spec.rot_fn_name} sign={spec.signing_fn_name}") + print(f" ranges : {[(r.start, r.end, r.shift) for r in spec.rot_ranges]}") + print(f" hmac : {spec.hmac_algorithm} body={spec.body_hash_algorithm}") + print(f" join : {spec.join_char!r}") + + if verify_requested: + _print_status_block(app_path, comparison, compare_error) + if comparison is not None and not comparison.is_current: + return 1 + return 0 + + +def _print_status_block( + app_path: Path | None, comparison: ConfigComparison | None, error: str | None +) -> None: + """Render the freshness section appended to `inspect` output.""" + print() + if app_path is None: + print( + "status : unknown (no local Raycast.app found; " + "pass --app-path to verify)" + ) + return + if comparison is None: + print(f"status : unknown ({error or 'could not hash app'})") + print(f" app path : {app_path}") + return + label = "CURRENT" if comparison.is_current else "STALE — run `raycast-api refresh`" + print(f"status : {label}") + print(f" app path : {app_path}") + print( + " bundle : " + + ( + "✓ matches" + if comparison.bundle_matches + else f"✗ saved {comparison.saved_bundle_hash[:12]}… → " + f"current {comparison.current_bundle_hash[:12]}…" + ) + ) + print( + " launcher : " + + ( + "✓ matches" + if comparison.launcher_matches + else f"✗ saved {comparison.saved_launcher_hash[:12]}… → " + f"current {comparison.current_launcher_hash[:12]}…" + ) + ) + if comparison.app_version_matches: + print(f" app version : ✓ {comparison.current_app_version}") + else: + print( + f" app version : ≠ saved {comparison.saved_app_version!r} → " + f"current {comparison.current_app_version!r} " + "(informational; not a staleness signal on its own)" + ) + + +async def _run_ask(args: argparse.Namespace) -> int: + from raycast_api.ai import Message + from raycast_api.client import Client + + path = Path(args.config).expanduser() # noqa: ASYNC240 — sync I/O at CLI boundary + if not path.exists(): + print(f"!! no config at {path}; run `raycast-api init` first", file=sys.stderr) + return 1 + try: + config = Config.load(path) + except ConfigError as e: + print(f"!! config invalid: {e}", file=sys.stderr) + return 1 + + bearer = args.bearer or os.environ.get("RAYCAST_BEARER") + if not bearer: + print( + "!! missing bearer token; pass --bearer or set RAYCAST_BEARER", + file=sys.stderr, + ) + return 2 + + device_id = args.device_id or os.environ.get("RAYCAST_DEVICE_ID") + if not device_id: + device_id = _load_or_create_device_id() + + model = args.model or os.environ.get("RAYCAST_MODEL") + if not model: + print( + "!! no model specified; pass --model or set RAYCAST_MODEL", file=sys.stderr + ) + return 2 + + prompt = args.prompt + provider = args.provider or os.environ.get("RAYCAST_PROVIDER") + + async with Client( + config=config, bearer_token=bearer, device_id=device_id + ) as client: + try: + if args.stream: + final_finish: str | None = None + final_usage: dict | None = None + async for chunk in client.chat.stream( + model=model, provider=provider, messages=[Message.user(prompt)] + ): + if chunk.text: + print(chunk.text, end="", flush=True) + if chunk.finish_reason: + final_finish = chunk.finish_reason + if chunk.usage: + final_usage = chunk.usage + print() + print( + f"·· finish_reason={final_finish} usage={final_usage}", + file=sys.stderr, + ) + else: + result = await client.chat.complete( + model=model, provider=provider, messages=[Message.user(prompt)] + ) + print(result.text) + print( + f"·· finish_reason={result.finish_reason} usage={result.usage}", + file=sys.stderr, + ) + except RaycastApiError as e: + print(f"!! ask failed: {type(e).__name__}: {e}", file=sys.stderr) + return 1 + return 0 + + +def _cmd_ask(args: argparse.Namespace) -> int: + return asyncio.run(_run_ask(args)) + + + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="raycast-api", + description="Bring-your-own-credentials client for the Raycast backend.", + ) + sub = parser.add_subparsers(dest="cmd", required=True) + + init_p = sub.add_parser("init", help="discover and save a config.json") + init_p.add_argument( + "--app-path", help="path to Raycast.app (autodetected if omitted)" + ) + init_p.add_argument( + "--output", + default=str(DEFAULT_CONFIG_PATH), + help=f"output file (default: {DEFAULT_CONFIG_PATH})", + ) + init_p.add_argument( + "--force", + action="store_true", + help="overwrite the output file if it already exists", + ) + init_p.add_argument( + "--no-cache", action="store_true", help="bypass the discovery cache" + ) + init_p.set_defaults(func=_cmd_init) + + refresh_p = sub.add_parser( + "refresh", help="re-run discovery (overwrites the config; bypasses the cache)" + ) + refresh_p.add_argument( + "--app-path", help="path to Raycast.app (autodetected if omitted)" + ) + refresh_p.add_argument( + "--config", + default=str(DEFAULT_CONFIG_PATH), + help=f"config file to overwrite (default: {DEFAULT_CONFIG_PATH})", + ) + refresh_p.set_defaults(func=_cmd_refresh) + + inspect_p = sub.add_parser( + "inspect", + help="print a saved config and verify freshness against the local app", + ) + inspect_p.add_argument( + "--config", + default=str(DEFAULT_CONFIG_PATH), + help=f"config file to read (default: {DEFAULT_CONFIG_PATH})", + ) + inspect_p.add_argument( + "--verify", + action="store_true", + help="check freshness against an autodetected local Raycast install", + ) + inspect_p.add_argument( + "--app-path", + help=( + "path to Raycast.app for freshness verification " + "(implies --verify; failure if the path doesn't exist)" + ), + ) + inspect_p.add_argument( + "--quiet", + action="store_true", + help=( + "no output; implies --verify. Exit 0=current, 1=stale, " + "2=unverifiable (no app available)" + ), + ) + inspect_p.set_defaults(func=_cmd_inspect) + + ask_p = sub.add_parser("ask", help="run a one-shot chat completion") + ask_p.add_argument("prompt", help="the user prompt") + ask_p.add_argument( + "--config", + default=str(DEFAULT_CONFIG_PATH), + help=f"config file to read (default: {DEFAULT_CONFIG_PATH})", + ) + ask_p.add_argument( + "--bearer", help="Raycast OAuth bearer token (or set RAYCAST_BEARER)" + ) + ask_p.add_argument( + "--device-id", + help="64-hex device id (or set RAYCAST_DEVICE_ID; auto-persisted by default)", + ) + ask_p.add_argument("--model", help="model id or catalog id (or set RAYCAST_MODEL)") + ask_p.add_argument( + "--provider", + help="explicit provider (skips catalog lookup; or set RAYCAST_PROVIDER)", + ) + ask_p.add_argument( + "--stream", action="store_true", help="stream tokens to stdout as they arrive" + ) + ask_p.set_defaults(func=_cmd_ask) + + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + parser = _build_parser() + args = parser.parse_args(argv) + return int(args.func(args) or 0) + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/src/raycast_api/client/__init__.py b/src/raycast_api/client/__init__.py new file mode 100644 index 0000000..4d4fc8c --- /dev/null +++ b/src/raycast_api/client/__init__.py @@ -0,0 +1,21 @@ +"""HTTP client and SSE streaming for the Raycast backend.""" + +from __future__ import annotations + +from raycast_api.client.http import Client +from raycast_api.client.retry import ( + DEFAULT_RETRY_STATUSES, + RetryPolicy, + parse_retry_after, +) +from raycast_api.client.streaming import SSEEvent, SSEParser, iter_sse + +__all__ = [ + "DEFAULT_RETRY_STATUSES", + "Client", + "RetryPolicy", + "SSEEvent", + "SSEParser", + "iter_sse", + "parse_retry_after", +] diff --git a/src/raycast_api/client/http.py b/src/raycast_api/client/http.py new file mode 100644 index 0000000..5b60158 --- /dev/null +++ b/src/raycast_api/client/http.py @@ -0,0 +1,436 @@ +"""Async HTTP client for the Raycast backend. + +`Client` is the single entry point used by the higher-level endpoint +wrappers (Phase 5). It: + + - owns (or borrows) an `aiohttp.ClientSession`, + - builds the full Raycast header set on every request — Bearer + + User-Agent + the four `X-Raycast-*` signing headers + optional + `Last-Event-ID` for resume + WebView fluff, + - delegates signature computation to a `Signer` constructed from the + `Config.signing_spec`, + - retries 429/5xx with exponential backoff (one retry per attempt + re-signs with a fresh timestamp), + - maps server errors to typed exceptions, + - exposes `stream(...)` as an async generator over parsed SSE events. + +Streaming responses are NOT auto-retried. Once the first chunk has reached +the caller, replaying the request would duplicate output; resume via +`Last-Event-ID` (see `is_resume=True`) is the supported recovery path. +""" + +from __future__ import annotations + +import asyncio +import json +import time +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING, Any, Self + +import aiohttp + +from raycast_api.client.retry import RetryPolicy, parse_retry_after +from raycast_api.client.streaming import SSEEvent, SSEParser +from raycast_api.errors import ( + AuthError, + HTTPStatusError, + RateLimitError, + StreamError, + TransportError, +) +from raycast_api.signing import Signer + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable + + from raycast_api.ai.chat import ChatAPI + from raycast_api.ai.files import FilesAPI + from raycast_api.ai.me import MeAPI + from raycast_api.ai.models import ModelsAPI, ModelsResponse + from raycast_api.config import Config + +_BROWSER_FLUFF: dict[str, str] = { + "Accept": "*/*", + "Origin": "file://", + "Sec-Fetch-Site": "cross-site", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Dest": "empty", +} + + +class Client: + """Signed HTTP client over `aiohttp`. + + Construction: + + async with Client( + config=cfg, + bearer_token="rca_...", + device_id="<64 hex>", + ) as client: + async with client.request("GET", "/api/v1/me", sign=False) as resp: + me = await resp.json() + + `device_id` is opaque to the server but must be a stable 64-char hex + string per install (Phase 6 CLI will generate one); for tests anything + that's 64 hex chars works. + + `bearer_token` is the user's OAuth access token. Pass an empty string + to send requests without `Authorization` — useful for endpoints that + accept anonymous calls (none observed so far, but kept open). + """ + + def __init__( + self, + *, + config: Config, + bearer_token: str, + device_id: str, + session: aiohttp.ClientSession | None = None, + signer: Signer | None = None, + retry: RetryPolicy | None = None, + locale: str = "en-US", + browser_headers: bool = True, + models: ModelsResponse | None = None, + clock: Callable[[], int] | None = None, + sleep: Callable[[float], Awaitable[None]] | None = None, + ) -> None: + self.config = config + self.bearer_token = bearer_token + self.device_id = device_id + self.locale = locale + self.browser_headers = browser_headers + self._retry = retry or RetryPolicy() + self._clock = clock or (lambda: int(time.time())) + self._sleep = sleep or asyncio.sleep + self._signer = signer or Signer( + spec=config.signing_spec, secret=config.signature_secret + ) + self._session = session + self._owned_session = session is None + self._chat: ChatAPI | None = None + self._models: ModelsAPI | None = None + self._me: MeAPI | None = None + self._files: FilesAPI | None = None + self._models_catalog: ModelsResponse | None = models + self._models_catalog_lock: asyncio.Lock | None = None + + @property + def chat(self) -> ChatAPI: + """Chat completions API.""" + if self._chat is None: + from raycast_api.ai.chat import ChatAPI + + self._chat = ChatAPI(self) + return self._chat + + @property + def models(self) -> ModelsAPI: + """Models catalog API.""" + if self._models is None: + from raycast_api.ai.models import ModelsAPI + + self._models = ModelsAPI(self) + return self._models + + @property + def me(self) -> MeAPI: + """Account info API.""" + if self._me is None: + from raycast_api.ai.me import MeAPI + + self._me = MeAPI(self) + return self._me + + @property + def files(self) -> FilesAPI: + """File upload API.""" + if self._files is None: + from raycast_api.ai.files import FilesAPI + + self._files = FilesAPI(self) + return self._files + + async def _get_models_catalog(self) -> ModelsResponse: + """Return the cached `/ai/models` response, fetching it once on first use. + + Used by `ChatAPI._resolve_model` to look up the wire `model` + `provider` + for a caller-supplied string id when no `provider=` was passed. Single + round-trip per Client lifetime; subsequent calls reuse the cache. + """ + if self._models_catalog is not None: + return self._models_catalog + if self._models_catalog_lock is None: + self._models_catalog_lock = asyncio.Lock() + async with self._models_catalog_lock: + if self._models_catalog is None: + self._models_catalog = await self.models.list() + return self._models_catalog + + def invalidate_models_cache(self) -> None: + """Drop the cached models catalog so the next resolution re-fetches it.""" + self._models_catalog = None + + + async def __aenter__(self) -> Self: + if self._session is None: + self._session = aiohttp.ClientSession() + self._owned_session = True + return self + + async def __aexit__(self, *exc_info: object) -> None: + await self.close() + + async def close(self) -> None: + """Close the owned session, if any. Idempotent.""" + if self._owned_session and self._session is not None: + await self._session.close() + self._session = None + + + def _url(self, path: str) -> str: + if path.startswith(("http://", "https://")): + return path + if not path.startswith("/"): + path = "/" + path + return self.config.backend_url + path + + def build_headers( + self, + *, + sign: bool, + body: bytes, + is_resume: bool = False, + last_event_id: str | None = None, + content_type: str | None = None, + timestamp: str | None = None, + extra: dict[str, str] | None = None, + ) -> dict[str, str]: + """Compose the full header set for one outgoing request. + + Mirrors the captured curl in `_extracted/captures/request_simple.curl.txt` + when called with the defaults the Phase 5 chat API will pass. + + Exposed (rather than tucked inside `request()`) so tests can pin the + exact set without hitting the wire, and so a caller doing something + unusual — e.g. signing a multipart upload — can build their own + request from these primitives. + """ + headers: dict[str, str] = {"User-Agent": self.config.user_agent} + if self.bearer_token: + headers["Authorization"] = f"Bearer {self.bearer_token}" + if self.browser_headers: + headers.update(_BROWSER_FLUFF) + headers["Accept-Language"] = self.locale + if content_type is not None and not is_resume: + headers["Content-Type"] = content_type + if sign: + ts = timestamp if timestamp is not None else str(self._clock()) + sig = self._signer.sign(timestamp=ts, device_id=self.device_id, body=body) + headers["X-Raycast-Timestamp"] = ts + headers["X-Raycast-DeviceId"] = self.device_id + headers["X-Raycast-Signature-v2"] = sig + headers["X-Raycast-Experimental"] = self.config.experimental_header + if last_event_id is not None: + headers["Last-Event-ID"] = last_event_id + if extra: + headers.update(extra) + return headers + + @staticmethod + def _coerce_body( + body: bytes | str | None, json_body: Any + ) -> tuple[bytes, str | None]: + """Return (body_bytes, content_type_or_None).""" + if json_body is not None: + if body is not None: + msg = "pass either `body` or `json_body`, not both" + raise ValueError(msg) + return ( + json.dumps(json_body, separators=(",", ":"), ensure_ascii=False).encode( + "utf-8" + ), + "application/json", + ) + if body is None: + return b"", None + if isinstance(body, str): + return body.encode("utf-8"), "application/json" + return bytes(body), "application/json" + + + @asynccontextmanager + async def request( + self, + method: str, + path: str, + *, + body: bytes | str | None = None, + json_body: Any = None, + sign: bool = True, + is_resume: bool = False, + last_event_id: str | None = None, + headers: dict[str, str] | None = None, + params: dict[str, Any] | None = None, + retry: RetryPolicy | None = None, + ) -> AsyncGenerator[aiohttp.ClientResponse, None]: + """Send one signed request and yield the open response. + + Use as an async context manager so the response body is released + even if the caller raises: + + async with client.request("POST", "/api/v1/ai/files", + json_body=blob) as resp: + data = await resp.json() + + Retries 429 and 5xx according to `retry` (or `self._retry` by + default), re-signing each attempt with a fresh timestamp. The + retry loop is unaware of streaming bodies — the streaming code + path goes through `request()` too but only retries on the + connection-establishment failures, not mid-stream. + """ + body_bytes, content_type = self._coerce_body(body, json_body) + policy = retry or self._retry + url = self._url(path) + + attempt = 0 + while True: + attempt += 1 + hdrs = self.build_headers( + sign=sign, + body=body_bytes, + is_resume=is_resume, + last_event_id=last_event_id, + content_type=content_type, + extra=headers, + ) + session = self._require_session() + + delay: float + try: + async with session.request( + method, url, data=body_bytes or None, headers=hdrs, params=params + ) as resp: + if resp.status < 400: + yield resp + return + err = await self._build_status_error(resp) + if not policy.should_retry(attempt, resp.status): + raise err + delay = policy.delay_for_attempt(attempt, err.retry_after) + except aiohttp.ClientError as e: + if attempt >= policy.max_attempts: + raise TransportError(str(e)) from e + delay = policy.delay_for_attempt(attempt, None) + + await self._sleep(delay) + + + async def stream( + self, + method: str, + path: str, + *, + body: bytes | str | None = None, + json_body: Any = None, + sign: bool = True, + is_resume: bool = False, + last_event_id: str | None = None, + headers: dict[str, str] | None = None, + params: dict[str, Any] | None = None, + on_last_event_id: Callable[[str], None] | None = None, + raise_on_error_event: bool = True, + ) -> AsyncIterator[SSEEvent]: + """Open an SSE request and yield each parsed event. + + Termination semantics: + + - The async generator naturally stops when the response body + closes. Raycast's terminators (`event: complete` and the legacy + `data: [DONE]`) are yielded to the caller but don't break the + loop here — letting the caller see them lets it distinguish + "stream ended cleanly" from "connection died mid-way". + - `event: error` chunks are yielded as `SSEEvent` and, if + `raise_on_error_event` is True (default), also raise + `StreamError` immediately after — but the consumer's `async for` + will have already seen the error event in the previous yield. + - `on_last_event_id` is called with the latest `id:` value each + time it advances. Useful for callers that want to checkpoint + for resume without keeping a reference to the parser. + """ + async with self.request( + method, + path, + body=body, + json_body=json_body, + sign=sign, + is_resume=is_resume, + last_event_id=last_event_id, + headers=headers, + params=params, + retry=self._retry, + ) as resp: + parser = SSEParser() + try: + async for chunk in resp.content.iter_any(): + for evt in parser.feed(chunk): + if evt.id is not None and on_last_event_id is not None: + on_last_event_id(evt.id) + yield evt + if evt.is_error and raise_on_error_event: + payload: Any + try: + payload = evt.json() + except (json.JSONDecodeError, ValueError): + payload = None + raise StreamError(payload, raw=evt.data) + for evt in parser.flush(): + if evt.id is not None and on_last_event_id is not None: + on_last_event_id(evt.id) + yield evt + if evt.is_error and raise_on_error_event: + try: + payload = evt.json() + except (json.JSONDecodeError, ValueError): + payload = None + raise StreamError(payload, raw=evt.data) + except aiohttp.ClientError as e: + raise TransportError(str(e)) from e + + + def _require_session(self) -> aiohttp.ClientSession: + if self._session is None: + msg = ( + "Client.session not initialised; use `async with Client(...)` " + "or pass an explicit aiohttp.ClientSession." + ) + raise RuntimeError( + msg + ) + return self._session + + async def _build_status_error( + self, resp: aiohttp.ClientResponse + ) -> HTTPStatusError: + try: + body_bytes = await resp.read() + except aiohttp.ClientError: # pragma: no cover — defensive + body_bytes = b"" + body_text = body_bytes.decode("utf-8", errors="replace") + retry_after = parse_retry_after(resp.headers.get("Retry-After")) + headers_dict = dict(resp.headers.items()) + message = resp.reason or "" + cls: type[HTTPStatusError] + if resp.status == 401: + cls = AuthError + elif resp.status == 429: + cls = RateLimitError + else: + cls = HTTPStatusError + return cls( + resp.status, + message, + body=body_text, + retry_after=retry_after, + headers=headers_dict, + ) diff --git a/src/raycast_api/client/retry.py b/src/raycast_api/client/retry.py new file mode 100644 index 0000000..3144749 --- /dev/null +++ b/src/raycast_api/client/retry.py @@ -0,0 +1,85 @@ +"""Retry policy for the HTTP client. + +The Raycast backend doesn't document its rate limits, but the standard +HTTP conventions apply: 429 means slow down (and may carry a `Retry-After` +header), 5xx means transient server error worth retrying once or twice. + +Kept as a pure helper so it's trivial to unit-test without aiohttp in the +loop — the `Client` consults `RetryPolicy.should_retry(...)` and +`RetryPolicy.delay_for_attempt(...)` and does the actual sleeping itself. +Streaming responses are NOT retried automatically: once we've started +yielding SSE events to the caller, replaying the request would duplicate +output. The caller is expected to use the resume mechanism (Last-Event-ID) +for that case, which is a different code path. +""" + +from __future__ import annotations + +import email.utils +import time +from dataclasses import dataclass + +DEFAULT_RETRY_STATUSES: frozenset[int] = frozenset({408, 425, 429, 500, 502, 503, 504}) + + +@dataclass(frozen=True) +class RetryPolicy: + """Exponential backoff schedule. + + Total wall-clock budget for a request is roughly + `initial * (multiplier^max_attempts - 1) / (multiplier - 1)`; with the + defaults that's about 7 seconds across 4 attempts, which is the right + order of magnitude for an interactive call. + """ + + max_attempts: int = 4 + initial_delay: float = 0.5 + max_delay: float = 30.0 + multiplier: float = 2.0 + retry_statuses: frozenset[int] = DEFAULT_RETRY_STATUSES + respect_retry_after: bool = True + + def should_retry(self, attempt: int, status: int) -> bool: + """`attempt` is 1-indexed (the attempt that just failed).""" + if attempt >= self.max_attempts: + return False + return status in self.retry_statuses + + def delay_for_attempt( + self, attempt: int, retry_after: float | None = None + ) -> float: + """Delay before attempt N+1 (where `attempt` is the attempt that failed). + + `retry_after`, when not None and `respect_retry_after` is True, takes + precedence over the backoff schedule — but we still clamp to + `max_delay` so a hostile / buggy server can't park the client. + """ + if retry_after is not None and self.respect_retry_after: + return max(0.0, min(retry_after, self.max_delay)) + delay = self.initial_delay * (self.multiplier ** (attempt - 1)) + return min(delay, self.max_delay) + + +def parse_retry_after(value: str | None, *, now: float | None = None) -> float | None: + """Parse a `Retry-After` header value into seconds. + + The spec allows either an integer "delay seconds" form or an HTTP-date. + Returns None if the header is missing or unparseable. Negative results + (clock skew, past dates) are clamped to 0. + """ + if not value: + return None + value = value.strip() + try: + return max(0.0, float(value)) + except ValueError: + pass + try: + parsed = email.utils.parsedate_to_datetime(value) + except (ValueError, TypeError): + return None + if parsed is None: + return None + target = parsed.timestamp() + current = now if now is not None else time.time() + return max(0.0, target - current) diff --git a/src/raycast_api/client/streaming.py b/src/raycast_api/client/streaming.py new file mode 100644 index 0000000..31a2b0e --- /dev/null +++ b/src/raycast_api/client/streaming.py @@ -0,0 +1,171 @@ +r"""SSE parser for the Raycast streaming endpoint. + +The wire format is the standard W3C `text/event-stream`, reduced to what +the Raycast backend actually emits (per `BUNDLE_NOTES.md` §SSE): + +- Line endings: `\n` or `\r\n` (both stripped). +- `:` prefix → comment, ignored. +- Field separator: first `:` on the line; one optional space after the colon + is stripped from the value. +- Recognised fields: `id`, `event`, `data`. Multiple `data` lines per event + are joined with `\n`. Unknown fields are dropped. +- Event boundary: an empty line. EOF flushes any pending event. + +The parser is byte-stream-driven (`SSEParser.feed(chunk_bytes)`) so it can +sit directly behind `aiohttp.StreamResponse.content` and not care about +chunk boundaries — a line can be split across two TCP chunks and the parser +still emits the right event. + +It tracks `last_event_id` for caller-driven resume bookkeeping: every event +that carries an `id:` field updates the attribute, and that's the value the +caller should pass back in `Last-Event-ID` on the resume GET. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import AsyncIterable, AsyncIterator, Iterable + + +@dataclass(frozen=True) +class SSEEvent: + r"""One parsed event from the stream. + + - `id`: the `id:` field if present on any line of the event, else None. + - `event`: the `event:` field. None means the default event (most chunks). + Raycast uses `complete` for the terminator and `error` for failures. + - `data`: the `data:` payload as a string. If the server sent multiple + `data:` lines, they're joined with `\n`. Empty string when no `data:` + line was sent (rare — typically heartbeat comments use the `:` form + instead and don't produce an event at all). + """ + + id: str | None + event: str | None + data: str + + def json(self) -> Any: + """Parse `data` as JSON. Raises `json.JSONDecodeError` on bad payloads.""" + return json.loads(self.data) + + @property + def is_terminal(self) -> bool: + """True for the two end-of-stream markers Raycast emits. + + - `event: complete` with `data: {"complete":true}` — production form + - `data: [DONE]` (no event) — legacy form, still supported by client + """ + if self.event == "complete": + return True + return self.event is None and self.data.strip() == "[DONE]" + + @property + def is_error(self) -> bool: + return self.event == "error" + + +class SSEParser: + """Stateful byte-stream SSE parser. + + Feed it bytes as they arrive; it yields `SSEEvent`s when complete events + are buffered. Holds line + event state across `.feed()` calls so chunk + boundaries are transparent. + """ + + __slots__ = ("_buf", "_data", "_event", "_id", "last_event_id") + + def __init__(self) -> None: + self._buf = bytearray() + self._id: str | None = None + self._event: str | None = None + self._data: list[str] = [] + self.last_event_id: str | None = None + + def feed(self, chunk: bytes) -> Iterable[SSEEvent]: + """Push bytes into the parser; yield any events that completed.""" + if not chunk: + return + self._buf.extend(chunk) + while True: + nl = self._buf.find(b"\n") + if nl == -1: + return + raw = bytes(self._buf[:nl]) + del self._buf[: nl + 1] + if raw.endswith(b"\r"): + raw = raw[:-1] + event = self._consume_line(raw) + if event is not None: + yield event + + def flush(self) -> Iterable[SSEEvent]: + """Yield any pending event at EOF. + + The W3C spec says EOF without a trailing blank line does NOT dispatch + the pending event, but Raycast's parser (`Rkt` in the bundle) does + flush — matching that behavior keeps us robust to abruptly-closed + connections that hold the last chunk. + """ + if self._buf: + raw = bytes(self._buf) + self._buf.clear() + if raw.endswith(b"\r"): + raw = raw[:-1] + self._consume_line(raw) + if self._data or self._event is not None or self._id is not None: + yield self._dispatch() + + + def _consume_line(self, raw: bytes) -> SSEEvent | None: + if not raw: + if self._data or self._event is not None or self._id is not None: + return self._dispatch() + return None + + line = raw.decode("utf-8", errors="replace") + + if line.startswith(":"): + return None + + if ":" in line: + field_name, _, value = line.partition(":") + value = value.removeprefix(" ") + else: + field_name, value = line, "" + + if field_name == "data": + self._data.append(value) + elif field_name == "event": + self._event = value + elif field_name == "id": + self._id = value + self.last_event_id = value + return None + + def _dispatch(self) -> SSEEvent: + data = "\n".join(self._data) + event = SSEEvent(id=self._id, event=self._event, data=data) + self._id = None + self._event = None + self._data = [] + return event + + +async def iter_sse(byte_stream: AsyncIterable[bytes]) -> AsyncIterator[SSEEvent]: + """Async generator: bytes → `SSEEvent`s. + + Useful when you have an aiohttp response and just want events out: + + async for evt in iter_sse(resp.content.iter_any()): + ... + """ + parser = SSEParser() + async for chunk in byte_stream: + for evt in parser.feed(chunk): + yield evt + for evt in parser.flush(): + yield evt diff --git a/src/raycast_api/config.py b/src/raycast_api/config.py new file mode 100644 index 0000000..e470c64 --- /dev/null +++ b/src/raycast_api/config.py @@ -0,0 +1,294 @@ +"""Top-level Config dataclass. + +Carries everything the runtime client needs that isn't a user credential: +the signing spec, the signing secret, the per-build constants, and a few +metadata fields. Built by `Config.discover_from_app(...)` or hand-loaded from +JSON via `Config.load(...)`. + +The split between "discovered" and "user-supplied" is: + + - Discovered (in this Config): backend URL, signing secret, signing spec, + user-agent template, app version. + - User-supplied (NOT in this Config): Bearer token, device id. These are + per-user credentials that should never be persisted alongside discovery + output. The HTTP client takes them as separate constructor args. +""" + +from __future__ import annotations + +import contextlib +import hashlib +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +from raycast_api.discovery.cache import read_json, write_json +from raycast_api.errors import ConfigError, DiscoveryError +from raycast_api.signing_spec import SigningSpec + +if TYPE_CHECKING: + from pathlib import Path + +DEFAULT_BACKEND_URL = "https://backend.raycast.com" +DEFAULT_API_PREFIX = "/api/v1" +DEFAULT_DEVICE_TAG_SALT = "xK7mQ2vLpN8wY4jR6tBfHsAeDc" +DEFAULT_OAUTH_CLIENT_ID = "FRsHICIAlyPB_v2m4tfVqHtVUS40Ieco_da0Y6zBwgA" +EXPERIMENTAL_HEADER_VALUE = "autoModels" + + +@dataclass(frozen=True) +class ConfigComparison: + """Result of comparing a saved Config against a local Raycast install. + + All three booleans need to be True for the config to be considered fully + current. In practice `launcher_matches` is the load-bearing one — it + catches secret rotation. `bundle_matches` catches JS-bundle rebuilds + (the signing spec might have moved). `app_version_matches` is + informational: it can drift independently of the hashes after a hot + patch, and a mismatch alone doesn't necessarily mean re-discovery is + required. + """ + + bundle_matches: bool + launcher_matches: bool + app_version_matches: bool + saved_bundle_hash: str + current_bundle_hash: str + saved_launcher_hash: str + current_launcher_hash: str + saved_app_version: str + current_app_version: str + + @property + def is_current(self) -> bool: + """True iff both hashes match. Version drift alone doesn't disqualify.""" + return self.bundle_matches and self.launcher_matches + + def reasons(self) -> list[str]: + """Human-readable explanations for any drift, in display order.""" + out: list[str] = [] + if not self.bundle_matches: + out.append("bundle rebuilt (signing spec may have moved)") + if not self.launcher_matches: + out.append("launcher rebuilt (secret may have rotated)") + if not self.app_version_matches: + out.append( + f"app version {self.saved_app_version!r} → {self.current_app_version!r}" + ) + return out + + +@dataclass +class Config: + """Runtime config for the Raycast client. + + `signature_secret` is the only sensitive field — keep this file readable + only by the user (`Config.save` writes with chmod 600). + """ + + signature_secret: str + signing_spec: SigningSpec + app_version: str + user_agent: str + bundle_hash: str + launcher_hash: str + backend_url: str = DEFAULT_BACKEND_URL + api_prefix: str = DEFAULT_API_PREFIX + oauth_client_id: str = DEFAULT_OAUTH_CLIENT_ID + device_tag_salt: str = DEFAULT_DEVICE_TAG_SALT + experimental_header: str = EXPERIMENTAL_HEADER_VALUE + extra: dict[str, Any] = field(default_factory=dict) + + + def cache_key(self) -> str: + """Identifier used by `DiscoveryCache` to find/replace this config. + + SHA-256 of `bundle_hash || launcher_hash`. Either side changing — + a JS bundle rebuild or a launcher rebuild that rotates the secret — + produces a fresh key and invalidates the previous cache entry. + """ + return hashlib.sha256( + (self.bundle_hash + self.launcher_hash).encode("ascii") + ).hexdigest() + + def to_dict(self) -> dict[str, Any]: + return { + "schema_version": 1, + "signature_secret": self.signature_secret, + "signing_spec": self.signing_spec.to_dict(), + "app_version": self.app_version, + "user_agent": self.user_agent, + "bundle_hash": self.bundle_hash, + "launcher_hash": self.launcher_hash, + "backend_url": self.backend_url, + "api_prefix": self.api_prefix, + "oauth_client_id": self.oauth_client_id, + "device_tag_salt": self.device_tag_salt, + "experimental_header": self.experimental_header, + "extra": dict(self.extra), + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> Config: + try: + spec_raw = data["signing_spec"] + except KeyError as e: + msg = f"Missing required field: {e}" + raise ConfigError(msg) from e + if not isinstance(spec_raw, dict): + msg = "signing_spec must be an object" + raise ConfigError(msg) + spec = SigningSpec.from_dict(spec_raw) + try: + return cls( + signature_secret=str(data["signature_secret"]), + signing_spec=spec, + app_version=str(data["app_version"]), + user_agent=str(data["user_agent"]), + bundle_hash=str(data["bundle_hash"]), + launcher_hash=str(data["launcher_hash"]), + backend_url=str(data.get("backend_url", DEFAULT_BACKEND_URL)), + api_prefix=str(data.get("api_prefix", DEFAULT_API_PREFIX)), + oauth_client_id=str( + data.get("oauth_client_id", DEFAULT_OAUTH_CLIENT_ID) + ), + device_tag_salt=str( + data.get("device_tag_salt", DEFAULT_DEVICE_TAG_SALT) + ), + experimental_header=str( + data.get("experimental_header", EXPERIMENTAL_HEADER_VALUE) + ), + extra=dict(data.get("extra", {}) or {}), + ) + except KeyError as e: + msg = f"Missing required field: {e}" + raise ConfigError(msg) from e + + def save(self, path: Path) -> None: + write_json(path, self.to_dict()) + with contextlib.suppress(OSError): + path.chmod(0o600) + + @classmethod + def load(cls, path: Path) -> Config: + return cls.from_dict(read_json(path)) + + + @classmethod + def discover_from_app( + cls, + app_path: Path, + *, + use_cache: bool = True, + platform_version: str | None = None, + ) -> Config: + """Run the full discovery pipeline against a local Raycast install. + + Steps (all in `raycast_api.discovery`): + + 1. Locate `Contents/Resources/.../backend/index.mjs` and hash it. + 2. If `use_cache`, look up the cached Config by hash and return it. + 3. Extract `signature_secret` from the launcher binary. + 4. Parse the bundle and derive the `SigningSpec` structurally. + 5. Read `CFBundleShortVersionString` from Info.plist for the + User-Agent template. + 6. Persist into the cache. + + Returns the Config. Raises `DiscoveryError` if any step can't find + what it expects (likely meaning Raycast changed its layout). + """ + from raycast_api.discovery.binary import find_signature_secret, launcher_hash + from raycast_api.discovery.bundle import ( + bundle_hash, + find_index_mjs, + read_bundle_source, + ) + from raycast_api.discovery.cache import DiscoveryCache + from raycast_api.discovery.extractors import ( + extract_signing_spec, + extract_user_agent_template, + read_app_version, + ) + + if not app_path.is_dir(): + msg = f"app_path is not a directory: {app_path}" + raise DiscoveryError(msg) + + index_mjs = find_index_mjs(app_path) + bhash = bundle_hash(index_mjs) + lhash = launcher_hash(app_path) + combined_key = hashlib.sha256((bhash + lhash).encode("ascii")).hexdigest() + + cache = DiscoveryCache() if use_cache else None + if cache is not None: + cached = cache.get(combined_key) + if cached is not None: + return cached + + secret = find_signature_secret(app_path) + spec = extract_signing_spec(read_bundle_source(index_mjs)) + version = read_app_version(app_path) + ua = extract_user_agent_template(app_path, platform_version=platform_version) + + config = cls( + signature_secret=secret, + signing_spec=spec, + app_version=version, + user_agent=ua, + bundle_hash=bhash, + launcher_hash=lhash, + ) + + if cache is not None: + with contextlib.suppress(OSError): + cache.set(combined_key, config) + + return config + + + def compare_with_app(self, app_path: Path) -> ConfigComparison: + """Re-hash the local Raycast install and compare to the saved config. + + Returns a `ConfigComparison` with per-field booleans plus the current + and saved hashes side-by-side, suitable for printing. Raises + `DiscoveryError` if the local app can't be hashed at all (missing + bundle / unreadable launcher) — callers that want to report "unknown" + instead of an error should catch it themselves. + + This does NOT re-run the AST extractor; it only re-hashes. A bundle + whose hash matches the saved value is assumed to have the same + signing spec — that's the whole premise of the discovery cache. + """ + from raycast_api.discovery.binary import launcher_hash + from raycast_api.discovery.bundle import bundle_hash, find_index_mjs + from raycast_api.discovery.extractors import read_app_version + + if not app_path.is_dir(): + msg = f"app_path is not a directory: {app_path}" + raise DiscoveryError(msg) + current_bundle = bundle_hash(find_index_mjs(app_path)) + current_launcher = launcher_hash(app_path) + try: + current_version = read_app_version(app_path) + except DiscoveryError: + current_version = "" + return ConfigComparison( + bundle_matches=current_bundle == self.bundle_hash, + launcher_matches=current_launcher == self.launcher_hash, + app_version_matches=current_version == self.app_version, + saved_bundle_hash=self.bundle_hash, + current_bundle_hash=current_bundle, + saved_launcher_hash=self.launcher_hash, + current_launcher_hash=current_launcher, + saved_app_version=self.app_version, + current_app_version=current_version, + ) + + def is_current_for(self, app_path: Path) -> bool: + """Convenience wrapper: True iff both hashes match the local install.""" + return self.compare_with_app(app_path).is_current + + def redacted_secret(self) -> str: + """Return a masked form of the secret (last 4 chars) for display.""" + if len(self.signature_secret) <= 4: + return "*" * len(self.signature_secret) + return "…" + self.signature_secret[-4:] diff --git a/src/raycast_api/discovery/__init__.py b/src/raycast_api/discovery/__init__.py new file mode 100644 index 0000000..f546459 --- /dev/null +++ b/src/raycast_api/discovery/__init__.py @@ -0,0 +1,5 @@ +"""Discovery — extract signing config from a local Raycast install at runtime. + +Submodules are imported lazily so partial-import scenarios (during package +construction or partial-install tests) don't blow up. +""" diff --git a/src/raycast_api/discovery/ast_parse.py b/src/raycast_api/discovery/ast_parse.py new file mode 100644 index 0000000..df4e48c --- /dev/null +++ b/src/raycast_api/discovery/ast_parse.py @@ -0,0 +1,380 @@ +"""JavaScript AST utilities for structural function matching. + +The Raycast Node bundle is 4 MB of minified, modern ES (import.meta, class +fields, etc.) and the Python `esprima` port — being a 2018-era ES2017 parser — +cannot parse it whole. We work around that by: + + 1. Scanning the source byte-by-byte (string/comment/regex-aware) for + `function NAME(...)` and `async function NAME(...)` *declarations* and + extracting each function's complete source via brace matching. + 2. Parsing each candidate's source — small, syntactically vanilla — with + esprima individually. + 3. Applying structural matchers on the resulting AST. + +This is enough for our needs (locate signing fn + rot fn), avoids depending on +a separate Node.js parse step, and survives the bundle being re-minified or +rearranged between releases. The structural patterns we match against are +documented in `BUNDLE_NOTES.md` §2. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +import esprima + +from raycast_api.errors import DiscoveryError + +if TYPE_CHECKING: + from collections.abc import Callable, Iterator + + +@dataclass +class FunctionInfo: + """Located function declaration with its AST and source snippet. + + `ast` is the dict form of esprima's FunctionDeclaration node — we use the + plain-dict form (via `toDict()`) throughout so downstream matchers don't + need to know esprima's object model. + """ + + name: str + is_async: bool + params: list[str] + source: str + """Full function source, from `function`/`async function` keyword through + the closing `}`.""" + + body_source: str + """Body source only, including the surrounding `{}`.""" + + ast: dict[str, Any] + """esprima dict for the FunctionDeclaration node.""" + + start: int + """Byte offset of the keyword in the original source.""" + + + +_DECL_RE = re.compile( + r"(?:^|[^A-Za-z0-9_$])(async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(" +) + + +def iter_function_declarations(source: str) -> Iterator[FunctionInfo]: + """Yield every `function NAME(...){...}` declaration found in `source`. + + Each FunctionInfo is parsed individually with esprima; declarations whose + bodies fail to parse are skipped (useful: the bundle contains some odd + minifier output that's syntactically borderline — we don't care about those + anyway because they're not our signing fn). + """ + for keyword_start, body_start in _iter_decl_positions(source): + end = _find_matching_brace(source, body_start) + if end == -1: + continue + snippet = source[keyword_start : end + 1] + info = _try_parse_function(snippet, keyword_start) + if info is not None: + yield info + + +def _iter_decl_positions(source: str) -> Iterator[tuple[int, int]]: + """Yield (keyword_start, body_open_brace) offsets for each function declaration.""" + for match in _DECL_RE.finditer(source): + if match.group(1) is not None: + kw_start = match.start(1) + else: + kw_start = source.rindex("function", match.start(), match.end()) + paren_end = _find_matching_paren(source, match.end() - 1) + if paren_end == -1: + continue + i = paren_end + 1 + while i < len(source) and source[i] in " \t\r\n": + i += 1 + if i >= len(source) or source[i] != "{": + continue + yield kw_start, i + + +def _try_parse_function(snippet: str, abs_start: int) -> FunctionInfo | None: + try: + tree = esprima.parseScript(snippet, {"tolerant": False}) + except esprima.Error: + return None + if not tree.body or tree.body[0].type != "FunctionDeclaration": + return None + fn_node = tree.body[0] + ast_dict = fn_node.toDict() + params: list[str] = [] + for p in ast_dict.get("params", []): + if p.get("type") == "Identifier": + params.append(p["name"]) + else: + params.append("") + body_open = snippet.index("{") + body_close = _find_matching_brace(snippet, body_open) + body_source = snippet[body_open : body_close + 1] if body_close != -1 else "" + return FunctionInfo( + name=ast_dict.get("id", {}).get("name", ""), + is_async=bool(ast_dict.get("async", False)), + params=params, + source=snippet, + body_source=body_source, + ast=ast_dict, + start=abs_start, + ) + + + + +def _find_matching_brace(source: str, open_pos: int) -> int: + """Return the index of the `}` that matches the `{` at `open_pos`, or -1. + + Tracks string literals (`"`, `'`, template literals), block/line comments, + and regex literals so curly braces inside them don't count. + """ + return _find_matching(source, open_pos, "{", "}") + + +def _find_matching_paren(source: str, open_pos: int) -> int: + return _find_matching(source, open_pos, "(", ")") + + +def _find_matching(source: str, open_pos: int, open_ch: str, close_ch: str) -> int: + if source[open_pos] != open_ch: + msg = f"expected {open_ch!r} at position {open_pos}, got {source[open_pos]!r}" + raise ValueError(msg) + n = len(source) + depth = 0 + i = open_pos + prev_significant = "" + while i < n: + ch = source[i] + + if ch in ("'", '"'): + i = _skip_string(source, i, ch) + prev_significant = ch + continue + + if ch == "`": + i = _skip_template(source, i) + prev_significant = "`" + continue + + if ch == "/" and i + 1 < n: + nxt = source[i + 1] + if nxt == "/": + i = source.find("\n", i + 2) + if i == -1: + return -1 + i += 1 + continue + if nxt == "*": + end = source.find("*/", i + 2) + if end == -1: + return -1 + i = end + 2 + continue + if _can_start_regex(prev_significant): + i = _skip_regex(source, i) + prev_significant = "/" + continue + + if ch == open_ch: + depth += 1 + elif ch == close_ch: + depth -= 1 + if depth == 0: + return i + + if not ch.isspace(): + prev_significant = ch + i += 1 + return -1 + + +def _skip_string(source: str, i: int, quote: str) -> int: + n = len(source) + j = i + 1 + while j < n: + c = source[j] + if c == "\\": + j += 2 + continue + if c == quote: + return j + 1 + if c == "\n": + return j + 1 + j += 1 + return n + + +def _skip_template(source: str, i: int) -> int: + """Skip a template literal `` `...${expr}...` `` starting at backtick `i`.""" + n = len(source) + j = i + 1 + while j < n: + c = source[j] + if c == "\\": + j += 2 + continue + if c == "`": + return j + 1 + if c == "$" and j + 1 < n and source[j + 1] == "{": + close = _find_matching_brace(source, j + 1) + if close == -1: + return n + j = close + 1 + continue + j += 1 + return n + + +def _skip_regex(source: str, i: int) -> int: + """Skip a `/regex/flags` literal starting at the `/` at index `i`.""" + n = len(source) + j = i + 1 + in_class = False + while j < n: + c = source[j] + if c == "\\": + j += 2 + continue + if c == "[": + in_class = True + elif c == "]": + in_class = False + elif c == "/" and not in_class: + j += 1 + while j < n and source[j].isalpha(): + j += 1 + return j + elif c == "\n": + return j + j += 1 + return n + + +def _can_start_regex(prev: str) -> bool: + if prev == "": + return True + return not (prev.isalnum() or prev in "_$)]") + + + + +def find_function_by_shape( + functions: list[FunctionInfo], + *, + is_async: bool | None = None, + param_count: int | None = None, + body_contains_all: list[str] | None = None, + body_contains_any: list[str] | None = None, + name_equals: str | None = None, + custom: list[Callable[[FunctionInfo], bool]] | None = None, +) -> list[FunctionInfo]: + """Filter `functions` to those matching all supplied predicates. + + Substring matching on body source is intentional: minifiers rename + identifiers but they preserve string literals, numeric literals, and + standard library identifiers like `crypto.subtle`. The structural + fingerprints in `BUNDLE_NOTES.md` are expressed in terms of these stable + substrings. + + For more specific structural checks (e.g. "this function calls X"), + pass a `custom` predicate that walks `fn.ast`. + """ + results: list[FunctionInfo] = [] + for fn in functions: + if is_async is not None and fn.is_async != is_async: + continue + if param_count is not None and len(fn.params) != param_count: + continue + if name_equals is not None and fn.name != name_equals: + continue + if body_contains_all and not all( + s in fn.body_source for s in body_contains_all + ): + continue + if body_contains_any and not any( + s in fn.body_source for s in body_contains_any + ): + continue + if custom and not all(p(fn) for p in custom): + continue + results.append(fn) + return results + + +def walk_ast(node: Any) -> Iterator[dict[str, Any]]: + """Depth-first iterator over every dict-shaped AST node.""" + if isinstance(node, dict): + yield node + for v in node.values(): + yield from walk_ast(v) + elif isinstance(node, list): + for v in node: + yield from walk_ast(v) + + +def find_calls(fn: FunctionInfo, callee_name: str) -> list[dict[str, Any]]: + """Return CallExpression nodes whose callee is the identifier `callee_name`. + + Also matches member-expression callees like `obj.callee_name` — uses the + `property.name` in that case. This is liberal on purpose: minifier-output + sometimes has the callee referenced via short helper variables, and we'd + rather over-match here and let the outer predicate verify. + """ + out: list[dict[str, Any]] = [] + for node in walk_ast(fn.ast): + if not (isinstance(node, dict) and node.get("type") == "CallExpression"): + continue + callee = node.get("callee", {}) + if callee.get("type") == "Identifier" and callee.get("name") == callee_name: + out.append(node) + elif callee.get("type") == "MemberExpression": + prop = callee.get("property", {}) + if prop.get("type") == "Identifier" and prop.get("name") == callee_name: + out.append(node) + return out + + +def has_string_literal(fn: FunctionInfo, value: str) -> bool: + """True if a string literal with the given value appears in the function body.""" + for node in walk_ast(fn.ast): + if ( + isinstance(node, dict) + and node.get("type") == "Literal" + and node.get("value") == value + ): + return True + return False + + +def collect_numeric_literals(fn: FunctionInfo) -> set[int]: + """Return the set of integer literal values used inside the function.""" + out: set[int] = set() + for node in walk_ast(fn.ast): + if not (isinstance(node, dict) and node.get("type") == "Literal"): + continue + v = node.get("value") + if isinstance(v, (int, float)) and float(v).is_integer(): + out.add(int(v)) + return out + + +def assert_one(matches: list[FunctionInfo], what: str) -> FunctionInfo: + """Helper that turns ambiguous match results into clear DiscoveryError messages.""" + if not matches: + msg = f"No function matched: {what}" + raise DiscoveryError(msg) + if len(matches) > 1: + names = ", ".join(f.name for f in matches[:5]) + raise DiscoveryError( + f"Multiple functions matched {what}: {names}" + + (" ..." if len(matches) > 5 else "") + ) + return matches[0] diff --git a/src/raycast_api/discovery/binary.py b/src/raycast_api/discovery/binary.py new file mode 100644 index 0000000..0146e0a --- /dev/null +++ b/src/raycast_api/discovery/binary.py @@ -0,0 +1,91 @@ +"""Extract `window.signatureSecret = '<64 hex>'` from the Raycast launcher binary. + +The Swift launcher hardcodes the per-build signing secret as a string literal so it +can inject it into the WebView via `window.signatureSecret = '...'`. The same value +is also forwarded across UniFFI into the Rust dylib as the `Secrets` record and +appears verbatim in the binary's `__cstring` section. Scanning the raw bytes for +the literal pattern is sufficient — no Mach-O parsing required. + +Pattern observed in Raycast Beta 0.60.1.0: + + window.signatureSecret = '6bc4...1408' + +The secret is always 64 lowercase hex characters (32 bytes encoded as hex). Per +HANDOFF.md the key is used AS-IS (utf-8 of the 64 ASCII chars), not hex-decoded. +""" + +from __future__ import annotations + +import hashlib +import re +from typing import TYPE_CHECKING + +from raycast_api.errors import DiscoveryError + +if TYPE_CHECKING: + from pathlib import Path + +_SECRET_RE = re.compile(rb"window\.signatureSecret\s*=\s*(['\"])([0-9a-f]{64})\1") + + +def find_signature_secret(app_path: Path) -> str: + """Locate and return the signature secret from the app's launcher binary. + + `app_path` is the path to the .app bundle (e.g. "Raycast Beta.app"). The + launcher binary lives at `Contents/MacOS/`. + + Raises `DiscoveryError` if the binary isn't found or the pattern doesn't + match — both indicate that Raycast changed the injection mechanism. + """ + binary = _resolve_launcher_binary(app_path) + data = binary.read_bytes() + match = _SECRET_RE.search(data) + if not match: + msg = ( + f"Could not find `window.signatureSecret = ''` in {binary}. " + "Raycast may have changed how the secret is embedded." + ) + raise DiscoveryError( + msg + ) + return match.group(2).decode("ascii") + + +def launcher_hash(app_path: Path) -> str: + """SHA-256 (hex) of the launcher binary. + + The launcher carries the signing secret, which can rotate per release + without necessarily forcing a JS bundle rebuild. We include this in the + discovery cache key so a secret-only rotation invalidates the cache too. + """ + binary = _resolve_launcher_binary(app_path) + h = hashlib.sha256() + with binary.open("rb") as f: + for chunk in iter(lambda: f.read(1 << 20), b""): + h.update(chunk) + return h.hexdigest() + + +def _resolve_launcher_binary(app_path: Path) -> Path: + """Return the path to the Mach-O launcher inside an .app bundle.""" + macos_dir = app_path / "Contents" / "MacOS" + if not macos_dir.is_dir(): + msg = f"Not an app bundle (missing {macos_dir}): {app_path}" + raise DiscoveryError(msg) + + expected_name = app_path.name.removesuffix(".app") + candidate = macos_dir / expected_name + if candidate.is_file(): + return candidate + + children = [p for p in macos_dir.iterdir() if p.is_file()] + if len(children) == 1: + return children[0] + + msg = ( + f"Could not identify launcher binary in {macos_dir}; " + f"expected {expected_name!r}, found {[p.name for p in children]}" + ) + raise DiscoveryError( + msg + ) diff --git a/src/raycast_api/discovery/bundle.py b/src/raycast_api/discovery/bundle.py new file mode 100644 index 0000000..b030de0 --- /dev/null +++ b/src/raycast_api/discovery/bundle.py @@ -0,0 +1,82 @@ +"""Locate the Node backend bundle inside a Raycast .app and read its source. + +Bundle layout observed in Raycast Beta 0.60.1.0: + + Raycast Beta.app/Contents/Resources/macos-app_RaycastDesktopApp.bundle/ + Contents/Resources/backend/index.mjs + +The minified bundle is ~4 MB and contains the signing function we want to AST-match +against. We hash it for cache invalidation and read its source straight from disk — +beautification is not required for esprima (it doesn't care about whitespace), so +we skip the prettier/js-beautify step that Phase 1 used for human reading. +""" + +from __future__ import annotations + +import hashlib +from pathlib import Path + +from raycast_api.errors import DiscoveryError + +_DESKTOP_BUNDLE_NAME = "macos-app_RaycastDesktopApp.bundle" +_BACKEND_INDEX_RELATIVE = Path("Contents/Resources/backend/index.mjs") + + +def locate_node_bundle(app_path: Path) -> Path: + """Return the path to the embedded RaycastDesktopApp sub-bundle.""" + resources = app_path / "Contents" / "Resources" + direct = resources / _DESKTOP_BUNDLE_NAME + if direct.is_dir(): + return direct + + matches = list(resources.glob("*RaycastDesktopApp*.bundle")) + if len(matches) == 1: + return matches[0] + if not matches: + msg = f"No RaycastDesktopApp bundle under {resources}" + raise DiscoveryError(msg) + names = [m.name for m in matches] + msg = f"Multiple RaycastDesktopApp bundles under {resources}: {names}" + raise DiscoveryError( + msg + ) + + +def find_index_mjs(bundle_or_app_path: Path) -> Path: + """Return `backend/index.mjs` given either an app bundle or a sub-bundle. + + Accepting both lets callers pass `Raycast Beta.app` directly or a + pre-located desktop bundle (e.g. in tests). + """ + candidates = [ + bundle_or_app_path / _BACKEND_INDEX_RELATIVE, + locate_node_bundle(bundle_or_app_path) / _BACKEND_INDEX_RELATIVE + if (bundle_or_app_path / "Contents" / "Resources").is_dir() + else None, + ] + for c in candidates: + if c and c.is_file(): + return c + msg = f"Could not find backend/index.mjs starting from {bundle_or_app_path}" + raise DiscoveryError( + msg + ) + + +def read_bundle_source(index_mjs_path: Path) -> str: + """Read index.mjs and return it as a UTF-8 string.""" + return index_mjs_path.read_text(encoding="utf-8") + + +def bundle_hash(index_mjs_path: Path) -> str: + """SHA-256 (hex) of the bundle file, used as a cache key. + + Any change to the bundle — new Raycast version, hotfix, secret rotation + (the secret itself isn't in this file, but the bundle is rebuilt around it) + — produces a different hash and invalidates the cached config. + """ + h = hashlib.sha256() + with index_mjs_path.open("rb") as f: + for chunk in iter(lambda: f.read(1 << 20), b""): + h.update(chunk) + return h.hexdigest() diff --git a/src/raycast_api/discovery/cache.py b/src/raycast_api/discovery/cache.py new file mode 100644 index 0000000..64c1ae3 --- /dev/null +++ b/src/raycast_api/discovery/cache.py @@ -0,0 +1,84 @@ +"""On-disk cache of discovered configs, keyed by the bundle's SHA-256. + +Discovery is fast (~2 s on a 6 MB bundle) but not free, and we'd rather not +repeat the work on every CLI invocation. We key the cache on the bundle hash +so a Raycast update — which always rebuilds the bundle — invalidates +automatically. + +Cache files live under `$XDG_CACHE_HOME/raycast-api/` (defaulting to +`~/.cache/raycast-api/`), each named `.json`. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from raycast_api.config import Config + + +def default_cache_dir() -> Path: + """Return the cache directory, honoring XDG_CACHE_HOME if set.""" + xdg = os.environ.get("XDG_CACHE_HOME") + base = Path(xdg) if xdg else Path.home() / ".cache" + return base / "raycast-api" + + +class DiscoveryCache: + """File-hash-keyed cache for `Config` blobs.""" + + def __init__(self, root: Path | None = None) -> None: + self.root = root or default_cache_dir() + + def path_for(self, bundle_hash: str) -> Path: + return self.root / f"{bundle_hash}.json" + + def get(self, bundle_hash: str) -> Config | None: + from raycast_api.config import Config + + path = self.path_for(bundle_hash) + if not path.is_file(): + return None + try: + return Config.load(path) + except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError): + return None + + def set(self, bundle_hash: str, config: Config) -> None: + self.root.mkdir(parents=True, exist_ok=True) + config.save(self.path_for(bundle_hash)) + + def clear(self) -> None: + if not self.root.is_dir(): + return + for p in self.root.glob("*.json"): + p.unlink() + + def _all(self) -> list[Path]: + if not self.root.is_dir(): + return [] + return sorted(self.root.glob("*.json")) + + def __repr__(self) -> str: # pragma: no cover — debug aid + return f"DiscoveryCache(root={self.root!r})" + + +__all__ = ["DiscoveryCache", "default_cache_dir"] + + +def write_json(path: Path, payload: dict[str, object]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(json.dumps(payload, indent=2, sort_keys=False), encoding="utf-8") + tmp.replace(path) + + +def read_json(path: Path) -> dict[str, object]: + data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + msg = f"Expected JSON object at {path}, got {type(data).__name__}" + raise TypeError(msg) + return data diff --git a/src/raycast_api/discovery/extractors.py b/src/raycast_api/discovery/extractors.py new file mode 100644 index 0000000..9d4ee7b --- /dev/null +++ b/src/raycast_api/discovery/extractors.py @@ -0,0 +1,302 @@ +"""High-level extractors that turn a parsed bundle into a `SigningSpec`. + +The structural matchers in `ast_parse` give us candidate functions; the +extractors here verify those candidates against the documented shape of +`Sur`/`Nkt` (rot + HMAC signer) and tease out the few parameters that aren't +visually identical between minified builds: + + - rot ranges and shifts (might rotate keys differently in future builds) + - canonical-string join character (currently ".") + - body-hash algorithm (currently "SHA-256") + - HMAC hash (currently "SHA-256") + - key encoding (currently utf-8 of the hex string AS-IS, per HANDOFF.md) + +This produces a `SigningSpec` that the runtime signer consumes — `sign.py`'s +constants become `SigningSpec` fields, so the whole signing pipeline is +data-driven and re-derives on every Raycast update. +""" + +from __future__ import annotations + +import plistlib +from typing import TYPE_CHECKING, Any + +from raycast_api.discovery.ast_parse import ( + FunctionInfo, + find_calls, + find_function_by_shape, + has_string_literal, + iter_function_declarations, + walk_ast, +) +from raycast_api.errors import DiscoveryError +from raycast_api.signing_spec import RotRange, SigningSpec + +if TYPE_CHECKING: + from pathlib import Path + +__all__ = ["extract_signing_spec", "extract_user_agent_template", "read_app_version"] + + +def extract_signing_spec(bundle_source: str) -> SigningSpec: + """Find the signing primitives in the bundle and return a `SigningSpec`. + + Strategy (in order): + + 1. Enumerate all top-level function declarations. + 2. Find the rot function by the literal triplet `(65, 90, 13)`, + `(97, 122, 13)`, `(48, 57, 5)` co-located inside one 1-param fn. + Minifier renames don't touch numeric literals; these five constants + (plus 26 and 10) uniquely identify rot13+rot5. + 3. Find the signing function: an async 4-param fn that imports an HMAC key + and calls .map() on a 3-element array. The verifier here is + strict: we require it to mention the rot fn we just found by name, so + we can't accidentally pick up an unrelated HMAC routine. + 4. Read the join character from the signing fn's `.join(...)` call and + the digest/HMAC algorithm strings from the crypto.subtle calls. + + `bundle_source` is the raw JS text — we don't need a pre-beautified copy. + """ + fns = list(iter_function_declarations(bundle_source)) + if not fns: + msg = "No function declarations found in bundle source" + raise DiscoveryError(msg) + + rot, signing = _find_rot_and_signing(fns) + join_char = _extract_join_char(signing, rot_fn_name=rot.name) + digest_algo = _extract_digest_algo(signing) + hmac_algo = _extract_hmac_algo(signing) + + rot_ranges = _extract_rot_ranges(rot) + + return SigningSpec( + rot_fn_name=rot.name, + signing_fn_name=signing.name, + rot_ranges=rot_ranges, + join_char=join_char, + body_hash_algorithm=digest_algo, + hmac_algorithm=hmac_algo, + key_encoding="utf-8", + output_encoding="hex-lower", + ) + + + + +def _find_rot_and_signing(fns: list[FunctionInfo]) -> tuple[FunctionInfo, FunctionInfo]: + """Find a (rot, signing) pair where signing calls .map(rot.name). + + Several rot candidates may exist (the bundle has two byte-identical copies, + `Sur` and `Tur`). Several signing candidates similarly (`Nkt` is unique by + the 4-param shape but if a future build splits the canonical path, we want + to handle the ambiguity). We resolve by requiring the rot fn referenced by + the signing fn's `.map(...)` call to be among the rot candidates. + """ + rot_candidates = find_function_by_shape( + fns, param_count=1, custom=[_has_required_rot_triplets] + ) + if not rot_candidates: + msg = ( + "No rot13+rot5 candidate " + "(1 param, all of (65,90,13)/(97,122,13)/(48,57,5))" + ) + raise DiscoveryError(msg) + rot_by_name = {f.name: f for f in rot_candidates} + + sign_candidates = find_function_by_shape( + fns, + is_async=True, + param_count=4, + body_contains_all=["HMAC", "SHA-256", "importKey"], + custom=[ + lambda f: has_string_literal(f, "HMAC"), + lambda f: has_string_literal(f, "SHA-256"), + ], + ) + if not sign_candidates: + msg = "No signing candidate (async, 4 params, HMAC+SHA-256+importKey)" + raise DiscoveryError( + msg + ) + + pairs: list[tuple[FunctionInfo, FunctionInfo]] = [] + for sign in sign_candidates: + for name in _map_argument_identifiers(sign): + if name in rot_by_name: + pairs.append((rot_by_name[name], sign)) + break + if not pairs: + msg = ( + f"Found rot candidates {list(rot_by_name)} and signing candidates " + f"{[s.name for s in sign_candidates]} but none of the signers calls " + f".map()" + ) + raise DiscoveryError( + msg + ) + return pairs[0] + + +def _map_argument_identifiers(fn: FunctionInfo) -> list[str]: + """Return identifier names passed to any `.map(...)` call inside fn.""" + names: list[str] = [] + for node in find_calls(fn, "map"): + args = node.get("arguments", []) + if ( + len(args) == 1 + and isinstance(args[0], dict) + and args[0].get("type") == "Identifier" + ): + names.append(args[0].get("name", "")) + return names + + +def _has_required_rot_triplets(fn: FunctionInfo) -> bool: + """True iff the fn contains all three (start, end, shift) triplets as numerics. + + We don't try to parse the *structure* of the conditional chain — too many + valid shapes (if/else vs ternary vs switch). The numeric fingerprint is + enough on its own; the 1-param shape filter prevents false positives from + unrelated maths. + """ + nums = _collect_numeric_literals(fn) + needed = {65, 90, 13, 26, 97, 122, 48, 57, 5, 10} + return needed.issubset(nums) + + +def _collect_numeric_literals(fn: FunctionInfo) -> set[int]: + out: set[int] = set() + for node in walk_ast(fn.ast): + if not (isinstance(node, dict) and node.get("type") == "Literal"): + continue + v = node.get("value") + if isinstance(v, (int, float)) and float(v).is_integer(): + out.add(int(v)) + return out + + +def _extract_rot_ranges(rot: FunctionInfo) -> list[RotRange]: # noqa: ARG001 — kept for future structural derivation + """Return the rot transform parameters as a list of (start, end, shift) ranges. + + For now we hardcode the three triplets we matched against — the structural + matcher already confirmed they're present. If future builds add/remove a + range, this is the place to teach the extractor to walk the conditional chain + and discover them dynamically. + """ + return [ + RotRange(start=65, end=90, shift=13), + RotRange(start=97, end=122, shift=13), + RotRange(start=48, end=57, shift=5), + ] + + + + +def _extract_join_char(fn: FunctionInfo, rot_fn_name: str) -> str: + """Find the `.join("X")` whose receiver is `.map()`. + + The signing fn body has several `.map(...).join(...)` chains — the hex + encoder uses `.join("")`, the canonical-string builder uses `.join(".")`. + We pick the one whose `.map`'s sole argument is the rot fn identifier. + """ + for call in find_calls(fn, "join"): + callee = call.get("callee", {}) + if callee.get("type") != "MemberExpression": + continue + receiver = callee.get("object", {}) + if receiver.get("type") != "CallExpression": + continue + r_callee = receiver.get("callee", {}) + if not ( + r_callee.get("type") == "MemberExpression" + and r_callee.get("property", {}).get("name") == "map" + ): + continue + r_args = receiver.get("arguments", []) + if not ( + len(r_args) == 1 + and r_args[0].get("type") == "Identifier" + and r_args[0].get("name") == rot_fn_name + ): + continue + args = call.get("arguments", []) + if args and args[0].get("type") == "Literal": + val = args[0].get("value") + if isinstance(val, str): + return val + msg = f"Could not find `.map({rot_fn_name}).join()` in `{fn.name}`" + raise DiscoveryError( + msg + ) + + +def _extract_digest_algo(fn: FunctionInfo) -> str: + """Read the algorithm name from `crypto.subtle.digest("SHA-256", ...)`.""" + for call in find_calls(fn, "digest"): + args = call.get("arguments", []) + if args and args[0].get("type") == "Literal": + v = args[0].get("value") + if isinstance(v, str): + return v + msg = f"No crypto.subtle.digest(...) call in `{fn.name}`" + raise DiscoveryError(msg) + + +def _extract_hmac_algo(fn: FunctionInfo) -> str: + """Read the `hash:"SHA-256"` from the HMAC importKey options. + + Looks for an object literal containing { name: "HMAC", hash: "" } + inside the signing fn. That's the importKey args[2] but we don't rely on + position — we walk all ObjectExpressions and find the matching shape. + """ + for node in walk_ast(fn.ast): + if not (isinstance(node, dict) and node.get("type") == "ObjectExpression"): + continue + props: dict[str, Any] = {} + for prop in node.get("properties", []): + key = prop.get("key", {}) + value = prop.get("value", {}) + if key.get("type") == "Identifier" and value.get("type") == "Literal": + props[key["name"]] = value.get("value") + if props.get("name") == "HMAC": + hash_val = props.get("hash") + if isinstance(hash_val, str): + return hash_val + msg = f"No {{name:'HMAC', hash:'...'}} object in `{fn.name}`" + raise DiscoveryError(msg) + + + + +def read_app_version(app_path: Path) -> str: + """Return `CFBundleShortVersionString` from the app's Info.plist.""" + plist_path = app_path / "Contents" / "Info.plist" + if not plist_path.is_file(): + msg = f"Missing Info.plist at {plist_path}" + raise DiscoveryError(msg) + with plist_path.open("rb") as f: + plist = plistlib.load(f) + version = plist.get("CFBundleShortVersionString") + if not isinstance(version, str): + msg = f"No CFBundleShortVersionString in {plist_path}" + raise DiscoveryError(msg) + return version + + +def extract_user_agent_template( + app_path: Path, *, platform: str = "macOS", platform_version: str | None = None +) -> str: + """Build the `User-Agent` header Raycast sends. + + Template (BUNDLE_NOTES §6): `Raycast/ (x- Version )`. + We default platform to "macOS" because the bundle is macOS-only; future + Windows builds would need this hooked up to a platform argument. + `platform_version` defaults to the host's macOS version, looked up at call + time so a config written on one machine still serializes the host string. + """ + import platform as platform_mod + + version = read_app_version(app_path) + if platform_version is None: + platform_version = platform_mod.mac_ver()[0] or "26.0" + return f"Raycast/{version} (x-{platform} Version {platform_version})" diff --git a/src/raycast_api/errors.py b/src/raycast_api/errors.py new file mode 100644 index 0000000..36c5e81 --- /dev/null +++ b/src/raycast_api/errors.py @@ -0,0 +1,82 @@ +"""Exception types for raycast_api.""" + +from __future__ import annotations + +from typing import Any + + +class RaycastApiError(Exception): + """Base for everything raycast_api raises.""" + + +class DiscoveryError(RaycastApiError): + """Failed to derive config from a local Raycast install. + + Raised when binary parsing, bundle location, or AST extraction can't find + what they expect — typically meaning Raycast changed its layout or signing + shape and the library needs updating. + """ + + +class ConfigError(RaycastApiError): + """Loaded config is missing required fields or is internally inconsistent.""" + + +class TransportError(RaycastApiError): + """Network-level failure (DNS, connect, read timeout, dropped socket). + + Wraps the underlying aiohttp / OSError so callers don't need to import + aiohttp's exception hierarchy to catch them. The original exception is + chained via `__cause__`. + """ + + +class HTTPStatusError(RaycastApiError): + """Server responded with a non-2xx status. + + `body` is the response payload decoded as utf-8 with replacement; + `retry_after` is the parsed `Retry-After` header in seconds if the server + sent one (either as an integer or HTTP-date), otherwise None. + """ + + def __init__( + self, + status: int, + message: str, + *, + body: str = "", + retry_after: float | None = None, + headers: dict[str, str] | None = None, + ) -> None: + super().__init__(f"HTTP {status}: {message}" if message else f"HTTP {status}") + self.status = status + self.message = message + self.body = body + self.retry_after = retry_after + self.headers = headers or {} + + +class AuthError(HTTPStatusError): + """401 — bearer token rejected or signature invalid.""" + + +class RateLimitError(HTTPStatusError): + """429 — rate limited. `retry_after` is set when the server provides it.""" + + +class StreamError(RaycastApiError): + """SSE stream emitted an `event: error` chunk. + + `payload` is the parsed JSON body of the error event (or None if it + failed to parse); `raw` is the raw `data:` string for debugging. + """ + + def __init__(self, payload: Any, *, raw: str = "") -> None: + msg = "" + if isinstance(payload, dict): + msg = str(payload.get("message") or payload.get("error") or payload) + elif payload is not None: + msg = str(payload) + super().__init__(msg or "stream error") + self.payload = payload + self.raw = raw diff --git a/src/raycast_api/signing/__init__.py b/src/raycast_api/signing/__init__.py new file mode 100644 index 0000000..b8077f5 --- /dev/null +++ b/src/raycast_api/signing/__init__.py @@ -0,0 +1,79 @@ +"""Request signer composing the discovered spec with a secret. + +Usage: + + >>> from raycast_api.signing import Signer + >>> signer = Signer(spec=config.signing_spec, secret=config.signature_secret) + >>> sig = signer.sign(timestamp="1778858809", device_id="20ec…", body=b"{...}") + +The `Signer` is cheap to instantiate (it caches the encoded key + hash factory +on construction) and is safe to reuse for many requests against the same +secret; create a new one if the secret rotates. +""" + +from __future__ import annotations + +from raycast_api.signing.canonical import build_canonical +from raycast_api.signing.hmac import HMACSigner, encode_key, encode_output, hash_body +from raycast_api.signing.transforms import apply_rot +from raycast_api.signing_spec import RotRange, SigningSpec + +__all__ = [ + "HMACSigner", + "RotRange", + "Signer", + "SigningSpec", + "apply_rot", + "build_canonical", + "encode_key", + "encode_output", + "hash_body", +] + + +class Signer: + """Produce `X-Raycast-Signature-v2` values for outgoing requests. + + Constructed from a `SigningSpec` (discovered once per bundle) and a secret + (discovered once per launcher build). Holds no per-request state; the same + instance can sign any number of requests in parallel. + """ + + def __init__(self, *, spec: SigningSpec, secret: str) -> None: + if not spec.rot_ranges: + msg = "SigningSpec has no rot_ranges; discovery did not populate them" + raise ValueError( + msg + ) + self._spec = spec + self._hmac = HMACSigner( + secret, + algorithm=spec.hmac_algorithm, + key_encoding=spec.key_encoding, + output_encoding=spec.output_encoding, + ) + + @property + def spec(self) -> SigningSpec: + return self._spec + + def canonical_string(self, timestamp: str, device_id: str, body: bytes) -> str: + """Return the rot-transformed, joined canonical string (debug helper).""" + body_hex = hash_body(body, self._spec.body_hash_algorithm) + return build_canonical( + (timestamp, device_id, body_hex), + self._spec.rot_ranges, + self._spec.join_char, + ) + + def sign(self, *, timestamp: str, device_id: str, body: bytes) -> str: + """Compute the signature header value. + + - `timestamp`: decimal seconds since epoch as a string. Use the SAME + string in `X-Raycast-Timestamp` (not a re-stringified int) — the + server hashes the byte representation, not the value. + - `device_id`: lowercase 64-char hex string. + - `body`: exact request body bytes. For GET resume requests pass `b""`. + """ + canonical = self.canonical_string(timestamp, device_id, body) + return self._hmac.sign(canonical.encode("utf-8")) diff --git a/src/raycast_api/signing/canonical.py b/src/raycast_api/signing/canonical.py new file mode 100644 index 0000000..33ae2fb --- /dev/null +++ b/src/raycast_api/signing/canonical.py @@ -0,0 +1,29 @@ +"""Canonical-string assembly. + +Raycast's signing canonical string is, per `BUNDLE_NOTES.md`: + + rot(timestamp) + "." + rot(device_id) + "." + rot(sha256_hex_lower(body)) + +The components are passed in as strings (already body-hashed), each is rot- +transformed, and they're joined with `join_char`. Both the rot ranges and the +join character come from `SigningSpec` — discovery, not hardcoded. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from raycast_api.signing.transforms import apply_rot + +if TYPE_CHECKING: + from collections.abc import Iterable, Sequence + + from raycast_api.signing_spec import RotRange + + +def build_canonical( + components: Sequence[str], rot_ranges: Iterable[RotRange], join_char: str = "." +) -> str: + """Apply `rot_ranges` to each component then join with `join_char`.""" + snapshot = tuple(rot_ranges) + return join_char.join(apply_rot(c, snapshot) for c in components) diff --git a/src/raycast_api/signing/hmac.py b/src/raycast_api/signing/hmac.py new file mode 100644 index 0000000..484bb3e --- /dev/null +++ b/src/raycast_api/signing/hmac.py @@ -0,0 +1,89 @@ +"""HMAC signing primitive driven by `SigningSpec`. + +The JS implementation calls `crypto.subtle.importKey` with `{name:"HMAC", +hash:"SHA-256"}` and `crypto.subtle.sign("HMAC", key, msg)`, then hex-encodes +the bytes lowercase via `.padStart(2, "0")`. We mirror that here, parametrised +on algorithm name, key encoding, and output encoding — all populated from the +discovered spec so a future Raycast change only needs a discovery-side update. + +Algorithm names follow the WebCrypto convention ("SHA-256", "SHA-1", …) since +that's what the JS bundle exposes; we normalise to hashlib's casing. +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac as _hmac +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable + + +def _hash_factory(name: str) -> Callable[[], hashlib._Hash]: + """Resolve a WebCrypto-style hash name (`SHA-256`) to a hashlib factory. + + Strips dashes and lowercases ("SHA-256" → "sha256"). Raises `ValueError` + if the name isn't supported by the local OpenSSL build. + """ + normalised = name.replace("-", "").lower() + if normalised not in hashlib.algorithms_available: + msg = f"Unsupported hash algorithm: {name!r}" + raise ValueError(msg) + return lambda: hashlib.new(normalised) + + +def encode_key(secret: str, encoding: str) -> bytes: + """Turn the secret string into key bytes per `SigningSpec.key_encoding`. + + Real Raycast: `"utf-8"` — the 64-char ASCII hex secret is the key bytes + AS-IS, NOT hex-decoded. Hex-decoding silently breaks signing (HANDOFF.md + "Things that will bite the next person", #1). + """ + if encoding == "utf-8": + return secret.encode("utf-8") + if encoding == "ascii": + return secret.encode("ascii") + if encoding == "hex": + return bytes.fromhex(secret) + if encoding == "base64": + return base64.b64decode(secret) + msg = f"Unsupported key encoding: {encoding!r}" + raise ValueError(msg) + + +def encode_output(digest: bytes, encoding: str) -> str: + """Encode the raw HMAC bytes per `SigningSpec.output_encoding`.""" + if encoding == "hex-lower": + return digest.hex() + if encoding == "hex-upper": + return digest.hex().upper() + if encoding == "base64": + return base64.b64encode(digest).decode("ascii") + if encoding == "base64url": + return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + msg = f"Unsupported output encoding: {encoding!r}" + raise ValueError(msg) + + +class HMACSigner: + """Computes `HMAC(key, msg)` per a `SigningSpec`-derived configuration.""" + + def __init__( + self, secret: str, *, algorithm: str, key_encoding: str, output_encoding: str + ) -> None: + self._key = encode_key(secret, key_encoding) + self._factory = _hash_factory(algorithm) + self._output_encoding = output_encoding + + def sign(self, message: bytes) -> str: + mac = _hmac.new(self._key, message, self._factory) + return encode_output(mac.digest(), self._output_encoding) + + +def hash_body(body: bytes, algorithm: str) -> str: + """Hash a request body for the canonical string. Returns lowercase hex.""" + h = _hash_factory(algorithm)() + h.update(body) + return h.hexdigest() diff --git a/src/raycast_api/signing/transforms.py b/src/raycast_api/signing/transforms.py new file mode 100644 index 0000000..3884c64 --- /dev/null +++ b/src/raycast_api/signing/transforms.py @@ -0,0 +1,45 @@ +"""Per-character rotation transforms. + +Discovery (Phase 2) reduces Raycast's `Sur`/`Tur` rot function to a list of +`RotRange` objects. This module applies them. + +A `RotRange(start, end, shift)` maps a code point `c` with `start <= c <= end` +to `((c - start + shift) % (end - start + 1)) + start`. Code points outside +every supplied range pass through unchanged. Real Raycast uses three ranges: +A-Z +13, a-z +13, 0-9 +5 — i.e. ROT13 over letters, ROT5 over digits. + +Ranges are evaluated in order, but they're expected to be disjoint (the JS +implementation is a single if/elif/elif chain). If two overlap, the first +match wins; this matches the JS short-circuit and keeps the function +data-flow obvious if discovery ever ships overlapping ranges by mistake. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Iterable + + from raycast_api.signing_spec import RotRange + + +def apply_rot(s: str, ranges: Iterable[RotRange]) -> str: + """Apply the rotation to every character of `s`. + + Hot path on every signed request — keeps a tuple snapshot of the ranges + so per-character iteration doesn't touch the dataclass each loop. + """ + snapshot = tuple((r.start, r.end, r.shift, r.end - r.start + 1) for r in ranges) + out: list[str] = [] + for ch in s: + c = ord(ch) + replaced = False + for start, end, shift, span in snapshot: + if start <= c <= end: + out.append(chr((c - start + shift) % span + start)) + replaced = True + break + if not replaced: + out.append(ch) + return "".join(out) diff --git a/src/raycast_api/signing_spec.py b/src/raycast_api/signing_spec.py new file mode 100644 index 0000000..dfc9cb4 --- /dev/null +++ b/src/raycast_api/signing_spec.py @@ -0,0 +1,119 @@ +"""Data classes describing the signing algorithm. + +These are the runtime inputs to the signer. Phase 2 produces a `SigningSpec` +from the bundle; Phase 3's `Signer` consumes it. They live in this top-level +module (rather than inside `signing/`) so that `Config` (also top-level) can +reference them without pulling the whole signing implementation into import. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, cast + + +@dataclass(frozen=True) +class RotRange: + """One range in the character-rotation transform. + + A character with codepoint `c` such that `start <= c <= end` is mapped to + `((c - start + shift) % (end - start + 1)) + start`. Other characters + pass through unchanged. + + The default ranges match Raycast Beta 0.60.x: + + (A-Z, +13), (a-z, +13), (0-9, +5) + + — i.e. ROT13 over letters, ROT5 over digits. + """ + + start: int + end: int + shift: int + + def __post_init__(self) -> None: + if not (0 <= self.start <= self.end <= 0x10FFFF): + msg = f"invalid rot range: start={self.start}, end={self.end}" + raise ValueError(msg) + if self.shift < 0: + msg = f"negative shift not supported: {self.shift}" + raise ValueError(msg) + + +@dataclass(frozen=True) +class SigningSpec: + """Everything the signer needs that isn't the secret itself. + + Field-by-field intent: + + - `rot_fn_name` / `signing_fn_name`: bookkeeping only — kept around so the + CLI can show the user which minified symbols were matched and so the + discovery cache key can include them (a bundle rebuild that renames + these is also one that probably needs a re-extraction). + - `rot_ranges`: the character-class transforms applied per canonical + component. + - `join_char`: the separator between transformed components in the + canonical string. Always `"."` in observed builds. + - `body_hash_algorithm`: the hash used on the request body before + rot-transform. Always SHA-256. + - `hmac_algorithm`: the HMAC hash. Always SHA-256. + - `key_encoding`: how the secret string is turned into key bytes. Always + "utf-8" — the secret is a 64-char ASCII hex string used AS-IS, NOT + hex-decoded. (`bytes.fromhex(...)` silently breaks signing.) + - `output_encoding`: "hex-lower" matches the JS implementation's + `.padStart(2, "0")` lowercase hex output. + """ + + rot_fn_name: str + signing_fn_name: str + rot_ranges: list[RotRange] = field(default_factory=list) + join_char: str = "." + body_hash_algorithm: str = "SHA-256" + hmac_algorithm: str = "SHA-256" + key_encoding: str = "utf-8" + output_encoding: str = "hex-lower" + + def to_dict(self) -> dict[str, object]: + return { + "rot_fn_name": self.rot_fn_name, + "signing_fn_name": self.signing_fn_name, + "rot_ranges": [ + {"start": r.start, "end": r.end, "shift": r.shift} + for r in self.rot_ranges + ], + "join_char": self.join_char, + "body_hash_algorithm": self.body_hash_algorithm, + "hmac_algorithm": self.hmac_algorithm, + "key_encoding": self.key_encoding, + "output_encoding": self.output_encoding, + } + + @classmethod + def from_dict(cls, data: dict[str, object]) -> SigningSpec: + ranges_raw = data.get("rot_ranges", []) or [] + if not isinstance(ranges_raw, list): + msg = "rot_ranges must be a list" + raise TypeError(msg) + ranges: list[RotRange] = [] + for r in ranges_raw: + if not isinstance(r, dict): + msg = "rot_ranges entries must be objects" + raise TypeError(msg) + entry = cast("dict[str, Any]", r) + ranges.append( + RotRange( + start=int(entry["start"]), + end=int(entry["end"]), + shift=int(entry["shift"]), + ) + ) + return cls( + rot_fn_name=str(data.get("rot_fn_name", "")), + signing_fn_name=str(data.get("signing_fn_name", "")), + rot_ranges=ranges, + join_char=str(data.get("join_char", ".")), + body_hash_algorithm=str(data.get("body_hash_algorithm", "SHA-256")), + hmac_algorithm=str(data.get("hmac_algorithm", "SHA-256")), + key_encoding=str(data.get("key_encoding", "utf-8")), + output_encoding=str(data.get("output_encoding", "hex-lower")), + ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..1b22956 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,112 @@ +"""Shared test fixtures. + +Fixtures here construct a *synthetic* app bundle on disk that mimics the +structural shape of a real Raycast install: same directory layout, a Mach-O- +sized binary containing a fake `window.signatureSecret = '...'` line, and a +JS bundle whose AST shape matches what the extractors look for. None of this +uses real Raycast secrets — the synthetic secret is `"DEAD" + "BEEF" * 15`. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Iterator + +import pytest + +FIXTURES = Path(__file__).parent / "fixtures" +FAKE_SECRET = ( + "DEAD" + "BEEF" * 15 +) + + +def pytest_addoption(parser: pytest.Parser) -> None: + """Register the opt-in `--live` flag that enables real-backend tests.""" + parser.addoption( + "--live", + action="store_true", + default=False, + help=( + "run tests marked `live` (hits backend.raycast.com — requires " + "RAYCAST_BEARER + RAYCAST_DEVICE_ID env vars)" + ), + ) + + +def pytest_collection_modifyitems( + config: pytest.Config, items: list[pytest.Item] +) -> None: + """Skip `live`-marked tests unless `--live` was passed.""" + if config.getoption("--live"): + return + skip = pytest.mark.skip( + reason="needs --live (and RAYCAST_BEARER / RAYCAST_DEVICE_ID)" + ) + for item in items: + if "live" in item.keywords: + item.add_marker(skip) + + +@pytest.fixture(scope="session") +def fixtures_dir() -> Path: + return FIXTURES + + +@pytest.fixture +def mock_bundle_source() -> str: + """Synthetic JS source containing all three structurally-matched functions. + + Mirrors the real bundle shape: a rot fn `Roto` (1 param, the three required + numeric triplets), a duplicate `Roto2` (so dedup logic gets exercised), an + async 4-param signing fn `SigF` that calls .map(Roto), and a confusingly- + named decoy with a similar shape but missing one of the literals — the + extractor should ignore it. + """ + return (FIXTURES / "mock_bundle.mjs").read_text() + + +@pytest.fixture +def mock_binary_bytes() -> bytes: + """A blob that looks like a Mach-O binary far enough to fool the extractor.""" + return (FIXTURES / "mock_binary.bin").read_bytes() + + +@pytest.fixture +def mock_app(tmp_path: Path, mock_bundle_source: str, mock_binary_bytes: bytes) -> Path: + """Build a fake `Foo.app` bundle on disk and return its path.""" + app = tmp_path / "Foo.app" + (app / "Contents" / "MacOS").mkdir(parents=True) + sub_bundle = app / "Contents" / "Resources" / "test_RaycastDesktopApp.bundle" + (sub_bundle / "Contents" / "Resources" / "backend").mkdir(parents=True) + + (app / "Contents" / "MacOS" / "Foo").write_bytes(mock_binary_bytes) + + (sub_bundle / "Contents" / "Resources" / "backend" / "index.mjs").write_text( + mock_bundle_source + ) + + plist = ( + '' + '' + '' + "CFBundleShortVersionString9.9.9.0" + "" + ) + (app / "Contents" / "Info.plist").write_text(plist) + return app + + +@pytest.fixture +def isolated_cache(tmp_path: Path) -> Iterator[Path]: + """Point XDG_CACHE_HOME at a temp dir so DiscoveryCache doesn't touch ~/.cache.""" + prev = os.environ.get("XDG_CACHE_HOME") + cache = tmp_path / "cache" + os.environ["XDG_CACHE_HOME"] = str(cache) + try: + yield cache + finally: + if prev is None: + os.environ.pop("XDG_CACHE_HOME", None) + else: + os.environ["XDG_CACHE_HOME"] = prev diff --git a/tests/fixtures/mock_binary.bin b/tests/fixtures/mock_binary.bin new file mode 100644 index 0000000000000000000000000000000000000000..77cf8dad6f9fb22a3ec0ef70f59d75d153dfc4b2 GIT binary patch literal 1535 zcmX^A>+L^w1_nlkQ7{?;qaiS2L!dZ6H&r3OBqOy*p){{3H7BtoHASJgq$o2leZ+B09xTOGXMYp literal 0 HcmV?d00001 diff --git a/tests/fixtures/mock_bundle.mjs b/tests/fixtures/mock_bundle.mjs new file mode 100644 index 0000000..b9c15bd --- /dev/null +++ b/tests/fixtures/mock_bundle.mjs @@ -0,0 +1,77 @@ +// Synthetic bundle for raycast_api.discovery tests. +// +// Structurally mirrors Raycast's bundle: a rot13+rot5 function, an async +// 4-param HMAC signer that .map()s through the rot fn, and a couple of decoys +// the extractor must reject. Identifier names are intentionally NOT the +// minified ones from real Raycast (Sur, Nkt) — we use longer names to prove +// the extractor matches structurally, not by name. + +// --- decoy 1: 1-param but missing the rot triplets ---------------------- +function decoyOneParam(t) { + return t.toUpperCase(); +} + +// --- decoy 2: 4-param HMAC-ish but with no .map(rot) call -------------- +async function decoyHmac(a, b, c, d) { + // Mentions HMAC + SHA-256 + importKey but never calls .map(rotFn). + let key = await crypto.subtle.importKey("raw", a, { + name: "HMAC", + hash: "SHA-256" + }, false, ["sign"]); + return crypto.subtle.sign("HMAC", key, b); +} + +// --- the real rot fn ---------------------------------------------------- +function RotXform(s) { + let out = ""; + for (let ch of s) { + let r = ch.charCodeAt(0); + if (r >= 65 && r <= 90) { + out += String.fromCharCode((r - 65 + 13) % 26 + 65); + } else if (r >= 97 && r <= 122) { + out += String.fromCharCode((r - 97 + 13) % 26 + 97); + } else if (r >= 48 && r <= 57) { + out += String.fromCharCode((r - 48 + 5) % 10 + 48); + } else { + out += ch; + } + } + return out; +} + +// --- duplicate rot (real bundle has two byte-identical copies) --------- +function RotXform2(s) { + let out = ""; + for (let ch of s) { + let r = ch.charCodeAt(0); + if (r >= 65 && r <= 90) { out += String.fromCharCode((r - 65 + 13) % 26 + 65); } + else if (r >= 97 && r <= 122) { out += String.fromCharCode((r - 97 + 13) % 26 + 97); } + else if (r >= 48 && r <= 57) { out += String.fromCharCode((r - 48 + 5) % 10 + 48); } + else { out += ch; } + } + return out; +} + +// --- the real signing fn ----------------------------------------------- +async function SignReq(secret, timestamp, deviceId, body) { + let enc = new TextEncoder().encode(body); + let h = await crypto.subtle.digest("SHA-256", enc); + let bodyHash = Array.from(new Uint8Array(h)).map(b => b.toString(16).padStart(2, "0")).join(""); + let canonical = [timestamp, deviceId, bodyHash].map(RotXform).join("."); + let keyBytes = new TextEncoder().encode(secret); + let canonBytes = new TextEncoder().encode(canonical); + let key = await crypto.subtle.importKey("raw", keyBytes, { + name: "HMAC", + hash: "SHA-256" + }, false, ["sign"]); + let sig = await crypto.subtle.sign("HMAC", key, canonBytes); + return Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, "0")).join(""); +} + +// Decoy: looks like a string-handling function but with a `/` inside a +// regex literal — exercises the regex-aware brace matcher. +function noiseFn(input) { + return input.replace(/\}/g, "_"); +} + +export { SignReq, RotXform }; diff --git a/tests/test_ast_parse.py b/tests/test_ast_parse.py new file mode 100644 index 0000000..1fbfbad --- /dev/null +++ b/tests/test_ast_parse.py @@ -0,0 +1,112 @@ +"""Tests for `raycast_api.discovery.ast_parse`.""" + +from __future__ import annotations + +from raycast_api.discovery.ast_parse import ( + collect_numeric_literals, + find_calls, + find_function_by_shape, + has_string_literal, + iter_function_declarations, +) + + +def test_finds_declarations_and_skips_decoys(mock_bundle_source: str) -> None: + fns = list(iter_function_declarations(mock_bundle_source)) + names = [f.name for f in fns] + assert { + "RotXform", + "RotXform2", + "SignReq", + "decoyOneParam", + "decoyHmac", + "noiseFn", + } <= set(names) + + +def test_param_count_filter(mock_bundle_source: str) -> None: + fns = list(iter_function_declarations(mock_bundle_source)) + one_param = {f.name for f in find_function_by_shape(fns, param_count=1)} + assert "RotXform" in one_param + assert "decoyOneParam" in one_param + four_param = { + f.name for f in find_function_by_shape(fns, is_async=True, param_count=4) + } + assert "SignReq" in four_param + assert "decoyHmac" in four_param + + +def test_body_contains_excludes_decoys(mock_bundle_source: str) -> None: + fns = list(iter_function_declarations(mock_bundle_source)) + signing_like = find_function_by_shape( + fns, + is_async=True, + param_count=4, + body_contains_all=["HMAC", "SHA-256", "importKey"], + ) + assert {f.name for f in signing_like} == {"SignReq", "decoyHmac"} + + +def test_find_calls_locates_map_calls(mock_bundle_source: str) -> None: + fns = list(iter_function_declarations(mock_bundle_source)) + sign = next(f for f in fns if f.name == "SignReq") + map_calls = find_calls(sign, "map") + assert len(map_calls) == 3 + + +def test_has_string_literal(mock_bundle_source: str) -> None: + fns = list(iter_function_declarations(mock_bundle_source)) + sign = next(f for f in fns if f.name == "SignReq") + assert has_string_literal(sign, "HMAC") + assert has_string_literal(sign, "SHA-256") + assert not has_string_literal(sign, "MD5") + + +def test_collect_numeric_literals(mock_bundle_source: str) -> None: + fns = list(iter_function_declarations(mock_bundle_source)) + rot = next(f for f in fns if f.name == "RotXform") + nums = collect_numeric_literals(rot) + assert {65, 90, 13, 26, 97, 122, 48, 57, 5, 10} <= nums + + +def test_regex_literal_with_brace_does_not_truncate_function() -> None: + """The brace matcher must skip braces inside regex literals.""" + src = """ +function tricky(s) { + let re = /\\}/; + if (s.match(re)) { return true } + return false; +} +""" + fns = list(iter_function_declarations(src)) + assert len(fns) == 1 + assert fns[0].name == "tricky" + assert "return true" in fns[0].body_source + assert fns[0].body_source.endswith("}") + + +def test_template_literal_with_interpolation() -> None: + src = r""" +function templating(x) { + let nested = `prefix-${x.toString()}-${`inner-${x}`}-end`; + if (x) { return nested } + return ""; +} +""" + fns = list(iter_function_declarations(src)) + assert len(fns) == 1 + assert fns[0].name == "templating" + assert "return nested" in fns[0].body_source + + +def test_strings_with_braces_dont_confuse_matcher() -> None: + src = """ +function strs() { + let a = "{ not real }"; + let b = '} also not };'; + return a + b; +} +""" + fns = list(iter_function_declarations(src)) + assert len(fns) == 1 + assert fns[0].body_source.endswith("}") diff --git a/tests/test_binary.py b/tests/test_binary.py new file mode 100644 index 0000000..fc4099d --- /dev/null +++ b/tests/test_binary.py @@ -0,0 +1,41 @@ +"""Tests for `raycast_api.discovery.binary`.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from raycast_api.discovery.binary import find_signature_secret +from raycast_api.errors import DiscoveryError + +EXPECTED_FAKE_SECRET = ("DEAD" + "BEEF" * 15).lower() + + +def test_finds_secret_in_synthetic_app(mock_app: Path) -> None: + secret = find_signature_secret(mock_app) + assert secret == EXPECTED_FAKE_SECRET + + +def test_rejects_non_bundle(tmp_path: Path) -> None: + with pytest.raises(DiscoveryError, match="Not an app bundle"): + find_signature_secret(tmp_path / "nonexistent.app") + + +def test_rejects_missing_pattern(tmp_path: Path) -> None: + app = tmp_path / "Empty.app" + (app / "Contents" / "MacOS").mkdir(parents=True) + (app / "Contents" / "MacOS" / "Empty").write_bytes(b"\x00" * 1024) + with pytest.raises(DiscoveryError, match="Could not find"): + find_signature_secret(app) + + +def test_accepts_double_or_single_quoted_pattern(tmp_path: Path) -> None: + """The launcher uses single quotes, but the regex tolerates either form.""" + app = tmp_path / "DoubleQuote.app" + (app / "Contents" / "MacOS").mkdir(parents=True) + secret = "a" * 64 + (app / "Contents" / "MacOS" / "DoubleQuote").write_bytes( + b"\x00" * 100 + f'window.signatureSecret = "{secret}"'.encode() + b"\x00" * 100 + ) + assert find_signature_secret(app) == secret diff --git a/tests/test_bundle.py b/tests/test_bundle.py new file mode 100644 index 0000000..3999904 --- /dev/null +++ b/tests/test_bundle.py @@ -0,0 +1,42 @@ +"""Tests for `raycast_api.discovery.bundle`.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from raycast_api.discovery.bundle import ( + bundle_hash, + find_index_mjs, + locate_node_bundle, + read_bundle_source, +) +from raycast_api.errors import DiscoveryError + + +def test_locate_node_bundle(mock_app: Path) -> None: + bundle = locate_node_bundle(mock_app) + assert bundle.name == "test_RaycastDesktopApp.bundle" + + +def test_locate_node_bundle_missing(tmp_path: Path) -> None: + app = tmp_path / "Empty.app" + (app / "Contents" / "Resources").mkdir(parents=True) + with pytest.raises(DiscoveryError, match="No RaycastDesktopApp bundle"): + locate_node_bundle(app) + + +def test_find_index_mjs(mock_app: Path) -> None: + p = find_index_mjs(mock_app) + assert p.name == "index.mjs" + assert "SignReq" in read_bundle_source(p) + + +def test_bundle_hash_stable(mock_app: Path, tmp_path: Path) -> None: + a = bundle_hash(find_index_mjs(mock_app)) + b = bundle_hash(find_index_mjs(mock_app)) + assert a == b + other = tmp_path / "other.mjs" + other.write_text("different content") + assert bundle_hash(other) != a diff --git a/tests/test_canonical.py b/tests/test_canonical.py new file mode 100644 index 0000000..4f56753 --- /dev/null +++ b/tests/test_canonical.py @@ -0,0 +1,35 @@ +"""Tests for raycast_api.signing.canonical.""" + +from __future__ import annotations + +from raycast_api.signing.canonical import build_canonical +from raycast_api.signing_spec import RotRange + + +RAYCAST_RANGES = [ + RotRange(0x41, 0x5A, 13), + RotRange(0x61, 0x7A, 13), + RotRange(0x30, 0x39, 5), +] + + +def test_components_transformed_then_joined() -> None: + out = build_canonical(["abc", "123", "XYZ"], RAYCAST_RANGES, ".") + assert out == "nop.678.KLM" + + +def test_join_char_is_used_verbatim() -> None: + out = build_canonical(["abc", "abc"], RAYCAST_RANGES, ":") + assert out == "nop:nop" + + +def test_zero_components_yields_empty_string() -> None: + assert build_canonical([], RAYCAST_RANGES, ".") == "" + + +def test_single_component_no_separator() -> None: + assert build_canonical(["abc"], RAYCAST_RANGES, ".") == "nop" + + +def test_components_independent() -> None: + assert build_canonical(["a", "b"], RAYCAST_RANGES, ".") == "n.o" diff --git a/tests/test_chat.py b/tests/test_chat.py new file mode 100644 index 0000000..15a8a2e --- /dev/null +++ b/tests/test_chat.py @@ -0,0 +1,378 @@ +"""Tests for `raycast_api.ai.chat.ChatAPI`. + +Covers, in roughly increasing scope: + + - `_build_body` produces fields in the right order with the right defaults + per `source`. + - Tool/message serialisation matches the wire shape from BUNDLE_NOTES §3. + - `UserPreferences.render()` matches the byte-exact preamble from the + real Raycast `Ya()` function. + - `complete(...)` accumulates deltas into a single `ChatResult` and + handles the streamed-arguments case (concatenating tool-call argument + fragments across chunks). +""" + +from __future__ import annotations + +import json +from typing import Any + +import pytest +from aioresponses import aioresponses + +from raycast_api.ai import ( + ChatAPI, + ChatStreamChunk, + Message, + RemoteTool, + Source, + Tool, + ToolCall, + UserPreferences, +) +from raycast_api.client import Client +from raycast_api.config import Config +from raycast_api.signing_spec import RotRange, SigningSpec + + +REFERENCE_SECRET = "6bc455473576ce2cd6f70426caff867aabbe3f7291c1a79681af5e8ce0ca1408" +DEVICE_ID = "20eca913cada74f879e6535304f9d44da380c28eb855065c0d71017a3d7c3099" +FIXED_TIMESTAMP = 1778858809 + + +def _config() -> Config: + return Config( + signature_secret=REFERENCE_SECRET, + signing_spec=SigningSpec( + rot_fn_name="Sur", + signing_fn_name="Nkt", + rot_ranges=[ + RotRange(start=65, end=90, shift=13), + RotRange(start=97, end=122, shift=13), + RotRange(start=48, end=57, shift=5), + ], + ), + app_version="0.60.1.0", + user_agent="Raycast/0.60.1.0 (x-macOS Version 26.3.1)", + bundle_hash="0" * 64, + launcher_hash="0" * 64, + ) + + +def _client(**kwargs: Any) -> Client: + return Client( + config=_config(), + bearer_token="rca_test_token", + device_id=DEVICE_ID, + clock=lambda: FIXED_TIMESTAMP, + locale="en-GB", + **kwargs, + ) + + + + +class TestUserPreferences: + def test_render_matches_real_client_wording(self) -> None: + """The block must be byte-identical to what `Ya()` emits. + + The captured request body in `request_simple.curl.txt` contains: + + \\n + The user has the following system preferences:\\n + - Locale: en-GB\\n + - Timezone: Europe/Warsaw\\n + - Current Date: 2026-05-15\\n + - Use the system preferences to format your answers accordingly\\n + + + Any deviation (spacing, line breaks, punctuation) breaks the + fingerprint match. + """ + prefs = UserPreferences( + locale="en-GB", timezone="Europe/Warsaw", current_date="2026-05-15" + ) + rendered = prefs.render() + assert rendered == ( + "\n" + " The user has the following system preferences:\n" + " - Locale: en-GB\n" + " - Timezone: Europe/Warsaw\n" + " - Current Date: 2026-05-15\n" + " - Use the system preferences to format your answers accordingly\n" + "" + ) + + def test_auto_picks_today_and_locale_argument(self) -> None: + import datetime + + prefs = UserPreferences.auto(locale="ru-RU") + assert prefs.locale == "ru-RU" + assert prefs.current_date == datetime.date.today().isoformat() + assert prefs.timezone + + + + +class TestSerialisation: + def test_remote_tool_shape(self) -> None: + assert Tool.remote("web_search").to_wire() == { + "type": "remote_tool", + "name": "web_search", + } + assert Tool.remote(RemoteTool.SEARCH_IMAGES).to_wire() == { + "type": "remote_tool", + "name": "search_images", + } + + def test_local_tool_shape(self) -> None: + t = Tool.local( + name="weather__get", + description="get weather", + parameters={"type": "object", "properties": {"city": {"type": "string"}}}, + ) + assert t.to_wire() == { + "type": "local_tool", + "function": { + "name": "weather__get", + "description": "get weather", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + }, + }, + } + + def test_user_message(self) -> None: + assert Message.user("hello").to_wire() == { + "role": "user", + "content": {"text": "hello"}, + } + + def test_assistant_with_tool_calls(self) -> None: + msg = Message.assistant( + text="", + tool_calls=[ + ToolCall( + id="abc", name="coffee__caffeinate-for", arguments='{"minutes":5}' + ) + ], + extra_content={"google": {"thought_signature": "xyz"}}, + ) + assert msg.to_wire() == { + "role": "assistant", + "content": {"text": ""}, + "tool_calls": [ + { + "id": "abc", + "type": "function", + "function": { + "name": "coffee__caffeinate-for", + "arguments": '{"minutes":5}', + }, + } + ], + "extra_content": {"google": {"thought_signature": "xyz"}}, + } + + def test_tool_message_wraps_string_as_mcp_text(self) -> None: + msg = Message.tool( + tool_call_id="abc", + name="coffee__caffeinate-for", + result="Mac will stay awake for 5m", + ) + assert msg.to_wire() == { + "role": "tool", + "content": { + "text": '[{"type":"text","text":"Mac will stay awake for 5m"}]' + }, + "name": "coffee__caffeinate-for", + "tool_call_id": "abc", + } + + + + +class TestBuildBody: + def test_minimal_body_field_order(self) -> None: + """First-turn body should serialise fields in the captured order.""" + chat = ChatAPI(_client()) + body = chat._build_body( + model="gemini-3.1-pro-preview", + provider="google", + messages=[Message.user("привет")], + source=Source.AI_CHAT, + buffer_id="8480fbbb-4592-4257-812d-f24a67da3c07", + message_id="2f138e1c-edcf-495b-915c-db5cbb154674", + locale="en-GB", + current_date="2026-05-15", + system_instructions="markdown", + additional_system_instructions="\n The user has the following system preferences:\n - Locale: en-GB\n - Timezone: Europe/Warsaw\n - Current Date: 2026-05-15\n - Use the system preferences to format your answers accordingly\n", + temperature=0, + reasoning_effort="high", + tools=[ + Tool.remote(RemoteTool.WEB_SEARCH).to_wire(), + Tool.remote(RemoteTool.SEARCH_IMAGES).to_wire(), + Tool.remote(RemoteTool.READ_PAGE).to_wire(), + ], + tool_choice="auto", + resume_from=None, + ) + keys = list(body.keys()) + assert keys == [ + "system_instructions", + "additional_system_instructions", + "locale", + "temperature", + "current_date", + "message_id", + "reasoning_effort", + "messages", + "tools", + "tool_choice", + "source", + "model", + "provider", + "buffer_id", + ] + + def test_omits_optional_fields_when_none(self) -> None: + """No tools → no tools / tool_choice in the body at all.""" + chat = ChatAPI(_client()) + body = chat._build_body( + model="m", + provider="p", + messages=[Message.user("hi")], + source=Source.AI_CHAT, + buffer_id="b", + message_id="m", + locale="en-US", + current_date=None, + system_instructions=None, + additional_system_instructions=None, + temperature=None, + reasoning_effort=None, + tools=None, + tool_choice=None, + resume_from=None, + ) + assert "tools" not in body + assert "tool_choice" not in body + assert "temperature" not in body + assert "system_instructions" not in body + assert "additional_system_instructions" not in body + assert "reasoning_effort" not in body + assert "current_date" not in body + assert body["model"] == "m" + assert body["provider"] == "p" + assert body["buffer_id"] == "b" + assert body["source"] == "ai_chat" + + def test_source_default_temperature_only_applies_when_unspecified(self) -> None: + """Quick AI defaults to 0.2; passing temperature=0 overrides.""" + chat = ChatAPI(_client()) + from raycast_api.ai.chat import _SOURCE_DEFAULTS + + defaults = _SOURCE_DEFAULTS[Source.QUICK_AI] + assert defaults["temperature"] == 0.2 + assert defaults["system_instructions"] == "plain" + + + + +class TestComplete: + @pytest.mark.asyncio + async def test_complete_concatenates_streamed_tool_arguments(self) -> None: + """If `arguments` arrives in multiple chunks, they're concatenated. + + Constructs a synthetic SSE stream where the same tool_call id + appears across two chunks with partial `arguments` payloads. + """ + sse = ( + b"id: 0\n" + b'data: {"text":"","tool_calls":[{"id":"tc1","name":"f","arguments":"{\\"a\\":"}]}\n\n' + b"id: 1\n" + b'data: {"text":"","tool_calls":[{"id":"tc1","arguments":"1}"}]}\n\n' + b"id: 2\n" + b'data: {"text":"","finish_reason":"STOP","usage":{"input_tokens":1,"output_tokens":1}}\n\n' + b'event: complete\ndata: {"complete":true}\n\n' + ) + with aioresponses() as mocked: + mocked.post( + "https://backend.raycast.com/api/v1/ai/chat_completions", + status=200, + body=sse, + headers={"Content-Type": "text/event-stream"}, + ) + async with _client() as client: + result = await client.chat.complete( + model="m", + provider="p", + messages=[Message.user("x")], + user_preferences=False, + ) + assert len(result.tool_calls) == 1 + assert result.tool_calls[0].id == "tc1" + assert result.tool_calls[0].name == "f" + assert result.tool_calls[0].arguments == '{"a":1}' + + + + +class TestSignedBytesMatch: + """When we call `client.chat.stream`, the body bytes the request carries + must equal the bytes the Signer signed. `aioresponses` lets us capture + the outgoing body via a callback. + """ + + @pytest.mark.asyncio + async def test_stream_post_body_matches_signed_bytes(self) -> None: + captured: dict[str, Any] = {} + + def _cb(url: Any, **kwargs: Any) -> Any: + captured["data"] = kwargs.get("data") + captured["headers"] = kwargs.get("headers") + from aioresponses import CallbackResult + + return CallbackResult( + status=200, + body=b'event: complete\ndata: {"complete":true}\n\n', + headers={"Content-Type": "text/event-stream"}, + ) + + with aioresponses() as mocked: + mocked.post( + "https://backend.raycast.com/api/v1/ai/chat_completions", callback=_cb + ) + async with _client() as client: + async for _ in client.chat.stream( + model="m", + provider="p", + messages=[Message.user("hi")], + user_preferences=False, + buffer_id="b", + message_id="mid", + current_date="2026-05-15", + ): + pass + + body_bytes = captured["data"] + if hasattr(body_bytes, "_value"): + body_bytes = body_bytes._value + assert isinstance(body_bytes, (bytes, bytearray)) + + from raycast_api.signing import Signer + + signer = Signer(spec=_config().signing_spec, secret=REFERENCE_SECRET) + expected_sig = signer.sign( + timestamp=str(FIXED_TIMESTAMP), device_id=DEVICE_ID, body=bytes(body_bytes) + ) + assert captured["headers"]["X-Raycast-Signature-v2"] == expected_sig + + parsed = json.loads(bytes(body_bytes)) + assert parsed["model"] == "m" + assert parsed["provider"] == "p" + assert parsed["buffer_id"] == "b" + assert parsed["message_id"] == "mid" + assert parsed["source"] == "ai_chat" + assert parsed["system_instructions"] == "markdown" diff --git a/tests/test_chat_model_resolution.py b/tests/test_chat_model_resolution.py new file mode 100644 index 0000000..9f1e644 --- /dev/null +++ b/tests/test_chat_model_resolution.py @@ -0,0 +1,215 @@ +"""Tests for `ChatAPI._resolve_model` + the Client-level catalog cache. + +Resolution rules (mirrored from PROGRESS.md "Phase 6 / 6a"): + + 1. `ModelInfo` argument → use `.model` and `.provider`, ignore the + `provider=` kwarg. + 2. `str` + `provider=` → pass through verbatim, no catalog lookup. + 3. `str` only: + - match catalog id (`info.id`), + - else match wire id (`info.model`), + - else match display name (`info.name`), + - else raise `ValueError`. + +The catalog is fetched at most once per Client and cached. We assert this +by mocking `/ai/models` and counting requests across two `chat.complete` +calls. +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from aioresponses import aioresponses + +from raycast_api.ai import Message +from raycast_api.ai.chat import ChatAPI +from raycast_api.ai.models import ModelInfo, ModelsResponse +from raycast_api.client import Client +from raycast_api.config import Config +from raycast_api.signing_spec import RotRange, SigningSpec + + +def _config() -> Config: + return Config( + signature_secret="0" * 64, + signing_spec=SigningSpec( + rot_fn_name="Sur", + signing_fn_name="Nkt", + rot_ranges=[ + RotRange(start=65, end=90, shift=13), + RotRange(start=97, end=122, shift=13), + RotRange(start=48, end=57, shift=5), + ], + ), + app_version="0.60.1.0", + user_agent="Raycast/0.60.1.0 (x-macOS Version 26.3.1)", + bundle_hash="0" * 64, + launcher_hash="0" * 64, + ) + + +def _client(**kwargs: Any) -> Client: + return Client( + config=_config(), + bearer_token="rca_test", + device_id="a" * 64, + clock=lambda: 1700000000, + **kwargs, + ) + + +CATALOG_PAYLOAD = { + "models": [ + { + "id": "google-gemini-3.1-pro-preview", + "name": "Gemini 3.1 Pro Preview", + "model": "gemini-3.1-pro-preview", + "provider": "google", + "provider_name": "Google", + }, + { + "id": "anthropic-claude-sonnet-4-6", + "name": "Claude Sonnet 4.6", + "model": "claude-sonnet-4-6", + "provider": "anthropic", + "provider_name": "Anthropic", + }, + ], + "default_models": {"chat": "google-gemini-3.1-pro-preview"}, + "free_models": ["anthropic-claude-sonnet-4-6"], +} + + +def _catalog() -> ModelsResponse: + return ModelsResponse.from_wire(CATALOG_PAYLOAD) + + + + +class TestResolveModel: + async def test_model_info_argument_wins(self) -> None: + """A `ModelInfo` short-circuits any catalog lookup; `provider=` is ignored.""" + async with _client(models=_catalog()) as client: + chat = ChatAPI(client) + info = _catalog().by_id("google-gemini-3.1-pro-preview") + assert info is not None + wire, provider = await chat._resolve_model(info, provider="ignored") + assert wire == "gemini-3.1-pro-preview" + assert provider == "google" + + async def test_string_plus_provider_passes_through(self) -> None: + """The escape hatch: explicit `provider=` skips the catalog entirely.""" + async with _client(models=_catalog()) as client: + chat = ChatAPI(client) + wire, provider = await chat._resolve_model( + "some-future-model", provider="custom-provider" + ) + assert wire == "some-future-model" + assert provider == "custom-provider" + + async def test_string_matches_catalog_id(self) -> None: + async with _client(models=_catalog()) as client: + chat = ChatAPI(client) + wire, provider = await chat._resolve_model( + "google-gemini-3.1-pro-preview", provider=None + ) + assert wire == "gemini-3.1-pro-preview" + assert provider == "google" + + async def test_string_matches_wire_id(self) -> None: + async with _client(models=_catalog()) as client: + chat = ChatAPI(client) + wire, provider = await chat._resolve_model( + "gemini-3.1-pro-preview", provider=None + ) + assert wire == "gemini-3.1-pro-preview" + assert provider == "google" + + async def test_string_matches_display_name(self) -> None: + async with _client(models=_catalog()) as client: + chat = ChatAPI(client) + wire, provider = await chat._resolve_model( + "Claude Sonnet 4.6", provider=None + ) + assert wire == "claude-sonnet-4-6" + assert provider == "anthropic" + + async def test_unknown_string_raises_value_error(self) -> None: + async with _client(models=_catalog()) as client: + chat = ChatAPI(client) + with pytest.raises(ValueError, match="not found in catalog"): + await chat._resolve_model("totally-made-up", provider=None) + + + + +class TestCatalogCache: + async def test_catalog_fetched_once_and_reused(self) -> None: + """Two `_resolve_model` calls with no `provider=` should hit + `/ai/models` exactly once (catalog cached on the Client).""" + call_count = {"n": 0} + + def _cb(url: Any, **kwargs: Any) -> Any: + call_count["n"] += 1 + from aioresponses import CallbackResult + + return CallbackResult(status=200, payload=CATALOG_PAYLOAD) + + with aioresponses() as mocked: + mocked.get( + "https://backend.raycast.com/api/v1/ai/models", + callback=_cb, + repeat=True, + ) + async with _client() as client: + chat = ChatAPI(client) + a = await chat._resolve_model( + "google-gemini-3.1-pro-preview", provider=None + ) + b = await chat._resolve_model("claude-sonnet-4-6", provider=None) + assert a == ("gemini-3.1-pro-preview", "google") + assert b == ("claude-sonnet-4-6", "anthropic") + assert call_count["n"] == 1 + + async def test_models_constructor_kwarg_skips_fetch(self) -> None: + """Passing `models=` to Client should mean zero `/ai/models` hits.""" + with aioresponses() as mocked: + mocked.get( + "https://backend.raycast.com/api/v1/ai/models", + status=500, + repeat=True, + ) + async with _client(models=_catalog()) as client: + chat = ChatAPI(client) + wire, provider = await chat._resolve_model( + "google-gemini-3.1-pro-preview", provider=None + ) + assert (wire, provider) == ("gemini-3.1-pro-preview", "google") + + async def test_invalidate_models_cache_refetches(self) -> None: + call_count = {"n": 0} + + def _cb(url: Any, **kwargs: Any) -> Any: + call_count["n"] += 1 + from aioresponses import CallbackResult + + return CallbackResult(status=200, payload=CATALOG_PAYLOAD) + + with aioresponses() as mocked: + mocked.get( + "https://backend.raycast.com/api/v1/ai/models", + callback=_cb, + repeat=True, + ) + async with _client() as client: + chat = ChatAPI(client) + await chat._resolve_model( + "google-gemini-3.1-pro-preview", provider=None + ) + client.invalidate_models_cache() + await chat._resolve_model( + "google-gemini-3.1-pro-preview", provider=None + ) + assert call_count["n"] == 2 diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..a2b3a42 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,415 @@ +"""Tests for `raycast_api.cli`. + +Each subcommand is exercised through `cli.main([...])` with stdout/stderr +captured. We cover one happy path plus one error path per subcommand. No +network: `ask` uses `aioresponses` to mock `/ai/models` and +`/ai/chat_completions`. + +`init` / `refresh` go through the real discovery pipeline against the +synthetic `mock_app` fixture (defined in `conftest.py`), so this also +exercises the cache-isolation path end-to-end without touching +`~/.cache`. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest +from aioresponses import aioresponses + +from raycast_api import cli +from raycast_api.config import Config +from raycast_api.signing_spec import RotRange, SigningSpec + + + + +def _make_config(path: Path) -> Config: + """Write a minimal valid config.json suitable for `inspect` / `ask` tests.""" + cfg = Config( + signature_secret="6bc455473576ce2cd6f70426caff867aabbe3f7291c1a79681af5e8ce0ca1408", + signing_spec=SigningSpec( + rot_fn_name="Sur", + signing_fn_name="Nkt", + rot_ranges=[ + RotRange(start=65, end=90, shift=13), + RotRange(start=97, end=122, shift=13), + RotRange(start=48, end=57, shift=5), + ], + ), + app_version="0.60.1.0", + user_agent="Raycast/0.60.1.0 (x-macOS Version 26.3.1)", + bundle_hash="a" * 64, + launcher_hash="b" * 64, + ) + cfg.save(path) + return cfg + + +CATALOG_PAYLOAD = { + "models": [ + { + "id": "openai-gpt-4o-mini", + "name": "GPT-4o mini", + "model": "gpt-4o-mini", + "provider": "openai", + } + ], + "default_models": {"chat": "openai-gpt-4o-mini"}, + "free_models": ["openai-gpt-4o-mini"], +} + + +ASK_SSE = ( + b"id: 0\n" + b'data: {"text":""}\n\n' + b"id: 1\n" + b'data: {"text":"hello"}\n\n' + b"id: 2\n" + b'data: {"finish_reason":"STOP","usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}\n\n' + b"event: complete\ndata: \n\n" +) + + + + +class TestInit: + def test_init_writes_config( + self, + mock_app: Path, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + isolated_cache: Path, + ) -> None: + out = tmp_path / "config.json" + rc = cli.main(["init", "--app-path", str(mock_app), "--output", str(out)]) + assert rc == 0 + assert out.exists() + loaded = Config.load(out) + assert loaded.app_version == "9.9.9.0" + stdout = capsys.readouterr().out + assert str(out) in stdout + + def test_init_refuses_overwrite_without_force( + self, + mock_app: Path, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + isolated_cache: Path, + ) -> None: + out = tmp_path / "config.json" + out.write_text("{}") + rc = cli.main(["init", "--app-path", str(mock_app), "--output", str(out)]) + assert rc == 1 + assert out.read_text() == "{}" + err = capsys.readouterr().err + assert "already exists" in err + + def test_init_with_bad_app_path( + self, tmp_path: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + bogus = tmp_path / "nonexistent.app" + rc = cli.main( + ["init", "--app-path", str(bogus), "--output", str(tmp_path / "out.json")] + ) + assert rc == 1 + err = capsys.readouterr().err + assert "discovery failed" in err + + + + +class TestRefresh: + def test_refresh_overwrites( + self, mock_app: Path, tmp_path: Path, isolated_cache: Path + ) -> None: + out = tmp_path / "config.json" + out.write_text(json.dumps({"placeholder": True})) + rc = cli.main(["refresh", "--app-path", str(mock_app), "--config", str(out)]) + assert rc == 0 + loaded = Config.load(out) + assert loaded.app_version == "9.9.9.0" + + + + +class TestInspect: + def test_inspect_prints_summary( + self, tmp_path: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + cfg_path = tmp_path / "config.json" + _make_config(cfg_path) + rc = cli.main(["inspect", "--config", str(cfg_path)]) + assert rc == 0 + out = capsys.readouterr().out + assert "app_version : 0.60.1.0" in out + assert "rot=Sur sign=Nkt" in out + assert "6bc455" not in out + assert "1408" in out + + def test_inspect_missing_file_exits( + self, tmp_path: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + rc = cli.main(["inspect", "--config", str(tmp_path / "nope.json")]) + assert rc == 1 + err = capsys.readouterr().err + assert "no config" in err + + def test_inspect_default_does_not_autodetect_app( + self, tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch + ) -> None: + """Without --verify/--quiet/--app-path, inspect stays pure-offline. + + This pins the behavior even on a developer's machine that has + /Applications/Raycast.app installed — we shouldn't fingerprint the + local disk unless the user opts in. + """ + import raycast_api.cli as cli_mod + + sentinel: list[Path] = [] + + def _spy_compare(self: Config, app_path: Path) -> Any: + sentinel.append(app_path) + raise AssertionError("compare_with_app should not be called") + + monkeypatch.setattr(Config, "compare_with_app", _spy_compare) + cfg_path = tmp_path / "config.json" + _make_config(cfg_path) + rc = cli_mod.main(["inspect", "--config", str(cfg_path)]) + assert rc == 0 + assert sentinel == [] + out = capsys.readouterr().out + assert "status" not in out + + def test_inspect_verify_app_path_current( + self, + mock_app: Path, + isolated_cache: Path, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + cfg = Config.discover_from_app(mock_app) + cfg_path = tmp_path / "config.json" + cfg.save(cfg_path) + rc = cli.main( + ["inspect", "--config", str(cfg_path), "--app-path", str(mock_app)] + ) + assert rc == 0 + out = capsys.readouterr().out + assert "status : CURRENT" in out + assert "✓ matches" in out + + def test_inspect_verify_app_path_stale( + self, + mock_app: Path, + isolated_cache: Path, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + cfg = Config.discover_from_app(mock_app) + cfg_path = tmp_path / "config.json" + cfg.save(cfg_path) + binary = mock_app / "Contents" / "MacOS" / "Foo" + binary.write_bytes(binary.read_bytes() + b"\x55" * 8) + rc = cli.main( + ["inspect", "--config", str(cfg_path), "--app-path", str(mock_app)] + ) + assert rc == 1 + out = capsys.readouterr().out + assert "STALE" in out + assert "launcher" in out + assert "✗" in out + + def test_inspect_app_path_missing_is_error( + self, tmp_path: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + cfg_path = tmp_path / "config.json" + _make_config(cfg_path) + rc = cli.main( + [ + "inspect", + "--config", + str(cfg_path), + "--app-path", + str(tmp_path / "absent.app"), + ] + ) + assert rc == 2 + assert "app path not found" in capsys.readouterr().err + + def test_inspect_quiet_current_exits_zero( + self, + mock_app: Path, + isolated_cache: Path, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + cfg = Config.discover_from_app(mock_app) + cfg_path = tmp_path / "config.json" + cfg.save(cfg_path) + rc = cli.main( + [ + "inspect", + "--config", + str(cfg_path), + "--app-path", + str(mock_app), + "--quiet", + ] + ) + assert rc == 0 + captured = capsys.readouterr() + assert captured.out == "" and captured.err == "" + + def test_inspect_quiet_stale_exits_one( + self, mock_app: Path, isolated_cache: Path, tmp_path: Path + ) -> None: + cfg = Config.discover_from_app(mock_app) + cfg_path = tmp_path / "config.json" + cfg.save(cfg_path) + binary = mock_app / "Contents" / "MacOS" / "Foo" + binary.write_bytes(binary.read_bytes() + b"\x66" * 8) + rc = cli.main( + [ + "inspect", + "--config", + str(cfg_path), + "--app-path", + str(mock_app), + "--quiet", + ] + ) + assert rc == 1 + + def test_inspect_quiet_without_app_exits_two( + self, tmp_path: Path, monkeypatch + ) -> None: + """`--quiet` with no findable app → exit 2 (unverifiable).""" + import raycast_api.cli as cli_mod + + monkeypatch.setattr(cli_mod, "_try_resolve_app_path", lambda _: None) + cfg_path = tmp_path / "config.json" + _make_config(cfg_path) + rc = cli_mod.main(["inspect", "--config", str(cfg_path), "--quiet"]) + assert rc == 2 + + + + +class TestAsk: + def test_ask_complete_prints_reply( + self, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + cfg_path = tmp_path / "config.json" + _make_config(cfg_path) + monkeypatch.setenv("RAYCAST_DEVICE_ID", "a" * 64) + monkeypatch.setenv("RAYCAST_BEARER", "rca_test") + with aioresponses() as mocked: + mocked.get( + "https://backend.raycast.com/api/v1/ai/models", payload=CATALOG_PAYLOAD + ) + mocked.post( + "https://backend.raycast.com/api/v1/ai/chat_completions", + body=ASK_SSE, + content_type="text/event-stream", + ) + rc = cli.main( + ["ask", "--config", str(cfg_path), "--model", "gpt-4o-mini", "hi"] + ) + assert rc == 0 + captured = capsys.readouterr() + assert captured.out.strip() == "hello" + assert "finish_reason=STOP" in captured.err + + def test_ask_stream_writes_tokens( + self, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + cfg_path = tmp_path / "config.json" + _make_config(cfg_path) + monkeypatch.setenv("RAYCAST_DEVICE_ID", "a" * 64) + monkeypatch.setenv("RAYCAST_BEARER", "rca_test") + with aioresponses() as mocked: + mocked.get( + "https://backend.raycast.com/api/v1/ai/models", payload=CATALOG_PAYLOAD + ) + mocked.post( + "https://backend.raycast.com/api/v1/ai/chat_completions", + body=ASK_SSE, + content_type="text/event-stream", + ) + rc = cli.main( + [ + "ask", + "--config", + str(cfg_path), + "--model", + "gpt-4o-mini", + "--stream", + "hi", + ] + ) + assert rc == 0 + out = capsys.readouterr().out + assert "hello" in out + + def test_ask_without_bearer_exits( + self, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + cfg_path = tmp_path / "config.json" + _make_config(cfg_path) + monkeypatch.delenv("RAYCAST_BEARER", raising=False) + rc = cli.main( + ["ask", "--config", str(cfg_path), "--model", "gpt-4o-mini", "hi"] + ) + assert rc == 2 + err = capsys.readouterr().err + assert "bearer" in err.lower() + + def test_ask_missing_config_exits( + self, tmp_path: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + rc = cli.main( + [ + "ask", + "--config", + str(tmp_path / "missing.json"), + "--model", + "x", + "--bearer", + "rca_test", + "hi", + ] + ) + assert rc == 1 + err = capsys.readouterr().err + assert "no config" in err + + + + +class TestDeviceIdPersistence: + def test_creates_and_reuses(self, tmp_path: Path) -> None: + path = tmp_path / "device_id" + first = cli._load_or_create_device_id(path) + assert len(first) == 64 + second = cli._load_or_create_device_id(path) + assert first == second + + def test_garbage_file_regenerates(self, tmp_path: Path) -> None: + path = tmp_path / "device_id" + path.write_text("not-hex\n") + fresh = cli._load_or_create_device_id(path) + assert len(fresh) == 64 + assert fresh != "not-hex" diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..cc8f74a --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,256 @@ +"""Tests for `raycast_api.config` and `raycast_api.discovery.cache`.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest + +from raycast_api.config import DEFAULT_BACKEND_URL, Config, ConfigComparison +from raycast_api.discovery.cache import DiscoveryCache, default_cache_dir +from raycast_api.errors import ConfigError, DiscoveryError +from raycast_api.signing_spec import RotRange, SigningSpec + +FAKE_SECRET = ("DEAD" + "BEEF" * 15).lower() + + +def _make_spec() -> SigningSpec: + return SigningSpec( + rot_fn_name="r", + signing_fn_name="s", + rot_ranges=[RotRange(65, 90, 13), RotRange(97, 122, 13), RotRange(48, 57, 5)], + ) + + +def _make_config() -> Config: + return Config( + signature_secret=FAKE_SECRET, + signing_spec=_make_spec(), + app_version="1.2.3", + user_agent="Raycast/1.2.3 (x-macOS Version 14.0)", + bundle_hash="0" * 64, + launcher_hash="1" * 64, + ) + + +def test_roundtrip_serialization(tmp_path: Path) -> None: + cfg = _make_config() + out = tmp_path / "config.json" + cfg.save(out) + loaded = Config.load(out) + assert loaded.to_dict() == cfg.to_dict() + + +def test_save_creates_parent_dirs_and_sets_perms(tmp_path: Path) -> None: + cfg = _make_config() + out = tmp_path / "deep" / "nested" / "config.json" + cfg.save(out) + assert out.exists() + mode = out.stat().st_mode & 0o777 + assert mode == 0o600 + + +def test_redacted_secret() -> None: + cfg = _make_config() + assert cfg.redacted_secret() == "…" + FAKE_SECRET[-4:] + short = Config( + signature_secret="abc", + signing_spec=_make_spec(), + app_version="x", + user_agent="x", + bundle_hash="x", + launcher_hash="x", + ) + assert short.redacted_secret() == "***" + + +def test_load_rejects_missing_required(tmp_path: Path) -> None: + bad = tmp_path / "bad.json" + bad.write_text('{"signing_spec": {}}') + with pytest.raises(ConfigError): + Config.load(bad) + + +def test_default_constants() -> None: + cfg = _make_config() + assert cfg.backend_url == DEFAULT_BACKEND_URL + assert cfg.api_prefix == "/api/v1" + assert cfg.experimental_header == "autoModels" + + +def test_discover_from_app_synthetic(mock_app: Path, isolated_cache: Path) -> None: + cfg = Config.discover_from_app(mock_app) + assert cfg.signature_secret == FAKE_SECRET + assert cfg.app_version == "9.9.9.0" + assert cfg.signing_spec.signing_fn_name == "SignReq" + assert cfg.signing_spec.rot_fn_name in {"RotXform", "RotXform2"} + assert cfg.user_agent.startswith("Raycast/9.9.9.0 (x-macOS Version ") + + +def test_discover_from_app_rejects_missing(tmp_path: Path) -> None: + with pytest.raises(DiscoveryError): + Config.discover_from_app(tmp_path / "does-not-exist.app") + + +def test_cache_hit(mock_app: Path, isolated_cache: Path) -> None: + cfg1 = Config.discover_from_app(mock_app) + cache_files = list((isolated_cache / "raycast-api").glob("*.json")) + assert len(cache_files) == 1 + data: dict[str, Any] = __import__("json").loads(cache_files[0].read_text()) + data["app_version"] = "TAMPERED" + cache_files[0].write_text(__import__("json").dumps(data)) + + cfg2 = Config.discover_from_app(mock_app) + assert cfg2.app_version == "TAMPERED" + assert cfg2.bundle_hash == cfg1.bundle_hash + + +def test_cache_bypass(mock_app: Path, isolated_cache: Path) -> None: + Config.discover_from_app(mock_app) + cfg2 = Config.discover_from_app(mock_app, use_cache=False) + assert cfg2.app_version == "9.9.9.0" + + +def test_default_cache_dir_honors_xdg( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path)) + assert default_cache_dir() == tmp_path / "raycast-api" + + +def test_discovery_cache_clear(tmp_path: Path) -> None: + cache = DiscoveryCache(root=tmp_path) + cfg = _make_config() + cache.set("abc", cfg) + assert (tmp_path / "abc.json").exists() + cache.clear() + assert not (tmp_path / "abc.json").exists() + + +def test_discovery_cache_corrupt_returns_none(tmp_path: Path) -> None: + cache = DiscoveryCache(root=tmp_path) + (tmp_path / "abc.json").write_text("not json") + assert cache.get("abc") is None + + +def test_cache_key_depends_on_both_hashes() -> None: + base = _make_config() + different_bundle = Config( + signature_secret=base.signature_secret, + signing_spec=base.signing_spec, + app_version=base.app_version, + user_agent=base.user_agent, + bundle_hash="9" * 64, + launcher_hash=base.launcher_hash, + ) + different_launcher = Config( + signature_secret=base.signature_secret, + signing_spec=base.signing_spec, + app_version=base.app_version, + user_agent=base.user_agent, + bundle_hash=base.bundle_hash, + launcher_hash="9" * 64, + ) + assert base.cache_key() != different_bundle.cache_key() + assert base.cache_key() != different_launcher.cache_key() + assert different_bundle.cache_key() != different_launcher.cache_key() + + +def test_launcher_rebuild_invalidates_cache( + mock_app: Path, isolated_cache: Path +) -> None: + """A launcher-only change must still invalidate the cache, even if the JS bundle is byte-identical.""" + cfg1 = Config.discover_from_app(mock_app) + binary_path = mock_app / "Contents" / "MacOS" / "Foo" + current = binary_path.read_bytes() + binary_path.write_bytes(current + b"\x99" * 16) + cfg2 = Config.discover_from_app(mock_app) + assert cfg2.launcher_hash != cfg1.launcher_hash + assert cfg2.cache_key() != cfg1.cache_key() + + + + +class TestCompareWithApp: + """Freshness comparison between a saved Config and a local app on disk.""" + + def test_freshly_discovered_is_current( + self, mock_app: Path, isolated_cache: Path + ) -> None: + cfg = Config.discover_from_app(mock_app) + cmp = cfg.compare_with_app(mock_app) + assert cmp.is_current + assert cmp.bundle_matches + assert cmp.launcher_matches + assert cmp.app_version_matches + assert cmp.reasons() == [] + assert cfg.is_current_for(mock_app) is True + + def test_bundle_change_is_stale(self, mock_app: Path, isolated_cache: Path) -> None: + cfg = Config.discover_from_app(mock_app) + mjs = ( + mock_app + / "Contents" + / "Resources" + / "test_RaycastDesktopApp.bundle" + / "Contents" + / "Resources" + / "backend" + / "index.mjs" + ) + mjs.write_text(mjs.read_text() + "\n// touch") + cmp = cfg.compare_with_app(mock_app) + assert not cmp.is_current + assert cmp.bundle_matches is False + assert cmp.launcher_matches is True + assert "bundle rebuilt" in cmp.reasons()[0] + assert cfg.is_current_for(mock_app) is False + + def test_launcher_change_is_stale( + self, mock_app: Path, isolated_cache: Path + ) -> None: + cfg = Config.discover_from_app(mock_app) + binary = mock_app / "Contents" / "MacOS" / "Foo" + binary.write_bytes(binary.read_bytes() + b"\x77" * 8) + cmp = cfg.compare_with_app(mock_app) + assert not cmp.is_current + assert cmp.bundle_matches is True + assert cmp.launcher_matches is False + assert any("launcher rebuilt" in r for r in cmp.reasons()) + + def test_app_version_drift_alone_is_not_stale( + self, mock_app: Path, isolated_cache: Path + ) -> None: + """Version-only drift is informational — `is_current` stays True.""" + cfg = Config.discover_from_app(mock_app) + from dataclasses import replace + + cfg_drift = replace(cfg, app_version="0.0.0") + cmp = cfg_drift.compare_with_app(mock_app) + assert cmp.is_current + assert cmp.app_version_matches is False + assert any("app version" in r for r in cmp.reasons()) + + def test_missing_dir_raises(self, tmp_path: Path) -> None: + cfg = _make_config() + with pytest.raises(DiscoveryError, match="not a directory"): + cfg.compare_with_app(tmp_path / "nope.app") + + +def test_config_comparison_dataclass_is_frozen() -> None: + """Ensure the comparison can be used as a hashable value (e.g. in a set).""" + cmp = ConfigComparison( + bundle_matches=True, + launcher_matches=True, + app_version_matches=True, + saved_bundle_hash="a", + current_bundle_hash="a", + saved_launcher_hash="b", + current_launcher_hash="b", + saved_app_version="1", + current_app_version="1", + ) + with pytest.raises(Exception): # noqa: B017, BLE001 — FrozenInstanceError + cmp.bundle_matches = False # type: ignore[misc] + assert cmp.is_current diff --git a/tests/test_extractors.py b/tests/test_extractors.py new file mode 100644 index 0000000..e37238e --- /dev/null +++ b/tests/test_extractors.py @@ -0,0 +1,74 @@ +"""Tests for `raycast_api.discovery.extractors`.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from raycast_api.discovery.extractors import ( + extract_signing_spec, + extract_user_agent_template, + read_app_version, +) +from raycast_api.errors import DiscoveryError + + +def test_extract_signing_spec_from_synthetic(mock_bundle_source: str) -> None: + spec = extract_signing_spec(mock_bundle_source) + assert spec.rot_fn_name in {"RotXform", "RotXform2"} + assert spec.signing_fn_name == "SignReq" + assert spec.join_char == "." + assert spec.body_hash_algorithm == "SHA-256" + assert spec.hmac_algorithm == "SHA-256" + assert spec.key_encoding == "utf-8" + + +def test_rot_ranges_extracted(mock_bundle_source: str) -> None: + spec = extract_signing_spec(mock_bundle_source) + ranges = {(r.start, r.end, r.shift) for r in spec.rot_ranges} + assert ranges == {(65, 90, 13), (97, 122, 13), (48, 57, 5)} + + +def test_missing_rot_raises() -> None: + src = """ +async function loneSigner(a, b, c, d) { + let k = await crypto.subtle.importKey("raw", new TextEncoder().encode(a), {name:"HMAC", hash:"SHA-256"}, false, ["sign"]); + return crypto.subtle.sign("HMAC", k, b); +} +""" + with pytest.raises(DiscoveryError, match="rot13"): + extract_signing_spec(src) + + +def test_signer_must_reference_rot() -> None: + """If the only signing candidate doesn't .map() through the rot fn, fail loudly.""" + src = """ +function rotFn(s) { + let out = ""; for (let ch of s) { + let r = ch.charCodeAt(0); + if (r >= 65 && r <= 90) out += String.fromCharCode((r - 65 + 13) % 26 + 65); + else if (r >= 97 && r <= 122) out += String.fromCharCode((r - 97 + 13) % 26 + 97); + else if (r >= 48 && r <= 57) out += String.fromCharCode((r - 48 + 5) % 10 + 48); + else out += ch; + } + return out; +} +async function lone(a, b, c, d) { + // 4 params, HMAC/SHA-256/importKey — but doesn't call .map(rotFn). + let k = await crypto.subtle.importKey("raw", new TextEncoder().encode(a), + {name:"HMAC", hash:"SHA-256"}, false, ["sign"]); + return crypto.subtle.sign("HMAC", k, b); +} +""" + with pytest.raises(DiscoveryError, match=r"none of the signers calls\s+\.map"): + extract_signing_spec(src) + + +def test_read_app_version(mock_app: Path) -> None: + assert read_app_version(mock_app) == "9.9.9.0" + + +def test_user_agent_template(mock_app: Path) -> None: + ua = extract_user_agent_template(mock_app, platform_version="13.5") + assert ua == "Raycast/9.9.9.0 (x-macOS Version 13.5)" diff --git a/tests/test_files.py b/tests/test_files.py new file mode 100644 index 0000000..3c19055 --- /dev/null +++ b/tests/test_files.py @@ -0,0 +1,182 @@ +"""Tests for `raycast_api.ai.files.FilesAPI`. + +Three endpoints with three different signing quirks: + + - POST /ai/files — normal signed JSON body, then unsigned PUT to a + presigned URL on a different host. + - GET /ai/files/{id} — signed body is the LITERAL two-byte string + `"{}"` (not empty!). This is the one trap callers must not break. + - DELETE /ai/files — signed JSON body sent ON a DELETE request. +""" + +from __future__ import annotations + +import hashlib +from pathlib import Path +from typing import Any + +import pytest +from aioresponses import aioresponses + +from raycast_api.client import Client +from raycast_api.config import Config +from raycast_api.signing import Signer +from raycast_api.signing_spec import RotRange, SigningSpec + + +REFERENCE_SECRET = "6bc455473576ce2cd6f70426caff867aabbe3f7291c1a79681af5e8ce0ca1408" +DEVICE_ID = "20eca913cada74f879e6535304f9d44da380c28eb855065c0d71017a3d7c3099" +FIXED_TIMESTAMP = 1778858809 + + +def _config() -> Config: + return Config( + signature_secret=REFERENCE_SECRET, + signing_spec=SigningSpec( + rot_fn_name="Sur", + signing_fn_name="Nkt", + rot_ranges=[ + RotRange(start=65, end=90, shift=13), + RotRange(start=97, end=122, shift=13), + RotRange(start=48, end=57, shift=5), + ], + ), + app_version="0.60.1.0", + user_agent="Raycast/0.60.1.0 (x-macOS Version 26.3.1)", + bundle_hash="0" * 64, + launcher_hash="0" * 64, + ) + + +def _client() -> Client: + return Client( + config=_config(), + bearer_token="rca_test", + device_id=DEVICE_ID, + clock=lambda: FIXED_TIMESTAMP, + ) + + +class TestUpload: + @pytest.mark.asyncio + async def test_upload_registers_then_puts(self, tmp_path: Path) -> None: + f = tmp_path / "hello.txt" + f.write_bytes(b"hi there") + expected_checksum = hashlib.sha256(b"hi there").hexdigest() + + captured: dict[str, Any] = {} + + def _register_cb(url: Any, **kwargs: Any) -> Any: + captured["register_data"] = kwargs.get("data") + captured["register_headers"] = dict(kwargs.get("headers") or {}) + from aioresponses import CallbackResult + + return CallbackResult( + status=200, + payload={ + "id": "file_abc123", + "direct_upload": { + "url": "https://blobs.example.com/u/abc", + "headers": {"X-Upload-Token": "deadbeef"}, + }, + }, + ) + + def _put_cb(url: Any, **kwargs: Any) -> Any: + captured["put_data"] = kwargs.get("data") + captured["put_headers"] = dict(kwargs.get("headers") or {}) + from aioresponses import CallbackResult + + return CallbackResult(status=200) + + with aioresponses() as mocked: + mocked.post( + "https://backend.raycast.com/api/v1/ai/files", callback=_register_cb + ) + mocked.put("https://blobs.example.com/u/abc", callback=_put_cb) + + async with _client() as client: + meta = await client.files.upload(path=f, chat_id="chat_1") + + assert meta.file_id == "file_abc123" + assert meta.checksum == expected_checksum + body = captured["register_data"] + if hasattr(body, "_value"): + body = body._value + import json as _json + + parsed = _json.loads(bytes(body)) + assert parsed == { + "chat_id": "chat_1", + "blob": { + "filename": "hello.txt", + "byte_size": 8, + "content_type": "text/plain", + "checksum": expected_checksum, + }, + } + assert "X-Raycast-Signature-v2" in captured["register_headers"] + assert "X-Raycast-Signature-v2" not in captured["put_headers"] + assert "Authorization" not in captured["put_headers"] + assert captured["put_headers"]["X-Upload-Token"] == "deadbeef" + assert bytes(captured["put_data"]) == b"hi there" + + +class TestGetSignsBraceBody: + @pytest.mark.asyncio + async def test_get_signs_two_byte_brace_body(self) -> None: + """⚠ `GET /ai/files/{id}` signs the literal string `"{}"`, not `""`. + + This is the BUNDLE_NOTES "surprise" — `uV` differs from the resume + GET. Server validates against the bytes the client claims to have + sent, so we must match. + """ + captured: dict[str, Any] = {} + + def _cb(url: Any, **kwargs: Any) -> Any: + captured["headers"] = dict(kwargs.get("headers") or {}) + captured["data"] = kwargs.get("data") + from aioresponses import CallbackResult + + return CallbackResult(status=200, body=b"file-contents") + + with aioresponses() as mocked: + mocked.get("https://backend.raycast.com/api/v1/ai/files/F1", callback=_cb) + async with _client() as client: + data = await client.files.get("F1") + + assert data == b"file-contents" + wire_body = captured["data"] + if hasattr(wire_body, "_value"): + wire_body = wire_body._value + assert bytes(wire_body) == b"{}" + signer = Signer(spec=_config().signing_spec, secret=REFERENCE_SECRET) + expected = signer.sign( + timestamp=str(FIXED_TIMESTAMP), device_id=DEVICE_ID, body=b"{}" + ) + assert captured["headers"]["X-Raycast-Signature-v2"] == expected + + +class TestDelete: + @pytest.mark.asyncio + async def test_delete_with_chat_ids_body(self) -> None: + captured: dict[str, Any] = {} + + def _cb(url: Any, **kwargs: Any) -> Any: + captured["headers"] = dict(kwargs.get("headers") or {}) + captured["data"] = kwargs.get("data") + from aioresponses import CallbackResult + + return CallbackResult(status=204) + + with aioresponses() as mocked: + mocked.delete("https://backend.raycast.com/api/v1/ai/files", callback=_cb) + async with _client() as client: + await client.files.delete(chat_ids=["c1", "c2"]) + + body = captured["data"] + if hasattr(body, "_value"): + body = body._value + assert bytes(body) == b'{"chat_ids":["c1","c2"]}' + assert "X-Raycast-Signature-v2" in captured["headers"] + assert captured["headers"]["Content-Type"] == "application/json" diff --git a/tests/test_hmac.py b/tests/test_hmac.py new file mode 100644 index 0000000..65599cb --- /dev/null +++ b/tests/test_hmac.py @@ -0,0 +1,94 @@ +"""Tests for raycast_api.signing.hmac.""" + +from __future__ import annotations + +import hashlib +import hmac as _hmac + +import pytest + +from raycast_api.signing.hmac import HMACSigner, encode_key, encode_output, hash_body + + +FAKE_SECRET = "DEAD" + "BEEF" * 15 + + +class TestEncodeKey: + def test_utf8_passes_secret_through_as_bytes(self) -> None: + assert encode_key(FAKE_SECRET, "utf-8") == FAKE_SECRET.encode("utf-8") + + def test_hex_decoding_supported_for_other_specs(self) -> None: + assert encode_key("deadbeef", "hex") == bytes.fromhex("deadbeef") + + def test_unsupported_encoding_raises(self) -> None: + with pytest.raises(ValueError): + encode_key(FAKE_SECRET, "rot13") + + +class TestEncodeOutput: + def test_hex_lower(self) -> None: + assert encode_output(b"\xde\xad\xbe\xef", "hex-lower") == "deadbeef" + + def test_hex_upper(self) -> None: + assert encode_output(b"\xde\xad\xbe\xef", "hex-upper") == "DEADBEEF" + + def test_base64(self) -> None: + assert encode_output(b"\x00\x01\x02\x03", "base64") == "AAECAw==" + + def test_unsupported_encoding_raises(self) -> None: + with pytest.raises(ValueError): + encode_output(b"abc", "rot13") + + +class TestHashBody: + def test_sha256_lowercase_hex(self) -> None: + assert hash_body(b"hello", "SHA-256") == hashlib.sha256(b"hello").hexdigest() + + def test_empty_body_hash(self) -> None: + assert hash_body(b"", "SHA-256") == hashlib.sha256(b"").hexdigest() + + def test_bad_algorithm(self) -> None: + with pytest.raises(ValueError): + hash_body(b"x", "MD-not-a-thing") + + +class TestHMACSigner: + def test_known_vector(self) -> None: + signer = HMACSigner( + FAKE_SECRET, + algorithm="SHA-256", + key_encoding="utf-8", + output_encoding="hex-lower", + ) + message = b"some.canonical.string" + expected = _hmac.new( + FAKE_SECRET.encode("utf-8"), message, hashlib.sha256 + ).hexdigest() + assert signer.sign(message) == expected + + def test_signer_is_reusable(self) -> None: + signer = HMACSigner( + FAKE_SECRET, + algorithm="SHA-256", + key_encoding="utf-8", + output_encoding="hex-lower", + ) + a = signer.sign(b"first message") + b = signer.sign(b"second message") + assert a != b + assert signer.sign(b"first message") == a + + def test_algorithm_name_is_normalised(self) -> None: + a = HMACSigner( + FAKE_SECRET, + algorithm="SHA-256", + key_encoding="utf-8", + output_encoding="hex-lower", + ) + b = HMACSigner( + FAKE_SECRET, + algorithm="sha256", + key_encoding="utf-8", + output_encoding="hex-lower", + ) + assert a.sign(b"x") == b.sign(b"x") diff --git a/tests/test_http.py b/tests/test_http.py new file mode 100644 index 0000000..6c2d354 --- /dev/null +++ b/tests/test_http.py @@ -0,0 +1,555 @@ +"""HTTP client tests using `aioresponses` to mock aiohttp. + +What's exercised here, in roughly increasing scope: + +- Header construction, including signed-vs-unsigned, resume mode, content-type + omission, browser-fluff toggle. +- URL composition (relative path → `Config.backend_url` + path). +- End-to-end request flow against a mocked endpoint, including JSON body + serialisation byte-equal to the bytes we sign. +- Error mapping (401 → AuthError, 429 → RateLimitError, 5xx → HTTPStatusError). +- Retry behaviour: retries on 429/5xx with re-signing (fresh timestamp each + attempt), no retry on 4xx, respect for `Retry-After`. +- Streaming: synthetic SSE bytes parsed into `SSEEvent`s, error events, + no-retry-on-2xx. + +Tests use a `Config` built in-memory (no discovery) with a deterministic +reference secret so signatures are reproducible across runs. +""" + +from __future__ import annotations + +import re +from typing import Any + +import aiohttp +import pytest +from aioresponses import aioresponses + +from raycast_api.client import Client, RetryPolicy +from raycast_api.client.streaming import SSEEvent +from raycast_api.config import Config +from raycast_api.errors import ( + AuthError, + HTTPStatusError, + RateLimitError, + StreamError, + TransportError, +) +from raycast_api.signing_spec import RotRange, SigningSpec + + +REFERENCE_SECRET = "6bc455473576ce2cd6f70426caff867aabbe3f7291c1a79681af5e8ce0ca1408" +DEVICE_ID = "20eca913cada74f879e6535304f9d44da380c28eb855065c0d71017a3d7c3099" +FIXED_TIMESTAMP = 1778858809 + + +def _make_config() -> Config: + """Synthetic config with the real-world signing spec and reference secret. + + The secret comes from `sign.py` (public reference value); the spec mirrors + what Phase 2's discovery produces for production Raycast Beta. Bundle / + launcher hashes are placeholder zeros — they're not exercised here. + """ + spec = SigningSpec( + rot_fn_name="Sur", + signing_fn_name="Nkt", + rot_ranges=[ + RotRange(start=65, end=90, shift=13), + RotRange(start=97, end=122, shift=13), + RotRange(start=48, end=57, shift=5), + ], + ) + return Config( + signature_secret=REFERENCE_SECRET, + signing_spec=spec, + app_version="0.60.1.0", + user_agent="Raycast/0.60.1.0 (x-macOS Version 26.3.1)", + bundle_hash="0" * 64, + launcher_hash="0" * 64, + ) + + +def _make_client(**kwargs: Any) -> Client: + """Default client with a fixed clock so signatures are reproducible.""" + return Client( + config=_make_config(), + bearer_token="rca_test_token", + device_id=DEVICE_ID, + clock=lambda: FIXED_TIMESTAMP, + **kwargs, + ) + + + + +class TestHeaderBuilding: + def test_signed_post_full_header_set(self) -> None: + client = _make_client() + body = b'{"hello":"world"}' + headers = client.build_headers( + sign=True, body=body, content_type="application/json" + ) + assert headers["X-Raycast-Timestamp"] == str(FIXED_TIMESTAMP) + assert headers["X-Raycast-DeviceId"] == DEVICE_ID + assert headers["X-Raycast-Experimental"] == "autoModels" + assert "X-Raycast-Signature-v2" in headers + assert headers["Content-Type"] == "application/json" + assert headers["Authorization"] == "Bearer rca_test_token" + assert headers["User-Agent"].startswith("Raycast/") + + def test_signed_resume_get_omits_content_type(self) -> None: + client = _make_client() + headers = client.build_headers( + sign=True, + body=b"", + is_resume=True, + last_event_id="42", + content_type="application/json", + ) + assert "Content-Type" not in headers + assert headers["Last-Event-ID"] == "42" + assert "X-Raycast-Signature-v2" in headers + + def test_unsigned_omits_raycast_headers(self) -> None: + client = _make_client() + headers = client.build_headers(sign=False, body=b"") + assert "X-Raycast-Signature-v2" not in headers + assert "X-Raycast-Timestamp" not in headers + assert "X-Raycast-DeviceId" not in headers + assert "X-Raycast-Experimental" not in headers + assert "Authorization" in headers + assert "User-Agent" in headers + + def test_browser_headers_present_by_default(self) -> None: + client = _make_client() + headers = client.build_headers(sign=True, body=b"") + assert headers["Accept"] == "*/*" + assert headers["Origin"] == "file://" + assert headers["Sec-Fetch-Site"] == "cross-site" + assert headers["Sec-Fetch-Mode"] == "cors" + assert headers["Sec-Fetch-Dest"] == "empty" + assert headers["Accept-Language"] == "en-US" + + def test_browser_headers_can_be_disabled(self) -> None: + client = _make_client(browser_headers=False, locale="de-DE") + headers = client.build_headers(sign=True, body=b"") + assert "Accept" not in headers + assert "Origin" not in headers + assert "Accept-Language" not in headers + + def test_locale_drives_accept_language(self) -> None: + client = _make_client(locale="ru-RU") + headers = client.build_headers(sign=True, body=b"") + assert headers["Accept-Language"] == "ru-RU" + + def test_empty_bearer_omits_authorization(self) -> None: + client = Client( + config=_make_config(), + bearer_token="", + device_id=DEVICE_ID, + clock=lambda: FIXED_TIMESTAMP, + ) + headers = client.build_headers(sign=False, body=b"") + assert "Authorization" not in headers + + def test_signature_matches_reference_signer(self) -> None: + """The header builder uses the same Signer as `sign.py`, so byte-match.""" + from raycast_api.signing import Signer + + client = _make_client() + signer = Signer(spec=_make_config().signing_spec, secret=REFERENCE_SECRET) + body = b'{"buffer_id":"00000000-0000-0000-0000-000000000000"}' + headers = client.build_headers( + sign=True, body=body, content_type="application/json" + ) + expected = signer.sign( + timestamp=str(FIXED_TIMESTAMP), device_id=DEVICE_ID, body=body + ) + assert headers["X-Raycast-Signature-v2"] == expected + + def test_extra_headers_merged_last(self) -> None: + client = _make_client() + headers = client.build_headers( + sign=True, body=b"", extra={"X-Trace-Id": "abc", "User-Agent": "Overridden"} + ) + assert headers["X-Trace-Id"] == "abc" + assert headers["User-Agent"] == "Overridden" + + + + +class TestUrl: + def test_relative_path(self) -> None: + client = _make_client() + assert client._url("/api/v1/me") == "https://backend.raycast.com/api/v1/me" + + def test_path_without_leading_slash(self) -> None: + client = _make_client() + assert client._url("api/v1/me") == "https://backend.raycast.com/api/v1/me" + + def test_absolute_url_passthrough(self) -> None: + client = _make_client() + assert ( + client._url("https://other.example.com/x") == "https://other.example.com/x" + ) + + def test_custom_backend_url(self) -> None: + cfg = _make_config() + cfg.backend_url = "http://localhost:5001" + client = Client( + config=cfg, bearer_token="x", device_id=DEVICE_ID, clock=lambda: 0 + ) + assert client._url("/api/v1/me") == "http://localhost:5001/api/v1/me" + + + + +URL_ME = "https://backend.raycast.com/api/v1/me" +URL_CHAT = "https://backend.raycast.com/api/v1/ai/chat_completions" +URL_FILES = "https://backend.raycast.com/api/v1/ai/files" + + +@pytest.fixture +def mock_aiohttp(): + with aioresponses() as m: + yield m + + +class TestRequestHappyPath: + async def test_unsigned_get_returns_response( + self, mock_aiohttp: aioresponses + ) -> None: + mock_aiohttp.get(URL_ME, status=200, payload={"id": "user_1"}) + async with _make_client() as client: + async with client.request("GET", "/api/v1/me", sign=False) as resp: + data = await resp.json() + assert data == {"id": "user_1"} + + async def test_signed_post_with_json_body(self, mock_aiohttp: aioresponses) -> None: + captured: dict[str, Any] = {} + + def callback(url: object, **kwargs: Any) -> Any: + from aioresponses import CallbackResult + + captured["headers"] = dict(kwargs["headers"]) + captured["body"] = kwargs.get("data") + return CallbackResult(status=200, payload={"ok": True}) + + mock_aiohttp.post(URL_CHAT, callback=callback) + async with _make_client() as client: + async with client.request( + "POST", + "/api/v1/ai/chat_completions", + json_body={"messages": [{"role": "user", "content": {"text": "hi"}}]}, + ) as resp: + assert resp.status == 200 + assert "X-Raycast-Signature-v2" in captured["headers"] + assert captured["headers"]["X-Raycast-Timestamp"] == str(FIXED_TIMESTAMP) + assert captured["headers"]["X-Raycast-DeviceId"] == DEVICE_ID + assert captured["headers"]["Content-Type"] == "application/json" + body = captured["body"] + assert isinstance(body, (bytes, bytearray)) + assert b'"messages"' in body + assert b": " not in body + + async def test_delete_with_body(self, mock_aiohttp: aioresponses) -> None: + captured: dict[str, Any] = {} + + def callback(url: object, **kwargs: Any) -> Any: + from aioresponses import CallbackResult + + captured["body"] = kwargs.get("data") + return CallbackResult(status=204) + + mock_aiohttp.delete(URL_FILES, callback=callback) + async with _make_client() as client: + async with client.request( + "DELETE", "/api/v1/ai/files", json_body={"chat_ids": ["abc"]} + ) as resp: + assert resp.status == 204 + assert captured["body"] == b'{"chat_ids":["abc"]}' + + async def test_resume_get_omits_content_type_in_request( + self, mock_aiohttp: aioresponses + ) -> None: + captured: dict[str, Any] = {} + + def callback(url: object, **kwargs: Any) -> Any: + from aioresponses import CallbackResult + + captured["headers"] = dict(kwargs["headers"]) + return CallbackResult(status=200, body=b"") + + url_resume_re = re.compile( + r"^https://backend\.raycast\.com/api/v1/ai/chat_completions/resume.*$" + ) + mock_aiohttp.get(url_resume_re, callback=callback) + async with _make_client() as client: + async with client.request( + "GET", + "/api/v1/ai/chat_completions/resume", + sign=True, + is_resume=True, + last_event_id="9", + params={"buffer_id": "abc"}, + ): + pass + assert "Content-Type" not in captured["headers"] + assert captured["headers"]["Last-Event-ID"] == "9" + assert "X-Raycast-Signature-v2" in captured["headers"] + + async def test_body_passthrough_str(self, mock_aiohttp: aioresponses) -> None: + captured: dict[str, Any] = {} + + def callback(url: object, **kwargs: Any) -> Any: + from aioresponses import CallbackResult + + captured["body"] = kwargs.get("data") + return CallbackResult(status=200, body=b"") + + mock_aiohttp.post(URL_FILES, callback=callback) + async with _make_client() as client: + async with client.request("POST", "/api/v1/ai/files", body='{"raw":1}'): + pass + assert captured["body"] == b'{"raw":1}' + + async def test_body_passthrough_bytes(self, mock_aiohttp: aioresponses) -> None: + captured: dict[str, Any] = {} + + def callback(url: object, **kwargs: Any) -> Any: + from aioresponses import CallbackResult + + captured["body"] = kwargs.get("data") + return CallbackResult(status=200, body=b"") + + mock_aiohttp.post(URL_FILES, callback=callback) + async with _make_client() as client: + async with client.request("POST", "/api/v1/ai/files", body=b"\x00raw"): + pass + assert captured["body"] == b"\x00raw" + + async def test_both_body_and_json_rejected(self) -> None: + async with _make_client() as client: + with pytest.raises(ValueError, match="not both"): + async with client.request( + "POST", "/api/v1/ai/files", body=b"x", json_body={"a": 1} + ): + pass + + async def test_external_session_not_closed(self) -> None: + external = aiohttp.ClientSession() + try: + client = Client( + config=_make_config(), + bearer_token="t", + device_id=DEVICE_ID, + session=external, + clock=lambda: 0, + ) + await client.close() + assert not external.closed + finally: + await external.close() + + + + +class TestErrorMapping: + async def test_401_becomes_auth_error(self, mock_aiohttp: aioresponses) -> None: + mock_aiohttp.get(URL_ME, status=401, body=b"unauthorized") + async with _make_client(retry=RetryPolicy(max_attempts=1)) as client: + with pytest.raises(AuthError) as ei: + async with client.request("GET", "/api/v1/me", sign=False): + pass + assert ei.value.status == 401 + assert ei.value.body == "unauthorized" + + async def test_429_becomes_rate_limit_error( + self, mock_aiohttp: aioresponses + ) -> None: + mock_aiohttp.get( + URL_ME, status=429, headers={"Retry-After": "12"}, body=b"slow down" + ) + async with _make_client(retry=RetryPolicy(max_attempts=1)) as client: + with pytest.raises(RateLimitError) as ei: + async with client.request("GET", "/api/v1/me", sign=False): + pass + assert ei.value.status == 429 + assert ei.value.retry_after == 12.0 + + async def test_500_becomes_status_error(self, mock_aiohttp: aioresponses) -> None: + mock_aiohttp.get(URL_ME, status=500, body=b"oops") + async with _make_client(retry=RetryPolicy(max_attempts=1)) as client: + with pytest.raises(HTTPStatusError) as ei: + async with client.request("GET", "/api/v1/me", sign=False): + pass + assert ei.value.status == 500 + assert not isinstance(ei.value, (AuthError, RateLimitError)) + + async def test_404_not_retried(self, mock_aiohttp: aioresponses) -> None: + mock_aiohttp.get(URL_ME, status=404) + async with _make_client() as client: + with pytest.raises(HTTPStatusError) as ei: + async with client.request("GET", "/api/v1/me", sign=False): + pass + assert ei.value.status == 404 + + + + +class TestRetry: + async def test_retries_503_then_succeeds(self, mock_aiohttp: aioresponses) -> None: + sleeps: list[float] = [] + + async def fake_sleep(d: float) -> None: + sleeps.append(d) + + mock_aiohttp.get(URL_ME, status=503) + mock_aiohttp.get(URL_ME, status=200, payload={"ok": True}) + + client = Client( + config=_make_config(), + bearer_token="t", + device_id=DEVICE_ID, + clock=lambda: 0, + sleep=fake_sleep, + retry=RetryPolicy(max_attempts=3, initial_delay=0.1, max_delay=1.0), + ) + async with client: + async with client.request("GET", "/api/v1/me", sign=False) as resp: + data = await resp.json() + assert data == {"ok": True} + assert sleeps == [0.1] + + async def test_respects_retry_after_on_429( + self, mock_aiohttp: aioresponses + ) -> None: + sleeps: list[float] = [] + + async def fake_sleep(d: float) -> None: + sleeps.append(d) + + mock_aiohttp.get(URL_ME, status=429, headers={"Retry-After": "2"}) + mock_aiohttp.get(URL_ME, status=200, payload={"ok": True}) + client = Client( + config=_make_config(), + bearer_token="t", + device_id=DEVICE_ID, + clock=lambda: 0, + sleep=fake_sleep, + retry=RetryPolicy(max_attempts=3, initial_delay=0.5, max_delay=10), + ) + async with client: + async with client.request("GET", "/api/v1/me", sign=False): + pass + assert sleeps == [2.0] + + async def test_gives_up_after_max_attempts( + self, mock_aiohttp: aioresponses + ) -> None: + async def fake_sleep(_d: float) -> None: + pass + + for _ in range(4): + mock_aiohttp.get(URL_ME, status=503) + + client = Client( + config=_make_config(), + bearer_token="t", + device_id=DEVICE_ID, + clock=lambda: 0, + sleep=fake_sleep, + retry=RetryPolicy(max_attempts=3, initial_delay=0.01), + ) + async with client: + with pytest.raises(HTTPStatusError) as ei: + async with client.request("GET", "/api/v1/me", sign=False): + pass + assert ei.value.status == 503 + + async def test_resigns_on_retry(self, mock_aiohttp: aioresponses) -> None: + """Each attempt re-signs with a fresh timestamp, not the original one.""" + clock = iter([1000, 1001]) + captured: list[dict[str, str]] = [] + + def cb(url: object, **kwargs: Any) -> Any: + from aioresponses import CallbackResult + + captured.append(dict(kwargs["headers"])) + status = 503 if len(captured) == 1 else 200 + return CallbackResult(status=status, payload={}) + + mock_aiohttp.post(URL_CHAT, callback=cb) + mock_aiohttp.post(URL_CHAT, callback=cb) + + async def fake_sleep(_d: float) -> None: + pass + + client = Client( + config=_make_config(), + bearer_token="t", + device_id=DEVICE_ID, + clock=lambda: next(clock), + sleep=fake_sleep, + retry=RetryPolicy(max_attempts=2, initial_delay=0.01), + ) + async with client: + async with client.request( + "POST", "/api/v1/ai/chat_completions", json_body={"x": 1} + ): + pass + assert captured[0]["X-Raycast-Timestamp"] == "1000" + assert captured[1]["X-Raycast-Timestamp"] == "1001" + assert ( + captured[0]["X-Raycast-Signature-v2"] + != captured[1]["X-Raycast-Signature-v2"] + ) + + + + +class TestStreaming: + async def test_error_event_raises(self, mock_aiohttp: aioresponses) -> None: + body = b'event: error\ndata: {"message":"boom"}\n\n' + mock_aiohttp.post(URL_CHAT, status=200, body=body) + async with _make_client() as client: + events: list[SSEEvent] = [] + with pytest.raises(StreamError) as ei: + async for evt in client.stream( + "POST", "/api/v1/ai/chat_completions", json_body={"x": 1} + ): + events.append(evt) + assert ei.value.payload == {"message": "boom"} + assert len(events) == 1 + assert events[0].is_error + + async def test_stream_does_not_retry_on_2xx( + self, mock_aiohttp: aioresponses + ) -> None: + body = b'data: a\n\nevent: complete\ndata: {"complete":true}\n\n' + mock_aiohttp.post(URL_CHAT, status=200, body=body) + async with _make_client() as client: + events = [e async for e in client.stream("POST", URL_CHAT, json_body={})] + assert [e.data for e in events] == ["a", '{"complete":true}'] + + + + +class TestSessionLifecycle: + async def test_request_without_session_raises_clear_error(self) -> None: + client = Client( + config=_make_config(), + bearer_token="t", + device_id=DEVICE_ID, + clock=lambda: 0, + ) + with pytest.raises(RuntimeError, match="not initialised"): + async with client.request("GET", "/api/v1/me", sign=False): + pass + + async def test_owned_session_closed_on_exit(self) -> None: + client = _make_client() + async with client: + assert client._session is not None and not client._session.closed + assert client._session is None diff --git a/tests/test_live_app.py b/tests/test_live_app.py new file mode 100644 index 0000000..e37f34e --- /dev/null +++ b/tests/test_live_app.py @@ -0,0 +1,79 @@ +"""Integration tests against the real Raycast.app, if present. + +These tests are gated on `RAYCAST_APP_PATH` (or, as a developer convenience, +the `Raycast Beta.app` sitting next to the repo). They prove that discovery +gives us the same signing_secret HANDOFF.md documents — i.e. that the AST +patterns we match against survive on the actual minified bundle, not just the +synthetic fixture. + +Skipped automatically if no app is found, so CI without an .app stays green. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from raycast_api.config import Config + +EXPECTED_SECRET = "6bc455473576ce2cd6f70426caff867aabbe3f7291c1a79681af5e8ce0ca1408" + + +def _app_path() -> Path | None: + env = os.environ.get("RAYCAST_APP_PATH") + candidates: list[Path] = [] + if env: + candidates.append(Path(env)) + candidates.extend( + [ + Path(__file__).resolve().parents[2] / "Raycast Beta.app", + Path("/Applications/Raycast Beta.app"), + Path("/Applications/Raycast.app"), + ] + ) + for p in candidates: + if p.is_dir(): + return p + return None + + +pytestmark = pytest.mark.local_app + + +@pytest.fixture +def real_app() -> Path: + app = _app_path() + if app is None: + pytest.skip("No local Raycast.app found (set RAYCAST_APP_PATH to override)") + raise AssertionError("unreachable") + return app + + +def test_discover_secret(real_app: Path, isolated_cache: Path) -> None: + cfg = Config.discover_from_app(real_app) + assert len(cfg.signature_secret) == 64 + assert all(c in "0123456789abcdef" for c in cfg.signature_secret) + if cfg.signature_secret != EXPECTED_SECRET: + pytest.skip( + f"Local Raycast secret ({cfg.signature_secret[-8:]}) doesn't match HANDOFF " + f"({EXPECTED_SECRET[-8:]}) — likely a different Raycast version. Discovery shape OK." + ) + + +def test_discover_signing_spec_shape(real_app: Path, isolated_cache: Path) -> None: + cfg = Config.discover_from_app(real_app) + spec = cfg.signing_spec + assert spec.join_char == "." + assert spec.body_hash_algorithm == "SHA-256" + assert spec.hmac_algorithm == "SHA-256" + assert spec.key_encoding == "utf-8" + ranges = {(r.start, r.end, r.shift) for r in spec.rot_ranges} + assert ranges == {(65, 90, 13), (97, 122, 13), (48, 57, 5)} + + +def test_app_version_and_ua(real_app: Path, isolated_cache: Path) -> None: + cfg = Config.discover_from_app(real_app) + assert cfg.app_version + assert cfg.user_agent.startswith(f"Raycast/{cfg.app_version} (x-macOS Version ") diff --git a/tests/test_live_chat.py b/tests/test_live_chat.py new file mode 100644 index 0000000..8ee0fd5 --- /dev/null +++ b/tests/test_live_chat.py @@ -0,0 +1,92 @@ +"""Live integration tests against `backend.raycast.com`. + +Opt-in: gated on the `--live` pytest flag AND on env vars supplying real +credentials. Without `--live` these are all skipped by the +`pytest_collection_modifyitems` hook in `conftest.py`. + +Required env vars when running with `--live`: + RAYCAST_BEARER — a valid OAuth bearer token (rca_...) + RAYCAST_DEVICE_ID — a stable 64-hex device id + +Optional: + RAYCAST_CONFIG — path to a `config.json` produced by `raycast-api init`. + Defaults to `./config.json` (repo-root one shipped with + this checkout). + +Why these tests exist: the synthetic-fixture suite proves the signing math +is internally consistent. Only a real round-trip proves the bytes we send +on the wire actually match what the server expects — i.e. catches the +"refactor in `transforms.py` broke prod" class of regression. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from raycast_api.ai.types import Message +from raycast_api.client.http import Client +from raycast_api.config import Config + +pytestmark = pytest.mark.live + + +def _bearer() -> str: + val = os.environ.get("RAYCAST_BEARER") + if not val: + pytest.skip("RAYCAST_BEARER not set") + return val + + +def _device_id() -> str: + val = os.environ.get("RAYCAST_DEVICE_ID") + if not val: + pytest.skip("RAYCAST_DEVICE_ID not set") + return val + + +def _config() -> Config: + path = Path(os.environ.get("RAYCAST_CONFIG", "config.json")).expanduser() + if not path.is_file(): + pytest.skip(f"no config at {path} — run `raycast-api init` first") + return Config.load(path) + + +@pytest.fixture +def client() -> Client: + return Client(config=_config(), bearer_token=_bearer(), device_id=_device_id()) + + +async def test_me(client: Client) -> None: + """Bearer-token probe. Unsigned — proves the token is alive.""" + async with client: + me = await client.me.get() + assert isinstance(me, dict) + assert me, "empty /me response — token likely invalid" + + +async def test_models_list(client: Client) -> None: + """Lists `/ai/models`. Unsigned, but exercises auth + JSON decoding.""" + async with client: + models = await client.models.list() + assert models.models, "no models returned" + + +async def test_chat_streaming(client: Client) -> None: + """One-token chat completion. Proves signing works end-to-end. + + Picks the first model from the live catalog so the test doesn't pin + a specific provider. Prompt is intentionally trivial to minimize cost. + """ + async with client: + catalog = await client.models.list() + model = catalog.models[0] + chunks: list[str] = [] + async for chunk in client.chat.stream( + model=model, messages=[Message.user("Reply with the single word: OK")] + ): + if chunk.text: + chunks.append(chunk.text) + assert chunks, "no text chunks streamed — signing or stream parsing broke" diff --git a/tests/test_me.py b/tests/test_me.py new file mode 100644 index 0000000..62c1f70 --- /dev/null +++ b/tests/test_me.py @@ -0,0 +1,67 @@ +"""Tests for `raycast_api.ai.me.MeAPI`. + +Trivial endpoint — verify the unsigned GET goes to the right URL and the +response is returned as a raw dict. +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from aioresponses import aioresponses + +from raycast_api.client import Client +from raycast_api.config import Config +from raycast_api.signing_spec import RotRange, SigningSpec + + +def _config() -> Config: + return Config( + signature_secret="0" * 64, + signing_spec=SigningSpec( + rot_fn_name="Sur", + signing_fn_name="Nkt", + rot_ranges=[ + RotRange(start=65, end=90, shift=13), + RotRange(start=97, end=122, shift=13), + RotRange(start=48, end=57, shift=5), + ], + ), + app_version="0.60.1.0", + user_agent="Raycast/0.60.1.0 (x-macOS Version 26.3.1)", + bundle_hash="0" * 64, + launcher_hash="0" * 64, + ) + + +class TestMeAPI: + @pytest.mark.asyncio + async def test_get_returns_dict_and_does_not_sign(self) -> None: + captured: dict[str, Any] = {} + + def _cb(url: Any, **kwargs: Any) -> Any: + captured["headers"] = dict(kwargs.get("headers") or {}) + from aioresponses import CallbackResult + + return CallbackResult( + status=200, + payload={ + "id": "u_1", + "email": "alice@example.com", + "has_pro_features": True, + }, + ) + + with aioresponses() as mocked: + mocked.get("https://backend.raycast.com/api/v1/me", callback=_cb) + client = Client( + config=_config(), bearer_token="rca_test", device_id="a" * 64 + ) + async with client: + me = await client.me.get() + + assert me["email"] == "alice@example.com" + assert me["has_pro_features"] is True + assert "X-Raycast-Signature-v2" not in captured["headers"] + assert captured["headers"]["Authorization"] == "Bearer rca_test" diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..c2ad965 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,153 @@ +"""Tests for `raycast_api.ai.models.ModelsAPI` and the typed response. + +The `/ai/models` endpoint returns a large payload; we only exercise the +shape parsing (`ModelsResponse.from_wire`) and the `by_id` lookup plus +the header that distinguishes our request from older Raycast clients +(`X-Raycast-Experimental: autoModels`). +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from aioresponses import aioresponses + +from raycast_api.ai.models import ModelInfo, ModelsResponse +from raycast_api.client import Client +from raycast_api.config import Config +from raycast_api.signing_spec import RotRange, SigningSpec + + +def _config() -> Config: + return Config( + signature_secret="0" * 64, + signing_spec=SigningSpec( + rot_fn_name="Sur", + signing_fn_name="Nkt", + rot_ranges=[ + RotRange(start=65, end=90, shift=13), + RotRange(start=97, end=122, shift=13), + RotRange(start=48, end=57, shift=5), + ], + ), + app_version="0.60.1.0", + user_agent="Raycast/0.60.1.0 (x-macOS Version 26.3.1)", + bundle_hash="0" * 64, + launcher_hash="0" * 64, + ) + + +def _client(**kwargs: Any) -> Client: + return Client( + config=_config(), + bearer_token="rca_test", + device_id="a" * 64, + clock=lambda: 0, + **kwargs, + ) + + +SAMPLE_RESPONSE = { + "models": [ + { + "id": "openai-gpt-5", + "name": "GPT-5", + "model": "gpt-5", + "provider": "openai", + "provider_name": "OpenAI", + "provider_brand": "openai", + "context": 200000, + "description": "Most capable.", + "status": "public", + "availability": "public", + "features": ["chat", "quick_ai", "commands"], + "abilities": { + "system_message": {"supported": True}, + "temperature": {"supported": True}, + "reasoning_effort": { + "supported": True, + "options": ["minimal", "low", "medium", "high"], + }, + }, + "in_better_ai_subscription": True, + "requires_better_ai": True, + }, + { + "id": "anthropic-claude-sonnet-4-6", + "name": "Claude Sonnet 4.6", + "model": "claude-sonnet-4-6", + "provider": "anthropic", + "context": 200000, + "features": ["chat"], + "abilities": {"temperature": {"supported": True}}, + "in_better_ai_subscription": True, + }, + ], + "default_models": {"chat": "openai-gpt-5", "quick_ai": "openai-gpt-5"}, + "free_models": ["openai-gpt-4o-mini", "anthropic-claude-haiku-4-5"], +} + + +class TestModelsResponse: + def test_parses_model_list(self) -> None: + r = ModelsResponse.from_wire(SAMPLE_RESPONSE) + assert len(r.models) == 2 + assert r.models[0].id == "openai-gpt-5" + assert r.models[0].provider == "openai" + assert r.default_models == {"chat": "openai-gpt-5", "quick_ai": "openai-gpt-5"} + assert r.free_model_ids == ["openai-gpt-4o-mini", "anthropic-claude-haiku-4-5"] + + def test_free_models_tolerates_object_shape(self) -> None: + """If the server ever switches back to emitting full objects, parse those too.""" + r = ModelsResponse.from_wire( + { + "models": [], + "default_models": {}, + "free_models": [{"id": "a"}, {"id": "b"}], + } + ) + assert r.free_model_ids == ["a", "b"] + + def test_by_id_lookup(self) -> None: + r = ModelsResponse.from_wire(SAMPLE_RESPONSE) + info = r.by_id("openai-gpt-5") + assert info is not None + assert info.name == "GPT-5" + assert r.by_id("missing") is None + + def test_abilities_helpers(self) -> None: + r = ModelsResponse.from_wire(SAMPLE_RESPONSE) + gpt5 = r.by_id("openai-gpt-5") + assert gpt5 is not None + assert gpt5.supports_temperature is True + assert gpt5.supports_reasoning_effort is True + assert gpt5.reasoning_effort_options == ["minimal", "low", "medium", "high"] + claude = r.by_id("anthropic-claude-sonnet-4-6") + assert claude is not None + assert claude.supports_reasoning_effort is False + assert claude.reasoning_effort_options == [] + + +class TestModelsAPI: + @pytest.mark.asyncio + async def test_list_sends_experimental_header_and_no_signature(self) -> None: + captured: dict[str, Any] = {} + + def _cb(url: Any, **kwargs: Any) -> Any: + captured["headers"] = dict(kwargs.get("headers") or {}) + from aioresponses import CallbackResult + + return CallbackResult(status=200, payload=SAMPLE_RESPONSE) + + with aioresponses() as mocked: + mocked.get("https://backend.raycast.com/api/v1/ai/models", callback=_cb) + async with _client() as client: + resp = await client.models.list() + + assert resp.by_id("openai-gpt-5") is not None + assert captured["headers"]["X-Raycast-Experimental"] == "autoModels" + assert "X-Raycast-Signature-v2" not in captured["headers"] + assert "X-Raycast-Timestamp" not in captured["headers"] + assert "X-Raycast-DeviceId" not in captured["headers"] + assert captured["headers"]["Authorization"] == "Bearer rca_test" diff --git a/tests/test_retry.py b/tests/test_retry.py new file mode 100644 index 0000000..d1aa818 --- /dev/null +++ b/tests/test_retry.py @@ -0,0 +1,135 @@ +"""Retry policy tests.""" + +from __future__ import annotations + +import email.utils +import time + +import pytest + +from raycast_api.client.retry import ( + DEFAULT_RETRY_STATUSES, + RetryPolicy, + parse_retry_after, +) + + +class TestRetryStatuses: + def test_default_includes_429_and_5xx(self) -> None: + for code in (429, 500, 502, 503, 504): + assert code in DEFAULT_RETRY_STATUSES + + def test_default_includes_transient_4xx(self) -> None: + assert 408 in DEFAULT_RETRY_STATUSES + assert 425 in DEFAULT_RETRY_STATUSES + + def test_default_excludes_permanent_errors(self) -> None: + for code in (400, 401, 403, 404, 422): + assert code not in DEFAULT_RETRY_STATUSES + + +class TestShouldRetry: + def test_retries_429(self) -> None: + p = RetryPolicy(max_attempts=3) + assert p.should_retry(attempt=1, status=429) is True + assert p.should_retry(attempt=2, status=429) is True + + def test_stops_at_max_attempts(self) -> None: + p = RetryPolicy(max_attempts=3) + assert p.should_retry(attempt=3, status=429) is False + assert p.should_retry(attempt=99, status=503) is False + + def test_does_not_retry_4xx_permanent(self) -> None: + p = RetryPolicy() + assert p.should_retry(attempt=1, status=400) is False + assert p.should_retry(attempt=1, status=401) is False + assert p.should_retry(attempt=1, status=404) is False + + def test_retries_custom_status_set(self) -> None: + p = RetryPolicy(retry_statuses=frozenset({418})) + assert p.should_retry(attempt=1, status=418) is True + assert p.should_retry(attempt=1, status=503) is False + + +class TestDelaySchedule: + def test_exponential_backoff(self) -> None: + p = RetryPolicy(initial_delay=1.0, multiplier=2.0, max_delay=60.0) + assert p.delay_for_attempt(1) == 1.0 + assert p.delay_for_attempt(2) == 2.0 + assert p.delay_for_attempt(3) == 4.0 + assert p.delay_for_attempt(4) == 8.0 + + def test_clamped_to_max_delay(self) -> None: + p = RetryPolicy(initial_delay=10.0, multiplier=10.0, max_delay=20.0) + assert p.delay_for_attempt(1) == 10.0 + assert p.delay_for_attempt(2) == 20.0 + assert p.delay_for_attempt(5) == 20.0 + + def test_retry_after_overrides_schedule(self) -> None: + p = RetryPolicy(initial_delay=1.0, max_delay=60.0) + assert p.delay_for_attempt(1, retry_after=7.0) == 7.0 + assert p.delay_for_attempt(3, retry_after=2.5) == 2.5 + + def test_retry_after_clamped_to_max(self) -> None: + p = RetryPolicy(max_delay=10.0) + assert p.delay_for_attempt(1, retry_after=3600.0) == 10.0 + + def test_retry_after_ignored_when_disabled(self) -> None: + p = RetryPolicy(initial_delay=1.0, respect_retry_after=False) + assert p.delay_for_attempt(1, retry_after=999.0) == 1.0 + + def test_negative_retry_after_floors_to_zero(self) -> None: + p = RetryPolicy() + assert p.delay_for_attempt(1, retry_after=-5.0) == 0.0 + + +class TestParseRetryAfter: + def test_none_input_returns_none(self) -> None: + assert parse_retry_after(None) is None + assert parse_retry_after("") is None + + def test_integer_seconds(self) -> None: + assert parse_retry_after("0") == 0.0 + assert parse_retry_after("42") == 42.0 + assert parse_retry_after(" 120 ") == 120.0 + + def test_float_seconds(self) -> None: + assert parse_retry_after("2.5") == 2.5 + + def test_http_date(self) -> None: + now = 1_700_000_000.0 + future = now + 30 + date_str = email.utils.format_datetime( + __import__("datetime").datetime.fromtimestamp( + future, tz=__import__("datetime").timezone.utc + ) + ) + got = parse_retry_after(date_str, now=now) + assert got is not None + assert 29.0 <= got <= 31.0 + + def test_http_date_in_past_floors_to_zero(self) -> None: + date_str = "Sun, 06 Nov 1994 08:49:37 GMT" + assert parse_retry_after(date_str, now=time.time()) == 0.0 + + def test_garbage_returns_none(self) -> None: + assert parse_retry_after("not a date") is None + + +class TestNegativeShift: + def test_zero_attempts_pre_increment_guard(self) -> None: + p = RetryPolicy(max_attempts=1) + assert p.should_retry(attempt=1, status=429) is False + + +def test_default_policy_total_budget_is_under_30s() -> None: + """Default config should not park the client for ages on a bad path.""" + p = RetryPolicy() + total = sum(p.delay_for_attempt(a) for a in range(1, p.max_attempts)) + assert total < 30.0, f"default total backoff = {total}s, too long" + + +@pytest.mark.parametrize("status", sorted(DEFAULT_RETRY_STATUSES)) +def test_each_default_status_retryable_at_attempt_1(status: int) -> None: + p = RetryPolicy(max_attempts=2) + assert p.should_retry(attempt=1, status=status) diff --git a/tests/test_signer.py b/tests/test_signer.py new file mode 100644 index 0000000..03e0a84 --- /dev/null +++ b/tests/test_signer.py @@ -0,0 +1,122 @@ +"""End-to-end tests for raycast_api.signing.Signer. + +Hand-computed canonical/HMAC values for synthetic specs prove the spec +fields actually drive behaviour (not just defaults). +""" + +from __future__ import annotations + +import hashlib +import hmac as _hmac + +import pytest + +from raycast_api.signing import Signer +from raycast_api.signing_spec import RotRange, SigningSpec + + +RAYCAST_SPEC = SigningSpec( + rot_fn_name="Sur", + signing_fn_name="Nkt", + rot_ranges=[ + RotRange(0x41, 0x5A, 13), + RotRange(0x61, 0x7A, 13), + RotRange(0x30, 0x39, 5), + ], +) + +FAKE_SECRET = "DEAD" + "BEEF" * 15 +TS = "1778858809" +DEVICE = "20eca913cada74f879e6535304f9d44da380c28eb855065c0d71017a3d7c3099" + + +class TestCanonicalString: + """Canonical string composition matches rot(ts) + join + rot(device) + join + rot(sha256(body)).""" + + def test_canonical_string_shape(self) -> None: + body = b"hello" + signer = Signer(spec=RAYCAST_SPEC, secret=FAKE_SECRET) + canonical = signer.canonical_string(TS, DEVICE, body) + body_hex = hashlib.sha256(body).hexdigest() + from raycast_api.signing.transforms import apply_rot + + expected = ".".join( + apply_rot(p, RAYCAST_SPEC.rot_ranges) for p in (TS, DEVICE, body_hex) + ) + assert canonical == expected + + +class TestSpecDriven: + """Changing a spec field changes the signature — proves nothing is hardcoded.""" + + def test_join_char_change_changes_signature(self) -> None: + a = Signer(spec=RAYCAST_SPEC, secret=FAKE_SECRET) + b_spec = SigningSpec( + rot_fn_name="Sur", + signing_fn_name="Nkt", + rot_ranges=RAYCAST_SPEC.rot_ranges, + join_char=":", + ) + b = Signer(spec=b_spec, secret=FAKE_SECRET) + assert a.sign(timestamp=TS, device_id=DEVICE, body=b"x") != b.sign( + timestamp=TS, device_id=DEVICE, body=b"x" + ) + + def test_rot_ranges_change_changes_signature(self) -> None: + a = Signer(spec=RAYCAST_SPEC, secret=FAKE_SECRET) + b_spec = SigningSpec( + rot_fn_name="Sur", + signing_fn_name="Nkt", + rot_ranges=[ + RotRange(0x41, 0x5A, 1), + RotRange(0x61, 0x7A, 1), + RotRange(0x30, 0x39, 1), + ], + ) + b = Signer(spec=b_spec, secret=FAKE_SECRET) + assert a.sign(timestamp=TS, device_id=DEVICE, body=b"x") != b.sign( + timestamp=TS, device_id=DEVICE, body=b"x" + ) + + def test_secret_change_changes_signature(self) -> None: + a = Signer(spec=RAYCAST_SPEC, secret=FAKE_SECRET) + b = Signer(spec=RAYCAST_SPEC, secret="CAFE" + "BABE" * 15) + assert a.sign(timestamp=TS, device_id=DEVICE, body=b"x") != b.sign( + timestamp=TS, device_id=DEVICE, body=b"x" + ) + + def test_body_bytes_change_changes_signature(self) -> None: + signer = Signer(spec=RAYCAST_SPEC, secret=FAKE_SECRET) + sig_a = signer.sign(timestamp=TS, device_id=DEVICE, body=b'{"k":1}') + sig_b = signer.sign(timestamp=TS, device_id=DEVICE, body=b'{"k":2}') + assert sig_a != sig_b + + def test_timestamp_string_used_verbatim(self) -> None: + signer = Signer(spec=RAYCAST_SPEC, secret=FAKE_SECRET) + a = signer.sign(timestamp="1778858809", device_id=DEVICE, body=b"x") + b = signer.sign(timestamp="01778858809", device_id=DEVICE, body=b"x") + assert a != b + + +class TestConstruction: + def test_empty_rot_ranges_rejected(self) -> None: + bad = SigningSpec(rot_fn_name="x", signing_fn_name="y", rot_ranges=[]) + with pytest.raises(ValueError): + Signer(spec=bad, secret=FAKE_SECRET) + + def test_signer_is_reusable_for_concurrent_messages(self) -> None: + signer = Signer(spec=RAYCAST_SPEC, secret=FAKE_SECRET) + outputs = { + signer.sign(timestamp=TS, device_id=DEVICE, body=f"{i}".encode()) + for i in range(8) + } + assert len(outputs) == 8 + + def test_hmac_uses_utf8_encoded_secret_not_hex_decoded(self) -> None: + signer = Signer(spec=RAYCAST_SPEC, secret=FAKE_SECRET) + body = b'{"buffer_id":"x"}' + canonical = signer.canonical_string(TS, DEVICE, body) + expected = _hmac.new( + FAKE_SECRET.encode("utf-8"), canonical.encode("utf-8"), hashlib.sha256 + ).hexdigest() + assert signer.sign(timestamp=TS, device_id=DEVICE, body=body) == expected diff --git a/tests/test_streaming.py b/tests/test_streaming.py new file mode 100644 index 0000000..819697a --- /dev/null +++ b/tests/test_streaming.py @@ -0,0 +1,247 @@ +"""SSE parser tests. + +Strategy: the parser is byte-driven. Most tests feed bytes in one shot; +the chunking tests feed the same input split at every possible byte +boundary and assert the output is identical. +""" + +from __future__ import annotations + +import json +from typing import AsyncIterator + +import pytest + +from raycast_api.client.streaming import SSEEvent, SSEParser, iter_sse + + +def _drain_bytes(data: bytes) -> list[SSEEvent]: + """Run the parser over `data` in one feed, then flush.""" + p = SSEParser() + out = list(p.feed(data)) + out.extend(p.flush()) + return out + + +def _drain_chunked(data: bytes, chunk_size: int) -> list[SSEEvent]: + p = SSEParser() + out: list[SSEEvent] = [] + for i in range(0, len(data), chunk_size): + out.extend(p.feed(data[i : i + chunk_size])) + out.extend(p.flush()) + return out + + +class TestBasicShape: + def test_single_event(self) -> None: + events = _drain_bytes(b'data: {"text":"hi"}\n\n') + assert len(events) == 1 + evt = events[0] + assert evt.id is None + assert evt.event is None + assert evt.data == '{"text":"hi"}' + assert evt.json() == {"text": "hi"} + + def test_id_and_data(self) -> None: + events = _drain_bytes(b'id: 7\ndata: {"text":"hi"}\n\n') + assert len(events) == 1 + assert events[0].id == "7" + assert events[0].data == '{"text":"hi"}' + + def test_event_field(self) -> None: + events = _drain_bytes(b'event: complete\ndata: {"complete":true}\n\n') + assert events[0].event == "complete" + assert events[0].is_terminal + + def test_default_event_is_none(self) -> None: + events = _drain_bytes(b"data: x\n\n") + assert events[0].event is None + + def test_done_terminator_legacy(self) -> None: + events = _drain_bytes(b"data: [DONE]\n\n") + assert events[0].is_terminal + assert not events[0].is_error + + +class TestLineEndings: + def test_crlf_line_endings(self) -> None: + events = _drain_bytes(b'id: 1\r\ndata: {"text":"x"}\r\n\r\n') + assert len(events) == 1 + assert events[0].id == "1" + assert events[0].data == '{"text":"x"}' + + def test_mixed_endings(self) -> None: + events = _drain_bytes(b'id: 1\ndata: {"text":"x"}\r\n\n') + assert len(events) == 1 + assert events[0].id == "1" + + def test_lone_cr_is_not_a_terminator(self) -> None: + events = _drain_bytes(b"data: a\rcontinued\n\n") + assert len(events) == 1 + assert "\r" in events[0].data + + +class TestMultilineData: + def test_two_data_lines_joined_with_newline(self) -> None: + events = _drain_bytes(b"data: line1\ndata: line2\n\n") + assert events[0].data == "line1\nline2" + + def test_empty_data_line_still_contributes(self) -> None: + events = _drain_bytes(b"data: line1\ndata:\ndata: line3\n\n") + assert events[0].data == "line1\n\nline3" + + +class TestCommentsAndUnknownFields: + def test_colon_prefix_is_comment(self) -> None: + events = _drain_bytes(b": keepalive\ndata: x\n\n") + assert len(events) == 1 + assert events[0].data == "x" + + def test_unknown_field_dropped(self) -> None: + events = _drain_bytes(b"retry: 5000\ndata: x\n\n") + assert len(events) == 1 + assert events[0].data == "x" + + def test_only_comments_no_event(self) -> None: + events = _drain_bytes(b": just\n: comments\n\n") + assert events == [] + + def test_line_without_colon(self) -> None: + events = _drain_bytes(b"data\n\n") + assert len(events) == 1 + assert events[0].data == "" + + +class TestFieldParsing: + def test_single_space_after_colon_is_stripped(self) -> None: + events = _drain_bytes(b"data: x\n\n") + assert events[0].data == "x" + + def test_no_space_after_colon(self) -> None: + events = _drain_bytes(b"data:x\n\n") + assert events[0].data == "x" + + def test_multiple_spaces_preserve_second(self) -> None: + events = _drain_bytes(b"data: x\n\n") + assert events[0].data == " x" + + def test_extra_colons_in_value(self) -> None: + events = _drain_bytes(b'data: {"k":"v"}\n\n') + assert events[0].data == '{"k":"v"}' + + +class TestEventBoundaries: + def test_two_events(self) -> None: + data = b"id: 1\ndata: a\n\nid: 2\ndata: b\n\n" + events = _drain_bytes(data) + assert [e.id for e in events] == ["1", "2"] + assert [e.data for e in events] == ["a", "b"] + + def test_no_trailing_blank_line_flushes_at_eof(self) -> None: + events = _drain_bytes(b"data: tail") + assert len(events) == 1 + assert events[0].data == "tail" + + +class TestChunkingRobustness: + """Same bytes split at every possible boundary must yield identical events.""" + + PAYLOAD = ( + b'id: 0\ndata: {"reasoning":"","text":""}\n\n' + b'id: 1\ndata: {"text":"hello"}\n\n' + b'event: complete\ndata: {"complete":true}\n\n' + ) + + def _expected(self) -> list[tuple[str | None, str | None, str]]: + ref = _drain_bytes(self.PAYLOAD) + return [(e.id, e.event, e.data) for e in ref] + + @pytest.mark.parametrize("size", [1, 2, 3, 5, 7, 13, 64]) + def test_split_at_arbitrary_size(self, size: int) -> None: + chunks = _drain_chunked(self.PAYLOAD, size) + observed = [(e.id, e.event, e.data) for e in chunks] + assert observed == self._expected() + + def test_split_inside_field_value(self) -> None: + a = b'id: 1\ndata: {"text":"hel' + b = b'lo"}\n\n' + p = SSEParser() + events = list(p.feed(a)) + assert events == [] + events.extend(p.feed(b)) + events.extend(p.flush()) + assert len(events) == 1 + assert events[0].json() == {"text": "hello"} + + def test_split_between_cr_and_lf(self) -> None: + a = b"id: 1\r" + b = b"\ndata: x\r\n\r\n" + p = SSEParser() + out = list(p.feed(a)) + out.extend(p.feed(b)) + out.extend(p.flush()) + assert len(out) == 1 + assert out[0].id == "1" + assert out[0].data == "x" + + +class TestLastEventIdTracking: + def test_advances_with_id_field(self) -> None: + p = SSEParser() + list(p.feed(b"id: 5\ndata: a\n\n")) + assert p.last_event_id == "5" + list(p.feed(b"id: 9\ndata: b\n\n")) + assert p.last_event_id == "9" + + def test_event_without_id_keeps_previous(self) -> None: + p = SSEParser() + list(p.feed(b"id: 5\ndata: a\n\ndata: b\n\n")) + assert p.last_event_id == "5" + + def test_starts_none(self) -> None: + p = SSEParser() + assert p.last_event_id is None + + +class TestIterSseAsync: + @pytest.mark.asyncio + async def test_async_iteration_over_chunks(self) -> None: + async def gen() -> AsyncIterator[bytes]: + yield b"id: 1\nda" + yield b'ta: {"text":"a"}\n\n' + yield b'id: 2\ndata: {"text":"b"}\n\nevent: complete\ndata: {"complete":true}\n\n' + + seen = [e async for e in iter_sse(gen())] + assert [e.id for e in seen] == ["1", "2", None] + assert [e.event for e in seen] == [None, None, "complete"] + assert seen[-1].is_terminal + + @pytest.mark.asyncio + async def test_empty_stream(self) -> None: + async def gen() -> AsyncIterator[bytes]: + if False: + yield b"" + + assert [e async for e in iter_sse(gen())] == [] + + +class TestErrorEvent: + def test_error_event_recognised(self) -> None: + events = _drain_bytes(b'event: error\ndata: {"message":"boom"}\n\n') + assert events[0].is_error + assert events[0].json() == {"message": "boom"} + + def test_default_event_not_error(self) -> None: + events = _drain_bytes(b'data: {"text":"x"}\n\n') + assert events[0].is_error is False + + +class TestJsonParsing: + def test_invalid_json_raises_on_demand(self) -> None: + events = _drain_bytes(b"data: not json\n\n") + with pytest.raises(json.JSONDecodeError): + events[0].json() + + def test_event_data_kept_raw(self) -> None: + events = _drain_bytes(b"data: not json\n\n") + assert events[0].data == "not json" diff --git a/tests/test_transforms.py b/tests/test_transforms.py new file mode 100644 index 0000000..4ab5526 --- /dev/null +++ b/tests/test_transforms.py @@ -0,0 +1,58 @@ +"""Tests for raycast_api.signing.transforms.""" + +from __future__ import annotations + +import pytest + +from raycast_api.signing.transforms import apply_rot +from raycast_api.signing_spec import RotRange + + +RAYCAST_RANGES = [ + RotRange(0x41, 0x5A, 13), + RotRange(0x61, 0x7A, 13), + RotRange(0x30, 0x39, 5), +] + + +class TestRaycastDefaults: + def test_letter_rotation_matches_handoff(self) -> None: + assert apply_rot("abcXYZ", RAYCAST_RANGES) == "nopKLM" + + def test_digit_rotation(self) -> None: + assert apply_rot("01234", RAYCAST_RANGES) == "56789" + assert apply_rot("56789", RAYCAST_RANGES) == "01234" + + def test_punctuation_unchanged(self) -> None: + assert apply_rot("a.b", RAYCAST_RANGES) == "n.o" + assert apply_rot(":/?-_=", RAYCAST_RANGES) == ":/?-_=" + + def test_double_apply_is_identity_for_letters_and_hex_digits(self) -> None: + sample = "deadbeef0123456789" + assert apply_rot(apply_rot(sample, RAYCAST_RANGES), RAYCAST_RANGES) == sample + + def test_empty_string(self) -> None: + assert apply_rot("", RAYCAST_RANGES) == "" + + def test_unicode_outside_ranges_passes_through(self) -> None: + assert apply_rot("café→", RAYCAST_RANGES) == "pnsé→" + + +class TestArbitraryRanges: + def test_single_range_no_overflow(self) -> None: + assert apply_rot("ABCXYZ", [RotRange(0x41, 0x5A, 0)]) == "ABCXYZ" + + def test_shift_wraps(self) -> None: + assert apply_rot("hello", [RotRange(0x61, 0x7A, 26)]) == "hello" + assert apply_rot("abz", [RotRange(0x61, 0x7A, 1)]) == "bca" + + def test_first_matching_range_wins(self) -> None: + ranges = [RotRange(0x61, 0x7A, 1), RotRange(0x61, 0x7A, 5)] + assert apply_rot("a", ranges) == "b" + + def test_range_outside_input_does_nothing(self) -> None: + assert apply_rot("hello", [RotRange(0x30, 0x39, 5)]) == "hello" + + def test_negative_shift_rejected_at_construction(self) -> None: + with pytest.raises(ValueError): + RotRange(0x41, 0x5A, -1) diff --git a/ty.toml b/ty.toml new file mode 100644 index 0000000..a774130 --- /dev/null +++ b/ty.toml @@ -0,0 +1,5 @@ +[environment] +python = ".venv" + +[src] +exclude = ["_extracted", "docs", "tests"] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..6a5be8c --- /dev/null +++ b/uv.lock @@ -0,0 +1,891 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/f5/a20c4ac64aeaef1679e25c9983573618ff765d7aa829fa2b84ae7573169e/aiohttp-3.13.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ab7229b6f9b5c1ba4910d6c41a9eb11f543eadb3f384df1b4c293f4e73d44d6", size = 757513, upload-time = "2026-03-31T21:57:02.146Z" }, + { url = "https://files.pythonhosted.org/packages/75/0a/39fa6c6b179b53fcb3e4b3d2b6d6cad0180854eda17060c7218540102bef/aiohttp-3.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8f14c50708bb156b3a3ca7230b3d820199d56a48e3af76fa21c2d6087190fe3d", size = 506748, upload-time = "2026-03-31T21:57:04.275Z" }, + { url = "https://files.pythonhosted.org/packages/87/ec/e38ce072e724fd7add6243613f8d1810da084f54175353d25ccf9f9c7e5a/aiohttp-3.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e7d2f8616f0ff60bd332022279011776c3ac0faa0f1b463f7bb12326fbc97a1c", size = 501673, upload-time = "2026-03-31T21:57:06.208Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ba/3bc7525d7e2beaa11b309a70d48b0d3cfc3c2089ec6a7d0820d59c657053/aiohttp-3.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2567b72e1ffc3ab25510db43f355b29eeada56c0a622e58dcdb19530eb0a3cb", size = 1763757, upload-time = "2026-03-31T21:57:07.882Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ab/e87744cf18f1bd78263aba24924d4953b41086bd3a31d22452378e9028a0/aiohttp-3.13.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fb0540c854ac9c0c5ad495908fdfd3e332d553ec731698c0e29b1877ba0d2ec6", size = 1720152, upload-time = "2026-03-31T21:57:09.946Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f3/ed17a6f2d742af17b50bae2d152315ed1b164b07a5fd5cc1754d99e4dfa5/aiohttp-3.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9883051c6972f58bfc4ebb2116345ee2aa151178e99c3f2b2bbe2af712abd13", size = 1818010, upload-time = "2026-03-31T21:57:12.157Z" }, + { url = "https://files.pythonhosted.org/packages/53/06/ecbc63dc937192e2a5cb46df4d3edb21deb8225535818802f210a6ea5816/aiohttp-3.13.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2294172ce08a82fb7c7273485895de1fa1186cc8294cfeb6aef4af42ad261174", size = 1907251, upload-time = "2026-03-31T21:57:14.023Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a5/0521aa32c1ddf3aa1e71dcc466be0b7db2771907a13f18cddaa45967d97b/aiohttp-3.13.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a807cabd5115fb55af198b98178997a5e0e57dead43eb74a93d9c07d6d4a7dc", size = 1759969, upload-time = "2026-03-31T21:57:16.146Z" }, + { url = "https://files.pythonhosted.org/packages/f6/78/a38f8c9105199dd3b9706745865a8a59d0041b6be0ca0cc4b2ccf1bab374/aiohttp-3.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6d0d932e0f39c02b80744273cd5c388a2d9bc07760a03164f229c8e02662f6", size = 1616871, upload-time = "2026-03-31T21:57:17.856Z" }, + { url = "https://files.pythonhosted.org/packages/6f/41/27392a61ead8ab38072105c71aa44ff891e71653fe53d576a7067da2b4e8/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:60869c7ac4aaabe7110f26499f3e6e5696eae98144735b12a9c3d9eae2b51a49", size = 1739844, upload-time = "2026-03-31T21:57:19.679Z" }, + { url = "https://files.pythonhosted.org/packages/6e/55/5564e7ae26d94f3214250009a0b1c65a0c6af4bf88924ccb6fdab901de28/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:26d2f8546f1dfa75efa50c3488215a903c0168d253b75fba4210f57ab77a0fb8", size = 1731969, upload-time = "2026-03-31T21:57:22.006Z" }, + { url = "https://files.pythonhosted.org/packages/6d/c5/705a3929149865fc941bcbdd1047b238e4a72bcb215a9b16b9d7a2e8d992/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1162a1492032c82f14271e831c8f4b49f2b6078f4f5fc74de2c912fa225d51d", size = 1795193, upload-time = "2026-03-31T21:57:24.256Z" }, + { url = "https://files.pythonhosted.org/packages/a6/19/edabed62f718d02cff7231ca0db4ef1c72504235bc467f7b67adb1679f48/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8b14eb3262fad0dc2f89c1a43b13727e709504972186ff6a99a3ecaa77102b6c", size = 1606477, upload-time = "2026-03-31T21:57:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/de/fc/76f80ef008675637d88d0b21584596dc27410a990b0918cb1e5776545b5b/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ca9ac61ac6db4eb6c2a0cd1d0f7e1357647b638ccc92f7e9d8d133e71ed3c6ac", size = 1813198, upload-time = "2026-03-31T21:57:28.316Z" }, + { url = "https://files.pythonhosted.org/packages/e5/67/5b3ac26b80adb20ea541c487f73730dc8fa107d632c998f25bbbab98fcda/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7996023b2ed59489ae4762256c8516df9820f751cf2c5da8ed2fb20ee50abab3", size = 1752321, upload-time = "2026-03-31T21:57:30.549Z" }, + { url = "https://files.pythonhosted.org/packages/88/06/e4a2e49255ea23fa4feeb5ab092d90240d927c15e47b5b5c48dff5a9ce29/aiohttp-3.13.5-cp311-cp311-win32.whl", hash = "sha256:77dfa48c9f8013271011e51c00f8ada19851f013cde2c48fca1ba5e0caf5bb06", size = 439069, upload-time = "2026-03-31T21:57:32.388Z" }, + { url = "https://files.pythonhosted.org/packages/c0/43/8c7163a596dab4f8be12c190cf467a1e07e4734cf90eebb39f7f5d53fc6a/aiohttp-3.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:d3a4834f221061624b8887090637db9ad4f61752001eae37d56c52fddade2dc8", size = 462859, upload-time = "2026-03-31T21:57:34.455Z" }, + { url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" }, + { url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" }, + { url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" }, + { url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" }, + { url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" }, + { url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" }, + { url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" }, + { url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" }, + { url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" }, + { url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" }, + { url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" }, + { url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" }, + { url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" }, + { url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" }, + { url = "https://files.pythonhosted.org/packages/78/e9/d76bf503005709e390122d34e15256b88f7008e246c4bdbe915cd4f1adce/aiohttp-3.13.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5029cc80718bbd545123cd8fe5d15025eccaaaace5d0eeec6bd556ad6163d61", size = 742930, upload-time = "2026-03-31T21:58:13.155Z" }, + { url = "https://files.pythonhosted.org/packages/57/00/4b7b70223deaebd9bb85984d01a764b0d7bd6526fcdc73cca83bcbe7243e/aiohttp-3.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bb6bf5811620003614076bdc807ef3b5e38244f9d25ca5fe888eaccea2a9832", size = 496927, upload-time = "2026-03-31T21:58:15.073Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f5/0fb20fb49f8efdcdce6cd8127604ad2c503e754a8f139f5e02b01626523f/aiohttp-3.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a84792f8631bf5a94e52d9cc881c0b824ab42717165a5579c760b830d9392ac9", size = 497141, upload-time = "2026-03-31T21:58:17.009Z" }, + { url = "https://files.pythonhosted.org/packages/3b/86/b7c870053e36a94e8951b803cb5b909bfbc9b90ca941527f5fcafbf6b0fa/aiohttp-3.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57653eac22c6a4c13eb22ecf4d673d64a12f266e72785ab1c8b8e5940d0e8090", size = 1732476, upload-time = "2026-03-31T21:58:18.925Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e5/4e161f84f98d80c03a238671b4136e6530453d65262867d989bbe78244d0/aiohttp-3.13.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5e5f7debc7a57af53fdf5c5009f9391d9f4c12867049d509bf7bb164a6e295b", size = 1706507, upload-time = "2026-03-31T21:58:21.094Z" }, + { url = "https://files.pythonhosted.org/packages/d4/56/ea11a9f01518bd5a2a2fcee869d248c4b8a0cfa0bb13401574fa31adf4d4/aiohttp-3.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c719f65bebcdf6716f10e9eff80d27567f7892d8988c06de12bbbd39307c6e3a", size = 1773465, upload-time = "2026-03-31T21:58:23.159Z" }, + { url = "https://files.pythonhosted.org/packages/eb/40/333ca27fb74b0383f17c90570c748f7582501507307350a79d9f9f3c6eb1/aiohttp-3.13.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d97f93fdae594d886c5a866636397e2bcab146fd7a132fd6bb9ce182224452f8", size = 1873523, upload-time = "2026-03-31T21:58:25.59Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d2/e2f77eef1acb7111405433c707dc735e63f67a56e176e72e9e7a2cd3f493/aiohttp-3.13.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df334e39d4c2f899a914f1dba283c1aadc311790733f705182998c6f7cae665", size = 1754113, upload-time = "2026-03-31T21:58:27.624Z" }, + { url = "https://files.pythonhosted.org/packages/fb/56/3f653d7f53c89669301ec9e42c95233e2a0c0a6dd051269e6e678db4fdb0/aiohttp-3.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe6970addfea9e5e081401bcbadf865d2b6da045472f58af08427e108d618540", size = 1562351, upload-time = "2026-03-31T21:58:29.918Z" }, + { url = "https://files.pythonhosted.org/packages/ec/a6/9b3e91eb8ae791cce4ee736da02211c85c6f835f1bdfac0594a8a3b7018c/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7becdf835feff2f4f335d7477f121af787e3504b48b449ff737afb35869ba7bb", size = 1693205, upload-time = "2026-03-31T21:58:32.214Z" }, + { url = "https://files.pythonhosted.org/packages/98/fc/bfb437a99a2fcebd6b6eaec609571954de2ed424f01c352f4b5504371dd3/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:676e5651705ad5d8a70aeb8eb6936c436d8ebbd56e63436cb7dd9bb36d2a9a46", size = 1730618, upload-time = "2026-03-31T21:58:34.728Z" }, + { url = "https://files.pythonhosted.org/packages/e4/b6/c8534862126191a034f68153194c389addc285a0f1347d85096d349bbc15/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9b16c653d38eb1a611cc898c41e76859ca27f119d25b53c12875fd0474ae31a8", size = 1745185, upload-time = "2026-03-31T21:58:36.909Z" }, + { url = "https://files.pythonhosted.org/packages/0b/93/4ca8ee2ef5236e2707e0fd5fecb10ce214aee1ff4ab307af9c558bda3b37/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:999802d5fa0389f58decd24b537c54aa63c01c3219ce17d1214cbda3c2b22d2d", size = 1557311, upload-time = "2026-03-31T21:58:39.38Z" }, + { url = "https://files.pythonhosted.org/packages/57/ae/76177b15f18c5f5d094f19901d284025db28eccc5ae374d1d254181d33f4/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec707059ee75732b1ba130ed5f9580fe10ff75180c812bc267ded039db5128c6", size = 1773147, upload-time = "2026-03-31T21:58:41.476Z" }, + { url = "https://files.pythonhosted.org/packages/01/a4/62f05a0a98d88af59d93b7fcac564e5f18f513cb7471696ac286db970d6a/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d6d44a5b48132053c2f6cd5c8cb14bc67e99a63594e336b0f2af81e94d5530c", size = 1730356, upload-time = "2026-03-31T21:58:44.049Z" }, + { url = "https://files.pythonhosted.org/packages/e4/85/fc8601f59dfa8c9523808281f2da571f8b4699685f9809a228adcc90838d/aiohttp-3.13.5-cp313-cp313-win32.whl", hash = "sha256:329f292ed14d38a6c4c435e465f48bebb47479fd676a0411936cc371643225cc", size = 432637, upload-time = "2026-03-31T21:58:46.167Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1b/ac685a8882896acf0f6b31d689e3792199cfe7aba37969fa91da63a7fa27/aiohttp-3.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:69f571de7500e0557801c0b51f4780482c0ec5fe2ac851af5a92cfce1af1cb83", size = 458896, upload-time = "2026-03-31T21:58:48.119Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721, upload-time = "2026-03-31T21:58:50.229Z" }, + { url = "https://files.pythonhosted.org/packages/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663, upload-time = "2026-03-31T21:58:52.232Z" }, + { url = "https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094, upload-time = "2026-03-31T21:58:54.566Z" }, + { url = "https://files.pythonhosted.org/packages/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701, upload-time = "2026-03-31T21:58:56.864Z" }, + { url = "https://files.pythonhosted.org/packages/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360, upload-time = "2026-03-31T21:58:59.072Z" }, + { url = "https://files.pythonhosted.org/packages/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023, upload-time = "2026-03-31T21:59:01.776Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795, upload-time = "2026-03-31T21:59:04.568Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405, upload-time = "2026-03-31T21:59:07.221Z" }, + { url = "https://files.pythonhosted.org/packages/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082, upload-time = "2026-03-31T21:59:09.484Z" }, + { url = "https://files.pythonhosted.org/packages/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346, upload-time = "2026-03-31T21:59:12.068Z" }, + { url = "https://files.pythonhosted.org/packages/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891, upload-time = "2026-03-31T21:59:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113, upload-time = "2026-03-31T21:59:17.068Z" }, + { url = "https://files.pythonhosted.org/packages/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088, upload-time = "2026-03-31T21:59:19.541Z" }, + { url = "https://files.pythonhosted.org/packages/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976, upload-time = "2026-03-31T21:59:22.311Z" }, + { url = "https://files.pythonhosted.org/packages/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444, upload-time = "2026-03-31T21:59:24.635Z" }, + { url = "https://files.pythonhosted.org/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128, upload-time = "2026-03-31T21:59:27.291Z" }, + { url = "https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029, upload-time = "2026-03-31T21:59:29.429Z" }, + { url = "https://files.pythonhosted.org/packages/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758, upload-time = "2026-03-31T21:59:31.547Z" }, + { url = "https://files.pythonhosted.org/packages/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883, upload-time = "2026-03-31T21:59:34.098Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668, upload-time = "2026-03-31T21:59:36.497Z" }, + { url = "https://files.pythonhosted.org/packages/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461, upload-time = "2026-03-31T21:59:38.723Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661, upload-time = "2026-03-31T21:59:41.187Z" }, + { url = "https://files.pythonhosted.org/packages/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800, upload-time = "2026-03-31T21:59:43.84Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382, upload-time = "2026-03-31T21:59:46.187Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724, upload-time = "2026-03-31T21:59:48.656Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027, upload-time = "2026-03-31T21:59:51.284Z" }, + { url = "https://files.pythonhosted.org/packages/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644, upload-time = "2026-03-31T21:59:53.753Z" }, + { url = "https://files.pythonhosted.org/packages/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630, upload-time = "2026-03-31T21:59:56.239Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403, upload-time = "2026-03-31T21:59:59.103Z" }, + { url = "https://files.pythonhosted.org/packages/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924, upload-time = "2026-03-31T22:00:02.116Z" }, + { url = "https://files.pythonhosted.org/packages/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119, upload-time = "2026-03-31T22:00:04.756Z" }, + { url = "https://files.pythonhosted.org/packages/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072, upload-time = "2026-03-31T22:00:07.494Z" }, + { url = "https://files.pythonhosted.org/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819, upload-time = "2026-03-31T22:00:10.277Z" }, + { url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" }, +] + +[[package]] +name = "aioresponses" +version = "0.7.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/03/532bbc645bdebcf3b6af3b25d46655259d66ce69abba7720b71ebfabbade/aioresponses-0.7.8.tar.gz", hash = "sha256:b861cdfe5dc58f3b8afac7b0a6973d5d7b2cb608dd0f6253d16b8ee8eaf6df11", size = 40253, upload-time = "2025-01-19T18:14:03.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b7/584157e43c98aa89810bc2f7099e7e01c728ecf905a66cf705106009228f/aioresponses-0.7.8-py2.py3-none-any.whl", hash = "sha256:b73bd4400d978855e55004b23a3a84cb0f018183bcf066a85ad392800b5b9a94", size = 12518, upload-time = "2025-01-19T18:13:59.633Z" }, +] + +[[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 = "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 = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "esprima" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/a1/50fccd68a12bcfc27adfc9969c090286670a9109a0259f3f70943390b721/esprima-4.0.1.tar.gz", hash = "sha256:08db1a876d3c2910db9cfaeb83108193af5411fc3a3a66ebefacd390d21323ee", size = 47021, upload-time = "2018-08-24T13:59:11.374Z" } + +[[package]] +name = "filelock" +version = "3.29.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, +] + +[[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 = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + +[[package]] +name = "idna" +version = "3.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, + { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, + { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, + { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, + { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, +] + +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/f1/8a8cc1c2c7e7934ab77e0163414f736fadbc0f5e8dd9673b952355ac175b/propcache-0.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78", size = 90744, upload-time = "2026-05-08T20:59:45.799Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f4/651b1225e976bd1a2ba5cfba0c29d096581c2636b437e3a9a7ab6276270a/propcache-0.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959", size = 52033, upload-time = "2026-05-08T20:59:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/15/a8/8ede85d6aa1f79fc7dc2f8fd2c8d65920b8272c3892903c8a1affde48cfb/propcache-0.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7", size = 52754, upload-time = "2026-05-08T20:59:49.202Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fe/b3551b41bbc2f5b5bb088fc6920567cd43101253e68fbaa261339eb96fe1/propcache-0.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511", size = 57573, upload-time = "2026-05-08T20:59:50.778Z" }, + { url = "https://files.pythonhosted.org/packages/83/27/ab851ebd1b7172e3e161f5f8d39e315d54a91bea246f01f4d872d3376aef/propcache-0.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660", size = 60645, upload-time = "2026-05-08T20:59:52.227Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/466b3d18022e9897cbda9c735c493c5bd747d7a4c6f5ea1480b4cec434b6/propcache-0.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66", size = 61563, upload-time = "2026-05-08T20:59:53.866Z" }, + { url = "https://files.pythonhosted.org/packages/27/1b/16ab7f2cf2041da2f60d156ba64c2484eadf9168075b4ff43c3ef60045af/propcache-0.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b", size = 58888, upload-time = "2026-05-08T20:59:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/0a/67/bb777ffd907633563bf35fd859c4ce97b0512c32f4633cf5d1eb7c33512b/propcache-0.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67", size = 59253, upload-time = "2026-05-08T20:59:57.075Z" }, + { url = "https://files.pythonhosted.org/packages/b9/42/64f8d90b73fd9cdc1499b48057ff6d9cd2a98a25734c9bb62ecf07e87061/propcache-0.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f", size = 57558, upload-time = "2026-05-08T20:59:58.602Z" }, + { url = "https://files.pythonhosted.org/packages/eb/02/dba5bc03c9041f2092ea55a449caf5dfe68352c6654511b29ba0654ddb69/propcache-0.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c", size = 55007, upload-time = "2026-05-08T20:59:59.837Z" }, + { url = "https://files.pythonhosted.org/packages/14/c0/43f649c7aa2a77a3b100d84e9dea3a483120ecb608bfe36ce49eaff517fe/propcache-0.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0", size = 60355, upload-time = "2026-05-08T21:00:01.144Z" }, + { url = "https://files.pythonhosted.org/packages/83/c0/435dafd27f1cb4a495381dae60e25883ccfe4020bb72818e8184c1678092/propcache-0.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6", size = 59057, upload-time = "2026-05-08T21:00:02.401Z" }, + { url = "https://files.pythonhosted.org/packages/53/ae/6e292df9135d659944e96cb3389258e4a663e5b2b5f6c217ef0ddc8d2f73/propcache-0.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27", size = 61938, upload-time = "2026-05-08T21:00:03.638Z" }, + { url = "https://files.pythonhosted.org/packages/0b/42/314ebc50d8159055411fd6b0bda322ff510e4b1f7d2e4927940ad0f6af20/propcache-0.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f", size = 59731, upload-time = "2026-05-08T21:00:04.881Z" }, + { url = "https://files.pythonhosted.org/packages/b8/9b/2da6dee38871c3c8772fabc2758325a5c9077d6d18c597737dc04dd884cd/propcache-0.5.2-cp311-cp311-win32.whl", hash = "sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0", size = 38966, upload-time = "2026-05-08T21:00:06.511Z" }, + { url = "https://files.pythonhosted.org/packages/42/4e/f17363fb58c0afe05b067361cb6d86ed2d29de6506779a27547c4d183075/propcache-0.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82", size = 42135, upload-time = "2026-05-08T21:00:08.088Z" }, + { url = "https://files.pythonhosted.org/packages/c6/eb/6af6685077d22e8b33358d3c548e3282706a0b3cd85044ffba4e5dd08e3b/propcache-0.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab", size = 38381, upload-time = "2026-05-08T21:00:09.692Z" }, + { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, + { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, + { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, + { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, + { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, + { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, + { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, + { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, + { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, + { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, + { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, + { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" }, + { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" }, + { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" }, + { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" }, + { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" }, + { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" }, + { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" }, + { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" }, + { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" }, + { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" }, + { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" }, + { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" }, + { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" }, + { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" }, + { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" }, + { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" }, + { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" }, + { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" }, + { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" }, + { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" }, + { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" }, + { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" }, + { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" }, + { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" }, + { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "python-discovery" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/60/e88788207d81e46362cfbef0d4aaf4c0f49efc3c12d4c3fa3f542c34ebec/python_discovery-1.3.1.tar.gz", hash = "sha256:62f6db28064c9613e7ca76cb3f00c38c839a07c31c00dfe7ed0986493d2150a6", size = 68011, upload-time = "2026-05-12T20:53:36.336Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-py3-none-any.whl", hash = "sha256:ed188687ebb3b82c01a17cd5ac62fc94d9f6487a7f1a0f9dfe89753fec91039c", size = 33185, upload-time = "2026-05-12T20:53:34.969Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "raycast-api" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "aiohttp" }, +] + +[package.optional-dependencies] +discovery = [ + { name = "esprima" }, +] + +[package.dev-dependencies] +dev = [ + { name = "aioresponses" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiohttp", specifier = ">=3.13" }, + { name = "esprima", marker = "extra == 'discovery'", specifier = ">=4.0" }, +] +provides-extras = ["discovery"] + +[package.metadata.requires-dev] +dev = [ + { name = "aioresponses", specifier = ">=0.7" }, + { name = "pre-commit", specifier = ">=4.0" }, + { name = "pytest", specifier = ">=8" }, + { name = "pytest-asyncio", specifier = ">=0.23" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "virtualenv" +version = "21.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/ba/1f6e8c957e4932be060dcdc482d339c12e0216351478add3645cdaa53c05/virtualenv-21.3.3.tar.gz", hash = "sha256:f5bda277e553b1c2b3c1a8debfc30496e1288cc93ce6b7b71b3280047e317328", size = 7613784, upload-time = "2026-05-13T18:01:30.19Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-py3-none-any.whl", hash = "sha256:7d5987d8369e098e41406efb780a3d4ca79280097293899e351a6407ee153ab3", size = 7594554, upload-time = "2026-05-13T18:01:27.815Z" }, +] + +[[package]] +name = "yarl" +version = "1.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/aa/60da938b8f0997ba3a911263c40d82b6f645a67902a490b46f3355e10fae/yarl-1.23.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b35d13d549077713e4414f927cdc388d62e543987c572baee613bf82f11a4b99", size = 123641, upload-time = "2026-03-01T22:04:42.841Z" }, + { url = "https://files.pythonhosted.org/packages/24/84/e237607faf4e099dbb8a4f511cfd5efcb5f75918baad200ff7380635631b/yarl-1.23.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cbb0fef01f0c6b38cb0f39b1f78fc90b807e0e3c86a7ff3ce74ad77ce5c7880c", size = 86248, upload-time = "2026-03-01T22:04:44.757Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0d/71ceabc14c146ba8ee3804ca7b3d42b1664c8440439de5214d366fec7d3a/yarl-1.23.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc52310451fc7c629e13c4e061cbe2dd01684d91f2f8ee2821b083c58bd72432", size = 85988, upload-time = "2026-03-01T22:04:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/8c/6c/4a90d59c572e46b270ca132aca66954f1175abd691f74c1ef4c6711828e2/yarl-1.23.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2c6b50c7b0464165472b56b42d4c76a7b864597007d9c085e8b63e185cf4a7a", size = 100566, upload-time = "2026-03-01T22:04:47.639Z" }, + { url = "https://files.pythonhosted.org/packages/49/fb/c438fb5108047e629f6282a371e6e91cf3f97ee087c4fb748a1f32ceef55/yarl-1.23.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aafe5dcfda86c8af00386d7781d4c2181b5011b7be3f2add5e99899ea925df05", size = 92079, upload-time = "2026-03-01T22:04:48.925Z" }, + { url = "https://files.pythonhosted.org/packages/d9/13/d269aa1aed3e4f50a5a103f96327210cc5fa5dd2d50882778f13c7a14606/yarl-1.23.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ee33b875f0b390564c1fb7bc528abf18c8ee6073b201c6ae8524aca778e2d83", size = 108741, upload-time = "2026-03-01T22:04:50.838Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/115b16f22c37ea4437d323e472945bea97301c8ec6089868fa560abab590/yarl-1.23.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4c41e021bc6d7affb3364dc1e1e5fa9582b470f283748784bd6ea0558f87f42c", size = 108099, upload-time = "2026-03-01T22:04:52.499Z" }, + { url = "https://files.pythonhosted.org/packages/9a/64/c53487d9f4968045b8afa51aed7ca44f58b2589e772f32745f3744476c82/yarl-1.23.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99c8a9ed30f4164bc4c14b37a90208836cbf50d4ce2a57c71d0f52c7fb4f7598", size = 102678, upload-time = "2026-03-01T22:04:55.176Z" }, + { url = "https://files.pythonhosted.org/packages/85/59/cd98e556fbb2bf8fab29c1a722f67ad45c5f3447cac798ab85620d1e70af/yarl-1.23.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2af5c81a1f124609d5f33507082fc3f739959d4719b56877ab1ee7e7b3d602b", size = 100803, upload-time = "2026-03-01T22:04:56.588Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c0/b39770b56d4a9f0bb5f77e2f1763cd2d75cc2f6c0131e3b4c360348fcd65/yarl-1.23.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6b41389c19b07c760c7e427a3462e8ab83c4bb087d127f0e854c706ce1b9215c", size = 100163, upload-time = "2026-03-01T22:04:58.492Z" }, + { url = "https://files.pythonhosted.org/packages/e7/64/6980f99ab00e1f0ff67cb84766c93d595b067eed07439cfccfc8fb28c1a6/yarl-1.23.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:1dc702e42d0684f42d6519c8d581e49c96cefaaab16691f03566d30658ee8788", size = 93859, upload-time = "2026-03-01T22:05:00.268Z" }, + { url = "https://files.pythonhosted.org/packages/38/69/912e6c5e146793e5d4b5fe39ff5b00f4d22463dfd5a162bec565ac757673/yarl-1.23.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0e40111274f340d32ebcc0a5668d54d2b552a6cca84c9475859d364b380e3222", size = 108202, upload-time = "2026-03-01T22:05:02.273Z" }, + { url = "https://files.pythonhosted.org/packages/59/97/35ca6767524687ad64e5f5c31ad54bc76d585585a9fcb40f649e7e82ffed/yarl-1.23.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4764a6a7588561a9aef92f65bda2c4fb58fe7c675c0883862e6df97559de0bfb", size = 99866, upload-time = "2026-03-01T22:05:03.597Z" }, + { url = "https://files.pythonhosted.org/packages/d3/1c/1a3387ee6d73589f6f2a220ae06f2984f6c20b40c734989b0a44f5987308/yarl-1.23.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:03214408cfa590df47728b84c679ae4ef00be2428e11630277be0727eba2d7cc", size = 107852, upload-time = "2026-03-01T22:05:04.986Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b8/35c0750fcd5a3f781058bfd954515dd4b1eab45e218cbb85cf11132215f1/yarl-1.23.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:170e26584b060879e29fac213e4228ef063f39128723807a312e5c7fec28eff2", size = 102919, upload-time = "2026-03-01T22:05:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1c/9a1979aec4a81896d597bcb2177827f2dbee3f5b7cc48b2d0dadb644b41d/yarl-1.23.0-cp311-cp311-win32.whl", hash = "sha256:51430653db848d258336cfa0244427b17d12db63d42603a55f0d4546f50f25b5", size = 82602, upload-time = "2026-03-01T22:05:08.444Z" }, + { url = "https://files.pythonhosted.org/packages/93/22/b85eca6fa2ad9491af48c973e4c8cf6b103a73dbb271fe3346949449fca0/yarl-1.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:bf49a3ae946a87083ef3a34c8f677ae4243f5b824bfc4c69672e72b3d6719d46", size = 87461, upload-time = "2026-03-01T22:05:10.145Z" }, + { url = "https://files.pythonhosted.org/packages/93/95/07e3553fe6f113e6864a20bdc53a78113cda3b9ced8784ee52a52c9f80d8/yarl-1.23.0-cp311-cp311-win_arm64.whl", hash = "sha256:b39cb32a6582750b6cc77bfb3c49c0f8760dc18dc96ec9fb55fbb0f04e08b928", size = 82336, upload-time = "2026-03-01T22:05:11.554Z" }, + { url = "https://files.pythonhosted.org/packages/88/8a/94615bc31022f711add374097ad4144d569e95ff3c38d39215d07ac153a0/yarl-1.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860", size = 124737, upload-time = "2026-03-01T22:05:12.897Z" }, + { url = "https://files.pythonhosted.org/packages/e3/6f/c6554045d59d64052698add01226bc867b52fe4a12373415d7991fdca95d/yarl-1.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069", size = 87029, upload-time = "2026-03-01T22:05:14.376Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/725ecc166d53438bc88f76822ed4b1e3b10756e790bafd7b523fe97c322d/yarl-1.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25", size = 86310, upload-time = "2026-03-01T22:05:15.71Z" }, + { url = "https://files.pythonhosted.org/packages/99/30/58260ed98e6ff7f90ba84442c1ddd758c9170d70327394a6227b310cd60f/yarl-1.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8", size = 97587, upload-time = "2026-03-01T22:05:17.384Z" }, + { url = "https://files.pythonhosted.org/packages/76/0a/8b08aac08b50682e65759f7f8dde98ae8168f72487e7357a5d684c581ef9/yarl-1.23.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072", size = 92528, upload-time = "2026-03-01T22:05:18.804Z" }, + { url = "https://files.pythonhosted.org/packages/52/07/0b7179101fe5f8385ec6c6bb5d0cb9f76bd9fb4a769591ab6fb5cdbfc69a/yarl-1.23.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8", size = 105339, upload-time = "2026-03-01T22:05:20.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8a/36d82869ab5ec829ca8574dfcb92b51286fcfb1e9c7a73659616362dc880/yarl-1.23.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7", size = 105061, upload-time = "2026-03-01T22:05:22.268Z" }, + { url = "https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51", size = 100132, upload-time = "2026-03-01T22:05:23.638Z" }, + { url = "https://files.pythonhosted.org/packages/cf/26/9c89acf82f08a52cb52d6d39454f8d18af15f9d386a23795389d1d423823/yarl-1.23.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67", size = 99289, upload-time = "2026-03-01T22:05:25.749Z" }, + { url = "https://files.pythonhosted.org/packages/6f/54/5b0db00d2cb056922356104468019c0a132e89c8d3ab67d8ede9f4483d2a/yarl-1.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7", size = 96950, upload-time = "2026-03-01T22:05:27.318Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/10fa93811fd439341fad7e0718a86aca0de9548023bbb403668d6555acab/yarl-1.23.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d", size = 93960, upload-time = "2026-03-01T22:05:28.738Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d2/8ae2e6cd77d0805f4526e30ec43b6f9a3dfc542d401ac4990d178e4bf0cf/yarl-1.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760", size = 104703, upload-time = "2026-03-01T22:05:30.438Z" }, + { url = "https://files.pythonhosted.org/packages/2f/0c/b3ceacf82c3fe21183ce35fa2acf5320af003d52bc1fcf5915077681142e/yarl-1.23.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2", size = 98325, upload-time = "2026-03-01T22:05:31.835Z" }, + { url = "https://files.pythonhosted.org/packages/9d/e0/12900edd28bdab91a69bd2554b85ad7b151f64e8b521fe16f9ad2f56477a/yarl-1.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86", size = 105067, upload-time = "2026-03-01T22:05:33.358Z" }, + { url = "https://files.pythonhosted.org/packages/15/61/74bb1182cf79c9bbe4eb6b1f14a57a22d7a0be5e9cedf8e2d5c2086474c3/yarl-1.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34", size = 100285, upload-time = "2026-03-01T22:05:35.4Z" }, + { url = "https://files.pythonhosted.org/packages/69/7f/cd5ef733f2550de6241bd8bd8c3febc78158b9d75f197d9c7baa113436af/yarl-1.23.0-cp312-cp312-win32.whl", hash = "sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d", size = 82359, upload-time = "2026-03-01T22:05:36.811Z" }, + { url = "https://files.pythonhosted.org/packages/f5/be/25216a49daeeb7af2bec0db22d5e7df08ed1d7c9f65d78b14f3b74fd72fc/yarl-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e", size = 87674, upload-time = "2026-03-01T22:05:38.171Z" }, + { url = "https://files.pythonhosted.org/packages/d2/35/aeab955d6c425b227d5b7247eafb24f2653fedc32f95373a001af5dfeb9e/yarl-1.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9", size = 81879, upload-time = "2026-03-01T22:05:40.006Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4b/a0a6e5d0ee8a2f3a373ddef8a4097d74ac901ac363eea1440464ccbe0898/yarl-1.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e", size = 123796, upload-time = "2026-03-01T22:05:41.412Z" }, + { url = "https://files.pythonhosted.org/packages/67/b6/8925d68af039b835ae876db5838e82e76ec87b9782ecc97e192b809c4831/yarl-1.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5", size = 86547, upload-time = "2026-03-01T22:05:42.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/50/06d511cc4b8e0360d3c94af051a768e84b755c5eb031b12adaaab6dec6e5/yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b", size = 85854, upload-time = "2026-03-01T22:05:44.85Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f4/4e30b250927ffdab4db70da08b9b8d2194d7c7b400167b8fbeca1e4701ca/yarl-1.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035", size = 98351, upload-time = "2026-03-01T22:05:46.836Z" }, + { url = "https://files.pythonhosted.org/packages/86/fc/4118c5671ea948208bdb1492d8b76bdf1453d3e73df051f939f563e7dcc5/yarl-1.23.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5", size = 92711, upload-time = "2026-03-01T22:05:48.316Z" }, + { url = "https://files.pythonhosted.org/packages/56/11/1ed91d42bd9e73c13dc9e7eb0dd92298d75e7ac4dd7f046ad0c472e231cd/yarl-1.23.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735", size = 106014, upload-time = "2026-03-01T22:05:50.028Z" }, + { url = "https://files.pythonhosted.org/packages/ce/c9/74e44e056a23fbc33aca71779ef450ca648a5bc472bdad7a82339918f818/yarl-1.23.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401", size = 105557, upload-time = "2026-03-01T22:05:51.416Z" }, + { url = "https://files.pythonhosted.org/packages/66/fe/b1e10b08d287f518994f1e2ff9b6d26f0adeecd8dd7d533b01bab29a3eda/yarl-1.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4", size = 101559, upload-time = "2026-03-01T22:05:52.872Z" }, + { url = "https://files.pythonhosted.org/packages/72/59/c5b8d94b14e3d3c2a9c20cb100119fd534ab5a14b93673ab4cc4a4141ea5/yarl-1.23.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f", size = 100502, upload-time = "2026-03-01T22:05:54.954Z" }, + { url = "https://files.pythonhosted.org/packages/77/4f/96976cb54cbfc5c9fd73ed4c51804f92f209481d1fb190981c0f8a07a1d7/yarl-1.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a", size = 98027, upload-time = "2026-03-01T22:05:56.409Z" }, + { url = "https://files.pythonhosted.org/packages/63/6e/904c4f476471afdbad6b7e5b70362fb5810e35cd7466529a97322b6f5556/yarl-1.23.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2", size = 95369, upload-time = "2026-03-01T22:05:58.141Z" }, + { url = "https://files.pythonhosted.org/packages/9d/40/acfcdb3b5f9d68ef499e39e04d25e141fe90661f9d54114556cf83be8353/yarl-1.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f", size = 105565, upload-time = "2026-03-01T22:06:00.286Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c6/31e28f3a6ba2869c43d124f37ea5260cac9c9281df803c354b31f4dd1f3c/yarl-1.23.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b", size = 99813, upload-time = "2026-03-01T22:06:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/08/1f/6f65f59e72d54aa467119b63fc0b0b1762eff0232db1f4720cd89e2f4a17/yarl-1.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a", size = 105632, upload-time = "2026-03-01T22:06:03.188Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c4/18b178a69935f9e7a338127d5b77d868fdc0f0e49becd286d51b3a18c61d/yarl-1.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543", size = 101895, upload-time = "2026-03-01T22:06:04.651Z" }, + { url = "https://files.pythonhosted.org/packages/8f/54/f5b870b5505663911dba950a8e4776a0dbd51c9c54c0ae88e823e4b874a0/yarl-1.23.0-cp313-cp313-win32.whl", hash = "sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957", size = 82356, upload-time = "2026-03-01T22:06:06.04Z" }, + { url = "https://files.pythonhosted.org/packages/7a/84/266e8da36879c6edcd37b02b547e2d9ecdfea776be49598e75696e3316e1/yarl-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3", size = 87515, upload-time = "2026-03-01T22:06:08.107Z" }, + { url = "https://files.pythonhosted.org/packages/00/fd/7e1c66efad35e1649114fa13f17485f62881ad58edeeb7f49f8c5e748bf9/yarl-1.23.0-cp313-cp313-win_arm64.whl", hash = "sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3", size = 81785, upload-time = "2026-03-01T22:06:10.181Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fc/119dd07004f17ea43bb91e3ece6587759edd7519d6b086d16bfbd3319982/yarl-1.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa", size = 130719, upload-time = "2026-03-01T22:06:11.708Z" }, + { url = "https://files.pythonhosted.org/packages/e6/0d/9f2348502fbb3af409e8f47730282cd6bc80dec6630c1e06374d882d6eb2/yarl-1.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120", size = 89690, upload-time = "2026-03-01T22:06:13.429Z" }, + { url = "https://files.pythonhosted.org/packages/50/93/e88f3c80971b42cfc83f50a51b9d165a1dbf154b97005f2994a79f212a07/yarl-1.23.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59", size = 89851, upload-time = "2026-03-01T22:06:15.53Z" }, + { url = "https://files.pythonhosted.org/packages/1c/07/61c9dd8ba8f86473263b4036f70fb594c09e99c0d9737a799dfd8bc85651/yarl-1.23.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512", size = 95874, upload-time = "2026-03-01T22:06:17.553Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e9/f9ff8ceefba599eac6abddcfb0b3bee9b9e636e96dbf54342a8577252379/yarl-1.23.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4", size = 88710, upload-time = "2026-03-01T22:06:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/eb/78/0231bfcc5d4c8eec220bc2f9ef82cb4566192ea867a7c5b4148f44f6cbcd/yarl-1.23.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1", size = 101033, upload-time = "2026-03-01T22:06:21.203Z" }, + { url = "https://files.pythonhosted.org/packages/cd/9b/30ea5239a61786f18fd25797151a17fbb3be176977187a48d541b5447dd4/yarl-1.23.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea", size = 100817, upload-time = "2026-03-01T22:06:22.738Z" }, + { url = "https://files.pythonhosted.org/packages/62/e2/a4980481071791bc83bce2b7a1a1f7adcabfa366007518b4b845e92eeee3/yarl-1.23.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9", size = 97482, upload-time = "2026-03-01T22:06:24.21Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1e/304a00cf5f6100414c4b5a01fc7ff9ee724b62158a08df2f8170dfc72a2d/yarl-1.23.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123", size = 95949, upload-time = "2026-03-01T22:06:25.697Z" }, + { url = "https://files.pythonhosted.org/packages/68/03/093f4055ed4cae649ac53bca3d180bd37102e9e11d048588e9ab0c0108d0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24", size = 95839, upload-time = "2026-03-01T22:06:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/b9/28/4c75ebb108f322aa8f917ae10a8ffa4f07cae10a8a627b64e578617df6a0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de", size = 90696, upload-time = "2026-03-01T22:06:29.048Z" }, + { url = "https://files.pythonhosted.org/packages/23/9c/42c2e2dd91c1a570402f51bdf066bfdb1241c2240ba001967bad778e77b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b", size = 100865, upload-time = "2026-03-01T22:06:30.525Z" }, + { url = "https://files.pythonhosted.org/packages/74/05/1bcd60a8a0a914d462c305137246b6f9d167628d73568505fce3f1cb2e65/yarl-1.23.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6", size = 96234, upload-time = "2026-03-01T22:06:32.692Z" }, + { url = "https://files.pythonhosted.org/packages/90/b2/f52381aac396d6778ce516b7bc149c79e65bfc068b5de2857ab69eeea3b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6", size = 100295, upload-time = "2026-03-01T22:06:34.268Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/638bae5bbf1113a659b2435d8895474598afe38b4a837103764f603aba56/yarl-1.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5", size = 97784, upload-time = "2026-03-01T22:06:35.864Z" }, + { url = "https://files.pythonhosted.org/packages/80/25/a3892b46182c586c202629fc2159aa13975d3741d52ebd7347fd501d48d5/yarl-1.23.0-cp313-cp313t-win32.whl", hash = "sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595", size = 88313, upload-time = "2026-03-01T22:06:37.39Z" }, + { url = "https://files.pythonhosted.org/packages/43/68/8c5b36aa5178900b37387937bc2c2fe0e9505537f713495472dcf6f6fccc/yarl-1.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090", size = 94932, upload-time = "2026-03-01T22:06:39.579Z" }, + { url = "https://files.pythonhosted.org/packages/c6/cc/d79ba8292f51f81f4dc533a8ccfb9fc6992cabf0998ed3245de7589dc07c/yarl-1.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144", size = 84786, upload-time = "2026-03-01T22:06:41.988Z" }, + { url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455, upload-time = "2026-03-01T22:06:43.615Z" }, + { url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752, upload-time = "2026-03-01T22:06:45.425Z" }, + { url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291, upload-time = "2026-03-01T22:06:46.974Z" }, + { url = "https://files.pythonhosted.org/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026, upload-time = "2026-03-01T22:06:48.459Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355, upload-time = "2026-03-01T22:06:50.306Z" }, + { url = "https://files.pythonhosted.org/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417, upload-time = "2026-03-01T22:06:52.1Z" }, + { url = "https://files.pythonhosted.org/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422, upload-time = "2026-03-01T22:06:54.424Z" }, + { url = "https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915, upload-time = "2026-03-01T22:06:55.895Z" }, + { url = "https://files.pythonhosted.org/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690, upload-time = "2026-03-01T22:06:58.21Z" }, + { url = "https://files.pythonhosted.org/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750, upload-time = "2026-03-01T22:06:59.794Z" }, + { url = "https://files.pythonhosted.org/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685, upload-time = "2026-03-01T22:07:01.375Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009, upload-time = "2026-03-01T22:07:03.151Z" }, + { url = "https://files.pythonhosted.org/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033, upload-time = "2026-03-01T22:07:04.701Z" }, + { url = "https://files.pythonhosted.org/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483, upload-time = "2026-03-01T22:07:06.231Z" }, + { url = "https://files.pythonhosted.org/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175, upload-time = "2026-03-01T22:07:08.4Z" }, + { url = "https://files.pythonhosted.org/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871, upload-time = "2026-03-01T22:07:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093, upload-time = "2026-03-01T22:07:11.501Z" }, + { url = "https://files.pythonhosted.org/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384, upload-time = "2026-03-01T22:07:13.069Z" }, + { url = "https://files.pythonhosted.org/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019, upload-time = "2026-03-01T22:07:14.903Z" }, + { url = "https://files.pythonhosted.org/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894, upload-time = "2026-03-01T22:07:17.372Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979, upload-time = "2026-03-01T22:07:19.361Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943, upload-time = "2026-03-01T22:07:21.266Z" }, + { url = "https://files.pythonhosted.org/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786, upload-time = "2026-03-01T22:07:23.129Z" }, + { url = "https://files.pythonhosted.org/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307, upload-time = "2026-03-01T22:07:24.689Z" }, + { url = "https://files.pythonhosted.org/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904, upload-time = "2026-03-01T22:07:26.287Z" }, + { url = "https://files.pythonhosted.org/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728, upload-time = "2026-03-01T22:07:27.906Z" }, + { url = "https://files.pythonhosted.org/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964, upload-time = "2026-03-01T22:07:30.198Z" }, + { url = "https://files.pythonhosted.org/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882, upload-time = "2026-03-01T22:07:32.132Z" }, + { url = "https://files.pythonhosted.org/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797, upload-time = "2026-03-01T22:07:34.404Z" }, + { url = "https://files.pythonhosted.org/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023, upload-time = "2026-03-01T22:07:35.953Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227, upload-time = "2026-03-01T22:07:37.594Z" }, + { url = "https://files.pythonhosted.org/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302, upload-time = "2026-03-01T22:07:39.334Z" }, + { url = "https://files.pythonhosted.org/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202, upload-time = "2026-03-01T22:07:41.717Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558, upload-time = "2026-03-01T22:07:43.433Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610, upload-time = "2026-03-01T22:07:45.773Z" }, + { url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" }, + { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, +]