diff --git a/src/co_op_translator/api/translation.py b/src/co_op_translator/api/translation.py index f69c9a28..8b0f1e09 100644 --- a/src/co_op_translator/api/translation.py +++ b/src/co_op_translator/api/translation.py @@ -1,4 +1,3 @@ -import importlib.resources import logging import os from contextlib import contextmanager @@ -7,7 +6,6 @@ from pathlib import Path from typing import Iterable, Mapping import click -import yaml from PIL import Image from co_op_translator.config.base_config import Config @@ -25,6 +23,10 @@ from co_op_translator.core.llm.jupyter_notebook_translator import ( from co_op_translator.core.llm.markdown_translator import MarkdownTranslator from co_op_translator.core.project.language_migrator import LanguageFolderMigrator from co_op_translator.core.project.project_translator import ProjectTranslator +from co_op_translator.core.project.translation.request import ( + build_translation_request, + resolve_translation_types, +) from co_op_translator.core.vision.image_translator import ImageTranslator from co_op_translator.glossary import glossary_terms_scope from co_op_translator.utils.common.file_utils import ( @@ -33,7 +35,6 @@ from co_op_translator.utils.common.file_utils import ( update_readme_languages_table, update_readme_other_courses, ) -from co_op_translator.utils.common.lang_utils import normalize_language_codes from co_op_translator.utils.common.logging_utils import setup_logging from co_op_translator.utils.common.metadata_utils import ( normalize_language_codes_in_lang_metadata, @@ -304,15 +305,13 @@ def run_translation( ) -> None: Config.check_configuration() - translation_types: list[str] = [] - if markdown: - translation_types.append("markdown") - if images: - translation_types.append("images") - if notebook: - translation_types.append("notebook") - if not translation_types: - translation_types = ["markdown", "notebook", "images"] + translation_types = list( + resolve_translation_types( + markdown=markdown, + images=images, + notebook=notebook, + ) + ) if "images" in translation_types: cv_available = VisionConfig.check_configuration() @@ -359,43 +358,16 @@ def run_translation( logger.info("Auto-confirming 'all' languages in non-interactive mode.") click.echo("Auto-confirming translation for all languages...") - try: - with importlib.resources.path( - "co_op_translator.fonts", "font_language_mappings.yml" - ) as mappings_path: - with open(mappings_path, "r", encoding="utf-8") as file: - font_mappings = yaml.safe_load(file) - if not font_mappings: - raise RuntimeError("Empty font mappings file") - language_codes = " ".join( - [ - lang_code - for lang_code in font_mappings - if isinstance(font_mappings[lang_code], dict) - ] - ) - if not language_codes: - raise RuntimeError( - "No valid language codes found in font mappings" - ) - logging.debug( - f"Loaded language codes from font mapping: {language_codes}" - ) - except (FileNotFoundError, yaml.YAMLError) as e: - raise RuntimeError(f"Failed to load font mappings: {str(e)}") from e - - if all_languages_selected: - try: - lang_list = Config.get_language_codes() - except Exception: - lang_list = [ - code.strip() for code in language_codes.split() if code.strip() - ] - else: - lang_list = [ - code.strip() for code in language_codes.split() if code.strip() - ] - lang_list = normalize_language_codes(lang_list) if lang_list else [] + request = build_translation_request( + language_codes=language_codes, + root_dir=root_dir, + markdown=markdown, + images=images, + notebook=notebook, + ) + language_codes = request.language_codes + lang_list = request.language_list_values() + translation_types = request.translation_types_list() if update: click.echo( @@ -590,18 +562,17 @@ def run_translation( lang_subdir: str | None, repo_url: str | None, ) -> dict[str, int]: - translation_types: list[str] = [] - if markdown: - translation_types.append("markdown") - if images: - translation_types.append("images") - if notebook: - translation_types.append("notebook") - if not translation_types: - translation_types = ["markdown", "notebook", "images"] + request = build_translation_request( + language_codes=language_codes, + root_dir=root_dir, + markdown=markdown, + images=images, + notebook=notebook, + ) + translation_types = request.translation_types_list() translator = ProjectTranslator( - language_codes, + request.language_codes, root_dir, translation_types=translation_types, add_disclaimer=add_disclaimer, @@ -610,7 +581,7 @@ def run_translation( lang_subdir=lang_subdir, ) virtual_file_contents = compute_pretranslation_virtual_inputs( - Path(root_dir).resolve(), + request.root_path, translation_types, repo_url=repo_url, ) @@ -665,15 +636,13 @@ def run_translation( "total": 0, "words": 0, } - translation_types_for_summary: list[str] = [] - if markdown: - translation_types_for_summary.append("markdown") - if images: - translation_types_for_summary.append("images") - if notebook: - translation_types_for_summary.append("notebook") - if not translation_types_for_summary: - translation_types_for_summary = ["markdown", "notebook", "images"] + translation_types_for_summary = list( + resolve_translation_types( + markdown=markdown, + images=images, + notebook=notebook, + ) + ) execution_targets: list[tuple[str, str | None, str | None]] = [] if groups is not None: diff --git a/src/co_op_translator/cli/translate.py b/src/co_op_translator/cli/translate.py index 698669f9..f7f89bf1 100644 --- a/src/co_op_translator/cli/translate.py +++ b/src/co_op_translator/cli/translate.py @@ -16,13 +16,14 @@ from co_op_translator.utils.common.file_utils import ( update_readme_languages_table, update_readme_other_courses, ) -from co_op_translator.utils.common.lang_utils import ( - normalize_language_codes, -) from co_op_translator.utils.common.metadata_utils import ( normalize_language_codes_in_lang_metadata, ) from co_op_translator.core.project.language_migrator import LanguageFolderMigrator +from co_op_translator.core.project.translation.request import ( + build_translation_request, + resolve_translation_types, +) logger = logging.getLogger(__name__) @@ -170,18 +171,13 @@ def translate_command( # Check that the required environment variables are set Config.check_configuration() - # Build translation types list based on user selection - translation_types = [] - if markdown: - translation_types.append("markdown") - if images: - translation_types.append("images") - if notebook: - translation_types.append("notebook") - - # Default: translate all supported file types if nothing specified - if not translation_types: - translation_types = ["markdown", "notebook", "images"] + translation_types = list( + resolve_translation_types( + markdown=markdown, + images=images, + notebook=notebook, + ) + ) # Check Azure AI Service availability if images are included if "images" in translation_types: @@ -249,21 +245,17 @@ def translate_command( click.echo("Proceeding with translation for all languages...") else: click.echo("Auto-confirming translation for all languages...") - # Use canonical list from config and normalize - lang_list = Config.get_language_codes() - if not lang_list: - raise click.ClickException( - "No valid language codes found in font mappings" - ) - language_codes = " ".join(normalize_language_codes(lang_list)) - else: - # Normalize explicit input codes to canonical form - lang_list = normalize_language_codes( - [code.strip() for code in language_codes.split()] - ) - if not lang_list: - raise click.ClickException("No valid language codes provided") - language_codes = " ".join(lang_list) + + request = build_translation_request( + language_codes=language_codes, + root_dir=root_dir, + markdown=markdown, + images=images, + notebook=notebook, + ) + language_codes = request.language_codes + lang_list = request.language_list_values() + translation_types = request.translation_types_list() # Detect and migrate alias-based language folders for the SELECTED languages # This runs before any translation work to avoid redundant re-translation. diff --git a/src/co_op_translator/core/project/translation/request.py b/src/co_op_translator/core/project/translation/request.py new file mode 100644 index 00000000..79ef3a69 --- /dev/null +++ b/src/co_op_translator/core/project/translation/request.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + +from co_op_translator.config.base_config import Config +from co_op_translator.utils.common.lang_utils import normalize_language_codes + +DEFAULT_TRANSLATION_TYPES = ("markdown", "notebook", "images") + + +@dataclass(frozen=True) +class TranslationRequest: + """Normalized project translation options shared by CLI and API entrypoints.""" + + language_codes: str + language_list: tuple[str, ...] + translation_types: tuple[str, ...] + root_dir: str + root_path: Path + all_languages_selected: bool + + def translation_types_list(self) -> list[str]: + return list(self.translation_types) + + def language_list_values(self) -> list[str]: + return list(self.language_list) + + +def resolve_translation_types( + *, + markdown: bool = False, + images: bool = False, + notebook: bool = False, +) -> tuple[str, ...]: + """Return translation types using the existing CLI/API option semantics.""" + translation_types: list[str] = [] + if markdown: + translation_types.append("markdown") + if images: + translation_types.append("images") + if notebook: + translation_types.append("notebook") + if not translation_types: + return DEFAULT_TRANSLATION_TYPES + return tuple(translation_types) + + +def normalize_requested_language_codes( + language_codes: str | Iterable[str], +) -> tuple[tuple[str, ...], bool]: + """Normalize requested language codes and expand the existing exact ``all`` token.""" + all_languages_selected = isinstance(language_codes, str) and language_codes == "all" + if all_languages_selected: + raw_codes = Config.get_language_codes() + if not raw_codes: + raise ValueError("No valid language codes found in font mappings") + elif isinstance(language_codes, str): + raw_codes = [code.strip() for code in language_codes.split()] + else: + raw_codes = [str(code).strip() for code in language_codes] + + normalized = tuple(normalize_language_codes(raw_codes)) + if not normalized: + raise ValueError("No valid language codes provided") + + return normalized, all_languages_selected + + +def build_translation_request( + *, + language_codes: str | Iterable[str], + root_dir: str = ".", + markdown: bool = False, + images: bool = False, + notebook: bool = False, +) -> TranslationRequest: + """Build normalized request data without performing I/O or provider checks.""" + language_list, all_languages_selected = normalize_requested_language_codes( + language_codes + ) + translation_types = resolve_translation_types( + markdown=markdown, + images=images, + notebook=notebook, + ) + root_path = Path(root_dir).resolve() + + return TranslationRequest( + language_codes=" ".join(language_list), + language_list=language_list, + translation_types=translation_types, + root_dir=root_dir, + root_path=root_path, + all_languages_selected=all_languages_selected, + ) diff --git a/tests/co_op_translator/core/project/translation/test_request.py b/tests/co_op_translator/core/project/translation/test_request.py new file mode 100644 index 00000000..950b6fb0 --- /dev/null +++ b/tests/co_op_translator/core/project/translation/test_request.py @@ -0,0 +1,67 @@ +from pathlib import Path + +import pytest + +from co_op_translator.core.project.translation import request + + +def test_resolve_translation_types_defaults_to_all_types(): + assert request.resolve_translation_types() == ("markdown", "notebook", "images") + + +def test_resolve_translation_types_preserves_existing_explicit_order(): + assert request.resolve_translation_types( + markdown=True, + images=True, + notebook=True, + ) == ("markdown", "images", "notebook") + + +def test_build_translation_request_normalizes_language_codes(tmp_path): + result = request.build_translation_request( + language_codes="ko tw pt-br", + root_dir=str(tmp_path), + markdown=True, + ) + + assert result.language_codes == "ko zh-TW pt-BR" + assert result.language_list == ("ko", "zh-TW", "pt-BR") + assert result.translation_types == ("markdown",) + assert result.root_path == Path(tmp_path).resolve() + assert result.all_languages_selected is False + + +def test_build_translation_request_expands_all(monkeypatch, tmp_path): + monkeypatch.setattr( + request.Config, + "get_language_codes", + lambda: ["ko", "tw", "pt-br"], + ) + + result = request.build_translation_request( + language_codes="all", + root_dir=str(tmp_path), + ) + + assert result.language_codes == "ko zh-TW pt-BR" + assert result.language_list == ("ko", "zh-TW", "pt-BR") + assert result.translation_types == ("markdown", "notebook", "images") + assert result.all_languages_selected is True + + +def test_build_translation_request_rejects_empty_language_codes(tmp_path): + with pytest.raises(ValueError, match="No valid language codes provided"): + request.build_translation_request( + language_codes=" ", + root_dir=str(tmp_path), + ) + + +def test_build_translation_request_rejects_empty_all_mapping(monkeypatch, tmp_path): + monkeypatch.setattr(request.Config, "get_language_codes", lambda: []) + + with pytest.raises(ValueError, match="No valid language codes found"): + request.build_translation_request( + language_codes="all", + root_dir=str(tmp_path), + )