sapixdb 0.1.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.
- sapixdb/__init__.py +25 -0
- sapixdb/_errors.py +20 -0
- sapixdb/_http.py +71 -0
- sapixdb/_types.py +87 -0
- sapixdb/client.py +121 -0
- sapixdb/collection.py +203 -0
- sapixdb/graph.py +80 -0
- sapixdb-0.1.0.dist-info/METADATA +250 -0
- sapixdb-0.1.0.dist-info/RECORD +10 -0
- sapixdb-0.1.0.dist-info/WHEEL +4 -0
sapixdb/__init__.py
ADDED
|
@@ -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
|
+
]
|
sapixdb/_errors.py
ADDED
|
@@ -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
|
sapixdb/_http.py
ADDED
|
@@ -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()
|
sapixdb/_types.py
ADDED
|
@@ -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"])
|
sapixdb/client.py
ADDED
|
@@ -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
|
sapixdb/collection.py
ADDED
|
@@ -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
|
sapixdb/graph.py
ADDED
|
@@ -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
|
|
@@ -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,10 @@
|
|
|
1
|
+
sapixdb/__init__.py,sha256=doYWkFj436pLYVsLXa8zquAdsy7vZq4VUMemtgGiK_o,766
|
|
2
|
+
sapixdb/_errors.py,sha256=uD7Bl9xN83IOGdGVlvGnMwDnmT4mjSEZqUl7-f0NTqI,736
|
|
3
|
+
sapixdb/_http.py,sha256=6J-OKS6FHTqjnVekqctPxDOfVxGpgBfrZhaykW2DnRM,1996
|
|
4
|
+
sapixdb/_types.py,sha256=R9oMIzzR6XH6oYVJ0pXFPB_orpT7DPOGwHC-7uaZn_k,1999
|
|
5
|
+
sapixdb/client.py,sha256=yzmhxl2vDRBaKW2kCFgtV0Z_1WcuuMBRcKg50gd0LL0,4294
|
|
6
|
+
sapixdb/collection.py,sha256=wjBU9T8mKcvPRBsAbaWG_pFRLajgIB0FMReF_19SkIU,9725
|
|
7
|
+
sapixdb/graph.py,sha256=RKFvsnPZGEHeJk9ODvOAYSaEEEO94GHhxggcwRxpg18,3812
|
|
8
|
+
sapixdb-0.1.0.dist-info/METADATA,sha256=Ax86jZ_Ab-aKXBQTA_7DKmPGRmqDdn3sZht3b-4xaaw,6975
|
|
9
|
+
sapixdb-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
10
|
+
sapixdb-0.1.0.dist-info/RECORD,,
|