From f6d2bfd7969244e044b8779b98c970e145a1cd72 Mon Sep 17 00:00:00 2001 From: Vinit Kumar Date: Mon, 4 May 2026 12:09:08 +0530 Subject: [PATCH 1/5] Improve CLI invalid input errors --- json2xml/cli.py | 30 +++++++++++++++++++++++++----- lat.md/tests.md | 12 ++++++++++++ tests/test_cli.py | 15 ++++++++++----- 3 files changed, 47 insertions(+), 10 deletions(-) diff --git a/json2xml/cli.py b/json2xml/cli.py index d53eca3..a7c48cc 100644 --- a/json2xml/cli.py +++ b/json2xml/cli.py @@ -43,6 +43,7 @@ import argparse import sys +from pathlib import Path from typing import NoReturn from json2xml import __version__ @@ -263,22 +264,36 @@ def read_input(args: argparse.Namespace) -> JSONValue: try: return readfromstring(args.string) except StringReadError as e: - exit_with_error(f"Error parsing JSON string: {e}") + exit_with_error( + "Error: Invalid JSON in --string input. " + f"Pass a valid JSON object, array, string, number, boolean, or null. ({e})" + ) if args.input_file: if args.input_file == "-": # Read from stdin return read_from_stdin() + if not Path(args.input_file).is_file(): + exit_with_error( + f"Error: JSON file not found: {args.input_file}. " + "Check the path or use - to read JSON from stdin." + ) try: return readfromjson(args.input_file) except JSONReadError as e: - exit_with_error(f"Error reading JSON file: {e}") + exit_with_error( + f"Error: Could not parse JSON file: {args.input_file}. " + f"Check that the file contains valid JSON. ({e})" + ) # Check if there's data on stdin if not sys.stdin.isatty(): return read_from_stdin() - exit_with_error("Error: No input provided. Use -h for help.") + exit_with_error( + "Error: No input provided. Pass a JSON file, use - for stdin, " + "or provide --string/--url." + ) def read_from_stdin() -> JSONValue: @@ -294,10 +309,15 @@ def read_from_stdin() -> JSONValue: try: json_str = sys.stdin.read().strip() if not json_str: - exit_with_error("Error: Empty input") + exit_with_error( + "Error: Empty stdin. Pipe JSON into stdin or pass a file/--string." + ) return readfromstring(json_str) except StringReadError as e: - exit_with_error(f"Error parsing JSON from stdin: {e}") + exit_with_error( + "Error: Invalid JSON from stdin. Pipe valid JSON into stdin " + f"or pass a file/--string. ({e})" + ) def write_output(output: str | bytes, output_file: str | None) -> None: diff --git a/lat.md/tests.md b/lat.md/tests.md index 95f8132..88070f3 100644 --- a/lat.md/tests.md +++ b/lat.md/tests.md @@ -26,6 +26,18 @@ These tests verify the concrete reader helpers against realistic source behavior URL input should read valid JSON over HTTP and wrap status, network, and decoding failures in `URLReadError`. +## 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. + +### No input is actionable + +Running the CLI without JSON should fail with a message that tells users to pass a file, stdin, string, or URL instead of only reporting a generic error. + +### Invalid file JSON names the source + +Malformed JSON read from a file should mention that file path so users can distinguish file parsing failures from string, stdin, or conversion failures. + ## Conversion behavior These tests pin the XML shapes that matter most for interoperability, especially the modes that intentionally diverge from the default serializer. diff --git a/tests/test_cli.py b/tests/test_cli.py index aed1dff..113b8b5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -187,7 +187,7 @@ def test_invalid_json_string(self) -> None: text=True, ) assert result.returncode == 1 - assert "Error" in result.stderr + assert "Invalid JSON in --string input" in result.stderr def test_no_input_error(self) -> None: """Test error when no input is provided.""" @@ -201,6 +201,7 @@ def test_no_input_error(self) -> None: ) # Should fail with no input error assert result.returncode == 1 + assert "Empty stdin" in result.stderr def test_list_input(self) -> None: """Test handling of JSON array input.""" @@ -267,7 +268,7 @@ def test_stdin_empty_with_dash(self) -> None: text=True, ) assert result.returncode == 1 - assert "Empty input" in result.stderr or "Error" in result.stderr + assert "Empty stdin" in result.stderr def test_stdin_whitespace_only(self) -> None: """Test error handling when stdin contains only whitespace.""" @@ -303,8 +304,10 @@ def test_nonexistent_file_error(self) -> None: text=True, ) assert result.returncode == 1 - assert "Error" in result.stderr + assert "JSON file not found" in result.stderr + assert "use - to read JSON from stdin" in result.stderr + # @lat: [[tests#CLI failure messages#Invalid file JSON names the source]] def test_invalid_json_file(self) -> None: """Test error handling for file with invalid JSON content.""" with tempfile.TemporaryDirectory() as tmpdir: @@ -318,7 +321,8 @@ def test_invalid_json_file(self) -> None: ) assert result.returncode == 1 - assert "Error" in result.stderr + assert "Could not parse JSON file" in result.stderr + assert str(json_file) in result.stderr def test_output_file_permission_error(self) -> None: """Test error handling when output file cannot be written.""" @@ -553,8 +557,9 @@ def test_read_input_json_file_error(self, capsys: CaptureFixture[str]) -> None: assert exc_info.value.code == 1 captured = capsys.readouterr() - assert "Error reading JSON file" in captured.err + assert "JSON file not found" in captured.err + # @lat: [[tests#CLI failure messages#No input is actionable]] def test_read_input_no_input_tty(self, capsys: CaptureFixture[str]) -> None: """Test read_input exits when no input provided and stdin is a tty.""" with patch("sys.stdin.isatty", return_value=True): From 255a2bb8a446cd7e4aeeebee2037c5eae0a87710 Mon Sep 17 00:00:00 2001 From: Vinit Kumar Date: Mon, 4 May 2026 12:10:01 +0530 Subject: [PATCH 2/5] Add Rust backend fallback tests --- lat.md/tests.md | 8 +++ tests/test_dicttoxml_fast_fallback.py | 98 +++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 tests/test_dicttoxml_fast_fallback.py diff --git a/lat.md/tests.md b/lat.md/tests.md index 88070f3..e85ee00 100644 --- a/lat.md/tests.md +++ b/lat.md/tests.md @@ -81,3 +81,11 @@ Keys ending in `@flat` should keep their flattening behavior where supported and ### Rust and Python XML name parity The Rust accelerator and Python serializer should agree on supported XML name normalization cases so fast-path output does not drift silently. + +### Fast wrapper uses Rust for supported options + +When the optional Rust callable is available and the selected options are Rust-backed, the fast wrapper should dispatch directly to that callable. + +### Special keys force Python fallback + +Special dictionary keys such as `@attrs` and `@val` should bypass the Rust callable so the Python serializer can preserve legacy attribute semantics. diff --git a/tests/test_dicttoxml_fast_fallback.py b/tests/test_dicttoxml_fast_fallback.py new file mode 100644 index 0000000..5d6b374 --- /dev/null +++ b/tests/test_dicttoxml_fast_fallback.py @@ -0,0 +1,98 @@ +"""Tests for optional Rust backend selection in dicttoxml_fast.""" +from __future__ import annotations + +from typing import Any +from unittest.mock import Mock + +import pytest + +import json2xml.dicttoxml_fast as fast_module + + +def _force_rust_backend(monkeypatch: pytest.MonkeyPatch) -> Mock: + """Install a fake Rust backend so tests can exercise selection logic without PyO3.""" + rust_backend = Mock(return_value=b"") + monkeypatch.setattr(fast_module, "_USE_RUST", True) + monkeypatch.setattr(fast_module, "_rust_dicttoxml", rust_backend) + return rust_backend + + +# @lat: [[tests#Conversion behavior#Fast wrapper uses Rust for supported options]] +def test_fast_wrapper_uses_rust_when_available_for_supported_options( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Supported option combinations should go through the Rust callable when present.""" + rust_backend = _force_rust_backend(monkeypatch) + + result = fast_module.dicttoxml( + {"name": "Ada"}, + root=False, + custom_root="person", + attr_type=False, + item_wrap=False, + cdata=True, + list_headers=True, + ) + + assert result == b"" + rust_backend.assert_called_once_with( + {"name": "Ada"}, + root=False, + custom_root="person", + attr_type=False, + item_wrap=False, + cdata=True, + list_headers=True, + ) + + +@pytest.mark.parametrize( + ("kwargs", "expected"), + [ + ({"ids": [1]}, b'id="'), + ({"item_func": lambda parent: "entry"}, b" None: + """Unsupported Rust options should preserve Python semantics instead of calling Rust.""" + rust_backend = _force_rust_backend(monkeypatch) + + result = fast_module.dicttoxml({"items": [1, 2]}, **kwargs) + + assert expected in result + rust_backend.assert_not_called() + + +# @lat: [[tests#Conversion behavior#Special keys force Python fallback]] +def test_fast_wrapper_falls_back_to_python_for_special_keys( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Special @attrs/@val keys require Python processing even when Rust is installed.""" + rust_backend = _force_rust_backend(monkeypatch) + + result = fast_module.dicttoxml({"record": {"@attrs": {"id": "7"}, "@val": "Ada"}}) + + assert b'id="7"' in result + assert b">Ada" in result + rust_backend.assert_not_called() + + +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) + + result = fast_module.dicttoxml({"name": "Ada"}) + + assert b"Ada" in result + rust_backend.assert_not_called() From 8b2a25bdf2aaf228e6e369d447837a979972e6ed Mon Sep 17 00:00:00 2001 From: Vinit Kumar Date: Mon, 4 May 2026 12:10:54 +0530 Subject: [PATCH 3/5] Add real-world conversion examples --- README.rst | 45 +++++++++++++++++++++++++++++++++++++++++++++ docs/usage.rst | 46 ++++++++++++++++++++++++++++++++++++++++++++++ lat.md/behavior.md | 6 ++++++ 3 files changed, 97 insertions(+) diff --git a/README.rst b/README.rst index 0f0a12a..a44804c 100644 --- a/README.rst +++ b/README.rst @@ -68,6 +68,51 @@ Install the accelerated path: pip install json2xml[fast] +Real-world Examples +^^^^^^^^^^^^^^^^^^^ + +Convert a JSON API response in Python when another system expects XML: + +.. code-block:: python + + from json2xml import json2xml + + api_response = {"user": {"id": 7, "name": "Ada"}, "active": True} + print(json2xml.Json2xml(api_response, pretty=False).to_xml().decode("utf-8")) + +Output: + +.. code-block:: xml + + 7Adatrue + +Convert a local JSON export from the shell: + +.. code-block:: console + + cat > orders.json <<'JSON' + {"orders":[{"id":"A100","total":19.99},{"id":"A101","total":5.5}]} + JSON + json2xml-py --no-pretty --no-type orders.json + +Output: + +.. code-block:: xml + + A10019.99A1015.5 + +Convert stdin in a shell pipeline: + +.. code-block:: console + + printf '%s\n' '{"event":"deploy","status":"ok"}' | json2xml-py --no-pretty --no-type - + +Output: + +.. code-block:: xml + + deployok + Performance Snapshot ^^^^^^^^^^^^^^^^^^^^ diff --git a/docs/usage.rst b/docs/usage.rst index 60e3171..6185bfa 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -33,6 +33,52 @@ Here's how to use each method: print(json2xml.Json2xml(data).to_xml()) +Real-world Examples +------------------- + +Convert a JSON API response in Python when another service expects XML: + +.. code-block:: python + + from json2xml import json2xml + + api_response = {"user": {"id": 7, "name": "Ada"}, "active": True} + print(json2xml.Json2xml(api_response, pretty=False).to_xml().decode("utf-8")) + +Output: + +.. code-block:: xml + + 7Adatrue + +Convert a local JSON export from the shell: + +.. code-block:: console + + cat > orders.json <<'JSON' + {"orders":[{"id":"A100","total":19.99},{"id":"A101","total":5.5}]} + JSON + json2xml-py --no-pretty --no-type orders.json + +Output: + +.. code-block:: xml + + A10019.99A1015.5 + +Convert stdin in a shell pipeline: + +.. code-block:: console + + printf '%s\n' '{"event":"deploy","status":"ok"}' | json2xml-py --no-pretty --no-type - + +Output: + +.. code-block:: xml + + deployok + + Constructor Parameters ---------------------- diff --git a/lat.md/behavior.md b/lat.md/behavior.md index a0ed7e8..039e5df 100644 --- a/lat.md/behavior.md +++ b/lat.md/behavior.md @@ -8,6 +8,12 @@ The input helpers convert files, strings, URLs, and stdin into Python data struc [[json2xml/utils.py#readfromjson]] wraps file and JSON decoding failures in `JSONReadError`. [[json2xml/utils.py#readfromstring]] accepts unknown caller input so invalid-type tests can call it honestly, then rejects non-string inputs and malformed JSON with `StringReadError`. [[json2xml/utils.py#readfromurl]] performs a bounded GET request and raises `URLReadError` for network, non-200, decoding, and JSON parse failures. +## User examples + +The public examples favor realistic API, file, and stdin flows with compact before-and-after output that can be checked against the real converter. + +README and docs examples use `pretty=False` for scan-friendly output and avoid hidden fixtures. They cover Python API conversion, local JSON exports, and shell pipelines so users can choose the right entry point quickly. + ## Conversion output Default output includes an XML declaration, wraps content in `all`, pretty prints the document, and annotates elements with their source type unless callers disable those features. From 5280d66b7d83384c52de2c4bf372fb89859d1082 Mon Sep 17 00:00:00 2001 From: Vinit Kumar Date: Mon, 4 May 2026 12:11:50 +0530 Subject: [PATCH 4/5] Document benchmark reproduction steps --- BENCHMARKS.md | 71 ++++++++++++++++++++++++++++++++++++++++++ lat.md/architecture.md | 4 ++- 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/BENCHMARKS.md b/BENCHMARKS.md index fdbf1f0..862d041 100644 --- a/BENCHMARKS.md +++ b/BENCHMARKS.md @@ -9,6 +9,16 @@ Comprehensive performance comparison between all json2xml implementations. - **Python**: 3.14.4 - **Date**: April 24, 2026 +To make new runs comparable, record the same fields for your machine before +publishing results: + +```bash +python --version +uname -a +sw_vers 2>/dev/null || true +which json2xml-go json2xml-zig 2>/dev/null || true +``` + ### Implementations Tested | Implementation | Type | Notes | @@ -114,24 +124,85 @@ go install github.com/vinitkumar/json2xml-go@latest ## Running the Benchmarks +Run benchmarks from a clean checkout with the project installed in an isolated +environment. The exact virtual environment tool does not matter; the commands +below use `uv` because it keeps Python setup reproducible. + +### Required Tools + +| Tool | Required for | Notes | +|------|--------------|-------| +| Python 3.10+ | Pure Python benchmarks | The published run used Python 3.14.4. | +| Rust toolchain | Rust extension benchmarks | Needed only when building `json2xml_rs` locally. | +| maturin | Rust extension benchmarks | Builds and installs the PyO3 extension. | +| json2xml-go | Go CLI benchmarks | Must be on `PATH` as `json2xml-go`. | +| json2xml-zig | Zig CLI benchmarks | Must be on `PATH` as `json2xml-zig`. | + +### Environment Setup + +```bash +uv venv +source .venv/bin/activate +uv pip install -e . +``` + +For Rust benchmarks, install the extension into the same environment: + +```bash +uv pip install maturin +cd rust +maturin develop --release +cd .. +``` + +For native CLI benchmarks, install the external tools and verify that the +commands are visible: + +```bash +go install github.com/vinitkumar/json2xml-go@latest +which json2xml-go +which json2xml-zig +``` + ### Comprehensive Benchmark (All Implementations) +Runs pure Python, Rust if `json2xml_rs` imports successfully, Go if +`json2xml-go` is on `PATH`, and Zig if `json2xml-zig` is on `PATH`. + ```bash python benchmark_all.py ``` ### Rust vs Python Only +Runs the library-call benchmark for pure Python and the PyO3 Rust extension. +If the extension is not installed, the script prints a warning and reports +Python-only results for reference. + ```bash python benchmark_rust.py ``` ### Multi-Python Version Benchmark +Creates per-interpreter virtual environments under `.benchmark_venvs/` and +compares the hard-coded Python paths in `benchmark_multi_python.py`. Edit +`PYTHON_VERSIONS` in that script or install the listed interpreters before +running it. Set `JSON2XML_GO_CLI=/path/to/json2xml-go` if the Go binary is not +named `json2xml-go` on `PATH`. + ```bash python benchmark_multi_python.py ``` +### Interpreting CLI Numbers + +The Go and Zig rows measure full process startup plus conversion because +`benchmark_all.py` invokes those tools with `subprocess.run`. That is the right +measurement for shell workflows, but it is not directly comparable to Python or +Rust library calls for tiny inputs. For small JSON payloads, process startup can +dominate the result; for large payloads, conversion throughput matters more. + ## Related Projects - **Go version**: [github.com/vinitkumar/json2xml-go](https://github.com/vinitkumar/json2xml-go) diff --git a/lat.md/architecture.md b/lat.md/architecture.md index 6fc152e..29a083b 100644 --- a/lat.md/architecture.md +++ b/lat.md/architecture.md @@ -26,8 +26,10 @@ The benchmark docs record measured implementation tradeoffs so users can choose The April 2026 benchmark on Apple Silicon shows the Rust extension as the best option for Python library calls, with 57-129x speedups over pure Python and no process overhead. Go and Zig remain useful for native CLI workflows where startup cost is acceptable. +Reproduction docs require contributors to record machine, OS, Python, and tool availability before comparing results. `benchmark_all.py` mixes library calls and CLI subprocesses intentionally, so its Go and Zig rows include process startup overhead. + ## CLI entrypoint The CLI is a thin adapter that parses options, resolves one input source, and forwards those options into the same converter used by the library API. -[[json2xml/cli.py#create_parser]] defines the user-facing flags. [[json2xml/cli.py#read_input]] enforces the source priority rules, and [[json2xml/cli.py#main]] constructs [[json2xml/json2xml.py#Json2xml]] so command-line use and library use stay aligned. \ No newline at end of file +[[json2xml/cli.py#create_parser]] defines the user-facing flags. [[json2xml/cli.py#read_input]] enforces the source priority rules, and [[json2xml/cli.py#main]] constructs [[json2xml/json2xml.py#Json2xml]] so command-line use and library use stay aligned. From b61408d738aaf6c6543c59df1961a732b49d9f45 Mon Sep 17 00:00:00 2001 From: Vinit Kumar Date: Mon, 4 May 2026 12:18:26 +0530 Subject: [PATCH 5/5] Cover CLI JSON file parse errors --- lat.md/tests.md | 2 +- tests/test_cli.py | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/lat.md/tests.md b/lat.md/tests.md index e85ee00..1d97068 100644 --- a/lat.md/tests.md +++ b/lat.md/tests.md @@ -36,7 +36,7 @@ Running the CLI without JSON should fail with a message that tells users to pass ### Invalid file JSON names the source -Malformed JSON read from a file should mention that file path so users can distinguish file parsing failures from string, stdin, or conversion failures. +Malformed JSON read from an existing file should mention that file path so users can distinguish file parsing failures from missing-file, string, stdin, or conversion failures. ## Conversion behavior diff --git a/tests/test_cli.py b/tests/test_cli.py index 113b8b5..8c9cacd 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -559,6 +559,31 @@ def test_read_input_json_file_error(self, capsys: CaptureFixture[str]) -> None: captured = capsys.readouterr() assert "JSON file not found" in captured.err + def test_read_input_existing_json_file_parse_error(self, capsys: CaptureFixture[str]) -> None: + """Test read_input keeps parse failures distinct from missing file failures.""" + from json2xml.utils import JSONReadError + + with tempfile.TemporaryDirectory() as tmpdir: + json_file = Path(tmpdir) / "invalid.json" + json_file.write_text("{") + + with patch("json2xml.cli.readfromjson") as mock_read: + mock_read.side_effect = JSONReadError("Invalid JSON File") + + args = MagicMock() + args.url = None + args.string = None + args.input_file = str(json_file) + + with pytest.raises(SystemExit) as exc_info: + read_input(args) + + assert exc_info.value.code == 1 + + captured = capsys.readouterr() + assert "Could not parse JSON file" in captured.err + assert str(json_file) in captured.err + # @lat: [[tests#CLI failure messages#No input is actionable]] def test_read_input_no_input_tty(self, capsys: CaptureFixture[str]) -> None: """Test read_input exits when no input provided and stdin is a tty."""