
昨晚十一点半DHU上机打卡群又弹出消息D32的打卡任务发布了。这一期不搞花活题目是用Python写一个批量文件重命名工具。乍一看感觉挺基础但真正上手之后我才发现这类工具是典型的看着简单、写起来全是细节的活儿。我大概花了两个晚上加一次完整上机调试才交出一个自己敢在日常里放心用的版本。这篇就把整个D32的解题过程、踩坑记录和最终代码思路完整拆一遍适合正在刷上机练习、准备课程设计、或者单纯想把手边重复劳动自动化掉的朋友参考。D32的任务要求其实很短写一个命令行工具把指定目录下的文件按照规则批量重命名支持预览、支持撤销。就这么一句话但拆开来看里面藏着文件遍历、正则匹配、安全防护、命名冲突处理这几个经典环节。这篇博文不打算只贴一段能跑的代码了事我会顺着自己上机时的思考顺序把每一步为什么这么写、中间踩了什么坑、最后怎么收尾的都讲清楚。1. D32的打卡任务一个看似基础但最容易翻车的练习1.1 任务拆解为什么选这个题先说背景。DHU上机打卡计划是我们这边一个坚持了很久的日常练习机制每天一道实操题不限制语言重点考察动手能力和排查问题的思路。D32这天给我的感觉是题目没有明确指定技术栈只说要实现批量重命名这就很考验你对文件系统操作的理解深度。我把需求拆成了四个子问题怎么安全地遍历一个目录下的所有文件而不误伤子目录和隐藏文件。怎么定义重命名的规则是做简单的前缀替换还是用正则表达式做更复杂的模式匹配。怎么防止因为命名冲突、非法字符、路径过长而导致操作中断甚至丢失文件。怎么给用户一个后悔的机会也就是预览模式和撤销机制。这四个子问题单拎出来任何一个都不算难但合在一起就会产生连锁反应。比如你用os.rename直接改文件名遇到目标文件名已经存在时不同系统会有完全不同的表现。我在第一次测试时就发现Windows 下会直接报错而某些网络文件系统上却可能静默覆盖。这种差异光靠看文档是体会不到的必须真上机跑一遍才有感觉。1.2 上机环境与工具约定我这次的运行环境是 Windows 11Python 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, recursiveFalse): 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, encodingutf-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, recursiveTrue) 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_pathrename_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, encodingutf-8) as f: json.dump(log_entries, f, ensure_asciiFalse, indent2) 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的记录能给你一些参考尤其是预览、冲突检测和日志回滚这三板斧值得你下次写工具时直接抄走。