本节目标:用 Click 8.5.0 与 Typer 0.27.3 把一段业务逻辑包成带子命令、类型校验、帮助文本与退出码的命令行工具,并学会用
CliRunner在进程内测它。
适用版本:Python 3.12+(实测 3.14.6);click 8.5.0、typer 0.27.3、rich 15.0.0
14.1 Click / Typer 构建 CLI
第 13 章把「批处理、Office 自动化、定时任务」这些能力装好了,但它们大多还是一堆函数。要让别人(以及 CI)用起来,得给它们一个稳定的外壳:命令行接口。argparse 能起步,但真到工程里,你要的是子命令、类型校验、可组合的帮助、以及能被测试的入口——这正是 Click 与 Typer 的战场。
本节用一个真实的小工具 textkit(递归统计目录下文本文件的行/词/字符数、列高频词)把 Click 与 Typer 两条路线各走一遍,所有命令都真跑过。
14.1.1 先分层:core 是纯逻辑,CLI 只是壳
写 CLI 最常见的坏味道是把业务逻辑塞进命令函数里,导致既不能复用也不能单测。正确结构是三层:core(纯逻辑,只依赖标准库)/ cli(参数与输出)/ __main__(入口)。先看 core:
# textkit/core.py
import re
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
WORD_RE = re.compile(r"[A-Za-z0-9']+")
@dataclass(frozen=True)
class TextStats:
path: Path
lines: int
words: int
chars: int
def count_text(text: str) -> tuple[int, int, int]:
return len(text.splitlines()), len(WORD_RE.findall(text)), len(text)
def count_file(path: Path) -> TextStats:
text = path.read_text(encoding="utf-8", errors="replace") # 坏字节不中断整批
lines, words, chars = count_text(text)
return TextStats(path, lines, words, chars)
def iter_files(root: Path, pattern: str = "*.txt") -> list[Path]:
if root.is_file():
return [root]
return sorted(p for p in root.rglob(pattern) if p.is_file())
def scan(root: Path, pattern: str = "*.txt") -> list[TextStats]:
return [count_file(p) for p in iter_files(root, pattern)]
def top_words(root: Path, n: int = 10, pattern: str = "*.txt") -> list[tuple[str, int]]:
counter: Counter[str] = Counter()
for path in iter_files(root, pattern):
text = path.read_text(encoding="utf-8", errors="replace")
counter.update(w.lower() for w in WORD_RE.findall(text))
return counter.most_common(n)
def totals(stats: list[TextStats]) -> tuple[int, int, int]:
return (sum(s.lines for s in stats), sum(s.words for s in stats), sum(s.chars for s in stats))
这一层里没有任何 click 或 typer 的痕迹——它只处理字符串与路径。CLI 层的职责被压缩成一句话:把命令行参数变成对 core 的调用,再把结果打成文本或 JSON。
14.1.2 Click:装饰器 + 命令组
Click 用装饰器声明「一个函数就是一个命令」,@click.group() 把若干命令挂到一个组下。下面是 textkit 的 Click 版核心:
# textkit/cli.py
import json
import click
from pathlib import Path
from . import __version__
from .core import scan, top_words, totals
@click.group()
@click.version_option(version=__version__, prog_name="textkit")
@click.option("-v", "--verbose", count=True, help="提高日志详细度,可叠加(-vv)。")
@click.pass_context
def cli(ctx: click.Context, verbose: int) -> None:
"""textkit —— 目录文本统计小工具。"""
ctx.ensure_object(dict)
ctx.obj["verbose"] = verbose
if verbose:
click.echo(f"[verbose={verbose}] 已提升日志详细度", err=True)
@cli.command()
@click.argument("paths", nargs=-1, required=True,
type=click.Path(exists=True, path_type=Path))
@click.option("--pattern", default="*.txt", show_default=True)
@click.option("--format", "fmt", type=click.Choice(["text", "json"]), default="text")
@click.pass_obj
def count(obj: dict, paths: tuple[Path, ...], pattern: str, fmt: str) -> None:
"""统计每个文件的行数、词数与字符数。"""
stats = []
for root in paths:
if obj["verbose"]:
click.echo(f"[verbose] 扫描 {root}", err=True)
stats.extend(scan(root, pattern))
if not stats:
click.echo(f"没有匹配 {pattern} 的文件", err=True)
raise SystemExit(1)
click.echo(render_json(stats) if fmt == "json" else render_text(stats))
几个关键点:
click.Path(exists=True, path_type=Path)把「路径是否存在」的校验前移到解析阶段,命令体里不用再写防御代码,错误信息也由框架统一输出。@click.option("-v", "--verbose", count=True)让-vv累加为 2——这是curl -v、ssh -vvv的经典约定。@click.pass_obj/ctx.obj是在命令间共享状态(日志级别、配置、连接)的标准位置:组回调里写一次,子命令里读。
14.1.3 真跑:帮助、统计、词频与退出码
先看自动生成的帮助——注意「组级选项」-v 与两个子命令:
python -m textkit --help
Usage: python -m textkit [OPTIONS] COMMAND [ARGS]...
textkit —— 目录文本统计小工具。
Options:
--version Show the version and exit.
-v, --verbose 提高日志详细度,可叠加(-vv)。
--help Show this message and exit.
Commands:
count 统计每个文件的行数、词数与字符数。
words 列出目录中出现频率最高的词。
统计三个样例文件(sample/ 下有 a.txt、b.txt、sub/c.txt),默认输出文本表格:
文件 行 词 字符
-------------------------------------------------
a.txt 3 11 52
b.txt 2 9 50
c.txt 1 4 25
-------------------------------------------------
合计(3 个) 6 24 127
--format json 走结构化输出,方便被 jq 或别的程序消费(下为节选,实际含全部文件条目):
{
"files": [
{ "path": "../sample/a.txt", "lines": 3, "words": 11, "chars": 52 }
],
"total": { "files": 3, "lines": 6, "words": 24, "chars": 127 }
}
组级选项必须写在子命令前面,-vv count sample 会把 verbose 打到 stderr(结果走 stdout、诊断走 stderr,管道才干净):
$ python -m textkit -vv count sample
[verbose=2] 已提升日志详细度
[verbose] 扫描 sample
文件 行 词 字符
...
words 子命令列高频词,退出码是「看不见的契约」:路径不存在是用法错误 2,没有匹配文件是业务失败 1:
$ python -m textkit count ../nope; echo $?
Error: Invalid value for 'PATHS...': Path '../nope' does not exist.
2
$ python -m textkit count sample --pattern '*.md'; echo $?
没有匹配 *.md 的文件
1
14.1.4 一个工程细节:中文表格的显示宽度
上面的表格能对齐,是因为我加了一个小函数——中文字符在终端占 2 列,而 Python 的 f-string 宽度只按「字符数」算,直接用 f"{'文件':<24}" 会让表头错位:
import unicodedata
def display_width(text: str) -> int:
return sum(2 if unicodedata.east_asian_width(ch) in "WF" else 1 for ch in text)
def pad(text: str, width: int, align: str = "<") -> str:
fill = " " * max(0, width - display_width(text))
return fill + text if align == ">" else text + fill
east_asian_width 返回 W(宽)/F(全角)的字符按 2 列计,其余按 1 列。这类「看起来是排版、其实是正确性」的细节,正是 CLI 工程里最容易漏掉的。
14.1.5 测试 CLI:CliRunner
CLI 最被低估的能力是可以在进程内被测试。click.testing.CliRunner 直接调用命令、捕获输出、隔离环境,无需真的起子进程:
# tests/test_cli.py
import json
import pytest
from pathlib import Path
from click.testing import CliRunner
from textkit.cli import cli
@pytest.fixture
def sample(tmp_path: Path) -> Path:
(tmp_path / "a.txt").write_text("hello world\nhello python\n", encoding="utf-8")
(tmp_path / "sub").mkdir()
(tmp_path / "sub" / "b.txt").write_text("one two three\n", encoding="utf-8")
return tmp_path
def test_count_json(sample: Path) -> None:
result = CliRunner().invoke(cli, ["count", str(sample), "--format", "json"])
assert result.exit_code == 0
assert json.loads(result.output)["total"]["words"] == 7
def test_missing_path() -> None:
result = CliRunner().invoke(cli, ["count", "/definitely/nope"])
assert result.exit_code == 2
assert "does not exist" in result.output
跑 pytest -q(本机 pytest 9.1.1)实测 12 passed。测试组织原则很清楚:CLI 层只测「参数 → 行为」的映射与退出码,真正的统计逻辑仍放在 tests/test_core.py 里对纯函数单测。
14.1.6 Typer:把参数声明换成类型标注
Typer 是 Click 的上层封装——参数类型来自类型标注,选项名从参数名推导(下划线转连字符),帮助文本来自 docstring。同一个工具,Typer 版是这样:
# textkit/cli_typer.py
from enum import Enum
from pathlib import Path
from typing import Annotated
import typer
class OutputFormat(str, Enum):
text = "text"
json = "json"
app = typer.Typer(help="textkit —— 目录文本统计小工具(Typer 版)。",
no_args_is_help=True, add_completion=False)
@app.command()
def count(
paths: Annotated[list[Path], typer.Argument(exists=True, help="要统计的文件或目录。")],
pattern: Annotated[str, typer.Option(help="递归匹配的文件通配符。")] = "*.txt",
fmt: Annotated[OutputFormat, typer.Option("--format")] = OutputFormat.text,
verbose: Annotated[int, typer.Option("-v", "--verbose", count=True)] = 0,
) -> None:
"""统计每个文件的行数、词数与字符数。"""
...
用 Enum 声明 OutputFormat,Typer 自动生成 --format <text|json> 的取值约束;count=True 复刻 -vv;no_args_is_help=True 让不带子命令时直接打印帮助。真跑 Typer 的 --help(带类型提示的富文本版,为排版略去了 --help 行):
Usage: python -m textkit.typer_main count [OPTIONS] {paths}...
统计每个文件的行数、词数与字符数。
╭─ Arguments ──────────────────────────────────────────────────────────────╮
│ * paths <path> 要统计的文件或目录。 [required] │
╰──────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────╮
│ --pattern <str> 递归匹配的文件通配符。 [default: *.txt] │
│ --format <text|json> 输出格式。 [default: text] │
│ --verbose -v <int> 详细度,可叠加。 [default: 0] │
╰──────────────────────────────────────────────────────────────────────────╯
非法取值由框架统一拦截(退出码 2):Invalid value for '--format': 'xml' is not one of 'text', 'json'.。Typer 的价值不是新功能,而是把「参数声明」这件重复劳动交给类型系统,代价是强依赖 click 与 typer 两个包。
14.1.7 Click 还是 Typer
| 维度 | argparse | Click | Typer |
|---|---|---|---|
| 依赖 | 标准库 | click | click + typer |
| 类型校验 | 手写 type= | 内置类型 | 从标注推导 |
| 嵌套子命令 | 繁琐 | 简单 | 最简单 |
| 帮助生成 | 手写 | 装饰器/docstring | docstring |
| 适用规模 | 单命令小工具 | 多子命令工具 | 已用类型标注的代码库 |
选型原则:一次性脚本用 argparse,团队级多子命令工具用 Click,已经全量类型标注的现代代码库用 Typer。三者可以共存——只要守住 14.1.1 的分层,CLI 层始终是薄壳,换框架不伤筋骨。更完整的 CLI 设计契约(三通道、退出码、shell 补全)见延伸阅读。
延伸阅读
- Python 命令行工具:argparse、Click 与 Typer
—— 参数设计规范、shell 补全与
console_scripts打包 - argparse 与命令行工具
—— 从
sys.argv起步的标准库基础
小结
- CLI 必须分层:core 是纯逻辑(无框架依赖),cli 只做参数与输出,
__main__只做入口。 - Click 用装饰器与
@click.group()组织命令,click.Path/Choice把校验前移,ctx.obj承载共享状态,count=True实现-vv。 - 结果走 stdout、诊断走 stderr、退出码语义明确(0 成功 / 1 业务失败 / 2 用法错误),是工具能被组合的前提。
- 中英混排要对齐必须按显示宽度(
unicodedata.east_asian_width)补位,不能直接用 f-string 宽度。 CliRunner让你在进程内测 CLI 的参数映射与退出码;业务逻辑仍对纯函数单测。- Typer 把参数声明交给类型标注,是 Click 的薄封装,代价是多一层依赖。
本节做出了「能被人和 CI 调用的工具」。可工具还躺在源码目录里——下一节我们把它打成一个可以直接双击运行、或拷给别人就能跑的单文件:标准库 zipapp,以及 PyInstaller 的取舍。
阅读导航:上一节:定时任务与系统集成 · 下一节:分发 CLI:zipapp 与打包 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。