diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a09271..388d0d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,9 @@ All notable changes to Fair Code are documented here, newest first. ## [Unreleased] -Nothing yet. Changes made after 2.3.0 are listed here until the next release. +### Fixed + +- `get_xlsx_sheet_info` now closes the workbook it opens to list sheet names, rather than leaking the file handle. The unclosed handle raised a `ResourceWarning` on garbage collection and, on Windows, kept the `.xlsx` locked against deletion or overwrite ([#1051](https://github.com/yakew7/Fair-Code/issues/1051)). ## [2.3.0] - 17 Oct 2026 diff --git a/faircode/loaders_extra.py b/faircode/loaders_extra.py index 208a3ee..650d8d6 100644 --- a/faircode/loaders_extra.py +++ b/faircode/loaders_extra.py @@ -172,12 +172,13 @@ def get_xlsx_sheet_info(path: str) -> tuple[str, list[str]] | None: if Path(path).suffix.lower() != ".xlsx": return None try: - book = pd.ExcelFile(path) + with pd.ExcelFile(path) as book: + sheet_names = book.sheet_names except Exception: return None - if not book.sheet_names: + if not sheet_names: return None - return book.sheet_names[0], book.sheet_names[1:] + return sheet_names[0], sheet_names[1:] # Binary table formats that never decode as text, so an explicit text encoding diff --git a/tests/test_loaders.py b/tests/test_loaders.py index f526696..66ea375 100644 --- a/tests/test_loaders.py +++ b/tests/test_loaders.py @@ -3,8 +3,10 @@ Run from the repo root: pytest tests/ -q """ +import gc import importlib.util import json +import warnings import pandas as pd import pytest @@ -204,6 +206,30 @@ def test_get_xlsx_sheet_info_returns_none_for_a_corrupt_xlsx_file(tmp_path): assert get_xlsx_sheet_info(str(path)) is None +@requires_openpyxl +def test_get_xlsx_sheet_info_closes_the_workbook(tmp_path): + # Listing sheet names must not leak the workbook's file handle: an unclosed + # handle raises a ResourceWarning on garbage collection and, on Windows, + # keeps the file locked against deletion or overwrite (#1051). + path = tmp_path / "multi.xlsx" + with pd.ExcelWriter(path) as writer: + pd.DataFrame(ROWS).to_excel(writer, sheet_name="First", index=False) + pd.DataFrame(ROWS).to_excel(writer, sheet_name="Second", index=False) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + info = get_xlsx_sheet_info(str(path)) + gc.collect() + + assert info == ("First", ["Second"]) + unclosed = [ + w + for w in caught + if issubclass(w.category, ResourceWarning) and "unclosed" in str(w.message) + ] + assert not unclosed, f"workbook left open: {[str(w.message) for w in unclosed]}" + + def test_read_table_unknown_extension_sniffs_tabs(tmp_path): path = tmp_path / "data.txt" _write_csv(path, sep="\t")