diff --git a/json2xml/utils.py b/json2xml/utils.py
index ed91ab1..73eb683 100644
--- a/json2xml/utils.py
+++ b/json2xml/utils.py
@@ -65,12 +65,14 @@ def _effective_port(parsed: SplitResult) -> int:
def readfromjson(filename: str) -> JSONValue:
"""Read JSON data from a file."""
try:
- with open(filename, encoding="utf-8") as jsondata:
+ # utf-8-sig drops the BOM that Windows tools write by default.
+ with open(filename, encoding="utf-8-sig") as jsondata:
return json.load(jsondata)
except OSError as error:
raise JSONReadError("Could not read JSON file") from error
except ValueError as error:
- raise JSONReadError("Invalid JSON File") from error
+ # Keep the decoder message so callers can show where parsing stopped.
+ raise JSONReadError(f"Invalid JSON File: {error}") from error
# @lat: [[behavior#URL security boundaries]]
@@ -349,9 +351,9 @@ def readfromurl(
response.close()
try:
- return json.loads(response_data.decode("utf-8"))
+ return json.loads(response_data.decode("utf-8-sig"))
except (UnicodeDecodeError, json.JSONDecodeError) as error:
- raise URLReadError("URL did not return valid JSON") from error
+ raise URLReadError(f"URL did not return valid JSON: {error}") from error
def readfromstring(jsondata: object) -> JSONValue:
@@ -359,6 +361,7 @@ def readfromstring(jsondata: object) -> JSONValue:
if not isinstance(jsondata, str):
raise StringReadError("Input is not a proper JSON string")
try:
- return json.loads(jsondata)
+ # Text sources such as stdin deliver a BOM as a leading U+FEFF.
+ return json.loads(jsondata.removeprefix("\ufeff"))
except ValueError as error:
- raise StringReadError("Input is not a proper JSON string") from error
+ raise StringReadError(f"Input is not a proper JSON string: {error}") from error
diff --git a/lat.md/behavior.md b/lat.md/behavior.md
index 6e65aac..22acf56 100644
--- a/lat.md/behavior.md
+++ b/lat.md/behavior.md
@@ -6,7 +6,7 @@ This file captures the observable conversion and input rules that matter more th
The input helpers convert files, strings, URLs, and stdin into Python data structures while surfacing source-specific errors to callers.
-[[json2xml/utils.py#readfromjson]] wraps file and JSON decoding failures in `JSONReadError`. [[json2xml/utils.py#readfromstring]] rejects non-string inputs and malformed JSON with `StringReadError`.
+[[json2xml/utils.py#readfromjson]] wraps file and JSON decoding failures in `JSONReadError`. [[json2xml/utils.py#readfromstring]] rejects non-string inputs and malformed JSON with `StringReadError`. Decoding failures keep the decoder's line and column in the message so the CLI can show where the input breaks. All three readers ignore a leading UTF-8 byte order mark, which Windows tools write by default.
[[json2xml/utils.py#readfromurl]] lazily initializes the HTTP client, performs a bounded GET request, and raises `URLReadError` for hostname encoding, network, status, size, decoding, and JSON failures.
diff --git a/lat.md/tests.md b/lat.md/tests.md
index 96cd1ab..d673af8 100644
--- a/lat.md/tests.md
+++ b/lat.md/tests.md
@@ -42,6 +42,14 @@ These tests verify the concrete reader helpers against realistic source behavior
A file that cannot be opened reports a read failure, while a file whose content does not parse reports invalid JSON, so callers can tell the two apart from the message.
+### Invalid JSON errors include the decoder position
+
+Malformed file, string, and URL input carry the decoder's line and column in the wrapped error, so the CLI can say where the document breaks instead of only that it is invalid.
+
+### Readers accept a UTF-8 byte order mark
+
+File, URL, and string input that starts with a UTF-8 byte order mark parses normally, because Windows tools write that prefix by default and it carries no data.
+
### URL reader uses real HTTP and wraps failures
URL input should read valid JSON over HTTP and wrap status, network, and decoding failures in `URLReadError`.
diff --git a/tests/test_cli.py b/tests/test_cli.py
index fcbd06f..3ab5dd0 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import io
+import re
import subprocess
import sys
import tempfile
@@ -404,6 +405,37 @@ def test_invalid_json_file(self) -> None:
assert "Could not parse JSON file" in result.stderr
assert str(json_file) in result.stderr
+ # @lat: [[tests#Input readers#Invalid JSON errors include the decoder position]]
+ def test_invalid_json_file_reports_position(self) -> None:
+ """The CLI shows where the decoder stopped, not only that it failed."""
+ json_file = Path(__file__).parent.parent / "examples" / "wrongjson.json"
+
+ result = subprocess.run(
+ [sys.executable, "-m", "json2xml.cli", str(json_file)],
+ capture_output=True,
+ text=True,
+ )
+
+ assert result.returncode == 1
+ assert "Could not parse JSON file" in result.stderr
+ # CPython and PyPy report different positions for the same file.
+ assert re.search(r"line \d+ column \d+", result.stderr)
+
+ def test_json_file_with_utf8_bom(self) -> None:
+ """A BOM-prefixed file converts instead of being reported as invalid."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ json_file = Path(tmpdir) / "bom.json"
+ json_file.write_bytes(b'\xef\xbb\xbf{"bom": true}')
+
+ result = subprocess.run(
+ [sys.executable, "-m", "json2xml.cli", str(json_file)],
+ capture_output=True,
+ text=True,
+ )
+
+ assert result.returncode == 0, result.stderr
+ assert 'true' in result.stdout
+
def test_output_file_permission_error(self) -> None:
"""Test error handling when output file cannot be written."""
with tempfile.TemporaryDirectory() as tmpdir:
diff --git a/tests/test_dicttoxml_unit.py b/tests/test_dicttoxml_unit.py
index 3b4ec3b..8dcf10f 100644
--- a/tests/test_dicttoxml_unit.py
+++ b/tests/test_dicttoxml_unit.py
@@ -13,6 +13,9 @@
class CustomNumber(numbers.Number):
+ def __hash__(self) -> int:
+ return 7
+
def __complex__(self) -> complex:
return complex(7)
diff --git a/tests/test_json2xml.py b/tests/test_json2xml.py
index 3fc4147..edbb832 100644
--- a/tests/test_json2xml.py
+++ b/tests/test_json2xml.py
@@ -12,6 +12,7 @@
from json2xml import json2xml
from json2xml.json2xml import _positive_limit
+from json2xml.types import JSONValue
from json2xml.utils import (
InvalidDataError,
JSONReadError,
@@ -215,7 +216,7 @@ def test_attrs(self) -> None:
assert "dict" == old_dict["all"]["empty_dict"]["@type"]
def test_dicttoxml_bug(self) -> None:
- input_dict = {
+ input_dict: JSONValue = {
"response": {
"results": {
"user": [
@@ -342,7 +343,7 @@ def test_exact_compact_output_limit_is_enforced_after_backend_render(
def test_pretty_output_limit_counts_indentation(self) -> None:
"""Pretty-only whitespace is included in the exact output byte budget."""
- data = {"outer": {"inner": {"leaf": "value"}}}
+ data: JSONValue = {"outer": {"inner": {"leaf": "value"}}}
compact_size = len(json2xml.Json2xml(data).to_xml() or b"")
with pytest.raises(InvalidDataError, match="XML output size limit exceeded"):
@@ -430,7 +431,7 @@ def test_read_boolean_data_from_json2(self) -> None:
assert dict_from_xml["all"]["string_array"]["item"][2]["#text"] == "c"
def test_dict_attr_crash(self) -> None:
- data = {
+ data: JSONValue = {
"product": {
"@attrs": {"attr_name": "attr_value", "a": "b"},
"@val": [],
@@ -461,7 +462,7 @@ def test_encoding_without_pretty_print(self) -> None:
# @lat: [[tests#Conversion behavior#XPath format adds functions namespace]]
def test_xpath_format_basic(self) -> None:
"""Test XPath 3.1 json-to-xml format with basic types."""
- data = {"name": "John", "age": 30, "active": True}
+ data: JSONValue = {"name": "John", "age": 30, "active": True}
xmldata = json2xml.Json2xml(data, xpath_format=True, pretty=False).to_xml()
assert isinstance(xmldata, bytes)
assert b'xmlns="http://www.w3.org/2005/xpath-functions"' in xmldata
@@ -471,7 +472,7 @@ def test_xpath_format_basic(self) -> None:
def test_xpath_format_nested_dict(self) -> None:
"""Test XPath 3.1 format with nested dictionaries."""
- data = {"person": {"name": "Alice", "age": 25}}
+ data: JSONValue = {"person": {"name": "Alice", "age": 25}}
xmldata = json2xml.Json2xml(data, xpath_format=True, pretty=False).to_xml()
assert isinstance(xmldata, bytes)
assert b'