For real. Tools schema generation and ux tweaks.

This commit is contained in:
2026-07-07 17:51:00 -07:00
parent a3d01ca1b3
commit 9a8e16143b
11 changed files with 1186 additions and 7 deletions

View File

@@ -20,6 +20,15 @@ class Settings(BaseSettings):
nats_token: str | None = None nats_token: str | None = None
mdns_enabled: bool = False mdns_enabled: bool = False
# LLM assist (off by default). Calls the litellm proxy's OpenAI-compatible
# /chat/completions to generate a tool-call schema for CLIs the deterministic
# parser can't structure (subcommand trees). The key is NOT stored here — it's
# read from the secret backend by name at call time.
llm_enabled: bool = False
llm_base_url: str = "https://litellm.civil.payne.io/v1"
llm_model: str = "qwen"
llm_api_key_secret: str = "LITELLM_MASTER_KEY"
model_config = { model_config = {
"env_prefix": "CASTLE_API_", "env_prefix": "CASTLE_API_",
"env_file": ".env", "env_file": ".env",

View File

@@ -6,7 +6,7 @@ import asyncio
from pathlib import Path from pathlib import Path
import yaml import yaml
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, Body, HTTPException, status
from pydantic import BaseModel from pydantic import BaseModel
from castle_core.config import ( from castle_core.config import (
@@ -454,6 +454,80 @@ def delete_tool(name: str) -> dict:
return _delete_deployment(name, kind="tool") return _delete_deployment(name, kind="tool")
@router.post("/tools/{name}/schema")
async def generate_tool_schema(
name: str, deep: bool = False, assist: str | None = None
) -> dict:
"""Generate a *draft* tool-call schema (neutral core) from the tool's ``--help``.
Not saved — the client reviews/edits it and persists via ``PUT
/config/tools/{name}`` (the schema rides in the deployment config as
``tool_schema``). Two modes:
* default (``assist`` unset) — deterministic: parse ``--help``. ``deep`` walks
subcommands. 422 if the tool isn't installed / emits no help.
* ``assist=llm`` — send the recursive ``--help`` to the litellm proxy for a
structured schema (the escape hatch for subcommand trees the parser can only
render as a ``command`` string). 503 if LLM assist is disabled; 502 on an
upstream/validation failure.
"""
from castle_core.tool_schema import (
ToolSchemaError,
collect_tool_help,
derive_tool_schema,
)
config = get_config()
if config.deployment("tool", name) is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Tool '{name}' not found",
)
if assist == "llm":
from castle_api.config import settings
from castle_api.llm import LLMAssistError, generate_tool_schema_llm
if not settings.llm_enabled:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="LLM assist is disabled (set CASTLE_API_LLM_ENABLED=true).",
)
try:
help_text = collect_tool_help(config, name)
except ToolSchemaError as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)
)
try:
schema = await generate_tool_schema_llm(help_text, name)
except LLMAssistError as e:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e)
)
return {"ok": True, "schema": schema, "assist": "llm"}
try:
schema = derive_tool_schema(config, name, deep=deep)
except ToolSchemaError as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(e),
)
return {"ok": True, "schema": schema}
@router.post("/tools/schema/validate")
def validate_tool_schema_endpoint(core: dict = Body(...)) -> dict:
"""Deterministically validate a tool-call schema core (no LLM) — the shape and
that ``parameters`` is a valid JSON Schema. Lets the UI check a hand-edited
schema. Returns ``{valid, errors}``."""
from castle_core.tool_schema import validate_tool_schema_core
errors = validate_tool_schema_core(core)
return {"valid": not errors, "errors": errors}
@router.put("/static/{name}") @router.put("/static/{name}")
def save_static(name: str, request: ServiceConfigRequest) -> dict: def save_static(name: str, request: ServiceConfigRequest) -> dict:
"""Create/update the *static* frontend named `name`.""" """Create/update the *static* frontend named `name`."""

View File

@@ -0,0 +1,159 @@
"""LLM assist — generate a tool-call schema from a CLI's --help via the litellm proxy.
castle's first LLM-backed feature. The deterministic parser
(``castle_core.tool_schema``) falls back to a single ``command`` string for
subcommand trees (git, castle); this asks an LLM to read the recursive ``--help``
and produce a *structured* neutral tool-call core instead.
Goes through castle's litellm proxy over its OpenAI-compatible
``/chat/completions`` (model-agnostic; the fleet standardizes on litellm), using
forced tool-calling to constrain the output. Every response is validated
deterministically (``validate_tool_schema_core``); on failure the errors are fed
back to the model to repair, up to a cap — so a weak model's malformed draft is
fixed rather than surfaced. The result is a reviewed draft — never auto-saved.
"""
from __future__ import annotations
import json
import httpx
from castle_core.config import read_secret
from castle_core.tool_schema import validate_tool_schema_core
from castle_api.config import settings
_TIMEOUT = 60.0
# Total attempts = 1 initial + repairs. Sonnet is valid first try; the extra
# rounds salvage weaker models (repairing a bad enum, etc.).
_MAX_ATTEMPTS = 3
_SYSTEM = (
"You convert a command-line tool's --help output into a tool-call schema for "
"an AI agent. Call the `emit_tool_schema` function exactly once. Produce a "
"JSON Schema in `parameters` (type: object) whose properties let an agent "
"invoke the tool: one property per meaningful option or subcommand path, each "
"typed (boolean for flags, string for valued options, enum as a LIST of the "
"allowed values for fixed choices) with a concise description drawn from the "
"help. For a tool with subcommands, include a `subcommand` property (an enum "
"listing the available subcommands) plus the important shared options. `name` "
"must match ^[a-zA-Z0-9_-]{1,64}$. Do not invent options not in the help."
)
_EMIT_TOOL = {
"type": "function",
"function": {
"name": "emit_tool_schema",
"description": "Emit the structured tool-call definition for this CLI.",
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Sanitized tool name."},
"description": {
"type": "string",
"description": "One-line description of what the tool does.",
},
"parameters": {
"type": "object",
"description": (
"A JSON Schema object (type: object with a properties map) "
"describing the tool's invocation arguments."
),
},
},
"required": ["name", "description", "parameters"],
},
},
}
class LLMAssistError(Exception):
"""The LLM assist couldn't produce a valid schema (config, upstream, or output)."""
def _extract_args(data: dict) -> dict | None:
"""Pull the emit_tool_schema arguments out of a chat-completions response.
Returns the parsed arguments dict (even if not yet valid — the caller
validates), or None if the model didn't call the function / returned non-JSON.
"""
try:
call = data["choices"][0]["message"]["tool_calls"][0]
args = call["function"]["arguments"]
except (KeyError, IndexError, TypeError):
return None
if isinstance(args, str):
try:
args = json.loads(args)
except json.JSONDecodeError:
return None
return args if isinstance(args, dict) else None
async def _complete(messages: list[dict], key: str) -> dict:
"""One forced-tool-call chat completion against the litellm proxy."""
payload = {
"model": settings.llm_model,
"messages": messages,
"tools": [_EMIT_TOOL],
"tool_choice": {"type": "function", "function": {"name": "emit_tool_schema"}},
}
url = f"{settings.llm_base_url.rstrip('/')}/chat/completions"
headers = {"Authorization": f"Bearer {key}"}
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
try:
resp = await client.post(url, json=payload, headers=headers)
except httpx.HTTPError as e:
raise LLMAssistError(f"litellm request failed: {e}") from e
if resp.status_code >= 400:
raise LLMAssistError(f"litellm returned {resp.status_code}: {resp.text[:300]}")
return resp.json()
def _repair_message(prior: dict, errors: list[str]) -> dict:
"""A user turn that shows the invalid schema + its errors and asks for a fix."""
return {
"role": "user",
"content": (
"Your previous emit_tool_schema output failed validation:\n"
+ "\n".join(f"- {e}" for e in errors)
+ "\n\nHere is what you returned:\n"
+ json.dumps(prior, indent=2)
+ "\n\nCall emit_tool_schema again with every problem fixed. Remember: "
"`enum` must be a list of the allowed values, not a count."
),
}
async def generate_tool_schema_llm(help_text: str, name: str) -> dict:
"""Ask the LLM for a structured neutral tool-call core, repairing invalid
output against ``validate_tool_schema_core`` until it passes (up to
``_MAX_ATTEMPTS``). Raises ``LLMAssistError`` on config/upstream failure or if
it can't be made valid."""
key = read_secret(settings.llm_api_key_secret)
if not key:
raise LLMAssistError(
f"LLM assist enabled but secret '{settings.llm_api_key_secret}' is unset."
)
base = [
{"role": "system", "content": _SYSTEM},
{"role": "user", "content": f"Tool name: {name}\n\n--- help ---\n{help_text}"},
]
messages = list(base)
errors = ["model did not call emit_tool_schema with JSON arguments"]
for _attempt in range(_MAX_ATTEMPTS):
data = await _complete(messages, key)
args = _extract_args(data)
if args is not None:
# Pin the name we were given so a model name-drift never costs a repair.
args["name"] = name
errors = validate_tool_schema_core(args)
if not errors:
return args
# Rebuild from base + a single repair turn (avoids provider-specific
# tool_call/tool_result threading; each repair is a fresh forced call).
messages = [*base, _repair_message(args or {}, errors)]
raise LLMAssistError(
f"could not produce a valid schema after {_MAX_ATTEMPTS} attempts: "
+ "; ".join(errors)
)

View File

@@ -0,0 +1,199 @@
"""POST /config/tools/{name}/schema — derive an Anthropic tool schema from --help,
and the tool_schema field round-tripping through the tool save path."""
from __future__ import annotations
from pathlib import Path
from fastapi.testclient import TestClient
import json
from castle_core.config import load_config
from castle_api.config import settings
def _completion(args: dict) -> dict:
"""A minimal chat-completions response carrying an emit_tool_schema call."""
return {
"choices": [
{"message": {"tool_calls": [
{"function": {"name": "emit_tool_schema", "arguments": json.dumps(args)}}
]}}
]
}
_BAD_CORE = {
"name": "python3", "description": "d",
"parameters": {"type": "object", "properties": {"m": {"type": "string", "enum": 3}}},
}
_GOOD_CORE = {
"name": "python3", "description": "d",
"parameters": {"type": "object", "properties": {"m": {"type": "string"}}},
}
# A tool whose program falls back to the tool name for its executable; `python3`
# is always on PATH, so derive succeeds deterministically.
_PY_TOOL = {"program": "python3", "manager": "path"}
class TestGenerateSchema:
def test_generate_returns_draft_not_saved(
self, client: TestClient, castle_root: Path
) -> None:
client.put("/config/tools/python3", json={"config": _PY_TOOL})
r = client.post("/config/tools/python3/schema")
assert r.status_code == 200, r.text
body = r.json()
schema = body["schema"]
# The stored draft is the neutral core (name/description/parameters),
# rendered to a provider envelope only on read.
assert schema["name"] == "python3"
assert "parameters" in schema
assert schema["parameters"]["properties"]
# It's a draft: nothing persisted until the client saves it.
cfg = load_config(castle_root)
assert cfg.tools["python3"].tool_schema is None
def test_unknown_tool_404(self, client: TestClient) -> None:
r = client.post("/config/tools/nope-not-a-tool/schema")
assert r.status_code == 404
def test_validate_endpoint_valid(self, client: TestClient) -> None:
r = client.post("/config/tools/schema/validate", json=_GOOD_CORE)
assert r.status_code == 200
assert r.json() == {"valid": True, "errors": []}
def test_validate_endpoint_invalid(self, client: TestClient) -> None:
r = client.post("/config/tools/schema/validate", json=_BAD_CORE)
assert r.status_code == 200
body = r.json()
assert body["valid"] is False and body["errors"]
def test_uninstalled_executable_422(
self, client: TestClient, castle_root: Path
) -> None:
client.put(
"/config/tools/ghosttool",
json={"config": {"program": "ghosttool", "manager": "path"}},
)
r = client.post("/config/tools/ghosttool/schema")
assert r.status_code == 422
assert "PATH" in r.json()["detail"]
def test_assist_disabled_returns_503(
self, client: TestClient, castle_root: Path
) -> None:
client.put("/config/tools/python3", json={"config": _PY_TOOL})
# llm_enabled is False by default.
r = client.post("/config/tools/python3/schema?assist=llm")
assert r.status_code == 503
assert "disabled" in r.json()["detail"]
def test_assist_success_returns_draft(
self, client: TestClient, castle_root: Path, monkeypatch
) -> None:
client.put("/config/tools/python3", json={"config": _PY_TOOL})
async def _fake_llm(help_text: str, name: str) -> dict:
assert "python3" in help_text # the recursive help was gathered
return {
"name": name,
"description": "the python interpreter",
"parameters": {
"type": "object",
"properties": {"script": {"type": "string"}},
},
}
monkeypatch.setattr(settings, "llm_enabled", True)
monkeypatch.setattr("castle_api.llm.generate_tool_schema_llm", _fake_llm)
r = client.post("/config/tools/python3/schema?assist=llm")
assert r.status_code == 200, r.text
body = r.json()
assert body["assist"] == "llm"
assert body["schema"]["parameters"]["properties"] == {"script": {"type": "string"}}
def test_assist_upstream_error_returns_502(
self, client: TestClient, castle_root: Path, monkeypatch
) -> None:
client.put("/config/tools/python3", json={"config": _PY_TOOL})
async def _fail_llm(help_text: str, name: str) -> dict:
from castle_api.llm import LLMAssistError
raise LLMAssistError("litellm returned 500")
monkeypatch.setattr(settings, "llm_enabled", True)
monkeypatch.setattr("castle_api.llm.generate_tool_schema_llm", _fail_llm)
r = client.post("/config/tools/python3/schema?assist=llm")
assert r.status_code == 502
assert "litellm" in r.json()["detail"]
def test_assist_repairs_invalid_then_valid(
self, client: TestClient, castle_root: Path, monkeypatch
) -> None:
"""A malformed first response (bad enum) is fed back and repaired."""
client.put("/config/tools/python3", json={"config": _PY_TOOL})
calls = {"n": 0}
async def _fake_complete(messages: list, key: str) -> dict:
calls["n"] += 1
return _completion(_BAD_CORE if calls["n"] == 1 else _GOOD_CORE)
monkeypatch.setattr(settings, "llm_enabled", True)
monkeypatch.setattr("castle_api.llm.read_secret", lambda name: "k")
monkeypatch.setattr("castle_api.llm._complete", _fake_complete)
r = client.post("/config/tools/python3/schema?assist=llm")
assert r.status_code == 200, r.text
assert calls["n"] == 2 # repaired on the second attempt
props = r.json()["schema"]["parameters"]["properties"]
assert props == {"m": {"type": "string"}}
def test_assist_unrepairable_returns_502(
self, client: TestClient, castle_root: Path, monkeypatch
) -> None:
"""Persistently invalid output exhausts the repair budget → 502."""
client.put("/config/tools/python3", json={"config": _PY_TOOL})
async def _always_bad(messages: list, key: str) -> dict:
return _completion(_BAD_CORE)
monkeypatch.setattr(settings, "llm_enabled", True)
monkeypatch.setattr("castle_api.llm.read_secret", lambda name: "k")
monkeypatch.setattr("castle_api.llm._complete", _always_bad)
r = client.post("/config/tools/python3/schema?assist=llm")
assert r.status_code == 502
assert "attempts" in r.json()["detail"]
def test_schema_round_trips_through_save(
self, client: TestClient, castle_root: Path
) -> None:
"""Saving a tool with tool_schema persists it (PATCH merge), and clearing
it with null drops the field."""
client.put("/config/tools/python3", json={"config": _PY_TOOL})
schema = client.post("/config/tools/python3/schema").json()["schema"]
client.put(
"/config/tools/python3",
json={"config": {"program": "python3", "tool_schema": schema}},
)
cfg = load_config(castle_root)
assert cfg.tools["python3"].tool_schema is not None
assert cfg.tools["python3"].tool_schema["name"] == "python3"
# Clearing with null removes it (default None).
client.put(
"/config/tools/python3",
json={"config": {"program": "python3", "tool_schema": None}},
)
cfg = load_config(castle_root)
assert cfg.tools["python3"].tool_schema is None

View File

@@ -13,9 +13,14 @@ import argparse
import json import json
import tomllib import tomllib
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING
from castle_cli.config import load_config from castle_cli.config import load_config
if TYPE_CHECKING:
from castle_core.config import CastleConfig
from castle_core.manifest import ProgramSpec
BOLD = "\033[1m" BOLD = "\033[1m"
DIM = "\033[2m" DIM = "\033[2m"
RESET = "\033[0m" RESET = "\033[0m"
@@ -24,16 +29,16 @@ GREY = "\033[90m"
CYAN = "\033[96m" CYAN = "\033[96m"
def _is_tool(config: object, name: str) -> bool: def _is_tool(config: CastleConfig, name: str) -> bool:
return any(kind == "tool" for _, kind in config.deployments_of(name)) return any(kind == "tool" for _, kind in config.deployments_of(name))
def _tool_programs(config: object) -> dict: def _tool_programs(config: CastleConfig) -> dict:
"""Programs with a tool (path) deployment, name-sorted.""" """Programs with a tool (path) deployment, name-sorted."""
return {name: comp for name, comp in sorted(config.programs.items()) if _is_tool(config, name)} return {name: comp for name, comp in sorted(config.programs.items()) if _is_tool(config, name)}
def _executables(comp: object) -> list[str]: def _executables(comp: ProgramSpec) -> list[str]:
"""The console scripts a tool exposes, from its pyproject `[project.scripts]`. """The console scripts a tool exposes, from its pyproject `[project.scripts]`.
This is the command(s) to actually run — the source of truth even when the This is the command(s) to actually run — the source of truth even when the
@@ -54,7 +59,16 @@ def _executables(comp: object) -> list[str]:
return [comp.id] return [comp.id]
def _tool_record(config: object, name: str, comp: object, installed: bool) -> dict: def _tool_record(
config: CastleConfig, name: str, comp: ProgramSpec, installed: bool, fmt: str = "openai"
) -> dict:
# The tool-call schema (for handing this CLI to an agent) is stored neutral on
# the path deployment; render it into the requested envelope for the --json
# context payload. Feed default is openai (litellm-native).
from castle_core.tool_schema import render_tool_schema
dep = config.deployment("tool", name)
core = getattr(dep, "tool_schema", None)
return { return {
"name": name, "name": name,
"executables": _executables(comp), "executables": _executables(comp),
@@ -63,6 +77,7 @@ def _tool_record(config: object, name: str, comp: object, installed: bool) -> di
"stack": comp.stack, "stack": comp.stack,
"source": comp.source, "source": comp.source,
"system_dependencies": comp.system_dependencies, "system_dependencies": comp.system_dependencies,
"tool_schema": render_tool_schema(core, fmt) if core else None,
} }
@@ -72,8 +87,10 @@ def run_tool_list(args: argparse.Namespace) -> int:
config = load_config() config = load_config()
tools = _tool_programs(config) tools = _tool_programs(config)
fmt = getattr(args, "format", "openai")
records = [ records = [
_tool_record(config, name, comp, tool_installed(name)) for name, comp in tools.items() _tool_record(config, name, comp, tool_installed(name), fmt)
for name, comp in tools.items()
] ]
if getattr(args, "json", False): if getattr(args, "json", False):
@@ -112,7 +129,7 @@ def run_tool_info(args: argparse.Namespace) -> int:
comp = config.programs[name] comp = config.programs[name]
installed = tool_installed(name) installed = tool_installed(name)
record = _tool_record(config, name, comp, installed) record = _tool_record(config, name, comp, installed, getattr(args, "format", "openai"))
if getattr(args, "json", False): if getattr(args, "json", False):
print(json.dumps(record, indent=2)) print(json.dumps(record, indent=2))

View File

@@ -81,12 +81,17 @@ def _build_tool_group(subparsers: argparse._SubParsersAction) -> None:
grp.set_defaults(resource="tool") grp.set_defaults(resource="tool")
sub = grp.add_subparsers(dest="tool_command") sub = grp.add_subparsers(dest="tool_command")
fmt_help = "Render each tool_schema in this envelope (default: openai)"
fmt_choices = ("openai", "anthropic", "neutral")
p = sub.add_parser("list", help="List tools with their executable + description") p = sub.add_parser("list", help="List tools with their executable + description")
p.add_argument("--json", action="store_true", help="Machine-readable output") p.add_argument("--json", action="store_true", help="Machine-readable output")
p.add_argument("--format", choices=fmt_choices, default="openai", help=fmt_help)
p = sub.add_parser("info", help="Show a tool's executable, description, install state") p = sub.add_parser("info", help="Show a tool's executable, description, install state")
_add_name(p, "Tool name") _add_name(p, "Tool name")
p.add_argument("--json", action="store_true", help="Machine-readable output") p.add_argument("--json", action="store_true", help="Machine-readable output")
p.add_argument("--format", choices=fmt_choices, default="openai", help=fmt_help)
def _add_service_create(sub: argparse._SubParsersAction, kind: str) -> None: def _add_service_create(sub: argparse._SubParsersAction, kind: str) -> None:

View File

@@ -6,6 +6,7 @@ requires-python = ">=3.11"
dependencies = [ dependencies = [
"pyyaml>=6.0.0", "pyyaml>=6.0.0",
"pydantic>=2.0.0", "pydantic>=2.0.0",
"jsonschema>=4.0.0",
] ]
[build-system] [build-system]

View File

@@ -506,6 +506,11 @@ class PathDeployment(DeploymentBase):
""" """
manager: Literal["path"] manager: Literal["path"]
# An Anthropic Messages-API tool definition ({name, description, input_schema})
# for handing this CLI to an agent. Derived from the tool's ``--help`` (see
# ``castle_core.tool_schema``) but editable/overridable — a stored draft the
# completion-context builder reads. None → not yet generated.
tool_schema: dict | None = None
class RemoteDeployment(DeploymentBase): class RemoteDeployment(DeploymentBase):

View File

@@ -0,0 +1,393 @@
"""Derive LLM tool-call schemas from a CLI tool's ``--help``.
castle's in-process version of the standalone ``toolify`` tool: given a tool (a
program with a ``path`` deployment), resolve its executable, run ``--help``, and
build a tool-call definition.
Two parameter shapes, chosen automatically:
* **structured** — one typed property per option/positional, parsed from a
standard argparse/click ``--help``, each carrying an ``x-cli`` executor hint.
Used when the help is recognizable *and* the tool has no subcommands.
* **command** — a single ``command`` string, full ``--help`` as description. The
universal fallback for non-standard help (``jq``) and subcommand trees.
Schemas are built and stored in a **neutral** core (``{name, description,
parameters}``); ``render_tool_schema`` wraps that in the Anthropic or OpenAI
envelope on read. castle's feed defaults to OpenAI (litellm-native).
The extraction is intentionally duplicated from ``toolify`` rather than shared —
``toolify`` is a standalone program that must never depend on castle. No LLM is
used; the output is a deterministic function of the tool's ``--help``.
"""
from __future__ import annotations
import re
import shutil
import subprocess
import tomllib
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from castle_core.config import CastleConfig
__all__ = [
"ToolSchemaError",
"derive_tool_schema",
"render_tool_schema",
"tool_executable",
]
_NAME_OK = re.compile(r"[^a-zA-Z0-9_-]")
_HELP_TIMEOUT = 10
_MAX_SUBCOMMANDS = 40
_SKIP_OPTS = {"help", "version"}
_CMD_HEADING = re.compile(
r"^\s*(commands|subcommands|available commands)\s*:?\s*$", re.IGNORECASE
)
_OPT_HEADING = re.compile(r"^\s*(options|optional arguments)\s*:?\s*$", re.IGNORECASE)
_POS_HEADING = re.compile(r"^\s*positional arguments\s*:?\s*$", re.IGNORECASE)
class ToolSchemaError(Exception):
"""The tool's executable couldn't be resolved or produced no help."""
def _sanitize_name(raw: str) -> str:
name = _NAME_OK.sub("_", raw).strip("_") or "tool"
return name[:64]
def _run_help(argv: list[str]) -> tuple[int | None, str]:
"""Run ``argv + ['--help']`` → ``(returncode, help_text)``; never raises."""
for flag in ("--help", "-h"):
try:
proc = subprocess.run(
[*argv, flag],
capture_output=True,
text=True,
stdin=subprocess.DEVNULL,
timeout=_HELP_TIMEOUT,
)
except (subprocess.TimeoutExpired, OSError):
continue
text = proc.stdout.strip() or proc.stderr.strip()
if text:
return proc.returncode, text
return None, ""
def _section_entries(help_text: str, heading_re: re.Pattern[str]) -> list[list[str]]:
"""Entries (each a list of lines) under headings matching ``heading_re``. A
row at the section's minimum indent starts an entry; deeper rows continue it."""
out: list[list[str]] = []
lines = help_text.splitlines()
i = 0
while i < len(lines):
if not heading_re.match(lines[i]):
i += 1
continue
i += 1
body: list[str] = []
while i < len(lines) and (not lines[i].strip() or lines[i][:1].isspace()):
if lines[i].strip():
body.append(lines[i])
i += 1
if not body:
continue
term = min(len(ln) - len(ln.lstrip()) for ln in body)
cur: list[str] = []
for ln in body:
if (len(ln) - len(ln.lstrip())) == term:
if cur:
out.append(cur)
cur = [ln]
else:
cur.append(ln)
if cur:
out.append(cur)
return out
def _extract_subcommands(help_text: str) -> list[str]:
"""Subcommand names, or [] for a flat tool. Only click ``Commands:`` entries
and argparse ``{a,b,c}`` choice rows count — a plain positional does not."""
cmds: list[str] = []
for entry in _section_entries(help_text, _CMD_HEADING):
word = re.match(r"^([A-Za-z][\w-]*)\b", entry[0].strip())
if word:
cmds.append(word.group(1))
for entry in _section_entries(help_text, _POS_HEADING):
choice = re.match(r"^\{([^}]+)\}", entry[0].strip())
if choice:
cmds.extend(p.strip() for p in choice.group(1).split(","))
seen: list[str] = []
for c in cmds:
if c and c not in seen:
seen.append(c)
return seen
def _entry_head_and_desc(entry: list[str]) -> tuple[str, str]:
m = re.match(r"^\s*(.*?)(?:\s{2,}(.*))?$", entry[0])
head = (m.group(1) if m else entry[0]).strip()
desc_parts = [m.group(2)] if m and m.group(2) else []
desc_parts += [ln.strip() for ln in entry[1:]]
return head, " ".join(p for p in desc_parts if p).strip()
def _parse_option(entry: list[str]) -> tuple[str, dict] | None:
head, desc = _entry_head_and_desc(entry)
flags: list[str] = []
metavar: str | None = None
for tok in head.split(", "):
fm = re.match(r"^(-{1,2}[\w-]+)(?:[ =](.+))?$", tok.strip())
if not fm:
continue
flags.append(fm.group(1))
if fm.group(2):
metavar = fm.group(2).strip()
longs = [f for f in flags if f.startswith("--")]
canonical = longs[-1] if longs else (flags[0] if flags else None)
if not canonical:
return None
key = canonical.lstrip("-").replace("-", "_")
if key in _SKIP_OPTS:
return None
prop: dict = {}
if metavar and metavar.startswith("{") and metavar.endswith("}"):
prop["type"] = "string"
prop["enum"] = [v.strip() for v in metavar[1:-1].split(",")]
elif metavar:
prop["type"] = "string"
else:
prop["type"] = "boolean"
if desc:
prop["description"] = desc
prop["x-cli"] = {"flag": canonical, "value": bool(metavar)}
return key, prop
def _parse_positional(entry: list[str], order: int) -> tuple[str, dict] | None:
head, desc = _entry_head_and_desc(entry)
if head.startswith("{"):
return None
nm = re.match(r"^([A-Za-z][\w-]*)", head)
if not nm:
return None
key = nm.group(1).replace("-", "_")
prop: dict = {"type": "string"}
if desc:
prop["description"] = desc
prop["x-cli"] = {"positional": True, "order": order}
return key, prop
def _summary(help_text: str, fallback: str) -> str:
for para in re.split(r"\n\s*\n", help_text):
p = para.strip()
if not p or p.lower().startswith("usage:") or p.rstrip().endswith(":"):
continue
return " ".join(p.split())
return fallback
def _structured_core(name: str, help_text: str) -> dict | None:
props: dict = {}
required: list[str] = []
for order, entry in enumerate(_section_entries(help_text, _POS_HEADING)):
parsed = _parse_positional(entry, order)
if parsed:
key, prop = parsed
props[key] = prop
required.append(key)
for entry in _section_entries(help_text, _OPT_HEADING):
parsed = _parse_option(entry)
if parsed:
key, prop = parsed
props[key] = prop
if not props:
return None
parameters: dict = {"type": "object", "properties": props}
if required:
parameters["required"] = required
return {
"name": _sanitize_name(name),
"description": _summary(help_text, f"Run the `{name}` command-line tool."),
"parameters": parameters,
}
def _command_core(name: str, argv: list[str], help_text: str, deep: bool) -> dict:
parts = [
f"Run the `{name}` command-line tool. Provide the arguments in the "
f"`command` parameter; the executable name is prepended automatically. "
f"Below is the tool's help output.",
f"\n$ {name} --help\n{help_text}",
]
if deep:
for sub in _extract_subcommands(help_text)[:_MAX_SUBCOMMANDS]:
rc, sub_help = _run_help([*argv, sub])
if rc == 0 and sub_help:
parts.append(f"\n$ {name} {sub} --help\n{sub_help}")
return {
"name": _sanitize_name(name),
"description": "\n".join(parts),
"parameters": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": (
f"Arguments to pass to `{name}`. The full command line run "
f"is `{name} ` followed by this string. Do not include the "
f"leading `{name}`."
),
}
},
"required": ["command"],
},
}
def tool_executable(config: CastleConfig, name: str) -> str:
"""The console script to invoke for tool ``name`` — its first
``[project.scripts]`` key (source of truth even when uninstalled), else the
program name. Mirrors the CLI's tools lens."""
comp = config.programs.get(name)
src = getattr(comp, "source", None) if comp else None
if src:
pyproject = Path(src) / "pyproject.toml"
if pyproject.exists():
try:
data = tomllib.loads(pyproject.read_text())
scripts = data.get("project", {}).get("scripts", {})
if scripts:
return sorted(scripts.keys())[0]
except (OSError, tomllib.TOMLDecodeError):
pass
return name
def collect_tool_help(config: CastleConfig, name: str) -> str:
"""The full recursive ``--help`` text for tool ``name`` — top-level plus each
subcommand's help, the raw material an LLM assist reads to structure a
subcommand tree. Deterministic; raises ``ToolSchemaError`` if unresolved.
"""
exe_name = tool_executable(config, name)
exe = shutil.which(exe_name)
if exe is None:
raise ToolSchemaError(
f"`{exe_name}` is not on PATH — install the tool (enable it, then "
f"`castle apply`) before generating its schema."
)
_, help_text = _run_help([exe])
if not help_text:
raise ToolSchemaError(f"`{exe_name} --help` produced no output.")
parts = [f"$ {exe_name} --help\n{help_text}"]
for sub in _extract_subcommands(help_text)[:_MAX_SUBCOMMANDS]:
rc, sub_help = _run_help([exe, sub])
if rc == 0 and sub_help:
parts.append(f"\n$ {exe_name} {sub} --help\n{sub_help}")
return "\n".join(parts)
def validate_tool_schema_core(core: object) -> list[str]:
"""Deterministically validate a neutral tool-call core.
Returns a list of human-readable error strings (empty ⇒ valid). Checks the
``{name, description, parameters}`` shape *and* that ``parameters`` is a valid
JSON Schema (via ``jsonschema`` meta-validation, which catches malformed
properties like an ``enum`` that isn't a list — the defect weak models hit).
Shared by the LLM repair loop, the ``validate`` endpoint, and the accept gate.
"""
errors: list[str] = []
if not isinstance(core, dict):
return ["schema must be a JSON object"]
name = core.get("name")
if not isinstance(name, str) or not name:
errors.append("`name` must be a non-empty string")
elif not re.fullmatch(r"[a-zA-Z0-9_-]{1,64}", name):
errors.append("`name` must match ^[a-zA-Z0-9_-]{1,64}$")
if not isinstance(core.get("description"), str):
errors.append("`description` must be a string")
params = core.get("parameters")
if not isinstance(params, dict):
errors.append("`parameters` must be an object")
return errors
if params.get("type") != "object":
errors.append('`parameters.type` must be "object"')
props = params.get("properties")
if not isinstance(props, dict):
errors.append("`parameters.properties` must be an object")
elif not props:
errors.append("`parameters.properties` is empty — no arguments captured")
try:
import jsonschema
from jsonschema.exceptions import SchemaError
try:
jsonschema.Draft202012Validator.check_schema(params)
except SchemaError as e:
loc = "/".join(str(p) for p in e.absolute_path) or "parameters"
errors.append(f"invalid JSON Schema at `{loc}`: {e.message}")
except ImportError: # jsonschema not installed — structural checks stand alone
pass
return errors
def is_tool_schema_core(obj: object) -> bool:
"""True if ``obj`` is a valid neutral tool-call core (no validation errors)."""
return not validate_tool_schema_core(obj)
def derive_tool_schema(config: CastleConfig, name: str, deep: bool = False) -> dict:
"""Derive the neutral tool-call core for tool ``name`` from its ``--help``.
Structured params when the help is standard and flat; the command-string
fallback for non-standard help / subcommand trees. Raises ``ToolSchemaError``
if the executable isn't on PATH or emits no help.
"""
exe_name = tool_executable(config, name)
exe = shutil.which(exe_name)
if exe is None:
raise ToolSchemaError(
f"`{exe_name}` is not on PATH — install the tool (enable it, then "
f"`castle apply`) before generating its schema."
)
_, help_text = _run_help([exe])
if not help_text:
raise ToolSchemaError(f"`{exe_name} --help` produced no output.")
if not _extract_subcommands(help_text):
structured = _structured_core(exe_name, help_text)
if structured:
return structured
return _command_core(exe_name, [exe], help_text, deep)
def render_tool_schema(core: dict, fmt: str = "openai") -> dict:
"""Wrap a neutral core in a provider envelope.
``openai`` (default, litellm-native) → ``{type: function, function: {…}}``;
``anthropic`` → ``{name, description, input_schema}``; ``neutral`` → as stored.
"""
if fmt == "neutral":
return core
if fmt == "anthropic":
return {
"name": core["name"],
"description": core["description"],
"input_schema": core["parameters"],
}
return {
"type": "function",
"function": {
"name": core["name"],
"description": core["description"],
"parameters": core["parameters"],
},
}

View File

@@ -0,0 +1,185 @@
"""Tests for castle_core.tool_schema — deriving neutral tool-call cores from --help."""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from castle_core.tool_schema import (
ToolSchemaError,
_command_core,
_extract_subcommands,
_sanitize_name,
_structured_core,
collect_tool_help,
derive_tool_schema,
is_tool_schema_core,
render_tool_schema,
tool_executable,
validate_tool_schema_core,
)
FLAT_HELP = """\
usage: widget [-h] [--deep] [--mode {a,b}] target
Do a widget thing.
positional arguments:
target What to widget
options:
-h, --help show this help message and exit
--deep Recurse
--mode {a,b} Pick a mode
"""
SUBCMD_HELP = "positional arguments:\n {build,deploy}\n build Build\n"
def _fake_config(programs: dict) -> object:
return SimpleNamespace(programs=programs)
class TestPure:
def test_sanitize_name(self) -> None:
assert _sanitize_name("my.tool v2") == "my_tool_v2"
assert len(_sanitize_name("x" * 100)) == 64
def test_flat_tool_has_no_subcommands(self) -> None:
assert _extract_subcommands(FLAT_HELP) == []
def test_argparse_choice_row_subcommands(self) -> None:
assert _extract_subcommands(SUBCMD_HELP) == ["build", "deploy"]
def test_structured_core_typed_params(self) -> None:
core = _structured_core("widget", FLAT_HELP)
assert core is not None
props = core["parameters"]["properties"]
assert set(props) == {"target", "deep", "mode"}
assert props["deep"]["type"] == "boolean"
assert props["mode"]["enum"] == ["a", "b"]
assert core["parameters"]["required"] == ["target"]
def test_structured_none_without_options(self) -> None:
assert _structured_core("x", "free-form help\n") is None
def test_command_core_shape(self) -> None:
core = _command_core("jq", ["/usr/bin/jq"], "Usage: jq ...", deep=False)
assert core["parameters"]["required"] == ["command"]
class TestRender:
def test_render_openai_default(self) -> None:
core = _command_core("jq", ["/usr/bin/jq"], "help", deep=False)
out = render_tool_schema(core)
assert out["type"] == "function"
assert out["function"]["parameters"] == core["parameters"]
def test_render_anthropic(self) -> None:
core = _command_core("jq", ["/usr/bin/jq"], "help", deep=False)
out = render_tool_schema(core, "anthropic")
assert set(out) == {"name", "description", "input_schema"}
assert out["input_schema"] == core["parameters"]
def test_render_neutral_is_identity(self) -> None:
core = _command_core("jq", ["/usr/bin/jq"], "help", deep=False)
assert render_tool_schema(core, "neutral") is core
class TestResolution:
def test_tool_executable_falls_back_to_name(self) -> None:
cfg = _fake_config({"widget": SimpleNamespace(source=None)})
assert tool_executable(cfg, "widget") == "widget"
def test_tool_executable_reads_pyproject_scripts(self, tmp_path) -> None:
(tmp_path / "pyproject.toml").write_text(
'[project.scripts]\nintent-router = "x:main"\n'
)
cfg = _fake_config({"r": SimpleNamespace(source=str(tmp_path))})
assert tool_executable(cfg, "r") == "intent-router"
class TestDerive:
def test_missing_executable_raises(self) -> None:
cfg = _fake_config({"nope": SimpleNamespace(source=None)})
with pytest.raises(ToolSchemaError, match="not on PATH"):
derive_tool_schema(cfg, "nope")
def test_derive_real_tool_returns_neutral_core(self) -> None:
cfg = _fake_config({"python3": SimpleNamespace(source=None)})
core = derive_tool_schema(cfg, "python3")
assert core["name"] == "python3"
assert "parameters" in core # neutral shape, not input_schema
assert core["description"]
class TestLLMAssistHelpers:
"""Deterministic helpers that feed / validate the (castle-api) LLM assist."""
def test_collect_tool_help_real_tool(self) -> None:
cfg = _fake_config({"python3": SimpleNamespace(source=None)})
help_text = collect_tool_help(cfg, "python3")
assert help_text and "$ python3 --help" in help_text
def test_collect_tool_help_missing_raises(self) -> None:
cfg = _fake_config({"nope": SimpleNamespace(source=None)})
with pytest.raises(ToolSchemaError, match="not on PATH"):
collect_tool_help(cfg, "nope")
def test_is_tool_schema_core(self) -> None:
good = {
"name": "jq",
"description": "process json",
"parameters": {"type": "object", "properties": {"filter": {"type": "string"}}},
}
assert is_tool_schema_core(good) is True
assert is_tool_schema_core({"name": "x", "description": "y"}) is False # no params
assert is_tool_schema_core("nope") is False
class TestValidate:
"""Deterministic validation — shape + JSON-Schema meta-validation."""
_GOOD = {
"name": "jq",
"description": "process json",
"parameters": {"type": "object", "properties": {"filter": {"type": "string"}}},
}
def test_valid_returns_no_errors(self) -> None:
assert validate_tool_schema_core(self._GOOD) == []
def test_malformed_enum_is_caught(self) -> None:
"""The qwen defect: `enum` as a count, not a list — a JSON-Schema error."""
bad = {
"name": "search",
"description": "d",
"parameters": {
"type": "object",
"properties": {"sub": {"type": "string", "enum": 4}},
},
}
errors = validate_tool_schema_core(bad)
assert errors and any("JSON Schema" in e for e in errors)
def test_missing_parameters(self) -> None:
assert validate_tool_schema_core({"name": "x", "description": "y"})
def test_empty_properties(self) -> None:
errors = validate_tool_schema_core(
{"name": "x", "description": "y", "parameters": {"type": "object", "properties": {}}}
)
assert any("empty" in e for e in errors)
def test_bad_name_char(self) -> None:
errors = validate_tool_schema_core(
{**self._GOOD, "name": "has space"}
)
assert any("name" in e for e in errors)
def test_parameters_not_object_type(self) -> None:
errors = validate_tool_schema_core(
{"name": "x", "description": "y", "parameters": {"type": "array", "properties": {}}}
)
assert errors

132
uv.lock generated
View File

@@ -40,6 +40,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
] ]
[[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]] [[package]]
name = "castle-api" name = "castle-api"
version = "0.1.0" version = "0.1.0"
@@ -119,6 +128,7 @@ name = "castle-core"
version = "0.1.0" version = "0.1.0"
source = { editable = "core" } source = { editable = "core" }
dependencies = [ dependencies = [
{ name = "jsonschema" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pyyaml" }, { name = "pyyaml" },
] ]
@@ -131,6 +141,7 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "jsonschema", specifier = ">=4.0.0" },
{ name = "pydantic", specifier = ">=2.0.0" }, { name = "pydantic", specifier = ">=2.0.0" },
{ name = "pyyaml", specifier = ">=6.0.0" }, { name = "pyyaml", specifier = ">=6.0.0" },
] ]
@@ -297,6 +308,33 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
] ]
[[package]]
name = "jsonschema"
version = "4.26.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "jsonschema-specifications" },
{ name = "referencing" },
{ name = "rpds-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
]
[[package]]
name = "jsonschema-specifications"
version = "2025.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "referencing" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
]
[[package]] [[package]]
name = "markupsafe" name = "markupsafe"
version = "3.0.3" version = "3.0.3"
@@ -562,6 +600,100 @@ wheels = [
{ 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" }, { 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 = "referencing"
version = "0.37.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "rpds-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
]
[[package]]
name = "rpds-py"
version = "2026.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" },
{ url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" },
{ url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" },
{ url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" },
{ url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" },
{ url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" },
{ url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" },
{ url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" },
{ url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" },
{ url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" },
{ url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" },
{ url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" },
{ url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" },
{ url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" },
{ url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" },
{ url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" },
{ url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" },
{ url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" },
{ url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" },
{ url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" },
{ url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" },
{ url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" },
{ url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" },
{ url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" },
{ url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" },
{ url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" },
{ url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" },
{ url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" },
{ url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" },
{ url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" },
{ url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" },
{ url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" },
{ url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" },
{ url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" },
{ url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" },
{ url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" },
{ url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" },
{ url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" },
{ url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" },
{ url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" },
{ url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" },
{ url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" },
{ url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" },
{ url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" },
{ url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" },
{ url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" },
{ url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" },
{ url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" },
{ url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" },
{ url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" },
{ url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" },
{ url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" },
{ url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" },
{ url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" },
{ url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" },
{ url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" },
{ url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" },
{ url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" },
{ url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" },
{ url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" },
{ url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" },
{ url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" },
{ url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" },
{ url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" },
{ url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" },
{ url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" },
{ url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" },
{ url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" },
{ url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" },
{ url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" },
{ url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" },
]
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.15.2" version = "0.15.2"