erplibre/test/test_todo_upgrade_cow_prompt.py
Mathieu Benoit 0870696701 [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
2026-08-22 07:23:59 -04:00

214 lines
8 KiB
Python

#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""L'étape 2 annonce des copies COW cassées, et ne demandait rien.
Le message disait le problème, invitait à arbitrer, puis passait à la suite :
« la migration proposera de neutraliser au palier ». Des dizaines de minutes
plus tard, donc — alors que REGARDER n'écrit rien, et que neutraliser ici vaut
pour tous les paliers, chaque base de palier étant un clone de celle-ci.
Ces tests portent sur ce que chaque réponse déclenche réellement, pas sur le
texte de l'invite.
"""
import builtins
import contextlib
import io
import os
import unittest
from script.todo import todo_i18n
from script.todo.todo_upgrade import TodoUpgrade
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
class PromptCase(unittest.TestCase):
def setUp(self):
# PAS set_lang() : il persiste la langue dans env_var.sh, suivi par
# git. On écrit la mémoïsation, et on la rend.
self.addCleanup(
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
)
todo_i18n._current_lang = "en"
def run_prompt(self, answers, database="db", version=13):
"""(commandes lancées, texte affiché) après ces réponses."""
upgrade = TodoUpgrade.__new__(TodoUpgrade)
upgrade.dct_progression = {}
upgrade.lst_command_executed = []
lst_cmd = []
upgrade.todo_upgrade_execute = lambda cmd, **kw: (
lst_cmd.append(cmd),
(False, cmd),
)[1]
# Les afficheurs ne passent PLUS par todo_upgrade_execute : il capture
# la sortie par un tube, et un plein écran refuse de s'ouvrir sur un
# stdout qui n'est pas un terminal. Sans ce bouchon, ces tests
# lanceraient les vraies commandes.
upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0
seq = iter(answers)
original = builtins.input
builtins.input = lambda *a: next(seq)
self.addCleanup(setattr, builtins, "input", original)
out = io.StringIO()
with contextlib.redirect_stdout(out):
upgrade.prompt_cow_prediction(database, version)
return lst_cmd, out.getvalue()
class TestWhatEachAnswerRuns(PromptCase):
def test_enter_runs_nothing_and_says_so(self):
lst_cmd, text = self.run_prompt([""])
self.assertEqual(lst_cmd, [])
self.assertIn("version bump", text)
def test_v_shows_what_the_copies_hold(self):
lst_cmd, _ = self.run_prompt(["v", ""])
self.assertEqual(len(lst_cmd), 1)
self.assertIn("cow_drift.py", lst_cmd[0])
self.assertIn("-d db -t odoo13.0", lst_cmd[0])
self.assertNotIn("--shape", lst_cmd[0])
def test_s_shows_why_it_breaks(self):
lst_cmd, _ = self.run_prompt(["s", ""])
self.assertTrue(lst_cmd[0].endswith("--shape"))
def test_w_opens_the_full_screen_view(self):
lst_cmd, _ = self.run_prompt(["w", ""])
self.assertTrue(lst_cmd[0].endswith("--tui"))
def test_looking_does_not_answer_the_question(self):
# LE piège de l'invite précédente : montrer puis passer à la suite.
# Après avoir regardé, on doit pouvoir encore choisir.
lst_cmd, _ = self.run_prompt(["v", "s", "w", "a"])
self.assertEqual(len(lst_cmd), 4)
self.assertIn("--apply", lst_cmd[-1])
class TestNeutralizingNow(PromptCase):
def test_a_applies_and_stops_asking(self):
lst_cmd, _ = self.run_prompt(["a"])
self.assertEqual(len(lst_cmd), 1)
self.assertIn("neutralize_cow_views.py", lst_cmd[0])
self.assertIn("-d db -t odoo13.0 --apply", lst_cmd[0])
def test_the_way_back_is_printed_and_is_valid(self):
# `--restore` refuse `-t` : une commande d'annulation qu'on ne peut
# pas coller est une commande d'annulation qui n'existe pas.
_, text = self.run_prompt(["a"])
self.assertIn("--restore", text)
ligne = [x for x in text.splitlines() if "--restore" in x][0]
self.assertIn("-d db", ligne)
self.assertNotIn("-t odoo", ligne)
def test_applying_says_nothing_about_deciding_later(self):
_, text = self.run_prompt(["a"])
self.assertNotIn("ask again", text)
class TestTheFullScreenViewGetsARealTerminal(PromptCase):
"""« w » réaffichait le rapport texte au lieu d'ouvrir le plein écran.
`todo_upgrade_execute` capture la sortie par un tube pour que le pilote
puisse la relire. La TUI y voit un stdout qui n'est PAS un terminal,
renonce — c'est son repli délibéré — et retombe sur le même rapport que
« v » venait d'afficher. Aucune erreur, aucune trace : deux touches
différentes donnant le même écran.
"""
def test_the_viewers_do_not_go_through_the_piped_executor(self):
import inspect
from script.todo.todo_upgrade import TodoUpgrade
source = inspect.getsource(TodoUpgrade.show_cow_drift)
self.assertNotIn("todo_upgrade_execute", source)
self.assertIn("self.run_on_terminal(", source)
def test_run_on_terminal_does_not_capture_anything(self):
# Capturer, c'est justement ce qui referme le plein écran.
import inspect
from script.todo.todo_upgrade import TodoUpgrade
source = inspect.getsource(TodoUpgrade.run_on_terminal)
self.assertNotIn("PIPE", source)
self.assertNotIn("capture_output", source)
self.assertIn("subprocess.call(", source)
def test_the_tui_refuses_a_pipe_and_accepts_a_terminal(self):
# La raison même du défaut, vérifiée sur le code qui décide.
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
finding = {
"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",
}
# 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):
return self.tty
real = sys.stdout
self.addCleanup(setattr, sys, "stdout", real)
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)
opened = run_tui([finding], run_app=False)
sys.stdout = real
self.assertTrue(opened)
class TestTheBumpPromptSharesTheSameView(PromptCase):
"""Les deux invites doivent montrer la même chose, sans se dupliquer."""
def test_show_cow_drift_builds_the_three_modes(self):
upgrade = TodoUpgrade.__new__(TodoUpgrade)
upgrade.dct_progression = {}
upgrade.lst_command_executed = []
lst_cmd = []
upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0
for mode in ("diff", "shape", "tui"):
upgrade.show_cow_drift("db", 13, mode)
self.assertNotIn("--", lst_cmd[0].split("odoo13.0")[1])
self.assertTrue(lst_cmd[1].endswith("--shape"))
self.assertTrue(lst_cmd[2].endswith("--tui"))
def test_the_bump_prompt_no_longer_builds_its_own_command(self):
# Sans cela les deux invites peuvent diverger en silence.
import inspect
source = inspect.getsource(TodoUpgrade.neutralize_cow_views)
self.assertNotIn("cow_drift.py", source)
self.assertIn("self.show_cow_drift(", source)
if __name__ == "__main__":
unittest.main()