diff --git a/script/odoo/migration/reset_stale_cow_tui.py b/script/odoo/migration/reset_stale_cow_tui.py new file mode 100755 index 0000000..766f90f --- /dev/null +++ b/script/odoo/migration/reset_stale_cow_tui.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Full-screen browsing of the COW copies that drifted from their module view. + +Two things decide whether to reset a copy, and the text report puts them a +thousand lines apart: what the copy holds that the module view does not — the +only thing a reset gives up — and which child no longer finds its anchor, +which is why anything breaks at all. Space switches between them. + +The reset command is on « c »: it takes a key, and copying a key by hand out +of a scrolled diff is where a character goes missing. A key matching no copy +is not an error for the tool — it runs and does nothing. +""" + +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__), "..", "..", "..")) +) + +from reset_stale_cow_views import render_broken, render_diff # noqa: E402 + +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; } +#head { height: 3; padding: 0 1; background: $panel; color: $text; } +#body { height: 1fr; } +#copies { width: 44; border-right: solid $accent; } +#pane { width: 1fr; padding: 0 1; } +""" + + +def weight(cow_view, module_view): + """Combien la copie s'écarte, pour trier d'un coup d'œil.""" + if not module_view: + return "—" + import difflib + + diff = list( + difflib.unified_diff( + module_view["arch"].splitlines(), + cow_view["arch"].splitlines(), + lineterm="", + n=0, + ) + ) + plus = sum(1 for x in diff if x[:1] == "+" and not x.startswith("+++")) + minus = sum(1 for x in diff if x[:1] == "-" and not x.startswith("---")) + return f"+{plus}/-{minus}" + + +def build_app(lst_finding, database): + """Build the application. Textual is imported here, not at module level. + + The module stays importable — and therefore testable — on a machine + without Textual, which is also what lets the caller fall back to the text + report rather than fail. + """ + from textual.app import App, ComposeResult + from textual.containers import Horizontal, VerticalScroll + from textual.widgets import DataTable, Footer, Header, Static + + class StaleCowApp(App): + CSS = globals()["CSS"] + BINDINGS = [ + ("q,escape", "quit", "Quit"), + ("space,tab", "toggle", "Diff / why"), + ("c", "copy", "Copy reset command"), + ] + + def __init__(self, lst_finding, database): + super().__init__() + self.lst_finding = lst_finding + self.database = database + self.why = False + self.index = 0 + + def compose(self) -> ComposeResult: + yield Header() + yield Static("", id="head") + with Horizontal(id="body"): + yield DataTable(id="copies", cursor_type="row") + with VerticalScroll(id="pane"): + yield Static("", id="content") + yield Footer() + + def on_mount(self): + self.title = "COW copies drifted from their module view" + table = self.query_one("#copies", DataTable) + table.add_columns("copy", "+/-", "broken") + for cow_view, module_view, broken in self.lst_finding: + table.add_row( + (cow_view["key"] or str(cow_view["id"]))[:30], + weight(cow_view, module_view), + str(len(broken)), + key=str(cow_view["id"]), + ) + self._show() + + def _show(self): + if not self.lst_finding: + return + cow_view, module_view, broken = self.lst_finding[self.index] + which = "why it breaks" if self.why else "what the copy holds" + self.query_one("#head", Static).update( + f"{cow_view['key']} · id={cow_view['id']}" + f" · website={cow_view.get('website_id')}\n" + f"[{which}] — space to switch, c to copy the reset command" + ) + if self.why: + text = render_broken(cow_view, module_view, broken) + elif module_view: + text = render_diff(module_view, cow_view, indent=" ") + else: + text = ( + f" {t('No module view carries this key: nothing to reset')}" + f" {t('onto.')}" + ) + self.query_one("#content", Static).update(text) + + def on_data_table_row_highlighted(self, event): + if event.data_table.id == "copies" and self.lst_finding: + self.index = event.cursor_row + self._show() + + def action_toggle(self): + self.why = not self.why + self._show() + + def action_copy(self): + """Mettre la commande de réinitialisation dans le presse-papier. + + Elle prend une CLÉ, et recopier une clé à la main depuis un diff + défilé est l'endroit où un caractère se perd — l'outil ne dirait + rien, il ne trouverait simplement aucune copie. + """ + cow_view = self.lst_finding[self.index][0] + command = ( + "./script/odoo/migration/reset_stale_cow_views.py" + f" -d {self.database} --reset {cow_view['key']} --apply" + ) + try: + self.copy_to_clipboard(command) + self.notify(t("Reset command copied.")) + except Exception: + self.notify(command, timeout=20) + + return StaleCowApp(lst_finding, database) + + +def run_tui(lst_finding, database, run_app=True): + """Ouvrir l'écran. False si on n'a pas pu — et alors on DIT pourquoi. + + Trois refus, trois raisons, aucune n'est une panne : rien à montrer, pas + de terminal, ou Textual absent. Se taire ferait réafficher le rapport + texte à la place de l'écran demandé, sans rien qui distingue les deux. + """ + 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(): + 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, database) + except ImportError: + print( + f"ℹ️ {t('Textual is missing from this interpreter:')}" + f" {sys.executable}" + ) + return False + if not run_app: + return app + app.run() + return True diff --git a/script/odoo/migration/reset_stale_cow_views.py b/script/odoo/migration/reset_stale_cow_views.py index f4cc956..62bb2ee 100755 --- a/script/odoo/migration/reset_stale_cow_views.py +++ b/script/odoo/migration/reset_stale_cow_views.py @@ -248,8 +248,13 @@ def find_copy_by_key(views, key): return copy, twin -def show_diff(module_view, cow_view): - """Module arch vs copy: what the copy would gain and lose on a reset.""" +def render_diff(module_view, cow_view, indent=" "): + """Module arch vs copy : ce qu'une réinitialisation rend et abandonne. + + Rendu en texte plutôt qu'imprimé : la TUI montre exactement le même + diff que la ligne de commande. Deux rendus séparés dériveraient sans + que rien ne le signale. + """ diff = difflib.unified_diff( module_view["arch"].splitlines(), cow_view["arch"].splitlines(), @@ -257,8 +262,44 @@ def show_diff(module_view, cow_view): tofile=f"cow id={cow_view['id']}", lineterm="", ) - for line in diff: - print(f" {line}") + return "\n".join(f"{indent}{line}" for line in diff) + + +def render_broken(cow_view, module_view, broken): + """Pourquoi ça casse : les enfants dont l'xpath ne trouve plus son point.""" + lines = [ + f"── id={cow_view['id']} {cow_view['key']}" + f" (website={cow_view.get('website_id')}) ──", + "", + ] + if not broken: + lines.append( + f" {t('No child fails on this copy: it drifted without')}" + f" {t('breaking anything yet.')}" + ) + else: + lines.append( + f" {t('These children no longer find their anchor in the copy')}" + " :" + ) + for child_id, expr in broken: + lines.append(f" {t('child')} {child_id} : {expr}") + lines += [ + "", + f" {t('The anchor exists in the module view')}" + f" (id={module_view['id'] if module_view else '?'})" + f" {t('but not in this copy, frozen on an older version.')}", + "", + f" {t('Resetting restores the module arch; the customization the')}", + f" {t('copy carried is saved first, and is yours to re-apply as an')}", + f" {t('INHERITING view with its own key.')}", + ] + return "\n".join(lines) + + +def show_diff(module_view, cow_view): + """Imprimer le diff, pour la ligne de commande.""" + print(render_diff(module_view, cow_view)) def backup(database, cow_view, directory): @@ -304,6 +345,11 @@ def main(): ) ) parser.add_argument("-d", "--database", required=True) + parser.add_argument( + "--tui", + action="store_true", + help="browse the differences full screen", + ) parser.add_argument( "--list-keys", action="store_true", @@ -381,6 +427,13 @@ def main(): ) return 1 + if config.tui and findings: + from reset_stale_cow_tui import run_tui + + # False n'est pas un échec : l'écran a dit pourquoi, et le rapport + # texte ci-dessus porte déjà la même information. + run_tui(findings, config.database) + wanted = set(config.reset) directory = config.backup_dir or os.path.join( "private", "odoo", "migration", config.database, "cow_reset" diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index df0712f..6529d4a 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -5094,6 +5094,50 @@ TRANSLATIONS = { "fr": "après — et ce ne sera pas la migration.", "en": "after — and that is not the migration.", }, + "No child fails on this copy: it drifted without": { + "fr": "Aucun enfant n'échoue sur cette copie : elle a dérivé sans", + "en": "No child fails on this copy: it drifted without", + }, + "breaking anything yet.": { + "fr": "encore rien casser.", + "en": "breaking anything yet.", + }, + "These children no longer find their anchor in the copy": { + "fr": "Ces enfants ne trouvent plus leur point d'ancrage dans la copie", + "en": "These children no longer find their anchor in the copy", + }, + "The anchor exists in the module view": { + "fr": "L'ancrage existe dans la vue module", + "en": "The anchor exists in the module view", + }, + "but not in this copy, frozen on an older version.": { + "fr": "mais pas dans cette copie, figée sur une version antérieure.", + "en": "but not in this copy, frozen on an older version.", + }, + "Resetting restores the module arch; the customization the": { + "fr": "Réinitialiser rend l'arch du module ; la personnalisation que", + "en": "Resetting restores the module arch; the customization the", + }, + "copy carried is saved first, and is yours to re-apply as an": { + "fr": "la copie portait est sauvegardée d'abord, à vous de la réappliquer en", + "en": "copy carried is saved first, and is yours to re-apply as an", + }, + "INHERITING view with its own key.": { + "fr": "vue HÉRITANTE avec sa propre clé.", + "en": "INHERITING view with its own key.", + }, + "No module view carries this key: nothing to reset": { + "fr": "Aucune vue module ne porte cette clé : rien sur quoi", + "en": "No module view carries this key: nothing to reset", + }, + "onto.": { + "fr": "réinitialiser.", + "en": "onto.", + }, + "Browse the differences full screen": { + "fr": "Parcourir les différences en plein écran", + "en": "Browse the differences full screen", + }, "Nothing to decide yet": { "fr": "Rien à décider pour l'instant", "en": "Nothing to decide yet", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index de79827..260c376 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -3412,6 +3412,7 @@ class TodoUpgrade: f" ({database_name})" ) print(f"[3] {t('Reset one of them onto its module view')}") + print(f"[4] {t('Browse the differences full screen')}") wait_status = ( input( f"💬 {t('Error detected, press enter to continue or')}" @@ -3434,6 +3435,18 @@ class TodoUpgrade: if wait_status == "3" and database_name: self.prompt_reset_stale_cow_views(database_name) continue + if wait_status == "4" and database_name: + # Sur le VRAI terminal : un plein écran refuse de + # s'ouvrir sur un stdout capturé, et retomberait sur le + # rapport texte sans que rien ne distingue les deux. + self.run_on_terminal( + f"{PYTHON_BIN} ./" + + os.path.join( + PATH_MIGRATION_GLOBAL, "reset_stale_cow_views.py" + ) + + f" -d {database_name} --tui" + ) + continue break if wait_status == "1": diff --git a/test/test_reset_stale_cow_tui.py b/test/test_reset_stale_cow_tui.py new file mode 100755 index 0000000..2b0296b --- /dev/null +++ b/test/test_reset_stale_cow_tui.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Lire une copie COW avant d'accepter de la perdre, en plein écran. + +Le rapport texte met à mille lignes d'écart les deux choses qui décident : +ce que la copie porte en propre — la seule chose qu'une réinitialisation +abandonne — et quel enfant ne trouve plus son ancrage, qui est la raison +pour laquelle quoi que ce soit casse. L'espace bascule entre les deux. + +La commande de réparation prend une CLÉ. La recopier à la main depuis un +diff défilé est l'endroit où un caractère se perd, et une clé sans +correspondance n'est pas une erreur pour l'outil : il tourne et ne fait +rien. D'où « c ». +""" + +import os +import sys +import unittest + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(REPO, "script", "odoo", "migration")) + +import reset_stale_cow_tui as tui # noqa: E402 +import reset_stale_cow_views as reset # noqa: E402 + + +def finding(**override): + cow = { + "id": 2841, + "key": "website_blog.blog_post_complete", + "website_id": 1, + "arch": "\n
\n vieux\n", + } + module = { + "id": 1826, + "key": "website_blog.blog_post_complete", + "website_id": None, + "arch": "\n
\n
\n", + } + broken = [(3288, "//section[@id='o_wblog_post_footer']")] + if "cow" in override: + cow.update(override["cow"]) + return cow, override.get("module", module), override.get("broken", broken) + + +class TestBothScreens(unittest.TestCase): + def setUp(self): + from script.todo import todo_i18n + + self.addCleanup( + setattr, todo_i18n, "_current_lang", todo_i18n._current_lang + ) + todo_i18n._current_lang = "en" + + def test_the_diff_shows_what_the_copy_holds(self): + cow, module, _broken = finding() + text = reset.render_diff(module, cow, indent=" ") + self.assertIn("vieux", text) + self.assertIn("o_wblog_post_footer", text) + + def test_the_why_screen_names_the_failing_child(self): + # Sans lui, on lit un diff sans savoir pourquoi il compte. + text = tui.render_broken(*finding()) + self.assertIn("3288", text) + self.assertIn("//section[@id='o_wblog_post_footer']", text) + + def test_the_why_screen_says_what_a_reset_costs(self): + text = tui.render_broken(*finding()) + self.assertIn("INHERITING", text) + + def test_a_copy_without_broken_child_is_said_so(self): + # Elle a dérivé sans rien casser ENCORE : le taire ferait croire à + # une erreur de détection. + cow, module, _ = finding() + text = tui.render_broken(cow, module, []) + self.assertIn("without", text) + + def test_the_two_screens_use_the_same_diff_as_the_cli(self): + # Deux rendus séparés dériveraient sans que rien ne le signale. + import inspect + + source = inspect.getsource(tui) + self.assertIn("from reset_stale_cow_views import", source) + self.assertIn("render_diff", source) + + +class TestTheTriageColumn(unittest.TestCase): + def test_the_weight_counts_both_directions(self): + cow, module, _ = finding() + self.assertEqual(tui.weight(cow, module), "+1/-1") + + def test_no_module_twin_has_no_weight(self): + cow, _module, _ = finding() + self.assertEqual(tui.weight(cow, None), "—") + + +class TestTheRefusalIsNeverSilent(unittest.TestCase): + """Un refus muet ferait réafficher le rapport texte sans le dire.""" + + def test_a_pipe_is_explained(self): + import io + from contextlib import redirect_stdout + + out = io.StringIO() + with redirect_stdout(out): + result = tui.run_tui([finding()], "db") + self.assertFalse(result) + self.assertTrue(out.getvalue().strip(), "refus muet") + + def test_nothing_to_show_stays_silent(self): + import io + from contextlib import redirect_stdout + + out = io.StringIO() + with redirect_stdout(out): + self.assertFalse(tui.run_tui([], "db")) + self.assertEqual(out.getvalue(), "") + + +class TestTheToolAndTheMigrationOfferIt(unittest.TestCase): + def test_the_tool_has_a_tui_flag(self): + import subprocess + + 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("--tui", done.stdout) + + def test_the_error_prompt_offers_it_as_option_four(self): + import inspect + + from script.todo.todo_upgrade import TodoUpgrade + + source = inspect.getsource(TodoUpgrade.todo_upgrade_execute) + self.assertIn("Browse the differences full screen", source) + self.assertIn('wait_status == "4"', source) + + def test_it_gets_a_real_terminal(self): + # Un plein écran lancé par l'exécuteur qui capture retomberait sur + # le rapport texte, sans que rien ne distingue les deux. + import inspect + + from script.todo.todo_upgrade import TodoUpgrade + + source = inspect.getsource(TodoUpgrade.todo_upgrade_execute) + start = source.index('wait_status == "4"') + window = source[start : start + 620] + self.assertIn("run_on_terminal", window) + self.assertIn("--tui", window) + + +if __name__ == "__main__": + unittest.main()