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
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
What I still imported in tests:
from billing import helper
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
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"
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(".")))
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())
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
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
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
- Compare
git ls-fileswithPath.iterdir()names before I rebuild a wheel. - Force case-only renames through
git mv, then clone onto Linux before I trust pytest. - Probe
disk_folds_case()on every host that claims to reproduce a missing module. - 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)