《Python编程实战》14.1 Click / Typer 构建 CLI

把脚本能力包成真正的命令行工具:用 Click 8.5.0 的装饰器、命令组与共享上下文声明参数与子命令,再用 Typer 0.27.3 的类型标注重写同一个工具,真跑帮助、统计、词频与退出码,并用 CliRunner 把 CLI 层测透,完成工程化的分层与测试。

本节目标:用 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

维度argparseClickTyper
依赖标准库clickclick + typer
类型校验手写 type=内置类型从标注推导
嵌套子命令繁琐简单最简单
帮助生成手写装饰器/docstringdocstring
适用规模单命令小工具多子命令工具已用类型标注的代码库

选型原则:一次性脚本用 argparse,团队级多子命令工具用 Click,已经全量类型标注的现代代码库用 Typer。三者可以共存——只要守住 14.1.1 的分层,CLI 层始终是薄壳,换框架不伤筋骨。更完整的 CLI 设计契约(三通道、退出码、shell 补全)见延伸阅读。

延伸阅读

小结

  • 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 与打包 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时