msgspec-xml 0.1.0__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 (30) hide show
  1. msgspec_xml-0.2.0/.pre-commit-config.yaml +94 -0
  2. msgspec_xml-0.2.0/PKG-INFO +93 -0
  3. msgspec_xml-0.2.0/README.md +66 -0
  4. msgspec_xml-0.2.0/msgspec-xml.zip +0 -0
  5. msgspec_xml-0.2.0/msgspec_xml/__init__.py +6 -0
  6. {msgspec_xml-0.1.0 → msgspec_xml-0.2.0}/msgspec_xml/decoder.py +19 -10
  7. msgspec_xml-0.2.0/msgspec_xml/encoder.py +109 -0
  8. {msgspec_xml-0.1.0 → msgspec_xml-0.2.0}/msgspec_xml/introspection.py +47 -14
  9. {msgspec_xml-0.1.0 → msgspec_xml-0.2.0}/msgspec_xml/metadata.py +5 -0
  10. {msgspec_xml-0.1.0 → msgspec_xml-0.2.0}/pylock.toml +115 -128
  11. {msgspec_xml-0.1.0 → msgspec_xml-0.2.0}/pyproject.toml +2 -2
  12. {msgspec_xml-0.1.0 → msgspec_xml-0.2.0}/tests/resources/test-02.xml +1 -0
  13. msgspec_xml-0.2.0/tests/resources/test-03.xml +7 -0
  14. msgspec_xml-0.2.0/tests/resources/test-04.xml +8 -0
  15. {msgspec_xml-0.1.0 → msgspec_xml-0.2.0}/tests/test_roundtrip.py +25 -7
  16. {msgspec_xml-0.1.0 → msgspec_xml-0.2.0}/uv.lock +117 -130
  17. msgspec_xml-0.1.0/PKG-INFO +0 -27
  18. msgspec_xml-0.1.0/README.md +0 -1
  19. msgspec_xml-0.1.0/msgspec_xml/__init__.py +0 -5
  20. msgspec_xml-0.1.0/msgspec_xml/encoder.py +0 -69
  21. msgspec_xml-0.1.0/prek.toml +0 -52
  22. msgspec_xml-0.1.0/tests/resources/test-03.xml +0 -7
  23. {msgspec_xml-0.1.0 → msgspec_xml-0.2.0}/.gitattributes +0 -0
  24. {msgspec_xml-0.1.0 → msgspec_xml-0.2.0}/.gitignore +0 -0
  25. {msgspec_xml-0.1.0 → msgspec_xml-0.2.0}/LICENSE +0 -0
  26. {msgspec_xml-0.1.0 → msgspec_xml-0.2.0}/pdm.toml +0 -0
  27. {msgspec_xml-0.1.0 → msgspec_xml-0.2.0}/renovate.json +0 -0
  28. {msgspec_xml-0.1.0 → msgspec_xml-0.2.0}/tests/__init__.py +0 -0
  29. {msgspec_xml-0.1.0 → msgspec_xml-0.2.0}/tests/resources/test-01.xml +0 -0
  30. {msgspec_xml-0.1.0 → msgspec_xml-0.2.0}/tests/test_metadata.py +0 -0
@@ -0,0 +1,94 @@
1
+ repos:
2
+ - repo: local
3
+ hooks:
4
+ - id: toml-sort
5
+ args:
6
+ - "--in-place"
7
+ entry: toml-sort
8
+ exclude: "uv.lock|pylock.toml"
9
+ language: system
10
+ name: toml sort
11
+ types:
12
+ - toml
13
+ - id: mdformat
14
+ args:
15
+ - "--number"
16
+ - "--wrap=keep"
17
+ entry: mdformat
18
+ language: system
19
+ name: mdformat
20
+ types:
21
+ - markdown
22
+ - id: ruff-check
23
+ entry: ruff check .
24
+ language: system
25
+ name: ruff check
26
+ pass_filenames: false
27
+ types:
28
+ - python
29
+ - id: ruff-format
30
+ entry: ruff format .
31
+ language: system
32
+ name: ruff format
33
+ pass_filenames: false
34
+ types:
35
+ - python
36
+ - id: ty
37
+ entry: ty check .
38
+ language: system
39
+ name: ruff check
40
+ pass_filenames: false
41
+ types:
42
+ - python
43
+ - repo: "https://github.com/pre-commit/pre-commit-hooks"
44
+ rev: v6.0.0
45
+ hooks:
46
+ - id: check-ast
47
+ - id: check-builtin-literals
48
+ - id: check-docstring-first
49
+ - id: debug-statements
50
+ - id: forbid-submodules
51
+ - repo: builtin
52
+ hooks:
53
+ - id: check-added-large-files
54
+ args:
55
+ - "--enforce-all"
56
+ - id: check-case-conflict
57
+ # - id: check-executables-have-shebangs
58
+ # - id: check-illegal-windows-names
59
+ - id: check-json
60
+ # - id: check-json5
61
+ - id: check-merge-conflict
62
+ args:
63
+ - "--assume-in-merge"
64
+ - id: check-shebang-scripts-are-executable
65
+ # - id: check-symlinks
66
+ - id: check-toml
67
+ - id: check-vcs-permalinks
68
+ - id: check-xml
69
+ - id: check-yaml
70
+ args:
71
+ - "--allow-multiple-documents"
72
+ - id: destroyed-symlinks
73
+ - id: detect-private-key
74
+ - id: end-of-file-fixer
75
+ exclude_types:
76
+ - json
77
+ - svg
78
+ - xml
79
+ - id: fix-byte-order-marker
80
+ - id: mixed-line-ending
81
+ args:
82
+ - "--fix=auto"
83
+ - id: pretty-format-json
84
+ args:
85
+ - "--autofix"
86
+ - "--indent=2"
87
+ - "--no-ensure-ascii"
88
+ - id: trailing-whitespace
89
+ args:
90
+ - "--markdown-linebreak-ext=md"
91
+ - repo: meta
92
+ hooks:
93
+ - id: check-hooks-apply
94
+ - id: check-useless-excludes
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.4
2
+ Name: msgspec-xml
3
+ Version: 0.2.0
4
+ Summary: XML Serialization/Deserialization using msgspec
5
+ Project-URL: Homepage, https://pypi.org/project/msgspec-xml
6
+ Project-URL: Issues, https://codefloe.com/buriedincode/msgspec-xml/issues
7
+ Project-URL: Source, https://codefloe.com/buriedincode/msgspec-xml
8
+ Author-email: BuriedInCode <buriedincode@duckpond.nz>
9
+ Maintainer-email: BuriedInCode <buriedincode@duckpond.nz>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Natural Language :: English
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.10
25
+ Requires-Dist: msgspec>=0.21.0
26
+ Description-Content-Type: text/markdown
27
+
28
+ # msgspec-xml
29
+
30
+ [![PyPI - Python](https://img.shields.io/pypi/pyversions/msgspec-xml.svg?logo=Python&label=Python&style=flat-square)](https://pypi.org/p/msgspec-xml/)
31
+ [![PyPI - Status](https://img.shields.io/pypi/status/msgspec-xml.svg?logo=Python&label=Status&style=flat-square)](https://pypi.org/p/msgspec-xml/)
32
+ [![PyPI - Version](https://img.shields.io/pypi/v/msgspec-xml.svg?logo=Python&label=Version&style=flat-square)](https://pypi.org/p/msgspec-xml/)
33
+ [![PyPI - License](https://img.shields.io/pypi/l/msgspec-xml.svg?logo=Python&label=License&style=flat-square)](https://opensource.org/licenses/MIT)
34
+
35
+ [![prek](https://img.shields.io/badge/prek-enabled-informational?logo=prek&style=flat-square)](https://github.com/j178/prek)
36
+ [![Ruff](https://img.shields.io/badge/Ruff-enabled-informational?logo=ruff&style=flat-square)](https://github.com/astral-sh/ruff)
37
+ [![ty](https://img.shields.io/badge/ty-enabled-informational?logo=ruff&style=flat-square)](https://github.com/astral-sh/ty)
38
+
39
+ [![status-badge](https://ci.codefloe.com/api/v1/badges/1329/status.svg)](https://ci.codefloe.com/repos/1329)
40
+
41
+ XML Serialization/Deserialization using [msgspec](https://github.com/msgspec/msgspec).
42
+
43
+ ## Installation
44
+
45
+ ```sh
46
+ pdm add msgspec-xml
47
+ ```
48
+
49
+ ## Usage
50
+
51
+ ```python
52
+ from decimal import Decimal
53
+ from typing import Annotated
54
+
55
+ from msgspec import Struct
56
+ from msgspec_xml import XML, decode, encode
57
+
58
+
59
+ class Price(Struct):
60
+ currency: Annotated[str, XML(attr=True)]
61
+ cost: Annotated[Decimal, XML(text=True)]
62
+
63
+
64
+ class Book(Struct, rename="pascal"):
65
+ title: str
66
+ prices: Annotated[list[Price], XML(wrapper="Prices", tag="Price")]
67
+
68
+
69
+ book = Book(
70
+ title="Example Book",
71
+ prices=[
72
+ Price(currency="USD", cost=Decimal("3.99")),
73
+ Price(currency="GBP", cost=Decimal("1.51")),
74
+ ],
75
+ )
76
+
77
+ xml = encode(book)
78
+ # <Book>
79
+ # <Title>Example Book</Title>
80
+ # <Prices>
81
+ # <Price currency="USD">3.99</Price>
82
+ # <Price currency="GBP">1.51</Price>
83
+ # </Prices>
84
+ # </Book>
85
+
86
+ decoded = decode(xml, type_=Book)
87
+
88
+ assert decoded == book
89
+ ```
90
+
91
+ ## Socials
92
+
93
+ [![Social - Matrix](https://img.shields.io/matrix/The-Dev-Environment:matrix.org?label=The-Dev-Environment&logo=matrix&style=for-the-badge)](https://matrix.to/#/#The-Dev-Environment:matrix.org)
@@ -0,0 +1,66 @@
1
+ # msgspec-xml
2
+
3
+ [![PyPI - Python](https://img.shields.io/pypi/pyversions/msgspec-xml.svg?logo=Python&label=Python&style=flat-square)](https://pypi.org/p/msgspec-xml/)
4
+ [![PyPI - Status](https://img.shields.io/pypi/status/msgspec-xml.svg?logo=Python&label=Status&style=flat-square)](https://pypi.org/p/msgspec-xml/)
5
+ [![PyPI - Version](https://img.shields.io/pypi/v/msgspec-xml.svg?logo=Python&label=Version&style=flat-square)](https://pypi.org/p/msgspec-xml/)
6
+ [![PyPI - License](https://img.shields.io/pypi/l/msgspec-xml.svg?logo=Python&label=License&style=flat-square)](https://opensource.org/licenses/MIT)
7
+
8
+ [![prek](https://img.shields.io/badge/prek-enabled-informational?logo=prek&style=flat-square)](https://github.com/j178/prek)
9
+ [![Ruff](https://img.shields.io/badge/Ruff-enabled-informational?logo=ruff&style=flat-square)](https://github.com/astral-sh/ruff)
10
+ [![ty](https://img.shields.io/badge/ty-enabled-informational?logo=ruff&style=flat-square)](https://github.com/astral-sh/ty)
11
+
12
+ [![status-badge](https://ci.codefloe.com/api/v1/badges/1329/status.svg)](https://ci.codefloe.com/repos/1329)
13
+
14
+ XML Serialization/Deserialization using [msgspec](https://github.com/msgspec/msgspec).
15
+
16
+ ## Installation
17
+
18
+ ```sh
19
+ pdm add msgspec-xml
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ ```python
25
+ from decimal import Decimal
26
+ from typing import Annotated
27
+
28
+ from msgspec import Struct
29
+ from msgspec_xml import XML, decode, encode
30
+
31
+
32
+ class Price(Struct):
33
+ currency: Annotated[str, XML(attr=True)]
34
+ cost: Annotated[Decimal, XML(text=True)]
35
+
36
+
37
+ class Book(Struct, rename="pascal"):
38
+ title: str
39
+ prices: Annotated[list[Price], XML(wrapper="Prices", tag="Price")]
40
+
41
+
42
+ book = Book(
43
+ title="Example Book",
44
+ prices=[
45
+ Price(currency="USD", cost=Decimal("3.99")),
46
+ Price(currency="GBP", cost=Decimal("1.51")),
47
+ ],
48
+ )
49
+
50
+ xml = encode(book)
51
+ # <Book>
52
+ # <Title>Example Book</Title>
53
+ # <Prices>
54
+ # <Price currency="USD">3.99</Price>
55
+ # <Price currency="GBP">1.51</Price>
56
+ # </Prices>
57
+ # </Book>
58
+
59
+ decoded = decode(xml, type_=Book)
60
+
61
+ assert decoded == book
62
+ ```
63
+
64
+ ## Socials
65
+
66
+ [![Social - Matrix](https://img.shields.io/matrix/The-Dev-Environment:matrix.org?label=The-Dev-Environment&logo=matrix&style=for-the-badge)](https://matrix.to/#/#The-Dev-Environment:matrix.org)
Binary file
@@ -0,0 +1,6 @@
1
+ __all__ = ["XML", "Decoder", "Encoder", "decode", "encode"]
2
+ __version__ = "0.2.0"
3
+
4
+ from msgspec_xml.decoder import Decoder, decode
5
+ from msgspec_xml.encoder import Encoder, encode
6
+ from msgspec_xml.metadata import XML
@@ -1,18 +1,20 @@
1
1
  __all__ = ["Decoder", "decode"]
2
2
 
3
3
  from typing import Any, Generic, TypeVar
4
+ from xml.etree import ElementTree as ET
4
5
  from xml.etree.ElementTree import Element
5
6
 
6
- from defusedxml import ElementTree
7
7
  from msgspec import Struct, convert
8
8
  from msgspec.structs import FieldInfo, fields
9
9
 
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)
@@ -24,7 +26,7 @@ class Decoder(Generic[T]):
24
26
  self._type = type_
25
27
 
26
28
  def decode(self, xml: str | bytes) -> T:
27
- root = ElementTree.fromstring(xml)
29
+ root = ET.fromstring(xml) # noqa: S314
28
30
  data = _decode_struct(element=root, cls=self._type)
29
31
  return convert(data, type=self._type, strict=False)
30
32
 
@@ -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: