diff --git a/docs/requirements.in b/docs/requirements.in index 7a526f33..7196bbdd 100644 --- a/docs/requirements.in +++ b/docs/requirements.in @@ -12,3 +12,5 @@ tornado==6.5.7 jinja2==3.1.6 idna==3.18 starlette==1.3.1 + +requests>=2.34.2 \ No newline at end of file diff --git a/docs/requirements.txt b/docs/requirements.txt index 3ef2c4cd..8e77f4a0 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -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 diff --git a/json2xml/json2xml.py b/json2xml/json2xml.py index 89552142..44741ca6 100644 --- a/json2xml/json2xml.py +++ b/json2xml/json2xml.py @@ -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 ```` 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, @@ -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: @@ -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 diff --git a/lat.md/architecture.md b/lat.md/architecture.md index e9520ba6..74fa41fa 100644 --- a/lat.md/architecture.md +++ b/lat.md/architecture.md @@ -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 diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 4a22bdc9..5ca4cecc 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -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 diff --git a/rust/src/lib.rs b/rust/src/lib.rs index ff0d104f..9dd85e98 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -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; @@ -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] @@ -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 { let markup = memchr::memchr3(b'&', b'<', b'>', bytes); @@ -79,7 +84,10 @@ 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); @@ -87,8 +95,10 @@ pub fn escape_xml(s: &str) -> String { 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(); @@ -182,7 +192,10 @@ fn write_cdata(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); @@ -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("( 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::() { let first_is_scalar = list .get_item(0) @@ -506,6 +522,8 @@ fn write_list_contents( 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 @@ -537,21 +555,27 @@ fn write_list_contents( 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 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 `` 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))] @@ -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);