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.
- aws_durable_execution_sdk_python/.gitignore +0 -0
- aws_durable_execution_sdk_python/__about__.py +4 -0
- aws_durable_execution_sdk_python/__init__.py +34 -0
- aws_durable_execution_sdk_python/botocore/data/lambdainternal/2015-03-31/service-2.json +7864 -0
- aws_durable_execution_sdk_python/concurrency/__init__.py +0 -0
- aws_durable_execution_sdk_python/concurrency/executor.py +436 -0
- aws_durable_execution_sdk_python/concurrency/models.py +469 -0
- aws_durable_execution_sdk_python/config.py +499 -0
- aws_durable_execution_sdk_python/context.py +551 -0
- aws_durable_execution_sdk_python/exceptions.py +374 -0
- aws_durable_execution_sdk_python/execution.py +428 -0
- aws_durable_execution_sdk_python/identifier.py +14 -0
- aws_durable_execution_sdk_python/lambda_service.py +1034 -0
- aws_durable_execution_sdk_python/logger.py +131 -0
- aws_durable_execution_sdk_python/operation/__init__.py +1 -0
- aws_durable_execution_sdk_python/operation/callback.py +123 -0
- aws_durable_execution_sdk_python/operation/child.py +162 -0
- aws_durable_execution_sdk_python/operation/invoke.py +119 -0
- aws_durable_execution_sdk_python/operation/map.py +137 -0
- aws_durable_execution_sdk_python/operation/parallel.py +122 -0
- aws_durable_execution_sdk_python/operation/step.py +269 -0
- aws_durable_execution_sdk_python/operation/wait.py +53 -0
- aws_durable_execution_sdk_python/operation/wait_for_condition.py +235 -0
- aws_durable_execution_sdk_python/py.typed +1 -0
- aws_durable_execution_sdk_python/retries.py +174 -0
- aws_durable_execution_sdk_python/serdes.py +502 -0
- aws_durable_execution_sdk_python/state.py +790 -0
- aws_durable_execution_sdk_python/suspend.py +84 -0
- aws_durable_execution_sdk_python/threading.py +222 -0
- aws_durable_execution_sdk_python/types.py +180 -0
- aws_durable_execution_sdk_python/waits.py +130 -0
- aws_durable_execution_sdk_python-1.0.0.dist-info/METADATA +679 -0
- aws_durable_execution_sdk_python-1.0.0.dist-info/RECORD +36 -0
- aws_durable_execution_sdk_python-1.0.0.dist-info/WHEEL +4 -0
- aws_durable_execution_sdk_python-1.0.0.dist-info/licenses/LICENSE +175 -0
- 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
|