refspy.manager

A facade for all major interface functions.

See refspy.refspy() for a useful helper function.

  1"""A facade for all major interface functions.
  2
  3See `refspy.refspy()` for a useful helper function.
  4"""
  5
  6import re
  7from collections.abc import Generator
  8from pydantic import TypeAdapter
  9
 10from refspy.models.book import Book
 11from refspy.models.language import Language
 12from refspy.models.library import Library
 13from refspy.models.range import combine_ranges, merge_ranges, range
 14from refspy.models.reference import (
 15    Reference,
 16    book_reference,
 17    chapter_reference,
 18    join_references,
 19    reference,
 20    split_reference,
 21    sort_references,
 22    unique_references,
 23    verse_reference,
 24)
 25from refspy.models.syntax import Syntax
 26from refspy.models.verse import verse
 27
 28from refspy.types.number import Number
 29
 30from refspy.formatter import Formatter
 31from refspy.indexers import (
 32    index_book_aliases,
 33    index_books,
 34    index_libraries,
 35)
 36from refspy.matcher import Matcher
 37from refspy.navigator import Navigator
 38from refspy.utils import url_param, url_escape
 39
 40"""
 41References can always be formatted with Manager.template(ref). If no
 42pattern argument is supplied, the default short format will be used, e.g.
 43'Rom 12:1-7'. or ref.abbrev_name()
 44"""
 45
 46
 47class Manager:
 48    """
 49    The manager object integrates the main library features into a single
 50    convenient facade.
 51    """
 52
 53    def __init__(
 54        self,
 55        libraries: list[Library],
 56        language: Language,
 57        syntax: Syntax | None = None,
 58        include_two_letter_aliases: bool = True,
 59    ):
 60        """
 61        Construct a new Manager object.
 62
 63        Args:
 64            libraries: A list of libraries like NT, OT.
 65            language: A language object like ENGLISH.
 66            language: A syntax object like INTERNATIONAL.
 67            include_two_letter_aliases: Whether to allow `len(alias) == 2`
 68                (default: True)
 69        """
 70        self.libraries: dict[Number, Library] = index_libraries(libraries)
 71        """A lookup dictionary for Libraries by library.id """
 72
 73        self.books: dict[tuple[Number, Number], Book] = index_books(libraries)
 74        """A lookup dictionary for Books by (library.id, book.id)"""
 75
 76        self.book_aliases: dict[str, tuple[Number, Number]] = index_book_aliases(
 77            libraries, include_two_letter_aliases=include_two_letter_aliases
 78        )
 79        """A lookup dictionary for (library.id, book.id) by book alias strings."""
 80
 81        self.language: Language = language
 82        """Language-specific program data."""
 83
 84        self.syntax: Syntax = syntax or language.syntax
 85        """Syntax-specific program data."""
 86
 87        self.matcher: Matcher = Matcher(
 88            self.books, self.book_aliases, self.language, self.syntax
 89        )
 90        """Delegate reference matching tasks."""
 91
 92        self.formatter: Formatter = Formatter(self.books, self.book_aliases)
 93        """Delegate formatting tasks."""
 94
 95        self.navigator: Navigator = Navigator(self.books, self.book_aliases)
 96        """Delegate navigation tasks."""
 97
 98    # -----------------------------------
 99    # Index and summary functions
100    # -----------------------------------
101
102    def make_index_references_by_chapter(
103        self, references: list[Reference]
104    ) -> list[Reference]:
105        """Return a sorted, combined, simplified list of references by book."""
106        collation = self.collate_chapter_references(
107            sorted([ref for ref in references if ref and not ref.is_book()])
108        )
109        indexes = []
110        for _, book_collation in collation:
111            for _, chapter_collation in book_collation:
112                for _, reference_list in chapter_collation:
113                    compact_ref = join_references(reference_list)
114                    indexes.append(compact_ref)
115        return indexes
116
117    def make_index(
118        self,
119        references: list[Reference],
120        pattern: str | None = None,
121    ) -> str | None:
122        """
123        Return a one-line list of sorted references. No merging or combining.
124
125        Args:
126            pattern: a formatting string suitable for links, see
127                `refspy.manager.Manager.template()`.
128
129        Note:
130            If no template pattern is provided, reference formatting
131            will default to `refspy.manager.Manager.abbrev_name()`
132        """
133        if indexes := self.make_index_references_by_chapter(references):
134            return self.template(join_references(indexes))
135        else:
136            return None
137
138    def make_summary_references(self, references: list[Reference]) -> list[Reference]:
139        """Return a sorted, combined, simplified list of References."""
140        collation = self.collate(
141            sorted([ref for ref in references if ref and not ref.is_book()])
142        )
143        summary = []
144        for _, book_collation in collation:
145            for _, reference_list in book_collation:
146                compact_ref = self.combine_references(reference_list)
147                summary.append(compact_ref)
148        return summary
149
150    def make_summary(
151        self, references: list[Reference], pattern: str | None = None
152    ) -> str | None:
153        """
154        Return a string showing a sorted, combined, list of References.
155
156        Args:
157            pattern: a formatting string suitable for links, see
158                `refspy.manager.Manager.template()`.
159
160        Note:
161            If no template pattern is provided, reference formatting
162            will default to `refspy.manager.Manager.abbrev_name()`.
163
164        Args:
165            max_chapters: The maximum number of chapter hotspots to return.
166            min_references: The minimal references per chapter that qualifies as a hotspot.
167        """
168        if summaries := self.make_summary_references(references):
169            return "; ".join([self.template(ref, pattern) for ref in summaries])
170        else:
171            return None
172
173    def make_summary_references_by_chapter(
174        self, references: list[Reference]
175    ) -> list[Reference]:
176        """Return a sorted, combined, simplified list of references by book."""
177        collation = self.collate_chapter_references(
178            sorted([ref for ref in references if ref and not ref.is_book()])
179        )
180        summary = []
181        for _, book_collation in collation:
182            for _, chapter_collation in book_collation:
183                for _, reference_list in chapter_collation:
184                    compact_ref = self.combine_references(reference_list)
185                    summary.append(compact_ref)
186        return summary
187
188    def make_summary_by_chapter(
189        self, references: list[Reference], pattern: str | None = None
190    ) -> str | None:
191        """
192               Return a string showing a sorted, combined, list of References.
193
194               Args:
195                   pattern: a formatting string suitable for links, see
196                       `refspy.manager.Manager.template()`.
197
198               Note:
199                   If no template pattern is provided, reference formatting
200                   will default to `refspy.manager.Manager.abbrev_name()`.
201
202               Args:
203        a           max_chapters: The maximum number of chapter hotspots to return.
204                   min_references: The minimal references per chapter that qualifies as a hotspot.
205        """
206        if summaries := self.make_summary_references_by_chapter(references):
207            return ", ".join([self.template(ref, pattern) for ref in summaries])
208        else:
209            return None
210
211    def make_hotspot_tuples(
212        self,
213        references: list[Reference],
214        max_chapters: int = 7,
215        min_references: int = 2,
216    ) -> list[tuple[Reference, int]]:
217        """
218        Find the most referenced chapters in a set of references.
219
220        Args:
221            max_chapters: The maximum number of chapter hotspots to return.
222            min_references: The minimal references per chapter that qualifies as a hotspot.
223
224        Return:
225            a list of tuples of (chapter reference, count) in descending frequency
226        """
227        if not references:
228            return []
229        totals = dict()
230        for ref in sort_references(references):
231            for _ in ref.ranges:
232                for tuple in [
233                    (_.start.library, _.start.book, _.start.chapter),
234                    (_.end.library, _.end.book, _.end.chapter),
235                ]:
236                    if tuple in totals:
237                        totals[tuple] += 1
238                    else:
239                        totals[tuple] = 1
240        hotspots = [
241            (chapter_reference(*tuple), int(total / 2))
242            for tuple, total in totals.items()
243            if total / 2 >= min_references
244        ]
245        hotspots_desc = sorted(hotspots, key=lambda item: item[1], reverse=True)
246        return hotspots_desc[:max_chapters]
247
248    def make_hotspot_references(
249        self,
250        references: list[Reference],
251        max_chapters: int = 7,
252        min_references: int = 2,
253    ) -> list[Reference]:
254        """
255        Return chapter references in descending order of frequency.
256
257        Args:
258            max_chapters: The maximum number of chapter hotspots to return.
259            min_references: The minimal references per chapter that qualifies as a hotspot.
260        """
261        tuples = self.make_hotspot_tuples(references, max_chapters, min_references)
262        return [ref for ref, _ in tuples]
263
264    def make_hotspots(
265        self,
266        references: list[Reference],
267        max_chapters: int = 7,
268        min_references: int = 2,
269        pattern: str | None = None,
270    ) -> str | None:
271        """
272        Return chapter references in descending order of frequency, as a text
273        string, e.g. "Rom 3, 1 Cor 6", or None.
274
275        Args:
276            max_chapters: The maximum number of chapter hotspots to return.
277            min_references: The minimal references per chapter that qualifies as a hotspot.
278            pattern: an optional template pattern for formatting references
279        """
280        if refs := self.make_hotspot_references(
281            references, max_chapters=max_chapters, min_references=min_references
282        ):
283            return ", ".join([self.template(ref, pattern) for ref in refs])
284        else:
285            return None
286
287    # -----------------------------------
288    # Merging functions
289    # -----------------------------------
290
291    def sort(self, references: list[Reference]) -> list[Reference]:
292        """Sort a list of references by comparing its lists of ranges"""
293        return sort_references(references)
294
295    def unique(self, references: list[Reference]) -> list[Reference]:
296        """Return unique entries in an already sorted list.
297
298        Note:
299            - Like `uniq` on UNIX.
300        """
301        return unique_references(references)
302
303    def split(self, reference: Reference) -> list[Reference]:
304        """Split a reference into a list, with one range per reference
305
306        TODO:
307            - Add split by book, chapter?
308        """
309        return split_reference(reference)
310
311    def join(self, references: list[Reference]) -> Reference:
312        """Join a list of references into a single reference
313
314        TODO:
315            - Add join by book, chapter?
316        """
317        return join_references(references)
318
319    def sort_references(self, references: list[Reference]) -> Reference:
320        """
321        DEPRECATED for unclear naming: will be removed before 1.0
322
323        Return a single reference containing sorted ranges from a list of
324        references.
325
326        Superceded by `join_references(sort_references(references))` or just
327        `__.join(__.sort(references))` in Manager.
328        """
329        all_ranges = [_ for ref in references for _ in ref.ranges]
330        return Reference(ranges=sorted(all_ranges))
331
332    def merge_references(self, references: list[Reference]) -> Reference:
333        """For a list of references, merge their ranges into a new reference.
334
335        Merging means combining ranges that overlap.
336        """
337        ranges = []
338        for ref in references:
339            ranges.extend(ref.ranges)
340        new_ref = reference(*merge_ranges(ranges))
341        return new_ref
342
343    def combine_references(self, references: list[Reference]) -> Reference:
344        """For a list of references, combine their ranges into a new reference.
345
346        Combining means sorting ranges, merging overlaps, and joining adjacent
347        records.
348        """
349        ranges = []
350        for ref in references:
351            ranges.extend(ref.ranges)
352        new_ref = reference(*combine_ranges(ranges))
353        return new_ref
354
355    # -----------------------------------
356    # Iteration functions
357    # -----------------------------------
358
359    def collate(
360        self, references: list[Reference]
361    ) -> list[tuple[Library, list[tuple[Book, list[Reference]]]]]:
362        """
363        The default collation is by book; use collate_chapter_references
364        otherwise.
365        """
366        return self.collate_book_references(references)
367
368    def collate_book_references(
369        self, references: list[Reference]
370    ) -> list[tuple[Library, list[tuple[Book, list[Reference]]]]]:
371        """
372        Collate references into nested library and book dictionaries for easy
373        looping.
374
375        Example:
376            ```
377            for library, book_collation in __.collate_books_references(refs):
378                for book, references in book_collation:
379                    # ...
380            ```
381        """
382        library_list = list()
383        for library_id, books_dict in self.collate_by_book(references).items():
384            book_list = list()
385            for book_id, references in books_dict.items():
386                book_list.append(tuple([self.books[library_id, book_id], references]))
387            library_list.append(tuple([self.libraries[library_id], book_list]))
388        return library_list
389
390    def collate_by_book(
391        self, references: list[Reference]
392    ) -> dict[Number, dict[Number, list[Reference]]]:
393        """
394        A collation groups single-book references by library and book IDs.
395        Multi-book references are ignored.
396        """
397        collation = dict()
398        for ref in [_ for _ in references if _.count_books() == 1]:
399            v1 = ref.ranges[0].start
400            if v1.library not in collation:
401                collation[v1.library] = dict()
402            if v1.book not in collation[v1.library]:
403                collation[v1.library][v1.book] = list()
404            collation[v1.library][v1.book].append(ref)
405        return collation
406
407    def collate_chapter_references(
408        self, references: list[Reference]
409    ) -> list[tuple[Library, list[tuple[Book, list[tuple[Number, list[Reference]]]]]]]:
410        """
411        Collate references into nested library, book, and chapter dictionaries
412        for easy looping.
413
414        Example:
415            ```
416            for library, book_collation in __.collate_chapter_references(refs):
417                for book, chapter_collation in book_collation:
418                    for chapter_number, references in chapter_collation:
419                        # ...
420            ```
421        """
422        library_list = list()
423        for library_id, books_dict in self.collate_by_chapter(references).items():
424            book_list = list()
425            for book_id, chapters_dict in books_dict.items():
426                chapter_list = list()
427                for chapter_id, references in chapters_dict.items():
428                    chapter_list.append(tuple([chapter_id, references]))
429                book_list.append(tuple([self.books[library_id, book_id], chapter_list]))
430            library_list.append(tuple([self.libraries[library_id], book_list]))
431        return library_list
432
433    def collate_by_chapter(
434        self, references: list[Reference]
435    ) -> dict[Number, dict[Number, dict[Number, list[Reference]]]]:
436        """
437        A collation groups single-book references by library, book, and chapter
438        IDs. Multi-book references are ignored.
439        """
440        collation = dict()
441        for ref in [_ for _ in references if _.count_books() == 1]:
442            v1 = ref.ranges[0].start
443            if v1.library not in collation:
444                collation[v1.library] = dict()
445            if v1.book not in collation[v1.library]:
446                collation[v1.library][v1.book] = dict()
447            if v1.chapter not in collation[v1.library][v1.book]:
448                collation[v1.library][v1.book][v1.chapter] = list()
449            collation[v1.library][v1.book][v1.chapter].append(ref)
450        return collation
451
452    def collate_verse_references(
453        self, references: list[Reference]
454    ) -> list[
455        tuple[
456            Library, list[tuple[Book, list[tuple[Number, list[tuple[str, Reference]]]]]]
457        ]
458    ]:
459        """
460        Collate references into nested library, book, chapter and verse-range
461        dictionaries for easy looping.
462
463        Example:
464            ```
465            for library, book_collation in __.collate_verse_references(refs):
466                for book, chapter_collation in book_collation:
467                    for chapter_number, verse_collation in chapter_collation:
468                        for verse_numbers, references in verse_collation:
469                            # ...
470            ```
471
472        Note:
473            - verse_numbers is a unique string representing the verse ranges,
474              e.g. '1, 5-6, 13'
475        """
476        library_list = list()
477        for library_id, books_dict in self.collate_by_verse(references).items():
478            book_list = list()
479            for book_id, chapters_dict in books_dict.items():
480                chapter_list = list()
481                for chapter_id, verses_dict in chapters_dict.items():
482                    verse_list = list()
483                    for ref_numbers, references in verses_dict.items():
484                        verse_list.append(tuple([ref_numbers, references]))
485                    chapter_list.append(tuple([chapter_id, verse_list]))
486                book_list.append(tuple([self.books[library_id, book_id], chapter_list]))
487            library_list.append(tuple([self.libraries[library_id], book_list]))
488        return library_list
489
490    def collate_by_verse(
491        self, references: list[Reference], aggregate_function=None
492    ) -> dict[Number, dict[Number, dict[Number, dict[str, list[Reference]]]]]:
493        """
494        A collation groups single-book references by library, book, chapter
495        number, and a string reresenting the verse ranges. Multi-book
496        references are ignored.
497        """
498        collation = dict()
499        for ref in [_ for _ in references if _.count_books() == 1]:
500            v1 = ref.ranges[0].start
501            if v1.library not in collation:
502                collation[v1.library] = dict()
503            if v1.book not in collation[v1.library]:
504                collation[v1.library][v1.book] = dict()
505            if v1.chapter not in collation[v1.library][v1.book]:
506                collation[v1.library][v1.book][v1.chapter] = dict()
507            ref_numbers = self.numbers(ref)
508            if ref_numbers not in collation[v1.library][v1.book][v1.chapter]:
509                collation[v1.library][v1.book][v1.chapter][ref_numbers] = list()
510            collation[v1.library][v1.book][v1.chapter][ref_numbers].append(ref)
511        return collation
512
513    # -----------------------------------
514    # Matching functions
515    # -----------------------------------
516
517    def first_reference(self, text: str) -> tuple[str | None, Reference | None]:
518        """
519        Return the first tuple of (match_str, reference) found by
520        `refspy.manager.Manager.generate_references()`
521        """
522        generator = self.matcher.generate_references(text)
523        return next(generator, (None, None))
524
525    def find_references(
526        self,
527        text: str,
528        include_books: bool = False,
529        include_nones: bool = False,
530        use_context: bool = True,
531    ) -> list[tuple[str, Reference | None]]:
532        """
533        Return a list of tuples of (match_str, reference) found by
534        `refspy.manager.Manager.generate_references()`
535        """
536        generator = self.matcher.generate_references(
537            text, include_books, include_nones, use_context
538        )
539        return list(generator)
540
541    def generate_references(
542        self,
543        text: str,
544        yield_books: bool = False,
545        yield_nones: bool = False,
546        use_context: bool = True,
547    ) -> Generator[tuple[str, Reference | None], None, None]:
548        """
549        Generate tuples of (match_str, reference) for provided text.manager
550
551        Task delegated to `refspy.matcher.Matcher.generate_references()`.
552
553        Args:
554            text: a string to search for references
555            yield_books: Whether to yield book names without reference numbers
556            yield_nones: Whether to yield (match_str, None) for malformed
557                references.
558            use_context: Whether to yield anything other than exact book and
559                number matches
560
561        Yield:
562            A tuple of `(match_str, reference)` for each valid reference.
563        """
564        yield from self.matcher.generate_references(
565            text, yield_books, yield_nones, use_context
566        )
567
568    # -----------------------------------
569    # Reference creator functions
570    # -----------------------------------
571
572    def bcv(
573        self,
574        alias: str,
575        c: Number | None = None,
576        v: Number | None = None,
577        v_end: Number | None = None,
578    ) -> Reference:
579        """Construct a reference from a book alias and chapter/verse numbers.
580
581        Omitting optional arguments will construct a reference to a whole book
582        or chapter.
583
584        Args:
585            v_end: constructs a verse range from `v` to `v_end`.
586
587        Raises:
588            ValueError: If the book_alias is missing, or the number values are
589                out of range.
590        """
591        if alias not in self.book_aliases:
592            raise ValueError(f'Book alias "{alias}" not found.')
593        library_id, book_id = self.book_aliases[alias]
594        ta = TypeAdapter(Number)
595        if c is None:
596            return book_reference(library_id, book_id)
597        if v is None:
598            return chapter_reference(library_id, book_id, ta.validate_python(c))
599
600        if not v_end:
601            return verse_reference(
602                library_id,
603                book_id,
604                ta.validate_python(c),
605                ta.validate_python(v),
606            )
607        else:
608            return reference(
609                range(
610                    verse(
611                        library_id,
612                        book_id,
613                        ta.validate_python(c),
614                        ta.validate_python(v),
615                    ),
616                    verse(
617                        library_id,
618                        book_id,
619                        ta.validate_python(c),
620                        ta.validate_python(v_end),
621                    ),
622                )
623            )
624
625    def bcr(
626        self, alias: str, c: Number, v_ranges: list[Number | tuple[Number, Number]]
627    ) -> Reference:
628        if alias not in self.book_aliases:
629            raise ValueError(f'Book alias "{alias}" not found.')
630        library_id, book_id = self.book_aliases[alias]
631        ranges = []
632        for val in v_ranges:
633            if isinstance(val, tuple):
634                v_start, v_end = val
635                ranges.append(
636                    range(
637                        verse(library_id, book_id, c, v_start),
638                        verse(library_id, book_id, c, v_end),
639                    )
640                )
641            elif isinstance(val, int):
642                v_start = v_end = val
643                ranges.append(
644                    range(
645                        verse(library_id, book_id, c, v_start),
646                        verse(library_id, book_id, c, v_end),
647                    )
648                )
649        return reference(*ranges)
650
651    def r(self, text: str) -> Reference | None:
652        """Return the first matching reference in the given text.
653
654        Book names and malformed references are not matched.
655        """
656        _, reference = self.first_reference(text)
657        return reference
658
659    # -----------------------------------
660    # Navigation functions
661    # -----------------------------------
662
663    def next_chapter(self, ref: Reference) -> Reference | None:
664        """Get the next chapter to the one containing this reference.
665
666        This loops over the end of books using `refspy.models.book.Book.chapters`.
667        This does not loop beyond the end of libraries.
668        """
669        return self.navigator.next_chapter(ref)
670
671    def prev_chapter(self, ref: Reference) -> Reference | None:
672        """Get the previous chapter to the one containing this reference.
673
674        This loops over the end of books using `refspy.models.book.Book.chapters`, but
675        not beyond the end of libraries.
676        """
677        return self.navigator.prev_chapter(ref)
678
679    # -----------------------------------
680    # Manipulation functions
681    # -----------------------------------
682
683    def get_book(self, ref: Reference) -> Book:
684        """Get the book object for this reference's first range."""
685        v1 = ref.ranges[0].start
686        return self.books[v1.library, v1.book]
687
688    def book_reference(self, ref: Reference) -> Reference:
689        """Create a reference to the book containing this reference's first
690        range."""
691        v1 = ref.ranges[0].start
692        return reference(
693            range(
694                verse(v1.library, v1.book, 1, 1),
695                verse(v1.library, v1.book, 999, 999),
696            )
697        )
698
699    def chapter_reference(self, ref: Reference) -> Reference:
700        """Create a reference to the chapter containing this reference's first
701        range."""
702        v1 = ref.ranges[0].start
703        return reference(
704            range(
705                verse(v1.library, v1.book, v1.chapter, 1),
706                verse(v1.library, v1.book, v1.chapter, 999),
707            )
708        )
709
710    # -----------------------------------
711    # Formatting functions
712    # -----------------------------------
713
714    def link(self, ref: Reference) -> str:
715        """Format a URL Link, with English style number references"""
716        return self.formatter.format(ref, self.formatter.link_format())
717
718    def name(self, ref: Reference) -> str:
719        """Format a reference."""
720        return self.formatter.format(ref, self.formatter.name_format(self.language))
721
722    def book(self, ref: Reference) -> str:
723        """Format a reference using only the book part of its name."""
724        return self.formatter.format(ref, self.formatter.book_format(self.language))
725
726    def abbrev_name(self, ref: Reference) -> str:
727        """Format an abbreviated reference."""
728        return self.formatter.format(
729            ref, self.formatter.abbrev_name_format(self.language)
730        )
731
732    def abbrev_book(self, ref: Reference) -> str:
733        """Format an abbreviated reference using only the book part of its name."""
734        return self.formatter.format(
735            ref, self.formatter.abbrev_book_format(self.language)
736        )
737
738    def numbers(self, ref: Reference) -> str:
739        """Format a reference using only the number part of its name."""
740        return self.formatter.format(ref, self.formatter.number_format(self.language))
741
742    def abbrev_numbers(self, ref: Reference) -> str:
743        """Format an abbreviated reference using only the number part of its name."""
744        return self.formatter.format(
745            ref, self.formatter.abbrev_number_format(self.language)
746        )
747
748    # -----------------------------------
749    # Template formatting functions
750    # -----------------------------------
751
752    def template(self, reference: Reference | None, pattern: str | None = None) -> str:
753        """
754        Substitute formatting values in a string:
755
756            * `{LINK}` -> "1%20Cor%202:3-4"
757                    - like ESC_ABBREV_NAME, but with English numbering in any language.
758                    - useful for BibleGateway and presumably other sites.
759            * `{NAME}` -> "1 Corinthians 2:3–4"
760            * `{BOOK}` -> "1 Corinthians"
761            * `{NUMBERS}` -> "2:3–4"
762            * `{ABBREV_NAME}` -> "1 Cor 2:3–4"
763            * `{ABBREV_BOOK}` -> "1 Cor"
764            * `{ABBREV_NUMBERS}` -> "2:3–4"
765            * `{ESC_NAME}` -> "1%20Corinthians%202%3A3-4"
766            * `{ESC_BOOK}` -> "1%20Corinthians"
767            * `{ESC_NUMBERS}` -> "2%3A3-4"
768            * `{ESC_ABBREV_NAME}` -> "1%20Cor%202%3A3-4"
769            * `{ESC_ABBREV_BOOK}` -> "1%20Cor"
770            * `{ESC_ABBREV_NUMBERS}` -> "2%3A3-4"
771            * `{PARAM_NAME}`-> "1cor+2.3-4"
772            * `{PARAM_BOOK}` -> "1cor"
773            * `{PARAM_NUMBERS}` -> "2.3-4"
774
775        For efficiency, we calculate only the values required by the template
776        string.
777        """
778        if reference is None:
779            return ""
780
781        out = pattern if pattern else "{ABBREV_NAME}"
782
783        regexp = re.compile(r"\{[A-Z_]+\}")
784        matches = regexp.findall(out)
785        numbers = self.numbers(reference)
786        for _ in matches:
787            if _ == "{LINK}":
788                out = out.replace("{LINK}", url_escape(self.link(reference)))
789            elif _ == "{NAME}":
790                out = out.replace("{NAME}", self.name(reference))
791            elif _ == "{BOOK}":
792                out = out.replace("{BOOK}", self.book(reference))
793            elif _ == "{NUMBERS}":
794                out = out.replace("{NUMBERS}", numbers)
795            elif _ == "{ASCII_NUMBERS}":
796                out = out.replace("{ASCII_NUMBERS}", numbers.replace("–", "-"))
797            elif _ == "{ABBREV_NAME}":
798                out = out.replace("{ABBREV_NAME}", self.abbrev_name(reference))
799            elif _ == "{ABBREV_BOOK}":
800                out = out.replace("{ABBREV_BOOK}", self.abbrev_name(reference))
801            elif _ == "{ESC_NAME}":
802                out = out.replace("{ESC_NAME}", url_escape(self.name(reference)))
803            elif _ == "{ESC_BOOK}":
804                out = out.replace("{ESC_BOOK}", url_escape(self.book(reference)))
805            elif _ == "{ESC_NUMBERS}":
806                out = out.replace("{ESC_NUMBERS}", url_escape(numbers))
807            elif _ == "{ESC_ASCII_NUMBERS}":
808                out = out.replace(
809                    "{ESC_ASCII_NUMBERS}",
810                    url_escape(numbers.replace("–", "-")),
811                )
812            elif _ == "{ESC_ABBREV_NAME}":
813                out = out.replace(
814                    "{ESC_ABBREV_NAME}", url_escape(self.abbrev_name(reference))
815                )
816            elif _ == "{ESC_ABBREV_BOOK}":
817                out = out.replace(
818                    "{ESC_ABBREV_BOOK}",
819                    url_escape(self.abbrev_book(reference)),
820                )
821            elif _ == "{PARAM_NAME}":
822                out = out.replace("{PARAM_NAME}", url_param(self.name(reference)))
823            elif _ == "{PARAM_BOOK}":
824                out = out.replace("{PARAM_BOOK}", url_param(numbers))
825            elif _ == "{PARAM_NUMBERS}":
826                out = out.replace("{PARAM_NUMBERS}", url_param(numbers))
827        return out
class Manager:
 48class Manager:
 49    """
 50    The manager object integrates the main library features into a single
 51    convenient facade.
 52    """
 53
 54    def __init__(
 55        self,
 56        libraries: list[Library],
 57        language: Language,
 58        syntax: Syntax | None = None,
 59        include_two_letter_aliases: bool = True,
 60    ):
 61        """
 62        Construct a new Manager object.
 63
 64        Args:
 65            libraries: A list of libraries like NT, OT.
 66            language: A language object like ENGLISH.
 67            language: A syntax object like INTERNATIONAL.
 68            include_two_letter_aliases: Whether to allow `len(alias) == 2`
 69                (default: True)
 70        """
 71        self.libraries: dict[Number, Library] = index_libraries(libraries)
 72        """A lookup dictionary for Libraries by library.id """
 73
 74        self.books: dict[tuple[Number, Number], Book] = index_books(libraries)
 75        """A lookup dictionary for Books by (library.id, book.id)"""
 76
 77        self.book_aliases: dict[str, tuple[Number, Number]] = index_book_aliases(
 78            libraries, include_two_letter_aliases=include_two_letter_aliases
 79        )
 80        """A lookup dictionary for (library.id, book.id) by book alias strings."""
 81
 82        self.language: Language = language
 83        """Language-specific program data."""
 84
 85        self.syntax: Syntax = syntax or language.syntax
 86        """Syntax-specific program data."""
 87
 88        self.matcher: Matcher = Matcher(
 89            self.books, self.book_aliases, self.language, self.syntax
 90        )
 91        """Delegate reference matching tasks."""
 92
 93        self.formatter: Formatter = Formatter(self.books, self.book_aliases)
 94        """Delegate formatting tasks."""
 95
 96        self.navigator: Navigator = Navigator(self.books, self.book_aliases)
 97        """Delegate navigation tasks."""
 98
 99    # -----------------------------------
100    # Index and summary functions
101    # -----------------------------------
102
103    def make_index_references_by_chapter(
104        self, references: list[Reference]
105    ) -> list[Reference]:
106        """Return a sorted, combined, simplified list of references by book."""
107        collation = self.collate_chapter_references(
108            sorted([ref for ref in references if ref and not ref.is_book()])
109        )
110        indexes = []
111        for _, book_collation in collation:
112            for _, chapter_collation in book_collation:
113                for _, reference_list in chapter_collation:
114                    compact_ref = join_references(reference_list)
115                    indexes.append(compact_ref)
116        return indexes
117
118    def make_index(
119        self,
120        references: list[Reference],
121        pattern: str | None = None,
122    ) -> str | None:
123        """
124        Return a one-line list of sorted references. No merging or combining.
125
126        Args:
127            pattern: a formatting string suitable for links, see
128                `refspy.manager.Manager.template()`.
129
130        Note:
131            If no template pattern is provided, reference formatting
132            will default to `refspy.manager.Manager.abbrev_name()`
133        """
134        if indexes := self.make_index_references_by_chapter(references):
135            return self.template(join_references(indexes))
136        else:
137            return None
138
139    def make_summary_references(self, references: list[Reference]) -> list[Reference]:
140        """Return a sorted, combined, simplified list of References."""
141        collation = self.collate(
142            sorted([ref for ref in references if ref and not ref.is_book()])
143        )
144        summary = []
145        for _, book_collation in collation:
146            for _, reference_list in book_collation:
147                compact_ref = self.combine_references(reference_list)
148                summary.append(compact_ref)
149        return summary
150
151    def make_summary(
152        self, references: list[Reference], pattern: str | None = None
153    ) -> str | None:
154        """
155        Return a string showing a sorted, combined, list of References.
156
157        Args:
158            pattern: a formatting string suitable for links, see
159                `refspy.manager.Manager.template()`.
160
161        Note:
162            If no template pattern is provided, reference formatting
163            will default to `refspy.manager.Manager.abbrev_name()`.
164
165        Args:
166            max_chapters: The maximum number of chapter hotspots to return.
167            min_references: The minimal references per chapter that qualifies as a hotspot.
168        """
169        if summaries := self.make_summary_references(references):
170            return "; ".join([self.template(ref, pattern) for ref in summaries])
171        else:
172            return None
173
174    def make_summary_references_by_chapter(
175        self, references: list[Reference]
176    ) -> list[Reference]:
177        """Return a sorted, combined, simplified list of references by book."""
178        collation = self.collate_chapter_references(
179            sorted([ref for ref in references if ref and not ref.is_book()])
180        )
181        summary = []
182        for _, book_collation in collation:
183            for _, chapter_collation in book_collation:
184                for _, reference_list in chapter_collation:
185                    compact_ref = self.combine_references(reference_list)
186                    summary.append(compact_ref)
187        return summary
188
189    def make_summary_by_chapter(
190        self, references: list[Reference], pattern: str | None = None
191    ) -> str | None:
192        """
193               Return a string showing a sorted, combined, list of References.
194
195               Args:
196                   pattern: a formatting string suitable for links, see
197                       `refspy.manager.Manager.template()`.
198
199               Note:
200                   If no template pattern is provided, reference formatting
201                   will default to `refspy.manager.Manager.abbrev_name()`.
202
203               Args:
204        a           max_chapters: The maximum number of chapter hotspots to return.
205                   min_references: The minimal references per chapter that qualifies as a hotspot.
206        """
207        if summaries := self.make_summary_references_by_chapter(references):
208            return ", ".join([self.template(ref, pattern) for ref in summaries])
209        else:
210            return None
211
212    def make_hotspot_tuples(
213        self,
214        references: list[Reference],
215        max_chapters: int = 7,
216        min_references: int = 2,
217    ) -> list[tuple[Reference, int]]:
218        """
219        Find the most referenced chapters in a set of references.
220
221        Args:
222            max_chapters: The maximum number of chapter hotspots to return.
223            min_references: The minimal references per chapter that qualifies as a hotspot.
224
225        Return:
226            a list of tuples of (chapter reference, count) in descending frequency
227        """
228        if not references:
229            return []
230        totals = dict()
231        for ref in sort_references(references):
232            for _ in ref.ranges:
233                for tuple in [
234                    (_.start.library, _.start.book, _.start.chapter),
235                    (_.end.library, _.end.book, _.end.chapter),
236                ]:
237                    if tuple in totals:
238                        totals[tuple] += 1
239                    else:
240                        totals[tuple] = 1
241        hotspots = [
242            (chapter_reference(*tuple), int(total / 2))
243            for tuple, total in totals.items()
244            if total / 2 >= min_references
245        ]
246        hotspots_desc = sorted(hotspots, key=lambda item: item[1], reverse=True)
247        return hotspots_desc[:max_chapters]
248
249    def make_hotspot_references(
250        self,
251        references: list[Reference],
252        max_chapters: int = 7,
253        min_references: int = 2,
254    ) -> list[Reference]:
255        """
256        Return chapter references in descending order of frequency.
257
258        Args:
259            max_chapters: The maximum number of chapter hotspots to return.
260            min_references: The minimal references per chapter that qualifies as a hotspot.
261        """
262        tuples = self.make_hotspot_tuples(references, max_chapters, min_references)
263        return [ref for ref, _ in tuples]
264
265    def make_hotspots(
266        self,
267        references: list[Reference],
268        max_chapters: int = 7,
269        min_references: int = 2,
270        pattern: str | None = None,
271    ) -> str | None:
272        """
273        Return chapter references in descending order of frequency, as a text
274        string, e.g. "Rom 3, 1 Cor 6", or None.
275
276        Args:
277            max_chapters: The maximum number of chapter hotspots to return.
278            min_references: The minimal references per chapter that qualifies as a hotspot.
279            pattern: an optional template pattern for formatting references
280        """
281        if refs := self.make_hotspot_references(
282            references, max_chapters=max_chapters, min_references=min_references
283        ):
284            return ", ".join([self.template(ref, pattern) for ref in refs])
285        else:
286            return None
287
288    # -----------------------------------
289    # Merging functions
290    # -----------------------------------
291
292    def sort(self, references: list[Reference]) -> list[Reference]:
293        """Sort a list of references by comparing its lists of ranges"""
294        return sort_references(references)
295
296    def unique(self, references: list[Reference]) -> list[Reference]:
297        """Return unique entries in an already sorted list.
298
299        Note:
300            - Like `uniq` on UNIX.
301        """
302        return unique_references(references)
303
304    def split(self, reference: Reference) -> list[Reference]:
305        """Split a reference into a list, with one range per reference
306
307        TODO:
308            - Add split by book, chapter?
309        """
310        return split_reference(reference)
311
312    def join(self, references: list[Reference]) -> Reference:
313        """Join a list of references into a single reference
314
315        TODO:
316            - Add join by book, chapter?
317        """
318        return join_references(references)
319
320    def sort_references(self, references: list[Reference]) -> Reference:
321        """
322        DEPRECATED for unclear naming: will be removed before 1.0
323
324        Return a single reference containing sorted ranges from a list of
325        references.
326
327        Superceded by `join_references(sort_references(references))` or just
328        `__.join(__.sort(references))` in Manager.
329        """
330        all_ranges = [_ for ref in references for _ in ref.ranges]
331        return Reference(ranges=sorted(all_ranges))
332
333    def merge_references(self, references: list[Reference]) -> Reference:
334        """For a list of references, merge their ranges into a new reference.
335
336        Merging means combining ranges that overlap.
337        """
338        ranges = []
339        for ref in references:
340            ranges.extend(ref.ranges)
341        new_ref = reference(*merge_ranges(ranges))
342        return new_ref
343
344    def combine_references(self, references: list[Reference]) -> Reference:
345        """For a list of references, combine their ranges into a new reference.
346
347        Combining means sorting ranges, merging overlaps, and joining adjacent
348        records.
349        """
350        ranges = []
351        for ref in references:
352            ranges.extend(ref.ranges)
353        new_ref = reference(*combine_ranges(ranges))
354        return new_ref
355
356    # -----------------------------------
357    # Iteration functions
358    # -----------------------------------
359
360    def collate(
361        self, references: list[Reference]
362    ) -> list[tuple[Library, list[tuple[Book, list[Reference]]]]]:
363        """
364        The default collation is by book; use collate_chapter_references
365        otherwise.
366        """
367        return self.collate_book_references(references)
368
369    def collate_book_references(
370        self, references: list[Reference]
371    ) -> list[tuple[Library, list[tuple[Book, list[Reference]]]]]:
372        """
373        Collate references into nested library and book dictionaries for easy
374        looping.
375
376        Example:
377            ```
378            for library, book_collation in __.collate_books_references(refs):
379                for book, references in book_collation:
380                    # ...
381            ```
382        """
383        library_list = list()
384        for library_id, books_dict in self.collate_by_book(references).items():
385            book_list = list()
386            for book_id, references in books_dict.items():
387                book_list.append(tuple([self.books[library_id, book_id], references]))
388            library_list.append(tuple([self.libraries[library_id], book_list]))
389        return library_list
390
391    def collate_by_book(
392        self, references: list[Reference]
393    ) -> dict[Number, dict[Number, list[Reference]]]:
394        """
395        A collation groups single-book references by library and book IDs.
396        Multi-book references are ignored.
397        """
398        collation = dict()
399        for ref in [_ for _ in references if _.count_books() == 1]:
400            v1 = ref.ranges[0].start
401            if v1.library not in collation:
402                collation[v1.library] = dict()
403            if v1.book not in collation[v1.library]:
404                collation[v1.library][v1.book] = list()
405            collation[v1.library][v1.book].append(ref)
406        return collation
407
408    def collate_chapter_references(
409        self, references: list[Reference]
410    ) -> list[tuple[Library, list[tuple[Book, list[tuple[Number, list[Reference]]]]]]]:
411        """
412        Collate references into nested library, book, and chapter dictionaries
413        for easy looping.
414
415        Example:
416            ```
417            for library, book_collation in __.collate_chapter_references(refs):
418                for book, chapter_collation in book_collation:
419                    for chapter_number, references in chapter_collation:
420                        # ...
421            ```
422        """
423        library_list = list()
424        for library_id, books_dict in self.collate_by_chapter(references).items():
425            book_list = list()
426            for book_id, chapters_dict in books_dict.items():
427                chapter_list = list()
428                for chapter_id, references in chapters_dict.items():
429                    chapter_list.append(tuple([chapter_id, references]))
430                book_list.append(tuple([self.books[library_id, book_id], chapter_list]))
431            library_list.append(tuple([self.libraries[library_id], book_list]))
432        return library_list
433
434    def collate_by_chapter(
435        self, references: list[Reference]
436    ) -> dict[Number, dict[Number, dict[Number, list[Reference]]]]:
437        """
438        A collation groups single-book references by library, book, and chapter
439        IDs. Multi-book references are ignored.
440        """
441        collation = dict()
442        for ref in [_ for _ in references if _.count_books() == 1]:
443            v1 = ref.ranges[0].start
444            if v1.library not in collation:
445                collation[v1.library] = dict()
446            if v1.book not in collation[v1.library]:
447                collation[v1.library][v1.book] = dict()
448            if v1.chapter not in collation[v1.library][v1.book]:
449                collation[v1.library][v1.book][v1.chapter] = list()
450            collation[v1.library][v1.book][v1.chapter].append(ref)
451        return collation
452
453    def collate_verse_references(
454        self, references: list[Reference]
455    ) -> list[
456        tuple[
457            Library, list[tuple[Book, list[tuple[Number, list[tuple[str, Reference]]]]]]
458        ]
459    ]:
460        """
461        Collate references into nested library, book, chapter and verse-range
462        dictionaries for easy looping.
463
464        Example:
465            ```
466            for library, book_collation in __.collate_verse_references(refs):
467                for book, chapter_collation in book_collation:
468                    for chapter_number, verse_collation in chapter_collation:
469                        for verse_numbers, references in verse_collation:
470                            # ...
471            ```
472
473        Note:
474            - verse_numbers is a unique string representing the verse ranges,
475              e.g. '1, 5-6, 13'
476        """
477        library_list = list()
478        for library_id, books_dict in self.collate_by_verse(references).items():
479            book_list = list()
480            for book_id, chapters_dict in books_dict.items():
481                chapter_list = list()
482                for chapter_id, verses_dict in chapters_dict.items():
483                    verse_list = list()
484                    for ref_numbers, references in verses_dict.items():
485                        verse_list.append(tuple([ref_numbers, references]))
486                    chapter_list.append(tuple([chapter_id, verse_list]))
487                book_list.append(tuple([self.books[library_id, book_id], chapter_list]))
488            library_list.append(tuple([self.libraries[library_id], book_list]))
489        return library_list
490
491    def collate_by_verse(
492        self, references: list[Reference], aggregate_function=None
493    ) -> dict[Number, dict[Number, dict[Number, dict[str, list[Reference]]]]]:
494        """
495        A collation groups single-book references by library, book, chapter
496        number, and a string reresenting the verse ranges. Multi-book
497        references are ignored.
498        """
499        collation = dict()
500        for ref in [_ for _ in references if _.count_books() == 1]:
501            v1 = ref.ranges[0].start
502            if v1.library not in collation:
503                collation[v1.library] = dict()
504            if v1.book not in collation[v1.library]:
505                collation[v1.library][v1.book] = dict()
506            if v1.chapter not in collation[v1.library][v1.book]:
507                collation[v1.library][v1.book][v1.chapter] = dict()
508            ref_numbers = self.numbers(ref)
509            if ref_numbers not in collation[v1.library][v1.book][v1.chapter]:
510                collation[v1.library][v1.book][v1.chapter][ref_numbers] = list()
511            collation[v1.library][v1.book][v1.chapter][ref_numbers].append(ref)
512        return collation
513
514    # -----------------------------------
515    # Matching functions
516    # -----------------------------------
517
518    def first_reference(self, text: str) -> tuple[str | None, Reference | None]:
519        """
520        Return the first tuple of (match_str, reference) found by
521        `refspy.manager.Manager.generate_references()`
522        """
523        generator = self.matcher.generate_references(text)
524        return next(generator, (None, None))
525
526    def find_references(
527        self,
528        text: str,
529        include_books: bool = False,
530        include_nones: bool = False,
531        use_context: bool = True,
532    ) -> list[tuple[str, Reference | None]]:
533        """
534        Return a list of tuples of (match_str, reference) found by
535        `refspy.manager.Manager.generate_references()`
536        """
537        generator = self.matcher.generate_references(
538            text, include_books, include_nones, use_context
539        )
540        return list(generator)
541
542    def generate_references(
543        self,
544        text: str,
545        yield_books: bool = False,
546        yield_nones: bool = False,
547        use_context: bool = True,
548    ) -> Generator[tuple[str, Reference | None], None, None]:
549        """
550        Generate tuples of (match_str, reference) for provided text.manager
551
552        Task delegated to `refspy.matcher.Matcher.generate_references()`.
553
554        Args:
555            text: a string to search for references
556            yield_books: Whether to yield book names without reference numbers
557            yield_nones: Whether to yield (match_str, None) for malformed
558                references.
559            use_context: Whether to yield anything other than exact book and
560                number matches
561
562        Yield:
563            A tuple of `(match_str, reference)` for each valid reference.
564        """
565        yield from self.matcher.generate_references(
566            text, yield_books, yield_nones, use_context
567        )
568
569    # -----------------------------------
570    # Reference creator functions
571    # -----------------------------------
572
573    def bcv(
574        self,
575        alias: str,
576        c: Number | None = None,
577        v: Number | None = None,
578        v_end: Number | None = None,
579    ) -> Reference:
580        """Construct a reference from a book alias and chapter/verse numbers.
581
582        Omitting optional arguments will construct a reference to a whole book
583        or chapter.
584
585        Args:
586            v_end: constructs a verse range from `v` to `v_end`.
587
588        Raises:
589            ValueError: If the book_alias is missing, or the number values are
590                out of range.
591        """
592        if alias not in self.book_aliases:
593            raise ValueError(f'Book alias "{alias}" not found.')
594        library_id, book_id = self.book_aliases[alias]
595        ta = TypeAdapter(Number)
596        if c is None:
597            return book_reference(library_id, book_id)
598        if v is None:
599            return chapter_reference(library_id, book_id, ta.validate_python(c))
600
601        if not v_end:
602            return verse_reference(
603                library_id,
604                book_id,
605                ta.validate_python(c),
606                ta.validate_python(v),
607            )
608        else:
609            return reference(
610                range(
611                    verse(
612                        library_id,
613                        book_id,
614                        ta.validate_python(c),
615                        ta.validate_python(v),
616                    ),
617                    verse(
618                        library_id,
619                        book_id,
620                        ta.validate_python(c),
621                        ta.validate_python(v_end),
622                    ),
623                )
624            )
625
626    def bcr(
627        self, alias: str, c: Number, v_ranges: list[Number | tuple[Number, Number]]
628    ) -> Reference:
629        if alias not in self.book_aliases:
630            raise ValueError(f'Book alias "{alias}" not found.')
631        library_id, book_id = self.book_aliases[alias]
632        ranges = []
633        for val in v_ranges:
634            if isinstance(val, tuple):
635                v_start, v_end = val
636                ranges.append(
637                    range(
638                        verse(library_id, book_id, c, v_start),
639                        verse(library_id, book_id, c, v_end),
640                    )
641                )
642            elif isinstance(val, int):
643                v_start = v_end = val
644                ranges.append(
645                    range(
646                        verse(library_id, book_id, c, v_start),
647                        verse(library_id, book_id, c, v_end),
648                    )
649                )
650        return reference(*ranges)
651
652    def r(self, text: str) -> Reference | None:
653        """Return the first matching reference in the given text.
654
655        Book names and malformed references are not matched.
656        """
657        _, reference = self.first_reference(text)
658        return reference
659
660    # -----------------------------------
661    # Navigation functions
662    # -----------------------------------
663
664    def next_chapter(self, ref: Reference) -> Reference | None:
665        """Get the next chapter to the one containing this reference.
666
667        This loops over the end of books using `refspy.models.book.Book.chapters`.
668        This does not loop beyond the end of libraries.
669        """
670        return self.navigator.next_chapter(ref)
671
672    def prev_chapter(self, ref: Reference) -> Reference | None:
673        """Get the previous chapter to the one containing this reference.
674
675        This loops over the end of books using `refspy.models.book.Book.chapters`, but
676        not beyond the end of libraries.
677        """
678        return self.navigator.prev_chapter(ref)
679
680    # -----------------------------------
681    # Manipulation functions
682    # -----------------------------------
683
684    def get_book(self, ref: Reference) -> Book:
685        """Get the book object for this reference's first range."""
686        v1 = ref.ranges[0].start
687        return self.books[v1.library, v1.book]
688
689    def book_reference(self, ref: Reference) -> Reference:
690        """Create a reference to the book containing this reference's first
691        range."""
692        v1 = ref.ranges[0].start
693        return reference(
694            range(
695                verse(v1.library, v1.book, 1, 1),
696                verse(v1.library, v1.book, 999, 999),
697            )
698        )
699
700    def chapter_reference(self, ref: Reference) -> Reference:
701        """Create a reference to the chapter containing this reference's first
702        range."""
703        v1 = ref.ranges[0].start
704        return reference(
705            range(
706                verse(v1.library, v1.book, v1.chapter, 1),
707                verse(v1.library, v1.book, v1.chapter, 999),
708            )
709        )
710
711    # -----------------------------------
712    # Formatting functions
713    # -----------------------------------
714
715    def link(self, ref: Reference) -> str:
716        """Format a URL Link, with English style number references"""
717        return self.formatter.format(ref, self.formatter.link_format())
718
719    def name(self, ref: Reference) -> str:
720        """Format a reference."""
721        return self.formatter.format(ref, self.formatter.name_format(self.language))
722
723    def book(self, ref: Reference) -> str:
724        """Format a reference using only the book part of its name."""
725        return self.formatter.format(ref, self.formatter.book_format(self.language))
726
727    def abbrev_name(self, ref: Reference) -> str:
728        """Format an abbreviated reference."""
729        return self.formatter.format(
730            ref, self.formatter.abbrev_name_format(self.language)
731        )
732
733    def abbrev_book(self, ref: Reference) -> str:
734        """Format an abbreviated reference using only the book part of its name."""
735        return self.formatter.format(
736            ref, self.formatter.abbrev_book_format(self.language)
737        )
738
739    def numbers(self, ref: Reference) -> str:
740        """Format a reference using only the number part of its name."""
741        return self.formatter.format(ref, self.formatter.number_format(self.language))
742
743    def abbrev_numbers(self, ref: Reference) -> str:
744        """Format an abbreviated reference using only the number part of its name."""
745        return self.formatter.format(
746            ref, self.formatter.abbrev_number_format(self.language)
747        )
748
749    # -----------------------------------
750    # Template formatting functions
751    # -----------------------------------
752
753    def template(self, reference: Reference | None, pattern: str | None = None) -> str:
754        """
755        Substitute formatting values in a string:
756
757            * `{LINK}` -> "1%20Cor%202:3-4"
758                    - like ESC_ABBREV_NAME, but with English numbering in any language.
759                    - useful for BibleGateway and presumably other sites.
760            * `{NAME}` -> "1 Corinthians 2:3–4"
761            * `{BOOK}` -> "1 Corinthians"
762            * `{NUMBERS}` -> "2:3–4"
763            * `{ABBREV_NAME}` -> "1 Cor 2:3–4"
764            * `{ABBREV_BOOK}` -> "1 Cor"
765            * `{ABBREV_NUMBERS}` -> "2:3–4"
766            * `{ESC_NAME}` -> "1%20Corinthians%202%3A3-4"
767            * `{ESC_BOOK}` -> "1%20Corinthians"
768            * `{ESC_NUMBERS}` -> "2%3A3-4"
769            * `{ESC_ABBREV_NAME}` -> "1%20Cor%202%3A3-4"
770            * `{ESC_ABBREV_BOOK}` -> "1%20Cor"
771            * `{ESC_ABBREV_NUMBERS}` -> "2%3A3-4"
772            * `{PARAM_NAME}`-> "1cor+2.3-4"
773            * `{PARAM_BOOK}` -> "1cor"
774            * `{PARAM_NUMBERS}` -> "2.3-4"
775
776        For efficiency, we calculate only the values required by the template
777        string.
778        """
779        if reference is None:
780            return ""
781
782        out = pattern if pattern else "{ABBREV_NAME}"
783
784        regexp = re.compile(r"\{[A-Z_]+\}")
785        matches = regexp.findall(out)
786        numbers = self.numbers(reference)
787        for _ in matches:
788            if _ == "{LINK}":
789                out = out.replace("{LINK}", url_escape(self.link(reference)))
790            elif _ == "{NAME}":
791                out = out.replace("{NAME}", self.name(reference))
792            elif _ == "{BOOK}":
793                out = out.replace("{BOOK}", self.book(reference))
794            elif _ == "{NUMBERS}":
795                out = out.replace("{NUMBERS}", numbers)
796            elif _ == "{ASCII_NUMBERS}":
797                out = out.replace("{ASCII_NUMBERS}", numbers.replace("–", "-"))
798            elif _ == "{ABBREV_NAME}":
799                out = out.replace("{ABBREV_NAME}", self.abbrev_name(reference))
800            elif _ == "{ABBREV_BOOK}":
801                out = out.replace("{ABBREV_BOOK}", self.abbrev_name(reference))
802            elif _ == "{ESC_NAME}":
803                out = out.replace("{ESC_NAME}", url_escape(self.name(reference)))
804            elif _ == "{ESC_BOOK}":
805                out = out.replace("{ESC_BOOK}", url_escape(self.book(reference)))
806            elif _ == "{ESC_NUMBERS}":
807                out = out.replace("{ESC_NUMBERS}", url_escape(numbers))
808            elif _ == "{ESC_ASCII_NUMBERS}":
809                out = out.replace(
810                    "{ESC_ASCII_NUMBERS}",
811                    url_escape(numbers.replace("–", "-")),
812                )
813            elif _ == "{ESC_ABBREV_NAME}":
814                out = out.replace(
815                    "{ESC_ABBREV_NAME}", url_escape(self.abbrev_name(reference))
816                )
817            elif _ == "{ESC_ABBREV_BOOK}":
818                out = out.replace(
819                    "{ESC_ABBREV_BOOK}",
820                    url_escape(self.abbrev_book(reference)),
821                )
822            elif _ == "{PARAM_NAME}":
823                out = out.replace("{PARAM_NAME}", url_param(self.name(reference)))
824            elif _ == "{PARAM_BOOK}":
825                out = out.replace("{PARAM_BOOK}", url_param(numbers))
826            elif _ == "{PARAM_NUMBERS}":
827                out = out.replace("{PARAM_NUMBERS}", url_param(numbers))
828        return out

The manager object integrates the main library features into a single convenient facade.

Manager( libraries: list[refspy.models.library.Library], language: refspy.models.language.Language, syntax: refspy.models.syntax.Syntax | None = None, include_two_letter_aliases: bool = True)
54    def __init__(
55        self,
56        libraries: list[Library],
57        language: Language,
58        syntax: Syntax | None = None,
59        include_two_letter_aliases: bool = True,
60    ):
61        """
62        Construct a new Manager object.
63
64        Args:
65            libraries: A list of libraries like NT, OT.
66            language: A language object like ENGLISH.
67            language: A syntax object like INTERNATIONAL.
68            include_two_letter_aliases: Whether to allow `len(alias) == 2`
69                (default: True)
70        """
71        self.libraries: dict[Number, Library] = index_libraries(libraries)
72        """A lookup dictionary for Libraries by library.id """
73
74        self.books: dict[tuple[Number, Number], Book] = index_books(libraries)
75        """A lookup dictionary for Books by (library.id, book.id)"""
76
77        self.book_aliases: dict[str, tuple[Number, Number]] = index_book_aliases(
78            libraries, include_two_letter_aliases=include_two_letter_aliases
79        )
80        """A lookup dictionary for (library.id, book.id) by book alias strings."""
81
82        self.language: Language = language
83        """Language-specific program data."""
84
85        self.syntax: Syntax = syntax or language.syntax
86        """Syntax-specific program data."""
87
88        self.matcher: Matcher = Matcher(
89            self.books, self.book_aliases, self.language, self.syntax
90        )
91        """Delegate reference matching tasks."""
92
93        self.formatter: Formatter = Formatter(self.books, self.book_aliases)
94        """Delegate formatting tasks."""
95
96        self.navigator: Navigator = Navigator(self.books, self.book_aliases)
97        """Delegate navigation tasks."""

Construct a new Manager object.

Arguments:
  • libraries: A list of libraries like NT, OT.
  • language: A language object like ENGLISH.
  • language: A syntax object like INTERNATIONAL.
  • include_two_letter_aliases: Whether to allow len(alias) == 2 (default: True)
libraries: dict[typing.Annotated[int, Ge(ge=1), Le(le=999)], refspy.models.library.Library]

A lookup dictionary for Libraries by library.id

books: dict[tuple[typing.Annotated[int, Ge(ge=1), Le(le=999)], typing.Annotated[int, Ge(ge=1), Le(le=999)]], refspy.models.book.Book]

A lookup dictionary for Books by (library.id, book.id)

book_aliases: dict[str, tuple[typing.Annotated[int, Ge(ge=1), Le(le=999)], typing.Annotated[int, Ge(ge=1), Le(le=999)]]]

A lookup dictionary for (library.id, book.id) by book alias strings.

Language-specific program data.

Syntax-specific program data.

Delegate reference matching tasks.

Delegate formatting tasks.

Delegate navigation tasks.

def make_index_references_by_chapter( self, references: list[refspy.models.reference.Reference]) -> list[refspy.models.reference.Reference]:
103    def make_index_references_by_chapter(
104        self, references: list[Reference]
105    ) -> list[Reference]:
106        """Return a sorted, combined, simplified list of references by book."""
107        collation = self.collate_chapter_references(
108            sorted([ref for ref in references if ref and not ref.is_book()])
109        )
110        indexes = []
111        for _, book_collation in collation:
112            for _, chapter_collation in book_collation:
113                for _, reference_list in chapter_collation:
114                    compact_ref = join_references(reference_list)
115                    indexes.append(compact_ref)
116        return indexes

Return a sorted, combined, simplified list of references by book.

def make_index( self, references: list[refspy.models.reference.Reference], pattern: str | None = None) -> str | None:
118    def make_index(
119        self,
120        references: list[Reference],
121        pattern: str | None = None,
122    ) -> str | None:
123        """
124        Return a one-line list of sorted references. No merging or combining.
125
126        Args:
127            pattern: a formatting string suitable for links, see
128                `refspy.manager.Manager.template()`.
129
130        Note:
131            If no template pattern is provided, reference formatting
132            will default to `refspy.manager.Manager.abbrev_name()`
133        """
134        if indexes := self.make_index_references_by_chapter(references):
135            return self.template(join_references(indexes))
136        else:
137            return None

Return a one-line list of sorted references. No merging or combining.

Arguments:
Note:

If no template pattern is provided, reference formatting will default to refspy.manager.Manager.abbrev_name()

def make_summary_references( self, references: list[refspy.models.reference.Reference]) -> list[refspy.models.reference.Reference]:
139    def make_summary_references(self, references: list[Reference]) -> list[Reference]:
140        """Return a sorted, combined, simplified list of References."""
141        collation = self.collate(
142            sorted([ref for ref in references if ref and not ref.is_book()])
143        )
144        summary = []
145        for _, book_collation in collation:
146            for _, reference_list in book_collation:
147                compact_ref = self.combine_references(reference_list)
148                summary.append(compact_ref)
149        return summary

Return a sorted, combined, simplified list of References.

def make_summary( self, references: list[refspy.models.reference.Reference], pattern: str | None = None) -> str | None:
151    def make_summary(
152        self, references: list[Reference], pattern: str | None = None
153    ) -> str | None:
154        """
155        Return a string showing a sorted, combined, list of References.
156
157        Args:
158            pattern: a formatting string suitable for links, see
159                `refspy.manager.Manager.template()`.
160
161        Note:
162            If no template pattern is provided, reference formatting
163            will default to `refspy.manager.Manager.abbrev_name()`.
164
165        Args:
166            max_chapters: The maximum number of chapter hotspots to return.
167            min_references: The minimal references per chapter that qualifies as a hotspot.
168        """
169        if summaries := self.make_summary_references(references):
170            return "; ".join([self.template(ref, pattern) for ref in summaries])
171        else:
172            return None

Return a string showing a sorted, combined, list of References.

Arguments:
Note:

If no template pattern is provided, reference formatting will default to refspy.manager.Manager.abbrev_name().

Arguments:
  • max_chapters: The maximum number of chapter hotspots to return.
  • min_references: The minimal references per chapter that qualifies as a hotspot.
def make_summary_references_by_chapter( self, references: list[refspy.models.reference.Reference]) -> list[refspy.models.reference.Reference]:
174    def make_summary_references_by_chapter(
175        self, references: list[Reference]
176    ) -> list[Reference]:
177        """Return a sorted, combined, simplified list of references by book."""
178        collation = self.collate_chapter_references(
179            sorted([ref for ref in references if ref and not ref.is_book()])
180        )
181        summary = []
182        for _, book_collation in collation:
183            for _, chapter_collation in book_collation:
184                for _, reference_list in chapter_collation:
185                    compact_ref = self.combine_references(reference_list)
186                    summary.append(compact_ref)
187        return summary

Return a sorted, combined, simplified list of references by book.

def make_summary_by_chapter( self, references: list[refspy.models.reference.Reference], pattern: str | None = None) -> str | None:
189    def make_summary_by_chapter(
190        self, references: list[Reference], pattern: str | None = None
191    ) -> str | None:
192        """
193               Return a string showing a sorted, combined, list of References.
194
195               Args:
196                   pattern: a formatting string suitable for links, see
197                       `refspy.manager.Manager.template()`.
198
199               Note:
200                   If no template pattern is provided, reference formatting
201                   will default to `refspy.manager.Manager.abbrev_name()`.
202
203               Args:
204        a           max_chapters: The maximum number of chapter hotspots to return.
205                   min_references: The minimal references per chapter that qualifies as a hotspot.
206        """
207        if summaries := self.make_summary_references_by_chapter(references):
208            return ", ".join([self.template(ref, pattern) for ref in summaries])
209        else:
210            return None

Return a string showing a sorted, combined, list of References.

   Args:
       pattern: a formatting string suitable for links, see
           `refspy.manager.Manager.template()`.

   Note:
       If no template pattern is provided, reference formatting
       will default to `refspy.manager.Manager.abbrev_name()`.

   Args:

a max_chapters: The maximum number of chapter hotspots to return. min_references: The minimal references per chapter that qualifies as a hotspot.

def make_hotspot_tuples( self, references: list[refspy.models.reference.Reference], max_chapters: int = 7, min_references: int = 2) -> list[tuple[refspy.models.reference.Reference, int]]:
212    def make_hotspot_tuples(
213        self,
214        references: list[Reference],
215        max_chapters: int = 7,
216        min_references: int = 2,
217    ) -> list[tuple[Reference, int]]:
218        """
219        Find the most referenced chapters in a set of references.
220
221        Args:
222            max_chapters: The maximum number of chapter hotspots to return.
223            min_references: The minimal references per chapter that qualifies as a hotspot.
224
225        Return:
226            a list of tuples of (chapter reference, count) in descending frequency
227        """
228        if not references:
229            return []
230        totals = dict()
231        for ref in sort_references(references):
232            for _ in ref.ranges:
233                for tuple in [
234                    (_.start.library, _.start.book, _.start.chapter),
235                    (_.end.library, _.end.book, _.end.chapter),
236                ]:
237                    if tuple in totals:
238                        totals[tuple] += 1
239                    else:
240                        totals[tuple] = 1
241        hotspots = [
242            (chapter_reference(*tuple), int(total / 2))
243            for tuple, total in totals.items()
244            if total / 2 >= min_references
245        ]
246        hotspots_desc = sorted(hotspots, key=lambda item: item[1], reverse=True)
247        return hotspots_desc[:max_chapters]

Find the most referenced chapters in a set of references.

Arguments:
  • max_chapters: The maximum number of chapter hotspots to return.
  • min_references: The minimal references per chapter that qualifies as a hotspot.
Return:

a list of tuples of (chapter reference, count) in descending frequency

def make_hotspot_references( self, references: list[refspy.models.reference.Reference], max_chapters: int = 7, min_references: int = 2) -> list[refspy.models.reference.Reference]:
249    def make_hotspot_references(
250        self,
251        references: list[Reference],
252        max_chapters: int = 7,
253        min_references: int = 2,
254    ) -> list[Reference]:
255        """
256        Return chapter references in descending order of frequency.
257
258        Args:
259            max_chapters: The maximum number of chapter hotspots to return.
260            min_references: The minimal references per chapter that qualifies as a hotspot.
261        """
262        tuples = self.make_hotspot_tuples(references, max_chapters, min_references)
263        return [ref for ref, _ in tuples]

Return chapter references in descending order of frequency.

Arguments:
  • max_chapters: The maximum number of chapter hotspots to return.
  • min_references: The minimal references per chapter that qualifies as a hotspot.
def make_hotspots( self, references: list[refspy.models.reference.Reference], max_chapters: int = 7, min_references: int = 2, pattern: str | None = None) -> str | None:
265    def make_hotspots(
266        self,
267        references: list[Reference],
268        max_chapters: int = 7,
269        min_references: int = 2,
270        pattern: str | None = None,
271    ) -> str | None:
272        """
273        Return chapter references in descending order of frequency, as a text
274        string, e.g. "Rom 3, 1 Cor 6", or None.
275
276        Args:
277            max_chapters: The maximum number of chapter hotspots to return.
278            min_references: The minimal references per chapter that qualifies as a hotspot.
279            pattern: an optional template pattern for formatting references
280        """
281        if refs := self.make_hotspot_references(
282            references, max_chapters=max_chapters, min_references=min_references
283        ):
284            return ", ".join([self.template(ref, pattern) for ref in refs])
285        else:
286            return None

Return chapter references in descending order of frequency, as a text string, e.g. "Rom 3, 1 Cor 6", or None.

Arguments:
  • max_chapters: The maximum number of chapter hotspots to return.
  • min_references: The minimal references per chapter that qualifies as a hotspot.
  • pattern: an optional template pattern for formatting references
def sort( self, references: list[refspy.models.reference.Reference]) -> list[refspy.models.reference.Reference]:
292    def sort(self, references: list[Reference]) -> list[Reference]:
293        """Sort a list of references by comparing its lists of ranges"""
294        return sort_references(references)

Sort a list of references by comparing its lists of ranges

def unique( self, references: list[refspy.models.reference.Reference]) -> list[refspy.models.reference.Reference]:
296    def unique(self, references: list[Reference]) -> list[Reference]:
297        """Return unique entries in an already sorted list.
298
299        Note:
300            - Like `uniq` on UNIX.
301        """
302        return unique_references(references)

Return unique entries in an already sorted list.

Note:
  • Like uniq on UNIX.
def split( self, reference: refspy.models.reference.Reference) -> list[refspy.models.reference.Reference]:
304    def split(self, reference: Reference) -> list[Reference]:
305        """Split a reference into a list, with one range per reference
306
307        TODO:
308            - Add split by book, chapter?
309        """
310        return split_reference(reference)

Split a reference into a list, with one range per reference

TODO:
  • Add split by book, chapter?
def join( self, references: list[refspy.models.reference.Reference]) -> refspy.models.reference.Reference:
312    def join(self, references: list[Reference]) -> Reference:
313        """Join a list of references into a single reference
314
315        TODO:
316            - Add join by book, chapter?
317        """
318        return join_references(references)

Join a list of references into a single reference

TODO:
  • Add join by book, chapter?
def sort_references( self, references: list[refspy.models.reference.Reference]) -> refspy.models.reference.Reference:
320    def sort_references(self, references: list[Reference]) -> Reference:
321        """
322        DEPRECATED for unclear naming: will be removed before 1.0
323
324        Return a single reference containing sorted ranges from a list of
325        references.
326
327        Superceded by `join_references(sort_references(references))` or just
328        `__.join(__.sort(references))` in Manager.
329        """
330        all_ranges = [_ for ref in references for _ in ref.ranges]
331        return Reference(ranges=sorted(all_ranges))

DEPRECATED for unclear naming: will be removed before 1.0

Return a single reference containing sorted ranges from a list of references.

Superceded by join_references(sort_references(references)) or just __.join(__.sort(references)) in Manager.

def merge_references( self, references: list[refspy.models.reference.Reference]) -> refspy.models.reference.Reference:
333    def merge_references(self, references: list[Reference]) -> Reference:
334        """For a list of references, merge their ranges into a new reference.
335
336        Merging means combining ranges that overlap.
337        """
338        ranges = []
339        for ref in references:
340            ranges.extend(ref.ranges)
341        new_ref = reference(*merge_ranges(ranges))
342        return new_ref

For a list of references, merge their ranges into a new reference.

Merging means combining ranges that overlap.

def combine_references( self, references: list[refspy.models.reference.Reference]) -> refspy.models.reference.Reference:
344    def combine_references(self, references: list[Reference]) -> Reference:
345        """For a list of references, combine their ranges into a new reference.
346
347        Combining means sorting ranges, merging overlaps, and joining adjacent
348        records.
349        """
350        ranges = []
351        for ref in references:
352            ranges.extend(ref.ranges)
353        new_ref = reference(*combine_ranges(ranges))
354        return new_ref

For a list of references, combine their ranges into a new reference.

Combining means sorting ranges, merging overlaps, and joining adjacent records.

def collate( self, references: list[refspy.models.reference.Reference]) -> list[tuple[refspy.models.library.Library, list[tuple[refspy.models.book.Book, list[refspy.models.reference.Reference]]]]]:
360    def collate(
361        self, references: list[Reference]
362    ) -> list[tuple[Library, list[tuple[Book, list[Reference]]]]]:
363        """
364        The default collation is by book; use collate_chapter_references
365        otherwise.
366        """
367        return self.collate_book_references(references)

The default collation is by book; use collate_chapter_references otherwise.

def collate_book_references( self, references: list[refspy.models.reference.Reference]) -> list[tuple[refspy.models.library.Library, list[tuple[refspy.models.book.Book, list[refspy.models.reference.Reference]]]]]:
369    def collate_book_references(
370        self, references: list[Reference]
371    ) -> list[tuple[Library, list[tuple[Book, list[Reference]]]]]:
372        """
373        Collate references into nested library and book dictionaries for easy
374        looping.
375
376        Example:
377            ```
378            for library, book_collation in __.collate_books_references(refs):
379                for book, references in book_collation:
380                    # ...
381            ```
382        """
383        library_list = list()
384        for library_id, books_dict in self.collate_by_book(references).items():
385            book_list = list()
386            for book_id, references in books_dict.items():
387                book_list.append(tuple([self.books[library_id, book_id], references]))
388            library_list.append(tuple([self.libraries[library_id], book_list]))
389        return library_list

Collate references into nested library and book dictionaries for easy looping.

Example:
for library, book_collation in __.collate_books_references(refs):
    for book, references in book_collation:
        # ...
def collate_by_book( self, references: list[refspy.models.reference.Reference]) -> dict[typing.Annotated[int, Ge(ge=1), Le(le=999)], dict[typing.Annotated[int, Ge(ge=1), Le(le=999)], list[refspy.models.reference.Reference]]]:
391    def collate_by_book(
392        self, references: list[Reference]
393    ) -> dict[Number, dict[Number, list[Reference]]]:
394        """
395        A collation groups single-book references by library and book IDs.
396        Multi-book references are ignored.
397        """
398        collation = dict()
399        for ref in [_ for _ in references if _.count_books() == 1]:
400            v1 = ref.ranges[0].start
401            if v1.library not in collation:
402                collation[v1.library] = dict()
403            if v1.book not in collation[v1.library]:
404                collation[v1.library][v1.book] = list()
405            collation[v1.library][v1.book].append(ref)
406        return collation

A collation groups single-book references by library and book IDs. Multi-book references are ignored.

def collate_chapter_references( self, references: list[refspy.models.reference.Reference]) -> list[tuple[refspy.models.library.Library, list[tuple[refspy.models.book.Book, list[tuple[typing.Annotated[int, Ge(ge=1), Le(le=999)], list[refspy.models.reference.Reference]]]]]]]:
408    def collate_chapter_references(
409        self, references: list[Reference]
410    ) -> list[tuple[Library, list[tuple[Book, list[tuple[Number, list[Reference]]]]]]]:
411        """
412        Collate references into nested library, book, and chapter dictionaries
413        for easy looping.
414
415        Example:
416            ```
417            for library, book_collation in __.collate_chapter_references(refs):
418                for book, chapter_collation in book_collation:
419                    for chapter_number, references in chapter_collation:
420                        # ...
421            ```
422        """
423        library_list = list()
424        for library_id, books_dict in self.collate_by_chapter(references).items():
425            book_list = list()
426            for book_id, chapters_dict in books_dict.items():
427                chapter_list = list()
428                for chapter_id, references in chapters_dict.items():
429                    chapter_list.append(tuple([chapter_id, references]))
430                book_list.append(tuple([self.books[library_id, book_id], chapter_list]))
431            library_list.append(tuple([self.libraries[library_id], book_list]))
432        return library_list

Collate references into nested library, book, and chapter dictionaries for easy looping.

Example:
for library, book_collation in __.collate_chapter_references(refs):
    for book, chapter_collation in book_collation:
        for chapter_number, references in chapter_collation:
            # ...
def collate_by_chapter( self, references: list[refspy.models.reference.Reference]) -> dict[typing.Annotated[int, Ge(ge=1), Le(le=999)], dict[typing.Annotated[int, Ge(ge=1), Le(le=999)], dict[typing.Annotated[int, Ge(ge=1), Le(le=999)], list[refspy.models.reference.Reference]]]]:
434    def collate_by_chapter(
435        self, references: list[Reference]
436    ) -> dict[Number, dict[Number, dict[Number, list[Reference]]]]:
437        """
438        A collation groups single-book references by library, book, and chapter
439        IDs. Multi-book references are ignored.
440        """
441        collation = dict()
442        for ref in [_ for _ in references if _.count_books() == 1]:
443            v1 = ref.ranges[0].start
444            if v1.library not in collation:
445                collation[v1.library] = dict()
446            if v1.book not in collation[v1.library]:
447                collation[v1.library][v1.book] = dict()
448            if v1.chapter not in collation[v1.library][v1.book]:
449                collation[v1.library][v1.book][v1.chapter] = list()
450            collation[v1.library][v1.book][v1.chapter].append(ref)
451        return collation

A collation groups single-book references by library, book, and chapter IDs. Multi-book references are ignored.

def collate_verse_references( self, references: list[refspy.models.reference.Reference]) -> list[tuple[refspy.models.library.Library, list[tuple[refspy.models.book.Book, list[tuple[typing.Annotated[int, Ge(ge=1), Le(le=999)], list[tuple[str, refspy.models.reference.Reference]]]]]]]]:
453    def collate_verse_references(
454        self, references: list[Reference]
455    ) -> list[
456        tuple[
457            Library, list[tuple[Book, list[tuple[Number, list[tuple[str, Reference]]]]]]
458        ]
459    ]:
460        """
461        Collate references into nested library, book, chapter and verse-range
462        dictionaries for easy looping.
463
464        Example:
465            ```
466            for library, book_collation in __.collate_verse_references(refs):
467                for book, chapter_collation in book_collation:
468                    for chapter_number, verse_collation in chapter_collation:
469                        for verse_numbers, references in verse_collation:
470                            # ...
471            ```
472
473        Note:
474            - verse_numbers is a unique string representing the verse ranges,
475              e.g. '1, 5-6, 13'
476        """
477        library_list = list()
478        for library_id, books_dict in self.collate_by_verse(references).items():
479            book_list = list()
480            for book_id, chapters_dict in books_dict.items():
481                chapter_list = list()
482                for chapter_id, verses_dict in chapters_dict.items():
483                    verse_list = list()
484                    for ref_numbers, references in verses_dict.items():
485                        verse_list.append(tuple([ref_numbers, references]))
486                    chapter_list.append(tuple([chapter_id, verse_list]))
487                book_list.append(tuple([self.books[library_id, book_id], chapter_list]))
488            library_list.append(tuple([self.libraries[library_id], book_list]))
489        return library_list

Collate references into nested library, book, chapter and verse-range dictionaries for easy looping.

Example:
for library, book_collation in __.collate_verse_references(refs):
    for book, chapter_collation in book_collation:
        for chapter_number, verse_collation in chapter_collation:
            for verse_numbers, references in verse_collation:
                # ...
Note:
  • verse_numbers is a unique string representing the verse ranges, e.g. '1, 5-6, 13'
def collate_by_verse( self, references: list[refspy.models.reference.Reference], aggregate_function=None) -> dict[typing.Annotated[int, Ge(ge=1), Le(le=999)], dict[typing.Annotated[int, Ge(ge=1), Le(le=999)], dict[typing.Annotated[int, Ge(ge=1), Le(le=999)], dict[str, list[refspy.models.reference.Reference]]]]]:
491    def collate_by_verse(
492        self, references: list[Reference], aggregate_function=None
493    ) -> dict[Number, dict[Number, dict[Number, dict[str, list[Reference]]]]]:
494        """
495        A collation groups single-book references by library, book, chapter
496        number, and a string reresenting the verse ranges. Multi-book
497        references are ignored.
498        """
499        collation = dict()
500        for ref in [_ for _ in references if _.count_books() == 1]:
501            v1 = ref.ranges[0].start
502            if v1.library not in collation:
503                collation[v1.library] = dict()
504            if v1.book not in collation[v1.library]:
505                collation[v1.library][v1.book] = dict()
506            if v1.chapter not in collation[v1.library][v1.book]:
507                collation[v1.library][v1.book][v1.chapter] = dict()
508            ref_numbers = self.numbers(ref)
509            if ref_numbers not in collation[v1.library][v1.book][v1.chapter]:
510                collation[v1.library][v1.book][v1.chapter][ref_numbers] = list()
511            collation[v1.library][v1.book][v1.chapter][ref_numbers].append(ref)
512        return collation

A collation groups single-book references by library, book, chapter number, and a string reresenting the verse ranges. Multi-book references are ignored.

def first_reference( self, text: str) -> tuple[str | None, refspy.models.reference.Reference | None]:
518    def first_reference(self, text: str) -> tuple[str | None, Reference | None]:
519        """
520        Return the first tuple of (match_str, reference) found by
521        `refspy.manager.Manager.generate_references()`
522        """
523        generator = self.matcher.generate_references(text)
524        return next(generator, (None, None))

Return the first tuple of (match_str, reference) found by refspy.manager.Manager.generate_references()

def find_references( self, text: str, include_books: bool = False, include_nones: bool = False, use_context: bool = True) -> list[tuple[str, refspy.models.reference.Reference | None]]:
526    def find_references(
527        self,
528        text: str,
529        include_books: bool = False,
530        include_nones: bool = False,
531        use_context: bool = True,
532    ) -> list[tuple[str, Reference | None]]:
533        """
534        Return a list of tuples of (match_str, reference) found by
535        `refspy.manager.Manager.generate_references()`
536        """
537        generator = self.matcher.generate_references(
538            text, include_books, include_nones, use_context
539        )
540        return list(generator)

Return a list of tuples of (match_str, reference) found by refspy.manager.Manager.generate_references()

def generate_references( self, text: str, yield_books: bool = False, yield_nones: bool = False, use_context: bool = True) -> Generator[tuple[str, refspy.models.reference.Reference | None], None, None]:
542    def generate_references(
543        self,
544        text: str,
545        yield_books: bool = False,
546        yield_nones: bool = False,
547        use_context: bool = True,
548    ) -> Generator[tuple[str, Reference | None], None, None]:
549        """
550        Generate tuples of (match_str, reference) for provided text.manager
551
552        Task delegated to `refspy.matcher.Matcher.generate_references()`.
553
554        Args:
555            text: a string to search for references
556            yield_books: Whether to yield book names without reference numbers
557            yield_nones: Whether to yield (match_str, None) for malformed
558                references.
559            use_context: Whether to yield anything other than exact book and
560                number matches
561
562        Yield:
563            A tuple of `(match_str, reference)` for each valid reference.
564        """
565        yield from self.matcher.generate_references(
566            text, yield_books, yield_nones, use_context
567        )

Generate tuples of (match_str, reference) for provided text.manager

Task delegated to refspy.matcher.Matcher.generate_references().

Arguments:
  • text: a string to search for references
  • yield_books: Whether to yield book names without reference numbers
  • yield_nones: Whether to yield (match_str, None) for malformed references.
  • use_context: Whether to yield anything other than exact book and number matches
Yield:

A tuple of (match_str, reference) for each valid reference.

def bcv( self, alias: str, c: Optional[Annotated[int, Ge(ge=1), Le(le=999)]] = None, v: Optional[Annotated[int, Ge(ge=1), Le(le=999)]] = None, v_end: Optional[Annotated[int, Ge(ge=1), Le(le=999)]] = None) -> refspy.models.reference.Reference:
573    def bcv(
574        self,
575        alias: str,
576        c: Number | None = None,
577        v: Number | None = None,
578        v_end: Number | None = None,
579    ) -> Reference:
580        """Construct a reference from a book alias and chapter/verse numbers.
581
582        Omitting optional arguments will construct a reference to a whole book
583        or chapter.
584
585        Args:
586            v_end: constructs a verse range from `v` to `v_end`.
587
588        Raises:
589            ValueError: If the book_alias is missing, or the number values are
590                out of range.
591        """
592        if alias not in self.book_aliases:
593            raise ValueError(f'Book alias "{alias}" not found.')
594        library_id, book_id = self.book_aliases[alias]
595        ta = TypeAdapter(Number)
596        if c is None:
597            return book_reference(library_id, book_id)
598        if v is None:
599            return chapter_reference(library_id, book_id, ta.validate_python(c))
600
601        if not v_end:
602            return verse_reference(
603                library_id,
604                book_id,
605                ta.validate_python(c),
606                ta.validate_python(v),
607            )
608        else:
609            return reference(
610                range(
611                    verse(
612                        library_id,
613                        book_id,
614                        ta.validate_python(c),
615                        ta.validate_python(v),
616                    ),
617                    verse(
618                        library_id,
619                        book_id,
620                        ta.validate_python(c),
621                        ta.validate_python(v_end),
622                    ),
623                )
624            )

Construct a reference from a book alias and chapter/verse numbers.

Omitting optional arguments will construct a reference to a whole book or chapter.

Arguments:
  • v_end: constructs a verse range from v to v_end.
Raises:
  • ValueError: If the book_alias is missing, or the number values are out of range.
def bcr( self, alias: str, c: Annotated[int, Ge(ge=1), Le(le=999)], v_ranges: list[typing.Union[typing.Annotated[int, Ge(ge=1), Le(le=999)], tuple[typing.Annotated[int, Ge(ge=1), Le(le=999)], typing.Annotated[int, Ge(ge=1), Le(le=999)]]]]) -> refspy.models.reference.Reference:
626    def bcr(
627        self, alias: str, c: Number, v_ranges: list[Number | tuple[Number, Number]]
628    ) -> Reference:
629        if alias not in self.book_aliases:
630            raise ValueError(f'Book alias "{alias}" not found.')
631        library_id, book_id = self.book_aliases[alias]
632        ranges = []
633        for val in v_ranges:
634            if isinstance(val, tuple):
635                v_start, v_end = val
636                ranges.append(
637                    range(
638                        verse(library_id, book_id, c, v_start),
639                        verse(library_id, book_id, c, v_end),
640                    )
641                )
642            elif isinstance(val, int):
643                v_start = v_end = val
644                ranges.append(
645                    range(
646                        verse(library_id, book_id, c, v_start),
647                        verse(library_id, book_id, c, v_end),
648                    )
649                )
650        return reference(*ranges)
def r(self, text: str) -> refspy.models.reference.Reference | None:
652    def r(self, text: str) -> Reference | None:
653        """Return the first matching reference in the given text.
654
655        Book names and malformed references are not matched.
656        """
657        _, reference = self.first_reference(text)
658        return reference

Return the first matching reference in the given text.

Book names and malformed references are not matched.

def next_chapter( self, ref: refspy.models.reference.Reference) -> refspy.models.reference.Reference | None:
664    def next_chapter(self, ref: Reference) -> Reference | None:
665        """Get the next chapter to the one containing this reference.
666
667        This loops over the end of books using `refspy.models.book.Book.chapters`.
668        This does not loop beyond the end of libraries.
669        """
670        return self.navigator.next_chapter(ref)

Get the next chapter to the one containing this reference.

This loops over the end of books using refspy.models.book.Book.chapters. This does not loop beyond the end of libraries.

def prev_chapter( self, ref: refspy.models.reference.Reference) -> refspy.models.reference.Reference | None:
672    def prev_chapter(self, ref: Reference) -> Reference | None:
673        """Get the previous chapter to the one containing this reference.
674
675        This loops over the end of books using `refspy.models.book.Book.chapters`, but
676        not beyond the end of libraries.
677        """
678        return self.navigator.prev_chapter(ref)

Get the previous chapter to the one containing this reference.

This loops over the end of books using refspy.models.book.Book.chapters, but not beyond the end of libraries.

def get_book(self, ref: refspy.models.reference.Reference) -> refspy.models.book.Book:
684    def get_book(self, ref: Reference) -> Book:
685        """Get the book object for this reference's first range."""
686        v1 = ref.ranges[0].start
687        return self.books[v1.library, v1.book]

Get the book object for this reference's first range.

def book_reference( self, ref: refspy.models.reference.Reference) -> refspy.models.reference.Reference:
689    def book_reference(self, ref: Reference) -> Reference:
690        """Create a reference to the book containing this reference's first
691        range."""
692        v1 = ref.ranges[0].start
693        return reference(
694            range(
695                verse(v1.library, v1.book, 1, 1),
696                verse(v1.library, v1.book, 999, 999),
697            )
698        )

Create a reference to the book containing this reference's first range.

def chapter_reference( self, ref: refspy.models.reference.Reference) -> refspy.models.reference.Reference:
700    def chapter_reference(self, ref: Reference) -> Reference:
701        """Create a reference to the chapter containing this reference's first
702        range."""
703        v1 = ref.ranges[0].start
704        return reference(
705            range(
706                verse(v1.library, v1.book, v1.chapter, 1),
707                verse(v1.library, v1.book, v1.chapter, 999),
708            )
709        )

Create a reference to the chapter containing this reference's first range.

def name(self, ref: refspy.models.reference.Reference) -> str:
719    def name(self, ref: Reference) -> str:
720        """Format a reference."""
721        return self.formatter.format(ref, self.formatter.name_format(self.language))

Format a reference.

def book(self, ref: refspy.models.reference.Reference) -> str:
723    def book(self, ref: Reference) -> str:
724        """Format a reference using only the book part of its name."""
725        return self.formatter.format(ref, self.formatter.book_format(self.language))

Format a reference using only the book part of its name.

def abbrev_name(self, ref: refspy.models.reference.Reference) -> str:
727    def abbrev_name(self, ref: Reference) -> str:
728        """Format an abbreviated reference."""
729        return self.formatter.format(
730            ref, self.formatter.abbrev_name_format(self.language)
731        )

Format an abbreviated reference.

def abbrev_book(self, ref: refspy.models.reference.Reference) -> str:
733    def abbrev_book(self, ref: Reference) -> str:
734        """Format an abbreviated reference using only the book part of its name."""
735        return self.formatter.format(
736            ref, self.formatter.abbrev_book_format(self.language)
737        )

Format an abbreviated reference using only the book part of its name.

def numbers(self, ref: refspy.models.reference.Reference) -> str:
739    def numbers(self, ref: Reference) -> str:
740        """Format a reference using only the number part of its name."""
741        return self.formatter.format(ref, self.formatter.number_format(self.language))

Format a reference using only the number part of its name.

def abbrev_numbers(self, ref: refspy.models.reference.Reference) -> str:
743    def abbrev_numbers(self, ref: Reference) -> str:
744        """Format an abbreviated reference using only the number part of its name."""
745        return self.formatter.format(
746            ref, self.formatter.abbrev_number_format(self.language)
747        )

Format an abbreviated reference using only the number part of its name.

def template( self, reference: refspy.models.reference.Reference | None, pattern: str | None = None) -> str:
753    def template(self, reference: Reference | None, pattern: str | None = None) -> str:
754        """
755        Substitute formatting values in a string:
756
757            * `{LINK}` -> "1%20Cor%202:3-4"
758                    - like ESC_ABBREV_NAME, but with English numbering in any language.
759                    - useful for BibleGateway and presumably other sites.
760            * `{NAME}` -> "1 Corinthians 2:3–4"
761            * `{BOOK}` -> "1 Corinthians"
762            * `{NUMBERS}` -> "2:3–4"
763            * `{ABBREV_NAME}` -> "1 Cor 2:3–4"
764            * `{ABBREV_BOOK}` -> "1 Cor"
765            * `{ABBREV_NUMBERS}` -> "2:3–4"
766            * `{ESC_NAME}` -> "1%20Corinthians%202%3A3-4"
767            * `{ESC_BOOK}` -> "1%20Corinthians"
768            * `{ESC_NUMBERS}` -> "2%3A3-4"
769            * `{ESC_ABBREV_NAME}` -> "1%20Cor%202%3A3-4"
770            * `{ESC_ABBREV_BOOK}` -> "1%20Cor"
771            * `{ESC_ABBREV_NUMBERS}` -> "2%3A3-4"
772            * `{PARAM_NAME}`-> "1cor+2.3-4"
773            * `{PARAM_BOOK}` -> "1cor"
774            * `{PARAM_NUMBERS}` -> "2.3-4"
775
776        For efficiency, we calculate only the values required by the template
777        string.
778        """
779        if reference is None:
780            return ""
781
782        out = pattern if pattern else "{ABBREV_NAME}"
783
784        regexp = re.compile(r"\{[A-Z_]+\}")
785        matches = regexp.findall(out)
786        numbers = self.numbers(reference)
787        for _ in matches:
788            if _ == "{LINK}":
789                out = out.replace("{LINK}", url_escape(self.link(reference)))
790            elif _ == "{NAME}":
791                out = out.replace("{NAME}", self.name(reference))
792            elif _ == "{BOOK}":
793                out = out.replace("{BOOK}", self.book(reference))
794            elif _ == "{NUMBERS}":
795                out = out.replace("{NUMBERS}", numbers)
796            elif _ == "{ASCII_NUMBERS}":
797                out = out.replace("{ASCII_NUMBERS}", numbers.replace("–", "-"))
798            elif _ == "{ABBREV_NAME}":
799                out = out.replace("{ABBREV_NAME}", self.abbrev_name(reference))
800            elif _ == "{ABBREV_BOOK}":
801                out = out.replace("{ABBREV_BOOK}", self.abbrev_name(reference))
802            elif _ == "{ESC_NAME}":
803                out = out.replace("{ESC_NAME}", url_escape(self.name(reference)))
804            elif _ == "{ESC_BOOK}":
805                out = out.replace("{ESC_BOOK}", url_escape(self.book(reference)))
806            elif _ == "{ESC_NUMBERS}":
807                out = out.replace("{ESC_NUMBERS}", url_escape(numbers))
808            elif _ == "{ESC_ASCII_NUMBERS}":
809                out = out.replace(
810                    "{ESC_ASCII_NUMBERS}",
811                    url_escape(numbers.replace("–", "-")),
812                )
813            elif _ == "{ESC_ABBREV_NAME}":
814                out = out.replace(
815                    "{ESC_ABBREV_NAME}", url_escape(self.abbrev_name(reference))
816                )
817            elif _ == "{ESC_ABBREV_BOOK}":
818                out = out.replace(
819                    "{ESC_ABBREV_BOOK}",
820                    url_escape(self.abbrev_book(reference)),
821                )
822            elif _ == "{PARAM_NAME}":
823                out = out.replace("{PARAM_NAME}", url_param(self.name(reference)))
824            elif _ == "{PARAM_BOOK}":
825                out = out.replace("{PARAM_BOOK}", url_param(numbers))
826            elif _ == "{PARAM_NUMBERS}":
827                out = out.replace("{PARAM_NUMBERS}", url_param(numbers))
828        return out
Substitute formatting values in a string:
  • {LINK} -> "1%20Cor%202:3-4"
    • like ESC_ABBREV_NAME, but with English numbering in any language.
    • useful for BibleGateway and presumably other sites.
  • {NAME} -> "1 Corinthians 2:3–4"
  • {BOOK} -> "1 Corinthians"
  • {NUMBERS} -> "2:3–4"
  • {ABBREV_NAME} -> "1 Cor 2:3–4"
  • {ABBREV_BOOK} -> "1 Cor"
  • {ABBREV_NUMBERS} -> "2:3–4"
  • {ESC_NAME} -> "1%20Corinthians%202%3A3-4"
  • {ESC_BOOK} -> "1%20Corinthians"
  • {ESC_NUMBERS} -> "2%3A3-4"
  • {ESC_ABBREV_NAME} -> "1%20Cor%202%3A3-4"
  • {ESC_ABBREV_BOOK} -> "1%20Cor"
  • {ESC_ABBREV_NUMBERS} -> "2%3A3-4"
  • {PARAM_NAME}-> "1cor+2.3-4"
  • {PARAM_BOOK} -> "1cor"
  • {PARAM_NUMBERS} -> "2.3-4"

For efficiency, we calculate only the values required by the template string.