From 4dcd563a8745fd95e1e217cc1cb33beca8f176c5 Mon Sep 17 00:00:00 2001 From: shguild Date: Sat, 10 Oct 2026 09:56:56 +0800 Subject: [PATCH 1/3] docs: record JSON Lines string fix --- AUTHORS.rst | 1 + CHANGES.rst | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/AUTHORS.rst b/AUTHORS.rst index 6b44851..bed48fe 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -37,6 +37,7 @@ Code contributions: - Jesper Schlegel (jesperschlegel) - J vanBemmel (jbemmel) - m-janicki +- shguild Suggestions and bug reporting: diff --git a/CHANGES.rst b/CHANGES.rst index c4a347d..da412d7 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,11 @@ Changelog ========= +Unreleased +---------- + +* Honor ``multiline=True`` for BoxList JSON string input and output, matching file behavior. + Version 7.4.1 ------------- From 2501db0f5a3b8d25838da59c137cd3eb78465201 Mon Sep 17 00:00:00 2001 From: shguild Date: Sat, 10 Oct 2026 09:58:26 +0800 Subject: [PATCH 2/3] fix: honor multiline JSON for strings --- box/box_list.py | 14 +++++++++----- box/converters.py | 11 +++++++++-- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/box/box_list.py b/box/box_list.py index 72609ff..b22ce04 100644 --- a/box/box_list.py +++ b/box/box_list.py @@ -210,14 +210,18 @@ def to_json( :param filename: If provided will save to file :param encoding: File encoding :param errors: How to handle encoding errors - :param multiline: Put each item in list onto it's own line + :param multiline: Put each item in list onto its own line, in a string or file :param json_kwargs: additional arguments to pass to json.dump(s) :return: string of JSON or return of `json.dump` """ - if filename and multiline: + if multiline: lines = [_to_json(item, filename=None, encoding=encoding, errors=errors, **json_kwargs) for item in self] - with open(filename, "w", encoding=encoding, errors=errors) as f: - f.write("\n".join(lines)) + text = "\n".join(lines) + if filename: + with open(filename, "w", encoding=encoding, errors=errors) as f: + f.write(text) + else: + return text else: return _to_json(self.to_list(), filename=filename, encoding=encoding, errors=errors, **json_kwargs) @@ -239,7 +243,7 @@ def from_json( :param filename: filename to open and pass to `json.load` :param encoding: File encoding :param errors: How to handle encoding errors - :param multiline: One object per line + :param multiline: One object per line, in a string or file :param kwargs: parameters to pass to `Box()` or `json.loads` :return: BoxList object from json data """ diff --git a/box/converters.py b/box/converters.py index bb91ba6..055caa1 100644 --- a/box/converters.py +++ b/box/converters.py @@ -182,8 +182,15 @@ def _from_json( ] else: data = json.load(f, **kwargs) - elif json_string: - data = json.loads(json_string, **kwargs) + elif json_string or (multiline and json_string is not None): + if multiline: + data = [ + json.loads(line.strip(), **kwargs) + for line in StringIO(json_string, newline=None) + if line.strip() and not line.strip().startswith("#") + ] + else: + data = json.loads(json_string, **kwargs) else: raise BoxError("from_json requires a string or filename") return data From f59cbc0ea178856c6d483a9b63b75408390c23a8 Mon Sep 17 00:00:00 2001 From: shguild Date: Sat, 10 Oct 2026 09:59:01 +0800 Subject: [PATCH 3/3] test: cover JSON Lines string round trips --- test/test_json_lines_strings.py | 63 +++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 test/test_json_lines_strings.py diff --git a/test/test_json_lines_strings.py b/test/test_json_lines_strings.py new file mode 100644 index 0000000..40ef6f6 --- /dev/null +++ b/test/test_json_lines_strings.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +import json + +import pytest + +from box import BoxList + + +@pytest.mark.parametrize("data", [[], [{"a": 1}], [{"a": 1}, {"b": 2}], [None, False, 3, "café\u2028text"]]) +def test_multiline_string_output_matches_file(tmp_path, data): + values = BoxList(data) + path = tmp_path / "values.jsonl" + values.to_json(filename=path, multiline=True) + + result = values.to_json(multiline=True) + + assert result == path.read_text(encoding="utf-8") + assert result == "\n".join(json.dumps(value, ensure_ascii=False) for value in data) + + +@pytest.mark.parametrize("newline", ["\n", "\r\n", "\r"]) +def test_multiline_string_input_matches_file(tmp_path, newline): + text = newline.join(["# comment", "", '{"FirstKey": 1}', " ", ' {"SecondKey": "café\u2028text"} ', "# end"]) + path = tmp_path / "values.jsonl" + path.write_bytes(text.encode("utf-8")) + + result = BoxList.from_json(text, multiline=True, camel_killer_box=True) + from_file = BoxList.from_json(filename=path, multiline=True, camel_killer_box=True) + + assert result == from_file + assert result[0].first_key == 1 + assert result[1].second_key == "café\u2028text" + + +@pytest.mark.parametrize("text", ["", "\n \r\n", "# comment\n # another\n"]) +def test_empty_multiline_string_input(text): + assert BoxList.from_json(text, multiline=True) == [] + + +def test_multiline_string_json_options(): + values = BoxList([{"b": 2, "a": 1}, {"c": 3}]) + text = values.to_json(multiline=True, sort_keys=True, separators=(",", ":")) + + assert text == '{"a":1,"b":2}\n{"c":3}' + assert BoxList.from_json(text, multiline=True, parse_int=str) == [{"a": "1", "b": "2"}, {"c": "3"}] + + +@pytest.mark.parametrize("text", ['{"a": 1}\nnot json', '{"a": 1}\n{"b":']) +def test_multiline_string_rejects_invalid_json(text): + with pytest.raises(json.JSONDecodeError): + BoxList.from_json(text, multiline=True) + + +def test_multiple_objects_require_multiline(): + with pytest.raises(json.JSONDecodeError): + BoxList.from_json('{"a": 1}\n{"b": 2}') + + +def test_default_json_array_behavior(): + values = BoxList([{"a": 1}, {"b": 2}]) + assert json.loads(values.to_json()) == values.to_list() + assert BoxList.from_json(values.to_json()) == values