diff --git a/fluent.runtime/docs/usage.rst b/fluent.runtime/docs/usage.rst index c4ad16af..d01e7a0b 100644 --- a/fluent.runtime/docs/usage.rst +++ b/fluent.runtime/docs/usage.rst @@ -272,6 +272,39 @@ 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 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(""" + ... login-input = Predefined value + ... .placeholder = { $email } + ... .aria-label = Login input value + ... .title = Type your login email + """) + >>> message, attributes = l10n.format_message( + "login-input", {"placeholder": "email@example.com"} + ) + >>> message + 'Predefined value' + >>> attributes + {'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 ~~~~~~~~~~~~~~~~~~~~~~~~~~ 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..0029ebd7 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,31 @@ 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 = { + 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, + ) + 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):