diff --git a/BENCHMARKS.md b/BENCHMARKS.md index 862d041..9082ed8 100644 --- a/BENCHMARKS.md +++ b/BENCHMARKS.md @@ -5,9 +5,10 @@ Comprehensive performance comparison between all json2xml implementations. ## Test Environment - **Machine**: Apple Silicon (arm64) -- **OS**: macOS 26.4.1 (Darwin 25.4.0) +- **OS**: macOS 26.5 (Darwin 25.5.0) - **Python**: 3.14.4 -- **Date**: April 24, 2026 +- **Date**: May 27, 2026 +- **CLI tools**: `json2xml-go` and `json2xml-zig` from `/Users/vinitkumar/.local/bin` To make new runs comparable, record the same fields for your machine before publishing results: @@ -33,10 +34,10 @@ which json2xml-go json2xml-zig 2>/dev/null || true | Size | Description | Bytes | |------|-------------|-------| | Small | Simple object `{"name": "John", "age": 30, "city": "New York"}` | 47 | -| Medium | 10 generated records with nested structures | 3,212 | +| Medium | 10 generated records with nested structures | 3,215 | | bigexample.json | Real-world patent data | 2,018 | -| Large | 100 generated records with nested structures | 32,207 | -| Very Large | 1,000 generated records with nested structures | 323,148 | +| Large | 100 generated records with nested structures | 32,206 | +| Very Large | 1,000 generated records with nested structures | 323,131 | ## Results @@ -44,54 +45,61 @@ which json2xml-go json2xml-zig 2>/dev/null || true | Test Case | Python | Rust | Go | Zig | |-----------|--------|------|-----|-----| -| Small (47B) | 31.49µs | 0.55µs | 4.09ms | 2.02ms | -| Medium (3.2KB) | 1.69ms | 16.15µs | 4.07ms | 2.09ms | -| bigexample (2KB) | 819.86µs | 6.44µs | 4.37ms | 2.11ms | -| Large (32KB) | 17.97ms | 168.21µs | 4.10ms | 2.42ms | -| Very Large (323KB) | 183.33ms | 1.42ms | 4.20ms | 5.12ms | +| Small (47B) | 3.19µs | 0.86µs | 6.05ms | 3.08ms | +| Medium (3.2KB) | 214.83µs | 18.41µs | 5.85ms | 3.12ms | +| bigexample (2KB) | 91.20µs | 7.32µs | 5.76ms | 3.08ms | +| Large (32KB) | 2.07ms | 175.46µs | 5.89ms | 3.73ms | +| Very Large (323KB) | 21.20ms | 1.48ms | 6.82ms | 7.82ms | ### Speedup vs Pure Python | Test Case | Rust | Go | Zig | |-----------|------|-----|-----| -| Small (47B) | **56.8x** | 0.0x* | 0.0x* | -| Medium (3.2KB) | **105.0x** | 0.4x* | 0.8x* | -| bigexample (2KB) | **127.2x** | 0.2x* | 0.4x* | -| Large (32KB) | **106.8x** | 4.4x | **7.4x** | -| Very Large (323KB) | **129.0x** | **43.6x** | **35.8x** | +| Small (47B) | **3.7x** | 0.0x* | 0.0x* | +| Medium (3.2KB) | **11.7x** | 0.0x* | 0.1x* | +| bigexample (2KB) | **12.5x** | 0.0x* | 0.0x* | +| Large (32KB) | **11.8x** | 0.4x* | 0.6x* | +| Very Large (323KB) | **14.4x** | **3.1x** | **2.7x** | -*CLI tools have process spawn overhead (~2-4ms) which dominates for small inputs. +*CLI tools have process spawn overhead (~3-6ms) which dominates for small inputs. ## Key Observations ### 1. Rust Extension is the Best Choice for Python Users 🦀 The Rust extension (json2xml-rs) provides: -- **~57-129x faster** conversion than pure Python in this run +- **~4-14x faster** conversion than the optimized pure Python path in this run - **Zero process overhead** - called directly from Python - **Automatic fallback** - pure Python used if Rust is unavailable or a feature requires it - **Easy install**: `pip install json2xml[fast]` -### 2. Go Excels for Very Large CLI Workloads 🚀 +### 2. Python Optimizations Changed the Relative Speedups + +Recent pure Python improvements substantially reduced conversion time: +- Small inputs dropped to microsecond-level library-call timings +- Medium and large inputs are roughly an order of magnitude faster than the April 2026 run +- Rust remains the fastest library backend, but its relative speedup is lower because the baseline improved + +### 3. Go Excels for Very Large CLI Workloads 🚀 For very large inputs (323KB+): -- **43.6x faster** than Python -- But ~4ms startup overhead hurts small file performance +- **3.1x faster** than optimized Python in this run +- But ~6ms startup overhead hurts small and medium file performance - Best for batch processing or large file conversions from shell scripts -### 3. Zig is Highly Competitive for CLI Use ⚡ +### 4. Zig is Highly Competitive for CLI Use ⚡ In this run: -- **35.8x faster** than Python for very large files -- **7.4x faster** for large files (32KB) -- Faster startup than Go (~2ms vs ~4ms) +- **2.7x faster** than optimized Python for very large files +- Slower than optimized Python for the 32KB library-call benchmark once process startup is included +- Faster startup than Go (~3ms vs ~6ms) - Best balance of startup time and throughput for mixed CLI workloads -### 4. Process Spawn Overhead Matters +### 5. Process Spawn Overhead Matters CLI tools (Go, Zig) have process spawn overhead: -- Go: ~4ms startup overhead -- Zig: ~2ms startup overhead +- Go: ~6ms startup overhead +- Zig: ~3ms startup overhead - Dominates for small inputs (makes them appear slower than Python) - Negligible for large inputs where actual work dominates - Rust extension avoids this entirely by being a native Python module @@ -100,9 +108,9 @@ CLI tools (Go, Zig) have process spawn overhead: | Use Case | Recommended | Why | |----------|-------------|-----| -| Python library calls | **Rust** (`pip install json2xml[fast]`) | 57-129x faster, no process overhead | -| Small files via CLI | **Zig** (json2xml-zig) | Fastest startup among native CLIs (~2ms) | -| Large files via CLI | **Go** or **Zig** | Both excellent; Zig wins at 32KB, Go wins at 323KB in this run | +| Python library calls | **Rust** (`pip install json2xml[fast]`) | 4-14x faster, no process overhead | +| Small files via CLI | **Zig** (json2xml-zig) | Fastest startup among native CLIs (~3ms) | +| Large files via CLI | **Go** or **Zig** | Both excellent; Go wins at 323KB in this run | | Batch processing | **Go** or **Rust** | Both excellent depending on shell vs Python integration | | Pure Python required | **Python** (json2xml) | Always available | diff --git a/docs/benchmarks.rst b/docs/benchmarks.rst index 5d1ec59..a35e13c 100644 --- a/docs/benchmarks.rst +++ b/docs/benchmarks.rst @@ -1,44 +1,46 @@ Benchmarks ========== -Comprehensive performance comparison between Python implementations and the Go version of json2xml. +Comprehensive performance comparison between all json2xml implementations. Test Environment ---------------- -* **Machine**: Apple Silicon (aarch64) -* **OS**: macOS -* **Date**: January 14, 2026 +* **Machine**: Apple Silicon (arm64) +* **OS**: macOS 26.5 (Darwin 25.5.0) +* **Python**: 3.14.4 +* **Date**: May 27, 2026 +* **CLI tools**: ``json2xml-go`` and ``json2xml-zig`` from ``/Users/vinitkumar/.local/bin`` Implementations Tested ~~~~~~~~~~~~~~~~~~~~~~ .. list-table:: :header-rows: 1 - :widths: 30 20 50 + :widths: 25 20 55 * - Implementation - - Version + - Type - Notes - * - CPython - - 3.14.2 - - Homebrew installation - * - CPython - - 3.15.0a4 - - Latest alpha via uv - * - PyPy - - 3.10.16 - - JIT-compiled Python + * - Python + - Library + - Pure Python ``json2xml`` + * - Rust + - Library + - Native extension via PyO3, imported as ``json2xml_rs`` * - Go - - 1.0.0 - - json2xml-go + - CLI + - Standalone ``json2xml-go`` binary + * - Zig + - CLI + - Standalone ``json2xml-zig`` binary Test Data ~~~~~~~~~ .. list-table:: :header-rows: 1 - :widths: 20 50 30 + :widths: 20 55 25 * - Size - Description @@ -47,175 +49,153 @@ Test Data - Simple object ``{"name": "John", "age": 30, "city": "New York"}`` - 47 * - Medium - - ``bigexample.json`` (patent data) - - 2,598 + - 10 generated records with nested structures + - 3,215 + * - bigexample.json + - Real-world patent data + - 2,018 * - Large - - 1,000 generated records with nested structures - - 323,130 + - 100 generated records with nested structures + - 32,206 * - Very Large - - 5,000 generated records with nested structures - - 1,619,991 + - 1,000 generated records with nested structures + - 323,131 Results ------- -Individual Test Results -~~~~~~~~~~~~~~~~~~~~~~~ +Performance Summary +~~~~~~~~~~~~~~~~~~~ .. list-table:: :header-rows: 1 - :widths: 25 20 20 20 15 + :widths: 28 18 18 18 18 - * - Test - - CPython 3.14.2 - - CPython 3.15.0a4 - - PyPy 3.10.16 + * - Test Case + - Python + - Rust - Go - * - **Small JSON** (47 bytes) - - 75.46ms - - 55.74ms (1.4x faster) - - 121.47ms (1.6x slower) - - 3.69ms (20.4x faster) - * - **Medium JSON** (2.6KB) - - 73.87ms - - 57.98ms (1.3x faster) - - 125.73ms (1.7x slower) - - 4.32ms (17.1x faster) - * - **Large JSON** (323KB) - - 419.67ms - - 328.98ms (1.3x faster) - - 517.51ms (1.2x slower) - - 67.13ms (6.3x faster) - * - **Very Large JSON** (1.6MB) - - 2.09s - - 1.86s (1.1x faster) - - 1.42s (1.5x faster) - - 287.58ms (7.3x faster) - -Summary (Average Across All Tests) -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + - Zig + * - Small (47B) + - 3.19µs + - 0.86µs + - 6.05ms + - 3.08ms + * - Medium (3.2KB) + - 214.83µs + - 18.41µs + - 5.85ms + - 3.12ms + * - bigexample (2KB) + - 91.20µs + - 7.32µs + - 5.76ms + - 3.08ms + * - Large (32KB) + - 2.07ms + - 175.46µs + - 5.89ms + - 3.73ms + * - Very Large (323KB) + - 21.20ms + - 1.48ms + - 6.82ms + - 7.82ms + +Speedup vs Pure Python +~~~~~~~~~~~~~~~~~~~~~~ .. list-table:: :header-rows: 1 - :widths: 40 30 30 + :widths: 34 22 22 22 - * - Implementation - - Avg Time - - vs CPython 3.14.2 - * - **Go** - - 90.68ms - - **7.34x faster** 🚀 - * - **PyPy 3.10.16** - - 545.58ms - - **1.22x faster** - * - **CPython 3.15.0a4** - - 575.45ms - - **1.16x faster** - * - **CPython 3.14.2** - - 665.23ms - - baseline + * - Test Case + - Rust + - Go + - Zig + * - Small (47B) + - **3.7x** + - 0.0x* + - 0.0x* + * - Medium (3.2KB) + - **11.7x** + - 0.0x* + - 0.1x* + * - bigexample (2KB) + - **12.5x** + - 0.0x* + - 0.0x* + * - Large (32KB) + - **11.8x** + - 0.4x* + - 0.6x* + * - Very Large (323KB) + - **14.4x** + - **3.1x** + - **2.7x** + +*CLI tools have process spawn overhead of about 3-6ms, which dominates for small inputs.* Key Observations ---------------- -1. Go is the Clear Winner -~~~~~~~~~~~~~~~~~~~~~~~~~ - -Go outperforms all Python implementations by a significant margin: - -* **7.34x faster** than CPython 3.14.2 on average -* Up to **20x faster** for small inputs due to minimal startup overhead -* Consistent performance across all input sizes - -2. CPython 3.15.0a4 Shows Promising Improvements -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -The latest Python alpha demonstrates consistent performance gains: - -* **13-35% faster** than CPython 3.14.2 across all test sizes -* Improvements likely due to ongoing interpreter optimizations - -3. PyPy Has Interesting Trade-offs -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -PyPy's JIT compiler creates a unique performance profile: +Rust remains the best option for Python library calls. It avoids process overhead and is about 4-14x faster than the optimized pure Python path in this run. -* **Slower for small/medium inputs**: JIT compilation overhead hurts for quick operations -* **Faster for very large inputs**: JIT shines on the 5K record test (1.5x faster than CPython) -* Best suited for long-running processes or batch processing +Recent pure Python improvements substantially reduced conversion time. Medium and large inputs are roughly an order of magnitude faster than the April 2026 baseline, so relative Rust speedups are lower even though Rust is still fastest. -4. Startup Overhead Dominates Small Inputs -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Python's interpreter startup time is significant: - -* CPython takes **55-75ms** even for 47 bytes of JSON -* Go takes only **3.7ms** for the same operation -* For CLI tools processing small files, Go provides a much better user experience +Go and Zig remain useful for native CLI workflows. They are slower for small and medium inputs because startup dominates, but both beat Python on the 323KB workload when full CLI process time is measured. When to Use Each Implementation ------------------------------- .. list-table:: :header-rows: 1 - :widths: 50 50 + :widths: 40 25 35 * - Use Case - Recommended - * - CLI tool for small/medium files - - **Go** (json2xml-go) - * - High-throughput batch processing - - **Go** or **PyPy** - * - Integration with Python codebase - - **CPython 3.15+** - * - One-off conversions in scripts - - **CPython** (any version) + - Why + * - Python library calls + - **Rust** + - 4-14x faster, no process overhead + * - Small files via CLI + - **Zig** + - Fastest startup among native CLIs in this run + * - Large files via CLI + - **Go** or **Zig** + - Both are faster than Python at 323KB + * - Batch processing + - **Go** or **Rust** + - Choose based on shell vs Python integration + * - Pure Python required + - **Python** + - Always available Running the Benchmarks ---------------------- -Python Multi-Implementation Benchmark -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Run benchmarks from a clean checkout with the project installed in an isolated environment. .. code-block:: bash - # Set the Go CLI path - export JSON2XML_GO_CLI=/path/to/json2xml-go - - # Run the benchmark - python benchmark_multi_python.py + uv venv + source .venv/bin/activate + uv pip install -e . + python benchmark_all.py -Simple Python vs Go Benchmark -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +For Rust benchmarks, install the extension into the same environment. .. code-block:: bash - # Set paths via environment variables (optional) - export JSON2XML_GO_CLI=/path/to/json2xml-go - export JSON2XML_EXAMPLES_DIR=/path/to/examples - - # Run the benchmark - python benchmark.py - -Reproducing Results -------------------- - -1. Install required Python versions using ``uv``: + uv pip install maturin + cd rust + maturin develop --release + cd .. - .. code-block:: bash +For native CLI benchmarks, install the external tools and verify that the commands are visible. - uv python install 3.14 3.15.0a4 pypy@3.10 - -2. Build the Go binary: - - .. code-block:: bash - - cd /path/to/json2xml-go - go build -o json2xml-go ./cmd/json2xml-go - -3. Run the multi-Python benchmark: - - .. code-block:: bash +.. code-block:: bash - cd /path/to/json2xml - python benchmark_multi_python.py + go install github.com/vinitkumar/json2xml-go@latest + which json2xml-go + which json2xml-zig diff --git a/json2xml/dicttoxml.py b/json2xml/dicttoxml.py index ab5d74c..c6def5c 100644 --- a/json2xml/dicttoxml.py +++ b/json2xml/dicttoxml.py @@ -6,16 +6,18 @@ from collections.abc import Callable, Sequence from decimal import Decimal from fractions import Fraction +from functools import lru_cache from random import SystemRandom from typing import Any, Union, cast -from defusedxml.minidom import parseString +__lazy_modules__ = ["defusedxml.minidom"] # Create a safe random number generator _SAFE_RANDOM = SystemRandom() # Set up logging LOG = logging.getLogger("dicttoxml") +_XML_ESCAPE_CHARS = frozenset("&\"'<>") def make_id(element: str, start: int = 100000, end: int = 999999) -> str: @@ -82,23 +84,23 @@ def get_xml_type(val: Any) -> str: Returns: str: The XML type. """ - if val is not None: - if type(val).__name__ in ("str", "unicode"): - return "str" - if type(val).__name__ in ("int", "long"): - return "int" - if type(val).__name__ == "float": - return "float" - if type(val).__name__ == "bool": - return "bool" - if isinstance(val, numbers.Number): - return "number" - if isinstance(val, dict): - return "dict" - if isinstance(val, Sequence): - return "list" - else: + 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 isinstance(val, numbers.Number): + return "number" + if isinstance(val, dict): + return "dict" + if isinstance(val, Sequence): + return "list" return type(val).__name__ @@ -113,7 +115,8 @@ def escape_xml(s: str | int | float | numbers.Number | None) -> str: str: The escaped string. """ if isinstance(s, str): - s = str(s) # avoid UnicodeDecodeError + if not _XML_ESCAPE_CHARS.intersection(s): + return s s = s.replace("&", "&") s = s.replace('"', """) s = s.replace("'", "'") @@ -132,10 +135,28 @@ def make_attrstring(attr: dict[str, Any]) -> str: Returns: str: The string of XML attributes. """ + if not attr: + return "" + if len(attr) == 1: + key, val = next(iter(attr.items())) + if key == "type": + return f' type="{val}"' + return f' {key}="{escape_xml(val)}"' attrstring = " ".join([f'{k}="{escape_xml(v)}"' for k, v in attr.items()]) - return f'{" " if attrstring != "" else ""}{attrstring}' + return f" {attrstring}" +def _is_fast_valid_xml_name(key: str) -> bool: + """Return True for ASCII XML names known to be accepted by the legacy parser.""" + if not key or not key.isascii() or ":" in key: + return False + first = key[0] + if not (first.isalpha() or first == "_"): + return False + return all(char.isalnum() or char in {"-", "_", "."} for char in key[1:]) + + +@lru_cache(maxsize=4096) def key_is_valid_xml(key: str) -> bool: """ Check if a key is a valid XML name. @@ -146,6 +167,14 @@ def key_is_valid_xml(key: str) -> bool: Returns: bool: True if the key is a valid XML name, False otherwise. """ + key = str(key) + if _is_fast_valid_xml_name(key): + return True + if not key or key.isdigit() or ":" in key: + return False + + from defusedxml.minidom import parseString + test_xml = f'<{key}>foo' try: parseString(test_xml) @@ -313,8 +342,7 @@ def convert( def is_primitive_type(val: Any) -> bool: - t = get_xml_type(val) - return t in {"str", "int", "float", "bool", "number", "null"} + return val is None or isinstance(val, (str, bool, numbers.Number)) def dict2xml_str( @@ -432,18 +460,18 @@ def convert_dict( # here, we just change order and check for bool first, because no other # type other than bool can be true for bool check if isinstance(val, bool): - addline(convert_bool(key, val, attr_type, attr, cdata)) + addline(convert_bool_valid_name(key, val, attr_type, attr)) elif isinstance(val, (numbers.Number, str)): addline( - convert_kv( + convert_kv_valid_name( key=key, val=val, attr_type=attr_type, attr=attr, cdata=cdata ) ) elif hasattr(val, "isoformat"): # datetime addline( - convert_kv( + convert_kv_valid_name( key=key, val=val.isoformat(), attr_type=attr_type, @@ -476,7 +504,7 @@ def convert_dict( ) elif not val: - addline(convert_none(key, attr_type, attr, cdata)) + addline(convert_none_valid_name(key, attr_type, attr)) else: raise TypeError(f"Unsupported data type: {val} ({type(val).__name__})") @@ -501,6 +529,9 @@ def convert_list( item_name = item_func(parent) # Is item_name still relevant if item_wrap is false if item_name.endswith("@flat"): item_name = item_name[:-5] + item_name, item_name_attr = make_valid_xml_name(item_name, {}) + scalar_key = item_name if item_wrap else parent + scalar_key, scalar_key_attr = make_valid_xml_name(scalar_key, {}) this_id = None if ids: this_id = get_unique_id(parent) @@ -509,13 +540,17 @@ def convert_list( attr = {} if not ids else {"id": f"{this_id}_{i + 1}"} if isinstance(item, bool): - addline(convert_bool(item_name, item, attr_type, attr, cdata)) + if item_name_attr: + attr.update(item_name_attr) + addline(convert_bool_valid_name(item_name, item, attr_type, attr)) elif isinstance(item, (numbers.Number, str)): + if scalar_key_attr: + attr.update(scalar_key_attr) if item_wrap: addline( - convert_kv( - key=item_name, + convert_kv_valid_name( + key=scalar_key, val=item, attr_type=attr_type, attr=attr, @@ -524,8 +559,8 @@ def convert_list( ) else: addline( - convert_kv( - key=parent, + convert_kv_valid_name( + key=scalar_key, val=item, attr_type=attr_type, attr=attr, @@ -534,8 +569,10 @@ def convert_list( ) elif hasattr(item, "isoformat"): # datetime + if item_name_attr: + attr.update(item_name_attr) addline( - convert_kv( + convert_kv_valid_name( key=item_name, val=item.isoformat(), attr_type=attr_type, @@ -575,7 +612,9 @@ def convert_list( ) elif item is None: - addline(convert_none(item_name, attr_type, attr, cdata)) + if item_name_attr: + attr.update(item_name_attr) + addline(convert_none_valid_name(item_name, attr_type, attr)) else: raise TypeError(f"Unsupported data type: {item} ({type(item).__name__})") @@ -604,6 +643,24 @@ def convert_kv( return f"<{key}{attr_string}>{wrap_cdata(val) if cdata else escape_xml(val)}" +def convert_kv_valid_name( + key: str, + val: str | int | float | numbers.Number | datetime.datetime | datetime.date, + 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)): + val = val.isoformat() + + attr = dict(attr) + if attr_type: + attr["type"] = get_xml_type(val) + attr_string = make_attrstring(attr) + return f"<{key}{attr_string}>{wrap_cdata(val) if cdata else escape_xml(val)}" + + def convert_bool( key: str, val: bool, attr_type: bool, attr: dict[str, Any] | None = None, cdata: bool = False ) -> str: @@ -618,6 +675,20 @@ def convert_bool( return f"<{key}{attr_string}>{str(val).lower()}" +def convert_bool_valid_name( + key: str, + val: bool, + attr_type: bool, + attr: dict[str, Any], +) -> str: + """Converts a boolean when the caller already validated the key.""" + attr = dict(attr) + if attr_type: + attr["type"] = "bool" + attr_string = make_attrstring(attr) + return f"<{key}{attr_string}>{str(val).lower()}" + + def convert_none( key: str, attr_type: bool, attr: dict[str, Any] | None = None, cdata: bool = False ) -> str: @@ -632,6 +703,17 @@ def convert_none( return f"<{key}{attr_string}>" +def convert_none_valid_name( + key: str, attr_type: bool, attr: dict[str, Any] +) -> str: + """Converts a null value when the caller already validated the key.""" + attr = dict(attr) + if attr_type: + attr["type"] = "null" + attr_string = make_attrstring(attr) + return f"<{key}{attr_string}>" + + # @lat: [[architecture#Conversion engine]] def dicttoxml( obj: ELEMENT, diff --git a/json2xml/dicttoxml_fast.py b/json2xml/dicttoxml_fast.py index 1bf1f3c..44205d9 100644 --- a/json2xml/dicttoxml_fast.py +++ b/json2xml/dicttoxml_fast.py @@ -22,7 +22,7 @@ LOG = logging.getLogger("dicttoxml_fast") # Try to import the Rust implementation -_USE_RUST = False +_use_rust = False _rust_dicttoxml: Callable[..., bytes] | None = None rust_escape_xml: RustStringTransform | None = None rust_wrap_cdata: RustStringTransform | None = None @@ -31,23 +31,23 @@ 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 wrap_cdata_py as rust_wrap_cdata # pragma: no cover - _USE_RUST = True # pragma: no cover + _use_rust = True # pragma: no cover LOG.debug("Using Rust backend for dicttoxml") # pragma: no cover except ImportError: # pragma: no cover LOG.debug("Rust backend not available, using pure Python") -# Import the pure Python implementation as fallback -from json2xml import dicttoxml as _py_dicttoxml # noqa: E402 +# Import the pure Python implementation as fallback. +import json2xml.dicttoxml as _py_dicttoxml # noqa: E402 def is_rust_available() -> bool: """Check if the Rust backend is available.""" - return _USE_RUST + return _use_rust def get_backend() -> str: """Return the name of the current backend ('rust' or 'python').""" - return "rust" if _USE_RUST else "python" + return "rust" if _use_rust else "python" # @lat: [[architecture#Backend selection]] @@ -92,13 +92,14 @@ def dicttoxml( or item_func is not None or xml_namespaces or xpath_format + or not isinstance(obj, (dict, list)) ) # Check for special dict keys that require Python if not needs_python and isinstance(obj, dict): needs_python = _has_special_keys(obj) - if _USE_RUST and not needs_python and _rust_dicttoxml is not None: # pragma: no cover + if _use_rust and not needs_python and _rust_dicttoxml is not None: # pragma: no cover # Use fast Rust implementation return _rust_dicttoxml( obj, @@ -146,14 +147,14 @@ def _has_special_keys(obj: Any) -> bool: # 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 + if _use_rust and rust_escape_xml is not None: # pragma: no cover return rust_escape_xml(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 + if _use_rust and rust_wrap_cdata is not None: # pragma: no cover return rust_wrap_cdata(s) return _py_dicttoxml.wrap_cdata(s) diff --git a/json2xml/json2xml.py b/json2xml/json2xml.py index c622b34..0842283 100644 --- a/json2xml/json2xml.py +++ b/json2xml/json2xml.py @@ -1,10 +1,8 @@ -from pyexpat import ExpatError from typing import Any -from defusedxml.minidom import parseString - -from json2xml import dicttoxml +__lazy_modules__ = ["defusedxml.minidom", "pyexpat"] +from . import dicttoxml_fast as dicttoxml from .types import JSONValue from .utils import InvalidDataError @@ -54,8 +52,12 @@ def to_xml(self) -> Any | None: list_headers=self.list_headers, ) if self.pretty: + from pyexpat import ExpatError + + from defusedxml.minidom import parseString + try: - result = parseString(xml_data).toprettyxml(encoding="UTF-8").decode() + result = parseString(xml_data.decode("utf-8")).toprettyxml(encoding="UTF-8").decode() except ExpatError: raise InvalidDataError return result diff --git a/json2xml/utils.py b/json2xml/utils.py index b9b8553..2d24fb8 100644 --- a/json2xml/utils.py +++ b/json2xml/utils.py @@ -2,13 +2,26 @@ from __future__ import annotations import json +from typing import Any -import urllib3 +__lazy_modules__ = ["urllib3"] from .types import JSONValue -DEFAULT_URL_TIMEOUT = urllib3.Timeout(connect=5.0, read=30.0) -_HTTP = urllib3.PoolManager() +DEFAULT_URL_TIMEOUT: Any | None = None +_HTTP: Any | None = None + + +def _get_http_client() -> tuple[Any, Any, Any]: + """Import and initialize urllib3 only for URL reads.""" + import urllib3 + + global DEFAULT_URL_TIMEOUT, _HTTP + if DEFAULT_URL_TIMEOUT is None: + DEFAULT_URL_TIMEOUT = urllib3.Timeout(connect=5.0, read=30.0) + if _HTTP is None: + _HTTP = urllib3.PoolManager() + return urllib3, _HTTP, DEFAULT_URL_TIMEOUT class JSONReadError(Exception): @@ -43,12 +56,13 @@ def readfromjson(filename: str) -> JSONValue: def readfromurl(url: str, params: dict[str, str] | None = None) -> JSONValue: """Load JSON data from a URL.""" + urllib3, http, timeout = _get_http_client() try: - response = _HTTP.request( + response = http.request( "GET", url, fields=params, - timeout=DEFAULT_URL_TIMEOUT, + timeout=timeout, retries=False, ) except urllib3.exceptions.HTTPError as error: diff --git a/lat.md/architecture.md b/lat.md/architecture.md index 7a1105e..ce60b57 100644 --- a/lat.md/architecture.md +++ b/lat.md/architecture.md @@ -4,27 +4,27 @@ This file documents the main execution paths that turn JSON input into XML outpu ## Core pipeline -The standard pipeline reads JSON into Python objects, passes that data through [[json2xml/json2xml.py#Json2xml]], and delegates serialization to [[json2xml/dicttoxml.py#dicttoxml]]. +The standard pipeline reads JSON into Python objects, passes that data through [[json2xml/json2xml.py#Json2xml]], and delegates serialization through the fast backend selector in [[json2xml/dicttoxml_fast.py#dicttoxml]]. -Library callers usually construct [[json2xml/json2xml.py#Json2xml]] with a decoded `dict` or `list`. CLI callers reach the same conversion path through [[json2xml/cli.py#read_input]], which resolves the input source before creating the converter. Pretty output is produced by reparsing the generated XML so callers get indented text when requested. +Library callers usually construct [[json2xml/json2xml.py#Json2xml]] with decoded JSON data. CLI callers reach the same conversion path through [[json2xml/cli.py#read_input]], which resolves the input source before creating the converter. Pretty output is produced by reparsing the generated XML so callers get indented text when requested. ## Conversion engine The pure Python serializer recursively maps Python values to XML elements, attributes, and text while preserving the project-specific options around wrappers, list handling, and type metadata. -[[json2xml/dicttoxml.py#dicttoxml]] is the public serializer. It handles the XML declaration, root wrapper, namespace emission, XPath mode, and then routes nested values through helper functions such as [[json2xml/dicttoxml.py#convert]], [[json2xml/dicttoxml.py#convert_dict]], and [[json2xml/dicttoxml.py#convert_list]]. [[json2xml/dicttoxml.py#get_xml_type]] and [[json2xml/dicttoxml.py#convert]] accept broad caller input and classify unsupported values at runtime, so tests can probe failure paths without lying to the type checker. Invalid XML names are normalized by [[json2xml/dicttoxml.py#make_valid_xml_name]] instead of crashing immediately on user keys, and special `@attrs`/`@val` handling avoids mutating caller data. +[[json2xml/dicttoxml.py#dicttoxml]] is the public serializer. It handles the XML declaration, root wrapper, namespace emission, XPath mode, and then routes nested values through helper functions such as [[json2xml/dicttoxml.py#convert]], [[json2xml/dicttoxml.py#convert_dict]], and [[json2xml/dicttoxml.py#convert_list]]. [[json2xml/dicttoxml.py#get_xml_type]] and [[json2xml/dicttoxml.py#convert]] accept broad caller input and classify unsupported values at runtime, so tests can probe failure paths without lying to the type checker. Invalid XML names are normalized by [[json2xml/dicttoxml.py#make_valid_xml_name]] instead of crashing immediately on user keys; common ASCII names use cached fast validation, while parser validation remains available for non-ASCII or unusual names. Dict and list scalar paths reuse validated element names and specialize generated type attributes so common payloads avoid repeated normalization and escaping work. Special `@attrs`/`@val` handling avoids mutating caller data. ## Backend selection 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]] uses the Rust backend only when optional features such as `ids`, custom `item_func`, XML namespaces, XPath mode, or special `@` keys are not involved. A local stub for the optional `json2xml_rs` module keeps static analysis aligned with that fallback design, so type checking still passes when the extension is not installed. This keeps fast installs fast without letting the optimized path silently change behavior. +[[json2xml/dicttoxml_fast.py#dicttoxml]] uses the Rust backend only when optional features such as `ids`, custom `item_func`, XML namespaces, XPath mode, root scalar payloads, or special `@` keys are not involved. A local stub for the optional `json2xml_rs` module keeps static analysis aligned with that fallback design, so type checking still passes when the extension is not installed. This keeps fast installs fast without letting the optimized path silently change behavior. ## Performance benchmarks The benchmark docs record measured implementation tradeoffs so users can choose between Python, Rust, Go, and Zig without guessing. -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. +The May 2026 benchmark on Apple Silicon shows the Rust extension as the best option for Python library calls, with 4-14x speedups over the optimized pure Python path 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. diff --git a/lat.md/behavior.md b/lat.md/behavior.md index 039e5df..5dabd7d 100644 --- a/lat.md/behavior.md +++ b/lat.md/behavior.md @@ -6,7 +6,7 @@ This file captures the observable conversion and input rules that matter more th The input helpers convert files, strings, URLs, and stdin into Python data structures while surfacing source-specific errors to callers. -[[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. +[[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]] lazily initializes the HTTP client, performs a bounded GET request, and raises `URLReadError` for network, non-200, decoding, and JSON parse failures. ## User examples @@ -18,7 +18,9 @@ README and docs examples use `pretty=False` for scan-friendly output and avoid h Default output includes an XML declaration, wraps content in `all`, pretty prints the document, and annotates elements with their source type unless callers disable those features. -[[json2xml/json2xml.py#Json2xml#to_xml]] calls [[json2xml/dicttoxml.py#dicttoxml]] with the configured wrapper, root, `attr_type`, `item_wrap`, `cdata`, and `list_headers` options. It treats only `None` as absent input, so falsy JSON values still serialize. When `item_wrap=False`, list values repeat the parent tag instead of creating `` children. When `pretty=False`, the library returns the serializer bytes directly. +[[json2xml/json2xml.py#Json2xml#to_xml]] calls [[json2xml/dicttoxml_fast.py#dicttoxml]] with the configured wrapper, root, `attr_type`, `item_wrap`, `cdata`, and `list_headers` options. It treats only `None` as absent input, so falsy JSON values still serialize. When `item_wrap=False`, list values repeat the parent tag instead of creating `` children. When `pretty=False`, the library returns the serializer bytes directly. + +The fast backend selector falls back to the pure Python serializer for root scalar payloads so values like `0`, `false`, and `""` keep the historical `` element inside the configured root wrapper. The Rust fast path in [[rust/src/lib.rs#write_dict_contents]] and [[rust/src/lib.rs#write_list_contents]] mirrors those Python list-wrapper rules. `list_headers=True` suppresses the outer list container and repeats the parent tag only for nested dict items, while primitive items still use the same scalar tags that Python emits. @@ -32,4 +34,4 @@ When `xpath_format=True`, [[json2xml/dicttoxml.py#dicttoxml]] delegates payload Pretty printing acts as a validation step, because the formatter reparses the generated XML before returning it. -[[json2xml/json2xml.py#Json2xml#to_xml]] uses `defusedxml.minidom.parseString` before `toprettyxml`. If the generated bytes are not well-formed XML, the converter raises `InvalidDataError` instead of returning broken pretty output. +[[json2xml/json2xml.py#Json2xml#to_xml]] imports `defusedxml.minidom.parseString` only for pretty output, then reparses before `toprettyxml`. If the generated bytes are not well-formed XML, the converter raises `InvalidDataError` instead of returning broken pretty output. diff --git a/lat.md/tests.md b/lat.md/tests.md index 1d97068..cd746bb 100644 --- a/lat.md/tests.md +++ b/lat.md/tests.md @@ -82,10 +82,46 @@ Keys ending in `@flat` should keep their flattening behavior where supported and The Rust accelerator and Python serializer should agree on supported XML name normalization cases so fast-path output does not drift silently. +### Invalid list item names preserve metadata + +Generated list item names that are not valid XML should emit `` elements with the original name preserved in a `name` attribute across scalar item types. + +### Valid-name scalar helper formats dates + +The scalar helper used after key validation should still ISO-format date values before assigning type metadata and serializing element text. + ### 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. +### Fast wrapper exposes backend metadata + +Backend metadata helpers should report whether Rust is active and name the selected backend so callers can diagnose fallback behavior. + +### Fast helper functions use Python fallback + +Helper exports for XML escaping and CDATA wrapping should preserve Python behavior when Rust helper callables are unavailable. + +### Json2xml uses fast backend selection + +The public `Json2xml` wrapper should delegate through the fast backend selector so regular library and CLI conversions can use the Rust accelerator when installed. + ### 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. + +### Root scalars keep Python fallback + +Root scalar payloads should bypass the Rust callable until the accelerator preserves the legacy Python `` wrapper shape under the configured root element. + +## XML helper behavior + +These tests pin low-level XML helper contracts so performance refactors keep the same serializer output and caller-side mutation behavior. + +### Valid-name helpers preserve caller attrs + +Helpers that receive prevalidated XML names should add type metadata only to the emitted element and must not mutate caller-owned attribute dictionaries. + +### XML name validity fast and cached paths + +XML name validation should agree across the ASCII fast path, parser-backed path, and repeated cached calls so optimization does not change accepted names. diff --git a/tests/test_dict2xml.py b/tests/test_dict2xml.py index bc4884b..b81726b 100644 --- a/tests/test_dict2xml.py +++ b/tests/test_dict2xml.py @@ -1070,6 +1070,45 @@ def test_list2xml_str_with_attr_type(self) -> None: ) assert 'type="list"' in result + # @lat: [[tests#Conversion behavior#Invalid list item names preserve metadata]] + def test_convert_list_invalid_item_name_metadata_for_scalar_paths(self) -> None: + """Invalid generated list item names should preserve the original name attribute.""" + item_name_result = dicttoxml.convert_list( + items=[True, datetime.date(2026, 5, 27), None], + ids=[], + parent="items", + attr_type=True, + item_func=lambda _parent: "bad&key", + cdata=False, + item_wrap=True, + ) + parent_name_result = dicttoxml.convert_list( + items=[7], + ids=[], + parent="bad&parent", + attr_type=True, + item_func=lambda _parent: "item", + cdata=False, + item_wrap=False, + ) + + assert 'true' in item_name_result + assert '2026-05-27' in item_name_result + assert '' in item_name_result + assert parent_name_result == '7' + + # @lat: [[tests#Conversion behavior#Valid-name scalar helper formats dates]] + def test_convert_kv_valid_name_formats_date_values(self) -> None: + """The valid-name scalar helper should format date values before type tagging.""" + result = dicttoxml.convert_kv_valid_name( + key="published", + val=datetime.date(2026, 5, 27), + attr_type=True, + attr={}, + ) + + assert result == '2026-05-27' + def test_convert_dict_with_bool_value(self) -> None: """Test convert_dict with boolean value.""" obj = {"flag": True} diff --git a/tests/test_dicttoxml_fast_fallback.py b/tests/test_dicttoxml_fast_fallback.py index 5d6b374..7e7a0f1 100644 --- a/tests/test_dicttoxml_fast_fallback.py +++ b/tests/test_dicttoxml_fast_fallback.py @@ -12,11 +12,22 @@ 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, "_use_rust", True) monkeypatch.setattr(fast_module, "_rust_dicttoxml", rust_backend) return rust_backend +# @lat: [[tests#Conversion behavior#Fast wrapper exposes backend metadata]] +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) + + assert fast_module.is_rust_available() is False + assert fast_module.get_backend() == "python" + + # @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, @@ -76,19 +87,34 @@ def test_fast_wrapper_falls_back_to_python_for_special_keys( """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"}}) + result = fast_module.dicttoxml( + {"records": [{"record": {"@attrs": {"id": "7"}, "@val": "Ada"}}]} + ) assert b'id="7"' in result assert b">Ada" in result rust_backend.assert_not_called() +# @lat: [[tests#Conversion behavior#Root scalars keep Python fallback]] +def test_fast_wrapper_falls_back_to_python_for_root_scalars( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Root scalar values should keep the legacy Python wrapper shape.""" + rust_backend = _force_rust_backend(monkeypatch) + + result = fast_module.dicttoxml(0, custom_root="all") + + assert b'0' 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, "_use_rust", False) monkeypatch.setattr(fast_module, "_rust_dicttoxml", rust_backend) result = fast_module.dicttoxml({"name": "Ada"}) @@ -96,3 +122,16 @@ def test_fast_wrapper_falls_back_to_python_when_rust_is_unavailable( assert b"Ada" in result rust_backend.assert_not_called() + + +# @lat: [[tests#Conversion behavior#Fast helper functions use Python fallback]] +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) + + assert fast_module.escape_xml("Ada & ") == "Ada & <XML>" + assert fast_module.wrap_cdata("Ada ") == "]]>" diff --git a/tests/test_dicttoxml_unit.py b/tests/test_dicttoxml_unit.py new file mode 100644 index 0000000..3e92dcb --- /dev/null +++ b/tests/test_dicttoxml_unit.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import numbers +from decimal import Decimal +from fractions import Fraction +from typing import Any + +import pytest + +from json2xml import dicttoxml + + +class CustomNumber(numbers.Number): + def __complex__(self) -> complex: + return complex(7) + + def __float__(self) -> float: + return 7.0 + + def __int__(self) -> int: + return 7 + + def __round__(self, ndigits: int | None = None) -> int: + return 7 + + +@pytest.mark.parametrize( + ("value", "xml_type", "is_primitive"), + [ + (None, "null", True), + (False, "bool", True), + (True, "bool", True), + (1, "int", True), + (3.5, "float", True), + (Decimal("1.25"), "number", True), + (Fraction(3, 4), "number", True), + (CustomNumber(), "number", True), + ({}, "dict", False), + ([], "list", False), + ], +) +def test_get_xml_type_and_primitive_classification(value: Any, xml_type: str, is_primitive: bool) -> None: + assert dicttoxml.get_xml_type(value) == xml_type + assert dicttoxml.is_primitive_type(value) is is_primitive + + +@pytest.mark.parametrize( + "value", + [ + "plain text", + "rock & roll", + "\"double\" and 'single'", + "value", + "mixed & 'text'", + ], +) +def test_escape_xml_matches_full_replacement_chain(value: str) -> None: + expected = ( + value.replace("&", "&") + .replace('"', """) + .replace("'", "'") + .replace("<", "<") + .replace(">", ">") + ) + assert dicttoxml.escape_xml(value) == expected + + +@pytest.mark.parametrize( + ("attrs", "expected"), + [ + ({}, ""), + ({"a": 1}, ' a="1"'), + ({"type": "str"}, ' type="str"'), + ({"type": "str", "id": 1}, ' type="str" id="1"'), + ], +) +def test_make_attrstring_pins_spacing_and_order(attrs: dict[str, Any], expected: str) -> None: + assert dicttoxml.make_attrstring(attrs) == expected + + +# @lat: [[tests#XML helper behavior#Valid-name helpers preserve caller attrs]] +def test_valid_name_helpers_set_type_without_mutating_caller_attrs() -> None: + base_attrs = {"id": "shared"} + + assert ( + dicttoxml.convert_kv_valid_name("name", "Bike", True, base_attrs) + == 'Bike' + ) + assert base_attrs == {"id": "shared"} + + assert ( + dicttoxml.convert_bool_valid_name("active", False, True, base_attrs) + == 'false' + ) + assert base_attrs == {"id": "shared"} + + assert ( + dicttoxml.convert_none_valid_name("empty", True, base_attrs) + == '' + ) + assert base_attrs == {"id": "shared"} + + +def test_valid_name_helpers_keep_existing_attrs_without_attr_type() -> None: + base_attrs = {"name": "invalid key"} + + assert ( + dicttoxml.convert_kv_valid_name("key", "Bike", False, base_attrs) + == 'Bike' + ) + assert dicttoxml.convert_bool_valid_name("key", True, False, base_attrs) == 'true' + assert dicttoxml.convert_none_valid_name("key", False, base_attrs) == '' + assert base_attrs == {"name": "invalid key"} + + +# @lat: [[tests#XML helper behavior#XML name validity fast and cached paths]] +def test_key_is_valid_xml_fast_and_parse_paths_are_stable_under_cache() -> None: + dicttoxml.key_is_valid_xml.cache_clear() + + cases = { + "foo": True, + "_bar-1": True, + "café": True, + "éclair": True, + "1foo": False, + "foo:bar": False, + "": False, + } + + first = {key: dicttoxml.key_is_valid_xml(key) for key in cases} + second = {key: dicttoxml.key_is_valid_xml(key) for key in reversed(cases)} + + assert first == cases + assert second == cases + cache_info = dicttoxml.key_is_valid_xml.cache_info() + assert cache_info.hits >= len(cases) diff --git a/tests/test_json2xml.py b/tests/test_json2xml.py index 5921c5e..1455a16 100644 --- a/tests/test_json2xml.py +++ b/tests/test_json2xml.py @@ -95,6 +95,33 @@ def test_json_to_xml_falsy_values(self, data: Any, expected: bytes) -> None: assert isinstance(xmldata, bytes) assert expected in xmldata + # @lat: [[tests#Conversion behavior#Json2xml uses fast backend selection]] + def test_json_to_xml_uses_fast_backend(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Json2xml should delegate through the backend selector used by fast installs.""" + calls: list[dict[str, Any]] = [] + + def fake_dicttoxml(data: Any, **kwargs: Any) -> bytes: + calls.append({"data": data, **kwargs}) + return b"Ada" + + monkeypatch.setattr(json2xml.dicttoxml, "dicttoxml", fake_dicttoxml) + + xmldata = json2xml.Json2xml({"name": "Ada"}, wrapper="all", pretty=False).to_xml() + + assert xmldata == b"Ada" + assert calls == [ + { + "data": {"name": "Ada"}, + "root": True, + "custom_root": "all", + "attr_type": True, + "item_wrap": True, + "xpath_format": False, + "cdata": False, + "list_headers": False, + } + ] + def test_custom_wrapper_and_indent(self) -> None: data = readfromstring( '{"login":"mojombo","id":1,"avatar_url":"https://avatars0.githubusercontent.com/u/1?v=4"}' diff --git a/tests/test_rust_dicttoxml.py b/tests/test_rust_dicttoxml.py index 4c684a6..1e8de56 100644 --- a/tests/test_rust_dicttoxml.py +++ b/tests/test_rust_dicttoxml.py @@ -595,8 +595,8 @@ def test_escape_xml_python_fallback(self): """Test escape_xml falls back to Python when Rust unavailable.""" from unittest.mock import patch - # Temporarily mock _USE_RUST to False - with patch.object(fast_module, '_USE_RUST', False): + # Temporarily mock Rust availability to False. + with patch.object(fast_module, '_use_rust', False): result = fast_module.escape_xml("Hello ") assert "<" in result assert ">" in result @@ -605,8 +605,8 @@ def test_wrap_cdata_python_fallback(self): """Test wrap_cdata falls back to Python when Rust unavailable.""" from unittest.mock import patch - # Temporarily mock _USE_RUST to False - with patch.object(fast_module, '_USE_RUST', False): + # Temporarily mock Rust availability to False. + with patch.object(fast_module, '_use_rust', False): result = fast_module.wrap_cdata("Hello World") assert result == ""