Skip to content

Commit 4a8e997

Browse files
authored
Merge pull request #22 from dapper91/dev
- field default parameter support added. - field default_factory parameter support added. - root model validation added. - pydantic field alias support implemented.
2 parents d717a29 + d3ef3ee commit 4a8e997

File tree

9 files changed

+108
-26
lines changed

9 files changed

+108
-26
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ repos:
3838
- commit
3939
args:
4040
- --diff
41-
- repo: https://gitlab.com/pycqa/flake8
41+
- repo: https://github.com/pycqa/flake8
4242
rev: 3.9.2
4343
hooks:
4444
- id: flake8

CHANGELOG.rst

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

4+
0.4.0 (2022-12-19)
5+
------------------
6+
7+
- field default parameter support added.
8+
- field default_factory parameter support added.
9+
- root model validation added.
10+
- pydantic field alias support implemented.
11+
12+
413
0.3.0 (2022-11-10)
514
------------------
615

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -356,7 +356,7 @@ AuthType = TypeVar('AuthType')
356356
PayloadType = TypeVar('PayloadType')
357357

358358

359-
class Request(pxml.BaseGenericXmlModel, Generic[AuthType, PayloadType], tag='reqeust'):
359+
class Request(pxml.BaseGenericXmlModel, Generic[AuthType, PayloadType], tag='request'):
360360
service_name: str = pxml.attr(name='service-name')
361361
request_id: str = pxml.attr(name='request-id')
362362
timestamp: dt.datetime = pxml.attr()

examples/generics.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ class BasicAuth(pxml.BaseXmlModel):
3434
PayloadType = TypeVar('PayloadType')
3535

3636

37-
class Request(pxml.BaseGenericXmlModel, Generic[AuthType, PayloadType], tag='reqeust'):
37+
class Request(pxml.BaseGenericXmlModel, Generic[AuthType, PayloadType], tag='request'):
3838
service_name: str = pxml.attr(name='service-name')
3939
request_id: str = pxml.attr(name='request-id')
4040
timestamp: dt.datetime = pxml.attr()

pydantic_xml/model.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,11 @@ def __init__(
100100
nsmap: Optional[NsMap] = None,
101101
**kwargs: Any,
102102
):
103+
if entity is not None:
104+
# copy arguments from the wrapped entity to let pydantic know how to process the field
105+
for entity_field_name in entity.__slots__:
106+
kwargs[entity_field_name] = getattr(entity, entity_field_name)
107+
103108
super().__init__(**kwargs)
104109
self._entity = entity
105110
self._path = path
@@ -208,7 +213,7 @@ def __init_serializer__(cls) -> None:
208213
cls.__xml_serializer__ = serializers.ModelSerializerFactory.from_model(cls)
209214

210215
@classmethod
211-
def from_xml_tree(cls, root: etree.Element) -> 'BaseXmlModel':
216+
def from_xml_tree(cls, root: etree.Element) -> Optional['BaseXmlModel']:
212217
"""
213218
Deserializes an xml element tree to an object of `cls` type.
214219
@@ -217,12 +222,15 @@ def from_xml_tree(cls, root: etree.Element) -> 'BaseXmlModel':
217222
"""
218223

219224
assert cls.__xml_serializer__ is not None, "model is partially initialized"
220-
obj = cls.__xml_serializer__.deserialize(root)
221225

222-
return cls.parse_obj(obj)
226+
if root.tag == cls.__xml_serializer__.element_name:
227+
obj = cls.__xml_serializer__.deserialize(root)
228+
return cls.parse_obj(obj)
229+
else:
230+
return None
223231

224232
@classmethod
225-
def from_xml(cls, source: Union[str, bytes]) -> 'BaseXmlModel':
233+
def from_xml(cls, source: Union[str, bytes]) -> Optional['BaseXmlModel']:
226234
"""
227235
Deserializes an xml string to an object of `cls` type.
228236
@@ -248,7 +256,7 @@ def to_xml_tree(
248256

249257
encoder = encoder or serializers.DEFAULT_ENCODER
250258

251-
assert self.__xml_serializer__ is not None
259+
assert self.__xml_serializer__ is not None, "model is partially initialized"
252260
root = self.__xml_serializer__.serialize(None, self, encoder=encoder, skip_empty=skip_empty)
253261
assert root is not None
254262

@@ -300,7 +308,7 @@ def __init_serializer__(cls) -> None:
300308
super().__init_serializer__()
301309

302310
@classmethod
303-
def from_xml_tree(cls, root: etree.Element) -> 'BaseXmlModel':
311+
def from_xml_tree(cls, root: etree.Element) -> Optional['BaseXmlModel']:
304312
"""
305313
Deserializes an xml element tree to an object of `cls` type.
306314

pydantic_xml/serializers.py

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ class PrimitiveTypeSerializerFactory:
200200

201201
class TextSerializer(Serializer):
202202
def serialize(
203-
self, element: etree.Element, value: Any, *, encoder: XmlEncoder, skip_empty: bool = False,
203+
self, element: etree.Element, value: Any, *, encoder: XmlEncoder, skip_empty: bool = False,
204204
) -> Optional[etree.Element]:
205205
if value is None and skip_empty:
206206
return element
@@ -210,14 +210,14 @@ def serialize(
210210
return element
211211

212212
def deserialize(self, element: etree.Element) -> Optional[str]:
213-
return element.text
213+
return element.text or None
214214

215215
class AttributeSerializer(Serializer):
216216
def __init__(
217217
self, model: Type['pxml.BaseXmlModel'], model_field: pd.fields.ModelField, ctx: Serializer.Context,
218218
):
219219
ns_attrs = model.__xml_ns_attrs__
220-
name = ctx.entity_name or model_field.name
220+
name = ctx.entity_name or model_field.alias
221221
ns = ctx.entity_ns or (ctx.parent_ns if ns_attrs else None)
222222
nsmap = ctx.parent_nsmap
223223

@@ -239,7 +239,7 @@ def deserialize(self, element: etree.Element) -> Optional[str]:
239239

240240
class ElementSerializer(Serializer):
241241
def __init__(self, model_field: pd.fields.ModelField, ctx: Serializer.Context):
242-
name = ctx.entity_name or model_field.name
242+
name = ctx.entity_name or model_field.alias
243243
ns = ctx.entity_ns or ctx.parent_ns
244244
nsmap = merge_nsmaps(ctx.entity_nsmap, ctx.parent_nsmap)
245245
self.element_name = QName.from_alias(tag=name, ns=ns, nsmap=nsmap).uri
@@ -252,9 +252,7 @@ def serialize(
252252

253253
encoded = encoder.encode(value)
254254

255-
if (sub_element := element.find(self.element_name)) is None:
256-
sub_element = etree.SubElement(element, self.element_name)
257-
255+
sub_element = find_element_or_create(element, self.element_name)
258256
sub_element.text = encoded
259257
return sub_element
260258

@@ -304,7 +302,7 @@ def __init__(
304302
self.is_root = is_root
305303
self.element_name = QName.from_alias(tag=name, ns=ns, nsmap=nsmap).uri
306304
self.field_serializers = {
307-
field_name: self.build_field_serializer(model, model_subfield, ctx)
305+
model_subfield.alias: self.build_field_serializer(model, model_subfield, ctx)
308306
for field_name, model_subfield in model.__fields__.items()
309307
}
310308

@@ -324,8 +322,9 @@ def serialize(
324322

325323
def deserialize(self, element: etree.Element) -> Any:
326324
result = {
327-
field_name: field_serializer.deserialize(element)
325+
field_name: field_value
328326
for field_name, field_serializer in self.field_serializers.items()
327+
if (field_value := field_serializer.deserialize(element)) is not None
329328
}
330329
if self.is_root:
331330
return result['__root__']
@@ -340,7 +339,7 @@ def __init__(
340339
model: Type['pxml.BaseXmlModel'],
341340
ctx: Serializer.Context,
342341
):
343-
field_name = model_field.name if model_field else None
342+
field_name = model_field.alias if model_field else None
344343
name = ctx.entity_name or model.__xml_tag__ or field_name or model.__name__
345344
ns = ctx.entity_ns or model.__xml_ns__
346345
nsmap = merge_nsmaps(ctx.entity_nsmap, model.__xml_nsmap__, ctx.parent_nsmap)
@@ -433,7 +432,7 @@ class BaseSerializer(Serializer, abc.ABC):
433432
def __init__(
434433
self, model: Type['pxml.BaseXmlModel'], model_field: pd.fields.ModelField, ctx: Serializer.Context,
435434
):
436-
name = ctx.entity_name or model_field.name
435+
name = ctx.entity_name or model_field.alias
437436

438437
self.parent_ns = ns = ctx.entity_ns or ctx.parent_ns
439438
self.parent_nsmap = nsmap = merge_nsmaps(ctx.entity_nsmap, ctx.parent_nsmap)
@@ -551,14 +550,14 @@ def __init__(
551550
):
552551
assert model_field.sub_fields is not None, "unexpected model field"
553552

554-
name = ctx.entity_name or model_field.name
553+
name = ctx.entity_name or model_field.alias
555554
ns = ctx.entity_ns or ctx.parent_ns
556555
nsmap = merge_nsmaps(ctx.entity_nsmap, ctx.parent_nsmap)
557556

558557
self.element_name = QName.from_alias(tag=name, ns=ns, nsmap=nsmap).uri
559558

560559
item_field = deepcopy(model_field.sub_fields[0])
561-
item_field.name = model_field.name
560+
item_field.name = model_field.alias
562561
self.serializer = self.build_field_serializer(
563562
model,
564563
item_field,
@@ -640,7 +639,7 @@ def __init__(
640639
):
641640
assert model_field.sub_fields is not None, "unexpected model field"
642641

643-
name = ctx.entity_name or model_field.name
642+
name = ctx.entity_name or model_field.alias
644643
ns = ctx.entity_ns or ctx.parent_ns
645644
nsmap = merge_nsmaps(ctx.entity_nsmap, ctx.parent_nsmap)
646645

@@ -649,7 +648,7 @@ def __init__(
649648
self.serializers = []
650649
for sub_field in model_field.sub_fields:
651650
sub_field = deepcopy(sub_field)
652-
sub_field.name = model_field.name
651+
sub_field.name = model_field.alias
653652

654653
self.serializers.append(
655654
self.build_field_serializer(

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 = "0.3.0"
3+
version = "0.4.0"
44
description = "pydantic xml serialization/deserialization extension"
55
authors = ["Dmitry Pershin <dapper1291@gmail.com>"]
66
license = "Unlicense"

tests/test_misc.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,15 @@ class TestModel(BaseXmlModel, tag='model'):
2222
assert_xml_equal(actual_xml, xml.encode())
2323

2424

25+
def test_root_model():
26+
class TestModel(BaseXmlModel, tag='model'):
27+
pass
28+
29+
xml = '''<model1/>'''
30+
31+
assert TestModel.from_xml(xml) is None
32+
33+
2534
def test_skip_empty():
2635
class TestSubModel(BaseXmlModel, tag='model'):
2736
text: Optional[str]
@@ -90,3 +99,43 @@ class TestModel(BaseXmlModel, tag='model'):
9099

91100
actual_xml = obj.to_xml(skip_empty=True)
92101
assert_xml_equal(actual_xml, xml.encode())
102+
103+
104+
def test_defaults():
105+
class TestModel(BaseXmlModel, tag='model'):
106+
attr1: int = attr(default=1)
107+
element1: int = element(default=1)
108+
text: str = 'text'
109+
attrs: Dict[str, str] = element(tag='model2', default={'key': 'value'})
110+
element2: int = wrapped('wrapper', element(tag='model3', default=2))
111+
112+
xml = '<model/>'
113+
actual_obj: TestModel = TestModel.from_xml(xml)
114+
expected_obj: TestModel = TestModel()
115+
assert actual_obj == expected_obj
116+
117+
expected_xml = '''
118+
<model attr1="1">text<element1>1</element1><model2 key="value"/><wrapper><model3>2</model3></wrapper></model>
119+
'''
120+
actual_xml = actual_obj.to_xml(skip_empty=True)
121+
assert_xml_equal(actual_xml, expected_xml.encode())
122+
123+
124+
def test_default_factory():
125+
class TestModel(BaseXmlModel, tag='model'):
126+
attr1: int = attr(default_factory=lambda: 1)
127+
element1: int = element(default_factory=lambda: 1)
128+
text: str = 'text'
129+
attrs: Dict[str, str] = element(tag='model2', default_factory=lambda: {'key': 'value'})
130+
element2: int = wrapped('wrapper', element(tag='model3', default_factory=lambda: 2))
131+
132+
xml = '<model/>'
133+
actual_obj: TestModel = TestModel.from_xml(xml)
134+
expected_obj: TestModel = TestModel()
135+
assert actual_obj == expected_obj
136+
137+
expected_xml = '''
138+
<model attr1="1">text<element1>1</element1><model2 key="value"/><wrapper><model3>2</model3></wrapper></model>
139+
'''
140+
actual_xml = actual_obj.to_xml(skip_empty=True)
141+
assert_xml_equal(actual_xml, expected_xml.encode())

tests/test_submodels.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ class TestSubModel(BaseXmlModel, tag='model2'):
1313

1414
class TestModel(BaseXmlModel, tag='model1'):
1515
model2: TestSubModel
16-
model3: Optional[TestSubModel] = element(tag='model3')
1716

1817
xml = '''
1918
<model1>
@@ -37,6 +36,24 @@ class TestModel(BaseXmlModel, tag='model1'):
3736
assert_xml_equal(actual_xml, xml)
3837

3938

39+
def test_optional_submodel_element_extraction():
40+
class TestSubModel(BaseXmlModel, tag='model2'):
41+
element1: float = element()
42+
43+
class TestModel(BaseXmlModel, tag='model1'):
44+
model2: Optional[TestSubModel]
45+
46+
xml = '''<model1/>'''
47+
48+
actual_obj = TestModel.from_xml(xml)
49+
expected_obj = TestModel(model2=None)
50+
51+
assert actual_obj == expected_obj
52+
53+
actual_xml = actual_obj.to_xml()
54+
assert_xml_equal(actual_xml, xml)
55+
56+
4057
def test_root_submodel_element_extraction():
4158
class TestSubModel(BaseXmlModel, tag='model2'):
4259
__root__: int

0 commit comments

Comments
 (0)