erplibre/script/odoo/migration/reset_stale_cow_tui.py
Mathieu Benoit 95ffbe6f13 [ADD] migration: browse the drifted COW copies full screen
The text report puts a thousand lines between the two things that decide:
what the copy holds that the module view does not — all a reset gives up
— and which child no longer finds its anchor, which is why anything
breaks. Space switches between them, « c » copies the reset command.

Offered as [4] on the error prompt, next to check and reset, and run on
the real terminal: a full screen started through the capturing executor
falls back to the text report with nothing to tell the two apart.

The diff is rendered in one place, shared with the command line. Two
renderings would drift without anything saying so.

--- FR ---

[ADD] migration : parcourir les copies COW dérivées en plein écran

Le rapport texte met mille lignes entre les deux choses qui décident :
ce que la copie porte et que la vue module n'a pas — tout ce qu'une
réinitialisation abandonne — et quel enfant ne trouve plus son ancrage,
la raison pour laquelle quoi que ce soit casse. L'espace bascule, « c »
copie la commande de réparation.

Proposé en [4] à l'invite d'erreur, à côté de vérifier et réinitialiser,
et lancé sur le vrai terminal : un plein écran passé par l'exécuteur qui
capture retombe sur le rapport texte, sans rien qui les distingue.

Le diff est rendu à un seul endroit, partagé avec la ligne de commande.
Deux rendus dériveraient sans que rien ne le signale.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00

198 lines
7 KiB
Python
Executable file
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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