diff --git a/script/todo/todo.py b/script/todo/todo.py index c5e08d3..9b9769f 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -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 = [ diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 88a3f96..f57a3f1 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -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)", diff --git a/test/test_todo_menu.py b/test/test_todo_menu.py index b31912c..3adb286 100644 --- a/test/test_todo_menu.py +++ b/test/test_todo_menu.py @@ -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", } diff --git a/test/test_todo_starship.py b/test/test_todo_starship.py new file mode 100644 index 0000000..fecbda6 --- /dev/null +++ b/test/test_todo_starship.py @@ -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()