iii-sdk 0.20.0.dev1__tar.gz → 0.21.0.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 (73) hide show
  1. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/PKG-INFO +2 -2
  2. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/pyproject.toml +2 -2
  3. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/src/iii/iii.py +78 -13
  4. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/src/iii/iii_types.py +11 -4
  5. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_invocation_exception.py +2 -2
  6. iii_sdk-0.21.0.dev1/tests/test_invocation_metadata.py +298 -0
  7. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/uv.lock +2 -2
  8. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/.gitignore +0 -0
  9. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/README.md +0 -0
  10. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/src/iii/__init__.py +0 -0
  11. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/src/iii/channel.py +0 -0
  12. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/src/iii/channels.py +0 -0
  13. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/src/iii/engine.py +0 -0
  14. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/src/iii/errors.py +0 -0
  15. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/src/iii/format_utils.py +0 -0
  16. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/src/iii/helpers.py +0 -0
  17. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/src/iii/iii_constants.py +0 -0
  18. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/src/iii/internal.py +0 -0
  19. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/src/iii/otel_worker_gauges.py +0 -0
  20. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/src/iii/protocol.py +0 -0
  21. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/src/iii/runtime.py +0 -0
  22. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/src/iii/state.py +0 -0
  23. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/src/iii/stream.py +0 -0
  24. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/src/iii/trigger.py +0 -0
  25. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/src/iii/triggers.py +0 -0
  26. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/src/iii/types.py +0 -0
  27. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/src/iii/utils.py +0 -0
  28. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/src/iii/worker_metrics.py +0 -0
  29. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/conftest.py +0 -0
  30. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_api_triggers.py +0 -0
  31. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_async_api.py +0 -0
  32. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_baggage_span_processor.py +0 -0
  33. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_bridge.py +0 -0
  34. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_channel_close_delay.py +0 -0
  35. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_channel_submodule.py +0 -0
  36. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_context_propagation.py +0 -0
  37. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_data_channels.py +0 -0
  38. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_engine_constants.py +0 -0
  39. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_errors.py +0 -0
  40. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_format_utils.py +0 -0
  41. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_healthcheck.py +0 -0
  42. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_helpers.py +0 -0
  43. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_hold_process.py +0 -0
  44. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_http_external_functions_integration.py +0 -0
  45. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_iii_registration_dedup.py +0 -0
  46. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_init_api.py +0 -0
  47. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_logger_function_ids.py +0 -0
  48. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_logger_otel.py +0 -0
  49. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_middleware.py +0 -0
  50. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_payload.py +0 -0
  51. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_pubsub.py +0 -0
  52. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_queue_integration.py +0 -0
  53. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_rbac_workers.py +0 -0
  54. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_register_function_args.py +0 -0
  55. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_runtime_submodule.py +0 -0
  56. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_span_ops.py +0 -0
  57. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_state.py +0 -0
  58. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_stream_models.py +0 -0
  59. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_stream_types.py +0 -0
  60. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_streams.py +0 -0
  61. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_streams_runtime_annotations.py +0 -0
  62. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_sync_api.py +0 -0
  63. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_telemetry.py +0 -0
  64. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_telemetry_exporters.py +0 -0
  65. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_telemetry_types.py +0 -0
  66. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_trace_helpers.py +0 -0
  67. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_trigger_metadata.py +0 -0
  68. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_trigger_registration_error.py +0 -0
  69. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_trigger_submodule.py +0 -0
  70. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_trigger_type_lifecycle.py +0 -0
  71. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_utils.py +0 -0
  72. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_worker_metadata.py +0 -0
  73. {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_worker_metrics.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iii-sdk
3
- Version: 0.20.0.dev1
3
+ Version: 0.21.0.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.20.0.dev1
17
+ Requires-Dist: iii-helpers==0.21.0.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.20.0.dev1"
7
+ version = "0.21.0.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.20.0.dev1",
26
+ "iii-helpers==0.21.0.dev1",
27
27
  ]
28
28
 
29
29
  [project.urls]
@@ -1,6 +1,7 @@
1
1
  """III SDK implementation for WebSocket communication with the III Engine."""
2
2
 
3
3
  import asyncio
4
+ import inspect
4
5
  import json
5
6
  import logging
6
7
  import os
@@ -64,6 +65,45 @@ TResult = TypeVar("TResult")
64
65
  log = logging.getLogger("iii.iii")
65
66
 
66
67
 
68
+ def _metadata_passing_mode(handler: Callable[..., Any]) -> str:
69
+ """Decide how (if at all) to forward per-invocation metadata to a handler.
70
+
71
+ Only handlers that explicitly declare a parameter named ``metadata``
72
+ receive the sidecar; every other signature keeps its exact pre-metadata
73
+ call shape (``handler(data)``), so existing handlers -- including ones
74
+ with unrelated optional parameters, ``*args``, or ``**kwargs`` -- keep
75
+ working unchanged. Returns one of:
76
+
77
+ - ``"positional"``: ``metadata`` is the second positional parameter
78
+ (e.g. ``def handler(data, metadata=None)``).
79
+ - ``"keyword"``: ``metadata`` is keyword-only
80
+ (e.g. ``def handler(data, *, metadata=None)``).
81
+ - ``"none"``: no ``metadata`` parameter -- omit metadata.
82
+
83
+ If the signature cannot be introspected (some builtins/C callables),
84
+ falls back to ``"none"`` to preserve back-compat.
85
+ """
86
+ try:
87
+ sig = inspect.signature(handler)
88
+ except (ValueError, TypeError):
89
+ return "none"
90
+
91
+ meta_param = sig.parameters.get("metadata")
92
+ if meta_param is None:
93
+ return "none"
94
+ if meta_param.kind == inspect.Parameter.KEYWORD_ONLY:
95
+ return "keyword"
96
+
97
+ positional = [
98
+ p
99
+ for p in sig.parameters.values()
100
+ if p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
101
+ ]
102
+ if len(positional) >= 2 and positional[1].name == "metadata":
103
+ return "positional"
104
+ return "none"
105
+
106
+
67
107
  def _resolve_format(fmt: Any) -> Any | None:
68
108
  """Resolve a format value: if it's a type (e.g. Pydantic model), convert to JSON Schema."""
69
109
  if fmt is None:
@@ -434,6 +474,7 @@ class III:
434
474
  data.get("data"),
435
475
  data.get("traceparent"),
436
476
  data.get("baggage"),
477
+ data.get("metadata"),
437
478
  )
438
479
  )
439
480
  elif msg_type == MessageType.REGISTER_TRIGGER.value:
@@ -483,10 +524,11 @@ class III:
483
524
  async def _invoke_with_otel_context(
484
525
  self,
485
526
  function_id: str,
486
- handler: Callable[[Any], Awaitable[Any]],
527
+ handler: Callable[..., Awaitable[Any]],
487
528
  data: Any,
488
529
  traceparent: str | None,
489
530
  baggage: str | None,
531
+ metadata: dict[str, Any] | None = None,
490
532
  ) -> tuple[Any, str | None]:
491
533
  from opentelemetry import context as otel_context
492
534
  from opentelemetry import propagate, trace
@@ -531,7 +573,7 @@ class III:
531
573
  },
532
574
  )
533
575
  try:
534
- result = await handler(data)
576
+ result = await handler(data, metadata)
535
577
  if trace_payloads and span.is_recording():
536
578
  out_json, out_truncated = redact_and_truncate(result, payload_max_bytes)
537
579
  span.add_event(
@@ -586,6 +628,7 @@ class III:
586
628
  data: Any,
587
629
  traceparent: str | None = None,
588
630
  baggage: str | None = None,
631
+ metadata: dict[str, Any] | None = None,
589
632
  ) -> None:
590
633
  func = self._functions.get(path)
591
634
 
@@ -627,7 +670,7 @@ class III:
627
670
  if not invocation_id:
628
671
  task = asyncio.create_task(
629
672
  self._invoke_with_otel_context(
630
- path, func.handler, resolved_data, traceparent, baggage
673
+ path, func.handler, resolved_data, traceparent, baggage, metadata
631
674
  )
632
675
  )
633
676
  task.add_done_callback(self._log_task_exception)
@@ -640,6 +683,7 @@ class III:
640
683
  resolved_data,
641
684
  traceparent,
642
685
  baggage,
686
+ metadata,
643
687
  )
644
688
  await self._send(
645
689
  InvocationResultMessage(
@@ -927,8 +971,14 @@ class III:
927
971
 
928
972
  Handlers can be synchronous or asynchronous. Sync handlers are
929
973
  automatically wrapped with ``run_in_executor`` so they do not
930
- block the event loop. Each handler receives a single ``data``
931
- argument containing the trigger payload.
974
+ block the event loop. Each handler receives a ``data`` argument
975
+ containing the trigger payload, and may optionally accept a second
976
+ ``metadata`` argument carrying per-invocation metadata (e.g.
977
+ ``def handler(data, metadata=None)`` or ``def handler(data, *,
978
+ metadata=None)``). Metadata is only forwarded to handlers that
979
+ declare a parameter literally named ``metadata``, so existing
980
+ handlers -- including ones with unrelated extra parameters,
981
+ ``*args``, or ``**kwargs`` -- keep working unchanged.
932
982
 
933
983
  ``request_format`` and ``response_format`` are auto-extracted
934
984
  from the handler's type hints when omitted or passed as ``None``
@@ -940,9 +990,10 @@ class III:
940
990
  Args:
941
991
  function_id: Unique string identifier for the function.
942
992
  handler_or_invocation: A callable handler or
943
- ``HttpInvocationConfig``. Callable handlers receive one
944
- positional argument (``data`` -- the trigger payload) and
945
- may return a value.
993
+ ``HttpInvocationConfig``. Callable handlers receive
994
+ ``data`` (the trigger payload) as the first argument and
995
+ may optionally accept ``metadata`` (per-invocation
996
+ metadata) as a second argument; they may return a value.
946
997
  description: Human-readable description.
947
998
  metadata: Arbitrary metadata.
948
999
  request_format: Schema describing expected input. When
@@ -1020,7 +1071,7 @@ class III:
1020
1071
  raise TypeError(
1021
1072
  f"handler_or_invocation must be callable or HttpInvocationConfig, got {actual_type}"
1022
1073
  )
1023
- handler = handler_or_invocation
1074
+ handler = cast("Callable[..., Any]", handler_or_invocation)
1024
1075
  msg = RegisterFunctionMessage(
1025
1076
  id=func.id,
1026
1077
  description=func.description,
@@ -1030,20 +1081,31 @@ class III:
1030
1081
  )
1031
1082
  self._send_if_connected(msg)
1032
1083
 
1084
+ # Decide once, at registration time, whether this handler accepts
1085
+ # per-invocation metadata. 1-arg handlers keep working unchanged.
1086
+ metadata_mode = _metadata_passing_mode(handler)
1087
+
1088
+ def _call_handler(input_data: Any, metadata: dict[str, Any] | None) -> Any:
1089
+ if metadata_mode == "positional":
1090
+ return handler(input_data, metadata)
1091
+ if metadata_mode == "keyword":
1092
+ return handler(input_data, metadata=metadata)
1093
+ return handler(input_data)
1094
+
1033
1095
  if asyncio.iscoroutinefunction(handler):
1034
1096
 
1035
- async def wrapped(input_data: Any) -> Any:
1036
- return await handler(input_data)
1097
+ async def wrapped(input_data: Any, metadata: dict[str, Any] | None = None) -> Any:
1098
+ return await _call_handler(input_data, metadata)
1037
1099
 
1038
1100
  else:
1039
1101
 
1040
- async def wrapped(input_data: Any) -> Any:
1102
+ async def wrapped(input_data: Any, metadata: dict[str, Any] | None = None) -> Any:
1041
1103
  loop = asyncio.get_running_loop()
1042
1104
  future: asyncio.Future[Any] = loop.create_future()
1043
1105
 
1044
1106
  def _run() -> None:
1045
1107
  try:
1046
- result = handler(input_data)
1108
+ result = _call_handler(input_data, metadata)
1047
1109
  loop.call_soon_threadsafe(future.set_result, result)
1048
1110
  except BaseException as exc:
1049
1111
  loop.call_soon_threadsafe(future.set_exception, exc)
@@ -1120,6 +1182,7 @@ class III:
1120
1182
  function_id = req["function_id"]
1121
1183
  payload = req.get("payload")
1122
1184
  action = req.get("action")
1185
+ metadata = req.get("metadata")
1123
1186
 
1124
1187
  timeout_ms = req.get("timeout_ms") or self._options.invocation_timeout_ms
1125
1188
 
@@ -1137,6 +1200,7 @@ class III:
1137
1200
  InvokeFunctionMessage(
1138
1201
  function_id=function_id,
1139
1202
  data=payload,
1203
+ metadata=metadata,
1140
1204
  traceparent=self._inject_traceparent(),
1141
1205
  baggage=self._inject_baggage(),
1142
1206
  action=action,
@@ -1160,6 +1224,7 @@ class III:
1160
1224
  InvokeFunctionMessage(
1161
1225
  function_id=function_id,
1162
1226
  data=payload,
1227
+ metadata=metadata,
1163
1228
  invocation_id=invocation_id,
1164
1229
  traceparent=self._inject_traceparent(),
1165
1230
  baggage=self._inject_baggage(),
@@ -92,7 +92,7 @@ class RegisterTriggerInput(BaseModel):
92
92
  type: str = Field(description="Trigger type identifier.")
93
93
  function_id: str = Field(description="ID of the function this trigger invokes.")
94
94
  config: Any = Field(default=None, description="Trigger-type-specific configuration.")
95
- metadata: dict[str, Any] | None = Field(
95
+ metadata: Any | None = Field(
96
96
  default=None, description="Arbitrary metadata attached to the trigger."
97
97
  )
98
98
 
@@ -104,7 +104,7 @@ class RegisterTriggerMessage(BaseModel):
104
104
  trigger_type: str = Field(alias="trigger_type")
105
105
  function_id: str = Field()
106
106
  config: Any
107
- metadata: dict[str, Any] | None = Field(default=None)
107
+ metadata: Any | None = Field(default=None)
108
108
  message_type: MessageType = Field(default=MessageType.REGISTER_TRIGGER, alias="type")
109
109
 
110
110
 
@@ -148,7 +148,7 @@ class RegisterFunctionInput(BaseModel):
148
148
  response_format: RegisterFunctionFormat | dict[str, Any] | None = Field(
149
149
  default=None, description="Schema describing expected output."
150
150
  )
151
- metadata: dict[str, Any] | None = Field(default=None, description="Arbitrary metadata attached to the function.")
151
+ metadata: Any | None = Field(default=None, description="Arbitrary metadata attached to the function.")
152
152
  invocation: HttpInvocationConfig | None = Field(
153
153
  default=None,
154
154
  description="HTTP invocation config for externally hosted functions.",
@@ -162,7 +162,7 @@ class RegisterFunctionMessage(BaseModel):
162
162
  description: str | None = None
163
163
  request_format: RegisterFunctionFormat | dict[str, Any] | None = Field(default=None)
164
164
  response_format: RegisterFunctionFormat | dict[str, Any] | None = Field(default=None)
165
- metadata: dict[str, Any] | None = None
165
+ metadata: Any | None = None
166
166
  invocation: HttpInvocationConfig | None = None
167
167
  message_type: MessageType = Field(default=MessageType.REGISTER_FUNCTION, alias="type")
168
168
 
@@ -223,6 +223,8 @@ class TriggerRequest(BaseModel):
223
223
  action: Routing action, ``None`` for sync, ``TriggerAction.Enqueue(...)``
224
224
  for queue, ``TriggerAction.Void()`` for fire-and-forget.
225
225
  timeout_ms: Override the default invocation timeout.
226
+ metadata: Arbitrary per-invocation metadata delivered to the handler
227
+ as a separate channel (not folded into ``payload``).
226
228
  """
227
229
 
228
230
  function_id: str = Field(description="ID of the function to invoke.")
@@ -236,6 +238,10 @@ class TriggerRequest(BaseModel):
236
238
  ),
237
239
  )
238
240
  timeout_ms: int | None = Field(default=None, description="Override the default invocation timeout.")
241
+ metadata: Any | None = Field(
242
+ default=None,
243
+ description="Arbitrary per-invocation metadata delivered to the handler.",
244
+ )
239
245
 
240
246
 
241
247
  class InvokeFunctionMessage(BaseModel):
@@ -243,6 +249,7 @@ class InvokeFunctionMessage(BaseModel):
243
249
 
244
250
  function_id: str = Field()
245
251
  data: Any
252
+ metadata: Any | None = Field(default=None)
246
253
  invocation_id: str | None = Field(default=None)
247
254
  traceparent: str | None = Field(default=None)
248
255
  baggage: str | None = Field(default=None)
@@ -30,7 +30,7 @@ async def test_invoke_with_otel_context_records_exception_with_stacktrace():
30
30
  client._queue = []
31
31
  client._running = False
32
32
 
33
- async def failing_handler(data):
33
+ async def failing_handler(data, metadata=None):
34
34
  raise ValueError("test invocation error")
35
35
 
36
36
  with pytest.raises(_TraceContextError) as exc_info:
@@ -78,7 +78,7 @@ async def test_invoke_with_otel_context_success_no_exception():
78
78
  client._queue = []
79
79
  client._running = False
80
80
 
81
- async def success_handler(data):
81
+ async def success_handler(data, metadata=None):
82
82
  return {"result": "ok"}
83
83
 
84
84
  result, traceparent = await client._invoke_with_otel_context("test.fn", success_handler, {"key": "value"}, None, None)
@@ -0,0 +1,298 @@
1
+ """Tests for per-invocation metadata support.
2
+
3
+ Covers the wire mirror (``InvokeFunctionMessage.metadata``), the handler
4
+ signature convention (metadata is forwarded only when the handler accepts
5
+ it -- 1-arg handlers keep working), and the outbound ``trigger`` API.
6
+ """
7
+
8
+ from unittest.mock import AsyncMock, patch
9
+
10
+ import pytest
11
+
12
+ import iii.iii as iii_module
13
+ from iii.iii import III, _metadata_passing_mode
14
+ from iii.iii_constants import InitOptions
15
+ from iii.iii_types import InvokeFunctionMessage, TriggerActionVoid, TriggerRequest
16
+
17
+
18
+ @pytest.fixture
19
+ def client(monkeypatch):
20
+ """A client whose background loop runs but never connects to an engine.
21
+
22
+ Constructing an ``III`` runs ``connect_async`` -> ``init_otel``, which
23
+ installs global OTel providers. We reset those singletons on teardown so
24
+ this test does not leak provider state into later tests (mirrors the
25
+ cleanup in ``test_context_propagation.py``).
26
+ """
27
+
28
+ async def fake_do_connect(self):
29
+ return None
30
+
31
+ monkeypatch.setattr(iii_module.III, "_do_connect", fake_do_connect)
32
+ c = III(address="ws://localhost:9999", options=InitOptions(worker_name="test"))
33
+ yield c
34
+ c.shutdown()
35
+ _reset_otel_singletons()
36
+
37
+
38
+ def _reset_otel_singletons():
39
+ """Reset OTel global provider singletons so tests stay isolated."""
40
+ try:
41
+ import opentelemetry._logs._internal as _li
42
+
43
+ _li._LOGGER_PROVIDER = None
44
+ _li._LOGGER_PROVIDER_SET_ONCE._done = False
45
+ except Exception:
46
+ pass
47
+ try:
48
+ import opentelemetry.trace._internal as _ti
49
+
50
+ _ti._TRACER_PROVIDER = None
51
+ _ti._TRACER_PROVIDER_SET_ONCE._done = False
52
+ except Exception:
53
+ pass
54
+ try:
55
+ import opentelemetry.metrics._internal as _mi
56
+
57
+ _mi._METER_PROVIDER = None
58
+ _mi._METER_PROVIDER_SET_ONCE._done = False
59
+ except Exception:
60
+ pass
61
+
62
+
63
+ # --- _metadata_passing_mode: handler-arity detection -----------------------
64
+
65
+
66
+ def test_mode_positional_for_two_arg_handler():
67
+ def handler(data, metadata=None):
68
+ return None
69
+
70
+ assert _metadata_passing_mode(handler) == "positional"
71
+
72
+
73
+ def test_mode_positional_for_required_metadata_param():
74
+ def handler(data, metadata):
75
+ return None
76
+
77
+ assert _metadata_passing_mode(handler) == "positional"
78
+
79
+
80
+ def test_mode_keyword_for_keyword_only_metadata():
81
+ def handler(data, *, metadata=None):
82
+ return None
83
+
84
+ assert _metadata_passing_mode(handler) == "keyword"
85
+
86
+
87
+ def test_mode_none_for_single_arg_handler():
88
+ def handler(data):
89
+ return None
90
+
91
+ assert _metadata_passing_mode(handler) == "none"
92
+
93
+
94
+ def test_mode_none_for_unrelated_second_positional_param():
95
+ # A pre-existing handler with an unrelated optional second parameter must
96
+ # keep its exact old call shape: handler(data), so `retries` stays 3.
97
+ def handler(data, retries=3):
98
+ return None
99
+
100
+ assert _metadata_passing_mode(handler) == "none"
101
+
102
+
103
+ def test_mode_none_for_var_keyword_handler():
104
+ # **kwargs handlers previously received no keyword arguments; injecting
105
+ # metadata into kwargs would change their observable input.
106
+ def handler(data, **kwargs):
107
+ return None
108
+
109
+ assert _metadata_passing_mode(handler) == "none"
110
+
111
+
112
+ def test_mode_none_for_var_positional_handler():
113
+ # *args handlers previously received exactly one argument; appending
114
+ # metadata would change len(args).
115
+ def handler(*args):
116
+ return None
117
+
118
+ assert _metadata_passing_mode(handler) == "none"
119
+
120
+
121
+ # --- handler receives invocation metadata when it accepts it ---------------
122
+
123
+
124
+ def test_sync_handler_receives_invocation_metadata(client):
125
+ received = {}
126
+
127
+ def handler(data, metadata=None):
128
+ received["data"] = data
129
+ received["metadata"] = metadata
130
+ return {"ok": True}
131
+
132
+ client.register_function("fn::meta", handler)
133
+
134
+ with patch.object(client, "_send", new_callable=AsyncMock):
135
+ client._run_on_loop(
136
+ client._handle_invoke(
137
+ invocation_id="inv-1",
138
+ path="fn::meta",
139
+ data={"x": 1},
140
+ metadata={"tenant": "acme"},
141
+ )
142
+ )
143
+
144
+ assert received["data"] == {"x": 1}
145
+ assert received["metadata"] == {"tenant": "acme"}
146
+
147
+
148
+ def test_async_handler_receives_invocation_metadata(client):
149
+ received = {}
150
+
151
+ async def handler(data, metadata=None):
152
+ received["metadata"] = metadata
153
+ return {"ok": True}
154
+
155
+ client.register_function("fn::async-meta", handler)
156
+
157
+ with patch.object(client, "_send", new_callable=AsyncMock):
158
+ client._run_on_loop(
159
+ client._handle_invoke(
160
+ invocation_id="inv-2",
161
+ path="fn::async-meta",
162
+ data={},
163
+ metadata={"region": "us"},
164
+ )
165
+ )
166
+
167
+ assert received["metadata"] == {"region": "us"}
168
+
169
+
170
+ def test_keyword_only_handler_receives_invocation_metadata(client):
171
+ received = {}
172
+
173
+ def handler(data, *, metadata=None):
174
+ received["metadata"] = metadata
175
+ return {"ok": True}
176
+
177
+ client.register_function("fn::kw-meta", handler)
178
+
179
+ with patch.object(client, "_send", new_callable=AsyncMock):
180
+ client._run_on_loop(
181
+ client._handle_invoke(
182
+ invocation_id="inv-3",
183
+ path="fn::kw-meta",
184
+ data={},
185
+ metadata={"k": "v"},
186
+ )
187
+ )
188
+
189
+ assert received["metadata"] == {"k": "v"}
190
+
191
+
192
+ # --- back-compat: 1-arg handler keeps working when metadata is present -----
193
+
194
+
195
+ def test_one_arg_handler_still_works_with_metadata_present(client):
196
+ received = {}
197
+
198
+ def handler(data):
199
+ received["data"] = data
200
+ return {"ok": True}
201
+
202
+ client.register_function("fn::legacy", handler)
203
+
204
+ with patch.object(client, "_send", new_callable=AsyncMock) as send_mock:
205
+ client._run_on_loop(
206
+ client._handle_invoke(
207
+ invocation_id="inv-4",
208
+ path="fn::legacy",
209
+ data={"x": 2},
210
+ metadata={"present": True},
211
+ )
212
+ )
213
+
214
+ # Handler ran with just `data` and did not crash.
215
+ assert received["data"] == {"x": 2}
216
+ # The invocation completed successfully (a result, not an error, was sent).
217
+ result_msg = send_mock.call_args.args[0]
218
+ assert result_msg.error is None
219
+ assert result_msg.result == {"ok": True}
220
+
221
+
222
+ # --- outbound trigger() populates the wire metadata field ------------------
223
+
224
+
225
+ def test_trigger_async_dict_populates_outbound_metadata(client):
226
+ with patch.object(client, "_send", new_callable=AsyncMock) as send_mock:
227
+ client._run_on_loop(
228
+ client.trigger_async(
229
+ {
230
+ "function_id": "remote::fn",
231
+ "payload": {"a": 1},
232
+ "action": TriggerActionVoid(),
233
+ "metadata": {"caller": "svc-a"},
234
+ }
235
+ )
236
+ )
237
+
238
+ msg = send_mock.call_args.args[0]
239
+ assert isinstance(msg, InvokeFunctionMessage)
240
+ assert msg.data == {"a": 1}
241
+ assert msg.metadata == {"caller": "svc-a"}
242
+
243
+
244
+ def test_trigger_async_request_object_populates_outbound_metadata(client):
245
+ with patch.object(client, "_send", new_callable=AsyncMock) as send_mock:
246
+ client._run_on_loop(
247
+ client.trigger_async(
248
+ TriggerRequest(
249
+ function_id="remote::fn",
250
+ payload={"a": 1},
251
+ action=TriggerActionVoid(),
252
+ metadata={"caller": "svc-b"},
253
+ )
254
+ )
255
+ )
256
+
257
+ msg = send_mock.call_args.args[0]
258
+ assert msg.metadata == {"caller": "svc-b"}
259
+
260
+
261
+ def test_trigger_async_without_metadata_leaves_field_none(client):
262
+ with patch.object(client, "_send", new_callable=AsyncMock) as send_mock:
263
+ client._run_on_loop(
264
+ client.trigger_async(
265
+ {
266
+ "function_id": "remote::fn",
267
+ "payload": {},
268
+ "action": TriggerActionVoid(),
269
+ }
270
+ )
271
+ )
272
+
273
+ msg = send_mock.call_args.args[0]
274
+ assert msg.metadata is None
275
+
276
+
277
+ # --- InvokeFunctionMessage round-trip / omission ---------------------------
278
+
279
+
280
+ def test_invoke_function_message_roundtrips_metadata():
281
+ msg = InvokeFunctionMessage(function_id="fn", data={"x": 1}, metadata={"trace": "abc"})
282
+ dumped = msg.model_dump(by_alias=True, exclude_none=True)
283
+ assert dumped["metadata"] == {"trace": "abc"}
284
+
285
+ parsed = InvokeFunctionMessage.model_validate(dumped)
286
+ assert parsed.metadata == {"trace": "abc"}
287
+
288
+
289
+ def test_invoke_function_message_omits_metadata_when_none():
290
+ msg = InvokeFunctionMessage(function_id="fn", data={"x": 1})
291
+ dumped = msg.model_dump(by_alias=True, exclude_none=True)
292
+ assert "metadata" not in dumped
293
+
294
+
295
+ def test_invoke_function_message_absent_metadata_parses_to_none():
296
+ # Backward compatible: an inbound message with no `metadata` key.
297
+ parsed = InvokeFunctionMessage.model_validate({"function_id": "fn", "data": {}})
298
+ assert parsed.metadata is None
@@ -546,7 +546,7 @@ wheels = [
546
546
 
547
547
  [[package]]
548
548
  name = "iii-helpers"
549
- version = "0.19.6"
549
+ version = "0.20.0"
550
550
  source = { editable = "../helpers" }
551
551
  dependencies = [
552
552
  { name = "httpx" },
@@ -574,7 +574,7 @@ provides-extras = ["dev"]
574
574
 
575
575
  [[package]]
576
576
  name = "iii-sdk"
577
- version = "0.19.7"
577
+ version = "0.20.0"
578
578
  source = { editable = "." }
579
579
  dependencies = [
580
580
  { name = "iii-helpers" },
File without changes
File without changes