[FIX] todo : une invite n'écrit plus son deux-points deux fois

`click.prompt` ajoute son propre « : » à l'étiquette qu'on lui donne, et
quinze étiquettes le portaient déjà. Le menu le plus vu du logiciel demandait
donc « Commande :: », et sept invites de déploiement à distance affichaient
un deux-points suivi d'un autre.

Le suffixe est désormais explicite partout où l'étiquette ponctue elle-même,
et sa valeur se déduit de l'étiquette : rien quand elle porte déjà l'espace
qui suit, un espace quand le deux-points est nu. Un contrôle lit l'arbre et
résout les DEUX langues, le français mettant une espace avant le deux-points
là où l'anglais n'en met pas — chercher dans le texte du code ne verrait que
la clé anglaise.

--- EN ---

`click.prompt` adds its own ":" to the label it is given, and fifteen labels
already carried one. The most-seen menu of the software therefore asked
"Command:: ", and seven remote-deployment prompts showed a colon followed by
another.

The suffix is now explicit wherever the label punctuates itself, and its value
follows from the label: nothing when it already carries the trailing space, a
space when the colon is bare. A check reads the tree and resolves BOTH
languages, French putting a space before the colon where English does not —
searching the code text would only see the English key.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-09-09 05:32:52 -04:00
parent ea6391a781
commit 19410e5b73
3 changed files with 173 additions and 15 deletions

View file

@ -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()

View file

@ -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: ")
)

141
test/test_prompt_suffix.py Normal file
View file

@ -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()