mirror of
https://github.com/vectorize-io/hindsight.git
synced 2026-09-14 19:31:49 +08:00
2bf10435d5
* fix(python-client): add the missing async twins to the convenience wrapper (#4221) The class docstring promises an `a`-prefixed variant for every convenience method, but 27 of them had none. That is not just inconvenient: the sync methods go through `_run_async` -> `loop.run_until_complete`, which raises `RuntimeError: This event loop is already running` inside a live loop. So mental models, knowledge pages, directives and bank config were unreachable through the wrapper from exactly the contexts the docstring points at (FastAPI, LangGraph, CrewAI), and callers had to drop to the generated SDK for a whole feature area. Each of the 27 now has its implementation on `a<name>` with the sync method forwarding to it via `_run_async`, so there is one body per operation rather than two that can drift. `tests/test_async_sync_parity.py` guards the family: every public convenience method must have an async twin, the twins must actually be sync/async, and their signatures must match argument for argument (a twin that quietly drops a parameter is the #2975/#3042 failure mode). Plus the issue's repro as a regression test — the async twin called from inside a running event loop. * fix(dev): read the bank-config updates dict from aupdate_bank_config The client-coverage check anchored on the sync update_bank_config to find the enumerated updates dict. That body now lives on the async twin, with the sync method forwarding to it, so the check saw a forwarder and reported all 48 fields as accepted-but-never-forwarded.
455 lines
18 KiB
Python
455 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Check that the Python and TypeScript high-level client wrappers expose every
|
|
request-body parameter from the OpenAPI spec.
|
|
|
|
The generated (low-level) clients are auto-generated from the spec and are
|
|
always in sync. The *wrapper* clients (``hindsight_client.py`` for Python,
|
|
``src/index.ts`` for TypeScript) are hand-written convenience layers that
|
|
re-export a curated surface. This script catches fields that are present in
|
|
the OpenAPI spec but missing from the wrapper.
|
|
|
|
For each client, the script:
|
|
1. Discovers which OpenAPI operations the wrapper covers by scanning for
|
|
SDK call sites (e.g. ``sdk.recallMemories(`` or
|
|
``self._memory_api.recall_memories(``).
|
|
2. For each covered operation, checks that every request-body property
|
|
appears somewhere in the wrapper source around that call site (with
|
|
automatic snake_case ↔ camelCase conversion).
|
|
3. Allows explicit skips via per-client ``.openapi-coverage.toml`` files.
|
|
|
|
Bank config is checked separately. ``PATCH /banks/{bank_id}/config`` takes a
|
|
free-form ``updates: dict[str, Any]``, so the spec advertises a single ``updates``
|
|
property and every individual setting is invisible to the request-body walk above.
|
|
The wrappers enumerate those settings as keyword arguments, which is exactly where
|
|
they drift, so ``check_bank_config_fields`` reads the server's ``_CONFIGURABLE_FIELDS``
|
|
directly and asserts each one reaches both wrappers. Skips live in the same manifest
|
|
under ``[bank_config]``.
|
|
|
|
Usage:
|
|
cd hindsight-dev
|
|
uv run client-coverage-check
|
|
uv run client-coverage-check --client python
|
|
uv run client-coverage-check --client typescript
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import ast
|
|
import json
|
|
import re
|
|
import sys
|
|
import tomllib
|
|
from collections.abc import Callable
|
|
from pathlib import Path
|
|
|
|
HTTP_METHODS = {"get", "post", "put", "patch", "delete"}
|
|
|
|
|
|
def get_repo_root() -> Path:
|
|
here = Path(__file__).resolve().parent
|
|
for parent in (here, *here.parents):
|
|
if (parent / "hindsight-clients").is_dir() and (parent / "hindsight-docs").is_dir():
|
|
return parent
|
|
raise RuntimeError(f"Could not locate repo root from {here}")
|
|
|
|
|
|
# ── helpers ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
def snake_to_camel(name: str) -> str:
|
|
"""Convert snake_case to camelCase."""
|
|
parts = name.split("_")
|
|
return parts[0] + "".join(p.capitalize() for p in parts[1:])
|
|
|
|
|
|
def load_openapi_operations(spec_path: Path) -> dict[str, list[str]]:
|
|
"""Return {operation_id: [property_names]} for operations with request bodies."""
|
|
spec = json.loads(spec_path.read_text())
|
|
schemas = spec.get("components", {}).get("schemas", {})
|
|
result: dict[str, list[str]] = {}
|
|
|
|
for path_item in spec.get("paths", {}).values():
|
|
for method, op in path_item.items():
|
|
if method.lower() not in HTTP_METHODS or not isinstance(op, dict):
|
|
continue
|
|
op_id = op.get("operationId")
|
|
if not op_id:
|
|
continue
|
|
body = op.get("requestBody", {}).get("content", {}).get("application/json", {}).get("schema")
|
|
if not body:
|
|
continue
|
|
if "$ref" in body:
|
|
schema_name = body["$ref"].split("/")[-1]
|
|
schema = schemas.get(schema_name, {})
|
|
else:
|
|
schema = body
|
|
props = list((schema.get("properties") or {}).keys())
|
|
if props:
|
|
result[op_id] = props
|
|
|
|
return result
|
|
|
|
|
|
def load_manifest(path: Path) -> tuple[dict[str, str], dict[str, dict[str, str]]]:
|
|
"""Return (operation-level skips, per-operation field skips) from a TOML manifest."""
|
|
if not path.exists():
|
|
return {}, {}
|
|
data = tomllib.loads(path.read_text())
|
|
|
|
skip_raw = data.get("skip", {}) or {}
|
|
op_skips: dict[str, str] = {}
|
|
for op_id, reason in skip_raw.items():
|
|
if not isinstance(reason, str) or not reason.strip():
|
|
raise ValueError(f"{path}: skip.{op_id} must be a non-empty string reason")
|
|
op_skips[op_id] = reason.strip()
|
|
|
|
fields_raw = data.get("fields", {}) or {}
|
|
field_skips: dict[str, dict[str, str]] = {}
|
|
for op_id, table in fields_raw.items():
|
|
if not isinstance(table, dict):
|
|
raise ValueError(f"{path}: [fields.{op_id}] must be a table")
|
|
per_op: dict[str, str] = {}
|
|
for field, reason in table.items():
|
|
if not isinstance(reason, str) or not reason.strip():
|
|
raise ValueError(f"{path}: fields.{op_id}.{field} must be a non-empty string reason")
|
|
per_op[field] = reason.strip()
|
|
field_skips[op_id] = per_op
|
|
|
|
return op_skips, field_skips
|
|
|
|
|
|
# ── per-client checking ─────────────────────────────────────────────────
|
|
|
|
|
|
def field_present_in_source(prop: str, source: str) -> bool:
|
|
"""Check if a field name (snake_case from OpenAPI) appears in wrapper source.
|
|
|
|
Checks both snake_case and camelCase variants, looking for the field
|
|
as an identifier (word boundary on at least one side).
|
|
"""
|
|
variants = {prop, snake_to_camel(prop)}
|
|
for v in variants:
|
|
# Match as an identifier: preceded by word boundary or common delimiters
|
|
# and followed by word boundary or common delimiters
|
|
if re.search(r"(?:^|[\s{,.(:\[\"'])" + re.escape(v) + r"(?:$|[\s},:)?\[\"'=])", source):
|
|
return True
|
|
return False
|
|
|
|
|
|
def check_client(
|
|
*,
|
|
client_name: str,
|
|
source_path: Path,
|
|
manifest_path: Path,
|
|
op_props: dict[str, list[str]],
|
|
find_covered_ops: Callable[[str, set[str]], set[str]],
|
|
) -> list[str]:
|
|
"""Check a single client wrapper. Returns list of error strings."""
|
|
source = source_path.read_text()
|
|
op_skips, field_skips = load_manifest(manifest_path)
|
|
errors: list[str] = []
|
|
|
|
# Find which operations this wrapper covers
|
|
covered_ops = find_covered_ops(source, set(op_props.keys()))
|
|
|
|
# Check that skipped operations actually exist and aren't covered
|
|
for op_id in sorted(op_skips.keys()):
|
|
if op_id not in op_props:
|
|
errors.append(f"STALE OP {op_id}: listed in [skip] but has no request body in the spec")
|
|
|
|
# Check fields for each covered operation
|
|
total_props = 0
|
|
covered_props = 0
|
|
skipped_props = 0
|
|
|
|
for op_id in sorted(covered_ops):
|
|
props = op_props.get(op_id, [])
|
|
if op_id in op_skips:
|
|
continue
|
|
per_op_skips = field_skips.get(op_id, {})
|
|
for prop in props:
|
|
total_props += 1
|
|
if prop in per_op_skips:
|
|
skipped_props += 1
|
|
continue
|
|
if field_present_in_source(prop, source):
|
|
covered_props += 1
|
|
continue
|
|
errors.append(
|
|
f"MISSING PARAM {op_id}.{prop}: request body field not found in "
|
|
f"wrapper source (checked: {prop}, {snake_to_camel(prop)})"
|
|
)
|
|
|
|
# Stale field skips
|
|
for op_id, per_op in field_skips.items():
|
|
if op_id not in op_props:
|
|
for field in per_op:
|
|
errors.append(
|
|
f"STALE PARAM {op_id}.{field}: [fields.{op_id}] references "
|
|
f"an operation with no request body in the spec"
|
|
)
|
|
continue
|
|
known = set(op_props.get(op_id, []))
|
|
for field in per_op:
|
|
if field not in known:
|
|
errors.append(
|
|
f"STALE PARAM {op_id}.{field}: field not present in the "
|
|
f"{op_id} request schema. Remove from [fields.{op_id}]."
|
|
)
|
|
|
|
print(f" [{client_name}]")
|
|
print(f" Source: {source_path}")
|
|
print(f" Manifest: {manifest_path}")
|
|
print(f" Operations: {len(covered_ops)} covered")
|
|
print(f" Request params: {total_props}")
|
|
print(f" covered: {covered_props}")
|
|
print(f" skipped: {skipped_props}")
|
|
print(f" missing: {total_props - covered_props - skipped_props}")
|
|
|
|
return errors
|
|
|
|
|
|
# ── bank config ──────────────────────────────────────────────────────────
|
|
|
|
|
|
def load_configurable_fields(config_path: Path) -> list[str]:
|
|
"""Return the server's per-bank overridable config field names.
|
|
|
|
Parsed out of ``config.py`` with ast rather than imported: hindsight-dev does
|
|
not depend on hindsight-api, and this only needs the literal set members.
|
|
"""
|
|
tree = ast.parse(config_path.read_text())
|
|
for node in ast.walk(tree):
|
|
# Bind the value inside each branch: narrowing on `node` does not survive
|
|
# the if/elif when only `targets` is assigned in them.
|
|
if isinstance(node, ast.Assign):
|
|
targets: list[ast.expr] = list(node.targets)
|
|
value: ast.expr | None = node.value
|
|
elif isinstance(node, ast.AnnAssign):
|
|
targets = [node.target]
|
|
value = node.value
|
|
else:
|
|
continue
|
|
if not any(isinstance(t, ast.Name) and t.id == "_CONFIGURABLE_FIELDS" for t in targets):
|
|
continue
|
|
if not isinstance(value, ast.Set | ast.List | ast.Tuple):
|
|
raise ValueError(f"{config_path}: _CONFIGURABLE_FIELDS is not a literal set/list/tuple")
|
|
return sorted(elt.value for elt in value.elts if isinstance(elt, ast.Constant) and isinstance(elt.value, str))
|
|
raise ValueError(f"{config_path}: no _CONFIGURABLE_FIELDS assignment found")
|
|
|
|
|
|
def load_bank_config_skips(path: Path) -> dict[str, str]:
|
|
"""Return the ``[bank_config]`` skip table from a client's coverage manifest."""
|
|
if not path.exists():
|
|
return {}
|
|
data = tomllib.loads(path.read_text())
|
|
raw = data.get("bank_config", {}) or {}
|
|
if not isinstance(raw, dict):
|
|
raise ValueError(f"{path}: [bank_config] must be a table")
|
|
skips: dict[str, str] = {}
|
|
for field, reason in raw.items():
|
|
if not isinstance(reason, str) or not reason.strip():
|
|
raise ValueError(f"{path}: bank_config.{field} must be a non-empty string reason")
|
|
skips[field] = reason.strip()
|
|
return skips
|
|
|
|
|
|
def extract_config_method(source: str, client_name: str) -> str:
|
|
"""Return just the wrapper's bank-config update method body.
|
|
|
|
Scoped rather than scanning the whole file: these field names also appear in
|
|
other methods (``recall_max_tokens`` is a recall argument, ``retain_chunk_size``
|
|
a retain one), so a whole-file search reports a field as reachable when the
|
|
config method does not actually accept it.
|
|
"""
|
|
if client_name == "python":
|
|
# The async twin, not the sync one: since #4221 every convenience method's
|
|
# body lives on ``a<name>`` and the sync method is a thin forwarder, so the
|
|
# enumerated updates dict this check reads is in ``aupdate_bank_config``.
|
|
start = re.search(r"^ async def aupdate_bank_config\(", source, re.M)
|
|
end_pat = r"^ (?:async )?def "
|
|
else:
|
|
start = re.search(r"^ async updateBankConfig\(", source, re.M)
|
|
end_pat = r"^ (?:async )?\w+\("
|
|
if start is None:
|
|
raise ValueError(f"{client_name}: could not locate the bank-config update method")
|
|
rest = source[start.end() :]
|
|
end = re.search(end_pat, rest, re.M)
|
|
return rest[: end.start()] if end else rest
|
|
|
|
|
|
def forwarding_pattern(field: str, client_name: str) -> str:
|
|
"""Regex proving a field is written into the PATCH body, not just accepted."""
|
|
if client_name == "python":
|
|
# "field_name": field_name, inside the updates dict comprehension
|
|
return r'"' + re.escape(field) + r'"\s*:'
|
|
# updates.field_name = options.fieldName;
|
|
return r"updates\." + re.escape(field) + r"\s*="
|
|
|
|
|
|
def check_bank_config_fields(
|
|
*,
|
|
client_name: str,
|
|
source_path: Path,
|
|
manifest_path: Path,
|
|
fields: list[str],
|
|
) -> list[str]:
|
|
"""Check that every server-configurable bank field reaches one client wrapper."""
|
|
source = extract_config_method(source_path.read_text(), client_name)
|
|
skips = load_bank_config_skips(manifest_path)
|
|
errors: list[str] = []
|
|
|
|
covered = skipped = 0
|
|
for field in fields:
|
|
if field in skips:
|
|
skipped += 1
|
|
continue
|
|
if not field_present_in_source(field, source):
|
|
errors.append(f"MISSING CONFIG {field}: not accepted by the wrapper's bank-config method")
|
|
continue
|
|
# Accepting the argument is not enough — both wrappers build the request
|
|
# body by enumerating fields, so one that is declared but never written
|
|
# into that map is accepted and then silently dropped.
|
|
if not re.search(forwarding_pattern(field, client_name), source):
|
|
errors.append(
|
|
f"UNFORWARDED CONFIG {field}: accepted by the wrapper but never "
|
|
f"written into the request body — it would be silently dropped"
|
|
)
|
|
continue
|
|
covered += 1
|
|
|
|
for field in sorted(skips):
|
|
if field not in fields:
|
|
errors.append(
|
|
f"STALE CONFIG {field}: listed in [bank_config] but not in "
|
|
f"the server's _CONFIGURABLE_FIELDS. Remove it."
|
|
)
|
|
|
|
print(f" Bank config: {len(fields)}")
|
|
print(f" covered: {covered}")
|
|
print(f" skipped: {skipped}")
|
|
print(f" missing: {len(fields) - covered - skipped}")
|
|
|
|
return errors
|
|
|
|
|
|
# ── TypeScript ───────────────────────────────────────────────────────────
|
|
|
|
|
|
def find_ts_covered_ops(source: str, op_ids: set[str]) -> set[str]:
|
|
"""Find operations called via ``sdk.<operationId>(`` in TS wrapper.
|
|
|
|
The TS generated SDK uses camelCase method names (e.g. ``retainMemories``)
|
|
while OpenAPI operation IDs are snake_case (``retain_memories``). We check
|
|
both variants.
|
|
"""
|
|
covered: set[str] = set()
|
|
for op_id in op_ids:
|
|
camel = snake_to_camel(op_id)
|
|
for variant in (op_id, camel):
|
|
if re.search(r"sdk\.\s*" + re.escape(variant) + r"\s*\(", source):
|
|
covered.add(op_id)
|
|
break
|
|
return covered
|
|
|
|
|
|
# ── Python ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
def find_py_covered_ops(source: str, op_ids: set[str]) -> set[str]:
|
|
"""Find operations called via ``self._*_api.<operation_id>(`` in Python wrapper."""
|
|
covered: set[str] = set()
|
|
for op_id in op_ids:
|
|
if re.search(r"self\._\w+_api\.\s*" + re.escape(op_id) + r"\s*\(", source):
|
|
covered.add(op_id)
|
|
return covered
|
|
|
|
|
|
# ── main ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description="Check client SDK wrapper coverage against OpenAPI spec")
|
|
parser.add_argument(
|
|
"--client",
|
|
choices=["python", "typescript", "all"],
|
|
default="all",
|
|
help="Which client to check (default: all)",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
root = get_repo_root()
|
|
spec_path = root / "hindsight-docs" / "static" / "openapi.json"
|
|
|
|
if not spec_path.exists():
|
|
print(f"ERROR: OpenAPI spec not found at {spec_path}", file=sys.stderr)
|
|
print(" Run ./scripts/generate-openapi.sh first.", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
op_props = load_openapi_operations(spec_path)
|
|
|
|
config_path = root / "hindsight-api-slim" / "hindsight_api" / "config.py"
|
|
bank_config_fields = load_configurable_fields(config_path)
|
|
|
|
clients: list[dict] = []
|
|
|
|
if args.client in ("python", "all"):
|
|
clients.append(
|
|
{
|
|
"client_name": "python",
|
|
"source_path": root / "hindsight-clients" / "python" / "hindsight_client" / "hindsight_client.py",
|
|
"manifest_path": root / "hindsight-clients" / "python" / ".openapi-coverage.toml",
|
|
"find_covered_ops": find_py_covered_ops,
|
|
}
|
|
)
|
|
|
|
if args.client in ("typescript", "all"):
|
|
clients.append(
|
|
{
|
|
"client_name": "typescript",
|
|
"source_path": root / "hindsight-clients" / "typescript" / "src" / "index.ts",
|
|
"manifest_path": root / "hindsight-clients" / "typescript" / ".openapi-coverage.toml",
|
|
"find_covered_ops": find_ts_covered_ops,
|
|
}
|
|
)
|
|
|
|
all_errors: list[str] = []
|
|
|
|
print("Client SDK OpenAPI coverage check")
|
|
print(f" Spec: {spec_path.relative_to(root)}")
|
|
print(f" Bank config: {config_path.relative_to(root)} ({len(bank_config_fields)} fields)")
|
|
print()
|
|
|
|
for client_cfg in clients:
|
|
if not client_cfg["source_path"].exists():
|
|
print(f" [{client_cfg['client_name']}] SKIPPED: source not found at {client_cfg['source_path']}")
|
|
continue
|
|
errs = check_client(op_props=op_props, **client_cfg)
|
|
errs += check_bank_config_fields(
|
|
client_name=client_cfg["client_name"],
|
|
source_path=client_cfg["source_path"],
|
|
manifest_path=client_cfg["manifest_path"],
|
|
fields=bank_config_fields,
|
|
)
|
|
all_errors.extend(f"[{client_cfg['client_name']}] {e}" for e in errs)
|
|
print()
|
|
|
|
if all_errors:
|
|
print(f"FAILED: {len(all_errors)} issue(s):")
|
|
for e in all_errors:
|
|
print(f" {e}")
|
|
print()
|
|
print(
|
|
"Fix by either exposing the field in the wrapper, or adding\n"
|
|
"an entry to the client's .openapi-coverage.toml with a reason."
|
|
)
|
|
sys.exit(1)
|
|
|
|
print("OK: all covered operations have their request params accounted for.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|