[ADD] migration: pick the COW copy to reset from a list, not from memory
The error prompt printed « --reset <key> --apply » and left the key to be found in a thousand-line diff. Copying it by hand is where a character goes missing, and a key matching no copy is not an error for the tool: the command runs and does nothing, silently. [3] now asks the tool for the keys, numbers them, and offers « all ». An out-of-range or unreadable answer resets nothing — falling back to « all » would act on what was never asked for. --- FR --- [ADD] migration : choisir la copie COW à réinitialiser dans une liste L'invite d'erreur imprimait « --reset <key> --apply » et laissait retrouver la clé dans un diff de mille lignes. La recopier à la main est l'endroit où un caractère se perd, et une clé ne correspondant à aucune copie n'est pas une erreur pour l'outil : la commande tourne et ne fait rien, en silence. [3] demande désormais les clés à l'outil, les numérote et offre « toutes ». Une réponse hors liste ou illisible ne réinitialise rien — retomber sur « toutes » agirait sur ce qui n'a pas été demandé. Assisted-by: Claude Opus 5 (cherry picked from commit ae08e13fe5bba2b02b8661c67c4024fcd79f29bc)
This commit is contained in:
parent
5c4d71e7a2
commit
7e74455d63
4 changed files with 262 additions and 0 deletions
|
|
@ -283,6 +283,11 @@ def main():
|
|||
)
|
||||
)
|
||||
parser.add_argument("-d", "--database", required=True)
|
||||
parser.add_argument(
|
||||
"--list-keys",
|
||||
action="store_true",
|
||||
help="print one drifted key per line, and nothing else",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--reset",
|
||||
metavar="KEY",
|
||||
|
|
@ -304,6 +309,14 @@ def main():
|
|||
config = parser.parse_args()
|
||||
|
||||
findings = analyse(config.database)
|
||||
# Une sortie sans décor, pour que l'appelant construise un menu : on ne
|
||||
# connaît pas les clés de tête, et les recopier depuis un diff de mille
|
||||
# lignes est le genre de recopie où l'on se trompe.
|
||||
if config.list_keys:
|
||||
for cow_view, _module_view, _broken in findings:
|
||||
print(cow_view["key"])
|
||||
return 1 if findings else 0
|
||||
|
||||
if not findings:
|
||||
print(f"✅ {t('No COW copy has drifted from its module view.')}")
|
||||
return 0
|
||||
|
|
|
|||
|
|
@ -4926,6 +4926,30 @@ TRANSLATIONS = {
|
|||
"fr": "et ne change rien.",
|
||||
"en": "and changes nothing.",
|
||||
},
|
||||
"Reset one of them onto its module view": {
|
||||
"fr": "Réinitialiser l'une d'elles sur sa vue module",
|
||||
"en": "Reset one of them onto its module view",
|
||||
},
|
||||
"Drifted COW copies": {
|
||||
"fr": "Copies COW ayant dérivé",
|
||||
"en": "Drifted COW copies",
|
||||
},
|
||||
"Which one(s) to reset onto the module view?": {
|
||||
"fr": "Laquelle ou lesquelles réinitialiser sur la vue module ?",
|
||||
"en": "Which one(s) to reset onto the module view?",
|
||||
},
|
||||
"numbers separated by commas, a = all, empty =": {
|
||||
"fr": "numéros séparés par des virgules, a = toutes, vide =",
|
||||
"en": "numbers separated by commas, a = all, empty =",
|
||||
},
|
||||
"Kept. Nothing was reset.": {
|
||||
"fr": "Conservées. Rien n'a été réinitialisé.",
|
||||
"en": "Kept. Nothing was reset.",
|
||||
},
|
||||
"Unknown choice, nothing was reset.": {
|
||||
"fr": "Choix inconnu, rien n'a été réinitialisé.",
|
||||
"en": "Unknown choice, nothing was reset.",
|
||||
},
|
||||
"Nothing to decide yet": {
|
||||
"fr": "Rien à décider pour l'instant",
|
||||
"en": "Nothing to decide yet",
|
||||
|
|
|
|||
|
|
@ -2792,6 +2792,69 @@ class TodoUpgrade:
|
|||
wait_at_error=False,
|
||||
)
|
||||
|
||||
def stale_cow_keys(self, database_name):
|
||||
"""Les clés des copies COW en retard, sans décor."""
|
||||
script_path = os.path.join(
|
||||
PATH_MIGRATION_GLOBAL, "reset_stale_cow_views.py"
|
||||
)
|
||||
if not os.path.exists(script_path):
|
||||
return []
|
||||
status, _cmd, output = self.todo_upgrade_execute(
|
||||
f"{PYTHON_BIN} ./{script_path} -d {database_name} --list-keys",
|
||||
get_output=True,
|
||||
wait_at_error=False,
|
||||
quiet=True,
|
||||
)
|
||||
if status == 2:
|
||||
return []
|
||||
return [line.strip() for line in (output or []) if line.strip()]
|
||||
|
||||
def prompt_reset_stale_cow_views(self, database_name):
|
||||
"""Choisir quoi réinitialiser dans une LISTE, pas de mémoire.
|
||||
|
||||
L'aide disait « --reset <key> --apply » et laissait retrouver la clé
|
||||
dans un diff de mille lignes. On la recopie, on se trompe d'un
|
||||
caractère, et la commande ne fait rien sans le dire — une clé qui ne
|
||||
correspond à rien n'est pas une erreur pour l'outil.
|
||||
"""
|
||||
lst_key = self.stale_cow_keys(database_name)
|
||||
if not lst_key:
|
||||
print(
|
||||
f"✅ -> {t('No COW copy has drifted from its module view.')}"
|
||||
)
|
||||
return
|
||||
print(f"\n✨ {t('Drifted COW copies')} :")
|
||||
for index, key in enumerate(lst_key, start=1):
|
||||
print(f" [{index}] {key}")
|
||||
print(f" [a] {t('All of the list above')}")
|
||||
answer = (
|
||||
self.ask_gate(
|
||||
f"💬 {t('Which one(s) to reset onto the module view?')}"
|
||||
f" ({t('numbers separated by commas, a = all, empty =')}"
|
||||
f" {t('nothing')}) : "
|
||||
)
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
if not answer:
|
||||
print(f"ℹ -> {t('Kept. Nothing was reset.')}")
|
||||
return
|
||||
if answer == "a":
|
||||
lst_chosen = ["all"]
|
||||
else:
|
||||
lst_chosen = []
|
||||
for part in answer.replace(" ", "").split(","):
|
||||
if part.isdigit() and 1 <= int(part) <= len(lst_key):
|
||||
lst_chosen.append(lst_key[int(part) - 1])
|
||||
if not lst_chosen:
|
||||
print(f"⚠️ {t('Unknown choice, nothing was reset.')}")
|
||||
return
|
||||
args = " ".join(f"--reset {key}" for key in lst_chosen)
|
||||
self.run_on_terminal(
|
||||
f"{PYTHON_BIN} ./{os.path.join(PATH_MIGRATION_GLOBAL, 'reset_stale_cow_views.py')}"
|
||||
f" -d {database_name} {args} --apply"
|
||||
)
|
||||
|
||||
def show_cow_drift(self, database_name, next_version, mode="diff"):
|
||||
"""Montre les copies COW à risque. Ne touche à rien.
|
||||
|
||||
|
|
@ -3253,6 +3316,7 @@ class TodoUpgrade:
|
|||
f"[2] {t('Check the COW views that drifted')}"
|
||||
f" ({database_name})"
|
||||
)
|
||||
print(f"[3] {t('Reset one of them onto its module view')}")
|
||||
wait_status = (
|
||||
input(
|
||||
f"💬 {t('Error detected, press enter to continue or')}"
|
||||
|
|
@ -3272,6 +3336,9 @@ class TodoUpgrade:
|
|||
# place, puis on repose le choix pour rejouer.
|
||||
self.check_stale_cow_views(database_name)
|
||||
continue
|
||||
if wait_status == "3" and database_name:
|
||||
self.prompt_reset_stale_cow_views(database_name)
|
||||
continue
|
||||
break
|
||||
|
||||
if wait_status == "1":
|
||||
|
|
|
|||
158
test/test_reset_stale_cow_prompt.py
Executable file
158
test/test_reset_stale_cow_prompt.py
Executable file
|
|
@ -0,0 +1,158 @@
|
|||
#!/usr/bin/env python3
|
||||
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
|
||||
"""Choisir une copie à réinitialiser dans une LISTE, pas de mémoire.
|
||||
|
||||
L'aide affichée après une erreur disait « --reset <key> --apply » et laissait
|
||||
retrouver la clé dans un diff de mille lignes. On la recopie, on se trompe
|
||||
d'un caractère, et la commande ne fait RIEN sans le dire — une clé qui ne
|
||||
correspond à aucune copie n'est pas une erreur pour l'outil.
|
||||
|
||||
D'où l'option [3] : elle demande les clés à l'outil, les numérote, et offre
|
||||
« toutes ». Ces tests portent sur ce que chaque réponse lance.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from script.todo import todo_i18n # noqa: E402
|
||||
from script.todo.todo_upgrade import TodoUpgrade # noqa: E402
|
||||
|
||||
|
||||
class PromptCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.addCleanup(
|
||||
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
|
||||
)
|
||||
todo_i18n._current_lang = "en"
|
||||
|
||||
def run_prompt(self, answer, lst_key=None):
|
||||
"""(commandes lancées, texte affiché) après cette réponse."""
|
||||
import contextlib
|
||||
import io
|
||||
|
||||
if lst_key is None:
|
||||
lst_key = ["website_sale.product", "web.layout", "website_blog.x"]
|
||||
upgrade = TodoUpgrade.__new__(TodoUpgrade)
|
||||
upgrade.dct_progression = {}
|
||||
upgrade.lst_command_executed = []
|
||||
upgrade.write_config = lambda: None
|
||||
upgrade.stale_cow_keys = lambda db: lst_key
|
||||
lst_cmd = []
|
||||
upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0
|
||||
upgrade.ask_gate = lambda prompt: answer
|
||||
out = io.StringIO()
|
||||
with contextlib.redirect_stdout(out):
|
||||
upgrade.prompt_reset_stale_cow_views("db")
|
||||
return lst_cmd, out.getvalue()
|
||||
|
||||
|
||||
class TestTheMenu(PromptCase):
|
||||
def test_every_key_is_numbered(self):
|
||||
# C'est tout l'objet : on ne connaît pas les clés de tête.
|
||||
_cmd, text = self.run_prompt("")
|
||||
self.assertIn("[1] website_sale.product", text)
|
||||
self.assertIn("[2] web.layout", text)
|
||||
self.assertIn("[3] website_blog.x", text)
|
||||
|
||||
def test_all_is_offered(self):
|
||||
_cmd, text = self.run_prompt("")
|
||||
self.assertIn("[a]", text)
|
||||
|
||||
def test_nothing_drifted_asks_nothing(self):
|
||||
lst_cmd, text = self.run_prompt("a", lst_key=[])
|
||||
self.assertEqual(lst_cmd, [])
|
||||
self.assertIn("No COW copy", text)
|
||||
|
||||
|
||||
class TestWhatEachAnswerRuns(PromptCase):
|
||||
def test_a_number_resets_that_key(self):
|
||||
lst_cmd, _ = self.run_prompt("2")
|
||||
self.assertEqual(len(lst_cmd), 1)
|
||||
self.assertIn("--reset web.layout", lst_cmd[0])
|
||||
self.assertIn("--apply", lst_cmd[0])
|
||||
self.assertNotIn("website_sale", lst_cmd[0])
|
||||
|
||||
def test_several_numbers_reset_several_keys(self):
|
||||
lst_cmd, _ = self.run_prompt("1,3")
|
||||
self.assertIn("--reset website_sale.product", lst_cmd[0])
|
||||
self.assertIn("--reset website_blog.x", lst_cmd[0])
|
||||
self.assertNotIn("web.layout", lst_cmd[0])
|
||||
|
||||
def test_a_resets_them_all(self):
|
||||
lst_cmd, _ = self.run_prompt("a")
|
||||
self.assertIn("--reset all", lst_cmd[0])
|
||||
|
||||
def test_empty_resets_nothing(self):
|
||||
# Par défaut on ne touche à rien : une copie porte parfois une vraie
|
||||
# personnalisation, et la réinitialiser la jette.
|
||||
lst_cmd, text = self.run_prompt("")
|
||||
self.assertEqual(lst_cmd, [])
|
||||
self.assertIn("Kept", text)
|
||||
|
||||
def test_an_out_of_range_number_resets_nothing(self):
|
||||
# Le piège serait de tomber en silence sur « all » ou sur la
|
||||
# première clé : on ne devine pas ce qui n'a pas été demandé.
|
||||
lst_cmd, text = self.run_prompt("9")
|
||||
self.assertEqual(lst_cmd, [])
|
||||
self.assertIn("Unknown choice", text)
|
||||
|
||||
def test_garbage_resets_nothing(self):
|
||||
lst_cmd, text = self.run_prompt("zzz")
|
||||
self.assertEqual(lst_cmd, [])
|
||||
self.assertIn("Unknown choice", text)
|
||||
|
||||
|
||||
class TestTheKeysComeFromTheTool(unittest.TestCase):
|
||||
"""La liste est demandée à l'outil, pas devinée."""
|
||||
|
||||
def test_list_keys_prints_only_keys(self):
|
||||
script = os.path.join(
|
||||
REPO, "script", "odoo", "migration", "reset_stale_cow_views.py"
|
||||
)
|
||||
with open(script) as handle:
|
||||
source = handle.read()
|
||||
self.assertIn("--list-keys", source)
|
||||
self.assertIn('print(cow_view["key"])', source)
|
||||
|
||||
def test_a_dead_database_yields_no_key(self):
|
||||
# Sans ce garde-fou, un menu vide se lirait comme « rien n'a dérivé ».
|
||||
upgrade = TodoUpgrade.__new__(TodoUpgrade)
|
||||
upgrade.dct_progression = {}
|
||||
upgrade.lst_command_executed = []
|
||||
upgrade.write_config = lambda: None
|
||||
upgrade.todo_upgrade_execute = lambda cmd, **kw: (2, cmd, [])
|
||||
self.assertEqual(upgrade.stale_cow_keys("nope"), [])
|
||||
|
||||
def test_the_error_prompt_offers_it(self):
|
||||
import inspect
|
||||
|
||||
source = inspect.getsource(TodoUpgrade.todo_upgrade_execute)
|
||||
self.assertIn("prompt_reset_stale_cow_views", source)
|
||||
self.assertIn("Reset one of them onto its module view", source)
|
||||
|
||||
|
||||
class TestTheToolAcceptsWhatWeSend(unittest.TestCase):
|
||||
def test_reset_can_be_repeated_and_accepts_all(self):
|
||||
done = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"./script/odoo/migration/reset_stale_cow_views.py",
|
||||
"--help",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=REPO,
|
||||
)
|
||||
self.assertEqual(done.returncode, 0)
|
||||
self.assertIn("--list-keys", done.stdout)
|
||||
self.assertIn("--reset", done.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Reference in a new issue