diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 2df1c0ae..4b55256f 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -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 @@ -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 diff --git a/lat.md/architecture.md b/lat.md/architecture.md index 74fa41fa..9765e298 100644 --- a/lat.md/architecture.md +++ b/lat.md/architecture.md @@ -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 diff --git a/lat.md/behavior.md b/lat.md/behavior.md index 91e87479..a1251bc8 100644 --- a/lat.md/behavior.md +++ b/lat.md/behavior.md @@ -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 `` 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 `` 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 `` element inside the configured root wrapper. diff --git a/lat.md/tests.md b/lat.md/tests.md index 3117d041..cb8929da 100644 --- a/lat.md/tests.md +++ b/lat.md/tests.md @@ -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. diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 5ca4cecc..4bc908e6 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -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" diff --git a/rust/pyproject.toml b/rust/pyproject.toml index 926d14e9..6a02d254 100644 --- a/rust/pyproject.toml +++ b/rust/pyproject.toml @@ -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" diff --git a/rust/uv.lock b/rust/uv.lock index 3fb2d877..291f80a6 100644 --- a/rust/uv.lock +++ b/rust/uv.lock @@ -3,5 +3,5 @@ requires-python = ">=3.9" [[package]] name = "json2xml-rs" -version = "0.4.2" +version = "0.4.3" source = { editable = "." } diff --git a/tests/test_json2xml.py b/tests/test_json2xml.py index 03f76f3f..b5ae318c 100644 --- a/tests/test_json2xml.py +++ b/tests/test_json2xml.py @@ -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'mojombo') + assert isinstance(xmldata, bytes) + assert xmldata.startswith(b'mojombo') pytest.raises(ExpatError, xmltodict.parse, xmldata) def test_item_wrap(self) -> None: @@ -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'John' in xmldata - assert b'30' in xmldata - assert b'true' in xmldata + assert isinstance(xmldata, bytes) + assert b'xmlns="http://www.w3.org/2005/xpath-functions"' in xmldata + assert b'John' in xmldata + assert b'30' in xmldata + assert b'true' 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'' in xmldata - assert b'Alice' in xmldata - assert b'25' in xmldata + assert isinstance(xmldata, bytes) + assert b'' in xmldata + assert b'Alice' in xmldata + assert b'25' 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'' in xmldata - assert b'1' in xmldata - assert b'2' in xmldata - assert b'3' in xmldata + assert isinstance(xmldata, bytes) + assert b'' in xmldata + assert b'1' in xmldata + assert b'2' in xmldata + assert b'3' 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'' in xmldata + assert isinstance(xmldata, bytes) + assert b'' 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'' in xmldata - assert b'text' in xmldata - assert b'42' in xmldata - assert b'true' in xmldata - assert b'' in xmldata + assert isinstance(xmldata, bytes) + assert b'' in xmldata + assert b'text' in xmldata + assert b'42' in xmldata + assert b'true' in xmldata + assert b'' in xmldata def test_xpath_format_complex_nested(self) -> None: """Test XPath 3.1 format with complex nested structures.""" @@ -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'' in xmldata - assert b'70805774' in xmldata - assert b'1001' in xmldata - assert b'' in xmldata - assert b'1004.0' in xmldata + assert isinstance(xmldata, bytes) + assert b'' in xmldata + assert b'70805774' in xmldata + assert b'1001' in xmldata + assert b'' in xmldata + assert b'1004.0' in xmldata def test_xpath_format_escaping(self) -> None: """Test XPath 3.1 format properly escapes special characters.""" data = {"text": ""} xmldata = json2xml.Json2xml(data, xpath_format=True, pretty=False).to_xml() - if xmldata: - assert b"<script>" in xmldata - assert b"'xss'" in xmldata + assert isinstance(xmldata, bytes) + assert b"<script>" in xmldata + assert b"'xss'" 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 'Test' in xmldata + assert isinstance(xmldata, str) + assert 'xmlns="http://www.w3.org/2005/xpath-functions"' in xmldata + assert 'Test' 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'' in xmldata - assert b'1' in xmldata + assert isinstance(xmldata, bytes) + assert b'' in xmldata + assert b'1' in xmldata