Source code for univention.lib.i18n
#!/usr/bin/python3
# SPDX-FileCopyrightText: 2006-2025 Univention GmbH
# SPDX-License-Identifier: AGPL-3.0-only
"""Internationalization (i18n) utilities."""
import gettext
import re
import weakref
from locale import LC_MESSAGES, Error, getlocale
[docs]
class I18N_Error(Exception):
"""Error in Internationalization."""
[docs]
class Locale:
"""
Represents a locale specification and provides simple access to
language, territory, codeset and modifier.
:param locale: The locale string `language[_territory][.codeset][@modifier]`.
:type locale: str or None
>>> Locale("deu_GER")
>>> str(Locale("ca_ES@valencia"))
>>> str(Locale(""))
"""
REGEX = re.compile(
r'^'
r'(?P<language>([a-z]{2}|C|POSIX))'
r'(?:_(?P<territory>[A-Z]{2}))?'
r'(?:\.(?P<codeset>[a-zA-Z-0-9]+))?'
r'(?:@(?P<modifier>.+))?'
r'$')
def __init__(self, locale: str | None = None) -> None:
self.__reset()
if locale is not None:
self.parse(locale)
def __reset(self) -> None:
self.language = ""
self.territory = ""
self.codeset = ""
self.modifier = ""
[docs]
def parse(self, locale: str) -> None:
"""
Parse locale string.
:param str locale: The locale string `language[_territory][.codeset][@modifier]`.
:raises TypeError: if `locale` is not a string.
:raises I18N_Error: if `locale` does not match the format.
"""
if not isinstance(locale, str):
raise TypeError('locale must be of type string')
self.__reset()
regex = Locale.REGEX.match(locale)
if not regex:
raise I18N_Error('attribute does not match locale specification language[_territory][.codeset][@modifier]')
self.codeset = 'UTF-8' # default encoding
for key, value in regex.groupdict().items():
if value is None:
continue
setattr(self, key, value)
def __bool__(self) -> bool:
return bool(self.language)
__nonzero__ = __bool__
def __str__(self) -> str:
text = self.language or ''
if self.language not in ('C', 'POSIX') and self.territory:
text += '_%s' % self.territory
if self.codeset:
text += '.%s' % self.codeset
if self.modifier:
text += '@%s' % self.modifier
return text
[docs]
class NullTranslation:
"""
Dummy translation.
:param str namespace: The name of the translation domain.
:param str locale_spec: The selected locale.
:param str localedir: The name of the directory containing the translation files.
"""
def __init__(self, namespace: str, locale_spec: str | None = None, localedir: str | None = None) -> None:
self._set_domain(namespace)
self._translation: gettext.NullTranslations | None = None
self._localedir: str | None = localedir
self._localespec: Locale | None = None
self._locale: str | None = locale_spec
if not self._locale:
self.set_language()
def _set_domain(self, namespace: str) -> None:
"""
Select translation domain.
:param str namespace: The name of the translation domain.
"""
if namespace is not None:
self._domain = namespace.replace('/', '-').replace('.', '-')
else:
self._domain = None
domain = property(fset=_set_domain)
[docs]
def set_language(self, language: str = "") -> None:
"""
Select language.
:param str language: The language code.
"""
def _get_locale(self) -> Locale | None:
"""
Return currently selected locale.
:returns: The currently selected locale.
:rtype: Locale
"""
return self._localespec
def _set_locale(self, locale_spec: str | None = None) -> None:
"""
Select new locale.
:param str locale_spec: The new locale specification.
"""
if locale_spec is None:
return
self._localespec = Locale(locale_spec)
locale = property(fget=_get_locale, fset=_set_locale)
[docs]
def translate(self, message: str) -> str:
"""
Translate message.
:param str message: The message to translate.
:returns: The localized message.
:rtype: str
"""
if self._translation is None:
return message
return self._translation.gettext(message)
_ = translate
[docs]
class Translation(NullTranslation):
"""Translation."""
_instances: list[weakref.ReferenceType['Translation']] = []
locale: Locale = Locale() # type: ignore
[docs]
def set_language(self, language: str = "") -> None:
"""
Select language.
:param str language: The language code.
:raises I18N_Error: if the given locale is not valid.
"""
if language:
Translation.locale.parse(language)
if not Translation.locale:
try:
lang = getlocale(LC_MESSAGES)
language = lang[0] or "C"
Translation.locale.parse(language)
except Error as exc:
raise I18N_Error('The given locale is not valid: %s' % (exc,))
if not self._domain:
return
try:
self._translation = gettext.translation(self._domain, languages=(Translation.locale.language, ), localedir=self._localedir)
except OSError:
try:
self._translation = gettext.translation(self._domain, languages=('%s_%s' % (Translation.locale.language, Translation.locale.territory), ), localedir=self._localedir)
except OSError:
self._translation = None
def __new__(cls, *args, **kwargs):
self = object.__new__(cls)
cls._instances.append(weakref.ref(self))
cls._instances = [ref for ref in cls._instances if ref() is not None]
return self
[docs]
@classmethod
def set_all_languages(cls, language: str) -> None:
"""
Set the language of all existing :class:`Translation` instances.
This is required when instances are created during import time but later on the language should be changed.
"""
for ref in cls._instances:
instance = ref()
if instance is not None:
instance.set_language(language)