引言
第九章(综合项目:命令行工具)到此收官。本篇综合测验以第 1 至 8 篇为范围:第 1 篇讲 sys.argv 与 if __name__ == '__main__' 的工程骨架;第 2、3 篇讲 argparse 的位置参数、type 转换、子命令与默认值;第 4、5 篇讲 logging 的级别体系与多 Handler 分流;第 6 篇讲退出码 sys.exit 与 JSON 机器可读输出;第 7 篇讲 unittest 测试组织与断言;第 8 篇用这些知识做出 todo 命令行工具。第九篇的打包发布不进入选择题范围(涉及账号与环境的流程更适合动手做而非纸面考),但综合任务沿用第九篇打包后的包结构,你在完成时会同时复习到它。本篇不引入第十章及以后的任何知识。文中所有运行结果均基于本机 CPython 3.12.10(Windows,Git Bash)实测,每题都附可以独立运行的代码,建议先独立做题再对照解析。
全篇分四部分:十道选择题按「记忆 → 理解 → 应用」的难度梯度排列,逐题解析放在答案折叠区;两道编程题给出可独立运行的参考解答与实测输出;一个综合任务——给第九篇的 todo 工具加 stats 子命令并配套测试,考察你把 argparse、JSON、unittest 串起来的能力;最后是高易错点速查与学习自检清单。
选择题(共 10 题,每题只有一个正确答案)
第 1 题:sys.argv 的内容【记忆】
import sys
print(sys.argv)
运行 python q1.py 张三 25,输出是什么?
A. ['张三', '25']
B. ['q1.py', '张三', '25']
C. ['python', 'q1.py', '张三', '25']
D. 抛 IndexError
第 2 题:if __name__ == '__main__' 的行为【记忆】
print('模块被加载')
if __name__ == '__main__':
print('作为脚本运行')
在另一个模块里 import q2 之后,会输出什么?
A. 两行都会输出 B. 只输出「模块被加载」 C. 什么都不输出 D. 抛 ImportError
第 3 题:缺失必填位置参数时 argparse 的行为【理解】
import argparse
parser = argparse.ArgumentParser(prog='greet')
parser.add_argument('name', help='姓名')
args = parser.parse_args()
print(f'你好,{args.name}')
运行 python q3.py(不提供 name),会发生什么?
A. name 为 None,输出「你好,None」并以退出码 0 结束 B. 向 stderr 打印 usage 与错误信息,并以退出码 2 结束 C. 抛 KeyError D. 程序阻塞等待用户输入
第 4 题:type=int 转换失败的行为【理解】
import argparse
parser = argparse.ArgumentParser(prog='todo')
parser.add_argument('index', type=int)
parser.parse_args()
运行 python q4.py abc,会发生什么?
A. index 取字符串 'abc',程序继续运行
B. 抛 ValueError:invalid literal for int()
C. 向 stderr 报 invalid int value: 'abc',以退出码 2 结束
D. index 为 None
第 5 题:action=‘store_true’ 的默认值【理解】
import argparse
parser = argparse.ArgumentParser()
parser.add_argument('--json', action='store_true')
ns1 = parser.parse_args([])
ns2 = parser.parse_args(['--json'])
print(ns1.json, ns2.json)
输出是什么?
A. True True
B. False True
C. False False
D. True False
第 6 题:logging 的默认级别【记忆】
import logging
logging.warning('磁盘空间不足')
logging.info('初始化完成')
输出是什么?
A. 两行都会输出
B. 只输出 WARNING:root:磁盘空间不足
C. 只输出 INFO:root:初始化完成
D. 什么都不输出
第 7 题:多个 Handler 的级别分流【应用】
import logging
logger = logging.getLogger('app')
logger.setLevel(logging.DEBUG)
fmt = logging.Formatter('%(levelname)s %(message)s')
info_f = logging.FileHandler('info.log', encoding='utf-8')
info_f.setLevel(logging.INFO)
info_f.setFormatter(fmt)
debug_f = logging.FileHandler('debug.log', encoding='utf-8')
debug_f.setLevel(logging.DEBUG)
debug_f.setFormatter(fmt)
logger.addHandler(info_f)
logger.addHandler(debug_f)
logger.debug('读取配置完成')
logger.info('任务成功')
首次运行后,两个文件的内容分别是什么?
A. info.log 与 debug.log 都各有 DEBUG 和 INFO 两行 B. info.log 只有「INFO 任务成功」;debug.log 有「DEBUG 读取配置完成」和「INFO 任务成功」 C. 两个文件都只有「INFO 任务成功」 D. info.log 只有「DEBUG 读取配置完成」;debug.log 只有「INFO 任务成功」
第 8 题:sys.exit 与退出码【应用】
import sys
print('开始运行')
sys.exit(3)
运行 python q8.py; echo $?(分号后用 echo 查看上一条命令的退出码),echo 输出什么?
A. 0
B. 1
C. 3
D. 程序被异常中断,看不到 echo 的输出
第 9 题:unittest 断言【应用】
import unittest
def parse_int(text):
return int(text)
class TestParseInt(unittest.TestCase):
def test_ok(self):
self.assertEqual(parse_int('12'), 12)
def test_wrong_type(self):
self.assertEqual(parse_int('12'), '12')
if __name__ == '__main__':
unittest.main()
运行结果是什么?
A. 两个测试全部通过,输出 OK
B. test_wrong_type 失败,输出 FAILED(failures=1),失败信息是 AssertionError: 12 != '12'
C. 抛 TypeError:int() 参数必须是字符串,程序崩溃
D. test_ok 失败,因为 parse_int 没有被测试入口调用
第 10 题:json.dumps 的中文输出【应用】
import json
data = {'名称': '买菜'}
print(json.dumps(data))
print(json.dumps(data, ensure_ascii=False))
输出是什么?
A. 两行都是 {"名称": "买菜"}
B. 第一行 {"\u540d\u79f0": "\u4e70\u83dc"},第二行 {"名称": "买菜"}
C. 第一行 {"名称": "买菜"},第二行 {"\u540d\u79f0": "\u4e70\u83dc"}
D. 抛 UnicodeEncodeError
点击查看答案
答案:B B B C B B B C B B
第 1 题(B):sys.argv 的第 0 项永远是脚本自身的名字(运行命令里的第一个字符串),真正的参数从第 1 项开始(第 1 篇)。第 2 题(B):直接运行时 __name__ 为 '__main__',被 import 时 __name__ 是模块名 q2,所以只有第一行「模块被加载」执行(第 1 篇)。这是”既能被调用又能独立运行”的骨架原理。第 3 题(B):必填位置参数缺失时,argparse 打印 usage 与错误信息到 stderr,并调用 sys.exit(2)——参数错误约定退出码 2,它替你挡掉了这类错误(第 2、6 篇)。第 4 题(C):type=int 的转换发生在解析阶段,转换失败被 argparse 归为参数错误,实测 stderr 内容为 usage: todo [-h] index 加 todo: error: argument index: invalid int value: 'abc',退出码 2(第 3、6 篇)。第 5 题(B):action='store_true' 的默认值是 False,命令行出现 --json 才置 True(第 3 篇)。第 6 题(B):未做任何配置时 root logger 的级别是 WARNING,INFO 低于门槛被过滤(第 4 篇)。第 7 题(B):logger 级别是”放行门槛”,每个 Handler 又各有一道自己的门槛:DEBUG 记录被 info.log 的 INFO 门槛拦下,却通过 debug.log 的 DEBUG 门槛;INFO 记录两处都通过(第 5 篇)。两级门槛都要理解,才能解释”明明 setLevel 了却还是不输出”。第 8 题(C):sys.exit(3) 抛出 SystemExit(3),进程退出码就是 3;sys.exit() 无参等价退出码 0,sys.exit('消息') 把消息写入 stderr 并以 1 退出——数字是给机器看的,字符串是给人看的(第 6 篇)。第 9 题(B):assertEqual 走 ==,整数 12 与字符串 '12' 不相等,断言失败、测试失败、整个测试运行以 failures=1 结束;注意这是”测试失败”而不是”程序崩溃”,unittest 会把差异打印成 AssertionError: 12 != '12'(第 7 篇)。第 10 题(B):ensure_ascii 默认 True,非 ASCII 字符转成 \uXXXX 转义序列;转义形式在任何终端、任何编码下都字节可靠,适合机器传输;ensure_ascii=False 保留原文更可读(第 6 篇)。
编程题(共 2 题,附可运行参考解答)
编程题 1:带 —json 输出的统计命令,并测它
题目:实现 word_counter.py:接收一个文件路径参数,统计该文件的行数、词数、字符数;提供 --json 选项输出机器可读的 JSON;核心统计逻辑必须放在独立的 count_text(text) 函数里(不依赖命令行),并配套一个 test_word_counter.py 用 unittest 覆盖空文本、英文文本、中文文本三种情况。统计规则:行数用 splitlines(),词数用 split(),字符数用 len()。最后用下面的 note.txt 演示两种输出。
参考解答:
"""统计文本文件的行、词、字符数,支持 --json 机器可读输出。"""
import argparse
import json
import sys
def count_text(text):
"""返回 {'lines': 行数, 'words': 词数, 'chars': 字符数}。"""
return {
'lines': len(text.splitlines()),
'words': len(text.split()),
'chars': len(text),
}
def main(argv=None):
parser = argparse.ArgumentParser(description='统计文本文件的统计信息')
parser.add_argument('file', help='要统计的文件路径')
parser.add_argument('--json', action='store_true', help='以 JSON 格式输出')
args = parser.parse_args(argv)
with open(args.file, encoding='utf-8') as f:
result = count_text(f.read())
if args.json:
print(json.dumps(result, ensure_ascii=False))
else:
print(f"行数 {result['lines']},词数 {result['words']},字符数 {result['chars']}")
return 0
if __name__ == '__main__':
sys.exit(main())
main(argv=None) 的写法让测试可以不经命令行直接调用,这是”逻辑与 I/O 分离”的测试友好设计(第 7 篇)。
import unittest
from word_counter import count_text
class TestCountText(unittest.TestCase):
def test_empty_text(self):
self.assertEqual(count_text(''),
{'lines': 0, 'words': 0, 'chars': 0})
def test_normal_text(self):
text = 'hello python\nwrite blog\n'
result = count_text(text)
self.assertEqual(result['lines'], 2)
self.assertEqual(result['words'], 4)
def test_chinese_chars_count(self):
text = '学习 Python\n中文写作\n'
result = count_text(text)
self.assertEqual(result['lines'], 2)
self.assertEqual(result['words'], 3)
if __name__ == '__main__':
unittest.main(verbosity=2)
演示文件 note.txt 两行内容:学习 Python 与 写博客。本机实测运行:
$ python word_counter.py note.txt
行数 2,词数 3,字符数 14
$ python word_counter.py note.txt --json
{"lines": 2, "words": 3, "chars": 14}
$ python -m unittest test_word_counter -v
test_chinese_chars_count (test_word_counter.TestCountText.test_chinese_chars_count) ... ok
test_empty_text (test_word_counter.TestCountText.test_empty_text) ... ok
test_normal_text (test_word_counter.TestCountText.test_normal_text) ... ok
----------------------------------------------------------------------
Ran 3 tests in 0.001s
OK
关键点:--json 用 store_true(第 3 篇),JSON 输出用 ensure_ascii=False 保留中文(第 10 题);测试不碰命令行,全部落在 count_text 上——断言值与实测输出一致,字符数 14 来自 len() 对全文(含换行符)的直接计算。先写测试、再写实现(或倒过来)都行,关键是”每个被测函数只做一件事”。
编程题 2:双出口日志
题目:写一个 setup_logging() 配置函数:控制台输出 INFO 起步,同时把 DEBUG 起步的完整日志写进 app-debug.log;统一使用 %(levelname)s %(message)s 格式;然后用 logging.debug/info/warning 三条语句验证两边的门槛,并回答:为什么 DEBUG 那条只在文件里?
参考解答:
"""双出口日志:控制台 INFO 起步,文件 DEBUG 起步。"""
import logging
def setup_logging(console_level=logging.INFO, debug_file='app-debug.log'):
fmt = logging.Formatter('%(levelname)s %(message)s')
console = logging.StreamHandler()
console.setLevel(console_level)
console.setFormatter(fmt)
file_handler = logging.FileHandler(debug_file, encoding='utf-8')
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(fmt)
root = logging.getLogger()
root.setLevel(logging.DEBUG) # root 只负责放行,真正的门槛在每个 Handler
root.addHandler(console)
root.addHandler(file_handler)
return root
if __name__ == '__main__':
setup_logging()
logging.debug('只进文件:调试细节')
logging.info('两端可见:任务完成')
logging.warning('两端可见:磁盘空间不足')
本机实测输出:控制台只见 INFO 与 WARNING 两行,app-debug.log 里则三条俱全:
INFO 两端可见:任务完成
WARNING 两端可见:磁盘空间不足
DEBUG 只进文件:调试细节
INFO 两端可见:任务完成
WARNING 两端可见:磁盘空间不足
关键点:logger 上的 setLevel(DEBUG) 只是放行,每个 Handler 才各自把守自己的门槛(第 5 篇)——这解释了”为什么 DEBUG 只在文件里”。另外注意 FileHandler 默认是追加模式 mode='a',脚本跑两次文件行数就翻倍;想要每次全新覆盖,构造时传 mode='w'(第 4、5 篇)。
综合任务:给 todo 工具加 stats 子命令与测试
任务描述:基于第九篇打包好的 todo-tool 包(src/todo_tool/ 下 core.py 管业务、cli.py 管命令行),做三件事:①在 core.py 增加纯函数 stats(tasks),返回 {'total': 总数, 'done': 已完成, 'open': 未完成};②在 cli.py 增加 stats 子命令,文本与 --json 两种输出;③写 tests/test_todo.py,用 unittest 覆盖 add_task 追加、mark_done 置位、越界下标抛 IndexError 且不修改原数据、stats 计数四类行为。要求测试全程不碰真实数据文件(只操作传入的列表),最后跑通全部测试并用命令演示统计。
考察点:argparse 子命令与公共选项注入(第 2、3 篇与第九篇的 parents 手法)、--json 机器输出(第 6 篇)、unittest 断言与 assertRaises(第 7 篇)、纯函数与副作用控制(第 7、8 篇)、在打包好的项目里改代码重跑(第 9 篇)。
参考实现(core.py 完整版,新增最后一段的 stats):
"""核心逻辑:任务的增、查、改与 JSON 持久化。
存储文件默认是用户目录下的 .todo_tool.json,内容为任务字典的列表:
[{"text": "买菜", "done": false}, ...]
"""
import json
from pathlib import Path
DEFAULT_FILE = Path.home() / ".todo_tool.json"
def load_tasks(path=DEFAULT_FILE):
"""读取任务列表;文件不存在时返回空列表。"""
if not path.exists():
return []
with open(path, encoding="utf-8") as f:
return json.load(f)
def save_tasks(tasks, path=DEFAULT_FILE):
"""把任务列表写回文件,中文不转义、保留缩进。"""
with open(path, "w", encoding="utf-8") as f:
json.dump(tasks, f, ensure_ascii=False, indent=2)
def add_task(tasks, text):
"""追加一条未完成任务,返回当前总数。"""
tasks.append({"text": text, "done": False})
return len(tasks)
def mark_done(tasks, index):
"""把第 index 条(从 1 开始)标记为完成;越界抛 IndexError。"""
if not 1 <= index <= len(tasks):
raise IndexError(f"任务编号 {index} 不存在")
tasks[index - 1]["done"] = True
def stats(tasks):
"""统计总数、已完成与未完成数量。"""
total = len(tasks)
done = sum(1 for task in tasks if task["done"])
return {"total": total, "done": done, "open": total - done}
cli.py 的两处改动:解析器里 stats 与 list 一样挂在公共父解析器下(从而天然获得 --json);main() 里新增一个分支,文本输出直接复用 f-string,JSON 输出供机器消费:
sub.add_parser("stats", parents=[common], help="统计总数与完成情况")
elif args.command == "stats":
result = stats(load_tasks())
if args.json:
print(json.dumps(result, ensure_ascii=False))
else:
print(f"共 {result['total']} 条:已完成 {result['done']},未完成 {result['open']}")
测试文件(放在与包并列的 tests/ 目录,运行用 python -m unittest discover -s tests -v):
"""todo_tool 核心逻辑的单元测试。"""
import unittest
from todo_tool.core import add_task, mark_done, stats
class TestTodoTool(unittest.TestCase):
def test_add_task_appends(self):
tasks = []
self.assertEqual(add_task(tasks, '买菜'), 1)
self.assertEqual(tasks, [{'text': '买菜', 'done': False}])
def test_mark_done_flips_flag(self):
tasks = [{'text': '买菜', 'done': False}]
mark_done(tasks, 1)
self.assertTrue(tasks[0]['done'])
def test_mark_done_rejects_out_of_range(self):
tasks = [{'text': '买菜', 'done': False}]
with self.assertRaises(IndexError):
mark_done(tasks, 5)
self.assertFalse(tasks[0]['done'])
def test_stats_counts(self):
tasks = [
{'text': '买菜', 'done': True},
{'text': '写周报', 'done': False},
{'text': '整理房间', 'done': True},
]
self.assertEqual(stats(tasks), {'total': 3, 'done': 2, 'open': 1})
if __name__ == '__main__':
unittest.main(verbosity=2)
注意 test_mark_done_rejects_out_of_range 的写法:先用 assertRaises 捕获异常,再断言原来的列表没被动过——测试”失败路径的副作用为零”,是比只看异常更严格的检验。本机实测运行:
$ venv/Scripts/python.exe -m unittest discover -s tests -v
test_add_task_appends (test_todo.TestTodoTool.test_add_task_appends) ... ok
test_mark_done_flips_flag (test_todo.TestTodoTool.test_mark_done_flips_flag) ... ok
test_mark_done_rejects_out_of_range (test_todo.TestTodoTool.test_mark_done_rejects_out_of_range) ... ok
test_stats_counts (test_todo.TestTodoTool.test_stats_counts) ... ok
----------------------------------------------------------------------
Ran 4 tests in 0.001s
OK
命令实测(沿用真实数据:三条待办,完成一条):
$ venv/Scripts/todo_tool.exe stats
共 3 条:已完成 1,未完成 2
$ venv/Scripts/todo_tool.exe stats --json
{"total": 3, "done": 1, "open": 2}
扩展思考:stats 的文本输出与 JSON 输出分别适合谁消费?如果要求「未完成的任务按添加顺序显示编号」,应该在 core.py 加函数还是直接在 cli.py 循环里实现?为什么?试试把 stats 也改成从文件加载后统计,再想想哪些测试会因此需要临时文件——这正是”纯函数好测”的边界。
高频易错点速查
- argv[0] 是脚本名:
sys.argv[0]是命令本身,第一个真正的参数从下标 1 开始(第 1 题)。 __name__判别:唯一可靠的”直接运行 vs 被导入”判断依据;忘记包这一层,模块被 import 时会偷偷执行副作用代码(第 2 题)。- argparse 的错误就是退出码 2:缺参数、类型错、未知参数都由 argparse 统一处理为 stderr 报错 + 退出码 2,业务错误才由你自己
return 1(第 3、4 题与第 6 篇)。 - 根解析器的选项放子命令之后会报 unrecognized arguments:第 2、3 篇与第九篇实测的坑——需要
--json这样各子命令通用的选项时,用公共父解析器 +parents=[common]注入到每个子命令,而不是挂在根解析器上。 - logging 双门槛:logger 级别放行、Handler 级别把守,DEBUG 没出现先想”是哪个门槛拦的”(第 6、7 题)。
sys.exit三态:数字是退出码(机器看),字符串写 stderr 且退出码 1(人看),无参是 0(第 8 题)。- assertEqual 是
==语义:12与'12'不相等,类型不同就是不等(第 9 题)。 ensure_ascii默认 True:中文会变成\u转义,机器友好;给人看要ensure_ascii=False(第 10 题)。- 命令不传退出码=永远成功:
sys.exit(main())才把返回值带给 Shell,这是第九篇入口点设计的最后一环。
学习自检清单
完成本章学习后,请逐项确认你能独立做到:
- 说出
sys.argv的构成(脚本名 + 参数),解释if __name__ == '__main__'为什么是工程骨架(第 1 篇)。 - 用 argparse 声明位置参数与
--xxx选项,解释缺失参数、非法类型时它打印什么、以什么退出码结束(第 2、3 篇)。 - 会用子命令组织多操作命令;把”各子命令通用选项”用公共父解析器注入,并知道放错位置的后果(第 2、3 篇)。
- 说出 logging 五个级别的高低顺序与默认级别,解释 logger 与 Handler 两级门槛的分工(第 4、5 篇)。
- 配置双出口日志(控制台与文件不同级别),知道
FileHandler的追加模式与encoding参数(第 5 篇)。 - 用
sys.exit控制退出码,区分数字/字符串/无参三种调用的语义(第 6 篇)。 - 让命令输出既可读又可机器消费(
--json+ensure_ascii=False),并说明各自适用场景(第 6 篇)。 - 用 unittest 组织测试类与用例,会用
assertEqual、assertTrue、assertRaises,理解”测试失败 ≠ 程序崩溃”(第 7 篇)。 - 独立完成第八篇的 todo 工具与第九篇的打包流程:pyproject.toml 三区段、
pip install -e .、python -m build、wheel 里entry_points.txt的含义(第 8、9 篇)。 - 独立完成本篇综合任务:新增子命令、补测试、跑通
unittest与命令演示,并能讲清”纯函数为什么好测”。
如果某一项无法打勾,请回到对应篇章重读。第九章的核心是:参数交给 argparse 解析、错误交给退出码表达、过程交给 logging 记录、正确交给 unittest 保证、交付交给打包完成——带着这份清单进入第十章,那里会把 300 篇课程 01 阶段一的全部知识做一次总检验。