一、什么是 Typer
Typer 是一个基于 Python 类型注解构建 CLI 的框架,底层封装了 Click。它的核心设计理念是:函数签名即命令定义,不需要额外的装饰器参数来声明类型,Python 的类型注解本身就是文档。
安装:
bash
pip install typer
二、最小可运行示例
python
import typer
app = typer.Typer()
@app.command()
def add(task: str):
"""添加一条待办事项"""
typer.echo(f"已添加:{task}")
if __name__ == "__main__":
app()
运行效果:
bash
$ python todo.py add "买牛奶"
已添加:买牛奶
$ python todo.py --help
Usage: todo.py [OPTIONS] COMMAND [ARGS]...
Commands:
add 添加一条待办事项
app = typer.Typer() 创建应用实例。@app.command() 把函数注册为一条命令。函数的 docstring 自动成为 --help 里的说明文字。
三、Argument 与 Option
Typer 通过有没有默认值来区分两种参数:
Argument(位置参数) :没有默认值,用户必须按位置传入,不带 -- 前缀。
Option(选项) :有默认值,用户用 --名称 传入,可以省略。
python
@app.command()
def add(
task: str, # 无默认值 → Argument,必填
priority: int = 2, # 有默认值 → Option,可选
done: bool = False, # 有默认值 → Option,可选
):
"""添加待办事项"""
typer.echo(f"[优先级{priority}] {task} 完成={done}")
bash
$ python todo.py add "买牛奶"
[优先级2] 买牛奶 完成=False
$ python todo.py add "买牛奶" --priority 1 --done
[优先级1] 买牛奶 完成=True
bool 类型的 Option 自动变成开关标志,传 --done 就是 True,不传就是 False。
四、显式声明 Argument 和 Option
不写默认值时 Typer 自动推断,但显式写出来可以加更多配置:
python
@app.command()
def add(
task: str = typer.Argument(..., help="待办事项内容"),
priority: int = typer.Option(2, "--priority", "-p", help="优先级 1-3,默认2"),
tag: str = typer.Option(None, "--tag", "-t", help="标签,如 work/life"),
):
"""添加待办事项"""
msg = f"[P{priority}] {task}"
if tag:
msg += f" #{tag}"
typer.echo(msg)
bash
$ python todo.py add "买牛奶" -p 1 -t life
[P1] 买牛奶 #life
... 表示必填但没有默认值,等价于不写默认值。"-p" 是短别名,让用户可以用 -p 1 代替 --priority 1。None 作为默认值表示"用户没传时为空"。
五、多值参数
接收多个值时使用 List[str]:
python
from typing import List
@app.command()
def done(
ids: List[int] = typer.Argument(..., help="要标记完成的任务ID,可传多个"),
):
"""标记多个任务为完成"""
for i in ids:
typer.echo(f"任务 #{i} 已完成")
bash
$ python todo.py done 1 3 5
任务 #1 已完成
任务 #3 已完成
任务 #5 已完成
六、子命令
一个 CLI 有多条命令时,用 @app.command("名称") 注册不同函数:
python
app = typer.Typer()
@app.command("add")
def add(task: str, priority: int = 2):
"""添加待办事项"""
typer.echo(f"已添加:[P{priority}] {task}")
@app.command("list")
def list_tasks(all: bool = False):
"""列出待办事项"""
typer.echo("显示所有任务..." if all else "显示未完成任务...")
@app.command("delete")
def delete(id: int):
"""删除待办事项"""
typer.echo(f"已删除任务 #{id}")
bash
$ python todo.py add "买牛奶" --priority 1
$ python todo.py list --all
$ python todo.py delete 3
$ python todo.py --help # 列出所有子命令
只有一个 @app.command() 时不需要子命令名,直接 python todo.py 就能运行。有多个时必须指定子命令名。
七、输出与颜色
typer.echo 是推荐的输出方式,比 print 多两个能力:
python
@app.command("add")
def add(task: str):
# 普通输出 → stdout
typer.echo(f"正在添加:{task}")
# 错误输出 → stderr(不会污染管道)
typer.echo("警告:任务内容过长", err=True)
# 带颜色输出
typer.echo(typer.style("✓ 添加成功", fg=typer.colors.GREEN, bold=True))
typer.echo(typer.style("✗ 添加失败", fg=typer.colors.RED))
typer.echo(typer.style("⚠ 注意", fg=typer.colors.YELLOW))
err=True 很重要:当用户用管道 python todo.py list | grep 牛奶 时,错误信息输出到 stderr,不会混进 grep 的输入里。
八、错误处理与退出
不要用 sys.exit(),用 Typer 提供的方式:
python
@app.command("delete")
def delete(id: int):
"""删除待办事项"""
if id <= 0:
typer.echo(
typer.style(f"错误:ID 必须大于 0,收到 {id}", fg=typer.colors.RED),
err=True
)
raise typer.Exit(code=1) # 退出码 1 表示失败,不打印堆栈
# 模拟确认操作
confirmed = typer.confirm(f"确认删除任务 #{id}?")
if not confirmed:
raise typer.Abort() # 打印 "Aborted!" 并退出
typer.echo(f"已删除任务 #{id}")
bash
$ python todo.py delete -1
错误:ID 必须大于 0,收到 -1
$ python todo.py delete 3
确认删除任务 #3? [y/N]: n
Aborted!
Exit(code=1) 用于程序检测到错误主动退出,Abort() 用于用户取消操作。两者都不会打印 Python 堆栈。
九、进度条
处理耗时操作时,用内置进度条给用户反馈:
python
import time
@app.command("sync")
def sync():
"""同步所有待办事项到云端"""
tasks = ["任务A", "任务B", "任务C", "任务D"]
with typer.progressbar(tasks, label="同步中") as progress:
for task in progress:
time.sleep(0.5) # 模拟网络请求
typer.echo(typer.style("✓ 同步完成", fg=typer.colors.GREEN))
bash
$ python todo.py sync
同步中 ████████████████████ 100%
✓ 同步完成
十、全局选项与版本号
用 @app.callback() 给整个 CLI 加全局选项,比如 --version:
python
def version_callback(value: bool):
if value:
typer.echo("todo-cli v1.0.0")
raise typer.Exit()
@app.callback()
def main(
version: bool = typer.Option(
None,
"--version", "-v",
callback=version_callback,
is_eager=True,
help="显示版本号",
)
):
"""待办事项管理工具"""
pass
bash
$ python todo.py --version
todo-cli v1.0.0
$ python todo.py -v
todo-cli v1.0.0
is_eager=True 让 --version 优先于其他所有参数处理,否则如果其他必填参数没传,会先报错再处理 --version,体验很差。
十一、完整示例
把以上所有概念组合成一个完整的 todo.py:
python
#!/usr/bin/env python3
"""待办事项 CLI --- Typer 完整示例"""
import time
from typing import List
import typer
app = typer.Typer(help="待办事项管理工具")
def version_callback(value: bool):
if value:
typer.echo("todo-cli v1.0.0")
raise typer.Exit()
@app.callback()
def main(
version: bool = typer.Option(
None, "--version", "-v",
callback=version_callback,
is_eager=True,
help="显示版本号",
)
):
pass
@app.command("add")
def add(
task: str = typer.Argument(..., help="待办事项内容"),
priority: int = typer.Option(2, "--priority", "-p", help="优先级 1-3"),
tag: str = typer.Option(None, "--tag", "-t", help="标签"),
):
"""添加一条待办事项"""
msg = f"[P{priority}] {task}"
if tag:
msg += f" #{tag}"
typer.echo(typer.style("✓ " + msg, fg=typer.colors.GREEN))
@app.command("done")
def done(
ids: List[int] = typer.Argument(..., help="要标记完成的任务ID"),
):
"""标记任务为完成"""
for i in ids:
typer.echo(f"任务 #{i} 已完成")
@app.command("delete")
def delete(
id: int = typer.Argument(..., help="要删除的任务ID"),
force: bool = typer.Option(False, "--force", "-f", help="跳过确认"),
):
"""删除一条待办事项"""
if id <= 0:
typer.echo(typer.style(f"错误:无效ID {id}", fg=typer.colors.RED), err=True)
raise typer.Exit(code=1)
if not force:
confirmed = typer.confirm(f"确认删除任务 #{id}?")
if not confirmed:
raise typer.Abort()
typer.echo(f"已删除任务 #{id}")
@app.command("sync")
def sync():
"""同步到云端"""
tasks = ["读取本地数据", "连接服务器", "上传变更", "确认同步"]
with typer.progressbar(tasks, label="同步中") as progress:
for _ in progress:
time.sleep(0.3)
typer.echo(typer.style("✓ 同步完成", fg=typer.colors.GREEN))
if __name__ == "__main__":
app()
bash
$ python todo.py --help
$ python todo.py add "买牛奶" -p 1 -t life
$ python todo.py done 1 3 5
$ python todo.py delete 2
$ python todo.py delete 2 --force
$ python todo.py sync
$ python todo.py --version
十二、Typer 与 Click 的关系
Typer 是 Click 的上层封装。两者的核心差异是参数声明方式:Click 需要用装饰器手动声明每个参数的类型,Typer 直接读取函数的类型注解。
python
# Click:类型在装饰器里声明
@click.command()
@click.argument("task")
@click.option("--priority", type=int, default=2, help="优先级")
def add(task, priority):
...
# Typer:类型注解即声明,代码量减少约 40%
@app.command()
def add(task: str, priority: int = 2):
...
Typer 的 app 本质上是一个 Click Group,所以两者可以混用,也可以把 Typer 的 app 嵌入到 Click 的命令树里。