实验 02 / 04 · 50 分钟
CSV 读取与数据汇总
把逐行解析组合成可定位错误、可重复运行的数据管线。
学习目标
- 使用标准库正确处理 CSV、引号与 UTF-8 BOM。
- 让错误包含具体行号,避免静默丢弃坏数据。
- 按项目汇总时间,输出顺序稳定的报告。
前置知识
- 完成实验 01,理解 Session 和 ValueError。
- 能遍历列表并更新字典。
环境配置与运行
Python 3.11+ · 仅标准库 · 在本机终端运行
下载并解压课程包,打开其中的 python-engineering 目录。以下命令均在包含 lab01.py 的目录执行。首次学习时创建环境,后续实验复用同一个 .venv。
py -3 --version
py -3 -m venv .venv
.\.venv\Scripts\python.exe --version.\.venv\Scripts\python.exe -X utf8 lab02.py.\.venv\Scripts\python.exe -X utf8 -m unittest test_lab02 -v{
"session_count": 4,
"total_minutes": 150,
"projects": {
"git": 30,
"python": 90,
"web": 30
}
}实验步骤
- 01
检查输入契约
打开 sessions.csv,确认表头严格等于 date,project,minutes。先手算 python 的两次 45 分钟,以及整体 150 分钟。
- 02
拆开两个职责
阅读 load_sessions 的文件上下文与错误行号,再阅读 summarize 的字典累计。先验证数据再计算汇总,坏行出现时整次处理失败。
- 03
验证错误路径
复制一份 CSV,把第三行分钟改成 -5。临时把脚本 sample 指向副本并运行,确认错误指出 line 3。随后还原代码并执行测试。
让 CSV 库负责解析格式
字段可能被引号包围,也可能含有逗号;split(',') 无法正确处理。DictReader 负责解析,parse_row 负责业务校验。utf-8-sig 同时接受普通 UTF-8 和带 BOM 的文件。
稳定输出便于比较
summarize 不修改输入列表,并按项目名排序。相同记录即使顺序不同,汇总结果也相同。重复记录代表多次学习,会重复累计;空数据必须保留表头。
完整代码
以下为课程包中的实际源码。后续实验复用前序模块,首次运行请下载完整课程包。
"""Lab 02: load CSV and build a deterministic summary."""
import csv
import json
from pathlib import Path
from lab01 import Session, parse_row
def load_sessions(path: Path) -> list[Session]:
sessions = []
with path.open(encoding="utf-8-sig", newline="") as source:
reader = csv.DictReader(source, strict=True)
if reader.fieldnames != ["date", "project", "minutes"]:
raise ValueError("header must be: date,project,minutes")
try:
for row in reader:
if None in row:
raise ValueError("unexpected extra columns")
sessions.append(parse_row(row))
except (ValueError, csv.Error) as error:
raise ValueError(f"line {reader.line_num}: {error}") from None
return sessions
def summarize(sessions: list[Session]) -> dict:
totals: dict[str, int] = {}
for session in sessions:
totals[session.project] = totals.get(session.project, 0) + session.minutes
return {
"session_count": len(sessions),
"total_minutes": sum(totals.values()),
"projects": dict(sorted(totals.items())),
}
if __name__ == "__main__":
sample = Path(__file__).with_name("sessions.csv")
print(json.dumps(summarize(load_sessions(sample)), indent=2))
测试用例
执行上面的测试命令,应看到 8 个测试通过和 OK。先理解断言,再尝试引入错误验证测试有效。
test_lab02.py
import tempfile
import unittest
from pathlib import Path
from lab02 import load_sessions, summarize
class CsvTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.path = Path(self.temp.name) / "sessions.csv"
def write(self, content, encoding="utf-8"):
self.path.write_text(content, encoding=encoding)
def test_sample_summary(self):
sample = Path(__file__).with_name("sessions.csv")
self.assertEqual(summarize(load_sessions(sample)), {
"session_count": 4,
"total_minutes": 150,
"projects": {"git": 30, "python": 90, "web": 30},
})
def test_header_only(self):
self.write("date,project,minutes\n")
self.assertEqual(summarize(load_sessions(self.path)), {
"session_count": 0, "total_minutes": 0, "projects": {},
})
def test_utf8_bom_and_quoted_comma(self):
self.write('date,project,minutes\n2026-09-28,"docs, tests",20\n', "utf-8-sig")
self.assertEqual(load_sessions(self.path)[0].project, "docs, tests")
def test_wrong_header(self):
self.write("day,project,minutes\n")
with self.assertRaisesRegex(ValueError, "header"):
load_sessions(self.path)
def test_error_includes_line(self):
self.write("date,project,minutes\n2026-09-28,python,-5\n")
with self.assertRaisesRegex(ValueError, "line 2: minutes"):
load_sessions(self.path)
def test_extra_column(self):
self.write("date,project,minutes\n2026-09-28,python,10,extra\n")
with self.assertRaisesRegex(ValueError, "extra columns"):
load_sessions(self.path)
def test_missing_cell(self):
self.write("date,project,minutes\n2026-09-28,python\n")
with self.assertRaisesRegex(ValueError, "required fields"):
load_sessions(self.path)
def test_unclosed_quote(self):
self.write('date,project,minutes\n2026-09-28,"python,10\n')
with self.assertRaisesRegex(ValueError, "line"):
load_sessions(self.path)
if __name__ == "__main__":
unittest.main()
常见错误
header must be: date,project,minutes
表头区分大小写、顺序和空格。使用课程样例的原始表头,分隔符为英文逗号。
UnicodeDecodeError
将文件另存为 UTF-8 CSV。utf-8-sig 不负责将 GBK 或其他编码自动转换成 UTF-8。
line 2: unexpected extra columns
检查是否多了分隔符;项目名本身包含逗号时,需要用双引号包住整个字段。
验收清单
自评记录仅保存在当前浏览器,不代表自动测试结果或 Issue 认领状态。
从实验到贡献
增加按日期汇总函数
这是完成基线后的扩展任务。先查看 Issue 当前状态和留言,与维护者确认认领范围。
- 新增 summarize_by_day,按 YYYY-MM-DD 升序返回每日分钟数。
- 覆盖同一天多条记录、跨天和空列表。
- 保留现有 summarize 的输出格式,补充样例与测试。