Skip to content
Closed
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
21 changes: 16 additions & 5 deletions api/core/rag/extractor/excel_extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
from typing import TypedDict, override

import pandas as pd
from openpyxl import load_workbook
from sqlalchemy import select

from configs import dify_config
Expand All @@ -26,6 +25,13 @@
from models.enums import CreatorUserRole
from models.model import UploadFile

try:
from wolfxl import load_workbook # pyrefly: ignore[missing-import]
except ModuleNotFoundError as exc:
if exc.name != "wolfxl":
raise
from openpyxl import load_workbook

logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -76,7 +82,7 @@ def __init__(

@override
def extract(self) -> list[Document]:
"""Load from Excel file in xls or xlsx format using Pandas and openpyxl."""
"""Load Excel files using Pandas and the installed optional XLSX reader."""
documents = []
file_extension = os.path.splitext(self._file_path)[-1].lower()

Expand All @@ -96,7 +102,12 @@ def extract(self) -> list[Document]:
valid_columns={column_idx + 1 for column_idx in column_map},
min_row=start_row,
)
for row in sheet.iter_rows(min_row=start_row, max_col=max_col_idx, values_only=False):
# Image-only rows below the last populated cell have no cells of their own.
image_rows = [anchor_row for anchor_row, _ in sheet_image_map]
max_row = max([sheet.max_row or 0, *image_rows]) if image_rows else None
for row in sheet.iter_rows(
min_row=start_row, max_row=max_row, max_col=max_col_idx, values_only=False
):
page_content = []
row_has_content = False
for col_idx, cell in enumerate(row):
Expand Down Expand Up @@ -284,7 +295,7 @@ def _build_image_file_key(
)

def _get_image_bytes(self, image) -> bytes | None:
"""Return embedded image bytes from an openpyxl image object."""
"""Return embedded image bytes from a worksheet image object."""
data_loader = getattr(image, "_data", None)
if not callable(data_loader):
return None
Expand All @@ -302,7 +313,7 @@ def _get_image_bytes(self, image) -> bytes | None:
return None

def _get_image_extension(self, image) -> str | None:
"""Resolve an image extension from openpyxl metadata."""
"""Resolve an image extension from worksheet image metadata."""
image_format = getattr(image, "format", None)
if isinstance(image_format, str) and image_format.strip():
return image_format.strip().lower()
Expand Down
3 changes: 3 additions & 0 deletions api/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,9 @@ override-dependencies = [

[dependency-groups]

# Opt-in XLSX reader. Not included in tool.uv.default-groups.
xlsx-wolfxl = ["wolfxl>=2.0.35,<2.1"]

############################################################
# [ Dev ] dependency group
# Required for development and running tests
Expand Down
104 changes: 103 additions & 1 deletion api/tests/unit_tests/core/rag/extractor/test_excel_extractor.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
from types import SimpleNamespace
import builtins
import importlib.util
import io
from collections.abc import Mapping, Sequence
from pathlib import Path
from types import ModuleType, SimpleNamespace

import pandas as pd
import pytest
Expand Down Expand Up @@ -33,6 +38,7 @@ def __init__(self, header_rows, data_rows, images=None):
self._header_rows = header_rows
self._data_rows = data_rows
self._images = images or []
self.max_row = len(header_rows) + len(data_rows)

def iter_rows(self, min_row=1, max_row=None, max_col=None, values_only=False):
if values_only:
Expand Down Expand Up @@ -89,6 +95,49 @@ def _get_upload_files(session_maker: sessionmaker[Session]) -> list[UploadFile]:


class TestExcelExtractor:
def test_installed_xlsx_reader(self, tmp_path):
import openpyxl

if importlib.util.find_spec("wolfxl") is None:
assert excel_module.load_workbook is openpyxl.load_workbook
else:
assert excel_module.load_workbook is importlib.import_module("wolfxl").load_workbook

path = tmp_path / "reader.xlsx"
workbook = openpyxl.Workbook()
sheet = workbook.active
assert sheet is not None
sheet.append(["Name", "Count"])
sheet.append(["Alice", 3])
workbook.save(path)
workbook.close()

documents = ExcelExtractor(str(path)).extract()
assert [document.page_content for document in documents] == ['"Name":"Alice";"Count":"3"']

def test_broken_wolfxl_install_is_not_masked(self, monkeypatch: pytest.MonkeyPatch) -> None:
real_import = builtins.__import__

def import_with_broken_wolfxl(
name: str,
globals: Mapping[str, object] | None = None,
locals: Mapping[str, object] | None = None,
fromlist: Sequence[str] = (),
level: int = 0,
) -> ModuleType:
if name == "wolfxl":
raise ModuleNotFoundError("No module named 'wolfxl._native'", name="wolfxl._native")
return real_import(name, globals, locals, fromlist, level)

monkeypatch.setattr(builtins, "__import__", import_with_broken_wolfxl)
spec = importlib.util.spec_from_file_location("_excel_extractor_import_probe", excel_module.__file__)
assert spec is not None
assert spec.loader is not None

with pytest.raises(ModuleNotFoundError) as exc_info:
spec.loader.exec_module(importlib.util.module_from_spec(spec))
assert exc_info.value.name == "wolfxl._native"

def test_extract_xlsx_with_hyperlinks_and_sheet_skip(self, monkeypatch: pytest.MonkeyPatch):
sheet_with_data = _FakeSheet(
header_rows=[("Name", "Link")],
Expand Down Expand Up @@ -243,6 +292,59 @@ def test_extract_xlsx_reuses_existing_embedded_image_uploads_on_retry(
assert second_docs[0].page_content == expected_page_content
assert len(saves) == 1

def test_extract_xlsx_keeps_trailing_image_only_rows_from_real_workbook(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
database_session_maker: sessionmaker[Session],
) -> None:
import openpyxl
from openpyxl.drawing.image import Image as WorksheetImage
from PIL import Image as PILImage

png = io.BytesIO()
PILImage.new("RGB", (4, 4), "red").save(png, format="PNG")
image_bytes = png.getvalue()

path = tmp_path / "images.xlsx"
workbook = openpyxl.Workbook()
sheet = workbook.active
assert sheet is not None
sheet.append(["Question", "Answer", "Image"])
sheet.append(["Q1", "A1", None])
# Row 3 is just past the populated cells; row 12 is past the 10-row header scan.
sheet.add_image(WorksheetImage(io.BytesIO(image_bytes)), "C3")
sheet.add_image(WorksheetImage(io.BytesIO(image_bytes)), "C12")
workbook.save(path)
workbook.close()
saves = _patch_image_persistence(monkeypatch)

extractor = ExcelExtractor(
str(path),
tenant_id="tenant-1",
user_id="user-1",
source_file_id="source-file-1",
)
first_docs = extractor.extract()
second_docs = extractor.extract()
upload_files = _get_upload_files(database_session_maker)

upload_ids = {
row: next(upload_file.id for upload_file in upload_files if f"_r{row}_c3_" in upload_file.key)
for row in (3, 12)
}
expected_page_contents = [
'"Question":"Q1";"Answer":"A1";"Image":""',
*(
f'"Question":"";"Answer":"";"Image":"![image](http://files.local/files/{upload_ids[row]}/file-preview)"'
for row in (3, 12)
),
]
assert [doc.page_content for doc in first_docs] == expected_page_contents
assert [doc.page_content for doc in second_docs] == expected_page_contents
assert len(upload_files) == 2
assert sorted(saves) == sorted((upload_file.key, image_bytes) for upload_file in upload_files)

def test_extract_xls_path(self, monkeypatch: pytest.MonkeyPatch):
class FakeExcelFile:
sheet_names = ["Sheet1"]
Expand Down
38 changes: 30 additions & 8 deletions api/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.