From 29580f4e54eb206c672fc742758319a78858a2ec Mon Sep 17 00:00:00 2001 From: Kevin Liu Date: Sat, 10 Oct 2026 14:23:59 -0700 Subject: [PATCH 1/2] fix(gt-flask,gt-fastapi): load translations for every configured locale Flask eager-loaded only the locales passed to initialize_gt, so an app that lists its locales in gt.config.json, as the quickstart does, never loaded any translations. Neither integration loaded a locale on its first request, and nothing reloaded expired translations. Flask now eager-loads the resolved locales, also inside a running event loop. Both integrations load each configured locale before its first request, which also starts the background reload once it expires. Locales outside the configuration are never loaded. "cacheUrl": null in gt.config.json turns CDN loading off, and the file's _branchId goes to the CDN loader unless version_id is passed explicitly. The example apps drop their hand-written loading hooks. The floors move to gt-i18n>=0.3.1, the release with the cache fix, and generaltranslation>=0.1.0. Co-Authored-By: Claude Opus 5.5 --- .sampo/changesets/load-configured-locales.md | 6 + examples/fastapi-lazy/app.py | 20 +-- examples/fastapi-lazy/tests/test_app.py | 11 +- examples/flask-lazy/app.py | 18 +- examples/flask-lazy/tests/test_app.py | 10 +- packages/gt-fastapi/pyproject.toml | 4 +- packages/gt-fastapi/src/gt_fastapi/_setup.py | 13 +- .../tests/test_fastapi_integration.py | 84 +++++++++ packages/gt-flask/pyproject.toml | 4 +- packages/gt-flask/src/gt_flask/_setup.py | 18 +- .../gt-flask/tests/test_flask_integration.py | 160 ++++++++++++++++++ 11 files changed, 288 insertions(+), 60 deletions(-) create mode 100644 .sampo/changesets/load-configured-locales.md diff --git a/.sampo/changesets/load-configured-locales.md b/.sampo/changesets/load-configured-locales.md new file mode 100644 index 0000000..4675198 --- /dev/null +++ b/.sampo/changesets/load-configured-locales.md @@ -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 diff --git a/examples/fastapi-lazy/app.py b/examples/fastapi-lazy/app.py index 64d91c3..5c36299 100644 --- a/examples/fastapi-lazy/app.py +++ b/examples/fastapi-lazy/app.py @@ -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") @@ -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!")} diff --git a/examples/fastapi-lazy/tests/test_app.py b/examples/fastapi-lazy/tests/test_app.py index 143c3aa..e5e0844 100644 --- a/examples/fastapi-lazy/tests/test_app.py +++ b/examples/fastapi-lazy/tests/test_app.py @@ -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 @@ -28,7 +28,7 @@ async def load_translations(locale): return TRANSLATIONS.get(locale, {}) app = FastAPI() - manager = initialize_gt( + initialize_gt( app, default_locale="en", locales=["en", "es", "fr"], @@ -36,13 +36,6 @@ async def load_translations(locale): 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!")} diff --git a/examples/flask-lazy/app.py b/examples/flask-lazy/app.py index 4f87f9d..ce8602a 100644 --- a/examples/flask-lazy/app.py +++ b/examples/flask-lazy/app.py @@ -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 @@ -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!")} diff --git a/examples/flask-lazy/tests/test_app.py b/examples/flask-lazy/tests/test_app.py index 2b38192..671231f 100644 --- a/examples/flask-lazy/tests/test_app.py +++ b/examples/flask-lazy/tests/test_app.py @@ -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 @@ -29,7 +27,7 @@ def load_translations(locale): return TRANSLATIONS.get(locale, {}) app = Flask(__name__) - manager = initialize_gt( + initialize_gt( app, default_locale="en", locales=["en", "es", "fr"], @@ -37,12 +35,6 @@ def load_translations(locale): 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!")} diff --git a/packages/gt-fastapi/pyproject.toml b/packages/gt-fastapi/pyproject.toml index 6f2dc6a..f975a8b 100644 --- a/packages/gt-fastapi/pyproject.toml +++ b/packages/gt-fastapi/pyproject.toml @@ -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", ] diff --git a/packages/gt-fastapi/src/gt_fastapi/_setup.py b/packages/gt-fastapi/src/gt_fastapi/_setup.py index a95b5a1..a856c82 100644 --- a/packages/gt-fastapi/src/gt_fastapi/_setup.py +++ b/packages/gt-fastapi/src/gt_fastapi/_setup.py @@ -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 "" + 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") @@ -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) @@ -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 diff --git a/packages/gt-fastapi/tests/test_fastapi_integration.py b/packages/gt-fastapi/tests/test_fastapi_integration.py index c44a1cb..3e965e2 100644 --- a/packages/gt-fastapi/tests/test_fastapi_integration.py +++ b/packages/gt-fastapi/tests/test_fastapi_integration.py @@ -1,6 +1,8 @@ """FastAPI integration tests.""" +import time from collections.abc import Generator +from types import SimpleNamespace from typing import Any import pytest @@ -104,3 +106,85 @@ 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 in the background on the app's event loop. + 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 + + +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 !" diff --git a/packages/gt-flask/pyproject.toml b/packages/gt-flask/pyproject.toml index b211094..f0c3ea1 100644 --- a/packages/gt-flask/pyproject.toml +++ b/packages/gt-flask/pyproject.toml @@ -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", ] diff --git a/packages/gt-flask/src/gt_flask/_setup.py b/packages/gt-flask/src/gt_flask/_setup.py index 004ee73..99f8c8a 100644 --- a/packages/gt-flask/src/gt_flask/_setup.py +++ b/packages/gt-flask/src/gt_flask/_setup.py @@ -2,7 +2,6 @@ from __future__ import annotations -import asyncio from collections.abc import Callable from typing import Any @@ -55,7 +54,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 "" + 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") @@ -68,11 +73,13 @@ 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) - if eager_loading and locales: - asyncio.run(manager.load_all_translations()) + if eager_loading and resolved_locales: + manager.load_all_translations_blocking() @app.before_request def _set_locale() -> None: @@ -83,5 +90,8 @@ def _set_locale() -> None: 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(): + manager.get_translations_blocking(locale) return manager diff --git a/packages/gt-flask/tests/test_flask_integration.py b/packages/gt-flask/tests/test_flask_integration.py index 5170e65..ac67c1d 100644 --- a/packages/gt-flask/tests/test_flask_integration.py +++ b/packages/gt-flask/tests/test_flask_integration.py @@ -1,6 +1,8 @@ """Flask integration tests.""" +import time from collections.abc import Generator +from types import SimpleNamespace from typing import Any import pytest @@ -108,3 +110,161 @@ def hello() -> dict[str, Any]: resp = client.get("/hello", headers={"Accept-Language": "es"}) assert resp.json is not None assert resp.json["message"] == "Hola, Carlos!" + + +def _hello_app(loader: Any, **kwargs: Any) -> Flask: + app = Flask(__name__) + initialize_gt(app, load_translations=loader, **kwargs) + + @app.route("/hello") + def hello() -> dict[str, Any]: + return {"message": t("Hello, world!")} + + return app + + +def test_flask_eager_loads_locales_from_config() -> None: + h = hash_message("Hello, world!") + loaded: list[str] = [] + + def loader(locale: str) -> dict[str, str]: + loaded.append(locale) + return {h: "Hola, mundo!"} if locale == "es" else {} + + app = _hello_app(loader, load_config=lambda path: {"default_locale": "en", "locales": ["en", "es"]}) + assert "es" in loaded # loaded at startup, before any request + + with app.test_client() as client: + resp = client.get("/hello", headers={"Accept-Language": "es"}) + assert resp.json is not None + assert resp.json["message"] == "Hola, mundo!" + + +def _wait_for(predicate: Any, timeout: float = 5.0) -> bool: + deadline = time.monotonic() + timeout + while not predicate(): + if time.monotonic() > deadline: + return False + time.sleep(0.01) + return True + + +def test_flask_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 = _hello_app(loader, default_locale="en", locales=["en", "es"]) + + with app.test_client() 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"}) + + assert first.json is not None and later.json is not None + assert first.json["message"] == "Hola, mundo!" + assert later.json["message"] == "Hola, mundo!" # served from the cache while it reloads + assert _wait_for(lambda: es_loads[0] == 2) # es reloaded once, in the background + + +def test_flask_null_cache_url_turns_cdn_loading_off(monkeypatch: pytest.MonkeyPatch) -> None: + import gt_i18n.i18n_manager._remote_loader as remote + + calls: list[str] = [] + + class NoClient: + def __init__(self, **kwargs: Any) -> None: + calls.append("client") + + monkeypatch.setattr(remote.httpx, "AsyncClient", NoClient) + config = {"default_locale": "en", "locales": ["en", "es"], "project_id": "proj", "cache_url": None} + app = Flask(__name__) + initialize_gt(app, load_config=lambda path: config) # type: ignore[arg-type,return-value] + + @app.route("/hello") + def hello() -> dict[str, Any]: + return {"message": t("Hello, world!")} + + with app.test_client() as client: + resp = client.get("/hello", headers={"Accept-Language": "es"}) + assert resp.json is not None and resp.json["message"] == "Hello, world!" + assert calls == [] + + +def test_flask_sends_the_config_version_and_branch_to_the_cdn(monkeypatch: pytest.MonkeyPatch) -> None: + 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) + ) + config = { + "default_locale": "en", + "locales": ["en", "es"], + "project_id": "proj", + "version_id": "v9", + "branch_id": "br_2", + } + app = Flask(__name__) + initialize_gt(app, load_config=lambda path: config) # type: ignore[arg-type,return-value] + + @app.route("/hello") + def hello() -> dict[str, Any]: + return {"message": t("Hello, world!")} + + with app.test_client() as client: + resp = client.get("/hello", headers={"Accept-Language": "es"}) + assert resp.json is not None and resp.json["message"] == "Hola, mundo!" + assert "https://cdn.gtx.dev/proj/es/v9?branchId=br_2" in urls + + +def test_flask_does_not_load_unconfigured_locales() -> None: + loaded: list[str] = [] + + def loader(locale: str) -> dict[str, str]: + loaded.append(locale) + return {} + + app = _hello_app( + loader, + default_locale="en", + locales=["en", "es"], + eager_loading=False, + get_locale=lambda req: req.args.get("lang", "en"), + ) + with app.test_client() as client: + client.get("/hello?lang=es-MX") + client.get("/hello?lang=es") + assert loaded == ["es"] + + +def test_flask_lazy_loading_loads_on_first_request() -> None: + h = hash_message("Hello, world!") + app = _hello_app( + lambda locale: {h: "Bonjour, le monde !"} if locale == "fr" else {}, + default_locale="en", + locales=["en", "fr"], + eager_loading=False, + ) + + with app.test_client() as client: + resp = client.get("/hello", headers={"Accept-Language": "fr"}) + assert resp.json is not None + assert resp.json["message"] == "Bonjour, le monde !" From b98c0b335b3a1cef0ff9e6922212fc7bc6250b26 Mon Sep 17 00:00:00 2001 From: Kevin Liu Date: Sat, 17 Oct 2026 00:24:09 -0700 Subject: [PATCH 2/2] test(gt-flask,gt-fastapi): cover the CDN settings in both integrations FastAPI had no tests for its cache_url and branch_id handling, which it implements apart from Flask's, so the two could drift without a test failing. Both integration test files now run one table of 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. The table replaces Flask's two single-case tests. The FastAPI expiry test's comment now says that the reload of its synchronous loader runs on a background thread. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_fastapi_integration.py | 66 ++++++++++++++- .../gt-flask/tests/test_flask_integration.py | 81 ++++++++++--------- 2 files changed, 106 insertions(+), 41 deletions(-) diff --git a/packages/gt-fastapi/tests/test_fastapi_integration.py b/packages/gt-fastapi/tests/test_fastapi_integration.py index 3e965e2..e8b6502 100644 --- a/packages/gt-fastapi/tests/test_fastapi_integration.py +++ b/packages/gt-fastapi/tests/test_fastapi_integration.py @@ -134,7 +134,7 @@ def hello() -> dict[str, Any]: now[0] += 120 # two minutes later, past the 60 s cache expiry later = client.get("/hello", headers={"Accept-Language": "es"}) - # The reload runs in the background on the app's event loop. + # The reload runs on a background thread. deadline = time.monotonic() + 5 while es_loads[0] < 2 and time.monotonic() < deadline: time.sleep(0.01) @@ -144,6 +144,70 @@ def hello() -> dict[str, Any]: 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] = [] diff --git a/packages/gt-flask/tests/test_flask_integration.py b/packages/gt-flask/tests/test_flask_integration.py index ac67c1d..e74448d 100644 --- a/packages/gt-flask/tests/test_flask_integration.py +++ b/packages/gt-flask/tests/test_flask_integration.py @@ -176,31 +176,9 @@ def loader(locale: str) -> dict[str, str]: assert _wait_for(lambda: es_loads[0] == 2) # es reloaded once, in the background -def test_flask_null_cache_url_turns_cdn_loading_off(monkeypatch: pytest.MonkeyPatch) -> None: - import gt_i18n.i18n_manager._remote_loader as remote - - calls: list[str] = [] - - class NoClient: - def __init__(self, **kwargs: Any) -> None: - calls.append("client") - - monkeypatch.setattr(remote.httpx, "AsyncClient", NoClient) - config = {"default_locale": "en", "locales": ["en", "es"], "project_id": "proj", "cache_url": None} - app = Flask(__name__) - initialize_gt(app, load_config=lambda path: config) # type: ignore[arg-type,return-value] - - @app.route("/hello") - def hello() -> dict[str, Any]: - return {"message": t("Hello, world!")} - - with app.test_client() as client: - resp = client.get("/hello", headers={"Accept-Language": "es"}) - assert resp.json is not None and resp.json["message"] == "Hello, world!" - assert calls == [] - - -def test_flask_sends_the_config_version_and_branch_to_the_cdn(monkeypatch: pytest.MonkeyPatch) -> None: +@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 @@ -215,24 +193,47 @@ def handler(request: httpx.Request) -> httpx.Response: monkeypatch.setattr( remote.httpx, "AsyncClient", lambda **kw: real_client(transport=httpx.MockTransport(handler), **kw) ) - config = { - "default_locale": "en", - "locales": ["en", "es"], - "project_id": "proj", - "version_id": "v9", - "branch_id": "br_2", - } - app = Flask(__name__) - initialize_gt(app, load_config=lambda path: config) # type: ignore[arg-type,return-value] - - @app.route("/hello") - def hello() -> dict[str, Any]: - return {"message": t("Hello, world!")} + return urls + + +# gt.config.json settings, initialize_gt() arguments, and the CDN requests for es that follow. +# test_fastapi_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_flask_cdn_settings( + cdn_urls: list[str], file_config: dict[str, Any], kwargs: dict[str, Any], es_urls: list[str] +) -> None: + config = {"default_locale": "en", "locales": ["en", "es"], "project_id": "proj", **file_config} + app = _hello_app(None, load_config=lambda path: config, **kwargs) with app.test_client() as client: resp = client.get("/hello", headers={"Accept-Language": "es"}) - assert resp.json is not None and resp.json["message"] == "Hola, mundo!" - assert "https://cdn.gtx.dev/proj/es/v9?branchId=br_2" in urls + assert [url for url in cdn_urls if "/proj/es" in url] == es_urls + assert resp.json is not None + assert resp.json["message"] == ("Hola, mundo!" if es_urls else "Hello, world!") def test_flask_does_not_load_unconfigured_locales() -> None: