erplibre/script/odoo/migration/check_stale_scss_tui.py
Mathieu Benoit 26de9a109f [ADD] migration: read the stale SCSS, then fix it, without leaving the tool
The report named the attachment and printed a command to paste. Deciding
« reset it » still meant accepting to lose one did not know what: the
diff against the module file is the only thing resetting gives up.

The tool now shows it and asks. --diff prints it, --tui browses it,
--apply resets without asking; with no flag and a terminal it asks, and
looking does not answer — the prompt comes back after each read.
--apply writes the copies under private/ first: reset_asset deletes the
attachment, so without that the customized lines would be nowhere.

The migration runs it on the real terminal, or none of this would be
reachable from there — the same pipe that was closing the TUI.

--- FR ---

[ADD] migration : lire le SCSS périmé, puis le corriger, sans sortir

Le rapport nommait la pièce jointe et imprimait une commande à coller.
Répondre « réinitialise » revenait encore à accepter de perdre on ne
sait quoi : l'écart avec le fichier du module est la seule chose que la
réinitialisation abandonne.

L'outil le montre et pose la question. --diff l'imprime, --tui le
parcourt, --apply réinitialise sans demander ; sans drapeau et devant
un terminal il demande, et regarder ne répond pas — l'invite revient
après chaque lecture. --apply écrit d'abord les copies sous private/ :
reset_asset supprime la pièce jointe, sans quoi les lignes
personnalisées ne seraient plus nulle part.

La migration le lance sur le vrai terminal, sinon rien de tout cela n'y
serait atteignable — le tube même qui fermait la TUI.

Assisted-by: Claude Opus 5
(cherry picked from commit 9e6c88622a74f7be408a8b1c523838eeadda1da3)
2026-08-22 07:23:59 -04:00

207 lines
7.1 KiB
Python
Executable file
Raw 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 customized SCSS a version bump would break.
Two things have to be read before answering « reset it »: what the copy
changed — the only thing resetting gives up — and which variables the target
no longer defines, which is why it breaks at all. Space switches between them.
The text report says the same, but a 1000-line diff in a terminal scrollback
is not something anyone reads before deciding.
"""
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 check_stale_scss import render_diff, reset_command # 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; }
#files { width: 42; border-right: solid $accent; }
#pane { width: 1fr; padding: 0 1; }
"""
def render_missing(finding):
"""Pourquoi ça casse : ce que la cible ne définit plus."""
lines = [
f"── id={finding['id']} {finding['url']} ──",
"",
f" {t('This copy uses variables that')} {finding['version_dir']}"
f" {t('no longer defines')} :",
"",
]
for name in finding["missing"]:
lines.append(f" ${name}")
lines += [
"",
f" {t('The copy was written against an older version and frozen')}",
f" {t('there. The module has since renamed what it relies on.')}",
"",
f" {t('Resetting restores')} :",
f" {finding['module_path'] or t('(nothing: the target no longer ships this file)')}",
]
return "\n".join(lines)
def build_app(lst_finding):
"""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 StaleScssApp(App):
CSS = globals()["CSS"]
BINDINGS = [
("q,escape", "quit", "Quit"),
("space,tab", "toggle", "Diff / why"),
("c", "copy", "Copy reset command"),
]
def __init__(self, lst_finding):
super().__init__()
self.lst_finding = lst_finding
self.why = False
self.index = 0
def compose(self) -> ComposeResult:
yield Header()
yield Static("", id="head")
with Horizontal(id="body"):
yield DataTable(id="files", cursor_type="row")
with VerticalScroll(id="pane"):
yield Static("", id="content")
yield Footer()
def on_mount(self):
self.title = "Customized SCSS at risk"
table = self.query_one("#files", DataTable)
table.add_columns("file", "+/-", "missing")
for finding in self.lst_finding:
table.add_row(
os.path.basename(finding["url"])[:30],
self._weight(finding),
str(len(finding["missing"])),
key=str(finding["id"]),
)
self._show()
def _weight(self, finding):
"""Combien la copie s'écarte, pour trier d'un coup d'œil."""
if not finding["module_path"]:
return "—"
import difflib
diff = list(
difflib.unified_diff(
finding["module_content"].splitlines(),
finding["custom"].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 _show(self):
if not self.lst_finding:
return
finding = self.lst_finding[self.index]
which = "why it breaks" if self.why else "what the copy changed"
self.query_one("#head", Static).update(
f"{finding['url']} · id={finding['id']}\n"
f"[{which}] — space to switch, c to copy the reset command"
)
render = render_missing if self.why else render_diff
self.query_one("#content", Static).update(render(finding))
def on_data_table_row_highlighted(self, event):
if event.data_table.id == "files" 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éparation dans le presse-papier.
Sans cela il faut la recopier à la main depuis un plein écran,
avec deux arguments dont une faute ne se voit pas.
"""
finding = self.lst_finding[self.index]
command = reset_command([finding], finding["database"])
try:
self.copy_to_clipboard(command)
self.notify(t("Reset command copied."))
except Exception:
self.notify(command, timeout=20)
return StaleScssApp(lst_finding)
def run_tui(lst_finding, 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)
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