Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions rust/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion rust/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ members = ["num2words2-core", "words2num2-core", "words2num2-py"]
resolver = "2"

[workspace.package]
version = "0.1.0"
version = "0.1.1"
edition = "2021"
license = "LGPL-2.1-only"
repository = "https://github.com/jqueguiner/words2num2"
Expand Down
5 changes: 5 additions & 0 deletions rust/words2num2-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ pub mod w2n_formats;
pub mod w2n_lang_en;
pub mod w2n_sentence;

/// The public single-token entry point (`words2num2.words2num`), re-exported at
/// the crate root. Its dispatch — `_resolve_lang`, the en-vs-reverse-table
/// choice, and the `to` mode selection — lives in [`w2n_sentence`].
pub use w2n_sentence::words2num;

/// Python's `Words2Num_Base.LOOKUP_RANGE`.
const LOOKUP_LO: i64 = -1;
const LOOKUP_HI: i64 = 10001;
Expand Down
17 changes: 17 additions & 0 deletions rust/words2num2-core/src/w2n_lang_en.rs
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,23 @@ impl PyDec {
// BigDecimal's scale is the negated exponent.
BigDecimal::new(signed, -self.exp)
}

/// The inverse of [`to_bigdecimal`] — build a `PyDec` from a `BigDecimal`.
///
/// `BigDecimal` has no signed zero, so a value it carries always maps to a
/// non-negative-zero `PyDec`; that is exactly right here, because the only
/// caller feeds it reverse-table results (`int`/`float` promoted through a
/// `Dec` arm), never a genuine grammar-produced signed zero.
pub fn from_bigdecimal(d: &BigDecimal) -> PyDec {
let (coeff, scale) = d.as_bigint_and_exponent();
let neg = coeff.sign() == Sign::Minus;
let coeff = if neg { -coeff } else { coeff };
PyDec {
neg,
coeff,
exp: -scale,
}
}
}

/// Port of `Decimal.__str__` (the spec's *to-scientific-string*) with the
Expand Down
75 changes: 75 additions & 0 deletions rust/words2num2-core/src/w2n_sentence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -779,6 +779,81 @@ fn call_words2num(text: &str, lang: &str) -> Result<W2nValue, W2nError> {
converter_for(&resolved).to_cardinal(text)
}

/// Port of the public `words2num2.words2num(text, lang, to)` — the single-token
/// entry point, dispatch and all.
///
/// ```python
/// def words2num(text, lang="en", to="cardinal", **kwargs):
/// resolved = _resolve_lang(lang)
/// converter = CONVERTER_CLASSES[resolved]
/// if to not in CONVERTER_TYPES:
/// raise NotImplementedError("conversion type %r unsupported" % to)
/// return getattr(converter, "to_{}".format(to))(text, **kwargs)
/// ```
///
/// Returns the English grammar's [`crate::w2n_lang_en::W2nValue`] rather than
/// this module's [`W2nValue`], so the `en` decimal path keeps its `PyDec`
/// backing: a signed-zero decimal (`Decimal('-0.0')`) and the exact
/// 28-significant-digit `str()` both survive, neither of which `BigDecimal` can
/// carry. The 119 reverse-table locales only ever produce an `int` or `float`,
/// so mapping their result back through [`sentence_to_en_value`] is lossless.
pub fn words2num(
text: &str,
lang: &str,
to: &str,
) -> Result<crate::w2n_lang_en::W2nValue, W2nError> {
let resolved = resolve_lang(lang)?;
let converter = converter_for(&resolved);

// `CONVERTER_TYPES` in `words2num2/__init__.py`. An unknown `to` is a
// `NotImplementedError`, distinct from the reverse table declining a word.
const CONVERTER_TYPES: [&str; 5] = ["cardinal", "ordinal", "ordinal_num", "year", "currency"];
if !CONVERTER_TYPES.contains(&to) {
return Err(W2nError::NotImplemented(format!(
"conversion type {} unsupported",
py_repr_str(to)
)));
}

// `getattr(converter, "to_{to}")(text)`. The English grammar path returns
// its native value directly; every other path is `int`/`float` and is
// promoted to the English value type.
match &converter {
Converter::En => match to {
// `Words2Num_Base.to_currency` == `self.to_cardinal` (polymorphic).
"cardinal" | "currency" => {
crate::en_to_cardinal(text).map_err(|e| W2nError::Words2Num(e.msg))
}
"ordinal" => crate::en_to_ordinal(text).map_err(|e| W2nError::Words2Num(e.msg)),
"year" => crate::en_to_year(text).map_err(|e| W2nError::Words2Num(e.msg)),
// `Words2Num_EN` inherits `Words2Num_Base.to_ordinal_num`.
_ => base_ordinal_num(text).map(sentence_to_en_value),
},
Converter::Table(_) => {
let v = match to {
// `Words2Num_Base.to_year`/`to_currency` == `self.to_cardinal`.
"cardinal" | "year" | "currency" => converter.to_cardinal(text),
"ordinal" => converter.to_ordinal(text),
_ => converter.to_ordinal_num(text),
}?;
Ok(sentence_to_en_value(v))
}
}
}

/// Promote a reverse-table / `ordinal_num` result into the English grammar's
/// value type. Those paths never yield a `Decimal`, so the `Dec` arm is
/// unreachable in practice; it is mapped losslessly (rather than panicked on —
/// the crate builds `panic = "abort"`) via [`crate::w2n_lang_en::PyDec`].
fn sentence_to_en_value(v: W2nValue) -> crate::w2n_lang_en::W2nValue {
use crate::w2n_lang_en::W2nValue as EnV;
match v {
W2nValue::Int(i) => EnV::Int(i),
W2nValue::Float(f) => EnV::Float(f),
W2nValue::Dec(d) => EnV::Dec(crate::w2n_lang_en::PyDec::from_bigdecimal(&d)),
}
}

// ===========================================================================
// `words2num_sentence` / `convert_sentence` / `sentence_to_words`
// ===========================================================================
Expand Down
28 changes: 28 additions & 0 deletions rust/words2num2-py/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,33 @@ fn parse_number_string(
}
}

/// `_rust.words2num(text, lang, to, kwargs)` — the public single-token entry.
///
/// Mirrors `words2num2.words2num(text, lang, to, **kwargs)`: the core owns the
/// whole dispatch (`_resolve_lang`, the en-vs-reverse-table choice, the `to`
/// mode selection). Python forwarded `**kwargs` straight to `to_<to>(text)`,
/// which accepts none, so any keyword argument is a `TypeError` — reproduced
/// here rather than passed down.
#[pyfunction]
#[pyo3(signature = (text, lang="en", to="cardinal", kwargs=None))]
fn words2num(
py: Python<'_>,
text: &str,
lang: &str,
to: &str,
kwargs: Option<Bound<'_, PyDict>>,
) -> PyResult<PyObject> {
if kwargs.as_ref().is_some_and(|d| !d.is_empty()) {
return Err(PyTypeError::new_err(
"words2num() got an unexpected keyword argument",
));
}
match words2num2_core::words2num(text, lang, to) {
Ok(v) => en_value_to_py(py, v),
Err(e) => Err(w2n_error_to_pyerr(py, e)),
}
}

/// `_rust.words2num_sentence(sentence, lang, to, kwargs)`.
#[pyfunction]
#[pyo3(signature = (sentence, lang="en", to="cardinal", kwargs=None))]
Expand Down Expand Up @@ -276,6 +303,7 @@ fn _rust(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(en_to_ordinal, m)?)?;
m.add_function(wrap_pyfunction!(en_to_year, m)?)?;
m.add_function(wrap_pyfunction!(parse_number_string, m)?)?;
m.add_function(wrap_pyfunction!(words2num, m)?)?;
m.add_function(wrap_pyfunction!(words2num_sentence, m)?)?;
m.add_function(wrap_pyfunction!(pluralize, m)?)?;
m.add_function(wrap_pyfunction!(auto_parse, m)?)?;
Expand Down
16 changes: 2 additions & 14 deletions tests/test_dispatch.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,8 @@
# -*- coding: utf-8 -*-
"""Tests for the locale dispatch."""
"""Tests for the locale dispatch (now entirely in the Rust core)."""
import pytest

from words2num2 import CONVERTER_CLASSES, words2num


def test_locale_count():
# 100+ locales registered.
assert len(CONVERTER_CLASSES) >= 100


def test_aliases():
assert "jp" in CONVERTER_CLASSES
assert "cn" in CONVERTER_CLASSES
assert type(CONVERTER_CLASSES["jp"]) is type(CONVERTER_CLASSES["ja"])
assert type(CONVERTER_CLASSES["cn"]) is type(CONVERTER_CLASSES["zh_CN"])
from words2num2 import words2num


def test_unknown_lang_raises():
Expand Down
Loading
Loading