diff --git a/script/analyse/analyse_custom_field.py b/script/analyse/analyse_custom_field.py old mode 100644 new mode 100755 diff --git a/script/analyse/analyse_diff_tui.py b/script/analyse/analyse_diff_tui.py old mode 100644 new mode 100755 diff --git a/script/analyse/analyse_schema_size.py b/script/analyse/analyse_schema_size.py old mode 100644 new mode 100755 diff --git a/script/analyse/analyse_view_custom.py b/script/analyse/analyse_view_custom.py old mode 100644 new mode 100755 diff --git a/script/analyse/lib_analyse.py b/script/analyse/lib_analyse.py old mode 100644 new mode 100755 diff --git a/script/analyse/shell/view_file_arch.py b/script/analyse/shell/view_file_arch.py old mode 100644 new mode 100755 diff --git a/script/odoo/migration/cow_drift.py b/script/odoo/migration/cow_drift.py old mode 100644 new mode 100755 diff --git a/script/odoo/migration/cow_drift_tui.py b/script/odoo/migration/cow_drift_tui.py old mode 100644 new mode 100755 index 953ef65..8bdec25 --- a/script/odoo/migration/cow_drift_tui.py +++ b/script/odoo/migration/cow_drift_tui.py @@ -20,6 +20,17 @@ import os import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) +) + +try: + from script.todo.todo_i18n import t +except Exception: # pragma: no cover - repli si i18n indisponible + + def t(key: str) -> str: + return key + CSS = """ Screen { layout: vertical; } @@ -143,18 +154,47 @@ def build_app(lst_finding): def run_tui(lst_finding, run_app=True): - """Open the screen. False when it could not be shown. + """Ouvrir l'écran. False si on n'a pas pu — et alors on DIT pourquoi. - False is not a failure: the caller prints the text report instead, which - is the same information without the navigation. + Trois refus, trois raisons, aucune n'est une panne : rien à montrer, pas + de terminal, ou Textual absent. Se taire faisait réafficher le rapport + texte à la place de l'écran demandé — la même sortie que la touche + précédente, sans rien qui distingue les deux. + + Textual n'est installé que dans `.venv.erplibre`. Lancer ce script + directement, maintenant qu'il porte le bit d'exécution, prend le python + du système : le shebang ne connaît pas le venv. C'est le chemin qui rend + ce message nécessaire. """ if not lst_finding: return False if not sys.stdout.isatty(): + print(f"ℹ️ {t('Not a terminal: showing the text report instead.')}") + return False + try: + from script.todo import textual_setup + except Exception: + textual_setup = None + if textual_setup and not textual_setup.available(): + # Le conseil AVANT la proposition d'installer : Textual est déjà là, + # dans le venv. Proposer de l'installer une seconde fois sous + # « --user » répond à côté de la question. + if not textual_setup.in_venv(): + print( + f"ℹ️ {t('Textual is missing from this interpreter:')}" + f" {sys.executable}" + ) + print(f" {t('Run it with')} .venv.erplibre/bin/python3") + if textual_setup and not textual_setup.ensure(): return False try: app = build_app(lst_finding) except ImportError: + print( + f"ℹ️ {t('Textual is missing from this interpreter:')}" + f" {sys.executable}" + ) + print(f" {t('Run it with')} .venv.erplibre/bin/python3") return False if not run_app: return app diff --git a/script/odoo/migration/fix_migration_odoo140_to_odoo150.py b/script/odoo/migration/fix_migration_odoo140_to_odoo150.py old mode 100644 new mode 100755 diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index fa29818..04faf93 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -4742,6 +4742,14 @@ TRANSLATIONS = { "fr": "Exécution de la commande", "en": "Execute command", }, + "Textual is missing from this interpreter:": { + "fr": "Textual manque à cet interpréteur :", + "en": "Textual is missing from this interpreter:", + }, + "Run it with": { + "fr": "Le lancer avec", + "en": "Run it with", + }, "Nothing to decide yet": { "fr": "Rien à décider pour l'instant", "en": "Nothing to decide yet", diff --git a/test/test_script_permissions.py b/test/test_script_permissions.py new file mode 100755 index 0000000..cadf43b --- /dev/null +++ b/test/test_script_permissions.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Un script avec shebang doit être exécutable — et pas inscriptible en groupe. + +Le bit d'exécution n'accorde rien : qui peut lire le fichier peut déjà faire +« python3 fichier ». Ce qui compte est la PAIRE : un fichier à la fois +exécuté par d'autres et modifiable par le groupe laisse un membre du groupe +changer ce que les autres lancent. L'umask 0002 du poste crée exactement +cette paire dès qu'on ajoute +x sans y penser (664 -> 775). + +Git ne stocke que le bit d'exécution, jamais 664 contre 644 : le mode complet +ne se vérifie donc que sur le disque, ici. +""" + +import glob +import os +import stat +import unittest + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +PATTERNS = ( + "script/analyse/*.py", + "script/analyse/*/*.py", + "script/odoo/migration/*.py", +) + + +def scripts(): + """(chemin, a un shebang) pour chaque script des dossiers visés.""" + found = [] + for pattern in PATTERNS: + for path in sorted(glob.glob(os.path.join(REPO, pattern))): + with open(path, "rb") as handle: + found.append((path, handle.read(2) == b"#!")) + return found + + +class TestExecutableBit(unittest.TestCase): + def test_the_inventory_is_not_empty(self): + # Un test qui ne trouve rien passe toujours. + self.assertGreater(len(scripts()), 10) + + def test_every_shebang_script_is_executable(self): + for path, has_shebang in scripts(): + if not has_shebang: + continue + with self.subTest(script=os.path.relpath(path, REPO)): + self.assertTrue(os.access(path, os.X_OK)) + + def test_none_is_writable_by_group_or_others(self): + # LE point qui compte : exécuté par d'autres ET modifiable par eux. + for path, _ in scripts(): + mode = stat.S_IMODE(os.stat(path).st_mode) + with self.subTest(script=os.path.relpath(path, REPO)): + self.assertFalse( + mode & (stat.S_IWGRP | stat.S_IWOTH), oct(mode) + ) + + def test_none_is_setuid_or_setgid(self): + for path, _ in scripts(): + mode = os.stat(path).st_mode + with self.subTest(script=os.path.relpath(path, REPO)): + self.assertFalse(mode & (stat.S_ISUID | stat.S_ISGID)) + + +class TestTheTuiSaysWhyItRefuses(unittest.TestCase): + """Rendre un script lançable ouvre un chemin sans le venv. + + Le shebang est « #!/usr/bin/env python3 » : lancé directement depuis un + shell où le venv n'est pas actif, c'est le python du système qui répond, + et Textual n'y est pas. La TUI retombait alors sur le rapport texte sans + rien dire — le même défaut muet que le tube de sortie. + """ + + def run_tui(self): + import sys + + sys.path.insert(0, os.path.join(REPO, "script", "odoo", "migration")) + self.addCleanup(sys.path.remove, sys.path[0]) + from cow_drift_tui import run_tui + + return run_tui + + def finding(self): + return { + "id": 1, + "key": "k", + "website_id": 1, + "reason": "r", + "module_id": 2, + "module_arch": "a", + "copy_arch": "b", + "decl_current": None, + "decl_target": None, + "current_version": "odoo12.0", + "target_version": "odoo13.0", + } + + def test_a_pipe_is_explained_not_swallowed(self): + import io + from contextlib import redirect_stdout + + out = io.StringIO() + with redirect_stdout(out): + result = self.run_tui()([self.finding()]) + self.assertFalse(result) + self.assertTrue(out.getvalue().strip(), "refus muet") + + def test_nothing_to_show_stays_silent(self): + # Une liste vide n'est pas un refus : il n'y a rien à annoncer. + import io + from contextlib import redirect_stdout + + out = io.StringIO() + with redirect_stdout(out): + self.assertFalse(self.run_tui()([])) + self.assertEqual(out.getvalue(), "") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_todo_upgrade_cow_prompt.py b/test/test_todo_upgrade_cow_prompt.py index d73426c..9188cb3 100644 --- a/test/test_todo_upgrade_cow_prompt.py +++ b/test/test_todo_upgrade_cow_prompt.py @@ -161,8 +161,12 @@ class TestTheFullScreenViewGetsARealTerminal(PromptCase): "target_version": "odoo13.0", } - class FakeStdout: + # Un vrai objet fichier : run_tui ÉCRIT maintenant la raison de son + # refus, et un faux sans write() ne mesurait plus le comportement mais + # sa propre incomplétude. + class FakeStdout(io.StringIO): def __init__(self, tty): + super().__init__() self.tty = tty def isatty(self): @@ -170,10 +174,16 @@ class TestTheFullScreenViewGetsARealTerminal(PromptCase): real = sys.stdout self.addCleanup(setattr, sys, "stdout", real) - sys.stdout = FakeStdout(False) - self.assertFalse(run_tui([finding], run_app=False)) + sys.stdout = piped = FakeStdout(False) + refused = run_tui([finding], run_app=False) + sys.stdout = real + self.assertFalse(refused) + self.assertIn("terminal", piped.getvalue()) + sys.stdout = FakeStdout(True) - self.assertTrue(run_tui([finding], run_app=False)) + opened = run_tui([finding], run_app=False) + sys.stdout = real + self.assertTrue(opened) class TestTheBumpPromptSharesTheSameView(PromptCase):