aws-durable-execution-sdk-python 1.0.0__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.
Files changed (36) hide show
  1. aws_durable_execution_sdk_python/.gitignore +0 -0
  2. aws_durable_execution_sdk_python/__about__.py +4 -0
  3. aws_durable_execution_sdk_python/__init__.py +34 -0
  4. aws_durable_execution_sdk_python/botocore/data/lambdainternal/2015-03-31/service-2.json +7864 -0
  5. aws_durable_execution_sdk_python/concurrency/__init__.py +0 -0
  6. aws_durable_execution_sdk_python/concurrency/executor.py +436 -0
  7. aws_durable_execution_sdk_python/concurrency/models.py +469 -0
  8. aws_durable_execution_sdk_python/config.py +499 -0
  9. aws_durable_execution_sdk_python/context.py +551 -0
  10. aws_durable_execution_sdk_python/exceptions.py +374 -0
  11. aws_durable_execution_sdk_python/execution.py +428 -0
  12. aws_durable_execution_sdk_python/identifier.py +14 -0
  13. aws_durable_execution_sdk_python/lambda_service.py +1034 -0
  14. aws_durable_execution_sdk_python/logger.py +131 -0
  15. aws_durable_execution_sdk_python/operation/__init__.py +1 -0
  16. aws_durable_execution_sdk_python/operation/callback.py +123 -0
  17. aws_durable_execution_sdk_python/operation/child.py +162 -0
  18. aws_durable_execution_sdk_python/operation/invoke.py +119 -0
  19. aws_durable_execution_sdk_python/operation/map.py +137 -0
  20. aws_durable_execution_sdk_python/operation/parallel.py +122 -0
  21. aws_durable_execution_sdk_python/operation/step.py +269 -0
  22. aws_durable_execution_sdk_python/operation/wait.py +53 -0
  23. aws_durable_execution_sdk_python/operation/wait_for_condition.py +235 -0
  24. aws_durable_execution_sdk_python/py.typed +1 -0
  25. aws_durable_execution_sdk_python/retries.py +174 -0
  26. aws_durable_execution_sdk_python/serdes.py +502 -0
  27. aws_durable_execution_sdk_python/state.py +790 -0
  28. aws_durable_execution_sdk_python/suspend.py +84 -0
  29. aws_durable_execution_sdk_python/threading.py +222 -0
  30. aws_durable_execution_sdk_python/types.py +180 -0
  31. aws_durable_execution_sdk_python/waits.py +130 -0
  32. aws_durable_execution_sdk_python-1.0.0.dist-info/METADATA +679 -0
  33. aws_durable_execution_sdk_python-1.0.0.dist-info/RECORD +36 -0
  34. aws_durable_execution_sdk_python-1.0.0.dist-info/WHEEL +4 -0
  35. aws_durable_execution_sdk_python-1.0.0.dist-info/licenses/LICENSE +175 -0
  36. aws_durable_execution_sdk_python-1.0.0.dist-info/licenses/NOTICE +1 -0
@@ -0,0 +1,174 @@
1
+ """Ready-made retry strategies and retry creators."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import re
7
+ from dataclasses import dataclass, field
8
+ from typing import TYPE_CHECKING
9
+
10
+ from aws_durable_execution_sdk_python.config import Duration, JitterStrategy
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import Callable
14
+
15
+ Numeric = int | float
16
+
17
+ # Default pattern that matches all error messages
18
+ _DEFAULT_RETRYABLE_ERROR_PATTERN = re.compile(r".*")
19
+
20
+
21
+ @dataclass
22
+ class RetryDecision:
23
+ """Decision about whether to retry a step and with what delay."""
24
+
25
+ should_retry: bool
26
+ delay: Duration
27
+
28
+ @property
29
+ def delay_seconds(self) -> int:
30
+ """Get delay in seconds."""
31
+ return self.delay.to_seconds()
32
+
33
+ @classmethod
34
+ def retry(cls, delay: Duration) -> RetryDecision:
35
+ """Create a retry decision."""
36
+ return cls(should_retry=True, delay=delay)
37
+
38
+ @classmethod
39
+ def no_retry(cls) -> RetryDecision:
40
+ """Create a no-retry decision."""
41
+ return cls(should_retry=False, delay=Duration())
42
+
43
+
44
+ @dataclass
45
+ class RetryStrategyConfig:
46
+ max_attempts: int = 3
47
+ initial_delay: Duration = field(default_factory=lambda: Duration.from_seconds(5))
48
+ max_delay: Duration = field(
49
+ default_factory=lambda: Duration.from_minutes(5)
50
+ ) # 5 minutes
51
+ backoff_rate: Numeric = 2.0
52
+ jitter_strategy: JitterStrategy = field(default=JitterStrategy.FULL)
53
+ retryable_errors: list[str | re.Pattern] | None = None
54
+ retryable_error_types: list[type[Exception]] | None = None
55
+
56
+ @property
57
+ def initial_delay_seconds(self) -> int:
58
+ """Get initial delay in seconds."""
59
+ return self.initial_delay.to_seconds()
60
+
61
+ @property
62
+ def max_delay_seconds(self) -> int:
63
+ """Get max delay in seconds."""
64
+ return self.max_delay.to_seconds()
65
+
66
+
67
+ def create_retry_strategy(
68
+ config: RetryStrategyConfig | None = None,
69
+ ) -> Callable[[Exception, int], RetryDecision]:
70
+ if config is None:
71
+ config = RetryStrategyConfig()
72
+
73
+ # Apply default retryableErrors only if user didn't specify either filter
74
+ should_use_default_errors: bool = (
75
+ config.retryable_errors is None and config.retryable_error_types is None
76
+ )
77
+
78
+ retryable_errors: list[str | re.Pattern] = (
79
+ config.retryable_errors
80
+ if config.retryable_errors is not None
81
+ else ([_DEFAULT_RETRYABLE_ERROR_PATTERN] if should_use_default_errors else [])
82
+ )
83
+ retryable_error_types: list[type[Exception]] = config.retryable_error_types or []
84
+
85
+ def retry_strategy(error: Exception, attempts_made: int) -> RetryDecision:
86
+ # Check if we've exceeded max attempts
87
+ if attempts_made >= config.max_attempts:
88
+ return RetryDecision.no_retry()
89
+
90
+ # Check if error is retryable based on error message
91
+ is_retryable_error_message: bool = any(
92
+ pattern.search(str(error))
93
+ if isinstance(pattern, re.Pattern)
94
+ else pattern in str(error)
95
+ for pattern in retryable_errors
96
+ )
97
+
98
+ # Check if error is retryable based on error type
99
+ is_retryable_error_type: bool = any(
100
+ isinstance(error, error_type) for error_type in retryable_error_types
101
+ )
102
+
103
+ if not is_retryable_error_message and not is_retryable_error_type:
104
+ return RetryDecision.no_retry()
105
+
106
+ # Calculate delay with exponential backoff
107
+ base_delay: float = min(
108
+ config.initial_delay_seconds * (config.backoff_rate ** (attempts_made - 1)),
109
+ config.max_delay_seconds,
110
+ )
111
+ # Apply jitter to get final delay
112
+ delay_with_jitter: float = config.jitter_strategy.apply_jitter(base_delay)
113
+ # Round up and ensure minimum of 1 second
114
+ final_delay: int = max(1, math.ceil(delay_with_jitter))
115
+
116
+ return RetryDecision.retry(Duration(seconds=final_delay))
117
+
118
+ return retry_strategy
119
+
120
+
121
+ class RetryPresets:
122
+ """Default retry presets."""
123
+
124
+ @classmethod
125
+ def none(cls) -> Callable[[Exception, int], RetryDecision]:
126
+ """No retries."""
127
+ return create_retry_strategy(RetryStrategyConfig(max_attempts=1))
128
+
129
+ @classmethod
130
+ def default(cls) -> Callable[[Exception, int], RetryDecision]:
131
+ """Default retries, will be used automatically if retryConfig is missing"""
132
+ return create_retry_strategy(
133
+ RetryStrategyConfig(
134
+ max_attempts=6,
135
+ initial_delay=Duration.from_seconds(5),
136
+ max_delay=Duration.from_minutes(1),
137
+ backoff_rate=2,
138
+ jitter_strategy=JitterStrategy.FULL,
139
+ )
140
+ )
141
+
142
+ @classmethod
143
+ def transient(cls) -> Callable[[Exception, int], RetryDecision]:
144
+ """Quick retries for transient errors"""
145
+ return create_retry_strategy(
146
+ RetryStrategyConfig(
147
+ max_attempts=3, backoff_rate=2, jitter_strategy=JitterStrategy.HALF
148
+ )
149
+ )
150
+
151
+ @classmethod
152
+ def resource_availability(cls) -> Callable[[Exception, int], RetryDecision]:
153
+ """Longer retries for resource availability"""
154
+ return create_retry_strategy(
155
+ RetryStrategyConfig(
156
+ max_attempts=5,
157
+ initial_delay=Duration.from_seconds(5),
158
+ max_delay=Duration.from_minutes(5),
159
+ backoff_rate=2,
160
+ )
161
+ )
162
+
163
+ @classmethod
164
+ def critical(cls) -> Callable[[Exception, int], RetryDecision]:
165
+ """Aggressive retries for critical operations"""
166
+ return create_retry_strategy(
167
+ RetryStrategyConfig(
168
+ max_attempts=10,
169
+ initial_delay=Duration.from_seconds(1),
170
+ max_delay=Duration.from_minutes(1),
171
+ backoff_rate=1.5,
172
+ jitter_strategy=JitterStrategy.NONE,
173
+ )
174
+ )
@@ -0,0 +1,502 @@
1
+ """Codec-based serialization and deserialization for Python types.
2
+
3
+ This module provides comprehensive serialization support using a codec-based
4
+ architecture with recursive encoding/decoding for nested structures.
5
+
6
+ Key Features:
7
+ - Plain JSON for primitives and simple lists (performance optimization)
8
+ - Envelope format with type tags for complex types
9
+ - Modular codec architecture
10
+ - Recursive handling of nested structures
11
+
12
+ Serialization Strategy:
13
+ - Primitives (None, str, int, float, bool): Plain JSON
14
+ - Simple lists containing only primitives: Plain JSON
15
+ - Everything else: Envelope format with type tags
16
+
17
+ Wire Formats:
18
+ Plain JSON: 42, "hello", [1, 2, 3]
19
+ Envelope: {"t": "<type_tag>", "v": <encoded_value>}
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import base64
25
+ import json
26
+ import logging
27
+ import uuid
28
+ from abc import ABC, abstractmethod
29
+ from dataclasses import dataclass
30
+ from datetime import date, datetime
31
+ from decimal import Decimal
32
+ from enum import StrEnum
33
+ from typing import Any, Generic, Protocol, TypeVar
34
+
35
+ from aws_durable_execution_sdk_python.concurrency.models import BatchResult
36
+ from aws_durable_execution_sdk_python.exceptions import (
37
+ DurableExecutionsError,
38
+ ExecutionError,
39
+ SerDesError,
40
+ )
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+ T = TypeVar("T")
45
+
46
+ TYPE_TOKEN: str = "t"
47
+ VALUE_TOKEN: str = "v"
48
+
49
+
50
+ class TypeTag(StrEnum):
51
+ """Type tags for envelope format."""
52
+
53
+ NONE = "n"
54
+ STR = "s"
55
+ INT = "i"
56
+ FLOAT = "f"
57
+ BOOL = "b"
58
+ BYTES = "B"
59
+ UUID = "u"
60
+ DECIMAL = "d"
61
+ DATETIME = "dt"
62
+ DATE = "D"
63
+ TUPLE = "t"
64
+ LIST = "l"
65
+ DICT = "m"
66
+ BATCH_RESULT = "br"
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class EncodedValue:
71
+ """Encoded value with type tag."""
72
+
73
+ tag: TypeTag
74
+
75
+ value: Any
76
+
77
+
78
+ # region codecs
79
+ class Codec(Protocol):
80
+ """Protocol for type-specific codecs."""
81
+
82
+ def encode(self, obj: Any) -> EncodedValue: ...
83
+
84
+ def decode(self, tag: TypeTag, value: Any) -> Any: ...
85
+
86
+
87
+ class PrimitiveCodec:
88
+ """Codec for primitive types."""
89
+
90
+ def encode(self, obj: Any) -> EncodedValue: # noqa: PLR6301
91
+ match obj:
92
+ case None:
93
+ return EncodedValue(TypeTag.NONE, None)
94
+ case str():
95
+ return EncodedValue(TypeTag.STR, obj)
96
+ case bool(): # Must come before int
97
+ return EncodedValue(TypeTag.BOOL, obj)
98
+ case int():
99
+ return EncodedValue(TypeTag.INT, obj)
100
+ case float():
101
+ return EncodedValue(TypeTag.FLOAT, obj)
102
+ case _:
103
+ msg = f"Unsupported primitive type: {type(obj)!r}"
104
+ raise SerDesError(msg)
105
+
106
+ def decode(self, tag: TypeTag, value: Any) -> Any: # noqa: PLR6301
107
+ match tag:
108
+ case TypeTag.NONE:
109
+ return None
110
+ case TypeTag.STR:
111
+ return str(value)
112
+ case TypeTag.BOOL:
113
+ return bool(value)
114
+ case TypeTag.INT:
115
+ return int(value)
116
+ case TypeTag.FLOAT:
117
+ return float(value)
118
+ case _:
119
+ msg = f"Unknown primitive tag: {tag}"
120
+ raise SerDesError(msg)
121
+
122
+
123
+ class BytesCodec:
124
+ """Codec for bytes, bytearray, and memoryview."""
125
+
126
+ def encode(self, obj: Any) -> EncodedValue: # noqa: PLR6301
127
+ encoded = base64.b64encode(bytes(obj)).decode("utf-8")
128
+ return EncodedValue(TypeTag.BYTES, encoded)
129
+
130
+ def decode(self, tag: TypeTag, value: Any) -> Any: # noqa: PLR6301
131
+ if tag != TypeTag.BYTES:
132
+ msg = f"Expected BYTES tag, got {tag}"
133
+ raise SerDesError(msg)
134
+ return base64.b64decode(value.encode("utf-8"))
135
+
136
+
137
+ class UuidCodec:
138
+ """Codec for UUID objects."""
139
+
140
+ def encode(self, obj: Any) -> EncodedValue: # noqa: PLR6301
141
+ return EncodedValue(TypeTag.UUID, str(obj))
142
+
143
+ def decode(self, tag: TypeTag, value: Any) -> Any: # noqa: PLR6301
144
+ if tag != TypeTag.UUID:
145
+ msg = f"Expected UUID tag, got {tag}"
146
+ raise SerDesError(msg)
147
+ return uuid.UUID(value)
148
+
149
+
150
+ class DecimalCodec:
151
+ """Codec for Decimal objects."""
152
+
153
+ def encode(self, obj: Any) -> EncodedValue: # noqa: PLR6301
154
+ return EncodedValue(TypeTag.DECIMAL, str(obj))
155
+
156
+ def decode(self, tag: TypeTag, value: Any) -> Any: # noqa: PLR6301
157
+ if tag != TypeTag.DECIMAL:
158
+ msg = f"Expected DECIMAL tag, got {tag}"
159
+ raise SerDesError(msg)
160
+ return Decimal(value)
161
+
162
+
163
+ class DateTimeCodec:
164
+ """Codec for datetime and date objects."""
165
+
166
+ def encode(self, obj: Any) -> EncodedValue: # noqa: PLR6301
167
+ match obj:
168
+ case datetime():
169
+ return EncodedValue(TypeTag.DATETIME, obj.isoformat())
170
+ case date():
171
+ return EncodedValue(TypeTag.DATE, obj.isoformat())
172
+ case _:
173
+ msg = f"Unsupported datetime type: {type(obj)!r}"
174
+ raise SerDesError(msg)
175
+
176
+ def decode(self, tag: TypeTag, value: Any) -> Any: # noqa: PLR6301
177
+ match tag:
178
+ case TypeTag.DATETIME:
179
+ # Handle Z suffix for UTC
180
+ s = value
181
+ if isinstance(s, str) and s.endswith("Z"):
182
+ s = s[:-1] + "+00:00"
183
+ return datetime.fromisoformat(s)
184
+ case TypeTag.DATE:
185
+ return date.fromisoformat(value)
186
+ case _:
187
+ msg = f"Unknown datetime tag: {tag}"
188
+ raise SerDesError(msg)
189
+
190
+
191
+ class ContainerCodec(Codec):
192
+ """Codec for container types with recursive encoding/decoding."""
193
+
194
+ def __init__(self) -> None:
195
+ self._dispatcher: TypeCodec | None = None
196
+
197
+ def set_dispatcher(self, dispatcher) -> None:
198
+ """Set the main codec dispatcher for recursive encoding."""
199
+ self._dispatcher = dispatcher
200
+
201
+ @property
202
+ def dispatcher(self):
203
+ """Get the dispatcher, raising error if not set."""
204
+ if self._dispatcher is None:
205
+ msg = "ContainerCodec not linked to a TypeCodec dispatcher."
206
+ raise DurableExecutionsError(msg)
207
+ return self._dispatcher
208
+
209
+ def encode(self, obj: Any) -> EncodedValue:
210
+ """Encode container using dispatcher for recursive elements."""
211
+
212
+ match obj:
213
+ case BatchResult():
214
+ # Encode BatchResult as dict with special tag
215
+ return EncodedValue(
216
+ TypeTag.BATCH_RESULT,
217
+ self._wrap(obj.to_dict(), self.dispatcher).value,
218
+ )
219
+ case list():
220
+ return EncodedValue(
221
+ TypeTag.LIST, [self._wrap(v, self.dispatcher) for v in obj]
222
+ )
223
+ case tuple():
224
+ return EncodedValue(
225
+ TypeTag.TUPLE, [self._wrap(v, self.dispatcher) for v in obj]
226
+ )
227
+ case dict():
228
+ for k in obj:
229
+ if isinstance(k, tuple):
230
+ msg = "Tuple keys not supported"
231
+ raise SerDesError(msg)
232
+ return EncodedValue(
233
+ TypeTag.DICT,
234
+ {k: self._wrap(v, self.dispatcher) for k, v in obj.items()},
235
+ )
236
+ case _:
237
+ msg = f"Unsupported container type: {type(obj)!r}"
238
+ raise SerDesError(msg)
239
+
240
+ def decode(self, tag: TypeTag, value: Any) -> Any:
241
+ """Decode container using dispatcher for recursive elements."""
242
+
243
+ match tag:
244
+ case TypeTag.BATCH_RESULT:
245
+ # Decode BatchResult from dict - value is already the dict structure
246
+ # First decode it as a dict to unwrap all nested EncodedValues
247
+ decoded_dict = self.decode(TypeTag.DICT, value)
248
+ return BatchResult.from_dict(decoded_dict)
249
+ case TypeTag.LIST:
250
+ if not isinstance(value, list):
251
+ msg = f"Expected list, got {type(value)}"
252
+ raise SerDesError(msg)
253
+ return [self._unwrap(v, self.dispatcher) for v in value]
254
+ case TypeTag.TUPLE:
255
+ if not isinstance(value, list):
256
+ msg = f"Expected list, got {type(value)}"
257
+ raise SerDesError(msg)
258
+ return tuple(self._unwrap(v, self.dispatcher) for v in value)
259
+ case TypeTag.DICT:
260
+ if not isinstance(value, dict):
261
+ msg = f"Expected dict, got {type(value)}"
262
+ raise SerDesError(msg)
263
+ return {k: self._unwrap(v, self.dispatcher) for k, v in value.items()}
264
+ case _:
265
+ msg = f"Unknown container tag: {tag}"
266
+ raise SerDesError(msg)
267
+
268
+ @staticmethod
269
+ def _wrap(obj: Any, dispatcher) -> EncodedValue:
270
+ """Wrap object using dispatcher."""
271
+ return dispatcher.encode(obj)
272
+
273
+ @staticmethod
274
+ def _unwrap(obj: Any, dispatcher) -> Any:
275
+ """Unwrap object using dispatcher."""
276
+ match obj:
277
+ case EncodedValue():
278
+ return dispatcher.decode(obj.tag, obj.value)
279
+ case dict() if TYPE_TOKEN in obj and VALUE_TOKEN in obj:
280
+ tag = TypeTag(obj[TYPE_TOKEN])
281
+ return dispatcher.decode(tag, obj[VALUE_TOKEN])
282
+ case _:
283
+ return obj
284
+
285
+
286
+ class TypeCodec(Codec):
287
+ """Main codec dispatcher."""
288
+
289
+ def __init__(self):
290
+ self.primitive_codec = PrimitiveCodec()
291
+ self.bytes_codec = BytesCodec()
292
+ self.uuid_codec = UuidCodec()
293
+ self.decimal_codec = DecimalCodec()
294
+ self.datetime_codec = DateTimeCodec()
295
+ self.container_codec = ContainerCodec()
296
+ self.container_codec.set_dispatcher(self)
297
+
298
+ def encode(self, obj: Any) -> EncodedValue:
299
+ match obj:
300
+ case None | str() | bool() | int() | float():
301
+ return self.primitive_codec.encode(obj)
302
+ case bytes() | bytearray() | memoryview():
303
+ return self.bytes_codec.encode(bytes(obj))
304
+ case uuid.UUID():
305
+ return self.uuid_codec.encode(obj)
306
+ case Decimal():
307
+ return self.decimal_codec.encode(obj)
308
+ case datetime() | date():
309
+ return self.datetime_codec.encode(obj)
310
+ case list() | tuple() | dict() | BatchResult():
311
+ return self.container_codec.encode(obj)
312
+ case _:
313
+ msg = f"Unsupported type: {type(obj)}"
314
+ raise SerDesError(msg)
315
+
316
+ def decode(self, tag: TypeTag, value: Any) -> Any:
317
+ match tag:
318
+ case (
319
+ TypeTag.NONE
320
+ | TypeTag.STR
321
+ | TypeTag.BOOL
322
+ | TypeTag.INT
323
+ | TypeTag.FLOAT
324
+ ):
325
+ return self.primitive_codec.decode(tag, value)
326
+ case TypeTag.BYTES:
327
+ return self.bytes_codec.decode(tag, value)
328
+ case TypeTag.UUID:
329
+ return self.uuid_codec.decode(tag, value)
330
+ case TypeTag.DECIMAL:
331
+ return self.decimal_codec.decode(tag, value)
332
+ case TypeTag.DATETIME | TypeTag.DATE:
333
+ return self.datetime_codec.decode(tag, value)
334
+ case TypeTag.LIST | TypeTag.TUPLE | TypeTag.DICT | TypeTag.BATCH_RESULT:
335
+ return self.container_codec.decode(tag, value)
336
+ case _:
337
+ msg = f"Unknown type tag: {tag}"
338
+ raise SerDesError(msg)
339
+
340
+
341
+ TYPE_CODEC = TypeCodec()
342
+
343
+
344
+ # endregion
345
+
346
+
347
+ @dataclass(frozen=True)
348
+ class SerDesContext:
349
+ """Context for serialization operations."""
350
+
351
+ operation_id: str = ""
352
+
353
+ durable_execution_arn: str = ""
354
+
355
+
356
+ class SerDes(ABC, Generic[T]):
357
+ @abstractmethod
358
+ def serialize(self, value: T, serdes_context: SerDesContext) -> str:
359
+ pass
360
+
361
+ @abstractmethod
362
+ def deserialize(self, data: str, serdes_context: SerDesContext) -> T:
363
+ pass
364
+
365
+ @staticmethod
366
+ def is_primitive(obj: Any) -> bool:
367
+ """Check if object contains only JSON-serializable primitives."""
368
+ if obj is None or isinstance(obj, str | int | float | bool):
369
+ return True
370
+ if isinstance(obj, list):
371
+ return all(SerDes.is_primitive(item) for item in obj)
372
+ return False
373
+
374
+
375
+ class PassThroughSerDes(SerDes[T]):
376
+ def serialize(self, value: T, _: SerDesContext) -> str: # noqa: PLR6301
377
+ return value # type: ignore
378
+
379
+ def deserialize(self, data: str, _: SerDesContext) -> T: # noqa: PLR6301
380
+ return data # type: ignore
381
+
382
+
383
+ class JsonSerDes(SerDes[T]):
384
+ def serialize(self, value: T, _: SerDesContext) -> str: # noqa: PLR6301
385
+ return json.dumps(value)
386
+
387
+ def deserialize(self, data: str, _: SerDesContext) -> T: # noqa: PLR6301
388
+ return json.loads(data)
389
+
390
+
391
+ class ExtendedTypeSerDes(SerDes[T]):
392
+ """Main serializer class."""
393
+
394
+ def __init__(self):
395
+ self._codec = TYPE_CODEC
396
+
397
+ def serialize(self, value: Any, context: SerDesContext | None = None) -> str: # noqa: ARG002
398
+ """Serialize value to JSON string."""
399
+ # Fast path for primitives
400
+ if SerDes.is_primitive(value):
401
+ return json.dumps(value, separators=(",", ":"))
402
+
403
+ encoded = self._codec.encode(value)
404
+ wrapped = self._to_json_serializable(encoded)
405
+ return json.dumps(wrapped, separators=(",", ":"))
406
+
407
+ def deserialize(self, data: str, context: SerDesContext | None = None) -> Any: # noqa: ARG002
408
+ """Deserialize JSON string to Python object."""
409
+ obj = json.loads(data)
410
+
411
+ # Fast path for primitives
412
+ if SerDes.is_primitive(obj):
413
+ return obj
414
+
415
+ if not (isinstance(obj, dict) and TYPE_TOKEN in obj and VALUE_TOKEN in obj):
416
+ msg = 'Malformed envelope: missing "t" or "v" at root.'
417
+ raise SerDesError(msg)
418
+ # Python 3.11 compatibility: Using try-except instead of 'in' operator
419
+ # because checking 'str in EnumType' raises TypeError in Python 3.11
420
+ try:
421
+ tag = TypeTag(obj[TYPE_TOKEN])
422
+ except ValueError:
423
+ msg = f'Unknown type tag: "{obj[TYPE_TOKEN]}"'
424
+ raise SerDesError(msg) from None
425
+
426
+ return self._codec.decode(tag, obj[VALUE_TOKEN])
427
+
428
+ def _to_json_serializable(self, obj: Any) -> Any:
429
+ """Convert EncodedValue objects to JSON-serializable format."""
430
+ match obj:
431
+ case EncodedValue():
432
+ return {
433
+ TYPE_TOKEN: obj.tag,
434
+ VALUE_TOKEN: self._to_json_serializable(obj.value),
435
+ }
436
+ case list():
437
+ return [self._to_json_serializable(x) for x in obj]
438
+ case dict():
439
+ return {k: self._to_json_serializable(v) for k, v in obj.items()}
440
+ case _:
441
+ return obj
442
+
443
+
444
+ DEFAULT_JSON_SERDES: SerDes[Any] = JsonSerDes()
445
+ EXTENDED_TYPES_SERDES: SerDes[Any] = ExtendedTypeSerDes()
446
+
447
+
448
+ def serialize(
449
+ serdes: SerDes[T] | None, value: T, operation_id: str, durable_execution_arn: str
450
+ ) -> str:
451
+ """Serialize value using provided or default serializer.
452
+
453
+ Args:
454
+ serdes: Custom serializer or None for default
455
+ value: Object to serialize
456
+ operation_id: Unique operation identifier
457
+ durable_execution_arn: ARN of durable execution
458
+
459
+ Returns:
460
+ Serialized string representation
461
+
462
+ Raises:
463
+ FatalError: If serialization fails
464
+ """
465
+ serdes_context: SerDesContext = SerDesContext(operation_id, durable_execution_arn)
466
+ active_serdes: SerDes[T] = serdes or EXTENDED_TYPES_SERDES
467
+ try:
468
+ return active_serdes.serialize(value, serdes_context)
469
+ except Exception as e:
470
+ logger.exception(
471
+ "⚠️ Serialization failed for id: %s",
472
+ operation_id,
473
+ )
474
+ msg = f"Serialization failed for id: {operation_id}, error: {e}."
475
+ raise ExecutionError(msg) from e
476
+
477
+
478
+ def deserialize(
479
+ serdes: SerDes[T] | None, data: str, operation_id: str, durable_execution_arn: str
480
+ ) -> T:
481
+ """Deserialize data using provided or default serializer.
482
+
483
+ Args:
484
+ serdes: Custom serializer or None for default
485
+ data: Serialized string data
486
+ operation_id: Unique operation identifier
487
+ durable_execution_arn: ARN of durable execution
488
+
489
+ Returns:
490
+ Deserialized Python object
491
+
492
+ Raises:
493
+ FatalError: If deserialization fails
494
+ """
495
+ serdes_context: SerDesContext = SerDesContext(operation_id, durable_execution_arn)
496
+ active_serdes: SerDes[T] = serdes or EXTENDED_TYPES_SERDES
497
+ try:
498
+ return active_serdes.deserialize(data, serdes_context)
499
+ except Exception as e:
500
+ logger.exception("⚠️ Deserialization failed for id: %s", operation_id)
501
+ msg = f"Deserialization failed for id: {operation_id}"
502
+ raise ExecutionError(msg) from e