msgspec-xml 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- msgspec_xml/__init__.py +5 -0
- msgspec_xml/decoder.py +76 -0
- msgspec_xml/encoder.py +69 -0
- msgspec_xml/introspection.py +84 -0
- msgspec_xml/metadata.py +38 -0
- msgspec_xml-0.1.0.dist-info/METADATA +27 -0
- msgspec_xml-0.1.0.dist-info/RECORD +9 -0
- msgspec_xml-0.1.0.dist-info/WHEEL +4 -0
- msgspec_xml-0.1.0.dist-info/licenses/LICENSE +21 -0
msgspec_xml/__init__.py
ADDED
msgspec_xml/decoder.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
__all__ = ["Decoder", "decode"]
|
|
2
|
+
|
|
3
|
+
from typing import Any, Generic, TypeVar
|
|
4
|
+
from xml.etree.ElementTree import Element
|
|
5
|
+
|
|
6
|
+
from defusedxml import ElementTree
|
|
7
|
+
from msgspec import Struct, convert
|
|
8
|
+
from msgspec.structs import FieldInfo, fields
|
|
9
|
+
|
|
10
|
+
from msgspec_xml.introspection import (
|
|
11
|
+
FieldType,
|
|
12
|
+
attribute_name,
|
|
13
|
+
element_tag,
|
|
14
|
+
inspect_annotation,
|
|
15
|
+
item_tag,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
T = TypeVar("T", bound=Struct)
|
|
19
|
+
_MISSING: Any = object()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Decoder(Generic[T]):
|
|
23
|
+
def __init__(self, type_: type[T]):
|
|
24
|
+
self._type = type_
|
|
25
|
+
|
|
26
|
+
def decode(self, xml: str | bytes) -> T:
|
|
27
|
+
root = ElementTree.fromstring(xml)
|
|
28
|
+
data = _decode_struct(element=root, cls=self._type)
|
|
29
|
+
return convert(data, type=self._type, strict=False)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def decode(xml: str | bytes, type_: type[T]) -> T:
|
|
33
|
+
return Decoder(type_=type_).decode(xml=xml)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _decode_struct(element: Element, cls: type[Struct]) -> dict[str, Any]:
|
|
37
|
+
result: dict[str, Any] = {}
|
|
38
|
+
for field in fields(cls):
|
|
39
|
+
info = inspect_annotation(annotation=field.type)
|
|
40
|
+
value = _decode_field(element=element, field=field, info=info)
|
|
41
|
+
if value is not _MISSING:
|
|
42
|
+
result[field.encode_name] = value
|
|
43
|
+
return result
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _decode_field(element: Element, field: FieldInfo, info: FieldType) -> Any: # noqa: ANN401
|
|
47
|
+
xml = info.xml
|
|
48
|
+
if xml.attr:
|
|
49
|
+
name = attribute_name(field=field, info=info)
|
|
50
|
+
return element.attrib.get(name, _MISSING)
|
|
51
|
+
if xml.text:
|
|
52
|
+
return (element.text or "").strip()
|
|
53
|
+
if info.item is not None:
|
|
54
|
+
return _decode_list(element=element, field=field, info=info)
|
|
55
|
+
child = element.find(element_tag(field=field, info=info))
|
|
56
|
+
if child is None:
|
|
57
|
+
return _MISSING
|
|
58
|
+
if info.is_struct:
|
|
59
|
+
return _decode_struct(element=child, cls=info.type)
|
|
60
|
+
return (child.text or "").strip()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _decode_list(element: Element, field: FieldInfo, info: FieldType) -> list[Any]:
|
|
64
|
+
item = info.item
|
|
65
|
+
assert item is not None # noqa: S101
|
|
66
|
+
parent = element.find(info.xml.wrapper) if info.xml.wrapper else element
|
|
67
|
+
if parent is None:
|
|
68
|
+
return []
|
|
69
|
+
tag = item_tag(field=field, info=info)
|
|
70
|
+
return [_decode_item(element=child, item=item) for child in parent.findall(tag)]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _decode_item(element: Element, item: FieldType) -> Any: # noqa: ANN401
|
|
74
|
+
if item.is_struct:
|
|
75
|
+
return _decode_struct(element=element, cls=item.type)
|
|
76
|
+
return (element.text or "").strip()
|
msgspec_xml/encoder.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
__all__ = ["Encoder", "encode"]
|
|
2
|
+
|
|
3
|
+
from xml.etree import ElementTree as ET
|
|
4
|
+
from xml.etree.ElementTree import Element
|
|
5
|
+
|
|
6
|
+
from msgspec import Struct, to_builtins
|
|
7
|
+
from msgspec.structs import FieldInfo, fields
|
|
8
|
+
|
|
9
|
+
from msgspec_xml.introspection import (
|
|
10
|
+
FieldType,
|
|
11
|
+
attribute_name,
|
|
12
|
+
class_tag,
|
|
13
|
+
element_tag,
|
|
14
|
+
inspect_annotation,
|
|
15
|
+
item_tag,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Encoder:
|
|
20
|
+
def encode(self, obj: Struct) -> bytes:
|
|
21
|
+
root = _encode_struct(obj=obj, tag=class_tag(cls=type(obj)))
|
|
22
|
+
return ET.tostring(root, encoding="UTF-8")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def encode(obj: Struct) -> bytes:
|
|
26
|
+
return Encoder().encode(obj=obj)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _encode_struct(obj: Struct, tag: str) -> Element:
|
|
30
|
+
element = ET.Element(tag)
|
|
31
|
+
for field in fields(type(obj)):
|
|
32
|
+
value = getattr(obj, field.name)
|
|
33
|
+
if value is None:
|
|
34
|
+
continue
|
|
35
|
+
info = inspect_annotation(annotation=field.type)
|
|
36
|
+
_encode_field(element=element, field=field, info=info, value=value)
|
|
37
|
+
return element
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _encode_field(element: Element, field: FieldInfo, info: FieldType, value: object) -> None:
|
|
41
|
+
xml = info.xml
|
|
42
|
+
if xml.attr:
|
|
43
|
+
element.set(attribute_name(field=field, info=info), _stringify(value))
|
|
44
|
+
elif xml.text:
|
|
45
|
+
element.text = _stringify(value)
|
|
46
|
+
elif info.item is not None:
|
|
47
|
+
_encode_list(element=element, field=field, info=info, value=value) # ty: ignore[invalid-argument-type]
|
|
48
|
+
elif info.is_struct:
|
|
49
|
+
element.append(_encode_struct(obj=value, tag=element_tag(field=field, info=info))) # ty: ignore[invalid-argument-type]
|
|
50
|
+
else:
|
|
51
|
+
child = ET.SubElement(element, element_tag(field=field, info=info))
|
|
52
|
+
child.text = _stringify(value)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _encode_list(element: Element, field: FieldInfo, info: FieldType, value: list) -> None:
|
|
56
|
+
item = info.item
|
|
57
|
+
assert item is not None # noqa: S101
|
|
58
|
+
parent = ET.SubElement(element, info.xml.wrapper) if info.xml.wrapper else element
|
|
59
|
+
tag = item_tag(field=field, info=info)
|
|
60
|
+
for item_value in value:
|
|
61
|
+
if item.is_struct:
|
|
62
|
+
parent.append(_encode_struct(obj=item_value, tag=tag))
|
|
63
|
+
else:
|
|
64
|
+
child = ET.SubElement(parent, tag)
|
|
65
|
+
child.text = _stringify(item_value)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _stringify(value: object) -> str:
|
|
69
|
+
return str(to_builtins(value))
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
__all__ = [
|
|
2
|
+
"FieldType",
|
|
3
|
+
"attribute_name",
|
|
4
|
+
"class_tag",
|
|
5
|
+
"element_tag",
|
|
6
|
+
"inspect_annotation",
|
|
7
|
+
"item_tag",
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
from types import NoneType
|
|
11
|
+
from typing import Annotated, Any, Optional, get_args, get_origin
|
|
12
|
+
|
|
13
|
+
from msgspec import Struct
|
|
14
|
+
from msgspec.inspect import is_struct_type
|
|
15
|
+
from msgspec.structs import FieldInfo
|
|
16
|
+
|
|
17
|
+
from msgspec_xml.metadata import XML
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FieldType(Struct, frozen=True, kw_only=True):
|
|
21
|
+
type: Any
|
|
22
|
+
xml: XML
|
|
23
|
+
is_struct: bool = False
|
|
24
|
+
item: Optional["FieldType"] = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def inspect_annotation(annotation: Any) -> FieldType: # noqa: ANN401
|
|
28
|
+
annotation, xml = _split_xml(annotation)
|
|
29
|
+
annotation = _unwrap_optional(annotation=annotation)
|
|
30
|
+
if get_origin(annotation) is list:
|
|
31
|
+
(item_annotation,) = get_args(annotation)
|
|
32
|
+
return FieldType(
|
|
33
|
+
type=annotation, xml=xml, item=inspect_annotation(annotation=item_annotation)
|
|
34
|
+
)
|
|
35
|
+
return FieldType(type=annotation, xml=xml, is_struct=is_struct_type(annotation))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def class_tag(cls: type[Struct]) -> str:
|
|
39
|
+
tag = getattr(cls, "__tag__", None)
|
|
40
|
+
if tag is not None:
|
|
41
|
+
return tag
|
|
42
|
+
config_tag = cls.__struct_config__.tag
|
|
43
|
+
if isinstance(config_tag, str):
|
|
44
|
+
return config_tag
|
|
45
|
+
return cls.__name__
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def attribute_name(field: FieldInfo, info: FieldType) -> str:
|
|
49
|
+
return info.xml.tag or field.encode_name
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def element_tag(field: FieldInfo, info: FieldType) -> str:
|
|
53
|
+
if info.xml.tag:
|
|
54
|
+
return info.xml.tag
|
|
55
|
+
if info.is_struct:
|
|
56
|
+
return class_tag(cls=info.type)
|
|
57
|
+
return field.encode_name
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def item_tag(field: FieldInfo, info: FieldType) -> str:
|
|
61
|
+
item = info.item
|
|
62
|
+
if item is None:
|
|
63
|
+
raise TypeError("item_tag() requires a list field")
|
|
64
|
+
if item.xml.tag:
|
|
65
|
+
return item.xml.tag
|
|
66
|
+
if info.xml.tag:
|
|
67
|
+
return info.xml.tag
|
|
68
|
+
if item.is_struct:
|
|
69
|
+
return class_tag(cls=item.type)
|
|
70
|
+
return field.encode_name
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _split_xml(annotation: Any) -> tuple[Any, XML]: # noqa: ANN401
|
|
74
|
+
if get_origin(annotation) is Annotated:
|
|
75
|
+
base, *metadata = get_args(annotation)
|
|
76
|
+
return base, next((value for value in metadata if isinstance(value, XML)), XML())
|
|
77
|
+
return annotation, XML()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _unwrap_optional(annotation: Any) -> Any: # noqa: ANN401
|
|
81
|
+
args = get_args(annotation)
|
|
82
|
+
if len(args) == 2 and NoneType in args:
|
|
83
|
+
return next(arg for arg in args if arg is not NoneType)
|
|
84
|
+
return annotation
|
msgspec_xml/metadata.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
__all__ = ["XML"]
|
|
2
|
+
|
|
3
|
+
from msgspec import Struct
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class XML(Struct, frozen=True, forbid_unknown_fields=True):
|
|
7
|
+
tag: str | None = None
|
|
8
|
+
wrapper: str | None = None
|
|
9
|
+
attr: bool = False
|
|
10
|
+
text: bool = False
|
|
11
|
+
|
|
12
|
+
def __post_init__(self) -> None:
|
|
13
|
+
if self.attr and self.text:
|
|
14
|
+
raise ValueError("A field cannot be both an XML attribute and element text.")
|
|
15
|
+
if self.wrapper is not None and self.attr:
|
|
16
|
+
raise ValueError("Wrapper elements cannot be used on XML attributes.")
|
|
17
|
+
if self.wrapper is not None and self.text:
|
|
18
|
+
raise ValueError("Wrapper elements cannot be used on XML text.")
|
|
19
|
+
if self.tag == "":
|
|
20
|
+
raise ValueError("tag cannot be empty.")
|
|
21
|
+
if self.wrapper == "":
|
|
22
|
+
raise ValueError("wrapper cannot be empty.")
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def is_element(self) -> bool:
|
|
26
|
+
return not self.attr and not self.text
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def is_attribute(self) -> bool:
|
|
30
|
+
return self.attr
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def is_text(self) -> bool:
|
|
34
|
+
return self.text
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def is_wrapped(self) -> bool:
|
|
38
|
+
return self.wrapper is not None
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: msgspec-xml
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Project-URL: Issues, https://codefloe.com/buriedincode/msgspec-xml/issues
|
|
5
|
+
Project-URL: Source, https://codefloe.com/buriedincode/msgspec-xml
|
|
6
|
+
Author-email: BuriedInCode <buriedincode@duckpond.nz>
|
|
7
|
+
Maintainer-email: BuriedInCode <buriedincode@duckpond.nz>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Natural Language :: English
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: defusedxml>=0.7.0
|
|
24
|
+
Requires-Dist: msgspec>=0.21.0
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# msgspec-xml
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
msgspec_xml/__init__.py,sha256=rac6RGVCax9JsQfc-BGa8gqdOUt4uuwXFK6bfwtlys4,172
|
|
2
|
+
msgspec_xml/decoder.py,sha256=7YAjKcb5HQkU7tI5z04_aw4abPk0bi7lJEYGIFkWgzo,2419
|
|
3
|
+
msgspec_xml/encoder.py,sha256=d1gxVPOvE-vG5RIu195Hc6KACp-GovDAQ7bALxy0uQU,2254
|
|
4
|
+
msgspec_xml/introspection.py,sha256=pIQ4UklUhPb2S3AUIRkbX5uVCQnYd5P__Jr6ONVqnoE,2416
|
|
5
|
+
msgspec_xml/metadata.py,sha256=gyZjzqaIN7EBrEv3k4Z8LJBAj30lVPQrcK3uqJAJJLY,1138
|
|
6
|
+
msgspec_xml-0.1.0.dist-info/METADATA,sha256=j-dzqle5nsh97dgHASGCO7wealmojlJfHJW3UOKg4XI,1032
|
|
7
|
+
msgspec_xml-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
8
|
+
msgspec_xml-0.1.0.dist-info/licenses/LICENSE,sha256=LQpab7RDZrnpwV_Sl0I9lET5A12-ERe84DWicEf9Fo8,1070
|
|
9
|
+
msgspec_xml-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jonah Jackson
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|