[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:
parent
ea6391a781
commit
19410e5b73
3 changed files with 173 additions and 15 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
141
test/test_prompt_suffix.py
Normal 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()
|
||||
Loading…
Reference in a new issue