Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/requirements.in
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@ tornado==6.5.7
jinja2==3.1.6
idna==3.18
starlette==1.3.1

requests>=2.34.2
6 changes: 4 additions & 2 deletions docs/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,10 @@ pygments==2.20.0
# accessible-pygments
# furo
# sphinx
requests==2.32.4
# via sphinx
requests==2.34.2
# via
# -r requirements.in
# sphinx
roman-numerals==4.1.0
# via sphinx
sniffio==1.3.1
Expand Down
25 changes: 20 additions & 5 deletions json2xml/json2xml.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,18 @@

# @lat: [[architecture#Core pipeline]]
class Json2xml:
"""
Wrapper class to convert the data to xml
"""Configure conversion of a decoded JSON value to XML.

:param data: The decoded JSON value. ``None`` represents absent input; other falsy values
are serialized.
:param wrapper: The root element name used when ``root`` is enabled.
:param root: Include the XML declaration and root element.
:param pretty: Reparse and indent the serialized XML, returning text instead of bytes.
:param attr_type: Add each value's JSON type as an XML attribute.
:param item_wrap: Wrap list members in ``<item>`` elements.
:param xpath_format: Emit the W3C XPath 3.1 JSON-to-XML representation.
:param cdata: Wrap string values in CDATA sections.
:param list_headers: Repeat the parent element for nested dictionary items in lists.
"""
def __init__(
self,
Expand All @@ -36,9 +46,13 @@ def __init__(

# @lat: [[behavior#Conversion output]]
# @lat: [[behavior#Invalid XML payloads]]
def to_xml(self) -> Any | None:
"""
Convert to xml using dicttoxml.dicttoxml and then pretty print it.
def to_xml(self) -> bytes | str | None:
"""Serialize the configured JSON value.

:return: Pretty-printed XML text when ``pretty`` is enabled, UTF-8 encoded XML bytes
otherwise, or ``None`` when the configured data is ``None``.
:raises InvalidDataError: If serialization rejects the data or pretty-print parsing finds
malformed XML.
"""
if self.data is not None:
try:
Expand All @@ -55,6 +69,7 @@ def to_xml(self) -> Any | None:
except ValueError as error:
raise InvalidDataError from error
if self.pretty:
# Keep parser imports off the compact-output path, which returns serializer bytes directly.
from pyexpat import ExpatError

from defusedxml.minidom import parseString
Expand Down
4 changes: 4 additions & 0 deletions lat.md/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ Dependency floors and lockfiles keep known vulnerable packages out of runtime an

Runtime dependencies are declared in `pyproject.toml` and mirrored by `uv.lock`; legacy requirements inputs remain pinned for tooling that still consumes requirements files. Security fixes should update both resolver paths so `uv audit` and requirements-based installs agree.

The Rust accelerator requires PyO3 0.29.1 or newer in the 0.29 series, excluding releases affected by GHSA-36hh-v3qg-5jq4's out-of-bounds list and tuple iterator reads.

Documentation builds declare Requests 2.34.2 as their minimum and regenerate `docs/requirements.txt` with pip-tools so transitive Sphinx resolution cannot select an older release.

Dependabot checks the root and documentation Python dependency manifests weekly, alongside the existing GitHub Actions monitoring, so stale security pins are surfaced automatically.

## Workflow supply-chain hardening
Expand Down
2 changes: 1 addition & 1 deletion rust/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ extension-module = ["python", "pyo3/extension-module"]

[dependencies]
memchr = "2.7"
pyo3 = { version = "0.28.2", optional = true }
pyo3 = { version = "0.29.1", optional = true }

[profile.release]
lto = true
Expand Down
62 changes: 44 additions & 18 deletions rust/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//! Fast native JSON to XML conversion for Python
//! Optional native JSON-to-XML backend for Python.
//!
//! This module provides a high-performance Rust implementation of dicttoxml
//! that can be used as a drop-in replacement for the pure Python version.
//! The Python selector uses this crate only for dict/list requests whose options it can
//! preserve. Unsupported features remain on the compatibility-focused Python serializer.

#[cfg(feature = "python")]
use pyo3::exceptions::PyValueError;
Expand All @@ -17,6 +17,8 @@ use std::borrow::Cow;
#[cfg(feature = "python")]
const OUTPUT_BUFFER_SIZE: usize = 16 * 1024;

// Restarted searches have lower setup cost for sparse escapes. After four matches, the
// monotonic iterators keep dense inputs linear instead of repeatedly scanning the same bytes.
const SPARSE_ESCAPE_SCAN_LIMIT: u8 = 4;

#[inline]
Expand All @@ -41,6 +43,9 @@ fn validate_xml_chars(s: &str) -> PyResult<()> {
}

/// Return the byte offset of the next character requiring XML escaping.
///
/// Every searched byte is ASCII, so a match is always a valid boundary in the original UTF-8
/// string.
#[inline(always)]
fn next_xml_escape(bytes: &[u8]) -> Option<usize> {
let markup = memchr::memchr3(b'&', b'<', b'>', bytes);
Expand Down Expand Up @@ -79,16 +84,21 @@ fn escape_replacement(byte: u8) -> &'static str {
}
}

/// Escape special XML characters in a string (allocating convenience wrapper).
/// Escape the five XML-special characters into a newly allocated string.
///
/// This low-level helper does not validate the XML 1.0 Char production; the Python export
/// validates before calling it.
#[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);
out
}

/// Append text content with XML escaping matching the Python implementation.
/// Scans bytes for speed, copies clean slices in bulk.
/// Append text content 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.
#[inline]
pub fn push_escaped_text(out: &mut String, s: &str) {
let bytes = s.as_bytes();
Expand Down Expand Up @@ -182,7 +192,10 @@ fn write_cdata<W: Write + ?Sized>(out: &mut W, s: &str) -> PyResult<()> {
write_str(out, "]]>")
}

/// Wrap content in CDATA section (allocating convenience wrapper).
/// Wrap content in a newly allocated CDATA section.
///
/// Embedded `]]>` terminators are split across adjacent CDATA sections. This low-level helper
/// does not validate the XML 1.0 Char production; the Python export validates before calling it.
#[inline]
pub fn wrap_cdata(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 12);
Expand All @@ -191,6 +204,8 @@ pub fn wrap_cdata(s: &str) -> String {
}

/// Append a CDATA section directly to the buffer.
///
/// The caller must validate XML characters before using the emitted section in a document.
#[inline]
pub fn push_cdata(out: &mut String, s: &str) {
out.push_str("<![CDATA[");
Expand Down Expand Up @@ -462,7 +477,8 @@ fn write_dict_contents<W: Write + ?Sized>(
let key_str = key_py_str.to_str()?;
let (xml_key, name_attr_pair) = make_valid_xml_name(key_str);
let name_attr = name_attr_pair.as_ref().map(|(_, v)| v.as_ref());
// Lists in dicts get special wrapping treatment
// Python's historical list shape depends only on the first member. Preserve that rule
// for mixed lists rather than reclassifying the container from every value.
if let Ok(list) = val.cast::<PyList>() {
let first_is_scalar = list
.get_item(0)
Expand Down Expand Up @@ -506,6 +522,8 @@ fn write_list_contents<W: Write + ?Sized>(
parent: &str,
cfg: &ConvertConfig,
) -> PyResult<()> {
// `list_headers` changes the tag policy only for dictionary members; primitive members
// continue to follow `item_wrap`.
let scalar_tag_name = if cfg.item_wrap { "item" } else { parent };
let dict_tag_name = if cfg.list_headers {
parent
Expand Down Expand Up @@ -537,21 +555,27 @@ fn write_list_contents<W: Write + ?Sized>(
Ok(())
}

/// Convert a Python dict/list to XML bytes.
/// Convert a Python value to UTF-8 encoded XML bytes.
///
/// This is a high-performance Rust implementation of dicttoxml.
/// The direct extension accepts scalars and iterables, while the automatic backend selector
/// dispatches only supported dict/list requests here.
///
/// Args:
/// obj: The Python object to convert (dict or list)
/// root: Whether to include XML declaration and root element (default: True)
/// custom_root: The name of the root element (default: "root")
/// attr_type: Whether to include type attributes (default: True)
/// item_wrap: Whether to wrap list items in <item> tags (default: True)
/// cdata: Whether to wrap string values in CDATA sections (default: False)
/// list_headers: Whether to repeat parent tag for each list item (default: False)
/// obj: The Python object to convert.
/// root: Whether to include the XML declaration and root element (default: True).
/// custom_root: The name of the root element (default: "root").
/// attr_type: Whether to include type attributes (default: True).
/// item_wrap: Whether to wrap list items in `<item>` tags (default: True).
/// cdata: Whether to wrap string values in CDATA sections (default: False).
/// list_headers: Suppress the outer list container and repeat the parent tag for nested
/// dictionary items; primitive tags continue to follow `item_wrap` (default: False).
///
/// Returns:
/// bytes: The XML representation of the input object
/// bytes: The XML representation of the input object.
///
/// Raises:
/// ValueError: If `custom_root` is not a supported XML name or data contains characters
/// excluded by XML 1.0.
#[cfg(feature = "python")]
#[pyfunction]
#[pyo3(signature = (obj, root=true, custom_root="root", attr_type=true, item_wrap=true, cdata=false, list_headers=false))]
Expand Down Expand Up @@ -580,6 +604,8 @@ fn dicttoxml(
list_headers,
};

// Stream into Python-owned bytes storage to avoid a complete Rust String and cross-language
// copy. The bounded buffer coalesces the serializer's many small writes.
PyBytes::new_with_writer(py, 0, |out| {
let mut out = BufWriter::with_capacity(OUTPUT_BUFFER_SIZE, out);

Expand Down
Loading