From 3a1728d43a13e57ecad2b3feebadf1d9fdc132c3 Mon Sep 17 00:00:00 2001 From: Jan Kowalleck Date: Mon, 8 Jul 2024 13:19:56 +0200 Subject: [PATCH] feat: XML string formats for `normalizedString` and `token` (#119) fixes #114 fixes #115 --------- Signed-off-by: Jan Kowalleck --- docs/customising-structure.rst | 20 ++++ serializable/__init__.py | 112 ++++++++++++++++-- serializable/json.py | 22 ++++ serializable/xml.py | 82 +++++++++++++ tests/base.py | 15 +-- ...-phoenix-project_unnormalized-input_v4.xml | 59 +++++++++ tests/model.py | 55 ++++++++- tests/test_xml.py | 29 ++++- 8 files changed, 371 insertions(+), 23 deletions(-) create mode 100644 serializable/json.py create mode 100644 serializable/xml.py create mode 100644 tests/fixtures/the-phoenix-project_unnormalized-input_v4.xml diff --git a/docs/customising-structure.rst b/docs/customising-structure.rst index 01db7b1..a8b89d2 100644 --- a/docs/customising-structure.rst +++ b/docs/customising-structure.rst @@ -176,6 +176,26 @@ For *Example 3*, you would add the following to your class: Further examples are available in our :ref:`unit tests `. +Serializing special XML string types +---------------------------------------------------- + +In XML, are special string types, ech with defined set of allowed characters and whitespace handling. +We can handle this by adding the decorator :obj:`serializable.xml_string()` to the appropriate property in your class. + +.. code-block:: python + + @property + @serializable.xml_string(serializable.XmlStringSerializationType.TOKEN) + def author(self) -> str: + return self._author + +Further examples are available in our :ref:`unit tests `. + +.. note:: + + The actual transformation is done by :func:`serializable.xml.xs_normalizedString()` + and :func:`serializable.xml.xs_token()` + Serialization Views ---------------------------------------------------- diff --git a/serializable/__init__.py b/serializable/__init__.py index dbf4c26..d25ecbe 100644 --- a/serializable/__init__.py +++ b/serializable/__init__.py @@ -48,6 +48,7 @@ from .formatters import BaseNameFormatter, CurrentFormatter from .helpers import BaseHelper +from .xml import xs_normalizedString, xs_token # `Intersection` is still not implemented, so it is interim replaced by Union for any support # see section "Intersection" in https://peps.python.org/pep-0483/ @@ -128,6 +129,47 @@ class XmlArraySerializationType(Enum): NESTED = 2 +@unique +class XmlStringSerializationType(Enum): + """ + Enum to differentiate how string-type properties are serialized. + """ + STRING = 1 + """ + as raw string. + see https://www.w3.org/TR/xmlschema-2/#string + """ + NORMALIZED_STRING = 2 + """ + as `normalizedString`. + see http://www.w3.org/TR/xmlschema-2/#normalizedString""" + TOKEN = 3 + """ + as `token`. + see http://www.w3.org/TR/xmlschema-2/#token""" + + # unimplemented cases + # - https://www.w3.org/TR/xmlschema-2/#language + # - https://www.w3.org/TR/xmlschema-2/#NMTOKEN + # - https://www.w3.org/TR/xmlschema-2/#Name + + +# region _xs_string_mod_apply + +__XS_STRING_MODS: Dict[XmlStringSerializationType, Callable[[str], str]] = { + XmlStringSerializationType.NORMALIZED_STRING: xs_normalizedString, + XmlStringSerializationType.TOKEN: xs_token, +} + + +def _xs_string_mod_apply(v: str, t: Optional[XmlStringSerializationType]) -> str: + mod = __XS_STRING_MODS.get(t) # type: ignore[arg-type] + return mod(v) if mod else v + + +# endregion _xs_string_mod_apply + + def _allow_property_for_view(prop_info: 'ObjectMetadataLibrary.SerializableProperty', value_: Any, view_: Optional[Type[ViewType]]) -> bool: # First check Property is part of the View is given @@ -394,7 +436,8 @@ def as_xml(self: Any, view_: Optional[Type[ViewType]] = None, elif prop_info.is_enum: v = v.value - this_e_attributes[_namespace_element_name(new_key, xmlns)] = str(v) + this_e_attributes[_namespace_element_name(new_key, xmlns)] = \ + _xs_string_mod_apply(str(v), prop_info.xml_string_config) element_name = _namespace_element_name( element_name if element_name else CurrentFormatter.formatter.encode(self.__class__.__name__), @@ -426,7 +469,8 @@ def as_xml(self: Any, view_: Optional[Type[ViewType]] = None, continue if new_key == '.': - this_e.text = str(v) + this_e.text = _xs_string_mod_apply(str(v), + prop_info.xml_string_config) continue if CurrentFormatter.formatter: @@ -445,14 +489,16 @@ def as_xml(self: Any, view_: Optional[Type[ViewType]] = None, nested_e.append( j.as_xml(view_=view_, as_string=False, element_name=nested_key, xmlns=xmlns)) elif prop_info.is_enum: - SubElement(nested_e, nested_key).text = str(j.value) + SubElement(nested_e, nested_key).text = _xs_string_mod_apply(str(j.value), + prop_info.xml_string_config) elif prop_info.concrete_type in (float, int): SubElement(nested_e, nested_key).text = str(j) elif prop_info.concrete_type is bool: SubElement(nested_e, nested_key).text = str(j).lower() else: # Assume type is str - SubElement(nested_e, nested_key).text = str(j) + SubElement(nested_e, nested_key).text = _xs_string_mod_apply(str(j), + prop_info.xml_string_config) elif prop_info.custom_type: if prop_info.is_helper_type(): v_ser = prop_info.custom_type.xml_normalize( @@ -462,11 +508,14 @@ def as_xml(self: Any, view_: Optional[Type[ViewType]] = None, elif isinstance(v_ser, Element): this_e.append(v_ser) else: - SubElement(this_e, new_key).text = str(v_ser) + SubElement(this_e, new_key).text = _xs_string_mod_apply(str(v_ser), + prop_info.xml_string_config) else: - SubElement(this_e, new_key).text = str(prop_info.custom_type(v)) + SubElement(this_e, new_key).text = _xs_string_mod_apply(str(prop_info.custom_type(v)), + prop_info.xml_string_config) elif prop_info.is_enum: - SubElement(this_e, new_key).text = str(v.value) + SubElement(this_e, new_key).text = _xs_string_mod_apply(str(v.value), + prop_info.xml_string_config) elif not prop_info.is_primitive_type(): global_klass_name = f'{prop_info.concrete_type.__module__}.{prop_info.concrete_type.__name__}' if global_klass_name in ObjectMetadataLibrary.klass_mappings: @@ -475,16 +524,19 @@ def as_xml(self: Any, view_: Optional[Type[ViewType]] = None, else: # Handle properties that have a type that is not a Python Primitive (e.g. int, float, str) if prop_info.string_format: - SubElement(this_e, new_key).text = f'{v:{prop_info.string_format}}' + SubElement(this_e, new_key).text = _xs_string_mod_apply(f'{v:{prop_info.string_format}}', + prop_info.xml_string_config) else: - SubElement(this_e, new_key).text = str(v) + SubElement(this_e, new_key).text = _xs_string_mod_apply(str(v), + prop_info.xml_string_config) elif prop_info.concrete_type in (float, int): SubElement(this_e, new_key).text = str(v) elif prop_info.concrete_type is bool: SubElement(this_e, new_key).text = str(v).lower() else: # Assume type is str - SubElement(this_e, new_key).text = str(v) + SubElement(this_e, new_key).text = _xs_string_mod_apply(str(v), + prop_info.xml_string_config) if as_string: return cast(Element, SafeElementTree.tostring(this_e, 'unicode')) @@ -542,6 +594,9 @@ def strip_default_namespace(s: str) -> str: raise ValueError(f'Non-primitive types not supported from XML Attributes - see {decoded_k} for ' f'{cls.__module__}.{cls.__qualname__} which has Prop Metadata: {prop_info}') + if prop_info.xml_string_config: + v = _xs_string_mod_apply(v, prop_info.xml_string_config) + if prop_info.custom_type and prop_info.is_helper_type(): _data[decoded_k] = prop_info.custom_type.xml_deserialize(v) elif prop_info.is_enum: @@ -555,7 +610,7 @@ def strip_default_namespace(s: str) -> str: if data.text: for p, pi in klass_properties.items(): if pi.custom_names.get(SerializationType.XML) == '.': - _data[p] = data.text.strip() + _data[p] = _xs_string_mod_apply(data.text.strip(), pi.xml_string_config) # Handle Sub-Elements for child_e in data: @@ -594,6 +649,9 @@ def strip_default_namespace(s: str) -> str: try: _logger.debug('Handling %s', prop_info) + if child_e.text: + child_e.text = _xs_string_mod_apply(child_e.text, prop_info.xml_string_config) + if prop_info.is_array and prop_info.xml_array_config: array_type, nested_name = prop_info.xml_array_config @@ -602,6 +660,9 @@ def strip_default_namespace(s: str) -> str: if array_type == XmlArraySerializationType.NESTED: for sub_child_e in child_e: + if sub_child_e.text: + sub_child_e.text = _xs_string_mod_apply(sub_child_e.text, + prop_info.xml_string_config) if not prop_info.is_primitive_type() and not prop_info.is_enum: _data[decoded_k].append(prop_info.concrete_type.from_xml( data=sub_child_e, default_namespace=default_namespace) @@ -675,6 +736,7 @@ class ObjectMetadataLibrary: _deferred_property_type_parsing: Dict[str, Set['ObjectMetadataLibrary.SerializableProperty']] = {} _klass_views: Dict[str, Type[ViewType]] = {} _klass_property_array_config: Dict[str, Tuple[XmlArraySerializationType, str]] = {} + _klass_property_string_config: Dict[str, Optional[XmlStringSerializationType]] = {} _klass_property_attributes: Set[str] = set() _klass_property_include_none: Dict[str, Set[Tuple[Type[ViewType], Any]]] = {} _klass_property_names: Dict[str, Dict[SerializationType, str]] = {} @@ -738,12 +800,14 @@ class SerializableProperty: _DEFAULT_XML_SEQUENCE = 100 - def __init__(self, *, prop_name: str, prop_type: Any, custom_names: Dict[SerializationType, str], + def __init__(self, *, + prop_name: str, prop_type: Any, custom_names: Dict[SerializationType, str], custom_type: Optional[Any] = None, include_none_config: Optional[Set[Tuple[Type[ViewType], Any]]] = None, is_xml_attribute: bool = False, string_format_: Optional[str] = None, views: Optional[Iterable[Type[ViewType]]] = None, xml_array_config: Optional[Tuple[XmlArraySerializationType, str]] = None, + xml_string_config: Optional[XmlStringSerializationType] = None, xml_sequence_: Optional[int] = None) -> None: self._name = prop_name @@ -764,6 +828,7 @@ def __init__(self, *, prop_name: str, prop_type: Any, custom_names: Dict[Seriali self._string_format = string_format_ self._views = set(views or ()) self._xml_array_config = xml_array_config + self._xml_string_config = xml_string_config self._xml_sequence = xml_sequence_ or self._DEFAULT_XML_SEQUENCE self._deferred_type_parsing = False @@ -834,6 +899,10 @@ def xml_array_config(self) -> Optional[Tuple[XmlArraySerializationType, str]]: def is_array(self) -> bool: return self._is_array + @property + def xml_string_config(self) -> Optional[XmlStringSerializationType]: + return self._xml_string_config + @property def is_enum(self) -> bool: return self._is_enum @@ -1050,6 +1119,7 @@ def register_klass(cls, klass: Type[_T], custom_name: Optional[str], string_format_=ObjectMetadataLibrary._klass_property_string_formats.get(qualified_property_name), views=ObjectMetadataLibrary._klass_property_views.get(qualified_property_name), xml_array_config=ObjectMetadataLibrary._klass_property_array_config.get(qualified_property_name), + xml_string_config=ObjectMetadataLibrary._klass_property_string_config.get(qualified_property_name), xml_sequence_=ObjectMetadataLibrary._klass_property_xml_sequence.get( qualified_property_name, ObjectMetadataLibrary.SerializableProperty._DEFAULT_XML_SEQUENCE) @@ -1117,6 +1187,11 @@ def register_xml_property_array_config(cls, qual_name: str, array_type: XmlArraySerializationType, child_name: str) -> None: cls._klass_property_array_config[qual_name] = (array_type, child_name) + @classmethod + def register_xml_property_string_config(cls, qual_name: str, + string_type: Optional[XmlStringSerializationType]) -> None: + cls._klass_property_string_config[qual_name] = string_type + @classmethod def register_xml_property_attribute(cls, qual_name: str) -> None: cls._klass_property_attributes.add(qual_name) @@ -1305,6 +1380,19 @@ def decorate(f: _F) -> _F: return decorate +def xml_string(string_type: XmlStringSerializationType) -> Callable[[_F], _F]: + """Decorator""" + + def decorate(f: _F) -> _F: + _logger.debug('Registering %s.%s as XML StringType: %s', f.__module__, f.__qualname__, string_type) + ObjectMetadataLibrary.register_xml_property_string_config( + qual_name=f'{f.__module__}.{f.__qualname__}', string_type=string_type + ) + return f + + return decorate + + def xml_name(name: str) -> Callable[[_F], _F]: """Decorator""" diff --git a/serializable/json.py b/serializable/json.py new file mode 100644 index 0000000..cf95f35 --- /dev/null +++ b/serializable/json.py @@ -0,0 +1,22 @@ +# encoding: utf-8 + +# This file is part of py-serializable +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) Paul Horton. All Rights Reserved. + +""" +JSON-specific functionality. +""" diff --git a/serializable/xml.py b/serializable/xml.py new file mode 100644 index 0000000..7363f53 --- /dev/null +++ b/serializable/xml.py @@ -0,0 +1,82 @@ +# encoding: utf-8 + +# This file is part of py-serializable +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) Paul Horton. All Rights Reserved. + +""" +XML-specific functionality. +""" + +__all__ = ['xs_normalizedString', 'xs_token'] + +from re import compile as re_compile + +# region normalizedString + +__NORMALIZED_STRING_FORBIDDEN_SEARCH = re_compile(r'\r\n|\t|\n|\r') +__NORMALIZED_STRING_FORBIDDEN_REPLACE = ' ' + + +def xs_normalizedString(s: str) -> str: + """Make a ``normalizedString``, adhering XML spec. + + .. epigraph:: + *normalizedString* represents white space normalized strings. + The `·value space· `_ of normalizedString is the set of + strings that do not contain the carriage return (#xD), line feed (#xA) nor tab (#x9) characters. + The `·lexical space· `_ of normalizedString is the set of + strings that do not contain the carriage return (#xD), line feed (#xA) nor tab (#x9) characters. + The `·base type· `_ of normalizedString is + `string `_. + + -- the `XML schema spec `_ + """ + return __NORMALIZED_STRING_FORBIDDEN_SEARCH.sub( + __NORMALIZED_STRING_FORBIDDEN_REPLACE, + s) + + +# endregion + +# region token + + +__TOKEN_MULTISTRING_SEARCH = re_compile(r' {2,}') +__TOKEN_MULTISTRING_REPLACE = ' ' + + +def xs_token(s: str) -> str: + """Make a ``token``, adhering XML spec. + + .. epigraph:: + *token* represents tokenized strings. + The `·value space· `_ of token is the set of strings that do + not contain the carriage return (#xD), line feed (#xA) nor tab (#x9) characters, that have no leading or + trailing spaces (#x20) and that have no internal sequences of two or more spaces. + The `·lexical space· `_ of token is the set of strings that + do not contain the carriage return (#xD), line feed (#xA) nor tab (#x9) characters, that have no leading or + trailing spaces (#x20) and that have no internal sequences of two or more spaces. + The `·base type· `_ of token is + `normalizedString `_. + + -- the `XML schema spec `_ + """ + return __TOKEN_MULTISTRING_SEARCH.sub( + __TOKEN_MULTISTRING_REPLACE, + xs_normalizedString(s).strip()) + +# endregion diff --git a/tests/base.py b/tests/base.py index 687b010..f129b76 100644 --- a/tests/base.py +++ b/tests/base.py @@ -41,24 +41,25 @@ def _sort_json_dict(item: object) -> Any: else: return item - def assertEqualJson(self, a: str, b: str) -> None: + def assertEqualJson(self, expected: str, actual: str) -> None: self.assertEqual( - BaseTestCase._sort_json_dict(json.loads(a)), - BaseTestCase._sort_json_dict(json.loads(b)) + BaseTestCase._sort_json_dict(json.loads(expected)), + BaseTestCase._sort_json_dict(json.loads(actual)) ) - def assertEqualXml(self, a: str, b: str) -> None: + def assertEqualXml(self, expected: str, actual: str) -> None: a = SafeElementTree.tostring( - SafeElementTree.fromstring(a, lxml.etree.XMLParser(remove_blank_text=True, remove_comments=True)), + SafeElementTree.fromstring(expected, lxml.etree.XMLParser(remove_blank_text=True, remove_comments=True)), 'unicode' ) b = SafeElementTree.tostring( - SafeElementTree.fromstring(b, lxml.etree.XMLParser(remove_blank_text=True, remove_comments=True)), + SafeElementTree.fromstring(actual, lxml.etree.XMLParser(remove_blank_text=True, remove_comments=True)), 'unicode' ) diff_results = main.diff_texts(a, b, diff_options={'F': 0.5}) diff_results = list(filter(lambda o: not isinstance(o, MoveNode), diff_results)) - self.assertEqual(len(diff_results), 0, f'There are XML differences: {diff_results}\n- {a}\n+ {b}') + self.assertEqual(len(diff_results), 0, + f'There are XML differences: {diff_results!r}\n- {a!s}\n+ {b!s}') class DeepCompareMixin(object): diff --git a/tests/fixtures/the-phoenix-project_unnormalized-input_v4.xml b/tests/fixtures/the-phoenix-project_unnormalized-input_v4.xml new file mode 100644 index 0000000..b8f7ae0 --- /dev/null +++ b/tests/fixtures/the-phoenix-project_unnormalized-input_v4.xml @@ -0,0 +1,59 @@ + + + f3758bf0-0ff7-4366-a5e5-c209d4352b2d + + {X} The + Phoenix Project + + 5th Anniversary Limited Edition + 2018-04-16 + Kevin Behr + Gene Kim + George Spafford + fiction + + IT Revolution Press LLC +
10 Downing Street
+
+ + + 1 + + Tuesday, September 2 + + + + 2 + + Tuesday, September 2 + + + + 3 + + Tuesday, September 2 + + + + 4 + + Wednesday, September 3 + + + + + + + + + + + + + + 9.8 + stock-id-1 + stock-id-2 +
diff --git a/tests/model.py b/tests/model.py index 024d9db..0d86ce7 100644 --- a/tests/model.py +++ b/tests/model.py @@ -25,7 +25,7 @@ from uuid import UUID, uuid4 import serializable -from serializable import ViewType, XmlArraySerializationType +from serializable import ViewType, XmlArraySerializationType, XmlStringSerializationType from serializable.helpers import BaseHelper, Iso8601Date """ @@ -109,6 +109,7 @@ def number(self) -> int: return self._number @property + @serializable.xml_string(XmlStringSerializationType.TOKEN) def title(self) -> str: return self._title @@ -196,6 +197,7 @@ def __init__(self, *, ref: str, references: Optional[Iterable['BookReference']] @property @serializable.json_name('reference') @serializable.xml_attribute() + @serializable.xml_string(XmlStringSerializationType.TOKEN) def ref(self) -> str: return self._ref @@ -304,6 +306,7 @@ def id(self) -> UUID: @property @serializable.xml_sequence(2) @serializable.type_mapping(TitleMapper) + @serializable.xml_string(XmlStringSerializationType.TOKEN) def title(self) -> str: return self._title @@ -327,6 +330,7 @@ def publish_date(self) -> date: @property @serializable.xml_array(XmlArraySerializationType.FLAT, 'author') + @serializable.xml_string(XmlStringSerializationType.NORMALIZED_STRING) @serializable.xml_sequence(5) def authors(self) -> Set[str]: return self._authors @@ -379,8 +383,13 @@ def stock_ids(self) -> Set[StockId]: return self._stock_ids +# region ThePhoenixProject_v2 + + ThePhoenixProject_v1 = Book( - title='The Phoenix Project', isbn='978-1942788294', publish_date=date(year=2018, month=4, day=16), + title='The Phoenix Project', + isbn='978-1942788294', + publish_date=date(year=2018, month=4, day=16), authors=['Gene Kim', 'Kevin Behr', 'George Spafford'], publisher=Publisher(name='IT Revolution Press LLC'), edition=BookEdition(number=5, name='5th Anniversary Limited Edition'), @@ -393,8 +402,14 @@ def stock_ids(self) -> Set[StockId]: ThePhoenixProject_v1.chapters.append(Chapter(number=3, title='Tuesday, September 2')) ThePhoenixProject_v1.chapters.append(Chapter(number=4, title='Wednesday, September 3')) +# endregion ThePhoenixProject_v2 + +# region ThePhoenixProject_v2 + ThePhoenixProject_v2 = Book( - title='The Phoenix Project', isbn='978-1942788294', publish_date=date(year=2018, month=4, day=16), + title='The Phoenix Project', + isbn='978-1942788294', + publish_date=date(year=2018, month=4, day=16), authors=['Gene Kim', 'Kevin Behr', 'George Spafford'], publisher=Publisher(name='IT Revolution Press LLC', address='10 Downing Street'), edition=BookEdition(number=5, name='5th Anniversary Limited Edition'), @@ -418,8 +433,42 @@ def stock_ids(self) -> Set[StockId]: ThePhoenixProject_v2.references = {Ref3, Ref2, Ref1} +# endregion ThePhoenixProject_v2 + ThePhoenixProject = ThePhoenixProject_v2 +# region ThePhoenixProject_unnormalized + +# a case where the `normalizedString` and `token` transformation must come into play +ThePhoenixProject_unnormalized = Book( + title='The \n Phoenix Project ', + isbn='978-1942788294', + publish_date=date(year=2018, month=4, day=16), + authors=['Gene Kim', 'Kevin\r\nBehr', 'George\tSpafford'], + publisher=Publisher(name='IT Revolution Press LLC', address='10 Downing Street'), + edition=BookEdition(number=5, name='5th Anniversary Limited Edition'), + id=UUID('f3758bf0-0ff7-4366-a5e5-c209d4352b2d'), + rating=Decimal('9.8'), + stock_ids=[StockId('stock-id-1'), StockId('stock-id-2')] +) + +ThePhoenixProject_unnormalized.chapters.append(Chapter(number=1, title='Tuesday, September 2')) +ThePhoenixProject_unnormalized.chapters.append(Chapter(number=2, title='Tuesday,\tSeptember 2')) +ThePhoenixProject_unnormalized.chapters.append(Chapter(number=3, title='Tuesday,\r\nSeptember 2')) +ThePhoenixProject_unnormalized.chapters.append(Chapter(number=4, title='Wednesday,\rSeptember\n3')) + +SubRef1 = BookReference(ref=' sub-ref-1 ') +SubRef2 = BookReference(ref='\rsub-ref-2\t') +SubRef3 = BookReference(ref='\nsub-ref-3\r\n') + +Ref1 = BookReference(ref='\r\nmy-ref-1') +Ref2 = BookReference(ref='\tmy-ref-2', references=[SubRef1, SubRef3]) +Ref3 = BookReference(ref=' my-ref-3\n', references=[SubRef2]) + +ThePhoenixProject_unnormalized.references = {Ref3, Ref2, Ref1} + +# endregion ThePhoenixProject_unnormalized + if __name__ == '__main__': tpp_as_xml = ThePhoenixProject.as_xml() # type:ignore[attr-defined] tpp_as_json = ThePhoenixProject.as_json() # type:ignore[attr-defined] diff --git a/tests/test_xml.py b/tests/test_xml.py index 01f8f5e..b49d6d3 100644 --- a/tests/test_xml.py +++ b/tests/test_xml.py @@ -30,7 +30,15 @@ SnakeCasePropertyNameFormatter, ) from tests.base import FIXTURES_DIRECTORY, BaseTestCase, DeepCompareMixin -from tests.model import Book, SchemaVersion2, SchemaVersion3, SchemaVersion4, ThePhoenixProject, ThePhoenixProject_v1 +from tests.model import ( + Book, + SchemaVersion2, + SchemaVersion3, + SchemaVersion4, + ThePhoenixProject, + ThePhoenixProject_unnormalized, + ThePhoenixProject_v1, +) logger = logging.getLogger('serializable') logger.setLevel(logging.DEBUG) @@ -112,6 +120,15 @@ def test_serializable_with_defaultNS(self) -> None: self.maxDiff = None self.assertEqual(expected, actual) + def test_serialize_unnormalized(self) -> None: + """regression test #119 + for https://github.com/madpah/serializable/issues/114 + and https://github.com/madpah/serializable/issues/115 + """ + CurrentFormatter.formatter = CamelCasePropertyNameFormatter + with open(os.path.join(FIXTURES_DIRECTORY, 'the-phoenix-project-camel-case-1-v4.xml')) as expected_xml: + self.assertEqualXml(expected_xml.read(), ThePhoenixProject_unnormalized.as_xml(SchemaVersion4)) + # endregion test_serialize # region test_deserialize @@ -241,4 +258,14 @@ def test_deserializable_mixed_defaultNS_autodetect(self) -> None: actual = Book.from_xml(fixture_xml) self.assertDeepEqual(expected, actual) + def test_deserializable_unnormalized(self) -> None: + """regression test #119 + for https://github.com/madpah/serializable/issues/114 + and https://github.com/madpah/serializable/issues/115 + """ + expected = ThePhoenixProject + with open(os.path.join(FIXTURES_DIRECTORY, 'the-phoenix-project_unnormalized-input_v4.xml')) as fixture_xml: + actual = Book.from_xml(fixture_xml) + self.assertDeepEqual(expected, actual) + # region test_deserialize