From e1ab67d29d905da733326ed6f72b9f2a5dd0ec53 Mon Sep 17 00:00:00 2001 From: Vinit Kumar Date: Wed, 8 Apr 2026 12:50:53 +0530 Subject: [PATCH 1/8] fix: replace mutable default argument in dicttoxml() xml_namespaces had a mutable default dict which is a classic Python footgun where mutations persist across calls. Changed to None with an 'or {}' guard inside the function body, matching the pattern already used in dicttoxml_fast.py. Amp-Thread-ID: https://ampcode.com/threads/T-019d6bf2-ce86-763d-8c62-08118f358cbe Co-authored-by: Amp --- json2xml/dicttoxml.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/json2xml/dicttoxml.py b/json2xml/dicttoxml.py index 4b88428..c1986ec 100644 --- a/json2xml/dicttoxml.py +++ b/json2xml/dicttoxml.py @@ -641,7 +641,7 @@ def dicttoxml( item_wrap: bool = True, item_func: Callable[[str], str] = default_item_func, cdata: bool = False, - xml_namespaces: dict[str, Any] = {}, + xml_namespaces: dict[str, Any] | None = None, list_headers: bool = False, xpath_format: bool = False, ) -> bytes: @@ -797,6 +797,7 @@ def dicttoxml( output = [] namespace_str = "" + xml_namespaces = xml_namespaces or {} for prefix in xml_namespaces: if prefix == 'xsi': for schema_att in xml_namespaces[prefix]: From 08be4ff32ef81de4eee92d451e994a5f33eebaed Mon Sep 17 00:00:00 2001 From: Vinit Kumar Date: Wed, 8 Apr 2026 12:51:11 +0530 Subject: [PATCH 2/8] fix: remove unused Dict and List imports from conftest.py These typing imports were dead code since the file already uses the built-in dict[...] and list[...] syntax everywhere. Amp-Thread-ID: https://ampcode.com/threads/T-019d6bf2-ce86-763d-8c62-08118f358cbe Co-authored-by: Amp --- tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 9e52860..9c40ee5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,7 +3,7 @@ import json from pathlib import Path -from typing import TYPE_CHECKING, Any, Dict, List +from typing import TYPE_CHECKING, Any import pytest From 65a86b68c4c40bf23e9393a62033e5fdefd527ae Mon Sep 17 00:00:00 2001 From: Vinit Kumar Date: Wed, 8 Apr 2026 12:51:24 +0530 Subject: [PATCH 3/8] fix: remove dead setUp/tearDown stubs from TestJson2xml These were unittest-style no-op methods that serve no purpose in pytest classes. Amp-Thread-ID: https://ampcode.com/threads/T-019d6bf2-ce86-763d-8c62-08118f358cbe Co-authored-by: Amp --- tests/test_json2xml.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/test_json2xml.py b/tests/test_json2xml.py index 672a6a1..9231eed 100644 --- a/tests/test_json2xml.py +++ b/tests/test_json2xml.py @@ -21,12 +21,6 @@ class TestJson2xml: """Tests for `json2xml` package.""" - def setUp(self) -> None: - """Set up test fixtures, if any.""" - - def tearDown(self) -> None: - """Tear down test fixtures, if any.""" - def test_read_from_json(self) -> None: """Test something.""" data = readfromjson("examples/bigexample.json") From 396b59934fe42bbd43405649e75d666880e62959 Mon Sep 17 00:00:00 2001 From: Vinit Kumar Date: Wed, 8 Apr 2026 12:51:40 +0530 Subject: [PATCH 4/8] fix: hoist SystemRandom() to module level in dicttoxml Avoids creating a new SystemRandom instance on every make_id() call. The module-level _SAFE_RANDOM is reused across all invocations. Amp-Thread-ID: https://ampcode.com/threads/T-019d6bf2-ce86-763d-8c62-08118f358cbe Co-authored-by: Amp --- json2xml/dicttoxml.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/json2xml/dicttoxml.py b/json2xml/dicttoxml.py index c1986ec..854c694 100644 --- a/json2xml/dicttoxml.py +++ b/json2xml/dicttoxml.py @@ -12,6 +12,7 @@ from defusedxml.minidom import parseString # Create a safe random number generator +_SAFE_RANDOM = SystemRandom() # Set up logging LOG = logging.getLogger("dicttoxml") @@ -29,8 +30,7 @@ def make_id(element: str, start: int = 100000, end: int = 999999) -> str: Returns: str: The generated ID. """ - safe_random = SystemRandom() - return f"{element}_{safe_random.randint(start, end)}" + return f"{element}_{_SAFE_RANDOM.randint(start, end)}" def get_unique_id(element: str) -> str: From 2f283f983b352077aa7b54fdf6a10fd022d8254a Mon Sep 17 00:00:00 2001 From: Vinit Kumar Date: Wed, 8 Apr 2026 13:06:01 +0530 Subject: [PATCH 5/8] chore: stop tracking coverage data --- .coverage | Bin 53248 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 .coverage diff --git a/.coverage b/.coverage deleted file mode 100644 index 8a62b2f7eca490c39266d561834ac66b38c8e4c3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 53248 zcmeI)OKcQ%90%}u?1L>^|JaI4Te1I(S(?6F1rbcds!b$d5JB`{TxNG?+kxGgGBXRs z@T!t%^x#E3spyG|i6$O2a)87GHw@7OC*g!7BC(04Ra(~He;)gwEFpvhXugx~?#%oj z^Z$STubtht+qP~tT%8v!yR5nVNokoR%hGe4OOlkJM>{>jElxX;@PJ7V=lH5NPTSxhAK|vOk5A|}FB&DC8)m`CYp!9A zad%t~V^A8UWDLku0HAdCnyu$ z7&Ao-zA8xG$8Ei++q#+89lsQ-j6z>q-@Av~;;GI~`90mMlV<1XXK7Py=tR9L8;g`t z-m(j#^f_BI^W(ZRz_nTp^A?qJ()DX=TjgQb;w8iMs%bigYgi`NC-r>A)eASQ;DA62 zH^OVI4dt~{RA{H4GPqWQPCfArQU=k3#6>EcX@#uygyPba*ekPHDd&iMSEW258i!K& z7j~^yDO;s=t)Q~E+%`98%X)pAnlmI9E$XMLn8u!pUTgdTUQapTRl~FfxTpm6fX9}` zQtLWpiF&0z>Fg=dTc@m6aV_sSOO2n+&{Oj0gG=J6-d_2=JzhrUq& zZqao_cP7;#SccG9oqi8;5#x zYP)SyU9otor$>G#;dP^+wSt#;Qx^(b@&?mr7*y6Zr(%Z_$;~FoFhzZq497?iLe&|J z$5JCbt*R5m%VvU|B~$T8JhgneJmrrFF)xWnw5bx}OnnGkw-DTV@h3EQoG&c9I86ml zM`NjHmbWUnC_*+Po?}OsDPED^S3UhQP5R=YHWgW%sTX;ZzJGA9R-)NtLNja{vNQ+# zD*zV}3mQe%a#qFl`mZ?jx~Eus>gDw_s2_*+6symRPQ&{Z%W(`^R>iXAtPjuf^tk4T zS;$HAoMn}C&Ggp>O0m(QYZ?ys%CK-%+ECBK*)Q@K+ne^v6fQ1en%_=?E*xL9Q!Z;} zAy}`(GVJF{bH5mz;yV5$LJ4`A2Bkwo^twF5OoLD{V}dL&-!D&9f)VUbPFaWE36!<4uEC+w)U&P&taq|4G)vQ4q{hxA zz#9z3p??K2Bl^vr^XbA^!5gHuK5>IDv#S#QV1obzAOHafKmY;|fB*y_009U*d_MQeOW;i8Uhf200bZa0SG_<0uX=z1Rwx`nm|%rDTf~eC~ay_ zBKQ$ z#ALZNO#jx%buHS<7B$DEe|PXuRF#Hn!4=miIdpbKM3vUo&ejr9NJ>?uHMNj%hXOhj zRq79qhw~*vM3x&PXS0TBxY;ZPi0}VntR%kw|CN2kO6&yt@OCSRbP#|51Rwwb2tWV= z5P$##AOHafNYbO~h+MlbuieUr_y4iw>To?cy#J4OscY+JYl-O2J+eYwQx6IAr2w^4 z?XL&;_y0;tU6Tmo3^&~Wo15P$##AOHafKmY;|fB*y*wSY>$6VNWc|CiaE zL_gRd009U<00Izz00bZa0SG_<0uWe)0;<}c@c;h5TVnsRpV&6GmaSsli?FJw5(FRs z0SG_<0uX=z1Rwwb2tc5PKr$AQ6W!5Ioik^TJv-kXrK3mG9U1wr-szKvyCZb?R7`p9 z_1{?EsL0{K*N5jeyj{KIMocv-C#K$v{FmyGI!_&&t1h`vwUpWPk;H}LJBt@rzo1;G zek6TcKCwwU`uTUq%IoAKSAU-SM7mbJnmG0F<>`-ltEWGfE`L9Ny0;1p~KPR!P>~D6S{mIU;iwmxU6A*v^1Rwwb2tWV=5P$## zAOHaf+>-+0!wWeY^Ip`b_Y#YEFA>#yQIrUM0N{Q9FD<(#tAIj500Izz00bZa0SG_< z0uX=z1R!vS1jP6Mxc|RHn8*eK5P$##AOHafKmY;|fB*y_aE}UzzyHVm|2^6`C>8`D X009U<00Izz00bZa0SG|g4hj4ZW_tfY From 76a8c4495c131e758690aa75fec332d5b9c294ff Mon Sep 17 00:00:00 2001 From: Vinit Kumar Date: Wed, 8 Apr 2026 15:29:05 +0530 Subject: [PATCH 6/8] Update json2xml/dicttoxml.py Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --- json2xml/dicttoxml.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/json2xml/dicttoxml.py b/json2xml/dicttoxml.py index 854c694..cc2a8f9 100644 --- a/json2xml/dicttoxml.py +++ b/json2xml/dicttoxml.py @@ -797,7 +797,8 @@ def dicttoxml( output = [] namespace_str = "" - xml_namespaces = xml_namespaces or {} + if xml_namespaces is None: + xml_namespaces = {} for prefix in xml_namespaces: if prefix == 'xsi': for schema_att in xml_namespaces[prefix]: From 0a4702fa401e49d756509d474786c5a6cdde81df Mon Sep 17 00:00:00 2001 From: Vinit Kumar Date: Wed, 8 Apr 2026 15:59:45 +0530 Subject: [PATCH 7/8] Align Rust list wrapper semantics with Python --- lat.md/behavior.md | 4 +++- rust/src/lib.rs | 35 ++++++++++++++++++++++++++++++----- tests/test_rust_dicttoxml.py | 6 ++---- 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/lat.md/behavior.md b/lat.md/behavior.md index 3e199c6..c82cfcc 100644 --- a/lat.md/behavior.md +++ b/lat.md/behavior.md @@ -14,6 +14,8 @@ Default output includes an XML declaration, wraps content in `all`, pretty print [[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. 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 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. + ## XPath 3.1 format XPath mode swaps the project-specific XML shape for the W3C `json-to-xml` mapping with typed element names and the XPath functions namespace. @@ -24,4 +26,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. \ No newline at end of file +[[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. diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 30b51da..f800720 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -340,7 +340,14 @@ fn write_dict_contents( // Lists in dicts get special wrapping treatment if let Ok(list) = val.cast::() { - if cfg.item_wrap { + let first_is_scalar = list + .get_item(0) + .ok() + .map(|item| is_python_scalar(&item)) + .unwrap_or(false); + let wrap_list_container = !cfg.list_headers && !(first_is_scalar && !cfg.item_wrap); + + if wrap_list_container { write_open_tag(out, &xml_key, name_attr, type_attr(cfg, "list")); write_list_contents(py, out, list, &xml_key, cfg)?; write_close_tag(out, &xml_key); @@ -354,6 +361,18 @@ fn write_dict_contents( Ok(()) } +/// Return true when a Python object is treated as a primitive scalar by the +/// pure-Python serializer for list-wrapper decisions. +#[cfg(feature = "python")] +#[inline] +fn is_python_scalar(obj: &Bound<'_, PyAny>) -> bool { + obj.is_none() + || obj.is_instance_of::() + || obj.is_instance_of::() + || obj.is_instance_of::() + || obj.is_instance_of::() +} + /// Write all items of a list into the buffer. #[cfg(feature = "python")] fn write_list_contents( @@ -363,7 +382,8 @@ fn write_list_contents( parent: &str, cfg: &ConvertConfig, ) -> PyResult<()> { - let tag_name = if cfg.list_headers { + let scalar_tag_name = if cfg.item_wrap { "item" } else { parent }; + let dict_tag_name = if cfg.list_headers { parent } else if cfg.item_wrap { "item" @@ -375,14 +395,19 @@ fn write_list_contents( // Dicts inside lists have special wrapping logic if let Ok(dict) = item.cast::() { if cfg.item_wrap || cfg.list_headers { - write_open_tag(out, tag_name, None, type_attr(cfg, "dict")); + let dict_type_attr = if cfg.list_headers { + None + } else { + type_attr(cfg, "dict") + }; + write_open_tag(out, dict_tag_name, None, dict_type_attr); write_dict_contents(py, out, dict, cfg)?; - write_close_tag(out, tag_name); + write_close_tag(out, dict_tag_name); } else { write_dict_contents(py, out, dict, cfg)?; } } else { - write_value(py, out, &item, tag_name, None, cfg, true)?; + write_value(py, out, &item, scalar_tag_name, None, cfg, true)?; } } Ok(()) diff --git a/tests/test_rust_dicttoxml.py b/tests/test_rust_dicttoxml.py index 6efbcef..f7489ae 100644 --- a/tests/test_rust_dicttoxml.py +++ b/tests/test_rust_dicttoxml.py @@ -252,7 +252,8 @@ def test_item_wrap_false(self): def test_list_headers(self): data = {"colors": ["red", "green"]} result = rust_dicttoxml(data, list_headers=True) - assert b"red" in result + assert b"green" in result class TestRustVsPythonCompatibility: @@ -334,21 +335,18 @@ def test_item_wrap_false_matches(self): rust, python = self.compare_outputs(data, item_wrap=False) assert rust == python - @pytest.mark.xfail(reason="Rust list_headers implementation differs from Python - uses different wrapping semantics") def test_list_headers_true_matches(self): """Test that list_headers=True produces matching output.""" data = {"items": ["one", "two", "three"]} rust, python = self.compare_outputs(data, list_headers=True) assert rust == python - @pytest.mark.xfail(reason="Rust item_wrap=False with nested dicts differs from Python - known limitation") def test_item_wrap_false_with_nested_dict_matches(self): """Test item_wrap=False with nested dicts in list.""" data = {"users": [{"name": "Alice"}, {"name": "Bob"}]} rust, python = self.compare_outputs(data, item_wrap=False) assert rust == python - @pytest.mark.xfail(reason="Rust list_headers with nested structures differs from Python - known limitation") def test_list_headers_with_nested_matches(self): """Test list_headers=True with nested structures.""" data = {"products": [{"id": 1, "name": "Widget"}, {"id": 2, "name": "Gadget"}]} From 248c9bfd763f80190f2aabe3d644ffd28ca78a30 Mon Sep 17 00:00:00 2001 From: Vinit Kumar Date: Wed, 8 Apr 2026 16:09:33 +0530 Subject: [PATCH 8/8] fix: warnings --- lat.md/tests.md | 14 ++++++- rust/src/lib.rs | 2 +- tests/test_dict2xml.py | 86 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 2 deletions(-) diff --git a/lat.md/tests.md b/lat.md/tests.md index 79838d2..05bd2a4 100644 --- a/lat.md/tests.md +++ b/lat.md/tests.md @@ -28,4 +28,16 @@ XPath mode should emit the W3C XPath functions namespace and typed child element ### Item-wrap false repeats parent tag -Disabling item wrapping should repeat the parent element name for primitive list items instead of producing nested `` tags. \ No newline at end of file +Disabling item wrapping should repeat the parent element name for primitive list items instead of producing nested `` tags. + +### Default xml namespaces stay empty + +Calling `dicttoxml` without `xml_namespaces` should preserve the legacy root output and avoid adding namespace declarations or `xsi:` attributes implicitly. + +### Explicit xml namespaces emit schema attributes + +Supplying namespace prefixes and an `xsi` mapping should emit the expected `xmlns:*` declarations plus supported schema attributes without mutating the caller input. + +### Xml namespace inputs are not mutated across calls + +Reusing one `xml_namespaces` mapping across multiple `dicttoxml` calls should return identical XML each time so namespace declarations never accumulate on the shared dict. diff --git a/rust/src/lib.rs b/rust/src/lib.rs index f800720..bb73436 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -345,7 +345,7 @@ fn write_dict_contents( .ok() .map(|item| is_python_scalar(&item)) .unwrap_or(false); - let wrap_list_container = !cfg.list_headers && !(first_is_scalar && !cfg.item_wrap); + let wrap_list_container = (cfg.item_wrap || !first_is_scalar) && !cfg.list_headers; if wrap_list_container { write_open_tag(out, &xml_key, name_attr, type_attr(cfg, "list")); diff --git a/tests/test_dict2xml.py b/tests/test_dict2xml.py index 42fe188..37dfaee 100644 --- a/tests/test_dict2xml.py +++ b/tests/test_dict2xml.py @@ -500,6 +500,92 @@ def test_dicttoxml_with_xml_namespaces(self) -> None: result = dicttoxml.dicttoxml(data, xml_namespaces=namespaces) assert b'xmlns="http://example.com"' in result + # @lat: [[tests#Conversion behavior#Default xml namespaces stay empty]] + def test_dicttoxml_without_xml_namespaces_keeps_previous_output(self) -> None: + """Test dicttoxml without xml_namespaces keeps the default XML shape.""" + data = {"bike": "blue"} + result = dicttoxml.dicttoxml(data, attr_type=False) + assert ( + b'' + b"blue" == result + ) + assert b"xmlns" not in result + assert b"xsi:" not in result + + # @lat: [[tests#Conversion behavior#Explicit xml namespaces emit schema attributes]] + def test_dicttoxml_with_explicit_xml_namespaces_emits_schema_attributes(self) -> None: + """Test dicttoxml emits explicit namespace declarations and XSI schema attributes.""" + data = {"bike": "blue"} + namespaces = { + "veh": "https://example.com/vehicle", + "xsi": { + "schemaInstance": "http://www.w3.org/2001/XMLSchema-instance", + "schemaLocation": "https://example.com/vehicle vehicle.xsd", + "noNamespaceSchemaLocation": "vehicle-no-namespace.xsd", + }, + } + namespaces_before = { + prefix: value.copy() if isinstance(value, dict) else value + for prefix, value in namespaces.items() + } + + result = dicttoxml.dicttoxml( + data, + custom_root="vehicle", + attr_type=False, + xml_namespaces=namespaces, + ) + + assert ( + b'' + b'' + b"blue" + b"" == result + ) + assert b"xsi:noNamespaceSchemaLocation" not in result + assert namespaces == namespaces_before + + # @lat: [[tests#Conversion behavior#Xml namespace inputs are not mutated across calls]] + def test_dicttoxml_reuses_xml_namespaces_without_mutating_input(self) -> None: + """Test reusing xml_namespaces across calls does not mutate or accumulate state.""" + data = {"bike": "blue"} + namespaces = { + "veh": "https://example.com/vehicle", + "xsi": { + "schemaInstance": "http://www.w3.org/2001/XMLSchema-instance", + "schemaLocation": "https://example.com/vehicle vehicle.xsd", + }, + } + namespaces_before = { + prefix: value.copy() if isinstance(value, dict) else value + for prefix, value in namespaces.items() + } + + first = dicttoxml.dicttoxml( + data, + custom_root="vehicle", + attr_type=False, + xml_namespaces=namespaces, + ) + second = dicttoxml.dicttoxml( + data, + custom_root="vehicle", + attr_type=False, + xml_namespaces=namespaces, + ) + + assert first == second + assert first.count(b'xmlns:veh="https://example.com/vehicle"') == 1 + assert first.count( + b'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"' + ) == 1 + assert first.count( + b'xsi:schemaLocation="https://example.com/vehicle vehicle.xsd"' + ) == 1 + assert namespaces == namespaces_before + def test_datetime_conversion(self) -> None: """Test datetime conversion.""" data = {"key": datetime.datetime(2023, 2, 15, 12, 30, 45)}