json2xml 6.4.0__tar.gz → 6.5.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. {json2xml-6.4.0 → json2xml-6.5.1}/HISTORY.rst +22 -0
  2. {json2xml-6.4.0 → json2xml-6.5.1}/PKG-INFO +17 -2
  3. {json2xml-6.4.0 → json2xml-6.5.1}/README.rst +15 -0
  4. {json2xml-6.4.0 → json2xml-6.5.1}/docs/usage.rst +13 -0
  5. {json2xml-6.4.0 → json2xml-6.5.1}/json2xml/__init__.py +1 -1
  6. {json2xml-6.4.0 → json2xml-6.5.1}/json2xml/dicttoxml.py +212 -33
  7. {json2xml-6.4.0 → json2xml-6.5.1}/json2xml/dicttoxml_fast.py +19 -2
  8. json2xml-6.5.1/json2xml/json2xml.py +83 -0
  9. json2xml-6.5.1/json2xml/utils.py +355 -0
  10. {json2xml-6.4.0 → json2xml-6.5.1}/json2xml.egg-info/PKG-INFO +17 -2
  11. {json2xml-6.4.0 → json2xml-6.5.1}/json2xml.egg-info/requires.txt +1 -1
  12. {json2xml-6.4.0 → json2xml-6.5.1}/pyproject.toml +2 -2
  13. {json2xml-6.4.0 → json2xml-6.5.1}/tests/test_dict2xml.py +141 -0
  14. {json2xml-6.4.0 → json2xml-6.5.1}/tests/test_dicttoxml_fast_fallback.py +16 -0
  15. {json2xml-6.4.0 → json2xml-6.5.1}/tests/test_dicttoxml_unit.py +135 -0
  16. {json2xml-6.4.0 → json2xml-6.5.1}/tests/test_json2xml.py +55 -43
  17. {json2xml-6.4.0 → json2xml-6.5.1}/tests/test_rust_dicttoxml.py +14 -0
  18. json2xml-6.5.1/tests/test_utils.py +798 -0
  19. json2xml-6.4.0/json2xml/json2xml.py +0 -65
  20. json2xml-6.4.0/json2xml/utils.py +0 -87
  21. json2xml-6.4.0/tests/test_utils.py +0 -291
  22. {json2xml-6.4.0 → json2xml-6.5.1}/AUTHORS.rst +0 -0
  23. {json2xml-6.4.0 → json2xml-6.5.1}/CONTRIBUTING.rst +0 -0
  24. {json2xml-6.4.0 → json2xml-6.5.1}/LICENSE +0 -0
  25. {json2xml-6.4.0 → json2xml-6.5.1}/MANIFEST.in +0 -0
  26. {json2xml-6.4.0 → json2xml-6.5.1}/docs/Makefile +0 -0
  27. {json2xml-6.4.0 → json2xml-6.5.1}/docs/authors.rst +0 -0
  28. {json2xml-6.4.0 → json2xml-6.5.1}/docs/benchmarks.rst +0 -0
  29. {json2xml-6.4.0 → json2xml-6.5.1}/docs/conf.py +0 -0
  30. {json2xml-6.4.0 → json2xml-6.5.1}/docs/contributing.rst +0 -0
  31. {json2xml-6.4.0 → json2xml-6.5.1}/docs/history.rst +0 -0
  32. {json2xml-6.4.0 → json2xml-6.5.1}/docs/index.rst +0 -0
  33. {json2xml-6.4.0 → json2xml-6.5.1}/docs/installation.rst +0 -0
  34. {json2xml-6.4.0 → json2xml-6.5.1}/docs/json2xml.rst +0 -0
  35. {json2xml-6.4.0 → json2xml-6.5.1}/docs/make.bat +0 -0
  36. {json2xml-6.4.0 → json2xml-6.5.1}/docs/modules.rst +0 -0
  37. {json2xml-6.4.0 → json2xml-6.5.1}/docs/readme.rst +0 -0
  38. {json2xml-6.4.0 → json2xml-6.5.1}/docs/rust_memory_benchmark.rst +0 -0
  39. {json2xml-6.4.0 → json2xml-6.5.1}/json2xml/backend_selector.py +0 -0
  40. {json2xml-6.4.0 → json2xml-6.5.1}/json2xml/cli.py +0 -0
  41. {json2xml-6.4.0 → json2xml-6.5.1}/json2xml/types.py +0 -0
  42. {json2xml-6.4.0 → json2xml-6.5.1}/json2xml.egg-info/SOURCES.txt +0 -0
  43. {json2xml-6.4.0 → json2xml-6.5.1}/json2xml.egg-info/dependency_links.txt +0 -0
  44. {json2xml-6.4.0 → json2xml-6.5.1}/json2xml.egg-info/entry_points.txt +0 -0
  45. {json2xml-6.4.0 → json2xml-6.5.1}/json2xml.egg-info/top_level.txt +0 -0
  46. {json2xml-6.4.0 → json2xml-6.5.1}/requirements.in +0 -0
  47. {json2xml-6.4.0 → json2xml-6.5.1}/setup.cfg +0 -0
  48. {json2xml-6.4.0 → json2xml-6.5.1}/setup.py +0 -0
  49. {json2xml-6.4.0 → json2xml-6.5.1}/tests/__init__.py +0 -0
  50. {json2xml-6.4.0 → json2xml-6.5.1}/tests/conftest.py +0 -0
  51. {json2xml-6.4.0 → json2xml-6.5.1}/tests/test_backend_selector.py +0 -0
  52. {json2xml-6.4.0 → json2xml-6.5.1}/tests/test_benchmark_multi_python.py +0 -0
  53. {json2xml-6.4.0 → json2xml-6.5.1}/tests/test_cli.py +0 -0
  54. {json2xml-6.4.0 → json2xml-6.5.1}/tests/test_missing_coverage.py +0 -0
@@ -1,4 +1,26 @@
1
1
 
2
+ 6.5.1 / 2026-08-05
3
+ ==================
4
+
5
+ * security: pin URL connections to validated DNS addresses to prevent DNS-rebinding bypasses of private-network blocking
6
+ * security: bound encoded and decoded compressed responses to prevent gzip and deflate resource-exhaustion attacks
7
+ * fix: reject XML 1.0-forbidden characters consistently across Python and Rust serializers instead of emitting invalid XML
8
+ * fix: require an actual boolean for ``allow_private_networks`` and normalize malformed-hostname failures as ``URLReadError``
9
+ * chore: release ``json2xml-rs`` 0.4.3 first and require it from ``json2xml[fast]`` for XML-safe accelerated installs
10
+ * docs: document URL safety controls, XML validation behavior, migration guidance, and the Rust-first release sequence
11
+
12
+
13
+ 6.5.0 / 2026-07-15
14
+ ==================
15
+
16
+ * perf: reduce pure Python serializer time by 31.1% on the deterministic 5,000-record benchmark through exact native-type dispatch
17
+ * perf: release ``json2xml-rs`` 0.4.2 with hybrid SIMD/word XML escape scanning and a 6.23% benchmark reduction
18
+ * fix: preserve numeric, string, dictionary, and sequence subclass behavior across the optimized Python dispatch paths
19
+ * test: require exactly 100% statement coverage from the complete Python suite and add dense Rust XML escape scanning regression coverage
20
+ * chore: require ``json2xml-rs>=0.4.2`` from ``json2xml[fast]`` and validate release wheels before PyPI publication
21
+ * docs: add Python and Rust before/after flamegraphs, buffer-capacity measurements, and release performance notes
22
+
23
+
2
24
  6.4.0 / 2026-07-13
3
25
  ==================
4
26
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: json2xml
3
- Version: 6.4.0
3
+ Version: 6.5.1
4
4
  Summary: Simple Python Library to convert JSON to XML
5
5
  Author-email: Vinit Kumar <mail@vinitkumar.me>
6
6
  License: Apache Software License 2.0
@@ -32,7 +32,7 @@ Requires-Dist: setuptools; extra == "dev"
32
32
  Requires-Dist: pygments>=2.20.0; extra == "dev"
33
33
  Requires-Dist: xmltodict>=0.12.0; extra == "dev"
34
34
  Provides-Extra: fast
35
- Requires-Dist: json2xml-rs>=0.4.1; extra == "fast"
35
+ Requires-Dist: json2xml-rs>=0.4.3; extra == "fast"
36
36
  Dynamic: license-file
37
37
 
38
38
  .. image:: ./json2xml-hero.png
@@ -276,6 +276,21 @@ You can use the json2xml library in the following ways:
276
276
  data = readfromjson("examples/licht.json")
277
277
  print(json2xml.Json2xml(data).to_xml())
278
278
 
279
+ URL reads accept only credential-free HTTP(S), reject redirects and non-public
280
+ destinations by default, pin public connections to their validated DNS address,
281
+ and stop after 10 MiB of encoded or decoded content. Gzip and deflate responses
282
+ honor ``Content-Length`` and are decoded incrementally; other content encodings
283
+ are rejected. Trusted library callers can opt into a private endpoint with the
284
+ boolean ``True`` or choose a smaller limit:
285
+
286
+ .. code-block:: python
287
+
288
+ data = readfromurl(
289
+ "http://127.0.0.1:8000/data.json",
290
+ allow_private_networks=True,
291
+ max_response_bytes=1024 * 1024,
292
+ )
293
+
279
294
 
280
295
  Custom Wrappers and Indentation
281
296
  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@@ -239,6 +239,21 @@ You can use the json2xml library in the following ways:
239
239
  data = readfromjson("examples/licht.json")
240
240
  print(json2xml.Json2xml(data).to_xml())
241
241
 
242
+ URL reads accept only credential-free HTTP(S), reject redirects and non-public
243
+ destinations by default, pin public connections to their validated DNS address,
244
+ and stop after 10 MiB of encoded or decoded content. Gzip and deflate responses
245
+ honor ``Content-Length`` and are decoded incrementally; other content encodings
246
+ are rejected. Trusted library callers can opt into a private endpoint with the
247
+ boolean ``True`` or choose a smaller limit:
248
+
249
+ .. code-block:: python
250
+
251
+ data = readfromurl(
252
+ "http://127.0.0.1:8000/data.json",
253
+ allow_private_networks=True,
254
+ max_response_bytes=1024 * 1024,
255
+ )
256
+
242
257
 
243
258
  Custom Wrappers and Indentation
244
259
  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@@ -32,6 +32,19 @@ Here's how to use each method:
32
32
  data = readfromjson("examples/licht.json")
33
33
  print(json2xml.Json2xml(data).to_xml())
34
34
 
35
+ URL reads accept only credential-free HTTP(S), reject redirects and non-public
36
+ destinations by default, and stop after 10 MiB of decoded content. Trusted
37
+ library callers can explicitly allow a private endpoint while retaining a
38
+ chosen response limit:
39
+
40
+ .. code-block:: python
41
+
42
+ data = readfromurl(
43
+ "http://127.0.0.1:8000/data.json",
44
+ allow_private_networks=True,
45
+ max_response_bytes=1024 * 1024,
46
+ )
47
+
35
48
 
36
49
  Real-world Examples
37
50
  -------------------
@@ -2,4 +2,4 @@
2
2
 
3
3
  __author__ = """Vinit Kumar"""
4
4
  __email__ = "mail@vinitkumar.me"
5
- __version__ = "6.4.0"
5
+ __version__ = "6.5.1"
@@ -79,6 +79,16 @@ ELEMENT = Union[
79
79
  ]
80
80
 
81
81
 
82
+ def _is_number(value: Any) -> bool:
83
+ """Recognize common numbers without paying for abstract-class dispatch."""
84
+ value_type = type(value)
85
+ if value_type is bool:
86
+ return False
87
+ if value_type is int or value_type is float or value_type is complex:
88
+ return True
89
+ return isinstance(value, numbers.Number)
90
+
91
+
82
92
  def get_xml_type(val: Any) -> str:
83
93
  """
84
94
  Get the XML type of a given value.
@@ -100,7 +110,13 @@ def get_xml_type(val: Any) -> str:
100
110
  return "float"
101
111
  if val_type is bool:
102
112
  return "bool"
103
- if isinstance(val, numbers.Number):
113
+ if val_type is dict:
114
+ return "dict"
115
+ if val_type is list or val_type is tuple:
116
+ return "list"
117
+ if isinstance(val, str):
118
+ return "str"
119
+ if _is_number(val):
104
120
  return "number"
105
121
  if isinstance(val, dict):
106
122
  return "dict"
@@ -109,6 +125,28 @@ def get_xml_type(val: Any) -> str:
109
125
  return type(val).__name__
110
126
 
111
127
 
128
+ # @lat: [[behavior#XML output safety]]
129
+ def _validate_xml_chars(value: str) -> None:
130
+ """Reject characters excluded from the XML 1.0 Char production."""
131
+ # XML 1.0 character production: https://www.w3.org/TR/xml/#charsets
132
+ # isprintable() keeps the common path in C; only strings containing a
133
+ # non-printable character need the explicit code-point boundary checks.
134
+ if not value or value.isprintable():
135
+ return
136
+
137
+ for character in value:
138
+ codepoint = ord(character)
139
+ is_forbidden_control = (
140
+ codepoint < 0x20 and codepoint not in (0x09, 0x0A, 0x0D)
141
+ )
142
+ is_surrogate = 0xD800 <= codepoint <= 0xDFFF
143
+ is_bmp_noncharacter = codepoint in (0xFFFE, 0xFFFF)
144
+ if is_forbidden_control or is_surrogate or is_bmp_noncharacter:
145
+ raise ValueError(
146
+ f"Character U+{codepoint:04X} is not allowed in XML 1.0"
147
+ )
148
+
149
+
112
150
  def escape_xml(s: str | int | float | numbers.Number | None) -> str:
113
151
  """
114
152
  Escape a string for use in XML.
@@ -119,15 +157,15 @@ def escape_xml(s: str | int | float | numbers.Number | None) -> str:
119
157
  Returns:
120
158
  str: The escaped string.
121
159
  """
122
- if isinstance(s, str):
123
- if not _XML_ESCAPE_CHARS.intersection(s):
124
- return s
125
- s = s.replace("&", "&amp;")
126
- s = s.replace('"', "&quot;")
127
- s = s.replace("'", "&apos;")
128
- s = s.replace("<", "&lt;")
129
- s = s.replace(">", "&gt;")
130
- return str(s)
160
+ value = s if isinstance(s, str) else str(s)
161
+ _validate_xml_chars(value)
162
+ if _XML_ESCAPE_CHARS.isdisjoint(value):
163
+ return value
164
+ value = value.replace("&", "&amp;")
165
+ value = value.replace('"', "&quot;")
166
+ value = value.replace("'", "&apos;")
167
+ value = value.replace("<", "&lt;")
168
+ return value.replace(">", "&gt;")
131
169
 
132
170
 
133
171
  def make_attrstring(attr: dict[str, Any]) -> str:
@@ -142,12 +180,13 @@ def make_attrstring(attr: dict[str, Any]) -> str:
142
180
  """
143
181
  if not attr:
144
182
  return ""
145
- validate_xml_attr_names(attr)
146
183
  if len(attr) == 1:
147
184
  key, val = next(iter(attr.items()))
148
185
  if key == "type":
149
- return f' type="{val}"'
186
+ return f' type="{escape_xml(val)}"'
187
+ validate_xml_attr_names(attr)
150
188
  return f' {key}="{escape_xml(val)}"'
189
+ validate_xml_attr_names(attr)
151
190
  attrstring = " ".join(f'{k}="{escape_xml(v)}"' for k, v in attr.items())
152
191
  return f" {attrstring}"
153
192
 
@@ -246,8 +285,10 @@ def make_valid_xml_name(key: str, attr: dict[str, Any]) -> tuple[str, dict[str,
246
285
 
247
286
  def wrap_cdata(s: str | int | float | numbers.Number) -> str:
248
287
  """Wraps a string into CDATA sections"""
249
- s = str(s).replace("]]>", "]]]]><![CDATA[>")
250
- return "<![CDATA[" + s + "]]>"
288
+ value = str(s)
289
+ _validate_xml_chars(value)
290
+ value = value.replace("]]>", "]]]]><![CDATA[>")
291
+ return "<![CDATA[" + value + "]]>"
251
292
 
252
293
 
253
294
  def default_item_func(parent: str) -> str:
@@ -277,7 +318,7 @@ def get_xpath31_tag_name(val: Any) -> str:
277
318
  return "boolean"
278
319
  if isinstance(val, dict):
279
320
  return "map"
280
- if isinstance(val, (int, float, numbers.Number)):
321
+ if _is_number(val):
281
322
  return "number"
282
323
  if isinstance(val, str):
283
324
  return "string"
@@ -368,7 +409,7 @@ def convert(
368
409
 
369
410
 
370
411
  def is_primitive_type(val: Any) -> bool:
371
- return val is None or isinstance(val, (str, bool, numbers.Number))
412
+ return val is None or isinstance(val, (str, bool)) or _is_number(val)
372
413
 
373
414
 
374
415
  def dict2xml_str(
@@ -493,12 +534,45 @@ def _append_convert(
493
534
  ) -> None:
494
535
  """Append converted XML directly into output without building subtree strings."""
495
536
  item_name = item_func(parent)
537
+ obj_type = type(obj)
496
538
 
497
- if isinstance(obj, bool):
539
+ # Exact built-ins stay ahead of ABC/subclass checks on this hot path. The
540
+ # later isinstance/_is_number branches intentionally preserve compatible
541
+ # str, numeric, dict, and sequence subclasses without charging native JSON
542
+ # values for abstract dispatch.
543
+ if obj_type is bool:
498
544
  output.write(convert_bool(key=item_name, val=obj, attr_type=attr_type, cdata=cdata))
499
- elif isinstance(obj, numbers.Number):
545
+ elif obj_type is str:
500
546
  output.write(convert_kv(key=item_name, val=obj, attr_type=attr_type, attr={}, cdata=cdata))
501
- elif isinstance(obj, str):
547
+ elif obj_type is int or obj_type is float or obj_type is complex:
548
+ output.write(convert_kv(key=item_name, val=obj, attr_type=attr_type, attr={}, cdata=cdata))
549
+ elif obj is None:
550
+ output.write(convert_none(key=item_name, attr_type=attr_type, cdata=cdata))
551
+ elif obj_type is dict:
552
+ _append_convert_dict(
553
+ output,
554
+ cast("dict[str, Any]", obj),
555
+ ids,
556
+ parent,
557
+ attr_type,
558
+ item_func,
559
+ cdata,
560
+ item_wrap,
561
+ list_headers=list_headers,
562
+ )
563
+ elif obj_type is list or obj_type is tuple:
564
+ _append_convert_list(
565
+ output,
566
+ obj,
567
+ ids,
568
+ parent,
569
+ attr_type,
570
+ item_func,
571
+ cdata,
572
+ item_wrap,
573
+ list_headers=list_headers,
574
+ )
575
+ elif isinstance(obj, str) or _is_number(obj):
502
576
  output.write(convert_kv(key=item_name, val=obj, attr_type=attr_type, attr={}, cdata=cdata))
503
577
  elif hasattr(obj, "isoformat") and isinstance(obj, (datetime.datetime, datetime.date)):
504
578
  output.write(
@@ -510,8 +584,6 @@ def _append_convert(
510
584
  cdata=cdata,
511
585
  )
512
586
  )
513
- elif obj is None:
514
- output.write(convert_none(key=item_name, attr_type=attr_type, cdata=cdata))
515
587
  elif isinstance(obj, dict):
516
588
  _append_convert_dict(
517
589
  output,
@@ -628,9 +700,24 @@ def _append_rawitem(
628
700
  ) -> None:
629
701
  if rawitem is None:
630
702
  return
631
- if isinstance(rawitem, bool):
703
+ rawitem_type = type(rawitem)
704
+ if rawitem_type is bool:
632
705
  output.write("true" if rawitem else "false")
633
- elif isinstance(rawitem, (str, numbers.Number)):
706
+ elif rawitem_type is str or rawitem_type is int or rawitem_type is float or rawitem_type is complex:
707
+ output.write(escape_xml(str(rawitem)))
708
+ elif rawitem_type is dict or rawitem_type is list or rawitem_type is tuple:
709
+ _append_convert(
710
+ output,
711
+ rawitem,
712
+ ids,
713
+ attr_type,
714
+ item_func,
715
+ cdata,
716
+ item_wrap,
717
+ item_name,
718
+ list_headers=list_headers,
719
+ )
720
+ elif isinstance(rawitem, str) or _is_number(rawitem):
634
721
  output.write(escape_xml(str(rawitem)))
635
722
  else:
636
723
  _append_convert(
@@ -708,15 +795,47 @@ def _append_convert_dict(
708
795
  ) -> None:
709
796
  """Append a dict as XML without allocating a joined child subtree."""
710
797
  for key, val in obj.items():
798
+ val_type = type(val)
711
799
  attr = {} if not ids else {"id": f"{get_unique_id(parent)}"}
712
800
  key_is_flat = isinstance(key, str) and key.endswith("@flat")
713
801
  xml_key = key[:-5] if key_is_flat else key
714
802
 
715
803
  key, attr = make_valid_xml_name(xml_key, attr)
716
804
 
717
- if isinstance(val, bool):
805
+ if val_type is bool:
718
806
  output.write(convert_bool_valid_name(key, val, attr_type, attr))
719
- elif isinstance(val, (numbers.Number, str)):
807
+ elif val_type is str or val_type is int or val_type is float or val_type is complex:
808
+ output.write(
809
+ convert_kv_valid_name(
810
+ key=key, val=val, attr_type=attr_type, attr=attr, cdata=cdata
811
+ )
812
+ )
813
+ elif val_type is dict:
814
+ _append_dict2xml_str(
815
+ output,
816
+ attr_type,
817
+ attr,
818
+ val,
819
+ item_func,
820
+ cdata,
821
+ key,
822
+ item_wrap,
823
+ False,
824
+ list_headers=list_headers,
825
+ )
826
+ elif val_type is list or val_type is tuple:
827
+ _append_list2xml_str(
828
+ output,
829
+ attr_type=attr_type,
830
+ attr=attr,
831
+ item=val,
832
+ item_func=item_func,
833
+ cdata=cdata,
834
+ item_name=f"{key}@flat" if key_is_flat else key,
835
+ item_wrap=item_wrap,
836
+ list_headers=list_headers,
837
+ )
838
+ elif isinstance(val, str) or _is_number(val):
720
839
  output.write(
721
840
  convert_kv_valid_name(
722
841
  key=key, val=val, attr_type=attr_type, attr=attr, cdata=cdata
@@ -784,16 +903,58 @@ def _append_convert_list(
784
903
  this_id = get_unique_id(parent) if ids else None
785
904
 
786
905
  for i, item in enumerate(items):
906
+ item_type = type(item)
787
907
  base_attr: dict[str, Any] | None = None
788
908
  if ids:
789
909
  base_attr = {"id": f"{this_id}_{i + 1}"}
790
910
 
791
- if isinstance(item, bool):
911
+ if item_type is bool:
792
912
  attr = dict(base_attr) if base_attr else {}
793
913
  if item_name_attr:
794
914
  attr.update(item_name_attr)
795
915
  output.write(convert_bool_valid_name(item_name, item, attr_type, attr))
796
- elif isinstance(item, (numbers.Number, str)):
916
+ elif item_type is str or item_type is int or item_type is float or item_type is complex:
917
+ attr = dict(base_attr) if base_attr else {}
918
+ if scalar_key_attr:
919
+ attr.update(scalar_key_attr)
920
+ output.write(
921
+ convert_kv_valid_name(
922
+ key=scalar_key,
923
+ val=item,
924
+ attr_type=attr_type,
925
+ attr=attr,
926
+ cdata=cdata,
927
+ )
928
+ )
929
+ elif item_type is dict:
930
+ attr = dict(base_attr) if base_attr else {}
931
+ _append_dict2xml_str(
932
+ output,
933
+ attr_type=attr_type,
934
+ attr=attr,
935
+ item=item,
936
+ item_func=item_func,
937
+ cdata=cdata,
938
+ item_name=item_name,
939
+ item_wrap=item_wrap,
940
+ parentIsList=True,
941
+ parent=parent,
942
+ list_headers=list_headers,
943
+ )
944
+ elif item_type is list or item_type is tuple:
945
+ attr = dict(base_attr) if base_attr else {}
946
+ _append_list2xml_str(
947
+ output,
948
+ attr_type=attr_type,
949
+ attr=attr,
950
+ item=item,
951
+ item_func=item_func,
952
+ cdata=cdata,
953
+ item_name=item_name,
954
+ item_wrap=item_wrap,
955
+ list_headers=list_headers,
956
+ )
957
+ elif isinstance(item, str) or _is_number(item):
797
958
  attr = dict(base_attr) if base_attr else {}
798
959
  if scalar_key_attr:
799
960
  attr.update(scalar_key_attr)
@@ -976,6 +1137,7 @@ class _XPathDocumentRenderer:
976
1137
  return output.to_bytes()
977
1138
 
978
1139
 
1140
+ # @lat: [[behavior#XML output safety]]
979
1141
  class _NamespaceFormatter:
980
1142
  """Keep namespace emission and schema-attribute quirks in one place."""
981
1143
 
@@ -985,21 +1147,38 @@ class _NamespaceFormatter:
985
1147
  return ""
986
1148
 
987
1149
  namespace_parts: list[str] = []
988
- for prefix in xml_namespaces:
1150
+ for prefix, namespace_value in xml_namespaces.items():
989
1151
  if prefix == "xsi":
990
- for schema_att in xml_namespaces[prefix]:
1152
+ if not isinstance(namespace_value, dict):
1153
+ raise ValueError("The xsi namespace value must be a mapping")
1154
+ if (
1155
+ "schemaLocation" in namespace_value
1156
+ and "schemaInstance" not in namespace_value
1157
+ ):
1158
+ raise ValueError(
1159
+ "xsi schemaLocation requires a schemaInstance namespace"
1160
+ )
1161
+ for schema_att in namespace_value:
991
1162
  if schema_att == "schemaInstance":
992
1163
  namespace_parts.append(
993
- f' xmlns:{prefix}="{xml_namespaces[prefix]["schemaInstance"]}"'
1164
+ f' xmlns:{prefix}="{escape_xml(namespace_value[schema_att])}"'
994
1165
  )
995
1166
  elif schema_att == "schemaLocation":
996
1167
  namespace_parts.append(
997
- f' xsi:{schema_att}="{xml_namespaces[prefix][schema_att]}"'
1168
+ f' xsi:{schema_att}="{escape_xml(namespace_value[schema_att])}"'
998
1169
  )
999
1170
  elif prefix == "xmlns":
1000
- namespace_parts.append(f' xmlns="{xml_namespaces[prefix]}"')
1171
+ namespace_parts.append(f' xmlns="{escape_xml(namespace_value)}"')
1001
1172
  else:
1002
- namespace_parts.append(f' xmlns:{prefix}="{xml_namespaces[prefix]}"')
1173
+ if (
1174
+ not isinstance(prefix, str)
1175
+ or prefix.lower().startswith("xml")
1176
+ or not key_is_valid_xml(prefix)
1177
+ ):
1178
+ raise ValueError(f"Invalid XML namespace prefix: {prefix}")
1179
+ namespace_parts.append(
1180
+ f' xmlns:{prefix}="{escape_xml(namespace_value)}"'
1181
+ )
1003
1182
  return "".join(namespace_parts)
1004
1183
 
1005
1184
 
@@ -30,12 +30,29 @@ _rust_dicttoxml: Callable[..., bytes] | None = None
30
30
  rust_escape_xml: RustStringTransform | None = None
31
31
  rust_wrap_cdata: RustStringTransform | None = None
32
32
 
33
+
34
+ def _rejects_invalid_xml(escape: RustStringTransform) -> bool:
35
+ """Return whether an optional backend enforces XML 1.0 characters."""
36
+ try:
37
+ escape("\x00")
38
+ except ValueError:
39
+ return True
40
+ except Exception:
41
+ return False
42
+ return False
43
+
44
+
33
45
  try:
34
46
  from json2xml_rs import dicttoxml as _rust_dicttoxml # pragma: no cover
35
47
  from json2xml_rs import escape_xml_py as rust_escape_xml # pragma: no cover
36
48
  from json2xml_rs import wrap_cdata_py as rust_wrap_cdata # pragma: no cover
37
- _use_rust = True # pragma: no cover
38
- LOG.debug("Using Rust backend for dicttoxml") # pragma: no cover
49
+ if _rejects_invalid_xml(rust_escape_xml): # pragma: no cover
50
+ _use_rust = True # pragma: no cover
51
+ LOG.debug("Using Rust backend for dicttoxml") # pragma: no cover
52
+ else: # pragma: no cover
53
+ LOG.warning( # pragma: no cover
54
+ "Ignoring an outdated Rust backend that permits invalid XML characters"
55
+ )
39
56
  except ImportError: # pragma: no cover
40
57
  LOG.debug("Rust backend not available, using pure Python")
41
58
 
@@ -0,0 +1,83 @@
1
+ from typing import Any
2
+
3
+ __lazy_modules__ = ["defusedxml.minidom", "pyexpat"]
4
+
5
+ from . import dicttoxml_fast as dicttoxml
6
+ from .types import JSONValue
7
+ from .utils import InvalidDataError
8
+
9
+
10
+ # @lat: [[architecture#Core pipeline]]
11
+ class Json2xml:
12
+ """Configure conversion of a decoded JSON value to XML.
13
+
14
+ :param data: The decoded JSON value. ``None`` represents absent input; other falsy values
15
+ are serialized.
16
+ :param wrapper: The root element name used when ``root`` is enabled.
17
+ :param root: Include the XML declaration and root element.
18
+ :param pretty: Reparse and indent the serialized XML, returning text instead of bytes.
19
+ :param attr_type: Add each value's JSON type as an XML attribute.
20
+ :param item_wrap: Wrap list members in ``<item>`` elements.
21
+ :param xpath_format: Emit the W3C XPath 3.1 JSON-to-XML representation.
22
+ :param cdata: Wrap string values in CDATA sections.
23
+ :param list_headers: Repeat the parent element for nested dictionary items in lists.
24
+ """
25
+ def __init__(
26
+ self,
27
+ data: JSONValue = None,
28
+ wrapper: str = "all",
29
+ root: bool = True,
30
+ pretty: bool = True,
31
+ attr_type: bool = True,
32
+ item_wrap: bool = True,
33
+ xpath_format: bool = False,
34
+ cdata: bool = False,
35
+ list_headers: bool = False,
36
+ ):
37
+ self.data = data
38
+ self.pretty = pretty
39
+ self.wrapper = wrapper
40
+ self.attr_type = attr_type
41
+ self.root = root
42
+ self.item_wrap = item_wrap
43
+ self.xpath_format = xpath_format
44
+ self.cdata = cdata
45
+ self.list_headers = list_headers
46
+
47
+ # @lat: [[behavior#Conversion output]]
48
+ # @lat: [[behavior#Invalid XML payloads]]
49
+ def to_xml(self) -> bytes | str | None:
50
+ """Serialize the configured JSON value.
51
+
52
+ :return: Pretty-printed XML text when ``pretty`` is enabled, UTF-8 encoded XML bytes
53
+ otherwise, or ``None`` when the configured data is ``None``.
54
+ :raises InvalidDataError: If serialization rejects the data or pretty-print parsing finds
55
+ malformed XML.
56
+ """
57
+ if self.data is not None:
58
+ try:
59
+ xml_data = dicttoxml.dicttoxml(
60
+ self.data,
61
+ root=self.root,
62
+ custom_root=self.wrapper,
63
+ attr_type=self.attr_type,
64
+ item_wrap=self.item_wrap,
65
+ xpath_format=self.xpath_format,
66
+ cdata=self.cdata,
67
+ list_headers=self.list_headers,
68
+ )
69
+ except ValueError as error:
70
+ raise InvalidDataError from error
71
+ if self.pretty:
72
+ # Keep parser imports off the compact-output path, which returns serializer bytes directly.
73
+ from pyexpat import ExpatError
74
+
75
+ from defusedxml.minidom import parseString
76
+
77
+ try:
78
+ result = parseString(xml_data).toprettyxml(encoding="UTF-8").decode()
79
+ except ExpatError:
80
+ raise InvalidDataError
81
+ return result
82
+ return xml_data
83
+ return None