All checks were successful
ci/woodpecker/push/unit Pipeline was successful
Strip session-only routes (auth, me, teams, hosts, channels, admin, audit-logs) from api/openapi.yaml via new scripts/prune_openapi.py, which is wired into `make generate` and `make prune-spec`. Spec drops from 4787 to 1717 lines; regenerated _generated.py drops from 906 to 415 lines with all obsolete models removed. Add SDK methods on CapsulesResource / AsyncCapsulesResource for the apiKey-usable routes that were previously unwrapped: - capsules.stats(range=...) -> CapsuleStats - capsules.usage(from_=..., to=...) -> UsageResponse (accepts date) - capsules.metrics(id, range=...) -> CapsuleMetrics Add EventsResource / AsyncEventsResource exposing WrennClient.events.stream() over /v1/events/stream (SSE). Parser handles `:keepalive` comments and multi-line `data:` frames. Re-export new models (CapsuleStats, CapsuleMetrics, MetricPoint, UsageResponse, SSEEvent, Resource, Actor) from wrenn.models. Drop email-validator runtime dep (no EmailStr remains). Bump version to 0.3.0 (pyproject + __version__). Add pyyaml to dev deps and types-PyYAML to the pre-commit mypy hook for the prune script. Normalize docstrings on new methods/classes to Google style with Example:: blocks where useful. Regenerate docs/reference.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
180 lines
5.7 KiB
Python
180 lines
5.7 KiB
Python
"""Prune the upstream Wrenn OpenAPI spec down to the SDK's surface.
|
|
|
|
The upstream spec at ``wrennhq/wrenn/internal/api/openapi.yaml`` covers
|
|
the whole control plane: account/team/host/channel/admin routes that
|
|
require an interactive ``wrenn_sid`` cookie. The Python SDK only ever
|
|
authenticates with an API key, so those routes are dead surface area
|
|
here. This script keeps only paths that list ``apiKeyAuth`` (and the
|
|
schemas they transitively reach) and rewrites the spec in place.
|
|
|
|
Run via ``make generate`` (curl ➜ prune ➜ datamodel-codegen) or
|
|
``uv run python scripts/prune_openapi.py [path]`` directly.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
|
|
KEEP_PATHS: set[str] = {
|
|
"/v1/capsules",
|
|
"/v1/capsules/stats",
|
|
"/v1/capsules/usage",
|
|
"/v1/capsules/{id}",
|
|
"/v1/capsules/{id}/exec",
|
|
"/v1/capsules/{id}/exec/stream",
|
|
"/v1/capsules/{id}/processes",
|
|
"/v1/capsules/{id}/processes/{selector}",
|
|
"/v1/capsules/{id}/processes/{selector}/stream",
|
|
"/v1/capsules/{id}/ping",
|
|
"/v1/capsules/{id}/metrics",
|
|
"/v1/capsules/{id}/pause",
|
|
"/v1/capsules/{id}/resume",
|
|
"/v1/capsules/{id}/files/write",
|
|
"/v1/capsules/{id}/files/read",
|
|
"/v1/capsules/{id}/files/list",
|
|
"/v1/capsules/{id}/files/mkdir",
|
|
"/v1/capsules/{id}/files/remove",
|
|
"/v1/capsules/{id}/files/stream/write",
|
|
"/v1/capsules/{id}/files/stream/read",
|
|
"/v1/capsules/{id}/pty",
|
|
"/v1/snapshots",
|
|
"/v1/snapshots/{name}",
|
|
"/v1/events/stream",
|
|
}
|
|
|
|
KEEP_SECURITY_SCHEMES: set[str] = {"apiKeyAuth"}
|
|
|
|
REF_RE = re.compile(r"#/components/(schemas|responses)/([A-Za-z0-9_]+)")
|
|
|
|
|
|
def _walk_refs(node: object, schemas: dict, responses: dict, seen: set[str]) -> None:
|
|
"""Depth-first walk collecting ``schemas:Name`` / ``responses:Name`` refs."""
|
|
if isinstance(node, dict):
|
|
for k, v in node.items():
|
|
if k == "$ref" and isinstance(v, str):
|
|
m = REF_RE.search(v)
|
|
if m:
|
|
kind, name = m.group(1), m.group(2)
|
|
key = f"{kind}:{name}"
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
target = (schemas if kind == "schemas" else responses).get(name)
|
|
if target is not None:
|
|
_walk_refs(target, schemas, responses, seen)
|
|
else:
|
|
_walk_refs(v, schemas, responses, seen)
|
|
elif isinstance(node, list):
|
|
for item in node:
|
|
_walk_refs(item, schemas, responses, seen)
|
|
|
|
|
|
def _filter_security(operation: dict) -> None:
|
|
sec = operation.get("security")
|
|
if not isinstance(sec, list):
|
|
return
|
|
kept = [
|
|
entry
|
|
for entry in sec
|
|
if isinstance(entry, dict)
|
|
and any(name in KEEP_SECURITY_SCHEMES for name in entry)
|
|
]
|
|
if kept:
|
|
operation["security"] = kept
|
|
else:
|
|
operation.pop("security", None)
|
|
|
|
|
|
def prune(spec: dict) -> dict:
|
|
paths = spec.get("paths", {})
|
|
components = spec.get("components", {})
|
|
schemas = components.get("schemas", {}) or {}
|
|
responses = components.get("responses", {}) or {}
|
|
|
|
missing = KEEP_PATHS - set(paths)
|
|
if missing:
|
|
raise SystemExit(
|
|
"prune_openapi: upstream spec is missing expected paths: "
|
|
+ ", ".join(sorted(missing))
|
|
)
|
|
|
|
kept_paths: dict[str, dict] = {}
|
|
for path, item in paths.items():
|
|
if path not in KEEP_PATHS:
|
|
continue
|
|
if not isinstance(item, dict):
|
|
kept_paths[path] = item
|
|
continue
|
|
for method, op in list(item.items()):
|
|
if isinstance(op, dict):
|
|
_filter_security(op)
|
|
if "security" in op and not op["security"]:
|
|
del item[method]
|
|
kept_paths[path] = item
|
|
|
|
seen: set[str] = set()
|
|
_walk_refs(kept_paths, schemas, responses, seen)
|
|
|
|
# Walk again to close over inter-schema refs we just pulled in.
|
|
while True:
|
|
before = len(seen)
|
|
for key in list(seen):
|
|
kind, name = key.split(":", 1)
|
|
target = (schemas if kind == "schemas" else responses).get(name)
|
|
if target is not None:
|
|
_walk_refs(target, schemas, responses, seen)
|
|
if len(seen) == before:
|
|
break
|
|
|
|
kept_schemas = {n: schemas[n] for n in schemas if f"schemas:{n}" in seen}
|
|
kept_responses = {n: responses[n] for n in responses if f"responses:{n}" in seen}
|
|
|
|
sec_schemes = components.get("securitySchemes", {}) or {}
|
|
kept_sec_schemes = {
|
|
n: sec_schemes[n] for n in sec_schemes if n in KEEP_SECURITY_SCHEMES
|
|
}
|
|
|
|
new_components: dict = {}
|
|
if kept_responses:
|
|
new_components["responses"] = kept_responses
|
|
if kept_sec_schemes:
|
|
new_components["securitySchemes"] = kept_sec_schemes
|
|
if kept_schemas:
|
|
new_components["schemas"] = kept_schemas
|
|
|
|
spec["paths"] = kept_paths
|
|
spec["components"] = new_components
|
|
|
|
top_security = spec.get("security")
|
|
if isinstance(top_security, list):
|
|
spec["security"] = [
|
|
e
|
|
for e in top_security
|
|
if isinstance(e, dict) and any(name in KEEP_SECURITY_SCHEMES for name in e)
|
|
]
|
|
|
|
return spec
|
|
|
|
|
|
def main(argv: list[str]) -> int:
|
|
target = Path(argv[1]) if len(argv) > 1 else Path("api/openapi.yaml")
|
|
spec = yaml.safe_load(target.read_text())
|
|
pruned = prune(spec)
|
|
target.write_text(
|
|
yaml.safe_dump(pruned, sort_keys=False, width=120, allow_unicode=True)
|
|
)
|
|
print(
|
|
f"prune_openapi: kept {len(pruned['paths'])} paths, "
|
|
f"{len(pruned['components'].get('schemas', {}))} schemas in {target}",
|
|
file=sys.stderr,
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main(sys.argv))
|