Source code for dsw_document_template_tool._translation_tree.xliff

"""XLIFF exchange helpers for translation trees."""

from __future__ import annotations

import xml.etree.ElementTree as ET
from collections.abc import Iterator
from dataclasses import dataclass
from pathlib import Path

from defusedxml import ElementTree as DefusedET
from defusedxml.common import DefusedXmlException

from .document import (
    parse_sentence_text,
    parse_translation_document,
    replace_translation_text,
)
from .manifest import TREE_MANIFEST_PATH, load_tree_manifest
from .models import TranslationTreeError
from .outline import refresh_outline_markdown

XLIFF_NS = "urn:oasis:names:tc:xliff:document:1.2"
XML_NS = "http://www.w3.org/XML/1998/namespace"
MAX_XLIFF_BYTES = 32 * 1024 * 1024
MAX_XLIFF_UNITS = 100_000
MAX_XLIFF_TEXT_BYTES = 1024 * 1024

ET.register_namespace("", XLIFF_NS)


[docs] @dataclass(frozen=True) class XliffImportReport: """Summary of one XLIFF import into a translation tree.""" imported_units: int
[docs] def export_xliff( *, tree_dir: Path, output_path: Path, source_lang: str, target_lang: str, ) -> Path: """Export a translation tree to one XLIFF 1.2 file.""" tree_dir = Path(tree_dir).resolve() output_path = Path(output_path).resolve() manifest = load_tree_manifest(tree_dir) units = _manifest_units(manifest, tree_dir=tree_dir) root = ET.Element(_tag("xliff"), {"version": "1.2"}) file_element = ET.SubElement( root, _tag("file"), { "original": tree_dir.name, "datatype": "plaintext", "source-language": source_lang, "target-language": target_lang, }, ) body = ET.SubElement(file_element, _tag("body")) for unit in units: document_path_raw = _required_str(unit, "document_path") document_path = tree_dir / document_path_raw source_text = parse_sentence_text( document_path=document_path, source_lang=source_lang, ) translation_text = parse_translation_document( document_path=document_path, source_lang=source_lang, target_lang=target_lang, ) trans_unit = ET.SubElement( body, _tag("trans-unit"), { "id": document_path_raw, "resname": _required_str(unit, "unit_key"), _xml_attr("space"): "preserve", }, ) ET.SubElement( trans_unit, _tag("source"), {_xml_attr("space"): "preserve"}, ).text = source_text ET.SubElement( trans_unit, _tag("target"), { _xml_attr("space"): "preserve", "state": "translated" if translation_text.strip() else "new", }, ).text = translation_text for key in ( "source_file", "wrapper_key", "unit_key", "unit_source_hash", "document_path", ): ET.SubElement(trans_unit, _tag("note"), {"from": key}).text = _required_str( unit, key, ) ET.indent(root, space=" ") output_path.parent.mkdir(parents=True, exist_ok=True) payload = ET.tostring(root, encoding="utf-8", xml_declaration=False) output_path.write_bytes(b'<?xml version="1.0" encoding="UTF-8"?>\n' + payload + b"\n") return output_path
[docs] def import_xliff( *, tree_dir: Path, xliff_path: Path, source_lang: str, target_lang: str, ) -> XliffImportReport: """Import edited XLIFF targets back into a translation tree.""" tree_dir = Path(tree_dir).resolve() xliff_path = Path(xliff_path).resolve() manifest = load_tree_manifest(tree_dir) units_by_document_path = { _required_str(unit, "document_path"): unit for unit in _manifest_units(manifest, tree_dir=tree_dir) } root = _parse_xliff(xliff_path) _validate_file_languages( root=root, xliff_path=xliff_path, source_lang=source_lang, target_lang=target_lang, ) seen_ids: set[str] = set() imported_units = 0 for trans_unit in _iter_children(root, "trans-unit"): if imported_units >= MAX_XLIFF_UNITS: raise TranslationTreeError( f"XLIFF file exceeds the limit of {MAX_XLIFF_UNITS} units: {xliff_path}" ) unit_id = trans_unit.attrib.get("id") if not unit_id: raise TranslationTreeError(f"XLIFF trans-unit is missing id in {xliff_path}") if unit_id in seen_ids: raise TranslationTreeError(f"Duplicate XLIFF trans-unit id in {xliff_path}: {unit_id}") seen_ids.add(unit_id) unit = units_by_document_path.get(unit_id) if unit is None: raise TranslationTreeError( f"XLIFF unit does not exist in the current translation tree: {unit_id}" ) notes = _notes_by_from(trans_unit) expected_hash = _required_str(unit, "unit_source_hash") actual_hash = notes.get("unit_source_hash") if actual_hash != expected_hash: raise TranslationTreeError( "XLIFF unit source hash does not match the current translation tree " f"for {unit_id}: expected {expected_hash}, found {actual_hash or '<missing>'}" ) document_path = tree_dir / unit_id source_text = _required_child_text(trans_unit, "source", unit_id=unit_id) current_source_text = parse_sentence_text( document_path=document_path, source_lang=source_lang, ) if source_text != current_source_text: raise TranslationTreeError( f"XLIFF source text does not match the current translation document for {unit_id}" ) translation_text = _optional_child_text(trans_unit, "target", unit_id=unit_id) replace_translation_text( document_path=document_path, target_lang=target_lang, translation_text=translation_text, ) imported_units += 1 refresh_outline_markdown( tree_dir=tree_dir, source_lang=source_lang, target_lang=target_lang, ) return XliffImportReport(imported_units=imported_units)
def _manifest_units(manifest: dict, *, tree_dir: Path) -> list[dict]: units = manifest.get("units") if not isinstance(units, list): raise TranslationTreeError( f"Invalid translation-tree manifest at {tree_dir / TREE_MANIFEST_PATH}" ) for unit in units: if not isinstance(unit, dict): raise TranslationTreeError( f"Invalid translation-tree manifest entry at {tree_dir / TREE_MANIFEST_PATH}" ) return units def _required_str(unit: dict, key: str) -> str: value = unit.get(key) if not isinstance(value, str): raise TranslationTreeError(f"Manifest unit is missing a valid {key}.") return value def _tag(name: str) -> str: return f"{{{XLIFF_NS}}}{name}" def _xml_attr(name: str) -> str: return f"{{{XML_NS}}}{name}" def _local_name(tag: str) -> str: return tag.rsplit("}", 1)[-1] def _parse_xliff(xliff_path: Path) -> ET.Element: with xliff_path.open("rb") as xliff_file: payload = xliff_file.read(MAX_XLIFF_BYTES + 1) if len(payload) > MAX_XLIFF_BYTES: raise TranslationTreeError( f"XLIFF file exceeds the {MAX_XLIFF_BYTES}-byte size limit: {xliff_path}" ) try: return DefusedET.fromstring(payload) except DefusedXmlException as error: raise TranslationTreeError(f"Unsafe XML in XLIFF file {xliff_path}: {error}") from error except ET.ParseError as error: raise TranslationTreeError(f"Invalid XML in XLIFF file {xliff_path}: {error}") from error def _iter_children(root: ET.Element, local_name: str) -> Iterator[ET.Element]: return (element for element in root.iter() if _local_name(element.tag) == local_name) def _validate_file_languages( *, root: ET.Element, xliff_path: Path, source_lang: str, target_lang: str, ) -> None: file_element = next(_iter_children(root, "file"), None) if file_element is None: raise TranslationTreeError(f"XLIFF file has no file element: {xliff_path}") actual_source_lang = file_element.attrib.get("source-language") actual_target_lang = file_element.attrib.get("target-language") if actual_source_lang != source_lang or actual_target_lang != target_lang: raise TranslationTreeError( "Unexpected XLIFF languages in " f"{xliff_path}: expected {source_lang}/{target_lang}, " f"found {actual_source_lang}/{actual_target_lang}" ) def _notes_by_from(trans_unit: ET.Element) -> dict[str, str]: notes: dict[str, str] = {} for child in trans_unit: if _local_name(child.tag) == "note": note_from = child.attrib.get("from") if note_from: notes[note_from] = child.text or "" return notes def _required_child_text(trans_unit: ET.Element, local_name: str, *, unit_id: str) -> str: text = _optional_child_text(trans_unit, local_name, unit_id=unit_id) if text == "": raise TranslationTreeError(f"XLIFF unit {unit_id} is missing {local_name} text") return text def _optional_child_text(trans_unit: ET.Element, local_name: str, *, unit_id: str) -> str: matches = [child for child in trans_unit if _local_name(child.tag) == local_name] if not matches: return "" if len(matches) > 1: raise TranslationTreeError(f"XLIFF unit {unit_id} has multiple {local_name} elements") element = matches[0] if list(element): raise TranslationTreeError( f"XLIFF unit {unit_id} contains nested XML inside {local_name}; " "use escaped text instead." ) text = element.text or "" if len(text.encode("utf-8")) > MAX_XLIFF_TEXT_BYTES: raise TranslationTreeError( f"XLIFF unit {unit_id} {local_name} text exceeds the " f"{MAX_XLIFF_TEXT_BYTES}-byte size limit" ) return text