dex-python-sdk 0.0.1__py3-none-any.whl
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.
- dex/__init__.py +14 -0
- dex/client.py +568 -0
- dex/client_options.py +34 -0
- dex/command_request.py +130 -0
- dex/command_results.py +116 -0
- dex/communication.py +141 -0
- dex/communication_schema.py +68 -0
- dex/data_attributes.py +75 -0
- dex/dexpb/__init__.py +1 -0
- dex/dexpb/dex_pb2.py +305 -0
- dex/dexpb/dex_pb2.pyi +1273 -0
- dex/dexpb/dex_pb2_grpc.py +1126 -0
- dex/errors.py +132 -0
- dex/object_encoder.py +818 -0
- dex/persistence.py +94 -0
- dex/persistence_options.py +24 -0
- dex/persistence_schema.py +58 -0
- dex/py.typed +1 -0
- dex/registry.py +209 -0
- dex/reset_workflow_type_and_options.py +77 -0
- dex/rpc.py +100 -0
- dex/search_attributes.py +189 -0
- dex/state_decision.py +161 -0
- dex/state_execution_locals.py +71 -0
- dex/state_movement.py +123 -0
- dex/state_schema.py +54 -0
- dex/stop_workflow_options.py +23 -0
- dex/tests/__init__.py +84 -0
- dex/tests/dex-service-env/.env +7 -0
- dex/tests/dex-service-env/docker-compose-init.sh +44 -0
- dex/tests/dex-service-env/docker-compose.yml +87 -0
- dex/tests/dex-service-env/dynamicconfig/README.md +39 -0
- dex/tests/dex-service-env/dynamicconfig/development-sql.yaml +6 -0
- dex/tests/dex-service-env/dynamicconfig/docker.yaml +0 -0
- dex/tests/test_abnormal_exit_workflow.py +48 -0
- dex/tests/test_basic_workflow.py +75 -0
- dex/tests/test_conditional_complete.py +56 -0
- dex/tests/test_describe_workflow.py +45 -0
- dex/tests/test_empty_data_decodes_properly.py +79 -0
- dex/tests/test_internal_channel.py +33 -0
- dex/tests/test_internal_channel_with_no_prefix_channel.py +46 -0
- dex/tests/test_persistence_data_attributes.py +67 -0
- dex/tests/test_persistence_search_attributes.py +132 -0
- dex/tests/test_persistence_state_execution_locals.py +43 -0
- dex/tests/test_rpc.py +69 -0
- dex/tests/test_rpc_with_memo.py +200 -0
- dex/tests/test_rpc_with_memo_duplicate_java_tests.py +122 -0
- dex/tests/test_signal.py +56 -0
- dex/tests/test_skip_wait_until.py +85 -0
- dex/tests/test_state_failure_recovery.py +33 -0
- dex/tests/test_timer.py +40 -0
- dex/tests/test_wait_for_state_execution_completion.py +58 -0
- dex/tests/test_workflow_errors.py +92 -0
- dex/tests/test_workflow_state_options.py +123 -0
- dex/tests/test_workflow_state_options_override.py +49 -0
- dex/tests/worker_server.py +74 -0
- dex/tests/workflows/abnormal_exit_workflow.py +48 -0
- dex/tests/workflows/basic_workflow.py +69 -0
- dex/tests/workflows/conditional_complete_workflow.py +101 -0
- dex/tests/workflows/describe_workflow.py +52 -0
- dex/tests/workflows/empty_data_workflow.py +51 -0
- dex/tests/workflows/internal_channel_workflow.py +137 -0
- dex/tests/workflows/internal_channel_workflow_with_no_prefix_channel.py +108 -0
- dex/tests/workflows/java_duplicate_rpc_memo_workflow.py +285 -0
- dex/tests/workflows/persistence_data_attributes_workflow.py +104 -0
- dex/tests/workflows/persistence_search_attributes_workflow.py +167 -0
- dex/tests/workflows/persistence_state_execution_local_workflow.py +69 -0
- dex/tests/workflows/recovery_workflow.py +90 -0
- dex/tests/workflows/rpc_memo_workflow.py +238 -0
- dex/tests/workflows/rpc_workflow.py +125 -0
- dex/tests/workflows/state_options_override_workflow.py +100 -0
- dex/tests/workflows/state_options_workflow.py +92 -0
- dex/tests/workflows/timer_workflow.py +52 -0
- dex/tests/workflows/wait_for_state_with_state_execution_id_workflow.py +77 -0
- dex/tests/workflows/wait_for_state_with_wait_for_key_workflow.py +78 -0
- dex/tests/workflows/wait_internal_channel_workflow.py +53 -0
- dex/tests/workflows/wait_signal_workflow.py +154 -0
- dex/type_store.py +105 -0
- dex/unregistered_client.py +595 -0
- dex/utils/__init__.py +14 -0
- dex/utils/dex_typing.py +31 -0
- dex/utils/persistence_utils.py +37 -0
- dex/worker_service.py +441 -0
- dex/workflow.py +86 -0
- dex/workflow_context.py +50 -0
- dex/workflow_info.py +21 -0
- dex/workflow_options.py +79 -0
- dex/workflow_state.py +134 -0
- dex/workflow_state_options.py +160 -0
- dex_python_sdk-0.0.1.dist-info/METADATA +140 -0
- dex_python_sdk-0.0.1.dist-info/RECORD +93 -0
- dex_python_sdk-0.0.1.dist-info/WHEEL +4 -0
- dex_python_sdk-0.0.1.dist-info/licenses/LICENSE +201 -0
dex/object_encoder.py
ADDED
|
@@ -0,0 +1,818 @@
|
|
|
1
|
+
# Copyright (c) 2022-2026 Super Durable, Inc.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Base converter and implementations for data conversion.
|
|
16
|
+
Adapted from https://github.com/temporalio/sdk-python/blob/main/temporalio/converter.py
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import collections
|
|
22
|
+
import collections.abc
|
|
23
|
+
import dataclasses
|
|
24
|
+
import inspect
|
|
25
|
+
import json
|
|
26
|
+
import sys
|
|
27
|
+
import uuid
|
|
28
|
+
import warnings
|
|
29
|
+
from abc import ABC, abstractmethod
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from enum import IntEnum
|
|
32
|
+
from typing import (
|
|
33
|
+
Any,
|
|
34
|
+
ClassVar,
|
|
35
|
+
Dict,
|
|
36
|
+
Mapping,
|
|
37
|
+
NewType,
|
|
38
|
+
Optional,
|
|
39
|
+
Sequence,
|
|
40
|
+
Tuple,
|
|
41
|
+
Type,
|
|
42
|
+
TypeVar,
|
|
43
|
+
Union,
|
|
44
|
+
get_type_hints,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
from typing_extensions import Literal
|
|
48
|
+
|
|
49
|
+
from dex.dex_api.models import EncodedObject
|
|
50
|
+
from dex.dex_api.types import UNSET, Unset
|
|
51
|
+
|
|
52
|
+
# StrEnum is available in 3.11+
|
|
53
|
+
if sys.version_info >= (3, 11):
|
|
54
|
+
from enum import StrEnum
|
|
55
|
+
|
|
56
|
+
if sys.version_info >= (3, 10):
|
|
57
|
+
from types import UnionType
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class PayloadConverter(ABC):
|
|
61
|
+
"""Base payload converter to/from payload/value."""
|
|
62
|
+
|
|
63
|
+
@abstractmethod
|
|
64
|
+
def to_payload(
|
|
65
|
+
self,
|
|
66
|
+
value: Any,
|
|
67
|
+
) -> Union[EncodedObject, Unset]:
|
|
68
|
+
"""Encode values into payloads.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
value: value to be converted
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
A boolean to indicate if the payload was converted and the converted value
|
|
75
|
+
or Unset
|
|
76
|
+
|
|
77
|
+
Raises:
|
|
78
|
+
Exception: Any issue during conversion.
|
|
79
|
+
"""
|
|
80
|
+
raise NotImplementedError
|
|
81
|
+
|
|
82
|
+
@abstractmethod
|
|
83
|
+
def from_payload(
|
|
84
|
+
self,
|
|
85
|
+
payload: EncodedObject,
|
|
86
|
+
type_hint: Optional[Type] = None,
|
|
87
|
+
) -> Any:
|
|
88
|
+
"""Decode payloads into values.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
payload: Payload to convert to Python values.
|
|
92
|
+
type_hint: Type that are expected if any.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
payload value
|
|
96
|
+
|
|
97
|
+
Raises:
|
|
98
|
+
Exception: Any issue during conversion.
|
|
99
|
+
"""
|
|
100
|
+
raise NotImplementedError
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class EncodingPayloadConverter(ABC):
|
|
104
|
+
"""Base converter for a **known encoding** for use in CompositePayloadConverter."""
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
@abstractmethod
|
|
108
|
+
def encoding(self) -> Union[str, Unset]:
|
|
109
|
+
"""Encoding for the payload this converter works with."""
|
|
110
|
+
raise NotImplementedError
|
|
111
|
+
|
|
112
|
+
@abstractmethod
|
|
113
|
+
def to_payload(self, value: Any) -> tuple[bool, Union[EncodedObject, Unset]]:
|
|
114
|
+
"""Encode a single value to a payload or None.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
value: Value to be converted.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
A boolean to indicate if the payload was converted and the converted value
|
|
121
|
+
or Unset
|
|
122
|
+
|
|
123
|
+
Raises:
|
|
124
|
+
TypeError: Value is not the expected type.
|
|
125
|
+
ValueError: Value is of the expected type but otherwise incorrect.
|
|
126
|
+
RuntimeError: General error during encoding.
|
|
127
|
+
"""
|
|
128
|
+
raise NotImplementedError
|
|
129
|
+
|
|
130
|
+
@abstractmethod
|
|
131
|
+
def from_payload(
|
|
132
|
+
self,
|
|
133
|
+
payload: EncodedObject,
|
|
134
|
+
type_hint: Optional[Type] = None,
|
|
135
|
+
) -> Any:
|
|
136
|
+
"""Decode a single payload to a Python value or raise exception.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
payload: Payload to convert to Python value.
|
|
140
|
+
type_hint: Type that is expected if any. This may not have a type if
|
|
141
|
+
there are no annotations on the target.
|
|
142
|
+
|
|
143
|
+
Return:
|
|
144
|
+
The decoded value from the payload. Since the encoding is checked by
|
|
145
|
+
the caller, this should raise an exception if the payload cannot be
|
|
146
|
+
converted.
|
|
147
|
+
|
|
148
|
+
Raises:
|
|
149
|
+
RuntimeError: General error during decoding.
|
|
150
|
+
"""
|
|
151
|
+
raise NotImplementedError
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class CompositePayloadConverter(PayloadConverter):
|
|
155
|
+
"""Composite payload converter that delegates to a list of encoding payload converters.
|
|
156
|
+
|
|
157
|
+
Encoding/decoding are attempted on each payload converter successively until
|
|
158
|
+
it succeeds.
|
|
159
|
+
|
|
160
|
+
Attributes:
|
|
161
|
+
converters: List of payload converters to delegate to, in order.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
converters: Mapping[Union[str, Unset], EncodingPayloadConverter]
|
|
165
|
+
|
|
166
|
+
def __init__(self, *converters: EncodingPayloadConverter) -> None:
|
|
167
|
+
"""Initializes the data converter.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
converters: Payload converters to delegate to, in order.
|
|
171
|
+
"""
|
|
172
|
+
# Insertion order preserved here since Python 3.7
|
|
173
|
+
self.converters = {c.encoding: c for c in converters}
|
|
174
|
+
|
|
175
|
+
def to_payload(
|
|
176
|
+
self,
|
|
177
|
+
value: Any,
|
|
178
|
+
) -> Union[EncodedObject, Unset]:
|
|
179
|
+
"""Encode values trying each converter.
|
|
180
|
+
|
|
181
|
+
See base class. Always returns the same number of payloads as values.
|
|
182
|
+
|
|
183
|
+
Raises:
|
|
184
|
+
RuntimeError: No known converter
|
|
185
|
+
"""
|
|
186
|
+
# We intentionally attempt these serially just in case a stateful
|
|
187
|
+
# converter may rely on the previous values
|
|
188
|
+
payload: Union[EncodedObject, Unset] = Unset()
|
|
189
|
+
is_encoded = False
|
|
190
|
+
for converter in self.converters.values():
|
|
191
|
+
is_encoded, payload = converter.to_payload(value)
|
|
192
|
+
if is_encoded:
|
|
193
|
+
break
|
|
194
|
+
if not is_encoded:
|
|
195
|
+
raise RuntimeError(
|
|
196
|
+
f"Value of type {type(value)} has no known converter",
|
|
197
|
+
)
|
|
198
|
+
return payload
|
|
199
|
+
|
|
200
|
+
def from_payload(
|
|
201
|
+
self,
|
|
202
|
+
payload: EncodedObject,
|
|
203
|
+
type_hint: Optional[Type] = None,
|
|
204
|
+
) -> Any:
|
|
205
|
+
"""Decode values trying each converter.
|
|
206
|
+
|
|
207
|
+
See base class. Always returns the same number of values as payloads.
|
|
208
|
+
|
|
209
|
+
Raises:
|
|
210
|
+
KeyError: Unknown payload encoding
|
|
211
|
+
RuntimeError: Error during decode
|
|
212
|
+
"""
|
|
213
|
+
encoding = payload.encoding
|
|
214
|
+
assert isinstance(encoding, (str, Unset))
|
|
215
|
+
converter = self.converters.get(encoding)
|
|
216
|
+
if converter is None:
|
|
217
|
+
raise KeyError(f"Unknown payload encoding {encoding}")
|
|
218
|
+
try:
|
|
219
|
+
value = converter.from_payload(payload, type_hint)
|
|
220
|
+
except RuntimeError as err:
|
|
221
|
+
raise RuntimeError(
|
|
222
|
+
f"Payload with encoding {encoding} could not be converted",
|
|
223
|
+
) from err
|
|
224
|
+
return value
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class DefaultPayloadConverter(CompositePayloadConverter):
|
|
228
|
+
"""Default payload converter compatible with other Temporal SDKs.
|
|
229
|
+
|
|
230
|
+
This handles None, bytes, all protobuf message types, and any type that
|
|
231
|
+
:py:func:`json.dump` accepts. A singleton instance of this is available at
|
|
232
|
+
:py:attr:`PayloadConverter.default`.
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
default_encoding_payload_converters: Tuple[EncodingPayloadConverter, ...]
|
|
236
|
+
"""Default set of encoding payload converters the default payload converter
|
|
237
|
+
uses.
|
|
238
|
+
"""
|
|
239
|
+
|
|
240
|
+
def __init__(self) -> None:
|
|
241
|
+
"""Create a default payload converter."""
|
|
242
|
+
super().__init__(*DefaultPayloadConverter.default_encoding_payload_converters)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class UnsetPayloadConverter(EncodingPayloadConverter):
|
|
246
|
+
"""Converter for 'unset' payloads supporting None values."""
|
|
247
|
+
|
|
248
|
+
@property
|
|
249
|
+
def encoding(self) -> Union[str, Unset]:
|
|
250
|
+
"""See base class."""
|
|
251
|
+
return UNSET
|
|
252
|
+
|
|
253
|
+
def to_payload(self, value: Any) -> tuple[bool, Union[EncodedObject, Unset]]:
|
|
254
|
+
"""See base class."""
|
|
255
|
+
if value is None:
|
|
256
|
+
return (True, UNSET)
|
|
257
|
+
return (False, UNSET)
|
|
258
|
+
|
|
259
|
+
def from_payload(
|
|
260
|
+
self,
|
|
261
|
+
payload: EncodedObject,
|
|
262
|
+
type_hint: Optional[Type] = None,
|
|
263
|
+
) -> Any:
|
|
264
|
+
"""See base class."""
|
|
265
|
+
if isinstance(payload.data, str) and len(payload.data) > 0:
|
|
266
|
+
raise RuntimeError("Expected empty data set for binary/null")
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class BinaryNullPayloadConverter(UnsetPayloadConverter):
|
|
271
|
+
"""Converter for 'binary/null' payloads supporting None values."""
|
|
272
|
+
|
|
273
|
+
@property
|
|
274
|
+
def encoding(self) -> Union[str, Unset]:
|
|
275
|
+
"""See base class."""
|
|
276
|
+
return "binary/null"
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class BinaryPlainPayloadConverter(EncodingPayloadConverter):
|
|
280
|
+
"""Converter for 'binary/plain' payloads supporting bytes values."""
|
|
281
|
+
|
|
282
|
+
@property
|
|
283
|
+
def encoding(self) -> Union[str, Unset]:
|
|
284
|
+
"""See base class."""
|
|
285
|
+
return "binary/plain"
|
|
286
|
+
|
|
287
|
+
def to_payload(self, value: Any) -> tuple[bool, Union[EncodedObject, Unset]]:
|
|
288
|
+
"""See base class."""
|
|
289
|
+
if isinstance(value, bytes):
|
|
290
|
+
return (
|
|
291
|
+
True,
|
|
292
|
+
EncodedObject(
|
|
293
|
+
encoding=self.encoding,
|
|
294
|
+
data=str(value),
|
|
295
|
+
),
|
|
296
|
+
)
|
|
297
|
+
return (False, UNSET)
|
|
298
|
+
|
|
299
|
+
def from_payload(
|
|
300
|
+
self,
|
|
301
|
+
payload: EncodedObject,
|
|
302
|
+
type_hint: Optional[Type] = None,
|
|
303
|
+
) -> Any:
|
|
304
|
+
"""See base class."""
|
|
305
|
+
return payload.data
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class AdvancedJSONEncoder(json.JSONEncoder):
|
|
309
|
+
"""Advanced JSON encoder.
|
|
310
|
+
|
|
311
|
+
This encoder supports dataclasses, classes with dict() functions, and
|
|
312
|
+
all iterables as lists.
|
|
313
|
+
"""
|
|
314
|
+
|
|
315
|
+
def default(self, o: Any) -> Any:
|
|
316
|
+
"""Override JSON encoding default.
|
|
317
|
+
|
|
318
|
+
See :py:meth:`json.JSONEncoder.default`.
|
|
319
|
+
"""
|
|
320
|
+
# Dataclass support
|
|
321
|
+
if dataclasses.is_dataclass(o) and not isinstance(o, type):
|
|
322
|
+
return dataclasses.asdict(o)
|
|
323
|
+
# Support for models with "dict" function like Pydantic
|
|
324
|
+
dict_fn = getattr(o, "dict", None)
|
|
325
|
+
if callable(dict_fn):
|
|
326
|
+
return dict_fn()
|
|
327
|
+
# Support for non-list iterables like set
|
|
328
|
+
if not isinstance(o, list) and isinstance(o, collections.abc.Iterable):
|
|
329
|
+
return list(o)
|
|
330
|
+
# Support for UUID
|
|
331
|
+
if isinstance(o, uuid.UUID):
|
|
332
|
+
return str(o)
|
|
333
|
+
return super().default(o)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
class JSONPlainPayloadConverter(EncodingPayloadConverter):
|
|
337
|
+
"""Converter for 'json/plain' payloads supporting common Python values.
|
|
338
|
+
|
|
339
|
+
For encoding, this supports all values that :py:func:`json.dump` supports
|
|
340
|
+
and by default adds extra encoding support for dataclasses, classes with
|
|
341
|
+
``dict()`` methods, and all iterables.
|
|
342
|
+
|
|
343
|
+
For decoding, this uses type hints to attempt to rebuild the type from the
|
|
344
|
+
type hint.
|
|
345
|
+
"""
|
|
346
|
+
|
|
347
|
+
_encoder: Optional[Type[json.JSONEncoder]]
|
|
348
|
+
_decoder: Optional[Type[json.JSONDecoder]]
|
|
349
|
+
_encoding: str
|
|
350
|
+
|
|
351
|
+
def __init__(
|
|
352
|
+
self,
|
|
353
|
+
*,
|
|
354
|
+
encoder: Optional[Type[json.JSONEncoder]] = AdvancedJSONEncoder,
|
|
355
|
+
decoder: Optional[Type[json.JSONDecoder]] = None,
|
|
356
|
+
encoding: str = "json/plain",
|
|
357
|
+
custom_type_converters: Sequence[JSONTypeConverter] = [],
|
|
358
|
+
) -> None:
|
|
359
|
+
"""Initialize a JSON data converter.
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
encoder: Custom encoder class object to use.
|
|
363
|
+
decoder: Custom decoder class object to use.
|
|
364
|
+
encoding: Encoding name to use.
|
|
365
|
+
custom_type_converters: Set of custom type converters that are used
|
|
366
|
+
when converting from a payload to type-hinted values.
|
|
367
|
+
"""
|
|
368
|
+
super().__init__()
|
|
369
|
+
self._encoder = encoder
|
|
370
|
+
self._decoder = decoder
|
|
371
|
+
self._encoding = encoding
|
|
372
|
+
self._custom_type_converters = custom_type_converters
|
|
373
|
+
|
|
374
|
+
@property
|
|
375
|
+
def encoding(self) -> Union[str, Unset]:
|
|
376
|
+
"""See base class."""
|
|
377
|
+
return self._encoding
|
|
378
|
+
|
|
379
|
+
def to_payload(self, value: Any) -> tuple[bool, Union[EncodedObject, Unset]]:
|
|
380
|
+
"""See base class."""
|
|
381
|
+
# Check for pydantic then send warning
|
|
382
|
+
if hasattr(value, "parse_obj"):
|
|
383
|
+
warnings.warn(
|
|
384
|
+
"If you're using pydantic model, refer to "
|
|
385
|
+
"https://github.com/temporalio/examples/python/tree/main/pydantic_converter for better support",
|
|
386
|
+
)
|
|
387
|
+
# We let JSON conversion errors be thrown to caller
|
|
388
|
+
return (
|
|
389
|
+
True,
|
|
390
|
+
EncodedObject(
|
|
391
|
+
encoding=self.encoding,
|
|
392
|
+
data=json.dumps(
|
|
393
|
+
value,
|
|
394
|
+
cls=self._encoder,
|
|
395
|
+
separators=(",", ":"),
|
|
396
|
+
sort_keys=True,
|
|
397
|
+
),
|
|
398
|
+
),
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
def from_payload(
|
|
402
|
+
self,
|
|
403
|
+
payload: EncodedObject,
|
|
404
|
+
type_hint: Optional[Type] = None,
|
|
405
|
+
) -> Any:
|
|
406
|
+
"""See base class."""
|
|
407
|
+
try:
|
|
408
|
+
if isinstance(payload.data, str):
|
|
409
|
+
obj = json.loads(payload.data, cls=self._decoder)
|
|
410
|
+
if type_hint:
|
|
411
|
+
obj = value_to_type(type_hint, obj, self._custom_type_converters)
|
|
412
|
+
return obj
|
|
413
|
+
else:
|
|
414
|
+
return None
|
|
415
|
+
except json.JSONDecodeError as err:
|
|
416
|
+
raise RuntimeError("Failed parsing") from err
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
_JSONTypeConverterUnhandled = NewType("_JSONTypeConverterUnhandled", object)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
class JSONTypeConverter(ABC):
|
|
423
|
+
"""Converter for converting an object from Python :py:func:`json.loads`
|
|
424
|
+
result (e.g. scalar, list, or dict) to a known type.
|
|
425
|
+
"""
|
|
426
|
+
|
|
427
|
+
Unhandled = _JSONTypeConverterUnhandled(object())
|
|
428
|
+
"""Sentinel value that must be used as the result of
|
|
429
|
+
:py:meth:`to_typed_value` to say the given type is not handled by this
|
|
430
|
+
converter."""
|
|
431
|
+
|
|
432
|
+
@abstractmethod
|
|
433
|
+
def to_typed_value(
|
|
434
|
+
self,
|
|
435
|
+
hint: Type,
|
|
436
|
+
value: Any,
|
|
437
|
+
) -> Union[Optional[Any], _JSONTypeConverterUnhandled]:
|
|
438
|
+
"""Convert the given value to a type based on the given hint.
|
|
439
|
+
|
|
440
|
+
Args:
|
|
441
|
+
hint: Type hint to use to help in converting the value.
|
|
442
|
+
value: Value as returned by :py:func:`json.loads`. Usually a scalar,
|
|
443
|
+
list, or dict.
|
|
444
|
+
|
|
445
|
+
Returns:
|
|
446
|
+
The converted value or :py:attr:`Unhandled` if this converter does
|
|
447
|
+
not handle this situation.
|
|
448
|
+
"""
|
|
449
|
+
raise NotImplementedError
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
class PayloadCodec(ABC):
|
|
453
|
+
"""Codec for encoding/decoding to/from bytes.
|
|
454
|
+
|
|
455
|
+
Commonly used for compression or encryption.
|
|
456
|
+
"""
|
|
457
|
+
|
|
458
|
+
@abstractmethod
|
|
459
|
+
def encode(
|
|
460
|
+
self,
|
|
461
|
+
payload: Union[EncodedObject, Unset],
|
|
462
|
+
) -> EncodedObject:
|
|
463
|
+
"""Encode the given payloads.
|
|
464
|
+
|
|
465
|
+
Args:
|
|
466
|
+
payload: Payloads to encode. This value should not be mutated.
|
|
467
|
+
|
|
468
|
+
Returns:
|
|
469
|
+
Encoded payloads. Note, this does not have to be the same number as
|
|
470
|
+
payloads given, but must be at least one and cannot be more than was
|
|
471
|
+
given.
|
|
472
|
+
"""
|
|
473
|
+
raise NotImplementedError
|
|
474
|
+
|
|
475
|
+
@abstractmethod
|
|
476
|
+
def decode(
|
|
477
|
+
self,
|
|
478
|
+
payload: EncodedObject,
|
|
479
|
+
) -> EncodedObject:
|
|
480
|
+
"""Decode the given payloads.
|
|
481
|
+
|
|
482
|
+
Args:
|
|
483
|
+
payload: Payloads to decode. This value should not be mutated.
|
|
484
|
+
|
|
485
|
+
Returns:
|
|
486
|
+
Decoded payloads. Note, this does not have to be the same number as
|
|
487
|
+
payloads given, but must be at least one and cannot be more than was
|
|
488
|
+
given.
|
|
489
|
+
"""
|
|
490
|
+
raise NotImplementedError
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
@dataclass(frozen=True)
|
|
494
|
+
class ObjectEncoder:
|
|
495
|
+
"""Object Encoder for converting and encoding payloads to/from Python values.
|
|
496
|
+
|
|
497
|
+
This combines :py:class:`PayloadConverter` which converts values with
|
|
498
|
+
:py:class:`PayloadCodec` which encodes bytes.
|
|
499
|
+
"""
|
|
500
|
+
|
|
501
|
+
payload_converter_class: Type[PayloadConverter] = DefaultPayloadConverter
|
|
502
|
+
"""Class to instantiate for payload conversion."""
|
|
503
|
+
|
|
504
|
+
payload_codec: Optional[PayloadCodec] = None
|
|
505
|
+
"""Optional codec for encoding payload bytes."""
|
|
506
|
+
|
|
507
|
+
payload_converter: PayloadConverter = dataclasses.field(init=False)
|
|
508
|
+
"""Payload converter created from the :py:attr:`payload_converter_class`."""
|
|
509
|
+
|
|
510
|
+
default: ClassVar[ObjectEncoder]
|
|
511
|
+
"""Singleton default data converter."""
|
|
512
|
+
|
|
513
|
+
def __post_init__(self) -> None: # noqa: D105
|
|
514
|
+
object.__setattr__(self, "payload_converter", self.payload_converter_class())
|
|
515
|
+
|
|
516
|
+
def encode(
|
|
517
|
+
self,
|
|
518
|
+
value: Any,
|
|
519
|
+
) -> Union[EncodedObject, Unset]:
|
|
520
|
+
"""Encode values into payloads.
|
|
521
|
+
|
|
522
|
+
First converts values to payload then encodes payload using codec.
|
|
523
|
+
|
|
524
|
+
Args:
|
|
525
|
+
value: Values to be converted and encoded.
|
|
526
|
+
|
|
527
|
+
Returns:
|
|
528
|
+
Converted and encoded payload.
|
|
529
|
+
"""
|
|
530
|
+
payload = self.payload_converter.to_payload(value)
|
|
531
|
+
if self.payload_codec:
|
|
532
|
+
payload = self.payload_codec.encode(payload)
|
|
533
|
+
return payload
|
|
534
|
+
|
|
535
|
+
def decode(
|
|
536
|
+
self,
|
|
537
|
+
payload: Union[Optional[EncodedObject], Unset],
|
|
538
|
+
type_hint: Optional[Type] = None,
|
|
539
|
+
) -> Any:
|
|
540
|
+
"""Decode payloads into values.
|
|
541
|
+
|
|
542
|
+
First decodes payloads using codec then converts payloads to values.
|
|
543
|
+
|
|
544
|
+
Args:
|
|
545
|
+
type_hint: type to decode to
|
|
546
|
+
payload: Payload to be decoded and converted.
|
|
547
|
+
|
|
548
|
+
Returns:
|
|
549
|
+
Decoded and converted value.
|
|
550
|
+
"""
|
|
551
|
+
if payload is None or isinstance(payload, Unset):
|
|
552
|
+
return None
|
|
553
|
+
if self.payload_codec:
|
|
554
|
+
payload = self.payload_codec.decode(payload)
|
|
555
|
+
return self.payload_converter.from_payload(payload, type_hint)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
DefaultPayloadConverter.default_encoding_payload_converters = (
|
|
559
|
+
UnsetPayloadConverter(),
|
|
560
|
+
BinaryNullPayloadConverter(),
|
|
561
|
+
BinaryPlainPayloadConverter(),
|
|
562
|
+
JSONPlainPayloadConverter(),
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
ObjectEncoder.default = ObjectEncoder()
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def value_to_type(
|
|
569
|
+
hint: Type,
|
|
570
|
+
value: Any,
|
|
571
|
+
custom_converters,
|
|
572
|
+
) -> Any:
|
|
573
|
+
"""Convert a given value to the given type hint.
|
|
574
|
+
|
|
575
|
+
This is used internally to convert a raw JSON loaded value to a specific
|
|
576
|
+
type hint.
|
|
577
|
+
|
|
578
|
+
Args:
|
|
579
|
+
hint: Type hint to convert the value to.
|
|
580
|
+
value: Raw value (e.g. primitive, dict, or list) to convert from.
|
|
581
|
+
custom_converters: Set of custom converters to try before doing default
|
|
582
|
+
conversion. Converters are tried in order and the first value that
|
|
583
|
+
is not :py:attr:`JSONTypeConverter.Unhandled` will be returned from
|
|
584
|
+
this function instead of doing default behavior.
|
|
585
|
+
|
|
586
|
+
Returns:
|
|
587
|
+
Converted value.
|
|
588
|
+
|
|
589
|
+
Raises:
|
|
590
|
+
TypeError: Unable to convert to the given hint.
|
|
591
|
+
"""
|
|
592
|
+
# Try custom converters
|
|
593
|
+
if custom_converters is None:
|
|
594
|
+
custom_converters = []
|
|
595
|
+
for conv in custom_converters:
|
|
596
|
+
ret = conv.to_typed_value(hint, value)
|
|
597
|
+
if ret is not JSONTypeConverter.Unhandled:
|
|
598
|
+
return ret
|
|
599
|
+
|
|
600
|
+
# Any or primitives
|
|
601
|
+
if hint is Any:
|
|
602
|
+
return value
|
|
603
|
+
elif hint is int or hint is float:
|
|
604
|
+
if not isinstance(value, (int, float)):
|
|
605
|
+
raise TypeError(f"Expected value to be int|float, was {type(value)}")
|
|
606
|
+
return hint(value)
|
|
607
|
+
elif hint is bool:
|
|
608
|
+
if not isinstance(value, bool):
|
|
609
|
+
raise TypeError(f"Expected value to be bool, was {type(value)}")
|
|
610
|
+
return bool(value)
|
|
611
|
+
elif hint is str:
|
|
612
|
+
if not isinstance(value, str):
|
|
613
|
+
raise TypeError(f"Expected value to be str, was {type(value)}")
|
|
614
|
+
return str(value)
|
|
615
|
+
elif hint is bytes:
|
|
616
|
+
if not isinstance(value, (str, bytes, list)):
|
|
617
|
+
raise TypeError(f"Expected value to be bytes, was {type(value)}")
|
|
618
|
+
# In some other SDKs, this is serialized as a base64 string, but in
|
|
619
|
+
# Python this is a numeric array.
|
|
620
|
+
return bytes(value) # type: ignore
|
|
621
|
+
elif hint is type(None):
|
|
622
|
+
if value is not None:
|
|
623
|
+
raise TypeError(f"Expected None, got value of type {type(value)}")
|
|
624
|
+
return None
|
|
625
|
+
|
|
626
|
+
# NewType. Note we cannot simply check isinstance NewType here because it's
|
|
627
|
+
# only been a class since 3.10. Instead we'll just check for the presence
|
|
628
|
+
# of a supertype.
|
|
629
|
+
supertype = getattr(hint, "__supertype__", None)
|
|
630
|
+
if supertype:
|
|
631
|
+
return value_to_type(supertype, value, custom_converters)
|
|
632
|
+
|
|
633
|
+
# Load origin for other checks
|
|
634
|
+
origin = getattr(hint, "__origin__", hint)
|
|
635
|
+
type_args: Tuple = getattr(hint, "__args__", ())
|
|
636
|
+
|
|
637
|
+
# Literal
|
|
638
|
+
if origin is Literal:
|
|
639
|
+
if value not in type_args:
|
|
640
|
+
raise TypeError(f"Value {value} not in literal values {type_args}")
|
|
641
|
+
return value
|
|
642
|
+
|
|
643
|
+
is_union = origin is Union
|
|
644
|
+
if sys.version_info >= (3, 10):
|
|
645
|
+
is_union = is_union or isinstance(origin, UnionType)
|
|
646
|
+
|
|
647
|
+
# Union
|
|
648
|
+
if is_union:
|
|
649
|
+
# Try each one. Note, Optional is just a union w/ none.
|
|
650
|
+
for arg in type_args:
|
|
651
|
+
try:
|
|
652
|
+
return value_to_type(arg, value, custom_converters)
|
|
653
|
+
except Exception:
|
|
654
|
+
pass
|
|
655
|
+
raise TypeError(f"Failed converting to {hint} from {value}")
|
|
656
|
+
|
|
657
|
+
# Mapping
|
|
658
|
+
if inspect.isclass(origin) and issubclass(origin, collections.abc.Mapping):
|
|
659
|
+
if not isinstance(value, collections.abc.Mapping):
|
|
660
|
+
raise TypeError(f"Expected {hint}, value was {type(value)}")
|
|
661
|
+
ret_dict = {}
|
|
662
|
+
# If there are required or optional keys that means we are a TypedDict
|
|
663
|
+
# and therefore can extract per-key types
|
|
664
|
+
per_key_types: Optional[Dict[str, Type]] = None
|
|
665
|
+
if getattr(origin, "__required_keys__", None) or getattr(
|
|
666
|
+
origin,
|
|
667
|
+
"__optional_keys__",
|
|
668
|
+
None,
|
|
669
|
+
):
|
|
670
|
+
per_key_types = get_type_hints(origin)
|
|
671
|
+
key_type = (
|
|
672
|
+
type_args[0]
|
|
673
|
+
if len(type_args) > 0
|
|
674
|
+
and type_args[0] is not Any
|
|
675
|
+
and not isinstance(type_args[0], TypeVar)
|
|
676
|
+
else None
|
|
677
|
+
)
|
|
678
|
+
value_type = (
|
|
679
|
+
type_args[1]
|
|
680
|
+
if len(type_args) > 1
|
|
681
|
+
and type_args[1] is not Any
|
|
682
|
+
and not isinstance(type_args[1], TypeVar)
|
|
683
|
+
else None
|
|
684
|
+
)
|
|
685
|
+
# Convert each key/value
|
|
686
|
+
for key, value in value.items():
|
|
687
|
+
if key_type:
|
|
688
|
+
try:
|
|
689
|
+
key = value_to_type(key_type, key, custom_converters)
|
|
690
|
+
except Exception as err:
|
|
691
|
+
raise TypeError(f"Failed converting key {key} on {hint}") from err
|
|
692
|
+
# If there are per-key types, use it instead of single type
|
|
693
|
+
this_value_type = value_type
|
|
694
|
+
if per_key_types:
|
|
695
|
+
# TODO(cretz): Strict mode would fail an unknown key
|
|
696
|
+
this_value_type = per_key_types.get(key)
|
|
697
|
+
if this_value_type:
|
|
698
|
+
try:
|
|
699
|
+
value = value_to_type(this_value_type, value, custom_converters)
|
|
700
|
+
except Exception as err:
|
|
701
|
+
raise TypeError(
|
|
702
|
+
f"Failed converting value for key {key} on {hint}",
|
|
703
|
+
) from err
|
|
704
|
+
ret_dict[key] = value
|
|
705
|
+
# If there are per-key types, it's a typed dict and we want to attempt
|
|
706
|
+
# instantiation to get its validation
|
|
707
|
+
if per_key_types:
|
|
708
|
+
ret_dict = hint(**ret_dict)
|
|
709
|
+
return ret_dict
|
|
710
|
+
|
|
711
|
+
# Dataclass
|
|
712
|
+
if dataclasses.is_dataclass(hint):
|
|
713
|
+
if not isinstance(value, dict):
|
|
714
|
+
raise TypeError(
|
|
715
|
+
f"Cannot convert to dataclass {hint}, value is {type(value)} not dict",
|
|
716
|
+
)
|
|
717
|
+
# Obtain dataclass fields and check that all dict fields are there and
|
|
718
|
+
# that no required fields are missing. Unknown fields are silently
|
|
719
|
+
# ignored.
|
|
720
|
+
fields = dataclasses.fields(hint)
|
|
721
|
+
field_hints = get_type_hints(hint)
|
|
722
|
+
field_values = {}
|
|
723
|
+
for field in fields:
|
|
724
|
+
field_value = value.get(field.name, dataclasses.MISSING)
|
|
725
|
+
# We do not check whether field is required here. Rather, we let the
|
|
726
|
+
# attempted instantiation of the dataclass raise if a field is
|
|
727
|
+
# missing
|
|
728
|
+
if field_value is not dataclasses.MISSING:
|
|
729
|
+
try:
|
|
730
|
+
field_values[field.name] = value_to_type(
|
|
731
|
+
field_hints[field.name],
|
|
732
|
+
field_value,
|
|
733
|
+
custom_converters,
|
|
734
|
+
)
|
|
735
|
+
except Exception as err:
|
|
736
|
+
raise TypeError(
|
|
737
|
+
f"Failed converting field {field.name} on dataclass {hint}",
|
|
738
|
+
) from err
|
|
739
|
+
# Simply instantiate the dataclass. This will fail as expected when
|
|
740
|
+
# missing required fields.
|
|
741
|
+
# TODO(cretz): Want way to convert snake case to camel case?
|
|
742
|
+
return hint(**field_values)
|
|
743
|
+
|
|
744
|
+
# If there is a @staticmethod or @classmethod parse_obj, we will use it.
|
|
745
|
+
# This covers Pydantic models.
|
|
746
|
+
parse_obj_attr = inspect.getattr_static(hint, "parse_obj", None)
|
|
747
|
+
if isinstance(parse_obj_attr, classmethod) or isinstance(
|
|
748
|
+
parse_obj_attr,
|
|
749
|
+
staticmethod,
|
|
750
|
+
):
|
|
751
|
+
if not isinstance(value, dict):
|
|
752
|
+
raise TypeError(
|
|
753
|
+
f"Cannot convert to {hint}, value is {type(value)} not dict",
|
|
754
|
+
)
|
|
755
|
+
return getattr(hint, "parse_obj")(value)
|
|
756
|
+
|
|
757
|
+
# IntEnum
|
|
758
|
+
if inspect.isclass(hint) and issubclass(hint, IntEnum):
|
|
759
|
+
if not isinstance(value, int):
|
|
760
|
+
raise TypeError(
|
|
761
|
+
f"Cannot convert to enum {hint}, value not an integer, value is {type(value)}",
|
|
762
|
+
)
|
|
763
|
+
return hint(value)
|
|
764
|
+
|
|
765
|
+
# StrEnum, available in 3.11+
|
|
766
|
+
if sys.version_info >= (3, 11):
|
|
767
|
+
if inspect.isclass(hint) and issubclass(hint, StrEnum):
|
|
768
|
+
if not isinstance(value, str):
|
|
769
|
+
raise TypeError(
|
|
770
|
+
f"Cannot convert to enum {hint}, value not a string, value is {type(value)}",
|
|
771
|
+
)
|
|
772
|
+
return hint(value)
|
|
773
|
+
|
|
774
|
+
# UUID
|
|
775
|
+
if inspect.isclass(hint) and issubclass(hint, uuid.UUID):
|
|
776
|
+
return hint(value)
|
|
777
|
+
|
|
778
|
+
# Iterable. We intentionally put this last as it catches several others.
|
|
779
|
+
if inspect.isclass(origin) and issubclass(origin, collections.abc.Iterable):
|
|
780
|
+
if not isinstance(value, collections.abc.Iterable):
|
|
781
|
+
raise TypeError(f"Expected {hint}, value was {type(value)}")
|
|
782
|
+
ret_list = []
|
|
783
|
+
# If there is no type arg, just return value as is
|
|
784
|
+
if not type_args or (
|
|
785
|
+
len(type_args) == 1
|
|
786
|
+
and (isinstance(type_args[0], TypeVar) or type_args[0] is Ellipsis)
|
|
787
|
+
):
|
|
788
|
+
ret_list = list(value)
|
|
789
|
+
else:
|
|
790
|
+
# Otherwise convert
|
|
791
|
+
for i, item in enumerate(value):
|
|
792
|
+
# Non-tuples use first type arg, tuples use arg set or one
|
|
793
|
+
# before ellipsis if that's set
|
|
794
|
+
if origin is not tuple:
|
|
795
|
+
arg_type = type_args[0]
|
|
796
|
+
elif len(type_args) > i and type_args[i] is not Ellipsis:
|
|
797
|
+
arg_type = type_args[i]
|
|
798
|
+
elif type_args[-1] is Ellipsis:
|
|
799
|
+
# Ellipsis means use the second to last one
|
|
800
|
+
arg_type = type_args[-2]
|
|
801
|
+
else:
|
|
802
|
+
raise TypeError(
|
|
803
|
+
f"Type {hint} only expecting {len(type_args)} values, got at least {i + 1}",
|
|
804
|
+
)
|
|
805
|
+
try:
|
|
806
|
+
ret_list.append(value_to_type(arg_type, item, custom_converters))
|
|
807
|
+
except Exception as err:
|
|
808
|
+
raise TypeError(f"Failed converting {hint} index {i}") from err
|
|
809
|
+
# If tuple, set, or deque convert back to that type
|
|
810
|
+
if origin is tuple:
|
|
811
|
+
return tuple(ret_list)
|
|
812
|
+
elif origin is set:
|
|
813
|
+
return set(ret_list)
|
|
814
|
+
elif origin is collections.deque:
|
|
815
|
+
return collections.deque(ret_list)
|
|
816
|
+
return ret_list
|
|
817
|
+
|
|
818
|
+
raise TypeError(f"Unserializable type during conversion: {hint}")
|