feat: extend translations and add translation unit test (#427)

This commit is contained in:
Jens Bergmann
2025-02-12 22:25:05 +01:00
committed by GitHub
parent c61c14709f
commit e43ac4f1f9
2 changed files with 497 additions and 75 deletions

View File

@@ -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]..."

View 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.")