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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,14 @@ releases may contain breaking changes.

- Project scaffolding: package skeleton, vendored LIFT 0.13 RELAX NG schema,
test corpus with provenance, corpus-prep and large-file-generator tooling.
- Full object model: all 35 LIFT 0.13 elements as typed dataclasses;
`sil_lift.load()` / `Lexicon.load()` full-document reader that keeps LIFT
residue per node in `Extras`; LIFT-version guard.
- Full object model: all 35 LIFT 0.13 elements as typed dataclasses.
Multilingual fields are `Multitext`, a `Mapping` from language code to
`Text` that coerces plain strings on assignment; `len()` and the views
count languages, while the `forms` list stays the full truth for what no
mapping can represent — a form with no lang, and a second form for a
language already present (which validation reports as
`duplicate-form-lang`). `sil_lift.load()` / `Lexicon.load()` full-document
reader that keeps LIFT residue per node in `Extras`; LIFT-version guard.
- `Lexicon.save()` writer with byte-fidelity passthrough — unchanged
documents and untouched entries are written byte-identically; touched entries
re-serialize canonically with all out-of-schema content preserved. Fidelity
Expand Down
3 changes: 2 additions & 1 deletion docs/en/guides/read-edit-write.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@ lex = sil_lift.load("dictionary.lift")

## The model

Every LIFT element is a typed dataclass: `Entry`, `Sense`, `Example`, `Pronunciation`, `Variant`, `Relation`, `Etymology`, `Reversal`, and so on. Multilingual text is a `Multitext`, which behaves like a mapping from language code to `Text`:
Every LIFT element is a typed dataclass: `Entry`, `Sense`, `Example`, `Pronunciation`, `Variant`, `Relation`, `Etymology`, `Reversal`, and so on. Multilingual text is a `Multitext`, which is a `Mapping` from language code to `Text`:

```python
entry = lex.find(id="abat")

str(entry.lexical_unit["seh"]) # "abat"
entry.lexical_unit["en"] = "grove" # plain strings are coerced
"en" in entry.citation # False
entry.lexical_unit.keys() # the languages present, in file order
```

`Text` is structured — an ordered list of `str` and `Span` fragments — because `<text>` can contain nested `<span>` markup. `str(text)` flattens to plain text; the fragments keep the markup for round-tripping.
Expand Down
52 changes: 29 additions & 23 deletions src/sil_lift/_text.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from __future__ import annotations

from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import date, datetime
from typing import TYPE_CHECKING
Expand Down Expand Up @@ -84,13 +85,22 @@ class Form:


@dataclass(slots=True, repr=False)
class Multitext:
class Multitext(Mapping[str, Text]):
"""An insertion-ordered collection of forms, one per language.

Behaves like a ``Mapping[str, Text]`` keyed by language (``mt["en"]``),
with assignment coercing plain strings (``mt["en"] = "dog"``). The
underlying ``forms`` list is the full truth — forms with a ``None`` lang
(schema-invalid input) are reachable there but not via mapping keys.
A ``Mapping[str, Text]`` keyed by language — ``mt["en"]``, ``"en" in mt``,
``mt.get(...)``, ``mt.keys()`` and the other views — plus the two mutators
LIFT editing needs: assignment coercing plain strings (``mt["en"] = "dog"``)
and deletion. The rest of ``MutableMapping`` is deliberately not inherited;
``clear`` and ``popitem`` have no clear meaning for a form list that can
also hold forms no key reaches.

The ``forms`` list is the full truth, and holds what no mapping can
represent: a form with a ``None`` lang, and a second form for a language
already present. Both are schema-invalid input that real exporters produce
— a repeated language is what validation reports as
``duplicate-form-lang``, off ``forms`` rather than off the mapping — and
neither is reachable by key or counted by ``len()``.
"""

forms: list[Form] = field(default_factory=list)
Expand Down Expand Up @@ -122,31 +132,27 @@ def __delitem__(self, lang: str) -> None:
raise KeyError(lang)
self.forms.remove(form)

def get(self, lang: str, default: Text | None = None) -> Text | None:
form = self._find(lang)
return default if form is None else form.text

def __contains__(self, lang: object) -> bool:
return isinstance(lang, str) and self._find(lang) is not None

# Both read forms directly rather than through keys(): the inherited views
# are built on these two, so consulting a view here would not terminate.
def __iter__(self) -> Iterator[str]:
return iter(self.keys())
# A language repeated across forms is one key, the one __getitem__
# answers with. Yielding it twice would make the inherited views
# report the first form's text once per duplicate, and leave len()
# disagreeing with dict(self).
seen: set[str] = set()
for form in self.forms:
if form.lang is not None and form.lang not in seen:
seen.add(form.lang)
yield form.lang

def __len__(self) -> int:
return len(self.forms)
return sum(1 for _ in self)

def __bool__(self) -> bool:
# Not derived from len(): emptiness here means "nothing to serialize",
# which residue and a lang-less form each defeat on their own.
return bool(self.forms) or bool(self.extra)

def keys(self) -> list[str]:
return [form.lang for form in self.forms if form.lang is not None]

def values(self) -> list[Text]:
return [form.text for form in self.forms if form.lang is not None]

def items(self) -> list[tuple[str, Text]]:
return [(form.lang, form.text) for form in self.forms if form.lang is not None]

def __repr__(self) -> str:
inner = ", ".join(f"{form.lang!r}: {str(form.text)!r}" for form in self.forms)
return f"Multitext({{{inner}}})"
24 changes: 22 additions & 2 deletions tests/test_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import pytest

import sil_lift
from sil_lift import LiftParseError, Span
from sil_lift import Form, LiftParseError, Multitext, Span, Text

CORPUS_DIR = Path(__file__).parent / "corpus"

Expand Down Expand Up @@ -101,6 +101,26 @@ def test_subsenses_spot_check() -> None:
assert str(sense_2.gloss("en") or "") == "master"


def test_multitext_mapping_over_forms_no_mapping_can_represent() -> None:
# Both are schema-invalid input real exporters produce: a repeated
# language (what duplicate-form-lang reports) and a form with no lang.
multitext = Multitext(
forms=[
Form("en", Text(["first"])),
Form("en", Text(["second"])),
Form(None, Text(["orphan"])),
Form("fr", Text(["deux"])),
]
)
assert list(multitext.keys()) == ["en", "fr"]
assert [str(text) for text in multitext.values()] == ["first", "deux"]
assert str(multitext["en"]) == "first" # the first form wins the key
assert len(multitext) == len(dict(multitext)) == 2
# Nothing is lost: forms still holds all four, which is what validation reads.
assert len(multitext.forms) == 4
assert [str(form.text) for form in multitext.forms if form.lang is None] == ["orphan"]


def test_reversal_main_chain() -> None:
lexicon = sil_lift.load(CORPUS_DIR / "spec-examples" / "0.13" / "reversals-hierarchy.lift")
(entry,) = lexicon.entries
Expand Down Expand Up @@ -178,7 +198,7 @@ def test_all_flex_fields_spot_check() -> None:
assert span.class_ == "Hyperlink"
(illustration,) = sense.illustrations
assert illustration.href == "Desert.jpg"
assert illustration.label.keys() == ["th", "en", "fr"]
assert list(illustration.label.keys()) == ["th", "en", "fr"]

other = lexicon.find(id="คาม ๒_dc4106ac-13fd-4ae0-a32b-b737f413d515")
assert other is not None
Expand Down