erplibre/script/analyse/analyse_diff_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

189 lines
6.8 KiB
Python
Executable file

#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Naviguer dans les différences entre une vue en base et celle du module.
Lecture seule, sans exception. La touche « r » AFFICHE la commande de
réinitialisation, elle ne l'exécute pas : cet écran sert à décider, et décider
suppose d'avoir lu. Une vue personnalisée porte souvent un travail réel que
personne ne veut perdre d'un appui sur une touche.
Pourquoi un seul DataTable à trois colonnes
--------------------------------------------
Deux panneaux séparés ne défilent pas ensemble : il faudrait synchroniser
deux barres, et une ligne de gauche finirait en face de la mauvaise ligne de
droite — exactement l'erreur qu'un diff doit rendre impossible. Un seul
tableau porte « gauche | marque | droite » sur la même ligne, donc
l'alignement est structurel et non entretenu. Il donne aussi le curseur, la
sélection et l'événement de survol sans rien écrire.
``TextArea`` a été écarté : c'est un éditeur, et il n'y a pas de coloration
XML sans tree-sitter, absent du venv.
"""
import os
import sys
new_path = os.path.normpath(
os.path.join(os.path.dirname(__file__), "..", "..")
)
sys.path.append(new_path)
from script.analyse.lib_analyse import side_by_side, t # noqa: E402
CSS = """
Screen { layout: vertical; }
#head { height: 3; padding: 0 1; background: $panel; color: $text; }
#body { height: 1fr; }
#views { width: 34; border-right: solid $accent; }
#diff { width: 1fr; }
"""
def diff_rows(finding):
"""Les lignes en écart d'un constat, sans les lignes identiques.
Le contexte est utile dans un diff unifié qu'on lit en entier ; ici on
saute d'un écart à l'autre, et les centaines de lignes identiques d'une
vue de site web ne feraient que les éloigner.
"""
return [
(mark, left, right)
for mark, left, right in side_by_side(
finding.get("arch_ref"), finding.get("arch_db_text")
)
if mark != " "
]
def build_app(data):
"""Construire l'application Textual. Importe Textual seulement ici.
L'import vit dans la fonction pour que le module reste importable — et
donc testable — sur une machine sans Textual.
"""
from textual.app import App, ComposeResult
from textual.containers import Horizontal
from textual.widgets import DataTable, Footer, Header, Static
class DiffApp(App):
CSS = globals()["CSS"]
BINDINGS = [
("q,escape", "quit", t("Quit")),
("n", "only_diff", t("Differences only")),
("w", "ignore_indent", t("Ignore indentation")),
("c", "copy", t("Copy")),
("r", "command", t("Reset command")),
]
def __init__(self, data):
super().__init__()
self.data = data
self.lst_finding = [
row for row in data["findings"] if row.get("differs")
]
self.only_diff = True
self.ignore_indent = False
self.intent = None
def compose(self) -> ComposeResult:
yield Header()
yield Static("", id="head")
with Horizontal(id="body"):
yield DataTable(id="views", cursor_type="row")
yield DataTable(id="diff", cursor_type="row")
yield Footer()
def on_mount(self):
self.title = t("Customised views")
table = self.query_one("#views", DataTable)
table.add_columns(t("view"), "+/-/≠")
for row in self.lst_finding:
stats = row.get("diff_stats") or {}
table.add_row(
(row.get("key") or str(row["id"]))[:26],
f"{stats.get('added', 0)}/{stats.get('removed', 0)}"
f"/{stats.get('changed', 0)}",
key=str(row["id"]),
)
diff = self.query_one("#diff", DataTable)
diff.add_columns(t("module (file)"), " ", t("database"))
if self.lst_finding:
self._show(0)
def _show(self, index):
row = self.lst_finding[index]
stats = row.get("diff_stats") or {}
self.query_one("#head", Static).update(
f"{row.get('key') or row['id']} · "
f"{row.get('arch_fs') or '—'}\n"
f"+{stats.get('added', 0)} -{stats.get('removed', 0)} "
f"≠{stats.get('changed', 0)} · "
f"{', '.join(row.get('reason') or []) or '—'}"
)
diff = self.query_one("#diff", DataTable)
diff.clear()
for mark, left, right in side_by_side(
row.get("arch_ref"), row.get("arch_db_text")
):
if self.only_diff and mark == " ":
continue
if self.ignore_indent:
left = (left or "").strip()
right = (right or "").strip()
diff.add_row(left or "", mark, right or "")
def on_data_table_row_highlighted(self, event):
if event.data_table.id == "views" and self.lst_finding:
self._show(event.cursor_row)
def _refresh(self):
table = self.query_one("#views", DataTable)
if self.lst_finding:
self._show(table.cursor_row)
def action_only_diff(self):
self.only_diff = not self.only_diff
self._refresh()
def action_ignore_indent(self):
self.ignore_indent = not self.ignore_indent
self._refresh()
def action_copy(self):
table = self.query_one("#views", DataTable)
if not self.lst_finding:
return
row = self.lst_finding[table.cursor_row]
text = "\n".join(
f"{mark} {left or ''} | {right or ''}"
for mark, left, right in diff_rows(row)
)
# Tronqué en gardant la FIN : c'est là que se trouve ce qu'on
# vient d'ajouter, donc ce qu'on cherche le plus souvent.
self.copy_to_clipboard(text[-100_000:])
self.notify(t("Difference copied."))
def action_command(self):
"""Rendre l'intention à l'appelant : l'écran n'écrit jamais."""
table = self.query_one("#views", DataTable)
if not self.lst_finding:
return
self.intent = ("command", self.lst_finding[table.cursor_row])
self.exit()
return DiffApp(data)
def run_diff_tui(data, run_app=True):
"""Ouvrir l'écran. Renvoie l'intention retenue, ou None.
``run_app=False`` construit l'application sans la lancer : c'est ce qui
permet de la tester sans terminal.
"""
app = build_app(data)
if not run_app:
return app
app.run()
return app.intent