typer 0.29 命令行工具:类型注解直接变参数,四个真实退出码
写一个带参数的脚本,argparse 要写一遍 add_argument,click 要写装饰器和类型声明,两处名字容易不一致。typer 把函数的类型注解直接当参数定义:path: Path 就是必填位置参数,top: int = 5 就是 --top 选项,bool 默认值自动生成 --verbose/--quiet。帮助文本、类型校验、退出码都由它生成。如果只是两三个位置参数、不需要 help,sys.argv 判断一下更省事;参数一多、还要嵌子命令,typer 省下的代码量就很明显。
安装与实际版本
python -m venv D:\ai\cache\temp\py-typer
D:\ai\cache\temp\py-typer\Scripts\python.exe -m pip install typer
Name: typer
Version: 0.29.0
Location: D:\ai\cache\temp\py-typer\Lib\site-packages
最小可运行示例
一个扫描目录、按文件大小排序的小工具,带两个子命令和全局 --version:
# filescan.py
from enum import Enum
from pathlib import Path
from typing import Annotated, Optional
import typer
app = typer.Typer(help="目录扫描与统计工具。", no_args_is_help=True, add_completion=False)
class OutputFormat(str, Enum):
text = "text"
json = "json"
def version_callback(value: bool) -> None:
if value:
typer.echo("filescan 1.0.0")
raise typer.Exit()
@app.callback()
def main(
version: Annotated[
Optional[bool],
typer.Option("--version", callback=version_callback, is_eager=True, help="打印版本号后退出。"),
] = None,
) -> None:
"""顶层入口,只处理全局选项。"""
@app.command()
def scan(
path: Annotated[Path, typer.Argument(help="要扫描的目录。", exists=True, file_okay=False)],
top: Annotated[int, typer.Option("--top", "-n", min=1, max=100, help="只列出最大的前 N 个文件。")] = 5,
pattern: Annotated[str, typer.Option(help="glob 模式,默认全部文件。")] = "*",
fmt: Annotated[OutputFormat, typer.Option("--format", "-f", help="输出格式。")] = OutputFormat.text,
verbose: Annotated[bool, typer.Option("--verbose/--quiet", help="是否打印每个文件的路径。")] = False,
) -> None:
"""扫描目录,按大小倒序输出前 N 个文件。"""
files = [p for p in path.rglob(pattern) if p.is_file()]
files.sort(key=lambda p: p.stat().st_size, reverse=True)
total = sum(p.stat().st_size for p in files)
if verbose:
for p in files[:top]:
typer.echo(f" {p.relative_to(path)} {p.stat().st_size} B")
if fmt is OutputFormat.json:
typer.echo(
'{"count": %d, "total_bytes": %d, "top": [%s]}'
% (len(files), total, ", ".join('"%s"' % p.name for p in files[:top]))
)
else:
typer.echo(f"共 {len(files)} 个文件,合计 {total} 字节")
for p in files[:top]:
typer.echo(f"{p.stat().st_size:>8} B {p.name}")
@app.command()
def greet(
name: Annotated[str, typer.Argument(help="称呼。")] = "world",
times: Annotated[int, typer.Option("--times", "-t", help="重复次数。")] = 1,
) -> None:
"""打印问候语。"""
for _ in range(times):
typer.echo(f"hello, {name}")
if __name__ == "__main__":
app()
准备一个 4 个文件的目录跑一下:
python filescan.py scan sample -n 3
python filescan.py scan sample -f json -n 2
python filescan.py greet typer -t 2
共 4 个文件,合计 10980 字节
9000 B access.log
1200 B app.py
480 B README.md
{"count": 4, "total_bytes": 10980, "top": ["access.log", "app.py"]}
hello, typer
hello, typer
--help 是自动生成的,参数和选项分两块,类型和取值范围都标在右边:
python filescan.py scan --help
Usage: filescan.py scan [OPTIONS] {path}
扫描目录,按大小倒序输出前 N 个文件。
┌─ Arguments ─────────────────────────────────────────────────────────────────┐
│ * path <dir> 要扫描的目录。 [required] │
└─────────────────────────────────────────────────────────────────────────────┘
┌─ Options ───────────────────────────────────────────────────────────────────┐
│ --top -n <int range> [1<=x<=100] 只列出最大的前 N │
│ 个文件。 │
│ [default: 5] │
│ --pattern <str> glob │
│ 模式,默认全部文件。 │
│ [default: *] │
│ --format -f <text|json> 输出格式。 │
│ [default: text] │
│ --verbose --quiet <bool> 是否打印每个文件的路径。 │
│ [default: quiet] │
└─────────────────────────────────────────────────────────────────────────────┘
--top 那行会自动写出 [1<=x<=100],因为 typer.Option(min=1, max=100);--verbose/--quiet 两个名字都接受,因为注解是 bool 且写了双斜杠形式。
常用 API 与参数
| 名称 | 作用 | 要点 |
|---|---|---|
typer.Typer() | 应用对象 | no_args_is_help 决定裸跑是否打帮助 |
@app.command() | 子命令 | 函数名即命令名,name= 可覆盖 |
@app.callback() | 顶层回调 | 放全局选项,如 --version |
typer.Argument(...) | 位置参数 | 必填;exists/file_okay/dir_okay 做路径校验 |
typer.Option(...) | 选项 | 默认值写在 = ...,min/max 变成范围校验 |
Annotated[T, ...] | 绑定元信息 | 0.9 之后推荐写法,默认值放等号右边 |
Enum 子类 | 枚举选项 | 自动生成可选值列表与校验 |
typer.echo() | 输出 | 比 print 多一层编码处理 |
typer.Exit(code) | 退出 | 在回调里用 raise,默认退出码 0 |
is_eager=True | 提前处理 | --version、--help 这类要先于校验 |
四个真实退出码
三种参数错误都返回退出码 2,输出格式一致:先打用法,再打一个 Error 面板。
缺必需参数:
Usage: filescan.py scan [OPTIONS] {path}
Try 'filescan.py scan --help' for help.
┌─ Error ─────────────────────────────────────────────────────────────────────┐
│ Missing argument 'path'. │
└─────────────────────────────────────────────────────────────────────────────┘
exit=2
类型不匹配:
┌─ Error ─────────────────────────────────────────────────────────────────────┐
│ Invalid value for '--top' / '-n': Input should be a valid integer, unable │
│ to parse string as an integer │
└─────────────────────────────────────────────────────────────────────────────┘
exit=2
路径校验(exists=True)失败:
┌─ Error ─────────────────────────────────────────────────────────────────────┐
│ Invalid value for 'path': dir './no-such-dir' does not exist. │
└─────────────────────────────────────────────────────────────────────────────┘
exit=2
这三种都在进入函数体之前被拦下,所以函数里不用写"参数是不是合法"的判断。第四种是本机遇到的一个意外:给 --pattern 传 *.py,期望只统计 Python 文件,结果报参数太多。
┌─ Error ─────────────────────────────────────────────────────────────────────┐
│ Got unexpected extra argument(s) (httpx_bench.py httpx_bench2.py │
│ httpx_demo.py httpx_err.py httpx_proxy.py lserver.py pd_demo.py ...) │
└─────────────────────────────────────────────────────────────────────────────┘
原因是 shell 先把 *.py 展开成了当前目录下的文件名列表,typer 收到的是十几个额外参数。Windows PowerShell 在这里也做了展开。要传字面量,用单引号并确认 shell 不展开,或者让 typer 从配置文件读模式串。
一个小细节
--version 用 Optional[bool] 声明时,帮助里显示成 <bool> 而不是常见的无值开关:
│ --version <bool> 打印版本号后退出。 │
功能上没问题(is_eager=True 会在其他校验之前触发回调),但排版上不如 flag 形式干净。用 typer.Option("--version", is_eager=True, callback=...) 配 bool 注解或者 typer.Option(..., hidden=True) 更贴近习惯。
小结
typer 的设计是"注解即接口":Path 带出路径校验,Enum 带出可选值,int 配 min/max 带出范围提示,bool 带出成对开关。上面这个工具不到 60 行,参数解析、help 排版、错误提示全在里面。
参数错误的三种形态都停在函数体之前,退出码统一是 2,这让 CI 里区分"用法错"和"业务失败"变简单:脚本内部 raise typer.Exit(1) 表示业务失败,2 表示调用方式不对。唯一需要额外注意的是 shell 自己会做的事,通配符展开和引号处理不归 typer 管。
数据来源与检索时间
检索时间 2026-11-10。版本、help 排版与退出码为本机实测。
| 来源 | URL | 对应数据 |
|---|---|---|
| PyPI《typer》项目页 | https://pypi.org/project/typer/ | 包名、安装方式与当前版本 0.29.0;基于类型注解的 CLI 构建方式 |
| typer 官方文档《First Steps》 | https://typer.tiangolo.com/tutorial/first-steps/ | CLI argument 与 CLI option 的区别(位置 vs 名字);argument 默认必填、option 默认可选;bool 参数会自动生成 --formal/--no-formal 成对开关;docstring 会成为帮助文本;带空格的值要用引号 |
| 本机运行记录 | D:\ai\cache\temp\work\filescan.py | 0.29.0;scan/greet 两个子命令的实际输出;--help 的 Arguments/Options 分栏;--version;四种参数错误的原文与退出码 |
核验说明
- 虚拟环境
D:\ai\cache\temp\py-typer,Python 3.11.9,pip install typer装到 0.29.0。 - 真跑的示例:
filescan.py的--help、scan --help、--version、裸跑(no_args_is_help)、scan sample -n 3、scan sample -f json -n 2、scan sample --verbose -n 2、greet typer -t 2,以及缺参数、类型错、路径不存在、通配符展开四种错误。 - 示例目录
sample是手工造的 4 个文件(1200 / 480 / 9000 / 300 字节),所以"合计 10980 字节"可以按文件大小核对。 - 没测的部分:
add_completion=True的 shell 补全、typer.testing.CliRunner写的单元测试、Annotated的旧式写法(typer.Option直接当默认值)、子命令分组(app.add_typer)、从环境变量读参数(envvar=)。这些都没有在本机验证。