diff --git a/script/todo/assistant_menu.py b/script/todo/assistant_menu.py index e607a77..0852065 100644 --- a/script/todo/assistant_menu.py +++ b/script/todo/assistant_menu.py @@ -349,7 +349,8 @@ class AssistantMenuMixin: if not choisis: return frappe = click.prompt( - t("Type the server name in full to delete it:") + t("Type the server name in full to delete it:"), + prompt_suffix=" ", ).strip() except (KeyboardInterrupt, click.exceptions.Abort): print() @@ -997,7 +998,8 @@ class AssistantMenuMixin: if choix == "2": try: frappe = click.prompt( - t("Type the pid of the holder to write into it:") + t("Type the pid of the holder to write into it:"), + prompt_suffix=" ", ).strip() except (KeyboardInterrupt, click.exceptions.Abort): print() diff --git a/script/todo/todo.py b/script/todo/todo.py index 8d0b131..b32d254 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -2292,22 +2292,31 @@ class TODO( def _get_ssh_params(self): """Prompt for SSH connection parameters. Returns dict or None on cancel.""" host = click.prompt( - t("Remote host (user@hostname or hostname): ") + t("Remote host (user@hostname or hostname): "), prompt_suffix="" ).strip() if not host: print(t("SSH host is required!")) return None user = ( - click.prompt(t("SSH user (default: erplibre): ")).strip() + click.prompt( + t("SSH user (default: erplibre): "), prompt_suffix="" + ).strip() or "erplibre" ) - port = click.prompt(t("SSH port (default: 22): ")).strip() or "22" + port = ( + click.prompt( + t("SSH port (default: 22): "), prompt_suffix="" + ).strip() + or "22" + ) key = click.prompt( - t("SSH key path (default: ~/.ssh/id_rsa, empty for none): ") + t("SSH key path (default: ~/.ssh/id_rsa, empty for none): "), + prompt_suffix="", ).strip() path = ( click.prompt( - t("Remote path (default: ~/erplibre_deploy_2): ") + t("Remote path (default: ~/erplibre_deploy_2): "), + prompt_suffix="", ).strip() or "~/erplibre_deploy_2" ) @@ -2415,7 +2424,9 @@ class TODO( params = self._get_ssh_params() if not params: return - target = click.prompt(t("Make target to run remotely: ")).strip() + target = click.prompt( + t("Make target to run remotely: "), prompt_suffix="" + ).strip() if not target: print(t("SSH host is required!")) return @@ -2441,11 +2452,15 @@ class TODO( params = self._get_ssh_params() if not params: return - domain = click.prompt(t("Domain name (e.g.: example.com): ")).strip() + domain = click.prompt( + t("Domain name (e.g.: example.com): "), prompt_suffix="" + ).strip() if not domain: print(t("SSH host is required!")) return - email = click.prompt(t("Admin email for SSL certificate: ")).strip() + email = click.prompt( + t("Admin email for SSL certificate: "), prompt_suffix="" + ).strip() cmd = self._build_ssh_make_cmd( "ssh_install_nginx", params, @@ -4097,7 +4112,7 @@ class TODO( print(f"[1] {t('A database')}") print(f"[2] {t('A backup .zip, without restoring it')}") print(f"[0] {t('Back')}") - answer = click.prompt(t("Command:")) + answer = click.prompt(t("Command:"), prompt_suffix=" ") print() if answer == "1": database = self._analyse_select_database() @@ -4469,7 +4484,7 @@ class TODO( print(f"[2] {t('Whitelist: only the models I name')}") print(f"[3] {t('Blacklist: every model except those I name')}") print(f"[0] {t('Back')}") - answer = click.prompt(t("Command:")) + answer = click.prompt(t("Command:"), prompt_suffix=" ") print() mode = {"1": "hybrid", "2": "whitelist", "3": "blacklist"}.get(answer) if not mode: @@ -4512,7 +4527,7 @@ class TODO( print() print(f"[1] {t('A development copy (restored, neutralised)')}") print(f"[2] {t('An instance in service')}") - answer = click.prompt(t("Command:")) + answer = click.prompt(t("Command:"), prompt_suffix=" ") print() return ( check_instance_state.LIVE @@ -4539,7 +4554,7 @@ class TODO( print(f"[3] {t('A remote backup (https + master password)')}") print(f"[4] {t('A live remote instance')}") print(f"[0] {t('Back')}") - answer = click.prompt(t("Command:")) + answer = click.prompt(t("Command:"), prompt_suffix=" ") print() if answer == "1": database = self.db_manager.select_database() @@ -4583,7 +4598,7 @@ class TODO( print() print(f"[1] {t('An API key')}") print(f"[2] {t('A password')}") - genre = click.prompt(t("Command:")) + genre = click.prompt(t("Command:"), prompt_suffix=" ") secret = getpass.getpass( t("API key: ") if genre == "1" else t("Password: ") ) diff --git a/test/test_prompt_suffix.py b/test/test_prompt_suffix.py new file mode 100644 index 0000000..d1ebf41 --- /dev/null +++ b/test/test_prompt_suffix.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Le deux-points d'une invite est écrit une fois, pas deux. + +`click.prompt` ajoute son propre suffixe — « : » suivi d'un espace — à +l'étiquette qu'on lui donne. Une étiquette qui porte déjà son deux-points +sortait donc en « Commande :: », et ce menu-là est le plus vu du logiciel. + +Le contrôle lit l'ARBRE de tout `script/**/*.py` et RÉSOUT l'étiquette dans +les deux langues, parce que la ponctuation n'est pas la même : le français +met une espace avant le deux-points, l'anglais non, et une seule des deux +traductions peut porter la marque. Chercher dans le texte du code ne verrait +que la clé anglaise. + +Deux suffixes explicites sont acceptés, et le choix se déduit de l'étiquette +elle-même : `" "` quand elle finit par un deux-points nu, `""` quand elle +porte déjà l'espace qui suit. Toute autre valeur est une décision à écrire +ici avec sa raison. + +La couverture est PARTIELLE et le reste : une étiquette calculée — variable, +f-string, concaténation — n'est pas lisible dans l'arbre, et le contrôle +l'ignore plutôt que de l'approximer. Il voit les étiquettes littérales, qui +sont celles où le deux-points s'écrit à la main. +""" + +import ast +import pathlib +import unittest + +from script.todo.todo_i18n import TRANSLATIONS + +RACINE = pathlib.Path(__file__).resolve().parents[1] + + +def _etiquette(noeud): + """La clé de l'étiquette d'un `click.prompt`, ou None. + + Reconnaît `t("…")` et la chaîne nue. Une étiquette calculée — variable, + f-string, concaténation — rend None : ce contrôle ne devine pas ce qu'il + ne peut pas lire, et le dire est plus honnête que de l'approximer.""" + if not noeud.args: + return None + premier = noeud.args[0] + if ( + isinstance(premier, ast.Call) + and getattr(premier.func, "id", "") == "t" + and premier.args + and isinstance(premier.args[0], ast.Constant) + and isinstance(premier.args[0].value, str) + ): + return premier.args[0].value + if isinstance(premier, ast.Constant) and isinstance(premier.value, str): + return premier.value + return None + + +def _invites(): + """[(fichier:ligne, clé, suffixe explicite ou None)] de tout le paquet.""" + trouves = [] + for chemin in sorted(RACINE.glob("script/**/*.py")): + try: + arbre = ast.parse(chemin.read_text(encoding="utf-8")) + except SyntaxError: + continue + for noeud in ast.walk(arbre): + if not isinstance(noeud, ast.Call): + continue + if getattr(noeud.func, "attr", "") != "prompt": + continue + cle = _etiquette(noeud) + if cle is None: + continue + suffixe = None + for mot in noeud.keywords: + if mot.arg == "prompt_suffix" and isinstance( + mot.value, ast.Constant + ): + suffixe = mot.value.value + ou = f"{chemin.relative_to(RACINE)}:{noeud.lineno}" + trouves.append((ou, cle, suffixe)) + return trouves + + +def _finit_par_deux_points(cle): + """Vrai si l'étiquette finit par un deux-points dans UNE des langues.""" + for langue in ("fr", "en"): + rendu = TRANSLATIONS.get(cle, {}).get(langue, cle) + if rendu.rstrip().endswith(":"): + return True + return False + + +class TestLesInvitesFurentTrouvees(unittest.TestCase): + """Sans ceci, une recherche cassée rendrait tous les tests verts.""" + + def test_the_search_finds_prompts(self): + self.assertGreater(len(_invites()), 20) + + def test_the_main_menu_prompt_is_among_them(self): + cles = {cle for _, cle, _ in _invites()} + self.assertIn("Command:", cles) + + +class TestAucunDeuxPointsDouble(unittest.TestCase): + def test_every_colon_label_passes_its_suffix(self): + fautives = [ + f"{ou} — {cle!r}" + for ou, cle, suffixe in _invites() + if _finit_par_deux_points(cle) and suffixe is None + ] + self.assertEqual( + fautives, + [], + "click ajoute « : » : ces invites en afficheraient deux", + ) + + def test_the_suffix_matches_the_label(self): + """L'espace est fourni une fois : par l'étiquette ou par le suffixe.""" + mauvais = [] + for ou, cle, suffixe in _invites(): + if suffixe is None: + continue + francais = TRANSLATIONS.get(cle, {}).get("fr", cle) + attendu = "" if francais.endswith(": ") else " " + if suffixe != attendu: + mauvais.append(f"{ou} — {suffixe!r} au lieu de {attendu!r}") + self.assertEqual(mauvais, []) + + def test_a_label_without_a_colon_leaves_click_alone(self): + """Le suffixe par défaut est ce qui ponctue les autres invites.""" + inutiles = [ + f"{ou} — {cle!r}" + for ou, cle, suffixe in _invites() + if suffixe is not None and not _finit_par_deux_points(cle) + ] + self.assertEqual(inutiles, []) + + +if __name__ == "__main__": + unittest.main()