A list of commands in flat text blends into its own headings the moment it outgrows the screen — which is exactly when it gets read. Commands are now cyan, steps bold blue, failed ones red, and each verdict wears the colour of its meaning: 1 is amber, because « there is something to look at » is not a crash and painting it red worries for nothing. Colour is refused three times over, and each refusal has cost someone: a log file full of escape codes, a grep that finds nothing, a terminal that prints them as text. A pipe is not a screen, and NO_COLOR is honoured. The full screen goes through Text.from_ansi, which does two things: it renders the colours, and it takes the rest literally. Textual parses Rich markup, so a command containing « [1] » was being eaten as a tag and vanished from the pane without a word. That was true before the colour. --- FR --- [ADD] état de migration : colorer les commandes, et savoir s'en abstenir Une liste de commandes en texte plat se confond avec ses titres dès qu'elle dépasse l'écran — et c'est justement quand elle le dépasse qu'on la lit. Les commandes sont en cyan, les étapes en bleu gras, celles qui ont échoué en rouge, et chaque verdict porte la couleur de son sens : 1 est ambre, car « il y a quelque chose à regarder » n'est pas une panne. La couleur se refuse trois fois, et chaque refus a coûté à quelqu'un : un fichier de journal truffé de codes d'échappement, un grep qui ne trouve plus rien, un terminal qui les affiche en clair. Le plein écran passe par Text.from_ansi, qui rend les couleurs ET prend le reste au pied de la lettre : une commande contenant « [1] » était avalée comme du balisage Rich et disparaissait sans un mot. Assisted-by: Claude Opus 5
224 lines
7.7 KiB
Python
224 lines
7.7 KiB
Python
#!/usr/bin/env python3
|
||
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||
|
||
"""L'état d'une migration, en plein écran.
|
||
|
||
Le rapport texte dit tout, mais il dit tout D'UN COUP : sur une migration
|
||
de six paliers il fait plusieurs centaines de lignes, et ce qu'on cherche —
|
||
l'étape où ça a cassé, ce que le test de fumée a conclu — se trouve
|
||
quelque part au milieu. Un écran qui se parcourt règle exactement cela.
|
||
|
||
Les données viennent de `migration_status`, comme le rapport texte. Deux
|
||
assemblages séparés dériveraient l'un de l'autre sans que rien ne le dise,
|
||
et l'on finirait par lire deux états contradictoires de la même migration.
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
|
||
sys.path.append(
|
||
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||
)
|
||
|
||
from script.todo import migration_status as status # 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: 4; padding: 0 1; background: $panel; color: $text; }
|
||
#body { height: 1fr; }
|
||
#left { width: 46; border-right: solid $accent; }
|
||
#pane { width: 1fr; padding: 0 1; }
|
||
"""
|
||
|
||
|
||
def head_text(dct):
|
||
"""Ce qui identifie la migration. Toujours visible, jamais à chercher."""
|
||
info = status.overview(dct)
|
||
lst_test = status.tests_summary(dct)
|
||
casse = [x for x in lst_test if x.get("status")]
|
||
return (
|
||
f"{info['database']} · {info['file']}\n"
|
||
f"{t('current step')} : {info['step']}\n"
|
||
f"{t('last written')} : {info['updated']}"
|
||
f" · {len(status.failures(dct))} {t('failed')}"
|
||
f" · {len(casse)}/{len(lst_test)} {t('Test results')}"
|
||
)
|
||
|
||
|
||
def rows(dct):
|
||
"""Les lignes du panneau de gauche : tests d'abord, puis les étapes.
|
||
|
||
Les tests en tête parce que c'est la question qu'on se pose en ouvrant
|
||
cet écran ; les étapes ensuite parce que c'est là qu'on cherche le
|
||
détail une fois qu'on sait QUOI chercher.
|
||
"""
|
||
lst = []
|
||
for item in status.tests_summary(dct):
|
||
icone, _phrase = status.verdict(item.get("status"))
|
||
lst.append(
|
||
{
|
||
"kind": "test",
|
||
"label": f"{icone} {item['name']}",
|
||
"detail": str(item.get("runs", 1)),
|
||
"data": item,
|
||
}
|
||
)
|
||
for section in status.journal_by_step(dct):
|
||
lst.append(
|
||
{
|
||
"kind": "step",
|
||
"label": section["step"],
|
||
"detail": str(len(section["lst_cmd"])),
|
||
"data": section,
|
||
}
|
||
)
|
||
return lst
|
||
|
||
|
||
def pane_text(dct, row, colour=False):
|
||
"""Le détail de la ligne choisie.
|
||
|
||
Le coloriage passe par de l'ANSI, que Rich sait décoder — et qui rend
|
||
au passage le texte LITTÉRAL : une commande contenant « [1] » était
|
||
jusqu'ici prise pour du balisage Rich et avalée sans un mot.
|
||
"""
|
||
if row is None:
|
||
return t("Nothing to show yet.")
|
||
if row["kind"] == "test":
|
||
item = row["data"]
|
||
icone, phrase = status.verdict(item.get("status"))
|
||
teinte = status.VERDICT_COLOUR.get(item.get("status"), "dim")
|
||
lignes = [
|
||
f"{icone} {status.paint(item['name'], teinte, colour)}",
|
||
f" {phrase} ({t('exit code')} {item.get('status')})",
|
||
f" {t('runs')} : {item.get('runs')}",
|
||
f" {t('current step')} : {item.get('step') or '?'}",
|
||
f" {item.get('at') or ''}",
|
||
]
|
||
return "\n".join(lignes)
|
||
section = row["data"]
|
||
lignes = [status.paint(section["step"], "step", colour), ""]
|
||
lst_failure = [
|
||
item
|
||
for item in status.failures(dct)
|
||
if (item.get("step") or "") == section["step"]
|
||
]
|
||
if lst_failure:
|
||
lignes.append(f"❌ {t('Commands that failed')} :")
|
||
for item in lst_failure:
|
||
lignes.append(
|
||
f" {status.paint(item.get('name') or '', 'fail', colour)}"
|
||
)
|
||
lignes.append("")
|
||
for cmd in section["lst_cmd"]:
|
||
lignes.append(f"· {status.paint(cmd, 'cmd', colour)}")
|
||
# La SORTIE des commandes, relue sur disque. C'est ce qui manquait :
|
||
# la liste des commandes dit ce qui a été lancé, jamais ce que cela a
|
||
# répondu — et c'est la réponse qu'on vient chercher.
|
||
tail = status.step_log_tail(dct, section["step"])
|
||
if tail:
|
||
lignes.append("")
|
||
lignes.append(f"── {t('server log')} ──")
|
||
lignes.extend(tail)
|
||
elif not section["lst_cmd"]:
|
||
lignes.append(t("No tool has run yet."))
|
||
return "\n".join(lignes)
|
||
|
||
|
||
def build_app(dct):
|
||
"""Textual est importé ICI, pas au chargement du module.
|
||
|
||
Le module reste importable — donc testable — sur une machine sans
|
||
Textual, et c'est aussi ce qui permet à l'appelant de retomber sur le
|
||
rapport texte plutôt que d'échouer.
|
||
"""
|
||
from textual.app import App, ComposeResult
|
||
from textual.containers import Horizontal, VerticalScroll
|
||
from textual.widgets import DataTable, Footer, Header, Static
|
||
|
||
class StatusApp(App):
|
||
CSS = globals()["CSS"]
|
||
BINDINGS = [("q,escape", "quit", "Quit")]
|
||
|
||
def __init__(self, dct):
|
||
super().__init__()
|
||
self.dct = dct
|
||
self.lst_row = rows(dct)
|
||
self.index = 0
|
||
|
||
def compose(self) -> ComposeResult:
|
||
yield Header()
|
||
yield Static("", id="head")
|
||
with Horizontal(id="body"):
|
||
yield DataTable(id="left", cursor_type="row")
|
||
with VerticalScroll(id="pane"):
|
||
yield Static("", id="content")
|
||
yield Footer()
|
||
|
||
def on_mount(self):
|
||
self.title = t("Migration state")
|
||
table = self.query_one("#left", DataTable)
|
||
table.add_columns(t("Test results"), "#")
|
||
for row in self.lst_row:
|
||
table.add_row(row["label"][:38], row["detail"])
|
||
self.query_one("#head", Static).update(head_text(self.dct))
|
||
self._show()
|
||
|
||
def _show(self):
|
||
from rich.text import Text
|
||
|
||
row = self.lst_row[self.index] if self.lst_row else None
|
||
# `from_ansi` fait DEUX choses : il rend les couleurs, et il
|
||
# traite le reste comme du texte LITTÉRAL. Sans lui, une
|
||
# commande contenant « [1] » passait pour du balisage Rich et
|
||
# disparaissait de l'écran sans que rien ne le signale.
|
||
self.query_one("#content", Static).update(
|
||
Text.from_ansi(pane_text(self.dct, row, colour=True))
|
||
)
|
||
|
||
def on_data_table_row_highlighted(self, event):
|
||
if event.data_table.id == "left" and self.lst_row:
|
||
self.index = event.cursor_row
|
||
self._show()
|
||
|
||
return StatusApp(dct)
|
||
|
||
|
||
def run_tui(dct, run_app=True):
|
||
"""Ouvrir l'écran. False si l'on n'a pas pu — et alors on DIT pourquoi.
|
||
|
||
Se taire ferait réafficher le rapport texte à la place de l'écran
|
||
demandé, sans rien qui distingue les deux.
|
||
"""
|
||
if not dct:
|
||
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.ensure():
|
||
return False
|
||
try:
|
||
app = build_app(dct)
|
||
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
|