Agent 读 Excel 前,先分清公式、缓存和可见范围
工作簿里保存了数值,不代表公式已经重算;读到一行,也不代表它在屏幕上可见。
1. 收到工作簿后,先回答它究竟存了什么
假设一项 Agent 任务要求汇总一张费用表,并说明公式结果有没有异常。表里只有三笔金额:125、875、250。第三行被隐藏,另有两个公式单元格。直接把 data_only=True 读到的内容交给模型,可能得到一个空值和一个数字 1;如果程序把空值补成零,后续文字再流畅,也无法补回已经丢掉的依据。
本文把入口缩小到一个可重复检查的问题:在开始分析之前,同时留下单元格坐标、公式文本、缓存状态、数字格式、日期系统和行可见性。输出是一份 JSON 检查结果,不改原工作簿,不执行公式,不启动 Excel。读者可以在空目录保存下方三个完整脚本,生成两份原创 XLSX 后运行检查。
这不是对真实财务文件的审计。夹具由 openpyxl 生成,再用 Python 标准库透明修改 OOXML,故意删除一个公式缓存、给另一个公式写入错误的数值缓存。文件没有经过 Excel 或 LibreOffice 重算。实验要证明读取方式的差别,不宣称真实用户文件一定会出现同样的历史状态。
2. data_only 选择读取分支,不负责验证公式
openpyxl 加载说明把 data_only 定义为公式与已保存值之间的选择;公式文档明确说明库不计算公式。本文实际使用预装的 openpyxl 3.1.5,官方 stable 文档页显示 3.1.3,因此运行版本另行记录,不能把文档页版本当作本次环境版本。
本例 C2 保存 =SUM(A2:A4),但没有数值缓存;C3 保存 =A2*2,同时被人工植入缓存 1。两次加载必须面对同一份字节,检查器先读取一个内存快照,再分别创建公式视图和缓存视图。C2 的状态为 missing_or_empty,保留 JSON null;C3 为 present_freshness_unknown,保留数值 1,不升级为“结果正确”。
测试可以独立验证本例的 125 × 2 等于 250,因此这个人工缓存与本例算术不符。检查器本身没有通用公式解释器,也不把这一断言泛化到其他函数、跨表依赖或外部引用。工作簿即便带有 auto、fullCalcOnLoad 和 forceFullCalc 标志,openpyxl 的这两次读取仍返回原有缓存,不会执行一次重算。
核对本环境解析器源码时,WorkSheetParser.parse_cell 的非 data_only 分支选择公式;另一分支读取已存值并按类型及格式转换。这里把源码观察与夹具输出相互核对,没有修改库或依靠模型推算测试是否通过。
3. 从空目录造出能解释每个字节的夹具
前置条件是 Python 3.12 与已安装的 openpyxl;本次运行是 Python 3.12.14、openpyxl 3.1.5。示例不安装软件、不下载文件、不要求专有制表工具。没有这些依赖时应先准备经允许的环境。把下面完整代码保存为 make_fixture.py。生成器使用独占创建模式,目标文件已存在就停止,避免覆盖已有表格。
Data!A2:A4 是数值常量;C2、C3 是公式;E2:E5 用于格式实验。Internal 工作表设为 veryHidden,A2 写入纯属演示的 9999。auto_filter.ref 只配置一个范围,不执行筛选。两个文件底层日期序号均为 46000,只改变工作簿的日期系统标记,这样可以单独观察解释的变化。
"""Original synthetic fixture; caches are deliberately patched, not recalculated."""
from io import BytesIO
from pathlib import Path
from zipfile import ZipFile, ZIP_DEFLATED
import xml.etree.ElementTree as ET
import openpyxl
from openpyxl.workbook.properties import CalcProperties
NS = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'
def tag(name):
return f'{{{NS}}}{name}'
def make_fixture(destination, date1904=False):
destination = Path(destination)
wb = openpyxl.Workbook()
ws = wb.active
ws.title = 'Data'
ws.append(['amount', 'note', 'formula', None, 'format_sample'])
for row, amount in enumerate([125, 875, 250], 2):
ws.cell(row, 1, amount)
ws['B3'] = 'deliberately hidden'
ws.row_dimensions[3].hidden = True
ws['C2'] = '=SUM(A2:A4)'
ws['C3'] = '=A2*2'
for addr, value, fmt in [('E2', 46000, 'General'),
('E3', 46000, 'yyyy-mm-dd'),
('E4', .25, '0%'), ('E5', 7, '0000')]:
ws[addr] = value
ws[addr].number_format = fmt
ws.auto_filter.ref = 'A1:B4' # Metadata only, no filtering performed.
hidden = wb.create_sheet('Internal')
hidden['A1'] = 'synthetic internal value'
hidden['A2'] = 9999
hidden.sheet_state = 'veryHidden'
wb.calculation = CalcProperties(calcMode='auto', fullCalcOnLoad=True,
forceFullCalc=True)
stream = BytesIO()
wb.save(stream)
wb.close()
with ZipFile(BytesIO(stream.getvalue())) as archive:
parts = {name: archive.read(name) for name in archive.namelist()}
# Paths are known because this generator created this two-sheet package.
sheet = ET.fromstring(parts['xl/worksheets/sheet1.xml'])
for addr, cache in [('C2', None), ('C3', '1')]:
cell = sheet.find(f'.//{tag("c")}[@r="{addr}"]')
for node in list(cell):
if node.tag == tag('v'):
cell.remove(node)
if cache is not None:
ET.SubElement(cell, tag('v')).text = cache
parts['xl/worksheets/sheet1.xml'] = ET.tostring(sheet)
book = ET.fromstring(parts['xl/workbook.xml'])
book.find(tag('workbookPr')).set('date1904', '1' if date1904 else '0')
parts['xl/workbook.xml'] = ET.tostring(book)
# Exclusive create: do not replace an existing workbook.
with destination.open('xb') as target:
with ZipFile(target, 'w', ZIP_DEFLATED) as archive:
for name, data in parts.items():
archive.writestr(name, data)
return destination
if __name__ == '__main__':
for epoch in [1900, 1904]:
print(make_fixture(f'controlled-{epoch}.xlsx', epoch == 1904))
4. 只读检查器保留范围与不确定性
保存下面代码为 intake.py。这里“只读”指程序不调用 save、不替换输入文件、不触发外部应用。为读取完整行尺寸元数据,加载参数明确采用 read_only=False,它不是 openpyxl 的流式 ReadOnlyWorksheet 模式。工作簿和字节快照都载入内存,适用于本页的小型可信夹具;不应据此宣称能安全处理任意上传文件。
检查范围固定为 Data!C2:C3 的公式、Data!A2:A4 的金额,以及四个格式样本。它不是全工作簿扫描器,改用真实文件之前必须显式定义工作表、范围和业务口径。金额范围若出现空值、字符串、布尔值或公式文本,会抛出错误,而不是猜测如何补值。keep_links=False 在此关闭外部链接信息的保留;它不是隔离恶意文件的安全沙箱。本例不解析或刷新外部数据源。
"""Inspect the tutorial's small, trusted synthetic workbooks without saving them."""
from io import BytesIO
from pathlib import Path
from hashlib import sha256
from datetime import datetime, date, time
import json
import sys
import openpyxl
def encode(value):
if isinstance(value, (datetime, date, time)):
return value.isoformat()
return value
def inspect_workbook(path):
blob = Path(path).read_bytes() # Both parser views use the same byte snapshot.
formula_book = openpyxl.load_workbook(BytesIO(blob), data_only=False,
read_only=False, keep_links=False)
try:
values_book = openpyxl.load_workbook(BytesIO(blob), data_only=True,
read_only=False, keep_links=False)
try:
ws, values = formula_book['Data'], values_book['Data']
formula_cells = []
# Bounded fixture range. This is not a whole-workbook scanner.
for row in ws.iter_rows(min_row=2, max_row=3, min_col=3, max_col=3):
for cell in row:
if cell.data_type != 'f':
continue
cache = values[cell.coordinate].value
formula_cells.append({
'sheet': ws.title, 'cell': cell.coordinate,
'formula': cell.value, 'cached_value': encode(cache),
'cache_status': ('missing_or_empty' if cache is None
else 'present_freshness_unknown')})
amounts = [ws.cell(r, 1).value for r in range(2, 5)]
# Fail closed on a changed teaching fixture; no None -> 0 coercion.
if any(type(value) not in (int, float) for value in amounts):
raise ValueError('Data!A2:A4 must contain numeric constants')
hidden_rows = [r for r in range(2, 5)
if r in ws.row_dimensions and ws.row_dimensions[r].hidden]
calc = formula_book.calculation
return {
'sha256': sha256(blob).hexdigest(),
'openpyxl': openpyxl.__version__,
'epoch': formula_book.epoch.date().isoformat(),
'scope': 'Data!A2:A4; exclude_hidden means saved row hidden flags',
'sheet_states': {s.title: s.sheet_state for s in formula_book},
'internal_A2': formula_book['Internal']['A2'].value,
'formulas': formula_cells,
'calculation_flags': {'calcMode': calc.calcMode,
'fullCalcOnLoad': calc.fullCalcOnLoad,
'forceFullCalc': calc.forceFullCalc},
'auto_filter_ref': ws.auto_filter.ref,
'amounts': amounts, 'hidden_rows': hidden_rows,
'all_rows_sum': sum(amounts),
'exclude_hidden_sum': sum(ws.cell(r, 1).value for r in range(2, 5)
if r not in hidden_rows),
'cells': {addr: {'value': encode(ws[addr].value),
'python_type': type(ws[addr].value).__name__,
'number_format': ws[addr].number_format}
for addr in ['E2', 'E3', 'E4', 'E5']}}
finally:
values_book.close()
finally:
formula_book.close()
if __name__ == '__main__':
print(json.dumps(inspect_workbook(sys.argv[1]), ensure_ascii=False, indent=2))
5. 1250 与 375:结果必须带上它的范围
循环读取行不会因为第三行 hidden 就自动略过它。本夹具 all_rows_sum 是 125 + 875 + 250 = 1250;exclude_hidden_sum 只排除文件中保存了行 hidden 标记的第三行,因此为 375。行尺寸 API提供 hidden 属性,但“保存的隐藏标记”并不等于用户此刻屏幕上的筛选视图。
筛选文档说明,openpyxl 配置的筛选和排序需要由 Excel 等应用执行。本文没有评估筛选条件,没有复现当前窗口、筛选器、分组或界面显示。本例 auto_filter.ref 虽然存在,仍读取三个金额。若请求写的是“当前可见费用”,应先确认指全范围、排除行 hidden,还是指定筛选条件后的范围。
Microsoft 的 SUBTOTAL 说明区分手动隐藏行与筛选排除行:1–11 与 101–111 对手动隐藏的处理不同,筛选排除行另有规则。这里的 Python 求和没有实现 SUBTOTAL 的语义,也没有处理嵌套小计,不应把 375 标成“Excel SUBTOTAL 已验证”。
同样,veryHidden 是工作表可见性状态,不是访问权限。本例的普通读取仍能取得 Internal!A2 的 9999。检查器为了教学显式输出这个合成值;真实流程应先确定允许向模型传递的工作表与范围,不能因某张表在界面上看不见就假设其中内容不会被读到。
6. 别把显示样式当成原始字符串
E2 与 E3 在 XML 中都存数值 46000。E2 使用 General,读取结果是整数;E3 使用日期格式,openpyxl 将它解释为 datetime。在 1900 日期系统中,本例读取为 2025-12-09;只把日期系统改为 1904,同一序号读为 2029-12-10。Microsoft 日期系统说明解释了两套日期系统之间 1462 天的差异。把日期转换为文本前,应同时记录工作簿 epoch 和单元格格式,不能凭五位数字猜测。
另外两个样本更容易在清洗阶段被误改:0.25 配置 0% 后,Python 读到的仍是浮点数 0.25;7 配置 0000 后,仍是整数 7。这两次读取没有返回“25%”或“0007”这样的 Excel 显示字符串。业务若需要展示文本,应另行定义格式化规则并验证;不能以为 values_only 或 data_only 已经完成渲染。本文也没有验证区域设置、条件格式或字体造成的视觉效果。
7. 实际运行与预期输出
将三个脚本放进同一空目录后运行下面命令。不要先用 Excel 打开生成的文件;其他程序可能重新保存或重算它们,从而改变实验条件。脚本再次运行会因目标文件存在而停止,重新实验可以换一个空目录。检查器只把结果写到标准输出,重定向生成的是独立 JSON 文件。
python make_fixture.py
python intake.py controlled-1900.xlsx > result-1900.json
python intake.py controlled-1904.xlsx > result-1904.json
python -m unittest -v test_intake.py1900 版本的完整输出结构如下;SHA-256 是该次生成文件的内容指纹,工作簿与 ZIP 的生成时间可能改变文件字节,因此不能要求不同运行的哈希相同。其余业务断言由下一节测试验证。
{
"sha256": "d8c355de0dcdb1ebcbd8b3f9db610f9b78657e88798ed4f63a29d6f463027c69",
"openpyxl": "3.1.5",
"epoch": "1899-12-30",
"scope": "Data!A2:A4; exclude_hidden means saved row hidden flags",
"sheet_states": {
"Data": "visible",
"Internal": "veryHidden"
},
"internal_A2": 9999,
"formulas": [
{
"sheet": "Data",
"cell": "C2",
"formula": "=SUM(A2:A4)",
"cached_value": null,
"cache_status": "missing_or_empty"
},
{
"sheet": "Data",
"cell": "C3",
"formula": "=A2*2",
"cached_value": 1,
"cache_status": "present_freshness_unknown"
}
],
"calculation_flags": {
"calcMode": "auto",
"fullCalcOnLoad": true,
"forceFullCalc": true
},
"auto_filter_ref": "A1:B4",
"amounts": [
125,
875,
250
],
"hidden_rows": [
3
],
"all_rows_sum": 1250,
"exclude_hidden_sum": 375,
"cells": {
"E2": {
"value": 46000,
"python_type": "int",
"number_format": "General"
},
"E3": {
"value": "2025-12-09T00:00:00",
"python_type": "datetime",
"number_format": "yyyy-mm-dd"
},
"E4": {
"value": 0.25,
"python_type": "float",
"number_format": "0%"
},
"E5": {
"value": 7,
"python_type": "int",
"number_format": "0000"
}
}
}
1904 版本的完整输出如下。它应只在日期系统、E3 的日期值和文件指纹等相应字段发生变化;金额、公式文本、人工缓存及格式仍相同。两个 expected JSON 是随包附带夹具的实测输出,不是由真实 Excel 重算得到的标准答案。
{
"sha256": "f9f479b5fd9cedfc3413fe76db339bcf5f4fb811dd8f4bd8948bb960d4b8cb92",
"openpyxl": "3.1.5",
"epoch": "1904-01-01",
"scope": "Data!A2:A4; exclude_hidden means saved row hidden flags",
"sheet_states": {
"Data": "visible",
"Internal": "veryHidden"
},
"internal_A2": 9999,
"formulas": [
{
"sheet": "Data",
"cell": "C2",
"formula": "=SUM(A2:A4)",
"cached_value": null,
"cache_status": "missing_or_empty"
},
{
"sheet": "Data",
"cell": "C3",
"formula": "=A2*2",
"cached_value": 1,
"cache_status": "present_freshness_unknown"
}
],
"calculation_flags": {
"calcMode": "auto",
"fullCalcOnLoad": true,
"forceFullCalc": true
},
"auto_filter_ref": "A1:B4",
"amounts": [
125,
875,
250
],
"hidden_rows": [
3
],
"all_rows_sum": 1250,
"exclude_hidden_sum": 375,
"cells": {
"E2": {
"value": 46000,
"python_type": "int",
"number_format": "General"
},
"E3": {
"value": "2029-12-10T00:00:00",
"python_type": "datetime",
"number_format": "yyyy-mm-dd"
},
"E4": {
"value": 0.25,
"python_type": "float",
"number_format": "0%"
},
"E5": {
"value": 7,
"python_type": "int",
"number_format": "0000"
}
}
}
8. 把解释写成十一项可执行测试
保存下方代码为 test_intake.py。测试在临时目录重新生成夹具,不依赖 ZIP 中附带的两份工作簿。原始 XML 断言检查缺失的 v 节点、人工缓存 1 和日期底数 46000;应用层断言检查两种读取、范围合计及输入字节不变。所有测试均在本次环境实际运行通过。
import hashlib
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest import TestCase, main
from zipfile import ZipFile
import xml.etree.ElementTree as ET
from make_fixture import make_fixture, tag
from intake import inspect_workbook
class IntakeTests(TestCase):
def setUp(self):
self.tmp = TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.path = make_fixture(Path(self.tmp.name) / '1900.xlsx')
self.result = inspect_workbook(self.path)
def test_missing_cache(self):
cell = self.result['formulas'][0]
self.assertEqual(cell['formula'], '=SUM(A2:A4)')
self.assertIsNone(cell['cached_value'])
self.assertEqual(cell['cache_status'], 'missing_or_empty')
def test_present_cache_does_not_mean_fresh(self):
cell = self.result['formulas'][1]
self.assertEqual(cell['formula'], '=A2*2')
self.assertEqual(cell['cached_value'], 1)
self.assertEqual(cell['cache_status'], 'present_freshness_unknown')
# Independent arithmetic for this specific formula, not an evaluator.
self.assertEqual(self.result['amounts'][0] * 2, 250)
self.assertNotEqual(cell['cached_value'], self.result['amounts'][0] * 2)
def test_calculation_flags_do_not_recalculate(self):
self.assertEqual(self.result['calculation_flags'],
{'calcMode': 'auto', 'fullCalcOnLoad': True,
'forceFullCalc': True})
self.assertEqual(self.result['formulas'][1]['cached_value'], 1)
def test_hidden_row_scope(self):
self.assertEqual(self.result['amounts'], [125, 875, 250])
self.assertEqual(self.result['hidden_rows'], [3])
self.assertEqual(self.result['all_rows_sum'], 1250)
self.assertEqual(self.result['exclude_hidden_sum'], 375)
def test_hidden_sheet_is_readable(self):
self.assertEqual(self.result['sheet_states']['Internal'], 'veryHidden')
self.assertEqual(self.result['internal_A2'], 9999)
def test_dates_depend_on_epoch(self):
p = make_fixture(Path(self.tmp.name) / '1904.xlsx', True)
r = inspect_workbook(p)
self.assertEqual(self.result['cells']['E3']['value'], '2025-12-09T00:00:00')
self.assertEqual(r['cells']['E3']['value'], '2029-12-10T00:00:00')
self.assertEqual(r['cells']['E2']['value'], 46000)
def test_number_formats_do_not_render_strings(self):
cells = self.result['cells']
self.assertEqual(cells['E4'], {'value': .25, 'python_type': 'float', 'number_format': '0%'})
self.assertEqual(cells['E5'], {'value': 7, 'python_type': 'int', 'number_format': '0000'})
self.assertEqual(cells['E2']['python_type'], 'int')
def test_raw_xml_caches_and_serial(self):
with ZipFile(self.path) as z:
root = ET.fromstring(z.read('xl/worksheets/sheet1.xml'))
cell = lambda a: root.find(f'.//{tag("c")}[@r="{a}"]')
self.assertIsNone(cell('C2').find(tag('v')))
self.assertEqual(cell('C3').findtext(tag('v')), '1')
self.assertEqual(cell('E3').findtext(tag('v')), '46000')
def test_inspection_does_not_modify_file(self):
before = self.path.read_bytes()
inspect_workbook(self.path)
self.assertEqual(self.path.read_bytes(), before)
self.assertEqual(self.result['sha256'], hashlib.sha256(before).hexdigest())
def test_filter_metadata_is_not_an_applied_view(self):
self.assertEqual(self.result['auto_filter_ref'], 'A1:B4')
self.assertEqual(len(self.result['amounts']), 3)
def test_generator_refuses_overwrite(self):
with self.assertRaises(FileExistsError):
make_fixture(self.path)
if __name__ == '__main__':
main()
Ran 11 tests
OK这些测试验证的是受控输入下的读取行为。哈希不变证明本次检查没有改写文件,不证明文件来源可信,也不证明宏、外部链接或嵌入对象安全。目录中的生成器会写新文件;检查器自身只读输入,两者职责需要在执行任务时分别说明。
9. 交给 Agent 的任务单与一份合格回复
读入检查结果后,任务应引用坐标和口径,让文字结论可以回到文件中的具体位置。下面任务单仅针对本页夹具,可直接配合生成的 result-1900.json 使用。不要将其中固定范围当成适用于所有费用表的默认设置。
依据 result-1900.json 解释这份合成工作簿。 金额范围固定 Data!A2:A4,同时报告全部行与排除保存的 hidden 行两种口径。 保留 C2、C3 的公式文本、缓存值和 cache_status。 不要把缺缓存转成零,不要把存在缓存说成公式已验证。 说明日期系统以及 E2:E5 的值、类型和格式。 只使用这份检查结果,不打开外部链接、不执行宏、不启动重算程序, 不修改工作簿、不发送其中内容。需要当前筛选视图或已重算结果时列为待确认。
一份符合这些边界的回复可以是:
Data!A2:A4 的三个常量为 125、875、250,全部行合计 1250;排除保存为 hidden 的第三行后合计 375。后一项只按行标记计算,没有验证 Excel 当前筛选视图。C2 的公式为 =SUM(A2:A4),缓存缺失;C3 的公式为 =A2*2,缓存为 1,新鲜度未知。本例对 A2 做独立乘二得到 250,与人工缓存不符,但读取器未执行公式。工作簿使用 1900 日期系统,E3 的日期读取为 2025-12-09。E4、E5 保持数值 0.25 和 7,显示格式分别为 0% 和 0000。若需要已重算公式或界面可见结果,还需在获准的表格应用流程中另行验证。
上面是示范交付文字,不是一次真实模型调用的成绩。它只总结已测证据。面对真实表格,还应补充文件来源、允许读取的工作表、单位、空值约定及异常处理,再决定哪些内容可以进入模型上下文。
10. 边界与后续验证
本文完成了小型原创 XLSX 的生成、两种读取视图、元数据和原始 XML 的交叉检查。尚未验证真实 Excel 或 LibreOffice 重算、动态数组、外部链接、数据透视表缓存、宏、筛选视觉效果、真实模型效果,以及大文件的安全性或性能。没有采用宏启用文件,也没有启动外部应用或安装任何依赖。
面对不可信上传,单靠固定读取范围、关闭链接保留或文件大小限制,不能构成安全承诺。容器隔离、解压预算、解析器风险、公式注入和内容披露都需要独立设计与验证;本页脚本没有实现这些生产控制。需要扩展时,先在新夹具中增加一种特性,写清预期,再扩大被允许的输入范围。
与 CSV 对账相比,这里检查的是工作簿保存语义;与 PDF 入口相比,这里关注坐标、计算状态和格式,而不是页码与版面;与自由文本结构化相比,这里要避免把解析出的数值误当成已经完成计算与渲染的结果。保住这些差异后,Agent 才有足够依据解释表格,而不会把未知状态藏进一句总计。
11. 补测:改成流式读取后,1250 为什么只剩 1000?
前文检查器使用 read_only=False,并固定金额范围;它没有发生下面的截断。这里另做一个优化时容易漏掉的实验:改用 read_only=True,再用没有显式边界的 iter_rows() 汇总。如果文件声明的使用范围比实际保存的单元格小,流式迭代可能提前结束,留下一个看起来合理的数字。
新表只含表头与三笔常量 125、875、250,没有公式、隐藏行或筛选。三份完整表的单元格 XML 相同,只把 worksheet dimension 分别写成 A1:B4、A1:B3、A1:A1。声明为 A1:B3 时,默认读取只得到前两笔,1250 变成 1000,没有抛异常。另造一份真正只有 A1 的表,避免把“一格范围”误判为损坏。
这是 Python 3.12.14、openpyxl 3.1.5 的小型合成实验。openpyxl 3.1.5 官方文档说明只读模式依赖生成方提供的范围信息,并在已知范围声明有误时给出 reset_dimensions() 的处理方式;只读工作簿也需显式关闭。文档核对日期为 2026-10-13,不是该页面的发布日期。下面的 force、旧边界与按坐标读取结果,另外由本次代码和测试验证。
12. 四组对照:force、单格读取与旧 max_row 都要分开看
| 夹具 | 默认读出行数 | 默认小计 | 只调用 force | reset 后重读 | 普通读取 |
|---|---|---|---|---|---|
| 正确声明 A1:B4 | 4 | 1250 | 1250 | 1250 | 1250 |
| 少报一行 A1:B3 | 3 | 1000 | 1000 | 1250 | 1250 |
| 声称 A1:A1,实际四行 | 1 | 0 | 0 | 1250 | 1250 |
| 真正只有 A1 | 1 | 0 | 0 | 0 | 0 |
行数包含表头。表中 0 是示例函数对“没读到数值”求和的结果,不是已经核验整张表后得到的总计;实际接入 Agent 时,不能用它替代缺失、未读完或范围未知状态。真正只有 A1 的负对照在 reset 后仍只有一格,因此 A1:A1 本身不是错误证据。
对 A1:B3 夹具,ws['A4'].value 能读出 250,但默认迭代仍少最后一行。一个坐标读得到,不代表整段迭代已经读全。本版本中,单独调用 calculate_dimension(force=True) 也仍返回 A1:B3;只有先 reset 再 force,才重新得到 A1:B4。不能把 force 参数理解成无条件刷新已有声明。
reset 后的 max_row、max_column 都是 None。若先保存 old_max_row = ws.max_row,再把这个旧值 3 传给 iter_rows(max_row=old_max_row),即使已经 reset,仍只得到 1000。相反,调用方若已经知道且获准读取固定范围 A1:B4,本例直接传入 min_row=1、max_row=4、min_col=1、max_col=2,就能取得四行,不必先扫描全表来寻找业务范围。
13. 完整代码:只改 dimension,逐次重新打开相同输入
保存以下脚本到新的空目录,与前文三个脚本分开放置。fixture() 在内存里造表,固定 ZIP 与核心属性的时间,然后修改声明。三份完整表之间仅 xl/worksheets/sheet1.xml 这一成员不同;把其中 dimension 的 ref 归一化后,该成员的其余原始字节也相等。测试还直接比较原始 sheetData 片段,并记录其 SHA-256,避免只用“金额看起来相同”来认定输入等价。
相同的是单元格部分,不是整份 XLSX:三份完整表的文件哈希各不相同。每个读取方法则重新打开同一份不可变的 blob;没有在方法之间保存、重算或重写文件。最后重新打开原来的 A1:B3 字节,默认读取依旧是 1000,说明 reset 没有修复磁盘文件。
保存为 dimension_lab.py:
"""Small trusted XLSX fixtures only; no model, network, Excel or file repair."""
from datetime import datetime
from hashlib import sha256
from io import BytesIO
import json
import platform
import re
from zipfile import ZIP_STORED, ZipFile, ZipInfo
import xml.etree.ElementTree as ET
import openpyxl
NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
SHEET = "xl/worksheets/sheet1.xml"
ROWS = [["amount", "note"], [125, "one"], [875, "two"], [250, "tail"]]
def fixture(declared="A1:B4", single=False):
"""Only dimension changes between full-table variants; all bodies equal."""
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Data"
for row in ([["amount"]] if single else ROWS):
ws.append(row)
wb.properties.created = datetime(2020, 1, 1)
raw = BytesIO()
wb.save(raw)
wb.close()
with ZipFile(BytesIO(raw.getvalue())) as z:
parts = {i.filename: z.read(i) for i in z.infolist()}
sheet = ET.fromstring(parts[SHEET])
sheet.find(f"{{{NS}}}dimension").set("ref", declared)
parts[SHEET] = ET.tostring(sheet)
# The library sets modified to now during save. Fix this fixture metadata.
core = ET.fromstring(parts["docProps/core.xml"])
core.find("{http://purl.org/dc/terms/}modified").text = "2020-01-01T00:00:00Z"
parts["docProps/core.xml"] = ET.tostring(core)
out = BytesIO()
with ZipFile(out, "w", ZIP_STORED) as z:
for name, payload in sorted(parts.items()):
entry = ZipInfo(name, (2020, 1, 1, 0, 0, 0))
entry.create_system = 3
entry.external_attr = 0o600 << 16
z.writestr(entry, payload)
return out.getvalue()
def xml_evidence(blob):
with ZipFile(BytesIO(blob)) as z:
sheet_xml = z.read(SHEET)
sheet = ET.fromstring(sheet_xml)
data = sheet.find(f"{{{NS}}}sheetData")
return {
"declared_dimension": sheet.find(f"{{{NS}}}dimension").get("ref"),
"cell_coordinates": [c.get("r") for c in data.iter(f"{{{NS}}}c")],
"sheet_data_sha256": sha256(ET.tostring(data)).hexdigest(),
"stored_sheet_data_sha256": sha256(
re.search(rb"<ns0:sheetData>.*?</ns0:sheetData>", sheet_xml).group()
).hexdigest(),
}
def read(blob, mode):
"""Every mode gets a fresh parser over exactly the same immutable bytes."""
wb = openpyxl.load_workbook(BytesIO(blob), read_only=(mode != "normal"),
data_only=True, keep_links=False)
try:
ws = wb["Data"]
declared = ws.calculate_dimension()
stale_max_row = ws.max_row
extra = {}
bounds = {}
if mode == "force_without_reset":
extra["forced_dimension"] = ws.calculate_dimension(force=True)
elif mode == "reset":
ws.reset_dimensions()
extra["bounds_after_reset"] = [ws.max_row, ws.max_column]
elif mode == "reset_then_force":
ws.reset_dimensions()
extra["forced_dimension"] = ws.calculate_dimension(force=True)
elif mode == "explicit_scope":
bounds = dict(min_row=1, max_row=4, min_col=1, max_col=2)
elif mode == "reset_but_stale_limit":
ws.reset_dimensions()
bounds = dict(max_row=stale_max_row, max_col=2)
elif mode not in {"normal", "default"}:
raise ValueError(mode)
rows = [list(r) for r in ws.iter_rows(values_only=True, **bounds)]
# Illustrative subtotal of observed numeric first-column values only.
# No assertion that this represents the entire workbook.
values = [r[0] for r in rows[1:] if r and type(r[0]) is int]
return {"initial_dimension": declared, "rows": rows,
"observed_amounts": values, "observed_subtotal": sum(values),
**extra}
finally:
wb.close()
def direct_a4(blob):
wb = openpyxl.load_workbook(BytesIO(blob), read_only=True, data_only=True)
try:
return wb["Data"]["A4"].value
finally:
wb.close()
def run():
cases = {}
for name, declared, single in [
("correct", "A1:B4", False),
("row_understated", "A1:B3", False),
("single_cell_claim", "A1:A1", False),
("genuine_single_cell", "A1:A1", True),
]:
blob = fixture(declared, single)
modes = ["default", "normal", "force_without_reset", "reset", "reset_then_force"]
if not single:
modes += ["explicit_scope", "reset_but_stale_limit"]
cases[name] = {
"sha256": sha256(blob).hexdigest(), "bytes": len(blob),
"xml": xml_evidence(blob), "direct_A4": direct_a4(blob),
"modes": {mode: read(blob, mode) for mode in modes},
}
return {"python": platform.python_version(), "openpyxl": openpyxl.__version__,
"cases": cases}
if __name__ == "__main__":
print(json.dumps(run(), ensure_ascii=False, indent=2))
14. 十项测试与四个真正运行过的错误版本
测试把 [125,875,250]、1250、1000、A1:B4 和各行坐标写成独立预期。正确范围、真正单格、显式范围、force、旧 max_row、源文件不变各有对照。原始 ZIP 成员的比较限定于这批生成器输出,不是一个通用 OOXML 等价判断器。
保存为 test_dimension_lab.py:
from hashlib import sha256
from io import BytesIO
import re
from zipfile import ZipFile
from pathlib import Path
from tempfile import TemporaryDirectory
import unittest
from dimension_lab import fixture, read, xml_evidence, direct_a4, ROWS, SHEET
class DimensionTests(unittest.TestCase):
def test_fixtures_deterministic_and_body_identical(self):
variants = [fixture(d) for d in ["A1:B4", "A1:B3", "A1:A1"]]
self.assertEqual(variants[0], fixture("A1:B4"))
self.assertEqual(len({xml_evidence(b)["sheet_data_sha256"] for b in variants}), 1)
self.assertEqual(len({sha256(b).hexdigest() for b in variants}), 3)
def test_only_dimension_attribute_changes_between_full_tables(self):
blobs = [fixture(d) for d in ["A1:B4", "A1:B3", "A1:A1"]]
archives = []
for blob in blobs:
with ZipFile(BytesIO(blob)) as z:
archives.append({n: z.read(n) for n in z.namelist()})
base = archives[0]
for parts in archives[1:]:
self.assertEqual(set(parts), set(base))
self.assertEqual([n for n in base if base[n] != parts[n]], [SHEET])
normalize = lambda b: re.sub(rb'(<ns0:dimension ref=")[^"]+(" />)',
rb'\1NORMALIZED\2', b)
self.assertEqual(normalize(base[SHEET]), normalize(parts[SHEET]))
# Compare the actual stored sheetData fragment, not just a parsed hash.
body = lambda b: re.search(rb'<ns0:sheetData>.*?</ns0:sheetData>', b).group()
self.assertEqual(body(base[SHEET]), body(parts[SHEET]))
def test_known_cell_content_and_no_formulas(self):
evidence = xml_evidence(fixture("A1:B3"))
self.assertEqual(evidence["cell_coordinates"],
["A1", "B1", "A2", "B2", "A3", "B3", "A4", "B4"])
self.assertEqual(read(fixture("A1:B3"), "explicit_scope")["observed_amounts"],
[125, 875, 250])
with ZipFile(BytesIO(fixture("A1:B3"))) as z:
self.assertNotIn(b'<ns0:f', z.read(SHEET))
def test_correct_dimensions_control(self):
blob = fixture()
for mode in ["default", "normal", "reset", "force_without_reset"]:
with self.subTest(mode=mode):
result = read(blob, mode)
self.assertEqual(result["rows"], ROWS)
self.assertEqual(result["observed_subtotal"], 1250)
def test_understated_rows_silently_clip_subtotal(self):
blob = fixture("A1:B3")
self.assertEqual(read(blob, "default")["rows"], ROWS[:3])
self.assertEqual(read(blob, "default")["observed_subtotal"], 1000)
self.assertEqual(read(blob, "normal")["rows"], ROWS)
self.assertEqual(read(blob, "reset")["rows"], ROWS)
def test_single_cell_claim_does_not_prove_empty_table(self):
blob = fixture("A1:A1")
self.assertEqual(read(blob, "default")["rows"], [["amount"]])
self.assertEqual(direct_a4(blob), 250)
self.assertEqual(read(blob, "reset")["rows"], ROWS)
def test_force_alone_is_not_a_refresh(self):
blob = fixture("A1:B3")
self.assertEqual(read(blob, "force_without_reset")["forced_dimension"], "A1:B3")
self.assertEqual(read(blob, "force_without_reset")["observed_subtotal"], 1000)
self.assertEqual(read(blob, "reset")["bounds_after_reset"], [None, None])
self.assertEqual(read(blob, "reset_then_force")["forced_dimension"], "A1:B4")
def test_genuine_single_cell_is_not_an_error(self):
blob = fixture("A1:A1", single=True)
for mode in ["default", "normal", "reset", "reset_then_force"]:
with self.subTest(mode=mode):
self.assertEqual(read(blob, mode)["rows"], [["amount"]])
self.assertIsNone(direct_a4(blob))
def test_explicit_scope_works_but_stale_limit_still_clips(self):
blob = fixture("A1:B3")
self.assertEqual(read(blob, "explicit_scope")["rows"], ROWS)
self.assertEqual(read(blob, "reset_but_stale_limit")["rows"], ROWS[:3])
def test_reset_does_not_repair_source_file(self):
with TemporaryDirectory() as directory:
path = Path(directory) / "original.xlsx"
path.write_bytes(fixture("A1:B3"))
before = path.read_bytes()
self.assertEqual(read(before, "reset")["observed_subtotal"], 1250)
self.assertEqual(path.read_bytes(), before)
self.assertEqual(read(path.read_bytes(), "default")["observed_subtotal"], 1000)
if __name__ == "__main__":
unittest.main(verbosity=2)
下面的变异脚本每次复制源码到临时目录,再实际运行同一组十项测试。四个错误版本分别删掉 reset、用 force 替代 reset、把旧 max_row 塞回 reset 分支,以及删掉显式范围。原版退出码为 0;四个变异版退出码都为 1,并产生断言失败,而不只是导入错误。每个失败的测试名保存在 mutation-results.json。
保存为 mutation_checks.py:
"""Run the unchanged tests against actual broken copies in temporary folders."""
import json
from pathlib import Path
import subprocess
import sys
from tempfile import TemporaryDirectory
ROOT = Path(__file__).resolve().parent
SOURCE = (ROOT / 'dimension_lab.py').read_text()
OLD = ''' elif mode == "reset":
ws.reset_dimensions()
extra["bounds_after_reset"] = [ws.max_row, ws.max_column]'''
MUTANTS = {
'omit_reset': OLD.replace('ws.reset_dimensions()', 'pass # broken: no reset'),
'force_instead_of_reset': OLD.replace('ws.reset_dimensions()',
'ws.calculate_dimension(force=True)'),
'reuse_stale_limit': OLD + '\n bounds = dict(max_row=stale_max_row, max_col=2)',
'drop_explicit_bounds': None,
}
def run_tests(source):
with TemporaryDirectory() as folder:
path = Path(folder)
(path / 'dimension_lab.py').write_text(source)
(path / 'test_dimension_lab.py').write_bytes(
(ROOT / 'test_dimension_lab.py').read_bytes())
result = subprocess.run(
[sys.executable, '-B', '-m', 'unittest', '-v', 'test_dimension_lab.py'],
cwd=path, text=True, capture_output=True, timeout=60)
failures = [line for line in result.stderr.splitlines()
if line.startswith(('FAIL:', 'ERROR:'))]
return {'exit_code': result.returncode, 'failures': failures}
def run():
if SOURCE.count(OLD) != 1:
raise RuntimeError('Reset branch anchor changed')
baseline = run_tests(SOURCE)
if baseline['exit_code'] != 0:
raise RuntimeError(f'Baseline failed: {baseline}')
records = {}
for name, replacement in MUTANTS.items():
if replacement is None:
anchor = 'bounds = dict(min_row=1, max_row=4, min_col=1, max_col=2)'
if SOURCE.count(anchor) != 1:
raise RuntimeError('Explicit range anchor changed')
mutant = SOURCE.replace(anchor, 'bounds = {} # broken: trusts declaration')
else:
mutant = SOURCE.replace(OLD, replacement)
result = run_tests(mutant)
# Require an assertion failure; an import error alone is not a kill.
if result['exit_code'] == 0 or not any(x.startswith('FAIL:') for x in result['failures']):
raise RuntimeError(f'Mutant not detected by assertions: {name}: {result}')
records[name] = result
return {'baseline': baseline, 'mutants': records}
if __name__ == '__main__':
print(json.dumps(run(), ensure_ascii=False, indent=2))
15. 复现输出与范围边界
环境已有 openpyxl 时运行下面三条命令;不会安装软件、下载表格或调用模型。第一、三条输出到独立 JSON,unittest 使用临时目录。源码包中另保留前文 original-experiment 目录,它的十一项测试也已重新通过,两个实验不共享夹具。
python -B dimension_lab.py > observed.json
python -B -m unittest -v test_dimension_lab.py
python -B mutation_checks.py > mutation-results.json下面从本次完整 observed.json 与测试记录摘出关键结果。它不是脚本标准输出的全部内容;完整 JSON、四份小型 XLSX 和全部脚本都在源码包中。文件指纹对应本次运行时,换版本后不要预设生成字节仍相同。
{
"runtime": {
"python": "3.12.14",
"openpyxl": "3.1.5"
},
"row_understated": {
"declared_dimension": "A1:B3",
"stored_coordinates": [
"A1",
"B1",
"A2",
"B2",
"A3",
"B3",
"A4",
"B4"
],
"subtotals": {
"default": 1000,
"normal": 1250,
"force_without_reset": 1000,
"reset": 1250,
"reset_then_force": 1250,
"explicit_scope": 1250,
"reset_but_stale_limit": 1000
},
"direct_A4": 250,
"source_sha256": "966b7903636a268740f840580bc14972ca56109a9311213e65561429b50315f3",
"stored_sheet_data_sha256": "4c96a8a749ba9d4a708696527c9ff90dccec8045ec977eea0a2e70090171597d"
},
"tests_passed": 10,
"mutants_detected": 4
}
排查真实输入时,先留下文件哈希、原声明、读取模式及实际采用的范围;发现与已知业务范围冲突,再选择获准的显式范围或重新确定边界。读取完成后关闭工作簿。不能仅凭“reset 后有结果”就宣称所有工作表完整、金额正确、公式已验证或文件可信。
本补测只使用四份不足 19 KiB 的受控文件,没有运行 Excel 或 LibreOffice,没有公式求值,也没有测试 pandas、稀疏超大坐标、合并单元格、异常 XML、重复 ZIP 成员、外部链接或不可信上传。清除边界可能增加扫描量;这里没有建立解压、内存、CPU 或超时预算。允许读哪些表和单元格仍由任务范围决定,不能因 reset 扩大了可读范围,就自动扩大可传给 Agent 的内容。
16. 下载完整复现实验包
下载 XLSX 读取实验包(ZIP)。包含与页面逐字一致的六份 Python 源码、六份受控 XLSX、完整实测 JSON、测试记录、README 和逐文件 SHA-256 清单。根目录是维度实验,original-experiment/ 是前文公式缓存实验。无需另外寻找文中提到的源码包。
本包在 Python 3.12.14、openpyxl 3.1.5 下重新运行既有的 11 + 10 项测试,全部通过;原有四个维度错误版本均由断言失败检出。这里补齐的是下载交付,没有增加算法覆盖,也没有修改检查器。包内 expected-1900.json 和 expected-1904.json 与本包夹具匹配;页面上方保留历史输出示例,原始实验生成时间不同会使文件哈希不同,不能把旧哈希用作本包的验收值。
解压到新目录后按 README 运行。包内已带同名夹具,不要直接重跑 original-experiment/make_fixture.py;它会按设计拒绝覆盖。可以直接运行检查器和测试,或把三个原始实验脚本复制到另一个空目录后重新生成。包内文件可用 SHA256SUMS 核对;这只检查文件完整性,不证明安全性或公式正确性。
ZIP:40412 字节;SHA-256:976491963533eb8e49f7b54d9d818bbf481e41e2da20569f1e6451932edd5960。本次仅复现小型合成输入,未执行 Excel/LibreOffice 重算、真实模型或不可信文件安全验证;浏览器与移动端点击下载交互尚未验证。
data_only=True 会算出公式吗?
不会。本例读到已保存缓存;没有缓存时保留 None,有缓存也不证明新鲜。
排除 hidden 行就是当前筛选视图吗?
不是。本文只按保存的行标记排除,未执行筛选,也未复现 Excel 界面。
veryHidden 表是否无法读取?
本例可以读取。它是可见性状态,不能作为数据访问授权。
本例用了真实 Excel 生成的缓存吗?
没有。两份原创文件由 openpyxl 生成,再用标准库人工删除或植入缓存,专用于受控读取实验。