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
5 changes: 5 additions & 0 deletions .sampo/changesets/haughty-king-tuonetar.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pypi/gt-i18n: patch
---

refactor: string registration interface
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ lint-fix:
uv run ruff check --fix .

format:
uv run ruff check --fix .
uv run ruff format .

format-check:
Expand Down
Original file line number Diff line number Diff line change
@@ -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}
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -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,
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
12 changes: 6 additions & 6 deletions packages/gt-i18n/src/gt_i18n/translation_functions/_msg.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
10 changes: 5 additions & 5 deletions packages/gt-i18n/src/gt_i18n/translation_functions/_t.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
6 changes: 6 additions & 0 deletions packages/gt-i18n/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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))
37 changes: 37 additions & 0 deletions packages/gt-i18n/tests/helpers.py
Original file line number Diff line number Diff line change
@@ -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}"
18 changes: 12 additions & 6 deletions packages/gt-i18n/tests/test_extract_variables.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"}
7 changes: 5 additions & 2 deletions packages/gt-i18n/tests/test_fallbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@
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())


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"]
Expand All @@ -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"]
Expand Down
20 changes: 10 additions & 10 deletions packages/gt-i18n/tests/test_interpolate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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!"


Expand All @@ -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"


Expand All @@ -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"


Expand All @@ -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!"

Expand All @@ -133,15 +133,15 @@ 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!"


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!"


Expand All @@ -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


Expand All @@ -167,13 +167,13 @@ 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


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
Loading