Skip to content
Open
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
6 changes: 6 additions & 0 deletions .sampo/changesets/load-configured-locales.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
pypi/gt-fastapi: patch
pypi/gt-flask: patch
---

fix: load each configured locale on its first request, eager-load `gt.config.json` locales in Flask, pass the `_branchId` from `gt.config.json` to the CDN loader, treat `cacheUrl: null` as no CDN loading, and require gt-i18n 0.3.1 and generaltranslation 0.1.0 or later
20 changes: 3 additions & 17 deletions examples/fastapi-lazy/app.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
"""FastAPI example with lazy translation loading.

Translations are loaded on first request per locale, not at startup.
Translations are loaded on the first request for each locale, not at startup;
gt-fastapi loads them itself before the route runs.
Run: uv run uvicorn app:app --port 8001
"""

from fastapi import Depends, FastAPI, Request
from fastapi import FastAPI
from gt_fastapi import initialize_gt, t

app = FastAPI(title="FastAPI Lazy Example")
Expand Down Expand Up @@ -36,21 +37,6 @@ async def load_translations(locale: str) -> dict[str, str]:
)


async def _ensure_translations(request: Request) -> None:
"""Load translations for the current locale if not already cached.

t() only reads from cache (get_translations_sync), so we must
explicitly trigger a load for the current locale before t() runs.
"""
locale = manager.get_locale()
if manager.requires_translation(locale):
await manager.get_translations(locale)


# Add dependency to all routes — runs inside route context after middleware
app.router.dependencies = [Depends(_ensure_translations)]


@app.get("/")
def index() -> dict[str, str]:
return {"message": t("Hello, world!")}
Expand Down
11 changes: 2 additions & 9 deletions examples/fastapi-lazy/tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
first request per locale, and are cached for subsequent requests.
"""

from fastapi import Depends, FastAPI, Request
from fastapi import FastAPI
from fastapi.testclient import TestClient
from gt_fastapi import initialize_gt, t

Expand All @@ -28,21 +28,14 @@ async def load_translations(locale):
return TRANSLATIONS.get(locale, {})

app = FastAPI()
manager = initialize_gt(
initialize_gt(
app,
default_locale="en",
locales=["en", "es", "fr"],
load_translations=load_translations,
eager_loading=False,
)

async def _ensure_translations(request: Request):
locale = manager.get_locale()
if manager.requires_translation(locale):
await manager.get_translations(locale)

app.router.dependencies = [Depends(_ensure_translations)]

@app.get("/")
def index():
return {"message": t("Hello, world!")}
Expand Down
18 changes: 2 additions & 16 deletions examples/flask-lazy/app.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
"""Flask example with lazy translation loading.

Translations are loaded on first request per locale, not at startup.
Translations are loaded on the first request for each locale, not at startup;
gt-flask loads them itself before the route runs.
Run: uv run python app.py (serves on port 5051)
"""

import asyncio

from flask import Flask
from gt_flask import initialize_gt, t

Expand Down Expand Up @@ -38,19 +37,6 @@ def load_translations(locale: str) -> dict[str, str]:
)


@app.before_request
def _ensure_translations() -> None:
"""Load translations for the current locale if not already cached.

Registered after initialize_gt(), so this runs after the locale is set.
t() only reads from cache (get_translations_sync), so we must
explicitly trigger a load for the current locale before t() runs.
"""
locale = manager.get_locale()
if manager.requires_translation(locale):
asyncio.run(manager.get_translations(locale))


@app.get("/")
def index() -> dict[str, str]:
return {"message": t("Hello, world!")}
Expand Down
10 changes: 1 addition & 9 deletions examples/flask-lazy/tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@
first request per locale via before_request hook, and are cached.
"""

import asyncio

from flask import Flask
from gt_flask import initialize_gt, t

Expand All @@ -29,20 +27,14 @@ def load_translations(locale):
return TRANSLATIONS.get(locale, {})

app = Flask(__name__)
manager = initialize_gt(
initialize_gt(
app,
default_locale="en",
locales=["en", "es", "fr"],
load_translations=load_translations,
eager_loading=False,
)

@app.before_request
def _ensure_translations():
locale = manager.get_locale()
if manager.requires_translation(locale):
asyncio.run(manager.get_translations(locale))

@app.get("/")
def index():
return {"message": t("Hello, world!")}
Expand Down
4 changes: 2 additions & 2 deletions packages/gt-fastapi/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ requires-python = ">=3.10"
license = "FSL-1.1-ALv2"
license-files = ["LICENSE.md"]
dependencies = [
"generaltranslation",
"gt-i18n",
"generaltranslation>=0.1.0",
"gt-i18n>=0.3.1",
"fastapi>=0.100.0",
]

Expand Down
13 changes: 12 additions & 1 deletion packages/gt-fastapi/src/gt_fastapi/_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,13 @@ def initialize_gt(
resolved_default_locale = default_locale or file_config.get("default_locale") or LIBRARY_DEFAULT_LOCALE
resolved_locales = locales if locales is not None else file_config.get("locales")
resolved_project_id = project_id or file_config.get("project_id")
resolved_cache_url = cache_url or file_config.get("cache_url")
if cache_url is not None:
resolved_cache_url: str | None = cache_url
elif "cache_url" in file_config:
# "cacheUrl": null (or "") in gt.config.json turns CDN loading off.
resolved_cache_url = file_config.get("cache_url") or ""
Comment on lines +58 to +62

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 CDN config paths lack tests

FastAPI's new CDN configuration rules have no integration tests. The Flask tests cannot catch mistakes in FastAPI's separate cache_url resolution or branch_id forwarding, so the two integrations could drift without a test failing.

Add FastAPI cases for null cache settings and file-provided version/branch values. Also test explicit cache_url and version_id overrides in both integrations, including that an explicit version drops the file's branch.

Knowledge Base Used: Web framework integrations

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/gt-fastapi/src/gt_fastapi/_setup.py
Line: 58-62

Comment:
**CDN config paths lack tests**

FastAPI's new CDN configuration rules have no integration tests. The Flask tests cannot catch mistakes in FastAPI's separate `cache_url` resolution or `branch_id` forwarding, so the two integrations could drift without a test failing.

Add FastAPI cases for null cache settings and file-provided version/branch values. Also test explicit `cache_url` and `version_id` overrides in both integrations, including that an explicit version drops the file's branch.

**Knowledge Base Used:** [Web framework integrations](https://app.greptile.com/generaltranslation/-/custom-context/knowledge-base/generaltranslation/gt-python/-/docs/web-framework-integrations.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Codex

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b98c0b3. Both integration test files now run the same table of five cases: the file's version and branch reach the CDN, an explicit version_id drops the file's branch, a null or empty cacheUrl turns CDN loading off, and an explicit cache_url replaces the file's. Reverting FastAPI's null cache_url handling or its branch_id rule makes the matching case fail.

else:
resolved_cache_url = None
resolved_custom_mapping = custom_mapping or file_config.get("custom_mapping")

resolved_version_id = version_id or file_config.get("version_id")
Expand All @@ -68,6 +74,8 @@ def initialize_gt(
cache_url=resolved_cache_url,
load_translations=load_translations,
version_id=resolved_version_id,
# The file's branch belongs to the file's version, not to an explicit version_id.
branch_id=file_config.get("branch_id") if version_id is None else None,
)
set_i18n_manager(manager)

Expand All @@ -90,6 +98,9 @@ async def gt_middleware(request: Any, call_next: Any) -> Any:
else:
locale = _detect_from_accept_language(request, manager)
manager.set_locale(locale)
# Load a configured locale on first use; once loaded, it reloads in the background after expiry.
if manager.requires_translation(locale) and locale in manager.get_locales():
await manager.get_translations(locale)
return await call_next(request)

return manager
148 changes: 148 additions & 0 deletions packages/gt-fastapi/tests/test_fastapi_integration.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"""FastAPI integration tests."""

import time
from collections.abc import Generator
from types import SimpleNamespace
from typing import Any

import pytest
Expand Down Expand Up @@ -104,3 +106,149 @@ def hello() -> dict[str, Any]:
with TestClient(app) as client:
resp = client.get("/hello", headers={"Accept-Language": "es"})
assert resp.json()["message"] == "Hola, Carlos!"


def test_fastapi_translations_survive_cache_expiry(monkeypatch: pytest.MonkeyPatch) -> None:
import gt_i18n.i18n_manager._translations_manager as cache

now = [1_000.0]
monkeypatch.setattr(cache, "time", SimpleNamespace(monotonic=lambda: now[0]))
h = hash_message("Hello, world!")
es_loads = [0]

def loader(locale: str) -> dict[str, str]:
if locale != "es":
return {}
es_loads[0] += 1
return {h: "Hola, mundo!"}

app = FastAPI()
initialize_gt(app, default_locale="en", locales=["en", "es"], load_translations=loader)

@app.get("/hello")
def hello() -> dict[str, Any]:
return {"message": t("Hello, world!")}

with TestClient(app) as client:
first = client.get("/hello", headers={"Accept-Language": "es"})
now[0] += 120 # two minutes later, past the 60 s cache expiry
later = client.get("/hello", headers={"Accept-Language": "es"})

# The reload runs on a background thread.
deadline = time.monotonic() + 5
while es_loads[0] < 2 and time.monotonic() < deadline:
time.sleep(0.01)

assert first.json()["message"] == "Hola, mundo!"
assert later.json()["message"] == "Hola, mundo!" # served from the cache while it reloads
assert es_loads[0] == 2 # es reloaded once, in the background


@pytest.fixture
def cdn_urls(monkeypatch: pytest.MonkeyPatch) -> list[str]:
"""Answer the CDN loader with a Spanish greeting and record the URL of each request."""
import gt_i18n.i18n_manager._remote_loader as remote
import httpx

h = hash_message("Hello, world!")
urls: list[str] = []
real_client = httpx.AsyncClient

def handler(request: httpx.Request) -> httpx.Response:
urls.append(str(request.url))
return httpx.Response(200, json={h: "Hola, mundo!"})

monkeypatch.setattr(
remote.httpx, "AsyncClient", lambda **kw: real_client(transport=httpx.MockTransport(handler), **kw)
)
return urls


# gt.config.json settings, initialize_gt() arguments, and the CDN requests for es that follow.
# test_flask_integration.py runs the same cases.
_CDN_CASES = [
pytest.param(
{"version_id": "v9", "branch_id": "br_2"},
{},
["https://cdn.gtx.dev/proj/es/v9?branchId=br_2"],
id="file version and branch",
),
pytest.param(
{"version_id": "v9", "branch_id": "br_2"},
{"version_id": "v10"},
["https://cdn.gtx.dev/proj/es/v10"],
id="explicit version drops the file branch",
),
pytest.param({"cache_url": None}, {}, [], id="null cacheUrl turns the CDN off"),
pytest.param({"cache_url": ""}, {}, [], id="empty cacheUrl turns the CDN off"),
pytest.param(
{"cache_url": None},
{"cache_url": "https://cdn.example.com"},
["https://cdn.example.com/proj/es"],
id="explicit cache_url replaces the file one",
),
]


@pytest.mark.parametrize(("file_config", "kwargs", "es_urls"), _CDN_CASES)
def test_fastapi_cdn_settings(
cdn_urls: list[str], file_config: dict[str, Any], kwargs: dict[str, Any], es_urls: list[str]
) -> None:
config: Any = {"default_locale": "en", "locales": ["en", "es"], "project_id": "proj", **file_config}
app = FastAPI()
initialize_gt(app, load_config=lambda path: config, **kwargs)

@app.get("/hello")
def hello() -> dict[str, Any]:
return {"message": t("Hello, world!")}

with TestClient(app) as client:
message = client.get("/hello", headers={"Accept-Language": "es"}).json()["message"]
assert [url for url in cdn_urls if "/proj/es" in url] == es_urls
assert message == ("Hola, mundo!" if es_urls else "Hello, world!")


def test_fastapi_does_not_load_unconfigured_locales() -> None:
loaded: list[str] = []

def loader(locale: str) -> dict[str, str]:
loaded.append(locale)
return {}

app = FastAPI()
initialize_gt(
app,
default_locale="en",
locales=["en", "es"],
load_translations=loader,
eager_loading=False,
get_locale=lambda req: req.query_params.get("lang", "en"),
)

@app.get("/hello")
def hello() -> dict[str, Any]:
return {"message": t("Hello, world!")}

with TestClient(app) as client:
client.get("/hello?lang=es-MX")
client.get("/hello?lang=es")
assert loaded == ["es"]


def test_fastapi_lazy_loading_loads_on_first_request() -> None:
h = hash_message("Hello, world!")
app = FastAPI()
initialize_gt(
app,
default_locale="en",
locales=["en", "fr"],
load_translations=lambda locale: {h: "Bonjour, le monde !"} if locale == "fr" else {},
eager_loading=False,
)

@app.get("/hello")
def hello() -> dict[str, Any]:
return {"message": t("Hello, world!")}

with TestClient(app) as client:
assert client.get("/hello", headers={"Accept-Language": "fr"}).json()["message"] == "Bonjour, le monde !"
4 changes: 2 additions & 2 deletions packages/gt-flask/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ requires-python = ">=3.10"
license = "FSL-1.1-ALv2"
license-files = ["LICENSE.md"]
dependencies = [
"generaltranslation",
"gt-i18n",
"generaltranslation>=0.1.0",
"gt-i18n>=0.3.1",
"flask>=3.0.0",
]

Expand Down
Loading
Loading