Skip to content

Commit 26d53d6

Browse files
authored
feat: add xPath support (#259)
* feat: add xPath support * feat: add missing tests * lint: fix ossues with lines * fix: should be all green now * fix: bump version * chore: bump version * docs: generate changelog * docs: add XPath 3.1 json-to-xml compliance and xpath_format parameter documentation * build
1 parent bd2fa5c commit 26d53d6

File tree

13 files changed

+874
-177
lines changed

13 files changed

+874
-177
lines changed

.coverage

0 Bytes
Binary file not shown.

HISTORY.rst

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,26 @@
11
History
22
=======
33

4+
5.3.0 / 2025-12-08
5+
==================
6+
7+
* chore: bump version
8+
* fix: bump version
9+
* fix: should be all green now
10+
* lint: fix ossues with lines
11+
* feat: add missing tests
12+
* feat: add xPath support
13+
* chore(deps): bump starlette from 0.47.2 to 0.49.1 in /docs (#257)
14+
* feat: improvements to ruff and new python 3.15 (#255)
15+
* Modernize Python code to 3.10+ with pyupgrade (#254)
16+
* check arm build ubuntu (#253)
17+
* Remove duplicate typecheck job from pythonpackage workflow
18+
* Migrate from mypy to ty for type checking (#252)
19+
* Add Python 3.14t (freethreaded) to testing matrix (#251)
20+
* prod release (#249)
21+
* bump python to latest rc2
22+
* fix: switch to release candidate 2 of Python 3.14
23+
424
5.2.0 / 2025-07-21
525
==================
626

README.rst

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ json2xml supports the following features:
3636
* Conversion from a `json` string to XML
3737
* Conversion from a `json` file to XML
3838
* Conversion from an API that emits `json` data to XML
39+
* Compliant with the `json-to-xml` function specification from `XPath 3.1 <https://www.w3.org/TR/xpath-functions-31/#func-json-to-xml>`_
3940

4041
Usage
4142
^^^^^
@@ -167,25 +168,42 @@ You can also specify if the output XML needs to have type specified or not. Here
167168

168169
.. code-block:: python
169170
170-
from json2xml import json2xml
171-
from json2xml.utils import readfromurl, readfromstring, readfromjson
171+
from json2xml import json2xml
172+
from json2xml.utils import readfromurl, readfromstring, readfromjson
172173
173-
data = readfromstring(
174-
'{"login":"mojombo","id":1,"avatar_url":"https://avatars0.githubusercontent.com/u/1?v=4"}'
175-
)
176-
print(json2xml.Json2xml(data, wrapper="all", pretty=True, attr_type=False).to_xml())
174+
data = readfromstring(
175+
'{"login":"mojombo","id":1,"avatar_url":"https://avatars0.githubusercontent.com/u/1?v=4"}'
176+
)
177+
print(json2xml.Json2xml(data, wrapper="all", pretty=True, attr_type=False).to_xml())
177178
178179
179180
Outputs this:
180181

181182
.. code-block:: xml
182183
183-
<?xml version="1.0" ?>
184-
<all>
185-
<login>mojombo</login>
186-
<id>1</id>
187-
<avatar_url>https://avatars0.githubusercontent.com/u/1?v=4</avatar_url>
188-
</all>
184+
<?xml version="1.0" ?>
185+
<all>
186+
<login>mojombo</login>
187+
<id>1</id>
188+
<avatar_url>https://avatars0.githubusercontent.com/u/1?v=4</avatar_url>
189+
</all>
190+
191+
192+
XPath 3.1 Compliance Options
193+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
194+
195+
The library supports the optional `xpath_format` parameter which makes the output compliant with the `json-to-xml` function specification from `XPath 3.1 <https://www.w3.org/TR/xpath-functions-31/#func-json-to-xml>`_. When enabled, the XML output follows the standardized format defined by the W3C specification.
196+
197+
.. code-block:: python
198+
199+
from json2xml import json2xml
200+
from json2xml.utils import readfromstring
201+
202+
data = readfromstring(
203+
'{"login":"mojombo","id":1,"avatar_url":"https://avatars0.githubusercontent.com/u/1?v=4"}'
204+
)
205+
# Use xpath_format=True for XPath 3.1 compliant output
206+
print(json2xml.Json2xml(data, xpath_format=True).to_xml())
189207
190208
191209
The methods are simple and easy to use and there are also checks inside of code to exit cleanly

docs/requirements.in

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
1-
furo==2025.7.19
1+
furo==2025.9.25
22
sphinx==8.2.3
3-
sphinx-autobuild==2024.10.3
3+
sphinx-autobuild==2025.8.25
44

55
# if using typehints
6-
sphinx-autodoc-typehints==3.2.0
6+
sphinx-autodoc-typehints==3.5.2
77

88
mock==5.2.0
99
autodoc==0.5.0
1010

1111
defusedxml==0.7.1
1212
tornado==6.5.2
13-
jinja2>=3.1.6
14-
idna==3.10
15-
starlette>=0.47.2
13+
jinja2==3.1.6
14+
idna==3.11
15+
starlette==0.50.0

docs/requirements.txt

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,5 @@
1-
#
2-
# This file is autogenerated by pip-compile with Python 3.13
3-
# by the following command:
4-
#
5-
# pip-compile
6-
#
1+
# This file was autogenerated by uv via the following command:
2+
# uv pip compile requirements.in -o requirements.txt
73
accessible-pygments==0.0.5
84
# via furo
95
alabaster==1.0.0
@@ -34,11 +30,11 @@ defusedxml==0.7.1
3430
# via -r requirements.in
3531
docutils==0.21.2
3632
# via sphinx
37-
furo==2025.7.19
33+
furo==2025.9.25
3834
# via -r requirements.in
3935
h11==0.16.0
4036
# via uvicorn
41-
idna==3.10
37+
idna==3.11
4238
# via
4339
# -r requirements.in
4440
# anyio
@@ -79,9 +75,9 @@ sphinx==8.2.3
7975
# sphinx-autobuild
8076
# sphinx-autodoc-typehints
8177
# sphinx-basic-ng
82-
sphinx-autobuild==2024.10.3
78+
sphinx-autobuild==2025.8.25
8379
# via -r requirements.in
84-
sphinx-autodoc-typehints==3.2.0
80+
sphinx-autodoc-typehints==3.5.2
8581
# via -r requirements.in
8682
sphinx-basic-ng==1.0.0b2
8783
# via furo
@@ -97,7 +93,7 @@ sphinxcontrib-qthelp==2.0.0
9793
# via sphinx
9894
sphinxcontrib-serializinghtml==2.0.0
9995
# via sphinx
100-
starlette==0.49.1
96+
starlette==0.50.0
10197
# via
10298
# -r requirements.in
10399
# sphinx-autobuild

json2xml/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@
22

33
__author__ = """Vinit Kumar"""
44
__email__ = "[email protected]"
5-
__version__ = "5.2.1"
5+
__version__ = "5.3.0"
66

json2xml/dicttoxml.py

Lines changed: 116 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
import logging
55
import numbers
66
from collections.abc import Callable, Sequence
7+
from decimal import Decimal
8+
from fractions import Fraction
79
from random import SystemRandom
8-
from typing import Any, Union
10+
from typing import Any, Union, cast
911

1012
from defusedxml.minidom import parseString
1113

@@ -58,6 +60,9 @@ def get_unique_id(element: str) -> str:
5860
int,
5961
float,
6062
bool,
63+
complex,
64+
Decimal,
65+
Fraction,
6166
numbers.Number,
6267
Sequence[Any],
6368
datetime.datetime,
@@ -188,6 +193,79 @@ def default_item_func(parent: str) -> str:
188193
return "item"
189194

190195

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+
191269
def convert(
192270
obj: ELEMENT,
193271
ids: Any,
@@ -233,7 +311,7 @@ def convert(
233311
return convert_none(key=item_name, attr_type=attr_type, cdata=cdata)
234312

235313
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)
237315

238316
if isinstance(obj, Sequence):
239317
return convert_list(obj, ids, parent, attr_type, item_func, cdata, item_wrap, list_headers=list_headers)
@@ -563,7 +641,8 @@ def dicttoxml(
563641
item_func: Callable[[str], str] = default_item_func,
564642
cdata: bool = False,
565643
xml_namespaces: dict[str, Any] = {},
566-
list_headers: bool = False
644+
list_headers: bool = False,
645+
xpath_format: bool = False,
567646
) -> bytes:
568647
"""
569648
Converts a python object into XML.
@@ -652,6 +731,28 @@ def dicttoxml(
652731
<Bike><frame_color>red</frame_color></Bike>
653732
<Bike><frame_color>green</frame_color></Bike>
654733
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+
655756
Dictionaries-keys with special char '@' has special meaning:
656757
@attrs: This allows custom xml attributes:
657758
@@ -681,6 +782,18 @@ def dicttoxml(
681782
<list a="b" c="d"><item>4</item><item>5</item><item>6</item></list>
682783
683784
"""
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+
684797
output = []
685798
namespace_str = ""
686799
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:

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "json2xml"
7-
version = "5.2.1" # Replace with the dynamic version if needed
7+
version = "5.3.1" # Replace with the dynamic version if needed
88
description = "Simple Python Library to convert JSON to XML"
99
readme = "README.rst"
1010
requires-python = ">=3.10"
@@ -45,7 +45,7 @@ include = ["json2xml"]
4545

4646
[project.optional-dependencies]
4747
test = [
48-
"pytest==7.0.1",
48+
"pytest>=8.4.1",
4949
]
5050

5151
[tool.pytest.ini_options]

0 commit comments

Comments
 (0)