wordsnum 0.2.0__tar.gz

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.
Files changed (37) hide show
  1. wordsnum-0.2.0/.gitattributes +1 -0
  2. wordsnum-0.2.0/.github/FUNDING.yml +1 -0
  3. wordsnum-0.2.0/.gitignore +12 -0
  4. wordsnum-0.2.0/CHANGELOG.md +35 -0
  5. wordsnum-0.2.0/CONTRIBUTING.md +19 -0
  6. wordsnum-0.2.0/GUIDE.md +141 -0
  7. wordsnum-0.2.0/IMPROVEMENTS-round.md +42 -0
  8. wordsnum-0.2.0/LICENSE +21 -0
  9. wordsnum-0.2.0/PKG-INFO +153 -0
  10. wordsnum-0.2.0/README.md +127 -0
  11. wordsnum-0.2.0/pyproject.toml +54 -0
  12. wordsnum-0.2.0/requirements-dev.txt +3 -0
  13. wordsnum-0.2.0/scripts/check.ps1 +11 -0
  14. wordsnum-0.2.0/scripts/release.ps1 +28 -0
  15. wordsnum-0.2.0/src/wordsnum/__init__.py +31 -0
  16. wordsnum-0.2.0/src/wordsnum/_cardinal.py +162 -0
  17. wordsnum-0.2.0/src/wordsnum/_cli.py +140 -0
  18. wordsnum-0.2.0/src/wordsnum/_errors.py +42 -0
  19. wordsnum-0.2.0/src/wordsnum/_find.py +108 -0
  20. wordsnum-0.2.0/src/wordsnum/_forms.py +181 -0
  21. wordsnum-0.2.0/src/wordsnum/_parser.py +91 -0
  22. wordsnum-0.2.0/src/wordsnum/_result.py +76 -0
  23. wordsnum-0.2.0/src/wordsnum/_spell.py +89 -0
  24. wordsnum-0.2.0/src/wordsnum/_tokens.py +155 -0
  25. wordsnum-0.2.0/src/wordsnum/_vocab.py +126 -0
  26. wordsnum-0.2.0/src/wordsnum/py.typed +0 -0
  27. wordsnum-0.2.0/tests/_oracle.py +178 -0
  28. wordsnum-0.2.0/tests/test_cli.py +103 -0
  29. wordsnum-0.2.0/tests/test_contract.py +156 -0
  30. wordsnum-0.2.0/tests/test_e2e.py +90 -0
  31. wordsnum-0.2.0/tests/test_find.py +80 -0
  32. wordsnum-0.2.0/tests/test_forms.py +157 -0
  33. wordsnum-0.2.0/tests/test_grammar.py +118 -0
  34. wordsnum-0.2.0/tests/test_roundtrip.py +127 -0
  35. wordsnum-0.2.0/tests/test_spell.py +55 -0
  36. wordsnum-0.2.0/tests/test_strict.py +86 -0
  37. wordsnum-0.2.0/tests/test_words_to_num.py +188 -0
@@ -0,0 +1 @@
1
+ * text=auto eol=lf
@@ -0,0 +1 @@
1
+ buy_me_a_coffee: jlianesglrs
@@ -0,0 +1,12 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ build/
6
+ dist/
7
+ *.egg-info/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .coverage
12
+ htmlcov/
@@ -0,0 +1,35 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.2.0]
10
+
11
+ - Replace the word-summing parser with a real grammar. Behaviour change: malformed
12
+ input that 0.1.0 summed into a number is now rejected with `MisplacedWordError`, for
13
+ example "one two" (was 3), "one, two" (was 3), "and one" (was 1), "one and" (was 1)
14
+ and "thousand million".
15
+ - Scales up to vigintillion (10**63), with ordinal forms.
16
+ - Fractions ("three quarters", "a half", "seven hundredths", "three thirty-seconds")
17
+ and mixed numbers ("two and three quarters").
18
+ - Decimals with a final scale word ("two point five million") and "oh" after "point".
19
+ - Digit groups mixed with words ("3 million", "2,500", "1.5 billion", "21st", "-5").
20
+ - `parse` returns a `ParsedNumber` with the exact value, kind and source span.
21
+ - `words_to_fraction` and `words_to_decimal` return exact results.
22
+ - `find_numbers` finds every number inside free text.
23
+ - `num_to_words` spells integers in the canonical form, round-tripping with the parser.
24
+ - `strict=True` accepts only the canonical spelling.
25
+ - Typed errors `UnknownWordError` and `MisplacedWordError` carry `word`, `start` and `end`.
26
+ - The `wordsnum` command line tool: `parse`, `find` and `say`.
27
+ - GUIDE.md documents the full grammar and the unsupported forms.
28
+
29
+ ## [0.1.0]
30
+
31
+ - First release.
32
+ - `words_to_num` parses spelled-out English numbers into `int` or `float`.
33
+ - Handles units, teens, tens, hundreds, and scales up to trillion.
34
+ - Accepts "and", hyphenated words, articles ("a hundred"), ordinals, negatives, and decimals.
35
+ - Raises `WordsNumError` with a clear message on unparseable input.
@@ -0,0 +1,19 @@
1
+ # Contributing
2
+
3
+ Thanks for taking the time. This project keeps a small, predictable shape.
4
+
5
+ ## Setup
6
+
7
+ ```
8
+ pip install -r requirements-dev.txt
9
+ ```
10
+
11
+ ## Before you open a pull request
12
+
13
+ - Keep public API in `src/wordsnum/__init__.py`; internals live in `_`-prefixed modules.
14
+ - Add a test for the behaviour you change. Tests inject time, disk and network; they
15
+ never touch the real thing.
16
+ - Run `scripts/check.ps1` (format, lint, types, tests) and make sure it passes.
17
+ - One logical change per commit, imperative subject line.
18
+
19
+ Open an issue first if the change is large or shifts the public API.
@@ -0,0 +1,141 @@
1
+ # wordsnum guide
2
+
3
+ This guide describes exactly what `wordsnum` accepts, how each form is read, and what
4
+ it does not support.
5
+
6
+ ## Public API
7
+
8
+ | Name | Returns | Purpose |
9
+ | --- | --- | --- |
10
+ | `words_to_num(text, *, strict=False)` | `int` or `float` | Quick conversion |
11
+ | `parse(text, *, strict=False)` | `ParsedNumber` | Exact value, kind and span |
12
+ | `words_to_fraction(text, *, strict=False)` | `Fraction` | Exact rational value |
13
+ | `words_to_decimal(text, *, strict=False)` | `Decimal` | Exact decimal value |
14
+ | `find_numbers(text)` | `list[ParsedNumber]` | Every number inside free text |
15
+ | `num_to_words(n, *, ordinal=False, british=False)` | `str` | Spell an integer |
16
+ | `ParsedNumber` | dataclass | `value`, `kind`, `text`, `start`, `end`, `number`, `to_decimal()` |
17
+ | `Kind` | `Literal` | `"cardinal"`, `"ordinal"`, `"decimal"`, `"fraction"` |
18
+ | `WordsNumError` | exception | Base error, a `ValueError` |
19
+ | `UnknownWordError` | exception | A word that is not a number word |
20
+ | `MisplacedWordError` | exception | A number word in a position the grammar rejects |
21
+
22
+ Every error carries `word`, `start` and `end`, with `text[start:end] == word`, or
23
+ `None` for all three when no single word is at fault (empty input).
24
+
25
+ ## Cardinals
26
+
27
+ A cardinal is a sequence of groups, each below one thousand, joined by scale words in
28
+ strictly decreasing order:
29
+
30
+ - Units and teens: `zero` to `nineteen`. `zero` must stand alone.
31
+ - Tens: `twenty` to `ninety`, optionally followed by a unit: `twenty-one`, `forty two`.
32
+ - Hundreds: `one hundred`, `nine hundred ninety-nine`, `a hundred`.
33
+ - Scales, short scale: `thousand` (10**3), `million`, `billion`, `trillion`,
34
+ `quadrillion`, `quintillion`, `sextillion`, `septillion`, `octillion`, `nonillion`,
35
+ `decillion` (10**33), `undecillion`, `duodecillion`, `tredecillion`,
36
+ `quattuordecillion`, `quindecillion`, `sexdecillion`, `septendecillion`,
37
+ `octodecillion`, `novemdecillion` and `vigintillion` (10**63).
38
+ - A leading scale may drop its `one`: `hundred`, `thousand two hundred`.
39
+ - `a`/`an` stands for one before `hundred` or a scale: `a thousand and one`.
40
+ - Year style: a teen or tens word before `hundred` (`nineteen hundred eighty four`,
41
+ `twenty-five hundred`) is accepted when no other scale word appears.
42
+ - British `and` is accepted after `hundred` (`one hundred and one`) and after a scale
43
+ before a final group below one hundred (`one thousand and five`). Anywhere else it is
44
+ an error.
45
+
46
+ Rejected examples: `one two`, `thousand million`, `one thousand one million`,
47
+ `zero hundred`, `one hundred hundred`, `one and two`.
48
+
49
+ ## Ordinals
50
+
51
+ The last word may be an ordinal: `first`, `twelfth`, `twentieth`, `twenty-first`,
52
+ `one hundred and third`, `hundredth`, `one millionth`, `decillionth`. Digit ordinals
53
+ such as `21st`, `2nd`, `103rd` and `111th` work when the suffix is correct. Ordinals
54
+ are never negative.
55
+
56
+ ## Decimals
57
+
58
+ `point` is followed by one-digit words (`zero` to `nine`, or `oh`):
59
+ `three point one four`, `point five`, `one point oh five`. One final scale word may
60
+ follow the digits when the whole part is below a thousand: `two point five million`.
61
+ Digit decimals also work: `3.14`, `1,234.5`, `1.5 billion`.
62
+
63
+ ## Fractions
64
+
65
+ A numerator followed by a denominator word:
66
+
67
+ - `half`/`halves`, `quarter`/`quarters`, and ordinal denominators such as `thirds`,
68
+ `fourths`, `hundredths`, `millionths`. A hyphenated compound also works:
69
+ `three thirty-seconds`, `a twenty-first`.
70
+ - A numerator of one takes the singular (`a third`, `one half`); any other numerator
71
+ takes the plural (`two thirds`, `zero halves`).
72
+ - `one` followed by a scale ordinal reads as an ordinal (`one hundredth` is the 100th);
73
+ use `a hundredth` for the fraction 1/100.
74
+ - Mixed numbers: `one and a half`, `two and three quarters`. The reading after the last
75
+ `and` is used as a mixed number only when that fraction is proper, so
76
+ `one hundred and one third` is 100 1/3 while `two hundred and fifty thirds` is 250/3.
77
+ - `first` and `second` are not denominators on their own (`two seconds` is not 2/2).
78
+ - Scale denominators read greedily as one numerator: `three hundred thousandths` is
79
+ 300/1000 and `three hundred and fifty thousandths` is 300 and 50/1000. Compound scale
80
+ denominators are not supported: `three hundred-thousandths` is also read as
81
+ 300/1000, so write 3/100000 as a decimal (`zero point zero zero zero zero three`).
82
+
83
+ ## Signs and digits
84
+
85
+ `minus` or `negative` may lead any cardinal, decimal or fraction, and a `-` written
86
+ directly before leading digits is a sign too (`-5`, `-1.5 million`). Digit groups mix with
87
+ words: `3 million`, `5 hundred`, `2,500`, `1 million two hundred`. A digit group after
88
+ a scale must be below a thousand, and a digit decimal (`1.5`) may only start the
89
+ number. Digits cannot follow a word inside a group (`twenty 5` and `one hundred 5` are
90
+ rejected). Only ASCII digits are read, and runs longer than 1000 characters are not
91
+ treated as numbers.
92
+
93
+ ## Strict and lenient
94
+
95
+ Both modes accept the same grammar; they differ only in how the words may be written.
96
+ Lenient mode, the default, ignores case, collapses whitespace and accepts hyphens and
97
+ commas between words. Strict mode checks the written form: lowercase words, exactly
98
+ one space between words, a hyphen exactly between a tens word and a unit word
99
+ (`twenty-one`, `thirty-second`), no commas between words, no surrounding whitespace,
100
+ and the article that matches the sound of the next word (`a hundred`, `an eighth`).
101
+ Everything `num_to_words` writes passes strict mode. Grammatical variants such as
102
+ `negative five`, `hundred`, `point five`, `2,500` or `twenty-five hundred` are valid in
103
+ both modes. Neither mode corrects spelling.
104
+
105
+ Spacing matters for compound denominators: `twenty-ninths` is a plural of 1/29 while
106
+ `twenty ninths` is 20/9, and `three thirty-seconds` (3/32) needs its hyphen in both
107
+ modes.
108
+
109
+ ## Finding numbers in text
110
+
111
+ `find_numbers` scans free text, case-insensitively, and returns the longest valid
112
+ reading at each position. Words that are also everyday words are reported as numbers:
113
+ `no one` contains 1 and `just a second` contains the ordinal 2. A comma-separated list
114
+ can merge when the words also form one number (`sixty, five, four` gives 65 and 4), and
115
+ dotted strings such as version numbers are read as decimals. Filter by `kind` or by
116
+ context when that matters. A decimal or fraction too large for a `float` makes
117
+ `number` raise `WordsNumError`; `value` always holds the exact result.
118
+
119
+ ## Unsupported forms
120
+
121
+ These are rejected rather than guessed:
122
+
123
+ - `dozen`, `score`, `gross`, `half a million`, `one and a half million`.
124
+ - Year style without `hundred`: `nineteen eighty four` (write `nineteen hundred eighty
125
+ four`); `find_numbers` reports it as 19 and 84.
126
+ - Long-scale meanings (British `billion` as 10**12) and scales above vigintillion.
127
+ - Spelling-error correction of any kind.
128
+ - Roman numerals and other languages.
129
+ - Spelling non-integers with `num_to_words`.
130
+
131
+ ## Command line
132
+
133
+ ```
134
+ wordsnum parse WORDS... [--strict] [--exact] [--json]
135
+ wordsnum find [--text TEXT] [--exact] [--json] # reads stdin without --text
136
+ wordsnum say NUMBER [--ordinal] [--british]
137
+ ```
138
+
139
+ Without words, `parse` reads one number per non-empty stdin line. `--exact` prints
140
+ `3/4` and `1.1` instead of floats. Errors are printed to stderr and the exit status
141
+ is 1.
@@ -0,0 +1,42 @@
1
+ # Improvement round: ranked plan
2
+
3
+ Ranked by value to users. Every item keeps the existing API working; new names are added
4
+ alongside `words_to_num` and `WordsNumError`.
5
+
6
+ 1. **A real grammar instead of a running sum.** The 0.1 parser added words together, so
7
+ `"one two"` gave 3 and `"hundred thousand hundred"` gave a number. Replace it with a
8
+ grammar that checks word order: groups below a thousand, scales in strictly
9
+ decreasing order, `"and"` only where British usage puts it, `"zero"` only on its own.
10
+ 2. **Scales to vigintillion.** Short scale from thousand up to vigintillion (10**63),
11
+ with ordinal forms (`"millionth"`, `"decillionth"`). Covers "at least decillion".
12
+ 3. **Fractions.** `"three quarters"`, `"a half"`, `"two thirds"`, `"seven hundredths"`,
13
+ `"three thirty-seconds"`, and mixed numbers (`"two and three quarters"`).
14
+ 4. **Exact results.** `words_to_fraction` and `words_to_decimal` return `Fraction` and
15
+ `Decimal`, so `"one point one"` is exactly 11/10 and never a binary float.
16
+ 5. **Typed errors that point at the bad word.** `UnknownWordError` (not a number word)
17
+ and `MisplacedWordError` (a number word in the wrong place), both carrying `word`,
18
+ `start` and `end` offsets into the original text.
19
+ 6. **Strict and lenient modes.** Lenient (default) tolerates case, extra spacing,
20
+ hyphens and commas. Strict accepts only the canonical lowercase spelling. Neither
21
+ mode guesses typos.
22
+ 7. **Mixed digits and words.** `"3 million"`, `"1.5 billion"`, `"2,500"`, `"21st"`.
23
+ 8. **Decimals with a scale.** `"two point five million"`, and `"oh"` as a digit after
24
+ the point (`"one point oh five"`).
25
+ 9. **Finding numbers inside text.** `find_numbers` returns every number phrase in free
26
+ text with its span, value and kind, using the longest valid reading.
27
+ 10. **A structured result.** `parse` returns a `ParsedNumber` with the exact value, the
28
+ kind (`cardinal`, `ordinal`, `decimal`, `fraction`) and the source span.
29
+ 11. **The inverse for integers.** `num_to_words` spells an int (cardinal or ordinal,
30
+ American or British) in exactly the canonical form strict mode accepts, so the two
31
+ directions round-trip.
32
+ 12. **A command line tool.** `wordsnum parse`, `wordsnum find` (reads stdin) and
33
+ `wordsnum say`, with JSON output and errors on stderr.
34
+ 13. **Documentation.** Docstrings on every public name and a GUIDE covering the grammar,
35
+ the modes, the supported and unsupported forms.
36
+ 14. **Tests as contracts.** A contract test over every public promise, a seeded
37
+ round-trip against an independent spelling generator on thousands of numbers, and
38
+ an end-to-end test that builds the wheel, installs it into a fresh virtual
39
+ environment and drives the installed command.
40
+
41
+ Out of scope, documented as unsupported: "dozen", "score", "half a million", long-scale
42
+ billions, Roman numerals, spelling of non-integers, and guessing misspelled words.
wordsnum-0.2.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Javier Lianes
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -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/
@@ -0,0 +1,127 @@
1
+ # wordsnum
2
+
3
+ Parse spelled-out English numbers into int or float, fully typed and dependency-free.
4
+
5
+ ## Install
6
+
7
+ ```
8
+ pip install wordsnum
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```python
14
+ from wordsnum import words_to_num
15
+
16
+ words_to_num("two hundred fifty") # 250
17
+ words_to_num("two thousand and twenty-one") # 2021
18
+ words_to_num("nineteen hundred eighty four") # 1984
19
+ words_to_num("a hundred") # 100
20
+ words_to_num("minus forty two") # -42
21
+ words_to_num("twenty-first") # 21 (ordinals)
22
+ words_to_num("three point one four") # 3.14
23
+ words_to_num("two point five million") # 2500000.0
24
+ words_to_num("two and three quarters") # 2.75
25
+ words_to_num("3 million") # 3000000
26
+ words_to_num("one decillion") # 10**33
27
+ ```
28
+
29
+ Cardinals and ordinals come back as `int`; decimals and fractions as `float`.
30
+
31
+ ### Exact values, kinds and spans
32
+
33
+ ```python
34
+ from wordsnum import parse, words_to_decimal, words_to_fraction
35
+
36
+ words_to_fraction("two thirds") # Fraction(2, 3)
37
+ words_to_decimal("one point one") # Decimal('1.1')
38
+
39
+ result = parse(" Seven Hundredths ")
40
+ result.value # Fraction(7, 100)
41
+ result.kind # 'fraction' (also 'cardinal', 'ordinal', 'decimal')
42
+ result.text, result.start, result.end # ('Seven Hundredths', 2, 18)
43
+ ```
44
+
45
+ ### Numbers inside text
46
+
47
+ ```python
48
+ from wordsnum import find_numbers
49
+
50
+ for match in find_numbers("We sold twenty-one boxes, then 3 million more."):
51
+ print(match.start, match.end, match.number, match.text)
52
+ # 8 18 21 twenty-one
53
+ # 31 40 3000000 3 million
54
+ ```
55
+
56
+ ### Spelling integers
57
+
58
+ ```python
59
+ from wordsnum import num_to_words
60
+
61
+ num_to_words(1234) # 'one thousand two hundred thirty-four'
62
+ num_to_words(1005, british=True) # 'one thousand and five'
63
+ num_to_words(21, ordinal=True) # 'twenty-first'
64
+ ```
65
+
66
+ `num_to_words` writes exactly the canonical form that strict parsing accepts, so the
67
+ two directions round-trip.
68
+
69
+ ### Strict and lenient
70
+
71
+ By default case, extra spaces, hyphens and commas are tolerated. `strict=True` accepts
72
+ only the canonical spelling (lowercase, single spaces, `twenty-one` hyphenated, no
73
+ commas). Neither mode guesses typos: `"fourty"` is always an error.
74
+
75
+ ### Errors that point at the problem
76
+
77
+ ```python
78
+ from wordsnum import MisplacedWordError, UnknownWordError, words_to_num
79
+
80
+ try:
81
+ words_to_num("two hundred banana")
82
+ except UnknownWordError as error:
83
+ print(error.word, error.start, error.end) # banana 12 18
84
+
85
+ try:
86
+ words_to_num("one thousand one million")
87
+ except MisplacedWordError as error:
88
+ print(error) # scale words must get smaller from left to right: 'million' at position 17
89
+ ```
90
+
91
+ Both subclass `WordsNumError`, which subclasses `ValueError`.
92
+
93
+ ## Command line
94
+
95
+ ```
96
+ wordsnum parse two thousand and twenty-one # 2021
97
+ wordsnum parse three quarters --exact # 3/4
98
+ echo "minus forty two" | wordsnum parse # -42
99
+ echo "I owe you twenty-one pounds" | wordsnum find
100
+ wordsnum say 21 --ordinal # twenty-first
101
+ ```
102
+
103
+ Run `wordsnum --help` for every option. Errors go to stderr with exit status 1.
104
+
105
+ See [GUIDE.md](GUIDE.md) for the full grammar and the list of unsupported forms.
106
+
107
+ ## Why
108
+
109
+ Turning words back into numbers is the reverse of spelling them out, and the existing
110
+ options are thin. `word2number` has not seen a release since 2017, ships no type hints,
111
+ and mishandles several common phrasings. `numerizer` is a port of a Ruby gem, is
112
+ untyped, and returns strings rather than numbers. `text2num` is maintained but builds
113
+ on a Rust extension, which is a heavy dependency for a small parsing job.
114
+
115
+ `wordsnum` is a small, pure-Python parser with zero runtime dependencies, a `py.typed`
116
+ marker, a clean `mypy --strict` build, a real grammar that rejects malformed input
117
+ instead of summing words, and exact results when you need them.
118
+
119
+ ## License
120
+
121
+ MIT. See [LICENSE](LICENSE).
122
+
123
+ ## Links
124
+
125
+ - Website: https://lianes8studio.com
126
+ - GitHub: https://github.com/JaviLianes8
127
+ - LinkedIn: https://www.linkedin.com/in/jlianes/
@@ -0,0 +1,54 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "wordsnum"
7
+ version = "0.2.0"
8
+ description = "Parse spelled-out English numbers into int or float, fully typed and dependency-free"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Javier Lianes", email = "javier@lianes8studio.com" }]
14
+ keywords = []
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3",
19
+ "Typing :: Typed",
20
+ ]
21
+ dependencies = []
22
+
23
+ [project.optional-dependencies]
24
+ dev = ["ruff", "mypy", "pytest", "build", "twine"]
25
+
26
+ [project.scripts]
27
+ wordsnum = "wordsnum._cli:main"
28
+
29
+ [project.urls]
30
+ Homepage = "https://lianes8studio.com"
31
+ Repository = "https://github.com/JaviLianes8/wordsnum"
32
+ Issues = "https://github.com/JaviLianes8/wordsnum/issues"
33
+ Changelog = "https://github.com/JaviLianes8/wordsnum/blob/main/CHANGELOG.md"
34
+ LinkedIn = "https://www.linkedin.com/in/jlianes/"
35
+ Funding = "https://buymeacoffee.com/jlianesglrs"
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["src/wordsnum"]
39
+
40
+ [tool.ruff]
41
+ line-length = 100
42
+ target-version = "py310"
43
+
44
+ [tool.ruff.lint]
45
+ select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
46
+
47
+ [tool.mypy]
48
+ python_version = "3.10"
49
+ strict = true
50
+
51
+ [tool.pytest.ini_options]
52
+ testpaths = ["tests"]
53
+ pythonpath = ["src"]
54
+ addopts = "-q"
@@ -0,0 +1,3 @@
1
+ # Development dependencies: the package in editable mode plus its dev extras
2
+ # (ruff, mypy, pytest, build, twine). Install with: pip install -r requirements-dev.txt
3
+ -e .[dev]
@@ -0,0 +1,11 @@
1
+ # Local quality gate. Run before every commit. No CI runs in the cloud.
2
+ $ErrorActionPreference = "Stop"
3
+
4
+ ruff format --check .
5
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
6
+ ruff check .
7
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
8
+ mypy --strict src
9
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
10
+ pytest
11
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
@@ -0,0 +1,28 @@
1
+ # Build and upload to PyPI. Run scripts/check.ps1 first.
2
+ # The name must not already exist on PyPI; the token is read from a secret store, never git.
3
+ $ErrorActionPreference = "Stop"
4
+
5
+ # Works on both Windows PowerShell 5.1 and pwsh 7 (generic catch, no PS7-only types).
6
+ $taken = $true
7
+ try {
8
+ Invoke-WebRequest -UseBasicParsing "https://pypi.org/pypi/wordsnum/json" | Out-Null
9
+ } catch {
10
+ if ($_.Exception.Response.StatusCode.value__ -eq 404) { $taken = $false } else { throw }
11
+ }
12
+ if ($taken) { throw "The name 'wordsnum' already exists on PyPI. Choose another before publishing." }
13
+
14
+ $token = $env:PYPI_TOKEN
15
+ if (-not $token -and (Test-Path "C:\secrets\pypi-token.txt")) {
16
+ $token = (Get-Content "C:\secrets\pypi-token.txt" -Raw).Trim()
17
+ }
18
+ if (-not $token) {
19
+ throw "No PyPI token. Set `$env:PYPI_TOKEN or save it to C:\secrets\pypi-token.txt (never commit it)."
20
+ }
21
+
22
+ Remove-Item -Recurse -Force dist -ErrorAction SilentlyContinue
23
+ python -m build
24
+ python -m twine check dist/*
25
+
26
+ $env:TWINE_USERNAME = "__token__"
27
+ $env:TWINE_PASSWORD = $token
28
+ try { python -m twine upload dist/* } finally { Remove-Item Env:\TWINE_PASSWORD -ErrorAction SilentlyContinue }