[ADD] script todo : Starship depuis le menu Git, renommé Git et Shell

Poser le binaire ne change pas le prompt : c'est la ligne d'initialisation
dans le fichier du shell qui le fait, et les deux étapes échouent séparément.
Le paquet vient de la distribution quand elle le connaît, de l'installateur
amont sinon — starship n'est pas empaqueté partout. Le fichier du shell n'est
demandé que devant plusieurs candidats, et la ligne ne s'écrit qu'une fois.

L'entrée porte sa destination dans « method » : son rang suit le nombre
d'entrées de todo.json, qu'un numéro codé en dur ignorerait. Vérifié : 18
tests.

--- EN ---

Laying down the binary does not change the prompt: the init line in the shell
file does, and the two steps fail separately. The package comes from the
distribution when it knows it, from the upstream installer otherwise —
starship is not packaged everywhere. The shell file is only asked for when
several candidates exist, and the line is written only once.

The entry carries its destination in « method »: its rank follows the number
of todo.json entries, which a hard-coded number would ignore. Checked: 18
tests.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-09-02 04:43:25 -04:00
parent d31ca9eab9
commit b321052888
4 changed files with 465 additions and 14 deletions

View file

@ -26,7 +26,7 @@ sys.path.append(new_path)
from script.config import config_file
from script.execute import execute
from script.todo import todo_prefs
from script.todo import todo_install, todo_prefs
from script.todo.database_manager import DatabaseManager
from script.todo.longtest_menu import LongTestMenuMixin
from script.todo.proxmox_menu import ProxmoxMenuMixin
@ -256,7 +256,7 @@ class TODO(
[7] {t("Analyse - Odoo database analysis")}
── {t("Sources & documentation")} ──
[8] {t("Git - Git tools")}
[8] {t("Git - Git and shell tools")}
[9] {t("Doc - Documentation search")}
── {t("AI & automation")} ──
@ -2530,7 +2530,7 @@ class TODO(
_GIT_HOOKS_PATH = os.path.join("script", "git", "hooks")
def prompt_execute_git(self):
print(f"🤖 {t('Git management tools!')}")
print(f"🤖 {t('Git and shell management tools!')}")
choices = [
{"prompt_description": t("Local git server")},
{"prompt_description": t("Add a remote to a local repository")},
@ -2551,6 +2551,17 @@ class TODO(
if config_entries:
choices.extend(config_entries)
# Starship ferme la liste : c'est un outil de shell, pas de git. Son
# rang dépend du nombre d'entrées venues de todo.json, donc « method »
# porte la destination dans l'entrée elle-même — un numéro codé en dur
# mènerait ailleurs dès qu'une entrée de configuration s'ajoute.
choices.append(
{
"prompt_description": t("Install Starship on Shell"),
"method": "_shell_install_starship",
}
)
help_info = self.fill_help_info(choices)
while True:
@ -2573,7 +2584,11 @@ class TODO(
if 0 < int_cmd <= len(choices):
cmd_no_found = False
instance = choices[int_cmd - 1]
self.execute_from_configuration(instance)
method = instance.get("method")
if method:
getattr(self, method)()
else:
self.execute_from_configuration(instance)
except ValueError:
pass
if cmd_no_found:
@ -2675,6 +2690,137 @@ class TODO(
)
print(f"✅ merge.conflictStyle = {value}")
# Le shell -> (fichier de configuration, ligne d'initialisation). La ligne
# va en FIN de fichier : starship compose le prompt et doit passer après
# tout ce qui y touche.
_STARSHIP_SHELLS = {
"bash": ("~/.bashrc", 'eval "$(starship init bash)"'),
"zsh": ("~/.zshrc", 'eval "$(starship init zsh)"'),
"fish": ("~/.config/fish/config.fish", "starship init fish | source"),
}
# L'installateur amont pose un binaire statique. Il sert de recours parce
# que le paquet manque d'une partie des dépôts des plateformes supportées.
_STARSHIP_UPSTREAM = "curl -sS https://starship.rs/install/install.sh | sh"
@staticmethod
def _shell_name():
"""Le nom du shell de l'utilisateur d'après $SHELL, '' s'il est vide."""
return os.path.basename(os.environ.get("SHELL", "")).strip()
def _shell_install_starship(self):
"""Poser starship, puis l'accrocher au shell de l'utilisateur.
Deux étapes qui échouent séparément : le binaire, que le gestionnaire
de paquets de la distribution fournit quand il le connaît, et la ligne
d'initialisation dans le fichier de configuration du shell. Sans la
seconde, starship est installé et le prompt ne change pas.
"""
if shutil.which("starship") is None:
self._shell_install_starship_binary()
if shutil.which("starship") is None:
print(
f"❌ {t('starship is not installed, shell left untouched.')}"
)
return
self._shell_hook_starship()
def _shell_install_starship_binary(self):
"""Poser le binaire : le paquet de la distribution, sinon l'amont.
Un refus de l'opérateur arrête là. Un paquet inconnu ou une
installation en échec passent au recours amont, qui couvre les dépôts
où starship n'est pas empaqueté.
"""
cmd = todo_install.install_command(["starship"])
if cmd:
status = todo_install.ask_and_install(
self.execute,
cmd,
t("Install starship? (y/N): "),
self._is_yes,
)
if status is None:
return
if status == 0 and shutil.which("starship"):
return
print(f" {t('No starship package here, falling back upstream.')}")
todo_install.ask_and_install(
self.execute,
self._STARSHIP_UPSTREAM,
t("Run the upstream installer? (y/N): "),
self._is_yes,
)
def _shell_starship_present(self):
"""Les shells dont le fichier de configuration existe déjà."""
return [
nom
for nom, (fichier, _ligne) in self._STARSHIP_SHELLS.items()
if os.path.exists(os.path.expanduser(fichier))
]
def _shell_starship_target(self):
"""Le shell à accrocher. Ne demande que devant un vrai choix.
Aucun fichier de configuration présent : bash, sans question — la pose
le créera. Un seul présent : celui-là, il n'y a rien à choisir. Deux
ou trois : à l'opérateur de trancher, le sien proposé par défaut.
"""
presents = self._shell_starship_present()
if not presents:
return "bash"
if len(presents) == 1:
return presents[0]
courant = self._shell_name()
defaut = courant if courant in presents else presents[0]
print(f"\n{t('Which shell configuration?')}")
for i, nom in enumerate(presents, 1):
print(f" [{i}] {nom:<5} {self._STARSHIP_SHELLS[nom][0]}")
sel = input(
f"{t('Choice (number or name, default:')} {defaut}) : "
).strip()
if not sel:
return defaut
if sel in presents:
return sel
try:
idx = int(sel) - 1
if 0 <= idx < len(presents):
return presents[idx]
except ValueError:
pass
return defaut
def _shell_hook_starship(self):
"""Ajouter la ligne d'initialisation au fichier du shell choisi.
La ligne n'est écrite qu'une fois : « starship init » cherché dans le
fichier couvre les trois shells, dont les lignes diffèrent. L'écriture
ne demande pas de confirmation — le choix du fichier, quand il y en a
un à faire, l'a déjà donnée.
"""
shell = self._shell_starship_target()
chemin, ligne = self._STARSHIP_SHELLS[shell]
chemin = os.path.expanduser(chemin)
contenu = ""
if os.path.exists(chemin):
with open(chemin, encoding="utf-8") as fh:
contenu = fh.read()
if "starship init" in contenu:
print(f"✅ {t('starship is already hooked into: ')}{chemin}")
return
os.makedirs(os.path.dirname(chemin), exist_ok=True)
with open(chemin, "a", encoding="utf-8") as fh:
# Un fichier qui ne finit pas par un saut de ligne collerait la
# ligne d'init à la dernière commande.
if contenu and not contenu.endswith("\n"):
fh.write("\n")
fh.write(f"{ligne}\n")
print(f"✅ {t('starship hooked into: ')}{chemin}")
print(f" {ligne}")
print(f" {t('Open a new shell to see it.')}")
def prompt_execute_git_local_server(self):
print(f"🤖 {t('Manage local git repository server!')}")
choices = [

View file

@ -821,13 +821,13 @@ TRANSLATIONS = {
"en": "Module name is required!",
},
# Git section
"Git - Git tools": {
"fr": "🌿 Git - Outils Git",
"en": "🌿 Git - Git tools",
"Git - Git and shell tools": {
"fr": "🌿 Git - Outils Git et Shell",
"en": "🌿 Git - Git and shell tools",
},
"Git management tools!": {
"fr": "Outils de gestion Git!",
"en": "Git management tools!",
"Git and shell management tools!": {
"fr": "Outils de gestion Git et Shell!",
"en": "Git and shell management tools!",
},
"Local git server": {
"fr": "Serveur git local",
@ -903,6 +903,43 @@ TRANSLATIONS = {
"en": "Error adding remote: ",
},
# Git hooks
# Starship
"Install Starship on Shell": {
"fr": "🚀 Installer Starship sur le shell",
"en": "🚀 Install Starship on Shell",
},
"Install starship? (y/N): ": {
"fr": "Installer starship ? (o/N) : ",
"en": "Install starship? (y/N): ",
},
"No starship package here, falling back upstream.": {
"fr": "Aucun paquet starship ici, recours à l'installateur amont.",
"en": "No starship package here, falling back upstream.",
},
"Run the upstream installer? (y/N): ": {
"fr": "Lancer l'installateur amont ? (o/N) : ",
"en": "Run the upstream installer? (y/N): ",
},
"starship is not installed, shell left untouched.": {
"fr": "starship n'est pas installé, le shell reste intact.",
"en": "starship is not installed, shell left untouched.",
},
"Which shell configuration?": {
"fr": "Quel fichier de configuration de shell ?",
"en": "Which shell configuration?",
},
"starship is already hooked into: ": {
"fr": "starship est déjà accroché à : ",
"en": "starship is already hooked into: ",
},
"starship hooked into: ": {
"fr": "starship accroché à : ",
"en": "starship hooked into: ",
},
"Open a new shell to see it.": {
"fr": "Ouvrir un nouveau shell pour le voir.",
"en": "Open a new shell to see it.",
},
"Set merge.conflictStyle to zdiff3 (global)": {
"fr": "Configurer merge.conflictStyle en zdiff3 (global)",
"en": "Set merge.conflictStyle to zdiff3 (global)",

View file

@ -148,6 +148,10 @@ class MenuCoherence:
todo.py), ce socle sert DEUX menus : QEMU/KVM et Proxmox. Un troisième
n'aura qu'à déclarer ses quatre attributs.
Une entrée peut aussi porter sa destination dans « method » plutôt que
dans un « elif status » numéroté. Elle échappe alors à la renumérotation
par construction, et EXPECTED la vérifie contre cette clé.
À déclarer par la sous-classe : SOURCE (le fichier), ENTRY (la ligne
« def prompt_execute_… »), END (le membre suivant, qui borne la lecture) et
EXPECTED (où mène chaque entrée, par le début de son libellé).
@ -170,18 +174,33 @@ class MenuCoherence:
r'(?:el)?if status == "(\d+)":\s*\n(?:\s*#.*\n)*'
r"\s*(?:status = )?self\.(\w+)\("
)
# Une entrée qui porte sa destination dans « method » se dispatche seule,
# par le repli générique. Elle n'a pas de numéro dans le code, donc aucune
# renumérotation ne peut la désaligner : c'est le seul moyen de placer une
# entrée codée en dur APRÈS des entrées venues de la configuration, dont
# le nombre n'est pas connu à la lecture du source.
RE_SELF_DISPATCH = re.compile(
r'"prompt_description": t\(\s*\n?\s*"([^"]+)"\s*\)?,?\s*\n'
r'\s*"method": "(\w+)"'
)
def setUp(self):
source = self.SOURCE.read_text(encoding="utf-8")
start = source.index(self.ENTRY)
end = source.index(self.END, start)
self.body = source[start:end]
self.self_dispatch = dict(self.RE_SELF_DISPATCH.findall(self.body))
num = 0
self.shown = []
for kind, label in self.RE_ENTRY.findall(self.body):
if kind == "prompt_description":
num += 1
self.shown.append((num, label))
self.numbered = [
(n, label)
for n, label in self.shown
if label not in self.self_dispatch
]
self.dispatch = [
(int(n), m) for n, m in self.RE_DISPATCH_CALL.findall(self.body)
]
@ -189,7 +208,7 @@ class MenuCoherence:
def test_the_menu_was_actually_parsed(self):
"""Sur une liste vide, tout test passe : mieux vaut tomber ici."""
self.assertGreater(len(self.shown), self.MINIMUM)
self.assertEqual(len(self.shown), len(self.dispatch))
self.assertEqual(len(self.numbered), len(self.dispatch))
def test_numbering_is_contiguous_from_one(self):
self.assertEqual(
@ -199,7 +218,7 @@ class MenuCoherence:
def test_every_shown_entry_has_the_matching_dispatch(self):
self.assertEqual(
[n for n, _ in self.shown], [n for n, _ in self.dispatch]
[n for n, _ in self.numbered], [n for n, _ in self.dispatch]
)
def _key(self, label):
@ -218,10 +237,11 @@ class MenuCoherence:
f"entrée [{num}] « {label} » absente d'EXPECTED :"
" déclarez où elle mène",
)
atteint = self.self_dispatch.get(label, dct.get(num))
self.assertEqual(
dct.get(num),
atteint,
self.EXPECTED[key],
f"[{num}] « {label} » mène à {dct.get(num)}"
f"[{num}] « {label} » mène à {atteint}"
f" au lieu de {self.EXPECTED[key]}",
)
@ -229,6 +249,16 @@ class MenuCoherence:
keys = {self._key(label) for _, label in self.shown}
self.assertEqual(set(self.EXPECTED) - keys, set())
def test_self_dispatched_entries_name_a_real_method(self):
"""« method » est une chaîne : rien ne la relie au code sans ceci."""
from script.todo.todo import TODO
for label, method in self.self_dispatch.items():
self.assertTrue(
hasattr(TODO, method),
f"« {label} » mène à {method}, qui n'existe pas",
)
class TestLaParitéProxmox(unittest.TestCase):
"""Deux manques signalés par l'audit du découpage, comblés.
@ -468,6 +498,7 @@ class TestGitMenuNumbering(MenuCoherence, unittest.TestCase):
"Add a remote to a local repository": "_git_add_remote",
"Install git hooks": "_git_install_hooks",
"Set merge.conflictStyle": "_git_set_conflict_style",
"Install Starship on Shell": "_shell_install_starship",
}

237
test/test_todo_starship.py Normal file
View file

@ -0,0 +1,237 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Installer starship : deux étapes qui échouent séparément.
Poser le binaire ne change pas le prompt ; c'est la ligne d'initialisation
dans le fichier du shell qui le fait. Un menu qui confond les deux annonce un
succès devant un prompt inchangé, ou réécrit la ligne à chaque passage.
Le fichier à modifier ne se demande que devant un vrai choix : plusieurs
fichiers de configuration présents. Aucun, ou un seul, ne laisse rien à
trancher — une question posée là n'attend qu'une frappe pour rien.
"""
import io
import os
import tempfile
import unittest
from contextlib import redirect_stdout
from pathlib import Path
from unittest.mock import MagicMock, patch
from script.todo.todo import TODO
LIGNE_BASH = 'eval "$(starship init bash)"'
LIGNE_ZSH = 'eval "$(starship init zsh)"'
def refuse_input(*args, **kwargs):
raise AssertionError("aucune question ne devait être posée")
class TestShellName(unittest.TestCase):
"""Le shell se lit dans $SHELL, dont seul le nom de base compte."""
def test_basename_of_shell(self):
with patch.dict(os.environ, {"SHELL": "/usr/bin/zsh"}):
self.assertEqual(TODO._shell_name(), "zsh")
def test_empty_when_unset(self):
with patch.dict(os.environ, {"SHELL": ""}):
self.assertEqual(TODO._shell_name(), "")
class ShellFixture(unittest.TestCase):
"""Un faux HOME où l'on pose les fichiers de configuration voulus."""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
racine = Path(self.tmp.name)
self.fichiers = {
"bash": racine / "bashrc",
"zsh": racine / "zshrc",
"fish": racine / "fish" / "config.fish",
}
table = {
"bash": (str(self.fichiers["bash"]), LIGNE_BASH),
"zsh": (str(self.fichiers["zsh"]), LIGNE_ZSH),
"fish": (
str(self.fichiers["fish"]),
"starship init fish | source",
),
}
patcher = patch.dict(TODO._STARSHIP_SHELLS, table, clear=True)
patcher.start()
self.addCleanup(patcher.stop)
self.todo = TODO()
def cree(self, *shells, contenu=""):
for shell in shells:
chemin = self.fichiers[shell]
chemin.parent.mkdir(parents=True, exist_ok=True)
chemin.write_text(contenu, encoding="utf-8")
class TestStarshipTarget(ShellFixture):
"""Quel fichier, et quand la question se pose."""
def choisit(self, shell_env="/bin/bash", reponse=None):
entree = refuse_input if reponse is None else (lambda *a: reponse)
with patch.dict(os.environ, {"SHELL": shell_env}), patch(
"builtins.input", entree
), redirect_stdout(io.StringIO()):
return self.todo._shell_starship_target()
def test_no_file_means_bash_without_asking(self):
self.assertEqual(self.choisit(), "bash")
def test_a_single_file_is_taken_without_asking(self):
self.cree("zsh")
self.assertEqual(self.choisit(), "zsh")
def test_a_single_file_wins_over_the_current_shell(self):
"""Le fichier présent l'emporte : $SHELL n'en a pas, lui."""
self.cree("fish")
self.assertEqual(self.choisit(shell_env="/bin/bash"), "fish")
def test_several_files_ask_and_default_to_the_current_shell(self):
self.cree("bash", "zsh")
self.assertEqual(
self.choisit(shell_env="/usr/bin/zsh", reponse=""), "zsh"
)
def test_several_files_answered_by_number(self):
self.cree("bash", "zsh")
self.assertEqual(self.choisit(reponse="2"), "zsh")
def test_several_files_answered_by_name(self):
self.cree("bash", "zsh")
self.assertEqual(self.choisit(reponse="zsh"), "zsh")
def test_a_nonsense_answer_falls_back_on_the_default(self):
self.cree("bash", "zsh")
self.assertEqual(self.choisit(reponse="42"), "bash")
class TestHookStarship(ShellFixture):
"""L'écriture de la ligne, qui ne demande rien."""
def hook(self, shell_env="/bin/bash"):
out = io.StringIO()
with patch.dict(os.environ, {"SHELL": shell_env}), patch(
"builtins.input", refuse_input
), redirect_stdout(out):
self.todo._shell_hook_starship()
return out.getvalue()
def test_it_appends_the_line(self):
self.cree("bash", contenu="export EDITOR=vim\n")
self.hook()
self.assertEqual(
self.fichiers["bash"].read_text(encoding="utf-8"),
f"export EDITOR=vim\n{LIGNE_BASH}\n",
)
def test_a_file_without_a_final_newline_keeps_its_last_command(self):
self.cree("bash", contenu="export EDITOR=vim")
self.hook()
self.assertEqual(
self.fichiers["bash"].read_text(encoding="utf-8"),
f"export EDITOR=vim\n{LIGNE_BASH}\n",
)
def test_it_writes_only_once(self):
self.cree("bash", contenu=f"{LIGNE_BASH}\n")
sortie = self.hook()
self.assertEqual(
self.fichiers["bash"].read_text(encoding="utf-8"),
f"{LIGNE_BASH}\n",
)
self.assertIn("✅", sortie)
def test_no_file_at_all_creates_the_bash_one(self):
self.hook()
self.assertEqual(
self.fichiers["bash"].read_text(encoding="utf-8"),
f"{LIGNE_BASH}\n",
)
def test_a_missing_parent_directory_is_created(self):
"""config.fish vit sous ~/.config/fish, que rien ne garantit."""
self.todo._STARSHIP_SHELLS["bash"] = (
str(self.fichiers["fish"]),
LIGNE_BASH,
)
self.hook()
self.assertEqual(
self.fichiers["fish"].read_text(encoding="utf-8"),
f"{LIGNE_BASH}\n",
)
class TestInstallStarship(unittest.TestCase):
"""L'enchaînement des deux étapes, et ce qui l'arrête."""
def test_an_installed_binary_goes_straight_to_the_shell(self):
todo = TODO()
todo.execute = MagicMock()
with patch(
"script.todo.todo.shutil.which", return_value="/usr/bin/starship"
), patch.object(TODO, "_shell_hook_starship") as hook, patch.object(
TODO, "_shell_install_starship_binary"
) as poser:
todo._shell_install_starship()
poser.assert_not_called()
hook.assert_called_once()
def test_a_missing_binary_leaves_the_shell_alone(self):
todo = TODO()
todo.execute = MagicMock()
out = io.StringIO()
with patch(
"script.todo.todo.shutil.which", return_value=None
), patch.object(TODO, "_shell_hook_starship") as hook, patch.object(
TODO, "_shell_install_starship_binary"
), redirect_stdout(
out
):
todo._shell_install_starship()
hook.assert_not_called()
self.assertIn("❌", out.getvalue())
def test_a_refused_package_does_not_chain_to_upstream(self):
"""Un refus est une décision : il n'appelle pas une seconde offre."""
todo = TODO()
todo.execute = MagicMock()
appels = []
with patch(
"script.todo.todo.todo_install.install_command",
return_value=["sudo", "pacman", "-S", "starship"],
), patch(
"script.todo.todo.todo_install.ask_and_install",
side_effect=lambda *a, **k: appels.append(a[1]) or None,
):
todo._shell_install_starship_binary()
self.assertEqual(len(appels), 1)
def test_no_package_falls_back_upstream(self):
todo = TODO()
todo.execute = MagicMock()
appels = []
with patch(
"script.todo.todo.todo_install.install_command", return_value=None
), patch(
"script.todo.todo.todo_install.ask_and_install",
side_effect=lambda *a, **k: appels.append(a[1]) or 0,
), redirect_stdout(
io.StringIO()
):
todo._shell_install_starship_binary()
self.assertEqual(appels, [TODO._STARSHIP_UPSTREAM])
if __name__ == "__main__":
unittest.main()