#!/usr/bin/env python3
#
# SPDX-FileCopyrightText: 2013-2025 Univention GmbH
# SPDX-License-Identifier: AGPL-3.0-only
from __future__ import annotations
import getpass
import json
import mimetypes
import os
import re
import shutil
import socket
import sys
import traceback
from datetime import date
from email.utils import formatdate
from glob import glob
from re import Pattern
from typing import TYPE_CHECKING, Any, TypedDict
import magic
from debian.deb822 import Deb822
from . import message_catalogs, sourcefileprocessing, umc
from .helper import Error, make_parent_dir
if TYPE_CHECKING:
from collections.abc import Iterable, Iterator
from types import TracebackType
[docs]
class BaseModule(TypedDict):
module_name: str
package: str
abs_path_to_src_pkg: str
relative_path_src_pkg: str
REFERENCE_LANG = 'de'
UMC_MODULES = '.umc-modules'
# Use this set to ignore whole sub trees of a given source tree
DIR_BLACKLIST = {
'doc',
'umc-module-templates',
'test',
'testframework',
}
# do not translate modules with these names, as they are examples and thus not worth the effort
MODULE_BLACKLIST = {
'PACKAGENAME',
}
[docs]
class NoSpecialCaseDefintionsFound(Error):
pass
[docs]
class NoMatchingFiles(Error):
pass
[docs]
class UMCModuleTranslation(umc.UMC_Module):
def __init__(self, attrs: dict[str, Any], target_language: str) -> None:
attrs['target_language'] = target_language
super().__init__(attrs)
@property
def python_po_files(self) -> Iterator[str]:
for path in super().python_po_files:
if os.path.isfile(os.path.join(self['abs_path_to_src_pkg'], os.path.dirname(path), f'{REFERENCE_LANG}.po')):
yield path
@property
def js_po_files(self) -> Iterator[str]:
for path in super().js_po_files:
if os.path.isfile(os.path.join(self['abs_path_to_src_pkg'], os.path.dirname(path), f'{REFERENCE_LANG}.po')):
yield path
@property
def xml_po_files(self) -> Iterator[tuple[str, str]]:
for lang, path in super().xml_po_files:
if os.path.isfile(os.path.join(self['abs_path_to_src_pkg'], os.path.dirname(path), f'{REFERENCE_LANG}.po')):
yield lang, path
[docs]
def python_mo_destinations(self) -> Iterator[tuple[str, str]]:
for po_file in self.python_po_files:
yield os.path.join(self['target_language'], self['relative_path_src_pkg'], po_file), 'usr/share/locale/{target_language}/LC_MESSAGES/{module_name}.mo'.format(**self)
[docs]
def json_targets(self) -> Iterator[tuple[str, str]]:
for js_po in self.js_po_files:
yield os.path.join(self['target_language'], self['relative_path_src_pkg'], js_po), 'usr/share/univention-management-console-frontend/js/umc/modules/i18n/{target_language}/{Module}.json'.format(**self)
[docs]
def xml_mo_destinations(self) -> Iterator[tuple[str, str]]:
for _, xml_po in self.xml_po_files:
yield os.path.join(self['target_language'], self['relative_path_src_pkg'], xml_po), 'usr/share/univention-management-console/i18n/{target_language}/{Module}.mo'.format(**self)
[docs]
@classmethod
def from_source_package(cls, module_in_source_tree: BaseModule, target_language: str) -> UMCModuleTranslation:
try:
# read package content with umc
module = cls._get_module_from_source_package(module_in_source_tree, target_language)
except AttributeError as e:
print("%s AttributeError in module, trying to load as core module" % (e,))
else:
module['core'] = False
return module
try:
module = cls._get_core_module_from_source_package(module_in_source_tree, target_language)
except AttributeError as e:
print("%s core module load failed" % (e,))
else:
print("Successfully loaded as core module: {}".format(module_in_source_tree['abs_path_to_src_pkg']))
module['core'] = True
return module
@staticmethod
def _read_module_attributes_from_source_package(module: BaseModule) -> umc.UMC_Module:
umc_module_definition_file = os.path.join(module['abs_path_to_src_pkg'], 'debian', '{}{}'.format(module['module_name'], UMC_MODULES))
with open(umc_module_definition_file) as fd:
def_file = fd.read()
attributes = Deb822(def_file)
attributes = {k: [v] for k, v in attributes.items()} # simulate dh_ucs.parseRfc822 behaviour
attributes.update(module)
return attributes
@classmethod
def _get_core_module_from_source_package(cls, module: BaseModule, target_language: str) -> UMCModuleTranslation:
attrs = cls._read_module_attributes_from_source_package(module)
umc_module = cls(attrs, target_language)
if umc_module.module_name != 'umc-core' or not umc_module.xml_categories:
raise ValueError('Module definition does not match core module')
return umc_module
@classmethod
def _get_module_from_source_package(cls, module: BaseModule, target_language: str) -> UMCModuleTranslation:
attrs = cls._read_module_attributes_from_source_package(module)
for required in (umc.MODULE, umc.PYTHON, umc.DEFINITION, umc.JAVASCRIPT):
if required not in attrs:
raise AttributeError(f'UMC module definition incomplete. key {required} is missing a value.')
return cls(attrs, target_language)
[docs]
class SpecialCase:
"""
Consumes special case definition and determines matching sets of source
files.
:param special_case_definition: Mapping with special case definitions.
:param source_dir: Base directory.
:param path_to_definition: Path to definition file.
:param target_language: 2-letter language code.
"""
RE_L10N = re.compile(r'(.+/)?debian/([^/]+).univention-l10n$')
def __init__(self, special_case_definition: dict[str, str], source_dir: str, path_to_definition: str, target_language: str) -> None:
# FIXME: this would circumvent custom getters and setter?
self.__dict__.update(special_case_definition)
def_relative = os.path.relpath(path_to_definition, start=source_dir)
matches = self.RE_L10N.match(def_relative)
if not matches:
raise ValueError(def_relative)
pdir, self.binary_package_name = matches.groups()
self.package_dir: str = os.getcwd() if pdir is None else pdir.rstrip('/')
self.source_dir = source_dir
if hasattr(self, 'po_path'):
self.new_po_path = self.po_path.format(lang=target_language)
else:
self.po_subdir: str = self.po_subdir.format(lang=target_language)
self.new_po_path = os.path.join(self.po_subdir, f'{target_language}.po')
self.destination: str = self.destination.format(lang=target_language)
self.path_to_definition = path_to_definition
def _get_files_matching_patterns(self) -> list[str]:
try:
src_pkg_path = os.path.join(self.source_dir, self.package_dir)
except AttributeError:
src_pkg_path = os.path.join(os.getcwd())
regexs: list[Pattern[str]] = []
for pattern in [os.path.join(src_pkg_path, pattern) for pattern in self.input_files]:
try:
regexs.append(re.compile(rf'{pattern}$'))
except re.error:
sys.exit(f"Invalid input_files statement in: {self.path_to_definition}. Value must be valid regular expression.")
matched = [
path
for parent, dirnames, fnames in os.walk(src_pkg_path)
for path in (os.path.join(parent, fn) for fn in fnames)
if any(rex.match(path) for rex in regexs)
]
if not matched:
raise NoMatchingFiles()
return matched
[docs]
def get_source_file_sets(self) -> list[sourcefileprocessing.SourceFileSet]:
files_by_mime: dict[str, list[str]] = {}
with MIMEChecker() as mime:
for file_path in self._get_files_matching_patterns():
files_by_mime.setdefault(mime.get(file_path), []).append(file_path)
source_file_sets: list[sourcefileprocessing.SourceFileSet] = []
for mime_type, file_set in files_by_mime.items():
try:
source_file_sets.append(sourcefileprocessing.from_mimetype(os.path.join(self.source_dir, self.package_dir), self.binary_package_name, mime_type, file_set))
except sourcefileprocessing.UnsupportedSourceType:
continue
return source_file_sets
[docs]
def create_po_template(self, output_path: str = os.path.curdir) -> str:
base, _ext = os.path.splitext(os.path.join(output_path, self.new_po_path))
pot_path = f'{base}.pot'
message_catalogs.create_empty_po(self.binary_package_name, pot_path)
partial_pot_path = f'{base}.pot.partial'
for sfs in self.get_source_file_sets():
sfs.process_po(partial_pot_path)
message_catalogs.concatenate_po(partial_pot_path, pot_path)
os.unlink(partial_pot_path)
return pot_path
[docs]
class MIMEChecker:
# FIXME: this is not need as mimetypes implements it already. The get()
# method should be adjusted to first use mimetypes.guess_type() and then
# resort to libmagic
suffixes = {
'.js': 'application/javascript',
'.ts': 'application/javascript',
'.vue': 'application/javascript',
'.py': 'text/x-python',
'.html': 'text/html',
'.sh': 'text/x-shellscript',
}
def __init__(self) -> None:
self._ms = magic.open(magic.MIME_TYPE)
self._ms.load()
def __enter__(self) -> MIMEChecker:
return self
def __exit__(self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None) -> None:
self._ms.close()
[docs]
def get(self, file_path):
_path, suffix = os.path.splitext(file_path)
if suffix in self.suffixes:
return self.suffixes[suffix]
with open(file_path, 'rb') as fd:
mime = self._ms.buffer(fd.read(4096))
if 'text/plain' in mime:
with open(file_path) as source_file:
if 'ucs-test/selenium' in source_file.readline():
mime = 'text/x-python'
else:
mime = mimetypes.guess_type(file_path)[0]
return mime
[docs]
def update_package_translation_files(module: UMCModuleTranslation, output_dir: str, template: bool = False) -> None:
print("Creating directories and PO files for {module_name} in translation source package".format(**module))
start_dir = os.getcwd()
output_dir = os.path.abspath(output_dir)
try:
os.chdir(module['abs_path_to_src_pkg'])
if not module.get('core'):
def _create_po_files(po_files: Iterable[str], src_files: Iterable[str], language: str) -> None:
for po_file in po_files:
po_path = os.path.join(output_dir, module['relative_path_src_pkg'], po_file)
make_parent_dir(po_path)
umc.create_po_file(po_path, module['module_name'], src_files, language, template)
# build Python po files
_create_po_files(module.python_po_files, module.python_files, 'Python')
_create_po_files(module.js_po_files, module.js_files, 'JavaScript')
# xml always has to be present
for lang, po_file in module.xml_po_files:
po_path = os.path.join(output_dir, module['relative_path_src_pkg'], po_file)
make_parent_dir(po_path)
umc.module_xml2po(module, po_path, lang, template)
except OSError as exc:
print(traceback.format_exc())
print("error in update_package_translation_files: %s" % (exc,))
raise Error("update_package_translation_files() failed")
finally:
os.chdir(start_dir)
[docs]
def write_makefile(all_modules: list[UMCModuleTranslation], special_cases: list[SpecialCase], new_package_dir: str, target_language: str) -> None:
mo_targets_list: list[str] = []
target_prerequisite: list[str] = []
def _append_to_target_lists(mo_destination: str, po_file: str) -> None:
mo_targets_list.append(f'$(DESTDIR)/{mo_destination}')
target_prerequisite.append(f'$(DESTDIR)/{mo_destination}: {po_file}')
for module in all_modules:
if not module.get('core'):
for file_paths in (module.python_mo_destinations, module.json_targets):
for po_file, mo_destination in file_paths():
_append_to_target_lists(mo_destination, po_file)
for po_file, mo_destination in module.xml_mo_destinations():
_append_to_target_lists(mo_destination, po_file)
for scase in special_cases:
_append_to_target_lists(scase.destination, os.path.join(target_language, scase.package_dir, scase.new_po_path))
with open(os.path.join(new_package_dir, 'all_targets.mk'), 'w') as fd:
fd.write("# This file is auto-generated by univention-ucs-translation-build-package and should not be edited!\n\n")
fd.write('ALL_TARGETS := {}\n\n'.format(' \\\n\t'.join(sorted(mo_targets_list))))
fd.write('\n'.join(sorted(target_prerequisite)))
fd.write('\n')
[docs]
def translate_special_case(special_case: SpecialCase, source_dir: str, output_dir: str) -> None:
path_src_pkg = os.path.join(source_dir, special_case.package_dir)
if not os.path.isdir(path_src_pkg):
print(f"Warning: Path defined under 'package_dir' not found. Please check the definitions in the *.univention-l10n file in {special_case.package_dir}")
return
new_po_path = os.path.join(output_dir, special_case.package_dir, special_case.new_po_path)
make_parent_dir(new_po_path)
pot_path = special_case.create_po_template(output_path=os.path.join(os.getcwd(), output_dir, special_case.package_dir))
message_catalogs.univention_location_lines(pot_path, os.path.join(source_dir, special_case.package_dir))
os.rename(pot_path, new_po_path)
[docs]
def read_special_case_definition(definition_path: str, source_tree_path: str, target_language: str) -> Iterator[SpecialCase]:
with open(definition_path) as fd:
try:
sc_definitions = json.load(fd)
except ValueError:
sys.exit(f'Error: Invalid syntax in {definition_path}. File must be valid JSON.')
for scdef in sc_definitions:
yield SpecialCase(scdef, source_tree_path, definition_path, target_language)
[docs]
def get_special_cases_from_srcpkg(source_tree_path: str, target_language: str) -> list[SpecialCase]:
special_case_files = glob('debian/*.univention-l10n')
return [
sc
for sc_definitions in special_case_files
for sc in read_special_case_definition(sc_definitions, os.getcwd(), target_language)
]
[docs]
def get_special_cases_from_checkout(source_tree_path: str, target_language: str) -> list[SpecialCase]:
"""
Process \\*.univention-l10n files in the whole branch. Currently they
lay 3 (UCS@school) or 4(UCS) directory levels deep in the repository.
"""
sc_files = glob(os.path.join(source_tree_path, '*/*/debian/*.univention-l10n')) or glob(os.path.join(source_tree_path, '*/debian/*.univention-l10n'))
if not sc_files:
raise NoSpecialCaseDefintionsFound()
return [
sc
for definition_path in sc_files
for sc in read_special_case_definition(definition_path, source_tree_path, target_language)
]
[docs]
def find_base_translation_modules(source_dir: str) -> list[BaseModule]:
base_translation_modules: list[BaseModule] = []
print('looking in %s' % source_dir)
for root, dirnames, filenames in os.walk(os.path.abspath(source_dir)):
dirnames[:] = [d for d in dirnames if d not in DIR_BLACKLIST]
(package_dir, tail) = os.path.split(root)
if tail != "debian":
continue
for fn in filenames:
(modulename, tail) = os.path.splitext(fn)
if tail != UMC_MODULES:
continue
if modulename in MODULE_BLACKLIST:
print("Ignoring module %s: Module is blacklisted\n" % modulename)
continue
print("Found package: %s" % package_dir)
module: BaseModule = {
'module_name': modulename,
'package': modulename,
'abs_path_to_src_pkg': package_dir,
'relative_path_src_pkg': os.path.relpath(package_dir, source_dir),
}
base_translation_modules.append(module)
return base_translation_modules
[docs]
def template_file(dst: str, fn: str, values: dict[str, str]) -> None:
"""
Render file from template file by filling in values.
:param dst: Destination path.
:param fn: File name for destination file and source template with `.tmpl` suffix.
:param values: A dictionary with the values.
"""
with open(os.path.join(os.path.dirname(__file__), fn + ".tmpl")) as f:
tmpl = f.read()
with open(os.path.join(dst, fn), 'w') as f:
f.write(tmpl.format(**values))
[docs]
def create_new_package(new_package_dir: str, target_language: str, target_locale: str, language_name: str, startdir: str) -> None:
new_package_dir_debian = os.path.join(new_package_dir, 'debian')
if not os.path.exists(new_package_dir_debian):
print("creating directory: %s" % new_package_dir_debian)
os.makedirs(new_package_dir_debian)
translation = {
"name": language_name,
"package_name": os.path.basename(new_package_dir),
"creator": getpass.getuser(),
"host": socket.getfqdn(),
"date": formatdate(),
"years": date.today().year,
}
template_file(new_package_dir, "Makefile", translation)
for fn in ("copyright", "changelog", "control", "compat", "rules"):
template_file(new_package_dir_debian, fn, translation)
with open(os.path.join(new_package_dir_debian, '%(package_name)s.postinst' % translation), 'w') as f:
f.write("""#!/bin/sh
#DEBHELPER#
eval \"$(ucr shell locale)\"
new_locale="%s"
case "${locale}" in
*"${new_locale}"*) echo "Locale ${new_locale} already known" ;;
*) ucr set locale="${locale} ${new_locale}" ;;
esac
ucr set ucs/server/languages/%s?"%s"
exit 0""" % (target_locale, target_locale.split('.', 1)[0], language_name))
# Move source files and installed .mo files to new package dir
if os.path.exists(os.path.join(new_package_dir, 'usr')):
shutil.rmtree(os.path.join(new_package_dir, 'usr'))
# shutil.copytree(os.path.join(startdir, 'usr'), os.path.join(new_package_dir, 'usr'))
# shutil.rmtree(os.path.join(startdir, 'usr'))
if os.path.exists(os.path.join(new_package_dir, target_language)):
shutil.rmtree(os.path.join(new_package_dir, target_language))
shutil.copytree(os.path.join(startdir, target_language), os.path.join(new_package_dir, target_language))
shutil.rmtree(os.path.join(startdir, target_language))