[FIX] script todo : rtk hors du PATH, résultat d'installation annoncé

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
This commit is contained in:
Mathieu Benoit 2026-09-02 03:53:40 -04:00
parent 1cc74c83b4
commit 3dcccf40d9
3 changed files with 249 additions and 38 deletions

View file

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

View file

@ -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",

132
test/test_todo_rtk.py Normal file
View file

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