下载目录越来越大,项目里还混着缓存、安装包和旧日志。想整理之前,先回答三个问题:有多少文件、哪类文件占得多、最大的文件在哪里?
这篇文章用 Python 标准库写一个只读的“文件夹体检工具”。复制脚本后就能运行,不需要安装第三方依赖,也不会主动删除、移动或改写被扫描目录里的文件。
图 1:Python 官方标志,来源:Python Software Foundation 官方素材页。Python 标志为 PSF 商标,此处用于 Python 技术介绍。
文章目录
- 一、先定义“统计什么”
- 二、处理流程:扫描、汇总、保留 TOP N
- 三、完整代码:保存为 folder_check.py
- 四、怎么运行
- 五、用一组小文件核对结果
- 六、几个值得理解的实现细节
- 1. 为什么用 lstat(),而不是直接 stat()?
- 2. 为什么必须原地修改 dirs?
- 3. 为什么榜单使用最小堆?
- 4. 扩展名只是分类线索
- 5. 出错后应该怎么理解结果?
- 七、下一步可以怎么扩展
- 参考资料与配图来源
一、先定义“统计什么”
工具输出普通文件数量、按扩展名汇总的大小,以及大文件 TOP N。开始写代码之前,先把统计口径说清楚:
| 项目 | 本文的处理方式 |
|---|---|
| 文件大小 | 使用st_size,统计逻辑字节数 |
| 子目录 | 递归扫描,可按目录名排除 |
| 扩展名 | 转为小写,.JPG和.jpg合并 |
| 无扩展名文件 | 归入[无扩展名] |
| 符号链接 | 不跟随子目录中的符号链接,不计入链接文件 |
| 权限不足或文件消失 | 记录错误,继续扫描其他路径 |
| 大小单位 | 使用 1024 进制:KiB、MiB、GiB |
这里最容易踩坑的是第一项。稀疏文件的逻辑大小可能明显大于实际占用;硬链接的多个路径会被分别计数。因此,这份报告适合定位大文件,不等于磁盘空间核算,也不代表删除后必然释放同样大小的空间。
二、处理流程:扫描、汇总、保留 TOP N
图 2:本文脚本的处理流程。遇到读取错误会继续扫描;遍历结束后通过错误数量和退出码提示结果可能不完整。
职责分给三个标准库组件:os.walk()遍历目录,pathlib.Path处理路径,heapq维护大文件榜单。
如果只需要最大的 10 个文件,就没有必要为了排行榜保存所有文件记录。最小堆里最多放 10 条,堆顶是当前榜单中最小的一条;遇到更大的文件时再替换它。
三、完整代码:保存为 folder_check.py
下面的脚本面向Python 3.10 及以上版本,只依赖标准库。
"""Report regular-file sizes without modifying the scanned directory."""importargparseimportheapqimportosimportstatimportsysfromcollectionsimportdefaultdictfrompathlibimportPathdefhuman_size(size):value=float(size)forunitin("B","KiB","MiB","GiB","TiB"):ifvalue<1024orunit=="TiB":returnf"{value:.2f}{unit}"value/=1024defscan(root,top_n=10,excluded=()):iftop_n<1:raiseValueError("top_n must be positive")excluded=set(excluded)groups=defaultdict(lambda:[0,0])largest=[]file_count=total_bytes=error_count=0error_samples=[]defrecord_error(exc):nonlocalerror_count error_count+=1iflen(error_samples)<5:error_samples.append(str(exc))forbase,dirs,filesinos.walk(root,topdown=True,onerror=record_error,followlinks=False):dirs[:]=[namefornameindirsifnamenotinexcluded]fornameinfiles:path=Path(base)/nametry:info=path.lstat()exceptOSErrorasexc:record_error(exc)continueifnotstat.S_ISREG(info.st_mode):continuesize=info.st_size ext=path.suffix.lower()or"[无扩展名]"file_count+=1total_bytes+=size groups[ext][0]+=1groups[ext][1]+=size item=(size,str(path.relative_to(root)))iflen(largest)<top_n:heapq.heappush(largest,item)elifitem>largest[0]:heapq.heapreplace(largest,item)returnfile_count,total_bytes,groups,largest,error_count,error_samplesdefmain():parser=argparse.ArgumentParser(description="只读文件夹体检工具")parser.add_argument("directory",type=Path,help="要扫描的目录")parser.add_argument("--top",type=int,default=10,help="大文件数量,默认 10")parser.add_argument("--exclude",action="append",default=[],help="跳过指定名称的子目录,可重复传入")args=parser.parse_args()ifargs.top<1:parser.error("--top 必须大于 0")try:root=args.directory.expanduser().resolve(strict=True)except(OSError,RuntimeError)asexc:parser.error(str(exc))ifnotroot.is_dir():parser.error("目标必须是目录")count,total,groups,largest,errors,samples=scan(root,args.top,args.exclude)print(f"目录:{root}")print(f"普通文件:{count}个;逻辑大小合计:{human_size(total)}")print(f"扫描错误:{errors}次")print("\n按扩展名统计(逻辑大小降序):")forext,(number,size)insorted(groups.items(),key=lambdarow:(-row[1][1],row[0])):print(f"{ext:<12}{number:>6}个{human_size(size):>12}")print(f"\n大文件 TOP{args.top}:")forsize,nameinsorted(largest,reverse=True):print(f"{human_size(size):>12}{name!r}")forsampleinsamples:print(f"警告:{sample}",file=sys.stderr)return1iferrorselse0if__name__=="__main__":raiseSystemExit(main())四、怎么运行
扫描下载目录,默认显示最大的 10 个文件:
python folder_check.py"~/Downloads"扫描项目目录,显示 TOP 20,同时跳过常见的依赖与版本控制目录:
python folder_check.py"./my-project"--top20--exclude.git--excludenode_modules--exclude.venvWindows 可以这样传入路径:
py folder_check.py"D:\Downloads"--top 10在将 Python 命令命名为python3的环境中,将示例中的python换成python3即可。含空格的路径需要保留引号。
--exclude匹配的是子目录名称,区分大小写,并在每一层生效。例如--exclude cache会跳过扫描过程中遇到的所有名为cache的子目录。它不是通配符,也不会排除同名的普通文件;默认不排除任何目录。
五、用一组小文件核对结果
不要一上来就扫描整个磁盘。先在单独的练习目录中创建一组已知大小的文件,确认输出符合预期。
下面的准备代码会新建demo目录;如果目录已存在,会直接报错,避免覆盖已有练习数据:
frompathlibimportPath root=Path("demo")root.mkdir(exist_ok=False)(root/"data").mkdir()(root/"cache").mkdir()files={"notes.txt":1024,"photo.JPG":2048,"data/data.csv":4096,"README":0,"cache/tmp.bin":8192,}forname,sizeinfiles.items():(root/name).write_bytes(b"0"*size)这里的.JPG、.csv只是测试文件名,内容均为占位字节,不是真实图片或数据表。准备代码会创建文件,前面的扫描脚本只读取目录和元数据。
然后运行:
python folder_check.py ./demo--top3--excludecache输出如下,首行的绝对路径会随机器变化:
目录:/path/to/demo 普通文件:4 个;逻辑大小合计:7.00 KiB 扫描错误:0 次 按扩展名统计(逻辑大小降序): .csv 1 个 4.00 KiB .jpg 1 个 2.00 KiB .txt 1 个 1.00 KiB [无扩展名] 1 个 0.00 B 大文件 TOP 3: 4.00 KiB 'data/data.csv' 2.00 KiB 'photo.JPG' 1.00 KiB 'notes.txt'可以手算验证:1024 + 2048 + 4096 + 0 = 7168字节,即7 KiB。cache/tmp.bin被排除,README虽然为空,仍然是一个普通文件。
本文配套脚本已在独立临时目录中检查了总大小、TOP N 顺序、大小写扩展名合并、空文件、空目录、目录排除、文件与目录符号链接、失效链接、非法参数及遍历错误。这里没有做跨操作系统实测或大规模性能基准。
六、几个值得理解的实现细节
1. 为什么用 lstat(),而不是直接 stat()?
stat()默认跟随符号链接,可能把链接指向的文件再算一遍。lstat()获取链接本身的元数据,配合stat.S_ISREG(),只把普通文件加入统计。
遍历时的followlinks=False用来避免进入子目录符号链接;如果用户传入的起始目录本身是符号链接,resolve()会先解析到目标目录。Windows 的目录联接、其他重解析点以及挂载点不应直接等同于普通符号链接,本例没有提供跨文件系统隔离或完整的重解析点策略。
2. 为什么必须原地修改 dirs?
dirs[:]=[namefornameindirsifnamenotinexcluded]在topdown=True时,os.walk()会依据dirs列表继续向下遍历。使用切片赋值能修改它实际使用的列表;如果仅写成dirs = [...],只是重新绑定局部变量,不能阻止遍历进入这些子目录。
3. 为什么榜单使用最小堆?
设普通文件数为 F、榜单容量为 K。维护榜单的时间复杂度约为O(F log K),榜单自身额外空间为O(K);输出前再对这 K 条记录排序。
这并不意味着整个扫描过程只用O(K)内存。扩展名汇总需要存储每一种扩展名,os.walk()也会维护目录遍历所需的数据。更准确的说法是:脚本不为 TOP N 榜单保存全量文件记录。
4. 扩展名只是分类线索
Path.suffix取最后一段扩展名,所以backup.tar.gz会归入.gz;.gitignore这类文件通常没有这里所说的扩展名。若要识别组合后缀,可根据业务规则使用suffixes。
扩展名也不代表真实格式。改名后的图片仍可能是图片,脚本没有读取文件内容来判断类型。
5. 出错后应该怎么理解结果?
脚本最多打印 5 条错误详情,同时保留错误总数。退出码0表示本次扫描没有捕获到读取错误;1表示存在扫描错误、结果可能不完整;命令行参数错误通常返回2。
文件系统可能在扫描期间变化,所以输出是扫描过程中逐个观察到的结果,不是某一时刻的原子快照。对正在高速写入的日志目录,前后两次统计不同属于正常现象。
七、下一步可以怎么扩展
这个工具已经可以用于整理下载目录、排查项目缓存和定位大日志。继续练习时,可以给它增加 CSV 导出、按修改时间筛选,或为每个一级子目录生成汇总。
如果要加入清理功能,建议先生成候选清单与预览,再设计确认和恢复机制。统计报告已经能帮助定位问题,没有必要在第一次运行时就自动删除文件。
参考资料与配图来源
- Python 官方文档:os.walk
- Python 官方文档:pathlib
- Python 官方文档:heapq
- Python Software Foundation:官方标志素材及使用说明
创作说明:本文由 AI 辅助撰写,示例脚本已运行验证;流程图为本文配套绘制,Python 标志来源见图下注释。