Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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 ``_``-prefixed keys).
Comment thread
eoinest marked this conversation as resolved.
Outdated
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