Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions json2xml/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]]
Expand Down Expand Up @@ -349,16 +351,17 @@ 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:
"""Load JSON data from a string."""
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
2 changes: 1 addition & 1 deletion lat.md/behavior.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
8 changes: 8 additions & 0 deletions lat.md/tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
32 changes: 32 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import io
import re
import subprocess
import sys
import tempfile
Expand Down Expand Up @@ -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 '<bom type="bool">true</bom>' 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:
Expand Down
3 changes: 3 additions & 0 deletions tests/test_dicttoxml_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@
from json2xml import dicttoxml


class CustomNumber(numbers.Number):

Check warning

Code scanning / CodeQL

Inconsistent equality and hashing Warning test

This class implements
__hash__
, but does not implement __eq__.
def __hash__(self) -> int:
return 7

def __complex__(self) -> complex:
return complex(7)

Expand Down
25 changes: 13 additions & 12 deletions tests/test_json2xml.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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": [
Expand Down Expand Up @@ -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"):
Expand Down Expand Up @@ -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": [],
Expand Down Expand Up @@ -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
Expand All @@ -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'<map key="person">' in xmldata
Expand All @@ -480,7 +481,7 @@ def test_xpath_format_nested_dict(self) -> None:

def test_xpath_format_array(self) -> None:
"""Test XPath 3.1 format with arrays."""
data = {"numbers": [1, 2, 3]}
data: JSONValue = {"numbers": [1, 2, 3]}
xmldata = json2xml.Json2xml(data, xpath_format=True, pretty=False).to_xml()
assert isinstance(xmldata, bytes)
assert b'<array key="numbers">' in xmldata
Expand All @@ -490,14 +491,14 @@ def test_xpath_format_array(self) -> None:

def test_xpath_format_null(self) -> None:
"""Test XPath 3.1 format with null values."""
data = {"value": None}
data: JSONValue = {"value": None}
xmldata = json2xml.Json2xml(data, xpath_format=True, pretty=False).to_xml()
assert isinstance(xmldata, bytes)
assert b'<null key="value"/>' in xmldata

def test_xpath_format_mixed_array(self) -> None:
"""Test XPath 3.1 format with mixed type arrays."""
data = {"items": ["text", 42, True, None]}
data: JSONValue = {"items": ["text", 42, True, None]}
xmldata = json2xml.Json2xml(data, xpath_format=True, pretty=False).to_xml()
assert isinstance(xmldata, bytes)
assert b'<array key="items">' in xmldata
Expand All @@ -508,7 +509,7 @@ def test_xpath_format_mixed_array(self) -> None:

def test_xpath_format_complex_nested(self) -> None:
"""Test XPath 3.1 format with complex nested structures."""
data = {
data: JSONValue = {
"content": [
{"id": 70805774, "value": "1001", "position": [1004.0, 288.0]},
]
Expand All @@ -523,23 +524,23 @@ def test_xpath_format_complex_nested(self) -> None:

def test_xpath_format_escaping(self) -> None:
"""Test XPath 3.1 format properly escapes special characters."""
data = {"text": "<script>alert('xss')</script>"}
data: JSONValue = {"text": "<script>alert('xss')</script>"}
xmldata = json2xml.Json2xml(data, xpath_format=True, pretty=False).to_xml()
assert isinstance(xmldata, bytes)
assert b"&lt;script&gt;" in xmldata
assert b"&apos;xss&apos;" in xmldata

def test_xpath_format_with_pretty_print(self) -> None:
"""Test XPath 3.1 format works with pretty printing."""
data = {"name": "Test"}
data: JSONValue = {"name": "Test"}
xmldata = json2xml.Json2xml(data, xpath_format=True, pretty=True).to_xml()
assert isinstance(xmldata, str)
assert 'xmlns="http://www.w3.org/2005/xpath-functions"' in xmldata
assert '<string key="name">Test</string>' in xmldata

def test_xpath_format_root_array(self) -> None:
"""Test XPath 3.1 format with root-level array."""
data = [1, 2, 3]
data: JSONValue = [1, 2, 3]
xmldata = json2xml.Json2xml(data, xpath_format=True, pretty=False).to_xml()
assert isinstance(xmldata, bytes)
assert b'<array xmlns="http://www.w3.org/2005/xpath-functions">' in xmldata
Expand Down
3 changes: 3 additions & 0 deletions tests/test_missing_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,13 +196,16 @@
def test_get_xml_type_with_custom_number_class(self) -> None:
"""Test get_xml_type with custom class inheriting from numbers.Number."""

class CustomNumber(numbers.Number):

Check warning

Code scanning / CodeQL

Inconsistent equality and hashing Warning test

This class implements
__hash__
, but does not implement __eq__.
def __init__(self, value: float) -> None:
self.value = value

def __str__(self) -> str:
return str(self.value)

def __hash__(self) -> int:
return hash(self.value)

result = get_xml_type(CustomNumber(42))
assert result == "number"

Expand Down
44 changes: 44 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import threading
import zlib
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar, cast
from unittest.mock import Mock, patch

Expand Down Expand Up @@ -34,6 +35,7 @@ class JsonTestHandler(BaseHTTPRequestHandler):
"/data.json": (200, b'{"key": "value", "number": 42}'),
"/api": (200, b'{"result": "success"}'),
"/invalid.json": (200, b"invalid json content"),
"/bom.json": (200, b'\xef\xbb\xbf{"bom": true}'),
"/error.json": (500, b'{"error": true}'),
"/api.json": (200, b'{"api": "response", "status": "ok"}'),
}
Expand Down Expand Up @@ -128,6 +130,28 @@ def test_readfromjson_invalid_json_content(self) -> None:

os.unlink(temp_filename)

def test_readfromjson_reports_decoder_position(self) -> None:
"""The wrapped error carries the decoder's line and column."""
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
f.write('{"a": 1 "b": 2}')
temp_filename = f.name

try:
with pytest.raises(JSONReadError, match="line 1 column 9"):
readfromjson(temp_filename)
finally:
import os

os.unlink(temp_filename)

# @lat: [[tests#Input readers#Readers accept a UTF-8 byte order mark]]
def test_readfromjson_accepts_utf8_bom(self, tmp_path: Path) -> None:
"""A leading UTF-8 BOM, as written by Windows tools, is not an error."""
bom_file = tmp_path / "bom.json"
bom_file.write_bytes(b'\xef\xbb\xbf{"bom": true}')

assert readfromjson(str(bom_file)) == {"bom": True}

# @lat: [[tests#Input readers#File reader distinguishes unreadable files from invalid JSON]]
def test_readfromjson_file_not_found(self) -> None:
"""Test reading a non-existent file."""
Expand Down Expand Up @@ -186,6 +210,17 @@ def test_readfromurl_invalid_json_response(self, json_server: str) -> None:
with pytest.raises(URLReadError, match="URL did not return valid JSON"):
readfromurl(f"{json_server}/invalid.json", allow_private_networks=True)

def test_readfromurl_reports_decoder_position(self, json_server: str) -> None:
"""The wrapped error carries the decoder's message."""
with pytest.raises(URLReadError, match=r"URL did not return valid JSON: .+line 1"):
readfromurl(f"{json_server}/invalid.json", allow_private_networks=True)

def test_readfromurl_accepts_utf8_bom(self, json_server: str) -> None:
"""A BOM-prefixed response body decodes like any other UTF-8 body."""
result = readfromurl(f"{json_server}/bom.json", allow_private_networks=True)

assert result == {"bom": True}

def test_readfromurl_network_error(self) -> None:
"""Test network failures are wrapped as URLReadError."""
with socket.socket() as unused_socket:
Expand Down Expand Up @@ -715,6 +750,15 @@ def test_readfromstring_complex_object(self) -> None:
}
assert result == expected

def test_readfromstring_reports_decoder_position(self) -> None:
"""The wrapped error carries the decoder's line and column."""
with pytest.raises(StringReadError, match="line 1 column 9"):
readfromstring('{"a": 1 "b": 2}')

def test_readfromstring_accepts_utf8_bom(self) -> None:
"""A leading U+FEFF, as stdin delivers a BOM, is dropped before parsing."""
assert readfromstring('\ufeff{"bom": true}') == {"bom": True}

def test_readfromstring_invalid_type_int(self) -> None:
"""Test reading with integer input."""
with pytest.raises(StringReadError, match="Input is not a proper JSON string"):
Expand Down
Loading