[ADD] migration state: colour the commands, and know when not to

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
This commit is contained in:
Mathieu Benoit 2026-08-19 05:36:49 -04:00
parent 4e0bf7cc8b
commit 0f0f853be4
4 changed files with 253 additions and 13 deletions

View file

@ -29,6 +29,10 @@ urwid
# Borne recopiée de script/todo/textual_setup.py (TEXTUAL_SPEC) : les écrans TUI
# sont écrits pour Textual 8, qui casse son API entre majeures. Modifier les deux.
textual>=8,<9
# Dépendance DIRECTE, même si Textual l'installe : l'écran d'état décode
# l'ANSI avec rich.text.Text.from_ansi. Compter sur une dépendance
# transitive, c'est dépendre du jour où Textual changera la sienne.
rich
# Dépendance directe : les outils d'analyse comparent des arbres XML avec lxml.
# Il n'arrivait que par pykeepass / openupgradelib / odoo-module-migrator, donc
# un jour où l'un d'eux s'en passe, il disparaît sans que rien ne le réclame.

View file

@ -50,6 +50,49 @@ VERDICT = {
}
# Les couleurs ANSI, et le droit de s'en passer. `NO_COLOR` est une
# convention respectée par la plupart des outils : la contredire oblige à
# nettoyer une sortie à la main, ce qui est exactement ce qu'on cherchait à
# éviter en la coloriant.
ANSI = {
"cmd": "\033[36m", # cyan : ce qui a été LANCÉ
"step": "\033[1;34m", # bleu gras : les étapes
"ok": "\033[32m",
"warn": "\033[33m",
"fail": "\033[31m",
"dim": "\033[2m",
}
RESET = "\033[0m"
def supports_colour(stream=None):
"""Peut-on colorier CETTE sortie ?
Trois refus, et chacun 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. Un tube n'est pas un écran.
"""
if os.environ.get("NO_COLOR"):
return False
if os.environ.get("TERM", "") in ("", "dumb"):
return False
stream = stream or sys.stdout
try:
return bool(stream.isatty())
except Exception:
return False
def paint(texte, couleur, actif=True):
"""Colorier, ou rendre le texte tel quel. Jamais d'à-peu-près."""
if not actif or couleur not in ANSI:
return texte
return f"{ANSI[couleur]}{texte}{RESET}"
VERDICT_COLOUR = {0: "ok", 1: "warn", 2: "fail"}
def read(path=DEFAULT_PATH):
"""La progression, complétée par ce qui a été écrit SUR DISQUE.
@ -257,8 +300,15 @@ def bumps(dct):
]
def render_text(dct, limit_cmd=12):
"""Le rapport complet, en texte. C'est aussi le repli du plein écran."""
def render_text(dct, limit_cmd=12, colour=None):
"""Le rapport complet, en texte. C'est aussi le repli du plein écran.
`colour` à None laisse la sortie décider : un écran est colorié, un
tube ne l'est pas. Le forcer sert aux tests, qui doivent pouvoir
vérifier les deux sans dépendre de l'endroit où ils tournent.
"""
if colour is None:
colour = supports_colour()
if not dct:
return f"ℹ️ {t('No migration in progress.')}"
info = overview(dct)
@ -282,20 +332,33 @@ def render_text(dct, limit_cmd=12):
if item.get("runs", 1) > 1
else ""
)
lignes.append(f" {icone} {item['name']:<24} {phrase}{rejeu}")
teinte = VERDICT_COLOUR.get(item.get("status"), "dim")
nom = f"{item['name']:<24}"
lignes.append(
f" {icone} {paint(nom, teinte, colour)} {phrase}{rejeu}"
)
lst_failure = failures(dct)
lignes.append(f"\n❌ {t('Commands that failed')} : {len(lst_failure)}")
for item in lst_failure[:10]:
lignes.append(f" · [{item.get('step') or '?'}] {item.get('name')}")
lignes.append(
f" · [{item.get('step') or '?'}]"
f" {paint(item.get('name') or '', 'fail', colour)}"
)
lignes.append(f"\n🔷 {t('What was done, step by step')}")
for section in journal_by_step(dct):
lst_cmd = section["lst_cmd"]
journal = " 📄" if step_log_path(dct, section["step"]) else ""
lignes.append(f" {section['step']} ({len(lst_cmd)}){journal}")
lignes.append(
f" {paint(section['step'], 'step', colour)}"
f" ({len(lst_cmd)}){journal}"
)
for cmd in lst_cmd[:limit_cmd]:
lignes.append(f" {cmd[:120]}")
# LA demande : distinguer d'un coup d'œil ce qui a été lancé
# du reste du rapport. Une liste de commandes en texte plat se
# confond avec ses titres dès qu'elle dépasse l'écran.
lignes.append(f" {paint(cmd[:120], 'cmd', colour)}")
if len(lst_cmd) > limit_cmd:
reste = len(lst_cmd) - limit_cmd
lignes.append(f" … {reste} {t('more')}")

View file

@ -84,15 +84,21 @@ def rows(dct):
return lst
def pane_text(dct, row):
"""Le détail de la ligne choisie."""
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} {item['name']}",
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 '?'}",
@ -100,7 +106,7 @@ def pane_text(dct, row):
]
return "\n".join(lignes)
section = row["data"]
lignes = [f"{section['step']}", ""]
lignes = [status.paint(section["step"], "step", colour), ""]
lst_failure = [
item
for item in status.failures(dct)
@ -109,10 +115,12 @@ def pane_text(dct, row):
if lst_failure:
lignes.append(f"❌ {t('Commands that failed')} :")
for item in lst_failure:
lignes.append(f" {item.get('name')}")
lignes.append(
f" {status.paint(item.get('name') or '', 'fail', colour)}"
)
lignes.append("")
for cmd in section["lst_cmd"]:
lignes.append(f"· {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.
@ -166,8 +174,16 @@ def build_app(dct):
self._show()
def _show(self):
from rich.text import Text
row = self.lst_row[self.index] if self.lst_row else None
self.query_one("#content", Static).update(pane_text(self.dct, row))
# `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:

View file

@ -564,6 +564,163 @@ class TestTheCommandOutputItself(DiskCase):
self.assertIsNone(getattr(moteur, "log_sink", None))
class TestColour(Base):
"""Distinguer d'un coup d'œil ce qui a été LANCÉ du reste.
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.
"""
def rapport(self, colour):
dct = progression()
return status.render_text(dct, colour=colour)
def test_the_commands_are_coloured(self):
texte = self.rapport(True)
self.assertIn(status.ANSI["cmd"], texte)
def test_a_verdict_wears_the_colour_of_its_meaning(self):
# 1 n'est pas une panne : c'est « il y a quelque chose à regarder ».
# Le peindre en rouge inquiéterait pour rien.
self.assertEqual(status.VERDICT_COLOUR[0], "ok")
self.assertEqual(status.VERDICT_COLOUR[1], "warn")
self.assertEqual(status.VERDICT_COLOUR[2], "fail")
def test_a_failed_command_is_red(self):
self.assertIn(status.ANSI["fail"], self.rapport(True))
def test_every_colour_is_closed(self):
# Une séquence ouverte teinte tout ce qui suit, y compris l'invite
# du terminal une fois l'outil terminé.
texte = self.rapport(True)
ouvertures = sum(texte.count(code) for code in status.ANSI.values())
self.assertEqual(texte.count(status.RESET), ouvertures)
def test_plain_output_carries_no_escape_at_all(self):
self.assertNotIn("\033", self.rapport(False))
def test_paint_returns_the_text_untouched_when_off(self):
self.assertEqual(status.paint("make", "cmd", False), "make")
def test_an_unknown_colour_never_invents_one(self):
self.assertEqual(status.paint("make", "mauve", True), "make")
class TestWhenColourWouldBeAMistake(Base):
"""Un tube n'est pas un écran, et chacun de ces refus a coûté.
Un fichier de journal truffé de codes d'échappement, un `grep` qui ne
trouve plus rien, un terminal qui les affiche en clair.
"""
def setUp(self):
super().setUp()
self.avant = {cle: os.environ.get(cle) for cle in ("NO_COLOR", "TERM")}
def remettre():
for cle, valeur in self.avant.items():
if valeur is None:
os.environ.pop(cle, None)
else:
os.environ[cle] = valeur
self.addCleanup(remettre)
class FauxEcran:
def __init__(self, tty):
self.tty = tty
def isatty(self):
return self.tty
def test_a_pipe_gets_no_colour(self):
os.environ["TERM"] = "xterm"
os.environ.pop("NO_COLOR", None)
self.assertFalse(status.supports_colour(self.FauxEcran(False)))
def test_a_terminal_does(self):
os.environ["TERM"] = "xterm"
os.environ.pop("NO_COLOR", None)
self.assertTrue(status.supports_colour(self.FauxEcran(True)))
def test_NO_COLOR_is_honoured(self):
# Convention respectée par la plupart des outils : la contredire
# oblige à nettoyer une sortie à la main.
os.environ["TERM"] = "xterm"
os.environ["NO_COLOR"] = "1"
self.assertFalse(status.supports_colour(self.FauxEcran(True)))
def test_a_dumb_terminal_gets_none(self):
os.environ.pop("NO_COLOR", None)
os.environ["TERM"] = "dumb"
self.assertFalse(status.supports_colour(self.FauxEcran(True)))
def test_a_stream_that_cannot_answer_gets_none(self):
os.environ["TERM"] = "xterm"
os.environ.pop("NO_COLOR", None)
class Muet:
def isatty(self):
raise OSError("fermé")
self.assertFalse(status.supports_colour(Muet()))
def test_the_report_decides_by_itself_when_not_told(self):
import inspect
source = inspect.getsource(status.render_text)
self.assertIn("colour = supports_colour()", source)
class TestTheFullScreenColoursToo(Base):
def test_the_pane_can_be_coloured(self):
lst = tui.rows(progression())
etape = [x for x in lst if x["kind"] == "step"][0]
self.assertIn(
status.ANSI["cmd"],
tui.pane_text(progression(), etape, colour=True),
)
def test_it_stays_plain_by_default(self):
# `pane_text` sert aussi aux tests et au repli texte.
lst = tui.rows(progression())
etape = [x for x in lst if x["kind"] == "step"][0]
self.assertNotIn("\033", tui.pane_text(progression(), etape))
def test_rich_decodes_the_colours_instead_of_showing_them(self):
from rich.text import Text
lst = tui.rows(progression())
etape = [x for x in lst if x["kind"] == "step"][0]
texte = Text.from_ansi(
tui.pane_text(progression(), etape, colour=True)
)
self.assertNotIn("\033", texte.plain)
self.assertTrue(texte.spans)
def test_a_bracket_in_a_command_is_NOT_eaten_as_markup(self):
# Piège latent d'avant la couleur : Textual interprète le balisage
# Rich, et « [1] » disparaissait sans que rien ne le signale.
from rich.text import Text
dct = progression(
command_executed=["# 2 - Update", "make config [1] et [/bold]"]
)
lst = tui.rows(dct)
etape = [x for x in lst if x["kind"] == "step"][0]
texte = Text.from_ansi(tui.pane_text(dct, etape, colour=True))
self.assertIn("[1]", texte.plain)
self.assertIn("[/bold]", texte.plain)
def test_the_screen_goes_through_from_ansi(self):
import inspect
source = inspect.getsource(tui.build_app)
self.assertIn("Text.from_ansi", source)
self.assertIn("colour=True", source)
class TestTheStatisticsScreenOffersIt(Base):
"""L'écran de statistiques répond « qu'a-t-on supprimé, et pourquoi ».