repair translations

This commit is contained in:
parent 98581132dd
commit 84adc5d941
18 changed files with 304 additions and 122 deletions

View file

@ -2,65 +2,90 @@
Утилиты локализации и зависимости переводов для FastAPI.
Содержимое модуля:
- `get_translations()`: фабрика зависимости FastAPI, считывающая локаль из
query-параметра `locale`, cookie `locale` или заголовка `Accept-Language`
(по приоритету), с дефолтом `ru`. Возвращает функцию-зависимость, которая
подгружает `babel.support.Translations` из каталога `SRV/locales`.
- `_()`: удобная обёртка для получения перевода строки с использованием
текущей локали (или дефолтного `ru`, если контекст запроса не передан).
Особенности:
- При отсутствии словаря перевода для выбранной локали выполняется fallback на
русскую локаль, что гарантирует наличие строк интерфейса.
- Локаль определяется только по первому языку из `Accept-Language` (без
региональных суффиксов) для упрощения.
- Модуль ведёт отладочное логирование выбора и загрузки локали.
- `get_translations`: зависимость FastAPI, считывающая локаль из query/cookie/header.
- `get_translations_ws`: аналог для WebSocket.
- `_load_translations`: низкоуровневая загрузка словаря переводов с fallback.
- `_()`: обёртка для gettext с дефолтом ru.
"""
from fastapi import Request, WebSocket
from babel.support import Translations
import logging
from typing import Optional, Union
from pathlib import Path
import os
from typing import Iterable, Optional
logger = logging.getLogger(__name__)
LOCALES_DIR = Path(__file__).resolve().parent.parent / "locales"
def _candidate_locale_dirs() -> Iterable[Path]:
"""Возвращает возможные каталоги с переводами."""
configured = os.getenv("GONETS_LOCALES_PATH")
if configured:
configured_path = Path(configured).expanduser()
if configured_path.exists():
yield configured_path
srv_root = Path(__file__).resolve().parent.parent
for candidate in (
srv_root / "locales",
srv_root.parent / "locales", # на случай, если каталоги вынесены на уровень выше
Path("/usr/share/gonets/locales"),
Path("/usr/local/share/gonets/locales"),
):
if candidate.exists():
yield candidate
LOCALE_DIRS = list(dict.fromkeys(_candidate_locale_dirs()))
if not LOCALE_DIRS:
logger.warning("Locales directory not found; translations will use NullTranslations")
def _load_translations(locale: str) -> Translations:
"""Подгружает словарь переводов с fallback на ru."""
try:
translations = Translations.load(str(LOCALES_DIR), [locale], "messages")
if not translations._catalog:
logger.warning(f"Translations for locale {locale} not found, falling back to ru")
translations = Translations.load(str(LOCALES_DIR), ["ru"], "messages")
return translations
except Exception as e: # pragma: no cover - логируем и возвращаем ru
logger.error(f"Failed to load translations for locale {locale}: {str(e)}")
return Translations.load(str(LOCALES_DIR), ["ru"], "messages")
last_error: Optional[str] = None
for directory in LOCALE_DIRS or [None]:
try:
if directory is None:
raise FileNotFoundError("Locales directory list is empty")
translations = Translations.load(str(directory), [locale], "messages")
if translations._catalog:
return translations
logger.warning(
"Translations catalog empty for locale %s in %s; trying fallback ru",
locale,
directory,
)
ru_translations = Translations.load(str(directory), ["ru"], "messages")
if ru_translations._catalog:
return ru_translations
except Exception as exc: # pragma: no cover - логируем и возвращаем ru
last_error = str(exc)
continue
logger.error(
"Failed to load translations for locale %s from dirs %s: %s. Using NullTranslations",
locale,
LOCALE_DIRS,
last_error,
)
return Translations()
def get_translations() -> callable:
"""
Создаёт зависимость для загрузки переводов на основе заголовка Accept-Language
или параметра запроса 'locale'. По умолчанию используется русский язык.
"""
def translations_dependency(request: Optional[Union[Request, WebSocket]] = None) -> Translations:
if request is None:
logger.warning("Translations dependency called without request/websocket; fallback to ru")
return _load_translations("ru")
def _resolve_locale_from_request(request: Request) -> str:
locale = request.query_params.get("locale")
if not locale:
locale = request.cookies.get("locale")
if not locale:
locale = request.headers.get("Accept-Language", "ru").split(",")[0].split("-")[0]
return locale or "ru"
# Проверяем параметр запроса 'locale', если он есть
locale = None
if hasattr(request, "query_params"):
locale = request.query_params.get("locale")
if not locale and hasattr(request, "cookies"):
locale = request.cookies.get("locale")
if not locale and hasattr(request, "headers"):
locale = request.headers.get("Accept-Language", "ru").split(",")[0].split("-")[0]
def get_translations(request: Request) -> Translations:
"""Зависимость FastAPI для HTTP-эндпоинтов."""
locale = _resolve_locale_from_request(request)
logger.debug(f"Determined locale (http): {locale}")
return _load_translations(locale)
locale = locale or "ru"
logger.debug(f"Determined locale: {locale}")
return _load_translations(locale)
return translations_dependency
def get_translations_ws(websocket: WebSocket) -> Translations:
"""Зависимость FastAPI для WebSocket-эндпоинтов."""
# WebSocket не имеет cookies/headers в виде HTTPRequest, но есть headers
header_locale = websocket.headers.get("accept-language", "ru").split(",")[0].split("-")[0] if websocket.headers else "ru"
logger.debug(f"Determined locale (ws): {header_locale}")
return _load_translations(header_locale or "ru")
def _(msgid: str, request: Request = None) -> str:
"""
@ -74,10 +99,9 @@ def _(msgid: str, request: Request = None) -> str:
str: Переведенная строка.
"""
if request is None:
# Язык, который будет отображаться по умолчанию
translations = _load_translations("ru")
logger.debug(f"Using default ru translations for msgid: {msgid}")
else:
translations = get_translations()(request)
translations = get_translations(request)
logger.debug(f"Using translations for locale {request.cookies.get('locale', 'ru')} for msgid: {msgid}")
return translations.gettext(msgid)