DEV Community

Taylor Wang
Taylor Wang

Posted on

48-Hour Field Notes: APFS Treated Utils.py as utils.py. Linux Did Not.

I spent forty-eight hours arguing with an import that only failed after I left my laptop. The Linux traceback named a module my tests had imported all morning without a single complaint. Have you ever trusted a green local suite, then watched a remote interpreter pretend the file was never in the tree?

The package was small, almost boring, until two helpers sat in the same folder: utils.py and a newer experiment called Utils.py. Finder showed two names, and Python on my Mac imported whichever spelling I typed. Why would that even bind, let alone look like a passing unit test?

What I thought was broken

I blamed packaging first, because that is the comfortable story when a remote box disagrees. Then I blamed PYTHONPATH, a missing __init__.py, and a stale __pycache__ directory that might still point at last Tuesday. None of those theories survived a second filesystem.

Here is the short list of dead ends from the first evening, before I touched disk case rules:

  • Reinstalling with pip install -e . still imported both spellings on the Mac
  • Deleting __pycache__ did not change which file import Utils bound to
  • Printing sys.path looked identical until I compared how each kernel folds case
  • python -c "import utils" succeeded on both machines for the lowercase name only

The Linux box answered with ModuleNotFoundError: No module named 'Utils' the moment I used the capital U. That is not a network error so much as a disk that refuses to lie about filenames.

Hours 0–8: the laptop lied politely

macOS APFS is usually case-insensitive and case-preserving, which means the directory can store utils.py while still resolving Utils.py to the same inode. Linux ext4 does not play that game, and it will either miss the capital spelling or keep a second real file. I confirmed the laptop volume with a boring command I should have run on hour one, before rewriting the package layout.

diskutil info / | grep -i 'case'
python3 -c "import os; print(os.path.normcase('Utils.py'))"
Enter fullscreen mode Exit fullscreen mode

On a typical Mac you will see an APFS personality without a case-sensitive flag, and os.path.normcase will fold the string. On Linux, ls and stat treat utils.py and Utils.py as different names, and the kernel will keep both if some copy tool created them separately. Git made the mess quieter on the laptop, because core.ignorecase was true and git status shrugged at the second spelling. Did I really think version control would save me from the filesystem?

git config --get core.ignorecase
git ls-files '*.py' | sort -f | uniq -di
ls -li utils.py Utils.py 2>/dev/null || true
Enter fullscreen mode Exit fullscreen mode

If uniq -di prints a pair, Git is already storing two names that only a case-sensitive checkout can honor. If ls -li shows one inode on the Mac and Linux later shows one file, import Utils was never a real module. It was APFS doing a favor.

Hours 8–16: prove it with a probe, not a vibe

I wanted a check I could run in five seconds on any tree, without arguing about Docker folklore. The probe below is the artifact I wish I had at hour two, because it groups files by parent directory plus a lowercased basename and then prints collisions. Run it before you blame importlib or pin a Python version that had nothing to do with the failure.

#!/usr/bin/env python3
"""Report basename collisions that a case-insensitive disk would hide."""
from __future__ import annotations

import os
import sys
from collections import defaultdict
from pathlib import Path

SKIP_DIRS = {".git", ".venv", "venv", "node_modules", "__pycache__"}


def collisions(root: Path) -> dict[tuple[str, str], list[str]]:
    grouped: dict[tuple[str, str], list[str]] = defaultdict(list)
    for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
        dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
        parent = os.path.abspath(dirpath)
        for name in filenames:
            grouped[(parent, name.lower())].append(name)
    return {key: names for key, names in grouped.items() if len(set(names)) > 1}


def probe_imports(names: list[str]) -> None:
    for name in sorted(set(names)):
        if not name.endswith(".py"):
            continue
        mod = name[:-3]
        print(f"--- import {mod!r}")
        try:
            __import__(mod)
            print("  bound to", sys.modules[mod].__file__)
        except Exception as exc:
            print("  ", type(exc).__name__, exc)


if __name__ == "__main__":
    root = Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve()
    found = collisions(root)
    if not found:
        print("No case collisions under", root)
        sys.exit(0)
    for (parent, _), names in sorted(found.items()):
        print(parent)
        for name in sorted(set(names)):
            print(f"  {name}")
        os.chdir(parent)
        probe_imports(names)
        sys.exit(1)
Enter fullscreen mode Exit fullscreen mode

Save it as case_collisions.py and run python3 case_collisions.py src. On a case-insensitive volume you may see one physical file answering two import spellings, which looks like success and is not. On Linux you should see either two real files or a hard failure for the spelling that was never committed. I also printed what Python thought the spec was, because __file__ is the argument that ends the hallway debate.

import importlib.util

for name in ("utils", "Utils"):
    spec = importlib.util.find_spec(name)
    print(name, spec.origin if spec else None)
Enter fullscreen mode Exit fullscreen mode

On the Mac, both specs could point at the same origin. On Linux, one spec was None. That single None was the whole outage, and no amount of pip check was going to invent a second inode.

Hours 16–32: I needed a disk that would not forgive me

A laptop cannot certify Linux behavior, no matter how carefully you empty sys.path. I needed a second machine whose filesystem was actually case-sensitive, not a bind mount that still sat on APFS. Have you noticed how easy it is to keep debugging on the same Mac because opening SSH feels like overhead you will do tomorrow?

This is where a throwaway Linux shell earned its keep, instead of another local virtualenv that inherited the same polite disk. Disclosure: This article was prepared as part of MonkeyCode's product outreach. I used MonkeyCode's free model access to draft the first version of case_collisions.py, then ran the same script on MonkeyCode's free server option so the kernel, not Finder, would decide whether Utils existed.

I am not going to quote model names, quotas, hardware, or timings I cannot verify from a primary source. The useful part was boring in the best way: sync the tree, run the probe, compare the two find_spec lines, and stop when they disagree. Any Linux host you already have would have taught the same lesson. The free server only removed my excuse to stay on APFS for one more hour.

What broke during that pass, in the order I actually hit it:

  1. The first draft followed symlinks and double-counted a src link that pointed back at the checkout.
  2. It treated readme.md and README.md as a Python crisis; the import probe now skips non-.py names.
  3. Running from the repo root imported a neighbor utils because . was still on sys.path.
  4. A Docker volume mounted from the Mac still folded case, so the container lied with the laptop.
# labeled example: drop cwd so the probe does not import a neighbor module
import os
import sys

cwd = os.path.abspath(os.getcwd())
sys.path = [p for p in sys.path if os.path.abspath(p or ".") != cwd]
Enter fullscreen mode Exit fullscreen mode

If you test in Docker, confirm the working copy is not an osxfs or virtiofs mount of the same APFS tree. df -T . on Linux should show ext4, xfs, or btrfs before you trust a green import. Otherwise you are still talking to your laptop, just with extra YAML.

Hours 32–48: the checklist I would run again

I stopped trying to memorize APFS folklore and wrote a gate I can copy into notes. If you maintain Python on a Mac and deploy on Linux, this is the sequence I would repeat without the forty-eight-hour detour.

  1. Run diskutil info / | grep -i case once per laptop, and keep the output in the incident doc.
  2. Read git config --get core.ignorecase in the repo, not in a global config you forgot.
  3. Run python3 case_collisions.py . locally and fail if the map is not empty.
  4. Run the same command on a case-sensitive Linux tree, not on a Mac-backed volume.
  5. Print importlib.util.find_spec for every colliding stem and compare origin.
  6. Rename until Linux and the laptop agree, then commit the rename from the Linux side if Git is folding.

Decision table

Symptom on Linux Laptop still green? First check Do not do this
ModuleNotFoundError for mixed-case module Yes find_spec both spellings Rename only on the Mac and push
Import binds the wrong helper Yes sys.modules[name].__file__ Insert another sys.path entry
Git refuses to add Utils.py Yes core.ignorecase Force-add without a Linux checkout
Two files exist on Linux, one on Mac Sometimes ls -li on both disks Assume the image used a Linux disk
Container imports like the laptop Yes df -T . inside the container Trust bind mounts from APFS

Limitations, and who should skip this

This workflow will not catch modules hidden inside zip imports, namespace packages that span several distributions, or a C extension whose filename case differs from the Python wrapper. It also will not save you if CI already checks out onto ext4 and you simply never opened the log. The scanner is a flashlight for basename collisions, not a type checker and not a substitute for running tests where you ship.

Skip the extra remote box if your pipeline already checks out onto a case-sensitive filesystem and runs pytest there on every push. Skip the import probe if the tree is not Python, or if the only collision is a pair of markdown files you do not import. Do not treat a free remote shell as a production runtime, a security boundary, or a performance lab, because I did not benchmark anything and you should not either on a shared scratch machine.

Would I spend another forty-eight hours on this class of bug after the probe exists? Only if I skip the Linux run and trust Finder again. After that, the loop is a few minutes: list collisions, execute them where the disk is honest, then rename until both interpreters bind the same origin. The Mac was never broken; it was being helpful about filenames, which is worse than a loud failure.

Top comments (0)