quran-processing-toolkit 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,615 @@
1
+ """Immutable query collections used throughout the public API.
2
+
3
+ ``QuerySet`` deliberately provides two layers:
4
+
5
+ * small, general operations such as :meth:`where`, :meth:`group_by`, and
6
+ :meth:`sorted_by`; and
7
+ * readable domain helpers such as :meth:`with_root`, :meth:`with_aspect`, and
8
+ :meth:`in_verse` for programs that should not need lambdas or a query language.
9
+
10
+ Semantic corpus collections (chapters, verses, words, and tokens) add keyed lookup
11
+ through ``IndexedCollection`` or ``OneBasedCollection``. A filtered result is an
12
+ ordinary ``QuerySet`` and therefore uses normal Python zero-based result positions.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections import Counter, defaultdict
18
+ from collections.abc import Callable, Iterable, Iterator
19
+ from typing import Any, Generic, TypeVar, overload
20
+
21
+ from .text import TranslationNotAvailableError
22
+
23
+ T = TypeVar("T")
24
+ K = TypeVar("K")
25
+ R = TypeVar("R")
26
+
27
+
28
+ def resolve_value(item: Any, field: str) -> Any:
29
+ """Resolve a dotted attribute name on an object or mapping."""
30
+ value = item
31
+ for component in field.split("."):
32
+ value = value[component] if isinstance(value, dict) else getattr(value, component)
33
+ return value
34
+
35
+
36
+ def _selector(field_or_callable: str | Callable[[T], R]) -> Callable[[T], R]:
37
+ if callable(field_or_callable):
38
+ return field_or_callable
39
+ return lambda item: resolve_value(item, field_or_callable)
40
+
41
+
42
+ def _values(item: Any, field: str) -> tuple[Any, ...]:
43
+ method = getattr(item, "values", None)
44
+ return method(field) if method else ()
45
+
46
+
47
+ def _morphology_values(item: Any, plural: str, singular: str) -> tuple[Any, ...]:
48
+ """Read a possibly multi-valued morphology attribute from words or tokens."""
49
+ values = getattr(item, plural, None)
50
+ if values is not None:
51
+ return tuple(values)
52
+ value = getattr(item, singular, None)
53
+ if value is None:
54
+ return ()
55
+ return value if isinstance(value, tuple) else (value,)
56
+
57
+
58
+ def _text_matches(item: Any, method: str, *args: Any, **kwargs: Any) -> bool:
59
+ """Run a text predicate, skipping only absent verse translation layers."""
60
+ try:
61
+ return bool(getattr(item, method)(*args, **kwargs))
62
+ except TranslationNotAvailableError:
63
+ # A partial translation is a supported sparse layer. Collection searches
64
+ # omit untranslated verses, while asking a single object for unavailable
65
+ # translation text continues to raise. Non-verse objects still surface the
66
+ # representation error rather than looking like a legitimate empty result.
67
+ if hasattr(item, "translation_text"):
68
+ return False
69
+ raise
70
+
71
+
72
+ class QuerySet(Generic[T]):
73
+ """An immutable, reusable result set with composable query operations."""
74
+
75
+ __slots__ = ("_items",)
76
+
77
+ def __init__(self, items: Iterable[T] = ()) -> None:
78
+ self._items = tuple(items)
79
+
80
+ def __iter__(self) -> Iterator[T]:
81
+ return iter(self._items)
82
+
83
+ def __len__(self) -> int:
84
+ return len(self._items)
85
+
86
+ @overload
87
+ def __getitem__(self, index: int) -> T: ...
88
+
89
+ @overload
90
+ def __getitem__(self, index: slice) -> QuerySet[T]: ...
91
+
92
+ def __getitem__(self, index: int | slice) -> T | QuerySet[T]:
93
+ if isinstance(index, slice):
94
+ return QuerySet(self._items[index])
95
+ return self._items[index]
96
+
97
+ def __bool__(self) -> bool:
98
+ return bool(self._items)
99
+
100
+ def __repr__(self) -> str:
101
+ return f"{type(self).__name__}({len(self)} items)"
102
+
103
+ def all(self) -> tuple[T, ...]:
104
+ """Return all results as an immutable tuple."""
105
+ return self._items
106
+
107
+ def first(self, default: T | None = None) -> T | None:
108
+ """Return the first result, or ``default`` when the set is empty."""
109
+ return self._items[0] if self._items else default
110
+
111
+ def last(self, default: T | None = None) -> T | None:
112
+ """Return the last result, or ``default`` when the set is empty."""
113
+ return self._items[-1] if self._items else default
114
+
115
+ def take(self, count: int) -> QuerySet[T]:
116
+ """Return at most the first ``count`` results without converting to a tuple."""
117
+ if count < 0:
118
+ raise ValueError("take() count cannot be negative")
119
+ return QuerySet(self._items[:count])
120
+
121
+ def filter(self, predicate: Callable[[T], bool]) -> QuerySet[T]:
122
+ """Return items for which ``predicate(item)`` is true."""
123
+ return QuerySet(item for item in self if predicate(item))
124
+
125
+ def exclude(self, predicate: Callable[[T], bool]) -> QuerySet[T]:
126
+ """Return items for which ``predicate(item)`` is false."""
127
+ return QuerySet(item for item in self if not predicate(item))
128
+
129
+ def where(self, **criteria: Any) -> QuerySet[T]:
130
+ """Filter by attributes; callable expected values act as predicates."""
131
+
132
+ def matches(item: T) -> bool:
133
+ for field, expected in criteria.items():
134
+ actual = resolve_value(item, field)
135
+ if callable(expected):
136
+ if not expected(actual):
137
+ return False
138
+ elif isinstance(actual, (tuple, list, set, frozenset)):
139
+ if expected not in actual:
140
+ return False
141
+ elif actual != expected:
142
+ return False
143
+ return True
144
+
145
+ return self.filter(matches)
146
+
147
+ def select(self, field_or_callable: str | Callable[[T], R]) -> tuple[R, ...]:
148
+ """Project each item to a dotted attribute or callable result."""
149
+ select = _selector(field_or_callable)
150
+ return tuple(select(item) for item in self)
151
+
152
+ def unique(self, field_or_callable: str | Callable[[T], R]) -> tuple[R, ...]:
153
+ """Return distinct projected values in their first-seen order."""
154
+ select = _selector(field_or_callable)
155
+ seen: set[Any] = set()
156
+ result: list[R] = []
157
+ for item in self:
158
+ value = select(item)
159
+ if value not in seen:
160
+ seen.add(value)
161
+ result.append(value)
162
+ return tuple(result)
163
+
164
+ def group_by(self, field_or_callable: str | Callable[[T], K]) -> dict[K, QuerySet[T]]:
165
+ """Group results by a dotted attribute or callable result."""
166
+ select = _selector(field_or_callable)
167
+ groups: defaultdict[K, list[T]] = defaultdict(list)
168
+ for item in self:
169
+ groups[select(item)].append(item)
170
+ return {key: QuerySet(values) for key, values in groups.items()}
171
+
172
+ def count_by(self, field_or_callable: str | Callable[[T], K]) -> dict[K, int]:
173
+ """Count projected values without materializing a ``QuerySet`` per group."""
174
+ select = _selector(field_or_callable)
175
+ return dict(Counter(select(item) for item in self))
176
+
177
+ def most_common(
178
+ self,
179
+ field: str,
180
+ *,
181
+ limit: int | None = None,
182
+ include_none: bool = False,
183
+ ) -> tuple[tuple[Any, int], ...]:
184
+ """Count and rank values without requiring ``group_by`` knowledge."""
185
+ counts: dict[Any, int] = self.count_by(field)
186
+ ranked = sorted(
187
+ (
188
+ (value, count)
189
+ for value, count in counts.items()
190
+ if include_none or value is not None
191
+ ),
192
+ key=lambda item: item[1],
193
+ reverse=True,
194
+ )
195
+ return tuple(ranked[:limit])
196
+
197
+ # Friendly morphology filters. They intentionally delegate to the generic query
198
+ # engine so specific and advanced programs compose identically.
199
+ def with_root(self, root: str) -> QuerySet[T]:
200
+ """Keep tokens or words annotated with the exact Buckwalter ``root``."""
201
+ return self.where(root=root)
202
+
203
+ def with_any_root(self, roots: Iterable[str]) -> QuerySet[T]:
204
+ """Keep tokens or words having any root in ``roots``."""
205
+ wanted = set(roots)
206
+ return self.filter(
207
+ lambda item: bool(wanted.intersection(_morphology_values(item, "roots", "root")))
208
+ )
209
+
210
+ def with_root_length(self, length: int) -> QuerySet[T]:
211
+ """Keep items having at least one root of exactly ``length`` letters."""
212
+ return self.filter(
213
+ lambda item: any(
214
+ len(root) == length for root in _morphology_values(item, "roots", "root")
215
+ )
216
+ )
217
+
218
+ def with_minimum_root_letter_count(self, letters: str, count: int) -> QuerySet[T]:
219
+ """Keep items whose root contains ``count`` selected Buckwalter letters."""
220
+ wanted = set(letters)
221
+ return self.filter(
222
+ lambda item: any(
223
+ sum(character in wanted for character in root) >= count
224
+ for root in _morphology_values(item, "roots", "root")
225
+ )
226
+ )
227
+
228
+ def with_lemma(self, lemma: str) -> QuerySet[T]:
229
+ """Keep tokens or words annotated with the exact Buckwalter ``lemma``."""
230
+ return self.where(lemma=lemma)
231
+
232
+ def with_form(self, form: str) -> QuerySet[T]:
233
+ """Keep verbs with a form/baab code such as ``I`` or ``IV``."""
234
+ return self.where(verb_form=form)
235
+
236
+ def with_tense(self, tense: str) -> QuerySet[T]:
237
+ """Keep items with derived ``PAST``, ``PRESENT``, or ``IMPERATIVE`` tense."""
238
+ aliases = {"PRES": "PRESENT", "IMPV": "IMPERATIVE"}
239
+ value = tense.upper()
240
+ return self.where(tense=aliases.get(value, value))
241
+
242
+ def with_aspect(self, aspect: str) -> QuerySet[T]:
243
+ """Keep items with perfect, imperfect, or imperative source aspect."""
244
+ aliases = {"PERF": "PERFECT", "IMPF": "IMPERFECT", "IMPV": "IMPERATIVE"}
245
+ value = aspect.upper()
246
+ return self.where(aspect=aliases.get(value, value))
247
+
248
+ def with_voice(self, voice: str) -> QuerySet[T]:
249
+ """Keep finite verbs with active or passive voice."""
250
+ aliases = {"ACT": "ACTIVE", "PASS": "PASSIVE"}
251
+ value = voice.upper()
252
+ return self.where(voice=aliases.get(value, value))
253
+
254
+ def with_nun_of_emphasis(self, present: bool = True) -> QuerySet[T]:
255
+ """Keep words or tokens with (or without) an annotated emphatic nun.
256
+
257
+ Pass False for absence. Non-verbs also lack an attached emphatic nun;
258
+ start with ``quran.verbs()`` when selecting only non-emphatic verbs.
259
+ """
260
+ if not isinstance(present, bool):
261
+ raise TypeError("present must be a bool")
262
+
263
+ def matches(item: Any) -> bool:
264
+ return bool(item.has_nun_of_emphasis()) == present
265
+
266
+ return self.filter(matches)
267
+
268
+ def without_nun_of_emphasis(self) -> QuerySet[T]:
269
+ """Keep words or tokens without an annotated emphatic nun."""
270
+ return self.with_nun_of_emphasis(False)
271
+
272
+ def with_person(self, person: int) -> QuerySet[T]:
273
+ """Keep inflected items with grammatical person 1, 2, or 3."""
274
+ return self.where(person=person)
275
+
276
+ def with_number(self, number: str) -> QuerySet[T]:
277
+ """Keep items whose number is singular, dual, or plural."""
278
+ aliases = {"S": "SINGULAR", "SG": "SINGULAR", "D": "DUAL", "P": "PLURAL", "PL": "PLURAL"}
279
+ value = number.upper()
280
+ return self.where(grammatical_number=aliases.get(value, value))
281
+
282
+ def with_gender(self, gender: str) -> QuerySet[T]:
283
+ """Keep masculine or feminine items; ``M`` and ``F`` are accepted."""
284
+ aliases = {"M": "MASC", "MASCULINE": "MASC", "F": "FEM", "FEMININE": "FEM"}
285
+ value = gender.upper()
286
+ return self.where(gender=aliases.get(value, value))
287
+
288
+ def with_part_of_speech(self, part_of_speech: str) -> QuerySet[T]:
289
+ """Keep items with an exact QAC part-of-speech code such as ``V`` or ``N``."""
290
+ return self.where(part_of_speech=part_of_speech.upper())
291
+
292
+ def with_case(self, case: str) -> QuerySet[T]:
293
+ """Keep items with a derived case/state such as ``NOM``, ``ACC``, or ``GEN``."""
294
+ return self.where(case=case.upper())
295
+
296
+ def with_mood(self, mood: str) -> QuerySet[T]:
297
+ """Keep items with an exact QAC mood code such as ``SUBJ`` or ``JUS``."""
298
+ return self.where(mood=mood.upper())
299
+
300
+ def with_definiteness(self, definiteness: str) -> QuerySet[T]:
301
+ """Keep items with the requested derived definiteness value."""
302
+ return self.where(definiteness=definiteness.upper())
303
+
304
+ def with_derived_noun(self, kind: str) -> QuerySet[T]:
305
+ """Keep verbal nouns or active/passive participles by derived noun kind."""
306
+ return self.where(derived_noun=kind.upper())
307
+
308
+ def with_conjugation(self, conjugation: int) -> QuerySet[T]:
309
+ """Keep items with a legacy numeric conjugation identifier (1 through 14)."""
310
+ return self.where(conjugation=conjugation)
311
+
312
+ def with_tag(self, tag: str) -> QuerySet[T]:
313
+ """Keep morphology tokens with an exact QAC tag."""
314
+ return self.where(tag=tag.upper())
315
+
316
+ def with_role(self, role: str) -> QuerySet[T]:
317
+ """Keep tokens whose structural role is ``PREFIX``, ``STEM``, or ``SUFFIX``."""
318
+ return self.where(role=role.upper())
319
+
320
+ def with_source_feature(self, feature: str) -> QuerySet[T]:
321
+ """Keep tokens containing an exact non-keyed source feature."""
322
+ return self.filter(lambda item: feature in getattr(item, "source_features", ()))
323
+
324
+ def in_chapter(self, chapter: int) -> QuerySet[T]:
325
+ """Keep addressed objects belonging to ``chapter``."""
326
+ return self.where(chapter=chapter)
327
+
328
+ def in_verse(self, chapter: int, verse: int) -> QuerySet[T]:
329
+ """Keep addressed objects belonging to one verse."""
330
+ return self.where(chapter=chapter, verse=verse)
331
+
332
+ def in_word(self, chapter: int, verse: int, word: int) -> QuerySet[T]:
333
+ """Keep addressed objects belonging to one morphology word."""
334
+ return self.where(chapter=chapter, verse=verse, word=word)
335
+
336
+ def roots(self) -> frozenset[str]:
337
+ """Return all distinct Buckwalter roots represented in the result."""
338
+ return frozenset(
339
+ root for item in self for root in _morphology_values(item, "roots", "root")
340
+ )
341
+
342
+ def lemmas(self) -> frozenset[str]:
343
+ """Return all distinct Buckwalter lemmas represented in the result."""
344
+ return frozenset(
345
+ lemma for item in self for lemma in _morphology_values(item, "lemmas", "lemma")
346
+ )
347
+
348
+ def forms(self) -> frozenset[str]:
349
+ """Return all distinct verb-form/baab codes represented in the result."""
350
+ return frozenset(
351
+ form
352
+ for item in self
353
+ for form in (
354
+ _values(item, "VERB_FORM") or _morphology_values(item, "forms", "verb_form")
355
+ )
356
+ )
357
+
358
+ def most_common_forms(self, limit: int | None = None) -> tuple[tuple[str, int], ...]:
359
+ """Rank individual verb-form codes, including words with multiple stems."""
360
+ counts = Counter(
361
+ form
362
+ for item in self
363
+ for form in (
364
+ _values(item, "VERB_FORM") or _morphology_values(item, "forms", "verb_form")
365
+ )
366
+ )
367
+ return tuple(counts.most_common(limit))
368
+
369
+ def sorted_by(
370
+ self,
371
+ field_or_callable: str | Callable[[T], Any],
372
+ *,
373
+ reverse: bool = False,
374
+ ) -> QuerySet[T]:
375
+ """Return results sorted by a dotted attribute or callable."""
376
+ return QuerySet(sorted(self, key=_selector(field_or_callable), reverse=reverse))
377
+
378
+ def starts_with(
379
+ self, prefix: str, *, representation: str = "auto", **normalization: Any
380
+ ) -> QuerySet[T]:
381
+ """Keep text-bearing objects whose normalized text starts with ``prefix``."""
382
+ return self.filter(
383
+ lambda item: _text_matches(
384
+ item,
385
+ "starts_with",
386
+ prefix,
387
+ representation=representation,
388
+ **normalization,
389
+ )
390
+ )
391
+
392
+ def ends_with(
393
+ self, suffix: str, *, representation: str = "auto", **normalization: Any
394
+ ) -> QuerySet[T]:
395
+ """Keep text-bearing objects whose normalized text ends with ``suffix``."""
396
+ return self.filter(
397
+ lambda item: _text_matches(
398
+ item,
399
+ "ends_with",
400
+ suffix,
401
+ representation=representation,
402
+ **normalization,
403
+ )
404
+ )
405
+
406
+ def contains(
407
+ self, fragment: str, *, representation: str = "auto", **normalization: Any
408
+ ) -> QuerySet[T]:
409
+ """Keep text-bearing objects containing a normalized substring."""
410
+ return self.filter(
411
+ lambda item: _text_matches(
412
+ item,
413
+ "contains",
414
+ fragment,
415
+ representation=representation,
416
+ **normalization,
417
+ )
418
+ )
419
+
420
+ def equals(
421
+ self, expected_text: str, *, representation: str = "auto", **normalization: Any
422
+ ) -> QuerySet[T]:
423
+ """Keep objects whose complete normalized text equals ``expected_text``."""
424
+ return self.filter(
425
+ lambda item: _text_matches(
426
+ item,
427
+ "equals",
428
+ expected_text,
429
+ representation=representation,
430
+ **normalization,
431
+ )
432
+ )
433
+
434
+ def contains_letter(
435
+ self, letter: str, *, representation: str = "auto", **normalization: Any
436
+ ) -> QuerySet[T]:
437
+ """Keep text-bearing objects containing exactly one requested character."""
438
+ return self.filter(
439
+ lambda item: _text_matches(
440
+ item,
441
+ "contains_letter",
442
+ letter,
443
+ representation=representation,
444
+ **normalization,
445
+ )
446
+ )
447
+
448
+ def contains_any_letter(
449
+ self, letters: str, *, representation: str = "auto", **normalization: Any
450
+ ) -> QuerySet[T]:
451
+ """Keep objects containing at least one requested character."""
452
+ return self.filter(
453
+ lambda item: _text_matches(
454
+ item,
455
+ "contains_any_letter",
456
+ letters,
457
+ representation=representation,
458
+ **normalization,
459
+ )
460
+ )
461
+
462
+ def contains_all_letters(
463
+ self, letters: str, *, representation: str = "auto", **normalization: Any
464
+ ) -> QuerySet[T]:
465
+ """Keep objects containing every requested character at least once."""
466
+ return self.filter(
467
+ lambda item: _text_matches(
468
+ item,
469
+ "contains_all_letters",
470
+ letters,
471
+ representation=representation,
472
+ **normalization,
473
+ )
474
+ )
475
+
476
+ def without_letters(
477
+ self, letters: str, *, representation: str = "auto", **normalization: Any
478
+ ) -> QuerySet[T]:
479
+ """Keep objects containing none of the requested characters."""
480
+ return self.filter(
481
+ lambda item: _text_matches(
482
+ item,
483
+ "contains_no_letters",
484
+ letters,
485
+ representation=representation,
486
+ **normalization,
487
+ )
488
+ )
489
+
490
+ def with_letter_count(
491
+ self,
492
+ count: int,
493
+ *,
494
+ representation: str = "arabic",
495
+ **normalization: Any,
496
+ ) -> QuerySet[T]:
497
+ """Keep objects with exactly ``count`` alphabetic characters."""
498
+ return self.filter(
499
+ lambda item: (
500
+ item.letter_count( # type: ignore[attr-defined]
501
+ representation=representation, **normalization
502
+ )
503
+ == count
504
+ )
505
+ )
506
+
507
+ def with_minimum_character_count(
508
+ self,
509
+ characters: str,
510
+ count: int,
511
+ *,
512
+ representation: str = "auto",
513
+ **normalization: Any,
514
+ ) -> QuerySet[T]:
515
+ """Keep objects containing selected characters at least ``count`` times."""
516
+ return self.filter(
517
+ lambda item: (
518
+ item.count_characters( # type: ignore[attr-defined]
519
+ characters, representation=representation, **normalization
520
+ )
521
+ >= count
522
+ )
523
+ )
524
+
525
+ def texts(self, representation: str = "arabic") -> tuple[str, ...]:
526
+ """Return display text for every item in the requested representation."""
527
+ return tuple(
528
+ item.get_text(representation) # type: ignore[attr-defined]
529
+ for item in self
530
+ )
531
+
532
+ def show(
533
+ self,
534
+ representation: str = "arabic",
535
+ *,
536
+ limit: int | None = None,
537
+ include_address: bool = True,
538
+ ) -> None:
539
+ """Print a result set in Arabic, Buckwalter, or English translation."""
540
+ items = self._items if limit is None else self._items[:limit]
541
+ for item in items:
542
+ text = item.get_text(representation) # type: ignore[attr-defined]
543
+ address = getattr(item, "address", None)
544
+ print(f"{address}\t{text}" if include_address and address else text)
545
+
546
+ def longest_run(self, predicate: Callable[[T], bool]) -> QuerySet[T]:
547
+ """Return the longest contiguous run satisfying ``predicate``."""
548
+ best: list[T] = []
549
+ current: list[T] = []
550
+ for item in self:
551
+ if predicate(item):
552
+ current.append(item)
553
+ if len(current) > len(best):
554
+ best = current.copy()
555
+ else:
556
+ current.clear()
557
+ return QuerySet(best)
558
+
559
+
560
+ class IndexedCollection(QuerySet[T], Generic[K, T]):
561
+ """A queryable collection addressed by semantic, usually 1-based, keys."""
562
+
563
+ __slots__ = ("_index",)
564
+
565
+ def __init__(self, items: Iterable[T], key: Callable[[T], K]) -> None:
566
+ super().__init__(items)
567
+ self._index = {key(item): item for item in self._items}
568
+ if len(self._index) != len(self._items):
569
+ raise ValueError("Collection keys must be unique")
570
+
571
+ def __getitem__(self, key: K) -> T: # type: ignore[override]
572
+ return self._index[key]
573
+
574
+ def __contains__(self, key: object) -> bool:
575
+ return key in self._index
576
+
577
+ def keys(self) -> tuple[K, ...]:
578
+ """Return semantic keys in collection order."""
579
+ return tuple(self._index)
580
+
581
+ def values(self) -> tuple[T, ...]:
582
+ """Return indexed objects in collection order."""
583
+ return self._items
584
+
585
+ def items(self) -> tuple[tuple[K, T], ...]:
586
+ """Return semantic key/object pairs in collection order."""
587
+ return tuple(self._index.items())
588
+
589
+
590
+ class OneBasedCollection(QuerySet[T]):
591
+ """A compact 1-based collection for naturally consecutive Quranic numbers."""
592
+
593
+ __slots__ = ()
594
+
595
+ def __getitem__(self, number: int) -> T: # type: ignore[override]
596
+ if not isinstance(number, int):
597
+ raise TypeError("One-based collections require an integer key")
598
+ if number < 1 or number > len(self._items):
599
+ raise KeyError(number)
600
+ return self._items[number - 1]
601
+
602
+ def __contains__(self, number: object) -> bool:
603
+ return isinstance(number, int) and 1 <= number <= len(self._items)
604
+
605
+ def keys(self) -> tuple[int, ...]:
606
+ """Return consecutive 1-based keys."""
607
+ return tuple(range(1, len(self._items) + 1))
608
+
609
+ def values(self) -> tuple[T, ...]:
610
+ """Return objects in semantic order."""
611
+ return self._items
612
+
613
+ def items(self) -> tuple[tuple[int, T], ...]:
614
+ """Return consecutive 1-based key/object pairs."""
615
+ return tuple(enumerate(self._items, 1))