昨晚十一点半,DHU上机打卡群又弹出消息,D32的打卡任务发布了。这一期不搞花活,题目是"用Python写一个批量文件重命名工具"。乍一看感觉挺基础,但真正上手之后我才发现,这类工具是典型的"看着简单、写起来全是细节"的活儿。我大概花了两个晚上加一次完整上机调试,才交出一个自己敢在日常里放心用的版本。这篇就把整个D32的解题过程、踩坑记录和最终代码思路完整拆一遍,适合正在刷上机练习、准备课程设计、或者单纯想把手边重复劳动自动化掉的朋友参考。
D32的任务要求其实很短:写一个命令行工具,把指定目录下的文件按照规则批量重命名,支持预览、支持撤销。就这么一句话,但拆开来看,里面藏着文件遍历、正则匹配、安全防护、命名冲突处理这几个经典环节。这篇博文不打算只贴一段能跑的代码了事,我会顺着自己上机时的思考顺序,把每一步为什么这么写、中间踩了什么坑、最后怎么收尾的都讲清楚。
1. D32的打卡任务:一个看似基础但最容易翻车的练习
1.1 任务拆解:为什么选这个题
先说背景。DHU上机打卡计划是我们这边一个坚持了很久的日常练习机制,每天一道实操题,不限制语言,重点考察动手能力和排查问题的思路。D32这天给我的感觉是,题目没有明确指定技术栈,只说要实现"批量重命名",这就很考验你对文件系统操作的理解深度。
我把需求拆成了四个子问题:
- 怎么安全地遍历一个目录下的所有文件,而不误伤子目录和隐藏文件。
- 怎么定义重命名的规则,是做简单的前缀替换,还是用正则表达式做更复杂的模式匹配。
- 怎么防止因为命名冲突、非法字符、路径过长而导致操作中断,甚至丢失文件。
- 怎么给用户一个后悔的机会,也就是"预览模式"和"撤销机制"。
这四个子问题单拎出来任何一个都不算难,但合在一起就会产生连锁反应。比如你用os.rename直接改文件名,遇到目标文件名已经存在时,不同系统会有完全不同的表现。我在第一次测试时就发现,Windows 下会直接报错,而某些网络文件系统上却可能静默覆盖。这种差异光靠看文档是体会不到的,必须真上机跑一遍才有感觉。
1.2 上机环境与工具约定
我这次的运行环境是 Windows 11,Python 3.11,没有装任何第三方库,全部用标准库实现。之所以刻意限制在标准库,是因为打卡练习的价值在于锻炼底层思路,而不是学会调用某个现成的轮子。当你只能靠os、re、pathlib这几个模块来解决问题时,反而会逼着自己把文件路径处理的细节彻底搞清楚。
目录结构我建议这样准备一个测试环境:
test_dir/ ├── report_2024_01.txt ├── report_2024_02.txt ├── photo_001.jpg ├── photo_002.jpg ├── notes/ │ ├── temp_old.md │ └── archive.zip └── .hidden_config这个结构里包含了普通文件、子目录、子目录里的文件、隐藏文件,正好覆盖了大部分边界情况。后面所有调试都在这个目录里进行,能快速验证脚本是否会把不该动的文件也改了。
2. 第一版脚本:能跑,但我没打算就这么交差
2.1 从最直接的思路开始写
面对"批量重命名"这个需求,新手最常见的写法是直接循环os.listdir,然后对每个文件名做字符串替换。我第一版也是这么干的,代码如下:
import os def rename_all(directory, old, new): for filename in os.listdir(directory): if old in filename: new_name = filename.replace(old, new) os.rename( os.path.join(directory, filename), os.path.join(directory, new_name) ) rename_all("test_dir", "report", "summary")这版本代码确实能工作,如果目录里只有一层文件,且文件名里也只有一处需要替换,那没问题。但稍微多想一步就会发现几个问题:
第一,它会把子目录里的文件漏掉,因为os.listdir只遍历顶层。第二,它会把.hidden_config这种隐藏文件也纳入替换范围,如果规则恰好匹配,很容易误操作。第三,如果new_name已经存在,os.rename在某些场景下会直接报错,导致整个循环中断。第四,这个脚本没有预览功能,执行完就完了,想反悔只能靠运气。
我上机到这一步时,真实想法是:这个版本可以作为"反例模板",但绝对不能作为打卡答案。既然目标是做一个能日常使用的工具,就必须把边界处理到位。
2.2 改用 pathlib 管理路径
Python 3 的pathlib模块是处理路径的更好选择。它把路径当成对象来操作,比手写字符串拼接优雅得多,也更安全。改造后的遍历逻辑是这样的:
from pathlib import Path def collect_files(directory, recursive=False): base = Path(directory) if recursive: # rglob("*") 会递归匹配所有子目录里的文件 files = [p for p in base.rglob("*") if p.is_file()] else: files = [p for p in base.iterdir() if p.is_file()] return files这里有一个很关键的判断:p.is_file()会把目录排除掉,避免我们试图去重命名一个文件夹。同时,Path对象直接支持.name、.suffix、.stem这些属性,后面做规则匹配时会非常方便。我强烈建议任何做文件操作相关练习的朋友,直接从pathlib起步,而不是用古老的os.path.join拼接方式。
2.3 把规则引擎独立出来
第二版脚本我不再把"替换规则"写死在循环里,而是设计成一个独立的匹配函数。这样以后想扩展成"所有 .jpg 文件加上日期前缀"或者"把文件名里的空格换成下划线"之类的规则,只需要新增一个函数就行。
def apply_rule(filename, rule): import re if rule["type"] == "replace": return filename.replace(rule["old"], rule["new"]) elif rule["type"] == "regex": return re.sub(rule["pattern"], rule["replacement"], filename) elif rule["type"] == "prefix": return rule["prefix"] + filename elif rule["type"] == "suffix": stem = Path(filename).stem suffix = Path(filename).suffix return stem + rule["suffix"] + suffix return filename以正则表达式为核心的规则引擎,意味着用户可以传入类似r"_\d{4}"这样的模式,精准匹配文件名中的年份信息。这种设计在打卡作业里算是加分项,因为它体现了"把核心逻辑与执行逻辑解耦"的工程意识,而不是把一堆条件写在一个大函数里。
3. 上机过程中踩到的三个坑:路径、编码和隐藏文件
3.1 坑一:Windows 路径分隔符与跨平台问题
第一坑出现在路径拼接上。当时我在函数里用os.path.join(directory, filename)处理路径,在 Windows 下跑得好好的,换成某位同学的 macOS 环境就出问题了,因为有些人会硬编码反斜杠\。虽然Path对象帮我们省去了大部分这类烦恼,但如果你写的脚本需要被别人共享,仍然要格外小心路径分隔符的处理。
实际调试中最典型的一个错误是:用字符串拼接生成新路径,比如directory + "/" + new_name。这个写法在 Windows 上不是不能用,但一旦目录路径的末尾已经带了斜杠,就会产生test_dir//new_name.txt这种双斜杠路径,某些老旧工具在读取时会犯迷糊。正确做法始终是用Path对象做除法运算:
new_path = Path(directory) / new_name我再补充一个容易被忽略的点:如果你在 Windows 上需要处理超长路径,光靠Path还不够,因为系统默认路径长度上限是 260 个字符。实测下来,很多人把文件放在多级目录里,名字稍微长一点就会触发OSError。解决办法要么是通过注册表启用长路径支持,要么在脚本里先检查路径长度并给出明确提示,我采用的是后者,宁可多写几行判断,也不想让用户在莫名其妙的地方卡住。
3.2 坑二:中文文件名和编码问题
这个坑几乎每个中文用户都会遇到。我们习惯于在文件名里使用中文,比如"课程报告_期末版.txt",但当你用某些工具批量处理时,控制台输出的文件名会显示成乱码,严重时还会导致重命名报错。
问题根源多半不在 Python,而是终端编码与 Python 默认编码不一致。Windows 的 cmd 默认使用 GBK 编码,而 Python 3 在读写文件系统时普遍使用 UTF-8。在上机时,我建议在脚本开头做一件事:在命令行里先执行chcp 65001切到 UTF-8 代码页,然后再运行脚本。如果你不想每次手动敲,也可以在 Python 里这样处理:
import sys import io sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')这段代码只影响输出显示,不影响文件系统的实际操作,但是能让你在调试时看清文件名到底是什么,避免"看着像改对了,实际上改错了"的尴尬。我那次卡了将近二十分钟,最后才发现只是显示问题,文件本身并没有坏。
3.3 坑三:隐藏文件与系统文件的误伤
第一版脚本的另一大隐患,是会把手动隐藏的文件也纳入重命名范围。.hidden_config这种文件在 Linux/macOS 下以点开头,在 Windows 下则通过隐藏属性标记,如果规则恰好是"把所有带点号的文件都加上前缀",那隐藏文件就会中招。
我在最终版本里加了一个过滤逻辑:
def should_skip(path: Path): # 跳过隐藏文件 if path.name.startswith("."): return True # Windows 下通过属性判断 import ctypes if hasattr(path, "stat") and path.stat().st_file_attributes: hidden_flag = 0x2 # FILE_ATTRIBUTE_HIDDEN if path.stat().st_file_attributes & hidden_flag: return True # 跳过临时文件和备份文件 if path.suffix.lower() in {".tmp", ".bak", ".swp"}: return True return False别看这段代码不起眼,它直接把脚本的容错等级提升了一个档次。至少在你执行批量操作时,不会因为误改了一个配置文件而把整个项目搞崩溃。我的一位朋友就是在没有这个保护的情况下,把某个软件的隐藏配置文件名改了,结果软件启动直接失败,那滋味可不好受。
4. 安全机制与测试:从"能跑的脚本"变成"敢用的工具"
4.1 预览模式:先告诉用户会发生什么
批量重命名这类操作最大的风险在于"不可见"。你看不清规则到底影响了哪些文件,等执行完才发现后悔。所以我在D32的实现中加入了一个核心设计:预览模式。在真正执行重命名之前,先把所有将要发生的改动打印出来,由用户确认后再动手。
def dry_run(directory, rule): files = collect_files(directory, recursive=True) plan = [] for f in files: if should_skip(f): continue old_name = f.name new_name = apply_rule(old_name, rule) if new_name and new_name != old_name: plan.append((f, new_name)) return plan返回值plan是一个由"原路径和新文件名"组成的列表,接下来你要做的就是逐行输出:
[预览] 将重命名 report_2024_01.txt -> summary_2024_01.txt [预览] 将重命名 photo_001.jpg -> image_001.jpg 是否继续?(y/n):只有输入y之后才会进入真实的执行阶段。这个设计让我在实际使用中避免了很多次误操作,尤其是当正则表达式写错、导致命中了一堆不该命中的文件时,预览模式会第一时间把异常暴露出来。
4.2 冲突处理:重名与非法字符
就算有了预览,仍然存在"两个文件同时被改成同一个新名字"的冲突场景。比如a_1.txt和a_2.txt应用规则后都可能变成a.txt,如果不加处理,其中一个文件就会覆盖掉另一个。这是批量重命名工具最容易造成的灾难。
我采用的策略是:在计划生成阶段就检查冲突,一旦发现冲突,立即停止并提示用户调整规则,而不是自作聪明地在文件名后面追加(1)、(2)。原因很简单:自动添加序号会引入新的复杂性,而且不符合用户预期。与其隐式地处理冲突,不如显式地让用户知情。
from collections import Counter def check_conflicts(plan): new_names = [p[1] for p in plan] conflict_names = [name for name, cnt in Counter(new_names).items() if cnt > 1] if conflict_names: print("发现命名冲突:", conflict_names) return False return True另外还要处理非法字符。Windows 下文件名不能包含\ / : * ? " < > |这些字符,macOS 和 Linux 的限制相对宽松一些,但为了最大兼容性,我还是会统一做一次过滤:
import re def sanitize_filename(name): cleaned = re.sub(r'[\\/:*?"<>|]', '_', name) return cleaned.strip()别小看这行正则,它省去了后续一堆"操作系统报错"的烦恼。我第一次没做过滤时,对包含问号的文件名执行重命名,程序直接抛异常,当时的排查过程非常崩溃。
4.3 真正的执行:带撤销日志的重命名
预览和冲突检测都通过之后,才进入执行环节。为了保证可回滚,我在执行阶段会先写一份"重命名操作日志"到本地文件,记录每一条旧路径 -> 新路径,万一执行到一半发现错误,就可以通过这个日志完整恢复。
import json from pathlib import Path def execute_plan(plan, log_path="rename_log.json"): log_entries = [] for old_path, new_name in plan: new_path = old_path.with_name(new_name) try: old_path.rename(new_path) log_entries.append({ "old": str(old_path), "new": str(new_path) }) except OSError as e: print(f"重命名失败: {old_path.name} -> {new_name}, 原因: {e}") with open(log_path, "w", encoding="utf-8") as f: json.dump(log_entries, f, ensure_ascii=False, indent=2) print(f"已记录 {len(log_entries)} 条操作日志到 {log_path}")这个设计在打卡作业里属于"超出基础要求"的亮点。它体现了你对数据安全的重视,哪怕脚本是一次性的玩具,也拥有生产级工具的严谨性。我后来在真实场景里清理一个下载文件夹时,就是因为有这份日志,才能把一次错误操作完整回滚回来。
5. 测试场景:用造出来的目录暴力验证
5.1 覆盖关键路径:递归、中文、隐藏文件、子目录
写完主体代码后,我没有立刻提交打卡,而是做了一个专门用于测试的目录,把所有能想到的刁钻情况都放进去。测试目录如下:
test_stress/ ├── 项目报告_最终版.pdf ├── 项目报告_最终版(1).pdf ├── .env ├── readme.txt ├── src/ │ ├── main.py │ ├── utility.py │ └── backup/ │ └── main.py.bak └── images/ ├── IMG_20240101_001.jpg └── IMG_20240101_002.jpg我用一条正则规则项目报告_最终版(.*)\.pdf -> 最终提交版\1.pdf来测试中文文件的替换。再用一条前缀规则IMG_ -> travel_来验证子目录里的批量操作。每条规则执行前先走预览模式,确认命中数量符合预期,再实际执行。
5.2 边界情况清单
我还整理了一份边界情况清单,每条都对应一个真实可能发生的使用场景:
- 空目录:脚本不该报错,应提示"没有找到文件"。
- 新文件名超长:超过系统允许的最大长度时应给出明确提示。
- 只读文件:重命名时不应被权限问题卡住,要么跳过,要么提示用户。
- 文件正在被占用:在 Windows 上重命名被其他程序打开的文件会失败,要捕获
OSError和平退出。 - 文件名完全没变:规则匹配后新旧文件名相同,不需要执行重命名。
逐条验证完这五类情况之后,我才放心把代码传到打卡群里。评论区有同学问"为什么花这么久做一个小工具",我的回答是:正是因为只做了这么一个小工具,才有机会把文件系统操作的细节磨清楚。
5.3 如何把这个工具打包成双击可用的脚本
命令行工具对于不熟悉终端的人来说还是不够友好。如果你希望脚本能在自己的电脑上随时用,又不想每次打开 IDE,我建议用 Python 自带的PyInstaller打包成一个.exe可执行文件。这里有一份大概流程:
pip install pyinstaller pyinstaller -F rename_tool.py打包完成后,在dist目录下会生成一个独立的rename_tool.exe。你可以把它放到任意位置,然后在资源管理器里直接把文件拖到可执行文件的图标上,或者用命令行传入目录参数。这样做的好处是,即使电脑上没装 Python 环境也能运行。
打包时有几个注意点我想特别提醒一下:
- 脚本中引用的资源路径要使用相对路径,打包后的工作目录可能与源码目录不同。
- 如果脚本用到了控制台交互(比如输入
y确认),打包时不要加-w参数,否则窗口不显示就尴尬了。 - 杀毒软件有时会误报 PyInstaller 打包的程序,强制让用户加入白名单,这其实是误报,不用太担心。
我自己的习惯是保留一份.py源码用于调试,只把稳定版本打成.exe供日常使用。这样万一工具出问题,还能从源码排查,而不是面对一个黑盒。
6. D32之后:打卡这件事给我的三个真实启发
6.1 小题目里的大体系
如果把D32这道题单独拿出来看,它确实不大,甚至不够写满一页代码。但当你把它当成一个"日常使用的工具"来做,就会发现背后牵连出文件系统、正则表达式、编码、跨平台兼容、安全回滚、测试设计这一整套知识体系。这正是上机打卡练习的价值所在:用一个足够小的入口,逼你建立系统性的思考方式。
以前我做练习题,习惯是"题目要什么我就写什么",能跑通就行。但这次D32我改变了一个心态:把自己想象成这个工具的维护者,而不是一次性的提交者。一旦心态转变,很多细节就自动浮现出来了,比如隐藏文件、命名冲突、日志回滚,这些在标准答案里不一定会写,但在真实场景中一定能用上。
6.2 预览与回滚:为所有破坏性操作上保险
这次打卡给我留下最深刻印象的,并不是某个高深的算法,而是"预览 + 回滚"这套朴素的安全机制。我在写第一版脚本时完全没有这个概念,直到自己亲自把一个文件误改后才意识到,破坏性操作的第一原则永远是"先让用户看清楚会失去什么"。
这个思路不仅限于文件重命名。后来我在做任何批量数据处理、批量修改配置、清理缓存这类操作时,都会下意识地先写一个预览函数,把将要发生的改动打印出来,再写一个备份函数,在改动前存一份快照。这个习惯让我在不少场景里避免了大麻烦。
6.3 打卡节奏比单次强度更重要
最后想聊聊坚持这件事。DHU打卡计划进行到D32,中间确实有过想偷懒的时刻,尤其是某天工作任务繁重的时候,还要硬挤出半小时做练习,会觉得是一种负担。但坚持下来后我发现,这种每天固定的"微练习"反而帮我维持了对代码的感觉。
D32这天让我重新理解了打卡的本质:它不是在考核你这一天能写出什么牛代码,而是借一个具体的任务,强制你保持思考和动手的频率。频率一旦断了,重新捡起来会很痛苦;频率保持住,哪怕每天只写三五十行,日积月累的效果也相当可观。
下一次打卡如果题目与文件处理相关,我准备尝试把规则引擎扩展成支持多规则的链式调用,比如先做前缀添加,再做扩展名过滤。这个目标不算大,但正好够下一天去琢磨。如果你也在刷类似的上机打卡,希望这篇D32的记录能给你一些参考,尤其是预览、冲突检测和日志回滚这三板斧,值得你下次写工具时直接抄走。