在 AI 开发者空间实现 CSV 账本汇总工具:整数分、退出码与 50 项测试
记账数据导出成 CSV 后,按月份、按分类求和,看起来只需要几行代码。但如果直接把金额读成浮点数、遇到坏数据就跳过,得到的结果可能不适合继续交给报表或其他程序使用。
这次我在华为云 AI 开发者空间的体验环境里,用 Python 标准库做了一个小型 CSV 账本汇总工具。它读取本地模拟账本,检查日期、分类和金额,再输出按分类排序的 JSON。最终在云端环境实际运行了 50 项 unittest 测试,以及正常汇总、月份筛选、非法金额和文件不存在四个 CLI 场景。
整个练习只处理合成数据,没有连接真实账单、部署服务或创建收费云资源。下面的代码与输出来自本次实际生成和运行的项目。
一、先把输入和失败方式说清楚
项目只有五个文件:
decimal-ledger-demo/
├── ledger_summary.py
├── test_ledger_summary.py
├── sample_valid.csv
├── sample_invalid.csv
└── README.md
CSV 必须有 date、category、amount 三列,允许额外的备注列。表头名称去除首尾空白后不能重复,每条数据记录的字段数必须与表头一致。文件使用 UTF-8 编码,也接受 UTF-8 BOM。
本次合法样例包含不同月份、闰日、备注中的逗号与双引号:
date,category,amount,note
2024-01-15,food,12.50,lunch
2024-01-20,transport,5.00,bus fare
2024-02-29,food,0.10,leap day snack
2024-02-29,food,0.20,leap day coffee
2024-03-01,books,999.99,"Python, the good parts"
2024-03-10,food,100.00,"""quoted"" note"
日期要能转换成真实日历日期;分类去除首尾空白后不能为空。金额在这个练习中统一表示正数金额,单笔范围是 0.01 到 999999999.99,最多两位小数。零、负数、科学计数法、NaN、Infinity、带加号和 .50 这样的写法都被拒绝。因此,它目前适合汇总一类正数记录;收入、支出和退款的业务模型需要另外设计。
退出码也是输入输出约定的一部分:
| 退出码 | 含义 | 本次实现的例子 |
|---|---|---|
| 0 | 成功 | 正常汇总、只有表头、筛选后没有记录 |
| 1 | 数据记录不合法 | 无效日期、空分类、非法金额、字段数量不一致 |
| 2 | 文件、编码、结构或命令参数错误 | 文件不存在、非 UTF-8、缺少必需列、重复表头、CSV 解析异常、错误月份参数 |
正常结果写到 stdout,错误说明写到 stderr。遇到第一条不合法记录就停止,不输出之前累计出的部分结果。这让调用方能够先看退出码,再决定是否接受 JSON。
二、金额从字符串转换为整数分
我采用的处理顺序是:字符串格式校验、Decimal 解析、范围校验、转换成整数分,最后使用 Python 整数累加。
主程序中的金额处理代码如下:
MAX_AMOUNT = Decimal("999999999.99")
AMOUNT_RE = re.compile(r"^[0-9]+(?:\.[0-9]{1,2})?$")
def parse_amount(raw):
s = raw.strip()
if not s:
raise ValueError("金额为空")
if not AMOUNT_RE.match(s):
raise ValueError("不是有效的正数十进制形式(最多2位小数)")
val = Decimal(s)
if val <= 0:
raise ValueError("金额必须为正数(非零)")
if val > MAX_AMOUNT:
raise ValueError("金额超过最大值 999999999.99")
return int(val * 100)
Decimal 直接接收原始字符串,中间不经过 float。通过上述范围校验的单笔金额最多有 11 位有效数字,乘以 100 在本程序默认的 Decimal 精度下足够使用。随后所有分类累计都在整数上进行,0.10 与 0.20 分别变成 10 分和 20 分。
输出时使用除法和余数拼接固定两位小数:
def cents_to_str(cents):
dollars, remainder = divmod(cents, 100)
return f"{dollars}.{remainder:02d}"
JSON 里的金额保留为字符串,例如 "0.30"。如果下游再把这个字段转换成二进制浮点数,仍需要自行处理后续计算的精度问题。Python 整数没有固定 32 位或 64 位的溢出边界,但处理能力仍然受内存、运行时间和输出转换限制。
三、CSV 记录序号与物理行号要分开
CSV 的引号字段可以包含换行,一条逻辑记录可能占据文件中的多行。手写 split(',') 或把每个换行都当成记录边界,都会破坏这类输入。
本次实现先以 utf-8-sig 读取整个文件,再交给标准库解析:
with open(filepath, "r", encoding="utf-8-sig") as f:
text = f.read()
reader = csv.reader(io.StringIO(text), strict=True)
raw_header = next(reader)
# 表头是第 1 条记录,数据记录从第 2 条开始。
for rec_no, row in enumerate(reader, start=2):
...
因此,数据错误里的 CSV记录序号 3 指第三条逻辑记录,不能一概说成第三个物理文本行。原始 README 里还有“行号”的简略表述,使用时应以最终代码中的“CSV记录序号”为准。
strict=True 会让解析器对某些不合法 CSV 抛出 csv.Error,程序把这类异常归为退出码 2。不过,它不能代替所有业务校验,50 项测试也没有覆盖全部畸形 CSV 输入。
这里还有一个明确取舍:当前版本使用 f.read(),内存占用会随输入文件大小增长,适合这次小文件练习。它没有实现流式读取,不能据此声称适合大规模账单。默认文本换行转换也意味着它不保证原样保留字段内的 CRLF 字节形式。
四、月份筛选之前先验证每条记录
可选参数 --month YYYY-MM 用来筛选月份。命令入口先检查格式,再通过 date(year, month, 1) 检查日历范围,例如 2024-13 会失败。
数据处理循环先验证日期、分类和金额,再判断是否属于目标月份。这样,即使某条记录位于其他月份,其中的不合法数据也会导致本次导入失败。这是当前实现的规则,不能把它理解为只检查筛选月份内的数据。
匹配的记录按分类累计,最终按分类名称排序输出。没有匹配记录时返回 {},退出码仍为 0;只有表头的文件也按空账本处理。空文件则因为缺少表头而返回 2。
五、实际命令与输出
本次在 AI 开发者空间的 /root/workspace/decimal-ledger-demo 目录执行了这些命令。下面保留实际输出与观察到的退出码。
$ python3 ledger_summary.py sample_valid.csv
{"books": "999.99", "food": "112.80", "transport": "5.00"}
exit=0
$ python3 ledger_summary.py sample_valid.csv --month 2024-02
{"food": "0.30"}
exit=0
$ python3 ledger_summary.py sample_invalid.csv
错误: CSV记录序号 3: 金额 '-5.00' 无效: 不是有效的正数十进制形式(最多2位小数)
exit=1
$ python3 ledger_summary.py /tmp/no_such_file.csv
错误: 文件不存在: /tmp/no_such_file.csv
exit=2
第二个场景使用两笔金额 0.10 和 0.20,结果为 "0.30"。非法样例先有一条有效记录,再出现负数金额;程序报错退出,没有把前一条记录的汇总当作成功结果输出。
六、50 项测试具体证明了什么
最终测试命令为:
python3 -m unittest test_ledger_summary -v
云端环境的实际结果:
Ran 50 tests in 0.974s
OK
测试包含金额格式与边界、整数分转换、0.10 + 0.20、多分类汇总与排序、月份筛选、闰日、重复或缺失表头、字段数量不一致、UTF-8 BOM、引号中的逗号、转义双引号、引号内换行、文件不存在、非法 UTF-8、CLI 退出码、无部分输出和输入内容不被修改。
其中一部分测试直接调用解析函数,另一部分通过 subprocess 启动 CLI,检查退出码、stdout 和 stderr。因此,最终结果覆盖了实际命令调用,而不只是函数内部计算。
0.974s 是这一次云端测试套件的运行时间,不能当作数据处理性能基准。50 项通过也不等于所有边界都已验证:文件权限错误、超大文件、异常 Unicode 月份参数、各种畸形引号输入及 Windows 运行行为,本次都没有完整实测。
七、作为小工具,下一步该改哪里
这个练习让我把金额表示、输入校验和命令行失败语义放在同一条流程里检查。实际使用前,我会先根据账单大小决定是否改成流式读取,再补上对应的读取异常测试;如果要处理收入、支出、退款或多币种,则先明确字段和金额规则,避免直接放开负数后混用不同含义。
当前版本保留首个错误后退出,适合发现问题后逐次修正。如果导入场景需要一次展示全部错误,可以另外增加错误收集模式,同时仍要求存在错误时不输出成功汇总。源码、样例、测试和原始运行结果已经保存,本次记录到的行为都可以据此复查。
- 点赞
- 收藏
- 关注作者
评论(0)