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.
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/PKG-INFO +2 -2
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/pyproject.toml +2 -2
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/src/iii/iii.py +78 -13
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/src/iii/iii_types.py +11 -4
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_invocation_exception.py +2 -2
- iii_sdk-0.21.0.dev1/tests/test_invocation_metadata.py +298 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/uv.lock +2 -2
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/.gitignore +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/README.md +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/src/iii/__init__.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/src/iii/channel.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/src/iii/channels.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/src/iii/engine.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/src/iii/errors.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/src/iii/format_utils.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/src/iii/helpers.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/src/iii/iii_constants.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/src/iii/internal.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/src/iii/otel_worker_gauges.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/src/iii/protocol.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/src/iii/runtime.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/src/iii/state.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/src/iii/stream.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/src/iii/trigger.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/src/iii/triggers.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/src/iii/types.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/src/iii/utils.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/src/iii/worker_metrics.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/conftest.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_api_triggers.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_async_api.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_baggage_span_processor.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_bridge.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_channel_close_delay.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_channel_submodule.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_context_propagation.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_data_channels.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_engine_constants.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_errors.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_format_utils.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_healthcheck.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_helpers.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_hold_process.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_http_external_functions_integration.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_iii_registration_dedup.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_init_api.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_logger_function_ids.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_logger_otel.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_middleware.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_payload.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_pubsub.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_queue_integration.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_rbac_workers.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_register_function_args.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_runtime_submodule.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_span_ops.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_state.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_stream_models.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_stream_types.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_streams.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_streams_runtime_annotations.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_sync_api.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_telemetry.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_telemetry_exporters.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_telemetry_types.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_trace_helpers.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_trigger_metadata.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_trigger_registration_error.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_trigger_submodule.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_trigger_type_lifecycle.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_utils.py +0 -0
- {iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_worker_metadata.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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[
|
|
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
|
|
931
|
-
|
|
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
|
|
944
|
-
|
|
945
|
-
may
|
|
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
|
|
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 =
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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.
|
|
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.
|
|
577
|
+
version = "0.20.0"
|
|
578
578
|
source = { editable = "." }
|
|
579
579
|
dependencies = [
|
|
580
580
|
{ name = "iii-helpers" },
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{iii_sdk-0.20.0.dev1 → iii_sdk-0.21.0.dev1}/tests/test_http_external_functions_integration.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|