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.