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.
Files changed (58) hide show
  1. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/PKG-INFO +1 -1
  2. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/pyproject.toml +1 -1
  3. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/src/iii/__init__.py +5 -0
  4. iii_sdk-0.11.4.dev1/src/iii/errors.py +88 -0
  5. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/src/iii/iii.py +49 -227
  6. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/src/iii/types.py +0 -15
  7. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_api_triggers.py +36 -0
  8. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_async_api.py +0 -18
  9. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_bridge.py +10 -4
  10. iii_sdk-0.11.4.dev1/tests/test_errors.py +168 -0
  11. iii_sdk-0.11.4.dev1/tests/test_queue_integration.py +358 -0
  12. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_rbac_workers.py +145 -4
  13. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_state.py +71 -0
  14. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_streams.py +114 -6
  15. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_sync_api.py +2 -3
  16. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/uv.lock +1 -1
  17. iii_sdk-0.11.3.dev1/tests/test_queue_integration.py +0 -136
  18. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/.gitignore +0 -0
  19. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/README.md +0 -0
  20. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/src/iii/channels.py +0 -0
  21. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/src/iii/format_utils.py +0 -0
  22. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/src/iii/iii_constants.py +0 -0
  23. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/src/iii/iii_types.py +0 -0
  24. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/src/iii/logger.py +0 -0
  25. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/src/iii/otel_worker_gauges.py +0 -0
  26. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/src/iii/state.py +0 -0
  27. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/src/iii/stream.py +0 -0
  28. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/src/iii/telemetry.py +0 -0
  29. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/src/iii/telemetry_exporters.py +0 -0
  30. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/src/iii/telemetry_types.py +0 -0
  31. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/src/iii/triggers.py +0 -0
  32. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/src/iii/utils.py +0 -0
  33. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/src/iii/worker_metrics.py +0 -0
  34. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/conftest.py +0 -0
  35. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_channel_close_delay.py +0 -0
  36. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_context_propagation.py +0 -0
  37. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_data_channels.py +0 -0
  38. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_format_utils.py +0 -0
  39. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_healthcheck.py +0 -0
  40. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_hold_process.py +0 -0
  41. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_http_external_functions_integration.py +0 -0
  42. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_iii_registration_dedup.py +0 -0
  43. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_init_api.py +0 -0
  44. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_invocation_exception.py +0 -0
  45. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_logger_function_ids.py +0 -0
  46. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_logger_otel.py +0 -0
  47. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_middleware.py +0 -0
  48. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_pubsub.py +0 -0
  49. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_register_function_args.py +0 -0
  50. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_streams_runtime_annotations.py +0 -0
  51. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_telemetry.py +0 -0
  52. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_telemetry_exporters.py +0 -0
  53. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_telemetry_types.py +0 -0
  54. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_trace_helpers.py +0 -0
  55. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_trigger_metadata.py +0 -0
  56. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_utils.py +0 -0
  57. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.dev1}/tests/test_worker_metadata.py +0 -0
  58. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.4.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.11.3.dev1
3
+ Version: 0.11.4.dev1
4
4
  Summary: III SDK for Python
5
5
  Project-URL: Homepage, https://github.com/iii-hq/sdk
6
6
  Project-URL: Repository, https://github.com/iii-hq/sdk
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "iii-sdk"
7
- version = "0.11.3.dev1"
7
+ version = "0.11.4.dev1"
8
8
  description = "III SDK for Python"
9
9
  authors = [{ name = "III" }]
10
10
  license = { text = "Apache-2.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, asyncio.Future[Any]] = {}
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, future in list(self._pending.items()):
228
- if not future.done():
229
- future.set_exception(Exception("iii is shutting down"))
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
- future = self._pending.pop(invocation_id, None)
405
- if not future:
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(Exception(str(error)))
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
- TimeoutError: If the invocation times out.
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
- TimeoutError: If the invocation times out.
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] = future
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 TimeoutError(
1060
- f"Invocation of '{function_id}' timed out after {timeout_ms}ms"
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
- functions = iii_client.list_functions()
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 list_functions()."""
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
- functions = iii_client.list_functions()
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