refspy.utils

Provide general utility functions for the Refspy package.

  1"""
  2Provide general utility functions for the Refspy package.
  3"""
  4
  5import re
  6from typing import Any
  7
  8from refspy.constants import SPACE, NON_BREAKING_SPACE
  9from refspy.types.number import Number
 10
 11
 12def parse_number(number_str: str) -> Number:
 13    """Remove non-digits and return an integer IF it falls between 1 and 999
 14    inclusive.
 15
 16    Args:
 17        number_str: A string expected to contain an integer, among other
 18            characters.
 19
 20    Raises:
 21        ValueError: if no digits in 1..999 range.
 22
 23    Return:
 24        None if no digits appear in `number_str` or not in range.
 25    """
 26    digits = re.sub(r"\D", "", number_str)
 27    if digits != "":
 28        number = int(digits)
 29        if 1 <= number <= 999:
 30            return number
 31    raise ValueError(f"Invalid number string for parse_number: '{number_str}'")
 32
 33
 34def url_escape(name: str) -> str:
 35    """Turn a library or book name into a URL escaped string
 36
 37    Simple URL encode for significant characters:
 38
 39        * '1 Cor 3:4–5 -> '1%20Cor%203%3A4-5'
 40    """
 41    return (
 42        name.replace(SPACE, "%20")
 43        .replace(NON_BREAKING_SPACE, "%20")
 44        .replace(":", "%3A")
 45        .replace("–", "-")
 46    )
 47
 48
 49def url_param(name: str) -> str:
 50    """Turn a library or book name into a URL-friendly string.
 51
 52    Lowercase and no spaces: '1 Cor 3:4-5' -> '1+cor+3.4-5'
 53    """
 54    return name.lower().replace(" ", "+").replace(":", ".").replace("–", "-")
 55
 56
 57def strip_book_number(name: str) -> str:
 58    """Remove any digit and space from the start of a string.
 59
 60    e.g. '2 Tim' becomes 'Tim'.
 61    """
 62    return re.sub(r"^\d ", "", name)
 63
 64
 65def get_unnumbered_book_aliases(book_aliases: dict) -> set:
 66    unique = set()
 67    for alias in book_aliases.keys():
 68        unique.add(strip_book_number(alias))
 69    return unique
 70
 71
 72def strip_space_after_book_number(name: str) -> str:
 73    """Remove space between any leading digit and all subsequent text.
 74
 75    e.g. '2 Tim' becomes '2Tim'.
 76    """
 77    return re.sub(r"^(\d) (.*)$", r"\1\2", name)
 78
 79
 80def add_space_after_book_number(
 81    name: str, unnumbered_book_aliases: set, number_prefixes: dict[str, list[str]]
 82) -> str:
 83    """Remove space between any leading digit and all subsequent text.
 84
 85    - '2Tim' becomes '2 Tim'.
 86    - 'SecondTim' or '2ndTim' or 'IITim' becomes '2 Tim' (use number_prefixes).
 87
 88    Note: Must search in reverse order, so 'II' is checked before 'I'.
 89    """
 90    if " " in name:  # Assume already done
 91        return name
 92    for number, prefixes in sorted(number_prefixes.items(), reverse=True):
 93        for prefix in prefixes:
 94            head = name[: len(prefix)]
 95            tail = name[len(prefix) :]
 96            if head == prefix and tail in unnumbered_book_aliases:
 97                return number + " " + tail
 98    return re.sub(r"^(\d)([A-Z])([a-z].*)$", r"\1 \2\3", name)
 99
100
101def trim_trailing_period(text: str) -> str:
102    """Remove any tailing '.' character."""
103    return text.rstrip(".")
104
105
106def normalize_spacing(text: str) -> str:
107    """Replace multiple spaces with single spaces.
108
109    Args:
110        text: A string expected to have irregular spacing.
111    """
112    return re.sub("\\s+", " ", text)
113
114
115def pluralize(number: int, singular: str, plural: str = "") -> str:
116    """
117    Super simple pluralization.
118    """
119    if number == 1:
120        return "%d %s" % (number, singular)
121    else:
122        if plural != "":
123            return "%d %s" % (number, plural)
124        else:
125            return "%d %ss" % (number, singular)
126
127
128def sequential_replace_tuples(text: str, tuples: list[tuple[str, str]]):
129    """Replace values, searching from the end of each previous replacement.
130
131    This is for replacing matches, and ensures they are replaced in the
132    same order they are matched.
133
134    This function takes find-replace strings as a list of tuples. See
135    `refspy.utils.sequential_replace`.
136
137    Args:
138        text: A string in which matches should be replaces
139        tuples: A list of find-replace tuples, in order of appearance.
140    """
141    new_text = ""
142    cursor = 0
143    for find, replace in tuples:
144        if find and replace:
145            next_match = text.find(find, cursor)
146            if next_match != -1:
147                new_text += text[cursor:next_match]
148                new_text += replace
149                cursor = next_match + len(find)
150    new_text += text[cursor:]
151    return new_text
152
153
154def sequential_replace(text: str, find: list[str], replace: list[str]) -> str:
155    """Replace values, searching from the end of each previous replacement.
156
157    This function takes find-replace strings as two separate lists. Unmatched
158    entries will be ignored by zip(). See
159    `refspy.utils.sequential_replace_tuples`.
160
161    Args:
162        text: A string in which matches should be replaces
163        find: A list of values to be found, in order of appearance.
164        replace: A list of replacement values.
165    """
166    return sequential_replace_tuples(text, list(zip(find, replace)))
167
168
169def string_together(*args: Any) -> str:
170    """Convert objects to strings and concatenate.
171
172    Args:
173        *args: A list of string-convertable values.
174    """
175    return "".join([str(arg) for arg in args])
def parse_number(number_str: str) -> Annotated[int, Ge(ge=1), Le(le=999)]:
13def parse_number(number_str: str) -> Number:
14    """Remove non-digits and return an integer IF it falls between 1 and 999
15    inclusive.
16
17    Args:
18        number_str: A string expected to contain an integer, among other
19            characters.
20
21    Raises:
22        ValueError: if no digits in 1..999 range.
23
24    Return:
25        None if no digits appear in `number_str` or not in range.
26    """
27    digits = re.sub(r"\D", "", number_str)
28    if digits != "":
29        number = int(digits)
30        if 1 <= number <= 999:
31            return number
32    raise ValueError(f"Invalid number string for parse_number: '{number_str}'")

Remove non-digits and return an integer IF it falls between 1 and 999 inclusive.

Arguments:
  • number_str: A string expected to contain an integer, among other characters.
Raises:
  • ValueError: if no digits in 1..999 range.
Return:

None if no digits appear in number_str or not in range.

def url_escape(name: str) -> str:
35def url_escape(name: str) -> str:
36    """Turn a library or book name into a URL escaped string
37
38    Simple URL encode for significant characters:
39
40        * '1 Cor 3:4–5 -> '1%20Cor%203%3A4-5'
41    """
42    return (
43        name.replace(SPACE, "%20")
44        .replace(NON_BREAKING_SPACE, "%20")
45        .replace(":", "%3A")
46        .replace("–", "-")
47    )

Turn a library or book name into a URL escaped string

Simple URL encode for significant characters:
  • '1 Cor 3:4–5 -> '1%20Cor%203%3A4-5'
def url_param(name: str) -> str:
50def url_param(name: str) -> str:
51    """Turn a library or book name into a URL-friendly string.
52
53    Lowercase and no spaces: '1 Cor 3:4-5' -> '1+cor+3.4-5'
54    """
55    return name.lower().replace(" ", "+").replace(":", ".").replace("–", "-")

Turn a library or book name into a URL-friendly string.

Lowercase and no spaces: '1 Cor 3:4-5' -> '1+cor+3.4-5'

def strip_book_number(name: str) -> str:
58def strip_book_number(name: str) -> str:
59    """Remove any digit and space from the start of a string.
60
61    e.g. '2 Tim' becomes 'Tim'.
62    """
63    return re.sub(r"^\d ", "", name)

Remove any digit and space from the start of a string.

e.g. '2 Tim' becomes 'Tim'.

def get_unnumbered_book_aliases(book_aliases: dict) -> set:
66def get_unnumbered_book_aliases(book_aliases: dict) -> set:
67    unique = set()
68    for alias in book_aliases.keys():
69        unique.add(strip_book_number(alias))
70    return unique
def strip_space_after_book_number(name: str) -> str:
73def strip_space_after_book_number(name: str) -> str:
74    """Remove space between any leading digit and all subsequent text.
75
76    e.g. '2 Tim' becomes '2Tim'.
77    """
78    return re.sub(r"^(\d) (.*)$", r"\1\2", name)

Remove space between any leading digit and all subsequent text.

e.g. '2 Tim' becomes '2Tim'.

def add_space_after_book_number( name: str, unnumbered_book_aliases: set, number_prefixes: dict[str, list[str]]) -> str:
81def add_space_after_book_number(
82    name: str, unnumbered_book_aliases: set, number_prefixes: dict[str, list[str]]
83) -> str:
84    """Remove space between any leading digit and all subsequent text.
85
86    - '2Tim' becomes '2 Tim'.
87    - 'SecondTim' or '2ndTim' or 'IITim' becomes '2 Tim' (use number_prefixes).
88
89    Note: Must search in reverse order, so 'II' is checked before 'I'.
90    """
91    if " " in name:  # Assume already done
92        return name
93    for number, prefixes in sorted(number_prefixes.items(), reverse=True):
94        for prefix in prefixes:
95            head = name[: len(prefix)]
96            tail = name[len(prefix) :]
97            if head == prefix and tail in unnumbered_book_aliases:
98                return number + " " + tail
99    return re.sub(r"^(\d)([A-Z])([a-z].*)$", r"\1 \2\3", name)

Remove space between any leading digit and all subsequent text.

  • '2Tim' becomes '2 Tim'.
  • 'SecondTim' or '2ndTim' or 'IITim' becomes '2 Tim' (use number_prefixes).

Note: Must search in reverse order, so 'II' is checked before 'I'.

def trim_trailing_period(text: str) -> str:
102def trim_trailing_period(text: str) -> str:
103    """Remove any tailing '.' character."""
104    return text.rstrip(".")

Remove any tailing '.' character.

def normalize_spacing(text: str) -> str:
107def normalize_spacing(text: str) -> str:
108    """Replace multiple spaces with single spaces.
109
110    Args:
111        text: A string expected to have irregular spacing.
112    """
113    return re.sub("\\s+", " ", text)

Replace multiple spaces with single spaces.

Arguments:
  • text: A string expected to have irregular spacing.
def pluralize(number: int, singular: str, plural: str = '') -> str:
116def pluralize(number: int, singular: str, plural: str = "") -> str:
117    """
118    Super simple pluralization.
119    """
120    if number == 1:
121        return "%d %s" % (number, singular)
122    else:
123        if plural != "":
124            return "%d %s" % (number, plural)
125        else:
126            return "%d %ss" % (number, singular)

Super simple pluralization.

def sequential_replace_tuples(text: str, tuples: list[tuple[str, str]]):
129def sequential_replace_tuples(text: str, tuples: list[tuple[str, str]]):
130    """Replace values, searching from the end of each previous replacement.
131
132    This is for replacing matches, and ensures they are replaced in the
133    same order they are matched.
134
135    This function takes find-replace strings as a list of tuples. See
136    `refspy.utils.sequential_replace`.
137
138    Args:
139        text: A string in which matches should be replaces
140        tuples: A list of find-replace tuples, in order of appearance.
141    """
142    new_text = ""
143    cursor = 0
144    for find, replace in tuples:
145        if find and replace:
146            next_match = text.find(find, cursor)
147            if next_match != -1:
148                new_text += text[cursor:next_match]
149                new_text += replace
150                cursor = next_match + len(find)
151    new_text += text[cursor:]
152    return new_text

Replace values, searching from the end of each previous replacement.

This is for replacing matches, and ensures they are replaced in the same order they are matched.

This function takes find-replace strings as a list of tuples. See refspy.utils.sequential_replace.

Arguments:
  • text: A string in which matches should be replaces
  • tuples: A list of find-replace tuples, in order of appearance.
def sequential_replace(text: str, find: list[str], replace: list[str]) -> str:
155def sequential_replace(text: str, find: list[str], replace: list[str]) -> str:
156    """Replace values, searching from the end of each previous replacement.
157
158    This function takes find-replace strings as two separate lists. Unmatched
159    entries will be ignored by zip(). See
160    `refspy.utils.sequential_replace_tuples`.
161
162    Args:
163        text: A string in which matches should be replaces
164        find: A list of values to be found, in order of appearance.
165        replace: A list of replacement values.
166    """
167    return sequential_replace_tuples(text, list(zip(find, replace)))

Replace values, searching from the end of each previous replacement.

This function takes find-replace strings as two separate lists. Unmatched entries will be ignored by zip(). See refspy.utils.sequential_replace_tuples.

Arguments:
  • text: A string in which matches should be replaces
  • find: A list of values to be found, in order of appearance.
  • replace: A list of replacement values.
def string_together(*args: Any) -> str:
170def string_together(*args: Any) -> str:
171    """Convert objects to strings and concatenate.
172
173    Args:
174        *args: A list of string-convertable values.
175    """
176    return "".join([str(arg) for arg in args])

Convert objects to strings and concatenate.

Arguments:
  • *args: A list of string-convertable values.