iii-sdk 0.17.0.dev1__tar.gz → 0.18.0.dev1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/PKG-INFO +2 -2
  2. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/pyproject.toml +2 -2
  3. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/src/iii/channels.py +47 -0
  4. iii_sdk-0.18.0.dev1/src/iii/helpers.py +80 -0
  5. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/src/iii/iii.py +16 -58
  6. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/src/iii/triggers.py +1 -1
  7. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/src/iii/types.py +67 -9
  8. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/tests/test_async_api.py +3 -4
  9. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/tests/test_data_channels.py +5 -4
  10. iii_sdk-0.18.0.dev1/tests/test_helpers.py +196 -0
  11. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/tests/test_streams.py +2 -1
  12. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/tests/test_sync_api.py +3 -2
  13. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/uv.lock +2 -2
  14. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/.gitignore +0 -0
  15. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/README.md +0 -0
  16. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/src/iii/__init__.py +0 -0
  17. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/src/iii/errors.py +0 -0
  18. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/src/iii/format_utils.py +0 -0
  19. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/src/iii/iii_constants.py +0 -0
  20. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/src/iii/iii_types.py +0 -0
  21. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/src/iii/otel_worker_gauges.py +0 -0
  22. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/src/iii/state.py +0 -0
  23. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/src/iii/stream.py +0 -0
  24. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/src/iii/utils.py +0 -0
  25. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/src/iii/worker_metrics.py +0 -0
  26. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/tests/conftest.py +0 -0
  27. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/tests/test_api_triggers.py +0 -0
  28. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/tests/test_baggage_span_processor.py +0 -0
  29. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/tests/test_bridge.py +0 -0
  30. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/tests/test_channel_close_delay.py +0 -0
  31. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/tests/test_context_propagation.py +0 -0
  32. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/tests/test_errors.py +0 -0
  33. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/tests/test_format_utils.py +0 -0
  34. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/tests/test_healthcheck.py +0 -0
  35. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/tests/test_hold_process.py +0 -0
  36. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/tests/test_http_external_functions_integration.py +0 -0
  37. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/tests/test_iii_registration_dedup.py +0 -0
  38. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/tests/test_init_api.py +0 -0
  39. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/tests/test_invocation_exception.py +0 -0
  40. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/tests/test_logger_function_ids.py +0 -0
  41. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/tests/test_logger_otel.py +0 -0
  42. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/tests/test_middleware.py +0 -0
  43. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/tests/test_payload.py +0 -0
  44. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/tests/test_pubsub.py +0 -0
  45. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/tests/test_queue_integration.py +0 -0
  46. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/tests/test_rbac_workers.py +0 -0
  47. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/tests/test_register_function_args.py +0 -0
  48. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/tests/test_span_ops.py +0 -0
  49. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/tests/test_state.py +0 -0
  50. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/tests/test_stream_models.py +0 -0
  51. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/tests/test_streams_runtime_annotations.py +0 -0
  52. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/tests/test_telemetry.py +0 -0
  53. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/tests/test_telemetry_exporters.py +0 -0
  54. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/tests/test_telemetry_types.py +0 -0
  55. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/tests/test_trace_helpers.py +0 -0
  56. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/tests/test_trigger_metadata.py +0 -0
  57. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/tests/test_trigger_registration_error.py +0 -0
  58. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/tests/test_utils.py +0 -0
  59. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.0.dev1}/tests/test_worker_metadata.py +0 -0
  60. {iii_sdk-0.17.0.dev1 → iii_sdk-0.18.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.17.0.dev1
3
+ Version: 0.18.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-observability==0.17.0.dev1
17
+ Requires-Dist: iii-observability==0.18.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.17.0.dev1"
7
+ version = "0.18.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-observability==0.17.0.dev1",
26
+ "iii-observability==0.18.0.dev1",
27
27
  ]
28
28
 
29
29
  [project.urls]
@@ -4,6 +4,8 @@ from __future__ import annotations
4
4
 
5
5
  import asyncio
6
6
  import logging
7
+ from dataclasses import dataclass
8
+ from enum import Enum
7
9
  from typing import Any, AsyncIterator, Callable
8
10
  from urllib.parse import quote
9
11
 
@@ -17,6 +19,51 @@ log = logging.getLogger("iii.channels")
17
19
  MAX_FRAME_SIZE = 64 * 1024
18
20
 
19
21
 
22
+ class ChannelDirection(str, Enum):
23
+ """Direction of a streaming channel reference.
24
+
25
+ Mirrors the Rust SDK's ``ChannelDirection`` enum. The string values
26
+ (``"read"`` / ``"write"``) match the wire format used by
27
+ :class:`StreamChannelRef.direction`.
28
+ """
29
+
30
+ READ = "read"
31
+ WRITE = "write"
32
+
33
+
34
+ @dataclass
35
+ class ChannelItem:
36
+ """A single frame transferred over a channel.
37
+
38
+ Mirrors the Rust SDK's ``ChannelItem`` enum. Exactly one of ``text`` or
39
+ ``binary`` is set; use :meth:`text_item` / :meth:`binary_item` to
40
+ construct instances.
41
+ """
42
+
43
+ text: str | None = None
44
+ binary: bytes | None = None
45
+
46
+ def __post_init__(self) -> None:
47
+ if (self.text is None) == (self.binary is None):
48
+ raise ValueError("ChannelItem requires exactly one of `text` or `binary`")
49
+
50
+ @classmethod
51
+ def text_item(cls, value: str) -> "ChannelItem":
52
+ return cls(text=value, binary=None)
53
+
54
+ @classmethod
55
+ def binary_item(cls, value: bytes) -> "ChannelItem":
56
+ return cls(text=None, binary=value)
57
+
58
+ @property
59
+ def is_text(self) -> bool:
60
+ return self.text is not None
61
+
62
+ @property
63
+ def is_binary(self) -> bool:
64
+ return self.binary is not None
65
+
66
+
20
67
  def build_channel_url(
21
68
  engine_ws_base: str,
22
69
  channel_id: str,
@@ -0,0 +1,80 @@
1
+ """Helper free functions that operate on an :class:`III` client instance.
2
+
3
+ These were previously instance methods on the SDK client. They take the
4
+ ``iii`` client as the first argument so the public surface of the client
5
+ stays focused on the core lifecycle and registration methods.
6
+
7
+ Mirrors the Rust ``iii_sdk::helpers`` module and the Node
8
+ ``iii-sdk/helpers`` subpath export.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any, Protocol, TypeVar
14
+
15
+ from .channels import ChannelDirection, ChannelItem
16
+ from .stream import IStream
17
+ from .types import Channel, IIIClient, extract_channel_refs, is_channel_ref
18
+
19
+ TData = TypeVar("TData")
20
+
21
+ __all__ = [
22
+ "ChannelDirection",
23
+ "ChannelItem",
24
+ "create_channel",
25
+ "create_channel_async",
26
+ "create_stream",
27
+ "extract_channel_refs",
28
+ "is_channel_ref",
29
+ ]
30
+
31
+
32
+ class _IIIWithHelperShims(IIIClient, Protocol):
33
+ """Internal Protocol that adds the ``_helpers_*`` shim methods.
34
+
35
+ The free functions below delegate to these private methods on the
36
+ concrete :class:`III` instance. Defining the Protocol here mirrors the
37
+ Node SDK's ``IIIWithHelperShims`` intersection type — callers see the
38
+ public :class:`IIIClient` Protocol; helpers see the shims internally.
39
+ """
40
+
41
+ def _helpers_create_channel(self, buffer_size: int | None = None) -> Channel: ...
42
+
43
+ async def _helpers_create_channel_async(
44
+ self, buffer_size: int | None = None
45
+ ) -> Channel: ...
46
+
47
+ def _helpers_create_stream(
48
+ self, stream_name: str, stream: IStream[Any]
49
+ ) -> None: ... # noqa: D401 — internal shim, generic erased at the boundary
50
+
51
+
52
+ def create_channel(iii: IIIClient, buffer_size: int | None = None) -> Channel:
53
+ """Create a streaming channel pair (sync wrapper).
54
+
55
+ Free-function form of the former ``III.create_channel`` instance method.
56
+ """
57
+ shim: _IIIWithHelperShims = iii # type: ignore[assignment]
58
+ return shim._helpers_create_channel(buffer_size)
59
+
60
+
61
+ async def create_channel_async(
62
+ iii: IIIClient, buffer_size: int | None = None
63
+ ) -> Channel:
64
+ """Create a streaming channel pair (async).
65
+
66
+ Free-function form of the former ``III.create_channel_async`` method.
67
+ """
68
+ shim: _IIIWithHelperShims = iii # type: ignore[assignment]
69
+ return await shim._helpers_create_channel_async(buffer_size)
70
+
71
+
72
+ def create_stream(iii: IIIClient, stream_name: str, stream: IStream[TData]) -> None:
73
+ """Register a custom stream implementation.
74
+
75
+ Free-function form of the former ``III.create_stream`` instance method.
76
+ The ``IStream`` generic ``TData`` is preserved so type checkers can
77
+ validate the implementor's get/set/delete/list signatures.
78
+ """
79
+ shim: _IIIWithHelperShims = iii # type: ignore[assignment]
80
+ shim._helpers_create_stream(stream_name, stream)
@@ -1142,49 +1142,20 @@ class III:
1142
1142
  invocation_id=invocation_id,
1143
1143
  )
1144
1144
 
1145
- def create_channel(self, buffer_size: int | None = None) -> Channel:
1146
- """Create a streaming channel pair for worker-to-worker data transfer.
1145
+ # Internal: backing methods for items in the `iii.helpers` submodule.
1146
+ # These are renamed with a leading underscore so they don't appear on the
1147
+ # public `IIIClient` Protocol; callers use `iii.helpers.<name>(iii, ...)`.
1148
+ def _helpers_create_channel(self, buffer_size: int | None = None) -> Channel:
1149
+ """Internal shim backing :func:`iii.helpers.create_channel`.
1147
1150
 
1148
- The returned ``Channel`` contains a local ``writer`` / ``reader``
1149
- and their serializable refs (``writer_ref``, ``reader_ref``) that
1150
- can be passed as fields in invocation data to other functions.
1151
-
1152
- Args:
1153
- buffer_size: Buffer capacity for the channel. Defaults to ``64``.
1154
-
1155
- Returns:
1156
- A ``Channel`` object with ``writer``, ``reader``,
1157
- ``writer_ref``, and ``reader_ref`` attributes. Pass
1158
- ``writer_ref`` or ``reader_ref`` in trigger payloads to
1159
- share channels across functions -- the receiving function
1160
- can reconstruct a ``ChannelWriter`` or ``ChannelReader``
1161
- from the ref.
1162
-
1163
- Examples:
1164
- >>> ch = iii.create_channel()
1165
- >>> fn = iii.register_function("producer", producer_handler)
1166
- >>> iii.trigger({"function_id": "producer", "payload": {"output": ch.writer_ref}})
1151
+ Public callers must use the free function from ``iii.helpers``.
1167
1152
  """
1168
- return self._run_on_loop(self.create_channel_async(buffer_size))
1169
-
1170
- async def create_channel_async(self, buffer_size: int | None = None) -> Channel:
1171
- """Create a streaming channel pair for worker-to-worker data transfer.
1172
-
1173
- The returned ``Channel`` contains a local ``writer`` / ``reader``
1174
- and their serializable refs (``writer_ref``, ``reader_ref``) that
1175
- can be passed as fields in invocation data to other functions.
1153
+ return self._run_on_loop(self._helpers_create_channel_async(buffer_size))
1176
1154
 
1177
- Args:
1178
- buffer_size: Buffer capacity for the channel. Defaults to ``64``.
1179
-
1180
- Returns:
1181
- A ``Channel`` with ``writer``, ``reader``, ``writer_ref``, and
1182
- ``reader_ref`` attributes.
1155
+ async def _helpers_create_channel_async(self, buffer_size: int | None = None) -> Channel:
1156
+ """Internal shim backing :func:`iii.helpers.create_channel_async`.
1183
1157
 
1184
- Examples:
1185
- >>> ch = await iii.create_channel_async()
1186
- >>> fn = iii.register_function("producer", producer_handler)
1187
- >>> await iii.trigger_async({"function_id": "producer", "payload": {"output": ch.writer_ref}})
1158
+ Public callers must use the free function from ``iii.helpers``.
1188
1159
  """
1189
1160
  result = await self.trigger_async(
1190
1161
  {
@@ -1248,27 +1219,14 @@ class III:
1248
1219
  )
1249
1220
  asyncio.run_coroutine_threadsafe(self._send(msg), self._loop)
1250
1221
 
1251
- def create_stream(self, stream_name: str, stream: IStream[Any]) -> None:
1252
- """Register a custom stream implementation, overriding the engine default.
1222
+ def _helpers_create_stream(self, stream_name: str, stream: IStream[Any]) -> None:
1223
+ """Internal shim backing :func:`iii.helpers.create_stream`.
1253
1224
 
1225
+ Public callers must use the free function from ``iii.helpers``.
1254
1226
  Registers 5 of the 6 ``IStream`` methods (``get``, ``set``, ``delete``,
1255
- ``list``, ``list_groups``). The ``update`` method is **not** registered
1256
- -- atomic updates are handled by the engine's built-in stream update logic.
1257
-
1258
- Args:
1259
- stream_name: Unique name for the stream.
1260
- stream: An object implementing the ``IStream`` interface.
1261
-
1262
- Examples:
1263
- >>> from iii.stream import IStream
1264
- >>> class MyStream(IStream):
1265
- ... async def get(self, input): ...
1266
- ... async def set(self, input): ...
1267
- ... async def delete(self, input): ...
1268
- ... async def list(self, input): ...
1269
- ... async def list_groups(self, input): ...
1270
- ... async def update(self, input): ...
1271
- >>> iii.create_stream("my-stream", MyStream())
1227
+ ``list``, ``list_groups``). The ``update`` method is **not** registered
1228
+ -- atomic updates are handled by the engine's built-in stream update
1229
+ logic.
1272
1230
  """
1273
1231
 
1274
1232
  async def get_handler(data: Any) -> Any:
@@ -52,7 +52,7 @@ class Trigger:
52
52
 
53
53
 
54
54
  class TriggerTypeRef(Generic[C, R]):
55
- """Typed handle returned by :meth:`III.register_trigger_type`.
55
+ """Typed handle returned by :meth:`iii.III.register_trigger_type`.
56
56
 
57
57
  Type parameters:
58
58
 
@@ -5,7 +5,18 @@ from __future__ import annotations
5
5
  import asyncio
6
6
  import json
7
7
  from dataclasses import dataclass
8
- from typing import TYPE_CHECKING, Any, Awaitable, Callable, Generic, Protocol, TypeVar
8
+ from typing import (
9
+ TYPE_CHECKING,
10
+ Any,
11
+ Awaitable,
12
+ Callable,
13
+ Generic,
14
+ Literal,
15
+ Protocol,
16
+ TypedDict,
17
+ TypeGuard,
18
+ TypeVar,
19
+ )
9
20
 
10
21
  from pydantic import BaseModel, ConfigDict, Field
11
22
 
@@ -18,7 +29,6 @@ from .iii_types import (
18
29
  StreamChannelRef,
19
30
  TriggerRequest,
20
31
  )
21
- from .stream import IStream
22
32
  from .triggers import Trigger, TriggerHandler
23
33
 
24
34
  if TYPE_CHECKING:
@@ -77,7 +87,11 @@ class RemoteServiceFunctionData(BaseModel):
77
87
 
78
88
 
79
89
  class IIIClient(Protocol):
80
- """Protocol for III client implementations."""
90
+ """Protocol for III client implementations.
91
+
92
+ Helper free functions live in :mod:`iii.helpers`. See
93
+ :func:`iii.helpers.create_channel` and :func:`iii.helpers.create_stream`.
94
+ """
81
95
 
82
96
  def register_trigger(self, trigger: RegisterTriggerInput | dict[str, Any]) -> Trigger: ...
83
97
 
@@ -97,10 +111,6 @@ class IIIClient(Protocol):
97
111
 
98
112
  def unregister_trigger_type(self, trigger_type: RegisterTriggerTypeInput | dict[str, Any]) -> None: ...
99
113
 
100
- def create_channel(self, buffer_size: int | None = None) -> Channel: ...
101
-
102
- def create_stream(self, stream_name: str, stream: IStream[Any]) -> None: ...
103
-
104
114
  def shutdown(self) -> None: ...
105
115
 
106
116
 
@@ -183,11 +193,59 @@ class HttpRequest:
183
193
  request_body: ChannelReader
184
194
 
185
195
 
186
- def is_channel_ref(value: Any) -> bool:
187
- """Check if a value looks like a StreamChannelRef."""
196
+ class StreamChannelRefDict(TypedDict):
197
+ """Wire-shape of a :class:`StreamChannelRef` as it appears in raw payloads.
198
+
199
+ Used as the narrowed type for :func:`is_channel_ref` so type checkers can
200
+ validate ``value["channel_id"]`` style access after the guard passes.
201
+ """
202
+
203
+ channel_id: str
204
+ access_key: str
205
+ direction: Literal["read", "write"]
206
+
207
+
208
+ def is_channel_ref(value: Any) -> TypeGuard[StreamChannelRefDict]:
209
+ """Check if a value looks like a StreamChannelRef.
210
+
211
+ Returns a :class:`typing.TypeGuard` that narrows ``value`` to
212
+ :class:`StreamChannelRefDict`, mirroring the TypeScript SDK's
213
+ ``value is StreamChannelRef`` predicate.
214
+ """
188
215
  return (
189
216
  isinstance(value, dict)
190
217
  and isinstance(value.get("channel_id"), str)
191
218
  and isinstance(value.get("access_key"), str)
192
219
  and value.get("direction") in {"read", "write"}
193
220
  )
221
+
222
+
223
+ def extract_channel_refs(data: Any) -> list[tuple[str, StreamChannelRef]]:
224
+ """Extract all channel references from a nested value, returning ``(path, ref)`` tuples.
225
+
226
+ Recursively walks ``dict`` and ``list`` structures. Dotted paths are
227
+ used for object fields, bracketed indices for list elements (e.g.
228
+ ``items[0].writer``). Mirrors the Rust SDK's ``extract_channel_refs``.
229
+ """
230
+ refs: list[tuple[str, StreamChannelRef]] = []
231
+ _extract_refs_recursive(data, "", refs)
232
+ return refs
233
+
234
+
235
+ def _extract_refs_recursive(
236
+ data: Any, prefix: str, refs: list[tuple[str, StreamChannelRef]]
237
+ ) -> None:
238
+ if is_channel_ref(data):
239
+ try:
240
+ refs.append((prefix, StreamChannelRef(**data)))
241
+ except Exception:
242
+ pass
243
+ return
244
+ if isinstance(data, dict):
245
+ for key, value in data.items():
246
+ path = key if not prefix else f"{prefix}.{key}"
247
+ _extract_refs_recursive(value, path, refs)
248
+ elif isinstance(data, list):
249
+ for idx, item in enumerate(data):
250
+ path = f"[{idx}]" if not prefix else f"{prefix}[{idx}]"
251
+ _extract_refs_recursive(item, path, refs)
@@ -2,6 +2,7 @@
2
2
 
3
3
  import inspect
4
4
 
5
+ from iii import helpers
5
6
  from iii.iii import III
6
7
 
7
8
 
@@ -10,9 +11,8 @@ def test_trigger_async_is_coroutine_function():
10
11
  assert inspect.iscoroutinefunction(III.trigger_async)
11
12
 
12
13
 
13
- def test_create_channel_async_is_coroutine_function():
14
- assert hasattr(III, "create_channel_async")
15
- assert inspect.iscoroutinefunction(III.create_channel_async)
14
+ def test_create_channel_async_helper_is_coroutine_function():
15
+ assert inspect.iscoroutinefunction(helpers.create_channel_async)
16
16
 
17
17
 
18
18
  def test_connect_async_is_coroutine_function():
@@ -29,7 +29,6 @@ def test_async_methods_have_docstrings():
29
29
  """All public async methods must have docstrings."""
30
30
  async_methods = [
31
31
  "trigger_async",
32
- "create_channel_async",
33
32
  "connect_async",
34
33
  "shutdown_async",
35
34
  ]
@@ -4,8 +4,9 @@ import asyncio
4
4
  import json
5
5
  import time
6
6
 
7
- from iii.iii import III
8
7
  from iii.channels import ChannelReader, ChannelWriter
8
+ from iii.helpers import create_channel_async
9
+ from iii.iii import III
9
10
 
10
11
 
11
12
  def test_stream_data_from_sender_to_processor(iii_client: III):
@@ -35,7 +36,7 @@ def test_stream_data_from_sender_to_processor(iii_client: III):
35
36
 
36
37
  async def sender_handler(input_data):
37
38
  records = input_data["records"]
38
- channel = await iii_client.create_channel_async()
39
+ channel = await create_channel_async(iii_client)
39
40
 
40
41
  payload = json.dumps(records).encode("utf-8")
41
42
  await channel.writer.write(payload)
@@ -137,8 +138,8 @@ def test_bidirectional_streaming(iii_client: III):
137
138
  text = input_data["text"]
138
139
  chunk_size = input_data["chunkSize"]
139
140
 
140
- input_channel = await iii_client.create_channel_async()
141
- output_channel = await iii_client.create_channel_async()
141
+ input_channel = await create_channel_async(iii_client)
142
+ output_channel = await create_channel_async(iii_client)
142
143
 
143
144
  messages = []
144
145
  output_channel.reader.on_message(lambda msg: messages.append(json.loads(msg)))
@@ -0,0 +1,196 @@
1
+ """Tests for the public helpers submodule (mirrors Rust/Node helpers parity)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ import json
7
+ from types import SimpleNamespace
8
+ from typing import Any
9
+
10
+ import pytest
11
+
12
+ import iii.iii as iii_module
13
+ from iii import InitOptions
14
+ from iii.iii import III
15
+
16
+
17
+ class FakeWebSocket:
18
+ def __init__(self) -> None:
19
+ self.sent: list[dict[str, Any]] = []
20
+ self.state = SimpleNamespace(name="OPEN")
21
+
22
+ async def send(self, payload: str) -> None:
23
+ self.sent.append(json.loads(payload))
24
+
25
+ async def close(self) -> None:
26
+ self.state = SimpleNamespace(name="CLOSED")
27
+
28
+ def __aiter__(self) -> "FakeWebSocket":
29
+ return self
30
+
31
+ async def __anext__(self) -> Any:
32
+ raise StopAsyncIteration
33
+
34
+
35
+ def _patch_ws(monkeypatch: pytest.MonkeyPatch) -> FakeWebSocket:
36
+ ws = FakeWebSocket()
37
+
38
+ async def fake_connect(_: str, **kwargs: object) -> FakeWebSocket:
39
+ return ws
40
+
41
+ monkeypatch.setattr(iii_module.websockets, "connect", fake_connect)
42
+ monkeypatch.setattr("iii_observability.telemetry.init_otel", lambda **kwargs: None)
43
+ monkeypatch.setattr("iii_observability.telemetry.attach_event_loop", lambda loop: None)
44
+ monkeypatch.setattr(iii_module.III, "_register_worker_metadata", lambda self: None)
45
+ return ws
46
+
47
+
48
+ def test_helpers_module_exports_expected_names() -> None:
49
+ from iii import helpers
50
+
51
+ expected = {
52
+ "ChannelDirection",
53
+ "ChannelItem",
54
+ "create_channel",
55
+ "create_channel_async",
56
+ "create_stream",
57
+ "extract_channel_refs",
58
+ "is_channel_ref",
59
+ }
60
+ actual = set(helpers.__all__)
61
+ missing = expected - actual
62
+ assert not missing, f"missing from helpers.__all__: {missing}"
63
+ for name in expected:
64
+ assert hasattr(helpers, name), f"helpers module missing attribute: {name}"
65
+
66
+
67
+ def test_helpers_free_functions_take_iii_first() -> None:
68
+ from iii import helpers
69
+
70
+ for name in (
71
+ "create_channel",
72
+ "create_channel_async",
73
+ "create_stream",
74
+ ):
75
+ sig = inspect.signature(getattr(helpers, name))
76
+ params = list(sig.parameters)
77
+ assert params and params[0] == "iii", f"{name} signature: {sig}"
78
+
79
+
80
+ def test_is_channel_ref_works_via_helpers() -> None:
81
+ from iii import helpers
82
+
83
+ assert helpers.is_channel_ref({}) is False
84
+ assert helpers.is_channel_ref(
85
+ {"channel_id": "c", "access_key": "k", "direction": "read"}
86
+ ) is True
87
+ assert helpers.is_channel_ref(
88
+ {"channel_id": "c", "access_key": "k", "direction": "garbage"}
89
+ ) is False
90
+
91
+
92
+ def test_extract_channel_refs_walks_nested_structures() -> None:
93
+ from iii import helpers
94
+
95
+ assert helpers.extract_channel_refs({}) == []
96
+
97
+ ref = {"channel_id": "c1", "access_key": "k1", "direction": "read"}
98
+ refs = helpers.extract_channel_refs({"input": ref})
99
+ assert len(refs) == 1
100
+ path, channel_ref = refs[0]
101
+ assert path == "input"
102
+ assert channel_ref.channel_id == "c1"
103
+
104
+ nested = helpers.extract_channel_refs(
105
+ {"items": [{"writer": ref}, {"writer": ref}]}
106
+ )
107
+ assert {p for p, _ in nested} == {"items[0].writer", "items[1].writer"}
108
+
109
+
110
+ def test_channel_direction_string_values() -> None:
111
+ from iii.helpers import ChannelDirection
112
+
113
+ assert ChannelDirection.READ.value == "read"
114
+ assert ChannelDirection.WRITE.value == "write"
115
+
116
+
117
+ def test_channel_item_constructors() -> None:
118
+ from iii.helpers import ChannelItem
119
+
120
+ text = ChannelItem.text_item("hi")
121
+ assert text.is_text and not text.is_binary
122
+ assert text.text == "hi"
123
+
124
+ binary = ChannelItem.binary_item(b"\x00\x01")
125
+ assert binary.is_binary and not binary.is_text
126
+ assert binary.binary == b"\x00\x01"
127
+
128
+
129
+ def test_iii_no_longer_exposes_relocated_methods(monkeypatch: pytest.MonkeyPatch) -> None:
130
+ _patch_ws(monkeypatch)
131
+ client = III("ws://fake", InitOptions())
132
+
133
+ try:
134
+ for name in (
135
+ "create_channel",
136
+ "create_channel_async",
137
+ "create_stream",
138
+ ):
139
+ assert not hasattr(client, name), f"client still has {name}"
140
+ finally:
141
+ client.shutdown()
142
+
143
+
144
+ def test_iii_client_protocol_no_longer_declares_relocated_methods() -> None:
145
+ """The :class:`iii.IIIClient` Protocol must not declare the relocated methods."""
146
+ from iii import IIIClient
147
+
148
+ for name in (
149
+ "create_channel",
150
+ "create_stream",
151
+ ):
152
+ assert name not in IIIClient.__dict__, (
153
+ f"IIIClient Protocol still declares {name}"
154
+ )
155
+
156
+
157
+ def test_init_no_longer_exports_relocated_channel_items() -> None:
158
+ import iii
159
+
160
+ for name in (
161
+ "extract_channel_refs",
162
+ "is_channel_ref",
163
+ "ChannelDirection",
164
+ "ChannelItem",
165
+ ):
166
+ assert name not in iii.__all__, f"{name} still in iii.__all__"
167
+
168
+
169
+ def test_iii_register_and_unregister_trigger_type_round_trip(
170
+ monkeypatch: pytest.MonkeyPatch,
171
+ ) -> None:
172
+ from iii import RegisterTriggerTypeInput
173
+ from iii.triggers import TriggerConfig, TriggerHandler
174
+
175
+ class DummyHandler(TriggerHandler[Any]):
176
+ async def register_trigger(self, config: TriggerConfig[Any]) -> None:
177
+ return None
178
+
179
+ async def unregister_trigger(self, config: TriggerConfig[Any]) -> None:
180
+ return None
181
+
182
+ _patch_ws(monkeypatch)
183
+ client = III("ws://fake", InitOptions())
184
+ try:
185
+ trigger_type = RegisterTriggerTypeInput(
186
+ id="helpers.test", description="from helpers"
187
+ )
188
+
189
+ ref = client.register_trigger_type(trigger_type, DummyHandler())
190
+ assert "helpers.test" in client._trigger_types
191
+ assert ref is not None
192
+
193
+ client.unregister_trigger_type(trigger_type)
194
+ assert "helpers.test" not in client._trigger_types
195
+ finally:
196
+ client.shutdown()
@@ -6,6 +6,7 @@ from typing import Any
6
6
 
7
7
  import pytest
8
8
 
9
+ from iii.helpers import create_stream
9
10
  from iii.iii import III
10
11
  from iii.stream import (
11
12
  IStream,
@@ -353,7 +354,7 @@ async def test_stream_custom_operations(iii_client: III):
353
354
  async def update(self, input: StreamUpdateInput) -> StreamUpdateResult | None:
354
355
  raise NotImplementedError
355
356
 
356
- iii_client.create_stream(stream_name, InMemoryStream())
357
+ create_stream(iii_client, stream_name, InMemoryStream())
357
358
  await asyncio.sleep(1.0)
358
359
 
359
360
  test_data = {"name": "Test", "value": 100}
@@ -9,6 +9,7 @@ import pytest
9
9
 
10
10
  import iii.iii as iii_module
11
11
  from iii import InitOptions, RegisterTriggerTypeInput
12
+ from iii.helpers import create_channel
12
13
  from iii.iii import III
13
14
  from iii.triggers import TriggerConfig, TriggerHandler
14
15
 
@@ -179,7 +180,7 @@ def test_register_and_unregister_trigger_type_accept_input_object(monkeypatch: p
179
180
 
180
181
 
181
182
  def test_public_methods_are_sync(monkeypatch: pytest.MonkeyPatch) -> None:
182
- """trigger and create_channel should be sync."""
183
+ """trigger and the helpers.create_channel free function should be sync."""
183
184
  _patch_ws(monkeypatch)
184
185
  client = III("ws://fake", InitOptions())
185
186
  time.sleep(0.05)
@@ -187,7 +188,7 @@ def test_public_methods_are_sync(monkeypatch: pytest.MonkeyPatch) -> None:
187
188
  import inspect
188
189
 
189
190
  assert not inspect.iscoroutinefunction(client.trigger)
190
- assert not inspect.iscoroutinefunction(client.create_channel)
191
+ assert not inspect.iscoroutinefunction(create_channel)
191
192
 
192
193
  client.shutdown()
193
194
 
@@ -546,7 +546,7 @@ wheels = [
546
546
 
547
547
  [[package]]
548
548
  name = "iii-observability"
549
- version = "0.13.0.dev1"
549
+ version = "0.16.0.dev3"
550
550
  source = { editable = "../observability" }
551
551
  dependencies = [
552
552
  { name = "httpx" },
@@ -572,7 +572,7 @@ provides-extras = ["dev"]
572
572
 
573
573
  [[package]]
574
574
  name = "iii-sdk"
575
- version = "0.13.0.dev1"
575
+ version = "0.16.0.dev3"
576
576
  source = { editable = "." }
577
577
  dependencies = [
578
578
  { name = "iii-observability" },
File without changes
File without changes