"""
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,
)
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"
[docs]
class LocaleNotFoundError(RuntimeError):
"""
Raise when a locale could not be found.
"""
[docs]
def __init__(self, locale: str) -> None:
super().__init__(f'Cannot find locale "{locale}"')
self.locale = locale
[docs]
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
)
[docs]
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
[docs]
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
[docs]
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
)
[docs]
class Localized(LinkedDataDumpable):
"""
A resource that is localized, e.g. contains information in a specific locale.
"""
locale: str | None
[docs]
def __init__(
self,
*args: Any,
locale: str | None = None,
**kwargs: Any,
):
super().__init__(*args, **kwargs)
self.locale = locale
[docs]
@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
[docs]
@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
[docs]
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}"
[docs]
class Date(LinkedDataDumpable):
"""
A (Gregorian) date.
"""
year: int | None
month: int | None
day: int | None
fuzzy: bool
[docs]
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
[docs]
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)
[docs]
@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
[docs]
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))
[docs]
@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
[docs]
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",
}
[docs]
@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
[docs]
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
[docs]
@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
[docs]
@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
[docs]
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,
)
[docs]
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",
}
[docs]
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
]
[docs]
class Localizer:
"""
Provide localization functionality for a specific locale.
"""
[docs]
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)
[docs]
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)
[docs]
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)
[docs]
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)
[docs]
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_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
)
DEFAULT_LOCALIZER = Localizer(DEFAULT_LOCALE, gettext.NullTranslations())
[docs]
class LocalizerRepository:
"""
Exposes the available localizers.
"""
[docs]
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
[docs]
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)
[docs]
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)
[docs]
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
[docs]
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
[docs]
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))
[docs]
async def run_babel(*args: str) -> None:
"""
Run a Babel Command Line Interface (CLI) command.
"""
await to_thread(_run_babel, *args)
[docs]
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}."
)
[docs]
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",
)