From 910ac10bab3352a0707eca928134231cd19c0794 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 17 Sep 2026 10:27:57 -0400 Subject: [PATCH 1/8] Skip a companion candidate that is not a ranges document, and report it Companion discovery loaded whatever existed at a candidate path and let RangesFile.load's refusal propagate, so one unusable file cost the whole lexicon its entries. A zero-byte or truncated .lift-ranges beside the .lift - an interrupted export, a failed sync, a partial checkout - was enough, with no unusual href involved. _resolve_ranges now catches LiftError and OSError around the companion load and records the rejection instead, extending to every non-companion the treatment the .lift itself already got: skipped, not fatal. The record is a private dict keyed by resolved path, left None until discovery runs so "nothing was rejected" stays distinguishable from "nothing was looked at". Validation reports each rejection as a new unreadable-ranges-file warning, addressed to the offending file and naming the route that reached it. _ranges_candidates now yields that route alongside each path, deduping on the path so the surviving source is the one load used; a broken sidecar is then fixed in the file, while an href pointing at an unrelated file is fixed in the header, and an intact file a stray href happens to name is never reported as though it were the defect. RangesFile.load is unchanged and still raises. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 15 +++++++ docs/en/guides/folder-media.md | 2 + docs/en/guides/validate.md | 5 ++- src/sil_lift/_model.py | 70 +++++++++++++++++++++++++++----- src/sil_lift/_validate.py | 24 +++++++++-- tests/test_ranges_folder.py | 74 ++++++++++++++++++++++++++++++++-- 6 files changed, 172 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9290cc1..4bb95a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,21 @@ releases may contain breaking changes. ## [Unreleased] +### Added + +- `unreadable-ranges-file` (warning): a companion candidate that exists but + cannot be read as a `` document. Addressed to the offending + file, and naming the route that reached it — the conventional sibling, or + the header `range/@href` that pointed there. + +### Fixed + +- `Lexicon.load()` no longer raises when a companion candidate is not a ranges + document. A zero-byte or truncated `.lift-ranges` beside the `.lift`, a + second `.lift`, or any other file a header `range/@href` names is now + skipped and reported, instead of costing the whole lexicon its entries. + `RangesFile.load()` is unchanged and still raises. + ## [0.1.0] - 2026-07-TBD ### Added diff --git a/docs/en/guides/folder-media.md b/docs/en/guides/folder-media.md index 420134c..35884a9 100644 --- a/docs/en/guides/folder-media.md +++ b/docs/en/guides/folder-media.md @@ -31,6 +31,8 @@ Several candidates are tried, and every distinct file among them is loaded. Names that differ only in case or Unicode normalization still match — `Dict.LIFT` finds `Dict.lift-ranges` — unless several files match one name, which loads none of them and is reported as [`ambiguous-ranges-file`](validate.md#problem-codes). +Companion discovery never fails the load. A candidate that exists but cannot be read as a `` document — a zero-byte or truncated sidecar from an interrupted export, another `.lift`, an unrelated file a header href happens to name — is skipped, so the lexicon's entries still load, and it is reported as [`unreadable-ranges-file`](validate.md#problem-codes). `RangesFile.load()` still raises, so opening a companion directly is as strict as ever. + Pass `resolve_ranges=False` to `load()` to skip companion discovery. ## Media diff --git a/docs/en/guides/validate.md b/docs/en/guides/validate.md index 363f5c9..5a86533 100644 --- a/docs/en/guides/validate.md +++ b/docs/en/guides/validate.md @@ -24,11 +24,11 @@ Each `Problem` carries `level` (`"error"`/`"warning"`), a stable `code`, `messag 1. **RELAX NG** against the LIFT 0.13 grammar (vendored from lift-standard — a byte-identical copy committed into this package). 2. **Ranges schema** — this project's `lift-ranges-0.13.rng` — over every tracked `.lift-ranges` companion, addressed to the companion rather than the `.lift`. -3. **Semantic checks** the grammar cannot express — ten of them, one code each. +3. **Semantic checks** the grammar cannot express — eleven of them, one code each. ## Problem codes -Every finding carries one of these, whichever layer produced it — `schema` and `uri-not-rfc` come from the schema layers, the other eleven are semantic checks. The strings are a supported interface; `--strict` promotes every warning to an error. +Every finding carries one of these, whichever layer produced it — `schema` and `uri-not-rfc` come from the schema layers, the other twelve are semantic checks. The strings are a supported interface; `--strict` promotes every warning to an error. | code | level | what it flags | | ------------------------ | ------- | -------------------------------------------------------------------------- | @@ -44,6 +44,7 @@ Every finding carries one of these, whichever layer produced it — `schema` and | `range-parent` | error | a `range-element/@parent` no sibling id defines | | `schema` | error | a RELAX NG grammar violation, in the `.lift` or in a companion | | `undefined-range-value` | warning | a grammatical-info or range-keyed trait value the range does not list | +| `unreadable-ranges-file` | warning | a companion candidate that exists but is not a readable ranges document | | `uri-not-rfc` | warning | an href that is not a valid URI — FLEx's `file://C:/...` | All three layers work from the document serialized as it stands, so one that cannot be serialized at all is reported as a single `lone-surrogate` error instead — see [Fidelity guarantees](../fidelity.md#content-xml-cannot-represent). Validation is read-only: it reports the document as it stands, before the `dateModified` stamping a save does. Nothing generated is ever a finding, so validate-then-save is sound. diff --git a/src/sil_lift/_model.py b/src/sil_lift/_model.py index 43c9b61..f85b688 100644 --- a/src/sil_lift/_model.py +++ b/src/sil_lift/_model.py @@ -15,8 +15,9 @@ from dataclasses import dataclass, field from datetime import date, datetime from pathlib import Path, PurePosixPath, PureWindowsPath -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, NamedTuple +from ._errors import LiftError from ._extras import Extras from ._header import Header, Range from ._text import Annotation, Form, Multitext, Text, Trait @@ -540,27 +541,48 @@ def _existing_file(candidate: Path, listings: dict[Path, dict[str, list[Path]]]) return matches[0] if len(matches) == 1 else None -def _ranges_candidates(lift_path: Path, ranges: Iterable[Range]) -> list[Path]: +class _Candidate(NamedTuple): + """A place a companion may be found, and what sent :meth:`Lexicon.load` there.""" + + path: Path + #: The header ``range`` id and ``href`` that named it, or None for the + #: conventional sibling, which no part of the document asserts. + source: tuple[str, str] | None + + +class _Rejection(NamedTuple): + """Why a candidate that exists could not be taken as a companion.""" + + source: tuple[str, str] | None + reason: str + + +def _ranges_candidates(lift_path: Path, ranges: Iterable[Range]) -> list[_Candidate]: """Where a companion may be found, in the order :meth:`Lexicon.load` tries. Each path once: an href shared by several ranges, or agreeing with the - sibling, is one candidate however many times it is written. + sibling, is one candidate however many times it is written. The first + spelling wins, so a candidate's ``source`` is the route load took to it. """ base = lift_path.parent # with_name and with_suffix agree on every name that has an extension, but # with_suffix would raise on a name that has none — which parse_document # accepts, since it never inspects the extension. - candidates = [lift_path.with_name(lift_path.name + "-ranges")] + candidates = [_Candidate(lift_path.with_name(lift_path.name + "-ranges"), None)] for range_ in ranges: if range_.href is None: continue + source = (range_.id, range_.href) relative = _normalize_href(range_.href) if relative is not None: - candidates.append(base / relative) + candidates.append(_Candidate(base / relative, source)) basename = range_.href.replace("\\", "/").rpartition("/")[2] if basename: - candidates.append(base / basename) - return list(dict.fromkeys(candidates)) + candidates.append(_Candidate(base / basename, source)) + seen: dict[Path, _Candidate] = {} + for candidate in candidates: + seen.setdefault(candidate.path, candidate) + return list(seen.values()) def _same_file(left: Path, right: Path) -> bool: @@ -582,6 +604,17 @@ def _same_file(left: Path, right: Path) -> bool: return False +def _reason(path: Path, exc: Exception) -> str: + """``exc``'s message without the path it repeats. + + Every rejection is reported against the file it concerns, so a parse + error's own ``: `` prefix would print the path twice. + """ + text = str(exc) + prefix = f"{path}: " + return text[len(prefix) :] if text.startswith(prefix) else text + + def _same_dir(left: Path, right: Path | None) -> bool: """Whether two paths denote the same directory, spelling aside. @@ -601,6 +634,7 @@ class Lexicon: """The root handle: a parsed ``.lift`` document and its folder companions.""" __slots__ = ( + "_rejected_ranges", "_source", "_stamps", "_tempdir", @@ -630,6 +664,9 @@ def __init__( self._source: _SourceInfo | None = None # set by the reader self._stamps: dict[int, _EntryRecord] = {} # stamping baselines (see stamp_entries) self._tempdir: tempfile.TemporaryDirectory[str] | None = None # zip extraction, if any + # None until companion discovery runs, which is what lets validation + # tell "nothing was rejected" from "nothing was ever looked at". + self._rejected_ranges: dict[Path, _Rejection] | None = None @classmethod def load(cls, path: str | os.PathLike[str], *, resolve_ranges: bool = True) -> Lexicon: @@ -650,6 +687,13 @@ def load(cls, path: str | os.PathLike[str], *, resolve_ranges: bool = True) -> L ambiguous and resolves to none of them, which validation reports as ``ambiguous-ranges-file``; the ``.lift`` is never its own companion. + Companion discovery never raises. A candidate that exists but cannot be + read as a ```` document -- a zero-byte or truncated + sidecar, another ``.lift``, an unrelated file an href names -- is + skipped, so its entries still load, and validation reports it as + ``unreadable-ranges-file``. :meth:`RangesFile.load` is unchanged and + still raises. + A ``.zip`` path is treated as a packaged LIFT folder: it is extracted to a temporary directory (kept alive for the returned lexicon's lifetime) and the single contained ``.lift`` is loaded. @@ -667,11 +711,12 @@ def load(cls, path: str | os.PathLike[str], *, resolve_ranges: bool = True) -> L return lexicon def _resolve_ranges(self) -> None: + self._rejected_ranges = {} if self.path is None: return listings: dict[Path, dict[str, list[Path]]] = {} for candidate in _ranges_candidates(self.path, self.header.ranges): - found = _existing_file(candidate, listings) + found = _existing_file(candidate.path, listings) if found is None: continue try: @@ -686,7 +731,14 @@ def _resolve_ranges(self) -> None: _same_file(resolved, other) for other in (self.path, *self.ranges_files) ): continue - self.ranges_files[resolved] = RangesFile.load(found) + try: + self.ranges_files[resolved] = RangesFile.load(found) + except (LiftError, OSError) as exc: + # A candidate that exists but cannot serve as a companion is + # skipped, exactly as the .lift itself is above: one unusable + # file must not cost the entries. Recorded so validation can + # report it as unreadable-ranges-file. + self._rejected_ranges[resolved] = _Rejection(candidate.source, _reason(found, exc)) def _apply_stamps(self, stamp: bool, when: datetime | None) -> _StampUndo: """The stamping step shared by :meth:`save` and :meth:`save_zip`. diff --git a/src/sil_lift/_validate.py b/src/sil_lift/_validate.py index 7f85330..5863ccf 100644 --- a/src/sil_lift/_validate.py +++ b/src/sil_lift/_validate.py @@ -536,14 +536,14 @@ def range_named(name: str) -> str | None: # several candidate names can fold onto the same one. reported: set[tuple[Path, tuple[str, ...]]] = set() for candidate in _ranges_candidates(lexicon.path, lexicon.header.ranges): - matches = _folded_matches(candidate, listings) + matches = _folded_matches(candidate.path, listings) if len(matches) < 2: continue # One of the colliding files loaded, named exactly by another candidate. if any(_same_file(path, loaded) for path in matches for loaded in lexicon.ranges_files): continue names = sorted(path.name for path in matches) - key = (candidate.parent, tuple(names)) + key = (candidate.path.parent, tuple(names)) if key in reported: continue reported.add(key) @@ -552,7 +552,7 @@ def range_named(name: str) -> str | None: yield Problem( "warning", "ambiguous-ranges-file", - f"companion {candidate.name!a} matches {spellings}; they differ only " + f"companion {candidate.path.name!a} matches {spellings}; they differ only " "in case or Unicode normalization, so none of them is loaded", file=lexicon.path, ) @@ -582,6 +582,24 @@ def range_named(name: str) -> str | None: file=lexicon.path, ) + # Candidates that exist but could not be read as ranges documents. + # Addressed to the offending file, and naming the route load took to + # it: a broken sidecar is fixed in the file, an href pointing at an + # unrelated file is fixed in the header. + for path, rejection in (lexicon._rejected_ranges or {}).items(): + if rejection.source is None: + where = f"the conventional companion beside {lexicon.path.name!a}" + else: + named_by, href = rejection.source + where = f"this file, named by header range {named_by!r} href {href!r}," + yield Problem( + "warning", + "unreadable-ranges-file", + f"{where} could not be read as a ranges document, so it is " + f"not loaded: {rejection.reason}", + file=path, + ) + # Undefined range values: every grammatical-info and every trait whose # name matches a known range, anywhere in the entry. Empty values are # skipped -- FLEx writes them. diff --git a/tests/test_ranges_folder.py b/tests/test_ranges_folder.py index 96acfcc..e4e12d2 100644 --- a/tests/test_ranges_folder.py +++ b/tests/test_ranges_folder.py @@ -269,18 +269,84 @@ def test_companion_resolves_when_companion_suffix_is_uppercase(tmp_path: Path) - @pytest.mark.parametrize("companion", ["Dict.lift-ranges", "Dict.LIFT-RANGES"]) -def test_a_companion_that_is_not_a_ranges_document_fails_the_load( +def test_a_companion_that_is_not_a_ranges_document_is_skipped( tmp_path: Path, companion: str ) -> None: # A sibling match leaves no href to dangle and no collision to report, so - # skipping a broken companion would be silent — hence loud, however spelled. + # skipping a broken companion would be silent - hence the dedicated code, + # however the companion is spelled. folder = tmp_path / "pkg" folder.mkdir(parents=True) lift = (PAIR_DIR / "test20080407.lift").read_bytes() (folder / "Dict.lift").write_bytes(lift) (folder / companion).write_bytes(lift) - with pytest.raises(LiftParseError, match="expected "): - sil_lift.load(folder / "Dict.lift") + lexicon = sil_lift.load(folder / "Dict.lift") + assert lexicon.entries + assert lexicon.ranges_files == {} + problems = [p for p in lexicon.iter_problems() if p.code == "unreadable-ranges-file"] + assert [p.level for p in problems] == ["warning"] + assert problems[0].file == (folder / companion).resolve() + assert "the conventional companion beside 'Dict.lift'" in problems[0].message + assert "expected " in problems[0].message + + +@pytest.mark.parametrize("payload", [b"", b" bytes: + """A minimal LIFT 0.13 document whose one header range points at ``href``.""" + return ( + '' + '
' + f'' + '
' + ).encode() + + +def test_an_href_naming_an_unrelated_file_names_the_header_range(tmp_path: Path) -> None: + # Addressed to the file, but the actionable fix is the href: the png is + # intact and must not be touched. + folder = tmp_path / "pkg" + folder.mkdir(parents=True) + (folder / "Dict.lift").write_bytes(_lift_with_href("pictures.png")) + (folder / "pictures.png").write_bytes(bytes.fromhex("89504E470D0A1A0A") + b" not xml at all") + lexicon = sil_lift.load(folder / "Dict.lift") + problems = [p for p in lexicon.iter_problems() if p.code == "unreadable-ranges-file"] + assert len(problems) == 1 + assert problems[0].file == (folder / "pictures.png").resolve() + assert "header range 'etymology' href 'pictures.png'" in problems[0].message + + +def test_an_unreadable_companion_is_not_reported_without_discovery(tmp_path: Path) -> None: + folder = tmp_path / "pkg" + folder.mkdir(parents=True) + (folder / "Dict.lift").write_bytes((PAIR_DIR / "test20080407.lift").read_bytes()) + (folder / "Dict.lift-ranges").write_bytes(b"") + lexicon = sil_lift.load(folder / "Dict.lift", resolve_ranges=False) + assert [p for p in lexicon.iter_problems() if p.code == "unreadable-ranges-file"] == [] + + +def test_a_second_lift_named_by_an_href_is_skipped_not_fatal(tmp_path: Path) -> None: + folder = tmp_path / "pkg" + folder.mkdir(parents=True) + (folder / "Dict.lift").write_bytes(_lift_with_href("Other.lift")) + shutil.copy(PAIR_DIR / "test20080407.lift", folder / "Other.lift") + lexicon = sil_lift.load(folder / "Dict.lift") + assert len(lexicon.entries) == 1 + problems = [p for p in lexicon.iter_problems() if p.code == "unreadable-ranges-file"] + assert "root element is " in problems[0].message def test_case_folded_companions_resolve_to_neither(tmp_path: Path) -> None: From 1f31a7e2cb810a93d37695209d19f6329bcca946 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 17 Sep 2026 10:31:47 -0400 Subject: [PATCH 2/8] Report companion-folder findings only when companion discovery ran The three companion-folder checks derive from the .lift's path and a directory listing, never from what the load accepted, so they reported on companions even for a lexicon loaded with resolve_ranges=False. That is the caller saying companions are out of scope for this load, which makes a finding about one unwanted whether or not it is accurate. Each of the two older checks also holds a suppression guard that reads lexicon.ranges_files - a collision where one of the colliding files loaded under an exact name, an href whose range a loaded sibling supplies anyway - and neither guard can fire on an empty dict, so both reported cases a resolving load would have withheld. _rejected_ranges already distinguishes "discovery ran" from "discovery never ran", so it gates the whole block; no second flag is needed. missing-media stays ungated: media is never resolved into the model, so there is nothing for a caller to have opted out of. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 8 ++++++++ docs/en/guides/validate.md | 2 ++ src/sil_lift/_model.py | 7 +++++++ src/sil_lift/_validate.py | 8 ++++++-- tests/test_ranges_folder.py | 16 ++++++++++++---- 5 files changed, 35 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bb95a5..33c7690 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,14 @@ releases may contain breaking changes. skipped and reported, instead of costing the whole lexicon its entries. `RangesFile.load()` is unchanged and still raises. +### Changed + +- The companion-folder checks (`ambiguous-ranges-file`, `dangling-ranges-href`, + `unreadable-ranges-file`) are reported only when companion discovery ran. + A lexicon loaded with `resolve_ranges=False` put companions out of scope, + so none is reported for it; previously the first two were, including in + cases a resolving load would have suppressed. `missing-media` is unaffected. + ## [0.1.0] - 2026-07-TBD ### Added diff --git a/docs/en/guides/validate.md b/docs/en/guides/validate.md index 5a86533..3ac06eb 100644 --- a/docs/en/guides/validate.md +++ b/docs/en/guides/validate.md @@ -51,6 +51,8 @@ All three layers work from the document serialized as it stands, so one that can A companion name matching several files loads none of them: the ranges they define go absent until all but one is renamed or removed. +The three companion-folder codes — `ambiguous-ranges-file`, `dangling-ranges-href` and `unreadable-ranges-file` — are reported only when companion discovery ran. A lexicon loaded with `resolve_ranges=False` put companions out of scope, so none of them is reported for it. `missing-media` is unaffected: media is never resolved into the model, so nothing was opted out of. + ## Real-world FieldWorks (FLEx) output FieldWorks systematically writes some content that strict tooling rejects. Here is sil-lift's policy, so that real lexicons validate usefully: diff --git a/src/sil_lift/_model.py b/src/sil_lift/_model.py index f85b688..786642e 100644 --- a/src/sil_lift/_model.py +++ b/src/sil_lift/_model.py @@ -1059,6 +1059,13 @@ def iter_problems(self, *, require_ids: bool = False) -> Iterator[Problem]: With ``require_ids``, entries missing a ``guid`` and senses missing an ``id`` are reported as ``missing-id`` errors — stricter than LIFT (both are optional there), for workflows that re-import by a stable id. + + The companion-folder checks (``ambiguous-ranges-file``, + ``dangling-ranges-href``, ``unreadable-ranges-file``) are reported only + when companion discovery ran: a lexicon loaded with + ``resolve_ranges=False`` put companions out of scope, so none is + reported for it. ``missing-media`` is unaffected — media is never + resolved into the model, so nothing was opted out of. """ from ._validate import iter_lexicon_problems diff --git a/src/sil_lift/_validate.py b/src/sil_lift/_validate.py index 5863ccf..83ea3eb 100644 --- a/src/sil_lift/_validate.py +++ b/src/sil_lift/_validate.py @@ -527,7 +527,11 @@ def range_named(name: str) -> str | None: # here, not inside the file check below, which a lexicon with no path skips. header_ranges = {range_.id: range_named(range_.id) for range_ in lexicon.header.ranges} - if lexicon.path is not None: + # Only what companion discovery actually went looking for. With + # resolve_ranges=False the caller put companions out of scope for this + # load, so a finding about one is unwanted whether or not it is accurate. + rejected = lexicon._rejected_ranges + if lexicon.path is not None and rejected is not None: base = lexicon.path.parent listings: dict[Path, dict[str, list[Path]]] = {} @@ -586,7 +590,7 @@ def range_named(name: str) -> str | None: # Addressed to the offending file, and naming the route load took to # it: a broken sidecar is fixed in the file, an href pointing at an # unrelated file is fixed in the header. - for path, rejection in (lexicon._rejected_ranges or {}).items(): + for path, rejection in rejected.items(): if rejection.source is None: where = f"the conventional companion beside {lexicon.path.name!a}" else: diff --git a/tests/test_ranges_folder.py b/tests/test_ranges_folder.py index e4e12d2..3f31966 100644 --- a/tests/test_ranges_folder.py +++ b/tests/test_ranges_folder.py @@ -329,13 +329,21 @@ def test_an_href_naming_an_unrelated_file_names_the_header_range(tmp_path: Path) assert "header range 'etymology' href 'pictures.png'" in problems[0].message -def test_an_unreadable_companion_is_not_reported_without_discovery(tmp_path: Path) -> None: +def test_companion_findings_need_companion_discovery(tmp_path: Path) -> None: + # resolve_ranges=False puts companions out of scope for the load, so no + # finding about one is reported -- accurate or not. missing-media is not a + # companion finding and is unaffected. folder = tmp_path / "pkg" folder.mkdir(parents=True) - (folder / "Dict.lift").write_bytes((PAIR_DIR / "test20080407.lift").read_bytes()) + (folder / "Dict.lift").write_bytes(_lift_with_href("gone.lift-ranges")) (folder / "Dict.lift-ranges").write_bytes(b"") - lexicon = sil_lift.load(folder / "Dict.lift", resolve_ranges=False) - assert [p for p in lexicon.iter_problems() if p.code == "unreadable-ranges-file"] == [] + lift = folder / "Dict.lift" + assert {p.code for p in sil_lift.load(lift).iter_problems()} == { + "dangling-ranges-href", + "unreadable-ranges-file", + } + resolved = sil_lift.load(lift, resolve_ranges=False) + assert list(resolved.iter_problems()) == [] def test_a_second_lift_named_by_an_href_is_skipped_not_fatal(tmp_path: Path) -> None: From a45568f14eef6e87b8954cbea2efcf8d11ad02fa Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 17 Sep 2026 10:39:18 -0400 Subject: [PATCH 3/8] Compare rejected companion paths by identity, not by spelling Path.resolve() canonicalizes case on Windows but not on macOS, so a companion found under a candidate's spelling is recorded under that spelling rather than the name it has on disk. Two tests compared the reported path to the on-disk spelling with ==, which held only where resolve() folds case. samefile() settles it the way the rest of the companion code already does. Co-Authored-By: Claude Opus 5 (1M context) --- tests/test_ranges_folder.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/test_ranges_folder.py b/tests/test_ranges_folder.py index 3f31966..7797e75 100644 --- a/tests/test_ranges_folder.py +++ b/tests/test_ranges_folder.py @@ -285,7 +285,10 @@ def test_a_companion_that_is_not_a_ranges_document_is_skipped( assert lexicon.ranges_files == {} problems = [p for p in lexicon.iter_problems() if p.code == "unreadable-ranges-file"] assert [p.level for p in problems] == ["warning"] - assert problems[0].file == (folder / companion).resolve() + # resolve() canonicalizes case on Windows but not on macOS, so the recorded + # path can carry the candidate's spelling rather than the file's own. + assert problems[0].file is not None + assert problems[0].file.samefile(folder / companion) assert "the conventional companion beside 'Dict.lift'" in problems[0].message assert "expected " in problems[0].message @@ -325,7 +328,8 @@ def test_an_href_naming_an_unrelated_file_names_the_header_range(tmp_path: Path) lexicon = sil_lift.load(folder / "Dict.lift") problems = [p for p in lexicon.iter_problems() if p.code == "unreadable-ranges-file"] assert len(problems) == 1 - assert problems[0].file == (folder / "pictures.png").resolve() + assert problems[0].file is not None + assert problems[0].file.samefile(folder / "pictures.png") assert "header range 'etymology' href 'pictures.png'" in problems[0].message From 5b9785fda50b16c4e7ed9c99fcddef69224292cf Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 17 Sep 2026 11:49:52 -0400 Subject: [PATCH 4/8] Reconcile rejected companions against the header and folder as they stand The rejection record was replayed verbatim by validation, while every other companion-folder check re-derives from the current header and a live directory listing. The two disagreed as soon as either input moved: deleting a rejected file drew both dangling-ranges-href and unreadable-ranges-file, and repointing a header href left the warning quoting an href the document no longer carried. Validation now walks the current candidates and looks each resolved path up in the record, so a file that is gone, or that nothing names any more, drops out, and the route named is the one the header describes. Only the parser's reason stays from the load - as the content of a tracked companion does, which the schema layer reads from memory rather than from disk. The record has nothing left to carry but that reason, so _Rejection goes and the dict holds plain strings. Rejections also join the identity test that already guarded ranges_files. Nothing skipped a candidate resolving to an already-rejected file, so a second spelling of one file - a .. segment, a symlink, a case variant - parsed it again and overwrote its entry with the later route; on macOS, where resolve() leaves those spellings distinct, it also keyed and reported the same file twice. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 4 ++-- src/sil_lift/_model.py | 29 ++++++++++++---------------- src/sil_lift/_validate.py | 32 ++++++++++++++++++++++--------- tests/test_ranges_folder.py | 38 +++++++++++++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33c7690..18ed49c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,8 +22,8 @@ releases may contain breaking changes. - `unreadable-ranges-file` (warning): a companion candidate that exists but cannot be read as a `` document. Addressed to the offending - file, and naming the route that reached it — the conventional sibling, or - the header `range/@href` that pointed there. + file, and naming how the document reaches it — the conventional sibling, or + the header `range/@href` that points there. ### Fixed diff --git a/src/sil_lift/_model.py b/src/sil_lift/_model.py index 786642e..cad1ae6 100644 --- a/src/sil_lift/_model.py +++ b/src/sil_lift/_model.py @@ -550,13 +550,6 @@ class _Candidate(NamedTuple): source: tuple[str, str] | None -class _Rejection(NamedTuple): - """Why a candidate that exists could not be taken as a companion.""" - - source: tuple[str, str] | None - reason: str - - def _ranges_candidates(lift_path: Path, ranges: Iterable[Range]) -> list[_Candidate]: """Where a companion may be found, in the order :meth:`Lexicon.load` tries. @@ -666,7 +659,7 @@ def __init__( self._tempdir: tempfile.TemporaryDirectory[str] | None = None # zip extraction, if any # None until companion discovery runs, which is what lets validation # tell "nothing was rejected" from "nothing was ever looked at". - self._rejected_ranges: dict[Path, _Rejection] | None = None + self._rejected_ranges: dict[Path, str] | None = None @classmethod def load(cls, path: str | os.PathLike[str], *, resolve_ranges: bool = True) -> Lexicon: @@ -711,7 +704,8 @@ def load(cls, path: str | os.PathLike[str], *, resolve_ranges: bool = True) -> L return lexicon def _resolve_ranges(self) -> None: - self._rejected_ranges = {} + rejected: dict[Path, str] = {} + self._rejected_ranges = rejected if self.path is None: return listings: dict[Path, dict[str, list[Path]]] = {} @@ -726,19 +720,20 @@ def _resolve_ranges(self) -> None: # Identity, not content, settles the .lift: it is the document being # loaded, so a header href folding onto it names no companion. Two # spellings of one companion, which resolve() leaves distinct on - # macOS, would otherwise load and write it twice. - if resolved in self.ranges_files or any( - _same_file(resolved, other) for other in (self.path, *self.ranges_files) + # macOS, would otherwise load and write it twice -- and a rejected + # one, parse and report it twice. + if resolved in self.ranges_files or resolved in rejected: + continue + if any( + _same_file(resolved, other) for other in (self.path, *self.ranges_files, *rejected) ): continue try: self.ranges_files[resolved] = RangesFile.load(found) except (LiftError, OSError) as exc: - # A candidate that exists but cannot serve as a companion is - # skipped, exactly as the .lift itself is above: one unusable - # file must not cost the entries. Recorded so validation can - # report it as unreadable-ranges-file. - self._rejected_ranges[resolved] = _Rejection(candidate.source, _reason(found, exc)) + # One unusable file must not cost the entries: skipped like + # the .lift, and recorded for unreadable-ranges-file to report. + rejected[resolved] = _reason(found, exc) def _apply_stamps(self, stamp: bool, when: datetime | None) -> _StampUndo: """The stamping step shared by :meth:`save` and :meth:`save_zip`. diff --git a/src/sil_lift/_validate.py b/src/sil_lift/_validate.py index 83ea3eb..baedaec 100644 --- a/src/sil_lift/_validate.py +++ b/src/sil_lift/_validate.py @@ -587,21 +587,35 @@ def range_named(name: str) -> str | None: ) # Candidates that exist but could not be read as ranges documents. - # Addressed to the offending file, and naming the route load took to - # it: a broken sidecar is fixed in the file, an href pointing at an - # unrelated file is fixed in the header. - for path, rejection in rejected.items(): - if rejection.source is None: + # Walked rather than read straight off the record: the header and the + # folder can both have moved since discovery, and a rejection whose + # file is gone, or that nothing names any more, would contradict the + # checks above. Only the parser's reason is necessarily historical. + seen: set[Path] = set() + for candidate in _ranges_candidates(lexicon.path, lexicon.header.ranges): + found = _existing_file(candidate.path, listings) + if found is None: + continue + try: + resolved = found.resolve() + except OSError: + continue + reason = rejected.get(resolved) + if reason is None or resolved in seen: + continue + seen.add(resolved) + # A broken sidecar is fixed in the file; an href pointing at an + # unrelated file is fixed in the header. Naming the route says which. + if candidate.source is None: where = f"the conventional companion beside {lexicon.path.name!a}" else: - named_by, href = rejection.source + named_by, href = candidate.source where = f"this file, named by header range {named_by!r} href {href!r}," yield Problem( "warning", "unreadable-ranges-file", - f"{where} could not be read as a ranges document, so it is " - f"not loaded: {rejection.reason}", - file=path, + f"{where} could not be read as a ranges document, so it is not loaded: {reason}", + file=resolved, ) # Undefined range values: every grammatical-info and every trait whose diff --git a/tests/test_ranges_folder.py b/tests/test_ranges_folder.py index 7797e75..4a216ed 100644 --- a/tests/test_ranges_folder.py +++ b/tests/test_ranges_folder.py @@ -333,6 +333,44 @@ def test_an_href_naming_an_unrelated_file_names_the_header_range(tmp_path: Path) assert "header range 'etymology' href 'pictures.png'" in problems[0].message +def test_a_rejection_is_dropped_once_its_file_is_gone(tmp_path: Path) -> None: + # The record is discovery-time; the checks beside it read the folder as it + # stands, so a replayed rejection would contradict dangling-ranges-href. + folder = tmp_path / "pkg" + folder.mkdir(parents=True) + (folder / "Dict.lift").write_bytes(_lift_with_href("bad.lift-ranges")) + (folder / "bad.lift-ranges").write_bytes(b"") + lexicon = sil_lift.load(folder / "Dict.lift") + assert [p.code for p in lexicon.iter_problems()] == ["unreadable-ranges-file"] + (folder / "bad.lift-ranges").unlink() + assert [p.code for p in lexicon.iter_problems()] == ["dangling-ranges-href"] + + +def test_a_rejection_is_dropped_once_the_href_moves(tmp_path: Path) -> None: + folder = tmp_path / "pkg" + folder.mkdir(parents=True) + (folder / "Dict.lift").write_bytes(_lift_with_href("bad.lift-ranges")) + (folder / "bad.lift-ranges").write_bytes(b"") + lexicon = sil_lift.load(folder / "Dict.lift") + lexicon.header.ranges[0].href = "elsewhere.lift-ranges" + # Nothing in the document names bad.lift-ranges any more, so nothing may + # report it -- least of all under the href it no longer carries. + assert [p.code for p in lexicon.iter_problems()] == ["dangling-ranges-href"] + + +def test_two_candidate_spellings_of_one_rejected_file_report_once(tmp_path: Path) -> None: + # Candidates dedup on spelling, so a .. segment survives as a second route + # to the sibling's own file. The first route is the one load took. + folder = tmp_path / "pkg" + (folder / "sub").mkdir(parents=True) + (folder / "Dict.lift").write_bytes(_lift_with_href("sub/../Dict.lift-ranges")) + (folder / "Dict.lift-ranges").write_bytes(b"") + lexicon = sil_lift.load(folder / "Dict.lift") + problems = [p for p in lexicon.iter_problems() if p.code == "unreadable-ranges-file"] + assert len(problems) == 1 + assert "the conventional companion beside 'Dict.lift'" in problems[0].message + + def test_companion_findings_need_companion_discovery(tmp_path: Path) -> None: # resolve_ranges=False puts companions out of scope for the load, so no # finding about one is reported -- accurate or not. missing-media is not a From c18bcd6102ef7d3482b3e9aef2eaa1c6994d3676 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 17 Sep 2026 14:17:30 -0400 Subject: [PATCH 5/8] Match a rejected companion by file identity, not by spelling _resolve_ranges keys a rejection under the spelling resolve() returned for the candidate that reached it, and guards that dict with an exact test and a _same_file test, since resolve() canonicalizes case on Windows but not on macOS. Validation looked the same dict up by == only, so on macOS a candidate reaching a rejected file under a second spelling found nothing and the warning went silent - a case-only edit to a header href being one way in. The lookup now falls back to _same_file, and dedups on the key that matched rather than on the candidate's spelling, so two spellings of one rejected file still report once. The test needs a filesystem that folds case while resolve() keeps the spelling given, which is macOS and not Windows or Linux. It probes for that behavior rather than for a platform name: a macOS volume formatted case-sensitive does not have it. Co-Authored-By: Claude Opus 5 (1M context) --- src/sil_lift/_validate.py | 15 ++++++++++++--- tests/test_ranges_folder.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/sil_lift/_validate.py b/src/sil_lift/_validate.py index baedaec..3800b62 100644 --- a/src/sil_lift/_validate.py +++ b/src/sil_lift/_validate.py @@ -600,10 +600,19 @@ def range_named(name: str) -> str | None: resolved = found.resolve() except OSError: continue - reason = rejected.get(resolved) - if reason is None or resolved in seen: + # Exact spelling, then identity, the way _resolve_ranges keys + # these: a candidate reaching a rejected file under another + # spelling yields a path no == will match -- see _same_file. + # Dedup on the key that matched, so two spellings report once. + recorded = ( + resolved + if resolved in rejected + else next((other for other in rejected if _same_file(resolved, other)), None) + ) + if recorded is None or recorded in seen: continue - seen.add(resolved) + seen.add(recorded) + reason = rejected[recorded] # A broken sidecar is fixed in the file; an href pointing at an # unrelated file is fixed in the header. Naming the route says which. if candidate.source is None: diff --git a/tests/test_ranges_folder.py b/tests/test_ranges_folder.py index 4a216ed..3910a15 100644 --- a/tests/test_ranges_folder.py +++ b/tests/test_ranges_folder.py @@ -256,6 +256,21 @@ def _case_sensitive(folder: Path) -> bool: return sensitive +def _keeps_candidate_case(folder: Path) -> bool: + """Whether the filesystem folds case but resolve() keeps the spelling given. + + True on macOS, where one file reached under two spellings resolves to two + distinct paths. Windows folds and canonicalizes, so both spellings resolve + alike; Linux does not fold, so only the real one is ever reached. + """ + probe = folder / "ResolveProbe" + probe.write_bytes(b"") + other = folder / "RESOLVEPROBE" + keeps = other.is_file() and other.resolve().name == "RESOLVEPROBE" + probe.unlink() + return keeps + + 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) @@ -346,6 +361,23 @@ def test_a_rejection_is_dropped_once_its_file_is_gone(tmp_path: Path) -> None: assert [p.code for p in lexicon.iter_problems()] == ["dangling-ranges-href"] +def test_a_case_only_href_edit_still_reaches_the_rejected_file(tmp_path: Path) -> None: + if not _keeps_candidate_case(tmp_path): + pytest.skip("needs a filesystem that folds case but resolves to the spelling given") + # The rejection is keyed by the spelling discovery resolved, so a candidate + # reaching the same file under another one matches only by identity. + folder = tmp_path / "pkg" + folder.mkdir(parents=True) + (folder / "Dict.lift").write_bytes(_lift_with_href("bad.lift-ranges")) + (folder / "bad.lift-ranges").write_bytes(b"") + lexicon = sil_lift.load(folder / "Dict.lift") + assert [p.code for p in lexicon.iter_problems()] == ["unreadable-ranges-file"] + lexicon.header.ranges[0].href = "BAD.lift-ranges" + problems = [p for p in lexicon.iter_problems() if p.code == "unreadable-ranges-file"] + assert len(problems) == 1 + assert "href 'BAD.lift-ranges'" in problems[0].message + + def test_a_rejection_is_dropped_once_the_href_moves(tmp_path: Path) -> None: folder = tmp_path / "pkg" folder.mkdir(parents=True) From fd6b3adb31d4d049bb0d5978fe5a0786090c3e1b Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 17 Sep 2026 14:29:26 -0400 Subject: [PATCH 6/8] Count the twelfth semantic check in the unreleased 0.1.0 The problem-code table and the layer list in the validation guide gave different counts - eleven against ten - where the table was right. A twelfth code lands in both, so they now agree, matching the table's fourteen rows less the two the schema layers produce. The changelog counted eleven with the table and follows. 0.1.0 has not shipped, so nothing else here is a change to record: a companion candidate that cannot be read is part of what discovery does in the first release, described where it is looked up rather than as a delta against behavior no reader has seen. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 25 +------------------------ docs/en/guides/validate.md | 2 +- 2 files changed, 2 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18ed49c..959c836 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,29 +18,6 @@ releases may contain breaking changes. ## [Unreleased] -### Added - -- `unreadable-ranges-file` (warning): a companion candidate that exists but - cannot be read as a `` document. Addressed to the offending - file, and naming how the document reaches it — the conventional sibling, or - the header `range/@href` that points there. - -### Fixed - -- `Lexicon.load()` no longer raises when a companion candidate is not a ranges - document. A zero-byte or truncated `.lift-ranges` beside the `.lift`, a - second `.lift`, or any other file a header `range/@href` names is now - skipped and reported, instead of costing the whole lexicon its entries. - `RangesFile.load()` is unchanged and still raises. - -### Changed - -- The companion-folder checks (`ambiguous-ranges-file`, `dangling-ranges-href`, - `unreadable-ranges-file`) are reported only when companion discovery ran. - A lexicon loaded with `resolve_ranges=False` put companions out of scope, - so none is reported for it; previously the first two were, including in - cases a resolving load would have suppressed. `missing-media` is unaffected. - ## [0.1.0] - 2026-07-TBD ### Added @@ -94,7 +71,7 @@ releases may contain breaking changes. file, entry, and line it concerns. RELAX NG layer with two documented departures from strict validation (invalid `file://` hrefs downgraded to `uri-not-rfc` warnings; legal interleaving not falsely flagged); vendored - ranges schema over companions; and eleven semantic checks, one `Problem` + ranges schema over companions; and twelve semantic checks, one `Problem` code each (with missing-id opt-in via `require_ids`). Every code is described in `docs/en/guides/validate.md`. - Canonical sort: `Lexicon.sort()` / `RangesFile.sort()` (entries by diff --git a/docs/en/guides/validate.md b/docs/en/guides/validate.md index 3ac06eb..5c323da 100644 --- a/docs/en/guides/validate.md +++ b/docs/en/guides/validate.md @@ -24,7 +24,7 @@ Each `Problem` carries `level` (`"error"`/`"warning"`), a stable `code`, `messag 1. **RELAX NG** against the LIFT 0.13 grammar (vendored from lift-standard — a byte-identical copy committed into this package). 2. **Ranges schema** — this project's `lift-ranges-0.13.rng` — over every tracked `.lift-ranges` companion, addressed to the companion rather than the `.lift`. -3. **Semantic checks** the grammar cannot express — eleven of them, one code each. +3. **Semantic checks** the grammar cannot express — twelve of them, one code each. ## Problem codes From 4dc1566c87bfef8cfcb12f1fb83f4ffa7f10f94f Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 17 Sep 2026 14:38:41 -0400 Subject: [PATCH 7/8] Tighten the companion-discovery prose in the guides The unreadable-companion paragraph in the folder guide ran to three sentences where the paragraph above it states the same kind of rule in one, and wedged a three-item aside between its subject and verb. It keeps the example readers actually hit, drops the two that were there for completeness, and loses the "still raises" framing, which implies a release that has not happened. The companion-folder gate in the validation guide named a lexicon as what put companions out of scope, where the caller is what did. Co-Authored-By: Claude Opus 5 (1M context) --- docs/en/guides/folder-media.md | 2 +- docs/en/guides/validate.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/en/guides/folder-media.md b/docs/en/guides/folder-media.md index 35884a9..4e03341 100644 --- a/docs/en/guides/folder-media.md +++ b/docs/en/guides/folder-media.md @@ -31,7 +31,7 @@ Several candidates are tried, and every distinct file among them is loaded. Names that differ only in case or Unicode normalization still match — `Dict.LIFT` finds `Dict.lift-ranges` — unless several files match one name, which loads none of them and is reported as [`ambiguous-ranges-file`](validate.md#problem-codes). -Companion discovery never fails the load. A candidate that exists but cannot be read as a `` document — a zero-byte or truncated sidecar from an interrupted export, another `.lift`, an unrelated file a header href happens to name — is skipped, so the lexicon's entries still load, and it is reported as [`unreadable-ranges-file`](validate.md#problem-codes). `RangesFile.load()` still raises, so opening a companion directly is as strict as ever. +A candidate that cannot be read as a `` document (e.g., a zero-byte file from an interrupted export) is skipped rather than failing the load, and is reported as [`unreadable-ranges-file`](validate.md#problem-codes). Loading one directly with `RangesFile.load()` raises instead. Pass `resolve_ranges=False` to `load()` to skip companion discovery. diff --git a/docs/en/guides/validate.md b/docs/en/guides/validate.md index 5c323da..98d43ab 100644 --- a/docs/en/guides/validate.md +++ b/docs/en/guides/validate.md @@ -51,7 +51,7 @@ All three layers work from the document serialized as it stands, so one that can A companion name matching several files loads none of them: the ranges they define go absent until all but one is renamed or removed. -The three companion-folder codes — `ambiguous-ranges-file`, `dangling-ranges-href` and `unreadable-ranges-file` — are reported only when companion discovery ran. A lexicon loaded with `resolve_ranges=False` put companions out of scope, so none of them is reported for it. `missing-media` is unaffected: media is never resolved into the model, so nothing was opted out of. +The three companion-folder codes (`ambiguous-ranges-file`, `dangling-ranges-href` and `unreadable-ranges-file`) are reported only when companion discovery ran. Loading with `resolve_ranges=False` puts companions out of scope, so none of them is reported. `missing-media` is unaffected: media is never resolved into the model, so nothing was opted out of. ## Real-world FieldWorks (FLEx) output From 14350b6a28380d1284a4abb206cf68e07c74e479 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 21 Sep 2026 09:00:41 -0400 Subject: [PATCH 8/8] Say that attaching a companion does not reopen the folder checks add_ranges_file attaches a companion and writes its header range/@href without reading the folder, so _rejected_ranges stays None and the three companion-folder codes stay silent after a resolve_ranges=False load. That is what those checks need: they re-derive from the folder as it stands, and on what such a load holds, a header range supplied by a sibling companion that went unloaded reads as a dangling href. The guide and the docstrings stated the rule as companion discovery having run, which reads as though putting a companion back in scope brings them back. The validation guide, Lexicon.iter_problems and add_ranges_file now say it does not, the gate carries the misfire it avoids, and a test pins the silence. The four normalization fixtures and the probe name beside them are spelled with \N{...} escapes. They differ only in which accent is decomposed, so an editor or tool that normalizes the source would collapse the distinction they exist to hold - silently, since a probe that no longer decomposes reports the filesystem as insensitive and skips its tests rather than failing them. Co-Authored-By: Claude Opus 5 (1M context) --- docs/en/guides/validate.md | 2 +- src/sil_lift/_model.py | 14 ++++++++++---- src/sil_lift/_validate.py | 4 ++++ tests/test_ranges_folder.py | 29 ++++++++++++++++++++++------- 4 files changed, 37 insertions(+), 12 deletions(-) diff --git a/docs/en/guides/validate.md b/docs/en/guides/validate.md index 98d43ab..729ae05 100644 --- a/docs/en/guides/validate.md +++ b/docs/en/guides/validate.md @@ -51,7 +51,7 @@ All three layers work from the document serialized as it stands, so one that can A companion name matching several files loads none of them: the ranges they define go absent until all but one is renamed or removed. -The three companion-folder codes (`ambiguous-ranges-file`, `dangling-ranges-href` and `unreadable-ranges-file`) are reported only when companion discovery ran. Loading with `resolve_ranges=False` puts companions out of scope, so none of them is reported. `missing-media` is unaffected: media is never resolved into the model, so nothing was opted out of. +The three companion-folder codes (`ambiguous-ranges-file`, `dangling-ranges-href`, and `unreadable-ranges-file`) are reported only when companion discovery ran. Loading with `resolve_ranges=False` puts companions out of scope, so none of them is reported; attaching one afterwards with `add_ranges_file()` does not bring them back, since it never reads the folder these codes report on. `missing-media` is unaffected: media is never resolved into the model, so nothing was opted out of. ## Real-world FieldWorks (FLEx) output diff --git a/src/sil_lift/_model.py b/src/sil_lift/_model.py index cad1ae6..dca3e53 100644 --- a/src/sil_lift/_model.py +++ b/src/sil_lift/_model.py @@ -1056,11 +1056,13 @@ def iter_problems(self, *, require_ids: bool = False) -> Iterator[Problem]: are optional there), for workflows that re-import by a stable id. The companion-folder checks (``ambiguous-ranges-file``, - ``dangling-ranges-href``, ``unreadable-ranges-file``) are reported only - when companion discovery ran: a lexicon loaded with + ``dangling-ranges-href``, and ``unreadable-ranges-file``) are reported + only when companion discovery ran: a lexicon loaded with ``resolve_ranges=False`` put companions out of scope, so none is - reported for it. ``missing-media`` is unaffected — media is never - resolved into the model, so nothing was opted out of. + reported for it, and attaching one afterwards with + :meth:`add_ranges_file` does not reinstate them. ``missing-media`` is + unaffected: media is never resolved into the model, so nothing was + opted out of. """ from ._validate import iter_lexicon_problems @@ -1094,6 +1096,10 @@ def add_ranges_file(self, ranges_file: RangesFile | None = None, *, href: str) - (or call again to reference ranges added later). Returns the attached (or newly created) :class:`RangesFile`. + + Attaching a companion does not read the folder, so it does not put one + back in scope for the companion-folder checks: under + ``resolve_ranges=False`` those stay silent (see :meth:`iter_problems`). """ if ranges_file is None: ranges_file = RangesFile() diff --git a/src/sil_lift/_validate.py b/src/sil_lift/_validate.py index 3800b62..eb7b9da 100644 --- a/src/sil_lift/_validate.py +++ b/src/sil_lift/_validate.py @@ -530,6 +530,10 @@ def range_named(name: str) -> str | None: # Only what companion discovery actually went looking for. With # resolve_ranges=False the caller put companions out of scope for this # load, so a finding about one is unwanted whether or not it is accurate. + # add_ranges_file attaches a companion without reading the folder, so it + # does not reinstate these: the checks below re-derive from a folder this + # load never looked at, where a sibling that supplies a header range went + # unloaded and so reads as a dangling href. rejected = lexicon._rejected_ranges if lexicon.path is not None and rejected is not None: base = lexicon.path.parent diff --git a/tests/test_ranges_folder.py b/tests/test_ranges_folder.py index 3910a15..3a56d23 100644 --- a/tests/test_ranges_folder.py +++ b/tests/test_ranges_folder.py @@ -420,6 +420,21 @@ def test_companion_findings_need_companion_discovery(tmp_path: Path) -> None: assert list(resolved.iter_problems()) == [] +def test_attaching_a_companion_does_not_reopen_companion_findings(tmp_path: Path) -> None: + # add_ranges_file attaches a companion and writes its header href, but it + # reads nothing from the folder, so the folder stays out of scope. Running + # the checks over what a non-resolving load holds would misreport it. + folder = tmp_path / "pkg" + folder.mkdir(parents=True) + (folder / "Dict.lift").write_bytes(_lift_with_href("gone.lift-ranges")) + (folder / "Dict.lift-ranges").write_bytes(b"") + lexicon = sil_lift.load(folder / "Dict.lift", resolve_ranges=False) + ranges = RangesFile() + ranges.add_range("semantic-domain-ddp4").add_element("1.6.1.2") + lexicon.add_ranges_file(ranges, href="Other.lift-ranges") + assert list(lexicon.iter_problems()) == [] + + def test_a_second_lift_named_by_an_href_is_skipped_not_fatal(tmp_path: Path) -> None: folder = tmp_path / "pkg" folder.mkdir(parents=True) @@ -461,16 +476,16 @@ def test_an_exactly_named_companion_ignores_the_variant_beside_it(tmp_path: Path assert [p for p in lexicon.iter_problems() if p.code == "ambiguous-ranges-file"] == [] -# One stem in the four spellings a filesystem that folds case still keeps -# apart: each accent composed or decomposed, independently. -_COMPOSED = "Ñandú" -_N_SPLIT = "Ñandú" -_U_SPLIT = "Ñandú" -_BOTH_SPLIT = "Ñandú" +# One stem (Ñandú) in the four spellings a filesystem that folds case still +# keeps apart: each accent composed or decomposed, independently. +_COMPOSED = "\N{LATIN CAPITAL LETTER N WITH TILDE}and\N{LATIN SMALL LETTER U WITH ACUTE}" +_N_SPLIT = "N\N{COMBINING TILDE}and\N{LATIN SMALL LETTER U WITH ACUTE}" +_U_SPLIT = "\N{LATIN CAPITAL LETTER N WITH TILDE}andu\N{COMBINING ACUTE ACCENT}" +_BOTH_SPLIT = "N\N{COMBINING TILDE}andu\N{COMBINING ACUTE ACCENT}" def _normalization_sensitive(folder: Path) -> bool: - probe = folder / "NormProbé" + probe = folder / "NormProbe\N{COMBINING ACUTE ACCENT}" probe.mkdir() sensitive = not (folder / unicodedata.normalize("NFC", probe.name)).exists() probe.rmdir()