json2xml 6.3.0__tar.gz → 6.4.0__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.3.0 → json2xml-6.4.0}/CONTRIBUTING.rst +1 -1
- {json2xml-6.3.0 → json2xml-6.4.0}/HISTORY.rst +8 -0
- {json2xml-6.3.0 → json2xml-6.4.0}/PKG-INFO +2 -2
- {json2xml-6.3.0 → json2xml-6.4.0}/json2xml/__init__.py +1 -1
- json2xml-6.4.0/json2xml/backend_selector.py +63 -0
- {json2xml-6.3.0 → json2xml-6.4.0}/json2xml/cli.py +148 -131
- {json2xml-6.3.0 → json2xml-6.4.0}/json2xml/dicttoxml.py +138 -73
- {json2xml-6.3.0 → json2xml-6.4.0}/json2xml/dicttoxml_fast.py +81 -55
- {json2xml-6.3.0 → json2xml-6.4.0}/json2xml.egg-info/PKG-INFO +2 -2
- {json2xml-6.3.0 → json2xml-6.4.0}/json2xml.egg-info/SOURCES.txt +3 -0
- {json2xml-6.3.0 → json2xml-6.4.0}/json2xml.egg-info/requires.txt +1 -1
- {json2xml-6.3.0 → json2xml-6.4.0}/pyproject.toml +2 -2
- json2xml-6.4.0/setup.cfg +11 -0
- json2xml-6.4.0/tests/test_backend_selector.py +44 -0
- json2xml-6.4.0/tests/test_benchmark_multi_python.py +35 -0
- {json2xml-6.3.0 → json2xml-6.4.0}/tests/test_cli.py +35 -1
- {json2xml-6.3.0 → json2xml-6.4.0}/tests/test_dict2xml.py +1 -7
- {json2xml-6.3.0 → json2xml-6.4.0}/tests/test_json2xml.py +0 -1
- {json2xml-6.3.0 → json2xml-6.4.0}/tests/test_missing_coverage.py +1 -51
- {json2xml-6.3.0 → json2xml-6.4.0}/tests/test_utils.py +0 -5
- json2xml-6.3.0/setup.cfg +0 -31
- {json2xml-6.3.0 → json2xml-6.4.0}/AUTHORS.rst +0 -0
- {json2xml-6.3.0 → json2xml-6.4.0}/LICENSE +0 -0
- {json2xml-6.3.0 → json2xml-6.4.0}/MANIFEST.in +0 -0
- {json2xml-6.3.0 → json2xml-6.4.0}/README.rst +0 -0
- {json2xml-6.3.0 → json2xml-6.4.0}/docs/Makefile +0 -0
- {json2xml-6.3.0 → json2xml-6.4.0}/docs/authors.rst +0 -0
- {json2xml-6.3.0 → json2xml-6.4.0}/docs/benchmarks.rst +0 -0
- {json2xml-6.3.0 → json2xml-6.4.0}/docs/conf.py +0 -0
- {json2xml-6.3.0 → json2xml-6.4.0}/docs/contributing.rst +0 -0
- {json2xml-6.3.0 → json2xml-6.4.0}/docs/history.rst +0 -0
- {json2xml-6.3.0 → json2xml-6.4.0}/docs/index.rst +0 -0
- {json2xml-6.3.0 → json2xml-6.4.0}/docs/installation.rst +0 -0
- {json2xml-6.3.0 → json2xml-6.4.0}/docs/json2xml.rst +0 -0
- {json2xml-6.3.0 → json2xml-6.4.0}/docs/make.bat +0 -0
- {json2xml-6.3.0 → json2xml-6.4.0}/docs/modules.rst +0 -0
- {json2xml-6.3.0 → json2xml-6.4.0}/docs/readme.rst +0 -0
- {json2xml-6.3.0 → json2xml-6.4.0}/docs/rust_memory_benchmark.rst +0 -0
- {json2xml-6.3.0 → json2xml-6.4.0}/docs/usage.rst +0 -0
- {json2xml-6.3.0 → json2xml-6.4.0}/json2xml/json2xml.py +0 -0
- {json2xml-6.3.0 → json2xml-6.4.0}/json2xml/types.py +0 -0
- {json2xml-6.3.0 → json2xml-6.4.0}/json2xml/utils.py +0 -0
- {json2xml-6.3.0 → json2xml-6.4.0}/json2xml.egg-info/dependency_links.txt +0 -0
- {json2xml-6.3.0 → json2xml-6.4.0}/json2xml.egg-info/entry_points.txt +0 -0
- {json2xml-6.3.0 → json2xml-6.4.0}/json2xml.egg-info/top_level.txt +0 -0
- {json2xml-6.3.0 → json2xml-6.4.0}/requirements.in +0 -0
- {json2xml-6.3.0 → json2xml-6.4.0}/setup.py +0 -0
- {json2xml-6.3.0 → json2xml-6.4.0}/tests/__init__.py +0 -0
- {json2xml-6.3.0 → json2xml-6.4.0}/tests/conftest.py +0 -0
- {json2xml-6.3.0 → json2xml-6.4.0}/tests/test_dicttoxml_fast_fallback.py +0 -0
- {json2xml-6.3.0 → json2xml-6.4.0}/tests/test_dicttoxml_unit.py +0 -0
- {json2xml-6.3.0 → json2xml-6.4.0}/tests/test_rust_dicttoxml.py +0 -0
|
@@ -186,7 +186,7 @@ A reminder for the maintainers on how to deploy.
|
|
|
186
186
|
Make sure all your changes are committed (including an entry in HISTORY.rst).
|
|
187
187
|
Then run::
|
|
188
188
|
|
|
189
|
-
|
|
189
|
+
Update the version in pyproject.toml and json2xml/__init__.py
|
|
190
190
|
$ git push
|
|
191
191
|
$ git push --tags
|
|
192
192
|
|
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
|
|
2
|
+
6.4.0 / 2026-07-13
|
|
3
|
+
==================
|
|
4
|
+
|
|
5
|
+
* perf: buffer Rust serializer writes in bounded 16 KiB chunks, reducing large-payload conversion time while keeping serializer memory near 80 MiB
|
|
6
|
+
* chore: release ``json2xml-rs`` 0.4.1 and require it from ``json2xml[fast]`` for accelerated installs
|
|
7
|
+
* docs: record before-and-after Rust buffering benchmarks and the unchanged XML output size
|
|
8
|
+
|
|
9
|
+
|
|
2
10
|
6.3.0 / 2026-06-10
|
|
3
11
|
==================
|
|
4
12
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: json2xml
|
|
3
|
-
Version: 6.
|
|
3
|
+
Version: 6.4.0
|
|
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.1; extra == "fast"
|
|
36
36
|
Dynamic: license-file
|
|
37
37
|
|
|
38
38
|
.. image:: ./json2xml-hero.png
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Protocol
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True, slots=True)
|
|
8
|
+
class ConversionRequest:
|
|
9
|
+
"""Normalized conversion request shared across backend adapters."""
|
|
10
|
+
|
|
11
|
+
obj: Any
|
|
12
|
+
root: bool
|
|
13
|
+
custom_root: str
|
|
14
|
+
ids: list[int] | None
|
|
15
|
+
attr_type: bool
|
|
16
|
+
item_wrap: bool
|
|
17
|
+
item_func: Any
|
|
18
|
+
cdata: bool
|
|
19
|
+
xml_namespaces: dict[str, Any] | None
|
|
20
|
+
list_headers: bool
|
|
21
|
+
xpath_format: bool
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BackendAdapter(Protocol):
|
|
25
|
+
"""Small adapter seam for conversion backends."""
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def name(self) -> str:
|
|
29
|
+
raise NotImplementedError # pragma: no cover
|
|
30
|
+
|
|
31
|
+
def can_handle(self, request: ConversionRequest) -> bool:
|
|
32
|
+
raise NotImplementedError # pragma: no cover
|
|
33
|
+
|
|
34
|
+
def render(self, request: ConversionRequest) -> bytes:
|
|
35
|
+
raise NotImplementedError # pragma: no cover
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class BackendSelector:
|
|
39
|
+
"""Pick the first backend that can preserve request semantics."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, *backends: BackendAdapter) -> None:
|
|
42
|
+
self._backends = backends
|
|
43
|
+
|
|
44
|
+
def render(self, request: ConversionRequest) -> bytes:
|
|
45
|
+
for backend in self._backends:
|
|
46
|
+
if backend.can_handle(request):
|
|
47
|
+
return backend.render(request)
|
|
48
|
+
raise RuntimeError("No XML backend can handle the requested conversion")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def has_special_keys(obj: Any) -> bool:
|
|
52
|
+
"""Return True when the payload uses Python-only special key semantics."""
|
|
53
|
+
if isinstance(obj, dict):
|
|
54
|
+
return any(
|
|
55
|
+
(isinstance(key, str) and (key.startswith("@") or key.endswith("@flat")))
|
|
56
|
+
or has_special_keys(value)
|
|
57
|
+
for key, value in obj.items()
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
if isinstance(obj, list):
|
|
61
|
+
return any(has_special_keys(item) for item in obj)
|
|
62
|
+
|
|
63
|
+
return False
|
|
@@ -43,6 +43,7 @@ from __future__ import annotations
|
|
|
43
43
|
|
|
44
44
|
import argparse
|
|
45
45
|
import sys
|
|
46
|
+
from dataclasses import dataclass
|
|
46
47
|
from pathlib import Path
|
|
47
48
|
from typing import NoReturn
|
|
48
49
|
|
|
@@ -62,12 +63,142 @@ AUTHOR = "Vinit Kumar"
|
|
|
62
63
|
EMAIL = "mail@vinitkumar.me"
|
|
63
64
|
|
|
64
65
|
|
|
66
|
+
@dataclass(frozen=True, slots=True)
|
|
67
|
+
class CLIConversionOptions:
|
|
68
|
+
"""Parsed CLI options normalized for the conversion workflow."""
|
|
69
|
+
|
|
70
|
+
input_file: str | None
|
|
71
|
+
url: str | None
|
|
72
|
+
string: str | None
|
|
73
|
+
output: str | None
|
|
74
|
+
wrapper: str
|
|
75
|
+
root: bool
|
|
76
|
+
pretty: bool
|
|
77
|
+
attr_type: bool
|
|
78
|
+
item_wrap: bool
|
|
79
|
+
xpath_format: bool
|
|
80
|
+
cdata: bool
|
|
81
|
+
list_headers: bool
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def from_namespace(cls, args: argparse.Namespace) -> "CLIConversionOptions":
|
|
85
|
+
return cls(
|
|
86
|
+
input_file=args.input_file,
|
|
87
|
+
url=args.url,
|
|
88
|
+
string=args.string,
|
|
89
|
+
output=args.output,
|
|
90
|
+
wrapper=args.wrapper,
|
|
91
|
+
root=args.root,
|
|
92
|
+
pretty=args.pretty,
|
|
93
|
+
attr_type=args.attr_type,
|
|
94
|
+
item_wrap=args.item_wrap,
|
|
95
|
+
xpath_format=args.xpath_format,
|
|
96
|
+
cdata=args.cdata,
|
|
97
|
+
list_headers=args.list_headers,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
65
101
|
def exit_with_error(message: str) -> NoReturn:
|
|
66
102
|
"""Print an error message and terminate CLI processing."""
|
|
67
103
|
print(message, file=sys.stderr)
|
|
68
104
|
raise SystemExit(1)
|
|
69
105
|
|
|
70
106
|
|
|
107
|
+
class CLIApplication:
|
|
108
|
+
"""Thin command adapter around input resolution, conversion, and output."""
|
|
109
|
+
|
|
110
|
+
def read_input(self, options: CLIConversionOptions) -> JSONValue:
|
|
111
|
+
if options.url:
|
|
112
|
+
try:
|
|
113
|
+
return readfromurl(options.url)
|
|
114
|
+
except URLReadError as error:
|
|
115
|
+
exit_with_error(f"Error reading from URL: {error}")
|
|
116
|
+
|
|
117
|
+
if options.string:
|
|
118
|
+
try:
|
|
119
|
+
return readfromstring(options.string)
|
|
120
|
+
except StringReadError as error:
|
|
121
|
+
exit_with_error(
|
|
122
|
+
"Error: Invalid JSON in --string input. "
|
|
123
|
+
"Pass a valid JSON object, array, string, number, boolean, or null. "
|
|
124
|
+
f"({error})"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
if options.input_file:
|
|
128
|
+
if options.input_file == "-":
|
|
129
|
+
return read_from_stdin()
|
|
130
|
+
if not Path(options.input_file).is_file():
|
|
131
|
+
exit_with_error(
|
|
132
|
+
f"Error: JSON file not found: {options.input_file}. "
|
|
133
|
+
"Check the path or use - to read JSON from stdin."
|
|
134
|
+
)
|
|
135
|
+
try:
|
|
136
|
+
return readfromjson(options.input_file)
|
|
137
|
+
except JSONReadError as error:
|
|
138
|
+
exit_with_error(
|
|
139
|
+
f"Error: Could not parse JSON file: {options.input_file}. "
|
|
140
|
+
f"Check that the file contains valid JSON. ({error})"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
if not sys.stdin.isatty():
|
|
144
|
+
return read_from_stdin()
|
|
145
|
+
|
|
146
|
+
exit_with_error(
|
|
147
|
+
"Error: No input provided. Pass a JSON file, use - for stdin, "
|
|
148
|
+
"or provide --string/--url."
|
|
149
|
+
)
|
|
150
|
+
raise AssertionError("unreachable")
|
|
151
|
+
|
|
152
|
+
def read_from_stdin(self) -> JSONValue:
|
|
153
|
+
try:
|
|
154
|
+
json_str = sys.stdin.read().strip()
|
|
155
|
+
if not json_str:
|
|
156
|
+
exit_with_error(
|
|
157
|
+
"Error: Empty stdin. Pipe JSON into stdin or pass a file/--string."
|
|
158
|
+
)
|
|
159
|
+
return readfromstring(json_str)
|
|
160
|
+
except StringReadError as error:
|
|
161
|
+
exit_with_error(
|
|
162
|
+
"Error: Invalid JSON from stdin. Pipe valid JSON into stdin "
|
|
163
|
+
f"or pass a file/--string. ({error})"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def convert(self, data: JSONValue, options: CLIConversionOptions) -> str | bytes:
|
|
167
|
+
converter = Json2xml(
|
|
168
|
+
data=data,
|
|
169
|
+
wrapper=options.wrapper,
|
|
170
|
+
root=options.root,
|
|
171
|
+
pretty=options.pretty,
|
|
172
|
+
attr_type=options.attr_type,
|
|
173
|
+
item_wrap=options.item_wrap,
|
|
174
|
+
xpath_format=options.xpath_format,
|
|
175
|
+
cdata=options.cdata,
|
|
176
|
+
list_headers=options.list_headers,
|
|
177
|
+
)
|
|
178
|
+
xml_output = converter.to_xml()
|
|
179
|
+
if xml_output is None:
|
|
180
|
+
raise ValueError("Empty data, no XML generated")
|
|
181
|
+
return xml_output
|
|
182
|
+
|
|
183
|
+
def write_output(self, output: str | bytes, output_file: str | None) -> None:
|
|
184
|
+
if isinstance(output, bytes):
|
|
185
|
+
output = output.decode("utf-8")
|
|
186
|
+
|
|
187
|
+
if output_file:
|
|
188
|
+
try:
|
|
189
|
+
with open(output_file, "w", encoding="utf-8") as file_obj:
|
|
190
|
+
file_obj.write(output)
|
|
191
|
+
except OSError as error:
|
|
192
|
+
print(f"Error writing to file: {error}", file=sys.stderr)
|
|
193
|
+
sys.exit(1)
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
print(output)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
_APP = CLIApplication()
|
|
200
|
+
|
|
201
|
+
|
|
71
202
|
# @lat: [[architecture#CLI entrypoint]]
|
|
72
203
|
def create_parser() -> argparse.ArgumentParser:
|
|
73
204
|
"""Create and configure the argument parser."""
|
|
@@ -238,153 +369,39 @@ Examples:
|
|
|
238
369
|
|
|
239
370
|
|
|
240
371
|
# @lat: [[behavior#Input readers]]
|
|
241
|
-
def read_input(args: argparse.Namespace) -> JSONValue:
|
|
242
|
-
"""
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
Priority: URL > String > File > Stdin
|
|
246
|
-
|
|
247
|
-
Args:
|
|
248
|
-
args: Parsed command line arguments.
|
|
249
|
-
|
|
250
|
-
Returns:
|
|
251
|
-
Parsed JSON data as dict or list.
|
|
252
|
-
|
|
253
|
-
Raises:
|
|
254
|
-
SystemExit: When no input is provided or reading fails.
|
|
255
|
-
"""
|
|
256
|
-
# Priority: URL > String > File > Stdin
|
|
257
|
-
if args.url:
|
|
258
|
-
try:
|
|
259
|
-
return readfromurl(args.url)
|
|
260
|
-
except URLReadError as e:
|
|
261
|
-
exit_with_error(f"Error reading from URL: {e}")
|
|
262
|
-
|
|
263
|
-
if args.string:
|
|
264
|
-
try:
|
|
265
|
-
return readfromstring(args.string)
|
|
266
|
-
except StringReadError as e:
|
|
267
|
-
exit_with_error(
|
|
268
|
-
"Error: Invalid JSON in --string input. "
|
|
269
|
-
f"Pass a valid JSON object, array, string, number, boolean, or null. ({e})"
|
|
270
|
-
)
|
|
271
|
-
|
|
272
|
-
if args.input_file:
|
|
273
|
-
if args.input_file == "-":
|
|
274
|
-
# Read from stdin
|
|
275
|
-
return read_from_stdin()
|
|
276
|
-
if not Path(args.input_file).is_file():
|
|
277
|
-
exit_with_error(
|
|
278
|
-
f"Error: JSON file not found: {args.input_file}. "
|
|
279
|
-
"Check the path or use - to read JSON from stdin."
|
|
280
|
-
)
|
|
281
|
-
try:
|
|
282
|
-
return readfromjson(args.input_file)
|
|
283
|
-
except JSONReadError as e:
|
|
284
|
-
exit_with_error(
|
|
285
|
-
f"Error: Could not parse JSON file: {args.input_file}. "
|
|
286
|
-
f"Check that the file contains valid JSON. ({e})"
|
|
287
|
-
)
|
|
288
|
-
|
|
289
|
-
# Check if there's data on stdin
|
|
290
|
-
if not sys.stdin.isatty():
|
|
291
|
-
return read_from_stdin()
|
|
292
|
-
|
|
293
|
-
exit_with_error(
|
|
294
|
-
"Error: No input provided. Pass a JSON file, use - for stdin, "
|
|
295
|
-
"or provide --string/--url."
|
|
296
|
-
)
|
|
372
|
+
def read_input(args: argparse.Namespace | CLIConversionOptions) -> JSONValue:
|
|
373
|
+
"""Read JSON input from the specified source."""
|
|
374
|
+
options = args if isinstance(args, CLIConversionOptions) else CLIConversionOptions.from_namespace(args)
|
|
375
|
+
return _APP.read_input(options)
|
|
297
376
|
|
|
298
377
|
|
|
299
378
|
def read_from_stdin() -> JSONValue:
|
|
300
|
-
"""
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
Returns:
|
|
304
|
-
Parsed JSON data.
|
|
305
|
-
|
|
306
|
-
Raises:
|
|
307
|
-
SystemExit: When stdin is empty or contains invalid JSON.
|
|
308
|
-
"""
|
|
309
|
-
try:
|
|
310
|
-
json_str = sys.stdin.read().strip()
|
|
311
|
-
if not json_str:
|
|
312
|
-
exit_with_error(
|
|
313
|
-
"Error: Empty stdin. Pipe JSON into stdin or pass a file/--string."
|
|
314
|
-
)
|
|
315
|
-
return readfromstring(json_str)
|
|
316
|
-
except StringReadError as e:
|
|
317
|
-
exit_with_error(
|
|
318
|
-
"Error: Invalid JSON from stdin. Pipe valid JSON into stdin "
|
|
319
|
-
f"or pass a file/--string. ({e})"
|
|
320
|
-
)
|
|
379
|
+
"""Read JSON from standard input."""
|
|
380
|
+
return _APP.read_from_stdin()
|
|
321
381
|
|
|
322
382
|
|
|
323
383
|
def write_output(output: str | bytes, output_file: str | None) -> None:
|
|
324
|
-
"""
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
Args:
|
|
328
|
-
output: XML content to write.
|
|
329
|
-
output_file: Path to output file, or None for stdout.
|
|
330
|
-
"""
|
|
331
|
-
if isinstance(output, bytes):
|
|
332
|
-
output = output.decode("utf-8")
|
|
333
|
-
|
|
334
|
-
if output_file:
|
|
335
|
-
try:
|
|
336
|
-
with open(output_file, "w", encoding="utf-8") as f:
|
|
337
|
-
f.write(output)
|
|
338
|
-
except OSError as e:
|
|
339
|
-
print(f"Error writing to file: {e}", file=sys.stderr)
|
|
340
|
-
sys.exit(1)
|
|
341
|
-
else:
|
|
342
|
-
print(output)
|
|
384
|
+
"""Write XML output to the specified destination."""
|
|
385
|
+
_APP.write_output(output, output_file)
|
|
343
386
|
|
|
344
387
|
|
|
345
388
|
def main(argv: list[str] | None = None) -> int:
|
|
346
|
-
"""
|
|
347
|
-
Main entry point for the CLI.
|
|
348
|
-
|
|
349
|
-
Args:
|
|
350
|
-
argv: Command line arguments (defaults to sys.argv[1:]).
|
|
351
|
-
|
|
352
|
-
Returns:
|
|
353
|
-
Exit code (0 for success, 1 for error).
|
|
354
|
-
"""
|
|
389
|
+
"""Main entry point for the CLI."""
|
|
355
390
|
parser = create_parser()
|
|
356
391
|
args = parser.parse_args(argv)
|
|
392
|
+
options = CLIConversionOptions.from_namespace(args)
|
|
357
393
|
|
|
358
|
-
# Read input data
|
|
359
394
|
try:
|
|
360
|
-
data = read_input(
|
|
361
|
-
except Exception as
|
|
362
|
-
print(f"Error reading input: {
|
|
395
|
+
data = read_input(options)
|
|
396
|
+
except Exception as error:
|
|
397
|
+
print(f"Error reading input: {error}", file=sys.stderr)
|
|
363
398
|
return 1
|
|
364
399
|
|
|
365
|
-
# Convert to XML
|
|
366
400
|
try:
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
pretty=args.pretty,
|
|
372
|
-
attr_type=args.attr_type,
|
|
373
|
-
item_wrap=args.item_wrap,
|
|
374
|
-
xpath_format=args.xpath_format,
|
|
375
|
-
cdata=args.cdata,
|
|
376
|
-
list_headers=args.list_headers,
|
|
377
|
-
)
|
|
378
|
-
xml_output = converter.to_xml()
|
|
379
|
-
|
|
380
|
-
if xml_output is None:
|
|
381
|
-
print("Error: Empty data, no XML generated", file=sys.stderr)
|
|
382
|
-
return 1
|
|
383
|
-
|
|
384
|
-
write_output(xml_output, args.output)
|
|
385
|
-
|
|
386
|
-
except Exception as e:
|
|
387
|
-
print(f"Error converting to XML: {e}", file=sys.stderr)
|
|
401
|
+
xml_output = _APP.convert(data, options)
|
|
402
|
+
write_output(xml_output, options.output)
|
|
403
|
+
except Exception as error:
|
|
404
|
+
print(f"Error converting to XML: {error}", file=sys.stderr)
|
|
388
405
|
return 1
|
|
389
406
|
|
|
390
407
|
return 0
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import datetime
|
|
4
|
-
import logging
|
|
5
4
|
import numbers
|
|
6
5
|
from collections.abc import Callable, Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
7
|
from decimal import Decimal
|
|
8
8
|
from fractions import Fraction
|
|
9
9
|
from functools import lru_cache
|
|
@@ -16,8 +16,6 @@ __lazy_modules__ = ["defusedxml.minidom"]
|
|
|
16
16
|
# Create a safe random number generator
|
|
17
17
|
_SAFE_RANDOM = SystemRandom()
|
|
18
18
|
|
|
19
|
-
# Set up logging
|
|
20
|
-
LOG = logging.getLogger("dicttoxml")
|
|
21
19
|
_XML_ESCAPE_CHARS = frozenset("&\"'<>")
|
|
22
20
|
|
|
23
21
|
|
|
@@ -53,24 +51,15 @@ def make_id(element: str, start: int = 100000, end: int = 999999) -> str:
|
|
|
53
51
|
|
|
54
52
|
def get_unique_id(element: str) -> str:
|
|
55
53
|
"""
|
|
56
|
-
Generate
|
|
54
|
+
Generate an ID for a given element.
|
|
57
55
|
|
|
58
56
|
Args:
|
|
59
57
|
element (str): The element to generate an ID for.
|
|
60
58
|
|
|
61
59
|
Returns:
|
|
62
|
-
str: The
|
|
60
|
+
str: The generated ID.
|
|
63
61
|
"""
|
|
64
|
-
|
|
65
|
-
this_id = make_id(element)
|
|
66
|
-
dup = True
|
|
67
|
-
while dup:
|
|
68
|
-
if this_id not in ids:
|
|
69
|
-
dup = False
|
|
70
|
-
ids.append(this_id)
|
|
71
|
-
else: # pragma: no cover
|
|
72
|
-
this_id = make_id(element)
|
|
73
|
-
return ids[-1]
|
|
62
|
+
return make_id(element)
|
|
74
63
|
|
|
75
64
|
|
|
76
65
|
ELEMENT = Union[
|
|
@@ -951,6 +940,127 @@ def convert_none_valid_name(
|
|
|
951
940
|
return f"<{key}{attr_string}></{key}>"
|
|
952
941
|
|
|
953
942
|
|
|
943
|
+
@dataclass(frozen=True, slots=True)
|
|
944
|
+
class SerializerConfig:
|
|
945
|
+
"""Normalized options for the pure Python serializer engine."""
|
|
946
|
+
|
|
947
|
+
obj: ELEMENT
|
|
948
|
+
root: bool
|
|
949
|
+
custom_root: str
|
|
950
|
+
ids: list[int] | None
|
|
951
|
+
attr_type: bool
|
|
952
|
+
item_wrap: bool
|
|
953
|
+
item_func: Callable[[str], str]
|
|
954
|
+
cdata: bool
|
|
955
|
+
xml_namespaces: dict[str, Any] | None
|
|
956
|
+
list_headers: bool
|
|
957
|
+
xpath_format: bool
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
class _XPathDocumentRenderer:
|
|
961
|
+
"""Render the W3C XPath 3.1 JSON-to-XML document shape."""
|
|
962
|
+
|
|
963
|
+
def __init__(self, config: SerializerConfig) -> None:
|
|
964
|
+
self._config = config
|
|
965
|
+
|
|
966
|
+
def render(self) -> bytes:
|
|
967
|
+
output = _XMLWriter()
|
|
968
|
+
output.write('<?xml version="1.0" encoding="UTF-8" ?>')
|
|
969
|
+
tag_name = get_xpath31_tag_name(self._config.obj)
|
|
970
|
+
if tag_name in {"map", "array"}:
|
|
971
|
+
_append_xpath31(output, self._config.obj, namespace=True)
|
|
972
|
+
else:
|
|
973
|
+
output.write(f'<map xmlns="{XPATH_FUNCTIONS_NS}">')
|
|
974
|
+
_append_xpath31(output, self._config.obj)
|
|
975
|
+
output.write("</map>")
|
|
976
|
+
return output.to_bytes()
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
class _NamespaceFormatter:
|
|
980
|
+
"""Keep namespace emission and schema-attribute quirks in one place."""
|
|
981
|
+
|
|
982
|
+
@staticmethod
|
|
983
|
+
def format(xml_namespaces: dict[str, Any] | None) -> str:
|
|
984
|
+
if xml_namespaces is None:
|
|
985
|
+
return ""
|
|
986
|
+
|
|
987
|
+
namespace_parts: list[str] = []
|
|
988
|
+
for prefix in xml_namespaces:
|
|
989
|
+
if prefix == "xsi":
|
|
990
|
+
for schema_att in xml_namespaces[prefix]:
|
|
991
|
+
if schema_att == "schemaInstance":
|
|
992
|
+
namespace_parts.append(
|
|
993
|
+
f' xmlns:{prefix}="{xml_namespaces[prefix]["schemaInstance"]}"'
|
|
994
|
+
)
|
|
995
|
+
elif schema_att == "schemaLocation":
|
|
996
|
+
namespace_parts.append(
|
|
997
|
+
f' xsi:{schema_att}="{xml_namespaces[prefix][schema_att]}"'
|
|
998
|
+
)
|
|
999
|
+
elif prefix == "xmlns":
|
|
1000
|
+
namespace_parts.append(f' xmlns="{xml_namespaces[prefix]}"')
|
|
1001
|
+
else:
|
|
1002
|
+
namespace_parts.append(f' xmlns:{prefix}="{xml_namespaces[prefix]}"')
|
|
1003
|
+
return "".join(namespace_parts)
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
class _StandardDocumentRenderer:
|
|
1007
|
+
"""Render the project-specific XML document shape."""
|
|
1008
|
+
|
|
1009
|
+
def __init__(self, config: SerializerConfig) -> None:
|
|
1010
|
+
self._config = config
|
|
1011
|
+
|
|
1012
|
+
def render(self) -> bytes:
|
|
1013
|
+
output = _XMLWriter()
|
|
1014
|
+
if self._config.root:
|
|
1015
|
+
self._render_with_root(output)
|
|
1016
|
+
else:
|
|
1017
|
+
self._render_fragment(output)
|
|
1018
|
+
return output.to_bytes()
|
|
1019
|
+
|
|
1020
|
+
def _render_with_root(self, output: _XMLWriter) -> None:
|
|
1021
|
+
custom_root, root_attr = make_valid_xml_name(self._config.custom_root, {})
|
|
1022
|
+
namespace_str = _NamespaceFormatter.format(self._config.xml_namespaces)
|
|
1023
|
+
output.write('<?xml version="1.0" encoding="UTF-8" ?>')
|
|
1024
|
+
output.write(f"<{custom_root}{make_attrstring(root_attr)}{namespace_str}>")
|
|
1025
|
+
_append_convert(
|
|
1026
|
+
output,
|
|
1027
|
+
self._config.obj,
|
|
1028
|
+
self._config.ids,
|
|
1029
|
+
self._config.attr_type,
|
|
1030
|
+
self._config.item_func,
|
|
1031
|
+
self._config.cdata,
|
|
1032
|
+
self._config.item_wrap,
|
|
1033
|
+
parent=custom_root,
|
|
1034
|
+
list_headers=self._config.list_headers,
|
|
1035
|
+
)
|
|
1036
|
+
output.write(f"</{custom_root}>")
|
|
1037
|
+
|
|
1038
|
+
def _render_fragment(self, output: _XMLWriter) -> None:
|
|
1039
|
+
_append_convert(
|
|
1040
|
+
output,
|
|
1041
|
+
self._config.obj,
|
|
1042
|
+
self._config.ids,
|
|
1043
|
+
self._config.attr_type,
|
|
1044
|
+
self._config.item_func,
|
|
1045
|
+
self._config.cdata,
|
|
1046
|
+
self._config.item_wrap,
|
|
1047
|
+
parent="",
|
|
1048
|
+
list_headers=self._config.list_headers,
|
|
1049
|
+
)
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
class _SerializerEngine:
|
|
1053
|
+
"""Choose the document renderer while keeping helper semantics local."""
|
|
1054
|
+
|
|
1055
|
+
def __init__(self, config: SerializerConfig) -> None:
|
|
1056
|
+
self._config = config
|
|
1057
|
+
|
|
1058
|
+
def render(self) -> bytes:
|
|
1059
|
+
if self._config.xpath_format:
|
|
1060
|
+
return _XPathDocumentRenderer(self._config).render()
|
|
1061
|
+
return _StandardDocumentRenderer(self._config).render()
|
|
1062
|
+
|
|
1063
|
+
|
|
954
1064
|
# @lat: [[architecture#Conversion engine]]
|
|
955
1065
|
def dicttoxml(
|
|
956
1066
|
obj: ELEMENT,
|
|
@@ -1103,62 +1213,17 @@ def dicttoxml(
|
|
|
1103
1213
|
<list a="b" c="d"><item>4</item><item>5</item><item>6</item></list>
|
|
1104
1214
|
|
|
1105
1215
|
"""
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
namespace_parts: list[str] = []
|
|
1119
|
-
if xml_namespaces is None:
|
|
1120
|
-
xml_namespaces = {}
|
|
1121
|
-
for prefix in xml_namespaces:
|
|
1122
|
-
if prefix == 'xsi':
|
|
1123
|
-
for schema_att in xml_namespaces[prefix]:
|
|
1124
|
-
if schema_att == 'schemaInstance':
|
|
1125
|
-
ns = xml_namespaces[prefix]['schemaInstance']
|
|
1126
|
-
namespace_parts.append(f' xmlns:{prefix}="{ns}"')
|
|
1127
|
-
elif schema_att == 'schemaLocation':
|
|
1128
|
-
ns = xml_namespaces[prefix][schema_att]
|
|
1129
|
-
namespace_parts.append(f' xsi:{schema_att}="{ns}"')
|
|
1130
|
-
|
|
1131
|
-
elif prefix == 'xmlns':
|
|
1132
|
-
# xmns needs no prefix
|
|
1133
|
-
ns = xml_namespaces[prefix]
|
|
1134
|
-
namespace_parts.append(f' xmlns="{ns}"')
|
|
1135
|
-
|
|
1136
|
-
else:
|
|
1137
|
-
ns = xml_namespaces[prefix]
|
|
1138
|
-
namespace_parts.append(f' xmlns:{prefix}="{ns}"')
|
|
1139
|
-
namespace_str = "".join(namespace_parts)
|
|
1140
|
-
if root:
|
|
1141
|
-
custom_root, root_attr = make_valid_xml_name(custom_root, {})
|
|
1142
|
-
output = _XMLWriter()
|
|
1143
|
-
output.write('<?xml version="1.0" encoding="UTF-8" ?>')
|
|
1144
|
-
output.write(f"<{custom_root}{make_attrstring(root_attr)}{namespace_str}>")
|
|
1145
|
-
_append_convert(
|
|
1146
|
-
output,
|
|
1147
|
-
obj,
|
|
1148
|
-
ids,
|
|
1149
|
-
attr_type,
|
|
1150
|
-
item_func,
|
|
1151
|
-
cdata,
|
|
1152
|
-
item_wrap,
|
|
1153
|
-
parent=custom_root,
|
|
1154
|
-
list_headers=list_headers,
|
|
1155
|
-
)
|
|
1156
|
-
output.write(f"</{custom_root}>")
|
|
1157
|
-
return output.to_bytes()
|
|
1158
|
-
|
|
1159
|
-
output = _XMLWriter()
|
|
1160
|
-
_append_convert(
|
|
1161
|
-
output,
|
|
1162
|
-
obj, ids, attr_type, item_func, cdata, item_wrap, parent="", list_headers=list_headers
|
|
1216
|
+
config = SerializerConfig(
|
|
1217
|
+
obj=obj,
|
|
1218
|
+
root=root,
|
|
1219
|
+
custom_root=custom_root,
|
|
1220
|
+
ids=ids,
|
|
1221
|
+
attr_type=attr_type,
|
|
1222
|
+
item_wrap=item_wrap,
|
|
1223
|
+
item_func=item_func,
|
|
1224
|
+
cdata=cdata,
|
|
1225
|
+
xml_namespaces=xml_namespaces,
|
|
1226
|
+
list_headers=list_headers,
|
|
1227
|
+
xpath_format=xpath_format,
|
|
1163
1228
|
)
|
|
1164
|
-
return
|
|
1229
|
+
return _SerializerEngine(config).render()
|
|
@@ -15,8 +15,11 @@ from __future__ import annotations
|
|
|
15
15
|
|
|
16
16
|
import logging
|
|
17
17
|
from collections.abc import Callable
|
|
18
|
+
from dataclasses import dataclass
|
|
18
19
|
from typing import Any
|
|
19
20
|
|
|
21
|
+
from .backend_selector import BackendSelector, ConversionRequest, has_special_keys
|
|
22
|
+
|
|
20
23
|
RustStringTransform = Callable[[str], str]
|
|
21
24
|
|
|
22
25
|
LOG = logging.getLogger("dicttoxml_fast")
|
|
@@ -49,6 +52,71 @@ def get_backend() -> str:
|
|
|
49
52
|
"""Return the name of the current backend ('rust' or 'python')."""
|
|
50
53
|
return "rust" if _use_rust else "python"
|
|
51
54
|
|
|
55
|
+
@dataclass(frozen=True, slots=True)
|
|
56
|
+
class _RustBackendAdapter:
|
|
57
|
+
"""Adapter for the optional Rust backend."""
|
|
58
|
+
|
|
59
|
+
name: str = "rust"
|
|
60
|
+
|
|
61
|
+
def can_handle(self, request: ConversionRequest) -> bool:
|
|
62
|
+
if not _use_rust or _rust_dicttoxml is None:
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
return not (
|
|
66
|
+
request.ids is not None
|
|
67
|
+
or request.item_func is not None
|
|
68
|
+
or request.xml_namespaces
|
|
69
|
+
or request.xpath_format
|
|
70
|
+
or not isinstance(request.obj, (dict, list))
|
|
71
|
+
or has_special_keys(request.obj)
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def render(self, request: ConversionRequest) -> bytes:
|
|
75
|
+
assert _rust_dicttoxml is not None
|
|
76
|
+
return _rust_dicttoxml(
|
|
77
|
+
request.obj,
|
|
78
|
+
root=request.root,
|
|
79
|
+
custom_root=request.custom_root,
|
|
80
|
+
attr_type=request.attr_type,
|
|
81
|
+
item_wrap=request.item_wrap,
|
|
82
|
+
cdata=request.cdata,
|
|
83
|
+
list_headers=request.list_headers,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True, slots=True)
|
|
88
|
+
class _PythonBackendAdapter:
|
|
89
|
+
"""Adapter for the compatibility-preserving Python backend."""
|
|
90
|
+
|
|
91
|
+
python_dicttoxml: Callable[..., bytes]
|
|
92
|
+
default_item_func: Callable[[str], str]
|
|
93
|
+
|
|
94
|
+
name: str = "python"
|
|
95
|
+
|
|
96
|
+
def can_handle(self, request: ConversionRequest) -> bool:
|
|
97
|
+
return True
|
|
98
|
+
|
|
99
|
+
def render(self, request: ConversionRequest) -> bytes:
|
|
100
|
+
return self.python_dicttoxml(
|
|
101
|
+
request.obj,
|
|
102
|
+
root=request.root,
|
|
103
|
+
custom_root=request.custom_root,
|
|
104
|
+
ids=request.ids,
|
|
105
|
+
attr_type=request.attr_type,
|
|
106
|
+
item_wrap=request.item_wrap,
|
|
107
|
+
item_func=request.item_func or self.default_item_func,
|
|
108
|
+
cdata=request.cdata,
|
|
109
|
+
xml_namespaces=request.xml_namespaces,
|
|
110
|
+
list_headers=request.list_headers,
|
|
111
|
+
xpath_format=request.xpath_format,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
_BACKEND_SELECTOR = BackendSelector(
|
|
116
|
+
_RustBackendAdapter(),
|
|
117
|
+
_PythonBackendAdapter(_py_dicttoxml.dicttoxml, _py_dicttoxml.default_item_func),
|
|
118
|
+
)
|
|
119
|
+
|
|
52
120
|
|
|
53
121
|
# @lat: [[architecture#Backend selection]]
|
|
54
122
|
def dicttoxml(
|
|
@@ -86,62 +154,20 @@ def dicttoxml(
|
|
|
86
154
|
Returns:
|
|
87
155
|
UTF-8 encoded XML as bytes
|
|
88
156
|
"""
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
157
|
+
request = ConversionRequest(
|
|
158
|
+
obj=obj,
|
|
159
|
+
root=root,
|
|
160
|
+
custom_root=custom_root,
|
|
161
|
+
ids=ids,
|
|
162
|
+
attr_type=attr_type,
|
|
163
|
+
item_wrap=item_wrap,
|
|
164
|
+
item_func=item_func,
|
|
165
|
+
cdata=cdata,
|
|
166
|
+
xml_namespaces=xml_namespaces,
|
|
167
|
+
list_headers=list_headers,
|
|
168
|
+
xpath_format=xpath_format,
|
|
96
169
|
)
|
|
97
|
-
|
|
98
|
-
# Check for special dict keys that require Python
|
|
99
|
-
if not needs_python and isinstance(obj, dict):
|
|
100
|
-
needs_python = _has_special_keys(obj)
|
|
101
|
-
|
|
102
|
-
if _use_rust and not needs_python and _rust_dicttoxml is not None: # pragma: no cover
|
|
103
|
-
# Use fast Rust implementation
|
|
104
|
-
return _rust_dicttoxml(
|
|
105
|
-
obj,
|
|
106
|
-
root=root,
|
|
107
|
-
custom_root=custom_root,
|
|
108
|
-
attr_type=attr_type,
|
|
109
|
-
item_wrap=item_wrap,
|
|
110
|
-
cdata=cdata,
|
|
111
|
-
list_headers=list_headers,
|
|
112
|
-
)
|
|
113
|
-
else:
|
|
114
|
-
# Fall back to pure Python
|
|
115
|
-
return _py_dicttoxml.dicttoxml(
|
|
116
|
-
obj,
|
|
117
|
-
root=root,
|
|
118
|
-
custom_root=custom_root,
|
|
119
|
-
ids=ids,
|
|
120
|
-
attr_type=attr_type,
|
|
121
|
-
item_wrap=item_wrap,
|
|
122
|
-
item_func=item_func or _py_dicttoxml.default_item_func,
|
|
123
|
-
cdata=cdata,
|
|
124
|
-
xml_namespaces=xml_namespaces,
|
|
125
|
-
list_headers=list_headers,
|
|
126
|
-
xpath_format=xpath_format,
|
|
127
|
-
)
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
def _has_special_keys(obj: Any) -> bool:
|
|
131
|
-
"""Check if a dict contains special keys that require Python processing."""
|
|
132
|
-
if isinstance(obj, dict):
|
|
133
|
-
for key, val in obj.items():
|
|
134
|
-
if isinstance(key, str) and (
|
|
135
|
-
key.startswith("@") or key.endswith("@flat")
|
|
136
|
-
):
|
|
137
|
-
return True
|
|
138
|
-
if _has_special_keys(val):
|
|
139
|
-
return True
|
|
140
|
-
elif isinstance(obj, list):
|
|
141
|
-
for item in obj:
|
|
142
|
-
if _has_special_keys(item):
|
|
143
|
-
return True
|
|
144
|
-
return False
|
|
170
|
+
return _BACKEND_SELECTOR.render(request)
|
|
145
171
|
|
|
146
172
|
|
|
147
173
|
# Re-export commonly used functions
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: json2xml
|
|
3
|
-
Version: 6.
|
|
3
|
+
Version: 6.4.0
|
|
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.1; extra == "fast"
|
|
36
36
|
Dynamic: license-file
|
|
37
37
|
|
|
38
38
|
.. image:: ./json2xml-hero.png
|
|
@@ -23,6 +23,7 @@ docs/readme.rst
|
|
|
23
23
|
docs/rust_memory_benchmark.rst
|
|
24
24
|
docs/usage.rst
|
|
25
25
|
json2xml/__init__.py
|
|
26
|
+
json2xml/backend_selector.py
|
|
26
27
|
json2xml/cli.py
|
|
27
28
|
json2xml/dicttoxml.py
|
|
28
29
|
json2xml/dicttoxml_fast.py
|
|
@@ -37,6 +38,8 @@ json2xml.egg-info/requires.txt
|
|
|
37
38
|
json2xml.egg-info/top_level.txt
|
|
38
39
|
tests/__init__.py
|
|
39
40
|
tests/conftest.py
|
|
41
|
+
tests/test_backend_selector.py
|
|
42
|
+
tests/test_benchmark_multi_python.py
|
|
40
43
|
tests/test_cli.py
|
|
41
44
|
tests/test_dict2xml.py
|
|
42
45
|
tests/test_dicttoxml_fast_fallback.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "json2xml"
|
|
7
|
-
version = "6.
|
|
7
|
+
version = "6.4.0"
|
|
8
8
|
description = "Simple Python Library to convert JSON to XML"
|
|
9
9
|
readme = "README.rst"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -50,7 +50,7 @@ dev = [
|
|
|
50
50
|
"pygments>=2.20.0",
|
|
51
51
|
"xmltodict>=0.12.0",
|
|
52
52
|
]
|
|
53
|
-
fast = ["json2xml-rs>=0.4.
|
|
53
|
+
fast = ["json2xml-rs>=0.4.1"]
|
|
54
54
|
|
|
55
55
|
[tool.pytest.ini_options]
|
|
56
56
|
testpaths = ["tests"]
|
json2xml-6.4.0/setup.cfg
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from json2xml.backend_selector import BackendSelector, ConversionRequest, has_special_keys
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class _NeverBackend:
|
|
9
|
+
name = "never"
|
|
10
|
+
|
|
11
|
+
def can_handle(self, request: ConversionRequest) -> bool:
|
|
12
|
+
return False
|
|
13
|
+
|
|
14
|
+
def render(self, request: ConversionRequest) -> bytes:
|
|
15
|
+
raise AssertionError("render should not be called")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# @lat: [[tests#Conversion behavior#Backend selector detects Python-only payload markers]]
|
|
19
|
+
def test_has_special_keys_detects_nested_python_only_markers() -> None:
|
|
20
|
+
assert has_special_keys({"items": [{"record": {"@attrs": {"id": "7"}}}]}) is True
|
|
21
|
+
assert has_special_keys({"items": [{"record@flat": [1, 2, 3]}]}) is True
|
|
22
|
+
assert has_special_keys({"items": [{"record": {"name": "Ada"}}]}) is False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# @lat: [[tests#Conversion behavior#Backend selector fails loudly with no compatible backend]]
|
|
26
|
+
def test_backend_selector_raises_when_no_backend_can_handle_request() -> None:
|
|
27
|
+
request = ConversionRequest(
|
|
28
|
+
obj={"name": "Ada"},
|
|
29
|
+
root=True,
|
|
30
|
+
custom_root="root",
|
|
31
|
+
ids=None,
|
|
32
|
+
attr_type=True,
|
|
33
|
+
item_wrap=True,
|
|
34
|
+
item_func=None,
|
|
35
|
+
cdata=False,
|
|
36
|
+
xml_namespaces=None,
|
|
37
|
+
list_headers=False,
|
|
38
|
+
xpath_format=False,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
selector = BackendSelector(_NeverBackend())
|
|
42
|
+
|
|
43
|
+
with pytest.raises(RuntimeError, match="No XML backend can handle"):
|
|
44
|
+
selector.render(request)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# @lat: [[tests#Performance benchmarks#Benchmark script derives interpreter paths from configurable uv base dir]]
|
|
8
|
+
def test_benchmark_multi_python_uses_configurable_uv_base_dir(monkeypatch) -> None:
|
|
9
|
+
monkeypatch.setenv("JSON2XML_UV_PYTHON_DIR", "/tmp/uv-python")
|
|
10
|
+
monkeypatch.delenv("JSON2XML_PYTHON_CPYTHON_314_6", raising=False)
|
|
11
|
+
monkeypatch.delenv("JSON2XML_PYTHON_CPYTHON_315_0B3", raising=False)
|
|
12
|
+
monkeypatch.delenv("JSON2XML_PYTHON_PYPY_311_15", raising=False)
|
|
13
|
+
|
|
14
|
+
module = importlib.import_module("benchmark_multi_python")
|
|
15
|
+
module = importlib.reload(module)
|
|
16
|
+
|
|
17
|
+
assert module.UV_PYTHON_DIR.as_posix() == "/tmp/uv-python"
|
|
18
|
+
assert Path(module.PYTHON_VERSIONS[0]["python"]).as_posix() == "/tmp/uv-python/cpython-3.14.6-macos-aarch64-none/bin/python3.14"
|
|
19
|
+
assert Path(module.PYTHON_VERSIONS[1]["python"]).as_posix() == "/tmp/uv-python/cpython-3.15.0b3-macos-aarch64-none/bin/python3.15"
|
|
20
|
+
assert Path(module.PYTHON_VERSIONS[2]["python"]).as_posix() == "/tmp/uv-python/pypy-3.11.15-macos-aarch64-none/bin/pypy3.11"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# @lat: [[tests#Performance benchmarks#Benchmark script lets explicit interpreter env vars override uv defaults]]
|
|
24
|
+
def test_benchmark_multi_python_env_overrides_interpreter_paths(monkeypatch) -> None:
|
|
25
|
+
monkeypatch.setenv("JSON2XML_UV_PYTHON_DIR", "/tmp/uv-python")
|
|
26
|
+
monkeypatch.setenv("JSON2XML_PYTHON_CPYTHON_314_6", "/custom/python314")
|
|
27
|
+
monkeypatch.setenv("JSON2XML_PYTHON_CPYTHON_315_0B3", "/custom/python315")
|
|
28
|
+
monkeypatch.setenv("JSON2XML_PYTHON_PYPY_311_15", "/custom/pypy311")
|
|
29
|
+
|
|
30
|
+
module = importlib.import_module("benchmark_multi_python")
|
|
31
|
+
module = importlib.reload(module)
|
|
32
|
+
|
|
33
|
+
assert Path(module.PYTHON_VERSIONS[0]["python"]).as_posix() == "/custom/python314"
|
|
34
|
+
assert Path(module.PYTHON_VERSIONS[1]["python"]).as_posix() == "/custom/python315"
|
|
35
|
+
assert Path(module.PYTHON_VERSIONS[2]["python"]).as_posix() == "/custom/pypy311"
|
|
@@ -11,7 +11,15 @@ from unittest.mock import MagicMock, patch
|
|
|
11
11
|
|
|
12
12
|
import pytest
|
|
13
13
|
|
|
14
|
-
from json2xml.cli import
|
|
14
|
+
from json2xml.cli import (
|
|
15
|
+
CLIApplication,
|
|
16
|
+
CLIConversionOptions,
|
|
17
|
+
create_parser,
|
|
18
|
+
main,
|
|
19
|
+
read_from_stdin,
|
|
20
|
+
read_input,
|
|
21
|
+
write_output,
|
|
22
|
+
)
|
|
15
23
|
|
|
16
24
|
if TYPE_CHECKING:
|
|
17
25
|
from pytest import CaptureFixture
|
|
@@ -601,6 +609,32 @@ class TestCLIUnitTests:
|
|
|
601
609
|
captured = capsys.readouterr()
|
|
602
610
|
assert "No input provided" in captured.err
|
|
603
611
|
|
|
612
|
+
# @lat: [[tests#CLI failure messages#No-input guard stays total if exit helper is bypassed]]
|
|
613
|
+
def test_read_input_no_input_guard_raises_assertion_if_exit_helper_returns(self) -> None:
|
|
614
|
+
"""Test the no-input branch keeps a hard failure if the exit helper is bypassed."""
|
|
615
|
+
app = CLIApplication()
|
|
616
|
+
options = CLIConversionOptions(
|
|
617
|
+
input_file=None,
|
|
618
|
+
url=None,
|
|
619
|
+
string=None,
|
|
620
|
+
output=None,
|
|
621
|
+
wrapper="all",
|
|
622
|
+
root=True,
|
|
623
|
+
pretty=True,
|
|
624
|
+
attr_type=True,
|
|
625
|
+
item_wrap=True,
|
|
626
|
+
xpath_format=False,
|
|
627
|
+
cdata=False,
|
|
628
|
+
list_headers=True,
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
with (
|
|
632
|
+
patch("sys.stdin.isatty", return_value=True),
|
|
633
|
+
patch("json2xml.cli.exit_with_error", return_value=None),
|
|
634
|
+
):
|
|
635
|
+
with pytest.raises(AssertionError, match="unreachable"):
|
|
636
|
+
app.read_input(options)
|
|
637
|
+
|
|
604
638
|
def test_read_input_stdin_when_not_tty(self) -> None:
|
|
605
639
|
"""Test read_input reads from stdin when not a tty."""
|
|
606
640
|
with (
|
|
@@ -1,18 +1,12 @@
|
|
|
1
1
|
import copy
|
|
2
2
|
import datetime
|
|
3
|
-
import
|
|
4
|
-
from typing import TYPE_CHECKING, Any
|
|
3
|
+
from typing import Any
|
|
5
4
|
|
|
6
5
|
import pytest
|
|
7
6
|
from _pytest.monkeypatch import MonkeyPatch
|
|
8
7
|
|
|
9
8
|
from json2xml import dicttoxml
|
|
10
9
|
|
|
11
|
-
if TYPE_CHECKING:
|
|
12
|
-
from _pytest.capture import CaptureFixture
|
|
13
|
-
from _pytest.fixtures import FixtureRequest
|
|
14
|
-
from _pytest.logging import LogCaptureFixture
|
|
15
|
-
|
|
16
10
|
|
|
17
11
|
class TestDict2xml:
|
|
18
12
|
"""Test class for dicttoxml functionality."""
|
|
@@ -3,65 +3,15 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import numbers
|
|
6
|
-
from
|
|
7
|
-
from unittest.mock import MagicMock, patch
|
|
8
|
-
|
|
9
|
-
import pytest
|
|
6
|
+
from unittest.mock import patch
|
|
10
7
|
|
|
11
8
|
from json2xml.dicttoxml import (
|
|
12
9
|
convert_to_xpath31,
|
|
13
10
|
dicttoxml,
|
|
14
|
-
get_unique_id,
|
|
15
11
|
get_xml_type,
|
|
16
12
|
get_xpath31_tag_name,
|
|
17
|
-
make_id,
|
|
18
13
|
)
|
|
19
14
|
|
|
20
|
-
if TYPE_CHECKING:
|
|
21
|
-
pass
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
class TestGetUniqueIdDuplicateGeneration:
|
|
25
|
-
"""Test line 52: duplicate ID generation in get_unique_id loop"""
|
|
26
|
-
|
|
27
|
-
def test_get_unique_id_generates_id_when_duplicates_occur(self) -> None:
|
|
28
|
-
"""Test that get_unique_id handles the while loop by regenerating IDs on duplicates.
|
|
29
|
-
|
|
30
|
-
Line 52 (this_id = make_id(element)) is executed when a duplicate is found.
|
|
31
|
-
Since make_id uses SystemRandom, we can't guarantee duplicates, but we can
|
|
32
|
-
ensure the function returns a valid ID in the correct format.
|
|
33
|
-
"""
|
|
34
|
-
result = get_unique_id("test_element")
|
|
35
|
-
|
|
36
|
-
# Verify it returns a string in the expected format
|
|
37
|
-
assert isinstance(result, str)
|
|
38
|
-
assert result.startswith("test_element_")
|
|
39
|
-
assert len(result) > len("test_element_")
|
|
40
|
-
|
|
41
|
-
# Verify the numeric part is valid
|
|
42
|
-
numeric_part = result.replace("test_element_", "")
|
|
43
|
-
assert numeric_part.isdigit()
|
|
44
|
-
assert 100000 <= int(numeric_part) <= 999999
|
|
45
|
-
|
|
46
|
-
def test_get_unique_id_duplicate_id_regeneration(self) -> None:
|
|
47
|
-
"""Test line 52: trigger duplicate ID regeneration.
|
|
48
|
-
|
|
49
|
-
Line 52 (this_id = make_id(element)) only executes if this_id is in the ids list.
|
|
50
|
-
We can't easily trigger this with the current implementation since ids starts empty,
|
|
51
|
-
but we test the function's robustness when make_id returns duplicates.
|
|
52
|
-
"""
|
|
53
|
-
# Call get_unique_id multiple times - while theoretically one could duplicate
|
|
54
|
-
# due to SystemRandom, the function handles this correctly
|
|
55
|
-
ids_generated = [get_unique_id("test") for _ in range(10)]
|
|
56
|
-
|
|
57
|
-
# All generated IDs should be properly formatted
|
|
58
|
-
for id_val in ids_generated:
|
|
59
|
-
assert isinstance(id_val, str)
|
|
60
|
-
assert id_val.startswith("test_")
|
|
61
|
-
numeric_part = id_val.split("_")[-1]
|
|
62
|
-
assert numeric_part.isdigit()
|
|
63
|
-
assert 100000 <= int(numeric_part) <= 999999
|
|
64
|
-
|
|
65
15
|
|
|
66
16
|
class TestGetXmlTypeWithNumbers:
|
|
67
17
|
"""Test line 90: get_xml_type handling of numbers.Number instances"""
|
|
@@ -22,11 +22,6 @@ from json2xml.utils import (
|
|
|
22
22
|
if TYPE_CHECKING:
|
|
23
23
|
from collections.abc import Iterator
|
|
24
24
|
|
|
25
|
-
from _pytest.capture import CaptureFixture
|
|
26
|
-
from _pytest.fixtures import FixtureRequest
|
|
27
|
-
from _pytest.logging import LogCaptureFixture
|
|
28
|
-
from _pytest.monkeypatch import MonkeyPatch
|
|
29
|
-
|
|
30
25
|
|
|
31
26
|
class JsonTestHandler(BaseHTTPRequestHandler):
|
|
32
27
|
"""Tiny HTTP handler for exercising the real URL reader."""
|
json2xml-6.3.0/setup.cfg
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
[bumpversion]
|
|
2
|
-
current_version = 6.1.0
|
|
3
|
-
commit = True
|
|
4
|
-
tag = True
|
|
5
|
-
|
|
6
|
-
[bumpversion:file:setup.py]
|
|
7
|
-
search = version='{current_version}'
|
|
8
|
-
replace = version='{new_version}'
|
|
9
|
-
|
|
10
|
-
[bumpversion:file:json2xml/__init__.py]
|
|
11
|
-
search = __version__ = '{current_version}'
|
|
12
|
-
replace = __version__ = '{new_version}'
|
|
13
|
-
|
|
14
|
-
[bdist_wheel]
|
|
15
|
-
universal = false
|
|
16
|
-
|
|
17
|
-
[flake8]
|
|
18
|
-
exclude = docs
|
|
19
|
-
max-line-length = 120
|
|
20
|
-
|
|
21
|
-
[aliases]
|
|
22
|
-
|
|
23
|
-
[tool.ty]
|
|
24
|
-
|
|
25
|
-
[coverage:run]
|
|
26
|
-
relative_files = True
|
|
27
|
-
|
|
28
|
-
[egg_info]
|
|
29
|
-
tag_build =
|
|
30
|
-
tag_date = 0
|
|
31
|
-
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|