json2xml 6.3.0__tar.gz → 6.5.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.
Files changed (52) hide show
  1. {json2xml-6.3.0 → json2xml-6.5.0}/CONTRIBUTING.rst +1 -1
  2. {json2xml-6.3.0 → json2xml-6.5.0}/HISTORY.rst +19 -0
  3. {json2xml-6.3.0 → json2xml-6.5.0}/PKG-INFO +2 -2
  4. {json2xml-6.3.0 → json2xml-6.5.0}/json2xml/__init__.py +1 -1
  5. json2xml-6.5.0/json2xml/backend_selector.py +63 -0
  6. {json2xml-6.3.0 → json2xml-6.5.0}/json2xml/cli.py +148 -131
  7. {json2xml-6.3.0 → json2xml-6.5.0}/json2xml/dicttoxml.py +291 -89
  8. {json2xml-6.3.0 → json2xml-6.5.0}/json2xml/dicttoxml_fast.py +81 -55
  9. {json2xml-6.3.0 → json2xml-6.5.0}/json2xml.egg-info/PKG-INFO +2 -2
  10. {json2xml-6.3.0 → json2xml-6.5.0}/json2xml.egg-info/SOURCES.txt +3 -0
  11. {json2xml-6.3.0 → json2xml-6.5.0}/json2xml.egg-info/requires.txt +1 -1
  12. {json2xml-6.3.0 → json2xml-6.5.0}/pyproject.toml +2 -2
  13. json2xml-6.5.0/setup.cfg +11 -0
  14. json2xml-6.5.0/tests/test_backend_selector.py +44 -0
  15. json2xml-6.5.0/tests/test_benchmark_multi_python.py +35 -0
  16. {json2xml-6.3.0 → json2xml-6.5.0}/tests/test_cli.py +35 -1
  17. {json2xml-6.3.0 → json2xml-6.5.0}/tests/test_dict2xml.py +1 -7
  18. {json2xml-6.3.0 → json2xml-6.5.0}/tests/test_dicttoxml_unit.py +135 -0
  19. {json2xml-6.3.0 → json2xml-6.5.0}/tests/test_json2xml.py +0 -1
  20. {json2xml-6.3.0 → json2xml-6.5.0}/tests/test_missing_coverage.py +1 -51
  21. {json2xml-6.3.0 → json2xml-6.5.0}/tests/test_utils.py +0 -5
  22. json2xml-6.3.0/setup.cfg +0 -31
  23. {json2xml-6.3.0 → json2xml-6.5.0}/AUTHORS.rst +0 -0
  24. {json2xml-6.3.0 → json2xml-6.5.0}/LICENSE +0 -0
  25. {json2xml-6.3.0 → json2xml-6.5.0}/MANIFEST.in +0 -0
  26. {json2xml-6.3.0 → json2xml-6.5.0}/README.rst +0 -0
  27. {json2xml-6.3.0 → json2xml-6.5.0}/docs/Makefile +0 -0
  28. {json2xml-6.3.0 → json2xml-6.5.0}/docs/authors.rst +0 -0
  29. {json2xml-6.3.0 → json2xml-6.5.0}/docs/benchmarks.rst +0 -0
  30. {json2xml-6.3.0 → json2xml-6.5.0}/docs/conf.py +0 -0
  31. {json2xml-6.3.0 → json2xml-6.5.0}/docs/contributing.rst +0 -0
  32. {json2xml-6.3.0 → json2xml-6.5.0}/docs/history.rst +0 -0
  33. {json2xml-6.3.0 → json2xml-6.5.0}/docs/index.rst +0 -0
  34. {json2xml-6.3.0 → json2xml-6.5.0}/docs/installation.rst +0 -0
  35. {json2xml-6.3.0 → json2xml-6.5.0}/docs/json2xml.rst +0 -0
  36. {json2xml-6.3.0 → json2xml-6.5.0}/docs/make.bat +0 -0
  37. {json2xml-6.3.0 → json2xml-6.5.0}/docs/modules.rst +0 -0
  38. {json2xml-6.3.0 → json2xml-6.5.0}/docs/readme.rst +0 -0
  39. {json2xml-6.3.0 → json2xml-6.5.0}/docs/rust_memory_benchmark.rst +0 -0
  40. {json2xml-6.3.0 → json2xml-6.5.0}/docs/usage.rst +0 -0
  41. {json2xml-6.3.0 → json2xml-6.5.0}/json2xml/json2xml.py +0 -0
  42. {json2xml-6.3.0 → json2xml-6.5.0}/json2xml/types.py +0 -0
  43. {json2xml-6.3.0 → json2xml-6.5.0}/json2xml/utils.py +0 -0
  44. {json2xml-6.3.0 → json2xml-6.5.0}/json2xml.egg-info/dependency_links.txt +0 -0
  45. {json2xml-6.3.0 → json2xml-6.5.0}/json2xml.egg-info/entry_points.txt +0 -0
  46. {json2xml-6.3.0 → json2xml-6.5.0}/json2xml.egg-info/top_level.txt +0 -0
  47. {json2xml-6.3.0 → json2xml-6.5.0}/requirements.in +0 -0
  48. {json2xml-6.3.0 → json2xml-6.5.0}/setup.py +0 -0
  49. {json2xml-6.3.0 → json2xml-6.5.0}/tests/__init__.py +0 -0
  50. {json2xml-6.3.0 → json2xml-6.5.0}/tests/conftest.py +0 -0
  51. {json2xml-6.3.0 → json2xml-6.5.0}/tests/test_dicttoxml_fast_fallback.py +0 -0
  52. {json2xml-6.3.0 → json2xml-6.5.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
- $ bumpversion patch # possible: major / minor / patch
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,23 @@
1
1
 
2
+ 6.5.0 / 2026-07-15
3
+ ==================
4
+
5
+ * perf: reduce pure Python serializer time by 31.1% on the deterministic 5,000-record benchmark through exact native-type dispatch
6
+ * perf: release ``json2xml-rs`` 0.4.2 with hybrid SIMD/word XML escape scanning and a 6.23% benchmark reduction
7
+ * fix: preserve numeric, string, dictionary, and sequence subclass behavior across the optimized Python dispatch paths
8
+ * test: require exactly 100% statement coverage from the complete Python suite and add dense Rust XML escape scanning regression coverage
9
+ * chore: require ``json2xml-rs>=0.4.2`` from ``json2xml[fast]`` and validate release wheels before PyPI publication
10
+ * docs: add Python and Rust before/after flamegraphs, buffer-capacity measurements, and release performance notes
11
+
12
+
13
+ 6.4.0 / 2026-07-13
14
+ ==================
15
+
16
+ * perf: buffer Rust serializer writes in bounded 16 KiB chunks, reducing large-payload conversion time while keeping serializer memory near 80 MiB
17
+ * chore: release ``json2xml-rs`` 0.4.1 and require it from ``json2xml[fast]`` for accelerated installs
18
+ * docs: record before-and-after Rust buffering benchmarks and the unchanged XML output size
19
+
20
+
2
21
  6.3.0 / 2026-06-10
3
22
  ==================
4
23
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: json2xml
3
- Version: 6.3.0
3
+ Version: 6.5.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.0; extra == "fast"
35
+ Requires-Dist: json2xml-rs>=0.4.2; extra == "fast"
36
36
  Dynamic: license-file
37
37
 
38
38
  .. image:: ./json2xml-hero.png
@@ -2,4 +2,4 @@
2
2
 
3
3
  __author__ = """Vinit Kumar"""
4
4
  __email__ = "mail@vinitkumar.me"
5
- __version__ = "6.3.0"
5
+ __version__ = "6.5.0"
@@ -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
- Read JSON input from the specified source.
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
- Read JSON from standard input.
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
- Write XML output to the specified destination.
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(args)
361
- except Exception as e:
362
- print(f"Error reading input: {e}", file=sys.stderr)
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
- converter = Json2xml(
368
- data=data,
369
- wrapper=args.wrapper,
370
- root=args.root,
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