Bobobk

Practical Python, Linux and automation guides

Check Filename Case and Unicode Collisions with Python Before Copying Projects

2026-11-01 Technology
Check Filename Case and Unicode Collisions with Python Before Copying Projects
On this page

After moving a Linux project to another computer, README.md and readme.md may no longer work as two independent files. Two visually identical café.txt names can also use different Unicode sequences. Checking names before copying is easier than investigating missing files afterward.

This beginner-to-intermediate tutorial builds a read-only filename preflight with Python’s standard library: find sibling names equivalent under an explicit policy, print JSON, and gate later automation with exit codes. It does not rename files, delete data, or change Hugo configuration.

1. Method: define a conservative comparison policy

  1. Normalize each name to NFC, apply casefold(), then normalize to NFC again to produce a comparison key.
  2. Compare immediate siblings only, including files, directories, hidden entries, and symlink names. Recurse into real subdirectories, not directory symlinks.
  3. Report multiple names sharing a key as one candidate group. Identical names in different directories are not collisions. Conflicting parent directory names are reported, but their descendants are not compared as a merged tree.

The official Unicode documentation explains NFC composition of canonically equivalent sequences. The official casefold() documentation describes broader processing than lowercasing, including mapping ß to ss. This is a conservative tutorial policy, not an exact simulation of a particular filesystem; some findings need human review.

It does not check Windows reserved names, trailing dots, invalid characters, path lengths, visual confusables, or duplicate url values in Hugo front matter. Zero findings do not guarantee portability. For a site release, also run the Hugo internal link and image checker.

2. Setup: create an isolated fixture

Use Linux, Python 3.9 or newer, and bash/zsh; no dependencies are needed. Commands were tested with Python 3.11.4. The fixture requires a case-sensitive filesystem that preserves both Unicode spellings independently. Use a fresh demo directory when repeating it to avoid overwriting old fixtures.

python3 --version
mkdir filename-audit-demo
cd filename-audit-demo
python3 - <<'PY_DATA'
from pathlib import Path
root = Path("sample")
(root / "assets").mkdir(parents=True)
for name in ["README.md", "readme.md"]:
    (root / name).write_text(name + "\n", encoding="utf-8")
for name in ["caf\u00e9.txt", "cafe\u0301.txt"]:
    (root / "assets" / name).write_text("demo\n", encoding="utf-8")
assert len(list(root.glob("*.md"))) == 2, "use a case-sensitive demo filesystem"
assert len(list((root / "assets").iterdir())) == 2, "Unicode names were merged"
PY_DATA

Both assertions should pass. If either fails, stop and choose a demo filesystem supporting these independent names; do not manufacture conflicts in your real project.

3. Implementation: scan names without reading file contents

Save this complete code as check_names.py in the current directory, or download the script.

"""Read-only sibling filename collision preflight. Python 3.9+."""
import argparse
import json
import os
from pathlib import Path
import sys
import unicodedata


def name_key(name):
    return unicodedata.normalize(
        "NFC", unicodedata.normalize("NFC", name).casefold()
    )


def audit(root):
    if not root.is_dir():
        raise ValueError("root must be an existing directory")
    groups = []
    pending = [root]
    while pending:
        directory = pending.pop()
        by_key = {}
        with os.scandir(directory) as entries:
            for entry in entries:
                by_key.setdefault(name_key(entry.name), []).append(entry.name)
                if entry.is_dir(follow_symlinks=False):
                    pending.append(Path(entry.path))
        for names in by_key.values():
            if len(names) > 1:
                groups.append({
                    "directory": directory.relative_to(root).as_posix(),
                    "names": sorted(names),
                })
    groups.sort(key=lambda item: (item["directory"], item["names"]))
    return {"collision_groups": len(groups), "groups": groups}


def main():
    cli = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
    cli.add_argument("root", type=Path, help="quiet local directory to scan")
    args = cli.parse_args()
    try:
        report = audit(args.root)
    except (OSError, ValueError) as exc:
        print(f"ERROR: {exc}", file=sys.stderr)
        return 2
    print(json.dumps(report, ensure_ascii=True, sort_keys=True))
    return 1 if report["collision_groups"] else 0


if __name__ == "__main__":
    raise SystemExit(main())

The official os.scandir() documentation describes directory entries and is_dir(follow_symlinks=False). The context manager closes each iterator. Directory listing and type-query errors are not ignored: an incomplete scan does not produce an apparently successful report.

Scan a trusted, quiet local directory. The supplied root resolves normally and may itself be a directory symlink; child symlinks are not traversed. This is not an atomic snapshot or protection against directories being replaced during scanning. Mount points inside real subdirectories can be traversed. Memory depends mainly on pending directories, entries in one directory, and the size of the findings.

4. Usage: report first, then review manually

  1. Show help, then scan the fixture. Do not put the last two commands in a script using set -e: exit 1 is expected here.
python3 check_names.py --help
python3 check_names.py sample
printf 'exit=%s\n' "$?"

Expected output:

{"collision_groups": 2, "groups": [{"directory": ".", "names": ["README.md", "readme.md"]}, {"directory": "assets", "names": ["cafe\u0301.txt", "caf\u00e9.txt"]}]}
exit=1

JSON uses ASCII escapes to distinguish combining characters: \u0301 is a combining accent and \u00e9 is precomposed. Original names in the report identify actual paths; do not use normalized comparison keys to overwrite filenames.

  1. After deciding that both files should remain, rename only the demo files. For real projects, back up first, compare contents, and update references together. The SHA-256 dataset manifest tutorial provides content evidence.
mv sample/readme.md sample/notes.md
python3 - <<'PY_FIX'
from pathlib import Path
source = Path("sample/assets/cafe\u0301.txt")
target = source.with_name("cafe-notes.txt")
assert not target.exists()
source.rename(target)
PY_FIX
python3 check_names.py sample
printf 'exit=%s\n' "$?"

Now expect {"collision_groups": 0, "groups": []} and exit=0. Checking symlink targets is a separate task; use the Python broken symlink audit for that.

5. Validation: exit codes and read-only behavior

After the previous renames, run this test. It adds fixtures only inside the demo directory; rebuild the entire demo directory before repeating it.

python3 - <<'PY_TEST'
import json
from pathlib import Path
import subprocess
import sys

root = Path("sample")
# Add a nested directory that must NOT be followed through a symlink.
outside = Path("outside")
outside.mkdir()
for name in ["A.txt", "a.txt"]:
    (outside / name).write_text("outside\n", encoding="utf-8")
(root / "external").symlink_to(outside.resolve(), target_is_directory=True)
(root / "missing-link").symlink_to("not-created")
(root / ".hidden").mkdir()
(root / ".hidden" / "ONLY.txt").write_text("hidden\n", encoding="utf-8")

def run(path):
    return subprocess.run(
        [sys.executable, "check_names.py", str(path)],
        capture_output=True, text=True, check=False,
    )

before = {str(p): p.read_bytes() for p in root.rglob("*")
          if not p.is_symlink() and p.is_file()}
result = run(root)
assert result.returncode == 0 and result.stderr == "", result
assert json.loads(result.stdout)["collision_groups"] == 0
print("PASS: clean tree; symlink targets not scanned")
(root / ".hidden" / "only.txt").write_text("second\n", encoding="utf-8")
result = run(root)
assert result.returncode == 1 and result.stderr == "", result
assert json.loads(result.stdout)["groups"] == [
    {"directory": ".hidden", "names": ["ONLY.txt", "only.txt"]}
]
print("PASS: hidden directory collision")
for bad in [root / "not-created", root / "notes.md"]:
    result = run(bad)
    assert result.returncode == 2 and not result.stdout
    assert result.stderr.startswith("ERROR:"), result
print("PASS: missing root and file root rejected")
assert all(Path(p).read_bytes() == data for p, data in before.items())
print("PASS: existing file contents unchanged")
PY_TEST

Expect four PASS lines. The test confirms hidden-directory traversal, skipping external directory symlink targets, failure for missing and regular-file roots, and unchanged existing file contents.

Exit codes: 0 means a complete scan with no candidate groups; 1 means a complete scan with findings; 2 means a scanning or CLI error. Scanning errors go to stderr with empty stdout. Gate later actions like this:

python3 check_names.py sample > filename-report.json && printf 'preflight passed\n'

The validation test leaves a hidden-directory collision, so this command currently returns 1 without printing the success message. Resolve the finding manually and rerun it. Redirection may create an empty report on error; callers must check the exit code too.

6. Troubleshooting: preflight is not automatic repair

Symptom Cause and concrete action
root must be an existing directory Missing path or a regular file; confirm your location with pwd, then pass an absolute path to a real directory.
Permission denied A directory cannot be listed or traversed; inspect the reported path and parents with ls -ld, and ask its owner for minimal required access. Do not use chmod -R 777.
Names look identical Inspect JSON escape sequences; choose explicit names based on contents and references instead of deleting one file merely to clear the warning.
ß and ss share a group This is the casefold policy; review target-platform requirements. It does not mean the current Linux filesystem already has a duplicate-name error.
A link target contains an unreported collision Only the link name is compared, not its target tree. Check that target separately rather than silently expanding the scan boundary.

7. Summary

Use NFC and casefold to flag candidate conflicts, then review original names and references before copying, backing up, or organizing static assets. Preserve the distinction between exit codes 0, 1, and 2 so an incomplete scan never looks clean. This preflight does not replace target-filesystem tests, content verification, or a Hugo build.

Tags

1024 2fa ablang adsense agents.md ai ai-agent ai-agents ai-seo algorithm amp antibodies apparmor argparse automation background backup bash batch-processing binarycif bioinformatics biopython blockchain boltz bootstrapping boxes bubblewrap c-index cca cdn checkpoint cli cloudflare codex cofoldarena concurrency configparser configuration copy cprofile cpu monitoring cron csv cuda curl data-leakage data-processing data-quality data-validation datascience datavisualization datetime debugging decimal deployment desktop-app devtools difflib disk-space disown dns docker dovecot download economics electron encoding esm esm2 esm3 esmc esmfold2 etag evaluation fable faceswap fasta fastmcp faulthandler ffmpeg file-descriptors file-io file-management filecmp filenames files flashppi flask folium frontend game generator git github-actions google google-research gpt-6.1-sol grep gzip harness heapq hls html http hugo imports indexnow ipaddress jailbreak javascript jev json jsonl just k-means kaggle langfuse leecode linux list litellm llm llms.txt logging logs lollipop lsof m3u8 machine-learning macos manacher matplotlib mcp memory mirror mmcif model-evaluation monitoring mp3 mp4 mpnn multiomics multiprocessing mutation mysql nanobert nanobodies networking networkx nginx normalize numpy ollama omegatherm pandas password pathlib pdb pep-723 performance permissions phaser pillow pip postfix preprocessing print probability prompt-engineering protein-alignment protein-design protein-embeddings protein-interactions protein-language-models protein-stability protein-structure proxy pydantic pyecharts pyqt python python3 r raincloud reproducibility requests reservoir-sampling rfoptimization rg ripgrep rosettafold3 roundcube rrsi rsi rsync s-tui sampling scale scikit-learn scrapy screen seaborn security seo sequence-identity sha256 shell signals sitemap sklearn socket solana somaticsignatures sorting spl sql sqlite ssh standardize static-site subprocess symlink sysadmin system-one tarfile tcp tempfile tensorflow tkinter tls toml totp tracemalloc trade tron tronpy troubleshooting turtle typesafe-ai ubuntu unicode upsert urllib usdt utf-8 uv venv vhhbert vite wallet web-tools webp wordcloud wordpress workflow xml yaml zipfile zoneinfo 迅雷解析

友情链接