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 fileimport Utilsbound to - Printing
sys.pathlooked 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'))"
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
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)
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)
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:
- The first draft followed symlinks and double-counted a
srclink that pointed back at the checkout. - It treated
readme.mdandREADME.mdas a Python crisis; the import probe now skips non-.pynames. - Running from the repo root imported a neighbor
utilsbecause.was still onsys.path. - 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]
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.
- Run
diskutil info / | grep -i caseonce per laptop, and keep the output in the incident doc. - Read
git config --get core.ignorecasein the repo, not in a global config you forgot. - Run
python3 case_collisions.py .locally and fail if the map is not empty. - Run the same command on a case-sensitive Linux tree, not on a Mac-backed volume.
- Print
importlib.util.find_specfor every colliding stem and compareorigin. - 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)