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 +31 -0
- wordsnum/_cardinal.py +162 -0
- wordsnum/_cli.py +140 -0
- wordsnum/_errors.py +42 -0
- wordsnum/_find.py +108 -0
- wordsnum/_forms.py +181 -0
- wordsnum/_parser.py +91 -0
- wordsnum/_result.py +76 -0
- wordsnum/_spell.py +89 -0
- wordsnum/_tokens.py +155 -0
- wordsnum/_vocab.py +126 -0
- wordsnum/py.typed +0 -0
- wordsnum-0.2.0.dist-info/METADATA +153 -0
- wordsnum-0.2.0.dist-info/RECORD +17 -0
- wordsnum-0.2.0.dist-info/WHEEL +4 -0
- wordsnum-0.2.0.dist-info/entry_points.txt +2 -0
- wordsnum-0.2.0.dist-info/licenses/LICENSE +21 -0
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
|