Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified .coverage
Binary file not shown.
20 changes: 20 additions & 0 deletions HISTORY.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,26 @@
History
=======

5.3.0 / 2025-12-08
==================

* chore: bump version
* fix: bump version
* fix: should be all green now
* lint: fix ossues with lines
* feat: add missing tests
* feat: add xPath support
* chore(deps): bump starlette from 0.47.2 to 0.49.1 in /docs (#257)
* feat: improvements to ruff and new python 3.15 (#255)
* Modernize Python code to 3.10+ with pyupgrade (#254)
* check arm build ubuntu (#253)
* Remove duplicate typecheck job from pythonpackage workflow
* Migrate from mypy to ty for type checking (#252)
* Add Python 3.14t (freethreaded) to testing matrix (#251)
* prod release (#249)
* bump python to latest rc2
* fix: switch to release candidate 2 of Python 3.14

5.2.0 / 2025-07-21
==================

Expand Down
42 changes: 30 additions & 12 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ json2xml supports the following features:
* Conversion from a `json` string to XML
* Conversion from a `json` file to XML
* Conversion from an API that emits `json` data to XML
* Compliant with the `json-to-xml` function specification from `XPath 3.1 <https://www.w3.org/TR/xpath-functions-31/#func-json-to-xml>`_

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

.. code-block:: python

from json2xml import json2xml
from json2xml.utils import readfromurl, readfromstring, readfromjson
from json2xml import json2xml
from json2xml.utils import readfromurl, readfromstring, readfromjson

data = readfromstring(
'{"login":"mojombo","id":1,"avatar_url":"https://avatars0.githubusercontent.com/u/1?v=4"}'
)
print(json2xml.Json2xml(data, wrapper="all", pretty=True, attr_type=False).to_xml())
data = readfromstring(
'{"login":"mojombo","id":1,"avatar_url":"https://avatars0.githubusercontent.com/u/1?v=4"}'
)
print(json2xml.Json2xml(data, wrapper="all", pretty=True, attr_type=False).to_xml())


Outputs this:

.. code-block:: xml

<?xml version="1.0" ?>
<all>
<login>mojombo</login>
<id>1</id>
<avatar_url>https://avatars0.githubusercontent.com/u/1?v=4</avatar_url>
</all>
<?xml version="1.0" ?>
<all>
<login>mojombo</login>
<id>1</id>
<avatar_url>https://avatars0.githubusercontent.com/u/1?v=4</avatar_url>
</all>


XPath 3.1 Compliance Options
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

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.

.. code-block:: python

from json2xml import json2xml
from json2xml.utils import readfromstring

data = readfromstring(
'{"login":"mojombo","id":1,"avatar_url":"https://avatars0.githubusercontent.com/u/1?v=4"}'
)
# Use xpath_format=True for XPath 3.1 compliant output
print(json2xml.Json2xml(data, xpath_format=True).to_xml())


The methods are simple and easy to use and there are also checks inside of code to exit cleanly
Expand Down
12 changes: 6 additions & 6 deletions docs/requirements.in
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
furo==2025.7.19
furo==2025.9.25
sphinx==8.2.3
sphinx-autobuild==2024.10.3
sphinx-autobuild==2025.8.25

# if using typehints
sphinx-autodoc-typehints==3.2.0
sphinx-autodoc-typehints==3.5.2

mock==5.2.0
autodoc==0.5.0

defusedxml==0.7.1
tornado==6.5.2
jinja2>=3.1.6
idna==3.10
starlette>=0.47.2
jinja2==3.1.6
idna==3.11
starlette==0.50.0
18 changes: 7 additions & 11 deletions docs/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
#
# This file is autogenerated by pip-compile with Python 3.13
# by the following command:
#
# pip-compile
#
# This file was autogenerated by uv via the following command:
# uv pip compile requirements.in -o requirements.txt
accessible-pygments==0.0.5
# via furo
alabaster==1.0.0
Expand Down Expand Up @@ -34,11 +30,11 @@ defusedxml==0.7.1
# via -r requirements.in
docutils==0.21.2
# via sphinx
furo==2025.7.19
furo==2025.9.25
# via -r requirements.in
h11==0.16.0
# via uvicorn
idna==3.10
idna==3.11
# via
# -r requirements.in
# anyio
Expand Down Expand Up @@ -79,9 +75,9 @@ sphinx==8.2.3
# sphinx-autobuild
# sphinx-autodoc-typehints
# sphinx-basic-ng
sphinx-autobuild==2024.10.3
sphinx-autobuild==2025.8.25
# via -r requirements.in
sphinx-autodoc-typehints==3.2.0
sphinx-autodoc-typehints==3.5.2
# via -r requirements.in
sphinx-basic-ng==1.0.0b2
# via furo
Expand All @@ -97,7 +93,7 @@ sphinxcontrib-qthelp==2.0.0
# via sphinx
sphinxcontrib-serializinghtml==2.0.0
# via sphinx
starlette==0.49.1
starlette==0.50.0
# via
# -r requirements.in
# sphinx-autobuild
Expand Down
2 changes: 1 addition & 1 deletion json2xml/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@

__author__ = """Vinit Kumar"""
__email__ = "mail@vinitkumar.me"
__version__ = "5.2.1"
__version__ = "5.3.0"
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Version mismatch: The version is set to "5.3.1" in pyproject.toml but "5.3.0" in json2xml/init.py. These should be consistent. Typically, init.py should match pyproject.toml as the single source of truth.

Suggested change
__version__ = "5.3.0"
__version__ = "5.3.1"

Copilot uses AI. Check for mistakes.

119 changes: 116 additions & 3 deletions json2xml/dicttoxml.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
import logging
import numbers
from collections.abc import Callable, Sequence
from decimal import Decimal
from fractions import Fraction
from random import SystemRandom
from typing import Any, Union
from typing import Any, Union, cast

from defusedxml.minidom import parseString

Expand Down Expand Up @@ -58,6 +60,9 @@ def get_unique_id(element: str) -> str:
int,
float,
bool,
complex,
Decimal,
Fraction,
numbers.Number,
Sequence[Any],
datetime.datetime,
Expand Down Expand Up @@ -188,6 +193,79 @@ def default_item_func(parent: str) -> str:
return "item"


# XPath 3.1 json-to-xml conversion
# Spec: https://www.w3.org/TR/xpath-functions-31/#json-to-xml-mapping
XPATH_FUNCTIONS_NS = "http://www.w3.org/2005/xpath-functions"


def get_xpath31_tag_name(val: Any) -> str:
"""
Determine XPath 3.1 tag name by Python type.

See: https://www.w3.org/TR/xpath-functions-31/#func-json-to-xml

Args:
val: The value to get the tag name for.

Returns:
str: The XPath 3.1 tag name (map, array, string, number, boolean, null).
"""
if val is None:
return "null"
if isinstance(val, bool):
return "boolean"
if isinstance(val, dict):
return "map"
if isinstance(val, (int, float, numbers.Number)):
return "number"
if isinstance(val, str):
return "string"
if isinstance(val, (bytes, bytearray)):
return "string"
if isinstance(val, Sequence):
return "array"
return "string"


def convert_to_xpath31(obj: Any, parent_key: str | None = None) -> str:
"""
Convert a Python object to XPath 3.1 json-to-xml format.

See: https://www.w3.org/TR/xpath-functions-31/#json-to-xml-mapping

Args:
obj: The object to convert.
parent_key: The key from the parent dict (used for key attribute).

Returns:
str: XML string in XPath 3.1 format.
"""
key_attr = f' key="{escape_xml(parent_key)}"' if parent_key is not None else ""
tag_name = get_xpath31_tag_name(obj)

if tag_name == "null":
return f"<null{key_attr}/>"

if tag_name == "boolean":
return f"<boolean{key_attr}>{str(obj).lower()}</boolean>"

if tag_name == "number":
return f"<number{key_attr}>{obj}</number>"

if tag_name == "string":
return f"<string{key_attr}>{escape_xml(str(obj))}</string>"

if tag_name == "map":
children = "".join(convert_to_xpath31(v, k) for k, v in obj.items())
return f"<map{key_attr}>{children}</map>"

if tag_name == "array":
children = "".join(convert_to_xpath31(item) for item in obj)
return f"<array{key_attr}>{children}</array>"

return f"<string{key_attr}>{escape_xml(str(obj))}</string>"


def convert(
obj: ELEMENT,
ids: Any,
Expand Down Expand Up @@ -233,7 +311,7 @@ def convert(
return convert_none(key=item_name, attr_type=attr_type, cdata=cdata)

if isinstance(obj, dict):
return convert_dict(obj, ids, parent, attr_type, item_func, cdata, item_wrap, list_headers=list_headers)
return convert_dict(cast("dict[str, Any]", obj), ids, parent, attr_type, item_func, cdata, item_wrap, list_headers=list_headers)

if isinstance(obj, Sequence):
return convert_list(obj, ids, parent, attr_type, item_func, cdata, item_wrap, list_headers=list_headers)
Expand Down Expand Up @@ -563,7 +641,8 @@ def dicttoxml(
item_func: Callable[[str], str] = default_item_func,
cdata: bool = False,
xml_namespaces: dict[str, Any] = {},
list_headers: bool = False
list_headers: bool = False,
xpath_format: bool = False,
) -> bytes:
"""
Converts a python object into XML.
Expand Down Expand Up @@ -652,6 +731,28 @@ def dicttoxml(
<Bike><frame_color>red</frame_color></Bike>
<Bike><frame_color>green</frame_color></Bike>

:param bool xpath_format:
Default is False
When True, produces XPath 3.1 json-to-xml compliant output as specified
by W3C (https://www.w3.org/TR/xpath-functions-31/#func-json-to-xml).
Uses type-based element names (map, array, string, number, boolean, null)
with key attributes and the http://www.w3.org/2005/xpath-functions namespace.

Example:

.. code-block:: python

{"name": "John", "age": 30}

results in

.. code-block:: xml

<map xmlns="http://www.w3.org/2005/xpath-functions">
<string key="name">John</string>
<number key="age">30</number>
</map>

Dictionaries-keys with special char '@' has special meaning:
@attrs: This allows custom xml attributes:

Expand Down Expand Up @@ -681,6 +782,18 @@ def dicttoxml(
<list a="b" c="d"><item>4</item><item>5</item><item>6</item></list>

"""
if xpath_format:
xml_content = convert_to_xpath31(obj)
output = [
'<?xml version="1.0" encoding="UTF-8" ?>',
xml_content.replace("<map", f'<map xmlns="{XPATH_FUNCTIONS_NS}"', 1)
if xml_content.startswith("<map")
else xml_content.replace("<array", f'<array xmlns="{XPATH_FUNCTIONS_NS}"', 1)
if xml_content.startswith("<array")
else f'<map xmlns="{XPATH_FUNCTIONS_NS}">{xml_content}</map>',
]
return "".join(output).encode("utf-8")

output = []
namespace_str = ""
for prefix in xml_namespaces:
Expand Down
5 changes: 4 additions & 1 deletion json2xml/json2xml.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,21 @@ class Json2xml:
"""
def __init__(
self,
data: dict[str, Any] | None = None,
data: dict[str, Any] | list[Any] | None = None,
wrapper: str = "all",
root: bool = True,
pretty: bool = True,
attr_type: bool = True,
item_wrap: bool = True,
xpath_format: bool = False,
):
self.data = data
self.pretty = pretty
self.wrapper = wrapper
self.attr_type = attr_type
self.root = root
self.item_wrap = item_wrap
self.xpath_format = xpath_format

def to_xml(self) -> Any | None:
"""
Expand All @@ -39,6 +41,7 @@ def to_xml(self) -> Any | None:
custom_root=self.wrapper,
attr_type=self.attr_type,
item_wrap=self.item_wrap,
xpath_format=self.xpath_format,
)
if self.pretty:
try:
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

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

[project.optional-dependencies]
test = [
"pytest==7.0.1",
"pytest>=8.4.1",
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test dependency constraint changed from pytest==7.0.1 (exact pin) to pytest>=8.4.1 (minimum version). This is a significant breaking change as pytest 7.0.1 is very old (2022) and may have different behavior than pytest 9.x. This change should be intentional and all tests should be verified to work with the newer version.

Copilot uses AI. Check for mistakes.
]

[tool.pytest.ini_options]
Expand Down
Loading
Loading