Python 首批实践:对象、CSV分类统计与完整测试
前置:安装与运行、对象与类型、控制流、函数模块。Python适合把数据转换过程写成可读脚本;对象共享、默认参数与异常是首批重点,不套用Rust的所有权检查规则。
本轮复核仅静态阅读正文、源码与测试规格,未执行示例或测试;文中原有实测版本记录属于此前批次,本轮不复用为修改后的运行证明。命令与断言结果均为读者后续验证预期。
解释器、venv与版本
本批使用标准库,实际验证Windows CPython3.12.14及WSL Linux3.14.4,语法最低3.12。核对日期2026-10-17;新版3.15存在不意味着教学项目必须立即升级。项目无pip依赖,不需要下载requests或pytest。macOS不列入本轮验收。
先运行 python --version 和 python -c "import sys; print(sys.executable)";Linux通常用python3替换。Windows若跳商店,命令解析到了执行别名,先选实际解释器。创建环境 python -m venv .venv 后,PowerShell可直接运行 .\.venv\Scripts\python.exe,Bash运行 .venv/bin/python;无需为了激活修改系统执行策略。用该路径运行所有测试和脚本即可。环境隔离依赖而非权限,见venv。
项目以UTF-8读写文本,Windows重定向输出时可用 python -X utf8 report.py sample.csv 明确编码,避免终端默认编码不同。标准库属于解释器;pip装到另一个环境不能解决当前解释器的ModuleNotFoundError。
Debian/Ubuntu若提示ensurepip不可用,通常缺少匹配解释器的venv系统包,按系统提示安装后重建。当前项目无第三方依赖,也可用 python3 -m venv --without-pip .venv 创建环境并直接用 .venv/bin/python 运行;本批WSL实际验证这条标准库路径,不宣称已验证pip安装。
对象、dict与函数边界
名称绑定到对象,赋值与传参不会自动深拷贝。下面是完整demo.py,运行 python demo.py,断言全部成立,预期输出 {'书': 2, '课': 1}。dict按插入顺序迭代,去重集合set不承诺这种输出顺序;本例显式保留字典。
def count_categories(rows):
counts = {}
for category in rows:
counts[category] = counts.get(category, 0) + 1
return counts
original = ["书", "课", "书"]
alias = original
copy = original.copy()
alias.append("书")
assert len(original) == 4 and len(copy) == 3
assert count_categories([]) == {}
print(count_categories(copy))浅拷贝只复制外层,嵌套容器仍共享内部对象;is比较身份,==比较值,None用is None。函数需要每次新列表时用None默认值再构造,不能用默认[]累积其他调用的数据。列表与队列处理顺序数据,本项目dict按分类聚合,不能用某个列表下标假设所有分类固定。
完整CSV分类统计项目
路径 examples/python/csv-report,保存以下三个文件即可。不写入输入文件;stdout输出JSON结果,stderr输出错误。CSV表头必须恰为 category,amount,分类去首尾空白后1–80字符;金额为ASCII非负十进制,整数最多9位、小数最多2位,不接受NaN/Infinity/负数/科学计数法/多余列;分类总额上限999999999999.99。教学项目不是完整账务系统。
report.py(完整实现)
"""Read a small CSV and summarize bounded, nonnegative decimal amounts."""
import argparse
import csv
import json
import re
import sys
from decimal import Decimal
from pathlib import Path
def summarize(stream):
reader=csv.DictReader(stream,strict=True)
if reader.fieldnames != ["category","amount"]:
raise ValueError("expected header category,amount")
totals={}
for row in reader:
category=(row["category"] or "").strip()
amount=(row["amount"] or "").strip()
if None in row or not category or len(category)>80:
raise ValueError(f"row {reader.line_num}: invalid category or column count")
if not re.fullmatch(r"\d{1,9}(?:\.\d{1,2})?",amount,flags=re.ASCII):
raise ValueError(f"row {reader.line_num}: invalid amount")
totals[category]=totals.get(category,Decimal(0))+Decimal(amount)
if totals[category]>Decimal("999999999999.99"):
raise ValueError("category total exceeds limit")
return {key:format(totals[key],".2f") for key in sorted(totals)}
def main(argv=None):
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument("file",type=Path)
args=parser.parse_args(argv)
try:
with args.file.open(encoding="utf-8-sig",newline="") as stream:
result=summarize(stream)
except (OSError,UnicodeError,ValueError,csv.Error) as error:
print(f"error: {error}",file=sys.stderr)
return 1
print(json.dumps(result,ensure_ascii=False,sort_keys=True))
return 0
if __name__=="__main__":
raise SystemExit(main())sample.csv
category,amount
书,0.10
书,0.20
课,2.00test_report.py(完整测试)
import contextlib
import io
import tempfile
import unittest
from pathlib import Path
from report import summarize, main
class ReportTests(unittest.TestCase):
def test_decimal_and_sorted_groups(self):
self.assertEqual(summarize(io.StringIO("category,amount\n书,0.10\n书,0.20\n课,2.00\n")), {"书": "0.30", "课": "2.00"})
self.assertEqual(summarize(io.StringIO("category,amount\n")), {})
def test_rejects_bad_schema_and_values(self):
for text in ["", "name,total\nx,1\n", "category,amount\nx,-1\n", "category,amount\nx,NaN\n", "category,amount\nx,Infinity\n", "category,amount\nx,1.001\n", "category,amount\n,1\n", "category,amount\nx,nope\n", "category,amount\nx,1,extra\n"]:
with self.subTest(text=text), self.assertRaises(ValueError): summarize(io.StringIO(text))
def test_zero_quoted_category_missing_value_and_total_bound(self):
self.assertEqual(summarize(io.StringIO('category,amount\n"book,course",0\n')),
{"book,course": "0.00"})
with self.assertRaises(ValueError):
summarize(io.StringIO("category,amount\nx\n"))
with self.assertRaises(ValueError):
summarize(io.StringIO("category,amount\nx,\n"))
# 1001 individually valid rows exceed the category cumulative limit.
with self.assertRaisesRegex(ValueError, "category total exceeds limit"):
summarize(io.StringIO("category,amount\n" + "x,999999999.99\n" * 1001))
def test_cli_bom_missing_file_and_bad_data(self):
with tempfile.TemporaryDirectory() as folder:
path=Path(folder)/"data.csv"
path.write_text("category,amount\n书,1.20\n",encoding="utf-8-sig")
out,err=io.StringIO(),io.StringIO()
with contextlib.redirect_stdout(out),contextlib.redirect_stderr(err):
self.assertEqual(main([str(path)]),0)
self.assertIn('"书": "1.20"',out.getvalue())
path.write_text("category,amount\nx,bad\n",encoding="utf-8")
with contextlib.redirect_stderr(err):
self.assertEqual(main([str(path)]),1)
self.assertEqual(main([str(path.with_name("missing.csv"))]),1)
self.assertIn("error:",err.getvalue())
if __name__=="__main__": unittest.main()进入目录,执行 python -X utf8 report.py sample.csv,预期:
{"书": "0.30", "课": "2.00"}金额输出字符串,保留两位小数;不是把二进制float四舍五入后当精确十进制。先限制数字格式与范围,再构造Decimal;默认Decimal精度足够本项目限定范围,若扩展范围必须同时评估上下文精度与累计上限。UTF-8-sig读取同时接受普通UTF-8和带BOM的文件,newline=""让csv模块处理换行。reader逐行读,但分类聚合字典随不同分类数增长,并非恒定内存。
解析函数不依赖打印和路径,便于用StringIO测试;CLI只负责参数、文件边界、打印与退出。if __name__ == "__main__"避免导入时自动执行CLI;模块名不要叫csv.py或decimal.py,以免遮蔽标准库。
异常、退出码与验证
模块目录执行 python -m unittest -v,预期四组测试通过,分别覆盖正常/空数据、格式失败、零金额/引号分类/缺值/累计上限和真实文件CLI;子场景包含精确小数、中文、BOM、坏数字、缺表头/列和不存在文件。保存错误输入再执行CLI也必须非零退出,不能只在函数单测里看异常。
python report.py missing.csv返回1并打印error;参数缺失由argparse打印usage、返回2;有效输入返回0。PowerShell用 $LASTEXITCODE,Bash用 $?检查前一命令。文件不存在、解码失败、CSV格式或业务值错误在CLI边界处理;未预料到的程序错误继续暴露,不能用宽泛except把所有错误伪装成空报告。
遇到ModuleNotFoundError先查 sys.executable、工作目录和模块名遮蔽;文件不存在时确认相对路径相对当前工作目录。不要把输入错误变成{},否则自动化下游无法区分“没有记录”和“根本没读成功”。
练习与自检
先独立设计金额0、超过分类总额上限、缺amount值、带引号的分类名四类输入,写出预期,再对照test_report.py中的同名规格(本轮新增、未执行)。将结果按总额降序输出时,说明为何不能直接按字符串排序。把统计函数导入另一个脚本,确认没有意外打印和参数解析。
参考解法
用StringIO构造每个输入,合法0应输出0.00,缺值和超上限应ValueError;分类中有逗号时用CSV引号格式而非手动split。排序时保留Decimal数值到排序完成,再格式化;字符串"10.00"与"2.00"的字典序不等于数值顺序。导入只定义函数,不触发main;测试结果应同时检查内容与错误通道。补充浅拷贝练习:复制二维列表外层后修改内部列表会同时可见,要按用途逐行复制或选择deepcopy,不能一律深拷贝。
后续范围:并发、异步、第三方数据分析与Web框架不作为本批前置;已有类与测试及线程协程可按需读,不宣称系统高级课程已交付。依据:CSV、Decimal、unittest、中文venv。
继续阅读
- 知识专题
- 排错
- 排错