iii-sdk 0.21.4.dev2__tar.gz → 0.21.5.dev1__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 (74) hide show
  1. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/PKG-INFO +2 -2
  2. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/pyproject.toml +2 -2
  3. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/src/iii/iii.py +9 -5
  4. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/src/iii/iii_types.py +3 -0
  5. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/tests/test_http_external_functions_integration.py +46 -76
  6. iii_sdk-0.21.5.dev1/tests/test_trigger_action.py +19 -0
  7. iii_sdk-0.21.4.dev2/tests/test_queue_integration.py +0 -363
  8. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/.gitignore +0 -0
  9. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/README.md +0 -0
  10. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/src/iii/__init__.py +0 -0
  11. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/src/iii/channel.py +0 -0
  12. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/src/iii/channels.py +0 -0
  13. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/src/iii/engine.py +0 -0
  14. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/src/iii/errors.py +0 -0
  15. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/src/iii/format_utils.py +0 -0
  16. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/src/iii/helpers.py +0 -0
  17. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/src/iii/iii_constants.py +0 -0
  18. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/src/iii/internal.py +0 -0
  19. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/src/iii/otel_worker_gauges.py +0 -0
  20. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/src/iii/protocol.py +0 -0
  21. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/src/iii/runtime.py +0 -0
  22. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/src/iii/state.py +0 -0
  23. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/src/iii/stream.py +0 -0
  24. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/src/iii/trigger.py +0 -0
  25. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/src/iii/triggers.py +0 -0
  26. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/src/iii/types.py +0 -0
  27. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/src/iii/utils.py +0 -0
  28. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/src/iii/worker_metrics.py +0 -0
  29. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/tests/conftest.py +0 -0
  30. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/tests/test_api_triggers.py +0 -0
  31. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/tests/test_async_api.py +0 -0
  32. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/tests/test_baggage_span_processor.py +0 -0
  33. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/tests/test_bridge.py +0 -0
  34. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/tests/test_channel_close_delay.py +0 -0
  35. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/tests/test_channel_submodule.py +0 -0
  36. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/tests/test_context_propagation.py +0 -0
  37. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/tests/test_data_channels.py +0 -0
  38. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/tests/test_engine_constants.py +0 -0
  39. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/tests/test_errors.py +0 -0
  40. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/tests/test_format_utils.py +0 -0
  41. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/tests/test_healthcheck.py +0 -0
  42. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/tests/test_helpers.py +0 -0
  43. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/tests/test_hold_process.py +0 -0
  44. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/tests/test_iii_registration_dedup.py +0 -0
  45. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/tests/test_init_api.py +0 -0
  46. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/tests/test_invocation_exception.py +0 -0
  47. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/tests/test_invocation_metadata.py +0 -0
  48. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/tests/test_logger_function_ids.py +0 -0
  49. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/tests/test_logger_otel.py +0 -0
  50. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/tests/test_middleware.py +0 -0
  51. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/tests/test_payload.py +0 -0
  52. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/tests/test_pubsub.py +0 -0
  53. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/tests/test_rbac_workers.py +0 -0
  54. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/tests/test_register_function_args.py +0 -0
  55. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/tests/test_runtime_submodule.py +0 -0
  56. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/tests/test_span_ops.py +0 -0
  57. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/tests/test_state.py +0 -0
  58. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/tests/test_stream_models.py +0 -0
  59. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/tests/test_stream_types.py +0 -0
  60. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/tests/test_streams.py +0 -0
  61. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/tests/test_streams_runtime_annotations.py +0 -0
  62. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/tests/test_sync_api.py +0 -0
  63. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/tests/test_telemetry.py +0 -0
  64. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/tests/test_telemetry_exporters.py +0 -0
  65. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/tests/test_telemetry_types.py +0 -0
  66. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/tests/test_trace_helpers.py +0 -0
  67. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/tests/test_trigger_metadata.py +0 -0
  68. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/tests/test_trigger_registration_error.py +0 -0
  69. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/tests/test_trigger_submodule.py +0 -0
  70. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/tests/test_trigger_type_lifecycle.py +0 -0
  71. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/tests/test_utils.py +0 -0
  72. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/tests/test_worker_metadata.py +0 -0
  73. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/tests/test_worker_metrics.py +0 -0
  74. {iii_sdk-0.21.4.dev2 → iii_sdk-0.21.5.dev1}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iii-sdk
3
- Version: 0.21.4.dev2
3
+ Version: 0.21.5.dev1
4
4
  Summary: III SDK for Python
5
5
  Project-URL: Homepage, https://github.com/iii-hq/iii
6
6
  Project-URL: Repository, https://github.com/iii-hq/iii
@@ -14,7 +14,7 @@ Classifier: Programming Language :: Python :: 3.10
14
14
  Classifier: Programming Language :: Python :: 3.11
15
15
  Classifier: Programming Language :: Python :: 3.12
16
16
  Requires-Python: >=3.10
17
- Requires-Dist: iii-helpers==0.21.4.dev2
17
+ Requires-Dist: iii-helpers==0.21.5.dev1
18
18
  Requires-Dist: opentelemetry-api>=1.25
19
19
  Requires-Dist: pydantic>=2.0
20
20
  Requires-Dist: websockets>=12.0
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "iii-sdk"
7
- version = "0.21.4.dev2"
7
+ version = "0.21.5.dev1"
8
8
  description = "III SDK for Python"
9
9
  authors = [{ name = "III" }]
10
10
  license = { text = "Apache-2.0" }
@@ -23,7 +23,7 @@ dependencies = [
23
23
  "websockets>=12.0",
24
24
  "pydantic>=2.0",
25
25
  "opentelemetry-api>=1.25",
26
- "iii-helpers==0.21.4.dev2",
26
+ "iii-helpers==0.21.5.dev1",
27
27
  ]
28
28
 
29
29
  [project.urls]
@@ -1130,7 +1130,8 @@ class III:
1130
1130
  The routing behavior and return type depend on the ``action`` field:
1131
1131
 
1132
1132
  - No action: synchronous -- waits for the function to return.
1133
- - ``TriggerAction.Enqueue(...)``: async via named queue -- returns ``EnqueueResult``.
1133
+ - ``TriggerAction.Enqueue(...)``: async via named queue -- returns a dict
1134
+ with ``messageReceiptId``.
1134
1135
  - ``TriggerAction.Void()``: fire-and-forget -- returns ``None``.
1135
1136
 
1136
1137
  Args:
@@ -1139,8 +1140,8 @@ class III:
1139
1140
 
1140
1141
  Returns:
1141
1142
  The function's return value for synchronous (no-action) calls,
1142
- an ``EnqueueResult`` for enqueue actions, or ``None`` for void
1143
- actions.
1143
+ a ``{"messageReceiptId": ...}`` dict for enqueue actions, or
1144
+ ``None`` for void actions.
1144
1145
 
1145
1146
  Raises:
1146
1147
  InvocationError: For any engine rejection. Inspect ``code``:
@@ -1159,7 +1160,8 @@ class III:
1159
1160
  The routing behavior and return type depend on the ``action`` field:
1160
1161
 
1161
1162
  - No action: synchronous -- waits for the function to return.
1162
- - ``TriggerAction.Enqueue(...)``: async via named queue -- returns ``EnqueueResult``.
1163
+ - ``TriggerAction.Enqueue(...)``: async via named queue -- returns a dict
1164
+ with ``messageReceiptId``.
1163
1165
  - ``TriggerAction.Void()``: fire-and-forget -- returns ``None``.
1164
1166
 
1165
1167
  Args:
@@ -1167,7 +1169,9 @@ class III:
1167
1169
  and optional ``action`` / ``timeout_ms``.
1168
1170
 
1169
1171
  Returns:
1170
- The result of the function invocation, or ``None`` for void calls.
1172
+ The function's return value for synchronous (no-action) calls,
1173
+ a ``{"messageReceiptId": ...}`` dict for enqueue actions, or
1174
+ ``None`` for void actions.
1171
1175
 
1172
1176
  Raises:
1173
1177
  InvocationError: For any engine rejection. Inspect ``code``:
@@ -170,6 +170,9 @@ class RegisterFunctionMessage(BaseModel):
170
170
  class TriggerActionEnqueue(BaseModel):
171
171
  """Routes the invocation through a named queue for async processing.
172
172
 
173
+ Requires a queue worker in the project — run ``iii worker add queue``.
174
+ Without it the trigger rejects with ``enqueue_error`` (no queue provider).
175
+
173
176
  Attributes:
174
177
  type: Always ``'enqueue'``.
175
178
  queue: Name of the target queue.
@@ -21,10 +21,6 @@ def _unique_function_id(prefix: str) -> str:
21
21
  return f"{prefix}::{int(time.time())}::{random.random():.10f}".replace(".", "")
22
22
 
23
23
 
24
- def _unique_topic(prefix: str) -> str:
25
- return f"{prefix}.{int(time.time())}.{random.random():.10f}".replace(".", "")
26
-
27
-
28
24
  class WebhookProbe:
29
25
  def __init__(self) -> None:
30
26
  self._received: list[dict[str, Any]] = []
@@ -191,6 +187,21 @@ def _make_integration_client() -> III:
191
187
  return client
192
188
 
193
189
 
190
+ async def _invoke(client: III, function_id: str, payload: dict[str, Any]) -> Any:
191
+ """Directly invoke an HTTP external function without blocking the test loop.
192
+
193
+ ``client.trigger`` is synchronous and blocks the caller until the engine's
194
+ webhook POST round-trips. Since the WebhookProbe server runs on this same
195
+ event loop, calling it inline would deadlock -- so we run it on a worker
196
+ thread and let the loop keep serving the probe.
197
+ """
198
+ loop = asyncio.get_running_loop()
199
+ return await loop.run_in_executor(
200
+ None,
201
+ lambda: client.trigger({"function_id": function_id, "payload": payload}),
202
+ )
203
+
204
+
194
205
  # ===========================================================================
195
206
  # Unit tests (FakeWs)
196
207
  # ===========================================================================
@@ -282,16 +293,14 @@ def test_unregister_removes_function_from_sent_messages(monkeypatch: pytest.Monk
282
293
 
283
294
 
284
295
  @pytest.mark.asyncio
285
- async def test_delivers_queue_events_to_external_http_function() -> None:
296
+ async def test_delivers_events_to_external_http_function() -> None:
286
297
  client = _make_integration_client()
287
298
 
288
299
  probe = WebhookProbe()
289
300
  await probe.start()
290
301
 
291
302
  function_id = _unique_function_id("test::http_external::target")
292
- topic = _unique_topic("test.http_external.topic")
293
303
  payload = {"hello": "world", "count": 1}
294
- trigger = None
295
304
  http_fn = None
296
305
 
297
306
  try:
@@ -301,21 +310,16 @@ async def test_delivers_queue_events_to_external_http_function() -> None:
301
310
  )
302
311
  time.sleep(0.5)
303
312
 
304
- trigger = client.register_trigger(
305
- {"type": "durable:subscriber", "function_id": function_id, "config": {"topic": topic}}
306
- )
307
- time.sleep(0.5)
308
-
309
- client.trigger({"function_id": "iii::durable::publish", "payload": {"topic": topic, "data": payload}})
313
+ await _invoke(client, function_id, payload)
310
314
 
311
315
  webhook = await probe.wait_for_webhook(7.0)
312
316
 
313
317
  assert webhook["method"] == "POST"
314
318
  assert webhook["url"] == "/webhook"
315
- assert webhook["body"] == payload
319
+ # Direct invocation injects caller metadata (e.g. _caller_worker_id) into
320
+ # the POSTed body, so assert the expected payload is a subset.
321
+ assert payload.items() <= webhook["body"].items()
316
322
  finally:
317
- if trigger:
318
- trigger.unregister()
319
323
  if http_fn:
320
324
  http_fn.unregister()
321
325
  await probe.close()
@@ -381,9 +385,7 @@ async def test_delivers_events_with_custom_headers() -> None:
381
385
  await probe.start()
382
386
 
383
387
  function_id = _unique_function_id("test::http_external::custom_headers")
384
- topic = _unique_topic("test.http_external.headers")
385
388
  payload = {"event": "custom_header_test"}
386
- trigger = None
387
389
  http_fn = None
388
390
 
389
391
  try:
@@ -398,17 +400,14 @@ async def test_delivers_events_with_custom_headers() -> None:
398
400
  )
399
401
  time.sleep(0.5)
400
402
 
401
- trigger = client.register_trigger(
402
- {"type": "durable:subscriber", "function_id": function_id, "config": {"topic": topic}}
403
- )
404
- time.sleep(0.5)
405
-
406
- client.trigger({"function_id": "iii::durable::publish", "payload": {"topic": topic, "data": payload}})
403
+ await _invoke(client, function_id, payload)
407
404
 
408
405
  webhook = await probe.wait_for_webhook(7.0)
409
406
 
410
407
  assert webhook["method"] == "POST"
411
- assert webhook["body"] == payload
408
+ # Direct invocation injects caller metadata (e.g. _caller_worker_id) into
409
+ # the POSTed body, so assert the expected payload is a subset.
410
+ assert payload.items() <= webhook["body"].items()
412
411
 
413
412
  # Verify custom headers were forwarded by the engine.
414
413
  # Header keys may be lowercased by the HTTP client.
@@ -418,8 +417,6 @@ async def test_delivers_events_with_custom_headers() -> None:
418
417
  )
419
418
  assert received_headers.get("x-another") == "123", f"Expected x-another=123, got headers: {received_headers}"
420
419
  finally:
421
- if trigger:
422
- trigger.unregister()
423
420
  if http_fn:
424
421
  http_fn.unregister()
425
422
  await probe.close()
@@ -437,15 +434,11 @@ async def test_delivers_events_to_multiple_external_functions() -> None:
437
434
 
438
435
  fn_id_a = _unique_function_id("test::http_external::multi_a")
439
436
  fn_id_b = _unique_function_id("test::http_external::multi_b")
440
- topic_a = _unique_topic("test.http_external.multi_a")
441
- topic_b = _unique_topic("test.http_external.multi_b")
442
437
  payload_a = {"source": "topic_a", "value": 1}
443
438
  payload_b = {"source": "topic_b", "value": 2}
444
439
 
445
440
  http_fn_a = None
446
441
  http_fn_b = None
447
- trigger_a = None
448
- trigger_b = None
449
442
 
450
443
  try:
451
444
  http_fn_a = client.register_function(
@@ -458,31 +451,20 @@ async def test_delivers_events_to_multiple_external_functions() -> None:
458
451
  )
459
452
  time.sleep(0.5)
460
453
 
461
- trigger_a = client.register_trigger(
462
- {"type": "durable:subscriber", "function_id": fn_id_a, "config": {"topic": topic_a}}
463
- )
464
- trigger_b = client.register_trigger(
465
- {"type": "durable:subscriber", "function_id": fn_id_b, "config": {"topic": topic_b}}
466
- )
467
- time.sleep(0.5)
468
-
469
- client.trigger({"function_id": "iii::durable::publish", "payload": {"topic": topic_a, "data": payload_a}})
470
- client.trigger({"function_id": "iii::durable::publish", "payload": {"topic": topic_b, "data": payload_b}})
454
+ await _invoke(client, fn_id_a, payload_a)
455
+ await _invoke(client, fn_id_b, payload_b)
471
456
 
472
457
  webhook_a = await probe_a.wait_for_webhook(7.0)
473
458
  webhook_b = await probe_b.wait_for_webhook(7.0)
474
459
 
475
- # Each probe should receive only its own topic's event.
476
- assert webhook_a["body"] == payload_a, f"probe_a got wrong body: {webhook_a['body']}"
460
+ # Each probe should receive only its own function's event. Direct
461
+ # invocation injects caller metadata into the body, so assert subset.
462
+ assert payload_a.items() <= webhook_a["body"].items(), f"probe_a got wrong body: {webhook_a['body']}"
477
463
  assert webhook_a["url"] == "/hook_a"
478
464
 
479
- assert webhook_b["body"] == payload_b, f"probe_b got wrong body: {webhook_b['body']}"
465
+ assert payload_b.items() <= webhook_b["body"].items(), f"probe_b got wrong body: {webhook_b['body']}"
480
466
  assert webhook_b["url"] == "/hook_b"
481
467
  finally:
482
- if trigger_a:
483
- trigger_a.unregister()
484
- if trigger_b:
485
- trigger_b.unregister()
486
468
  if http_fn_a:
487
469
  http_fn_a.unregister()
488
470
  if http_fn_b:
@@ -500,10 +482,8 @@ async def test_stops_delivering_after_unregister() -> None:
500
482
  await probe.start()
501
483
 
502
484
  function_id = _unique_function_id("test::http_external::stop_deliver")
503
- topic = _unique_topic("test.http_external.stop_deliver")
504
485
  payload_before = {"phase": "before_unregister"}
505
486
  payload_after = {"phase": "after_unregister"}
506
- trigger = None
507
487
  http_fn = None
508
488
 
509
489
  try:
@@ -513,30 +493,27 @@ async def test_stops_delivering_after_unregister() -> None:
513
493
  )
514
494
  time.sleep(0.5)
515
495
 
516
- trigger = client.register_trigger(
517
- {"type": "durable:subscriber", "function_id": function_id, "config": {"topic": topic}}
518
- )
519
- time.sleep(0.5)
520
-
521
- # First enqueue -- should be delivered.
522
- client.trigger({"function_id": "iii::durable::publish", "payload": {"topic": topic, "data": payload_before}})
496
+ # First invocation -- should be delivered.
497
+ await _invoke(client, function_id, payload_before)
523
498
  webhook = await probe.wait_for_webhook(7.0)
524
- assert webhook["body"] == payload_before
499
+ # Direct invocation injects caller metadata (e.g. _caller_worker_id) into
500
+ # the POSTed body, so assert the expected payload is a subset.
501
+ assert payload_before.items() <= webhook["body"].items()
525
502
 
526
- # Unregister trigger and function.
527
- trigger.unregister()
528
- trigger = None
503
+ # Unregister the function.
529
504
  http_fn.unregister()
530
505
  http_fn = None
531
506
  time.sleep(0.5)
532
507
 
533
- # Second enqueue -- should NOT be delivered (function is gone).
534
- client.trigger({"function_id": "iii::durable::publish", "payload": {"topic": topic, "data": payload_after}})
508
+ # Second invocation -- the function is gone, so the trigger is rejected
509
+ # and nothing reaches the webhook.
510
+ try:
511
+ await _invoke(client, function_id, payload_after)
512
+ except Exception:
513
+ pass
535
514
  no_delivery = await probe.wait_for_webhook_or_none(timeout=2.0)
536
515
  assert no_delivery is None, f"Expected no delivery after unregister, but got: {no_delivery}"
537
516
  finally:
538
- if trigger:
539
- trigger.unregister()
540
517
  if http_fn:
541
518
  http_fn.unregister()
542
519
  await probe.close()
@@ -551,9 +528,7 @@ async def test_delivers_with_put_method() -> None:
551
528
  await probe.start()
552
529
 
553
530
  function_id = _unique_function_id("test::http_external::put_method")
554
- topic = _unique_topic("test.http_external.put_method")
555
531
  payload = {"action": "update", "id": 42}
556
- trigger = None
557
532
  http_fn = None
558
533
 
559
534
  try:
@@ -563,20 +538,15 @@ async def test_delivers_with_put_method() -> None:
563
538
  )
564
539
  time.sleep(0.5)
565
540
 
566
- trigger = client.register_trigger(
567
- {"type": "durable:subscriber", "function_id": function_id, "config": {"topic": topic}}
568
- )
569
- time.sleep(0.5)
570
-
571
- client.trigger({"function_id": "iii::durable::publish", "payload": {"topic": topic, "data": payload}})
541
+ await _invoke(client, function_id, payload)
572
542
 
573
543
  webhook = await probe.wait_for_webhook(7.0)
574
544
 
575
545
  assert webhook["method"] == "PUT", f"Expected PUT, got {webhook['method']}"
576
- assert webhook["body"] == payload
546
+ # Direct invocation injects caller metadata (e.g. _caller_worker_id) into
547
+ # the POSTed body, so assert the expected payload is a subset.
548
+ assert payload.items() <= webhook["body"].items()
577
549
  finally:
578
- if trigger:
579
- trigger.unregister()
580
550
  if http_fn:
581
551
  http_fn.unregister()
582
552
  await probe.close()
@@ -0,0 +1,19 @@
1
+ """Contract test for the SDK-owned TriggerAction builders.
2
+
3
+ The queue *behavior* (delivery, ordering, DLQ, durable fan-out) is exercised by
4
+ the standalone queue worker's own suite; here we only pin the wire shape the
5
+ engine's ``TriggerAction`` deserialization depends on, so it needs no live engine.
6
+ """
7
+
8
+ from iii.iii_types import TriggerActionEnqueue, TriggerActionVoid
9
+
10
+
11
+ def test_enqueue_serializes_to_type_and_queue() -> None:
12
+ assert TriggerActionEnqueue(queue="orders").model_dump() == {
13
+ "type": "enqueue",
14
+ "queue": "orders",
15
+ }
16
+
17
+
18
+ def test_void_serializes_to_type() -> None:
19
+ assert TriggerActionVoid().model_dump() == {"type": "void"}
@@ -1,363 +0,0 @@
1
- """Integration tests for queue enqueue and void trigger actions."""
2
-
3
- import time
4
-
5
- from iii import TriggerAction
6
- from iii.iii import III
7
-
8
-
9
- def wait_for(condition, timeout=5.0, interval=0.1):
10
- """Poll until condition() is truthy or timeout."""
11
- deadline = time.monotonic() + timeout
12
- while time.monotonic() < deadline:
13
- if condition():
14
- return
15
- time.sleep(interval)
16
- raise TimeoutError(f"Condition not met within {timeout}s")
17
-
18
-
19
- def _wait_for_registration(iii_client: III, function_id: str, timeout: float = 5.0) -> None:
20
- """Poll engine::functions::list until function_id is registered, or TimeoutError."""
21
- deadline = time.monotonic() + timeout
22
- while time.monotonic() < deadline:
23
- try:
24
- result = iii_client.trigger({"function_id": "engine::functions::list", "payload": {}})
25
- functions = result.get("functions", []) if isinstance(result, dict) else []
26
- ids = [f.get("function_id") for f in functions if isinstance(f, dict)]
27
- if function_id in ids:
28
- return
29
- except Exception:
30
- pass
31
- time.sleep(0.1)
32
- raise TimeoutError(f"Function {function_id} was not registered within {timeout}s")
33
-
34
-
35
- def test_enqueue_delivers_message_to_function(iii_client: III):
36
- """Enqueue action delivers payload to the registered consumer function."""
37
- received = []
38
-
39
- def consumer_handler(input_data):
40
- received.append(input_data)
41
- return None
42
-
43
- ref = iii_client.register_function("test.queue.py.consumer", consumer_handler)
44
- time.sleep(0.3)
45
-
46
- try:
47
- result = iii_client.trigger(
48
- {
49
- "function_id": "test.queue.py.consumer",
50
- "payload": {"order": "pizza"},
51
- "action": TriggerAction.Enqueue(queue="test-orders"),
52
- }
53
- )
54
-
55
- assert isinstance(result, dict)
56
- assert isinstance(result["messageReceiptId"], str)
57
-
58
- wait_for(lambda: len(received) > 0, timeout=5.0)
59
-
60
- assert received[0]["order"] == "pizza"
61
- finally:
62
- ref.unregister()
63
-
64
-
65
- def test_void_trigger_returns_none(iii_client: III):
66
- """Void trigger action fires without waiting and returns None."""
67
- calls = []
68
-
69
- def void_consumer_handler(input_data):
70
- calls.append(input_data)
71
- return None
72
-
73
- ref = iii_client.register_function("test.queue.py.void-consumer", void_consumer_handler)
74
- time.sleep(0.3)
75
-
76
- try:
77
- result = iii_client.trigger(
78
- {
79
- "function_id": "test.queue.py.void-consumer",
80
- "payload": {"msg": "fire"},
81
- "action": TriggerAction.Void(),
82
- }
83
- )
84
-
85
- assert result is None
86
-
87
- wait_for(lambda: len(calls) > 0, timeout=5.0)
88
-
89
- assert calls[0]["msg"] == "fire"
90
- finally:
91
- ref.unregister()
92
-
93
-
94
- def test_enqueue_multiple_messages(iii_client: III):
95
- """Multiple enqueued messages are all delivered to the consumer."""
96
- received = []
97
-
98
- def multi_handler(input_data):
99
- received.append(input_data)
100
- return None
101
-
102
- ref = iii_client.register_function("test.queue.py.multi", multi_handler)
103
- time.sleep(0.3)
104
-
105
- try:
106
- for i in range(5):
107
- iii_client.trigger(
108
- {
109
- "function_id": "test.queue.py.multi",
110
- "payload": {"index": i},
111
- "action": TriggerAction.Enqueue(queue="test-multi"),
112
- }
113
- )
114
-
115
- wait_for(lambda: len(received) == 5, timeout=10.0)
116
-
117
- assert len(received) == 5
118
- received_indices = sorted(item["index"] for item in received)
119
- assert received_indices == [0, 1, 2, 3, 4]
120
- finally:
121
- ref.unregister()
122
-
123
-
124
- def test_chained_enqueue(iii_client: III):
125
- """Function A enqueues a message to function B, forming a chain."""
126
- chain_received = []
127
-
128
- def chain_a_handler(input_data):
129
- iii_client.trigger(
130
- {
131
- "function_id": "test.queue.py.chain-b",
132
- "payload": {**input_data, "chained": True},
133
- "action": TriggerAction.Enqueue(queue="test-chain"),
134
- }
135
- )
136
- return input_data
137
-
138
- def chain_b_handler(input_data):
139
- chain_received.append(input_data)
140
- return None
141
-
142
- ref_a = iii_client.register_function("test.queue.py.chain-a", chain_a_handler)
143
- ref_b = iii_client.register_function("test.queue.py.chain-b", chain_b_handler)
144
- time.sleep(0.3)
145
-
146
- try:
147
- iii_client.trigger(
148
- {
149
- "function_id": "test.queue.py.chain-a",
150
- "payload": {"origin": "test"},
151
- "action": TriggerAction.Enqueue(queue="test-chain"),
152
- }
153
- )
154
-
155
- wait_for(lambda: len(chain_received) > 0, timeout=5.0)
156
-
157
- assert chain_received[0]["chained"] is True
158
- assert chain_received[0]["origin"] == "test"
159
- finally:
160
- ref_b.unregister()
161
- ref_a.unregister()
162
-
163
-
164
- # ---------------------------------------------------------------------------
165
- # Durable subscriber / publisher scenarios (ported from motia-py, MOT-3109).
166
- # These exercise the `durable:subscriber` trigger + `iii::durable::publish`
167
- # path, complementing the TriggerAction.Enqueue tests above.
168
- # ---------------------------------------------------------------------------
169
-
170
-
171
- def test_durable_publish_delivers_to_subscribed_handler(iii_client: III):
172
- """Publishing to a topic delivers the message to a subscribed handler."""
173
- function_id = f"test.queue.py.durable.basic.{int(time.time() * 1000)}"
174
- topic = f"test-topic-{int(time.time() * 1000)}"
175
- received: list = []
176
-
177
- def handler(data):
178
- received.append(data)
179
- return None
180
-
181
- fn_ref = iii_client.register_function(function_id, handler)
182
- trigger = iii_client.register_trigger(
183
- {"type": "durable:subscriber", "function_id": function_id, "config": {"topic": topic}}
184
- )
185
- time.sleep(0.5)
186
-
187
- try:
188
- iii_client.trigger(
189
- {
190
- "function_id": "iii::durable::publish",
191
- "payload": {"topic": topic, "data": {"order": "abc"}},
192
- }
193
- )
194
- wait_for(lambda: len(received) > 0, timeout=5.0)
195
- assert received == [{"order": "abc"}]
196
- finally:
197
- trigger.unregister()
198
- fn_ref.unregister()
199
-
200
-
201
- def test_durable_publish_delivers_exact_payload(iii_client: III):
202
- """Handler receives the exact published payload without mutation."""
203
- function_id = f"test.queue.py.durable.payload.{int(time.time() * 1000)}"
204
- topic = f"test-topic-payload-{int(time.time() * 1000)}"
205
- payload = {"id": "x1", "count": 42, "nested": {"a": 1}}
206
- received: list = []
207
-
208
- def handler(data):
209
- received.append(data)
210
- return None
211
-
212
- fn_ref = iii_client.register_function(function_id, handler)
213
- trigger = iii_client.register_trigger(
214
- {"type": "durable:subscriber", "function_id": function_id, "config": {"topic": topic}}
215
- )
216
- time.sleep(0.5)
217
-
218
- try:
219
- iii_client.trigger(
220
- {
221
- "function_id": "iii::durable::publish",
222
- "payload": {"topic": topic, "data": payload},
223
- }
224
- )
225
- wait_for(lambda: len(received) > 0, timeout=5.0)
226
- assert received == [payload]
227
- finally:
228
- trigger.unregister()
229
- fn_ref.unregister()
230
-
231
-
232
- def test_durable_subscriber_with_queue_config(iii_client: III):
233
- """Subscription with queue_config (maxRetries, concurrency, type) still delivers."""
234
- function_id = f"test.queue.py.durable.infra.{int(time.time() * 1000)}"
235
- topic = f"test-topic-infra-{int(time.time() * 1000)}"
236
- received: list = []
237
-
238
- def handler(data):
239
- received.append(data)
240
- return None
241
-
242
- fn_ref = iii_client.register_function(function_id, handler)
243
- trigger = iii_client.register_trigger(
244
- {
245
- "type": "durable:subscriber",
246
- "function_id": function_id,
247
- "config": {
248
- "topic": topic,
249
- "queue_config": {
250
- "maxRetries": 5,
251
- "type": "standard",
252
- "concurrency": 2,
253
- },
254
- },
255
- }
256
- )
257
- time.sleep(0.5)
258
-
259
- try:
260
- iii_client.trigger(
261
- {
262
- "function_id": "iii::durable::publish",
263
- "payload": {"topic": topic, "data": {"infra": True}},
264
- }
265
- )
266
- wait_for(lambda: len(received) > 0, timeout=5.0)
267
- assert received == [{"infra": True}]
268
- finally:
269
- trigger.unregister()
270
- fn_ref.unregister()
271
-
272
-
273
- def test_durable_multiple_subscribers_fanout(iii_client: III):
274
- """Multiple subscribers on the same topic each receive every message."""
275
- topic = f"test-topic-multi-{int(time.time() * 1000)}"
276
- function_id1 = f"test.queue.py.durable.multi1.{int(time.time() * 1000)}"
277
- function_id2 = f"test.queue.py.durable.multi2.{int(time.time() * 1000)}"
278
- received1: list = []
279
- received2: list = []
280
-
281
- def handler1(data):
282
- received1.append(data)
283
- return None
284
-
285
- def handler2(data):
286
- received2.append(data)
287
- return None
288
-
289
- fn1 = iii_client.register_function(function_id1, handler1)
290
- fn2 = iii_client.register_function(function_id2, handler2)
291
- trigger1 = iii_client.register_trigger(
292
- {"type": "durable:subscriber", "function_id": function_id1, "config": {"topic": topic}}
293
- )
294
- trigger2 = iii_client.register_trigger(
295
- {"type": "durable:subscriber", "function_id": function_id2, "config": {"topic": topic}}
296
- )
297
- time.sleep(0.5)
298
-
299
- try:
300
- iii_client.trigger({"function_id": "iii::durable::publish", "payload": {"topic": topic, "data": {"msg": 1}}})
301
- iii_client.trigger({"function_id": "iii::durable::publish", "payload": {"topic": topic, "data": {"msg": 2}}})
302
- wait_for(lambda: len(received1) >= 2 and len(received2) >= 2, timeout=8.0)
303
-
304
- assert len(received1) == 2
305
- assert len(received2) == 2
306
- assert {"msg": 1} in received1
307
- assert {"msg": 2} in received1
308
- assert {"msg": 1} in received2
309
- assert {"msg": 2} in received2
310
- finally:
311
- trigger1.unregister()
312
- trigger2.unregister()
313
- fn1.unregister()
314
- fn2.unregister()
315
-
316
-
317
- def test_durable_condition_function_filters_messages(iii_client: III):
318
- """Registering a condition_function_id filters out non-matching messages."""
319
- topic = f"test-topic-cond-{int(time.time() * 1000)}"
320
- function_id = f"test.queue.py.durable.cond.{int(time.time() * 1000)}"
321
- condition_path = f"{function_id}::conditions::0"
322
- handler_calls: list = []
323
-
324
- def handler(data):
325
- handler_calls.append(data)
326
- return None
327
-
328
- def condition(input_data):
329
- return bool(input_data.get("accept"))
330
-
331
- fn_ref = iii_client.register_function(function_id, handler)
332
- cond_ref = iii_client.register_function(condition_path, condition)
333
- trigger = iii_client.register_trigger(
334
- {
335
- "type": "durable:subscriber",
336
- "function_id": function_id,
337
- "config": {
338
- "topic": topic,
339
- "condition_function_id": condition_path,
340
- },
341
- }
342
- )
343
-
344
- try:
345
- _wait_for_registration(iii_client, function_id)
346
- _wait_for_registration(iii_client, condition_path)
347
-
348
- iii_client.trigger(
349
- {"function_id": "iii::durable::publish", "payload": {"topic": topic, "data": {"accept": False}}}
350
- )
351
- iii_client.trigger(
352
- {"function_id": "iii::durable::publish", "payload": {"topic": topic, "data": {"accept": True}}}
353
- )
354
- wait_for(lambda: len(handler_calls) >= 1, timeout=8.0)
355
- # Give the rejected message a moment to ensure it does NOT arrive.
356
- time.sleep(1.0)
357
-
358
- assert len(handler_calls) == 1
359
- assert handler_calls[0] == {"accept": True}
360
- finally:
361
- trigger.unregister()
362
- cond_ref.unregister()
363
- fn_ref.unregister()
File without changes
File without changes
File without changes