DEV Community

Taylor Wang
Taylor Wang

Posted on

I Trusted CWD for 48 Hours. The Worker Had Already Left the Repo.

Have you ever watched a config loader succeed in your editor and fail the moment a background job touched it? I did, for forty-eight hours, and I wrote the wrong fix three times before the paths lined up. The worker had already called os.chdir, or the unit file had started it from a home directory I never print. The same yaml lived in git, the same import succeeded, and FileNotFoundError appeared only after launch.

I keep field notes when a bug lasts longer than a coffee, because memory edits the timeline. This one earned two days because every local run printed loaded config.yaml while the remote worker raised. The file was in git, the filename matched, and I still needed a second machine to stop guessing. Why did I keep treating "here" as a moral property of the repo instead of a process attribute?

Day one was a tour of the wrong suspects

I blamed packaging first, then a volume mount, then a stale install in site-packages. None of those theories survived a find plus a checksum of the yaml. The loader I shipped looked obvious in the editor, which is exactly how it survived review. Here is the function I kept defending while the worker looked somewhere else.

from pathlib import Path
import yaml

def load_config():
    path = Path("config.yaml")
    with path.open("r", encoding="utf-8") as handle:
        return yaml.safe_load(handle)
Enter fullscreen mode Exit fullscreen mode

That relative path is not "the file next to my module," and it never was. It is whatever the process currently calls the working directory, including / after a service chdir. I printed Path.cwd() in the editor and saw the repo root, then printed it in the job and saw a home directory. Same bytecode, same image, and a completely different answer to the question "where is here?"

What I tried, in the order I actually tried it

I number the attempts in the notes so I cannot pretend the day was systematic. The list is not heroic. It is a record of how long I protected a bad invariant.

  1. I copied config.yaml into /tmp and added a debug print. The job still opened the relative name, so nothing moved.
  2. I exported CONFIG_PATH in an interactive shell and forgot the worker unit does not inherit that session.
  3. I asked a model to make the path robust, and it wrapped the same relative name in Path(...).resolve().
  4. I reproduced the launch on a scratch machine whose default directory was not my repo.

Step three wasted most of the first evening, and I still wince at it. resolve() does not invent a sibling of __file__; it only turns a cwd-relative miss into an absolute miss. Would you have caught that in review if the traceback looked "more complete" after the call? I like to think so. I did not.

The scratch server is where the story stopped lying

I needed a process that did not start in the repo root, because my laptop kept rescuing me. Every local command began in the project directory, so Path("config.yaml") kept finding the file by accident. Disclosure: This article was prepared as part of MonkeyCode's product outreach. I used MonkeyCode's free model access to draft a launcher that changes directory before import, then ran it on the free server option so laptop habits could not hide the split.

The model suggested Path(__file__).parent / "config.yaml" on the second try, after I pasted the diagnostic table instead of the original function. Feeding it the cwd printout mattered more than asking it to be robust in the abstract. Have you noticed how often a model polishes the wrong invariant when you only paste the helper?

Artifact: the audit I now drop into any file-loading job

This module does not fix the path. It makes the lie expensive by printing both interpretations before any yaml parse. I treat it as field equipment, not as a framework.

# cwd_audit.py
from __future__ import annotations

import os
import sys
from pathlib import Path

def audit(label: str) -> dict[str, str]:
    here = Path(__file__).resolve()
    report = {
        "label": label,
        "argv0": sys.argv[0],
        "pid": str(os.getpid()),
        "cwd": str(Path.cwd()),
        "file": str(here),
        "module_dir": str(here.parent),
        "config_from_cwd": str((Path.cwd() / "config.yaml").resolve()),
        "config_from_file": str((here.parent / "config.yaml").resolve()),
        "config_from_env": os.environ.get("CONFIG_PATH", ""),
    }
    for key, value in report.items():
        print(f"{key:18} {value}")
    return report

if __name__ == "__main__":
    audit("cli")
Enter fullscreen mode Exit fullscreen mode

Then I force a launch that does not start in the repo. This is the reproduction, not a mystery ritual. Use an absolute path to the audit file after the chdir, or the interpreter will not even find the script.

python - <<'PY'
import os, runpy
os.chdir("/")
runpy.run_path("/absolute/path/to/repo/cwd_audit.py", run_name="__main__")
PY
Enter fullscreen mode Exit fullscreen mode

On my laptop, config_from_cwd still looked fine if I skipped the chdir. After an explicit os.chdir("/") on the scratch server, config_from_cwd pointed at /config.yaml while config_from_file still pointed at the repo. That single split ended the packaging theories. Can a checksum of yaml compete with two printed paths that disagree?

The loader I should have written on hour one

The default now lives beside the module. An explicit override still wins when operations needs a different file. The error message carries both paths, because future me will not want another two-day tour.

from pathlib import Path
import os
import yaml

def config_path() -> Path:
    override = os.environ.get("CONFIG_PATH")
    if override:
        return Path(override).expanduser().resolve()
    return Path(__file__).resolve().parent / "config.yaml"

def load_config() -> dict:
    path = config_path()
    if not path.is_file():
        raise FileNotFoundError(
            f"config not found at {path}; cwd={Path.cwd()} file={__file__}"
        )
    with path.open("r", encoding="utf-8") as handle:
        data = yaml.safe_load(handle) or {}
    if not isinstance(data, dict):
        raise TypeError(f"config must be a mapping, got {type(data)!r}")
    return data
Enter fullscreen mode Exit fullscreen mode

I still refuse a silent fallback to cwd. A missing override should be loud. A missing sibling file should be loud. Quiet success from a random directory is how this bug lived.

A test that refuses to trust your shell

Pytest will also start in a directory you did not expect, especially under an IDE. I stopped treating the real cwd as a fixture and started moving it on purpose. The test below is labeled as an example you should point at your own module path.

# test_config_path.py
from pathlib import Path
import importlib.util

def load_audit(repo_root: Path):
    path = repo_root / "cwd_audit.py"
    spec = importlib.util.spec_from_file_location("cwd_audit", path)
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod

def test_chdir_does_not_move_module_dir(tmp_path, monkeypatch):
    repo = Path(__file__).resolve().parent
    monkeypatch.chdir(tmp_path)
    report = load_audit(repo).audit("pytest")
    assert Path(report["cwd"]) == tmp_path
    assert Path(report["module_dir"]) == repo
    assert Path(report["config_from_cwd"]) == (tmp_path / "config.yaml").resolve()
    assert Path(report["config_from_file"]) == (repo / "config.yaml").resolve()
Enter fullscreen mode Exit fullscreen mode

If that test fails, your layout is weirder than this note, and you should print the whole report before editing the loader. If it passes, you still must not load config with Path("config.yaml"). Passing only proves the two interpretations can diverge.

Decision table I wish I had taped above the terminal

Situation Use this Do not use this
Default file shipped beside the module Path(__file__).resolve().parent / name Path(name) or Path.cwd() / name
Operator-supplied file on each machine CONFIG_PATH, then .resolve() A silent fallback to cwd
CLI that is documented to run from a project root An explicit --config argument A hidden cwd assumption
Tests tmp_path plus monkeypatch.chdir Whatever directory pytest inherited
Generated logs, reports, caches A directory you create and own Writing next to __file__ inside an installed package

The last row saved me from the rebound bug. Once I "fixed" input by using __file__, I almost started writing reports next to the module too. Installed packages are often not writable, and cwd is the wrong default for input. Output wants a dedicated directory. Input wants a stable origin. Those are different jobs.

What broke while I was being clever

The model-generated resolve() made a relative missing file look absolute and still missing. That wasted an evening because the traceback felt more serious than a cwd mixup. Absolute and wrong is still wrong, just harder to see in a hurry.

__file__ is not always present either. A frozen binary or exec of a string will not give you a useful parent. I hit that when I pasted a snippet into an agent shell and the audit had nothing to resolve. The guard I added is boring, which is what I wanted after forty-eight hours.

def module_dir() -> Path:
    file = globals().get("__file__")
    if not file:
        raise RuntimeError("no __file__; pass CONFIG_PATH explicitly")
    return Path(file).resolve().parent
Enter fullscreen mode Exit fullscreen mode

Symlinks can still surprise you, because resolve() follows them and absolute() does not. If deploy uses a current symlink, following it is usually what you want. If you need the symlink path itself, you wanted absolute(). I mixed those two names on day two. Have you done that under a deadline and then blamed the mount?

What I would repeat

I would print cwd and __file__ before I blame packaging, wheels, or a registry mirror. I would reproduce with an explicit os.chdir("/") instead of trusting a local shell that starts in the repo. I would ask a model only after those two lines exist in the log, not before.

I would keep CONFIG_PATH as an override, not as the only mechanism. Defaults that live beside the module survive a forgotten export. Overrides still matter when a host needs a different yaml. I would not ask anyone, human or model, to make paths robust with no failing output attached.

Limitations, and who should skip this

If your process is a short CLI that documents "run me from the repo root," a relative cwd path can be a feature. Do not "fix" that without changing the README, or you will break scripts that feed configs from the current directory on purpose. This note is for workers, agents, and services whose launch directory you do not control.

If __file__ is unreliable in your runtime, this Python-shaped advice will not travel cleanly. If config is fetched from a remote store, arguing about local paths is the wrong layer. A prettier helper will not help there.

Free model access will not save you from a bad invariant. It will generate a cleaner version of whatever you assumed. The free server option helped me only because I used it as a cwd I did not own. If you need that same split, it is one way to get a process that does not start in your repo; the audit is still the part I would keep after the vendor name is gone.

Top comments (0)