flo-python 0.1.0__tar.gz

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,64 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [master]
6
+ pull_request:
7
+ branches: [master]
8
+ workflow_call:
9
+
10
+ jobs:
11
+ lint:
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: actions/setup-python@v5
16
+ with:
17
+ python-version: "3.12"
18
+ - name: Install tools
19
+ run: pip install ruff mypy
20
+ - name: Ruff check
21
+ run: ruff check src/
22
+ - name: Ruff format check
23
+ run: ruff format --check src/
24
+ - name: Type check
25
+ run: |
26
+ pip install -e .
27
+ mypy src/flo/
28
+
29
+ test:
30
+ runs-on: ubuntu-latest
31
+ strategy:
32
+ matrix:
33
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
34
+ steps:
35
+ - uses: actions/checkout@v4
36
+ - uses: actions/setup-python@v5
37
+ with:
38
+ python-version: ${{ matrix.python-version }}
39
+ - name: Install
40
+ run: |
41
+ python -m pip install --upgrade pip
42
+ pip install -e ".[dev]"
43
+ - name: Run tests
44
+ run: pytest -v
45
+
46
+ build:
47
+ runs-on: ubuntu-latest
48
+ needs: [lint, test]
49
+ steps:
50
+ - uses: actions/checkout@v4
51
+ - uses: actions/setup-python@v5
52
+ with:
53
+ python-version: "3.12"
54
+ - name: Build
55
+ run: |
56
+ pip install build
57
+ python -m build
58
+ - name: Upload artifacts
59
+ uses: actions/upload-artifact@v4
60
+ with:
61
+ name: dist
62
+ path: dist/
63
+
64
+
@@ -0,0 +1,40 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ permissions:
9
+ contents: write
10
+
11
+ jobs:
12
+ ci:
13
+ uses: ./.github/workflows/ci.yml
14
+
15
+ publish:
16
+ runs-on: ubuntu-latest
17
+ needs: ci
18
+ permissions:
19
+ contents: write
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+ - uses: actions/setup-python@v5
23
+ with:
24
+ python-version: "3.12"
25
+ - name: Build
26
+ run: |
27
+ pip install build twine
28
+ python -m build
29
+ - name: Publish to PyPI
30
+ env:
31
+ TWINE_USERNAME: __token__
32
+ TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
33
+ run: python -m twine upload dist/*
34
+ - name: Create GitHub Release
35
+ uses: softprops/action-gh-release@v2
36
+ with:
37
+ generate_release_notes: true
38
+ files: dist/*
39
+
40
+
@@ -0,0 +1,561 @@
1
+ Metadata-Version: 2.4
2
+ Name: flo-python
3
+ Version: 0.1.0
4
+ Summary: Python SDK for the Flo distributed systems platform
5
+ Project-URL: Homepage, https://github.com/floruntime/flo-python
6
+ Project-URL: Documentation, https://github.com/floruntime/flo-python#readme
7
+ Project-URL: Repository, https://github.com/floruntime/flo-python
8
+ Author: Flo Team
9
+ License-Expression: MIT
10
+ Keywords: async,distributed-systems,flo,kv-store,queue
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Framework :: AsyncIO
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Database
21
+ Classifier: Topic :: System :: Distributed Computing
22
+ Requires-Python: >=3.10
23
+ Provides-Extra: dev
24
+ Requires-Dist: mypy>=1.0; extra == 'dev'
25
+ Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
26
+ Requires-Dist: pytest>=7.0; extra == 'dev'
27
+ Requires-Dist: ruff>=0.1; extra == 'dev'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # flo-python
31
+
32
+ Python SDK for the [Flo](https://github.com/floruntime) distributed systems platform.
33
+
34
+ ## Installation
35
+
36
+ ```bash
37
+ pip install flo
38
+ ```
39
+
40
+ ## Quick Start
41
+
42
+ ```python
43
+ import asyncio
44
+ from flo import FloClient
45
+
46
+ async def main():
47
+ # Connect to Flo server
48
+ async with FloClient("localhost:9000") as client:
49
+ # KV operations
50
+ await client.kv.put("user:123", b"John Doe")
51
+ value = await client.kv.get("user:123")
52
+ print(f"Got: {value.decode()}")
53
+
54
+ # Queue operations
55
+ seq = await client.queue.enqueue("tasks", b'{"task": "process"}')
56
+ print(f"Enqueued message with seq: {seq}")
57
+
58
+ asyncio.run(main())
59
+ ```
60
+
61
+ ## Features
62
+
63
+ - **KV Store**: Versioned key-value storage with MVCC, TTL, and optimistic locking
64
+ - **Queues**: Priority-based task queues with visibility timeout and dead letter queues
65
+ - **Streams**: Append-only logs with consumer groups for distributed processing
66
+ - **Actions**: Registered tasks with configurable timeouts, retries, and idempotency
67
+ - **Workers**: Distributed task execution with lease management and heartbeats
68
+ - **Async/await**: Native asyncio support for high-performance applications
69
+ - **Type hints**: Full type annotations for IDE support
70
+
71
+ ## KV Operations
72
+
73
+ ### Get
74
+
75
+ ```python
76
+ from flo import GetOptions
77
+
78
+ # Simple get
79
+ value = await client.kv.get("key")
80
+ if value is not None:
81
+ print(value.decode())
82
+
83
+ # Blocking get - wait up to 5 seconds for value to appear
84
+ value = await client.kv.get("key", GetOptions(block_ms=5000))
85
+
86
+ # Blocking get - wait indefinitely (0 = infinite)
87
+ value = await client.kv.get("key", GetOptions(block_ms=0))
88
+
89
+ # Get with namespace override
90
+ value = await client.kv.get("key", GetOptions(namespace="other"))
91
+ ```
92
+
93
+ ### Put
94
+
95
+ ```python
96
+ from flo import PutOptions
97
+
98
+ # Simple put
99
+ await client.kv.put("key", b"value")
100
+
101
+ # Put with TTL (expires in 1 hour)
102
+ await client.kv.put("session:abc", b"data", PutOptions(ttl_seconds=3600))
103
+
104
+ # Put with CAS (optimistic locking)
105
+ await client.kv.put("counter", b"2", PutOptions(cas_version=1))
106
+
107
+ # Put only if key doesn't exist
108
+ await client.kv.put("key", b"value", PutOptions(if_not_exists=True))
109
+
110
+ # Put only if key exists
111
+ await client.kv.put("key", b"value", PutOptions(if_exists=True))
112
+ ```
113
+
114
+ ### Delete
115
+
116
+ ```python
117
+ await client.kv.delete("key")
118
+ ```
119
+
120
+ ### Scan
121
+
122
+ ```python
123
+ from flo import ScanOptions
124
+
125
+ # Scan all keys with prefix
126
+ result = await client.kv.scan("user:")
127
+ for entry in result.entries:
128
+ print(f"{entry.key.decode()}: {entry.value.decode()}")
129
+
130
+ # Paginated scan
131
+ result = await client.kv.scan("user:", ScanOptions(limit=100))
132
+ while result.has_more:
133
+ # Process entries...
134
+ result = await client.kv.scan("user:", ScanOptions(cursor=result.cursor, limit=100))
135
+
136
+ # Keys only (more efficient when you don't need values)
137
+ result = await client.kv.scan("user:", ScanOptions(keys_only=True))
138
+ ```
139
+
140
+ ### History
141
+
142
+ ```python
143
+ from flo import HistoryOptions
144
+
145
+ # Get version history for a key
146
+ history = await client.kv.history("user:123", HistoryOptions(limit=10))
147
+ for entry in history:
148
+ print(f"v{entry.version} at {entry.timestamp}: {entry.value.decode()}")
149
+ ```
150
+
151
+ ## Queue Operations
152
+
153
+ ### Enqueue
154
+
155
+ ```python
156
+ from flo import EnqueueOptions
157
+
158
+ # Simple enqueue
159
+ seq = await client.queue.enqueue("tasks", b'{"task": "process"}')
160
+
161
+ # With priority (higher = more urgent, 0-255)
162
+ seq = await client.queue.enqueue("tasks", payload, EnqueueOptions(priority=10))
163
+
164
+ # With delay (message invisible for 60 seconds)
165
+ seq = await client.queue.enqueue("tasks", payload, EnqueueOptions(delay_ms=60000))
166
+
167
+ # With deduplication key
168
+ seq = await client.queue.enqueue("tasks", payload, EnqueueOptions(dedup_key="task-123"))
169
+ ```
170
+
171
+ ### Dequeue
172
+
173
+ ```python
174
+ from flo import DequeueOptions
175
+
176
+ # Dequeue up to 10 messages
177
+ result = await client.queue.dequeue("tasks", 10)
178
+ for msg in result.messages:
179
+ print(f"Processing message {msg.seq}: {msg.payload}")
180
+
181
+ # Long polling (wait up to 30s for messages)
182
+ result = await client.queue.dequeue("tasks", 10, DequeueOptions(block_ms=30000))
183
+
184
+ # Custom visibility timeout (message invisible for 60s)
185
+ result = await client.queue.dequeue("tasks", 10, DequeueOptions(visibility_timeout_ms=60000))
186
+ ```
187
+
188
+ ### Ack/Nack
189
+
190
+ ```python
191
+ from flo import NackOptions
192
+
193
+ result = await client.queue.dequeue("tasks", 10)
194
+ for msg in result.messages:
195
+ try:
196
+ process(msg.payload)
197
+ # Acknowledge successful processing
198
+ await client.queue.ack("tasks", [msg.seq])
199
+ except Exception:
200
+ # Retry the message
201
+ await client.queue.nack("tasks", [msg.seq])
202
+ # Or send to DLQ
203
+ await client.queue.nack("tasks", [msg.seq], NackOptions(to_dlq=True))
204
+ ```
205
+
206
+ ### Dead Letter Queue
207
+
208
+ ```python
209
+ from flo import DlqListOptions
210
+
211
+ # List DLQ messages
212
+ result = await client.queue.dlq_list("tasks", DlqListOptions(limit=100))
213
+ for msg in result.messages:
214
+ print(f"Failed message {msg.seq}: {msg.payload}")
215
+
216
+ # Requeue DLQ messages
217
+ seqs = [msg.seq for msg in result.messages]
218
+ await client.queue.dlq_requeue("tasks", seqs)
219
+ ```
220
+
221
+ ### Peek
222
+
223
+ ```python
224
+ # Peek at messages without creating leases (no visibility timeout)
225
+ result = await client.queue.peek("tasks", 5)
226
+ for msg in result.messages:
227
+ print(f"Message {msg.seq}: {msg.payload}")
228
+ # Messages remain visible to other consumers
229
+ ```
230
+
231
+ ### Touch (Lease Renewal)
232
+
233
+ ```python
234
+ # Renew lease on messages during long-running processing
235
+ result = await client.queue.dequeue("tasks", 1)
236
+ msg = result.messages[0]
237
+
238
+ # Long running task - periodically touch to prevent visibility timeout
239
+ for chunk in process_in_chunks(msg.payload):
240
+ await process_chunk(chunk)
241
+ await client.queue.touch("tasks", [msg.seq]) # Renew lease
242
+
243
+ await client.queue.ack("tasks", [msg.seq])
244
+ ```
245
+
246
+ ## Stream Operations
247
+
248
+ Streams are append-only logs ideal for event sourcing, activity feeds, and real-time data pipelines.
249
+
250
+ ### Append
251
+
252
+ ```python
253
+ from flo import StreamAppendOptions
254
+
255
+ # Simple append
256
+ result = await client.stream.append("events", b'{"event": "click", "user": "123"}')
257
+ print(f"Appended at offset {result.offset}, timestamp {result.timestamp}")
258
+
259
+ # Append with partition key (for ordered processing within partition)
260
+ result = await client.stream.append(
261
+ "events", payload,
262
+ StreamAppendOptions(partition_key="user-123")
263
+ )
264
+
265
+ # Append with client-provided timestamp
266
+ result = await client.stream.append(
267
+ "events", payload,
268
+ StreamAppendOptions(timestamp=1699999999000)
269
+ )
270
+ ```
271
+
272
+ ### Read
273
+
274
+ ```python
275
+ from flo import StreamReadOptions, StreamStartMode
276
+
277
+ # Read from beginning (default)
278
+ result = await client.stream.read("events")
279
+ for record in result.records:
280
+ print(f"offset={record.offset} ts={record.timestamp}: {record.payload}")
281
+
282
+ # Read from specific offset
283
+ result = await client.stream.read(
284
+ "events",
285
+ StreamReadOptions(start_mode=StreamStartMode.OFFSET, offset=100, count=10)
286
+ )
287
+
288
+ # Read from tail (latest records)
289
+ result = await client.stream.read(
290
+ "events",
291
+ StreamReadOptions(start_mode=StreamStartMode.TAIL, count=10)
292
+ )
293
+
294
+ # Read from timestamp
295
+ result = await client.stream.read(
296
+ "events",
297
+ StreamReadOptions(start_mode=StreamStartMode.TIMESTAMP, timestamp=1699999999000)
298
+ )
299
+
300
+ # Blocking read (long polling) - wait up to 30s for new records
301
+ result = await client.stream.read(
302
+ "events",
303
+ StreamReadOptions(offset=100, block_ms=30000)
304
+ )
305
+ ```
306
+
307
+ ### Consumer Groups
308
+
309
+ Consumer groups allow multiple consumers to process a stream in parallel, with each record delivered to only one consumer.
310
+
311
+ ```python
312
+ from flo import StreamGroupReadOptions
313
+
314
+ # Join a consumer group
315
+ await client.stream.group_join("events", "processors", "worker-1")
316
+
317
+ # Read from consumer group
318
+ result = await client.stream.group_read("events", "processors", "worker-1")
319
+ for record in result.records:
320
+ try:
321
+ process(record.payload)
322
+ # Acknowledge successful processing
323
+ await client.stream.group_ack("events", "processors", [record.offset])
324
+ except Exception:
325
+ # Record will be redelivered to another consumer
326
+ pass
327
+
328
+ # With blocking (long polling)
329
+ result = await client.stream.group_read(
330
+ "events", "processors", "worker-1",
331
+ StreamGroupReadOptions(count=10, block_ms=30000)
332
+ )
333
+ ```
334
+
335
+ ## Action Operations
336
+
337
+ Actions are registered tasks that can be invoked and executed by workers.
338
+
339
+ ### Register an Action
340
+
341
+ ```python
342
+ from flo import ActionRegisterOptions, ActionType
343
+
344
+ # Register a user-based action (executed by external workers)
345
+ await client.action.register(
346
+ "process-image",
347
+ ActionType.USER,
348
+ ActionRegisterOptions(timeout_ms=60000, max_retries=3)
349
+ )
350
+ ```
351
+
352
+ ### Invoke an Action
353
+
354
+ ```python
355
+ from flo import ActionInvokeOptions
356
+
357
+ # Invoke an action
358
+ result = await client.action.invoke(
359
+ "process-image",
360
+ b'{"image_url": "https://example.com/image.jpg"}',
361
+ ActionInvokeOptions(priority=10)
362
+ )
363
+ print(f"Run ID: {result.run_id}")
364
+
365
+ # Invoke with idempotency key (prevents duplicate runs)
366
+ result = await client.action.invoke(
367
+ "process-image",
368
+ payload,
369
+ ActionInvokeOptions(idempotency_key="order-123-image")
370
+ )
371
+ ```
372
+
373
+ ### Check Action Status
374
+
375
+ ```python
376
+ status = await client.action.status(result.run_id)
377
+ print(f"Status: {status.status}")
378
+ if status.result:
379
+ print(f"Result: {status.result}")
380
+ ```
381
+
382
+ ### List and Delete Actions
383
+
384
+ ```python
385
+ from flo import ActionListOptions
386
+
387
+ # List all actions
388
+ actions = await client.action.list()
389
+
390
+ # List with prefix filter
391
+ actions = await client.action.list(ActionListOptions(prefix="process-"))
392
+
393
+ # Delete an action
394
+ await client.action.delete("process-image")
395
+ ```
396
+
397
+ ## Worker Operations
398
+
399
+ Workers execute tasks for registered actions.
400
+
401
+ ### Register a Worker
402
+
403
+ ```python
404
+ # Register a worker that handles specific task types
405
+ await client.worker.register(
406
+ "worker-1",
407
+ ["process-image", "resize-image"]
408
+ )
409
+ ```
410
+
411
+ ### Await and Process Tasks
412
+
413
+ ```python
414
+ from flo import WorkerAwaitOptions
415
+
416
+ # Wait for a task (blocking)
417
+ result = await client.worker.await_task(
418
+ "worker-1",
419
+ ["process-image", "resize-image"],
420
+ WorkerAwaitOptions(block_ms=30000) # Wait up to 30s
421
+ )
422
+
423
+ if result.task:
424
+ task = result.task
425
+ print(f"Got task: {task.task_id} for action: {task.action_name}")
426
+
427
+ try:
428
+ # Process the task
429
+ output = process(task.input)
430
+
431
+ # Complete successfully
432
+ await client.worker.complete("worker-1", task.task_id, output)
433
+ except Exception as e:
434
+ # Fail the task (will be retried)
435
+ await client.worker.fail("worker-1", task.task_id, str(e))
436
+ ```
437
+
438
+ ### Extend Task Lease
439
+
440
+ ```python
441
+ from flo import WorkerTouchOptions
442
+
443
+ # For long-running tasks, extend the lease periodically
444
+ await client.worker.touch(
445
+ "worker-1",
446
+ task.task_id,
447
+ WorkerTouchOptions(extend_ms=30000)
448
+ )
449
+ ```
450
+
451
+ ### Worker Lifecycle Example
452
+
453
+ ```python
454
+ import asyncio
455
+ from flo import FloClient, WorkerAwaitOptions, WorkerFailOptions
456
+
457
+ async def run_worker():
458
+ async with FloClient("localhost:9000") as client:
459
+ worker_id = "worker-1"
460
+ task_types = ["process-image"]
461
+
462
+ # Register the worker
463
+ await client.worker.register(worker_id, task_types)
464
+
465
+ while True:
466
+ # Wait for tasks
467
+ result = await client.worker.await_task(
468
+ worker_id,
469
+ task_types,
470
+ WorkerAwaitOptions(block_ms=30000)
471
+ )
472
+
473
+ if not result.task:
474
+ continue
475
+
476
+ task = result.task
477
+ try:
478
+ # Process task
479
+ output = await process_image(task.input)
480
+ await client.worker.complete(worker_id, task.task_id, output)
481
+ except Exception as e:
482
+ await client.worker.fail(
483
+ worker_id,
484
+ task.task_id,
485
+ str(e),
486
+ WorkerFailOptions(retry=True)
487
+ )
488
+
489
+ asyncio.run(run_worker())
490
+ ```
491
+
492
+ ## Configuration
493
+
494
+ ### Client Options
495
+
496
+ ```python
497
+ client = FloClient(
498
+ "localhost:9000",
499
+ namespace="myapp", # Default namespace for all operations
500
+ timeout_ms=5000, # Connection and operation timeout
501
+ debug=True, # Enable debug logging
502
+ )
503
+ ```
504
+
505
+ ### Namespaces
506
+
507
+ Each operation can override the default namespace:
508
+
509
+ ```python
510
+ from flo import GetOptions, PutOptions, EnqueueOptions
511
+
512
+ # Use client's default namespace
513
+ await client.kv.put("key", b"value")
514
+
515
+ # Override namespace for this operation
516
+ await client.kv.put("key", b"value", PutOptions(namespace="other"))
517
+ await client.kv.get("key", GetOptions(namespace="other"))
518
+ await client.queue.enqueue("tasks", payload, EnqueueOptions(namespace="other"))
519
+ ```
520
+
521
+ ## Error Handling
522
+
523
+ ```python
524
+ from flo import (
525
+ FloError,
526
+ NotFoundError,
527
+ ConflictError,
528
+ ConnectionFailedError,
529
+ )
530
+
531
+ try:
532
+ await client.kv.put("key", b"new", PutOptions(cas_version=1))
533
+ except ConflictError:
534
+ print("CAS version mismatch - value was modified")
535
+ except ConnectionFailedError:
536
+ print("Failed to connect to server")
537
+ except FloError as e:
538
+ print(f"Flo error: {e}")
539
+ ```
540
+
541
+ ### Error Types
542
+
543
+ | Error | Description |
544
+ |-------|-------------|
545
+ | `NotFoundError` | Key or resource not found |
546
+ | `BadRequestError` | Invalid request parameters |
547
+ | `ConflictError` | CAS version mismatch |
548
+ | `UnauthorizedError` | Authentication failed |
549
+ | `OverloadedError` | Server is overloaded |
550
+ | `InternalServerError` | Server internal error |
551
+ | `ConnectionFailedError` | Failed to connect |
552
+ | `InvalidChecksumError` | Response CRC32 mismatch |
553
+
554
+ ## Requirements
555
+
556
+ - Python 3.10+
557
+ - asyncio
558
+
559
+ ## License
560
+
561
+ MIT