sapixdb 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,5 @@
1
+ /target
2
+ .DS_Store
3
+ *.env
4
+ *.env.*
5
+ !*.env.example
sapixdb-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,250 @@
1
+ Metadata-Version: 2.4
2
+ Name: sapixdb
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for SapixDB — the agent-native living database
5
+ Project-URL: Homepage, https://sapixdb.com
6
+ Project-URL: Docs, https://sapixdb.com/docs/sdk/python
7
+ Project-URL: Repository, https://github.com/sensart/sapixdb
8
+ Author: Sensart Technologies LLC
9
+ License: MIT
10
+ Keywords: agent-native,ai,database,sapixdb,sdk
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Database
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.9
22
+ Requires-Dist: httpx>=0.27.0
23
+ Provides-Extra: dev
24
+ Requires-Dist: hatch; extra == 'dev'
25
+ Requires-Dist: pytest; extra == 'dev'
26
+ Requires-Dist: pytest-asyncio; extra == 'dev'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # SapixDB Python SDK
30
+
31
+ Official Python SDK for [SapixDB](https://sapixdb.com) — the agent-native living database.
32
+
33
+ Supports both **sync** and **async** (asyncio / FastAPI / Django Async). Python 3.9+.
34
+
35
+ ## Installation
36
+
37
+ ```bash
38
+ pip install sapixdb
39
+ # or
40
+ uv add sapixdb
41
+ # or
42
+ poetry add sapixdb
43
+ ```
44
+
45
+ ## Quick Start
46
+
47
+ ```python
48
+ from sapixdb import SapixClient
49
+
50
+ db = SapixClient(url="http://localhost:7475", agent="my-app")
51
+
52
+ # Write a record
53
+ record = db.collection("products").write({
54
+ "name": "Classic T-Shirt",
55
+ "price": 29.99,
56
+ "stock": 100,
57
+ })
58
+ print(record.id) # "nuc_abc123"
59
+ print(record.hash) # "sha3:e7f2a1..."
60
+
61
+ # Read latest records
62
+ products = db.collection("products").latest()
63
+
64
+ # Filter
65
+ shirts = db.collection("products").find({"category": "apparel"})
66
+
67
+ # Time travel — what did the DB look like yesterday?
68
+ from datetime import datetime, timedelta, timezone
69
+ yesterday = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
70
+ snapshot = db.collection("orders").as_of(yesterday).latest()
71
+ ```
72
+
73
+ ## Async Usage
74
+
75
+ ```python
76
+ import asyncio
77
+ from sapixdb import AsyncSapixClient
78
+
79
+ async def main():
80
+ db = AsyncSapixClient(url="http://localhost:7475", agent="my-app")
81
+ record = await db.collection("products").write({"name": "T-Shirt", "price": 29.99})
82
+ products = await db.collection("products").latest()
83
+
84
+ # Or as a context manager
85
+ async def main():
86
+ async with AsyncSapixClient(url="http://localhost:7475", agent="my-app") as db:
87
+ record = await db.collection("products").write({"name": "T-Shirt"})
88
+
89
+ asyncio.run(main())
90
+ ```
91
+
92
+ ## FastAPI Integration
93
+
94
+ ```python
95
+ from fastapi import FastAPI
96
+ from sapixdb import AsyncSapixClient
97
+
98
+ app = FastAPI()
99
+ db = AsyncSapixClient(url="http://localhost:7475", agent="store")
100
+
101
+ @app.post("/products")
102
+ async def create_product(name: str, price: float):
103
+ record = await db.collection("products").write({"name": name, "price": price})
104
+ return {"id": record.id, "hash": record.hash}
105
+
106
+ @app.get("/products")
107
+ async def list_products():
108
+ items = await db.collection("products").latest()
109
+ return [{"id": r.id, **r.data} for r in items]
110
+ ```
111
+
112
+ ## API Reference
113
+
114
+ ### `SapixClient` / `AsyncSapixClient`
115
+
116
+ | Parameter | Type | Default | Description |
117
+ |-----------|------|---------|-------------|
118
+ | `url` | `str` | — | SapixDB agent URL |
119
+ | `agent` | `str` | — | Agent ID (matches `SAPIX_AGENT_ID`) |
120
+ | `headers` | `dict` | `{}` | Extra HTTP headers |
121
+ | `timeout` | `float` | `10.0` | Request timeout in seconds |
122
+
123
+ ---
124
+
125
+ ### `db.collection(name)`
126
+
127
+ Returns a `CollectionClient` (or `AsyncCollectionClient`).
128
+
129
+ #### `.write(data)` → `WriteResult`
130
+ Append a new record. Nothing is ever overwritten.
131
+
132
+ #### `.write_batch(records)` → `list[WriteResult]`
133
+ Write multiple records. Async version runs them concurrently.
134
+
135
+ #### `.get(record_id)` → `NucleotideRecord`
136
+ Fetch a record by ID. Raises `SapixNotFoundError` if missing.
137
+
138
+ #### `.latest(*, filter?, limit?)` → `list[NucleotideRecord]`
139
+ Current version of every record.
140
+
141
+ #### `.history(*, filter?, limit?)` → `list[NucleotideRecord]`
142
+ Full append-only history — every version ever written.
143
+
144
+ #### `.find(filter, *, limit?)` → `list[NucleotideRecord]`
145
+ Filter records (latest version only).
146
+
147
+ #### `.find_one(filter)` → `NucleotideRecord | None`
148
+ First match, or `None`.
149
+
150
+ #### `.as_of(timestamp)` → `CollectionQuery`
151
+ Scope reads to a point in time. Returns a query object with `.latest()`, `.find()`, `.find_one()`, `.all()`.
152
+
153
+ ---
154
+
155
+ ### `db.graph`
156
+
157
+ #### `.relate(src, dst, edge_type, weight=1.0)`
158
+ Create a typed directed edge between two records.
159
+
160
+ #### `.add_edge(src, dst, edge_type, weight=1.0)`
161
+ Full edge creation.
162
+
163
+ #### `.remove_edge(src, dst, edge_type)`
164
+ Delete an edge.
165
+
166
+ #### `.traverse(from_id, *, depth=1, direction="outbound")` → `TraverseResult`
167
+ Walk the graph. `direction`: `"outbound"` | `"inbound"` | `"both"`.
168
+
169
+ #### `.neighbors(node_id, direction="outbound")` → `list[NucleotideRecord]`
170
+ Direct neighbours (depth=1 shortcut).
171
+
172
+ #### `.edges(node_id)` → `list[GraphEdge]`
173
+ All outbound edges from a node.
174
+
175
+ ---
176
+
177
+ ### `db.ingest(collection, data)` → `WriteResult`
178
+ Write via the ingest endpoint — for AI agents, webhooks, and pipelines.
179
+
180
+ ```python
181
+ db.ingest("ai_decisions", {
182
+ "model": "gpt-4o",
183
+ "action": "approve_loan",
184
+ "confidence": 0.94,
185
+ "reasoning": "Credit score 780, DTI 28%",
186
+ })
187
+ ```
188
+
189
+ ---
190
+
191
+ ## Error Handling
192
+
193
+ ```python
194
+ from sapixdb import SapixError, SapixNetworkError, SapixNotFoundError
195
+
196
+ try:
197
+ record = db.collection("orders").get("nuc_missing")
198
+ except SapixNotFoundError as e:
199
+ print(f"Not found: {e.record_id}")
200
+ except SapixNetworkError:
201
+ print("SapixDB is unreachable — is it running?")
202
+ except SapixError as e:
203
+ print(f"Error {e.status}: {e}")
204
+ ```
205
+
206
+ ---
207
+
208
+ ## Full Example: Online Store
209
+
210
+ ```python
211
+ from sapixdb import SapixClient
212
+
213
+ db = SapixClient(url="http://localhost:7475", agent="store")
214
+
215
+ # 1. Add a product
216
+ shirt = db.collection("products").write({
217
+ "sku": "SHIRT-001", "name": "Classic T-Shirt",
218
+ "price": 29.99, "stock": 200, "category": "apparel",
219
+ })
220
+
221
+ # 2. Register customer
222
+ customer = db.collection("customers").write({
223
+ "name": "Alice Johnson", "email": "alice@example.com",
224
+ })
225
+
226
+ # 3. Place order
227
+ order = db.collection("orders").write({
228
+ "customer_id": customer.id,
229
+ "items": [{"product_id": shirt.id, "qty": 2, "unit_price": 29.99}],
230
+ "total": 59.98,
231
+ "status": "placed",
232
+ })
233
+
234
+ # 4. Link in graph
235
+ db.graph.relate(order.id, customer.id, "placed_by")
236
+ db.graph.relate(order.id, shirt.id, "contains")
237
+
238
+ # 5. Ship (appends — "placed" version is preserved forever)
239
+ db.collection("orders").write({
240
+ "customer_id": customer.id,
241
+ "status": "shipped",
242
+ "tracking": "UPS-1Z999AA10123456784",
243
+ })
244
+
245
+ # 6. Audit: what was the order status when it was placed?
246
+ original = db.collection("orders").as_of(order.timestamp).find_one(
247
+ {"customer_id": customer.id}
248
+ )
249
+ print(original.data["status"]) # "placed", not "shipped"
250
+ ```
@@ -0,0 +1,222 @@
1
+ # SapixDB Python SDK
2
+
3
+ Official Python SDK for [SapixDB](https://sapixdb.com) — the agent-native living database.
4
+
5
+ Supports both **sync** and **async** (asyncio / FastAPI / Django Async). Python 3.9+.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pip install sapixdb
11
+ # or
12
+ uv add sapixdb
13
+ # or
14
+ poetry add sapixdb
15
+ ```
16
+
17
+ ## Quick Start
18
+
19
+ ```python
20
+ from sapixdb import SapixClient
21
+
22
+ db = SapixClient(url="http://localhost:7475", agent="my-app")
23
+
24
+ # Write a record
25
+ record = db.collection("products").write({
26
+ "name": "Classic T-Shirt",
27
+ "price": 29.99,
28
+ "stock": 100,
29
+ })
30
+ print(record.id) # "nuc_abc123"
31
+ print(record.hash) # "sha3:e7f2a1..."
32
+
33
+ # Read latest records
34
+ products = db.collection("products").latest()
35
+
36
+ # Filter
37
+ shirts = db.collection("products").find({"category": "apparel"})
38
+
39
+ # Time travel — what did the DB look like yesterday?
40
+ from datetime import datetime, timedelta, timezone
41
+ yesterday = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
42
+ snapshot = db.collection("orders").as_of(yesterday).latest()
43
+ ```
44
+
45
+ ## Async Usage
46
+
47
+ ```python
48
+ import asyncio
49
+ from sapixdb import AsyncSapixClient
50
+
51
+ async def main():
52
+ db = AsyncSapixClient(url="http://localhost:7475", agent="my-app")
53
+ record = await db.collection("products").write({"name": "T-Shirt", "price": 29.99})
54
+ products = await db.collection("products").latest()
55
+
56
+ # Or as a context manager
57
+ async def main():
58
+ async with AsyncSapixClient(url="http://localhost:7475", agent="my-app") as db:
59
+ record = await db.collection("products").write({"name": "T-Shirt"})
60
+
61
+ asyncio.run(main())
62
+ ```
63
+
64
+ ## FastAPI Integration
65
+
66
+ ```python
67
+ from fastapi import FastAPI
68
+ from sapixdb import AsyncSapixClient
69
+
70
+ app = FastAPI()
71
+ db = AsyncSapixClient(url="http://localhost:7475", agent="store")
72
+
73
+ @app.post("/products")
74
+ async def create_product(name: str, price: float):
75
+ record = await db.collection("products").write({"name": name, "price": price})
76
+ return {"id": record.id, "hash": record.hash}
77
+
78
+ @app.get("/products")
79
+ async def list_products():
80
+ items = await db.collection("products").latest()
81
+ return [{"id": r.id, **r.data} for r in items]
82
+ ```
83
+
84
+ ## API Reference
85
+
86
+ ### `SapixClient` / `AsyncSapixClient`
87
+
88
+ | Parameter | Type | Default | Description |
89
+ |-----------|------|---------|-------------|
90
+ | `url` | `str` | — | SapixDB agent URL |
91
+ | `agent` | `str` | — | Agent ID (matches `SAPIX_AGENT_ID`) |
92
+ | `headers` | `dict` | `{}` | Extra HTTP headers |
93
+ | `timeout` | `float` | `10.0` | Request timeout in seconds |
94
+
95
+ ---
96
+
97
+ ### `db.collection(name)`
98
+
99
+ Returns a `CollectionClient` (or `AsyncCollectionClient`).
100
+
101
+ #### `.write(data)` → `WriteResult`
102
+ Append a new record. Nothing is ever overwritten.
103
+
104
+ #### `.write_batch(records)` → `list[WriteResult]`
105
+ Write multiple records. Async version runs them concurrently.
106
+
107
+ #### `.get(record_id)` → `NucleotideRecord`
108
+ Fetch a record by ID. Raises `SapixNotFoundError` if missing.
109
+
110
+ #### `.latest(*, filter?, limit?)` → `list[NucleotideRecord]`
111
+ Current version of every record.
112
+
113
+ #### `.history(*, filter?, limit?)` → `list[NucleotideRecord]`
114
+ Full append-only history — every version ever written.
115
+
116
+ #### `.find(filter, *, limit?)` → `list[NucleotideRecord]`
117
+ Filter records (latest version only).
118
+
119
+ #### `.find_one(filter)` → `NucleotideRecord | None`
120
+ First match, or `None`.
121
+
122
+ #### `.as_of(timestamp)` → `CollectionQuery`
123
+ Scope reads to a point in time. Returns a query object with `.latest()`, `.find()`, `.find_one()`, `.all()`.
124
+
125
+ ---
126
+
127
+ ### `db.graph`
128
+
129
+ #### `.relate(src, dst, edge_type, weight=1.0)`
130
+ Create a typed directed edge between two records.
131
+
132
+ #### `.add_edge(src, dst, edge_type, weight=1.0)`
133
+ Full edge creation.
134
+
135
+ #### `.remove_edge(src, dst, edge_type)`
136
+ Delete an edge.
137
+
138
+ #### `.traverse(from_id, *, depth=1, direction="outbound")` → `TraverseResult`
139
+ Walk the graph. `direction`: `"outbound"` | `"inbound"` | `"both"`.
140
+
141
+ #### `.neighbors(node_id, direction="outbound")` → `list[NucleotideRecord]`
142
+ Direct neighbours (depth=1 shortcut).
143
+
144
+ #### `.edges(node_id)` → `list[GraphEdge]`
145
+ All outbound edges from a node.
146
+
147
+ ---
148
+
149
+ ### `db.ingest(collection, data)` → `WriteResult`
150
+ Write via the ingest endpoint — for AI agents, webhooks, and pipelines.
151
+
152
+ ```python
153
+ db.ingest("ai_decisions", {
154
+ "model": "gpt-4o",
155
+ "action": "approve_loan",
156
+ "confidence": 0.94,
157
+ "reasoning": "Credit score 780, DTI 28%",
158
+ })
159
+ ```
160
+
161
+ ---
162
+
163
+ ## Error Handling
164
+
165
+ ```python
166
+ from sapixdb import SapixError, SapixNetworkError, SapixNotFoundError
167
+
168
+ try:
169
+ record = db.collection("orders").get("nuc_missing")
170
+ except SapixNotFoundError as e:
171
+ print(f"Not found: {e.record_id}")
172
+ except SapixNetworkError:
173
+ print("SapixDB is unreachable — is it running?")
174
+ except SapixError as e:
175
+ print(f"Error {e.status}: {e}")
176
+ ```
177
+
178
+ ---
179
+
180
+ ## Full Example: Online Store
181
+
182
+ ```python
183
+ from sapixdb import SapixClient
184
+
185
+ db = SapixClient(url="http://localhost:7475", agent="store")
186
+
187
+ # 1. Add a product
188
+ shirt = db.collection("products").write({
189
+ "sku": "SHIRT-001", "name": "Classic T-Shirt",
190
+ "price": 29.99, "stock": 200, "category": "apparel",
191
+ })
192
+
193
+ # 2. Register customer
194
+ customer = db.collection("customers").write({
195
+ "name": "Alice Johnson", "email": "alice@example.com",
196
+ })
197
+
198
+ # 3. Place order
199
+ order = db.collection("orders").write({
200
+ "customer_id": customer.id,
201
+ "items": [{"product_id": shirt.id, "qty": 2, "unit_price": 29.99}],
202
+ "total": 59.98,
203
+ "status": "placed",
204
+ })
205
+
206
+ # 4. Link in graph
207
+ db.graph.relate(order.id, customer.id, "placed_by")
208
+ db.graph.relate(order.id, shirt.id, "contains")
209
+
210
+ # 5. Ship (appends — "placed" version is preserved forever)
211
+ db.collection("orders").write({
212
+ "customer_id": customer.id,
213
+ "status": "shipped",
214
+ "tracking": "UPS-1Z999AA10123456784",
215
+ })
216
+
217
+ # 6. Audit: what was the order status when it was placed?
218
+ original = db.collection("orders").as_of(order.timestamp).find_one(
219
+ {"customer_id": customer.id}
220
+ )
221
+ print(original.data["status"]) # "placed", not "shipped"
222
+ ```
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "sapixdb"
7
+ version = "0.1.0"
8
+ description = "Official Python SDK for SapixDB — the agent-native living database"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ authors = [{ name = "Sensart Technologies LLC" }]
12
+ requires-python = ">=3.9"
13
+ keywords = ["sapixdb", "database", "ai", "agent-native", "sdk"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.9",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Topic :: Database",
24
+ "Typing :: Typed",
25
+ ]
26
+ dependencies = [
27
+ "httpx>=0.27.0",
28
+ ]
29
+
30
+ [project.optional-dependencies]
31
+ dev = ["pytest", "pytest-asyncio", "hatch"]
32
+
33
+ [project.urls]
34
+ Homepage = "https://sapixdb.com"
35
+ Docs = "https://sapixdb.com/docs/sdk/python"
36
+ Repository = "https://github.com/sensart/sapixdb"
37
+
38
+ [tool.hatch.build.targets.wheel]
39
+ packages = ["sapixdb"]
@@ -0,0 +1,25 @@
1
+ from .client import SapixClient, AsyncSapixClient
2
+ from .collection import CollectionClient, AsyncCollectionClient, CollectionQuery, AsyncCollectionQuery
3
+ from .graph import GraphClient, AsyncGraphClient
4
+ from ._types import WriteResult, NucleotideRecord, GraphEdge, TraverseResult, HealthResponse
5
+ from ._errors import SapixError, SapixNetworkError, SapixNotFoundError
6
+
7
+ __version__ = "0.1.0"
8
+ __all__ = [
9
+ "SapixClient",
10
+ "AsyncSapixClient",
11
+ "CollectionClient",
12
+ "AsyncCollectionClient",
13
+ "CollectionQuery",
14
+ "AsyncCollectionQuery",
15
+ "GraphClient",
16
+ "AsyncGraphClient",
17
+ "WriteResult",
18
+ "NucleotideRecord",
19
+ "GraphEdge",
20
+ "TraverseResult",
21
+ "HealthResponse",
22
+ "SapixError",
23
+ "SapixNetworkError",
24
+ "SapixNotFoundError",
25
+ ]
@@ -0,0 +1,20 @@
1
+ class SapixError(Exception):
2
+ """Base error for all SapixDB SDK errors."""
3
+ def __init__(self, message: str, status: int | None = None, code: str | None = None):
4
+ super().__init__(message)
5
+ self.status = status
6
+ self.code = code
7
+
8
+
9
+ class SapixNetworkError(SapixError):
10
+ """Raised when the SapixDB agent cannot be reached."""
11
+ def __init__(self, cause: Exception):
12
+ super().__init__(f"Network error: {cause}")
13
+ self.cause = cause
14
+
15
+
16
+ class SapixNotFoundError(SapixError):
17
+ """Raised when a requested record does not exist."""
18
+ def __init__(self, record_id: str):
19
+ super().__init__(f"Record not found: {record_id}", status=404, code="NOT_FOUND")
20
+ self.record_id = record_id
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+ from typing import Any
3
+ import httpx
4
+ from ._errors import SapixError, SapixNetworkError
5
+
6
+
7
+ def request(
8
+ base_url: str,
9
+ agent: str,
10
+ method: str,
11
+ path: str,
12
+ body: dict[str, Any] | None = None,
13
+ extra_headers: dict[str, str] | None = None,
14
+ timeout: float = 10.0,
15
+ ) -> Any:
16
+ url = f"{base_url}{path}"
17
+ headers = {"Content-Type": "application/json", **(extra_headers or {})}
18
+ try:
19
+ resp = httpx.request(
20
+ method,
21
+ url,
22
+ json=body,
23
+ headers=headers,
24
+ timeout=timeout,
25
+ )
26
+ except httpx.RequestError as exc:
27
+ raise SapixNetworkError(exc) from exc
28
+
29
+ if not resp.is_success:
30
+ message = f"HTTP {resp.status_code}"
31
+ code: str | None = None
32
+ try:
33
+ data = resp.json()
34
+ message = data.get("error", message)
35
+ code = data.get("code")
36
+ except Exception:
37
+ pass
38
+ raise SapixError(message, status=resp.status_code, code=code)
39
+
40
+ return resp.json()
41
+
42
+
43
+ async def async_request(
44
+ base_url: str,
45
+ agent: str,
46
+ method: str,
47
+ path: str,
48
+ body: dict[str, Any] | None = None,
49
+ extra_headers: dict[str, str] | None = None,
50
+ timeout: float = 10.0,
51
+ ) -> Any:
52
+ url = f"{base_url}{path}"
53
+ headers = {"Content-Type": "application/json", **(extra_headers or {})}
54
+ try:
55
+ async with httpx.AsyncClient(timeout=timeout) as client:
56
+ resp = await client.request(method, url, json=body, headers=headers)
57
+ except httpx.RequestError as exc:
58
+ raise SapixNetworkError(exc) from exc
59
+
60
+ if not resp.is_success:
61
+ message = f"HTTP {resp.status_code}"
62
+ code: str | None = None
63
+ try:
64
+ data = resp.json()
65
+ message = data.get("error", message)
66
+ code = data.get("code")
67
+ except Exception:
68
+ pass
69
+ raise SapixError(message, status=resp.status_code, code=code)
70
+
71
+ return resp.json()
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass, field
3
+ from typing import Any, Generic, TypeVar
4
+
5
+ T = TypeVar("T")
6
+
7
+
8
+ @dataclass
9
+ class WriteResult:
10
+ id: str
11
+ hash: str
12
+ prev_hash: str | None
13
+ timestamp: str
14
+ collection: str
15
+
16
+ @classmethod
17
+ def _from_dict(cls, d: dict[str, Any]) -> "WriteResult":
18
+ return cls(
19
+ id=d["id"],
20
+ hash=d["hash"],
21
+ prev_hash=d.get("prev_hash"),
22
+ timestamp=d["timestamp"],
23
+ collection=d["collection"],
24
+ )
25
+
26
+
27
+ @dataclass
28
+ class NucleotideRecord(Generic[T]):
29
+ id: str
30
+ data: T
31
+ timestamp: str
32
+ hash: str
33
+ prev_hash: str | None
34
+ collection: str
35
+
36
+ @classmethod
37
+ def _from_dict(cls, d: dict[str, Any]) -> "NucleotideRecord[Any]":
38
+ return cls(
39
+ id=d["id"],
40
+ data=d["data"],
41
+ timestamp=d["timestamp"],
42
+ hash=d["hash"],
43
+ prev_hash=d.get("prev_hash"),
44
+ collection=d["collection"],
45
+ )
46
+
47
+
48
+ @dataclass
49
+ class GraphEdge:
50
+ src: str
51
+ dst: str
52
+ edge_type: str
53
+ weight: float
54
+ timestamp_hlc: int | None = None
55
+
56
+ @classmethod
57
+ def _from_dict(cls, d: dict[str, Any]) -> "GraphEdge":
58
+ return cls(
59
+ src=d["src"],
60
+ dst=d["dst"],
61
+ edge_type=d["edge_type"],
62
+ weight=d.get("weight", 1.0),
63
+ timestamp_hlc=d.get("timestamp_hlc"),
64
+ )
65
+
66
+
67
+ @dataclass
68
+ class TraverseResult:
69
+ nodes: list[NucleotideRecord[Any]]
70
+ edges: list[GraphEdge]
71
+
72
+ @classmethod
73
+ def _from_dict(cls, d: dict[str, Any]) -> "TraverseResult":
74
+ return cls(
75
+ nodes=[NucleotideRecord._from_dict(n) for n in d.get("nodes", [])],
76
+ edges=[GraphEdge._from_dict(e) for e in d.get("edges", [])],
77
+ )
78
+
79
+
80
+ @dataclass
81
+ class HealthResponse:
82
+ status: str
83
+ agent: str
84
+
85
+ @classmethod
86
+ def _from_dict(cls, d: dict[str, Any]) -> "HealthResponse":
87
+ return cls(status=d["status"], agent=d["agent"])
@@ -0,0 +1,121 @@
1
+ from __future__ import annotations
2
+ from typing import Any
3
+ from .collection import CollectionClient, AsyncCollectionClient
4
+ from .graph import GraphClient, AsyncGraphClient
5
+ from ._types import WriteResult, HealthResponse
6
+ from ._http import request, async_request
7
+
8
+
9
+ class SapixClient:
10
+ """
11
+ Synchronous SapixDB client.
12
+
13
+ Usage::
14
+
15
+ from sapixdb import SapixClient
16
+
17
+ db = SapixClient(url="http://localhost:7475", agent="my-app")
18
+ record = db.collection("products").write({"name": "T-Shirt", "price": 29.99})
19
+ products = db.collection("products").latest()
20
+ """
21
+
22
+ def __init__(
23
+ self,
24
+ *,
25
+ url: str,
26
+ agent: str,
27
+ headers: dict[str, str] | None = None,
28
+ timeout: float = 10.0,
29
+ ):
30
+ self._base_url = url.rstrip("/")
31
+ self._agent = agent
32
+ self._headers = headers or {}
33
+ self._timeout = timeout
34
+ self.graph = GraphClient(self._base_url, self._agent, self._headers, self._timeout)
35
+
36
+ def collection(self, name: str) -> CollectionClient:
37
+ """Access a collection by name. Collections are created on first write."""
38
+ return CollectionClient(self._base_url, self._agent, name, self._headers, self._timeout)
39
+
40
+ def ingest(self, collection: str, data: dict[str, Any]) -> WriteResult:
41
+ """
42
+ Write via the ingest endpoint — for AI agents, webhooks, and pipelines.
43
+ Supports optional dual-write to Supabase if configured on the server.
44
+ """
45
+ raw = request(self._base_url, self._agent, "POST",
46
+ f"/v1/{self._agent}/ingest",
47
+ {"collection": collection, "data": data},
48
+ self._headers, self._timeout)
49
+ return WriteResult._from_dict(raw)
50
+
51
+ def health(self) -> HealthResponse:
52
+ """Check that the SapixDB agent is reachable. Raises on failure."""
53
+ raw = request(self._base_url, self._agent, "GET", "/v1/health",
54
+ None, self._headers, self._timeout)
55
+ return HealthResponse._from_dict(raw)
56
+
57
+ def ping(self) -> bool:
58
+ """Returns True if the agent is healthy. Never raises."""
59
+ try:
60
+ return self.health().status == "ok"
61
+ except Exception:
62
+ return False
63
+
64
+
65
+ class AsyncSapixClient:
66
+ """
67
+ Async SapixDB client for use with asyncio / FastAPI / etc.
68
+
69
+ Usage::
70
+
71
+ from sapixdb import AsyncSapixClient
72
+
73
+ async def main():
74
+ db = AsyncSapixClient(url="http://localhost:7475", agent="my-app")
75
+ record = await db.collection("products").write({"name": "T-Shirt"})
76
+
77
+ # Or as an async context manager:
78
+ async with AsyncSapixClient(url="...", agent="...") as db:
79
+ record = await db.collection("products").write({...})
80
+ """
81
+
82
+ def __init__(
83
+ self,
84
+ *,
85
+ url: str,
86
+ agent: str,
87
+ headers: dict[str, str] | None = None,
88
+ timeout: float = 10.0,
89
+ ):
90
+ self._base_url = url.rstrip("/")
91
+ self._agent = agent
92
+ self._headers = headers or {}
93
+ self._timeout = timeout
94
+ self.graph = AsyncGraphClient(self._base_url, self._agent, self._headers, self._timeout)
95
+
96
+ async def __aenter__(self) -> "AsyncSapixClient":
97
+ return self
98
+
99
+ async def __aexit__(self, *_: Any) -> None:
100
+ pass
101
+
102
+ def collection(self, name: str) -> AsyncCollectionClient:
103
+ return AsyncCollectionClient(self._base_url, self._agent, name, self._headers, self._timeout)
104
+
105
+ async def ingest(self, collection: str, data: dict[str, Any]) -> WriteResult:
106
+ raw = await async_request(self._base_url, self._agent, "POST",
107
+ f"/v1/{self._agent}/ingest",
108
+ {"collection": collection, "data": data},
109
+ self._headers, self._timeout)
110
+ return WriteResult._from_dict(raw)
111
+
112
+ async def health(self) -> HealthResponse:
113
+ raw = await async_request(self._base_url, self._agent, "GET", "/v1/health",
114
+ None, self._headers, self._timeout)
115
+ return HealthResponse._from_dict(raw)
116
+
117
+ async def ping(self) -> bool:
118
+ try:
119
+ return (await self.health()).status == "ok"
120
+ except Exception:
121
+ return False
@@ -0,0 +1,203 @@
1
+ from __future__ import annotations
2
+ from typing import Any
3
+ from ._types import NucleotideRecord, WriteResult
4
+ from ._errors import SapixNotFoundError, SapixError
5
+ from ._http import request, async_request
6
+
7
+
8
+ class CollectionQuery:
9
+ """Time-scoped read-only view of a collection."""
10
+
11
+ def __init__(self, base_url: str, agent: str, name: str, as_of: str, headers: dict, timeout: float):
12
+ self._base_url = base_url
13
+ self._agent = agent
14
+ self._name = name
15
+ self._as_of = as_of
16
+ self._headers = headers
17
+ self._timeout = timeout
18
+
19
+ def _query(self, *, latest: bool, filter: dict[str, Any] | None = None,
20
+ limit: int | None = None, offset: int | None = None) -> list[NucleotideRecord]:
21
+ body: dict[str, Any] = {"collection": self._name, "latest": latest, "as_of": self._as_of}
22
+ if filter:
23
+ body["filter"] = filter
24
+ if limit is not None:
25
+ body["limit"] = limit
26
+ if offset is not None:
27
+ body["offset"] = offset
28
+ data = request(self._base_url, self._agent, "POST",
29
+ f"/v1/{self._agent}/strand/query", body, self._headers, self._timeout)
30
+ return [NucleotideRecord._from_dict(r) for r in data.get("results", [])]
31
+
32
+ def latest(self, *, filter: dict[str, Any] | None = None, limit: int | None = None) -> list[NucleotideRecord]:
33
+ return self._query(latest=True, filter=filter, limit=limit)
34
+
35
+ def all(self, *, filter: dict[str, Any] | None = None, limit: int | None = None) -> list[NucleotideRecord]:
36
+ return self._query(latest=False, filter=filter, limit=limit)
37
+
38
+ def find(self, filter: dict[str, Any], *, limit: int | None = None) -> list[NucleotideRecord]:
39
+ return self._query(latest=True, filter=filter, limit=limit)
40
+
41
+ def find_one(self, filter: dict[str, Any]) -> NucleotideRecord | None:
42
+ results = self._query(latest=True, filter=filter, limit=1)
43
+ return results[0] if results else None
44
+
45
+
46
+ class AsyncCollectionQuery:
47
+ """Async time-scoped read-only view of a collection."""
48
+
49
+ def __init__(self, base_url: str, agent: str, name: str, as_of: str, headers: dict, timeout: float):
50
+ self._base_url = base_url
51
+ self._agent = agent
52
+ self._name = name
53
+ self._as_of = as_of
54
+ self._headers = headers
55
+ self._timeout = timeout
56
+
57
+ async def _query(self, *, latest: bool, filter: dict[str, Any] | None = None,
58
+ limit: int | None = None) -> list[NucleotideRecord]:
59
+ body: dict[str, Any] = {"collection": self._name, "latest": latest, "as_of": self._as_of}
60
+ if filter:
61
+ body["filter"] = filter
62
+ if limit is not None:
63
+ body["limit"] = limit
64
+ data = await async_request(self._base_url, self._agent, "POST",
65
+ f"/v1/{self._agent}/strand/query", body, self._headers, self._timeout)
66
+ return [NucleotideRecord._from_dict(r) for r in data.get("results", [])]
67
+
68
+ async def latest(self, *, filter: dict[str, Any] | None = None, limit: int | None = None) -> list[NucleotideRecord]:
69
+ return await self._query(latest=True, filter=filter, limit=limit)
70
+
71
+ async def all(self, *, filter: dict[str, Any] | None = None, limit: int | None = None) -> list[NucleotideRecord]:
72
+ return await self._query(latest=False, filter=filter, limit=limit)
73
+
74
+ async def find(self, filter: dict[str, Any], *, limit: int | None = None) -> list[NucleotideRecord]:
75
+ return await self._query(latest=True, filter=filter, limit=limit)
76
+
77
+ async def find_one(self, filter: dict[str, Any]) -> NucleotideRecord | None:
78
+ results = await self._query(latest=True, filter=filter, limit=1)
79
+ return results[0] if results else None
80
+
81
+
82
+ class CollectionClient:
83
+ def __init__(self, base_url: str, agent: str, name: str, headers: dict, timeout: float):
84
+ self._base_url = base_url
85
+ self._agent = agent
86
+ self._name = name
87
+ self._headers = headers
88
+ self._timeout = timeout
89
+
90
+ def as_of(self, timestamp: str) -> CollectionQuery:
91
+ """Scope all reads to a specific point in time. Returns a CollectionQuery."""
92
+ return CollectionQuery(self._base_url, self._agent, self._name,
93
+ timestamp, self._headers, self._timeout)
94
+
95
+ def write(self, data: dict[str, Any]) -> WriteResult:
96
+ raw = request(self._base_url, self._agent, "POST",
97
+ f"/v1/{self._agent}/strand/write",
98
+ {"collection": self._name, "data": data},
99
+ self._headers, self._timeout)
100
+ return WriteResult._from_dict(raw)
101
+
102
+ def write_batch(self, records: list[dict[str, Any]]) -> list[WriteResult]:
103
+ return [self.write(r) for r in records]
104
+
105
+ def get(self, record_id: str) -> NucleotideRecord:
106
+ try:
107
+ raw = request(self._base_url, self._agent, "GET",
108
+ f"/v1/{self._agent}/strand/{record_id}",
109
+ None, self._headers, self._timeout)
110
+ return NucleotideRecord._from_dict(raw)
111
+ except SapixError as e:
112
+ if e.status == 404:
113
+ raise SapixNotFoundError(record_id) from e
114
+ raise
115
+
116
+ def latest(self, *, filter: dict[str, Any] | None = None, limit: int | None = None) -> list[NucleotideRecord]:
117
+ body: dict[str, Any] = {"collection": self._name, "latest": True}
118
+ if filter:
119
+ body["filter"] = filter
120
+ if limit is not None:
121
+ body["limit"] = limit
122
+ data = request(self._base_url, self._agent, "POST",
123
+ f"/v1/{self._agent}/strand/query", body, self._headers, self._timeout)
124
+ return [NucleotideRecord._from_dict(r) for r in data.get("results", [])]
125
+
126
+ def history(self, *, filter: dict[str, Any] | None = None, limit: int | None = None) -> list[NucleotideRecord]:
127
+ body: dict[str, Any] = {"collection": self._name, "latest": False}
128
+ if filter:
129
+ body["filter"] = filter
130
+ if limit is not None:
131
+ body["limit"] = limit
132
+ data = request(self._base_url, self._agent, "POST",
133
+ f"/v1/{self._agent}/strand/query", body, self._headers, self._timeout)
134
+ return [NucleotideRecord._from_dict(r) for r in data.get("results", [])]
135
+
136
+ def find(self, filter: dict[str, Any], *, limit: int | None = None) -> list[NucleotideRecord]:
137
+ return self.latest(filter=filter, limit=limit)
138
+
139
+ def find_one(self, filter: dict[str, Any]) -> NucleotideRecord | None:
140
+ results = self.latest(filter=filter, limit=1)
141
+ return results[0] if results else None
142
+
143
+
144
+ class AsyncCollectionClient:
145
+ def __init__(self, base_url: str, agent: str, name: str, headers: dict, timeout: float):
146
+ self._base_url = base_url
147
+ self._agent = agent
148
+ self._name = name
149
+ self._headers = headers
150
+ self._timeout = timeout
151
+
152
+ def as_of(self, timestamp: str) -> AsyncCollectionQuery:
153
+ return AsyncCollectionQuery(self._base_url, self._agent, self._name,
154
+ timestamp, self._headers, self._timeout)
155
+
156
+ async def write(self, data: dict[str, Any]) -> WriteResult:
157
+ raw = await async_request(self._base_url, self._agent, "POST",
158
+ f"/v1/{self._agent}/strand/write",
159
+ {"collection": self._name, "data": data},
160
+ self._headers, self._timeout)
161
+ return WriteResult._from_dict(raw)
162
+
163
+ async def write_batch(self, records: list[dict[str, Any]]) -> list[WriteResult]:
164
+ import asyncio
165
+ return list(await asyncio.gather(*[self.write(r) for r in records]))
166
+
167
+ async def get(self, record_id: str) -> NucleotideRecord:
168
+ try:
169
+ raw = await async_request(self._base_url, self._agent, "GET",
170
+ f"/v1/{self._agent}/strand/{record_id}",
171
+ None, self._headers, self._timeout)
172
+ return NucleotideRecord._from_dict(raw)
173
+ except SapixError as e:
174
+ if e.status == 404:
175
+ raise SapixNotFoundError(record_id) from e
176
+ raise
177
+
178
+ async def latest(self, *, filter: dict[str, Any] | None = None, limit: int | None = None) -> list[NucleotideRecord]:
179
+ body: dict[str, Any] = {"collection": self._name, "latest": True}
180
+ if filter:
181
+ body["filter"] = filter
182
+ if limit is not None:
183
+ body["limit"] = limit
184
+ data = await async_request(self._base_url, self._agent, "POST",
185
+ f"/v1/{self._agent}/strand/query", body, self._headers, self._timeout)
186
+ return [NucleotideRecord._from_dict(r) for r in data.get("results", [])]
187
+
188
+ async def history(self, *, filter: dict[str, Any] | None = None, limit: int | None = None) -> list[NucleotideRecord]:
189
+ body: dict[str, Any] = {"collection": self._name, "latest": False}
190
+ if filter:
191
+ body["filter"] = filter
192
+ if limit is not None:
193
+ body["limit"] = limit
194
+ data = await async_request(self._base_url, self._agent, "POST",
195
+ f"/v1/{self._agent}/strand/query", body, self._headers, self._timeout)
196
+ return [NucleotideRecord._from_dict(r) for r in data.get("results", [])]
197
+
198
+ async def find(self, filter: dict[str, Any], *, limit: int | None = None) -> list[NucleotideRecord]:
199
+ return await self.latest(filter=filter, limit=limit)
200
+
201
+ async def find_one(self, filter: dict[str, Any]) -> NucleotideRecord | None:
202
+ results = await self.latest(filter=filter, limit=1)
203
+ return results[0] if results else None
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+ from typing import Any
3
+ from ._types import GraphEdge, TraverseResult, NucleotideRecord
4
+ from ._http import request, async_request
5
+
6
+
7
+ class GraphClient:
8
+ def __init__(self, base_url: str, agent: str, headers: dict, timeout: float):
9
+ self._base_url = base_url
10
+ self._agent = agent
11
+ self._headers = headers
12
+ self._timeout = timeout
13
+
14
+ def add_edge(self, src: str, dst: str, edge_type: str, weight: float = 1.0) -> None:
15
+ request(self._base_url, self._agent, "POST",
16
+ f"/v1/{self._agent}/graph/edge",
17
+ {"src": src, "dst": dst, "edge_type": edge_type, "weight": weight},
18
+ self._headers, self._timeout)
19
+
20
+ def remove_edge(self, src: str, dst: str, edge_type: str) -> None:
21
+ request(self._base_url, self._agent, "DELETE",
22
+ f"/v1/{self._agent}/graph/edge",
23
+ {"src": src, "dst": dst, "edge_type": edge_type},
24
+ self._headers, self._timeout)
25
+
26
+ def relate(self, src: str, dst: str, edge_type: str, weight: float = 1.0) -> None:
27
+ """Convenience alias for add_edge with a readable name."""
28
+ self.add_edge(src, dst, edge_type, weight)
29
+
30
+ def traverse(self, from_id: str, *, depth: int = 1,
31
+ direction: str = "outbound") -> TraverseResult:
32
+ data = request(self._base_url, self._agent, "GET",
33
+ f"/v1/{self._agent}/graph/traverse/{from_id}"
34
+ f"?depth={depth}&direction={direction}",
35
+ None, self._headers, self._timeout)
36
+ return TraverseResult._from_dict(data)
37
+
38
+ def neighbors(self, node_id: str,
39
+ direction: str = "outbound") -> list[NucleotideRecord]:
40
+ return self.traverse(node_id, depth=1, direction=direction).nodes
41
+
42
+ def edges(self, node_id: str) -> list[GraphEdge]:
43
+ return self.traverse(node_id, depth=1).edges
44
+
45
+
46
+ class AsyncGraphClient:
47
+ def __init__(self, base_url: str, agent: str, headers: dict, timeout: float):
48
+ self._base_url = base_url
49
+ self._agent = agent
50
+ self._headers = headers
51
+ self._timeout = timeout
52
+
53
+ async def add_edge(self, src: str, dst: str, edge_type: str, weight: float = 1.0) -> None:
54
+ await async_request(self._base_url, self._agent, "POST",
55
+ f"/v1/{self._agent}/graph/edge",
56
+ {"src": src, "dst": dst, "edge_type": edge_type, "weight": weight},
57
+ self._headers, self._timeout)
58
+
59
+ async def remove_edge(self, src: str, dst: str, edge_type: str) -> None:
60
+ await async_request(self._base_url, self._agent, "DELETE",
61
+ f"/v1/{self._agent}/graph/edge",
62
+ {"src": src, "dst": dst, "edge_type": edge_type},
63
+ self._headers, self._timeout)
64
+
65
+ async def relate(self, src: str, dst: str, edge_type: str, weight: float = 1.0) -> None:
66
+ await self.add_edge(src, dst, edge_type, weight)
67
+
68
+ async def traverse(self, from_id: str, *, depth: int = 1,
69
+ direction: str = "outbound") -> TraverseResult:
70
+ data = await async_request(self._base_url, self._agent, "GET",
71
+ f"/v1/{self._agent}/graph/traverse/{from_id}"
72
+ f"?depth={depth}&direction={direction}",
73
+ None, self._headers, self._timeout)
74
+ return TraverseResult._from_dict(data)
75
+
76
+ async def neighbors(self, node_id: str, direction: str = "outbound") -> list[NucleotideRecord]:
77
+ return (await self.traverse(node_id, depth=1, direction=direction)).nodes
78
+
79
+ async def edges(self, node_id: str) -> list[GraphEdge]:
80
+ return (await self.traverse(node_id, depth=1)).edges