iii-sdk 0.11.7.dev1__tar.gz → 0.11.7.dev2__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 (59) hide show
  1. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/PKG-INFO +1 -1
  2. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/pyproject.toml +1 -1
  3. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/src/iii/stream.py +65 -8
  4. iii_sdk-0.11.7.dev2/tests/test_stream_models.py +134 -0
  5. iii_sdk-0.11.7.dev1/tests/test_stream_models.py +0 -73
  6. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/.gitignore +0 -0
  7. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/README.md +0 -0
  8. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/src/iii/__init__.py +0 -0
  9. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/src/iii/channels.py +0 -0
  10. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/src/iii/errors.py +0 -0
  11. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/src/iii/format_utils.py +0 -0
  12. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/src/iii/iii.py +0 -0
  13. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/src/iii/iii_constants.py +0 -0
  14. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/src/iii/iii_types.py +0 -0
  15. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/src/iii/logger.py +0 -0
  16. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/src/iii/otel_worker_gauges.py +0 -0
  17. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/src/iii/state.py +0 -0
  18. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/src/iii/telemetry.py +0 -0
  19. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/src/iii/telemetry_exporters.py +0 -0
  20. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/src/iii/telemetry_types.py +0 -0
  21. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/src/iii/triggers.py +0 -0
  22. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/src/iii/types.py +0 -0
  23. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/src/iii/utils.py +0 -0
  24. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/src/iii/worker_metrics.py +0 -0
  25. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/tests/conftest.py +0 -0
  26. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/tests/test_api_triggers.py +0 -0
  27. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/tests/test_async_api.py +0 -0
  28. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/tests/test_bridge.py +0 -0
  29. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/tests/test_channel_close_delay.py +0 -0
  30. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/tests/test_context_propagation.py +0 -0
  31. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/tests/test_data_channels.py +0 -0
  32. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/tests/test_errors.py +0 -0
  33. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/tests/test_format_utils.py +0 -0
  34. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/tests/test_healthcheck.py +0 -0
  35. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/tests/test_hold_process.py +0 -0
  36. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/tests/test_http_external_functions_integration.py +0 -0
  37. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/tests/test_iii_registration_dedup.py +0 -0
  38. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/tests/test_init_api.py +0 -0
  39. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/tests/test_invocation_exception.py +0 -0
  40. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/tests/test_logger_function_ids.py +0 -0
  41. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/tests/test_logger_otel.py +0 -0
  42. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/tests/test_middleware.py +0 -0
  43. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/tests/test_pubsub.py +0 -0
  44. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/tests/test_queue_integration.py +0 -0
  45. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/tests/test_rbac_workers.py +0 -0
  46. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/tests/test_register_function_args.py +0 -0
  47. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/tests/test_state.py +0 -0
  48. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/tests/test_streams.py +0 -0
  49. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/tests/test_streams_runtime_annotations.py +0 -0
  50. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/tests/test_sync_api.py +0 -0
  51. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/tests/test_telemetry.py +0 -0
  52. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/tests/test_telemetry_exporters.py +0 -0
  53. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/tests/test_telemetry_types.py +0 -0
  54. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/tests/test_trace_helpers.py +0 -0
  55. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/tests/test_trigger_metadata.py +0 -0
  56. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/tests/test_utils.py +0 -0
  57. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/tests/test_worker_metadata.py +0 -0
  58. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/tests/test_worker_metrics.py +0 -0
  59. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev2}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iii-sdk
3
- Version: 0.11.7.dev1
3
+ Version: 0.11.7.dev2
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "iii-sdk"
7
- version = "0.11.7.dev1"
7
+ version = "0.11.7.dev2"
8
8
  description = "III SDK for Python"
9
9
  authors = [{ name = "III" }]
10
10
  license = { text = "Apache-2.0" }
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  from abc import ABC, abstractmethod
6
6
  from typing import Any, Generic, List, Literal, TypeVar
7
7
 
8
- from pydantic import BaseModel, Field
8
+ from pydantic import BaseModel, Field, model_serializer
9
9
 
10
10
  TData = TypeVar("TData")
11
11
 
@@ -117,11 +117,13 @@ class StreamUpdateResult(BaseModel, Generic[TData]):
117
117
 
118
118
  old_value: TData | None = None
119
119
  new_value: TData
120
- # Per-op errors. Currently emitted only by the ``merge`` op for
121
- # validation rejections. Field is omitted from the JSON wire when
122
- # empty. ``default_factory`` is used (not ``= []``) to keep
123
- # Pydantic's parameterized-Generic + default handling well-behaved
124
- # across Python versions.
120
+ # Per-op errors. Emitted by ``merge`` and ``append`` for validation
121
+ # rejections (path/value bounds, proto-pollution segments) and by
122
+ # ``append`` for the ``append.type_mismatch`` and
123
+ # ``append.target_not_object`` surfaces. Field is omitted from the
124
+ # JSON wire when empty. ``default_factory`` is used (not ``= []``)
125
+ # to keep Pydantic's parameterized-Generic + default handling
126
+ # well-behaved across Python versions.
125
127
  errors: list[UpdateOpError] = Field(default_factory=list)
126
128
 
127
129
 
@@ -156,12 +158,57 @@ class UpdateDecrement(BaseModel):
156
158
 
157
159
 
158
160
  class UpdateAppend(BaseModel):
159
- """Append operation for stream update."""
161
+ """Append an element to an array, concatenate a string, or push at a nested path.
162
+
163
+ The target is the root (when ``path`` is omitted, an empty string,
164
+ or an empty list), a single first-level key (when ``path`` is a
165
+ non-empty string), or an arbitrary nested location (when ``path``
166
+ is a list of literal segments).
167
+
168
+ Path forms accepted (mirrors :class:`UpdateMerge` after #1547):
169
+ - ``None`` / ``""`` / ``[]``: append at the root.
170
+ - ``"foo"``: append at the first-level key ``foo``. A dotted
171
+ string like ``"a.b"`` is the literal key ``"a.b"``, *not*
172
+ traversed as ``a -> b``.
173
+ - ``["a", "b", "c"]``: nested path; each element is a literal
174
+ segment.
175
+
176
+ Engine semantics:
177
+ - Missing/non-object intermediates along a nested path are
178
+ auto-created/replaced with ``{}``.
179
+ - At the leaf:
180
+ - missing/null + nested path -> ``[value]`` (always an array)
181
+ - missing/null + single-string path -> string-as-string for
182
+ the string-concat tier, otherwise ``[value]``
183
+ - existing array -> push
184
+ - existing string + string value -> concatenate
185
+ - existing object/scalar at the leaf -> ``append.type_mismatch``
186
+
187
+ Validation: invalid paths (depth > 32 segments, segment > 256
188
+ bytes, or any ``__proto__`` / ``constructor`` / ``prototype``
189
+ segment) are rejected with a structured error in the ``errors``
190
+ field of the ``state::update`` / ``stream::update`` response. The
191
+ append does not apply when an error is returned for that op.
192
+ """
160
193
 
161
194
  type: str = "append"
162
- path: str
195
+ # Optional. Accepts a single string (legacy / first-level key) or
196
+ # a list of literal segments (nested append). ``None`` / ``""`` /
197
+ # ``[]`` all route to root append.
198
+ path: str | list[str] | None = None
163
199
  value: Any
164
200
 
201
+ @model_serializer(mode="wrap")
202
+ def _omit_none_path(self, handler): # type: ignore[no-untyped-def]
203
+ # Drop ``path: None`` from the wire so cross-SDK consumers see
204
+ # the field absent rather than ``null``. Mirrors the Rust
205
+ # ``#[serde(skip_serializing_if = "Option::is_none")]`` on
206
+ # ``UpdateOp::Append.path``.
207
+ data = handler(self)
208
+ if data.get("path") is None:
209
+ data.pop("path", None)
210
+ return data
211
+
165
212
 
166
213
  class UpdateRemove(BaseModel):
167
214
  """Remove operation for stream update."""
@@ -205,6 +252,16 @@ class UpdateMerge(BaseModel):
205
252
  path: str | list[str] | None = None
206
253
  value: Any
207
254
 
255
+ @model_serializer(mode="wrap")
256
+ def _omit_none_path(self, handler): # type: ignore[no-untyped-def]
257
+ # Mirrors the same skip-when-none rule applied to
258
+ # ``UpdateOp::Merge.path`` in the Rust SDK so cross-SDK wire
259
+ # payloads are byte-identical for root merges.
260
+ data = handler(self)
261
+ if data.get("path") is None:
262
+ data.pop("path", None)
263
+ return data
264
+
208
265
 
209
266
  UpdateOp = UpdateSet | UpdateIncrement | UpdateDecrement | UpdateAppend | UpdateRemove | UpdateMerge
210
267
 
@@ -0,0 +1,134 @@
1
+ """Unit tests for stream model serialization."""
2
+
3
+ import json
4
+
5
+ from iii.stream import StreamUpdateResult, UpdateAppend, UpdateMerge, UpdateOpError
6
+
7
+
8
+ def test_update_append_model_serializes() -> None:
9
+ op = UpdateAppend(path="chunks", value={"text": "hello"})
10
+
11
+ assert op.model_dump() == {"type": "append", "path": "chunks", "value": {"text": "hello"}}
12
+
13
+
14
+ def test_update_append_with_array_path_round_trips() -> None:
15
+ """Closes issue #1552 case 3: nested-path array form is the new happy path."""
16
+ op = UpdateAppend(path=["entityId", "buffer"], value="chunk")
17
+ dumped = op.model_dump()
18
+ assert dumped == {
19
+ "type": "append",
20
+ "path": ["entityId", "buffer"],
21
+ "value": "chunk",
22
+ }
23
+ parsed = UpdateAppend.model_validate(json.loads(json.dumps(dumped)))
24
+ assert parsed.path == ["entityId", "buffer"]
25
+
26
+
27
+ def test_update_append_without_path_round_trips() -> None:
28
+ """Root append omits ``path`` from the wire (parity with the Rust
29
+ ``skip_serializing_if = "Option::is_none"`` on
30
+ ``UpdateOp::Append.path`` — surfaced in #1612 review)."""
31
+ op = UpdateAppend(value="first")
32
+ dumped = op.model_dump()
33
+ assert dumped == {"type": "append", "value": "first"}
34
+ assert "path" not in dumped
35
+ parsed = UpdateAppend.model_validate(json.loads(json.dumps(dumped)))
36
+ assert parsed.path is None
37
+
38
+
39
+ def test_update_append_with_explicit_none_path() -> None:
40
+ """Explicit ``None`` and missing field both round-trip through a
41
+ wire payload that has no ``path`` key. ``path: null`` payloads
42
+ coming from older clients still deserialize for backward compat."""
43
+ op = UpdateAppend(path=None, value=42)
44
+ assert op.path is None
45
+ assert op.model_dump() == {"type": "append", "value": 42}
46
+ parsed = UpdateAppend.model_validate({"type": "append", "value": 42})
47
+ assert parsed.path is None
48
+ parsed_null = UpdateAppend.model_validate(
49
+ {"type": "append", "path": None, "value": 42}
50
+ )
51
+ assert parsed_null.path is None
52
+
53
+
54
+ def test_update_append_with_empty_string_path() -> None:
55
+ """Empty string is preserved as `Single("")` — engine maps it to root append."""
56
+ op = UpdateAppend(path="", value="x")
57
+ dumped = op.model_dump()
58
+ assert dumped["path"] == ""
59
+
60
+
61
+ def test_update_append_with_dotted_string_keeps_literal_segment() -> None:
62
+ """`"a.b"` is a single literal key, never traversed as `a -> b`."""
63
+ op = UpdateAppend(path="entityId.buffer", value="literal")
64
+ parsed = UpdateAppend.model_validate(json.loads(json.dumps(op.model_dump())))
65
+ assert parsed.path == "entityId.buffer"
66
+
67
+
68
+ def test_update_merge_with_string_path_round_trips() -> None:
69
+ op = UpdateMerge(path="session-abc", value={"author": "alice"})
70
+ dumped = op.model_dump()
71
+ assert dumped == {
72
+ "type": "merge",
73
+ "path": "session-abc",
74
+ "value": {"author": "alice"},
75
+ }
76
+ # JSON round-trip preserves the string form.
77
+ parsed = UpdateMerge.model_validate(json.loads(json.dumps(dumped)))
78
+ assert parsed.path == "session-abc"
79
+
80
+
81
+ def test_update_merge_with_array_path_round_trips() -> None:
82
+ op = UpdateMerge(path=["sessions", "abc"], value={"ts": "chunk"})
83
+ dumped = op.model_dump()
84
+ assert dumped == {
85
+ "type": "merge",
86
+ "path": ["sessions", "abc"],
87
+ "value": {"ts": "chunk"},
88
+ }
89
+ parsed = UpdateMerge.model_validate(json.loads(json.dumps(dumped)))
90
+ assert parsed.path == ["sessions", "abc"]
91
+
92
+
93
+ def test_update_merge_without_path_round_trips() -> None:
94
+ """Same wire-format rule as ``UpdateAppend``: root merge omits the
95
+ ``path`` key entirely. ``path: null`` payloads still deserialize."""
96
+ op = UpdateMerge(value={"x": 1})
97
+ dumped = op.model_dump()
98
+ assert dumped == {"type": "merge", "value": {"x": 1}}
99
+ assert "path" not in dumped
100
+ parsed = UpdateMerge.model_validate(json.loads(json.dumps(dumped)))
101
+ assert parsed.path is None
102
+ parsed_null = UpdateMerge.model_validate(
103
+ {"type": "merge", "path": None, "value": {"x": 1}}
104
+ )
105
+ assert parsed_null.path is None
106
+
107
+
108
+ def test_update_op_error_round_trip() -> None:
109
+ err = UpdateOpError(
110
+ op_index=0,
111
+ code="merge.path.too_deep",
112
+ message="Path depth 33 exceeds maximum of 32",
113
+ doc_url="https://iii.dev/docs/workers/iii-state#merge-bounds",
114
+ )
115
+ dumped = err.model_dump()
116
+ assert dumped["code"] == "merge.path.too_deep"
117
+ assert dumped["op_index"] == 0
118
+
119
+
120
+ def test_stream_update_result_with_errors_round_trips() -> None:
121
+ result = StreamUpdateResult[dict](
122
+ old_value=None,
123
+ new_value={"a": 1},
124
+ errors=[
125
+ UpdateOpError(
126
+ op_index=0,
127
+ code="merge.path.proto_polluted",
128
+ message='Path segment "__proto__" is a prototype-pollution sink',
129
+ )
130
+ ],
131
+ )
132
+ dumped = result.model_dump()
133
+ assert len(dumped["errors"]) == 1
134
+ assert dumped["errors"][0]["code"] == "merge.path.proto_polluted"
@@ -1,73 +0,0 @@
1
- """Unit tests for stream model serialization."""
2
-
3
- import json
4
-
5
- from iii.stream import StreamUpdateResult, UpdateAppend, UpdateMerge, UpdateOpError
6
-
7
-
8
- def test_update_append_model_serializes() -> None:
9
- op = UpdateAppend(path="chunks", value={"text": "hello"})
10
-
11
- assert op.model_dump() == {"type": "append", "path": "chunks", "value": {"text": "hello"}}
12
-
13
-
14
- def test_update_merge_with_string_path_round_trips() -> None:
15
- op = UpdateMerge(path="session-abc", value={"author": "alice"})
16
- dumped = op.model_dump()
17
- assert dumped == {
18
- "type": "merge",
19
- "path": "session-abc",
20
- "value": {"author": "alice"},
21
- }
22
- # JSON round-trip preserves the string form.
23
- parsed = UpdateMerge.model_validate(json.loads(json.dumps(dumped)))
24
- assert parsed.path == "session-abc"
25
-
26
-
27
- def test_update_merge_with_array_path_round_trips() -> None:
28
- op = UpdateMerge(path=["sessions", "abc"], value={"ts": "chunk"})
29
- dumped = op.model_dump()
30
- assert dumped == {
31
- "type": "merge",
32
- "path": ["sessions", "abc"],
33
- "value": {"ts": "chunk"},
34
- }
35
- parsed = UpdateMerge.model_validate(json.loads(json.dumps(dumped)))
36
- assert parsed.path == ["sessions", "abc"]
37
-
38
-
39
- def test_update_merge_without_path_round_trips() -> None:
40
- op = UpdateMerge(value={"x": 1})
41
- dumped = op.model_dump()
42
- assert dumped == {"type": "merge", "path": None, "value": {"x": 1}}
43
- parsed = UpdateMerge.model_validate(json.loads(json.dumps(dumped)))
44
- assert parsed.path is None
45
-
46
-
47
- def test_update_op_error_round_trip() -> None:
48
- err = UpdateOpError(
49
- op_index=0,
50
- code="merge.path.too_deep",
51
- message="Path depth 33 exceeds maximum of 32",
52
- doc_url="https://iii.dev/docs/workers/iii-state#merge-bounds",
53
- )
54
- dumped = err.model_dump()
55
- assert dumped["code"] == "merge.path.too_deep"
56
- assert dumped["op_index"] == 0
57
-
58
-
59
- def test_stream_update_result_with_errors_round_trips() -> None:
60
- result = StreamUpdateResult[dict](
61
- old_value=None,
62
- new_value={"a": 1},
63
- errors=[
64
- UpdateOpError(
65
- op_index=0,
66
- code="merge.path.proto_polluted",
67
- message='Path segment "__proto__" is a prototype-pollution sink',
68
- )
69
- ],
70
- )
71
- dumped = result.model_dump()
72
- assert len(dumped["errors"]) == 1
73
- assert dumped["errors"][0]["code"] == "merge.path.proto_polluted"
File without changes
File without changes
File without changes