KernelRanch
返回文章列表

typer 0.29 命令行工具:类型注解直接变参数,四个真实退出码

2026-11-10 #Python

写一个带参数的脚本,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.py0.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=)。这些都没有在本机验证。