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/_parser.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Parse a string that holds exactly one spelled-out English number."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from decimal import Decimal
|
|
6
|
+
from fractions import Fraction
|
|
7
|
+
|
|
8
|
+
from ._errors import WordsNumError
|
|
9
|
+
from ._forms import parse_tokens
|
|
10
|
+
from ._result import ParsedNumber
|
|
11
|
+
from ._tokens import tokenize
|
|
12
|
+
|
|
13
|
+
__all__ = ["WordsNumError", "parse", "words_to_decimal", "words_to_fraction", "words_to_num"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def parse(text: str, *, strict: bool = False) -> ParsedNumber:
|
|
17
|
+
"""Parse ``text`` as one number and return a ``ParsedNumber``.
|
|
18
|
+
|
|
19
|
+
The whole string must be a single number; surrounding whitespace is allowed in
|
|
20
|
+
lenient mode. Use ``find_numbers`` to pull numbers out of free text.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
text: The number, such as ``"two thousand and twenty-one"``.
|
|
24
|
+
strict: When ``False`` (the default) case, runs of whitespace, hyphens and
|
|
25
|
+
commas between words are tolerated. When ``True`` only the canonical
|
|
26
|
+
spelling is accepted: lowercase words, single spaces, a hyphen exactly
|
|
27
|
+
between a tens word and a unit word, and no commas between words. Neither
|
|
28
|
+
mode guesses misspelled words.
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
UnknownWordError: A word is not a number word (``"banana"``, ``"fourty"``).
|
|
32
|
+
MisplacedWordError: Number words appear in an order the grammar rejects.
|
|
33
|
+
WordsNumError: The text holds no number words at all.
|
|
34
|
+
|
|
35
|
+
>>> parse("three quarters").value
|
|
36
|
+
Fraction(3, 4)
|
|
37
|
+
>>> parse("twenty-first").kind
|
|
38
|
+
'ordinal'
|
|
39
|
+
"""
|
|
40
|
+
tokens = tokenize(text, strict=strict)
|
|
41
|
+
value, kind = parse_tokens(tokens)
|
|
42
|
+
start, end = tokens[0].start, tokens[-1].end
|
|
43
|
+
return ParsedNumber(value, kind, text[start:end], start, end)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def words_to_num(text: str, *, strict: bool = False) -> int | float:
|
|
47
|
+
"""Convert spelled-out English to an ``int``, or a ``float`` when it is not whole.
|
|
48
|
+
|
|
49
|
+
Cardinals and ordinals return ``int`` (``"twenty-first"`` is 21). Decimals and
|
|
50
|
+
fractions return ``float`` (``"three point one four"`` is 3.14, ``"a half"`` is
|
|
51
|
+
0.5). Use ``words_to_fraction`` or ``words_to_decimal`` for exact results.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
text: The number, such as ``"one hundred and one"`` or ``"3 million"``.
|
|
55
|
+
strict: Accept only the canonical spelling; see ``parse``.
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
WordsNumError: The text is not a valid number (see ``parse`` for subclasses).
|
|
59
|
+
|
|
60
|
+
>>> words_to_num("two million three hundred thousand")
|
|
61
|
+
2300000
|
|
62
|
+
>>> words_to_num("minus two point five")
|
|
63
|
+
-2.5
|
|
64
|
+
"""
|
|
65
|
+
return parse(text, strict=strict).number
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def words_to_fraction(text: str, *, strict: bool = False) -> Fraction:
|
|
69
|
+
"""Convert spelled-out English to an exact ``Fraction``.
|
|
70
|
+
|
|
71
|
+
>>> words_to_fraction("two and three quarters")
|
|
72
|
+
Fraction(11, 4)
|
|
73
|
+
>>> words_to_fraction("zero point one")
|
|
74
|
+
Fraction(1, 10)
|
|
75
|
+
"""
|
|
76
|
+
return parse(text, strict=strict).value
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def words_to_decimal(text: str, *, strict: bool = False) -> Decimal:
|
|
80
|
+
"""Convert spelled-out English to a ``Decimal``.
|
|
81
|
+
|
|
82
|
+
Exact for every cardinal, ordinal and decimal (``"one point one"`` is
|
|
83
|
+
``Decimal('1.1')``). A fraction without a terminating expansion, such as
|
|
84
|
+
``"two thirds"``, is rounded to the precision of the current ``decimal`` context.
|
|
85
|
+
|
|
86
|
+
>>> words_to_decimal("one point one")
|
|
87
|
+
Decimal('1.1')
|
|
88
|
+
>>> words_to_decimal("seven hundredths")
|
|
89
|
+
Decimal('0.07')
|
|
90
|
+
"""
|
|
91
|
+
return parse(text, strict=strict).to_decimal()
|
wordsnum/_result.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""The structured result of parsing one number."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
from fractions import Fraction
|
|
8
|
+
|
|
9
|
+
from ._errors import WordsNumError
|
|
10
|
+
from ._forms import Kind
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class ParsedNumber:
|
|
15
|
+
"""One parsed number with its exact value, its kind and where it was found.
|
|
16
|
+
|
|
17
|
+
Attributes:
|
|
18
|
+
value: The exact value as a ``Fraction`` (``"three point one four"`` is
|
|
19
|
+
``Fraction(157, 50)``, never a rounded float).
|
|
20
|
+
kind: ``"cardinal"`` (``"forty two"``), ``"ordinal"`` (``"forty-second"``,
|
|
21
|
+
``"21st"``), ``"decimal"`` (``"two point five"``, ``"1.5 million"``) or
|
|
22
|
+
``"fraction"`` (``"three quarters"``, ``"two and a half"``).
|
|
23
|
+
text: The source text of the number, exactly as written.
|
|
24
|
+
start: Offset of the first character of the number in the parsed string.
|
|
25
|
+
end: Offset just past the last character, so ``source[start:end] == text``.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
value: Fraction
|
|
29
|
+
kind: Kind
|
|
30
|
+
text: str
|
|
31
|
+
start: int
|
|
32
|
+
end: int
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def number(self) -> int | float:
|
|
36
|
+
"""The value as ``words_to_num`` returns it.
|
|
37
|
+
|
|
38
|
+
An ``int`` for cardinals and ordinals, a ``float`` for decimals and fractions
|
|
39
|
+
(even when the value is whole, such as ``"four halves"``).
|
|
40
|
+
|
|
41
|
+
Raises:
|
|
42
|
+
WordsNumError: A decimal or fraction is too large for a ``float``.
|
|
43
|
+
"""
|
|
44
|
+
if self.kind in ("cardinal", "ordinal"):
|
|
45
|
+
return int(self.value)
|
|
46
|
+
try:
|
|
47
|
+
return self.value.numerator / self.value.denominator
|
|
48
|
+
except OverflowError:
|
|
49
|
+
raise WordsNumError(
|
|
50
|
+
f"{self.text[:40]!r} is too large for a float; use words_to_fraction or "
|
|
51
|
+
"words_to_decimal for the exact value"
|
|
52
|
+
) from None
|
|
53
|
+
|
|
54
|
+
def to_decimal(self) -> Decimal:
|
|
55
|
+
"""The value as a ``Decimal``.
|
|
56
|
+
|
|
57
|
+
Exact whenever the value has a terminating decimal expansion, which covers every
|
|
58
|
+
cardinal, ordinal and decimal. A fraction such as ``"one third"`` is divided in
|
|
59
|
+
the current ``decimal`` context and so is rounded to its precision.
|
|
60
|
+
"""
|
|
61
|
+
value = self.value
|
|
62
|
+
if value.denominator == 1:
|
|
63
|
+
return Decimal(value.numerator)
|
|
64
|
+
denominator = value.denominator
|
|
65
|
+
twos = fives = 0
|
|
66
|
+
while denominator % 2 == 0:
|
|
67
|
+
denominator //= 2
|
|
68
|
+
twos += 1
|
|
69
|
+
while denominator % 5 == 0:
|
|
70
|
+
denominator //= 5
|
|
71
|
+
fives += 1
|
|
72
|
+
if denominator != 1:
|
|
73
|
+
return Decimal(value.numerator) / Decimal(value.denominator)
|
|
74
|
+
places = max(twos, fives)
|
|
75
|
+
scaled = value.numerator * 10**places // value.denominator
|
|
76
|
+
return Decimal(f"{scaled}E-{places}")
|
wordsnum/_spell.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Spell an integer in English words, the inverse of the parser for whole numbers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._vocab import LARGEST_SCALE, SCALE_WORDS, TENS_WORDS, UNIT_WORDS, ordinal_of
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def num_to_words(number: int, *, ordinal: bool = False, british: bool = False) -> str:
|
|
9
|
+
"""Spell ``number`` in canonical English words.
|
|
10
|
+
|
|
11
|
+
The output is exactly the canonical form that ``parse(..., strict=True)`` accepts,
|
|
12
|
+
so ``words_to_num(num_to_words(n)) == n`` for every supported ``n``, and the same
|
|
13
|
+
holds with ``ordinal=True`` (the result parses as an ordinal of value ``n``).
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
number: An ``int`` whose absolute value is below 10**66 (one thousand
|
|
17
|
+
vigintillion). ``bool`` is rejected.
|
|
18
|
+
ordinal: Spell the ordinal (``"twenty-first"``) instead of the cardinal.
|
|
19
|
+
british: Insert ``"and"`` the British way, after ``"hundred"`` and before a
|
|
20
|
+
final group below one hundred (``"one thousand and five"``).
|
|
21
|
+
|
|
22
|
+
Raises:
|
|
23
|
+
TypeError: ``number`` is not an ``int``.
|
|
24
|
+
ValueError: ``number`` is out of range, or negative with ``ordinal=True``.
|
|
25
|
+
|
|
26
|
+
>>> num_to_words(1234)
|
|
27
|
+
'one thousand two hundred thirty-four'
|
|
28
|
+
>>> num_to_words(101, british=True)
|
|
29
|
+
'one hundred and one'
|
|
30
|
+
>>> num_to_words(21, ordinal=True)
|
|
31
|
+
'twenty-first'
|
|
32
|
+
"""
|
|
33
|
+
if isinstance(number, bool) or not isinstance(number, int):
|
|
34
|
+
raise TypeError(f"num_to_words() needs an int, got {type(number).__name__}")
|
|
35
|
+
if abs(number) >= LARGEST_SCALE * 1000:
|
|
36
|
+
raise ValueError("num_to_words() supports absolute values below 10**66")
|
|
37
|
+
if number < 0 and ordinal:
|
|
38
|
+
raise ValueError("an ordinal cannot be negative")
|
|
39
|
+
words = _cardinal(abs(number), british=british)
|
|
40
|
+
if ordinal:
|
|
41
|
+
words = _to_ordinal(words)
|
|
42
|
+
return f"minus {words}" if number < 0 else words
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _cardinal(number: int, *, british: bool) -> str:
|
|
46
|
+
if number == 0:
|
|
47
|
+
return "zero"
|
|
48
|
+
groups: list[int] = []
|
|
49
|
+
while number:
|
|
50
|
+
number, group = divmod(number, 1000)
|
|
51
|
+
groups.append(group)
|
|
52
|
+
parts: list[str] = []
|
|
53
|
+
for power in range(len(groups) - 1, -1, -1):
|
|
54
|
+
group = groups[power]
|
|
55
|
+
if not group:
|
|
56
|
+
continue
|
|
57
|
+
if british and power == 0 and parts and group < 100:
|
|
58
|
+
parts.append("and")
|
|
59
|
+
parts.append(_group(group, british=british))
|
|
60
|
+
if power:
|
|
61
|
+
parts.append(SCALE_WORDS[power - 1])
|
|
62
|
+
return " ".join(parts)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _group(number: int, *, british: bool) -> str:
|
|
66
|
+
hundreds, rest = divmod(number, 100)
|
|
67
|
+
parts: list[str] = []
|
|
68
|
+
if hundreds:
|
|
69
|
+
parts += [UNIT_WORDS[hundreds], "hundred"]
|
|
70
|
+
if rest and british:
|
|
71
|
+
parts.append("and")
|
|
72
|
+
if rest:
|
|
73
|
+
parts.append(_below_hundred(rest))
|
|
74
|
+
return " ".join(parts)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _below_hundred(number: int) -> str:
|
|
78
|
+
if number < 20:
|
|
79
|
+
return UNIT_WORDS[number]
|
|
80
|
+
tens, unit = divmod(number, 10)
|
|
81
|
+
word = TENS_WORDS[tens - 2]
|
|
82
|
+
return f"{word}-{UNIT_WORDS[unit]}" if unit else word
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _to_ordinal(words: str) -> str:
|
|
86
|
+
head, space, last = words.rpartition(" ")
|
|
87
|
+
stem, hyphen, unit = last.rpartition("-")
|
|
88
|
+
last = f"{stem}{hyphen}{ordinal_of(unit)}"
|
|
89
|
+
return f"{head}{space}{last}"
|
wordsnum/_tokens.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Split text into number tokens with their offsets, and check what lies between them."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from fractions import Fraction
|
|
8
|
+
from itertools import pairwise
|
|
9
|
+
from typing import NoReturn
|
|
10
|
+
|
|
11
|
+
from ._errors import MisplacedWordError, UnknownWordError, WordsNumError
|
|
12
|
+
from ._vocab import ARTICLES, COMPOUND_PLURALS, ORDINALS, TENS, UNITS, VOCABULARY
|
|
13
|
+
|
|
14
|
+
_TOKEN = re.compile(
|
|
15
|
+
r"(?:(?<![^\s(])-)?" # a minus sign glued to digits at the start of a word: -5
|
|
16
|
+
r"(?:\d{1,3}(?:,\d{3})+(?:\.\d+)?" # 2,500 or 1,234.5
|
|
17
|
+
r"|\d+(?:\.\d+)?(?:st|nd|rd|th)?)" # 42, 3.14, 21st
|
|
18
|
+
r"|[A-Za-z]+",
|
|
19
|
+
re.IGNORECASE | re.ASCII,
|
|
20
|
+
)
|
|
21
|
+
_DIGITS = re.compile(r"-?[\d,]+(?:\.\d+)?", re.ASCII)
|
|
22
|
+
# Longer digit runs are not numbers anyone writes, and Python refuses to convert them.
|
|
23
|
+
MAX_DIGITS = 1000
|
|
24
|
+
_ORDINAL_DIGITS = re.compile(r"(\d+)(st|nd|rd|th)", re.ASCII)
|
|
25
|
+
_LENIENT_GAP = re.compile(r"[\s,\-]*")
|
|
26
|
+
_LENIENT_EDGE = re.compile(r"\s*")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class Token:
|
|
31
|
+
"""One word or digit group: lowercased ``word``, the ``raw`` text and its offsets."""
|
|
32
|
+
|
|
33
|
+
word: str
|
|
34
|
+
raw: str
|
|
35
|
+
start: int
|
|
36
|
+
end: int
|
|
37
|
+
hyphen_before: bool
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def number(self) -> Fraction | None:
|
|
41
|
+
"""The value of a plain digit token such as ``2,500`` or ``1.5``, else ``None``."""
|
|
42
|
+
if len(self.word) > MAX_DIGITS or _DIGITS.fullmatch(self.word) is None:
|
|
43
|
+
return None
|
|
44
|
+
return Fraction(self.word.replace(",", ""))
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def digit_ordinal(self) -> int | None:
|
|
48
|
+
"""The value of a digit ordinal with the right suffix (``21st``), else ``None``."""
|
|
49
|
+
match = _ORDINAL_DIGITS.fullmatch(self.word)
|
|
50
|
+
if match is None or len(self.word) > MAX_DIGITS:
|
|
51
|
+
return None
|
|
52
|
+
value = int(match.group(1))
|
|
53
|
+
return value if match.group(2) == _suffix(value) else None
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def known(self) -> bool:
|
|
57
|
+
"""Whether this token can be part of a number."""
|
|
58
|
+
if self.word in VOCABULARY:
|
|
59
|
+
return True
|
|
60
|
+
return self.number is not None or self.digit_ordinal is not None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _suffix(value: int) -> str:
|
|
64
|
+
if value % 100 in (11, 12, 13):
|
|
65
|
+
return "th"
|
|
66
|
+
return {1: "st", 2: "nd", 3: "rd"}.get(value % 10, "th")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def scan(text: str) -> list[Token]:
|
|
70
|
+
"""Find every candidate token in ``text`` without validating anything."""
|
|
71
|
+
tokens: list[Token] = []
|
|
72
|
+
previous_end = 0
|
|
73
|
+
for match in _TOKEN.finditer(text):
|
|
74
|
+
gap = text[previous_end : match.start()]
|
|
75
|
+
raw = match.group()
|
|
76
|
+
tokens.append(Token(raw.lower(), raw, match.start(), match.end(), "-" in gap))
|
|
77
|
+
previous_end = match.end()
|
|
78
|
+
return tokens
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def gap_ok(text: str, left: Token, right: Token) -> bool:
|
|
82
|
+
"""Whether the text between two tokens is a lenient separator."""
|
|
83
|
+
return _LENIENT_GAP.fullmatch(text, left.end, right.start) is not None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def tokenize(text: str, *, strict: bool) -> list[Token]:
|
|
87
|
+
"""Tokenize a whole string that must contain one number and nothing else."""
|
|
88
|
+
tokens = scan(text)
|
|
89
|
+
if not tokens:
|
|
90
|
+
stray = text.strip()
|
|
91
|
+
if stray:
|
|
92
|
+
start = text.index(stray[0])
|
|
93
|
+
raise UnknownWordError(
|
|
94
|
+
f"unexpected text: {stray!r}", word=stray, start=start, end=len(text.rstrip())
|
|
95
|
+
)
|
|
96
|
+
raise WordsNumError("no number words found in the input")
|
|
97
|
+
for token in tokens:
|
|
98
|
+
if not token.known:
|
|
99
|
+
raise UnknownWordError(
|
|
100
|
+
f"unexpected word: {token.raw!r} at position {token.start}",
|
|
101
|
+
word=token.raw,
|
|
102
|
+
start=token.start,
|
|
103
|
+
end=token.end,
|
|
104
|
+
)
|
|
105
|
+
_check_edges(text, tokens, strict=strict)
|
|
106
|
+
for left, right in pairwise(tokens):
|
|
107
|
+
_check_gap(text, left, right, strict=strict)
|
|
108
|
+
if strict:
|
|
109
|
+
for token, following in zip(tokens, [*tokens[1:], None], strict=True):
|
|
110
|
+
if token.raw != token.word:
|
|
111
|
+
_misplaced(token, "strict mode needs lowercase words")
|
|
112
|
+
if token.word in ARTICLES and following is not None:
|
|
113
|
+
article = "an" if following.word[0] in "aeiou" else "a"
|
|
114
|
+
if token.word != article:
|
|
115
|
+
_misplaced(token, f"strict mode needs {article!r} before {following.raw!r}")
|
|
116
|
+
return tokens
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _check_edges(text: str, tokens: list[Token], *, strict: bool) -> None:
|
|
120
|
+
head, tail = text[: tokens[0].start], text[tokens[-1].end :]
|
|
121
|
+
for edge, start in ((head, 0), (tail, tokens[-1].end)):
|
|
122
|
+
if edge and (strict or _LENIENT_EDGE.fullmatch(edge) is None):
|
|
123
|
+
_stray(edge, start)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _check_gap(text: str, left: Token, right: Token, *, strict: bool) -> None:
|
|
127
|
+
gap = text[left.end : right.start]
|
|
128
|
+
if not strict:
|
|
129
|
+
if _LENIENT_GAP.fullmatch(gap) is None:
|
|
130
|
+
_stray(gap, left.end)
|
|
131
|
+
return
|
|
132
|
+
if left.word in TENS and right.word in COMPOUND_PLURALS and gap in (" ", "-"):
|
|
133
|
+
return # "twenty ninths" is 20/9 and "twenty-ninths" is a plural 1/29: both valid
|
|
134
|
+
joins_tens = left.word in TENS and 1 <= UNITS.get(ORDINALS.get(right.word, right.word), 0) <= 9
|
|
135
|
+
if gap == ("-" if joins_tens else " "):
|
|
136
|
+
return
|
|
137
|
+
expected = "a hyphen" if joins_tens else "a single space"
|
|
138
|
+
_misplaced(right, f"strict mode needs {expected} before {right.raw!r}")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _stray(gap: str, offset: int) -> NoReturn:
|
|
142
|
+
stripped = gap.strip() or gap
|
|
143
|
+
start = offset + gap.index(stripped)
|
|
144
|
+
raise UnknownWordError(
|
|
145
|
+
f"unexpected text: {stripped!r} at position {start}",
|
|
146
|
+
word=stripped,
|
|
147
|
+
start=start,
|
|
148
|
+
end=start + len(stripped),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _misplaced(token: Token, reason: str) -> NoReturn:
|
|
153
|
+
raise MisplacedWordError(
|
|
154
|
+
f"{reason} (at position {token.start})", word=token.raw, start=token.start, end=token.end
|
|
155
|
+
)
|
wordsnum/_vocab.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Vocabulary tables mapping English number words to their numeric values."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
UNIT_WORDS: tuple[str, ...] = (
|
|
6
|
+
"zero",
|
|
7
|
+
"one",
|
|
8
|
+
"two",
|
|
9
|
+
"three",
|
|
10
|
+
"four",
|
|
11
|
+
"five",
|
|
12
|
+
"six",
|
|
13
|
+
"seven",
|
|
14
|
+
"eight",
|
|
15
|
+
"nine",
|
|
16
|
+
"ten",
|
|
17
|
+
"eleven",
|
|
18
|
+
"twelve",
|
|
19
|
+
"thirteen",
|
|
20
|
+
"fourteen",
|
|
21
|
+
"fifteen",
|
|
22
|
+
"sixteen",
|
|
23
|
+
"seventeen",
|
|
24
|
+
"eighteen",
|
|
25
|
+
"nineteen",
|
|
26
|
+
)
|
|
27
|
+
TENS_WORDS: tuple[str, ...] = (
|
|
28
|
+
"twenty",
|
|
29
|
+
"thirty",
|
|
30
|
+
"forty",
|
|
31
|
+
"fifty",
|
|
32
|
+
"sixty",
|
|
33
|
+
"seventy",
|
|
34
|
+
"eighty",
|
|
35
|
+
"ninety",
|
|
36
|
+
)
|
|
37
|
+
# Short scale, one entry per power of a thousand: thousand = 10**3 ... vigintillion = 10**63.
|
|
38
|
+
SCALE_WORDS: tuple[str, ...] = (
|
|
39
|
+
"thousand",
|
|
40
|
+
"million",
|
|
41
|
+
"billion",
|
|
42
|
+
"trillion",
|
|
43
|
+
"quadrillion",
|
|
44
|
+
"quintillion",
|
|
45
|
+
"sextillion",
|
|
46
|
+
"septillion",
|
|
47
|
+
"octillion",
|
|
48
|
+
"nonillion",
|
|
49
|
+
"decillion",
|
|
50
|
+
"undecillion",
|
|
51
|
+
"duodecillion",
|
|
52
|
+
"tredecillion",
|
|
53
|
+
"quattuordecillion",
|
|
54
|
+
"quindecillion",
|
|
55
|
+
"sexdecillion",
|
|
56
|
+
"septendecillion",
|
|
57
|
+
"octodecillion",
|
|
58
|
+
"novemdecillion",
|
|
59
|
+
"vigintillion",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
UNITS: dict[str, int] = {word: value for value, word in enumerate(UNIT_WORDS)}
|
|
63
|
+
TENS: dict[str, int] = {word: 20 + 10 * index for index, word in enumerate(TENS_WORDS)}
|
|
64
|
+
SCALES: dict[str, int] = {word: 1000 ** (index + 1) for index, word in enumerate(SCALE_WORDS)}
|
|
65
|
+
HUNDRED = "hundred"
|
|
66
|
+
LARGEST_SCALE: int = 1000 ** len(SCALE_WORDS)
|
|
67
|
+
|
|
68
|
+
_IRREGULAR_ORDINALS: dict[str, str] = {
|
|
69
|
+
"one": "first",
|
|
70
|
+
"two": "second",
|
|
71
|
+
"three": "third",
|
|
72
|
+
"five": "fifth",
|
|
73
|
+
"eight": "eighth",
|
|
74
|
+
"nine": "ninth",
|
|
75
|
+
"twelve": "twelfth",
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def ordinal_of(word: str) -> str:
|
|
80
|
+
"""Return the ordinal form of one cardinal word ("twenty" -> "twentieth")."""
|
|
81
|
+
if word in _IRREGULAR_ORDINALS:
|
|
82
|
+
return _IRREGULAR_ORDINALS[word]
|
|
83
|
+
if word.endswith("y"):
|
|
84
|
+
return word[:-1] + "ieth"
|
|
85
|
+
return word + "th"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
_CARDINAL_WORDS: tuple[str, ...] = (*UNIT_WORDS, *TENS_WORDS, HUNDRED, *SCALE_WORDS)
|
|
89
|
+
# Ordinal word -> the cardinal word it stands for ("first" -> "one").
|
|
90
|
+
ORDINALS: dict[str, str] = {ordinal_of(word): word for word in _CARDINAL_WORDS}
|
|
91
|
+
|
|
92
|
+
# Words that name a denominator when plural ("thirds") or after "a"/"one" ("a third").
|
|
93
|
+
# "first" and "second" only do so inside a hyphenated compound ("thirty-seconds").
|
|
94
|
+
_NOT_DENOMINATORS = frozenset({"zeroth", "first", "second"})
|
|
95
|
+
DENOMINATORS: dict[str, int] = {"half": 2, "quarter": 4}
|
|
96
|
+
for _ordinal, _cardinal in ORDINALS.items():
|
|
97
|
+
if _ordinal not in _NOT_DENOMINATORS:
|
|
98
|
+
_value = UNITS.get(_cardinal) or TENS.get(_cardinal) or SCALES.get(_cardinal, 100)
|
|
99
|
+
DENOMINATORS[_ordinal] = _value
|
|
100
|
+
PLURAL_DENOMINATORS: dict[str, str] = {word + "s": word for word in DENOMINATORS if word != "half"}
|
|
101
|
+
PLURAL_DENOMINATORS["halves"] = "half"
|
|
102
|
+
# Plural ordinal units usable only after a hyphenated tens word ("twenty-firsts").
|
|
103
|
+
COMPOUND_PLURALS: dict[str, str] = {
|
|
104
|
+
ordinal_of(word) + "s": ordinal_of(word) for word in UNIT_WORDS[1:10]
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
ARTICLES: frozenset[str] = frozenset({"a", "an"})
|
|
108
|
+
AND = "and"
|
|
109
|
+
POINT = "point"
|
|
110
|
+
SIGNS: frozenset[str] = frozenset({"minus", "negative"})
|
|
111
|
+
POINT_DIGITS: dict[str, int] = {**{word: UNITS[word] for word in UNIT_WORDS[:10]}, "oh": 0}
|
|
112
|
+
|
|
113
|
+
VOCABULARY: frozenset[str] = frozenset(
|
|
114
|
+
{
|
|
115
|
+
*_CARDINAL_WORDS,
|
|
116
|
+
*ORDINALS,
|
|
117
|
+
*DENOMINATORS,
|
|
118
|
+
*PLURAL_DENOMINATORS,
|
|
119
|
+
*COMPOUND_PLURALS,
|
|
120
|
+
*ARTICLES,
|
|
121
|
+
AND,
|
|
122
|
+
POINT,
|
|
123
|
+
*SIGNS,
|
|
124
|
+
"oh",
|
|
125
|
+
}
|
|
126
|
+
)
|
wordsnum/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: wordsnum
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Parse spelled-out English numbers into int or float, fully typed and dependency-free
|
|
5
|
+
Project-URL: Homepage, https://lianes8studio.com
|
|
6
|
+
Project-URL: Repository, https://github.com/JaviLianes8/wordsnum
|
|
7
|
+
Project-URL: Issues, https://github.com/JaviLianes8/wordsnum/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/JaviLianes8/wordsnum/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: LinkedIn, https://www.linkedin.com/in/jlianes/
|
|
10
|
+
Project-URL: Funding, https://buymeacoffee.com/jlianesglrs
|
|
11
|
+
Author-email: Javier Lianes <javier@lianes8studio.com>
|
|
12
|
+
License-Expression: MIT
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: build; extra == 'dev'
|
|
21
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
22
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
23
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
24
|
+
Requires-Dist: twine; extra == 'dev'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# wordsnum
|
|
28
|
+
|
|
29
|
+
Parse spelled-out English numbers into int or float, fully typed and dependency-free.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
pip install wordsnum
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from wordsnum import words_to_num
|
|
41
|
+
|
|
42
|
+
words_to_num("two hundred fifty") # 250
|
|
43
|
+
words_to_num("two thousand and twenty-one") # 2021
|
|
44
|
+
words_to_num("nineteen hundred eighty four") # 1984
|
|
45
|
+
words_to_num("a hundred") # 100
|
|
46
|
+
words_to_num("minus forty two") # -42
|
|
47
|
+
words_to_num("twenty-first") # 21 (ordinals)
|
|
48
|
+
words_to_num("three point one four") # 3.14
|
|
49
|
+
words_to_num("two point five million") # 2500000.0
|
|
50
|
+
words_to_num("two and three quarters") # 2.75
|
|
51
|
+
words_to_num("3 million") # 3000000
|
|
52
|
+
words_to_num("one decillion") # 10**33
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Cardinals and ordinals come back as `int`; decimals and fractions as `float`.
|
|
56
|
+
|
|
57
|
+
### Exact values, kinds and spans
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from wordsnum import parse, words_to_decimal, words_to_fraction
|
|
61
|
+
|
|
62
|
+
words_to_fraction("two thirds") # Fraction(2, 3)
|
|
63
|
+
words_to_decimal("one point one") # Decimal('1.1')
|
|
64
|
+
|
|
65
|
+
result = parse(" Seven Hundredths ")
|
|
66
|
+
result.value # Fraction(7, 100)
|
|
67
|
+
result.kind # 'fraction' (also 'cardinal', 'ordinal', 'decimal')
|
|
68
|
+
result.text, result.start, result.end # ('Seven Hundredths', 2, 18)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Numbers inside text
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from wordsnum import find_numbers
|
|
75
|
+
|
|
76
|
+
for match in find_numbers("We sold twenty-one boxes, then 3 million more."):
|
|
77
|
+
print(match.start, match.end, match.number, match.text)
|
|
78
|
+
# 8 18 21 twenty-one
|
|
79
|
+
# 31 40 3000000 3 million
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Spelling integers
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from wordsnum import num_to_words
|
|
86
|
+
|
|
87
|
+
num_to_words(1234) # 'one thousand two hundred thirty-four'
|
|
88
|
+
num_to_words(1005, british=True) # 'one thousand and five'
|
|
89
|
+
num_to_words(21, ordinal=True) # 'twenty-first'
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`num_to_words` writes exactly the canonical form that strict parsing accepts, so the
|
|
93
|
+
two directions round-trip.
|
|
94
|
+
|
|
95
|
+
### Strict and lenient
|
|
96
|
+
|
|
97
|
+
By default case, extra spaces, hyphens and commas are tolerated. `strict=True` accepts
|
|
98
|
+
only the canonical spelling (lowercase, single spaces, `twenty-one` hyphenated, no
|
|
99
|
+
commas). Neither mode guesses typos: `"fourty"` is always an error.
|
|
100
|
+
|
|
101
|
+
### Errors that point at the problem
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from wordsnum import MisplacedWordError, UnknownWordError, words_to_num
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
words_to_num("two hundred banana")
|
|
108
|
+
except UnknownWordError as error:
|
|
109
|
+
print(error.word, error.start, error.end) # banana 12 18
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
words_to_num("one thousand one million")
|
|
113
|
+
except MisplacedWordError as error:
|
|
114
|
+
print(error) # scale words must get smaller from left to right: 'million' at position 17
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Both subclass `WordsNumError`, which subclasses `ValueError`.
|
|
118
|
+
|
|
119
|
+
## Command line
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
wordsnum parse two thousand and twenty-one # 2021
|
|
123
|
+
wordsnum parse three quarters --exact # 3/4
|
|
124
|
+
echo "minus forty two" | wordsnum parse # -42
|
|
125
|
+
echo "I owe you twenty-one pounds" | wordsnum find
|
|
126
|
+
wordsnum say 21 --ordinal # twenty-first
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Run `wordsnum --help` for every option. Errors go to stderr with exit status 1.
|
|
130
|
+
|
|
131
|
+
See [GUIDE.md](GUIDE.md) for the full grammar and the list of unsupported forms.
|
|
132
|
+
|
|
133
|
+
## Why
|
|
134
|
+
|
|
135
|
+
Turning words back into numbers is the reverse of spelling them out, and the existing
|
|
136
|
+
options are thin. `word2number` has not seen a release since 2017, ships no type hints,
|
|
137
|
+
and mishandles several common phrasings. `numerizer` is a port of a Ruby gem, is
|
|
138
|
+
untyped, and returns strings rather than numbers. `text2num` is maintained but builds
|
|
139
|
+
on a Rust extension, which is a heavy dependency for a small parsing job.
|
|
140
|
+
|
|
141
|
+
`wordsnum` is a small, pure-Python parser with zero runtime dependencies, a `py.typed`
|
|
142
|
+
marker, a clean `mypy --strict` build, a real grammar that rejects malformed input
|
|
143
|
+
instead of summing words, and exact results when you need them.
|
|
144
|
+
|
|
145
|
+
## License
|
|
146
|
+
|
|
147
|
+
MIT. See [LICENSE](LICENSE).
|
|
148
|
+
|
|
149
|
+
## Links
|
|
150
|
+
|
|
151
|
+
- Website: https://lianes8studio.com
|
|
152
|
+
- GitHub: https://github.com/JaviLianes8
|
|
153
|
+
- LinkedIn: https://www.linkedin.com/in/jlianes/
|