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
32 changes: 24 additions & 8 deletions packages/gt-fastapi/src/gt_fastapi/_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,22 @@
from generaltranslation import CustomMapping
from generaltranslation._settings import LIBRARY_DEFAULT_LOCALE
from gt_i18n import I18nManager, set_i18n_manager
from gt_i18n.internal import _detect_from_accept_language
from gt_i18n.internal import GTConfig, _detect_from_accept_language, load_gt_config


def initialize_gt(
app: Any,
*,
default_locale: str = LIBRARY_DEFAULT_LOCALE,
default_locale: str | None = None,
locales: list[str] | None = None,
custom_mapping: CustomMapping | None = None,
project_id: str | None = None,
cache_url: str | None = None,
get_locale: Callable[..., str] | None = None,
load_translations: Callable[[str], dict[str, str]] | None = None,
eager_loading: bool = True,
config_path: str | None = None,
load_config: Callable[[str | None], GTConfig] | None = None,
) -> I18nManager:
"""Initialize General Translation for a FastAPI app.

Expand All @@ -35,17 +37,31 @@ def initialize_gt(
get_locale: Custom locale detection callback ``(request) -> str``.
load_translations: Custom translation loader ``(locale) -> dict``.
eager_loading: Load all translations at startup (default True).
**kwargs: Additional kwargs passed to I18nManager.
config_path: Path to a ``gt.config.json`` file.
load_config: Custom config loader replacing the default.

Returns:
The configured I18nManager.
"""
if load_config is not None:
file_config = load_config(config_path)
elif config_path is not None:
file_config = load_gt_config(config_path)
else:
file_config = load_gt_config()

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")
resolved_custom_mapping = custom_mapping or file_config.get("custom_mapping")

manager = I18nManager(
default_locale=default_locale,
locales=locales,
custom_mapping=custom_mapping,
project_id=project_id,
cache_url=cache_url,
default_locale=resolved_default_locale,
locales=resolved_locales,
custom_mapping=resolved_custom_mapping,
project_id=resolved_project_id,
cache_url=resolved_cache_url,
load_translations=load_translations,
)
set_i18n_manager(manager)
Expand Down
32 changes: 24 additions & 8 deletions packages/gt-flask/src/gt_flask/_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,22 @@
from generaltranslation import CustomMapping
from generaltranslation._settings import LIBRARY_DEFAULT_LOCALE
from gt_i18n import I18nManager, set_i18n_manager
from gt_i18n.internal import _detect_from_accept_language
from gt_i18n.internal import GTConfig, _detect_from_accept_language, load_gt_config


def initialize_gt(
app: Any,
*,
default_locale: str = LIBRARY_DEFAULT_LOCALE,
default_locale: str | None = None,
locales: list[str] | None = None,
custom_mapping: CustomMapping | None = None,
project_id: str | None = None,
cache_url: str | None = None,
get_locale: Callable[..., str] | None = None,
load_translations: Callable[[str], dict[str, str]] | None = None,
eager_loading: bool = True,
config_path: str | None = None,
load_config: Callable[[str | None], GTConfig] | None = None,
) -> I18nManager:
"""Initialize General Translation for a Flask app.

Expand All @@ -35,17 +37,31 @@ def initialize_gt(
get_locale: Custom locale detection callback ``(request) -> str``.
load_translations: Custom translation loader ``(locale) -> dict``.
eager_loading: Load all translations at startup (default True).
**kwargs: Additional kwargs passed to I18nManager.
config_path: Path to a ``gt.config.json`` file.
load_config: Custom config loader replacing the default.

Returns:
The configured I18nManager.
"""
if load_config is not None:
file_config = load_config(config_path)
elif config_path is not None:
file_config = load_gt_config(config_path)
else:
file_config = load_gt_config()

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")
resolved_custom_mapping = custom_mapping or file_config.get("custom_mapping")

manager = I18nManager(
default_locale=default_locale,
locales=locales,
custom_mapping=custom_mapping,
project_id=project_id,
cache_url=cache_url,
default_locale=resolved_default_locale,
locales=resolved_locales,
custom_mapping=resolved_custom_mapping,
project_id=resolved_project_id,
cache_url=resolved_cache_url,
load_translations=load_translations,
)
set_i18n_manager(manager)
Expand Down
3 changes: 2 additions & 1 deletion packages/gt-i18n/src/gt_i18n/i18n_manager/_i18n_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ def __init__(
cache_expiry_time: int = 60_000,
) -> None:
self._default_locale = default_locale
self._locales = locales or []
locales_set: set[str] = {default_locale, *(locales or [])}
self._locales = list(locales_set)
self._project_id = project_id
self._cache_url = cache_url
self._custom_mapping = custom_mapping
Expand Down
8 changes: 8 additions & 0 deletions packages/gt-i18n/src/gt_i18n/internal/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
from gt_i18n.internal._detect_from_accept_language import _detect_from_accept_language
from gt_i18n.internal._load_gt_config import (
DEFAULT_GT_CONFIG_PATH,
GTConfig,
load_gt_config,
)

__all__ = [
"_detect_from_accept_language",
"DEFAULT_GT_CONFIG_PATH",
"GTConfig",
"load_gt_config",
]
69 changes: 69 additions & 0 deletions packages/gt-i18n/src/gt_i18n/internal/_load_gt_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""Load configuration from a gt.config.json file."""

from __future__ import annotations

import json
from typing import TypedDict

from generaltranslation import CustomMapping

DEFAULT_GT_CONFIG_PATH = "gt.config.json"

_KEY_MAP: dict[str, str] = {
"defaultLocale": "default_locale",
"locales": "locales",
"projectId": "project_id",
"cacheUrl": "cache_url",
"runtimeUrl": "runtime_url",
"customMapping": "custom_mapping",
}


class GTConfig(TypedDict, total=False):
default_locale: str
locales: list[str]
project_id: str
cache_url: str
runtime_url: str
custom_mapping: CustomMapping


def load_gt_config(config_path: str | None = None) -> GTConfig:
"""Read a ``gt.config.json`` file and return the relevant config values.

Args:
config_path: Explicit path to the config file. When *None*, the
default ``gt.config.json`` (relative to CWD) is tried; if it does
not exist an empty config is returned.

Raises:
FileNotFoundError: If *config_path* was explicitly provided but the
file does not exist.
ValueError: If the file contains invalid JSON or the top-level value
is not an object.
"""
path = config_path if config_path is not None else DEFAULT_GT_CONFIG_PATH
explicit = config_path is not None

try:
with open(path, encoding="utf-8") as f:
raw = f.read()
except FileNotFoundError:
if explicit:
raise
return GTConfig()

try:
data = json.loads(raw)
except json.JSONDecodeError as exc:
raise ValueError(f"Invalid JSON in {path}: {exc}") from exc

if not isinstance(data, dict):
raise ValueError(f"Expected a JSON object in {path}, got {type(data).__name__}")

config = GTConfig()
for json_key, py_key in _KEY_MAP.items():
if json_key in data:
config[py_key] = data[json_key] # type: ignore[literal-required]

return config
4 changes: 2 additions & 2 deletions packages/gt-i18n/tests/test_i18n_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ def test_requires_translation_different_locale() -> None:

def test_get_locales() -> None:
mgr = I18nManager(default_locale="en", locales=["en", "es", "fr"])
assert mgr.get_locales() == ["en", "es", "fr"]
assert set(mgr.get_locales()) == {"en", "es", "fr"}


def test_custom_loader() -> None:
Expand All @@ -67,4 +67,4 @@ def loader(locale: str) -> dict[str, str]:

mgr = I18nManager(default_locale="en", locales=["es", "fr"], load_translations=loader)
asyncio.run(mgr.load_all_translations())
assert loaded == {"es", "fr"}
assert loaded == {"en", "es", "fr"}
104 changes: 104 additions & 0 deletions packages/gt-i18n/tests/test_load_gt_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""Tests for gt_i18n.internal._load_gt_config."""

from __future__ import annotations

import json
from pathlib import Path
from typing import Any

import pytest
from gt_i18n.internal._load_gt_config import load_gt_config


def _write_json(tmp_path: Path, data: Any, filename: str = "gt.config.json") -> str:
path = tmp_path / filename
path.write_text(json.dumps(data), encoding="utf-8")
return str(path)


class TestLoadGTConfig:
def test_all_fields(self, tmp_path: Path) -> None:
path = _write_json(
tmp_path,
{
"defaultLocale": "en-US",
"locales": ["fr", "de"],
"projectId": "proj-123",
"cacheUrl": "https://cache.example.com",
"runtimeUrl": "https://runtime.example.com",
"customMapping": {"en-US": "English"},
},
)
config = load_gt_config(path)
assert config["default_locale"] == "en-US"
assert config["locales"] == ["fr", "de"]
assert config["project_id"] == "proj-123"
assert config["cache_url"] == "https://cache.example.com"
assert config["runtime_url"] == "https://runtime.example.com"
assert config["custom_mapping"] == {"en-US": "English"}

def test_custom_mapping(self, tmp_path: Path) -> None:
path = _write_json(
tmp_path,
{
"customMapping": {
"en-US": {"name": "English", "nativeName": "English"},
"fr": "French",
},
},
)
config = load_gt_config(path)
assert config["custom_mapping"] == {
"en-US": {"name": "English", "nativeName": "English"},
"fr": "French",
}

def test_explicit_path_missing_raises(self, tmp_path: Path) -> None:
missing = str(tmp_path / "does_not_exist.json")
with pytest.raises(FileNotFoundError):
load_gt_config(missing)

def test_no_path_default_missing_returns_empty(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
config = load_gt_config()
assert config == {}

def test_invalid_json_raises(self, tmp_path: Path) -> None:
path = tmp_path / "bad.json"
path.write_text("{not valid json", encoding="utf-8")
with pytest.raises(ValueError, match="Invalid JSON"):
load_gt_config(str(path))

def test_non_object_json_raises(self, tmp_path: Path) -> None:
path = _write_json(tmp_path, [1, 2, 3], filename="array.json")
with pytest.raises(ValueError, match="Expected a JSON object"):
load_gt_config(path)

def test_unknown_keys_ignored(self, tmp_path: Path) -> None:
path = _write_json(
tmp_path,
{
"defaultLocale": "en",
"unknownKey": "should be ignored",
"anotherUnknown": 42,
},
)
config = load_gt_config(path)
assert config == {"default_locale": "en"}
assert "unknownKey" not in config
assert "anotherUnknown" not in config

def test_partial_config(self, tmp_path: Path) -> None:
path = _write_json(
tmp_path,
{
"projectId": "my-project",
"locales": ["es"],
},
)
config = load_gt_config(path)
assert config == {
"project_id": "my-project",
"locales": ["es"],
}
assert "default_locale" not in config