sillsdev / sillsdev/python-sil-lift
A companion candidate that is not a ranges document fails the whole load, so a zero-byte .lift-ranges makes the lexicon unloadable
@imnasnainaec is already working on this.
Since Sep 17, 2026.
- Dominant language
- Python
- Stars
- 1
- Forks
- 0
- Avg merge
- 10d 11h
- Merged PRs (30d)
- 6
Description
Lexicon.load() raises LiftParseError and hands back nothing when a companion candidate exists but is not a .lift-ranges document. One unusable file costs the whole lexicon, including its entries — where every other candidate that cannot serve as a companion is skipped, and the reference reported as dangling-ranges-href.
The realistic trigger needs no unusual href at all: a zero-byte or truncated Dict.lift-ranges beside Dict.lift — an interrupted export, a failed sync, a partial checkout — makes the lexicon unloadable.
Reproduction
Sibling only, no href involved:
(folder / "Dict.lift").write_bytes(intact_lift)
(folder / "Dict.lift-ranges").write_bytes(b"") # or a truncated copy
sil_lift.load(folder / "Dict.lift")
zero-byte sibling LiftParseError: ...\pkg\Dict.lift-ranges: not well-formed XML: Document is empty, line 1, column 1
truncated sibling LiftParseError: ...\pkg\Dict.lift-ranges: not well-formed XML: ...
intact sibling loaded ok, ranges_files=['Dict.lift-ranges']
Any header range/@href resolving to any existing non-ranges file does the same. Exact spellings throughout — no case folding involved:
another .lift LiftParseError: ...\pkg\Other.lift: root element is <lift>, expected <lift-ranges>
a text file LiftParseError: ...\pkg\notes.txt: not well-formed XML: Start tag expected, ...
a png LiftParseError: ...\pkg\pictures.png: not well-formed XML: Start tag expected, ...
well-formed XML, wrong root LiftParseError: ...\pkg\stuff.xml: root element is <stuff>, expected <lift-ranges>
Mechanism
_resolve_ranges (src/sil_lift/_model.py) loads whatever exists:
found = _existing_file(candidate, listings)
if found is None:
continue
...
self.ranges_files[resolved] = RangesFile.load(found)
Nothing between the existence test and the load asks whether the file is a ranges document. RangesFile.load decides, and its refusal propagates straight out of Lexicon.load.
One file is already exempt: the lexicon itself. #19 added a _same_file check for it precisely because a case-variant href folding onto the .lift took the whole load down, and settled on skipping the candidate and letting dangling-ranges-href report the reference. That establishes the principle — a candidate that exists but cannot serve as a companion is skipped and reported, not fatal. It just was not extended past the lexicon: a second .lift in the folder, or any other file, still ends the load.
Consequences
sil-lift validatecannot diagnose it. The CLI catchesLiftError, printserror: ..., and exits 2 with noProblemstream — the tool that exists to report a broken export cannot reach the report. (exit 1is the documented "errors found" code.)- Zip packages inherit it:
load_zipextracts and callsLexicon.load. - The only workaround is
resolve_ranges=False, which drops every companion, so ranges-dependent checks (undefined-range-value, the ranges schema layer) go silent too.
Fix options
- Never raise from companion discovery. Skip a candidate that does not parse as a ranges document, and report it. This extends #19's existing treatment of the lexicon to every non-companion file, so the whole class behaves one way.
- Needs somewhere to put the reason: validation re-derives existence independently via
_existing_file, so without a record it would reportdangling-ranges-hrefsaying "no companion file was found" when one was found and rejected. Either the load path records rejections for validation to read, or a new code covers "found, but not a ranges document".
- Needs somewhere to put the reason: validation re-derives existence independently via
- Distinguish an asserted reference from a guessed one. The conventional sibling and an href basename are discovery heuristics — nothing in the document claims those files exist, so failing over them is disproportionate. A relative href the header writes out is an assertion by the document, where a rejection is meaningful. The code already knows which candidate is which, so this line can be drawn if the wholesale skip looks too permissive.
- Leave
loadstrict and document it. Smallest change, but keeps a zero-byte sidecar fatal to reading a lexicon's entries, which is hard to defend.
The first looks right, with the second as the fallback if silently skipping a malformed genuine companion is judged to hide too much.
Notes
Pre-existing: before #19, _resolve_ranges went straight from candidate.is_file() to RangesFile.load(candidate), so this failed the same way — the reproductions above use exact spellings and never reach the folded lookup. Split out of review discussion on #19, which kept its scope to companion resolution.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.