For real. Tools schema generation and ux tweaks.
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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`."""
|
||||||
|
|||||||
159
castle-api/src/castle_api/llm.py
Normal file
159
castle-api/src/castle_api/llm.py
Normal 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)
|
||||||
|
)
|
||||||
199
castle-api/tests/test_tool_schema_api.py
Normal file
199
castle-api/tests/test_tool_schema_api.py
Normal 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
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
393
core/src/castle_core/tool_schema.py
Normal file
393
core/src/castle_core/tool_schema.py
Normal 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"],
|
||||||
|
},
|
||||||
|
}
|
||||||
185
core/tests/test_tool_schema.py
Normal file
185
core/tests/test_tool_schema.py
Normal 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
132
uv.lock
generated
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user