flo-python 0.1.0.dev3__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/types.py CHANGED
@@ -6,7 +6,7 @@ Core types, constants, and data classes for the Flo client SDK.
6
6
  import struct
7
7
  from dataclasses import dataclass
8
8
  from enum import IntEnum
9
- from typing import Optional
9
+ from typing import Any, Generic, Optional, TypeVar
10
10
 
11
11
  # =============================================================================
12
12
  # Protocol Constants
@@ -67,7 +67,7 @@ class OpCode(IntEnum):
67
67
  CLUSTER_MEMBERS_RESPONSE = 0x041
68
68
  CLUSTER_JOIN_RESPONSE = 0x042
69
69
 
70
- # ── KV + Transactions + Snapshots (0x100 – 0x12F) ──
70
+ # ── KV (0x100 – 0x12F) ──
71
71
  KV_PUT = 0x100
72
72
  KV_GET = 0x101
73
73
  KV_MGET = 0x102
@@ -79,13 +79,23 @@ class OpCode(IntEnum):
79
79
  KV_PUT_RESPONSE = 0x108
80
80
  KV_SCAN_RESPONSE = 0x109
81
81
  KV_HISTORY_RESPONSE = 0x10A
82
+ # KV Extended (atomic counters, JSON ops)
83
+ KV_INCR = 0x10B
84
+ KV_JSON_GET = 0x10C
85
+ KV_JSON_SET = 0x10D
86
+ KV_JSON_DEL = 0x10E
87
+ # KV Per-Shard Transactions
82
88
  KV_BEGIN_TXN = 0x110
83
89
  KV_COMMIT_TXN = 0x111
84
90
  KV_ROLLBACK_TXN = 0x112
85
- KV_SNAPSHOT_CREATE = 0x120
86
- KV_SNAPSHOT_GET = 0x121
87
- KV_SNAPSHOT_RELEASE = 0x122
88
- KV_SNAPSHOT_CREATE_RESPONSE = 0x123
91
+ # KV Extended (TTL lifecycle, exists)
92
+ KV_TOUCH = 0x113
93
+ KV_PERSIST = 0x114
94
+ KV_EXISTS = 0x115
95
+ KV_INCR_RESPONSE = 0x116
96
+ KV_JSON_RESPONSE = 0x117
97
+ KV_EXISTS_RESPONSE = 0x118
98
+ KV_TXN_RESPONSE = 0x119
89
99
 
90
100
  # ── Streams (0x130 – 0x14F) ──
91
101
  STREAM_APPEND = 0x130
@@ -285,6 +295,7 @@ class OptionTag(IntEnum):
285
295
  KEYS_ONLY = 0x06 # u8: Skip values in scan response (0/1)
286
296
  CURSOR = 0x07 # bytes: Pagination cursor (ShardWalker format)
287
297
  ROUTING_KEY = 0x08 # string: Explicit routing key for shard co-location
298
+ TXN_ID = 0x09 # u64: Transaction ID for per-shard transactions
288
299
 
289
300
  # Queue Options (0x10 - 0x1F)
290
301
  PRIORITY = 0x10 # u8: Message priority (0-255, higher = more urgent)
@@ -382,6 +393,88 @@ class VersionEntry:
382
393
  value: bytes
383
394
 
384
395
 
396
+ @dataclass
397
+ class PutResult:
398
+ """Result of a successful KV put.
399
+
400
+ The ``version`` field is the new version assigned by the server, suitable
401
+ for CAS on the next write via :class:`PutOptions.cas_version`.
402
+ """
403
+
404
+ version: int
405
+
406
+
407
+ @dataclass
408
+ class KVBeginResult:
409
+ """Result of a successful KV transaction begin.
410
+
411
+ ``txn_id`` is the server-assigned transaction handle. ``pinned_hash`` is
412
+ the partition hash this transaction is bound to — every key written or
413
+ read inside the transaction must hash to the same partition.
414
+ """
415
+
416
+ txn_id: int
417
+ pinned_hash: int
418
+
419
+
420
+ @dataclass
421
+ class KVCommitResult:
422
+ """Result of a successful KV transaction commit.
423
+
424
+ ``commit_index`` is the Raft log index of the committed batch and
425
+ ``op_count`` is the number of buffered operations applied atomically.
426
+ """
427
+
428
+ commit_index: int
429
+ op_count: int
430
+
431
+
432
+ @dataclass
433
+ class GetResult:
434
+ """Result of a KV get that found a key.
435
+
436
+ ``kv.get`` returns ``None`` when the key is missing; check for ``None``
437
+ before dereferencing.
438
+ """
439
+
440
+ value: bytes
441
+ version: int
442
+
443
+
444
+ T = TypeVar("T")
445
+
446
+
447
+ @dataclass
448
+ class GetJsonResult(Generic[T]):
449
+ """Result of a :meth:`KV.json_get` that found the key and path.
450
+
451
+ Unlike :class:`GetResult`, ``value`` is the already-parsed JSON (the
452
+ ``KV_JSON_GET`` wire body is always a JSON document, so it is decoded for
453
+ you). ``version`` is the document's current version. ``kv.json_get``
454
+ returns ``None`` when the key or path is missing.
455
+
456
+ The value type defaults to ``Any``; the SDK does not validate the shape at
457
+ runtime.
458
+ """
459
+
460
+ value: Any
461
+ version: int
462
+
463
+
464
+ @dataclass
465
+ class MGetEntry:
466
+ """One entry in a :meth:`KV.mget` response.
467
+
468
+ ``found`` is ``False`` when the key did not exist; in that case ``value``
469
+ is ``b''`` and ``version`` is ``0``.
470
+ """
471
+
472
+ key: str
473
+ value: bytes
474
+ version: int
475
+ found: bool
476
+
477
+
385
478
  @dataclass
386
479
  class Message:
387
480
  """Queue message."""
@@ -440,6 +533,7 @@ class StreamRecord:
440
533
 
441
534
  id: StreamID = None # type: ignore[assignment]
442
535
  tier: StorageTier = StorageTier.HOT
536
+ stream: str = ""
443
537
  payload: bytes = b""
444
538
  headers: dict[str, str] | None = None
445
539
 
@@ -458,6 +552,35 @@ class StreamReadResult:
458
552
  records: list[StreamRecord]
459
553
 
460
554
 
555
+ @dataclass
556
+ class PendingEntry:
557
+ """One entry in a consumer group's Pending Entry List (PEL).
558
+
559
+ A delivered-but-unacked message. ``delivery_count`` is how many times the
560
+ entry has been delivered; ``consumer`` is the consumer that currently owns
561
+ it. (FLO-102)
562
+ """
563
+
564
+ id: StreamID = None # type: ignore[assignment]
565
+ consumer: str = ""
566
+ delivery_count: int = 0
567
+
568
+
569
+ @dataclass
570
+ class StreamClaimResult:
571
+ """Result of a :meth:`StreamOperations.group_claim` cursor page (FLO-102).
572
+
573
+ ``records`` carry payload + headers (same shape as ``group_read``).
574
+ ``next_cursor`` is the ``start_id`` to pass on the next ``group_claim``
575
+ call; when ``done`` is ``True`` the PEL has been fully scanned and the loop
576
+ should stop.
577
+ """
578
+
579
+ records: list[StreamRecord]
580
+ next_cursor: StreamID = None # type: ignore[assignment]
581
+ done: bool = False
582
+
583
+
461
584
  @dataclass
462
585
  class StreamInfo:
463
586
  """Stream metadata."""
@@ -498,6 +621,7 @@ class DeleteOptions:
498
621
  """Options for KV delete operations."""
499
622
 
500
623
  namespace: str | None = None
624
+ if_match: int | None = None # CAS: only delete when current version equals if_match
501
625
 
502
626
 
503
627
  @dataclass
@@ -518,6 +642,43 @@ class HistoryOptions:
518
642
  limit: int | None = None
519
643
 
520
644
 
645
+ @dataclass
646
+ class KVIncrOptions:
647
+ """Options for KV incr operations."""
648
+
649
+ namespace: str | None = None
650
+ delta: int | None = None # default +1 when None
651
+
652
+
653
+ @dataclass
654
+ class KVTouchOptions:
655
+ """Options for KV touch / persist operations."""
656
+
657
+ namespace: str | None = None
658
+ if_match: int | None = None # CAS: only succeed when current version equals if_match
659
+
660
+
661
+ @dataclass
662
+ class KVExistsOptions:
663
+ """Options for KV exists operations."""
664
+
665
+ namespace: str | None = None
666
+
667
+
668
+ @dataclass
669
+ class KVJsonOptions:
670
+ """Options for KV JSON.* operations."""
671
+
672
+ namespace: str | None = None
673
+
674
+
675
+ @dataclass
676
+ class KVMGetOptions:
677
+ """Options for KV mget operations."""
678
+
679
+ namespace: str | None = None
680
+
681
+
521
682
  @dataclass
522
683
  class EnqueueOptions:
523
684
  """Options for queue enqueue operations."""
@@ -769,6 +930,8 @@ class TaskAssignment:
769
930
  payload: bytes
770
931
  created_at: int
771
932
  attempt: int
933
+ caller_run_id: str = ""
934
+ caller_workflow_name: str = ""
772
935
 
773
936
 
774
937
  # Alias for backwards compatibility
flo/wire.py CHANGED
@@ -30,6 +30,7 @@ from .types import (
30
30
  Message,
31
31
  OpCode,
32
32
  OptionTag,
33
+ PendingEntry,
33
34
  ScanResult,
34
35
  StatusCode,
35
36
  StorageTier,
@@ -597,7 +598,8 @@ def parse_stream_read_response(data: bytes) -> StreamReadResult:
597
598
  key_present = data[pos]
598
599
  pos += 1
599
600
 
600
- # Skip key if present
601
+ # Read key (stream name) if present
602
+ stream_name = ""
601
603
  if key_present != 0:
602
604
  if pos + 4 > len(data):
603
605
  raise IncompleteResponseError("Stream record: missing key length")
@@ -605,6 +607,7 @@ def parse_stream_read_response(data: bytes) -> StreamReadResult:
605
607
  pos += 4
606
608
  if pos + key_len > len(data):
607
609
  raise IncompleteResponseError("Stream record: missing key data")
610
+ stream_name = data[pos : pos + key_len].decode("utf-8")
608
611
  pos += key_len
609
612
 
610
613
  # Read payload
@@ -618,17 +621,41 @@ def parse_stream_read_response(data: bytes) -> StreamReadResult:
618
621
  payload = data[pos : pos + payload_len]
619
622
  pos += payload_len
620
623
 
621
- # Skip header_count (TODO: parse headers)
624
+ # Read headers: [header_count:u32]([key_len:u32][key][val_len:u32][val])*
622
625
  if pos + 4 > len(data):
623
626
  raise IncompleteResponseError("Stream record: missing header count")
627
+ header_count = struct.unpack("<I", data[pos : pos + 4])[0]
624
628
  pos += 4
625
629
 
630
+ headers = None
631
+ if header_count > 0:
632
+ headers = {}
633
+ for _ in range(header_count):
634
+ if pos + 4 > len(data):
635
+ raise IncompleteResponseError("Stream record: missing header key length")
636
+ hkey_len = struct.unpack("<I", data[pos : pos + 4])[0]
637
+ pos += 4
638
+ if pos + hkey_len > len(data):
639
+ raise IncompleteResponseError("Stream record: missing header key")
640
+ hkey = data[pos : pos + hkey_len].decode("utf-8")
641
+ pos += hkey_len
642
+ if pos + 4 > len(data):
643
+ raise IncompleteResponseError("Stream record: missing header value length")
644
+ hval_len = struct.unpack("<I", data[pos : pos + 4])[0]
645
+ pos += 4
646
+ if pos + hval_len > len(data):
647
+ raise IncompleteResponseError("Stream record: missing header value")
648
+ hval = data[pos : pos + hval_len].decode("utf-8")
649
+ pos += hval_len
650
+ headers[hkey] = hval
651
+
626
652
  records.append(
627
653
  StreamRecord(
628
654
  id=StreamID(timestamp_ms=timestamp_ms, sequence=sequence),
629
655
  tier=tier,
656
+ stream=stream_name,
630
657
  payload=payload,
631
- headers=None,
658
+ headers=headers,
632
659
  )
633
660
  )
634
661
 
@@ -661,6 +688,33 @@ def parse_stream_info_response(data: bytes) -> StreamInfo:
661
688
  )
662
689
 
663
690
 
691
+ def build_stream_batch_value(
692
+ payload: bytes,
693
+ headers: dict[str, str] | None = None,
694
+ ) -> bytes:
695
+ """Frame a single record for stream_append (batch of one).
696
+
697
+ Wire format:
698
+ [record_count:u32=1][payload_len:u32][payload]
699
+ [header_count:u16]([key_len:u16][key][val_len:u16][val])*
700
+ """
701
+ hdrs = headers or {}
702
+ parts: list[bytes] = [
703
+ struct.pack("<I", 1),
704
+ struct.pack("<I", len(payload)),
705
+ payload,
706
+ struct.pack("<H", len(hdrs)),
707
+ ]
708
+ for key, val in hdrs.items():
709
+ key_bytes = key.encode("utf-8")
710
+ val_bytes = val.encode("utf-8")
711
+ parts.append(struct.pack("<H", len(key_bytes)))
712
+ parts.append(key_bytes)
713
+ parts.append(struct.pack("<H", len(val_bytes)))
714
+ parts.append(val_bytes)
715
+ return b"".join(parts)
716
+
717
+
664
718
  def serialize_group_value(group: str, consumer: str) -> bytes:
665
719
  """Serialize group and consumer names for consumer group operations.
666
720
 
@@ -697,6 +751,92 @@ def serialize_group_ack_value(group: str, ids: list[StreamID], consumer: str = "
697
751
  return bytes(result)
698
752
 
699
753
 
754
+ def serialize_group_pending_value(group: str, consumer: str = "") -> bytes:
755
+ """Serialize group (and optional consumer filter) for STREAM_GROUP_PENDING.
756
+
757
+ Format: [group_len:u16][group]([consumer_len:u16][consumer])?
758
+
759
+ An empty ``consumer`` returns the whole group's PEL; a non-empty one filters
760
+ to that consumer's entries.
761
+ """
762
+ group_bytes = group.encode("utf-8")
763
+
764
+ result = bytearray()
765
+ result.extend(struct.pack("<H", len(group_bytes)))
766
+ result.extend(group_bytes)
767
+ if consumer:
768
+ consumer_bytes = consumer.encode("utf-8")
769
+ result.extend(struct.pack("<H", len(consumer_bytes)))
770
+ result.extend(consumer_bytes)
771
+ return bytes(result)
772
+
773
+
774
+ def serialize_group_claim_value(
775
+ group: str,
776
+ consumer: str,
777
+ min_idle_ms: int,
778
+ start_id: StreamID,
779
+ count: int,
780
+ ) -> bytes:
781
+ """Serialize the request value for STREAM_GROUP_CLAIM (FLO-102).
782
+
783
+ Format: [group_len:u16][group][consumer_len:u16][consumer]
784
+ [min_idle_ms:u32][start_ts:u64][start_seq:u64][count:u32]
785
+ """
786
+ group_bytes = group.encode("utf-8")
787
+ consumer_bytes = consumer.encode("utf-8")
788
+
789
+ result = bytearray()
790
+ result.extend(struct.pack("<H", len(group_bytes)))
791
+ result.extend(group_bytes)
792
+ result.extend(struct.pack("<H", len(consumer_bytes)))
793
+ result.extend(consumer_bytes)
794
+ result.extend(struct.pack("<I", min_idle_ms))
795
+ result.extend(struct.pack("<Q", start_id.timestamp_ms))
796
+ result.extend(struct.pack("<Q", start_id.sequence))
797
+ result.extend(struct.pack("<I", count))
798
+ return bytes(result)
799
+
800
+
801
+ def parse_pending_entries(data: bytes) -> list[PendingEntry]:
802
+ """Parse the STREAM_GROUP_PENDING response (FLO-102).
803
+
804
+ Format: [count:u32]([ts:u64][seq:u64][delivery_count:u32]
805
+ [consumer_len:u16][consumer])*
806
+ """
807
+ if len(data) < 4:
808
+ return []
809
+
810
+ pos = 0
811
+ count = struct.unpack("<I", data[pos : pos + 4])[0]
812
+ pos += 4
813
+
814
+ entries: list[PendingEntry] = []
815
+ for _ in range(count):
816
+ if pos + 8 + 8 + 4 + 2 > len(data):
817
+ raise IncompleteResponseError("Pending entry: truncated header")
818
+ ts = struct.unpack("<Q", data[pos : pos + 8])[0]
819
+ pos += 8
820
+ seq = struct.unpack("<Q", data[pos : pos + 8])[0]
821
+ pos += 8
822
+ delivery_count = struct.unpack("<I", data[pos : pos + 4])[0]
823
+ pos += 4
824
+ clen = struct.unpack("<H", data[pos : pos + 2])[0]
825
+ pos += 2
826
+ if pos + clen > len(data):
827
+ raise IncompleteResponseError("Pending entry: truncated consumer")
828
+ consumer = data[pos : pos + clen].decode("utf-8")
829
+ pos += clen
830
+ entries.append(
831
+ PendingEntry(
832
+ id=StreamID(timestamp_ms=ts, sequence=seq),
833
+ consumer=consumer,
834
+ delivery_count=delivery_count,
835
+ )
836
+ )
837
+ return entries
838
+
839
+
700
840
  # =============================================================================
701
841
  # Action/Worker Value Serialization
702
842
  # =============================================================================
@@ -760,7 +900,8 @@ def serialize_action_invoke_value(
760
900
  """Serialize action invoke value.
761
901
 
762
902
  Format: [priority:u8][delay_ms:i64][has_caller:u8]
763
- [has_idempotency_key:u8][key_len:u16]?[key]?[input...]
903
+ [has_idempotency_key:u8][key_len:u16]?[key]?
904
+ [has_labels:u8][labels_len:u16]?[labels]?[input...]
764
905
  """
765
906
  result = bytearray()
766
907
 
@@ -782,6 +923,9 @@ def serialize_action_invoke_value(
782
923
  else:
783
924
  result.append(0)
784
925
 
926
+ # labels (none)
927
+ result.append(0)
928
+
785
929
  # input
786
930
  result.extend(input_data)
787
931
 
@@ -945,7 +1089,9 @@ def parse_task_assignment(data: bytes) -> "types.TaskAssignment":
945
1089
  """Parse task assignment from server response.
946
1090
 
947
1091
  Format: [task_id_len:u16][task_id][task_type_len:u16][task_type]
948
- [created_at:i64][attempt:u32][payload...]
1092
+ [created_at:i64][attempt:u32]
1093
+ [has_caller:u8]([run_id_len:u16][run_id][wf_name_len:u16][wf_name])?
1094
+ [payload...]
949
1095
  """
950
1096
  if len(data) < 10:
951
1097
  raise ValueError("Incomplete task assignment response")
@@ -982,6 +1128,32 @@ def parse_task_assignment(data: bytes) -> "types.TaskAssignment":
982
1128
  attempt = struct.unpack_from("<I", data, pos)[0]
983
1129
  pos += 4
984
1130
 
1131
+ # caller block: [has_caller:u8][run_id_len:u16][run_id][wf_name_len:u16][wf_name]
1132
+ if pos + 1 > len(data):
1133
+ raise ValueError("Incomplete task assignment: missing has_caller")
1134
+ has_caller = data[pos]
1135
+ pos += 1
1136
+ caller_run_id = ""
1137
+ caller_workflow_name = ""
1138
+ if has_caller == 1:
1139
+ if pos + 2 > len(data):
1140
+ raise ValueError("Incomplete task assignment: missing caller_run_id length")
1141
+ crid_len = struct.unpack_from("<H", data, pos)[0]
1142
+ pos += 2
1143
+ if pos + crid_len > len(data):
1144
+ raise ValueError("Incomplete task assignment: missing caller_run_id")
1145
+ caller_run_id = data[pos : pos + crid_len].decode("utf-8")
1146
+ pos += crid_len
1147
+
1148
+ if pos + 2 > len(data):
1149
+ raise ValueError("Incomplete task assignment: missing caller_workflow_name length")
1150
+ cwf_len = struct.unpack_from("<H", data, pos)[0]
1151
+ pos += 2
1152
+ if pos + cwf_len > len(data):
1153
+ raise ValueError("Incomplete task assignment: missing caller_workflow_name")
1154
+ caller_workflow_name = data[pos : pos + cwf_len].decode("utf-8")
1155
+ pos += cwf_len
1156
+
985
1157
  # payload (rest of data)
986
1158
  payload = data[pos:]
987
1159
 
@@ -991,4 +1163,6 @@ def parse_task_assignment(data: bytes) -> "types.TaskAssignment":
991
1163
  payload=payload,
992
1164
  created_at=created_at,
993
1165
  attempt=attempt,
1166
+ caller_run_id=caller_run_id,
1167
+ caller_workflow_name=caller_workflow_name,
994
1168
  )