← 文章

项目根目录算了两遍:Path(__file__).parents[N] 在文件挪动后静默指向别处

约 5 分钟#Python#单一真源#文件路径

按 __file__ 往上数几级来找项目根,等于把文件所在的位置在代码里又抄了一份;文件挪了或者代码被照抄到别的深度,这份副本不会跟着变。Autarkia 里一个模块因此把数据写进了 core/ 下面一个自动建出来的目录,旧数据被冻住,全程没有报错。

目录

Path(__file__).resolve().parents[N] 里的 N,其实是「这个文件在项目根下面第几层」。这个事实本来由文件系统决定,写成数字以后,代码里就多了一份副本。文件被挪到更深的目录、或者这一行被复制到别的深度的文件里,N 不会自己变,它算出来的就不再是项目根。路径照样合法,os.makedirs(..., exist_ok=True) 会把那个错的目录建出来,什么都不会报错。

01两个目录

有一天我发现工作区里同时有两个同名的数据目录,一个在项目根的 companion_data/ 下,一个在 core/companion_data/ 下。项目根那份存着 24 条旧事件,core/ 下那份有 4 条,时间都更新。

写这些事件的模块在 core/services/<子模块>/ 下面,离项目根三级。它是这样算根目录的:

python
_project_root = os.path.abspath(
    os.path.join(os.path.dirname(__file__), "../..")
)
...
self._events_dir = os.path.join(_project_root, "companion_data", "<子模块>", "events")

dirname(__file__) 是 core/services/<子模块>/,再上两级是 core/,少了一级。这一行在 git 历史里第一次出现时就是这样,它是被照抄过来的,还是文件后来被挪深了一级,现在已经查不到。

02为什么一声不响

写文件的函数是这样的:

python
def _persist_events(self, conversation_id: str) -> None:
    events = list(self._events.get(cid) or [])
    try:
        os.makedirs(self._events_dir, exist_ok=True)
        with open(self._event_file_path(cid), "w", encoding="utf-8") as f:
            json.dump({"conversation_id": cid, "events": events[-24:]}, f)
    except Exception as e:
        self._logger.warning(f"持久化事件失败 {cid}: {e}")

三件事叠在一起,让它没有任何症状:

  • makedirs(exist_ok=True) 本来是为了第一次运行时目录不存在也能写,这里它把错的目录建了出来。
  • 读取也走同一个错的目录,找不到文件就当作没有历史,从空开始。读写一致地错,模块自己看不出任何矛盾。
  • 持久化是覆盖写、只留最近 24 条。正确位置那份文件从此不再被碰,停在了旧状态,看起来像一份完好的历史,而不是一份被废弃的文件。

项目里其实早就有一个规范函数专门返回这个数据目录,这个模块没用它。修法就是改用它,再写个一次性脚本按时间戳把两边的事件去重合并回正确位置,删掉 core/ 下的目录。

03两个月后它又出现了

写这篇的时候我把仓库里所有按 __file__ 数层级的写法逐个核对了一遍:拿文件路径里的目录层数和 N 比,parents[N] 要求 N 等于文件的深度,dirname(__file__) 后面跟的 .. 个数也一样。项目代码里的十几处都对,但有 5 个脚本不对。

它们原来在 tests/scripts/ 和 tests/scripts/personas/ 下,9 月底一次归档提交把它们挪到了 core/character/configs/legacy/ 下面,深了两三级。提交说明写着「并同步引用路径」,引用它们的地方确实都改了,脚本内部的层级数没改:

python
# 原来在 tests/scripts/,深 2 级,parents[2] 是项目根
# 现在在 core/character/configs/legacy/scripts/,深 5 级,parents[2] 是 core/character/
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))

挪文件的时候,人会去搜「谁引用了这个文件」,不会去搜「这个文件里有没有写着自己在哪」。后者不是引用,是一份关于位置的假设,git mv 和编辑器的重命名都不会碰它。

04先问它是真源还是投影

这类问题我后来用一个很土的判据先分类:把这份数据删掉,系统能不能自己长回来?

  • 能长回来,它是投影。投影只能单向派生,永远不许被写入。
  • 长不回来,它是真源。全系统只允许有一个写入点。
  • 能长回来但很慢,它是缓存。必须带失效条件,并且能被整个丢掉。

「这个文件在项目根下面第几层」的真源是文件系统,parents[N] 里的 N 是它的投影,只不过这份投影是手写的,没有任何东西负责重新派生它。同一个项目里还有一个更直观的例子:计划的真源是 plan.json,plan.md 是从它生成给人看的投影;App 的勾选接口写的却是 plan.md,后端当天两次检查点重排时都从 plan.json 重新生成了 plan.md,勾掉的项又变回了未勾选。所以凡是「改完又被改回去」或者「数据写到了别处」的问题,我会先问这一处写的、算的,是真源还是投影。

我给主项目留的修法是:项目根只允许在一个函数里算,其余地方一律调用它;再加一条测试,在 core/ 下搜 parents[ 和 dirname(__file__), ".." 这类拼项目根的写法,除那个函数外出现就失败。独立运行、还没法 import 项目代码的脚本,改成从 __file__ 往上找 pyproject.toml 这类标记文件,而不是数层级。

相关阅读