The remote job died with FileNotFoundError: config.yaml, and I could see that file sitting next to job.py. Have you ever trusted a relative open() because the tree looked obvious in your editor? I did, for two long days, while every local run stayed green. The bug was not missing YAML. The process current working directory was never the repository root.
This is a 48-hour field notebook, not a victory lap. I write down what I tried, what quietly lied, and what I would run again before blaming Linux, cron, or an assistant.
Hour 0: A File I Could Point At
The script was a boring batch job. It loaded a YAML file, computed a small report, and printed a one-line summary to stdout. On my laptop I launched it from the repo root, so open("config.yaml") felt like gravity.
# job.py — the version that wasted a weekend
import yaml
def main():
with open("config.yaml", encoding="utf-8") as handle:
config = yaml.safe_load(handle)
print(config["report_name"])
if __name__ == "__main__":
main()
Why would that fail on a server when ls showed the file? I asked that out loud more than once. The traceback named config.yaml with no directory prefix, which my brain translated as "the file is gone." It was not gone. The process had simply started somewhere else.
Hours 1–8: Permissions, Because Of Course
I treated it like an ops ticket. I checked mode bits, parent directories, and whether the service user could read the tree. None of that matched the traceback, but it felt productive.
ls -l config.yaml job.py
stat config.yaml
id
namei -l /opt/app/config.yaml
python3 -c "import os; print(os.access('config.yaml', os.R_OK), os.getcwd())"
The last command was the first honest clue, and I ignored it. os.access returned False in the service account's default shell, while os.getcwd() printed /tmp. Did I pause and ask what /tmp had to do with a repo under /opt/app? No. I assumed a deploy script had failed to copy YAML, then I copied the file again.
Copying into /opt/app did not fix a process that never looked there.
Hours 8–16: Docker, pytest, and a Polite Traceback
I rebuilt the image, then ran the unit suite. Pytest collected tests/test_job.py from the project root, so the relative open succeeded again. That green run felt like evidence. It was contamination.
# tests/test_job.py — false confidence
from job import main
def test_main_prints_report_name(capsys):
main()
captured = capsys.readouterr()
assert "daily" in captured.out
Have you noticed how pytest inherits the directory you launched it from? I had. I still did not force a hostile cwd. The suite proved that job.py could read YAML when the test runner already stood in the repo. Production never promised that.
I also let a coding assistant "fix" the traceback by wrapping open() in try/except and returning {}. That silenced the crash and shipped an empty report. Useful? Only if you enjoy debugging the wrong layer. Catching FileNotFoundError does not locate a file. It hides the cwd.
Hours 16–24: The Agent Shell Was a Different Planet
The assistant's terminal started inside the checkout, same as mine. Commands such as python job.py worked there, so the model kept proposing content fixes for YAML keys I had already validated. I needed a machine whose login cwd was not the repo.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. I used MonkeyCode's free model access and free server option as a throwaway Linux box, not as an oracle. I pasted the traceback, then I refused every patch that only caught the exception. The useful turn was a request for a cwd audit script I could run under /tmp.
# prove the failure outside the checkout
cd /tmp
python3 /opt/app/job.py
# FileNotFoundError: [Errno 2] No such file or directory: 'config.yaml'
ls -l /proc/self/cwd
python3 -c "import os; print(os.getcwd()); print(os.path.abspath('config.yaml'))"
os.path.abspath('config.yaml') printed /tmp/config.yaml. That single line ended the mystery, late, after I had already blamed rsync, SELinux, and a "corrupt" compose volume.
Hours 24–36: Pin the File to the Module, Not the Shell
Relative paths follow the process, not the source file. Path(__file__).resolve().parent follows the source file. I wanted the config that ships beside job.py, even if systemd, cron, or a CI step started in /.
# job.py — load config next to this module
from pathlib import Path
import yaml
CONFIG_PATH = Path(__file__).resolve().parent / "config.yaml"
def load_config(path: Path = CONFIG_PATH):
if not path.is_file():
raise FileNotFoundError(
f"config not found at {path} (cwd={Path.cwd()})"
)
with path.open(encoding="utf-8") as handle:
return yaml.safe_load(handle)
def main():
config = load_config()
print(config["report_name"])
if __name__ == "__main__":
main()
The error message now includes both the resolved path and Path.cwd(). Future me will not need another 48 hours to see the mismatch. Would I still print pwd in the job wrapper? Yes. Two signals beat one clever path helper.
The Artifact: A Cwd Audit You Can Re-Run
Do not argue with a traceback. Print the process view of the world, then make pytest lie on purpose.
# cwd_audit.py
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
def audit(relative_name: str = "config.yaml") -> dict:
module_dir = Path(__file__).resolve().parent
cwd = Path.cwd()
relative = Path(relative_name)
return {
"argv0": sys.argv[0],
"pid": os.getpid(),
"cwd": str(cwd),
"cwd_resolved": str(cwd.resolve()),
"module_dir": str(module_dir),
"relative_open_would_hit": str((cwd / relative).resolve()),
"file_sibling_would_hit": str((module_dir / relative).resolve()),
"relative_exists_from_cwd": (cwd / relative).is_file(),
"sibling_exists": (module_dir / relative).is_file(),
"proc_cwd": os.readlink(f"/proc/{os.getpid()}/cwd")
if sys.platform.startswith("linux")
else None,
}
if __name__ == "__main__":
print(json.dumps(audit(), indent=2))
Run it from two places before you touch YAML keys.
python3 cwd_audit.py
cd /tmp && python3 /path/to/repo/cwd_audit.py
Then pin the failure in CI so a friendly developer cwd cannot hide it.
# tests/test_cwd_contract.py
from pathlib import Path
import job
def test_load_config_survives_hostile_cwd(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
assert Path.cwd() == tmp_path
config = job.load_config()
assert "report_name" in config
def test_bare_open_still_fails_from_tmp(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
try:
open("config.yaml", encoding="utf-8").close()
except FileNotFoundError:
return
raise AssertionError("bare open() must not succeed from a temp cwd")
The second test looks petty. It documents the old contract so nobody "simplifies" the loader back to open("config.yaml") during a cleanup pass.
Decision table I keep in the repo
| Symptom | First check | Do not do first |
|---|---|---|
FileNotFoundError: config.yaml with no directory |
Path.cwd() and Path(__file__)
|
Rewrite YAML keys |
| Works in pytest, fails under cron/systemd |
WorkingDirectory= and the unit's pwd
|
chmod -R 777 |
| Works in an agent shell, fails in CI | Print abspath(relative) in both |
Catch FileNotFoundError and return {}
|
Docker run fails, docker exec works |
Container entrypoint cwd vs WORKDIR
|
Copy the file into /tmp "just in case" |
| Windows laptop green, Linux box red | Case of the filename and cwd | Blame the orchestrator by default |
Hours 36–48: What Broke After the Fix
The sibling-path fix is not magic. A test that wrote config.yaml into tmp_path and expected load_config() to see it now failed, because the loader no longer honors cwd. That was the point, and it still surprised one caller.
I also learned that __file__ is not always a real filesystem path. Frozen launchers, zipimport, and some -c snippets do not give you a stable parent directory. For those, an explicit CONFIG_PATH environment variable is the boring contract. Relative files remain a footgun there.
Cron added a second twist. The job ran as python3 /opt/app/job.py with a default cwd of the user's home, not /opt/app. Systemd was kinder only when WorkingDirectory= was set. I now print cwd in the wrapper regardless of the loader.
# systemd fragment worth keeping
[Service]
WorkingDirectory=/opt/app
ExecStart=/usr/bin/python3 /opt/app/job.py
Environment=PYTHONUNBUFFERED=1
Would WorkingDirectory alone have saved the old open("config.yaml")? Yes, until the next caller invoked the module from a different unit. I want both: an absolute config path, and a logged cwd.
What I Would Repeat
- Reproduce with
cd /tmpbefore I rebuild images or rotate secrets. - Print
cwd,Path(__file__), andabspath(relative)in the same JSON blob. - Add one pytest that
chdirs intotmp_pathand still loads config. - Reject assistant patches that catch
FileNotFoundErrorwithout printing those three paths. - Keep a tiny Linux box whose login directory is not my checkout, so the failure can exist.
The last item is why a free remote shell helped. My laptop starts in the repo out of habit. A disposable server does not care about my habits.
Limitations, and Who Should Skip This
This workflow assumes a real file on disk beside the module. It is the wrong tool for config that must come from the environment, a secret manager, or a read-only container path injected at runtime. If your process chdirs on purpose after start, pin config before that chdir, or you will audit the wrong directory.
Do not treat Path(__file__).parent as portable in PyInstaller one-file mode, namespace packages without a file, or modules imported from a zip. Do not use this pattern to load user uploads; those paths should stay explicit and untrusted. And please do not "fix" a missing file by returning empty dicts so the job can exit zero.
I still like YAML next to the script for small batch jobs. I no longer let the shell choose where that YAML lives. Forty-eight hours is a long time to learn that config.yaml in a traceback is not a location. It is a wish.
Top comments (0)