Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import csv
import json
import zipfile
from datetime import datetime, time
from pathlib import Path

import chardet
Expand Down Expand Up @@ -56,6 +57,35 @@ 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
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)
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
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.

Expand All @@ -71,13 +101,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))
],
}
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]

Expand Down
92 changes: 92 additions & 0 deletions tests/test_builtin_office_skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,98 @@ 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_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()
Expand Down
Loading