第06章 换Python重做一遍click与rich
第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.js | Python |
|---|---|---|
| 命令名声明 | 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.com | pypi.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 相对于源码分发的优势。
更多推荐
所有评论(0)