进阶2-3 小时·工具开发

构建一个专业的 Python CLI 工具

使用 Click + Rich 构建一个带颜色输出、进度条、子命令的命令行工具,支持打包分发

Pythoncliclickrichpackaging

构建一个专业的 Python CLI 工具

使用 Click + Rich 构建一个带颜色输出、进度条、子命令的命令行工具

你将学到

  • 使用 Click 框架定义命令、参数和选项
  • 使用 Rich 库实现彩色输出、表格、进度条
  • 设计清晰的 CLI 交互体验
  • 将 Python 脚本打包成可分发的命令行工具

前置知识

架构设计

filetool/
├── filetool/
│   ├── __init__.py
│   ├── cli.py          # CLI 入口和命令定义
│   ├── core.py         # 核心业务逻辑
│   └── formatters.py   # 输出格式化
├── pyproject.toml      # 项目配置和打包
└── README.md

实现步骤

第一步:项目初始化

# 创建项目目录
mkdir filetool && cd filetool

# 创建虚拟环境
python -m venv venv
source venv/bin/activate  # Linux/Mac
# venv\Scripts\activate   # Windows

# 安装依赖
pip install click rich

第二步:定义 CLI 入口

# filetool/cli.py
import click
from rich.console import Console
from rich.table import Table
from .core import scan_directory, get_file_stats

console = Console()

@click.group()
@click.version_option(version="1.0.0")
def cli():
    """FileTool - 一个专业的文件管理命令行工具"""
    pass

@cli.command()
@click.argument("directory", default=".")
@click.option("-e", "--ext", help="按扩展名过滤")
@click.option("-s", "--sort", type=click.Choice(["name", "size", "date"]),
              default="name", help="排序方式")
@click.option("--hidden", is_flag=True, help="显示隐藏文件")
def list(directory, ext, sort, hidden):
    """列出目录中的文件"""
    files = scan_directory(directory, ext_filter=ext, show_hidden=hidden)

    table = Table(title=f"📁 {directory} 目录内容")
    table.add_column("文件名", style="cyan")
    table.add_column("大小", justify="right", style="green")
    table.add_column("修改时间", style="yellow")
    table.add_column("类型", style="magenta")

    for f in sorted(files, key=lambda x: x[sort]):
        table.add_row(f["name"], f["size"], f["modified"], f["type"])

    console.print(table)
    console.print(f"\n共 {len(files)} 个文件")

@cli.command()
@click.argument("directory", default=".")
def stats(directory):
    """统计目录信息"""
    with console.status("[bold blue]扫描目录中..."):
        result = get_file_stats(directory)

    console.print(f"\n📊 [bold]目录统计:{directory}[/bold]")
    console.print(f"  文件总数:{result['total_files']}")
    console.print(f"  目录总数:{result['total_dirs']}")
    console.print(f"  总大小:{result['total_size']}")

    # 按类型统计
    table = Table(title="按文件类型统计")
    table.add_column("类型", style="cyan")
    table.add_column("数量", justify="right", style="green")
    table.add_column("大小", justify="right", style="yellow")

    for ext, info in result["by_extension"].items():
        table.add_row(ext or "(无扩展名)", str(info["count"]), info["size"])

    console.print(table)

if __name__ == "__main__":
    cli()

第三步:实现核心逻辑

# filetool/core.py
import os
from pathlib import Path
from datetime import datetime

def format_size(size_bytes):
    """将字节数转换为人类可读格式"""
    for unit in ["B", "KB", "MB", "GB"]:
        if size_bytes < 1024:
            return f"{size_bytes:.1f} {unit}"
        size_bytes /= 1024
    return f"{size_bytes:.1f} TB"

def scan_directory(directory=".", ext_filter=None, show_hidden=False):
    """扫描目录,返回文件信息列表"""
    path = Path(directory)
    files = []

    for item in path.iterdir():
        # 跳过隐藏文件
        if not show_hidden and item.name.startswith("."):
            continue

        # 按扩展名过滤
        if ext_filter and item.suffix != f".{ext_filter}":
            continue

        stat = item.stat()
        files.append({
            "name": item.name,
            "size": format_size(stat.st_size),
            "size_bytes": stat.st_size,
            "modified": datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M"),
            "modified_timestamp": stat.st_mtime,
            "type": item.suffix or "(无)",
            "is_dir": item.is_dir(),
        })

    return files

def get_file_stats(directory="."):
    """获取目录统计信息"""
    path = Path(directory)
    total_files = 0
    total_dirs = 0
    total_size = 0
    by_extension = {}

    for item in path.rglob("*"):
        if item.is_file():
            total_files += 1
            size = item.stat().st_size
            total_size += size

            ext = item.suffix
            if ext not in by_extension:
                by_extension[ext] = {"count": 0, "size_bytes": 0}
            by_extension[ext]["count"] += 1
            by_extension[ext]["size_bytes"] += size
        elif item.is_dir():
            total_dirs += 1

    # 格式化大小
    for ext in by_extension:
        by_extension[ext]["size"] = format_size(by_extension[ext]["size_bytes"])

    return {
        "total_files": total_files,
        "total_dirs": total_dirs,
        "total_size": format_size(total_size),
        "by_extension": by_extension,
    }

第四步:添加进度条支持

# filetool/formatters.py
from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn
from rich.console import Console
import time

console = Console()

def process_with_progress(items, process_func, description="处理中"):
    """带进度条的批量处理"""
    results = []

    with Progress(
        SpinnerColumn(),
        TextColumn("[progress.description]{task.description}"),
        BarColumn(),
        TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
        console=console,
    ) as progress:
        task = progress.add_task(description, total=len(items))

        for item in items:
            result = process_func(item)
            results.append(result)
            progress.advance(task)
            time.sleep(0.01)  # 模拟处理时间

    return results

# 在 cli.py 中使用
@cli.command()
@click.argument("directory", default=".")
@click.option("-p", "--pattern", default="*", help="文件匹配模式")
def process(directory, pattern):
    """批量处理文件(带进度条)"""
    from pathlib import Path
    files = list(Path(directory).glob(pattern))

    def process_file(f):
        # 这里可以添加实际处理逻辑
        return {"file": f.name, "status": "完成"}

    results = process_with_progress(files, process_file, "处理文件")

    console.print(f"\n✅ 处理完成,共 {len(results)} 个文件")

第五步:打包为可分发工具

# pyproject.toml
[build-system]
requires = ["setuptools>=68.0", "wheel"]
build-backend = "setuptools.backends._legacy:_Backend"

[project]
name = "filetool"
version = "1.0.0"
description = "一个专业的文件管理命令行工具"
readme = "README.md"
requires-python = ">=3.9"
dependencies = [
    "click>=8.0",
    "rich>=13.0",
]

[project.scripts]
filetool = "filetool.cli:cli"

[tool.setuptools.packages.find]
where = ["."]
# 安装为全局命令
pip install -e .

# 现在可以直接使用
filetool list .
filetool stats .

完整项目结构

filetool/
├── filetool/
│   ├── __init__.py       # 包初始化
│   ├── cli.py            # CLI 入口和命令定义
│   ├── core.py           # 核心业务逻辑
│   └── formatters.py     # 输出格式化和进度条
├── pyproject.toml        # 项目配置和打包
└── README.md             # 项目说明

最佳实践

  1. 使用 Click 而非 argparse:Click 的装饰器语法更简洁,自动生成帮助文档
  2. Rich 让输出更专业:彩色输出、表格、进度条能显著提升用户体验
  3. 分离业务逻辑:CLI 层只负责参数解析和输出,核心逻辑放在独立模块
  4. 提供合理的默认值:参数有合理的默认值,减少用户输入
  5. 使用 pyproject.toml:现代 Python 项目的标准配置方式

常见问题

Q: 如何添加子命令? A: 使用 @click.group() 创建命令组,然后用 @group.command() 添加子命令。

Q: 如何处理大文件的进度显示? A: 使用 Rich 的 Progress 上下文管理器,在处理每个文件时调用 progress.advance()

Q: 如何支持 Windows? A: Click 和 Rich 都支持跨平台,但注意路径处理要用 pathlib.Path 而非硬编码路径分隔符。

扩展挑战

  1. 添加 search 命令,支持按文件名搜索
  2. 添加 duplicate 命令,查找重复文件
  3. 支持配置文件,保存常用选项

相关课程

课程相关章节
Python函数定义与调用
Python模块、包与导入机制
Python文件读写操作
Python面向对象编程:类与对象