mirror of
https://github.com/Second-Hand-Friends/kleinanzeigen-bot.git
synced 2026-03-12 02:31:45 +01:00
feat: extend translations and add translation unit test (#427)
This commit is contained in:
@@ -16,22 +16,8 @@ getopt.py:
|
|||||||
#################################################
|
#################################################
|
||||||
kleinanzeigen_bot/__init__.py:
|
kleinanzeigen_bot/__init__.py:
|
||||||
#################################################
|
#################################################
|
||||||
run:
|
module:
|
||||||
"DONE: No configuration errors found.": "FERTIG: Keine Konfigurationsfehler gefunden."
|
"Direct execution not supported. Use 'pdm run app'": "Direkte Ausführung nicht unterstützt. Bitte 'pdm run app' verwenden"
|
||||||
'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"
|
|
||||||
|
|
||||||
configure_file_logging:
|
configure_file_logging:
|
||||||
"Logging to [%s]...": "Protokollierung in [%s]..."
|
"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):"
|
"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: 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 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.":
|
" -> SKIPPED: ad [%s] is not new. already has an id assigned.": " -> ÜBERSPRUNGEN: Anzeige [%s] ist nicht neu. Eine ID wurde bereits zugewiesen."
|
||||||
" -> Ü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]."
|
||||||
" -> 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."
|
|
||||||
"Loaded %s": "%s geladen"
|
"Loaded %s": "%s geladen"
|
||||||
"ad": "Anzeige"
|
"ad": "Anzeige"
|
||||||
|
|
||||||
@@ -56,16 +40,17 @@ kleinanzeigen_bot/__init__.py:
|
|||||||
" -> found %s": "-> %s gefunden"
|
" -> found %s": "-> %s gefunden"
|
||||||
"category": "Kategorie"
|
"category": "Kategorie"
|
||||||
"config": "Konfiguration"
|
"config": "Konfiguration"
|
||||||
|
"Config file %s does not exist. Creating it with default values...": "Konfigurationsdatei %s existiert nicht. Erstelle sie mit Standardwerten..."
|
||||||
|
|
||||||
login:
|
login:
|
||||||
"Checking if already logged in...": "Überprüfe, ob bereits eingeloggt..."
|
"Checking if already logged in...": "Überprüfe, ob bereits eingeloggt..."
|
||||||
"Already logged in as [%s]. Skipping login.": "Bereits eingeloggt als [%s]. Überspringe Anmeldung."
|
"Already logged in as [%s]. Skipping login.": "Bereits eingeloggt als [%s]. Überspringe Anmeldung."
|
||||||
"Opening login page...": "Öffne Anmeldeseite..."
|
"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:
|
handle_after_login_logic:
|
||||||
"# Device verification message detected. Please follow the instruction displayed in the Browser.":
|
"# Device verification message detected. Please follow the instruction displayed in the Browser.": "# Nachricht zur Geräteverifizierung erkannt. Bitte den Anweisungen im Browser folgen."
|
||||||
"# Nachricht zur Geräteverifizierung erkannt. Bitte den Anweisungen im Browser folgen."
|
|
||||||
"Press ENTER when done...": "EINGABETASTE drücken, wenn erledigt..."
|
"Press ENTER when done...": "EINGABETASTE drücken, wenn erledigt..."
|
||||||
"Handling GDPR disclaimer...": "Verarbeite DSGVO-Hinweis..."
|
"Handling GDPR disclaimer...": "Verarbeite DSGVO-Hinweis..."
|
||||||
|
|
||||||
@@ -77,6 +62,7 @@ kleinanzeigen_bot/__init__.py:
|
|||||||
delete_ad:
|
delete_ad:
|
||||||
"Deleting ad '%s' if already present...": "Lösche Anzeige '%s', falls bereits vorhanden..."
|
"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!"
|
"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:
|
publish_ads:
|
||||||
"Processing %s/%s: '%s' from [%s]...": "Verarbeite %s/%s: '%s' von [%s]..."
|
"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."
|
"# 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..."
|
"Press a key to continue...": "Eine Taste drücken, um fortzufahren..."
|
||||||
" -> SUCCESS: ad published with ID %s": " -> ERFOLG: Anzeige mit ID %s veröffentlicht"
|
" -> 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:
|
__set_condition:
|
||||||
"Unable to close condition dialog!": "Kann den Dialog für Artikelzustand nicht schließen!"
|
"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:
|
__upload_images:
|
||||||
" -> found %s": "-> %s gefunden"
|
" -> found %s": "-> %s gefunden"
|
||||||
"image": "Bild"
|
"image": "Bild"
|
||||||
" -> uploading image [%s]": " -> Lade Bild [%s] hoch"
|
" -> uploading image [%s]": " -> Lade Bild [%s] hoch"
|
||||||
|
|
||||||
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.'
|
|
||||||
"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!'
|
|
||||||
|
|
||||||
__check_ad_republication:
|
__check_ad_republication:
|
||||||
"Hash comparison for [%s]:": "Hash-Vergleich für [%s]:"
|
"Hash comparison for [%s]:": "Hash-Vergleich für [%s]:"
|
||||||
" Stored hash: %s": " Gespeicherter Hash: %s"
|
" Stored hash: %s": " Gespeicherter Hash: %s"
|
||||||
" Current hash: %s": " Aktueller Hash: %s"
|
" Current hash: %s": " Aktueller Hash: %s"
|
||||||
"Changes detected in ad [%s], will republish": "Änderungen in Anzeige [%s] erkannt, wird neu veröffentlicht"
|
"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..."
|
||||||
|
"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."
|
||||||
|
"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."
|
||||||
|
"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:
|
_download_images_from_ad_page:
|
||||||
"Found %s.": "%s gefunden."
|
"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."
|
"Downloaded %s.": "%s heruntergeladen."
|
||||||
"No image area found. Continue without downloading images.":
|
"NEXT button in image gallery somehow missing, aborting image fetching.": "WEITER-Button in der Bildergalerie fehlt, breche Bildabruf ab."
|
||||||
"Kein Bildbereich gefunden. Fahre fort ohne Bilder herunterzuladen."
|
"No image area found. Continuing without downloading images.": "Keine Bildbereiche gefunden. Fahre ohne Bilder-Download fort."
|
||||||
|
|
||||||
extract_ad_id_from_ad_url:
|
extract_ad_id_from_ad_url:
|
||||||
"The ad ID could not be extracted from the given URL %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"
|
||||||
"Die Anzeigen-ID konnte nicht aus der angegebenen URL extrahiert werden: %s"
|
|
||||||
|
|
||||||
extract_own_ads_urls:
|
extract_own_ads_urls:
|
||||||
"There are currently no ads on your profile!": "Derzeit gibt es keine Anzeigen auf deinem Profil!"
|
"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!"
|
"A popup appeared!": "Ein Popup ist erschienen!"
|
||||||
|
|
||||||
_extract_ad_page_info:
|
_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:
|
_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:
|
_detect_locale:
|
||||||
"ERROR": "FEHLER"
|
"Error detecting language on Windows": "Fehler bei der Spracherkennung unter Windows"
|
||||||
"WARNING": "WARNUNG"
|
|
||||||
"CRITICAL": "KRITISCH"
|
|
||||||
|
|
||||||
|
|
||||||
|
#################################################
|
||||||
|
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:
|
load_dict_if_exists:
|
||||||
"Loading %s[%s]...": "Lade %s[%s]..."
|
"Loading %s[%s]...": "Lade %s[%s]..."
|
||||||
" from ": " aus "
|
"Loading %s[%s.%s]...": "Lade %s[%s.%s]..."
|
||||||
'Unsupported file type. The file name "%s" must end with *.json, *.yaml, or *.yml':
|
" from ": " von "
|
||||||
'Nicht unterstützter Dateityp. Der Dateiname "%s" muss mit *.json, *.yaml oder *.yml enden.'
|
"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:
|
save_dict:
|
||||||
"Saving [%s]...": "Speichere [%s]..."
|
"Saving [%s]...": "Speichere [%s]..."
|
||||||
|
load_dict_from_module:
|
||||||
on_sigint:
|
"Loading %s[%s.%s]...": "Lade %s[%s.%s]..."
|
||||||
"Aborted on user request.": "Auf Benutzerwunsch abgebrochen."
|
|
||||||
|
|
||||||
|
|
||||||
#################################################
|
#################################################
|
||||||
kleinanzeigen_bot/web_scraping_mixin.py:
|
kleinanzeigen_bot/utils/web_scraping_mixin.py:
|
||||||
#################################################
|
#################################################
|
||||||
create_browser_session:
|
create_browser_session:
|
||||||
"Creating Browser session...": "Erstelle Browsersitzung..."
|
"Creating Browser session...": "Erstelle Browser-Sitzung..."
|
||||||
" -> Browser binary location: %s": " -> Speicherort der Browser-Binärdatei: %s"
|
"Closing Browser session...": "Schließe Browser-Sitzung..."
|
||||||
"Using existing browser process at %s:%s": "Verwende bestehenden Browser-Prozess unter %s:%s"
|
|
||||||
"New Browser session is %s": "Neue Browsersitzung ist %s"
|
|
||||||
" -> Browser profile name: %s": " -> Browser-Profilname: %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]..."
|
|
||||||
" -> 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"
|
"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"
|
||||||
|
" -> 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:
|
web_check:
|
||||||
"Unsupported attribute: %s": "Nicht unterstütztes Attribut: %s"
|
"Unsupported attribute: %s": "Nicht unterstütztes Attribut: %s"
|
||||||
|
|
||||||
web_find:
|
web_find:
|
||||||
"Unsupported selector type: %s": "Nicht unterstützter Selektortyp: %s"
|
"Unsupported selector type: %s": "Nicht unterstützter Selektor-Typ: %s"
|
||||||
|
|
||||||
web_find_all:
|
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..."
|
||||||
|
|
||||||
web_sleep:
|
get_compatible_browser:
|
||||||
" ... pausing for %d ms ...": " ... pausiere für %d ms ..."
|
"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_request:
|
||||||
|
" -> HTTP %s [%s]...": " -> HTTP %s [%s]..."
|
||||||
|
|||||||
383
tests/unit/test_translations.py
Normal file
383
tests/unit/test_translations.py
Normal file
@@ -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.")
|
||||||
Reference in New Issue
Block a user