Skip to content

Commit 15c29eb

Browse files
Bernd VerstCopilot
andcommitted
Tighten _to_serializable docs and drop casts that were not load-bearing
The docstring promised a "JSON-safe structure", which the function does not guarantee: values reached through dict[str, Any] fields keep their original behavior, and a few shapes -- a tuple holding a datetime, for one -- were never JSON-encodable. Say that plainly rather than implying every output can be encoded. The list and dict branches test value_type rather than the value, so the value is never narrowed and iterating it directly is already clean under strict type checking. The casts there did nothing but call typing.cast on every container. The cast on the type lookup is load-bearing and now says so. type(value) is type[Unknown] to a checker because value is Any. Two details that look like noise are deliberate: the quoted annotation avoids building a throwaway GenericAlias per call, and the lookup stays type(value) rather than the cheaper value.__class__ because asdict dispatched on type(obj). Dispatching on __class__ would let an object claiming to be a str take the JSON-native fast path and be returned by reference, where the original pipeline deep-copied it. A regression test pins that, and fails if the swap is made. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6322dc7f-81ee-4d42-be2d-cef84cf62d15
1 parent e31bb48 commit 15c29eb

2 files changed

Lines changed: 66 additions & 3 deletions

File tree

‎durabletask/history.py‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -389,7 +389,13 @@ def _legacy_compat(value: Any) -> Any:
389389

390390

391391
def _to_serializable(value: Any) -> Any:
392-
"""Recursively convert *value* into a JSON-safe structure.
392+
"""Recursively convert *value* into the form history export writes.
393+
394+
Values the SDK itself produces convert to JSON-native types. Arbitrary
395+
values can also arrive through ``dict[str, Any]`` fields, and those keep
396+
whatever the original pipeline did with them -- which for a few shapes,
397+
such as a tuple holding a ``datetime``, is not JSON-encodable. That is
398+
preserved on purpose rather than fixed here; see :func:`_legacy_compat`.
393399
394400
This walks dataclass instances directly instead of going through
395401
``dataclasses.asdict``, which would deep-copy the whole event graph
@@ -404,6 +410,14 @@ def _to_serializable(value: Any) -> Any:
404410
so their original semantics -- constructor round-trips, key
405411
recursion and deep-copied leaves -- are preserved exactly.
406412
"""
413+
# ``type(value)`` is ``type[Unknown]`` to a type checker because *value*
414+
# is ``Any``, so the cast is what keeps this module clean under strict
415+
# checking. Two details are deliberate: the annotation is quoted, since
416+
# an unquoted ``type[Any]`` is evaluated on every call and builds a
417+
# throwaway ``types.GenericAlias``; and the lookup is ``type(value)``
418+
# rather than the cheaper ``value.__class__``, because ``asdict`` used
419+
# ``type(obj)`` and an object overriding ``__class__`` would otherwise
420+
# be dispatched differently than it was before.
407421
value_type = cast('type[Any]', type(value))
408422
if value_type in _JSON_NATIVE_TYPES:
409423
return value
@@ -417,15 +431,15 @@ def _to_serializable(value: Any) -> Any:
417431
if value_type is datetime:
418432
return value.isoformat()
419433
if value_type is list:
420-
return [_to_serializable(item) for item in cast(list[Any], value)]
434+
return [_to_serializable(item) for item in value]
421435
if value_type is dict:
422436
# ``asdict`` recursed into keys but the conversion pass that followed
423437
# it did not, so keys get ``asdict`` semantics only. Native keys are
424438
# returned as-is because ``asdict`` leaves those untouched too.
425439
return {
426440
(key if type(key) in _JSON_NATIVE_TYPES else _asdict_only(key)):
427441
_to_serializable(item)
428-
for key, item in cast(dict[Any, Any], value).items()
442+
for key, item in value.items()
429443
}
430444
return _legacy_compat(value)
431445

‎tests/durabletask/test_history_serialization.py‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -582,6 +582,34 @@ def __eq__(self, other: object) -> bool:
582582
return isinstance(other, _DeepCopyKey) and other.name == self.name
583583

584584

585+
class _ClassSpoofer:
586+
"""Reports a JSON-native ``__class__`` while being no such thing.
587+
588+
``asdict`` dispatched on ``type(obj)``, which cannot be overridden, so
589+
this reached the deep-copy fallback. Anything that dispatches on
590+
``__class__`` instead would mistake it for a ``str`` and hand back the
591+
original object untouched.
592+
"""
593+
594+
def __init__(self, tag: str) -> None:
595+
self.tag = tag
596+
597+
@property
598+
def __class__(self) -> Any: # type: ignore[override]
599+
return str
600+
601+
def __eq__(self, other: object) -> bool:
602+
# Compares on ``type`` rather than ``isinstance`` because the
603+
# spoofed ``__class__`` makes ``isinstance`` checks ambiguous here.
604+
return type(other) is _ClassSpoofer and other.tag == self.tag
605+
606+
def __hash__(self) -> int:
607+
return hash(self.tag)
608+
609+
def __repr__(self) -> str:
610+
return f'_ClassSpoofer({self.tag!r})'
611+
612+
585613
_EXOTIC_PAYLOADS = [
586614
pytest.param(_OddIsoDatetime(2024, 1, 2, 3, 4, 5), id='datetime-subclass'),
587615
pytest.param(
@@ -608,6 +636,7 @@ def __eq__(self, other: object) -> bool:
608636
pytest.param(bytearray(b'abc'), id='bytearray'),
609637
pytest.param(b'abc', id='bytes'),
610638
pytest.param(_MutableLeaf(1), id='custom-object'),
639+
pytest.param(_ClassSpoofer('t'), id='class-spoofing-object'),
611640
]
612641

613642

@@ -770,6 +799,26 @@ def test_mapping_key_is_not_converted_by_the_value_walker(self) -> None:
770799

771800
assert list(exported) == [_TS]
772801

802+
def test_dispatch_uses_type_not_the_overridable_class_attribute(self) -> None:
803+
"""Guards a tempting micro-optimization that would change behavior.
804+
805+
``value.__class__`` is measurably cheaper than ``type(value)`` and
806+
satisfies a type checker without a cast, so it is an easy swap to
807+
make. It is also wrong here: ``__class__`` can be overridden, and a
808+
value claiming to be a ``str`` would take the JSON-native fast path
809+
and be returned by reference. ``asdict`` dispatched on
810+
``type(obj)``, so it deep-copied this instead.
811+
"""
812+
spoofer = _ClassSpoofer('t')
813+
event = _state_event(spoofer)
814+
815+
exported = event.to_dict()['orchestration_state']['value']
816+
817+
assert exported is not spoofer, 'fast path taken on a spoofed __class__'
818+
assert type(exported) is _ClassSpoofer
819+
assert exported.tag == 't'
820+
assert _legacy_to_dict(event)['orchestration_state']['value'] is not spoofer
821+
773822

774823
class TestTupleHandling:
775824
"""Tuples keep their original ``asdict`` semantics.

0 commit comments

Comments
 (0)