Python Typer 命令行工具开发实战:5 分钟打造专业级 CLI

3068 字
15 分钟
Python Typer 命令行工具开发实战:5 分钟打造专业级 CLI

你有没有想过,为什么有些命令行工具用起来就是特别舒服?自动补全、彩色输出、清晰的帮助信息……而你自己用 argparse 写的脚本,光是解析参数就写了半屏代码,用户体验还一言难尽?

今天介绍的 Typer,是 FastAPI 作者 Sebastián Ramírez(tiangolo)的另一力作。如果你用过 FastAPI 就知道,这哥们的设计哲学就一个字:。Typer 把同样的理念带到了 CLI 开发领域——用 Python 类型注解自动生成命令行接口,代码量砍掉 80%,功能反而更强。

为什么不用 argparse?#

先看一个对比,假设我们要做一个简单的文件搜索工具:

argparse 版本(传统写法):

import argparse
import 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 os
import typer
from 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 版本你得到了:

特性argparseTyper
自动帮助信息✅ 基础✅ 更美观
类型校验❌ 手动✅ 自动
自动补全✅ bash/zsh/fish
彩色输出❌ 手动✅ 内置
子命令需要 subparsers天然支持
测试友好一般CliRunner

关键区别在于扩展性。当你的工具从 1 个命令长到 10 个命令时,argparse 的代码会变成灾难,而 Typer 几乎不增加复杂度。

安装与基础#

Terminal window
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)

运行效果:

Terminal window
$ python hello.py 世界
你好,世界!
$ python hello.py --help
Usage: 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 typer
from typing import Annotated, Optional
from 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}")

用户输入错误时:

Terminal window
$ python server.py --port 80
Error: Invalid value for '--port': 80 is not in the range 1024<=x<=65535
$ python server.py --log-level critical
Error: Invalid value for '--log-level': 'critical' is not one of 'debug', 'info', 'warning', 'error'

不用写一行校验代码,Typer 全帮你搞定了。这就是”为什么这样写”的答案——把校验逻辑声明在类型里,而不是散落在代码各处。

子命令:构建复杂 CLI#

真实世界的 CLI 工具通常有多个子命令,比如 git addgit commitdocker run。Typer 天然支持这种模式:

import typer
from typing import Annotated, Optional
from pathlib import Path
import json
import 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()

运行效果:

Terminal window
$ python pm.py --help
Usage: 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...")

使用方式:

Terminal window
$ 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 os
import re
import shutil
import hashlib
from pathlib import Path
from typing import Annotated, Optional
from datetime import datetime
import typer
from rich.console import Console
from rich.table import Table
from 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 的几个高级特性:

  1. Rich 集成:彩色表格、进度条,用户体验拉满
  2. 安全确认typer.confirm() 在危险操作前二次确认
  3. 预览模式--dry-run 让用户先看效果再执行
  4. 错误处理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,
):
pass

Typer 默认会为 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 subprocess
result = 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 == 0
assert "你好" in result.output

打包发布:让工具可以 pip install#

最后一步,把你的 CLI 工具打包成可安装的 Python 包:

pyproject.toml
[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"

这样用户安装后就能直接用命令名调用:

Terminal window
pip install batch-file
bf rename ./photos --prefix "2026-"
bf dedup ./downloads -r
bf stats ./project --top 20

Typer vs Click vs argparse:怎么选?#

维度argparseClickTyper
学习成本中等中等
代码量中等
类型安全部分✅ 原生
自动补全需插件✅ 内置
测试工具CliRunnerCliRunner
依赖标准库需安装需安装
适用场景简单脚本成熟项目新项目首选

我的建议:

  • 一次性脚本(< 50 行):argparse 够用,不用装依赖
  • 团队项目或开源工具:直接上 Typer,开发体验和用户体验都是最好的
  • 已有 Click 项目:别急着迁移,Typer 底层就是 Click,两者可以混用

总结#

Typer 的设计哲学和 FastAPI 一脉相承:用类型注解驱动一切。你写的是普通的 Python 函数,Typer 帮你把它变成一个专业的命令行工具。这不是什么黑魔法,而是 Python 类型系统的正确打开方式。

下次当你想写一个”临时脚本”的时候,花 5 分钟用 Typer 包装一下。你会发现:

  1. 参数校验不用写了
  2. 帮助信息自动生成
  3. 三个月后你还能看懂自己的代码
  4. 同事也能直接用

这就是好工具的价值——让你专注于业务逻辑,而不是基础设施

📌 推荐阅读Typer 官方文档Rich 库文档

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

Python Typer 命令行工具开发实战:5 分钟打造专业级 CLI
https://boke.hackerdream.xyz/posts/python-typer-cli-practical-guide/
作者
晴天
发布于
2026-05-09
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
晴天
Hello, I'm 晴天.
公告
欢迎来到我的博客!这是一则示例公告。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
125
分类
17
标签
287
总字数
257,955
运行时长
0
最后活动
0 天前

目录