betty.locale - Betty 0.4.0a3 documentation

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

# Source code for betty.locale

    """
    Provide the Locale API.
    """

    from __future__ import annotations

    import calendar
    import datetime
    import gettext
    import logging
    import operator
    from asyncio import to_thread
    from collections import defaultdict
    from contextlib import suppress, redirect_stdout, redirect_stderr
    from functools import total_ordering
    from io import StringIO
    from pathlib import Path
    from typing import (
        Any,
        Iterator,
        Sequence,
        Mapping,
        Callable,
        TypeAlias,
        TYPE_CHECKING,
        final,
    )

    import aiofiles
    import babel
    from aiofiles.os import makedirs
    from aiofiles.ospath import exists
    from babel import dates, Locale
    from babel.dates import format_date
    from babel.messages.frontend import CommandLineInterface
    from langcodes import Language
    from polib import pofile
    from typing_extensions import override

    from betty import fs
    from betty.concurrent import _Lock, AsynchronizedLock
    from betty.fs import ROOT_DIRECTORY_PATH, ASSETS_DIRECTORY_PATH
    from betty.hashid import hashid_file_meta
    from betty.json.linked_data import LinkedDataDumpable, dump_context, add_json_ld
    from betty.json.schema import ref_locale, add_property
    from betty.serde.dump import DictDump, Dump, dump_default

    if TYPE_CHECKING:
        from betty.project import Project
        from betty.assets import AssetRepository
        from collections.abc import AsyncIterator

    DEFAULT_LOCALE = "en-US"
    UNDETERMINED_LOCALE = "und"

    _LOCALE_DIRECTORY_PATH = fs.ASSETS_DIRECTORY_PATH / "locale"

    class LocaleNotFoundError(RuntimeError):
        """
        Raise when a locale could not be found.
        """

        def __init__(self, locale: str) -> None:
            super().__init__(f'Cannot find locale "{locale}"')
            self.locale = locale

    def to_babel_identifier(locale: Localey) -> str:
        """
        Convert a locale or locale metadata to a Babel locale identifier.
        """
        if isinstance(locale, Locale):
            return str(locale)
        language_data = Language.get(locale)
        return "_".join(
            part
            for part in [
                language_data.language,
                language_data.script,
                language_data.territory,
            ]
            if part
        )

    def to_locale(locale: Localey) -> str:
        """
        Ensure that a locale or locale metadata is a locale.
        """
        if isinstance(locale, str):
            return locale
        return "-".join(
            part
            for part in [
                locale.language,
                locale.script,
                locale.territory,
            ]
            if part
        )

    Localey: TypeAlias = str | Locale

    def get_data(locale: Localey) -> Locale:
        """
        Get locale metadata.
        """
        if isinstance(locale, Locale):
            return locale
        try:
            return Locale.parse(to_babel_identifier(locale))
        except Exception as e:
            raise LocaleNotFoundError(locale) from e

    def get_display_name(
        locale: Localey, display_locale: Localey | None = None
    ) -> str | None:
        """
        Return a locale's human-readable display name.
        """
        locale_data = get_data(locale)
        return locale_data.get_display_name(
            get_data(display_locale) if display_locale else locale_data
        )

    class Localized(LinkedDataDumpable):
        """
        A resource that is localized, e.g. contains information in a specific locale.
        """

        locale: str | None

        def __init__(
            self,
            *args: Any,
            locale: str | None = None,
            **kwargs: Any,
        ):
            super().__init__(*args, **kwargs)
            self.locale = locale

        @override
        async def dump_linked_data(self, project: Project) -> DictDump[Dump]:
            dump = await super().dump_linked_data(project)
            if self.locale is not None:
                dump["locale"] = self.locale
            return dump

        @override
        @classmethod
        async def linked_data_schema(cls, project: Project) -> DictDump[Dump]:
            schema = await super().linked_data_schema(project)
            properties = dump_default(schema, "properties", dict)
            properties["locale"] = ref_locale(schema)
            return schema

    class IncompleteDateError(ValueError):
        """
        Raised when a datey was unexpectedly incomplete.
        """

        pass  # pragma: no cover

    def _dump_date_iso8601(date: Date) -> str | None:
        if not date.complete:
            return None
        assert date.year
        assert date.month
        assert date.day
        return f"{date.year:04d}-{date.month:02d}-{date.day:02d}"

    class Date(LinkedDataDumpable):
        """
        A (Gregorian) date.
        """

        year: int | None
        month: int | None
        day: int | None
        fuzzy: bool

        def __init__(
            self,
            year: int | None = None,
            month: int | None = None,
            day: int | None = None,
            fuzzy: bool = False,
        ):
            self.year = year
            self.month = month
            self.day = day
            self.fuzzy = fuzzy

        @override
        def __repr__(self) -> str:
            return "<%s.%s(%s, %s, %s)>" % (
                self.__class__.__module__,
                self.__class__.__name__,
                self.year,
                self.month,
                self.day,
            )

        @property
        def comparable(self) -> bool:
            """
            If this date is comparable to other dateys.
            """
            return self.year is not None

        @property
        def complete(self) -> bool:
            """
            Whether this date is complete.
            """
            return self.year is not None and self.month is not None and self.day is not None

        @property
        def parts(self) -> tuple[int | None, int | None, int | None]:
            """
            The date parts: a 3-tuple of the year, month, and day.
            """
            return self.year, self.month, self.day

        def to_range(self) -> DateRange:
            """
            Convert this date to a date range.
            """
            if not self.comparable:
                raise ValueError(
                    "Cannot convert non-comparable date %s to a date range." % self
                )
            if self.month is None:
                month_start = 1
                month_end = 12
            else:
                month_start = month_end = self.month
            if self.day is None:
                day_start = 1
                day_end = calendar.monthrange(
                    self.year,  # type: ignore[arg-type]
                    month_end,
                )[1]
            else:
                day_start = day_end = self.day
            return DateRange(
                Date(self.year, month_start, day_start), Date(self.year, month_end, day_end)
            )

        def _compare(self, other: Any, comparator: Callable[[Any, Any], bool]) -> bool:
            if not isinstance(other, Date):
                return NotImplemented
            selfish = self
            if not selfish.comparable or not other.comparable:
                return NotImplemented
            if selfish.complete and other.complete:
                return comparator(selfish.parts, other.parts)
            if not other.complete:
                other = other.to_range()
            if not selfish.complete:
                selfish = selfish.to_range()  # type: ignore[assignment]
            return comparator(selfish, other)

        def __contains__(self, other: Any) -> bool:
            if isinstance(other, Date):
                return self == other
            if isinstance(other, DateRange):
                return self in other
            raise TypeError(
                "Expected to check a %s, but a %s was given" % (type(Datey), type(other))
            )

        def __lt__(self, other: Any) -> bool:
            return self._compare(other, operator.lt)

        def __le__(self, other: Any) -> bool:
            return self._compare(other, operator.le)

        @override
        def __eq__(self, other: Any) -> bool:
            if not isinstance(other, Date):
                return NotImplemented
            return self.parts == other.parts

        def __ge__(self, other: Any) -> bool:
            return self._compare(other, operator.ge)

        def __gt__(self, other: Any) -> bool:
            return self._compare(other, operator.gt)

        @override
        async def dump_linked_data(
            self, project: Project, schemas_org: list[str] | None = None
        ) -> DictDump[Dump]:
            dump = await super().dump_linked_data(project)
            if self.year:
                dump["year"] = self.year
            if self.month:
                dump["month"] = self.month
            if self.day:
                dump["day"] = self.day
            if self.comparable:
                dump["iso8601"] = _dump_date_iso8601(self)
            return dump

        async def datey_dump_linked_data(
            self,
            dump: DictDump[Dump],
            start_schema_org: str,
            end_schema_org: str,
        ) -> None:
            """
            Dump this instance to `JSON-LD <https://json-ld.org/>`_ for a 'datey' field.
            """
            if self.comparable:
                dump_context(dump, iso8601=(start_schema_org, end_schema_org))

        @override
        @classmethod
        async def linked_data_schema(cls, project: Project) -> DictDump[Dump]:
            schema = await super().linked_data_schema(project)
            schema["type"] = "object"
            schema["additionalProperties"] = False
            add_json_ld(schema)
            add_property(schema, "year", {"type": "number"}, False)
            add_property(schema, "month", {"type": "number"}, False)
            add_property(schema, "day", {"type": "number"}, False)
            add_property(
                schema,
                "iso8601",
                {
                    "type": "string",
                    "pattern": "^\\d\\d\\d\\d-\\d\\d-\\d\\d$",
                    "description": "An ISO 8601 date.",
                },
                False,
            )
            return schema

    async def ref_date(root_schema: DictDump[Dump], project: Project) -> DictDump[Dump]:
        """
        Reference the Date schema.
        """
        definitions = dump_default(root_schema, "definitions", dict)
        if "date" not in definitions:
            definitions["date"] = await Date.linked_data_schema(project)
        return {
            "$ref": "#/definitions/date",
        }

    @total_ordering
    class DateRange(LinkedDataDumpable):
        """
        A date range can describe a period of time between, before, after, or around start and/or end dates.
        """

        start: Date | None
        start_is_boundary: bool
        end: Date | None
        end_is_boundary: bool

        def __init__(
            self,
            start: Date | None = None,
            end: Date | None = None,
            start_is_boundary: bool = False,
            end_is_boundary: bool = False,
        ):
            self.start = start
            self.start_is_boundary = start_is_boundary
            self.end = end
            self.end_is_boundary = end_is_boundary

        @override
        def __repr__(self) -> str:
            return "%s.%s(%s, %s, start_is_boundary=%s, end_is_boundary=%s)" % (
                self.__class__.__module__,
                self.__class__.__name__,
                repr(self.start),
                repr(self.end),
                repr(self.start_is_boundary),
                repr(self.end_is_boundary),
            )

        @property
        def comparable(self) -> bool:
            """
            If this date is comparable to other dateys.
            """
            return (
                self.start is not None
                and self.start.comparable
                or self.end is not None
                and self.end.comparable
            )

        def __contains__(self, other: Any) -> bool:
            if not self.comparable:
                return False

            if isinstance(other, Date):
                others = [other]
            elif isinstance(other, DateRange):
                if not other.comparable:
                    return False
                others = []
                if other.start is not None and other.start.comparable:
                    others.append(other.start)
                if other.end is not None and other.end.comparable:
                    others.append(other.end)
            else:
                raise TypeError(
                    "Expected to check a %s, but a %s was given"
                    % (type(Datey), type(other))
                )

            if self.start is not None and self.end is not None:
                if isinstance(other, DateRange) and (
                    other.start is None or other.end is None
                ):
                    if other.start is None:
                        return self.start <= other.end or self.end <= other.end
                    if other.end is None:
                        return self.start >= other.start or self.end >= other.start
                for another in others:
                    if self.start <= another <= self.end:
                        return True
                if isinstance(other, DateRange):
                    for selfdate in [self.start, self.end]:
                        if other.start <= selfdate <= other.end:
                            return True

            elif self.start is not None:
                # Two date ranges with start dates only always overlap.
                if isinstance(other, DateRange) and other.end is None:
                    return True

                for other in others:
                    if self.start <= other:
                        return True
            elif self.end is not None:
                # Two date ranges with end dates only always overlap.
                if isinstance(other, DateRange) and other.start is None:
                    return True

                for other in others:
                    if other <= self.end:
                        return True
            return False

        @override
        async def dump_linked_data(
            self,
            project: Project,
            start_schema_org: str | None = None,
            end_schema_org: str | None = None,
        ) -> DictDump[Dump]:
            dump: DictDump[Dump] = {}
            if self.start:
                dump["start"] = await self.start.dump_linked_data(
                    project,
                    [start_schema_org] if start_schema_org else None,
                )
            if self.end:
                dump["end"] = await self.end.dump_linked_data(
                    project,
                    [end_schema_org] if end_schema_org else None,
                )
            return dump

        @override
        @classmethod
        async def linked_data_schema(cls, project: Project) -> DictDump[Dump]:
            schema = await super().linked_data_schema(project)
            schema["type"] = "object"
            schema["additionalProperties"] = False
            add_property(schema, "start", await ref_date(schema, project), False)
            add_property(schema, "end", await ref_date(schema, project), False)
            return schema

        async def datey_dump_linked_data(
            self,
            dump: DictDump[Dump],
            start_schema_org: str,
            end_schema_org: str,
        ) -> None:
            """
            Dump this instance to `JSON-LD <https://json-ld.org/>`_ for a 'datey' field.
            """
            if self.start and self.start.comparable:
                start = dump_default(dump, "start", dict)
                dump_context(start, iso8601=start_schema_org)
            if self.end and self.end.comparable:
                end = dump_default(dump, "end", dict)
                dump_context(end, iso8601=end_schema_org)

        def _get_comparable_date(self, date: Date | None) -> Date | None:
            if date and date.comparable:
                return date
            return None

        _LT_DATE_RANGE_COMPARATORS = {
            (
                True,
                True,
                True,
                True,
            ): lambda self_start, self_end, other_start, other_end: self_start
            < other_start,
            (
                True,
                True,
                True,
                False,
            ): lambda self_start, self_end, other_start, other_end: self_start
            <= other_start,
            (
                True,
                True,
                False,
                True,
            ): lambda self_start, self_end, other_start, other_end: self_start < other_end
            or self_end <= other_end,
            (
                True,
                True,
                False,
                False,
            ): lambda self_start, self_end, other_start, other_end: NotImplemented,
            (
                True,
                False,
                True,
                True,
            ): lambda self_start, self_end, other_start, other_end: self_start
            < other_start,
            (
                True,
                False,
                True,
                False,
            ): lambda self_start, self_end, other_start, other_end: self_start
            < other_start,
            (
                True,
                False,
                False,
                True,
            ): lambda self_start, self_end, other_start, other_end: self_start < other_end,
            (
                True,
                False,
                False,
                False,
            ): lambda self_start, self_end, other_start, other_end: NotImplemented,
            (
                False,
                True,
                True,
                True,
            ): lambda self_start, self_end, other_start, other_end: self_end <= other_start,
            (
                False,
                True,
                True,
                False,
            ): lambda self_start, self_end, other_start, other_end: self_end <= other_start,
            (
                False,
                True,
                False,
                True,
            ): lambda self_start, self_end, other_start, other_end: self_end < other_end,
            (
                False,
                True,
                False,
                False,
            ): lambda self_start, self_end, other_start, other_end: NotImplemented,
            (
                False,
                False,
                True,
                True,
            ): lambda self_start, self_end, other_start, other_end: NotImplemented,
            (
                False,
                False,
                True,
                False,
            ): lambda self_start, self_end, other_start, other_end: NotImplemented,
            (
                False,
                False,
                False,
                True,
            ): lambda self_start, self_end, other_start, other_end: NotImplemented,
            (
                False,
                False,
                False,
                False,
            ): lambda self_start, self_end, other_start, other_end: NotImplemented,
        }

        _LT_DATE_COMPARATORS = {
            (True, True): lambda self_start, self_end, other: self_start < other,
            (True, False): lambda self_start, self_end, other: self_start < other,
            (False, True): lambda self_start, self_end, other: self_end <= other,
            (False, False): lambda self_start, self_end, other: NotImplemented,
        }

        def __lt__(self, other: Any) -> bool:
            if not isinstance(other, (Date, DateRange)):
                return NotImplemented

            self_start = self._get_comparable_date(self.start)
            self_end = self._get_comparable_date(self.end)
            signature = (
                self_start is not None,
                self_end is not None,
            )
            if isinstance(other, DateRange):
                other_start = self._get_comparable_date(other.start)
                other_end = self._get_comparable_date(other.end)
                return self._LT_DATE_RANGE_COMPARATORS[
                    (
                        *signature,
                        other_start is not None,
                        other_end is not None,
                    )
                ](self_start, self_end, other_start, other_end)
            else:
                return self._LT_DATE_COMPARATORS[signature](self_start, self_end, other)

        @override
        def __eq__(self, other: Any) -> bool:
            if isinstance(other, Date):
                return False

            if not isinstance(other, DateRange):
                return NotImplemented
            return (self.start, self.end, self.start_is_boundary, self.end_is_boundary) == (
                other.start,
                other.end,
                other.start_is_boundary,
                other.end_is_boundary,
            )

    async def ref_date_range(
        root_schema: DictDump[Dump], project: Project
    ) -> DictDump[Dump]:
        """
        Reference the DateRange schema.
        """
        definitions = dump_default(root_schema, "definitions", dict)
        if "dateRange" not in definitions:
            definitions["dateRange"] = await DateRange.linked_data_schema(project)
        return {
            "$ref": "#/definitions/dateRange",
        }

    async def ref_datey(root_schema: DictDump[Dump], project: Project) -> DictDump[Dump]:
        """
        Reference the Datey schema.
        """
        definitions = dump_default(root_schema, "definitions", dict)
        if "datey" not in definitions:
            definitions["datey"] = {
                "oneOf": [
                    await ref_date(root_schema, project),
                    await ref_date_range(root_schema, project),
                ]
            }
        return {
            "$ref": "#/definitions/datey",
        }

    Datey: TypeAlias = Date | DateRange
    DatePartsFormatters: TypeAlias = Mapping[tuple[bool, bool, bool], str]
    DateFormatters: TypeAlias = Mapping[tuple[bool | None], str]
    DateRangeFormatters: TypeAlias = Mapping[
        tuple[bool | None, bool | None, bool | None, bool | None], str
    ]

    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

    def negotiate_locale(
        preferred_locales: Localey | Sequence[Localey], available_locales: Sequence[Localey]
    ) -> Locale | None:
        """
        Negotiate the preferred locale from a sequence.
        """
        if isinstance(preferred_locales, (str, Locale)):
            preferred_locales = [preferred_locales]
        return _negotiate_locale(
            [to_babel_identifier(locale) for locale in preferred_locales],
            {to_babel_identifier(locale) for locale in available_locales},
            False,
        )

    def _negotiate_locale(
        preferred_locale_babel_identifiers: Sequence[str],
        available_locale_babel_identifiers: set[str],
        root: bool,
    ) -> Locale | None:
        negotiated_locale = babel.negotiate_locale(
            preferred_locale_babel_identifiers, available_locale_babel_identifiers
        )
        if negotiated_locale is not None:
            return Locale.parse(negotiated_locale)
        if not root:
            return _negotiate_locale(
                [
                    (
                        babel_identifier.split("_")[0]
                        if "_" in babel_identifier
                        else babel_identifier
                    )
                    for babel_identifier in preferred_locale_babel_identifiers
                ],
                available_locale_babel_identifiers,
                True,
            )
        return None

    def negotiate_localizeds(
        preferred_locales: Localey | Sequence[Localey], localizeds: Sequence[Localized]
    ) -> Localized | None:
        """
        Negotiate the preferred localized value from a sequence.
        """
        negotiated_locale_data = negotiate_locale(
            preferred_locales,
            [localized.locale for localized in localizeds if localized.locale is not None],
        )
        if negotiated_locale_data is not None:
            negotiated_locale = to_locale(negotiated_locale_data)
            for localized in localizeds:
                if localized.locale == negotiated_locale:
                    return localized
        for localized in localizeds:
            if localized.locale is None:
                return localized
        with suppress(IndexError):
            return localizeds[0]
        return None

    def _run_babel(*args: str) -> None:
        with redirect_stderr(StringIO()):
            CommandLineInterface().run(list(args))

    async def run_babel(*args: str) -> None:
        """
        Run a Babel Command Line Interface (CLI) command.
        """
        await to_thread(_run_babel, *args)

    async def init_translation(locale: str) -> None:
        """
        Initialize a new translation.
        """
        po_file_path = _LOCALE_DIRECTORY_PATH / locale / "betty.po"
        with redirect_stdout(StringIO()):
            if await exists(po_file_path):
                logging.getLogger(__name__).info(
                    f"Translations for {locale} already exist at {po_file_path}."
                )
                return

            locale_data = get_data(locale)
            await run_babel(
                "",
                "init",
                "--no-wrap",
                "-i",
                str(fs.ASSETS_DIRECTORY_PATH / "betty.pot"),
                "-o",
                str(po_file_path),
                "-l",
                str(locale_data),
                "-D",
                "betty",
            )
            logging.getLogger(__name__).info(
                f"Translations for {locale} initialized at {po_file_path}."
            )

    async def update_translations(
        _output_assets_directory_path: Path = fs.ASSETS_DIRECTORY_PATH,
    ) -> None:
        """
        Update all existing translations based on changes in translatable strings.
        """
        source_directory_path = ROOT_DIRECTORY_PATH / "betty"
        test_directory_path = source_directory_path / "tests"
        source_paths = [
            path
            for path in source_directory_path.rglob("*")
            # Remove the tests directory from the extraction, or we'll
            # be seeing some unusual additions to the translations.
            if test_directory_path not in path.parents and path.suffix in (".j2", ".py")
        ]
        pot_file_path = _output_assets_directory_path / "betty.pot"
        await run_babel(
            "",
            "extract",
            "--no-location",
            "--no-wrap",
            "--sort-output",
            "-F",
            "babel.ini",
            "-o",
            str(pot_file_path),
            "--project",
            "Betty",
            "--copyright-holder",
            "Bart Feenstra & contributors",
            *(str(ROOT_DIRECTORY_PATH / source_path) for source_path in source_paths),
        )
        for input_po_file_path in Path(ASSETS_DIRECTORY_PATH).glob("locale/*/betty.po"):
            # During production, the input and output paths are identical. During testing,
            # _output_assets_directory_path provides an alternative output, so the changes
            # to the translations can be tested in isolation.
            output_po_file_path = (
                _output_assets_directory_path
                / input_po_file_path.relative_to(ASSETS_DIRECTORY_PATH)
            ).resolve()
            await makedirs(output_po_file_path.parent, exist_ok=True)
            output_po_file_path.touch()

            locale = output_po_file_path.parent.name
            locale_data = get_data(locale)
            await run_babel(
                "",
                "update",
                "-i",
                str(pot_file_path),
                "-o",
                str(output_po_file_path),
                "-l",
                str(locale_data),
                "-D",
                "betty",
            )