[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
|
||||
|
||||
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
|
||||
|
|
|
|||
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",
|
||||
"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
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",
|
||||
}
|
||||
|
||||
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):
|
||||
|
|
|
|||
Loading…
Reference in a new issue