CSV的一行不等于一条记录:用Python验证逗号、引号和换行
作者:lgbisha。本文由AI辅助起草、整理代码与校验结果;示例全部为合成数据。代码在本地Python 3.12.3运行,32项断言通过、0失败。不包含真实客户文件、云端部署或生产环境验证。
处理CSV时,最容易写出的代码是先splitlines(),再对每一行split(",")。它在只有简单数字和英文的演示文件上似乎能用,但遇到一条带逗号的备注,或者单元格中合法存在的换行,就不再可靠。
这次用一个很小的输入验证三件事:物理行与逻辑记录不是同一个概念;CSV解析不等于业务校验;完整返回结果之前,可以先检查结构,避免调用方误用半份结果。
1. 一个只有两条记录的反例
下面的备注包含逗号和单元格内换行:
id,note
001,"first, second"
002,"line one
line two"
本地示例使用CRLF作为记录分隔符,第二条备注内部使用LF。对整份内容调用splitlines(),得到4段;但CSV解析后的数据记录只有2条,另有1条表头。
另一个很直接的对比:
broken = '001,"first, second"'.split(",")
assert len(broken) == 3
这里多出来的列不是源数据错误,而是切分方法没有理解引号。不能再用“把后面的列拼回去”修补,因为还存在引号转义、空字段和多行字段等组合情况。
2. 先写清这个小导入器的契约
本例约定输入为UTF-8字节,允许文件开头存在UTF-8 BOM;表头必须与调用方指定的名称和顺序完全一致;每条记录的列数必须等于表头数。
其他选择也明确下来:
- 只含表头是合法的空数据集;完全空文件不是。
- 两个空字段与一条完全空白的记录不同:前者可保留为空字符串,后者在本例中拒绝。
- 不自动去除字段前后空格,不把
007转换成数字7。 - 任何一条记录结构错误,函数都不返回部分列表。
这些是演示程序的策略,不代表所有业务都必须如此。例如有的系统允许列顺序变化,但那时就应该显式实现映射,而不是让行为依赖偶然的文件格式。
3. 使用csv.reader,再添加结构校验
import csv
import io
def read_records(payload, expected_headers):
if not expected_headers or len(set(expected_headers)) != len(expected_headers):
raise ValueError("表头配置为空或重复")
text = payload.decode("utf-8-sig", errors="strict")
reader = csv.reader(io.StringIO(text, newline=""), strict=True)
headers = next(reader, None)
if headers != list(expected_headers):
raise ValueError("表头或顺序不匹配")
records = []
for fields in reader:
if len(fields) != len(headers):
raise ValueError(f"截至物理行 {reader.line_num} 的记录列数不匹配")
records.append(dict(zip(headers, fields)))
return records
newline=""让CSV解析器处理换行,而不是先把内容拆成字符串列表。读取磁盘文本文件时,也应结合实际编码使用open(..., newline="");这里用StringIO只是为了让合成输入容易复现。
strict=True能够让部分格式错误以csv.Error报告,例如本次验证中的未闭合引号。但它不是完整的业务规则检查器,也不能代替表头、列数和字段内容校验。代码显式检查列数,避免直接zip时静默丢掉多余字段。
另外,reader.line_num是读取过的源文本行数,不是业务记录序号。多行字段存在时,两者会不同,所以错误消息写成“截至物理行”,而不是把它冒充为第几条订单。
4. 测试不只检查正常输入
最小验证可以这样写:
payload = (
b'id,note\r\n'
b'001,"first, second"\r\n'
b'002,"line one\nline two"\r\n'
)
records = read_records(payload, ["id", "note"])
assert len(records) == 2
assert records[0] == {"id": "001", "note": "first, second"}
assert records[1]["note"] == "line one\nline two"
完整的本地校验共32项:
- 6项基础反例:记录数、前导零、逗号、多行内容、物理行数和错误切分。
- 10项writer→reader往返校验:普通中文、逗号、双引号、LF、CRLF、空字符串、前后空格、emoji、制表符和分号。
- 4项正常边界:BOM、仅表头、空字段与不隐式转换数字。
- 11项拒绝策略:空文件、重复或错序表头、缺列、多列、空白记录、未闭合引号、非法UTF-8、错误的表头配置,以及后部坏记录。
- 1项范围校验:
=1+1仍作为普通字符串返回,确认解析器没有假装完成公式安全处理。
运行结果为32通过、0失败。这些断言只证明列出的输入在这个版本和实现上符合约定,不代表已覆盖所有CSV方言或任意大文件。
5. 结构正确之后,还有哪些事没有做
首先,CSV语法合法不代表业务数据有效。日期、金额、枚举值、主键唯一性等仍需各自校验;空字符串是否能代表缺失值也应由字段规则决定。
其次,本函数先收集再返回,只保证调用方不会收到半份返回列表。若调用方边解析边向数据库写入,就需要另外设计事务或暂存机制,不能把这个函数当成数据库原子性保证。
再次,CSV解析不会自动消除电子表格公式风险。本例刻意保留公式形态的字符串,没有宣称文件可以直接安全地交给任意表格软件打开。导出到电子表格属于另一个需要单独设计和验证的环节。
最后,这是一份适合小型合成文件的内存演示,没有实现字节数、记录数、处理时限等资源上限,也没有处理任意编码自动识别。真正的上传接口仍需增加容量限制,并选择错误发生时的清理与反馈策略。
结语
CSV看起来像按行排列的纯文本,但“文本的一行”和“表格的一条记录”不总是相同。使用已有解析器解决语法,再把表头、列数、空值和失败行为写成可执行契约,比不断补丁式拼接字符串更容易验证。
参考与复现范围
- Python标准库官方文档:csv模块的reader、writer、Dialect.strict与csvreader.line_num。
- Python标准库官方文档:io.StringIO的newline参数与codecs中的utf-8-sig编码。
- 验证环境:本地Python 3.12.3;只使用标准库和合成输入,不调用云API,不涉及真实业务数据。
- 点赞
- 收藏
- 关注作者
评论(0)