From 52d6a60d3adf1b3ee01421be578493a584db27e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AD=90=E6=B6=B5=E7=9A=84=E4=BB=A3=E7=A0=81=E6=97=A5?= =?UTF-8?q?=E8=AE=B0?= Date: Fri, 18 Sep 2026 15:05:38 +0800 Subject: [PATCH] fix(core): round-trip dicts that carry the serde marker key serialize_dict passed an ordinary dict through unchanged, so a dict whose own keys include __burr_serde__ was read back as a serde envelope. deserialize then raised "No deserializer registered for key", which surfaced through State.deserialize as a state field that cannot be restored. The key is not documented as reserved. Wrap such a dict in an envelope that restores it verbatim; every other dict keeps its serialized form, and real envelopes still dispatch (or still raise the informative error when their module was not imported). --- burr/core/serde.py | 18 +++++++++++++++++- tests/core/test_serde.py | 32 +++++++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/burr/core/serde.py b/burr/core/serde.py index effdd892b..2d89b74e9 100644 --- a/burr/core/serde.py +++ b/burr/core/serde.py @@ -19,6 +19,13 @@ from typing import Any, Union KEY = "__burr_serde__" +# Marker used to wrap an ordinary dictionary that itself contains KEY. Without +# it, such a dictionary is indistinguishable from a serde envelope and +# `deserialize` fails -- or dispatches to an unrelated deserializer -- when the +# value is read back. +ESCAPED_DICT = "burr.dict" +# Key an escaped dictionary stores its contents under. +PAYLOAD_KEY = "value" class StringDispatch: @@ -116,7 +123,16 @@ def serialize_primitive(value, **kwargs) -> Union[str, int, float, bool]: @serialize.register(dict) def serialize_dict(value: dict, **kwargs) -> dict[str, Any]: - return {k: serialize(v, **kwargs) for k, v in value.items()} + serialized = {k: serialize(v, **kwargs) for k, v in value.items()} + if KEY in value: + return {KEY: ESCAPED_DICT, PAYLOAD_KEY: serialized} + return serialized + + +@deserializer.register(ESCAPED_DICT) +def deserialize_escaped_dict(value: dict, **kwargs) -> dict[str, Any]: + """Deserializes an ordinary dictionary that carries the serde marker key.""" + return {k: deserialize(v, **kwargs) for k, v in value[PAYLOAD_KEY].items()} @serialize.register(list) diff --git a/tests/core/test_serde.py b/tests/core/test_serde.py index 302a1cdce..26183ab93 100644 --- a/tests/core/test_serde.py +++ b/tests/core/test_serde.py @@ -17,7 +17,8 @@ import pytest -from burr.core.serde import StringDispatch, deserialize, serialize +from burr.core import State +from burr.core.serde import KEY, StringDispatch, deserialize, serialize def test_serialize_primitive_types(): @@ -74,3 +75,32 @@ def test_string_dispatch_no_key_informative_message(): assert "nonexistent_key" in str(exc_info.value) assert "known_key" in str(exc_info.value) assert "imported" in str(exc_info.value) + + +def test_dict_with_serde_key_round_trips(): + """An ordinary dict carrying the serde marker must survive a round trip. + + It used to be read back as a serde envelope, which raised "No deserializer + registered for key" instead of returning the value. + """ + value = {KEY: "hello", "nested": {KEY: {"deep": 1}}, "list": [{KEY: 1}]} + + assert deserialize(serialize(value)) == value + + +def test_state_with_serde_key_round_trips(): + """State containing such a dict deserializes instead of failing.""" + state = State({"payload": {KEY: "hello", "count": 2}}) + + restored = State.deserialize(state.serialize()) + + assert restored["payload"] == {KEY: "hello", "count": 2} + + +def test_envelope_without_imported_deserializer_still_raises(): + """A real envelope whose module was not imported keeps its helpful error.""" + with pytest.raises(ValueError) as exc_info: + deserialize({KEY: "some.serde.that.is.not.imported"}) + + assert "some.serde.that.is.not.imported" in str(exc_info.value) + assert "imported" in str(exc_info.value)