diff --git a/api/core/rag/extractor/excel_extractor.py b/api/core/rag/extractor/excel_extractor.py index 5d5316dc1a310b..c2fd0b82eab27b 100644 --- a/api/core/rag/extractor/excel_extractor.py +++ b/api/core/rag/extractor/excel_extractor.py @@ -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 @@ -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__) @@ -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() @@ -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): @@ -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 @@ -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() diff --git a/api/pyproject.toml b/api/pyproject.toml index e226e804b6705a..44e0035baa95a6 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -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 diff --git a/api/tests/unit_tests/core/rag/extractor/test_excel_extractor.py b/api/tests/unit_tests/core/rag/extractor/test_excel_extractor.py index 5b87121f12b798..e9189ef5e25ed5 100644 --- a/api/tests/unit_tests/core/rag/extractor/test_excel_extractor.py +++ b/api/tests/unit_tests/core/rag/extractor/test_excel_extractor.py @@ -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 @@ -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: @@ -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")], @@ -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"] diff --git a/api/uv.lock b/api/uv.lock index 8e2887ee428f35..7ef60d02b32ad3 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1773,6 +1773,9 @@ vdb-weaviate = [ vdb-xinference = [ { name = "xinference-client" }, ] +xlsx-wolfxl = [ + { name = "wolfxl" }, +] [package.metadata] requires-dist = [ @@ -1994,6 +1997,7 @@ vdb-vastbase = [{ name = "dify-vdb-vastbase", editable = "providers/vdb/vdb-vast vdb-vikingdb = [{ name = "dify-vdb-vikingdb", editable = "providers/vdb/vdb-vikingdb" }] vdb-weaviate = [{ name = "dify-vdb-weaviate", editable = "providers/vdb/vdb-weaviate" }] vdb-xinference = [{ name = "xinference-client", specifier = ">=2.7.0,<3.0.0" }] +xlsx-wolfxl = [{ name = "wolfxl", specifier = ">=2.0.35,<2.1" }] [[package]] name = "dify-trace-aliyun" @@ -4040,15 +4044,15 @@ dependencies = [ { name = "tiktoken" }, { name = "tokenizers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/63/28fb5696e52f40f4bafd096a3d0ed2c8679a24311ed89c200a280ee3b68a/litellm-1.100.4.tar.gz", hash = "sha256:c031cbc63848be16403725594f6060fe002dc2e65f0866878285d72923ac27ab" } +sdist = { url = "https://files.pythonhosted.org/packages/71/63/28fb5696e52f40f4bafd096a3d0ed2c8679a24311ed89c200a280ee3b68a/litellm-1.100.4.tar.gz", hash = "sha256:c031cbc63848be16403725594f6060fe002dc2e65f0866878285d72923ac27ab", size = 17338625, upload-time = "2026-09-29T23:04:57.065Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/67/5e/407031806577da6b1f3c9217774dabcc1353345337282093915411596aa9/litellm-1.100.4-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c86e2e3370674557547022eb9acd8ec842f802bddb2cfa97992332ad56309a53" }, - { url = "https://files.pythonhosted.org/packages/db/71/a284cce63fa6c37dab677415d146c968d9d802e1eed2778efcdddba49010/litellm-1.100.4-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:737c99748f9a37e74d4927ad9fe311e3f531a5379ea423eb0d1412d3310f1643" }, - { url = "https://files.pythonhosted.org/packages/0c/55/c16164e6cbae93437f50aaf550c5dcacc7a1ca4f1af80ece2c116fbeb01e/litellm-1.100.4-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7228d4d12eb332e5e7b7dbfeb41a4182784ad2acf86f6b7eb806565e04912ada" }, - { url = "https://files.pythonhosted.org/packages/ff/09/d99612074d3e114c449fc928d7374f09d8e87cc74dc36fdbb466fc1b9f5a/litellm-1.100.4-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ded14b15466163c76c9a7da0cd091663b4bebbff9aa56724a5905386a9d12532" }, - { url = "https://files.pythonhosted.org/packages/6e/a0/cc57f435963705f6baeaa717a870a616dd83981217daa81c9de2c2456a0d/litellm-1.100.4-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:eb12645456c19dd56f1fa5d7e165a2a76effb1501ace5d0a4df18efc0ac5c39a" }, - { url = "https://files.pythonhosted.org/packages/d5/dd/b7b458120100e47fa17718fa7aa7f1953cc01b34ce0439cc9f681cc746a0/litellm-1.100.4-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:aa8e8507678451b18c4369b69506d8948154b2ab4af211454dd062380597c1ce" }, - { url = "https://files.pythonhosted.org/packages/26/35/51d5be49f6add1bea14a0d47cb4f125fc343150095e2e655aa02cba2e885/litellm-1.100.4-cp310-abi3-win_amd64.whl", hash = "sha256:83342175eb948f146f405735b03c1cb1e5c332aeef6a98cfb31f59233d10426a" }, + { url = "https://files.pythonhosted.org/packages/67/5e/407031806577da6b1f3c9217774dabcc1353345337282093915411596aa9/litellm-1.100.4-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c86e2e3370674557547022eb9acd8ec842f802bddb2cfa97992332ad56309a53", size = 23902271, upload-time = "2026-09-29T23:04:39.582Z" }, + { url = "https://files.pythonhosted.org/packages/db/71/a284cce63fa6c37dab677415d146c968d9d802e1eed2778efcdddba49010/litellm-1.100.4-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:737c99748f9a37e74d4927ad9fe311e3f531a5379ea423eb0d1412d3310f1643", size = 23553194, upload-time = "2026-09-29T23:04:43.133Z" }, + { url = "https://files.pythonhosted.org/packages/0c/55/c16164e6cbae93437f50aaf550c5dcacc7a1ca4f1af80ece2c116fbeb01e/litellm-1.100.4-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7228d4d12eb332e5e7b7dbfeb41a4182784ad2acf86f6b7eb806565e04912ada", size = 23696180, upload-time = "2026-09-29T23:04:45.734Z" }, + { url = "https://files.pythonhosted.org/packages/ff/09/d99612074d3e114c449fc928d7374f09d8e87cc74dc36fdbb466fc1b9f5a/litellm-1.100.4-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ded14b15466163c76c9a7da0cd091663b4bebbff9aa56724a5905386a9d12532", size = 24066201, upload-time = "2026-09-29T23:04:48.071Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a0/cc57f435963705f6baeaa717a870a616dd83981217daa81c9de2c2456a0d/litellm-1.100.4-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:eb12645456c19dd56f1fa5d7e165a2a76effb1501ace5d0a4df18efc0ac5c39a", size = 23769546, upload-time = "2026-09-29T23:04:50.353Z" }, + { url = "https://files.pythonhosted.org/packages/d5/dd/b7b458120100e47fa17718fa7aa7f1953cc01b34ce0439cc9f681cc746a0/litellm-1.100.4-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:aa8e8507678451b18c4369b69506d8948154b2ab4af211454dd062380597c1ce", size = 24166511, upload-time = "2026-09-29T23:04:52.583Z" }, + { url = "https://files.pythonhosted.org/packages/26/35/51d5be49f6add1bea14a0d47cb4f125fc343150095e2e655aa02cba2e885/litellm-1.100.4-cp310-abi3-win_amd64.whl", hash = "sha256:83342175eb948f146f405735b03c1cb1e5c332aeef6a98cfb31f59233d10426a", size = 23981383, upload-time = "2026-09-29T23:04:54.838Z" }, ] [[package]] @@ -8015,6 +8019,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a1/38/df03f564f43cec2684823f3cccae1a652ee7face1cbaa76fb223096e64d7/werkzeug-3.1.9-py3-none-any.whl", hash = "sha256:6392e50c78460ba618e5b21f08a71f59c99ce99cdc6cf6e3dd7e6ccca8754fab", size = 228700, upload-time = "2026-09-27T18:33:39.685Z" }, ] +[[package]] +name = "wolfxl" +version = "2.0.35" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "defusedxml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/9f/91eeb1fc3f4c2141ace46d1250712a69ff536911ce9da5bcc6a49ddf8305/wolfxl-2.0.35.tar.gz", hash = "sha256:d9d31596322c695c4e8c0e11e8eaac7611d00c798d964ca0b7d836239d10066a", size = 11758998, upload-time = "2026-10-30T13:41:39.45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/40/a562f04aacc1f2ce55e3ead282f292033d55d4fd21c82218c686c527afdf/wolfxl-2.0.35-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:c2a48d9a1f71ccf6a2b6b82625e3f811efe745c9986af949b77432bb14e403a1", size = 3721923, upload-time = "2026-10-30T13:40:42.411Z" }, + { url = "https://files.pythonhosted.org/packages/87/9e/f1ceee4378a59205f1f0fc0815048dadba6064595011408453e746c13d36/wolfxl-2.0.35-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7b5b10306fa7ff4be0ed11833b2cfd2a0136dc694608cf1c95268238ea79b824", size = 3528438, upload-time = "2026-10-30T13:40:43.956Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fb/284a9f3ad782d2db893431315f161d51b23bc06fe7ba2dbc750805a9527e/wolfxl-2.0.35-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:994fe17568f2dec7e1f28925e07024ddedb0cb2cfbb4a8ab12f71d06ab531d01", size = 3611421, upload-time = "2026-10-30T13:40:45.474Z" }, + { url = "https://files.pythonhosted.org/packages/b1/d3/e69854324ab184a5c2b0a9430a83563683dedf2088d06df729e4830a7ea3/wolfxl-2.0.35-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:bf92b706923f4cbbcf9f04de2df8861f3c41245cd761a08a1db4838da4c46441", size = 3814830, upload-time = "2026-10-30T13:40:47.219Z" }, + { url = "https://files.pythonhosted.org/packages/76/1c/c556572642674da69ca417b46e4b49cdf26d8739dfbb353ae1bd1a55d12e/wolfxl-2.0.35-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c12b123973273516870ceb499b8e43361e884c02454526906d4892a701653f6c", size = 3790183, upload-time = "2026-10-30T13:40:48.719Z" }, + { url = "https://files.pythonhosted.org/packages/2f/b9/616f9c0c5c10c9e1d4c916a22716b2c16dc9b8ea28dd47360ad195d198d4/wolfxl-2.0.35-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69ebbc580c326b79b09905a8a03dab6f41b016d969a82161755712092b7a5100", size = 4021133, upload-time = "2026-10-30T13:40:50.569Z" }, + { url = "https://files.pythonhosted.org/packages/59/56/74427cddcfe51614d0c8307eaff5a9872a0097aa1b2c7234f67641c1027f/wolfxl-2.0.35-cp313-cp313-win_amd64.whl", hash = "sha256:9d5bb597c10f392884418072db34cbefa7d3778fe0f6a904e9fe2907e0eea45e", size = 3807771, upload-time = "2026-10-30T13:40:52.092Z" }, +] + [[package]] name = "wrapt" version = "2.4.0"