flo-python 0.1.0.dev4__py3-none-any.whl → 0.1.1__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/__init__.py CHANGED
@@ -60,6 +60,7 @@ from .exceptions import (
60
60
  ValueTooLargeError,
61
61
  is_connection_error,
62
62
  )
63
+ from .kv_txn import Transaction, TxnFinishedError, TxnUnsupportedOpError
63
64
  from .processing import ProcessingOperations
64
65
  from .types import (
65
66
  AckOptions,
@@ -79,14 +80,25 @@ from .types import (
79
80
  DlqListOptions,
80
81
  DlqRequeueOptions,
81
82
  EnqueueOptions,
83
+ GetJsonResult,
82
84
  GetOptions,
85
+ GetResult,
83
86
  HistoryOptions,
87
+ KVBeginResult,
88
+ KVCommitResult,
84
89
  KVEntry,
90
+ KVExistsOptions,
91
+ KVIncrOptions,
92
+ KVJsonOptions,
93
+ KVMGetOptions,
94
+ KVTouchOptions,
85
95
  Message,
96
+ MGetEntry,
86
97
  NackOptions,
87
98
  OpCode,
88
99
  OptionTag,
89
100
  PeekOptions,
101
+ PendingEntry,
90
102
  ProcessingCancelOptions,
91
103
  ProcessingListEntry,
92
104
  ProcessingListOptions,
@@ -100,12 +112,14 @@ from .types import (
100
112
  ProcessingSyncOptions,
101
113
  ProcessingSyncResult,
102
114
  PutOptions,
115
+ PutResult,
103
116
  ScanOptions,
104
117
  ScanResult,
105
118
  StatusCode,
106
119
  StorageTier,
107
120
  StreamAppendOptions,
108
121
  StreamAppendResult,
122
+ StreamClaimResult,
109
123
  StreamGroupAckOptions,
110
124
  StreamGroupJoinOptions,
111
125
  StreamGroupNackOptions,
@@ -156,9 +170,23 @@ from .worker import (
156
170
  )
157
171
  from .workflows import WorkflowOperations
158
172
 
159
- __version__ = "0.1.0"
173
+ __version__ = "0.1.1"
160
174
 
161
175
  __all__ = [
176
+ "Transaction",
177
+ "TxnFinishedError",
178
+ "TxnUnsupportedOpError",
179
+ "KVBeginResult",
180
+ "KVCommitResult",
181
+ "KVExistsOptions",
182
+ "KVIncrOptions",
183
+ "KVJsonOptions",
184
+ "KVMGetOptions",
185
+ "KVTouchOptions",
186
+ "GetResult",
187
+ "GetJsonResult",
188
+ "MGetEntry",
189
+ "PutResult",
162
190
  # Client
163
191
  "FloClient",
164
192
  # High-level Worker API
@@ -229,6 +257,8 @@ __all__ = [
229
257
  "StreamRecord",
230
258
  "StreamAppendResult",
231
259
  "StreamReadResult",
260
+ "StreamClaimResult",
261
+ "PendingEntry",
232
262
  "StreamInfo",
233
263
  # Stream options
234
264
  "StreamAppendOptions",
flo/client.py CHANGED
@@ -83,7 +83,27 @@ class FloClient:
83
83
 
84
84
  @staticmethod
85
85
  def _parse_endpoint(endpoint: str) -> tuple[str, int]:
86
- """Parse endpoint string into host and port."""
86
+ """Parse endpoint string into host and port.
87
+
88
+ Accepts an optional URI scheme: ``flo://`` (plaintext) is stripped and
89
+ bare ``host:port`` remains valid. ``flos://`` (TLS) is reserved but not
90
+ yet implemented, and any other scheme is rejected so it fails here
91
+ rather than as a confusing DNS error at connect time.
92
+ """
93
+ if "://" in endpoint:
94
+ scheme, _, rest = endpoint.partition("://")
95
+ scheme = scheme.lower()
96
+ if scheme == "flo":
97
+ # Drop scheme and any trailing path/query (reserved for future use).
98
+ endpoint = rest.split("/", 1)[0]
99
+ elif scheme == "flos":
100
+ raise InvalidEndpointError(f"flos:// (TLS) is not yet supported: {endpoint}")
101
+ else:
102
+ raise InvalidEndpointError(
103
+ f"Unsupported scheme {scheme}:// (expected flo:// or bare "
104
+ f"host:port): {endpoint}"
105
+ )
106
+
87
107
  parts = endpoint.rsplit(":", 1)
88
108
  if len(parts) != 2:
89
109
  raise InvalidEndpointError(f"Invalid endpoint format: {endpoint} (expected host:port)")
@@ -402,6 +422,8 @@ class FloClient:
402
422
  batch_size: int = 10,
403
423
  block_ms: int = 30000,
404
424
  message_timeout: float = 300.0,
425
+ redeliver_pending_on_reconnect: bool = True,
426
+ claim_min_idle_ms: int = 0,
405
427
  ) -> "StreamWorker":
406
428
  """Create a new StreamWorker from this client.
407
429
 
@@ -419,6 +441,14 @@ class FloClient:
419
441
  batch_size: Number of records to fetch per poll.
420
442
  block_ms: Timeout for blocking read in milliseconds.
421
443
  message_timeout: Timeout for record handlers in seconds.
444
+ redeliver_pending_on_reconnect: Drain this consumer's pending
445
+ (delivered-but-unacked) entries via group_claim after a
446
+ reconnect, before resuming normal reads. Defaults to True
447
+ (required for at-least-once across reconnects). Set False for
448
+ at-most-once.
449
+ claim_min_idle_ms: Minimum idle time (ms) before the reconnect drain
450
+ claims an entry. 0 drains everything this consumer owns; > 0
451
+ also steals entries abandoned by dead consumers.
422
452
 
423
453
  Returns:
424
454
  A new StreamWorker instance ready to start.
@@ -446,4 +476,6 @@ class FloClient:
446
476
  batch_size=batch_size,
447
477
  block_ms=block_ms,
448
478
  message_timeout=message_timeout,
479
+ redeliver_pending_on_reconnect=redeliver_pending_on_reconnect,
480
+ claim_min_idle_ms=claim_min_idle_ms,
449
481
  )
flo/kv.py CHANGED
@@ -3,16 +3,26 @@
3
3
  Key-value store operations for Flo client.
4
4
  """
5
5
 
6
+ import json
6
7
  import struct
7
- from typing import TYPE_CHECKING
8
+ from typing import TYPE_CHECKING, Any
8
9
 
9
10
  from .types import (
10
11
  DeleteOptions,
12
+ GetJsonResult,
11
13
  GetOptions,
14
+ GetResult,
12
15
  HistoryOptions,
16
+ KVExistsOptions,
17
+ KVIncrOptions,
18
+ KVJsonOptions,
19
+ KVMGetOptions,
20
+ KVTouchOptions,
21
+ MGetEntry,
13
22
  OpCode,
14
23
  OptionTag,
15
24
  PutOptions,
25
+ PutResult,
16
26
  ScanOptions,
17
27
  ScanResult,
18
28
  VersionEntry,
@@ -21,6 +31,7 @@ from .wire import OptionsBuilder, parse_history_response, parse_scan_response
21
31
 
22
32
  if TYPE_CHECKING:
23
33
  from .client import FloClient
34
+ from .kv_txn import Transaction
24
35
 
25
36
 
26
37
  class KVOperations:
@@ -33,36 +44,30 @@ class KVOperations:
33
44
  self,
34
45
  key: str | bytes,
35
46
  options: GetOptions | None = None,
36
- ) -> bytes | None:
37
- """Get value for a key.
47
+ ) -> GetResult | None:
48
+ """Get value and version for a key.
38
49
 
39
50
  Args:
40
51
  key: The key to retrieve.
41
52
  options: Optional operation options (block_ms for blocking get).
42
53
 
43
54
  Returns:
44
- Value bytes if found, None if not found.
55
+ ``GetResult(value, version)`` if found, ``None`` if not found.
45
56
 
46
57
  Example:
47
- # Simple get
48
- value = await client.kv.get("user:123")
49
- if value is not None:
50
- print(f"Found: {value.decode()}")
58
+ result = await client.kv.get("user:123")
59
+ if result is not None:
60
+ print(f"Found {result.value!r} at version {result.version}")
51
61
 
52
- # Blocking get - wait up to 5 seconds for value to appear
53
- value = await client.kv.get("key", GetOptions(block_ms=5000))
54
-
55
- # Blocking get - wait indefinitely (0 = infinite)
56
- value = await client.kv.get("key", GetOptions(block_ms=0))
62
+ # Blocking get — wait up to 5 seconds
63
+ result = await client.kv.get("key", GetOptions(block_ms=5000))
57
64
  """
58
65
  opts = options or GetOptions()
59
66
  namespace = self._client.get_namespace(opts.namespace)
60
67
 
61
68
  key_bytes = key.encode("utf-8") if isinstance(key, str) else key
62
69
 
63
- # Build TLV options
64
70
  builder = OptionsBuilder()
65
-
66
71
  if opts.block_ms is not None:
67
72
  builder.add_u32(OptionTag.BLOCK_MS, opts.block_ms)
68
73
 
@@ -78,52 +83,51 @@ class KVOperations:
78
83
  if response.is_not_found():
79
84
  return None
80
85
 
81
- return response.data if response.data else None
86
+ # Wire body: [version:u64 LE][value bytes]
87
+ if not response.data or len(response.data) < 8:
88
+ return GetResult(value=b"", version=0)
89
+ version = struct.unpack("<Q", response.data[:8])[0]
90
+ return GetResult(value=bytes(response.data[8:]), version=version)
82
91
 
83
92
  async def put(
84
93
  self,
85
94
  key: str | bytes,
86
95
  value: bytes,
87
96
  options: PutOptions | None = None,
88
- ) -> None:
89
- """Set a key-value pair.
97
+ ) -> PutResult:
98
+ """Set a key-value pair and return the new version.
90
99
 
91
100
  Args:
92
101
  key: The key to set.
93
102
  value: The value bytes.
94
103
  options: Optional operation options (TTL, CAS, etc.).
95
104
 
96
- 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))
105
+ Returns:
106
+ ``PutResult(version)`` — the version assigned by the server,
107
+ usable for CAS on the next write.
102
108
 
103
- # Put with CAS (optimistic locking)
104
- await client.kv.put("counter", b"2", PutOptions(cas_version=1))
109
+ Example:
110
+ res = await client.kv.put("user:123", b"John Doe")
111
+ # CAS the next write against this version:
112
+ await client.kv.put("user:123", b"Jane",
113
+ PutOptions(cas_version=res.version))
105
114
  """
106
115
  opts = options or PutOptions()
107
116
  namespace = self._client.get_namespace(opts.namespace)
108
117
 
109
118
  key_bytes = key.encode("utf-8") if isinstance(key, str) else key
110
119
 
111
- # Build TLV options
112
120
  builder = OptionsBuilder()
113
-
114
121
  if opts.ttl_seconds is not None:
115
122
  builder.add_u64(OptionTag.TTL_SECONDS, opts.ttl_seconds)
116
-
117
123
  if opts.cas_version is not None:
118
124
  builder.add_u64(OptionTag.CAS_VERSION, opts.cas_version)
119
-
120
125
  if opts.if_not_exists:
121
126
  builder.add_flag(OptionTag.IF_NOT_EXISTS)
122
-
123
127
  if opts.if_exists:
124
128
  builder.add_flag(OptionTag.IF_EXISTS)
125
129
 
126
- await self._client._send_and_check(
130
+ response = await self._client._send_and_check(
127
131
  OpCode.KV_PUT,
128
132
  namespace,
129
133
  key_bytes,
@@ -131,6 +135,11 @@ class KVOperations:
131
135
  builder.build(),
132
136
  )
133
137
 
138
+ # Wire body: [version:u64 LE]
139
+ if not response.data or len(response.data) < 8:
140
+ return PutResult(version=0)
141
+ return PutResult(version=struct.unpack("<Q", response.data[:8])[0])
142
+
134
143
  async def delete(
135
144
  self,
136
145
  key: str | bytes,
@@ -143,25 +152,117 @@ class KVOperations:
143
152
  options: Optional operation options.
144
153
 
145
154
  Note:
146
- This operation succeeds even if the key doesn't exist.
155
+ This operation succeeds even if the key doesn't exist (unless
156
+ ``if_match`` is set, in which case a missing key is treated as a
157
+ CAS mismatch).
147
158
 
148
159
  Example:
149
160
  await client.kv.delete("user:123")
161
+ # CAS-protected release — only the owner deletes:
162
+ await client.kv.delete("lock:resource",
163
+ DeleteOptions(if_match=tag_version))
150
164
  """
151
165
  opts = options or DeleteOptions()
152
166
  namespace = self._client.get_namespace(opts.namespace)
153
167
 
154
168
  key_bytes = key.encode("utf-8") if isinstance(key, str) else key
155
169
 
156
- # Delete succeeds for both OK and NOT_FOUND
170
+ builder = OptionsBuilder()
171
+ if opts.if_match is not None:
172
+ builder.add_u64(OptionTag.CAS_VERSION, opts.if_match)
173
+
174
+ # Delete succeeds for both OK and NOT_FOUND (when if_match is unset).
157
175
  await self._client._send_and_check(
158
176
  OpCode.KV_DELETE,
159
177
  namespace,
160
178
  key_bytes,
161
179
  b"",
162
- allow_not_found=True,
180
+ builder.build(),
181
+ allow_not_found=opts.if_match is None,
182
+ )
183
+
184
+ async def mget(
185
+ self,
186
+ keys: list[str | bytes],
187
+ options: KVMGetOptions | None = None,
188
+ ) -> list[MGetEntry]:
189
+ """Look up many keys in a single round trip.
190
+
191
+ Keys may live on different shards — the server gathers results in
192
+ parallel and returns one entry per requested key in the same order.
193
+
194
+ Args:
195
+ keys: Keys to fetch (max 256). Empty list returns ``[]``.
196
+ options: Optional namespace override.
197
+
198
+ Returns:
199
+ List of :class:`MGetEntry`. ``found=False`` indicates a missing key.
200
+
201
+ Example:
202
+ for e in await client.kv.mget(["user:1", "user:2", "user:3"]):
203
+ if e.found:
204
+ print(e.key, "@v", e.version, ":", e.value)
205
+ """
206
+ if not keys:
207
+ return []
208
+ if len(keys) > 256:
209
+ raise ValueError("mget: too many keys (max 256)")
210
+ opts = options or KVMGetOptions()
211
+ namespace = self._client.get_namespace(opts.namespace)
212
+
213
+ # Pack request: [count:u16 LE]([key_len:u16 LE][key])*
214
+ encoded: list[bytes] = [(k.encode("utf-8") if isinstance(k, str) else k) for k in keys]
215
+ for k in encoded:
216
+ if len(k) > 0xFFFF:
217
+ raise ValueError("mget: key too long")
218
+ parts: list[bytes] = [struct.pack("<H", len(encoded))]
219
+ for k in encoded:
220
+ parts.append(struct.pack("<H", len(k)))
221
+ parts.append(k)
222
+ value = b"".join(parts)
223
+
224
+ response = await self._client._send_and_check(
225
+ OpCode.KV_MGET,
226
+ namespace,
227
+ b"",
228
+ value,
163
229
  )
164
230
 
231
+ data = bytes(response.data or b"")
232
+ if len(data) < 4:
233
+ return []
234
+ (count,) = struct.unpack("<I", data[:4])
235
+ out: list[MGetEntry] = []
236
+ off = 4
237
+ for _ in range(count):
238
+ if off + 1 + 2 > len(data):
239
+ break
240
+ status = data[off]
241
+ off += 1
242
+ (klen,) = struct.unpack("<H", data[off : off + 2])
243
+ off += 2
244
+ if off + klen + 8 + 4 > len(data):
245
+ break
246
+ key_bytes = data[off : off + klen]
247
+ off += klen
248
+ (version,) = struct.unpack("<Q", data[off : off + 8])
249
+ off += 8
250
+ (vlen,) = struct.unpack("<I", data[off : off + 4])
251
+ off += 4
252
+ if off + vlen > len(data):
253
+ break
254
+ val = data[off : off + vlen]
255
+ off += vlen
256
+ out.append(
257
+ MGetEntry(
258
+ key=key_bytes.decode("utf-8", errors="replace"),
259
+ value=bytes(val),
260
+ version=version,
261
+ found=status == 0,
262
+ )
263
+ )
264
+ return out
265
+
165
266
  async def scan(
166
267
  self,
167
268
  prefix: str | bytes,
@@ -255,3 +356,227 @@ class KVOperations:
255
356
  )
256
357
 
257
358
  return parse_history_response(response.data)
359
+
360
+ # ── Extended ops: counters, TTL lifecycle, exists, JSON paths ──────
361
+
362
+ async def incr(
363
+ self,
364
+ key: str | bytes,
365
+ options: KVIncrOptions | None = None,
366
+ ) -> int:
367
+ """Atomically add ``delta`` (default +1) to the i64 counter at ``key``.
368
+
369
+ The first ``incr`` on a missing key creates it at the delta value.
370
+ Raises a server error if the key already holds a non-counter value.
371
+
372
+ Returns:
373
+ The new counter value as a Python int.
374
+ """
375
+ opts = options or KVIncrOptions()
376
+ namespace = self._client.get_namespace(opts.namespace)
377
+ key_bytes = key.encode("utf-8") if isinstance(key, str) else key
378
+
379
+ value = b""
380
+ if opts.delta is not None:
381
+ value = struct.pack("<q", opts.delta)
382
+
383
+ response = await self._client._send_and_check(
384
+ OpCode.KV_INCR,
385
+ namespace,
386
+ key_bytes,
387
+ value,
388
+ b"",
389
+ )
390
+ # Wire body: [version:u64][counter:i64 LE]
391
+ if not response.data or len(response.data) < 16:
392
+ raise ValueError("incr: short response")
393
+ return int(struct.unpack("<q", response.data[8:16])[0])
394
+
395
+ async def touch(
396
+ self,
397
+ key: str | bytes,
398
+ ttl_seconds: int,
399
+ options: KVTouchOptions | None = None,
400
+ ) -> None:
401
+ """Update the TTL on an existing key. ``ttl_seconds=0`` clears the TTL.
402
+
403
+ When ``options.if_match`` is set, the touch only succeeds if the
404
+ current key version equals it — enabling race-free lease renewal.
405
+ """
406
+ opts = options or KVTouchOptions()
407
+ namespace = self._client.get_namespace(opts.namespace)
408
+ key_bytes = key.encode("utf-8") if isinstance(key, str) else key
409
+ builder = OptionsBuilder()
410
+ if opts.if_match is not None:
411
+ builder.add_u64(OptionTag.CAS_VERSION, opts.if_match)
412
+ await self._client._send_and_check(
413
+ OpCode.KV_TOUCH,
414
+ namespace,
415
+ key_bytes,
416
+ struct.pack("<Q", ttl_seconds),
417
+ builder.build(),
418
+ )
419
+
420
+ async def persist(
421
+ self,
422
+ key: str | bytes,
423
+ options: KVTouchOptions | None = None,
424
+ ) -> None:
425
+ """Clear the TTL on an existing key, making it permanent.
426
+
427
+ When ``options.if_match`` is set, the persist only succeeds if the
428
+ current key version equals it.
429
+ """
430
+ opts = options or KVTouchOptions()
431
+ namespace = self._client.get_namespace(opts.namespace)
432
+ key_bytes = key.encode("utf-8") if isinstance(key, str) else key
433
+ builder = OptionsBuilder()
434
+ if opts.if_match is not None:
435
+ builder.add_u64(OptionTag.CAS_VERSION, opts.if_match)
436
+ await self._client._send_and_check(
437
+ OpCode.KV_PERSIST,
438
+ namespace,
439
+ key_bytes,
440
+ b"",
441
+ builder.build(),
442
+ )
443
+
444
+ async def exists(
445
+ self,
446
+ key: str | bytes,
447
+ options: KVExistsOptions | None = None,
448
+ ) -> bool:
449
+ """Return ``True`` if ``key`` is present without transferring its value."""
450
+ opts = options or KVExistsOptions()
451
+ namespace = self._client.get_namespace(opts.namespace)
452
+ key_bytes = key.encode("utf-8") if isinstance(key, str) else key
453
+ response = await self._client._send_and_check(
454
+ OpCode.KV_EXISTS,
455
+ namespace,
456
+ key_bytes,
457
+ b"",
458
+ b"",
459
+ )
460
+ # Wire body: [version:u64][1 byte 0/1]
461
+ if not response.data or len(response.data) < 9:
462
+ return False
463
+ return response.data[8] == 1
464
+
465
+ async def json_get(
466
+ self,
467
+ key: str | bytes,
468
+ path: str = "$",
469
+ options: KVJsonOptions | None = None,
470
+ ) -> GetJsonResult[Any] | None:
471
+ """Extract the value at ``path`` from the JSON document at ``key``.
472
+
473
+ Returns a :class:`GetJsonResult` whose ``value`` is the already-parsed
474
+ JSON (the ``KV_JSON_GET`` wire body is always a JSON document, so it is
475
+ decoded for you) together with the document's current version, or
476
+ ``None`` if the key or path is missing.
477
+ """
478
+ opts = options or KVJsonOptions()
479
+ namespace = self._client.get_namespace(opts.namespace)
480
+ key_bytes = key.encode("utf-8") if isinstance(key, str) else key
481
+ path_bytes = path.encode("utf-8") if isinstance(path, str) else path
482
+
483
+ response = await self._client._send_and_check(
484
+ OpCode.KV_JSON_GET,
485
+ namespace,
486
+ key_bytes,
487
+ path_bytes,
488
+ b"",
489
+ allow_not_found=True,
490
+ )
491
+ if response.is_not_found():
492
+ return None
493
+ # Wire body: [version:u64 LE][json bytes]
494
+ if not response.data or len(response.data) < 8:
495
+ return None
496
+ version = int.from_bytes(response.data[:8], "little")
497
+ value = json.loads(bytes(response.data[8:]).decode("utf-8"))
498
+ return GetJsonResult(value=value, version=version)
499
+
500
+ async def json_set(
501
+ self,
502
+ key: str | bytes,
503
+ path: str,
504
+ json_value: bytes,
505
+ options: KVJsonOptions | None = None,
506
+ ) -> PutResult:
507
+ """Set the JSON value at ``path`` inside the document at ``key``.
508
+
509
+ Path ``"$"`` replaces the whole document (and creates the key if missing).
510
+ Sub-paths require the key to already exist. Returns a :class:`PutResult`
511
+ with the new document version.
512
+ """
513
+ opts = options or KVJsonOptions()
514
+ namespace = self._client.get_namespace(opts.namespace)
515
+ key_bytes = key.encode("utf-8") if isinstance(key, str) else key
516
+ path_str = path or "$"
517
+ path_bytes = path_str.encode("utf-8")
518
+ if len(path_bytes) > 0xFFFF:
519
+ raise ValueError("json_set: path too long")
520
+ value = struct.pack("<H", len(path_bytes)) + path_bytes + json_value
521
+ response = await self._client._send_and_check(
522
+ OpCode.KV_JSON_SET,
523
+ namespace,
524
+ key_bytes,
525
+ value,
526
+ b"",
527
+ )
528
+ version = (
529
+ int.from_bytes(response.data[:8], "little")
530
+ if response.data and len(response.data) >= 8
531
+ else 0
532
+ )
533
+ return PutResult(version=version)
534
+
535
+ async def json_del(
536
+ self,
537
+ key: str | bytes,
538
+ path: str = "$",
539
+ options: KVJsonOptions | None = None,
540
+ ) -> PutResult:
541
+ """Remove the value at ``path`` from the JSON document at ``key``.
542
+
543
+ For sub-paths the returned :class:`PutResult` carries the new document
544
+ version. For ``"$"`` (whole document delete) the version is ``0`` since
545
+ the key is gone.
546
+ """
547
+ opts = options or KVJsonOptions()
548
+ namespace = self._client.get_namespace(opts.namespace)
549
+ key_bytes = key.encode("utf-8") if isinstance(key, str) else key
550
+ path_bytes = (path or "$").encode("utf-8")
551
+ response = await self._client._send_and_check(
552
+ OpCode.KV_JSON_DEL,
553
+ namespace,
554
+ key_bytes,
555
+ path_bytes,
556
+ b"",
557
+ )
558
+ version = (
559
+ int.from_bytes(response.data[:8], "little")
560
+ if response.data and len(response.data) >= 8
561
+ else 0
562
+ )
563
+ return PutResult(version=version)
564
+
565
+ async def begin(self, routing_key: str) -> "Transaction":
566
+ """Open a per-shard KV transaction pinned to ``routing_key``'s partition.
567
+
568
+ Every key written or read inside the transaction must hash to the same
569
+ partition; otherwise the server returns a "kv_txn_cross_shard" error.
570
+
571
+ Returns a :class:`Transaction` handle. Use as an async context manager
572
+ for automatic rollback on exception::
573
+
574
+ async with await client.kv.begin("user:123") as txn:
575
+ await txn.put("user:123:name", b"Jane")
576
+ await txn.incr("user:123:visits", 1)
577
+ await txn.commit()
578
+ """
579
+ from .kv_txn import begin as _begin
580
+
581
+ namespace = self._client.get_namespace(None)
582
+ return await _begin(self._client, namespace, routing_key)