flo-python 0.1.0.dev2__tar.gz → 0.1.0.dev3__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.
Files changed (29) hide show
  1. {flo_python-0.1.0.dev2 → flo_python-0.1.0.dev3}/PKG-INFO +29 -4
  2. {flo_python-0.1.0.dev2 → flo_python-0.1.0.dev3}/README.md +28 -3
  3. flo_python-0.1.0.dev3/examples/stream_worker.py +76 -0
  4. flo_python-0.1.0.dev3/examples/test_decorator.py +44 -0
  5. {flo_python-0.1.0.dev2 → flo_python-0.1.0.dev3}/examples/worker.py +1 -1
  6. {flo_python-0.1.0.dev2 → flo_python-0.1.0.dev3}/pyproject.toml +3 -1
  7. {flo_python-0.1.0.dev2 → flo_python-0.1.0.dev3}/src/flo/__init__.py +80 -8
  8. {flo_python-0.1.0.dev2 → flo_python-0.1.0.dev3}/src/flo/actions.py +44 -15
  9. {flo_python-0.1.0.dev2 → flo_python-0.1.0.dev3}/src/flo/client.py +137 -16
  10. {flo_python-0.1.0.dev2 → flo_python-0.1.0.dev3}/src/flo/exceptions.py +21 -0
  11. {flo_python-0.1.0.dev2 → flo_python-0.1.0.dev3}/src/flo/kv.py +6 -6
  12. flo_python-0.1.0.dev3/src/flo/processing.py +341 -0
  13. {flo_python-0.1.0.dev2 → flo_python-0.1.0.dev3}/src/flo/streams.py +17 -16
  14. {flo_python-0.1.0.dev2 → flo_python-0.1.0.dev3}/src/flo/types.py +440 -190
  15. {flo_python-0.1.0.dev2 → flo_python-0.1.0.dev3}/src/flo/wire.py +107 -49
  16. flo_python-0.1.0.dev3/src/flo/worker.py +979 -0
  17. flo_python-0.1.0.dev3/src/flo/workflows.py +463 -0
  18. flo_python-0.1.0.dev3/tests/conftest.py +186 -0
  19. flo_python-0.1.0.dev3/tests/order-workflow.yaml +74 -0
  20. {flo_python-0.1.0.dev2 → flo_python-0.1.0.dev3}/tests/test_wire.py +8 -6
  21. flo_python-0.1.0.dev3/tests/test_workflows.py +747 -0
  22. flo_python-0.1.0.dev2/src/flo/worker.py +0 -411
  23. {flo_python-0.1.0.dev2 → flo_python-0.1.0.dev3}/.github/workflows/ci.yml +0 -0
  24. {flo_python-0.1.0.dev2 → flo_python-0.1.0.dev3}/.github/workflows/release.yml +0 -0
  25. {flo_python-0.1.0.dev2 → flo_python-0.1.0.dev3}/.gitignore +0 -0
  26. {flo_python-0.1.0.dev2 → flo_python-0.1.0.dev3}/LICENSE +0 -0
  27. {flo_python-0.1.0.dev2 → flo_python-0.1.0.dev3}/examples/basic.py +0 -0
  28. {flo_python-0.1.0.dev2 → flo_python-0.1.0.dev3}/src/flo/queue.py +0 -0
  29. {flo_python-0.1.0.dev2 → flo_python-0.1.0.dev3}/tests/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flo-python
3
- Version: 0.1.0.dev2
3
+ Version: 0.1.0.dev3
4
4
  Summary: Python SDK for the Flo distributed systems platform
5
5
  Project-URL: Homepage, https://github.com/floruntime/flo-python
6
6
  Project-URL: Documentation, https://github.com/floruntime/flo-python#readme
@@ -321,7 +321,7 @@ for record in result.records:
321
321
  try:
322
322
  process(record.payload)
323
323
  # Acknowledge successful processing
324
- await client.stream.group_ack("events", "processors", [record.offset])
324
+ await client.stream.group_ack("events", "processors", [record.id])
325
325
  except Exception:
326
326
  # Record will be redelivered to another consumer
327
327
  pass
@@ -457,8 +457,8 @@ from flo import FloClient
457
457
 
458
458
  async def run_worker():
459
459
  async with FloClient("localhost:9000", namespace="myapp") as client:
460
- # Create a worker from the client
461
- worker = client.new_worker(concurrency=5)
460
+ # Create an action worker from the client
461
+ worker = client.new_action_worker(concurrency=5)
462
462
 
463
463
  @worker.action("process-image")
464
464
  async def process_image(ctx):
@@ -471,6 +471,31 @@ async def run_worker():
471
471
  asyncio.run(run_worker())
472
472
  ```
473
473
 
474
+ ### StreamWorker Example
475
+
476
+ ```python
477
+ import asyncio
478
+ from flo import FloClient, StreamContext
479
+
480
+ async def process_event(ctx: StreamContext) -> None:
481
+ event = ctx.json()
482
+ print(f"Got event: {event}")
483
+ # Return normally → auto-ack
484
+ # Raise an exception → auto-nack (redelivery)
485
+
486
+ async def run_stream_worker():
487
+ async with FloClient("localhost:9000", namespace="myapp") as client:
488
+ worker = client.new_stream_worker(
489
+ stream="events",
490
+ group="processors",
491
+ handler=process_event,
492
+ concurrency=5,
493
+ )
494
+ await worker.start()
495
+
496
+ asyncio.run(run_stream_worker())
497
+ ```
498
+
474
499
  ## Configuration
475
500
 
476
501
  ### Client Options
@@ -291,7 +291,7 @@ for record in result.records:
291
291
  try:
292
292
  process(record.payload)
293
293
  # Acknowledge successful processing
294
- await client.stream.group_ack("events", "processors", [record.offset])
294
+ await client.stream.group_ack("events", "processors", [record.id])
295
295
  except Exception:
296
296
  # Record will be redelivered to another consumer
297
297
  pass
@@ -427,8 +427,8 @@ from flo import FloClient
427
427
 
428
428
  async def run_worker():
429
429
  async with FloClient("localhost:9000", namespace="myapp") as client:
430
- # Create a worker from the client
431
- worker = client.new_worker(concurrency=5)
430
+ # Create an action worker from the client
431
+ worker = client.new_action_worker(concurrency=5)
432
432
 
433
433
  @worker.action("process-image")
434
434
  async def process_image(ctx):
@@ -441,6 +441,31 @@ async def run_worker():
441
441
  asyncio.run(run_worker())
442
442
  ```
443
443
 
444
+ ### StreamWorker Example
445
+
446
+ ```python
447
+ import asyncio
448
+ from flo import FloClient, StreamContext
449
+
450
+ async def process_event(ctx: StreamContext) -> None:
451
+ event = ctx.json()
452
+ print(f"Got event: {event}")
453
+ # Return normally → auto-ack
454
+ # Raise an exception → auto-nack (redelivery)
455
+
456
+ async def run_stream_worker():
457
+ async with FloClient("localhost:9000", namespace="myapp") as client:
458
+ worker = client.new_stream_worker(
459
+ stream="events",
460
+ group="processors",
461
+ handler=process_event,
462
+ concurrency=5,
463
+ )
464
+ await worker.start()
465
+
466
+ asyncio.run(run_stream_worker())
467
+ ```
468
+
444
469
  ## Configuration
445
470
 
446
471
  ### Client Options
@@ -0,0 +1,76 @@
1
+ """Example: StreamWorker usage with the Flo Python SDK
2
+
3
+ Demonstrates how to use StreamWorker to process stream records
4
+ via consumer groups with automatic ack/nack handling.
5
+ """
6
+
7
+ import asyncio
8
+ import logging
9
+ import os
10
+ import signal
11
+
12
+ from flo import FloClient, StreamContext
13
+
14
+ logging.basicConfig(
15
+ level=logging.INFO,
16
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
17
+ )
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ async def process_event(ctx: StreamContext) -> None:
22
+ """Process a stream record.
23
+
24
+ Return normally to auto-ack. Raise an exception to auto-nack
25
+ (the record will be redelivered).
26
+ """
27
+ event = ctx.json()
28
+ logger.info(
29
+ f"Processing event (stream={ctx.stream}, id={ctx.stream_id}): {event}"
30
+ )
31
+
32
+ # Simulate work
33
+ await asyncio.sleep(0.1)
34
+
35
+ # If processing fails, just raise — the worker will nack for you:
36
+ # raise RuntimeError("transient failure")
37
+
38
+
39
+ async def main():
40
+ client = FloClient(
41
+ os.getenv("FLO_ENDPOINT", "localhost:3000"),
42
+ namespace=os.getenv("FLO_NAMESPACE", "myapp"),
43
+ debug=os.getenv("FLO_DEBUG", "").lower() in ("1", "true"),
44
+ )
45
+ await client.connect()
46
+
47
+ worker = client.new_stream_worker(
48
+ stream="events",
49
+ group="processors",
50
+ handler=process_event,
51
+ concurrency=5,
52
+ batch_size=10,
53
+ )
54
+
55
+ # Handle shutdown signals
56
+ def signal_handler():
57
+ logger.info("Received shutdown signal")
58
+ worker.stop()
59
+
60
+ loop = asyncio.get_running_loop()
61
+ for sig in (signal.SIGINT, signal.SIGTERM):
62
+ loop.add_signal_handler(sig, signal_handler)
63
+
64
+ logger.info("Starting stream worker...")
65
+ try:
66
+ await worker.start()
67
+ except KeyboardInterrupt:
68
+ logger.info("Interrupted")
69
+ finally:
70
+ await worker.close()
71
+ await client.close()
72
+ logger.info("Stream worker shutdown complete")
73
+
74
+
75
+ if __name__ == "__main__":
76
+ asyncio.run(main())
@@ -0,0 +1,44 @@
1
+ """Minimal test: register an action using only the @worker.action decorator."""
2
+ import asyncio
3
+ import logging
4
+ import signal
5
+ from flo import FloClient, ActionContext
6
+
7
+ logging.basicConfig(level=logging.DEBUG, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
8
+
9
+
10
+ async def main():
11
+ client = FloClient("localhost:9000", namespace="default", debug=True)
12
+ await client.connect()
13
+ print(f"Connected: {client.is_connected}")
14
+
15
+ worker = client.new_action_worker(concurrency=1)
16
+
17
+ @worker.action("health-check")
18
+ async def health_check(ctx: ActionContext) -> bytes:
19
+ return ctx.to_bytes({"status": "healthy"})
20
+
21
+ stop = asyncio.Event()
22
+
23
+ def on_signal():
24
+ print("Shutting down...")
25
+ worker.stop()
26
+ stop.set()
27
+
28
+ loop = asyncio.get_running_loop()
29
+ for sig in (signal.SIGINT, signal.SIGTERM):
30
+ loop.add_signal_handler(sig, on_signal)
31
+
32
+ print("Starting worker with @worker.action('health-check')...")
33
+ try:
34
+ await worker.start()
35
+ except KeyboardInterrupt:
36
+ pass
37
+ finally:
38
+ await worker.close()
39
+ await client.close()
40
+ print("Done.")
41
+
42
+
43
+ if __name__ == "__main__":
44
+ asyncio.run(main())
@@ -167,7 +167,7 @@ async def main():
167
167
  await client.connect()
168
168
 
169
169
  # Create a worker from the client
170
- worker = client.new_worker(
170
+ worker = client.new_action_worker(
171
171
  concurrency=5,
172
172
  action_timeout=300, # 5 minutes
173
173
  )
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "flo-python"
7
- version = "0.1.0.dev2"
7
+ version = "0.1.0.dev3"
8
8
  description = "Python SDK for the Flo distributed systems platform"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -45,6 +45,8 @@ packages = ["src/flo"]
45
45
 
46
46
  [tool.pytest.ini_options]
47
47
  asyncio_mode = "auto"
48
+ asyncio_default_fixture_loop_scope = "session"
49
+ asyncio_default_test_loop_scope = "session"
48
50
  testpaths = ["tests"]
49
51
 
50
52
  [tool.mypy]
@@ -24,7 +24,7 @@ Example:
24
24
  await client.action.invoke("process", b'{}')
25
25
 
26
26
  # Worker (created from client)
27
- worker = client.new_worker(concurrency=5)
27
+ worker = client.new_action_worker(concurrency=5)
28
28
  worker.register_action("my-action", handler)
29
29
  await worker.start()
30
30
 
@@ -45,6 +45,7 @@ from .exceptions import (
45
45
  InvalidMagicError,
46
46
  KeyTooLargeError,
47
47
  NamespaceTooLargeError,
48
+ NonRetryableError,
48
49
  NotConnectedError,
49
50
  NotFoundError,
50
51
  OverloadedError,
@@ -57,11 +58,11 @@ from .exceptions import (
57
58
  UnsupportedVersionError,
58
59
  ValidationError,
59
60
  ValueTooLargeError,
61
+ is_connection_error,
60
62
  )
63
+ from .processing import ProcessingOperations
61
64
  from .types import (
62
- # KV types
63
65
  AckOptions,
64
- # Action types
65
66
  ActionDeleteOptions,
66
67
  ActionInfo,
67
68
  ActionInvokeOptions,
@@ -86,11 +87,22 @@ from .types import (
86
87
  OpCode,
87
88
  OptionTag,
88
89
  PeekOptions,
90
+ ProcessingCancelOptions,
91
+ ProcessingListEntry,
92
+ ProcessingListOptions,
93
+ ProcessingRescaleOptions,
94
+ ProcessingRestoreOptions,
95
+ ProcessingSavepointOptions,
96
+ ProcessingStatusOptions,
97
+ ProcessingStatusResult,
98
+ ProcessingStopOptions,
99
+ ProcessingSubmitOptions,
100
+ ProcessingSyncOptions,
101
+ ProcessingSyncResult,
89
102
  PutOptions,
90
103
  ScanOptions,
91
104
  ScanResult,
92
105
  StatusCode,
93
- # Stream types
94
106
  StorageTier,
95
107
  StreamAppendOptions,
96
108
  StreamAppendResult,
@@ -105,7 +117,6 @@ from .types import (
105
117
  StreamReadResult,
106
118
  StreamRecord,
107
119
  StreamTrimOptions,
108
- # Worker types
109
120
  TaskAssignment,
110
121
  TouchOptions,
111
122
  VersionEntry,
@@ -119,8 +130,31 @@ from .types import (
119
130
  WorkerRegisterOptions,
120
131
  WorkerTask,
121
132
  WorkerTouchOptions,
133
+ WorkflowCancelOptions,
134
+ WorkflowCreateOptions,
135
+ WorkflowDisableOptions,
136
+ WorkflowEnableOptions,
137
+ WorkflowGetDefinitionOptions,
138
+ WorkflowHistoryOptions,
139
+ WorkflowListDefinitionsOptions,
140
+ WorkflowListRunsOptions,
141
+ WorkflowSignalOptions,
142
+ WorkflowStartOptions,
143
+ WorkflowStatusOptions,
144
+ WorkflowSyncOptions,
145
+ WorkflowSyncResult,
146
+ )
147
+ from .worker import (
148
+ ActionContext,
149
+ ActionResult,
150
+ ActionWorker,
151
+ ActionWorkerOptions,
152
+ StreamContext,
153
+ StreamRecordHandler,
154
+ StreamWorker,
155
+ StreamWorkerOptions,
122
156
  )
123
- from .worker import ActionContext, Worker, WorkerOptions
157
+ from .workflows import WorkflowOperations
124
158
 
125
159
  __version__ = "0.1.0"
126
160
 
@@ -128,11 +162,19 @@ __all__ = [
128
162
  # Client
129
163
  "FloClient",
130
164
  # High-level Worker API
131
- "Worker",
132
- "WorkerOptions",
165
+ "ActionWorker",
166
+ "ActionWorkerOptions",
133
167
  "ActionContext",
168
+ "ActionResult",
169
+ "StreamWorker",
170
+ "StreamWorkerOptions",
171
+ "StreamContext",
172
+ "StreamRecordHandler",
173
+ "WorkflowOperations",
174
+ "ProcessingOperations",
134
175
  # Exceptions
135
176
  "FloError",
177
+ "NonRetryableError",
136
178
  "NotConnectedError",
137
179
  "ConnectionFailedError",
138
180
  "InvalidEndpointError",
@@ -156,6 +198,7 @@ __all__ = [
156
198
  "RateLimitedError",
157
199
  "InternalServerError",
158
200
  "GenericServerError",
201
+ "is_connection_error",
159
202
  # Types
160
203
  "OpCode",
161
204
  "StatusCode",
@@ -221,4 +264,33 @@ __all__ = [
221
264
  "WorkerCompleteOptions",
222
265
  "WorkerFailOptions",
223
266
  "WorkerListOptions",
267
+ # Workflow types
268
+ "WorkflowOperations",
269
+ "WorkflowCreateOptions",
270
+ "WorkflowGetDefinitionOptions",
271
+ "WorkflowStartOptions",
272
+ "WorkflowStatusOptions",
273
+ "WorkflowSignalOptions",
274
+ "WorkflowCancelOptions",
275
+ "WorkflowHistoryOptions",
276
+ "WorkflowListRunsOptions",
277
+ "WorkflowListDefinitionsOptions",
278
+ "WorkflowDisableOptions",
279
+ "WorkflowEnableOptions",
280
+ "WorkflowSyncOptions",
281
+ "WorkflowSyncResult",
282
+ # Processing types
283
+ "ProcessingOperations",
284
+ "ProcessingSubmitOptions",
285
+ "ProcessingStatusOptions",
286
+ "ProcessingListOptions",
287
+ "ProcessingStopOptions",
288
+ "ProcessingCancelOptions",
289
+ "ProcessingSavepointOptions",
290
+ "ProcessingRestoreOptions",
291
+ "ProcessingRescaleOptions",
292
+ "ProcessingSyncOptions",
293
+ "ProcessingStatusResult",
294
+ "ProcessingListEntry",
295
+ "ProcessingSyncResult",
224
296
  ]
@@ -68,7 +68,7 @@ class ActionOperations:
68
68
 
69
69
  Args:
70
70
  name: Action name.
71
- action_type: Type of action (USER or WASM).
71
+ action_type: Type of action (USER only).
72
72
  options: Optional registration options.
73
73
  """
74
74
  opts = options or ActionRegisterOptions()
@@ -231,7 +231,12 @@ class WorkerOperations:
231
231
  opts = options or WorkerRegisterOptions()
232
232
  namespace = self._client.get_namespace(opts.namespace)
233
233
 
234
- value = serialize_worker_register_value(task_types)
234
+ value = serialize_worker_register_value(
235
+ task_types,
236
+ max_concurrency=opts.concurrency,
237
+ metadata=opts.metadata,
238
+ machine_id=opts.machine_id,
239
+ )
235
240
 
236
241
  await self._client._send_and_check(
237
242
  OpCode.WORKER_REGISTER,
@@ -240,6 +245,30 @@ class WorkerOperations:
240
245
  value,
241
246
  )
242
247
 
248
+ async def heartbeat(
249
+ self,
250
+ worker_id: str,
251
+ current_load: int = 0,
252
+ namespace: str | None = None,
253
+ ) -> None:
254
+ """Send a worker heartbeat to keep the registration alive.
255
+
256
+ Args:
257
+ worker_id: Worker identifier.
258
+ current_load: Current number of active tasks.
259
+ namespace: Optional namespace override.
260
+ """
261
+ import struct
262
+
263
+ ns = self._client.get_namespace(namespace)
264
+ value = struct.pack("<I", current_load)
265
+ await self._client._send_and_check(
266
+ OpCode.WORKER_HEARTBEAT,
267
+ ns,
268
+ worker_id.encode("utf-8"),
269
+ value,
270
+ )
271
+
243
272
  async def await_task(
244
273
  self,
245
274
  worker_id: str,
@@ -269,7 +298,7 @@ class WorkerOperations:
269
298
  options_builder.add_u32(OptionTag.TIMEOUT_MS, opts.timeout_ms)
270
299
 
271
300
  response = await self._client._send_and_check(
272
- OpCode.WORKER_AWAIT,
301
+ OpCode.ACTION_AWAIT,
273
302
  namespace,
274
303
  worker_id.encode("utf-8"),
275
304
  value,
@@ -287,6 +316,7 @@ class WorkerOperations:
287
316
  async def touch(
288
317
  self,
289
318
  worker_id: str,
319
+ action_name: str,
290
320
  task_id: str,
291
321
  options: WorkerTouchOptions | None = None,
292
322
  ) -> None:
@@ -294,16 +324,17 @@ class WorkerOperations:
294
324
 
295
325
  Args:
296
326
  worker_id: Worker identifier.
327
+ action_name: Action name for routing.
297
328
  task_id: Task identifier to extend.
298
329
  options: Optional touch options (extend_ms).
299
330
  """
300
331
  opts = options or WorkerTouchOptions()
301
332
  namespace = self._client.get_namespace(opts.namespace)
302
333
 
303
- value = serialize_worker_touch_value(task_id, opts.extend_ms)
334
+ value = serialize_worker_touch_value(action_name, task_id, opts.extend_ms)
304
335
 
305
336
  await self._client._send_and_check(
306
- OpCode.WORKER_TOUCH,
337
+ OpCode.ACTION_TOUCH,
307
338
  namespace,
308
339
  worker_id.encode("utf-8"),
309
340
  value,
@@ -312,6 +343,7 @@ class WorkerOperations:
312
343
  async def complete(
313
344
  self,
314
345
  worker_id: str,
346
+ action_name: str,
315
347
  task_id: str,
316
348
  result: bytes,
317
349
  options: WorkerCompleteOptions | None = None,
@@ -320,6 +352,7 @@ class WorkerOperations:
320
352
 
321
353
  Args:
322
354
  worker_id: Worker identifier.
355
+ action_name: Action name for routing.
323
356
  task_id: Task identifier to complete.
324
357
  result: Result data from the task.
325
358
  options: Optional complete options.
@@ -327,10 +360,10 @@ class WorkerOperations:
327
360
  opts = options or WorkerCompleteOptions()
328
361
  namespace = self._client.get_namespace(opts.namespace)
329
362
 
330
- value = serialize_worker_complete_value(task_id, result)
363
+ value = serialize_worker_complete_value(action_name, task_id, result, opts.outcome)
331
364
 
332
365
  await self._client._send_and_check(
333
- OpCode.WORKER_COMPLETE,
366
+ OpCode.ACTION_COMPLETE,
334
367
  namespace,
335
368
  worker_id.encode("utf-8"),
336
369
  value,
@@ -339,6 +372,7 @@ class WorkerOperations:
339
372
  async def fail(
340
373
  self,
341
374
  worker_id: str,
375
+ action_name: str,
342
376
  task_id: str,
343
377
  error_message: str,
344
378
  options: WorkerFailOptions | None = None,
@@ -347,6 +381,7 @@ class WorkerOperations:
347
381
 
348
382
  Args:
349
383
  worker_id: Worker identifier.
384
+ action_name: Action name for routing.
350
385
  task_id: Task identifier that failed.
351
386
  error_message: Error message describing the failure.
352
387
  options: Optional fail options (retry flag).
@@ -354,19 +389,13 @@ class WorkerOperations:
354
389
  opts = options or WorkerFailOptions()
355
390
  namespace = self._client.get_namespace(opts.namespace)
356
391
 
357
- value = serialize_worker_fail_value(task_id, error_message)
358
-
359
- # Retry flag goes in TLV options (matches Go SDK)
360
- options_builder = OptionsBuilder()
361
- if opts.retry:
362
- options_builder.add_flag(OptionTag.RETRY)
392
+ value = serialize_worker_fail_value(action_name, task_id, error_message, opts.retry)
363
393
 
364
394
  await self._client._send_and_check(
365
- OpCode.WORKER_FAIL,
395
+ OpCode.ACTION_FAIL,
366
396
  namespace,
367
397
  worker_id.encode("utf-8"),
368
398
  value,
369
- options_builder.build(),
370
399
  )
371
400
 
372
401
  async def list(