From 09454193d93afe92cf2dd657e9705713062cc152 Mon Sep 17 00:00:00 2001 From: L4XB Date: Wed, 16 Sep 2026 20:17:01 +0200 Subject: [PATCH 1/2] fix(skills): inspect a legacy .xls by its values, not by its storage xlrd hands back what Excel stores rather than what the cell says, so the spreadsheet skill described the same sheet two different ways: .xls .xlsx date 45292.0 2024-01-01 00:00:00 bool 1 True int 12.0 12 The date is the one that cannot be recovered afterwards: the sample the model reads says 45292.0 and nothing marks it as a date. Convert the date, the boolean and the whole number the way openpyxl already delivers them, and report an error cell as the text Excel shows. A float that is not whole and every string are untouched. --- .../spreadsheets/scripts/inspect_workbook.py | 33 +++++++++++- pyproject.toml | 1 + tests/test_builtin_office_skills.py | 50 +++++++++++++++++++ 3 files changed, 83 insertions(+), 1 deletion(-) diff --git a/astrbot/builtin_stars/astrbot/skills/spreadsheets/scripts/inspect_workbook.py b/astrbot/builtin_stars/astrbot/skills/spreadsheets/scripts/inspect_workbook.py index 11fc0e3457..2001171807 100644 --- a/astrbot/builtin_stars/astrbot/skills/spreadsheets/scripts/inspect_workbook.py +++ b/astrbot/builtin_stars/astrbot/skills/spreadsheets/scripts/inspect_workbook.py @@ -7,6 +7,7 @@ import csv import json import zipfile +from datetime import datetime from pathlib import Path import chardet @@ -56,6 +57,32 @@ def _inspect_delimited(path: Path, sample_rows: int, sample_cols: int) -> dict: } +def _xls_cell_value(cell: xlrd.sheet.Cell, datemode: int) -> object: + """Read one xlrd cell as its value, the way the XLSX path already reads one. + + xlrd hands back the raw storage: a date is the serial number Excel keeps it + as, a boolean is 1 or 0, and every number is a double, so a whole number + arrives as 12.0. Left alone, the same workbook is described one way as .xls + and another as .xlsx, and the date cannot be recovered from the sample. + """ + if cell.ctype == xlrd.XL_CELL_DATE: + try: + year, month, day, hour, minute, second = xlrd.xldate_as_tuple( + cell.value, datemode + ) + except (ValueError, xlrd.XLDateError): + return cell.value + return datetime(year, month, day, hour, minute, second) + if cell.ctype == xlrd.XL_CELL_BOOLEAN: + return bool(cell.value) + if cell.ctype == xlrd.XL_CELL_NUMBER and float(cell.value).is_integer(): + return int(cell.value) + if cell.ctype == xlrd.XL_CELL_ERROR: + # openpyxl reports the text Excel shows, e.g. #DIV/0! + return xlrd.error_text_from_code.get(cell.value, "") + return cell.value + + def _inspect_xls(path: Path, sample_rows: int, sample_cols: int) -> dict: """Inspect a legacy XLS workbook. @@ -71,13 +98,17 @@ def _inspect_xls(path: Path, sample_rows: int, sample_cols: int) -> dict: sheets = [] for name in workbook.sheet_names(): sheet = workbook.sheet_by_name(name) + columns = min(sheet.ncols, sample_cols) sheets.append( { "name": name, "rows": sheet.nrows, "columns": sheet.ncols, "sample": [ - sheet.row_values(row, end_colx=min(sheet.ncols, sample_cols)) + [ + _xls_cell_value(cell, workbook.datemode) + for cell in sheet.row_slice(row, 0, columns) + ] for row in range(min(sheet.nrows, sample_rows)) ], } diff --git a/pyproject.toml b/pyproject.toml index 14406011ac..13a0322b80 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,6 +80,7 @@ dev = [ "pytest>=8.4.1", "pytest-asyncio>=1.1.0", "pytest-cov>=6.2.1", + "xlwt>=1.3.0", # writes the legacy .xls fixtures the spreadsheet skill tests read "ruff==0.15.22", ] diff --git a/tests/test_builtin_office_skills.py b/tests/test_builtin_office_skills.py index aae86d47c5..558c76b6e5 100644 --- a/tests/test_builtin_office_skills.py +++ b/tests/test_builtin_office_skills.py @@ -74,6 +74,56 @@ def test_spreadsheet_skill_converts_inspects_and_validates_csv(tmp_path: Path) - assert json.loads(validated.stdout)["valid"] is True +def test_spreadsheet_skill_inspects_legacy_xls_values_not_their_storage( + tmp_path: Path, +) -> None: + """xlrd hands back the raw storage, so the two formats described one sheet + two different ways: a date as its serial number, a boolean as 1, and a whole + number as a float. + """ + import pytest + + xlwt = pytest.importorskip("xlwt") + + import datetime + + date_style = xlwt.XFStyle() + date_style.num_format_str = "YYYY-MM-DD" + + book = xlwt.Workbook() + sheet = book.add_sheet("Data") + for column, heading in enumerate(["When", "Active", "Units", "Rate"]): + sheet.write(0, column, heading) + sheet.write(1, 0, datetime.date(2024, 1, 1), date_style) + sheet.write(1, 1, True) + sheet.write(1, 2, 12) + sheet.write(1, 3, 1.5) + legacy = tmp_path / "legacy.xls" + book.save(legacy) + + modern = tmp_path / "modern.xlsx" + workbook = Workbook() + worksheet = workbook.active + worksheet.title = "Data" + worksheet.append(["When", "Active", "Units", "Rate"]) + worksheet.append([datetime.date(2024, 1, 1), True, 12, 1.5]) + workbook.save(modern) + workbook.close() + + inspected_xls = _run_script(SPREADSHEET_SCRIPTS / "inspect_workbook.py", legacy) + inspected_xlsx = _run_script(SPREADSHEET_SCRIPTS / "inspect_workbook.py", modern) + + assert inspected_xls.returncode == 0, inspected_xls.stderr + assert inspected_xlsx.returncode == 0, inspected_xlsx.stderr + + xls_sample = json.loads(inspected_xls.stdout)["sheets"][0]["sample"] + xlsx_sample = json.loads(inspected_xlsx.stdout)["sheets"][0]["sample"] + + # [45292.0, 1, 12.0, 1.5] before this. + assert xls_sample[1] == ["2024-01-01 00:00:00", True, 12, 1.5] + assert xls_sample == xlsx_sample + + def test_spreadsheet_skill_rejects_broken_formula_reference(tmp_path: Path) -> None: path = tmp_path / "broken.xlsx" workbook = Workbook() From f81f481dc27fa4ee73c31c83053b4eeac42911ae Mon Sep 17 00:00:00 2001 From: L4XB Date: Wed, 16 Sep 2026 20:45:37 +0200 Subject: [PATCH 2/2] fix(skills): read a time-only .xls cell as a time, not as a broken date `xldate_as_tuple` reports a cell that carries only a time as `(0, 0, 0, hour, minute, second)`, so `datetime(...)` raised `ValueError: year 0 is out of range` and the inspector exited with an error instead of printing the sample. openpyxl reads the same cell in an .xlsx as a `datetime.time`, so return one here too. --- .../spreadsheets/scripts/inspect_workbook.py | 5 ++- tests/test_builtin_office_skills.py | 42 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/astrbot/builtin_stars/astrbot/skills/spreadsheets/scripts/inspect_workbook.py b/astrbot/builtin_stars/astrbot/skills/spreadsheets/scripts/inspect_workbook.py index 2001171807..a9975ff303 100644 --- a/astrbot/builtin_stars/astrbot/skills/spreadsheets/scripts/inspect_workbook.py +++ b/astrbot/builtin_stars/astrbot/skills/spreadsheets/scripts/inspect_workbook.py @@ -7,7 +7,7 @@ import csv import json import zipfile -from datetime import datetime +from datetime import datetime, time from pathlib import Path import chardet @@ -72,6 +72,9 @@ def _xls_cell_value(cell: xlrd.sheet.Cell, datemode: int) -> object: ) except (ValueError, xlrd.XLDateError): return cell.value + if (year, month, day) == (0, 0, 0): + # A time-only cell has no date part; openpyxl reads one as a time. + return time(hour, minute, second) return datetime(year, month, day, hour, minute, second) if cell.ctype == xlrd.XL_CELL_BOOLEAN: return bool(cell.value) diff --git a/tests/test_builtin_office_skills.py b/tests/test_builtin_office_skills.py index 558c76b6e5..27448a22e9 100644 --- a/tests/test_builtin_office_skills.py +++ b/tests/test_builtin_office_skills.py @@ -124,6 +124,48 @@ def test_spreadsheet_skill_inspects_legacy_xls_values_not_their_storage( assert xls_sample == xlsx_sample +def test_spreadsheet_skill_inspects_a_legacy_xls_time_only_cell( + tmp_path: Path, +) -> None: + """A time carries no date, so xlrd reports year, month and day as zero.""" + import pytest + + xlwt = pytest.importorskip("xlwt") + + import datetime + + time_style = xlwt.XFStyle() + time_style.num_format_str = "HH:MM:SS" + + book = xlwt.Workbook() + sheet = book.add_sheet("Data") + sheet.write(0, 0, "Starts") + sheet.write(1, 0, datetime.time(12, 0, 0), time_style) + legacy = tmp_path / "legacy.xls" + book.save(legacy) + + modern = tmp_path / "modern.xlsx" + workbook = Workbook() + worksheet = workbook.active + worksheet.title = "Data" + worksheet.append(["Starts"]) + worksheet.append([datetime.time(12, 0, 0)]) + workbook.save(modern) + workbook.close() + + inspected_xls = _run_script(SPREADSHEET_SCRIPTS / "inspect_workbook.py", legacy) + inspected_xlsx = _run_script(SPREADSHEET_SCRIPTS / "inspect_workbook.py", modern) + + assert inspected_xls.returncode == 0, inspected_xls.stderr + assert inspected_xlsx.returncode == 0, inspected_xlsx.stderr + + xls_sample = json.loads(inspected_xls.stdout)["sheets"][0]["sample"] + xlsx_sample = json.loads(inspected_xlsx.stdout)["sheets"][0]["sample"] + + assert xls_sample[1] == ["12:00:00"] + assert xls_sample == xlsx_sample + + def test_spreadsheet_skill_rejects_broken_formula_reference(tmp_path: Path) -> None: path = tmp_path / "broken.xlsx" workbook = Workbook()