Coverage for async_durable_execution/_core/serdes.py: 94%
456 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-08-30 23:43 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-08-30 23:43 +0000
1"""Codec-based serialization and deserialization for Python types.
3This module provides comprehensive serialization support using a codec-based
4architecture with recursive encoding/decoding for nested structures.
6Key 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
12Serialization 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
17Wire Formats:
18 Plain JSON: 42, "hello", [1, 2, 3]
19 Envelope: {"t": "<type_tag>", "v": <encoded_value>}
20"""
22from __future__ import annotations
24import base64
25import json
26import logging
27import uuid
28from abc import ABC, abstractmethod
29from collections.abc import Callable
30from dataclasses import dataclass, replace
31from datetime import date, datetime
32from decimal import Decimal
33from enum import Enum
34from typing import Any, Generic, Literal, Protocol, TypeVar, cast
36from .context import SerDesContext, bind_current_context, get_current_context
37from .exceptions import (
38 DurableExecutionsError,
39 ExecutionError,
40 RetryableSerDesError,
41 SerDesError,
42)
43from .models import OperationSubTypeValue, OperationType
45logger = logging.getLogger(__name__)
47T = TypeVar("T")
49TYPE_TOKEN: str = "t"
50VALUE_TOKEN: str = "v"
53class TypeTag(str, Enum):
54 """Type tags for envelope format."""
56 NONE = "n"
57 STR = "s"
58 INT = "i"
59 FLOAT = "f"
60 BOOL = "b"
61 BYTES = "B"
62 UUID = "u"
63 DECIMAL = "d"
64 DATETIME = "dt"
65 DATE = "D"
66 TUPLE = "t"
67 LIST = "l"
68 DICT = "m"
71@dataclass(frozen=True)
72class EncodedValue:
73 """Encoded value with type tag."""
75 tag: TypeTag | str
77 value: Any
80class Codec(Protocol):
81 """Protocol for type-specific codecs."""
83 def encode(self, obj: Any) -> EncodedValue: ...
85 def decode(self, tag: TypeTag | str, value: Any) -> Any: ...
88class TypeCodecExtension(Protocol):
89 """Extension point for types owned outside the core package."""
91 tag: str
93 def can_encode(self, obj: Any) -> bool: ...
95 def encode(
96 self,
97 obj: Any,
98 encode_value: Callable[[Any], EncodedValue],
99 ) -> Any: ...
101 def decode(
102 self,
103 value: Any,
104 decode_value: Callable[[TypeTag | str, Any], Any],
105 ) -> Any: ...
108class PrimitiveCodec:
109 """Codec for primitive types."""
111 def encode(self, obj: Any) -> EncodedValue: # noqa: PLR6301
112 match obj:
113 case None:
114 return EncodedValue(TypeTag.NONE, None)
115 case str():
116 return EncodedValue(TypeTag.STR, obj)
117 case bool(): # Must come before int
118 return EncodedValue(TypeTag.BOOL, obj)
119 case int():
120 return EncodedValue(TypeTag.INT, obj)
121 case float():
122 return EncodedValue(TypeTag.FLOAT, obj)
123 case _:
124 msg = f"Unsupported primitive type: {type(obj)!r}"
125 raise SerDesError(msg)
127 def decode(self, tag: TypeTag, value: Any) -> Any: # noqa: PLR6301
128 match tag:
129 case TypeTag.NONE:
130 return None
131 case TypeTag.STR:
132 return str(value)
133 case TypeTag.BOOL:
134 return bool(value)
135 case TypeTag.INT:
136 return int(value)
137 case TypeTag.FLOAT:
138 return float(value)
139 case _:
140 msg = f"Unknown primitive tag: {tag}"
141 raise SerDesError(msg)
144class BytesCodec:
145 """Codec for bytes, bytearray, and memoryview."""
147 def encode(self, obj: Any) -> EncodedValue: # noqa: PLR6301
148 encoded = base64.b64encode(bytes(obj)).decode("utf-8")
149 return EncodedValue(TypeTag.BYTES, encoded)
151 def decode(self, tag: TypeTag, value: Any) -> Any: # noqa: PLR6301
152 if tag != TypeTag.BYTES:
153 msg = f"Expected BYTES tag, got {tag}"
154 raise SerDesError(msg)
155 return base64.b64decode(value.encode("utf-8"))
158class UuidCodec:
159 """Codec for UUID objects."""
161 def encode(self, obj: Any) -> EncodedValue: # noqa: PLR6301
162 return EncodedValue(TypeTag.UUID, str(obj))
164 def decode(self, tag: TypeTag, value: Any) -> Any: # noqa: PLR6301
165 if tag != TypeTag.UUID:
166 msg = f"Expected UUID tag, got {tag}"
167 raise SerDesError(msg)
168 return uuid.UUID(value)
171class DecimalCodec:
172 """Codec for Decimal objects."""
174 def encode(self, obj: Any) -> EncodedValue: # noqa: PLR6301
175 return EncodedValue(TypeTag.DECIMAL, str(obj))
177 def decode(self, tag: TypeTag, value: Any) -> Any: # noqa: PLR6301
178 if tag != TypeTag.DECIMAL:
179 msg = f"Expected DECIMAL tag, got {tag}"
180 raise SerDesError(msg)
181 return Decimal(value)
184class DateTimeCodec:
185 """Codec for datetime and date objects."""
187 def encode(self, obj: Any) -> EncodedValue: # noqa: PLR6301
188 match obj:
189 case datetime():
190 return EncodedValue(TypeTag.DATETIME, obj.isoformat())
191 case date():
192 return EncodedValue(TypeTag.DATE, obj.isoformat())
193 case _:
194 msg = f"Unsupported datetime type: {type(obj)!r}"
195 raise SerDesError(msg)
197 def decode(self, tag: TypeTag, value: Any) -> Any: # noqa: PLR6301
198 match tag:
199 case TypeTag.DATETIME:
200 # Handle Z suffix for UTC
201 s = value
202 if isinstance(s, str) and s.endswith("Z"):
203 s = s[:-1] + "+00:00"
204 return datetime.fromisoformat(s)
205 case TypeTag.DATE:
206 return date.fromisoformat(value)
207 case _:
208 msg = f"Unknown datetime tag: {tag}"
209 raise SerDesError(msg)
212class ContainerCodec(Codec):
213 """Codec for container types with recursive encoding/decoding."""
215 def __init__(self) -> None:
216 self._dispatcher: TypeCodec | None = None
218 def set_dispatcher(self, dispatcher) -> None:
219 """Set the main codec dispatcher for recursive encoding."""
220 self._dispatcher = dispatcher
222 @property
223 def dispatcher(self) -> TypeCodec:
224 """Get the dispatcher, raising error if not set."""
225 if self._dispatcher is None:
226 msg = "ContainerCodec not linked to a TypeCodec dispatcher."
227 raise DurableExecutionsError(msg)
228 return self._dispatcher
230 def encode(self, obj: Any) -> EncodedValue:
231 """Encode container using dispatcher for recursive elements."""
233 match obj:
234 case list():
235 return EncodedValue(
236 TypeTag.LIST, [self._wrap(v, self.dispatcher) for v in obj]
237 )
238 case tuple():
239 return EncodedValue(
240 TypeTag.TUPLE, [self._wrap(v, self.dispatcher) for v in obj]
241 )
242 case dict():
243 for k in obj:
244 if isinstance(k, tuple):
245 msg = "Tuple keys not supported"
246 raise SerDesError(msg)
247 return EncodedValue(
248 TypeTag.DICT,
249 {k: self._wrap(v, self.dispatcher) for k, v in obj.items()},
250 )
251 case _:
252 msg = f"Unsupported container type: {type(obj)!r}"
253 raise SerDesError(msg)
255 def decode(self, tag: TypeTag | str, value: Any) -> Any:
256 """Decode container using dispatcher for recursive elements."""
258 match tag:
259 case TypeTag.LIST:
260 if not isinstance(value, list):
261 msg = f"Expected list, got {type(value)}"
262 raise SerDesError(msg)
263 return [self._unwrap(v, self.dispatcher) for v in value]
264 case TypeTag.TUPLE:
265 if not isinstance(value, list):
266 msg = f"Expected list, got {type(value)}"
267 raise SerDesError(msg)
268 return tuple(self._unwrap(v, self.dispatcher) for v in value)
269 case TypeTag.DICT:
270 if not isinstance(value, dict):
271 msg = f"Expected dict, got {type(value)}"
272 raise SerDesError(msg)
273 return {k: self._unwrap(v, self.dispatcher) for k, v in value.items()}
274 case _:
275 msg = f"Unknown container tag: {tag}"
276 raise SerDesError(msg)
278 @staticmethod
279 def _wrap(obj: Any, dispatcher) -> EncodedValue:
280 """Wrap object using dispatcher."""
281 return dispatcher.encode(obj)
283 @staticmethod
284 def _unwrap(obj: Any, dispatcher) -> Any:
285 """Unwrap object using dispatcher."""
286 match obj:
287 case EncodedValue():
288 return dispatcher.decode(obj.tag, obj.value)
289 case dict() if TYPE_TOKEN in obj and VALUE_TOKEN in obj:
290 return dispatcher.decode(obj[TYPE_TOKEN], obj[VALUE_TOKEN])
291 case _:
292 return obj
295class TypeCodec(Codec):
296 """Main codec dispatcher."""
298 def __init__(
299 self,
300 extensions: tuple[TypeCodecExtension, ...] = (),
301 ) -> None:
302 built_in_tags = {tag.value for tag in TypeTag}
303 extension_tags = [extension.tag for extension in extensions]
304 if len(extension_tags) != len(set(extension_tags)): 304 ↛ 305line 304 didn't jump to line 305 because the condition on line 304 was never true
305 msg = "Type codec extension tags must be unique."
306 raise ValueError(msg)
307 if built_in_tags.intersection(extension_tags): 307 ↛ 308line 307 didn't jump to line 308 because the condition on line 307 was never true
308 msg = "Type codec extension tags cannot replace built-in tags."
309 raise ValueError(msg)
311 self.extensions = extensions
312 self.extensions_by_tag = {extension.tag: extension for extension in extensions}
313 self.primitive_codec = PrimitiveCodec()
314 self.bytes_codec = BytesCodec()
315 self.uuid_codec = UuidCodec()
316 self.decimal_codec = DecimalCodec()
317 self.datetime_codec = DateTimeCodec()
318 self.container_codec = ContainerCodec()
319 self.container_codec.set_dispatcher(self)
321 def encode(self, obj: Any) -> EncodedValue:
322 for extension in self.extensions:
323 if extension.can_encode(obj):
324 return EncodedValue(
325 extension.tag,
326 extension.encode(obj, self.encode),
327 )
329 match obj:
330 case None | str() | bool() | int() | float():
331 return self.primitive_codec.encode(obj)
332 case bytes() | bytearray() | memoryview():
333 return self.bytes_codec.encode(bytes(obj))
334 case uuid.UUID():
335 return self.uuid_codec.encode(obj)
336 case Decimal():
337 return self.decimal_codec.encode(obj)
338 case datetime() | date():
339 return self.datetime_codec.encode(obj)
340 case list() | tuple() | dict():
341 return self.container_codec.encode(obj)
342 case _:
343 msg = f"Unsupported type: {type(obj)}"
344 raise SerDesError(msg)
346 def decode(self, tag: TypeTag | str, value: Any) -> Any:
347 tag_value = tag.value if isinstance(tag, TypeTag) else tag
348 extension = self.extensions_by_tag.get(tag_value)
349 if extension is not None:
350 return extension.decode(value, self.decode)
352 try:
353 built_in_tag = TypeTag(tag_value)
354 except (TypeError, ValueError):
355 msg = f"Unknown type tag: {tag}"
356 raise SerDesError(msg) from None
358 match built_in_tag:
359 case (
360 TypeTag.NONE | TypeTag.STR | TypeTag.BOOL | TypeTag.INT | TypeTag.FLOAT
361 ):
362 return self.primitive_codec.decode(built_in_tag, value)
363 case TypeTag.BYTES:
364 return self.bytes_codec.decode(built_in_tag, value)
365 case TypeTag.UUID:
366 return self.uuid_codec.decode(built_in_tag, value)
367 case TypeTag.DECIMAL:
368 return self.decimal_codec.decode(built_in_tag, value)
369 case TypeTag.DATETIME | TypeTag.DATE:
370 return self.datetime_codec.decode(built_in_tag, value)
371 case TypeTag.LIST | TypeTag.TUPLE | TypeTag.DICT: 371 ↛ 373line 371 didn't jump to line 373 because the pattern on line 371 always matched
372 return self.container_codec.decode(built_in_tag, value)
373 case _:
374 msg = f"Unknown type tag: {tag}"
375 raise SerDesError(msg)
377 def has_tag(self, tag: Any) -> bool:
378 """Return whether a built-in or extension codec owns a tag."""
379 if not isinstance(tag, str): 379 ↛ 380line 379 didn't jump to line 380 because the condition on line 379 was never true
380 return False
381 return tag in self.extensions_by_tag or tag in {item.value for item in TypeTag}
384TYPE_CODEC = TypeCodec()
387def get_serdes_context() -> SerDesContext:
388 """Return the active `SerDesContext`."""
389 current_context = get_current_context()
390 if not isinstance(current_context, SerDesContext):
391 msg = (
392 "get_serdes_context() can only be used while a SerDes operation "
393 "is executing."
394 )
395 raise RuntimeError(msg)
396 return current_context
399class SerDes(ABC, Generic[T]):
400 """Abstract serializer interface for durable operation payloads and results."""
402 @abstractmethod
403 async def serialize(self, value: T) -> str:
404 """Convert a Python value into the wire format stored by the SDK."""
405 pass
407 @abstractmethod
408 async def deserialize(self, data: str) -> T:
409 """Reconstruct a Python value from the durable wire format."""
410 pass
412 def then(self, stage: SerDesStage) -> ComposableSerDes[T]:
413 """Return an immutable pipeline with ``stage`` appended."""
414 return ComposableSerDes(self, (stage,))
416 @staticmethod
417 def is_primitive(obj: Any) -> bool:
418 """Check if object contains only JSON-serializable primitives."""
419 if obj is None or isinstance(obj, str | int | float | bool):
420 return True
421 if isinstance(obj, list):
422 return all(SerDes.is_primitive(item) for item in obj)
423 return False
426class SerDesStage(Protocol):
427 """A reversible, self-identifying string transformation.
429 During deserialization, a stage must reverse recognized valid input,
430 reject recognized malformed or unsupported input, and return unrecognized
431 input unchanged.
432 """
434 async def serialize(self, value: str, context: SerDesContext) -> str:
435 """Transform the preceding pipeline stage's string."""
436 ...
438 async def deserialize(self, data: str, context: SerDesContext) -> str:
439 """Reverse this stage or return unrecognized input unchanged."""
440 ...
443class SerDesPipelineError(SerDesError):
444 """Identifies the pipeline component and action that failed."""
446 def __init__(
447 self,
448 stage_index: int,
449 action: Literal["serialize", "deserialize"],
450 stage: object,
451 ) -> None:
452 self.stage_index = stage_index
453 self.action = action
454 self.stage = stage
455 component_name = f"{type(stage).__module__}.{type(stage).__qualname__}"
456 super().__init__(
457 f"SerDes pipeline stage {stage_index} ({component_name}) failed to {action}"
458 )
461class ComposableSerDes(SerDes[T]):
462 """Immutable SerDes pipeline with one value codec and string stages."""
464 def __init__(
465 self,
466 value_codec: SerDes[T],
467 stages: tuple[SerDesStage, ...] = (),
468 ) -> None:
469 if value_codec is None: 469 ↛ 470line 469 didn't jump to line 470 because the condition on line 469 was never true
470 msg = "value_codec must not be None."
471 raise TypeError(msg)
472 if any(stage is None for stage in stages):
473 msg = "pipeline stages must not be None."
474 raise TypeError(msg)
475 if isinstance(value_codec, ComposableSerDes):
476 self._value_codec = value_codec.value_codec
477 self._stages = (*value_codec.stages, *stages)
478 else:
479 self._value_codec = value_codec
480 self._stages = stages
482 @property
483 def value_codec(self) -> SerDes[T]:
484 """Return the root value codec."""
485 return self._value_codec
487 @property
488 def stages(self) -> tuple[SerDesStage, ...]:
489 """Return the ordered immutable string stages."""
490 return self._stages
492 def then(self, stage: SerDesStage) -> ComposableSerDes[T]:
493 """Return a new pipeline with ``stage`` appended."""
494 if stage is None: 494 ↛ 495line 494 didn't jump to line 495 because the condition on line 494 was never true
495 msg = "stage must not be None."
496 raise TypeError(msg)
497 return ComposableSerDes(self._value_codec, (*self._stages, stage))
499 async def serialize(self, value: T) -> str:
500 context = _current_or_empty_serdes_context()
501 stage_context = replace(context, original_value=value)
502 current = await self._invoke_value_codec_serialize(value)
503 for index, stage in enumerate(self._stages, start=1):
504 try:
505 current = await stage.serialize(current, stage_context)
506 if not isinstance(current, str):
507 msg = "Stage returned a non-string value."
508 raise TypeError(msg)
509 except RetryableSerDesError as error:
510 raise RetryableSerDesError(
511 _pipeline_failure_message(index, "serialize", stage)
512 ) from error
513 except Exception as error:
514 raise SerDesPipelineError(index, "serialize", stage) from error
515 return current
517 async def deserialize(self, data: str) -> T:
518 context = _current_or_empty_serdes_context()
519 stage_context = replace(context, original_value=None)
520 current = data
521 for index in range(len(self._stages) - 1, -1, -1):
522 stage = self._stages[index]
523 try:
524 current = await stage.deserialize(current, stage_context)
525 if not isinstance(current, str): 525 ↛ 526line 525 didn't jump to line 526 because the condition on line 525 was never true
526 msg = "Stage returned a non-string value."
527 raise TypeError(msg)
528 except RetryableSerDesError as error:
529 raise RetryableSerDesError(
530 _pipeline_failure_message(index + 1, "deserialize", stage)
531 ) from error
532 except Exception as error:
533 raise SerDesPipelineError(
534 index + 1,
535 "deserialize",
536 stage,
537 ) from error
538 return await self._invoke_value_codec_deserialize(current)
540 async def _invoke_value_codec_serialize(self, value: T) -> str:
541 try:
542 result = await self._value_codec.serialize(value)
543 if not isinstance(result, str): 543 ↛ 544line 543 didn't jump to line 544 because the condition on line 543 was never true
544 msg = "Value codec returned a non-string value."
545 raise TypeError(msg)
546 return result
547 except RetryableSerDesError as error:
548 raise RetryableSerDesError(
549 _pipeline_failure_message(0, "serialize", self._value_codec)
550 ) from error
551 except Exception as error:
552 raise SerDesPipelineError(0, "serialize", self._value_codec) from error
554 async def _invoke_value_codec_deserialize(self, data: str) -> T:
555 try:
556 return await self._value_codec.deserialize(data)
557 except RetryableSerDesError as error:
558 raise RetryableSerDesError(
559 _pipeline_failure_message(0, "deserialize", self._value_codec)
560 ) from error
561 except Exception as error:
562 raise SerDesPipelineError(0, "deserialize", self._value_codec) from error
565def create_serdes_pipeline(
566 value_codec: SerDes[T],
567 *stages: SerDesStage,
568) -> ComposableSerDes[T]:
569 """Create an immutable value-codec and string-stage pipeline."""
570 if value_codec is None:
571 msg = "value_codec must not be None."
572 raise TypeError(msg)
573 if any(stage is None for stage in stages): 573 ↛ 574line 573 didn't jump to line 574 because the condition on line 573 was never true
574 msg = "pipeline stages must not be None."
575 raise TypeError(msg)
576 return ComposableSerDes(value_codec, tuple(stages))
579def is_composable_serdes(serdes: SerDes[Any]) -> bool:
580 """Return whether ``serdes`` is an SDK composable pipeline."""
581 return isinstance(serdes, ComposableSerDes)
584def _current_or_empty_serdes_context() -> SerDesContext:
585 try:
586 return get_serdes_context()
587 except RuntimeError:
588 return SerDesContext()
591def _pipeline_failure_message(
592 stage_index: int,
593 action: Literal["serialize", "deserialize"],
594 stage: object,
595) -> str:
596 component_name = f"{type(stage).__module__}.{type(stage).__qualname__}"
597 return f"SerDes pipeline stage {stage_index} ({component_name}) failed to {action}"
600class PassThroughSerDes(SerDes[T]):
601 """Serializer that leaves already-serialized string payloads unchanged."""
603 async def serialize(self, value: T) -> str: # noqa: PLR6301
604 return cast("str", value)
606 async def deserialize(self, data: str) -> T: # noqa: PLR6301
607 return cast("T", data)
610class JsonSerDes(SerDes[T]):
611 """Serializer that uses the standard library `json` module."""
613 async def serialize(self, value: T) -> str: # noqa: PLR6301
614 return json.dumps(value)
616 async def deserialize(self, data: str) -> T: # noqa: PLR6301
617 return json.loads(data)
620class ExtendedTypeSerDes(SerDes[T]):
621 """Main serializer class."""
623 def __init__(
624 self,
625 type_codecs: tuple[TypeCodecExtension, ...] = (),
626 ) -> None:
627 self._codec = TypeCodec(type_codecs) if type_codecs else TYPE_CODEC
629 async def serialize(self, value: Any) -> str:
630 """Serialize value to JSON string."""
631 return self.serialize_sync(value)
633 async def deserialize(self, data: str) -> Any:
634 """Deserialize JSON string to Python object."""
635 return self.deserialize_sync(data)
637 def serialize_sync(self, value: Any) -> str:
638 """Serialize value to JSON string without awaiting."""
639 # Fast path for primitives
640 if SerDes.is_primitive(value):
641 return json.dumps(value, separators=(",", ":"))
643 self._check_circular_references(value)
644 encoded = self._codec.encode(value)
645 wrapped = self._to_json_serializable(encoded)
646 return json.dumps(wrapped, separators=(",", ":"))
648 def deserialize_sync(self, data: str) -> Any:
649 """Deserialize JSON string to Python object without awaiting."""
650 obj = json.loads(data)
652 # Fast path for primitives
653 if SerDes.is_primitive(obj):
654 return obj
656 if not (isinstance(obj, dict) and TYPE_TOKEN in obj and VALUE_TOKEN in obj):
657 msg = 'Malformed envelope: missing "t" or "v" at root.'
658 raise SerDesError(msg)
659 tag = obj[TYPE_TOKEN]
660 if not self._codec.has_tag(tag):
661 msg = f'Unknown type tag: "{obj[TYPE_TOKEN]}"'
662 raise SerDesError(msg)
664 return self._codec.decode(tag, obj[VALUE_TOKEN])
666 def _to_json_serializable(self, obj: Any) -> Any:
667 """Convert EncodedValue objects to JSON-serializable format."""
668 match obj:
669 case EncodedValue():
670 return {
671 TYPE_TOKEN: (
672 obj.tag.value if isinstance(obj.tag, TypeTag) else obj.tag
673 ),
674 VALUE_TOKEN: self._to_json_serializable(obj.value),
675 }
676 case list():
677 return [self._to_json_serializable(x) for x in obj]
678 case dict():
679 return {k: self._to_json_serializable(v) for k, v in obj.items()}
680 case _:
681 return obj
683 def _check_circular_references(
684 self, obj: Any, seen: set[int] | None = None
685 ) -> None:
686 """Reject circular containers before recursive encoding."""
687 if not isinstance(obj, (dict, list, tuple)):
688 return
690 if seen is None:
691 seen = set()
693 obj_id = id(obj)
694 if obj_id in seen:
695 msg = "Circular references are not supported"
696 raise SerDesError(msg)
698 seen.add(obj_id)
699 try:
700 values = obj.values() if isinstance(obj, dict) else obj
701 for value in values:
702 self._check_circular_references(value, seen)
703 finally:
704 seen.remove(obj_id)
707DEFAULT_JSON_SERDES: SerDes[Any] = JsonSerDes()
708EXTENDED_TYPES_SERDES: SerDes[Any] = ExtendedTypeSerDes()
711async def serialize(
712 serdes: SerDes[T] | None,
713 value: T,
714 operation_id: str,
715 durable_execution_arn: str,
716 recursive_level: int = 0,
717 *,
718 entity_id: str | None = None,
719 operation_name: str | None = None,
720 parent_id: str | None = None,
721 operation_type: OperationType | None = None,
722 operation_sub_type: OperationSubTypeValue | None = None,
723 attempt: int | None = None,
724) -> str:
725 """Serialize value using provided or default serializer.
727 Args:
728 serdes: Custom serializer or None for default
729 value: Object to serialize
730 operation_id: Unique operation identifier
731 durable_execution_arn: ARN of durable execution
733 Returns:
734 Serialized string representation
736 Raises:
737 FatalError: If serialization fails
738 """
739 serdes_context: SerDesContext = SerDesContext(
740 operation_id,
741 durable_execution_arn,
742 recursive_level,
743 entity_id=entity_id or f"operation/{operation_id}",
744 operation_name=operation_name,
745 parent_id=parent_id,
746 operation_type=operation_type,
747 operation_sub_type=operation_sub_type,
748 attempt=attempt,
749 )
750 active_serdes: SerDes[T] = serdes or EXTENDED_TYPES_SERDES
752 async def serialize_value() -> str:
753 return await active_serdes.serialize(value)
755 try:
756 with bind_current_context(serdes_context):
757 return await serialize_value()
758 except RetryableSerDesError:
759 logger.exception(
760 "⚠️ Retryable serialization failure for id: %s",
761 operation_id,
762 )
763 raise
764 except Exception as e:
765 logger.exception(
766 "⚠️ Serialization failed for id: %s",
767 operation_id,
768 )
769 msg = f"Serialization failed for id: {operation_id}, error: {e}."
770 raise ExecutionError(msg) from e
773async def deserialize(
774 serdes: SerDes[T] | None,
775 data: str,
776 operation_id: str,
777 durable_execution_arn: str,
778 recursive_level: int = 0,
779 *,
780 entity_id: str | None = None,
781 operation_name: str | None = None,
782 parent_id: str | None = None,
783 operation_type: OperationType | None = None,
784 operation_sub_type: OperationSubTypeValue | None = None,
785 attempt: int | None = None,
786) -> T:
787 """Deserialize data using provided or default serializer.
789 Args:
790 serdes: Custom serializer or None for default
791 data: Serialized string data
792 operation_id: Unique operation identifier
793 durable_execution_arn: ARN of durable execution
795 Returns:
796 Deserialized Python object
798 Raises:
799 FatalError: If deserialization fails
800 """
801 serdes_context: SerDesContext = SerDesContext(
802 operation_id,
803 durable_execution_arn,
804 recursive_level,
805 entity_id=entity_id or f"operation/{operation_id}",
806 operation_name=operation_name,
807 parent_id=parent_id,
808 operation_type=operation_type,
809 operation_sub_type=operation_sub_type,
810 attempt=attempt,
811 )
812 active_serdes: SerDes[T] = serdes or EXTENDED_TYPES_SERDES
814 async def deserialize_value() -> T:
815 return await active_serdes.deserialize(data)
817 try:
818 with bind_current_context(serdes_context):
819 return await deserialize_value()
820 except RetryableSerDesError:
821 logger.exception(
822 "⚠️ Retryable deserialization failure for id: %s",
823 operation_id,
824 )
825 raise
826 except Exception as e:
827 logger.exception("⚠️ Deserialization failed for id: %s", operation_id)
828 msg = f"Deserialization failed for id: {operation_id}"
829 raise ExecutionError(msg) from e