"""
The localizable API allows objects to be localized at the point of use.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Callable, Mapping, Sequence, MutableMapping
from typing import Any, cast, TypeAlias, Self, final, TYPE_CHECKING, overload, TypeVar
from warnings import warn
from typing_extensions import override
from betty.json.linked_data import (
LinkedDataDumpableProvider,
LinkedDataDumpable,
)
from betty.json.schema import OneOf, Null, Schema, Object
from betty.locale import UNDETERMINED_LOCALE
from betty.locale import negotiate_locale, to_locale
from betty.locale.localized import LocalizedStr
from betty.locale.localizer import DEFAULT_LOCALIZER
from betty.locale.localizer import Localizer
from betty.privacy import is_private
from betty.repr import repr_instance
from betty.typing import internal
from betty.serde.dump import DumpMapping, Dump
if TYPE_CHECKING:
from betty.project import Project
_T = TypeVar("_T")
[docs]
class Localizable(ABC):
"""
A localizable object.
Objects of this type can convert themselves to localized strings at the point of use.
"""
[docs]
@abstractmethod
def localize(self, localizer: Localizer) -> LocalizedStr:
"""
Localize ``self`` to a human-readable string.
"""
pass
@override
def __str__(self) -> str:
localized = self.localize(DEFAULT_LOCALIZER)
warn(
f'{type(self)} ("{localized}") SHOULD NOT be cast to a string. Instead, call {type(self)}.localize() to ensure it is always formatted in the desired locale.',
stacklevel=2,
)
return localized
class _FormattableLocalizable(Localizable):
def format(
self, *format_args: str | Localizable, **format_kwargs: str | Localizable
) -> Localizable:
return format(self, **format_kwargs)
class _CallLocalizable(Localizable):
def __init__(self, call: Callable[[Localizer], str]):
self._call = call
@override
def localize(self, localizer: Localizer) -> LocalizedStr:
return LocalizedStr(self._call(localizer), locale=localizer.locale)
[docs]
def call(call: Callable[[Localizer], str]) -> Localizable:
"""
Create a new localizable that outputs the callable's return value.
"""
return _CallLocalizable(call)
class _JoinLocalizable(Localizable):
def __init__(self, *localizables: Localizable, separator: str):
self._localizables = localizables
self._separator = separator
@override
def localize(self, localizer: Localizer) -> LocalizedStr:
return LocalizedStr(
self._separator.join(
localizable.localize(localizer) for localizable in self._localizables
),
locale=localizer.locale,
)
[docs]
def join(*localizables: Localizable, separator: str = " ") -> Localizable:
"""
Join multiple localizables.
"""
return _JoinLocalizable(*localizables, separator=separator)
[docs]
def do_you_mean(*available_options: str) -> Localizable:
"""
Produce a message listing available options.
"""
match len(available_options):
case 0:
return _("There are no available options.")
case 1:
return _("Do you mean {available_option}?").format(
available_option=available_options[0]
)
case _:
return _("Do you mean one of {available_options}?").format(
available_options=", ".join(sorted(available_options))
)
class _GettextLocalizable(_FormattableLocalizable):
def __init__(
self,
gettext_method_name: str,
*gettext_args: Any,
) -> None:
self._gettext_method_name = gettext_method_name
self._gettext_args = gettext_args
@override
def localize(self, localizer: Localizer) -> LocalizedStr:
return LocalizedStr(
cast(
str,
getattr(localizer, self._gettext_method_name)(*self._gettext_args), # type: ignore[operator]
),
locale=localizer.locale,
)
[docs]
def gettext(message: str) -> _GettextLocalizable:
"""
Like :py:meth:`gettext.gettext`.
Positional arguments are identical to those of :py:meth:`gettext.gettext`.
Keyword arguments are identical to those of :py:met:`str.format`, except that
any :py:class:`betty.locale.localizable.Localizable` will be localized before string
formatting.
"""
return _GettextLocalizable("gettext", message)
def _(message: str) -> _GettextLocalizable:
"""
Like :py:meth:`betty.locale.localizable.gettext`.
Positional arguments are identical to those of :py:meth:`gettext.gettext`.
Keyword arguments are identical to those of :py:met:`str.format`, except that
any :py:class:`betty.locale.localizable.Localizable` will be localized before string
formatting.
"""
return gettext(message)
[docs]
def ngettext(message_singular: str, message_plural: str, n: int) -> _GettextLocalizable:
"""
Like :py:meth:`gettext.ngettext`.
Positional arguments are identical to those of :py:meth:`gettext.ngettext`.
Keyword arguments are identical to those of :py:met:`str.format`, except that
any :py:class:`betty.locale.localizable.Localizable` will be localized before string
formatting.
"""
return _GettextLocalizable("ngettext", message_singular, message_plural, n)
[docs]
def pgettext(context: str, message: str) -> _GettextLocalizable:
"""
Like :py:meth:`gettext.pgettext`.
Positional arguments are identical to those of :py:meth:`gettext.pgettext`.
Keyword arguments are identical to those of :py:met:`str.format`, except that
any :py:class:`betty.locale.localizable.Localizable` will be localized before string
formatting.
"""
return _GettextLocalizable("pgettext", context, message)
[docs]
def npgettext(
context: str, message_singular: str, message_plural: str, n: int
) -> _GettextLocalizable:
"""
Like :py:meth:`gettext.npgettext`.
Positional arguments are identical to those of :py:meth:`gettext.npgettext`.
Keyword arguments are identical to those of :py:met:`str.format`, except that
any :py:class:`betty.locale.localizable.Localizable` will be localized before string
formatting.
"""
return _GettextLocalizable(
"npgettext", context, message_singular, message_plural, n
)
class _FormattedLocalizable(Localizable):
def __init__(
self,
localizable: Localizable,
format_args: Sequence[str | Localizable],
format_kwargs: Mapping[str, str | Localizable],
):
self._localizable = localizable
self._format_args = format_args
self._format_kwargs = format_kwargs
@override
def localize(self, localizer: Localizer) -> LocalizedStr:
return LocalizedStr(
self._localizable.localize(localizer).format(
*(
format_arg.localize(localizer)
if isinstance(format_arg, Localizable)
else format_arg
for format_arg in self._format_args
),
**{
format_kwarg_key: format_kwarg.localize(localizer)
if isinstance(format_kwarg, Localizable)
else format_kwarg
for format_kwarg_key, format_kwarg in self._format_kwargs.items()
},
)
)
class _PlainStrLocalizable(Localizable):
def __init__(self, string: str, locale: str = UNDETERMINED_LOCALE):
self._string = string
self._locale = locale
@override
def localize(self, localizer: Localizer) -> LocalizedStr:
return LocalizedStr(self._string, locale=self._locale)
[docs]
def plain(string: str) -> Localizable:
"""
Turns a plain string into a :py:class:`betty.locale.localizable.Localizable` without any actual translations.
"""
return _PlainStrLocalizable(string)
StaticTranslations: TypeAlias = Mapping[str, str]
"""
Keys are locales, values are translations.
See :py:func:`betty.locale.localizable.assertion.assert_static_translations`.
"""
ShorthandStaticTranslations: TypeAlias = StaticTranslations | str
"""
:py:const:`StaticTranslations` or a string which is the translation for the undetermined locale.
See :py:func:`betty.locale.localizable.assertion.assert_static_translations`.
"""
[docs]
class StaticTranslationsLocalizableSchema(Object):
"""
A JSON Schema for :py:class:`betty.locale.localizable.StaticTranslationsLocalizable`.
"""
[docs]
def __init__(
self, *, title: str = "Static translations", description: str | None = None
):
super().__init__(
title=title,
description=(
(description or "") + "Keys are IETF BCP-47 language tags."
).strip(),
)
self._schema["additionalProperties"] = {
"type": "string",
"description": "A human-readable translation.",
}
[docs]
class StaticTranslationsLocalizable(
_FormattableLocalizable, LinkedDataDumpable[Object, DumpMapping[Dump]]
):
"""
Provide a :py:class:`betty.locale.localizable.Localizable` backed by static translations.
"""
_translations: MutableMapping[str, str]
[docs]
def __init__(
self,
translations: ShorthandStaticTranslations | None = None,
*args: Any,
required: bool = True,
**kwargs: Any,
):
"""
:param translations: Keys are locales, values are translations.
"""
super().__init__(*args, **kwargs)
self._required = required
if translations is not None:
self.replace(translations)
else:
self._translations = {}
def __repr__(self) -> str:
return repr_instance(self, translations=self._translations)
def __getitem__(self, locale: str) -> str:
return self._translations[locale]
def __setitem__(self, locale: str, translation: str) -> None:
self._translations[locale] = translation
def __len__(self) -> int:
return len(self._translations)
[docs]
def replace(self, translations: Self | ShorthandStaticTranslations) -> None:
"""
Replace the translations.
"""
from betty.assertion import assert_len
from betty.locale.localizable.assertion import assert_static_translations
if isinstance(translations, StaticTranslationsLocalizable):
self._translations = translations._translations
else:
translations = assert_static_translations()(translations)
assert_len(minimum=1 if self._required else 0)(translations)
self._translations = dict(translations)
@property
def translations(self) -> StaticTranslations:
"""
The translations.
"""
return dict(self._translations)
[docs]
@override
def localize(self, localizer: Localizer) -> LocalizedStr:
if len(self._translations) > 1:
available_locales = tuple(self._translations.keys())
requested_locale = to_locale(
(
negotiate_locale(localizer.locale, available_locales)
or available_locales[0]
)
)
if requested_locale:
return LocalizedStr(
self._translations[requested_locale], locale=requested_locale
)
elif not self._translations:
return LocalizedStr("")
locale, translation = next(iter(self._translations.items()))
return LocalizedStr(translation, locale=locale)
[docs]
@override
async def dump_linked_data(self, project: Project) -> DumpMapping[Dump]:
return {**self._translations}
[docs]
@override
@classmethod
async def linked_data_schema(cls, project: Project) -> Object:
return StaticTranslationsLocalizableSchema()
[docs]
def static(translations: ShorthandStaticTranslations) -> Localizable:
"""
Create a new localizable that outputs the given static translations.
"""
from betty.locale.localizable.assertion import assert_static_translations
return StaticTranslationsLocalizable(assert_static_translations()(translations))
[docs]
@internal
class StaticTranslationsLocalizableAttr(LinkedDataDumpableProvider[object]):
"""
An instance attribute that contains :py:class:`betty.locale.localizable.StaticTranslationsLocalizable`.
"""
_required: bool
[docs]
def __init__(
self, attr_name: str, title: str | None = None, description: str | None = None
):
self._attr_name = f"_{attr_name}"
self._title = title
self._description = description
@overload
def __get__(self, instance: None, owner: type[object]) -> Self:
pass
@overload
def __get__(self, instance: _T, owner: type[_T]) -> StaticTranslationsLocalizable:
pass
def __get__(
self, instance: object | None, owner: type[object]
) -> StaticTranslationsLocalizable | Self:
if instance is None:
return self # type: ignore[return-value]
try:
return cast(
StaticTranslationsLocalizable, getattr(instance, self._attr_name)
)
except AttributeError:
value = StaticTranslationsLocalizable(None, required=self._required)
setattr(instance, self._attr_name, value)
return value
def __set__(self, instance: object, value: ShorthandStaticTranslations) -> None:
self.__get__(instance, type(instance)).replace(value)
[docs]
@override
async def linked_data_schema_for(self, project: Project) -> Schema:
return OneOf(
await StaticTranslationsLocalizable.linked_data_schema(project),
Null(),
title=self._title,
description=self._description,
)
[docs]
@override
async def dump_linked_data_for(self, project: Project, target: object) -> Dump:
if is_private(target):
return None
return await self.__get__(target, type(target)).dump_linked_data(project)
[docs]
@final
class RequiredStaticTranslationsLocalizableAttr(StaticTranslationsLocalizableAttr):
"""
An instance attribute that contains :py:class:`betty.locale.localizable.StaticTranslationsLocalizable`.
"""
_required = True
[docs]
@final
class OptionalStaticTranslationsLocalizableAttr(StaticTranslationsLocalizableAttr):
"""
An instance attribute that contains :py:class:`betty.locale.localizable.StaticTranslationsLocalizable`.
"""
_required = False
def __delete__(self, instance: object) -> None:
self.__get__(instance, type(instance)).replace({})