msgspec-xml 0.1.1__tar.gz → 0.2.0__tar.gz

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.
Files changed (25) hide show
  1. {msgspec_xml-0.1.1 → msgspec_xml-0.2.0}/PKG-INFO +1 -1
  2. msgspec_xml-0.2.0/msgspec-xml.zip +0 -0
  3. {msgspec_xml-0.1.1 → msgspec_xml-0.2.0}/msgspec_xml/__init__.py +1 -1
  4. {msgspec_xml-0.1.1 → msgspec_xml-0.2.0}/msgspec_xml/decoder.py +17 -8
  5. msgspec_xml-0.2.0/msgspec_xml/encoder.py +109 -0
  6. {msgspec_xml-0.1.1 → msgspec_xml-0.2.0}/msgspec_xml/introspection.py +47 -14
  7. {msgspec_xml-0.1.1 → msgspec_xml-0.2.0}/msgspec_xml/metadata.py +5 -0
  8. {msgspec_xml-0.1.1 → msgspec_xml-0.2.0}/pylock.toml +114 -114
  9. {msgspec_xml-0.1.1 → msgspec_xml-0.2.0}/tests/resources/test-02.xml +1 -0
  10. msgspec_xml-0.2.0/tests/resources/test-04.xml +8 -0
  11. {msgspec_xml-0.1.1 → msgspec_xml-0.2.0}/tests/test_roundtrip.py +23 -4
  12. {msgspec_xml-0.1.1 → msgspec_xml-0.2.0}/uv.lock +116 -116
  13. msgspec_xml-0.1.1/msgspec_xml/encoder.py +0 -69
  14. {msgspec_xml-0.1.1 → msgspec_xml-0.2.0}/.gitattributes +0 -0
  15. {msgspec_xml-0.1.1 → msgspec_xml-0.2.0}/.gitignore +0 -0
  16. {msgspec_xml-0.1.1 → msgspec_xml-0.2.0}/.pre-commit-config.yaml +0 -0
  17. {msgspec_xml-0.1.1 → msgspec_xml-0.2.0}/LICENSE +0 -0
  18. {msgspec_xml-0.1.1 → msgspec_xml-0.2.0}/README.md +0 -0
  19. {msgspec_xml-0.1.1 → msgspec_xml-0.2.0}/pdm.toml +0 -0
  20. {msgspec_xml-0.1.1 → msgspec_xml-0.2.0}/pyproject.toml +0 -0
  21. {msgspec_xml-0.1.1 → msgspec_xml-0.2.0}/renovate.json +0 -0
  22. {msgspec_xml-0.1.1 → msgspec_xml-0.2.0}/tests/__init__.py +0 -0
  23. {msgspec_xml-0.1.1 → msgspec_xml-0.2.0}/tests/resources/test-01.xml +0 -0
  24. {msgspec_xml-0.1.1 → msgspec_xml-0.2.0}/tests/resources/test-03.xml +0 -0
  25. {msgspec_xml-0.1.1 → msgspec_xml-0.2.0}/tests/test_metadata.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: msgspec-xml
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: XML Serialization/Deserialization using msgspec
5
5
  Project-URL: Homepage, https://pypi.org/project/msgspec-xml
6
6
  Project-URL: Issues, https://codefloe.com/buriedincode/msgspec-xml/issues
Binary file
@@ -1,5 +1,5 @@
1
1
  __all__ = ["XML", "Decoder", "Encoder", "decode", "encode"]
2
- __version__ = "0.1.1"
2
+ __version__ = "0.2.0"
3
3
 
4
4
  from msgspec_xml.decoder import Decoder, decode
5
5
  from msgspec_xml.encoder import Encoder, encode
@@ -10,9 +10,11 @@ from msgspec.structs import FieldInfo, fields
10
10
  from msgspec_xml.introspection import (
11
11
  FieldType,
12
12
  attribute_name,
13
+ class_namespaces,
13
14
  element_tag,
14
15
  inspect_annotation,
15
16
  item_tag,
17
+ wrapper_tag,
16
18
  )
17
19
 
18
20
  T = TypeVar("T", bound=Struct)
@@ -34,25 +36,29 @@ def decode(xml: str | bytes, type_: type[T]) -> T:
34
36
 
35
37
 
36
38
  def _decode_struct(element: Element, cls: type[Struct]) -> dict[str, Any]:
39
+ namespaces = class_namespaces(cls=cls)
37
40
  result: dict[str, Any] = {}
38
41
  for field in fields(cls):
39
42
  info = inspect_annotation(annotation=field.type)
40
- value = _decode_field(element=element, field=field, info=info)
43
+ value = _decode_field(element=element, field=field, info=info, namespaces=namespaces)
41
44
  if value is not _MISSING:
42
45
  result[field.encode_name] = value
43
46
  return result
44
47
 
45
48
 
46
- def _decode_field(element: Element, field: FieldInfo, info: FieldType) -> Any: # noqa: ANN401
49
+ def _decode_field(
50
+ element: Element, field: FieldInfo, info: FieldType, namespaces: dict[str, str]
51
+ ) -> Any: # noqa: ANN401
47
52
  xml = info.xml
48
53
  if xml.attr:
49
- name = attribute_name(field=field, info=info)
54
+ name = attribute_name(field=field, info=info, namespaces=namespaces)
50
55
  return element.attrib.get(name, _MISSING)
51
56
  if xml.text:
52
57
  return (element.text or "").strip()
53
58
  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))
59
+ return _decode_list(element=element, field=field, info=info, namespaces=namespaces)
60
+ tag = element_tag(field=field, info=info, namespaces=namespaces)
61
+ child = element.find(tag)
56
62
  if child is None:
57
63
  return _MISSING
58
64
  if info.is_struct:
@@ -60,13 +66,16 @@ def _decode_field(element: Element, field: FieldInfo, info: FieldType) -> Any:
60
66
  return (child.text or "").strip()
61
67
 
62
68
 
63
- def _decode_list(element: Element, field: FieldInfo, info: FieldType) -> list[Any]:
69
+ def _decode_list(
70
+ element: Element, field: FieldInfo, info: FieldType, namespaces: dict[str, str]
71
+ ) -> list[Any]:
64
72
  item = info.item
65
73
  assert item is not None # noqa: S101
66
- parent = element.find(info.xml.wrapper) if info.xml.wrapper else element
74
+ wrapper = wrapper_tag(info=info, namespaces=namespaces)
75
+ parent = element.find(wrapper) if wrapper else element
67
76
  if parent is None:
68
77
  return []
69
- tag = item_tag(field=field, info=info)
78
+ tag = item_tag(field=field, info=info, namespaces=namespaces)
70
79
  return [_decode_item(element=child, item=item) for child in parent.findall(tag)]
71
80
 
72
81
 
@@ -0,0 +1,109 @@
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_namespaces,
13
+ class_tag,
14
+ element_tag,
15
+ inspect_annotation,
16
+ item_tag,
17
+ wrapper_tag,
18
+ )
19
+
20
+
21
+ class Encoder:
22
+ def __init__(
23
+ self,
24
+ indent: int | None = None,
25
+ xml_declaration: bool = False,
26
+ short_empty_elements: bool = True,
27
+ ) -> None:
28
+ if indent is not None and indent < 0:
29
+ raise ValueError("'indent' must be a non-negative integer")
30
+ self.indent = indent
31
+ self.xml_declaration = xml_declaration
32
+ self.short_empty_elements = short_empty_elements
33
+
34
+ def encode(self, obj: Struct) -> bytes:
35
+ root = _encode_struct(obj=obj, tag=class_tag(cls=type(obj)))
36
+ if self.indent is not None:
37
+ ET.indent(root, space=" " * self.indent)
38
+ return ET.tostring(
39
+ root,
40
+ encoding="UTF-8",
41
+ xml_declaration=self.xml_declaration,
42
+ short_empty_elements=self.short_empty_elements,
43
+ )
44
+
45
+
46
+ def encode(
47
+ obj: Struct,
48
+ indent: int | None = None,
49
+ xml_declaration: bool = False,
50
+ short_empty_elements: bool = True,
51
+ ) -> bytes:
52
+ return Encoder(
53
+ indent=indent, xml_declaration=xml_declaration, short_empty_elements=short_empty_elements
54
+ ).encode(obj=obj)
55
+
56
+
57
+ def _encode_struct(obj: Struct, tag: str) -> Element:
58
+ cls = type(obj)
59
+ namespaces = class_namespaces(cls=cls)
60
+ for prefix, uri in namespaces.items():
61
+ ET.register_namespace(prefix, uri)
62
+ element = ET.Element(tag)
63
+ for field in fields(cls):
64
+ value = getattr(obj, field.name)
65
+ if value is None:
66
+ continue
67
+ info = inspect_annotation(annotation=field.type)
68
+ _encode_field(element=element, field=field, info=info, value=value, namespaces=namespaces)
69
+ return element
70
+
71
+
72
+ def _encode_field(
73
+ element: Element, field: FieldInfo, info: FieldType, value: object, namespaces: dict[str, str]
74
+ ) -> None:
75
+ xml = info.xml
76
+ if xml.attr:
77
+ name = attribute_name(field=field, info=info, namespaces=namespaces)
78
+ element.set(name, _stringify(value=value))
79
+ elif xml.text:
80
+ element.text = _stringify(value)
81
+ elif info.item is not None:
82
+ _encode_list(element=element, field=field, info=info, value=value, namespaces=namespaces) # ty: ignore[invalid-argument-type]
83
+ elif info.is_struct:
84
+ struct_tag = element_tag(field=field, info=info, namespaces=namespaces)
85
+ element.append(_encode_struct(obj=value, tag=struct_tag)) # ty: ignore[invalid-argument-type]
86
+ else:
87
+ tag = element_tag(field=field, info=info, namespaces=namespaces)
88
+ child = ET.SubElement(element, tag)
89
+ child.text = _stringify(value)
90
+
91
+
92
+ def _encode_list(
93
+ element: Element, field: FieldInfo, info: FieldType, value: list, namespaces: dict[str, str]
94
+ ) -> None:
95
+ item = info.item
96
+ assert item is not None # noqa: S101
97
+ wrapper = wrapper_tag(info=info, namespaces=namespaces)
98
+ parent = ET.SubElement(element, wrapper) if wrapper else element
99
+ tag = item_tag(field=field, info=info, namespaces=namespaces)
100
+ for item_value in value:
101
+ if item.is_struct:
102
+ parent.append(_encode_struct(obj=item_value, tag=tag))
103
+ else:
104
+ child = ET.SubElement(parent, tag)
105
+ child.text = _stringify(item_value)
106
+
107
+
108
+ def _stringify(value: object) -> str:
109
+ return str(to_builtins(value))
@@ -1,10 +1,13 @@
1
1
  __all__ = [
2
2
  "FieldType",
3
3
  "attribute_name",
4
+ "class_namespaces",
4
5
  "class_tag",
5
6
  "element_tag",
6
7
  "inspect_annotation",
7
8
  "item_tag",
9
+ "qualify",
10
+ "wrapper_tag",
8
11
  ]
9
12
 
10
13
  from types import NoneType
@@ -45,29 +48,59 @@ def class_tag(cls: type[Struct]) -> str:
45
48
  return cls.__name__
46
49
 
47
50
 
48
- def attribute_name(field: FieldInfo, info: FieldType) -> str:
49
- return info.xml.tag or field.encode_name
51
+ def class_namespaces(cls: type[Struct]) -> dict[str, str]:
52
+ return getattr(cls, "__namespaces__", {})
50
53
 
51
54
 
52
- def element_tag(field: FieldInfo, info: FieldType) -> str:
55
+ def attribute_name(field: FieldInfo, info: FieldType, namespaces: dict[str, str]) -> str:
56
+ name = info.xml.tag or field.encode_name
57
+ return qualify(tag=name, ns=_resolve_ns(prefix=info.xml.ns, namespaces=namespaces))
58
+
59
+
60
+ def element_tag(field: FieldInfo, info: FieldType, namespaces: dict[str, str]) -> str:
53
61
  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
62
+ name = info.xml.tag
63
+ elif info.is_struct:
64
+ name = class_tag(cls=info.type)
65
+ else:
66
+ name = field.encode_name
67
+ return qualify(tag=name, ns=_resolve_ns(prefix=info.xml.ns, namespaces=namespaces))
58
68
 
59
69
 
60
- def item_tag(field: FieldInfo, info: FieldType) -> str:
70
+ def item_tag(field: FieldInfo, info: FieldType, namespaces: dict[str, str]) -> str:
61
71
  item = info.item
62
72
  if item is None:
63
73
  raise TypeError("item_tag() requires a list field")
74
+ prefix = item.xml.ns if item.xml.ns is not None else info.xml.ns
64
75
  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
76
+ name = item.xml.tag
77
+ elif info.xml.tag:
78
+ name = info.xml.tag
79
+ elif item.is_struct:
80
+ name = class_tag(cls=item.type)
81
+ else:
82
+ name = field.encode_name
83
+ return qualify(tag=name, ns=_resolve_ns(prefix=prefix, namespaces=namespaces))
84
+
85
+
86
+ def wrapper_tag(info: FieldType, namespaces: dict[str, str]) -> str | None:
87
+ if info.xml.wrapper is None:
88
+ return None
89
+ return qualify(tag=info.xml.wrapper, ns=_resolve_ns(prefix=info.xml.ns, namespaces=namespaces))
90
+
91
+
92
+ def qualify(tag: str, ns: str | None) -> str:
93
+ return f"{{{ns}}}{tag}" if ns else tag
94
+
95
+
96
+ def _resolve_ns(prefix: str | None, namespaces: dict[str, str]) -> str | None:
97
+ if prefix is None:
98
+ return None
99
+ if prefix not in namespaces:
100
+ raise ValueError(
101
+ f"Namespace prefix {prefix!r} is not declared in this Struct's __namespaces__."
102
+ )
103
+ return namespaces[prefix]
71
104
 
72
105
 
73
106
  def _split_xml(annotation: Any) -> tuple[Any, XML]: # noqa: ANN401
@@ -8,6 +8,7 @@ class XML(Struct, frozen=True, forbid_unknown_fields=True):
8
8
  wrapper: str | None = None
9
9
  attr: bool = False
10
10
  text: bool = False
11
+ ns: str | None = None
11
12
 
12
13
  def __post_init__(self) -> None:
13
14
  if self.attr and self.text:
@@ -16,10 +17,14 @@ class XML(Struct, frozen=True, forbid_unknown_fields=True):
16
17
  raise ValueError("Wrapper elements cannot be used on XML attributes.")
17
18
  if self.wrapper is not None and self.text:
18
19
  raise ValueError("Wrapper elements cannot be used on XML text.")
20
+ if self.ns is not None and self.text:
21
+ raise ValueError("Namespaces cannot be used on XML text.")
19
22
  if self.tag == "":
20
23
  raise ValueError("tag cannot be empty.")
21
24
  if self.wrapper == "":
22
25
  raise ValueError("wrapper cannot be empty.")
26
+ if self.ns == "":
27
+ raise ValueError("ns cannot be empty.")
23
28
 
24
29
  @property
25
30
  def is_element(self) -> bool: