From 7248a56cf36156b02f685b4a964a0f4eacdd707e Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Wed, 23 Aug 2023 12:25:42 -0700 Subject: [PATCH] fix dumping of empty YAMLObject on Python 3.11+ * translate __getstate__ returning `None` to empty mapping (arguably wrong, but backward-compatibie) * adds constructor/representer tests to exercise this case --- lib/yaml/constructor.py | 1 + lib/yaml/representer.py | 12 ++++++++++++ tests/data/construct-python-object.code | 1 + tests/data/construct-python-object.data | 1 + tests/lib/test_constructor.py | 14 +++++++++++++- 5 files changed, 28 insertions(+), 1 deletion(-) diff --git a/lib/yaml/constructor.py b/lib/yaml/constructor.py index 619acd30..14d31f75 100644 --- a/lib/yaml/constructor.py +++ b/lib/yaml/constructor.py @@ -416,6 +416,7 @@ def construct_yaml_map(self, node): def construct_yaml_object(self, node, cls): data = cls.__new__(cls) yield data + # FIXME: __getstate__/__setstate__ support non-mapping state if hasattr(data, '__setstate__'): state = self.construct_mapping(node, deep=True) data.__setstate__(state) diff --git a/lib/yaml/representer.py b/lib/yaml/representer.py index 808ca06d..782e2f0f 100644 --- a/lib/yaml/representer.py +++ b/lib/yaml/representer.py @@ -224,7 +224,19 @@ def represent_yaml_object(self, tag, data, cls, flow_style=None): if hasattr(data, '__getstate__'): state = data.__getstate__() else: + # FIXME: this isn't always possible (eg, __slots__) state = data.__dict__.copy() + + # fix for https://github.com/yaml/pyyaml/issues/692 + # __getstate__() has always supported non-mapping state blobs, and as of Python 3.11 returns None by default for + # objects with no instance attrs. Ideally, we'd attempt to serialize and round-trip a null scalar for full + # fidelity with the types supported by __getstate__/__setstate__, but we can't be guaranteed that it'll be + # interpreted as such. For compatibility with existing behavior on older Pythons, we'll continue to represent + # this case as an empty mapping. + # (see https://github.com/python/cpython/issues/70766 for a specific case) + if state is None: + state = {} + return self.represent_mapping(tag, state, flow_style=flow_style) def represent_undefined(self, data): diff --git a/tests/data/construct-python-object.code b/tests/data/construct-python-object.code index 9e611e43..4ffd44a2 100644 --- a/tests/data/construct-python-object.code +++ b/tests/data/construct-python-object.code @@ -1,4 +1,5 @@ [ +EmptyObject(), AnObject(1, 'two', [3,3,3]), AnInstance(1, 'two', [3,3,3]), diff --git a/tests/data/construct-python-object.data b/tests/data/construct-python-object.data index 66797e4c..23d66a60 100644 --- a/tests/data/construct-python-object.data +++ b/tests/data/construct-python-object.data @@ -1,3 +1,4 @@ +- !!python/object:test_constructor.EmptyObject {} - !!python/object:test_constructor.AnObject { foo: 1, bar: two, baz: [3,3,3] } - !!python/object:test_constructor.AnInstance { foo: 1, bar: two, baz: [3,3,3] } diff --git a/tests/lib/test_constructor.py b/tests/lib/test_constructor.py index 0783a21b..10c3243c 100644 --- a/tests/lib/test_constructor.py +++ b/tests/lib/test_constructor.py @@ -15,7 +15,7 @@ def execute(code): def _make_objects(): global MyLoader, MyDumper, MyTestClass1, MyTestClass2, MyTestClass3, YAMLObject1, YAMLObject2, \ - AnObject, AnInstance, AState, ACustomState, InitArgs, InitArgsWithState, \ + EmptyObject, AnObject, AnInstance, AState, ACustomState, InitArgs, InitArgsWithState, \ NewArgs, NewArgsWithState, Reduce, ReduceWithState, Slots, MyInt, MyList, MyDict, \ FixedOffset, today, execute, MyFullLoader @@ -128,6 +128,18 @@ def __eq__(self, other): return type(self) is type(other) and \ (self.foo, self.bar, self.baz) == (other.foo, other.bar, other.baz) + # Python 3.11+ implements __getstate__() returning `None` on objects with no attrs https://github.com/python/cpython/issues/70766 + class EmptyObject(yaml.YAMLObject): + yaml_tag = '!emptyobj' + + def __eq__(self, other): + return type(other) is EmptyObject + + # simulate Python 3.11 behavior with a generated __getstate__ returning None on objects with no attrs + def __getstate__(self): + return None + + class AnInstance: def __init__(self, foo=None, bar=None, baz=None): self.foo = foo