iii-sdk 0.11.3.dev1__tar.gz → 0.11.4.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.11.3.dev1 → iii_sdk-0.11.4.dev1}/PKG-INFO +1 -1
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/pyproject.toml +1 -1
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/src/iii/__init__.py +5 -0
- iii_sdk-0.11.4.dev1/src/iii/errors.py +88 -0
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/src/iii/iii.py +49 -227
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/src/iii/types.py +0 -15
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_api_triggers.py +36 -0
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_async_api.py +0 -18
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_bridge.py +10 -4
- iii_sdk-0.11.4.dev1/tests/test_errors.py +168 -0
- iii_sdk-0.11.4.dev1/tests/test_queue_integration.py +358 -0
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_rbac_workers.py +145 -4
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_state.py +71 -0
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_streams.py +114 -6
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_sync_api.py +2 -3
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/uv.lock +1 -1
- iii_sdk-0.11.3.dev1/tests/test_queue_integration.py +0 -136
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/.gitignore +0 -0
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/README.md +0 -0
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/src/iii/channels.py +0 -0
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/src/iii/format_utils.py +0 -0
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/src/iii/iii_constants.py +0 -0
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/src/iii/iii_types.py +0 -0
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/src/iii/logger.py +0 -0
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/src/iii/otel_worker_gauges.py +0 -0
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/src/iii/state.py +0 -0
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/src/iii/stream.py +0 -0
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/src/iii/telemetry.py +0 -0
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/src/iii/telemetry_exporters.py +0 -0
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/src/iii/telemetry_types.py +0 -0
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/src/iii/triggers.py +0 -0
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/src/iii/utils.py +0 -0
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/src/iii/worker_metrics.py +0 -0
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/conftest.py +0 -0
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_channel_close_delay.py +0 -0
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_context_propagation.py +0 -0
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_data_channels.py +0 -0
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_format_utils.py +0 -0
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_healthcheck.py +0 -0
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_hold_process.py +0 -0
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_http_external_functions_integration.py +0 -0
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_iii_registration_dedup.py +0 -0
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_init_api.py +0 -0
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_invocation_exception.py +0 -0
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_logger_function_ids.py +0 -0
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_logger_otel.py +0 -0
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_middleware.py +0 -0
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_pubsub.py +0 -0
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_register_function_args.py +0 -0
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_streams_runtime_annotations.py +0 -0
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_telemetry.py +0 -0
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_telemetry_exporters.py +0 -0
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_telemetry_types.py +0 -0
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_trace_helpers.py +0 -0
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_trigger_metadata.py +0 -0
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_utils.py +0 -0
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_worker_metadata.py +0 -0
- {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_worker_metrics.py +0 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""III SDK for Python."""
|
|
2
2
|
|
|
3
3
|
from .channels import ChannelReader, ChannelWriter
|
|
4
|
+
from .errors import IIIForbiddenError, IIIInvocationError, IIITimeoutError
|
|
4
5
|
from .format_utils import extract_request_format, extract_response_format, python_type_to_format
|
|
5
6
|
from .iii import TriggerAction, register_worker
|
|
6
7
|
from .iii_constants import FunctionRef, InitOptions, ReconnectionConfig, TelemetryOptions
|
|
@@ -62,6 +63,10 @@ __all__ = [
|
|
|
62
63
|
# Channels
|
|
63
64
|
"ChannelReader",
|
|
64
65
|
"ChannelWriter",
|
|
66
|
+
# Errors
|
|
67
|
+
"IIIForbiddenError",
|
|
68
|
+
"IIIInvocationError",
|
|
69
|
+
"IIITimeoutError",
|
|
65
70
|
# Core
|
|
66
71
|
"FunctionRef",
|
|
67
72
|
"InitOptions",
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class IIIInvocationError(Exception):
|
|
7
|
+
"""Raised when an invocation dispatched by the SDK fails.
|
|
8
|
+
|
|
9
|
+
Subclass by code:
|
|
10
|
+
- ``IIIForbiddenError`` (``code == 'FORBIDDEN'``)
|
|
11
|
+
- ``IIITimeoutError`` (``code == 'TIMEOUT'``)
|
|
12
|
+
|
|
13
|
+
Catch the base to handle every rejection; catch a subclass to react to
|
|
14
|
+
a specific category. ``except Exception`` continues to work because
|
|
15
|
+
``IIIInvocationError`` inherits from ``Exception``.
|
|
16
|
+
|
|
17
|
+
Attributes are read-only after construction. ``stacktrace`` is the
|
|
18
|
+
engine-side trace when the remote handler raised; it may include
|
|
19
|
+
internal file paths and should not be surfaced to end users. ``str(err)``
|
|
20
|
+
intentionally never includes the stacktrace.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
code: str,
|
|
26
|
+
message: str,
|
|
27
|
+
function_id: str | None = None,
|
|
28
|
+
stacktrace: str | None = None,
|
|
29
|
+
invocation_id: str | None = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
super().__init__(f"{code}: {message}")
|
|
32
|
+
self.code = code
|
|
33
|
+
self.message = message
|
|
34
|
+
self.function_id = function_id
|
|
35
|
+
self.stacktrace = stacktrace
|
|
36
|
+
self.invocation_id = invocation_id
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class IIIForbiddenError(IIIInvocationError):
|
|
40
|
+
"""Raised when RBAC denies an invocation. ``code == 'FORBIDDEN'``."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class IIITimeoutError(IIIInvocationError):
|
|
44
|
+
"""Raised when an invocation exceeds its timeout. ``code == 'TIMEOUT'``."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _wrap_wire_error(
|
|
48
|
+
error: Any,
|
|
49
|
+
*,
|
|
50
|
+
function_id: str | None,
|
|
51
|
+
invocation_id: str | None,
|
|
52
|
+
) -> IIIInvocationError:
|
|
53
|
+
"""Convert a wire ``ErrorBody``-shaped dict into a typed exception.
|
|
54
|
+
|
|
55
|
+
Dispatches to ``IIIForbiddenError`` / ``IIITimeoutError`` based on
|
|
56
|
+
``error['code']``. Malformed shapes (non-dict, missing fields, non-string
|
|
57
|
+
values) fall back to ``IIIInvocationError(code='UNKNOWN', ...)`` so no
|
|
58
|
+
rejection path prints as a raw dict repr.
|
|
59
|
+
"""
|
|
60
|
+
if isinstance(error, dict):
|
|
61
|
+
raw_code = error.get("code")
|
|
62
|
+
code = raw_code if isinstance(raw_code, str) else "UNKNOWN"
|
|
63
|
+
|
|
64
|
+
raw_message = error.get("message")
|
|
65
|
+
message = raw_message if isinstance(raw_message, str) else "<no message>"
|
|
66
|
+
|
|
67
|
+
raw_stacktrace = error.get("stacktrace")
|
|
68
|
+
stacktrace = raw_stacktrace if isinstance(raw_stacktrace, str) else None
|
|
69
|
+
|
|
70
|
+
cls: type[IIIInvocationError] = {
|
|
71
|
+
"FORBIDDEN": IIIForbiddenError,
|
|
72
|
+
"TIMEOUT": IIITimeoutError,
|
|
73
|
+
}.get(code, IIIInvocationError)
|
|
74
|
+
|
|
75
|
+
return cls(
|
|
76
|
+
code=code,
|
|
77
|
+
message=message,
|
|
78
|
+
function_id=function_id,
|
|
79
|
+
stacktrace=stacktrace,
|
|
80
|
+
invocation_id=invocation_id,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
return IIIInvocationError(
|
|
84
|
+
code="UNKNOWN",
|
|
85
|
+
message=str(error),
|
|
86
|
+
function_id=function_id,
|
|
87
|
+
invocation_id=invocation_id,
|
|
88
|
+
)
|
|
@@ -9,6 +9,7 @@ import random
|
|
|
9
9
|
import threading
|
|
10
10
|
import traceback
|
|
11
11
|
import uuid
|
|
12
|
+
from dataclasses import dataclass
|
|
12
13
|
from importlib.metadata import version
|
|
13
14
|
from typing import Any, Awaitable, Callable, Coroutine, TypeVar, cast
|
|
14
15
|
|
|
@@ -16,6 +17,7 @@ import websockets
|
|
|
16
17
|
from websockets.asyncio.client import ClientConnection
|
|
17
18
|
|
|
18
19
|
from .channels import ChannelReader, ChannelWriter
|
|
20
|
+
from .errors import IIIInvocationError, IIITimeoutError, _wrap_wire_error
|
|
19
21
|
from .format_utils import extract_request_format, extract_response_format
|
|
20
22
|
from .iii_constants import (
|
|
21
23
|
DEFAULT_RECONNECTION_CONFIG,
|
|
@@ -25,7 +27,6 @@ from .iii_constants import (
|
|
|
25
27
|
InitOptions,
|
|
26
28
|
)
|
|
27
29
|
from .iii_types import (
|
|
28
|
-
FunctionInfo,
|
|
29
30
|
HttpInvocationConfig,
|
|
30
31
|
InvocationResultMessage,
|
|
31
32
|
InvokeFunctionMessage,
|
|
@@ -42,13 +43,10 @@ from .iii_types import (
|
|
|
42
43
|
StreamChannelRef,
|
|
43
44
|
TriggerActionEnqueue,
|
|
44
45
|
TriggerActionVoid,
|
|
45
|
-
TriggerInfo,
|
|
46
46
|
TriggerRequest,
|
|
47
|
-
TriggerTypeInfo,
|
|
48
47
|
UnregisterFunctionMessage,
|
|
49
48
|
UnregisterTriggerMessage,
|
|
50
49
|
UnregisterTriggerTypeMessage,
|
|
51
|
-
WorkerInfo,
|
|
52
50
|
)
|
|
53
51
|
from .stream import (
|
|
54
52
|
IStream,
|
|
@@ -86,6 +84,18 @@ class _TraceContextError(Exception):
|
|
|
86
84
|
self.traceparent = traceparent
|
|
87
85
|
|
|
88
86
|
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class _PendingInvocation:
|
|
89
|
+
"""Pending invocation record kept on the SDK until the engine responds.
|
|
90
|
+
|
|
91
|
+
``function_id`` is preserved so the timeout and error-wrapping paths
|
|
92
|
+
can name the target without plumbing it through every call site.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
future: asyncio.Future[Any]
|
|
96
|
+
function_id: str
|
|
97
|
+
|
|
98
|
+
|
|
89
99
|
class III:
|
|
90
100
|
"""WebSocket client for communication with the III Engine.
|
|
91
101
|
|
|
@@ -107,18 +117,13 @@ class III:
|
|
|
107
117
|
self._ws: ClientConnection | None = None
|
|
108
118
|
self._functions: dict[str, RemoteFunctionData] = {}
|
|
109
119
|
self._services: dict[str, RegisterServiceMessage] = {}
|
|
110
|
-
self._pending: dict[str,
|
|
120
|
+
self._pending: dict[str, _PendingInvocation] = {}
|
|
111
121
|
self._triggers: dict[str, RegisterTriggerMessage] = {}
|
|
112
122
|
self._trigger_types: dict[str, RemoteTriggerTypeData] = {}
|
|
113
123
|
self._queue: list[dict[str, Any]] = []
|
|
114
124
|
self._reconnect_task: asyncio.Task[None] | None = None
|
|
115
125
|
self._running = False
|
|
116
126
|
self._receiver_task: asyncio.Task[None] | None = None
|
|
117
|
-
self._functions_available_callbacks: set[
|
|
118
|
-
Callable[[list[FunctionInfo]], None]
|
|
119
|
-
] = set()
|
|
120
|
-
self._functions_available_trigger: Trigger | None = None
|
|
121
|
-
self._functions_available_function_id: str | None = None
|
|
122
127
|
self._reconnection_config = (
|
|
123
128
|
self._options.reconnection_config or DEFAULT_RECONNECTION_CONFIG
|
|
124
129
|
)
|
|
@@ -224,9 +229,16 @@ class III:
|
|
|
224
229
|
pass
|
|
225
230
|
|
|
226
231
|
# Reject all pending invocations
|
|
227
|
-
for invocation_id,
|
|
228
|
-
if not future.done():
|
|
229
|
-
future.set_exception(
|
|
232
|
+
for invocation_id, pending in list(self._pending.items()):
|
|
233
|
+
if not pending.future.done():
|
|
234
|
+
pending.future.set_exception(
|
|
235
|
+
IIIInvocationError(
|
|
236
|
+
code="SHUTDOWN",
|
|
237
|
+
message="iii is shutting down",
|
|
238
|
+
function_id=pending.function_id,
|
|
239
|
+
invocation_id=invocation_id,
|
|
240
|
+
)
|
|
241
|
+
)
|
|
230
242
|
self._pending.clear()
|
|
231
243
|
|
|
232
244
|
if self._ws:
|
|
@@ -401,15 +413,21 @@ class III:
|
|
|
401
413
|
log.debug(f"Worker registered with ID: {worker_id}")
|
|
402
414
|
|
|
403
415
|
def _handle_result(self, invocation_id: str, result: Any, error: Any) -> None:
|
|
404
|
-
|
|
405
|
-
if not
|
|
416
|
+
pending = self._pending.pop(invocation_id, None)
|
|
417
|
+
if not pending:
|
|
406
418
|
log.debug(f"No pending invocation: {invocation_id}")
|
|
407
419
|
return
|
|
408
420
|
|
|
409
421
|
if error:
|
|
410
|
-
future.set_exception(
|
|
422
|
+
pending.future.set_exception(
|
|
423
|
+
_wrap_wire_error(
|
|
424
|
+
error,
|
|
425
|
+
function_id=pending.function_id,
|
|
426
|
+
invocation_id=invocation_id,
|
|
427
|
+
)
|
|
428
|
+
)
|
|
411
429
|
else:
|
|
412
|
-
future.set_result(result)
|
|
430
|
+
pending.future.set_result(result)
|
|
413
431
|
|
|
414
432
|
def _inject_traceparent(self) -> str | None:
|
|
415
433
|
from opentelemetry import context as otel_context
|
|
@@ -972,7 +990,9 @@ class III:
|
|
|
972
990
|
actions.
|
|
973
991
|
|
|
974
992
|
Raises:
|
|
975
|
-
|
|
993
|
+
IIITimeoutError: If the invocation times out. ``code == 'TIMEOUT'``.
|
|
994
|
+
IIIForbiddenError: If RBAC denies the invocation. ``code == 'FORBIDDEN'``.
|
|
995
|
+
IIIInvocationError: For any other engine rejection.
|
|
976
996
|
|
|
977
997
|
Examples:
|
|
978
998
|
>>> result = iii.trigger({'function_id': 'greet', 'payload': {'name': 'World'}})
|
|
@@ -997,7 +1017,9 @@ class III:
|
|
|
997
1017
|
The result of the function invocation, or ``None`` for void calls.
|
|
998
1018
|
|
|
999
1019
|
Raises:
|
|
1000
|
-
|
|
1020
|
+
IIITimeoutError: If the invocation times out. ``code == 'TIMEOUT'``.
|
|
1021
|
+
IIIForbiddenError: If RBAC denies the invocation. ``code == 'FORBIDDEN'``.
|
|
1022
|
+
IIIInvocationError: For any other engine rejection.
|
|
1001
1023
|
|
|
1002
1024
|
Examples:
|
|
1003
1025
|
>>> result = await iii.trigger_async({'function_id': 'greet', 'payload': {'name': 'World'}})
|
|
@@ -1035,7 +1057,9 @@ class III:
|
|
|
1035
1057
|
invocation_id = str(uuid.uuid4())
|
|
1036
1058
|
future: asyncio.Future[Any] = self._loop.create_future()
|
|
1037
1059
|
|
|
1038
|
-
self._pending[invocation_id] =
|
|
1060
|
+
self._pending[invocation_id] = _PendingInvocation(
|
|
1061
|
+
future=future, function_id=function_id
|
|
1062
|
+
)
|
|
1039
1063
|
|
|
1040
1064
|
enqueue_action: TriggerActionEnqueue | None = (
|
|
1041
1065
|
action if isinstance(action, TriggerActionEnqueue) else None
|
|
@@ -1056,152 +1080,13 @@ class III:
|
|
|
1056
1080
|
return await asyncio.wait_for(future, timeout=timeout_secs)
|
|
1057
1081
|
except asyncio.TimeoutError:
|
|
1058
1082
|
self._pending.pop(invocation_id, None)
|
|
1059
|
-
raise
|
|
1060
|
-
|
|
1083
|
+
raise IIITimeoutError(
|
|
1084
|
+
code="TIMEOUT",
|
|
1085
|
+
message=f"invocation timed out after {timeout_ms}ms",
|
|
1086
|
+
function_id=function_id,
|
|
1087
|
+
invocation_id=invocation_id,
|
|
1061
1088
|
)
|
|
1062
1089
|
|
|
1063
|
-
def list_functions(self) -> list[FunctionInfo]:
|
|
1064
|
-
"""List all functions registered with the engine across all workers.
|
|
1065
|
-
|
|
1066
|
-
Returns:
|
|
1067
|
-
A list of ``FunctionInfo`` objects describing each function.
|
|
1068
|
-
|
|
1069
|
-
Examples:
|
|
1070
|
-
>>> for fn in iii.list_functions():
|
|
1071
|
-
... print(fn.function_id, fn.description)
|
|
1072
|
-
"""
|
|
1073
|
-
return self._run_on_loop(self.list_functions_async())
|
|
1074
|
-
|
|
1075
|
-
async def list_functions_async(self) -> list[FunctionInfo]:
|
|
1076
|
-
"""List all functions registered with the engine across all workers.
|
|
1077
|
-
|
|
1078
|
-
Returns:
|
|
1079
|
-
A list of ``FunctionInfo`` objects describing each function.
|
|
1080
|
-
|
|
1081
|
-
Examples:
|
|
1082
|
-
>>> for fn in await iii.list_functions_async():
|
|
1083
|
-
... print(fn.function_id, fn.description)
|
|
1084
|
-
"""
|
|
1085
|
-
result = await self.trigger_async(
|
|
1086
|
-
{"function_id": "engine::functions::list", "payload": {}}
|
|
1087
|
-
)
|
|
1088
|
-
functions_data = result.get("functions", [])
|
|
1089
|
-
return [FunctionInfo(**f) for f in functions_data]
|
|
1090
|
-
|
|
1091
|
-
def list_workers(self) -> list[WorkerInfo]:
|
|
1092
|
-
"""List all workers currently connected to the engine.
|
|
1093
|
-
|
|
1094
|
-
Returns:
|
|
1095
|
-
A list of ``WorkerInfo`` objects with worker metadata.
|
|
1096
|
-
|
|
1097
|
-
Examples:
|
|
1098
|
-
>>> for w in iii.list_workers():
|
|
1099
|
-
... print(w.name, w.worker_id)
|
|
1100
|
-
"""
|
|
1101
|
-
return self._run_on_loop(self.list_workers_async())
|
|
1102
|
-
|
|
1103
|
-
async def list_workers_async(self) -> list[WorkerInfo]:
|
|
1104
|
-
"""List all workers currently connected to the engine.
|
|
1105
|
-
|
|
1106
|
-
Returns:
|
|
1107
|
-
A list of ``WorkerInfo`` objects with worker metadata.
|
|
1108
|
-
|
|
1109
|
-
Examples:
|
|
1110
|
-
>>> for w in await iii.list_workers_async():
|
|
1111
|
-
... print(w.name, w.worker_id)
|
|
1112
|
-
"""
|
|
1113
|
-
result = await self.trigger_async(
|
|
1114
|
-
{"function_id": "engine::workers::list", "payload": {}}
|
|
1115
|
-
)
|
|
1116
|
-
workers_data = result.get("workers", [])
|
|
1117
|
-
return [WorkerInfo(**w) for w in workers_data]
|
|
1118
|
-
|
|
1119
|
-
def list_triggers(self, include_internal: bool = False) -> list[TriggerInfo]:
|
|
1120
|
-
"""List all triggers registered with the engine.
|
|
1121
|
-
|
|
1122
|
-
Args:
|
|
1123
|
-
include_internal: If ``True``, include engine-internal triggers
|
|
1124
|
-
(e.g. ``functions-available``). Defaults to ``False``.
|
|
1125
|
-
|
|
1126
|
-
Returns:
|
|
1127
|
-
A list of ``TriggerInfo`` objects.
|
|
1128
|
-
|
|
1129
|
-
Examples:
|
|
1130
|
-
>>> triggers = iii.list_triggers()
|
|
1131
|
-
>>> internal = iii.list_triggers(include_internal=True)
|
|
1132
|
-
"""
|
|
1133
|
-
return self._run_on_loop(self.list_triggers_async(include_internal))
|
|
1134
|
-
|
|
1135
|
-
async def list_triggers_async(
|
|
1136
|
-
self, include_internal: bool = False
|
|
1137
|
-
) -> list[TriggerInfo]:
|
|
1138
|
-
"""List all triggers registered with the engine.
|
|
1139
|
-
|
|
1140
|
-
Args:
|
|
1141
|
-
include_internal: If ``True``, include engine-internal triggers
|
|
1142
|
-
(e.g. ``functions-available``). Defaults to ``False``.
|
|
1143
|
-
|
|
1144
|
-
Returns:
|
|
1145
|
-
A list of ``TriggerInfo`` objects.
|
|
1146
|
-
|
|
1147
|
-
Examples:
|
|
1148
|
-
>>> triggers = await iii.list_triggers_async()
|
|
1149
|
-
>>> internal = await iii.list_triggers_async(include_internal=True)
|
|
1150
|
-
"""
|
|
1151
|
-
result = await self.trigger_async(
|
|
1152
|
-
{
|
|
1153
|
-
"function_id": "engine::triggers::list",
|
|
1154
|
-
"payload": {"include_internal": include_internal},
|
|
1155
|
-
}
|
|
1156
|
-
)
|
|
1157
|
-
triggers_data = result.get("triggers", [])
|
|
1158
|
-
return [TriggerInfo(**t) for t in triggers_data]
|
|
1159
|
-
|
|
1160
|
-
def list_trigger_types(
|
|
1161
|
-
self, include_internal: bool = False
|
|
1162
|
-
) -> list[TriggerTypeInfo]:
|
|
1163
|
-
"""List all trigger types registered with the engine.
|
|
1164
|
-
|
|
1165
|
-
Args:
|
|
1166
|
-
include_internal: If ``True``, include engine-internal trigger
|
|
1167
|
-
types (e.g. ``engine::functions-available``). Defaults to ``False``.
|
|
1168
|
-
|
|
1169
|
-
Returns:
|
|
1170
|
-
A list of ``TriggerTypeInfo`` objects with ``trigger_request_format``
|
|
1171
|
-
and ``call_request_format`` schemas.
|
|
1172
|
-
|
|
1173
|
-
Examples:
|
|
1174
|
-
>>> trigger_types = iii.list_trigger_types()
|
|
1175
|
-
>>> for tt in trigger_types:
|
|
1176
|
-
... print(tt.id, tt.trigger_request_format)
|
|
1177
|
-
"""
|
|
1178
|
-
return self._run_on_loop(self.list_trigger_types_async(include_internal))
|
|
1179
|
-
|
|
1180
|
-
async def list_trigger_types_async(
|
|
1181
|
-
self, include_internal: bool = False
|
|
1182
|
-
) -> list[TriggerTypeInfo]:
|
|
1183
|
-
"""List all trigger types registered with the engine.
|
|
1184
|
-
|
|
1185
|
-
Args:
|
|
1186
|
-
include_internal: If ``True``, include engine-internal trigger
|
|
1187
|
-
types (e.g. ``engine::functions-available``). Defaults to ``False``.
|
|
1188
|
-
|
|
1189
|
-
Returns:
|
|
1190
|
-
A list of ``TriggerTypeInfo`` objects with ``trigger_request_format``
|
|
1191
|
-
and ``call_request_format`` schemas.
|
|
1192
|
-
|
|
1193
|
-
Examples:
|
|
1194
|
-
>>> trigger_types = await iii.list_trigger_types_async()
|
|
1195
|
-
"""
|
|
1196
|
-
result = await self.trigger_async(
|
|
1197
|
-
{
|
|
1198
|
-
"function_id": "engine::trigger-types::list",
|
|
1199
|
-
"payload": {"include_internal": include_internal},
|
|
1200
|
-
}
|
|
1201
|
-
)
|
|
1202
|
-
types_data = result.get("trigger_types", [])
|
|
1203
|
-
return [TriggerTypeInfo(**t) for t in types_data]
|
|
1204
|
-
|
|
1205
1090
|
def create_channel(self, buffer_size: int | None = None) -> Channel:
|
|
1206
1091
|
"""Create a streaming channel pair for worker-to-worker data transfer.
|
|
1207
1092
|
|
|
@@ -1305,69 +1190,6 @@ class III:
|
|
|
1305
1190
|
)
|
|
1306
1191
|
asyncio.run_coroutine_threadsafe(self._send(msg), self._loop)
|
|
1307
1192
|
|
|
1308
|
-
def on_functions_available(
|
|
1309
|
-
self, callback: Callable[[list[FunctionInfo]], None]
|
|
1310
|
-
) -> Callable[[], None]:
|
|
1311
|
-
"""Subscribe to function-availability events from the engine.
|
|
1312
|
-
|
|
1313
|
-
The callback fires whenever the set of available functions changes
|
|
1314
|
-
(e.g. a new worker connects or a function is unregistered).
|
|
1315
|
-
|
|
1316
|
-
Args:
|
|
1317
|
-
callback (Callable[[list[FunctionInfo]], None]): Receives the
|
|
1318
|
-
current list of ``FunctionInfo`` objects each time
|
|
1319
|
-
availability changes.
|
|
1320
|
-
|
|
1321
|
-
Returns:
|
|
1322
|
-
A callable that unsubscribes when called. Calling the
|
|
1323
|
-
returned function removes the callback and, if no callbacks
|
|
1324
|
-
remain, tears down the internal trigger.
|
|
1325
|
-
|
|
1326
|
-
Examples:
|
|
1327
|
-
>>> def on_change(functions):
|
|
1328
|
-
... print("Available:", [f.function_id for f in functions])
|
|
1329
|
-
>>> unsub = iii.on_functions_available(on_change)
|
|
1330
|
-
>>> # later ...
|
|
1331
|
-
>>> unsub()
|
|
1332
|
-
"""
|
|
1333
|
-
self._functions_available_callbacks.add(callback)
|
|
1334
|
-
|
|
1335
|
-
if not self._functions_available_trigger:
|
|
1336
|
-
if not self._functions_available_function_id:
|
|
1337
|
-
self._functions_available_function_id = (
|
|
1338
|
-
f"iii.on_functions_available.{uuid.uuid4()}"
|
|
1339
|
-
)
|
|
1340
|
-
|
|
1341
|
-
function_id = self._functions_available_function_id
|
|
1342
|
-
if function_id not in self._functions:
|
|
1343
|
-
|
|
1344
|
-
async def handler(data: dict[str, Any]) -> None:
|
|
1345
|
-
functions_data = data.get("functions", [])
|
|
1346
|
-
functions = [FunctionInfo(**f) for f in functions_data]
|
|
1347
|
-
for cb in list(self._functions_available_callbacks):
|
|
1348
|
-
cb(functions)
|
|
1349
|
-
|
|
1350
|
-
self.register_function({"id": function_id}, handler)
|
|
1351
|
-
|
|
1352
|
-
self._functions_available_trigger = self.register_trigger(
|
|
1353
|
-
{
|
|
1354
|
-
"type": "engine::functions-available",
|
|
1355
|
-
"function_id": function_id,
|
|
1356
|
-
"config": {},
|
|
1357
|
-
}
|
|
1358
|
-
)
|
|
1359
|
-
|
|
1360
|
-
def unsubscribe() -> None:
|
|
1361
|
-
self._functions_available_callbacks.discard(callback)
|
|
1362
|
-
if (
|
|
1363
|
-
len(self._functions_available_callbacks) == 0
|
|
1364
|
-
and self._functions_available_trigger
|
|
1365
|
-
):
|
|
1366
|
-
self._functions_available_trigger.unregister()
|
|
1367
|
-
self._functions_available_trigger = None
|
|
1368
|
-
|
|
1369
|
-
return unsubscribe
|
|
1370
|
-
|
|
1371
1193
|
def create_stream(self, stream_name: str, stream: IStream[Any]) -> None:
|
|
1372
1194
|
"""Register a custom stream implementation, overriding the engine default.
|
|
1373
1195
|
|
|
@@ -10,7 +10,6 @@ from typing import TYPE_CHECKING, Any, Awaitable, Callable, Generic, Protocol, T
|
|
|
10
10
|
from pydantic import BaseModel, ConfigDict, Field
|
|
11
11
|
|
|
12
12
|
from .iii_types import (
|
|
13
|
-
FunctionInfo,
|
|
14
13
|
HttpInvocationConfig,
|
|
15
14
|
RegisterFunctionInput,
|
|
16
15
|
RegisterFunctionMessage,
|
|
@@ -19,7 +18,6 @@ from .iii_types import (
|
|
|
19
18
|
RegisterTriggerTypeInput,
|
|
20
19
|
RegisterTriggerTypeMessage,
|
|
21
20
|
StreamChannelRef,
|
|
22
|
-
TriggerInfo,
|
|
23
21
|
TriggerRequest,
|
|
24
22
|
)
|
|
25
23
|
from .stream import IStream
|
|
@@ -80,11 +78,6 @@ class RemoteServiceFunctionData(BaseModel):
|
|
|
80
78
|
handler: RemoteFunctionHandler
|
|
81
79
|
|
|
82
80
|
|
|
83
|
-
# Type aliases for registration inputs
|
|
84
|
-
# Callback type for functions available event
|
|
85
|
-
FunctionsAvailableCallback = Callable[[list[FunctionInfo]], None]
|
|
86
|
-
|
|
87
|
-
|
|
88
81
|
class IIIClient(Protocol):
|
|
89
82
|
"""Protocol for III client implementations."""
|
|
90
83
|
|
|
@@ -108,18 +101,10 @@ class IIIClient(Protocol):
|
|
|
108
101
|
|
|
109
102
|
def unregister_trigger_type(self, trigger_type: RegisterTriggerTypeInput | dict[str, Any]) -> None: ...
|
|
110
103
|
|
|
111
|
-
def list_trigger_types(self, include_internal: bool = False) -> list[Any]: ...
|
|
112
|
-
|
|
113
104
|
def create_channel(self, buffer_size: int | None = None) -> Channel: ...
|
|
114
105
|
|
|
115
106
|
def create_stream(self, stream_name: str, stream: IStream[Any]) -> None: ...
|
|
116
107
|
|
|
117
|
-
def list_functions(self) -> list[FunctionInfo]: ...
|
|
118
|
-
|
|
119
|
-
def list_triggers(self, include_internal: bool = False) -> list[TriggerInfo]: ...
|
|
120
|
-
|
|
121
|
-
def on_functions_available(self, callback: FunctionsAvailableCallback) -> Callable[[], None]: ...
|
|
122
|
-
|
|
123
108
|
def shutdown(self) -> None: ...
|
|
124
109
|
|
|
125
110
|
|
|
@@ -194,6 +194,42 @@ async def test_custom_status_code(engine_http_url, iii_client: III):
|
|
|
194
194
|
trigger.unregister()
|
|
195
195
|
|
|
196
196
|
|
|
197
|
+
@pytest.mark.asyncio
|
|
198
|
+
async def test_content_type_on_api_response_return(engine_http_url, iii_client: III):
|
|
199
|
+
"""Returning an ApiResponse dict with headers should set the response Content-Type."""
|
|
200
|
+
xml_body = '<?xml version="1.0" encoding="UTF-8"?><note><to>user</to><body>hello</body></note>'
|
|
201
|
+
|
|
202
|
+
def handler(_input_data):
|
|
203
|
+
return {
|
|
204
|
+
"status_code": 200,
|
|
205
|
+
"headers": {"Content-Type": "text/xml"},
|
|
206
|
+
"body": xml_body,
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
fn_ref = iii_client.register_function({"id": "test.api.xml.return.py"}, handler)
|
|
210
|
+
trigger = iii_client.register_trigger(
|
|
211
|
+
{
|
|
212
|
+
"type": "http",
|
|
213
|
+
"function_id": "test.api.xml.return.py",
|
|
214
|
+
"config": {
|
|
215
|
+
"api_path": "test/py/xml-return",
|
|
216
|
+
"http_method": "POST",
|
|
217
|
+
},
|
|
218
|
+
}
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
time.sleep(0.3)
|
|
222
|
+
|
|
223
|
+
async with aiohttp.ClientSession() as session:
|
|
224
|
+
async with session.post(f"{engine_http_url}/test/py/xml-return") as resp:
|
|
225
|
+
assert resp.status == 200
|
|
226
|
+
assert resp.headers.get("content-type") == "text/xml"
|
|
227
|
+
assert await resp.text() == xml_body
|
|
228
|
+
|
|
229
|
+
fn_ref.unregister()
|
|
230
|
+
trigger.unregister()
|
|
231
|
+
|
|
232
|
+
|
|
197
233
|
@pytest.mark.asyncio
|
|
198
234
|
async def test_download_pdf_streaming(engine_http_url, iii_client: III):
|
|
199
235
|
"""Stream a PDF file as a download response."""
|
|
@@ -10,21 +10,6 @@ def test_trigger_async_is_coroutine_function():
|
|
|
10
10
|
assert inspect.iscoroutinefunction(III.trigger_async)
|
|
11
11
|
|
|
12
12
|
|
|
13
|
-
def test_list_functions_async_is_coroutine_function():
|
|
14
|
-
assert hasattr(III, "list_functions_async")
|
|
15
|
-
assert inspect.iscoroutinefunction(III.list_functions_async)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def test_list_workers_async_is_coroutine_function():
|
|
19
|
-
assert hasattr(III, "list_workers_async")
|
|
20
|
-
assert inspect.iscoroutinefunction(III.list_workers_async)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def test_list_triggers_async_is_coroutine_function():
|
|
24
|
-
assert hasattr(III, "list_triggers_async")
|
|
25
|
-
assert inspect.iscoroutinefunction(III.list_triggers_async)
|
|
26
|
-
|
|
27
|
-
|
|
28
13
|
def test_create_channel_async_is_coroutine_function():
|
|
29
14
|
assert hasattr(III, "create_channel_async")
|
|
30
15
|
assert inspect.iscoroutinefunction(III.create_channel_async)
|
|
@@ -44,9 +29,6 @@ def test_async_methods_have_docstrings():
|
|
|
44
29
|
"""All public async methods must have docstrings."""
|
|
45
30
|
async_methods = [
|
|
46
31
|
"trigger_async",
|
|
47
|
-
"list_functions_async",
|
|
48
|
-
"list_workers_async",
|
|
49
|
-
"list_triggers_async",
|
|
50
32
|
"create_channel_async",
|
|
51
33
|
"connect_async",
|
|
52
34
|
"shutdown_async",
|
|
@@ -4,7 +4,7 @@ import asyncio
|
|
|
4
4
|
|
|
5
5
|
import pytest
|
|
6
6
|
|
|
7
|
-
from iii import TriggerAction
|
|
7
|
+
from iii import FunctionInfo, TriggerAction
|
|
8
8
|
from iii.iii import III
|
|
9
9
|
|
|
10
10
|
|
|
@@ -21,7 +21,10 @@ async def wait_for(condition, timeout=5.0, interval=0.1):
|
|
|
21
21
|
@pytest.mark.asyncio
|
|
22
22
|
async def test_connect_successfully(iii_client: III):
|
|
23
23
|
"""SDK connects to the engine and can list functions."""
|
|
24
|
-
|
|
24
|
+
result = iii_client.trigger(
|
|
25
|
+
{"function_id": "engine::functions::list", "payload": {}}
|
|
26
|
+
)
|
|
27
|
+
functions = [FunctionInfo(**f) for f in result.get("functions", [])]
|
|
25
28
|
assert isinstance(functions, list)
|
|
26
29
|
|
|
27
30
|
|
|
@@ -83,13 +86,16 @@ async def test_invoke_function_fire_and_forget(iii_client: III):
|
|
|
83
86
|
|
|
84
87
|
@pytest.mark.asyncio
|
|
85
88
|
async def test_list_registered_functions(iii_client: III):
|
|
86
|
-
"""Registered function IDs appear in
|
|
89
|
+
"""Registered function IDs appear in the engine functions list."""
|
|
87
90
|
fn1 = iii_client.register_function({"id": "test.bridge.py.list.func1"}, lambda _: {})
|
|
88
91
|
fn2 = iii_client.register_function({"id": "test.bridge.py.list.func2"}, lambda _: {})
|
|
89
92
|
await asyncio.sleep(0.3)
|
|
90
93
|
|
|
91
94
|
try:
|
|
92
|
-
|
|
95
|
+
result = iii_client.trigger(
|
|
96
|
+
{"function_id": "engine::functions::list", "payload": {}}
|
|
97
|
+
)
|
|
98
|
+
functions = [FunctionInfo(**f) for f in result.get("functions", [])]
|
|
93
99
|
function_ids = [f.function_id for f in functions]
|
|
94
100
|
|
|
95
101
|
assert "test.bridge.py.list.func1" in function_ids
|