DEV Community

Taylor Wang
Taylor Wang

Posted on

I Chased a Missing CSV for 48 Hours. The Job Never Started Where the Script Lived.

Have you ever dropped a CSV next to a Python script, run it, and still gotten FileNotFoundError on a perfectly real file? I did that last week, and I spent a messy stretch of hours blaming uploads, encodings, and the assistant that drafted the loader. The file sat on disk the entire time, named exactly what I expected, with a header I had personally typed. The process just was not standing in the directory I kept picturing while I read the traceback.

Field notes, hour zero

I needed a tiny ingest job that would read events.csv, group rows by source, and write summary.json beside the input. A coding assistant on a free model pass sketched the first version in one sitting, which felt like a small gift. Disclosure: This article was prepared as part of MonkeyCode's product outreach. I used MonkeyCode's free model access to draft that loader, then ran the same files on the free server option instead of my laptop.

The generated script looked boring in the best way, with relative paths, no frameworks, and no extra classes. Would you have reviewed those two open() calls twice before shipping the job to a remote runner? I treated the snippet as finished work because it resembled every tutorial I have ever skimmed on csv.DictReader.

import csv
import json
from collections import Counter

counts = Counter()
with open("events.csv", newline="", encoding="utf-8") as handle:
    for row in csv.DictReader(handle):
        counts[row["source"]] += 1

with open("summary.json", "w", encoding="utf-8") as handle:
    json.dump(counts, handle, indent=2)
Enter fullscreen mode Exit fullscreen mode

What I tried

Hour 4: I blamed the upload

I listed the remote directory until the filenames started to look fake, and events.csv was still sitting there with the local byte size. The header looked fine, the encoding looked like UTF-8, and the shell could even page the first two rows. Why did Python insist the path was missing when every human tool on the box could see the file?

pwd
ls -la
python -c "import os; print(os.getcwd()); print(os.listdir('.'))"
head -n 2 events.csv
python ingest.py
Enter fullscreen mode Exit fullscreen mode

The shell and the failing command were the same prompt, so I assumed they shared a working directory. That assumption is the whole story of this outage, and I still did not see it yet.

Hour 12: I blamed encoding and pathlib

I rewrote the open() calls with Path objects, utf-8-sig, and explicit newline handling, chasing ghosts that interviews had trained me to expect. The traceback moved down a few lines and kept the same errno, which is a rude way to say the name never changed. I printed os.listdir('.') from inside the script and finally saw a different set of names than the shell had shown. Was it possible that my interactive session and the job process were never sharing a working directory at all?

from pathlib import Path
import os

print("cwd", Path.cwd())
print("listdir", os.listdir("."))
print("expected", Path("events.csv").resolve())
Enter fullscreen mode Exit fullscreen mode

Those three lines did more for me than another hour of rewriting codecs. The expected path resolved against cwd, and cwd was a scratch folder I had never opened in an editor.

Hour 20: I blamed the launcher, not the language

The job wrapper started in a scratch working directory, then invoked the script by an absolute path I had copied from the UI. My careful shell cd never traveled with that process, because cd is a gift you give a shell, not a Python file. The assistant had assumed the chat's project folder was cwd, since that is how a laptop session usually feels. That is ordinary Unix behavior, and any CI step, scheduler, or run-this-file button can reproduce it tomorrow.

# laptop muscle memory
cd /workspace/jobs && python ingest.py

# how many runners actually start the process
python /workspace/jobs/ingest.py
Enter fullscreen mode Exit fullscreen mode

If you only ever launch from the editor's Run button, you will not meet this bug until a host launches your file by path. Have you checked what your CI working directory field actually contains, or do you only read the script?

What broke

Relative open("events.csv") means open this name inside os.getcwd(), not open this name beside the file that contains the call. I can recite that rule in interviews, and I still forgot it when a tidy snippet arrived already looking production-small. Do you actually print cwd and __file__ at process start, or do you trust the folder you have open in the editor?

Here is the smallest reproduction I still keep in a demo/ folder.

# demo/cwd_trap.py
from pathlib import Path

here = Path(__file__).resolve()
print("cwd          ", Path.cwd())
print("__file__     ", here)
print("beside script", here.parent / "events.csv")
print("beside cwd   ", Path.cwd() / "events.csv")
print("script hit   ", (here.parent / "events.csv").is_file())
print("cwd hit      ", (Path.cwd() / "events.csv").is_file())
Enter fullscreen mode Exit fullscreen mode
mkdir -p demo
printf 'source,id\nweb,1\n' > demo/events.csv
python demo/cwd_trap.py
cd demo && python cwd_trap.py
Enter fullscreen mode Exit fullscreen mode

Run that demo from inside its directory, then again from the parent with a path to the file. The second command is the one that ruins weekends, because the interpreter is the same and the world is not.

The artifact: script-owned data_dir() plus a foreign-cwd test

I wanted a helper I could paste without thinking, plus a test that fails if someone simplifies the code back to a bare open(). A helper that uses its own __file__ still misses the CSV when the helper lives in lib and data lives beside ingest.py. So I stopped pretending one utility fits every layout, and I put data_dir() in the script that actually owns the files.

# ingest.py
import csv
import json
import os
import sys
from collections import Counter
from pathlib import Path


def data_dir() -> Path:
    override = os.environ.get("INGEST_DATA_DIR")
    if override:
        return Path(override).expanduser().resolve()
    return Path(__file__).resolve().parent


def main() -> int:
    base = data_dir()
    csv_path = base / "events.csv"
    out_path = base / "summary.json"
    print(f"cwd={Path.cwd()} data_dir={base}", file=sys.stderr)
    if not csv_path.is_file():
        print(f"missing {csv_path}", file=sys.stderr)
        return 2
    counts = Counter()
    with csv_path.open(newline="", encoding="utf-8") as handle:
        reader = csv.DictReader(handle)
        if reader.fieldnames is None or "source" not in reader.fieldnames:
            print("events.csv needs a source column", file=sys.stderr)
            return 2
        for row in reader:
            counts[row["source"]] += 1
    with out_path.open("w", encoding="utf-8") as handle:
        json.dump(dict(counts), handle, indent=2, sort_keys=True)
    print(f"wrote {out_path}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
Enter fullscreen mode Exit fullscreen mode

The environment override exists because production data often lives on a mount that is not the script directory. Failing with the resolved absolute path is the whole observability story; a bare FileNotFoundError just sends you back to ls. Would I still log cwd on every start after this weekend? Yes, because the log line is cheap and my confidence is not.

Reproducible test plan

This is a local pytest sketch you can run against the script above; I am not attaching fake timings or a staged pass rate. The important move is monkeypatch.chdir into an empty scratch directory before calling main, which is exactly how the remote job felt. Keep ingest.py and test_ingest_paths.py in the same folder when you copy this layout.

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


def load_ingest(path: Path):
    spec = importlib.util.spec_from_file_location("ingest_under_test", path)
    module = importlib.util.module_from_spec(spec)
    assert spec.loader is not None
    spec.loader.exec_module(module)
    return module


def test_reads_csv_beside_script_not_cwd(tmp_path, monkeypatch):
    job = tmp_path / "job"
    scratch = tmp_path / "scratch"
    job.mkdir()
    scratch.mkdir()
    (job / "events.csv").write_text(
        "source,id\nweb,1\napi,2\nweb,3\n", encoding="utf-8"
    )
    repo = Path(__file__).resolve().parent / "ingest.py"
    (job / "ingest.py").write_text(repo.read_text(encoding="utf-8"), encoding="utf-8")
    monkeypatch.chdir(scratch)
    monkeypatch.delenv("INGEST_DATA_DIR", raising=False)
    module = load_ingest(job / "ingest.py")
    assert module.main() == 0
    assert (job / "summary.json").is_file()
    assert not (scratch / "summary.json").exists()


def test_env_override_wins(tmp_path, monkeypatch):
    job = tmp_path / "job"
    data = tmp_path / "data"
    job.mkdir()
    data.mkdir()
    (data / "events.csv").write_text("source,id\nbatch,1\n", encoding="utf-8")
    repo = Path(__file__).resolve().parent / "ingest.py"
    (job / "ingest.py").write_text(repo.read_text(encoding="utf-8"), encoding="utf-8")
    monkeypatch.setenv("INGEST_DATA_DIR", str(data))
    monkeypatch.chdir(tmp_path)
    module = load_ingest(job / "ingest.py")
    assert module.main() == 0
    assert (data / "summary.json").is_file()


def test_naive_open_fails_from_foreign_cwd(tmp_path, monkeypatch):
    """Labeled failure mode: cwd-relative open() against a foreign working directory."""
    scratch = tmp_path / "scratch"
    scratch.mkdir()
    monkeypatch.chdir(scratch)
    missing = Path("events.csv")
    assert not missing.exists()
    try:
        missing.open(encoding="utf-8")
    except FileNotFoundError as exc:
        assert "events.csv" in str(exc)
    else:
        raise AssertionError("cwd-relative open() should fail in an empty scratch dir")
Enter fullscreen mode Exit fullscreen mode
pytest -q test_ingest_paths.py
Enter fullscreen mode Exit fullscreen mode

If the first test fails, you probably copied ingest.py without __file__ resolution, or the loader picked up a different module from sys.path. If the second test writes summary.json beside the script instead of beside the env dir, the override is being ignored after Path(__file__) wins by accident.

Decision table

Use this table when an assistant hands you a path and you are about to paste it into a job.

  • You own the launcher and cwd is the project root: cwd-relative names are fine, but write a test that changes directory anyway.
  • The script may be invoked by absolute path: resolve data through Path(__file__).parent, not through Path.cwd().
  • Data lives on a volume, bucket mount, or shared drop folder: take an env var or CLI flag, then resolve it once at startup.
  • The program is a user-facing CLI that should operate on the caller's folder: respect cwd on purpose, and do not silently switch to __file__.
  • Tests need isolation: treat tmp_path plus chdir as a feature, not as an accident of pytest.
Launch style Where the file should live What to code
cd project && python ingest.py project directory cwd or __file__ both happen to work
python /abs/ingest.py beside the script, unless overridden __file__ or INGEST_DATA_DIR
systemd, cron, CI without a working-directory field wherever the service manager starts never guess; log cwd
python -m jobs.ingest package layout, not the inner file's folder package resources or an explicit base path

That last row is another trap, because __file__ for a package module may sit under jobs/ while operators still drop CSV files at the repo root. Are you running a file, or are you running a module? The answers point at different directories, and assistants almost never ask which one you meant.

Limitations, and who should not copy this

This pattern is the wrong default for jobs that truly stream stdin, or for tools whose contract is "operate on the user's cwd". It also misfires when a packager rewrites __file__ into an unpack directory that is not where you deployed events.csv. Do not treat script-relative paths as a security boundary, because __file__ does not validate uploads or stop path traversal in user arguments. If you need a guaranteed persistent workspace with a stable cwd, a free shared runner is the wrong control plane for that requirement.

I would not use this approach for notebooks either, because __file__ is often missing and the kernel's cwd is a third character in the play. People building installable CLI tools should follow the user's cwd unless a subcommand is explicitly about package data. The foreign-cwd test is still worth stealing even if you reject data_dir() and keep relative opens on purpose.

What I would repeat

  1. Print cwd, __file__, and the resolved data path on every job start, even when the script feels too small to deserve logging.
  2. Fail with the absolute missing path in the message, so the next traceback does not send anyone back into a guessing loop.
  3. Keep one pytest that changes directory away from the script before calling main(), and refuse to delete that test during cleanup.
  4. Treat assistant snippets as laptop-local until a foreign cwd test says otherwise, because chat workspaces are not process launchers.

Would I still ask a free model to draft the boring CSV loop after this weekend? Yes, because the loop was never the bug, and the path policy has to live in the repo. If you already reproduce jobs on a scratch host, that is the workflow I would repeat: draft with MonkeyCode's free model access, run it on the free server option, then keep the chdir test that no model will volunteer on its own.

Top comments (0)