文件该放在哪(2):仓库门口的三份声明
一个仓库被打开时,读的人只做三件事:看它叫什么、看它要干什么、看它需要什么。这三件事对应根目录上的三个位置——项目名、项目说明、依赖清单。
它们的共同点是都不参与运行。删掉 README,程序照样跑;把依赖清单写错,本地照样跑。正因如此,它们最容易失真:写的时候随手,改的时候忘记,等到别人用的时候才发现说的和做的不一样。
这一篇处理仓库门口的三份声明,判据只有一个:它说的是不是真的,以及能不能被验证。
一、三份声明,三种失真的方式
| 声明 | 位置 | 读者 | 失真之后 |
|---|---|---|---|
| 项目名 | pyproject.toml 的 name,与 src/ 下那层目录 |
安装工具、导入语句、终端 | 装得上但导不进,或者和别人的包撞名 |
| 项目说明 | 根目录 README.md |
第一次打开仓库的人 | 照着文档做,第一步就失败 |
| 依赖清单 | [project] dependencies |
装这个包的人、CI | 作者机器上能跑,别人装不上 |
三份声明排成一条进入路径:
flowchart LR
A["看到仓库名"] --> B["读 README<br/>这是什么、怎么装、怎么跑"]
B --> C["按依赖清单安装"]
C --> D["跑起来"]
B -.失败.-> E["放弃"]
C -.失败.-> E
这条路径上的任何一环断了,后面都不用谈。所以三份声明的位置不是「根目录看起来整齐」,而是它们是进入这个仓库的唯一路径,必须在最外层、唯一一份、且可验证。
二、项目名:一个名字要活过四套规则
名字在仓库里出现七次:仓库目录、project()、src/<name>/、PyPI 发行名、命令行入口、环境变量前缀、日志文件名。写的时候是同一个词,校验它的规则却来自四个互不相干的体系。
第一套,Python 标识符。 发行名带横线、包目录用下划线,是很常见的写法:
1 | $ python -c "import envlog-probe" |
- 在 import 语句里不是分隔符,是二元减号。所以「发行名带横线、导入名带下划线」不是风格偏好,是语法强制。
第二套,包装配规则。 同一个包,三种写法问 pip,回显都是它:
1 | $ python -m pip show envlog_probe | head -2 |
PEP 503 规定:连续出现的 -、_、. 折成一个 -,整体转小写。三行代码就能复现:
1 | import re |
这条规则意味着索引层面根本不存在「大小写不同的两个包」,my_cool_tool 和 my-cool-tool 也只能活一个。
第三套,文件系统。 装完之后名字在磁盘上被改写。发行名 envlog-probe,落地成了:
1 | $ ls .venv/Lib/site-packages/ | grep -i envlog |
用发行名去 grep 目录,得 0 条。任何「按发行名找安装目录」的脚本都会把装好的包判成没装。
第四套,大小写。 有个流传很广的说法:Windows 文件系统不区分大小写,所以 import 写错大小写也能跑。本机实测(Python 3.13.14,NTFS):
1 | Foo ok Foo.py |
三个模块名只差大小写,只有精确的那个能导入。导入系统按目录项里的真实字符逐个比对,文件系统是否宽容并不改变这一步。这条不是跨版本保证,但足够支撑一条实践:名字统一小写,别让「能不能导入」依赖运行环境。
四套规则之外,还有一项本地永远测不出来:注册表占用。语法错、导入错、大小写错,跑一次就暴露;撞名要等到发布那天。
1 | $ curl -s -o /dev/null -w "%{http_code}" https://pypi.org/pypi/envlog/json |
200 表示已被占,404 表示可用。五秒钟能查完的事,代价却要拖到发布前才出现。
名字是仓库里唯一一个「改一行、牵连全仓和已上线数据」的配置项。 它会走进 CMake 的 project()、脚本里硬编码的产物路径、日志文件名、Redis 键空间。前几处 sed 能覆盖,最后那处在数据库里,改名等于一次数据迁移。
三、项目说明:README 里的每条命令都要能跑
README 只需要回答三件事,顺序固定:
- 这是什么(一句话,不要讲架构)
- 怎么装(一条命令)
- 怎么跑(能立刻看到结果的最小例子)
dustlog 的 README 就是这三段。判断它合不合格,方法很简单:照着执行一遍。
1 | $ pip install -e ".[dev]" |
三条命令全部跑通,说明这份 README 是真的。
反例更容易见:很多 README 第一句就写 pip install <名字>,而这个包还没发布。在干净环境里试一下:
1 | $ python -m pip install --dry-run dustlog |
同一个命令,在作者机器上「已经装好了」所以看不出问题,在别人的空环境里直接失败。README 不是宣传页,是一份需要被执行验证的文档。 里面的每条命令都该有人真的跑过一遍,最好就是写它的那个人。
四、依赖清单:声明的是条件,不是结论
pyproject.toml 里写了两条:
1 | dependencies = [ |
装完之后,环境里实际有什么:
1 | $ pip freeze |
声明两条,装出六个第三方包。差额去哪了:
1 | $ pip show pytest | grep -E "^(Name|Requires)" |
那五个是 pytest 的传递依赖,不需要你声明,但会出现在你的环境里。反过来,tomli 那条根本没装——它的条件是 python_version < '3.11',而本机是 3.13。声明里写着,环境里没有。
这两件事说明依赖清单的性质:它声明的是约束条件,不是最终结论。 结论得靠安装器算出来。
三种精度要分开看:
| 形态 | 写的是什么 | 例子 | 谁在用 |
|---|---|---|---|
| 声明范围 | 允许的版本区间 | pytest>=8.0 |
装包的人、包管理器 |
| 锁定确定值 | 某个具体版本 | pytest==9.2.0 |
CI、可复现构建 |
| 环境快照 | 当前环境所有包 | pip freeze |
排障、迁移 |
最常见的错误是把三者混为一谈:用 pip freeze 的输出当依赖清单。那份输出里混着传递依赖、本机路径、以及只在当前平台生效的包,换台机器就装不上。
比版本精度更要紧的是完整性。给 cli.py 加一行 YAML 输出,忘了写进清单:
1 | $ dustlog --version |
照着报错补一条安装命令,程序立刻恢复正常:
1 | $ pip install pyyaml |
然后检查清单:
1 | $ grep -c yaml pyproject.toml |
本地能跑,只是因为本地顺手装过。 依赖清单和代码之间没有任何自动同步机制,漏一条不会被发现,直到有人在干净环境里安装。这是三份声明里唯一一份「错了不会立刻报错、但别人一定跑不起来」的。
五、三份声明的共同点:都能被验证
写完三份声明不算完,要能给它们各自找一个验证动作:
| 声明 | 验证方式 | 失败长什么样 |
|---|---|---|
| 项目名 | 查一次注册表 + 装一次看能否 import | No matching distribution / ModuleNotFoundError |
| 项目说明 | 照着 README 从头执行一遍 | 第一步就报错 |
| 依赖清单 | 空环境装一次,跑最小例子 | ModuleNotFoundError |
三个动作都不需要额外工具,成本分别是五秒、两分钟、五分钟。声明式文件的价值不在于写得多漂亮,而在于它说的和做的一致——而一致性只能靠执行来证明。
六、三条能带走的
一、名字要同时过四套规则,最后再查一次注册表。 标识符、PEP 503、文件系统落地名、大小写,四关都过的名字才稳;注册表占用是唯一本地测不出的项,定名前发一个 HTTP 请求即可。
二、README 里的每条命令都要真跑过。 最典型的失效是 pip install <未发布的包>——在作者机器上装好了所以看不出来,在别人机器上是第一步就失败。
三、依赖清单是约束,不是快照。 pip freeze 混着传递依赖和本机路径,不能当清单用;清单漏一条不会报错,只会在干净环境里报出 ModuleNotFoundError。
本质一句话:仓库门口这三份声明,决定别人能不能进来——它们不参与运行,所以不会被运行检验,只能靠人主动验证一次。

