feat: vibed out some slop over here

This commit is contained in:
h
2026-05-19 11:18:37 +02:00
commit 52e7528b86
60 changed files with 9176 additions and 0 deletions
+17
View File
@@ -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
+18
View File
@@ -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
+109
View File
@@ -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.
+55
View File
@@ -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())
+103
View File
@@ -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())
+53
View File
@@ -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())
+46
View File
@@ -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",
]
+10
View File
@@ -0,0 +1,10 @@
{
"venvPath": ".",
"venv": ".venv",
"pythonVersion": "3.11",
"include": ["raycast_api", "tests"],
"exclude": [".venv", "**/__pycache__", ".pytest_cache"],
"extraPaths": ["."],
"reportMissingImports": "error",
"reportMissingTypeStubs": "none"
}
+35
View File
@@ -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
+96
View File
@@ -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)
+40
View File
@@ -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",
]
+534
View File
@@ -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 `<user-preferences>` 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=<id>` with
`Last-Event-ID: <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
+172
View File
@@ -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"
+36
View File
@@ -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()
+173
View File
@@ -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)
+397
View File
@@ -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":<value>}`.
"""
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 `<user-preferences>` 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] = (
"<user-preferences>\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"
"</user-preferences>"
)
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
)
+451
View File
@@ -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 <path-to-Raycast.app>"
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())
+21
View File
@@ -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",
]
+436
View File
@@ -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,
)
+85
View File
@@ -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)
+171
View File
@@ -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
+294
View File
@@ -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:]
+5
View File
@@ -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.
"""
+380
View File
@@ -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]
+91
View File
@@ -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/<basename minus .app>`.
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 = '<hex>'` 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
)
+82
View File
@@ -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()
+84
View File
@@ -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 `<bundle_sha256>.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
+302
View File
@@ -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(<rotFnName>) 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(<rotName>)"
)
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 `<arr>.map(<rotName>)`.
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(<str>)` 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: "<algo>" }
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/<version> (x-<platform> Version <ver>)`.
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})"
+82
View File
@@ -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
+79
View File
@@ -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"))
+29
View File
@@ -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)
+89
View File
@@ -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()
+45
View File
@@ -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)
+119
View File
@@ -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")),
)
View File
+112
View File
@@ -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 = (
'<?xml version="1.0" encoding="UTF-8"?>'
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">'
'<plist version="1.0"><dict>'
"<key>CFBundleShortVersionString</key><string>9.9.9.0</string>"
"</dict></plist>"
)
(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
BIN
View File
Binary file not shown.
+77
View File
@@ -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 };
+112
View File
@@ -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("}")
+41
View File
@@ -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
+42
View File
@@ -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
+35
View File
@@ -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"
+378
View File
@@ -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:
<user-preferences>\\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
</user-preferences>
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 == (
"<user-preferences>\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"
"</user-preferences>"
)
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="<user-preferences>\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</user-preferences>",
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"
+215
View File
@@ -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
+415
View File
@@ -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"
+256
View File
@@ -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
+74
View File
@@ -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)"
+182
View File
@@ -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"
+94
View File
@@ -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")
+555
View File
@@ -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
+79
View File
@@ -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 ")
+92
View File
@@ -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"
+67
View File
@@ -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"
+153
View File
@@ -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"
+135
View File
@@ -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)
+122
View File
@@ -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
+247
View File
@@ -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"
+58
View File
@@ -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)
+5
View File
@@ -0,0 +1,5 @@
[environment]
python = ".venv"
[src]
exclude = ["_extracted", "docs", "tests"]
Generated
+891
View File
@@ -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" },
]