iii-helpers 0.19.4a2__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.
@@ -0,0 +1,92 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ *.egg-info/
24
+ .installed.cfg
25
+ *.egg
26
+
27
+ # PyInstaller
28
+ *.manifest
29
+ *.spec
30
+
31
+ # Installer logs
32
+ pip-log.txt
33
+ pip-delete-this-directory.txt
34
+
35
+ # Unit test / coverage reports
36
+ htmlcov/
37
+ .tox/
38
+ .nox/
39
+ .coverage
40
+ .coverage.*
41
+ .cache
42
+ nosetests.xml
43
+ coverage.xml
44
+ *.cover
45
+ *.py,cover
46
+ .hypothesis/
47
+ .pytest_cache/
48
+ pytest_cache/
49
+
50
+ # Translations
51
+ *.mo
52
+ *.pot
53
+
54
+ # Environments
55
+ .env
56
+ .venv
57
+ env/
58
+ venv/
59
+ ENV/
60
+ env.bak/
61
+ venv.bak/
62
+
63
+ # Spyder project settings
64
+ .spyderproject
65
+ .spyproject
66
+
67
+ # Rope project settings
68
+ .ropeproject
69
+
70
+ # mkdocs documentation
71
+ /site
72
+
73
+ # mypy
74
+ .mypy_cache/
75
+ .dmypy.json
76
+ dmypy.json
77
+
78
+ # Pyre type checker
79
+ .pyre/
80
+
81
+ # pytype static type analyzer
82
+ .pytype/
83
+
84
+ # Cython debug symbols
85
+ cython_debug/
86
+
87
+ # IDE
88
+ .idea/
89
+ .vscode/
90
+ *.swp
91
+ *.swo
92
+ *~
@@ -0,0 +1,31 @@
1
+ Metadata-Version: 2.4
2
+ Name: iii-helpers
3
+ Version: 0.19.4a2
4
+ Summary: Shared helper primitives across iii SDKs.
5
+ Project-URL: Homepage, https://github.com/iii-hq/iii
6
+ Project-URL: Repository, https://github.com/iii-hq/iii
7
+ Author: III
8
+ License: Apache-2.0
9
+ Keywords: helpers,iii
10
+ Classifier: License :: OSI Approved :: Apache Software License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Requires-Python: >=3.10
17
+ Requires-Dist: pydantic>=2.0
18
+ Provides-Extra: dev
19
+ Requires-Dist: mypy>=1.8; extra == 'dev'
20
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
21
+ Requires-Dist: pytest-cov>=6.0; extra == 'dev'
22
+ Requires-Dist: pytest-httpx>=0.30; extra == 'dev'
23
+ Requires-Dist: pytest>=8.0; extra == 'dev'
24
+ Requires-Dist: ruff>=0.2; extra == 'dev'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # iii-helpers
28
+
29
+ Shared helper primitives across the iii SDKs.
30
+
31
+ See https://github.com/iii-hq/iii for the full project.
@@ -0,0 +1,5 @@
1
+ # iii-helpers
2
+
3
+ Shared helper primitives across the iii SDKs.
4
+
5
+ See https://github.com/iii-hq/iii for the full project.
@@ -0,0 +1,57 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "iii-helpers"
7
+ version = "0.19.4a2"
8
+ description = "Shared helper primitives across iii SDKs."
9
+ authors = [{ name = "III" }]
10
+ license = { text = "Apache-2.0" }
11
+ readme = "README.md"
12
+ requires-python = ">=3.10"
13
+ keywords = ["iii", "helpers"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.10",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "License :: OSI Approved :: Apache Software License",
20
+ "Operating System :: OS Independent",
21
+ ]
22
+ dependencies = [
23
+ "pydantic>=2.0",
24
+ ]
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/iii-hq/iii"
28
+ Repository = "https://github.com/iii-hq/iii"
29
+
30
+ [project.optional-dependencies]
31
+ dev = [
32
+ "pytest>=8.0",
33
+ "pytest-asyncio>=0.23",
34
+ "pytest-cov>=6.0",
35
+ "pytest-httpx>=0.30",
36
+ "mypy>=1.8",
37
+ "ruff>=0.2",
38
+ ]
39
+
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["src/iii_helpers"]
42
+
43
+ [tool.ruff]
44
+ line-length = 120
45
+ target-version = "py310"
46
+
47
+ [tool.ruff.lint]
48
+ select = ["E", "F", "I", "W"]
49
+
50
+ [tool.mypy]
51
+ python_version = "3.10"
52
+ strict = true
53
+
54
+ [tool.pytest.ini_options]
55
+ addopts = "--cov=src/iii_helpers --cov-branch --cov-report=term-missing"
56
+ testpaths = ["tests"]
57
+ asyncio_mode = "auto"
@@ -0,0 +1 @@
1
+ """iii helpers."""
@@ -0,0 +1,142 @@
1
+ """iii http helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any, Awaitable, Callable, Generic, Literal, TypeVar
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field
8
+
9
+ if TYPE_CHECKING:
10
+ from iii.types import StreamRequest, StreamResponse
11
+
12
+ TInput = TypeVar("TInput")
13
+ TOutput = TypeVar("TOutput")
14
+
15
+ HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE"]
16
+ """HTTP method accepted by :data:`HttpInvocationConfig`. Distinct from the core
17
+ ``builtin_triggers`` HTTP method enum, which also covers HEAD/OPTIONS."""
18
+
19
+
20
+ class HttpAuthHmac(BaseModel):
21
+ """HMAC signature verification using a shared secret."""
22
+
23
+ type: Literal["hmac"] = "hmac"
24
+ secret_key: str = Field(description="Environment variable name containing the HMAC shared secret.")
25
+
26
+
27
+ class HttpAuthBearer(BaseModel):
28
+ """Bearer token authentication."""
29
+
30
+ type: Literal["bearer"] = "bearer"
31
+ token_key: str = Field(description="Environment variable name containing the bearer token.")
32
+
33
+
34
+ class HttpAuthApiKey(BaseModel):
35
+ """API key sent via a custom header."""
36
+
37
+ type: Literal["api_key"] = "api_key"
38
+ header: str = Field(description="HTTP header name for the API key.")
39
+ value_key: str = Field(description="Environment variable name containing the API key value.")
40
+
41
+
42
+ HttpAuthConfig = HttpAuthHmac | HttpAuthBearer | HttpAuthApiKey
43
+ """Authentication configuration for HTTP-invoked functions."""
44
+
45
+
46
+ class HttpInvocationConfig(BaseModel):
47
+ """Config for HTTP external function invocation.
48
+
49
+ Attributes:
50
+ url: Target URL for the HTTP invocation.
51
+ method: HTTP method. Defaults to ``'POST'``.
52
+ timeout_ms: Request timeout in milliseconds.
53
+ headers: Additional HTTP headers to include in the request.
54
+ auth: Authentication configuration (bearer, HMAC, or API key).
55
+ """
56
+
57
+ url: str = Field(description="Target URL for the HTTP invocation.")
58
+ method: HttpMethod = Field(default="POST", description="HTTP method. Defaults to ``'POST'``.")
59
+ timeout_ms: int | None = Field(default=None, description="Request timeout in milliseconds.")
60
+ headers: dict[str, str] | None = Field(
61
+ default=None,
62
+ description="Additional HTTP headers to include in the request.",
63
+ )
64
+ auth: HttpAuthConfig | None = Field(
65
+ default=None,
66
+ description="Authentication configuration (bearer, HMAC, or API key).",
67
+ )
68
+
69
+
70
+ class HttpRequest(BaseModel, Generic[TInput]):
71
+ """Represents a buffered HTTP request."""
72
+
73
+ path_params: dict[str, str] = Field(default_factory=dict)
74
+ query_params: dict[str, str | list[str]] = Field(default_factory=dict)
75
+ body: Any | None = None
76
+ headers: dict[str, str | list[str]] = Field(default_factory=dict)
77
+ method: str = "GET"
78
+
79
+
80
+ class HttpResponse(BaseModel, Generic[TOutput]):
81
+ """Represents a buffered HTTP response."""
82
+
83
+ model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True)
84
+
85
+ status_code: int = Field(alias="statusCode")
86
+ body: Any | None = None
87
+ headers: dict[str, str] = Field(default_factory=dict)
88
+
89
+
90
+ def http(
91
+ callback: Callable[[StreamRequest, StreamResponse], Awaitable[HttpResponse[Any] | None]],
92
+ ) -> Callable[[Any], Awaitable[HttpResponse[Any] | None]]:
93
+ """Wrap a streaming handler so it receives typed StreamRequest and StreamResponse.
94
+
95
+ Takes a callback ``(req, res) -> HttpResponse | None`` and returns a
96
+ function the iii engine can invoke directly. The wrapper converts the
97
+ raw dict (or ``InternalHttpRequest``) delivered by the engine into the
98
+ typed ``StreamRequest`` / ``StreamResponse`` pair that the callback expects.
99
+ """
100
+ from iii.types import InternalHttpRequest, StreamRequest, StreamResponse
101
+
102
+ async def wrapper(req: Any) -> HttpResponse[Any] | None:
103
+ if isinstance(req, InternalHttpRequest):
104
+ internal = req
105
+ elif isinstance(req, dict):
106
+ internal = InternalHttpRequest(
107
+ path_params=req.get("path_params", {}),
108
+ query_params=req.get("query_params", {}),
109
+ body=req.get("body"),
110
+ headers=req.get("headers", {}),
111
+ method=req.get("method", "GET"),
112
+ response=req["response"],
113
+ request_body=req["request_body"],
114
+ )
115
+ else:
116
+ internal = req
117
+
118
+ http_response = StreamResponse(internal.response)
119
+ http_request = StreamRequest(
120
+ path_params=internal.path_params,
121
+ query_params=internal.query_params,
122
+ body=internal.body,
123
+ headers=internal.headers,
124
+ method=internal.method,
125
+ request_body=internal.request_body,
126
+ )
127
+ return await callback(http_request, http_response)
128
+
129
+ return wrapper
130
+
131
+
132
+ __all__ = [
133
+ "HttpAuthApiKey",
134
+ "HttpAuthBearer",
135
+ "HttpAuthConfig",
136
+ "HttpAuthHmac",
137
+ "HttpInvocationConfig",
138
+ "HttpMethod",
139
+ "HttpRequest",
140
+ "HttpResponse",
141
+ "http",
142
+ ]
File without changes
@@ -0,0 +1,18 @@
1
+ """iii queue helpers."""
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ class EnqueueResult(BaseModel):
7
+ """Result returned when a function is invoked with ``TriggerAction.Enqueue``.
8
+
9
+ Attributes:
10
+ messageReceiptId: UUID assigned by the engine to the enqueued job.
11
+ """
12
+
13
+ messageReceiptId: str = Field(description="UUID assigned by the engine to the enqueued job.")
14
+
15
+
16
+ __all__ = [
17
+ "EnqueueResult",
18
+ ]
@@ -0,0 +1,334 @@
1
+ """iii stream helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Generic, Literal, TypeVar
6
+
7
+ from pydantic import BaseModel, Field, model_serializer
8
+
9
+ __all__ = [
10
+ "MergePath",
11
+ "StreamAuthInput",
12
+ "StreamAuthResult",
13
+ "StreamChangeEvent",
14
+ "StreamChangeEventDetail",
15
+ "StreamContext",
16
+ "StreamDeleteInput",
17
+ "StreamDeleteResult",
18
+ "StreamGetInput",
19
+ "StreamJoinLeaveEvent",
20
+ "StreamJoinLeaveTriggerConfig",
21
+ "StreamJoinResult",
22
+ "StreamListGroupsInput",
23
+ "StreamListInput",
24
+ "StreamSetInput",
25
+ "StreamSetResult",
26
+ "StreamTriggerConfig",
27
+ "StreamUpdateInput",
28
+ "StreamUpdateResult",
29
+ "UpdateAppend",
30
+ "UpdateDecrement",
31
+ "UpdateIncrement",
32
+ "UpdateMerge",
33
+ "UpdateOp",
34
+ "UpdateOpError",
35
+ "UpdateRemove",
36
+ "UpdateSet",
37
+ ]
38
+
39
+ TData = TypeVar("TData")
40
+
41
+ # Path target for a ``merge`` / ``append`` op. Accepts either a single
42
+ # string (legacy / first-level key) or a list of literal segments
43
+ # (nested path). Mirrors the Node ``string | string[]`` alias and the
44
+ # Rust ``MergePath`` enum.
45
+ MergePath = str | list[str]
46
+
47
+
48
+ class StreamAuthInput(BaseModel):
49
+ """Input for stream authentication."""
50
+
51
+ headers: dict[str, str]
52
+ path: str
53
+ query_params: dict[str, list[str]]
54
+ addr: str
55
+
56
+
57
+ class StreamAuthResult(BaseModel):
58
+ """Result of stream authentication."""
59
+
60
+ context: Any | None = None
61
+
62
+
63
+ StreamContext = Any
64
+
65
+
66
+ class StreamJoinLeaveEvent(BaseModel):
67
+ """Event for stream join/leave."""
68
+
69
+ subscription_id: str
70
+ stream_name: str
71
+ group_id: str
72
+ id: str | None = None
73
+ context: Any | None = None
74
+
75
+
76
+ class StreamJoinResult(BaseModel):
77
+ """Result of stream join."""
78
+
79
+ unauthorized: bool
80
+
81
+
82
+ class StreamGetInput(BaseModel):
83
+ """Input for stream get operation."""
84
+
85
+ stream_name: str
86
+ group_id: str
87
+ item_id: str
88
+
89
+
90
+ class StreamSetInput(BaseModel):
91
+ """Input for stream set operation."""
92
+
93
+ stream_name: str
94
+ group_id: str
95
+ item_id: str
96
+ data: Any
97
+
98
+
99
+ class StreamDeleteInput(BaseModel):
100
+ """Input for stream delete operation."""
101
+
102
+ stream_name: str
103
+ group_id: str
104
+ item_id: str
105
+
106
+
107
+ class StreamListInput(BaseModel):
108
+ """Input for stream list operation."""
109
+
110
+ stream_name: str
111
+ group_id: str
112
+
113
+
114
+ class StreamListGroupsInput(BaseModel):
115
+ """Input for stream list groups operation."""
116
+
117
+ stream_name: str
118
+
119
+
120
+ class StreamUpdateInput(BaseModel):
121
+ """Input for stream update operation."""
122
+
123
+ stream_name: str
124
+ group_id: str
125
+ item_id: str
126
+ ops: list["UpdateOp"]
127
+
128
+
129
+ class UpdateOpError(BaseModel):
130
+ """Per-op error returned by ``state::update`` / ``stream::update``.
131
+
132
+ Currently emitted only by the ``merge`` op when input violates the
133
+ new validation bounds. Successfully applied ops are still
134
+ reflected in the response's ``new_value``.
135
+ """
136
+
137
+ op_index: int
138
+ code: str
139
+ message: str
140
+ doc_url: str | None = None
141
+
142
+
143
+ class StreamSetResult(BaseModel, Generic[TData]):
144
+ """Result of stream set operation."""
145
+
146
+ old_value: TData | None = None
147
+ new_value: TData
148
+
149
+
150
+ class StreamUpdateResult(BaseModel, Generic[TData]):
151
+ """Result of stream update operation."""
152
+
153
+ old_value: TData | None = None
154
+ new_value: TData
155
+ # Per-op errors. Emitted by ``merge`` and ``append`` for validation
156
+ # rejections (path/value bounds, proto-pollution segments) and by
157
+ # ``append`` for the ``append.type_mismatch`` and
158
+ # ``append.target_not_object`` surfaces. Field is omitted from the
159
+ # JSON wire when empty. ``default_factory`` is used (not ``= []``)
160
+ # to keep Pydantic's parameterized-Generic + default handling
161
+ # well-behaved across Python versions.
162
+ errors: list[UpdateOpError] = Field(default_factory=list)
163
+
164
+
165
+ class StreamDeleteResult(BaseModel):
166
+ """Result of stream delete operation."""
167
+
168
+ old_value: Any | None = None
169
+
170
+
171
+ class UpdateSet(BaseModel):
172
+ """Set operation for stream update."""
173
+
174
+ type: str = "set"
175
+ path: str
176
+ value: Any
177
+
178
+
179
+ class UpdateIncrement(BaseModel):
180
+ """Increment operation for stream update."""
181
+
182
+ type: str = "increment"
183
+ path: str
184
+ by: int | float
185
+
186
+
187
+ class UpdateDecrement(BaseModel):
188
+ """Decrement operation for stream update."""
189
+
190
+ type: str = "decrement"
191
+ path: str
192
+ by: int | float
193
+
194
+
195
+ class UpdateAppend(BaseModel):
196
+ """Append an element to an array, concatenate a string, or push at a nested path.
197
+
198
+ The target is the root (when ``path`` is omitted, an empty string,
199
+ or an empty list), a single first-level key (when ``path`` is a
200
+ non-empty string), or an arbitrary nested location (when ``path``
201
+ is a list of literal segments).
202
+
203
+ Path forms accepted (mirrors :class:`UpdateMerge` after #1547):
204
+ - ``None`` / ``""`` / ``[]``: append at the root.
205
+ - ``"foo"``: append at the first-level key ``foo``. A dotted
206
+ string like ``"a.b"`` is the literal key ``"a.b"``, *not*
207
+ traversed as ``a -> b``.
208
+ - ``["a", "b", "c"]``: nested path; each element is a literal
209
+ segment.
210
+
211
+ Engine semantics:
212
+ - Missing/non-object intermediates along a nested path are
213
+ auto-created/replaced with ``{}``.
214
+ - At the leaf:
215
+ - missing/null + nested path -> ``[value]`` (always an array)
216
+ - missing/null + single-string path -> string-as-string for
217
+ the string-concat tier, otherwise ``[value]``
218
+ - existing array -> push
219
+ - existing string + string value -> concatenate
220
+ - existing object/scalar at the leaf -> ``append.type_mismatch``
221
+
222
+ Validation: invalid paths (depth > 32 segments, segment > 256
223
+ bytes, or any ``__proto__`` / ``constructor`` / ``prototype``
224
+ segment) are rejected with a structured error in the ``errors``
225
+ field of the ``state::update`` / ``stream::update`` response. The
226
+ append does not apply when an error is returned for that op.
227
+ """
228
+
229
+ type: str = "append"
230
+ # Optional. Accepts a single string (legacy / first-level key) or
231
+ # a list of literal segments (nested append). ``None`` / ``""`` /
232
+ # ``[]`` all route to root append. See :data:`MergePath`.
233
+ path: MergePath | None = None
234
+ value: Any
235
+
236
+ @model_serializer(mode="wrap")
237
+ def _omit_none_path(self, handler): # type: ignore[no-untyped-def]
238
+ # Drop ``path: None`` from the wire so cross-SDK consumers see
239
+ # the field absent rather than ``null``. Mirrors the Rust
240
+ # ``#[serde(skip_serializing_if = "Option::is_none")]`` on
241
+ # ``UpdateOp::Append.path``.
242
+ data = handler(self)
243
+ if data.get("path") is None:
244
+ data.pop("path", None)
245
+ return data
246
+
247
+
248
+ class UpdateRemove(BaseModel):
249
+ """Remove operation for stream update."""
250
+
251
+ type: str = "remove"
252
+ path: str
253
+
254
+
255
+ class UpdateMerge(BaseModel):
256
+ """Shallow merge an object into the target.
257
+
258
+ The target is the root (when ``path`` is omitted, an empty string,
259
+ or an empty list) or an arbitrary nested location specified by an
260
+ array of literal segments.
261
+
262
+ Path forms accepted:
263
+ - ``None`` / ``""`` / ``[]``: merge at the root.
264
+ - ``"foo"``: equivalent to ``["foo"]`` -- single first-level key.
265
+ - ``["a", "b", "c"]``: nested path. Each element is a *literal*
266
+ key. ``["a.b"]`` writes a single key named ``"a.b"``, not
267
+ ``a -> b``.
268
+
269
+ Engine semantics:
270
+ - Missing or non-object intermediates along the path are
271
+ auto-replaced with ``{}``.
272
+ - The merge is shallow at the target node (top-level keys of
273
+ ``value`` overwrite same-named keys; siblings preserved).
274
+
275
+ Validation: invalid paths/values (depth > 32 segments, segment >
276
+ 256 bytes, value depth > 16, > 1024 top-level keys, or any
277
+ ``__proto__`` / ``constructor`` / ``prototype`` segment or
278
+ top-level key) are rejected with a structured error in the
279
+ ``errors`` array of the ``state::update`` / ``stream::update``
280
+ response. The merge does not apply when an error is returned.
281
+ """
282
+
283
+ type: str = "merge"
284
+ # Optional. Accepts a single string or a list of literal segments.
285
+ # Pydantic resolves ``str | list[str]`` via smart-union: string
286
+ # input -> str, array input -> list[str]. See :data:`MergePath`.
287
+ path: MergePath | None = None
288
+ value: Any
289
+
290
+ @model_serializer(mode="wrap")
291
+ def _omit_none_path(self, handler): # type: ignore[no-untyped-def]
292
+ # Mirrors the same skip-when-none rule applied to
293
+ # ``UpdateOp::Merge.path`` in the Rust SDK so cross-SDK wire
294
+ # payloads are byte-identical for root merges.
295
+ data = handler(self)
296
+ if data.get("path") is None:
297
+ data.pop("path", None)
298
+ return data
299
+
300
+
301
+ UpdateOp = UpdateSet | UpdateIncrement | UpdateDecrement | UpdateAppend | UpdateRemove | UpdateMerge
302
+
303
+
304
+ class StreamTriggerConfig(BaseModel):
305
+ """Trigger config for ``stream`` triggers. Filters which item changes fire the handler."""
306
+
307
+ stream_name: str
308
+ group_id: str | None = None
309
+ item_id: str | None = None
310
+ condition_function_id: str | None = None
311
+
312
+
313
+ class StreamJoinLeaveTriggerConfig(BaseModel):
314
+ """Trigger config for ``stream:join`` and ``stream:leave`` triggers."""
315
+
316
+ condition_function_id: str | None = None
317
+
318
+
319
+ class StreamChangeEventDetail(BaseModel):
320
+ """Detail of a stream change event containing the mutation type and data."""
321
+
322
+ type: Literal["create", "update", "delete"]
323
+ data: Any
324
+
325
+
326
+ class StreamChangeEvent(BaseModel):
327
+ """Handler input for ``stream`` triggers, fired when an item changes."""
328
+
329
+ type: Literal["stream"]
330
+ timestamp: int
331
+ streamName: str
332
+ groupId: str
333
+ id: str | None = None
334
+ event: StreamChangeEventDetail