dex-python-sdk 1.28.1__tar.gz → 1.30.0__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 (72) hide show
  1. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/PKG-INFO +36 -7
  2. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/README.md +35 -6
  3. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/dex/__init__.py +4 -0
  4. dex_python_sdk-1.30.0/dex/_attribute_reads.py +200 -0
  5. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/dex/async_client.py +96 -0
  6. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/dex/attribute.py +71 -0
  7. dex_python_sdk-1.30.0/dex/attribute_values.py +171 -0
  8. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/dex/client.py +96 -0
  9. dex_python_sdk-1.30.0/dex/dexpb/dex_pb2.py +419 -0
  10. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/dex/dexpb/dex_pb2.pyi +24 -4
  11. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/dex/flow.py +30 -0
  12. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/dex/runtime_errors.py +7 -1
  13. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/pyproject.toml +1 -1
  14. dex_python_sdk-1.28.1/dex/dexpb/dex_pb2.py +0 -417
  15. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/LEGACY_NOTICES.md +0 -0
  16. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/LICENSE +0 -0
  17. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/dex/_async_value_hydrator.py +0 -0
  18. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/dex/_async_worker_dispatcher.py +0 -0
  19. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/dex/_credential_transport.py +0 -0
  20. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/dex/_grpc_errors.py +0 -0
  21. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/dex/_invocation_context.py +0 -0
  22. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/dex/_native.pyi +0 -0
  23. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/dex/_pull_worker.py +0 -0
  24. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/dex/_server_protocol.py +0 -0
  25. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/dex/_utils.py +0 -0
  26. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/dex/_value_hydrator.py +0 -0
  27. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/dex/_value_mapper.py +0 -0
  28. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/dex/_worker_dispatcher.py +0 -0
  29. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/dex/async_worker.py +0 -0
  30. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/dex/attribute_match.py +0 -0
  31. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/dex/blob_cache.py +0 -0
  32. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/dex/channel.py +0 -0
  33. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/dex/client_options.py +0 -0
  34. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/dex/codec.py +0 -0
  35. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/dex/condition.py +0 -0
  36. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/dex/context.py +0 -0
  37. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/dex/credential.py +0 -0
  38. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/dex/dexpb/__init__.py +0 -0
  39. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/dex/dexpb/dex_pb2_grpc.py +0 -0
  40. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/dex/flow_config.py +0 -0
  41. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/dex/flow_info.py +0 -0
  42. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/dex/flow_options.py +0 -0
  43. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/dex/flow_result.py +0 -0
  44. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/dex/pull_registration.py +0 -0
  45. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/dex/py.typed +0 -0
  46. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/dex/rpc_invoke_options.py +0 -0
  47. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/dex/step.py +0 -0
  48. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/dex/step_execution.py +0 -0
  49. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/dex/stream.py +0 -0
  50. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/dex/subflow.py +0 -0
  51. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/dex/timer.py +0 -0
  52. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/dex/wait.py +0 -0
  53. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/dex/wait_options.py +0 -0
  54. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/dex/worker.py +0 -0
  55. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/dex/worker_options.py +0 -0
  56. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/sdk-rust/Cargo.lock +0 -0
  57. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/sdk-rust/Cargo.toml +0 -0
  58. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/sdk-rust/crates/dex-blob-cache/Cargo.toml +0 -0
  59. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/sdk-rust/crates/dex-blob-cache/LICENSE +0 -0
  60. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/sdk-rust/crates/dex-blob-cache/README.md +0 -0
  61. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/sdk-rust/crates/dex-blob-cache/src/config.rs +0 -0
  62. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/sdk-rust/crates/dex-blob-cache/src/entry.rs +0 -0
  63. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/sdk-rust/crates/dex-blob-cache/src/error.rs +0 -0
  64. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/sdk-rust/crates/dex-blob-cache/src/format.rs +0 -0
  65. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/sdk-rust/crates/dex-blob-cache/src/lib.rs +0 -0
  66. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/sdk-rust/crates/dex-blob-cache/src/policy.rs +0 -0
  67. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/sdk-rust/crates/dex-blob-cache/src/store.rs +0 -0
  68. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/sdk-rust/crates/dex-blob-cache/tests/blob_cache_integration.rs +0 -0
  69. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/sdk-rust/crates/dex-blob-cache-python/Cargo.toml +0 -0
  70. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/sdk-rust/crates/dex-blob-cache-python/LICENSE +0 -0
  71. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/sdk-rust/crates/dex-blob-cache-python/README.md +0 -0
  72. {dex_python_sdk-1.28.1 → dex_python_sdk-1.30.0}/sdk-rust/crates/dex-blob-cache-python/src/lib.rs +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dex-python-sdk
3
- Version: 1.28.1
3
+ Version: 1.30.0
4
4
  Requires-Dist: grpcio>=1.83.0
5
5
  Requires-Dist: grpcio-status>=1.83.0
6
6
  Requires-Dist: protobuf>=7.35.1
@@ -244,10 +244,38 @@ to assess reserve pressure. It never resets and remains available after shutdown
244
244
 
245
245
  ## Flow state I/O
246
246
 
247
- Applications read and write Flow state through typed RPCs. Client and AsyncClient
248
- do not expose direct Attribute reads or writes, Channel publication, or
249
- pending-message mutation. This keeps each external state transition behind a
250
- Flow-owned method.
247
+ Applications write Flow state through typed RPCs. Client and AsyncClient do not
248
+ expose direct Attribute writes, Channel publication, or pending-message
249
+ mutation. This keeps each external state transition behind a Flow-owned method.
250
+
251
+ Read committed Attribute values without an RPC through `get_attributes`. Pass
252
+ one or more singleton Attributes or AttributeMap instances selected with
253
+ `AttributeMap.instance(key)`:
254
+
255
+ ```python
256
+ values = client.get_attributes(flow_id, ORDER_STATUS, NOTES.instance("n1"))
257
+ status = values.get(ORDER_STATUS, "new")
258
+ if NOTES.instance("n1") in values:
259
+ note = values[NOTES.instance("n1")]
260
+ ```
261
+
262
+ `AsyncClient.get_attributes` is the awaitable form. There is no single-Attribute
263
+ method; pass one read to read one value. The Client sends every key in one
264
+ request and no Worker runs, so all values come from the same committed state.
265
+ The call reads the current run, or the latest run when no run is open, and a
266
+ closed run stays readable until Dex purges it. Unlike a read inside an RPC
267
+ handler, it never sees writes that a running Step or RPC has not committed.
268
+
269
+ The result `AttributeValues` is keyed by the reads passed to the call, and type
270
+ checkers infer each value type from its key. An unset Attribute or map instance
271
+ is absent: `values[key]` raises `KeyError`, `values.get(key)` returns `None`,
272
+ `values.get(key, default)` returns `default`, and `key in values` is `False`. A
273
+ stored value that decodes to `None` is still present. Each read must equal a
274
+ definition declared by some Flow in the Client's Registry, with the same name,
275
+ value type, index, `sync_to_attribute_store`, and `hard_delete_on_null`;
276
+ otherwise the call raises `FlowDefinitionError` before contacting Dex. Two
277
+ reads of the same key, or no read at all, raise `ValueError`. Large values are
278
+ resolved through LoadBlobs and the BlobCache before decoding.
251
279
 
252
280
  RPC handlers can stage `channel.delete(context, message_id)`. Declare the RPC as
253
281
  `@rpc(is_transactional=True)` when a missing message must abort its other writes.
@@ -439,7 +467,8 @@ the Attribute or setting it to `None` permanently deletes the stored value, and
439
467
  deleted value reads as absent. Only string and object values, including
440
468
  `bytes`, are supported; `int`, `bool`, and `float` types are rejected. The
441
469
  option cannot be combined with an index or `sync_to_attribute_store`, and
442
- `wait_for_attribute_match` rejects it. Handlers receive the value inline, and
470
+ `wait_for_attribute_match` rejects it. `get_attributes` reads it; Dex resolves
471
+ the stored value. Handlers and `get_attributes` receive the value inline, and
443
472
  the SDK never stores it in the local blob cache.
444
473
 
445
474
  Hard-delete writes require a Server with an enabled blob store and Server
@@ -796,7 +825,7 @@ SubFlow.
796
825
  ### Errors
797
826
 
798
827
  Client calls raise concrete `DexServiceError` subclasses. Existing-Flow reads
799
- (`describe_flow`, `wait_for_flow`, and `time_travel`) raise
828
+ (`describe_flow`, `get_attributes`, `wait_for_flow`, and `time_travel`) raise
800
829
  `FlowNotFoundError` when the Flow does not exist. Mutations, RPCs, timer/Step
801
830
  waits, config updates, and state-transfer triggers raise
802
831
  `FlowNotActiveOrNotFoundError` when no running Flow can accept the operation.
@@ -228,10 +228,38 @@ to assess reserve pressure. It never resets and remains available after shutdown
228
228
 
229
229
  ## Flow state I/O
230
230
 
231
- Applications read and write Flow state through typed RPCs. Client and AsyncClient
232
- do not expose direct Attribute reads or writes, Channel publication, or
233
- pending-message mutation. This keeps each external state transition behind a
234
- Flow-owned method.
231
+ Applications write Flow state through typed RPCs. Client and AsyncClient do not
232
+ expose direct Attribute writes, Channel publication, or pending-message
233
+ mutation. This keeps each external state transition behind a Flow-owned method.
234
+
235
+ Read committed Attribute values without an RPC through `get_attributes`. Pass
236
+ one or more singleton Attributes or AttributeMap instances selected with
237
+ `AttributeMap.instance(key)`:
238
+
239
+ ```python
240
+ values = client.get_attributes(flow_id, ORDER_STATUS, NOTES.instance("n1"))
241
+ status = values.get(ORDER_STATUS, "new")
242
+ if NOTES.instance("n1") in values:
243
+ note = values[NOTES.instance("n1")]
244
+ ```
245
+
246
+ `AsyncClient.get_attributes` is the awaitable form. There is no single-Attribute
247
+ method; pass one read to read one value. The Client sends every key in one
248
+ request and no Worker runs, so all values come from the same committed state.
249
+ The call reads the current run, or the latest run when no run is open, and a
250
+ closed run stays readable until Dex purges it. Unlike a read inside an RPC
251
+ handler, it never sees writes that a running Step or RPC has not committed.
252
+
253
+ The result `AttributeValues` is keyed by the reads passed to the call, and type
254
+ checkers infer each value type from its key. An unset Attribute or map instance
255
+ is absent: `values[key]` raises `KeyError`, `values.get(key)` returns `None`,
256
+ `values.get(key, default)` returns `default`, and `key in values` is `False`. A
257
+ stored value that decodes to `None` is still present. Each read must equal a
258
+ definition declared by some Flow in the Client's Registry, with the same name,
259
+ value type, index, `sync_to_attribute_store`, and `hard_delete_on_null`;
260
+ otherwise the call raises `FlowDefinitionError` before contacting Dex. Two
261
+ reads of the same key, or no read at all, raise `ValueError`. Large values are
262
+ resolved through LoadBlobs and the BlobCache before decoding.
235
263
 
236
264
  RPC handlers can stage `channel.delete(context, message_id)`. Declare the RPC as
237
265
  `@rpc(is_transactional=True)` when a missing message must abort its other writes.
@@ -423,7 +451,8 @@ the Attribute or setting it to `None` permanently deletes the stored value, and
423
451
  deleted value reads as absent. Only string and object values, including
424
452
  `bytes`, are supported; `int`, `bool`, and `float` types are rejected. The
425
453
  option cannot be combined with an index or `sync_to_attribute_store`, and
426
- `wait_for_attribute_match` rejects it. Handlers receive the value inline, and
454
+ `wait_for_attribute_match` rejects it. `get_attributes` reads it; Dex resolves
455
+ the stored value. Handlers and `get_attributes` receive the value inline, and
427
456
  the SDK never stores it in the local blob cache.
428
457
 
429
458
  Hard-delete writes require a Server with an enabled blob store and Server
@@ -780,7 +809,7 @@ SubFlow.
780
809
  ### Errors
781
810
 
782
811
  Client calls raise concrete `DexServiceError` subclasses. Existing-Flow reads
783
- (`describe_flow`, `wait_for_flow`, and `time_travel`) raise
812
+ (`describe_flow`, `get_attributes`, `wait_for_flow`, and `time_travel`) raise
784
813
  `FlowNotFoundError` when the Flow does not exist. Mutations, RPCs, timer/Step
785
814
  waits, config updates, and state-transfer triggers raise
786
815
  `FlowNotActiveOrNotFoundError` when no running Flow can accept the operation.
@@ -16,10 +16,12 @@ from dex.attribute import (
16
16
  AttributeIndex,
17
17
  AttributeLock,
18
18
  AttributeMap,
19
+ AttributeMapInstance,
19
20
  AttributeMapLoad,
20
21
  IndexType,
21
22
  )
22
23
  from dex.attribute_match import AttributeMatch
24
+ from dex.attribute_values import AttributeValues
23
25
  from dex.blob_cache import BlobCache, BlobCacheConfig, open_blob_cache
24
26
  from dex.channel import Channel, ChannelMap, ChannelMapLoad, ChannelMessage
25
27
  from dex.client import Client
@@ -139,9 +141,11 @@ __all__ = [
139
141
  "AttributeIndex",
140
142
  "AttributeLock",
141
143
  "AttributeMap",
144
+ "AttributeMapInstance",
142
145
  "AttributeMapLoad",
143
146
  "AttributeMapNotLoadedError",
144
147
  "AttributeMatch",
148
+ "AttributeValues",
145
149
  "AsyncContext",
146
150
  "AsyncClient",
147
151
  "AsyncBufferedTextStream",
@@ -0,0 +1,200 @@
1
+ # Copyright (c) 2026 Super Durable, Inc.
2
+ #
3
+ # Licensed under the Sustainable Use License 1.0.
4
+ # You may not use this file except in compliance with the License.
5
+ # See the LICENSE file in the repository root.
6
+ #
7
+ # SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Sequence
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+
15
+ import grpc
16
+
17
+ from dex._grpc_errors import translate_rpc_error
18
+ from dex._value_mapper import ValueMapper
19
+ from dex.attribute import Attribute, AttributeMap, AttributeMapInstance
20
+ from dex.attribute_values import AttributeValues
21
+ from dex.codec import Codec
22
+ from dex.dexpb import dex_pb2 as pb
23
+ from dex.flow import Registry
24
+ from dex.runtime_errors import DexServiceError, FlowDefinitionError, ValueMappingError
25
+
26
+ _OPERATION = "get_attributes"
27
+
28
+ AttributeRead = Attribute[Any] | AttributeMapInstance[Any]
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class _PlannedAttributeRead:
33
+ read: AttributeRead
34
+ key: str
35
+ codec: Codec[Any]
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class AttributeReadPlan:
40
+ """Validated reads of one get_attributes call, shared by both Clients."""
41
+
42
+ flow_id: str
43
+ reads: tuple[_PlannedAttributeRead, ...]
44
+
45
+ @property
46
+ def keys(self) -> tuple[str, ...]:
47
+ return tuple(planned.key for planned in self.reads)
48
+
49
+ def request(self) -> pb.GetAttributesRequest:
50
+ return pb.GetAttributesRequest(flow_id=self.flow_id, keys=self.keys)
51
+
52
+ def service_error(
53
+ self, error: grpc.RpcError, service_method: str
54
+ ) -> DexServiceError:
55
+ translated = translate_rpc_error(error, _OPERATION, self.flow_id, "existing")
56
+ translated.args = (
57
+ f"{self._context()} Attributes {self._joined_keys()} {service_method} "
58
+ f"failed: {translated.detail}",
59
+ )
60
+ return translated
61
+
62
+ def hydration_error(self, error: Exception) -> ValueMappingError:
63
+ return ValueMappingError(
64
+ "decode",
65
+ f"{self._context()} Attributes {self._joined_keys()} returned a value "
66
+ f"that cannot be resolved: {error}",
67
+ )
68
+
69
+ def set_entries(self, response: pb.GetAttributesResponse) -> list[pb.KV]:
70
+ """Return the set entries after rejecting malformed responses.
71
+
72
+ Returns:
73
+ One entry per set read, in response order. An omitted key is unset.
74
+
75
+ Raises:
76
+ ValueError: If an entry has an unexpected or duplicate key, or no value.
77
+ """
78
+ requested = set(self.keys)
79
+ seen: set[str] = set()
80
+ entries: list[pb.KV] = []
81
+ for entry in response.attributes:
82
+ if entry.key not in requested:
83
+ raise ValueError(
84
+ f"{self._context()} returned unexpected Attribute {entry.key!r}"
85
+ )
86
+ if entry.key in seen:
87
+ raise ValueError(
88
+ f"{self._context()} returned duplicate Attribute {entry.key!r}"
89
+ )
90
+ if not entry.HasField("value"):
91
+ raise ValueError(
92
+ f"{self._context()} returned Attribute {entry.key!r} without a value"
93
+ )
94
+ seen.add(entry.key)
95
+ entries.append(entry)
96
+ return entries
97
+
98
+ def decode(
99
+ self,
100
+ values: ValueMapper,
101
+ entries: Sequence[pb.KV],
102
+ hydrated: Sequence[pb.Value],
103
+ ) -> AttributeValues:
104
+ """Decode every set value, or raise before returning any of them.
105
+
106
+ Returns:
107
+ The set values keyed by the read passed to the call.
108
+
109
+ Raises:
110
+ ValueMappingError: If a value cannot be decoded as its value type.
111
+ """
112
+ hydrated_by_key = {
113
+ entry.key: value for entry, value in zip(entries, hydrated, strict=True)
114
+ }
115
+ decoded: dict[AttributeRead, object] = {}
116
+ for planned in self.reads:
117
+ value = hydrated_by_key.get(planned.key)
118
+ if value is None:
119
+ continue
120
+ try:
121
+ decoded[planned.read] = values.decode(value, planned.codec)
122
+ except ValueMappingError as error:
123
+ raise ValueMappingError(
124
+ "decode",
125
+ f"{self._context()} Attribute {planned.key!r}: {error.detail}",
126
+ ) from error
127
+ return AttributeValues._of_set_values(decoded)
128
+
129
+ def _context(self) -> str:
130
+ return f"{_OPERATION} Flow {self.flow_id!r}"
131
+
132
+ def _joined_keys(self) -> str:
133
+ return ", ".join(repr(key) for key in self.keys)
134
+
135
+
136
+ def plan_attribute_reads(
137
+ registry: Registry,
138
+ values: ValueMapper,
139
+ flow_id: str,
140
+ reads: Sequence[object],
141
+ ) -> AttributeReadPlan:
142
+ """Validate the reads of one get_attributes call before any transport.
143
+
144
+ Returns:
145
+ The plan whose keys follow the order of ``reads``.
146
+
147
+ Raises:
148
+ ValueError: If the Flow ID is empty, no read is given, or a key repeats.
149
+ TypeError: If a read is not an Attribute or AttributeMapInstance.
150
+ FlowDefinitionError: If no registered Flow declares an equal definition.
151
+ """
152
+ if not isinstance(flow_id, str) or not flow_id.strip():
153
+ raise ValueError(f"{_OPERATION} requires a non-empty Flow ID, got {flow_id!r}")
154
+ if not reads:
155
+ raise ValueError(
156
+ f"{_OPERATION} Flow {flow_id!r} requires at least one Attribute read"
157
+ )
158
+ planned_reads: list[_PlannedAttributeRead] = []
159
+ seen_keys: set[str] = set()
160
+ for read in reads:
161
+ typed_read, definition, key = _definition_and_key(flow_id, read)
162
+ try:
163
+ registry._require_registered_attribute(definition)
164
+ except FlowDefinitionError as error:
165
+ raise FlowDefinitionError(
166
+ f"{_OPERATION} Flow {flow_id!r} Attribute {key!r}: {error}"
167
+ ) from error
168
+ if key in seen_keys:
169
+ raise ValueError(
170
+ f"{_OPERATION} Flow {flow_id!r} has duplicate Attribute read {key!r}"
171
+ )
172
+ seen_keys.add(key)
173
+ planned_reads.append(
174
+ _PlannedAttributeRead(
175
+ read=typed_read,
176
+ key=key,
177
+ codec=values.codec(definition.value_type),
178
+ )
179
+ )
180
+ return AttributeReadPlan(flow_id, tuple(planned_reads))
181
+
182
+
183
+ def _definition_and_key(
184
+ flow_id: str,
185
+ read: object,
186
+ ) -> tuple[AttributeRead, Attribute[Any] | AttributeMap[Any], str]:
187
+ if isinstance(read, Attribute):
188
+ return read, read, read.name
189
+ if isinstance(read, AttributeMapInstance):
190
+ key = Registry.physical_name(read.attribute_map.name, read.instance)
191
+ return read, read.attribute_map, key
192
+ if isinstance(read, AttributeMap):
193
+ raise TypeError(
194
+ f"{_OPERATION} Flow {flow_id!r} AttributeMap {read.name!r} needs an "
195
+ "instance; pass AttributeMap.instance(key)"
196
+ )
197
+ raise TypeError(
198
+ f"{_OPERATION} Flow {flow_id!r} read must be an Attribute or "
199
+ f"AttributeMapInstance, not {type(read).__name__}"
200
+ )
@@ -19,6 +19,7 @@ from uuid import uuid4
19
19
  import grpc
20
20
  from google.protobuf import empty_pb2
21
21
 
22
+ from dex._attribute_reads import plan_attribute_reads
22
23
  from dex._credential_transport import AsyncCredentialRunner, async_channel
23
24
  from dex._async_value_hydrator import AsyncValueHydrator
24
25
  from dex._grpc_errors import FlowTargetRequirement, translate_rpc_error
@@ -34,12 +35,14 @@ from dex._worker_dispatcher import WorkerDispatcher
34
35
  from dex.attribute import (
35
36
  Attribute,
36
37
  AttributeMap,
38
+ AttributeMapInstance,
37
39
  _apply_attribute_store_sync,
38
40
  _apply_hard_delete_on_null,
39
41
  _require_attribute_wait_supported,
40
42
  _require_matching_hard_delete_on_null,
41
43
  )
42
44
  from dex.attribute_match import AttributeMatch, _encode_attribute_match
45
+ from dex.attribute_values import AttributeValues
43
46
  from dex.blob_cache import BlobCache
44
47
  from dex.channel import Channel, ChannelMap
45
48
  from dex.client_options import ClientOptions
@@ -660,6 +663,99 @@ class AsyncClient:
660
663
  response.start_time.ToDatetime(tzinfo=timezone.utc),
661
664
  )
662
665
 
666
+ async def get_attributes(
667
+ self,
668
+ flow_id: str,
669
+ *reads: Attribute[Any] | AttributeMapInstance[Any],
670
+ ) -> AttributeValues:
671
+ """Read committed Attribute values from the current or latest Flow run.
672
+
673
+ Use ``get_attributes`` to read Flow state from outside the Flow without
674
+ defining an RPC. Pass one or more reads. A read is a singleton
675
+ ``Attribute`` or one AttributeMap instance selected with
676
+ ``AttributeMap.instance(key)``. To read one Attribute, pass a single read.
677
+ The Client sends every key in one FlowService request, so all values come
678
+ from the same committed state. No Worker runs.
679
+
680
+ The call targets the current run, or the latest run when no run is open. A
681
+ closed run stays readable until Dex purges it. The call sees only committed
682
+ state. Unlike reading inside an RPC handler, it never sees writes that a
683
+ running Step or RPC has staged but not yet committed.
684
+
685
+ Each read must use a definition that some Flow in this Client's Registry
686
+ declares in its ``PersistenceSchema``. The definition matches when it is
687
+ equal to the registered one: same class (``Attribute`` or
688
+ ``AttributeMap``), name, ``value_type``, ``index``,
689
+ ``sync_to_attribute_store``, and ``hard_delete_on_null``. The definition
690
+ may come from any registered Flow. The server does not check that the
691
+ target Flow's type declares it, so a same-named Attribute of another type
692
+ in the target Flow fails to decode.
693
+
694
+ Attributes defined with ``hard_delete_on_null=True`` are readable; the
695
+ server resolves their stored values. The Client resolves other large
696
+ values through LoadBlobs and the Client's BlobCache, and then decodes every
697
+ value. On any error the call returns nothing, so no partial result is
698
+ visible. Every error message names the Flow ID. An error about one read or
699
+ value also names its Attribute name or physical key.
700
+
701
+ Example::
702
+
703
+ ORDER_STATUS = Attribute("order-status", str)
704
+ NOTES = AttributeMap("notes", Note)
705
+
706
+ try:
707
+ values = await client.get_attributes(
708
+ "order-42", ORDER_STATUS, NOTES.instance("n1")
709
+ )
710
+ except FlowNotFoundError:
711
+ return None
712
+ status = values.get(ORDER_STATUS, "new")
713
+ if NOTES.instance("n1") in values:
714
+ note = values[NOTES.instance("n1")]
715
+
716
+ Args:
717
+ flow_id: The non-empty Flow ID.
718
+ *reads: At least one ``Attribute`` or ``AttributeMapInstance``. Two
719
+ reads of the same Attribute or AttributeMap instance are rejected.
720
+
721
+ Returns:
722
+ The set values, keyed by the reads passed to this call. An Attribute or
723
+ AttributeMap instance without a stored value is absent from the result.
724
+
725
+ Raises:
726
+ ValueError: If ``flow_id`` is empty, no read is given, two reads select
727
+ the same Attribute or AttributeMap instance, or the response has an
728
+ unexpected key, a duplicate key, or an entry without a value.
729
+ TypeError: If a read is not an ``Attribute`` or
730
+ ``AttributeMapInstance``, such as an ``AttributeMap`` without an
731
+ instance.
732
+ FlowDefinitionError: If no Flow in the Registry declares a matching
733
+ definition, including a same-named definition with another value
734
+ type.
735
+ FlowNotFoundError: If the Flow does not exist or its latest run is
736
+ being purged.
737
+ ValueMappingError: If a returned value cannot be resolved or decoded
738
+ as its value type. This includes an empty or non-finite value, an
739
+ unsupported object encoding, and a large value that LoadBlobs omits.
740
+ DexServiceError: If FlowService or LoadBlobs otherwise fails. A LoadBlobs
741
+ failure for a missing Flow raises ``FlowNotFoundError``.
742
+ """
743
+ plan = plan_attribute_reads(self.registry, self._values, flow_id, reads)
744
+ try:
745
+ response = await self._service.GetAttributes(plan.request())
746
+ except grpc.RpcError as error:
747
+ raise plan.service_error(error, "GetAttributes") from error
748
+ entries = plan.set_entries(response)
749
+ try:
750
+ hydrated = await self._hydrator.hydrate_all(
751
+ flow_id, [entry.value for entry in entries]
752
+ )
753
+ except grpc.RpcError as error:
754
+ raise plan.service_error(error, "LoadBlobs") from error
755
+ except (ValueError, RuntimeError) as error:
756
+ raise plan.hydration_error(error) from error
757
+ return plan.decode(self._values, entries, hydrated)
758
+
663
759
  async def search_flows(
664
760
  self,
665
761
  query: str,
@@ -378,6 +378,37 @@ class AttributeMap(Generic[ValueT]):
378
378
  """
379
379
  return AttributeMapLoad(self, require_map_instance(instance))
380
380
 
381
+ def instance(self, instance: str) -> AttributeMapInstance[ValueT]:
382
+ """Select one typed instance for a Client Attribute read.
383
+
384
+ Pass the result to ``Client.get_attributes`` or
385
+ ``AsyncClient.get_attributes``, and use the same selector, or an equal
386
+ one, to look up the value in the returned ``AttributeValues``. The
387
+ selector keeps the AttributeMap value type, so type checkers infer the
388
+ value type of the lookup.
389
+
390
+ This differs from :meth:`load`. ``load`` selects an instance that an RPC
391
+ handler receives in its snapshot. ``instance`` selects an instance that
392
+ a Client reads from outside the Flow.
393
+
394
+ Example::
395
+
396
+ notes = AttributeMap("notes", Note)
397
+ values = client.get_attributes("order-42", notes.instance("n1"))
398
+ note = values.get(notes.instance("n1"))
399
+
400
+ Args:
401
+ instance: The non-empty, slash-free logical map key to read.
402
+
403
+ Returns:
404
+ A typed selector for this one instance.
405
+
406
+ Raises:
407
+ TypeError: If ``instance`` is not a string.
408
+ ValueError: If ``instance`` is empty or contains ``/``.
409
+ """
410
+ return AttributeMapInstance(self, instance)
411
+
381
412
  def get(self, context: Context, instance: str) -> ValueT:
382
413
  """Return one map instance from a Step or RPC Context.
383
414
 
@@ -451,6 +482,46 @@ class AttributeMap(Generic[ValueT]):
451
482
  return AttributeLock(self, instance)
452
483
 
453
484
 
485
+ @dataclass(frozen=True)
486
+ class AttributeMapInstance(Generic[ValueT]):
487
+ """Select one typed AttributeMap instance for a Client Attribute read.
488
+
489
+ Create a selector with :meth:`AttributeMap.instance`. Pass it to
490
+ ``Client.get_attributes`` or ``AsyncClient.get_attributes`` together with
491
+ singleton Attributes. Then use it as the key of the returned
492
+ ``AttributeValues``. Selectors are immutable and hashable. Two selectors are
493
+ equal when their AttributeMap definitions are equal and their instance keys
494
+ are the same string.
495
+
496
+ Example::
497
+
498
+ notes = AttributeMap("notes", Note)
499
+ first_note = notes.instance("n1")
500
+ values = client.get_attributes("order-42", first_note)
501
+ if first_note in values:
502
+ print(values[first_note].text)
503
+
504
+ Attributes:
505
+ attribute_map: The AttributeMap definition that owns the instance.
506
+ instance: The non-empty logical map key. It must not contain ``/``.
507
+
508
+ Raises:
509
+ TypeError: If ``attribute_map`` is not an AttributeMap, or ``instance`` is
510
+ not a string.
511
+ ValueError: If ``instance`` is empty or contains ``/``.
512
+ """
513
+
514
+ attribute_map: AttributeMap[ValueT]
515
+ instance: str
516
+
517
+ def __post_init__(self) -> None:
518
+ if not isinstance(self.attribute_map, AttributeMap):
519
+ raise TypeError("AttributeMapInstance requires an AttributeMap definition")
520
+ if not isinstance(self.instance, str):
521
+ raise TypeError("attribute-map instance must be a string")
522
+ require_map_instance(self.instance)
523
+
524
+
454
525
  @dataclass(frozen=True)
455
526
  class AttributeLock:
456
527
  """Describe an Attribute lock acquired for a Step or RPC handler.