Agent 读取 CSV 前,先留下一份输入证据清单

在字段映射之前保存原始输入证据,让结构异常和空值待确认状态有明确出口。

1. 表格出现之前,输入已经发生了变化

Agent 接收一份 CSV 后,常见的第一步是把它读成 DataFrame,再交给后面的规则或模型解释。到了这个位置,某些输入区别已经看不见了:原文中的 NA 可能变成缺失值,少写的一列可能被补成空字符串,重复表头可能获得自动追加的后缀。后续程序看到的表格很整齐,却未必能回答“文件里原来写了什么”。

本文在表格映射之前增加一份 CSV 输入证据清单,下面称为 manifest。它绑定保留下来的原始文件,逐条记录解析出的字段字符串、字段数、逻辑记录编号和物理起止行;结构不符合约定时保留证据并阻断后续处理。这个入口适合放在 Agent 的文件接收工具中,先完成确定性的验收,再允许业务解释继续。

示例全部使用固定合成输入。本次离线运行环境是 Python 3.12.14、pandas 2.2.3,没有安装依赖、调用模型或读取真实用户文件。manifest 程序与主体测试只用 Python 标准库;pandas 仅用于复现几种读取行为。完整源码、输入定义和手写预期都在正文中,复制到一个空目录即可重建实验。

2. 两项 pandas 选项解决的是不同问题

固定主样本包含八条数据记录,表头为 item_id,label,note。前两个 label 分别写成 NA 和 NULL,随后各放一个未加引号和加引号的空字段,再放空格、带空格的文字及字符串 0。第一条 note 内含一个 CRLF,方便同时检验记录定位。

默认 read_csv 在这份样本中把前四个 label 转成缺失值。只设置 dtype=str,缺失值推断仍然发生;只设置 keep_default_na=False,item_id 仍被推断为整数。两项合用后,主样本中的解码字段字符串得到保留。这是两类独立的转换开关,具体含义可查 pandas 2.2.3 read_csv 文档。

样本中的一个空格、引号内的一个空格和 NA 在默认读取中都没有成为缺失值,因此不能把结果概括成“空白都会被吞掉”。另一个实验显式给 label 设置 na_values=[''],只把两个空字符串解释成缺失值。这是在演示选择一条规则会产生什么结果,本文的入口不会自动采用它。

图 1 这四种 read_csv 设置使用同一份 206 字节输入;结构异常在另外的短行、长行和重复表头夹具中验证。
图 1 这四种 read_csv 设置使用同一份 206 字节输入;结构异常在另外的短行、长行和重复表头夹具中验证。

3. 字段列表能留下表格隐藏的结构异常

把 a,b,c 后面的数据写成 1,2,两项选项合用的 pandas 读取结果仍是三个字段,最后补了一个空字符串。源文件少写一列,与源文件写了第三个空字段,在这里变成相同的单元格值。csv.reader 分别返回两个和三个字符串,可以在补齐发生之前留下这个区别。

另一份微型样本的表头为 a,b,第一条数据是 1,2,3。本次 pandas 把第一个字段推断为索引,列 a、b 获得后面的 2、3;如果先出现一条正常的两字段记录,再出现三字段记录,同一读取方式则抛出 ParserError。不能把“列数超出表头总会报错”作为入口保证。

重复的 id,id 表头在 pandas 中成为 id,id.1,而直接使用 csv.DictReader 会让后一个 id 覆盖前一个。本文先保留表头列表,验收后才映射成字典。标准库的 strict=True 在未闭合引号样本中报错,但仍允许不等宽的字段列表;列数需要程序自己检查。Python csv 方言说明和 DictReader 说明可与后面的负控源码一起核对。

4. 给这个入口写一份有限的约定

这里采用固定 UTF-8 严格解码、逗号分隔、双引号引用、双引号加倍转义,不猜编码和分隔符,也不去除字段空格。表头必须按顺序等于 item_id,label,note,不接受重复、缺失、额外或调换顺序的表头。每条数据必须正好有三个字段,独立空记录会阻断处理。CSV 解析错误发生后,只保留此前已经返回的完整记录,并把 parse_complete 设为 false;错误区间内的残片不会冒充完整记录。

在这组教学输入里,NA 和 NULL 明确作为普通 label 文字。空 label 暂不赋予缺失、未知或零的业务含义,而是产生 empty_semantics_pending,使整个文件停在 semantics_pending。这个标记专门针对 label;空 note 允许通过。空格原样保留,本文不声称它与空 label 等价。只有表头的文件允许通过并产生零条数据,完全空文件则因没有表头而阻断。这些都是示例自己的规则,接入实际数据源前需要与其约定逐项核对。

manifest 的 ready 只表示通过上述有限契约。它没有检查业务编号是否合法、编号是否重复、note 是否可信,也没有授予任何外部行动权限。CSV 字段进入 Agent 后仍是待处理数据,不能因为输入验收通过就成为工具调用指令。

图 2 blocked 保留异常和已解析记录;semantics_pending 保留空 label 待确认;ready 只表示通过本文的有限输入契约。
图 2 blocked 保留异常和已解析记录;semantics_pending 保留空 label 待确认;ready 只表示通过本文的有限输入契约。

5. 记录定位必须绑定原文件

程序直接把解码后的完整文本传给 StringIO(newline=''),没有先过滤空行、注释或其他内容。每次 reader 返回一条记录,就取 reader.line_num 作为结束行;起始行是上一条结束行加一。Python 的 line_num 文档明确区分已经读取的源行数量和已返回的记录数量。主样本的 header 在第 1 行,R0001 横跨第 2—3 行,R0002 在第 4 行。

这些是物理行号,不能拿去对原始字节做切片。R0001 也是当前源哈希和解析约定下的顺序编号;在文件前方插入记录后,它可能指向别的数据。需要长期稳定身份时,应另外设计业务标识,不能把这个局部定位当成业务主键。

程序把输入一次读入字节对象,对这份对象计算 SHA-256,并把相同字节保存为输出目录里的 source.csv。manifest 中的文件名相对于该目录,复核时需要同时保留这两个文件。哈希能核对字节内容有没有变化,不能证明文件来自谁、获得过授权或内容为真。

图 3 R0001 占第 2—3 行,R0002 从第 4 行开始。行号按原始未过滤的文本迭代器记录,不是字节偏移。
图 3 R0001 占第 2—3 行,R0002 从第 4 行开始。行号按原始未过滤的文本迭代器记录,不是字节偏移。

csv.reader 返回的是解析后的字符串,并不保存字段的原始拼写。未加引号的空字段与 "" 都成为空字符串;引号和转义写法也不能从字段列表中逐字重建。这里保留了字段内的 CRLF,但这不构成通用的“无损 CSV”保证。原始文件的副本承担原文证据,manifest 承担结构定位,两者要一起使用。

6. 在空目录运行

把下面五个完整文件分别保存为所标注的文件名。核心实验只需现有 Python 3.12。先执行 python -m unittest -v test_intake.py,再执行 python fixtures.py 生成 14 份固定输入。fixtures.py 中的字节字面量明确写出 CRLF,避免从网页手动复制 CSV 时换行被改变。该脚本会重写当前目录的 inputs 内同名合成文件,只应在专用实验目录运行。

python -m unittest -v test_intake.py
python fixtures.py
python intake.py inputs/main.csv demo-main
python intake.py inputs/ready.csv demo-ready

第三条命令打印 semantics_pending 并返回退出码 2,这是预期的阻断结果;随后可单独运行第四条。它打印 ready 并返回 0。每个输出目录都包含 source.csv 和 manifest.json。输出目录已存在时程序报错,重新演示应换一个新目录名。代码未实现原子写入、并发锁或失败回滚,操作系统 I/O 错误会原样抛出;输出中只出现一个文件时应视为不完整,不得交给下游。

如果本机已经有 pandas 2.2.3,可额外运行 python probe_pandas.py。它不安装依赖,其他 pandas 版本会退出并说明版本不匹配。核心 manifest 与主体测试不需要 pandas。为避免优化模式去掉负控断言,运行 probe_pandas.py 时不要加 -O。

7. intake.py 完整文件

"""Small, explicit CSV intake contract. Standard library only."""
import csv
import hashlib
import io
import json
from pathlib import Path
import sys

HEADER = ['item_id', 'label', 'note']
DIALECT = dict(delimiter=',', quotechar='"', doublequote=True,
               escapechar=None, skipinitialspace=False,
               quoting=csv.QUOTE_MINIMAL, strict=True)


def inspect_bytes(raw):
    manifest = {
        'version': 1,
        'contract': 'item-label-note-v1',
        'source': {'file': 'source.csv', 'sha256': hashlib.sha256(raw).hexdigest(),
                   'byte_count': len(raw)},
        'parse': {'encoding': 'utf-8', 'errors': 'strict',
                  'newline': '', 'dialect': dict(DIALECT)},
        'expected_header': list(HEADER),
        'records': [], 'errors': [], 'pending': [],
        'parse_complete': False, 'status': 'blocked',
    }
    errors = manifest['errors']
    try:
        text = raw.decode('utf-8', errors='strict')
    except UnicodeDecodeError as exc:
        errors.append({'code': 'invalid_utf8', 'byte_start': exc.start,
                       'byte_end': exc.end})
        return manifest
    reader = csv.reader(io.StringIO(text, newline=''), **DIALECT)
    previous = 0
    try:
        for ordinal, fields in enumerate(reader):
            record = {'record_id': 'header' if ordinal == 0 else f'R{ordinal:04d}',
                      'physical_lines': [previous + 1, reader.line_num],
                      'field_count': len(fields), 'fields': fields}
            manifest['records'].append(record)
            previous = reader.line_num
            rid = record['record_id']
            if ordinal == 0:
                if len(set(fields)) != len(fields):
                    errors.append({'code': 'duplicate_header', 'record_id': rid})
                if fields != HEADER:
                    errors.append({'code': 'header_mismatch', 'record_id': rid})
            elif not fields:
                errors.append({'code': 'blank_record', 'record_id': rid})
            elif len(fields) != len(HEADER):
                errors.append({'code': 'field_count', 'record_id': rid,
                               'expected': len(HEADER), 'actual': len(fields)})
            elif manifest['records'][0]['fields'] == HEADER and fields[1] == '':
                manifest['pending'].append({'code': 'empty_semantics_pending',
                                            'record_id': rid, 'field': 'label'})
        manifest['parse_complete'] = True
    except csv.Error:
        errors.append({'code': 'csv_syntax',
                       'physical_lines': [previous + 1, reader.line_num]})
    if not manifest['records'] and manifest['parse_complete']:
        errors.append({'code': 'missing_header'})
    if not errors:
        manifest['status'] = 'semantics_pending' if manifest['pending'] else 'ready'
    return manifest


def accepted_rows(raw):
    """Recompute from bytes; never trust a caller-edited manifest status."""
    manifest = inspect_bytes(raw)
    if manifest['status'] != 'ready':
        raise ValueError(manifest['status'])
    return [{'source_sha256': manifest['source']['sha256'],
             'record_id': r['record_id'], 'physical_lines': r['physical_lines'],
             'values': dict(zip(HEADER, r['fields']))}
            for r in manifest['records'][1:]]


def main():
    if len(sys.argv) != 3:
        raise SystemExit('usage: python intake.py INPUT.csv NEW_OUTPUT_DIR')
    raw = Path(sys.argv[1]).read_bytes()
    manifest = inspect_bytes(raw)
    output = Path(sys.argv[2])
    output.mkdir(parents=True, exist_ok=False)
    (output / 'source.csv').write_bytes(raw)
    (output / 'manifest.json').write_text(
        json.dumps(manifest, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
    print(manifest['status'])
    raise SystemExit(0 if manifest['status'] == 'ready' else 2)


if __name__ == '__main__':
    main()

8. fixtures.py 完整文件

"""Fixed synthetic input bytes. No real user data."""
from pathlib import Path

MAIN = (b'item_id,label,note\r\n'
        b'0007,NA,"first line\r\nsecond line"\r\n'
        b'0042,NULL,plain\r\n'
        b'0000,,unquoted-empty\r\n'
        b'0001,"",quoted-empty\r\n'
        b'0002, ,one-space\r\n'
        b'0003," ",quoted-one-space\r\n'
        b'0004, NA ,padded-token\r\n'
        b'0005,0,literal-zero\r\n')
H = b'item_id,label,note\n'
CASES = {
    'main': MAIN,
    'ready': H + b'0007,NA,plain\n',
    'short': H + b'0007,NA\n',
    'present_empty_note': H + b'0007,NA,\n',
    'long_first': H + b'0007,NA,plain,extra\n',
    'long_later': H + b'0007,NA,plain\n0042,NULL,plain,extra\n',
    'duplicate_header': b'item_id,label,label\n0007,NA,plain\n',
    'wrong_header': b'label,item_id,note\nNA,0007,plain\n',
    'missing_header_column': b'item_id,label\n0007,NA,plain\n',
    'blank': H + b'0007,NA,plain\n\n0042,NULL,plain\n',
    'unclosed': H + b'0007,NA,"unfinished\n',
    'empty_file': b'',
    'header_only': H,
    'invalid_utf8': H + b'0007,NA,\xff\n',
}

if __name__ == '__main__':
    directory = Path('inputs')
    directory.mkdir(exist_ok=True)
    for name, raw in CASES.items():
        (directory / f'{name}.csv').write_bytes(raw)
    print(f'{len(CASES)} synthetic inputs written')

9. expected.json 完整文件

{
  "main": {"status": "semantics_pending", "errors": [], "records": 9, "parse_complete": true, "pending": ["R0003", "R0004"]},
  "ready": {"status": "ready", "errors": [], "records": 2, "parse_complete": true, "pending": []},
  "short": {"status": "blocked", "errors": ["field_count"], "records": 2, "parse_complete": true, "pending": []},
  "present_empty_note": {"status": "ready", "errors": [], "records": 2, "parse_complete": true, "pending": []},
  "long_first": {"status": "blocked", "errors": ["field_count"], "records": 2, "parse_complete": true, "pending": []},
  "long_later": {"status": "blocked", "errors": ["field_count"], "records": 3, "parse_complete": true, "pending": []},
  "duplicate_header": {"status": "blocked", "errors": ["duplicate_header", "header_mismatch"], "records": 2, "parse_complete": true, "pending": []},
  "wrong_header": {"status": "blocked", "errors": ["header_mismatch"], "records": 2, "parse_complete": true, "pending": []},
  "missing_header_column": {"status": "blocked", "errors": ["header_mismatch"], "records": 2, "parse_complete": true, "pending": []},
  "blank": {"status": "blocked", "errors": ["blank_record"], "records": 4, "parse_complete": true, "pending": []},
  "unclosed": {"status": "blocked", "errors": ["csv_syntax"], "records": 1, "parse_complete": false, "pending": []},
  "empty_file": {"status": "blocked", "errors": ["missing_header"], "records": 0, "parse_complete": true, "pending": []},
  "header_only": {"status": "ready", "errors": [], "records": 1, "parse_complete": true, "pending": []},
  "invalid_utf8": {"status": "blocked", "errors": ["invalid_utf8"], "records": 0, "parse_complete": false, "pending": []}
}

10. test_intake.py 完整文件

import hashlib
import json
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from fixtures import CASES, MAIN
from intake import accepted_rows, inspect_bytes

ROOT = Path(__file__).resolve().parent


class IntakeTests(unittest.TestCase):
    def test_fixed_cases(self):
        expected = json.loads((ROOT / 'expected.json').read_text(encoding='utf-8'))
        self.assertEqual(set(CASES), set(expected))
        for name, raw in CASES.items():
            with self.subTest(name=name):
                m = inspect_bytes(raw)
                actual = dict(status=m['status'], errors=[e['code'] for e in m['errors']],
                              records=len(m['records']), parse_complete=m['parse_complete'],
                              pending=[p['record_id'] for p in m['pending']])
                self.assertEqual(actual, expected[name])

    def test_main_exact_evidence(self):
        m = inspect_bytes(MAIN)
        self.assertEqual(m['source']['byte_count'], 206)
        self.assertEqual(m['source']['sha256'],
                         '89573d7d2e60675bed26e986532ef6f17b02b9893a06b35f05a4a0e1a8e0512c')
        self.assertEqual([r['physical_lines'] for r in m['records']],
                         [[1,1],[2,3],[4,4],[5,5],[6,6],[7,7],[8,8],[9,9],[10,10]])
        self.assertEqual([r['field_count'] for r in m['records']], [3] * 9)
        self.assertEqual([r['fields'] for r in m['records']], [
            ['item_id', 'label', 'note'],
            ['0007', 'NA', 'first line\r\nsecond line'],
            ['0042', 'NULL', 'plain'], ['0000', '', 'unquoted-empty'],
            ['0001', '', 'quoted-empty'], ['0002', ' ', 'one-space'],
            ['0003', ' ', 'quoted-one-space'], ['0004', ' NA ', 'padded-token'],
            ['0005', '0', 'literal-zero']])

    def test_absent_and_empty_field_differ(self):
        a = inspect_bytes(CASES['short'])['records'][1]
        b = inspect_bytes(CASES['present_empty_note'])['records'][1]
        self.assertEqual(a['fields'], ['0007', 'NA'])
        self.assertEqual(b['fields'], ['0007', 'NA', ''])

    def test_blank_keeps_location(self):
        records = inspect_bytes(CASES['blank'])['records']
        self.assertEqual(records[2], dict(record_id='R0002', physical_lines=[3,3],
                                         field_count=0, fields=[]))
        self.assertEqual(records[3]['physical_lines'], [4,4])

    def test_partial_parse_is_marked(self):
        m = inspect_bytes(CASES['unclosed'])
        self.assertFalse(m['parse_complete'])
        self.assertEqual(m['errors'], [{'code': 'csv_syntax', 'physical_lines': [2,2]}])

    def test_downstream_gate(self):
        rows = accepted_rows(CASES['ready'])
        self.assertEqual(len(rows), 1)
        self.assertEqual(rows[0]['values'],
                         {'item_id': '0007', 'label': 'NA', 'note': 'plain'})
        self.assertEqual(rows[0]['record_id'], 'R0001')
        self.assertEqual(rows[0]['physical_lines'], [2, 2])
        self.assertEqual(rows[0]['source_sha256'],
                         hashlib.sha256(CASES['ready']).hexdigest())
        self.assertEqual(accepted_rows(CASES['header_only']), [])
        for name in ['main', 'short', 'long_first', 'duplicate_header', 'unclosed']:
            with self.subTest(name=name), self.assertRaises(ValueError):
                accepted_rows(CASES[name])

    def test_quote_spelling_requires_original(self):
        a = b'item_id,label,note\n1,,x\n'
        b = b'item_id,label,note\n1,"",x\n'
        ma, mb = inspect_bytes(a), inspect_bytes(b)
        self.assertEqual(ma['records'], mb['records'])
        self.assertNotEqual(ma['source']['sha256'], mb['source']['sha256'])

    def test_cli_keeps_bytes_and_refuses_existing_output(self):
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            source, output = root / 'input.csv', root / 'bundle'
            source.write_bytes(MAIN)
            cmd = [sys.executable, str(ROOT / 'intake.py'), str(source), str(output)]
            run = subprocess.run(cmd, capture_output=True, text=True)
            self.assertEqual(run.returncode, 2)
            self.assertEqual(run.stdout.strip(), 'semantics_pending')
            saved = (output / 'source.csv').read_bytes()
            self.assertEqual(saved, MAIN)
            m = json.loads((output / 'manifest.json').read_text(encoding='utf-8'))
            self.assertEqual(hashlib.sha256(saved).hexdigest(), m['source']['sha256'])
            again = subprocess.run(cmd, capture_output=True, text=True)
            self.assertNotEqual(again.returncode, 0)
            self.assertEqual((output / 'source.csv').read_bytes(), MAIN)


if __name__ == '__main__':
    unittest.main(verbosity=2)

11. probe_pandas.py 完整文件

"""Version-scoped negative controls; requires existing pandas 2.2.3."""
import csv
import io
import json
import sys
import pandas as pd
from fixtures import MAIN


def read(raw=MAIN, **options):
    return pd.read_csv(io.BytesIO(raw), **options)


def main():
    if pd.__version__ != '2.2.3':
        raise SystemExit('This reproduction targets pandas 2.2.3; no installation is performed.')
    default = read()
    dtype_only = read(dtype=str)
    no_na_only = read(keep_default_na=False)
    both = read(dtype=str, keep_default_na=False)
    assert default.item_id.tolist() == [7, 42, 0, 1, 2, 3, 4, 5]
    assert dtype_only.item_id.tolist() == ['0007','0042','0000','0001','0002','0003','0004','0005']
    assert dtype_only.label.isna().tolist() == [True,True,True,True,False,False,False,False]
    assert no_na_only.item_id.tolist() == [7, 42, 0, 1, 2, 3, 4, 5]
    assert no_na_only.label.tolist() == ['NA','NULL','','',' ',' ',' NA ','0']
    assert both.label.tolist() == ['NA','NULL','','',' ',' ',' NA ','0']
    assert default.label.iloc[4:7].tolist() == [' ', ' ', ' NA ']
    policy = read(dtype=str, keep_default_na=False, na_values={'label': ['']})
    assert policy.label.isna().tolist() == [False,False,True,True,False,False,False,False]
    short = read(b'a,b,c\n1,2\n', dtype=str, keep_default_na=False)
    assert short.iloc[0].tolist() == ['1','2','']
    wide = read(b'a,b\n1,2,3\n', dtype=str, keep_default_na=False)
    assert wide.index.tolist() == ['1'] and wide.iloc[0].tolist() == ['2','3']
    duplicate = read(b'id,id\n0007,0042\n', dtype=str, keep_default_na=False)
    assert duplicate.columns.tolist() == ['id', 'id.1']
    assert list(csv.DictReader(io.StringIO('id,id\n0007,0042\n'))) == [{'id': '0042'}]
    assert list(csv.reader(io.StringIO('a,b\n1,2,3\n'), strict=True))[1] == ['1','2','3']
    assert len(read(b'a,b\n1,2\n\n3,4\n')) == 2
    try:
        read(b'a,b\n1,2\n3,4,5\n', dtype=str, keep_default_na=False)
    except pd.errors.ParserError:
        pass
    else:
        raise AssertionError('Expected ParserError for this long-later fixture')
    print(json.dumps({'python': sys.version.split()[0], 'pandas': pd.__version__,
                      'assertions': 14, 'expected_exception_checks': 1,
                      'result': 'PASS',
                      'both_labels': both.label.tolist(),
                      'short_row': short.iloc[0].tolist(),
                      'wide_index': wide.index.tolist(),
                      'duplicate_columns': duplicate.columns.tolist()},
                     ensure_ascii=False, indent=2))


if __name__ == '__main__':
    main()

12. 怎样读这次运行结果

主体测试通过了 8 个 unittest 方法。其中固定输入测试逐一比较 14 份手写预期,检查状态、错误代码、返回记录数、解析是否完整和待确认记录编号;其余方法检查完整字段内容、固定 SHA-256、全部物理行范围、少字段与空字段的区别、空行定位、部分解析、下游阻断、引号拼写证据和 CLI 保存的字节。这里没有把一次测试中的多条断言算成多个独立样本。

主样本共 206 字节,SHA-256 是 89573d7d2e60675bed26e986532ef6f17b02b9893a06b35f05a4a0e1a8e0512c。manifest 返回 9 条记录,包含表头;errors 为空,pending 指向 R0003 与 R0004,状态为 semantics_pending。物理行区间依次为 [1,1], [2,3], [4,4], [5,5], [6,6], [7,7], [8,8], [9,9], [10,10]。固定预期直接写在 expected.json 和测试源码里,测试时不会调用验收器来生成标准答案。

另外的负控脚本通过 14 条断言(12 条 pandas 断言、2 条标准库 csv 断言)和 1 个 pandas 预期异常检查,输出 Python 3.12.14、pandas 2.2.3 及 PASS。它复现类型推断、缺失值推断、补字段、隐式索引、重复表头、空行跳过和一份会报错的长行样本,结果只适用于文中输入及这次默认引擎选择。未做 Python、C、pyarrow 引擎矩阵,也未测试其他 pandas 版本。

下游调用 accepted_rows(raw) 时,会从字节重新生成 manifest 并核对状态,不能通过手改 manifest 的 status 绕过这个函数。成功返回的每条对象都带有 source_sha256、record_id、physical_lines 和 values。实际接入时,可让 Agent 的工具适配层只暴露这个通过验收的结果,并保存相应源文件;不要另外开放一条不经过验收的直接 read_csv 路径。示例函数本身无法约束一个另有文件访问权限的程序。

13. 准备接到真实文件前

这个教学入口一次读取整个文件,解析后的字段和 manifest 也都留在内存中。它没有文件大小、记录数或总输出字节预算,没有恶意输入压力测试、模糊测试、并发测试、权限隔离或安全沙箱。标准库解析器可能因字段过长抛出 csv.Error,这不等于已经实现资源耗尽防护。本例把所有 csv.Error 统一记为 csv_syntax,因此仅看这个代码不能断定原文引号或分隔符有误;字段上限也可能触发同一出口,文末有可复跑的区分实验。真实不可信文件应先经过另外设计并测试的容量限制和访问控制。

固定契约没有为其他编码、BOM、区域分隔符、注释行或跳过行提供兼容分支;也没有覆盖 NUL、所有畸形引号组合、公式注入、Excel 执行行为和业务空值词典。严格解析可以报告部分语法问题,不能据此宣称完整验证了所有 CSV 规范。本文只增加了一份非法 UTF-8 固定夹具来检查解码阻断,并未穷举编码错误。

文件保存发生在读入之后,未保证正在被其他进程修改的输入能够获得一致磁盘快照。输出也不是防篡改档案,完整性校验依赖可信的预期哈希和保留策略。需要生产级审计时,还应绑定数据来源、获取时间、访问权限以及不可变存储策略;这些能力未包含在示例中。

实用的接入点是先把“文件能否按既定结构解释”变成可复跑测试。字段含义仍交给明确的业务约定,模型效果需要另行评估。本次实验没有调用模型,无法据此推断 Agent 的理解准确率。三张图是围绕固定案例绘制的原创结构图,本文做了 HTML 静态结构与源码一致性检查,没有浏览器像素验收。

14. 资料与运行版本

官方资料核对日期为 2026 年 10 月 7 日。pandas 2.2.3 read_csv用于核对 dtype、NA、索引和坏行处理选项。Python 3.12 csv用于核对方言、newline、字段读取和 line_num;该文档页面当前标注 3.12.15,本次离线运行的解释器是 3.12.14。文档说明与固定案例观察分别记录,避免把某次读入结果当成任意文件的通用保证。

15. 补充:表头看起来正确,先检查开头的三个字节

如果一份 CSV 在表格里显示的列名是 item_id、label、note,交给前文入口却得到 header_mismatch,可以先看源文件开头的字节,以及解析后表头的 repr。编辑器里的视觉结果可能看不出 U+FEFF;只截取表格截图,也无法判断它是否在原始文件里。

下面另建一组固定合成输入。plain 包含两行 CRLF 文本,共 35 字节;one_bom 只在它前面增加 EF BB BF,共 38 字节。原来的 intake.py 对 plain 返回 ready,对 one_bom 返回 blocked。后者通过 utf-8 解码后,第一个字段实际为 '\ufeffitem_id',与合同要求的 'item_id' 不同。

plain = b'item_id,label,note\r\n0007,NA,plain\r\n'
one_bom = b'\xef\xbb\xbf' + plain

本次在 pandas 2.2.3 中使用 encoding='utf-8'、dtype=str、keep_default_na=False 读取这两个样本,得到相同列名和数据字符串。这个观察解释了两种读取路径为何看似矛盾,也说明不能拿其中一个工具的显示结果替另一个入口验收。这里没有比较其他 pandas 版本或显式指定的解析引擎,不能把结果推广成所有版本、所有 CSV 都相同。

这份补充于 2026 年 10 月 13 日独立验证。前文的严格 UTF-8 入口、14 份固定输入、五个源码文件以及 BOM 不在兼容范围内的说明均保留。新增程序只生成两个解码视图,不把带签名的文件自动改成 ready。

35 字节与 38 字节输入在可选签名解码下得到相同字段,但原始哈希不同;严格 UTF-8 入口继续阻断带签名输入。
图 4 解码视图、入口判断与原始字节身份分别记录。

16. utf-8-sig 只处理起始签名,位置和顺序都重要

utf-8-sig 的比较放在 CSV 解析之前:先严格解码,再把文本交给 csv.reader。本实验中,没有签名时两种解码得到同样的字段;有一份起始签名时,utf-8-sig 不把它交给 CSV 解析器。连续放两份签名,则仍留下一个 U+FEFF,表头比较继续失败。它没有为字段里的不可见字符提供通用清理规则。

另外两个输入把相同字节放在文件内部。embedded_header 将 U+FEFF 放在第二个列名 label 前,两种解码都保留它,原入口因完整表头不匹配而阻断。embedded_value 将它放在 note 的 left 与 right 之间,两种解码都保留 left\ufeffright。原入口允许这个 note,因此该样本仍为 ready;这只反映已有有限合同,不表示其中每个字符都符合某个业务词典。

bom_before_quoted_header 在签名后紧接带引号的第一列表头。普通 utf-8 保留前面的 U+FEFF,双引号不再位于字段开头,csv.reader 返回的第一个字段为 '\ufeff"item_id"'。此后再对已解析单元格去掉 U+FEFF,剩下的仍是带两个字面引号的 '"item_id"'。utf-8-sig 在解析前处理起始签名,才会让这对引号承担 CSV 引用作用。因此,“解析后修列名”并不等价于选择另一种解码方式。

最后两份输入用于确认失败出口:一份有完整签名,但后面的数据含非法 UTF-8 字节 FF;另一份只有 EF BB 两个字节。两种严格解码都返回 UnicodeDecodeError。可选签名解码不提供坏字节修复,也不会因为开头识别成功就使后续文本有效。

17. 保留原始哈希,再看那些会掩盖问题的修复

one_bom 经 utf-8-sig 解码后的字段,与 plain 完全相同,但二者的原始 SHA-256 不同。补充报告的 byte_count、sha256 和 hex 始终来自最初的 bytes;输出目录同时保存八份未改写的 CSV 字节副本。原始哈希只用于核对字节内容,仍不能证明来源可信。

plain SHA-256: adb2e885d7d15fa7f63e108c7df017b78205981dd56735bda6aaa4ad2a702134
one_bom SHA-256: e3d7c7ae36be23cda31b8e36bdb2c48a4496cd7283ed8494cba2f27497338d8b

测试还故意制作五种错误结果,逐一确认完整预期会拒绝它们。第一种用 lstrip('\ufeff') 清掉双前缀,误把本应保留一个 U+FEFF 的表头变为匹配。第二种在全文 replace('\ufeff', ''),把 note 改成 leftright。第三种同样全局替换,掩盖第二列名里的隐藏字符,造成错误通过。第四种对解码后重新编码的文本计算“源哈希”,使 38 字节输入获得 35 字节输入的哈希。第五种把诊断视图中的表头匹配直接升级为原入口 ready,绕过仍然有效的合同。

这些负控只在测试内构造错误候选,不写回样本,也不改变 intake.py。完整预期会比较所有字段、错误类型、原入口状态和源字节证据,因此测试不会只凭“程序没报错”判定改法正确。

排查真实数据时,应先核对发送方与入口约定。如果仍要求无签名 UTF-8,就保留阻断结果并请来源端按约定重新导出。如果确实要允许一份起始签名,应另行修改和测试版本化合同,同时继续绑定原始文件。本文的 header_matches 只是解码视图中一次表头比较,没有实现一个新的完整导入合同。

18. 在独立目录重跑这组比较

下载 ZIP 后从解压目录执行下面命令。也可以把前文五个文件与以下六个补充文件都放在 src 子目录,再运行相同命令。核心诊断和测试只用标准库,不需要 pandas;程序不接受真实业务文件路径,只建立这里列出的固定输入。

python -m unittest discover -s src -p "test_*.py" -v
python src/bom_diagnostic.py --output run-bom

第一条命令共运行 21 个 unittest 方法,其中 8 个来自未改动的原入口,13 个来自本补充。第二条命令成功返回 0,表示诊断文件已写出;它不是八个输入都可被入口接收的信号。run-bom/report.json 包含完整视图,标准输出只概括第一字段。embedded_header 的第一字段正常,却因第二字段而失败,需要看完整 JSON 才能找到原因。

run-bom 必须是一个尚不存在的目录。输出包括八个原始字节副本和 report.json;已有目录会报错,代码不保证磁盘失败后的原子回滚。若写入中断,应把该目录视为不完整,换一个新目录重跑。程序不安装依赖、不联网、不读取真实用户文件,也不调用模型。

本次运行使用 Linux、Python 3.12.14,另用已安装的 pandas 2.2.3 完成可选观察。源文件、从正文提取的副本和 ZIP 解压副本都通过同一组测试,生成相同的完整诊断 JSON、固定标准输出与原始字节副本。原创 SVG 以 1120 × 810 自然比例渲染并检查;HTML 做了静态结构和源码一致性检查,没有浏览器、macOS、Windows、真实 WorkBuddy 或生产文件测试。

下载完整源码、测试、固定预期与原创图 ZIP

19. 补充输入:bom_fixtures.py

字节字面量明确写出 CRLF 和 EF BB BF,不依赖从网页复制文本时编辑器如何保存文件。

"""Eight explicit synthetic byte inputs; no real files are accepted."""
BOM = b'\xef\xbb\xbf'
BASE = b'item_id,label,note\r\n0007,NA,plain\r\n'
CASES = {
    'plain': BASE,
    'one_bom': BOM + BASE,
    'double_bom': BOM + BOM + BASE,
    'embedded_header': b'item_id,' + BOM + b'label,note\r\n0007,NA,plain\r\n',
    'embedded_value': b'item_id,label,note\r\n0007,NA,left' + BOM + b'right\r\n',
    'bom_before_quoted_header': BOM + b'"item_id",label,note\r\n0007,NA,plain\r\n',
    'bom_invalid_utf8': BOM + b'item_id,label,note\r\n0007,NA,\xff\r\n',
    'partial_signature': b'\xef\xbb',
}

20. 补充程序:bom_diagnostic.py

它直接调用未改动的 inspect_bytes 保存原入口判断,再独立生成 utf-8 与 utf-8-sig 视图。两种视图均采用相同的 CSV 方言与 newline 设置。

"""Compare decoders without changing the published intake contract."""
import argparse
import csv
import hashlib
import io
import json
from pathlib import Path
from bom_fixtures import CASES
from intake import DIALECT, HEADER, inspect_bytes


def decode_rows(raw, codec):
    """Return a diagnostic view, not an ingestion approval."""
    try:
        text = raw.decode(codec, errors='strict')
    except UnicodeDecodeError:
        return {'error': 'UnicodeDecodeError'}
    try:
        rows = list(csv.reader(io.StringIO(text, newline=''), **DIALECT))
    except csv.Error:
        return {'error': 'csv.Error'}
    return {'rows': rows, 'header_matches': bool(rows) and rows[0] == HEADER}


def diagnose(raw):
    original = inspect_bytes(raw)
    return {
        'source': {'byte_count': len(raw),
                   'sha256': hashlib.sha256(raw).hexdigest(),
                   'hex': raw.hex(' ')},
        'published': {'status': original['status'],
                      'errors': [e['code'] for e in original['errors']]},
        'utf-8': decode_rows(raw, 'utf-8'),
        'utf-8-sig': decode_rows(raw, 'utf-8-sig'),
    }


def make_report():
    return {name: diagnose(raw) for name, raw in CASES.items()}


def summary(report):
    lines = []
    for name, result in report.items():
        fields = []
        for codec in ['utf-8', 'utf-8-sig']:
            view = result[codec]
            fields.append(view.get('error') or repr(view['rows'][0][0]))
        lines.append(f"{name}: {result['source']['byte_count']} bytes; "
                     f"published={result['published']['status']}; "
                     f"utf-8={fields[0]}; utf-8-sig={fields[1]}")
    return '\n'.join(lines) + '\n'


def save_bundle(output, report):
    """New directory only. A partial I/O failure is not an atomic bundle."""
    output.mkdir(parents=True, exist_ok=False)
    for name, raw in CASES.items():
        (output / f'{name}.csv').write_bytes(raw)
    (output / 'report.json').write_text(
        json.dumps(report, ensure_ascii=True, indent=2) + '\n', encoding='utf-8')


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--output', required=True, type=Path,
                        help='new output directory for synthetic byte copies and report')
    args = parser.parse_args()
    report = make_report()
    save_bundle(args.output, report)
    print(summary(report), end='')


if __name__ == '__main__':
    main()

21. 补充完整预期:bom_expected.json

以下预期包含每个输入的全部十六进制字节、源哈希、完整字段列表和解码错误。字段列表由案例要求手写,不从诊断程序的运行结果反推。JSON 中的 \ufeff 是 Unicode 转义写法,读取后表示那个字符本身。

{
  "plain": {
    "source": {
      "byte_count": 35,
      "sha256": "adb2e885d7d15fa7f63e108c7df017b78205981dd56735bda6aaa4ad2a702134",
      "hex": "69 74 65 6d 5f 69 64 2c 6c 61 62 65 6c 2c 6e 6f 74 65 0d 0a 30 30 30 37 2c 4e 41 2c 70 6c 61 69 6e 0d 0a"
    },
    "published": {
      "status": "ready",
      "errors": []
    },
    "utf-8": {
      "rows": [
        [
          "item_id",
          "label",
          "note"
        ],
        [
          "0007",
          "NA",
          "plain"
        ]
      ],
      "header_matches": true
    },
    "utf-8-sig": {
      "rows": [
        [
          "item_id",
          "label",
          "note"
        ],
        [
          "0007",
          "NA",
          "plain"
        ]
      ],
      "header_matches": true
    }
  },
  "one_bom": {
    "source": {
      "byte_count": 38,
      "sha256": "e3d7c7ae36be23cda31b8e36bdb2c48a4496cd7283ed8494cba2f27497338d8b",
      "hex": "ef bb bf 69 74 65 6d 5f 69 64 2c 6c 61 62 65 6c 2c 6e 6f 74 65 0d 0a 30 30 30 37 2c 4e 41 2c 70 6c 61 69 6e 0d 0a"
    },
    "published": {
      "status": "blocked",
      "errors": [
        "header_mismatch"
      ]
    },
    "utf-8": {
      "rows": [
        [
          "\ufeffitem_id",
          "label",
          "note"
        ],
        [
          "0007",
          "NA",
          "plain"
        ]
      ],
      "header_matches": false
    },
    "utf-8-sig": {
      "rows": [
        [
          "item_id",
          "label",
          "note"
        ],
        [
          "0007",
          "NA",
          "plain"
        ]
      ],
      "header_matches": true
    }
  },
  "double_bom": {
    "source": {
      "byte_count": 41,
      "sha256": "d0a80e7450090d807c06b7ec47222328ab4150be1d60eda16e517dc2aad806f4",
      "hex": "ef bb bf ef bb bf 69 74 65 6d 5f 69 64 2c 6c 61 62 65 6c 2c 6e 6f 74 65 0d 0a 30 30 30 37 2c 4e 41 2c 70 6c 61 69 6e 0d 0a"
    },
    "published": {
      "status": "blocked",
      "errors": [
        "header_mismatch"
      ]
    },
    "utf-8": {
      "rows": [
        [
          "\ufeff\ufeffitem_id",
          "label",
          "note"
        ],
        [
          "0007",
          "NA",
          "plain"
        ]
      ],
      "header_matches": false
    },
    "utf-8-sig": {
      "rows": [
        [
          "\ufeffitem_id",
          "label",
          "note"
        ],
        [
          "0007",
          "NA",
          "plain"
        ]
      ],
      "header_matches": false
    }
  },
  "embedded_header": {
    "source": {
      "byte_count": 38,
      "sha256": "e6a1402c0b1ebd006825426e27c858f85fcae3ffe88e131cf2841d11b19ccd92",
      "hex": "69 74 65 6d 5f 69 64 2c ef bb bf 6c 61 62 65 6c 2c 6e 6f 74 65 0d 0a 30 30 30 37 2c 4e 41 2c 70 6c 61 69 6e 0d 0a"
    },
    "published": {
      "status": "blocked",
      "errors": [
        "header_mismatch"
      ]
    },
    "utf-8": {
      "rows": [
        [
          "item_id",
          "\ufefflabel",
          "note"
        ],
        [
          "0007",
          "NA",
          "plain"
        ]
      ],
      "header_matches": false
    },
    "utf-8-sig": {
      "rows": [
        [
          "item_id",
          "\ufefflabel",
          "note"
        ],
        [
          "0007",
          "NA",
          "plain"
        ]
      ],
      "header_matches": false
    }
  },
  "embedded_value": {
    "source": {
      "byte_count": 42,
      "sha256": "f5bf8da7781f3a42e5ecdeeaf8a7cf00f4c55fec944133aeb64ea322fece60cf",
      "hex": "69 74 65 6d 5f 69 64 2c 6c 61 62 65 6c 2c 6e 6f 74 65 0d 0a 30 30 30 37 2c 4e 41 2c 6c 65 66 74 ef bb bf 72 69 67 68 74 0d 0a"
    },
    "published": {
      "status": "ready",
      "errors": []
    },
    "utf-8": {
      "rows": [
        [
          "item_id",
          "label",
          "note"
        ],
        [
          "0007",
          "NA",
          "left\ufeffright"
        ]
      ],
      "header_matches": true
    },
    "utf-8-sig": {
      "rows": [
        [
          "item_id",
          "label",
          "note"
        ],
        [
          "0007",
          "NA",
          "left\ufeffright"
        ]
      ],
      "header_matches": true
    }
  },
  "bom_before_quoted_header": {
    "source": {
      "byte_count": 40,
      "sha256": "b42234a3266beffe8a1df74c36498cdfb6f8ee187538840566bd124e049bb981",
      "hex": "ef bb bf 22 69 74 65 6d 5f 69 64 22 2c 6c 61 62 65 6c 2c 6e 6f 74 65 0d 0a 30 30 30 37 2c 4e 41 2c 70 6c 61 69 6e 0d 0a"
    },
    "published": {
      "status": "blocked",
      "errors": [
        "header_mismatch"
      ]
    },
    "utf-8": {
      "rows": [
        [
          "\ufeff\"item_id\"",
          "label",
          "note"
        ],
        [
          "0007",
          "NA",
          "plain"
        ]
      ],
      "header_matches": false
    },
    "utf-8-sig": {
      "rows": [
        [
          "item_id",
          "label",
          "note"
        ],
        [
          "0007",
          "NA",
          "plain"
        ]
      ],
      "header_matches": true
    }
  },
  "bom_invalid_utf8": {
    "source": {
      "byte_count": 34,
      "sha256": "8f5124e1fd06bce0fad315da65559e6fe422b269f1755f15adfc7ba8d1db4f68",
      "hex": "ef bb bf 69 74 65 6d 5f 69 64 2c 6c 61 62 65 6c 2c 6e 6f 74 65 0d 0a 30 30 30 37 2c 4e 41 2c ff 0d 0a"
    },
    "published": {
      "status": "blocked",
      "errors": [
        "invalid_utf8"
      ]
    },
    "utf-8": {
      "error": "UnicodeDecodeError"
    },
    "utf-8-sig": {
      "error": "UnicodeDecodeError"
    }
  },
  "partial_signature": {
    "source": {
      "byte_count": 2,
      "sha256": "f257f0501a5e137710e26f1c35ddd32ce2b5752f20274a648cb6d0347849a5a9",
      "hex": "ef bb"
    },
    "published": {
      "status": "blocked",
      "errors": [
        "invalid_utf8"
      ]
    },
    "utf-8": {
      "error": "UnicodeDecodeError"
    },
    "utf-8-sig": {
      "error": "UnicodeDecodeError"
    }
  }
}

22. 补充测试:test_bom.py

测试既检查正例,也让五种错误结果实际触发断言失败。另有带引号表头的时序检查,以及输出文件逐字节一致、已有目录不可覆盖的检查。

import copy
import csv
import hashlib
import io
import json
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from bom_fixtures import CASES
from bom_diagnostic import decode_rows, diagnose, make_report, summary
from intake import DIALECT, HEADER, accepted_rows, inspect_bytes

ROOT = Path(__file__).resolve().parent
EXPECTED = json.loads((ROOT / 'bom_expected.json').read_text(encoding='utf-8'))


def parse_text(text):
    return list(csv.reader(io.StringIO(text, newline=''), **DIALECT))


class BomTests(unittest.TestCase):
    def test_all_full_rows_and_errors(self):
        self.assertEqual(set(CASES), set(EXPECTED))
        for name, raw in CASES.items():
            with self.subTest(case=name):
                self.assertEqual(diagnose(raw), EXPECTED[name])

    def test_raw_hashes_and_reversible_hex(self):
        for name, raw in CASES.items():
            with self.subTest(case=name):
                source = diagnose(raw)['source']
                self.assertEqual(bytes.fromhex(source['hex']), raw)
                self.assertEqual(source['sha256'], hashlib.sha256(raw).hexdigest())
                self.assertEqual(source['byte_count'], len(raw))

    def test_optional_signature_does_not_grant_acceptance(self):
        self.assertEqual(decode_rows(CASES['one_bom'], 'utf-8-sig')['rows'],
                         [['item_id', 'label', 'note'], ['0007', 'NA', 'plain']])
        self.assertEqual(inspect_bytes(CASES['one_bom'])['status'], 'blocked')
        with self.assertRaises(ValueError):
            accepted_rows(CASES['one_bom'])
        self.assertEqual(accepted_rows(CASES['plain'])[0]['values']['item_id'], '0007')

    def test_one_prefix_only(self):
        self.assertEqual(decode_rows(CASES['double_bom'], 'utf-8-sig')['rows'][0],
                         ['\ufeffitem_id', 'label', 'note'])
        self.assertFalse(decode_rows(CASES['double_bom'], 'utf-8-sig')['header_matches'])

    def test_invalid_bytes_are_not_repaired(self):
        for name in ['bom_invalid_utf8', 'partial_signature']:
            for codec in ['utf-8', 'utf-8-sig']:
                with self.subTest(case=name, codec=codec):
                    self.assertEqual(decode_rows(CASES[name], codec),
                                     {'error': 'UnicodeDecodeError'})

    def test_kill_lstrip_all_prefixes(self):
        wrong = diagnose(CASES['double_bom'])
        rows = parse_text(CASES['double_bom'].decode('utf-8').lstrip('\ufeff'))
        wrong['utf-8-sig'] = {'rows': rows, 'header_matches': rows[0] == HEADER}
        self.assertTrue(wrong['utf-8-sig']['header_matches'])
        with self.assertRaises(AssertionError):
            self.assertEqual(wrong, EXPECTED['double_bom'])

    def test_kill_global_replace_value(self):
        wrong = diagnose(CASES['embedded_value'])
        rows = parse_text(CASES['embedded_value'].decode('utf-8').replace('\ufeff', ''))
        wrong['utf-8-sig'] = {'rows': rows, 'header_matches': rows[0] == HEADER}
        self.assertEqual(rows[1][2], 'leftright')
        with self.assertRaises(AssertionError):
            self.assertEqual(wrong, EXPECTED['embedded_value'])

    def test_kill_global_replace_hidden_header(self):
        wrong = diagnose(CASES['embedded_header'])
        rows = parse_text(CASES['embedded_header'].decode('utf-8').replace('\ufeff', ''))
        wrong['utf-8-sig'] = {'rows': rows, 'header_matches': rows[0] == HEADER}
        self.assertTrue(wrong['utf-8-sig']['header_matches'])
        with self.assertRaises(AssertionError):
            self.assertEqual(wrong, EXPECTED['embedded_header'])

    def test_kill_normalized_hash_identity(self):
        wrong = diagnose(CASES['one_bom'])
        normalized = CASES['one_bom'].decode('utf-8-sig').encode('utf-8')
        wrong['source']['sha256'] = hashlib.sha256(normalized).hexdigest()
        self.assertEqual(wrong['source']['sha256'], EXPECTED['plain']['source']['sha256'])
        with self.assertRaises(AssertionError):
            self.assertEqual(wrong, EXPECTED['one_bom'])

    def test_kill_diagnostic_promoted_to_ready(self):
        wrong = copy.deepcopy(EXPECTED['one_bom'])
        wrong['published'] = {'status': 'ready', 'errors': []}
        with self.assertRaises(AssertionError):
            self.assertEqual(diagnose(CASES['one_bom']), wrong)

    def test_stripping_parsed_cell_is_too_late_for_quotes(self):
        rows = decode_rows(CASES['bom_before_quoted_header'], 'utf-8')['rows']
        self.assertEqual(rows[0][0].lstrip('\ufeff'), '"item_id"')
        self.assertNotEqual(rows[0][0].lstrip('\ufeff'), 'item_id')
        self.assertEqual(decode_rows(CASES['bom_before_quoted_header'], 'utf-8-sig')['rows'][0][0],
                         'item_id')

    def test_summary_matches_fixed_output(self):
        expected = (ROOT / 'bom_expected_output.txt').read_text(encoding='utf-8')
        self.assertEqual(summary(make_report()), expected)

    def test_cli_bundle_identity_and_no_overwrite(self):
        with tempfile.TemporaryDirectory() as tmp:
            output = Path(tmp) / 'bundle'
            cmd = [sys.executable, str(ROOT / 'bom_diagnostic.py'), '--output', str(output)]
            run = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
            self.assertEqual(run.returncode, 0, run.stderr)
            self.assertEqual(run.stdout, (ROOT / 'bom_expected_output.txt').read_text())
            self.assertEqual(json.loads((output / 'report.json').read_text()), EXPECTED)
            for name, raw in CASES.items():
                self.assertEqual((output / f'{name}.csv').read_bytes(), raw)
            before = {f.name: f.read_bytes() for f in output.iterdir()}
            again = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
            self.assertNotEqual(again.returncode, 0)
            self.assertEqual({f.name: f.read_bytes() for f in output.iterdir()}, before)


if __name__ == '__main__':
    unittest.main(verbosity=2)

23. 补充固定输出:bom_expected_output.txt

这份摘要只显示第一个表头字段,不能替代前面的完整预期。每个例子的原入口状态都来自原始 bytes。

plain: 35 bytes; published=ready; utf-8='item_id'; utf-8-sig='item_id'
one_bom: 38 bytes; published=blocked; utf-8='\ufeffitem_id'; utf-8-sig='item_id'
double_bom: 41 bytes; published=blocked; utf-8='\ufeff\ufeffitem_id'; utf-8-sig='\ufeffitem_id'
embedded_header: 38 bytes; published=blocked; utf-8='item_id'; utf-8-sig='item_id'
embedded_value: 42 bytes; published=ready; utf-8='item_id'; utf-8-sig='item_id'
bom_before_quoted_header: 40 bytes; published=blocked; utf-8='\ufeff"item_id"'; utf-8-sig='item_id'
bom_invalid_utf8: 34 bytes; published=blocked; utf-8=UnicodeDecodeError; utf-8-sig=UnicodeDecodeError
partial_signature: 2 bytes; published=blocked; utf-8=UnicodeDecodeError; utf-8-sig=UnicodeDecodeError

24. 可选比较:probe_bom_pandas.py

只有本机已经装有 pandas 2.2.3 时才运行 python src/probe_bom_pandas.py。版本不符或未安装会退出;脚本不安装任何依赖。它比较六份可解码样本的完整列名和数据字符串,预期均写在文件内。

这份版本锁定观察不用于声明当前 pandas 的行为,也不建立多引擎兼容矩阵。外部库的显示结果适合帮助解释现象,本文入口是否继续接收仍由原来的确定性合同决定。

"""Optional, pinned observation. Does not define the intake contract."""
import io
import json
import sys
from bom_fixtures import CASES


def main():
    try:
        import pandas as pd
    except ImportError:
        raise SystemExit('Optional probe requires an already installed pandas 2.2.3')
    if pd.__version__ != '2.2.3':
        raise SystemExit(f'Expected pandas 2.2.3; found {pd.__version__}')
    observations = {}
    for name in ['plain', 'one_bom', 'double_bom', 'embedded_header',
                 'embedded_value', 'bom_before_quoted_header']:
        frame = pd.read_csv(io.BytesIO(CASES[name]), encoding='utf-8',
                            dtype=str, keep_default_na=False)
        observations[name] = {'columns': frame.columns.tolist(),
                              'rows': frame.values.tolist()}
    expected = {
        'plain': {'columns': ['item_id', 'label', 'note'], 'rows': [['0007', 'NA', 'plain']]},
        'one_bom': {'columns': ['item_id', 'label', 'note'], 'rows': [['0007', 'NA', 'plain']]},
        'double_bom': {'columns': ['\ufeffitem_id', 'label', 'note'], 'rows': [['0007', 'NA', 'plain']]},
        'embedded_header': {'columns': ['item_id', '\ufefflabel', 'note'], 'rows': [['0007', 'NA', 'plain']]},
        'embedded_value': {'columns': ['item_id', 'label', 'note'], 'rows': [['0007', 'NA', 'left\ufeffright']]},
        'bom_before_quoted_header': {'columns': ['item_id', 'label', 'note'], 'rows': [['0007', 'NA', 'plain']]},
    }
    if observations != expected:
        raise RuntimeError('Pinned observation differs from handwritten expectations')
    print(json.dumps({'python': sys.version.split()[0], 'pandas': pd.__version__,
                      'observations': observations}, ensure_ascii=True, indent=2))


if __name__ == '__main__':
    main()

25. 补充:相同源哈希还不够,重放时要核对字段上限

2026 年 10 月 20 日复查时,原有 21 个测试和两组 pandas 2.2.3 观察均可重跑,但发现一个未记录的运行条件:csv.field_size_limit()。它不在前文 manifest 的 parse 字段中,也不是 DIALECT 的一部分。Python 文档说明,这个函数读取当前字段上限,传入参数则改变该上限。

使用前文同一份 206 字节 MAIN,在独立子进程把上限设为 16,结果是 blocked,只保留表头,parse_complete=false,错误为 csv_syntax、区间 [2,3]。设为 32 后,恢复原来的 semantics_pending、9 条记录和 R0003/R0004 两个待确认项。两个结果中的 source 与 parse 完全相同。这不是源文件被修改,而是当前清单没有记录足以重放结果的全部运行条件。

另用纯 ASCII 和汉字各造 16、17 字符的 note。上限为 16 时,两种 16 字符字段都能通过,17 字符都阻断;16 个汉字的 UTF-8 编码占 48 字节,并未因为超过 16 字节而失败。本次 CPython 3.12.14 观察的是解码后字段字符数,不是原文件字节预算。对应版本的 _csv.c在 parse_add_char 中比较字段长度和模块状态中的上限,可与固定实验对照。

这个设置在同一解释器内由 csv 模块共享,不是 reader 创建时保存的一份独立配置。补充测试先设 32 并创建 reader,再改成 16,随后读取 17 字符字段仍然失败。因而,在共享进程中临时调高、解析后再恢复,并不能据此宣称其他并发解析不受影响。下面每个观察都新建子进程,并在子进程的 finally 中恢复先前上限,恢复检查失败就不报告成功;父进程的字段上限从未被改写。这只是隔离本实验,不是生产沙箱或并发正确性证明。

排查时应把实际字段上限与解释器版本一并记录,并区分“解析器拒绝”与“已经证明 CSV 语法有错”。若要让入口可稳定重放,需要在受控、不会被其他解析任务修改设置的执行环境中固定并记录上限,再测试边界;不要为让文件通过而直接移除限制。字段上限也不能替代文件字节数、记录数、总输出和内存预算。前文入口源码、错误代码与接收合同在本补充中均未改变。

本补充通过 4 个 unittest 方法,覆盖相同输入不同上限、两种字符的边界、reader 创建后的设置变化,以及子进程不修改父进程设置。普通模式与 -O 均通过,子进程也采用对应模式;原有 21 个方法再次通过。只使用合成输入和 Python 标准库,没有真实业务文件、模型、WorkBuddy、浏览器像素验收、其他 Python 版本或平台的验证。

26. 字段上限复现:test_field_limit.py

前面的 ZIP 仍是原入口与 BOM 两组实验,未混入这份新补充。将以下文件另存为 src/test_field_limit.py,与 ZIP 中的 intake.py 和 fixtures.py 放在同一目录,然后在解压目录运行:

python -m unittest discover -s src -p "test_field_limit.py" -v
python -O -m unittest discover -s src -p "test_field_limit.py" -v

每条命令应显示 Ran 4 tests 与 OK。加入本文件后,使用前面的 test_*.py 通配命令会运行共 25 个方法。测试对失败输入仍然期待阻断,没有把诊断成功变成导入成功。

单独下载字段上限测试文件

"""Synthetic reproduction of ambient CSV field limits; no intake changes."""
import csv
import json
from pathlib import Path
import subprocess
import sys
import unittest

ROOT = Path(__file__).resolve().parent
WORKER = r'''
import csv
import io
import json
import sys
from fixtures import MAIN
from intake import DIALECT, inspect_bytes

case, limit = sys.argv[1], int(sys.argv[2])
original_limit = csv.field_size_limit()
try:
    if case == "late":
        csv.field_size_limit(32)
        reader = csv.reader(io.StringIO("x" * 17 + "\n", newline=""), **DIALECT)
        csv.field_size_limit(limit)
        try:
            result = {"rows": list(reader)}
        except csv.Error:
            result = {"error": "csv.Error"}
    else:
        csv.field_size_limit(limit)
        if case == "main":
            raw = MAIN
        else:
            letter, count = case.split(":")
            note = ("x" if letter == "ascii" else "\u6c49") * int(count)
            raw = ("item_id,label,note\n0007,NA," + note + "\n").encode("utf-8")
        m = inspect_bytes(raw)
        result = {
            "source": m["source"], "parse": m["parse"], "status": m["status"],
            "errors": m["errors"], "records": len(m["records"]),
            "complete": m["parse_complete"], "pending": m["pending"],
        }
        if case != "main":
            result["note_characters"] = len(note)
            result["note_utf8_bytes"] = len(note.encode("utf-8"))
finally:
    csv.field_size_limit(original_limit)
if csv.field_size_limit() != original_limit:
    raise RuntimeError("CSV limit was not restored")
print(json.dumps(result))
'''


def observe(case, limit):
    flags = ["-O"] if sys.flags.optimize else []
    run = subprocess.run(
        [sys.executable, *flags, "-c", WORKER, case, str(limit)],
        cwd=ROOT, capture_output=True, text=True, timeout=10, check=True)
    return json.loads(run.stdout)


class FieldLimitTests(unittest.TestCase):
    def test_same_source_and_parse_metadata_can_have_different_outcomes(self):
        small, large = observe("main", 16), observe("main", 32)
        self.assertEqual(small["source"], large["source"])
        self.assertEqual(small["source"]["byte_count"], 206)
        self.assertEqual(small["source"]["sha256"],
                         "89573d7d2e60675bed26e986532ef6f17b02b9893a06b35f05a4a0e1a8e0512c")
        self.assertEqual(small["parse"], large["parse"])
        self.assertNotIn("field_size_limit", small["parse"])
        self.assertEqual((small["status"], small["records"], small["complete"]),
                         ("blocked", 1, False))
        self.assertEqual(small["errors"],
                         [{"code": "csv_syntax", "physical_lines": [2, 3]}])
        self.assertEqual((large["status"], large["records"], large["complete"]),
                         ("semantics_pending", 9, True))
        self.assertEqual(large["errors"], [])
        self.assertEqual([p["record_id"] for p in large["pending"]],
                         ["R0003", "R0004"])

    def test_boundary_counts_decoded_characters_not_utf8_bytes(self):
        for alphabet, width in [("ascii", 1), ("han", 3)]:
            for count in [16, 17]:
                with self.subTest(alphabet=alphabet, count=count):
                    result = observe(f"{alphabet}:{count}", 16)
                    self.assertEqual(result["note_characters"], count)
                    self.assertEqual(result["note_utf8_bytes"], width * count)
                    self.assertEqual(result["status"],
                                     "ready" if count == 16 else "blocked")
                    self.assertEqual(result["records"], 2 if count == 16 else 1)
                    self.assertEqual(result["complete"], count == 16)
                    self.assertEqual(result["errors"], [] if count == 16 else
                                     [{"code": "csv_syntax", "physical_lines": [2, 2]}])

    def test_existing_reader_uses_later_module_limit(self):
        self.assertEqual(observe("late", 16), {"error": "csv.Error"})
        self.assertEqual(observe("late", 32), {"rows": [["x" * 17]]})

    def test_children_do_not_change_host_setting(self):
        before = csv.field_size_limit()
        observe("main", 16)
        self.assertEqual(csv.field_size_limit(), before)


if __name__ == "__main__":
    unittest.main(verbosity=2)