originchain 0.3.0__py3-none-any.whl

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,78 @@
1
+ """OriginChain Python client.
2
+
3
+ Two clients ship in this package:
4
+
5
+ - :class:`OriginChain` — synchronous, suitable for scripts and Jupyter.
6
+ - :class:`AsyncOriginChain` — asyncio, suitable for ASGI / async data
7
+ pipelines.
8
+
9
+ Both wrap the same ``/v1`` HTTP surface that the engine exposes; they
10
+ encode bearer auth, idempotency keys, and retries so callers don't have
11
+ to repeat themselves.
12
+
13
+ Quick start::
14
+
15
+ from originchain import OriginChain
16
+
17
+ db = OriginChain.from_env() # OC_BASE_URL + OC_BEARER
18
+ db.schemas.register(open("orders.toml").read())
19
+ db.rows.put("trading.orders", {"order_id": "o1", "symbol": "AAPL", "qty": 100})
20
+ rows = db.ask("orders for AAPL above 50 shares last week")
21
+
22
+ The four substrate-extension surfaces (SQL, vector, full-text, graph)
23
+ are also typed first-class. See :class:`OriginChain.sql`,
24
+ ``vector_topk`` / ``vector_put``, ``fts_search``, and the ``graph``
25
+ namespace.
26
+ """
27
+
28
+ from .client import OriginChain
29
+ from .async_client import AsyncOriginChain
30
+ from .errors import (
31
+ OCAuthError,
32
+ OCError,
33
+ OCNotFoundError,
34
+ OCPaymentRequiredError,
35
+ OCRateLimitedError,
36
+ OCReplicationDegraded,
37
+ OCServerError,
38
+ OCValidationError,
39
+ )
40
+ from .models import (
41
+ DijkstraResult,
42
+ FtsHit,
43
+ GraphBfsHit,
44
+ GraphPath,
45
+ Neighbor,
46
+ SqlDelete,
47
+ SqlInsert,
48
+ SqlResponse,
49
+ SqlSelect,
50
+ VectorHit,
51
+ )
52
+
53
+ __all__ = [
54
+ "OriginChain",
55
+ "AsyncOriginChain",
56
+ # Errors
57
+ "OCError",
58
+ "OCAuthError",
59
+ "OCNotFoundError",
60
+ "OCPaymentRequiredError",
61
+ "OCRateLimitedError",
62
+ "OCServerError",
63
+ "OCValidationError",
64
+ "OCReplicationDegraded",
65
+ # Models
66
+ "SqlSelect",
67
+ "SqlInsert",
68
+ "SqlDelete",
69
+ "SqlResponse",
70
+ "VectorHit",
71
+ "FtsHit",
72
+ "Neighbor",
73
+ "GraphBfsHit",
74
+ "GraphPath",
75
+ "DijkstraResult",
76
+ ]
77
+
78
+ __version__ = "0.3.0"
@@ -0,0 +1,471 @@
1
+ """Async variant of :class:`OriginChain`. Mirrors the sync surface.
2
+
3
+ Both clients are kept side-by-side rather than sharing a common base
4
+ because httpx's sync/async clients have subtly different timeout +
5
+ context-manager semantics. Duplication is the lesser evil; pyright
6
+ catches drift.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import json as _json
13
+ import os
14
+ import warnings
15
+ from typing import Any, List, Literal, Mapping, Optional
16
+
17
+ import httpx
18
+
19
+ from .client import (
20
+ DEFAULT_MAX_RETRIES,
21
+ DEFAULT_TIMEOUT_S,
22
+ RETRYABLE_STATUSES,
23
+ _HTTP2_AVAILABLE,
24
+ _MUTATING_METHODS,
25
+ _new_idempotency_key,
26
+ )
27
+ from .errors import (
28
+ OCAuthError,
29
+ OCError,
30
+ OCNotFoundError,
31
+ OCPaymentRequiredError,
32
+ OCRateLimitedError,
33
+ OCReplicationDegraded,
34
+ OCServerError,
35
+ OCValidationError,
36
+ )
37
+ from .models import (
38
+ DijkstraResult,
39
+ FtsHit,
40
+ GraphBfsHit,
41
+ GraphPath,
42
+ Neighbor,
43
+ SqlResponse,
44
+ SqlSelect,
45
+ VectorHit,
46
+ _decode_sql_response,
47
+ )
48
+
49
+
50
+ class _AsyncSchemas:
51
+ def __init__(self, parent: "AsyncOriginChain") -> None:
52
+ self._p = parent
53
+
54
+ async def list(self) -> list[str]:
55
+ r = await self._p._request("GET", f"/v1/tenants/{self._p.tenant}/schemas")
56
+ return r.json()
57
+
58
+ async def get(self, schema: str) -> str:
59
+ r = await self._p._request("GET", f"/v1/tenants/{self._p.tenant}/schemas/{schema}")
60
+ return r.text
61
+
62
+ async def register(self, toml_source: str) -> dict[str, Any]:
63
+ r = await self._p._request(
64
+ "POST",
65
+ f"/v1/tenants/{self._p.tenant}/schemas",
66
+ content=toml_source.encode("utf-8"),
67
+ headers={"Content-Type": "text/plain"},
68
+ )
69
+ return r.json()
70
+
71
+
72
+ class _AsyncRows:
73
+ def __init__(self, parent: "AsyncOriginChain") -> None:
74
+ self._p = parent
75
+
76
+ async def get(self, schema: str, pk: str) -> dict[str, Any]:
77
+ r = await self._p._request("GET", f"/v1/tenants/{self._p.tenant}/rows/{schema}/{pk}")
78
+ return r.json()
79
+
80
+ async def put(
81
+ self,
82
+ schema: str,
83
+ row: Mapping[str, Any],
84
+ *,
85
+ expect_insert: bool = False,
86
+ idempotency_key: Optional[str] = None,
87
+ ) -> dict[str, Any]:
88
+ params = {"expect": "insert"} if expect_insert else None
89
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else {}
90
+ r = await self._p._request(
91
+ "POST",
92
+ f"/v1/tenants/{self._p.tenant}/rows/{schema}",
93
+ json=row,
94
+ params=params,
95
+ headers=headers,
96
+ )
97
+ return r.json()
98
+
99
+
100
+ class _AsyncGraph:
101
+ """Async ``db.graph.*`` namespace. Mirrors the sync ``_Graph``."""
102
+
103
+ def __init__(self, parent: "AsyncOriginChain") -> None:
104
+ self._p = parent
105
+
106
+ async def neighbors(self, schema: str, *, rel: str, pk: str) -> List[Neighbor]:
107
+ params = {"rel": rel, "pk": pk}
108
+ r = await self._p._request(
109
+ "GET",
110
+ f"/v1/tenants/{self._p.tenant}/graph/{schema}/neighbors",
111
+ params=params,
112
+ )
113
+ return [Neighbor(pk=str(p), depth=1) for p in r.json()]
114
+
115
+ async def reverse_neighbors(
116
+ self, schema: str, *, rel: str, pk: str
117
+ ) -> List[Neighbor]:
118
+ params = {"rel": rel, "pk": pk}
119
+ r = await self._p._request(
120
+ "GET",
121
+ f"/v1/tenants/{self._p.tenant}/graph/{schema}/reverse",
122
+ params=params,
123
+ )
124
+ return [Neighbor(pk=str(p), depth=1) for p in r.json()]
125
+
126
+ async def bfs(
127
+ self,
128
+ schema: str,
129
+ *,
130
+ rel: str,
131
+ pk: str,
132
+ max_depth: int = 3,
133
+ ) -> List[GraphBfsHit]:
134
+ params = {"rel": rel, "pk": pk, "max_depth": str(max_depth)}
135
+ r = await self._p._request(
136
+ "GET",
137
+ f"/v1/tenants/{self._p.tenant}/graph/{schema}/bfs",
138
+ params=params,
139
+ )
140
+ return [GraphBfsHit._from_payload(h) for h in r.json()]
141
+
142
+ async def path(
143
+ self,
144
+ schema: str,
145
+ *,
146
+ rel: str,
147
+ src: str,
148
+ dst: str,
149
+ max_depth: int = 3,
150
+ ) -> GraphPath:
151
+ params = {"rel": rel, "src": src, "dst": dst, "max_depth": str(max_depth)}
152
+ r = await self._p._request(
153
+ "GET",
154
+ f"/v1/tenants/{self._p.tenant}/graph/{schema}/path",
155
+ params=params,
156
+ )
157
+ body = r.json()
158
+ return GraphPath(reachable=bool(body.get("reachable", False)))
159
+
160
+ async def dijkstra(
161
+ self,
162
+ schema: str,
163
+ *,
164
+ rel: str,
165
+ src: str,
166
+ dst: str,
167
+ weights: Mapping[str, float],
168
+ ) -> DijkstraResult:
169
+ params = {
170
+ "rel": rel,
171
+ "src": src,
172
+ "dst": dst,
173
+ "weights_json": _json.dumps(dict(weights)),
174
+ }
175
+ r = await self._p._request(
176
+ "GET",
177
+ f"/v1/tenants/{self._p.tenant}/graph/{schema}/dijkstra",
178
+ params=params,
179
+ )
180
+ body = r.json()
181
+ cost = body.get("cost")
182
+ return DijkstraResult(cost=None if cost is None else float(cost))
183
+
184
+
185
+ class AsyncOriginChain:
186
+ """asyncio-native client. ``async with`` for resource cleanup."""
187
+
188
+ def __init__(
189
+ self,
190
+ *,
191
+ base_url: str,
192
+ bearer: str,
193
+ tenant: str,
194
+ timeout: float = DEFAULT_TIMEOUT_S,
195
+ max_retries: int = DEFAULT_MAX_RETRIES,
196
+ verify: bool | str = True,
197
+ user_agent: Optional[str] = None,
198
+ ) -> None:
199
+ self.base_url = base_url.rstrip("/")
200
+ self.bearer = bearer
201
+ self.tenant = tenant
202
+ self.max_retries = max_retries
203
+ self._client = httpx.AsyncClient(
204
+ base_url=self.base_url,
205
+ timeout=timeout,
206
+ verify=verify,
207
+ http2=_HTTP2_AVAILABLE,
208
+ headers={
209
+ "Authorization": f"Bearer {bearer}",
210
+ "User-Agent": user_agent or "originchain-python/0.3.0",
211
+ },
212
+ )
213
+ self.schemas = _AsyncSchemas(self)
214
+ self.rows = _AsyncRows(self)
215
+ self.graph = _AsyncGraph(self)
216
+
217
+ @classmethod
218
+ def from_env(cls, **kwargs: Any) -> "AsyncOriginChain":
219
+ try:
220
+ base_url = os.environ["OC_BASE_URL"]
221
+ bearer = os.environ["OC_BEARER"]
222
+ tenant = os.environ["OC_TENANT"]
223
+ except KeyError as e:
224
+ raise OCError(f"missing required env var: {e.args[0]}") from None
225
+ return cls(base_url=base_url, bearer=bearer, tenant=tenant, **kwargs)
226
+
227
+ async def health(self) -> dict[str, Any]:
228
+ r = await self._request("GET", "/health")
229
+ return r.json()
230
+
231
+ async def ask(
232
+ self, nl: str, *, schemas: Optional[list[str]] = None
233
+ ) -> dict[str, Any]:
234
+ body: dict[str, Any] = {"nl": nl}
235
+ if schemas is not None:
236
+ body["schemas"] = schemas
237
+ r = await self._request("POST", f"/v1/tenants/{self.tenant}/ask", json=body)
238
+ return r.json()
239
+
240
+ async def query(self, plan: dict[str, Any]) -> list[Any]:
241
+ r = await self._request(
242
+ "POST", f"/v1/tenants/{self.tenant}/query", json=plan
243
+ )
244
+ return r.json()
245
+
246
+ # ── SQL ───────────────────────────────────────────────────────────
247
+
248
+ async def sql(self, query: str) -> SqlResponse:
249
+ """See :meth:`OriginChain.sql` for the contract."""
250
+ r = await self._request(
251
+ "POST",
252
+ f"/v1/tenants/{self.tenant}/sql",
253
+ json={"sql": query},
254
+ )
255
+ return _decode_sql_response(r.json())
256
+
257
+ async def sql_one(self, query: str) -> Optional[dict[str, Any]]:
258
+ resp = await self.sql(query)
259
+ if not isinstance(resp, SqlSelect):
260
+ raise OCValidationError(
261
+ f"sql_one expected SELECT, got {type(resp).__name__}"
262
+ )
263
+ if not resp.rows:
264
+ return None
265
+ first = resp.rows[0]
266
+ if not isinstance(first, dict):
267
+ return {"value": first}
268
+ return dict(first)
269
+
270
+ # ── Vector ────────────────────────────────────────────────────────
271
+
272
+ async def vector_put(
273
+ self,
274
+ table: str,
275
+ *,
276
+ id: str,
277
+ embedding: list[float],
278
+ dim: int,
279
+ metric: str = "cosine",
280
+ metadata: Optional[Mapping[str, Any]] = None,
281
+ ) -> None:
282
+ body: dict[str, Any] = {
283
+ "id": id,
284
+ "embedding": list(embedding),
285
+ "dim": dim,
286
+ "metric": metric,
287
+ }
288
+ if metadata is not None:
289
+ body["metadata"] = dict(metadata)
290
+ await self._request(
291
+ "POST",
292
+ f"/v1/tenants/{self.tenant}/vector/{table}/put",
293
+ json=body,
294
+ )
295
+
296
+ async def vector_topk(
297
+ self,
298
+ table: str,
299
+ *,
300
+ query: list[float],
301
+ k: int = 10,
302
+ dim: int,
303
+ metric: str = "cosine",
304
+ filter: Optional[Mapping[str, Any]] = None,
305
+ mode: Optional[Literal["fast", "high_recall"]] = None,
306
+ ) -> list[VectorHit]:
307
+ body: dict[str, Any] = {
308
+ "query": list(query),
309
+ "k": k,
310
+ "dim": dim,
311
+ "metric": metric,
312
+ }
313
+ if mode is not None:
314
+ body["mode"] = mode
315
+ if filter is not None:
316
+ body["filter"] = dict(filter)
317
+ r = await self._request(
318
+ "POST",
319
+ f"/v1/tenants/{self.tenant}/vector/{table}/topk",
320
+ json=body,
321
+ )
322
+ return [VectorHit._from_payload(h) for h in r.json()]
323
+
324
+ # ── Full-text ─────────────────────────────────────────────────────
325
+
326
+ async def fts_index(
327
+ self,
328
+ table: str,
329
+ field: str,
330
+ *,
331
+ doc_id: str,
332
+ text: str,
333
+ ) -> None:
334
+ await self._request(
335
+ "POST",
336
+ f"/v1/tenants/{self.tenant}/fts/{table}/{field}",
337
+ json={"doc_id": doc_id, "text": text},
338
+ )
339
+
340
+ async def fts_search(
341
+ self,
342
+ table: str,
343
+ field: str,
344
+ *,
345
+ q: str,
346
+ mode: str = "boolean",
347
+ k: int = 10,
348
+ ) -> list[FtsHit]:
349
+ params: dict[str, str] = {"q": q, "mode": mode}
350
+ if mode == "bm25":
351
+ params["k"] = str(k)
352
+ r = await self._request(
353
+ "GET",
354
+ f"/v1/tenants/{self.tenant}/fts/{table}/{field}",
355
+ params=params,
356
+ )
357
+ body = r.json()
358
+ if mode == "bm25":
359
+ return [FtsHit._from_ranked(h) for h in body]
360
+ return [FtsHit._from_doc_id(str(d)) for d in body]
361
+
362
+ async def aclose(self) -> None:
363
+ await self._client.aclose()
364
+
365
+ async def __aenter__(self) -> "AsyncOriginChain":
366
+ return self
367
+
368
+ async def __aexit__(self, *exc: Any) -> None:
369
+ await self.aclose()
370
+
371
+ async def _request(
372
+ self,
373
+ method: str,
374
+ path: str,
375
+ *,
376
+ params: Optional[dict] = None,
377
+ json: Any = None,
378
+ content: Optional[bytes] = None,
379
+ headers: Optional[dict[str, str]] = None,
380
+ ) -> httpx.Response:
381
+ # Auto-Idempotency-Key on mutating calls. See the sync client's
382
+ # `_request` for the rationale; engine cache is LRU-bounded so
383
+ # fresh-per-call is safe, and callers retain override semantics
384
+ # by passing the header explicitly.
385
+ if method.upper() in _MUTATING_METHODS:
386
+ if headers is None:
387
+ headers = {"Idempotency-Key": _new_idempotency_key()}
388
+ elif not any(k.lower() == "idempotency-key" for k in headers):
389
+ headers = {**headers, "Idempotency-Key": _new_idempotency_key()}
390
+ last_exc: Optional[Exception] = None
391
+ for attempt in range(self.max_retries + 1):
392
+ try:
393
+ resp = await self._client.request(
394
+ method,
395
+ path,
396
+ params=params,
397
+ json=json,
398
+ content=content,
399
+ headers=headers,
400
+ )
401
+ except httpx.RequestError as e:
402
+ last_exc = e
403
+ if attempt < self.max_retries:
404
+ await asyncio.sleep(self._backoff(attempt))
405
+ continue
406
+ raise OCError(f"transport error: {e}") from e
407
+
408
+ if resp.status_code < 400:
409
+ if resp.headers.get("X-OC-Replication", "").lower() == "degraded":
410
+ warnings.warn(
411
+ "leader returned 200 but follower(s) didn't ack within"
412
+ " --sync-timeout-ms; write is durable but RPO=0 not met",
413
+ OCReplicationDegraded,
414
+ stacklevel=3,
415
+ )
416
+ return resp
417
+
418
+ if resp.status_code in RETRYABLE_STATUSES and attempt < self.max_retries:
419
+ wait = self._retry_after(resp) or self._backoff(attempt)
420
+ await asyncio.sleep(wait)
421
+ continue
422
+ self._raise_for(resp)
423
+ raise OCError(f"request failed after retries: {last_exc}")
424
+
425
+ @staticmethod
426
+ def _backoff(attempt: int) -> float:
427
+ return min(4.0, 0.25 * (2 ** attempt))
428
+
429
+ @staticmethod
430
+ def _retry_after(resp: httpx.Response) -> Optional[float]:
431
+ ra = resp.headers.get("Retry-After")
432
+ if ra is None:
433
+ return None
434
+ try:
435
+ return float(ra)
436
+ except ValueError:
437
+ return None
438
+
439
+ @staticmethod
440
+ def _raise_for(resp: httpx.Response) -> None:
441
+ body: Any
442
+ try:
443
+ body = resp.json()
444
+ except Exception:
445
+ body = resp.text
446
+ msg = body.get("error") if isinstance(body, dict) else str(body)
447
+ status = resp.status_code
448
+ if status in (401, 403):
449
+ raise OCAuthError(msg or "unauthorized", status=status, body=body)
450
+ if status == 402:
451
+ addon_msg = (
452
+ body.get("msg") if isinstance(body, dict) else None
453
+ ) or msg or "payment required (add-on)"
454
+ raise OCPaymentRequiredError(addon_msg, status=status, body=body)
455
+ if status == 404:
456
+ raise OCNotFoundError(msg or "not found", status=status, body=body)
457
+ if status == 400:
458
+ raise OCValidationError(msg or "validation failed", status=status, body=body)
459
+ if status == 429:
460
+ raise OCRateLimitedError(
461
+ msg or "rate limited",
462
+ retry_after=AsyncOriginChain._retry_after(resp) or 1.0,
463
+ status=status,
464
+ body=body,
465
+ )
466
+ if 500 <= status < 600:
467
+ raise OCServerError(msg or f"server error {status}", status=status, body=body)
468
+ raise OCError(msg or f"unexpected status {status}", status=status, body=body)
469
+
470
+
471
+ __all__ = ["AsyncOriginChain"]