feat: vibed out some slop over here
This commit is contained in:
+17
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,109 @@
|
||||
# raycast-api
|
||||
[](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.
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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",
|
||||
]
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"venvPath": ".",
|
||||
"venv": ".venv",
|
||||
"pythonVersion": "3.11",
|
||||
"include": ["raycast_api", "tests"],
|
||||
"exclude": [".venv", "**/__pycache__", ".pytest_cache"],
|
||||
"extraPaths": ["."],
|
||||
"reportMissingImports": "error",
|
||||
"reportMissingTypeStubs": "none"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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())
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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:]
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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]
|
||||
@@ -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
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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})"
|
||||
@@ -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
|
||||
@@ -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"))
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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")),
|
||||
)
|
||||
@@ -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
|
||||
Vendored
BIN
Binary file not shown.
Vendored
+77
@@ -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 };
|
||||
@@ -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("}")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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)"
|
||||
@@ -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"
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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 ")
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -0,0 +1,5 @@
|
||||
[environment]
|
||||
python = ".venv"
|
||||
|
||||
[src]
|
||||
exclude = ["_extracted", "docs", "tests"]
|
||||
@@ -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" },
|
||||
]
|
||||
Reference in New Issue
Block a user