Skip to content

API reference🔗

The public API is exactly what sil_lift re-exports at the top level; underscore-prefixed modules are implementation details.

Read, write, and validate LIFT (Lexicon Interchange Format) 0.13 lexicons.

The public API is exactly what this module re-exports; underscore-prefixed modules are implementation details.

LiftError 🔗

Bases: Exception

Base class for all sil-lift errors.

LiftParseError 🔗

Bases: LiftError

A document could not be read as LIFT 0.13.

Raised for non-XML input, a non-LIFT root element, or a LIFT version other than 0.13 (sil-lift does not migrate; see the lift-standard repo's XSLTs for one-off migration of legacy files).

LiftWriteError 🔗

Bases: LiftError

An in-memory document holds content that XML cannot represent, so it cannot be written.

Extras dataclass 🔗

Out-of-schema content carried losslessly (opaque; see module docstring).

to_string() 🔗

Human-readable dump of the carried content (not a serialization format).

FieldDefinition dataclass 🔗

A header <field tag=...>: documents a field type used in the document.

Header dataclass 🔗

The optional <header>: description, ranges, field definitions.

ranges_extra / fields_extra carry out-of-schema LIFT residue found on the <ranges> / <fields> wrapper elements themselves (unknown attributes, stray nodes). Those wrappers have no model object of their own, so their residue is kept apart from the header's and re-emitted onto the wrapper rather than migrating up onto <header>.

Range dataclass 🔗

A <range>: a controlled vocabulary, inline and/or via href.

href points at an external .lift-ranges resource; its contents are carried unresolved here.

add_element(id, *, parent=None, guid=None) 🔗

Append a new :class:RangeElement to this range and return it.

The label / abbrev / description multitexts start empty; populate them on the returned element (e.g. el.label["en"] = "...").

RangeElement dataclass 🔗

A <range-element>: one value in a range; parent builds hierarchies.

Changes dataclass 🔗

Everything that differs between a lexicon and the document it was loaded from.

Falsy only when :meth:Lexicon.save would reproduce the source bytes, which makes it the correct guard for skipping an in-place write — :meth:Lexicon.changed_entries alone is not, since it covers only entry content.

The guarantee is one-way. Every condition that sends the writer down its canonical path is reported here, so a falsy result is never a wrong "nothing to write"; but that path can land back on the source bytes for a document already in canonical form, so a truthy result means the write is not provably unnecessary rather than certainly needed.

reordered answers only where the same entries are still present. An addition or a removal re-serializes the document on its own, so a reordering alongside one is not reported separately — enough for the guard, short of a full account of what happened.

The same entry object in the list twice is named once in entries; against a baseline, the second occurrence is an addition.

baseline is False when no byte snapshot was captured. Nothing can be compared then, so the fields say what :meth:Lexicon.save will write rather than what differs: entries lists every entry and header is True if there is a header to emit, while added, removed, and root stay empty or False — nothing is known to have been there before. The object is truthy either way.

Entry dataclass 🔗

Bases: _Extensible

An <entry>. A set date_deleted marks it deleted (a tombstone).

all_senses() 🔗

Every sense and subsense, depth-first in document order.

LIFT nests senses arbitrarily deep, so anything asking a question of "the entry's senses" — how many there are, what languages they gloss, what media they reference — means this list rather than :attr:senses, which holds only the top level.

gloss_langs() 🔗

Every language that has a gloss in any sense or subsense.

Etymology dataclass 🔗

Bases: _Extensible

An <etymology type=... source=...>; each gloss carries its own lang.

Example dataclass 🔗

Bases: _Extensible

An <example> under a sense.

Field dataclass 🔗

Bases: _ExtensibleNoFields

An entry-level usage <field type=...> (extensible, but no nested field).

GrammaticalInfo dataclass 🔗

A <grammatical-info value=...>; the extensibility exception — traits only, no fields.

Lexicon 🔗

The root handle: a parsed .lift document and its folder companions.

load(path, *, resolve_ranges=True) classmethod 🔗

Parse a .lift file (LIFT 0.13 only) into a full object graph.

With resolve_ranges (the default), companion .lift-ranges files are loaded and tracked in :attr:ranges_files. Several candidates are tried and every distinct file among them is loaded: the conventional <name>.lift-ranges sibling, and for each header 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).

A candidate matching no file exactly resolves across differences in case or Unicode normalization, so a folder authored on Windows loads the same way everywhere. One that several files answer to that way is 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 <lift-ranges> 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.

save(path=None, *, stamp=True, when=None) 🔗

Write the .lift file and every tracked .lift-ranges companion.

Untouched entries are emitted byte-identical to the source; modified entries are re-serialized canonically with all residue preserved. With no path, saves to where the lexicon was loaded from. When saving into a different directory, companions are written next to the new .lift file under their original basenames. Saving under a new name in the same directory leaves companions at their original paths (they are shared with the original document, not copied).

This mutates the model: every entry whose content changed since the load goes out with a fresh dateModified, and with a dateCreated if it had none — in a save(path) used to export a copy too. Left as they stand: a date the caller set deliberately, and any entry whose content did not change, reordering included (see :meth:sort).

stamp=False writes the model exactly as it stands. when supplies the moment in place of the clock, normalized to UTC at seconds precision, which is what makes stamped output byte-reproducible. See docs/en/fidelity.md for the rest of the contract.

Raises :class:ValueError if no target path is available (none was passed and the lexicon was not loaded from a file) or if when is naive, and :class:~sil_lift.LiftWriteError if the lexicon or one of its companions holds content XML cannot represent (a lone surrogate) — nothing is written in that case, and nothing is stamped.

save_zip(path, *, wrap_folder=True, stamp=True, when=None) 🔗

Write the lexicon and its folder companions as a zip package.

The .lift and .lift-ranges are (re-)serialized with the usual fidelity (untouched entries byte-identical); any other files from the source package (media, WritingSystems/, consent/, ...) are carried through verbatim. wrap_folder controls the layout: True (default) nests everything under a folder named after the zip — the convention FieldWorks and The Combine expect on import — False writes the files at the archive root, and a string uses that folder name. The archive container itself is not byte-reproducible.

stamp and when work exactly as on :meth:save, and matter more here: a package is the hand-off to the tools that read dateModified.

sort() 🔗

Sort into canonical order, in place: entries by (guid, id), header ranges/range-elements by id, field definitions by tag.

Sorting alone does not mark entries as modified — a subsequent :meth:save still emits untouched entries byte-identically, just in the new order. For fully re-serialized diff-ready output use :func:sil_lift.canonicalize.

changed_entries() 🔗

Entries currently in the lexicon whose content differs from the document as loaded.

An entry's digest covers its whole subtree, so an edit at any depth — a gloss on a nested subsense included — reports the containing entry. Assigning a field the value it already had reports nothing, and neither does reordering (see :meth:sort).

The comparison is always against the document as loaded, never against the most recent :meth:save, so an entry stays reported once changed.

Content changes only. Entries added since loading are reported by :meth:added_entries and removed ones by :meth:removed_entries; for everything that makes :meth:save differ from the source — including the header, the ranges companions, and the root element — use :meth:changes. An empty result here does not mean the document would round-trip byte-identically.

Every entry is reported when there is no byte baseline to compare against — a lexicon built from scratch, and equally one whose source the byte scanner declined to read (an encoding that is not ASCII-compatible, or a scanner/parser disagreement). Both re-serialize in full on :meth:save, so those entries genuinely are rewritten.

Costs one canonical serialization pass over the entries.

added_entries() 🔗

Entries in the lexicon that were not in the loaded document.

Empty when there is no byte baseline: nothing is known to be new, since nothing is known to have been there before. Needs no serialization.

removed_entries() 🔗

Entries from the loaded document that are no longer in the lexicon.

The entry objects survive because the parse-time records hold them, so a removed entry is returned intact rather than merely counted.

Empty when there is no byte baseline. Needs no serialization.

changes() 🔗

Everything that differs between this lexicon and the document it was loaded from.

Falsy only when :meth:save would reproduce the source bytes, which makes it the correct guard for skipping an in-place write::

if not lex.changes():
    return

One-way, and in the safe direction — see :class:Changes.

Content only, so it says nothing about the destination: a save(path) into another directory writes the document and its companions there whatever this reports, and skipping that is a lost copy, not a saved one. Never guard :meth:save_zip with it — that has no in-place form, its archive is never byte-reproducible, and it carries package files (media, WritingSystems/, ...) that nothing here inspects.

It costs more than the :meth:save it guards: it saves the write, not the work.

iter_problems(*, require_ids=False) 🔗

Validate the in-memory state (schema layers + semantic checks).

The schema layers need serialized bytes, so the document is serialized as it stands and those bytes are validated: in-memory edits are always visible. For an untouched loaded document they are the source bytes (line numbers match the file on disk); otherwise serialization is a documented cost on large lexicons.

Read-only: it reports the document as it stands, before the dateModified stamping :meth:save does. That is the one way these bytes differ from the bytes written. A stamp is a well-formed date in a valid place, so nothing generated is ever a finding and validate-then-save is sound.

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, 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, and attaching one afterwards with :meth:add_ranges_file does not reinstate them. missing-media and media-href-mismatch are unaffected: media is never resolved into the model, so nothing was opted out of.

all_ranges() 🔗

Inline and external ranges, merged by id.

An inline header range that enumerates its own elements wins; otherwise the external definition (from any tracked ranges file) is used. External ranges never referenced by the header are included too.

add_ranges_file(ranges_file, *, href) 🔗

Attach a companion .lift-ranges document so :meth:save writes it.

For every range already in ranges_file that the header does not list, a <range id=... href=...> reference is added to the header so LIFT consumers can find the companion. href is the reference as written in the header — normally a filename beside the .lift (e.g. "mydict.lift-ranges"); the companion is written next to the saved .lift under that basename. Populate the companion before calling (or call again to reference ranges added later).

Returns ranges_file.

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).

media_refs() 🔗

Every <media> and <illustration> reference, with its owner.

check_media() 🔗

Media references that don't resolve cleanly in the LIFT folder layout.

A relative href is checked as given (backslashes normalized) and under the conventional subfolder (audio/ for media, pictures/ for illustrations). Remote/absolute hrefs can't be checked and are skipped.

A reference whose href names its file exactly is not reported. One that reaches a file only under case folding or NFC is a mismatch: the file is here, but a case-sensitive host serving this folder will not find it. One that reaches nothing is missing.

find(*, id=None, guid=None) 🔗

The first entry matching the given id and/or guid, or None.

Raises :class:ValueError if neither id nor guid is given.

MediaRef dataclass 🔗

One media reference in the document, with its owner's identity.

MediaResolution dataclass 🔗

A media reference that did not resolve cleanly against the LIFT folder.

missing means no file answered the href under any spelling; mismatch means one did, but only under case folding or NFC, and found is the file it named. A href that names its file exactly is not reported at all -- see :meth:Lexicon.check_media.

Note dataclass 🔗

Bases: _Extensible

A <note>; sibling notes conventionally have distinct types.

Pronunciation dataclass 🔗

Bases: _Extensible

A <pronunciation> with optional <media> references.

RangesChanges dataclass 🔗

What differs between a companion .lift-ranges and the file it was read from.

baseline is False when no byte snapshot was captured (a companion built from scratch, or a source the byte scanner declined to read). Nothing can be compared then, so the fields say what :meth:RangesFile.save will write rather than what differs: ranges lists everything, while added, removed, and root stay empty or False — nothing is known to have been there before. The object is truthy either way: the file will be written in full.

As on :class:Changes, reordered answers only where the same ranges are still present. The same range object in the list twice is named once in ranges; against a baseline, the second occurrence is an addition.

RangesFile 🔗

A standalone .lift-ranges document (root <lift-ranges>).

save(path=None) 🔗

Write the .lift-ranges file (byte-identical when unchanged).

Raises :class:ValueError if no target path is available (none was passed and the file was not loaded from disk).

add_range(id, *, href=None, guid=None) 🔗

Append a new :class:Range to this file and return it.

Populate its values with :meth:Range.add_element.

sort() 🔗

Sort ranges and their elements into canonical (id) order.

changes() 🔗

Everything that differs between this companion and the file it was read from.

Falsy only when :meth:save would reproduce the source bytes, one-way in the same safe direction as :class:Changes. Costs one canonical serialization pass over the ranges.

Relation dataclass 🔗

Bases: _Extensible

A <relation type=... ref=...> cross-reference to another entry/sense.

Reversal dataclass 🔗

A <reversal> index entry (not extensible).

ReversalMain dataclass 🔗

A reversal <main>: the parent node in a reversal-index chain (recursive).

Sense dataclass 🔗

Bases: _Extensible

A <sense> (or <subsense> — same content model, recursive).

gloss(lang) 🔗

The gloss text in lang, or None (first match; each <gloss> has its own lang).

Translation dataclass 🔗

A <translation> of an example (not extensible).

URLRef dataclass 🔗

A <media> or <illustration>: an href with an optional label.

Variant dataclass 🔗

Bases: _Extensible

A <variant>: an inline variant form and/or a ref cross-reference.

LiftReader 🔗

Lazy entry iterator over a .lift file; the header is parsed eagerly.

Open one with :func:open_reader; the class is exported for type annotations. Use as a context manager. The version guard runs at open time; the header (which precedes entries in a conforming file) is available immediately as :attr:header, and the root's producer attribute as :attr:producer.

LiftWriter 🔗

Streaming writer: header up front, then one canonical chunk per entry.

Open one with :func:open_writer; the class is exported for type annotations. Use as a context manager; the closing </lift> is written only on a clean exit (an exception leaves an unterminated file, deliberately — a half-written lexicon must not look complete).

Annotation dataclass 🔗

An <annotation>: reviewer/editorial metadata on a node.

Form dataclass 🔗

A single-language text unit: <form lang=...><text/></form>, also <gloss>.

lang is required by the schema; it is None only when reading schema-invalid real-world files (which sil-lift loads rather than rejects).

Multitext dataclass 🔗

Bases: Mapping[str, Text]

An insertion-ordered collection of forms, keyed by language.

A Mapping[str, Text] — mt["en"], "en" in mt, mt.get(...), mt.keys() and the other views — plus assignment, which coerces plain strings (mt["en"] = "dog"), and deletion.

Both mutators act on the language, not on one form: assignment leaves a single form for the language it names, and del mt["en"] leaves "en" not in mt. Writing through the mapping is therefore also how a caller repairs a form list the schema does not allow.

forms is the full truth and holds what no key can reach: a form with a None lang, and a second form for a language already present. Neither is yielded by a view or counted by len(), reads answer with the first form for a language, and validation reports both as form-missing-lang and duplicate-form-lang. Assigning None is refused, and re-serializing drops a lang-less form read from a file.

bool(mt) asks "is there anything to serialize", which residue defeats on its own, so a residue-only multitext is truthy while len() is 0. Equality compares forms, stricter than Mapping equality.

Span dataclass 🔗

An inline markup run inside a <text>; nests recursively.

Text dataclass 🔗

Mixed content of a <text>: ordered str and Span fragments.

__str__() 🔗

Plain-text flattening; span markup is stripped, span text kept.

Trait dataclass 🔗

A <trait>: a name/value pair, typically keyed to a range.

Problem dataclass 🔗

One validation finding, addressable to a file/entry/line.

canonicalize(src, dst) 🔗

Write a fully canonical copy of a .lift file: sorted entries and ranges, documented child grouping and attribute order, 2-space layout.

Unlike :meth:Lexicon.save, the output is entirely re-serialized (no byte-preserving passthrough) — that is the point: two canonicalized files diff cleanly. Text content is never whitespace-normalized. The whole document is held in memory (sorting requires it; the C# oracle buffers too).

No timestamp is generated either: sorting and reformatting change no entry's content, so nothing here is a modification to stamp. The output is a pure function of the input.

Only the .lift file is written: companion .lift-ranges files are neither read nor rewritten (the source is loaded with resolve_ranges=False). Sort a ranges file separately via :meth:RangesFile.sort + :meth:RangesFile.save. This differs from :meth:Lexicon.save, which writes every tracked companion.

open_reader(path) 🔗

Open a .lift file for streaming reads (bounded memory).

open_writer(path, *, header=None, producer=None, ranges=None) 🔗

Open a .lift file for streaming writes (bounded memory).

If ranges is given, its companion .lift-ranges is written beside path on clean close, and matching <range href> references are added to header (created if absent) so the document points to the companion.

iter_problems(path, *, require_ids=False) 🔗

All problems in the document and its tracked companions, lazily.

With require_ids, also report entries/senses missing a stable id (missing-id errors); see :meth:Lexicon.iter_problems.

load(path, *, resolve_ranges=True) 🔗

Parse a .lift file (LIFT 0.13 only) into a :class:Lexicon.

Companion .lift-ranges files are located and loaded too unless resolve_ranges=False; see :meth:Lexicon.load.