Coverage for src/local_deep_research/document_loaders/xls_loader.py: 92%
30 statements
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-19 23:35 +0000
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-19 23:35 +0000
1"""Custom loader for legacy ``.xls`` (BIFF) spreadsheets.
3``unstructured``'s Excel partitioner runs a ``msoffcrypto`` "is encrypted?"
4pre-check on every workbook. That check crashes on some legacy ``.xls`` files
5(``struct.error: unpack requires a buffer of 4 bytes`` in its xls97 record
6reader) even though ``xlrd`` reads the same file fine. To make ``.xls`` uploads
7extract reliably, this loader reads the workbook directly with pandas + xlrd
8and bypasses that pre-check. Modern ``.xlsx`` files still use the unstructured
9loader, whose OOXML path is unaffected.
10"""
12from pathlib import Path
13from typing import Iterator
15import pandas as pd
16from langchain_core.document_loaders import BaseLoader
17from langchain_core.documents import Document
18from loguru import logger
21class XLSLoader(BaseLoader):
22 """Load a legacy ``.xls`` workbook and convert each sheet to text.
24 Every sheet is read with the ``xlrd`` engine and rendered as one
25 space-joined line per row (skipping empty cells), which mirrors the plain
26 text that the unstructured Excel loader produces for ``.xlsx``.
27 """
29 def __init__(self, file_path: str | Path, **kwargs: object):
30 self.file_path = Path(file_path)
32 def lazy_load(self) -> Iterator[Document]:
33 try:
34 # sheet_name=None reads all sheets; header=None keeps the first row
35 # as data so column headers are included in the extracted text.
36 sheets = pd.read_excel(
37 self.file_path,
38 sheet_name=None,
39 header=None,
40 engine="xlrd",
41 )
42 except Exception as exc:
43 msg = str(exc).lower()
44 if "encrypt" in msg or "password" in msg:
45 raise ValueError(
46 f"XLS file is encrypted and cannot be read: {self.file_path}"
47 ) from exc
48 logger.exception(f"Error loading XLS file: {self.file_path}")
49 raise
51 for sheet_name, frame in sheets.items():
52 lines = []
53 for row in frame.itertuples(index=False, name=None):
54 cells = [str(value) for value in row if pd.notna(value)]
55 if cells: 55 ↛ 53line 55 didn't jump to line 53 because the condition on line 55 was always true
56 lines.append(" ".join(cells))
57 text = "\n".join(lines).strip()
58 if not text: 58 ↛ 59line 58 didn't jump to line 59 because the condition on line 58 was never true
59 continue
60 yield Document(
61 page_content=text,
62 metadata={
63 "source": str(self.file_path),
64 "file_type": "xls",
65 "sheet_name": sheet_name,
66 },
67 )
69 def load(self) -> list[Document]:
70 """Load all sheets from the XLS file."""
71 return list(self.lazy_load())