Skip to content

Retire five duplicated implementations, three of them the stdlib's job - #40

Draft
imnasnainaec wants to merge 12 commits into
mainfrom
stdlib-and-internal-duplicates
Draft

Retire five duplicated implementations, three of them the stdlib's job#40
imnasnainaec wants to merge 12 commits into
mainfrom
stdlib-and-internal-duplicates

Conversation

@imnasnainaec

@imnasnainaec imnasnainaec commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Resolves #39 — five duplicated implementations retired, one commit each.

  • _scan.py → expat's CurrentByteIndex, which lxml has no equivalent
    for. 227 lines to 137, and 3.6x faster: 660 ms to 185 ms on sango.lift.
  • _nearest_entrybisect, so a document failing schema validation on
    every entry stops costing entries x errors.
  • duplicate-form-langCounter, not list.count per language.
  • Three identical dataclass walkers → one _iter_instances(obj, cls).
  • Five copies of the subsense walkEntry.all_senses(), public because
    Entry.senses holds only the top level.

Worth your attention

The scanner rewrite, since it underpins byte identity. Both scanners agree
exactly, region for region, across 26 crafted documents (BOM, > inside an
attribute value, CDATA-embedded </entry>, DOCTYPE, six truncations) and all
57 corpus files. The subtlety to look hardest at: an empty element's end event
reports the offset past the element, where every other element's reports the
< of its end tag, and the offsets alone cannot tell those apart — the start
tag's / decides it. Only 6 of the 26 cases distinguish a wrong rule there,
and none of the 57 corpus files does.

media_refs() ordering changed. Sibling senses come out in document order
now; the old stack walk reversed them within an entry. No test pinned it, and
check-media output is the visible difference.

python scripts/check.py green: 575 passed, 97%+ coverage. Why eight other
hand-rolled things are deliberately left alone is in the follow-up comment
on #39.

🤖 Generated with Claude Code


Devin review: https://app.devin.ai/review/sillsdev/python-sil-lift/pull/40


This change is Reviewable

imnasnainaec and others added 8 commits August 26, 2026 13:22
lxml exposes no byte offsets, but the stdlib's expat binding does:
CurrentByteIndex reports where the current event's markup begins. Taking
region starts and ends from element events retires the tag, comment, CDATA,
processing-instruction and nesting walk that found them by hand — the module
drops from 189 lines to 137, and scanning sango.lift (4.8 MB) from 660 ms to
185 ms, since the byte loop it replaces ran in Python and expat runs in C.

_tag_end survives, and does the one thing offsets alone cannot settle. An
empty element's end event reports the offset just past the whole element,
where every other element's reports the "<" of its end tag; the two cases are
indistinguishable from the offset, so which one applies is read off the start
tag's "/" instead. The same quote-aware scan supplies the root's open-tag end.

Conservative refusals are unchanged, and now come from one place: expat
rejects the malformed and truncated markup the walk used to detect case by
case, and a DTD is refused as before, since entity expansion would make these
offsets describe bytes that are not in the file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both replace a hand-rolled scan with the standard-library operation it spells
out.

_nearest_entry is called once per schema error and walked the entry table
looking for the last entry starting at or before the error's line. The table
is built in document order, so it is sorted and bisect_right finds that entry
directly; a document failing validation on every entry no longer costs entries
x errors. Filtering the lineless entries out once, before the loop, is also
what gives the bisected list a total order to search.

duplicate-form-lang asked langs.count(lang) per language of a multitext,
rescanning the list once for every form in it. Counter answers the same
question in one pass, and _writer already imports it for the same reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
_iter_multitexts, _iter_traits and _iter_grammatical_infos were the same
recursion over dataclasses.fields with the isinstance target swapped, so they
become one _iter_instances(obj, cls) and each caller names the class it wants.
Why each of the three reaches as far as it does is now stated in one place
rather than argued separately in each copy.

The field label the multitext check reports was only ever produced by one of
the three, and is now carried through the list branch as well: a match found
inside a list field is labelled with that field rather than left unlabelled.

The unified walk descends into a match instead of returning at it. That
descent is what finds a Multitext inside a Multitext's own annotations, which
the multitext copy already relied on; nothing in the model nests a Trait or a
GrammaticalInfo inside another, so the other two callers see the same
instances in the same order as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five places carried their own depth-first walk over sense.subsenses: two in
the CLI, two on the model, one in validation. They become one method, which
answers a question callers outside this package have as much as the ones
inside it — Entry.senses holds only the top level, so anything asking about
"the entry's senses" has to recurse.

The CLI's leaf-sense pass is a filter over it now: with the walk in document
order, the senses with no subsenses are exactly the rows export wants, in the
order it wants them.

media_refs() and the missing_media() list derived from it therefore report
sibling senses in document order. The stack walk they used popped from the
end, so sibling illustrations came out reversed within an entry — visible in
check-media output, and in nothing that depended on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The class hand-wrote get, __contains__, keys, values and items on top of
__getitem__ / __iter__ / __len__ — which is the set collections.abc.Mapping
derives from those three. Inheriting it drops them, and makes
isinstance(mt, Mapping) true for consumers that ask.

keys(), values() and items() therefore return views rather than lists. A view
is what a Mapping promises, and typing the class as one while returning lists
would have been three suppressed Liskov violations; set operations on keys()
work now, and a caller wanting a list can say so. __iter__ and __len__ read
forms directly, since the views are built on them.

__len__ counts languages rather than forms. It counted every form including a
lang=None one, which keys() has always excluded, so len(mt) could exceed
len(mt.keys()) on schema-invalid input; as a Mapping that would leave
len(mt) != len(list(mt)). __bool__ still answers "is there anything to
serialize", which residue and a lang-less form each defeat on their own, so it
stays independent of len().

The two mutators stay as they are. MutableMapping is not inherited: clear and
popitem have no clear meaning for a form list that can hold forms no key
reaches.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every module under src/ carries `from __future__ import annotations` except
_extras, which needs nothing from it and so went unnoticed. Ruff's isort
required-imports enforces what was being eyeballed, and adds the import there.

The rule is scoped to the package: tests and scripts mostly do without it, and
that is their own convention rather than a lapse in this one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both belong in the object-model entry: what a multilingual field supports is
most of what a caller does with this model, and that Entry.senses holds only
the top level is the surprise all_senses() exists to answer.

The media helpers' entry gains the two facts a caller has to know to read
their output: every subsense is covered, and references come out in document
order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A language repeated across forms was yielded once per form, so the inherited
views walked it twice and resolved both to the first form's text: values()
reported that text twice and never the second form's, and len() counted 2
where dict() held 1 key. A repeated language is exactly the schema-invalid
input validation reports as duplicate-form-lang, so it is real FLEx and WeSay
output rather than a hypothetical.

__iter__ now yields each language once — the one __getitem__ answers with —
and __len__ counts those, so len(mt) == len(dict(mt)) whatever the forms hold.
Nothing is hidden: forms still holds every form, which is where
duplicate-form-lang reads from and where a lang-less form was already the
docstring's example of content no mapping can represent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Whether the package's `from __future__ import annotations` convention is worth
enforcing with a linter is a preference, settled by argument rather than by any
test, and it shares nothing with the duplicated implementations the rest of
this branch retires — no other commit here touches pyproject.toml or _extras.

It is proposed on its own in #41 instead, where accepting or rejecting it costs
nothing else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@imnasnainaec imnasnainaec changed the title Retire seven duplicated implementations: four to the stdlib, three into one Retire six duplicated implementations, four of them the stdlib's job Aug 26, 2026
Both were true, and both were true somewhere else.

_nearest_entry bisects the entry table, which needs it ordered by line. That
held because entry_lines is built from the parsed document's root children in
order — a fact about _group_children_by_tag leaving the root alone, two
functions away from the bisect that depends on it. Sorting at the call site
states the requirement where it applies. The sort is keyed on the line rather
than comparing whole tuples, which would raise as soon as two entries sharing
a line differ in having an id.

_iter_instances descends into a match rather than stopping there, which is
harmless only while the model nests no Trait inside a Trait and no
GrammaticalInfo inside a GrammaticalInfo. That was established by reading the
dataclasses; the new test establishes it from the annotations, so gaining such
a field fails the suite instead of quietly widening what the walk yields.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@imnasnainaec imnasnainaec self-assigned this Aug 26, 2026
Inheriting collections.abc.Mapping changes three things callers can see:
keys(), values() and items() return views rather than lists, len() counts
languages rather than forms, and a language spelled on two forms becomes one
key. Accepting that is a judgement about how much of a 0.x API is worth
breaking for a correct protocol, which nothing here shares a file with — the
rest of this branch deletes duplicated code without changing what anything
returns.

It is proposed on its own in #42, so it can be taken or refused without
holding up six changes that are only refactors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@imnasnainaec imnasnainaec changed the title Retire six duplicated implementations, four of them the stdlib's job Retire five duplicated implementations, three of them the stdlib's job Aug 26, 2026
The guard's stated reason was that "byte scanning assumes an ASCII-compatible
encoding", describing a scan that no longer exists. The reason it still holds
is different and less obvious: expat parses UTF-16 quite happily and reports
byte offsets into UTF-16 bytes, tag names and region boundaries and all, so
nothing downstream can tell those regions apart from usable ones. Reused
verbatim they leave an unchanged document identical to its source and an edited
one mixing both encodings, which will not parse at all.

The ranges-side guard, which carried no reason, gets one pointing at that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Four hand-rolled implementations the stdlib covers, and three that duplicate each other

1 participant