命令行工具:argparse、click、参数解析与子命令
命令行工具:argparse、click、装饰器与子命令
命令行工具是开发者日常使用的必备工具。Python提供了强大的库来创建专业的CLI应用。本文将介绍argparse和click两种主流方案。
argparse基础
argparse是Python标准库的命令行解析模块:
import argparse
# 创建解析器
parser = argparse.ArgumentParser(
description="一个简单的文件处理工具",
epilog="示例: python tool.py input.txt -o output.txt"
)
# 添加参数
parser.add_argument('input', help='输入文件路径')
parser.add_argument('-o', '--output', default='output.txt', help='输出文件路径')
parser.add_argument('-v', '--verbose', action='store_true', help='显示详细信息')
parser.add_argument('-n', '--number', type=int, default=1, help='处理次数')
parser.add_argument('--format', choices=['json', 'csv', 'xml'], default='json', help='输出格式')
# 解析参数
args = parser.parse_args()
print(f"输入文件: {args.input}")
print(f"输出文件: {args.output}")
print(f"详细模式: {args.verbose}")
print(f"处理次数: {args.number}")
print(f"输出格式: {args.format}")
argparse高级用法
import argparse
parser = argparse.ArgumentParser(description="高级参数示例")
# 必需参数
parser.add_argument('filename', help='要处理的文件')
# 可选参数
parser.add_argument('-o', '--output', help='输出文件')
# 参数组
group = parser.add_argument_group('处理选项')
group.add_argument('--encoding', default='utf-8', help='文件编码')
group.add_argument('--chunk-size', type=int, default=1024, help='块大小')
# 互斥组
mutex_group = parser.add_mutually_exclusive_group()
mutex_group.add_argument('--compress', action='store_true', help='压缩输出')
mutex_group.add_argument('--encrypt', action='store_true', help='加密输出')
# 参数验证
def validate_positive(value):
ivalue = int(value)
if ivalue <= 0:
raise argparse.ArgumentTypeError(f"{value} 必须是正整数")
return ivalue
parser.add_argument('-c', '--count', type=validate_positive, help='处理数量')
# 带默认值和类型
parser.add_argument('--scale', type=float, default=1.0, help='缩放比例')
# 参数动作
parser.add_argument('--debug', action='store_true', help='启用调试')
parser.add_argument('--log-level', choices=['DEBUG', 'INFO', 'WARNING', 'ERROR'],
default='INFO', help='日志级别')
args = parser.parse_args()
# 使用参数
if args.debug:
print("调试模式已启用")
print(f"处理文件: {args.filename}")
click基础
click是一个更现代的CLI库,使用装饰器语法:
import click
@click.command()
@click.argument('filename')
@click.option('-o', '--output', default='output.txt', help='输出文件')
@click.option('-v', '--verbose', is_flag=True, help='显示详细信息')
@click.option('-n', '--number', type=int, default=1, help='处理次数')
def process(filename, output, verbose, number):
"""处理文件的CLI工具"""
if verbose:
click.echo(f"处理文件: {filename}")
click.echo(f"输出文件: {output}")
for i in range(number):
click.echo(f"处理第 {i+1} 次...")
click.echo(f"处理完成,结果保存到 {output}")
if __name__ == '__main__':
process()
click高级特性
import click
from pathlib import Path
# 参数类型验证
@click.command()
@click.argument('source', type=click.Path(exists=True, file_okay=False))
@click.argument('dest', type=click.Path(writable=True))
@click.option('--pattern', default='*.txt', help='文件匹配模式')
def copy(source, dest, pattern):
"""复制目录中的文件"""
source_path = Path(source)
dest_path = Path(dest)
dest_path.mkdir(parents=True, exist_ok=True)
files = list(source_path.glob(pattern))
click.echo(f"找到 {len(files)} 个文件")
for file in files:
dest_file = dest_path / file.name
dest_file.write_text(file.read_text())
click.echo(f"复制: {file.name}")
# 密码输入
@click.command()
@click.option('--username', prompt=True, help='用户名')
@click.option('--password', prompt=True, hide_input=True, confirmation_prompt=True, help='密码')
def login(username, password):
"""登录系统"""
click.echo(f"用户 {username} 登录中...")
# 验证逻辑
click.echo("登录成功!")
# 进度条
@click.command()
@click.option('--items', type=int, default=100, help='处理项目数')
def process_items(items):
"""处理项目并显示进度"""
with click.progressbar(range(items), label='处理中') as bar:
for i in bar:
# 模拟处理
pass
click.echo("处理完成!")
# 确认提示
@click.command()
@click.option('--force', is_flag=True, help='跳过确认')
def delete(force):
"""删除文件"""
if not force:
click.confirm('确定要删除吗?', abort=True)
click.echo("文件已删除")
# 颜色输出
@click.command()
def colored_output():
"""彩色输出示例"""
click.secho('成功!', fg='green', bold=True)
click.secho('警告!', fg='yellow')
click.secho('错误!', fg='red', bg='white')
子命令实现
argparse子命令
import argparse
# 主解析器
parser = argparse.ArgumentParser(description='Git风格CLI工具')
subparsers = parser.add_subparsers(dest='command', help='可用命令')
# init子命令
init_parser = subparsers.add_parser('init', help='初始化仓库')
init_parser.add_argument('name', help='仓库名称')
init_parser.add_argument('--bare', action='store_true', help='创建裸仓库')
# commit子命令
commit_parser = subparsers.add_parser('commit', help='提交更改')
commit_parser.add_argument('-m', '--message', required=True, help='提交信息')
commit_parser.add_argument('-a', '--all', action='store_true', help='提交所有更改')
# push子命令
push_parser = subparsers.add_parser('push', help='推送到远程')
push_parser.add_argument('remote', default='origin', nargs='?', help='远程名称')
push_parser.add_argument('branch', default='main', nargs='?', help='分支名称')
push_parser.add_argument('-f', '--force', action='store_true', help='强制推送')
args = parser.parse_args()
if args.command == 'init':
print(f"初始化仓库: {args.name}")
if args.bare:
print("创建裸仓库")
elif args.command == 'commit':
print(f"提交: {args.message}")
if args.all:
print("提交所有更改")
elif args.command == 'push':
print(f"推送到 {args.remote}/{args.branch}")
if args.force:
print("强制推送!")
else:
parser.print_help()
click子命令组
import click
@click.group()
@click.version_option(version='1.0.0')
def cli():
"""Git风格的CLI工具"""
pass
@cli.command()
@click.argument('name')
@click.option('--bare', is_flag=True, help='创建裸仓库')
def init(name, bare):
"""初始化仓库"""
click.echo(f"初始化仓库: {name}")
if bare:
click.echo("创建裸仓库")
@cli.command()
@click.option('-m', '--message', required=True, help='提交信息')
@click.option('-a', '--all', is_flag=True, help='提交所有更改')
def commit(message, all):
"""提交更改"""
click.echo(f"提交: {message}")
if all:
click.echo("提交所有更改")
@cli.command()
@click.argument('remote', default='origin')
@click.argument('branch', default='main')
@click.option('-f', '--force', is_flag=True, help='强制推送')
def push(remote, branch, force):
"""推送到远程"""
click.echo(f"推送到 {remote}/{branch}")
if force:
click.echo("强制推送!")
@cli.group()
def remote():
"""远程仓库管理"""
pass
@remote.command('add')
@click.argument('name')
@click.argument('url')
def remote_add(name, url):
"""添加远程仓库"""
click.echo(f"添加远程 {name}: {url}")
@remote.command('remove')
@click.argument('name')
def remote_remove(name):
"""移除远程仓库"""
click.echo(f"移除远程: {name}")
if __name__ == '__main__':
cli()
实战:文件处理工具
import click
from pathlib import Path
import json
from datetime import datetime
@click.group()
def cli():
"""文件处理工具集"""
pass
@cli.command('count')
@click.argument('path', type=click.Path(exists=True))
@click.option('--ext', multiple=True, help='文件扩展名过滤')
@click.option('-r', '--recursive', is_flag=True, help='递归处理')
def count_files(path, ext, recursive):
"""统计文件数量"""
path = Path(path)
if recursive:
files = path.rglob('*')
else:
files = path.glob('*')
if ext:
files = [f for f in files if f.suffix in ext]
else:
files = [f for f in files if f.is_file()]
click.echo(f"找到 {len(list(files))} 个文件")
@cli.command('search')
@click.argument('pattern')
@click.argument('path', type=click.Path(exists=True))
@click.option('--ext', help='文件扩展名')
@click.option('--ignore-case', is_flag=True, help='忽略大小写')
def search(pattern, path, ext, ignore_case):
"""在文件中搜索文本"""
path = Path(path)
results = []
for file in path.rglob(f'*{ext or "*"}'):
if file.is_file():
try:
content = file.read_text(encoding='utf-8')
if ignore_case:
found = pattern.lower() in content.lower()
else:
found = pattern in content
if found:
results.append(file)
except:
pass
click.echo(f"在 {len(results)} 个文件中找到 '{pattern}'")
for r in results:
click.echo(f" - {r}")
@cli.command('stats')
@click.argument('path', type=click.Path(exists=True))
def file_stats(path):
"""显示文件统计信息"""
path = Path(path)
total_size = 0
file_count = 0
type_count = {}
for file in path.rglob('*'):
if file.is_file():
file_count += 1
total_size += file.stat().st_size
ext = file.suffix or '(无扩展名)'
type_count[ext] = type_count.get(ext, 0) + 1
click.echo(f"文件总数: {file_count}")
click.echo(f"总大小: {total_size / 1024:.2f} KB")
click.echo("\n按类型统计:")
for ext, count in sorted(type_count.items(), key=lambda x: -x[1]):
click.echo(f" {ext}: {count}")
@cli.command('export')
@click.argument('path', type=click.Path(exists=True))
@click.option('-o', '--output', default='report.json', help='输出文件')
@click.option('--format', type=click.Choice(['json', 'csv']), default='json')
def export_report(path, output, format):
"""导出文件报告"""
path = Path(path)
report = {
'generated_at': datetime.now().isoformat(),
'path': str(path),
'files': []
}
for file in path.rglob('*'):
if file.is_file():
report['files'].append({
'name': file.name,
'path': str(file.relative_to(path)),
'size': file.stat().st_size,
'extension': file.suffix
})
with open(output, 'w', encoding='utf-8') as f:
json.dump(report, f, ensure_ascii=False, indent=2)
click.echo(f"报告已导出到 {output}")
if __name__ == '__main__':
cli()
测试CLI工具
from click.testing import CliRunner
from mycli import cli
def test_count_files():
runner = CliRunner()
result = runner.invoke(cli, ['count', '.'])
assert result.exit_code == 0
assert '找到' in result.output
def test_help():
runner = CliRunner()
result = runner.invoke(cli, ['--help'])
assert result.exit_code == 0
assert '文件处理工具集' in result.output
def test_search():
runner = CliRunner()
with runner.isolated_filesystem():
with open('test.txt', 'w') as f:
f.write('Hello World')
result = runner.invoke(cli, ['search', 'Hello', '.'])
assert result.exit_code == 0
总结
命令行工具是提高工作效率的利器。argparse适合简单场景,click提供更优雅的API。选择合适的工具,遵循最佳实践,你就能创建专业、易用的CLI应用。记住:好的命令行工具应该有清晰的帮助信息、合理的默认值和友好的错误提示。