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.
@@ -0,0 +1,5 @@
1
+ __all__ = ["Decoder", "Encoder", "decode", "encode"]
2
+ __version__ = "0.1.0"
3
+
4
+ from msgspec_xml.decoder import Decoder, decode
5
+ from msgspec_xml.encoder import Encoder, encode
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
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.