## /utils/system/localization.py
import json
import sys
from threading import Lock
from pathlib import Path
from typing import Any
from loguru import logger
from ..interface import MutableStaticMeta, StaticClass, Disposable
from .properties import SystemProperties
from .err import LocalizationError
__all__ = [
'Localization',
'_',
]
[документация]
class Localization(StaticClass, Disposable, metaclass=MutableStaticMeta):
"""
Глобальный статический менеджер локализации (i18n).
Хранит словари переводов и обеспечивает рантайм-доступ к фразам.
"""
__slots__ = ()
_LOCK: Lock = Lock()
# Текущий активный язык (по умолчанию подтягивается из ядра ОС)
# Обрезаем до двух символов (например, 'ru_RU' -> 'ru')
current_locale: str = "en"
# Глобальное дерево переводов: { 'ru': { 'auth': { 'title': 'Вход' } } }
_translations: dict[str, dict[str, Any]] = {
"en": {},
"ru": {}
}
[документация]
@classmethod
def initialize(cls) -> None:
"""Автоматическая инициализация дефолтного системного языка."""
with cls._LOCK:
sys_lang = SystemProperties.language.split("_")[0].lower() #type ignore[attr-defined]
if sys_lang in cls._translations:
cls.current_locale = sys_lang
else:
cls.current_locale = "en"
logger.info(f"Localization initialized. Default system language: '{cls.current_locale}'")
[документация]
@classmethod
def load_from_dict(cls, locale: str, data: dict[str, Any]) -> None:
"""
Загружает словарь переводов. Безопасно выполняет глубокое рекурсивное
слияние, если ключи (например, 'ERRORS') пересекаются в разных модулях.
:param locale: Локаль, данные для которой нужно установить.
:param data: Словарь с данными, которые необходимо загрузить.
"""
with cls._LOCK:
if locale not in cls._translations:
cls._translations[locale] = {}
cls._deep_merge(cls._translations[locale], data)
# --- ИНТРОСПЕКЦИЯ СТЕКА ВЫЗОВА ДЛЯ ИНФОРМАТИВНОГО ЛОГА ---
try:
# f_back возвращает кадр (frame) функции, которая вызвала load_from_dict
caller_frame = sys._getframe(1)
caller_module = caller_frame.f_globals.get('__name__', 'unknown_module')
caller_file = Path(caller_frame.f_globals.get('__file__', '')).name
# Используем .opt() логуру для динамической подмены контекста в логе
logger.patch(lambda record: record.update( #type ignore[call-arg]
name=caller_module, #type ignore[call-arg]
file=caller_file #type ignore[call-arg]
)).debug(f"Translations loaded for locale '{locale}' (Source: {caller_module})")
except Exception:
# Резервный вариант, если стек недоступен в данной сборке Python
logger.debug(f"Translations loaded from dict for locale '{locale}'")
@classmethod
def _deep_merge(cls, target: dict[str, Any], source: dict[str, Any]) -> None:
"""
Внутренний рекурсивный метод для слияния вложенных словарей.
:param target: Словарь для записи новых значений.
:param source: Словарь содержащий новые значения.
"""
for key, value in source.items():
if key in target and isinstance(target[key], dict) and isinstance(value, dict):
# Если оба элемента являются словарями, уходим в рекурсию
cls._deep_merge(target[key], value)
else:
# В противном случае (новый ключ или перезапись конечной строки) просто копируем
target[key] = value
[документация]
@classmethod
def load_from_json(cls, locale: str, file_path: str | Path) -> None:
"""
Загружает переводы из внешнего JSON-файла.
:param locale: Локаль, данные для которой нужно установить.
:param file_path: Путь к файлу со словарем с данными, которые необходимо загрузить.
"""
try:
path = Path(file_path)
if not path.exists():
raise FileNotFoundError(f"Localization file {path} not found.")
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
cls.load_from_dict(locale, data)
except Exception as e:
logger.error(f"Failed to load localization file for '{locale}': {e}")
[документация]
@classmethod
def set_locale(cls, locale: str) -> None:
"""
Переключает текущий активный язык приложения в рантайме.
:param locale: Локаль которую необходимо установить.
:raises LocalizationError: Если данная локаль не инициализированна.
"""
with cls._LOCK:
if locale not in cls._translations:
logger.error(f"Error switching to unregistered locale: '{locale}'")
raise LocalizationError(f"Error switching to unregistered locale: '{locale}'")
cls.current_locale = locale
logger.info(f"Application language changed to: '{locale}'")
[документация]
@classmethod
def get(cls, key_path: str, default: str | None = None) -> str:
"""
Извлекает фразу по иерархическому пути через точку.
Пример: Localization.get("auth.login_btn")
:param key_path: Путь к ключу (например, 'main_menu.profile.title').
:param default: Значение по умолчанию, если ключ не найден.
:return: Значение, установленное по данному ключу.
"""
with cls._LOCK:
locale_dict = cls._translations.get(cls.current_locale, {})
# Резервный откат на английский, если в текущем языке вообще нет словаря
if not locale_dict and cls.current_locale != "en":
locale_dict = cls._translations.get("en", {})
# Идем вглубь словаря по точкам (разбиваем 'auth.btn' -> ['auth', 'btn'])
keys = key_path.split(".")
current_node: Any = locale_dict
for key in keys:
if isinstance(current_node, dict) and key in current_node:
current_node = current_node[key]
else:
# Если ключ не найден, пробуем поискать в дефолтном английском
if cls.current_locale != "en":
return cls._get_fallback(key_path, default)
return default or f"[{key_path}]"
return str(current_node)
@classmethod
def _get_fallback(cls, key_path: str, default: str | None = None) -> str:
"""
Внутренний резервный поиск фразы в английском словаре (fallback).
:param key_path: Путь к ключу (например, 'main_menu.profile.title').
:param default: Значение по умолчанию, если ключ не найден.
:return: Значение, установленное по данному ключу.
"""
en_dict = cls._translations.get("en", {})
keys = key_path.split(".")
current_node: Any = en_dict
for key in keys:
if isinstance(current_node, dict) and key in current_node:
current_node = current_node[key]
else:
return default or f"[{key_path}]"
return str(current_node)
[документация]
@classmethod
def clear(cls) -> None:
"""Потокобезопасно очищает все загруженные переводы и сбрасывает локаль."""
with cls._LOCK:
cls.current_locale = "en"
cls._translations.clear()
cls._translations["en"] = {}
cls._translations["ru"] = {}
[документация]
@classmethod
def destroy(cls) -> None:
"""Потокобезопасно очищает все загруженные переводы и сбрасывает локаль."""
cls.clear()
def _(key_path: str, default: str | None = None) -> str:
"""
Глобальная функция-шорткат (общепринятый стандарт i18n).
Позволяет лаконично вызывать перевод в любом месте кода: _("auth.title")
:param key_path: Путь до нужной фразы.
:param default: Значение по умолчанию, если в словаре фраза не зарегестрирована.
:return: Фраза, зарегестрированая под ключом.
"""
return Localization.get(key_path, default)