Repository navigation
Conversation
|
@Harbor404 is attempting to deploy a commit to the deepset Team on Vercel. A member of the Team first needs to authorize it. |
|
Hi @Harbor404, thanks for working on this! I was also investigating #13041 and tested a few additional cases against commit I found that custom column names ( I have tests and a local fix covering these cases. Would you be open to collaborating on adding that coverage and addressing them in this PR, or in a follow-up PR if you and the maintainers prefer? Reproduction: expected vs. actual on a6d6dceThree cases: from io import BytesIO
from openpyxl import Workbook
from haystack.components.converters import XLSXToDocument
from haystack.dataclasses import ByteStream
def xlsx(rows: list[list[str]], links: dict[str, str]) -> ByteStream:
workbook = Workbook()
sheet = workbook.active
for row in rows:
sheet.append(row)
for cell, url in links.items():
sheet[cell].hyperlink = url
buffer = BytesIO()
workbook.save(buffer)
return ByteStream(data=buffer.getvalue())
people = xlsx(
[["Alice", "Bob"], ["Carol", "Dave"]], {"A1": "https://example.com/alice", "B1": "https://example.com/bob"}
)
notes = xlsx(
[["a1", "b1"], ["# note", "b2"], ["a3", "b3"], ["a4", "b4"]],
{"A3": "https://example.com/a3", "A4": "https://example.com/a4"},
)
cases = [
(people, {"names": [1, 0]}, ",A,B\n1,[Alice](https://example.com/alice),[Bob](https://example.com/bob)\n2,Carol,Dave\n"),
(people, {"names": ["second"], "usecols": "B"}, ",A\n1,[Bob](https://example.com/bob)\n2,Dave\n"),
# ideal output; at minimum a4 must not receive a3's link
(notes, {"comment": "#"}, ",A,B\n1,a1,b1\n2,[a3](https://example.com/a3),b3\n3,[a4](https://example.com/a4),b4\n"),
]
for source, read_excel_kwargs, expected in cases:
converter = XLSXToDocument(link_format="markdown", read_excel_kwargs=read_excel_kwargs)
actual = converter.run(sources=[source])["documents"][0].content
print(read_excel_kwargs, "OK" if actual == expected else "MISMATCH")
print(" expected:", repr(expected))
print(" actual: ", repr(actual))Output on |
|
Hi @Harbor404, thanks a lot for your contribution! 🙏 We noticed that the Contributor License Agreement (CLA) check ( To get your PR reviewed, please sign the CLA via the link in the |
Credit: @amna-cpp for the reproduction covering names, usecols, and comment.
|
Thanks for the detailed reproduction and triage, @amna-cpp! I've incorporated all three cases into this PR:
The implementation delegates coordinate resolution back to pandas using a tokenized copy of the sheet, so If you'd like to co-author the follow-up or have your local patch carried directly, please share the |
|
Thanks for signing the CLA, @Harbor404! 🎉 This PR is now ready for review again and the reviewer has been re-assigned. |
|
Thanks @Harbor404, I appreciate you incorporating the cases and crediting the reproduction! I’d be happy to collaborate on additional tests and review the updated approach. I’ll check it against my local cases and share any focused findings or changes here. |
|
Thanks again @Harbor404. I reviewed the coordinate-probe approach at What holds up
Two regressions I found Both arise from writing a token into every cell:
Reproduction: expected vs. actual on 5534abdfrom io import BytesIO
from openpyxl import Workbook
from openpyxl.styles import Font
from haystack.components.converters import XLSXToDocument
from haystack.dataclasses import ByteStream
def convert(workbook: Workbook, **read_excel_kwargs: object) -> str | None:
buffer = BytesIO()
workbook.save(buffer)
converter = XLSXToDocument(
link_format="markdown",
read_excel_kwargs=read_excel_kwargs,
)
documents = converter.run(
sources=[ByteStream(data=buffer.getvalue())]
)["documents"]
return documents[0].content if documents else None
# 1. A merged range, without selection options
workbook = Workbook()
sheet = workbook.active
sheet.append(["Team", None])
sheet.append(["Alice", "Profile"])
sheet.merge_cells("A1:B1")
sheet["B2"].hyperlink = "https://example.com/alice"
print(
"expected:",
repr(",A,B\n1,Team,\n2,Alice,[Profile](https://example.com/alice)\n"),
)
print("actual: ", repr(convert(workbook)))
# 2. A formatted empty cell next to the table, combined with names
workbook = Workbook()
sheet = workbook.active
sheet.append(["Alice", "Bob"])
sheet["A1"].hyperlink = "https://example.com/alice"
sheet["B1"].hyperlink = "https://example.com/bob"
sheet["C1"].font = Font(bold=True)
print(
"expected:",
repr(",A,B\n1,[Alice](https://example.com/alice),[Bob](https://example.com/bob)\n"),
)
print("actual: ", repr(convert(workbook, names=["first", "second"])))Output on Proposed changes I prepared two commits on [a branch based on your head](https://github.com/amna-cpp/haystack/commits/collab/pr-13059-xlsx-probe-cells):
On that branch, all 41 XLSX tests and 325 converter tests pass. Limitation: the first commit leaves hyperlinks on cells without a value unmapped when the probe runs. The optional second commit preserves the existing behavior when no position-related options are supplied. I could not create an empty linked cell using openpyxl, so that case remains untested. Two additional issues outside these commits
Please feel free to cherry-pick If you incorporate the changes another way, my co-author identity is: |
Keep empty and merged cells untouched, skip unnecessary probes, and avoid comment-marker collisions in coordinate tokens. Co-authored-by: Amna Hameed <183917584+amna-cpp@users.noreply.github.com>
|
Integrated
Local verification: |
bogdankostic
left a comment
There was a problem hiding this comment.
Thanks for working on this @Harbor404!
The approach looks good to me, but the PR is bigger than it needs to be. Almost half of the code added to xlsx.py is there for comment, which isn't part of #13041. Let's drop it and keep the rest close to the existing code. Details are in the inline comments.
| from tabulate import tabulate # noqa: F401 # the library is used but not directly referenced | ||
|
|
||
| # The `pandas.read_excel` options that decide which cells end up where in the DataFrame. | ||
| _POSITION_KWARGS = ("skiprows", "nrows", "skipfooter", "usecols", "names", "comment", "index_col") |
There was a problem hiding this comment.
Let's drop comment from this PR. It isn't part of #13041 and is already broken on main. Passing it to the second parse is what makes _get_position_token_parts, _encode_position, _decode_position, _make_position_token, _parse_position_token and use_letters necessary, so without it all of these can go. The three comment tests can go with it. If someone needs comment together with hyperlinks, we can handle it in a follow-up.
| _POSITION_KWARGS = ("skiprows", "nrows", "skipfooter", "usecols", "names", "comment", "index_col") | |
| _POSITION_KWARGS = ("skiprows", "nrows", "skipfooter", "usecols", "names", "index_col") |
| return workbook.active | ||
| return workbook[sheet_key] | ||
|
|
||
| def _get_cell_positions( |
There was a problem hiding this comment.
Without comment, a plain tag is enough. If this returns the hyperlinks re-keyed to their DataFrame positions instead of a separate mapping, the rest of _extract_tables barely needs to change. I tried something like this locally, and it passes all tests in this PR except test_link_extraction_with_comment, which also fails on main:
_CELL_TAG = "__haystack_cell__"
def _map_hyperlinks_to_dataframe(
self, workbook: Any, hyperlinks_by_sheet: dict[str | int | None, dict[tuple[int, int], str]]
) -> dict[str | int | None, dict[tuple[int, int], str]]:
for sheet_key in hyperlinks_by_sheet:
for row in self._get_worksheet(workbook, sheet_key).iter_rows():
for cell in row:
# Empty cells must stay empty: pandas sizes the sheet by its non-empty cells, and the placeholder
# cells of merged ranges are read-only.
if cell.value not in (None, ""):
cell.value = f"{_CELL_TAG}{cell.row - 1},{cell.column - 1}"
tagged_bytes = io.BytesIO()
workbook.save(tagged_bytes)
position_kwargs = {key: self.read_excel_kwargs[key] for key in _POSITION_KWARGS if key in self.read_excel_kwargs}
tagged_sheets = pd.read_excel(
io=tagged_bytes, sheet_name=self.sheet_name, header=None, engine="openpyxl", **position_kwargs
)
if isinstance(tagged_sheets, pd.DataFrame):
tagged_sheets = {self.sheet_name: tagged_sheets}
mapped_hyperlinks = {}
for sheet_key, cell_links in hyperlinks_by_sheet.items():
positions = {}
for row_idx, values in enumerate(tagged_sheets[sheet_key].itertuples(index=False, name=None)):
for col_idx, value in enumerate(values):
if isinstance(value, str) and value.startswith(_CELL_TAG):
excel_row, excel_col = value.removeprefix(_CELL_TAG).split(",")
positions[(int(excel_row), int(excel_col))] = (row_idx, col_idx)
mapped_hyperlinks[sheet_key] = {positions[cell]: url for cell, url in cell_links.items() if cell in positions}
return mapped_hyperlinksAnd in _extract_tables:
if any(hyperlinks_by_sheet.values()) and any(key in self.read_excel_kwargs for key in _POSITION_KWARGS):
hyperlinks_by_sheet = self._map_hyperlinks_to_dataframe(wb, hyperlinks_by_sheet)| if any(hyperlinks_by_sheet.values()) and any(key in self.read_excel_kwargs for key in _POSITION_KWARGS): | ||
| cell_positions_by_sheet = self._get_cell_positions(wb, list(sheet_to_dataframe)) | ||
| else: | ||
| # No option moves cells, or there is nothing to place: Excel coordinates are DataFrame positions. |
There was a problem hiding this comment.
Let's drop this identity mapping. If the hyperlinks are re-keyed directly, hyperlinks_by_sheet can just stay as it is when no option moves cells.
| df = sheet_to_dataframe[key] | ||
| # Row starts at 1 in Excel | ||
| df.index = df.index + 1 | ||
| # Apply hyperlinks to cell values before replacing the original column indices with Excel column names. |
There was a problem hiding this comment.
This comment is left over from the first commit, which used pandas' integer column labels. With the hyperlinks re-keyed as in my comment on _get_cell_positions, the original loop works as it is, so let's keep it where it was and drop _apply_hyperlinks.
| assert "Click here (https://example.com)" in content | ||
| assert "Docs (https://python.org)" in content | ||
|
|
||
| def test_link_extraction_with_skiprows(self, tmp_path: Path) -> None: |
There was a problem hiding this comment.
Let's merge these into one parametrized test over read_excel_kwargs. A lot of the new lines are the same workbook setup, and the internal and range links don't test anything new here, since the link target doesn't affect where the link ends up. Something like:
@pytest.mark.parametrize(
("read_excel_kwargs", "expected_content"),
[
pytest.param({"skiprows": 1}, "...", id="skiprows"),
pytest.param({"usecols": "B:C"}, "...", id="usecols"),
pytest.param({"names": [...]}, "...", id="names"),
pytest.param({"names": [...], "usecols": "B"}, "...", id="names-and-usecols"),
],
)
def test_link_extraction_with_read_excel_kwargs(
self, tmp_path: Path, read_excel_kwargs: dict[str, Any], expected_content: str
) -> None:The merged-cell and formatted-empty-cell tests are worth keeping as they are.
| Fixed ``XLSXToDocument`` assigning hyperlinks to the wrong cells when | ||
| ``read_excel_kwargs`` includes ``skiprows``, ``names``, ``usecols``, or | ||
| ``comment``. Hyperlinks now remain attached to the selected cells, | ||
| including external, internal, and range links. |
There was a problem hiding this comment.
Let's keep comment out of here (see my comment on _POSITION_KWARGS) and drop the link types, since the fix doesn't depend on them.
| Fixed ``XLSXToDocument`` assigning hyperlinks to the wrong cells when | |
| ``read_excel_kwargs`` includes ``skiprows``, ``names``, ``usecols``, or | |
| ``comment``. Hyperlinks now remain attached to the selected cells, | |
| including external, internal, and range links. | |
| Fixed ``XLSXToDocument`` attaching hyperlinks to the wrong cells when | |
| ``read_excel_kwargs`` includes ``skiprows``, ``usecols``, or ``names``. |
|
Hi @Harbor404, just a friendly reminder: this PR is still in draft because the Contributor License Agreement (CLA) hasn't been signed yet. We'd love to review your contribution! Please sign the CLA via the link in the |
The hyperlink coordinate mapping only needs to follow the cells through `read_excel_kwargs`, so the extra machinery for `comment` is out of scope for deepset-ai#13041 (and `comment` is already broken on main). - `_POSITION_KWARGS` no longer carries `comment`, which lets the position token encoders/decoders, the token-prefix/separator probing and the `use_letters` branch go with it. - `_map_hyperlinks_to_dataframe` replaces `_get_cell_positions`: non-empty cells get one plain `_CELL_TAG` placeholder, pandas resolves where they land, and the hyperlinks are re-keyed to those DataFrame positions directly. That removes the identity mapping and lets the original apply loop stay where it was, so `_apply_hyperlinks` is gone too. - The `read_excel_kwargs` cases collapse into one parametrized test; the three `comment` tests go with the feature. - The release note no longer mentions `comment` or the link kinds. Fixes the `check-imports` failure too: `_apply_hyperlinks` annotated `pd.DataFrame` in a class body, and `pd` is only imported lazily.
|
@bogdankostic all six inline comments are addressed in
Two CI notes:
Local: |
|
Thanks for signing the CLA, @Harbor404! 🎉 This PR is now ready for review again and the reviewer has been re-assigned. |
Coverage reportClick to see where and how coverage changed
This report was generated by python-coverage-comment-action |
||||||||||||||||||||||||||||||||||||
Related Issues
Proposed Changes:
XLSXToDocumentstored hyperlinks using their original zero-based Excel coordinates but applied those coordinates directly to the filtered pandas DataFrame. Whenread_excel_kwargscontainedskiprowsorusecols, rows and columns shifted and hyperlinks were attached to the wrong cells.This change maps original row positions through
skiprowsand derives selected column positions from the original integer column labels retained by pandas before replacing them with A/B column names. The unfiltered path keeps its existing identity mapping.Regression tests cover external URL, internal sheet cell, and range-target hyperlinks with
skiprows,usecols="B", andusecols="B:C".This PR includes AI-assisted changes. The final diff was reviewed and tested locally.
How did you test it?
uvx hatch run test:unit test/components/converters/test_xlsx_to_document.py -q- 32 passeduvx hatch run test:unit test/components/converters -q- 316 passed, 17 deselected, 1 warninguvx hatch run test:types- no issues in 550 source filesuvx hatch run fmt-check- passedNotes for the reviewer
skiprowssupports integer, list-like, and callable forms. Column mapping uses the integer labels pandas retains withheader=None, so string/list/callableusecolsselections follow pandas without duplicating its parsing logic. Whennamesreplaces those labels, the code preserves the previous positional behavior.Checklist