Skip to content

Commit

Permalink
feat: support ignoring elements/properties during deserialization
Browse files Browse the repository at this point in the history
Signed-off-by: Paul Horton <paul.horton@owasp.org>
  • Loading branch information
madpah committed Aug 19, 2022
1 parent f632d2f commit 6319d1f
Show file tree
Hide file tree
Showing 7 changed files with 153 additions and 15 deletions.
11 changes: 10 additions & 1 deletion docs/customising-structure.rst
Expand Up @@ -51,7 +51,16 @@ decorator to the **isbn** property:
Excluding Property from Serialization
----------------------------------------------------

Coming soon...
Properties can be ignored during deserialization by including them in the :obj:`serializable.serializable_class()`
annotation as per the following example.

A typical use case for this might be where a JSON schema is referenced, but this is not part of the constructor for the
class you are deserializing to.

.. code-block::
@serializable.serializable_class(ignore_during_deserialization=['$schema'])
class Book:
Customised Property Serialization
Expand Down
61 changes: 48 additions & 13 deletions serializable/__init__.py
Expand Up @@ -21,6 +21,7 @@
import inspect
import json
import logging
import warnings
from copy import copy
from io import StringIO, TextIOWrapper
from json import JSONEncoder
Expand Down Expand Up @@ -155,29 +156,41 @@ def _from_json(cls: Type[_T], data: Dict[str, Any]) -> object:
``serializable``.
"""
logging.debug(f'Rendering JSON to {cls}...')
klass = ObjectMetadataLibrary.klass_mappings.get(f'{cls.__module__}.{cls.__qualname__}', None)
klass_properties = ObjectMetadataLibrary.klass_property_mappings.get(f'{cls.__module__}.{cls.__qualname__}', {})

if klass is None:
warnings.warn(f'{cls.__module__}.{cls.__qualname__} is not a known serializable class')
return None

_data = copy(data)
for k, v in data.items():
decoded_k = CurrentFormatter.formatter.decode(property_name=k)
if decoded_k in klass.ignore_during_deserialization:
logger.debug(f'Ignoring {k} when deserializing {cls.__module__}.{cls.__qualname__}')
del _data[k]
continue

new_key = None
if decoded_k not in klass_properties:
for p, pi in klass_properties.items():
if pi.custom_names.get(SerializationType.JSON, None):
del (_data[k])
_data[p] = v
if pi.custom_names.get(SerializationType.JSON, None) == decoded_k:
new_key = p
else:
del (_data[k])
_data[decoded_k] = v
new_key = decoded_k

if new_key is None:
raise ValueError(f'Unexpected key {k} in data being serialized to {cls.__module__}.{cls.__qualname__}')

del (_data[k])
_data[new_key] = v

for k, v in _data.items():
prop_info = klass_properties.get(k, None)
if not prop_info:
raise ValueError(f'No Prop Info for {k} in {cls}')
print(f'{k} has {prop_info}')

if prop_info.custom_type:
print(f'{k} is custom type: {prop_info}')
if prop_info.is_helper_type():
_data[k] = prop_info.custom_type.deserialize(v)
else:
Expand Down Expand Up @@ -300,6 +313,7 @@ def _as_xml(self: _T, as_string: bool = True, element_name: Optional[str] = None
def _from_xml(cls: Type[_T], data: Union[TextIOWrapper, ElementTree.Element],
default_namespace: Optional[str] = None) -> object:
logging.debug(f'Rendering XML from {type(data)} to {cls}...')
klass = ObjectMetadataLibrary.klass_mappings.get(f'{cls.__module__}.{cls.__qualname__}', None)
klass_properties = ObjectMetadataLibrary.klass_property_mappings.get(f'{cls.__module__}.{cls.__qualname__}', {})

if isinstance(data, TextIOWrapper):
Expand All @@ -318,10 +332,13 @@ def _from_xml(cls: Type[_T], data: Union[TextIOWrapper, ElementTree.Element],
# Handle attributes on the root element if there are any
for k, v in data.attrib.items():
decoded_k = CurrentFormatter.formatter.decode(property_name=k)
if decoded_k in klass.ignore_during_deserialization:
logger.debug(f'Ignoring {decoded_k} when deserializing {cls.__module__}.{cls.__qualname__}')
continue

if decoded_k not in klass_properties:
for p, pi in klass_properties.items():
if pi.custom_names.get(SerializationType.XML, None):
if pi.custom_names.get(SerializationType.XML, None) == decoded_k:
decoded_k = p

prop_info = klass_properties.get(decoded_k, None)
Expand All @@ -344,7 +361,12 @@ def _from_xml(cls: Type[_T], data: Union[TextIOWrapper, ElementTree.Element],
# Handle Sub-Elements
for child_e in data:
child_e_tag_name = str(child_e.tag).replace('{' + default_namespace + '}', '')

decoded_k = CurrentFormatter.formatter.decode(property_name=child_e_tag_name)
if decoded_k in klass.ignore_during_deserialization:
logger.debug(f'Ignoring {decoded_k} when deserializing {cls.__module__}.{cls.__qualname__}')
continue

if decoded_k not in klass_properties:
for p, pi in klass_properties.items():
if pi.xml_array_config:
Expand Down Expand Up @@ -410,12 +432,14 @@ class SerializableClass:
"""

def __init__(self, *, klass: Any, custom_name: Optional[str] = None,
serialization_types: Optional[Iterable[SerializationType]] = None) -> None:
serialization_types: Optional[Iterable[SerializationType]] = None,
ignore_during_deserialization: Optional[Iterable[str]] = None) -> None:
self._name = str(klass.__name__)
self._custom_name = custom_name
if serialization_types is None:
serialization_types = _DEFAULT_SERIALIZATION_TYPES
self._serialization_types = serialization_types
self._ignore_during_deserialization = set(ignore_during_deserialization or {})

@property
def name(self) -> str:
Expand All @@ -429,6 +453,10 @@ def custom_name(self) -> Optional[str]:
def serialization_types(self) -> Iterable[SerializationType]:
return self._serialization_types

@property
def ignore_during_deserialization(self) -> Set[str]:
return self._ignore_during_deserialization

def __repr__(self) -> str:
return f'<s.oml.SerializableClass name={self.name}>'

Expand Down Expand Up @@ -531,13 +559,15 @@ def is_property(cls, o: object) -> bool:

@classmethod
def register_klass(cls, klass: _T, custom_name: Optional[str],
serialization_types: Iterable[SerializationType]) -> _T:
serialization_types: Iterable[SerializationType],
ignore_during_deserialization: Optional[Iterable[str]] = None) -> _T:
if cls.is_klass_serializable(klass=klass):
return klass

cls.klass_mappings.update({
f'{klass.__module__}.{klass.__qualname__}': ObjectMetadataLibrary.SerializableClass( # type: ignore
klass=klass, serialization_types=serialization_types
klass=klass, serialization_types=serialization_types,
ignore_during_deserialization=ignore_during_deserialization
)
})

Expand Down Expand Up @@ -600,21 +630,26 @@ def register_property_type_mapping(cls, qual_name: str, mapped_type: Any) -> Non


def serializable_class(cls: Optional[Type[_T]] = None, *, name: Optional[str] = None,
serialization_types: Optional[Iterable[SerializationType]] = None
serialization_types: Optional[Iterable[SerializationType]] = None,
ignore_during_deserialization: Optional[Iterable[str]] = None
) -> Union[Callable[[Any], Type[_T]], Type[_T]]:
"""
Decorator used to tell ``serializable`` that a class is to be included in (de-)serialization.
:param cls: Class
:param name: Alternative name to use for this Class
:param serialization_types: Serialization Types that are to be supported for this class.
:param ignore_during_deserialization: List of properties/elements to ignore during deserialization
:return:
"""
if serialization_types is None:
serialization_types = _DEFAULT_SERIALIZATION_TYPES

def wrap(kls: Type[_T]) -> Type[_T]:
ObjectMetadataLibrary.register_klass(klass=kls, custom_name=name, serialization_types=serialization_types or {})
ObjectMetadataLibrary.register_klass(
klass=kls, custom_name=name, serialization_types=serialization_types or {},
ignore_during_deserialization=ignore_during_deserialization
)
return kls

# See if we're being called as @register_klass or @register_klass().
Expand Down
37 changes: 37 additions & 0 deletions tests/fixtures/the-phoenix-project-camel-case-with-ignored.json
@@ -0,0 +1,37 @@
{
"something_to_be_ignored": "some_value",
"title": "The Phoenix Project",
"isbnNumber": "978-1942788294",
"edition": {
"number": 5,
"name": "5th Anniversary Limited Edition"
},
"publishDate": "2018-04-16",
"type": "fiction",
"authors": [
"Kevin Behr",
"Gene Kim",
"George Spafford"
],
"publisher": {
"name": "IT Revolution Press LLC"
},
"chapters": [
{
"number": 1,
"title": "Tuesday, September 2"
},
{
"number": 2,
"title": "Tuesday, September 2"
},
{
"number": 3,
"title": "Tuesday, September 2"
},
{
"number": 4,
"title": "Wednesday, September 3"
}
]
}
32 changes: 32 additions & 0 deletions tests/fixtures/the-phoenix-project-camel-case-with-ignored.xml
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<book isbnNumber="978-1942788294" ignoreMe="something">
<ignored>thing</ignored>
<title>The Phoenix Project</title>
<edition number="5">5th Anniversary Limited Edition</edition>
<publishDate>2018-04-16</publishDate>
<author>Kevin Behr</author>
<author>Gene Kim</author>
<author>George Spafford</author>
<type>fiction</type>
<publisher>
<name>IT Revolution Press LLC</name>
</publisher>
<chapters>
<chapter>
<number>1</number>
<title>Tuesday, September 2</title>
</chapter>
<chapter>
<number>2</number>
<title>Tuesday, September 2</title>
</chapter>
<chapter>
<number>3</number>
<title>Tuesday, September 2</title>
</chapter>
<chapter>
<number>4</number>
<title>Wednesday, September 3</title>
</chapter>
</chapters>
</book>
3 changes: 2 additions & 1 deletion tests/model.py
Expand Up @@ -111,7 +111,8 @@ def __hash__(self) -> int:
return hash((self.number, self.name))


@serializable.serializable_class(name='bigbook')
@serializable.serializable_class(name='bigbook',
ignore_during_deserialization=['something_to_be_ignored', 'ignore_me', 'ignored'])
class Book:

def __init__(self, title: str, isbn: str, publish_date: date, authors: Iterable[str],
Expand Down
12 changes: 12 additions & 0 deletions tests/test_json.py
Expand Up @@ -48,6 +48,18 @@ def test_deserialize_tfp_cc(self) -> None:
self.assertEqual(ThePhoenixProject.publisher, book.publisher)
self.assertEqual(ThePhoenixProject.chapters, book.chapters)

def test_deserialize_tfp_cc_with_ignored(self) -> None:
CurrentFormatter.formatter = CamelCasePropertyNameFormatter
with open(os.path.join(FIXTURES_DIRECTORY, 'the-phoenix-project-camel-case-with-ignored.json')) as input_json:
book: Book = Book.from_json(data=json.loads(input_json.read()))
self.assertEqual(ThePhoenixProject.title, book.title)
self.assertEqual(ThePhoenixProject.isbn, book.isbn)
self.assertEqual(ThePhoenixProject.edition, book.edition)
self.assertEqual(ThePhoenixProject.publish_date, book.publish_date)
self.assertEqual(ThePhoenixProject.authors, book.authors)
self.assertEqual(ThePhoenixProject.publisher, book.publisher)
self.assertEqual(ThePhoenixProject.chapters, book.chapters)

def test_serialize_tfp_kc(self) -> None:
CurrentFormatter.formatter = KebabCasePropertyNameFormatter
with open(os.path.join(FIXTURES_DIRECTORY, 'the-phoenix-project-kebab-case.json')) as expected_json:
Expand Down
12 changes: 12 additions & 0 deletions tests/test_xml.py
Expand Up @@ -59,6 +59,18 @@ def test_deserialize_tfp_cc1(self) -> None:
self.assertEqual(ThePhoenixProject.authors, book.authors)
self.assertEqual(ThePhoenixProject.chapters, book.chapters)

def test_deserialize_tfp_cc1_with_ignored(self) -> None:
CurrentFormatter.formatter = CamelCasePropertyNameFormatter
with open(os.path.join(FIXTURES_DIRECTORY, 'the-phoenix-project-camel-case-with-ignored.xml')) as input_xml:
book: Book = Book.from_xml(data=ElementTree.fromstring(input_xml.read()))
self.assertEqual(ThePhoenixProject.title, book.title)
self.assertEqual(ThePhoenixProject.isbn, book.isbn)
self.assertEqual(ThePhoenixProject.edition, book.edition)
self.assertEqual(ThePhoenixProject.publish_date, book.publish_date)
self.assertEqual(ThePhoenixProject.publisher, book.publisher)
self.assertEqual(ThePhoenixProject.authors, book.authors)
self.assertEqual(ThePhoenixProject.chapters, book.chapters)

def test_deserialize_tfp_kc1(self) -> None:
CurrentFormatter.formatter = KebabCasePropertyNameFormatter
with open(os.path.join(FIXTURES_DIRECTORY, 'the-phoenix-project-kebab-case-1.xml')) as input_xml:
Expand Down

0 comments on commit 6319d1f

Please sign in to comment.