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,
    cast,
    TYPE_CHECKING,
)
from warnings import warn

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.asyncio import wait_to_thread
from betty.concurrent import _Lock, AsynchronizedLock
from betty.fs import FileSystem, 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
from betty.warnings import deprecated

if TYPE_CHECKING:
    from collections.abc import AsyncIterator
    from betty.app import App


DEFAULT_LOCALE = "en-US"

_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, app: App) -> DictDump[Dump]: dump = await super().dump_linked_data(app) if self.locale is not None: dump["locale"] = self.locale return dump
[docs] @override @classmethod async def linked_data_schema(cls, app: App) -> DictDump[Dump]: schema = await super().linked_data_schema(app) 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 # type: ignore[no-any-return] selfish = self if not selfish.comparable or not other.comparable: return NotImplemented # type: ignore[no-any-return] 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, app: App, schemas_org: list[str] | None = None ) -> DictDump[Dump]: dump = await super().dump_linked_data(app) 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, app: App) -> DictDump[Dump]: schema = await super().linked_data_schema(app) 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], app: App) -> 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(app) 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, app: App, 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( app, [start_schema_org] if start_schema_org else None, ) if self.end: dump["end"] = await self.end.dump_linked_data( app, [end_schema_org] if end_schema_org else None, ) return dump
[docs] @override @classmethod async def linked_data_schema(cls, app: App) -> DictDump[Dump]: schema = await super().linked_data_schema(app) schema["type"] = "object" schema["additionalProperties"] = False add_property(schema, "start", await ref_date(schema, app), False) add_property(schema, "end", await ref_date(schema, app), 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], app: App) -> 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(app) return { "$ref": "#/definitions/dateRange", }
[docs] async def ref_datey(root_schema: DictDump[Dump], app: App) -> 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, app), await ref_date_range(root_schema, app), ] } 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
[docs] 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)
[docs] 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 )
[docs] 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 )
[docs] 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())
[docs] class LocalizerRepository: """ Exposes the available localizers. """
[docs] def __init__(self, assets: FileSystem): 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.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)
@deprecated( "This method is deprecated as of Betty 0.3.4, and will be removed in Betty 0.4.x. Instead, use `LocalizerRepository.get()`." ) def __getitem__(self, locale: Localey) -> Localizer: return wait_to_thread(self.get(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.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.CACHE_DIRECTORY_PATH / "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.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.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] class Localizable: """ A localizable object. Objects of this type can convert themselves to localized strings at the point of use. """
[docs] def localize(self, localizer: Localizer) -> str: """ Localize ``self`` to a human-readable string. """ raise NotImplementedError
@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
[docs] class Str(Localizable): """ Create new localizable strings. """ def _localize_format_kwargs( self, localizer: Localizer, **format_kwargs: str | Localizable ) -> dict[str, str]: return { key: value.localize(localizer) if isinstance(value, Localizable) else value for key, value in format_kwargs.items() }
[docs] @classmethod def plain(cls, plain: Any, **format_kwargs: str | Localizable) -> Str: """ Create a new localizable that outputs the given plain text string. Keyword arguments are identical to those of :py:met:`str.format`, except that any :py:class:`betty.locale.Localizable` will be localized before string formatting. """ return _PlainStr(str(plain), **format_kwargs)
[docs] @classmethod def call(cls, call: Callable[[Localizer], str]) -> Str: """ Create a new localizable that outputs the callable's return value. """ return _CallStr(call)
@classmethod def _(cls, message: str, **format_kwargs: str | Localizable) -> Str: """ 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` will be localized before string formatting. """ return cls.gettext(message, **format_kwargs)
[docs] @classmethod def gettext(cls, message: str, **format_kwargs: str | Localizable) -> Str: """ 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` will be localized before string formatting. """ return _GettextStr("gettext", message, **format_kwargs)
[docs] @classmethod def ngettext( cls, message_singular: str, message_plural: str, n: int, **format_kwargs: str | Localizable, ) -> Str: """ 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` will be localized before string formatting. """ return _GettextStr( "ngettext", message_singular, message_plural, n, **format_kwargs )
[docs] @classmethod def pgettext( cls, context: str, message: str, **format_kwargs: str | Localizable ) -> Str: """ 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` will be localized before string formatting. """ return _GettextStr("pgettext", context, message, **format_kwargs)
[docs] @classmethod def npgettext( cls, context: str, message_singular: str, message_plural: str, n: int, **format_kwargs: str | Localizable, ) -> Str: """ 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` will be localized before string formatting. """ return _GettextStr( "npgettext", context, message_singular, message_plural, n, **format_kwargs )
class _PlainStr(Str): def __init__(self, plain: str, **format_kwargs: str | Localizable): self._plain = plain self._format_kwargs = format_kwargs @override def localize(self, localizer: Localizer) -> str: return self._plain.format( **self._localize_format_kwargs(localizer, **self._format_kwargs) ) class _CallStr(Str): def __init__(self, call: Callable[[Localizer], str]): self._call = call @override def localize(self, localizer: Localizer) -> str: return self._call(localizer) class _GettextStr(Str): def __init__( self, gettext_method_name: str, *gettext_args: Any, **format_kwargs: str | Localizable, ) -> None: self._gettext_method_name = gettext_method_name self._gettext_args = gettext_args self._format_kwargs = format_kwargs @override def localize(self, localizer: Localizer) -> str: return cast( str, getattr(localizer, self._gettext_method_name)(*self._gettext_args), # type: ignore[operator] ).format(**self._localize_format_kwargs(localizer, **self._format_kwargs))
[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", )