From 3dcccf40d933dc982e0ab186aaaea67ce846534c Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 2 Sep 2026 03:53:40 -0400 Subject: [PATCH] =?UTF-8?q?[FIX]=20script=20todo=20:=20rtk=20hors=20du=20P?= =?UTF-8?q?ATH,=20r=C3=A9sultat=20d'installation=20annonc=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un processus garde le PATH qu'il avait au démarrage : rtk installé dans ~/.local/bin pendant que TODO tourne échappe à shutil.which, et « rtk » nu sort en 127. Le menu le cherche donc aussi à l'emplacement de l'installateur et l'appelle par son chemin absolu, sans confondre un binaire hors PATH avec une absence. L'installation annonce son résultat : version et chemin, ou échec. Vérifié : 9 tests neufs, 117 au total. --- EN --- A process keeps the PATH it had at startup: rtk installed into ~/.local/bin while TODO runs escapes shutil.which, and a bare « rtk » exits 127. The menu therefore also looks at the installer's location and calls the binary by its absolute path, without mistaking a binary outside the PATH for a missing one. Installation now reports its outcome: version and path, or failure. Checked: 9 new tests, 117 in total. Assisted-by: Claude Opus 5 --- script/todo/todo.py | 136 ++++++++++++++++++++++++++++----------- script/todo/todo_i18n.py | 19 ++++++ test/test_todo_rtk.py | 132 +++++++++++++++++++++++++++++++++++++ 3 files changed, 249 insertions(+), 38 deletions(-) create mode 100644 test/test_todo_rtk.py diff --git a/script/todo/todo.py b/script/todo/todo.py index 0755c1f..1bcdc7b 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -4227,6 +4227,83 @@ class TODO( else: print(t("Command not found !")) + def rtk_locate(self): + """Localise l'exécutable rtk. + + Rend le couple (chemin, visible_dans_le_PATH). Le chemin vaut None + quand rtk est introuvable. Le second membre est faux quand le binaire + existe à l'emplacement où l'installateur le dépose sans que le PATH y + mène : un processus garde le PATH qu'il avait au démarrage, donc une + installation faite pendant que TODO tourne lui reste invisible tant + qu'il n'est pas relancé. + """ + rtk_path = shutil.which("rtk") + if rtk_path: + return rtk_path, True + # Emplacement par défaut de l'installateur (RTK_INSTALL_DIR le change). + fallback = os.path.expanduser("~/.local/bin/rtk") + if os.access(fallback, os.X_OK): + return fallback, False + return None, False + + def rtk_exec(self, args): + """Lance rtk par son chemin absolu, ou signale qu'il est absent. + + Le chemin absolu évite le code 127 d'un « rtk » nu quand le PATH du + processus ne mène pas à l'emplacement d'installation. + """ + rtk_path, _ = self.rtk_locate() + if rtk_path is None: + print(t("RTK is not installed. Use option 1 to install it.")) + return 1 + return self.execute.exec_command_live( + f"{shlex.quote(rtk_path)} {args}", + source_erplibre=False, + ) + + def rtk_version(self, rtk_path): + """Rend la version qu'annonce le binaire, « ? » s'il ne répond pas.""" + result = self.execute.exec_command_live( + f"{shlex.quote(rtk_path)} --version", + source_erplibre=False, + quiet=True, + return_status_and_output=True, + ) + if isinstance(result, tuple) and result[0] == 0: + return " ".join(result[1]).strip() + return "?" + + def rtk_report_path_warning(self): + """Dit comment rendre rtk appelable quand le PATH ne le porte pas.""" + print(t("rtk is not in the PATH of this process, restart TODO.")) + print(t("To make it permanent, add to your shell profile:")) + print(' export PATH="$HOME/.local/bin:$PATH"') + + def rtk_report_install(self, exit_code): + """Annonce le résultat de l'installation, PATH compris. + + Une installation réussie ne rend pas rtk appelable pour autant : le + binaire atterrit dans un répertoire que le PATH du processus courant + peut ignorer. Distinguer les deux cas évite de conclure à un échec + devant un « commande introuvable » qui ne tient qu'au PATH. + """ + if exit_code: + print(f"❌ {t('RTK installation failed, see the output above.')}") + return + rtk_path, in_path = self.rtk_locate() + if rtk_path is None: + print( + "❌" + f" {t('Installation ended without error, but no rtk binary was found.')}" + ) + return + print( + f"✅ {t('RTK is installed, version: ')}{self.rtk_version(rtk_path)}" + ) + print(f" {rtk_path}") + if not in_path: + self.rtk_report_path_warning() + def rtk_install(self): print(f"🤖 {t('Installation method:')}") choices = [ @@ -4244,64 +4321,47 @@ class TODO( if status == "0": return elif status == "1": - self.execute.exec_command_live( - "curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | sh", - source_erplibre=False, + command = ( + "curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/" + "refs/heads/master/install.sh | sh" ) elif status == "2": - self.execute.exec_command_live( - "brew install rtk", - source_erplibre=False, - ) + command = "brew install rtk" elif status == "3": - self.execute.exec_command_live( - "cargo install --git https://github.com/rtk-ai/rtk", - source_erplibre=False, - ) + command = "cargo install --git https://github.com/rtk-ai/rtk" else: print(t("Command not found !")) + return + exit_code = self.execute.exec_command_live( + command, + source_erplibre=False, + ) + self.rtk_report_install(exit_code) def rtk_check_version(self): - self.execute.exec_command_live( - "rtk --version", - source_erplibre=False, - ) + self.rtk_exec("--version") def rtk_show_gain(self): - self.execute.exec_command_live( - "rtk gain", - source_erplibre=False, - ) + self.rtk_exec("gain") def rtk_discover(self): - self.execute.exec_command_live( - "rtk discover", - source_erplibre=False, - ) + self.rtk_exec("discover") def rtk_init_global(self): - self.execute.exec_command_live( - "rtk init --global", - source_erplibre=False, - ) + self.rtk_exec("init --global") def rtk_check_status(self): - rtk_path = shutil.which("rtk") + rtk_path, in_path = self.rtk_locate() if rtk_path is None: print(t("RTK is not installed. Use option 1 to install it.")) return - result = self.execute.exec_command_live( - "rtk --version", - source_erplibre=False, - quiet=True, - return_status_and_output=True, + print( + f"{t('RTK is installed, version: ')}{self.rtk_version(rtk_path)}" ) - if isinstance(result, tuple) and result[0] == 0: - version_output = " ".join(result[1]).strip() - print(f"{t('RTK is installed, version: ')}{version_output}") - else: - print(f"{t('RTK is installed, version: ')}?") + print(f" {rtk_path}") + if not in_path: + self.rtk_report_path_warning() config_path = os.path.expanduser("~/.config/rtk/config.toml") if os.path.exists(config_path): diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index b905c61..4a21a71 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -367,6 +367,25 @@ TRANSLATIONS = { "fr": "RTK est installé, version : ", "en": "RTK is installed, version: ", }, + "RTK installation failed, see the output above.": { + "fr": "L'installation de RTK a échoué, voir la sortie ci-dessus.", + "en": "RTK installation failed, see the output above.", + }, + "Installation ended without error, but no rtk binary was found.": { + "fr": ( + "L'installation s'est terminée sans erreur, mais aucun binaire" + " rtk n'a été trouvé." + ), + "en": "Installation ended without error, but no rtk binary was found.", + }, + "rtk is not in the PATH of this process, restart TODO.": { + "fr": "rtk n'est pas dans le PATH de ce processus, relancer TODO.", + "en": "rtk is not in the PATH of this process, restart TODO.", + }, + "To make it permanent, add to your shell profile:": { + "fr": "Pour que ce soit permanent, ajouter au profil du shell :", + "en": "To make it permanent, add to your shell profile:", + }, "Global auto-rewrite hook: active": { "fr": "Hook auto-rewrite global : actif", "en": "Global auto-rewrite hook: active", diff --git a/test/test_todo_rtk.py b/test/test_todo_rtk.py new file mode 100644 index 0000000..56b6486 --- /dev/null +++ b/test/test_todo_rtk.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Le menu RTK : dit-il ce qui s'est réellement passé ? + +Deux modes de défaillance se ressemblent à l'écran et n'ont pas le même +remède. Le binaire peut être absent — l'installation a échoué. Il peut aussi +être posé sur le disque sans que le PATH du processus y mène : un processus +garde le PATH qu'il avait au démarrage, donc une installation faite pendant +que TODO tourne lui reste invisible jusqu'au redémarrage. Lancer « rtk » nu +rend alors 127, que rien ne distingue d'une absence. + +Ce test vérifie que les deux cas sont annoncés séparément, et que les +commandes passent par le chemin absolu du binaire plutôt que par le PATH. +""" + +import io +import os +import unittest +from contextlib import redirect_stdout +from unittest.mock import MagicMock, patch + +from script.todo.todo import TODO + +FALLBACK = os.path.expanduser("~/.local/bin/rtk") +PATH_HINT = 'export PATH="$HOME/.local/bin:$PATH"' + + +class TestRtkLocate(unittest.TestCase): + """rtk_locate distingue « dans le PATH », « posé ailleurs » et « absent ».""" + + def test_found_in_path(self): + with patch( + "script.todo.todo.shutil.which", return_value="/usr/bin/rtk" + ): + self.assertEqual(TODO().rtk_locate(), ("/usr/bin/rtk", True)) + + def test_found_outside_path(self): + with patch("script.todo.todo.shutil.which", return_value=None), patch( + "script.todo.todo.os.access", return_value=True + ): + self.assertEqual(TODO().rtk_locate(), (FALLBACK, False)) + + def test_absent(self): + with patch("script.todo.todo.shutil.which", return_value=None), patch( + "script.todo.todo.os.access", return_value=False + ): + self.assertEqual(TODO().rtk_locate(), (None, False)) + + +class TestRtkExec(unittest.TestCase): + """rtk_exec appelle le binaire par son chemin absolu, jamais « rtk » nu.""" + + def test_uses_absolute_path(self): + todo = TODO() + todo.execute = MagicMock() + todo.execute.exec_command_live.return_value = 0 + with patch("script.todo.todo.shutil.which", return_value=None), patch( + "script.todo.todo.os.access", return_value=True + ): + todo.rtk_exec("gain") + command = todo.execute.exec_command_live.call_args[0][0] + self.assertTrue(command.startswith(FALLBACK), command) + self.assertTrue(command.endswith(" gain"), command) + + def test_absent_runs_nothing(self): + todo = TODO() + todo.execute = MagicMock() + with patch("script.todo.todo.shutil.which", return_value=None), patch( + "script.todo.todo.os.access", return_value=False + ): + with redirect_stdout(io.StringIO()): + status = todo.rtk_exec("gain") + self.assertEqual(status, 1) + todo.execute.exec_command_live.assert_not_called() + + +class TestRtkReportInstall(unittest.TestCase): + """Le compte rendu d'installation nomme le résultat, sans le supposer.""" + + def report(self, todo, exit_code): + out = io.StringIO() + with redirect_stdout(out): + todo.rtk_report_install(exit_code) + return out.getvalue() + + def test_failure_is_not_announced_as_success(self): + todo = TODO() + todo.execute = MagicMock() + output = self.report(todo, 1) + self.assertIn("❌", output) + self.assertNotIn("✅", output) + todo.execute.exec_command_live.assert_not_called() + + def test_success_reports_version_and_path(self): + todo = TODO() + todo.execute = MagicMock() + todo.execute.exec_command_live.return_value = (0, ["rtk 0.47.0"]) + with patch( + "script.todo.todo.shutil.which", return_value="/usr/bin/rtk" + ): + output = self.report(todo, 0) + self.assertIn("✅", output) + self.assertIn("rtk 0.47.0", output) + self.assertIn("/usr/bin/rtk", output) + self.assertNotIn(PATH_HINT, output) + + def test_success_outside_path_tells_how_to_reach_it(self): + todo = TODO() + todo.execute = MagicMock() + todo.execute.exec_command_live.return_value = (0, ["rtk 0.47.0"]) + with patch("script.todo.todo.shutil.which", return_value=None), patch( + "script.todo.todo.os.access", return_value=True + ): + output = self.report(todo, 0) + self.assertIn("✅", output) + self.assertIn(PATH_HINT, output) + + def test_success_without_binary_is_not_a_success(self): + todo = TODO() + todo.execute = MagicMock() + with patch("script.todo.todo.shutil.which", return_value=None), patch( + "script.todo.todo.os.access", return_value=False + ): + output = self.report(todo, 0) + self.assertIn("❌", output) + self.assertNotIn("✅", output) + + +if __name__ == "__main__": + unittest.main()