diff --git a/.sampo/changesets/haughty-king-tuonetar.md b/.sampo/changesets/haughty-king-tuonetar.md new file mode 100644 index 0000000..cab9688 --- /dev/null +++ b/.sampo/changesets/haughty-king-tuonetar.md @@ -0,0 +1,5 @@ +--- +pypi/gt-i18n: patch +--- + +refactor: string registration interface diff --git a/Makefile b/Makefile index e61f916..2a38d2b 100644 --- a/Makefile +++ b/Makefile @@ -10,6 +10,7 @@ lint-fix: uv run ruff check --fix . format: + uv run ruff check --fix . uv run ruff format . format-check: diff --git a/packages/gt-i18n/src/gt_i18n/translation_functions/_extract_variables.py b/packages/gt-i18n/src/gt_i18n/translation_functions/_extract_variables.py index 949d380..6e8ac91 100644 --- a/packages/gt-i18n/src/gt_i18n/translation_functions/_extract_variables.py +++ b/packages/gt-i18n/src/gt_i18n/translation_functions/_extract_variables.py @@ -1,16 +1,27 @@ -"""Filter $-prefixed GT keys from options, returning user variables.""" +"""Filter GT-reserved keys from options, returning user variables.""" from __future__ import annotations +_GT_RESERVED_KEYS = frozenset( + { + "_context", + "_id", + "_max_chars", + "__hash", + "__source", + "__fallback", + } +) + def extract_variables(options: dict[str, object]) -> dict[str, object]: - """Return only user interpolation variables (non-$ keys). + """Return only user interpolation variables. Args: - options: The full kwargs dict which may contain ``$context``, - ``$id``, ``$max_chars``, etc. + options: The full kwargs dict which may contain GT-reserved keys + like ``_context``, ``_id``, ``_max_chars``, etc. Returns: - A new dict with only the user-provided variable keys. + A new dict with GT-reserved keys removed. """ - return {k: v for k, v in options.items() if not k.startswith("$")} + return {k: v for k, v in options.items() if k not in _GT_RESERVED_KEYS} diff --git a/packages/gt-i18n/src/gt_i18n/translation_functions/_interpolate.py b/packages/gt-i18n/src/gt_i18n/translation_functions/_interpolate.py index 61eb7c9..9e6dfd5 100644 --- a/packages/gt-i18n/src/gt_i18n/translation_functions/_interpolate.py +++ b/packages/gt-i18n/src/gt_i18n/translation_functions/_interpolate.py @@ -25,15 +25,15 @@ def interpolate_message( Mirrors JS ``interpolateMessage()`` behavior: - 1. Extract user variables (filter out ``$``-prefixed keys). + 1. Extract user variables (filter out GT-reserved keys). 2. Extract ``_gt_`` declared variables from the source/fallback. 3. Condense ``_gt_`` selects to simple refs (only if declared vars exist). 4. Format with ICU MessageFormat. - 5. Apply ``$max_chars`` cutoff if specified. + 5. Apply ``_max_chars`` cutoff if specified. On error: - - If ``$_fallback`` (source) is available, recursively retry with - the source message (clearing ``$_fallback`` to prevent infinite loop). + - If ``__fallback`` (source) is available, recursively retry with + the source message (clearing ``__fallback`` to prevent infinite loop). - Otherwise, return the raw message with cutoff applied. Args: @@ -44,8 +44,8 @@ def interpolate_message( Returns: The interpolated string. """ - source = options.get("$_fallback") - max_chars = options.get("$max_chars") + source = options.get("__fallback") + max_chars = options.get("_max_chars") # Remove GT-related options, keep user variables variables = extract_variables(options) @@ -83,7 +83,7 @@ def interpolate_message( if source is not None and isinstance(source, str): return interpolate_message( source, - {**options, "$_fallback": None}, + {**options, "__fallback": None}, locale, ) diff --git a/packages/gt-i18n/src/gt_i18n/translation_functions/_is_encoded.py b/packages/gt-i18n/src/gt_i18n/translation_functions/_is_encoded.py index 8bc3ee2..6d27390 100644 --- a/packages/gt-i18n/src/gt_i18n/translation_functions/_is_encoded.py +++ b/packages/gt-i18n/src/gt_i18n/translation_functions/_is_encoded.py @@ -4,5 +4,5 @@ def is_encoded_translation_options(decoded_options: dict[str, object]) -> bool: - """Return True if the dict contains both ``$_hash`` and ``$_source``.""" - return bool(decoded_options.get("$_hash") and decoded_options.get("$_source")) + """Return True if the dict contains both ``__hash`` and ``__source``.""" + return bool(decoded_options.get("__hash") and decoded_options.get("__source")) diff --git a/packages/gt-i18n/src/gt_i18n/translation_functions/_msg.py b/packages/gt-i18n/src/gt_i18n/translation_functions/_msg.py index 2247f94..6a8e50c 100644 --- a/packages/gt-i18n/src/gt_i18n/translation_functions/_msg.py +++ b/packages/gt-i18n/src/gt_i18n/translation_functions/_msg.py @@ -29,15 +29,15 @@ def msg(message: str, **kwargs: object) -> str: except Exception: return message - # Build encoded options (preserve all kwargs including $-prefixed) - h = kwargs.get("$_hash") or hash_message( + # Build encoded options (preserve all kwargs including _-prefixed) + h = kwargs.get("__hash") or hash_message( message, - context=kwargs.get("$context"), # type: ignore[arg-type] - id=kwargs.get("$id"), # type: ignore[arg-type] - max_chars=kwargs.get("$max_chars"), # type: ignore[arg-type] + context=kwargs.get("_context"), # type: ignore[arg-type] + id=kwargs.get("_id"), # type: ignore[arg-type] + max_chars=kwargs.get("_max_chars"), # type: ignore[arg-type] ) - encoded_options = {**kwargs, "$_source": message, "$_hash": h} + encoded_options = {**kwargs, "__source": message, "__hash": h} options_encoding = base64.b64encode(json.dumps(encoded_options, separators=(",", ":")).encode()).decode() return f"{interpolated}:{options_encoding}" diff --git a/packages/gt-i18n/src/gt_i18n/translation_functions/_t.py b/packages/gt-i18n/src/gt_i18n/translation_functions/_t.py index f5f6bca..405027a 100644 --- a/packages/gt-i18n/src/gt_i18n/translation_functions/_t.py +++ b/packages/gt-i18n/src/gt_i18n/translation_functions/_t.py @@ -17,7 +17,7 @@ def t(message: str, **kwargs: object) -> str: Args: message: The ICU MessageFormat source string. **kwargs: Interpolation variables and GT options - (``$context``, ``$id``, ``$max_chars``). + (``_context``, ``_id``, ``_max_chars``). Returns: The translated and interpolated string. @@ -31,13 +31,13 @@ def t(message: str, **kwargs: object) -> str: translations = manager.get_translations_sync(locale) h = hash_message( message, - context=kwargs.get("$context"), # type: ignore[arg-type] - id=kwargs.get("$id"), # type: ignore[arg-type] - max_chars=kwargs.get("$max_chars"), # type: ignore[arg-type] + context=kwargs.get("_context"), # type: ignore[arg-type] + id=kwargs.get("_id"), # type: ignore[arg-type] + max_chars=kwargs.get("_max_chars"), # type: ignore[arg-type] ) translated = translations.get(h) if translated: - return interpolate_message(translated, {**kwargs, "$_fallback": message}, locale) + return interpolate_message(translated, {**kwargs, "__fallback": message}, locale) # No translation found — use source return interpolate_message(message, kwargs, locale) diff --git a/packages/gt-i18n/tests/conftest.py b/packages/gt-i18n/tests/conftest.py new file mode 100644 index 0000000..ea2e575 --- /dev/null +++ b/packages/gt-i18n/tests/conftest.py @@ -0,0 +1,6 @@ +"""Make the tests directory importable so helpers.py can be used.""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) diff --git a/packages/gt-i18n/tests/helpers.py b/packages/gt-i18n/tests/helpers.py new file mode 100644 index 0000000..de3af29 --- /dev/null +++ b/packages/gt-i18n/tests/helpers.py @@ -0,0 +1,37 @@ +"""Shared test helpers for converting JS-style fixture keys to Python-style.""" + +from __future__ import annotations + +import base64 +import json +from typing import Any + +# Mapping from JS $-prefixed keys to Python _-prefixed keys +JS_TO_PY_KEY_MAP: dict[str, str] = { + "$context": "_context", + "$id": "_id", + "$max_chars": "_max_chars", + "$_source": "__source", + "$_hash": "__hash", +} + + +def convert_keys(d: dict[str, Any]) -> dict[str, Any]: + """Convert JS-style $-prefixed keys to Python-style _-prefixed keys.""" + return {JS_TO_PY_KEY_MAP.get(k, k): v for k, v in d.items()} + + +def convert_encoded_string(encoded: str) -> str: + """Re-encode a JS-style encoded message with Python-style keys. + + Takes 'message:base64payload', decodes the payload, converts + $-prefixed keys to _-prefixed, and re-encodes. + """ + idx = encoded.rfind(":") + if idx == -1: + return encoded + msg_part = encoded[:idx] + payload = json.loads(base64.b64decode(encoded[idx + 1 :]).decode()) + converted = convert_keys(payload) + new_b64 = base64.b64encode(json.dumps(converted, separators=(",", ":")).encode()).decode() + return f"{msg_part}:{new_b64}" diff --git a/packages/gt-i18n/tests/test_extract_variables.py b/packages/gt-i18n/tests/test_extract_variables.py index a556cff..f874a6c 100644 --- a/packages/gt-i18n/tests/test_extract_variables.py +++ b/packages/gt-i18n/tests/test_extract_variables.py @@ -1,15 +1,15 @@ -"""Tests for extract_variables ($-key filtering).""" +"""Tests for extract_variables (_-key filtering).""" from gt_i18n.translation_functions._extract_variables import extract_variables -def test_filters_dollar_keys() -> None: - opts: dict[str, object] = dict({"name": "Alice", "$context": "greeting", "$id": "hello"}) +def test_filters_underscore_keys() -> None: + opts: dict[str, object] = dict({"name": "Alice", "_context": "greeting", "_id": "hello"}) result = extract_variables(opts) assert result == {"name": "Alice"} -def test_keeps_all_non_dollar() -> None: +def test_keeps_all_non_underscore() -> None: opts = {"name": "Alice", "count": 5, "item": "apples"} result = extract_variables(opts) assert result == opts @@ -19,6 +19,12 @@ def test_empty_dict() -> None: assert extract_variables({}) == {} -def test_all_dollar_keys() -> None: - opts = {"$context": "x", "$id": "y", "$max_chars": 10} +def test_all_underscore_keys() -> None: + opts = {"_context": "x", "_id": "y", "_max_chars": 10} assert extract_variables(opts) == {} + + +def test_user_underscore_prefixed_keys_preserved() -> None: + opts: dict[str, object] = {"_name": "Alice", "_context": "greeting"} + result = extract_variables(opts) + assert result == {"_name": "Alice"} diff --git a/packages/gt-i18n/tests/test_fallbacks.py b/packages/gt-i18n/tests/test_fallbacks.py index 22ae72e..326c758 100644 --- a/packages/gt-i18n/tests/test_fallbacks.py +++ b/packages/gt-i18n/tests/test_fallbacks.py @@ -4,6 +4,7 @@ from pathlib import Path from gt_i18n import m_fallback, t_fallback +from helpers import convert_encoded_string, convert_keys FIXTURES = json.loads((Path(__file__).parent / "fixtures" / "js_msg_parity.json").read_text()) @@ -11,7 +12,8 @@ class TestMFallback: def test_encoded_message(self) -> None: f = FIXTURES["mFallback"]["encoded"] - assert m_fallback(f["input"]) == f["result"] + converted_input = convert_encoded_string(f["input"]) + assert m_fallback(converted_input) == f["result"] def test_plain_text(self) -> None: f = FIXTURES["mFallback"]["plain"] @@ -33,7 +35,8 @@ def test_plain(self) -> None: def test_with_vars(self) -> None: f = FIXTURES["gtFallback"]["with_vars"] - assert t_fallback(f["input"][0], **f["input"][1]) == f["result"] + py_args = convert_keys(f["input"][1]) + assert t_fallback(f["input"][0], **py_args) == f["result"] def test_declare_var(self) -> None: f = FIXTURES["gtFallback"]["declare_var"] diff --git a/packages/gt-i18n/tests/test_interpolate.py b/packages/gt-i18n/tests/test_interpolate.py index 3005bee..60a36a1 100644 --- a/packages/gt-i18n/tests/test_interpolate.py +++ b/packages/gt-i18n/tests/test_interpolate.py @@ -30,7 +30,7 @@ def test_no_variables() -> None: def test_max_chars_cutoff() -> None: result = interpolate_message( "This is a very long message that should be cut off", - {"$max_chars": 10}, + {"_max_chars": 10}, ) assert len(result) <= 10 @@ -83,7 +83,7 @@ def test_translated_with_declare_var() -> None: """ source = f"Welcome back, {declare_var('Alice', name='user_name')}!" translated = "Bienvenido de nuevo, {_gt_1}!" - result = interpolate_message(translated, {"$_fallback": source}) + result = interpolate_message(translated, {"__fallback": source}) assert result == "Bienvenido de nuevo, Alice!" @@ -95,7 +95,7 @@ def test_translated_multiple_declare_vars() -> None: f"{declare_var('oranges', name='item')}" ) translated = "{_gt_1} compró {_gt_2} de {_gt_3}" - result = interpolate_message(translated, {"$_fallback": source}) + result = interpolate_message(translated, {"__fallback": source}) assert result == "Bob compró 5 de oranges" @@ -104,7 +104,7 @@ def test_translated_reordered_vars() -> None: source = f"{declare_var('Alice', name='user')} bought {declare_var('apples', name='item')}" # Translation reorders: item before user translated = "{_gt_2} fueron comprados por {_gt_1}" - result = interpolate_message(translated, {"$_fallback": source}) + result = interpolate_message(translated, {"__fallback": source}) assert result == "apples fueron comprados por Alice" @@ -120,7 +120,7 @@ def test_fallback_retry_on_format_error() -> None: """ bad_translation = "{broken, plural, }" source = "Hello, world!" - result = interpolate_message(bad_translation, {"$_fallback": source}) + result = interpolate_message(bad_translation, {"__fallback": source}) # Should successfully format the source, not return it raw assert result == "Hello, world!" @@ -133,7 +133,7 @@ def test_fallback_retry_preserves_user_vars() -> None: """ bad_translation = "{broken, plural, }" source = "Hello, {name}!" - result = interpolate_message(bad_translation, {"$_fallback": source, "name": "Alice"}) + result = interpolate_message(bad_translation, {"__fallback": source, "name": "Alice"}) assert result == "Hello, Alice!" @@ -141,7 +141,7 @@ def test_fallback_retry_preserves_declare_vars() -> None: """Fallback retry should also handle declare_var in the source.""" bad_translation = "{broken, plural, }" source = f"Hello, {declare_var('Alice', name='user')}!" - result = interpolate_message(bad_translation, {"$_fallback": source}) + result = interpolate_message(bad_translation, {"__fallback": source}) assert result == "Hello, Alice!" @@ -151,7 +151,7 @@ def test_no_fallback_returns_raw_with_cutoff() -> None: JS behavior: final catch returns formatCutoff(encodedMsg, {maxChars}). """ bad_msg = "{broken, plural, }" - result = interpolate_message(bad_msg, {"$max_chars": 5}) + result = interpolate_message(bad_msg, {"_max_chars": 5}) assert len(result) <= 5 @@ -167,7 +167,7 @@ def test_no_fallback_returns_raw_message() -> None: def test_cutoff_applied_after_interpolation() -> None: """$max_chars truncates after interpolation.""" - result = interpolate_message("Hello, {name}!", {"name": "Alice", "$max_chars": 8}) + result = interpolate_message("Hello, {name}!", {"name": "Alice", "_max_chars": 8}) assert len(result) <= 8 @@ -175,5 +175,5 @@ def test_cutoff_on_fallback_retry() -> None: """$max_chars should still apply when falling back to source.""" bad_translation = "{broken, plural, }" source = "This is a long fallback message" - result = interpolate_message(bad_translation, {"$_fallback": source, "$max_chars": 10}) + result = interpolate_message(bad_translation, {"__fallback": source, "_max_chars": 10}) assert len(result) <= 10 diff --git a/packages/gt-i18n/tests/test_msg.py b/packages/gt-i18n/tests/test_msg.py index 961c42e..16b7cc7 100644 --- a/packages/gt-i18n/tests/test_msg.py +++ b/packages/gt-i18n/tests/test_msg.py @@ -4,6 +4,7 @@ from pathlib import Path from gt_i18n import decode_msg, decode_options, msg +from helpers import convert_encoded_string, convert_keys FIXTURES = json.loads((Path(__file__).parent / "fixtures" / "js_msg_parity.json").read_text()) @@ -15,50 +16,57 @@ def test_no_options(self) -> None: def test_with_vars(self) -> None: f = FIXTURES["msg"]["with_vars"] - result = msg(f["args"][0], **f["args"][1]) + py_args = convert_keys(f["args"][1]) + result = msg(f["args"][0], **py_args) assert decode_msg(result) == decode_msg(f["result"]) py_opts = decode_options(result) assert py_opts is not None - js_opts = FIXTURES["decodeOptions"]["encoded"]["result"] - assert py_opts["$_hash"] == js_opts["$_hash"] - assert py_opts["$_source"] == js_opts["$_source"] + js_opts = convert_keys(FIXTURES["decodeOptions"]["encoded"]["result"]) + assert py_opts["__hash"] == js_opts["__hash"] + assert py_opts["__source"] == js_opts["__source"] assert py_opts["name"] == js_opts["name"] def test_with_context(self) -> None: f = FIXTURES["msg"]["with_context"] - result = msg(f["args"][0], **f["args"][1]) + py_args = convert_keys(f["args"][1]) + result = msg(f["args"][0], **py_args) assert ":" in result assert decode_msg(result) == "Save" opts = decode_options(result) assert opts is not None - assert opts["$context"] == "button" + assert opts["_context"] == "button" f_opts = decode_options(f["result"]) assert f_opts is not None - assert opts["$_hash"] == f_opts["$_hash"] + f_opts_py = convert_keys(f_opts) + assert opts["__hash"] == f_opts_py["__hash"] def test_with_id(self) -> None: f = FIXTURES["msg"]["with_id"] - result = msg(f["args"][0], **f["args"][1]) + py_args = convert_keys(f["args"][1]) + result = msg(f["args"][0], **py_args) assert ":" in result assert decode_msg(result) == "Hello" opts = decode_options(result) assert opts is not None - assert opts["$id"] == "greeting" + assert opts["_id"] == "greeting" f_opts = decode_options(f["result"]) assert f_opts is not None - assert opts["$_hash"] == f_opts["$_hash"] + f_opts_py = convert_keys(f_opts) + assert opts["__hash"] == f_opts_py["__hash"] def test_with_id_and_var(self) -> None: f = FIXTURES["msg"]["with_id_and_var"] - result = msg(f["args"][0], **f["args"][1]) + py_args = convert_keys(f["args"][1]) + result = msg(f["args"][0], **py_args) assert decode_msg(result) == "Hi, Bob!" opts = decode_options(result) assert opts is not None assert opts["name"] == "Bob" - assert opts["$id"] == "greet" + assert opts["_id"] == "greet" f_opts = decode_options(f["result"]) assert f_opts is not None - assert opts["$_hash"] == f_opts["$_hash"] + f_opts_py = convert_keys(f_opts) + assert opts["__hash"] == f_opts_py["__hash"] class TestDecodeMsg: @@ -82,11 +90,13 @@ def test_with_colon(self) -> None: class TestDecodeOptions: def test_encoded(self) -> None: f = FIXTURES["decodeOptions"]["encoded"] - result = decode_options(f["input"]) + converted_input = convert_encoded_string(f["input"]) + result = decode_options(converted_input) + expected = convert_keys(f["result"]) assert result is not None - assert result["$_hash"] == f["result"]["$_hash"] - assert result["$_source"] == f["result"]["$_source"] - assert result["name"] == f["result"]["name"] + assert result["__hash"] == expected["__hash"] + assert result["__source"] == expected["__source"] + assert result["name"] == expected["name"] def test_plain_returns_none(self) -> None: f = FIXTURES["decodeOptions"]["plain"]