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
25 changes: 24 additions & 1 deletion RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,26 @@
# json2xml_rs 0.4.3

Released 2026-08-05.

## Highlights

- Rejects XML 1.0-forbidden characters in serialized text, attributes, CDATA, and the exported `escape_xml_py()` and `wrap_cdata_py()` helpers instead of emitting invalid XML.
- Gives the Python package a compatible accelerator that passes its XML-safety probe, restoring automatic Rust dispatch for supported payloads.
- Updates PyO3 from 0.28.2 to 0.29.1 while preserving the existing Python 3.9+ package contract and wheel matrix.

## Migration guidance

Inputs containing forbidden XML 1.0 characters now raise `ValueError`. Remove or replace those characters before conversion; callers must not depend on invalid XML being emitted.

## Package Version

- Rust accelerator: `json2xml-rs==0.4.3`

## Verification

The release is gated on Rust formatting, Clippy with warnings denied, Rust unit tests, the full Python suite, and built-wheel tests for Linux, macOS, and Windows before PyPI publication.


# Unreleased

## Security
Expand All @@ -16,7 +39,7 @@

## Release prerequisite

Publish a compatible accelerator version newer than `json2xml-rs==0.4.2`, then raise the `json2xml[fast]` and `uv.lock` minimum to that published version. Until then, the compatibility probe safely disables 0.4.2 and uses the Python backend.
Publish `json2xml-rs==0.4.3`, then raise the `json2xml[fast]` and `uv.lock` minimum to that published version. Until then, the compatibility probe safely disables 0.4.2 and uses the Python backend.


# json2xml 6.5.0
Expand Down
2 changes: 1 addition & 1 deletion lat.md/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ Release and CI workflows install the pinned Rust toolchain before building wheel

Package releases keep the Python wrapper and Rust accelerator requirements aligned so optional fast installs receive compatible wheels.

The Python package version lives in `pyproject.toml` and `json2xml/__init__.py`. The Rust accelerator version lives in both `rust/Cargo.toml` and `rust/pyproject.toml`, and the Python `fast` extra should require the Rust package version that contains any expected accelerator behavior. Release notes live in `HISTORY.rst`, with the current release also summarized in `RELEASE_NOTES.md` for tag and PyPI copy.
The Python package version lives in `pyproject.toml` and `json2xml/__init__.py`. The Rust accelerator version lives in both `rust/Cargo.toml` and `rust/pyproject.toml`, and the Python `fast` extra should require the Rust package version that contains any expected accelerator behavior. Publish and verify the Rust package before updating that Python requirement and lockfile. Release notes live in `HISTORY.rst`, with the current release also summarized in `RELEASE_NOTES.md` for tag and PyPI copy.

## Performance benchmarks

Expand Down
2 changes: 1 addition & 1 deletion lat.md/behavior.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ README and docs examples use `pretty=False` for scan-friendly output and avoid h

Default output includes an XML declaration, wraps content in `all`, pretty prints the document, and annotates elements with their source type unless callers disable those features.

[[json2xml/json2xml.py#Json2xml#to_xml]] calls [[json2xml/dicttoxml_fast.py#dicttoxml]] with the configured wrapper, root, `attr_type`, `item_wrap`, `cdata`, and `list_headers` options. It treats only `None` as absent input, so falsy JSON values still serialize. When `item_wrap=False`, list values repeat the parent tag instead of creating `<item>` children. When `pretty=False`, the library returns the serializer bytes directly.
[[json2xml/json2xml.py#Json2xml#to_xml]] calls [[json2xml/dicttoxml_fast.py#dicttoxml]] with the configured wrapper, root, `attr_type`, `item_wrap`, `cdata`, and `list_headers` options. It treats only `None` as absent input, so falsy JSON values still serialize. When `item_wrap=False`, list values repeat the parent tag instead of creating `<item>` children. Pretty output is Unicode text; `pretty=False` returns the serializer's UTF-8 bytes directly.

The fast backend selector falls back to the pure Python serializer for root scalar payloads so values like `0`, `false`, and `""` keep the historical `<item>` element inside the configured root wrapper.

Expand Down
4 changes: 4 additions & 0 deletions lat.md/tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,10 @@ If every backend rejects a conversion request, the selector should raise a clear

The public `Json2xml` wrapper should delegate through the fast backend selector so regular library and CLI conversions can use the Rust accelerator when installed.

### Json2xml return types match pretty mode

The public wrapper should return Unicode text for pretty output and UTF-8 bytes for compact output so callers can rely on the documented `to_xml()` type contract.

### Special keys force Python fallback

Special dictionary keys such as `@attrs` and `@val` should bypass the Rust callable so the Python serializer can preserve legacy attribute semantics.
Expand Down
2 changes: 1 addition & 1 deletion rust/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "json2xml_rs"
version = "0.4.2"
version = "0.4.3"
edition = "2024"
rust-version = "1.96"
description = "Fast native JSON to XML conversion for Python"
Expand Down
2 changes: 1 addition & 1 deletion rust/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "maturin"

[project]
name = "json2xml_rs"
version = "0.4.2"
version = "0.4.3"
description = "Fast native JSON to XML conversion - Rust extension for json2xml"
readme = "README.md"
requires-python = ">=3.9"
Expand Down
2 changes: 1 addition & 1 deletion rust/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

87 changes: 44 additions & 43 deletions tests/test_json2xml.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,8 @@ def test_no_wrapper(self) -> None:
'{"login":"mojombo","id":1,"avatar_url":"https://avatars0.githubusercontent.com/u/1?v=4"}'
)
xmldata = json2xml.Json2xml(data, root=False, pretty=False).to_xml()
if xmldata:
assert xmldata.startswith(b'<login type="str">mojombo</login>')
assert isinstance(xmldata, bytes)
assert xmldata.startswith(b'<login type="str">mojombo</login>')
pytest.raises(ExpatError, xmltodict.parse, xmldata)

def test_item_wrap(self) -> None:
Expand Down Expand Up @@ -261,69 +261,70 @@ def test_dict_attr_crash(self) -> None:
assert dict_from_xml["all"]["product"]["@attr_name"] == "attr_value"
assert dict_from_xml["all"]["product"]["@a"] == "b"

# @lat: [[tests#Conversion behavior#Json2xml return types match pretty mode]]
def test_encoding_pretty_print(self) -> None:
data = readfromstring(
'{"login":"mojombo","id":1,"avatar_url":"https://avatars0.githubusercontent.com/u/1?v=4"}'
)
xmldata = json2xml.Json2xml(data, pretty=True).to_xml()
if xmldata:
assert 'encoding="UTF-8"' in xmldata
assert isinstance(xmldata, str)
assert 'encoding="UTF-8"' in xmldata

def test_encoding_without_pretty_print(self) -> None:
data = readfromstring(
'{"login":"mojombo","id":1,"avatar_url":"https://avatars0.githubusercontent.com/u/1?v=4"}'
)
xmldata = json2xml.Json2xml(data, pretty=False).to_xml()
if xmldata:
assert b'encoding="UTF-8"' in xmldata
assert isinstance(xmldata, bytes)
assert b'encoding="UTF-8"' in xmldata

# @lat: [[tests#Conversion behavior#XPath format adds functions namespace]]
def test_xpath_format_basic(self) -> None:
"""Test XPath 3.1 json-to-xml format with basic types."""
data = {"name": "John", "age": 30, "active": True}
xmldata = json2xml.Json2xml(data, xpath_format=True, pretty=False).to_xml()
if xmldata:
assert b'xmlns="http://www.w3.org/2005/xpath-functions"' in xmldata
assert b'<string key="name">John</string>' in xmldata
assert b'<number key="age">30</number>' in xmldata
assert b'<boolean key="active">true</boolean>' in xmldata
assert isinstance(xmldata, bytes)
assert b'xmlns="http://www.w3.org/2005/xpath-functions"' in xmldata
assert b'<string key="name">John</string>' in xmldata
assert b'<number key="age">30</number>' in xmldata
assert b'<boolean key="active">true</boolean>' in xmldata

def test_xpath_format_nested_dict(self) -> None:
"""Test XPath 3.1 format with nested dictionaries."""
data = {"person": {"name": "Alice", "age": 25}}
xmldata = json2xml.Json2xml(data, xpath_format=True, pretty=False).to_xml()
if xmldata:
assert b'<map key="person">' in xmldata
assert b'<string key="name">Alice</string>' in xmldata
assert b'<number key="age">25</number>' in xmldata
assert isinstance(xmldata, bytes)
assert b'<map key="person">' in xmldata
assert b'<string key="name">Alice</string>' in xmldata
assert b'<number key="age">25</number>' in xmldata

def test_xpath_format_array(self) -> None:
"""Test XPath 3.1 format with arrays."""
data = {"numbers": [1, 2, 3]}
xmldata = json2xml.Json2xml(data, xpath_format=True, pretty=False).to_xml()
if xmldata:
assert b'<array key="numbers">' in xmldata
assert b'<number>1</number>' in xmldata
assert b'<number>2</number>' in xmldata
assert b'<number>3</number>' in xmldata
assert isinstance(xmldata, bytes)
assert b'<array key="numbers">' in xmldata
assert b'<number>1</number>' in xmldata
assert b'<number>2</number>' in xmldata
assert b'<number>3</number>' in xmldata

def test_xpath_format_null(self) -> None:
"""Test XPath 3.1 format with null values."""
data = {"value": None}
xmldata = json2xml.Json2xml(data, xpath_format=True, pretty=False).to_xml()
if xmldata:
assert b'<null key="value"/>' in xmldata
assert isinstance(xmldata, bytes)
assert b'<null key="value"/>' in xmldata

def test_xpath_format_mixed_array(self) -> None:
"""Test XPath 3.1 format with mixed type arrays."""
data = {"items": ["text", 42, True, None]}
xmldata = json2xml.Json2xml(data, xpath_format=True, pretty=False).to_xml()
if xmldata:
assert b'<array key="items">' in xmldata
assert b'<string>text</string>' in xmldata
assert b'<number>42</number>' in xmldata
assert b'<boolean>true</boolean>' in xmldata
assert b'<null/>' in xmldata
assert isinstance(xmldata, bytes)
assert b'<array key="items">' in xmldata
assert b'<string>text</string>' in xmldata
assert b'<number>42</number>' in xmldata
assert b'<boolean>true</boolean>' in xmldata
assert b'<null/>' in xmldata

def test_xpath_format_complex_nested(self) -> None:
"""Test XPath 3.1 format with complex nested structures."""
Expand All @@ -333,33 +334,33 @@ def test_xpath_format_complex_nested(self) -> None:
]
}
xmldata = json2xml.Json2xml(data, xpath_format=True, pretty=False).to_xml()
if xmldata:
assert b'<array key="content">' in xmldata
assert b'<number key="id">70805774</number>' in xmldata
assert b'<string key="value">1001</string>' in xmldata
assert b'<array key="position">' in xmldata
assert b'<number>1004.0</number>' in xmldata
assert isinstance(xmldata, bytes)
assert b'<array key="content">' in xmldata
assert b'<number key="id">70805774</number>' in xmldata
assert b'<string key="value">1001</string>' in xmldata
assert b'<array key="position">' in xmldata
assert b'<number>1004.0</number>' in xmldata

def test_xpath_format_escaping(self) -> None:
"""Test XPath 3.1 format properly escapes special characters."""
data = {"text": "<script>alert('xss')</script>"}
xmldata = json2xml.Json2xml(data, xpath_format=True, pretty=False).to_xml()
if xmldata:
assert b"&lt;script&gt;" in xmldata
assert b"&apos;xss&apos;" in xmldata
assert isinstance(xmldata, bytes)
assert b"&lt;script&gt;" in xmldata
assert b"&apos;xss&apos;" in xmldata

def test_xpath_format_with_pretty_print(self) -> None:
"""Test XPath 3.1 format works with pretty printing."""
data = {"name": "Test"}
xmldata = json2xml.Json2xml(data, xpath_format=True, pretty=True).to_xml()
if xmldata:
assert 'xmlns="http://www.w3.org/2005/xpath-functions"' in xmldata
assert '<string key="name">Test</string>' in xmldata
assert isinstance(xmldata, str)
assert 'xmlns="http://www.w3.org/2005/xpath-functions"' in xmldata
assert '<string key="name">Test</string>' in xmldata

def test_xpath_format_root_array(self) -> None:
"""Test XPath 3.1 format with root-level array."""
data = [1, 2, 3]
xmldata = json2xml.Json2xml(data, xpath_format=True, pretty=False).to_xml()
if xmldata:
assert b'<array xmlns="http://www.w3.org/2005/xpath-functions">' in xmldata
assert b'<number>1</number>' in xmldata
assert isinstance(xmldata, bytes)
assert b'<array xmlns="http://www.w3.org/2005/xpath-functions">' in xmldata
assert b'<number>1</number>' in xmldata
Loading