qinghuan
← 回到知识探索

AI 直觉 / 动手实验

已经用了 CSV 解析器,两行备注为什么还是粘成了一行?

实际生成一份带逗号、引号与多种换行的工具报表,对照四条读取路径;把记录数正确、语法通过与字段原文保留分开验收。

展开本文目录
  1. 下载完整练习包
  2. 先分清文件里的一行和表里的一条记录
  3. 用一份能看见边界的输入做对照
  4. 第一条错误路径连记录数量都会认错
  5. 换成解析器,也恢复不了已经删掉的字符
  6. 另一种变化发生在文本读取层
  7. 运行四条路径,再对照五个文件
  8. 严格语法、表格形状和字段含义各查一层
  9. 把原文边界保留到工具结果核验之后
  10. 记住这些要点
  11. 别把例子用过头
  12. 自己试一小步
  13. 查原始资料
开始前,会这些就够了
  • 能运行 Python,读懂字符串、列表与 with 文件读取
  • 知道反斜杠转义写法:\r 表示回车、\n 表示换行
  • Python 3.10+,仅标准库;本例不调用模型或真实业务服务

查询工具交回四条记录,其中一条备注有两行。助手的包装程序先把全文切成多行,再交给 CSV 解析器;没有异常,记录数和列数也正确,备注却变成 firstsecond。出错的步骤早于解析器:需要保留的换行已经被删掉了。

本篇实际写出并读取一份固定 CSV,比较四条路径,保留原始文件、字节十六进制和逐字段差异。原来的 IO 课讲过读取边界;这里进一步检查 CSV 的字段边界,特别观察“结果长得像一张表”仍不足以证明原文完整。

把例子带到自己的电脑上

下载完整练习包

需要 Python 3.10 或更新版本,无第三方依赖;制作时实测 Python 3.14.0。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • csv_record_boundaries.py:完整示例
  • README.md:环境、范围与独立练习
  • observed.stdout.txt:制作时的实际输出
  • verification.json:源码与产物验证记录
  • SHA256SUMS.txt:文件内容摘要

在这个文件夹打开 PowerShell,逐行运行

python --version
python -B csv_record_boundaries.py

解压到独立练习文件夹,在含源文件的目录打开终端运行。程序会在当前目录写入教学报告;先预测结果,再查看当次输出。Windows、Python 标准库对一份固定 UTF-8 CSV 的四条读取路径;只核对虚构字段原文,不访问真实业务文件或电子表格界面。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

先分清文件里的一行和表里的一条记录

CSV 中的逗号通常分隔字段,但被双引号包住的字段可以包含逗号。换行也有类似情况:它可能结束一条记录,也可能属于某条备注内部。先按屏幕上看见的行数决定记录数,会绕过引号所表达的结构。

RFC 4180 第 2 节说明含换行、逗号和双引号的字段如何使用引号,字段内部的双引号以两个双引号表示。本例由 csv.writer 负责写出这些形式,不自己拼引号,也不把引号数量当成记录数量。

工具的交付合同先固定为三列 id、title、note,四条数据记录,文件首条另放表头。所有字段都是字符串,并要求逐字符保留。这个合同是在读取之前声明的,不能看到某条读取路径已经改写结果,再把改写后的值当成正确预期。

用一份能看见边界的输入做对照

R1 是普通对照,标题是青禾,备注是 ordinary。R2 的标题含逗号,备注是 first、回车换行、second;R3 的标题含文字双引号,备注只有一个换行;R4 标题为空,备注中间只有一个回车。三个换行样本能分别指出被删除和被转换的字符。

输出明确使用 UTF-8、逗号、双引号、双写内部引号,以及 CRLF 记录终止符。写文件时设置 newline='',让 CSV 写入器决定换行字节。文件中包含一个表头和四条数据记录,但按本夹具出现的行结束符观察,一共有八个物理行。

十六进制文件让不可见字符也能检查:0d 0a 是回车加换行,0a 是单独换行,0d 是单独回车。JSON 报告则用转义显示字段原文。一个展示字节,一个展示解析后的值,两者对应起来,比只看终端中排得整齐的几行文字更可靠。

第一条错误路径连记录数量都会认错

split_lines_and_commas 先执行 text.splitlines(),再对每段执行 split(',')。它把字段里的换行当成记录分隔,把标题里的逗号当成额外一列。四条数据因此被算成七条,部分行的列数也不再是三。

这组错误比较明显,但普通对照依然可以读对。如果测试数据只有不带标点的短文字,错误实现会显得正常。应在实现前准备能够区分结构符号与字段内容的样本,而不是只增加更多普通姓名,期待数量增加就会自动覆盖边界。

换成解析器,也恢复不了已经删掉的字符

split_lines_then_csv 把同一份 splitlines 结果交给 csv.reader,并开启 strict=True。解析器能跟踪尚未闭合的引号,所以这次仍交回四条数据,每条也都有三列。然而三个带换行的备注分别变成 firstsecond、upperlower、leftright,没有任何异常。

str.splitlines 文档说明默认结果不保留行结束符。解析器接到的已经是没有这些字符的字符串片段;它能继续识别字段,却没有依据猜出被删除的是回车、换行,还是两者组合。

这次反例的重点是数量检查仍然通过。只验证有四条记录、每条三列,会漏掉全部三个备注错误。必须继续比较每一列的实际值;原文需要保留时,换行也是值的一部分,不能因为它看不见就从验收条件中删掉。

另一种变化发生在文本读取层

default_newline_then_csv 直接把文件交给解析器,但以 newline=None 打开,这是默认文本换行处理。它没有粘掉两段文字,却把 R2 的回车换行和 R4 的单独回车都转换成了单独换行;R3 原本就是换行,所以不变。报告中只有两个字段与原文不同。

TextIOWrapper 文档区分了两种设置:None 会进行通用换行转换,空字符串则识别这些结束形式而不转换。最终路径以 newline='' 打开,再交给 csv.reader,四条记录及所有字段才与原始列表完全一致。

如果业务明确允许把各种换行统一,转换未必违反需求。本题检验的是原文逐字符保留,所以把转换列为差异。需要规范化时,应把它设计为独立步骤,保存原文与规范化结果的关系;不要让读取文件的默认选项悄悄代替业务作决定。

运行四条路径,再对照五个文件

把完整代码保存为 csv_record_boundaries.py,在独立练习目录运行。它会生成原始 CSV、十六进制文本、JSON 报告、四行摘要 CSV 和逐字段差异 CSV;重复运行会更新这些同名教学文件。制作时在 Windows、Python 3.14.0 实际通过,未执行其他系统或逐个 Python 版本。

三个解析器分支都没有语法错误,但字段完全一致只出现在保留换行的分支。摘要中的变化单元格数依次为十四、三、二、零。第一组已经错位,十四是按位置比较的差异数,不能解释成十四条独立业务记录。记录下标零代表表头。

正确路径的 reader.line_num 依次为一、二、四、六、八。csv 文档说明这个数统计消耗的来源行,不等于返回的记录数。差异 CSV 中的值先编码成 JSON 文字,让报告自己的换行也能被直接检查。

展开收起代码 · 小实验python · 124 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""Four local CSV reading paths; fictional string fields, no service or model."""
from pathlib import Path
import csv
import hashlib
import json
import platform
import sys

HEADER = ["id", "title", "note"]
RECORDS = [
    ["R1", "青禾", "ordinary"],
    ["R2", "comma,inside", "first\r\nsecond"],
    ["R3", 'say "hello"', "upper\nlower"],
    ["R4", "", "left\rright"],
]
EXPECTED = [HEADER, *RECORDS]
FORMAT = {"delimiter": ",", "quotechar": '"', "doublequote": True,
          "quoting": csv.QUOTE_MINIMAL, "skipinitialspace": False}


def parse_csv(lines):
    reader = csv.reader(lines, strict=True, **FORMAT)
    rows, ends = [], []
    for row in reader:
        rows.append(row)
        ends.append(reader.line_num)
    return rows, ends


def cell(rows, record, column):
    if record < len(rows) and column < len(rows[record]):
        return {"present": True, "value": rows[record][column]}
    return {"present": False, "value": None}


def inspect(name, rows, consumed_lines):
    differences = []
    for record in range(max(len(EXPECTED), len(rows))):
        expected_width = len(EXPECTED[record]) if record < len(EXPECTED) else 0
        actual_width = len(rows[record]) if record < len(rows) else 0
        for column in range(max(expected_width, actual_width)):
            expected, actual = cell(EXPECTED, record, column), cell(rows, record, column)
            if expected != actual:
                differences.append({"record_index": record, "column_index": column,
                                    "expected": expected, "actual": actual})
    return {"mode": name, "records_including_header": len(rows),
            "data_records": len(rows) - 1, "all_widths_three": all(len(row) == 3 for row in rows),
            "same_record_count": len(rows) == len(EXPECTED), "exact_values": rows == EXPECTED,
            "source_lines_after_each_record": consumed_lines,
            "rows": rows, "differences": differences}


def main():
    path = Path("tool-records.csv")
    with path.open("w", encoding="utf-8", newline="") as file:
        writer = csv.writer(file, lineterminator="\r\n", **FORMAT)
        writer.writerows(EXPECTED)
    raw = path.read_bytes()
    text = raw.decode("utf-8")  # No newline conversion in bytes.decode.
    physical_lines = text.splitlines()
    cases = [inspect("split_lines_and_commas", [line.split(",") for line in physical_lines], [])]
    rows, ends = parse_csv(physical_lines)
    cases.append(inspect("split_lines_then_csv", rows, ends))
    with path.open(encoding="utf-8", newline=None) as file:
        rows, ends = parse_csv(file)
    cases.append(inspect("default_newline_then_csv", rows, ends))
    with path.open(encoding="utf-8", newline="") as file:
        rows, ends = parse_csv(file)
    cases.append(inspect("preserved_newline_then_csv", rows, ends))

    naive, stripped, translated, preserved = cases
    assert len(physical_lines) == 8 and len(EXPECTED) == 5
    assert naive["data_records"] == 7 and not naive["all_widths_three"]
    assert stripped["same_record_count"] and stripped["all_widths_three"]
    assert [row[2] for row in stripped["rows"][2:]] == ["firstsecond", "upperlower", "leftright"]
    assert len(stripped["differences"]) == 3 and not stripped["exact_values"]
    assert [row[2] for row in translated["rows"][2:]] == ["first\nsecond", "upper\nlower", "left\nright"]
    assert len(translated["differences"]) == 2 and not translated["exact_values"]
    assert preserved["exact_values"] and not preserved["differences"]
    assert preserved["source_lines_after_each_record"] == [1, 2, 4, 6, 8]
    assert path.read_bytes() == raw  # Every reading path leaves the input bytes untouched.

    # Unclosed quoted input is a separate syntax failure; it is never repaired silently.
    malformed = 'id,title,note\r\nR5,plain,"unfinished\r\n'
    from io import StringIO
    try:
        parse_csv(StringIO(malformed, newline=""))
    except csv.Error as error:
        syntax_error = {"rejected": True, "type": type(error).__name__, "message": str(error)}
    else:
        raise AssertionError("unterminated quoted field was accepted")

    report = {"assertions": "passed", "python": sys.version.split()[0], "platform": platform.system(),
              "scope": "Four standard-library readers over one fixed local UTF-8 CSV. No spreadsheet UI, model, network or external business file.",
              "dialect": {"encoding": "utf-8", "delimiter": ",", "quotechar": '"',
                          "doublequote": True, "record_terminator": "\r\n", "reader_strict": True},
              "input_sha256": hashlib.sha256(raw).hexdigest(), "input_byte_count": len(raw),
              "input_hex": raw.hex(), "escaped_input": text, "expected": EXPECTED,
              "physical_lines_for_this_fixture": len(physical_lines), "cases": cases,
              "syntax_control": syntax_error}
    Path("csv-record-report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    Path("tool-records.hex.txt").write_text(raw.hex() + "\n", encoding="ascii")
    fields = ["mode", "data_records", "all_widths_three", "same_record_count", "exact_values", "changed_cells"]
    with Path("csv-record-summary.csv").open("w", encoding="utf-8", newline="") as file:
        writer = csv.DictWriter(file, fieldnames=fields)
        writer.writeheader()
        writer.writerows({**{key: case[key] for key in fields[:-1]}, "changed_cells": len(case["differences"])} for case in cases)
    # Escape each value as JSON so a difference row never hides its own CR/LF.
    with Path("csv-record-differences.csv").open("w", encoding="utf-8", newline="") as file:
        writer = csv.writer(file)
        writer.writerow(["mode", "record_index", "column_index", "expected_json", "actual_json"])
        for case in cases:
            for change in case["differences"]:
                writer.writerow([case["mode"], change["record_index"], change["column_index"],
                                 json.dumps(change["expected"], ensure_ascii=False),
                                 json.dumps(change["actual"], ensure_ascii=False)])
    print(json.dumps({"assertions": "passed", "read_paths": len(cases), "input_data_records": len(RECORDS),
                      "physical_lines": len(physical_lines), "stripped_changed_cells": 3,
                      "translated_changed_cells": 2, "preserved_changed_cells": 0,
                      "syntax_control_rejected": syntax_error["rejected"]}))


if __name__ == "__main__":
    main()

怎样运行:Python 3.10+,仅标准库。保存为 csv_record_boundaries.py,在独立目录运行 python -B csv_record_boundaries.py;不要加 -O,验收使用 assert。 2026-10-16 已用 Python 3.14.0 执行这份代码并通过自带检查;下方是当次输出。Windows、Python 标准库对一份固定 UTF-8 CSV 的四条读取路径;只核对虚构字段原文,不访问真实业务文件或电子表格界面。

运行后对照结果
{"assertions": "passed", "read_paths": 4, "input_data_records": 4, "physical_lines": 8, "stripped_changed_cells": 3, "translated_changed_cells": 2, "preserved_changed_cells": 0, "syntax_control_rejected": true}

严格语法、表格形状和字段含义各查一层

脚本另给解析器一份引号没有闭合的短输入,strict=True 会抛出 csv.Error。这说明它能发现本例的语法问题,却不说明它会识别先前丢失的换行。错误处理要分别回答:能否按所选方言解析、表头与列数是否符合合同、字段值是否保持要求。

RFC 4180 是说明性文档,记录终止形式写作 CRLF;Python 的 reader 也能识别单独回车或换行,不能把这种宽容读取等同于完整标准一致性校验。本例固定 UTF-8 与上述方言,并在字段内放入不同换行进行比较,没有实现全部 RFC 检查器,也不判断任意来源文件的格式。

把原文边界保留到工具结果核验之后

把这个实验接回 Agent:工具包装层先按已声明的编码和 CSV 方言读取,核对表头、记录与字段,再交给后续摘要或选择步骤。若要让模型概括备注,可以另生成展示文本;原始字段仍应可回查,否则后续无法解释它究竟看过两行文字还是粘接后的字符串。

这里没有访问真实商户文件、电子表格界面或远程接口,也不测大文件速度。输入尺寸、编码错误、不同方言与业务字段约束需要在真实工具中另行定义。本次产物只证明固定夹具的四条读取路径怎样改变或保留内容,不能把一次成功往返扩大成所有 CSV 都能无损处理。

记住这些要点

  • CSV 记录可以跨物理行,逗号和换行不总是结构分隔符。
  • 先删掉行结束符,再调用解析器,仍可能无异常地改坏字段。
  • newline=None 与 newline='' 的区别也会影响字段原文。
  • 把语法、记录形状和逐字段内容分别核对,并保留原始字节。

别把例子用过头

  • 只用不含引号和换行的普通数据验证读取器。
  • 用记录数和列数正确替代字段内容完整。
  • 把 reader.line_num 当成已返回记录数。
  • 把 Python 宽容读取或 strict=True 说成完整 RFC 一致性证明。

自己试一小步

自己设计一份工具报表,怎样证明读取过程没有悄悄改变字段?

  1. 固定编码、分隔符、引号和原文保留要求,再设计至少六条虚构记录;包含多行字段、文字引号、空字段及一条普通对照。
  2. 在运行前独立列出每个字段的预期字符串,至少写出两种会改变结果的预处理路径。
  3. 保存原始字节、解析结果与按记录及字段定位的差异,并区分记录数量错误和仅内容错误。
  4. 另设计一条格式错误输入,说明语法拒绝与业务拒绝分别由谁负责;不用真实个人资料,不接模型或生产导入。

做到这些,就算完成

  • 预期是否来自原始记录,而不是错误处理后的输出?
  • 是否能指出具体哪个不可见字符被删除或转换?
  • 是否说明换行规范化被允许还是被禁止,以及这一决定的依据?
  • 是否报告实际运行环境与未验证的方言范围?

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。

  • RFC 4180:CSV 公共格式说明 (新标签页)↗RFC Editor · 英文

    实际核读说明性文档状态、第 2 节记录/字段/引号规则及 ABNF,核对 CRLF 记录边界和字段内部换行。

    2026-10-16 实际打开并核读;不把本文 UTF-8/Python 方言实验称为完整 RFC 一致性认证。

  • Python 3.14:csv reader 与 newline (新标签页)↗Python 官方文档 · 英文

    实际核读 reader、writer、Dialect、strict、line_num 与 newline='' 脚注,区分消耗行数和返回记录。

    2026-10-16 公开页面已核读;页面为当前 3.14 文档,实跑解释器为 3.14.0,仅检验指定方言。

  • Python 3.14:TextIOWrapper 的换行处理 (新标签页)↗Python 官方文档 · 英文

    实际核读 newline=None、空字符串以及读取转换规则,用于解释解析之前已发生的字符变化。

    2026-10-16 实际打开并核读;未测试所有编码、操作系统和流实现。

  • Python 3.14:str.splitlines (新标签页)↗Python 官方文档 · 英文

    实际核读 keepends 默认值、默认删除行结束符及支持的边界集合,解释预处理丢失信息。

    2026-10-16 页面重定向到同站 builtins/stdtypes.html 后核读;本文只实测夹具中的 CRLF、LF、CR。