From 943c3a877e48d8811a2ad12309c43ee2b847926e Mon Sep 17 00:00:00 2001 From: Vinit Kumar Date: Thu, 3 Sep 2026 01:19:12 +0530 Subject: [PATCH 01/11] Keep tuples off the Rust backend Python applies its list-shape rules to tuples, so under item_wrap=False or list_headers=True a nested tuple drops or borrows its wrapper. The native writer only recognizes lists and always wraps a tuple, yet both gates admitted tuples. Reject them in the Python reference gate and in payload_is_supported so backend choice cannot change output. Closes #408 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CC6FMMGY3EKteEdBJBYkbp --- json2xml/backend_selector.py | 6 +++--- lat.md/architecture.md | 2 +- lat.md/tests.md | 4 ++++ rust/src/lib.rs | 11 +++-------- tests/test_rust_python_parity.py | 21 ++++++++++++++++++++- 5 files changed, 31 insertions(+), 13 deletions(-) diff --git a/json2xml/backend_selector.py b/json2xml/backend_selector.py index 83cf410..0a0f835 100644 --- a/json2xml/backend_selector.py +++ b/json2xml/backend_selector.py @@ -56,9 +56,9 @@ def render(self, request: ConversionRequest) -> bytes: # Types the Rust backend renders byte-identically to the Python serializer. Subclasses are # excluded on purpose: Python classifies them through its isinstance fallbacks, which the -# native writer does not reproduce. +# native writer does not reproduce. Tuples are excluded because Python applies its +# list-shape rules to them while the native writer only recognizes lists. _RUST_SCALAR_TYPES = frozenset({str, bool, int, float, type(None)}) -_RUST_CONTAINER_TYPES = frozenset({dict, list, tuple}) def _rust_renders_key_identically(key: Any) -> bool: @@ -105,7 +105,7 @@ def rust_renders_identically(obj: Any) -> bool: return False stack.append(child) continue - if value_type in _RUST_CONTAINER_TYPES: + if value_type is list: stack.extend(value) continue return False diff --git a/lat.md/architecture.md b/lat.md/architecture.md index 3bd3647..a405243 100644 --- a/lat.md/architecture.md +++ b/lat.md/architecture.md @@ -102,7 +102,7 @@ GitHub Actions workflows run with read-only tokens by default and use full SHA p The `.github/workflows/` files declare the minimum `permissions:` scopes needed by each workflow, with CodeQL retaining `security-events: write` for result upload and TestPyPI retaining `id-token: write` for explicit trusted-publishing runs. The Python test job also scopes `id-token: write` and `FLAKINESS_PROJECT` to its pytest matrix so the flakiness reporter can authenticate through GitHub OIDC without exposing that permission to lint. Release-branch pushes build distributions and runs Twine checks; TestPyPI upload is a manual opt-in because that external registry requires separate publisher configuration. Action references are pinned to immutable commits with the upstream tag retained in comments for reviewability, and all CodeQL phases use the same release commit because mixed action versions reject each other's configuration. `.github/dependabot.yml` checks the `github-actions` ecosystem weekly so those pins do not silently age. The Python test matrix pins its PyPy 3.11 job to an explicit PyPy release so CI keeps exercising the intended CPython 3.11.15-compatible runtime instead of silently drifting with runner cache updates. It also exercises regular CPython 3.15.0rc1 while leaving that beta's free-threaded builds out of CI until the runner support is less brittle. -The native backend is only selected for payloads it renders byte-identically. [[json2xml/backend_selector.py#rust_renders_identically]] is the reference definition of that subset -- dict, list, tuple, and exact `str`, `bool`, `int`, `float`, and `None` values, under keys both implementations name the same way -- and `payload_is_supported` in the crate is its native form, used at dispatch so the walk does not cost more than the conversion it guards. Subclasses and non-JSON-native values stay on Python because its isinstance fallbacks classify them in ways the native writer does not reproduce. +The native backend is only selected for payloads it renders byte-identically. [[json2xml/backend_selector.py#rust_renders_identically]] is the reference definition of that subset -- dict, list, and exact `str`, `bool`, `int`, `float`, and `None` values, under keys both implementations name the same way -- and `payload_is_supported` in the crate is its native form, used at dispatch so the walk does not cost more than the conversion it guards. Subclasses and non-JSON-native values stay on Python because its isinstance fallbacks classify them in ways the native writer does not reproduce. Tuples also stay on Python: it applies its list-shape rules to them, while the native writer only recognizes lists. Key naming is the subtle half of that subset. Python resolves any name its ASCII fast path rejects through a real XML parser, whose verdict the crate cannot reproduce, so non-ASCII names, colon names, and names ending in whitespace stay on Python. For ASCII colon-free names the two agree by construction, because the XML Name production restricted to ASCII is exactly the fast path. diff --git a/lat.md/tests.md b/lat.md/tests.md index b25fcaf..e3a7730 100644 --- a/lat.md/tests.md +++ b/lat.md/tests.md @@ -252,6 +252,10 @@ Values Python classifies through isinstance fallbacks, scalar subclasses, and ke Every divergence found by differential testing is pinned across the full option matrix, and randomized payloads are compared against the Python serializer under a fixed seed. +#### Tuples stay on Python + +Python applies its list-shape rules to tuples but the native writer only recognizes lists, so the gate must keep every payload containing a tuple on the Python serializer. + #### Native and Python gates agree The native gate is an optimization over the Python reference walk, so the two must return the same verdict for every payload; disagreeing in either direction is a correctness bug. diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 3f5a19c..b5e42c4 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -8,7 +8,7 @@ use pyo3::exceptions::PyValueError; #[cfg(feature = "python")] use pyo3::prelude::*; #[cfg(feature = "python")] -use pyo3::types::{PyBool, PyBytes, PyDict, PyFloat, PyInt, PyList, PyString, PyTuple}; +use pyo3::types::{PyBool, PyBytes, PyDict, PyFloat, PyInt, PyList, PyString}; #[cfg(feature = "python")] use std::io::{BufWriter, Write}; @@ -754,7 +754,8 @@ fn key_renders_identically(key: &Bound<'_, PyAny>) -> bool { /// This is the native form of `rust_renders_identically`; the selector calls it before /// dispatching so the walk does not cost a Python-level traversal of the whole payload. /// Types are matched exactly, because Python classifies subclasses through isinstance -/// fallbacks that this writer does not reproduce. +/// fallbacks that this writer does not reproduce. Tuples are rejected because Python applies +/// its list-shape rules to them while this writer only recognizes lists. #[cfg(feature = "python")] #[pyfunction] fn payload_is_supported(obj: &Bound<'_, PyAny>) -> PyResult { @@ -784,12 +785,6 @@ fn payload_is_supported(obj: &Bound<'_, PyAny>) -> PyResult { } continue; } - if let Ok(tuple) = value.cast_exact::() { - for item in tuple.iter() { - stack.push(item); - } - continue; - } return Ok(false); } Ok(true) diff --git a/tests/test_rust_python_parity.py b/tests/test_rust_python_parity.py index a2f29f2..b12df5c 100644 --- a/tests/test_rust_python_parity.py +++ b/tests/test_rust_python_parity.py @@ -107,11 +107,30 @@ def test_gate_admits_keys_both_backends_name_identically(key: str) -> None: def test_gate_walks_nested_containers() -> None: """A single unsupported value anywhere in the payload disqualifies the request.""" - assert rust_renders_identically({"a": [{"b": (1, 2)}, None]}) + assert rust_renders_identically({"a": [{"b": [1, 2]}, None]}) assert not rust_renders_identically({"a": [{"b": [Decimal("1")]}]}) assert not rust_renders_identically([[{"deep": {"deeper": Decimal("1")}}]]) +# @lat: [[tests#Conversion behavior#Rust backend parity#Tuples stay on Python]] +@pytest.mark.parametrize( + "data", + [ + {"a": (1, 2)}, + {"a": [(1, 2)]}, + {"a": ({"b": 1},)}, + [(1, 2)], + ], +) +def test_gate_rejects_tuples(data: Any) -> None: + """Python applies its list-shape rules to tuples; the native writer only to lists. + + Under ``item_wrap=False`` or ``list_headers=True`` the two disagree on whether a + nested tuple keeps its wrapper, so tuples never reach the native backend. + """ + assert not rust_renders_identically(data) + + def test_root_gate_only_applies_when_a_root_is_emitted() -> None: """A rootless document never names the root, so its value cannot matter.""" assert rust_renders_root_identically(True, "root") From 04610558e9e46ef14ddc898f087caad1ce8772a8 Mon Sep 17 00:00:00 2001 From: Vinit Kumar Date: Thu, 3 Sep 2026 01:19:55 +0530 Subject: [PATCH 02/11] Write identical CLI output to stdout and files print() appended a newline on stdout while the -o branch wrote the text verbatim, so pretty output ended with a blank line on stdout and compact output had no final newline in files. Both destinations now receive the document followed by exactly one newline. Closes #409 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CC6FMMGY3EKteEdBJBYkbp --- json2xml/cli.py | 34 ++++++++++++++++++++++++---------- lat.md/tests.md | 8 ++++++++ tests/test_cli.py | 39 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 70 insertions(+), 11 deletions(-) diff --git a/json2xml/cli.py b/json2xml/cli.py index eae89d1..e635ad0 100644 --- a/json2xml/cli.py +++ b/json2xml/cli.py @@ -186,19 +186,33 @@ def convert(self, data: JSONValue, options: CLIConversionOptions) -> str | bytes return xml_output def write_output(self, output: str | bytes, output_file: str | None) -> None: - if isinstance(output, bytes): - output = output.decode("utf-8") + text = output.decode("utf-8") if isinstance(output, bytes) else output + # Both destinations get the same text: the document plus exactly one final + # newline, whether the serializer emitted none, one, or several. + text = text.rstrip("\n") + "\n" - if output_file: - try: - with open(output_file, "w", encoding="utf-8") as file_obj: - file_obj.write(output) - except OSError as error: - print(f"Error writing to file: {error}", file=sys.stderr) - sys.exit(1) + if not output_file: + self._write_stdout(text) + return + + try: + Path(output_file).write_text(text, encoding="utf-8") + except OSError as error: + print(f"Error writing to file: {error}", file=sys.stderr) + sys.exit(1) + + @staticmethod + def _write_stdout(text: str) -> None: + """Write UTF-8 bytes so stdout matches the file output byte for byte.""" + buffer = getattr(sys.stdout, "buffer", None) + if buffer is None: + # A text-only replacement such as io.StringIO has no byte layer. + sys.stdout.write(text) return - print(output) + sys.stdout.flush() + buffer.write(text.encode("utf-8")) + buffer.flush() _APP = CLIApplication() diff --git a/lat.md/tests.md b/lat.md/tests.md index e3a7730..0c2903c 100644 --- a/lat.md/tests.md +++ b/lat.md/tests.md @@ -62,6 +62,14 @@ Malformed IDNA hostnames should raise `URLReadError` so hostname encoding failur Public URL reads should connect to a validated resolved address while preserving the original Host header and TLS hostname so DNS rebinding cannot redirect the connection. +## CLI output + +The CLI writes the same text whether it targets stdout or a file, so redirecting stdout and passing `-o` produce byte-identical results. + +### Stdout and file output are identical + +Both destinations receive the document followed by exactly one newline; pretty output already ends with one and compact output gains one. + ## CLI failure messages These tests verify common command-line failures return short messages that name the broken input source and point users at the next valid action. diff --git a/tests/test_cli.py b/tests/test_cli.py index 8d1f877..c64a652 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -604,7 +604,44 @@ def test_write_output_to_file(self) -> None: output_file = Path(tmpdir) / "output.xml" write_output("test", str(output_file)) assert output_file.exists() - assert output_file.read_text() == "test" + assert output_file.read_text() == "test\n" + + # @lat: [[tests#CLI output#Stdout and file output are identical]] + @pytest.mark.parametrize( + "output", + [ + b"", + "", + "\n \n\n", + "\n\n\n", + "Zo\u00eb \U0001f389", + ], + ids=["compact-bytes", "compact-text", "pretty-text", "extra-newlines", "utf8"], + ) + def test_write_output_ends_with_exactly_one_newline_everywhere( + self, output: str | bytes, capsys: CaptureFixture[str], tmp_path: Path + ) -> None: + """Stdout and a file receive the same text: the document plus one newline.""" + text = output.decode() if isinstance(output, bytes) else output + expected = text.rstrip("\n") + "\n" + output_file = tmp_path / "output.xml" + + write_output(output, None) + write_output(output, str(output_file)) + + assert capsys.readouterr().out == expected + assert output_file.read_text(encoding="utf-8") == expected + + def test_write_output_falls_back_to_text_stdout_without_a_buffer( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A text-only stdout replacement still receives the document.""" + stdout = io.StringIO() + monkeypatch.setattr(sys, "stdout", stdout) + + write_output("Zo\u00eb", None) + + assert stdout.getvalue() == "Zo\u00eb\n" def test_read_from_stdin_valid_json(self) -> None: """Test read_from_stdin with valid JSON.""" From 830c34475fcf9c3be975049bbd37e50883a89013 Mon Sep 17 00:00:00 2001 From: Vinit Kumar Date: Thu, 3 Sep 2026 01:20:36 +0530 Subject: [PATCH 03/11] Accept scalars in fast escape_xml and wrap_cdata The Rust helpers take str only, so escape_xml(5) returned "5" on the Python backend and raised TypeError once the extension was installed. Render non-str scalars with str() before calling into Rust so backend choice cannot change the public helper API. Closes #410 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CC6FMMGY3EKteEdBJBYkbp --- json2xml/dicttoxml_fast.py | 26 +++++++++++++++--------- lat.md/tests.md | 4 ++++ tests/test_dicttoxml_fast_fallback.py | 29 +++++++++++++++++++++++++++ tests/test_rust_dicttoxml.py | 11 ++++++++++ 4 files changed, 61 insertions(+), 9 deletions(-) diff --git a/json2xml/dicttoxml_fast.py b/json2xml/dicttoxml_fast.py index 516bc03..556625f 100644 --- a/json2xml/dicttoxml_fast.py +++ b/json2xml/dicttoxml_fast.py @@ -15,6 +15,7 @@ from __future__ import annotations import logging +import numbers from collections.abc import Callable from dataclasses import dataclass from typing import Any @@ -223,18 +224,25 @@ def dicttoxml( return _BACKEND_SELECTOR.render(request) -# Re-export commonly used functions -def escape_xml(s: str) -> str: - """Escape special XML characters in a string.""" - if _use_rust and rust_escape_xml is not None: # pragma: no cover - return rust_escape_xml(s) +# Re-export commonly used functions. The Rust helpers take str only, so scalars +# are rendered the way the Python helpers render them before crossing over. +def escape_xml(s: str | int | float | numbers.Number | None) -> str: + """Escape special XML characters in a string or scalar value. + + Scalar values (int, float, numbers.Number, or None) are converted with str(). + """ + if _use_rust and rust_escape_xml is not None: + return rust_escape_xml(s if isinstance(s, str) else str(s)) return _py_dicttoxml.escape_xml(s) -def wrap_cdata(s: str) -> str: - """Wrap a string in a CDATA section.""" - if _use_rust and rust_wrap_cdata is not None: # pragma: no cover - return rust_wrap_cdata(s) +def wrap_cdata(s: str | int | float | numbers.Number) -> str: + """Wrap a string or scalar value in a CDATA section. + + Scalar values (int, float, or numbers.Number) are converted with str(). + """ + if _use_rust and rust_wrap_cdata is not None: + return rust_wrap_cdata(s if isinstance(s, str) else str(s)) return _py_dicttoxml.wrap_cdata(s) diff --git a/lat.md/tests.md b/lat.md/tests.md index 0c2903c..aa29cf5 100644 --- a/lat.md/tests.md +++ b/lat.md/tests.md @@ -206,6 +206,10 @@ Backend metadata helpers should report whether Rust is active and name the selec Helper exports for XML escaping and CDATA wrapping should preserve Python behavior when Rust helper callables are unavailable. +### Fast helper functions accept every scalar + +The Rust escape and CDATA helpers take only `str`, so the public helpers must coerce numbers the same way the Python helpers do; installing the extension must not narrow the API. + ### Backend selector detects Python-only payload markers The backend selector should recognize nested `@attrs`, `@val`, and `@flat` markers so Rust is skipped before semantics drift. diff --git a/tests/test_dicttoxml_fast_fallback.py b/tests/test_dicttoxml_fast_fallback.py index 9db9a80..1a528b9 100644 --- a/tests/test_dicttoxml_fast_fallback.py +++ b/tests/test_dicttoxml_fast_fallback.py @@ -175,6 +175,35 @@ def test_fast_helper_functions_use_python_fallback( assert fast_module.wrap_cdata("Ada ") == "]]>" +def _str_only(transform: Any) -> Any: + """Mimic a PyO3 ``&str`` parameter, which rejects every non-str argument.""" + + def guarded(value: Any) -> str: + if not isinstance(value, str): + raise TypeError(f"'{type(value).__name__}' object is not a 'str'") + return transform(value) + + return guarded + + +# @lat: [[tests#Conversion behavior#Fast helper functions accept every scalar]] +def test_fast_helpers_coerce_scalars_before_calling_rust( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The public helpers accept the same scalars on both backends.""" + monkeypatch.setattr(fast_module, "_use_rust", True) + monkeypatch.setattr( + fast_module, "rust_escape_xml", _str_only(py_dicttoxml.escape_xml) + ) + monkeypatch.setattr( + fast_module, "rust_wrap_cdata", _str_only(py_dicttoxml.wrap_cdata) + ) + + assert fast_module.escape_xml(5) == py_dicttoxml.escape_xml(5) == "5" + assert fast_module.wrap_cdata(1.5) == py_dicttoxml.wrap_cdata(1.5) + assert fast_module.escape_xml("a None: diff --git a/tests/test_rust_dicttoxml.py b/tests/test_rust_dicttoxml.py index bf952f5..01261ec 100644 --- a/tests/test_rust_dicttoxml.py +++ b/tests/test_rust_dicttoxml.py @@ -653,6 +653,17 @@ def test_list_with_flat_key(self): assert b"John" in result +class TestFastHelpersWithRealExtension: + """The installed extension must not narrow the helpers' accepted inputs.""" + + def test_escape_xml_accepts_numbers(self) -> None: + assert fast_module.escape_xml(5) == "5" + assert fast_module.escape_xml(1.5) == "1.5" + + def test_wrap_cdata_accepts_numbers(self) -> None: + assert fast_module.wrap_cdata(5) == "" + + class TestFastDicttoxmlPythonFallback: """Test Python fallback paths in dicttoxml_fast module.""" From 63dec31694dbcc7f54b5381e09960c4077f4925e Mon Sep 17 00:00:00 2001 From: Vinit Kumar Date: Thu, 3 Sep 2026 01:21:22 +0530 Subject: [PATCH 04/11] Raise InvalidDataError for unsupported values The budget walk treats any Mapping as a container while the serializer accepts only dict, so a mappingproxy value escaped to_xml as a bare TypeError. Translate TypeError alongside ValueError so the documented InvalidDataError contract holds for every rejected payload. Closes #411 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CC6FMMGY3EKteEdBJBYkbp --- json2xml/json2xml.py | 4 +++- lat.md/tests.md | 4 ++++ tests/test_json2xml.py | 9 +++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/json2xml/json2xml.py b/json2xml/json2xml.py index 2d65ed2..bb6fe46 100644 --- a/json2xml/json2xml.py +++ b/json2xml/json2xml.py @@ -111,7 +111,9 @@ def to_xml(self) -> bytes | str | None: max_output_bytes=self.max_output_bytes, indent=PRETTY_INDENT if self.pretty else None, ) - except ValueError as error: + except (ValueError, TypeError) as error: + # ValueError covers limits and forbidden characters; TypeError covers + # values the serializer cannot map, such as a Mapping that is not a dict. raise InvalidDataError(str(error)) from error if len(xml_data) > self.max_output_bytes: raise InvalidDataError("XML output size limit exceeded") diff --git a/lat.md/tests.md b/lat.md/tests.md index aa29cf5..b4f67ec 100644 --- a/lat.md/tests.md +++ b/lat.md/tests.md @@ -222,6 +222,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. +### Serializer rejections become InvalidDataError + +`Json2xml.to_xml` documents `InvalidDataError` as its only conversion failure, so a value the budget walk accepts but the serializer rejects, such as a non-dict Mapping, must surface as that error rather than a bare `TypeError`. + ### 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. diff --git a/tests/test_json2xml.py b/tests/test_json2xml.py index a74cfcb..93bcbe7 100644 --- a/tests/test_json2xml.py +++ b/tests/test_json2xml.py @@ -3,6 +3,7 @@ """Tests for `json2xml` package.""" from pyexpat import ExpatError +from types import MappingProxyType from typing import Any, TypedDict from unittest.mock import Mock @@ -236,6 +237,14 @@ def test_dicttoxml_bug(self) -> None: old_dict = xmltodict.parse(xmldata) assert "response" in old_dict.keys() + # @lat: [[tests#Conversion behavior#Serializer rejections become InvalidDataError]] + def test_unsupported_mapping_raises_invalid_data_error(self) -> None: + """A Mapping that is not a dict passes the budget walk but not the serializer.""" + data: Any = {"a": MappingProxyType({"b": 1})} + + with pytest.raises(InvalidDataError, match="Unsupported data type"): + json2xml.Json2xml(data).to_xml() + def test_bad_data(self) -> None: data = b"!\0a8f" decoded = data.decode("utf-8") From 49568c8c1d475097537734db612dad261f085d54 Mon Sep 17 00:00:00 2001 From: Vinit Kumar Date: Thu, 3 Sep 2026 01:23:52 +0530 Subject: [PATCH 05/11] Load Rust bindings through one function The import block mutated four module globals and carried pragma comments on twelve lines. A loader now returns an immutable record of the callables, or None when the extension is missing, predates the payload gate, or permits invalid XML characters. Each outcome is covered by tests through a stand-in module, so no pragmas remain. The logger is named after the module so it sits under the json2xml logger hierarchy. Closes #414 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CC6FMMGY3EKteEdBJBYkbp --- json2xml/dicttoxml_fast.py | 96 ++++++++++-------- lat.md/tests.md | 4 + tests/test_dicttoxml_fast_fallback.py | 135 ++++++++++++++++---------- tests/test_rust_dicttoxml.py | 22 +---- tests/test_rust_python_parity.py | 5 +- 5 files changed, 146 insertions(+), 116 deletions(-) diff --git a/json2xml/dicttoxml_fast.py b/json2xml/dicttoxml_fast.py index 556625f..317bbbd 100644 --- a/json2xml/dicttoxml_fast.py +++ b/json2xml/dicttoxml_fast.py @@ -20,6 +20,8 @@ from dataclasses import dataclass from typing import Any +import json2xml.dicttoxml as _py_dicttoxml + from .backend_selector import ( BackendSelector, ConversionRequest, @@ -28,15 +30,18 @@ RustStringTransform = Callable[[str], str] -LOG = logging.getLogger("dicttoxml_fast") +LOG = logging.getLogger(__name__) + + +@dataclass(frozen=True, slots=True) +class _RustBindings: + """Callables exported by a ``json2xml_rs`` build the selector may use.""" -# Try to import the Rust implementation -_use_rust = False -_rust_dicttoxml: Callable[..., bytes] | None = None -# The payload gate walks the whole input, so run it natively when the extension provides it. -_rust_payload_is_supported: Callable[[Any], bool] | None = None -rust_escape_xml: RustStringTransform | None = None -rust_wrap_cdata: RustStringTransform | None = None + dicttoxml: Callable[..., bytes] + # The payload gate walks the whole input, so it runs natively when available. + payload_is_supported: Callable[[Any], bool] + escape_xml: RustStringTransform + wrap_cdata: RustStringTransform def _rejects_invalid_xml(escape: RustStringTransform) -> bool: @@ -50,38 +55,48 @@ def _rejects_invalid_xml(escape: RustStringTransform) -> bool: return False -try: - from json2xml_rs import dicttoxml as _rust_dicttoxml # pragma: no cover - from json2xml_rs import escape_xml_py as rust_escape_xml # pragma: no cover - from json2xml_rs import ( # pragma: no cover - payload_is_supported as _rust_payload_is_supported, - ) - from json2xml_rs import wrap_cdata_py as rust_wrap_cdata # pragma: no cover +def _load_rust_bindings() -> _RustBindings | None: + """Import ``json2xml_rs`` and refuse builds that would change output or safety.""" + try: + import json2xml_rs + except ImportError: + LOG.debug("Rust backend not available, using pure Python") + return None - if _rejects_invalid_xml(rust_escape_xml): # pragma: no cover - _use_rust = True # pragma: no cover - LOG.debug("Using Rust backend for dicttoxml") # pragma: no cover - else: # pragma: no cover - LOG.warning( # pragma: no cover + try: + bindings = _RustBindings( + dicttoxml=json2xml_rs.dicttoxml, + payload_is_supported=json2xml_rs.payload_is_supported, + escape_xml=json2xml_rs.escape_xml_py, + wrap_cdata=json2xml_rs.wrap_cdata_py, + ) + except AttributeError: + # Builds before payload_is_supported existed also predate the output parity + # fixes, so they must leave the Python serializer in charge. + LOG.debug("Ignoring an outdated Rust backend that predates the payload gate") + return None + + if not _rejects_invalid_xml(bindings.escape_xml): + LOG.warning( "Ignoring an outdated Rust backend that permits invalid XML characters" ) -except ImportError: # pragma: no cover - # Builds before payload_is_supported existed also predate the output parity fixes, so a - # failed import of any name here correctly leaves the Python serializer in charge. - LOG.debug("Rust backend not available or too old, using pure Python") + return None + + LOG.debug("Using Rust backend for dicttoxml") + return bindings -# Import the pure Python implementation as fallback. -import json2xml.dicttoxml as _py_dicttoxml # noqa: E402 + +_RUST = _load_rust_bindings() def is_rust_available() -> bool: """Check if the Rust backend is available.""" - return _use_rust + return _RUST is not None def get_backend() -> str: """Return the name of the current backend ('rust' or 'python').""" - return "rust" if _use_rust else "python" + return "rust" if _RUST is not None else "python" @dataclass(frozen=True, slots=True) @@ -91,11 +106,8 @@ class _RustBackendAdapter: name: str = "rust" def can_handle(self, request: ConversionRequest) -> bool: - if ( - not _use_rust - or _rust_dicttoxml is None - or _rust_payload_is_supported is None - ): + rust = _RUST + if rust is None: return False return not ( @@ -108,12 +120,12 @@ def can_handle(self, request: ConversionRequest) -> bool: or not rust_renders_root_identically(request.root, request.custom_root) # The native walk keeps this gate from costing more than the conversion it # guards; rust_renders_identically is its pure-Python reference. - or not _rust_payload_is_supported(request.obj) + or not rust.payload_is_supported(request.obj) ) def render(self, request: ConversionRequest) -> bytes: - assert _rust_dicttoxml is not None - output = _rust_dicttoxml( + assert _RUST is not None + output = _RUST.dicttoxml( request.obj, root=request.root, custom_root=request.custom_root, @@ -231,9 +243,9 @@ def escape_xml(s: str | int | float | numbers.Number | None) -> str: Scalar values (int, float, numbers.Number, or None) are converted with str(). """ - if _use_rust and rust_escape_xml is not None: - return rust_escape_xml(s if isinstance(s, str) else str(s)) - return _py_dicttoxml.escape_xml(s) + if _RUST is None: + return _py_dicttoxml.escape_xml(s) + return _RUST.escape_xml(s if isinstance(s, str) else str(s)) def wrap_cdata(s: str | int | float | numbers.Number) -> str: @@ -241,9 +253,9 @@ def wrap_cdata(s: str | int | float | numbers.Number) -> str: Scalar values (int, float, or numbers.Number) are converted with str(). """ - if _use_rust and rust_wrap_cdata is not None: - return rust_wrap_cdata(s if isinstance(s, str) else str(s)) - return _py_dicttoxml.wrap_cdata(s) + if _RUST is None: + return _py_dicttoxml.wrap_cdata(s) + return _RUST.wrap_cdata(s if isinstance(s, str) else str(s)) # Export the same API as the original dicttoxml module diff --git a/lat.md/tests.md b/lat.md/tests.md index b4f67ec..2290192 100644 --- a/lat.md/tests.md +++ b/lat.md/tests.md @@ -126,6 +126,10 @@ Every harness subprocess should use an inline argv list with shell parsing expli These tests pin the XML shapes that matter most for interoperability, especially the modes that intentionally diverge from the default serializer. +### Rust backend loader refuses unusable builds + +The loader binds `json2xml_rs` only when it is importable, exports the payload gate, and rejects XML 1.0 forbidden characters; every other outcome leaves the Python serializer in charge. + ### Outdated Rust backends stay disabled An optional Rust accelerator is eligible only when its escape helper rejects XML 1.0 forbidden characters, preventing an older wheel from bypassing the Python serializer's validation. diff --git a/tests/test_dicttoxml_fast_fallback.py b/tests/test_dicttoxml_fast_fallback.py index 1a528b9..c1b2b46 100644 --- a/tests/test_dicttoxml_fast_fallback.py +++ b/tests/test_dicttoxml_fast_fallback.py @@ -2,6 +2,9 @@ from __future__ import annotations +import logging +import sys +from types import SimpleNamespace from typing import Any from unittest.mock import Mock @@ -9,7 +12,20 @@ import json2xml.dicttoxml_fast as fast_module from json2xml import dicttoxml as py_dicttoxml -from json2xml.backend_selector import ConversionRequest, rust_renders_identically +from json2xml.backend_selector import rust_renders_identically +from json2xml.dicttoxml_fast import _RustBindings + + +def _fake_bindings(**overrides: Any) -> _RustBindings: + """Build Rust bindings backed by the Python reference implementations.""" + fields: dict[str, Any] = { + "dicttoxml": Mock(return_value=b""), + "payload_is_supported": rust_renders_identically, + "escape_xml": py_dicttoxml.escape_xml, + "wrap_cdata": py_dicttoxml.wrap_cdata, + } + fields.update(overrides) + return _RustBindings(**fields) def _force_rust_backend(monkeypatch: pytest.MonkeyPatch) -> Mock: @@ -20,14 +36,68 @@ def _force_rust_backend(monkeypatch: pytest.MonkeyPatch) -> Mock: would exercise nothing. """ rust_backend = Mock(return_value=b"") - monkeypatch.setattr(fast_module, "_use_rust", True) - monkeypatch.setattr(fast_module, "_rust_dicttoxml", rust_backend) - monkeypatch.setattr( - fast_module, "_rust_payload_is_supported", rust_renders_identically - ) + monkeypatch.setattr(fast_module, "_RUST", _fake_bindings(dicttoxml=rust_backend)) return rust_backend +def _fake_extension(monkeypatch: pytest.MonkeyPatch, **exports: Any) -> None: + """Register a stand-in ``json2xml_rs`` module exposing only ``exports``.""" + monkeypatch.setitem(sys.modules, "json2xml_rs", SimpleNamespace(**exports)) + + +_COMPLETE_EXPORTS: dict[str, Any] = { + "dicttoxml": Mock(return_value=b""), + "payload_is_supported": rust_renders_identically, + "escape_xml_py": py_dicttoxml.escape_xml, + "wrap_cdata_py": py_dicttoxml.wrap_cdata, +} + + +# @lat: [[tests#Conversion behavior#Rust backend loader refuses unusable builds]] +def test_loader_returns_none_when_extension_is_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setitem(sys.modules, "json2xml_rs", None) + + assert fast_module._load_rust_bindings() is None + + +def test_loader_refuses_builds_without_the_payload_gate( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An extension predating the payload gate also predates the output parity fixes.""" + exports = { + k: v for k, v in _COMPLETE_EXPORTS.items() if k != "payload_is_supported" + } + _fake_extension(monkeypatch, **exports) + + assert fast_module._load_rust_bindings() is None + + +def test_loader_refuses_builds_that_permit_invalid_xml( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + _fake_extension(monkeypatch, **{**_COMPLETE_EXPORTS, "escape_xml_py": str}) + + with caplog.at_level(logging.WARNING, logger="json2xml.dicttoxml_fast"): + assert fast_module._load_rust_bindings() is None + + assert "permits invalid XML characters" in caplog.text + + +def test_loader_binds_a_complete_extension(monkeypatch: pytest.MonkeyPatch) -> None: + _fake_extension(monkeypatch, **_COMPLETE_EXPORTS) + + bindings = fast_module._load_rust_bindings() + + assert bindings == _RustBindings( + dicttoxml=_COMPLETE_EXPORTS["dicttoxml"], + payload_is_supported=rust_renders_identically, + escape_xml=py_dicttoxml.escape_xml, + wrap_cdata=py_dicttoxml.wrap_cdata, + ) + + # @lat: [[tests#Conversion behavior#Outdated Rust backends stay disabled]] @pytest.mark.parametrize( ("escape", "expected"), @@ -47,7 +117,7 @@ def test_fast_wrapper_reports_python_backend_when_rust_is_unavailable( monkeypatch: pytest.MonkeyPatch, ) -> None: """Backend metadata should reflect the active fallback backend.""" - monkeypatch.setattr(fast_module, "_use_rust", False) + monkeypatch.setattr(fast_module, "_RUST", None) assert fast_module.is_rust_available() is False assert fast_module.get_backend() == "python" @@ -151,15 +221,12 @@ def test_fast_wrapper_falls_back_to_python_when_rust_is_unavailable( monkeypatch: pytest.MonkeyPatch, ) -> None: """Contributors without json2xml_rs should still exercise the pure Python fallback.""" - rust_backend = Mock(return_value=b"") - monkeypatch.setattr(fast_module, "_use_rust", False) - monkeypatch.setattr(fast_module, "_rust_dicttoxml", rust_backend) + monkeypatch.setattr(fast_module, "_RUST", None) result = fast_module.dicttoxml({"name": "Ada"}) assert b"Ada" in result - rust_backend.assert_not_called() # @lat: [[tests#Conversion behavior#Fast helper functions use Python fallback]] @@ -167,9 +234,7 @@ def test_fast_helper_functions_use_python_fallback( monkeypatch: pytest.MonkeyPatch, ) -> None: """Helper exports should preserve behavior when Rust helpers are unavailable.""" - monkeypatch.setattr(fast_module, "_use_rust", False) - monkeypatch.setattr(fast_module, "rust_escape_xml", None) - monkeypatch.setattr(fast_module, "rust_wrap_cdata", None) + monkeypatch.setattr(fast_module, "_RUST", None) assert fast_module.escape_xml("Ada & ") == "Ada & <XML>" assert fast_module.wrap_cdata("Ada ") == "]]>" @@ -191,46 +256,12 @@ def test_fast_helpers_coerce_scalars_before_calling_rust( monkeypatch: pytest.MonkeyPatch, ) -> None: """The public helpers accept the same scalars on both backends.""" - monkeypatch.setattr(fast_module, "_use_rust", True) - monkeypatch.setattr( - fast_module, "rust_escape_xml", _str_only(py_dicttoxml.escape_xml) - ) - monkeypatch.setattr( - fast_module, "rust_wrap_cdata", _str_only(py_dicttoxml.wrap_cdata) + bindings = _fake_bindings( + escape_xml=_str_only(py_dicttoxml.escape_xml), + wrap_cdata=_str_only(py_dicttoxml.wrap_cdata), ) + monkeypatch.setattr(fast_module, "_RUST", bindings) assert fast_module.escape_xml(5) == py_dicttoxml.escape_xml(5) == "5" assert fast_module.wrap_cdata(1.5) == py_dicttoxml.wrap_cdata(1.5) assert fast_module.escape_xml("a None: - """An extension predating the payload gate also predates the output parity fixes. - - Such a build would render admitted payloads differently from the Python serializer, so - the adapter must decline every request rather than trust it. - """ - _force_rust_backend(monkeypatch) - monkeypatch.setattr(fast_module, "_rust_payload_is_supported", None) - - adapter = fast_module._RustBackendAdapter() - request = ConversionRequest( - obj={"a": 1}, - root=True, - custom_root="root", - ids=None, - attr_type=True, - item_wrap=True, - item_func=None, - cdata=False, - xml_namespaces=None, - list_headers=False, - xpath_format=False, - max_output_bytes=None, - indent=None, - ) - - assert adapter.can_handle(request) is False - assert fast_module.dicttoxml({"a": 1}) == py_dicttoxml.dicttoxml({"a": 1}) diff --git a/tests/test_rust_dicttoxml.py b/tests/test_rust_dicttoxml.py index 01261ec..20487c9 100644 --- a/tests/test_rust_dicttoxml.py +++ b/tests/test_rust_dicttoxml.py @@ -671,8 +671,7 @@ def test_escape_xml_python_fallback(self): """Test escape_xml falls back to Python when Rust unavailable.""" from unittest.mock import patch - # Temporarily mock Rust availability to False. - with patch.object(fast_module, "_use_rust", False): + with patch.object(fast_module, "_RUST", None): result = fast_module.escape_xml("Hello ") assert "<" in result assert ">" in result @@ -681,23 +680,6 @@ def test_wrap_cdata_python_fallback(self): """Test wrap_cdata falls back to Python when Rust unavailable.""" from unittest.mock import patch - # Temporarily mock Rust availability to False. - with patch.object(fast_module, "_use_rust", False): + with patch.object(fast_module, "_RUST", None): result = fast_module.wrap_cdata("Hello World") assert result == "" - - def test_escape_xml_fallback_when_rust_func_none(self): - """Test escape_xml falls back when rust_escape_xml is None.""" - from unittest.mock import patch - - with patch.object(fast_module, "rust_escape_xml", None): - result = fast_module.escape_xml("Test & Value") - assert "&" in result - - def test_wrap_cdata_fallback_when_rust_func_none(self): - """Test wrap_cdata falls back when rust_wrap_cdata is None.""" - from unittest.mock import patch - - with patch.object(fast_module, "rust_wrap_cdata", None): - result = fast_module.wrap_cdata("Test Content") - assert result == "" diff --git a/tests/test_rust_python_parity.py b/tests/test_rust_python_parity.py index b12df5c..62535b1 100644 --- a/tests/test_rust_python_parity.py +++ b/tests/test_rust_python_parity.py @@ -247,9 +247,10 @@ def test_native_and_python_gates_agree() -> None: payload the writer cannot reproduce, and admitting too little silently drops the fast path. """ - from json2xml.dicttoxml_fast import _rust_payload_is_supported + from json2xml.dicttoxml_fast import _RUST - assert _rust_payload_is_supported is not None + assert _RUST is not None + _rust_payload_is_supported = _RUST.payload_is_supported fixtures: list[Any] = [ {}, From 486a529e79f98fab82f723af199303f1aa0e5bdc Mon Sep 17 00:00:00 2001 From: Vinit Kumar Date: Thu, 3 Sep 2026 01:26:55 +0530 Subject: [PATCH 06/11] Enforce conversion limits in the native payload walk On the Rust path Json2xml.to_xml spent most of its time in the Python depth and item walk: 5.9 ms of 7.1 ms on a 2000-record payload, against 1.0 ms for the conversion itself. payload_is_supported now takes max_depth and max_items and enforces them during the walk it already performs, visiting values in the same order and raising the same messages as the Python walk. to_xml skips the Python walk when the native walk completes; the same payload now converts in 1.4 ms. The loader probes whether the installed build accepts the limit keywords, so the published 0.5.0 wheel keeps working with the Python walk. The crate version moves to 0.6.0. Closes #412 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CC6FMMGY3EKteEdBJBYkbp --- json2xml/dicttoxml_fast.py | 35 +++++++++++++- json2xml/json2xml.py | 9 +++- json2xml_rs.pyi | 4 +- lat.md/behavior.md | 2 +- lat.md/tests.md | 6 +++ rust/Cargo.toml | 2 +- rust/pyproject.toml | 2 +- rust/src/lib.rs | 29 ++++++++++-- rust/uv.lock | 2 +- tests/test_dicttoxml_fast_fallback.py | 67 ++++++++++++++++++++++++++- tests/test_json2xml.py | 39 ++++++++++++++++ tests/test_rust_python_parity.py | 45 ++++++++++++++++++ 12 files changed, 229 insertions(+), 13 deletions(-) diff --git a/json2xml/dicttoxml_fast.py b/json2xml/dicttoxml_fast.py index 317bbbd..77a4d4a 100644 --- a/json2xml/dicttoxml_fast.py +++ b/json2xml/dicttoxml_fast.py @@ -32,6 +32,9 @@ LOG = logging.getLogger(__name__) +# Limits are passed to the extension as u64; anything larger stays on the Python walk. +_NATIVE_LIMIT_MAX = 2**64 - 1 + @dataclass(frozen=True, slots=True) class _RustBindings: @@ -39,9 +42,11 @@ class _RustBindings: dicttoxml: Callable[..., bytes] # The payload gate walks the whole input, so it runs natively when available. - payload_is_supported: Callable[[Any], bool] + payload_is_supported: Callable[..., bool] escape_xml: RustStringTransform wrap_cdata: RustStringTransform + # Builds before 0.6.0 cannot enforce depth and item limits during that walk. + enforces_limits: bool def _rejects_invalid_xml(escape: RustStringTransform) -> bool: @@ -55,6 +60,15 @@ def _rejects_invalid_xml(escape: RustStringTransform) -> bool: return False +def _accepts_limits(payload_is_supported: Callable[..., bool]) -> bool: + """Return whether the payload gate takes ``max_depth`` and ``max_items``.""" + try: + payload_is_supported({}, max_depth=1, max_items=1) + except TypeError: + return False + return True + + def _load_rust_bindings() -> _RustBindings | None: """Import ``json2xml_rs`` and refuse builds that would change output or safety.""" try: @@ -69,6 +83,7 @@ def _load_rust_bindings() -> _RustBindings | None: payload_is_supported=json2xml_rs.payload_is_supported, escape_xml=json2xml_rs.escape_xml_py, wrap_cdata=json2xml_rs.wrap_cdata_py, + enforces_limits=_accepts_limits(json2xml_rs.payload_is_supported), ) except AttributeError: # Builds before payload_is_supported existed also predate the output parity @@ -99,6 +114,24 @@ def get_backend() -> str: return "rust" if _RUST is not None else "python" +def check_conversion_budget(obj: Any, max_depth: int, max_items: int) -> bool: + """Enforce nesting and item limits during the native payload walk. + + Returns True when the native walk completed within budget, so the caller can + skip its Python-level walk. Returns False when the caller must run that walk + instead: no usable extension, a build without limit support, a limit outside + the native range, or a payload outside the exact subset the gate walks. + + :raises ValueError: If ``obj`` exceeds ``max_depth`` or ``max_items``. + """ + rust = _RUST + if rust is None or not rust.enforces_limits: + return False + if max_depth > _NATIVE_LIMIT_MAX or max_items > _NATIVE_LIMIT_MAX: + return False + return rust.payload_is_supported(obj, max_depth=max_depth, max_items=max_items) + + @dataclass(frozen=True, slots=True) class _RustBackendAdapter: """Adapter for the optional Rust backend.""" diff --git a/json2xml/json2xml.py b/json2xml/json2xml.py index bb6fe46..d187360 100644 --- a/json2xml/json2xml.py +++ b/json2xml/json2xml.py @@ -97,8 +97,15 @@ def to_xml(self) -> bytes | str | None: the data. """ if self.data is not None: - _validate_conversion_budget(self.data, self.max_depth, self.max_items) try: + # The native gate walks the payload anyway, so it enforces the budget + # when it can; the Python walk remains for every other case. + if not dicttoxml.check_conversion_budget( + self.data, self.max_depth, self.max_items + ): + _validate_conversion_budget( + self.data, self.max_depth, self.max_items + ) xml_data = dicttoxml.dicttoxml( self.data, root=self.root, diff --git a/json2xml_rs.pyi b/json2xml_rs.pyi index 4f92b29..a164f1f 100644 --- a/json2xml_rs.pyi +++ b/json2xml_rs.pyi @@ -11,4 +11,6 @@ def dicttoxml( ) -> bytes: ... def escape_xml_py(s: str) -> str: ... def wrap_cdata_py(s: str) -> str: ... -def payload_is_supported(obj: Any) -> bool: ... +def payload_is_supported( + obj: Any, max_depth: int | None = None, max_items: int | None = None +) -> bool: ... diff --git a/lat.md/behavior.md b/lat.md/behavior.md index 29f5f09..6e65aac 100644 --- a/lat.md/behavior.md +++ b/lat.md/behavior.md @@ -44,7 +44,7 @@ When `xpath_format=True`, [[json2xml/dicttoxml.py#dicttoxml]] delegates payload Opt-in pretty printing indents during serialization, so no generated XML is ever parsed back. -[[json2xml/json2xml.py#Json2xml#to_xml]] rejects excessive depth and item counts before conversion, then passes an indent unit to the serializer when pretty output is requested. Compact and pretty output are both bounded as UTF-8 bytes are emitted, and indentation is counted in that budget because the writer emits it. Because the library never reads XML back, malformed markup, DTDs, and entities have no formatter to reach. +[[json2xml/json2xml.py#Json2xml#to_xml]] rejects excessive depth and item counts before conversion, then passes an indent unit to the serializer when pretty output is requested. When the installed extension can enforce those limits, the native payload gate performs that walk so the Rust path does not pay for a second Python-level traversal; otherwise the Python walk runs. Compact and pretty output are both bounded as UTF-8 bytes are emitted, and indentation is counted in that budget because the writer emits it. Because the library never reads XML back, malformed markup, DTDs, and entities have no formatter to reach. ## Generated documents are well formed diff --git a/lat.md/tests.md b/lat.md/tests.md index 2290192..13275bc 100644 --- a/lat.md/tests.md +++ b/lat.md/tests.md @@ -252,6 +252,12 @@ Conversion should reject excessive nesting and item counts before serialization, Limit validation rejects booleans, non-integers, and non-positive values. Tests cover exact backend bytes and all pretty whitespace, including indentation and the trailing newline, in that same budget. +### Native conversion budget + +The native payload gate enforces depth and item limits in the same visiting order as the Python walk, so both report the same first violation. + +`Json2xml` skips the Python walk only when the native walk completes; builds without limit support keep the Python walk. + ### Pretty printing avoids DOM reparsing Pretty output should be produced by the serializer itself rather than by constructing or reparsing a DOM. diff --git a/rust/Cargo.toml b/rust/Cargo.toml index ae1a504..1b0c804 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "json2xml_rs" -version = "0.5.0" +version = "0.6.0" 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 1e4bd6c..da5078d 100644 --- a/rust/pyproject.toml +++ b/rust/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "json2xml_rs" -version = "0.5.0" +version = "0.6.0" description = "Fast native JSON to XML conversion - Rust extension for json2xml" readme = "README.md" requires-python = ">=3.9" diff --git a/rust/src/lib.rs b/rust/src/lib.rs index b5e42c4..13a8cd7 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -756,12 +756,31 @@ fn key_renders_identically(key: &Bound<'_, PyAny>) -> bool { /// Types are matched exactly, because Python classifies subclasses through isinstance /// fallbacks that this writer does not reproduce. Tuples are rejected because Python applies /// its list-shape rules to them while this writer only recognizes lists. +/// +/// When `max_depth` or `max_items` is given, the same walk enforces the conversion budget +/// exactly as `_validate_conversion_budget` in `json2xml/json2xml.py` does: values are +/// visited in the same order and the same message is raised for the first violation, so +/// the wrapper can skip its own Python-level walk. A `false` verdict says nothing about the +/// budget; the caller must then fall back to the Python walk. #[cfg(feature = "python")] #[pyfunction] -fn payload_is_supported(obj: &Bound<'_, PyAny>) -> PyResult { - let mut stack: Vec> = vec![obj.clone()]; +#[pyo3(signature = (obj, max_depth=None, max_items=None))] +fn payload_is_supported( + obj: &Bound<'_, PyAny>, + max_depth: Option, + max_items: Option, +) -> PyResult { + let mut stack: Vec<(Bound<'_, PyAny>, u64)> = vec![(obj.clone(), 0)]; + let mut items: u64 = 0; - while let Some(value) = stack.pop() { + while let Some((value, depth)) = stack.pop() { + items += 1; + if max_items.is_some_and(|limit| items > limit) { + return Err(PyValueError::new_err("JSON item limit exceeded")); + } + if max_depth.is_some_and(|limit| depth > limit) { + return Err(PyValueError::new_err("JSON nesting depth limit exceeded")); + } if value.is_none() || value.is_exact_instance_of::() || value.is_exact_instance_of::() @@ -775,13 +794,13 @@ fn payload_is_supported(obj: &Bound<'_, PyAny>) -> PyResult { if !key_renders_identically(&key) { return Ok(false); } - stack.push(child); + stack.push((child, depth + 1)); } continue; } if let Ok(list) = value.cast_exact::() { for item in list.iter() { - stack.push(item); + stack.push((item, depth + 1)); } continue; } diff --git a/rust/uv.lock b/rust/uv.lock index f0379a3..ecaa026 100644 --- a/rust/uv.lock +++ b/rust/uv.lock @@ -4,5 +4,5 @@ requires-python = ">=3.9" [[package]] name = "json2xml-rs" -version = "0.5.0" +version = "0.6.0" source = { editable = "." } diff --git a/tests/test_dicttoxml_fast_fallback.py b/tests/test_dicttoxml_fast_fallback.py index c1b2b46..b922b63 100644 --- a/tests/test_dicttoxml_fast_fallback.py +++ b/tests/test_dicttoxml_fast_fallback.py @@ -16,13 +16,21 @@ from json2xml.dicttoxml_fast import _RustBindings +def _gate_with_limits( + obj: Any, max_depth: int | None = None, max_items: int | None = None +) -> bool: + """Reference gate with the keyword signature of a limit-enforcing extension.""" + return rust_renders_identically(obj) + + def _fake_bindings(**overrides: Any) -> _RustBindings: """Build Rust bindings backed by the Python reference implementations.""" fields: dict[str, Any] = { "dicttoxml": Mock(return_value=b""), - "payload_is_supported": rust_renders_identically, + "payload_is_supported": _gate_with_limits, "escape_xml": py_dicttoxml.escape_xml, "wrap_cdata": py_dicttoxml.wrap_cdata, + "enforces_limits": True, } fields.update(overrides) return _RustBindings(**fields) @@ -95,8 +103,65 @@ def test_loader_binds_a_complete_extension(monkeypatch: pytest.MonkeyPatch) -> N payload_is_supported=rust_renders_identically, escape_xml=py_dicttoxml.escape_xml, wrap_cdata=py_dicttoxml.wrap_cdata, + enforces_limits=False, + ) + + +# @lat: [[tests#Conversion behavior#Native conversion budget]] +def test_loader_detects_builds_that_enforce_limits( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A gate that accepts limit keywords lets Json2xml skip its Python walk.""" + _fake_extension( + monkeypatch, **{**_COMPLETE_EXPORTS, "payload_is_supported": _gate_with_limits} + ) + + bindings = fast_module._load_rust_bindings() + + assert bindings is not None + assert bindings.enforces_limits is True + + +def test_budget_check_defers_to_python_without_rust( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(fast_module, "_RUST", None) + + assert fast_module.check_conversion_budget({"a": 1}, 10, 10) is False + + +def test_budget_check_defers_when_build_cannot_enforce_limits( + monkeypatch: pytest.MonkeyPatch, +) -> None: + gate = Mock(return_value=True) + monkeypatch.setattr( + fast_module, + "_RUST", + _fake_bindings(payload_is_supported=gate, enforces_limits=False), ) + assert fast_module.check_conversion_budget({"a": 1}, 10, 10) is False + gate.assert_not_called() + + +def test_budget_check_defers_for_limits_beyond_native_range( + monkeypatch: pytest.MonkeyPatch, +) -> None: + gate = Mock(return_value=True) + monkeypatch.setattr(fast_module, "_RUST", _fake_bindings(payload_is_supported=gate)) + + assert fast_module.check_conversion_budget({"a": 1}, 2**64, 10) is False + assert fast_module.check_conversion_budget({"a": 1}, 10, 2**64) is False + gate.assert_not_called() + + +def test_budget_check_runs_natively(monkeypatch: pytest.MonkeyPatch) -> None: + gate = Mock(return_value=True) + monkeypatch.setattr(fast_module, "_RUST", _fake_bindings(payload_is_supported=gate)) + + assert fast_module.check_conversion_budget({"a": 1}, 10, 20) is True + gate.assert_called_once_with({"a": 1}, max_depth=10, max_items=20) + # @lat: [[tests#Conversion behavior#Outdated Rust backends stay disabled]] @pytest.mark.parametrize( diff --git a/tests/test_json2xml.py b/tests/test_json2xml.py index 93bcbe7..3fc4147 100644 --- a/tests/test_json2xml.py +++ b/tests/test_json2xml.py @@ -268,6 +268,45 @@ def test_conversion_resource_limits( with pytest.raises(InvalidDataError): json2xml.Json2xml(data, **limits).to_xml() + # @lat: [[tests#Conversion behavior#Native conversion budget]] + def test_native_budget_check_replaces_python_walk( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A native walk that completes within budget makes the Python walk redundant.""" + python_walk = Mock() + monkeypatch.setattr(json2xml, "_validate_conversion_budget", python_walk) + monkeypatch.setattr( + json2xml.dicttoxml, "check_conversion_budget", Mock(return_value=True) + ) + + assert json2xml.Json2xml({"a": 1}).to_xml() is not None + python_walk.assert_not_called() + + def test_python_walk_runs_when_native_check_declines( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + python_walk = Mock() + monkeypatch.setattr(json2xml, "_validate_conversion_budget", python_walk) + monkeypatch.setattr( + json2xml.dicttoxml, "check_conversion_budget", Mock(return_value=False) + ) + + json2xml.Json2xml({"a": 1}, max_depth=7, max_items=9).to_xml() + + python_walk.assert_called_once_with({"a": 1}, 7, 9) + + def test_native_budget_violation_becomes_invalid_data_error( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr( + json2xml.dicttoxml, + "check_conversion_budget", + Mock(side_effect=ValueError("JSON item limit exceeded")), + ) + + with pytest.raises(InvalidDataError, match="JSON item limit exceeded"): + json2xml.Json2xml({"a": 1}).to_xml() + def test_output_limit_accepts_payload_when_generated_xml_fits(self) -> None: """Output limits use generated bytes rather than a conservative estimate.""" xml = json2xml.Json2xml( diff --git a/tests/test_rust_python_parity.py b/tests/test_rust_python_parity.py index 62535b1..da234a9 100644 --- a/tests/test_rust_python_parity.py +++ b/tests/test_rust_python_parity.py @@ -21,6 +21,8 @@ rust_renders_identically, rust_renders_root_identically, ) +from json2xml.json2xml import _validate_conversion_budget +from json2xml.utils import InvalidDataError try: from json2xml_rs import ( @@ -280,3 +282,46 @@ def test_native_and_python_gates_agree() -> None: for _ in range(400): data = _random_payload(rng) assert _rust_payload_is_supported(data) == rust_renders_identically(data), data + + +def _python_budget_verdict(data: Any, max_depth: int, max_items: int) -> str: + try: + _validate_conversion_budget(data, max_depth, max_items) + except InvalidDataError as error: + return str(error) + return "ok" + + +def _native_budget_verdict(data: Any, max_depth: int, max_items: int) -> str: + from json2xml.dicttoxml_fast import _RUST + + assert _RUST is not None + try: + supported = _RUST.payload_is_supported( + data, max_depth=max_depth, max_items=max_items + ) + except ValueError as error: + return str(error) + assert supported + return "ok" + + +# @lat: [[tests#Conversion behavior#Native conversion budget]] +@requires_rust +@pytest.mark.parametrize( + ("max_depth", "max_items"), + [(100, 100), (1, 100), (2, 100), (100, 3), (100, 4), (2, 4), (1, 2)], +) +def test_native_budget_walk_matches_python_walk(max_depth: int, max_items: int) -> None: + """Both walks visit values in the same order, so they report the same first violation.""" + payloads: list[Any] = [ + {"a": [1, 2, {"b": [3]}]}, + [[[[1]]]], + {"a": {"b": {"c": 1}}, "d": [1, 2, 3, 4, 5]}, + [], + {}, + ] + for data in payloads: + assert _native_budget_verdict(data, max_depth, max_items) == ( + _python_budget_verdict(data, max_depth, max_items) + ), (data, max_depth, max_items) From d95f024e032ff62dc6e4a5bf1c1f825b6211ee42 Mon Sep 17 00:00:00 2001 From: Vinit Kumar Date: Thu, 3 Sep 2026 01:28:09 +0530 Subject: [PATCH 07/11] Share one config type across serializer and selector ConversionRequest duplicated SerializerConfig field for field, and the Python adapter re-splatted thirteen keyword arguments to turn one into the other. The selector now passes SerializerConfig straight to a new serialize() entry point. The public wrapper resolves the default item function when it builds the config, so the Rust adapter declines only custom item functions. has_special_keys had no callers outside its test; the key gate already rejects every special key. Closes #413 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CC6FMMGY3EKteEdBJBYkbp --- json2xml/backend_selector.py | 43 +++---------------------- json2xml/dicttoxml.py | 9 ++++-- json2xml/dicttoxml_fast.py | 45 +++++++-------------------- lat.md/architecture.md | 2 +- lat.md/tests.md | 4 --- tests/test_backend_selector.py | 22 ++++--------- tests/test_dicttoxml_fast_fallback.py | 20 ++++++++++++ tests/test_rust_dicttoxml.py | 11 ++++--- 8 files changed, 55 insertions(+), 101 deletions(-) diff --git a/json2xml/backend_selector.py b/json2xml/backend_selector.py index 0a0f835..2c452bc 100644 --- a/json2xml/backend_selector.py +++ b/json2xml/backend_selector.py @@ -1,30 +1,10 @@ from __future__ import annotations -from dataclasses import dataclass from typing import Any, Protocol # Shared with the Python serializer deliberately: the root-name gate below is only # correct while it uses the exact predicate Python's name resolver starts from. -from .dicttoxml import _is_fast_valid_xml_name - - -@dataclass(frozen=True, slots=True) -class ConversionRequest: - """Normalized conversion request shared across backend adapters.""" - - obj: Any - root: bool - custom_root: str - ids: list[int] | None - attr_type: bool - item_wrap: bool - item_func: Any - cdata: bool - xml_namespaces: dict[str, Any] | None - list_headers: bool - xpath_format: bool - max_output_bytes: int | None = None - indent: str | None = None +from .dicttoxml import SerializerConfig, _is_fast_valid_xml_name class BackendAdapter(Protocol): @@ -34,10 +14,10 @@ class BackendAdapter(Protocol): def name(self) -> str: raise NotImplementedError # pragma: no cover - def can_handle(self, request: ConversionRequest) -> bool: + def can_handle(self, request: SerializerConfig) -> bool: raise NotImplementedError # pragma: no cover - def render(self, request: ConversionRequest) -> bytes: + def render(self, request: SerializerConfig) -> bytes: raise NotImplementedError # pragma: no cover @@ -47,7 +27,7 @@ class BackendSelector: def __init__(self, *backends: BackendAdapter) -> None: self._backends = backends - def render(self, request: ConversionRequest) -> bytes: + def render(self, request: SerializerConfig) -> bytes: for backend in self._backends: if backend.can_handle(request): return backend.render(request) @@ -110,18 +90,3 @@ def rust_renders_identically(obj: Any) -> bool: continue return False return True - - -def has_special_keys(obj: Any) -> bool: - """Return True when the payload uses Python-only special key semantics.""" - if isinstance(obj, dict): - return any( - (isinstance(key, str) and (key.startswith("@") or key.endswith("@flat"))) - or has_special_keys(value) - for key, value in obj.items() - ) - - if isinstance(obj, list): - return any(has_special_keys(item) for item in obj) - - return False diff --git a/json2xml/dicttoxml.py b/json2xml/dicttoxml.py index 803ed44..7f1c15d 100644 --- a/json2xml/dicttoxml.py +++ b/json2xml/dicttoxml.py @@ -1168,9 +1168,9 @@ def convert_none_valid_name(key: str, attr_type: bool, attr: dict[str, Any]) -> @dataclass(frozen=True, slots=True) class SerializerConfig: - """Normalized options for the pure Python serializer engine.""" + """Normalized conversion options shared by the backend selector and this engine.""" - obj: ELEMENT + obj: Any root: bool custom_root: str ids: list[int] | None @@ -1484,4 +1484,9 @@ def dicttoxml( max_output_bytes=max_output_bytes, indent=indent, ) + return serialize(config) + + +def serialize(config: SerializerConfig) -> bytes: + """Render an already normalized configuration; see :func:`dicttoxml`.""" return _SerializerEngine(config).render() diff --git a/json2xml/dicttoxml_fast.py b/json2xml/dicttoxml_fast.py index 77a4d4a..05bb893 100644 --- a/json2xml/dicttoxml_fast.py +++ b/json2xml/dicttoxml_fast.py @@ -22,11 +22,8 @@ import json2xml.dicttoxml as _py_dicttoxml -from .backend_selector import ( - BackendSelector, - ConversionRequest, - rust_renders_root_identically, -) +from .backend_selector import BackendSelector, rust_renders_root_identically +from .dicttoxml import SerializerConfig RustStringTransform = Callable[[str], str] @@ -138,14 +135,14 @@ class _RustBackendAdapter: name: str = "rust" - def can_handle(self, request: ConversionRequest) -> bool: + def can_handle(self, request: SerializerConfig) -> bool: rust = _RUST if rust is None: return False return not ( request.ids is not None - or request.item_func is not None + or request.item_func is not _py_dicttoxml.default_item_func or request.xml_namespaces or request.xpath_format or request.indent is not None @@ -156,7 +153,7 @@ def can_handle(self, request: ConversionRequest) -> bool: or not rust.payload_is_supported(request.obj) ) - def render(self, request: ConversionRequest) -> bytes: + def render(self, request: SerializerConfig) -> bytes: assert _RUST is not None output = _RUST.dicttoxml( request.obj, @@ -179,36 +176,16 @@ def render(self, request: ConversionRequest) -> bytes: class _PythonBackendAdapter: """Adapter for the compatibility-preserving Python backend.""" - python_dicttoxml: Callable[..., bytes] - default_item_func: Callable[[str], str] - name: str = "python" - def can_handle(self, request: ConversionRequest) -> bool: + def can_handle(self, request: SerializerConfig) -> bool: return True - def render(self, request: ConversionRequest) -> bytes: - return self.python_dicttoxml( - request.obj, - root=request.root, - custom_root=request.custom_root, - ids=request.ids, - attr_type=request.attr_type, - item_wrap=request.item_wrap, - item_func=request.item_func or self.default_item_func, - cdata=request.cdata, - xml_namespaces=request.xml_namespaces, - list_headers=request.list_headers, - xpath_format=request.xpath_format, - max_output_bytes=request.max_output_bytes, - indent=request.indent, - ) + def render(self, request: SerializerConfig) -> bytes: + return _py_dicttoxml.serialize(request) -_BACKEND_SELECTOR = BackendSelector( - _RustBackendAdapter(), - _PythonBackendAdapter(_py_dicttoxml.dicttoxml, _py_dicttoxml.default_item_func), -) +_BACKEND_SELECTOR = BackendSelector(_RustBackendAdapter(), _PythonBackendAdapter()) # @lat: [[architecture#Backend selection]] @@ -251,14 +228,14 @@ def dicttoxml( Returns: UTF-8 encoded XML as bytes """ - request = ConversionRequest( + request = SerializerConfig( obj=obj, root=root, custom_root=custom_root, ids=ids, attr_type=attr_type, item_wrap=item_wrap, - item_func=item_func, + item_func=_py_dicttoxml.default_item_func if item_func is None else item_func, cdata=cdata, xml_namespaces=xml_namespaces, list_headers=list_headers, diff --git a/lat.md/architecture.md b/lat.md/architecture.md index a405243..43cd4ea 100644 --- a/lat.md/architecture.md +++ b/lat.md/architecture.md @@ -24,7 +24,7 @@ Text, CDATA, custom attributes, and namespace declarations share XML 1.0 charact The fast-path module prefers the Rust extension when it can preserve Python semantics, and falls back to the Python serializer for unsupported features. -[[json2xml/dicttoxml_fast.py#dicttoxml]] now normalizes each call into a shared conversion request and asks a tiny backend selector seam to choose Rust or Python. The Rust adapter accepts only requests whose semantics it can preserve, namely no `ids`, custom `item_func`, XML namespaces, XPath mode, root scalar payloads, or special `@` keys. At import time, the wrapper also verifies that an installed Rust backend rejects XML 1.0 forbidden characters; outdated or broken accelerators stay disabled so the Python security boundary cannot be bypassed. +[[json2xml/dicttoxml_fast.py#dicttoxml]] normalizes each call into the serializer's own `SerializerConfig` and asks a tiny backend selector seam to choose Rust or Python; the Python adapter hands that record to [[json2xml/dicttoxml.py#serialize]] unchanged. The Rust adapter accepts only requests whose semantics it can preserve, namely no `ids`, custom `item_func`, XML namespaces, XPath mode, root scalar payloads, or special `@` keys. At import time, the wrapper also verifies that an installed Rust backend rejects XML 1.0 forbidden characters; outdated or broken accelerators stay disabled so the Python security boundary cannot be bypassed. The backend adapter protocol exposes its diagnostic name as a read-only property, matching the frozen adapter implementations while still allowing selector code to inspect backend metadata. diff --git a/lat.md/tests.md b/lat.md/tests.md index 13275bc..4f3b71a 100644 --- a/lat.md/tests.md +++ b/lat.md/tests.md @@ -214,10 +214,6 @@ Helper exports for XML escaping and CDATA wrapping should preserve Python behavi The Rust escape and CDATA helpers take only `str`, so the public helpers must coerce numbers the same way the Python helpers do; installing the extension must not narrow the API. -### Backend selector detects Python-only payload markers - -The backend selector should recognize nested `@attrs`, `@val`, and `@flat` markers so Rust is skipped before semantics drift. - ### Backend selector fails loudly with no compatible backend If every backend rejects a conversion request, the selector should raise a clear error instead of silently returning bad output. diff --git a/tests/test_backend_selector.py b/tests/test_backend_selector.py index 1f34d38..46e1ac2 100644 --- a/tests/test_backend_selector.py +++ b/tests/test_backend_selector.py @@ -2,40 +2,30 @@ import pytest -from json2xml.backend_selector import ( - BackendSelector, - ConversionRequest, - has_special_keys, -) +from json2xml.backend_selector import BackendSelector +from json2xml.dicttoxml import SerializerConfig, default_item_func class _NeverBackend: name = "never" - def can_handle(self, request: ConversionRequest) -> bool: + def can_handle(self, request: SerializerConfig) -> bool: return False - def render(self, request: ConversionRequest) -> bytes: + def render(self, request: SerializerConfig) -> bytes: raise AssertionError("render should not be called") -# @lat: [[tests#Conversion behavior#Backend selector detects Python-only payload markers]] -def test_has_special_keys_detects_nested_python_only_markers() -> None: - assert has_special_keys({"items": [{"record": {"@attrs": {"id": "7"}}}]}) is True - assert has_special_keys({"items": [{"record@flat": [1, 2, 3]}]}) is True - assert has_special_keys({"items": [{"record": {"name": "Ada"}}]}) is False - - # @lat: [[tests#Conversion behavior#Backend selector fails loudly with no compatible backend]] def test_backend_selector_raises_when_no_backend_can_handle_request() -> None: - request = ConversionRequest( + request = SerializerConfig( obj={"name": "Ada"}, root=True, custom_root="root", ids=None, attr_type=True, item_wrap=True, - item_func=None, + item_func=default_item_func, cdata=False, xml_namespaces=None, list_headers=False, diff --git a/tests/test_dicttoxml_fast_fallback.py b/tests/test_dicttoxml_fast_fallback.py index b922b63..0584a1b 100644 --- a/tests/test_dicttoxml_fast_fallback.py +++ b/tests/test_dicttoxml_fast_fallback.py @@ -253,6 +253,26 @@ def test_fast_wrapper_falls_back_to_python_for_unsupported_options( rust_backend.assert_not_called() +class _FalsyNamer: + """A callable whose truthiness is False, as a callable object legitimately can be.""" + + def __bool__(self) -> bool: + return False + + def __call__(self, parent: str) -> str: + return "entry" + + +def test_falsy_item_func_is_still_used(monkeypatch: pytest.MonkeyPatch) -> None: + """Only None selects the default item function; truthiness is not a proxy for it.""" + rust_backend = _force_rust_backend(monkeypatch) + + result = fast_module.dicttoxml({"items": [1]}, root=False, item_func=_FalsyNamer()) + + assert b" ConversionRequest: - """Build a ConversionRequest with the defaults the public wrapper would use.""" - return ConversionRequest( +) -> SerializerConfig: + """Build a SerializerConfig with the defaults the public wrapper would use.""" + return SerializerConfig( obj=obj, root=root, custom_root=custom_root, ids=None, attr_type=attr_type, item_wrap=item_wrap, - item_func=None, + item_func=default_item_func, cdata=cdata, xml_namespaces=None, list_headers=list_headers, From 179c94656f8374c20595dfcbf1ab1f999018fb26 Mon Sep 17 00:00:00 2001 From: Vinit Kumar Date: Thu, 3 Sep 2026 01:29:16 +0530 Subject: [PATCH 08/11] Declare CLI toggles with BooleanOptionalAction Each boolean conversion option was declared twice, as a store_true flag whose value already matched the default and a separate --no-* flag. BooleanOptionalAction declares both forms at once with the same last-one-wins semantics. write_output now reports file errors through exit_with_error like every other CLI failure. Closes #415 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CC6FMMGY3EKteEdBJBYkbp --- json2xml/cli.py | 53 ++++++++++++++--------------------------------- lat.md/tests.md | 4 ++++ tests/test_cli.py | 23 ++++++++++++++++++++ 3 files changed, 42 insertions(+), 38 deletions(-) diff --git a/json2xml/cli.py b/json2xml/cli.py index e635ad0..9647138 100644 --- a/json2xml/cli.py +++ b/json2xml/cli.py @@ -7,10 +7,11 @@ Flags: -w, --wrapper string Wrapper element name (default "all") - -r, --root Include root element (default true) - -p, --pretty Pretty print output (default false) - -t, --type Include type attributes (default true) - -i, --item-wrap Wrap list items in elements (default true) + -r, --root Include root element (default true, --no-root disables) + -p, --pretty Pretty print output (default false, --no-pretty disables) + -t, --type Include type attributes (default true, --no-type disables) + -i, --item-wrap Wrap list items in elements (default true, + --no-item-wrap disables) -x, --xpath Use XPath 3.1 json-to-xml format -o, --output string Output file (default: stdout) -u, --url string Read JSON from URL @@ -198,8 +199,7 @@ def write_output(self, output: str | bytes, output_file: str | None) -> None: try: Path(output_file).write_text(text, encoding="utf-8") except OSError as error: - print(f"Error writing to file: {error}", file=sys.stderr) - sys.exit(1) + exit_with_error(f"Error writing to file: {error}") @staticmethod def _write_stdout(text: str) -> None: @@ -295,61 +295,38 @@ def create_parser() -> argparse.ArgumentParser: default="all", help='Wrapper element name (default: "all")', ) + # Each toggle also accepts its --no-* form; the last occurrence wins. conv_group.add_argument( "-r", "--root", dest="root", - action="store_true", + action=argparse.BooleanOptionalAction, default=True, - help="Include root element (default: true)", - ) - conv_group.add_argument( - "--no-root", - dest="root", - action="store_false", - help="Exclude root element", + help="Include root element", ) conv_group.add_argument( "-p", "--pretty", dest="pretty", - action="store_true", + action=argparse.BooleanOptionalAction, default=False, - help="Pretty print output (default: false)", - ) - conv_group.add_argument( - "--no-pretty", - dest="pretty", - action="store_false", - help="Disable pretty printing", + help="Pretty print output", ) conv_group.add_argument( "-t", "--type", dest="attr_type", - action="store_true", + action=argparse.BooleanOptionalAction, default=True, - help="Include type attributes (default: true)", - ) - conv_group.add_argument( - "--no-type", - dest="attr_type", - action="store_false", - help="Exclude type attributes", + help="Include type attributes", ) conv_group.add_argument( "-i", "--item-wrap", dest="item_wrap", - action="store_true", + action=argparse.BooleanOptionalAction, default=True, - help="Wrap list items in elements (default: true)", - ) - conv_group.add_argument( - "--no-item-wrap", - dest="item_wrap", - action="store_false", - help="Don't wrap list items", + help="Wrap list items in elements", ) conv_group.add_argument( "-x", diff --git a/lat.md/tests.md b/lat.md/tests.md index 4f3b71a..9ca13be 100644 --- a/lat.md/tests.md +++ b/lat.md/tests.md @@ -26,6 +26,10 @@ An explicitly empty `--url` value should fail URL validation instead of silently An explicitly empty positional path should fail with an empty-path error instead of silently selecting piped stdin, preserving the caller's chosen source. +### Boolean flags toggle in both directions + +Every boolean conversion flag can enable, disable, and re-enable its option, and the last occurrence wins, so scripts can override an earlier flag instead of passing a no-op. + ### Dash argument reads stdin When the positional input is `-`, the CLI should read stdin instead of trying to open a file literally named `-`. diff --git a/tests/test_cli.py b/tests/test_cli.py index c64a652..fcbd06f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -512,6 +512,29 @@ def test_create_parser(self) -> None: assert parser.prog == "json2xml-py" assert parser.parse_args(["-s", "{}"]).pretty is False + # @lat: [[tests#CLI input resolution#Boolean flags toggle in both directions]] + @pytest.mark.parametrize( + ("dest", "short", "long"), + [ + ("root", "-r", "--root"), + ("pretty", "-p", "--pretty"), + ("attr_type", "-t", "--type"), + ("item_wrap", "-i", "--item-wrap"), + ], + ) + def test_boolean_flags_toggle_in_both_directions( + self, dest: str, short: str, long: str + ) -> None: + """Each flag can enable, disable, and re-enable its option; the last one wins.""" + parser = create_parser() + negated = long.replace("--", "--no-", 1) + + assert getattr(parser.parse_args([short]), dest) is True + assert getattr(parser.parse_args([long]), dest) is True + assert getattr(parser.parse_args([negated]), dest) is False + assert getattr(parser.parse_args([negated, short]), dest) is True + assert getattr(parser.parse_args([long, negated]), dest) is False + def test_create_parser_parses_all_args(self) -> None: """Test parser handles all argument combinations.""" parser = create_parser() From 228791c2a7855668da31384c40ce67704ae6ec0f Mon Sep 17 00:00:00 2001 From: Vinit Kumar Date: Thu, 3 Sep 2026 01:30:29 +0530 Subject: [PATCH 09/11] Derive type names from the exact-type table get_xml_type repeated the type ladder that _EXACT_KINDS already encodes, so a supported type had to be added in two places. Native types now resolve through one dict lookup and only subclasses take the isinstance fallback. The hasattr guards in front of the datetime isinstance checks were redundant, and ids is annotated for what it is: a value tested only for truthiness. Closes #416 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CC6FMMGY3EKteEdBJBYkbp --- json2xml/dicttoxml.py | 95 +++++++++++++++++++++--------------- json2xml/dicttoxml_fast.py | 6 +-- tests/test_dicttoxml_unit.py | 38 +++++++++++++++ 3 files changed, 97 insertions(+), 42 deletions(-) diff --git a/json2xml/dicttoxml.py b/json2xml/dicttoxml.py index 7f1c15d..d8ce12a 100644 --- a/json2xml/dicttoxml.py +++ b/json2xml/dicttoxml.py @@ -9,7 +9,7 @@ from functools import lru_cache from io import BytesIO from random import SystemRandom -from typing import Any, Union, cast +from typing import Any, TypeAlias, Union, cast __lazy_modules__ = ["defusedxml.minidom"] @@ -141,6 +141,10 @@ def get_unique_id(element: str) -> str: return make_id(element) +# Only the truthiness of ``ids`` is ever consulted, so any value is a valid argument: +# the historical list form, a bool, or an int all enable generated id attributes. +IdsOption: TypeAlias = object + ELEMENT = Union[ str, int, @@ -214,6 +218,19 @@ def _classify(value: Any) -> int: return _KIND_UNSUPPORTED +# Type attribute values for native JSON types, keyed by exact type like _EXACT_KINDS. +_EXACT_TYPE_NAMES: dict[type, str] = { + type(None): "null", + str: "str", + int: "int", + float: "float", + bool: "bool", + dict: "dict", + list: "list", + tuple: "list", +} + + def get_xml_type(val: Any) -> str: """ Get the XML type of a given value. @@ -224,21 +241,10 @@ def get_xml_type(val: Any) -> str: Returns: str: The XML type. """ - if val is None: - return "null" - val_type = type(val) - if val_type is str: - return "str" - if val_type is int: - return "int" - if val_type is float: - return "float" - if val_type is bool: - return "bool" - if val_type is dict: - return "dict" - if val_type is list or val_type is tuple: - return "list" + name = _EXACT_TYPE_NAMES.get(type(val)) + if name is not None: + return name + # Subclasses and other Number implementations take the historical fallback names. if isinstance(val, str): return "str" if _is_number(val): @@ -514,7 +520,7 @@ def _append_xpath31( def convert( obj: Any, - ids: Any, + ids: IdsOption, attr_type: bool, item_func: Callable[[str], str], cdata: bool, @@ -616,7 +622,7 @@ def list2xml_str( def convert_dict( obj: dict[str, Any], - ids: list[str], + ids: IdsOption, parent: str, attr_type: bool, item_func: Callable[[str], str], @@ -647,7 +653,7 @@ def convert_dict( def convert_list( items: Sequence[Any], - ids: list[str] | None, + ids: IdsOption, parent: str, attr_type: bool, item_func: Callable[[str], str], @@ -679,7 +685,7 @@ def convert_list( def _append_convert( output: _XMLWriter, obj: Any, - ids: Any, + ids: IdsOption, attr_type: bool, item_func: Callable[[str], str], cdata: bool, @@ -755,7 +761,7 @@ def _append_dict2xml_str( list_headers: bool = False, ) -> None: """Append a dict element using the same shape as dict2xml_str.""" - ids: list[str] = [] + ids: IdsOption = None attr = dict(attr) if attr_type: @@ -821,7 +827,7 @@ def _append_dict2xml_str( def _append_rawitem( output: _XMLWriter, rawitem: Any, - ids: list[str], + ids: IdsOption, attr_type: bool, item_func: Callable[[str], str], cdata: bool, @@ -863,7 +869,7 @@ def _append_list2xml_str( item_wrap: bool, list_headers: bool = False, ) -> None: - ids: list[str] = [] + ids: IdsOption = None attr = dict(attr) if attr_type: attr["type"] = get_xml_type(item) @@ -908,7 +914,7 @@ def _append_list2xml_str( def _append_convert_dict( output: _XMLWriter, obj: dict[str, Any], - ids: list[str], + ids: IdsOption, parent: str, attr_type: bool, item_func: Callable[[str], str], @@ -977,7 +983,7 @@ def _append_convert_dict( def _append_convert_list( output: _XMLWriter, items: Sequence[Any], - ids: list[str] | None, + ids: IdsOption, parent: str, attr_type: bool, item_func: Callable[[str], str], @@ -1067,9 +1073,18 @@ def _append_convert_list( raise TypeError(f"Unsupported data type: {item} ({type(item).__name__})") +_DATE_LIKE_TYPES = (datetime.datetime, datetime.date, datetime.time) + + def convert_kv( key: str, - val: str | int | float | numbers.Number | datetime.datetime | datetime.date, + val: str + | int + | float + | numbers.Number + | datetime.datetime + | datetime.date + | datetime.time, attr_type: bool, attr: dict[str, Any] | None = None, cdata: bool = False, @@ -1078,10 +1093,8 @@ def convert_kv( attr = dict(attr) if attr else {} key, attr = make_valid_xml_name(key, attr) - # Convert datetime to isoformat string - if hasattr(val, "isoformat") and isinstance( - val, (datetime.datetime, datetime.date) - ): + # Date-like values serialize as ISO text, matching the main serializer path. + if isinstance(val, _DATE_LIKE_TYPES): val = val.isoformat() if attr_type: @@ -1094,15 +1107,19 @@ def convert_kv( def convert_kv_valid_name( key: str, - val: str | int | float | numbers.Number | datetime.datetime | datetime.date, + val: str + | int + | float + | numbers.Number + | datetime.datetime + | datetime.date + | datetime.time, attr_type: bool, attr: dict[str, Any], cdata: bool = False, ) -> str: """Converts a scalar into an XML element when the caller already validated the key.""" - if hasattr(val, "isoformat") and isinstance( - val, (datetime.datetime, datetime.date) - ): + if isinstance(val, _DATE_LIKE_TYPES): val = val.isoformat() attr_string = ( @@ -1173,7 +1190,7 @@ class SerializerConfig: obj: Any root: bool custom_root: str - ids: list[int] | None + ids: IdsOption attr_type: bool item_wrap: bool item_func: Callable[[str], str] @@ -1312,7 +1329,7 @@ def dicttoxml( obj: ELEMENT, root: bool = True, custom_root: str = "root", - ids: list[int] | None = None, + ids: IdsOption = None, attr_type: bool = True, item_wrap: bool = True, item_func: Callable[[str], str] = default_item_func, @@ -1337,9 +1354,9 @@ def dicttoxml( Default is 'root' allows you to specify a custom root element. - :param bool ids: - Default is False - specifies whether elements get unique ids. + :param ids: + Default is None + any truthy value gives elements unique ids. :param bool attr_type: Default is True diff --git a/json2xml/dicttoxml_fast.py b/json2xml/dicttoxml_fast.py index 05bb893..1271158 100644 --- a/json2xml/dicttoxml_fast.py +++ b/json2xml/dicttoxml_fast.py @@ -23,7 +23,7 @@ import json2xml.dicttoxml as _py_dicttoxml from .backend_selector import BackendSelector, rust_renders_root_identically -from .dicttoxml import SerializerConfig +from .dicttoxml import IdsOption, SerializerConfig RustStringTransform = Callable[[str], str] @@ -193,7 +193,7 @@ def dicttoxml( obj: Any, root: bool = True, custom_root: str = "root", - ids: list[int] | None = None, + ids: IdsOption = None, attr_type: bool = True, item_wrap: bool = True, item_func: Callable[[str], str] | None = None, @@ -214,7 +214,7 @@ def dicttoxml( obj: The Python object to convert (dict or list) root: Include XML declaration and root element (default: True) custom_root: Name of the root element (default: "root") - ids: Generate unique IDs for elements (not supported in Rust) + ids: Any truthy value generates unique element IDs (not supported in Rust) attr_type: Include type attributes on elements (default: True) item_wrap: Wrap list items in tags (default: True) item_func: Custom function for item names (not supported in Rust) diff --git a/tests/test_dicttoxml_unit.py b/tests/test_dicttoxml_unit.py index babab48..3b4ec3b 100644 --- a/tests/test_dicttoxml_unit.py +++ b/tests/test_dicttoxml_unit.py @@ -96,6 +96,44 @@ def test_get_xml_type_preserves_container_subclasses(value: Any, expected: str) assert dicttoxml.get_xml_type(value) == expected +@pytest.mark.parametrize( + ("value", "expected"), + [(None, "null"), ((1, 2), "list"), ([], "list"), (1.5, "float"), (False, "bool")], +) +def test_get_xml_type_names_native_types_by_exact_type( + value: Any, expected: str +) -> None: + assert dicttoxml.get_xml_type(value) == expected + + +@pytest.mark.parametrize( + "value", + [ + datetime.datetime(2026, 1, 1, 10, 30, 15, 123456), + datetime.date(2026, 1, 1), + datetime.time(10, 30, 15, 123456), + ], +) +def test_direct_scalar_helpers_match_serializer_for_date_like_values( + value: Any, +) -> None: + """convert_kv and its valid-name variant classify date-like values as the engine does.""" + expected = dicttoxml.dicttoxml({"k": value}, root=False).decode() + + assert dicttoxml.convert_kv("k", value, attr_type=True) == expected + assert dicttoxml.convert_kv_valid_name("k", value, attr_type=True, attr={}) == ( + expected + ) + + +@pytest.mark.parametrize("ids", [True, ["seed"], 1]) +def test_ids_accepts_any_truthy_value(ids: Any) -> None: + """Only the truthiness of ``ids`` matters; the historical list form still works.""" + result = dicttoxml.dicttoxml({"a": 1, "b": 2}, root=False, ids=ids) + + assert result.count(b' id="') == 2 + + # @lat: [[tests#XML helper behavior#Exact-type dispatch preserves subclass fallbacks]] def test_exact_type_dispatch_preserves_subclass_fallbacks() -> None: data = DictSubclass({"values": ListSubclass([IntSubclass(7)])}) From 6e51be6d33cce53559e12d8c12dad9208b876d0d Mon Sep 17 00:00:00 2001 From: Vinit Kumar Date: Thu, 3 Sep 2026 01:30:35 +0530 Subject: [PATCH 10/11] Separate unreadable files from invalid JSON in utils readfromjson reported "Invalid JSON File" for a missing or unreadable file, which points users at the wrong problem. OSError now yields a read failure message and only parse errors keep the invalid JSON message. The effective port computation that two URL helpers repeated inline lives in one helper. Closes #417 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CC6FMMGY3EKteEdBJBYkbp --- json2xml/utils.py | 14 +++++++++++--- lat.md/tests.md | 4 ++++ tests/test_utils.py | 7 ++++--- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/json2xml/utils.py b/json2xml/utils.py index 34da5d0..ed91ab1 100644 --- a/json2xml/utils.py +++ b/json2xml/utils.py @@ -14,6 +14,7 @@ from .types import JSONValue DEFAULT_URL_TIMEOUT: Any | None = None +_DEFAULT_PORTS = {"http": 80, "https": 443} DEFAULT_MAX_RESPONSE_BYTES = 10 * 1024 * 1024 COMPRESSED_READ_CHUNK_BYTES = 64 * 1024 _HTTP: Any | None = None @@ -55,13 +56,20 @@ class StringReadError(Exception): pass +def _effective_port(parsed: SplitResult) -> int: + """Return the explicit port, or the default for a validated scheme.""" + return parsed.port or _DEFAULT_PORTS[parsed.scheme] + + # @lat: [[behavior#Input readers]] def readfromjson(filename: str) -> JSONValue: """Read JSON data from a file.""" try: with open(filename, encoding="utf-8") as jsondata: return json.load(jsondata) - except (ValueError, OSError) as error: + except OSError as error: + raise JSONReadError("Could not read JSON file") from error + except ValueError as error: raise JSONReadError("Invalid JSON File") from error @@ -92,7 +100,7 @@ def _resolve_validated_address( assert parsed.hostname is not None hostname = parsed.hostname - port = parsed.port or (443 if parsed.scheme == "https" else 80) + port = _effective_port(parsed) try: addresses = [ip_address(hostname)] except ValueError: @@ -127,7 +135,7 @@ def _request_via_validated_address( except UnicodeError as error: raise URLReadError("URL hostname could not be resolved") from error - port = parsed.port or (443 if parsed.scheme == "https" else 80) + port = _effective_port(parsed) authority = f"[{hostname}]" if ":" in hostname else hostname if parsed.port is not None: authority = f"{authority}:{parsed.port}" diff --git a/lat.md/tests.md b/lat.md/tests.md index 9ca13be..f40032d 100644 --- a/lat.md/tests.md +++ b/lat.md/tests.md @@ -38,6 +38,10 @@ When the positional input is `-`, the CLI should read stdin instead of trying to These tests verify the concrete reader helpers against realistic source behavior so parsing and error wrapping stay aligned with production use. +### File reader distinguishes unreadable files from invalid JSON + +A file that cannot be opened reports a read failure, while a file whose content does not parse reports invalid JSON, so callers can tell the two apart from the message. + ### URL reader uses real HTTP and wraps failures URL input should read valid JSON over HTTP and wrap status, network, and decoding failures in `URLReadError`. diff --git a/tests/test_utils.py b/tests/test_utils.py index 71020e7..b781cfc 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -128,9 +128,10 @@ def test_readfromjson_invalid_json_content(self) -> None: os.unlink(temp_filename) + # @lat: [[tests#Input readers#File reader distinguishes unreadable files from invalid JSON]] def test_readfromjson_file_not_found(self) -> None: """Test reading a non-existent file.""" - with pytest.raises(JSONReadError, match="Invalid JSON File"): + with pytest.raises(JSONReadError, match="Could not read JSON file"): readfromjson("non_existent_file.json") @patch("builtins.open") @@ -139,7 +140,7 @@ def test_readfromjson_permission_error(self, mock_open: Mock) -> None: # Mock open to raise PermissionError mock_open.side_effect = PermissionError("Permission denied") - with pytest.raises(JSONReadError, match="Invalid JSON File"): + with pytest.raises(JSONReadError, match="Could not read JSON file"): readfromjson("some_file.json") @patch("builtins.open") @@ -148,7 +149,7 @@ def test_readfromjson_os_error(self, mock_open: Mock) -> None: # Mock open to raise OSError (covers line 34-35 in utils.py) mock_open.side_effect = OSError("Device not ready") - with pytest.raises(JSONReadError, match="Invalid JSON File"): + with pytest.raises(JSONReadError, match="Could not read JSON file"): readfromjson("some_file.json") From abc3a84b2eb135841bac184c00a218170b8b1582 Mon Sep 17 00:00:00 2001 From: Vinit Kumar Date: Thu, 3 Sep 2026 01:33:00 +0530 Subject: [PATCH 11/11] Remove Rust escape aliases and guessing fallbacks push_escaped_attr and write_escaped_attr were byte-identical aliases of the text variants, and escape_xml called the attr one. A stray PyResult import duplicated the prelude. The generic-iterable and str() branches in write_value were unreachable through the selector and let a direct caller obtain output the Python serializer would never produce; such values now raise TypeError with the serializer's message. invalid_xml_char is gated with its only caller so the fuzz crate builds without a dead-code warning. Closes #418 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CC6FMMGY3EKteEdBJBYkbp --- lat.md/tests.md | 4 ++ rust/src/lib.rs | 74 +++++++++++------------------------- tests/test_rust_dicttoxml.py | 14 +++++++ 3 files changed, 41 insertions(+), 51 deletions(-) diff --git a/lat.md/tests.md b/lat.md/tests.md index f40032d..96cd1ab 100644 --- a/lat.md/tests.md +++ b/lat.md/tests.md @@ -286,6 +286,10 @@ Every divergence found by differential testing is pinned across the full option Python applies its list-shape rules to tuples but the native writer only recognizes lists, so the gate must keep every payload containing a tuple on the Python serializer. +#### Native writer rejects unsupported types + +A direct call into the extension with a value outside the gate's subset raises `TypeError` instead of guessing an output shape the Python serializer would not produce. + #### Native and Python gates agree The native gate is an optimization over the Python reference walk, so the two must return the same verdict for every payload; disagreeing in either direction is a correctness bug. diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 13a8cd7..7345892 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -4,7 +4,7 @@ //! preserve. Unsupported features remain on the compatibility-focused Python serializer. #[cfg(feature = "python")] -use pyo3::exceptions::PyValueError; +use pyo3::exceptions::{PyTypeError, PyValueError}; #[cfg(feature = "python")] use pyo3::prelude::*; #[cfg(feature = "python")] @@ -21,6 +21,7 @@ const OUTPUT_BUFFER_SIZE: usize = 16 * 1024; // monotonic iterators keep dense inputs linear instead of repeatedly scanning the same bytes. const SPARSE_ESCAPE_SCAN_LIMIT: u8 = 4; +#[cfg(feature = "python")] #[inline] fn invalid_xml_char(s: &str) -> Option { s.chars().find(|character| { @@ -91,14 +92,15 @@ fn escape_replacement(byte: u8) -> &'static str { #[inline] pub fn escape_xml(s: &str) -> String { let mut out = String::with_capacity(s.len() + s.len() / 10); - push_escaped_attr(&mut out, s); + push_escaped_text(&mut out, s); out } -/// Append text content with the five-character escaping used by the Python implementation. +/// Append text with the five-character escaping used by the Python implementation. /// -/// This low-level helper assumes `s` already satisfies the XML 1.0 Char production. It scans -/// bytes for speed and copies clean UTF-8 slices in bulk. +/// Python escapes attribute values and character data identically, so this one routine +/// serves both. It assumes `s` already satisfies the XML 1.0 Char production, scans bytes +/// for speed, and copies clean UTF-8 slices in bulk. #[inline] pub fn push_escaped_text(out: &mut String, s: &str) { let bytes = s.as_bytes(); @@ -124,12 +126,6 @@ pub fn push_escaped_text(out: &mut String, s: &str) { out.push_str(&s[last..]); } -/// Append attribute value with full XML escaping (also escapes quotes). -#[inline] -pub fn push_escaped_attr(out: &mut String, s: &str) { - push_escaped_text(out, s); -} - #[cfg(feature = "python")] #[inline] fn write_str(out: &mut W, s: &str) -> PyResult<()> { @@ -170,12 +166,6 @@ fn write_escaped_text(out: &mut W, s: &str) -> PyResult<()> { write_str(out, &s[last..]) } -#[cfg(feature = "python")] -#[inline] -fn write_escaped_attr(out: &mut W, s: &str) -> PyResult<()> { - write_escaped_text(out, s) -} - #[cfg(feature = "python")] #[inline] fn write_cdata(out: &mut W, s: &str) -> PyResult<()> { @@ -280,7 +270,7 @@ fn push_attrs(out: &mut String, attrs: &[(String, String)]) { out.push(' '); out.push_str(k); out.push_str("=\""); - push_escaped_attr(out, v); + push_escaped_text(out, v); out.push('"'); } } @@ -298,7 +288,7 @@ fn write_open_tag( write_str(out, tag)?; if let Some(name) = name_attr { write_str(out, " name=\"")?; - write_escaped_attr(out, name)?; + write_escaped_text(out, name)?; write_byte(out, b'"')?; } if let Some(ty) = type_attr { @@ -369,9 +359,6 @@ struct ConvertConfig { list_headers: bool, } -#[cfg(feature = "python")] -use pyo3::PyResult; - /// Return `Some(type_name)` when `attr_type` is enabled. #[cfg(feature = "python")] #[inline] @@ -381,6 +368,10 @@ fn type_attr<'a>(cfg: &ConvertConfig, ty: &'a str) -> Option<&'a str> { /// Single unified type-dispatch writer. Every Python value goes through here /// exactly once, writing directly into the shared output buffer. +/// +/// Only the types the payload gate admits are handled; anything else is a `TypeError` +/// rather than a guess, so a direct caller cannot get output the Python serializer would +/// not produce. #[cfg(feature = "python")] fn write_value( py: Python<'_>, @@ -461,27 +452,11 @@ fn write_value( return Ok(()); } - // Other iterables (tuples, generators, etc.) - if let Ok(iter) = obj.try_iter() { - let items: Vec> = iter.collect::>()?; - let list = PyList::new(py, &items)?; - if wrap_container { - write_open_tag(out, tag, name_attr, type_attr(cfg, "list"))?; - } - write_convert_list(py, out, &list, tag, cfg)?; - if wrap_container { - write_close_tag(out, tag)?; - } - return Ok(()); - } - - // Fallback: convert to string via Python's str() - let py_str = obj.str()?; - let s = py_str.to_str()?; - write_open_tag(out, tag, name_attr, type_attr(cfg, "str"))?; - write_scalar_body(out, s, cfg.cdata)?; - write_close_tag(out, tag)?; - Ok(()) + Err(PyTypeError::new_err(format!( + "Unsupported data type: {} ({})", + obj.repr()?, + obj.get_type().name()? + ))) } /// Write every key/value pair of a dict, mirroring `_append_convert_dict`. @@ -642,8 +617,8 @@ fn is_python_scalar(obj: &Bound<'_, PyAny>) -> bool { /// Convert a Python value to UTF-8 encoded XML bytes. /// -/// The direct extension accepts scalars and iterables, while the automatic backend selector -/// dispatches only supported dict/list requests here. +/// The direct extension accepts dicts, lists, and JSON scalars, while the automatic backend +/// selector dispatches only supported dict/list requests here. /// /// Args: /// obj: The Python object to convert. @@ -661,6 +636,7 @@ fn is_python_scalar(obj: &Bound<'_, PyAny>) -> bool { /// Raises: /// ValueError: If `custom_root` is not a supported XML name or data contains characters /// excluded by XML 1.0. +/// TypeError: If a value is not a dict, list, or JSON scalar. #[cfg(feature = "python")] #[pyfunction] #[pyo3(signature = (obj, root=true, custom_root="root", attr_type=true, item_wrap=true, cdata=false, list_headers=false))] @@ -1154,15 +1130,11 @@ mod tests { push_escaped_text(&mut out, "café & thé"); assert_eq!(out, "café & thé"); } - } - - mod push_escaped_attr_tests { - use super::*; #[test] - fn escapes_quotes_and_special_chars() { + fn escapes_attribute_values_with_the_same_table() { let mut out = String::new(); - push_escaped_attr(&mut out, "a\"b'c&df"); + push_escaped_text(&mut out, "a\"b'c&df"); assert_eq!(out, "a"b'c&d<e>f"); } } diff --git a/tests/test_rust_dicttoxml.py b/tests/test_rust_dicttoxml.py index c7a8cc1..4439ce6 100644 --- a/tests/test_rust_dicttoxml.py +++ b/tests/test_rust_dicttoxml.py @@ -7,6 +7,7 @@ from __future__ import annotations +from decimal import Decimal from typing import Any import pytest @@ -654,6 +655,19 @@ def test_list_with_flat_key(self): assert b"John" in result +class TestRustRejectsUnsupportedTypes: + """The native writer raises instead of guessing for types outside the gate.""" + + # @lat: [[tests#Conversion behavior#Rust backend parity#Native writer rejects unsupported types]] + @pytest.mark.parametrize("value", [(1, 2), {1, 2}, object(), iter([1])]) + def test_direct_call_raises_type_error(self, value: Any) -> None: + with pytest.raises(TypeError, match="Unsupported data type"): + rust_dicttoxml({"a": value}) + + def test_selector_keeps_such_payloads_on_python(self) -> None: + assert b">1" in fast_dicttoxml({"a": Decimal("1")}, root=False) + + class TestFastHelpersWithRealExtension: """The installed extension must not narrow the helpers' accepted inputs."""