DEV Community

Taylor Wang
Taylor Wang

Posted on

Local Imports Forgave the Case. The Linux Box Did Not.

Have you ever shipped a rename that looked clean in git, then watched Linux refuse the import on a fresh box? I burned almost two days chasing pip, PYTHONPATH, and a supposedly stale wheel before the disk itself became the suspect. Local pytest kept importing helper, even after I had renamed the file to Helper.py in the repository. Why did one machine forgive the mismatch while the other treated it as a missing module?

Hour 0–8: I treated it like another environment lie

The first failure looked like every other works-on-my-machine report, and I reached for environment dumps out of habit. A clean Linux process raised ModuleNotFoundError: No module named 'helper' while the local suite stayed loudly green. I printed sys.executable, dumped sys.path, and confirmed the package directory was on disk. Nothing in that output explained a missing module that I could still open in the editor.

Commands I ran first, because they have burned me before:

python -c "import sys; print(sys.executable); print('\n'.join(sys.path))"
python -c "import helper; print(helper.__file__)"
git ls-files | grep -i helper
Enter fullscreen mode Exit fullscreen mode

Locally, the second command printed a path and kept going without complaint. On Linux, the same command died before it printed anything I could use. Was the file not checked out? Was an old .pyc shadowing the name I thought I had renamed? I started down both roads, and both roads wasted a quiet morning.

Hour 8–24: packaging, bytecode, and a rename git never quite accepted

I rebuilt the editable install and deleted every __pycache__ tree I could find. I even hashed the module on both sides, expecting a content mismatch that never arrived. The bytes were the same. The path string was not, and that should have been the clue.

Here is the shape of the working tree after a supposedly simple case-only rename:

src/billing/
  __init__.py
  Helper.py      # git index, intended name
Enter fullscreen mode Exit fullscreen mode

What I still imported in tests:

from billing import helper
Enter fullscreen mode Exit fullscreen mode

On a case-insensitive volume, that import is not a bug you will ever see. The kernel folds the lookup, Python asks for helper.py, and the disk hands back Helper.py without a warning. On Linux those are different directory entries, and the interpreter does not get to be polite for you. Have you ever watched an import succeed while the filename in git disagrees with the identifier in the test?

Git made the story worse than the kernel did. On a case-insensitive clone, core.ignorecase is often true, so a casual rename does not look like a rename at all.

git config --get core.ignorecase
git mv helper.py Helper.py
git status --short
git ls-files -- src/billing
Enter fullscreen mode Exit fullscreen mode

Without git mv, the index may keep the old name while your editor shows the new one. Then a Linux checkout materializes whichever name the index actually stored. I had been reading the editor tab and ignoring git ls-files, which is how a one-character rename becomes a two-day incident.

Hour 24–48: the filesystem was the test I never ran

I finally copied the tree onto a case-sensitive Linux machine and ran one boring listing. The file was Helper.py. The import was helper. Linux was not confused. I was.

find src -iname '*helper*' -print
python -c "from pathlib import Path; print([p.name for p in Path('src/billing').iterdir()])"
python -c "import billing.helper"
Enter fullscreen mode Exit fullscreen mode

That last line is the whole incident in one command. If you can only reproduce it on Linux, you need a Linux disk, not another local virtualenv with the same folding volume underneath. Before I trust any host now, I probe whether the disk folds case instead of assuming the operating system name is enough.

from pathlib import Path


def disk_folds_case(dir_path: Path) -> bool:
    probe = dir_path / ".case-probe-AaA"
    probe.write_text("x", encoding="utf-8")
    try:
        return (dir_path / ".case-probe-aaa").exists()
    finally:
        probe.unlink(missing_ok=True)


print("folds case:", disk_folds_case(Path(".")))
Enter fullscreen mode Exit fullscreen mode

On a folding volume that probe returns True, and a Linux box should return False. If both answers are True, you are still debugging the same disk in two costumes. If they disagree, stop rebuilding wheels and start comparing directory entries.

This is where a spare Linux box earns its keep. Disclosure: This article was prepared as part of MonkeyCode's product outreach. I used MonkeyCode's free model access to draft a collision scanner from the failure notes above, then ran that scanner on MonkeyCode's free server option so the filesystem would actually disagree with my laptop. I am not going to pretend that a coding assistant found the bug for me. It generated the first version of the script, and the Linux box made the failure real.

The artifact: a case-collision and import-mismatch scanner

Label this as a workflow to keep in the repo, not as a product benchmark or a complete type checker. Save it as tools/check_import_case.py and run it before you trust a green local suite after any rename.

#!/usr/bin/env python3
"""Fail when Python files collide by case, or when imports disagree with filenames."""
from __future__ import annotations

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

SKIP_DIRS = {
    ".git", ".hg", ".venv", "venv", "__pycache__",
    "node_modules", "dist", "build",
}


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


def actual_entry(path: Path) -> Path:
    """Return the directory entry with its real case, even on a folding disk."""
    parent = path.parent
    needle = path.name.lower()
    try:
        for entry in parent.iterdir():
            if entry.name.lower() == needle:
                return entry
    except FileNotFoundError:
        return path
    return path


def walk_ci(root: Path, dotted: str) -> Path | None:
    """Resolve a dotted name with case-insensitive directory walks."""
    current = root
    parts = dotted.split(".")
    for index, part in enumerate(parts):
        if not current.exists():
            return None
        try:
            entries = list(current.iterdir())
        except NotADirectoryError:
            return None
        is_last = index == len(parts) - 1
        match = None
        for entry in entries:
            if entry.name.lower() == part.lower():
                match = entry
                break
            if is_last and entry.stem.lower() == part.lower() and entry.suffix == ".py":
                match = entry
                break
        if match is None and is_last:
            for entry in entries:
                if entry.name == "__init__.py" and current.name.lower() == part.lower():
                    return actual_entry(entry)
        if match is None:
            return None
        current = match
    if current.is_dir():
        init = current / "__init__.py"
        return actual_entry(init) if init.exists() else current
    return actual_entry(current)


def git_index_collisions() -> list[list[str]]:
    proc = subprocess.run(
        ["git", "ls-files"],
        check=False,
        capture_output=True,
        text=True,
    )
    if proc.returncode != 0:
        return []
    groups: dict[str, list[str]] = defaultdict(list)
    for line in proc.stdout.splitlines():
        groups[line.lower()].append(line)
    return [names for names in groups.values() if len(set(names)) > 1]


def import_case_mismatches(files: list[Path], root: Path) -> list[str]:
    problems: list[str] = []
    search_roots = [root]
    src = root / "src"
    if src.is_dir():
        search_roots.append(src)

    for path in files:
        try:
            tree = ast.parse(path.read_text(encoding="utf-8"))
        except SyntaxError as exc:
            problems.append(f"skip parse {path}: {exc}")
            continue

        names: list[str] = []
        for node in ast.walk(tree):
            if isinstance(node, ast.Import):
                names.extend(alias.name for alias in node.names)
            elif isinstance(node, ast.ImportFrom):
                if node.level:
                    continue  # relative imports are a documented limitation
                base = node.module or ""
                for alias in node.names:
                    if alias.name == "*":
                        continue
                    names.append(f"{base}.{alias.name}" if base else alias.name)

        rel = path.relative_to(root).as_posix()
        for name in names:
            leaf = name.split(".")[-1]
            target = None
            for search in search_roots:
                target = walk_ci(search, name)
                if target is not None:
                    break
            if target is None or target.suffix != ".py":
                continue
            actual_leaf = target.stem if target.name != "__init__.py" else target.parent.name
            if leaf != actual_leaf and leaf.lower() == actual_leaf.lower():
                problems.append(
                    f"{rel}: imports {name!r} but disk has {actual_leaf!r}"
                )
    return problems


def main() -> int:
    root = Path(".").resolve()
    files = iter_py_files(root)
    collisions = git_index_collisions()
    mismatches = import_case_mismatches(files, root)

    if collisions:
        print("Case-colliding paths in the git index:")
        for names in collisions:
            for name in names:
                print(f"  - {name}")
    if mismatches:
        print("Import names that only work on a case-insensitive disk:")
        for item in mismatches:
            print(f"  - {item}")
    if collisions or mismatches:
        print("\nReproduce on a case-sensitive filesystem before you merge.")
        return 1
    print(
        f"Checked {len(files)} Python files. "
        "No case collisions or case-only import mismatches."
    )
    return 0


if __name__ == "__main__":
    sys.exit(main())
Enter fullscreen mode Exit fullscreen mode

A tiny pytest file makes the rule visible to people who never open the linter:

# tests/test_import_case_contract.py
from pathlib import Path
import subprocess
import sys


def test_repo_has_no_case_only_import_mismatches():
    script = Path("tools/check_import_case.py")
    result = subprocess.run(
        [sys.executable, str(script)],
        check=False,
        capture_output=True,
        text=True,
    )
    assert result.returncode == 0, result.stdout + result.stderr
Enter fullscreen mode Exit fullscreen mode

Run both locally and on Linux. The interesting outcome is a local pass plus a remote fail, or the same fail in both places after the scanner exists.

python tools/check_import_case.py
python -m pytest tests/test_import_case_contract.py -q
Enter fullscreen mode Exit fullscreen mode

Decision table I wish I had on hour two

Symptom Local folding disk Linux case-sensitive disk What to check first
ModuleNotFoundError after a rename Import still works Import dies git ls-files versus Path.iterdir() names
Two people add util.py and Util.py Second file overwrites the first Both files exist and imports become chaotic git index collision scanner
CI green, laptop green, third Linux host red Editor shows Helper.py Checkout has helper.py core.ignorecase and git mv
from pkg import helper works and __file__ looks right Kernel folded the lookup Lookup never folded exact stem match, not str.lower() in your head

What broke, besides my schedule

The scanner is not a type checker, and it will miss work it cannot see. Dynamic imports built with importlib.import_module(some_name.lower()) sail past ast without leaving a trace. Relative imports, generated code, and files outside the repo root are also invisible to this script. If your layout uses a src/ path that is not the process cwd, you must keep that extra search root or you will get false comfort.

A free shared server is the wrong place for secrets, production data, or private wheels you are not allowed to upload. It is also the wrong place if you need a guaranteed image, a pinned kernel, or a long-lived daemon. Use it to reproduce filesystem behavior. Do not use it as staging, and do not treat a green local suite as proof that Linux will fold names for you.

Who should skip this whole approach? Anyone who already runs Linux CI on every branch, and anyone whose repo never leaves a case-sensitive disk. If that is you, keep the linter if you like, but you do not need a spare box to learn the lesson. People shipping only Windows-internal tools can also ignore this, until the first Linux runner appears.

What I would repeat in the next 48 hours

  1. Compare git ls-files with Path.iterdir() names before I rebuild a wheel.
  2. Force case-only renames through git mv, then clone onto Linux before I trust pytest.
  3. Probe disk_folds_case() on every host that claims to reproduce a missing module.
  4. Keep the scanner in tools/ so the next rename does not depend on my memory.

Would I still print sys.path next time? Yes, because that class of bug is real and it still wastes afternoons. I would just refuse to stop there when the file is visibly on disk and the import name only differs by case. The cheap Linux reproduction step is the part I now refuse to skip, because a folding disk will keep lying in a calm and consistent voice.

Top comments (0)