feat:优化增量执行、工具调用容错与评测体系

This commit is contained in:
2026-10-11 04:40:25 -07:00
parent cd5a2b4d8f
commit dadd797391
22 changed files with 2198 additions and 578 deletions
+182 -63
View File
@@ -1,16 +1,20 @@
"""把各种格式读成原始网格(CSV、Excel)或按列的记录(JSON、JSONL),Parquet 直接读成 Arrow 表
CSV 和 Excel 可能有标题行、多行表头、合计行,所以先读成不带表头的网格,再由 tables.py 识别表区域
文本格式边读边转成列(grid.py),不把整个文件解码进内存
"""
import codecs
import csv
import io
import json
from dataclasses import dataclass, field
from itertools import islice
from pathlib import Path
from typing import Any
import pyarrow as pa
import pyarrow.csv as pacsv
import pyarrow.parquet as pq
from python_calamine import (
CalamineWorkbook,
@@ -28,7 +32,8 @@ from app.datafiles.base import (
rows_exceeded,
scaled,
)
from app.datafiles.columns import as_text
from app.datafiles.cells import as_text
from app.datafiles.grid import CHUNK_ROWS, Column, Grid, GridBuilder
from app.schemas.data_file import ParseWarning
# 原始网格比数据多出的标题、表头、空行,读取时先粗略拦住明显超限的文件,精确的行数在拆表后检查
@@ -43,37 +48,120 @@ MAX_BAD_LINE_RATIO = 0.01
# 逐行读取时每隔这么多行报告一次进度
PROGRESS_ROWS = 20_000
# Arrow 读 CSV 每次处理的字节数;单行超过它时 Arrow 报错,改用标准库
ARROW_BLOCK_BYTES = 4 * 1024 * 1024
# 单元格最长的字符数;csv 模块默认 128KB,长文本单元格会报错
csv.field_size_limit(16 * 1024 * 1024)
@dataclass
class Grid:
"""不带表头的原始网格;rows[i] 是文件或 sheet 中的第 i + 1 行"""
sheet: str | None
rows: list[list[Any]]
@dataclass
class Records:
"""已有列结构的数据,按列存放"""
names: list[str]
columns: list[list[Any]]
columns: list[Column]
warnings: list[ParseWarning] = field(default_factory=list)
def read_delimited(text: str, delimiter: str, report: Reporter = no_report) -> Grid:
rows = []
stream = io.StringIO(text, newline="")
for row in csv.reader(stream, delimiter=delimiter):
rows.append(row)
if len(rows) > MAX_TABLE_ROWS + ROW_SLACK:
raise rows_exceeded(None)
if len(rows) % PROGRESS_ROWS == 0:
report(stream.tell() / len(text))
return Grid(None, rows)
def read_delimited(
path: Path, codec: str, delimiter: str, report: Reporter = no_report
) -> Grid:
"""codec 是 detect 确认过的编码,整个文件都能按它解码
先用 Arrow 读(C++ 实现,快一个数量级);它要求每行列数相同,遇到标题行、说明行这类长短不齐的行,
或引号不成对、单行过长,就报错。这时改用标准库 csv 从头读一遍。Arrow 读得出来时两者结果相同,测试里对比
"""
report = _increasing(report)
try:
return _read_delimited_arrow(path, codec, delimiter, report)
except pa.ArrowInvalid:
return _read_delimited_python(path, codec, delimiter, report)
def _read_delimited_arrow(
path: Path, codec: str, delimiter: str, report: Reporter
) -> Grid:
with path.open("rb") as raw, io.TextIOWrapper(raw, codec, newline="") as text:
first = next(csv.reader(text, delimiter=delimiter), [])
if not first:
raise pa.ArrowInvalid("第一行是空行,列数要从后面的行推断")
names = [f"c{i}" for i in range(len(first))]
builder = GridBuilder(strings=True)
size = max(path.stat().st_size, 1)
with path.open("rb") as raw:
if codec == "utf-8-sig" and raw.read(3) != codecs.BOM_UTF8:
raw.seek(0)
reader = pacsv.open_csv(
raw,
read_options=pacsv.ReadOptions(
column_names=names,
block_size=ARROW_BLOCK_BYTES,
encoding="utf8" if codec.startswith("utf-8") else codec,
),
parse_options=pacsv.ParseOptions(
delimiter=delimiter, newlines_in_values=True, ignore_empty_lines=False
),
convert_options=pacsv.ConvertOptions(
column_types={name: pa.string() for name in names},
strings_can_be_null=False,
quoted_strings_can_be_null=False,
),
)
batches: list[pa.RecordBatch] = []
waiting = 0
for batch in reader:
batches.append(batch)
waiting += len(batch)
# 攒到一个块再转:每段太短时,类型推断的固定开销会变大
if waiting >= CHUNK_ROWS:
_add_batches(builder, batches)
batches, waiting = [], 0
if builder.height > MAX_TABLE_ROWS + ROW_SLACK:
raise rows_exceeded(None)
report(raw.tell() / size)
if batches:
_add_batches(builder, batches)
if builder.height > MAX_TABLE_ROWS + ROW_SLACK:
raise rows_exceeded(None)
return builder.finish(None)
def _add_batches(builder: GridBuilder, batches: list[pa.RecordBatch]) -> None:
builder.add_strings(
[
pa.concat_arrays([batch.column(i) for batch in batches])
for i in range(batches[0].num_columns)
]
)
def _read_delimited_python(
path: Path, codec: str, delimiter: str, report: Reporter
) -> Grid:
builder = GridBuilder(strings=True)
size = max(path.stat().st_size, 1)
with path.open("rb") as raw, io.TextIOWrapper(raw, codec, newline="") as text:
reader = csv.reader(text, delimiter=delimiter)
while rows := list(islice(reader, PROGRESS_ROWS)):
builder.add_rows(rows)
if builder.height > MAX_TABLE_ROWS + ROW_SLACK:
raise rows_exceeded(None)
report(raw.tell() / size)
return builder.finish(None)
def _increasing(report: Reporter) -> Reporter:
"""Arrow 读到一半失败、换标准库从头读时,进度不往回走"""
reached = 0.0
def inner(fraction: float) -> None:
nonlocal reached
if fraction > reached:
reached = fraction
report(fraction)
return inner
def read_excel(
@@ -104,9 +192,19 @@ def read_excel(
sheet = book.get_sheet_by_name(meta.name)
if sheet.total_height > MAX_TABLE_ROWS + ROW_SLACK:
raise rows_exceeded(meta.name)
# 保留左上角的空白区域,行号和 Excel 里看到的一致
rows = sheet.to_python(skip_empty_area=False)
if not any(cell != "" for row in rows for cell in row):
# 解析 sheet 的 XML 占读取的大部分时间,逐行转成列占剩下的
rows = scaled(
report, (number + 0.8) / len(sheets), (number + 1) / len(sheets)
)
# iter_rows 保留顶部的空行,行号和 Excel 里看到的一致;左侧的空列不影响识别表区域;
# 没有单元格的 sheet 上 iter_rows 会崩溃,不调用
builder = GridBuilder()
cells = sheet.iter_rows() if sheet.height else iter(())
while batch := list(islice(cells, PROGRESS_ROWS)):
builder.add_rows(batch)
rows(builder.height / sheet.height)
grid = builder.finish(meta.name)
if not any(column.present().any() for column in grid.columns):
warnings.append(
ParseWarning(
code="empty_sheet_skipped",
@@ -114,7 +212,7 @@ def read_excel(
)
)
continue
grids.append(Grid(meta.name, rows))
grids.append(grid)
finally:
book.close()
return grids, warnings
@@ -137,42 +235,53 @@ def read_json(value: Any, report: Reporter = no_report) -> Grid | Records:
if not records:
raise DataFileError(422, "JSON 里没有找到数据")
if all(isinstance(item, list) for item in records):
return Grid(None, [[_scalar(cell) for cell in row] for row in records])
if len(records) > MAX_TABLE_ROWS + ROW_SLACK:
raise rows_exceeded(None)
return Grid.from_rows(
None, ([_scalar(cell) for cell in row] for row in records)
)
if all(isinstance(item, dict) for item in records):
result = _flatten_records(records, report)
flattener = _Flattener()
for number, record in enumerate(records):
if number % PROGRESS_ROWS == 0:
report(number / len(records))
flattener.add(record)
result = flattener.finish()
result.warnings = warnings
return result
if not any(isinstance(item, (dict, list)) for item in records):
return Records(["值"], [list(records)], warnings)
if len(records) > MAX_TABLE_ROWS:
raise rows_exceeded(None)
return Records(["值"], [Column.from_values(records)], warnings)
raise DataFileError(422, "JSON 数组里的元素类型不一致,需要都是对象或都是数组")
def read_jsonl(text: str, report: Reporter = no_report) -> Records:
records, bad, total = [], [], 0
lines = text.splitlines()
# 解析每行 JSON 和展开成列各占一半
parsing = scaled(report, 0.0, 0.5)
for number, line in enumerate(lines, start=1):
if number % PROGRESS_ROWS == 0:
parsing(number / len(lines))
if not line.strip():
continue
total += 1
try:
record = json.loads(line)
except ValueError:
record = None
if isinstance(record, dict):
records.append(record)
else:
bad.append(number)
if not records:
def read_jsonl(path: Path, codec: str, report: Reporter = no_report) -> Records:
"""逐行读取;codec 是 detect 确认过的编码"""
flattener, bad, total = _Flattener(), [], 0
size = max(path.stat().st_size, 1)
with path.open("rb") as raw, io.TextIOWrapper(raw, codec) as text:
for number, line in enumerate(text, start=1):
if number % PROGRESS_ROWS == 0:
report(raw.tell() / size)
if not line.strip():
continue
total += 1
try:
record = json.loads(line)
except ValueError:
record = None
if isinstance(record, dict):
flattener.add(record)
else:
bad.append(number)
if not flattener.count:
raise DataFileError(422, "JSONL 里没有找到数据")
if len(bad) > total * MAX_BAD_LINE_RATIO:
raise DataFileError(
422, f"JSONL 有 {len(bad)} 行无法解析(例如第 {bad[0]} 行),超过 1%"
)
result = _flatten_records(records, scaled(report, 0.5, 1.0))
result = flattener.finish()
if bad:
result.warnings.append(
ParseWarning(
@@ -226,24 +335,34 @@ def _is_record_list(value: Any) -> bool:
)
def _flatten_records(records: list[dict], report: Reporter = no_report) -> Records:
# 列按首次出现的顺序排列,记录里没有的字段为空
index: dict[str, int] = {}
columns: list[list[Any]] = []
for row_number, record in enumerate(records):
if row_number % PROGRESS_ROWS == 0:
report(row_number / len(records))
class _Flattener:
"""逐条展开记录,按列存放:列按首次出现的顺序排列,记录里没有的字段为空"""
def __init__(self) -> None:
self._index: dict[str, int] = {}
self._builder = GridBuilder()
@property
def count(self) -> int:
return self._builder.height
def add(self, record: dict) -> None:
if self._builder.height >= MAX_TABLE_ROWS:
raise rows_exceeded(None)
flat: dict[str, Any] = {}
_flatten(record, "", 0, flat)
row: list[Any] = [None] * len(self._index)
for name, value in flat.items():
if name not in index:
index[name] = len(columns)
columns.append([None] * row_number)
columns[index[name]].append(value)
for column in columns:
if len(column) == row_number:
column.append(None)
return Records(list(index), columns)
position = self._index.setdefault(name, len(self._index))
if position < len(row):
row[position] = value
else:
row.append(value)
self._builder.add(row)
def finish(self) -> Records:
grid = self._builder.finish(None)
return Records(list(self._index), grid.columns)
def _flatten(record: dict, prefix: str, level: int, out: dict[str, Any]) -> None: