msgspec-xml 0.1.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.
@@ -0,0 +1,116 @@
1
+ # Common settings that generally should always be used with your language specific settings
2
+
3
+ # Auto detect text files and perform LF normalization
4
+ * text=auto
5
+
6
+ #
7
+ # The above will handle all files NOT found below
8
+ #
9
+
10
+ # Documents
11
+ *.bibtex text diff=bibtex
12
+ *.doc diff=astextplain
13
+ *.DOC diff=astextplain
14
+ *.docx diff=astextplain
15
+ *.DOCX diff=astextplain
16
+ *.dot diff=astextplain
17
+ *.DOT diff=astextplain
18
+ *.pdf diff=astextplain
19
+ *.PDF diff=astextplain
20
+ *.rtf diff=astextplain
21
+ *.RTF diff=astextplain
22
+ *.md text diff=markdown
23
+ *.mdx text diff=markdown
24
+ *.tex text diff=tex
25
+ *.adoc text
26
+ *.textile text
27
+ *.mustache text
28
+ *.csv text eol=crlf
29
+ *.tab text
30
+ *.tsv text
31
+ *.txt text
32
+ *.sql text
33
+ *.epub diff=astextplain
34
+
35
+ # Graphics
36
+ *.png binary
37
+ *.jpg binary
38
+ *.jpeg binary
39
+ *.gif binary
40
+ *.tif binary
41
+ *.tiff binary
42
+ *.ico binary
43
+ # SVG treated as text by default.
44
+ *.svg text
45
+ # If you want to treat it as binary,
46
+ # use the following line instead.
47
+ # *.svg binary
48
+ *.eps binary
49
+
50
+ # Scripts
51
+ *.bash text eol=lf
52
+ *.fish text eol=lf
53
+ *.sh text eol=lf
54
+ *.zsh text eol=lf
55
+ # These are explicitly windows files and should use crlf
56
+ *.bat text eol=crlf
57
+ *.cmd text eol=crlf
58
+ *.ps1 text eol=crlf
59
+
60
+ # Serialisation
61
+ *.json text
62
+ *.toml text
63
+ *.xml text
64
+ *.yaml text
65
+ *.yml text
66
+
67
+ # Archives
68
+ *.7z binary
69
+ *.gz binary
70
+ *.tar binary
71
+ *.tgz binary
72
+ *.zip binary
73
+
74
+ # Text files where line endings should be preserved
75
+ *.patch -text
76
+
77
+ #
78
+ # Exclude files from exporting
79
+ #
80
+
81
+ .gitattributes export-ignore
82
+ .gitignore export-ignore
83
+ .gitkeep export-ignore
84
+ # Apply override to all files in the directory
85
+ *.md linguist-detectable
86
+ # Basic .gitattributes for a python repo.
87
+
88
+ # Source files
89
+ # ============
90
+ *.pxd text diff=python
91
+ *.py text diff=python
92
+ *.py3 text diff=python
93
+ *.pyw text diff=python
94
+ *.pyx text diff=python
95
+ *.pyz text diff=python
96
+ *.pyi text diff=python
97
+
98
+ # Binary files
99
+ # ============
100
+ *.db binary
101
+ *.p binary
102
+ *.pkl binary
103
+ *.pickle binary
104
+ *.pyc binary export-ignore
105
+ *.pyo binary export-ignore
106
+ *.pyd binary
107
+
108
+ # Jupyter notebook
109
+ *.ipynb text eol=lf
110
+
111
+ # Note: .db, .p, and .pkl files are associated
112
+ # with the python modules ``pickle``, ``dbm.*``,
113
+ # ``shelve``, ``marshal``, ``anydbm``, & ``bsddb``
114
+ # (among others).
115
+ # Fix syntax highlighting on GitHub to allow comments
116
+ .vscode/*.json linguist-language=JSON-with-Comments
@@ -0,0 +1,21 @@
1
+ # =====Folders=====
2
+ __pycache__/
3
+ __pypackages__/
4
+ .idea/
5
+ .pytest_cache/
6
+ .ruff_cache/
7
+ .tox/
8
+ .venv/
9
+ build/
10
+ dist/
11
+ logs/
12
+ site/
13
+
14
+ # =====Files=====
15
+ *.iml
16
+ *.log
17
+ *.txt
18
+ .coverage
19
+ .envrc
20
+ .pdm-python
21
+ .python-version
@@ -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.
@@ -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 @@
1
+ # msgspec-xml
@@ -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
@@ -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()
@@ -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,2 @@
1
+ [lock]
2
+ format = "pylock"
@@ -0,0 +1,52 @@
1
+ [[repos]]
2
+ hooks = [
3
+ {args = ["--all", "--ignore-case", "--in-place", "--trailing-comma-inline-array"], entry = "toml-sort", exclude = ".*.lock|pylock.toml", id = "toml-sort", language = "system", name = "toml sort", types = ["toml"]},
4
+ {args = ["--number", "--wrap=keep"], entry = "mdformat", id = "mdformat", language = "system", name = "mdformat", types = ["markdown"]},
5
+ {entry = "ruff check .", id = "ruff-check", language = "system", name = "ruff check", pass_filenames = false, types = ["python"]},
6
+ {entry = "ruff format .", id = "ruff-format", language = "system", name = "ruff format", pass_filenames = false, types = ["python"]},
7
+ {entry = "ty check .", id = "ty", language = "system", name = "ty", pass_filenames = false, types = ["python"]},
8
+ ]
9
+ repo = "local"
10
+
11
+ [[repos]]
12
+ hooks = [
13
+ {id = "check-ast"},
14
+ {id = "check-builtin-literals"},
15
+ {id = "check-docstring-first"},
16
+ {id = "debug-statements"},
17
+ {id = "forbid-submodules"},
18
+ ]
19
+ repo = "https://github.com/pre-commit/pre-commit-hooks"
20
+ rev = "v6.0.0"
21
+
22
+ [[repos]]
23
+ hooks = [
24
+ {args = ["--allow-multiple-documents"], id = "check-yaml"},
25
+ {args = ["--assume-in-merge"], id = "check-merge-conflict"},
26
+ {args = ["--autofix", "--indent=2", "--no-ensure-ascii"], id = "pretty-format-json"},
27
+ {args = ["--markdown-linebreak-ext=md"], id = "trailing-whitespace"},
28
+ {exclude_types = ["json", "svg", "xml"], id = "end-of-file-fixer"},
29
+ {id = "check-added-large-files"},
30
+ {id = "check-case-conflict"},
31
+ # {id = "check-executables-have-shebangs"},
32
+ # {id = "check-illegal-windows-names"},
33
+ {id = "check-json"},
34
+ # {id = "check-json5"},
35
+ {id = "check-shebang-scripts-are-executable"},
36
+ # {id = "check-symlinks"},
37
+ {id = "check-toml"},
38
+ {id = "check-vcs-permalinks"},
39
+ {id = "check-xml"},
40
+ {id = "destroyed-symlinks"},
41
+ {id = "detect-private-key"},
42
+ {id = "fix-byte-order-marker"},
43
+ {id = "mixed-line-ending"},
44
+ ]
45
+ repo = "builtin"
46
+
47
+ [[repos]]
48
+ hooks = [
49
+ {id = "check-hooks-apply"},
50
+ {id = "check-useless-excludes"},
51
+ ]
52
+ repo = "meta"