AI 技术博客
返回首页
Python 基础 31 分钟阅读

第九章综合测验:命令行工具工程化

Python命令行综合测验工程化

引言

第九章(综合项目:命令行工具)到此收官。本篇综合测验以第 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 阶段一的全部知识做一次总检验。