diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d1dd95..5c73616 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,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 diff --git a/src/sil_lift/_model.py b/src/sil_lift/_model.py index e130f9f..2bde198 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 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(): + return candidate + except OSError: + return None + folder = candidate.parent + if folder not in listings: + files: dict[str, Path] = {} + try: + for path in sorted(folder.iterdir()): + if path.is_file(): + files.setdefault(path.name.lower(), path) + except OSError: + pass # unreadable folder: no candidate resolves out of it + listings[folder] = files + 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 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 @@ -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..4869bc2 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. + + 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()) + (folder / ranges_name).write_bytes((PAIR_DIR / "test20080407.lift-ranges").read_bytes()) + return folder / lift_name + + +def _case_sensitive_filesystem(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_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. + 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 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()) + assert sil_lift.load(folder / "Dict.lift").ranges_files == {} + + @pytest.mark.parametrize( ("href", "expected"), [