DEV Community

Taylor Wang
Taylor Wang

Posted on

I Parsed report.csv for 48 Hours. The Disk Only Stored Report.csv

Have you ever watched a parser fail on a file that ls clearly listed in the same directory? I have, and I burned two days before I printed the filename as Python actually saw it. This is a field note from that stretch, not a polished postmortem with a neat villain. I still catch myself trusting a case-insensitive laptop when the next machine will not play along.

The setup I thought was boring

I needed a small batch job that downloaded a vendor CSV and computed a daily rollup from it. The vendor portal labeled the export Report.csv, with a capital R that I never took seriously enough. My laptop runs macOS, where the default APFS volume folds case, so report.csv and Report.csv behave like one directory entry. I never confirmed that behavior before writing assertions, and I assumed the filesystem would stay casual.

I asked a coding agent to sketch the downloader, the parser, and a tiny pytest that opened report.csv. Disclosure: This article was prepared as part of MonkeyCode's product outreach. I used MonkeyCode's free model access to iterate on those three files after each failed local hypothesis. I used the free server option when I wanted a Linux contrast that was not my laptop.

I will not invent model names, hardware details, or quota numbers that nobody verified for this piece. The local run was green in a way that made me sloppy about every path I had not printed. Pytest found the fixture, the rollup matched a spreadsheet I had totaled by hand, and I shipped the folder. Why would I question a filename that ls printed right there in the project tree?

Hour 0–8: I trusted the listing

I copied the job to the Linux server and ran the same pytest invocation I had used at home without edits. The test failed with FileNotFoundError for report.csv, which felt impossible because I had just uploaded the fixture. I ran ls -l in the fixtures directory and saw Report.csv sitting there, looking exactly like the file I expected. On a terminal font that does not shout about case, those two spellings are painfully easy to conflate.

I blamed the upload next, because that is the story that keeps the application code looking innocent. Maybe scp had dropped the file, or maybe pytest was running inside a different checkout than I thought. I hashed both copies, and the SHA-256 values matched, which only made me more certain the path logic was haunted. Have you noticed how a matching hash can talk you out of looking at the name itself?

These are the commands I actually ran, copied here so I cannot romanticize the first evening:

ls -l fixtures/
python -c "from pathlib import Path; print(Path('fixtures/report.csv').exists())"
sha256sum fixtures/Report.csv
Enter fullscreen mode Exit fullscreen mode

The exists() check returned False on Linux and True on my laptop for the same relative path. I still did not print the directory listing from Python, which is the whole mistake. I stared at the shell listing instead, and that is how you spend eight hours solving the wrong mystery. Would a second ls have helped? It would have printed the same capital R I had already decided not to see.

Hour 8–24: I blamed encoding, then the agent

Once I accepted that the file was there and that Python could not see it, I jumped straight to encoding. Maybe a hidden character had ridden along from the vendor portal and lodged itself inside the filename. I ran xxd on the name, I ran cat -A, and I pasted the letters into a Unicode inspector like someone who has read too many NFC posts. The bytes were boring ASCII, and the only interesting bit was that leading R I kept ignoring.

Then I blamed the agent, which was half fair and half a way to avoid printing directory entries. Had it written report.csv in the test and Report.csv in the downloader? Yes, that is exactly what it had done, and both spellings were legal on my laptop. I asked it to make the fixture names consistent, and it renamed the test to Report.csv while a helper still opened report.csv. We ping-ponged the capital letter across three files without ever forcing a case-sensitive check on disk.

I added logging that printed the path I intended to open, which felt like progress at midnight. Logging printed fixtures/report.csv, which matched the test, so I trusted the log line and kept going. It did not print list(Path('fixtures').iterdir()), which would have ended the argument. Would you have printed the directory from Python, or would you have trusted ls the way I did?

Here is the helper that kept lying to me on macOS:

from pathlib import Path
import pandas as pd


def load_vendor_report(root: Path) -> pd.DataFrame:
    # Looks innocent. It is not innocent on a case-sensitive volume.
    path = root / "fixtures" / "report.csv"
    return pd.read_csv(path)
Enter fullscreen mode Exit fullscreen mode

And here is the downloader the agent generated, which used the vendor's original spelling:

from pathlib import Path


def save_vendor_export(root: Path, payload: bytes) -> Path:
    path = root / "fixtures" / "Report.csv"
    path.write_bytes(payload)
    return path
Enter fullscreen mode Exit fullscreen mode

Two functions, one directory, two names that only look identical on some disks. On my laptop they collapsed into a single inode and a green test. On the server they were unrelated paths, and the parser asked for the one that did not exist. That is not a pandas bug, and it is not a flaky upload. It is two strings I refused to compare.

Hour 24–40: I finally printed repr()

The breakthrough was ugly, and it should have been the first script I wrote that night. I printed every name in the directory with repr(), and I printed os.listdir as bytes so nothing could hide. Linux showed Report.csv. The opener asked for report.csv. There was no hidden character, no NFC decomposition, and no truncated upload waiting in another tree.

from pathlib import Path
import os

root = Path("fixtures")
print("iterdir:", [repr(p.name) for p in root.iterdir()])
print("listdir bytes:", [os.fsencode(n) for n in os.listdir(root)])
print("exists report.csv:", (root / "report.csv").exists())
print("exists Report.csv:", (root / "Report.csv").exists())
Enter fullscreen mode Exit fullscreen mode

On macOS both exists() calls returned True for that folder, which is the lie that ate the first day. On the Linux server only Report.csv returned True, which is the entire incident in four prints. Why did I wait a day and a half to write the script that compared the two spellings as data?

I also checked whether the vendor had sent mixed case across days, because that would turn this into a recurring outage instead of a one-off. Some exports were Report.csv. One older export sitting in my mail was report.CSV. A case-insensitive laptop hid that mess behind a single icon. A Linux server would have failed on a different day for a slightly different reason, and I would have called the vendor flaky.

Hour 40–48: I built a check I will actually keep

I did not want another 48 hours the next time a helper lowercased a path for readability. I added a small helper that refuses to open a file unless the directory entry matches the requested name, byte for byte. It is not clever, and it is not fast in a directory with tens of thousands of files. It is loud, and it fails even when exists() would return True on a folding volume.

from pathlib import Path


def require_exact_name(path: Path) -> Path:
    """Reject paths that only match after case folding."""
    parent = path.parent
    if not parent.exists():
        raise FileNotFoundError(f"parent missing: {parent!r}")

    names = list(parent.iterdir())
    exact = [p for p in names if p.name == path.name]
    if exact:
        return path

    folded = [p.name for p in names if p.name.lower() == path.name.lower()]
    neighbors = sorted(p.name for p in names)
    if folded:
        raise FileNotFoundError(
            f"requested {path.name!r} but the directory has {folded!r}; "
            f"neighbors={neighbors!r}"
        )
    raise FileNotFoundError(
        f"{path.name!r} missing; neighbors={neighbors!r}"
    )
Enter fullscreen mode Exit fullscreen mode

I wired it into the parser so the next failure would name the neighbor file instead of shrugging with a generic missing path. Then I wrote a pytest that creates Report.csv and tries to open report.csv, which must fail everywhere, including my laptop. That is the whole point of the artifact: make the laptop as strict as Linux before I wait for production to do it.

from pathlib import Path
import pytest

from vendor_io import require_exact_name


def test_require_exact_name_rejects_case_fold(tmp_path: Path) -> None:
    (tmp_path / "Report.csv").write_text("a,b\n1,2\n", encoding="utf-8")
    with pytest.raises(FileNotFoundError, match="Report.csv"):
        require_exact_name(tmp_path / "report.csv")


def test_require_exact_name_accepts_exact_bytes(tmp_path: Path) -> None:
    target = tmp_path / "Report.csv"
    target.write_text("a,b\n1,2\n", encoding="utf-8")
    assert require_exact_name(target) == target
Enter fullscreen mode Exit fullscreen mode

If you want a Linux contrast without standing up a permanent box, run that test in any case-sensitive environment you already have. A container is enough. A second checkout on a case-sensitive APFS volume is enough. The free server option is how I ran the second half of this comparison when I wanted a machine that would not fold the letter R.

You can reproduce the exists() lie with three commands and no vendor portal:

mkdir -p /tmp/case-lab/fixtures
printf 'a,b\n1,2\n' > /tmp/case-lab/fixtures/Report.csv
cd /tmp/case-lab
python -c "from pathlib import Path; p=Path('fixtures/report.csv'); print(p.exists(), list(Path('fixtures').iterdir()))"
Enter fullscreen mode Exit fullscreen mode

On a folding Mac volume that printout can show True beside a listing that still contains Report.csv. On Linux it should show False beside the same listing. If those two machines agree, you are not looking at this bug.

Decision table I wish I had on hour one

Symptom What I assumed What to print first
exists() is True locally, False on Linux The upload failed repr(name) for every iterdir() entry
SHA-256 matches, open still fails pandas has a path bug exact requested name versus directory entry
Agent standardized filenames Consistency was achieved git diff plus a case-sensitive test
ls shows the file Python should see it os.fsencode of every name
Intermittent missing file The vendor is flaky history of mixed case from the vendor

What broke

  • The laptop filesystem folded case, so two spellings looked like one healthy file.
  • The agent copied the vendor's Report.csv in one module and the test's report.csv in another.
  • ls confirmed presence without confirming the exact bytes in the name.
  • A matching SHA-256 convinced me the path was correct, which was a category error.
  • I regenerated code instead of printing repr(name) from the same interpreter that opened the file.

What I would repeat

  1. Print repr() of every path the program opens, and print the neighbor names in the same breath.
  2. Run one job on a case-sensitive filesystem before I trust a green local suite.
  3. Pin the filename in a contract test that compares exact strings, not Path.exists().
  4. Tell the agent the target filesystem is case-sensitive, because it will otherwise fix names by matching the laptop.

I would also keep a one-line note in the README that the vendor filename is part of the contract. That sounds obvious after the fact, when the capital letter is the whole story. It is not obvious while you are staring at a listing that already proves the file is present. Would I still skip the repr() print if the test were green at home? I want the answer to be no.

Limitations, and who should skip this

require_exact_name does not save you from Unicode normalization, where é can be one code point or a letter plus a combining mark. It does not model Windows short-name aliases, and it does not replace a real schema contract with the vendor. In a directory with huge file counts, listing neighbors on every open is the wrong performance trade. If your pipeline already canonicalizes names to lowercase at the boundary, the helper is mostly noise.

Who should skip this approach? Anyone parsing streams rather than files, anyone whose object store already enforces a canonical key, and anyone who will never run on a case-sensitive volume. Also skip it if you cannot run even one Linux job, because the laptop will keep folding case and the helper is then the only remaining alarm. I am not claiming this is a universal path library. It is a field note about a capital letter I refused to print.

The next time a file is right there and Python disagrees, start with repr(), not with another rewrite. Hashing contents answers a different question than comparing names. Green tests on a folding volume answer a third question that production may never ask.

Top comments (0)