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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,5 @@
1
+ httpx>=0.27.0
2
+
3
+ [dev]
4
+ pytest>=8.0.0
5
+ respx>=0.21.0
@@ -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