Skip to content

Commit ce20508

Browse files
authored
Merge pull request #193 from dapper91/dev
- named tuple support added.
2 parents b8348a9 + e928d4b commit ce20508

File tree

10 files changed

+278
-3
lines changed

10 files changed

+278
-3
lines changed

CHANGELOG.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
Changelog
22
=========
33

4+
2.11.0 (2024-05-11)
5+
------------------
6+
7+
- named tuple support added. See https://github.com/dapper91/pydantic-xml/issues/172
8+
9+
410
2.10.0 (2024-05-09)
511
------------------
612

README.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ What is not supported?
4343
______________________
4444

4545
- `dataclasses <https://docs.pydantic.dev/usage/dataclasses/>`_
46+
- `callable discriminators <https://docs.pydantic.dev/latest/concepts/unions/#discriminated-unions-with-callable-discriminator>`_
4647

4748
Getting started
4849
---------------
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
from . import heterogeneous, homogeneous, is_instance, mapping, model, primitive, raw, tagged_union, tuple
2-
from . import typed_mapping, union, wrapper
1+
from . import call, heterogeneous, homogeneous, is_instance, mapping, model, named_tuple, primitive, raw, tagged_union
2+
from . import tuple, typed_mapping, union, wrapper
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import inspect
2+
3+
from pydantic_core import core_schema as pcs
4+
5+
from pydantic_xml import errors
6+
from pydantic_xml.serializers.factories import named_tuple
7+
from pydantic_xml.serializers.serializer import Serializer
8+
9+
10+
def from_core_schema(schema: pcs.CallSchema, ctx: Serializer.Context) -> Serializer:
11+
func = schema['function']
12+
13+
if inspect.isclass(func) and issubclass(func, tuple):
14+
return named_tuple.from_core_schema(schema, ctx)
15+
else:
16+
raise errors.ModelError("type call is not supported")

pydantic_xml/serializers/factories/heterogeneous.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ def from_core_schema(schema: pcs.TupleSchema, ctx: Serializer.Context) -> Serial
8585
SchemaTypeFamily.TYPED_MAPPING,
8686
SchemaTypeFamily.UNION,
8787
SchemaTypeFamily.IS_INSTANCE,
88+
SchemaTypeFamily.CALL,
8889
):
8990
raise errors.ModelFieldError(
9091
ctx.model_name, ctx.field_name, "collection item must be of primitive, model, mapping or union type",

pydantic_xml/serializers/factories/homogeneous.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ def from_core_schema(schema: HomogeneousCollectionTypeSchema, ctx: Serializer.Co
103103
SchemaTypeFamily.TYPED_MAPPING,
104104
SchemaTypeFamily.UNION,
105105
SchemaTypeFamily.IS_INSTANCE,
106+
SchemaTypeFamily.CALL,
106107
SchemaTypeFamily.TUPLE,
107108
):
108109
raise errors.ModelFieldError(
@@ -113,6 +114,7 @@ def from_core_schema(schema: HomogeneousCollectionTypeSchema, ctx: Serializer.Co
113114
SchemaTypeFamily.MODEL,
114115
SchemaTypeFamily.UNION,
115116
SchemaTypeFamily.TUPLE,
117+
SchemaTypeFamily.CALL,
116118
) and ctx.entity_location is None:
117119
raise errors.ModelFieldError(ctx.model_name, ctx.field_name, "entity name is not provided")
118120

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import typing
2+
from typing import Any, Dict, List, Optional, Tuple
3+
4+
from pydantic_core import core_schema as pcs
5+
6+
from pydantic_xml import errors
7+
from pydantic_xml.element import XmlElementReader, XmlElementWriter
8+
from pydantic_xml.serializers.factories import heterogeneous
9+
from pydantic_xml.serializers.serializer import TYPE_FAMILY, SchemaTypeFamily, Serializer
10+
from pydantic_xml.typedefs import EntityLocation, Location
11+
12+
13+
class ElementSerializer(Serializer):
14+
@classmethod
15+
def from_core_schema(cls, schema: pcs.ArgumentsSchema, ctx: Serializer.Context) -> 'ElementSerializer':
16+
model_name = ctx.model_name
17+
computed = ctx.field_computed
18+
inner_serializers: List[Serializer] = []
19+
for argument_schema in schema['arguments_schema']:
20+
param_schema = argument_schema['schema']
21+
inner_serializers.append(Serializer.parse_core_schema(param_schema, ctx))
22+
23+
return cls(model_name, computed, tuple(inner_serializers))
24+
25+
def __init__(self, model_name: str, computed: bool, inner_serializers: Tuple[Serializer, ...]):
26+
self._inner_serializer = heterogeneous.ElementSerializer(model_name, computed, inner_serializers)
27+
28+
def serialize(
29+
self, element: XmlElementWriter, value: List[Any], encoded: List[Any], *, skip_empty: bool = False,
30+
) -> Optional[XmlElementWriter]:
31+
return self._inner_serializer.serialize(element, value, encoded, skip_empty=skip_empty)
32+
33+
def deserialize(
34+
self,
35+
element: Optional[XmlElementReader],
36+
*,
37+
context: Optional[Dict[str, Any]],
38+
sourcemap: Dict[Location, int],
39+
loc: Location,
40+
) -> Optional[List[Any]]:
41+
return self._inner_serializer.deserialize(element, context=context, sourcemap=sourcemap, loc=loc)
42+
43+
44+
def from_core_schema(schema: pcs.CallSchema, ctx: Serializer.Context) -> Serializer:
45+
arguments_schema = typing.cast(pcs.ArgumentsSchema, schema['arguments_schema'])
46+
for argument_schema in arguments_schema['arguments_schema']:
47+
param_schema = argument_schema['schema']
48+
param_schema, ctx = Serializer.preprocess_schema(param_schema, ctx)
49+
50+
param_type_family = TYPE_FAMILY.get(param_schema['type'])
51+
if param_type_family not in (
52+
SchemaTypeFamily.PRIMITIVE,
53+
SchemaTypeFamily.MODEL,
54+
SchemaTypeFamily.MAPPING,
55+
SchemaTypeFamily.TYPED_MAPPING,
56+
SchemaTypeFamily.UNION,
57+
SchemaTypeFamily.IS_INSTANCE,
58+
SchemaTypeFamily.CALL,
59+
):
60+
raise errors.ModelFieldError(
61+
ctx.model_name, ctx.field_name, "tuple item must be of primitive, model, mapping or union type",
62+
)
63+
64+
if param_type_family not in (SchemaTypeFamily.MODEL, SchemaTypeFamily.UNION) and ctx.entity_location is None:
65+
raise errors.ModelFieldError(ctx.model_name, ctx.field_name, "entity name is not provided")
66+
67+
if ctx.entity_location is EntityLocation.ELEMENT:
68+
return ElementSerializer.from_core_schema(arguments_schema, ctx)
69+
elif ctx.entity_location is None:
70+
return ElementSerializer.from_core_schema(arguments_schema, ctx)
71+
elif ctx.entity_location is EntityLocation.ATTRIBUTE:
72+
raise errors.ModelFieldError(ctx.model_name, ctx.field_name, "attributes of tuple types are not supported")
73+
else:
74+
raise AssertionError("unreachable")

pydantic_xml/serializers/serializer.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ class SchemaTypeFamily(IntEnum):
3939
DEFINITION_REF = 10
4040
JSON_OR_PYTHON = 11
4141
IS_INSTANCE = 12
42+
CALL = 13
4243

4344

4445
TYPE_FAMILY = {
@@ -87,6 +88,8 @@ class SchemaTypeFamily(IntEnum):
8788
'definition-ref': SchemaTypeFamily.DEFINITION_REF,
8889

8990
'json-or-python': SchemaTypeFamily.JSON_OR_PYTHON,
91+
92+
'call': SchemaTypeFamily.CALL,
9093
}
9194

9295

@@ -265,6 +268,10 @@ def select_serializer(cls, schema: pcs.CoreSchema, ctx: Context) -> 'Serializer'
265268
schema = typing.cast(pcs.IsInstanceSchema, schema)
266269
return factories.is_instance.from_core_schema(schema, ctx)
267270

271+
elif type_family is SchemaTypeFamily.CALL:
272+
schema = typing.cast(pcs.CallSchema, schema)
273+
return factories.call.from_core_schema(schema, ctx)
274+
268275
else:
269276
raise AssertionError("unreachable")
270277

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "pydantic-xml"
3-
version = "2.10.0"
3+
version = "2.11.0"
44
description = "pydantic xml extension"
55
authors = ["Dmitry Pershin <dapper1291@gmail.com>"]
66
license = "Unlicense"

tests/test_named_tuple.py

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
from typing import List, NamedTuple, Optional, Union
2+
3+
from helpers import assert_xml_equal
4+
5+
from pydantic_xml import BaseXmlModel, RootXmlModel, attr, element
6+
7+
8+
def test_named_tuple_of_primitives_extraction():
9+
class TestTuple(NamedTuple):
10+
field1: int
11+
field2: float
12+
field3: str
13+
field4: Optional[str]
14+
15+
class TestModel(BaseXmlModel, tag='model1'):
16+
elements: TestTuple = element(tag='element')
17+
18+
xml = '''
19+
<model1>
20+
<element>1</element>
21+
<element>2.2</element>
22+
<element>string3</element>
23+
</model1>
24+
'''
25+
26+
actual_obj = TestModel.from_xml(xml)
27+
expected_obj = TestModel(elements=(1, 2.2, "string3", None))
28+
29+
assert actual_obj == expected_obj
30+
31+
actual_xml = actual_obj.to_xml(skip_empty=True)
32+
assert_xml_equal(actual_xml, xml)
33+
34+
35+
def test_named_tuple_of_mixed_types_extraction():
36+
class TestSubModel1(BaseXmlModel):
37+
attr1: int = attr()
38+
element1: float = element()
39+
40+
class TestTuple(NamedTuple):
41+
field1: TestSubModel1
42+
field2: int
43+
44+
class TestModel(BaseXmlModel, tag='model1'):
45+
submodels: TestTuple = element(tag='submodel')
46+
47+
xml = '''
48+
<model1>
49+
<submodel attr1="1">
50+
<element1>2.2</element1>
51+
</submodel>
52+
<submodel>1</submodel>
53+
</model1>
54+
'''
55+
56+
actual_obj = TestModel.from_xml(xml)
57+
expected_obj = TestModel(
58+
submodels=[
59+
TestSubModel1(attr1=1, element1=2.2),
60+
1,
61+
],
62+
)
63+
64+
assert actual_obj == expected_obj
65+
66+
actual_xml = actual_obj.to_xml()
67+
assert_xml_equal(actual_xml, xml)
68+
69+
70+
def test_list_of_named_tuples_extraction():
71+
class TestTuple(NamedTuple):
72+
field1: int
73+
field2: Optional[float] = None
74+
75+
class RootModel(BaseXmlModel, tag='model'):
76+
elements: List[TestTuple] = element(tag='element')
77+
78+
xml = '''
79+
<model>
80+
<element>1</element>
81+
<element>1.1</element>
82+
<element>2</element>
83+
<element></element>
84+
<element>3</element>
85+
<element>3.3</element>
86+
</model>
87+
'''
88+
89+
actual_obj = RootModel.from_xml(xml)
90+
expected_obj = RootModel(
91+
elements=[
92+
(1, 1.1),
93+
(2, None),
94+
(3, 3.3),
95+
],
96+
)
97+
98+
assert actual_obj == expected_obj
99+
100+
actual_xml = actual_obj.to_xml()
101+
assert_xml_equal(actual_xml, xml)
102+
103+
104+
def test_list_of_named_tuples_of_models_extraction():
105+
class SubModel1(RootXmlModel[str], tag='text'):
106+
pass
107+
108+
class SubModel2(RootXmlModel[int], tag='number'):
109+
pass
110+
111+
class TestTuple(NamedTuple):
112+
field1: SubModel1
113+
field2: Optional[SubModel2] = None
114+
115+
class RootModel(BaseXmlModel, tag='model'):
116+
elements: List[TestTuple]
117+
118+
xml = '''
119+
<model>
120+
<text>text1</text>
121+
<number>1</number>
122+
<text>text2</text>
123+
<text>text3</text>
124+
<number>3</number>
125+
</model>
126+
'''
127+
128+
actual_obj = RootModel.from_xml(xml)
129+
expected_obj = RootModel(
130+
elements=[
131+
(SubModel1('text1'), SubModel2(1)),
132+
(SubModel1('text2'), None),
133+
(SubModel1('text3'), SubModel2(3)),
134+
],
135+
)
136+
137+
assert actual_obj == expected_obj
138+
139+
actual_xml = actual_obj.to_xml()
140+
assert_xml_equal(actual_xml, xml)
141+
142+
143+
def test_primitive_union_named_tuple():
144+
class TestTuple(NamedTuple):
145+
field1: Union[int, float]
146+
field2: str
147+
field3: Union[int, float]
148+
149+
class TestModel(BaseXmlModel, tag='model'):
150+
sublements: TestTuple = element(tag='model1')
151+
152+
xml = '''
153+
<model>
154+
<model1>1.1</model1>
155+
<model1>text</model1>
156+
<model1>1</model1>
157+
</model>
158+
'''
159+
160+
actual_obj = TestModel.from_xml(xml)
161+
expected_obj = TestModel(
162+
sublements=(float('1.1'), 'text', 1),
163+
)
164+
165+
assert actual_obj == expected_obj
166+
167+
actual_xml = actual_obj.to_xml()
168+
assert_xml_equal(actual_xml, xml)

0 commit comments

Comments
 (0)