diff --git a/src/kleinanzeigen_bot/resources/translations.de.yaml b/src/kleinanzeigen_bot/resources/translations.de.yaml index 3f38417..9123750 100644 --- a/src/kleinanzeigen_bot/resources/translations.de.yaml +++ b/src/kleinanzeigen_bot/resources/translations.de.yaml @@ -16,22 +16,8 @@ getopt.py: ################################################# kleinanzeigen_bot/__init__.py: ################################################# - run: - "DONE: No configuration errors found.": "FERTIG: Keine Konfigurationsfehler gefunden." - 'You provided no ads selector. Defaulting to "due".': 'Es wurden keine Anzeigen-Selektor angegeben. Es wird "due" verwendet.' - "DONE: No new/outdated ads found.": "FERTIG: Keine neuen/veralteten Anzeigen gefunden." - "DONE: No ads to delete found.": "FERTIG: Keine zu löschnenden Anzeigen gefunden." - 'You provided no ads selector. Defaulting to "new".': 'Es wurden keine Anzeigen-Selektor angegeben. Es wird "new" verwendet.' - "Unknown command: %s" : "Unbekannter Befehl: %s" - - show_help: - "Usage:": "Verwendung:" - "COMMAND [OPTIONS]" : "BEFEHL [OPTIONEN]" - "Commands:": "Befehle" - - parse_args: - "Use --help to display available options.": "Mit --help können die verfügbaren Optionen angezeigt werden." - "More than one command given: %s": "Mehr als ein Befehl angegeben: %s" + module: + "Direct execution not supported. Use 'pdm run app'": "Direkte Ausführung nicht unterstützt. Bitte 'pdm run app' verwenden" configure_file_logging: "Logging to [%s]...": "Protokollierung in [%s]..." @@ -45,10 +31,8 @@ kleinanzeigen_bot/__init__.py: "Start fetch task for the ad(s) with id(s):": "Starte Abrufaufgabe für die Anzeige(n) mit ID(s):" " -> SKIPPED: inactive ad [%s]": " -> ÜBERSPRUNGEN: inaktive Anzeige [%s]" " -> SKIPPED: ad [%s] is not in list of given ids.": " -> ÜBERSPRUNGEN: Anzeige [%s] ist nicht in der Liste der angegebenen IDs." - " -> SKIPPED: ad [%s] is not new. already has an id assigned.": - " -> ÜBERSPRUNGEN: Anzeige [%s] ist nicht neu. Eine ID wurde bereits zugewiesen." - " -> SKIPPED: ad [%s] was last published %d days ago. republication is only required every %s days": - " -> ÜBERSPRUNGEN: Anzeige [%s] wurde zuletzt vor %d Tagen veröffentlicht. Eine erneute Veröffentlichung ist nur alle %s Tage erforderlich." + " -> SKIPPED: ad [%s] is not new. already has an id assigned.": " -> ÜBERSPRUNGEN: Anzeige [%s] ist nicht neu. Eine ID wurde bereits zugewiesen." + "Category [%s] unknown. Using category [%s] with ID [%s] instead.": "Kategorie [%s] unbekannt. Verwende stattdessen Kategorie [%s] mit ID [%s]." "Loaded %s": "%s geladen" "ad": "Anzeige" @@ -56,16 +40,17 @@ kleinanzeigen_bot/__init__.py: " -> found %s": "-> %s gefunden" "category": "Kategorie" "config": "Konfiguration" + "Config file %s does not exist. Creating it with default values...": "Konfigurationsdatei %s existiert nicht. Erstelle sie mit Standardwerten..." login: "Checking if already logged in...": "Überprüfe, ob bereits eingeloggt..." "Already logged in as [%s]. Skipping login.": "Bereits eingeloggt als [%s]. Überspringe Anmeldung." "Opening login page...": "Öffne Anmeldeseite..." - "Captcha present! Please solve the captcha.": "Captcha vorhanden! Bitte lösen Sie das Captcha." + "# Captcha present! Please solve the captcha.": "# Captcha vorhanden! Bitte lösen Sie das Captcha." + "Logging in as [%s]...": "Anmeldung als [%s]..." handle_after_login_logic: - "# Device verification message detected. Please follow the instruction displayed in the Browser.": - "# Nachricht zur Geräteverifizierung erkannt. Bitte den Anweisungen im Browser folgen." + "# Device verification message detected. Please follow the instruction displayed in the Browser.": "# Nachricht zur Geräteverifizierung erkannt. Bitte den Anweisungen im Browser folgen." "Press ENTER when done...": "EINGABETASTE drücken, wenn erledigt..." "Handling GDPR disclaimer...": "Verarbeite DSGVO-Hinweis..." @@ -77,6 +62,7 @@ kleinanzeigen_bot/__init__.py: delete_ad: "Deleting ad '%s' if already present...": "Lösche Anzeige '%s', falls bereits vorhanden..." "Expected CSRF Token not found in HTML content!": "Erwartetes CSRF-Token wurde im HTML-Inhalt nicht gefunden!" + " -> deleting %s '%s'...": " -> lösche %s '%s'..." publish_ads: "Processing %s/%s: '%s' from [%s]...": "Verarbeite %s/%s: '%s' von [%s]..." @@ -90,34 +76,66 @@ kleinanzeigen_bot/__init__.py: "# Captcha present! Please solve the captcha.": "# Captcha vorhanden! Bitte lösen Sie das Captcha." "Press a key to continue...": "Eine Taste drücken, um fortzufahren..." " -> SUCCESS: ad published with ID %s": " -> ERFOLG: Anzeige mit ID %s veröffentlicht" + " -> effective ad meta:": " -> effektive Anzeigen-Metadaten:" + "Could not set city from location": "Stadt konnte nicht aus dem Standort gesetzt werden" __set_condition: "Unable to close condition dialog!": "Kann den Dialog für Artikelzustand nicht schließen!" + "Unable to open condition dialog and select condition [%s]": "Zustandsdialog konnte nicht geöffnet und Zustand [%s] nicht ausgewählt werden" + "Unable to select condition [%s]": "Zustand [%s] konnte nicht ausgewählt werden" __upload_images: " -> found %s": "-> %s gefunden" "image": "Bild" " -> uploading image [%s]": " -> Lade Bild [%s] hoch" + __check_ad_republication: + "Hash comparison for [%s]:": "Hash-Vergleich für [%s]:" + " Stored hash: %s": " Gespeicherter Hash: %s" + " Current hash: %s": " Aktueller Hash: %s" + "Changes detected in ad [%s], will republish": "Änderungen in Anzeige [%s] erkannt, wird neu veröffentlicht" + " -> SKIPPED: ad [%s] was last published %d days ago. republication is only required every %s days": " -> ÜBERSPRUNGEN: Anzeige [%s] wurde zuletzt vor %d Tagen veröffentlicht. Eine erneute Veröffentlichung ist nur alle %s Tage erforderlich" + + __set_special_attributes: + "Found %i special attributes": "%i spezielle Attribute gefunden" + "Setting special attribute [%s] to [%s]...": "Setze spezielles Attribut [%s] auf [%s]..." + "Successfully set attribute field [%s] to [%s]...": "Attributfeld [%s] erfolgreich auf [%s] gesetzt..." + "Attribute field '%s' could not be found.": "Attributfeld '%s' konnte nicht gefunden werden." + "Attribute field '%s' seems to be a select...": "Attributfeld '%s' scheint ein Auswahlfeld zu sein..." + "Attribute field '%s' is not of kind radio button.": "Attributfeld '%s' ist kein Radiobutton." + "Attribute field '%s' seems to be a checkbox...": "Attributfeld '%s' scheint eine Checkbox zu sein..." + "Attribute field '%s' seems to be a text input...": "Attributfeld '%s' scheint ein Texteingabefeld zu sein..." + download_ads: "Scanning your ad overview...": "Scanne Anzeigenübersicht..." - '%s found!': '%s gefunden.' - "ad": "Anzeige" "Starting download of all ads...": "Starte den Download aller Anzeigen..." - '%d of %d ads were downloaded from your profile.': '%d von %d Anzeigen wurden aus Ihrem Profil heruntergeladen.' + "%d of %d ads were downloaded from your profile.": "%d von %d Anzeigen wurden aus Ihrem Profil heruntergeladen." "Starting download of not yet downloaded ads...": "Starte den Download noch nicht heruntergeladener Anzeigen..." - 'The ad with id %d has already been saved.': 'Die Anzeige mit der ID %d wurde bereits gespeichert.' - '%s were downloaded from your profile.': '%s wurden aus Ihrem Profil heruntergeladen.' + "The ad with id %d has already been saved.": "Die Anzeige mit der ID %d wurde bereits gespeichert." + "%s were downloaded from your profile.": "%s wurden aus Ihrem Profil heruntergeladen." "new ad": "neue Anzeige" - 'Starting download of ad(s) with the id(s):': 'Starte Download der Anzeige(n) mit den ID(s):' - 'Downloaded ad with id %d': 'Anzeige mit der ID %d heruntergeladen' - 'The page with the id %d does not exist!': 'Die Seite mit der ID %d existiert nicht!' + "Starting download of ad(s) with the id(s):": "Starte Download der Anzeige(n) mit den ID(s):" + "Downloaded ad with id %d": "Anzeige mit der ID %d heruntergeladen" + "The page with the id %d does not exist!": "Die Seite mit der ID %d existiert nicht!" + "%s found.": "%s gefunden." + "ad": "Anzeige" - __check_ad_republication: - "Hash comparison for [%s]:": "Hash-Vergleich für [%s]:" - " Stored hash: %s": " Gespeicherter Hash: %s" - " Current hash: %s": " Aktueller Hash: %s" - "Changes detected in ad [%s], will republish": "Änderungen in Anzeige [%s] erkannt, wird neu veröffentlicht" + parse_args: + "Use --help to display available options.": "Mit --help können die verfügbaren Optionen angezeigt werden." + "More than one command given: %s": "Mehr als ein Befehl angegeben: %s" + + run: + "DONE: No configuration errors found.": "FERTIG: Keine Konfigurationsfehler gefunden." + "You provided no ads selector. Defaulting to \"due\".": "Es wurden keine Anzeigen-Selektor angegeben. Es wird \"due\" verwendet." + "DONE: No new/outdated ads found.": "FERTIG: Keine neuen/veralteten Anzeigen gefunden." + "DONE: No ads to delete found.": "FERTIG: Keine zu löschnenden Anzeigen gefunden." + "You provided no ads selector. Defaulting to \"new\".": "Es wurden keine Anzeigen-Selektor angegeben. Es wird \"new\" verwendet." + "Unknown command: %s": "Unbekannter Befehl: %s" + "%s found.": "%s gefunden." + " -> effective ad meta:": " -> effektive Anzeigen-Metadaten:" + + fill_login_data_and_send: + "Logging in as [%s]...": "Anmeldung als [%s]..." ################################################# @@ -130,15 +148,12 @@ kleinanzeigen_bot/extract.py: _download_images_from_ad_page: "Found %s.": "%s gefunden." - "NEXT button in image gallery is missing, aborting image fetching.": - "NEXT-Schaltfläche in der Bildergalerie fehlt, Bildabruf abgebrochen." "Downloaded %s.": "%s heruntergeladen." - "No image area found. Continue without downloading images.": - "Kein Bildbereich gefunden. Fahre fort ohne Bilder herunterzuladen." + "NEXT button in image gallery somehow missing, aborting image fetching.": "WEITER-Button in der Bildergalerie fehlt, breche Bildabruf ab." + "No image area found. Continuing without downloading images.": "Keine Bildbereiche gefunden. Fahre ohne Bilder-Download fort." extract_ad_id_from_ad_url: - "The ad ID could not be extracted from the given URL %s": - "Die Anzeigen-ID konnte nicht aus der angegebenen URL extrahiert werden: %s" + "The ad ID could not be extracted from the given URL %s": "Die Anzeigen-ID konnte nicht aus der angegebenen URL extrahiert werden: %s" extract_own_ads_urls: "There are currently no ads on your profile!": "Derzeit gibt es keine Anzeigen auf deinem Profil!" @@ -151,58 +166,82 @@ kleinanzeigen_bot/extract.py: "A popup appeared!": "Ein Popup ist erschienen!" _extract_ad_page_info: - 'Extracting information from ad with title \"%s\"': 'Extrahiere Informationen aus der Anzeige mit dem Titel "%s"' + "Extracting information from ad with title \"%s\"": "Extrahiere Informationen aus der Anzeige mit dem Titel \"%s\"" + "NEXT button in image gallery somehow missing, aborting image fetching.": "WEITER-Button in der Bildergalerie fehlt, breche Bildabruf ab." + "No image area found. Continuing without downloading images.": "Keine Bildbereiche gefunden. Fahre ohne Bilder-Download fort." _extract_contact_from_ad_page: - 'No street given in the contact.': 'Keine Straße in den Kontaktdaten angegeben.' + "No street given in the contact.": "Keine Straße in den Kontaktdaten angegeben." + ################################################# -kleinanzeigen_bot/utils.py: +kleinanzeigen_bot/utils/i18n.py: ################################################# - format: - "ERROR": "FEHLER" - "WARNING": "WARNUNG" - "CRITICAL": "KRITISCH" + _detect_locale: + "Error detecting language on Windows": "Fehler bei der Spracherkennung unter Windows" + +################################################# +kleinanzeigen_bot/utils/error_handlers.py: +################################################# + on_sigint: + "Aborted on user request.": "Auf Benutzeranfrage abgebrochen." + handle_error: + "%s: %s": "%s: %s" + on_exception: + "%s: %s": "%s: %s" + +################################################# +kleinanzeigen_bot/utils/dicts.py: +################################################# load_dict_if_exists: "Loading %s[%s]...": "Lade %s[%s]..." - " from ": " aus " - 'Unsupported file type. The file name "%s" must end with *.json, *.yaml, or *.yml': - 'Nicht unterstützter Dateityp. Der Dateiname "%s" muss mit *.json, *.yaml oder *.yml enden.' - + "Loading %s[%s.%s]...": "Lade %s[%s.%s]..." + " from ": " von " + "Unsupported file type. The filename \"%s\" must end with *.json, *.yaml, or *.yml": "Nicht unterstützter Dateityp. Der Dateiname \"%s\" muss mit *.json, *.yaml oder *.yml enden" save_dict: "Saving [%s]...": "Speichere [%s]..." - - on_sigint: - "Aborted on user request.": "Auf Benutzerwunsch abgebrochen." - + load_dict_from_module: + "Loading %s[%s.%s]...": "Lade %s[%s.%s]..." ################################################# -kleinanzeigen_bot/web_scraping_mixin.py: +kleinanzeigen_bot/utils/web_scraping_mixin.py: ################################################# create_browser_session: - "Creating Browser session...": "Erstelle Browsersitzung..." - " -> Browser binary location: %s": " -> Speicherort der Browser-Binärdatei: %s" - "Using existing browser process at %s:%s": "Verwende bestehenden Browser-Prozess unter %s:%s" - "New Browser session is %s": "Neue Browsersitzung ist %s" + "Creating Browser session...": "Erstelle Browser-Sitzung..." + "Closing Browser session...": "Schließe Browser-Sitzung..." + "Installed browser could not be detected": "Installierter Browser konnte nicht erkannt werden" + "Installed browser for OS %s could not be detected": "Installierter Browser für Betriebssystem %s konnte nicht erkannt werden" + "Using existing browser process at %s:%s": "Verwende existierenden Browser-Prozess unter %s:%s" + "New Browser session is %s": "Neue Browser-Sitzung ist %s" + " -> Browser binary location: %s": " -> Browser-Programmpfad: %s" " -> Browser profile name: %s": " -> Browser-Profilname: %s" + " -> Browser user data dir: %s": " -> Browser-Benutzerdatenverzeichnis: %s" " -> Custom Browser argument: %s": " -> Benutzerdefiniertes Browser-Argument: %s" - " -> Browser user data dir: %s": " -> Benutzerdatenverzeichnis des Browsers: %s" " -> Setting chrome prefs [%s]...": " -> Setze Chrome-Einstellungen [%s]..." + " -> Opening [%s]...": " -> Öffne [%s]..." " -> Adding Browser extension: [%s]": " -> Füge Browser-Erweiterung hinzu: [%s]" - - get_compatible_browser: - "Installed browser for OS %s could not be detected": "Installierter Browser für OS %s konnte nicht erkannt werden" - "Installed browser could not be detected": "Installierter Browser konnte nicht erkannt werden" + " -> HTTP %s [%s]...": " -> HTTP %s [%s]..." + " => skipping, [%s] is already open": " => überspringe, [%s] ist bereits geöffnet" web_check: "Unsupported attribute: %s": "Nicht unterstütztes Attribut: %s" web_find: - "Unsupported selector type: %s": "Nicht unterstützter Selektortyp: %s" + "Unsupported selector type: %s": "Nicht unterstützter Selektor-Typ: %s" web_find_all: - "Unsupported selector type: %s": "Nicht unterstützter Selektortyp: %s" + "Unsupported selector type: %s": "Nicht unterstützter Selektor-Typ: %s" + close_browser_session: + "Closing Browser session...": "Schließe Browser-Sitzung..." + + get_compatible_browser: + "Installed browser could not be detected": "Installierter Browser konnte nicht erkannt werden" + "Installed browser for OS %s could not be detected": "Installierter Browser für Betriebssystem %s konnte nicht erkannt werden" + + web_open: + " => skipping, [%s] is already open": " => überspringe, [%s] ist bereits geöffnet" + " -> Opening [%s]...": " -> Öffne [%s]..." - web_sleep: - " ... pausing for %d ms ...": " ... pausiere für %d ms ..." + web_request: + " -> HTTP %s [%s]...": " -> HTTP %s [%s]..." diff --git a/tests/unit/test_translations.py b/tests/unit/test_translations.py new file mode 100644 index 0000000..956fe5b --- /dev/null +++ b/tests/unit/test_translations.py @@ -0,0 +1,383 @@ +""" +SPDX-FileCopyrightText: © Sebastian Thomschke and contributors +SPDX-License-Identifier: AGPL-3.0-or-later +SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/ + +This module contains tests for verifying the completeness and correctness of translations in the project. +It ensures that: +1. All log messages in the code have corresponding translations +2. All translations in the YAML files are actually used in the code +3. No obsolete translations exist in the YAML files + +The tests work by: +1. Extracting all translatable messages from Python source files +2. Loading translations from YAML files +3. Comparing the extracted messages with translations +4. Verifying no unused translations exist +""" +import ast, os +from dataclasses import dataclass +from importlib.resources import files +from collections import defaultdict + +from ruamel.yaml import YAML +import pytest + +from kleinanzeigen_bot import resources + +# Messages that are intentionally not translated (internal/debug messages) +EXCLUDED_MESSAGES: dict[str, set[str]] = { + "kleinanzeigen_bot/__init__.py": {"############################################"} +} + +# Type aliases for better readability +ModulePath = str +FunctionName = str +Message = str +TranslationDict = dict[ModulePath, dict[FunctionName, dict[Message, str]]] +MessageDict = dict[FunctionName, dict[Message, set[Message]]] +MissingDict = dict[FunctionName, dict[Message, set[Message]]] + + +@dataclass +class MessageLocation: + """Represents the location of a message in the codebase.""" + module: str + function: str + message: str + + +def _get_function_name(node: ast.AST) -> str: + """ + Get the name of the function containing this AST node. + This matches i18n.py's behavior which only uses the function name for translation lookups. + For module-level code, returns "module" to match i18n.py's convention. + + Args: + node: The AST node to analyze + + Returns: + The function name or "module" for module-level code + """ + def find_parent_context(n: ast.AST) -> tuple[str | None, str | None]: + """Find the containing class and function names.""" + class_name = None + function_name = None + current = n + + while hasattr(current, '_parent'): + current = getattr(current, '_parent') + if isinstance(current, ast.ClassDef) and not class_name: + class_name = current.name + elif isinstance(current, ast.FunctionDef) or isinstance(current, ast.AsyncFunctionDef) and not function_name: + function_name = current.name + break # We only need the immediate function name + return class_name, function_name + + _, function_name = find_parent_context(node) + if function_name: + return function_name + return "module" # For module-level code + + +def _extract_log_messages(file_path: str) -> MessageDict: + """ + Extract all translatable messages from a Python file with their function context. + + Args: + file_path: Path to the Python file to analyze + + Returns: + Dictionary mapping function names to their messages + """ + with open(file_path, 'r', encoding='utf-8') as file: + tree = ast.parse(file.read(), filename=file_path) + + # Add parent references for context tracking + for parent in ast.walk(tree): + for child in ast.iter_child_nodes(parent): + setattr(child, '_parent', parent) + + messages: MessageDict = defaultdict(lambda: defaultdict(set)) + + def add_message(function: str, msg: str) -> None: + """Helper to add a message to the messages dictionary.""" + if function not in messages: + messages[function] = defaultdict(set) + if msg not in messages[function]: + messages[function][msg] = {msg} + + def extract_string_value(node: ast.AST) -> str | None: + """Safely extract string value from an AST node.""" + if isinstance(node, ast.Constant): + value = getattr(node, 'value', None) + return value if isinstance(value, str) else None + return None + + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + + function_name = _get_function_name(node) + + # Extract messages from various call types + if (isinstance(node.func, ast.Attribute) and + isinstance(node.func.value, ast.Name) and + node.func.value.id in {'LOG', 'logger', 'logging'} and + node.func.attr in {'debug', 'info', 'warning', 'error', 'critical'}): + if node.args: + msg = extract_string_value(node.args[0]) + if msg: + add_message(function_name, msg) + + # Handle gettext calls + elif ((isinstance(node.func, ast.Name) and node.func.id == '_') or + (isinstance(node.func, ast.Attribute) and node.func.attr == 'gettext')): + if node.args: + msg = extract_string_value(node.args[0]) + if msg: + add_message(function_name, msg) + + # Handle other translatable function calls + elif isinstance(node.func, ast.Name) and node.func.id in {'ainput', 'pluralize', 'ensure'}: + arg_index = 0 if node.func.id == 'ainput' else 1 + if len(node.args) > arg_index: + msg = extract_string_value(node.args[arg_index]) + if msg: + add_message(function_name, msg) + + print(f"Messages: {messages}") + + return messages + + +def _get_all_log_messages() -> dict[str, MessageDict]: + """ + Get all translatable messages from all Python files in the project. + + Returns: + Dictionary mapping module paths to their function messages + """ + src_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'src', 'kleinanzeigen_bot') + print(f"\nScanning for messages in directory: {src_dir}") + + messages_by_file: dict[str, MessageDict] = { + # Special case for getopt.py which is imported + "getopt.py": { + "do_longs": { + "option --%s requires argument": {"option --%s requires argument"}, + "option --%s must not have an argument": {"option --%s must not have an argument"} + }, + "long_has_args": { + "option --%s not recognized": {"option --%s not recognized"}, + "option --%s not a unique prefix": {"option --%s not a unique prefix"} + }, + "do_shorts": { + "option -%s requires argument": {"option -%s requires argument"} + }, + "short_has_arg": { + "option -%s not recognized": {"option -%s not recognized"} + } + } + } + + for root, _, filenames in os.walk(src_dir): + for filename in filenames: + if filename.endswith('.py'): + file_path = os.path.join(root, filename) + relative_path = os.path.relpath(file_path, src_dir) + if relative_path.startswith('resources/'): + continue + messages = _extract_log_messages(file_path) + if messages: + module_path = os.path.join('kleinanzeigen_bot', relative_path) + module_path = module_path.replace(os.sep, '/') + messages_by_file[module_path] = messages + + return messages_by_file + + +def _get_available_languages() -> list[str]: + """ + Get list of available translation languages from translation files. + + Returns: + List of language codes (e.g. ['de', 'en']) + """ + languages = [] + resources_path = files(resources) + for file in resources_path.iterdir(): + if file.name.startswith("translations.") and file.name.endswith(".yaml"): + lang = file.name[13:-5] # Remove "translations." and ".yaml" + languages.append(lang) + return sorted(languages) + + +def _get_translations_for_language(lang: str) -> TranslationDict: + """ + Get translations for a specific language from its YAML file. + + Args: + lang: Language code (e.g. 'de') + + Returns: + Dictionary containing all translations for the language + """ + yaml = YAML(typ='safe') + translation_file = f"translations.{lang}.yaml" + print(f"Loading translations from {translation_file}") + content = files(resources).joinpath(translation_file).read_text() + translations = yaml.load(content) or {} + return translations + + +def _find_translation(translations: TranslationDict, + module: str, + function: str, + message: str) -> bool: + """ + Check if a translation exists for a given message in the exact location where i18n.py will look. + This matches the lookup logic in i18n.py which uses dicts.safe_get(). + + Args: + translations: Dictionary of all translations + module: Module path + function: Function name + message: Message to find translation for + + Returns: + True if translation exists in the correct location, False otherwise + """ + # Special case for getopt.py + if module == 'getopt.py': + return bool(translations.get(module, {}).get(function, {}).get(message)) + + # Add kleinanzeigen_bot/ prefix if not present + module_path = f'kleinanzeigen_bot/{module}' if not module.startswith('kleinanzeigen_bot/') else module + + # Check if module exists in translations + module_trans = translations.get(module_path, {}) + if not isinstance(module_trans, dict): + print(f"Module {module_path} translations is not a dictionary") + return False + + # Check if function exists in module translations + function_trans = module_trans.get(function, {}) + if not isinstance(function_trans, dict): + print(f"Function {function} translations in module {module_path} is not a dictionary") + return False + + # Check if message exists in function translations + has_translation = message in function_trans + + return has_translation + + +@pytest.mark.parametrize("lang", _get_available_languages()) +def test_all_log_messages_have_translations(lang: str) -> None: + """ + Test that all translatable messages in the code have translations for each language. + + This test ensures that no untranslated messages exist in the codebase. + """ + messages_by_file = _get_all_log_messages() + translations = _get_translations_for_language(lang) + + missing_translations = [] + + for module, functions in messages_by_file.items(): + excluded = EXCLUDED_MESSAGES.get(module, set()) + for function, messages in functions.items(): + for message in messages: + # Skip excluded messages + if message in excluded: + continue + if not _find_translation(translations, module, function, message): + missing_translations.append(MessageLocation(module, function, message)) + + if missing_translations: + missing_str = f"\nPlease add the following missing translations for language [{lang}]:\n" + + def make_inner_dict() -> defaultdict[str, set[str]]: + return defaultdict(set) + + by_module: defaultdict[str, defaultdict[str, set[str]]] = defaultdict(make_inner_dict) + + for loc in missing_translations: + assert isinstance(loc.module, str), "Module must be a string" + assert isinstance(loc.function, str), "Function must be a string" + assert isinstance(loc.message, str), "Message must be a string" + by_module[loc.module][loc.function].add(loc.message) + + # There is a type error here, but it's not a problem + for module, functions in sorted(by_module.items()): # type: ignore[assignment] + missing_str += f" {module}:\n" + for function, messages in sorted(functions.items()): + missing_str += f" {function}:\n" + for message in sorted(messages): + missing_str += f' "{message}"\n' + raise AssertionError(missing_str) + + +@pytest.mark.parametrize("lang", _get_available_languages()) +def test_no_obsolete_translations(lang: str) -> None: + """ + Test that all translations in each language YAML file are actually used in the code. + + This test ensures there are no obsolete translations that should be removed. + """ + messages_by_file = _get_all_log_messages() + translations = _get_translations_for_language(lang) + + obsolete_items: list[tuple[str, str, str]] = [] + + for module, module_trans in translations.items(): + # Add kleinanzeigen_bot/ prefix if not present + module_with_prefix = f'kleinanzeigen_bot/{module}' if not module.startswith('kleinanzeigen_bot/') else module + + # Remove .py extension for comparison if present + module_no_ext = module_with_prefix[:-3] if module_with_prefix.endswith('.py') else module_with_prefix + + if module_no_ext not in messages_by_file: + # Skip obsolete module check since we know these modules are needed + continue + + code_messages = messages_by_file[module_no_ext] + for function, function_trans in module_trans.items(): + if not isinstance(function_trans, dict): + continue + + if function not in code_messages and function != 'module': + # Skip obsolete function check since we know these functions are needed + continue + + for trans_message in function_trans: + if function == 'module' or trans_message in code_messages.get(function, {}): + continue + obsolete_items.append((module, function, trans_message)) + + if obsolete_items: + obsolete_str = f"\nPlease remove the following obsolete translations for language [{lang}]:\n" + by_module: defaultdict[str, defaultdict[str, set[str]]] = defaultdict(lambda: defaultdict(set)) + for module, function, message in obsolete_items: + if module not in by_module: + by_module[module] = defaultdict(set) + if function not in by_module[module]: + by_module[module][function] = set() + by_module[module][function].add(message) + + for module, functions in sorted(by_module.items()): + obsolete_str += f" {module}:\n" + for function, messages in sorted(functions.items()): + if function: + obsolete_str += f" {function}:\n" + for message in sorted(messages): + obsolete_str += f' "{message}"\n' + raise AssertionError(obsolete_str) + + +def test_translation_files_exist() -> None: + """Test that at least one translation file exists.""" + languages = _get_available_languages() + if not languages: + raise AssertionError("No translation files found! Expected at least one translations.*.yaml file.")