第06章 换Python重做一遍click与rich

作者:亢AIRTC | 源码地址:https://github.com/kang-airtc/cli-mini-book

前五章用 Node.js 搭出了一个布局完整、可发布到 npmjs 的 Agent CLI。读者可能要问:同样的能力换成 Python 实现会有什么不同。答案是核心思路完全一致,但每一步的工具与术语会切换到 Python 生态:commander 换成 click,npm 换成 pip,bin 字段换成 entry_points,npm registry 换成 PyPI(Python Package Index,Python 软件包索引)。下文基于 agent-cli-demo 仓库里 my-agent-python-cli 子项目,把这套替换过程走一遍。

6.1 Python生态的CLI约定

6.1.1 entry_points与命令注册

Python 项目里没有“全局 bin 目录 + 软链接”这种 npm 风格的机制。声明命令名靠的是 setup.py 或 pyproject.toml 里的 entry_points 配置,由 pip 在安装包时把命令名注册到 Python 环境的 Scripts 目录(Windows)或 bin 目录(macOS/Linux)。下面这份 setup.py 来自 my-agent-python-cli,重点观察 entry_points 字段。

from setuptools import setup, find_packages

setup(
    name="my-agent-python-cli",
    version="1.0.0",
    packages=find_packages(),
    install_requires=[
        "click>=8.0.0",
        "rich>=10.0.0",
    ],
    entry_points={
        "console_scripts": [
            "my-agent-py=src.cli:cli",
        ],
    },
    python_requires=">=3.7",
)

entry_points 是一个嵌套字典,外层的 console_scripts 表明这是“控制台脚本”分组。内层的字符串 my-agent-py=src.cli:cli 由三段构成:等号左边是用户安装后敲的命令名,等号右边的模块路径加冒号加函数名指向命令入口。pip 安装该包时会读取这一行,自动生成一个可执行包装脚本,命名为 my-agent-py,调用 src.cli 模块的 cli 函数。

6.1.2 与Node bin字段的概念对照

请添加图片描述

如图 6-1 所示,两种生态完成的是同一件事情:把“命令名”绑定到“入口函数或入口文件”,然后由包管理器把这个绑定落到全局可执行目录。两种语言的概念对照如表 6-1 所示。

表 6-1 Node和Python在CLI命令注册上的概念对照

概念Node.jsPython
命令名声明package.json 的 bin 字段setup.py 的 entry_points.console_scripts
入口指向文件路径(dist/cli.js)模块函数(src.cli:cli)
全局安装npm install -g .pip install -e . 或 pip install .
全局可执行目录$(npm config get prefix)/bin$(python -m site --user-base)/bin
发布注册表npmjs.compypi.org

如表 6-1 所示,最大的差异在于入口指向:Node 指向编译产物文件,Python 指向模块里的函数。这意味着 Python 项目不需要构建步骤(除非用 PyInstaller 等打包工具),源码即产物,pip 安装时直接拷贝 .py 文件到 site-packages。

6.2 用click写第一个子命令

6.2.1 click的装饰器风格

click 是 Python 生态最常用的 CLI 框架之一,与 Node 的 commander 不同的是,click 采用装饰器(decorator)风格。下面是 my-agent-python-cli 里 hello 子命令的最小骨架。

import click

@click.group()
@click.version_option(version="1.0.0")
def cli():
    """My Agent Python CLI"""
    pass

@cli.command()
@click.option("--name", default="World", help="指定名字")
def hello(name):
    """Say hello"""
    click.echo(f"Hello, {name}!")

if __name__ == "__main__":
    cli()

代码结构由三块构成:@click.group() 装饰的 cli 函数充当根命令、@cli.command() 装饰的子命令注册到根命令、装饰器自动把函数 docstring 转为帮助文本。函数参数 name 对应 --name 选项的值,由 click 在调用时自动注入。

6.2.2 选项、参数、回调

click 区分两种输入:@click.option 用于带 -- 前缀的选项、@click.argument 用于位置参数。下面这段示例同时使用两者,对应 commander 的 option/argument 对照。

@cli.command()
@click.argument("message", required=False)
@click.option("--stdin", is_flag=True, help="从标准输入读取上下文")
def chat(message, stdin):
    """开始对话"""
    if stdin:
        import sys
        context = sys.stdin.read()
    click.echo(f"You said: {message or '(empty)'}")

is_flag=True 表示 --stdin 是布尔开关,不带参数。required=False 让 message 成为可选位置参数,未传时为 None。click 在解析失败时会自动以退出码 2 退出,并打印用户友好的错误,比 commander 的默认行为更贴近 Agent 调用约定。

6.2.3 嵌套子命令组

config 这类需要二级子命令的功能,click 通过 group 嵌套实现。

@cli.group()
def config():
    """配置管理"""
    pass

@config.command("get")
@click.argument("key")
def config_get(key):
    """读取配置项"""
    click.echo(f"{key}=...")

@config.command("set")
@click.argument("key")
@click.argument("value")
def config_set(key, value):
    """写入配置项"""
    click.echo(f"set {key}={value}")

用户敲 my-agent-py config get xxx 时,click 根据 @cli.group() + @config.command("get") 这两层装饰器定位到 config_get 函数。这一机制与 commander 的链式 .command().command() 等价,写法上更模块化。

6.3 rich带来的输出表达力

6.3.1 rich库的核心组件

rich 是 Python 生态的终端美化库,提供 Console、Table、Progress、Markdown、Syntax 等组件。my-agent-python-cli 用 rich 渲染 status 与 model --list 的输出。下面这段示例展示 rich.table 的用法。

from rich.console import Console
from rich.table import Table

console = Console()

@cli.command()
def models():
    """列出可用模型"""
    table = Table(show_header=True, header_style="bold")
    table.add_column("模型", style="cyan")
    table.add_column("厂商")
    table.add_column("说明")
    table.add_row("claude-opus-4-7", "Anthropic", "默认")
    table.add_row("gpt-4o", "OpenAI", "")
    table.add_row("gemini-pro", "Google", "")
    console.print(table)

console.print(table) 会自动生成带边框、对齐、颜色的表格输出。rich 处理 TTY 检测的方式与上一章 Node 部分讨论的一致:输出被重定向时自动退化为无色纯文本。

6.3.2 双输出模式的Python实现

click 自带的 click.echo 已经处理了 TTY 检测,但要实现 text/json 双输出模式,仍然需要类似上一章的分发器。

import json

@cli.command()
@click.option("--output", type=click.Choice(["text", "json"]), default="text")
def status(output):
    """查看 CLI 状态"""
    data = {
        "version": "1.0.0",
        "auth": "logged in",
        "model": "claude-opus-4-7",
    }
    if output == "json":
        click.echo(json.dumps(data), nl=True)
    else:
        for k, v in data.items():
            console.print(f"[bold]{k}[/bold]: {v}")

click.Choice(["text", "json"]) 让 click 自动校验输入值,传非法值时直接以退出码 2 失败。这避免了写很多手工 if 判断。

注意:rich 默认会把样式标记符号写入输出,但当输出不是 TTY 时(被管道或重定向接收)自动剥离。如果读者用 print 而非 console.print 输出,则失去这一保护,可能让 [bold]xxx[/bold] 字面字符串混入下游 JSON 解析。

6.4 虚拟环境与Python版本兼容

6.4.1 虚拟环境的隔离作用

Python 的全局包安装容易污染系统环境,主流做法是为每个项目建一个虚拟环境(virtual environment,venv)。开发期与发布期都建议在 venv 里工作。

# 创建虚拟环境
python3 -m venv venv

# 激活(macOS/Linux)
source venv/bin/activate

# 激活(Windows)
venv\Scripts\activate

# 在 venv 内开发模式安装
pip install -e .

# 验证命令是否进入 venv 的 bin 目录
which my-agent-py

pip install -e . 的 -e 是 editable 模式,类似 npm install -g . 但更轻量:pip 只把当前目录注册为可导入路径,源码改动立即生效,不需要重装。

6.4.2 Python版本兼容

setup.py 里的 python_requires=">=3.7" 是声明性约束,pip 在安装时会拒绝低于该版本的环境。读者发布 CLI 时应该谨慎选择最低支持版本:太低会限制可用的语言特性(例如 3.7 没有 walrus 运算符、3.10 才有 match 语句),太高会拒绝许多企业环境里的存量 Python。

笔者在内部推广 Python 写的 Agent CLI 时观察到,Python 3.7 与 3.8 仍是企业里最常见的部署版本,因此选用 3.7 作为最低版本能覆盖最广的安装基数。如果使用了 from __future__ import annotations 等向后兼容写法,3.7 通常足够。

注意:macOS 系统自带的 /usr/bin/python3 在不同系统版本里 Python 版本不同,且 Apple 时常在小版本里调整。建议读者在 install.sh 中显式探测 python3 与 python 两个命令、并校验 python3 --version 是否满足 python_requires 声明。后续章节讲 install.sh 时会展开这一逻辑。

6.5 本章小结

至此读者已经看到同一份 Agent CLI 在 Node 与 Python 两种生态里的实现路径。装饰器 vs 链式 API、entry_points vs bin、pip vs npm、PyPI vs npmjs,这些差异都是表面工具差异,核心约定一致:命令名注册到全局可执行目录、入口函数提供子命令、输出格式区分人类与机器、退出码语义稳定。

下一章把 Python 实现的产物打包成 wheel 文件,这是 Python 生态里把“源码”变成“可分发产物”的关键一步。读者将看到 wheel 的命名规则、构建流程,以及 wheel 相对于源码分发的优势。

更多推荐