Computing the project root twice: Path(__file__).parents[N] silently points elsewhere once the file moves
Finding the project root by counting levels up from __file__ is a second copy of where the file lives, and it doesn’t update when the file moves or the snippet is copied to a different depth. In Autarkia one module wrote its data into an auto-created directory under core/, froze the old data, and never raised an error.
Table of Contents
The N in Path(__file__).resolve().parents[N] really means “how many levels below the project root this file sits”. That fact belongs to the file system; once you write it as a number, the code holds a second copy. Move the file deeper, or copy the line into a file at a different depth, and N doesn’t change by itself, so what it computes is no longer the root. The path is still valid, os.makedirs(..., exist_ok=True) happily creates the wrong directory, and nothing raises.
01Two directories
One day I noticed two identically named data directories in the workspace: one under companion_data/ at the project root, one under core/companion_data/. The root one held 24 older events; the one under core/ held 4, all newer.
The module writing those events lives in core/services/<package>/, three levels below the root. This is how it found the root:
_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__) is core/services/<package>/; two levels up is core/, one short. The line was already like this the first time it appears in git history; whether it was copied from a shallower file or the file was moved one level deeper can no longer be determined.
02Why it made no noise
The write function looked like this:
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}")Three things stacked up so that it had no symptoms:
makedirs(exist_ok=True)is meant to let the first run write before the directory exists; here it created the wrong directory.- Reads went through the same wrong directory; no file meant no history, start empty. Reading and writing were consistently wrong, so the module saw no contradiction.
- Persistence was an overwrite keeping the last 24. The file in the correct location was never touched again and stayed frozen, looking like an intact history rather than an abandoned file.
The project already had a canonical function returning exactly this data directory; the module just didn’t use it. The fix was to switch to it, then run a one-off script that merged both sides by timestamp back into the right place and removed the directory under core/.
03Two months later it happened again
While writing this I checked every count-the-levels path in the repository: compare the file’s directory depth with N. parents[N] needs N equal to the file’s depth, and so does the number of .. after dirname(__file__). The dozen-plus in project code were all right, but five scripts were not.
They used to live in tests/scripts/ and tests/scripts/personas/. An archiving commit at the end of September moved them under core/character/configs/legacy/, two or three levels deeper. The commit message says references were updated, and the places that refer to these scripts were; the level counts inside them were not:
# 原来在 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]))When moving a file you search for “who refers to this file”, not “does this file encode where it lives”. The latter isn’t a reference but an assumption about location, and neither git mv nor an editor rename touches it.
04First ask: source of truth or projection?
I later started sorting this kind of thing with a crude test: if you delete this piece of data, can the system grow it back on its own?
- If it can, it’s a projection. A projection is derived one way only and must never be written to.
- If it can’t, it’s a source of truth. The whole system gets exactly one place that writes it.
- If it can but slowly, it’s a cache. It needs an invalidation condition and must be safe to throw away entirely.
For “how many levels below the project root is this file”, the source of truth is the file system, and the N in parents[N] is a projection of it, except that this projection is handwritten and nothing is responsible for re-deriving it. The same project has a more obvious case: the plan’s source of truth is plan.json, and plan.md is a projection generated from it for humans to read; the app’s checkbox endpoint wrote to plan.md, the backend regenerated plan.md from plan.json at two checkpoint reshuffles that same day, and the ticked items went back to unticked. So whenever something “gets changed back” or “lands somewhere else”, I first ask whether the thing being written or computed here is the source of truth or a projection.
The fix I’ve noted for the main project: compute the project root in exactly one function and call it everywhere else, plus a test that searches core/ for root-building patterns like parents[ and dirname(__file__), ".." and fails on any outside that function. Standalone scripts that can’t import project code yet should walk up from __file__ looking for a marker such as pyproject.toml instead of counting levels.