命令行程序构建(argparse/click)
创建专业的Python命令行工具
一、概述与命令行程序基础
命令行界面(Command-Line Interface, CLI)是程序与用户交互的最基本方式。一个专业的 Python 命令行工具应当具备:清晰的帮助信息、合理的参数校验、直观的报错提示,以及可选地支持子命令体系。Python 生态中构建 CLI 的主要方式有两种:标准库 argparse 和第三方库 click。
核心选择建议
- argparse(标准库): 无需额外依赖,适合简单到中等复杂度的 CLI,发布时无依赖负担
- click(三方库): 装饰器驱动、自动生成帮助、支持嵌套子命令,适合复杂的多级命令工具
- 两者选其一即可,大项目不建议混用
从一个最简单的 CLI 开始
任何 Python 命令行工具的入口通常是 if __name__ == "__main__": 配合参数解析。下面分别展示两种框架的"Hello World"版本:
# 原生 sys.argv 方式(不推荐,仅示意)
import sys
def main():
args = sys.argv[1:]
if not args:
print("Hello, World!")
else:
print(f"Hello, {args[0]}!")
if __name__ == "__main__":
main()
直接使用 sys.argv 虽简单但功能薄弱:无法处理 -h 自动帮助、无法校验参数类型、不支持可选参数。这就是 argparse 和 click 存在的价值。
二、argparse 基础:三大核心 API
三步用法
- 创建解析器:
ArgumentParser(description="...")
- 定义参数:
add_argument("--name", type=str, help="...")
- 解析参数:
args = parser.parse_args()
完整示例
import argparse
import os
import glob
def main():
parser = argparse.ArgumentParser(
description="批量重命名文件工具",
epilog="示例: python rename.py --prefix backup_ --dry-run ./docs/",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("directory", type=str, help="目标目录")
parser.add_argument("--prefix", type=str, default="new_", help="文件名前缀(默认: new_)")
parser.add_argument("--dry-run", action="store_true", help="仅预览不实际执行")
parser.add_argument("--ext", type=str, default="*", help="匹配扩展名(如 .txt)")
args = parser.parse_args()
if not os.path.isdir(args.directory):
parser.error(f"目录不存在: {args.directory}")
pattern = os.path.join(args.directory, f"*.{args.ext.lstrip('.')}") \
if args.ext != "*" else os.path.join(args.directory, "*")
for fpath in glob.glob(pattern):
dirname, basename = os.path.split(fpath)
new_name = args.prefix + basename
new_path = os.path.join(dirname, new_name)
print(f" {basename} -> {new_name}")
if not args.dry_run:
os.rename(fpath, new_path)
print(f"完成(dry_run={args.dry_run})")
if __name__ == "__main__":
main()
上述代码演示了三种最常用的参数类型:
- 位置参数(positional):
"directory"——不带横线的参数,按顺序解析,必须提供
- 可选参数(optional):
--prefix / --ext——带双横线,可通过 default 设定默认值
- 标志参数(flag):
--dry-run——action="store_true",存在为 True,否则为 False
parse_args() 返回值
parse_args() 返回一个 argparse.Namespace 对象,所有参数以属性方式访问。例如上述代码中的 args.directory、args.prefix。
也可以通过 vars(args) 将其转为普通字典。
三、argparse 参数类型详解
3.1 位置参数
位置参数按照在命令行中出现的顺序匹配。可以指定多个、设置默认值或限制数量。
parser = argparse.ArgumentParser(description="文件差异对比工具")
parser.add_argument("file1", type=str, help="第一个文件")
parser.add_argument("file2", type=str, help="第二个文件")
# 使用: python diff.py a.txt b.txt
3.2 可选参数
可选参数有短格式(-v)和长格式(--verbose)两种写法,一般成对提供。
parser.add_argument(
"-v", "--verbose",
action="store_true",
help="输出详细信息"
)
parser.add_argument(
"-o", "--output",
type=str,
default="result.txt",
help="输出文件路径(默认: result.txt)"
)
3.3 标志参数
通过 action 控制参数的行为,常用的 action 有:
| action 值 | 行为 |
store_true | 参数存在则为 True,否则 False(常用标志) |
store_false | 与 store_true 相反 |
count | 统计参数出现的次数(如 -vvv) |
append | 允许多次使用,结果聚合成列表 |
extend | 类似于 append,但接受多个值并展平(3.8+) |
version | 配合 version= 参数打印版本号后退出 |
# action="count" 示例
parser.add_argument("-v", "--verbosity", action="count", default=0,
help="详细程度,可用 -v -vv -vvv")
args = parser.parse_args()
# python script.py -vvv => args.verbosity == 3
# action="append" 示例
parser.add_argument("--include", action="append", dest="includes",
help="可多次使用: --include src --include tests")
# python script.py --include src --include tests => args.includes = ['src', 'tests']
3.4 子命令
子命令(subparsers)让一个程序实现类似 git commit / git push 的多命令体系。
import argparse
parser = argparse.ArgumentParser(description="文件管理工具")
subparsers = parser.add_subparsers(dest="command", required=True,
help="可用子命令")
# copy 子命令
copy_parser = subparsers.add_parser("copy", help="复制文件")
copy_parser.add_argument("src", type=str, help="源文件")
copy_parser.add_argument("dst", type=str, help="目标路径")
copy_parser.add_argument("--force", action="store_true", help="覆盖已存在文件")
# move 子命令
move_parser = subparsers.add_parser("move", help="移动文件")
move_parser.add_argument("src", type=str, help="源文件")
move_parser.add_argument("dst", type=str, help="目标路径")
args = parser.parse_args()
if args.command == "copy":
print(f"复制 {args.src} -> {args.dst} (force={args.force})")
elif args.command == "move":
print(f"移动 {args.src} -> {args.dst}")
# 使用: python file_mgr.py copy a.txt b.txt --force
# python file_mgr.py move a.txt b.txt
关键注意点
dest="command" 将子命令名称存入 args.command,后续通过 if/elif 分发
required=True(Python 3.7+)强制要求提供子命令,否则报错
- 每个子命令拥有独立的参数空间,互不污染
四、argparse 高级用法
4.1 类型自定义与类型转换
type 参数可以传入任意可调用对象,argparse 会自动用返回值替换原始字符串。
# 自定义文件类型(检查文件是否存在)
def readable_file(path):
if not os.path.isfile(path):
raise argparse.ArgumentTypeError(f"文件不存在: {path}")
if not os.access(path, os.R_OK):
raise argparse.ArgumentTypeError(f"文件不可读: {path}")
return open(path, "r") # 直接返回文件对象
parser.add_argument("input", type=readable_file, help="输入文件")
# 端口号范围校验
def port_type(value):
ivalue = int(value)
if ivalue < 1 or ivalue > 65535:
raise argparse.ArgumentTypeError(f"端口号 1-65535: {value}")
return ivalue
parser.add_argument("--port", type=port_type, default=8080)
4.2 choices 限定可选值
parser.add_argument("--mode", type=str, choices=["dev", "staging", "prod"],
default="dev", help="运行模式")
parser.add_argument("--log-level", type=str.upper, choices=["DEBUG", "INFO", "WARNING", "ERROR"],
default="INFO")
# 如果传入 choices 以外的值,argparse 自动输出错误和提示
4.3 nargs 控制参数数量
| nargs 值 | 行为 |
N(整数) | 需要恰好 N 个参数 |
? | 0 或 1 个参数,配合 default/const 使用 |
* | 0 到任意多个参数,转为列表 |
+ | 至少 1 个参数,转为列表 |
argparse.REMAINDER | 收集剩余所有参数(原样列表) |
# nargs=2 精确需要两个参数
parser.add_argument("--range", type=int, nargs=2,
help="数值范围: --range 1 100")
# nargs='+' 最少一个
parser.add_argument("files", type=str, nargs="+",
help="一个或多个文件")
# nargs='?' 可选位置参数
parser.add_argument("output", type=str, nargs="?", default="output.txt",
help="输出文件(可选,默认 output.txt)")
4.4 互斥组
parser = argparse.ArgumentParser(description="数据处理工具")
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--encode", action="store_true", help="编码模式")
group.add_argument("--decode", action="store_true", help="解码模式")
group.add_argument("--verify", action="store_true", help="校验模式")
# 用户只能选择其一,同时传两个会报错
# python script.py --encode # OK
# python script.py --encode --decode # Error
4.5 参数默认值与来自文件
# 从文件读默认值
import json
DEFAULTS = {}
if os.path.exists("config.json"):
DEFAULTS = json.load(open("config.json"))
parser = argparse.ArgumentParser()
parser.set_defaults(**DEFAULTS) # 批量设置默认值
parser.add_argument("--host", type=str, default="localhost")
parser.add_argument("--port", type=int, default=5432)
# 参数取值优先级: 命令行 > DEFAULTS > default
五、click 库入门
click 是由 Armin Ronacher(Flask 作者)开发的第三方 CLI 框架,核心思路是 装饰器驱动。安装:pip install click(支持 8.x 的最新特性)。
click 三大装饰器
@click.command() —— 将一个函数标记为 CLI 命令
@click.option("--name", ...) —— 定义可选/标志参数
@click.argument("name", ...) —— 定义位置参数
5.1 第一个 click 程序
import click
@click.command()
@click.argument("name")
@click.option("--greeting", default="Hello", help="问候语")
@click.option("--count", default=1, type=int, help="重复次数")
@click.option("--uppercase", is_flag=True, help="转为大写")
def greet(name, greeting, count, uppercase):
"""向指定名称发送问候"""
msg = f"{greeting}, {name}!"
if uppercase:
msg = msg.upper()
for _ in range(count):
click.echo(msg)
if __name__ == "__main__":
greet()
# 用法:
# python greet.py 世界 => Hello, 世界!
# python greet.py 世界 --greeting 你好 --count 3 => 你好, 世界! (x3)
# python greet.py 世界 --uppercase => HELLO, 世界!
click 自动生成格式化的帮助信息;函数参数名自动对应选项名(--greeting 对应 greeting,用下划线替代横线)。
5.2 @click.option 常用参数
| 参数 | 说明 |
default | 默认值 |
type | 类型,支持 click 内置类型如 click.INT、click.FLOAT、click.Choice |
required | 是否必须提供 |
is_flag | 是否为布尔标志 |
multiple | 是否允许多次使用,结果聚合成元组 |
help | 帮助说明文本 |
prompt | 当参数未提供时交互式提示输入 |
hide_input | 隐藏输入内容(用于密码) |
callback | 每次解析时调用的回调函数 |
# 实用示例: 密码输入与选项限定
@click.command()
@click.option("--username", prompt="用户名", required=True,
help="登录用户名(如未提供则交互提示)")
@click.option("--password", prompt=True, hide_input=True,
confirmation_prompt=True, help="密码")
@click.option("--env", type=click.Choice(["dev", "staging", "prod"],
case_sensitive=False),
default="dev", help="部署环境")
def login(username, password, env):
click.echo(f"正在登录 {env} 环境...")
5.3 @click.argument 使用
@click.command()
@click.argument("source", type=click.Path(exists=True, readable=True))
@click.argument("dest", type=click.Path())
@click.option("--overwrite", is_flag=True, help="覆盖目标")
def copy_file(source, dest, overwrite):
"""复制 source 到 dest"""
click.echo(f"复制 {source} -> {dest}")
click.Path 内置路径校验(exists, file_okay, dir_okay, readable, writable 等),无需手动写类型校验函数。
click vs argparse 参数风格差异
click 会自动将参数名中的横线替换为下划线:选项 --dry-run 对应函数参数 dry_run。这是 click 的约定,函数参数必须是有效的 Python 标识符。
六、click 子命令与 Group
click 通过 @click.group() 装饰器实现类似 git 的子命令体系,这是 click 相对于 argparse 的显著优势——代码组织更清晰。
6.1 基础 Group 定义
import click
@click.group()
def cli():
"""文件操作工具集"""
pass
@cli.command()
@click.argument("src", type=click.Path(exists=True))
@click.argument("dst", type=click.Path())
@click.option("--force", is_flag=True, help="强制覆盖")
def copy(src, dst, force):
"""复制文件"""
click.echo(f"复制 {src} -> {dst} (force={force})")
@cli.command()
@click.argument("src", type=click.Path(exists=True))
@click.argument("dst", type=click.Path())
def move(src, dst):
"""移动文件"""
click.echo(f"移动 {src} -> {dst}")
@cli.command()
@click.argument("paths", nargs=-1, type=click.Path(exists=True))
def delete(paths):
"""删除一个或多个文件"""
for path in paths:
click.echo(f"删除 {path}")
if __name__ == "__main__":
cli()
# 使用:
# python file_cli.py copy a.txt b.txt
# python file_cli.py move a.txt b.txt
# python file_cli.py delete f1.txt f2.txt
# python file_cli.py --help
# python file_cli.py copy --help
6.2 嵌套 Group
Group 可以多层嵌套,适合复杂的 CLI 工具(如 docker container run)。
@click.group()
def docker():
"""Docker CLI 模拟"""
pass
@docker.group()
def container():
"""管理容器"""
pass
@container.command()
@click.argument("image")
@click.option("--name", help="容器名称")
@click.option("--port", type=int, multiple=True, help="端口映射 -p")
def run(image, name, port):
"""运行一个新容器"""
click.echo(f"运行 {image} name={name} ports={port}")
@container.command()
@click.argument("container_id")
def stop(container_id):
"""停止一个容器"""
click.echo(f"停止容器 {container_id}")
# 使用: python docker_cli.py container run nginx --name web -p 80 -p 443
# python docker_cli.py container stop abc123
Group 最佳实践
- 每个 .py 文件定义一个 Group,子命令分散在不同模块中
- 通过
@group_name.command() 跨文件注册命令
- 在 Group 函数中可以编写公共逻辑(如初始化日志、加载配置)
- 使用
@click.pass_context 在 Group 和子命令之间共享上下文
七、click 高级特性
7.1 上下文传递(Context)
click 的 Context 对象允许在 Group 和子命令之间共享数据,类似 Flask 的 g 对象。
@click.group()
@click.option("--debug", is_flag=True, help="调试模式")
@click.pass_context
def cli(ctx, debug):
"""工具集 - 带上下文"""
ctx.ensure_object(dict)
ctx.obj["debug"] = debug
ctx.obj["verbose"] = True
@cli.command()
@click.argument("name")
@click.pass_context
def hello(ctx, name):
"""打招呼(继承父命令的 debug 设置)"""
if ctx.obj["debug"]:
click.echo("[DEBUG] 正在处理...")
click.echo(f"Hello, {name}!")
7.2 回调(Callback)
在参数被解析时执行额外的校验或转换。
import sys
def validate_python_version(ctx, param, value):
"""校验 Python 版本并在 --version 时打印额外信息"""
if value:
click.echo(f"Python {sys.version}")
ctx.exit()
return value
@click.command()
@click.option("--version", is_flag=True,
callback=validate_python_version,
expose_value=False)
@click.option("--name", default="World")
def greet(name):
click.echo(f"Hello {name}")
# callback 的签名: callback(ctx, param, value)
# expose_value=False 表示该参数不会传递给函数
7.3 参数校验
click 8.x 引入了 @click.option() 中的 callback 用于校验,也可以使用 click.ParamType 自定义类型。
import click
class PortType(click.ParamType):
name = "port"
def convert(self, value, param, ctx):
try:
ivalue = int(value)
except (TypeError, ValueError):
self.fail(f"'{value}' 不是有效的端口号")
if ivalue < 1 or ivalue > 65535:
self.fail(f"端口号 1-65535: {value}")
return ivalue
@click.command()
@click.option("--port", type=PortType(), default=8080,
help="监听端口号")
def serve(port):
click.echo(f"在端口 {port} 上启动服务")
7.4 自动补全
click 8.x 支持 shell 自动补全,为终端用户提供类似 git <TAB> 的体验。
import click
import os
def complete_env(ctx, param, incomplete):
"""为 SHELL 环境变量提供自动补全"""
return [k for k in os.environ if k.startswith(incomplete.upper())]
@click.command()
@click.argument("env_var", autocompletion=complete_env)
def show_env(env_var):
"""显示环境变量值"""
click.echo(f"{env_var}={os.environ.get(env_var, '未设置')}")
# 安装自动补全(在 shell 中运行):
# _FOO_COMPLETE=bash_source foo > ~/.foo-complete.sh
# echo "source ~/.foo-complete.sh" >> ~/.bashrc
click 8.x 新特性
@click.option("--name", show_default=True)——在帮助中显示默认值
@click.option("--name", show_envvar=True)——显示环境变量回退
click.Path() 新增 resolve_path=True 自动解析为绝对路径
@click.pass_context 已默认可用,无需从 click 单独导入
八、命令行程序的测试
8.1 使用 CliRunner 测试 click 程序
click 内置了 CliRunner 用于模拟命令行调用,无需实际启动子进程,速度飞快。
# app.py - 被测试的命令
import click
@click.group()
def cli():
pass
@cli.command()
@click.argument("a", type=int)
@click.argument("b", type=int)
def add(a, b):
"""计算 a + b"""
click.echo(a + b)
# test_app.py - 测试代码
from click.testing import CliRunner
from app import cli
def test_add():
runner = CliRunner()
result = runner.invoke(cli, ["add", "3", "5"])
assert result.exit_code == 0
assert result.output.strip() == "8"
def test_add_negative():
runner = CliRunner()
result = runner.invoke(cli, ["add", "-3", "10"])
assert result.exit_code == 0
assert result.output.strip() == "7"
def test_help():
runner = CliRunner()
result = runner.invoke(cli, ["add", "--help"])
assert result.exit_code == 0
assert "计算 a + b" in result.output
8.2 CliRunner 隔离文件系统
CliRunner.isolated_filesystem() 提供临时目录上下文,适合测试文件操作类型的命令。
def test_write_file():
runner = CliRunner()
with runner.isolated_filesystem():
# 在当前隔离目录中运行命令
result = runner.invoke(cli, ["write", "test.txt", "--content", "Hello"])
assert result.exit_code == 0
with open("test.txt") as f:
assert f.read() == "Hello"
# 测试完成后临时目录自动清理
8.3 使用 pexpect 测试交互式 CLI
对于包含 click.prompt() 等交互提示的命令,需使用 pexpect 库模拟终端交互。
# 安装: pip install pexpect
import pexpect
def test_interactive_login():
child = pexpect.spawn("python login_cli.py")
child.expect("用户名:")
child.sendline("admin")
child.expect("密码:")
child.sendline("secret123")
child.expect("登录成功")
child.expect(pexpect.EOF)
# pexpect.spawn 支持传入参数列表
child = pexpect.spawn("python my_cli.py", ["--env", "prod"])
8.4 测试 argparse 程序
argparse 本身就是函数调用,构造参数列表并捕获输出即可。
import sys
from io import StringIO
import pytest
def test_rename_dry_run(monkeypatch):
# 模拟命令行参数
test_args = ["program", "./test_dir", "--prefix", "bak_", "--dry-run"]
monkeypatch.setattr(sys, "argv", test_args)
# 捕获 stdout
captured = StringIO()
monkeypatch.setattr(sys, "stdout", captured)
# 运行主函数
main()
output = captured.getvalue()
assert "dry_run=True" in output
九、发布到 pip 作为控制台脚本
完成 CLI 工具的开发后,将其发布到 PyPI 供用户通过 pip install 安装,并在终端中直接通过命令名调用,是专业 CLI 工具的最后一环。
9.1 项目结构
mycli/
├── pyproject.toml # 项目元数据和构建配置(推荐)
├── setup.cfg # 或使用 setup.cfg
├── setup.py # 传统方式
├── src/
│ └── mycli/
│ ├── __init__.py
│ ├── __main__.py # python -m mycli 入口
│ ├── cli.py # CLI 主逻辑
│ └── commands/
│ ├── __init__.py
│ ├── serve.py
│ └── config.py
└── tests/
├── test_cli.py
└── test_commands.py
9.2 pyproject.toml 配置(推荐方式)
# pyproject.toml
[build-system]
requires = ["setuptools>=64", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "mycli-tool"
version = "0.1.0"
description = "一个专业的命令行工具"
readme = "README.md"
requires-python = ">=3.8"
dependencies = [
"click>=8.0",
]
[project.scripts]
mycli = "mycli.cli:main" # 安装后可直接在终端输入 mycli
[project.optional-dependencies]
dev = [
"pytest>=7.0",
"pexpect>=4.8",
]
9.3 入口函数约定
# src/mycli/cli.py
import click
@click.group()
def main():
"""mycli - 专业的命令行工具"""
pass
@main.command()
@click.argument("name")
def greet(name):
"""向某人打招呼"""
click.echo(f"Hello, {name}!")
# src/mycli/__main__.py(支持 python -m mycli 调用)
from .cli import main
main()
# 安装到当前环境进行测试:
# pip install -e .
# 然后终端中直接运行: mycli greet World
9.4 发布到 PyPI
# 确保构建工具是最新的
pip install --upgrade build twine
# 构建分发包
python -m build
# 输出在 dist/ 目录:
# mycli_tool-0.1.0-py3-none-any.whl
# mycli_tool-0.1.0.tar.gz
# 上传到 PyPI(需要先注册账号)
python -m twine upload dist/*
# 用户安装:
pip install mycli-tool # 从 PyPI 安装
mycli --help # 直接可用
python -m mycli # 也支持 module 模式
发布注意事项
- 确保
__init__.py 文件存在,否则 setuptools 无法自动发现包
- 控制台脚本名避免与系统命令冲突(不要叫
test 或 ipython)
- 始终提供
__main__.py 以支持 python -m 调用
- 第一次发布用
--repository testpypi 做预发布验证
- 使用
entry_points(setuptools)而非 scripts=,后者在 Windows 上无法生成 .exe 包装器
十、核心要点总结
- argparse vs click: 标准库 argparse 适合轻量级工具,click 适合复杂子命令体系。选一个用到底。
- argparse 三步法: ArgumentParser → add_argument → parse_args。核心参数:type(类型校验)、action(行为控制)、nargs(参数数量)、choices(可选值限定)。
- 互斥组:
add_mutually_exclusive_group 确保参数互斥。子命令用 add_subparsers(dest="command") 配合 if/elif 分发。
- click 装饰器模式:
@click.command() + @click.option() + @click.argument(),自动生成帮助文档。Group 实现多层子命令。
- click 上下文:
@click.pass_context 配合 ctx.obj 在父子命令间共享数据。
- 自定义类型: argparse 用
type 参数传入可调用对象;click 继承 click.ParamType。
- 测试策略: click 用
CliRunner(速度快、支持隔离文件系统);argparse 用 monkeypatch 模拟 sys.argv;交互式测试用 pexpect。
- 发布流程: pyproject.toml 中配置
[project.scripts],用 build 打包,twine 上传 PyPI,用户 pip 安装后即可在终端直接调用。
十一、进一步思考
命令行工具是 Python 开发者日常接触最多的程序形态之一。写好 CLI 不仅是为他人提供便利,更是自我代码设计能力的体现。
实战项目建议
- 用 argparse 重写一个日常使用的脚本: 添加
-h 帮助、参数校验和 --dry-run 安全模式
- 用 click + Group 实现一个项目脚手架工具: 包含
init、build、serve 三个子命令
- 添加自动补全支持: 为工具提供 shell 自动补全,大幅提升用户体验
- 编写完整的测试套件: 使用 CliRunner 覆盖正常路径、错误路径和边界情况
- 发布到 PyPI: 完成打包、发布、安装验证的完整流程
"CLI 的设计哲学:每个工具只做一件事,并把它做好。参数命名要直观,错误信息要可读,帮助文档要完整。一个好的 CLI 就像一本说明书——用户不需要打开就能猜到它的用法。"
在 Python 生态中,还有更多 CLI 框架值得关注:Typer(基于类型注解的 CLI 框架,比 click 更简洁)、argcomplete(为 argparse 添加自动补全)、rich(为 CLI 输出添加彩色和格式化能力)。掌握 argparse 和 click 后,可以进一步探索这些工具构建更强大的命令行体验。