From 465617354b1e5369f2600a2089162bc93e1f19be Mon Sep 17 00:00:00 2001 From: loudblow <145815114+loudblow@users.noreply.github.com> Date: Thu, 27 Nov 2025 23:39:08 +0400 Subject: [PATCH 1/3] runtime: add attribute formatting functionality in FluentLocalization --- fluent.runtime/docs/usage.rst | 19 ++++++++ fluent.runtime/fluent/runtime/__init__.py | 3 +- fluent.runtime/fluent/runtime/fallback.py | 35 ++++++++++++++ fluent.runtime/tests/test_fallback.py | 59 +++++++++++++++++++++-- 4 files changed, 110 insertions(+), 6 deletions(-) diff --git a/fluent.runtime/docs/usage.rst b/fluent.runtime/docs/usage.rst index c4ad16af..0df0a67f 100644 --- a/fluent.runtime/docs/usage.rst +++ b/fluent.runtime/docs/usage.rst @@ -272,6 +272,25 @@ instances to indicate an error or missing data. Otherwise they should return unicode strings, or instances of a ``FluentType`` subclass as above. +Attributes +~~~~~~~~~~ +When rendering UI elements, it's handy to have a single translation that +contains everything you need in one variable. For example, a Web +Component confirm window with an OK button, a Cancel button, and a +message. + +.. code-block:: python + >>> l10n = DemoLocalization(""" + ... order-cancel-window = Are you sure you want to cancel the order #{ $order }? + ... .ok = Yes + ... .cancel = No + """) + >>> message, attributes = l10n.format_message("order-cancel-window", {'order': 123}) + >>> message + 'Are you sure you want to cancel the order #123?' + >>> attributes + {'ok': 'Yes', 'cancel': 'No'} + Known limitations and bugs ~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/fluent.runtime/fluent/runtime/__init__.py b/fluent.runtime/fluent/runtime/__init__.py index a36d9578..beff3d29 100644 --- a/fluent.runtime/fluent/runtime/__init__.py +++ b/fluent.runtime/fluent/runtime/__init__.py @@ -2,7 +2,7 @@ from fluent.syntax.ast import Resource from .bundle import FluentBundle -from .fallback import AbstractResourceLoader, FluentLocalization, FluentResourceLoader +from .fallback import AbstractResourceLoader, FluentLocalization, FluentResourceLoader, FormattedMessage __all__ = [ "FluentLocalization", @@ -10,6 +10,7 @@ "FluentResourceLoader", "FluentResource", "FluentBundle", + "FormattedMessage", ] diff --git a/fluent.runtime/fluent/runtime/fallback.py b/fluent.runtime/fluent/runtime/fallback.py index f39bbf82..4fa3d315 100644 --- a/fluent.runtime/fluent/runtime/fallback.py +++ b/fluent.runtime/fluent/runtime/fallback.py @@ -13,6 +13,7 @@ ) from fluent.syntax import FluentParser +from typing_extensions import NamedTuple from .bundle import FluentBundle @@ -22,6 +23,11 @@ from .types import FluentType +class FormattedMessage(NamedTuple): + message: Union[str, None] + attributes: Dict[str, str] + + class FluentLocalization: """ Generic API for Fluent applications. @@ -48,6 +54,35 @@ def __init__( self._bundle_cache: List[FluentBundle] = [] self._bundle_it = self._iterate_bundles() + def format_message( + self, msg_id: str, args: Union[Dict[str, Any], None] = None + ) -> FormattedMessage: + for bundle in self._bundles(): + if not bundle.has_message(msg_id): + continue + msg = bundle.get_message(msg_id) + formatted_attrs = None + if msg.attributes: + formatted_attrs = { + attr: cast( + str, + bundle.format_pattern(msg.attributes[attr], args)[0], + ) + for attr in msg.attributes + } + if not msg.value and formatted_attrs is None: + continue + elif not msg.value and formatted_attrs: + val = None + else: + val, _errors = bundle.format_pattern(msg.value, args) + return FormattedMessage( + # Never FluentNone when format_pattern called externally + cast(str, val), + formatted_attrs if formatted_attrs else {}, + ) + return FormattedMessage(msg_id, {}) + def format_value( self, msg_id: str, args: Union[Dict[str, Any], None] = None ) -> str: diff --git a/fluent.runtime/tests/test_fallback.py b/fluent.runtime/tests/test_fallback.py index e1bc42fb..7192c022 100644 --- a/fluent.runtime/tests/test_fallback.py +++ b/fluent.runtime/tests/test_fallback.py @@ -12,11 +12,25 @@ def test_init(self): self.assertTrue(callable(l10n.format_value)) @patch_files({ - "de/one.ftl": "one = in German", - "de/two.ftl": "two = in German", - "fr/two.ftl": "three = in French", - "en/one.ftl": "four = exists", - "en/two.ftl": "five = exists", + "de/one.ftl": """one = in German + .foo = one in German + """, + "de/two.ftl": """two = in German + .foo = two in German + """, + "fr/two.ftl": """three = in French + .foo = three in French + """, + "en/one.ftl": """four = exists + .foo = four in English + """, + "en/two.ftl": """ +five = exists + .foo = five in English +bar = + .foo = bar in English +baz = baz in English + """, }) def test_bundles(self): l10n = FluentLocalization( @@ -39,6 +53,41 @@ def test_bundles(self): self.assertEqual(l10n.format_value("three"), "in French") self.assertEqual(l10n.format_value("four"), "exists") self.assertEqual(l10n.format_value("five"), "exists") + self.assertEqual(l10n.format_value("bar"), "bar") + self.assertEqual(l10n.format_value("baz"), "baz in English") + self.assertEqual(l10n.format_value("not-exists"), "not-exists") + self.assertEqual( + tuple(l10n.format_message("one")), + ("in German", {"foo": "one in German"}), + ) + self.assertEqual( + tuple(l10n.format_message("two")), + ("in German", {"foo": "two in German"}), + ) + self.assertEqual( + tuple(l10n.format_message("three")), + ("in French", {"foo": "three in French"}), + ) + self.assertEqual( + tuple(l10n.format_message("four")), + ("exists", {"foo": "four in English"}), + ) + self.assertEqual( + tuple(l10n.format_message("five")), + ("exists", {"foo": "five in English"}), + ) + self.assertEqual( + tuple(l10n.format_message("bar")), + (None, {"foo": "bar in English"}), + ) + self.assertEqual( + tuple(l10n.format_message("baz")), + ("baz in English", {}), + ) + self.assertEqual( + tuple(l10n.format_message("not-exists")), + ("not-exists", {}), + ) class TestResourceLoader(unittest.TestCase): From 226ee6b7445bed0c2b049038d5be527e465fef75 Mon Sep 17 00:00:00 2001 From: loudblow <145815114+loudblow@users.noreply.github.com> Date: Fri, 28 Nov 2025 15:39:07 +0400 Subject: [PATCH 2/3] runtime: remove unnecessary code --- fluent.runtime/fluent/runtime/fallback.py | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/fluent.runtime/fluent/runtime/fallback.py b/fluent.runtime/fluent/runtime/fallback.py index 4fa3d315..0029ebd7 100644 --- a/fluent.runtime/fluent/runtime/fallback.py +++ b/fluent.runtime/fluent/runtime/fallback.py @@ -61,25 +61,21 @@ def format_message( if not bundle.has_message(msg_id): continue msg = bundle.get_message(msg_id) - formatted_attrs = None - if msg.attributes: - formatted_attrs = { - attr: cast( - str, - bundle.format_pattern(msg.attributes[attr], args)[0], - ) - for attr in msg.attributes - } - if not msg.value and formatted_attrs is None: - continue - elif not msg.value and formatted_attrs: + formatted_attrs = { + attr: cast( + str, + bundle.format_pattern(msg.attributes[attr], args)[0], + ) + for attr in msg.attributes + } + if not msg.value: val = None else: val, _errors = bundle.format_pattern(msg.value, args) return FormattedMessage( # Never FluentNone when format_pattern called externally cast(str, val), - formatted_attrs if formatted_attrs else {}, + formatted_attrs, ) return FormattedMessage(msg_id, {}) From 076d05c7df92b552caab122d3a8f813ecb6d7e6b Mon Sep 17 00:00:00 2001 From: loudblow <145815114+loudblow@users.noreply.github.com> Date: Fri, 28 Nov 2025 15:49:34 +0400 Subject: [PATCH 3/3] runtime: update docs --- fluent.runtime/docs/usage.rst | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/fluent.runtime/docs/usage.rst b/fluent.runtime/docs/usage.rst index 0df0a67f..d01e7a0b 100644 --- a/fluent.runtime/docs/usage.rst +++ b/fluent.runtime/docs/usage.rst @@ -275,21 +275,35 @@ above. Attributes ~~~~~~~~~~ When rendering UI elements, it's handy to have a single translation that -contains everything you need in one variable. For example, a Web -Component confirm window with an OK button, a Cancel button, and a -message. +contains everything you need in one variable. For example, a HTML +form input may have a value, but also a placeholder attribute, aria-label +attribute, and maybe a title attribute. .. code-block:: python >>> l10n = DemoLocalization(""" - ... order-cancel-window = Are you sure you want to cancel the order #{ $order }? - ... .ok = Yes - ... .cancel = No + ... login-input = Predefined value + ... .placeholder = { $email } + ... .aria-label = Login input value + ... .title = Type your login email """) - >>> message, attributes = l10n.format_message("order-cancel-window", {'order': 123}) + >>> message, attributes = l10n.format_message( + "login-input", {"placeholder": "email@example.com"} + ) >>> message - 'Are you sure you want to cancel the order #123?' + 'Predefined value' >>> attributes - {'ok': 'Yes', 'cancel': 'No'} + {'placeholder': 'email@example.com', 'aria-label': 'Login input value', 'title': 'Type your login email'} + +You can also use the formatted message without unpacking it. + +.. code-block:: python + >>> fmt_msg = l10n.format_message( + "login-input", {"placeholder": "email@example.com"} + ) + >>> fmt_msg.message + 'Predefined value' + >>> fmt_msg.attributes + {'placeholder': 'email@example.com', 'aria-label': 'Login input value', 'title': 'Type your login email'} Known limitations and bugs ~~~~~~~~~~~~~~~~~~~~~~~~~~