实验 03 / 04 · 55 分钟
命令行工具与文件输出
让数据管线成为别人拿到就能用的命令行工具。
学习目标
- 使用 argparse 接收文件和输出参数。
- 区分 stdout、stderr 与进程退出码。
- 通过子进程测试真实命令行行为。
前置知识
- 完成实验 02,能解释 load_sessions 和 summarize。
- 知道相对路径取决于终端当前目录。
环境配置与运行
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 lab03.py sessions.csv.\.venv\Scripts\python.exe -X utf8 -m unittest test_lab03 -v{
"session_count": 4,
"total_minutes": 150,
"projects": {
"git": 30,
"python": 90,
"web": 30
}
}实验步骤
- 01
运行命令行入口
运行 lab03.py sessions.csv,然后加上 --help 查看参数。main 返回整数,由 sys.exit 转成进程退出码。
- 02
导出真实报告
在运行命令末尾追加 --output report.json。打开生成的文件,与 expected.json 对照;成功写文件时终端没有 JSON 输出是正常的。
- 03
演练失败情况
将输入改为 missing.csv。观察终端的 error:,再在 PowerShell 查看 $LASTEXITCODE,或在 Bash 查看 $?,确认值为 2。
把 CLI 当成一个接口
正常 JSON 写入 stdout,错误写入 stderr,成功退出码为 0,失败为 2。其他程序就能判断这次执行是否成功,而不是从文字猜测。--help 由 argparse 提供。
先验证,再写文件
代码会先读完并验证输入,再写报告。无效数据不能覆盖已有报告;--output 不允许与输入使用同一路径。成功输出会覆盖现有报告,本实验不处理硬链接别名和并发写入。
完整代码
以下为课程包中的实际源码。后续实验复用前序模块,首次运行请下载完整课程包。
"""Lab 03: expose the pipeline as a command-line tool."""
import argparse
import json
import sys
from pathlib import Path
from lab02 import load_sessions, summarize
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Summarize a study-session CSV.")
parser.add_argument("input", type=Path, help="UTF-8 CSV: date,project,minutes")
parser.add_argument("--output", type=Path, help="Write JSON to a file")
args = parser.parse_args(argv)
try:
if args.output and args.output.resolve() == args.input.resolve():
raise ValueError("output must not overwrite the input")
report = summarize(load_sessions(args.input))
content = json.dumps(report, ensure_ascii=False, indent=2) + "\n"
if args.output:
args.output.write_text(content, encoding="utf-8")
else:
print(content, end="")
except (OSError, ValueError) as error:
print(f"error: {error}", file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
sys.exit(main())
测试用例
执行上面的测试命令,应看到 6 个测试通过和 OK。先理解断言,再尝试引入错误验证测试有效。
test_lab03.py
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parent
def run_cli(*args):
return subprocess.run(
[sys.executable, "-X", "utf8", str(ROOT / "lab03.py"), *map(str, args)],
capture_output=True, text=True, encoding="utf-8",
)
class CliTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.directory = Path(self.temp.name)
def test_stdout_json(self):
result = run_cli(ROOT / "sessions.csv")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(json.loads(result.stdout)["total_minutes"], 150)
self.assertEqual(result.stderr, "")
def test_output_file(self):
output = self.directory / "report.json"
result = run_cli(ROOT / "sessions.csv", "--output", output)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(json.loads(output.read_text(encoding="utf-8"))["session_count"], 4)
self.assertEqual(result.stdout, "")
def test_missing_input(self):
result = run_cli(self.directory / "missing.csv")
self.assertEqual(result.returncode, 2)
self.assertIn("error:", result.stderr)
self.assertNotIn("Traceback", result.stderr)
def test_invalid_input_preserves_existing_report(self):
source = self.directory / "invalid.csv"
source.write_text("date,project,minutes\n2026-09-28,python,-1\n", encoding="utf-8")
output = self.directory / "report.json"
output.write_text("keep me", encoding="utf-8")
result = run_cli(source, "--output", output)
self.assertEqual(result.returncode, 2)
self.assertEqual(output.read_text(encoding="utf-8"), "keep me")
def test_cannot_overwrite_source(self):
source = self.directory / "input.csv"
content = (ROOT / "sessions.csv").read_text(encoding="utf-8")
source.write_text(content, encoding="utf-8")
result = run_cli(source, "--output", source)
self.assertEqual(result.returncode, 2)
self.assertEqual(source.read_text(encoding="utf-8"), content)
def test_help(self):
result = run_cli("--help")
self.assertEqual(result.returncode, 0)
self.assertIn("--output", result.stdout)
if __name__ == "__main__":
unittest.main()
常见错误
error: No such file or directory
检查终端当前目录。路径有空格时用引号包围;输出报告的父目录也必须已经存在。
output must not overwrite the input
CSV 和 JSON 应使用不同文件名,不要把 sessions.csv 传给 --output。
Windows 子进程的中文错误无法解码
测试通过 -X utf8 启动子进程,并按 UTF-8 读取输出。仅指定父进程的解码格式并不能改变子进程编码。
验收清单
自评记录仅保存在当前浏览器,不代表自动测试结果或 Issue 认领状态。
从实验到贡献
为命令行增加项目筛选参数
这是完成基线后的扩展任务。先查看 Issue 当前状态和留言,与维护者确认认领范围。
- 新增可选 --project 参数,在汇总前按项目名精确筛选。
- 没有匹配记录时返回成功的零值报告。
- 覆盖匹配、不匹配和不传参数的 CLI 测试,并更新 --help。