Python Typer 命令行工具开发实战:5 分钟打造专业级 CLI
你有没有想过,为什么有些命令行工具用起来就是特别舒服?自动补全、彩色输出、清晰的帮助信息……而你自己用 argparse 写的脚本,光是解析参数就写了半屏代码,用户体验还一言难尽?
今天介绍的 Typer,是 FastAPI 作者 Sebastián Ramírez(tiangolo)的另一力作。如果你用过 FastAPI 就知道,这哥们的设计哲学就一个字:爽。Typer 把同样的理念带到了 CLI 开发领域——用 Python 类型注解自动生成命令行接口,代码量砍掉 80%,功能反而更强。
为什么不用 argparse?
先看一个对比,假设我们要做一个简单的文件搜索工具:
argparse 版本(传统写法):
import argparseimport os
def main(): parser = argparse.ArgumentParser(description="文件搜索工具") parser.add_argument("pattern", help="搜索模式") parser.add_argument("--directory", "-d", default=".", help="搜索目录") parser.add_argument("--recursive", "-r", action="store_true", help="递归搜索") parser.add_argument("--ignore-case", "-i", action="store_true", help="忽略大小写")
args = parser.parse_args()
for root, dirs, files in os.walk(args.directory): for f in files: name = f.lower() if args.ignore_case else f pat = args.pattern.lower() if args.ignore_case else args.pattern if pat in name: print(os.path.join(root, f)) if not args.recursive: break
if __name__ == "__main__": main()Typer 版本(现代写法):
import osimport typerfrom typing import Annotated
app = typer.Typer(help="文件搜索工具")
@app.command()def search( pattern: str, directory: Annotated[str, typer.Option("--directory", "-d", help="搜索目录")] = ".", recursive: Annotated[bool, typer.Option("--recursive", "-r", help="递归搜索")] = False, ignore_case: Annotated[bool, typer.Option("--ignore-case", "-i", help="忽略大小写")] = False,): """搜索匹配模式的文件""" for root, dirs, files in os.walk(directory): for f in files: name = f.lower() if ignore_case else f pat = pattern.lower() if ignore_case else pattern if pat in name: typer.echo(os.path.join(root, f)) if not recursive: break
if __name__ == "__main__": app()代码量差不多?但 Typer 版本你得到了:
| 特性 | argparse | Typer |
|---|---|---|
| 自动帮助信息 | ✅ 基础 | ✅ 更美观 |
| 类型校验 | ❌ 手动 | ✅ 自动 |
| 自动补全 | ❌ | ✅ bash/zsh/fish |
| 彩色输出 | ❌ 手动 | ✅ 内置 |
| 子命令 | 需要 subparsers | 天然支持 |
| 测试友好 | 一般 | CliRunner |
关键区别在于扩展性。当你的工具从 1 个命令长到 10 个命令时,argparse 的代码会变成灾难,而 Typer 几乎不增加复杂度。
安装与基础
pip install "typer[all]"[all] 会安装 rich(美化输出)和 shellingham(shell 检测),强烈建议加上。
最简单的 Typer 程序
import typer
def main(name: str): """向某人打招呼""" typer.echo(f"你好,{name}!")
if __name__ == "__main__": typer.run(main)运行效果:
$ python hello.py 世界你好,世界!
$ python hello.py --helpUsage: hello.py [OPTIONS] NAME
向某人打招呼
Arguments: NAME [required]
Options: --help Show this message and exit.注意:函数参数自动变成命令行参数,docstring 自动变成帮助说明。这就是 Typer 的核心理念——Python 函数签名即 CLI 接口。
参数类型:Argument vs Option
Typer 区分两种参数:
- Argument(位置参数):
pattern→ 用户必须按顺序提供 - Option(选项参数):
--directory→ 用--前缀的可选参数
import typerfrom typing import Annotated, Optionalfrom pathlib import Path
app = typer.Typer()
@app.command()def convert( # Argument:位置参数,必填 source: Annotated[Path, typer.Argument(help="源文件路径")],
# Option:选项参数,有默认值 output: Annotated[Optional[Path], typer.Option("--output", "-o", help="输出路径")] = None,
# Option:布尔开关 verbose: Annotated[bool, typer.Option("--verbose", "-v", help="详细输出")] = False,
# Option:枚举选择 format: Annotated[str, typer.Option("--format", "-f", help="输出格式")] = "json",): """转换文件格式""" if not source.exists(): typer.echo(f"错误:文件 {source} 不存在", err=True) raise typer.Exit(code=1)
out = output or source.with_suffix(f".{format}")
if verbose: typer.echo(f"源文件: {source}") typer.echo(f"输出到: {out}") typer.echo(f"格式: {format}")
# 转换逻辑... typer.echo(f"✅ 转换完成: {out}")类型自动校验的威力
Typer 利用 Python 类型注解做自动校验,这是它最强的地方:
from enum import Enum
class LogLevel(str, Enum): debug = "debug" info = "info" warning = "warning" error = "error"
@app.command()def run_server( port: Annotated[int, typer.Option(min=1024, max=65535)] = 8000, log_level: Annotated[LogLevel, typer.Option(case_sensitive=False)] = LogLevel.info, workers: Annotated[int, typer.Option(min=1, max=32)] = 4,): """启动服务器""" typer.echo(f"启动服务器 :{port},日志级别={log_level.value},工作进程={workers}")用户输入错误时:
$ python server.py --port 80Error: Invalid value for '--port': 80 is not in the range 1024<=x<=65535
$ python server.py --log-level criticalError: Invalid value for '--log-level': 'critical' is not one of 'debug', 'info', 'warning', 'error'不用写一行校验代码,Typer 全帮你搞定了。这就是”为什么这样写”的答案——把校验逻辑声明在类型里,而不是散落在代码各处。
子命令:构建复杂 CLI
真实世界的 CLI 工具通常有多个子命令,比如 git add、git commit、docker run。Typer 天然支持这种模式:
import typerfrom typing import Annotated, Optionalfrom pathlib import Pathimport jsonimport time
app = typer.Typer(help="📦 项目管理工具")
# ============ 子命令组:项目操作 ============
@app.command()def init( name: Annotated[str, typer.Argument(help="项目名称")], template: Annotated[str, typer.Option("--template", "-t")] = "default",): """初始化新项目""" project_dir = Path(name) project_dir.mkdir(exist_ok=True)
config = { "name": name, "version": "0.1.0", "template": template, "created_at": time.strftime("%Y-%m-%d %H:%M:%S"), }
config_file = project_dir / "project.json" config_file.write_text(json.dumps(config, indent=2, ensure_ascii=False))
typer.echo(f"✅ 项目 {name} 已创建(模板: {template})")
@app.command()def info( path: Annotated[Path, typer.Argument(help="项目路径")] = Path("."),): """查看项目信息""" config_file = path / "project.json" if not config_file.exists(): typer.echo("❌ 当前目录不是有效项目", err=True) raise typer.Exit(1)
config = json.loads(config_file.read_text()) typer.echo(f"📦 项目: {config['name']}") typer.echo(f"📌 版本: {config['version']}") typer.echo(f"📅 创建: {config['created_at']}")
@app.command()def build( path: Annotated[Path, typer.Argument(help="项目路径")] = Path("."), production: Annotated[bool, typer.Option("--prod", help="生产模式")] = False,): """构建项目""" mode = "生产" if production else "开发" typer.echo(f"🔨 构建中({mode}模式)...")
# 模拟构建过程 with typer.progressbar(range(100), label="编译") as progress: for _ in progress: time.sleep(0.02)
typer.echo("✅ 构建完成!")
if __name__ == "__main__": app()运行效果:
$ python pm.py --helpUsage: pm.py [OPTIONS] COMMAND [ARGS]...
📦 项目管理工具
Commands: init 初始化新项目 info 查看项目信息 build 构建项目
$ python pm.py init my-app --template vue✅ 项目 my-app 已创建(模板: vue)
$ python pm.py build --prod🔨 构建中(生产模式)...编译 [####################################] 100%✅ 构建完成!子命令分组:大型 CLI 架构
当命令多到 10+ 个时,需要分组管理。Typer 用 add_typer 实现命名空间:
import typer
# 主应用app = typer.Typer(help="🛠️ DevOps 工具箱")
# 子命令组db_app = typer.Typer(help="数据库管理")deploy_app = typer.Typer(help="部署管理")
app.add_typer(db_app, name="db")app.add_typer(deploy_app, name="deploy")
# db 子命令@db_app.command("migrate")def db_migrate(version: str = "latest"): """执行数据库迁移""" typer.echo(f"📦 迁移到版本: {version}")
@db_app.command("seed")def db_seed(count: int = 100): """填充测试数据""" typer.echo(f"🌱 生成 {count} 条测试数据")
# deploy 子命令@deploy_app.command("staging")def deploy_staging(): """部署到预发环境""" typer.echo("🚀 部署到 staging...")
@deploy_app.command("production")def deploy_production( confirm: Annotated[bool, typer.Option(prompt="确认部署到生产环境?")] = False,): """部署到生产环境""" if confirm: typer.echo("🚀 部署到 production...")使用方式:
$ python devops.py db migrate --version v2.1$ python devops.py deploy production确认部署到生产环境? [y/N]: y🚀 部署到 production...这种架构清晰、可维护,每个模块独立开发,最后 add_typer 组合起来。
实战:构建文件批量处理工具
来做一个真正有用的东西——批量文件处理工具,支持重命名、格式转换、清理等操作:
#!/usr/bin/env python3"""batch-file — 批量文件处理工具"""
import osimport reimport shutilimport hashlibfrom pathlib import Pathfrom typing import Annotated, Optionalfrom datetime import datetime
import typerfrom rich.console import Consolefrom rich.table import Tablefrom rich.progress import track
app = typer.Typer(help="🗂️ 批量文件处理工具", no_args_is_help=True)console = Console()
@app.command()def rename( directory: Annotated[Path, typer.Argument(help="目标目录")], pattern: Annotated[str, typer.Option("--pattern", "-p", help="匹配模式(glob)")] = "*", prefix: Annotated[Optional[str], typer.Option(help="添加前缀")] = None, suffix: Annotated[Optional[str], typer.Option(help="添加后缀(扩展名前)")] = None, replace: Annotated[Optional[str], typer.Option(help="替换规则,格式: old=new")] = None, dry_run: Annotated[bool, typer.Option("--dry-run", "-n", help="预览模式,不实际执行")] = False,): """批量重命名文件""" files = sorted(directory.glob(pattern))
if not files: console.print(f"[yellow]⚠️ 没有找到匹配 '{pattern}' 的文件[/yellow]") raise typer.Exit()
table = Table(title="重命名预览") table.add_column("原文件名", style="red") table.add_column("新文件名", style="green")
renames = [] for f in files: if f.is_dir(): continue
new_name = f.stem ext = f.suffix
if replace: old, new = replace.split("=", 1) new_name = new_name.replace(old, new) if prefix: new_name = prefix + new_name if suffix: new_name = new_name + suffix
new_path = f.parent / (new_name + ext)
if new_path != f: renames.append((f, new_path)) table.add_row(f.name, new_path.name)
if not renames: console.print("[green]✅ 无需重命名[/green]") return
console.print(table)
if dry_run: console.print(f"\n[yellow]预览模式:{len(renames)} 个文件将被重命名[/yellow]") return
if not typer.confirm(f"确认重命名 {len(renames)} 个文件?"): raise typer.Abort()
for old, new in track(renames, description="重命名中..."): old.rename(new)
console.print(f"[green]✅ 完成!{len(renames)} 个文件已重命名[/green]")
@app.command()def dedup( directory: Annotated[Path, typer.Argument(help="目标目录")], recursive: Annotated[bool, typer.Option("-r", help="递归搜索")] = False, dry_run: Annotated[bool, typer.Option("--dry-run", "-n", help="预览模式")] = False,): """查找并删除重复文件(基于 MD5)""" glob_pattern = "**/*" if recursive else "*"
hash_map: dict[str, Path] = {} duplicates: list[tuple[Path, Path]] = []
files = [f for f in directory.glob(glob_pattern) if f.is_file()]
for f in track(files, description="计算哈希..."): file_hash = hashlib.md5(f.read_bytes()).hexdigest() if file_hash in hash_map: duplicates.append((f, hash_map[file_hash])) else: hash_map[file_hash] = f
if not duplicates: console.print("[green]✅ 没有发现重复文件[/green]") return
table = Table(title=f"发现 {len(duplicates)} 个重复文件") table.add_column("重复文件", style="red") table.add_column("原始文件", style="green")
total_size = 0 for dup, orig in duplicates: size = dup.stat().st_size total_size += size table.add_row(str(dup), str(orig))
console.print(table) console.print(f"\n可释放空间: [bold]{total_size / 1024 / 1024:.2f} MB[/bold]")
if dry_run: return
if not typer.confirm("确认删除重复文件?"): raise typer.Abort()
for dup, _ in track(duplicates, description="删除中..."): dup.unlink()
console.print(f"[green]✅ 已删除 {len(duplicates)} 个重复文件[/green]")
@app.command()def stats( directory: Annotated[Path, typer.Argument(help="目标目录")] = Path("."), top: Annotated[int, typer.Option(help="显示 Top N 大文件")] = 10,): """统计目录文件信息""" ext_stats: dict[str, dict] = {} all_files: list[tuple[Path, int]] = []
for f in directory.rglob("*"): if not f.is_file(): continue size = f.stat().st_size ext = f.suffix.lower() or "(无扩展名)" all_files.append((f, size))
if ext not in ext_stats: ext_stats[ext] = {"count": 0, "size": 0} ext_stats[ext]["count"] += 1 ext_stats[ext]["size"] += size
# 按扩展名统计 table = Table(title="📊 文件类型统计") table.add_column("扩展名", style="cyan") table.add_column("数量", justify="right") table.add_column("总大小", justify="right")
for ext, info in sorted(ext_stats.items(), key=lambda x: x[1]["size"], reverse=True): size_mb = info["size"] / 1024 / 1024 table.add_row(ext, str(info["count"]), f"{size_mb:.2f} MB")
console.print(table)
# Top N 大文件 all_files.sort(key=lambda x: x[1], reverse=True)
big_table = Table(title=f"📁 Top {top} 大文件") big_table.add_column("文件", style="yellow") big_table.add_column("大小", justify="right", style="red")
for f, size in all_files[:top]: size_mb = size / 1024 / 1024 big_table.add_row(str(f.relative_to(directory)), f"{size_mb:.2f} MB")
console.print(big_table) console.print(f"\n总计: {len(all_files)} 个文件, {sum(s for _, s in all_files) / 1024 / 1024:.2f} MB")
if __name__ == "__main__": app()这个工具展示了 Typer 的几个高级特性:
- Rich 集成:彩色表格、进度条,用户体验拉满
- 安全确认:
typer.confirm()在危险操作前二次确认 - 预览模式:
--dry-run让用户先看效果再执行 - 错误处理:
typer.Exit(code=1)返回正确的退出码
常见坑和避坑指南
坑 1:布尔选项的 --no- 前缀
# ❌ 这样写,用户必须 --verbose / --no-verbose@app.command()def run(verbose: bool = False): pass
# ✅ 用 Annotated 明确指定 flag 名@app.command()def run( verbose: Annotated[bool, typer.Option("--verbose", "-v")] = False,): passTyper 默认会为 bool 类型生成 --flag 和 --no-flag 两个选项。如果你只想要 --verbose 作为开关,需要显式声明。
坑 2:Optional 类型需要明确默认值
# ❌ 运行时报错@app.command()def search(query: Optional[str]): pass
# ✅ 必须给默认值@app.command()def search(query: Optional[str] = None): pass坑 3:回调函数中的 Context
有时候你需要在所有子命令之前做一些全局操作(比如设置日志级别):
@app.callback()def main( verbose: Annotated[bool, typer.Option("--verbose", "-v")] = False,): """全局选项""" if verbose: import logging logging.basicConfig(level=logging.DEBUG)@app.callback() 装饰的函数会在任何子命令之前执行,用于处理全局选项。
坑 4:测试时别用 subprocess
# ❌ 慢且脆弱import subprocessresult = subprocess.run(["python", "app.py", "hello"], capture_output=True)
# ✅ 用 CliRunner,快且可靠from typer.testing import CliRunner
runner = CliRunner()result = runner.invoke(app, ["hello"])assert result.exit_code == 0assert "你好" in result.output打包发布:让工具可以 pip install
最后一步,把你的 CLI 工具打包成可安装的 Python 包:
[build-system]requires = ["hatchling"]build-backend = "hatchling.build"
[project]name = "batch-file"version = "0.1.0"description = "批量文件处理工具"requires-python = ">=3.10"dependencies = [ "typer[all]>=0.9.0",]
[project.scripts]bf = "batch_file.main:app"这样用户安装后就能直接用命令名调用:
pip install batch-filebf rename ./photos --prefix "2026-"bf dedup ./downloads -rbf stats ./project --top 20Typer vs Click vs argparse:怎么选?
| 维度 | argparse | Click | Typer |
|---|---|---|---|
| 学习成本 | 中等 | 中等 | 低 |
| 代码量 | 多 | 中等 | 少 |
| 类型安全 | ❌ | 部分 | ✅ 原生 |
| 自动补全 | ❌ | 需插件 | ✅ 内置 |
| 测试工具 | 无 | CliRunner | CliRunner |
| 依赖 | 标准库 | 需安装 | 需安装 |
| 适用场景 | 简单脚本 | 成熟项目 | 新项目首选 |
我的建议:
- 一次性脚本(< 50 行):
argparse够用,不用装依赖 - 团队项目或开源工具:直接上 Typer,开发体验和用户体验都是最好的
- 已有 Click 项目:别急着迁移,Typer 底层就是 Click,两者可以混用
总结
Typer 的设计哲学和 FastAPI 一脉相承:用类型注解驱动一切。你写的是普通的 Python 函数,Typer 帮你把它变成一个专业的命令行工具。这不是什么黑魔法,而是 Python 类型系统的正确打开方式。
下次当你想写一个”临时脚本”的时候,花 5 分钟用 Typer 包装一下。你会发现:
- 参数校验不用写了
- 帮助信息自动生成
- 三个月后你还能看懂自己的代码
- 同事也能直接用
这就是好工具的价值——让你专注于业务逻辑,而不是基础设施。
📌 推荐阅读:Typer 官方文档、Rich 库文档
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!