graphe-sdk 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- graphe_sdk-0.1.0/PKG-INFO +28 -0
- graphe_sdk-0.1.0/README.md +17 -0
- graphe_sdk-0.1.0/pyproject.toml +21 -0
- graphe_sdk-0.1.0/setup.cfg +4 -0
- graphe_sdk-0.1.0/src/graphe_sdk/__init__.py +14 -0
- graphe_sdk-0.1.0/src/graphe_sdk/client.py +117 -0
- graphe_sdk-0.1.0/src/graphe_sdk/config.py +68 -0
- graphe_sdk-0.1.0/src/graphe_sdk/instrument.py +180 -0
- graphe_sdk-0.1.0/src/graphe_sdk/output.py +47 -0
- graphe_sdk-0.1.0/src/graphe_sdk.egg-info/PKG-INFO +28 -0
- graphe_sdk-0.1.0/src/graphe_sdk.egg-info/SOURCES.txt +16 -0
- graphe_sdk-0.1.0/src/graphe_sdk.egg-info/dependency_links.txt +1 -0
- graphe_sdk-0.1.0/src/graphe_sdk.egg-info/requires.txt +5 -0
- graphe_sdk-0.1.0/src/graphe_sdk.egg-info/top_level.txt +1 -0
- graphe_sdk-0.1.0/tests/test_client.py +50 -0
- graphe_sdk-0.1.0/tests/test_config.py +29 -0
- graphe_sdk-0.1.0/tests/test_health.py +27 -0
- graphe_sdk-0.1.0/tests/test_instrument.py +29 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: graphe-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for the Graphe Layer 1 world-state API
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: httpx>=0.27.0
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
10
|
+
Requires-Dist: respx>=0.21.0; extra == "dev"
|
|
11
|
+
|
|
12
|
+
# graphe-sdk
|
|
13
|
+
|
|
14
|
+
Python client for the [Graphe](https://github.com/) Layer 1 API.
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install graphe-sdk
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from graphe_sdk import GrapheClient, GrapheConfig
|
|
22
|
+
|
|
23
|
+
config = GrapheConfig.from_env()
|
|
24
|
+
client = GrapheClient(config.base_url, config.api_key)
|
|
25
|
+
print(client.get_health())
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
See the monorepo `API.md` for endpoint contracts.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# graphe-sdk
|
|
2
|
+
|
|
3
|
+
Python client for the [Graphe](https://github.com/) Layer 1 API.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install graphe-sdk
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from graphe_sdk import GrapheClient, GrapheConfig
|
|
11
|
+
|
|
12
|
+
config = GrapheConfig.from_env()
|
|
13
|
+
client = GrapheClient(config.base_url, config.api_key)
|
|
14
|
+
print(client.get_health())
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
See the monorepo `API.md` for endpoint contracts.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "graphe-sdk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python SDK for the Graphe Layer 1 world-state API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
dependencies = ["httpx>=0.27.0"]
|
|
12
|
+
|
|
13
|
+
[project.optional-dependencies]
|
|
14
|
+
dev = ["pytest>=8.0.0", "respx>=0.21.0"]
|
|
15
|
+
|
|
16
|
+
[tool.setuptools.packages.find]
|
|
17
|
+
where = ["src"]
|
|
18
|
+
|
|
19
|
+
[tool.pytest.ini_options]
|
|
20
|
+
pythonpath = ["src"]
|
|
21
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from graphe_sdk.client import AuthError, ConflictError, GrapheClient, GrapheError, NotFoundError
|
|
2
|
+
from graphe_sdk.config import GrapheConfig
|
|
3
|
+
from graphe_sdk.instrument import GrapheRun, start_run
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"AuthError",
|
|
7
|
+
"ConflictError",
|
|
8
|
+
"GrapheClient",
|
|
9
|
+
"GrapheConfig",
|
|
10
|
+
"GrapheError",
|
|
11
|
+
"GrapheRun",
|
|
12
|
+
"NotFoundError",
|
|
13
|
+
"start_run",
|
|
14
|
+
]
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class GrapheError(Exception):
|
|
8
|
+
def __init__(self, status_code: int, detail: Any):
|
|
9
|
+
super().__init__(f"Graphe API error {status_code}: {detail}")
|
|
10
|
+
self.status_code = status_code
|
|
11
|
+
self.detail = detail
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ConflictError(GrapheError):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AuthError(GrapheError):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class NotFoundError(GrapheError):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class GrapheClient:
|
|
28
|
+
base_url: str
|
|
29
|
+
api_key: str
|
|
30
|
+
timeout: float = 10
|
|
31
|
+
|
|
32
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
|
|
33
|
+
headers = kwargs.pop("headers", {})
|
|
34
|
+
headers["x-api-key"] = self.api_key
|
|
35
|
+
response = httpx.request(method, f"{self.base_url.rstrip('/')}{path}", headers=headers, timeout=self.timeout, **kwargs)
|
|
36
|
+
if response.status_code >= 400:
|
|
37
|
+
detail = response.json().get("detail") if response.headers.get("content-type", "").startswith("application/json") else response.text
|
|
38
|
+
if response.status_code in {401, 403}:
|
|
39
|
+
raise AuthError(response.status_code, detail)
|
|
40
|
+
if response.status_code == 404:
|
|
41
|
+
raise NotFoundError(response.status_code, detail)
|
|
42
|
+
if response.status_code == 409:
|
|
43
|
+
raise ConflictError(response.status_code, detail)
|
|
44
|
+
raise GrapheError(response.status_code, detail)
|
|
45
|
+
return response.json()
|
|
46
|
+
|
|
47
|
+
def log_event(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
48
|
+
return self._request("POST", "/api/v1/events", json=payload)
|
|
49
|
+
|
|
50
|
+
def create_run(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
51
|
+
return self._request("POST", "/api/v1/runs", json=payload)
|
|
52
|
+
|
|
53
|
+
def list_runs(self, **params: Any) -> dict[str, Any]:
|
|
54
|
+
return self._request("GET", "/api/v1/runs", params=params)
|
|
55
|
+
|
|
56
|
+
def get_run(self, run_id: str) -> dict[str, Any]:
|
|
57
|
+
return self._request("GET", f"/api/v1/runs/{run_id}")
|
|
58
|
+
|
|
59
|
+
def get_trace(self, run_id: str) -> dict[str, Any]:
|
|
60
|
+
return self._request("GET", f"/api/v1/runs/{run_id}/trace")
|
|
61
|
+
|
|
62
|
+
def get_run_issues(self, run_id: str) -> dict[str, Any]:
|
|
63
|
+
return self._request("GET", f"/api/v1/runs/{run_id}/issues")
|
|
64
|
+
|
|
65
|
+
def explain_run(self, run_id: str) -> dict[str, Any]:
|
|
66
|
+
return self._request("GET", f"/api/v1/runs/{run_id}/explain")
|
|
67
|
+
|
|
68
|
+
def suggest_next(self, run_id: str) -> dict[str, Any]:
|
|
69
|
+
return self._request("GET", f"/api/v1/runs/{run_id}/suggest")
|
|
70
|
+
|
|
71
|
+
def get_current_state(self, **params: Any) -> dict[str, Any]:
|
|
72
|
+
return self._request("GET", "/api/v1/state/current", params=params)
|
|
73
|
+
|
|
74
|
+
def get_history(self, **params: Any) -> dict[str, Any]:
|
|
75
|
+
return self._request("GET", "/api/v1/state/history", params=params)
|
|
76
|
+
|
|
77
|
+
def get_health(self) -> dict[str, Any]:
|
|
78
|
+
return self._request("GET", "/api/v1/health")
|
|
79
|
+
|
|
80
|
+
def hybrid_search(self, payload: dict[str, Any], *, mode: str | None = None) -> dict[str, Any]:
|
|
81
|
+
path = "/api/v1/search/hybrid"
|
|
82
|
+
if mode:
|
|
83
|
+
path = f"{path}?mode={mode}"
|
|
84
|
+
return self._request("POST", path, json=payload)
|
|
85
|
+
|
|
86
|
+
def propose_claim(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
87
|
+
return self._request("POST", "/api/v1/claims/propose", json=payload)
|
|
88
|
+
|
|
89
|
+
def confirm_claim(self, claim_id: str) -> dict[str, Any]:
|
|
90
|
+
return self._request("POST", f"/api/v1/claims/{claim_id}/confirm")
|
|
91
|
+
|
|
92
|
+
def invalidate_claim(self, claim_id: str) -> dict[str, Any]:
|
|
93
|
+
return self._request("POST", f"/api/v1/claims/{claim_id}/invalidate")
|
|
94
|
+
|
|
95
|
+
def supersede_claim(self, claim_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
96
|
+
return self._request("POST", f"/api/v1/claims/{claim_id}/supersede", json=payload)
|
|
97
|
+
|
|
98
|
+
def list_claims(self, **params: Any) -> dict[str, Any]:
|
|
99
|
+
return self._request("GET", "/api/v1/claims", params=params)
|
|
100
|
+
|
|
101
|
+
def get_claim(self, claim_id: str) -> dict[str, Any]:
|
|
102
|
+
return self._request("GET", f"/api/v1/claims/{claim_id}")
|
|
103
|
+
|
|
104
|
+
def create_annotation(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
105
|
+
return self._request("POST", "/api/v1/annotations", json=payload)
|
|
106
|
+
|
|
107
|
+
def get_observation(self, observation_id: str) -> dict[str, Any]:
|
|
108
|
+
return self._request("GET", f"/api/v1/observations/{observation_id}")
|
|
109
|
+
|
|
110
|
+
def list_entities(self, **params: Any) -> dict[str, Any]:
|
|
111
|
+
return self._request("GET", "/api/v1/entities", params=params)
|
|
112
|
+
|
|
113
|
+
def get_entity(self, entity_type: str, entity_id: str) -> dict[str, Any]:
|
|
114
|
+
return self._request("GET", f"/api/v1/entities/{entity_type}/{entity_id}")
|
|
115
|
+
|
|
116
|
+
def get_entity_subgraph(self, entity_type: str, entity_id: str) -> dict[str, Any]:
|
|
117
|
+
return self._request("GET", f"/api/v1/entities/{entity_type}/{entity_id}/subgraph")
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
import tomllib
|
|
10
|
+
except ImportError: # pragma: no cover
|
|
11
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class GrapheConfig:
|
|
16
|
+
base_url: str
|
|
17
|
+
api_key: str
|
|
18
|
+
workspace_id: str | None = None
|
|
19
|
+
project_id: str | None = None
|
|
20
|
+
actor_id: str | None = None
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def from_env(cls, *, config_path: str | Path | None = None) -> GrapheConfig:
|
|
24
|
+
file_values = _load_config_file(config_path) if config_path else _load_default_config_file()
|
|
25
|
+
return cls(
|
|
26
|
+
base_url=_env_or_file("GRAPHE_API_BASE", file_values, "base_url", "http://localhost:8000"),
|
|
27
|
+
api_key=_env_or_file("GRAPHE_API_KEY", file_values, "api_key", ""),
|
|
28
|
+
workspace_id=_env_or_file("GRAPHE_WORKSPACE_ID", file_values, "workspace_id"),
|
|
29
|
+
project_id=_env_or_file("GRAPHE_PROJECT_ID", file_values, "project_id"),
|
|
30
|
+
actor_id=_env_or_file("GRAPHE_ACTOR_ID", file_values, "actor_id"),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
def require_scope(self) -> tuple[str, str]:
|
|
34
|
+
if not self.workspace_id or not self.project_id:
|
|
35
|
+
raise ValueError(
|
|
36
|
+
"workspace_id and project_id are required. Set GRAPHE_WORKSPACE_ID and "
|
|
37
|
+
"GRAPHE_PROJECT_ID or pass --workspace / --project."
|
|
38
|
+
)
|
|
39
|
+
return self.workspace_id, self.project_id
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _load_default_config_file() -> dict[str, Any]:
|
|
43
|
+
for path in (Path.home() / ".config" / "graphe" / "config.toml", Path.cwd() / "graphe.toml"):
|
|
44
|
+
if path.is_file():
|
|
45
|
+
return _load_config_file(path)
|
|
46
|
+
return {}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _load_config_file(path: str | Path) -> dict[str, Any]:
|
|
50
|
+
data = tomllib.loads(Path(path).read_text(encoding="utf-8"))
|
|
51
|
+
if not isinstance(data, dict):
|
|
52
|
+
return {}
|
|
53
|
+
return data
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _env_or_file(
|
|
57
|
+
env_name: str,
|
|
58
|
+
file_values: dict[str, Any],
|
|
59
|
+
key: str,
|
|
60
|
+
default: str | None = None,
|
|
61
|
+
) -> str | None:
|
|
62
|
+
env_value = os.environ.get(env_name)
|
|
63
|
+
if env_value:
|
|
64
|
+
return env_value
|
|
65
|
+
file_value = file_values.get(key)
|
|
66
|
+
if file_value is not None:
|
|
67
|
+
return str(file_value)
|
|
68
|
+
return default
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from contextlib import AbstractContextManager
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Any, Callable, TypeVar
|
|
8
|
+
from uuid import UUID
|
|
9
|
+
|
|
10
|
+
from graphe_sdk.client import GrapheClient
|
|
11
|
+
|
|
12
|
+
T = TypeVar("T")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _utc_now() -> str:
|
|
16
|
+
return datetime.now(timezone.utc).isoformat()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _args_hash(args: tuple[Any, ...], kwargs: dict[str, Any]) -> str:
|
|
20
|
+
blob = json.dumps({"args": args, "kwargs": kwargs}, default=str, sort_keys=True)
|
|
21
|
+
return hashlib.sha256(blob.encode()).hexdigest()[:16]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class GrapheRun(AbstractContextManager["GrapheRun"]):
|
|
25
|
+
"""Context manager that creates a run and logs lifecycle events."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
client: GrapheClient,
|
|
30
|
+
*,
|
|
31
|
+
workspace_id: str,
|
|
32
|
+
project_id: str,
|
|
33
|
+
actor_id: str,
|
|
34
|
+
goal: str,
|
|
35
|
+
metadata: dict[str, Any] | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
self.client = client
|
|
38
|
+
self.workspace_id = workspace_id
|
|
39
|
+
self.project_id = project_id
|
|
40
|
+
self.actor_id = actor_id
|
|
41
|
+
self.goal = goal
|
|
42
|
+
self.metadata = metadata or {}
|
|
43
|
+
self.run_id: str | None = None
|
|
44
|
+
self._terminal_logged = False
|
|
45
|
+
|
|
46
|
+
def __enter__(self) -> GrapheRun:
|
|
47
|
+
response = self.client.create_run(
|
|
48
|
+
{
|
|
49
|
+
"workspace_id": self.workspace_id,
|
|
50
|
+
"project_id": self.project_id,
|
|
51
|
+
"actor_id": self.actor_id,
|
|
52
|
+
"goal": self.goal,
|
|
53
|
+
"started_at": _utc_now(),
|
|
54
|
+
"status": "started",
|
|
55
|
+
"metadata": self.metadata,
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
self.run_id = str(response["run_id"])
|
|
59
|
+
return self
|
|
60
|
+
|
|
61
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
62
|
+
if self._terminal_logged or not self.run_id:
|
|
63
|
+
return
|
|
64
|
+
if exc_type is not None:
|
|
65
|
+
self.end_failed(summary=str(exc), error_type=exc_type.__name__ if exc_type else "Error")
|
|
66
|
+
else:
|
|
67
|
+
self.end_completed(summary=f"Run completed: {self.goal}")
|
|
68
|
+
|
|
69
|
+
def _base_event(self, event_type: str, payload: dict[str, Any], *, idempotency_key: str | None = None) -> dict[str, Any]:
|
|
70
|
+
if not self.run_id:
|
|
71
|
+
raise RuntimeError("GrapheRun is not active; use as context manager.")
|
|
72
|
+
body: dict[str, Any] = {
|
|
73
|
+
"workspace_id": self.workspace_id,
|
|
74
|
+
"project_id": self.project_id,
|
|
75
|
+
"actor_id": self.actor_id,
|
|
76
|
+
"run_id": self.run_id,
|
|
77
|
+
"event_type": event_type,
|
|
78
|
+
"occurred_at": _utc_now(),
|
|
79
|
+
"payload": payload,
|
|
80
|
+
}
|
|
81
|
+
if idempotency_key:
|
|
82
|
+
body["idempotency_key"] = idempotency_key
|
|
83
|
+
return body
|
|
84
|
+
|
|
85
|
+
def log_event(self, event_type: str, payload: dict[str, Any], *, idempotency_key: str | None = None) -> dict[str, Any]:
|
|
86
|
+
return self.client.log_event(self._base_event(event_type, payload, idempotency_key=idempotency_key))
|
|
87
|
+
|
|
88
|
+
def log_tool(
|
|
89
|
+
self,
|
|
90
|
+
tool_name: str,
|
|
91
|
+
fn: Callable[[], T],
|
|
92
|
+
*,
|
|
93
|
+
summary: str | None = None,
|
|
94
|
+
) -> T:
|
|
95
|
+
key = f"{self.run_id}:tool:{tool_name}"
|
|
96
|
+
try:
|
|
97
|
+
result = fn()
|
|
98
|
+
self.log_event(
|
|
99
|
+
"tool.invoked",
|
|
100
|
+
{
|
|
101
|
+
"summary": summary or f"{tool_name} succeeded",
|
|
102
|
+
"tool_name": tool_name,
|
|
103
|
+
"args_hash": key,
|
|
104
|
+
},
|
|
105
|
+
idempotency_key=f"{key}:ok",
|
|
106
|
+
)
|
|
107
|
+
return result
|
|
108
|
+
except Exception as exc:
|
|
109
|
+
self.log_event(
|
|
110
|
+
"tool.failed",
|
|
111
|
+
{
|
|
112
|
+
"summary": summary or f"{tool_name} failed",
|
|
113
|
+
"tool_name": tool_name,
|
|
114
|
+
"error_type": type(exc).__name__,
|
|
115
|
+
"args_hash": key,
|
|
116
|
+
},
|
|
117
|
+
idempotency_key=f"{key}:fail",
|
|
118
|
+
)
|
|
119
|
+
raise
|
|
120
|
+
|
|
121
|
+
def log_api(
|
|
122
|
+
self,
|
|
123
|
+
service: str,
|
|
124
|
+
method: str,
|
|
125
|
+
endpoint: str,
|
|
126
|
+
*,
|
|
127
|
+
status_code: int,
|
|
128
|
+
latency_ms: float | None = None,
|
|
129
|
+
summary: str | None = None,
|
|
130
|
+
error_code: str | None = None,
|
|
131
|
+
) -> dict[str, Any]:
|
|
132
|
+
failed = status_code >= 400
|
|
133
|
+
event_type = "api.request.failed" if failed else "api.request.completed"
|
|
134
|
+
payload = {
|
|
135
|
+
"summary": summary or f"{method} {endpoint} -> {status_code}",
|
|
136
|
+
"service": service,
|
|
137
|
+
"method": method,
|
|
138
|
+
"endpoint": endpoint,
|
|
139
|
+
"status_code": status_code,
|
|
140
|
+
}
|
|
141
|
+
if latency_ms is not None:
|
|
142
|
+
payload["latency_ms"] = latency_ms
|
|
143
|
+
if error_code:
|
|
144
|
+
payload["error_code"] = error_code
|
|
145
|
+
key = f"{self.run_id}:api:{service}:{method}:{endpoint}:{status_code}"
|
|
146
|
+
return self.log_event(event_type, payload, idempotency_key=key)
|
|
147
|
+
|
|
148
|
+
def with_context_used(self, query: str, items: list[dict[str, Any]]) -> dict[str, Any]:
|
|
149
|
+
return {"query": query, "items": items}
|
|
150
|
+
|
|
151
|
+
def end_completed(self, summary: str) -> dict[str, Any]:
|
|
152
|
+
self._terminal_logged = True
|
|
153
|
+
return self.log_event("run.completed", {"summary": summary, "status": "completed"}, idempotency_key=f"{self.run_id}:completed")
|
|
154
|
+
|
|
155
|
+
def end_failed(self, summary: str, *, error_type: str = "RunFailed") -> dict[str, Any]:
|
|
156
|
+
self._terminal_logged = True
|
|
157
|
+
return self.log_event(
|
|
158
|
+
"run.failed",
|
|
159
|
+
{"summary": summary, "status": "failed", "error_type": error_type},
|
|
160
|
+
idempotency_key=f"{self.run_id}:failed",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def start_run(
|
|
165
|
+
client: GrapheClient,
|
|
166
|
+
*,
|
|
167
|
+
workspace_id: str,
|
|
168
|
+
project_id: str,
|
|
169
|
+
actor_id: str,
|
|
170
|
+
goal: str,
|
|
171
|
+
metadata: dict[str, Any] | None = None,
|
|
172
|
+
) -> GrapheRun:
|
|
173
|
+
return GrapheRun(
|
|
174
|
+
client,
|
|
175
|
+
workspace_id=workspace_id,
|
|
176
|
+
project_id=project_id,
|
|
177
|
+
actor_id=actor_id,
|
|
178
|
+
goal=goal,
|
|
179
|
+
metadata=metadata,
|
|
180
|
+
)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Any, Iterable, Mapping, Sequence
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def emit(data: Any, *, output: str = "json", stream: Any = None) -> None:
|
|
9
|
+
target = stream or sys.stdout
|
|
10
|
+
if output == "json":
|
|
11
|
+
json.dump(data, target, indent=2, default=str)
|
|
12
|
+
target.write("\n")
|
|
13
|
+
return
|
|
14
|
+
if isinstance(data, Mapping):
|
|
15
|
+
for key, value in data.items():
|
|
16
|
+
target.write(f"{key}: {value}\n")
|
|
17
|
+
return
|
|
18
|
+
if isinstance(data, Sequence) and not isinstance(data, (str, bytes)):
|
|
19
|
+
for row in data:
|
|
20
|
+
if isinstance(row, Mapping):
|
|
21
|
+
target.write(" ".join(f"{k}={v}" for k, v in row.items()) + "\n")
|
|
22
|
+
else:
|
|
23
|
+
target.write(f"{row}\n")
|
|
24
|
+
return
|
|
25
|
+
target.write(f"{data}\n")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def print_table(
|
|
29
|
+
rows: Iterable[Mapping[str, Any]],
|
|
30
|
+
columns: Sequence[str],
|
|
31
|
+
*,
|
|
32
|
+
stream: Any = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
target = stream or sys.stdout
|
|
35
|
+
materialized = list(rows)
|
|
36
|
+
if not materialized:
|
|
37
|
+
target.write("(no rows)\n")
|
|
38
|
+
return
|
|
39
|
+
widths = {column: len(column) for column in columns}
|
|
40
|
+
for row in materialized:
|
|
41
|
+
for column in columns:
|
|
42
|
+
widths[column] = max(widths[column], len(str(row.get(column, ""))))
|
|
43
|
+
header = " ".join(column.ljust(widths[column]) for column in columns)
|
|
44
|
+
target.write(header + "\n")
|
|
45
|
+
target.write(" ".join("-" * widths[column] for column in columns) + "\n")
|
|
46
|
+
for row in materialized:
|
|
47
|
+
target.write(" ".join(str(row.get(column, "")).ljust(widths[column]) for column in columns) + "\n")
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: graphe-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for the Graphe Layer 1 world-state API
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: httpx>=0.27.0
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
10
|
+
Requires-Dist: respx>=0.21.0; extra == "dev"
|
|
11
|
+
|
|
12
|
+
# graphe-sdk
|
|
13
|
+
|
|
14
|
+
Python client for the [Graphe](https://github.com/) Layer 1 API.
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install graphe-sdk
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from graphe_sdk import GrapheClient, GrapheConfig
|
|
22
|
+
|
|
23
|
+
config = GrapheConfig.from_env()
|
|
24
|
+
client = GrapheClient(config.base_url, config.api_key)
|
|
25
|
+
print(client.get_health())
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
See the monorepo `API.md` for endpoint contracts.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/graphe_sdk/__init__.py
|
|
4
|
+
src/graphe_sdk/client.py
|
|
5
|
+
src/graphe_sdk/config.py
|
|
6
|
+
src/graphe_sdk/instrument.py
|
|
7
|
+
src/graphe_sdk/output.py
|
|
8
|
+
src/graphe_sdk.egg-info/PKG-INFO
|
|
9
|
+
src/graphe_sdk.egg-info/SOURCES.txt
|
|
10
|
+
src/graphe_sdk.egg-info/dependency_links.txt
|
|
11
|
+
src/graphe_sdk.egg-info/requires.txt
|
|
12
|
+
src/graphe_sdk.egg-info/top_level.txt
|
|
13
|
+
tests/test_client.py
|
|
14
|
+
tests/test_config.py
|
|
15
|
+
tests/test_health.py
|
|
16
|
+
tests/test_instrument.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
graphe_sdk
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
|
|
3
|
+
from graphe_sdk import AuthError, ConflictError, GrapheClient, NotFoundError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class MockTransport:
|
|
7
|
+
def __init__(self, status_code=200, payload=None):
|
|
8
|
+
self.status_code = status_code
|
|
9
|
+
self.payload = payload or {"ok": True}
|
|
10
|
+
|
|
11
|
+
def __call__(self, request: httpx.Request) -> httpx.Response:
|
|
12
|
+
assert request.headers["x-api-key"] == "key"
|
|
13
|
+
return httpx.Response(self.status_code, json=self.payload, request=request)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_log_event_sends_api_key(monkeypatch) -> None:
|
|
17
|
+
transport = httpx.MockTransport(MockTransport(payload={"event_id": "1", "status": "accepted"}))
|
|
18
|
+
monkeypatch.setattr(httpx, "request", httpx.Client(transport=transport).request)
|
|
19
|
+
client = GrapheClient("http://test", "key")
|
|
20
|
+
assert client.log_event({"payload": {}})["status"] == "accepted"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_list_runs_builds_query(monkeypatch) -> None:
|
|
24
|
+
observed: dict[str, object] = {}
|
|
25
|
+
|
|
26
|
+
def capture_request(method, url, **kwargs):
|
|
27
|
+
observed["method"] = method
|
|
28
|
+
observed["url"] = url
|
|
29
|
+
observed["params"] = kwargs.get("params")
|
|
30
|
+
return httpx.Response(200, json={"runs": []}, request=httpx.Request(method, url))
|
|
31
|
+
|
|
32
|
+
monkeypatch.setattr(httpx, "request", capture_request)
|
|
33
|
+
client = GrapheClient("http://test", "key")
|
|
34
|
+
client.list_runs(workspace_id="w", project_id="p", limit=5)
|
|
35
|
+
assert observed["method"] == "GET"
|
|
36
|
+
assert observed["url"] == "http://test/api/v1/runs"
|
|
37
|
+
assert observed["params"] == {"workspace_id": "w", "project_id": "p", "limit": 5}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_typed_errors(monkeypatch) -> None:
|
|
41
|
+
for status, error_type in [(401, AuthError), (404, NotFoundError), (409, ConflictError)]:
|
|
42
|
+
transport = httpx.MockTransport(MockTransport(status_code=status, payload={"detail": "bad"}))
|
|
43
|
+
monkeypatch.setattr(httpx, "request", httpx.Client(transport=transport).request)
|
|
44
|
+
client = GrapheClient("http://test", "key")
|
|
45
|
+
try:
|
|
46
|
+
client.get_run("missing")
|
|
47
|
+
except error_type:
|
|
48
|
+
pass
|
|
49
|
+
else:
|
|
50
|
+
raise AssertionError(f"expected {error_type.__name__}")
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from graphe_sdk.config import GrapheConfig
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_from_env_reads_graphe_variables(monkeypatch) -> None:
|
|
7
|
+
monkeypatch.setenv("GRAPHE_API_BASE", "http://api.test")
|
|
8
|
+
monkeypatch.setenv("GRAPHE_API_KEY", "secret")
|
|
9
|
+
monkeypatch.setenv("GRAPHE_WORKSPACE_ID", "ws-1")
|
|
10
|
+
monkeypatch.setenv("GRAPHE_PROJECT_ID", "proj-1")
|
|
11
|
+
monkeypatch.setenv("GRAPHE_ACTOR_ID", "actor-1")
|
|
12
|
+
config = GrapheConfig.from_env()
|
|
13
|
+
assert config.base_url == "http://api.test"
|
|
14
|
+
assert config.api_key == "secret"
|
|
15
|
+
assert config.workspace_id == "ws-1"
|
|
16
|
+
assert config.project_id == "proj-1"
|
|
17
|
+
assert config.actor_id == "actor-1"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_require_scope_raises_when_missing(monkeypatch) -> None:
|
|
21
|
+
monkeypatch.delenv("GRAPHE_WORKSPACE_ID", raising=False)
|
|
22
|
+
monkeypatch.delenv("GRAPHE_PROJECT_ID", raising=False)
|
|
23
|
+
config = GrapheConfig.from_env()
|
|
24
|
+
try:
|
|
25
|
+
config.require_scope()
|
|
26
|
+
except ValueError as exc:
|
|
27
|
+
assert "workspace_id" in str(exc)
|
|
28
|
+
else:
|
|
29
|
+
raise AssertionError("expected ValueError")
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
|
|
3
|
+
from graphe_sdk import GrapheClient
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_get_health(monkeypatch) -> None:
|
|
7
|
+
def capture_request(method, url, **kwargs):
|
|
8
|
+
assert method == "GET"
|
|
9
|
+
assert url == "http://test/api/v1/health"
|
|
10
|
+
return httpx.Response(200, json={"status": "ok"}, request=httpx.Request(method, url))
|
|
11
|
+
|
|
12
|
+
monkeypatch.setattr(httpx, "request", capture_request)
|
|
13
|
+
client = GrapheClient("http://test", "key")
|
|
14
|
+
assert client.get_health()["status"] == "ok"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_hybrid_search_mode_query(monkeypatch) -> None:
|
|
18
|
+
observed: dict[str, object] = {}
|
|
19
|
+
|
|
20
|
+
def capture_request(method, url, **kwargs):
|
|
21
|
+
observed["url"] = url
|
|
22
|
+
return httpx.Response(200, json={"results": []}, request=httpx.Request(method, url))
|
|
23
|
+
|
|
24
|
+
monkeypatch.setattr(httpx, "request", capture_request)
|
|
25
|
+
client = GrapheClient("http://test", "key")
|
|
26
|
+
client.hybrid_search({"query": "auth"}, mode="graph_first")
|
|
27
|
+
assert observed["url"] == "http://test/api/v1/search/hybrid?mode=graph_first"
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
|
|
3
|
+
from graphe_sdk import GrapheClient
|
|
4
|
+
from graphe_sdk.instrument import GrapheRun
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Recorder:
|
|
8
|
+
def __init__(self):
|
|
9
|
+
self.calls: list[tuple[str, dict]] = []
|
|
10
|
+
|
|
11
|
+
def create_run(self, payload):
|
|
12
|
+
self.calls.append(("create_run", payload))
|
|
13
|
+
return {"run_id": "run-1", "status": "started"}
|
|
14
|
+
|
|
15
|
+
def log_event(self, payload):
|
|
16
|
+
self.calls.append(("log_event", payload))
|
|
17
|
+
return {"event_id": f"e-{len(self.calls)}", "status": "accepted"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_graphe_run_logs_tool_and_terminal() -> None:
|
|
21
|
+
recorder = Recorder()
|
|
22
|
+
client = GrapheClient("http://test", "key")
|
|
23
|
+
client.create_run = recorder.create_run # type: ignore[method-assign]
|
|
24
|
+
client.log_event = recorder.log_event # type: ignore[method-assign]
|
|
25
|
+
with GrapheRun(client, workspace_id="w", project_id="p", actor_id="a", goal="demo") as run:
|
|
26
|
+
run.log_tool("fetch", lambda: "ok", summary="fetched")
|
|
27
|
+
event_types = [call[1]["event_type"] for call in recorder.calls if call[0] == "log_event"]
|
|
28
|
+
assert "tool.invoked" in event_types
|
|
29
|
+
assert "run.completed" in event_types
|