Hide navigation sidebar
Hide table of contents sidebar
Skip to content
Back to top
Toggle table of contents sidebar

# Source code for betty.locale.localizer

    """
    Localizers provide a wide range of localization utilities through a single entry point.
    """

    from __future__ import annotations

    import datetime
    import gettext
    from collections import defaultdict
    from contextlib import suppress
    from typing import final, Mapping, Iterator, AsyncIterator, TYPE_CHECKING

    import aiofiles
    from babel import dates
    from babel.dates import format_date
    from betty import fs
    from betty.concurrent import _Lock, AsynchronizedLock
    from betty.hashid import hashid_file_meta
    from betty.locale import (
        get_data,
        to_babel_identifier,
        DEFAULT_LOCALE,
        Localey,
        to_locale,
        negotiate_locale,
    )
    from betty.locale.babel import run_babel
    from betty.locale.date import (
        DatePartsFormatters,
        DateFormatters,
        DateRangeFormatters,
        Datey,
        Date,
        IncompleteDateError,
        DateRange,
    )
    from polib import pofile

    if TYPE_CHECKING:
        from betty.assets import AssetRepository
        from pathlib import Path

    class Localizer:
        """
        Provide localization functionality for a specific locale.
        """

        def __init__(self, locale: str, translations: gettext.NullTranslations):
            self._locale = locale
            self._locale_data = get_data(locale)
            self._translations = translations
            self.__date_parts_formatters: DatePartsFormatters | None = None
            self.__date_formatters: DateFormatters | None = None
            self.__date_range_formatters: DateRangeFormatters | None = None

        @property
        def locale(self) -> str:
            """
            The locale.
            """
            return self._locale

        def _(self, message: str) -> str:
            """
            Like :py:meth:`gettext.gettext`.

            Arguments are identical to those of :py:meth:`gettext.gettext`.
            """
            return self._translations.gettext(message)

        def gettext(self, message: str) -> str:
            """
            Like :py:meth:`gettext.gettext`.

            Arguments are identical to those of :py:meth:`gettext.gettext`.
            """
            return self._translations.gettext(message)

        def ngettext(self, message_singular: str, message_plural: str, n: int) -> str:
            """
            Like :py:meth:`gettext.ngettext`.

            Arguments are identical to those of :py:meth:`gettext.ngettext`.
            """
            return self._translations.ngettext(message_singular, message_plural, n)

        def pgettext(self, context: str, message: str) -> str:
            """
            Like :py:meth:`gettext.pgettext`.

            Arguments are identical to those of :py:meth:`gettext.pgettext`.
            """
            return self._translations.pgettext(context, message)

        def npgettext(
            self, context: str, message_singular: str, message_plural: str, n: int
        ) -> str:
            """
            Like :py:meth:`gettext.npgettext`.

            Arguments are identical to those of :py:meth:`gettext.npgettext`.
            """
            return self._translations.npgettext(
                context, message_singular, message_plural, n
            )

        @property
        def _date_parts_formatters(self) -> DatePartsFormatters:
            if self.__date_parts_formatters is None:
                self.__date_parts_formatters = {
                    (True, True, True): self._("MMMM d, y"),
                    (True, True, False): self._("MMMM, y"),
                    (True, False, False): self._("y"),
                    (False, True, True): self._("MMMM d"),
                    (False, True, False): self._("MMMM"),
                }
            return self.__date_parts_formatters

        @property
        def _date_formatters(self) -> DateFormatters:
            if self.__date_formatters is None:
                self.__date_formatters = {
                    (True,): self._("around {date}"),
                    (False,): self._("{date}"),
                }
            return self.__date_formatters

        @property
        def _date_range_formatters(self) -> DateRangeFormatters:
            if self.__date_range_formatters is None:
                self.__date_range_formatters = {
                    (False, False, False, False): self._(
                        "from {start_date} until {end_date}"
                    ),
                    (False, False, False, True): self._(
                        "from {start_date} until sometime before {end_date}"
                    ),
                    (False, False, True, False): self._(
                        "from {start_date} until around {end_date}"
                    ),
                    (False, False, True, True): self._(
                        "from {start_date} until sometime before around {end_date}"
                    ),
                    (False, True, False, False): self._(
                        "from sometime after {start_date} until {end_date}"
                    ),
                    (False, True, False, True): self._(
                        "sometime between {start_date} and {end_date}"
                    ),
                    (False, True, True, False): self._(
                        "from sometime after {start_date} until around {end_date}"
                    ),
                    (False, True, True, True): self._(
                        "sometime between {start_date} and around {end_date}"
                    ),
                    (True, False, False, False): self._(
                        "from around {start_date} until {end_date}"
                    ),
                    (True, False, False, True): self._(
                        "from around {start_date} until sometime before {end_date}"
                    ),
                    (True, False, True, False): self._(
                        "from around {start_date} until around {end_date}"
                    ),
                    (True, False, True, True): self._(
                        "from around {start_date} until sometime before around {end_date}"
                    ),
                    (True, True, False, False): self._(
                        "from sometime after around {start_date} until {end_date}"
                    ),
                    (True, True, False, True): self._(
                        "sometime between around {start_date} and {end_date}"
                    ),
                    (True, True, True, False): self._(
                        "from sometime after around {start_date} until around {end_date}"
                    ),
                    (True, True, True, True): self._(
                        "sometime between around {start_date} and around {end_date}"
                    ),
                    (False, False, None, None): self._("from {start_date}"),
                    (False, True, None, None): self._("sometime after {start_date}"),
                    (True, False, None, None): self._("from around {start_date}"),
                    (True, True, None, None): self._("sometime after around {start_date}"),
                    (None, None, False, False): self._("until {end_date}"),
                    (None, None, False, True): self._("sometime before {end_date}"),
                    (None, None, True, False): self._("until around {end_date}"),
                    (None, None, True, True): self._("sometime before around {end_date}"),
                }
            return self.__date_range_formatters

        def format_datey(self, date: Datey) -> str:
            """
            Format a datey value into a human-readable string.
            """
            if isinstance(date, Date):
                return self.format_date(date)
            return self.format_date_range(date)

        def format_date(self, date: Date) -> str:
            """
            Format a date to a human-readable string.
            """
            try:
                return self._date_formatters[(date.fuzzy,)].format(
                    date=self._format_date_parts(date),
                )
            except IncompleteDateError:
                return self._("unknown date")

        def _format_date_parts(self, date: Date | None) -> str:
            if date is None:
                raise IncompleteDateError("This date is None.")
            try:
                date_parts_format = self._date_parts_formatters[
                    tuple(
                        (x is not None for x in date.parts),  # type: ignore[index]
                    )
                ]
            except KeyError:
                raise IncompleteDateError(
                    "This date does not have enough parts to be rendered."
                ) from None
            parts = (1 if x is None else x for x in date.parts)
            return dates.format_date(
                datetime.date(*parts), date_parts_format, self._locale_data
            )

        def format_date_range(self, date_range: DateRange) -> str:
            """
            Format a date range to a human-readable string.
            """
            formatter_configuration: tuple[
                bool | None, bool | None, bool | None, bool | None
            ] = (None, None, None, None)
            formatter_arguments = {}

            with suppress(IncompleteDateError):
                formatter_arguments["start_date"] = self._format_date_parts(
                    date_range.start
                )
                formatter_configuration = (
                    None if date_range.start is None else date_range.start.fuzzy,
                    date_range.start_is_boundary,
                    formatter_configuration[2],
                    formatter_configuration[3],
                )

            with suppress(IncompleteDateError):
                formatter_arguments["end_date"] = self._format_date_parts(date_range.end)
                formatter_configuration = (
                    formatter_configuration[0],
                    formatter_configuration[1],
                    None if date_range.end is None else date_range.end.fuzzy,
                    date_range.end_is_boundary,
                )

            if not formatter_arguments:
                raise IncompleteDateError(
                    "This date range does not have enough parts to be rendered."
                )

            return self._date_range_formatters[formatter_configuration].format(
                **formatter_arguments
            )

        def format_datetime_datetime(self, datetime_datetime: datetime.datetime) -> str:
            """
            Format a datetime date to a human-readable string.
            """
            return format_date(
                datetime_datetime, "long", locale=to_babel_identifier(self.locale)
            )

    DEFAULT_LOCALIZER = Localizer(DEFAULT_LOCALE, gettext.NullTranslations())

    @final
    class LocalizerRepository:
        """
        Exposes the available localizers.
        """

        def __init__(self, assets: AssetRepository):
            self._assets = assets
            self._localizers: dict[str, Localizer] = {}
            self._locks: Mapping[str, _Lock] = defaultdict(AsynchronizedLock.threading)
            self._locales: set[str] | None = None

        @property
        def locales(self) -> Iterator[str]:
            """
            The available locales.
            """
            if self._locales is None:
                self._locales = set()
                self._locales.add(DEFAULT_LOCALE)
                for assets_directory_path in reversed(self._assets.assets_directory_paths):
                    for po_file_path in assets_directory_path.glob("locale/*/betty.po"):
                        self._locales.add(po_file_path.parent.name)
            yield from self._locales

        async def get(self, locale: Localey) -> Localizer:
            """
            Get the localizer for the given locale.
            """
            locale = to_locale(locale)
            async with self._locks[locale]:
                try:
                    return self._localizers[locale]
                except KeyError:
                    return await self._build_translation(locale)

        async def get_negotiated(self, *preferred_locales: str) -> Localizer:
            """
            Get the best matching available locale for the given preferred locales.
            """
            preferred_locales = (*preferred_locales, DEFAULT_LOCALE)
            negotiated_locale = negotiate_locale(
                preferred_locales,
                [str(get_data(locale)) for locale in self.locales],
            )
            return await self.get(negotiated_locale or DEFAULT_LOCALE)

        async def _build_translation(self, locale: str) -> Localizer:
            translations = gettext.NullTranslations()
            for assets_directory_path in reversed(self._assets.assets_directory_paths):
                opened_translations = await self._open_translations(
                    locale, assets_directory_path
                )
                if opened_translations:
                    opened_translations.add_fallback(translations)
                    translations = opened_translations
            self._localizers[locale] = Localizer(locale, translations)
            return self._localizers[locale]

        async def _open_translations(
            self, locale: str, assets_directory_path: Path
        ) -> gettext.GNUTranslations | None:
            po_file_path = assets_directory_path / "locale" / locale / "betty.po"
            try:
                translation_version = await hashid_file_meta(po_file_path)
            except FileNotFoundError:
                return None
            cache_directory_path = (
                fs.HOME_DIRECTORY_PATH / "cache" / "locale" / translation_version
            )
            mo_file_path = cache_directory_path / "betty.mo"

            with suppress(FileNotFoundError), open(mo_file_path, "rb") as f:
                return gettext.GNUTranslations(f)

            cache_directory_path.mkdir(exist_ok=True, parents=True)

            await run_babel(
                "",
                "compile",
                "-i",
                str(po_file_path),
                "-o",
                str(mo_file_path),
                "-l",
                str(get_data(locale)),
                "-D",
                "betty",
            )
            with open(mo_file_path, "rb") as f:
                return gettext.GNUTranslations(f)

        async def coverage(self, locale: Localey) -> tuple[int, int]:
            """
            Get the translation coverage for the given locale.

            :return: A 2-tuple of the number of available translations and the
                number of translatable source strings.
            """
            translatables = {
                translatable async for translatable in self._get_translatables()
            }
            locale = to_locale(locale)
            if locale == DEFAULT_LOCALE:
                return len(translatables), len(translatables)
            translations = {
                translation async for translation in self._get_translations(locale)
            }
            return len(translations), len(translatables)

        async def _get_translatables(self) -> AsyncIterator[str]:
            for assets_directory_path in self._assets.assets_directory_paths:
                with suppress(FileNotFoundError):
                    async with aiofiles.open(
                        assets_directory_path / "betty.pot"
                    ) as pot_data_f:
                        pot_data = await pot_data_f.read()
                        for entry in pofile(pot_data):
                            yield entry.msgid_with_context

        async def _get_translations(self, locale: str) -> AsyncIterator[str]:
            for assets_directory_path in reversed(self._assets.assets_directory_paths):
                with suppress(FileNotFoundError):
                    async with aiofiles.open(
                        assets_directory_path / "locale" / locale / "betty.po",
                        encoding="utf-8",
                    ) as p_data_f:
                        po_data = await p_data_f.read()
                        for entry in pofile(po_data):
                            if entry.translated():
                                yield entry.msgid_with_context