Source code for betty.locale.translation

"""
Manage translations of built-in translatable strings.
"""

from __future__ import annotations

import logging
from contextlib import redirect_stdout
from io import StringIO
from pathlib import Path
from typing import TYPE_CHECKING, TypeVar

from aiofiles.os import makedirs
from aiofiles.ospath import exists

from betty import fs
from betty.error import UserFacingError
from betty.locale import get_data
from betty.locale.babel import run_babel
from betty.locale.localizable import _
from betty.project.extension import Extension

if TYPE_CHECKING:
    from collections.abc import Iterable
    from betty.project import Project


ExtensionT = TypeVar("ExtensionT", bound=Extension)


[docs] def assert_extension_assets_directory_path(extension: type[Extension]) -> Path: """ Check that the given extension has an assets directory, and return its path. """ assets_directory_path = extension.assets_directory_path() if assets_directory_path is None: raise UserFacingError( _("{extension} does not have an assets directory.").format( extension=extension.plugin_id() ) ) return assets_directory_path
[docs] def assert_extension_has_assets_directory_path( extension: type[ExtensionT], ) -> type[ExtensionT]: """ Check that the given extension has an assets directory, and return it. """ assert_extension_assets_directory_path(extension) return extension
[docs] async def new_extension_translation(locale: str, extension: type[Extension]) -> None: """ Create a new translation for the given extension. """ await _new_translation(locale, assert_extension_assets_directory_path(extension))
[docs] async def new_project_translation(locale: str, project: Project) -> None: """ Create a new translation for the given project. """ await _new_translation(locale, project.configuration.assets_directory_path)
[docs] async def new_dev_translation(locale: str) -> None: """ Create a new translation for Betty itself. """ await _new_translation(locale, fs.ASSETS_DIRECTORY_PATH)
async def _new_translation(locale: str, assets_directory_path: Path) -> None: po_file_path = assets_directory_path / "locale" / 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(assets_directory_path / "locale" / "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_extension_translations( extension: type[Extension], source_directory_path: Path | None = None, exclude_source_directory_paths: set[Path] | None = None, *, _output_assets_directory_path_override: Path | None = None, ) -> None: """ Update the translations for the given extension. """ if source_directory_path: source_file_paths = set( find_source_files( source_directory_path, *exclude_source_directory_paths or set() ) ) else: source_file_paths = set() await _update_translations( source_file_paths, assert_extension_assets_directory_path(extension), _output_assets_directory_path_override, )
[docs] async def update_project_translations( project_directory_path: Path, source_directory_path: Path | None = None, exclude_source_directory_paths: set[Path] | None = None, *, _output_assets_directory_path_override: Path | None = None, ) -> None: """ Update the translations for the given project. """ if source_directory_path: source_file_paths = set( find_source_files( source_directory_path, *exclude_source_directory_paths or set() ) ) else: source_file_paths = set() await _update_translations( source_file_paths, project_directory_path / "assets", _output_assets_directory_path_override, )
[docs] async def update_dev_translations( *, _output_assets_directory_path_override: Path | None = None, ) -> None: """ Update the translations for Betty itself. """ source_directory_path = fs.ROOT_DIRECTORY_PATH / "betty" test_directory_path = source_directory_path / "tests" await _update_translations( set(find_source_files(source_directory_path, test_directory_path)), fs.ASSETS_DIRECTORY_PATH, _output_assets_directory_path_override, )
async def _update_translations( source_file_paths: set[Path], assets_directory_path: Path, _output_assets_directory_path_override: Path | None = None, ) -> None: """ Update all existing translations based on changes in translatable strings. """ # 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_assets_directory_path = ( _output_assets_directory_path_override or assets_directory_path ) pot_file_path = output_assets_directory_path / "locale" / "betty.pot" await makedirs(pot_file_path.parent, exist_ok=True) await run_babel( "", "extract", "--no-location", "--width", # Weblate uses 77 characters. "77", "--sort-output", "-F", "babel.ini", "-o", str(pot_file_path), "--project", "Betty", "--copyright-holder", "Bart Feenstra & contributors", *map(str, {*source_file_paths, *find_source_files(assets_directory_path)}), ) for input_po_file_path in Path(assets_directory_path).glob("locale/*/betty.po"): 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", "--domain", "betty", "--input-file", str(pot_file_path), "--ignore-obsolete", "--locale", str(locale_data), "--no-fuzzy-matching", "--output-file", str(output_po_file_path), )
[docs] def find_source_files( source_directory_path: Path, *exclude_directory_paths: Path ) -> Iterable[Path]: """ Find source files in a directory. """ exclude_directory_paths = { exclude_directory_path.expanduser().resolve() for exclude_directory_path in exclude_directory_paths } for source_file_path in source_directory_path.expanduser().resolve().rglob("*"): source_file_path = source_directory_path / source_file_path if exclude_directory_paths & set(source_file_path.parents): continue if source_file_path.suffix in {".j2", ".py"}: yield source_file_path