From 21617c756b2c54aed4464cf773dae6fb548c3c16 Mon Sep 17 00:00:00 2001 From: Cimbali Date: Wed, 18 Oct 2023 21:38:37 +0100 Subject: [PATCH] Support type parameter (ordinal/cardinal) in NUMBER() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The type option is simply ignored outside of a select expression, and allows to select based on CLDR plural categories for ordinals (which differ from normal plurals aka cardinal numbers). This is as per (part of) the docs, and per javascript implementation, see projectfluent/fluent#259 -- I haven’t found any definition of NUMBER() in the spec. --- fluent.runtime/fluent/runtime/bundle.py | 2 + fluent.runtime/fluent/runtime/resolver.py | 6 ++- fluent.runtime/fluent/runtime/types.py | 10 ++++ .../tests/format/test_primitives.py | 4 ++ .../tests/format/test_select_expression.py | 48 +++++++++++++++++++ fluent.runtime/tests/test_bundle.py | 15 ++++++ 6 files changed, 84 insertions(+), 1 deletion(-) diff --git a/fluent.runtime/fluent/runtime/bundle.py b/fluent.runtime/fluent/runtime/bundle.py index 45e32852..186a7be5 100644 --- a/fluent.runtime/fluent/runtime/bundle.py +++ b/fluent.runtime/fluent/runtime/bundle.py @@ -47,6 +47,8 @@ def __init__(self, self._babel_locale = self._get_babel_locale() self._plural_form = cast(Callable[[Any], Callable[[Union[int, float]], PluralCategory]], babel.plural.to_python)(self._babel_locale.plural_form) + self._ordinal_form = cast(Callable[[Any], Callable[[Union[int, float]], PluralCategory]], + babel.plural.to_python)(self._babel_locale.ordinal_form) def add_resource(self, resource: FTL.Resource, allow_overrides: bool = False) -> None: # TODO - warn/error about duplicates diff --git a/fluent.runtime/fluent/runtime/resolver.py b/fluent.runtime/fluent/runtime/resolver.py index e4d0caa5..7782e3a9 100644 --- a/fluent.runtime/fluent/runtime/resolver.py +++ b/fluent.runtime/fluent/runtime/resolver.py @@ -319,7 +319,11 @@ def match(val1: Any, val2: Any, env: ResolverEnvironment) -> bool: if is_number(val1): if not is_number(val2): # Could be plural rule match - return cast(bool, env.context._plural_form(val1) == val2) + if isinstance(val1, (FluentInt, FluentFloat)) and val1.options.type == 'ordinal': + val1_form = env.context._ordinal_form(val1) + else: + val1_form = env.context._plural_form(val1) + return cast(bool, val1_form == val2) elif is_number(val2): return match(val2, val1, env) diff --git a/fluent.runtime/fluent/runtime/types.py b/fluent.runtime/fluent/runtime/types.py index e57e653c..a27d6ec6 100644 --- a/fluent.runtime/fluent/runtime/types.py +++ b/fluent.runtime/fluent/runtime/types.py @@ -28,6 +28,13 @@ CURRENCY_DISPLAY_NAME, } +NUMBER_TYPE_ORDINAL = "ordinal" +NUMBER_TYPE_CARDINAL = "cardinal" +NUMBER_TYPE_OPTIONS = { + NUMBER_TYPE_ORDINAL, + NUMBER_TYPE_CARDINAL, +} + DATE_STYLE_OPTIONS = { "full", "long", @@ -71,6 +78,9 @@ class NumberFormatOptions: style: Literal['decimal', 'currency', 'percent'] = attr.ib( default=FORMAT_STYLE_DECIMAL, validator=attr.validators.in_(FORMAT_STYLE_OPTIONS)) + type: Literal['ordinal', 'cardinal'] = attr.ib( + default=NUMBER_TYPE_CARDINAL, + validator=attr.validators.in_(NUMBER_TYPE_OPTIONS)) currency: Union[str, None] = attr.ib(default=None) currencyDisplay: Literal['symbol', 'code', 'name'] = attr.ib( default=CURRENCY_DISPLAY_SYMBOL, diff --git a/fluent.runtime/tests/format/test_primitives.py b/fluent.runtime/tests/format/test_primitives.py index 5e183f40..b0bf83d3 100644 --- a/fluent.runtime/tests/format/test_primitives.py +++ b/fluent.runtime/tests/format/test_primitives.py @@ -128,6 +128,10 @@ def setUp(self): *[0] Zero [1] One } + position = { NUMBER(1, type: "ordinal") -> + *[other] Zero + [one] ${1}st + } """))) def test_int_number_used_in_placeable(self): diff --git a/fluent.runtime/tests/format/test_select_expression.py b/fluent.runtime/tests/format/test_select_expression.py index 0101da54..993a5c62 100644 --- a/fluent.runtime/tests/format/test_select_expression.py +++ b/fluent.runtime/tests/format/test_select_expression.py @@ -135,6 +135,18 @@ def setUp(self): [one] A *[other] B } + + count = { NUMBER($num, type: "cardinal") -> + *[other] B + [one] A + } + + order = { NUMBER($num, type: "ordinal") -> + *[other] {$num}th + [one] {$num}st + [two] {$num}nd + [few] {$num}rd + } """))) def test_selects_the_right_category(self): @@ -172,6 +184,42 @@ def test_with_argument_float(self): self.assertEqual(val, "A") self.assertEqual(len(errs), 0) + def test_with_cardinal_integer(self): + val, errs = self.bundle.format_pattern(self.bundle.get_message('count').value, {'num': 1}) + self.assertEqual(val, "A") + self.assertEqual(len(errs), 0) + + val, errs = self.bundle.format_pattern(self.bundle.get_message('count').value, {'num': 2}) + self.assertEqual(val, "B") + self.assertEqual(len(errs), 0) + + def test_with_cardinal_float(self): + val, errs = self.bundle.format_pattern(self.bundle.get_message('count').value, {'num': 1.0}) + self.assertEqual(val, "A") + self.assertEqual(len(errs), 0) + + def test_with_ordinal_integer(self): + val, errs = self.bundle.format_pattern(self.bundle.get_message('order').value, {'num': 1}) + self.assertEqual(val, "1st") + self.assertEqual(len(errs), 0) + + val, errs = self.bundle.format_pattern(self.bundle.get_message('order').value, {'num': 2}) + self.assertEqual(val, "2nd") + self.assertEqual(len(errs), 0) + + val, errs = self.bundle.format_pattern(self.bundle.get_message('order').value, {'num': 11}) + self.assertEqual(val, "11th") + self.assertEqual(len(errs), 0) + + val, errs = self.bundle.format_pattern(self.bundle.get_message('order').value, {'num': 21}) + self.assertEqual(val, "21st") + self.assertEqual(len(errs), 0) + + def test_with_ordinal_float(self): + val, errs = self.bundle.format_pattern(self.bundle.get_message('order').value, {'num': 1.0}) + self.assertEqual(val, "1st") + self.assertEqual(len(errs), 0) + class TestSelectExpressionWithTerms(unittest.TestCase): diff --git a/fluent.runtime/tests/test_bundle.py b/fluent.runtime/tests/test_bundle.py index 19bf9612..27946fb2 100644 --- a/fluent.runtime/tests/test_bundle.py +++ b/fluent.runtime/tests/test_bundle.py @@ -75,6 +75,21 @@ def test_plural_form_french(self): self.assertEqual(bundle._plural_form(2), 'other') + def test_ordinal_form_english_ints(self): + bundle = FluentBundle(['en-US']) + self.assertEqual(bundle._ordinal_form(0), + 'other') + self.assertEqual(bundle._ordinal_form(1), + 'one') + self.assertEqual(bundle._ordinal_form(2), + 'two') + self.assertEqual(bundle._ordinal_form(3), + 'few') + self.assertEqual(bundle._ordinal_form(11), + 'other') + self.assertEqual(bundle._ordinal_form(21), + 'one') + def test_format_args(self): self.bundle.add_resource(FluentResource('foo = Foo')) val, errs = self.bundle.format_pattern(self.bundle.get_message('foo').value)