diff --git a/requirement/erplibre_require-ments.txt b/requirement/erplibre_require-ments.txt index b170dc0..81505cd 100644 --- a/requirement/erplibre_require-ments.txt +++ b/requirement/erplibre_require-ments.txt @@ -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. diff --git a/script/todo/migration_status.py b/script/todo/migration_status.py index d59eecb..579e10c 100755 --- a/script/todo/migration_status.py +++ b/script/todo/migration_status.py @@ -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')}") diff --git a/script/todo/migration_status_tui.py b/script/todo/migration_status_tui.py index d19486b..93ec246 100644 --- a/script/todo/migration_status_tui.py +++ b/script/todo/migration_status_tui.py @@ -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: diff --git a/test/test_migration_status.py b/test/test_migration_status.py index a3689f1..9b98fb8 100644 --- a/test/test_migration_status.py +++ b/test/test_migration_status.py @@ -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 ».