flo-python 0.1.0.dev4__py3-none-any.whl → 0.1.2__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.
flo/kv.py CHANGED
@@ -3,24 +3,172 @@
3
3
  Key-value store operations for Flo client.
4
4
  """
5
5
 
6
- import struct
7
- from typing import TYPE_CHECKING
8
-
6
+ import json
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ from .exceptions import IncompleteResponseError, ServerError, raise_for_status
10
+ from .kv_body import (
11
+ decode_delete_answer,
12
+ decode_empty_answer,
13
+ decode_exists_answer,
14
+ decode_get_answer,
15
+ decode_history_answer,
16
+ decode_incr_answer,
17
+ decode_json_del_answer,
18
+ decode_json_get_answer,
19
+ decode_json_set_answer,
20
+ decode_mget_answer,
21
+ decode_scan_answer,
22
+ decode_set_answer,
23
+ encode_delete_body,
24
+ encode_exists_body,
25
+ encode_expire_body,
26
+ encode_get_body,
27
+ encode_history_body,
28
+ encode_incr_body,
29
+ encode_json_del_body,
30
+ encode_json_get_body,
31
+ encode_json_set_body,
32
+ encode_mget_body,
33
+ encode_scan_body,
34
+ encode_set_body,
35
+ split_mget_keys,
36
+ )
9
37
  from .types import (
10
38
  DeleteOptions,
39
+ GetJsonResult,
11
40
  GetOptions,
41
+ GetResult,
12
42
  HistoryOptions,
43
+ IncrResult,
44
+ KVExistsOptions,
45
+ KVExpireOptions,
46
+ KVIncrOptions,
47
+ KVJsonOptions,
48
+ KVMGetOptions,
49
+ MGetEntry,
13
50
  OpCode,
14
- OptionTag,
15
51
  PutOptions,
52
+ PutResult,
53
+ Reason,
16
54
  ScanOptions,
17
55
  ScanResult,
18
56
  VersionEntry,
19
57
  )
20
- from .wire import OptionsBuilder, parse_history_response, parse_scan_response
58
+ from .wire import RawResponse
21
59
 
22
60
  if TYPE_CHECKING:
23
61
  from .client import FloClient
62
+ from .kv_txn import Transaction
63
+
64
+
65
+ def _key(key: str | bytes) -> bytes:
66
+ return key.encode("utf-8") if isinstance(key, str) else key
67
+
68
+
69
+ def _absent(response: RawResponse) -> bool:
70
+ """Whether a not-found answer says the thing is absent; any other not-found
71
+ (no such transaction) is raised."""
72
+ if not response.is_not_found():
73
+ return False
74
+ try:
75
+ raise_for_status(response.status, response.data)
76
+ except ServerError as e:
77
+ if e.reason == Reason.NOT_FOUND:
78
+ return True
79
+ raise
80
+ return False # pragma: no cover - raise_for_status always raises here
81
+
82
+
83
+ async def kv_get(
84
+ client: "FloClient", namespace: str, key: str | bytes, opts: GetOptions, txn: int | None = None
85
+ ) -> GetResult | None:
86
+ """kv.get, inside transaction ``txn`` when given."""
87
+ response = await client._send_and_check(
88
+ OpCode.KV_GET, namespace, _key(key), encode_get_body(opts, txn), allow_not_found=True
89
+ )
90
+ if _absent(response):
91
+ return None
92
+ return decode_get_answer(response.data)
93
+
94
+
95
+ async def kv_set(
96
+ client: "FloClient",
97
+ namespace: str,
98
+ key: str | bytes,
99
+ value: bytes,
100
+ opts: PutOptions,
101
+ txn: int | None = None,
102
+ ) -> PutResult:
103
+ """kv.set, inside transaction ``txn`` when given."""
104
+ response = await client._send_and_check(
105
+ OpCode.KV_PUT, namespace, _key(key), encode_set_body(value, opts, txn)
106
+ )
107
+ return decode_set_answer(response.data)
108
+
109
+
110
+ async def kv_delete(
111
+ client: "FloClient",
112
+ namespace: str,
113
+ key: str | bytes,
114
+ opts: DeleteOptions,
115
+ txn: int | None = None,
116
+ ) -> bool | None:
117
+ """kv.delete, inside transaction ``txn`` when given: whether the key existed
118
+ (None inside a transaction)."""
119
+ response = await client._send_and_check(
120
+ OpCode.KV_DELETE,
121
+ namespace,
122
+ _key(key),
123
+ encode_delete_body(opts.cas_version, opts.routing_key, txn),
124
+ )
125
+ return decode_delete_answer(response.data)
126
+
127
+
128
+ async def kv_incr(
129
+ client: "FloClient",
130
+ namespace: str,
131
+ key: str | bytes,
132
+ opts: KVIncrOptions,
133
+ txn: int | None = None,
134
+ ) -> tuple[int | None, int | None]:
135
+ """kv.incr, inside transaction ``txn`` when given: (version, value)."""
136
+ response = await client._send_and_check(
137
+ OpCode.KV_INCR, namespace, _key(key), encode_incr_body(opts.delta, opts.routing_key, txn)
138
+ )
139
+ return decode_incr_answer(response.data)
140
+
141
+
142
+ async def kv_expire(
143
+ client: "FloClient",
144
+ namespace: str,
145
+ key: str | bytes,
146
+ ttl_ms: int,
147
+ opts: KVExpireOptions,
148
+ txn: int | None = None,
149
+ ) -> None:
150
+ """kv.expire, inside transaction ``txn`` when given."""
151
+ response = await client._send_and_check(
152
+ OpCode.KV_EXPIRE,
153
+ namespace,
154
+ _key(key),
155
+ encode_expire_body(ttl_ms, opts.cas_version, opts.routing_key, txn),
156
+ )
157
+ decode_empty_answer("kv.expire", response.data)
158
+
159
+
160
+ async def kv_exists(
161
+ client: "FloClient",
162
+ namespace: str,
163
+ key: str | bytes,
164
+ opts: KVExistsOptions,
165
+ txn: int | None = None,
166
+ ) -> bool:
167
+ """kv.exists, inside transaction ``txn`` when given."""
168
+ response = await client._send_and_check(
169
+ OpCode.KV_EXISTS, namespace, _key(key), encode_exists_body(opts.routing_key, txn)
170
+ )
171
+ return decode_exists_answer(response.data)[0]
24
172
 
25
173
 
26
174
  class KVOperations:
@@ -33,225 +181,285 @@ class KVOperations:
33
181
  self,
34
182
  key: str | bytes,
35
183
  options: GetOptions | None = None,
36
- ) -> bytes | None:
37
- """Get value for a key.
184
+ ) -> GetResult | None:
185
+ """Get value and version for a key.
38
186
 
39
- Args:
40
- key: The key to retrieve.
41
- options: Optional operation options (block_ms for blocking get).
42
-
43
- Returns:
44
- Value bytes if found, None if not found.
187
+ Returns ``GetResult(value, version)``, or ``None`` if the key is not
188
+ found, also when a wait (``wait_ms``) ends without it.
45
189
 
46
190
  Example:
47
- # Simple get
48
- value = await client.kv.get("user:123")
49
- if value is not None:
50
- print(f"Found: {value.decode()}")
51
-
52
- # Blocking get - wait up to 5 seconds for value to appear
53
- value = await client.kv.get("key", GetOptions(block_ms=5000))
191
+ result = await client.kv.get("user:123")
54
192
 
55
- # Blocking get - wait indefinitely (0 = infinite)
56
- value = await client.kv.get("key", GetOptions(block_ms=0))
193
+ # Wait up to 2 seconds for the key to exist
194
+ result = await client.kv.get("key", GetOptions(wait_ms=2000))
57
195
  """
58
196
  opts = options or GetOptions()
59
- namespace = self._client.get_namespace(opts.namespace)
60
-
61
- key_bytes = key.encode("utf-8") if isinstance(key, str) else key
62
-
63
- # Build TLV options
64
- builder = OptionsBuilder()
65
-
66
- if opts.block_ms is not None:
67
- builder.add_u32(OptionTag.BLOCK_MS, opts.block_ms)
68
-
69
- response = await self._client._send_and_check(
70
- OpCode.KV_GET,
71
- namespace,
72
- key_bytes,
73
- b"",
74
- builder.build(),
75
- allow_not_found=True,
76
- )
77
-
78
- if response.is_not_found():
79
- return None
80
-
81
- return response.data if response.data else None
197
+ return await kv_get(self._client, self._client.get_namespace(opts.namespace), key, opts)
82
198
 
83
199
  async def put(
84
200
  self,
85
201
  key: str | bytes,
86
202
  value: bytes,
87
203
  options: PutOptions | None = None,
88
- ) -> None:
89
- """Set a key-value pair.
204
+ ) -> PutResult:
205
+ """Set a key-value pair and return the new version.
90
206
 
91
- Args:
92
- key: The key to set.
93
- value: The value bytes.
94
- options: Optional operation options (TTL, CAS, etc.).
207
+ A ``cas_version`` that isn't the key's, or a ``condition`` that doesn't
208
+ hold, raises a conflict with reason CAS_MISMATCH or CONDITION_FAILED.
95
209
 
96
210
  Example:
97
- # Simple put
98
- await client.kv.put("user:123", b"John Doe")
99
-
100
- # Put with TTL
101
- await client.kv.put("session:abc", b"data", PutOptions(ttl_seconds=3600))
102
-
103
- # Put with CAS (optimistic locking)
104
- await client.kv.put("counter", b"2", PutOptions(cas_version=1))
211
+ res = await client.kv.put("user:123", b"John Doe")
212
+ await client.kv.put("user:123", b"Jane", PutOptions(cas_version=res.version))
105
213
  """
106
214
  opts = options or PutOptions()
107
- namespace = self._client.get_namespace(opts.namespace)
108
-
109
- key_bytes = key.encode("utf-8") if isinstance(key, str) else key
110
-
111
- # Build TLV options
112
- builder = OptionsBuilder()
113
-
114
- if opts.ttl_seconds is not None:
115
- builder.add_u64(OptionTag.TTL_SECONDS, opts.ttl_seconds)
116
-
117
- if opts.cas_version is not None:
118
- builder.add_u64(OptionTag.CAS_VERSION, opts.cas_version)
119
-
120
- if opts.if_not_exists:
121
- builder.add_flag(OptionTag.IF_NOT_EXISTS)
122
-
123
- if opts.if_exists:
124
- builder.add_flag(OptionTag.IF_EXISTS)
125
-
126
- await self._client._send_and_check(
127
- OpCode.KV_PUT,
128
- namespace,
129
- key_bytes,
130
- value,
131
- builder.build(),
215
+ return await kv_set(
216
+ self._client, self._client.get_namespace(opts.namespace), key, value, opts
132
217
  )
133
218
 
134
219
  async def delete(
135
220
  self,
136
221
  key: str | bytes,
137
222
  options: DeleteOptions | None = None,
138
- ) -> None:
139
- """Delete a key.
223
+ ) -> bool:
224
+ """Delete a key and return whether it existed.
140
225
 
141
- Args:
142
- key: The key to delete.
143
- options: Optional operation options.
144
-
145
- Note:
146
- This operation succeeds even if the key doesn't exist.
226
+ Deleting a missing key is not an error. A ``cas_version`` that isn't
227
+ the key's raises a conflict with reason CAS_MISMATCH.
147
228
 
148
229
  Example:
149
- await client.kv.delete("user:123")
230
+ existed = await client.kv.delete("user:123")
231
+ # Only the owner releases the lock:
232
+ await client.kv.delete("lock:resource", DeleteOptions(cas_version=held))
150
233
  """
151
234
  opts = options or DeleteOptions()
152
- namespace = self._client.get_namespace(opts.namespace)
153
-
154
- key_bytes = key.encode("utf-8") if isinstance(key, str) else key
155
-
156
- # Delete succeeds for both OK and NOT_FOUND
157
- await self._client._send_and_check(
158
- OpCode.KV_DELETE,
159
- namespace,
160
- key_bytes,
161
- b"",
162
- allow_not_found=True,
235
+ existed = await kv_delete(
236
+ self._client, self._client.get_namespace(opts.namespace), key, opts
163
237
  )
238
+ if existed is None:
239
+ raise IncompleteResponseError("kv.delete answer has no existed outside a transaction")
240
+ return existed
164
241
 
165
- async def scan(
242
+ async def mget(
166
243
  self,
167
- prefix: str | bytes,
168
- options: ScanOptions | None = None,
169
- ) -> ScanResult:
170
- """Scan keys with a prefix.
244
+ keys: list[str | bytes],
245
+ options: KVMGetOptions | None = None,
246
+ ) -> list[MGetEntry]:
247
+ """Look up many keys, one entry per key in the order asked.
248
+
249
+ Keys are split across requests so each fits one request frame, and
250
+ the server answers as many keys as fit one answer; this asks again
251
+ from where it stopped until every key is answered.
171
252
 
172
253
  Args:
173
- prefix: Key prefix to scan.
174
- options: Optional scan options (cursor, limit, keys_only).
254
+ keys: Keys to fetch (max 256). Empty list returns ``[]``.
255
+ options: Optional namespace override, and the routing key every
256
+ key was written with (all are then looked up on its shard).
175
257
 
176
258
  Returns:
177
- ScanResult with entries, cursor, and has_more flag.
259
+ List of :class:`MGetEntry`. ``found=False`` indicates a missing key.
178
260
 
179
261
  Example:
180
- # Scan all users
181
- result = await client.kv.scan("user:")
182
- for entry in result.entries:
183
- print(f"{entry.key}: {entry.value}")
184
-
185
- # Paginated scan
186
- result = await client.kv.scan("user:", ScanOptions(limit=100))
187
- while result.has_more:
188
- result = await client.kv.scan("user:", ScanOptions(cursor=result.cursor))
189
-
190
- # Keys only (more efficient)
191
- result = await client.kv.scan("user:", ScanOptions(keys_only=True))
262
+ for e in await client.kv.mget(["user:1", "user:2", "user:3"]):
263
+ if e.found:
264
+ print(e.key, "@v", e.version, ":", e.value)
192
265
  """
193
- opts = options or ScanOptions()
266
+ if not keys:
267
+ return []
268
+ opts = options or KVMGetOptions()
194
269
  namespace = self._client.get_namespace(opts.namespace)
270
+ encoded = [_key(k) for k in keys]
271
+ names = [k if isinstance(k, str) else k.decode("utf-8", errors="replace") for k in keys]
272
+ encode_mget_body(encoded[:1], opts.routing_key) # refuses a bad routing key up front
273
+ out: list[MGetEntry] = []
274
+ for run in split_mget_keys(encoded):
275
+ done = 0
276
+ while done < len(run):
277
+ body = encode_mget_body(run[done:], opts.routing_key)
278
+ response = await self._client._send_and_check(OpCode.KV_MGET, namespace, b"", body)
279
+ entries, next_index = decode_mget_answer(
280
+ response.data, names[len(out) : len(out) + len(run) - done]
281
+ )
282
+ if next_index == 0:
283
+ raise IncompleteResponseError("kv.mget answered none of the keys asked")
284
+ out.extend(entries)
285
+ done += len(entries)
286
+ return out
195
287
 
196
- prefix_bytes = prefix.encode("utf-8") if isinstance(prefix, str) else prefix
197
-
198
- # Build TLV options (keys_only only — limit is in value now)
199
- builder = OptionsBuilder()
200
-
201
- if opts.keys_only:
202
- builder.add_u8(OptionTag.KEYS_ONLY, 1)
288
+ async def scan(
289
+ self,
290
+ prefix: str | bytes = "",
291
+ options: ScanOptions | None = None,
292
+ ) -> ScanResult:
293
+ """List the names of keys with a prefix ("" for all), a page at a time.
203
294
 
204
- # Value: [limit:u32][cursor...]
205
- limit = opts.limit if opts.limit is not None else 0 # 0 = server default
206
- cursor = opts.cursor if opts.cursor is not None else b""
207
- value = struct.pack("<I", limit) + cursor
295
+ Example:
296
+ result = await client.kv.scan("user:")
297
+ for name in result.names:
298
+ print(name)
208
299
 
300
+ # Next pages
301
+ while result.cursor is not None:
302
+ result = await client.kv.scan("user:", ScanOptions(cursor=result.cursor))
303
+ """
304
+ opts = options or ScanOptions()
209
305
  response = await self._client._send_and_check(
210
306
  OpCode.KV_SCAN,
211
- namespace,
212
- prefix_bytes,
213
- value,
214
- builder.build(),
307
+ self._client.get_namespace(opts.namespace),
308
+ _key(prefix),
309
+ encode_scan_body(opts.limit, opts.cursor),
215
310
  )
216
-
217
- return parse_scan_response(response.data)
311
+ names, cursor = decode_scan_answer(response.data)
312
+ return ScanResult(names=names, cursor=cursor)
218
313
 
219
314
  async def history(
220
315
  self,
221
316
  key: str | bytes,
222
317
  options: HistoryOptions | None = None,
223
318
  ) -> list[VersionEntry]:
224
- """Get version history for a key.
225
-
226
- Args:
227
- key: The key to get history for.
228
- options: Optional history options (limit).
229
-
230
- Returns:
231
- List of VersionEntry with version, timestamp, and value.
319
+ """A key's versions, newest first, each with when it was written; empty
320
+ for a missing key.
232
321
 
233
322
  Example:
234
- history = await client.kv.history("user:123", HistoryOptions(limit=10))
235
- for entry in history:
236
- print(f"v{entry.version}: {entry.value} at {entry.timestamp}")
323
+ for entry in await client.kv.history("user:123", HistoryOptions(limit=10)):
324
+ print(f"v{entry.version} at {entry.at_ms}: {entry.value}")
237
325
  """
238
326
  opts = options or HistoryOptions()
239
- namespace = self._client.get_namespace(opts.namespace)
327
+ response = await self._client._send_and_check(
328
+ OpCode.KV_HISTORY,
329
+ self._client.get_namespace(opts.namespace),
330
+ _key(key),
331
+ encode_history_body(opts.limit, opts.routing_key),
332
+ allow_not_found=True,
333
+ )
334
+ if _absent(response):
335
+ return []
336
+ return decode_history_answer(response.data)
240
337
 
241
- key_bytes = key.encode("utf-8") if isinstance(key, str) else key
338
+ # ── Counters, TTL, exists, JSON paths ──────────────────────────────
242
339
 
243
- # Build TLV options
244
- builder = OptionsBuilder()
340
+ async def incr(
341
+ self,
342
+ key: str | bytes,
343
+ options: KVIncrOptions | None = None,
344
+ ) -> IncrResult:
345
+ """Atomically add ``delta`` (default +1) to the i64 counter at ``key``.
245
346
 
246
- if opts.limit is not None:
247
- builder.add_u32(OptionTag.LIMIT, opts.limit)
347
+ The first ``incr`` on a missing key creates it at the delta value. A
348
+ key that holds something else raises with reason WRONG_TYPE.
349
+ """
350
+ opts = options or KVIncrOptions()
351
+ version, value = await kv_incr(
352
+ self._client, self._client.get_namespace(opts.namespace), key, opts
353
+ )
354
+ if version is None or value is None:
355
+ raise IncompleteResponseError("kv.incr answer has no value outside a transaction")
356
+ return IncrResult(value=value, version=version)
248
357
 
358
+ async def expire(
359
+ self,
360
+ key: str | bytes,
361
+ ttl_ms: int,
362
+ options: KVExpireOptions | None = None,
363
+ ) -> None:
364
+ """Set the TTL on an existing key, in milliseconds; ``ttl_ms=0`` clears it.
365
+
366
+ With ``cas_version`` it only succeeds when the key's version is that,
367
+ for race-free lease renewal.
368
+ """
369
+ opts = options or KVExpireOptions()
370
+ await kv_expire(self._client, self._client.get_namespace(opts.namespace), key, ttl_ms, opts)
371
+
372
+ async def exists(
373
+ self,
374
+ key: str | bytes,
375
+ options: KVExistsOptions | None = None,
376
+ ) -> bool:
377
+ """Return ``True`` if ``key`` is present without transferring its value."""
378
+ opts = options or KVExistsOptions()
379
+ return await kv_exists(self._client, self._client.get_namespace(opts.namespace), key, opts)
380
+
381
+ async def json_get(
382
+ self,
383
+ key: str | bytes,
384
+ path: str = "$",
385
+ options: KVJsonOptions | None = None,
386
+ ) -> GetJsonResult[Any] | None:
387
+ """Extract the value at ``path`` from the JSON document at ``key``.
388
+
389
+ Returns a :class:`GetJsonResult` whose ``value`` is the parsed JSON,
390
+ with the document's current version, or ``None`` if the key or path
391
+ is missing.
392
+ """
393
+ opts = options or KVJsonOptions()
249
394
  response = await self._client._send_and_check(
250
- OpCode.KV_HISTORY,
251
- namespace,
252
- key_bytes,
253
- b"",
254
- builder.build(),
395
+ OpCode.KV_JSON_GET,
396
+ self._client.get_namespace(opts.namespace),
397
+ _key(key),
398
+ encode_json_get_body(path, opts.routing_key),
399
+ allow_not_found=True,
400
+ )
401
+ if _absent(response):
402
+ return None
403
+ version, document = decode_json_get_answer(response.data)
404
+ return GetJsonResult(value=json.loads(document.decode("utf-8")), version=version)
405
+
406
+ async def json_set(
407
+ self,
408
+ key: str | bytes,
409
+ path: str,
410
+ json_value: bytes,
411
+ options: KVJsonOptions | None = None,
412
+ ) -> PutResult:
413
+ """Set the JSON value at ``path`` ("$" when empty) inside the document at ``key``.
414
+
415
+ Path ``"$"`` replaces the whole document (and creates the key if missing).
416
+ Sub-paths require the key to already exist. Returns the new version.
417
+ """
418
+ opts = options or KVJsonOptions()
419
+ response = await self._client._send_and_check(
420
+ OpCode.KV_JSON_SET,
421
+ self._client.get_namespace(opts.namespace),
422
+ _key(key),
423
+ encode_json_set_body(path, json_value, opts.routing_key),
255
424
  )
425
+ return PutResult(version=decode_json_set_answer(response.data))
426
+
427
+ async def json_del(
428
+ self,
429
+ key: str | bytes,
430
+ path: str = "$",
431
+ options: KVJsonOptions | None = None,
432
+ ) -> PutResult | None:
433
+ """Remove the value at ``path`` from the JSON document at ``key``.
434
+
435
+ Returns the document's new version, or ``None`` for ``"$"``, which
436
+ deletes the key.
437
+ """
438
+ opts = options or KVJsonOptions()
439
+ response = await self._client._send_and_check(
440
+ OpCode.KV_JSON_DEL,
441
+ self._client.get_namespace(opts.namespace),
442
+ _key(key),
443
+ encode_json_del_body(path, opts.routing_key),
444
+ )
445
+ version = decode_json_del_answer(response.data)
446
+ return None if version is None else PutResult(version=version)
447
+
448
+ async def begin(self, routing_key: str) -> "Transaction":
449
+ """Open a per-shard KV transaction pinned to ``routing_key``'s partition.
450
+
451
+ Every op inside the transaction is routed by ``routing_key``; the
452
+ server refuses one routed otherwise with reason WRONG_ROUTING_KEY.
453
+
454
+ Returns a :class:`Transaction` handle. Use as an async context manager
455
+ for automatic rollback on exception::
456
+
457
+ async with await client.kv.begin("user:123") as txn:
458
+ await txn.put("user:123:name", b"Jane")
459
+ await txn.incr("user:123:visits", 1)
460
+ await txn.commit()
461
+ """
462
+ from .kv_txn import begin as _begin
256
463
 
257
- return parse_history_response(response.data)
464
+ namespace = self._client.get_namespace(None)
465
+ return await _begin(self._client, namespace, routing_key)