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/_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/