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.
- {json2xml-6.4.0 → json2xml-6.5.1}/HISTORY.rst +22 -0
- {json2xml-6.4.0 → json2xml-6.5.1}/PKG-INFO +17 -2
- {json2xml-6.4.0 → json2xml-6.5.1}/README.rst +15 -0
- {json2xml-6.4.0 → json2xml-6.5.1}/docs/usage.rst +13 -0
- {json2xml-6.4.0 → json2xml-6.5.1}/json2xml/__init__.py +1 -1
- {json2xml-6.4.0 → json2xml-6.5.1}/json2xml/dicttoxml.py +212 -33
- {json2xml-6.4.0 → json2xml-6.5.1}/json2xml/dicttoxml_fast.py +19 -2
- json2xml-6.5.1/json2xml/json2xml.py +83 -0
- json2xml-6.5.1/json2xml/utils.py +355 -0
- {json2xml-6.4.0 → json2xml-6.5.1}/json2xml.egg-info/PKG-INFO +17 -2
- {json2xml-6.4.0 → json2xml-6.5.1}/json2xml.egg-info/requires.txt +1 -1
- {json2xml-6.4.0 → json2xml-6.5.1}/pyproject.toml +2 -2
- {json2xml-6.4.0 → json2xml-6.5.1}/tests/test_dict2xml.py +141 -0
- {json2xml-6.4.0 → json2xml-6.5.1}/tests/test_dicttoxml_fast_fallback.py +16 -0
- {json2xml-6.4.0 → json2xml-6.5.1}/tests/test_dicttoxml_unit.py +135 -0
- {json2xml-6.4.0 → json2xml-6.5.1}/tests/test_json2xml.py +55 -43
- {json2xml-6.4.0 → json2xml-6.5.1}/tests/test_rust_dicttoxml.py +14 -0
- json2xml-6.5.1/tests/test_utils.py +798 -0
- json2xml-6.4.0/json2xml/json2xml.py +0 -65
- json2xml-6.4.0/json2xml/utils.py +0 -87
- json2xml-6.4.0/tests/test_utils.py +0 -291
- {json2xml-6.4.0 → json2xml-6.5.1}/AUTHORS.rst +0 -0
- {json2xml-6.4.0 → json2xml-6.5.1}/CONTRIBUTING.rst +0 -0
- {json2xml-6.4.0 → json2xml-6.5.1}/LICENSE +0 -0
- {json2xml-6.4.0 → json2xml-6.5.1}/MANIFEST.in +0 -0
- {json2xml-6.4.0 → json2xml-6.5.1}/docs/Makefile +0 -0
- {json2xml-6.4.0 → json2xml-6.5.1}/docs/authors.rst +0 -0
- {json2xml-6.4.0 → json2xml-6.5.1}/docs/benchmarks.rst +0 -0
- {json2xml-6.4.0 → json2xml-6.5.1}/docs/conf.py +0 -0
- {json2xml-6.4.0 → json2xml-6.5.1}/docs/contributing.rst +0 -0
- {json2xml-6.4.0 → json2xml-6.5.1}/docs/history.rst +0 -0
- {json2xml-6.4.0 → json2xml-6.5.1}/docs/index.rst +0 -0
- {json2xml-6.4.0 → json2xml-6.5.1}/docs/installation.rst +0 -0
- {json2xml-6.4.0 → json2xml-6.5.1}/docs/json2xml.rst +0 -0
- {json2xml-6.4.0 → json2xml-6.5.1}/docs/make.bat +0 -0
- {json2xml-6.4.0 → json2xml-6.5.1}/docs/modules.rst +0 -0
- {json2xml-6.4.0 → json2xml-6.5.1}/docs/readme.rst +0 -0
- {json2xml-6.4.0 → json2xml-6.5.1}/docs/rust_memory_benchmark.rst +0 -0
- {json2xml-6.4.0 → json2xml-6.5.1}/json2xml/backend_selector.py +0 -0
- {json2xml-6.4.0 → json2xml-6.5.1}/json2xml/cli.py +0 -0
- {json2xml-6.4.0 → json2xml-6.5.1}/json2xml/types.py +0 -0
- {json2xml-6.4.0 → json2xml-6.5.1}/json2xml.egg-info/SOURCES.txt +0 -0
- {json2xml-6.4.0 → json2xml-6.5.1}/json2xml.egg-info/dependency_links.txt +0 -0
- {json2xml-6.4.0 → json2xml-6.5.1}/json2xml.egg-info/entry_points.txt +0 -0
- {json2xml-6.4.0 → json2xml-6.5.1}/json2xml.egg-info/top_level.txt +0 -0
- {json2xml-6.4.0 → json2xml-6.5.1}/requirements.in +0 -0
- {json2xml-6.4.0 → json2xml-6.5.1}/setup.cfg +0 -0
- {json2xml-6.4.0 → json2xml-6.5.1}/setup.py +0 -0
- {json2xml-6.4.0 → json2xml-6.5.1}/tests/__init__.py +0 -0
- {json2xml-6.4.0 → json2xml-6.5.1}/tests/conftest.py +0 -0
- {json2xml-6.4.0 → json2xml-6.5.1}/tests/test_backend_selector.py +0 -0
- {json2xml-6.4.0 → json2xml-6.5.1}/tests/test_benchmark_multi_python.py +0 -0
- {json2xml-6.4.0 → json2xml-6.5.1}/tests/test_cli.py +0 -0
- {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.
|
|
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.
|
|
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
|
-------------------
|
|
@@ -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
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
return
|
|
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("&", "&")
|
|
165
|
+
value = value.replace('"', """)
|
|
166
|
+
value = value.replace("'", "'")
|
|
167
|
+
value = value.replace("<", "<")
|
|
168
|
+
return value.replace(">", ">")
|
|
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
|
-
|
|
250
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
703
|
+
rawitem_type = type(rawitem)
|
|
704
|
+
if rawitem_type is bool:
|
|
632
705
|
output.write("true" if rawitem else "false")
|
|
633
|
-
elif
|
|
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
|
|
805
|
+
if val_type is bool:
|
|
718
806
|
output.write(convert_bool_valid_name(key, val, attr_type, attr))
|
|
719
|
-
elif
|
|
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
|
|
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
|
|
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
|
-
|
|
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}="{
|
|
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}="{
|
|
1168
|
+
f' xsi:{schema_att}="{escape_xml(namespace_value[schema_att])}"'
|
|
998
1169
|
)
|
|
999
1170
|
elif prefix == "xmlns":
|
|
1000
|
-
namespace_parts.append(f' xmlns="{
|
|
1171
|
+
namespace_parts.append(f' xmlns="{escape_xml(namespace_value)}"')
|
|
1001
1172
|
else:
|
|
1002
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|