Files
AgentLab/backend/tests/test_csv_grid.py
ThearchyHelios 80fcf27732 配方导入只把 .csv / .tsv 当 CSV 读;网页、XML 冒充的 Excel 照旧拒收并说清楚
复核发现(C-csv-sniff-any-text):配方导入的扫描入口 scan_any 只看内容,不是 zip、不是 OLE、前 64KB 没有 NUL
就当 CSV 读,不看扩展名。企业系统常见的「HTML 表格冒充 .xls」、误传的文本文件,会被拆成一张叫「CSV」的工作表去
起草、核对;给已有 xlsx 配方传新一期时还会报找不到工作表、给出 rename_sheet 修复,都误导用户。

改法:
- 和简单导入(tabular._CSV)一样按扩展名定:scan_any(原件, 文件名) 只把 .csv / .tsv(且内容是文字)按 CSV 读,
  别的照旧交给 xlsx_scan 拒收。内容以「<」开头的(网页、Excel 2003 的 XML 表格,含带 BOM 的 UTF-8 / UTF-16)另给
  一句「它其实是网页或 XML 格式……请用 Excel 打开后另存为 .xlsx 或 .csv」。扩展名是 CSV、内容却不是文字的(图片
  改了扩展名)照实说;内容其实是 xlsx 的照工作簿读。
- .tsv 一律按制表符分隔(同 tabular):每行逗号、制表符个数都一样时,csv.Sniffer 优先挑逗号,会把 TSV 拆错。
- 扫描时定下的分隔符记进工作表的 path("csv:," / "csv:\t"),读格子(xlsx_cells.sheet_rows)用同一个,重放、
  试运行不再各嗅各的;这一版之前只有「csv:」的照旧再嗅一次。
- Pipeline.scan 改成 (原件, 文件名),recipe_imports 的四处调用都传上暂存区记下的文件名。

测试:test_csv_grid 加 .txt 的 CSV 内容拒收、四种网页 / XML 冒充 .xls 的拒收说法、.csv 扩展名的图片、TSV 按制表符
并且分隔符经扫描 JSON 往返后读格子照用、老暂存区的 path;改回只看内容回跑两条失败。
2026-10-16 12:23:19 +08:00

150 lines
8.6 KiB
Python

"""CSV 走配方(Excel 期 5 第 4 项):CSV 适配成网格,后面的执行器、核对、溯源全部复用。
格子坐标沿用「工作表!A1」(见 csv_grid 的模块说明):CSV 没有工作表,名字固定是「CSV」,每月的文件名变了也认得出。
"""
from __future__ import annotations
import pytest
from app.data import csv_grid, recipe_engine, xlsx_cells
from app.data.recipe_types import Recipe
from app.data.xlsx_scan import SheetScan, UnsupportedTable, scan_from_json, scan_to_json
CSV = ("订单号,下单日期,门店,金额,备注\n"
"A001,2026-08-01,一号店,120,\n"
"A002,2026-08-02,二号店,80.5,\"带,逗号\"\n"
"\n"
"A003,2026-08-03,一号店,1,234,多出的一格\n").encode("utf-8")
def test_text_is_sniffed_as_csv_and_binary_is_not():
assert csv_grid.looks_like_csv(CSV)
assert csv_grid.looks_like_csv("a;b\n1;2\n".encode("gb18030"))
assert csv_grid.looks_like_csv(b"\xff\xfe" + "a\tb\n".encode("utf-16-le"))
assert not csv_grid.looks_like_csv(b"PK\x03\x04rest-of-zip")
assert not csv_grid.looks_like_csv(b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1" + b"\x00" * 100) # 旧版 .xls
assert not csv_grid.looks_like_csv(b"\x89PNG\r\n\x1a\n\x00\x00")
assert not csv_grid.looks_like_csv(b"")
def test_scan_gives_one_visible_sheet_with_bounds_and_row_counts():
scan = csv_grid.scan(CSV)
assert scan.format == "csv" and len(scan.sheets) == 1
sh = scan.sheets[0]
assert sh.name == csv_grid.CSV_SHEET and sh.state == "visible" and sh.path == csv_grid.CSV_PATH + ","
assert (sh.bounds.min_row, sh.bounds.min_col, sh.bounds.max_row, sh.bounds.max_col) == (1, 1, 5, 6)
# 空字段不算非空格;第 4 行是空行
assert sh.row_runs == [(1, 1, 5), (2, 2, 4), (3, 3, 5), (5, 5, 6)]
assert sh.nonempty == 20
again = scan_from_json(scan_to_json(scan))
assert again.format == "csv" and again.sheets[0].path == csv_grid.CSV_PATH + ","
def test_cell_values_are_typed_only_when_unambiguous():
"""整数、小数写成数;千分位、前导零、正号、科学计数法、日期都留作文字,交给配方的规则(text_number、类型)判。"""
v = csv_grid.cell_value
assert v("120") == 120 and v(" 80.5 ") == 80.5 and v("-3") == -3 and v("0") == 0
for text in ("1,234", "007", "+5", "1e5", "2026-08-01", "12%", "TRUE", "=SUM(A1:A2)", "1234567890123456"):
assert v(text) == text, text
assert v("") is None and v(" ") is None
def test_grid_and_rows_read_the_same_cells():
scan = csv_grid.scan(CSV)
grid = xlsx_cells.read_grid(CSV, scan, csv_grid.CSV_SHEET)
assert grid.get(2, 4).value == 120 and grid.get(3, 4).value == 80.5 and grid.get(3, 5).value == "带,逗号"
assert grid.get(2, 5) is None and grid.get(1, 1).value == "订单号"
assert grid.get(5, 4).value == 1 and grid.get(5, 5).value == 234 # 没加引号的千分位被逗号拆开了:照实读
rows = list(xlsx_cells.iter_rows(CSV, scan, csv_grid.CSV_SHEET, min_row=1, max_row=5, min_col=1, max_col=6))
assert [r for r, _ in rows] == [1, 2, 3, 5]
assert sum(len(cells) for _, cells in rows) == len(grid.cells) == scan.sheets[0].nonempty
small = xlsx_cells.read_grid(CSV, scan, csv_grid.CSV_SHEET, max_rows=2)
assert small.truncated and set(r for r, _ in small.cells) == {1, 2}
def test_semicolon_and_tab_delimiters_and_gbk():
scan = csv_grid.scan("名称;数量\n甲;3\n".encode("gb18030"))
grid = xlsx_cells.read_grid("名称;数量\n甲;3\n".encode("gb18030"), scan, csv_grid.CSV_SHEET)
assert grid.get(2, 1).value == "甲" and grid.get(2, 2).value == 3
raw = "a\tb\n1\t2\n".encode("utf-8")
assert xlsx_cells.read_grid(raw, csv_grid.scan(raw), csv_grid.CSV_SHEET).get(2, 2).value == 2
def test_scan_any_dispatches_and_keeps_the_xlsx_refusal():
assert csv_grid.scan_any(CSV, "orders.csv").format == "csv"
assert csv_grid.scan_any(CSV, "ORDERS.CSV").format == "csv"
with pytest.raises(UnsupportedTable):
csv_grid.scan_any(b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1" + b"\x00" * 100, "old.xls")
def test_only_csv_and_tsv_names_are_read_as_csv():
"""复核反例:以前只看内容,不是 zip / OLE、前 64KB 没有 NUL 就当 CSV 读。系统导出的「HTML 表格冒充 .xls」、
误传的文本文件,都被拆成一张叫「CSV」的工作表去起草。现在和简单导入(tabular)一样按扩展名定:只有 .csv / .tsv
按 CSV 读;别的照旧交给 xlsx_scan 拒收。"""
with pytest.raises(UnsupportedTable) as e:
csv_grid.scan_any(CSV, "orders.txt")
assert "不是有效的 .xlsx" in str(e.value)
html = "<html><body><table><tr><td>订单号</td><td>金额</td></tr></table></body></html>".encode("utf-8")
for raw in (html, b"\xef\xbb\xbf \r\n" + html, b"\xff\xfe" + "<table><tr><td>a</td></tr></table>".encode("utf-16-le"),
b'<?xml version="1.0"?><Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"/>'):
with pytest.raises(UnsupportedTable) as e:
csv_grid.scan_any(raw, "report.xls")
assert "网页或 XML" in str(e.value) and "另存为 .xlsx 或 .csv" in str(e.value)
# 扩展名是 .csv、内容却不是文字(图片之类改了扩展名):按 CSV 读不了,照实说
with pytest.raises(UnsupportedTable) as e:
csv_grid.scan_any(b"\x89PNG\r\n\x1a\n\x00\x00", "orders.csv")
assert "不是文字" in str(e.value)
def test_tsv_is_always_tab_separated_and_the_delimiter_is_kept_for_reading():
""".tsv 一律按制表符分隔(同 tabular):每行逗号个数一样的 TSV,嗅探会挑逗号(csv.Sniffer 优先逗号)。
扫描时定下的分隔符记在工作表的 path 里,之后读格子(重放、试运行)用同一个,不再各嗅各的。"""
raw = "名称,规格\t金额\n甲,1kg\t10\n乙,2kg\t20\n".encode("utf-8") # 每行一个逗号、一个制表符
scan = csv_grid.scan_any(raw, "goods.tsv")
assert scan.sheets[0].path == csv_grid.CSV_PATH + "\t"
grid = xlsx_cells.read_grid(raw, scan_from_json(scan_to_json(scan)), csv_grid.CSV_SHEET)
assert grid.get(2, 1).value == "甲,1kg" and grid.get(2, 2).value == 10
# 这一版之前的暂存区:path 只有「csv:」,读的时候再嗅
assert csv_grid.is_csv_sheet(SheetScan(name="CSV", state="visible", path=csv_grid.CSV_PATH))
assert csv_grid.sheet_delimiter(SheetScan(name="CSV", state="visible", path=csv_grid.CSV_PATH)) is None
assert not csv_grid.is_csv_sheet(SheetScan(name="明细", state="visible", path="xl/worksheets/sheet1.xml"))
sniffed = csv_grid.scan_any(raw, "goods.csv")
assert sniffed.sheets[0].path == csv_grid.CSV_PATH + "," # 同样的内容叫 .csv:照嗅探的来(逗号)
grid = xlsx_cells.read_grid(raw, sniffed, csv_grid.CSV_SHEET)
assert grid.get(2, 1).value == "甲" and grid.get(2, 2).value == "1kg\t10"
def test_too_many_columns_is_refused():
raw = (",".join(["x"] * (csv_grid.MAX_COLS + 1)) + "\n").encode()
with pytest.raises(UnsupportedTable) as e:
csv_grid.scan(raw)
assert "列" in str(e.value)
LIST_RECIPE = {
"recipe_format": "agentlab-recipe/2",
"sheets": [{"id": "s1", "match": {"name": "CSV"},
"blocks": [{"id": "列表1", "layout": "list", "table": "订单",
"columns": [{"header": "订单号", "name": "订单号", "type": "TEXT"},
{"header": "下单日期", "name": "下单日期", "type": "DATE"},
{"header": "门店", "name": "门店", "type": "TEXT"},
{"header": "金额", "name": "金额", "type": "REAL"}],
"rows": {"blank_rows": "stop"}}]}],
"tables": [{"name": "订单", "grain": ["订单号"]}],
}
def test_engine_runs_a_list_recipe_on_csv(tmp_path):
raw = "订单号,下单日期,门店,金额\nA001,2026-08-01,一号店,120\nA002,2026-08-02,二号店,80.5\n".encode()
scan = csv_grid.scan(raw)
db = tmp_path / "t.sqlite"
ex = recipe_engine.execute(Recipe.model_validate(LIST_RECIPE), raw, "orders_2026-08.csv", scan, str(db))
assert ex.ok, [p.message for p in ex.problems]
assert ex.tables[0].rows == 2 and ex.tables[0].sheet == "CSV"
# 溯源照常:订单号这一列从 CSV!A2 起往下 2 格
run = ex.lineage["订单"]["订单号"][0]
assert run[1:] == ["CSV", "A2", 2, "down"]
import sqlite3
rows = sqlite3.connect(db).execute('SELECT "订单号", "下单日期", "金额" FROM "订单" ORDER BY rowid').fetchall()
assert rows == [("A001", "2026-08-01", 120.0), ("A002", "2026-08-02", 80.5)]