json2xml 6.2.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.2.0 → json2xml-6.4.0}/CONTRIBUTING.rst +1 -1
- {json2xml-6.2.0 → json2xml-6.4.0}/HISTORY.rst +18 -0
- {json2xml-6.2.0 → json2xml-6.4.0}/PKG-INFO +2 -2
- {json2xml-6.2.0 → json2xml-6.4.0}/docs/rust_memory_benchmark.rst +40 -9
- {json2xml-6.2.0 → json2xml-6.4.0}/json2xml/__init__.py +1 -1
- json2xml-6.4.0/json2xml/backend_selector.py +63 -0
- {json2xml-6.2.0 → json2xml-6.4.0}/json2xml/cli.py +148 -131
- {json2xml-6.2.0 → json2xml-6.4.0}/json2xml/dicttoxml.py +575 -288
- {json2xml-6.2.0 → json2xml-6.4.0}/json2xml/dicttoxml_fast.py +81 -55
- {json2xml-6.2.0 → json2xml-6.4.0}/json2xml.egg-info/PKG-INFO +2 -2
- {json2xml-6.2.0 → json2xml-6.4.0}/json2xml.egg-info/SOURCES.txt +3 -0
- {json2xml-6.2.0 → json2xml-6.4.0}/json2xml.egg-info/requires.txt +1 -1
- {json2xml-6.2.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.2.0 → json2xml-6.4.0}/tests/test_cli.py +35 -1
- {json2xml-6.2.0 → json2xml-6.4.0}/tests/test_dict2xml.py +33 -13
- {json2xml-6.2.0 → json2xml-6.4.0}/tests/test_dicttoxml_unit.py +62 -0
- {json2xml-6.2.0 → json2xml-6.4.0}/tests/test_json2xml.py +0 -1
- {json2xml-6.2.0 → json2xml-6.4.0}/tests/test_missing_coverage.py +1 -51
- {json2xml-6.2.0 → json2xml-6.4.0}/tests/test_utils.py +0 -5
- json2xml-6.2.0/setup.cfg +0 -31
- {json2xml-6.2.0 → json2xml-6.4.0}/AUTHORS.rst +0 -0
- {json2xml-6.2.0 → json2xml-6.4.0}/LICENSE +0 -0
- {json2xml-6.2.0 → json2xml-6.4.0}/MANIFEST.in +0 -0
- {json2xml-6.2.0 → json2xml-6.4.0}/README.rst +0 -0
- {json2xml-6.2.0 → json2xml-6.4.0}/docs/Makefile +0 -0
- {json2xml-6.2.0 → json2xml-6.4.0}/docs/authors.rst +0 -0
- {json2xml-6.2.0 → json2xml-6.4.0}/docs/benchmarks.rst +0 -0
- {json2xml-6.2.0 → json2xml-6.4.0}/docs/conf.py +0 -0
- {json2xml-6.2.0 → json2xml-6.4.0}/docs/contributing.rst +0 -0
- {json2xml-6.2.0 → json2xml-6.4.0}/docs/history.rst +0 -0
- {json2xml-6.2.0 → json2xml-6.4.0}/docs/index.rst +0 -0
- {json2xml-6.2.0 → json2xml-6.4.0}/docs/installation.rst +0 -0
- {json2xml-6.2.0 → json2xml-6.4.0}/docs/json2xml.rst +0 -0
- {json2xml-6.2.0 → json2xml-6.4.0}/docs/make.bat +0 -0
- {json2xml-6.2.0 → json2xml-6.4.0}/docs/modules.rst +0 -0
- {json2xml-6.2.0 → json2xml-6.4.0}/docs/readme.rst +0 -0
- {json2xml-6.2.0 → json2xml-6.4.0}/docs/usage.rst +0 -0
- {json2xml-6.2.0 → json2xml-6.4.0}/json2xml/json2xml.py +0 -0
- {json2xml-6.2.0 → json2xml-6.4.0}/json2xml/types.py +0 -0
- {json2xml-6.2.0 → json2xml-6.4.0}/json2xml/utils.py +0 -0
- {json2xml-6.2.0 → json2xml-6.4.0}/json2xml.egg-info/dependency_links.txt +0 -0
- {json2xml-6.2.0 → json2xml-6.4.0}/json2xml.egg-info/entry_points.txt +0 -0
- {json2xml-6.2.0 → json2xml-6.4.0}/json2xml.egg-info/top_level.txt +0 -0
- {json2xml-6.2.0 → json2xml-6.4.0}/requirements.in +0 -0
- {json2xml-6.2.0 → json2xml-6.4.0}/setup.py +0 -0
- {json2xml-6.2.0 → json2xml-6.4.0}/tests/__init__.py +0 -0
- {json2xml-6.2.0 → json2xml-6.4.0}/tests/conftest.py +0 -0
- {json2xml-6.2.0 → json2xml-6.4.0}/tests/test_dicttoxml_fast_fallback.py +0 -0
- {json2xml-6.2.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,22 @@
|
|
|
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
|
+
|
|
10
|
+
6.3.0 / 2026-06-10
|
|
11
|
+
==================
|
|
12
|
+
|
|
13
|
+
* feat: reduce pure Python serializer allocations in hot dict, list, and scalar paths
|
|
14
|
+
* feat: preserve XML output semantics while reusing validated element-name and attribute work
|
|
15
|
+
* perf: lower peak memory pressure for large conversions after the 6.2.0 Rust bytes-writer release
|
|
16
|
+
* docs: add hyperfine Rust memory benchmark notes with reproduction details and the measured throughput tradeoff
|
|
17
|
+
* chore: release ``json2xml-rs`` 0.4.0 and require it from ``json2xml[fast]`` for accelerated installs
|
|
18
|
+
|
|
19
|
+
|
|
2
20
|
6.2.0 / 2026-06-05
|
|
3
21
|
==================
|
|
4
22
|
|
|
@@ -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.
|
|
35
|
+
Requires-Dist: json2xml-rs>=0.4.1; extra == "fast"
|
|
36
36
|
Dynamic: license-file
|
|
37
37
|
|
|
38
38
|
.. image:: ./json2xml-hero.png
|
|
@@ -6,7 +6,7 @@ This benchmark compares peak memory usage before and after the Rust serializer s
|
|
|
6
6
|
Summary
|
|
7
7
|
-------
|
|
8
8
|
|
|
9
|
-
The current branch reduces serializer peak RSS by about **77.
|
|
9
|
+
The current branch still reduces serializer peak RSS by about **77.43 MiB** on a 100,000-record payload that produces **78.17 MiB** of XML. That is a **49.1% reduction** in serializer memory delta compared with the previous PR commit.
|
|
10
10
|
|
|
11
11
|
.. list-table::
|
|
12
12
|
:header-rows: 1
|
|
@@ -23,10 +23,10 @@ The current branch reduces serializer peak RSS by about **77.44 MiB** on a 100,0
|
|
|
23
23
|
- 157.70 MiB
|
|
24
24
|
- 0.180s
|
|
25
25
|
* - Current
|
|
26
|
-
- ``
|
|
27
|
-
-
|
|
28
|
-
- 80.
|
|
29
|
-
- 0.
|
|
26
|
+
- ``43543e8`` + worktree
|
|
27
|
+
- 270.91 MiB
|
|
28
|
+
- 80.27 MiB
|
|
29
|
+
- 0.232s
|
|
30
30
|
|
|
31
31
|
The memory result matches the implementation change: the previous version held roughly one final XML payload in Rust plus one Python ``bytes`` payload, while the current version writes into the Python ``bytes`` object directly.
|
|
32
32
|
|
|
@@ -36,14 +36,14 @@ Methodology
|
|
|
36
36
|
The benchmark uses ``benchmark_memory_rust.py`` with a deterministic generated payload so the Rust fast path can be measured without file parsing or pure-Python fallback behavior.
|
|
37
37
|
|
|
38
38
|
* Machine: Apple Silicon arm64
|
|
39
|
-
* OS: macOS 26.5
|
|
39
|
+
* OS: macOS 26.5.1
|
|
40
40
|
* Python: 3.14.0
|
|
41
41
|
* Build: ``python3 -m maturin develop --release --offline``
|
|
42
42
|
* Payload: 100,000 nested records
|
|
43
43
|
* Input JSON size: 44.31 MiB
|
|
44
44
|
* Output XML size: 78.17 MiB
|
|
45
45
|
* Measurement: process ``ru_maxrss`` after payload creation versus peak after ``json2xml_rs.dicttoxml(payload, attr_type=True)``
|
|
46
|
-
* Sampling:
|
|
46
|
+
* Sampling: five fresh Python processes for the current version using ``hyperfine 1.20.0 --runs 5 --show-output --export-json``
|
|
47
47
|
|
|
48
48
|
The baseline RSS is captured after the large Python payload is already built. The reported serializer delta is ``peak_rss - baseline_rss``, which focuses the comparison on output construction rather than payload allocation.
|
|
49
49
|
|
|
@@ -89,11 +89,41 @@ Raw Samples
|
|
|
89
89
|
- 271.55 MiB
|
|
90
90
|
- 80.30 MiB
|
|
91
91
|
- 0.258s
|
|
92
|
+
* - current-hyperfine-1
|
|
93
|
+
- 190.77 MiB
|
|
94
|
+
- 271.12 MiB
|
|
95
|
+
- 80.36 MiB
|
|
96
|
+
- 0.233s
|
|
97
|
+
* - current-hyperfine-2
|
|
98
|
+
- 190.66 MiB
|
|
99
|
+
- 271.02 MiB
|
|
100
|
+
- 80.36 MiB
|
|
101
|
+
- 0.234s
|
|
102
|
+
* - current-hyperfine-3
|
|
103
|
+
- 190.62 MiB
|
|
104
|
+
- 270.98 MiB
|
|
105
|
+
- 80.36 MiB
|
|
106
|
+
- 0.234s
|
|
107
|
+
* - current-hyperfine-4
|
|
108
|
+
- 190.78 MiB
|
|
109
|
+
- 270.84 MiB
|
|
110
|
+
- 80.06 MiB
|
|
111
|
+
- 0.231s
|
|
112
|
+
* - current-hyperfine-5
|
|
113
|
+
- 190.38 MiB
|
|
114
|
+
- 270.61 MiB
|
|
115
|
+
- 80.23 MiB
|
|
116
|
+
- 0.229s
|
|
117
|
+
|
|
118
|
+
Hyperfine Result
|
|
119
|
+
----------------
|
|
120
|
+
|
|
121
|
+
Hyperfine measured full fresh-process runtime for the current benchmark command at **684.3 ms +/- 26.5 ms** across five runs, with a range of **664.1 ms to 728.0 ms**. The exported hyperfine memory sample was **272.59 MiB**, which is consistent with the script's average peak RSS of **270.91 MiB**.
|
|
92
122
|
|
|
93
123
|
Tradeoff
|
|
94
124
|
--------
|
|
95
125
|
|
|
96
|
-
The memory improvement comes with a throughput cost in this release benchmark. Average conversion time increased from 0.180s to 0.
|
|
126
|
+
The memory improvement comes with a throughput cost in this release benchmark. Average conversion time increased from the previous 0.180s to the current hyperfine-sampled 0.232s, about **29.0% slower** for this payload.
|
|
97
127
|
|
|
98
128
|
That cost is likely from routing every XML write through ``std::io::Write`` and PyO3's bytes writer. The memory win is substantial for large outputs, but latency-sensitive callers may want more timing data before treating the bytes-writer path as a universal improvement.
|
|
99
129
|
|
|
@@ -105,6 +135,7 @@ Run each version in a fresh process after installing the desired Rust extension
|
|
|
105
135
|
.. code-block:: bash
|
|
106
136
|
|
|
107
137
|
python3 -m maturin develop --release --offline
|
|
108
|
-
|
|
138
|
+
hyperfine --runs 5 --show-output --export-json /private/tmp/json2xml-rust-memory-hyperfine.json \
|
|
139
|
+
'python3 benchmark_memory_rust.py --records 100000 --label current-hyperfine'
|
|
109
140
|
|
|
110
141
|
For the previous comparison, install commit ``7dd86b0`` in a temporary worktree, then run the same command from the main checkout so the benchmark script stays identical.
|
|
@@ -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
|