json2xml 7.1.0__tar.gz → 7.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. {json2xml-7.1.0 → json2xml-7.1.1}/HISTORY.rst +7 -0
  2. {json2xml-7.1.0 → json2xml-7.1.1}/PKG-INFO +1 -1
  3. {json2xml-7.1.0 → json2xml-7.1.1}/json2xml/__init__.py +1 -1
  4. {json2xml-7.1.0 → json2xml-7.1.1}/json2xml/utils.py +9 -6
  5. {json2xml-7.1.0 → json2xml-7.1.1}/json2xml.egg-info/PKG-INFO +1 -1
  6. {json2xml-7.1.0 → json2xml-7.1.1}/pyproject.toml +1 -1
  7. {json2xml-7.1.0 → json2xml-7.1.1}/tests/test_cli.py +32 -0
  8. {json2xml-7.1.0 → json2xml-7.1.1}/tests/test_dict2xml.py +2 -2
  9. {json2xml-7.1.0 → json2xml-7.1.1}/tests/test_dicttoxml_unit.py +3 -0
  10. {json2xml-7.1.0 → json2xml-7.1.1}/tests/test_json2xml.py +13 -12
  11. {json2xml-7.1.0 → json2xml-7.1.1}/tests/test_missing_coverage.py +3 -0
  12. {json2xml-7.1.0 → json2xml-7.1.1}/tests/test_utils.py +44 -0
  13. {json2xml-7.1.0 → json2xml-7.1.1}/AUTHORS.rst +0 -0
  14. {json2xml-7.1.0 → json2xml-7.1.1}/CONTRIBUTING.rst +0 -0
  15. {json2xml-7.1.0 → json2xml-7.1.1}/LICENSE +0 -0
  16. {json2xml-7.1.0 → json2xml-7.1.1}/MANIFEST.in +0 -0
  17. {json2xml-7.1.0 → json2xml-7.1.1}/README.rst +0 -0
  18. {json2xml-7.1.0 → json2xml-7.1.1}/docs/Makefile +0 -0
  19. {json2xml-7.1.0 → json2xml-7.1.1}/docs/authors.rst +0 -0
  20. {json2xml-7.1.0 → json2xml-7.1.1}/docs/benchmarks.rst +0 -0
  21. {json2xml-7.1.0 → json2xml-7.1.1}/docs/conf.py +0 -0
  22. {json2xml-7.1.0 → json2xml-7.1.1}/docs/contributing.rst +0 -0
  23. {json2xml-7.1.0 → json2xml-7.1.1}/docs/history.rst +0 -0
  24. {json2xml-7.1.0 → json2xml-7.1.1}/docs/index.rst +0 -0
  25. {json2xml-7.1.0 → json2xml-7.1.1}/docs/installation.rst +0 -0
  26. {json2xml-7.1.0 → json2xml-7.1.1}/docs/json2xml.rst +0 -0
  27. {json2xml-7.1.0 → json2xml-7.1.1}/docs/make.bat +0 -0
  28. {json2xml-7.1.0 → json2xml-7.1.1}/docs/modules.rst +0 -0
  29. {json2xml-7.1.0 → json2xml-7.1.1}/docs/readme.rst +0 -0
  30. {json2xml-7.1.0 → json2xml-7.1.1}/docs/rust_memory_benchmark.rst +0 -0
  31. {json2xml-7.1.0 → json2xml-7.1.1}/docs/usage.rst +0 -0
  32. {json2xml-7.1.0 → json2xml-7.1.1}/json2xml/backend_selector.py +0 -0
  33. {json2xml-7.1.0 → json2xml-7.1.1}/json2xml/cli.py +0 -0
  34. {json2xml-7.1.0 → json2xml-7.1.1}/json2xml/dicttoxml.py +0 -0
  35. {json2xml-7.1.0 → json2xml-7.1.1}/json2xml/dicttoxml_fast.py +0 -0
  36. {json2xml-7.1.0 → json2xml-7.1.1}/json2xml/json2xml.py +0 -0
  37. {json2xml-7.1.0 → json2xml-7.1.1}/json2xml/types.py +0 -0
  38. {json2xml-7.1.0 → json2xml-7.1.1}/json2xml.egg-info/SOURCES.txt +0 -0
  39. {json2xml-7.1.0 → json2xml-7.1.1}/json2xml.egg-info/dependency_links.txt +0 -0
  40. {json2xml-7.1.0 → json2xml-7.1.1}/json2xml.egg-info/entry_points.txt +0 -0
  41. {json2xml-7.1.0 → json2xml-7.1.1}/json2xml.egg-info/requires.txt +0 -0
  42. {json2xml-7.1.0 → json2xml-7.1.1}/json2xml.egg-info/top_level.txt +0 -0
  43. {json2xml-7.1.0 → json2xml-7.1.1}/setup.cfg +0 -0
  44. {json2xml-7.1.0 → json2xml-7.1.1}/setup.py +0 -0
  45. {json2xml-7.1.0 → json2xml-7.1.1}/tests/__init__.py +0 -0
  46. {json2xml-7.1.0 → json2xml-7.1.1}/tests/conftest.py +0 -0
  47. {json2xml-7.1.0 → json2xml-7.1.1}/tests/test_backend_selector.py +0 -0
  48. {json2xml-7.1.0 → json2xml-7.1.1}/tests/test_benchmark_multi_python.py +0 -0
  49. {json2xml-7.1.0 → json2xml-7.1.1}/tests/test_benchmark_security_hardening.py +0 -0
  50. {json2xml-7.1.0 → json2xml-7.1.1}/tests/test_dicttoxml_fast_fallback.py +0 -0
  51. {json2xml-7.1.0 → json2xml-7.1.1}/tests/test_rust_dicttoxml.py +0 -0
  52. {json2xml-7.1.0 → json2xml-7.1.1}/tests/test_rust_python_parity.py +0 -0
@@ -1,4 +1,11 @@
1
1
 
2
+ 7.1.1 / 2026-09-23
3
+ ==================
4
+
5
+ * fix: include the JSON decoder's line and column in ``JSONReadError``, ``StringReadError``, and ``URLReadError`` messages, so the CLI reports where the input breaks instead of only that it is invalid
6
+ * fix: accept a UTF-8 byte order mark in file, URL, string, and stdin JSON input
7
+ * chore: fix the diagnostics ty 0.0.83 reports in tests and make the list-omission test compare its result
8
+
2
9
  7.1.0 / 2026-09-03
3
10
  ==================
4
11
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: json2xml
3
- Version: 7.1.0
3
+ Version: 7.1.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
@@ -2,4 +2,4 @@
2
2
 
3
3
  __author__ = """Vinit Kumar"""
4
4
  __email__ = "mail@vinitkumar.me"
5
- __version__ = "7.1.0"
5
+ __version__ = "7.1.1"
@@ -65,12 +65,14 @@ def _effective_port(parsed: SplitResult) -> int:
65
65
  def readfromjson(filename: str) -> JSONValue:
66
66
  """Read JSON data from a file."""
67
67
  try:
68
- with open(filename, encoding="utf-8") as jsondata:
68
+ # utf-8-sig drops the BOM that Windows tools write by default.
69
+ with open(filename, encoding="utf-8-sig") as jsondata:
69
70
  return json.load(jsondata)
70
71
  except OSError as error:
71
72
  raise JSONReadError("Could not read JSON file") from error
72
73
  except ValueError as error:
73
- raise JSONReadError("Invalid JSON File") from error
74
+ # Keep the decoder message so callers can show where parsing stopped.
75
+ raise JSONReadError(f"Invalid JSON File: {error}") from error
74
76
 
75
77
 
76
78
  # @lat: [[behavior#URL security boundaries]]
@@ -349,9 +351,9 @@ def readfromurl(
349
351
  response.close()
350
352
 
351
353
  try:
352
- return json.loads(response_data.decode("utf-8"))
354
+ return json.loads(response_data.decode("utf-8-sig"))
353
355
  except (UnicodeDecodeError, json.JSONDecodeError) as error:
354
- raise URLReadError("URL did not return valid JSON") from error
356
+ raise URLReadError(f"URL did not return valid JSON: {error}") from error
355
357
 
356
358
 
357
359
  def readfromstring(jsondata: object) -> JSONValue:
@@ -359,6 +361,7 @@ def readfromstring(jsondata: object) -> JSONValue:
359
361
  if not isinstance(jsondata, str):
360
362
  raise StringReadError("Input is not a proper JSON string")
361
363
  try:
362
- return json.loads(jsondata)
364
+ # Text sources such as stdin deliver a BOM as a leading U+FEFF.
365
+ return json.loads(jsondata.removeprefix("\ufeff"))
363
366
  except ValueError as error:
364
- raise StringReadError("Input is not a proper JSON string") from error
367
+ raise StringReadError(f"Input is not a proper JSON string: {error}") from error
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: json2xml
3
- Version: 7.1.0
3
+ Version: 7.1.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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "json2xml"
7
- version = "7.1.0"
7
+ version = "7.1.1"
8
8
  description = "Simple Python Library to convert JSON to XML"
9
9
  readme = "README.rst"
10
10
  requires-python = ">=3.10"
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import io
6
+ import re
6
7
  import subprocess
7
8
  import sys
8
9
  import tempfile
@@ -404,6 +405,37 @@ class TestCLI:
404
405
  assert "Could not parse JSON file" in result.stderr
405
406
  assert str(json_file) in result.stderr
406
407
 
408
+ # @lat: [[tests#Input readers#Invalid JSON errors include the decoder position]]
409
+ def test_invalid_json_file_reports_position(self) -> None:
410
+ """The CLI shows where the decoder stopped, not only that it failed."""
411
+ json_file = Path(__file__).parent.parent / "examples" / "wrongjson.json"
412
+
413
+ result = subprocess.run(
414
+ [sys.executable, "-m", "json2xml.cli", str(json_file)],
415
+ capture_output=True,
416
+ text=True,
417
+ )
418
+
419
+ assert result.returncode == 1
420
+ assert "Could not parse JSON file" in result.stderr
421
+ # CPython and PyPy report different positions for the same file.
422
+ assert re.search(r"line \d+ column \d+", result.stderr)
423
+
424
+ def test_json_file_with_utf8_bom(self) -> None:
425
+ """A BOM-prefixed file converts instead of being reported as invalid."""
426
+ with tempfile.TemporaryDirectory() as tmpdir:
427
+ json_file = Path(tmpdir) / "bom.json"
428
+ json_file.write_bytes(b'\xef\xbb\xbf{"bom": true}')
429
+
430
+ result = subprocess.run(
431
+ [sys.executable, "-m", "json2xml.cli", str(json_file)],
432
+ capture_output=True,
433
+ text=True,
434
+ )
435
+
436
+ assert result.returncode == 0, result.stderr
437
+ assert '<bom type="bool">true</bom>' in result.stdout
438
+
407
439
  def test_output_file_permission_error(self) -> None:
408
440
  """Test error handling when output file cannot be written."""
409
441
  with tempfile.TemporaryDirectory() as tmpdir:
@@ -190,8 +190,8 @@ class TestDict2xml:
190
190
  def test_dict2xml_omit_list(self) -> None:
191
191
  """Test dicttoxml with list omission."""
192
192
  obj = {"list": [{"bike": "blue"}, {"wheel": "black"}]}
193
- dicttoxml.dicttoxml(obj, root=False, attr_type=False, item_wrap=False)
194
- assert b"<list><bike>blue</bike><wheel>black</wheel></list>"
193
+ result = dicttoxml.dicttoxml(obj, root=False, attr_type=False, item_wrap=False)
194
+ assert b"<list><bike>blue</bike><wheel>black</wheel></list>" == result
195
195
 
196
196
  def test_dict2xml_with_val_and_custom_attr(self) -> None:
197
197
  """Test dicttoxml with @val and custom attributes."""
@@ -13,6 +13,9 @@ from json2xml import dicttoxml
13
13
 
14
14
 
15
15
  class CustomNumber(numbers.Number):
16
+ def __hash__(self) -> int:
17
+ return 7
18
+
16
19
  def __complex__(self) -> complex:
17
20
  return complex(7)
18
21
 
@@ -12,6 +12,7 @@ import xmltodict
12
12
 
13
13
  from json2xml import json2xml
14
14
  from json2xml.json2xml import _positive_limit
15
+ from json2xml.types import JSONValue
15
16
  from json2xml.utils import (
16
17
  InvalidDataError,
17
18
  JSONReadError,
@@ -215,7 +216,7 @@ class TestJson2xml:
215
216
  assert "dict" == old_dict["all"]["empty_dict"]["@type"]
216
217
 
217
218
  def test_dicttoxml_bug(self) -> None:
218
- input_dict = {
219
+ input_dict: JSONValue = {
219
220
  "response": {
220
221
  "results": {
221
222
  "user": [
@@ -342,7 +343,7 @@ class TestJson2xml:
342
343
 
343
344
  def test_pretty_output_limit_counts_indentation(self) -> None:
344
345
  """Pretty-only whitespace is included in the exact output byte budget."""
345
- data = {"outer": {"inner": {"leaf": "value"}}}
346
+ data: JSONValue = {"outer": {"inner": {"leaf": "value"}}}
346
347
  compact_size = len(json2xml.Json2xml(data).to_xml() or b"")
347
348
 
348
349
  with pytest.raises(InvalidDataError, match="XML output size limit exceeded"):
@@ -430,7 +431,7 @@ class TestJson2xml:
430
431
  assert dict_from_xml["all"]["string_array"]["item"][2]["#text"] == "c"
431
432
 
432
433
  def test_dict_attr_crash(self) -> None:
433
- data = {
434
+ data: JSONValue = {
434
435
  "product": {
435
436
  "@attrs": {"attr_name": "attr_value", "a": "b"},
436
437
  "@val": [],
@@ -461,7 +462,7 @@ class TestJson2xml:
461
462
  # @lat: [[tests#Conversion behavior#XPath format adds functions namespace]]
462
463
  def test_xpath_format_basic(self) -> None:
463
464
  """Test XPath 3.1 json-to-xml format with basic types."""
464
- data = {"name": "John", "age": 30, "active": True}
465
+ data: JSONValue = {"name": "John", "age": 30, "active": True}
465
466
  xmldata = json2xml.Json2xml(data, xpath_format=True, pretty=False).to_xml()
466
467
  assert isinstance(xmldata, bytes)
467
468
  assert b'xmlns="http://www.w3.org/2005/xpath-functions"' in xmldata
@@ -471,7 +472,7 @@ class TestJson2xml:
471
472
 
472
473
  def test_xpath_format_nested_dict(self) -> None:
473
474
  """Test XPath 3.1 format with nested dictionaries."""
474
- data = {"person": {"name": "Alice", "age": 25}}
475
+ data: JSONValue = {"person": {"name": "Alice", "age": 25}}
475
476
  xmldata = json2xml.Json2xml(data, xpath_format=True, pretty=False).to_xml()
476
477
  assert isinstance(xmldata, bytes)
477
478
  assert b'<map key="person">' in xmldata
@@ -480,7 +481,7 @@ class TestJson2xml:
480
481
 
481
482
  def test_xpath_format_array(self) -> None:
482
483
  """Test XPath 3.1 format with arrays."""
483
- data = {"numbers": [1, 2, 3]}
484
+ data: JSONValue = {"numbers": [1, 2, 3]}
484
485
  xmldata = json2xml.Json2xml(data, xpath_format=True, pretty=False).to_xml()
485
486
  assert isinstance(xmldata, bytes)
486
487
  assert b'<array key="numbers">' in xmldata
@@ -490,14 +491,14 @@ class TestJson2xml:
490
491
 
491
492
  def test_xpath_format_null(self) -> None:
492
493
  """Test XPath 3.1 format with null values."""
493
- data = {"value": None}
494
+ data: JSONValue = {"value": None}
494
495
  xmldata = json2xml.Json2xml(data, xpath_format=True, pretty=False).to_xml()
495
496
  assert isinstance(xmldata, bytes)
496
497
  assert b'<null key="value"/>' in xmldata
497
498
 
498
499
  def test_xpath_format_mixed_array(self) -> None:
499
500
  """Test XPath 3.1 format with mixed type arrays."""
500
- data = {"items": ["text", 42, True, None]}
501
+ data: JSONValue = {"items": ["text", 42, True, None]}
501
502
  xmldata = json2xml.Json2xml(data, xpath_format=True, pretty=False).to_xml()
502
503
  assert isinstance(xmldata, bytes)
503
504
  assert b'<array key="items">' in xmldata
@@ -508,7 +509,7 @@ class TestJson2xml:
508
509
 
509
510
  def test_xpath_format_complex_nested(self) -> None:
510
511
  """Test XPath 3.1 format with complex nested structures."""
511
- data = {
512
+ data: JSONValue = {
512
513
  "content": [
513
514
  {"id": 70805774, "value": "1001", "position": [1004.0, 288.0]},
514
515
  ]
@@ -523,7 +524,7 @@ class TestJson2xml:
523
524
 
524
525
  def test_xpath_format_escaping(self) -> None:
525
526
  """Test XPath 3.1 format properly escapes special characters."""
526
- data = {"text": "<script>alert('xss')</script>"}
527
+ data: JSONValue = {"text": "<script>alert('xss')</script>"}
527
528
  xmldata = json2xml.Json2xml(data, xpath_format=True, pretty=False).to_xml()
528
529
  assert isinstance(xmldata, bytes)
529
530
  assert b"&lt;script&gt;" in xmldata
@@ -531,7 +532,7 @@ class TestJson2xml:
531
532
 
532
533
  def test_xpath_format_with_pretty_print(self) -> None:
533
534
  """Test XPath 3.1 format works with pretty printing."""
534
- data = {"name": "Test"}
535
+ data: JSONValue = {"name": "Test"}
535
536
  xmldata = json2xml.Json2xml(data, xpath_format=True, pretty=True).to_xml()
536
537
  assert isinstance(xmldata, str)
537
538
  assert 'xmlns="http://www.w3.org/2005/xpath-functions"' in xmldata
@@ -539,7 +540,7 @@ class TestJson2xml:
539
540
 
540
541
  def test_xpath_format_root_array(self) -> None:
541
542
  """Test XPath 3.1 format with root-level array."""
542
- data = [1, 2, 3]
543
+ data: JSONValue = [1, 2, 3]
543
544
  xmldata = json2xml.Json2xml(data, xpath_format=True, pretty=False).to_xml()
544
545
  assert isinstance(xmldata, bytes)
545
546
  assert b'<array xmlns="http://www.w3.org/2005/xpath-functions">' in xmldata
@@ -203,6 +203,9 @@ class TestXmlTypeEdgeCases:
203
203
  def __str__(self) -> str:
204
204
  return str(self.value)
205
205
 
206
+ def __hash__(self) -> int:
207
+ return hash(self.value)
208
+
206
209
  result = get_xml_type(CustomNumber(42))
207
210
  assert result == "number"
208
211
 
@@ -7,6 +7,7 @@ import tempfile
7
7
  import threading
8
8
  import zlib
9
9
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
10
+ from pathlib import Path
10
11
  from typing import TYPE_CHECKING, Any, ClassVar, cast
11
12
  from unittest.mock import Mock, patch
12
13
 
@@ -34,6 +35,7 @@ class JsonTestHandler(BaseHTTPRequestHandler):
34
35
  "/data.json": (200, b'{"key": "value", "number": 42}'),
35
36
  "/api": (200, b'{"result": "success"}'),
36
37
  "/invalid.json": (200, b"invalid json content"),
38
+ "/bom.json": (200, b'\xef\xbb\xbf{"bom": true}'),
37
39
  "/error.json": (500, b'{"error": true}'),
38
40
  "/api.json": (200, b'{"api": "response", "status": "ok"}'),
39
41
  }
@@ -128,6 +130,28 @@ class TestReadFromJson:
128
130
 
129
131
  os.unlink(temp_filename)
130
132
 
133
+ def test_readfromjson_reports_decoder_position(self) -> None:
134
+ """The wrapped error carries the decoder's line and column."""
135
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
136
+ f.write('{"a": 1 "b": 2}')
137
+ temp_filename = f.name
138
+
139
+ try:
140
+ with pytest.raises(JSONReadError, match="line 1 column 9"):
141
+ readfromjson(temp_filename)
142
+ finally:
143
+ import os
144
+
145
+ os.unlink(temp_filename)
146
+
147
+ # @lat: [[tests#Input readers#Readers accept a UTF-8 byte order mark]]
148
+ def test_readfromjson_accepts_utf8_bom(self, tmp_path: Path) -> None:
149
+ """A leading UTF-8 BOM, as written by Windows tools, is not an error."""
150
+ bom_file = tmp_path / "bom.json"
151
+ bom_file.write_bytes(b'\xef\xbb\xbf{"bom": true}')
152
+
153
+ assert readfromjson(str(bom_file)) == {"bom": True}
154
+
131
155
  # @lat: [[tests#Input readers#File reader distinguishes unreadable files from invalid JSON]]
132
156
  def test_readfromjson_file_not_found(self) -> None:
133
157
  """Test reading a non-existent file."""
@@ -186,6 +210,17 @@ class TestReadFromUrl:
186
210
  with pytest.raises(URLReadError, match="URL did not return valid JSON"):
187
211
  readfromurl(f"{json_server}/invalid.json", allow_private_networks=True)
188
212
 
213
+ def test_readfromurl_reports_decoder_position(self, json_server: str) -> None:
214
+ """The wrapped error carries the decoder's message."""
215
+ with pytest.raises(URLReadError, match=r"URL did not return valid JSON: .+line 1"):
216
+ readfromurl(f"{json_server}/invalid.json", allow_private_networks=True)
217
+
218
+ def test_readfromurl_accepts_utf8_bom(self, json_server: str) -> None:
219
+ """A BOM-prefixed response body decodes like any other UTF-8 body."""
220
+ result = readfromurl(f"{json_server}/bom.json", allow_private_networks=True)
221
+
222
+ assert result == {"bom": True}
223
+
189
224
  def test_readfromurl_network_error(self) -> None:
190
225
  """Test network failures are wrapped as URLReadError."""
191
226
  with socket.socket() as unused_socket:
@@ -715,6 +750,15 @@ class TestReadFromString:
715
750
  }
716
751
  assert result == expected
717
752
 
753
+ def test_readfromstring_reports_decoder_position(self) -> None:
754
+ """The wrapped error carries the decoder's line and column."""
755
+ with pytest.raises(StringReadError, match="line 1 column 9"):
756
+ readfromstring('{"a": 1 "b": 2}')
757
+
758
+ def test_readfromstring_accepts_utf8_bom(self) -> None:
759
+ """A leading U+FEFF, as stdin delivers a BOM, is dropped before parsing."""
760
+ assert readfromstring('\ufeff{"bom": true}') == {"bom": True}
761
+
718
762
  def test_readfromstring_invalid_type_int(self) -> None:
719
763
  """Test reading with integer input."""
720
764
  with pytest.raises(StringReadError, match="Input is not a proper JSON string"):
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