diff --git a/script/todo/qemu_manage.py b/script/todo/qemu_manage.py index e348af9..7b9ee38 100644 --- a/script/todo/qemu_manage.py +++ b/script/todo/qemu_manage.py @@ -16,6 +16,7 @@ from script.todo import todo_install from script.todo.qemu_privilege import ( LIBVIRT_URI as URI, sudo_prefix, + system_path, virsh_argv, ) from script.todo.todo_i18n import t @@ -399,7 +400,14 @@ class QemuManageMixin: shlex.quote(c) for c in entry["cmd"] ) print(f"\n{t('Will execute:')} {cmd}") - self.execute.exec_command_live(cmd, source_erplibre=False) + # virt-xml est un script Python du système : sans PATH assaini, il + # s'amorce sur l'interpréteur du venv, où les modules de la + # distribution n'existent pas. + self.execute.exec_command_live( + cmd, + source_erplibre=False, + new_env={"PATH": system_path()}, + ) def _qemu_hw_form(self, rows, node, nets=None): """Formulaire TUI d'ajustement. Renvoie l'intention par VM, {} pour diff --git a/script/todo/qemu_privilege.py b/script/todo/qemu_privilege.py index 21b37e9..570fceb 100644 --- a/script/todo/qemu_privilege.py +++ b/script/todo/qemu_privilege.py @@ -93,6 +93,48 @@ def virsh_cmd(args: str = "") -> str: return f"{base} {args}" if args else base +def system_path(path: str = "") -> str: + """PATH débarrassé des répertoires de venv du projet. + + TODO tourne DANS son venv, dont le « bin » est en TÊTE du PATH : chaque + commande lancée par le menu le voit en premier, et ce répertoire contient + un « python3 ». Un outil système écrit en Python et amorcé par + « #!/usr/bin/env python3 » y trouve donc l'interpréteur du venv, où les + modules fournis par la distribution — PyGObject, entre autres — n'existent + pas, et l'import échoue sur un module que le système possède pourtant. + + Sous sudo le piège ne se voyait pas : sudo réinitialise le PATH par son + « secure_path ». Le retirer là où il n'était pas nécessaire l'a mis au + jour, et l'assainissement doit donc être explicite. + """ + path = path or os.environ.get("PATH", "") + venv = os.environ.get("VIRTUAL_ENV", "") + gardees = [] + for entree in path.split(os.pathsep): + if not entree: + continue + # Un répertoire du venv courant, ou de n'importe quel « .venv* » du + # dépôt : les deux mènent au même interpréteur de trop. + if venv and os.path.normpath(entree).startswith( + os.path.normpath(venv) + os.sep + ): + continue + if any( + part.startswith(".venv") + for part in os.path.normpath(entree).split(os.sep) + ): + continue + gardees.append(entree) + return os.pathsep.join(gardees) + + +def system_env(env: dict | None = None) -> dict: + """Environnement où un outil système trouve l'interpréteur système.""" + base = dict(env or os.environ) + base["PATH"] = system_path(base.get("PATH", "")) + return base + + def group_state(user: str = "") -> tuple[bool, bool]: """(déclaré, actif) pour le groupe libvirt. diff --git a/script/todo/qemu_recover.py b/script/todo/qemu_recover.py index 92b52da..90c21d5 100644 --- a/script/todo/qemu_recover.py +++ b/script/todo/qemu_recover.py @@ -18,6 +18,7 @@ import shlex import shutil import subprocess +from script.todo.qemu_privilege import system_env, system_path from script.todo.todo_i18n import t # Le paquet qui apporte guestfish, par gestionnaire de paquets. Il ne vit pas @@ -102,6 +103,7 @@ class QemuRecoverMixin: capture_output=True, text=True, timeout=timeout, + env=system_env(), ) except (OSError, subprocess.SubprocessError): return [] @@ -135,7 +137,9 @@ class QemuRecoverMixin: input(t("Install libguestfs now? (Y/n): ")) ): return not manque_essentiel - self.execute.exec_command_live(install, source_erplibre=False) + self.execute.exec_command_live( + install, source_erplibre=False, new_env={"PATH": system_path()} + ) return shutil.which(GUESTFS_BIN_ESSENTIEL) is not None def _qemu_recover_ready(self, name): @@ -239,7 +243,9 @@ class QemuRecoverMixin: f"copy-out {shlex.quote(source)} {shlex.quote(dest)}", ) print(f"\n{t('Will execute:')} {cmd}") - code = self.execute.exec_command_live(cmd, source_erplibre=False) + code = self.execute.exec_command_live( + cmd, source_erplibre=False, new_env={"PATH": system_path()} + ) if code: print(f" ⚠ {t('Extraction failed.')}") return False @@ -274,7 +280,9 @@ class QemuRecoverMixin: for titre, cmd in sondes: print(f"\n── {titre} ──") print(f"{t('Will execute:')} {cmd}") - self.execute.exec_command_live(cmd, source_erplibre=False) + self.execute.exec_command_live( + cmd, source_erplibre=False, new_env={"PATH": system_path()} + ) def _qemu_recover_files(self): """Récupère des fichiers dans le disque d'une VM, sans la démarrer.""" diff --git a/test/test_qemu_privilege.py b/test/test_qemu_privilege.py index 341fbbb..9f66ede 100644 --- a/test/test_qemu_privilege.py +++ b/test/test_qemu_privilege.py @@ -17,6 +17,7 @@ Ce que ces tests gardent : """ import io +import os import sys import unittest from contextlib import redirect_stdout @@ -182,5 +183,70 @@ class LUriEstToujoursExplicite(unittest.TestCase): ) +class LePathDesOutilsSysteme(unittest.TestCase): + """TODO tourne dans son venv, dont le « bin » est en tête du PATH. + + Ce répertoire contient un « python3 ». Un outil de la distribution amorcé + par « #!/usr/bin/env python3 » y trouve donc l'interpréteur du venv, où + les modules du système n'existent pas — l'import échoue sur un module que + la machine possède pourtant. Sous sudo le piège était invisible : sudo + réinitialise le PATH. + """ + + def test_the_project_venvs_are_dropped(self): + chemin = os.pathsep.join( + [ + "/home/x/git/erplibre/.venv.erplibre/bin", + "/home/x/git/erplibre/.venv.odoo18.0_python3.12.10/bin", + "/usr/local/bin", + "/usr/bin", + ] + ) + with mock.patch.dict(qp.os.environ, {"VIRTUAL_ENV": ""}, clear=False): + propre = qp.system_path(chemin) + self.assertNotIn(".venv", propre) + # L'ordre du reste ne bouge pas : il décide quel outil gagne. + self.assertEqual(["/usr/local/bin", "/usr/bin"], propre.split(":")) + + def test_the_active_venv_is_dropped_even_without_the_name(self): + """Un venv hors du dépôt ne porte pas « .venv » : VIRTUAL_ENV le + désigne, et c'est ce nom-là qui tranche.""" + with mock.patch.dict( + qp.os.environ, {"VIRTUAL_ENV": "/opt/env"}, clear=False + ): + propre = qp.system_path("/opt/env/bin:/usr/bin") + self.assertEqual("/usr/bin", propre) + + def test_a_path_without_any_venv_is_untouched(self): + with mock.patch.dict(qp.os.environ, {"VIRTUAL_ENV": ""}, clear=False): + self.assertEqual( + "/usr/local/bin:/usr/bin", + qp.system_path("/usr/local/bin:/usr/bin"), + ) + + def test_empty_entries_do_not_become_the_current_directory(self): + """Une entrée vide dans PATH signifie « le répertoire courant » : la + recopier ferait chercher un outil système là où on se trouve.""" + with mock.patch.dict(qp.os.environ, {"VIRTUAL_ENV": ""}, clear=False): + self.assertEqual("/usr/bin", qp.system_path("/usr/bin::")) + + def test_the_env_carries_the_cleaned_path(self): + with mock.patch.dict( + qp.os.environ, + {"VIRTUAL_ENV": "/opt/env", "PATH": "/opt/env/bin:/usr/bin"}, + clear=False, + ): + env = qp.system_env() + self.assertEqual("/usr/bin", env["PATH"]) + + def test_the_hardware_plan_uses_it(self): + """virt-xml est un script Python du système : sans PATH assaini, il + s'amorce sur l'interpréteur du venv.""" + source = Path("script/todo/qemu_manage.py").read_text(encoding="utf-8") + debut = source.index("def _qemu_adjust_hardware") + corps = source[debut : source.index("\n def ", debut + 10)] + self.assertIn("system_path()", corps) + + if __name__ == "__main__": unittest.main()