Skip to content
Closed
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
204 changes: 204 additions & 0 deletions .claude/skills/port-from-gt-js.md

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions .sampo/changesets/runtime-translation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
pypi/generaltranslation: patch
pypi/gt-i18n: minor
pypi/gt-fastapi: minor
pypi/gt-flask: minor
---

feat: add runtime translation system (ports gt PR #1207 / #1217)

- **gt-i18n**: new public async `tx()` for runtime string translation via `GT.translate_many`. Replaces `TranslationsManager` with a two-level cache hierarchy (`LocalesCache` → `TranslationsCache`) with batching, in-flight dedup, and a configurable concurrency cap. Adds new `I18nManager` methods (`lookup_translation`, `lookup_translation_with_fallback`, `load_translations`, `get_lookup_translation`) plus `lifecycle`, `batch_size`, `batch_interval_ms`, `max_concurrent_requests`, and `translation_timeout_ms` constructor kwargs. `hash_message` now accepts `format="ICU" | "STRING" | "I18NEXT"` and only applies `index_vars()` for ICU — subtle breaking change for any pre-existing STRING/I18NEXT cache keys. Deprecates `get_translations`, `get_translation_resolver`, `resolve_translation_sync`, and `get_translation_loader` with `DeprecationWarning`.
- **gt-fastapi / gt-flask**: `initialize_gt()` gains `lifecycle`, `batch_size`, `batch_interval_ms`, `max_concurrent_requests`, and `translation_timeout_ms` kwargs (all forwarded to `I18nManager`). Both packages now re-export `tx`.
- **generaltranslation**: `hash_source` and `hash_template` now pass `ensure_ascii=False` to `json.dumps`, matching JS `JSON.stringify` semantics for non-ASCII content. Fixes a cross-SDK hash divergence for messages and contexts containing unicode.
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ def hash_source(
if max_chars is not None:
sanitized_data["maxChars"] = abs(max_chars)

stringified = json.dumps(sanitized_data, sort_keys=True, separators=(",", ":"))
# ensure_ascii=False matches JS JSON.stringify, which preserves raw UTF-8.
# Without this, Python escapes non-ASCII to \uXXXX and diverges from JS hashes.
stringified = json.dumps(sanitized_data, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
return hash_function(stringified)


Expand All @@ -44,5 +46,5 @@ def hash_template(
"""Hash a template dict."""
if hash_function is None:
hash_function = hash_string
stringified = json.dumps(template, sort_keys=True, separators=(",", ":"))
stringified = json.dumps(template, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
return hash_function(stringified)
2 changes: 2 additions & 0 deletions packages/gt-fastapi/src/gt_fastapi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@
get_locales,
get_version_id,
t,
tx,
)

from gt_fastapi._setup import initialize_gt

__all__ = [
"initialize_gt",
"t",
"tx",
"declare_var",
"derive",
"declare_static",
Expand Down
13 changes: 13 additions & 0 deletions packages/gt-fastapi/src/gt_fastapi/_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from generaltranslation import CustomMapping
from generaltranslation._settings import LIBRARY_DEFAULT_LOCALE
from gt_i18n import I18nManager, set_i18n_manager
from gt_i18n.i18n_manager._lifecycle import LifecycleCallbacks
from gt_i18n.internal import GTConfig, _detect_from_accept_language, load_gt_config


Expand All @@ -26,6 +27,12 @@ def initialize_gt(
eager_loading: bool = True,
config_path: str | None = None,
load_config: Callable[[str | None], GTConfig] | None = None,
lifecycle: LifecycleCallbacks | None = None,
batch_size: int = 25,
batch_interval_ms: int = 50,
max_concurrent_requests: int = 100,
translation_timeout_ms: int = 12_000,
cache_expiry_time: int = 60_000,
) -> I18nManager:
"""Initialize General Translation for a FastAPI app.

Expand Down Expand Up @@ -68,6 +75,12 @@ def initialize_gt(
cache_url=resolved_cache_url,
load_translations=load_translations,
version_id=resolved_version_id,
lifecycle=lifecycle,
batch_size=batch_size,
batch_interval_ms=batch_interval_ms,
max_concurrent_requests=max_concurrent_requests,
translation_timeout_ms=translation_timeout_ms,
cache_expiry_time=cache_expiry_time,
)
set_i18n_manager(manager)

Expand Down
97 changes: 97 additions & 0 deletions packages/gt-fastapi/tests/test_fastapi_lifecycle_forwarding.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""Golden-standard test: ``initialize_gt()`` forwards the ``lifecycle`` kwarg.

The gt-fastapi ``initialize_gt`` entry point is a thin pass-through to
``I18nManager(...)``. PR #1207 added lifecycle callbacks to the I18nManager;
this test pins that gt-fastapi accepts and forwards the ``lifecycle`` kwarg
so users can observe cache behavior without bypassing the framework helper.

Contract:
- ``initialize_gt(app, ..., lifecycle={...})`` constructs the I18nManager with
those callbacks wired through.
- A runtime translate miss / hit triggers the corresponding callback.

This test should FAIL until PR #1207 is ported — both the ``lifecycle`` kwarg
on ``initialize_gt`` and the underlying I18nManager plumbing need to exist.
"""

from __future__ import annotations

from collections.abc import Generator
from typing import Any

import pytest
from fastapi import FastAPI
from gt_fastapi import initialize_gt
from gt_i18n.i18n_manager._i18n_manager import I18nManager


@pytest.fixture(autouse=True)
def _reset_singleton() -> Generator[None, None, None]:
import gt_i18n.i18n_manager._singleton as mod

old = mod._manager
yield
mod._manager = old


class _FakeGT:
"""Minimal GT test double returning an echoing translation."""

async def translate_many(
self,
sources: dict[str, Any],
options: dict[str, Any] | str,
timeout: int | None = None,
) -> dict[str, Any]:
if isinstance(options, str):
options = {"target_locale": options}
locale = options.get("target_locale", options.get("targetLocale", "?"))
return {
h: {"success": True, "translation": f"[{locale}]{entry['source']}", "locale": locale}
for h, entry in sources.items()
}


# ---------------------------------------------------------------------------
# test_fastapi_initialize_gt_forwards_lifecycle_kwarg
#
# Example:
# events = []
# manager = initialize_gt(
# app,
# default_locale="en", locales=["en", "es"],
# load_translations=lambda loc: {},
# lifecycle={"on_translations_cache_miss": lambda **p: events.append(p)},
# )
# # After a runtime miss, `events` has one entry.
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_fastapi_initialize_gt_forwards_lifecycle_kwarg(monkeypatch: pytest.MonkeyPatch) -> None:
"""``initialize_gt`` accepts ``lifecycle`` and wires it through to the I18nManager."""
gt = _FakeGT()
monkeypatch.setattr(I18nManager, "get_gt_instance", lambda self: gt)

events: list[dict[str, Any]] = []

app = FastAPI()
manager = initialize_gt(
app,
default_locale="en",
locales=["en", "es"],
load_translations=lambda locale: {},
eager_loading=False,
lifecycle={
"on_translations_cache_miss": lambda *, locale, hash, value: events.append(
{"locale": locale, "hash": hash, "value": value}
),
},
)

manager.set_locale("es")
await manager.lookup_translation_with_fallback("Hello", _format="STRING")

assert len(events) == 1, "on_translations_cache_miss should have fired once via the forwarded lifecycle dict"
assert events[0]["locale"] == "es"
assert events[0]["value"] == "[es]Hello"
2 changes: 2 additions & 0 deletions packages/gt-flask/src/gt_flask/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@
get_locales,
get_version_id,
t,
tx,
)

from gt_flask._setup import initialize_gt

__all__ = [
"initialize_gt",
"t",
"tx",
"declare_var",
"derive",
"declare_static",
Expand Down
13 changes: 13 additions & 0 deletions packages/gt-flask/src/gt_flask/_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from generaltranslation import CustomMapping
from generaltranslation._settings import LIBRARY_DEFAULT_LOCALE
from gt_i18n import I18nManager, set_i18n_manager
from gt_i18n.i18n_manager._lifecycle import LifecycleCallbacks
from gt_i18n.internal import GTConfig, _detect_from_accept_language, load_gt_config


Expand All @@ -26,6 +27,12 @@ def initialize_gt(
eager_loading: bool = True,
config_path: str | None = None,
load_config: Callable[[str | None], GTConfig] | None = None,
lifecycle: LifecycleCallbacks | None = None,
batch_size: int = 25,
batch_interval_ms: int = 50,
max_concurrent_requests: int = 100,
translation_timeout_ms: int = 12_000,
cache_expiry_time: int = 60_000,
) -> I18nManager:
"""Initialize General Translation for a Flask app.

Expand Down Expand Up @@ -68,6 +75,12 @@ def initialize_gt(
cache_url=resolved_cache_url,
load_translations=load_translations,
version_id=resolved_version_id,
lifecycle=lifecycle,
batch_size=batch_size,
batch_interval_ms=batch_interval_ms,
max_concurrent_requests=max_concurrent_requests,
translation_timeout_ms=translation_timeout_ms,
cache_expiry_time=cache_expiry_time,
)
set_i18n_manager(manager)

Expand Down
91 changes: 91 additions & 0 deletions packages/gt-flask/tests/test_flask_lifecycle_forwarding.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"""Golden-standard test: ``initialize_gt()`` forwards the ``lifecycle`` kwarg.

Sibling to the identically-named test in gt-fastapi. Both frameworks are thin
pass-throughs to ``I18nManager(...)`` and both must accept and forward the
``lifecycle`` kwarg so users can observe cache behavior without bypassing
``initialize_gt``.

This test should FAIL until PR #1207 is ported.
"""

from __future__ import annotations

from collections.abc import Generator
from typing import Any

import pytest
from flask import Flask
from gt_flask import initialize_gt
from gt_i18n.i18n_manager._i18n_manager import I18nManager


@pytest.fixture(autouse=True)
def _reset_singleton() -> Generator[None, None, None]:
import gt_i18n.i18n_manager._singleton as mod

old = mod._manager
yield
mod._manager = old


class _FakeGT:
"""Minimal GT test double returning an echoing translation."""

async def translate_many(
self,
sources: dict[str, Any],
options: dict[str, Any] | str,
timeout: int | None = None,
) -> dict[str, Any]:
if isinstance(options, str):
options = {"target_locale": options}
locale = options.get("target_locale", options.get("targetLocale", "?"))
return {
h: {"success": True, "translation": f"[{locale}]{entry['source']}", "locale": locale}
for h, entry in sources.items()
}


# ---------------------------------------------------------------------------
# test_flask_initialize_gt_forwards_lifecycle_kwarg
#
# Example:
# events = []
# manager = initialize_gt(
# app,
# default_locale="en", locales=["en", "es"],
# load_translations=lambda loc: {},
# lifecycle={"on_translations_cache_miss": lambda **p: events.append(p)},
# )
# # After a runtime miss, `events` has one entry.
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_flask_initialize_gt_forwards_lifecycle_kwarg(monkeypatch: pytest.MonkeyPatch) -> None:
"""``initialize_gt`` accepts ``lifecycle`` and wires it through to the I18nManager."""
gt = _FakeGT()
monkeypatch.setattr(I18nManager, "get_gt_instance", lambda self: gt)

events: list[dict[str, Any]] = []

app = Flask(__name__)
manager = initialize_gt(
app,
default_locale="en",
locales=["en", "es"],
load_translations=lambda locale: {},
eager_loading=False,
lifecycle={
"on_translations_cache_miss": lambda *, locale, hash, value: events.append(
{"locale": locale, "hash": hash, "value": value}
),
},
)

manager.set_locale("es")
await manager.lookup_translation_with_fallback("Hello", _format="STRING")

assert len(events) == 1, "on_translations_cache_miss should have fired once via the forwarded lifecycle dict"
assert events[0]["locale"] == "es"
assert events[0]["value"] == "[es]Hello"
2 changes: 2 additions & 0 deletions packages/gt-i18n/src/gt_i18n/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
msg,
t,
t_fallback,
tx,
)

__all__ = [
Expand All @@ -46,6 +47,7 @@
"msg",
"t",
"t_fallback",
"tx",
# Locale helpers
"get_locale",
"get_locales",
Expand Down
Loading
Loading