Skip to content

Commit

Permalink
Add globalns support via SerializerConfig (#724)
Browse files Browse the repository at this point in the history
Co-authored-by: Simon Schiele <[email protected]>
  • Loading branch information
simonschiele and simonschiele authored Dec 12, 2022
1 parent 351464d commit 751198b
Show file tree
Hide file tree
Showing 12 changed files with 188 additions and 7 deletions.
1 change: 1 addition & 0 deletions docs/examples.rst
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Advance Topics
examples/custom-property-names
examples/custom-class-factory
examples/wrapped-list
examples/custom-type-mapping


Test Suites
Expand Down
75 changes: 75 additions & 0 deletions docs/examples/custom-type-mapping.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
===================
Custom type mapping
===================

When managing a big collection of models, it sometimes is tricky to split them
into multiple python modules. Even more so if they depend on each other. For
the models to be serializable by xsdata, they need to be able to import all
other referenced models, which might not be possible due to circular imports.

One solution to get around this problem is to fence the imports within the
python modules by using :data:`python:typing.TYPE_CHECKING` and passing a
dedicated type-map dictionary to the
:class:`~xsdata.formats.dataclass.serializers.config.SerializerConfig`.


.. tab:: city.py

.. literalinclude:: /../tests/models/typemapping/city.py
:language: python

.. tab:: street.py

.. literalinclude:: /../tests/models/typemapping/street.py
:language: python

.. tab:: house.py

.. literalinclude:: /../tests/models/typemapping/house.py
:language: python


By fencing the imports, we are able to keep our models in different python
modules that are cleanly importable and considered valid by static type
checkers.

Passing the type-map dictionary, which maps the class/model-names directly to
imported objects, enables xsdata to serialize the models.


.. testcode::

from xsdata.formats.dataclass.serializers import XmlSerializer
from xsdata.formats.dataclass.serializers.config import SerializerConfig

from tests.models.typemapping.city import City
from tests.models.typemapping.house import House
from tests.models.typemapping.street import Street


city1 = City(name="footown")
street1 = Street(name="foostreet")
house1 = House(number=23)
city1.streets.append(street1)
street1.houses.append(house1)

type_map = {"City": City, "Street": Street, "House": House}
serializer_config = SerializerConfig(pretty_print=True, globalns=type_map)

xml_serializer = XmlSerializer(config=serializer_config)
serialized_house = xml_serializer.render(city1)
print(serialized_house)


.. testoutput::

<?xml version="1.0" encoding="UTF-8"?>
<City>
<name>footown</name>
<streets>
<name>foostreet</name>
<houses>
<number>23</number>
</houses>
</streets>
</City>
30 changes: 30 additions & 0 deletions tests/models/test_type_mapping.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from unittest import TestCase

from tests.models.typemapping.city import City
from tests.models.typemapping.house import House
from tests.models.typemapping.street import Street
from xsdata.formats.dataclass.serializers import JsonSerializer
from xsdata.formats.dataclass.serializers import PycodeSerializer
from xsdata.formats.dataclass.serializers import XmlSerializer
from xsdata.formats.dataclass.serializers.config import SerializerConfig


class TypeMappingTests(TestCase):
def test_type_mapping(self):
city1 = City(name="footown")
street1 = Street(name="foostreet")
house1 = House(number=23)
city1.streets.append(street1)
street1.houses.append(house1)

type_mapping = {"City": City, "Street": Street, "House": House}
serializer_config = SerializerConfig(globalns=type_mapping)

json_serializer = JsonSerializer(config=serializer_config)
xml_serializer = XmlSerializer(config=serializer_config)
pycode_serializer = PycodeSerializer(config=serializer_config)

for model in (city1, street1, house1):
json_serializer.render(model)
xml_serializer.render(model)
pycode_serializer.render(model)
Empty file.
16 changes: 16 additions & 0 deletions tests/models/typemapping/city.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from dataclasses import dataclass
from dataclasses import field
from typing import List
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from tests.models.typemapping.street import Street


@dataclass
class City:
class Meta:
global_type = False

name: str
streets: List["Street"] = field(default_factory=list)
15 changes: 15 additions & 0 deletions tests/models/typemapping/house.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from dataclasses import dataclass
from typing import Optional
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from tests.models.typemapping.street import Street


@dataclass
class House:
class Meta:
global_type = False

number: int
street: Optional["Street"] = None
19 changes: 19 additions & 0 deletions tests/models/typemapping/street.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from dataclasses import dataclass
from dataclasses import field
from typing import List
from typing import Optional
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from tests.models.typemapping.city import City
from tests.models.typemapping.house import House


@dataclass
class Street:
class Meta:
global_type = False

name: str
city: Optional["City"] = None
houses: List["House"] = field(default_factory=list)
8 changes: 7 additions & 1 deletion xsdata/formats/dataclass/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,12 @@ def find_subclass(self, clazz: Type, qname: str) -> Optional[Type]:

return None

def build(self, clazz: Type, parent_ns: Optional[str] = None) -> XmlMeta:
def build(
self,
clazz: Type,
parent_ns: Optional[str] = None,
globalns: Optional[Dict[str, Callable]] = None,
) -> XmlMeta:
"""
Fetch from cache or build the binding metadata for the given class and
parent namespace.
Expand All @@ -188,6 +193,7 @@ def build(self, clazz: Type, parent_ns: Optional[str] = None) -> XmlMeta:
class_type=self.class_type,
element_name_generator=self.element_name_generator,
attribute_name_generator=self.attribute_name_generator,
globalns=globalns,
)
self.cache[clazz] = builder.build(clazz, parent_ns)
return self.cache[clazz]
Expand Down
11 changes: 9 additions & 2 deletions xsdata/formats/dataclass/models/builders.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,18 +40,25 @@ class ClassMeta(NamedTuple):


class XmlMetaBuilder:
__slots__ = "class_type", "element_name_generator", "attribute_name_generator"
__slots__ = (
"class_type",
"element_name_generator",
"attribute_name_generator",
"globalns",
)

def __init__(
self,
class_type: ClassType,
element_name_generator: Callable,
attribute_name_generator: Callable,
globalns: Optional[Dict[str, Callable]] = None,
):

self.class_type = class_type
self.element_name_generator = element_name_generator
self.attribute_name_generator = attribute_name_generator
self.globalns = globalns

def build(self, clazz: Type, parent_namespace: Optional[str]) -> XmlMeta:
"""Build the binding metadata for a dataclass and its fields."""
Expand Down Expand Up @@ -112,7 +119,7 @@ def build_vars(
attribute_name_generator: Callable,
):
"""Build the binding metadata for the given dataclass fields."""
type_hints = get_type_hints(clazz)
type_hints = get_type_hints(clazz, globalns=self.globalns)
builder = XmlVarBuilder(
class_type=self.class_type,
default_xml_type=self.default_xml_type(clazz),
Expand Down
7 changes: 7 additions & 0 deletions xsdata/formats/dataclass/serializers/config.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import Callable
from typing import Dict
from typing import Optional


Expand All @@ -16,6 +18,8 @@ class SerializerConfig:
:param schema_location: xsi:schemaLocation attribute value
:param no_namespace_schema_location: xsi:noNamespaceSchemaLocation
attribute value
:param globalns: Dictionary containing global variables to extend
or overwrite for typing
"""

__slots__ = (
Expand All @@ -26,6 +30,7 @@ class SerializerConfig:
"ignore_default_attributes",
"schema_location",
"no_namespace_schema_location",
"globalns",
)

def __init__(
Expand All @@ -37,6 +42,7 @@ def __init__(
ignore_default_attributes: bool = False,
schema_location: Optional[str] = None,
no_namespace_schema_location: Optional[str] = None,
globalns: Optional[Dict[str, Callable]] = None,
):
self.encoding = encoding
self.xml_version = xml_version
Expand All @@ -45,3 +51,4 @@ def __init__(
self.ignore_default_attributes = ignore_default_attributes
self.schema_location = schema_location
self.no_namespace_schema_location = no_namespace_schema_location
self.globalns = globalns
4 changes: 3 additions & 1 deletion xsdata/formats/dataclass/serializers/json.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,9 @@ def convert(self, obj: Any, var: Optional[XmlVar] = None) -> Any:
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():
for var in self.context.build(
obj.__class__, globalns=self.config.globalns
).get_all_vars():
value = getattr(obj, var.name)
if var.is_attribute and ignore_optionals and var.is_optional(value):
continue
Expand Down
9 changes: 6 additions & 3 deletions xsdata/formats/dataclass/serializers/xml.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,9 @@ def write_object(self, obj: Any):
"""Produce an events stream from a dataclass or a derived element."""
qname = xsi_type = None
if isinstance(obj, self.context.class_type.derived_element):
meta = self.context.build(obj.value.__class__)
meta = self.context.build(
obj.value.__class__, globalns=self.config.globalns
)
qname = obj.qname
obj = obj.value
xsi_type = namespaces.real_xsi_type(qname, meta.target_qname)
Expand All @@ -99,8 +101,9 @@ def write_dataclass(
Optionally override the qualified name and the xsi properties
type and nil.
"""

meta = self.context.build(obj.__class__, namespace)
meta = self.context.build(
obj.__class__, namespace, globalns=self.config.globalns
)
qname = qname or meta.qname
nillable = nillable or meta.nillable
namespace, tag = namespaces.split_qname(qname)
Expand Down

0 comments on commit 751198b

Please sign in to comment.