From 6bc1d33d583c54bd69fbdd2071117e2d38c354a1 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 22 Sep 2026 11:34:27 -0700 Subject: [PATCH] Fix resource leaks caused by buffered readers Spotted running 'just test -Wall' on the LLM project. --- sqlite_utils/utils.py | 6 +++- tests/test_rows_from_file.py | 70 ++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/sqlite_utils/utils.py b/sqlite_utils/utils.py index ee6695b..91fd24d 100644 --- a/sqlite_utils/utils.py +++ b/sqlite_utils/utils.py @@ -380,10 +380,14 @@ def rows_from_file( "rows_from_file() requires a file-like object that supports peek(), such as io.BytesIO" ) if not first_bytes: + buffered.close() return (), Format.CSV if first_bytes.startswith((b"[", b"{")): + # JSON is read eagerly, so the detection wrapper can close now, + # including when parsing raises an error. # TODO: Detect newline-JSON - return rows_from_file(buffered, format=Format.JSON) + with buffered: + return rows_from_file(buffered, format=Format.JSON) else: dialect = csv.Sniffer().sniff( first_bytes.decode(encoding or "utf-8-sig", "ignore") diff --git a/tests/test_rows_from_file.py b/tests/test_rows_from_file.py index 3de3582..a8e7f9d 100644 --- a/tests/test_rows_from_file.py +++ b/tests/test_rows_from_file.py @@ -1,3 +1,5 @@ +import io +import json from io import BytesIO, StringIO import pytest @@ -61,3 +63,71 @@ def test_rows_from_file_error_on_string_io(): assert ex.value.args == ( "rows_from_file() requires a file-like object that supports peek(), such as io.BytesIO", ) + + +@pytest.fixture +def buffered_readers(monkeypatch): + # Keep wrappers alive so these checks cannot pass due to garbage collection. + readers = [] + original = io.BufferedReader + + def buffered_reader(*args, **kwargs): + reader = original(*args, **kwargs) + readers.append(reader) + return reader + + monkeypatch.setattr(io, "BufferedReader", buffered_reader) + yield readers + for reader in readers: + reader.close() + + +@pytest.mark.parametrize( + "content, expected_format, expected_rows", + [ + (b'[{"id": 1}]', Format.JSON, [{"id": 1}]), + (b'{"id": 1}', Format.JSON, [{"id": 1}]), + (b"[]", Format.JSON, []), + (b"", Format.CSV, []), + (b" \n\t", Format.CSV, []), + ], +) +def test_detect_format_closes_eager_reader( + tmp_path, buffered_readers, content, expected_format, expected_rows +): + path = tmp_path / "input" + path.write_bytes(content) + with path.open("rb") as fp: + rows, detected = rows_from_file(fp) + assert detected == expected_format + assert list(rows) == expected_rows + assert len(buffered_readers) == 1 + assert buffered_readers[0].closed + + +@pytest.mark.parametrize("content", [b"[", b'{"id":']) +def test_detect_format_closes_reader_on_invalid_json( + tmp_path, buffered_readers, content +): + path = tmp_path / "input.json" + path.write_bytes(content) + with path.open("rb") as fp: + with pytest.raises(json.JSONDecodeError): + rows_from_file(fp) + assert len(buffered_readers) == 1 + assert buffered_readers[0].closed + + +@pytest.mark.parametrize( + "delimiter, expected_format", [(b",", Format.CSV), (b"\t", Format.TSV)] +) +def test_detect_format_keeps_streaming_reader_open( + buffered_readers, delimiter, expected_format +): + content = b"id" + delimiter + b"name\n" + (b"1" + delimiter + b"Cleo\n") * 2000 + rows, detected = rows_from_file(BytesIO(content)) + assert detected == expected_format + assert len(buffered_readers) == 1 + assert not buffered_readers[0].closed + assert len(list(rows)) == 2000 + assert buffered_readers[0].closed