Skip to content

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

Open
imnasnainaec wants to merge 13 commits into
mainfrom
stdlib-and-internal-duplicates
Open

Retire five duplicated implementations, three of them the stdlib's job#40
imnasnainaec wants to merge 13 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.

  • _scan.py → expat's CurrentByteIndex, which lxml has no equivalent
    for. 189 lines to 140, and 3.3x faster: 340 ms to 103 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. The corpus
byte-identity tests cover all 57 documents (53 .lift plus companions), and
tests/test_scan.py drives the scanner directly for what the corpus cannot
reach.

  • 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. The offsets alone cannot tell those apart, so the start tag's
    / is what decides it.
  • No corpus document exercises that rule, and none carries a > inside an
    attribute value either, so both rest entirely on the three cases added to
    tests/test_scan.py. An empty child needs both rules right at once, since
    its region ends at the measured tag end rather than at the end event.

Sense walk order changed. Sibling senses come out in document order now;
the old stack walk reversed them within an entry. Two surfaces show it:

  • media_refs() / missing_media(), so check-media output reorders.
  • Validation, so missing-sense-id and sense-relation dangling-ref findings
    reorder. iter_problems() never sorts, so callers see generator order; the
    walk order shifts for five corpus entries carrying sense relations.

Out-of-scope

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 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
@imnasnainaec imnasnainaec self-assigned this Aug 26, 2026
@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
imnasnainaec and others added 12 commits September 3, 2026 08:51
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>
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>
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>
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>
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>
The ranges guide now runs load, read, edit, save without a detour
through resolution. The three candidate sources move below the
standalone example as a bulleted "Companion discovery", where the
resolve_ranges=False opt-out already sat stranded at the foot of the
section, and gain the case/normalization matching rule — which the guide
had never stated, leaving validate.md's ambiguous-ranges-file row as the
only prose that mentioned it, and that only from the failure side.

The changelog drops that same matching rule, which says how discovery
resolves rather than what the release offers, and no longer calls the
vendored ranges schema the first of its kind: the entry says what ships,
the guide says how it behaves.

The comment on the sort feeding _nearest_entry's bisect moves onto the
sort, off the filter that precedes it, and stops noting that list.sort
is stable — documented Python, and the tie that settles is already
described by _nearest_entry itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bulk-edit worked example built its own recursive generator to reach
glosses under subsenses, which all_senses() now does. The note that
explained the recursion becomes a note about the method, and the
entry.senses pitfall it warned about moves to a sub-bullet under it.

The model tour introduced senses as entry.senses[0] and said nothing
about nesting, leaving the partial accessor as the one a reader meets
first and all_senses() reachable only through the generated API
reference. Its sense block now shows both, with the comments saying
which covers what, and a line on which one to reach for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@imnasnainaec
imnasnainaec force-pushed the stdlib-and-internal-duplicates branch from 0f6b5e1 to 2f133f7 Compare September 3, 2026 14:01
No corpus document carries a ">" inside an attribute value, so replacing
_tag_end's quote tracking with a plain search for the delimiter passed the
whole suite. Ignoring the "/" that marks an empty element passed every
test but the Hypothesis round-trips, which report it as a byte-identity
failure far from its cause; the existing self-closing test covers the
root, which appends no region at all.

Three cases now fail deterministically instead. An empty child needs both
rules right at once, because its region ends at the measured tag end
rather than at the end event, so that case covers either mistake alone.

The non-empty child in the attribute-value case is asserted as well: its
end comes from the end event, so a tag end stopping inside an attribute
value still leaves its region correct.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@imnasnainaec
imnasnainaec marked this pull request as ready for review September 3, 2026 15:29
@imnasnainaec imnasnainaec added the 🟨Medium Medium-priority PR label Sep 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🟨Medium Medium-priority PR

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