← 返回总目录

Day 75 — 阶段项目:标准库综合实战

标准库与生态系统 948 行 3 个可运行脚本

Day 075 — 阶段项目:标准库综合实战

概述

Phase 5 的最后一天,我们将综合运用所有学到的知识,构建一个完整的 CLI 工具——文件批量重命名工具。

今天你将学到:
1. 构建完整的 CLI 工具(argparse + logging + json + pathlib)
2. 项目结构规范(src layout)
3. 文档编写(README + docstring)
4. 测试编写(unittest)
5. 实战:文件批量重命名工具

💡 为什么要做综合项目?
- 知识点只有在实际项目中才能真正掌握
- 综合项目能暴露你对各模块理解的不足
- 完整的项目经历是简历上的亮点
- 学会从零构建一个可用的工具


1. 项目规划

1.1 功能需求

文件批量重命名工具 rename-tool
├── 基本重命名
│   ├── 添加前缀/后缀
│   ├── 替换字符串
│   └── 序号重命名
├── 高级功能
│   ├── 正则替换
│   ├── 日期重命名
│   └── 大小写转换
├── 安全特性
│   ├── 预览模式(dry-run)
│   ├── 确认提示
│   └── 撤销支持
└── 输出格式
    ├── 终端输出
    ├── JSON 报告
    └── 日志记录

1.2 项目结构

rename-tool/
├── src/
│   └── rename_tool/
│       ├── __init__.py
│       ├── cli.py          # 命令行接口
│       ├── core.py         # 核心重命名逻辑
│       ├── utils.py        # 工具函数
│       └── exceptions.py   # 自定义异常
├── tests/
│   ├── __init__.py
│   ├── test_core.py
│   ├── test_cli.py
│   └── test_utils.py
├── pyproject.toml
├── README.md
├── CHANGELOG.md
└── LICENSE

2. 核心代码实现

2.1 异常定义

python
# src/rename_tool/exceptions.py
"""自定义异常"""


class RenameToolError(Exception):
    """重命名工具基础异常"""
    pass


class FileNotFoundError(RenameToolError):
    """文件不存在"""
    pass


class PermissionError(RenameToolError):
    """权限不足"""
    pass


class ConflictError(RenameToolError):
    """文件名冲突"""
    pass


class PatternError(RenameToolError):
    """正则模式错误"""
    pass

2.2 核心重命名逻辑

python
# src/rename_tool/core.py
"""核心重命名逻辑"""
import os
import re
import json
import logging
from pathlib import Path
from datetime import datetime
from typing import List, Dict, Optional, Callable

from .exceptions import (
    FileNotFoundError,
    PermissionError,
    ConflictError,
    PatternError,
)

logger = logging.getLogger(__name__)


class RenameOperation:
    """单个重命名操作"""

    def __init__(self, old_path: Path, new_path: Path):
        self.old_path = old_path
        self.new_path = new_path
        self.success = False
        self.error: Optional[str] = None

    def to_dict(self) -> Dict:
        """转换为字典"""
        return {
            "old": str(self.old_path),
            "new": str(self.new_path),
            "success": self.success,
            "error": self.error,
        }


class RenameResult:
    """重命名结果"""

    def __init__(self):
        self.operations: List[RenameOperation] = []
        self.start_time = datetime.now()
        self.end_time: Optional[datetime] = None

    def add(self, operation: RenameOperation):
        """添加操作"""
        self.operations.append(operation)

    def finish(self):
        """完成"""
        self.end_time = datetime.now()

    @property
    def total(self) -> int:
        """总操作数"""
        return len(self.operations)

    @property
    def success_count(self) -> int:
        """成功数"""
        return sum(1 for op in self.operations if op.success)

    @property
    def failure_count(self) -> int:
        """失败数"""
        return self.total - self.success_count

    def to_dict(self) -> Dict:
        """转换为字典"""
        return {
            "start_time": self.start_time.isoformat(),
            "end_time": self.end_time.isoformat() if self.end_time else None,
            "total": self.total,
            "success": self.success_count,
            "failure": self.failure_count,
            "operations": [op.to_dict() for op in self.operations],
        }

    def to_json(self, indent: int = 2) -> str:
        """转换为 JSON"""
        return json.dumps(self.to_dict(), indent=indent, ensure_ascii=False)


class Renamer:
    """重命名器"""

    def __init__(self, directory: str, dry_run: bool = False):
        self.directory = Path(directory)
        self.dry_run = dry_run
        self.result = RenameResult()

        if not self.directory.exists():
            raise FileNotFoundError(f"目录不存在: {directory}")

        if not self.directory.is_dir():
            raise ValueError(f"不是目录: {directory}")

    def _get_files(self, pattern: Optional[str] = None) -> List[Path]:
        """获取文件列表"""
        if pattern:
            return sorted(self.directory.glob(pattern))
        return sorted(self.directory.iterdir())

    def _check_conflict(self, new_path: Path) -> bool:
        """检查文件名冲突"""
        if new_path.exists():
            logger.warning(f"目标文件已存在: {new_path}")
            return True
        return False

    def _execute_rename(self, old_path: Path, new_path: Path) -> RenameOperation:
        """执行重命名"""
        operation = RenameOperation(old_path, new_path)

        try:
            if self.dry_run:
                logger.info(f"[预览] {old_path.name} → {new_path.name}")
                operation.success = True
            else:
                old_path.rename(new_path)
                logger.info(f"重命名: {old_path.name} → {new_path.name}")
                operation.success = True
        except Exception as e:
            operation.error = str(e)
            logger.error(f"重命名失败: {old_path.name} - {e}")

        return operation

    def add_prefix(self, prefix: str, pattern: Optional[str] = None) -> RenameResult:
        """添加前缀"""
        files = self._get_files(pattern)
        for file in files:
            if file.is_file():
                new_name = f"{prefix}{file.name}"
                new_path = file.parent / new_name
                operation = self._execute_rename(file, new_path)
                self.result.add(operation)

        self.result.finish()
        return self.result

    def add_suffix(self, suffix: str, pattern: Optional[str] = None) -> RenameResult:
        """添加后缀"""
        files = self._get_files(pattern)
        for file in files:
            if file.is_file():
                stem = file.stem
                new_name = f"{stem}{suffix}{file.suffix}"
                new_path = file.parent / new_name
                operation = self._execute_rename(file, new_path)
                self.result.add(operation)

        self.result.finish()
        return self.result

    def replace(
        self, old: str, new: str, pattern: Optional[str] = None
    ) -> RenameResult:
        """替换字符串"""
        files = self._get_files(pattern)
        for file in files:
            if file.is_file():
                new_name = file.name.replace(old, new)
                new_path = file.parent / new_name
                operation = self._execute_rename(file, new_path)
                self.result.add(operation)

        self.result.finish()
        return self.result

    def regex_replace(
        self, regex_pattern: str, replacement: str, pattern: Optional[str] = None
    ) -> RenameResult:
        """正则替换"""
        try:
            compiled = re.compile(regex_pattern)
        except re.error as e:
            raise PatternError(f"正则表达式错误: {e}")

        files = self._get_files(pattern)
        for file in files:
            if file.is_file():
                new_name = compiled.sub(replacement, file.name)
                new_path = file.parent / new_name
                operation = self._execute_rename(file, new_path)
                self.result.add(operation)

        self.result.finish()
        return self.result

    def sequence(
        self,
        template: str = "{name}_{index:03d}",
        start: int = 1,
        pattern: Optional[str] = None,
    ) -> RenameResult:
        """序号重命名"""
        files = self._get_files(pattern)
        for i, file in enumerate(files, start=start):
            if file.is_file():
                new_name = template.format(
                    name=file.stem,
                    index=i,
                    ext=file.suffix,
                )
                new_path = file.parent / new_name
                operation = self._execute_rename(file, new_path)
                self.result.add(operation)

        self.result.finish()
        return self.result

    def date_rename(
        self,
        date_format: str = "%Y%m%d_%H%M%S",
        pattern: Optional[str] = None,
    ) -> RenameResult:
        """日期重命名"""
        files = self._get_files(pattern)
        for file in files:
            if file.is_file():
                # 获取文件修改时间
                mtime = datetime.fromtimestamp(file.stat().st_mtime)
                date_str = mtime.strftime(date_format)
                new_name = f"{date_str}_{file.name}"
                new_path = file.parent / new_name
                operation = self._execute_rename(file, new_path)
                self.result.add(operation)

        self.result.finish()
        return self.result

    def case转换(self, case: str, pattern: Optional[str] = None) -> RenameResult:
        """大小写转换"""
        files = self._get_files(pattern)
        for file in files:
            if file.is_file():
                stem = file.stem
                suffix = file.suffix

                if case == "lower":
                    new_stem = stem.lower()
                elif case == "upper":
                    new_stem = stem.upper()
                elif case == "title":
                    new_stem = stem.title()
                elif case == "snake":
                    # 简单的蛇形命名
                    new_stem = re.sub(r'([A-Z])', r'_\1', stem).lower().lstrip('_')
                elif case == "camel":
                    # 简单的驼峰命名
                    parts = stem.split('_')
                    new_stem = parts[0] + ''.join(p.capitalize() for p in parts[1:])
                else:
                    raise ValueError(f"未知的大小写模式: {case}")

                new_name = f"{new_stem}{suffix}"
                new_path = file.parent / new_name
                operation = self._execute_rename(file, new_path)
                self.result.add(operation)

        self.result.finish()
        return self.result

2.3 工具函数

python
# src/rename_tool/utils.py
"""工具函数"""
import json
import logging
from pathlib import Path
from typing import List, Dict, Optional

logger = logging.getLogger(__name__)


def setup_logging(
    level: str = "INFO",
    log_file: Optional[str] = None,
) -> None:
    """配置日志"""
    log_level = getattr(logging, level.upper(), logging.INFO)

    # 日志格式
    formatter = logging.Formatter(
        "%(asctime)s [%(levelname)s] %(name)s: %(message)s",
        datefmt="%Y-%m-%d %H:%M:%S",
    )

    # 控制台处理器
    console_handler = logging.StreamHandler()
    console_handler.setFormatter(formatter)

    # 配置根日志器
    root_logger = logging.getLogger()
    root_logger.setLevel(log_level)
    root_logger.addHandler(console_handler)

    # 文件处理器(可选)
    if log_file:
        file_handler = logging.FileHandler(log_file, encoding="utf-8")
        file_handler.setFormatter(formatter)
        root_logger.addHandler(file_handler)


def format_file_size(size: int) -> str:
    """格式化文件大小"""
    for unit in ['B', 'KB', 'MB', 'GB']:
        if size < 1024:
            return f"{size:.2f} {unit}"
        size /= 1024
    return f"{size:.2f} TB"


def save_report(result, output_file: str) -> None:
    """保存重命名报告"""
    report = result.to_dict()
    with open(output_file, "w", encoding="utf-8") as f:
        json.dump(report, f, indent=2, ensure_ascii=False)
    logger.info(f"报告已保存: {output_file}")


def preview_changes(operations) -> None:
    """预览变更"""
    print("\n" + "=" * 60)
    print("📋 变更预览")
    print("=" * 60)

    for i, op in enumerate(operations, 1):
        old_name = op.old_path.name
        new_name = op.new_path.name
        status = "✅" if op.success else "❌"
        print(f"{i:3d}. {status} {old_name} → {new_name}")

    print("=" * 60)

2.4 命令行接口

python
# src/rename_tool/cli.py
"""命令行接口"""
import argparse
import sys
import logging
from pathlib import Path

from .core import Renamer
from .utils import setup_logging, save_report, preview_changes

logger = logging.getLogger(__name__)


def create_parser() -> argparse.ArgumentParser:
    """创建命令行参数解析器"""
    parser = argparse.ArgumentParser(
        prog="rename-tool",
        description="文件批量重命名工具",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
示例:
  # 添加前缀
  rename-tool add-prefix --prefix "photo_" --directory ./images

  # 替换字符串
  rename-tool replace --old " " --new "_" --directory ./files

  # 序号重命名
  rename-tool sequence --template "doc_{index:03d}" --directory ./docs

  # 正则替换
  rename-tool regex --pattern "(\\d+)" --replacement "file_\\1" --directory ./data
        """,
    )

    # 通用参数
    parser.add_argument(
        "-d", "--directory",
        required=True,
        help="目标目录",
    )
    parser.add_argument(
        "-n", "--dry-run",
        action="store_true",
        help="预览模式,不实际重命名",
    )
    parser.add_argument(
        "-f", "--filter",
        help="文件过滤模式(如 *.txt)",
    )
    parser.add_argument(
        "-o", "--output",
        help="输出 JSON 报告文件",
    )
    parser.add_argument(
        "-v", "--verbose",
        action="store_true",
        help="详细输出",
    )
    parser.add_argument(
        "--log-file",
        help="日志文件路径",
    )

    # 子命令
    subparsers = parser.add_subparsers(dest="command", help="可用命令")

    # add-prefix
    prefix_parser = subparsers.add_parser("add-prefix", help="添加前缀")
    prefix_parser.add_argument("--prefix", required=True, help="要添加的前缀")

    # add-suffix
    suffix_parser = subparsers.add_parser("add-suffix", help="添加后缀")
    suffix_parser.add_argument("--suffix", required=True, help="要添加的后缀")

    # replace
    replace_parser = subparsers.add_parser("replace", help="替换字符串")
    replace_parser.add_argument("--old", required=True, help="要替换的字符串")
    replace_parser.add_argument("--new", required=True, help="替换后的字符串")

    # regex
    regex_parser = subparsers.add_parser("regex", help="正则替换")
    regex_parser.add_argument("--pattern", required=True, help="正则表达式")
    regex_parser.add_argument("--replacement", required=True, help="替换内容")

    # sequence
    seq_parser = subparsers.add_parser("sequence", help="序号重命名")
    seq_parser.add_argument(
        "--template",
        default="{name}_{index:03d}",
        help="命名模板(默认: {name}_{index:03d})",
    )
    seq_parser.add_argument(
        "--start",
        type=int,
        default=1,
        help="起始序号(默认: 1)",
    )

    # date
    date_parser = subparsers.add_parser("date", help="日期重命名")
    date_parser.add_argument(
        "--format",
        default="%Y%m%d_%H%M%S",
        help="日期格式(默认: %Y%m%d_%H%M%S)",
    )

    # case
    case_parser = subparsers.add_parser("case", help="大小写转换")
    case_parser.add_argument(
        "--mode",
        choices=["lower", "upper", "title", "snake", "camel"],
        required=True,
        help="转换模式",
    )

    return parser


def main(args=None):
    """主入口"""
    parser = create_parser()
    args = parser.parse_args(args)

    # 配置日志
    log_level = "DEBUG" if args.verbose else "INFO"
    setup_logging(level=log_level, log_file=args.log_file)

    # 检查命令
    if not args.command:
        parser.print_help()
        sys.exit(1)

    # 创建重命名器
    try:
        renamer = Renamer(args.directory, dry_run=args.dry_run)
    except Exception as e:
        logger.error(f"初始化失败: {e}")
        sys.exit(1)

    # 执行命令
    try:
        if args.command == "add-prefix":
            result = renamer.add_prefix(args.prefix, args.filter)
        elif args.command == "add-suffix":
            result = renamer.add_suffix(args.suffix, args.filter)
        elif args.command == "replace":
            result = renamer.replace(args.old, args.new, args.filter)
        elif args.command == "regex":
            result = renamer.regex_replace(args.pattern, args.replacement, args.filter)
        elif args.command == "sequence":
            result = renamer.sequence(args.template, args.start, args.filter)
        elif args.command == "date":
            result = renamer.date_rename(args.format, args.filter)
        elif args.command == "case":
            result = renamer.case转换(args.mode, args.filter)
        else:
            parser.print_help()
            sys.exit(1)

        # 显示结果
        preview_changes(result.operations)

        print(f"\n📊 统计: 成功 {result.success_count}, 失败 {result.failure_count}")

        # 保存报告
        if args.output:
            save_report(result, args.output)

    except Exception as e:
        logger.error(f"执行失败: {e}")
        sys.exit(1)


if __name__ == "__main__":
    main()

2.5 项目配置

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

[project]
name = "rename-tool"
version = "1.0.0"
description = "文件批量重命名工具"
readme = "README.md"
license = {text = "MIT"}
requires-python = ">=3.9"

[project.scripts]
rename-tool = "rename_tool.cli:main"

[tool.setuptools.packages.find]
where = ["src"]

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-v"

[tool.coverage.run]
source = ["rename_tool"]

[tool.coverage.report]
exclude_lines = [
    "pragma: no cover",
    "def __repr__",
    "if __name__ == .__main__",
]

3. 测试代码

python
# tests/test_core.py
"""核心功能测试"""
import pytest
import tempfile
import os
from pathlib import Path
from rename_tool.core import Renamer, RenameResult
from rename_tool.exceptions import FileNotFoundError, PatternError


@pytest.fixture
def temp_dir():
    """创建临时目录"""
    with tempfile.TemporaryDirectory() as tmpdir:
        # 创建测试文件
        for i in range(5):
            (Path(tmpdir) / f"file{i}.txt").touch()
        yield tmpdir


class TestRenamer:
    """重命名器测试"""

    def test_init(self, temp_dir):
        """初始化"""
        renamer = Renamer(temp_dir)
        assert renamer.directory == Path(temp_dir)
        assert renamer.dry_run is False

    def test_init_nonexistent(self):
        """初始化不存在的目录"""
        with pytest.raises(FileNotFoundError):
            Renamer("/nonexistent/path")

    def test_add_prefix(self, temp_dir):
        """添加前缀"""
        renamer = Renamer(temp_dir)
        result = renamer.add_prefix("test_")

        assert result.success_count == 5
        assert result.failure_count == 0

        # 验证文件已重命名
        files = list(Path(temp_dir).glob("test_*.txt"))
        assert len(files) == 5

    def test_add_suffix(self, temp_dir):
        """添加后缀"""
        renamer = Renamer(temp_dir)
        result = renamer.add_suffix("_backup")

        assert result.success_count == 5

        files = list(Path(temp_dir).glob("*_backup.txt"))
        assert len(files) == 5

    def test_replace(self, temp_dir):
        """替换字符串"""
        renamer = Renamer(temp_dir)
        result = renamer.replace("file", "doc")

        assert result.success_count == 5

        files = list(Path(temp_dir).glob("doc*.txt"))
        assert len(files) == 5

    def test_sequence(self, temp_dir):
        """序号重命名"""
        renamer = Renamer(temp_dir)
        result = renamer.sequence("item_{index:03d}.txt")

        assert result.success_count == 5

        files = list(Path(temp_dir).glob("item_*.txt"))
        assert len(files) == 5

    def test_dry_run(self, temp_dir):
        """预览模式"""
        renamer = Renamer(temp_dir, dry_run=True)
        result = renamer.add_prefix("test_")

        assert result.success_count == 5

        # 文件不应该被重命名
        files = list(Path(temp_dir).glob("file*.txt"))
        assert len(files) == 5

    def test_regex_replace(self, temp_dir):
        """正则替换"""
        renamer = Renamer(temp_dir)
        result = renamer.regex_replace(r"(\d+)", r"num_\1")

        assert result.success_count == 5

    def test_invalid_regex(self, temp_dir):
        """无效正则"""
        renamer = Renamer(temp_dir)
        with pytest.raises(PatternError):
            renamer.regex_replace(r"[invalid", "test")


class TestRenameResult:
    """重命名结果测试"""

    def test_empty_result(self):
        """空结果"""
        result = RenameResult()
        assert result.total == 0
        assert result.success_count == 0
        assert result.failure_count == 0

    def test_to_json(self):
        """转换为 JSON"""
        result = RenameResult()
        result.finish()
        json_str = result.to_json()
        assert "start_time" in json_str
        assert "total" in json_str

4. 使用示例

bash
# 安装工具
pip install -e .

# 添加前缀
rename-tool add-prefix --prefix "photo_" --directory ./images

# 替换空格为下划线
rename-tool replace --old " " --new "_" --directory ./files

# 序号重命名
rename-tool sequence --template "doc_{index:03d}" --directory ./docs

# 正则替换
rename-tool regex --pattern "(\d+)" --replacement "file_\1" --directory ./data

# 预览模式
rename-tool add-prefix --prefix "test_" --directory ./images --dry-run

# 保存报告
rename-tool replace --old " " --new "_" --directory ./files --output report.json

# 详细输出
rename-tool add-prefix --prefix "test_" --directory ./images -v

# 只处理 .txt 文件
rename-tool add-prefix --prefix "text_" --directory ./files --filter "*.txt"

5. 项目文档

README.md 模板

markdown
# Rename Tool

文件批量重命名工具 - 让文件管理更简单

## 功能特性

- ✅ 添加前缀/后缀
- ✅ 替换字符串
- ✅ 正则替换
- ✅ 序号重命名
- ✅ 日期重命名
- ✅ 大小写转换
- ✅ 预览模式
- ✅ JSON 报告

## 安装

```bash
pip install rename-tool

使用方法

bash
# 添加前缀
rename-tool add-prefix --prefix "photo_" --directory ./images

# 替换字符串
rename-tool replace --old " " --new "_" --directory ./files

# 预览模式
rename-tool add-prefix --prefix "test_" --directory ./images --dry-run

开发

bash
# 克隆仓库
git clone https://github.com/yourname/rename-tool.git
cd rename-tool

# 安装依赖
pip install -e ".[dev]"

# 运行测试
pytest

# 生成覆盖率报告
pytest --cov=rename_tool --cov-report=html

许可证

MIT License
```


今日总结


练习题

练习 1:扩展功能 ⭐⭐

为重命名工具添加以下功能:
- 按文件大小重命名
- 按文件类型分组重命名
- 批量移动到子目录
- 支持撤销操作

练习 2:Web 界面 ⭐⭐⭐

为重命名工具添加 Web 界面:
- 使用 Flask/FastAPI
- 上传文件并预览重命名
- 实时预览变更
- 下载重命名报告

练习 3:插件系统 ⭐⭐⭐

为重命名工具实现插件系统:
- 定义插件接口
- 实现内置插件
- 支持第三方插件
- 插件配置管理

练习 4:性能优化 ⭐⭐⭐⭐

优化重命名工具的性能:
- 异步文件操作
- 并发重命名
- 进度条显示
- 内存优化

练习 5:完整发布 ⭐⭐⭐⭐

将重命名工具发布到 PyPI:
- 完善项目结构
- 编写完整文档
- 添加 CI/CD
- 发布到 PyPI


🎉 恭喜完成 Phase 5!

你已经完成了标准库与生态系统的所有学习:
- ✅ Day 072: 正则表达式深入
- ✅ Day 073: 包管理与虚拟环境
- ✅ Day 074: 单元测试进阶
- ✅ Day 075: 阶段项目实战

下一步:Phase 6 将进入实战项目阶段,我们将构建真实世界的应用!