stratus-db 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.
@@ -0,0 +1,90 @@
1
+ Metadata-Version: 2.1
2
+ Name: stratus-db
3
+ Version: 0.1.0
4
+ Summary: The stratus-db service from Python: typed JSON over urllib, no dependencies.
5
+ Project-URL: Homepage, https://stratus-db.com
6
+ Classifier: Programming Language :: Python :: 3
7
+ Classifier: Operating System :: OS Independent
8
+ Requires-Python: >=3.9
9
+ Description-Content-Type: text/markdown
10
+
11
+ # stratus-db (Python)
12
+
13
+ The stratus service from Python 3.9+: typed JSON over `urllib` (docs/json.md), no
14
+ dependencies.
15
+
16
+ The client is one file, `stratus_db.py`. Install it from PyPI (`pip install stratus-db`; a
17
+ wheel only, published from this repository's release workflow with PyPI's attestations), or
18
+ copy the file into your project. To pin it, use `pip install --require-hashes` with the wheel's
19
+ hash, or compare the file's sha256 with the one published beside it.
20
+
21
+ ```python
22
+ import datetime as dt, os
23
+ from decimal import Decimal
24
+ from stratus_db import connect, kw, squuid
25
+
26
+ s = connect("https://api.stratus-db.com", api_key=os.environ["STRATUS_KEY"])
27
+ db = s.create_db(primary_key="invoice/id", name="invoices")["id"]
28
+
29
+ s.transact(db, tx=[{
30
+ "invoice/id": squuid(), # every row carries its key
31
+ "invoice/total": Decimal("1234.50"), # exact, scale kept
32
+ "invoice/due": dt.date(2026, 11, 1),
33
+ "invoice/sent": dt.datetime.now(dt.timezone.utc),
34
+ "invoice/status": kw("status/sent"), # a keyword, not the string "status/sent"
35
+ }], tx_id="import-42") # a retry with the same id applies once
36
+
37
+ s.request_index(db, "invoice/status")
38
+ s.await_index(db, "invoice/status")
39
+ page = s.query(db, where=["=", "invoice/status", kw("status/sent")], limit=100)
40
+ for row in s.query_all(db, where=["range", "invoice/due", dt.date(2026, 11, 1), None]):
41
+ ...
42
+ ```
43
+
44
+ ## Values
45
+
46
+ | stratus | Python |
47
+ |---|---|
48
+ | string, boolean, nil | `str`, `bool`, `None` |
49
+ | long | `int` (exact at any size) |
50
+ | double | `float` (`1.0` stays a double) |
51
+ | BigDecimal | `decimal.Decimal` (scale kept) |
52
+ | BigInteger, ratio | `BigInteger` (an `int`), `fractions.Fraction` |
53
+ | keyword, symbol, char | `Keyword` (`kw("a/b")`, interned), `Sym`, `Char` |
54
+ | UUID | `uuid.UUID` (`squuid()` for keys) |
55
+ | instant | `datetime` with a timezone (naive ones are refused); reads as `Instant`, a UTC `datetime` with `.nanosecond` past its microseconds |
56
+ | local date | `datetime.date` |
57
+ | bytes | `bytes` |
58
+ | vector, list | `list` or `tuple`, `List` |
59
+ | set | `set`/`frozenset` (reads as `frozenset`; `SetOf` when its elements can't be hashed) |
60
+ | map | `dict` (string keys are attribute names); `Map` when keys are values (a string key a string) |
61
+
62
+ A date or instant outside years 1-9999 reads as a `Tagged`, which writes back unchanged. A
63
+ UUID, instant or keyword passed as a plain string is stored as a string and matches nothing of
64
+ the intended type.
65
+
66
+ ## API
67
+
68
+ Failures raise `StratusError` (`status`, `error` - e.g. `"invalid-query"`, `"conflict"` -,
69
+ `message`). 429s are retried with backoff (`retries`).
70
+
71
+ - Catalog: `list_dbs(deleted=False)`, `db_by_name`, `db_record`, `create_db(primary_key, name=None)`,
72
+ `rename_db`, `delete_db`.
73
+ - A database: `status`, `transact(db, tx=, retract=, delete=, tx_id=, match=)`,
74
+ `query(db, where=, select=, limit=, after=)`, `query_all` (a generator), `pull(db, pks)`,
75
+ `request_index`, `await_index`, `drop_index`, `history(db, start=1, limit=)`, `history_all`,
76
+ `fork(db, t=, name=)` (hosted: a new database, this one as of t), `excise(db, rows=,
77
+ attributes=)` (erasure: rows and (pk, att) out of the state, indexes, history and forks).
78
+ - Keys (hosted, with a key reaching every database): `list_keys()`, `create_key(name=, scope=)`,
79
+ `revoke_key(key_id)`.
80
+ - Codec: `encode`, `decode`, `to_json_value`, `from_json_value`.
81
+
82
+ Addressing: `"hosted"` (default: `<url>/v1/dbs/<id>/...`, `Authorization: Bearer <api_key>`)
83
+ or `"function-urls"` (the internal service's Function URLs, resolved through its catalog;
84
+ `caller` for a local service).
85
+
86
+ ## Tests
87
+
88
+ `python3 -m unittest tests.test_ejson` (the codec, over velojity's own output in
89
+ `clients/fixtures/velojity-ejson.json`), and `scripts/test-python-client` from the repo root
90
+ (`PYTHON=` picks the interpreter): the codec, then the client against the local service.
@@ -0,0 +1,5 @@
1
+ stratus_db.py,sha256=8a3pFwDcgwBs5H4k4_TyDBUe_zV5kJedfQQKtIBnysc,31420
2
+ stratus_db-0.1.0.dist-info/METADATA,sha256=SbmtCwfHh99wzIxm6k3bgt-tO-2i6oFoWk0YL4A2Rbk,4237
3
+ stratus_db-0.1.0.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
4
+ stratus_db-0.1.0.dist-info/top_level.txt,sha256=Zek6EF6TjIfVIN_fr6U7vxvm-vft4udFrtvcQUFWU8w,11
5
+ stratus_db-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (75.6.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ stratus_db
stratus_db.py ADDED
@@ -0,0 +1,801 @@
1
+ """stratus_db: the stratus-db service from Python (3.9+), typed JSON over urllib. No
2
+ dependencies, and one file: the same file is on PyPI and at stratus-db.com/clients/, with its
3
+ sha256. See README.md and docs/json.md.
4
+
5
+ The stratus client: typed JSON over urllib (docs/json.md), 429s retried with backoff, pages
6
+ followed. Failures raise StratusError.
7
+
8
+ from stratus_db import connect, kw, squuid
9
+ s = connect("https://api.stratus-db.com", api_key=os.environ["STRATUS_KEY"])
10
+ db = s.create_db(name="orders", primary_key="order/id")["id"]
11
+ s.transact(db, tx=[{"order/id": squuid(), "order/status": kw("status/new")}])
12
+ rows = s.query(db, where=["=", "order/status", kw("status/new")])["rows"]
13
+
14
+ Addressing: "hosted" (the default) - the catalog at <url>/v1/dbs, a database at
15
+ <url>/v1/dbs/<id>/..., the API key as "Authorization: Bearer <key>". "function-urls" - the
16
+ internal service: the catalog at url, each database at the Function URL its catalog record
17
+ names (looked up, cached for resolve_s, and again when one answers 403 or 404); caller names
18
+ the caller (x-stratus-caller) for a local service.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import base64
24
+ import datetime as _dt
25
+ import gzip
26
+ import json
27
+ import math
28
+ import os
29
+ import random
30
+ import re
31
+ import threading
32
+ import time
33
+ import urllib.error
34
+ import urllib.parse
35
+ import urllib.request
36
+ import uuid as _uuid
37
+ from decimal import Decimal, InvalidOperation
38
+ from fractions import Fraction
39
+
40
+ __version__ = "0.1.0"
41
+
42
+ # sent with every request, so old copies can be seen (and their users warned)
43
+ USER_AGENT = f"stratus-db-python/{__version__}"
44
+
45
+ __all__ = [
46
+ "BigInteger", "Char", "Client", "Instant", "Keyword", "List", "Map", "SetOf", "StratusError", "Sym", "Tagged",
47
+ "connect", "decode", "encode", "from_json_value", "kw", "squuid", "to_json_value",
48
+ ]
49
+
50
+
51
+ # ------------------------------------------------------------------ Values
52
+ # The value types stratus stores that Python has no exact equivalent for.
53
+ #
54
+ # Python's own types cover most of stratus's: str, bool, None, int (a long), float (a double),
55
+ # decimal.Decimal, fractions.Fraction, uuid.UUID, datetime (an instant), date (a local date),
56
+ # bytes, list (a vector), set/frozenset, dict (a map with keyword keys). These add the rest:
57
+ # keywords, symbols, chars, lists (as opposed to vectors), big integers (as opposed to longs),
58
+ # instants past microseconds, maps keyed by values, and tags this client doesn't know.
59
+ #
60
+ # Strings stay strings: a UUID, an instant or a keyword must be one of these types, or it is
61
+ # stored as a string and matches nothing of the intended type.
62
+
63
+ class Keyword:
64
+ """A keyword (:status/sent). Interned: equal keywords are the same object."""
65
+
66
+ __slots__ = ("namespace", "name", "__weakref__")
67
+ _table: dict = {}
68
+ _lock = threading.Lock()
69
+
70
+ def __new__(cls, text: str):
71
+ t = str(text)
72
+ if t.startswith(":"):
73
+ t = t[1:]
74
+ if t == "":
75
+ raise ValueError("A keyword has a name")
76
+ k = cls._table.get(t)
77
+ if k is None:
78
+ with cls._lock:
79
+ k = cls._table.get(t)
80
+ if k is None:
81
+ k = object.__new__(cls)
82
+ i = t.find("/")
83
+ if i <= 0 or len(t) == 1:
84
+ object.__setattr__(k, "namespace", None)
85
+ object.__setattr__(k, "name", t)
86
+ else:
87
+ object.__setattr__(k, "namespace", t[:i])
88
+ object.__setattr__(k, "name", t[i + 1:])
89
+ cls._table[t] = k
90
+ return k
91
+
92
+ def __setattr__(self, *_):
93
+ raise AttributeError("Keywords are immutable")
94
+
95
+ @property
96
+ def full_name(self) -> str:
97
+ return self.name if self.namespace is None else f"{self.namespace}/{self.name}"
98
+
99
+ def __repr__(self):
100
+ return f"kw({self.full_name!r})"
101
+
102
+ def __str__(self):
103
+ return ":" + self.full_name
104
+
105
+ def __reduce__(self):
106
+ return (Keyword, (self.full_name,))
107
+
108
+
109
+ def kw(text: str) -> Keyword:
110
+ """The keyword named "ns/name" (or "name")."""
111
+ return Keyword(text)
112
+
113
+
114
+ class Sym(str):
115
+ """A symbol (rare in data; kept so values read and written round-trip)."""
116
+
117
+ def __repr__(self):
118
+ return f"Sym({str.__repr__(self)})"
119
+
120
+
121
+ class Char(str):
122
+ """A single character."""
123
+
124
+ def __new__(cls, c):
125
+ s = str.__new__(cls, c)
126
+ if len(s) != 1:
127
+ raise ValueError(f"A char is one character: {c!r}")
128
+ return s
129
+
130
+ def __repr__(self):
131
+ return f"Char({str.__repr__(self)})"
132
+
133
+
134
+ class BigInteger(int):
135
+ """An arbitrary-size integer (java.math.BigInteger), as distinct from a long. An int: it
136
+ does arithmetic as one (the result a plain int)."""
137
+
138
+ def __repr__(self):
139
+ return f"BigInteger({int.__repr__(self)})"
140
+
141
+
142
+ class List(list):
143
+ """A list (as opposed to a vector, which is a plain list or tuple)."""
144
+
145
+ def __repr__(self):
146
+ return f"List({list.__repr__(self)})"
147
+
148
+
149
+ class Map(dict):
150
+ """A map whose keys are values (a string key is a string, a UUID a UUID), as opposed to a
151
+ plain dict, whose string keys are keywords (attribute names)."""
152
+
153
+ def __repr__(self):
154
+ return f"Map({dict.__repr__(self)})"
155
+
156
+
157
+ class SetOf(list):
158
+ """A set whose elements Python can't hash (maps, vectors): its elements, as a list."""
159
+
160
+ def __repr__(self):
161
+ return f"SetOf({list.__repr__(self)})"
162
+
163
+
164
+ class Instant(_dt.datetime):
165
+ """An instant with nanosecond precision: a UTC datetime plus nanosecond (0-999 past its
166
+ microsecond). Arithmetic gives plain datetimes."""
167
+
168
+ nanosecond: int = 0
169
+
170
+ @classmethod
171
+ def of(cls, d: _dt.datetime, nanosecond: int = 0) -> "Instant":
172
+ if d.tzinfo is None:
173
+ raise ValueError("A naive datetime isn't an instant: give it a timezone")
174
+ u = d.astimezone(_dt.timezone.utc)
175
+ i = cls(u.year, u.month, u.day, u.hour, u.minute, u.second, u.microsecond, tzinfo=_dt.timezone.utc)
176
+ if not 0 <= nanosecond <= 999:
177
+ raise ValueError(f"nanosecond: {nanosecond}")
178
+ i.nanosecond = nanosecond
179
+ return i
180
+
181
+ def __repr__(self):
182
+ return f"Instant({iso_instant(self)!r})"
183
+
184
+ def __eq__(self, other):
185
+ if isinstance(other, _dt.datetime):
186
+ return _dt.datetime.__eq__(self, other) and getattr(other, "nanosecond", 0) == self.nanosecond
187
+ return NotImplemented
188
+
189
+ def __ne__(self, other):
190
+ # datetime defines its own !=, which would ignore the nanoseconds
191
+ eq = self.__eq__(other)
192
+ return eq if eq is NotImplemented else not eq
193
+
194
+ def __hash__(self):
195
+ return hash((_dt.datetime.__hash__(self), self.nanosecond))
196
+
197
+
198
+ def iso_instant(d: _dt.datetime) -> str:
199
+ """ISO-8601 UTC text, as Java's Instant writes it: a fraction of 3, 6 or 9 digits, or none."""
200
+ if d.tzinfo is None:
201
+ raise ValueError("A naive datetime isn't an instant: give it a timezone")
202
+ u = d.astimezone(_dt.timezone.utc)
203
+ nanos = u.microsecond * 1000 + getattr(d, "nanosecond", 0)
204
+ frac = ""
205
+ if nanos:
206
+ n = f"{nanos:09d}"
207
+ frac = "." + (n[:3] if nanos % 1000000 == 0 else n[:6] if nanos % 1000 == 0 else n)
208
+ return f"{u.year:04d}-{u.month:02d}-{u.day:02d}T{u.hour:02d}:{u.minute:02d}:{u.second:02d}{frac}Z"
209
+
210
+
211
+ class Tagged:
212
+ """A typed-JSON tag this client doesn't know or Python can't hold (a date past year 9999),
213
+ kept as it came: it writes back as itself."""
214
+
215
+ __slots__ = ("tag", "rep")
216
+
217
+ def __init__(self, tag: str, rep):
218
+ self.tag = tag
219
+ self.rep = rep
220
+
221
+ def __eq__(self, other):
222
+ return isinstance(other, Tagged) and other.tag == self.tag and other.rep == self.rep
223
+
224
+ def __hash__(self):
225
+ return hash((self.tag, repr(self.rep)))
226
+
227
+ def __repr__(self):
228
+ return f"Tagged({self.tag!r}, {self.rep!r})"
229
+
230
+
231
+ # ------------------------------------------------------------------ Typed JSON
232
+ # Typed JSON (docs/json.md): stratus's encoding, MongoDB Extended JSON v2's tags where they
233
+ # mean the same and stratus's own where it has none.
234
+ #
235
+ # Python to stratus:
236
+ # str, bool, None themselves
237
+ # int a long (within ±2^53 plain, else $numberLong); past 64 bits $bigint;
238
+ # a BigInteger always $bigint
239
+ # float a double (NaN and the infinities $numberDouble)
240
+ # Decimal, Fraction $numberDecimal (scale kept), $ratio
241
+ # UUID $uuid
242
+ # datetime $inst (timezone required; an Instant keeps its nanoseconds)
243
+ # date $localDate
244
+ # Keyword, Sym, Char $kw, $sym, $char
245
+ # bytes, bytearray $binary
246
+ # list, tuple a vector; a List a list
247
+ # set, frozenset, SetOf $set
248
+ # dict a map: its keys are keywords (str or Keyword); other keys - or a
249
+ # Map - $map
250
+ # Tagged its tag
251
+ # stratus to Python: the same, backwards. A local date or instant Python can't hold (past year
252
+ # 9999, before year 1) reads as a Tagged, which writes back as it came.
253
+
254
+ MAX_SAFE = 2 ** 53 - 1
255
+ INT64_MIN = -(2 ** 63)
256
+ INT64_MAX = 2 ** 63 - 1
257
+
258
+
259
+ def _tag(t, rep):
260
+ return {t: rep}
261
+
262
+
263
+ def _double_text(f: float) -> str:
264
+ if math.isnan(f):
265
+ return "NaN"
266
+ return "Infinity" if f > 0 else "-Infinity"
267
+
268
+
269
+ def _key_name(k) -> str | None:
270
+ """A dict key's keyword name, or None when the key isn't a keyword."""
271
+ if isinstance(k, Keyword):
272
+ return k.full_name
273
+ if type(k) is str:
274
+ return k
275
+ return None
276
+
277
+
278
+ def to_json_value(x):
279
+ """The JSON tree of value x (json.dumps writes it)."""
280
+ if x is None or isinstance(x, bool):
281
+ return x
282
+ if isinstance(x, (Sym, Char)):
283
+ return _tag("$sym" if isinstance(x, Sym) else "$char", str(x))
284
+ if isinstance(x, str):
285
+ return x
286
+ if isinstance(x, BigInteger):
287
+ return _tag("$bigint", str(int(x)))
288
+ if isinstance(x, int):
289
+ if -MAX_SAFE <= x <= MAX_SAFE:
290
+ return int(x)
291
+ if INT64_MIN <= x <= INT64_MAX:
292
+ return _tag("$numberLong", str(int(x)))
293
+ return _tag("$bigint", str(int(x)))
294
+ if isinstance(x, float):
295
+ return x if math.isfinite(x) else _tag("$numberDouble", _double_text(x))
296
+ if isinstance(x, Decimal):
297
+ if not x.is_finite():
298
+ raise ValueError(f"No typed-JSON encoding for the decimal {x}")
299
+ return _tag("$numberDecimal", str(x))
300
+ if isinstance(x, Fraction):
301
+ return _tag("$ratio", f"{x.numerator}/{x.denominator}")
302
+ if isinstance(x, _uuid.UUID):
303
+ return _tag("$uuid", str(x))
304
+ if isinstance(x, _dt.datetime):
305
+ return _tag("$inst", iso_instant(x))
306
+ if isinstance(x, _dt.date):
307
+ return _tag("$localDate", x.isoformat())
308
+ if isinstance(x, Keyword):
309
+ return _tag("$kw", x.full_name)
310
+ if isinstance(x, (bytes, bytearray)):
311
+ return _tag("$binary", {"base64": base64.b64encode(bytes(x)).decode("ascii"), "subType": "00"})
312
+ if isinstance(x, Tagged):
313
+ return _tag(x.tag, to_json_value(x.rep))
314
+ if isinstance(x, List):
315
+ return _tag("$list", [to_json_value(y) for y in x])
316
+ if isinstance(x, SetOf):
317
+ return _tag("$set", [to_json_value(y) for y in x])
318
+ if isinstance(x, (list, tuple)):
319
+ return [to_json_value(y) for y in x]
320
+ if isinstance(x, (set, frozenset)):
321
+ return _tag("$set", [to_json_value(y) for y in x])
322
+ if isinstance(x, dict):
323
+ names = None if isinstance(x, Map) else [_key_name(k) for k in x]
324
+ # a map of keyword keys is an object - unless its one key starts with $ (a tag)
325
+ if names is not None and None not in names and not (len(names) == 1 and names[0].startswith("$")):
326
+ return {n: to_json_value(v) for n, v in zip(names, x.values())}
327
+ return _tag("$map", [[to_json_value(Keyword(k) if type(k) is str and not isinstance(x, Map) else k),
328
+ to_json_value(v)] for k, v in x.items()])
329
+ raise TypeError(f"No typed-JSON encoding for {type(x).__name__}")
330
+
331
+
332
+ def encode(x) -> str:
333
+ """x as typed-JSON text."""
334
+ return json.dumps(to_json_value(x), separators=(",", ":"), ensure_ascii=False, allow_nan=False)
335
+
336
+
337
+ def _text(t, rep) -> str:
338
+ if not isinstance(rep, str):
339
+ raise ValueError(f"{t} takes a string")
340
+ return rep
341
+
342
+
343
+ def _array(t, rep) -> list:
344
+ if not isinstance(rep, list):
345
+ raise ValueError(f"{t} takes an array")
346
+ return rep
347
+
348
+
349
+ _INT = re.compile(r"^[+-]?\d+$")
350
+ _NUM = re.compile(r"^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$")
351
+ _ISO_INSTANT = re.compile(r"^([+-]\d{4,}|\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?Z$")
352
+ _ISO_DATE = re.compile(r"^([+-]\d{4,}|\d{4})-(\d{2})-(\d{2})$")
353
+
354
+
355
+ def _parse_long(t, s):
356
+ if not _INT.match(s):
357
+ raise ValueError(f"{t} isn't an integer: {s}")
358
+ n = int(s)
359
+ if not INT64_MIN <= n <= INT64_MAX:
360
+ raise ValueError(f"{t} isn't a 64-bit integer: {s}")
361
+ return n
362
+
363
+
364
+ def _parse_double(s):
365
+ if s == "NaN":
366
+ return math.nan
367
+ if s == "Infinity":
368
+ return math.inf
369
+ if s == "-Infinity":
370
+ return -math.inf
371
+ if not _NUM.match(s):
372
+ raise ValueError(f"$numberDouble isn't a number: {s}")
373
+ return float(s)
374
+
375
+
376
+ def _parse_instant(text):
377
+ m = _ISO_INSTANT.match(text)
378
+ if m is None:
379
+ raise ValueError(f"Not an ISO instant: {text}")
380
+ y, mo, d, h, mi, s, frac = m.groups()
381
+ year = int(y)
382
+ nanos = int(frac.ljust(9, "0")) if frac else 0
383
+ if not 1 <= year <= 9999:
384
+ _check_date_fields(year, int(mo), int(d), text)
385
+ return Tagged("$inst", text)
386
+ base = _dt.datetime(year, int(mo), int(d), int(h), int(mi), int(s), nanos // 1000, tzinfo=_dt.timezone.utc)
387
+ return Instant.of(base, nanos % 1000)
388
+
389
+
390
+ def _check_date_fields(year, month, day, text):
391
+ # a year Python can't hold: check the month and day against a leap or common year like it
392
+ leap = year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
393
+ try:
394
+ _dt.date(2000 if leap else 2001, month, day)
395
+ except ValueError:
396
+ raise ValueError(f"Not a date: {text}") from None
397
+
398
+
399
+ def _parse_date(text):
400
+ m = _ISO_DATE.match(text)
401
+ if m is None:
402
+ raise ValueError(f"Not an ISO date: {text}")
403
+ year, month, day = int(m.group(1)), int(m.group(2)), int(m.group(3))
404
+ if not 1 <= year <= 9999:
405
+ _check_date_fields(year, month, day, text)
406
+ return Tagged("$localDate", text)
407
+ return _dt.date(year, month, day)
408
+
409
+
410
+ def _hashable(x):
411
+ """x as a hashable map key or set element (vectors as tuples), or raise TypeError."""
412
+ if isinstance(x, list) and not isinstance(x, (List, SetOf)):
413
+ return tuple(_hashable(y) for y in x)
414
+ hash(x)
415
+ return x
416
+
417
+
418
+ def _decode_tag(t, rep, strict):
419
+ if t in ("$numberLong", "$numberInt"):
420
+ return _parse_long(t, _text(t, rep))
421
+ if t == "$numberDouble":
422
+ return _parse_double(_text(t, rep))
423
+ if t == "$numberDecimal":
424
+ s = _text(t, rep)
425
+ if not _NUM.match(s):
426
+ raise ValueError(f"$numberDecimal isn't a decimal: {s}")
427
+ return Decimal(s)
428
+ if t == "$bigint":
429
+ s = _text(t, rep)
430
+ if not _INT.match(s):
431
+ raise ValueError(f"$bigint isn't an integer: {s}")
432
+ return BigInteger(int(s))
433
+ if t == "$ratio":
434
+ m = re.match(r"^([+-]?\d+)/(\d+)$", _text(t, rep))
435
+ if m is None:
436
+ raise ValueError(f"$ratio is n/d: {rep}")
437
+ return Fraction(int(m.group(1)), int(m.group(2)))
438
+ if t == "$kw":
439
+ return Keyword(_text(t, rep))
440
+ if t == "$sym":
441
+ return Sym(_text(t, rep))
442
+ if t == "$char":
443
+ return Char(_text(t, rep))
444
+ if t == "$uuid":
445
+ return _uuid.UUID(_text(t, rep))
446
+ if t == "$inst":
447
+ return _parse_instant(_text(t, rep))
448
+ if t == "$date":
449
+ if isinstance(rep, str):
450
+ return Instant.of(_dt.datetime.fromisoformat(rep.replace("Z", "+00:00")))
451
+ if isinstance(rep, dict) and isinstance(rep.get("$numberLong"), str):
452
+ ms = _parse_long(t, rep["$numberLong"])
453
+ return Instant.of(_dt.datetime(1970, 1, 1, tzinfo=_dt.timezone.utc) + _dt.timedelta(milliseconds=ms))
454
+ raise ValueError("$date takes an ISO string or {$numberLong: millis}")
455
+ if t == "$localDate":
456
+ return _parse_date(_text(t, rep))
457
+ if t == "$binary":
458
+ if not isinstance(rep, dict):
459
+ raise ValueError("$binary takes {base64, subType}")
460
+ b = base64.b64decode(_text("$binary's base64", rep.get("base64")), validate=True)
461
+ sub = _text("$binary's subType", rep.get("subType"))
462
+ if sub in ("04", "4"):
463
+ if len(b) != 16:
464
+ raise ValueError("A $binary UUID (subtype 04) has 16 bytes")
465
+ return _uuid.UUID(bytes=b)
466
+ return b
467
+ if t == "$set":
468
+ items = [from_json_value(y, strict) for y in _array(t, rep)]
469
+ try:
470
+ return frozenset(_hashable(y) for y in items)
471
+ except TypeError:
472
+ return SetOf(items)
473
+ if t == "$list":
474
+ return List(from_json_value(y, strict) for y in _array(t, rep))
475
+ if t == "$map":
476
+ pairs = []
477
+ for p in _array(t, rep):
478
+ if not isinstance(p, list) or len(p) != 2:
479
+ raise ValueError("A $map entry is [k, v]")
480
+ pairs.append((from_json_value(p[0], strict), from_json_value(p[1], strict)))
481
+ # keys all keywords (a lone $-named key, escaped): a plain dict, whose keys are keywords
482
+ if pairs and all(isinstance(k, Keyword) for k, _ in pairs):
483
+ return {k.full_name: v for k, v in pairs}
484
+ return Map((_hashable(k), v) for k, v in pairs)
485
+ if strict:
486
+ raise ValueError(f"Unknown typed-JSON tag {t}")
487
+ return Tagged(t, from_json_value(rep, strict))
488
+
489
+
490
+ def from_json_value(x, strict: bool = False):
491
+ """The value of a parsed JSON tree. strict: an unknown tag raises."""
492
+ if isinstance(x, list):
493
+ return [from_json_value(y, strict) for y in x]
494
+ if isinstance(x, dict):
495
+ if len(x) == 1:
496
+ (k, v), = x.items()
497
+ if k.startswith("$"):
498
+ return _decode_tag(k, v, strict)
499
+ return {k: from_json_value(v, strict) for k, v in x.items()}
500
+ return x
501
+
502
+
503
+ def decode(text, strict: bool = False):
504
+ """The value of typed-JSON text (str or bytes). strict: an unknown tag raises."""
505
+ try:
506
+ return from_json_value(json.loads(text), strict)
507
+ except InvalidOperation as e:
508
+ raise ValueError(str(e)) from None
509
+
510
+
511
+ # ------------------------------------------------------------------ The client
512
+
513
+ class StratusError(Exception):
514
+ """A request that failed: its HTTP status, the error's kind ("invalid-query") and message."""
515
+
516
+ def __init__(self, status: int, error, message, body=None):
517
+ super().__init__(f"HTTP {status}{' ' + error if error else ''}: {message or ''}")
518
+ self.status = status
519
+ self.error = error
520
+ self.message = message
521
+ self.body = body
522
+
523
+
524
+ def _att_name(att) -> str:
525
+ return att.full_name if isinstance(att, Keyword) else str(att).lstrip(":")
526
+
527
+
528
+ def connect(url: str, **opts) -> "Client":
529
+ """A client of the stratus service at url (see Client)."""
530
+ return Client(url, **opts)
531
+
532
+
533
+ class Client:
534
+ def __init__(self, url: str, api_key: str | None = None, addressing: str = "hosted",
535
+ caller: str | None = None, retries: int = 10, resolve_s: float = 300.0,
536
+ timeout: float = 30.0):
537
+ """url: the service's base URL (hosted) or the catalog's (function-urls). retries:
538
+ attempts after a 429. timeout: seconds a request may take."""
539
+ if addressing not in ("hosted", "function-urls"):
540
+ raise ValueError(f"addressing: {addressing}")
541
+ self.url = url.rstrip("/")
542
+ self.api_key = api_key
543
+ self.addressing = addressing
544
+ self.caller = caller
545
+ self.retries = retries
546
+ self.resolve_s = resolve_s
547
+ self.timeout = timeout
548
+ self._urls = (0.0, {})
549
+ self._lock = threading.Lock()
550
+
551
+ # -- requests
552
+
553
+ def send(self, url: str, method: str, body=None):
554
+ """method url body: (status, body read as typed JSON when it is JSON, else text)."""
555
+ headers = {"accept": "application/json", "accept-encoding": "gzip", "user-agent": USER_AGENT}
556
+ data = None
557
+ if body is not None:
558
+ headers["content-type"] = "application/json"
559
+ data = encode(body).encode("utf-8")
560
+ if self.api_key:
561
+ headers["authorization"] = f"Bearer {self.api_key}"
562
+ if self.caller:
563
+ headers["x-stratus-caller"] = self.caller
564
+ attempt = 0
565
+ while True:
566
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
567
+ try:
568
+ with urllib.request.urlopen(req, timeout=self.timeout) as r:
569
+ status, rh, raw = r.status, r.headers, r.read()
570
+ except urllib.error.HTTPError as e:
571
+ status, rh, raw = e.code, e.headers, e.read()
572
+ if status == 429 and attempt < self.retries:
573
+ # full jitter: up to 50 ms x 2^attempt, at most 5 s
574
+ time.sleep(random.random() * min(5.0, 0.05 * 2 ** attempt))
575
+ attempt += 1
576
+ continue
577
+ if (rh.get("content-encoding") or "").lower() == "gzip":
578
+ raw = gzip.decompress(raw)
579
+ text = raw.decode("utf-8")
580
+ value = text
581
+ if (rh.get("content-type") or "").startswith("application/json") and text:
582
+ try:
583
+ value = decode(text)
584
+ except ValueError:
585
+ value = text
586
+ return status, value
587
+
588
+ @staticmethod
589
+ def _ok(r):
590
+ status, body = r
591
+ if 200 <= status < 300:
592
+ return body
593
+ if isinstance(body, dict):
594
+ raise StratusError(status, body.get("error"), body.get("message"), body)
595
+ raise StratusError(status, None, str(body), body)
596
+
597
+ def _catalog_url(self, path: str) -> str:
598
+ return f"{self.url}/v1{path}" if self.addressing == "hosted" else f"{self.url}{path}"
599
+
600
+ def call_catalog(self, method: str, path: str, body=None):
601
+ """method path body to the catalog: (status, body)."""
602
+ return self.send(self._catalog_url(path), method, body)
603
+
604
+ def _resolve(self) -> dict:
605
+ recs = self._ok(self.call_catalog("GET", "/dbs"))
606
+ by_id = {str(r["id"]): r["url"] for r in recs if r.get("url")}
607
+ with self._lock:
608
+ self._urls = (time.monotonic(), by_id)
609
+ return by_id
610
+
611
+ def _db_base(self, id):
612
+ key = str(id)
613
+ if self.addressing == "hosted":
614
+ return f"{self.url}/v1/dbs/{urllib.parse.quote(key, safe='')}"
615
+ at, by_id = self._urls
616
+ if key in by_id and time.monotonic() - at < self.resolve_s:
617
+ return by_id[key].rstrip("/")
618
+ u = self._resolve().get(key)
619
+ return None if u is None else u.rstrip("/")
620
+
621
+ def call_db(self, id, method: str, path: str, body=None):
622
+ """method path body to database id: (status, body)."""
623
+ base = self._db_base(id)
624
+ if base is None:
625
+ return 404, {"error": "no-database", "message": f"No database {id}"}
626
+ r = self.send(base + path, method, body)
627
+ if self.addressing == "function-urls" and r[0] in (403, 404):
628
+ again = self._resolve().get(str(id))
629
+ if again is not None and again.rstrip("/") != base:
630
+ return self.send(again.rstrip("/") + path, method, body)
631
+ return r
632
+
633
+ # -- the catalog
634
+
635
+ def list_dbs(self, deleted: bool = False) -> list:
636
+ """The live databases' records (deleted=True: deleted ones too)."""
637
+ return self._ok(self.call_catalog("GET", "/dbs?deleted=true" if deleted else "/dbs"))
638
+
639
+ def db_by_name(self, name: str) -> dict:
640
+ return self._ok(self.call_catalog("GET", "/dbs?" + urllib.parse.urlencode({"name": name})))
641
+
642
+ def db_record(self, id) -> dict:
643
+ return self._ok(self.call_catalog("GET", f"/dbs/{urllib.parse.quote(str(id), safe='')}"))
644
+
645
+ def create_db(self, primary_key, name: str | None = None) -> dict:
646
+ """Creates a database: {"id", "url"}. primary_key is fixed for its life ("order/id")."""
647
+ body = {"primary-key": Keyword(_att_name(primary_key))}
648
+ if name is not None:
649
+ body["name"] = name
650
+ return self._ok(self.call_catalog("POST", "/dbs", body))
651
+
652
+ def rename_db(self, id, name: str) -> dict:
653
+ return self._ok(self.call_catalog("PATCH", f"/dbs/{urllib.parse.quote(str(id), safe='')}", {"name": name}))
654
+
655
+ def delete_db(self, id) -> dict:
656
+ return self._ok(self.call_catalog("DELETE", f"/dbs/{urllib.parse.quote(str(id), safe='')}"))
657
+
658
+ # -- keys (hosted: an account's keys, managed with a key whose scope is every database)
659
+
660
+ def list_keys(self) -> list:
661
+ """The account's keys' records (no secrets)."""
662
+ return self._ok(self.call_catalog("GET", "/keys"))
663
+
664
+ def create_key(self, name: str | None = None, scope=None) -> dict:
665
+ """A new key: {"key" (shown once), "record"}. scope: database ids it may reach (default all)."""
666
+ body = {}
667
+ if name is not None:
668
+ body["name"] = name
669
+ if scope is not None:
670
+ body["scope"] = [str(x) for x in scope]
671
+ return self._ok(self.call_catalog("POST", "/keys", body))
672
+
673
+ def revoke_key(self, key_id: str) -> dict:
674
+ """Revokes key key_id: its record."""
675
+ return self._ok(self.call_catalog("DELETE", f"/keys/{urllib.parse.quote(key_id, safe='')}"))
676
+
677
+ # -- a database
678
+
679
+ def status(self, id) -> dict:
680
+ """The database's state: id, primary-key, t, checkpoint-t, indexes, index-errors, status."""
681
+ return self._ok(self.call_db(id, "GET", "/status" if self.addressing == "hosted" else "/"))
682
+
683
+ def transact(self, id, tx=None, retract=None, delete=None, tx_id=None, match=None) -> dict:
684
+ """Transacts: {"t", "pks", "duplicate"?}. tx: rows (each with its primary key:
685
+ squuid()), retract: rows of attributes to remove, delete: primary keys, tx_id: makes a
686
+ retry safe, match: rows whose attributes must hold those values now (else a 409
687
+ "conflict")."""
688
+ body = {}
689
+ for k, v in (("tx", tx), ("retract", retract), ("delete", delete), ("tx-id", tx_id), ("match", match)):
690
+ if v is not None:
691
+ body[k] = v
692
+ return self._ok(self.call_db(id, "POST", "/transact", body))
693
+
694
+ def query(self, id, where=None, select=None, limit: int | None = None, after=None) -> dict:
695
+ """One page of a query (docs/query.md, "JSON"): {"rows", "next", "t"}."""
696
+ q = {}
697
+ for k, v in (("where", where), ("select", select), ("limit", limit), ("after", after)):
698
+ if v is not None:
699
+ q[k] = v
700
+ return self._ok(self.call_db(id, "POST", "/query", q))
701
+
702
+ def query_all(self, id, where=None, select=None, limit: int | None = None, after=None):
703
+ """Every row of a query, page after page (a generator)."""
704
+ while True:
705
+ page = self.query(id, where=where, select=select, limit=limit, after=after)
706
+ yield from page["rows"]
707
+ if page.get("next") is None:
708
+ return
709
+ after = page["next"]
710
+
711
+ def pull(self, id, pks) -> list:
712
+ """The rows of primary keys pks, in order (None where there is none)."""
713
+ return self._ok(self.call_db(id, "POST", "/pull", {"pks": list(pks)}))["rows"]
714
+
715
+ def request_index(self, id, att) -> dict:
716
+ """Requests a value index on att: {"att", "state"} ("building" until a checkpoint)."""
717
+ return self._ok(self.call_db(id, "POST", "/indexes", {"att": _att_name(att)}))
718
+
719
+ def drop_index(self, id, att) -> dict:
720
+ path = "/".join(urllib.parse.quote(p, safe="") for p in _att_name(att).split("/"))
721
+ return self._ok(self.call_db(id, "DELETE", f"/indexes/{path}"))
722
+
723
+ def await_index(self, id, att, timeout_s: float = 60.0, interval_s: float = 0.2) -> dict:
724
+ """Waits until att's index is ready: the status (raises if it failed or timed out)."""
725
+ name = _att_name(att)
726
+ deadline = time.monotonic() + timeout_s
727
+ while True:
728
+ s = self.status(id)
729
+ state = (s.get("indexes") or {}).get(name)
730
+ if state == "ready":
731
+ return s
732
+ if state == "failed":
733
+ raise StratusError(409, "index-failed", (s.get("index-errors") or {}).get(name, f"{name} failed"), s)
734
+ if time.monotonic() > deadline:
735
+ raise StratusError(408, "timeout", f"{name} isn't ready ({state})", s)
736
+ time.sleep(interval_s)
737
+
738
+ def fork(self, id, t: int | None = None, name: str | None = None) -> dict:
739
+ """Forks database id (hosted): a new database, this one as of t (default its latest),
740
+ recorded in both logs. The fork's record ({"id", "fork-of": {"db", "t"}, ...})."""
741
+ body = {}
742
+ if t is not None:
743
+ body["t"] = t
744
+ if name is not None:
745
+ body["name"] = name
746
+ return self._ok(self.call_db(id, "POST", "/fork", body))
747
+
748
+ def excise(self, id, rows=None, attributes=None) -> dict:
749
+ """Excises rows (primary keys) and attributes ([(pk, att) ...]) - erasure: gone from the
750
+ current state, its indexes and history (and, hosted, the database's forks)."""
751
+ body = {}
752
+ if rows is not None:
753
+ body["rows"] = list(rows)
754
+ if attributes is not None:
755
+ body["attributes"] = [{"pk": pk, "att": _att_name(att)} for pk, att in attributes]
756
+ return self._ok(self.call_db(id, "POST", "/excise", body))
757
+
758
+ def history(self, id, start: int = 1, limit: int | None = None) -> dict:
759
+ """One page of the database's history from t = start: {"entries", "next"}."""
760
+ params = {"from": start}
761
+ if limit is not None:
762
+ params["limit"] = limit
763
+ return self._ok(self.call_db(id, "GET", "/history?" + urllib.parse.urlencode(params)))
764
+
765
+ def history_all(self, id, start: int = 1, limit: int | None = None):
766
+ """Every history entry from t = start, page after page (a generator)."""
767
+ t = start
768
+ while True:
769
+ page = self.history(id, start=t, limit=limit)
770
+ yield from page["entries"]
771
+ if page.get("next") is None:
772
+ return
773
+ t = page["next"]
774
+
775
+
776
+ # Sequential UUIDs for primary keys: every transacted row carries its key (so a retried
777
+ # transaction lands on the same rows), and squuids keep new rows at the right of the
778
+ # database's tree. The server's layout (RFC 9562 version 7): 48 bits of Unix ms, the version,
779
+ # a 12-bit sequence, the variant, 62 random bits. Later ones in this process sort after
780
+ # earlier ones (the server's UUID order); across processes, to the millisecond.
781
+
782
+ _squuid_lock = threading.Lock()
783
+ _squuid_state = [0, 0]
784
+
785
+
786
+ def squuid() -> _uuid.UUID:
787
+ """A sequential UUID."""
788
+ now = time.time_ns() // 1000000
789
+ with _squuid_lock:
790
+ ms, seq = _squuid_state
791
+ if now > ms:
792
+ ms, seq = now, 0
793
+ elif seq < 0xFFF:
794
+ seq += 1
795
+ else:
796
+ ms, seq = ms + 1, 0
797
+ _squuid_state[0], _squuid_state[1] = ms, seq
798
+ r = bytearray(os.urandom(8))
799
+ r[0] = 0x80 | (r[0] & 0x3F)
800
+ hi = (ms << 16) | (0x7 << 12) | seq
801
+ return _uuid.UUID(int=(hi << 64) | int.from_bytes(r, "big"))