From e58c91e0a0b7ce943cb35e49e6934fd6e6bb2757 Mon Sep 17 00:00:00 2001 From: Benjamin Pelletier Date: Wed, 11 Jan 2023 11:47:23 -0800 Subject: [PATCH] Do not serialize class properties (#5) --- src/implicitdict/__init__.py | 1 + tests/test_mutability.py | 1 - tests/test_optional.py | 14 ++++++ tests/test_properties.py | 95 ++++++++++++++++++++++++++++++++++++ 4 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 tests/test_properties.py diff --git a/src/implicitdict/__init__.py b/src/implicitdict/__init__.py index 8be6152..81a1383 100644 --- a/src/implicitdict/__init__.py +++ b/src/implicitdict/__init__.py @@ -244,6 +244,7 @@ def _get_fields(subtype: Type) -> Tuple[Set[str], Set[str]]: and key not in _DICT_FIELDS and key[0:2] != '__' and not callable(getattr(subtype, key)) + and not isinstance(getattr(subtype, key), property) ): all_fields.add(key) attributes.add(key) diff --git a/tests/test_mutability.py b/tests/test_mutability.py index 135745b..222fbf8 100644 --- a/tests/test_mutability.py +++ b/tests/test_mutability.py @@ -1,4 +1,3 @@ -import json from typing import Optional, List from implicitdict import ImplicitDict diff --git a/tests/test_optional.py b/tests/test_optional.py index 44a3d2a..efcf5b6 100644 --- a/tests/test_optional.py +++ b/tests/test_optional.py @@ -40,6 +40,20 @@ def test_fully_defined(): assert "foo5" in s +def test_over_defined(): + data = MyData( + required_field="foo1", + optional_field1="foo2", + field_with_default="foo3", + optional_field2_with_none_default="foo4", + optional_field3_with_default="foo5", + unknown_field="foo6", + ) + d = json.loads(json.dumps(data)) + d["another_unknown_field"] = {"third_unknown_field": "foo7"} + _ = ImplicitDict.parse(d, MyData) + + def test_minimally_defined(): # An unspecified optional field will not be present in the object at all data = MyData(required_field="foo1") diff --git a/tests/test_properties.py b/tests/test_properties.py new file mode 100644 index 0000000..515484e --- /dev/null +++ b/tests/test_properties.py @@ -0,0 +1,95 @@ +import json + +from implicitdict import ImplicitDict + + +class MyData(ImplicitDict): + foo: str + + @property + def bar(self) -> str: + return self.foo + 'bar' + + def get_baz(self) -> str: + return self.foo + 'baz' + + def set_baz(self, value: str) -> None: + self.foo = value + + baz = property(get_baz, set_baz) + + @property + def booz(self) -> str: + return self.foo + 'booz' + + @booz.setter + def booz(self, value: str) -> None: + self.foo = value + + +def test_property_exclusion(): + """Ensure implicitdict doesn't serialize dynamic properties. + + Properties of a class instance are computed dynamically -- they are + methods with syntactic sugar which makes them look like fields. Because we + shouldn't serialize the result of a class method, we also shouldn't + serialize the result of a class property, even if that property includes a + setter. + """ + # Create class instance and ensure it works as expected + data = MyData(foo='foo') + assert data.bar == 'foobar' + assert data.baz == 'foobaz' + assert data.booz == 'foobooz' + + # Serialize class instance and ensure the properties weren't serialized + obj = json.loads(json.dumps(data)) + assert 'bar' not in obj + assert 'baz' not in obj + assert 'booz' not in obj + + # Ensure serialization can be deserialized to class instance + data: MyData = ImplicitDict.parse(obj, MyData) + + # Ensure deserialized instance works as expected + assert data.bar == 'foobar' + assert data.baz == 'foobaz' + assert data.booz == 'foobooz' + + +class MyDict(dict): + @property + def foo(self) -> str: + return 'foo' + + def get_bar(self) -> str: + return self.foo + 'bar' + + def set_bar(self, value: str) -> None: + self['bar'] = value + + bar = property(get_bar, set_bar) + + @property + def baz(self) -> str: + return self.foo + 'baz' + + @baz.setter + def baz(self, value: str) -> None: + self['baz'] = value + + +def test_dict_inheritance(): + """Demonstrate that classes inheriting dict do not have their properties serialized.""" + data = MyDict() + assert data.foo == 'foo' + assert data.bar == 'foobar' + assert data.baz == 'foobaz' + + data['booz'] = 'biz' + deserialized = json.loads(json.dumps(data)) + + assert 'foo' not in deserialized + assert 'bar' not in deserialized + assert 'baz' not in deserialized + assert deserialized['booz'] == 'biz'