diff --git a/packages/gt-fastapi/src/gt_fastapi/_setup.py b/packages/gt-fastapi/src/gt_fastapi/_setup.py index eeb289f..034a626 100644 --- a/packages/gt-fastapi/src/gt_fastapi/_setup.py +++ b/packages/gt-fastapi/src/gt_fastapi/_setup.py @@ -9,13 +9,13 @@ 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, @@ -23,6 +23,8 @@ def initialize_gt( 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. @@ -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) diff --git a/packages/gt-flask/src/gt_flask/_setup.py b/packages/gt-flask/src/gt_flask/_setup.py index fd79288..4ecb254 100644 --- a/packages/gt-flask/src/gt_flask/_setup.py +++ b/packages/gt-flask/src/gt_flask/_setup.py @@ -9,13 +9,13 @@ 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, @@ -23,6 +23,8 @@ def initialize_gt( 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. @@ -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) diff --git a/packages/gt-i18n/src/gt_i18n/i18n_manager/_i18n_manager.py b/packages/gt-i18n/src/gt_i18n/i18n_manager/_i18n_manager.py index 095d110..2004f52 100644 --- a/packages/gt-i18n/src/gt_i18n/i18n_manager/_i18n_manager.py +++ b/packages/gt-i18n/src/gt_i18n/i18n_manager/_i18n_manager.py @@ -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 diff --git a/packages/gt-i18n/src/gt_i18n/internal/__init__.py b/packages/gt-i18n/src/gt_i18n/internal/__init__.py index c6cc4db..063e74a 100644 --- a/packages/gt-i18n/src/gt_i18n/internal/__init__.py +++ b/packages/gt-i18n/src/gt_i18n/internal/__init__.py @@ -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", ] diff --git a/packages/gt-i18n/src/gt_i18n/internal/_load_gt_config.py b/packages/gt-i18n/src/gt_i18n/internal/_load_gt_config.py new file mode 100644 index 0000000..7dac98d --- /dev/null +++ b/packages/gt-i18n/src/gt_i18n/internal/_load_gt_config.py @@ -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 diff --git a/packages/gt-i18n/tests/test_i18n_manager.py b/packages/gt-i18n/tests/test_i18n_manager.py index ccb7bba..83f48b1 100644 --- a/packages/gt-i18n/tests/test_i18n_manager.py +++ b/packages/gt-i18n/tests/test_i18n_manager.py @@ -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: @@ -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"} diff --git a/packages/gt-i18n/tests/test_load_gt_config.py b/packages/gt-i18n/tests/test_load_gt_config.py new file mode 100644 index 0000000..29e0308 --- /dev/null +++ b/packages/gt-i18n/tests/test_load_gt_config.py @@ -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