行数对不上 — 数的是记录,不是行
目标
构建守门程序 csvgate.py,按记录而不是按行来统计客户的 CSV。用自己的数据量出手工切分的解析器与标准解析器的结果相差多少,判别分隔符、行尾和是否有表头,并把无法使用的行分离到拒绝文件中。
为什么重要
在 CSV 中,行与记录是两回事。订单备注中如果有换行,一条记录在文件里就占两行;商号中如果有逗号,按逗号切分这一行时,列就会增多。这两种情况下,解析器都不会报错,而是给出看起来很像样的数字。所以这种错误会一直存活,直到客户看着数字说不对劲。 RFC 4180 规定了引号规则和行尾,但收到的并不都是完全遵守规范的文件。分隔符可能是分号或制表符,行尾可能在一个文件里混用,还可能没有表头。是否有表头并没有写在文件里,所以必须自己判别。 接下来还有一个问题:如何处理无法使用的行。悄悄跳过,销售额就会少掉对应的条数,之后没有依据可以解释。如果是银行接收文件,哪怕只有一行对不上,也要把整个文件退回去;而客户给的分析用数据没有地方可退:能用的行照用,只把不能用的行分离出来。 评分器不会相信你的文字。它在临时目录里摆好评分器生成的 feed,真实运行你的守门程序,并把记录数、金额合计和拒绝原因与它自己数出来的值进行核对。商号和金额每次运行都会变。
步骤
- 创建并运行 /root/csv/gen_feed.py,在 /root/csv/feed 下生成七个文件。同样的 20 条订单会以七种形态出现。
- 在 /root/csv/csvgate.py 中实现
naive,让它给出“把一行当作一条记录”的解析器的结果(rows、amount_total、bad_field_count)。 - 增加
parse,让它给出用 csv 模块正确统计的结果(records、amount_total),并把两个结果的差异写入 /root/csv/gap.json。 - 增加
sniff,让它把行尾判别为 CRLF、LF 或 MIXED。 - 让
sniff从候选(逗号、分号、制表符、竖线)中判别分隔符。 - 让
sniff判别是否有表头(header)和列数(fields)。 - 增加
check,让它按原因统计无法使用的行,并分离到拒绝文件中。拒绝文件以相同的名称写到输入文件所在目录下的rejects/中。 - 一次性处理七个 feed 文件,生成 /root/csv/feed_report.json 和 /root/csv/feed_report.md。
参考
- 运行契约:
python3 /root/csv/csvgate.py <명령> <파일>(占位符依次为命令、文件)。命令有 naive、parse、sniff、check 四个。成功时退出码为 0,文件不存在时为 3,用法错误时为 2。结果以一整块 JSON 输出到标准输出。 naive的响应:{"rows": 정수, "amount_total": 정수, "bad_field_count": 정수}(占位符为整数)。第一行视为表头并跳过,空行也不计,用逗号切分后列数不是 5 的,计入 bad_field_count。parse的响应:{"records": 정수, "amount_total": 정수}(占位符为整数)。sniff的响应:{"path": 문자열, "delimiter": 문자열, "newline": "CRLF"|"LF"|"MIXED"|"NONE"}(占位符依次为字符串、字符串)。从第 6 步起增加fields和header,从第 7 步起增加unterminated。check的响应:{"file": 이름, "delimiter": 문자열, "records": 정수, "accepted": 정수, "rejected": 정수, "amount_total": 정수, "reasons": {"field_count": 정수, "empty": 정수, "unterminated": 정수}}(占位符依次为名称、字符串、整数、整数、整数、整数、整数、整数、整数)。- 拒绝文件以表头
reason,record_no,fields开头,record_no 是去掉表头之后的第几条记录(从 1 开始)。 - 用 Python 的 csv 模块打开文件时,写成
open(path, encoding="utf-8", newline="")。漏掉newline="",引号内的换行就会把记录截断。 - 表头判别采用本实验的假设:如果第一行没有任何一列能读成数字,而紧随其后的三行中有,就视为表头。数据变了,这把尺子也要重新确定。
- 分隔符判别也是假设。对每个候选把整个文件解析一遍,选择列数最一致且最宽的候选。
- 官方文档:RFC 4180 · python csv · python json
- 常见错误:用
wc -l来报告条数;漏掉newline="";悄悄跳过无法使用的行;把没有闭合的引号误当作错误(解析器是安静的)。 - 想直接看到行尾,请使用
od -c <파일> | head(占位符为文件)。
生成七种形态的同一份 feed
创建并运行 /root/csv/gen_feed.py,在 /root/csv/feed 下生成七个文件。同样的 20 条订单,只在分隔符、行尾、表头和是否损坏上有所不同。
用 Python csv 模块的 writer 来写,引号会自动处理。改变 lineterminator 来选择行尾,改变 delimiter 来选择分隔符。故意弄坏的文件(列数不同的行、没有闭合的引号)不要用 writer,而是直接用字符串写。
给出手工切分的解析器的结果
在 /root/csv/csvgate.py 中实现 naive <파일>(占位符为文件),让它把“一行就是一条记录”的解析器的结果以 JSON 输出。包含 rows、amount_total、bad_field_count 三个值。
这一步是故意做出一个错误的解析器:把文件整个读入,按换行切分,跳过第一行和空行,再把每一行按逗号切分。列数不是 5 的,不要累加金额,只计入 bad_field_count。之后要与正确统计的结果并排放在一起,所以规则要严格遵守。
正确读取引号内的逗号和换行
增加 parse <파일>(占位符为文件),让它给出用 csv 模块统计的结果(records、amount_total),并把针对 orders_rfc.csv 的两个结果的差异,以 file、naive_rows、csv_records、naive_amount、csv_amount 的形式写入 /root/csv/gap.json。
关键在于打开文件时传入 newline=""。csv.reader 会处理引号内的分隔符和换行,以及写成两个的引号。去掉表头行之后剩下的就是记录,金额只在列数正确的记录中累加。
找出行尾混用的文件
增加 sniff <파일>(占位符为文件),让它判别行尾。只有 CRLF 时为 CRLF,只有 LF 时为 LF,两者混用时为 MIXED。响应中必须包含 path、delimiter、newline。
行尾要按字节来数。以二进制方式读取文件,数出 CRLF 的个数,再用 LF 的总数减去它,就得到单独出现的 LF 的个数。两者都不是 0,就是混用。这一步中分隔符可以先设为逗号。
从候选中选出分隔符
让 sniff 从逗号、分号、制表符、竖线四个候选中判别分隔符。对每个候选把整个文件解析一遍,选择列数最一致且最宽的那个。
只看第一行来数,会被引号内的分隔符骗到。对每个候选用 csv.reader 整个解析,求出列数的众数,以及具有该众数的行所占的比例;只有一列的候选,得分设为 0。即使各行列数不同的文件,这个办法也撑得住。
判别是否有表头
给 sniff 的响应增加 fields(列数)和 header(是否有表头)。如果第一行没有任何一列能读成数字,而紧随其后的三行中有,就视为表头。
是否有表头并没有写在文件里。RFC 4180 也只是说要通过媒体类型的参数来告知。所以这不是判别而是推测,而推测需要一把尺子。把这把尺子写进代码,下次交付时如果出错,就能看到该改什么。
只分离无法使用的行
增加 check <파일>(占位符为文件),让它逐条记录检查,按原因统计空行、列数不一致和没有闭合的引号,并分离到拒绝文件中。拒绝文件以相同的名称写到输入文件所在目录的 rejects/ 中。给 sniff 的响应增加 unterminated。
没有闭合的引号不会报错。从那个位置到文件末尾成为一个字段,看起来只是“最后一条”。把文件扫一遍,看它是否以引号打开的状态结束,就能确切知道:写成两个的引号要跳过。被拒绝的行要同时写明它原本是第几条记录。
用一份 feed 报告
一次性处理七个 feed 文件,在 /root/csv/feed_report.json 中写入 files、accepted、rejected、amount_total,并在 /root/csv/feed_report.md 中分 ## 무엇을 받았나 ## 줄과 레코드는 다르다 ## 떼어 낸 줄 ## 보내는 쪽에 요청할 것 四节书写(韩文标题,依次意为“收到了什么”“行与记录不同”“分离出的行”“需要向发送方请求的内容”)。
files 是以文件名为键的对象,其中包含 delimiter、newline、header、records、accepted、rejected、amount_total。报告里要用数字写明分离出的行数:客户必须能在自己的文件里打开那一行,沟通才会更快。调用前面步骤中做好的函数即可。