diff --git a/src/kleinanzeigen_bot/resources/translations.de.yaml b/src/kleinanzeigen_bot/resources/translations.de.yaml index 0c8b5c8..ffae456 100644 --- a/src/kleinanzeigen_bot/resources/translations.de.yaml +++ b/src/kleinanzeigen_bot/resources/translations.de.yaml @@ -33,18 +33,15 @@ kleinanzeigen_bot/__init__.py: load_ads: "Searching for ad config files...": "Suche nach Anzeigendateien..." " -> found %s": "-> %s gefunden" - "ad config file": "Anzeigendatei" "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." "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" load_config: " -> 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..." @@ -53,7 +50,6 @@ kleinanzeigen_bot/__init__.py: "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." - "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." @@ -63,7 +59,6 @@ kleinanzeigen_bot/__init__.py: delete_ads: "Processing %s/%s: '%s' from [%s]...": "Verarbeite %s/%s: '%s' von [%s]..." "DONE: Deleted %s": "FERTIG: %s gelöscht" - "ad": "Anzeige" delete_ad: "Deleting ad '%s' if already present...": "Lösche Anzeige '%s', falls bereits vorhanden..." @@ -74,7 +69,6 @@ kleinanzeigen_bot/__init__.py: "Processing %s/%s: '%s' from [%s]...": "Verarbeite %s/%s: '%s' von [%s]..." "Skipping because ad is reserved": "Überspringen, da Anzeige reserviert ist" "DONE: (Re-)published %s": "FERTIG: %s (erneut) veröffentlicht" - "ad": "Anzeige" publish_ad: "Publishing ad '%s'...": "Veröffentliche Anzeige '%s'..." @@ -92,11 +86,9 @@ kleinanzeigen_bot/__init__.py: __upload_images: " -> found %s": "-> %s gefunden" - "image": "Bild" " -> uploading image [%s]": " -> Lade Bild [%s] hoch" __check_ad_republication: - "Changes detected in ad [%s], will republish": "Änderungen in Anzeige [%s] erkannt, wird erneut 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. Erneute Veröffentlichung ist erst nach %s Tagen erforderlich" __set_special_attributes: @@ -116,12 +108,10 @@ kleinanzeigen_bot/__init__.py: "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." - "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!" "%s found.": "%s gefunden." - "ad": "Anzeige" parse_args: "Use --help to display available options.": "Mit --help können die verfügbaren Optionen angezeigt werden." @@ -134,8 +124,6 @@ kleinanzeigen_bot/__init__.py: "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]..." @@ -158,22 +146,20 @@ kleinanzeigen_bot/extract.py: "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 %s extrahiert werden" extract_own_ads_urls: - "There are currently no ads on your profile!": "Derzeit gibt es keine Anzeigen auf deinem Profil!" - "It looks like you have many ads!": "Es scheint viele Anzeigen zu geben!" - "It looks like all your ads fit on one overview page.": "Alle Anzeigen scheinen auf eine Übersichtsseite zu passen." - "Last ad overview page explored.": "Letzte Anzeigenübersichtsseite gesichtet." + "There are currently no ads on your profile!": "Es gibt derzeit keine Anzeigen in deinem Profil!" + "It looks like you have many ads!": "Es sieht so aus, als hättest du viele Anzeigen!" + "It looks like all your ads fit on one overview page.": "Es sieht so aus, als würden alle deine Anzeigen auf eine Übersichtsseite passen." + "Last ad overview page explored.": "Letzte Übersichtsseite erkundet." naviagte_to_ad_page: "There is no ad under the given ID.": "Es gibt keine Anzeige unter der angegebenen ID." "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\"" - "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." + "Extracting information from ad with title \"%s\"": "Extrahiere Informationen aus Anzeige mit Titel \"%s\"" _extract_contact_from_ad_page: "No street given in the contact.": "Keine Straße in den Kontaktdaten angegeben." @@ -191,8 +177,6 @@ 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" @@ -201,7 +185,6 @@ kleinanzeigen_bot/utils/dicts.py: ################################################# load_dict_if_exists: "Loading %s[%s]...": "Lade %s[%s]..." - "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: @@ -214,9 +197,6 @@ kleinanzeigen_bot/utils/web_scraping_mixin.py: ################################################# create_browser_session: "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" @@ -224,10 +204,7 @@ kleinanzeigen_bot/utils/web_scraping_mixin.py: " -> Browser user data dir: %s": " -> Browser-Benutzerdatenverzeichnis: %s" " -> Custom Browser argument: %s": " -> Benutzerdefiniertes Browser-Argument: %s" " -> Setting chrome prefs [%s]...": " -> Setze Chrome-Einstellungen [%s]..." - " -> Opening [%s]...": " -> Öffne [%s]..." " -> Adding Browser extension: [%s]": " -> Füge Browser-Erweiterung hinzu: [%s]" - " -> 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" @@ -249,4 +226,4 @@ kleinanzeigen_bot/utils/web_scraping_mixin.py: " -> Opening [%s]...": " -> Öffne [%s]..." web_request: - " -> HTTP %s [%s]...": " -> HTTP %s [%s]..." + " -> HTTP %s [%s]...": " -> HTTP %s [%s]..." \ No newline at end of file diff --git a/tests/unit/test_translations.py b/tests/unit/test_translations.py index 956fe5b..e1e6b39 100644 --- a/tests/unit/test_translations.py +++ b/tests/unit/test_translations.py @@ -16,12 +16,12 @@ The tests work by: 4. Verifying no unused translations exist """ import ast, os +from collections import defaultdict from dataclasses import dataclass from importlib.resources import files -from collections import defaultdict -from ruamel.yaml import YAML import pytest +from ruamel.yaml import YAML from kleinanzeigen_bot import resources @@ -30,6 +30,9 @@ EXCLUDED_MESSAGES: dict[str, set[str]] = { "kleinanzeigen_bot/__init__.py": {"############################################"} } +# Special modules that are known to be needed even if not in messages_by_file +KNOWN_NEEDED_MODULES = {'getopt.py'} + # Type aliases for better readability ModulePath = str FunctionName = str @@ -273,6 +276,46 @@ def _find_translation(translations: TranslationDict, return has_translation +def _message_exists_in_code(code_messages: dict[str, MessageDict], + module: str, + function: str, + message: str) -> bool: + """ + Check if a message exists in the code at the given location. + This is the reverse of _find_translation - it checks if a translation's message + exists in the code messages. + + Args: + code_messages: Dictionary of all code messages + module: Module path + function: Function name + message: Message to find in code + + Returns: + True if message exists in the code, False otherwise + """ + # Special case for getopt.py + if module == 'getopt.py': + return bool(code_messages.get(module, {}).get(function, {}).get(message)) + + # Remove kleinanzeigen_bot/ prefix if present for code message lookup + module_path = module[len('kleinanzeigen_bot/'):] if module.startswith('kleinanzeigen_bot/') else module + module_path = f'kleinanzeigen_bot/{module_path}' + + # Check if module exists in code messages + module_msgs = code_messages.get(module_path) + if not module_msgs: + return False + + # Check if function exists in module messages + function_msgs = module_msgs.get(function) + if not function_msgs: + return False + + # Check if message exists in any of the function's message sets + return any(message in msg_dict for msg_dict in function_msgs.values()) + + @pytest.mark.parametrize("lang", _get_available_languages()) def test_all_log_messages_have_translations(lang: str) -> None: """ @@ -325,54 +368,51 @@ 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. + The translations file has the structure: + module: + function: + "original message": "translated message" """ 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 + if not isinstance(module_trans, dict): + continue + + # Skip known needed modules + if module in KNOWN_NEEDED_MODULES: 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 original_message in function_trans.keys(): + # Check if this message exists in the code + message_exists = _message_exists_in_code(messages_by_file, module, function, original_message) - 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 not message_exists: + obsolete_items.append((module, function, original_message)) + # Fail the test if obsolete translations are found 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)) + obsolete_str = f"\nObsolete translations found for language [{lang}]:\n" + + # Group by module and function for better readability + by_module: defaultdict[str, defaultdict[str, list[str]]] = defaultdict(lambda: defaultdict(list)) + 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) + by_module[module][function].append(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" + obsolete_str += f" {function}:\n" for message in sorted(messages): - obsolete_str += f' "{message}"\n' + obsolete_str += f' "{message}": "{translations[module][function][message]}"\n' + raise AssertionError(obsolete_str)