From 8025fbe5b86e34a001a0f46db692f375f52cec7a Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 2 Aug 2021 22:09:36 +0300 Subject: [PATCH] Add serializer option to ignore optional default attributes --- docs/xml.rst | 3 +- .../formats/dataclass/models/test_builders.py | 6 +++ .../dataclass/serializers/test_json.py | 22 ++++++++ .../formats/dataclass/serializers/test_xml.py | 17 +++++-- tests/formats/dataclass/test_elements.py | 12 +++++ xsdata/formats/dataclass/models/builders.py | 2 + xsdata/formats/dataclass/models/elements.py | 14 ++++++ xsdata/formats/dataclass/parsers/config.py | 31 ++++++++---- .../dataclass/parsers/nodes/element.py | 3 +- xsdata/formats/dataclass/parsers/tree.py | 1 + .../formats/dataclass/serializers/config.py | 46 ++++++++++++----- xsdata/formats/dataclass/serializers/json.py | 8 ++- xsdata/formats/dataclass/serializers/xml.py | 50 +++++++++++++------ xsdata/utils/testing.py | 2 + 14 files changed, 175 insertions(+), 42 deletions(-) diff --git a/docs/xml.rst b/docs/xml.rst index 87f0f336b..4de144440 100644 --- a/docs/xml.rst +++ b/docs/xml.rst @@ -392,12 +392,13 @@ Serializer Config ... encoding="UTF-8", ... xml_version="1.1", ... xml_declaration=False, + ... ignore_default_attributes=True, ... schema_location="urn books.xsd", ... no_namespace_schema_location=None, ... )) >>> print(serializer.render(books)) - + Hightower, Kim The First Book Fiction diff --git a/tests/formats/dataclass/models/test_builders.py b/tests/formats/dataclass/models/test_builders.py index 128e245d6..1a569cc71 100644 --- a/tests/formats/dataclass/models/test_builders.py +++ b/tests/formats/dataclass/models/test_builders.py @@ -144,6 +144,7 @@ def test_build_vars(self): name="author", qname="Author", types=(str,), + required=True, ), XmlVarFactory.create( xml_type=XmlType.ELEMENT, @@ -151,6 +152,7 @@ def test_build_vars(self): name="title", qname="Title", types=(str,), + required=True, ), XmlVarFactory.create( xml_type=XmlType.ELEMENT, @@ -158,6 +160,7 @@ def test_build_vars(self): name="genre", qname="Genre", types=(str,), + required=True, ), XmlVarFactory.create( xml_type=XmlType.ELEMENT, @@ -165,6 +168,7 @@ def test_build_vars(self): name="price", qname="Price", types=(float,), + required=True, ), XmlVarFactory.create( xml_type=XmlType.ELEMENT, @@ -172,6 +176,7 @@ def test_build_vars(self): name="pub_date", qname="PubDate", types=(XmlDate,), + required=True, ), XmlVarFactory.create( xml_type=XmlType.ELEMENT, @@ -179,6 +184,7 @@ def test_build_vars(self): name="review", qname="Review", types=(str,), + required=True, ), XmlVarFactory.create( xml_type=XmlType.ATTRIBUTE, index=7, name="id", qname="ID", types=(str,) diff --git a/tests/formats/dataclass/serializers/test_json.py b/tests/formats/dataclass/serializers/test_json.py index ace75ff06..ff8056638 100644 --- a/tests/formats/dataclass/serializers/test_json.py +++ b/tests/formats/dataclass/serializers/test_json.py @@ -116,3 +116,25 @@ def test_indent_deprecation(self): "JsonSerializer indent property is deprecated, use SerializerConfig", str(w[-1].message), ) + + def test_next_value(self): + book = self.books.book[0] + serializer = JsonSerializer() + + actual = [name for name, value in serializer.next_value(book)] + expected = [ + "author", + "title", + "genre", + "price", + "pub_date", + "review", + "id", + "lang", + ] + self.assertEqual(expected, actual) + + serializer.config.ignore_default_attributes = True + expected = expected[:-1] + actual = [name for name, value in serializer.next_value(book)] + self.assertEqual(expected, actual) diff --git a/tests/formats/dataclass/serializers/test_xml.py b/tests/formats/dataclass/serializers/test_xml.py index 18d75d70a..56c74c2c8 100644 --- a/tests/formats/dataclass/serializers/test_xml.py +++ b/tests/formats/dataclass/serializers/test_xml.py @@ -505,7 +505,7 @@ def test_write_value_with_list_value(self): self.assertEqual(expected, list(result)) def test_next_value(self): - obj = SequentialType(x0=1, x1=[2, 3, 4], x2=[6, 7], x3=[9]) + obj = SequentialType(x0=1, x1=[2, 3, 4, None], x2=[6, 7], x3=[9]) meta = self.serializer.context.build(SequentialType) x0 = meta.text x1 = next(meta.find_children("x1")) @@ -530,7 +530,7 @@ def test_next_attribute(self): obj = SequentialType(a0="foo", a1={"b": "c", "d": "e"}) meta = self.serializer.context.build(SequentialType) - actual = self.serializer.next_attribute(obj, meta, False, None) + actual = self.serializer.next_attribute(obj, meta, False, None, False) expected = [ ("a0", "foo"), ("b", "c"), @@ -540,7 +540,7 @@ def test_next_attribute(self): self.assertIsInstance(actual, Generator) self.assertEqual(expected, list(actual)) - actual = self.serializer.next_attribute(obj, meta, True, "xs:bool") + actual = self.serializer.next_attribute(obj, meta, True, "xs:bool", False) expected.extend( [ (QNames.XSI_TYPE, "xs:bool"), @@ -549,6 +549,17 @@ def test_next_attribute(self): ) self.assertEqual(expected, list(actual)) + meta.attributes["a0"].required = False + meta.attributes["a0"].default = "foo" + actual = self.serializer.next_attribute(obj, meta, False, None, True) + expected = [ + ("b", "c"), + ("d", "e"), + ] + + self.assertIsInstance(actual, Generator) + self.assertEqual(expected, list(actual)) + def test_render_mixed_content(self): obj = Paragraph() diff --git a/tests/formats/dataclass/test_elements.py b/tests/formats/dataclass/test_elements.py index dd74b8b82..bf97c9c95 100644 --- a/tests/formats/dataclass/test_elements.py +++ b/tests/formats/dataclass/test_elements.py @@ -95,6 +95,18 @@ def test_find_value_choice(self): self.assertEqual(var.elements["a"], var.find_value_choice(TypeA(1), True)) self.assertEqual(var.elements["b"], var.find_value_choice(TypeB(1, "b"), True)) + def test_is_optional(self): + var = XmlVarFactory.create(xml_type=XmlType.ATTRIBUTE, name="att") + + self.assertTrue(var.is_optional(None)) + self.assertFalse(var.is_optional("foo")) + + var.default = lambda: [1, 2, 3] + self.assertTrue(var.is_optional([1, 2, 3])) + + var.required = True + self.assertFalse(var.is_optional([1, 2, 3])) + def test_match_namespace(self): var = XmlVarFactory.create(xml_type=XmlType.WILDCARD, name="foo") self.assertTrue(var.match_namespace("a")) diff --git a/xsdata/formats/dataclass/models/builders.py b/xsdata/formats/dataclass/models/builders.py index d6de970c3..5dbad68ab 100644 --- a/xsdata/formats/dataclass/models/builders.py +++ b/xsdata/formats/dataclass/models/builders.py @@ -245,6 +245,7 @@ def build( namespace = metadata.get("namespace") choices = metadata.get("choices", EMPTY_SEQUENCE) mixed = metadata.get("mixed", False) + required = metadata.get("required", False) nillable = metadata.get("nillable", False) format_str = metadata.get("format", None) sequential = metadata.get("sequential", False) @@ -288,6 +289,7 @@ def build( format=format_str, clazz=clazz, any_type=any_type, + required=required, nillable=nillable, sequential=sequential, factory=origin, diff --git a/xsdata/formats/dataclass/models/elements.py b/xsdata/formats/dataclass/models/elements.py index ec37ef20f..0aa06194c 100644 --- a/xsdata/formats/dataclass/models/elements.py +++ b/xsdata/formats/dataclass/models/elements.py @@ -65,6 +65,7 @@ class XmlVar(MetaMixin): :param format: Value format information :param derived: Wrap parsed values with a generic type :param any_type: Field supports dynamic value types + :param required: Field is mandatory :param nillable: Field supports nillable content :param sequential: Render values in sequential mode :param list_element: Field is a list of elements @@ -88,6 +89,7 @@ class XmlVar(MetaMixin): "format", "derived", "any_type", + "required", "nillable", "sequential", "default", @@ -122,6 +124,7 @@ def __init__( format: Optional[str], derived: bool, any_type: bool, + required: bool, nillable: bool, sequential: bool, default: Any, @@ -142,6 +145,7 @@ def __init__( self.format = format self.derived = derived self.any_type = any_type + self.required = required self.nillable = nillable self.sequential = sequential self.list_element = factory in (list, tuple) @@ -219,6 +223,16 @@ def find_type_choice( return None + def is_optional(self, value: Any) -> bool: + """Return whether this var instance is not required and the given value + matches the default one.""" + if self.required: + return False + + if callable(self.default): + return self.default() == value + return self.default == value + @classmethod def match_type( cls, value: Any, tp: Type, types: Sequence[Type], check_subclass: bool diff --git a/xsdata/formats/dataclass/parsers/config.py b/xsdata/formats/dataclass/parsers/config.py index 2c0d776ce..cca47fb95 100644 --- a/xsdata/formats/dataclass/parsers/config.py +++ b/xsdata/formats/dataclass/parsers/config.py @@ -1,18 +1,15 @@ -from dataclasses import dataclass from typing import Callable from typing import Dict from typing import Optional from typing import Type -from typing import TypeVar -T = TypeVar("T") +from xsdata.formats.bindings import T def default_class_factory(cls: Type[T], params: Dict) -> T: return cls(**params) # type: ignore -@dataclass class ParserConfig: """ Parsing configuration options. @@ -27,8 +24,24 @@ class ParserConfig: exceptions """ - base_url: Optional[str] = None - process_xinclude: bool = False - class_factory: Callable[[Type[T], Dict], T] = default_class_factory - fail_on_unknown_properties: bool = True - fail_on_converter_warnings: bool = False + __slots__ = ( + "base_url", + "process_xinclude", + "class_factory", + "fail_on_unknown_properties", + "fail_on_converter_warnings", + ) + + def __init__( + self, + base_url: Optional[str] = None, + process_xinclude: bool = False, + class_factory: Callable[[Type[T], Dict], T] = default_class_factory, + fail_on_unknown_properties: bool = True, + fail_on_converter_warnings: bool = False, + ): + self.base_url = base_url + self.process_xinclude = process_xinclude + self.class_factory = class_factory + self.fail_on_unknown_properties = fail_on_unknown_properties + self.fail_on_converter_warnings = fail_on_converter_warnings diff --git a/xsdata/formats/dataclass/parsers/nodes/element.py b/xsdata/formats/dataclass/parsers/nodes/element.py index f9aff71ad..001520a54 100644 --- a/xsdata/formats/dataclass/parsers/nodes/element.py +++ b/xsdata/formats/dataclass/parsers/nodes/element.py @@ -81,13 +81,12 @@ def bind( self, qname: str, text: Optional[str], tail: Optional[str], objects: List ) -> bool: + obj: Any = None if not self.xsi_nil or self.meta.nillable: params: Dict = {} self.bind_attrs(params) self.bind_content(params, text, tail, objects) obj = self.config.class_factory(self.meta.clazz, params) - else: - obj = None if self.derived_factory: obj = self.derived_factory(qname=qname, value=obj, type=self.xsi_type) diff --git a/xsdata/formats/dataclass/parsers/tree.py b/xsdata/formats/dataclass/parsers/tree.py index 3e4134c83..4a990de31 100644 --- a/xsdata/formats/dataclass/parsers/tree.py +++ b/xsdata/formats/dataclass/parsers/tree.py @@ -52,6 +52,7 @@ def start( format=None, derived=False, any_type=False, + required=False, nillable=False, sequential=False, default=None, diff --git a/xsdata/formats/dataclass/serializers/config.py b/xsdata/formats/dataclass/serializers/config.py index 603167ec9..b140d75cd 100644 --- a/xsdata/formats/dataclass/serializers/config.py +++ b/xsdata/formats/dataclass/serializers/config.py @@ -1,25 +1,47 @@ -from dataclasses import dataclass -from dataclasses import field from typing import Optional -@dataclass class SerializerConfig: """ - Serializing configuration options. + Serializer configuration options. + + Some options are not applicable for both xml or json documents. :param encoding: Text encoding :param xml_version: XML Version number (1.0|1.1) :param xml_declaration: Generate XML declaration :param pretty_print: Enable pretty output - :param schema_location: Specify the xsi:schemaLocation attribute value - :param no_namespace_schema_location: Specify the xsi:noNamespaceSchemaLocation + :param ignore_default_attributes: Ignore optional attributes with + default values + :param schema_location: xsi:schemaLocation attribute value + :param no_namespace_schema_location: xsi:noNamespaceSchemaLocation attribute value """ - encoding: str = field(default="UTF-8") - xml_version: str = field(default="1.0") - xml_declaration: bool = field(default=True) - pretty_print: bool = field(default=False) - schema_location: Optional[str] = field(default=None) - no_namespace_schema_location: Optional[str] = field(default=None) + __slots__ = ( + "encoding", + "xml_version", + "xml_declaration", + "pretty_print", + "ignore_default_attributes", + "schema_location", + "no_namespace_schema_location", + ) + + def __init__( + self, + encoding: str = "UTF-8", + xml_version: str = "1.0", + xml_declaration: bool = True, + pretty_print: bool = False, + ignore_default_attributes: bool = False, + schema_location: Optional[str] = None, + no_namespace_schema_location: Optional[str] = None, + ): + self.encoding = encoding + self.xml_version = xml_version + self.xml_declaration = xml_declaration + self.pretty_print = pretty_print + self.ignore_default_attributes = ignore_default_attributes + self.schema_location = schema_location + self.no_namespace_schema_location = no_namespace_schema_location diff --git a/xsdata/formats/dataclass/serializers/json.py b/xsdata/formats/dataclass/serializers/json.py index e8edc6441..f62e24ce3 100644 --- a/xsdata/formats/dataclass/serializers/json.py +++ b/xsdata/formats/dataclass/serializers/json.py @@ -94,5 +94,11 @@ def convert(self, obj: Any, var: Optional[XmlVar] = None) -> Any: return converter.serialize(obj, format=var.format) def next_value(self, obj: Any) -> Iterator[Tuple[str, Any]]: + ignore_optionals = self.config.ignore_default_attributes + for var in self.context.build(obj.__class__).get_all_vars(): - yield var.local_name, self.convert(getattr(obj, var.name), var) + value = getattr(obj, var.name) + if var.is_attribute and ignore_optionals and var.is_optional(value): + continue + + yield var.local_name, self.convert(value, var) diff --git a/xsdata/formats/dataclass/serializers/xml.py b/xsdata/formats/dataclass/serializers/xml.py index 0deb26c73..b3cda0343 100644 --- a/xsdata/formats/dataclass/serializers/xml.py +++ b/xsdata/formats/dataclass/serializers/xml.py @@ -6,9 +6,11 @@ from typing import Dict from typing import Generator from typing import Iterable +from typing import Iterator from typing import List from typing import Optional from typing import TextIO +from typing import Tuple from typing import Type from xml.etree.ElementTree import QName @@ -105,12 +107,13 @@ def write_dataclass( yield XmlWriterEvent.START, qname - for key, value in self.next_attribute(obj, meta, nillable, xsi_type): + for key, value in self.next_attribute( + obj, meta, nillable, xsi_type, self.config.ignore_default_attributes + ): yield XmlWriterEvent.ATTR, key, value for var, value in self.next_value(obj, meta): - if value is not None or var.nillable: - yield from self.write_value(value, var, namespace) + yield from self.write_value(value, var, namespace) yield XmlWriterEvent.END, qname @@ -302,7 +305,7 @@ def write_data(cls, value: Any, var: XmlVar, namespace: NoneStr) -> Generator: yield XmlWriterEvent.DATA, cls.encode(value, var) @classmethod - def next_value(cls, obj: Any, meta: XmlMeta): + def next_value(cls, obj: Any, meta: XmlMeta) -> Iterator[Tuple[XmlVar, Any]]: """ Return the non attribute variables with their object values in the correct order according to their definition and the sequential metadata @@ -318,7 +321,9 @@ def next_value(cls, obj: Any, meta: XmlMeta): var = attrs[index] if not var.sequential: - yield var, getattr(obj, var.name) + value = getattr(obj, var.name) + if value is not None or var.nillable: + yield var, value index += 1 continue @@ -335,23 +340,40 @@ def next_value(cls, obj: Any, meta: XmlMeta): values = getattr(obj, var.name) if j < len(values): rolling = True - yield var, values[j] + value = values[j] + + if value is not None or var.nillable: + yield var, value j += 1 @classmethod def next_attribute( - cls, obj: Any, meta: XmlMeta, xsi_nil: bool, xsi_type: Optional[str] - ) -> Generator: + cls, + obj: Any, + meta: XmlMeta, + nillable: bool, + xsi_type: Optional[str], + ignore_optionals: bool, + ) -> Iterator[Tuple[str, Any]]: """ - Return the attribute variables with their object values. - - Ignores None values and empty lists. Optionally include the xsi - properties type and nil. + Return the attribute variables with their object values if set and not + empty iterables. + + :param obj: Input object + :param meta: Object metadata + :param nillable: Is model nillable + :param xsi_type: The true xsi:type of the object + :param ignore_optionals: Skip optional attributes with default value + :return: """ for var in meta.get_attribute_vars(): if var.is_attribute: value = getattr(obj, var.name) - if value is None or collections.is_array(value) and not value: + if ( + value is None + or (collections.is_array(value) and not value) + or (ignore_optionals and var.is_optional(value)) + ): continue yield var.qname, cls.encode(value, var) @@ -361,7 +383,7 @@ def next_attribute( if xsi_type: yield QNames.XSI_TYPE, QName(xsi_type) - if xsi_nil: + if nillable: yield QNames.XSI_NIL, "true" @classmethod diff --git a/xsdata/utils/testing.py b/xsdata/utils/testing.py index 73e3bf1ef..be59be33d 100644 --- a/xsdata/utils/testing.py +++ b/xsdata/utils/testing.py @@ -356,6 +356,7 @@ def create( format: Optional[str] = None, derived: bool = False, any_type: bool = False, + required: bool = False, nillable: bool = False, sequential: bool = False, list_element: bool = False, @@ -395,6 +396,7 @@ def create( format=format, derived=derived, any_type=any_type, + required=required, nillable=nillable, sequential=sequential, list_element=list_element,