Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 43 additions & 5 deletions src/sil_lift/_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
49 changes: 49 additions & 0 deletions tests/test_ranges_folder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
[
Expand Down