DEV Community

Taylor Wang
Taylor Wang

Posted on

The Import Passed on My Mac. Linux Treated Utils and utils as Strangers.

Have you ever watched a Python import fail on a remote box after it passed every local test? I spent the next forty-eight hours blaming packaging, PYTHONPATH, and a stale Docker layer that did not exist. The module file was sitting in the repo the entire time, and Linux was simply reading the letters differently than my laptop. This is the field notebook I wish I had taped above the terminal before the second all-nighter.

Hour 0: The laptop lied politely

My Mac still uses APFS in the default case-insensitive mode, which remains the common laptop setup in 2026. Windows NTFS usually folds case as well, unless somebody enabled that optional POSIX sensitivity flag nobody remembers. Linux ext4, xfs, and btrfs compare bytes, so Utils.py and utils.py are different names. I knew that distinction in the abstract, and I still refused to check the letters first.

The project itself looked ordinary, which is exactly how these filesystem bugs prefer to arrive on a Friday. A worker imported a helper module, loaded a JSON config, and wrote a small status file. I ran the module on the laptop, watched a friendly print, and trusted the exit code. After the push, the Linux clone raised ModuleNotFoundError for a package name I could still see.

# app/worker.py — reconstructed example of the local-green version
from Utils.helper import parse_event
import json

def main():
    with open("Config.json", "r", encoding="utf-8") as handle:
        cfg = json.load(handle)
    print("loaded", cfg.get("name"), flush=True)
    parse_event(cfg)
Enter fullscreen mode Exit fullscreen mode

How do you debug a missing module when ls already shows the file in the same directory? I will walk the hours in order, including the four theories that did not deserve a full day. The capital letters were the whole bug, and everything else was a story I told myself.

Hours 1–12: Four theories that wasted daylight

I treated the traceback like a packaging bug because that is the story Python usually tells first. Here is the ordered list of mistakes I would not repeat the next time Linux disagrees.

  1. I reinstalled the package. pip install -e . succeeded, and the error did not move one inch.
  2. I printed sys.path. The repo root was present, which made me trust import machinery more than the disk.
  3. I blamed Docker layer cache. There was no Dockerfile on this path; I was running a plain clone.
  4. I blamed Git. git ls-files showed app/utils/helper.py and config.json, which I misread as comfort.

Notice the letters in those paths, because I did not notice them until hour eighteen. The import said Utils while the directory said utils, and my laptop folded those spellings together. The open call said Config.json, the blob said config.json, and Linux refused to honor either alias. Why did the listing look correct when I typed capital letters from muscle memory on the Mac?

Because I typed ls app/Utils on the Mac and the shell still resolved it. On Linux the same command said No such file or directory. That one-line difference is the entire incident, hiding under a traceback that talks about modules instead of letters.

# On a case-insensitive Mac volume this can succeed even when the directory is app/utils
ls app/Utils/helper.py
python3 -c "import Utils.helper"

# On Linux both should fail if the directory is really app/utils
ls app/Utils/helper.py
python3 -c "import Utils.helper"
Enter fullscreen mode Exit fullscreen mode

Hours 12–24: Reproduce on a disk that argues back

I needed a case-sensitive filesystem more than I needed another local virtualenv for the same tree. A billed VPS would have worked, and so would any Linux VM I already owned. I did not have one warmed up, so I reproduced the clone on MonkeyCode's free server option. A free model listed imports for me, and it also invented a Utils package that was not in Git.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

The useful part of that session was the Linux shell, not a product name on the tab. Any ext4 machine would have shown the same ModuleNotFoundError after a single cold clone of the repo. The checker below exists because a chat log will complete the spelling your laptop already taught it. Free model access is handy for drafting a file walk, and it is unsafe as a substitute for git ls-files.

Commands that actually moved the incident

uname -s
df -T .
git clone "$REPO_URL" repro-case
cd repro-case
python3 -c "import pathlib; print('\\n'.join(sorted(str(p) for p in pathlib.Path('.').rglob('*.py'))))"
python3 app/worker.py
Enter fullscreen mode Exit fullscreen mode

The uname output printed Linux, and the traceback repeated with that missing capital-U package name. The printed file list contained app/utils/helper.py and config.json, with no uppercase aliases beside them. At hour eighteen I finally stopped arguing with pip and started reading the import letters instead.

Hours 24–36: The checker I should have run at hour one

Chat will not keep you honest when the model completes from Utils because your laptop already did. A tiny walk of the tree will, and it will still be there after the tab closes. The script below is the artifact from this incident, and it is deliberately boring on purpose. It does not install extra packages, and it does not call a network API to guess paths.

It builds a map of lowercased relative paths to the real paths Git would see. Then it reads Python files for import / from lines and for open("...") string literals. If the lowercase key exists but the exact spelling does not, it prints a mismatch. If two real paths share a lowercase key, it prints a bomb, because that pair cannot survive a round trip through macOS and Linux.

#!/usr/bin/env python3
"""case_audit.py — find import/open spellings that only work on case-folding disks.

Label: example script for a local clone. Run it at the repo root.
"""
from __future__ import annotations

import ast
import sys
from collections import defaultdict
from pathlib import Path

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

def iter_files(root: Path) -> list[Path]:
    files: list[Path] = []
    for path in root.rglob("*"):
        if any(part in SKIP_DIRS for part in path.parts):
            continue
        if path.is_file():
            files.append(path)
    return files

def index_by_lower(root: Path) -> dict[str, list[str]]:
    grouped: dict[str, list[str]] = defaultdict(list)
    for path in iter_files(root):
        rel = path.relative_to(root).as_posix()
        grouped[rel.lower()].append(rel)
    return grouped

def module_to_candidates(mod: str) -> list[str]:
    parts = mod.replace(".", "/")
    return [f"{parts}.py", f"{parts}/__init__.py"]

class Visitor(ast.NodeVisitor):
    def __init__(self) -> None:
        self.imports: list[str] = []
        self.opens: list[str] = []

    def visit_Import(self, node: ast.Import) -> None:
        for alias in node.names:
            self.imports.append(alias.name)
        self.generic_visit(node)

    def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
        if node.module:
            self.imports.append(node.module)
        self.generic_visit(node)

    def visit_Call(self, node: ast.Call) -> None:
        func = node.func
        name = getattr(func, "id", None) or getattr(func, "attr", None)
        if name == "open" and node.args:
            arg0 = node.args[0]
            if isinstance(arg0, ast.Constant) and isinstance(arg0.value, str):
                self.opens.append(arg0.value)
        self.generic_visit(node)

def audit(root: Path) -> list[str]:
    grouped = index_by_lower(root)
    findings: list[str] = []
    for key, paths in grouped.items():
        if len(paths) > 1:
            findings.append(f"BOMB case-fold collision: {paths}")
    stdlib = {
        "json", "sys", "os", "re", "pathlib", "typing",
        "collections", "ast", "__future__",
    }
    for path in iter_files(root):
        if path.suffix != ".py":
            continue
        try:
            tree = ast.parse(path.read_text(encoding="utf-8"))
        except (SyntaxError, UnicodeDecodeError) as exc:
            findings.append(f"SKIP {path}: {exc}")
            continue
        vis = Visitor()
        vis.visit(tree)
        rel_dir = path.parent.relative_to(root).as_posix()
        for mod in vis.imports:
            if mod.startswith(".") or mod.split(".")[0] in stdlib:
                continue
            candidates = module_to_candidates(mod)
            if rel_dir != ".":
                candidates += [f"{rel_dir}/{c}" for c in module_to_candidates(mod)]
            exact = any((root / c).is_file() for c in candidates)
            folded = [c for c in candidates if c.lower() in grouped]
            if folded and not exact:
                findings.append(
                    f"IMPORT case mismatch in {path}: {mod} -> {folded}"
                )
        for spec in vis.opens:
            if spec.startswith("/"):
                continue
            opened = path.parent / spec
            try:
                rel = opened.resolve().relative_to(root.resolve()).as_posix()
            except ValueError:
                continue
            if rel.lower() in grouped and rel not in grouped[rel.lower()]:
                findings.append(
                    f"OPEN case mismatch in {path}: {spec} (disk has {grouped[rel.lower()]})"
                )
            elif not opened.exists() and rel.lower() in grouped:
                findings.append(
                    f"OPEN case mismatch in {path}: {spec} (disk has {grouped[rel.lower()]})"
                )
    return findings

if __name__ == "__main__":
    root = Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve()
    hits = audit(root)
    if not hits:
        print("No case-fold mismatches found (stdlib imports ignored).")
        raise SystemExit(0)
    print("\\n".join(hits))
    raise SystemExit(1)
Enter fullscreen mode Exit fullscreen mode

Run it once on the laptop and once on Linux, because the laptop can hide a collision. A macOS volume may already have folded two names into one directory entry before Git complained. That is why the Linux run is not optional, even when the audit looks clean at home. The probe snippet below asks the disk a direct question before you argue with the interpreter.

python3 case_audit.py .
echo $?

# Prove the disk policy before you argue with Python.
python3 - <<'PY'
from pathlib import Path
p = Path("_CaseProbe")
p.mkdir(exist_ok=True)
(p / "a.txt").write_text("lower\n")
try:
    (p / "A.txt").write_text("upper\n")
    print("case-insensitive or collision-allowed:", list(p.iterdir()))
except OSError as exc:
    print("write of A.txt failed:", exc)
PY
Enter fullscreen mode Exit fullscreen mode

A regression test so I cannot “fix it from memory”

A small pytest file keeps me from fixing the import by memory and then regressing it next week. The test plants a lowercase package and a mismatched import, then expects the auditor to fail closed. I want that failure on the laptop, not after a reviewer has already approved the pull request.

# tests/test_case_audit.py — reconstructed example
from pathlib import Path
import case_audit

def test_detects_utils_vs_Utils(tmp_path: Path):
    pkg = tmp_path / "app" / "utils"
    pkg.mkdir(parents=True)
    (pkg / "__init__.py").write_text("", encoding="utf-8")
    (pkg / "helper.py").write_text(
        "def parse_event(cfg): return cfg\n", encoding="utf-8"
    )
    worker = tmp_path / "app" / "worker.py"
    worker.write_text("from Utils.helper import parse_event\n", encoding="utf-8")
    hits = case_audit.audit(tmp_path)
    assert any("IMPORT case mismatch" in h for h in hits)
Enter fullscreen mode Exit fullscreen mode

Decision table I now keep in the notes

Symptom on Linux only Looks like Check first Do not do next
ModuleNotFoundError for a file ls shows Missing package / PYTHONPATH Exact letters in git ls-files vs the import Another pip install
FileNotFoundError for a config you opened locally Wrong working directory open("Config.json") vs config.json Exporting extra env vars
Tests pass on Mac, fail in CI Flaky test or Python version CI filesystem (df -T) and the audit script Pinning a random dependency
Two files differ only by case in git ls-files Harmless rename leftover Collision bomb on Linux checkout Pushing “just a rename” from macOS

Hours 36–48: What broke, and what I would repeat

What broke was not Python three, and it was not the worker logic in the helper. What broke was my habit of treating the local disk as a source of truth for names. The Mac completed my paths, the editor tab showed helper.py, and I stopped reading the import. A free model suggested recreating Utils as a second package, which would have exploded on the next Linux clone.

What I would repeat looks small, and it is the only part of the forty-eight hours that compounded.

  • Clone once onto a case-sensitive volume before I trust a green local run.
  • Run the git ls-files pipeline below to catch collision bombs Git already recorded.
  • Keep case_audit.py next to the tests, not in a chat transcript that will vanish.
  • Prefer lowercase package names (app/utils) and never import a different spelling.
git ls-files | tr '[:upper:]' '[:lower:]' | sort | uniq -d
Enter fullscreen mode Exit fullscreen mode

If that pipeline prints any path, stop the merge and rename before another Linux clone happens. Two blobs that differ only by case will checkout as a fight on a strict filesystem. macOS will hide that fight until CI, or some other Linux shell, files the bug for you.

Limitations, and who should not bother

The script does not understand dynamic imports, importlib, or open calls that are built from joined variables. It ignores relative imports that walk more than a sibling, and it ignores pathlib constructed at runtime. It also skips stdlib names with a hard-coded set that will go stale as modules are added. Treat a clean run as no obvious static mismatch, not as proof that every path is honest.

Do not use this approach if your team already develops on Linux builders every working day. You already have the honest disk, and a second remote shell will not teach a new lesson. Do not place proprietary code on a third-party machine when an internal builder can run the same commands. Do not ask a model to fix the imports without a listing from git ls-files beside the prompt.

If you already have a Linux builder, skip the extra login and run case_audit.py there instead. The worker runs now because from utils.helper matches app/utils/helper.py on every case-sensitive clone. That is a dull ending, and filesystem bugs should be allowed to end dull once the letters agree.

Top comments (0)