Skip to content

Commit adff1ea

Browse files
committed
feat: add openapi generator and python client stub
1 parent 0aa7ca5 commit adff1ea

File tree

6 files changed

+207
-0
lines changed

6 files changed

+207
-0
lines changed

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,11 @@ Example ingestion payload:
156156
- Set `PROVENANCE_DASHBOARD_API` to point at your deployed API when running remotely.
157157
- To enable trend charts, set `PROVENANCE_DASHBOARD_EVENTS` to a path containing the exported JSONL events (defaults to `data/timeseries_events.jsonl`).
158158

159+
## SDK & Schema
160+
161+
- Generate an OpenAPI schema with `make docs` (writes `openapi.json`).
162+
- A lightweight synchronous client lives in `clients/python`; use `ProvenanceClient` for basic ingestion/status/analytics calls.
163+
159164
## Data Persistence Model
160165

161166
- **Analyses** – Stored as JSON blobs keyed by `analysis:{analysis_id}` with a sorted set index for time-window queries.

clients/python/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""Python client stubs for interacting with the Provenance API."""
2+
3+
from .client import ProvenanceClient, AnalysisRequest
4+
5+
__all__ = ["ProvenanceClient", "AnalysisRequest"]

clients/python/client.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import asdict, dataclass, field
4+
from typing import Any, Dict, Iterable
5+
6+
import httpx
7+
8+
9+
@dataclass
10+
class AnalysisRequest:
11+
"""Convenience wrapper for POST /analysis payloads."""
12+
13+
repo: str
14+
pr_number: str
15+
base_sha: str
16+
head_sha: str
17+
branch: str | None = None
18+
provenance_data: Dict[str, Any] = field(default_factory=lambda: {"changed_lines": []})
19+
20+
21+
class ProvenanceClient:
22+
"""Lightweight synchronous client for the Provenance API."""
23+
24+
def __init__(
25+
self,
26+
base_url: str,
27+
*,
28+
timeout: float = 10.0,
29+
headers: Dict[str, str] | None = None,
30+
transport: httpx.BaseTransport | None = None,
31+
) -> None:
32+
normalized_base = base_url.rstrip("/") + "/"
33+
self._client = httpx.Client(
34+
base_url=normalized_base,
35+
timeout=timeout,
36+
headers=headers,
37+
transport=transport,
38+
)
39+
40+
def __enter__(self) -> "ProvenanceClient":
41+
return self
42+
43+
def __exit__(self, exc_type, exc, tb) -> None:
44+
self.close()
45+
46+
def close(self) -> None:
47+
self._client.close()
48+
49+
def submit_analysis(self, request: AnalysisRequest) -> dict:
50+
response = self._client.post("analysis", json=asdict(request))
51+
response.raise_for_status()
52+
return response.json()
53+
54+
def get_analysis_status(self, analysis_id: str) -> dict:
55+
response = self._client.get(f"analysis/{analysis_id}")
56+
response.raise_for_status()
57+
return response.json()
58+
59+
def get_analysis_decision(self, analysis_id: str) -> dict:
60+
response = self._client.get(f"analysis/{analysis_id}/decision")
61+
response.raise_for_status()
62+
return response.json()
63+
64+
def get_analytics_summary(
65+
self,
66+
metric: str,
67+
*,
68+
time_window: str = "7d",
69+
group_by: str = "agent_id",
70+
category: str | None = None,
71+
agent_id: str | None = None,
72+
) -> dict:
73+
params = {
74+
"metric": metric,
75+
"time_window": time_window,
76+
"group_by": group_by,
77+
}
78+
if category:
79+
params["category"] = category
80+
if agent_id:
81+
params["agent_id"] = agent_id
82+
response = self._client.get("analytics/summary", params=params)
83+
response.raise_for_status()
84+
return response.json()
85+
86+
def get_agent_behavior(self, *, time_window: str = "7d", agent_id: str | None = None, top_categories: int = 3) -> dict:
87+
params = {"time_window": time_window, "top_categories": top_categories}
88+
if agent_id:
89+
params["agent_id"] = agent_id
90+
response = self._client.get("analytics/agents/behavior", params=params)
91+
response.raise_for_status()
92+
return response.json()
93+
94+
def healthcheck(self) -> dict:
95+
response = self._client.get("healthz")
96+
response.raise_for_status()
97+
return response.json()

scripts/generate_openapi.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""Generate and persist the OpenAPI schema for the Provenance service."""
2+
3+
from __future__ import annotations
4+
5+
import argparse
6+
import json
7+
from pathlib import Path
8+
9+
from fastapi.openapi.utils import get_openapi
10+
11+
from app.main import create_app
12+
13+
14+
def main() -> None:
15+
parser = argparse.ArgumentParser(description="Generate OpenAPI schema")
16+
parser.add_argument(
17+
"--output",
18+
default="openapi.json",
19+
help="Path to write the OpenAPI schema (default: openapi.json)",
20+
)
21+
args = parser.parse_args()
22+
23+
app = create_app()
24+
schema = get_openapi(
25+
title=app.title,
26+
version=app.version,
27+
routes=app.routes,
28+
description=app.description,
29+
)
30+
output_path = Path(args.output)
31+
output_path.write_text(json.dumps(schema, indent=2), encoding="utf-8")
32+
print(f"OpenAPI schema written to {output_path}")
33+
34+
35+
if __name__ == "__main__":
36+
main()

tests/test_client_stub.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from __future__ import annotations
2+
3+
import json
4+
5+
import httpx
6+
7+
from clients.python import AnalysisRequest, ProvenanceClient
8+
9+
10+
def test_client_submit_analysis_payload():
11+
captured = {}
12+
13+
def handler(request: httpx.Request) -> httpx.Response:
14+
captured["method"] = request.method
15+
captured["url"] = str(request.url)
16+
captured["json"] = json.loads(request.content.decode())
17+
return httpx.Response(202, json={"analysis_id": "an_1", "status": "received"})
18+
19+
transport = httpx.MockTransport(handler)
20+
with ProvenanceClient("http://example.com/v1", transport=transport) as client:
21+
request = AnalysisRequest(
22+
repo="acme/repo",
23+
pr_number="42",
24+
base_sha="base",
25+
head_sha="head",
26+
provenance_data={"changed_lines": []},
27+
)
28+
response = client.submit_analysis(request)
29+
30+
assert response["analysis_id"] == "an_1"
31+
assert captured["method"] == "POST"
32+
assert captured["url"].endswith("/analysis")
33+
assert captured["json"]["repo"] == "acme/repo"
34+
35+
36+
def test_client_summary_queries():
37+
params_captured = {}
38+
39+
def handler(request: httpx.Request) -> httpx.Response:
40+
params_captured.update(request.url.params)
41+
return httpx.Response(200, json={"result": {"data": []}})
42+
43+
transport = httpx.MockTransport(handler)
44+
with ProvenanceClient("http://example.com/v1", transport=transport) as client:
45+
client.get_analytics_summary("risk_rate", time_window="3d", category="sqli", agent_id="claude")
46+
47+
assert params_captured["metric"] == "risk_rate"
48+
assert params_captured["time_window"] == "3d"
49+
assert params_captured["category"] == "sqli"
50+
assert params_captured["agent_id"] == "claude"

tests/test_openapi_generation.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import json
2+
import sys
3+
from pathlib import Path
4+
5+
import scripts.generate_openapi as generator
6+
7+
8+
def test_generate_openapi(tmp_path, monkeypatch):
9+
output = tmp_path / "schema.json"
10+
monkeypatch.setattr(sys, "argv", ["generate_openapi", "--output", str(output)])
11+
generator.main()
12+
assert output.exists()
13+
schema = json.loads(output.read_text(encoding="utf-8"))
14+
assert schema["info"]["title"] == "Provenance & Risk Analytics"

0 commit comments

Comments
 (0)