[FIX] script: make the analysis and migration tools executable

Nine of the thirteen carried a shebang without the bit, so only four
could be run by their own path. Set to 755, not chmod +x: two were
664 under the 0002 umask and would have become 775 — executed by
others while a group member could still rewrite them. That pairing is
the only real risk here; the bit alone grants nothing, since reading
the file is enough to run python3 on it.

Being runnable opens a path without the venv: the shebang resolves to
the system python3, which has no Textual. --tui fell back to the text
report saying nothing. It now names the interpreter and the venv.

--- FR ---

[FIX] script : rendre exécutables les outils d'analyse et de migration

Neuf des treize portaient un shebang sans le bit ; quatre seulement
se lançaient par leur chemin. Mis à 755, pas chmod +x : deux étaient
en 664 sous l'umask 0002 et seraient passés à 775 — exécutés par
d'autres alors qu'un membre du groupe pouvait encore les réécrire.
C'est la seule vraie prise ici ; le bit seul n'accorde rien, lire le
fichier suffit déjà à lancer python3 dessus.

Devenir lançable ouvre un chemin sans le venv : le shebang résout le
python du système, sans Textual. --tui retombait sur le rapport texte
sans rien dire. Il nomme désormais l'interpréteur et le venv.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-13 04:21:24 -04:00
parent d2c5d92d11
commit 0870696701
12 changed files with 189 additions and 7 deletions

0
script/analyse/analyse_custom_field.py Normal file → Executable file
View file

0
script/analyse/analyse_diff_tui.py Normal file → Executable file
View file

0
script/analyse/analyse_schema_size.py Normal file → Executable file
View file

0
script/analyse/analyse_view_custom.py Normal file → Executable file
View file

0
script/analyse/lib_analyse.py Normal file → Executable file
View file

0
script/analyse/shell/view_file_arch.py Normal file → Executable file
View file

0
script/odoo/migration/cow_drift.py Normal file → Executable file
View file

46
script/odoo/migration/cow_drift_tui.py Normal file → Executable file
View file

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

View file

View file

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

124
test/test_script_permissions.py Executable file
View file

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

View file

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