Python代码库静态分析工具:一条命令看清依赖与环境变量

📅 发布时间:2026/10/14 13:01:49
Python代码库静态分析工具:一条命令看清依赖与环境变量
接手一个不熟悉的代码库我通常要花至少半小时干这么几件事翻package.json或者requirements.txt找.env.example看 README 里的启动命令再统计一下目录里到底有多少文件、哪些是大块头。次数多了就觉得这活儿太机械于是我用 Python 写了个小工具代号叫rea。名字虽然随便但全称是Repository Environment Analyzer作用就是一条命令给我交底依赖是什么、环境变量缺没缺、项目里哪些文件占地方、启动脚本在哪儿。它不解决具体业务问题只解决一个“这项目大概什么情况”的认知问题。这篇文章把rea从灵感到实现、再到我实际使用的感受都聊一遍。1. 从乱翻文件到一条命令交底rea 的功能边界1.1 我到底需要它解决什么问题很多开发者拿到一个陌生项目第一反应是跑起来试试。但跑起来之前你其实需要回答几个问题这个项目用了哪些第三方依赖版本分散不分散我在本地需要配置哪些环境变量哪些是必填的项目里最大的文件是哪几个是不是有人误提交了构建产物有没有现成的启动脚本还是必须自己拼命令这些问题单个看都不复杂但挨个查一遍就特别琐碎。我最早是写一长串 shell 命令组合每次粘贴到终端里后来觉得不如做成一个固定的工具久而久之就有了rea。1.2 明确不做什么避免做成大而全的监控系统第一版rea确实差点跑偏我想把进程占用、端口监听、虚拟环境激活这种运行态信息也塞进去。后来想明白一个道理静态排查和运行态监控是两码事。rea只做静态分析只管你ls和cat就能看到的信息不做任何会改变系统状态的操作。这样有三点好处命令执行足够快基本在几秒内能完成可以做批量扫描比如对一堆仓库统一跑一遍收集依赖画像不容易误伤比如误杀进程、误写环境变量这类事故基本不会发生。所以rea的核心边界就是三条只读扫描、不修改文件、输出固定格式报告。后来补的rea env --diff也只是输出提示不会主动写.env。2. 目录扫描策略先认清项目长什么样2.1 扫描范围的兜底逻辑忽略规则与自定义忽略rea的第一个模块是磁盘扫描目标是把目录里的文件类型、大小分布和大文件捞出来。这个功能听着简单但第一步就卡在“扫描范围”上。如果从项目根目录开始递归你会把.git、node_modules、dist、__pycache__这些目录全部算进去。这对统计毫无意义还拖慢速度。更严重的是某些目录里的文件数量以万为单位会导致输出报告被无关信息淹没。我的做法是内置一套默认忽略规则DEFAULT_IGNORE_DIRS { .git, .hg, .svn, .idea, .vscode, .next, .nuxt, node_modules, __pycache__, dist, build, target, .venv, venv, env, .tox, .pytest_cache, .mypy_cache, coverage, htmlcov, .gradle, .cache, }同时提供--no-default-ignore和--ignore-dir参数让用户按需调整。为什么默认忽略.venv而不是venv因为 Python 社区里.venv越来越常见甚至有些项目用.venv作为约定俗成的虚拟环境目录不放进去的话统计结果会很别扭。2.2 文件类型统计与大文件预警扫到文件之后按扩展名分组分别统计文件数量和总大小。这一步对定位“仓库为什么会这么大”很有帮助。比如*.pdb有 1.2G那大概率是谁不小心把调试符号提交了*.zip有 800M可能是误提交了压缩包。除了分组统计rea scan还会输出一个大文件 Top 15 列表。这里有个容易被忽略的细节大文件的判断不能只按单文件大小还得考虑跟整个项目大小的比例。因此在实现里我同时输出绝对大小和相对占比方便判断它是不是异常文件。打印文件的尺寸时用人类可读的方式而非纯字节数比如1.2 MB但保留原始字节数供--json输出使用。这个小设计帮我省了不少事因为后续做报告对比时光靠字符串解析容易出错。2.3 关键代码基于 os.scandir 的递归扫描这里放出rea scan目录遍历的核心代码。我选择os.scandir而不是os.walk的一个重要原因是scandir在列目录时能直接拿到文件条目且支持惰性迭代配合并发扫描能减少一次额外的lstat调用。import os def iter_project_files(root_path, ignore_dirsNone, use_default_ignoreTrue): ignore set(ignore_dirs or []) if use_default_ignore: ignore.update(DEFAULT_IGNORE_DIRS) stack [root_path] while stack: current stack.pop() try: entries list(os.scandir(current)) except PermissionError: continue for entry in entries: if entry.name in ignore: continue if entry.is_dir(follow_symlinksFalse): stack.append(entry.path) elif entry.is_file(follow_symlinksFalse): yield entry.path这里特意把follow_symlinks设成了False避免符号链接把扫描带出项目边界或者引入循环引用。第一版我没管这一点结果一个带软链的测试项目让rea扫了十分钟才回来原因就是链上套链。3. 依赖解析多语言描述文件识别与你以为的简单3.1 先找出项目里所有依赖描述文件rea deps的目标是输出项目依赖清单。第一步不是解析而是“找文件”。现代项目经常是 monorepo根目录一个package.json子目录又有各自独立的package.json直接只读根目录会丢掉大量信息。我维护了一个常见依赖描述文件的映射表文件生态解析方式package.jsonNode.js / 前端JSONrequirements.txtPython按行解析pyproject.tomlPythonTOMLgo.modGo正则提取包路径Cargo.tomlRustTOMLGemfileRuby正则/简单语法解析pom.xmlJavaXMLcomposer.jsonPHPJSONbuild.gradleJava / Android极简正则匹配默认情况下只在根目录寻找这些文件因为大多数项目依赖是集中在根目录的。如果你确实在子项目里也有加--deep参数rea才会全项目扫描。这个设计后面会提到它其实是我踩坑后做出的妥协。3.2 解析 package.json 和 requirements.txt 的具体逻辑对package.json读取dependencies和devDependencies字段合并成清单并标记区分生产依赖与开发依赖。有些项目还有peerDependencies它更偏插件场景所以单独放一组不会污染普通依赖列表。requirements.txt的解析看着简单实际坑点在注释和行内空格def parse_requirements(content): deps [] for raw_line in content.splitlines(): line raw_line.strip() if not line or line.startswith(#): continue # 去掉行内注释 if # in line: line line.split( #, 1)[0].strip() if not line: continue # 跳过文件引用 if line.startswith(-r ) or line.startswith(--requirement): continue # 从 distutils/受管格式里剥离版本比较 marker None for sep in (, , , ~, !, ,): if sep in line: line, marker line.split(sep, 1) break deps.append({ name: line.strip(), specifier: (marker.strip() if marker else ), }) return deps这种剥离方式比较保守。看到pkg1.2.3我会拆成pkg具体版本看到pkg1.0就拆成pkg1.0。这样后续做版本对比时比较容易。3.3 与解析失败的战争编码问题与会污染的格式实际跑起来才发现很多依赖文件没法按教科书式解析。最常见的三类问题编码问题某些遗留项目是 GBK 编码直接open()报UnicodeDecodeError。解决方案是用二进制读入再尝试 UTF-8、GBK、latin1 逐级回退实在不行就在报告里标为“无法解析”。非标准 JSON一些手写的package.json末尾多了逗号或者用了注释。标准库json直接拒收我只能再包一层容错先尝试标准解析失败后再用正则把注释和尾逗号剥掉实在不行才放弃。深度不够的 TOMLpyproject.toml中依赖部分除了dependencies还有optional-dependencies、dev-dependencies等结构。解析时不能只找一个键得递归查找所有可能包含依赖的键。另外go.mod用正则提取module和require块时要特别小心replace指令。因为replace会改变实际依赖来源如果只提取包路径得到的依赖清单跟实际构建结果会有偏差。我选择在输出里保留一个replaced标记而不是强行合并。4. 并发扫描与进度反馈性能不是玄学4.1 为什么需要并发实测数据先看一个实际数字一个包含五万个文件的老项目用纯单线程递归遍历耗时六点几秒。这个时间其实可以接受但rea不只做文件遍历后面还要对每个文件做分类、忽略规则匹配、大文件聚合单线程总耗时会被拉长到十几秒。在交互式命令行里十几秒的等待体验相当糟糕。尤其当用户只是想知道依赖画像却要等个十几秒他会觉得这工具有问题。所以我把扫描部分改成并发执行。选ThreadPoolExecutor还是ProcessPoolExecutor按照常规认知文件 I/O 密集任务用多线程就够了因为瓶颈在磁盘而 GIL 基本不会被释放的问题卡住。实测也验证了这个判断用线程池处理三万个文件耗时比单线程下降约百分之四十但改用进程池后内存开销暴涨收益却很小。所以我最终选择了线程池。4.2 共享进度的实现并发之后的新问题是进度条不知道总任务量。好在目录树的叶子节点文件数量可以在遍历时提前统计然后在线程提交阶段按比例更新进度。我用了最简单可靠的模式主线程扫描目录构建任务列表同时通过queue.Queue把文件路径发给工作线程工作线程处理完文件后返回(path, size)主线程负责聚合和更新进度条。from concurrent.futures import ThreadPoolExecutor import os import tqdm def scan_with_progress(root_path, max_workersNone): files list(iter_project_files(root_path)) total len(files) max_workers max_workers or min(32, os.cpu_count() * 2) result {} def _work(path): try: st os.stat(path) return path, st.st_size except OSError: return None with ThreadPoolExecutor(max_workersmax_workers) as pool: futures pool.map(_work, files) for item in tqdm.tqdm(futures, totaltotal, descscanning): if item: result[item[0]] item[1] return result使用pool.map的好处是任务提交和结果返回都保持有序进度条不会乱跳。当然如果你在极小的项目上跑并发反而会有线程创建开销。因此我把并发上限设置成min(32, os.cpu_count() * 2)避免线程数过多导致上下文切换开销超过 I/O 收益。4.3 实际效果与取舍跑同一个五万文件项目单线程六秒四线程三秒左右十六线程也只有两秒多再往上就没明显收益了。所以并发不是越高越好。后来我在一个网络磁盘上试过并发拉到三十二反而比八线程更慢原因是网络文件系统对并发访问有限制过高的并发会引起大量重试。rea现在默认并发数是os.cpu_count() * 2但允许用户用--workers覆盖。如果是内网盘或者远程挂载目录我强烈建议把并发数调到 4~8。5. 命令行设计的手感问题5.1 子命令怎么划分更顺手rea最早是一个大命令后面跟很多--flag比如rea --scan-dir . --parse-deps --check-env。用了几次就感觉很别扭因为调用方式跟用ls完全不搭无法快速记忆。后来我重构为四个子命令子命令作用典型用法rea scan扫描目录输出文件类型、大文件、总大小rea scan /path/to/reporea deps解析依赖描述文件输出依赖清单rea deps --deeprea env对比.env.example与当前环境变量rea env --diffrea report把以上结果汇总成 Markdown / JSON 报告rea report -o report.md这样划分的原因很简单每个子命令对应一个“我想知道什么”的问题而不是对应实现模块。用户不需要关心底层怎么实现只要能从命令名猜到它做什么就行。5.2 输出格式的克制命令行工具最容易犯的毛病是输出五颜六色、信息过载。rea的默认输出采用纯文本大概长这样Scanned 38,120 files, total size 1.3 GB Top file types: .ts 1,204 files 312.1 MB .js 8,417 files 268.4 MB .png 67 files 220.8 MB Largest files: 1. /dist/main.bundle.js 12.2 MB 2. /data/sample.mp4 11.5 MB如果你用--json输出就是一个 JSON 对象用--md会生成一份完整的 Markdown 报告。我刻意不让控制台输出包含 Markdown 标记因为大多数人是想直接快速浏览而不是复制到文档里。5.3 退出码与错误信息为了能顺利嵌入 CI 脚本rea的退出码定义必须明确0扫描完成无严重问题1有配置错误或系统错误2扫描完成但发现警告比如解析失败、环境变量缺少必填项。此外错误信息统一输出到stderr而正常结果输出到stdout。这个细节在集成到流水线时非常重要很多人忽略后导致结果管道失真。6. 实际使用中踩过的坑和几个实用的后续扩展6.1 被 .gitignore 坑的第一版第一版rea scan为了实现“尊重项目习惯”直接读取.gitignore来作为忽略规则。想法很好但结果很惨很多.gitignore里写了*.log、*.tmp却不会忽略.env.example。我把.env.example当成普通文件这没问题但某些项目的.gitignore把根目录下的.env*整体忽略了导致rea scan完全跳过环境模板文件。后来我意识到扫描工具不能完全复用 Git 的忽略规则因为两者目的不同Git 不想提交的东西未必对目录分析没有价值。比如.venv里的文件对分析整个项目大小还是有一定意义的至少要知道它们占了多少空间。因此现在默认忽略列表是硬编码的配合截图里那些典型目录而不是动态加载用户的.gitignore。6.2 在自建的测试项目里翻车依赖文件嵌套我造了个模拟 monorepo 的测试项目子项目apps/web和packages/kit都有独立的package.json。第一版rea deps会递归扫描这些文件并输出所有依赖合集。结果发现大部分用户其实只想知道根项目的直接依赖而不是几十个内部依赖交织在一起的清单。更麻烦的是很多子项目的依赖是内部包名对排查问题没有直接帮助。最终我改成默认只扫描根目录的依赖描述文件只有显式加--deep才递归子目录。这个转变非常典型工具不是越全越好而是越符合直觉越好。6.3 给 rea 增加compare能力的设想用了一段时间后我最常产生的需求是对比两个分支或两个目录之间的依赖差异。比如发布前想知道生产环境用的requirements.txt和当前分支差了什么。目前的rea只支持把两次输出保存成 JSON再用diff脚本比较但这比较繁琐。我下一步计划增加rea deps --compare file1.json file2.json专门输出新增、删除、升级的依赖列表。这属于很自然的一个扩展点因为项目版本升级时这种对比特别常用。6.4 最后谈谈我的实际使用体会我在实际使用中发现rea最大的价值不是帮我省了多少时间而是改变了我接手项目的流程。以前我会带着“猜谜”的心态去翻文件很容易被局部信息带偏现在先跑一遍rea scan rea deps rea env拿到一张简门口清单再看代码就有的放矢得多。如果你也经常需要维护多个仓库或者动不动要帮同事看项目我建议你花一个周末把这种小工具做出来不需要发布也不需要考虑生态只要对你自己的工作流有真实帮助它就值得存在。