erplibre/script/odoo/migration/cow_drift_tui.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

202 lines
7.1 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 at-risk website COW copies.
Read-only, without exception: this screen exists to decide, and deciding
supposes having looked. Neutralizing stays a separate, explicit command.
Two views of the same copy, one key apart
-----------------------------------------
« What do I lose » and « why does it break » are different questions with
different answers, and putting them side by side would halve the width of
each on a screen that already shows XML. They share the pane instead, and
``space`` switches — the header always says which one is showing, because a
diff and a declaration look alike at a glance.
"""
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__), "..", "..", ".."))
)
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; }
#views { width: 38; border-right: solid $accent; }
#pane { width: 1fr; padding: 0 1; }
"""
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
from cow_drift import render_diff, render_shape
class DriftApp(App):
CSS = globals()["CSS"]
BINDINGS = [
("q,escape", "quit", "Quit"),
("space,tab", "toggle", "Diff / declarations"),
("c", "copy", "Copy"),
]
def __init__(self, lst_finding):
super().__init__()
self.lst_finding = lst_finding
self.shape = False
self.index = 0
def compose(self) -> ComposeResult:
yield Header()
yield Static("", id="head")
with Horizontal(id="body"):
yield DataTable(id="views", cursor_type="row")
with VerticalScroll(id="pane"):
yield Static("", id="content")
yield Footer()
def on_mount(self):
self.title = "Website COW copies at risk"
table = self.query_one("#views", DataTable)
table.add_columns("copy", "+/-")
for finding in self.lst_finding:
table.add_row(
(finding["key"] or str(finding["id"]))[:28],
self._weight(finding),
key=str(finding["id"]),
)
self._show()
def _weight(self, finding):
"""How much the copy diverges, so the list can be triaged."""
if finding["module_id"] is None:
return "—"
import difflib
diff = list(
difflib.unified_diff(
finding["module_arch"].splitlines(),
finding["copy_arch"].splitlines(),
lineterm="",
n=0,
)
)
plus = sum(
1
for x in diff
if x.startswith("+") and not x.startswith("+++")
)
minus = sum(
1
for x in diff
if x.startswith("-") and not x.startswith("---")
)
return f"+{plus}/-{minus}"
def _show(self):
if not self.lst_finding:
return
finding = self.lst_finding[self.index]
which = "declarations" if self.shape else "what the copy changed"
self.query_one("#head", Static).update(
f"{finding['key']} · id={finding['id']}"
f" · website={finding['website_id']}\n"
f"[{which}] — space to switch"
)
render = render_shape if self.shape else render_diff
self.query_one("#content", Static).update(render(finding))
def on_data_table_row_highlighted(self, event):
if event.data_table.id == "views" and self.lst_finding:
self.index = event.cursor_row
self._show()
def action_toggle(self):
self.shape = not self.shape
self._show()
def action_copy(self):
if not self.lst_finding:
return
from cow_drift import render_diff, render_shape
render = render_shape if self.shape else render_diff
# Truncated keeping the END: that is where the added lines are,
# and what someone is most often after.
self.copy_to_clipboard(
render(self.lst_finding[self.index])[-100_000:]
)
self.notify("Copied.")
return DriftApp(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 faisait réafficher le rapport
texte à la place de l'écran demandé — la même sortie que la touche
précédente, sans rien qui distingue les deux.
Textual n'est installé que dans `.venv.erplibre`. Lancer ce script
directement, maintenant qu'il porte le bit d'exécution, prend le python
du système : le shebang ne connaît pas le venv. C'est le chemin qui rend
ce message nécessaire.
"""
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():
# Le conseil AVANT la proposition d'installer : Textual est déjà là,
# dans le venv. Proposer de l'installer une seconde fois sous
# « --user » répond à côté de la question.
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}"
)
print(f" {t('Run it with')} .venv.erplibre/bin/python3")
return False
if not run_app:
return app
app.run()
return True