开始前,会这些就够了
- 能运行 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 文字,让报告自己的换行也能被直接检查。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""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 一致性证明。
自己试一小步
自己设计一份工具报表,怎样证明读取过程没有悄悄改变字段?
- 固定编码、分隔符、引号和原文保留要求,再设计至少六条虚构记录;包含多行字段、文字引号、空字段及一条普通对照。
- 在运行前独立列出每个字段的预期字符串,至少写出两种会改变结果的预处理路径。
- 保存原始字节、解析结果与按记录及字段定位的差异,并区分记录数量错误和仅内容错误。
- 另设计一条格式错误输入,说明语法拒绝与业务拒绝分别由谁负责;不用真实个人资料,不接模型或生产导入。
做到这些,就算完成
- 预期是否来自原始记录,而不是错误处理后的输出?
- 是否能指出具体哪个不可见字符被删除或转换?
- 是否说明换行规范化被允许还是被禁止,以及这一决定的依据?
- 是否报告实际运行环境与未验证的方言范围?
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- RFC 4180:CSV 公共格式说明 (新标签页)↗
实际核读说明性文档状态、第 2 节记录/字段/引号规则及 ABNF,核对 CRLF 记录边界和字段内部换行。
- Python 3.14:csv reader 与 newline (新标签页)↗
实际核读 reader、writer、Dialect、strict、
line_num与 newline='' 脚注,区分消耗行数和返回记录。 - Python 3.14:TextIOWrapper 的换行处理 (新标签页)↗
实际核读 newline=None、空字符串以及读取转换规则,用于解释解析之前已发生的字符变化。
- Python 3.14:str.splitlines (新标签页)↗
实际核读 keepends 默认值、默认删除行结束符及支持的边界集合,解释预处理丢失信息。