From 3a64221efe1fa859da4929542cd350915bb628ab Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 4 Aug 2026 16:37:00 -0400 Subject: [PATCH 1/3] Resolve companion ranges across filename case differences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A LIFT folder written on Windows can spell its pair inconsistently — Dict.LIFT beside Dict.lift-ranges, or the reverse — and load fine there, because the filesystem folds case. On Linux the sibling candidate is built from the .lift's own suffix, so it missed, the companion was skipped without a word, and every range it defined went absent. Candidates that match no file exactly now fall back to one whose name differs only in case. The fallback is reached only after an exact miss, so a case-folding filesystem never enters it and behaves as before; a case-sensitive one gets one directory read per folder, cached across the candidate list. Where several names fold together the lexicographically first wins. The choice is arbitrary but fixed, which matters more than which file it picks: directory order varies between filesystems and runs, and a companion that loads differently on consecutive reads would be worse than one that never loads. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 10 ++++++++ src/sil_lift/_model.py | 48 ++++++++++++++++++++++++++++++++---- tests/test_ranges_folder.py | 49 +++++++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d1dd95..88d4a26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,16 @@ releases may contain breaking changes. ## [Unreleased] +### Fixed + +- Companion `.lift-ranges` files now resolve when the folder's filenames + disagree in case (`Dict.LIFT` beside `Dict.lift-ranges`, or the reverse). + Such a folder loads on Windows and macOS, whose filesystems fold case, but + on Linux the companion was silently skipped and its ranges went missing. + A candidate that matches no file exactly now falls back to one whose name + differs only in case; where several fold together the lexicographically + first wins, so resolution is stable across runs. + ## [0.1.0] - 2026-07-TBD ### Added diff --git a/src/sil_lift/_model.py b/src/sil_lift/_model.py index e130f9f..3895046 100644 --- a/src/sil_lift/_model.py +++ b/src/sil_lift/_model.py @@ -452,6 +452,38 @@ def _normalize_href(href: str) -> Path | None: return Path(normalized) +def _existing_file(candidate: Path, listings: dict[Path, dict[str, Path]]) -> Path | None: + """``candidate`` if it is a file, else one whose name differs only in case. + + LIFT folders are written on Windows, where the filesystem folds case, and + read everywhere. A folder whose ``.lift`` and ``.lift-ranges`` disagree in + case (``Dict.LIFT`` beside ``Dict.lift-ranges``) resolves there and, before + this fallback, silently did not on a case-sensitive filesystem. + + The fallback fires only where the exact name missed, so a case-folding + filesystem never reaches it and nothing changes there. Where several names + fold together, the lexicographically first wins — arbitrary, but stable + across runs, which "whatever the directory yields first" would not be. + ``listings`` caches one directory read per folder. + """ + try: + if candidate.is_file(): + return candidate + except OSError: + return None + folder = candidate.parent + if folder not in listings: + entries: dict[str, Path] = {} + try: + for path in sorted(folder.iterdir()): + if path.is_file(): + entries.setdefault(path.name.lower(), path) + except OSError: + pass # unreadable folder: no candidate resolves out of it + listings[folder] = entries + return listings[folder].get(candidate.name.lower()) + + def _same_dir(left: Path, right: Path | None) -> bool: """Whether two paths denote the same directory, spelling aside. @@ -510,7 +542,10 @@ def load(cls, path: str | os.PathLike[str], *, resolve_ranges: bool = True) -> L ``range/@href`` both the href resolved as a path relative to the ``.lift`` file and its bare basename in the same directory (FLEx hrefs are usually dangling absolute ``file://C:/...`` paths from the - exporting machine, so the basename is what resolves locally). + exporting machine, so the basename is what resolves locally). A + candidate no file matches exactly still resolves to one whose name + differs only in case, so a folder authored on Windows loads the same + way on a case-sensitive filesystem. A ``.zip`` path is treated as a packaged LIFT folder: it is extracted to a temporary directory (kept alive for the returned lexicon's @@ -544,14 +579,17 @@ def _resolve_ranges(self) -> None: basename = range_.href.replace("\\", "/").rpartition("/")[2] if basename: candidates.append(base / basename) + listings: dict[Path, dict[str, Path]] = {} for candidate in candidates: + found = _existing_file(candidate, listings) + if found is None: + continue try: - resolved = candidate.resolve() - exists = candidate.is_file() + resolved = found.resolve() except OSError: continue - if exists and resolved not in self.ranges_files: - self.ranges_files[resolved] = RangesFile.load(candidate) + if resolved not in self.ranges_files: + self.ranges_files[resolved] = RangesFile.load(found) def save(self, path: str | os.PathLike[str] | None = None) -> None: """Write the ``.lift`` file and every tracked ``.lift-ranges`` companion. diff --git a/tests/test_ranges_folder.py b/tests/test_ranges_folder.py index 9544db9..336a6bc 100644 --- a/tests/test_ranges_folder.py +++ b/tests/test_ranges_folder.py @@ -225,6 +225,55 @@ def test_missing_media_flags_broken_ref(tmp_path: Path) -> None: assert [r.href for r in missing] == ["pictures\\sdd.png"] +def _write_case_variant_pair(folder: Path, lift_name: str, ranges_name: str) -> Path: + """A loadable .lift plus companion under arbitrary filename casing. + + Named off the fixture stem so the header's ``range/@href`` basename + candidate finds nothing — only the sibling candidate can resolve these. + """ + folder.mkdir(parents=True, exist_ok=True) + (folder / lift_name).write_bytes((PAIR_DIR / "test20080407.lift").read_bytes()) + (folder / ranges_name).write_bytes((PAIR_DIR / "test20080407.lift-ranges").read_bytes()) + return folder / lift_name + + +def _case_sensitive_fs(folder: Path) -> bool: + (folder / "CaseProbe").mkdir() + return not (folder / "caseprobe").exists() + + +def test_companion_resolves_when_lift_suffix_is_uppercase(tmp_path: Path) -> None: + lift = _write_case_variant_pair(tmp_path / "pkg", "Dict.LIFT", "Dict.lift-ranges") + lexicon = sil_lift.load(lift) + assert lexicon.all_ranges()["grammatical-info"].elements + + +def test_companion_resolves_when_companion_suffix_is_uppercase(tmp_path: Path) -> None: + lift = _write_case_variant_pair(tmp_path / "pkg", "Dict.lift", "Dict.LIFT-RANGES") + lexicon = sil_lift.load(lift) + assert lexicon.all_ranges()["grammatical-info"].elements + + +def test_case_folded_companions_resolve_deterministically(tmp_path: Path) -> None: + if not _case_sensitive_fs(tmp_path): + pytest.skip("needs a case-sensitive filesystem to hold both spellings at once") + # Neither spelling matches the Dict.LIFT-ranges candidate exactly, so the + # tie-break picks one: lexicographically first, the same one every run. + folder = tmp_path / "pkg" + lift = _write_case_variant_pair(folder, "Dict.LIFT", "Dict.lift-ranges") + (folder / "Dict.Lift-ranges").write_bytes((folder / "Dict.lift-ranges").read_bytes()) + lexicon = sil_lift.load(lift) + assert [path.name for path in lexicon.ranges_files] == ["Dict.Lift-ranges"] + + +def test_absent_companion_stays_absent(tmp_path: Path) -> None: + # The fallback must not reach past a folder for a name that isn't in it. + folder = tmp_path / "pkg" + folder.mkdir() + (folder / "Dict.lift").write_bytes((PAIR_DIR / "test20080407.lift").read_bytes()) + assert sil_lift.load(folder / "Dict.lift").ranges_files == {} + + @pytest.mark.parametrize( ("href", "expected"), [ From 13e48a88213edf3ccd5aa6add3ac3e71aca45aa7 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 4 Aug 2026 16:43:47 -0400 Subject: [PATCH 2/3] Describe case-tolerant companion discovery under 0.1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.1.0 has not shipped, so there is no released behavior for an Unreleased entry to be fixing — the tolerance is simply part of what companion discovery does in the first release. Fold it into that bullet. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 25 ++++++++----------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88d4a26..5c73616 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,16 +18,6 @@ releases may contain breaking changes. ## [Unreleased] -### Fixed - -- Companion `.lift-ranges` files now resolve when the folder's filenames - disagree in case (`Dict.LIFT` beside `Dict.lift-ranges`, or the reverse). - Such a folder loads on Windows and macOS, whose filesystems fold case, but - on Linux the companion was silently skipped and its ranges went missing. - A candidate that matches no file exactly now falls back to one whose name - differs only in case; where several fold together the lexicographically - first wins, so resolution is stable across runs. - ## [0.1.0] - 2026-07-TBD ### Added @@ -58,13 +48,14 @@ releases may contain breaking changes. against the most recent `save()`. - LIFT-folder handling: `RangesFile` (standalone `.lift-ranges` documents, same fidelity guarantees), automatic companion discovery/tracking on load - (`Lexicon.ranges_files`), `save()` writes companions together, - `all_ranges()` merged view, `media_refs()` / `missing_media()` helpers, - build-from-scratch helpers `Lexicon.add_ranges_file()` / - `RangesFile.add_range()` / `Range.add_element()` (`save()` writes and - header-references a new companion beside the `.lift`); vendored - `schemas/lift-ranges-0.13.rng` — the first schema for standalone - ranges documents. + (`Lexicon.ranges_files`, resolving a companion whose filename differs from + the `.lift` only in case, as Windows-authored folders often do), `save()` + writes companions together, `all_ranges()` merged view, `media_refs()` / + `missing_media()` helpers, build-from-scratch helpers + `Lexicon.add_ranges_file()` / `RangesFile.add_range()` / + `Range.add_element()` (`save()` writes and header-references a new companion + beside the `.lift`); vendored `schemas/lift-ranges-0.13.rng` — the first + schema for standalone ranges documents. - Zipped LIFT packages: `sil_lift.load()` reads a `.zip` (both the flat and folder-wrapped layouts, junk entries like `__MACOSX` ignored), `Lexicon.save_zip()` writes one (carrying media, `WritingSystems/`, and other From cd9cb67a0f80099d0e0ef80ae6454cc7134aea8a Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Wed, 12 Aug 2026 15:16:08 -0400 Subject: [PATCH 3/3] Keep "entry" for LIFT entries in the companion-case fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The directory listing the fallback builds called its files "entries", the word this module uses for a LIFT everywhere else — the same collision that keeps byte regions from being called spans. Name them files. Spell the surrounding prose the way the rest of the package does: a fallback that runs rather than fires, a name that matched no file rather than missed, a helper named for the filesystem it probes rather than abbreviating it, and fixture names deliberately not taken from the corpus file. Unpack the two densest clauses so each reads in one pass. Co-Authored-By: Claude Opus 5 (1M context) --- src/sil_lift/_model.py | 22 +++++++++++----------- tests/test_ranges_folder.py | 10 +++++----- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/sil_lift/_model.py b/src/sil_lift/_model.py index 3895046..2bde198 100644 --- a/src/sil_lift/_model.py +++ b/src/sil_lift/_model.py @@ -460,11 +460,11 @@ def _existing_file(candidate: Path, listings: dict[Path, dict[str, Path]]) -> Pa case (``Dict.LIFT`` beside ``Dict.lift-ranges``) resolves there and, before this fallback, silently did not on a case-sensitive filesystem. - The fallback fires only where the exact name missed, so a case-folding - filesystem never reaches it and nothing changes there. Where several names - fold together, the lexicographically first wins — arbitrary, but stable - across runs, which "whatever the directory yields first" would not be. - ``listings`` caches one directory read per folder. + The fallback runs only where the exact name matched no file, so a + case-folding filesystem never reaches it and nothing changes there. Where + several names fold together, the lexicographically first wins — arbitrary, + but stable across runs, which "whatever the directory yields first" would + not be. ``listings`` caches one directory read per folder. """ try: if candidate.is_file(): @@ -473,14 +473,14 @@ def _existing_file(candidate: Path, listings: dict[Path, dict[str, Path]]) -> Pa return None folder = candidate.parent if folder not in listings: - entries: dict[str, Path] = {} + files: dict[str, Path] = {} try: for path in sorted(folder.iterdir()): if path.is_file(): - entries.setdefault(path.name.lower(), path) + files.setdefault(path.name.lower(), path) except OSError: pass # unreadable folder: no candidate resolves out of it - listings[folder] = entries + listings[folder] = files return listings[folder].get(candidate.name.lower()) @@ -543,9 +543,9 @@ def load(cls, path: str | os.PathLike[str], *, resolve_ranges: bool = True) -> L ``.lift`` file and its bare basename in the same directory (FLEx hrefs are usually dangling absolute ``file://C:/...`` paths from the exporting machine, so the basename is what resolves locally). A - candidate no file matches exactly still resolves to one whose name - differs only in case, so a folder authored on Windows loads the same - way on a case-sensitive filesystem. + candidate that no file matches exactly still resolves to a file whose + name differs only in case, so a folder authored on Windows loads the + same way on a case-sensitive filesystem. A ``.zip`` path is treated as a packaged LIFT folder: it is extracted to a temporary directory (kept alive for the returned lexicon's diff --git a/tests/test_ranges_folder.py b/tests/test_ranges_folder.py index 336a6bc..4869bc2 100644 --- a/tests/test_ranges_folder.py +++ b/tests/test_ranges_folder.py @@ -228,8 +228,8 @@ def test_missing_media_flags_broken_ref(tmp_path: Path) -> None: def _write_case_variant_pair(folder: Path, lift_name: str, ranges_name: str) -> Path: """A loadable .lift plus companion under arbitrary filename casing. - Named off the fixture stem so the header's ``range/@href`` basename - candidate finds nothing — only the sibling candidate can resolve these. + Deliberately not named after the fixture, so the header's ``range/@href`` + basename candidate finds nothing — only the sibling candidate resolves these. """ folder.mkdir(parents=True, exist_ok=True) (folder / lift_name).write_bytes((PAIR_DIR / "test20080407.lift").read_bytes()) @@ -237,7 +237,7 @@ def _write_case_variant_pair(folder: Path, lift_name: str, ranges_name: str) -> return folder / lift_name -def _case_sensitive_fs(folder: Path) -> bool: +def _case_sensitive_filesystem(folder: Path) -> bool: (folder / "CaseProbe").mkdir() return not (folder / "caseprobe").exists() @@ -255,7 +255,7 @@ def test_companion_resolves_when_companion_suffix_is_uppercase(tmp_path: Path) - def test_case_folded_companions_resolve_deterministically(tmp_path: Path) -> None: - if not _case_sensitive_fs(tmp_path): + if not _case_sensitive_filesystem(tmp_path): pytest.skip("needs a case-sensitive filesystem to hold both spellings at once") # Neither spelling matches the Dict.LIFT-ranges candidate exactly, so the # tie-break picks one: lexicographically first, the same one every run. @@ -267,7 +267,7 @@ def test_case_folded_companions_resolve_deterministically(tmp_path: Path) -> Non def test_absent_companion_stays_absent(tmp_path: Path) -> None: - # The fallback must not reach past a folder for a name that isn't in it. + # The fallback must not look outside the folder for a name not in it. folder = tmp_path / "pkg" folder.mkdir() (folder / "Dict.lift").write_bytes((PAIR_DIR / "test20080407.lift").read_bytes())