Skip to content

Commit 3498d69

Browse files
authored
refactor: thin num2words2 Python binder — move all logic to Rust (#127)
num2words2/ is now a pure thin binder like words2num2/: every public function is a `return _RUST.x(...)` pass-through, with no conversion/dispatch/presentation logic left in Python. Moved into num2words2-core (new pyo3-free `presentation.rs`, so the crate still publishes standalone) + the num2words2-py binder: - `_normalize_lang` -> `presentation::resolve_lang` (locale resolution). - `_apply_style` -> `presentation::apply_style` (style=terse/us output post-processing). - `_normalize_cents` -> `presentation::normalize_cents` (cents kwarg handling). - `_rust_kw_items` kwarg marshalling -> the binder (`extras_to_kwargs`). - the whole `num2words`/`num2words_sentence` dispatch pipeline -> `#[pyfunction]`s (type inspection + raw-lang resolve + style, sharing the core converters). - grouping.py: drop the pure-Python western/indian/chinese fallback; it's now a pass-through to `_RUST.group_digits` (which raises the same ValueError/TypeError). __init__.py 300 -> 77 lines. num2words2-core 0.1.0 -> 0.1.1 (published). Verified: cargo test 579; unittest OK (2780, 33 expected-failures, exit 0); E2E 16/16; coverage source 84% / tests 98%; flake8 0; isort clean. Output parity preserved (style/cents/lang-resolve produce identical results).
1 parent 050c4b5 commit 3498d69

8 files changed

Lines changed: 734 additions & 355 deletions

File tree

num2words2/__init__.py

Lines changed: 16 additions & 238 deletions
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,16 @@
1616
# MA 02110-1301 USA
1717
"""num2words2 — a thin Python binder over the Rust conversion core.
1818
19-
Every conversion is served by the compiled `_rust` extension: this module
20-
normalises the language code, maps the public keyword arguments onto the
21-
core's entry points, and applies the two string-level post-processing steps
22-
(`style=`, `cents=`) that are presentation, not conversion. There is no
23-
pure-Python conversion fallback — the core is authoritative, and an input it
24-
declines raises rather than silently diverging.
19+
Every conversion, and every piece of presentation logic that used to live
20+
here (language-code resolution, the ``style=`` post-processing, the ``cents=``
21+
mode mapping and the whole type dispatch), is now served by the compiled
22+
``_rust`` extension. This module is a pass-through: it re-exports the public
23+
entry points and the exception types, nothing more.
2524
"""
2625
from __future__ import unicode_literals
2726

28-
import decimal
29-
30-
from .grouping import group_digits # noqa: E402
27+
from . import _rust as _RUST
28+
from .grouping import group_digits # noqa: F401 (re-exported)
3129

3230
# Version information
3331
try:
@@ -37,21 +35,11 @@
3735
__version__ = "unknown"
3836
__version_tuple__ = (0, 0, 0, "unknown", 0)
3937

40-
41-
# The compiled core is mandatory: this package is a binder over it.
42-
from . import _rust as _RUST # noqa: E402
43-
44-
_RUST_LANGS = frozenset(_RUST.supported_langs())
45-
_RUST_TYPES = frozenset(["cardinal", "ordinal", "ordinal_num", "year"])
46-
# The core's "declined" signal (see rust/num2words2-py/src/lib.rs). It is
47-
# distinct from NotImplementedError so a genuine NotImplementedError raise
48-
# propagates natively; here, with no Python conversion path behind it, a
49-
# decline is surfaced as NotImplementedError to the caller.
50-
_RUST_FALLBACK = _RUST.RustFallback
51-
52-
# bn raises NumberTooLargeError past its MAX_NUMBER. The class used to live in
53-
# lang_BN.py; the pure binder defines it in the core and re-exports it here so
54-
# `from num2words2 import NumberTooLargeError` (and `except`) keep working.
38+
# Exception types defined in the compiled core and re-exported so
39+
# ``from num2words2 import NumberTooLargeError`` (and ``except`` on it) keep
40+
# working. RustFallback is the core's "declined" signal, kept importable for
41+
# the same reason.
42+
RustFallback = _RUST.RustFallback
5543
NumberTooLargeError = _RUST.NumberTooLargeError
5644

5745

@@ -72,226 +60,16 @@
7260
CONVERTER_TYPES = CONVERTES_TYPES # Alias for compatibility
7361

7462

75-
def maxval(lang="en"):
76-
"""Return the maximum integer ``num2words(..., lang=lang)`` can convert.
77-
78-
Issue #582 ports savoirfairelinux/num2words#582.
79-
"""
80-
return _RUST.maxval(lang)
81-
82-
83-
def _normalize_lang(lang):
84-
"""Resolve a caller's language code to a core key, mirroring the historic
85-
dispatcher: exact match, then hyphen->underscore, then ``xx_YY`` casing,
86-
then the bare two-letter prefix. Raises NotImplementedError if none match.
87-
"""
88-
if lang in _RUST_LANGS:
89-
return lang
90-
nl = lang.replace("-", "_")
91-
if nl in _RUST_LANGS:
92-
return nl
93-
parts = nl.split("_")
94-
if len(parts) >= 2:
95-
candidate = "%s_%s" % (parts[0].lower(), parts[1].upper())
96-
if candidate in _RUST_LANGS:
97-
return candidate
98-
if parts[0] in _RUST_LANGS:
99-
return parts[0]
100-
if nl[:2] in _RUST_LANGS:
101-
return nl[:2]
102-
raise NotImplementedError()
103-
104-
105-
def _rust_kw_items(kwargs):
106-
"""kwargs -> [(k, v)] for the core boundary, or None when a value has a
107-
type the boundary cannot carry (then the call is out of the core's
108-
envelope and must raise)."""
109-
items = []
110-
for k, v in kwargs.items():
111-
if v is None or isinstance(v, (bool, int, str)):
112-
items.append((k, v))
113-
elif isinstance(v, (list, tuple)) and all(
114-
isinstance(x, str) for x in v):
115-
items.append((k, list(v)))
116-
else:
117-
return None
118-
return items
119-
120-
121-
def _apply_style(result, style, to, lang):
122-
"""`style=` presentation post-processing (issues #535, #562). Operates on
123-
the rendered string, so it is conversion-independent."""
124-
if style == "terse" and to == "ordinal" and isinstance(result, str):
125-
for prefix in ("one ", "un ", "uno "):
126-
if result.startswith(prefix) and len(result) > len(prefix):
127-
result = result[len(prefix):]
128-
break
129-
if style == "us" and lang.startswith("en") and isinstance(result, str):
130-
result = result.replace(" and ", " ")
131-
return result
132-
133-
134-
def _normalize_cents(kwargs):
135-
"""`cents='omit'|'verbose'|'terse'` -> the legacy bool the core expects
136-
(issue #554). Returns (cents_bool, drop_cents) where drop_cents means the
137-
float value should be truncated to an int so no cents segment appears."""
138-
cents_kw = kwargs.get("cents", True)
139-
if cents_kw == "omit":
140-
return True, True
141-
if cents_kw == "verbose":
142-
return True, False
143-
if cents_kw == "terse":
144-
return False, False
145-
return cents_kw, False
146-
147-
14863
def num2words(number, ordinal=False, lang="en", to="cardinal", **kwargs):
149-
# Captured before any normalisation: the core keys off the *arrival* type
150-
# (a plain int vs a float/Decimal vs a str), not a post-parse value.
151-
_plain_int = type(number) is int
152-
_plain_num = isinstance(number, (float, decimal.Decimal))
153-
154-
lang = _normalize_lang(lang)
155-
_style = kwargs.get("style")
156-
157-
# ---- string input: the core's from_string owns the whole pipeline
158-
# (fraction strings, str_to_number incl. the ES "1ro" / pt_BR "ponto"
159-
# handshakes, mixed text -> sentence, then the mode dispatch).
160-
if isinstance(number, str):
161-
_to_final = "ordinal" if ordinal else to
162-
if _to_final not in CONVERTES_TYPES:
163-
raise NotImplementedError()
164-
_cents, _ = _normalize_cents(kwargs)
165-
_extras = {k: v for k, v in kwargs.items()
166-
if k not in ("currency", "cents", "separator",
167-
"adjective", "style", "precision")}
168-
_items = _rust_kw_items(_extras)
169-
if _cents not in (True, False) or _items is None:
170-
raise NotImplementedError()
171-
try:
172-
_kind, _out = _RUST.from_string(
173-
lang, number, _to_final, kwargs.get("currency"), _cents,
174-
kwargs.get("separator"), kwargs.get("adjective"), _items)
175-
except _RUST_FALLBACK:
176-
raise NotImplementedError()
177-
if _kind != 0:
178-
raise NotImplementedError()
179-
return _apply_style(_out, _style, _to_final, lang)
180-
181-
# backwards compatible
182-
if ordinal:
183-
to = "ordinal"
184-
if to not in CONVERTES_TYPES:
185-
raise NotImplementedError()
186-
187-
_precision = kwargs.get("precision")
188-
_extras = {k: v for k, v in kwargs.items()
189-
if k not in ("style", "precision")}
190-
191-
# ---- integer modes with a plain int
192-
if _plain_int and to in _RUST_TYPES:
193-
_items = _rust_kw_items(_extras)
194-
if _items is not None:
195-
try:
196-
if _items:
197-
result = getattr(_RUST, "to_%s_kw" % to)(
198-
lang, number, _items)
199-
else:
200-
result = getattr(_RUST, "to_%s" % to)(lang, number)
201-
except _RUST_FALLBACK:
202-
raise NotImplementedError()
203-
return _apply_style(result, _style, to, lang)
204-
205-
# ---- float / Decimal, all four integer modes
206-
if _plain_num and to in _RUST_TYPES:
207-
try:
208-
_finite = float(number) == float(number) and float(
209-
number) not in (float("inf"), float("-inf"))
210-
except (OverflowError, ValueError):
211-
_finite = False
212-
_fitems = {k: v for k, v in _extras.items()
213-
if k not in ("currency", "cents", "separator", "adjective")}
214-
_items = _rust_kw_items(_fitems)
215-
if _finite and _items is not None:
216-
_prec = abs(decimal.Decimal(str(number)).as_tuple().exponent)
217-
_dec = str(number) if isinstance(number, decimal.Decimal) else ""
218-
try:
219-
result = _RUST.to_float(
220-
lang, to, float(number), _prec, _dec, str(number),
221-
_precision, _items)
222-
except _RUST_FALLBACK:
223-
raise NotImplementedError()
224-
return _apply_style(result, _style, to, lang)
225-
226-
# ---- currency
227-
if to == "currency" and isinstance(number, (int, float, decimal.Decimal)):
228-
_cents, _drop = _normalize_cents(kwargs)
229-
if _drop and isinstance(number, float):
230-
number = int(number) # int path drops cents naturally
231-
if _cents in (True, False):
232-
_citems = {k: v for k, v in _extras.items()
233-
if k not in ("currency", "cents", "separator",
234-
"adjective")}
235-
_items = _rust_kw_items(_citems)
236-
if _items is not None:
237-
_args = (
238-
lang, str(number), type(number) is int,
239-
isinstance(number, float) or "." in str(number),
240-
isinstance(number, float),
241-
kwargs.get("currency"), _cents,
242-
kwargs.get("separator"), kwargs.get("adjective"),
243-
)
244-
try:
245-
if _items:
246-
return _RUST.to_currency_kw(*_args, _items)
247-
return _RUST.to_currency(*_args)
248-
except _RUST_FALLBACK:
249-
raise NotImplementedError()
250-
251-
if to == "cheque" and isinstance(number, (int, float, decimal.Decimal)):
252-
try:
253-
return _RUST.to_cheque(lang, str(number), kwargs.get("currency"))
254-
except _RUST_FALLBACK:
255-
raise NotImplementedError()
256-
257-
# to='fraction' with a non-string number: the historic dispatcher fed the
258-
# value straight to converter.to_fraction(value). A single positional (the
259-
# tuple ``(1, 2)``) raised TypeError where the method exists, or
260-
# AttributeError where it does not (bn/dv/id have no to_fraction). Probe
261-
# the core to tell them apart; genuine "n/d" fractions arrive as strings
262-
# and are served by from_string above.
263-
if to == "fraction":
264-
try:
265-
_RUST.to_fraction(lang, 1, 1)
266-
except AttributeError:
267-
raise
268-
except Exception: # noqa: BLE001 - probing for the method's existence
269-
pass
270-
raise TypeError(
271-
"to_fraction() missing 1 required positional argument: "
272-
"'denominator'")
273-
274-
raise NotImplementedError()
64+
return _RUST.num2words(number, ordinal, lang, to, **kwargs)
27565

27666

27767
def num2words_sentence(sentence, lang="en", to="cardinal", **kwargs):
278-
"""Convert every number in a sentence to words.
68+
return _RUST.num2words_sentence(sentence, lang, to, **kwargs)
27969

280-
`lang=None` auto-detects (lingua-rs in the full build). Handles ordinals,
281-
currency, dates, temperatures and plain numbers, splicing each conversion
282-
back in place.
28370

284-
>>> num2words_sentence("I bought 6 apples")
285-
'I bought six apples'
286-
>>> num2words_sentence("The 1st place winner got $100")
287-
'The first place winner got one hundred dollars, zero cents'
288-
"""
289-
if lang is None:
290-
return _RUST.sentence_auto(sentence, to)
291-
# The core's sentence converter does its own lang validation (and the
292-
# same two-letter-prefix fallback), raising NotImplementedError for an
293-
# unsupported language exactly as the historic dispatcher did.
294-
return _RUST.sentence(sentence, lang, to)
71+
def maxval(lang="en"):
72+
return _RUST.maxval(lang)
29573

29674

29775
# Aliases for num2words_sentence

num2words2/grouping.py

Lines changed: 5 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
# -*- coding: utf-8 -*-
22
# Copyright (c) 2026, num2words2 contributors. All Rights Reserved.
33
# Licensed under LGPL-2.1 (see COPYING).
4-
"""Number-string grouping helpers.
4+
"""Number-string grouping — thin binder over the Rust core.
55
66
Some locales group digits in patterns other than the standard ``,###``
7-
Western convention. This module exposes a single helper, :func:`group_digits`,
8-
that returns the locale-grouped form of an integer.
7+
Western convention. :func:`group_digits` returns the locale-grouped form of an
8+
integer; all logic lives in the ``num2words2._rust`` extension.
99
1010
Currently supported groupings:
1111
@@ -18,12 +18,7 @@
1818

1919
from __future__ import unicode_literals
2020

21-
# Independent of the package __init__ (no circular import): the extension
22-
# module is importable on its own. Absent -> pure-Python behaviour.
23-
try:
24-
from . import _rust as _RUST
25-
except ImportError: # pragma: no cover - depends on build
26-
_RUST = None
21+
from . import _rust as _RUST
2722

2823

2924
def group_digits(value, locale="western", separator=","):
@@ -33,38 +28,9 @@ def group_digits(value, locale="western", separator=","):
3328
--------
3429
>>> group_digits(100000, locale="indian")
3530
'1,00,000'
36-
>>> group_digits(12345678, locale="indian")
37-
'1,23,45,678'
3831
>>> group_digits(1234567, locale="western")
3932
'1,234,567'
4033
>>> group_digits(12345678, locale="chinese")
4134
'1234,5678'
4235
"""
43-
if not isinstance(value, int):
44-
raise TypeError("group_digits requires an int, got %r" % type(value))
45-
# The isinstance check stays here: its TypeError message needs Python's
46-
# %r of the type object. Everything after it is pure string work.
47-
if _RUST is not None:
48-
return _RUST.group_digits(value, locale, separator)
49-
sign = "-" if value < 0 else ""
50-
s = str(abs(value))
51-
52-
if locale == "western":
53-
return sign + _group(s, 3, separator)
54-
if locale == "indian":
55-
# Last three digits, then groups of two on the high side.
56-
if len(s) <= 3:
57-
return sign + s
58-
last3, rest = s[-3:], s[:-3]
59-
return sign + _group(rest, 2, separator) + separator + last3
60-
if locale == "chinese":
61-
return sign + _group(s, 4, separator)
62-
raise ValueError("Unknown locale: %r" % locale)
63-
64-
65-
def _group(s, size, separator):
66-
out = []
67-
while s:
68-
out.append(s[-size:])
69-
s = s[:-size]
70-
return separator.join(reversed(out))
36+
return _RUST.group_digits(value, locale, separator)

rust/Cargo.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

rust/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ members = ["num2words2-core", "num2words2-py"]
33
resolver = "2"
44

55
[workspace.package]
6-
version = "0.1.0"
6+
version = "0.1.1"
77
edition = "2021"
88
license = "MIT OR Apache-2.0"
99
repository = "https://github.com/jqueguiner/num2words2"

rust/num2words2-core/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
pub mod base;
55
pub mod currency;
66
pub mod floatpath;
7+
pub mod presentation;
78
pub mod strnum;
89
pub mod lang_af;
910
pub mod lang_am;

0 commit comments

Comments
 (0)