hexdb 1.0.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.
hexdb/__init__.py ADDED
@@ -0,0 +1,295 @@
1
+ """HexDB client for Python 3.9+. Standard library only.
2
+
3
+ from hexdb import HexDB
4
+
5
+ db = HexDB("http://127.0.0.1:7700", api_key=os.environ["HEXDB_API_KEY"])
6
+ orders = db.tessellation("orders")
7
+ doc = orders.insert({"customer": "ada", "total": 12})
8
+ page = orders.query(filter={"total": {"$gt": 10}}, sort="-total")
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import ssl
15
+ import time
16
+ import urllib.error
17
+ import urllib.parse
18
+ import urllib.request
19
+ from typing import Any, Callable, Dict, Iterator, List, Optional, Sequence, Union
20
+
21
+ __all__ = ["HexDB", "HexDBError", "Tessellation", "Stream"]
22
+ __version__ = "1.0.0"
23
+
24
+ Json = Any
25
+ Document = Dict[str, Any]
26
+
27
+
28
+ class HexDBError(Exception):
29
+ """An error answer from HexDB: `status` is the HTTP status, `code` HexDB's error code."""
30
+
31
+ def __init__(self, status: int, code: str, message: str, retry_after: Optional[float] = None):
32
+ super().__init__(message)
33
+ self.status = status
34
+ self.code = code
35
+ self.message = message
36
+ self.retry_after = retry_after
37
+
38
+ def __repr__(self) -> str:
39
+ return f"HexDBError({self.status}, {self.code!r}, {self.message!r})"
40
+
41
+
42
+ def _quote(text: str) -> str:
43
+ return urllib.parse.quote(text, safe="")
44
+
45
+
46
+ class HexDB:
47
+ """A connection to a HexDB server (any hex; replicas forward writes)."""
48
+
49
+ def __init__(
50
+ self,
51
+ url: str,
52
+ api_key: Optional[str] = None,
53
+ *,
54
+ retries: int = 3,
55
+ timeout: float = 30.0,
56
+ ca_file: Optional[str] = None,
57
+ ):
58
+ self.base = url.rstrip("/")
59
+ self.token = api_key
60
+ self.retries = retries
61
+ self.timeout = timeout
62
+ self._context = ssl.create_default_context(cafile=ca_file) if ca_file else None
63
+
64
+ # -- transport ---------------------------------------------------------
65
+
66
+ def request(self, method: str, path: str, body: Json = None, headers: Optional[Dict[str, str]] = None, timeout: Optional[float] = None) -> Json:
67
+ """Send a request to the REST API; returns the parsed JSON (None for 204)."""
68
+ headers = dict(headers or {})
69
+ data = None
70
+ if body is not None:
71
+ data = json.dumps(body).encode("utf-8")
72
+ headers["Content-Type"] = "application/json"
73
+ headers["Accept"] = "application/json"
74
+ if self.token:
75
+ headers["Authorization"] = f"Bearer {self.token}"
76
+ attempt = 0
77
+ while True:
78
+ req = urllib.request.Request(self.base + path, data=data, method=method, headers=headers)
79
+ try:
80
+ with urllib.request.urlopen(req, timeout=timeout or self.timeout, context=self._context) as response:
81
+ if response.status == 204:
82
+ return None
83
+ text = response.read().decode("utf-8")
84
+ return json.loads(text) if text else None
85
+ except urllib.error.HTTPError as e:
86
+ text = e.read().decode("utf-8", "replace")
87
+ e.close()
88
+ retry_after = float(e.headers.get("Retry-After") or 0) or None
89
+ retryable = e.code in (429, 503) and (method == "GET" or "Idempotency-Key" in headers)
90
+ if retryable and attempt < self.retries:
91
+ time.sleep(retry_after or 2**attempt)
92
+ attempt += 1
93
+ continue
94
+ try:
95
+ error = json.loads(text).get("error", {})
96
+ except ValueError:
97
+ error = {}
98
+ raise HexDBError(e.code, error.get("code", "error"), error.get("message", text or e.reason), retry_after) from None
99
+ except urllib.error.URLError as e:
100
+ raise HexDBError(0, "unreachable", f"Couldn't reach HexDB at {self.base}: {e.reason}") from None
101
+
102
+ # -- session -----------------------------------------------------------
103
+
104
+ def login(self, login: str, password: str, code: Optional[str] = None) -> Dict[str, Any]:
105
+ """Sign in (with a one-time code if MFA is on); later requests use the session."""
106
+ body: Dict[str, Any] = {"login": login, "password": password, "return_token": True}
107
+ if code:
108
+ body["code"] = code
109
+ result = self.request("POST", "/auth/login", body)
110
+ self.token = result["token"]
111
+ return {"user": result["user"], "expires_at": result["expires_at"]}
112
+
113
+ def logout(self) -> None:
114
+ self.request("POST", "/auth/logout")
115
+ self.token = None
116
+
117
+ # -- server ------------------------------------------------------------
118
+
119
+ def health(self) -> Dict[str, Any]:
120
+ return self.request("GET", "/health")
121
+
122
+ def status(self) -> Dict[str, Any]:
123
+ return self.request("GET", "/status")
124
+
125
+ def tessellation(self, name: str) -> "Tessellation":
126
+ return Tessellation(self, name)
127
+
128
+ def tessellations(self) -> List[Dict[str, Any]]:
129
+ return self.request("GET", "/tessellations")["tessellations"]
130
+
131
+ def transaction(self, operations: List[Dict[str, Any]], idempotency_key: Optional[str] = None) -> Dict[str, Any]:
132
+ """Operations across tessellations, all or nothing."""
133
+ return self.request("POST", "/transactions", {"operations": operations}, _idem(idempotency_key))
134
+
135
+ def graphql(self, query: str, variables: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
136
+ """Run a GraphQL query or mutation; raises HexDBError on GraphQL errors."""
137
+ result = self.request("POST", "/graphql", {"query": query, "variables": variables or {}})
138
+ if result.get("errors"):
139
+ first = result["errors"][0]
140
+ raise HexDBError(200, first.get("extensions", {}).get("code", "GRAPHQL"), first["message"])
141
+ return result["data"]
142
+
143
+ def changes(self, after: Optional[int] = None, tessellation: Optional[str] = None, stop: Optional[Callable[[], bool]] = None) -> Iterator[Dict[str, Any]]:
144
+ """Committed changes after `after` (default: from now), as they happen (long polling)."""
145
+ cursor = after
146
+ while not (stop and stop()):
147
+ params = {"wait": "30", "limit": "500"}
148
+ if cursor is not None:
149
+ params["after"] = str(cursor)
150
+ if tessellation:
151
+ params["tessellation"] = tessellation
152
+ page = self.request("GET", "/changes?" + urllib.parse.urlencode(params), timeout=60)
153
+ cursor = page["last_seq"]
154
+ yield from page["changes"]
155
+
156
+ def stream(self, name: str) -> "Stream":
157
+ return Stream(self, name)
158
+
159
+ def run_function(self, name: str, params: Optional[Dict[str, Any]] = None) -> Json:
160
+ """Run a saved function; returns its result."""
161
+ return self.request("POST", f"/functions/{_quote(name)}/run", {"params": params or {}})["result"]
162
+
163
+
164
+ def _idem(key: Optional[str]) -> Dict[str, str]:
165
+ return {"Idempotency-Key": key} if key else {}
166
+
167
+
168
+ def _ttl(ttl_seconds: Optional[int]) -> str:
169
+ return f"?ttl={int(ttl_seconds)}" if ttl_seconds else ""
170
+
171
+
172
+ class Tessellation:
173
+ """A collection of documents."""
174
+
175
+ def __init__(self, db: HexDB, name: str):
176
+ self.db = db
177
+ self.name = name
178
+ self.path = "/" + _quote(name)
179
+
180
+ def insert(self, doc: Document, *, ttl_seconds: Optional[int] = None, idempotency_key: Optional[str] = None) -> Document:
181
+ return self.db.request("POST", self.path + _ttl(ttl_seconds), doc, _idem(idempotency_key))
182
+
183
+ def insert_many(self, docs: Sequence[Document], *, ttl_seconds: Optional[int] = None, idempotency_key: Optional[str] = None) -> List[str]:
184
+ """Insert up to 1000 documents atomically; returns their IDs in order."""
185
+ return self.db.request("POST", f"{self.path}/_bulk{_ttl(ttl_seconds)}", list(docs), _idem(idempotency_key))["ids"]
186
+
187
+ def get(self, id: str, fields: Optional[Sequence[str]] = None) -> Optional[Document]:
188
+ query = "?fields=" + _quote(",".join(fields)) if fields else ""
189
+ try:
190
+ return self.db.request("GET", f"{self.path}/{_quote(id)}{query}")
191
+ except HexDBError as e:
192
+ if e.status == 404:
193
+ return None
194
+ raise
195
+
196
+ def replace(self, id: str, doc: Document, *, ttl_seconds: Optional[int] = None, idempotency_key: Optional[str] = None) -> Document:
197
+ return self.db.request("PUT", f"{self.path}/{_quote(id)}{_ttl(ttl_seconds)}", doc, _idem(idempotency_key))
198
+
199
+ def patch(self, id: str, changes: Document, *, ttl_seconds: Optional[int] = None, idempotency_key: Optional[str] = None) -> Document:
200
+ """Merge fields into a document (None removes a field)."""
201
+ return self.db.request("PATCH", f"{self.path}/{_quote(id)}{_ttl(ttl_seconds)}", changes, _idem(idempotency_key))
202
+
203
+ def delete(self, id: str, *, idempotency_key: Optional[str] = None) -> bool:
204
+ try:
205
+ self.db.request("DELETE", f"{self.path}/{_quote(id)}", headers=_idem(idempotency_key))
206
+ return True
207
+ except HexDBError as e:
208
+ if e.status == 404:
209
+ return False
210
+ raise
211
+
212
+ def query(
213
+ self,
214
+ filter: Optional[Dict[str, Any]] = None,
215
+ sort: Union[str, List[Dict[str, Any]], None] = None,
216
+ limit: int = 100,
217
+ offset: int = 0,
218
+ after: Optional[str] = None,
219
+ fields: Optional[Sequence[str]] = None,
220
+ ) -> Dict[str, Any]:
221
+ """One page: {"documents", "total", "next", "plan"}."""
222
+ body: Dict[str, Any] = {"limit": limit, "offset": offset}
223
+ for key, value in (("filter", filter), ("sort", sort), ("after", after), ("fields", list(fields) if fields else None)):
224
+ if value is not None:
225
+ body[key] = value
226
+ return self.db.request("POST", f"{self.path}/_query", body)
227
+
228
+ def iterate(self, filter: Optional[Dict[str, Any]] = None, page_size: int = 500, fields: Optional[Sequence[str]] = None) -> Iterator[Document]:
229
+ """Every matching document, page by page, in ID order."""
230
+ after = None
231
+ while True:
232
+ page = self.query(filter=filter, limit=page_size, after=after, fields=fields)
233
+ yield from page["documents"]
234
+ if not page.get("next"):
235
+ return
236
+ after = page["next"]
237
+
238
+ def count(self, filter: Optional[Dict[str, Any]] = None) -> int:
239
+ query = "?filter=" + _quote(json.dumps(filter)) if filter else ""
240
+ return self.db.request("GET", f"{self.path}/count{query}")["count"]
241
+
242
+ def aggregate(self, group_by: Optional[List[str]] = None, aggregates: Optional[Dict[str, Any]] = None, **options: Any) -> Dict[str, Any]:
243
+ """Group and summarize: aggregate(["status"], {"total": {"$sum": "amount"}}, filter=...)."""
244
+ body = dict(options)
245
+ if group_by is not None:
246
+ body["group_by"] = group_by
247
+ if aggregates is not None:
248
+ body["aggregates"] = aggregates
249
+ return self.db.request("POST", f"{self.path}/_aggregate", body)
250
+
251
+ def upsert(self, key: Union[str, Sequence[str]], docs: Sequence[Document], *, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
252
+ """Insert or replace documents matched by key fields."""
253
+ return self.db.request("POST", f"{self.path}/_upsert", {"key": key if isinstance(key, str) else list(key), "documents": list(docs)}, _idem(idempotency_key))
254
+
255
+ def update_where(self, filter: Dict[str, Any], update: Dict[str, Any]) -> Dict[str, int]:
256
+ return self.db.request("POST", f"{self.path}/_update", {"filter": filter, "update": update})
257
+
258
+
259
+ class Stream:
260
+ """A publish/subscribe stream."""
261
+
262
+ def __init__(self, db: HexDB, name: str):
263
+ self.db = db
264
+ self.name = name
265
+ self.path = "/streams/" + _quote(name)
266
+
267
+ def publish(self, *messages: Dict[str, Any]) -> List[str]:
268
+ """Publish messages ({"payload", "key"?, "headers"?}); returns their offsets."""
269
+ return self.db.request("POST", f"{self.path}/messages", list(messages))["offsets"]
270
+
271
+ def read(self, after: Optional[str] = None, group: Optional[str] = None, limit: int = 100, wait: int = 0) -> Dict[str, Any]:
272
+ params = {"limit": str(limit)}
273
+ if after:
274
+ params["after"] = after
275
+ if group:
276
+ params["group"] = group
277
+ if wait:
278
+ params["wait"] = str(wait)
279
+ return self.db.request("GET", f"{self.path}/messages?" + urllib.parse.urlencode(params), timeout=wait + 30)
280
+
281
+ def commit(self, group: str, offset: str) -> None:
282
+ self.db.request("POST", f"{self.path}/groups/{_quote(group)}/commit", {"offset": offset})
283
+
284
+ def consume(self, group: str, handle: Callable[[Dict[str, Any]], None], stop: Optional[Callable[[], bool]] = None, batch: int = 100) -> None:
285
+ """Call `handle` for each message in order as a consumer group, committing after each batch."""
286
+ while not (stop and stop()):
287
+ page = self.read(group=group, limit=batch, wait=30)
288
+ handled = None
289
+ for message in page["messages"]:
290
+ handle(message)
291
+ handled = message["offset"]
292
+ if stop and stop():
293
+ break
294
+ if handled:
295
+ self.commit(group, handled)
@@ -0,0 +1,16 @@
1
+ Metadata-Version: 2.4
2
+ Name: hexdb
3
+ Version: 1.0.0
4
+ Summary: Client for HexDB, the hexagonal document database.
5
+ Author: Dream In Hex
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://hexdb-website.vercel.app
8
+ Project-URL: Source, https://github.com/dreaminhex/hexdb
9
+ Project-URL: Issues, https://github.com/dreaminhex/hexdb/issues
10
+ Keywords: hexdb,database,document,nosql,client
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+
14
+ # hexdb (Python)
15
+
16
+ Client for HexDB. Python 3.9+, standard library only. See ../README.md and the HexDB manual.
@@ -0,0 +1,5 @@
1
+ hexdb/__init__.py,sha256=4z3dTmR-RjFfG3YpXGc5eOlYVssKJyjGsa4564yx5nY,13151
2
+ hexdb-1.0.0.dist-info/METADATA,sha256=LfP415owB2vnO0TqevmvnOmwhzzv2_TpGIQOqiZAj1c,560
3
+ hexdb-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
4
+ hexdb-1.0.0.dist-info/top_level.txt,sha256=Cr6II455cco1qlc0WFX8BX8hG2tAUx689HB-clHguFg,6
5
+ hexdb-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ hexdb