[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:
parent
d2c5d92d11
commit
0870696701
12 changed files with 189 additions and 7 deletions
0
script/analyse/analyse_custom_field.py
Normal file → Executable file
0
script/analyse/analyse_custom_field.py
Normal file → Executable file
0
script/analyse/analyse_diff_tui.py
Normal file → Executable file
0
script/analyse/analyse_diff_tui.py
Normal file → Executable file
0
script/analyse/analyse_schema_size.py
Normal file → Executable file
0
script/analyse/analyse_schema_size.py
Normal file → Executable file
0
script/analyse/analyse_view_custom.py
Normal file → Executable file
0
script/analyse/analyse_view_custom.py
Normal file → Executable file
0
script/analyse/lib_analyse.py
Normal file → Executable file
0
script/analyse/lib_analyse.py
Normal file → Executable file
0
script/analyse/shell/view_file_arch.py
Normal file → Executable file
0
script/analyse/shell/view_file_arch.py
Normal file → Executable file
0
script/odoo/migration/cow_drift.py
Normal file → Executable file
0
script/odoo/migration/cow_drift.py
Normal file → Executable file
46
script/odoo/migration/cow_drift_tui.py
Normal file → Executable file
46
script/odoo/migration/cow_drift_tui.py
Normal file → Executable file
|
|
@ -20,6 +20,17 @@ import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
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 = """
|
CSS = """
|
||||||
Screen { layout: vertical; }
|
Screen { layout: vertical; }
|
||||||
|
|
@ -143,18 +154,47 @@ def build_app(lst_finding):
|
||||||
|
|
||||||
|
|
||||||
def run_tui(lst_finding, run_app=True):
|
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
|
Trois refus, trois raisons, aucune n'est une panne : rien à montrer, pas
|
||||||
is the same information without the navigation.
|
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:
|
if not lst_finding:
|
||||||
return False
|
return False
|
||||||
if not sys.stdout.isatty():
|
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
|
return False
|
||||||
try:
|
try:
|
||||||
app = build_app(lst_finding)
|
app = build_app(lst_finding)
|
||||||
except ImportError:
|
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
|
return False
|
||||||
if not run_app:
|
if not run_app:
|
||||||
return app
|
return app
|
||||||
|
|
|
||||||
0
script/odoo/migration/fix_migration_odoo140_to_odoo150.py
Normal file → Executable file
0
script/odoo/migration/fix_migration_odoo140_to_odoo150.py
Normal file → Executable file
|
|
@ -4742,6 +4742,14 @@ TRANSLATIONS = {
|
||||||
"fr": "Exécution de la commande",
|
"fr": "Exécution de la commande",
|
||||||
"en": "Execute command",
|
"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": {
|
"Nothing to decide yet": {
|
||||||
"fr": "Rien à décider pour l'instant",
|
"fr": "Rien à décider pour l'instant",
|
||||||
"en": "Nothing to decide yet",
|
"en": "Nothing to decide yet",
|
||||||
|
|
|
||||||
124
test/test_script_permissions.py
Executable file
124
test/test_script_permissions.py
Executable 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()
|
||||||
|
|
@ -161,8 +161,12 @@ class TestTheFullScreenViewGetsARealTerminal(PromptCase):
|
||||||
"target_version": "odoo13.0",
|
"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):
|
def __init__(self, tty):
|
||||||
|
super().__init__()
|
||||||
self.tty = tty
|
self.tty = tty
|
||||||
|
|
||||||
def isatty(self):
|
def isatty(self):
|
||||||
|
|
@ -170,10 +174,16 @@ class TestTheFullScreenViewGetsARealTerminal(PromptCase):
|
||||||
|
|
||||||
real = sys.stdout
|
real = sys.stdout
|
||||||
self.addCleanup(setattr, sys, "stdout", real)
|
self.addCleanup(setattr, sys, "stdout", real)
|
||||||
sys.stdout = FakeStdout(False)
|
sys.stdout = piped = FakeStdout(False)
|
||||||
self.assertFalse(run_tui([finding], run_app=False))
|
refused = run_tui([finding], run_app=False)
|
||||||
|
sys.stdout = real
|
||||||
|
self.assertFalse(refused)
|
||||||
|
self.assertIn("terminal", piped.getvalue())
|
||||||
|
|
||||||
sys.stdout = FakeStdout(True)
|
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):
|
class TestTheBumpPromptSharesTheSameView(PromptCase):
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue