|
4 | 4 | import logging |
5 | 5 | import numbers |
6 | 6 | from collections.abc import Callable, Sequence |
| 7 | +from decimal import Decimal |
| 8 | +from fractions import Fraction |
7 | 9 | from random import SystemRandom |
8 | | -from typing import Any, Union |
| 10 | +from typing import Any, Union, cast |
9 | 11 |
|
10 | 12 | from defusedxml.minidom import parseString |
11 | 13 |
|
@@ -58,6 +60,9 @@ def get_unique_id(element: str) -> str: |
58 | 60 | int, |
59 | 61 | float, |
60 | 62 | bool, |
| 63 | + complex, |
| 64 | + Decimal, |
| 65 | + Fraction, |
61 | 66 | numbers.Number, |
62 | 67 | Sequence[Any], |
63 | 68 | datetime.datetime, |
@@ -188,6 +193,79 @@ def default_item_func(parent: str) -> str: |
188 | 193 | return "item" |
189 | 194 |
|
190 | 195 |
|
| 196 | +# XPath 3.1 json-to-xml conversion |
| 197 | +# Spec: https://www.w3.org/TR/xpath-functions-31/#json-to-xml-mapping |
| 198 | +XPATH_FUNCTIONS_NS = "http://www.w3.org/2005/xpath-functions" |
| 199 | + |
| 200 | + |
| 201 | +def get_xpath31_tag_name(val: Any) -> str: |
| 202 | + """ |
| 203 | + Determine XPath 3.1 tag name by Python type. |
| 204 | +
|
| 205 | + See: https://www.w3.org/TR/xpath-functions-31/#func-json-to-xml |
| 206 | +
|
| 207 | + Args: |
| 208 | + val: The value to get the tag name for. |
| 209 | +
|
| 210 | + Returns: |
| 211 | + str: The XPath 3.1 tag name (map, array, string, number, boolean, null). |
| 212 | + """ |
| 213 | + if val is None: |
| 214 | + return "null" |
| 215 | + if isinstance(val, bool): |
| 216 | + return "boolean" |
| 217 | + if isinstance(val, dict): |
| 218 | + return "map" |
| 219 | + if isinstance(val, (int, float, numbers.Number)): |
| 220 | + return "number" |
| 221 | + if isinstance(val, str): |
| 222 | + return "string" |
| 223 | + if isinstance(val, (bytes, bytearray)): |
| 224 | + return "string" |
| 225 | + if isinstance(val, Sequence): |
| 226 | + return "array" |
| 227 | + return "string" |
| 228 | + |
| 229 | + |
| 230 | +def convert_to_xpath31(obj: Any, parent_key: str | None = None) -> str: |
| 231 | + """ |
| 232 | + Convert a Python object to XPath 3.1 json-to-xml format. |
| 233 | +
|
| 234 | + See: https://www.w3.org/TR/xpath-functions-31/#json-to-xml-mapping |
| 235 | +
|
| 236 | + Args: |
| 237 | + obj: The object to convert. |
| 238 | + parent_key: The key from the parent dict (used for key attribute). |
| 239 | +
|
| 240 | + Returns: |
| 241 | + str: XML string in XPath 3.1 format. |
| 242 | + """ |
| 243 | + key_attr = f' key="{escape_xml(parent_key)}"' if parent_key is not None else "" |
| 244 | + tag_name = get_xpath31_tag_name(obj) |
| 245 | + |
| 246 | + if tag_name == "null": |
| 247 | + return f"<null{key_attr}/>" |
| 248 | + |
| 249 | + if tag_name == "boolean": |
| 250 | + return f"<boolean{key_attr}>{str(obj).lower()}</boolean>" |
| 251 | + |
| 252 | + if tag_name == "number": |
| 253 | + return f"<number{key_attr}>{obj}</number>" |
| 254 | + |
| 255 | + if tag_name == "string": |
| 256 | + return f"<string{key_attr}>{escape_xml(str(obj))}</string>" |
| 257 | + |
| 258 | + if tag_name == "map": |
| 259 | + children = "".join(convert_to_xpath31(v, k) for k, v in obj.items()) |
| 260 | + return f"<map{key_attr}>{children}</map>" |
| 261 | + |
| 262 | + if tag_name == "array": |
| 263 | + children = "".join(convert_to_xpath31(item) for item in obj) |
| 264 | + return f"<array{key_attr}>{children}</array>" |
| 265 | + |
| 266 | + return f"<string{key_attr}>{escape_xml(str(obj))}</string>" |
| 267 | + |
| 268 | + |
191 | 269 | def convert( |
192 | 270 | obj: ELEMENT, |
193 | 271 | ids: Any, |
@@ -233,7 +311,7 @@ def convert( |
233 | 311 | return convert_none(key=item_name, attr_type=attr_type, cdata=cdata) |
234 | 312 |
|
235 | 313 | if isinstance(obj, dict): |
236 | | - return convert_dict(obj, ids, parent, attr_type, item_func, cdata, item_wrap, list_headers=list_headers) |
| 314 | + return convert_dict(cast("dict[str, Any]", obj), ids, parent, attr_type, item_func, cdata, item_wrap, list_headers=list_headers) |
237 | 315 |
|
238 | 316 | if isinstance(obj, Sequence): |
239 | 317 | return convert_list(obj, ids, parent, attr_type, item_func, cdata, item_wrap, list_headers=list_headers) |
@@ -563,7 +641,8 @@ def dicttoxml( |
563 | 641 | item_func: Callable[[str], str] = default_item_func, |
564 | 642 | cdata: bool = False, |
565 | 643 | xml_namespaces: dict[str, Any] = {}, |
566 | | - list_headers: bool = False |
| 644 | + list_headers: bool = False, |
| 645 | + xpath_format: bool = False, |
567 | 646 | ) -> bytes: |
568 | 647 | """ |
569 | 648 | Converts a python object into XML. |
@@ -652,6 +731,28 @@ def dicttoxml( |
652 | 731 | <Bike><frame_color>red</frame_color></Bike> |
653 | 732 | <Bike><frame_color>green</frame_color></Bike> |
654 | 733 |
|
| 734 | + :param bool xpath_format: |
| 735 | + Default is False |
| 736 | + When True, produces XPath 3.1 json-to-xml compliant output as specified |
| 737 | + by W3C (https://www.w3.org/TR/xpath-functions-31/#func-json-to-xml). |
| 738 | + Uses type-based element names (map, array, string, number, boolean, null) |
| 739 | + with key attributes and the http://www.w3.org/2005/xpath-functions namespace. |
| 740 | +
|
| 741 | + Example: |
| 742 | +
|
| 743 | + .. code-block:: python |
| 744 | +
|
| 745 | + {"name": "John", "age": 30} |
| 746 | +
|
| 747 | + results in |
| 748 | +
|
| 749 | + .. code-block:: xml |
| 750 | +
|
| 751 | + <map xmlns="http://www.w3.org/2005/xpath-functions"> |
| 752 | + <string key="name">John</string> |
| 753 | + <number key="age">30</number> |
| 754 | + </map> |
| 755 | +
|
655 | 756 | Dictionaries-keys with special char '@' has special meaning: |
656 | 757 | @attrs: This allows custom xml attributes: |
657 | 758 |
|
@@ -681,6 +782,18 @@ def dicttoxml( |
681 | 782 | <list a="b" c="d"><item>4</item><item>5</item><item>6</item></list> |
682 | 783 |
|
683 | 784 | """ |
| 785 | + if xpath_format: |
| 786 | + xml_content = convert_to_xpath31(obj) |
| 787 | + output = [ |
| 788 | + '<?xml version="1.0" encoding="UTF-8" ?>', |
| 789 | + xml_content.replace("<map", f'<map xmlns="{XPATH_FUNCTIONS_NS}"', 1) |
| 790 | + if xml_content.startswith("<map") |
| 791 | + else xml_content.replace("<array", f'<array xmlns="{XPATH_FUNCTIONS_NS}"', 1) |
| 792 | + if xml_content.startswith("<array") |
| 793 | + else f'<map xmlns="{XPATH_FUNCTIONS_NS}">{xml_content}</map>', |
| 794 | + ] |
| 795 | + return "".join(output).encode("utf-8") |
| 796 | + |
684 | 797 | output = [] |
685 | 798 | namespace_str = "" |
686 | 799 | for prefix in xml_namespaces: |
|
0 commit comments