← 返回首页
⌨️

命令行工具:argparse、click、参数解析与子命令

📂 python ⏱ 4 min 792 words

命令行工具: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应用。记住:好的命令行工具应该有清晰的帮助信息、合理的默认值和友好的错误提示。