Skip to content

Commit 109007e

Browse files
committed
feat: add xPath support
1 parent bd2fa5c commit 109007e

File tree

3 files changed

+196
-2
lines changed

3 files changed

+196
-2
lines changed

json2xml/dicttoxml.py

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,76 @@ def default_item_func(parent: str) -> str:
188188
return "item"
189189

190190

191+
# XPath 3.1 json-to-xml conversion
192+
# Spec: https://www.w3.org/TR/xpath-functions-31/#json-to-xml-mapping
193+
XPATH_FUNCTIONS_NS = "http://www.w3.org/2005/xpath-functions"
194+
195+
196+
def get_xpath31_tag_name(val: Any) -> str:
197+
"""
198+
Determine XPath 3.1 tag name by Python type.
199+
200+
See: https://www.w3.org/TR/xpath-functions-31/#func-json-to-xml
201+
202+
Args:
203+
val: The value to get the tag name for.
204+
205+
Returns:
206+
str: The XPath 3.1 tag name (map, array, string, number, boolean, null).
207+
"""
208+
if val is None:
209+
return "null"
210+
if isinstance(val, bool):
211+
return "boolean"
212+
if isinstance(val, dict):
213+
return "map"
214+
if isinstance(val, (int, float, numbers.Number)):
215+
return "number"
216+
if isinstance(val, Sequence) and not isinstance(val, str):
217+
return "array"
218+
if isinstance(val, str):
219+
return "string"
220+
return "string"
221+
222+
223+
def convert_to_xpath31(obj: Any, parent_key: str | None = None) -> str:
224+
"""
225+
Convert a Python object to XPath 3.1 json-to-xml format.
226+
227+
See: https://www.w3.org/TR/xpath-functions-31/#json-to-xml-mapping
228+
229+
Args:
230+
obj: The object to convert.
231+
parent_key: The key from the parent dict (used for key attribute).
232+
233+
Returns:
234+
str: XML string in XPath 3.1 format.
235+
"""
236+
key_attr = f' key="{escape_xml(parent_key)}"' if parent_key is not None else ""
237+
238+
if obj is None:
239+
return f"<null{key_attr}/>"
240+
241+
if isinstance(obj, bool):
242+
return f"<boolean{key_attr}>{str(obj).lower()}</boolean>"
243+
244+
if isinstance(obj, (int, float, numbers.Number)):
245+
return f"<number{key_attr}>{obj}</number>"
246+
247+
if isinstance(obj, str):
248+
return f"<string{key_attr}>{escape_xml(obj)}</string>"
249+
250+
if isinstance(obj, dict):
251+
children = "".join(convert_to_xpath31(v, k) for k, v in obj.items())
252+
return f"<map{key_attr}>{children}</map>"
253+
254+
if isinstance(obj, Sequence):
255+
children = "".join(convert_to_xpath31(item) for item in obj)
256+
return f"<array{key_attr}>{children}</array>"
257+
258+
return f"<string{key_attr}>{escape_xml(str(obj))}</string>"
259+
260+
191261
def convert(
192262
obj: ELEMENT,
193263
ids: Any,
@@ -563,7 +633,8 @@ def dicttoxml(
563633
item_func: Callable[[str], str] = default_item_func,
564634
cdata: bool = False,
565635
xml_namespaces: dict[str, Any] = {},
566-
list_headers: bool = False
636+
list_headers: bool = False,
637+
xpath_format: bool = False,
567638
) -> bytes:
568639
"""
569640
Converts a python object into XML.
@@ -652,6 +723,28 @@ def dicttoxml(
652723
<Bike><frame_color>red</frame_color></Bike>
653724
<Bike><frame_color>green</frame_color></Bike>
654725
726+
:param bool xpath_format:
727+
Default is False
728+
When True, produces XPath 3.1 json-to-xml compliant output as specified
729+
by W3C (https://www.w3.org/TR/xpath-functions-31/#func-json-to-xml).
730+
Uses type-based element names (map, array, string, number, boolean, null)
731+
with key attributes and the http://www.w3.org/2005/xpath-functions namespace.
732+
733+
Example:
734+
735+
.. code-block:: python
736+
737+
{"name": "John", "age": 30}
738+
739+
results in
740+
741+
.. code-block:: xml
742+
743+
<map xmlns="http://www.w3.org/2005/xpath-functions">
744+
<string key="name">John</string>
745+
<number key="age">30</number>
746+
</map>
747+
655748
Dictionaries-keys with special char '@' has special meaning:
656749
@attrs: This allows custom xml attributes:
657750
@@ -681,6 +774,18 @@ def dicttoxml(
681774
<list a="b" c="d"><item>4</item><item>5</item><item>6</item></list>
682775
683776
"""
777+
if xpath_format:
778+
xml_content = convert_to_xpath31(obj)
779+
output = [
780+
'<?xml version="1.0" encoding="UTF-8" ?>',
781+
xml_content.replace("<map", f'<map xmlns="{XPATH_FUNCTIONS_NS}"', 1)
782+
if xml_content.startswith("<map")
783+
else xml_content.replace("<array", f'<array xmlns="{XPATH_FUNCTIONS_NS}"', 1)
784+
if xml_content.startswith("<array")
785+
else f'<map xmlns="{XPATH_FUNCTIONS_NS}">{xml_content}</map>',
786+
]
787+
return "".join(output).encode("utf-8")
788+
684789
output = []
685790
namespace_str = ""
686791
for prefix in xml_namespaces:

json2xml/json2xml.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,21 @@ class Json2xml:
1414
"""
1515
def __init__(
1616
self,
17-
data: dict[str, Any] | None = None,
17+
data: dict[str, Any] | list[Any] | None = None,
1818
wrapper: str = "all",
1919
root: bool = True,
2020
pretty: bool = True,
2121
attr_type: bool = True,
2222
item_wrap: bool = True,
23+
xpath_format: bool = False,
2324
):
2425
self.data = data
2526
self.pretty = pretty
2627
self.wrapper = wrapper
2728
self.attr_type = attr_type
2829
self.root = root
2930
self.item_wrap = item_wrap
31+
self.xpath_format = xpath_format
3032

3133
def to_xml(self) -> Any | None:
3234
"""
@@ -39,6 +41,7 @@ def to_xml(self) -> Any | None:
3941
custom_root=self.wrapper,
4042
attr_type=self.attr_type,
4143
item_wrap=self.item_wrap,
44+
xpath_format=self.xpath_format,
4245
)
4346
if self.pretty:
4447
try:

tests/test_json2xml.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,3 +228,89 @@ def test_encoding_without_pretty_print(self) -> None:
228228
xmldata = json2xml.Json2xml(data, pretty=False).to_xml()
229229
if xmldata:
230230
assert b'encoding="UTF-8"' in xmldata
231+
232+
def test_xpath_format_basic(self) -> None:
233+
"""Test XPath 3.1 json-to-xml format with basic types."""
234+
data = {"name": "John", "age": 30, "active": True}
235+
xmldata = json2xml.Json2xml(data, xpath_format=True, pretty=False).to_xml()
236+
if xmldata:
237+
assert b'xmlns="http://www.w3.org/2005/xpath-functions"' in xmldata
238+
assert b'<string key="name">John</string>' in xmldata
239+
assert b'<number key="age">30</number>' in xmldata
240+
assert b'<boolean key="active">true</boolean>' in xmldata
241+
242+
def test_xpath_format_nested_dict(self) -> None:
243+
"""Test XPath 3.1 format with nested dictionaries."""
244+
data = {"person": {"name": "Alice", "age": 25}}
245+
xmldata = json2xml.Json2xml(data, xpath_format=True, pretty=False).to_xml()
246+
if xmldata:
247+
assert b'<map key="person">' in xmldata
248+
assert b'<string key="name">Alice</string>' in xmldata
249+
assert b'<number key="age">25</number>' in xmldata
250+
251+
def test_xpath_format_array(self) -> None:
252+
"""Test XPath 3.1 format with arrays."""
253+
data = {"numbers": [1, 2, 3]}
254+
xmldata = json2xml.Json2xml(data, xpath_format=True, pretty=False).to_xml()
255+
if xmldata:
256+
assert b'<array key="numbers">' in xmldata
257+
assert b'<number>1</number>' in xmldata
258+
assert b'<number>2</number>' in xmldata
259+
assert b'<number>3</number>' in xmldata
260+
261+
def test_xpath_format_null(self) -> None:
262+
"""Test XPath 3.1 format with null values."""
263+
data = {"value": None}
264+
xmldata = json2xml.Json2xml(data, xpath_format=True, pretty=False).to_xml()
265+
if xmldata:
266+
assert b'<null key="value"/>' in xmldata
267+
268+
def test_xpath_format_mixed_array(self) -> None:
269+
"""Test XPath 3.1 format with mixed type arrays."""
270+
data = {"items": ["text", 42, True, None]}
271+
xmldata = json2xml.Json2xml(data, xpath_format=True, pretty=False).to_xml()
272+
if xmldata:
273+
assert b'<array key="items">' in xmldata
274+
assert b'<string>text</string>' in xmldata
275+
assert b'<number>42</number>' in xmldata
276+
assert b'<boolean>true</boolean>' in xmldata
277+
assert b'<null/>' in xmldata
278+
279+
def test_xpath_format_complex_nested(self) -> None:
280+
"""Test XPath 3.1 format with complex nested structures."""
281+
data = {
282+
"content": [
283+
{"id": 70805774, "value": "1001", "position": [1004.0, 288.0]},
284+
]
285+
}
286+
xmldata = json2xml.Json2xml(data, xpath_format=True, pretty=False).to_xml()
287+
if xmldata:
288+
assert b'<array key="content">' in xmldata
289+
assert b'<number key="id">70805774</number>' in xmldata
290+
assert b'<string key="value">1001</string>' in xmldata
291+
assert b'<array key="position">' in xmldata
292+
assert b'<number>1004.0</number>' in xmldata
293+
294+
def test_xpath_format_escaping(self) -> None:
295+
"""Test XPath 3.1 format properly escapes special characters."""
296+
data = {"text": "<script>alert('xss')</script>"}
297+
xmldata = json2xml.Json2xml(data, xpath_format=True, pretty=False).to_xml()
298+
if xmldata:
299+
assert b"&lt;script&gt;" in xmldata
300+
assert b"&apos;xss&apos;" in xmldata
301+
302+
def test_xpath_format_with_pretty_print(self) -> None:
303+
"""Test XPath 3.1 format works with pretty printing."""
304+
data = {"name": "Test"}
305+
xmldata = json2xml.Json2xml(data, xpath_format=True, pretty=True).to_xml()
306+
if xmldata:
307+
assert 'xmlns="http://www.w3.org/2005/xpath-functions"' in xmldata
308+
assert '<string key="name">Test</string>' in xmldata
309+
310+
def test_xpath_format_root_array(self) -> None:
311+
"""Test XPath 3.1 format with root-level array."""
312+
data = [1, 2, 3]
313+
xmldata = json2xml.Json2xml(data, xpath_format=True, pretty=False).to_xml()
314+
if xmldata:
315+
assert b'<array xmlns="http://www.w3.org/2005/xpath-functions">' in xmldata
316+
assert b'<number>1</number>' in xmldata

0 commit comments

Comments
 (0)