wordsnum 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
wordsnum/__init__.py ADDED
@@ -0,0 +1,31 @@
1
+ """Parse spelled-out English numbers into int or float, fully typed and dependency-free.
2
+
3
+ Start with ``words_to_num`` for a quick ``int``/``float``, ``parse`` for the exact value
4
+ and kind, ``find_numbers`` to pull numbers out of free text, and ``num_to_words`` to
5
+ spell an integer back out.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from ._errors import MisplacedWordError, UnknownWordError, WordsNumError
11
+ from ._find import find_numbers
12
+ from ._forms import Kind
13
+ from ._parser import parse, words_to_decimal, words_to_fraction, words_to_num
14
+ from ._result import ParsedNumber
15
+ from ._spell import num_to_words
16
+
17
+ __version__ = "0.2.0"
18
+
19
+ __all__ = [
20
+ "Kind",
21
+ "MisplacedWordError",
22
+ "ParsedNumber",
23
+ "UnknownWordError",
24
+ "WordsNumError",
25
+ "find_numbers",
26
+ "num_to_words",
27
+ "parse",
28
+ "words_to_decimal",
29
+ "words_to_fraction",
30
+ "words_to_num",
31
+ ]
wordsnum/_cardinal.py ADDED
@@ -0,0 +1,162 @@
1
+ """Grammar for cardinal numbers: groups below a thousand joined by decreasing scales."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from dataclasses import dataclass, field
7
+ from fractions import Fraction
8
+ from typing import NoReturn
9
+
10
+ from ._errors import MisplacedWordError
11
+ from ._tokens import Token
12
+ from ._vocab import AND, ARTICLES, HUNDRED, SCALES, TENS, UNITS
13
+
14
+
15
+ def fail(token: Token, reason: str) -> NoReturn:
16
+ """Raise a ``MisplacedWordError`` that points at ``token``."""
17
+ raise MisplacedWordError(
18
+ f"{reason}: {token.raw!r} at position {token.start}",
19
+ word=token.raw,
20
+ start=token.start,
21
+ end=token.end,
22
+ )
23
+
24
+
25
+ @dataclass
26
+ class _Reader:
27
+ tokens: Sequence[Token]
28
+ index: int = 0
29
+ decimal: bool = field(default=False)
30
+
31
+ def peek(self) -> Token | None:
32
+ return self.tokens[self.index] if self.index < len(self.tokens) else None
33
+
34
+ def word(self) -> str | None:
35
+ token = self.peek()
36
+ return None if token is None else token.word
37
+
38
+ def take(self) -> Token:
39
+ token = self.tokens[self.index]
40
+ self.index += 1
41
+ return token
42
+
43
+
44
+ def parse_cardinal(tokens: Sequence[Token]) -> tuple[Fraction, bool]:
45
+ """Parse a whole cardinal. Returns the value and whether a decimal digit group was used.
46
+
47
+ ``tokens`` must be non-empty and must not hold a sign word.
48
+ """
49
+ if len(tokens) == 1 and tokens[0].word == "zero":
50
+ return Fraction(0), False
51
+ reader = _Reader(tokens)
52
+ total = Fraction(0)
53
+ last_scale = 0
54
+ while True:
55
+ group_token = reader.peek()
56
+ assert group_token is not None
57
+ value, wide = _group(reader, first=last_scale == 0)
58
+ if last_scale and value >= 1000:
59
+ fail(group_token, "a group after a scale word must be below a thousand")
60
+ scale_token = reader.peek()
61
+ if scale_token is None:
62
+ if wide and last_scale:
63
+ fail(group_token, "a teen or tens hundred cannot follow a scale word")
64
+ return total + value, reader.decimal
65
+ if scale_token.word not in SCALES:
66
+ fail(scale_token, "unexpected word")
67
+ scale = SCALES[scale_token.word]
68
+ if last_scale and scale >= last_scale:
69
+ fail(scale_token, "scale words must get smaller from left to right")
70
+ if wide:
71
+ fail(scale_token, "a teen or tens hundred cannot take a scale word")
72
+ if value == 0:
73
+ fail(scale_token, "a scale word needs a non-zero number before it")
74
+ reader.take()
75
+ total += value * scale
76
+ last_scale = scale
77
+ if reader.peek() is None:
78
+ return total, reader.decimal
79
+ if reader.word() == AND:
80
+ and_token = reader.take()
81
+ target = reader.peek() or and_token
82
+ rest = _below_hundred(reader)
83
+ if not rest:
84
+ fail(target, "'and' after a scale word needs 1 to 99")
85
+ if (extra := reader.peek()) is not None:
86
+ fail(extra, "nothing may follow the number after 'and'")
87
+ return total + rest, reader.decimal
88
+
89
+
90
+ def _group(reader: _Reader, *, first: bool) -> tuple[Fraction, bool]:
91
+ """Read one group. Returns its value and whether it is a wide ``nineteen hundred``."""
92
+ token = reader.peek()
93
+ assert token is not None
94
+ if token.word in SCALES and first:
95
+ return Fraction(1), False
96
+ if token.word in ARTICLES:
97
+ reader.take()
98
+ following = reader.word()
99
+ if following in SCALES:
100
+ return Fraction(1), False
101
+ if following != HUNDRED:
102
+ fail(token, "an article must be followed by 'hundred' or a scale word")
103
+ return _hundreds(reader, Fraction(1)), False
104
+ if token.word == HUNDRED and first:
105
+ return _hundreds(reader, Fraction(1)), False
106
+ number = token.number
107
+ if number is not None:
108
+ reader.take()
109
+ if "." in token.word:
110
+ if not first:
111
+ fail(token, "digits with a decimal point must start the number")
112
+ reader.decimal = True
113
+ after = reader.peek()
114
+ if after is not None and after.word not in SCALES:
115
+ fail(after, "digits with a decimal point can only take a scale word")
116
+ return number, False
117
+ if reader.word() != HUNDRED:
118
+ return number, False
119
+ prefix = number
120
+ else:
121
+ below = _below_hundred(reader)
122
+ if below is None:
123
+ fail(token, "unexpected word")
124
+ if below == 0:
125
+ fail(token, "'zero' must stand alone")
126
+ prefix = Fraction(below)
127
+ if reader.word() != HUNDRED:
128
+ return prefix, False
129
+ if not 1 <= prefix <= 99:
130
+ fail(token, "'hundred' needs a number from 1 to 99 before it")
131
+ return _hundreds(reader, prefix), prefix >= 10
132
+
133
+
134
+ def _hundreds(reader: _Reader, prefix: Fraction) -> Fraction:
135
+ reader.take()
136
+ value = prefix * 100
137
+ if reader.word() == AND:
138
+ and_token = reader.take()
139
+ target = reader.peek() or and_token
140
+ rest = _below_hundred(reader)
141
+ if not rest:
142
+ fail(target, "'and' after 'hundred' needs 1 to 99")
143
+ return value + rest
144
+ rest = _below_hundred(reader)
145
+ if rest == 0:
146
+ fail(reader.tokens[reader.index - 1], "'zero' must stand alone")
147
+ return value + (rest or 0)
148
+
149
+
150
+ def _below_hundred(reader: _Reader) -> int | None:
151
+ word = reader.word()
152
+ if word in UNITS:
153
+ reader.take()
154
+ return UNITS[word]
155
+ if word in TENS:
156
+ reader.take()
157
+ unit = reader.word()
158
+ if unit in UNITS and 1 <= UNITS[unit] <= 9:
159
+ reader.take()
160
+ return TENS[word] + UNITS[unit]
161
+ return TENS[word]
162
+ return None
wordsnum/_cli.py ADDED
@@ -0,0 +1,140 @@
1
+ """Command line interface: ``wordsnum parse``, ``wordsnum find`` and ``wordsnum say``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from collections.abc import Sequence
9
+ from typing import NoReturn
10
+
11
+ from . import __version__
12
+ from ._errors import WordsNumError
13
+ from ._find import find_numbers
14
+ from ._parser import parse
15
+ from ._result import ParsedNumber
16
+ from ._spell import num_to_words
17
+
18
+ _EXAMPLES = """\
19
+ examples:
20
+ wordsnum parse two thousand and twenty-one -> 2021
21
+ wordsnum parse three quarters --exact -> 3/4
22
+ wordsnum parse "1.5 million" --json
23
+ echo "minus forty two" | wordsnum parse -> -42 (one number per line)
24
+ echo "I owe you twenty-one pounds" | wordsnum find
25
+ wordsnum say 1234 --british -> one thousand two hundred and thirty-four
26
+ wordsnum say 21 --ordinal -> twenty-first
27
+
28
+ Errors go to stderr and the exit status is 1.
29
+ """
30
+
31
+
32
+ class _Parser(argparse.ArgumentParser):
33
+ """An argument parser whose usage errors exit with status 1, like every other error."""
34
+
35
+ def error(self, message: str) -> NoReturn:
36
+ self.print_usage(sys.stderr)
37
+ self.exit(1, f"{self.prog}: error: {message}\n")
38
+
39
+
40
+ def _build_parser() -> argparse.ArgumentParser:
41
+ parser = _Parser(
42
+ prog="wordsnum",
43
+ description="Parse spelled-out English numbers, find them in text, or spell integers.",
44
+ epilog=_EXAMPLES,
45
+ formatter_class=argparse.RawDescriptionHelpFormatter,
46
+ )
47
+ parser.add_argument("--version", action="version", version=f"wordsnum {__version__}")
48
+ commands = parser.add_subparsers(dest="command", required=True, metavar="COMMAND")
49
+
50
+ parse_cmd = commands.add_parser(
51
+ "parse", help="parse one number (words as arguments, or one per stdin line)"
52
+ )
53
+ parse_cmd.add_argument("words", nargs="*", help="the number words; read stdin when omitted")
54
+ parse_cmd.add_argument("--strict", action="store_true", help="accept only canonical spelling")
55
+ _add_output_flags(parse_cmd)
56
+
57
+ find_cmd = commands.add_parser("find", help="list every number in text read from stdin")
58
+ find_cmd.add_argument("--text", help="search this text instead of stdin")
59
+ _add_output_flags(find_cmd)
60
+
61
+ say_cmd = commands.add_parser("say", help="spell an integer in words")
62
+ say_cmd.add_argument("number", help="the integer to spell, such as 1234 or -7")
63
+ say_cmd.add_argument("--ordinal", action="store_true", help="spell the ordinal form")
64
+ say_cmd.add_argument("--british", action="store_true", help="use British 'and'")
65
+ return parser
66
+
67
+
68
+ def _add_output_flags(command: argparse.ArgumentParser) -> None:
69
+ command.add_argument(
70
+ "--exact", action="store_true", help="print exact values (3/4, 3.14) instead of floats"
71
+ )
72
+ command.add_argument("--json", action="store_true", help="print JSON objects")
73
+
74
+
75
+ def _value(match: ParsedNumber, *, exact: bool) -> str:
76
+ if not exact or match.kind in ("cardinal", "ordinal"):
77
+ return str(match.number)
78
+ if match.kind == "decimal":
79
+ return str(match.to_decimal())
80
+ return str(match.value)
81
+
82
+
83
+ def _record(match: ParsedNumber, *, exact: bool) -> dict[str, object]:
84
+ return {
85
+ "value": match.number,
86
+ "exact": _value(match, exact=True),
87
+ "kind": match.kind,
88
+ "text": match.text,
89
+ "start": match.start,
90
+ "end": match.end,
91
+ }
92
+
93
+
94
+ def _run_parse(args: argparse.Namespace) -> None:
95
+ if args.words:
96
+ lines = [" ".join(args.words)]
97
+ else:
98
+ lines = [line for line in sys.stdin.read().splitlines() if line.strip()]
99
+ if not lines:
100
+ raise WordsNumError("no input: pass number words or pipe them on stdin")
101
+ for line in lines:
102
+ match = parse(line, strict=args.strict)
103
+ if args.json:
104
+ print(json.dumps(_record(match, exact=args.exact)))
105
+ else:
106
+ print(_value(match, exact=args.exact))
107
+
108
+
109
+ def _run_find(args: argparse.Namespace) -> None:
110
+ text = args.text if args.text is not None else sys.stdin.read()
111
+ matches = find_numbers(text)
112
+ if args.json:
113
+ print(json.dumps([_record(match, exact=args.exact) for match in matches]))
114
+ return
115
+ for match in matches:
116
+ print(f"{match.start}\t{match.end}\t{_value(match, exact=args.exact)}\t{match.text}")
117
+
118
+
119
+ def _run_say(args: argparse.Namespace) -> None:
120
+ try:
121
+ number = int(args.number.replace(",", "").replace("_", ""))
122
+ except ValueError:
123
+ raise ValueError(f"not an integer: {args.number!r}") from None
124
+ print(num_to_words(number, ordinal=args.ordinal, british=args.british))
125
+
126
+
127
+ def main(argv: Sequence[str] | None = None) -> int:
128
+ """Run the command line tool and return its exit status (0 on success, 1 on error)."""
129
+ args = _build_parser().parse_args(argv)
130
+ handlers = {"parse": _run_parse, "find": _run_find, "say": _run_say}
131
+ try:
132
+ handlers[args.command](args)
133
+ except (ValueError, TypeError) as error:
134
+ print(f"wordsnum: error: {error}", file=sys.stderr)
135
+ return 1
136
+ return 0
137
+
138
+
139
+ if __name__ == "__main__":
140
+ sys.exit(main())
wordsnum/_errors.py ADDED
@@ -0,0 +1,42 @@
1
+ """Exceptions raised when text is not a valid spelled-out number."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class WordsNumError(ValueError):
7
+ """Raised when the text is not a valid spelled-out number.
8
+
9
+ It subclasses ``ValueError``, so ``except ValueError`` keeps working. When the
10
+ problem can be pinned to one word, ``word`` holds that word as written and
11
+ ``start``/``end`` are its offsets in the original text, so that
12
+ ``text[error.start:error.end] == error.word``. Otherwise all three are ``None``
13
+ (for example on empty input).
14
+ """
15
+
16
+ def __init__(
17
+ self,
18
+ message: str,
19
+ *,
20
+ word: str | None = None,
21
+ start: int | None = None,
22
+ end: int | None = None,
23
+ ) -> None:
24
+ super().__init__(message)
25
+ self.word = word
26
+ self.start = start
27
+ self.end = end
28
+
29
+
30
+ class UnknownWordError(WordsNumError):
31
+ """Raised for a word that is not an English number word at all, such as ``"banana"``.
32
+
33
+ Misspellings are never guessed: ``"fourty"`` raises this error too.
34
+ """
35
+
36
+
37
+ class MisplacedWordError(WordsNumError):
38
+ """Raised for a number word in a position the grammar does not allow.
39
+
40
+ Examples: ``"one two"``, ``"thousand million"``, ``"minus"`` with nothing after it,
41
+ or ``"one thirds"`` (a numerator of one takes a singular denominator).
42
+ """
wordsnum/_find.py ADDED
@@ -0,0 +1,108 @@
1
+ """Find every spelled-out number inside free text."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+
7
+ from ._errors import WordsNumError
8
+ from ._forms import parse_tokens
9
+ from ._result import ParsedNumber
10
+ from ._tokens import Token, gap_ok, scan
11
+ from ._vocab import COMPOUND_PLURALS, DENOMINATORS, ORDINALS, PLURAL_DENOMINATORS
12
+
13
+ # No real number phrase is longer than this; the cap bounds the work on hostile input.
14
+ _MAX_TOKENS = 160
15
+
16
+
17
+ def find_numbers(text: str) -> list[ParsedNumber]:
18
+ """Return every number written in ``text``, in order, as ``ParsedNumber`` objects.
19
+
20
+ Number words, digit groups (``"3"``, ``"2,500"``, ``"1.5"``, ``"21st"``) and their
21
+ mixtures (``"3 million"``) are recognised, case-insensitively, when they are
22
+ separated only by whitespace, hyphens or commas. Each match is the longest valid
23
+ reading starting at its first word, so ``"one, two, three"`` gives three numbers
24
+ while ``"one thousand, two hundred"`` gives one. ``start`` and ``end`` are offsets
25
+ into ``text``.
26
+
27
+ Everyday words that are also numbers are reported too: ``"no one"`` yields 1 and
28
+ ``"just a second"`` yields the ordinal 2. Filter on ``kind`` or context if needed.
29
+
30
+ >>> [match.number for match in find_numbers("I paid twenty-one pounds for 3 pies")]
31
+ [21, 3]
32
+ """
33
+ found: list[ParsedNumber] = []
34
+ for run in _runs(text, scan(text)):
35
+ found.extend(_matches(text, run))
36
+ return found
37
+
38
+
39
+ def _runs(text: str, tokens: Sequence[Token]) -> list[list[Token]]:
40
+ """Group known tokens that are separated only by lenient separators."""
41
+ runs: list[list[Token]] = []
42
+ current: list[Token] = []
43
+ for token in tokens:
44
+ if not token.known:
45
+ if current:
46
+ runs.append(current)
47
+ current = []
48
+ continue
49
+ if current and not gap_ok(text, current[-1], token):
50
+ runs.append(current)
51
+ current = []
52
+ current.append(token)
53
+ if current:
54
+ runs.append(current)
55
+ return runs
56
+
57
+
58
+ def _matches(text: str, run: Sequence[Token]) -> list[ParsedNumber]:
59
+ found: list[ParsedNumber] = []
60
+ start = 0
61
+ while start < len(run):
62
+ end = _window_end(run, start)
63
+ while end > start:
64
+ try:
65
+ value, kind = parse_tokens(run[start:end])
66
+ except WordsNumError as error:
67
+ end = _shorter(run, start, end, error)
68
+ continue
69
+ first, last = run[start], run[end - 1]
70
+ found.append(
71
+ ParsedNumber(value, kind, text[first.start : last.end], first.start, last.end)
72
+ )
73
+ break
74
+ start = max(end, start + 1)
75
+ return found
76
+
77
+
78
+ def _shorter(run: Sequence[Token], start: int, end: int, error: WordsNumError) -> int:
79
+ """Pick the next, shorter candidate end after a failed parse.
80
+
81
+ Words after the offending one are dropped at once. The offending word itself is
82
+ kept for one more try, because as the last word it may read differently.
83
+ """
84
+ for index in range(start, end - 1):
85
+ if run[index].start == error.start:
86
+ return index + 1
87
+ return end - 1
88
+
89
+
90
+ def _window_end(run: Sequence[Token], start: int) -> int:
91
+ """The furthest candidate end from ``start``.
92
+
93
+ Ordinals, denominators and digit ordinals can only be the last word of a number,
94
+ so a candidate never extends past the first of them. This keeps the scan linear
95
+ even on long runs of such words.
96
+ """
97
+ limit = min(len(run), start + _MAX_TOKENS)
98
+ for index in range(start, limit):
99
+ if _decides_form(run[index]):
100
+ return index + 1
101
+ return limit
102
+
103
+
104
+ def _decides_form(token: Token) -> bool:
105
+ word = token.word
106
+ if word in ORDINALS or word in DENOMINATORS or word in PLURAL_DENOMINATORS:
107
+ return True
108
+ return word in COMPOUND_PLURALS or token.digit_ordinal is not None
wordsnum/_forms.py ADDED
@@ -0,0 +1,181 @@
1
+ """Top-level number forms: signs, decimals, fractions, ordinals and plain cardinals."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from dataclasses import replace
7
+ from fractions import Fraction
8
+ from typing import Literal
9
+
10
+ from ._cardinal import fail, parse_cardinal
11
+ from ._errors import WordsNumError
12
+ from ._tokens import Token
13
+ from ._vocab import (
14
+ AND,
15
+ ARTICLES,
16
+ COMPOUND_PLURALS,
17
+ DENOMINATORS,
18
+ HUNDRED,
19
+ ORDINALS,
20
+ PLURAL_DENOMINATORS,
21
+ POINT,
22
+ POINT_DIGITS,
23
+ SCALES,
24
+ SIGNS,
25
+ TENS,
26
+ UNITS,
27
+ )
28
+
29
+ Kind = Literal["cardinal", "ordinal", "decimal", "fraction"]
30
+ _SCALE_ORDINALS = frozenset(word for word, base in ORDINALS.items() if base in SCALES)
31
+ _SCALE_ORDINALS |= {"hundredth"}
32
+
33
+
34
+ def parse_tokens(tokens: Sequence[Token]) -> tuple[Fraction, Kind]:
35
+ """Parse one complete number from a non-empty token list."""
36
+ sign = 1
37
+ if tokens[0].word.startswith("-"):
38
+ first = tokens[0]
39
+ unsigned = replace(first, word=first.word[1:], raw=first.raw[1:], start=first.start + 1)
40
+ sign, tokens = -1, [unsigned, *tokens[1:]]
41
+ elif tokens[0].word in SIGNS:
42
+ if len(tokens) == 1:
43
+ fail(tokens[0], "a sign word needs a number after it")
44
+ sign, tokens = -1, tokens[1:]
45
+ for token in tokens:
46
+ if token.word in SIGNS or token.word.startswith("-"):
47
+ fail(token, "a sign can only come first")
48
+ value, kind = _unsigned(tokens)
49
+ if sign < 0 and kind == "ordinal":
50
+ fail(tokens[0], "an ordinal cannot be negative")
51
+ return sign * value, kind
52
+
53
+
54
+ def _unsigned(tokens: Sequence[Token]) -> tuple[Fraction, Kind]:
55
+ if any(token.word == POINT for token in tokens):
56
+ return _decimal(tokens), "decimal"
57
+ last = tokens[-1]
58
+ if last.digit_ordinal is not None:
59
+ if len(tokens) > 1:
60
+ fail(last, "a digit ordinal must stand alone")
61
+ return Fraction(last.digit_ordinal), "ordinal"
62
+ fraction = _fraction(tokens)
63
+ if fraction is not None:
64
+ return fraction, "fraction"
65
+ if last.word in ORDINALS:
66
+ cardinal = replace(last, word=ORDINALS[last.word])
67
+ value, decimal = parse_cardinal([*tokens[:-1], cardinal])
68
+ if decimal:
69
+ fail(last, "an ordinal cannot have a decimal part")
70
+ return value, "ordinal"
71
+ value, decimal = parse_cardinal(tokens)
72
+ return value, "decimal" if decimal else "cardinal"
73
+
74
+
75
+ def _decimal(tokens: Sequence[Token]) -> Fraction:
76
+ point = next(index for index, token in enumerate(tokens) if token.word == POINT)
77
+ whole_tokens, rest = tokens[:point], tokens[point + 1 :]
78
+ digits = ""
79
+ while len(digits) < len(rest) and rest[len(digits)].word in POINT_DIGITS:
80
+ digits += str(POINT_DIGITS[rest[len(digits)].word])
81
+ if not digits:
82
+ fail(rest[0] if rest else tokens[point], "expected a digit word after 'point'")
83
+ tail = rest[len(digits) :]
84
+ scale = 1
85
+ if tail:
86
+ scale_word = tail[0].word
87
+ if scale_word not in SCALES and scale_word != HUNDRED:
88
+ fail(tail[0], "only one-digit words or a final scale word may follow 'point'")
89
+ if len(tail) > 1:
90
+ fail(tail[1], "nothing may follow the scale word of a decimal")
91
+ scale = SCALES.get(scale_word, 100)
92
+ whole = Fraction(0)
93
+ if whole_tokens:
94
+ whole, decimal = parse_cardinal(whole_tokens)
95
+ if decimal:
96
+ fail(tokens[point], "digits with a decimal point cannot be followed by 'point'")
97
+ if tail and whole >= 1000:
98
+ fail(tail[0], "a decimal with a scale word needs a whole part below a thousand")
99
+ return (whole + Fraction(int(digits), 10 ** len(digits))) * scale
100
+
101
+
102
+ def _fraction(tokens: Sequence[Token]) -> Fraction | None:
103
+ """Parse ``tokens`` as a fraction, or return ``None`` when they do not end in one."""
104
+ found = _denominator(tokens)
105
+ if found is None:
106
+ return None
107
+ numerator_tokens, denominator, plural = found
108
+ last = tokens[-1]
109
+ if not numerator_tokens:
110
+ fail(last, "a fraction needs a numerator before its denominator")
111
+ and_at = [index for index, token in enumerate(numerator_tokens) if token.word == AND]
112
+ if and_at and and_at[-1] > 0:
113
+ whole_tokens, part_tokens = (
114
+ numerator_tokens[: and_at[-1]],
115
+ numerator_tokens[and_at[-1] + 1 :],
116
+ )
117
+ try:
118
+ whole = _whole(whole_tokens)
119
+ part = _numerator(part_tokens, last, plural)
120
+ except WordsNumError:
121
+ pass
122
+ else:
123
+ if part < denominator:
124
+ return whole + Fraction(part, denominator)
125
+ return Fraction(_numerator(numerator_tokens, last, plural), denominator)
126
+
127
+
128
+ def _denominator(tokens: Sequence[Token]) -> tuple[Sequence[Token], int, bool] | None:
129
+ """Split off a denominator. Returns numerator tokens, denominator and plurality."""
130
+ last, word = tokens[-1], tokens[-1].word
131
+ before = tokens[-2].word if len(tokens) > 1 else None
132
+ if last.hyphen_before and before in TENS:
133
+ unit = COMPOUND_PLURALS.get(word, word)
134
+ if unit in ORDINALS and 1 <= UNITS.get(ORDINALS[unit], 0) <= 9:
135
+ value = TENS[before] + UNITS[ORDINALS[unit]]
136
+ plural = word in COMPOUND_PLURALS
137
+ numerator = tokens[:-2]
138
+ if plural or _single_one(numerator, allow_one=True):
139
+ return numerator, value, plural
140
+ return None
141
+ if word in PLURAL_DENOMINATORS:
142
+ return tokens[:-1], DENOMINATORS[PLURAL_DENOMINATORS[word]], True
143
+ if word in ("half", "quarter"):
144
+ return tokens[:-1], DENOMINATORS[word], False
145
+ if word in DENOMINATORS and _single_one(tokens[:-1], allow_one=word not in _SCALE_ORDINALS):
146
+ return tokens[:-1], DENOMINATORS[word], False
147
+ return None
148
+
149
+
150
+ def _single_one(numerator: Sequence[Token], *, allow_one: bool) -> bool:
151
+ """Whether the numerator ends in "a"/"an" (or "one") as a whole or after "and"."""
152
+ if not numerator:
153
+ return False
154
+ if len(numerator) > 1 and numerator[-2].word != AND:
155
+ return False
156
+ last = numerator[-1].word
157
+ return last in ARTICLES or (allow_one and last == "one")
158
+
159
+
160
+ def _whole(tokens: Sequence[Token]) -> int:
161
+ value, decimal = parse_cardinal(tokens)
162
+ if decimal:
163
+ fail(tokens[-1], "the whole part of a mixed number must be an integer")
164
+ return int(value)
165
+
166
+
167
+ def _numerator(tokens: Sequence[Token], denominator: Token, plural: bool) -> int:
168
+ if not tokens:
169
+ fail(denominator, "a fraction needs a numerator before its denominator")
170
+ if len(tokens) == 1 and tokens[0].word in ARTICLES:
171
+ value = 1
172
+ else:
173
+ number, decimal = parse_cardinal(tokens)
174
+ if decimal or number.denominator != 1:
175
+ fail(tokens[-1], "a numerator must be a whole number")
176
+ value = int(number)
177
+ if value == 1 and plural:
178
+ fail(denominator, "a numerator of one takes a singular denominator")
179
+ if value != 1 and not plural:
180
+ fail(denominator, "a numerator other than one takes a plural denominator")
181
+ return value