From abb605aad36c6876673e348ff11dccad6ac92f1f Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 25 Aug 2026 03:55:46 -0400 Subject: [PATCH 01/22] [ADD] database: dupliquer une base, et la neutraliser pour de bon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La duplication passe par exp_duplicate_database d'Odoo plutôt que par CREATE DATABASE ... TEMPLATE : lui seul coupe les connexions de la source, régénère database.uuid, copie le filestore et neutralise. Les trois modules maison du dépôt n'obtenaient aucun des quatre. Sous Odoo 15 et avant la neutralisation n'existe pas : la demande est REFUSÉE, jamais ignorée — une copie qu'on croit neutralisée est pire qu'une brute. Vérifié sur la copie d'une base migrée 12 → 18 : is_neutralized posé, crons réduits au seul autovacuum, clé de paiement effacée, filestore copié. --- EN --- Duplication goes through Odoo's exp_duplicate_database rather than CREATE DATABASE ... TEMPLATE: only it drops the source's connections, regenerates database.uuid, copies the filestore and neutralises. The repository's three in-house modules obtained none of the four. Under Odoo 15 and earlier neutralisation does not exist: the request is REFUSED, never ignored — a copy believed neutralised is worse than a raw one. Checked on the copy of a 12 → 18 migrated database: is_neutralized set, crons reduced to autovacuum alone, payment key cleared, filestore copied. Assisted-by: Claude Opus 5 (cherry picked from commit af99d450c12e70a891d01d0ccf556c1d560e943b) --- script/database/db_duplicate.py | 259 ++++++++++++++++++++++++++++++++ script/todo/database_manager.py | 70 +++++++++ script/todo/todo.py | 4 + script/todo/todo_i18n.py | 84 +++++++++++ test/test_db_duplicate.py | 148 ++++++++++++++++++ test/test_todo_menu.py | 32 ++++ 6 files changed, 597 insertions(+) create mode 100755 script/database/db_duplicate.py create mode 100644 test/test_db_duplicate.py diff --git a/script/database/db_duplicate.py b/script/database/db_duplicate.py new file mode 100755 index 0000000..6cf3518 --- /dev/null +++ b/script/database/db_duplicate.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Dupliquer une base, et la neutraliser si on le demande. + +Pourquoi passer par Odoo plutôt que par `CREATE DATABASE … TEMPLATE` +------------------------------------------------------------------- +La commande PostgreSQL copie les tables, et RIEN d'autre. Odoo, lui, fait +quatre choses de plus que `exp_duplicate_database` réunit : + +1. Il coupe les connexions ouvertes sur la source. Sans cela PostgreSQL + refuse : « is being accessed by other users » — un shell Odoo laissé + ouvert suffit à faire échouer la copie. +2. Il RÉGÉNÈRE le `database.uuid`. Deux bases qui partagent le leur se + présentent comme la même instance au service de garantie d'Odoo. +3. Il copie le FILESTORE. Sans lui, chaque pièce jointe de la copie + pointe vers un fichier qui n'existe pas — 57 Mo perdus en silence sur + la base qui a servi à écrire ceci. +4. Il neutralise pour de bon, quand on le demande. + +Ce que « neutraliser » veut dire, mesuré +---------------------------------------- +Le dépôt a trois modules maison — `disable_mail_server`, +`disable_auto_backup`, `disable_payment_provider` — qui font trois gestes. +Aucun ne pose le drapeau, et l'un d'eux ouvre une porte : supprimer tous +les `ir.mail_server` fait retomber Odoo sur le `smtp_server` du fichier +de configuration. + +`neutralize_database` d'Odoo exécute les 73 fichiers `neutralize.sql` +livrés par les modules INSTALLÉS. Mesuré sur une base migrée 12 → 18 : + + database.is_neutralized ABSENT → true + crons actifs 33 → 1 (l'autovacuum, voulu) + serveurs de courriel 0 → 1 le bouchon « invalid » + clé Stripe présente 1 → 0 + +La neutralisation n'existe qu'à partir d'Odoo 16 : `neutralize.py` est +absent des versions antérieures, et `exp_duplicate_database` n'y prend +que deux arguments. On le DIT plutôt que d'ignorer la demande. +""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys + +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 + + +REPO_ROOT = os.path.normpath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..") +) + +# La neutralisation n'existe pas avant cette version. +PREMIERE_VERSION_NEUTRALISABLE = 16.0 + + +def lire_version(nom): + """Le contenu d'un fichier `.xxx-version`, ou None.""" + chemin = os.path.join(REPO_ROOT, nom) + try: + with open(chemin, "r", encoding="utf-8") as handle: + return handle.read().strip() or None + except OSError: + return None + + +def chemins_odoo(): + """(python du venv, racine d'Odoo) — résolus comme odoo_bin.sh le fait. + + Le nom du venv porte les DEUX versions, Odoo et Python : le composer + de tête revient à se tromper un jour sur l'une des deux. + """ + erplibre = lire_version(".erplibre-version") + odoo = lire_version(".odoo-version") + if not erplibre or not odoo: + return None, None + python = os.path.join(REPO_ROOT, f".venv.{erplibre}", "bin", "python") + racine = os.path.join(REPO_ROOT, f"odoo{odoo}", "odoo") + return python, racine + + +# « rien n'a été passé » et « on m'a passé une version illisible » sont +# deux questions différentes, et un `None` ne doit pas se faire prendre +# pour l'autre : l'appelant dont la lecture a échoué se verrait répondre +# sur la version du checkout, c'est-à-dire sur autre chose. +_NON_FOURNI = object() + + +def supporte_neutralisation(version=_NON_FOURNI): + """La version sait-elle neutraliser ? Sans argument : celle du checkout.""" + if version is _NON_FOURNI: + version = lire_version(".odoo-version") + try: + return float(version) >= PREMIERE_VERSION_NEUTRALISABLE + except (TypeError, ValueError): + return False + + +def nom_valide(nom): + """Un nom de base acceptable — c'est aussi ce qui entre dans du SQL.""" + if not nom or len(nom) > 63: + return False + if not (nom[0].isalpha() or nom[0] == "_"): + return False + return all(c.isalnum() or c in "_-$" for c in nom) + + +def script_python(source, cible, neutraliser, config): + """Le programme qu'on remet à l'interpréteur d'Odoo. + + `parse_config` est indispensable : sans lui, `Registry.new` ne sait ni + où se connecter ni où sont les addons, et la duplication échoue après + avoir DÉJÀ créé la base. + """ + return ( + "import sys\n" + "sys.path.insert(0, %r)\n" + "import odoo\n" + "odoo.tools.config.parse_config(['-c', %r])\n" + "from odoo.service.db import exp_duplicate_database\n" + "exp_duplicate_database(%r, %r%s)\n" + % ( + chemins_odoo()[1], + config, + source, + cible, + ", True" if neutraliser else "", + ) + ) + + +def bases_existantes(): + """Les bases que PostgreSQL expose, ou None si on n'a pas pu demander.""" + try: + fait = subprocess.run( + [ + "psql", + "-X", + "-w", + "-tA", + "-d", + "postgres", + "-c", + "SELECT datname FROM pg_database WHERE NOT datistemplate", + ], + capture_output=True, + text=True, + timeout=60, + ) + except (OSError, subprocess.SubprocessError): + return None + if fait.returncode: + return None + return { + ligne.strip() for ligne in fait.stdout.splitlines() if ligne.strip() + } + + +def verifier(source, cible, neutraliser): + """Ce qui empêche de commencer. Rendre la liste des refus.""" + refus = [] + for nom, role in ((source, "source"), (cible, "cible")): + if not nom_valide(nom): + refus.append(f"{t('Invalid database name: ')}{nom!r} ({role})") + if source == cible: + refus.append(t("The source and the copy cannot share a name.")) + bases = bases_existantes() + if bases is not None: + if source not in bases: + refus.append(f"{t('No such database: ')}{source}") + if cible in bases: + # Écraser une base sans le dire est la faute qu'on ne rattrape + # pas : le contenu d'avant n'existe plus nulle part. + refus.append(f"{t('This database already exists: ')}{cible}") + if neutraliser and not supporte_neutralisation(): + refus.append( + f"{t('Odoo')} {lire_version('.odoo-version')}" + f" {t('cannot neutralise: that arrived in Odoo 16.')}" + ) + return refus + + +def dupliquer( + source, cible, neutraliser=False, config="config.conf", timeout=3600 +): + """Faire la copie. Rendre (code, sortie).""" + python, racine = chemins_odoo() + if not python or not os.path.isfile(python): + return 2, t("Cannot find the virtualenv for this checkout.") + if not racine or not os.path.isdir(racine): + return 2, t("Cannot find the Odoo source for this checkout.") + fait = subprocess.run( + [python, "-c", script_python(source, cible, neutraliser, config)], + cwd=REPO_ROOT, + capture_output=True, + text=True, + timeout=timeout, + ) + if fait.returncode: + return fait.returncode, (fait.stderr or fait.stdout).strip() + return 0, (fait.stdout or "").strip() + + +def main(argv=None): + parser = argparse.ArgumentParser( + description=t("Duplicate a database, filestore included."), + ) + parser.add_argument("-s", "--source", required=True) + parser.add_argument("-d", "--database", required=True, help=t("the copy")) + parser.add_argument("-c", "--config", default="config.conf") + parser.add_argument( + "--neutralize", + action="store_true", + help=t("disable crons, outgoing mail and payment providers"), + ) + args = parser.parse_args(argv) + + refus = verifier(args.source, args.database, args.neutralize) + if refus: + for ligne in refus: + print(f"❌ {ligne}", file=sys.stderr) + return 2 + + print( + f"🧬 {args.source} → {args.database}" + f"{' (' + t('neutralised') + ')' if args.neutralize else ''}" + ) + code, sortie = dupliquer( + args.source, args.database, args.neutralize, args.config + ) + if code: + print(f"❌ {t('The duplication failed.')}", file=sys.stderr) + if sortie: + print(sortie[-2000:], file=sys.stderr) + return 2 + print(f"✅ {t('Done:')} {args.database}") + if not args.neutralize: + print( + f"⚠️ {t('NOT neutralised: its crons run, its mail leaves and')}" + f" {t('its payment providers can charge.')}" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/todo/database_manager.py b/script/todo/database_manager.py index a6e51ba..cee81cf 100644 --- a/script/todo/database_manager.py +++ b/script/todo/database_manager.py @@ -199,6 +199,76 @@ class DatabaseManager: source_erplibre=False, ) + def duplicate_database(self) -> None: + """Copier une base, et proposer de la neutraliser. + + On passe par `db_duplicate.py`, donc par Odoo, et non par un + `CREATE DATABASE … TEMPLATE` : lui seul coupe les connexions + ouvertes sur la source, régénère le `database.uuid`, copie le + filestore et sait neutraliser pour de bon. + + La neutralisation est proposée par DÉFAUT. Mesuré sur trois + migrations de suite : la copie gardait 33 crons actifs, aucun + serveur de courriel — donc le repli sur `smtp_server` de la + configuration — et une clé de paiement vivante. Le défaut à + « oui » est le seul qui protège celui qui appuie sur Entrée. + """ + source = self.select_database() + if not source: + return + defaut = f"{source}_neutralize" + cible = input( + f"\U0001f4ac {t('Name of the copy (default=')}{defaut}) : " + ).strip() + cible = cible or defaut + + reponse = ( + input(f"\U0001f4ac {t('Neutralize the copy (Y/n)? ')}") + .strip() + .lower() + ) + neutraliser = reponse != "n" + if not neutraliser: + print( + f"⚠️ {t('The copy will keep its scheduled actions, its')}" + f" {t('outgoing mail and its payment providers.')}" + ) + + commande = ( + f"python3 ./script/database/db_duplicate.py" + f" -s {source} -d {cible}" + ) + if neutraliser: + commande += " --neutralize" + status, _ = self._execute.exec_command_live( + commande, + return_status_and_output=True, + single_source_erplibre=True, + source_erplibre=False, + ) + if status: + print(f"❌ {t('The duplication failed.')}") + return + # RELIRE plutôt que croire : c'est le contrôle qui manquait aux + # trois modules maison, dont aucun ne posait le drapeau. + if neutraliser: + self._report_neutralize(cible) + + @staticmethod + def _report_neutralize(database: str) -> None: + """Dire ce que la neutralisation a réellement pris.""" + try: + from script.analyse import monitoring + + print() + print( + monitoring.neutralize_report( + monitoring.neutralize_state(database), colour=True + ) + ) + except Exception as exc: # noqa: BLE001 - un rapport, pas le sujet + print(f"ℹ️ {t('Cannot read the copy back: ')}{exc}") + def create_backup_from_database( self, show_remote_list: bool = True ) -> None: diff --git a/script/todo/todo.py b/script/todo/todo.py index 0a5fb21..08fca71 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -2893,6 +2893,8 @@ class TODO( }, {"section": t("Restore")}, {"prompt_description": t("Restore from backup (.zip)")}, + {"section": t("Duplicate")}, + {"prompt_description": t("Duplicate a database")}, {"section": t("Danger zone")}, {"prompt_description": t("Erase a database")}, ] @@ -2910,6 +2912,8 @@ class TODO( elif status == "3": self.db_manager.restore_from_database() elif status == "4": + self.db_manager.duplicate_database() + elif status == "5": self.db_manager.drop_database() else: print(t("Command not found !")) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index d12f08e..9466b5a 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -10008,6 +10008,90 @@ TRANSLATIONS = { "fr": "Mode inconnu :", "en": "Unknown mode:", }, + "Duplicate": { + "fr": "Copie", + "en": "Copy", + }, + "Duplicate a database": { + "fr": "Dupliquer une base de données", + "en": "Duplicate a database", + }, + "Name of the copy (default=": { + "fr": "Nom de la copie (défaut=", + "en": "Name of the copy (default=", + }, + "Neutralize the copy (Y/n)? ": { + "fr": "Neutraliser la copie (O/n) ? ", + "en": "Neutralize the copy (Y/n)? ", + }, + "The copy will keep its scheduled actions, its": { + "fr": "La copie gardera ses actions planifiées, son", + "en": "The copy will keep its scheduled actions, its", + }, + "outgoing mail and its payment providers.": { + "fr": "courriel sortant et ses fournisseurs de paiement.", + "en": "outgoing mail and its payment providers.", + }, + "The duplication failed.": { + "fr": "La duplication a échoué.", + "en": "The duplication failed.", + }, + "Cannot read the copy back: ": { + "fr": "Impossible de relire la copie : ", + "en": "Cannot read the copy back: ", + }, + "Duplicate a database, filestore included.": { + "fr": "Dupliquer une base, filestore compris.", + "en": "Duplicate a database, filestore included.", + }, + "the copy": { + "fr": "la copie", + "en": "the copy", + }, + "disable crons, outgoing mail and payment providers": { + "fr": "désactiver les crons, le courriel sortant et les fournisseurs de paiement", + "en": "disable crons, outgoing mail and payment providers", + }, + "The source and the copy cannot share a name.": { + "fr": "La source et la copie ne peuvent pas porter le même nom.", + "en": "The source and the copy cannot share a name.", + }, + "No such database: ": { + "fr": "Base introuvable : ", + "en": "No such database: ", + }, + "This database already exists: ": { + "fr": "Cette base existe déjà : ", + "en": "This database already exists: ", + }, + "cannot neutralise: that arrived in Odoo 16.": { + "fr": "ne sait pas neutraliser : c'est arrivé avec Odoo 16.", + "en": "cannot neutralise: that arrived in Odoo 16.", + }, + "Cannot find the virtualenv for this checkout.": { + "fr": "Venv introuvable pour ce checkout.", + "en": "Cannot find the virtualenv for this checkout.", + }, + "Cannot find the Odoo source for this checkout.": { + "fr": "Sources Odoo introuvables pour ce checkout.", + "en": "Cannot find the Odoo source for this checkout.", + }, + "neutralised": { + "fr": "neutralisée", + "en": "neutralised", + }, + "Done:": { + "fr": "Fait :", + "en": "Done:", + }, + "NOT neutralised: its crons run, its mail leaves and": { + "fr": "PAS neutralisée : ses crons tournent, son courriel part et", + "en": "NOT neutralised: its crons run, its mail leaves and", + }, + "its payment providers can charge.": { + "fr": "ses fournisseurs de paiement peuvent débiter.", + "en": "its payment providers can charge.", + }, } diff --git a/test/test_db_duplicate.py b/test/test_db_duplicate.py new file mode 100644 index 0000000..6865291 --- /dev/null +++ b/test/test_db_duplicate.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Dupliquer : ce qui se refuse AVANT de créer quoi que ce soit. + +Une duplication rate de deux façons. Elle peut échouer — c'est le cas +bénin, on recommence. Ou elle peut RÉUSSIR sur la mauvaise base : écraser +une copie qui contenait autre chose, ou rendre une copie qui n'est pas +neutralisée alors qu'on l'a demandé. Ces deux-là ne se rattrapent pas, et +c'est ce que ce fichier épingle. + +La neutralisation n'existe qu'à partir d'Odoo 16 : `neutralize.py` est +absent avant, et `exp_duplicate_database` n'y prend que deux arguments. +Demander la neutralisation sous Odoo 15 doit être REFUSÉ, jamais ignoré +en silence — une copie qu'on croit neutralisée est pire qu'une copie +qu'on sait brute. +""" + +import unittest + +from script.database import db_duplicate as dup + + +class TestWhatANameMayBe(unittest.TestCase): + """Le nom entre dans du SQL par `database_identifier` : il se filtre.""" + + def test_ordinary_names_pass(self): + for nom in ("chezlepro", "el_essai", "a", "base-2024", "_interne"): + self.assertTrue(dup.nom_valide(nom), nom) + + def test_a_name_that_could_carry_sql_is_refused(self): + for nom in ('a"; DROP DATABASE x; --', "a b", "a'b", "a;b", "a(b)"): + self.assertFalse(dup.nom_valide(nom), nom) + + def test_an_empty_or_oversized_name_is_refused(self): + self.assertFalse(dup.nom_valide("")) + self.assertFalse(dup.nom_valide(None)) + self.assertFalse(dup.nom_valide("x" * 64)) + self.assertTrue(dup.nom_valide("x" * 63)) + + def test_a_name_starting_with_a_digit_is_refused(self): + self.assertFalse(dup.nom_valide("2024_base")) + + +class TestWhatIsRefusedBeforeAnythingIsCreated(unittest.TestCase): + def setUp(self): + # On ne veut pas d'un PostgreSQL réel : le sujet est la décision, + # pas la connexion. `None` = « je n'ai pas pu demander », ce qui + # doit laisser passer plutôt que d'inventer un refus. + self._vrai = dup.bases_existantes + dup.bases_existantes = lambda: {"source", "deja_la"} + self.addCleanup(setattr, dup, "bases_existantes", self._vrai) + + def test_the_same_name_twice_is_refused(self): + refus = dup.verifier("source", "source", False) + self.assertTrue(any("nom" in r or "name" in r for r in refus)) + + def test_an_unknown_source_is_refused(self): + self.assertTrue(dup.verifier("pas_la", "neuve", False)) + + def test_overwriting_an_existing_database_is_refused(self): + """Le contenu d'avant n'existerait plus nulle part.""" + refus = dup.verifier("source", "deja_la", False) + self.assertTrue(refus) + self.assertTrue(any("deja_la" in r for r in refus)) + + def test_a_plain_duplication_passes(self): + self.assertEqual(dup.verifier("source", "neuve", False), []) + + def test_when_postgres_cannot_be_asked_we_do_not_invent_a_refusal(self): + dup.bases_existantes = lambda: None + self.assertEqual(dup.verifier("source", "neuve", False), []) + + +class TestNeutralisationIsRefusedBeforeOdoo16(unittest.TestCase): + def test_the_boundary_is_sixteen(self): + self.assertFalse(dup.supporte_neutralisation("15.0")) + self.assertTrue(dup.supporte_neutralisation("16.0")) + self.assertTrue(dup.supporte_neutralisation("18.0")) + + def test_an_unreadable_version_is_treated_as_unsupported(self): + """Dans le doute, refuser : le silence serait un faux calme.""" + for version in (None, "", "quatorze"): + self.assertFalse(dup.supporte_neutralisation(version)) + + def test_asking_for_it_on_an_old_checkout_is_refused(self): + self._vrai = dup.bases_existantes + dup.bases_existantes = lambda: {"source"} + vraie_version = dup.lire_version + dup.lire_version = lambda nom: "15.0" + try: + refus = dup.verifier("source", "neuve", True) + finally: + dup.bases_existantes = self._vrai + dup.lire_version = vraie_version + self.assertTrue(refus) + self.assertTrue(any("16" in r for r in refus)) + + +class TestTheProgramHandedToOdoo(unittest.TestCase): + """Ce qu'on exécute, et surtout ce qu'on n'exécute pas par mégarde.""" + + def test_it_parses_the_config_before_duplicating(self): + """Sans parse_config, Registry.new échoue APRÈS avoir créé la base.""" + code = dup.script_python("a", "b", False, "config.conf") + self.assertIn("parse_config", code) + self.assertLess( + code.index("parse_config"), code.index("exp_duplicate") + ) + + def test_neutralisation_is_passed_only_when_asked(self): + self.assertIn("'a', 'b', True", dup.script_python("a", "b", True, "c")) + self.assertIn("'a', 'b')", dup.script_python("a", "b", False, "c")) + + def test_the_names_travel_as_python_literals(self): + """Ils sont interpolés dans du code : `repr` et rien d'autre.""" + code = dup.script_python("a'b", "c", False, "config.conf") + self.assertIn(repr("a'b"), code) + + def test_the_odoo_path_is_read_from_the_version_files(self): + """Composer le nom de tête, c'est se tromper un jour sur l'une + des deux versions qu'il porte.""" + import inspect + + source = inspect.getsource(dup.chemins_odoo) + self.assertIn(".erplibre-version", source) + self.assertIn(".odoo-version", source) + + +class TestTheContractOfTheCommandLine(unittest.TestCase): + def test_a_refusal_exits_two_and_creates_nothing(self): + appels = [] + vrai = dup.dupliquer + dup.dupliquer = lambda *a, **k: appels.append(a) or (0, "") + vraies_bases = dup.bases_existantes + dup.bases_existantes = lambda: {"source", "deja_la"} + try: + code = dup.main(["-s", "source", "-d", "deja_la"]) + finally: + dup.dupliquer = vrai + dup.bases_existantes = vraies_bases + self.assertEqual(code, 2) + self.assertEqual(appels, [], "la duplication a été tentée quand même") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_todo_menu.py b/test/test_todo_menu.py index f88855f..5c38677 100644 --- a/test/test_todo_menu.py +++ b/test/test_todo_menu.py @@ -380,6 +380,38 @@ class TestAnalyseMenuNumbering(MenuCoherence, unittest.TestCase): } +class TestDatabaseMenuNumbering(MenuCoherence, unittest.TestCase): + """Le menu Database, qui manie des bases entières. + + Il n'avait aucun garde, et c'est celui où une renumérotation coûte le + plus cher : sa dernière entrée EFFACE une base. Insérer « Dupliquer » + avant elle décale « Effacer » de [4] à [5] — si le dispatch ne suit + pas, taper [4] efface au lieu de copier. + """ + + SOURCE = TODO_DIR / "todo.py" + ENTRY = "def prompt_execute_database(self):" + END = "def prompt_execute_analyse(self):" + MINIMUM = 4 + + # Ce menu délègue à `self.db_manager.methode()`, pas à `self.methode()`. + # Le motif du socle ne voit que la forme courte : sans cette surcharge + # il lit ZÉRO dispatch et ne compare plus rien — un garde qui passe au + # vert sans rien garder. + RE_DISPATCH_CALL = re.compile( + r'(?:el)?if status == "(\d+)":\s*\n(?:\s*#.*\n)*' + r"\s*(?:status = )?self\.(?:\w+\.)*(\w+)\(" + ) + + EXPECTED = { + "Create backup": "create_backup_from_database", + "Download database": "download_database_backup_cli", + "Restore from backup": "restore_from_database", + "Duplicate a database": "duplicate_database", + "Erase a database": "drop_database", + } + + class TestProxmoxMenuNumbering(MenuCoherence, unittest.TestCase): """Le menu Proxmox : dix-huit entrées, le même piège. From fe52ad99745cfefd919b54fbb2c19c13ac1d374b Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 25 Aug 2026 04:12:04 -0400 Subject: [PATCH 02/22] =?UTF-8?q?[FIX]=20database:=20de=2012=20=C3=A0=2015?= =?UTF-8?q?,=20neutraliser=20par=20le=20script=20plut=C3=B4t=20que=20refus?= =?UTF-8?q?er?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit De 12 à 15 la neutralisation d'Odoo n'existe pas et la demande était refusée ; le dépôt a pourtant sa technique de longue date, update_prod_to_dev.sh, et une copie imparfaitement neutralisée vaut mieux qu'une copie brute. Le chemin suivi est ANNONCÉ à l'exécution, car les deux ne posent pas les mêmes gestes : le script ne pose pas is_neutralized, ne désactive pas les crons et laisse les clés de paiement, là où il supprime les serveurs de courriel et pose un compte de développement. Un seul cas reste refusé, le script introuvable, et son échec fait échouer la copie : elle sortirait brute en s'annonçant neutralisée. --- EN --- From 12 to 15 Odoo's neutralisation does not exist and the request was refused; yet the repository has long had its own technique, update_prod_to_dev.sh, and an imperfectly neutralised copy beats a raw one. The route taken is ANNOUNCED at run time, because the two do not make the same gestures: the script does not set is_neutralized, does not disable crons and leaves the payment keys, where it deletes the mail servers and sets up a development account. One case is still refused, a missing script, and its failure fails the copy: it would come out raw while announcing itself neutralised. Assisted-by: Claude Opus 5 (cherry picked from commit bd7b648305a09c37adfe4b280e7479f1f178f20e) --- script/database/db_duplicate.py | 134 ++++++++++++++++++++++++++------ script/todo/todo_i18n.py | 16 ++++ test/test_db_duplicate.py | 113 ++++++++++++++++++++++++++- 3 files changed, 238 insertions(+), 25 deletions(-) diff --git a/script/database/db_duplicate.py b/script/database/db_duplicate.py index 6cf3518..101a70d 100755 --- a/script/database/db_duplicate.py +++ b/script/database/db_duplicate.py @@ -35,9 +35,30 @@ livrés par les modules INSTALLÉS. Mesuré sur une base migrée 12 → 18 : serveurs de courriel 0 → 1 le bouchon « invalid » clé Stripe présente 1 → 0 -La neutralisation n'existe qu'à partir d'Odoo 16 : `neutralize.py` est -absent des versions antérieures, et `exp_duplicate_database` n'y prend -que deux arguments. On le DIT plutôt que d'ignorer la demande. +Deux techniques, selon la version +--------------------------------- +`neutralize.py` n'existe qu'à partir d'Odoo 16 ; avant, +`exp_duplicate_database` ne prend que deux arguments. De 12 à 15 on +retombe donc sur la technique du dépôt : `update_prod_to_dev.sh`, qui +installe puis désinstalle `user_test`, `disable_mail_server`, +`disable_auto_backup` et `disable_payment_provider`. + +Les deux n'obtiennent PAS la même chose, et il faut le savoir : + + Odoo ≥ 16 script 12→15 + database.is_neutralized posé non posé + crons désactivés oui non + serveur de courriel bouchon supprimés (*) + clés de paiement mises à laissées + NULL en place + compte test/test non oui + +(*) supprimer tous les `ir.mail_server` fait retomber Odoo sur le +`smtp_server` du fichier de configuration ; le bouchon d'Odoo existe +précisément pour boucher ce trou-là. + +La technique employée est ANNONCÉE à chaque exécution : une copie dont +on ignore par quel chemin elle est passée ne se juge pas. """ from __future__ import annotations @@ -63,9 +84,14 @@ REPO_ROOT = os.path.normpath( os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..") ) -# La neutralisation n'existe pas avant cette version. +# La neutralisation d'Odoo n'existe pas avant cette version. PREMIERE_VERSION_NEUTRALISABLE = 16.0 +# Comment on neutralise, selon ce que la version sait faire. +NEUTRALISATION_ODOO = "odoo" +NEUTRALISATION_SCRIPT = "script" +SCRIPT_PROD_TO_DEV = "./script/addons/update_prod_to_dev.sh" + def lire_version(nom): """Le contenu d'un fichier `.xxx-version`, ou None.""" @@ -109,6 +135,18 @@ def supporte_neutralisation(version=_NON_FOURNI): return False +def technique_neutralisation(version=_NON_FOURNI): + """Par quel chemin neutraliser, pour cette version. + + On ne REFUSE plus sous 12→15 : le dépôt a sa technique, et une copie + imparfaitement neutralisée vaut mieux qu'une copie brute. Ce qui + compte est de dire laquelle a servi. + """ + if supporte_neutralisation(version): + return NEUTRALISATION_ODOO + return NEUTRALISATION_SCRIPT + + def nom_valide(nom): """Un nom de base acceptable — c'est aussi ce qui entre dans du SQL.""" if not nom or len(nom) > 63: @@ -185,25 +223,25 @@ def verifier(source, cible, neutraliser): # Écraser une base sans le dire est la faute qu'on ne rattrape # pas : le contenu d'avant n'existe plus nulle part. refus.append(f"{t('This database already exists: ')}{cible}") - if neutraliser and not supporte_neutralisation(): - refus.append( - f"{t('Odoo')} {lire_version('.odoo-version')}" - f" {t('cannot neutralise: that arrived in Odoo 16.')}" - ) + if neutraliser and technique_neutralisation() == NEUTRALISATION_SCRIPT: + # PAS un refus : de 12 à 15 on passe par update_prod_to_dev.sh. + # Le script doit exister, sinon la copie sortirait brute en se + # croyant neutralisée — le seul cas vraiment dangereux. + if not os.path.isfile(os.path.join(REPO_ROOT, SCRIPT_PROD_TO_DEV)): + refus.append(f"{t('Missing script: ')}{SCRIPT_PROD_TO_DEV}") return refus -def dupliquer( - source, cible, neutraliser=False, config="config.conf", timeout=3600 -): - """Faire la copie. Rendre (code, sortie).""" - python, racine = chemins_odoo() - if not python or not os.path.isfile(python): - return 2, t("Cannot find the virtualenv for this checkout.") - if not racine or not os.path.isdir(racine): - return 2, t("Cannot find the Odoo source for this checkout.") +def neutraliser_par_script(cible, timeout=3600): + """La technique du dépôt, pour Odoo 12 à 15. Rendre (code, sortie). + + `update_prod_to_dev.sh` installe puis désinstalle quatre modules + maison. Il tourne depuis la racine du dépôt : les chemins qu'il + contient sont relatifs, et l'appeler d'ailleurs le fait échouer sur + `install_addons_dev.sh` introuvable. + """ fait = subprocess.run( - [python, "-c", script_python(source, cible, neutraliser, config)], + ["bash", SCRIPT_PROD_TO_DEV, cible], cwd=REPO_ROOT, capture_output=True, text=True, @@ -214,6 +252,41 @@ def dupliquer( return 0, (fait.stdout or "").strip() +def dupliquer( + source, cible, neutraliser=False, config="config.conf", timeout=3600 +): + """Faire la copie, puis la neutraliser par le chemin de la version. + + La copie d'abord, la neutralisation ensuite : si la seconde échoue, + la base existe et l'on sait qu'elle est BRUTE. L'inverse — une base + à moitié neutralisée — ne se distinguerait pas d'une base traitée. + """ + python, racine = chemins_odoo() + if not python or not os.path.isfile(python): + return 2, t("Cannot find the virtualenv for this checkout.") + if not racine or not os.path.isdir(racine): + return 2, t("Cannot find the Odoo source for this checkout.") + + par_odoo = neutraliser and ( + technique_neutralisation() == NEUTRALISATION_ODOO + ) + fait = subprocess.run( + [python, "-c", script_python(source, cible, par_odoo, config)], + cwd=REPO_ROOT, + capture_output=True, + text=True, + timeout=timeout, + ) + if fait.returncode: + return fait.returncode, (fait.stderr or fait.stdout).strip() + + if neutraliser and not par_odoo: + code, sortie = neutraliser_par_script(cible, timeout=timeout) + if code: + return code, sortie + return 0, (fait.stdout or "").strip() + + def main(argv=None): parser = argparse.ArgumentParser( description=t("Duplicate a database, filestore included."), @@ -234,10 +307,19 @@ def main(argv=None): print(f"❌ {ligne}", file=sys.stderr) return 2 - print( - f"🧬 {args.source} → {args.database}" - f"{' (' + t('neutralised') + ')' if args.neutralize else ''}" - ) + technique = technique_neutralisation() + if args.neutralize: + # DIRE par quel chemin : les deux ne posent pas les mêmes gestes, + # et une copie dont on ignore le traitement ne se juge pas. + comment = ( + t("Odoo's own neutralisation") + if technique == NEUTRALISATION_ODOO + else f"{SCRIPT_PROD_TO_DEV} ({t('Odoo')}" + f" {lire_version('.odoo-version')})" + ) + print(f"🧬 {args.source} → {args.database} — {comment}") + else: + print(f"🧬 {args.source} → {args.database}") code, sortie = dupliquer( args.source, args.database, args.neutralize, args.config ) @@ -247,6 +329,12 @@ def main(argv=None): print(sortie[-2000:], file=sys.stderr) return 2 print(f"✅ {t('Done:')} {args.database}") + if args.neutralize and technique == NEUTRALISATION_SCRIPT: + print( + f"ℹ️ {t('This route does not set database.is_neutralized, does')}" + f" {t('not disable crons, and leaves payment keys in place.')}" + ) + print(f"ℹ️ {t('You can log in with test / test.')}") if not args.neutralize: print( f"⚠️ {t('NOT neutralised: its crons run, its mail leaves and')}" diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 9466b5a..8f86596 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -10092,6 +10092,22 @@ TRANSLATIONS = { "fr": "ses fournisseurs de paiement peuvent débiter.", "en": "its payment providers can charge.", }, + "Missing script: ": { + "fr": "Script introuvable : ", + "en": "Missing script: ", + }, + "Odoo's own neutralisation": { + "fr": "la neutralisation d'Odoo lui-même", + "en": "Odoo's own neutralisation", + }, + "This route does not set database.is_neutralized, does": { + "fr": "Ce chemin ne pose pas database.is_neutralized, ne", + "en": "This route does not set database.is_neutralized, does", + }, + "not disable crons, and leaves payment keys in place.": { + "fr": "désactive pas les crons, et laisse les clés de paiement en place.", + "en": "not disable crons, and leaves payment keys in place.", + }, } diff --git a/test/test_db_duplicate.py b/test/test_db_duplicate.py index 6865291..6fc2a9d 100644 --- a/test/test_db_duplicate.py +++ b/test/test_db_duplicate.py @@ -84,18 +84,50 @@ class TestNeutralisationIsRefusedBeforeOdoo16(unittest.TestCase): for version in (None, "", "quatorze"): self.assertFalse(dup.supporte_neutralisation(version)) - def test_asking_for_it_on_an_old_checkout_is_refused(self): + def test_an_old_checkout_falls_back_instead_of_refusing(self): + """De 12 à 15, le dépôt a sa technique : on l'emploie.""" self._vrai = dup.bases_existantes dup.bases_existantes = lambda: {"source"} vraie_version = dup.lire_version dup.lire_version = lambda nom: "15.0" try: refus = dup.verifier("source", "neuve", True) + technique = dup.technique_neutralisation() finally: dup.bases_existantes = self._vrai dup.lire_version = vraie_version + self.assertEqual( + refus, [], "la demande a été refusée au lieu de basculer" + ) + self.assertEqual(technique, dup.NEUTRALISATION_SCRIPT) + + def test_the_route_is_chosen_by_the_version(self): + for version, attendu in ( + ("12.0", dup.NEUTRALISATION_SCRIPT), + ("15.0", dup.NEUTRALISATION_SCRIPT), + ("16.0", dup.NEUTRALISATION_ODOO), + ("18.0", dup.NEUTRALISATION_ODOO), + ): + self.assertEqual( + dup.technique_neutralisation(version), attendu, version + ) + + def test_a_missing_script_is_the_one_case_still_refused(self): + """Sinon la copie sortirait brute en se croyant neutralisée.""" + self._vrai = dup.bases_existantes + dup.bases_existantes = lambda: {"source"} + vraie_version = dup.lire_version + vrai_isfile = dup.os.path.isfile + dup.lire_version = lambda nom: "14.0" + dup.os.path.isfile = lambda chemin: False + try: + refus = dup.verifier("source", "neuve", True) + finally: + dup.bases_existantes = self._vrai + dup.lire_version = vraie_version + dup.os.path.isfile = vrai_isfile self.assertTrue(refus) - self.assertTrue(any("16" in r for r in refus)) + self.assertTrue(any("update_prod_to_dev" in r for r in refus)) class TestTheProgramHandedToOdoo(unittest.TestCase): @@ -109,6 +141,83 @@ class TestTheProgramHandedToOdoo(unittest.TestCase): code.index("parse_config"), code.index("exp_duplicate") ) + def test_odoo_is_not_asked_to_neutralise_when_it_cannot(self): + """Sous 12→15, exp_duplicate_database ne prend que deux arguments : + lui en passer un troisième ferait échouer la duplication.""" + # `lire_version` sert AUSSI à composer le venv : lui faire dire + # « 14.0 » partout rendait le chemin introuvable et `dupliquer` + # sortait avant d'appeler quoi que ce soit — un test vert pour + # rien. On ne détourne donc que la version d'Odoo. + vraie = dup.lire_version + appels = [] + dup.lire_version = lambda nom: ( + "14.0" if nom == ".odoo-version" else vraie(nom) + ) + vrais_chemins = dup.chemins_odoo + dup.chemins_odoo = lambda: ("/usr/bin/python3", "/tmp") + vrai_run = dup.subprocess.run + + class Faux: + returncode = 0 + stdout = "" + stderr = "" + + def espion(cmd, **kw): + appels.append(cmd) + return Faux() + + dup.subprocess.run = espion + try: + dup.dupliquer("a", "b", neutraliser=True) + finally: + dup.lire_version = vraie + dup.chemins_odoo = vrais_chemins + dup.subprocess.run = vrai_run + programme = [c for c in appels if "-c" in c] + self.assertTrue(programme) + self.assertNotIn("True", programme[0][-1]) + # …et le script du dépôt a bien été lancé ensuite. + self.assertTrue( + any( + dup.SCRIPT_PROD_TO_DEV in " ".join(map(str, c)) for c in appels + ) + ) + + def test_a_failing_script_makes_the_whole_thing_fail(self): + """Le cas vraiment dangereux : la copie existe, le script a + échoué, et l'on annoncerait « neutralisée ». Avaler ce code de + retour rendrait une base brute indiscernable d'une base traitée. + """ + vraie = dup.lire_version + dup.lire_version = lambda nom: ( + "14.0" if nom == ".odoo-version" else vraie(nom) + ) + vrais_chemins = dup.chemins_odoo + dup.chemins_odoo = lambda: ("/usr/bin/python3", "/tmp") + vrai_run = dup.subprocess.run + + class Reponse: + def __init__(self, code, sortie=""): + self.returncode = code + self.stdout = sortie + self.stderr = sortie + + def espion(cmd, **kw): + # La duplication réussit, le script échoue. + if dup.SCRIPT_PROD_TO_DEV in " ".join(map(str, cmd)): + return Reponse(1, "install_addons_dev.sh a échoué") + return Reponse(0) + + dup.subprocess.run = espion + try: + code, sortie = dup.dupliquer("a", "b", neutraliser=True) + finally: + dup.lire_version = vraie + dup.chemins_odoo = vrais_chemins + dup.subprocess.run = vrai_run + self.assertNotEqual(code, 0, "l'échec du script a été avalé") + self.assertIn("install_addons_dev", sortie) + def test_neutralisation_is_passed_only_when_asked(self): self.assertIn("'a', 'b', True", dup.script_python("a", "b", True, "c")) self.assertIn("'a', 'b')", dup.script_python("a", "b", False, "c")) From 13a7bca972b3e7430a49c6419436451fa3b7b960 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 25 Aug 2026 04:32:08 -0400 Subject: [PATCH 03/22] =?UTF-8?q?[FIX]=20anonymize=20:=20passer=20la=20lis?= =?UTF-8?q?te=20noire=20enti=C3=A8re=20sans=20=C3=A9chec=20ni=20omission?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le SQL de la liste noire dépasse MAX_ARG_STRLEN, les 131 072 octets que Linux impose à UN argument ; il passe par un fichier avec -f, qui garde --single-transaction que l'entrée standard aurait perdu. Les colonnes texte à longueur déclarée sont tronquées par left(..., n), l'identifiant en TÊTE sur une colonne unique. Tous les identifiants sont cités : Odoo laisse nommer un champ user ou order. Écarter toute colonne sous CHECK laissait res_partner.name intact ; sur du texte, seules les contraintes de FORME sont hors de portée. Vérifié sur les quatre cas ; un UPDATE qui échoue n'écrit rien. --- EN --- The blacklist SQL exceeds MAX_ARG_STRLEN, the 131 072 bytes Linux allows for ONE argument; it travels through a file with -f, which keeps --single-transaction that stdin would have dropped. Text columns with a declared length are truncated by left(..., n), the id FIRST on a unique column. Every identifier is quoted: Odoo allows a field named user or order. Skipping every column under a CHECK left res_partner.name untouched; on text, only FORM constraints are out of reach. Checked on the four cases; an UPDATE that fails writes nothing. Assisted-by: Claude Opus 5 (cherry picked from commit 610f1d30414d578b49bff5649dbda82f4a2eb19f) --- script/analyse/anonymize.py | 144 +++++++++++++++------ test/test_anonymize.py | 244 +++++++++++++++++++++++++++++++++++- 2 files changed, 350 insertions(+), 38 deletions(-) diff --git a/script/analyse/anonymize.py b/script/analyse/anonymize.py index 63be592..f3fbc97 100755 --- a/script/analyse/anonymize.py +++ b/script/analyse/anonymize.py @@ -198,11 +198,16 @@ def champ_retenu(champ, inclure_connexion=False): # Une relation qui aurait échappé au filtre de ttype. return False if champ.get("checked"): - # Une contrainte CHECK dit ce que la colonne a le droit de valoir. - # Mesuré : crm_lead.probability doit rester entre 0 et 100, et un - # tirage à 1000 fait échouer l'UPDATE — donc, transaction unique - # oblige, TOUTE l'anonymisation. Lire l'expression du CHECK pour - # tirer dedans serait deviner ; on s'abstient et on le dit. + # Une contrainte CHECK hors de portée. Mesuré, et la distinction + # compte : sur un NOMBRE toute contrainte borne la valeur — + # crm_lead.probability entre 0 et 100, credit * debit = 0 — et un + # tirage à 1000 fait échouer l'UPDATE, donc toute l'anonymisation. + # Sur du TEXTE, presque toutes ne garantissent que la non-nullité : + # `res_partner.name` n'exige d'être non nul que pour un contact, ce + # qu'un mot satisfait. Les écarter TOUTES laissait le champ le plus + # important de la base intact — une anonymisation qui n'anonymisait + # pas les noms. La requête ne lève donc ce drapeau, pour du texte, + # que sur les contraintes de FORME. return False if champ["ttype"] in TYPES_NOMBRE and champ.get("pg_type") == "jsonb": # Mesuré sur res_partner.credit_limit : un `float` d'Odoo peut @@ -233,22 +238,46 @@ def litteral(texte): return "'" + str(texte).replace("'", "''") + "'" +def ident(nom): + """Un identifiant SQL, cité. + + Odoo laisse nommer un champ `user`, `order` ou `group` : ce sont des + mots réservés de PostgreSQL, et un identifiant nu fait échouer + l'analyse syntaxique — donc, transaction unique oblige, TOUTE + l'anonymisation. Mesuré en liste noire sur une base réelle : + « syntax error at or near "user" ». Les citer coûte deux caractères + et ferme la question pour tous les noms à venir. + """ + return '"' + str(nom).replace('"', '""') + '"' + + def expression_texte(champ, mots): """Le SQL qui remplace un champ texte, en préservant les NULL. Un NULL qui deviendrait un mot créerait de la donnée là où il n'y en avait pas : la copie mentirait dans l'autre sens. """ - nom = champ["name"] - liste = mots_pour(nom, mots) + nom = ident(champ["name"]) + liste = mots_pour(champ["name"], mots) # Les parenthèses ne sont pas décoratives : PostgreSQL refuse # d'indexer un constructeur ARRAY[...] directement. tableau = "(ARRAY[" + ",".join(litteral(m) for m in liste) + "])" - tirage = f"{tableau}[(id % {len(liste)}) + 1]" + tirage = f'{tableau}[("id" % {len(liste)}) + 1]' + borne = champ.get("max_len") if champ.get("unique"): # Deux lignes qui reçoivent le même mot feraient échouer TOUT - # l'UPDATE sur une colonne unique. - tirage = f"{tirage} || '-' || id::text" + # l'UPDATE sur une colonne unique. L'identifiant vient EN TÊTE + # quand la colonne est bornée : c'est lui qui porte l'unicité, et + # une troncature par la droite doit le laisser intact. + if borne: + tirage = f"\"id\"::text || '-' || {tirage}" + else: + tirage = f"{tirage} || '-' || \"id\"::text" + if borne: + # varchar(n) : mesuré, 13 colonnes sont bornées sur une base + # réelle, dont des codes à 1, 2 et 3 caractères. « jonquille » + # dans un varchar(3) fait échouer l'UPDATE entier. + tirage = f"left({tirage}, {borne})" if champ.get("pg_type") == "jsonb": # Un objet par langue depuis Odoo 17 : on le reconstruit clé à # clé. Écrire une chaîne par-dessus détruirait la colonne. @@ -262,7 +291,7 @@ def expression_texte(champ, mots): def expression_nombre(champ): """Le SQL qui remplace un nombre : au hasard, entre 0 et 1000.""" - nom = champ["name"] + nom = ident(champ["name"]) if champ["ttype"] == "integer": tirage = "floor(random() * 1001)::integer" else: @@ -275,14 +304,13 @@ def sql_pour_table(table, champs, mots): morceaux = [] for champ in champs: if champ["ttype"] in TYPES_TEXTE: - morceaux.append( - f"{champ['name']} = {expression_texte(champ, mots)}" - ) + valeur = expression_texte(champ, mots) else: - morceaux.append(f"{champ['name']} = {expression_nombre(champ)}") + valeur = expression_nombre(champ) + morceaux.append(f"{ident(champ['name'])} = {valeur}") if not morceaux: return None - return f"UPDATE {table} SET " + ", ".join(morceaux) + ";" + return f"UPDATE {ident(table)} SET " + ", ".join(morceaux) + ";" def table_de(modele): @@ -311,7 +339,19 @@ SELECT f.model || '\x1f' || f.name || '\x1f' || f.ttype || '\x1f' WHERE k.conrelid = c.oid AND k.contype = 'c' AND a.attnum = ANY(k.conkey) - ) THEN '1' ELSE '0' END + AND ( + -- Sur un NOMBRE, toute contrainte borne la valeur : + -- `credit * debit = 0`, `amount >= 0`. On s'abstient. + f.ttype IN ('integer','float','monetary') + -- Sur du TEXTE, presque toutes ne garantissent que la + -- non-nullité, ce qu'un mot satisfait. Seules celles + -- qui contraignent la FORME sont hors de portée. + OR pg_get_constraintdef(k.oid) ~ + 'char_length|~~|jsonb_typeof|similar to' + ) + ) THEN '1' ELSE '0' END || '\x1f' + || CASE WHEN a.atttypmod > 4 + THEN (a.atttypmod - 4)::text ELSE '' END FROM ir_model_fields f JOIN pg_class c ON c.relname = replace(f.model, '.', '_') AND c.relkind = 'r' @@ -333,7 +373,7 @@ def inspect(database, config_path=None): champs = [] for ligne in brut.splitlines(): parts = ligne.split(SEP) - if len(parts) != 6: + if len(parts) != 7: continue champs.append( { @@ -343,6 +383,11 @@ def inspect(database, config_path=None): "pg_type": parts[3], "unique": parts[4] == "1", "checked": parts[5] == "1", + # varchar(n) : n, sinon None. Mesuré sur une base réelle, + # 13 colonnes sont bornées — dont des codes à 1, 2 et 3 + # caractères. Y écrire « jonquille » fait échouer tout + # l'UPDATE, et donc toute l'anonymisation. + "max_len": int(parts[6]) if parts[6].isdigit() else None, } ) return champs @@ -446,29 +491,56 @@ def ecrire(database, etapes, config_path=None, timeout=900): redéclarer ici, ce serait accepter qu'elle diverge un jour. """ import subprocess + import tempfile env = lib_analyse.pg_env(config_path, timeout=timeout) env["PGOPTIONS"] = f"-c statement_timeout={timeout}s" sql = "\n".join(etape["sql"] for etape in etapes) - done = subprocess.run( - [ - "psql", - "-X", - "-w", - "-1", - "-v", - "ON_ERROR_STOP=1", - "-d", - database, - "-tA", - "-c", - sql, - ], - capture_output=True, - text=True, - env=env, - timeout=timeout + 60, + + # PAR FICHIER, jamais par `-c`. Linux plafonne un seul argument à + # MAX_ARG_STRLEN — 32 pages, soit 131 072 octets. Mesuré sur une base + # réelle : le mode hybride tient dans 58 Ko et passait, la liste noire + # produit 342 Ko sur 410 modèles et rendait « OSError: [Errno 7] + # Argument list too long ». Le mode qui couvre le plus est justement + # celui qui cassait. + # + # `-f` plutôt que l'entrée standard : `--single-transaction` n'est + # documenté qu'avec `-c` ou `-f`, et c'est lui qui garantit le tout + # ou rien. Le perdre en silence serait pire que le message d'erreur. + handle = tempfile.NamedTemporaryFile( + mode="w", + suffix=".sql", + prefix="el_anonymize_", + encoding="utf-8", + delete=False, ) + try: + handle.write(sql) + handle.close() + done = subprocess.run( + [ + "psql", + "-X", + "-w", + "-1", + "-v", + "ON_ERROR_STOP=1", + "-d", + database, + "-tA", + "-f", + handle.name, + ], + capture_output=True, + text=True, + env=env, + timeout=timeout + 60, + ) + finally: + try: + os.unlink(handle.name) + except OSError: + pass if done.returncode: detail = (done.stderr or "").strip().splitlines() return detail[0][:200] if detail else "psql" diff --git a/test/test_anonymize.py b/test/test_anonymize.py index c7eb172..6da204d 100644 --- a/test/test_anonymize.py +++ b/test/test_anonymize.py @@ -178,9 +178,9 @@ class TestTheSqlItWrites(unittest.TestCase): def test_a_unique_column_gets_the_id_appended(self): """Deux lignes au même mot feraient échouer TOUT l'UPDATE.""" sql = anon.expression_texte(champ("ref", unique=True), ["a"]) - self.assertIn("id::text", sql) + self.assertIn('"id"::text', sql) self.assertNotIn( - "id::text", anon.expression_texte(champ("ref"), ["a"]) + '"id"::text', anon.expression_texte(champ("ref"), ["a"]) ) def test_a_translated_column_is_rebuilt_key_by_key(self): @@ -366,3 +366,243 @@ class TestTheRefusalToWrite(unittest.TestCase): if __name__ == "__main__": unittest.main() + + +class TestTheSqlNeverTravelsThroughArgv(unittest.TestCase): + """La panne signalée : « OSError: [Errno 7] Argument list too long ». + + Linux plafonne UN SEUL argument à MAX_ARG_STRLEN — 32 pages, soit + 131 072 octets. Mesuré sur une base réelle : le mode hybride produit + 58 Ko de SQL et passait, la liste noire en produit 342 Ko sur 410 + modèles et cassait. Le mode qui couvre le plus était celui qui + échouait, donc celui qu'aucun de mes essais n'exerçait. + + Le rendu de `render` reste borné, lui ; c'est bien l'exécution qu'il + faut regarder, et pas seulement le plan. + """ + + def _executer(self, etapes): + """Lancer `ecrire` avec un faux psql, et rendre ce qu'il a reçu.""" + import script.analyse.anonymize as module + + vu = {} + vrai_run = module.__dict__.get("subprocess") + + class FauxFait: + returncode = 0 + stdout = "" + stderr = "" + + import subprocess as vrai_subprocess + + def espion(cmd, **kwargs): + vu["cmd"] = list(cmd) + chemin = cmd[cmd.index("-f") + 1] if "-f" in cmd else None + if chemin: + with open(chemin, encoding="utf-8") as handle: + vu["fichier"] = handle.read() + vu["chemin"] = chemin + return FauxFait() + + vrai_env = anon.lib_analyse.pg_env + anon.lib_analyse.pg_env = lambda *a, **k: {"PATH": "/usr/bin"} + vrai_subprocess_run = vrai_subprocess.run + vrai_subprocess.run = espion + try: + erreur = anon.ecrire("une_base", etapes) + finally: + vrai_subprocess.run = vrai_subprocess_run + anon.lib_analyse.pg_env = vrai_env + del vrai_run + vu["erreur"] = erreur + return vu + + def _gros_plan(self, combien=400): + """Un plan de la taille de celui qui cassait.""" + etapes = [] + for index in range(combien): + champ = { + "model": "m.%d" % index, + "name": "name", + "ttype": "char", + "pg_type": "character varying", + "unique": False, + "checked": False, + } + etapes.append( + { + "model": champ["model"], + "fields": [champ], + "sql": anon.sql_pour_table("m_%d" % index, [champ], None), + } + ) + return etapes + + def test_no_single_argument_comes_close_to_the_kernel_limit(self): + vu = self._executer(self._gros_plan()) + plus_gros = max(len(a) for a in vu["cmd"]) + self.assertLess( + plus_gros, + 4096, + "un argument porte le SQL : c'est ce qui rendait E2BIG", + ) + + def test_the_sql_goes_through_a_file_not_through_c(self): + vu = self._executer(self._gros_plan(3)) + self.assertIn("-f", vu["cmd"]) + self.assertNotIn("-c", vu["cmd"]) + self.assertIn('UPDATE "m_0"', vu["fichier"]) + + def test_the_single_transaction_survives_the_change(self): + """`--single-transaction` n'est documenté qu'avec -c ou -f : passer + par l'entrée standard l'aurait perdu en silence.""" + vu = self._executer(self._gros_plan(2)) + self.assertIn("-1", vu["cmd"]) + self.assertIn("ON_ERROR_STOP=1", vu["cmd"]) + + def test_the_temporary_file_does_not_survive(self): + import os as vrai_os + + vu = self._executer(self._gros_plan(2)) + self.assertFalse(vrai_os.path.exists(vu["chemin"])) + + +class TestABoundedColumnIsNeverOverflowed(unittest.TestCase): + """`value too long for type character varying(3)`. + + Mesuré sur une base réelle : 13 colonnes texte portent une longueur + déclarée, dont des codes à 1, 2 et 3 caractères — `res.country.code`, + `account.journal.code`. Y écrire « jonquille » fait échouer l'UPDATE, + et comme l'écriture est transactionnelle, TOUTE l'anonymisation. + + Le mode hybride ne touchait aucune de ces colonnes ; la liste noire, + si. Le mode qui couvre le plus est celui qui cassait. + """ + + def _champ(self, **kw): + base = { + "model": "m", + "name": "code", + "ttype": "char", + "pg_type": "character varying", + "unique": False, + "checked": False, + "max_len": None, + } + base.update(kw) + return base + + def test_a_bounded_column_is_truncated(self): + sql = anon.expression_texte(self._champ(max_len=3), ["jonquille"]) + self.assertIn("left(", sql) + self.assertIn(", 3)", sql) + + def test_an_unbounded_column_is_left_alone(self): + sql = anon.expression_texte(self._champ(), ["jonquille"]) + self.assertNotIn("left(", sql) + + def test_a_bounded_unique_column_keeps_the_id_in_front(self): + """Tronquer par la droite doit laisser l'identifiant intact : + c'est lui qui porte l'unicité.""" + sql = anon.expression_texte( + self._champ(max_len=8, unique=True), ["jonquille"] + ) + self.assertIn("left(\"id\"::text || '-'", sql) + + def test_an_unbounded_unique_column_keeps_the_old_shape(self): + sql = anon.expression_texte(self._champ(unique=True), ["jonquille"]) + self.assertTrue(sql.rstrip().endswith("|| '-' || \"id\"::text END")) + + def test_the_length_is_read_from_the_database_not_guessed(self): + """`atttypmod` est la seule source : une longueur devinée serait + fausse dès qu'un module en change une.""" + self.assertIn("atttypmod", anon.REQUETE_CHAMPS) + + +class TestReservedWordsCannotBreakTheStatement(unittest.TestCase): + """`syntax error at or near "user"`. + + Odoo laisse nommer un champ `user`, `order` ou `group`. Un identifiant + nu fait alors échouer l'analyse syntaxique — et l'écriture étant + transactionnelle, c'est toute l'anonymisation qui tombe. Trouvé en + liste noire sur une base réelle, jamais en mode hybride : les quinze + modèles par défaut n'en portent aucun. + """ + + def _champ(self, nom): + return { + "model": "m", + "name": nom, + "ttype": "char", + "pg_type": "character varying", + "unique": False, + "checked": False, + "max_len": None, + } + + def test_a_column_named_like_a_keyword_is_quoted(self): + for nom in ("user", "order", "group", "check", "references", "limit"): + sql = anon.sql_pour_table("t", [self._champ(nom)], ["a"]) + self.assertIn(f'"{nom}" =', sql, nom) + self.assertNotIn(f" {nom} =", sql, nom) + + def test_a_table_named_like_a_keyword_is_quoted(self): + sql = anon.sql_pour_table("order", [self._champ("name")], ["a"]) + self.assertTrue(sql.startswith('UPDATE "order" SET')) + + def test_the_row_identifier_is_quoted_too(self): + """Une seule règle vaut mieux que deux : tout identifiant est cité.""" + sql = anon.expression_texte(self._champ("name"), ["a", "b"]) + self.assertIn('"id"', sql) + + def test_a_quote_inside_an_identifier_cannot_escape(self): + self.assertEqual(anon.ident('a"b'), '"a""b"') + + +class TestACheckDoesNotSilenceTheMainField(unittest.TestCase): + """La règle « écarter toute colonne sous CHECK » était trop large. + + Mesuré : `res_partner.name` porte + CHECK ((type='contact' AND name IS NOT NULL) OR type<>'contact') + — une garantie de non-nullité, qu'un mot satisfait. L'écarter rendait + une anonymisation qui n'anonymisait pas les noms, en annonçant 255 + colonnes écrites. Le pire des deux mondes : silencieux et faux. + + Sur un NOMBRE la distinction s'inverse : `credit * debit = 0` et + `amount >= 0` bornent la valeur, et un tirage à 1000 les viole. + """ + + def test_the_query_tells_numbers_from_text(self): + """La règle vit dans le SQL : c'est là qu'elle se vérifie.""" + requete = anon.REQUETE_CHAMPS + self.assertIn("f.ttype IN ('integer','float','monetary')", requete) + self.assertIn("pg_get_constraintdef", requete) + + def test_only_shape_constraints_disqualify_text(self): + for motif in ("char_length", "~~", "jsonb_typeof"): + self.assertIn(motif, anon.REQUETE_CHAMPS, motif) + + def test_a_field_the_query_cleared_is_anonymised(self): + """`checked=False` doit suffire : aucune seconde barrière cachée.""" + champ = { + "model": "res.partner", + "name": "name", + "ttype": "char", + "pg_type": "character varying", + "unique": False, + "checked": False, + "max_len": None, + } + self.assertTrue(anon.champ_retenu(champ)) + + def test_a_field_the_query_flagged_is_left_alone(self): + champ = { + "model": "account.move.line", + "name": "credit", + "ttype": "monetary", + "pg_type": "numeric", + "unique": False, + "checked": True, + "max_len": None, + } + self.assertFalse(anon.champ_retenu(champ)) From 2437a9b5eae74941f2b98e424e5cbd69419e0203 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 25 Aug 2026 05:06:58 -0400 Subject: [PATCH 04/22] =?UTF-8?q?[FIX]=20analyse:=20ne=20pas=20=C3=A9crire?= =?UTF-8?q?=20de=20mots=20dans=20les=20champs=20qui=20portent=20des=20ids?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Odoo déclare `parent_path` en `char` mais y range un chemin d'identifiants — « 1/7/12/ » — reparsé aussitôt par int() : un mot écrit là casse le premier chargement de page. Sept modèles `_parent_store` en portent un dans une base ordinaire, et account_payment_term passe de même `days_next_month` à int(). L'anonymisation écarte les deux noms connus, puis sonde le contenu : une colonne dont chaque valeur est un chemin reste intacte, même dans un module maison. Les barres obliques sont exigées : un numéro tout en chiffres y échapperait. Vérifié sur une base de production : les sept modèles chargent, les partenaires sont anonymisés. --- EN --- Odoo declares `parent_path` as `char` but stores an identifier path in it — « 1/7/12/ » — parsed right back with int(): a word written there breaks the first page load. Seven `_parent_store` models carry one in an ordinary database, and account_payment_term likewise passes `days_next_month` to int(). Anonymisation skips the two known names, then probes the content: a column whose every value is a path is left alone, even in an in-house module. The slashes are required: a number of pure digits would escape it. Verified on a production database: the seven models load, partners are anonymised. Assisted-by: Claude Opus 5 (cherry picked from commit b47c94524d75172e7afce96e62f3f15b7ad6e225) --- script/analyse/anonymize.py | 103 +++++++++++++++++++++++++++++ script/todo/todo_i18n.py | 8 +++ test/test_anonymize.py | 128 ++++++++++++++++++++++++++++++++++++ 3 files changed, 239 insertions(+) diff --git a/script/analyse/anonymize.py b/script/analyse/anonymize.py index f3fbc97..d34cee5 100755 --- a/script/analyse/anonymize.py +++ b/script/analyse/anonymize.py @@ -106,6 +106,24 @@ CHAMPS_INTERDITS = frozenset( ) CHAMPS_CONNEXION = frozenset({"login", "password"}) +# Des `char` d'Odoo qui portent une STRUCTURE, pas du texte. Odoo les +# reparse, et un mot y fait lever le serveur entier : +# parent_path → `int(id) for id in parent_path.split('/')` +# (base/models/res_company.py:117, models.py:203) +# days_next_month → `int(self.days_next_month)` +# (account/models/account_payment_term.py:321) +# `parent_path` existe sur tout modèle `_parent_store` — sept dans une +# base ordinaire. La sonde de contenu ci-dessous les retrouverait, mais +# les nommer coûte moins cher qu'une requête et ne dépend pas des données +# présentes le jour où l'on passe. +CHAMPS_STRUCTURES = frozenset({"parent_path", "days_next_month"}) + +# Un chemin d'identifiants : « 1/ », « 1/7/12/ ». Le motif exige les +# barres obliques — sans elles, un numéro de téléphone tout en chiffres +# serait pris pour une structure et échapperait à l'anonymisation, ce +# qui serait un défaut de confidentialité, pas de robustesse. +MOTIF_CHEMIN = r"^[0-9]+(/[0-9]+)*/$" + # Le point de départ du mode hybride : ce qui porte des données # personnelles dans une base Odoo ordinaire. MODELES_PAR_DEFAUT = ( @@ -197,6 +215,8 @@ def champ_retenu(champ, inclure_connexion=False): if champ["name"].endswith("_id") or champ["name"].endswith("_ids"): # Une relation qui aurait échappé au filtre de ttype. return False + if champ["name"] in CHAMPS_STRUCTURES: + return False if champ.get("checked"): # Une contrainte CHECK hors de portée. Mesuré, et la distinction # compte : sur un NOMBRE toute contrainte borne la valeur — @@ -428,6 +448,75 @@ def plan( return etapes +def colonnes_structurees(database, etapes, config_path=None): + """Les colonnes texte dont TOUT le contenu est un chemin d'identifiants. + + Le filet général, là où `CHAMPS_STRUCTURES` ne nomme que le connu : + un module maison peut poser son propre champ de chemin, et il ne + portera pas ce nom-là. On regarde donc ce que la colonne CONTIENT. + + Une colonne est écartée seulement si elle est NON VIDE et que toutes + ses valeurs sont des chemins. Un seul contre-exemple suffit à la + garder : mieux vaut anonymiser une colonne douteuse que taire une + donnée personnelle. + + Une requête par TABLE, pas par colonne : sur une liste noire de 410 + modèles, la différence est de 410 allers-retours au lieu de 863. + """ + ecartees = {} + for etape in etapes: + textes = [ + champ + for champ in etape["fields"] + if champ["ttype"] in TYPES_TEXTE and champ["pg_type"] != "jsonb" + ] + if not textes: + continue + morceaux = [] + for champ in textes: + nom = ident(champ["name"]) + morceaux.append( + f"count(*) FILTER (WHERE {nom} IS NOT NULL AND {nom} <> '')" + f" || ':' || count(*) FILTER (WHERE {nom} ~ {litteral(MOTIF_CHEMIN)})" + ) + sql = ( + "SELECT " + + " || '\x1f' || ".join(morceaux) + + f" FROM {ident(table_de(etape['model']))}" + ) + try: + brut = lib_analyse.run_psql( + database, sql, config_path=config_path + ).strip() + except Exception: # noqa: BLE001 - une table illisible ne bloque pas + continue + for champ, mesure in zip(textes, brut.split(SEP)): + try: + remplies, chemins = (int(x) for x in mesure.split(":")) + except ValueError: + continue + if remplies and remplies == chemins: + ecartees.setdefault(etape["model"], []).append(champ["name"]) + return ecartees + + +def sans_structurees(etapes, ecartees, mots): + """Refaire le plan sans les colonnes que la sonde a écartées.""" + if not ecartees: + return etapes + propre = [] + for etape in etapes: + exclues = set(ecartees.get(etape["model"], ())) + gardes = [c for c in etape["fields"] if c["name"] not in exclues] + # Pas de garde sur une liste vide : `sql_pour_table` rend None, et + # le `if sql` ci-dessous l'écarte. Deux vérifications pour la même + # chose se contredisent un jour. + sql = sql_pour_table(table_de(etape["model"]), gardes, mots) + if sql: + propre.append({**etape, "fields": gardes, "sql": sql}) + return propre + + def render(etapes, applique=False, verbeux=False): """Le rapport. Il dit ce qui est ÉCARTÉ autant que ce qui est pris.""" if not etapes: @@ -606,6 +695,20 @@ def main(argv=None): args.include_logins, mots, ) + # La sonde AVANT le rendu : la marche à blanc doit montrer ce que + # `--apply` ferait, pas une approximation plus large. + ecartees = colonnes_structurees(args.database, etapes, args.config) + etapes = sans_structurees(etapes, ecartees, mots) + if ecartees: + combien = sum(len(v) for v in ecartees.values()) + print( + f"🧭 {combien} {t('column(s) hold identifier paths and are left')}" + f" {t('alone:')}" + ) + for modele in sorted(ecartees): + print(f" {modele} : {', '.join(sorted(ecartees[modele]))}") + print() + if not args.apply: print(render(etapes, applique=False, verbeux=args.verbose)) return 1 if etapes else 0 diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 8f86596..f7feb41 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -10108,6 +10108,14 @@ TRANSLATIONS = { "fr": "désactive pas les crons, et laisse les clés de paiement en place.", "en": "not disable crons, and leaves payment keys in place.", }, + "column(s) hold identifier paths and are left": { + "fr": "colonne(s) portent des chemins d'identifiants et sont laissées", + "en": "column(s) hold identifier paths and are left", + }, + "alone:": { + "fr": "intactes :", + "en": "alone:", + }, } diff --git a/test/test_anonymize.py b/test/test_anonymize.py index 6da204d..c845479 100644 --- a/test/test_anonymize.py +++ b/test/test_anonymize.py @@ -606,3 +606,131 @@ class TestACheckDoesNotSilenceTheMainField(unittest.TestCase): "max_len": None, } self.assertFalse(anon.champ_retenu(champ)) + + +class TestStructuredCharFieldsSurvive(unittest.TestCase): + """`ValueError: invalid literal for int() with base 10: 'bruyere'`. + + Odoo déclare `parent_path` en `char`, mais y range un CHEMIN + D'IDENTIFIANTS — « 1/7/12/ » — qu'il reparse : + + int(id) for id in company.parent_path.split('/') + base/models/res_company.py:117, models.py:203 et :221 + + Y écrire un mot fait lever le serveur au premier chargement de page. + Mesuré : sept modèles `_parent_store` dans une base ordinaire — + res.company, product.category, stock.location, hr.department, + website.menu, account.analytic.plan, helpdesk.ticket.category. + + Même chose pour `days_next_month`, qu'Odoo passe à `int()` + (account/models/account_payment_term.py:321). + """ + + def _champ(self, nom): + return { + "model": "res.company", + "name": nom, + "ttype": "char", + "pg_type": "character varying", + "unique": False, + "checked": False, + "max_len": None, + } + + def test_the_known_parsers_are_named(self): + self.assertIn("parent_path", anon.CHAMPS_STRUCTURES) + self.assertIn("days_next_month", anon.CHAMPS_STRUCTURES) + + def test_they_are_never_touched_whatever_the_model(self): + for nom in anon.CHAMPS_STRUCTURES: + self.assertFalse(anon.champ_retenu(self._champ(nom)), nom) + + def test_a_phone_number_is_not_mistaken_for_a_structure(self): + """Le motif EXIGE les barres obliques. Sans elles, un numéro tout + en chiffres passerait pour une structure et échapperait à + l'anonymisation — un défaut de confidentialité, pas de robustesse. + """ + import re + + motif = re.compile(anon.MOTIF_CHEMIN) + for valeur in ("5141234567", "0", "42", "1234-5678"): + self.assertIsNone(motif.match(valeur), valeur) + for valeur in ("1/", "1/7/12/", "3/4/"): + self.assertIsNotNone(motif.match(valeur), valeur) + + def test_the_probe_asks_once_per_table_not_once_per_column(self): + """Sur 410 modèles, la différence est de 410 allers-retours au + lieu de 863.""" + appels = [] + + def espion(database, sql, config_path=None): + appels.append(sql) + return "0:0\x1f0:0" + + vrai = anon.lib_analyse.run_psql + anon.lib_analyse.run_psql = espion + etapes = [ + { + "model": "res.company", + "fields": [self._champ("name"), self._champ("street")], + "sql": "x", + } + ] + try: + anon.colonnes_structurees("base", etapes) + finally: + anon.lib_analyse.run_psql = vrai + self.assertEqual(len(appels), 1) + self.assertIn('"name"', appels[0]) + self.assertIn('"street"', appels[0]) + + def test_one_counter_example_is_enough_to_keep_a_column(self): + """Mieux vaut anonymiser une colonne douteuse que taire une + donnée personnelle.""" + + def espion(database, sql, config_path=None): + # 10 valeurs remplies, 9 seulement sont des chemins. + return "10:9" + + vrai = anon.lib_analyse.run_psql + anon.lib_analyse.run_psql = espion + etapes = [ + {"model": "m", "fields": [self._champ("chemin")], "sql": "x"} + ] + try: + ecartees = anon.colonnes_structurees("base", etapes) + finally: + anon.lib_analyse.run_psql = vrai + self.assertEqual(ecartees, {}) + + def test_an_all_paths_column_is_dropped_from_the_plan(self): + def espion(database, sql, config_path=None): + return "10:10" + + vrai = anon.lib_analyse.run_psql + anon.lib_analyse.run_psql = espion + etapes = [ + { + "model": "m", + "fields": [self._champ("chemin"), self._champ("nom")], + "sql": "x", + } + ] + try: + ecartees = anon.colonnes_structurees("base", etapes) + finally: + anon.lib_analyse.run_psql = vrai + # Les deux colonnes rendent le même compte ici : c'est le principe + # qu'on vérifie, pas la ligne exacte. + self.assertIn("m", ecartees) + propre = anon.sans_structurees(etapes, {"m": ["chemin"]}, None) + self.assertEqual([c["name"] for c in propre[0]["fields"]], ["nom"]) + self.assertNotIn('"chemin"', propre[0]["sql"]) + + def test_a_model_entirely_dropped_leaves_no_empty_statement(self): + etapes = [ + {"model": "m", "fields": [self._champ("chemin")], "sql": "x"} + ] + self.assertEqual( + anon.sans_structurees(etapes, {"m": ["chemin"]}, None), [] + ) From 46eebf085908d836d9b74c55c10818ec61441674 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 25 Aug 2026 05:28:02 -0400 Subject: [PATCH 05/22] =?UTF-8?q?[ADD]=20run=20:=20pr=C3=A9charger=20le=20?= =?UTF-8?q?registre=20Odoo=20au=20lancement,=20sans=20nuire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Odoo ne charge le registre d'une base qu'à la PREMIÈRE requête : sur une base migrée, la personne qui ouvre la page attend des dizaines de secondes. La sonde prend ce temps à sa place et s'arrête à la première réponse : un 303, un 404 ou un 500 prouvent tous que le registre est chargé. Elle ne peut pas nuire : elle rend TOUJOURS 0, meurt avec run.sh par un trap et abandonne après deux minutes. Le port vient de la ligne de commande, puis de config.conf, puis du journal, exact même quand le port demandé était pris ; l'écoute sur une interface vide ou générale est sondée par le bouclage. `--erplibre-disable-warmup-http` la coupe, et elle seule est RETIRÉE avant odoo_bin.sh, qu'Odoo refuse ; `--no-http` et `--stop-after-init` aussi. --- EN --- Odoo only loads a database's registry on the FIRST request: on a migrated database, whoever opens the page waits tens of seconds. The probe takes that time instead and stops at the first answer: a 303, a 404 or a 500 all prove the registry is loaded. It cannot get in the way: it ALWAYS returns 0, dies with run.sh through a trap and gives up after two minutes. The port comes from the command line, then config.conf, then the log, exact even when the requested port was taken; an empty or wildcard listen interface is probed on the loopback. `--erplibre-disable-warmup-http` turns it off, and it alone is STRIPPED before odoo_bin.sh, which Odoo rejects; `--no-http` and `--stop-after-init` too. Assisted-by: Claude Opus 5 (cherry picked from commit d0943782029ac9a23830d8a8041fccbcac98b67d) --- run.sh | 29 ++++ script/odoo/warmup_http.py | 227 +++++++++++++++++++++++++ test/test_warmup_http.py | 331 +++++++++++++++++++++++++++++++++++++ 3 files changed, 587 insertions(+) create mode 100755 script/odoo/warmup_http.py create mode 100644 test/test_warmup_http.py diff --git a/run.sh b/run.sh index 119c695..8c2a35f 100755 --- a/run.sh +++ b/run.sh @@ -17,6 +17,7 @@ fi # main : Odoo ne les connaît pas et mourrait sur « no such option ». # --auto-erplibre arme le choix de la base à démarrer # --no-cli-erplibre interdit le menu, sans interdire le choix +# --erplibre-disable-warmup-http n'envoie pas la requête de réveil # Sans AUCUN argument, le choix s'arme de lui-même : c'est « make run », # quelqu'un devant son terminal. Ce défaut-là reste timide — il exige un # terminal des deux côtés — parce que systemd lance lui aussi run.sh sans @@ -25,6 +26,7 @@ EL_ARGS=() EL_AUTO=0 EL_AUTO_EXPLICITE=0 EL_NO_CLI=0 +EL_WARMUP=1 [ $# -eq 0 ] && EL_AUTO=1 while [ $# -gt 0 ]; do case "$1" in @@ -33,6 +35,13 @@ while [ $# -gt 0 ]; do EL_AUTO_EXPLICITE=1 ;; --no-cli-erplibre) EL_NO_CLI=1 ;; + --erplibre-disable-warmup-http) EL_WARMUP=0 ;; + # Rien à réveiller si personne n'écoute. On les laisse passer à Odoo, + # on se contente de ne pas sonder. + --no-http|--stop-after-init) + EL_WARMUP=0 + EL_ARGS+=("$1") + ;; # Tout le reste passe tel quel, y compris les arguments vides et ceux # qui portent des espaces : un tableau, jamais une chaîne reconstruite. *) EL_ARGS+=("$1") ;; @@ -58,6 +67,26 @@ if [ "${EL_AUTO}" = "1" ] && [ -f "${EL_LIB}" ]; then fi fi +# Le réveil, EN PARALLÈLE. Odoo ne charge le registre d'une base qu'à la +# première requête qui la concerne ; sur une base migrée cela prend des +# dizaines de secondes, et c'est la personne qui ouvre la page qui les +# attend. La sonde prend ce temps à sa place, se tait, et meurt avec nous. +EL_WARMUP_PID="" +EL_WARMUP_BIN="./script/odoo/warmup_http.py" +if [ "${EL_WARMUP}" = "1" ] && [ "$ODOO_MODE_TEST" != "true" ] \ + && [ -f "${EL_WARMUP_BIN}" ]; then + EL_WARMUP_DB=() + [ -n "${EL_DB_NAME}" ] && EL_WARMUP_DB=(-d "${EL_DB_NAME}") + # `--` sépare NOS options de celles d'Odoo : sans lui, un « -c » destiné + # à Odoo serait lu comme le nôtre. + python3 "${EL_WARMUP_BIN}" -c "${CONFIG_PATH}" "${EL_WARMUP_DB[@]}" \ + -- "${EL_ARGS[@]}" 2>/dev/null & + EL_WARMUP_PID=$! + # Elle ne doit pas survivre au serveur qu'elle réveille. Un Ctrl-C sur + # run.sh doit tout emporter. + trap '[ -n "${EL_WARMUP_PID}" ] && kill "${EL_WARMUP_PID}" 2>/dev/null' EXIT +fi + if [ "$ODOO_MODE_TEST" = "true" ]; then ./odoo_bin.sh -c "${CONFIG_PATH}" --limit-time-real 99999 --limit-time-cpu 99999 --limit-memory-hard=0 --log-level=test --test-enable --no-http --stop-after-init "${EL_DB[@]}" "${EL_ARGS[@]}" else diff --git a/script/odoo/warmup_http.py b/script/odoo/warmup_http.py new file mode 100755 index 0000000..0dd3dca --- /dev/null +++ b/script/odoo/warmup_http.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Réveiller l'instance dès qu'elle écoute, puis disparaître. + +Odoo ne charge le registre d'une base qu'à la PREMIÈRE requête qui la +concerne. Sur une base migrée, ce premier chargement prend des dizaines de +secondes — et c'est la personne qui ouvre la page qui les attend. Lancée +en parallèle de `run.sh`, cette sonde prend ce temps à sa place. + +Trois règles, et elles tiennent au fait qu'elle tourne À CÔTÉ du serveur +-------------------------------------------------------------------- +1. Elle se TAIT. Une seule ligne, au démarrage, pour dire quelle adresse + elle a choisie — c'est ce qui permet de comprendre après coup un + réveil qui n'a pas eu lieu. Ensuite plus rien : ni échec, ni trace. + Un serveur qu'on arrête au bout de dix secondes est un cas ordinaire, + pas une erreur, et un message d'erreur dans le journal de `run.sh` + ferait chercher une panne qui n'existe pas. +2. Elle s'arrête à la PREMIÈRE réponse, quelle qu'elle soit. Un 303 vers + /web/login, un 404, un 500 : tous prouvent que le registre est chargé, + ce qui est l'objet. Seul le refus de connexion ne compte pas. +3. Elle meurt au bout de deux minutes. Le réveil est un confort ; une + sonde qui survit à son serveur en est un autre, mauvais. + +Où elle trouve le port +---------------------- +D'abord la ligne de commande — `-p 8090` l'emporte sur tout le reste, +c'est ce que la personne vient de taper. Puis `config.conf`, que `run.sh` +a déjà résolu et qu'elle reçoit tel quel. Le journal d'exécution ne sert +qu'en dernier recours : Odoo y écrit « HTTP service (werkzeug) running on +127.0.0.1:8069 » une fois qu'il écoute, ce qui arrive parfois APRÈS le +premier essai — mais c'est aussi la seule source exacte quand le port +demandé était déjà pris. +""" + +from __future__ import annotations + +import os +import re +import sys +import time + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..")) +) + +from script.analyse import lib_analyse # noqa: E402 + +DELAI_TOTAL = 120.0 +DELAI_ENTRE_ESSAIS = 1.0 +DELAI_REQUETE = 10.0 +PORT_PAR_DEFAUT = 8069 +HOTE_PAR_DEFAUT = "127.0.0.1" + +# « HTTP service (werkzeug) running on 127.0.0.1:8069 » +MOTIF_JOURNAL = re.compile( + r"HTTP service \(werkzeug\) running on ([^:\s]+):(\d+)" +) + + +def port_de_la_ligne(argv): + """Le port demandé sur la ligne de commande, ou None. + + `-p 8090`, `--http-port 8090` et `--http-port=8090` : les trois + formes existent, et Odoo les accepte toutes. + """ + for index, argument in enumerate(argv): + if argument in ("-p", "--http-port") and index + 1 < len(argv): + valeur = argv[index + 1] + if valeur.isdigit(): + return int(valeur) + if argument.startswith("--http-port="): + valeur = argument.split("=", 1)[1] + if valeur.isdigit(): + return int(valeur) + return None + + +def adresse_du_journal(chemin, taille=8192): + """(hôte, port) lus dans la fin du journal, ou (None, None). + + On ne lit que la queue : un journal de migration pèse des mégaoctets, + et la ligne qu'on cherche est la dernière écrite. + """ + if not chemin or not os.path.isfile(chemin): + return None, None + try: + with open(chemin, "rb") as handle: + handle.seek(0, os.SEEK_END) + debut = max(0, handle.tell() - taille) + handle.seek(debut) + queue = handle.read().decode("utf-8", "replace") + except OSError: + return None, None + trouve = MOTIF_JOURNAL.findall(queue) + if not trouve: + return None, None + hote, port = trouve[-1] + return hote, int(port) + + +def adresse(argv=(), config_path=None, journal=None): + """(hôte, port) à réveiller, dans l'ordre de ce qui fait autorité.""" + config = lib_analyse.read_config(config_path) + + port = port_de_la_ligne(argv) + hote = None + if port is None: + hote, port = adresse_du_journal(journal) + if port is None: + brut = str(config.get("http_port", "")).strip() + port = int(brut) if brut.isdigit() else PORT_PAR_DEFAUT + + if not hote: + interface = str(config.get("http_interface", "")).strip() + # Une interface vide veut dire « toutes » : on se parle à soi-même. + # 0.0.0.0 n'est pas une adresse de destination. + if interface.lower() in ("", "false", "none", "0.0.0.0", "::"): + hote = HOTE_PAR_DEFAUT + else: + hote = interface + return hote, port + + +def base_a_reveiller(argv=(), config_path=None): + """La base dont on veut le registre, ou None. + + `-d` d'abord : c'est celle que `run.sh` vient de choisir. Sinon + `db_name` de la configuration, qu'Odoo utiliserait de toute façon. + """ + for index, argument in enumerate(argv): + if argument in ("-d", "--database") and index + 1 < len(argv): + return argv[index + 1] + if argument.startswith("--database="): + return argument.split("=", 1)[1] + valeur = str( + lib_analyse.read_config(config_path).get("db_name", "") + ).strip() + return valeur if valeur.lower() not in ("", "false", "none") else None + + +def url(hote, port, database=None): + """L'adresse à demander. + + `/web/login` plutôt que `/` : elle ne redirige pas vers un tableau de + bord, ne crée pas de session, et suffit à faire charger le registre. + Le paramètre `db` dit LAQUELLE quand plusieurs bases répondent. + """ + base = f"http://{hote}:{port}/web/login" + if database: + from urllib.parse import quote + + return f"{base}?db={quote(database)}" + return base + + +def sonder( + cible, + delai_total=DELAI_TOTAL, + entre_essais=DELAI_ENTRE_ESSAIS, + requete=DELAI_REQUETE, + horloge=time.monotonic, + dormir=time.sleep, +): + """Demander jusqu'à obtenir une réponse. Rendre True si elle est venue. + + N'importe quel code HTTP est une réussite : 303, 404 ou 500 prouvent + tous que le serveur a répondu, donc que le registre est chargé. Seul + le refus de connexion fait recommencer. + """ + import urllib.error + import urllib.request + + fin = horloge() + delai_total + while horloge() < fin: + try: + with urllib.request.urlopen(cible, timeout=requete): + return True + except urllib.error.HTTPError: + # Le serveur a répondu, et c'est tout ce qu'on demandait. + return True + except Exception: # noqa: BLE001 - un serveur pas encore prêt + pass + dormir(entre_essais) + return False + + +def main(argv=None): + """Toujours rendre 0 : personne ne doit lire ce code de retour. + + La sonde tourne en arrière-plan d'un `run.sh` qui, lui, rapporte + l'état d'Odoo. Un code non nul finirait dans un journal et ferait + croire à une panne du serveur. + """ + import argparse + + parser = argparse.ArgumentParser(description="Warm up the HTTP registry.") + parser.add_argument("-c", "--config") + parser.add_argument("-d", "--database") + parser.add_argument("--log") + parser.add_argument("--timeout", type=float, default=DELAI_TOTAL) + parser.add_argument("--quiet", action="store_true") + parser.add_argument("odoo_args", nargs="*") + # `parse_known_args` et non `parse_args` : les arguments d'Odoo nous + # traversent, et l'un d'eux ressemblera un jour à une option qu'on ne + # connaît pas. `parse_args` sortirait alors par SystemExit — ce qui + # romprait la seule promesse qui compte ici : ne jamais gêner. + args, restes = parser.parse_known_args(argv) + args.odoo_args = list(args.odoo_args) + list(restes) + + try: + hote, port = adresse(args.odoo_args, args.config, args.log) + database = args.database or base_a_reveiller( + args.odoo_args, args.config + ) + cible = url(hote, port, database) + if not args.quiet: + print(f"🔥 warmup: {cible}", flush=True) + sonder(cible, delai_total=args.timeout) + except Exception: # noqa: BLE001 - le réveil est un confort, pas un dû + pass + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/test_warmup_http.py b/test/test_warmup_http.py new file mode 100644 index 0000000..faf1aaf --- /dev/null +++ b/test/test_warmup_http.py @@ -0,0 +1,331 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Le réveil HTTP : il doit se taire, et ne jamais gêner. + +Cette sonde tourne à côté du serveur, pas devant lui. Tout ce qu'elle +peut faire de mal, elle le ferait en parlant : un message d'erreur dans +le journal de `run.sh` ferait chercher une panne d'Odoo qui n'existe pas, +et un code de retour non nul ferait échouer un démarrage réussi. + +D'où les deux propriétés que ce fichier épingle avant les autres : elle +rend TOUJOURS 0, et elle n'écrit qu'une ligne — l'adresse choisie, parce +que c'est la seule chose qui permette de comprendre après coup un réveil +qui n'a pas eu lieu. + +Le troisième sujet est l'option elle-même. `--erplibre-disable-warmup-http` +doit être RETIRÉE avant `odoo_bin.sh` : Odoo meurt sur « no such option », +et le démarrage entier tombe pour un confort. +""" + +import io +import unittest +from pathlib import Path + +from script.odoo import warmup_http as warmup + +RUN_SH = Path(__file__).resolve().parent.parent / "run.sh" + + +class TestWhereItKnocks(unittest.TestCase): + """Le port, dans l'ordre de ce qui fait autorité.""" + + def test_the_command_line_wins_over_everything(self): + for argv in ( + ["-p", "8090"], + ["--http-port", "8090"], + ["--http-port=8090"], + ): + self.assertEqual(warmup.port_de_la_ligne(argv), 8090, argv) + + def test_a_port_that_is_not_a_number_is_ignored(self): + self.assertIsNone(warmup.port_de_la_ligne(["-p", "huit-mille"])) + self.assertIsNone(warmup.port_de_la_ligne(["-p"])) + + def test_without_anything_it_falls_back_on_the_default(self): + vrai = warmup.lib_analyse.read_config + warmup.lib_analyse.read_config = lambda *a, **k: {} + try: + self.assertEqual( + warmup.adresse(), + (warmup.HOTE_PAR_DEFAUT, warmup.PORT_PAR_DEFAUT), + ) + finally: + warmup.lib_analyse.read_config = vrai + + def test_an_empty_interface_means_ourselves(self): + """`http_interface =` veut dire « toutes » ; 0.0.0.0 n'est pas une + adresse de destination.""" + vrai = warmup.lib_analyse.read_config + for valeur in ("", "False", "0.0.0.0", "::"): + warmup.lib_analyse.read_config = lambda *a, v=valeur, **k: { + "http_interface": v + } + try: + self.assertEqual(warmup.adresse()[0], "127.0.0.1", valeur) + finally: + warmup.lib_analyse.read_config = vrai + + def test_a_real_interface_is_used_as_is(self): + vrai = warmup.lib_analyse.read_config + warmup.lib_analyse.read_config = lambda *a, **k: { + "http_interface": "10.0.0.5", + "http_port": "8071", + } + try: + self.assertEqual(warmup.adresse(), ("10.0.0.5", 8071)) + finally: + warmup.lib_analyse.read_config = vrai + + +class TestReadingTheExecutionLog(unittest.TestCase): + """La seule source exacte quand le port demandé était déjà pris.""" + + def _journal(self, contenu): + import tempfile + + handle = tempfile.NamedTemporaryFile( + mode="w", suffix=".log", delete=False, encoding="utf-8" + ) + handle.write(contenu) + handle.close() + self.addCleanup(lambda: Path(handle.name).unlink(missing_ok=True)) + return handle.name + + def test_it_reads_the_address_odoo_announced(self): + chemin = self._journal( + "blah\nHTTP service (werkzeug) running on 127.0.0.1:8075\nblah\n" + ) + self.assertEqual( + warmup.adresse_du_journal(chemin), ("127.0.0.1", 8075) + ) + + def test_the_last_line_wins(self): + """Un redémarrage laisse les deux ; la dernière est la vraie.""" + chemin = self._journal( + "HTTP service (werkzeug) running on 127.0.0.1:8069\n" + "HTTP service (werkzeug) running on 127.0.0.1:8070\n" + ) + self.assertEqual(warmup.adresse_du_journal(chemin)[1], 8070) + + def test_a_missing_or_silent_log_says_nothing(self): + self.assertEqual(warmup.adresse_du_journal(None), (None, None)) + self.assertEqual(warmup.adresse_du_journal("/pas/la"), (None, None)) + self.assertEqual( + warmup.adresse_du_journal(self._journal("rien\n")), (None, None) + ) + + def test_the_command_line_still_wins_over_the_log(self): + chemin = self._journal( + "HTTP service (werkzeug) running on 127.0.0.1:8075\n" + ) + self.assertEqual( + warmup.adresse(["-p", "8090"], journal=chemin)[1], 8090 + ) + + +class TestWhichDatabase(unittest.TestCase): + def test_the_one_run_sh_just_chose(self): + self.assertEqual(warmup.base_a_reveiller(["-d", "ma_base"]), "ma_base") + self.assertEqual( + warmup.base_a_reveiller(["--database=ma_base"]), "ma_base" + ) + + def test_otherwise_the_one_odoo_would_use(self): + vrai = warmup.lib_analyse.read_config + warmup.lib_analyse.read_config = lambda *a, **k: {"db_name": "prod"} + try: + self.assertEqual(warmup.base_a_reveiller([]), "prod") + finally: + warmup.lib_analyse.read_config = vrai + + def test_db_name_false_is_not_a_database(self): + vrai = warmup.lib_analyse.read_config + for valeur in ("False", "", "None"): + warmup.lib_analyse.read_config = lambda *a, v=valeur, **k: { + "db_name": v + } + try: + self.assertIsNone(warmup.base_a_reveiller([]), valeur) + finally: + warmup.lib_analyse.read_config = vrai + + def test_the_name_is_escaped_in_the_url(self): + self.assertIn("db=ma%20base", warmup.url("127.0.0.1", 8069, "ma base")) + + def test_without_a_database_the_url_carries_no_parameter(self): + self.assertEqual( + warmup.url("127.0.0.1", 8069), "http://127.0.0.1:8069/web/login" + ) + + +class TestWhenItStops(unittest.TestCase): + """N'importe quelle réponse suffit ; seul le refus fait recommencer.""" + + def _sonder(self, reponses, delai=10.0): + import urllib.error + import urllib.request + + essais = [] + temps = [0.0] + + def faux_urlopen(cible, timeout=None): + essais.append(cible) + resultat = ( + reponses.pop(0) if reponses else ConnectionRefusedError() + ) + if isinstance(resultat, Exception): + raise resultat + return io.BytesIO(b"") + + vrai = urllib.request.urlopen + urllib.request.urlopen = faux_urlopen + try: + venu = warmup.sonder( + "http://x", + delai_total=delai, + entre_essais=0, + horloge=lambda: temps[0], + dormir=lambda s: temps.__setitem__(0, temps[0] + 1), + ) + finally: + urllib.request.urlopen = vrai + return venu, essais + + def test_a_first_answer_ends_it(self): + venu, essais = self._sonder([None]) + self.assertTrue(venu) + self.assertEqual(len(essais), 1) + + def test_an_http_error_is_an_answer(self): + """303, 404, 500 : tous prouvent que le registre est chargé.""" + import urllib.error + + erreur = urllib.error.HTTPError("u", 500, "boom", {}, None) + venu, essais = self._sonder([erreur]) + self.assertTrue(venu) + self.assertEqual(len(essais), 1) + + def test_a_refused_connection_makes_it_try_again(self): + venu, essais = self._sonder([ConnectionRefusedError(), None]) + self.assertTrue(venu) + self.assertEqual(len(essais), 2) + + def test_it_gives_up_at_the_deadline(self): + venu, essais = self._sonder([], delai=5.0) + self.assertFalse(venu) + self.assertEqual(len(essais), 5) + + def test_the_default_deadline_is_two_minutes(self): + self.assertEqual(warmup.DELAI_TOTAL, 120.0) + + +class TestItNeverGetsInTheWay(unittest.TestCase): + """La propriété qui compte le plus : elle ne peut pas nuire.""" + + def test_it_always_returns_zero(self): + vrai = warmup.sonder + warmup.sonder = lambda *a, **k: (_ for _ in ()).throw( + RuntimeError("x") + ) + try: + self.assertEqual(warmup.main(["--quiet"]), 0) + finally: + warmup.sonder = vrai + + def test_a_broken_config_does_not_stop_it(self): + vrai = warmup.lib_analyse.read_config + warmup.lib_analyse.read_config = lambda *a, **k: (_ for _ in ()).throw( + OSError("illisible") + ) + try: + self.assertEqual(warmup.main(["--quiet"]), 0) + finally: + warmup.lib_analyse.read_config = vrai + + def test_it_announces_the_address_it_chose(self): + """La seule ligne qu'elle écrit, et celle qui rend le silence + compréhensible plus tard.""" + import contextlib + + vrai = warmup.sonder + warmup.sonder = lambda *a, **k: True + sortie = io.StringIO() + try: + with contextlib.redirect_stdout(sortie): + warmup.main(["-d", "ma_base", "--", "-p", "8090"]) + finally: + warmup.sonder = vrai + texte = sortie.getvalue() + self.assertEqual(len(texte.strip().splitlines()), 1) + self.assertIn("ma_base", texte) + + def test_an_option_it_does_not_know_cannot_kill_it(self): + """Les arguments d'Odoo nous traversent : l'un d'eux ressemblera un + jour à une option inconnue, et `parse_args` sortirait par SystemExit. + """ + vrai = warmup.sonder + warmup.sonder = lambda *a, **k: True + try: + self.assertEqual(warmup.main(["--quiet", "--option-a-odoo"]), 0) + finally: + warmup.sonder = vrai + + def test_quiet_says_nothing_at_all(self): + import contextlib + + vrai = warmup.sonder + warmup.sonder = lambda *a, **k: True + sortie = io.StringIO() + try: + with contextlib.redirect_stdout(sortie): + warmup.main(["--quiet"]) + finally: + warmup.sonder = vrai + self.assertEqual(sortie.getvalue(), "") + + +class TestHowRunShWiresIt(unittest.TestCase): + """Une option qu'Odoo ne connaît pas le tue au démarrage.""" + + @classmethod + def setUpClass(cls): + cls.source = RUN_SH.read_text(encoding="utf-8") + + def test_the_flag_is_stripped_and_never_forwarded(self): + bloc = self.source[ + self.source.index("--erplibre-disable-warmup-http)") : + ][:120] + self.assertNotIn("EL_ARGS+=", bloc) + + def test_no_http_disables_it_but_still_reaches_odoo(self): + """Rien à réveiller, mais Odoo doit garder son option.""" + bloc = self.source[ + self.source.index("--no-http|--stop-after-init)") : + ][:200] + self.assertIn("EL_WARMUP=0", bloc) + self.assertIn('EL_ARGS+=("$1")', bloc) + + def test_it_runs_in_parallel(self): + lancement = [ + texte + for texte in self.source.splitlines() + if texte.strip().endswith("&") and "2>/dev/null" in texte + ] + self.assertTrue(lancement, "le réveil ne part pas en arrière-plan") + + def test_it_does_not_survive_the_server(self): + self.assertIn("trap", self.source) + self.assertIn("EL_WARMUP_PID", self.source) + + def test_its_own_errors_are_swallowed(self): + self.assertIn("2>/dev/null &", self.source) + + def test_odoo_arguments_are_separated_by_a_double_dash(self): + """Sans `--`, un « -c » destiné à Odoo serait lu comme le nôtre.""" + self.assertIn('-- "${EL_ARGS[@]}" 2>/dev/null &', self.source) + + +if __name__ == "__main__": + unittest.main() From 55a093a86d13189fc1b417d932723287c0caf32a Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 25 Aug 2026 05:40:44 -0400 Subject: [PATCH 06/22] [ADD] convention : ce qu'un sujet de commit doit dire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le manuel encadrait le corps en détail — pourquoi, dix lignes par langue, ce qu'on ne répète pas du diff — et ne disait du sujet que « courte, à l'impératif ». Le sujet est pourtant lu cent fois pour une fois que le corps l'est : git log --oneline, un blame, un bisect, une note de version. Faute de règle, les sujets dérivent vers le symptôme et la métaphore, qui se lisent bien sans nommer la partie du système en jeu. La règle ajoute une épreuve, pas un gabarit : lire le sujet seul, sans diff ni corps, et savoir ce qui change et où. Les trois exemples avant/après viennent de l'historique du dépôt, un exemple inventé ne convainquant personne. --- EN --- The manual framed the body in detail — why, ten lines per language, what not to repeat from the diff — and said of the subject only that it be short and imperative. Yet the subject is read a hundred times for every reading of the body: git log --oneline, a blame, a bisect, a release note. With no rule, subjects drift towards the symptom and the metaphor, which read well without naming the part of the system at stake. The rule adds a test, not a template: read the subject alone, with no diff and no body, and be able to say what changes and where. The three before/after pairs come from the repository's own history, an invented example convincing nobody. Assisted-by: Claude Opus 5 (cherry picked from commit c86357eab899c65f41287d4f19e88d0da98285c4) --- .claude/rules/04-code-conventions.md | 20 +++++++++++++++ conf/template_claude_commands_commit.md | 33 +++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/.claude/rules/04-code-conventions.md b/.claude/rules/04-code-conventions.md index 4001c2f..75e1616 100644 --- a/.claude/rules/04-code-conventions.md +++ b/.claude/rules/04-code-conventions.md @@ -16,6 +16,26 @@ indentations par type de fichier. caractères au plus. Tags réellement utilisés : `[UPD]`, `[FIX]`, `[ADD]`, `[IMP]`, `[REF]`. +### Le sujet + +Le sujet est lu cent fois pour une fois que le corps l'est — `git log +--oneline`, un blame, une note de version, un bisect. Il a une seule tâche : +dire **sur quoi porte le code**. + +L'épreuve : le lire seul, sans diff ni corps. Sait-on quelle partie du système +est en jeu, et ce qui y est désormais différent ? Sinon il n'est pas fini. + +Nommer la chose, puis ce qui change pour elle. Le symptôme, le message d'erreur +cité et la métaphore sont des PREUVES, et une preuve va dans le corps — un +sujet bâti sur elles se lit bien et n'apprend rien. La portée dit OÙ, les mots +après le deux-points doivent dire QUOI. + +Le sujet résume le commit ENTIER, pas sa plus grosse pièce. S'il lui faut un +« et » entre deux choses sans rapport, c'étaient deux commits. + +Le mode d'emploi complet, avec des exemples avant/après pris dans l'historique +de ce dépôt, est dans `conf/template_claude_commands_commit.md`. + ### Tout commit assisté par IA Trois exigences, sans exception — `AI_POLICY.md` en donne la raison : diff --git a/conf/template_claude_commands_commit.md b/conf/template_claude_commands_commit.md index e97ca07..ad00dc4 100644 --- a/conf/template_claude_commands_commit.md +++ b/conf/template_claude_commands_commit.md @@ -90,6 +90,39 @@ The same body, translated. Assisted-by: {MODEL} ``` +### The subject line + +The subject is read a hundred times for every time the body is read: in +`git log --oneline`, in a blame, in a release note, in a bisect. It has one +job — say what the code is about. + +**The test.** Read the subject alone, with no diff and no body. Can you say +which part of the system it concerns, and what is now different about it? If +not, it is not finished. + +**Name the thing, then what changed about it.** The symptom, the quoted error +and the metaphor are EVIDENCE, and evidence belongs in the body. A subject +built on them reads well and tells the next reader nothing: + +| Instead of | Write | +|-----------|-------| +| `[FIX] nettoyage : les enfants s'en vont avec leur rebond` | `[FIX] nettoyage : les entrées ssh qui rebondissent par une VM effacée` | +| `[FIX] proxmox : « il manque le stockage » était le symptôme, pas la cause` | `[FIX] proxmox : signaler pmxcfs à terre, et non « aucun stockage »` | +| `[FIX] migration: un module fautif n'emporte plus tout le lot` | `[FIX] migration: isoler l'échec d'un module dans la désinstallation` | + +The scope is not the subject. `proxmox` says WHERE; the words after the colon +must say WHAT. A subject that works with its scope removed is usually the +right one. + +**Summarise the whole commit, not its largest piece.** When the work has two +faces — a guard moved and the check that proves it, a screen and the service +under it — the subject covers both or the commit should have been two. If the +only honest subject needs an `and` joining two unrelated things, split it. + +**It must be complete in 72 characters.** A subject cut mid-phrase by +`--oneline` has failed at the one place it is read most. Write it to fit +rather than trimming it afterwards: drop the adjectives, keep the nouns. + ### Keep it short The body answers one question: why was this necessary. Stop once it is From f9edaa4e59e4cef91c519930642cd131e5c80b90 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 25 Aug 2026 05:48:54 -0400 Subject: [PATCH 07/22] [ADD] git : un garde-fou commit-msg pour le sujet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rien ne tenait la convention sur le sujet : le tag est respecté partout, c'est la longueur qui glisse. Le hook refuse le mécanique et rien de plus — tag absent, plus de 72 caractères, sujet ouvrant sur une citation ; dire sur quoi porte le code reste un jugement qu'aucun hook ne rendra. Il compte des caractères et non des octets, sans quoi un sujet français de 72 caractères tomberait sur ses accents. Le refus enseigne le repli vers des mots-clés plutôt que la troncature, et nomme `--no-verify` : un garde-fou qui refuse trop est désinstallé. Les tests pèsent donc autant les acceptations, « Merge branch » ou fixup de rebase. Le lanceur balaie le préfixe test_git_ : 32 tests jamais exécutés, le total va de 1021 à 1071. --- EN --- Nothing held the subject convention: the tag is respected everywhere, it is the length that slips. The hook refuses the mechanical and nothing more — no tag, over 72 characters, a subject opening on a quotation; whether it says what the code is about stays a judgement no hook will make. It counts characters, not bytes, or a 72-character French subject would fall on its accents. The refusal teaches the fallback to keywords rather than truncation, and names `--no-verify`: a guard rail that refuses too much gets uninstalled. So the tests weigh the acceptances as much, a "Merge branch" or a rebase fixup. The runner sweeps the test_git_ prefix: 32 tests never ran, and the total goes from 1021 to 1071. Assisted-by: Claude Opus 5 (cherry picked from commit c9abf4d1b723ae8ff3762d39bfe1d6982400d12d) --- .claude/rules/04-code-conventions.md | 14 +++ conf/template_claude_commands_commit.md | 18 +++ script/git/commit_msg_lib.py | 71 +++++++++++ script/git/hooks/commit-msg | 48 ++++++++ script/test/run_unit_test.sh | 2 +- test/test_git_commit_msg.py | 152 ++++++++++++++++++++++++ 6 files changed, 304 insertions(+), 1 deletion(-) create mode 100644 script/git/commit_msg_lib.py create mode 100755 script/git/hooks/commit-msg create mode 100644 test/test_git_commit_msg.py diff --git a/.claude/rules/04-code-conventions.md b/.claude/rules/04-code-conventions.md index 75e1616..b692e75 100644 --- a/.claude/rules/04-code-conventions.md +++ b/.claude/rules/04-code-conventions.md @@ -33,6 +33,20 @@ après le deux-points doivent dire QUOI. Le sujet résume le commit ENTIER, pas sa plus grosse pièce. S'il lui faut un « et » entre deux choses sans rapport, c'étaient deux commits. +Si le travail n'entre décidément pas dans une phrase de 72 caractères, ne pas +en écrire une amputée : des **mots-clés qui résument**, séparés par des +virgules, en disent plus dans la même place — `[FIX] proxmox : pmxcfs à terre, +pvesm muet, diagnostic à la source`. C'est un repli, pas un défaut : la phrase +reste préférable quand elle tient. + +Un garde-fou refuse le mécanique — tag absent, plus de 72 caractères, sujet +qui s'ouvre sur une citation : + +```bash +git config core.hooksPath script/git/hooks # une fois par clone +git commit --no-verify # exception légitime +``` + Le mode d'emploi complet, avec des exemples avant/après pris dans l'historique de ce dépôt, est dans `conf/template_claude_commands_commit.md`. diff --git a/conf/template_claude_commands_commit.md b/conf/template_claude_commands_commit.md index ad00dc4..01fd745 100644 --- a/conf/template_claude_commands_commit.md +++ b/conf/template_claude_commands_commit.md @@ -123,6 +123,24 @@ only honest subject needs an `and` joining two unrelated things, split it. `--oneline` has failed at the one place it is read most. Write it to fit rather than trimming it afterwards: drop the adjectives, keep the nouns. +When the work genuinely will not fit in a sentence, do not write an amputated +one — write **keywords that summarise**. A comma-separated list of the nouns +that matter says more in the space than half a sentence does: + +``` +[FIX] proxmox : pmxcfs à terre, pvesm muet, diagnostic à la source +[ADD] migration : copies de site, index doublés, réglages perdus +``` + +That form is a fallback, not a default. Prefer the sentence when it fits. + +**The guard rail.** `script/git/hooks/commit-msg` refuses a subject with no +tag, one over 72 characters, and one opening on a quotation. Install it with +`git config core.hooksPath script/git/hooks`; `git commit --no-verify` passes +a legitimate exception. It checks only what is mechanical — whether the +subject says what the code is about stays a judgement, and the test above is +how you make it. + ### Keep it short The body answers one question: why was this necessary. Stop once it is diff --git a/script/git/commit_msg_lib.py b/script/git/commit_msg_lib.py new file mode 100644 index 0000000..2c9366d --- /dev/null +++ b/script/git/commit_msg_lib.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Le sujet d'un commit tient-il la convention ? + +La convention est dans `.claude/rules/04-code-conventions.md`, son mode +d'emploi dans `conf/template_claude_commands_commit.md`. Ce module en vérifie +la part MÉCANIQUE : le tag, la longueur, et le sujet qui s'ouvre sur une +citation. Le reste — « ce sujet dit-il sur quoi porte le code » — est un +jugement, et aucun hook ne le rendra. + +Compté en CARACTÈRES et non en octets : « préchauffer » pèse 12 caractères et +14 octets, et une limite en octets refuserait des sujets français conformes. +""" + +MAX = 72 + +TAGS = ("ADD", "FIX", "UPD", "IMP", "REF", "REM", "MOV", "I18N") + +# Ce que git écrit lui-même, ou ce qu'un rebase consomme : hors convention. +GENERATED = ("Merge ", "Revert ", "fixup!", "squash!", "amend!") + +# Un sujet qui s'ouvre sur une citation cite un écran. C'est une PREUVE, et une +# preuve va dans le corps — le sujet doit nommer la cause. +QUOTES = ("«", '"', "'", "`", "“", "‘") + + +def subject_of(message: str) -> str: + """La première ligne utile : ni commentaire, ni ligne vide.""" + for line in message.split("\n"): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + return stripped + return "" + + +def check(message: str) -> list: + """Rend la liste des problèmes. Vide si le sujet passe.""" + subject = subject_of(message) + if not subject or subject.startswith(GENERATED): + return [] + + problems = [] + + tag = None + for candidate in TAGS: + if subject.startswith(f"[{candidate}]"): + tag = candidate + break + if tag is None: + problems.append( + f"le sujet doit commencer par un tag : {', '.join('[%s]' % t for t in TAGS)}" + ) + + if len(subject) > MAX: + problems.append( + f"le sujet fait {len(subject)} caractères, {MAX} au plus.\n" + " Ne le tronquez pas : à cette longueur, écrivez des MOTS-CLÉS\n" + " qui résument plutôt qu'une phrase amputée. « proxmox : pmxcfs,\n" + " stockage, diagnostic » vaut mieux qu'une phrase coupée net." + ) + + rest = subject.split(":", 1)[1].strip() if ":" in subject else "" + if rest.startswith(QUOTES): + problems.append( + "le sujet s'ouvre sur une citation. Un message d'écran est une\n" + " preuve : elle va dans le corps. Le sujet nomme la cause." + ) + + return problems diff --git a/script/git/hooks/commit-msg b/script/git/hooks/commit-msg new file mode 100755 index 0000000..7674ef7 --- /dev/null +++ b/script/git/hooks/commit-msg @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Hook commit-msg : refuse un sujet hors convention. + +Installation : + + git config core.hooksPath script/git/hooks + +Contournement pour un cas légitime : `git commit --no-verify`. +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from commit_msg_lib import check # noqa: E402 + + +def main() -> int: + if len(sys.argv) < 2: + print("commit-msg : aucun fichier de message reçu", file=sys.stderr) + return 1 + path = Path(sys.argv[1]) + try: + message = path.read_text(encoding="utf-8") + except OSError as exc: + print(f"commit-msg : {exc}", file=sys.stderr) + return 1 + + problems = check(message) + if not problems: + return 0 + + print("\n ✗ sujet de commit hors convention\n", file=sys.stderr) + for problem in problems: + print(f" - {problem}", file=sys.stderr) + print( + "\n La règle : .claude/rules/04-code-conventions.md" + "\n Le mode d'emploi : conf/template_claude_commands_commit.md" + "\n Passer outre pour un cas légitime : git commit --no-verify\n", + file=sys.stderr, + ) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/test/run_unit_test.sh b/script/test/run_unit_test.sh index 6aadf94..9352e64 100755 --- a/script/test/run_unit_test.sh +++ b/script/test/run_unit_test.sh @@ -50,7 +50,7 @@ if [[ ${#FILES[@]} -eq 0 ]]; then # Aucun argument : tout ce que le dépôt sait tester sans base de données. mapfile -t FILES < <(ls test/test_qemu_*.py test/test_mobile_*.py \ test/test_todo_*.py test/test_install_*.py test/test_run_*.py \ - test/test_proxmox_*.py \ + test/test_proxmox_*.py test/test_git_*.py \ 2>/dev/null) fi diff --git a/test/test_git_commit_msg.py b/test/test_git_commit_msg.py new file mode 100644 index 0000000..0cd7d62 --- /dev/null +++ b/test/test_git_commit_msg.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Le garde-fou du sujet de commit refuse-t-il ce qu'il doit, et rien d'autre ? + +Un hook qui refuse trop est désinstallé dans la semaine. Ces tests pèsent donc +autant les refus que les acceptations : un « Merge branch » que git écrit +lui-même, un sujet français de 70 caractères dont les accents pèsent 2 octets, +un fixup de rebase — tout cela doit passer. + +La part de la convention qu'aucun hook ne juge — « ce sujet dit-il sur quoi +porte le code » — n'est pas testée ici parce qu'elle n'est pas vérifiable. +""" +import os +import subprocess +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "script", "git")) + +from commit_msg_lib import MAX, check, subject_of # noqa: E402 + +HOOK = os.path.join( + os.path.dirname(__file__), "..", "script", "git", "hooks", "commit-msg" +) + + +class TestCeQuiPasse(unittest.TestCase): + def test_un_sujet_conforme(self): + self.assertEqual([], check("[FIX] proxmox : signaler pmxcfs à terre")) + + def test_les_huit_tags_de_la_convention(self): + for tag in ("ADD", "FIX", "UPD", "IMP", "REF", "REM", "MOV", "I18N"): + self.assertEqual([], check(f"[{tag}] portée : quelque chose"), tag) + + def test_un_sujet_de_72_caracteres_pile(self): + sujet = "[FIX] portée : " + "a" * (MAX - len("[FIX] portée : ")) + self.assertEqual(MAX, len(sujet)) + self.assertEqual([], check(sujet)) + + def test_les_accents_comptent_pour_un_caractere(self): + """« é » pèse 2 octets : une limite en octets refuserait ce sujet.""" + sujet = "[FIX] déploiement : " + "é" * (MAX - len("[FIX] déploiement : ")) + self.assertEqual(MAX, len(sujet)) + self.assertGreater(len(sujet.encode("utf-8")), MAX) + self.assertEqual([], check(sujet)) + + def test_ce_que_git_ecrit_lui_meme(self): + for genere in ( + "Merge branch 'develop' into master", + "Merge remote-tracking branch 'origin/develop'", + "Revert \"[FIX] portée : quelque chose\"", + "fixup! [FIX] portée : quelque chose", + "squash! [FIX] portée : quelque chose", + ): + self.assertEqual([], check(genere), genere) + + def test_un_message_vide_ou_commente(self): + """Un commit abandonné : git s'en occupe, le hook ne s'en mêle pas.""" + self.assertEqual([], check("")) + self.assertEqual([], check("# Please enter the commit message\n#\n")) + + def test_le_sujet_est_la_premiere_ligne_utile(self): + self.assertEqual( + "[FIX] portée : sujet", + subject_of("#\n# commentaire\n\n[FIX] portée : sujet\n\ncorps\n"), + ) + + +class TestCeQuiEstRefuse(unittest.TestCase): + def test_un_sujet_sans_tag(self): + problemes = check("corriger le pont NAT") + self.assertEqual(1, len(problemes)) + self.assertIn("tag", problemes[0]) + + def test_un_tag_inconnu(self): + self.assertTrue(check("[WIP] portée : quelque chose")) + + def test_un_sujet_de_73_caracteres(self): + sujet = "[FIX] portée : " + "a" * (MAX + 1 - len("[FIX] portée : ")) + self.assertEqual(MAX + 1, len(sujet)) + problemes = check(sujet) + self.assertEqual(1, len(problemes)) + self.assertIn("73 caractères", problemes[0]) + + def test_le_message_de_longueur_enseigne_les_mots_cles(self): + """Tronquer est le réflexe et c'est le mauvais : le hook doit le dire.""" + probleme = check("[FIX] portée : " + "a" * 80)[0] + self.assertIn("MOTS-CLÉS", probleme) + self.assertIn("Ne le tronquez pas", probleme) + + def test_un_sujet_qui_ouvre_sur_une_citation(self): + for ouvrant in ("«", '"', "'", "`", "“"): + problemes = check(f"[FIX] proxmox : {ouvrant}il manque le stockage{ouvrant}") + self.assertEqual(1, len(problemes), ouvrant) + self.assertIn("citation", problemes[0]) + + def test_une_citation_ailleurs_dans_le_sujet_passe(self): + """Seule l'OUVERTURE est refusée : citer en fin de sujet reste permis.""" + self.assertEqual( + [], check("[FIX] proxmox : pmxcfs à terre, dit « aucun stockage »") + ) + + def test_deux_problemes_sont_rapportes_ensemble(self): + problemes = check("proxmox : « un sujet sans tag et beaucoup trop long » " + "a" * 40) + self.assertEqual(3, len(problemes)) + + +class TestLeHookLuiMeme(unittest.TestCase): + """Le module peut être juste et le hook faux : on l'exécute vraiment.""" + + def _lancer(self, message): + with tempfile.NamedTemporaryFile( + "w", suffix=".txt", delete=False, encoding="utf-8" + ) as fh: + fh.write(message) + chemin = fh.name + try: + return subprocess.run( + [sys.executable, HOOK, chemin], + capture_output=True, + text=True, + ) + finally: + os.unlink(chemin) + + def test_il_sort_en_zero_sur_un_sujet_conforme(self): + r = self._lancer("[FIX] portée : quelque chose\n") + self.assertEqual(0, r.returncode, r.stderr) + + def test_il_sort_en_un_et_dit_pourquoi(self): + r = self._lancer("[FIX] portée : " + "a" * 80 + "\n") + self.assertEqual(1, r.returncode) + self.assertIn("hors convention", r.stderr) + self.assertIn("MOTS-CLÉS", r.stderr) + + def test_il_nomme_le_contournement(self): + """Sans issue annoncée, un hook se contourne en le supprimant.""" + r = self._lancer("pas de tag\n") + self.assertIn("--no-verify", r.stderr) + + def test_sans_argument_il_echoue_proprement(self): + r = subprocess.run( + [sys.executable, HOOK], capture_output=True, text=True + ) + self.assertEqual(1, r.returncode) + self.assertIn("aucun fichier", r.stderr) + + +if __name__ == "__main__": + unittest.main(verbosity=2) From 8cc5566743429b0ff05a722a2200272cceaef202 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 25 Aug 2026 06:40:11 -0400 Subject: [PATCH 08/22] =?UTF-8?q?[FIX]=20anonymisation=20:=20tirer=20les?= =?UTF-8?q?=20nombres=20dans=20l'=C3=A9tendue=20mesur=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resource.calendar.attendance.hour_from` est un `float` qui porte une HEURE DE LA JOURNÉE : un tirage uniforme sur 0 à 1000 le sort de 0..23 et l'affichage de la fiche lève « ValueError: hour must be in 0..23 ». La borne vit dans `time(int(integral), ...)` de resource/models/utils.py, et aucune contrainte PostgreSQL ne la déclare : le schéma ne dit pas le SENS de la donnée. « 0 à 1000 » est donc faux pour tout nombre borné par un usage : heures, pourcentages, taux. On respecte désormais la seule borne que les DONNÉES déclarent, leur propre étendue — min et max mesurés par table, et 0 à 1000 quand elle est inconnue. Vérifié : les tirages tiennent dans l'étendue mesurée, et la fiche s'affiche sans lever. --- EN --- `resource.calendar.attendance.hour_from` is a `float` holding an HOUR OF DAY: a uniform draw over 0 to 1000 takes it out of 0..23 and displaying the record raises "ValueError: hour must be in 0..23". The bound lives in `time(int(integral), ...)` in resource/models/utils.py, and no PostgreSQL constraint declares it: the schema does not say what the data MEANS. So "0 to 1000" is wrong for any number bounded by usage: hours, percentages, rates. We now respect the only bound the DATA declares, its own range — min and max measured per table, and 0 to 1000 when it is unknown. Checked: draws stay inside the measured range, and the record displays without raising. Assisted-by: Claude Opus 5 (cherry picked from commit 8e0a19823714b1d18f05613d498f8136b682c047) --- script/analyse/anonymize.py | 124 ++++++++++++++++++++------ test/test_anonymize.py | 173 ++++++++++++++++++++++++++++++++++-- 2 files changed, 265 insertions(+), 32 deletions(-) diff --git a/script/analyse/anonymize.py b/script/analyse/anonymize.py index d34cee5..e9ab434 100755 --- a/script/analyse/anonymize.py +++ b/script/analyse/anonymize.py @@ -310,12 +310,40 @@ def expression_texte(champ, mots): def expression_nombre(champ): - """Le SQL qui remplace un nombre : au hasard, entre 0 et 1000.""" + """Le SQL qui remplace un nombre, DANS l'étendue de la colonne. + + 0 à 1000 était l'intention, et c'est faux pour tout nombre qui porte + un sens borné. Mesuré : `resource.calendar.attendance.hour_from` est + un `float` qui vaut une heure de la journée — 8,00 à 13,00 dans la + base d'origine. Un tirage à 957 fait lever Odoo : + + time(int(integral), ...) → ValueError: hour must be in 0..23 + resource/models/utils.py:45 + + Aucune contrainte PostgreSQL ne dit cela : la borne vit dans le code. + La seule que les DONNÉES déclarent est leur propre étendue, et c'est + celle qu'on respecte. Elle protège aussi les pourcentages, les taux et + les quantités, sans qu'il faille les nommer un par un. + + Sans étendue connue — colonne vide, ou sonde impossible — on retombe + sur 0 à 1000. + """ nom = ident(champ["name"]) - if champ["ttype"] == "integer": - tirage = "floor(random() * 1001)::integer" + bas, haut = champ.get("borne_min"), champ.get("borne_max") + entier = champ["ttype"] == "integer" + if bas is None or haut is None: + tirage = ( + "floor(random() * 1001)::integer" + if entier + else "round((random() * 1000)::numeric, 2)" + ) + elif entier: + # +1 pour que la borne haute soit atteignable ; si bas == haut, + # le tirage rend cette valeur, ce qui est sans risque : une + # colonne constante ne porte aucune information à masquer. + tirage = f"floor({bas} + random() * ({haut} - {bas} + 1))::integer" else: - tirage = "round((random() * 1000)::numeric, 2)" + tirage = f"round(({bas} + random() * ({haut} - {bas}))::numeric, 2)" return f"CASE WHEN {nom} IS NULL THEN NULL ELSE {tirage} END" @@ -448,29 +476,36 @@ def plan( return etapes -def colonnes_structurees(database, etapes, config_path=None): - """Les colonnes texte dont TOUT le contenu est un chemin d'identifiants. +def sonder_colonnes(database, etapes, config_path=None): + """Regarder ce que les colonnes CONTIENNENT. Rendre (écartées, bornes). - Le filet général, là où `CHAMPS_STRUCTURES` ne nomme que le connu : - un module maison peut poser son propre champ de chemin, et il ne - portera pas ce nom-là. On regarde donc ce que la colonne CONTIENT. + Deux mesures, une seule requête par table — sur une liste noire de 410 + modèles, la différence est de 410 allers-retours au lieu de 1 300. - Une colonne est écartée seulement si elle est NON VIDE et que toutes - ses valeurs sont des chemins. Un seul contre-exemple suffit à la - garder : mieux vaut anonymiser une colonne douteuse que taire une - donnée personnelle. + ÉCARTÉES — les colonnes texte dont TOUT le contenu est un chemin + d'identifiants. Le filet général, là où `CHAMPS_STRUCTURES` ne nomme + que le connu : un module maison peut poser le sien sans lui donner ce + nom. Une seule valeur non conforme suffit à garder la colonne — mieux + vaut anonymiser une colonne douteuse que taire une donnée personnelle. - Une requête par TABLE, pas par colonne : sur une liste noire de 410 - modèles, la différence est de 410 allers-retours au lieu de 863. + BORNES — l'étendue réelle de chaque colonne numérique. C'est la seule + borne que les données déclarent, et elle vaut pour toutes celles que + le code d'Odoo impose sans que PostgreSQL en sache rien : heures de la + journée, pourcentages, taux. """ - ecartees = {} + ecartees, bornes = {}, {} for etape in etapes: textes = [ champ for champ in etape["fields"] if champ["ttype"] in TYPES_TEXTE and champ["pg_type"] != "jsonb" ] - if not textes: + nombres = [ + champ + for champ in etape["fields"] + if champ["ttype"] in TYPES_NOMBRE and champ["pg_type"] != "jsonb" + ] + if not textes and not nombres: continue morceaux = [] for champ in textes: @@ -479,6 +514,12 @@ def colonnes_structurees(database, etapes, config_path=None): f"count(*) FILTER (WHERE {nom} IS NOT NULL AND {nom} <> '')" f" || ':' || count(*) FILTER (WHERE {nom} ~ {litteral(MOTIF_CHEMIN)})" ) + for champ in nombres: + nom = ident(champ["name"]) + morceaux.append( + f"coalesce(min({nom})::text, '') || ':'" + f" || coalesce(max({nom})::text, '')" + ) sql = ( "SELECT " + " || '\x1f' || ".join(morceaux) @@ -490,24 +531,55 @@ def colonnes_structurees(database, etapes, config_path=None): ).strip() except Exception: # noqa: BLE001 - une table illisible ne bloque pas continue - for champ, mesure in zip(textes, brut.split(SEP)): + mesures = brut.split(SEP) + if len(mesures) != len(textes) + len(nombres): + continue + for champ, mesure in zip(textes, mesures): try: remplies, chemins = (int(x) for x in mesure.split(":")) except ValueError: continue if remplies and remplies == chemins: ecartees.setdefault(etape["model"], []).append(champ["name"]) - return ecartees + for champ, mesure in zip(nombres, mesures[len(textes) :]): + bas, _, haut = mesure.partition(":") + if nombre_valide(bas) and nombre_valide(haut): + bornes.setdefault(etape["model"], {})[champ["name"]] = ( + bas, + haut, + ) + return ecartees, bornes -def sans_structurees(etapes, ecartees, mots): - """Refaire le plan sans les colonnes que la sonde a écartées.""" - if not ecartees: - return etapes +def nombre_valide(texte): + """Un littéral numérique, et rien d'autre. + + Ces valeurs viennent de la base mais retournent dans du SQL : on ne + les recopie qu'après avoir vérifié qu'elles sont bien des nombres. + """ + try: + float(texte) + except (TypeError, ValueError): + return False + return True + + +def appliquer_sondes(etapes, ecartees, bornes, mots): + """Refaire le plan sans les écartées, et avec les bornes mesurées.""" propre = [] for etape in etapes: exclues = set(ecartees.get(etape["model"], ())) - gardes = [c for c in etape["fields"] if c["name"] not in exclues] + mesures = bornes.get(etape["model"], {}) + gardes = [] + for champ in etape["fields"]: + if champ["name"] in exclues: + continue + borne = mesures.get(champ["name"]) + gardes.append( + {**champ, "borne_min": borne[0], "borne_max": borne[1]} + if borne + else champ + ) # Pas de garde sur une liste vide : `sql_pour_table` rend None, et # le `if sql` ci-dessous l'écarte. Deux vérifications pour la même # chose se contredisent un jour. @@ -697,8 +769,8 @@ def main(argv=None): ) # La sonde AVANT le rendu : la marche à blanc doit montrer ce que # `--apply` ferait, pas une approximation plus large. - ecartees = colonnes_structurees(args.database, etapes, args.config) - etapes = sans_structurees(etapes, ecartees, mots) + ecartees, bornes = sonder_colonnes(args.database, etapes, args.config) + etapes = appliquer_sondes(etapes, ecartees, bornes, mots) if ecartees: combien = sum(len(v) for v in ecartees.values()) print( diff --git a/test/test_anonymize.py b/test/test_anonymize.py index c845479..7c15ac2 100644 --- a/test/test_anonymize.py +++ b/test/test_anonymize.py @@ -677,7 +677,7 @@ class TestStructuredCharFieldsSurvive(unittest.TestCase): } ] try: - anon.colonnes_structurees("base", etapes) + anon.sonder_colonnes("base", etapes) finally: anon.lib_analyse.run_psql = vrai self.assertEqual(len(appels), 1) @@ -698,14 +698,16 @@ class TestStructuredCharFieldsSurvive(unittest.TestCase): {"model": "m", "fields": [self._champ("chemin")], "sql": "x"} ] try: - ecartees = anon.colonnes_structurees("base", etapes) + ecartees, _ = anon.sonder_colonnes("base", etapes) finally: anon.lib_analyse.run_psql = vrai self.assertEqual(ecartees, {}) def test_an_all_paths_column_is_dropped_from_the_plan(self): def espion(database, sql, config_path=None): - return "10:10" + # Une mesure PAR COLONNE : la sonde compte ce qu'elle + # reçoit et renonce si le compte ne tombe pas juste. + return "10:10\x1f10:10" vrai = anon.lib_analyse.run_psql anon.lib_analyse.run_psql = espion @@ -717,13 +719,13 @@ class TestStructuredCharFieldsSurvive(unittest.TestCase): } ] try: - ecartees = anon.colonnes_structurees("base", etapes) + ecartees, _ = anon.sonder_colonnes("base", etapes) finally: anon.lib_analyse.run_psql = vrai # Les deux colonnes rendent le même compte ici : c'est le principe # qu'on vérifie, pas la ligne exacte. self.assertIn("m", ecartees) - propre = anon.sans_structurees(etapes, {"m": ["chemin"]}, None) + propre = anon.appliquer_sondes(etapes, {"m": ["chemin"]}, {}, None) self.assertEqual([c["name"] for c in propre[0]["fields"]], ["nom"]) self.assertNotIn('"chemin"', propre[0]["sql"]) @@ -732,5 +734,164 @@ class TestStructuredCharFieldsSurvive(unittest.TestCase): {"model": "m", "fields": [self._champ("chemin")], "sql": "x"} ] self.assertEqual( - anon.sans_structurees(etapes, {"m": ["chemin"]}, None), [] + anon.appliquer_sondes(etapes, {"m": ["chemin"]}, {}, None), [] ) + + +class TestANumberKeepsItsMeaningfulRange(unittest.TestCase): + """`ValueError: hour must be in 0..23`. + + `resource.calendar.attendance.hour_from` est un `float` qui vaut une + heure de la journée — 8,00 à 13,00 dans la base d'origine. Un tirage + à 957 fait lever Odoo au premier affichage d'un employé : + + time(int(integral), ...) resource/models/utils.py:45 + + Aucune contrainte PostgreSQL ne dit cela : la borne vit dans le code. + La seule que les DONNÉES déclarent est leur propre étendue, et c'est + la seule qu'on puisse respecter sans nommer les champs un par un. + """ + + def _champ(self, ttype="float", **kw): + base = { + "model": "resource.calendar.attendance", + "name": "hour_from", + "ttype": ttype, + "pg_type": "numeric", + "unique": False, + "checked": False, + "max_len": None, + } + base.update(kw) + return base + + def test_a_measured_range_bounds_the_draw(self): + sql = anon.expression_nombre( + self._champ(borne_min="8.0", borne_max="13.0") + ) + self.assertIn("8.0 + random() * (13.0 - 8.0)", sql) + self.assertNotIn("1000", sql) + + def test_an_integer_can_reach_its_upper_bound(self): + sql = anon.expression_nombre( + self._champ(ttype="integer", borne_min="0", borne_max="5") + ) + self.assertIn("(5 - 0 + 1)", sql) + + def test_without_a_range_it_falls_back_on_a_thousand(self): + self.assertIn("1000", anon.expression_nombre(self._champ())) + self.assertIn( + "1001", anon.expression_nombre(self._champ(ttype="integer")) + ) + + def test_a_half_known_range_is_not_used(self): + """Une borne sans l'autre ne borne rien.""" + for kw in ({"borne_min": "8.0"}, {"borne_max": "13.0"}): + self.assertIn("1000", anon.expression_nombre(self._champ(**kw))) + + def test_a_value_that_is_not_a_number_never_reaches_the_sql(self): + """Ces bornes viennent de la base et retournent dans du SQL.""" + self.assertTrue(anon.nombre_valide("8.0")) + self.assertTrue(anon.nombre_valide("-3")) + for mauvais in ("8.0); DROP TABLE x; --", "", None, "huit"): + self.assertFalse(anon.nombre_valide(mauvais), mauvais) + + def test_the_probe_reads_the_bounds(self): + recu = {} + + def espion(database, sql, config_path=None): + recu["sql"] = sql + return "8.0:13.0" + + vrai = anon.lib_analyse.run_psql + anon.lib_analyse.run_psql = espion + etapes = [{"model": "m", "fields": [self._champ()], "sql": "x"}] + try: + _, bornes = anon.sonder_colonnes("base", etapes) + finally: + anon.lib_analyse.run_psql = vrai + self.assertIn("min(", recu["sql"]) + self.assertIn("max(", recu["sql"]) + self.assertEqual(bornes["m"]["hour_from"], ("8.0", "13.0")) + + def test_an_empty_column_yields_no_bound(self): + def espion(database, sql, config_path=None): + return ":" + + vrai = anon.lib_analyse.run_psql + anon.lib_analyse.run_psql = espion + etapes = [{"model": "m", "fields": [self._champ()], "sql": "x"}] + try: + _, bornes = anon.sonder_colonnes("base", etapes) + finally: + anon.lib_analyse.run_psql = vrai + self.assertEqual(bornes, {}) + + def test_the_bounds_reach_the_generated_sql(self): + etapes = [{"model": "m", "fields": [self._champ()], "sql": "x"}] + propre = anon.appliquer_sondes( + etapes, {}, {"m": {"hour_from": ("8.0", "13.0")}}, None + ) + self.assertIn("8.0 + random()", propre[0]["sql"]) + + +class TestTheProbeDistrustsWhatItReads(unittest.TestCase): + """Ce que la sonde reçoit repart dans du SQL : elle le vérifie. + + Deux mutations ont survécu au premier tour, et les deux disaient la + même chose : j'éprouvais les fonctions de contrôle isolément sans + vérifier que la sonde s'en sert. Une garde qu'on n'exerce pas ne + garde rien. + """ + + def _champ(self, nom="hour_from", ttype="float"): + return { + "model": "m", + "name": nom, + "ttype": ttype, + "pg_type": "numeric", + "unique": False, + "checked": False, + "max_len": None, + } + + def _sonder(self, reponse, champs): + def espion(database, sql, config_path=None): + return reponse + + vrai = anon.lib_analyse.run_psql + anon.lib_analyse.run_psql = espion + etapes = [{"model": "m", "fields": champs, "sql": "x"}] + try: + return anon.sonder_colonnes("base", etapes) + finally: + anon.lib_analyse.run_psql = vrai + + def test_a_bound_that_is_not_a_number_is_refused(self): + """La base peut rendre autre chose qu'un nombre ; ces valeurs + retournent telles quelles dans un littéral SQL.""" + for reponse in ("abc:def", "8.0); DROP TABLE x; --:13", ":13"): + _, bornes = self._sonder(reponse, [self._champ()]) + self.assertEqual(bornes, {}, reponse) + + def test_a_valid_bound_still_passes(self): + _, bornes = self._sonder("8.0:13.0", [self._champ()]) + self.assertEqual(bornes["m"]["hour_from"], ("8.0", "13.0")) + + def test_a_short_answer_is_refused_whole(self): + """Moins de mesures que de colonnes : `zip` tronquerait en silence + et attribuerait la mesure d'une colonne à une autre.""" + champs = [self._champ("a"), self._champ("b"), self._champ("c")] + ecartees, bornes = self._sonder("8.0:13.0", champs) + self.assertEqual(bornes, {}) + self.assertEqual(ecartees, {}) + + def test_a_long_answer_is_refused_too(self): + champs = [self._champ("a")] + _, bornes = self._sonder("8.0:13.0\x1f1.0:2.0", champs) + self.assertEqual(bornes, {}) + + def test_the_exact_count_is_accepted(self): + champs = [self._champ("a"), self._champ("b")] + _, bornes = self._sonder("8.0:13.0\x1f1.0:2.0", champs) + self.assertEqual(len(bornes["m"]), 2) From 2534d4d022493ae4164133192e20d03dde58de18 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 26 Aug 2026 03:58:45 -0400 Subject: [PATCH 09/22] =?UTF-8?q?[FIX]=20liste=20de=20prix=20:=20d=C3=A9ci?= =?UTF-8?q?der=20sur=20la=20fonctionnalit=C3=A9,=20pas=20sur=20l'ex=C3=A9c?= =?UTF-8?q?utant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `env.user.has_group()` répond « oui » dès que l'exécutant est membre du groupe, et la migration l'y ajoute en cours de route. Or la case des réglages lit tout autre chose : ce que `base.group_user` IMPLIQUE (res_config.py, « which groups are implied by the group Employee »). Décider sur l'exécutant créait une liste de prix dans une base dont la fonctionnalité est éteinte, et Odoo prévenait à chaque ouverture des réglages qu'il allait l'archiver. Le contrôle « restant de migration » posait la même mauvaise question ; les deux lisent désormais l'implication du groupe. Vérifié sur copie jetable, dans les deux sens : fonctionnalité éteinte, rien n'est signalé ; activée, constat et réparation reviennent. --- EN --- `env.user.has_group()` says yes as soon as the caller belongs to the group, and the migration adds it along the way. But the settings checkbox reads something else: what `base.group_user` IMPLIES (res_config.py, "which groups are implied by the group Employee"). Deciding on the caller created a pricelist in a database whose feature is off, and Odoo warned on every opening of the settings that it would archive it. The migration-residue check asked the same wrong question; both now read the group implication. Verified on a throwaway copy, both ways: feature off, nothing is reported; feature on, finding and repair come back. Assisted-by: Claude Opus 5 (cherry picked from commit 38ba25e01894f45fb956bcbb08cc3d96a125e648) --- script/analyse/check_migration_residue.py | 13 +++++++ .../odoo/migration/restore_config_defaults.py | 19 ++++++++-- test/test_monitoring.py | 37 +++++++++++++++++++ 3 files changed, 65 insertions(+), 4 deletions(-) diff --git a/script/analyse/check_migration_residue.py b/script/analyse/check_migration_residue.py index f2ef0e3..b2320ea 100755 --- a/script/analyse/check_migration_residue.py +++ b/script/analyse/check_migration_residue.py @@ -139,9 +139,22 @@ CONTROLES = ( # chercher l'xmlid signalait donc une base parfaitement saine, et # aurait signalé de même celle d'un client qui a créé la sienne à # la main. + # + # Et seulement si la FONCTIONNALITÉ est active, c'est-à-dire si + # `base.group_user` implique `product.group_product_pricelist` — + # la question exacte que pose la case des réglages. Sans elle, + # l'absence de liste est normale ; signaler quand même menait à + # créer une liste dans une base qui n'en veut pas, et Odoo + # prévenait alors à chaque ouverture des réglages qu'il allait + # l'archiver. "sql": "SELECT CASE WHEN EXISTS (SELECT 1 FROM ir_module_module" " WHERE name='product' AND state='installed')" " AND to_regclass('public.product_pricelist') IS NOT NULL" + " AND EXISTS (SELECT 1 FROM res_groups_implied_rel r" + " JOIN ir_model_data u ON u.model='res.groups' AND u.res_id=r.gid" + " AND u.module='base' AND u.name='group_user'" + " JOIN ir_model_data g ON g.model='res.groups' AND g.res_id=r.hid" + " AND g.module='product' AND g.name='group_product_pricelist')" " AND NOT EXISTS (SELECT 1 FROM product_pricelist)" " THEN 1 ELSE 0 END", "gravity": "broken", diff --git a/script/odoo/migration/restore_config_defaults.py b/script/odoo/migration/restore_config_defaults.py index e40cf92..ff932b8 100755 --- a/script/odoo/migration/restore_config_defaults.py +++ b/script/odoo/migration/restore_config_defaults.py @@ -74,10 +74,21 @@ try: if "product.pricelist" in env: Liste = env["product.pricelist"].sudo().with_context(active_test=False) rapport["pricelist_before"] = Liste.search_count([]) - # Le groupe décide : sans lui la fonctionnalité est masquée et - # l'absence de liste est normale, pas une perte. - rapport["pricelist_group"] = env.user.has_group( - "product.group_product_pricelist" + # Ce qui décide, c'est la FONCTIONNALITÉ, pas l'appartenance de + # celui qui exécute. `has_group` était vrai ici parce que six + # utilisateurs sont membres directs du groupe — hérité d'un + # palier de migration — alors que la case de configuration était + # décochée. On créait donc une liste de prix dans une base dont + # la fonctionnalité est éteinte, et Odoo prévenait à chaque + # ouverture des réglages qu'il allait l'archiver. + # + # `res.config.settings` lit ce que `base.group_user` IMPLIQUE + # (res_config.py : « which groups are implied by the group + # Employee ») : c'est la même question qu'on pose ici. + fonction = env.ref("product.group_product_pricelist", False) + employe = env.ref("base.group_user", False) + rapport["pricelist_group"] = bool( + fonction and employe and fonction in employe.implied_ids ) if not DRY and rapport["pricelist_group"]: Societe._activate_or_create_pricelists() diff --git a/test/test_monitoring.py b/test/test_monitoring.py index eb6e9a7..0870edb 100644 --- a/test/test_monitoring.py +++ b/test/test_monitoring.py @@ -346,3 +346,40 @@ class TestTheSourceMenuIsWrittenTwice(unittest.TestCase): """La provenance la plus directe, et l'ordre du menu d'à côté.""" self.assertEqual(self.affiches[0], "1") self.assertIn("A local database", self.corps.split("if answer")[0]) + + +class TestAMissingPricelistOnlyCountsWhenTheFeatureIsOn(unittest.TestCase): + """Une liste de prix absente n'est un défaut que si on l'a demandée. + + Mesuré : six utilisateurs étaient membres DIRECTS de + `product.group_product_pricelist` — hérité d'un palier de migration — + alors que `base.group_user` ne l'impliquait pas. La case des réglages + était donc décochée, et la réparation créait quand même une liste. + Odoo prévenait ensuite à chaque ouverture des réglages qu'il allait + l'archiver. + + `res.config.settings` lit ce que `base.group_user` IMPLIQUE ; le + contrôle pose désormais la même question. + """ + + def _controle(self): + for controle in residue.CONTROLES: + if controle["key"] == "missing_pricelist": + return controle + raise AssertionError("contrôle introuvable") + + def test_it_asks_whether_the_feature_is_implied(self): + sql = " ".join(self._controle()["sql"].split()) + self.assertIn("res_groups_implied_rel", sql) + self.assertIn("group_product_pricelist", sql) + self.assertIn("group_user", sql) + + def test_it_does_not_settle_for_direct_membership(self): + """`res_groups_users_rel` dirait « quelqu'un est dans le groupe », + ce qui était vrai et menait au faux positif.""" + self.assertNotIn("res_groups_users_rel", self._controle()["sql"]) + + def test_it_still_requires_the_module_and_the_table(self): + sql = " ".join(self._controle()["sql"].split()) + self.assertIn("ir_module_module", sql) + self.assertIn("to_regclass", sql) From ccbb2a85979e7bd52d8d111801c8e7171d7883c6 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 26 Aug 2026 04:37:21 -0400 Subject: [PATCH 10/22] =?UTF-8?q?[FIX]=20liste=20de=20prix=20:=20ne=20rien?= =?UTF-8?q?=20cr=C3=A9er=20quand=20il=20en=20existe=20d=C3=A9j=C3=A0=20une?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_activate_or_create_pricelists` ne compte pas une liste de prix partagée entre sociétés (company_id vide) comme appartenant à la société : elle voit une société « sans liste » et lui en fabrique une. Sur une base migrée qui n'a rien perdu, cela ajoute un doublon vide à côté de la liste existante. Elle interroge maintenant le détecteur `pricelist_missing` du même outil, qui distingue déjà les deux cas. Vérifié dans les deux sens sur copie jetable : liste présente, rien n'est créé ; aucune liste, la réparation en crée une. --- EN --- `_activate_or_create_pricelists` does not count a pricelist shared across companies (empty company_id) as belonging to the company: it sees a company "without a pricelist" and makes one. On a migrated database that lost nothing, that adds an empty duplicate beside the existing list. It now asks the same tool's `pricelist_missing` detector, which already tells the two cases apart. Verified both ways on a throwaway copy: a pricelist present, nothing is created; no pricelist, the repair creates one. Assisted-by: Claude Opus 5 (cherry picked from commit b5bd2e4e44e89bdba4d639672611dcb964265987) --- .../odoo/migration/restore_config_defaults.py | 10 +++- test/test_monitoring.py | 53 +++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/script/odoo/migration/restore_config_defaults.py b/script/odoo/migration/restore_config_defaults.py index ff932b8..e887351 100755 --- a/script/odoo/migration/restore_config_defaults.py +++ b/script/odoo/migration/restore_config_defaults.py @@ -90,7 +90,15 @@ try: rapport["pricelist_group"] = bool( fonction and employe and fonction in employe.implied_ids ) - if not DRY and rapport["pricelist_group"]: + # Et seulement s'il n'y en a AUCUNE. `_activate_or_create_pricelists` + # ne compte pas une liste PARTAGÉE (company_id vide) comme + # appartenant à la société : appelée alors qu'une liste existe, elle + # en ajoute une seconde. Mesuré sur une migration où la liste avait + # traversé les six paliers — la réparation a créé un doublon vide à + # côté d'elle. Cet outil a son détecteur ; il le consulte. + if not DRY and rapport["pricelist_group"] and not rapport[ + "pricelist_before" + ]: Societe._activate_or_create_pricelists() env.cr.commit() rapport["pricelist_after"] = Liste.search_count([]) diff --git a/test/test_monitoring.py b/test/test_monitoring.py index 0870edb..3f0a880 100644 --- a/test/test_monitoring.py +++ b/test/test_monitoring.py @@ -383,3 +383,56 @@ class TestAMissingPricelistOnlyCountsWhenTheFeatureIsOn(unittest.TestCase): sql = " ".join(self._controle()["sql"].split()) self.assertIn("ir_module_module", sql) self.assertIn("to_regclass", sql) + + +class TestTheRepairAsksItsOwnDetector(unittest.TestCase): + """Une réparation qui n'écoute pas son détecteur fabrique des doublons. + + Mesuré sur une migration de bout en bout : la liste de prix avait + traversé les six paliers, PARTAGÉE entre sociétés (company_id vide). + `_activate_or_create_pricelists` ne compte pas une liste partagée + comme appartenant à la société — elle en a donc créé une seconde, + vide, à côté de celle du client. + + Le détecteur `pricelist_missing`, lui, disait déjà « rien ne manque ». + Il fallait que la réparation le lui demande. + """ + + def _source(self): + from pathlib import Path + + chemin = ( + Path(__file__).resolve().parent.parent + / "script" + / "odoo" + / "migration" + / "restore_config_defaults.py" + ) + return chemin.read_text(encoding="utf-8") + + def _garde(self): + """Le texte qui précède l'APPEL, pas sa mention dans la docstring. + + `index` trouvait la PREMIÈRE occurrence — celle de l'en-tête du + module, qui explique justement ce que fait cette méthode. Le test + s'évaluait alors sur un extrait de prose et échouait. `rindex` + prend la dernière, qui est l'appel. + """ + source = self._source() + debut = source.rindex("_activate_or_create_pricelists()") + return source[max(0, debut - 400) : debut] + + def test_the_repair_is_gated_on_an_empty_count(self): + self.assertIn("pricelist_before", self._garde()) + + def test_the_feature_condition_is_still_there(self): + self.assertIn("pricelist_group", self._garde()) + + def test_the_feature_is_read_from_the_implication(self): + """Et non de l'appartenance de celui qui exécute.""" + source = self._source() + self.assertIn("implied_ids", source) + self.assertNotIn( + 'env.user.has_group(\n "product.group_product_pricelist"', + source, + ) From 4a90f9face5627f2b39b3755bcc67eaa25ed2a72 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 26 Aug 2026 07:46:14 -0400 Subject: [PATCH 11/22] [FIX] tests : balayer test/test_*.py et refuser un fichier muet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le lanceur ne prenait que sept préfixes de noms, « le reste demandant une base de données » : les 3703 tests du répertoire passent avec PostgreSQL injoignable, et la suite n'en exécutait que 1131. Il balaie test/test_*.py. Deux formes rendent un fichier muet sans erreur : unittest.main() posé en plein milieu, qui sort avant que la seconde moitié soit définie (quatre fichiers, 87 tests), et l'absence de bloc __main__, qui compte zéro (huit fichiers, 174 tests). Une garde refuse ces deux formes et le retour d'une liste de préfixes. --- EN --- The runner took seven filename prefixes only, "the rest needing a database": all 3703 tests in the directory pass with PostgreSQL unreachable, and the suite ran 1131 of them. It now globs test/test_*.py. Two shapes make a file silent without error: unittest.main() placed mid-file, which exits before the second half is defined (four files, 87 tests), and no __main__ block at all, which counts zero (eight files, 174 tests). A guard now refuses both shapes and the return of a prefix list. Assisted-by: Claude Opus 5 (cherry picked from commit db71b333cc08ae0fce4ddaaf037357648b10236b) --- script/test/run_unit_test.sh | 26 +++--- test/test_analyse_lib.py | 16 +++- test/test_analyse_view_custom.py | 8 +- test/test_anonymize.py | 8 +- test/test_check_addons_exist.py | 4 + test/test_code_generator_tools.py | 4 + test/test_database_tools.py | 4 + test/test_docker_update_version.py | 4 + test/test_format_file_to_commit.py | 4 + test/test_iscompatible.py | 4 + test/test_mail_imap_transport.py | 4 + test/test_monitoring.py | 8 +- test/test_suite_reaches_every_file.py | 113 ++++++++++++++++++++++++++ test/test_version.py | 4 + 14 files changed, 184 insertions(+), 27 deletions(-) create mode 100644 test/test_suite_reaches_every_file.py diff --git a/script/test/run_unit_test.sh b/script/test/run_unit_test.sh index 9352e64..57772ab 100755 --- a/script/test/run_unit_test.sh +++ b/script/test/run_unit_test.sh @@ -16,12 +16,19 @@ # # ./script/test/run_unit_test.sh [fichiers...] # -# NOMMER UN NOUVEAU FICHIER : sans argument, ce script ne prend que les -# préfixes listés plus bas (test_qemu_, test_todo_, test_proxmox_…) — le reste -# de test/ demande une base de données. Un fichier hors préfixe tombe donc dans -# le même silence qu'un fichier absent : douze tests écrits, jamais lancés, et -# un total qui n'a pas bougé. Choisir le préfixe de sa famille, ou l'ajouter -# ici. +# TOUT test/test_*.py, et non une liste de préfixes. La liste disait prendre +# test_qemu_, test_todo_, test_proxmox_… « le reste demandant une base de +# données ». Mesuré : les 3670 tests du répertoire passent avec PostgreSQL +# injoignable, et la liste laissait 2400 d'entre eux hors de la suite — +# écrits, verts, jamais lancés. Un glob n'oublie personne ; une liste, si. +# +# Un fichier n'est vu que s'il finit par le bloc habituel : +# +# if __name__ == "__main__": +# unittest.main() +# +# EN DERNIER, sinon tout ce qui suit est défini après l'appel et ne tourne +# jamais. C'est arrivé quatre fois ici, pour 87 tests. set -uo pipefail Red='\033[0;31m' @@ -47,11 +54,8 @@ fi FILES=("$@") if [[ ${#FILES[@]} -eq 0 ]]; then - # Aucun argument : tout ce que le dépôt sait tester sans base de données. - mapfile -t FILES < <(ls test/test_qemu_*.py test/test_mobile_*.py \ - test/test_todo_*.py test/test_install_*.py test/test_run_*.py \ - test/test_proxmox_*.py test/test_git_*.py \ - 2>/dev/null) + # Aucun argument : tout le répertoire. Mesuré sans PostgreSQL. + mapfile -t FILES < <(ls test/test_*.py 2>/dev/null) fi fail=0 diff --git a/test/test_analyse_lib.py b/test/test_analyse_lib.py index c41894f..6517ec3 100644 --- a/test/test_analyse_lib.py +++ b/test/test_analyse_lib.py @@ -132,6 +132,14 @@ class TestPgEnv(unittest.TestCase): def test_literal_false_means_unset(self): # Odoo écrit « False » dans config.conf pour dire « pas de valeur ». # L'exporter tel quel ferait chercher un hôte nommé « False ». + # + # L'environnement est VIDÉ de ses PG* : `pg_env` part de + # `os.environ`, et un PGHOST exporté par le shell de qui lance les + # tests les ferait échouer sans que rien du code n'ait changé. + for cle in ("PGHOST", "PGPORT", "PGPASSWORD"): + if cle in os.environ: + ancien = os.environ.pop(cle) + self.addCleanup(os.environ.__setitem__, cle, ancien) path = self._write( "[options]\ndb_host = False\ndb_port = False\n" "db_password = False\ndb_user = erplibre\n" @@ -306,10 +314,6 @@ class TestRunPsqlGuards(unittest.TestCase): self.assertIn("Invalid database name", str(caught.exception)) -if __name__ == "__main__": - unittest.main() - - class TestCanonical(unittest.TestCase): """Ce qui décide s'il y a un écart. Le bruit se joue ici.""" @@ -520,3 +524,7 @@ class TestBackupWithoutManifest(unittest.TestCase): def test_manifest_is_the_fallback_when_the_dump_says_nothing(self): self.assertEqual(L.backup_version({}, {"version": "16.0"}), "16.0") self.assertIsNone(L.backup_version({}, {})) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_analyse_view_custom.py b/test/test_analyse_view_custom.py index a85a275..274f978 100644 --- a/test/test_analyse_view_custom.py +++ b/test/test_analyse_view_custom.py @@ -266,10 +266,6 @@ class TestRender(unittest.TestCase): self.assertNotEqual(english, french) -if __name__ == "__main__": - unittest.main() - - class TestCowTwinDiff(unittest.TestCase): """Comparer une copie de site web à la vue de module qu'elle masque. @@ -358,3 +354,7 @@ class TestCowTwinDiff(unittest.TestCase): ) self.assertEqual(n, 0) self.assertNotIn("differs", row) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_anonymize.py b/test/test_anonymize.py index 7c15ac2..28a3210 100644 --- a/test/test_anonymize.py +++ b/test/test_anonymize.py @@ -364,10 +364,6 @@ class TestTheRefusalToWrite(unittest.TestCase): self.assertEqual(appels, []) -if __name__ == "__main__": - unittest.main() - - class TestTheSqlNeverTravelsThroughArgv(unittest.TestCase): """La panne signalée : « OSError: [Errno 7] Argument list too long ». @@ -895,3 +891,7 @@ class TestTheProbeDistrustsWhatItReads(unittest.TestCase): champs = [self._champ("a"), self._champ("b")] _, bornes = self._sonder("8.0:13.0\x1f1.0:2.0", champs) self.assertEqual(len(bornes["m"]), 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_check_addons_exist.py b/test/test_check_addons_exist.py index d0711f1..374d31d 100644 --- a/test/test_check_addons_exist.py +++ b/test/test_check_addons_exist.py @@ -177,3 +177,7 @@ class TestMainBadConfig(unittest.TestCase): ): result = main() self.assertEqual(result, -1) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_code_generator_tools.py b/test/test_code_generator_tools.py index 4549d0d..7adff06 100644 --- a/test/test_code_generator_tools.py +++ b/test/test_code_generator_tools.py @@ -197,3 +197,7 @@ class TestArgsTypeParam(unittest.TestCase): def test_selection_has_selection(self): self.assertIn("selection", ARGS_TYPE_PARAM["Selection"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_database_tools.py b/test/test_database_tools.py index f85675c..9797e01 100644 --- a/test/test_database_tools.py +++ b/test/test_database_tools.py @@ -144,3 +144,7 @@ class TestCompareDatabaseApplicationLogic(unittest.TestCase): s2 = {a["name"] for a in r2} self.assertEqual(s1.difference(s2), {"mod1"}) self.assertEqual(len(s2.difference(s1)), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_docker_update_version.py b/test/test_docker_update_version.py index 2df796b..fe47931 100644 --- a/test/test_docker_update_version.py +++ b/test/test_docker_update_version.py @@ -119,3 +119,7 @@ class TestEditDockerProd(unittest.TestCase): for line in from_lines: self.assertIn("base:v3", line) os.unlink(path) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_format_file_to_commit.py b/test/test_format_file_to_commit.py index fae5593..db5f51c 100644 --- a/test/test_format_file_to_commit.py +++ b/test/test_format_file_to_commit.py @@ -138,3 +138,7 @@ class TestGetModifiedFiles(unittest.TestCase): mock_run.side_effect = subprocess.CalledProcessError(1, "git") files = get_modified_files() self.assertIsNone(files) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_iscompatible.py b/test/test_iscompatible.py index cf605cb..6061287 100644 --- a/test/test_iscompatible.py +++ b/test/test_iscompatible.py @@ -92,3 +92,7 @@ class TestIsCompatible(unittest.TestCase): def test_not_equal_false(self): self.assertFalse(iscompatible("foo!=1.0.0", Version("1.0.0"))) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_mail_imap_transport.py b/test/test_mail_imap_transport.py index 603c5ba..1633a53 100644 --- a/test/test_mail_imap_transport.py +++ b/test/test_mail_imap_transport.py @@ -434,3 +434,7 @@ class TestConnect(unittest.TestCase): with patch("imaplib.IMAP4_SSL", side_effect=OSError("injoignable")): with self.assertRaises(ImapError): connect(self._account("ssl"), "hunter2") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_monitoring.py b/test/test_monitoring.py index 3f0a880..fbba336 100644 --- a/test/test_monitoring.py +++ b/test/test_monitoring.py @@ -250,10 +250,6 @@ class TestWhatAMigrationLeftBehind(unittest.TestCase): self.assertTrue(residue.judge(plein)[0]) -if __name__ == "__main__": - unittest.main() - - class TestTheScreenAnswersKeys(unittest.TestCase): """Presser les touches, et pas seulement lire les fonctions pures. @@ -436,3 +432,7 @@ class TestTheRepairAsksItsOwnDetector(unittest.TestCase): 'env.user.has_group(\n "product.group_product_pricelist"', source, ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_suite_reaches_every_file.py b/test/test_suite_reaches_every_file.py new file mode 100644 index 0000000..a1d6d56 --- /dev/null +++ b/test/test_suite_reaches_every_file.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Un test qui ne tourne pas est pire qu'un test absent. + +Absent, on le sait. Vert sans avoir tourné, il rassure. Le dépôt a +rencontré les deux formes de ce silence le même jour : + + · `unittest.main()` posé au MILIEU d'un fichier. Python exécute de haut + en bas : l'appel part, découvre ce qui est défini jusque-là, et sort. + Tout ce qui suit n'existe pas encore. Quatre fichiers, 87 tests. + + · Aucun bloc `__main__` du tout. Le fichier ne fait rien quand on le + lance, le lanceur compte zéro test, et le total ne bouge pas. Huit + fichiers, 174 tests. + +À quoi s'ajoutait une liste de préfixes dans le lanceur, qui laissait 2400 +tests hors de la suite. Trois façons différentes d'obtenir le même +résultat : du vert qui ne prouve rien. + +Ce fichier est la garde. Il ne lit pas ce que les tests vérifient — il +vérifie qu'ils PEUVENT être vérifiés. +""" + +import ast +import glob +import io +import os +import unittest + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +LANCEUR = os.path.join(REPO, "script", "test", "run_unit_test.sh") + + +def fichiers_de_test(): + return sorted(glob.glob(os.path.join(REPO, "test", "test_*.py"))) + + +def bloc_main(arbre): + """L'indice du `if __name__ == "__main__":` du module, ou None.""" + for rang, noeud in enumerate(arbre.body): + if ( + isinstance(noeud, ast.If) + and isinstance(noeud.test, ast.Compare) + and getattr(noeud.test.left, "id", "") == "__name__" + ): + return rang + return None + + +class TestEveryFileCanRun(unittest.TestCase): + def setUp(self): + self.fichiers = fichiers_de_test() + self.assertTrue(self.fichiers, "aucun fichier de test trouvé") + + def test_every_file_has_a_way_to_run_itself(self): + # Le lanceur exécute chaque fichier comme un programme. Sans ce + # bloc, il ne fait rien et le lanceur compte zéro — sans erreur. + for chemin in self.fichiers: + arbre = ast.parse(io.open(chemin, encoding="utf-8").read()) + self.assertIsNotNone( + bloc_main(arbre), + f"{os.path.basename(chemin)} : pas de bloc __main__", + ) + + def test_the_run_call_is_the_last_thing_in_the_file(self): + # Au milieu, il coupe le fichier en deux et la seconde moitié + # n'est jamais définie au moment où les tests sont découverts. + for chemin in self.fichiers: + arbre = ast.parse(io.open(chemin, encoding="utf-8").read()) + rang = bloc_main(arbre) + if rang is None: + continue + suivants = arbre.body[rang + 1 :] + self.assertEqual( + [], + [type(n).__name__ for n in suivants], + f"{os.path.basename(chemin)} : du code après unittest.main()", + ) + + def test_every_file_actually_declares_a_test(self): + for chemin in self.fichiers: + arbre = ast.parse(io.open(chemin, encoding="utf-8").read()) + combien = sum( + 1 + for noeud in ast.walk(arbre) + if isinstance(noeud, (ast.FunctionDef, ast.AsyncFunctionDef)) + and noeud.name.startswith("test_") + ) + self.assertTrue( + combien, f"{os.path.basename(chemin)} : aucun test dedans" + ) + + +class TestTheRunnerLeavesNobodyOut(unittest.TestCase): + """Une liste de préfixes oublie ; un glob, non.""" + + def setUp(self): + self.source = io.open(LANCEUR, encoding="utf-8").read() + + def test_it_takes_the_whole_directory(self): + self.assertIn("ls test/test_*.py", self.source) + + def test_it_does_not_pick_families_by_name(self): + # Le défaut d'origine : un fichier hors préfixe tombait dans le + # même silence qu'un fichier absent. + for prefixe in ("test/test_qemu_*.py", "test/test_todo_*.py"): + self.assertNotIn(prefixe, self.source, prefixe) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_version.py b/test/test_version.py index 2d48072..dffbb19 100644 --- a/test/test_version.py +++ b/test/test_version.py @@ -241,3 +241,7 @@ class TestUpdatePrintLog(unittest.TestCase): update = Update() update.execute_log = ["entry1", "entry2"] update.print_log() + + +if __name__ == "__main__": + unittest.main() From c95cf2e9d8b56135e43a6fbd3dbd55b08f06ae50 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 26 Aug 2026 07:46:32 -0400 Subject: [PATCH 12/22] =?UTF-8?q?[ADD]=20qualit=C3=A9=20de=20migration=20:?= =?UTF-8?q?=20verdicts,=20sources,=20revue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le rapport comparait les paliers sans dire si la migration avait réussi, alors que les verdicts dorment déjà dans lst_event du journal de progression : des contrôles en échec y restent sans remonter nulle part. Trois sections s'ajoutent sous les paliers : les verdicts, rattachés au palier ODOO et non au compteur du pilote, décalé d'un rang ; où vivent les traces, car config.conf laisse logfile= vide et la sortie d'Odoo meurt avec le terminal ; et la revue, six étapes lançables par « r ». Le contrôle de résidus porte la même section sans toucher son code de sortie : un verdict vient du fichier, pas de la base. --- EN --- The report compared the tiers without saying whether the migration had succeeded, while the verdicts already sit in lst_event of the progression file: failed checks stay there and surface nowhere. Three sections are added below the tiers: the verdicts, tied to the ODOO tier and not to the driver counter, which is off by one; where the traces live, since config.conf leaves logfile= empty and Odoo's output dies with the terminal; and the review, six steps runnable with "r". The residue check carries the same section without touching its exit code: a verdict comes from the file, not from the database. Assisted-by: Claude Opus 5 (cherry picked from commit b05e0333c4b94d58eb794f09a2459e41d826c655) --- script/analyse/check_migration_quality.py | 164 +++++++ script/analyse/check_migration_quality_tui.py | 380 +++++++++++++++- script/analyse/check_migration_residue.py | 89 +++- script/todo/todo_i18n.py | 203 +++++++++ test/test_check_migration_quality.py | 420 +++++++++++++++++- test/test_monitoring.py | 134 ++++++ 6 files changed, 1372 insertions(+), 18 deletions(-) diff --git a/script/analyse/check_migration_quality.py b/script/analyse/check_migration_quality.py index 5dba90a..d9c4c17 100755 --- a/script/analyse/check_migration_quality.py +++ b/script/analyse/check_migration_quality.py @@ -87,6 +87,170 @@ def read_progression(path=DEFAULT_PROGRESSION): return {} +# Où la migration laisse une trace, et ce qu'on y trouve. Les DEUX +# chemins sont montrés à l'écran même quand le second n'existe pas : sans +# `logfile` dans config.conf, Odoo écrit sur le terminal et sa sortie est +# perdue à la fermeture. Ne rien dire laisserait chercher un fichier qui +# n'a jamais été écrit. +JOURNAL_ODOO = "log/odoo.log" +MOTIFS_ERREUR = ("CRITICAL", "ERROR", "Traceback") + + +def read_events(dct): + """Les verdicts que la migration a laissés, dans l'ordre où ils sont venus. + + `command_executed` dit ce qui a été LANCÉ ; `lst_event` dit ce que + cela a RENDU. C'est la seule trace persistante d'un échec : la sortie + d'Odoo, elle, part sur le terminal et disparaît avec lui. + """ + lst = [] + for brut in dct.get("lst_event") or []: + if not isinstance(brut, dict): + continue + try: + statut = int(brut.get("status") or 0) + except (TypeError, ValueError): + statut = 0 + lst.append( + { + "at": str(brut.get("at") or ""), + "step": str(brut.get("step") or ""), + "kind": str(brut.get("kind") or ""), + "name": str(brut.get("name") or ""), + "status": statut, + "detail": str(brut.get("detail") or ""), + } + ) + return lst + + +def failures(events): + """Ceux qui n'ont pas rendu zéro, et seulement ceux-là. + + On garde `kind == "test"` : les entrées `command` à 1 appartiennent au + dialogue du pilote — « voulez-vous effacer le module manquant » — et + signaler un choix comme un échec ferait ignorer la liste entière. + """ + return [e for e in events if e["status"] and e["kind"] == "test"] + + +def event_database(event): + """La base sur laquelle ce verdict portait, lue dans sa commande.""" + for mot in event.get("detail", "").split(): + if mot.startswith("test_") or "_upgrade_" in mot: + return mot + morceaux = event.get("detail", "").split("-d ") + if len(morceaux) > 1: + return morceaux[1].split()[0] + return "" + + +def event_step(event, database=""): + """Le palier Odoo que ce verdict concernait — « 14 », « 18 ». + + Le nom de la base le porte — « …_upgrade_14 » — et c'est plus sûr que + le champ `step`, dont la numérotation (« 4.1.I ») compte les ÉTAPES du + pilote et non les versions d'Odoo : elles sont décalées d'un rang. + """ + base = database or event_database(event) + if "_upgrade_" in base: + suffixe = base.rsplit("_upgrade_", 1)[1] + if suffixe.isdigit(): + return suffixe + return (event.get("step") or "").split(" ")[0][:5] + + +def log_sources(path=DEFAULT_PROGRESSION, journal=JOURNAL_ODOO): + """[(rôle, chemin, existe, ce qu'on y lit)] — la carte des traces. + + Documenter les chemins EST la fonctionnalité : il a fallu une question + pour découvrir que les verdicts vivaient dans `lst_event`, et que la + sortie d'Odoo n'était écrite nulle part. + """ + return [ + ( + t("Migration progression"), + path, + os.path.isfile(path), + t("steps, commands and their verdicts (lst_event)"), + ), + ( + t("Odoo log"), + journal, + os.path.isfile(journal), + t("set logfile= in config.conf, else output is lost"), + ), + ] + + +def scan_log(path=JOURNAL_ODOO, limite=12): + """{motif: compte} et les dernières lignes fautives d'un journal Odoo. + + On lit la QUEUE : un journal de migration pèse des dizaines de + mégaoctets et ce qu'on cherche est ce qui a échoué en dernier. + """ + rapport = { + "path": path, + "exists": os.path.isfile(path), + "counts": {}, + "lines": [], + } + if not rapport["exists"]: + return rapport + try: + with open(path, "rb") as handle: + handle.seek(0, os.SEEK_END) + debut = max(0, handle.tell() - 2_000_000) + handle.seek(debut) + queue = handle.read().decode("utf-8", "replace") + except OSError: + return rapport + for motif in MOTIFS_ERREUR: + rapport["counts"][motif] = queue.count(motif) + for ligne in queue.splitlines(): + if any(motif in ligne for motif in MOTIFS_ERREUR): + rapport["lines"].append(ligne.strip()[:200]) + rapport["lines"] = rapport["lines"][-limite:] + return rapport + + +# La revue : ce qu'on vérifie, dans l'ordre, et ce que chaque étape prouve. +# Elle est ÉCRITE ici plutôt que dans une documentation à côté, parce +# qu'une liste de contrôle qu'il faut aller chercher n'est pas suivie. +REVUE = ( + ( + "Did the migration reach the end?", + "lst_event, and the six state_4_*_lst flags at 6/6", + None, + ), + ( + "Does Odoo load the database?", + "./odoo_bin.sh shell -c ./config.conf -d {db} --no-http", + "shell", + ), + ( + "What did the migration leave behind?", + "script/analyse/check_migration_residue.py -d {db}", + "residue", + ), + ( + "Is this copy safe to open?", + "script/analyse/check_instance_state.py -d {db} --expect copy", + "state", + ), + ( + "Do the public pages still answer?", + "script/odoo/migration/smoke_public_url.py -d {db}", + "smoke", + ), + ( + "Is anything left that cleanup could not drop?", + "script/odoo/migration/database_cleanup.py -d {db}", + "cleanup", + ), +) + + def chain(dct): """[(version, base)] du départ à l'arrivée, dans l'ordre du parcours. diff --git a/script/analyse/check_migration_quality_tui.py b/script/analyse/check_migration_quality_tui.py index fc38097..c56a1da 100644 --- a/script/analyse/check_migration_quality_tui.py +++ b/script/analyse/check_migration_quality_tui.py @@ -13,6 +13,7 @@ Deux assemblages sépareraient les deux vues, et l'on finirait par lire deux """ import os +import subprocess import sys sys.path.append( @@ -30,6 +31,13 @@ except Exception: # pragma: no cover - repli si i18n indisponible return key +# Les commandes se lancent depuis la RACINE : les chemins qu'elles portent +# — « script/odoo/migration/… » — y sont relatifs, et les lancer d'ailleurs +# les rend introuvables. +REPO_ROOT = os.path.normpath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..") +) + CSS = """ Screen { layout: vertical; } #head { height: 3; padding: 0 1; background: $panel; color: $text; } @@ -39,12 +47,17 @@ Screen { layout: vertical; } """ -def rows(lst_snapshot): - """Un palier par ligne, puis le bilan d'ensemble en dernier. +def rows(lst_snapshot, dct=None): + """Un palier par ligne, le bilan, puis les trois sections de revue. - Le bilan EN DERNIER et non en tête : on descend la liste comme on a - vécu la migration, et la question « qu'est-ce qu'il en reste » se pose - une fois qu'on a vu le chemin. + Le bilan APRÈS les paliers et non en tête : on descend la liste comme + on a vécu la migration, et la question « qu'est-ce qu'il en reste » se + pose une fois qu'on a vu le chemin. Ce qui suit — verdicts, où lire, + quoi vérifier — répond à « et maintenant ». + + `dct` se passe pour éviter la lecture du fichier de progression : + autrement la liste dépend de ce qu'une migration a laissé sur CE + poste, et deux exécutions ne rendent pas la même chose. """ lst = [] presents = [x for x in lst_snapshot if x.get("exists")] @@ -95,9 +108,129 @@ def rows(lst_snapshot): "diff": quality.overall(lst_snapshot), } ) + lst.extend(extra_rows(presents, dct)) return lst +def extra_rows(presents, dct=None): + """Les trois sections sous les paliers : succès, validation, revue. + + Elles vivent dans la MÊME table que les paliers plutôt que dans un + second panneau : on descend la colonne une seule fois, et l'ordre dit + la démarche — ce que la migration a rendu, où le lire, quoi vérifier + ensuite. + + Un séparateur n'est PAS sélectionnable au sens où il n'affiche qu'un + titre ; le laisser dans la liste garde l'index de la table aligné sur + celui des lignes, ce qu'une liste filtrée perdrait. + """ + dct = quality.read_progression() if dct is None else dct + cible = presents[-1]["database"] if presents else "" + lst = [] + + # ── Succès ── + evenements = quality.read_events(dct) + ratés = quality.failures(evenements) + lst.append( + { + "kind": "header", + "label": f"── {t('Verdicts')} ──", + "detail": str(len(ratés)) if ratés else "✅", + } + ) + if not evenements: + lst.append( + { + "kind": "verdict-none", + "label": f" {t('no verdict recorded')}", + "detail": "", + } + ) + for event in ratés: + base = quality.event_database(event) + # Le PALIER dans le libellé : « smoke_public_url » quatre fois de + # suite ne dit pas lequel a échoué, et c'est la seule chose qu'on + # veut savoir en parcourant la colonne. + palier = quality.event_step(event, base) + lst.append( + { + "kind": "verdict", + "label": f" ❌ {palier:<5} {event['name'][:18]}", + "detail": "▶", + "event": event, + "database": base or cible, + "command": rerun_command(event, base or cible), + } + ) + if evenements and not ratés: + lst.append( + { + "kind": "verdict-ok", + "label": f" ✅ {len(evenements)} {t('checks, all passed')}", + "detail": "", + } + ) + + # ── Validation ── + lst.append( + {"kind": "header", "label": f"── {t('Validation')} ──", "detail": ""} + ) + for role, chemin, existe, _quoi in quality.log_sources(): + lst.append( + { + "kind": "source", + "label": f" {'📄' if existe else '∅'} {role[:24]}", + "detail": "", + "path": chemin, + } + ) + lst.append( + { + "kind": "logscan", + "label": f" 🔎 {t('errors in the log')}", + "detail": "", + } + ) + + # ── Revue ── + lst.append( + {"kind": "header", "label": f"── {t('Review')} ──", "detail": ""} + ) + for rang, (question, commande, clef) in enumerate(quality.REVUE, 1): + lst.append( + { + "kind": "review", + "label": f" {rang}. {t(question)[:26]}", + "detail": "▶" if clef else "", + "question": question, + "command": ( + commande.format(db=cible) if cible and clef else "" + ), + } + ) + return lst + + +def rerun_command(event, database): + """La commande qui rejoue ce verdict, ou "" si on ne sait pas la refaire. + + On la RECONSTRUIT depuis le nom de l'outil plutôt que de rejouer le + `detail` tel quel : celui-ci porte le chemin d'un venv de palier, qui + n'est plus celui du checkout courant. + """ + if not database: + return "" + outils = { + "smoke_public_url": "script/odoo/migration/smoke_public_url.py", + "database_cleanup": "script/odoo/migration/database_cleanup.py", + "check_hidden_models": "script/odoo/migration/check_hidden_models.py", + } + chemin = outils.get(event.get("name", "")) + if not chemin: + return "" + return f"{chemin} -d {database}" + + def head_text(lst_snapshot): presents = [x for x in lst_snapshot if x.get("exists")] manquants = len(lst_snapshot) - len(presents) @@ -159,6 +292,183 @@ def statistics(etat, precedent): ] +EXTRA_KINDS = ( + "header", + "verdict", + "verdict-none", + "verdict-ok", + "source", + "logscan", + "review", +) + + +def extra_pane(row, colour=False): + """Le panneau des trois sections ajoutées sous les paliers.""" + genre = row["kind"] + if genre == "header": + return t("Pick a line below.") + if genre == "verdict": + return verdict_pane(row, colour) + if genre == "verdict-none": + return "\n".join( + [ + t("The migration recorded no verdict."), + "", + t("Verdicts live in lst_event of the progression file,"), + t("not in command_executed, which only lists what ran."), + ] + ) + if genre == "verdict-ok": + return t("Every recorded check returned zero.") + if genre == "source": + return source_pane(row, colour) + if genre == "logscan": + return logscan_pane(colour) + if genre == "review": + return review_pane(row, colour) + return "" + + +def verdict_pane(row, colour=False): + """Ce qu'un verdict raté dit, et ce que son code de retour signifie.""" + event = row.get("event") or {} + lignes = [ + status.paint(f"❌ {event.get('name')}", "fail", colour), + "", + # Le palier est la version d'ODOO, pas le compteur du pilote : + # « 4.1.I » désigne la première étape du quatrième bloc, et la + # migration en est alors au palier 14. Les afficher tous deux sous + # le même mot faisait lire « palier 4.1 », qui n'existe pas. + f" {t('step'):<10} {quality.event_step(event)}" + f" ({event.get('step')})", + f" {t('when'):<10} {event.get('at')[:19]}", + f" {t('status'):<10} {event.get('status')}", + "", + f" {t('what it ran')}", + f" {event.get('detail', '')[:150]}", + "", + ] + sens = { + "smoke_public_url": t( + "1 means a public page failed — not merely a finding." + ), + "database_cleanup": t( + "1 means leftovers remain that it could not drop." + ), + }.get(event.get("name"), "") + if sens: + lignes.append(f" {sens}") + lignes.append("") + if row.get("command"): + lignes.append( + status.paint( + f" ▶ {t('press r to run it again')}", "step", colour + ) + ) + lignes.append(f" {row['command']}") + lignes.append("") + lignes.append( + f" {t('The screen steps aside, the test takes the terminal,')}" + ) + lignes.append(f" {t('and you come back with Enter.')}") + else: + lignes.append(f" {t('No known way to replay this one.')}") + return "\n".join(lignes) + + +def source_pane(row, colour=False): + """Où la migration laisse ses traces, et ce que chacune contient.""" + lignes = [status.paint(t("Where the traces live"), "step", colour), ""] + for role, chemin, existe, quoi in quality.log_sources(): + marque = "📄" if existe else "∅" + teinte = "ok" if existe else "warn" + lignes.append(f" {marque} {status.paint(role, teinte, colour)}") + lignes.append(f" {chemin}") + lignes.append(f" {quoi}") + if not existe: + lignes.append(f" {t('missing: nothing was written here')}") + lignes.append("") + lignes.append(f" {t('To read the verdicts by hand:')}") + lignes.append(" python3 - <<'PY'") + lignes.append(" import json, io") + lignes.append( + " d = json.load(io.open(" + f"'{quality.DEFAULT_PROGRESSION}', encoding='utf-8'))" + ) + lignes.append(" for e in d['lst_event']:") + lignes.append(" if e.get('status'):") + lignes.append(" print(e['step'], e['name'], e['status'])") + lignes.append(" PY") + return "\n".join(lignes) + + +def logscan_pane(colour=False): + """Les erreurs du journal d'Odoo — ou pourquoi il n'y en a pas.""" + rapport = quality.scan_log() + lignes = [status.paint(t("Errors in the Odoo log"), "step", colour), ""] + lignes.append(f" {rapport['path']}") + if not rapport["exists"]: + lignes.append("") + lignes.append( + status.paint( + f" ∅ {t('This file does not exist.')}", "warn", colour + ) + ) + lignes.append("") + lignes.append( + f" {t('config.conf has an empty logfile=, so Odoo writes to')}" + ) + lignes.append( + f" {t('the terminal and its output dies with it. To keep it:')}" + ) + lignes.append("") + lignes.append( + f" logfile = {os.path.abspath(quality.JOURNAL_ODOO)}" + ) + return "\n".join(lignes) + lignes.append("") + for motif, combien in rapport["counts"].items(): + teinte = "fail" if combien else "ok" + lignes.append( + f" {status.paint(motif.ljust(10), teinte, colour)} {combien}" + ) + if rapport["lines"]: + lignes.append("") + lignes.append(f" {t('last offending lines')}") + for ligne in rapport["lines"]: + lignes.append(f" {ligne}") + return "\n".join(lignes) + + +def review_pane(row, colour=False): + """Une étape de la revue : ce qu'elle prouve, et ce qu'elle ne prouve pas.""" + lignes = [ + status.paint(t(row.get("question", "")), "step", colour), + "", + ] + if row.get("command"): + lignes.append(f" {row['command']}") + lignes.append("") + lignes.append( + status.paint(f" ▶ {t('press r to run it')}", "step", colour) + ) + else: + lignes.append(f" {t('Read it in the Verdicts section above.')}") + lignes.append("") + lignes.append(f" {t('What none of these can see')}") + lignes.append( + f" {t('They all read the DATABASE. A module that kept a')}" + ) + lignes.append( + f" {t('pre-18 view type, or an image URL Odoo no longer')}" + ) + lignes.append( + f" {t('accepts, breaks at runtime on a perfectly sound one.')}" + ) + return "\n".join(lignes) + + def pane_text(lst_snapshot, row, colour=False, mode=None): """Le détail du palier choisi, ou le bilan d'ensemble. @@ -168,6 +478,8 @@ def pane_text(lst_snapshot, row, colour=False, mode=None): """ if row is None: return t("Nothing to show yet.") + if row["kind"] in EXTRA_KINDS: + return extra_pane(row, colour) if row["kind"] == "missing": return f"⚠️ {row['data']['database']} : {t('database not found')}" etat = row["data"] @@ -222,6 +534,41 @@ def mode_label(mode): return t(mode) +def run_in_terminal(command, wait=True): + """Rendre le terminal au test, puis le reprendre. (code, a_tourné). + + L'écran DOIT s'effacer : `smoke_public_url` monte une instance Odoo et + écrit des centaines de lignes ; les capturer les cacherait, et les + laisser passer par-dessus la TUI la déchirerait. Textual sait se + retirer — `suspend()` chez l'appelant — et c'est le seul moment où ce + processus peut lancer autre chose sans se battre pour l'affichage. + + L'attente d'Entrée n'est pas une politesse : sans elle, l'écran se + reconstruit par-dessus le résultat avant qu'on ait pu le lire. + """ + if not command: + return None, False + argv = command.split() + if argv[0].endswith(".py"): + argv = [sys.executable] + argv + print() + print(f"▶ {' '.join(argv)}") + print("─" * 72) + try: + code = subprocess.run(argv, cwd=REPO_ROOT).returncode + except OSError as exc: + print(f"❌ {exc}") + code = None + print("─" * 72) + print(f"↩ {t('exit code:')} {code}") + if wait: + try: + input(t("Press Enter to go back to the screen…")) + except (EOFError, KeyboardInterrupt): + pass + return code, True + + def build_app(lst_snapshot): """Textual est importé ICI : le module reste testable sans lui.""" from rich.text import Text @@ -235,6 +582,7 @@ def build_app(lst_snapshot): ("q,escape", "quit", t("Quit")), ("m", "toggle_missing", t("Missing files")), ("d", "cycle_detail", t("Details")), + ("r,enter", "run_selected", t("Run")), ] def __init__(self, lst_snapshot): @@ -305,6 +653,28 @@ def build_app(lst_snapshot): self.mode = suite[(suite.index(courant) + 1) % len(suite)] self._show() + def action_run_selected(self): + """Rejouer le test de la ligne choisie, hors de l'écran. + + `suspend()` rend le terminal pour de bon : le test peut monter + son instance Odoo et écrire ce qu'il veut. Rien n'est capturé, + donc rien n'est caché. + """ + row = ( + self.lst_row[self.index] + if self.lst_row and self.index < len(self.lst_row) + else None + ) + commande = row.get("command") if row else "" + if not commande: + # Une ligne sans commande n'est pas une erreur : la plupart + # n'en ont pas. Un bip dit « rien ici » sans interrompre. + self.bell() + return + with self.suspend(): + run_in_terminal(commande) + self.refresh() + def on_data_table_row_highlighted(self, event): if event.data_table.id == "left" and self.lst_row: self.index = event.cursor_row diff --git a/script/analyse/check_migration_residue.py b/script/analyse/check_migration_residue.py index b2320ea..d75f75c 100755 --- a/script/analyse/check_migration_residue.py +++ b/script/analyse/check_migration_residue.py @@ -74,10 +74,12 @@ except Exception: # pragma: no cover - repli si i18n indisponible return key +from script.analyse import check_migration_quality as quality # noqa: E402 from script.analyse import lib_analyse # noqa: E402 COULEURS = { "broken": "\033[31m", + "step": "\033[36m", "watch": "\033[33m", "ok": "\033[32m", "dim": "\033[90m", @@ -244,6 +246,83 @@ def judge(resultats): return trouve, illisibles +def famille(nom): + """La base d'origine dont ce nom est un palier. + + « test_neutralize_upgrade_14 » et « …_upgrade_18 » sont deux paliers + de la MÊME migration. Interroger la base 18 doit montrer l'échec du + palier 14 : c'est le seul endroit où il subsiste. + """ + return nom.rsplit("_upgrade_", 1)[0] if "_upgrade_" in nom else nom + + +def verdicts(database, path=None): + """(tous, ratés) pour cette base — lus dans le FICHIER, pas en SQL. + + Un test de fumée qui échoue ne laisse aucune trace en base : rien + n'est écrit, rien n'est cassé, la requête suivante répond. Les + contrôles ci-dessus sont donc structurellement aveugles à ce type + d'échec, et c'était la moitié de ce qu'une migration peut rater. + """ + dct = quality.read_progression(path or quality.DEFAULT_PROGRESSION) + lignee = famille(database) + tous = [ + e + for e in quality.read_events(dct) + if famille(quality.event_database(e)) == lignee + ] + return tous, quality.failures(tous) + + +def verdicts_block(database, colour=True, path=None): + """La section « Verdicts », ou rien du tout s'il n'y en a pas. + + Silencieuse quand le fichier n'existe pas : devant la sauvegarde d'un + client, il n'y a jamais eu de migration locale, et annoncer l'absence + d'un fichier qu'on n'attendait pas ne renseigne personne. + """ + chemin = path or quality.DEFAULT_PROGRESSION + tous, ratés = verdicts(database, chemin) + if not tous: + return [] + lignes = [ + "", + paint(f"🚦 {t('Verdicts the migration recorded')}", "step", colour), + ] + lignes.append(paint(f" {t('recorded in')} {chemin}", "dim", colour)) + lignes.append("") + if not ratés: + lignes.append( + paint( + f"✅ {str(len(tous)).rjust(6)} {t('checks, all passed')}", + "ok", + colour, + ) + ) + for event in ratés: + palier = quality.event_step(event) + lignes.append( + paint( + f"❌ {palier.rjust(6)} {event['name']}", + "broken", + colour, + ) + ) + lignes.append( + paint(f" {event['detail'][:120]}", "dim", colour) + ) + lignes.append("") + lignes.append( + paint( + f" {t('These come from the file, not the database:')}" + f" {t('the exit code ignores them.')}", + "dim", + colour, + ) + ) + return lignes + + def render(database, resultats, version=None, colour=True): """Le rapport lisible. Chaque constat dit quoi lancer pour le réparer.""" trouve, illisibles = judge(resultats) @@ -266,7 +345,7 @@ def render(database, resultats, version=None, colour=True): colour, ) ) - return "\n".join(lignes) + return "\n".join(lignes + verdicts_block(database, colour)).rstrip() for controle, combien in trouve: icone = "❌" if controle["gravity"] == "broken" else "⚠" @@ -295,7 +374,7 @@ def render(database, resultats, version=None, colour=True): ) ) lignes.append(paint(f" {erreur}", "dim", colour)) - return "\n".join(lignes).rstrip() + return "\n".join(lignes + verdicts_block(database, colour)).rstrip() def main(argv=None): @@ -329,6 +408,7 @@ def main(argv=None): trouve, illisibles = judge(resultats) if args.json: + tous_verdicts, verdicts_ratés = verdicts(args.database) print( json.dumps( { @@ -337,6 +417,11 @@ def main(argv=None): "checks": resultats, "found": [c["key"] for c, _ in trouve], "unreadable": [c["key"] for c, _ in illisibles], + "verdicts": tous_verdicts, + "verdicts_failed": [ + {"step": quality.event_step(e), "name": e["name"]} + for e in verdicts_ratés + ], }, indent=2, ensure_ascii=False, diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index f7feb41..43e644a 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -6977,6 +6977,209 @@ TRANSLATIONS = { "fr": "Qualité de la migration, palier par palier", "en": "Migration quality, step by step", }, + # --- Écran de qualité : Verdicts, Validation, Revue --- + "Verdicts the migration recorded": { + "fr": "Verdicts enregistrés par la migration", + "en": "Verdicts the migration recorded", + }, + "recorded in": { + "fr": "consignés dans", + "en": "recorded in", + }, + "These come from the file, not the database:": { + "fr": "Ils viennent du fichier, non de la base :", + "en": "These come from the file, not the database:", + }, + "the exit code ignores them.": { + "fr": "le code de sortie ne les compte pas.", + "en": "the exit code ignores them.", + }, + # Les six questions de la revue : t() les reçoit par + # variable, un scan des littéraux ne les voyait pas. + "Did the migration reach the end?": { + "fr": "La migration est-elle allée jusqu'au bout ?", + "en": "Did the migration reach the end?", + }, + "Does Odoo load the database?": { + "fr": "Odoo charge-t-il la base ?", + "en": "Does Odoo load the database?", + }, + "What did the migration leave behind?": { + "fr": "Qu'est-ce que la migration a laissé derrière ?", + "en": "What did the migration leave behind?", + }, + "Is this copy safe to open?": { + "fr": "Cette copie est-elle sans danger à ouvrir ?", + "en": "Is this copy safe to open?", + }, + "Do the public pages still answer?": { + "fr": "Les pages publiques répondent-elles encore ?", + "en": "Do the public pages still answer?", + }, + "Is anything left that cleanup could not drop?": { + "fr": "Reste-t-il ce que le nettoyage n'a pu supprimer ?", + "en": "Is anything left that cleanup could not drop?", + }, + "Verdicts": { + "fr": "Verdicts", + "en": "Verdicts", + }, + "Validation": { + "fr": "Validation", + "en": "Validation", + }, + "Review": { + "fr": "Revue de la migration", + "en": "Review", + }, + "no verdict recorded": { + "fr": "aucun verdict enregistré", + "en": "no verdict recorded", + }, + "checks, all passed": { + "fr": "tests, tous réussis", + "en": "checks, all passed", + }, + "step": { + "fr": "palier", + "en": "step", + }, + "when": { + "fr": "quand", + "en": "when", + }, + "status": { + "fr": "statut", + "en": "status", + }, + "what it ran": { + "fr": "ce qu'il a lancé", + "en": "what it ran", + }, + "1 means a public page failed — not merely a finding.": { + "fr": "1 signifie qu'une page publique a échoué — pas un simple constat.", + "en": "1 means a public page failed — not merely a finding.", + }, + "1 means leftovers remain that it could not drop.": { + "fr": "1 signifie qu'il reste des objets qu'il n'a pu supprimer.", + "en": "1 means leftovers remain that it could not drop.", + }, + "press r to run it again": { + "fr": "appuie sur r pour le relancer", + "en": "press r to run it again", + }, + "The screen steps aside, the test takes the terminal,": { + "fr": "L'écran s'efface, le test prend le terminal,", + "en": "The screen steps aside, the test takes the terminal,", + }, + "and you come back with Enter.": { + "fr": "et tu reviens avec Entrée.", + "en": "and you come back with Enter.", + }, + "No known way to replay this one.": { + "fr": "Aucun moyen connu de rejouer celui-ci.", + "en": "No known way to replay this one.", + }, + "The migration recorded no verdict.": { + "fr": "La migration n'a enregistré aucun verdict.", + "en": "The migration recorded no verdict.", + }, + "Every recorded check returned zero.": { + "fr": "Chaque test enregistré a rendu zéro.", + "en": "Every recorded check returned zero.", + }, + "Where the traces live": { + "fr": "Où vivent les traces", + "en": "Where the traces live", + }, + "missing: nothing was written here": { + "fr": "absent : rien n'a été écrit ici", + "en": "missing: nothing was written here", + }, + "To read the verdicts by hand:": { + "fr": "Pour lire les verdicts à la main :", + "en": "To read the verdicts by hand:", + }, + "Verdicts live in lst_event of the progression file,": { + "fr": "Les verdicts vivent dans lst_event du fichier de", + "en": "Verdicts live in lst_event of the progression file,", + }, + "not in command_executed, which only lists what ran.": { + "fr": "progression — command_executed ne liste que l'exécuté.", + "en": "not in command_executed, which only lists what ran.", + }, + "Errors in the Odoo log": { + "fr": "Erreurs dans le journal d'Odoo", + "en": "Errors in the Odoo log", + }, + "This file does not exist.": { + "fr": "Ce fichier n'existe pas.", + "en": "This file does not exist.", + }, + "config.conf has an empty logfile=, so Odoo writes to": { + "fr": "config.conf a un logfile= vide : Odoo écrit dans le", + "en": "config.conf has an empty logfile=, so Odoo writes to", + }, + "the terminal and its output dies with it. To keep it:": { + "fr": "terminal et sa sortie meurt avec lui. Pour la garder :", + "en": "the terminal and its output dies with it. To keep it:", + }, + "last offending lines": { + "fr": "dernières lignes fautives", + "en": "last offending lines", + }, + "press r to run it": { + "fr": "appuie sur r pour le lancer", + "en": "press r to run it", + }, + "Read it in the Verdicts section above.": { + "fr": "À lire dans la section Verdicts ci-dessus.", + "en": "Read it in the Verdicts section above.", + }, + "What none of these can see": { + "fr": "Ce qu'aucun d'eux ne voit", + "en": "What none of these can see", + }, + "They all read the DATABASE. A module that kept a": { + "fr": "Tous lisent la BASE. Un module resté sur un type de", + "en": "They all read the DATABASE. A module that kept a", + }, + "pre-18 view type, or an image URL Odoo no longer": { + "fr": "vue d'avant 18, ou une URL d'image qu'Odoo", + "en": "pre-18 view type, or an image URL Odoo no longer", + }, + "accepts, breaks at runtime on a perfectly sound one.": { + "fr": "refuse désormais, casse à l'exécution sur une base saine.", + "en": "accepts, breaks at runtime on a perfectly sound one.", + }, + "exit code:": { + "fr": "code de sortie :", + "en": "exit code:", + }, + "Press Enter to go back to the screen…": { + "fr": "Appuie sur Entrée pour revenir à l'écran…", + "en": "Press Enter to go back to the screen…", + }, + "Pick a line below.": { + "fr": "Choisis une ligne ci-dessous.", + "en": "Pick a line below.", + }, + "Migration progression": { + "fr": "Progression de la migration", + "en": "Migration progression", + }, + "Odoo log": { + "fr": "Journal d'Odoo", + "en": "Odoo log", + }, + "steps, commands and their verdicts (lst_event)": { + "fr": "paliers, commandes et leurs verdicts (lst_event)", + "en": "steps, commands and their verdicts (lst_event)", + }, + "set logfile= in config.conf, else output is lost": { + "fr": "poser logfile= dans config.conf, sinon la sortie est perdue", + "en": "set logfile= in config.conf, else output is lost", + }, "database not found": { "fr": "base introuvable", "en": "database not found", diff --git a/test/test_check_migration_quality.py b/test/test_check_migration_quality.py index 283c72f..5aed2be 100644 --- a/test/test_check_migration_quality.py +++ b/test/test_check_migration_quality.py @@ -1025,26 +1025,71 @@ class TestItNeverWrites(Base): def test_no_odoo_is_started(self): # Six démarrages coûteraient une heure ET écriraient dans les # bases. L'inspection en SQL prend moins d'une demi-seconde. + # + # La garde porte sur le CODE, fonction par fonction, et non sur le + # texte du fichier : la revue CITE « ./odoo_bin.sh shell » comme + # étape à faire soi-même, et interdire le mot interdirait de le + # nommer. Ce qu'on veut garantir est que rien ici ne le LANCE. import inspect - source = inspect.getsource(quality) - for interdit in ("odoo_bin", "run.sh", "--update", "-u all"): - self.assertNotIn(interdit, source, interdit) + for nom, objet in vars(quality).items(): + if not inspect.isfunction(objet): + continue + if objet.__module__ != quality.__name__: + continue + source = inspect.getsource(objet) + for interdit in ("odoo_bin", "run.sh", "--update", "-u all"): + self.assertNotIn(interdit, source, f"{nom} → {interdit}") + + def test_the_only_process_it_launches_is_psql(self): + # Complément du précédent : une donnée peut nommer un programme, + # un `subprocess.run` le lance. Il n'y en a qu'un, et c'est psql. + import ast + import inspect + + arbre = ast.parse(inspect.getsource(quality)) + lances = [ + n + for n in ast.walk(arbre) + if isinstance(n, ast.Call) + and isinstance(n.func, ast.Attribute) + and n.func.attr == "run" + and getattr(n.func.value, "id", "") == "subprocess" + ] + self.assertEqual(1, len(lances)) + premier = lances[0].args[0] + self.assertEqual("psql", premier.elts[0].value) class TestTheFullScreen(Base): - def test_one_row_per_step_plus_the_overall(self): + def test_one_row_per_step_then_the_overall(self): lst = [snapshot(odoo="12.0"), snapshot(odoo="18.0")] - lignes = qtui.rows(lst) - self.assertEqual( - [x["kind"] for x in lignes], ["step", "step", "overall"] - ) + lignes = qtui.rows(lst, {}) + debut = [x["kind"] for x in lignes][:3] + self.assertEqual(debut, ["step", "step", "overall"]) - def test_the_overall_comes_LAST(self): + def test_the_overall_closes_the_steps(self): # On descend la liste comme on a vécu la migration ; « qu'en - # reste-t-il » se pose une fois le chemin vu. + # reste-t-il » se pose une fois le chemin vu. Ce qui suit — les + # verdicts, où les lire, quoi vérifier — répond à « et après ». lst = [snapshot(odoo="12.0"), snapshot(odoo="18.0")] - self.assertEqual(qtui.rows(lst)[-1]["kind"], "overall") + genres = [x["kind"] for x in qtui.rows(lst, {})] + rang = genres.index("overall") + self.assertEqual(set(genres[:rang]), {"step"}) + self.assertNotIn("step", genres[rang:]) + + def test_the_review_sections_come_after_the_overall(self): + lst = [snapshot(odoo="12.0"), snapshot(odoo="18.0")] + genres = [x["kind"] for x in qtui.rows(lst, {})] + apres = set(genres[genres.index("overall") + 1 :]) + self.assertTrue(apres <= set(qtui.EXTRA_KINDS) | {"header"}, apres) + + def test_the_screen_does_not_depend_on_this_machine(self): + # Sans `dct`, la liste refléterait ce qu'une migration a laissé + # sur CE poste : deux exécutions ne rendraient pas la même chose. + lst = [snapshot(odoo="12.0"), snapshot(odoo="18.0")] + avec = qtui.rows(lst, {"lst_event": []}) + self.assertNotIn("verdict", [x["kind"] for x in avec]) def test_a_missing_database_gets_its_own_row(self): lignes = qtui.rows( @@ -1587,5 +1632,358 @@ class TestWhyAnAttachmentWentAway(Base): self.assertNotEqual("ir_attachment", entree.get("table")) +def evenement(**champs): + """Un événement du journal de progression, forme réelle.""" + brut = { + "at": "2026-08-26 03:19:59.846453", + "step": "4.1.I - Migrate database", + "kind": "test", + "name": "smoke_public_url", + "status": 1, + "detail": ".venv.erplibre/bin/python3" + " ./script/odoo/migration/smoke_public_url.py" + " -d test_neutralize_upgrade_14 --internal-required", + } + brut.update(champs) + return brut + + +class TestTheVerdictsAreReadFromTheFile(Base): + """`lst_event` est la SEULE trace persistante d'un échec. + + `command_executed` ne dit que ce qui a été lancé. La sortie d'Odoo, + elle, part sur le terminal et meurt avec lui. + """ + + def test_a_status_written_as_text_still_counts(self): + # Le pilote écrit parfois le code en chaîne ; le comparer à zéro + # sans le convertir ferait passer "0" pour un échec. + lus = quality.read_events({"lst_event": [evenement(status="1")]}) + self.assertEqual(1, lus[0]["status"]) + + def test_a_zero_written_as_text_is_not_a_failure(self): + lus = quality.read_events({"lst_event": [evenement(status="0")]}) + self.assertEqual([], quality.failures(lus)) + + def test_an_unreadable_status_is_read_as_success(self): + # Mieux vaut taire un verdict illisible que crier un faux échec. + lus = quality.read_events({"lst_event": [evenement(status="oui")]}) + self.assertEqual(0, lus[0]["status"]) + + def test_an_entry_that_is_not_a_record_is_skipped(self): + lus = quality.read_events({"lst_event": ["cassé", evenement()]}) + self.assertEqual(1, len(lus)) + + def test_no_list_at_all_reads_as_no_verdict(self): + self.assertEqual([], quality.read_events({})) + self.assertEqual([], quality.read_events({"lst_event": None})) + + def test_a_dialogue_answer_is_not_a_failed_test(self): + # Les entrées `command` à 1 sont les réponses du pilote — « veux-tu + # effacer le module manquant ». Les compter comme des échecs + # noierait les vrais sous une liste qu'on cesse de lire. + lus = quality.read_events( + { + "lst_event": [ + evenement(kind="command", name="./odoo_bin.sh db --clone"), + evenement(kind="test"), + ] + } + ) + ratés = quality.failures(lus) + self.assertEqual(["smoke_public_url"], [e["name"] for e in ratés]) + + +class TestWhichStepAVerdictBelongsTo(Base): + def test_the_database_is_read_from_the_command(self): + self.assertEqual( + "test_neutralize_upgrade_14", + quality.event_database(evenement()), + ) + + def test_a_command_without_database_yields_nothing(self): + self.assertEqual("", quality.event_database(evenement(detail=""))) + + def test_the_step_comes_from_the_database_not_the_counter(self): + # `step` compte les ÉTAPES du pilote — « 4.1.I » — et non les + # versions d'Odoo : les deux sont décalées d'un rang. Afficher + # « 4.1 » à côté de « palier » nommerait la mauvaise version. + self.assertEqual("14", quality.event_step(evenement())) + + def test_without_a_step_database_it_falls_back_to_the_counter(self): + seul = evenement(detail="", step="4.1.I - Migrate database") + self.assertEqual("4.1.I", quality.event_step(seul)) + + +class TestTheMapOfTraces(Base): + """Documenter les chemins EST la fonctionnalité.""" + + def test_both_places_are_named_even_when_one_is_empty(self): + sources = quality.log_sources( + path="rien.json", journal="rien_non_plus.log" + ) + self.assertEqual(2, len(sources)) + for _role, _chemin, existe, _quoi in sources: + self.assertFalse(existe) + + def test_the_missing_journal_is_shown_not_hidden(self): + # Le taire laisserait chercher un fichier qu'Odoo n'a jamais + # écrit, faute de `logfile=` dans config.conf. + chemins = [c for _r, c, _e, _q in quality.log_sources()] + self.assertIn(quality.JOURNAL_ODOO, chemins) + + +class TestScanningTheOdooLog(Base): + def setUp(self): + super().setUp() + import tempfile + + self.dossier = tempfile.mkdtemp() + self.addCleanup(__import__("shutil").rmtree, self.dossier) + self.journal = os.path.join(self.dossier, "odoo.log") + + def ecrire(self, texte): + with io.open(self.journal, "w", encoding="utf-8") as handle: + handle.write(texte) + + def test_a_missing_file_is_not_an_error(self): + rapport = quality.scan_log(os.path.join(self.dossier, "absent.log")) + self.assertFalse(rapport["exists"]) + self.assertEqual([], rapport["lines"]) + + def test_each_pattern_is_counted(self): + self.ecrire("INFO ok\nERROR boum\nCRITICAL pire\nTraceback (x)\n") + rapport = quality.scan_log(self.journal) + for motif in quality.MOTIFS_ERREUR: + self.assertEqual(1, rapport["counts"][motif], motif) + + def test_a_clean_log_counts_zero_and_shows_nothing(self): + self.ecrire("INFO tout va bien\nINFO encore\n") + rapport = quality.scan_log(self.journal) + self.assertEqual([], rapport["lines"]) + self.assertEqual(0, rapport["counts"]["ERROR"]) + + def test_only_the_last_lines_are_kept(self): + # Un journal de migration pèse des dizaines de mégaoctets ; ce + # qu'on cherche est ce qui a échoué en DERNIER. + self.ecrire("".join("ERROR %d\n" % i for i in range(50))) + rapport = quality.scan_log(self.journal, limite=3) + self.assertEqual(3, len(rapport["lines"])) + self.assertIn("ERROR 49", rapport["lines"][-1]) + + +class TestTheReviewChecklist(Base): + """Une liste de contrôle qu'il faut aller chercher n'est pas suivie.""" + + def test_every_step_names_a_question(self): + for question, _commande, _clef in quality.REVUE: + self.assertTrue(question.endswith("?"), question) + + def test_a_runnable_step_carries_the_database_placeholder(self): + for _q, commande, clef in quality.REVUE: + if clef: + self.assertIn("{db}", commande, commande) + + def test_the_first_step_has_nothing_to_run(self): + # « La migration est-elle allée jusqu'au bout » se lit dans le + # fichier ; aucune commande ne le rejoue. + self.assertIsNone(quality.REVUE[0][2]) + + +class TestTheThreeExtraSections(Base): + """Elles vivent dans la MÊME table que les paliers.""" + + def entetes(self, lst): + return [r["label"] for r in lst if r["kind"] == "header"] + + def test_the_sections_come_in_the_order_of_the_work(self): + lst = qtui.extra_rows([], {}) + self.assertEqual( + [ + f"── {qtui.t('Verdicts')} ──", + f"── {qtui.t('Validation')} ──", + f"── {qtui.t('Review')} ──", + ], + self.entetes(lst), + ) + + def test_no_verdict_at_all_says_so(self): + lst = qtui.extra_rows([], {}) + self.assertIn("verdict-none", [r["kind"] for r in lst]) + + def test_every_check_passing_is_stated_not_left_blank(self): + lst = qtui.extra_rows([], {"lst_event": [evenement(status=0)]}) + ok = [r for r in lst if r["kind"] == "verdict-ok"] + self.assertEqual(1, len(ok)) + self.assertIn("1", ok[0]["label"]) + + def test_a_failed_verdict_names_its_step_in_the_label(self): + # « smoke_public_url » quatre fois de suite ne dit pas lequel a + # échoué, et c'est la seule chose qu'on veut savoir. + lst = qtui.extra_rows([], {"lst_event": [evenement()]}) + ligne = [r for r in lst if r["kind"] == "verdict"][0] + self.assertIn("14", ligne["label"]) + self.assertIn("smoke_public_url", ligne["label"]) + + def test_a_failed_verdict_offers_a_way_to_replay_it(self): + lst = qtui.extra_rows([], {"lst_event": [evenement()]}) + ligne = [r for r in lst if r["kind"] == "verdict"][0] + self.assertEqual("▶", ligne["detail"]) + self.assertTrue(ligne["command"]) + + def test_the_header_counts_the_failures(self): + lst = qtui.extra_rows([], {"lst_event": [evenement(), evenement()]}) + self.assertEqual("2", self.premier_entete(lst)["detail"]) + + def premier_entete(self, lst): + return [r for r in lst if r["kind"] == "header"][0] + + def test_a_clean_run_shows_a_tick_not_a_zero(self): + lst = qtui.extra_rows([], {"lst_event": [evenement(status=0)]}) + self.assertEqual("✅", self.premier_entete(lst)["detail"]) + + def test_review_steps_target_the_last_database(self): + presents = [ + {"database": "base_upgrade_17", "exists": True}, + {"database": "base_upgrade_18", "exists": True}, + ] + lst = qtui.extra_rows(presents, {}) + commandes = [ + r["command"] for r in lst if r["kind"] == "review" and r["command"] + ] + self.assertTrue(commandes) + for commande in commandes: + self.assertIn("base_upgrade_18", commande) + self.assertNotIn("{db}", commande) + + def test_without_a_database_no_review_step_can_be_run(self): + lst = qtui.extra_rows([], {}) + for ligne in [r for r in lst if r["kind"] == "review"]: + self.assertEqual("", ligne["command"]) + + +class TestTheVerdictPanel(Base): + def panneau(self, **champs): + lst = qtui.extra_rows([], {"lst_event": [evenement(**champs)]}) + ligne = [r for r in lst if r["kind"] == "verdict"][0] + return qtui.extra_pane(ligne) + + def test_the_step_shown_is_the_odoo_version(self): + # « 4.1.I » est la première étape du quatrième bloc du pilote ; la + # migration en est alors au palier 14. Afficher le compteur sous + # le mot « palier » faisait lire une version qui n'existe pas. + texte = self.panneau() + self.assertIn(f"{qtui.t('step'):<10} 14", texte) + + def test_the_pilot_counter_is_still_there_in_brackets(self): + # Il sert à retrouver l'entrée dans le journal ; le perdre + # obligerait à compter les étapes à la main. + self.assertIn("(4.1.I - Migrate database)", self.panneau()) + + def test_what_a_status_of_one_means_is_spelled_out(self): + # « status 1 » ne dit pas si c'est un constat ou une panne. + texte = self.panneau() + self.assertIn( + qtui.t("1 means a public page failed — not merely a finding."), + texte, + ) + + def test_a_tool_we_cannot_replay_says_so(self): + texte = self.panneau(name="un_outil_inconnu") + self.assertIn(qtui.t("No known way to replay this one."), texte) + self.assertNotIn(qtui.t("press r to run it again"), texte) + + +class TestReplayingAVerdict(Base): + def test_a_known_tool_is_rebuilt_from_its_name(self): + self.assertEqual( + "script/odoo/migration/smoke_public_url.py -d base", + qtui.rerun_command(evenement(), "base"), + ) + + def test_the_recorded_command_is_not_replayed_as_is(self): + # Le `detail` porte le venv du PALIER — « .venv.erplibre/bin/python3 » + # d'alors — qui n'est plus celui du checkout courant. + commande = qtui.rerun_command(evenement(), "base") + self.assertNotIn(".venv", commande) + self.assertNotIn("--internal-required", commande) + + def test_an_unknown_tool_offers_nothing(self): + self.assertEqual( + "", qtui.rerun_command(evenement(name="autre_chose"), "base") + ) + + def test_without_a_database_there_is_nothing_to_run(self): + self.assertEqual("", qtui.rerun_command(evenement(), "")) + + +class TestRunningATestFromTheScreen(Base): + """Le test prend le terminal, puis le rend.""" + + def setUp(self): + super().setUp() + self.appels = [] + + class FauxRetour: + returncode = 0 + + def faux_run(argv, cwd=None): + self.appels.append((argv, cwd)) + return FauxRetour() + + faux = type("M", (), {"run": staticmethod(faux_run)}) + self.addCleanup(setattr, qtui, "subprocess", qtui.subprocess) + qtui.subprocess = faux + + def lancer(self, commande, wait=False): + tampon = io.StringIO() + with redirect_stdout(tampon): + resultat = qtui.run_in_terminal(commande, wait=wait) + return resultat, tampon.getvalue() + + def test_nothing_to_run_runs_nothing(self): + (code, tourné), _sortie = self.lancer("") + self.assertIsNone(code) + self.assertFalse(tourné) + self.assertEqual([], self.appels) + + def test_a_python_tool_runs_with_this_checkout_interpreter(self): + # Le lancer avec le python du système le priverait des dépendances + # d'ERPLibre : l'outil échouerait à l'import, pas sur la base. + self.lancer("script/odoo/migration/smoke_public_url.py -d base") + argv, _cwd = self.appels[0] + self.assertEqual(sys.executable, argv[0]) + + def test_it_runs_from_the_repository_root(self): + # Les chemins que la commande porte y sont relatifs ; ailleurs, + # ils sont introuvables. + self.lancer("script/odoo/migration/smoke_public_url.py -d base") + _argv, cwd = self.appels[0] + self.assertEqual(qtui.REPO_ROOT, cwd) + self.assertTrue( + os.path.isdir(os.path.join(cwd, "script", "odoo", "migration")) + ) + + def test_a_plain_command_keeps_its_own_program(self): + self.lancer("./odoo_bin.sh shell -d base") + argv, _cwd = self.appels[0] + self.assertEqual("./odoo_bin.sh", argv[0]) + + def test_the_exit_code_is_shown_not_swallowed(self): + (code, _t), sortie = self.lancer("./odoo_bin.sh shell") + self.assertEqual(0, code) + self.assertIn(qtui.t("exit code:"), sortie) + + def test_a_missing_program_is_reported_not_raised(self): + def explose(argv, cwd=None): + raise OSError("No such file or directory") + + qtui.subprocess = type("M", (), {"run": staticmethod(explose)}) + (code, tourné), sortie = self.lancer("absent.sh") + self.assertIsNone(code) + self.assertTrue(tourné) + self.assertIn("No such file", sortie) + + if __name__ == "__main__": unittest.main() diff --git a/test/test_monitoring.py b/test/test_monitoring.py index fbba336..a4ceb31 100644 --- a/test/test_monitoring.py +++ b/test/test_monitoring.py @@ -19,6 +19,7 @@ le DIRE. Une analyse muette qu'on prend pour rassurante est pire que pas d'analyse : c'est la faute que ce dépôt a déjà corrigée trois fois. """ +import io import os import sys import unittest @@ -434,5 +435,138 @@ class TestTheRepairAsksItsOwnDetector(unittest.TestCase): ) +def verdict(**champs): + """Un événement du journal de progression, forme réelle.""" + brut = { + "at": "2026-08-26 03:19:59.846453", + "step": "4.1.I - Migrate database", + "kind": "test", + "name": "smoke_public_url", + "status": 1, + "detail": ".venv.erplibre/bin/python3" + " ./script/odoo/migration/smoke_public_url.py" + " -d test_neutralize_upgrade_14 --internal-required", + } + brut.update(champs) + return brut + + +class TestTheVerdictsSection(unittest.TestCase): + """Ce que les contrôles SQL ne peuvent structurellement pas voir. + + Un test de fumée qui échoue n'écrit rien en base : rien n'est cassé, + la requête suivante répond. Aucun contrôle lisant la base ne le + retrouvera jamais — et c'était la moitié de ce qu'une migration peut + rater. + """ + + def setUp(self): + import json + import shutil + import tempfile + + self.dossier = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.dossier) + self.chemin = os.path.join(self.dossier, "progression.json") + self.json = json + + def ecrire(self, evenements): + with io.open(self.chemin, "w", encoding="utf-8") as handle: + self.json.dump({"lst_event": evenements}, handle) + return self.chemin + + def test_two_steps_of_one_migration_are_the_same_lineage(self): + # Interroger la base 18 doit montrer l'échec du palier 14 : c'est + # le seul endroit où il subsiste. + self.assertEqual( + residue.famille("test_neutralize_upgrade_14"), + residue.famille("test_neutralize_upgrade_18"), + ) + + def test_a_plain_name_is_its_own_lineage(self): + self.assertEqual("copy_chezlepro3", residue.famille("copy_chezlepro3")) + + def test_another_migration_verdicts_are_not_shown(self): + # Deux migrations partagent le fichier. Attribuer l'échec de + # l'une à l'autre enverrait chercher une panne qui n'existe pas. + chemin = self.ecrire([verdict()]) + _tous, ratés = residue.verdicts("autre_client_upgrade_18", chemin) + self.assertEqual([], ratés) + + def test_the_failure_of_an_earlier_step_is_shown(self): + chemin = self.ecrire([verdict()]) + _tous, ratés = residue.verdicts("test_neutralize_upgrade_18", chemin) + self.assertEqual(["smoke_public_url"], [e["name"] for e in ratés]) + + def test_a_step_is_named_by_its_odoo_version(self): + chemin = self.ecrire([verdict()]) + texte = "\n".join( + residue.verdicts_block("test_neutralize_upgrade_18", False, chemin) + ) + self.assertIn("14", texte) + self.assertIn("smoke_public_url", texte) + + def test_no_file_says_nothing_at_all(self): + # Devant la sauvegarde d'un client, il n'y a jamais eu de + # migration locale : annoncer l'absence d'un fichier qu'on + # n'attendait pas ne renseigne personne. + absent = os.path.join(self.dossier, "jamais_ecrit.json") + self.assertEqual([], residue.verdicts_block("base", False, absent)) + + def test_all_green_is_stated_not_left_silent(self): + chemin = self.ecrire([verdict(status=0), verdict(status=0)]) + texte = "\n".join( + residue.verdicts_block("test_neutralize_upgrade_18", False, chemin) + ) + self.assertIn("2", texte) + self.assertIn(residue.t("checks, all passed"), texte) + + def test_the_report_says_these_are_not_from_the_database(self): + # Sans cette phrase, un verdict d'il y a quatre paliers se lirait + # comme un défaut présent de la base qu'on a sous les yeux. + chemin = self.ecrire([verdict()]) + texte = "\n".join( + residue.verdicts_block("test_neutralize_upgrade_18", False, chemin) + ) + self.assertIn( + residue.t("These come from the file, not the database:"), texte + ) + + def test_a_past_verdict_does_not_become_a_finding(self): + # Le code de sortie dit « la BASE porte un défaut ». Y compter un + # verdict passé rendrait 1 pour toujours, et le pilote traiterait + # une base saine comme cassée à chaque appel. + vide = {c["key"]: 0 for c in residue.CONTROLES} + trouve, _illisibles = residue.judge(vide) + self.assertEqual([], trouve) + + def test_the_clean_report_still_carries_the_verdicts(self): + # Le cas qui compte : aucun résidu en base, et pourtant un test + # de fumée a échoué en chemin. Le rapport « rien trouvé » ne doit + # pas être le dernier mot. + chemin = self.ecrire([verdict()]) + ancien = residue.quality.DEFAULT_PROGRESSION + residue.quality.DEFAULT_PROGRESSION = chemin + self.addCleanup( + setattr, residue.quality, "DEFAULT_PROGRESSION", ancien + ) + vide = {c["key"]: 0 for c in residue.CONTROLES} + texte = residue.render( + "test_neutralize_upgrade_18", vide, colour=False + ) + self.assertIn(residue.t("None of the checks found anything."), texte) + self.assertIn("smoke_public_url", texte) + + def test_the_colour_it_asks_for_exists(self): + # `paint` retombe sur une chaîne vide suivie d'un RESET quand le + # genre est inconnu : cela n'annule rien et ne teinte rien. + chemin = self.ecrire([verdict()]) + for ligne in residue.verdicts_block( + "test_neutralize_upgrade_18", True, chemin + ): + if ligne and ligne.endswith(residue.RESET): + self.assertNotEqual(residue.RESET, ligne.strip()) + + if __name__ == "__main__": unittest.main() From 33cf04c7f9fdcedf40d79a7d1f705dda347cde84 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 27 Aug 2026 02:15:30 -0400 Subject: [PATCH 13/22] =?UTF-8?q?[REM]=20r=C3=A9sidus=20:=20retirer=20le?= =?UTF-8?q?=20contr=C3=B4le=20res=5Flang.active=20=C3=A0=20NULL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le contrôle jugeait cassée une langue dont `active` est NULL : listée nulle part, plus réactivable. Faux dans la source 18 — un domaine ('active','=',False) compile en (IS NULL OR = FALSE) (models.py:3217), l'action du menu Langues porte active_test: False, le tri passe par COALESCE(active, FALSE) et la lecture rend bool(value). Odoo écrit ce NULL : active = fields.Boolean() sans défaut (res_lang.py:64) et un res.lang.csv sans la colonne, soit un NULL par langue ajoutée au catalogue. « Zéro avant, non nul après » ne suffit donc pas. Un test refuse désormais une clé que plus rien ne définit. --- EN --- The check called a language broken when `active` is NULL: listed nowhere, no longer re-enablable. False in the 18 source — an ('active','=',False) domain compiles to (IS NULL OR = FALSE) (models.py:3217), the Languages menu action carries active_test: False, sorting goes through COALESCE(active, FALSE), reading returns bool(value). Odoo writes that NULL itself: active = fields.Boolean() with no default (res_lang.py:64) and a res.lang.csv without the column, one NULL per language added to the catalogue. So "zero before, nonzero after" is not enough. A test now refuses a key that nothing defines any more. Assisted-by: Claude Opus 5 (cherry picked from commit ee932334cfde63c1af831316549a432c3d382721) --- script/analyse/check_migration_residue.py | 35 +++++++++++------- test/test_monitoring.py | 44 +++++++++++++++++++---- 2 files changed, 61 insertions(+), 18 deletions(-) diff --git a/script/analyse/check_migration_residue.py b/script/analyse/check_migration_residue.py index d75f75c..651b84b 100755 --- a/script/analyse/check_migration_residue.py +++ b/script/analyse/check_migration_residue.py @@ -27,7 +27,6 @@ finit par être ignoré en entier. Ne restent ici que les constats qui se jugent SANS point de comparaison, parce qu'ils sont faux en eux-mêmes. Les mêmes bases, mêmes mesures : - res_lang.active à NULL 0 → 9 un booléen NULL est un bug ir_model_relation sans table 0 → 68 la table m2m est nommée, elle n'existe pas index doublés convention 17 0 → 414 Odoo 17 renomme, sans @@ -35,9 +34,30 @@ parce qu'ils sont faux en eux-mêmes. Les mêmes bases, mêmes mesures : liste de prix par défaut absente 0 → 1 `product` installé, son xmlid pas là -Zéro avant, non nul après : aucun de ces trois ne peut s'expliquer +Zéro avant, non nul après : aucun de ceux-là ne peut s'expliquer autrement que par la migration. +« Zéro avant, non nul après » ne suffit pourtant pas +---------------------------------------------------- +Un quatrième contrôle a vécu ici et n'y est plus : `res_lang.active` à +NULL, 0 avant et 9 après. Le chiffre était juste, la conclusion fausse. + +Mesuré palier par palier : 0, 1, 2, 3, 5, 8, 9 — les NULL arrivent avec +les langues que CHAQUE version ajoute au catalogue. Et c'est Odoo +lui-même qui les écrit : `active = fields.Boolean()` sans défaut +(res_lang.py:64) et un `res.lang.csv` sans colonne `active` — l'INSERT ne +porte pas la colonne, PostgreSQL y met NULL. + +Aucune conséquence, vérifiée dans la source de la 18 : un domaine +`('active','=',False)` compile en `(IS NULL OR = FALSE)` +(models.py:3217-3222), l'action du menu Langues porte `active_test: False` +(res_lang_views.xml:136), le tri passe par `COALESCE(active, FALSE)` +(models.py:5692) et la lecture rend `bool(value)` (fields.py:1515). NULL +et FALSE sont indiscernables partout. + +La leçon : la croissance mesurée doit AUSSI être inexplicable autrement. +Ici elle s'expliquait très bien. + Un quatrième a été RETIRÉ après vérification -------------------------------------------- « ir_model_relation nomme une table absente » : 0 avant, 68 après, le @@ -107,15 +127,6 @@ CONTROLES = ( "gravity": "broken", "repair": "script/todo/todo_upgrade.py (uninstall_one_by_one)", }, - { - "key": "lang_active_null", - "title": "Languages whose active flag is NULL", - "why": "A boolean that is neither true nor false: the language is" - " listed nowhere and cannot be re-enabled from the interface.", - "sql": "SELECT count(*) FROM res_lang WHERE active IS NULL", - "gravity": "broken", - "repair": None, - }, { "key": "duplicate_index", "title": "Indexes duplicated by the Odoo 17 renaming", @@ -189,7 +200,7 @@ CONTROLES = ( "sql": "SELECT count(*) FROM ir_model_data d WHERE NOT EXISTS" " (SELECT 1 FROM ir_model m WHERE m.model = d.model)", "gravity": "broken", - "repair": "script/analyse/database_cleanup.py", + "repair": "script/odoo/migration/database_cleanup.py", }, { "key": "attachment_field_gone", diff --git a/test/test_monitoring.py b/test/test_monitoring.py index a4ceb31..c7375b7 100644 --- a/test/test_monitoring.py +++ b/test/test_monitoring.py @@ -24,9 +24,8 @@ import os import sys import unittest -sys.path.insert( - 0, os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) -) +REPO = os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) +sys.path.insert(0, REPO) from script.analyse import check_migration_residue as residue # noqa: E402 from script.analyse import monitoring # noqa: E402 @@ -190,9 +189,9 @@ class TestWhatAMigrationLeftBehind(unittest.TestCase): def test_broken_is_read_before_watch(self): resultats = {c["key"]: 0 for c in residue.CONTROLES} resultats["duplicate_index"] = 400 # watch - resultats["lang_active_null"] = 1 # broken + resultats["stuck_modules"] = 1 # broken trouve, _ = residue.judge(resultats) - self.assertEqual(trouve[0][0]["key"], "lang_active_null") + self.assertEqual(trouve[0][0]["key"], "stuck_modules") def test_a_check_that_could_not_run_is_not_a_check_that_found_nothing( self, @@ -247,7 +246,7 @@ class TestWhatAMigrationLeftBehind(unittest.TestCase): def test_the_exit_code_separates_nothing_from_something(self): vide = {c["key"]: 0 for c in residue.CONTROLES} self.assertEqual(residue.judge(vide)[0], []) - plein = dict(vide, lang_active_null=3) + plein = dict(vide, stuck_modules=3) self.assertTrue(residue.judge(plein)[0]) @@ -451,6 +450,39 @@ def verdict(**champs): return brut +class TestTheControlsThemselves(unittest.TestCase): + """Un contrôle retiré ne doit pas survivre dans les tests. + + « res_lang.active à NULL » a vécu ici avec la gravité « broken », et + c'était un faux constat : Odoo écrit lui-même ce NULL, faute de défaut + sur `active = fields.Boolean()`. Deux tests le nommaient encore après + son retrait — ils passaient, en construisant un dictionnaire avec une + clé inconnue de personne. + """ + + def test_every_key_a_test_names_still_exists(self): + import re + + connues = {c["key"] for c in residue.CONTROLES} + source = io.open(__file__, encoding="utf-8").read() + nommees = set(re.findall(r'resultats\["([a-z_]+)"\]', source)) + nommees |= set(re.findall(r"dict\(vide, ([a-z_]+)=", source)) + self.assertTrue(nommees) + self.assertEqual(set(), nommees - connues) + + def test_no_control_promises_a_repair_tool_that_is_missing(self): + import os + + for controle in residue.CONTROLES: + morceaux = (controle["repair"] or "").split() + chemin = morceaux[0] if morceaux else "" + if chemin.startswith("script/"): + self.assertTrue( + os.path.isfile(os.path.join(REPO, chemin)), + f"{controle['key']} → {chemin}", + ) + + class TestTheVerdictsSection(unittest.TestCase): """Ce que les contrôles SQL ne peuvent structurellement pas voir. From 599401b6c489735602e02b4ba12ed0bb43dfd93a Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 27 Aug 2026 02:15:45 -0400 Subject: [PATCH 14/22] =?UTF-8?q?[ADD]=20manifestes=20:=20d=C3=A9tecter=20?= =?UTF-8?q?un=20d=C3=A9p=C3=B4t=20absent=20d'un=20palier=20de=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une migration 12 → 18 traverse six paliers, et le checkout bascule de manifeste à chaque fois. Un dépôt d'addons absent du manifeste du 17 n'existe pas sur disque pendant l'étape 17 : Odoo déclare ses modules introuvables, le pilote propose de les effacer, on répond oui, et la fonctionnalité part sans qu'aucun échec ne soit signalé. Seul le trou compte — présent avant et après, absent au milieu — et la branche en amont le confirme : sur 35 trous, 19 sont de vraies omissions, quinze déclarées en 16 et 18 mais pas en 17 ; les 35 auraient fait 46 % de bruit. development.git est rétabli en 13 et en 15. --- EN --- A 12 → 18 migration crosses six steps, and the checkout switches manifest each time. An addons repository missing from the 17 manifest does not exist on disk during step 17: Odoo reports its modules as missing, the driver offers to delete them, you answer yes, and the feature is gone without a single failure being reported. Only the hole counts — present before and after, absent in between — and the upstream branch confirms it: of 35 holes, 19 are real omissions, fifteen declared in 16 and 18 but not in 17; all 35 would have been 46% noise. development.git is restored in 13 and in 15. Assisted-by: Claude Opus 5 (cherry picked from commit d7613ea930d45153e9fb7c08460690a95e3dca0e) --- manifest/git_manifest_odoo13.0.xml | 8 + manifest/git_manifest_odoo15.0.xml | 8 + script/analyse/check_manifest_gaps.py | 334 +++++++++++++++ script/analyse/check_migration_quality.py | 5 + script/analyse/check_migration_quality_tui.py | 4 +- script/todo/todo_i18n.py | 49 +++ test/test_check_manifest_gaps.py | 391 ++++++++++++++++++ test/test_check_migration_quality.py | 48 ++- 8 files changed, 840 insertions(+), 7 deletions(-) create mode 100755 script/analyse/check_manifest_gaps.py create mode 100644 test/test_check_manifest_gaps.py diff --git a/manifest/git_manifest_odoo13.0.xml b/manifest/git_manifest_odoo13.0.xml index b7c0b2f..be38851 100644 --- a/manifest/git_manifest_odoo13.0.xml +++ b/manifest/git_manifest_odoo13.0.xml @@ -2,6 +2,7 @@ + @@ -240,6 +241,13 @@ revision="13.0" groups="addons,odoo13.0" /> + + @@ -244,6 +245,13 @@ revision="15.0" groups="addons,odoo15.0" /> + str: + return key + + +REPO_ROOT = os.path.normpath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..") +) +DOSSIER = os.path.join(REPO_ROOT, "manifest") +MOTIF_FICHIER = re.compile(r"git_manifest_odoo(\d+\.\d+)\.xml$") +MOTIF_BRANCHE = re.compile(r"refs/heads/(\d+\.\d+)$") +DELAI_RESEAU = 60 +PARALLELE = 8 + +COULEURS = { + "broken": "\033[31m", + "watch": "\033[33m", + "ok": "\033[32m", + "step": "\033[36m", + "dim": "\033[90m", +} +RESET = "\033[0m" + + +def paint(texte, genre, colour): + """Teinter, ou rendre le texte tel quel quand la couleur est coupée.""" + if not colour: + return texte + return f"{COULEURS.get(genre, '')}{texte}{RESET}" + + +def rang(version): + """« 9.0 » avant « 10.0 » : trier des versions comme du texte ment.""" + return tuple(int(x) for x in version.split(".")) + + +def versions(dossier=DOSSIER): + """Les versions qui ont un manifeste, lues sur le disque. + + Les DÉDUIRE plutôt que les écrire : une version ajoutée au dépôt doit + entrer dans l'analyse sans qu'on pense à toucher ici. Les manifestes + `_dev` sont écartés — ils complètent le principal, et un dépôt qui + n'est que dans le `_dev` n'est pas censé être partout. + """ + trouves = {} + for chemin in glob.glob(os.path.join(dossier, "git_manifest_odoo*.xml")): + found = MOTIF_FICHIER.search(os.path.basename(chemin)) + if found: + trouves[found.group(1)] = chemin + return [(v, trouves[v]) for v in sorted(trouves, key=rang)] + + +def declarations(dossier=DOSSIER): + """{dépôt: {version: url}} — qui est déclaré où, et d'où il vient. + + L'URL se recompose du `fetch` de son remote et du nom du projet ; + c'est elle qui permettra d'aller demander à l'amont s'il a la branche. + """ + par_depot = {} + for version, chemin in versions(dossier): + try: + racine = ET.parse(chemin).getroot() + except ET.ParseError: + continue + fetch = { + r.get("name"): r.get("fetch") or "" for r in racine.iter("remote") + } + for projet in racine.iter("project"): + nom = projet.get("name") + if not nom: + continue + base = fetch.get(projet.get("remote"), "") + url = base.rstrip("/") + "/" + nom if base else "" + par_depot.setdefault(nom, {})[version] = url + return par_depot + + +def gaps(par_depot, toutes_versions=None): + """[(dépôt, présentes, manquantes, url)] — les trous, et eux seuls. + + Un trou est un manque ENTRE deux présences. Ce qui manque avant la + première ou après la dernière n'en est pas un : un dépôt né en 16 n'a + rien à faire en 12, et l'exiger crierait sur presque tout le fichier. + """ + ordre = toutes_versions or [v for v, _ in versions()] + lst = [] + for nom, par_version in sorted(par_depot.items()): + presentes = sorted(par_version, key=rang) + debut, fin = ordre.index(presentes[0]), ordre.index(presentes[-1]) + manquantes = [ + v for v in ordre[debut : fin + 1] if v not in par_version + ] + if manquantes: + url = par_version[presentes[-1]] or par_version[presentes[0]] + lst.append((nom, presentes, manquantes, url)) + return lst + + +def branches(url, delai=DELAI_RESEAU): + """Les branches de version publiées en amont, ou None si injoignable. + + None et l'ensemble vide ne disent PAS la même chose : « je n'ai pas + pu demander » n'est pas « il n'y en a pas », et confondre les deux + transformerait une coupure réseau en absolution générale. + """ + if not url: + return None + try: + done = subprocess.run( + ["git", "ls-remote", "--heads", url], + capture_output=True, + text=True, + timeout=delai, + ) + except (OSError, subprocess.SubprocessError): + return None + if done.returncode: + return None + trouvees = set() + for ligne in done.stdout.splitlines(): + found = MOTIF_BRANCHE.search(ligne.strip()) + if found: + trouvees.add(found.group(1)) + return trouvees + + +def judge(lst_gaps, verifier=False, lecteur=branches, parallele=PARALLELE): + """[(dépôt, manquantes, omissions, etat)] — trancher, ou avouer. + + `etat` vaut "omission" quand l'amont a la branche, "legitime" quand + il ne l'a pas, "inconnu" quand on n'a pas demandé ou pas pu. + """ + if not verifier: + return [(n, m, [], "inconnu") for n, _p, m, _u in lst_gaps] + + with ThreadPoolExecutor(max_workers=parallele) as pool: + amonts = list(pool.map(lambda g: lecteur(g[3]), lst_gaps)) + + resultat = [] + for (nom, _presentes, manquantes, _url), amont in zip(lst_gaps, amonts): + if amont is None: + resultat.append((nom, manquantes, [], "inconnu")) + continue + omissions = [v for v in manquantes if v in amont] + resultat.append( + ( + nom, + manquantes, + omissions, + "omission" if omissions else "legitime", + ) + ) + return resultat + + +def render(juges, verifie, colour=True): + """Le rapport lisible. Les omissions d'abord, le reste en sourdine.""" + lignes = [f"🗺 {t('Repositories missing from a step')}", ""] + omissions = [j for j in juges if j[3] == "omission"] + inconnus = [j for j in juges if j[3] == "inconnu"] + legitimes = [j for j in juges if j[3] == "legitime"] + + if not juges: + lignes.append( + paint( + f"✅ {t('Every repository is declared without a hole.')}", + "ok", + colour, + ) + ) + return "\n".join(lignes) + + for nom, _manq, omis, _etat in omissions: + lignes.append(paint(f"❌ {nom}", "broken", colour)) + lignes.append( + paint( + f" {t('missing from')} {', '.join(omis)}" + f" — {t('upstream has the branch')}", + "dim", + colour, + ) + ) + + if omissions: + lignes.append("") + lignes.append( + paint( + f" {t('Add the entry to')}" + f" manifest/git_manifest_odoo.xml", + "dim", + colour, + ) + ) + + if inconnus: + lignes.append("") + mot = ( + t("not asked: run with --upstream") + if not verifie + else t("upstream unreachable") + ) + lignes.append( + paint( + f"❔ {len(inconnus)} {t('holes not settled')} — {mot}", + "watch", + colour, + ) + ) + for nom, manq, _o, _e in inconnus: + lignes.append( + paint(f" {nom} — {', '.join(manq)}", "dim", colour) + ) + + if legitimes: + lignes.append("") + lignes.append( + paint( + f"✅ {len(legitimes)} {t('holes are legitimate: no such branch upstream')}", + "ok", + colour, + ) + ) + return "\n".join(lignes) + + +def main(argv=None): + import argparse + import json + + parser = argparse.ArgumentParser( + description=t("Repositories declared for some steps but not others."), + ) + parser.add_argument( + "--upstream", + action="store_true", + help=t("ask each remote whether the branch exists (network)"), + ) + parser.add_argument("--manifest-dir", default=DOSSIER) + parser.add_argument("--json", action="store_true") + parser.add_argument("--no-color", action="store_true") + args = parser.parse_args(argv) + + if not os.path.isdir(args.manifest_dir): + print(f"❌ {args.manifest_dir}", file=sys.stderr) + return 2 + + lst = gaps( + declarations(args.manifest_dir), + [v for v, _ in versions(args.manifest_dir)], + ) + juges = judge(lst, verifier=args.upstream) + + if args.json: + print( + json.dumps( + { + "checked_upstream": args.upstream, + "gaps": [ + { + "repository": n, + "missing": m, + "omissions": o, + "state": e, + } + for n, m, o, e in juges + ], + }, + indent=2, + ensure_ascii=False, + ) + ) + else: + colour = sys.stdout.isatty() and not args.no_color + print(render(juges, args.upstream, colour)) + return 1 if any(j[3] == "omission" for j in juges) else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/analyse/check_migration_quality.py b/script/analyse/check_migration_quality.py index d9c4c17..69e9850 100755 --- a/script/analyse/check_migration_quality.py +++ b/script/analyse/check_migration_quality.py @@ -248,6 +248,11 @@ REVUE = ( "script/odoo/migration/database_cleanup.py -d {db}", "cleanup", ), + ( + "Will every step find its addons?", + "script/analyse/check_manifest_gaps.py --upstream", + "manifest", + ), ) diff --git a/script/analyse/check_migration_quality_tui.py b/script/analyse/check_migration_quality_tui.py index c56a1da..d0b8d31 100644 --- a/script/analyse/check_migration_quality_tui.py +++ b/script/analyse/check_migration_quality_tui.py @@ -204,7 +204,9 @@ def extra_rows(presents, dct=None): "detail": "▶" if clef else "", "question": question, "command": ( - commande.format(db=cible) if cible and clef else "" + commande.format(db=cible) + if clef and (cible or "{db}" not in commande) + else "" ), } ) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 43e644a..980588a 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -6978,6 +6978,55 @@ TRANSLATIONS = { "en": "Migration quality, step by step", }, # --- Écran de qualité : Verdicts, Validation, Revue --- + # --- Trous dans les manifestes Google Repo --- + "Will every step find its addons?": { + "fr": "Chaque palier trouvera-t-il ses addons ?", + "en": "Will every step find its addons?", + }, + "Repositories missing from a step": { + "fr": "Dépôts absents d'un palier", + "en": "Repositories missing from a step", + }, + "Every repository is declared without a hole.": { + "fr": "Chaque dépôt est déclaré sans trou.", + "en": "Every repository is declared without a hole.", + }, + "missing from": { + "fr": "absent de", + "en": "missing from", + }, + "upstream has the branch": { + "fr": "la branche existe en amont", + "en": "upstream has the branch", + }, + "Add the entry to": { + "fr": "Ajouter l'entrée dans", + "en": "Add the entry to", + }, + "holes not settled": { + "fr": "trous non départagés", + "en": "holes not settled", + }, + "not asked: run with --upstream": { + "fr": "pas demandé : relancer avec --upstream", + "en": "not asked: run with --upstream", + }, + "upstream unreachable": { + "fr": "amont injoignable", + "en": "upstream unreachable", + }, + "holes are legitimate: no such branch upstream": { + "fr": "trous légitimes : pas de telle branche en amont", + "en": "holes are legitimate: no such branch upstream", + }, + "Repositories declared for some steps but not others.": { + "fr": "Dépôts déclarés pour certains paliers et pas d'autres.", + "en": "Repositories declared for some steps but not others.", + }, + "ask each remote whether the branch exists (network)": { + "fr": "demander à chaque amont si la branche existe (réseau)", + "en": "ask each remote whether the branch exists (network)", + }, "Verdicts the migration recorded": { "fr": "Verdicts enregistrés par la migration", "en": "Verdicts the migration recorded", diff --git a/test/test_check_manifest_gaps.py b/test/test_check_manifest_gaps.py new file mode 100644 index 0000000..6dd3d94 --- /dev/null +++ b/test/test_check_manifest_gaps.py @@ -0,0 +1,391 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Le trou dans un manifeste, et ce qui le distingue d'une absence normale. + +Beaucoup de dépôts n'ont pas de branche pour toutes les versions d'Odoo. +Les signaler tous ferait 46 % de bruit — mesuré : 35 trous, 19 vraies +omissions. L'outil ne vaut que par ce tri, et c'est donc lui qu'on teste. + +Aucun test ne touche au réseau : `judge` reçoit son lecteur d'amont, et +les tests lui en donnent un qui répond de mémoire. Un test qui appelle +GitHub échoue dans un train, et l'on finit par ne plus le lancer. +""" + +import io +import os +import sys +import tempfile +import unittest +from contextlib import redirect_stdout + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, REPO) + +from script.analyse import check_manifest_gaps as gaps # noqa: E402 + +MANIFESTE = """ + + + +%s + +""" + +PROJET = """ """ + + +def projet(nom, version, remote="OCA"): + court = nom.replace(".git", "") + return PROJET % (nom, version, court, remote, version, version) + + +class Base(unittest.TestCase): + def setUp(self): + self.dossier = tempfile.mkdtemp() + self.addCleanup(__import__("shutil").rmtree, self.dossier) + + def ecrire(self, version, noms, remote="OCA"): + corps = "\n".join(projet(n, version, remote) for n in noms) + chemin = os.path.join( + self.dossier, "git_manifest_odoo%s.xml" % version + ) + with io.open(chemin, "w", encoding="utf-8") as handle: + handle.write(MANIFESTE % corps) + return chemin + + def trous(self): + ordre = [v for v, _ in gaps.versions(self.dossier)] + return gaps.gaps(gaps.declarations(self.dossier), ordre) + + +class TestReadingTheManifests(Base): + def test_the_versions_come_from_the_files_not_a_list(self): + # Une version ajoutée au dépôt doit entrer dans l'analyse sans + # qu'on pense à toucher le code. + for v in ("12.0", "14.0", "18.0"): + self.ecrire(v, ["a.git"]) + self.assertEqual( + ["12.0", "14.0", "18.0"], + [v for v, _ in gaps.versions(self.dossier)], + ) + + def test_ten_comes_after_nine_not_before(self): + # Trier des versions comme du texte met « 10.0 » avant « 9.0 ». + self.assertLess(gaps.rang("9.0"), gaps.rang("10.0")) + + def test_the_dev_manifests_are_left_out(self): + # Ils COMPLÈTENT le principal : un dépôt qui n'est que là n'a + # aucune raison d'être partout, et le compter créerait des trous + # imaginaires dans toutes les versions. + self.ecrire("14.0", ["a.git"]) + with io.open( + os.path.join(self.dossier, "git_manifest_odoo14.0_dev.xml"), + "w", + encoding="utf-8", + ) as handle: + handle.write(MANIFESTE % projet("b.git", "14.0")) + self.assertEqual(["14.0"], [v for v, _ in gaps.versions(self.dossier)]) + + def test_the_url_is_rebuilt_from_its_remote(self): + self.ecrire("14.0", ["donation.git"]) + dct = gaps.declarations(self.dossier) + self.assertEqual( + "https://github.com/OCA/donation.git", + dct["donation.git"]["14.0"], + ) + + def test_an_unparsable_manifest_does_not_stop_the_others(self): + self.ecrire("14.0", ["a.git"]) + with io.open( + os.path.join(self.dossier, "git_manifest_odoo16.0.xml"), + "w", + encoding="utf-8", + ) as handle: + handle.write(" Date: Thu, 27 Aug 2026 02:36:58 -0400 Subject: [PATCH 15/22] =?UTF-8?q?[ADD]=20migration=20:=20d=C3=A9tecter=20l?= =?UTF-8?q?e=20type=20de=20vue=20tree=20rest=C3=A9=20dans=20les=20sources?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Odoo 18 a supprimé le type de vue tree, sans conversion : un module resté dessus ne s'installe pas, ou casse au clic (« View types not defined tree found in act_window »). Les autres outils de la revue lisent la BASE, où un module jamais installé ne laisse rien. Le mot reste un identifiant valide : sur 465 occurrences, 80 cassent — le noyau 18 garde account.view_invoice_tree en . D'où lxml et ast, jamais de regex : seule la position du littéral décide. Trois portes filtrent avant tout motif : version, module installable, fichier chargé. Balayage : 80 constats sur 2654 modules, 26 touchés. --- EN --- Odoo 18 removed the tree view type with no shim: a module still on it fails to install, or breaks on click ("View types not defined tree found in act_window"). The other review tools read the DATABASE, where a module that never installed leaves nothing. The word is still a valid identifier: of 465 occurrences, 80 break — the 18 core keeps account.view_invoice_tree in . Hence lxml and ast, never regex: only the literal's position decides. Three gates filter before any pattern: version, installable module, loaded file. Sweep: 80 findings over 2654 modules, 26 affected. Assisted-by: Claude Opus 5 (cherry picked from commit 38a91039d111b3fa6de32edf315c5caae8553c11) --- script/analyse/check_migration_quality.py | 5 + script/analyse/check_view_type_tree.py | 525 ++++++++++++++++++++++ script/todo/todo_i18n.py | 33 ++ test/test_check_migration_quality.py | 22 +- test/test_check_view_type_tree.py | 491 ++++++++++++++++++++ 5 files changed, 1067 insertions(+), 9 deletions(-) create mode 100755 script/analyse/check_view_type_tree.py create mode 100644 test/test_check_view_type_tree.py diff --git a/script/analyse/check_migration_quality.py b/script/analyse/check_migration_quality.py index 69e9850..b2de944 100755 --- a/script/analyse/check_migration_quality.py +++ b/script/analyse/check_migration_quality.py @@ -253,6 +253,11 @@ REVUE = ( "script/analyse/check_manifest_gaps.py --upstream", "manifest", ), + ( + "Is any module still on a pre-18 view type?", + "script/analyse/check_view_type_tree.py", + "viewtype", + ), ) diff --git a/script/analyse/check_view_type_tree.py b/script/analyse/check_view_type_tree.py new file mode 100755 index 0000000..51f3db9 --- /dev/null +++ b/script/analyse/check_view_type_tree.py @@ -0,0 +1,525 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Le type de vue « tree » qui survit dans un module, et casse à l'usage. + +Odoo 18 a supprimé le type `tree`. La sélection de `ir.ui.view.type` est +`list, form, graph, pivot, calendar, kanban, search, qweb` +(ir_ui_view.py:153), et le noyau ne porte AUCUNE conversion de +compatibilité — vérifié par balayage : la seule occurrence hors `etree` +est `odoo/tests/form.py:261`, sans rapport. + +Pourquoi cet outil-ci ne lit pas la base +---------------------------------------- +C'est l'angle mort de tous les autres. Un module resté sur `` ne +s'installe pas : il n'y a donc RIEN à lire en base, et un rapport tiré de +la base dit « tout va bien » avec assurance. Le défaut est sur le DISQUE, +et il revient à chaque resynchronisation de Google Repo. + +Le piège, et pourquoi ni grep ni regex +-------------------------------------- +« tree » est d'abord un morceau d'IDENTIFIANT. Sur 465 occurrences +mesurées dans `odoo18.0/addons`, une soixantaine cassent ; le reste est +un id de ``, un ``, un `env.ref()`, un nom de +variable. Et ce n'est pas de la négligence : le noyau 18 a gardé ses +propres identifiants historiques en ne renommant que les balises — +`account.view_invoice_tree` existe toujours, son arch en ``. + +La démonstration tient en deux lignes du même dépôt : + + openeducat_admission/models/admission.py:420 + (tree_view and tree_view.id or False, 'list') ← sain + openeducat_fees/models/student.py:142 + (tree_view and tree_view.id or False, 'tree') ← cassé + +Même nom de variable, même forme. Seul le littéral en POSITION de type +décide. D'où la règle : lxml et `ast`, jamais la ligne brute. Le bénéfice +est double — les commentaires disparaissent d'eux-mêmes, puisque ni l'un +ni l'autre ne construit de nœud pour eux. + +Trois portes avant tout motif +----------------------------- +Ce sont elles qui font le tri, pas les motifs. + +1. VERSION — rien à faire sous la 18 : `tree` y est valide. +2. MODULE — un fichier ne compte que sous un `__manifest__.py`, lu par + `literal_eval`. `installable: False` disqualifie le module entier. +3. CHARGEMENT — un XML doit figurer dans `data` ou `demo` ; un Python + doit être ATTEIGNABLE depuis `__init__.py`. Un fichier que personne ne + charge ne casse rien, et `literal_eval` fait disparaître d'elle-même + toute entrée commentée du manifeste. + +Ce que la balise en sous-vue fait vraiment +------------------------------------------ +Un `` sous un x2many n'est pas ignoré poliment : `ir_ui_view.py` +ne le reconnaît pas, donc ne le RETIRE pas, et ses `` repartent +dans la validation avec le modèle du formulaire PARENT. Le message parle +alors d'un champ inexistant et ne prononce jamais le mot « tree ». +""" + +from __future__ import annotations + +import ast +import io +import os +import sys +import warnings + +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 + + +REPO_ROOT = os.path.normpath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..") +) +FICHIER_VERSION = ".odoo-version" +PREMIERE_VERSION_SANS_TREE = (18, 0) +MANIFESTE = "__manifest__.py" + +COULEURS = { + "broken": "\033[31m", + "watch": "\033[33m", + "ok": "\033[32m", + "step": "\033[36m", + "dim": "\033[90m", +} +RESET = "\033[0m" + + +def paint(texte, genre, colour): + """Teinter, ou rendre le texte tel quel quand la couleur est coupée.""" + if not colour: + return texte + return f"{COULEURS.get(genre, '')}{texte}{RESET}" + + +def version_active(racine=REPO_ROOT): + """(majeur, mineur) du checkout, ou None si illisible.""" + try: + with io.open( + os.path.join(racine, FICHIER_VERSION), encoding="utf-8" + ) as handle: + brut = handle.read().strip() + except OSError: + return None + morceaux = brut.split(".") + try: + return (int(morceaux[0]), int(morceaux[1])) + except (IndexError, ValueError): + return None + + +def concerne(version): + """La 18 et au-delà. Sous elle, `tree` est valide — au pire déprécié.""" + return bool(version) and version >= PREMIERE_VERSION_SANS_TREE + + +def lire_manifeste(chemin): + """Le dict du manifeste, par `literal_eval` — jamais par import. + + Un `__manifest__.py` est une expression, pas un programme : l'évaluer + exécuterait du code d'un dépôt tiers pour lire quatre clés. Et + `literal_eval` fait disparaître d'elle-même toute entrée commentée. + """ + try: + with io.open(chemin, encoding="utf-8") as handle: + valeur = ast.literal_eval(handle.read()) + except (OSError, ValueError, SyntaxError, MemoryError, RecursionError): + return None + return valeur if isinstance(valeur, dict) else None + + +def modules(racine): + """[(chemin du module, manifeste)] — ceux qu'Odoo installerait. + + `installable: False` disqualifie : Odoo ne le liste même pas, il ne + peut casser ni à l'installation ni à l'exécution. + """ + lst = [] + for dossier, sous, fichiers in os.walk(racine): + if MANIFESTE not in fichiers: + continue + sous[:] = [] # un module ne contient pas un autre module + manifeste = lire_manifeste(os.path.join(dossier, MANIFESTE)) + if manifeste is None: + continue + if manifeste.get("installable", True) is False: + continue + lst.append((dossier, manifeste)) + return sorted(lst) + + +def xml_charges(module, manifeste): + """Les XML que le manifeste demande de charger, et qui existent.""" + lst, absents = [], [] + for clef in ("data", "demo"): + for relatif in manifeste.get(clef) or []: + if not isinstance(relatif, str) or not relatif.endswith(".xml"): + continue + chemin = os.path.join(module, relatif) + (lst if os.path.isfile(chemin) else absents).append(chemin) + return lst, absents + + +def py_atteignables(module): + """Les .py qu'un `import` du module finit par charger. + + On SUIT les `from . import x`, plutôt que de ramasser tout le dossier : + un fichier que personne n'importe ne s'exécute jamais, et le signaler + ferait crier sur des brouillons laissés là. + """ + vus, atteints = set(), [] + pile = [module] + while pile: + paquet = pile.pop() + init = os.path.join(paquet, "__init__.py") + if init in vus or not os.path.isfile(init): + continue + vus.add(init) + atteints.append(init) + try: + with io.open(init, encoding="utf-8") as handle: + arbre = ast.parse(handle.read()) + except (OSError, SyntaxError, ValueError): + continue + for noeud in ast.walk(arbre): + noms = [] + if isinstance(noeud, ast.ImportFrom) and noeud.level: + if noeud.module: + noms.append(noeud.module) + noms += [a.name for a in noeud.names] + elif isinstance(noeud, ast.Import): + noms = [a.name.split(".")[0] for a in noeud.names] + for nom in noms: + relatif = nom.replace(".", os.sep) + fichier = os.path.join(paquet, relatif + ".py") + dossier = os.path.join(paquet, relatif) + if os.path.isfile(fichier) and fichier not in vus: + vus.add(fichier) + atteints.append(fichier) + elif os.path.isdir(dossier): + pile.append(dossier) + return [c for c in atteints if os.sep + "tests" + os.sep not in c] + + +def _modes(texte): + """« form, tree » → ['form', 'tree'] — le découpage est obligatoire. + + Un simple `in` attraperait aussi un futur « treemap », et raterait + l'espace après la virgule que les vues réelles écrivent. + """ + return [x.strip() for x in (texte or "").split(",")] + + +def ligne_de_la_balise(chemin, sourceline, balise="tree"): + """lxml rend la ligne où FINIT la balise ouvrante ; on remonte. + + Mesuré : sur une balise étalée sur six lignes, `sourceline` disait 66 + quand `. + if not isinstance(el.tag, str): + continue + if el.tag == "tree": + lst.append( + ( + ligne_de_la_balise(chemin, el.sourceline), + "xml_balise", + "", + "", + ) + ) + continue + expr = el.get("expr") + if expr: + segments = expr.replace("//", "/").split("/") + for segment in segments: + if segment.split("[")[0] == "tree": + lst.append((el.sourceline, "xml_xpath", expr[:70], "list")) + break + if el.tag != "field": + continue + nom = el.get("name") + texte = (el.text or "").strip() + if nom == "view_mode" and "tree" in _modes(texte): + lst.append( + ( + el.sourceline, + "xml_view_mode", + texte[:60], + texte.replace("tree", "list"), + ) + ) + elif nom == "type" and texte == "tree": + lst.append((el.sourceline, "xml_type", texte, "list")) + return lst + + +def _est_tuple_de_vue(noeud): + """(id, 'tree') — un couple dont le SECOND membre est le type. + + Le premier membre est un id, False, un Name ou une expression : jamais + une chaîne. Sans cette condition, la règle crierait sur les couples de + compatibilité ('list', 'tree'), qui sont du code défensif correct. + """ + if not isinstance(noeud, (ast.Tuple, ast.List)) or len(noeud.elts) != 2: + return False + premier, second = noeud.elts + if not isinstance(second, ast.Constant) or second.value != "tree": + return False + return not ( + isinstance(premier, ast.Constant) and isinstance(premier.value, str) + ) + + +def constats_py(chemin): + """[(ligne, motif, extrait, correction)] pour UN fichier python.""" + try: + with io.open(chemin, encoding="utf-8") as handle: + source = handle.read() + with warnings.catch_warnings(): + warnings.simplefilter("ignore", SyntaxWarning) + arbre = ast.parse(source) + except (OSError, SyntaxError, ValueError): + return None + lst = [] + for noeud in ast.walk(arbre): + if _est_tuple_de_vue(noeud): + lst.append( + (noeud.lineno, "py_views", "(…, 'tree')", "(…, 'list')") + ) + continue + if not isinstance(noeud, ast.Dict): + continue + paires = { + clef.value: valeur + for clef, valeur in zip(noeud.keys, noeud.values) + if isinstance(clef, ast.Constant) and isinstance(clef.value, str) + } + est_action = ( + isinstance(paires.get("type"), ast.Constant) + and paires["type"].value == "ir.actions.act_window" + ) + for clef in ("view_mode", "view_type"): + valeur = paires.get(clef) + if not isinstance(valeur, ast.Constant): + continue + if not isinstance(valeur.value, str): + continue + if "tree" not in _modes(valeur.value): + continue + # `view_type` sur un dict d'act_window est un vestige d'Odoo 12 + # que la 18 ignore sans bruit. Ailleurs — un `code.generator. + # view`, dont la sélection ne propose que `list` — il lève. + if clef == "view_type" and est_action: + continue + lst.append( + ( + valeur.lineno, + "py_" + clef, + valeur.value[:60], + valeur.value.replace("tree", "list"), + ) + ) + return lst + + +def inspect(racine, sous_dossier="addons"): + """{'findings': […], 'unreadable': […], 'missing': […]} pour un checkout.""" + version = version_active(racine) + rapport = { + "version": version, + "scanned": 0, + "findings": [], + "unreadable": [], + "missing": [], + } + if not concerne(version): + return rapport + base = os.path.join(racine, "odoo%d.%d" % version, sous_dossier) + if not os.path.isdir(base): + return rapport + for module, manifeste in modules(base): + rapport["scanned"] += 1 + court = os.path.basename(module) + charges, absents = xml_charges(module, manifeste) + for chemin in absents: + rapport["missing"].append((court, os.path.relpath(chemin, racine))) + for chemin in charges: + lst = constats_xml(chemin) + if lst is None: + rapport["unreadable"].append( + (court, os.path.relpath(chemin, racine)) + ) + continue + for ligne, motif, extrait, correction in lst: + rapport["findings"].append( + ( + court, + os.path.relpath(chemin, racine), + ligne, + motif, + extrait, + correction, + ) + ) + for chemin in py_atteignables(module): + lst = constats_py(chemin) + if lst is None: + rapport["unreadable"].append( + (court, os.path.relpath(chemin, racine)) + ) + continue + for ligne, motif, extrait, correction in lst: + rapport["findings"].append( + ( + court, + os.path.relpath(chemin, racine), + ligne, + motif, + extrait, + correction, + ) + ) + return rapport + + +def render(rapport, colour=True): + """Le rapport lisible, groupé par module.""" + lignes = [f"🌲 {t('View type tree, removed in Odoo 18')}", ""] + if not concerne(rapport["version"]): + lignes.append( + paint( + f" {t('nothing to do below 18.0 — tree is valid there')}", + "dim", + colour, + ) + ) + return "\n".join(lignes) + + trouves = rapport["findings"] + if not trouves: + lignes.append( + paint( + f"✅ {t('No module carries it.')}" + f" ({rapport['scanned']} {t('modules read')})", + "ok", + colour, + ) + ) + par_module = {} + for module, chemin, ligne, motif, extrait, correction in trouves: + par_module.setdefault(module, []).append( + (chemin, ligne, motif, extrait, correction) + ) + for module in sorted(par_module): + lst = par_module[module] + lignes.append( + paint(f"❌ {str(len(lst)).rjust(4)} {module}", "broken", colour) + ) + for chemin, ligne, motif, extrait, correction in sorted(lst): + lignes.append(paint(f" {chemin}:{ligne}", "dim", colour)) + lignes.append(f" {extrait} → {correction}") + lignes.append("") + + # Deux constats voisins, jamais mélangés aux « tree » : ils ont une + # autre cause et une autre réparation. + for clef, titre, genre in ( + ("missing", "listed in the manifest, absent from disk", "broken"), + ("unreadable", "listed in the manifest, unparsable", "watch"), + ): + if rapport[clef]: + lignes.append(paint(f"{t(titre)} :", genre, colour)) + for module, chemin in sorted(rapport[clef]): + lignes.append(paint(f" {chemin}", "dim", colour)) + lignes.append("") + return "\n".join(lignes).rstrip() + + +def main(argv=None): + import argparse + import json + + parser = argparse.ArgumentParser( + description=t("Modules still carrying the removed tree view type."), + ) + parser.add_argument("--root", default=REPO_ROOT) + parser.add_argument("--json", action="store_true") + parser.add_argument("--no-color", action="store_true") + args = parser.parse_args(argv) + + if not os.path.isdir(args.root): + print(f"❌ {args.root}", file=sys.stderr) + return 2 + + rapport = inspect(args.root) + if args.json: + print( + json.dumps( + { + "odoo": ( + "%d.%d" % rapport["version"] + if rapport["version"] + else None + ), + "modules_read": rapport["scanned"], + "findings": [ + { + "module": m, + "file": f, + "line": ligne, + "pattern": motif, + "found": extrait, + "replace_with": corr, + } + for m, f, ligne, motif, extrait, corr in rapport[ + "findings" + ] + ], + "missing_files": [f for _m, f in rapport["missing"]], + "unparsable_files": [f for _m, f in rapport["unreadable"]], + }, + indent=2, + ensure_ascii=False, + ) + ) + else: + colour = sys.stdout.isatty() and not args.no_color + print(render(rapport, colour)) + return 1 if rapport["findings"] or rapport["missing"] else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 980588a..2bc1f0c 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -6979,6 +6979,39 @@ TRANSLATIONS = { }, # --- Écran de qualité : Verdicts, Validation, Revue --- # --- Trous dans les manifestes Google Repo --- + # --- Type de vue « tree » resté dans un module --- + "Is any module still on a pre-18 view type?": { + "fr": "Un module est-il resté sur un type de vue d'avant 18 ?", + "en": "Is any module still on a pre-18 view type?", + }, + "View type tree, removed in Odoo 18": { + "fr": "Type de vue tree, supprimé dans Odoo 18", + "en": "View type tree, removed in Odoo 18", + }, + "nothing to do below 18.0 — tree is valid there": { + "fr": "rien à faire sous la 18.0 — tree y est valide", + "en": "nothing to do below 18.0 — tree is valid there", + }, + "No module carries it.": { + "fr": "Aucun module ne le porte.", + "en": "No module carries it.", + }, + "modules read": { + "fr": "modules lus", + "en": "modules read", + }, + "listed in the manifest, absent from disk": { + "fr": "listés au manifeste, absents du disque", + "en": "listed in the manifest, absent from disk", + }, + "listed in the manifest, unparsable": { + "fr": "listés au manifeste, illisibles", + "en": "listed in the manifest, unparsable", + }, + "Modules still carrying the removed tree view type.": { + "fr": "Modules portant encore le type de vue tree, supprimé.", + "en": "Modules still carrying the removed tree view type.", + }, "Will every step find its addons?": { "fr": "Chaque palier trouvera-t-il ses addons ?", "en": "Will every step find its addons?", diff --git a/test/test_check_migration_quality.py b/test/test_check_migration_quality.py index 4d5dee6..af71a94 100644 --- a/test/test_check_migration_quality.py +++ b/test/test_check_migration_quality.py @@ -1793,7 +1793,7 @@ class TestTheReviewChecklist(Base): continue sans_base += 1 self.assertIn("script/analyse/", commande, commande) - self.assertEqual(1, sans_base) + self.assertTrue(sans_base) def test_the_checkout_step_needs_no_database(self): # C'est l'angle mort des six autres : elles lisent toutes la BASE @@ -1801,8 +1801,9 @@ class TestTheReviewChecklist(Base): sans_base = [ (q, c) for q, c, k in quality.REVUE if k and "{db}" not in c ] - self.assertEqual(1, len(sans_base)) - self.assertIn("check_manifest_gaps", sans_base[0][1]) + self.assertTrue(sans_base) + for _question, commande in sans_base: + self.assertTrue(commande.startswith("script/analyse/"), commande) def test_the_first_step_has_nothing_to_run(self): # « La migration est-elle allée jusqu'au bout » se lit dans le @@ -1880,14 +1881,17 @@ class TestTheThreeExtraSections(Base): self.assertNotIn("{db}", commande) def test_without_a_database_only_the_db_free_step_can_be_run(self): - # La revue du checkout — les manifestes — se lance justement AVANT - # qu'une migration existe. Exiger une base la rendait inerte au - # seul moment où elle sert. + # La revue du checkout — manifestes, types de vue — se lance + # justement AVANT qu'une migration existe. Exiger une base la + # rendait inerte au seul moment où elle sert. lst = qtui.extra_rows([], {}) lancables = [r for r in lst if r["kind"] == "review" and r["command"]] - self.assertEqual(1, len(lancables)) - self.assertNotIn("{db}", lancables[0]["command"]) - self.assertNotIn(" -d ", lancables[0]["command"]) + attendu = [c for _q, c, k in quality.REVUE if k and "{db}" not in c] + self.assertEqual(len(attendu), len(lancables)) + self.assertTrue(lancables) + for ligne in lancables: + self.assertNotIn("{db}", ligne["command"]) + self.assertNotIn(" -d ", ligne["command"]) def test_a_step_that_names_a_database_stays_silent_without_one(self): lst = qtui.extra_rows([], {}) diff --git a/test/test_check_view_type_tree.py b/test/test_check_view_type_tree.py new file mode 100644 index 0000000..e9450bc --- /dev/null +++ b/test/test_check_view_type_tree.py @@ -0,0 +1,491 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""« tree » est d'abord un IDENTIFIANT, et rarement un type de vue. + +Sur 465 occurrences du mot dans odoo18.0/addons, quatre-vingts cassent. +Le reste est un id de , un , un env.ref(), un +nom de variable — et le noyau 18 lui-même a gardé ses anciens ids en ne +renommant que les balises : account.view_invoice_tree existe toujours. + +Un détecteur qui ancre sur le mot rend quatre cents lignes fausses et ne +sera pas relancé une seconde fois. C'est donc le TRI qu'on teste ici, pas +la détection : chaque exclusion a son test, et il y en a plus que de +motifs. +""" + +import io +import os +import shutil +import sys +import tempfile +import unittest +from contextlib import redirect_stdout + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, REPO) + +from script.analyse import check_view_type_tree as arbre # noqa: E402 + + +class Base(unittest.TestCase): + """Un checkout jetable : une version, un module, ce qu'on y met.""" + + def setUp(self): + self.racine = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.racine) + self.version("18.0") + self.addons = os.path.join(self.racine, "odoo18.0", "addons") + os.makedirs(self.addons) + + def version(self, texte): + with io.open( + os.path.join(self.racine, arbre.FICHIER_VERSION), + "w", + encoding="utf-8", + ) as handle: + handle.write(texte + "\n") + + def module(self, nom="essai", manifeste=None, init="from . import models"): + chemin = os.path.join(self.addons, "depot", nom) + os.makedirs(chemin, exist_ok=True) + dct = {"name": nom, "version": "18.0.1.0.0", "data": []} + dct.update(manifeste or {}) + self.ecrire(os.path.join(chemin, arbre.MANIFESTE), repr(dct)) + if init is not None: + self.ecrire(os.path.join(chemin, "__init__.py"), init) + return chemin + + def ecrire(self, chemin, contenu): + os.makedirs(os.path.dirname(chemin), exist_ok=True) + with io.open(chemin, "w", encoding="utf-8") as handle: + handle.write(contenu) + return chemin + + def constats(self): + return arbre.inspect(self.racine)["findings"] + + def motifs(self): + return sorted(c[3] for c in self.constats()) + + +class TestTheVersionGate(Base): + def test_below_18_nothing_is_a_defect(self): + # `tree` y est valide, au pire déprécié en 17. Crier dessus + # ferait rejeter le rapport en entier. + self.assertFalse(arbre.concerne((17, 0))) + self.assertFalse(arbre.concerne((12, 0))) + + def test_18_and_beyond_are_judged(self): + self.assertTrue(arbre.concerne((18, 0))) + self.assertTrue(arbre.concerne((19, 0))) + + def test_an_unreadable_version_judges_nothing(self): + os.remove(os.path.join(self.racine, arbre.FICHIER_VERSION)) + self.assertIsNone(arbre.version_active(self.racine)) + self.assertEqual([], self.constats()) + + def test_a_nonsense_version_judges_nothing(self): + self.version("develop") + self.assertIsNone(arbre.version_active(self.racine)) + + +class TestTheModuleGate(Base): + def test_a_manifest_is_read_never_executed(self): + # Un __manifest__.py vient d'un dépôt tiers : l'exécuter pour lire + # quatre clés donnerait la main à n'importe qui. + chemin = self.ecrire( + os.path.join(self.racine, "m", arbre.MANIFESTE), + "__import__('os').system('touch /tmp/jamais')\n", + ) + self.assertIsNone(arbre.lire_manifeste(chemin)) + + def test_a_module_that_odoo_would_not_list_is_skipped(self): + # `installable: False` : il ne peut casser ni à l'installation ni + # à l'exécution. + module = self.module( + manifeste={"installable": False, "data": ["v.xml"]} + ) + self.ecrire( + os.path.join(module, "v.xml"), + "" + "", + ) + self.assertEqual([], self.constats()) + + def test_a_module_that_is_installable_is_read(self): + module = self.module(manifeste={"data": ["v.xml"]}) + self.ecrire( + os.path.join(module, "v.xml"), + "" + "", + ) + self.assertEqual(["xml_balise"], self.motifs()) + + def test_a_directory_without_a_manifest_is_not_a_module(self): + os.makedirs(os.path.join(self.addons, "depot", "pas_un_module")) + self.assertEqual([], arbre.modules(self.addons)) + + +class TestTheLoadingGate(Base): + def test_an_xml_the_manifest_does_not_load_is_ignored(self): + # Personne ne le charge : il ne casse rien, et le signaler ferait + # crier sur des brouillons laissés là. + module = self.module(manifeste={"data": []}) + self.ecrire( + os.path.join(module, "orphelin.xml"), "" + ) + self.assertEqual([], self.constats()) + + def test_a_demo_file_counts_like_a_data_file(self): + module = self.module(manifeste={"demo": ["d.xml"]}) + self.ecrire(os.path.join(module, "d.xml"), "") + self.assertEqual(["xml_balise"], self.motifs()) + + def test_a_python_nobody_imports_is_ignored(self): + module = self.module(init="") + self.ecrire( + os.path.join(module, "brouillon.py"), + "A = [(1, 'tree')]\n", + ) + self.assertEqual([], self.constats()) + + def test_a_python_reached_through_the_init_is_read(self): + module = self.module(init="from . import models") + self.ecrire(os.path.join(module, "models.py"), "A = [(1, 'tree')]\n") + self.assertEqual(["py_views"], self.motifs()) + + def test_a_module_imported_for_its_function_is_still_followed(self): + # « from .hooks import post_init_hook » importe une FONCTION ; + # chercher post_init_hook.py manquait tous les hooks.py, et avec + # eux vingt-sept constats réels. + module = self.module(init="from .hooks import post_init_hook") + self.ecrire( + os.path.join(module, "hooks.py"), + "def post_init_hook(env):\n" + " env['code.generator.view'].create({'view_type': 'tree'})\n", + ) + self.assertEqual(["py_view_type"], self.motifs()) + + def test_a_subpackage_is_walked(self): + module = self.module(init="from . import models") + self.ecrire( + os.path.join(module, "models", "__init__.py"), "from . import x" + ) + self.ecrire( + os.path.join(module, "models", "x.py"), "A = [(1, 'tree')]\n" + ) + self.assertEqual(["py_views"], self.motifs()) + + def test_a_test_file_is_never_judged(self): + module = self.module(init="from . import tests") + self.ecrire( + os.path.join(module, "tests", "__init__.py"), "from . import t" + ) + self.ecrire( + os.path.join(module, "tests", "t.py"), "A = [(1, 'tree')]\n" + ) + self.assertEqual([], self.constats()) + + +class TestTheXmlPatterns(Base): + def vue(self, corps, manifeste=None): + module = self.module(manifeste=manifeste or {"data": ["v.xml"]}) + self.ecrire(os.path.join(module, "v.xml"), corps) + return self.constats() + + def test_a_tree_tag_is_caught(self): + self.assertEqual( + ["xml_balise"], + [ + c[3] + for c in self.vue( + "" + ) + ], + ) + + def test_the_closing_tag_is_not_a_second_defect(self): + # Elle est la moitié du même : les compter séparément double le + # total et fait croire à deux corrections. + self.assertEqual(1, len(self.vue(""))) + + def test_a_commented_tree_is_not_a_defect(self): + # Aucun geste explicite : lxml ne construit pas d'élément pour le + # contenu d'un commentaire. C'est l'argument décisif contre grep. + self.assertEqual([], self.vue("")) + + def test_a_record_id_that_contains_tree_is_not_a_defect(self): + # Le noyau 18 a gardé account.view_invoice_tree, arch en . + self.assertEqual( + [], + self.vue( + "" + "account.move.tree" + "" + "" + ), + ) + + def test_a_view_mode_naming_tree_is_caught(self): + constats = self.vue( + "tree,form" + ) + self.assertEqual(["xml_view_mode"], [c[3] for c in constats]) + self.assertEqual("list,form", constats[0][5]) + + def test_a_space_after_the_comma_does_not_hide_it(self): + self.assertEqual( + ["xml_view_mode"], + [ + c[3] + for c in self.vue( + "form, tree" + ) + ], + ) + + def test_a_mode_that_merely_starts_with_tree_is_not_caught(self): + # Un substring attraperait « treemap ». + self.assertEqual( + [], + self.vue( + "treemap,form" + ), + ) + + def test_an_explicit_type_field_is_caught(self): + self.assertEqual( + ["xml_type"], + [ + c[3] + for c in self.vue( + "tree" + ) + ], + ) + + def test_an_xpath_aiming_at_a_tree_is_caught(self): + self.assertEqual( + ["xml_xpath"], + [ + c[3] + for c in self.vue( + "" + ) + ], + ) + + def test_an_xpath_naming_tree_inside_a_predicate_is_not_caught(self): + self.assertEqual( + [], + self.vue( + "" + ), + ) + + def test_an_unparsable_file_is_reported_apart(self): + # Autre cause, autre réparation : ne pas le mêler aux « tree ». + module = self.module(manifeste={"data": ["v.xml"]}) + self.ecrire(os.path.join(module, "v.xml"), "\n\n" + "\n" + ) + self.assertEqual(2, constats[0][2]) + + +class TestThePythonPatterns(Base): + def code(self, source): + module = self.module(init="from . import models") + self.ecrire(os.path.join(module, "models.py"), source) + return self.constats() + + def test_a_view_tuple_typed_tree_is_caught(self): + self.assertEqual( + ["py_views"], + [ + c[3] + for c in self.code( + "def f(res):\n return [(res and res.id or False, 'tree')]\n" + ) + ], + ) + + def test_a_pair_of_two_strings_is_defensive_code_not_a_defect(self): + # ('list', 'tree') couvre les deux noms de balise ; en 18 seule la + # branche list se produit, l'entrée tree reste morte. + self.assertEqual([], self.code("A = ('list', 'tree')\n")) + + def test_the_variable_name_decides_nothing(self): + # Le piège le plus tentant : les deux formes sont à quelques + # lignes d'écart dans le même dépôt et ne diffèrent que par le + # littéral. Odoo ne lit pas les noms de variables. + self.assertEqual([], self.code("tree_view = [(1, 'list')]\n")) + + def test_a_dict_view_mode_is_caught(self): + self.assertEqual( + ["py_view_mode"], + [c[3] for c in self.code("A = {'view_mode': 'tree,form'}\n")], + ) + + def test_a_view_mode_without_the_action_type_is_still_caught(self): + # Plusieurs actions réelles omettent la clé `type` et sont + # pourtant renvoyées telles quelles au client. + self.assertEqual( + ["py_view_mode"], + [ + c[3] + for c in self.code( + "A = {'name': 'x', 'view_mode': 'form,tree,kanban'}\n" + ) + ], + ) + + def test_view_type_on_an_act_window_is_a_harmless_leftover(self): + # Vestige d'Odoo 12 : la 18 ne lit que view_mode / views / view_ids + # et ignore la clé surnuméraire sans bruit. + self.assertEqual( + [], + self.code( + "A = {'type': 'ir.actions.act_window', 'view_type': 'tree'}\n" + ), + ) + + def test_view_type_anywhere_else_is_a_live_selection(self): + # code.generator.view.view_type ne propose plus que « list » : + # la valeur tree lève au post_init_hook. + self.assertEqual( + ["py_view_type"], + [ + c[3] + for c in self.code( + "A = {'view_type': 'tree', 'view_name': 'x'}\n" + ) + ], + ) + + def test_a_comparison_against_tree_is_not_a_defect(self): + # Une comparaison ne fabrique jamais un type de vue : au pire du + # code mort. La compter gonflerait le rapport de lignes que + # personne ne peut réparer isolément. + self.assertEqual( + [], + self.code( + "def f(v):\n return v != 'tree' and v in ('list', 'tree')\n" + ), + ) + + def test_a_tree_used_as_a_data_structure_is_not_a_defect(self): + self.assertEqual( + [], + self.code( + "import lxml.etree as etree\n" + "def f(d):\n return d['tree'], etree.tostring\n" + ), + ) + + def test_the_unix_command_named_tree_is_not_a_defect(self): + self.assertEqual([], self.code("cmd = 'tree'\nPKGS = ['tree']\n")) + + def test_a_file_that_does_not_parse_is_reported_apart(self): + module = self.module(init="from . import models") + self.ecrire(os.path.join(module, "models.py"), "def f(:\n") + rapport = arbre.inspect(self.racine) + self.assertEqual([], rapport["findings"]) + self.assertEqual(1, len(rapport["unreadable"])) + + +class TestTheCommand(Base): + def lancer(self, argv): + tampon = io.StringIO() + with redirect_stdout(tampon): + code = arbre.main(argv) + return code, tampon.getvalue() + + def test_a_missing_root_is_a_tool_failure(self): + code, _ = self.lancer(["--root", os.path.join(self.racine, "absent")]) + self.assertEqual(2, code) + + def test_a_clean_checkout_exits_zero(self): + self.module() + code, texte = self.lancer(["--root", self.racine, "--no-color"]) + self.assertEqual(0, code) + self.assertIn(arbre.t("No module carries it."), texte) + + def test_a_finding_exits_one(self): + module = self.module(manifeste={"data": ["v.xml"]}) + self.ecrire(os.path.join(module, "v.xml"), "") + code, _ = self.lancer(["--root", self.racine, "--no-color"]) + self.assertEqual(1, code) + + def test_below_18_it_says_so_instead_of_staying_silent(self): + self.version("17.0") + _code, texte = self.lancer(["--root", self.racine, "--no-color"]) + self.assertIn( + arbre.t("nothing to do below 18.0 — tree is valid there"), texte + ) + + def test_the_json_says_what_to_replace_it_with(self): + import json + + module = self.module(manifeste={"data": ["v.xml"]}) + self.ecrire( + os.path.join(module, "v.xml"), + "tree,form", + ) + _code, texte = self.lancer(["--root", self.racine, "--json"]) + dct = json.loads(texte) + self.assertEqual("18.0", dct["odoo"]) + self.assertEqual("list,form", dct["findings"][0]["replace_with"]) + + +class TestAgainstTheRealCheckout(unittest.TestCase): + """Sur le vrai arbre — c'est là que le tri se prouve.""" + + @classmethod + def setUpClass(cls): + cls.rapport = arbre.inspect(REPO) + + def test_it_reads_the_whole_addon_tree(self): + if not arbre.concerne(arbre.version_active(REPO)): + self.skipTest("checkout sous la 18.0") + self.assertGreater(self.rapport["scanned"], 1000) + + def test_it_finds_the_line_that_broke_a_kanban(self): + # « View types not defined tree found in act_window action 444 », + # au clic sur action_view_ticket depuis le kanban de projet. + if not arbre.concerne(arbre.version_active(REPO)): + self.skipTest("checkout sous la 18.0") + vus = [ + (f, ligne) + for _m, f, ligne, _p, _e, _c in self.rapport["findings"] + if "helpdesk_mgmt_project" in f + ] + self.assertTrue(vus, "le défaut rencontré en production n'est pas vu") + + def test_the_core_odoo_identifiers_are_not_reported(self): + # account.view_invoice_tree et ses semblables vivent dans le noyau + # 18 avec une arch en : les signaler serait ~400 lignes + # fausses, et le rapport ne serait pas relancé. + for _m, fichier, _l, _p, _e, _c in self.rapport["findings"]: + self.assertNotIn("odoo18.0/odoo/", fichier) + + +if __name__ == "__main__": + unittest.main() From 1f606fed000d44065868df6716b5a02690dcdd8c Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 27 Aug 2026 04:36:03 -0400 Subject: [PATCH 16/22] [ADD] verdicts : tous les paliers, leur journal, et la bascule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'écran de qualité ne listait que les échecs, quand la question devant une base migrée est « qu'a-t-on vérifié » : les quatorze verdicts s'affichent, de la 12 à la 18, seule façon de voir qu'un échec a été rattrapé à un palier plus haut. Le panneau ne portait que la commande ; il montre le passage du journal d'étape qui l'entoure, garde par tee ce qu'il lance lui-même et le relit sans relancer. La sortie de l'outil, elle, part sur le terminal : un tube ferait renoncer les pleins écrans. Relancer un test d'un autre palier ouvrait la base avec la mauvaise version, qui y écrit avant d'échouer ; l'écran demande avant de basculer. --- EN --- The quality screen listed failures only, when the question in front of a migrated database is "what did we check": all fourteen verdicts now show, 12 through 18, the only way to see that a failure at one tier was recovered higher up. The panel carried only the command; it shows the step-log passage around it, keeps by tee what it runs itself and re-reads that without rerunning. The tool output goes to the terminal: a pipe would make full-screen tools give up. Replaying a test from another tier opened the database with the wrong version, which writes before it fails; the screen asks before switching the checkout. Assisted-by: Claude Opus 5 (cherry picked from commit 2d460b7c777d39a887dabfe7cf5405864c6c3f8c) --- script/analyse/check_migration_quality.py | 110 +++++ script/analyse/check_migration_quality_tui.py | 323 ++++++++++++-- script/analyse/check_migration_residue.py | 46 +- script/todo/todo_i18n.py | 93 +++- test/test_check_migration_quality.py | 406 +++++++++++++++++- test/test_monitoring.py | 132 +++++- 6 files changed, 1063 insertions(+), 47 deletions(-) diff --git a/script/analyse/check_migration_quality.py b/script/analyse/check_migration_quality.py index b2de944..1d750bb 100755 --- a/script/analyse/check_migration_quality.py +++ b/script/analyse/check_migration_quality.py @@ -36,6 +36,7 @@ Codes de sortie : 0 rien à signaler, 1 des trouvailles, 2 l'outil a échoué. import json import os +import re import subprocess import sys @@ -134,6 +135,115 @@ def failures(events): return [e for e in events if e["status"] and e["kind"] == "test"] +FICHIER_VERSION = ".odoo-version" + + +def checkout_version(racine=None): + """La version d'Odoo sur laquelle le checkout est posé, ou None. + + Un entier, pas « 18.0 » : c'est ce que porte le nom des bases de + palier et ce que la chaîne de migration rend, et deux formes du même + fait finissent toujours par diverger. + """ + base = racine or os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) + try: + with open( + os.path.join(base, FICHIER_VERSION), encoding="utf-8" + ) as handle: + return int(float(handle.read().strip())) + except (OSError, ValueError): + return None + + +def verdicts(events): + """TOUS les verdicts de test, réussis compris. + + N'afficher que les échecs répondait à « qu'est-ce qui a raté » et à + rien d'autre. Or la question qu'on se pose devant une base migrée est + « qu'a-t-on vérifié », et un test réussi au palier 17 est une preuve + au même titre qu'un raté au 14 — c'est même la seule façon de voir + qu'un échec du 14 a été RATTRAPÉ ensuite. + """ + return [e for e in events if e["kind"] == "test"] + + +def version_of(database, dct): + """La version d'Odoo de cette base, lue dans la chaîne de la migration. + + Plus sûr que le suffixe du nom : la base de DÉPART n'en porte pas — + « test_neutralize » ne dit pas 12.0 — et son palier retombait alors + sur le compteur du pilote, qui affichait « 2 ». + """ + for version, base in chain(dct): + if base == database: + return version + return None + + +# Ce que la migration écrit dans le journal d'une étape autour d'un test : +# +# [2026-08-26 03:19:44.166204] $ .venv…/python3 ./script/…/smoke.py -d … +# [2026-08-26 03:19:59.846406] -> 1 +# [2026-08-26 03:19:59.847489] [test] smoke_public_url -> 1 +# +# Entre le « $ » et le « -> », RIEN : mesuré sur trois exécutions du même +# test, la sortie de l'outil n'est pas capturée. Ce qui explique l'échec +# est donc AVANT, dans ce qu'Odoo écrivait juste avant qu'on le teste — +# et c'est pour cela que l'extrait remonte, au lieu de descendre. +MARQUEUR_COMMANDE = "] $ " +MOTIF_HORODATAGE = re.compile(r"^\[(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d)") + + +def _horodatage(ligne): + """La seconde portée par cette ligne de journal, ou "".""" + found = MOTIF_HORODATAGE.match(ligne) + return found.group(1) if found else "" + + +def event_tool(event): + """Le script que ce verdict a lancé — « smoke_public_url.py ».""" + for mot in (event.get("detail") or "").split(): + if mot.endswith(".py"): + return os.path.basename(mot) + return "" + + +def event_excerpt(lignes, event, avant=18): + """(extrait, rang de la commande) — le passage qui entoure ce verdict. + + Quand la migration a été rejouée, le même test apparaît plusieurs fois + dans le même fichier. On retient donc l'occurrence la plus PROCHE de + l'horodatage du verdict, et non la dernière : la dernière appartient + peut-être à une exécution qui n'est pas celle qu'on regarde. + """ + outil = event_tool(event) + if not outil or not lignes: + return [], None + base = event_database(event) + quand = (event.get("at") or "")[:19] + candidats = [ + rang + for rang, ligne in enumerate(lignes) + if MARQUEUR_COMMANDE in ligne + and outil in ligne + and (not base or base in ligne) + ] + if not candidats: + return [], None + avant_le_verdict = [ + rang for rang in candidats if _horodatage(lignes[rang]) <= quand + ] + rang = (avant_le_verdict or candidats)[-1] + fin = rang + for suivant in range(rang, min(rang + 400, len(lignes))): + if "[test] " in lignes[suivant] and event["name"] in lignes[suivant]: + fin = suivant + break + return lignes[max(0, rang - avant) : fin + 1], rang + + def event_database(event): """La base sur laquelle ce verdict portait, lue dans sa commande.""" for mot in event.get("detail", "").split(): diff --git a/script/analyse/check_migration_quality_tui.py b/script/analyse/check_migration_quality_tui.py index d0b8d31..691b42e 100644 --- a/script/analyse/check_migration_quality_tui.py +++ b/script/analyse/check_migration_quality_tui.py @@ -12,7 +12,9 @@ Deux assemblages sépareraient les deux vues, et l'on finirait par lire deux états contradictoires de la même migration. """ +import io import os +import shlex import subprocess import sys @@ -128,17 +130,20 @@ def extra_rows(presents, dct=None): cible = presents[-1]["database"] if presents else "" lst = [] - # ── Succès ── + # ── Verdicts ── evenements = quality.read_events(dct) + tous = quality.verdicts(evenements) ratés = quality.failures(evenements) lst.append( { "kind": "header", "label": f"── {t('Verdicts')} ──", - "detail": str(len(ratés)) if ratés else "✅", + "detail": ( + f"{len(ratés)}/{len(tous)}" if ratés else f"✅ {len(tous)}" + ), } ) - if not evenements: + if not tous: lst.append( { "kind": "verdict-none", @@ -146,28 +151,30 @@ def extra_rows(presents, dct=None): "detail": "", } ) - for event in ratés: + for event in tous: base = quality.event_database(event) - # Le PALIER dans le libellé : « smoke_public_url » quatre fois de - # suite ne dit pas lequel a échoué, et c'est la seule chose qu'on - # veut savoir en parcourant la colonne. - palier = quality.event_step(event, base) + # Le PALIER dans le libellé : « smoke_public_url » sept fois de + # suite ne dit pas lequel on regarde, et c'est la seule chose + # qu'on veut savoir en parcourant la colonne. Il vient de la + # CHAÎNE de la migration, car la base de départ ne le porte pas + # dans son nom — « test_neutralize » ne dit pas 12.0. + version = quality.version_of(base, dct) + palier = str(version) if version else quality.event_step(event, base) + icone = "❌" if event["status"] else "✅" lst.append( { "kind": "verdict", - "label": f" ❌ {palier:<5} {event['name'][:18]}", + "label": f" {icone} {palier:<5} {event['name'][:18]}", "detail": "▶", "event": event, "database": base or cible, + "step": palier, + "log": status.step_log_path(dct, event.get("step")), "command": rerun_command(event, base or cible), - } - ) - if evenements and not ratés: - lst.append( - { - "kind": "verdict-ok", - "label": f" ✅ {len(evenements)} {t('checks, all passed')}", - "detail": "", + "switch": switch_needed(base or cible, dct), + "capture": run_log_path( + "verdict_%s_%s" % (event["name"], base or cible) + ), } ) @@ -208,6 +215,19 @@ def extra_rows(presents, dct=None): if clef and (cible or "{db}" not in commande) else "" ), + # Les étapes qui nomment une base ont le même écueil que + # les verdicts : le checkout doit être au bon palier. + "switch": ( + switch_needed(cible, dct) if "{db}" in commande else None + ), + # Le « shell » est un REPL : le passer par un tube lui + # ferait perdre son invite. Les sept autres écrivent et + # s'arrêtent, on peut donc en garder une copie. + "capture": ( + run_log_path(f"review_{clef}") + if clef and clef != "shell" + else None + ), } ) return lst @@ -298,7 +318,6 @@ EXTRA_KINDS = ( "header", "verdict", "verdict-none", - "verdict-ok", "source", "logscan", "review", @@ -321,8 +340,6 @@ def extra_pane(row, colour=False): t("not in command_executed, which only lists what ran."), ] ) - if genre == "verdict-ok": - return t("Every recorded check returned zero.") if genre == "source": return source_pane(row, colour) if genre == "logscan": @@ -332,11 +349,95 @@ def extra_pane(row, colour=False): return "" -def verdict_pane(row, colour=False): - """Ce qu'un verdict raté dit, et ce que son code de retour signifie.""" - event = row.get("event") or {} +def log_excerpt_lines(row, colour=False): + """Le passage du journal d'étape qui entoure ce verdict. + + Ce que le journal contient VRAIMENT, dit sans détour : la commande et + son code, pas la sortie de l'outil. Le pilote l'explique — ce qui + passe par `run_on_terminal` n'a pas de sortie capturable, un tube y + ferait renoncer les pleins écrans. Ce qui précède la commande est donc + tout ce qu'on a, et c'est déjà ce qu'Odoo écrivait au moment du test. + + Le taire enverrait chercher un fichier plus complet qui n'existe pas. + """ + chemin = row.get("log") + if not chemin: + return [ + status.paint(f" ── {t('step log')} ──", "step", colour), + f" {t('no log file for this step')}", + "", + ] + try: + with io.open(chemin, encoding="utf-8", errors="replace") as handle: + brut = handle.read().splitlines() + except OSError: + return [] + extrait, _rang = quality.event_excerpt(brut, row.get("event") or {}) lignes = [ - status.paint(f"❌ {event.get('name')}", "fail", colour), + status.paint(f" ── {t('step log')} ──", "step", colour), + status.paint(f" {chemin}", "dim", colour), + status.paint(f" {len(brut)} {t('lines in all')}", "dim", colour), + "", + ] + if not extrait: + lignes.append(f" {t('this verdict is not in it')}") + lignes.append("") + return lignes + for ligne in extrait: + lignes.append(f" {ligne[:150]}") + lignes.append("") + lignes.append( + status.paint( + f" {t('the tool output is not in there: it goes to the')}", + "dim", + colour, + ) + ) + lignes.append( + status.paint( + f" {t('terminal, which a pipe would make full-screen tools')}", + "dim", + colour, + ) + ) + lignes.append( + status.paint( + f" {t('give up. What precedes is what Odoo was writing.')}", + "dim", + colour, + ) + ) + lignes.append("") + return lignes + + +def switch_needed(database, dct): + """(version, cible make) si le checkout n'est pas au palier de cette base. + + Lancer un outil sur une base d'un autre palier n'est pas seulement + inutile : Odoo ÉCRIT dedans avant d'échouer. Le test de fumée s'en + garde déjà et sort en 2 ; l'écran, lui, peut proposer la bascule au + lieu de laisser relancer trois fois la même erreur. + """ + version = quality.version_of(database, dct) + if not version: + return None + courante = quality.checkout_version() + if not courante or courante == version: + return None + return (version, "switch_odoo_%d" % version) + + +def verdict_pane(row, colour=False): + """Ce qu'un verdict dit, ce que son code signifie, et ce qu'on en lit.""" + event = row.get("event") or {} + rate = bool(event.get("status")) + lignes = [ + status.paint( + f"{'❌' if rate else '✅'} {event.get('name')}", + "fail" if rate else "ok", + colour, + ), "", # Le palier est la version d'ODOO, pas le compteur du pilote : # « 4.1.I » désigne la première étape du quatrième bloc, et la @@ -359,9 +460,26 @@ def verdict_pane(row, colour=False): "1 means leftovers remain that it could not drop." ), }.get(event.get("name"), "") - if sens: + if sens and rate: lignes.append(f" {sens}") lignes.append("") + lignes.extend(log_excerpt_lines(row, colour)) + lignes.extend(run_log_lines(row, colour)) + if row.get("command") and row.get("switch"): + version, cible = row["switch"] + lignes.append( + status.paint( + f" ⚠ {t('the checkout is on Odoo')}" + f" {quality.checkout_version()}.0," + f" {t('this database is on')} {version}.0", + "warn", + colour, + ) + ) + lignes.append( + f" {t('r offers to run')} « make {cible} » {t('first')}" + ) + lignes.append("") if row.get("command"): lignes.append( status.paint( @@ -443,6 +561,40 @@ def logscan_pane(colour=False): return "\n".join(lignes) +def run_log_lines(row, colour=False): + """Ce que la dernière exécution de cette ligne a écrit, ou l'invitation. + + Le panneau défile — c'est un VerticalScroll — donc on n'ampute pas : + montrer vingt lignes d'un rapport qui en fait deux cents obligerait à + le relancer dans un terminal pour lire la suite, et l'écran n'aurait + servi qu'à donner envie. + """ + chemin = row.get("capture") + if not chemin: + return [] + lst, total = read_run_log(chemin) + if not lst: + if not row.get("command"): + return [] + return [ + status.paint(f" ── {t('last run')} ──", "step", colour), + status.paint( + f" {t('never run from here yet')}", "dim", colour + ), + "", + ] + lignes = [ + status.paint(f" ── {t('last run')} ──", "step", colour), + status.paint(f" {chemin}", "dim", colour), + status.paint(f" {total} {t('lines in all')}", "dim", colour), + "", + ] + for ligne in lst: + lignes.append(f" {ligne}") + lignes.append("") + return lignes + + def review_pane(row, colour=False): """Une étape de la revue : ce qu'elle prouve, et ce qu'elle ne prouve pas.""" lignes = [ @@ -457,7 +609,7 @@ def review_pane(row, colour=False): ) else: lignes.append(f" {t('Read it in the Verdicts section above.')}") - lignes.append("") + lignes.extend(run_log_lines(row, colour)) lignes.append(f" {t('What none of these can see')}") lignes.append( f" {t('They all read the DATABASE. A module that kept a')}" @@ -536,7 +688,94 @@ def mode_label(mode): return t(mode) -def run_in_terminal(command, wait=True): +# La sortie d'une exécution lancée DEPUIS l'écran, gardée pour être +# relue. Celle des tests de la migration ne l'était pas — le pilote lance +# par `run_on_terminal`, qui n'a pas de sortie capturable — mais ce que +# l'on lance soi-même, on peut le garder. +REVIEW_LOG_DIR = os.path.join(".venv.erplibre", "screen_log") + + +def run_log_path(clef): + """Le fichier où garder la sortie de cette commande, ou None. + + Une clé par ligne de l'écran, et non par commande : deux exécutions + de la même étape doivent se remplacer, pas s'empiler. On veut « ce + que ça donne maintenant », jamais un historique qu'il faudrait trier. + """ + if not clef: + return None + propre = "".join(c if c.isalnum() or c in "._-" else "_" for c in clef) + return os.path.join(REPO_ROOT, REVIEW_LOG_DIR, propre + ".log") + + +def read_run_log(chemin, limite=4000): + """(lignes, total) de la derniere execution, ou ([], 0).""" + if not chemin: + return [], 0 + try: + with io.open(chemin, encoding="utf-8", errors="replace") as handle: + lst = handle.read().splitlines() + except OSError: + return [], 0 + return lst[-limite:], len(lst) + + +def ask_yes(question, defaut=True): + """Poser une question fermée dans le terminal rendu. Entrée = défaut. + + `input()` et non une boîte de dialogue : on est DÉJÀ sorti de l'écran + quand la question se pose, et y rentrer pour un oui/non ferait + clignoter tout l'affichage entre deux commandes. + """ + suffixe = " [O/n] " if defaut else " [o/N] " + try: + reponse = input(question + suffixe).strip().lower() + except (EOFError, KeyboardInterrupt): + return False + if not reponse: + return defaut + return reponse[0] in ("o", "y") + + +def run_with_switch(command, switch, wait=True, capture=None): + """Lancer, en proposant d'abord de basculer le checkout s'il le faut. + + Sans cela on relance trois fois la même erreur : le test de fumée + refuse d'ouvrir une base 16 avec un checkout 18 — « l'ouvrir avec la + mauvaise version y écrit avant d'échouer » — et rend 2 sans rien + faire. La bascule est la seule suite possible, autant la proposer. + + On DEMANDE au lieu de basculer d'office : `make switch_odoo_16` change + le checkout entier, et quelqu'un peut être en train d'y travailler. + """ + if switch: + version, cible = switch + print() + print( + status.paint( + f"⚠ {t('the checkout is on Odoo')}" + f" {quality.checkout_version()}.0," + f" {t('this database is on')} {version}.0", + "warn", + True, + ) + ) + print( + f" {t('opening it with the wrong version writes before it fails.')}" + ) + print() + if not ask_yes(f" {t('run')} « make {cible} » {t('first?')}"): + print(f" {t('left as is — the test will refuse and return 2.')}") + else: + code, _tourne = run_in_terminal(f"make {cible}", wait=False) + if code: + print(status.paint(f" ✗ make {cible} → {code}", "fail", True)) + if not ask_yes(f" {t('run the test anyway?')}", defaut=False): + return None, False + return run_in_terminal(command, wait=wait, capture=capture) + + +def run_in_terminal(command, wait=True, capture=None): """Rendre le terminal au test, puis le reprendre. (code, a_tourné). L'écran DOIT s'effacer : `smoke_public_url` monte une instance Odoo et @@ -556,6 +795,21 @@ def run_in_terminal(command, wait=True): print() print(f"▶ {' '.join(argv)}") print("─" * 72) + if capture: + # `tee` garde une copie SANS rien cacher : la sortie continue + # d'aller au terminal en direct. Le prix est la couleur — les + # outils la coupent quand stdout n'est plus un terminal — et + # c'est un bon prix pour un journal qu'on relira dans l'écran. + # `pipefail` pour que le code rendu soit celui de la commande, + # et non celui de `tee`, qui réussit toujours. + os.makedirs(os.path.dirname(capture), exist_ok=True) + argv = [ + "bash", + "-o", + "pipefail", + "-c", + "%s 2>&1 | tee %s" % (shlex.join(argv), shlex.quote(capture)), + ] try: code = subprocess.run(argv, cwd=REPO_ROOT).returncode except OSError as exc: @@ -585,6 +839,11 @@ def build_app(lst_snapshot): ("m", "toggle_missing", t("Missing files")), ("d", "cycle_detail", t("Details")), ("r,enter", "run_selected", t("Run")), + # La table garde les flèches et pgup/pgdn pour ses lignes ; + # ces deux-là ne lui appartiennent pas et restent donc + # disponibles pour le panneau. + ("ctrl+u", "pane_up", t("Pane up")), + ("ctrl+d", "pane_down", t("Pane down")), ] def __init__(self, lst_snapshot): @@ -674,9 +933,17 @@ def build_app(lst_snapshot): self.bell() return with self.suspend(): - run_in_terminal(commande) + run_with_switch( + commande, row.get("switch"), capture=row.get("capture") + ) self.refresh() + def action_pane_up(self): + self.query_one("#pane", VerticalScroll).scroll_page_up() + + def action_pane_down(self): + self.query_one("#pane", VerticalScroll).scroll_page_down() + def on_data_table_row_highlighted(self, event): if event.data_table.id == "left" and self.lst_row: self.index = event.cursor_row diff --git a/script/analyse/check_migration_residue.py b/script/analyse/check_migration_residue.py index 651b84b..ebda974 100755 --- a/script/analyse/check_migration_residue.py +++ b/script/analyse/check_migration_residue.py @@ -96,6 +96,7 @@ except Exception: # pragma: no cover - repli si i18n indisponible from script.analyse import check_migration_quality as quality # noqa: E402 from script.analyse import lib_analyse # noqa: E402 +from script.todo import migration_status as status # noqa: E402 COULEURS = { "broken": "\033[31m", @@ -285,7 +286,36 @@ def verdicts(database, path=None): return tous, quality.failures(tous) -def verdicts_block(database, colour=True, path=None): +def extrait_du_journal(dct, event, colour=True, avant=6): + """Ce que le journal d'étape montre autour de ce verdict. + + La commande seule ne dit pas POURQUOI. Le journal, lui, garde ce + qu'Odoo écrivait au moment du test — et c'est tout ce qu'on a : la + sortie de l'outil n'y est pas, parce qu'elle passe par le terminal, + qu'un tube ferait renoncer aux outils en plein écran. Le pilote le + documente à l'endroit où il l'écrit. + + Silencieux quand il n'y a rien à montrer : une ligne « pas de + journal » par verdict noierait les quatre qui comptent. + """ + chemin = status.step_log_path(dct, event.get("step")) + if not chemin or avant <= 0: + return [] + try: + with open(chemin, "r", encoding="utf-8", errors="replace") as handle: + brut = handle.read().splitlines() + except OSError: + return [] + extrait, _rang = quality.event_excerpt(brut, event, avant=avant) + if not extrait: + return [] + lignes = [paint(f" {chemin}", "dim", colour)] + for ligne in extrait: + lignes.append(paint(f" {ligne[:150]}", "dim", colour)) + return lignes + + +def verdicts_block(database, colour=True, path=None, lignes_avant=6): """La section « Verdicts », ou rien du tout s'il n'y en a pas. Silencieuse quand le fichier n'existe pas : devant la sauvegarde d'un @@ -310,8 +340,10 @@ def verdicts_block(database, colour=True, path=None): colour, ) ) + dct = quality.read_progression(chemin) for event in ratés: - palier = quality.event_step(event) + version = quality.version_of(quality.event_database(event), dct) + palier = str(version) if version else quality.event_step(event) lignes.append( paint( f"❌ {palier.rjust(6)} {event['name']}", @@ -322,6 +354,7 @@ def verdicts_block(database, colour=True, path=None): lignes.append( paint(f" {event['detail'][:120]}", "dim", colour) ) + lignes.extend(extrait_du_journal(dct, event, colour, lignes_avant)) lignes.append("") lignes.append( paint( @@ -331,6 +364,15 @@ def verdicts_block(database, colour=True, path=None): colour, ) ) + if ratés and lignes_avant > 0: + lignes.append( + paint( + f" {t('the tool output is not in the step log: it goes')}" + f" {t('to the terminal and dies with it.')}", + "dim", + colour, + ) + ) return lignes diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 2bc1f0c..8d6ef65 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -3499,8 +3499,8 @@ TRANSLATIONS = { "en": "vendors document two, not more.", }, "Measured at level 4: 36x slower, then a frozen kernel.": { - "fr": "Mesuré au niveau 4 : 36x plus lent, puis noyau gelé.", - "en": "Measured at level 4: 36x slower, then a frozen kernel.", + "fr": "Mesuré au niveau 4 : 36x plus lent, et 12 vCPU gelaient.", + "en": "Measured at level 4: 36x slower, and 12 vCPU froze.", }, "vCPU capped to": { "fr": "vCPU borné à", @@ -6978,6 +6978,95 @@ TRANSLATIONS = { "en": "Migration quality, step by step", }, # --- Écran de qualité : Verdicts, Validation, Revue --- + # --- Journal d'étape et bascule de version --- + "last run": { + "fr": "dernière exécution", + "en": "last run", + }, + "never run from here yet": { + "fr": "jamais lancée depuis ici", + "en": "never run from here yet", + }, + "Pane up": { + "fr": "Panneau ↑", + "en": "Pane up", + }, + "Pane down": { + "fr": "Panneau ↓", + "en": "Pane down", + }, + "the tool output is not in the step log: it goes": { + "fr": "la sortie de l'outil n'est pas dans le journal d'étape : elle part", + "en": "the tool output is not in the step log: it goes", + }, + "to the terminal and dies with it.": { + "fr": "sur le terminal et meurt avec lui.", + "en": "to the terminal and dies with it.", + }, + "r offers to run": { + "fr": "r propose de lancer", + "en": "r offers to run", + }, + "first": { + "fr": "d'abord", + "en": "first", + }, + "step log": { + "fr": "journal de l'étape", + "en": "step log", + }, + "no log file for this step": { + "fr": "aucun journal pour cette étape", + "en": "no log file for this step", + }, + "lines in all": { + "fr": "lignes en tout", + "en": "lines in all", + }, + "this verdict is not in it": { + "fr": "ce verdict n'y figure pas", + "en": "this verdict is not in it", + }, + "the tool output is not in there: it goes to the": { + "fr": "la sortie de l'outil n'y est pas : elle part sur le", + "en": "the tool output is not in there: it goes to the", + }, + "terminal, which a pipe would make full-screen tools": { + "fr": "terminal, qu'un tube ferait renoncer aux outils en", + "en": "terminal, which a pipe would make full-screen tools", + }, + "give up. What precedes is what Odoo was writing.": { + "fr": "plein écran. Ce qui précède est ce qu'Odoo écrivait.", + "en": "give up. What precedes is what Odoo was writing.", + }, + "the checkout is on Odoo": { + "fr": "le checkout est sur Odoo", + "en": "the checkout is on Odoo", + }, + "this database is on": { + "fr": "cette base est en", + "en": "this database is on", + }, + "opening it with the wrong version writes before it fails.": { + "fr": "l'ouvrir avec la mauvaise version y écrit avant d'échouer.", + "en": "opening it with the wrong version writes before it fails.", + }, + "run": { + "fr": "lancer", + "en": "run", + }, + "first?": { + "fr": "d'abord ?", + "en": "first?", + }, + "left as is — the test will refuse and return 2.": { + "fr": "laissé tel quel — le test refusera et rendra 2.", + "en": "left as is — the test will refuse and return 2.", + }, + "run the test anyway?": { + "fr": "lancer le test quand même ?", + "en": "run the test anyway?", + }, # --- Trous dans les manifestes Google Repo --- # --- Type de vue « tree » resté dans un module --- "Is any module still on a pre-18 view type?": { diff --git a/test/test_check_migration_quality.py b/test/test_check_migration_quality.py index af71a94..fce2a73 100644 --- a/test/test_check_migration_quality.py +++ b/test/test_check_migration_quality.py @@ -1832,12 +1832,6 @@ class TestTheThreeExtraSections(Base): lst = qtui.extra_rows([], {}) self.assertIn("verdict-none", [r["kind"] for r in lst]) - def test_every_check_passing_is_stated_not_left_blank(self): - lst = qtui.extra_rows([], {"lst_event": [evenement(status=0)]}) - ok = [r for r in lst if r["kind"] == "verdict-ok"] - self.assertEqual(1, len(ok)) - self.assertIn("1", ok[0]["label"]) - def test_a_failed_verdict_names_its_step_in_the_label(self): # « smoke_public_url » quatre fois de suite ne dit pas lequel a # échoué, et c'est la seule chose qu'on veut savoir. @@ -1852,17 +1846,9 @@ class TestTheThreeExtraSections(Base): self.assertEqual("▶", ligne["detail"]) self.assertTrue(ligne["command"]) - def test_the_header_counts_the_failures(self): - lst = qtui.extra_rows([], {"lst_event": [evenement(), evenement()]}) - self.assertEqual("2", self.premier_entete(lst)["detail"]) - def premier_entete(self, lst): return [r for r in lst if r["kind"] == "header"][0] - def test_a_clean_run_shows_a_tick_not_a_zero(self): - lst = qtui.extra_rows([], {"lst_event": [evenement(status=0)]}) - self.assertEqual("✅", self.premier_entete(lst)["detail"]) - def test_review_steps_target_the_last_database(self): presents = [ {"database": "base_upgrade_17", "exists": True}, @@ -1902,6 +1888,398 @@ class TestTheThreeExtraSections(Base): self.assertEqual("", ligne["command"], ligne["question"]) +class TestEveryVerdictIsListed(Base): + """N'afficher que les échecs répondait à une autre question. + + Devant une base migrée on se demande « qu'a-t-on vérifié », pas + seulement « qu'est-ce qui a raté ». Et c'est la seule façon de voir + qu'un échec du palier 14 a été RATTRAPÉ au 17. + """ + + def journal(self, *statuts): + lst = [] + for rang, statut in enumerate(statuts): + version = 13 + rang + lst.append( + evenement( + status=statut, + detail=( + "./script/odoo/migration/smoke_public_url.py" + " -d base_upgrade_%d" % version + ), + ) + ) + return { + "lst_event": lst, + "config_database_name": "base", + "target_odoo_version": "18.0", + "state_4_upgrade_odoo_lst": [1, 2, 3, 4, 5, 6], + } + + def test_a_passing_verdict_gets_its_own_line(self): + lst = qtui.extra_rows([], self.journal(0, 0)) + lignes = [r for r in lst if r["kind"] == "verdict"] + self.assertEqual(2, len(lignes)) + for ligne in lignes: + self.assertIn("✅", ligne["label"]) + + def test_a_failure_and_a_success_sit_side_by_side(self): + lst = qtui.extra_rows([], self.journal(1, 0)) + icones = [ + "❌" if "❌" in r["label"] else "✅" + for r in lst + if r["kind"] == "verdict" + ] + self.assertEqual(["❌", "✅"], icones) + + def test_the_header_counts_failures_over_the_total(self): + lst = qtui.extra_rows([], self.journal(1, 0, 0)) + entete = [r for r in lst if r["kind"] == "header"][0] + self.assertEqual("1/3", entete["detail"]) + + def test_an_all_green_run_says_how_many(self): + lst = qtui.extra_rows([], self.journal(0, 0, 0)) + entete = [r for r in lst if r["kind"] == "header"][0] + self.assertIn("3", entete["detail"]) + self.assertIn("✅", entete["detail"]) + + def test_the_starting_database_gets_its_real_version(self): + # Elle ne porte pas « _upgrade_ » dans son nom : le palier + # retombait alors sur le compteur du pilote et affichait « 2 ». + dct = { + "lst_event": [ + evenement( + status=0, + step="2 - Update all addons", + detail="./script/odoo/migration/smoke_public_url.py -d base", + ) + ], + "config_database_name": "base", + "target_odoo_version": "18.0", + "state_4_upgrade_odoo_lst": [1, 2, 3, 4, 5, 6], + } + ligne = [ + r for r in qtui.extra_rows([], dct) if r["kind"] == "verdict" + ][0] + self.assertIn("12", ligne["label"]) + self.assertNotIn(" 2 ", ligne["label"]) + + def test_a_dialogue_answer_is_still_not_a_verdict(self): + # Les entrées `command` à 1 sont les réponses du pilote ; les + # lister ferait sept faux échecs par migration. + dct = self.journal(0) + dct["lst_event"].append( + evenement( + kind="command", name="./odoo_bin.sh db --clone", status=1 + ) + ) + lignes = [ + r for r in qtui.extra_rows([], dct) if r["kind"] == "verdict" + ] + self.assertEqual(1, len(lignes)) + + +class TestTheStepLogInThePanel(Base): + """Ce que le journal contient vraiment, dit sans détour.""" + + def setUp(self): + super().setUp() + import shutil + import tempfile + + self.dossier = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.dossier) + + def journal(self, lignes): + chemin = os.path.join(self.dossier, "etape.log") + with io.open(chemin, "w", encoding="utf-8") as handle: + handle.write("\n".join(lignes) + "\n") + return chemin + + def ligne_verdict(self, chemin, **champs): + return { + "kind": "verdict", + "label": "", + "event": evenement(**champs), + "log": chemin, + "command": "script/odoo/migration/smoke_public_url.py -d x", + } + + def test_the_passage_around_the_verdict_is_shown(self): + chemin = self.journal( + [ + "2026-08-26 03:19:00,000 INFO odoo: avant", + "[2026-08-26 03:19:44.166204] $ ./script/odoo/migration/" + "smoke_public_url.py -d test_neutralize_upgrade_14", + "[2026-08-26 03:19:59.846406] -> 1", + "[2026-08-26 03:19:59.847489] [test] smoke_public_url -> 1", + "2026-08-26 03:20:00,000 INFO odoo: après", + ] + ) + texte = qtui.extra_pane(self.ligne_verdict(chemin)) + self.assertIn("INFO odoo: avant", texte) + self.assertIn("[test] smoke_public_url -> 1", texte) + self.assertNotIn("INFO odoo: après", texte) + + def test_it_says_the_tool_output_is_absent(self): + # Sans cela on cherche un fichier plus complet qui n'existe pas : + # ce qui passe par run_on_terminal n'a pas de sortie capturable. + chemin = self.journal( + [ + "[2026-08-26 03:19:44.166204] $ ./script/odoo/migration/" + "smoke_public_url.py -d test_neutralize_upgrade_14", + "[2026-08-26 03:19:59.847489] [test] smoke_public_url -> 1", + ] + ) + texte = qtui.extra_pane(self.ligne_verdict(chemin)) + self.assertIn( + qtui.t("the tool output is not in there: it goes to the"), texte + ) + + def test_the_path_and_the_size_are_documented(self): + chemin = self.journal(["a"] * 40) + texte = qtui.extra_pane(self.ligne_verdict(chemin)) + self.assertIn(chemin, texte) + self.assertIn("40", texte) + + def test_no_log_at_all_says_so(self): + ligne = self.ligne_verdict(None) + ligne["log"] = None + texte = qtui.extra_pane(ligne) + self.assertIn(qtui.t("no log file for this step"), texte) + + def test_a_verdict_absent_from_the_log_says_so(self): + chemin = self.journal(["rien à voir", "vraiment rien"]) + texte = qtui.extra_pane(self.ligne_verdict(chemin)) + self.assertIn(qtui.t("this verdict is not in it"), texte) + + def test_the_tool_is_read_from_the_command_not_from_the_name(self): + # Le nom du test se retrouve dans d'AUTRES lignes du journal — une + # cible make qui le mentionne, par exemple. Chercher le nom au + # lieu du script y accroche la mauvaise commande, et l'extrait + # montre alors un passage qui n'a rien à voir. + chemin = self.journal( + [ + "[2026-08-26 03:19:44.000000] $ ./script/odoo/migration/" + "database_cleanup.py -d test_neutralize_upgrade_14", + "[2026-08-26 03:19:50.000000] [test] database_cleanup -> 0", + "[2026-08-26 03:19:52.000000] $ make database_cleanup_all", + "[2026-08-26 03:19:55.000000] -> 0", + ] + ) + with io.open(chemin, encoding="utf-8") as handle: + brut = handle.read().splitlines() + _extrait, rang = quality.event_excerpt( + brut, + evenement( + name="database_cleanup", + at="2026-08-26 03:19:56.000000", + detail="./script/odoo/migration/database_cleanup.py" + " -d test_neutralize_upgrade_14", + ), + ) + self.assertEqual(0, rang) + + def test_a_verdict_launched_by_no_script_matches_nothing(self): + # Sans « .py » dans la commande on ne sait pas quoi chercher ; + # deviner accrocherait la première ligne venue. + chemin = self.journal( + ["[2026-08-26 03:19:44.000000] $ make quelque_chose"] + ) + with io.open(chemin, encoding="utf-8") as handle: + brut = handle.read().splitlines() + _extrait, rang = quality.event_excerpt( + brut, evenement(name="quelque_chose", detail="make quelque_chose") + ) + self.assertIsNone(rang) + + def test_a_replayed_migration_shows_the_matching_run(self): + # Le même test apparaît plusieurs fois : la dernière occurrence + # peut appartenir à une exécution qui n'est pas celle qu'on + # regarde. On retient celle qui précède l'horodatage du verdict. + chemin = self.journal( + [ + "[2026-08-23 00:43:59.331479] $ ./script/odoo/migration/" + "smoke_public_url.py -d test_neutralize_upgrade_14", + "[2026-08-23 00:44:19.776150] [test] smoke_public_url -> 1", + "premiere execution finie", + "[2026-08-26 03:19:44.166204] $ ./script/odoo/migration/" + "smoke_public_url.py -d test_neutralize_upgrade_14", + "[2026-08-26 03:19:59.847489] [test] smoke_public_url -> 1", + ] + ) + with io.open(chemin, encoding="utf-8") as handle: + brut = handle.read().splitlines() + _extrait, rang = quality.event_excerpt( + brut, evenement(at="2026-08-23 00:44:19.776150") + ) + self.assertEqual(0, rang) + + +class TestKeepingWhatARunWrote(Base): + """Ce que la migration ne pouvait pas garder, l'écran le peut. + + Le pilote lance par `run_on_terminal`, qui n'a pas de sortie + capturable — un tube y ferait renoncer les pleins écrans. Mais ce que + l'on lance SOI-MÊME depuis l'écran, on peut en garder une copie, et + la relire sans relancer. + """ + + def setUp(self): + super().setUp() + import shutil + import tempfile + + self.dossier = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.dossier) + + def fichier(self, contenu): + chemin = os.path.join(self.dossier, "run.log") + with io.open(chemin, "w", encoding="utf-8") as handle: + handle.write(contenu) + return chemin + + def test_one_file_per_screen_line_not_per_run(self): + # Deux exécutions de la même étape se remplacent : on veut « ce + # que ça donne maintenant », pas un historique à trier. + self.assertEqual( + qtui.run_log_path("review_residue"), + qtui.run_log_path("review_residue"), + ) + self.assertNotEqual( + qtui.run_log_path("review_residue"), + qtui.run_log_path("review_state"), + ) + + def test_a_key_that_could_escape_the_directory_cannot(self): + chemin = os.path.realpath(qtui.run_log_path("../../etc/passwd")) + dossier = os.path.realpath( + os.path.join(qtui.REPO_ROOT, qtui.REVIEW_LOG_DIR) + ) + self.assertEqual(dossier, os.path.dirname(chemin)) + self.assertNotIn(os.sep, os.path.basename(chemin)) + + def test_no_key_means_no_file(self): + self.assertIsNone(qtui.run_log_path("")) + + def test_a_missing_file_reads_as_nothing(self): + lst, total = qtui.read_run_log( + os.path.join(self.dossier, "jamais_ecrit.log") + ) + self.assertEqual(([], 0), (lst, total)) + + def test_the_total_is_told_even_when_the_tail_is_kept(self): + # Montrer la fin SANS dire qu'on en cache se lit « il manque des + # lignes ». + chemin = self.fichier("\n".join("l%d" % i for i in range(50))) + lst, total = qtui.read_run_log(chemin, limite=10) + self.assertEqual(10, len(lst)) + self.assertEqual(50, total) + + def test_the_whole_output_is_shown_not_a_sample(self): + # Le panneau DÉFILE : amputer obligerait à relancer dans un + # terminal pour lire la suite. + chemin = self.fichier("\n".join("ligne %d" % i for i in range(300))) + row = { + "kind": "review", + "question": "Does Odoo load the database?", + "command": "x", + "capture": chemin, + } + texte = qtui.extra_pane(row) + self.assertIn("ligne 0", texte) + self.assertIn("ligne 299", texte) + + def test_a_line_never_run_says_so(self): + row = { + "kind": "review", + "question": "Does Odoo load the database?", + "command": "x", + "capture": os.path.join(self.dossier, "absent.log"), + } + self.assertIn(qtui.t("never run from here yet"), qtui.extra_pane(row)) + + def test_a_line_with_nothing_to_run_promises_no_log(self): + row = { + "kind": "review", + "question": "Did the migration reach the end?", + "command": "", + "capture": os.path.join(self.dossier, "absent.log"), + } + self.assertNotIn( + qtui.t("never run from here yet"), qtui.extra_pane(row) + ) + + def test_the_repl_step_is_not_captured(self): + # Le passer par un tube lui ferait perdre son invite. + lignes = qtui.rows([snapshot(odoo="18.0")], {}) + shell = [ + row + for row in lignes + if row["kind"] == "review" + and "shell" in (row.get("command") or "") + ] + self.assertTrue(shell) + self.assertIsNone(shell[0].get("capture")) + + def test_every_other_runnable_step_is_captured(self): + lignes = qtui.rows([snapshot(odoo="18.0")], {}) + lancables = [ + row + for row in lignes + if row["kind"] == "review" and row.get("command") + ] + self.assertTrue(lancables) + sans = [r for r in lancables if not r.get("capture")] + self.assertEqual(1, len(sans), [r.get("command") for r in sans]) + + +class TestSwitchingTheCheckoutFirst(Base): + """Lancer un outil sur une base d'un autre palier ÉCRIT dedans.""" + + def dct(self): + return { + "config_database_name": "base", + "target_odoo_version": "18.0", + "state_4_upgrade_odoo_lst": [1, 2, 3, 4, 5, 6], + } + + def test_the_same_version_needs_no_switch(self): + courante = quality.checkout_version() + self.assertIsNone( + qtui.switch_needed("base_upgrade_%d" % courante, self.dct()) + ) + + def test_another_tier_names_its_make_target(self): + courante = quality.checkout_version() + autre = 16 if courante != 16 else 15 + besoin = qtui.switch_needed("base_upgrade_%d" % autre, self.dct()) + self.assertEqual((autre, "switch_odoo_%d" % autre), besoin) + + def test_a_database_outside_the_chain_asks_for_nothing(self): + # On ne sait pas à quel palier elle est : proposer une bascule au + # hasard basculerait le checkout pour rien. + self.assertIsNone(qtui.switch_needed("une_autre_base", self.dct())) + + def test_the_panel_warns_before_the_key_not_after(self): + courante = quality.checkout_version() + autre = 16 if courante != 16 else 15 + ligne = { + "kind": "verdict", + "label": "", + "event": evenement(), + "log": None, + "command": "script/odoo/migration/smoke_public_url.py -d x", + "switch": (autre, "switch_odoo_%d" % autre), + } + texte = qtui.extra_pane(ligne) + self.assertIn("switch_odoo_%d" % autre, texte) + self.assertLess( + texte.index("switch_odoo_%d" % autre), + texte.index(qtui.t("press r to run it again")), + ) + + class TestTheVerdictPanel(Base): def panneau(self, **champs): lst = qtui.extra_rows([], {"lst_event": [evenement(**champs)]}) diff --git a/test/test_monitoring.py b/test/test_monitoring.py index c7375b7..cd952ff 100644 --- a/test/test_monitoring.py +++ b/test/test_monitoring.py @@ -450,6 +450,135 @@ def verdict(**champs): return brut +class TestTheStepLogInTheReport(unittest.TestCase): + """La commande seule ne dit pas POURQUOI.""" + + def setUp(self): + import json + import shutil + import tempfile + + self.dossier = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.dossier) + self.json = json + self.progression = os.path.join(self.dossier, "progression.json") + # `step_log_path` cherche sous private/odoo/migration//step_log + self.ancien = residue.status.PATH_MIGRATION_PRIVATE + residue.status.PATH_MIGRATION_PRIVATE = os.path.join( + self.dossier, "private" + ) + self.addCleanup( + setattr, + residue.status, + "PATH_MIGRATION_PRIVATE", + self.ancien, + ) + + def ecrire_progression(self, evenements): + with io.open(self.progression, "w", encoding="utf-8") as handle: + self.json.dump( + { + "lst_event": evenements, + "config_database_name": "test_neutralize", + "target_odoo_version": "18.0", + "state_4_upgrade_odoo_lst": [1, 2, 3, 4, 5, 6], + }, + handle, + ) + return self.progression + + def ecrire_journal(self, step, lignes): + dossier = os.path.join( + residue.status.PATH_MIGRATION_PRIVATE, + "test_neutralize", + "step_log", + ) + os.makedirs(dossier, exist_ok=True) + chemin = os.path.join(dossier, residue.status.step_slug(step) + ".log") + with io.open(chemin, "w", encoding="utf-8") as handle: + handle.write("\n".join(lignes) + "\n") + return chemin + + def bloc(self, **kw): + return "\n".join( + residue.verdicts_block( + "test_neutralize_upgrade_14", False, self.progression, **kw + ) + ) + + def test_the_passage_around_the_failure_is_shown(self): + self.ecrire_progression([verdict()]) + self.ecrire_journal( + "4.1.I - Migrate database", + [ + "odoo: ce qui precedait", + "[2026-08-26 03:19:44.166204] $ .venv.erplibre/bin/python3" + " ./script/odoo/migration/smoke_public_url.py" + " -d test_neutralize_upgrade_14 --internal-required", + "[2026-08-26 03:19:59.847489] [test] smoke_public_url -> 1", + "odoo: ce qui suivait", + ], + ) + texte = self.bloc() + self.assertIn("odoo: ce qui precedait", texte) + self.assertIn("[test] smoke_public_url -> 1", texte) + self.assertNotIn("odoo: ce qui suivait", texte) + + def test_the_log_path_is_named(self): + self.ecrire_progression([verdict()]) + chemin = self.ecrire_journal( + "4.1.I - Migrate database", + [ + "[2026-08-26 03:19:44.166204] $ ./script/odoo/migration/" + "smoke_public_url.py -d test_neutralize_upgrade_14", + "[2026-08-26 03:19:59.847489] [test] smoke_public_url -> 1", + ], + ) + self.assertIn(chemin, self.bloc()) + + def test_no_step_log_stays_silent(self): + # Une ligne « pas de journal » par verdict noierait ceux qui + # comptent. + self.ecrire_progression([verdict()]) + texte = self.bloc() + self.assertIn("smoke_public_url", texte) + self.assertNotIn("step_log", texte) + + def test_asking_for_none_shows_none(self): + self.ecrire_progression([verdict()]) + self.ecrire_journal( + "4.1.I - Migrate database", + [ + "[2026-08-26 03:19:44.166204] $ ./script/odoo/migration/" + "smoke_public_url.py -d test_neutralize_upgrade_14", + "[2026-08-26 03:19:59.847489] [test] smoke_public_url -> 1", + ], + ) + self.assertNotIn("[test]", self.bloc(lignes_avant=0)) + + def test_it_says_the_tool_output_is_elsewhere(self): + # Sans cela on cherche dans le journal une sortie qui n'y a + # jamais été écrite. + self.ecrire_progression([verdict()]) + self.assertIn( + residue.t("the tool output is not in the step log: it goes"), + self.bloc(), + ) + + def test_an_all_green_run_does_not_carry_that_warning(self): + self.ecrire_progression([verdict(status=0)]) + self.assertNotIn( + residue.t("the tool output is not in the step log: it goes"), + self.bloc(), + ) + + def test_the_step_shown_is_the_odoo_version(self): + # « 4.1.I » est le compteur du pilote ; la migration en est au 14. + self.ecrire_progression([verdict()]) + texte = self.bloc() + self.assertIn("14 smoke_public_url", texte) + + class TestTheControlsThemselves(unittest.TestCase): """Un contrôle retiré ne doit pas survivre dans les tests. @@ -464,7 +593,8 @@ class TestTheControlsThemselves(unittest.TestCase): import re connues = {c["key"] for c in residue.CONTROLES} - source = io.open(__file__, encoding="utf-8").read() + with io.open(__file__, encoding="utf-8") as handle: + source = handle.read() nommees = set(re.findall(r'resultats\["([a-z_]+)"\]', source)) nommees |= set(re.findall(r"dict\(vide, ([a-z_]+)=", source)) self.assertTrue(nommees) From f6aa2575c309cb8775195ca9097b6d3bee60cc80 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 27 Aug 2026 05:54:24 -0400 Subject: [PATCH 17/22] [ADD] migration : garder la sortie des tests, par pseudo-terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le journal d'étape notait la commande et son code de retour, jamais ce qu'elle avait écrit : l'écran d'analyse ne pouvait rien montrer d'un échec. Un tube aurait capturé et changé le programme — smoke_public_url appelle can_ask(), qui exige stdin ET stdout sur un terminal, et derrière un tube il cesse en silence d'offrir la réparation des vues COW. Un pseudo-terminal lève le dilemme : l'enfant voit un vrai terminal, la réponse tapée lui parvient, le code de retour survit. Neuf exécutions y passent, dont check_hidden_models, qui tournait sans verdict retenu ; deux restent dehors, pty.spawn naît en 0×0 et un plein écran s'y perdrait. --- EN --- The step log recorded the command and its exit code, never what it wrote: the analysis screen could show nothing of a failure. A pipe would have captured and changed the program — smoke_public_url calls can_ask(), which requires stdin AND stdout to be terminals, and behind a pipe it silently stops offering the COW view repair. A pty settles it: the child sees a real terminal, a typed answer reaches it, the exit code survives. Nine runs go through it, including check_hidden_models, which ran with no verdict recorded; two stay out, pty.spawn starts at 0×0 and a full-screen app would lay out on nothing. Assisted-by: Claude Opus 5 (cherry picked from commit 81e9227502a0d16e6409338cf958495a5f8e3827) --- script/analyse/check_migration_quality.py | 22 ++ script/analyse/check_migration_quality_tui.py | 41 +-- script/analyse/check_migration_residue.py | 25 +- script/todo/todo_i18n.py | 4 + script/todo/todo_upgrade.py | 137 ++++++- test/test_check_migration_quality.py | 54 +++ test/test_check_stale_scss.py | 38 +- test/test_database_cleanup.py | 19 +- test/test_error_retry_loop.py | 6 +- test/test_migration_status.py | 2 +- test/test_monitoring.py | 21 ++ test/test_reset_stale_cow_prompt.py | 2 +- test/test_smoke_public_url.py | 4 +- test/test_theme_on_error.py | 9 +- test/test_todo_upgrade_capture.py | 348 ++++++++++++++++++ test/test_uninstall_addons_theme.py | 13 +- 16 files changed, 660 insertions(+), 85 deletions(-) create mode 100644 test/test_todo_upgrade_capture.py diff --git a/script/analyse/check_migration_quality.py b/script/analyse/check_migration_quality.py index 1d750bb..3d7b51b 100755 --- a/script/analyse/check_migration_quality.py +++ b/script/analyse/check_migration_quality.py @@ -244,6 +244,28 @@ def event_excerpt(lignes, event, avant=18): return lignes[max(0, rang - avant) : fin + 1], rang +def excerpt_has_output(extrait, rang_relatif): + """La sortie de l'outil est-elle DANS l'extrait, ou seulement le cadre ? + + Avant que le pilote ne capture, le journal ne portait que « $ … » + suivi de « -> code », sans une ligne entre les deux. Le dire était + juste ; le dire encore quand la sortie est là serait un mensonge, et + le panneau enverrait chercher ailleurs ce qu'il a sous les yeux. + """ + for ligne in extrait[rang_relatif + 1 :]: + depouille = ligne.strip() + if not depouille: + continue + if MARQUEUR_COMMANDE in ligne: + continue + if depouille.startswith("-> ") or depouille.startswith("[test] "): + continue + if "] -> " in ligne or "] [test] " in ligne: + continue + return True + return False + + def event_database(event): """La base sur laquelle ce verdict portait, lue dans sa commande.""" for mot in event.get("detail", "").split(): diff --git a/script/analyse/check_migration_quality_tui.py b/script/analyse/check_migration_quality_tui.py index 691b42e..e0fcf57 100644 --- a/script/analyse/check_migration_quality_tui.py +++ b/script/analyse/check_migration_quality_tui.py @@ -372,7 +372,7 @@ def log_excerpt_lines(row, colour=False): brut = handle.read().splitlines() except OSError: return [] - extrait, _rang = quality.event_excerpt(brut, row.get("event") or {}) + extrait, rang = quality.event_excerpt(brut, row.get("event") or {}) lignes = [ status.paint(f" ── {t('step log')} ──", "step", colour), status.paint(f" {chemin}", "dim", colour), @@ -386,28 +386,23 @@ def log_excerpt_lines(row, colour=False): for ligne in extrait: lignes.append(f" {ligne[:150]}") lignes.append("") - lignes.append( - status.paint( - f" {t('the tool output is not in there: it goes to the')}", - "dim", - colour, - ) - ) - lignes.append( - status.paint( - f" {t('terminal, which a pipe would make full-screen tools')}", - "dim", - colour, - ) - ) - lignes.append( - status.paint( - f" {t('give up. What precedes is what Odoo was writing.')}", - "dim", - colour, - ) - ) - lignes.append("") + # Ne le dire que si c'est vrai : depuis que le pilote capture, la + # sortie EST là, et répéter qu'elle manque enverrait la chercher + # ailleurs alors qu'on l'a sous les yeux. + rang_relatif = None + if rang is not None: + debut = max(0, rang - 18) + rang_relatif = rang - debut + if rang_relatif is None or not quality.excerpt_has_output( + extrait, rang_relatif + ): + for phrase in ( + "the tool output is not in there: it goes to the", + "terminal, which a pipe would make full-screen tools", + "give up. What precedes is what Odoo was writing.", + ): + lignes.append(status.paint(f" {t(phrase)}", "dim", colour)) + lignes.append("") return lignes diff --git a/script/analyse/check_migration_residue.py b/script/analyse/check_migration_residue.py index ebda974..0de9d8b 100755 --- a/script/analyse/check_migration_residue.py +++ b/script/analyse/check_migration_residue.py @@ -287,7 +287,7 @@ def verdicts(database, path=None): def extrait_du_journal(dct, event, colour=True, avant=6): - """Ce que le journal d'étape montre autour de ce verdict. + """(lignes, la sortie de l'outil y est-elle) autour de ce verdict. La commande seule ne dit pas POURQUOI. Le journal, lui, garde ce qu'Odoo écrivait au moment du test — et c'est tout ce qu'on a : la @@ -300,19 +300,23 @@ def extrait_du_journal(dct, event, colour=True, avant=6): """ chemin = status.step_log_path(dct, event.get("step")) if not chemin or avant <= 0: - return [] + return [], False try: with open(chemin, "r", encoding="utf-8", errors="replace") as handle: brut = handle.read().splitlines() except OSError: - return [] - extrait, _rang = quality.event_excerpt(brut, event, avant=avant) + return [], False + extrait, rang = quality.event_excerpt(brut, event, avant=avant) if not extrait: - return [] + return [], False lignes = [paint(f" {chemin}", "dim", colour)] for ligne in extrait: lignes.append(paint(f" {ligne[:150]}", "dim", colour)) - return lignes + debut = max(0, rang - avant) if rang is not None else 0 + avec_sortie = rang is not None and quality.excerpt_has_output( + extrait, rang - debut + ) + return lignes, avec_sortie def verdicts_block(database, colour=True, path=None, lignes_avant=6): @@ -341,6 +345,7 @@ def verdicts_block(database, colour=True, path=None, lignes_avant=6): ) ) dct = quality.read_progression(chemin) + sortie_presente = False for event in ratés: version = quality.version_of(quality.event_database(event), dct) palier = str(version) if version else quality.event_step(event) @@ -354,7 +359,11 @@ def verdicts_block(database, colour=True, path=None, lignes_avant=6): lignes.append( paint(f" {event['detail'][:120]}", "dim", colour) ) - lignes.extend(extrait_du_journal(dct, event, colour, lignes_avant)) + bloc, avec_sortie = extrait_du_journal( + dct, event, colour, lignes_avant + ) + sortie_presente = sortie_presente or avec_sortie + lignes.extend(bloc) lignes.append("") lignes.append( paint( @@ -364,7 +373,7 @@ def verdicts_block(database, colour=True, path=None, lignes_avant=6): colour, ) ) - if ratés and lignes_avant > 0: + if ratés and lignes_avant > 0 and not sortie_presente: lignes.append( paint( f" {t('the tool output is not in the step log: it goes')}" diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 8d6ef65..f1a9f1b 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -6979,6 +6979,10 @@ TRANSLATIONS = { }, # --- Écran de qualité : Verdicts, Validation, Revue --- # --- Journal d'étape et bascule de version --- + "capture unavailable": { + "fr": "capture indisponible", + "en": "capture unavailable", + }, "last run": { "fr": "dernière exécution", "en": "last run", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index 908099a..e9ed49e 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -1813,7 +1813,7 @@ class TodoUpgrade: # la décision se prend sur le code de retour — et la capture faisait # annoncer « Command returned error code: 1 » sur un rapport qui va # bien. 1 veut dire « des copies casseront », pas « l'outil a raté ». - status = self.run_on_terminal( + status = self.run_captured( f"{PYTHON_BIN} ./script/odoo/migration/check_cow_views.py" f" -d {database_name} -t odoo{start_version + 1}.0" ) @@ -1837,7 +1837,9 @@ class TodoUpgrade: # Appliquer depuis là lève « KeyError: 'web_editor.assets' » — c'est # arrivé sur une vraie migration, et l'échec est passé pour un # succès. La correction est proposée après le palier, plus bas. - self.run_on_terminal( + # « --report-only » éteint l'invite de l'outil : rien ici + # n'attend de réponse, tout est bon à garder. + self.run_captured( f"{PYTHON_BIN} ./script/odoo/migration/check_stale_scss.py" f" -d {database_name} -t odoo{start_version + 1}.0 --report-only" ) @@ -2761,7 +2763,7 @@ class TodoUpgrade: # L'outil ne fait rien s'il n'y a pas de DMS, et rien non # plus s'il a déjà réparé : le rejouer est sans effet. if next_version == 13: - self.run_on_terminal( + self.run_captured( f"{PYTHON_BIN}" " ./script/odoo/migration/dms_access_repair.py" f" -d {database_name_upgrade} --apply" @@ -2798,10 +2800,11 @@ class TodoUpgrade: # c'est vrai aussi. Pourtant plus personne n'atteint les # données. Mesuré sur DMS au palier 13 : 69 fichiers et # 23 Mo intacts, zéro visible, pour tous les utilisateurs. - self.run_on_terminal( + self.run_tool( + "check_hidden_models", f"{PYTHON_BIN}" " ./script/odoo/migration/check_hidden_models.py" - f" -d {database_name_upgrade}" + f" -d {database_name_upgrade}", ) print(f"[y] {t('Open the server with Selenium')}") @@ -2961,7 +2964,7 @@ class TodoUpgrade: # Le thème parti, la commande mérite un nouvel essai — # et `repare` autorise le rejeu automatique borné. for theme in themes: - self.run_on_terminal( + self.run_captured( f"./script/addons/uninstall_addons_theme.sh" f" {database_name} {theme}" ) @@ -3348,13 +3351,14 @@ class TodoUpgrade: print(f"ℹ -> {t('Kept. Nothing was uninstalled.')}") return for theme in lst_theme: - # Sur le VRAI terminal : ce script finit par theme_leftover.py, - # qui pose une question. L'exécuteur capture la sortie par un - # tube — Python la met alors en tampon par blocs — et l'invite - # restait invisible pendant que le processus attendait une - # réponse. On tape Entrée à l'aveugle, plusieurs fois, et les - # frappes en trop vont à la question suivante. - self.run_on_terminal( + # Ce script finit par theme_leftover.py, qui pose une + # question. Un TUBE la rendrait invisible — Python met alors + # sa sortie en tampon par blocs — et l'on taperait Entrée à + # l'aveugle, les frappes en trop allant à la question + # suivante. Le pseudo-terminal de `run_captured` n'a pas ce + # défaut : l'invite s'affiche comme sur un terminal, et l'on + # garde tout de même le journal de la désinstallation. + self.run_captured( f"./script/addons/uninstall_addons_theme.sh" f" {database_name} {theme}" ) @@ -3536,13 +3540,106 @@ class TodoUpgrade: cmd += " --tui" self.run_on_terminal(cmd) - def run_on_terminal(self, cmd): - """Lance une commande en lui laissant le VRAI terminal. + # Les séquences ANSI d'un outil qui se croit — à juste titre — sur un + # terminal. Elles n'ont aucun sens dans un fichier qu'on relira, et + # une seule d'entre elles suffit à rendre l'extrait illisible. + RE_ANSI = re.compile(r"\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07]*\x07") - `todo_upgrade_execute` capture la sortie par un tube. Un plein écran - y voit un stdout qui n'est pas un terminal, renonce, et retombe sur - son rapport texte : « w » réaffichait mot pour mot ce que « v » - venait de montrer, sans rien signaler. + def run_captured(self, cmd): + """Lancer en gardant la sortie, SANS que la commande s'en aperçoive. + + Un tube aurait suffi à capturer — et aurait changé le programme. + `smoke_public_url` demande `can_ask()`, qui exige stdin ET stdout + sur un terminal (smoke_public_url.py:63-72) : derrière un tube il + cesse d'offrir la réparation des vues COW, en silence. C'est + exactement la classe de régression que `run_on_terminal` évitait + en ne capturant rien du tout. + + Un pseudo-terminal lève le dilemme : l'enfant voit un vrai + terminal — mesuré, `isatty()` rend True des deux côtés — la + réponse tapée lui parvient, son code de sortie est intact, et la + sortie passe tout de même entre nos mains avant d'aller à l'écran. + + Ce qui est écrit dans le journal est nettoyé : les séquences ANSI + retirées, les secrets caviardés par le MÊME filtre que + `exec_command_live`, et les fins de ligne du terminal ramenées à + « \n ». Le terminal, lui, reçoit les octets tels quels. + """ + import pty + + self.lst_command_executed.append(cmd) + self.dct_progression["command_executed"] = self.lst_command_executed + self.write_config() + print(f"\n🏠 ⬇ {t('Execute command')} :\n") + print(cmd) + self.note_step_log(f"$ {cmd}") + + handle = getattr(self, "step_log", None) + if not handle: + # Sans journal ouvert il n'y a rien à gagner et un pty à + # payer : on retombe sur le chemin ordinaire. + status = subprocess.call(cmd, shell=True, executable="/bin/bash") + self.note_step_log(f" -> {status}") + return status + + reste = bytearray() + + def ecrire(morceau, dernier=False): + """Journaliser par LIGNES ENTIÈRES. + + Une séquence ANSI coupée entre deux lectures laisserait ses + débris dans le fichier ; on n'écrit donc qu'une fois la ligne + complète, et le reliquat part à la fermeture. + """ + reste.extend(morceau) + *lignes, tail = bytes(reste).split(b"\n") + del reste[:] + if dernier and tail: + lignes.append(tail) + else: + reste.extend(tail) + for ligne in lignes: + texte = ligne.decode("utf-8", "replace").replace("\r", "") + try: + handle.write( + execute.redact_secrets(self.RE_ANSI.sub("", texte)) + + "\n" + ) + except Exception: + return + + def lire(fd): + morceau = os.read(fd, 4096) + if morceau: + ecrire(morceau) + return morceau + + try: + brut = pty.spawn(["/bin/bash", "-c", cmd], lire) + status = os.waitstatus_to_exitcode(brut) + except Exception as exc: # noqa: BLE001 - un pty peut manquer + # Jamais au prix de l'exécution : mieux vaut la commande sans + # son journal que pas de commande du tout. + print(f"⚠ {t('capture unavailable')} : {exc}") + status = subprocess.call(cmd, shell=True, executable="/bin/bash") + else: + ecrire(b"", dernier=True) + self.note_step_log(f" -> {status}") + return status + + def run_on_terminal(self, cmd): + """Le terminal, tel quel, sans rien garder — pour les PLEINS ÉCRANS. + + C'est devenu l'exception : `run_captured` garde la sortie sans que + la commande s'en aperçoive, et presque tout passe par lui. Deux + appels restent ici, et pour une raison mesurée. + + `pty.spawn` crée son terminal en 0×0, et l'enfant lit sa taille + AVANT que le premier octet ne nous parvienne — donc avant qu'on + puisse la corriger. Vérifié : un programme lancé ainsi voit + « columns=0, lines=0 ». Sans conséquence pour un outil qui écrit + des lignes — aucun de ceux qu'on capture ne lit la largeur — mais + une application Textual s'y disposerait sur rien. Rien ne relit cette sortie — elle est REGARDÉE. Et le code de retour d'un afficheur ne veut pas dire « erreur » : 1 signifie « il y a des @@ -4524,7 +4621,7 @@ class TodoUpgrade: exactement ce qu'on veut relire plus tard. Sans cela, « le test de fumée est-il passé ? » n'a pas de réponse une heure après. """ - status = self.run_on_terminal(cmd) + status = self.run_captured(cmd) self.record_event("test", name, status, cmd) return status diff --git a/test/test_check_migration_quality.py b/test/test_check_migration_quality.py index fce2a73..7074e5c 100644 --- a/test/test_check_migration_quality.py +++ b/test/test_check_migration_quality.py @@ -2042,6 +2042,60 @@ class TestTheStepLogInThePanel(Base): self.assertIn(chemin, texte) self.assertIn("40", texte) + def test_it_stays_quiet_once_the_output_is_actually_there(self): + # Le pilote capture désormais. Répéter que la sortie manque + # enverrait la chercher ailleurs alors qu'on l'a sous les yeux. + chemin = self.journal( + [ + "[2026-08-26 03:19:44.166204] $ ./script/odoo/migration/" + "smoke_public_url.py -d test_neutralize_upgrade_14", + "⧖ Démarrage d'Odoo…", + "❌ 3 URL sur 37 rendent 500", + "[2026-08-26 03:19:59.847489] [test] smoke_public_url -> 1", + ] + ) + texte = qtui.extra_pane(self.ligne_verdict(chemin)) + self.assertIn("3 URL sur 37", texte) + self.assertNotIn( + qtui.t("the tool output is not in there: it goes to the"), texte + ) + + def test_the_frame_alone_is_not_mistaken_for_output(self): + # « $ … » puis « -> 1 » : c'est le cadre, pas la sortie. + extrait = [ + "[2026-08-26 03:19:44.166204] $ ./script/x.py -d base", + "[2026-08-26 03:19:59.846406] -> 1", + "[2026-08-26 03:19:59.847489] [test] smoke_public_url -> 1", + ] + self.assertFalse(quality.excerpt_has_output(extrait, 0)) + + def test_one_line_of_output_is_enough(self): + extrait = [ + "[2026-08-26 03:19:44.166204] $ ./script/x.py -d base", + "une seule ligne dite par l'outil", + "[2026-08-26 03:19:59.846406] -> 1", + ] + self.assertTrue(quality.excerpt_has_output(extrait, 0)) + + def test_blank_lines_are_not_output(self): + extrait = [ + "[2026-08-26 03:19:44.166204] $ ./script/x.py -d base", + "", + " ", + "[2026-08-26 03:19:59.846406] -> 1", + ] + self.assertFalse(quality.excerpt_has_output(extrait, 0)) + + def test_what_came_before_the_command_is_not_its_output(self): + # Le contexte d'Odoo précède la commande ; le compter dirait + # « la sortie est là » sur tous les anciens journaux. + extrait = [ + "odoo: ce qui tournait avant", + "[2026-08-26 03:19:44.166204] $ ./script/x.py -d base", + "[2026-08-26 03:19:59.846406] -> 1", + ] + self.assertFalse(quality.excerpt_has_output(extrait, 1)) + def test_no_log_at_all_says_so(self): ligne = self.ligne_verdict(None) ligne["log"] = None diff --git a/test/test_check_stale_scss.py b/test/test_check_stale_scss.py index f301e5a..f0d70d9 100755 --- a/test/test_check_stale_scss.py +++ b/test/test_check_stale_scss.py @@ -285,22 +285,38 @@ class TestTheMigrationRunsIt(unittest.TestCase): source.index("4 - Upgrade version with OpenUpgrade"), ) - def test_it_gets_a_real_terminal(self): - # L'outil pose lui-même ses questions et peut ouvrir un plein écran : - # un tube les rendrait toutes injoignables, sans rien signaler. + def test_no_call_goes_through_the_piped_executor(self): + # L'outil pose lui-même ses questions : un TUBE les rendrait + # injoignables, sans rien signaler — Python met sa sortie en + # tampon par blocs et l'invite reste invisible. + # + # Les DEUX voies terminal conviennent : `run_on_terminal` laisse + # le vrai terminal, `run_captured` en fabrique un et garde une + # copie. Seul `todo_upgrade_execute` bufferise. import inspect from script.todo.todo_upgrade import TodoUpgrade source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade) - # On ne compare pas à une mise en forme — black la change — mais à - # l'appel le plus proche EN AMONT : c'est lui qui exécute. - avant = source[: source.index("check_stale_scss.py")] - self.assertGreater( - avant.rfind("run_on_terminal("), - avant.rfind("todo_upgrade_execute("), - "l'outil repasse par l'exécuteur à tube", - ) + depart = 0 + vus = 0 + while True: + rang = source.find("check_stale_scss.py", depart) + if rang < 0: + break + vus += 1 + avant = source[:rang] + # L'appel le plus proche EN AMONT : c'est lui qui exécute. + terminal = max( + avant.rfind("run_on_terminal("), avant.rfind("run_captured(") + ) + self.assertGreater( + terminal, + avant.rfind("todo_upgrade_execute("), + "l'outil repasse par l'exécuteur à tube", + ) + depart = rang + 1 + self.assertEqual(2, vus) class TestTheFixCannotRunTooEarly(unittest.TestCase): diff --git a/test/test_database_cleanup.py b/test/test_database_cleanup.py index b6767a1..a97003e 100755 --- a/test/test_database_cleanup.py +++ b/test/test_database_cleanup.py @@ -771,15 +771,14 @@ class TestTheMigrationRunsItBeforeTheSmokeTest(unittest.TestCase): from script.todo.todo_upgrade import TodoUpgrade source = inspect.getsource(TodoUpgrade.prompt_database_cleanup) - # `run_tool` retient le verdict PUIS délègue à `run_on_terminal` : - # les deux propriétés comptent, et vérifier la seconde à la source - # évite qu'un raccourci futur reprenne l'exécuteur qui capture. self.assertIn("run_tool(", source) self.assertNotIn("todo_upgrade_execute", source) - self.assertIn( - "self.run_on_terminal(", - inspect.getsource(TodoUpgrade.run_tool), - ) + capture = inspect.getsource(TodoUpgrade.run_captured) + self.assertIn("run_captured", inspect.getsource(TodoUpgrade.run_tool)) + # Un pseudo-terminal, jamais un tube : derrière un tube + # `can_ask()` rend False et l'outil cesse de poser sa question. + self.assertIn("pty.spawn", capture) + self.assertNotIn("subprocess.PIPE", capture) def test_saying_no_cleans_nothing(self): # Le défaut ne retire pas le choix : il ne fait qu'en proposer un. @@ -798,7 +797,7 @@ class TestTheMigrationRunsItBeforeTheSmokeTest(unittest.TestCase): upgrade.lst_command_executed = [] upgrade.write_config = lambda: None lst_cmd = [] - upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0 + upgrade.run_captured = lambda cmd: lst_cmd.append(cmd) or 0 upgrade.ask_gate = lambda prompt, default="": "n" or default with contextlib.redirect_stdout(io.StringIO()): upgrade.prompt_database_cleanup("db") @@ -818,7 +817,7 @@ class TestTheMigrationRunsItBeforeTheSmokeTest(unittest.TestCase): upgrade.lst_command_executed = [] upgrade.write_config = lambda: None lst_cmd = [] - upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0 + upgrade.run_captured = lambda cmd: lst_cmd.append(cmd) or 0 upgrade.ask_gate = lambda prompt, default="": "" or default with contextlib.redirect_stdout(io.StringIO()): upgrade.prompt_database_cleanup("db") @@ -833,7 +832,7 @@ class TestTheMigrationRunsItBeforeTheSmokeTest(unittest.TestCase): upgrade.lst_command_executed = [] upgrade.write_config = lambda: None lst_cmd = [] - upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0 + upgrade.run_captured = lambda cmd: lst_cmd.append(cmd) or 0 upgrade.ask_gate = lambda prompt, default="": "y" or default upgrade.prompt_database_cleanup("db_upgrade_18") self.assertEqual(len(lst_cmd), 1) diff --git a/test/test_error_retry_loop.py b/test/test_error_retry_loop.py index 4729f13..3613bea 100644 --- a/test/test_error_retry_loop.py +++ b/test/test_error_retry_loop.py @@ -85,7 +85,7 @@ class Harness(unittest.TestCase): obj.prompt_reset_stale_cow_views = faux_reset obj.check_stale_cow_views = lambda db: None - obj.run_on_terminal = lambda cmd: 0 + obj.run_captured = lambda cmd: 0 reponses = iter(lst_answer or []) def faux_ask(prompt, default=""): @@ -233,7 +233,7 @@ class TestTheResetReportsWhatItDid(unittest.TestCase): obj.write_config = lambda: None obj.stale_cow_keys = lambda db: ["web.layout"] obj.ask_gate = lambda prompt, default="": default - obj.run_on_terminal = lambda cmd: 0 + obj.run_captured = lambda cmd: 0 with redirect_stdout(io.StringIO()): self.assertTrue(obj.prompt_reset_stale_cow_views("db")) @@ -245,7 +245,7 @@ class TestTheResetReportsWhatItDid(unittest.TestCase): obj.write_config = lambda: None obj.stale_cow_keys = lambda db: ["web.layout"] obj.ask_gate = lambda prompt, default="": default - obj.run_on_terminal = lambda cmd: 2 + obj.run_captured = lambda cmd: 2 with redirect_stdout(io.StringIO()): self.assertFalse(obj.prompt_reset_stale_cow_views("db")) diff --git a/test/test_migration_status.py b/test/test_migration_status.py index 62c486c..cb963e7 100644 --- a/test/test_migration_status.py +++ b/test/test_migration_status.py @@ -1559,7 +1559,7 @@ class TestWhatGetsRecorded(Base): def test_a_tool_run_keeps_its_verdict(self): obj = self.upgrade() - obj.run_on_terminal = lambda cmd: 1 + obj.run_captured = lambda cmd: 1 self.assertEqual(obj.run_tool("smoke_public_url", "cmd"), 1) event = obj.dct_progression["lst_event"][0] self.assertEqual(event["kind"], "test") diff --git a/test/test_monitoring.py b/test/test_monitoring.py index cd952ff..ab3dacc 100644 --- a/test/test_monitoring.py +++ b/test/test_monitoring.py @@ -556,6 +556,27 @@ class TestTheStepLogInTheReport(unittest.TestCase): ) self.assertNotIn("[test]", self.bloc(lignes_avant=0)) + def test_the_report_stops_saying_it_once_the_output_is_captured(self): + # Depuis que le pilote capture par pseudo-terminal, la sortie est + # dans le journal : le dire encore serait faux. + self.ecrire_progression([verdict()]) + self.ecrire_journal( + "4.1.I - Migrate database", + [ + "[2026-08-26 03:19:44.166204] $ .venv.erplibre/bin/python3" + " ./script/odoo/migration/smoke_public_url.py" + " -d test_neutralize_upgrade_14 --internal-required", + "❌ 3 URL sur 37 rendent 500", + "[2026-08-26 03:19:59.847489] [test] smoke_public_url -> 1", + ], + ) + texte = self.bloc() + self.assertIn("3 URL sur 37", texte) + self.assertNotIn( + residue.t("the tool output is not in the step log: it goes"), + texte, + ) + def test_it_says_the_tool_output_is_elsewhere(self): # Sans cela on cherche dans le journal une sortie qui n'y a # jamais été écrite. diff --git a/test/test_reset_stale_cow_prompt.py b/test/test_reset_stale_cow_prompt.py index c12fd8f..1639cde 100755 --- a/test/test_reset_stale_cow_prompt.py +++ b/test/test_reset_stale_cow_prompt.py @@ -44,7 +44,7 @@ class PromptCase(unittest.TestCase): upgrade.write_config = lambda: None upgrade.stale_cow_keys = lambda db: lst_key lst_cmd = [] - upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0 + upgrade.run_captured = lambda cmd: lst_cmd.append(cmd) or 0 # Le doublon HONORE le défaut, comme le vrai `ask_gate` : sinon # les tests de défaut ne testeraient que le doublon. upgrade.ask_gate = lambda prompt, default="": answer or default diff --git a/test/test_smoke_public_url.py b/test/test_smoke_public_url.py index 3c0977c..3eea61b 100755 --- a/test/test_smoke_public_url.py +++ b/test/test_smoke_public_url.py @@ -433,7 +433,7 @@ class TestTheMigrationOffersIt(unittest.TestCase): upgrade.lst_command_executed = [] upgrade.write_config = lambda: None lst_cmd = [] - upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0 + upgrade.run_captured = lambda cmd: lst_cmd.append(cmd) or 0 # Le doublon HONORE le défaut, comme le vrai `ask_gate`. upgrade.ask_gate = lambda prompt, default="": answer or default upgrade.prompt_smoke_public_url("db_upgrade_13") @@ -502,7 +502,7 @@ class TestTheMigrationOffersIt(unittest.TestCase): upgrade.dct_progression = {} upgrade.lst_command_executed = [] upgrade.write_config = lambda: None - upgrade.run_on_terminal = lambda cmd: 0 + upgrade.run_captured = lambda cmd: 0 upgrade.ask_gate = lambda prompt, default="": "n" out = io.StringIO() with contextlib.redirect_stdout(out): diff --git a/test/test_theme_on_error.py b/test/test_theme_on_error.py index 1b61111..ee97f67 100644 --- a/test/test_theme_on_error.py +++ b/test/test_theme_on_error.py @@ -169,10 +169,17 @@ class TestTheMenu(unittest.TestCase): def test_it_goes_through_the_real_terminal(self): # `uninstall_addons_theme.sh` finit par poser une question ; un # tube la rendrait invisible et l'on répondrait à l'aveugle. + # `run_captured` fabrique un pseudo-terminal : l'invite s'affiche + # comme sur un vrai, et le journal de désinstallation est gardé. bloc = self.source() debut = bloc.index('if wait_status == "6"') fin = bloc.index('if wait_status == "5"') - self.assertIn("run_on_terminal", bloc[debut:fin]) + fenetre = bloc[debut:fin] + self.assertTrue( + "run_captured" in fenetre or "run_on_terminal" in fenetre, + fenetre[:120], + ) + self.assertNotIn("todo_upgrade_execute", fenetre) def test_the_label_is_translated(self): self.assertIn( diff --git a/test/test_todo_upgrade_capture.py b/test/test_todo_upgrade_capture.py new file mode 100644 index 0000000..65cc94d --- /dev/null +++ b/test/test_todo_upgrade_capture.py @@ -0,0 +1,348 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Capturer la sortie d'un test SANS que le test s'en aperçoive. + +Le journal d'étape gardait la commande et son code, jamais ce que la +commande avait écrit. Mesuré sur une vraie migration : « $ … +smoke_public_url.py … » suivi IMMÉDIATEMENT de « -> 1 », zéro ligne entre +les deux, trois fois de suite. L'écran d'analyse ne pouvait donc rien +montrer de ce qui avait échoué. + +Un tube aurait suffi à capturer, et aurait changé le programme : +`smoke_public_url` appelle `can_ask()`, qui exige stdin ET stdout sur un +terminal (smoke_public_url.py:63-72). Derrière un tube il cesse d'offrir +la réparation des vues COW — en silence, ce qui est le pire. + +D'où le pseudo-terminal. Ce fichier vérifie les deux moitiés de la +promesse : que la sortie arrive dans le journal, et que l'enfant continue +de voir un vrai terminal. +""" + +import io +import os +import shutil +import sys +import tempfile +import unittest +from contextlib import redirect_stdout + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, REPO) + +from script.todo.todo_upgrade import TodoUpgrade # noqa: E402 + + +class Pilote: + """Le strict nécessaire : `run_captured` ne touche à rien d'autre. + + Instancier le vrai pilote demanderait une configuration, une base et + un dépôt Odoo. La méthode, elle, n'a besoin que d'un journal ouvert et + de deux listes — c'est ce qui la rend testable. + """ + + RE_ANSI = TodoUpgrade.RE_ANSI + run_captured = TodoUpgrade.run_captured + + def __init__(self, handle): + self.step_log = handle + self.lst_command_executed = [] + self.dct_progression = {} + self.notes = [] + + def write_config(self): + pass + + def note_step_log(self, texte): + self.notes.append(texte) + if self.step_log: + self.step_log.write(texte + "\n") + + +class Base(unittest.TestCase): + def setUp(self): + self.dossier = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.dossier) + self.chemin = os.path.join(self.dossier, "etape.log") + + def outil(self, source): + chemin = os.path.join(self.dossier, "outil.py") + with io.open(chemin, "w", encoding="utf-8") as handle: + handle.write(source) + return f"{sys.executable} {chemin}" + + def lancer(self, cmd, avec_journal=True): + muet = os.open(os.devnull, os.O_WRONLY) + garde = os.dup(1) + os.dup2(muet, 1) + try: + return self._lancer(cmd, avec_journal) + finally: + os.dup2(garde, 1) + os.close(garde) + os.close(muet) + + def _lancer(self, cmd, avec_journal=True): + tampon = io.StringIO() + if not avec_journal: + pilote = Pilote(None) + with redirect_stdout(tampon): + code = pilote.run_captured(cmd) + return code, "", pilote + with io.open( + self.chemin, "w", encoding="utf-8", buffering=1 + ) as handle: + pilote = Pilote(handle) + with redirect_stdout(tampon): + code = pilote.run_captured(cmd) + with io.open(self.chemin, encoding="utf-8") as handle: + return code, handle.read(), pilote + + +class TestTheChildStillSeesATerminal(Base): + """La moitié qui n'a l'air de rien, et qui décide de tout.""" + + def test_stdout_is_a_terminal_for_the_child(self): + # Derrière un tube, `can_ask()` rend False et le test de fumée + # cesse d'offrir la réparation, sans le dire. + _code, journal, _p = self.lancer( + self.outil( + "import sys\nprint('T' if sys.stdout.isatty() else 'F')\n" + ) + ) + self.assertIn("\nT\n", "\n" + journal) + + def test_stdin_is_a_terminal_for_the_child(self): + _code, journal, _p = self.lancer( + self.outil( + "import sys\nprint('T' if sys.stdin.isatty() else 'F')\n" + ) + ) + self.assertIn("\nT\n", "\n" + journal) + + def test_the_exit_code_survives(self): + # C'est lui qui devient le verdict : 0 rien, 1 des trouvailles, + # 2 l'outil a échoué. Le perdre viderait `record_event` de sens. + code, _journal, _p = self.lancer( + self.outil("import sys\nsys.exit(4)\n") + ) + self.assertEqual(4, code) + + def test_a_zero_stays_a_zero(self): + code, _journal, _p = self.lancer(self.outil("pass\n")) + self.assertEqual(0, code) + + +class TestWhatTheLogKeeps(Base): + def test_the_output_is_there(self): + _code, journal, _p = self.lancer( + self.outil("print('la ligne qui manquait')\n") + ) + self.assertIn("la ligne qui manquait", journal) + + def test_the_command_and_its_code_frame_it(self): + _code, journal, _p = self.lancer(self.outil("print('milieu')\n")) + lignes = journal.splitlines() + self.assertTrue(lignes[0].startswith("$ ")) + self.assertIn("milieu", journal) + self.assertTrue(lignes[-1].strip().startswith("-> ")) + + def test_colour_is_stripped_from_the_file(self): + # L'enfant se croit — à juste titre — sur un terminal, donc il + # colorie. Une seule séquence suffit à rendre l'extrait illisible. + _code, journal, _p = self.lancer( + self.outil("print('\\x1b[31mrouge\\x1b[0m nu')\n") + ) + self.assertIn("rouge nu", journal) + self.assertNotIn("\x1b", journal) + + def test_a_sequence_split_across_two_reads_leaves_no_debris(self): + # On n'écrit qu'une fois la ligne complète : autrement les débris + # d'une séquence coupée restent dans le fichier. + source = ( + "import sys, time\n" + "sys.stdout.write('\\x1b[3')\n" + "sys.stdout.flush()\n" + "time.sleep(0.2)\n" + "sys.stdout.write('1mtardif\\x1b[0m\\n')\n" + "sys.stdout.flush()\n" + ) + _code, journal, _p = self.lancer(self.outil(source)) + self.assertIn("tardif", journal) + self.assertNotIn("\x1b", journal) + + def test_secrets_go_through_the_same_filter_as_everything_else(self): + _code, journal, _p = self.lancer( + self.outil("print('outil --db_password TRESSECRET -d base')\n") + ) + self.assertNotIn("TRESSECRET", journal) + self.assertIn("--db_password", journal) + + def test_the_last_line_without_a_newline_is_not_lost(self): + _code, journal, _p = self.lancer( + self.outil( + "import sys\nsys.stdout.write('sans saut final')\n" + "sys.stdout.flush()\n" + ) + ) + self.assertIn("sans saut final", journal) + + def test_carriage_returns_do_not_survive(self): + # Un terminal en pose à chaque ligne ; dans un fichier ils + # produisent des lignes qui s'écrasent à la lecture. + _code, journal, _p = self.lancer(self.outil("print('normale')\n")) + self.assertNotIn("\r", journal) + + +class TestWithoutAnOpenLog(Base): + """Sans journal, rien à gagner et un pty à payer.""" + + def test_the_command_still_runs_and_returns_its_code(self): + code, _journal, _p = self.lancer( + self.outil("import sys\nsys.exit(5)\n"), avec_journal=False + ) + self.assertEqual(5, code) + + def test_no_terminal_is_fabricated_when_there_is_nothing_to_keep(self): + """Sans journal, `run_captured` doit valoir `run_on_terminal`. + + Fabriquer un pseudo-terminal changerait ce que la commande voit — + couleur, invites, mise en page — pour un journal qui n'existe pas. + L'enfant écrit son verdict dans un fichier À CÔTÉ, car sa sortie + standard est précisément ce qu'on est en train de mesurer. + """ + temoin = os.path.join(self.dossier, "temoin.txt") + cmd = self.outil( + "import io, sys\n" + f"io.open({temoin!r}, 'w').write(str(sys.stdout.isatty()))\n" + ) + self.lancer(cmd, avec_journal=False) + with io.open(temoin, encoding="utf-8") as handle: + self.assertEqual("False", handle.read()) + + def test_a_terminal_IS_fabricated_when_there_is_a_log(self): + # Le pendant : c'est ce qui permet à l'outil de poser sa question. + temoin = os.path.join(self.dossier, "temoin2.txt") + cmd = self.outil( + "import io, sys\n" + f"io.open({temoin!r}, 'w').write(str(sys.stdout.isatty()))\n" + ) + self.lancer(cmd, avec_journal=True) + with io.open(temoin, encoding="utf-8") as handle: + self.assertEqual("True", handle.read()) + + def test_the_command_is_still_recorded(self): + _code, _journal, pilote = self.lancer( + self.outil("pass\n"), avec_journal=False + ) + self.assertEqual(1, len(pilote.lst_command_executed)) + self.assertEqual( + pilote.lst_command_executed, + pilote.dct_progression["command_executed"], + ) + + +class TestTheDriverUsesIt(unittest.TestCase): + """Le câblage : quels appels passent par la capture, et lesquels non.""" + + def setUp(self): + import inspect + + self.source = inspect.getsource(TodoUpgrade) + + def test_every_tool_verdict_goes_through_the_capture(self): + import inspect + + run_tool = inspect.getsource(TodoUpgrade.run_tool) + self.assertIn("run_captured", run_tool) + self.assertNotIn("run_on_terminal", run_tool) + + def test_the_hidden_models_check_is_a_recorded_verdict(self): + # Il suit la même convention de code de sortie que les autres et + # tournait pourtant sans que rien ne retienne sa conclusion. + avant = self.source.index("check_hidden_models.py") + debut = self.source.rindex("self.run_", 0, avant) + self.assertTrue( + self.source[debut:avant].startswith("self.run_tool("), + self.source[debut:avant][:60], + ) + + def test_only_full_screen_paths_stay_outside_the_capture(self): + """La frontière, épinglée par ce qu'elle laisse dehors. + + Elle a une raison mesurée : `pty.spawn` crée son terminal en 0×0 + et l'enfant lit sa taille avant le premier octet. Sans effet sur + un outil qui écrit des lignes ; fatal pour une application qui se + dispose. Tout le reste doit donc être capturé. + """ + import re + + lignes = self.source.split("\n") + dehors = [] + for rang, ligne in enumerate(lignes): + if "self.run_on_terminal(" not in ligne: + continue + suite = " ".join(x.strip() for x in lignes[rang : rang + 7]) + trouve = re.search(r"(\w+\.(?:py|sh))", suite) + if trouve: + dehors.append((trouve.group(1), "--tui" in suite)) + self.assertTrue(dehors, "plus aucun appel : la garde ne garde rien") + for outil, tui in dehors: + # Soit il demande explicitement un plein écran, soit son + # invite peut en ouvrir un — check_stale_scss le fait sur « w ». + self.assertTrue( + tui or outil == "check_stale_scss.py", + f"{outil} n'est pas un plein écran : il devrait être capturé", + ) + + def test_the_tools_captured_do_not_read_the_terminal_width(self): + # C'est ce qui rend le 0×0 sans conséquence pour eux. Si l'un s'y + # mettait, sa mise en page partirait sur une largeur nulle. + import glob + + for chemin in glob.glob( + os.path.join(REPO, "script", "odoo", "migration", "*.py") + ): + if os.path.basename(chemin) in ( + "check_stale_scss.py", + "reset_stale_cow_views.py", + ): + continue + with io.open(chemin, encoding="utf-8") as handle: + source = handle.read() + if "run_captured" in source: + continue + self.assertNotIn( + "get_terminal_size", source, os.path.basename(chemin) + ) + + def test_the_full_screen_path_still_captures_nothing(self): + # C'est sa raison d'être : un plein écran derrière un tube + # renonce et retombe sur son rapport texte. + import inspect + + import ast + + import textwrap + + arbre = ast.parse( + textwrap.dedent(inspect.getsource(TodoUpgrade.run_on_terminal)) + ) + corps = arbre.body[0].body + if isinstance(corps[0], ast.Expr) and isinstance( + corps[0].value, ast.Constant + ): + corps = corps[1:] + code = "\n".join(ast.unparse(noeud) for noeud in corps) + self.assertIn("subprocess.call", code) + self.assertNotIn("pty", code) + + def test_the_viewer_of_cow_copies_keeps_its_full_screen(self): + avant = self.source.index("cow_drift.py") + suite = self.source[avant : avant + 600] + self.assertIn("self.run_on_terminal(cmd)", suite) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_uninstall_addons_theme.py b/test/test_uninstall_addons_theme.py index af5ffa3..b95778f 100755 --- a/test/test_uninstall_addons_theme.py +++ b/test/test_uninstall_addons_theme.py @@ -156,7 +156,7 @@ class TestTheMigrationOffersIt(unittest.TestCase): )[1] # Le désinstalleur ne passe PLUS par l'exécuteur qui capture : il # pose une question, et un tube la rendrait invisible. - obj.run_on_terminal = lambda cmd: self.lst_cmd.append(cmd) or 0 + obj.run_captured = lambda cmd: self.lst_cmd.append(cmd) or 0 # Le doublon HONORE le défaut, comme le vrai `ask_gate` : sinon # les tests de défaut ne testeraient que le doublon. obj.ask_gate = lambda prompt, default="": answer or default @@ -347,7 +347,10 @@ class TestTheQuestionMustBeVisible(unittest.TestCase): from script.todo.todo_upgrade import TodoUpgrade source = inspect.getsource(TodoUpgrade.prompt_uninstall_theme) - self.assertIn("run_on_terminal", source) + # Un pseudo-terminal convient : l'invite reste visible, et la + # sortie est gardée pour l'écran d'analyse. Seul le tube de + # `todo_upgrade_execute` la rendrait muette. + self.assertIn("run_captured", source) self.assertNotIn("todo_upgrade_execute", source) def test_asking_needs_stdout_not_just_stdin(self): @@ -458,10 +461,10 @@ class TestTheMisleadingErrorCode(unittest.TestCase): source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade) avant = source[: source.index("check_cow_views.py")] - self.assertGreater( - avant.rfind("run_on_terminal("), - avant.rfind("todo_upgrade_execute("), + terminal = max( + avant.rfind("run_on_terminal("), avant.rfind("run_captured(") ) + self.assertGreater(terminal, avant.rfind("todo_upgrade_execute(")) class TestExitCodes(unittest.TestCase): From 7ba3c6504c066eff690488ac6953097d895d1aeb Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 27 Aug 2026 06:26:26 -0400 Subject: [PATCH 18/22] =?UTF-8?q?[UPD]=20invites=20:=2015=20secondes=20pou?= =?UTF-8?q?r=20d=C3=A9cider,=20et=20le=20fichier=20nomme=20la=20base?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cinq secondes ne suffisaient pas à LIRE la question. Le compte à rebours n'existe pas pour aller vite mais pour qu'on puisse s'absenter ; trop court, il fait l'inverse — on répond par réflexe, ou l'on subit un défaut non lu. Et « test » ne disait rien de ce qu'on migrait : des migrations successives portaient toutes le même nom, quand le fichier de sauvegarde en porte déjà un parlant. Le nom est assaini — il finit dans un createdb — et borné à 41 caractères : le pilote ajoute « _neutralize_upgrade_18 » et PostgreSQL tronque à 63, ce qui ferait finir deux paliers sur le même nom. Un téléchargement distant garde celui que le serveur a donné. --- EN --- Five seconds were not enough to READ the question. The countdown does not exist to go fast but so you can step away; too short, it does the opposite — you answer by reflex, or you get a default you never read. And "test" said nothing about what was being migrated: successive migrations all carried the same name, when the backup file already carries a telling one. The name is sanitised — it ends up in a createdb — and capped at 41 characters: the driver appends "_neutralize_upgrade_18" and PostgreSQL truncates at 63, which would land two steps on one name. A remote download keeps the name the server gave. Assisted-by: Claude Opus 5 (cherry picked from commit dad0f6b0ef4551898fe2964e177ff5c1c888b5dc) --- script/todo/auto_ask.py | 2 +- script/todo/todo_upgrade.py | 40 +++++++++++++ test/test_prompt_defaults.py | 105 +++++++++++++++++++++++++++++++++++ 3 files changed, 146 insertions(+), 1 deletion(-) diff --git a/script/todo/auto_ask.py b/script/todo/auto_ask.py index 19c02ce..1750dd1 100644 --- a/script/todo/auto_ask.py +++ b/script/todo/auto_ask.py @@ -37,7 +37,7 @@ except Exception: # pragma: no cover - repli si i18n indisponible ENV_ENABLED = "ERPLIBRE_AUTO_EXECUTE" ENV_DELAY = "ERPLIBRE_AUTO_DELAY" -DEFAULT_DELAY = 5 +DEFAULT_DELAY = 15 def enabled(): diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index e9ed49e..235bb54 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -164,6 +164,39 @@ class MigrationRewind(Exception): """ +# Le pilote suffixe le nom choisi : « _neutralize » à l'étape 1, puis +# « _upgrade_ » à chaque palier. PostgreSQL tronque un +# identifiant à 63 octets — deux paliers finiraient alors sur le MÊME +# nom, et le second écraserait le premier sans rien dire. +SUFFIXE_MAX = len("_neutralize_upgrade_18") +NOM_BASE_MAX = 63 - SUFFIXE_MAX + + +def database_name_from_file(chemin, defaut="test", limite=NOM_BASE_MAX): + """Le nom de base que suggère le fichier de sauvegarde. + + « test » ne disait rien de ce qu'on migrait : trois migrations de + suite portaient le même nom, et retrouver laquelle avait échoué + demandait de relire le journal. Le fichier, lui, porte déjà le nom du + client — c'est la seule information qu'on ait à ce moment-là. + + Le nom est ASSAINI, car il finit dans un `createdb` et dans des noms + de fichiers : minuscules, et tout ce qui n'est ni lettre ni chiffre + devient un « _ ». Un nom qui commence par un chiffre reçoit un + préfixe — PostgreSQL refuse un identifiant nu qui en commence un. + """ + nom = os.path.basename(chemin or "") + if nom.lower().endswith(".zip"): + nom = nom[: -len(".zip")] + nom = re.sub(r"[^a-z0-9]+", "_", nom.lower()) + nom = re.sub(r"_{2,}", "_", nom).strip("_") + if not nom: + return defaut + if nom[0].isdigit(): + nom = "db_" + nom + return nom[:limite].rstrip("_") or defaut + + class TodoUpgrade: def __init__(self, todo): self.file_path = None @@ -1370,6 +1403,13 @@ class TodoUpgrade: self.dct_progression["migration_file"] = self.file_path self.write_config() + # Le téléchargement distant a déjà nommé la base — le serveur sait + # mieux que le nom du fichier. Sinon, c'est le fichier qui parle. + if default_database_name == "test" and self.file_path: + default_database_name = database_name_from_file( + self.file_path, default_database_name + ) + print(f"✅ {t('Open file')} {self.file_path}") with zipfile.ZipFile(self.file_path, "r") as zip_ref: manifest_file_1 = zip_ref.open("manifest.json") diff --git a/test/test_prompt_defaults.py b/test/test_prompt_defaults.py index b2e9a22..e466206 100644 --- a/test/test_prompt_defaults.py +++ b/test/test_prompt_defaults.py @@ -224,6 +224,111 @@ class TestTheCountdownSpeaksThePromptsLanguage(EnvCase): self.assertIn("Entrée", texte) +class TestHowLongTheCountdownLasts(EnvCase): + """Cinq secondes ne suffisaient pas à LIRE la question. + + Le compte à rebours n'existe pas pour aller vite : il existe pour + qu'on puisse s'absenter. Trop court, il fait l'inverse — on répond par + réflexe, ou l'on subit un défaut qu'on n'a pas eu le temps de lire. + """ + + def test_the_default_leaves_time_to_read_and_decide(self): + self.assertGreaterEqual(auto_ask.DEFAULT_DELAY, 15) + + def test_the_environment_still_wins(self): + # C'est le seul canal qui traverse un fork : une valeur posée par + # le pilote doit primer sur celle écrite ici. + os.environ[auto_ask.ENV_DELAY] = "3" + self.assertEqual(3, auto_ask.delay()) + + +class TestTheDatabaseNameTheFileSuggests(unittest.TestCase): + """« test » ne disait rien de ce qu'on migrait. + + Trois migrations de suite portaient le même nom, et retrouver + laquelle avait échoué demandait de relire le journal. Le fichier de + sauvegarde porte déjà le nom du client — c'est la seule information + disponible au moment où la question se pose. + """ + + def nom(self, chemin, **kw): + from script.todo.todo_upgrade import database_name_from_file + + return database_name_from_file(chemin, **kw) + + def test_the_zip_extension_goes_away(self): + self.assertEqual("chezlepro3", self.nom("image_db/chezlepro3.zip")) + + def test_an_uppercase_extension_goes_away_too(self): + self.assertEqual("client", self.nom("image_db/CLIENT.ZIP")) + + def test_a_real_backup_name_survives_whole(self): + self.assertEqual( + "technolibre_2026_08_26_02h41m23s", + self.nom("image_db/technolibre_2026-08-26_02h41m23s.zip"), + ) + + def test_a_file_without_extension_still_names_a_base(self): + # Une sauvegarde peut être posée sans « .zip » ; elle s'ouvre + # quand même comme une archive. + self.assertEqual("technolibre", self.nom("image_db/technolibre")) + + def test_spaces_and_punctuation_become_underscores(self): + # Le nom finit dans un createdb et dans des noms de fichiers. + self.assertEqual( + "client_final_2026", self.nom("image_db/Client Final (2026).zip") + ) + + def test_it_never_starts_with_a_digit(self): + # PostgreSQL refuse un identifiant nu qui commence par un chiffre. + nom = self.nom("image_db/2026-backup.zip") + self.assertFalse(nom[0].isdigit(), nom) + + def test_nothing_usable_falls_back_to_the_default(self): + for chemin in ("", None, "image_db/.zip", "image_db/---.zip"): + self.assertEqual("test", self.nom(chemin)) + + def test_a_long_name_leaves_room_for_the_suffixes(self): + # Le pilote ajoute « _neutralize » puis « _upgrade_ ». + # PostgreSQL tronque à 63 octets : sans marge, deux paliers + # finiraient sur le MÊME nom et le second écraserait le premier. + from script.todo.todo_upgrade import database_name_from_file + + nom = database_name_from_file("image_db/" + "a" * 90 + ".zip") + complet = nom + "_neutralize_upgrade_18" + self.assertLessEqual(len(complet), 63, complet) + + def test_it_does_not_end_on_a_separator(self): + nom = self.nom("image_db/" + "a" * 40 + "-suite.zip") + self.assertFalse(nom.endswith("_"), nom) + + +class TestTheMigrationUsesThatName(unittest.TestCase): + def test_the_prompt_offers_what_the_file_suggests(self): + import inspect + + from script.todo.todo_upgrade import TodoUpgrade + + source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade) + pose = source.index("database_name_from_file(") + invite = source.index("Which database name do you want to work with?") + # Il faut le calculer AVANT de le proposer. + self.assertLess(pose, invite) + + def test_a_remote_download_keeps_the_name_the_server_gave(self): + # Le serveur sait mieux que le nom du fichier téléchargé. + import inspect + + from script.todo.todo_upgrade import TodoUpgrade + + source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade) + fenetre = source[ + source.index("database_name_from_file(") + - 400 : source.index("database_name_from_file(") + ] + self.assertIn('default_database_name == "test"', fenetre) + + class TestTheQuestionThatCannotAnswerItself(EnvCase): """Celle-là engage toutes les suivantes : elle ne se prend pas seule. From b7d68cd199b150569d6c390f768c8b5dd5ae2820 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 27 Aug 2026 16:29:00 -0400 Subject: [PATCH 19/22] =?UTF-8?q?[FIX]=20odoo=5Fbin=20:=20lire=20le=20conf?= =?UTF-8?q?ig.conf=20du=20d=C3=A9p=C3=B4t,=20pas=20le=20~/.odoorc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit « odoo_bin.sh db --drop » échoue par AccessDenied à chaque palier de migration, et le clone bute ensuite sur « database already exists ». db_restore.py lit ./config.conf, y voit « admin_passwd = admin » et n'envoie donc aucun mot de passe ; odoo_bin.sh ne passait pas de « -c », donc Odoo lisait ~/.odoorc, qui porte un mot de passe haché. ODOO_RC ferme la couture en un point plutôt qu'à vingt sites d'appel : les versions 12 à 18 le lisent après « -c » et avant ~/.odoorc, donc un choix explicite l'emporte. Vérifié : le drop passe, la précédence est testée. --- EN --- "odoo_bin.sh db --drop" fails with AccessDenied at every migration step, and the clone then hits "database already exists". db_restore.py reads ./config.conf, sees "admin_passwd = admin" and so sends no master password; odoo_bin.sh passed no "-c", so Odoo read ~/.odoorc, which carries a hashed password. ODOO_RC closes the seam in one place instead of twenty call sites: versions 12 to 18 read it after "-c" and before ~/.odoorc, so an explicit choice wins. Verified: the drop succeeds, and a test covers precedence. Assisted-by: Claude Opus 5 (cherry picked from commit 9503d80982b725034513b610b527f29d49025381) --- odoo_bin.sh | 5 ++ script/lib_odoo_rc.sh | 38 +++++++++++++ test/test_odoo_rc.py | 129 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 172 insertions(+) create mode 100755 script/lib_odoo_rc.sh create mode 100644 test/test_odoo_rc.py diff --git a/odoo_bin.sh b/odoo_bin.sh index 12aeb29..bd7a53a 100755 --- a/odoo_bin.sh +++ b/odoo_bin.sh @@ -2,6 +2,11 @@ # This is required to change environment for the running Odoo source ./.venv.$(< .erplibre-version)/bin/activate +# Le config.conf du DÉPÔT, pas le ~/.odoorc de qui lance. Un « -c » +# explicite l'emporte toujours : Odoo lit opt.config avant ODOO_RC. +source ./script/lib_odoo_rc.sh +odoo_rc_resolve "$(pwd)" + ODOO_PATH="$(pwd)/odoo$(< .odoo-version)" #export PATH=$ODOO_PATH:$PATH #echo $PATH diff --git a/script/lib_odoo_rc.sh b/script/lib_odoo_rc.sh new file mode 100755 index 0000000..ecd575f --- /dev/null +++ b/script/lib_odoo_rc.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +# +# Quel fichier de configuration Odoo doit lire, quand personne ne le dit. +# +# Sans cela, Odoo retombe sur ~/.odoorc — un fichier PERSONNEL, hors du +# dépôt, que rien ne synchronise avec `config.conf`. Mesuré : un ~/.odoorc +# portant un mot de passe maître haché faisait échouer +# « odoo_bin.sh db --drop » par AccessDenied, alors que db_restore.py +# venait de lire `admin_passwd = admin` dans config.conf et d'en conclure +# qu'aucun mot de passe n'était nécessaire. Les deux avaient raison : ils +# ne parlaient pas du même fichier. +# +# La précédence d'Odoo est la même en 12 et en 18 (tools/config.py) : +# +# self.config_file or opt.config or ODOO_RC or OPENERP_SERVER or ~/.odoorc +# +# Poser ODOO_RC ne retire donc rien à personne : un « -c » explicite +# l'emporte toujours, et un ODOO_RC déjà posé n'est pas écrasé. +# +# L'ordre des candidats est celui de db_restore.py, pour que la +# vérification qu'il fait porte sur le fichier qu'Odoo lira vraiment. + +odoo_rc_resolve() { + if [[ -n "${ODOO_RC:-}" ]]; then + return 0 + fi + local racine="${1:-$(pwd)}" + local candidat + for candidat in "${racine}/config.conf" /etc/odoo/odoo.conf; do + if [[ -f "${candidat}" ]]; then + export ODOO_RC="${candidat}" + return 0 + fi + done + return 0 +} diff --git a/test/test_odoo_rc.py b/test/test_odoo_rc.py new file mode 100644 index 0000000..1529ddb --- /dev/null +++ b/test/test_odoo_rc.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Quel fichier de configuration Odoo lit, quand personne ne le dit. + +Sans ODOO_RC, Odoo retombe sur `~/.odoorc` — un fichier PERSONNEL que +rien ne synchronise avec le `config.conf` du dépôt. Mesuré sur une vraie +migration : un `~/.odoorc` portant un mot de passe maître haché faisait +échouer « odoo_bin.sh db --drop » par AccessDenied, alors que +`db_restore.py` venait de lire « admin_passwd = admin » dans config.conf +et d'en conclure qu'aucun mot de passe n'était nécessaire. + +Les deux avaient raison. Ils ne parlaient pas du même fichier — et rien, +nulle part, ne les confrontait. Le drop échouait, le clone butait ensuite +sur « database already exists », et la boucle de reprise faisait passer +les deux pour un accident. Huit fois par migration. +""" + +import io +import os +import subprocess +import unittest + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +LIB = os.path.join(REPO, "script", "lib_odoo_rc.sh") + + +class Base(unittest.TestCase): + def resoudre(self, racine, environ=None): + """Ce que la résolution pose dans ODOO_RC, pour cette racine.""" + env = dict(os.environ) + env.pop("ODOO_RC", None) + env.update(environ or {}) + done = subprocess.run( + [ + "bash", + "-c", + f'source {LIB!r}; odoo_rc_resolve "$1"; echo "${{ODOO_RC:-}}"', + "bash", + racine, + ], + capture_output=True, + text=True, + env=env, + ) + self.assertEqual(0, done.returncode, done.stderr) + return done.stdout.strip() + + +class TestWhichFileIsChosen(Base): + def setUp(self): + import shutil + import tempfile + + self.dossier = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.dossier) + + def config(self): + chemin = os.path.join(self.dossier, "config.conf") + with io.open(chemin, "w", encoding="utf-8") as handle: + handle.write("[options]\nadmin_passwd = admin\n") + return chemin + + def test_the_repository_config_wins_over_the_personal_one(self): + # C'est tout le propos : ~/.odoorc n'est plus consulté. + self.assertEqual(self.config(), self.resoudre(self.dossier)) + + def test_an_explicit_choice_is_never_overridden(self): + # Odoo lit ODOO_RC avant ~/.odoorc, et « -c » avant ODOO_RC : + # écraser une valeur posée exprès retirerait ce choix. + self.config() + self.assertEqual( + "/ailleurs/odoo.conf", + self.resoudre(self.dossier, {"ODOO_RC": "/ailleurs/odoo.conf"}), + ) + + def test_without_a_repository_config_nothing_is_forced(self): + # Sur une machine sans config.conf ni /etc/odoo/odoo.conf, on + # laisse Odoo faire ce qu'il a toujours fait. + vide = self.resoudre(self.dossier) + self.assertIn(vide, ("", "/etc/odoo/odoo.conf")) + + def test_it_never_fails_the_command_that_sourced_it(self): + # Elle est appelée au tout début d'odoo_bin.sh : y échouer + # empêcherait de lancer Odoo, pour une question de configuration. + done = subprocess.run( + ["bash", "-c", f"source {LIB!r}; odoo_rc_resolve /inexistant"], + capture_output=True, + text=True, + env={k: v for k, v in os.environ.items() if k != "ODOO_RC"}, + ) + self.assertEqual(0, done.returncode, done.stderr) + + +class TestTheEntryPointUsesIt(unittest.TestCase): + def source(self, nom): + with io.open(os.path.join(REPO, nom), encoding="utf-8") as handle: + return handle.read() + + def test_odoo_bin_resolves_before_running_odoo(self): + source = self.source("odoo_bin.sh") + self.assertIn("odoo_rc_resolve", source) + self.assertLess( + source.index("odoo_rc_resolve"), source.index("odoo-bin") + ) + + def test_the_order_matches_what_db_restore_checks(self): + # `db_restore.py` lit ./config.conf puis /etc/odoo/odoo.conf pour + # décider s'il faut un mot de passe maître. Si Odoo en lisait un + # autre, cette vérification ne vérifierait rien. + lib = self.source(os.path.join("script", "lib_odoo_rc.sh")) + restore = self.source( + os.path.join("script", "database", "db_restore.py") + ) + for candidat in ("config.conf", "/etc/odoo/odoo.conf"): + self.assertIn(candidat, lib, candidat) + self.assertIn(candidat, restore, candidat) + self.assertLess( + lib.index("config.conf"), lib.index("/etc/odoo/odoo.conf") + ) + self.assertLess( + restore.index("config.conf"), + restore.index("/etc/odoo/odoo.conf"), + ) + + +if __name__ == "__main__": + unittest.main() From 809273eb345e6e569220c31b8fc6bed8a5119d78 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 28 Aug 2026 01:09:24 -0400 Subject: [PATCH 20/22] =?UTF-8?q?[FIX]=20verdicts=20de=20migration=20:=20d?= =?UTF-8?q?istinguer=20trouvaille=20et=20=C3=A9chec=20d'outil?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'écran peignait en échec tout code non nul, quand la convention écrite dans todo_upgrade.run_tool dit : 0 rien à signaler, 1 des trouvailles, 2 l'outil a échoué. database_cleanup imprime lui-même « This is a warning, not a failure » avant de rendre 1. Un écran qui contredit l'outil apprend à ignorer les deux : du rouge signalait une migration en échec là où rien n'avait échoué. L'icône rejoint la couleur dans migration_status, et le panneau écrit le sens du chiffre à côté de lui : « statut 1 (des trouvailles) ». Testé. --- EN --- The screen painted every non-zero code as a failure, where the convention written in todo_upgrade.run_tool says: 0 nothing to report, 1 findings, 2 the tool failed. database_cleanup itself prints "This is a warning, not a failure" before returning 1. A screen that contradicts the tool teaches you to ignore both: red marked a migration as failed where nothing had failed. The icon now lives beside the colour in migration_status, and the panel spells the number out: "status 1 (findings)". Covered by tests. Assisted-by: Claude Opus 5 (cherry picked from commit 39d122965e7a09d710255fff061a7526a4077aaa) --- script/analyse/check_migration_quality_tui.py | 23 +++++--- script/analyse/check_migration_residue.py | 5 +- script/todo/migration_status.py | 18 ++++++ script/todo/todo_i18n.py | 8 +++ test/test_check_migration_quality.py | 58 ++++++++++++++++++- test/test_migration_status.py | 35 +++++++++++ 6 files changed, 135 insertions(+), 12 deletions(-) diff --git a/script/analyse/check_migration_quality_tui.py b/script/analyse/check_migration_quality_tui.py index e0fcf57..23a0df0 100644 --- a/script/analyse/check_migration_quality_tui.py +++ b/script/analyse/check_migration_quality_tui.py @@ -160,7 +160,7 @@ def extra_rows(presents, dct=None): # dans son nom — « test_neutralize » ne dit pas 12.0. version = quality.version_of(base, dct) palier = str(version) if version else quality.event_step(event, base) - icone = "❌" if event["status"] else "✅" + icone, _teinte = status.verdict_mark(event["status"]) lst.append( { "kind": "verdict", @@ -349,6 +349,17 @@ def extra_pane(row, colour=False): return "" +# Ce que le chiffre veut dire, pour TOUS les outils — la convention est +# écrite dans todo_upgrade.run_tool. Le sens PROPRE à chaque outil, lui, +# se dit plus bas : « 1 » n'a pas la même gravité pour un test de fumée +# que pour un nettoyage. +SENS_DU_CODE = { + 0: "nothing to report", + 1: "findings", + 2: "the tool failed", +} + + def log_excerpt_lines(row, colour=False): """Le passage du journal d'étape qui entoure ce verdict. @@ -427,12 +438,9 @@ def verdict_pane(row, colour=False): """Ce qu'un verdict dit, ce que son code signifie, et ce qu'on en lit.""" event = row.get("event") or {} rate = bool(event.get("status")) + icone, teinte = status.verdict_mark(event.get("status")) lignes = [ - status.paint( - f"{'❌' if rate else '✅'} {event.get('name')}", - "fail" if rate else "ok", - colour, - ), + status.paint(f"{icone} {event.get('name')}", teinte, colour), "", # Le palier est la version d'ODOO, pas le compteur du pilote : # « 4.1.I » désigne la première étape du quatrième bloc, et la @@ -441,7 +449,8 @@ def verdict_pane(row, colour=False): f" {t('step'):<10} {quality.event_step(event)}" f" ({event.get('step')})", f" {t('when'):<10} {event.get('at')[:19]}", - f" {t('status'):<10} {event.get('status')}", + f" {t('status'):<10} {event.get('status')}" + f" ({t(SENS_DU_CODE.get(event.get('status'), 'the tool failed'))})", "", f" {t('what it ran')}", f" {event.get('detail', '')[:150]}", diff --git a/script/analyse/check_migration_residue.py b/script/analyse/check_migration_residue.py index 0de9d8b..65d0538 100755 --- a/script/analyse/check_migration_residue.py +++ b/script/analyse/check_migration_residue.py @@ -349,10 +349,11 @@ def verdicts_block(database, colour=True, path=None, lignes_avant=6): for event in ratés: version = quality.version_of(quality.event_database(event), dct) palier = str(version) if version else quality.event_step(event) + icone, teinte = status.verdict_mark(event["status"]) lignes.append( paint( - f"❌ {palier.rjust(6)} {event['name']}", - "broken", + f"{icone} {palier.rjust(6)} {event['name']}", + "broken" if teinte == "fail" else "watch", colour, ) ) diff --git a/script/todo/migration_status.py b/script/todo/migration_status.py index d8f2061..c736f2d 100755 --- a/script/todo/migration_status.py +++ b/script/todo/migration_status.py @@ -93,6 +93,24 @@ def paint(texte, couleur, actif=True): VERDICT_COLOUR = {0: "ok", 1: "warn", 2: "fail"} +# La convention des outils de migration, écrite dans todo_upgrade.run_tool : +# « 0 rien à signaler, 1 des trouvailles, 2 l'outil a échoué ». L'icône vit +# à côté de la couleur pour qu'elles ne puissent pas dire deux choses +# différentes — un écran affichait ❌ sur un 1, alors que database_cleanup +# imprime lui-même « This is a warning, not a failure ». +VERDICT_ICON = {0: "✅", 1: "⚠", 2: "❌"} + + +def verdict_mark(statut): + """(icône, couleur) pour ce code de sortie. Inconnu ⇒ traité comme un échec.""" + try: + code = int(statut or 0) + except (TypeError, ValueError): + code = 2 + if code not in VERDICT_ICON: + code = 2 + return VERDICT_ICON[code], VERDICT_COLOUR[code] + def read(path=DEFAULT_PATH): """La progression, complétée par ce qui a été écrit SUR DISQUE. diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index f1a9f1b..5a58e7b 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -6979,6 +6979,14 @@ TRANSLATIONS = { }, # --- Écran de qualité : Verdicts, Validation, Revue --- # --- Journal d'étape et bascule de version --- + "findings": { + "fr": "des trouvailles", + "en": "findings", + }, + "the tool failed": { + "fr": "l'outil a échoué", + "en": "the tool failed", + }, "capture unavailable": { "fr": "capture indisponible", "en": "capture unavailable", diff --git a/test/test_check_migration_quality.py b/test/test_check_migration_quality.py index 7074e5c..0dbe03d 100644 --- a/test/test_check_migration_quality.py +++ b/test/test_check_migration_quality.py @@ -1923,14 +1923,14 @@ class TestEveryVerdictIsListed(Base): for ligne in lignes: self.assertIn("✅", ligne["label"]) - def test_a_failure_and_a_success_sit_side_by_side(self): + def test_a_finding_and_a_success_sit_side_by_side(self): lst = qtui.extra_rows([], self.journal(1, 0)) icones = [ - "❌" if "❌" in r["label"] else "✅" + r["label"].strip().split()[0] for r in lst if r["kind"] == "verdict" ] - self.assertEqual(["❌", "✅"], icones) + self.assertEqual(["⚠", "✅"], icones) def test_the_header_counts_failures_over_the_total(self): lst = qtui.extra_rows([], self.journal(1, 0, 0)) @@ -1979,6 +1979,58 @@ class TestEveryVerdictIsListed(Base): self.assertEqual(1, len(lignes)) +class TestTheScreenObeysTheConvention(Base): + """L'écran peignait en ❌ ce que l'outil appelle un avertissement.""" + + def journal(self, statut): + return { + "lst_event": [ + evenement( + status=statut, + name="database_cleanup", + detail="./script/odoo/migration/database_cleanup.py" + " -d base_upgrade_18", + ) + ], + "config_database_name": "base", + "target_odoo_version": "18.0", + "state_4_upgrade_odoo_lst": [1, 2, 3, 4, 5, 6], + } + + def ligne(self, statut): + lst = qtui.extra_rows([], self.journal(statut)) + return [r for r in lst if r["kind"] == "verdict"][0] + + def test_findings_are_a_warning_in_the_column(self): + # database_cleanup imprime « This is a warning, not a failure » + # avant de rendre 1 : l'écran doit dire la même chose. + self.assertIn("⚠", self.ligne(1)["label"]) + self.assertNotIn("❌", self.ligne(1)["label"]) + + def test_a_tool_that_failed_is_still_red(self): + self.assertIn("❌", self.ligne(2)["label"]) + + def test_nothing_to_report_stays_green(self): + self.assertIn("✅", self.ligne(0)["label"]) + + def test_the_panel_header_agrees_with_the_column(self): + for statut in (0, 1, 2): + ligne = self.ligne(statut) + icone = ligne["label"].strip().split()[0] + self.assertTrue( + qtui.extra_pane(ligne).startswith(icone), + (statut, icone, qtui.extra_pane(ligne)[:20]), + ) + + def test_the_panel_spells_out_what_the_number_means(self): + # « statut 1 » ne dit rien à qui ne connaît pas la convention. + texte = qtui.extra_pane(self.ligne(1)) + self.assertIn(qtui.t("findings"), texte) + + def test_the_meaning_covers_the_whole_convention(self): + self.assertEqual({0, 1, 2}, set(qtui.SENS_DU_CODE)) + + class TestTheStepLogInThePanel(Base): """Ce que le journal contient vraiment, dit sans détour.""" diff --git a/test/test_migration_status.py b/test/test_migration_status.py index cb963e7..1cbcfc1 100644 --- a/test/test_migration_status.py +++ b/test/test_migration_status.py @@ -1548,6 +1548,41 @@ class TestNothingOnThatScreenCanHang(Base): self.assertEqual(TodoUpgrade.ask_ui(), "tui") +class TestWhatTheExitCodeMeans(Base): + """0 rien à signaler, 1 des trouvailles, 2 l'outil a échoué. + + La convention est écrite dans todo_upgrade.run_tool, et elle n'est pas + décorative : `database_cleanup` imprime lui-même « This is a warning, + not a failure » avant de rendre 1. Un écran qui peint ce 1 en rouge + contredit l'outil, et l'on finit par ignorer les deux. + """ + + def test_zero_is_green(self): + self.assertEqual(("✅", "ok"), status.verdict_mark(0)) + + def test_one_is_a_finding_not_a_failure(self): + icone, teinte = status.verdict_mark(1) + self.assertEqual("⚠", icone) + self.assertEqual("warn", teinte) + self.assertNotEqual("fail", teinte) + + def test_two_is_the_tool_itself_failing(self): + self.assertEqual(("❌", "fail"), status.verdict_mark(2)) + + def test_an_unknown_code_is_treated_as_a_failure(self): + # Un code qu'on ne sait pas lire ne doit pas passer pour un succès. + for inconnu in (3, 127, -1, "oui", None, ""): + icone, teinte = status.verdict_mark(inconnu) + if inconnu in (None, ""): + self.assertEqual("ok", teinte, inconnu) + else: + self.assertEqual("fail", teinte, inconnu) + + def test_the_icon_and_the_colour_cannot_drift(self): + # Elles vivent au même endroit précisément pour cela. + self.assertEqual(set(status.VERDICT_ICON), set(status.VERDICT_COLOUR)) + + class TestWhatGetsRecorded(Base): def upgrade(self): obj = TodoUpgrade.__new__(TodoUpgrade) From d2832f5c60b949feafac72af48a5fea73c1ee630 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 28 Aug 2026 01:20:25 -0400 Subject: [PATCH 21/22] =?UTF-8?q?[FIX]=20migration=2017=E2=86=9218=20:=20r?= =?UTF-8?q?etirer=20la=20vue=20morte=20account=5Froot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Odoo 17 créait une vue SQL pour account.root ; en 18 le modèle porte _auto = False et _table_query = '0' : son nom n'entre plus dans aucune requête, et le contrôle des tables manquantes ignore ces modèles. Elle n'est pas seulement morte, elle est FAUSSE : bâtie sur la colonne code que l'ORM 18 n'écrit plus, elle rend trop peu de racines. Elle est aussi l'unique épingle des deux colonnes héritées où database_cleanup échoue, d'où ce retrait avant le chargement en 18 ; aucun DROP COLUMN, OpenUpgrade les lit encore. Sur base jetable, l'ordre est rejouable et laisse intactes une vue d'une autre forme, une vue dont dépend un objet, une table homonyme. --- EN --- Odoo 17 created a SQL view for account.root; in 18 the model carries _auto = False and _table_query = '0': its name no longer enters any query, and the missing-table check ignores such models. It is not merely dead, it is WRONG: built on the code column the 18 ORM no longer writes, it returns too few roots. It is also the sole pin holding the two legacy columns database_cleanup fails on, hence its removal before the load into 18; no DROP COLUMN, OpenUpgrade still reads them afterwards. On a throwaway database the statement replays cleanly and leaves alone a view of another shape, a view something depends on, a table of that name. Assisted-by: Claude Opus 5 (cherry picked from commit b50e2308cbdd1efdfa914497062919eab1bbd329) --- .../fix_migration_odoo170_to_odoo180.sql | 82 ++++++ test/test_fix_account_root.py | 242 ++++++++++++++++++ 2 files changed, 324 insertions(+) create mode 100644 test/test_fix_account_root.py diff --git a/script/odoo/migration/fix_migration_odoo170_to_odoo180.sql b/script/odoo/migration/fix_migration_odoo170_to_odoo180.sql index 65993f4..e58f32e 100644 --- a/script/odoo/migration/fix_migration_odoo170_to_odoo180.sql +++ b/script/odoo/migration/fix_migration_odoo170_to_odoo180.sql @@ -34,3 +34,85 @@ BEGIN RAISE NOTICE 'forum_tag_rel.forum_id renommee en forum_post_id'; END IF; END $$; + +-- account_root : une VUE SQL qu'Odoo 17 créait pour le modèle +-- `account.root` (son init() bâtissait la vue sur account_account.code), +-- et que la 18 a orphelinée sans la retirer. En 18 le modèle porte +-- `_auto = False` et `_table_query = '0'` : son nom n'entre plus dans +-- aucune requête, et registry.py exclut ces modèles du contrôle des +-- tables manquantes — Odoo ne la recréera donc jamais. +-- +-- Elle n'est pas seulement morte, elle est FAUSSE : bâtie sur la colonne +-- `code` que l'ORM 18 n'écrit plus, elle rendait 14 racines là où la +-- donnée vivante (code_store) en portait 31. Un objet du schéma public +-- qui répond faux, sans avertir, à qui l'interroge à la main ou par un +-- outil décisionnel. +-- +-- Elle est AUSSI l'unique épingle de deux colonnes héritées : +-- database_cleanup a purgé 110 colonnes orphelines au palier 18 et n'a +-- échoué que sur account_account.code et .company_id, dont elle dépend. +-- On retire donc l'obstacle ICI, et l'outil déjà en place finit sa passe +-- APRÈS le chargement en 18. +-- +-- On ne supprime SURTOUT PAS les colonnes ici : le post-migration +-- d'OpenUpgrade les lit encore pour remplir code_store et company_ids, +-- et il tourne après ce fichier. +DO $$ +DECLARE + oid_vue oid; + oid_table oid; + nb_dependants int; +BEGIN + SELECT c.oid INTO oid_vue + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' + AND c.relname = 'account_root' + AND c.relkind = 'v'; + IF oid_vue IS NULL THEN + -- Ni vue, ni base sans le module account : rien à faire. C'est + -- aussi ce qui rend l'ordre rejouable après un premier passage. + RETURN; + END IF; + + oid_table := to_regclass('public.account_account'); + IF oid_table IS NULL THEN + RAISE NOTICE 'account_root sans account_account : laissee en place'; + RETURN; + END IF; + + -- La vue morte est CELLE QUI LIT account_account.code. Le contrôle + -- est structurel et non textuel : si une version future d'Odoo + -- recree un account_root d'une autre forme, on n'y touche pas. + IF NOT EXISTS ( + SELECT 1 + FROM pg_depend d + JOIN pg_rewrite r ON r.oid = d.objid + JOIN pg_attribute a ON a.attrelid = d.refobjid + AND a.attnum = d.refobjsubid + WHERE r.ev_class = oid_vue + AND d.refobjid = oid_table + AND a.attname = 'code' + ) THEN + RAISE NOTICE 'account_root ne lit pas account_account.code : laissee en place'; + RETURN; + END IF; + + -- Rien ne doit en dependre. Le jour ou quelque chose en dependrait, + -- APPRENDRE plutot que detruire : on le dit et on passe. Jamais de + -- CASCADE, et jamais d echec — ce fichier tourne sous ON_ERROR_STOP. + SELECT count(DISTINCT ev.oid) INTO nb_dependants + FROM pg_depend d + JOIN pg_rewrite r ON r.oid = d.objid + JOIN pg_class ev ON ev.oid = r.ev_class + WHERE d.refobjid = oid_vue + AND ev.oid <> oid_vue; + IF nb_dependants > 0 THEN + RAISE NOTICE 'account_root : % objet(s) en dependent, laissee en place', + nb_dependants; + RETURN; + END IF; + + DROP VIEW public.account_root; + RAISE NOTICE 'vue morte account_root supprimee (Odoo 18 ne la recree pas)'; +END $$; diff --git a/test/test_fix_account_root.py b/test/test_fix_account_root.py new file mode 100644 index 0000000..498e50b --- /dev/null +++ b/test/test_fix_account_root.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Retirer la vue morte account_root, et seulement quand c'est elle. + +Odoo 17 créait une VUE SQL pour le modèle `account.root` ; la 18 l'a +orphelinée sans la retirer — son modèle porte `_auto = False` et +`_table_query = '0'`, donc son nom n'entre plus dans aucune requête, et +registry.py exclut ces modèles du contrôle des tables manquantes. + +Elle n'est pas seulement morte, elle est FAUSSE : bâtie sur la colonne +`code` que l'ORM 18 n'écrit plus, elle rendait 14 racines là où la donnée +vivante en portait 31. Mesuré sur une migration 12 → 18 réelle. + +Et elle est l'unique épingle de deux colonnes héritées : database_cleanup +a purgé 110 colonnes orphelines au palier 18 et n'a échoué que sur +account_account.code et .company_id, dont la vue dépend. + +Ce que ce fichier garde, c'est la PRUDENCE du geste. On supprime une vue +sur une base client : les quatre refus comptent autant que la suppression. +""" + +import io +import os +import subprocess +import unittest + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +FIX = os.path.join( + REPO, "script", "odoo", "migration", "fix_migration_odoo170_to_odoo180.sql" +) +BASE = "_erplibre_test_account_root" + + +def psql_disponible(): + try: + done = subprocess.run( + ["psql", "-X", "-w", "-l"], capture_output=True, timeout=20 + ) + return done.returncode == 0 + except (OSError, subprocess.SubprocessError): + return False + + +@unittest.skipUnless(psql_disponible(), "pas de PostgreSQL joignable") +class Base(unittest.TestCase): + """Une base jetable, remontée à neuf pour chaque cas.""" + + @classmethod + def setUpClass(cls): + subprocess.run(["dropdb", "--if-exists", BASE], capture_output=True) + done = subprocess.run(["createdb", BASE], capture_output=True) + if done.returncode: + raise unittest.SkipTest( + "createdb refusé : " + done.stderr.decode() + ) + + @classmethod + def tearDownClass(cls): + subprocess.run(["dropdb", "--if-exists", BASE], capture_output=True) + + def setUp(self): + self.sql( + "DROP VIEW IF EXISTS autre_vue", + "DROP VIEW IF EXISTS account_root", + "DROP TABLE IF EXISTS account_root", + "DROP TABLE IF EXISTS account_account", + ) + + def sql(self, *ordres): + argv = ["psql", "-X", "-w", "-q", "-d", BASE] + for ordre in ordres: + argv += ["-c", ordre] + done = subprocess.run(argv, capture_output=True, text=True) + self.assertEqual(0, done.returncode, done.stderr) + return done + + def lire(self, requete): + done = subprocess.run( + ["psql", "-X", "-w", "-tA", "-d", BASE, "-c", requete], + capture_output=True, + text=True, + ) + self.assertEqual(0, done.returncode, done.stderr) + return done.stdout.strip() + + def vue_presente(self): + return ( + self.lire( + "SELECT (to_regclass('public.account_root') IS NOT NULL)::text" + ) + == "true" + ) + + def appliquer(self): + """Le fichier tel que la migration le lance : ON_ERROR_STOP=1.""" + done = subprocess.run( + [ + "psql", + "-X", + "-w", + "-q", + "-v", + "ON_ERROR_STOP=1", + "-d", + BASE, + "-f", + FIX, + ], + capture_output=True, + text=True, + ) + return done.returncode, done.stderr + + def poser_la_vue_morte(self): + self.sql( + "CREATE TABLE account_account (" + " id serial PRIMARY KEY, code varchar, company_id int)", + "CREATE VIEW account_root AS SELECT DISTINCT" + " ascii(code::text) AS id, left(code::text, 2) AS name," + " company_id FROM account_account WHERE code::text <> ''", + ) + + +class TestItRemovesTheDeadView(Base): + def test_the_view_is_gone_and_psql_is_happy(self): + self.poser_la_vue_morte() + self.assertTrue(self.vue_presente()) + code, _err = self.appliquer() + self.assertEqual(0, code) + self.assertFalse(self.vue_presente()) + + def test_it_says_what_it_did(self): + # Une suppression silencieuse sur une base client est une + # suppression qu'on ne retrouve pas six mois plus tard. + self.poser_la_vue_morte() + _code, err = self.appliquer() + self.assertIn("account_root supprimee", err) + + def test_running_it_twice_changes_nothing(self): + # Le fichier est rejoué à chaque reprise de migration. + self.poser_la_vue_morte() + self.appliquer() + code, err = self.appliquer() + self.assertEqual(0, code) + self.assertNotIn("NOTICE", err) + + +class TestWhatItRefusesToTouch(Base): + """Les refus comptent autant que la suppression.""" + + def test_a_view_that_does_not_read_code_is_left_alone(self): + # Si une version future d'Odoo recrée un account_root d'une autre + # forme, il ne nous appartient pas. Le contrôle est STRUCTUREL — + # pg_depend — et non un examen du texte de la définition. + self.sql( + "CREATE TABLE account_account (" + " id serial PRIMARY KEY, code varchar, code_store jsonb)", + "CREATE VIEW account_root AS SELECT id, code_store" + " FROM account_account", + ) + code, err = self.appliquer() + self.assertEqual(0, code) + self.assertTrue(self.vue_presente()) + self.assertIn("ne lit pas account_account.code", err) + + def test_a_view_something_depends_on_is_left_alone(self): + # Apprendre plutôt que détruire : on le dit, et on passe. Jamais + # de CASCADE. + self.poser_la_vue_morte() + self.sql("CREATE VIEW autre_vue AS SELECT * FROM account_root") + code, err = self.appliquer() + self.assertEqual(0, code) + self.assertTrue(self.vue_presente()) + self.assertIn("1 objet(s) en dependent", err) + + def test_a_table_named_account_root_is_never_dropped(self): + # relkind = 'v' : une TABLE de ce nom porterait des données. + self.sql( + "CREATE TABLE account_account (" + " id serial PRIMARY KEY, code varchar)", + "CREATE TABLE account_root (id int, name varchar)", + ) + code, _err = self.appliquer() + self.assertEqual(0, code) + self.assertEqual( + "r", + self.lire( + "SELECT relkind FROM pg_class" + " WHERE relname = 'account_root'" + ), + ) + + def test_a_database_without_account_stays_silent(self): + # Le module account n'est pas installé partout, et le fichier + # tourne sur toutes les bases. + code, err = self.appliquer() + self.assertEqual(0, code) + self.assertNotIn("account_root", err) + + +class TestItNeverTouchesTheColumns(unittest.TestCase): + """Le post-migration d'OpenUpgrade les lit APRÈS ce fichier. + + Il remplit code_store depuis `code` et company_ids depuis `company_id`. + Les retirer ici casserait les deux. Leur suppression revient à + database_cleanup, une fois la base chargée en 18. + """ + + def sans_commentaire(self): + """Le fichier dépouillé de sa prose. + + Les gardes portent sur le CODE : les commentaires disent justement + « on ne supprime pas les colonnes » et « jamais de CASCADE », et + les lire suffisait à faire échouer les tests qui l'exigent. + """ + with io.open(FIX, encoding="utf-8") as handle: + source = handle.read() + return "\n".join( + ligne + for ligne in source.split("\n") + if not ligne.strip().startswith("--") + ) + + def test_the_file_carries_no_drop_column(self): + self.assertNotIn("DROP COLUMN", self.sans_commentaire().upper()) + + def test_the_only_drop_is_the_view(self): + drops = [ + ligne.strip() + for ligne in self.sans_commentaire().split("\n") + if "DROP " in ligne.upper() + ] + self.assertEqual(["DROP VIEW public.account_root;"], drops) + + def test_it_never_uses_cascade(self): + self.assertNotIn("CASCADE", self.sans_commentaire().upper()) + + +if __name__ == "__main__": + unittest.main() From 17295fd7c3851bae9527fc329fcf69f9d9ad8cf9 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 28 Aug 2026 03:37:57 -0400 Subject: [PATCH 22/22] =?UTF-8?q?[ADD]=20migration=20:=20mesurer=20la=20d?= =?UTF-8?q?=C3=A9rive=20du=20plan=20comptable=20entre=20paliers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une montée de version recharge le gabarit de localisation sans rien dire : trois des scripts l10n du noyau appellent try_loading() sans force_create=False, et tout compte que le gabarit n'apparie pas par code est CRÉÉ. Les groupes ajoutés reclassent alors le plan existant. Un compte absolu ne dit rien : seul l'ÉCART entre deux paliers de la même migration se juge — comptes sans écriture, noms homonymes, grille apparue de rien. L'outil ne répare pas : la clé naturelle de suppression attrape aussi des comptes réappariés par code, et des ajoutés sont accrochés en ON DELETE SET NULL. Il dit de rejouer le palier ; 24 tests le couvrent. --- EN --- A version bump reloads the localisation template silently: three of the core l10n scripts call try_loading() without force_create=False, and every account the template cannot match by code is CREATED. The groups thus added then reclassify the existing chart. An absolute count says nothing: only the GAP between two steps of one migration can be judged — accounts with no entry, homonymous names, a grouping grid from nothing. The tool does not repair: its natural deletion key also catches accounts re-matched by code, and added ones are pinned by ON DELETE SET NULL. It says to replay the step; 24 tests cover it. Assisted-by: Claude Opus 5 (cherry picked from commit e99575967e0ad5b5122a2c35df3b9eaef7a72d53) --- script/analyse/check_chart_drift.py | 376 ++++++++++++++++++++++ script/analyse/check_migration_quality.py | 5 + script/todo/todo_i18n.py | 93 ++++++ test/test_check_chart_drift.py | 338 +++++++++++++++++++ 4 files changed, 812 insertions(+) create mode 100755 script/analyse/check_chart_drift.py create mode 100644 test/test_check_chart_drift.py diff --git a/script/analyse/check_chart_drift.py b/script/analyse/check_chart_drift.py new file mode 100755 index 0000000..364ae16 --- /dev/null +++ b/script/analyse/check_chart_drift.py @@ -0,0 +1,376 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Ce qu'une montée de version ajoute au référentiel comptable, sans le dire. + +Le cas qui a fait écrire cet outil, mesuré sur une migration 12 → 18 : + + palier 17 : 96 comptes, 32 taxes, 14 positions, 0 groupe + palier 18 : 427 comptes, 64 taxes, 28 positions, 168 groupes + +Le script de migration de `l10n_ca` recharge le plan canadien `ca_2023` +par `try_loading(...)` SANS `force_create=False` — 31 des 33 scripts de +localisation du noyau le passent, trois l'oublient. Les codes du client +(sa propre numérotation) ne recouvrent celui du gabarit que sur 15 codes +sur 341 : les 326 autres ont été CRÉÉS. + +Rien n'est détruit — les écritures ne bougent pas, les journaux gardent +leurs comptes. Mais trois écrans se remplissent de doublons, et surtout +les 168 groupes RECLASSENT les comptes du client : un compte fournisseurs +de 1067 écritures apparaît sous « Residential Mortgage Loans ». + +Pourquoi une comparaison, et non un compte absolu +------------------------------------------------- +« 427 comptes » ne dit rien : un plan peut légitimement en compter mille. +Ce qui se juge, c'est l'ÉCART entre deux paliers de la même migration, et +la signature qui l'accompagne — des comptes ajoutés sans une écriture, +des noms qui deviennent homonymes, une grille de regroupement qui +apparaît là où il n'y en avait aucune. + +L'outil ne répare rien. Il ne peut pas : la clé naturelle de suppression +attrape aussi des comptes du client réappariés par code, et douze des +comptes ajoutés sont accrochés en ON DELETE SET NULL. La bonne réponse +est de rejouer le palier avec le correctif, pas d'opérer la base livrée. +""" + +from __future__ import annotations + +import os +import sys + +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 + + +from script.analyse import check_migration_quality as quality # noqa: E402 +from script.analyse import lib_analyse # noqa: E402 + +COULEURS = { + "broken": "\033[31m", + "watch": "\033[33m", + "ok": "\033[32m", + "step": "\033[36m", + "dim": "\033[90m", +} +RESET = "\033[0m" + + +def paint(texte, genre, colour): + """Teinter, ou rendre le texte tel quel quand la couleur est coupée.""" + if not colour: + return texte + return f"{COULEURS.get(genre, '')}{texte}{RESET}" + + +# Ce que le référentiel comptable porte, et ce qu'un gonflement y veut +# dire. `gravity` suit la règle du dépôt : `broken` pour ce qui est faux +# en soi, `watch` pour ce qui mérite un regard. +TABLES = ( + { + "key": "account", + "table": "account_account", + "title": "Accounts in the chart", + "why": "A reloaded chart template adds every account it could not" + " match by code. They carry no entry, and they bury the" + " customer's own chart.", + "gravity": "broken", + }, + { + "key": "tax", + "table": "account_tax", + "title": "Taxes", + "why": "Reloaded taxes are not renamed '[old]': the duplicates are" + " indistinguishable in the interface.", + "gravity": "broken", + }, + { + "key": "fiscal_position", + "table": "account_fiscal_position", + "title": "Fiscal positions", + "why": "Each province then appears twice, under the same name.", + "gravity": "broken", + }, + { + "key": "group", + "table": "account_group", + "title": "Account groups", + "why": "Groups are matched to accounts by code PREFIX: a grid that" + " appears reclassifies the customer's own accounts, and the trial" + " balance in hierarchy mode follows.", + "gravity": "broken", + }, + { + "key": "journal", + "table": "account_journal", + "title": "Journals", + "why": "A journal added by a template reload competes with the" + " customer's own.", + "gravity": "watch", + }, +) + + +def compte(database, table, **kwargs): + """Combien de lignes, ou None si la table n'existe pas.""" + existe = lib_analyse.scalar_query( + database, + f"SELECT (to_regclass('public.{table}') IS NOT NULL)::text;", + **kwargs, + ) + if (existe or "").strip().lower() != "true": + return None + brut = lib_analyse.scalar_query( + database, f"SELECT count(*) FROM {table};", **kwargs + ) + try: + return int(brut) + except (TypeError, ValueError): + return None + + +def comptes_sans_ecriture(database, **kwargs): + """(comptes, ceux qui ne portent aucune écriture). + + C'est la signature d'un gabarit rechargé : un plan vécu a des comptes + vides, mais pas trois cents d'un coup. + """ + lignes = lib_analyse.json_query( + database, + "SELECT count(*) AS total," + " count(*) FILTER (WHERE NOT EXISTS (" + " SELECT 1 FROM account_move_line l WHERE l.account_id = a.id" + " )) AS sans_ecriture" + " FROM account_account a", + **kwargs, + ) + if not lignes: + return None, None + return lignes[0].get("total"), lignes[0].get("sans_ecriture") + + +def homonymes(database, table, **kwargs): + """Combien de noms portés par plus d'une ligne. + + Le rechargement ne renomme pas ce qu'il double : dans l'interface, + deux lignes du même nom ne se distinguent pas. + """ + brut = lib_analyse.scalar_query( + database, + "SELECT coalesce(sum(n - 1), 0) FROM (" + f" SELECT count(*) AS n FROM {table}" + " GROUP BY name->>'en_US' HAVING count(*) > 1) x;", + **kwargs, + ) + try: + return int(brut) + except (TypeError, ValueError): + return None + + +def reclasses(database, **kwargs): + """Combien de comptes tombent sous un groupe, par préfixe de code. + + Rejoue ce que fait `_compute_account_group` : le groupe dont le + préfixe encadre le code, le plus spécifique d'abord. + """ + brut = lib_analyse.scalar_query( + database, + "SELECT count(*) FROM (" + " SELECT a.id, a.code_store->>'1' AS code FROM account_account a" + ") c JOIN LATERAL (" + " SELECT ag.id FROM account_group ag" + " WHERE ag.code_prefix_start <=" + " left(c.code, char_length(ag.code_prefix_start))" + " AND ag.code_prefix_end >=" + " left(c.code, char_length(ag.code_prefix_end))" + " ORDER BY char_length(ag.code_prefix_start) DESC, ag.id" + " LIMIT 1) g ON true;", + **kwargs, + ) + try: + return int(brut) + except (TypeError, ValueError): + return None + + +def previous_database(database, dct=None): + """Le palier qui précède celui-ci, lu dans la chaîne de la migration. + + On ne le devine pas depuis le nom : la base de DÉPART ne porte pas de + suffixe, et retrancher 1 à « _upgrade_18 » donnerait « _upgrade_17 » + même quand la chaîne n'est pas contiguë. + """ + dct = quality.read_progression() if dct is None else dct + chaine = quality.chain(dct) + for rang, (_version, base) in enumerate(chaine): + if base == database and rang: + return chaine[rang - 1][1] + return None + + +def inspect(after, before, **kwargs): + """Ce que le dernier palier ajoute au référentiel du précédent.""" + rapport = {"before": before, "after": after, "tables": [], "signes": {}} + for entree in TABLES: + avant = compte(before, entree["table"], **kwargs) + apres = compte(after, entree["table"], **kwargs) + rapport["tables"].append( + { + "key": entree["key"], + "table": entree["table"], + "before": avant, + "after": apres, + "delta": ( + None if avant is None or apres is None else apres - avant + ), + } + ) + + total, vides = comptes_sans_ecriture(after, **kwargs) + rapport["signes"]["accounts"] = total + rapport["signes"]["accounts_without_entry"] = vides + for table, clef in ( + ("account_tax", "tax_homonyms"), + ("account_fiscal_position", "fiscal_position_homonyms"), + ): + rapport["signes"][clef] = homonymes(after, table, **kwargs) + if compte(after, "account_group", **kwargs): + rapport["signes"]["accounts_regrouped"] = reclasses(after, **kwargs) + return rapport + + +def judge(rapport): + """[(entrée, écart)] pour ce qui a gonflé, du plus grave au moins.""" + par_clef = {e["key"]: e for e in TABLES} + trouve = [ + (par_clef[ligne["key"]], ligne) + for ligne in rapport["tables"] + if ligne["delta"] and ligne["delta"] > 0 + ] + ordre = {"broken": 0, "watch": 1} + trouve.sort( + key=lambda paire: ( + ordre.get(paire[0]["gravity"], 2), + -paire[1]["delta"], + ) + ) + return trouve + + +def render(rapport, colour=True): + """Le rapport lisible. L'écart d'abord, ce qu'il signifie ensuite.""" + lignes = [ + f"📚 {t('Accounting reference data, one step to the next')}", + paint(f" {rapport['before']} → {rapport['after']}", "dim", colour), + "", + ] + trouve = judge(rapport) + if not trouve: + lignes.append( + paint( + f"✅ {t('Nothing was added to the reference data.')}", + "ok", + colour, + ) + ) + return "\n".join(lignes) + + for entree, ligne in trouve: + icone = "❌" if entree["gravity"] == "broken" else "⚠" + lignes.append( + paint( + f"{icone} {('+' + str(ligne['delta'])).rjust(6)}" + f" {t(entree['title'])}" + f" ({ligne['before']} → {ligne['after']})", + entree["gravity"], + colour, + ) + ) + lignes.append(paint(f" {t(entree['why'])}", "dim", colour)) + lignes.append("") + + signes = rapport["signes"] + lignes.append(paint(f" {t('What it looks like')}", "step", colour)) + vides = signes.get("accounts_without_entry") + if vides: + lignes.append( + f" {vides} / {signes.get('accounts')}" + f" {t('accounts carry no entry at all')}" + ) + for clef, phrase in ( + ("tax_homonyms", "taxes share a name with another"), + ( + "fiscal_position_homonyms", + "fiscal positions share a name with another", + ), + ): + if signes.get(clef): + lignes.append(f" {signes[clef]} {t(phrase)}") + if signes.get("accounts_regrouped"): + lignes.append( + f" {signes['accounts_regrouped']}" + f" {t('accounts fall under a group by code prefix')}" + ) + lignes.append("") + lignes.append( + paint( + f" {t('Replay the step with the fix; do not operate the')}" + f" {t('delivered database.')}", + "dim", + colour, + ) + ) + return "\n".join(lignes).rstrip() + + +def main(argv=None): + import argparse + import json + + parser = argparse.ArgumentParser( + description=t("What a version bump adds to the accounting data."), + ) + parser.add_argument("-d", "--database", required=True) + parser.add_argument( + "--before", + help=t("the step to compare against (default: the previous one)"), + ) + parser.add_argument("-c", "--config", help="odoo config file") + parser.add_argument("--json", action="store_true") + parser.add_argument("--no-color", action="store_true") + args = parser.parse_args(argv) + + before = args.before or previous_database(args.database) + if not before: + print( + f"❌ {t('No previous step found: name it with --before.')}", + file=sys.stderr, + ) + return 2 + for base in (before, args.database): + try: + lib_analyse.require_odoo_database(base, config_path=args.config) + except Exception as exc: # noqa: BLE001 + print(f"❌ {exc}", file=sys.stderr) + return 2 + + rapport = inspect(args.database, before, config_path=args.config) + if args.json: + print(json.dumps(rapport, indent=2, ensure_ascii=False)) + else: + colour = sys.stdout.isatty() and not args.no_color + print(render(rapport, colour)) + return 1 if judge(rapport) else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/analyse/check_migration_quality.py b/script/analyse/check_migration_quality.py index 3d7b51b..8c5ee59 100755 --- a/script/analyse/check_migration_quality.py +++ b/script/analyse/check_migration_quality.py @@ -390,6 +390,11 @@ REVUE = ( "script/analyse/check_view_type_tree.py", "viewtype", ), + ( + "Did the last step add to the accounting data?", + "script/analyse/check_chart_drift.py -d {db}", + "chartdrift", + ), ) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 5a58e7b..9214823 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -6979,6 +6979,99 @@ TRANSLATIONS = { }, # --- Écran de qualité : Verdicts, Validation, Revue --- # --- Journal d'étape et bascule de version --- + # --- Dérive du référentiel comptable --- + "Did the last step add to the accounting data?": { + "fr": "Le dernier palier a-t-il gonflé le référentiel comptable ?", + "en": "Did the last step add to the accounting data?", + }, + "Accounting reference data, one step to the next": { + "fr": "Référentiel comptable, d'un palier au suivant", + "en": "Accounting reference data, one step to the next", + }, + "Nothing was added to the reference data.": { + "fr": "Rien n'a été ajouté au référentiel.", + "en": "Nothing was added to the reference data.", + }, + "Accounts in the chart": { + "fr": "Comptes du plan comptable", + "en": "Accounts in the chart", + }, + "Taxes": { + "fr": "Taxes", + "en": "Taxes", + }, + "Fiscal positions": { + "fr": "Positions fiscales", + "en": "Fiscal positions", + }, + "Account groups": { + "fr": "Groupes de comptes", + "en": "Account groups", + }, + "Journals": { + "fr": "Journaux", + "en": "Journals", + }, + "What it looks like": { + "fr": "À quoi cela ressemble", + "en": "What it looks like", + }, + "accounts carry no entry at all": { + "fr": "comptes ne portent aucune écriture", + "en": "accounts carry no entry at all", + }, + "taxes share a name with another": { + "fr": "taxes portent le nom d'une autre", + "en": "taxes share a name with another", + }, + "fiscal positions share a name with another": { + "fr": "positions fiscales portent le nom d'une autre", + "en": "fiscal positions share a name with another", + }, + "accounts fall under a group by code prefix": { + "fr": "comptes tombent sous un groupe par préfixe de code", + "en": "accounts fall under a group by code prefix", + }, + "Replay the step with the fix; do not operate the": { + "fr": "Rejouer le palier avec le correctif ; ne pas opérer la", + "en": "Replay the step with the fix; do not operate the", + }, + "delivered database.": { + "fr": "base livrée.", + "en": "delivered database.", + }, + "What a version bump adds to the accounting data.": { + "fr": "Ce qu'une montée de version ajoute au référentiel comptable.", + "en": "What a version bump adds to the accounting data.", + }, + "the step to compare against (default: the previous one)": { + "fr": "le palier de comparaison (défaut : le précédent)", + "en": "the step to compare against (default: the previous one)", + }, + "No previous step found: name it with --before.": { + "fr": "Aucun palier précédent trouvé : le nommer avec --before.", + "en": "No previous step found: name it with --before.", + }, + "A reloaded chart template adds every account it could not match by code. They carry no entry, and they bury the customer's own chart.": { + "fr": "Un gabarit de plan rechargé ajoute chaque compte qu'il n'a pu apparier par son code. Ils ne portent aucune écriture, et noient le plan du client.", + "en": "A reloaded chart template adds every account it could not match by code. They carry no entry, and they bury the customer's own chart.", + }, + "Reloaded taxes are not renamed '[old]': the duplicates are indistinguishable in the interface.": { + "fr": "Les taxes rechargées ne sont pas renommées « [old] » : les doublons ne se distinguent pas dans l'interface.", + "en": "Reloaded taxes are not renamed '[old]': the duplicates are indistinguishable in the interface.", + }, + "Each province then appears twice, under the same name.": { + "fr": "Chaque province apparaît alors deux fois, sous le même nom.", + "en": "Each province then appears twice, under the same name.", + }, + "Groups are matched to accounts by code PREFIX: a grid that appears reclassifies the customer's own accounts, and the trial balance in hierarchy mode follows.": { + "fr": "Les groupes s'apparient aux comptes par PRÉFIXE de code : une grille qui apparaît reclasse les comptes du client, et la balance en mode hiérarchie suit.", + "en": "Groups are matched to accounts by code PREFIX: a grid that appears reclassifies the customer's own accounts, and the trial balance in hierarchy mode follows.", + }, + "A journal added by a template reload competes with the customer's own.": { + "fr": "Un journal ajouté par un rechargement concurrence celui du client.", + "en": "A journal added by a template reload competes with the customer's own.", + }, "findings": { "fr": "des trouvailles", "en": "findings", diff --git a/test/test_check_chart_drift.py b/test/test_check_chart_drift.py new file mode 100644 index 0000000..6e68636 --- /dev/null +++ b/test/test_check_chart_drift.py @@ -0,0 +1,338 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Ce qu'une montée de version ajoute au référentiel comptable. + +Mesuré sur une migration 12 → 18 réelle, entre les deux derniers paliers : + + comptes 96 → 427 taxes 32 → 64 + groupes 0 → 168 positions 14 → 28 + +Le script de migration de `l10n_ca` recharge le plan `ca_2023` sans +`force_create=False` ; les codes du client ne recouvrent le gabarit que +sur 15 des 341, et les 326 autres sont créés. Rien n'est détruit — mais +les 168 groupes reclassent les comptes du client par PRÉFIXE de code, et +un compte fournisseurs de 1067 écritures se retrouve sous « Residential +Mortgage Loans ». + +Ce qui se teste ici n'est pas le comptage — psql le fait — mais le +JUGEMENT : un compte absolu ne dit rien (un plan peut légitimement porter +mille comptes), seul l'écart entre deux paliers de la même migration se +juge. Et la comparaison doit rester muette quand rien n'a bougé, sinon +personne ne la lira deux fois. +""" + +import io +import os +import sys +import unittest +from contextlib import redirect_stdout + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, REPO) + +from script.analyse import check_chart_drift as drift # noqa: E402 +from script.analyse import lib_analyse # noqa: E402 + + +class Base(unittest.TestCase): + """psql bouchonné : chaque base rend les comptes qu'on lui prête.""" + + def setUp(self): + self.vues = [] + self.ancien = lib_analyse.run_psql + self.addCleanup(setattr, lib_analyse, "run_psql", self.ancien) + self.tables = {} + self.sans_ecriture = (0, 0) + self.homonymes = 0 + self.reclasses = 0 + lib_analyse.run_psql = self.repondre + + def poser(self, base, **compteurs): + self.tables[base] = compteurs + + def repondre(self, database, sql, **kwargs): + self.vues.append((database, sql)) + compteurs = self.tables.get(database, {}) + if "to_regclass" in sql and "count(*)" not in sql: + table = sql.split("public.")[1].split("'")[0] + return "true" if table in compteurs else "false" + if "account_move_line l WHERE l.account_id" in sql: + total, vides = self.sans_ecriture + return '[{"total": %d, "sans_ecriture": %d}]' % (total, vides) + if "HAVING count(*) > 1" in sql: + return str(self.homonymes) + if "code_prefix_start" in sql: + return str(self.reclasses) + for table, valeur in compteurs.items(): + if f"FROM {table};" in sql: + return str(valeur) + return "" + + +class TestWhatItCompares(Base): + def test_it_reads_both_steps(self): + self.poser("avant", account_account=96) + self.poser("apres", account_account=427) + drift.inspect("apres", "avant") + bases = {base for base, _sql in self.vues} + self.assertEqual({"avant", "apres"}, bases) + + def test_a_table_that_does_not_exist_is_not_a_zero(self): + # Sans le module account, la table manque : dire « 0 → 0 » ferait + # croire à une mesure, et « 0 → 400 » à une explosion. + self.poser("avant") + self.poser("apres", account_account=427) + rapport = drift.inspect("apres", "avant") + ligne = [x for x in rapport["tables"] if x["key"] == "account"][0] + self.assertIsNone(ligne["before"]) + self.assertIsNone(ligne["delta"]) + + def test_every_declared_table_is_measured(self): + self.poser("avant", account_account=1) + self.poser("apres", account_account=1) + rapport = drift.inspect("apres", "avant") + self.assertEqual( + {e["key"] for e in drift.TABLES}, + {x["key"] for x in rapport["tables"]}, + ) + + +class TestWhatItJudges(Base): + def rapport(self, avant, apres): + self.poser("avant", **avant) + self.poser("apres", **apres) + return drift.inspect("apres", "avant") + + def test_nothing_added_is_nothing_to_report(self): + rapport = self.rapport( + {"account_account": 96}, {"account_account": 96} + ) + self.assertEqual([], drift.judge(rapport)) + + def test_a_chart_that_quadruples_is_a_finding(self): + rapport = self.rapport( + {"account_account": 96}, {"account_account": 427} + ) + trouve = drift.judge(rapport) + self.assertEqual(1, len(trouve)) + self.assertEqual(331, trouve[0][1]["delta"]) + + def test_a_chart_that_SHRINKS_is_not_reported_here(self): + # Ce n'est pas le sujet de cet outil, et le dire ferait deux + # constats pour deux causes différentes sous un seul nom. + rapport = self.rapport( + {"account_account": 427}, {"account_account": 96} + ) + self.assertEqual([], drift.judge(rapport)) + + def test_groups_appearing_from_nothing_are_judged(self): + # Zéro groupe puis cent soixante-huit : c'est la grille du gabarit, + # et elle reclasse les comptes du client. + rapport = self.rapport({"account_group": 0}, {"account_group": 168}) + self.assertEqual("group", drift.judge(rapport)[0][0]["key"]) + + def test_gravity_wins_over_size(self): + rapport = self.rapport( + {"account_journal": 8, "account_account": 96}, + {"account_journal": 28, "account_account": 99}, + ) + trouve = drift.judge(rapport) + cles = [entree["key"] for entree, _ in trouve] + self.assertEqual("account", cles[0], "la gravite doit primer") + self.assertEqual("journal", cles[-1]) + # Et l'écart le plus GROS est bien celui qu'on relègue. + deltas = dict(zip(cles, [ligne["delta"] for _e, ligne in trouve])) + self.assertGreater(deltas["journal"], deltas["account"]) + + def test_at_equal_gravity_the_bigger_gap_comes_first(self): + rapport = self.rapport( + {"account_account": 96, "account_tax": 32}, + {"account_account": 427, "account_tax": 64}, + ) + cles = [entree["key"] for entree, _ in drift.judge(rapport)] + self.assertEqual(["account", "tax"], cles) + + def test_every_entry_says_why_it_matters(self): + for entree in drift.TABLES: + self.assertTrue(entree["why"].strip(), entree["key"]) + self.assertIn(entree["gravity"], ("broken", "watch")) + + +class TestTheReport(Base): + def test_it_names_both_steps(self): + self.poser("base_upgrade_17", account_account=96) + self.poser("base_upgrade_18", account_account=427) + rapport = drift.inspect("base_upgrade_18", "base_upgrade_17") + texte = drift.render(rapport, colour=False) + self.assertIn("base_upgrade_17", texte) + self.assertIn("base_upgrade_18", texte) + + def test_it_shows_the_before_and_the_after_not_just_the_delta(self): + # « +331 » seul ne dit pas si l'on part de 96 ou de 4000. + self.poser("avant", account_account=96) + self.poser("apres", account_account=427) + texte = drift.render(drift.inspect("apres", "avant"), colour=False) + self.assertIn("+331", texte) + self.assertIn("96", texte) + self.assertIn("427", texte) + + def test_a_quiet_migration_says_so_and_stops(self): + self.poser("avant", account_account=96) + self.poser("apres", account_account=96) + texte = drift.render(drift.inspect("apres", "avant"), colour=False) + self.assertIn( + drift.t("Nothing was added to the reference data."), texte + ) + self.assertNotIn("❌", texte) + + def test_it_points_at_the_replay_not_at_surgery(self): + # La clé naturelle de suppression attrape aussi des comptes du + # client, et douze des ajoutés sont accrochés en SET NULL. + self.poser("avant", account_account=96) + self.poser("apres", account_account=427) + texte = drift.render(drift.inspect("apres", "avant"), colour=False) + self.assertIn( + drift.t("Replay the step with the fix; do not operate the"), + texte, + ) + + def test_the_shape_is_shown_when_there_is_something_to_show(self): + self.sans_ecriture = (427, 404) + self.homonymes = 15 + self.reclasses = 422 + self.poser("avant", account_account=96) + self.poser("apres", account_account=427, account_group=168) + texte = drift.render(drift.inspect("apres", "avant"), colour=False) + self.assertIn("404", texte) + self.assertIn("422", texte) + + +class TestFindingThePreviousStep(unittest.TestCase): + """Le palier précédent se LIT dans la chaîne, il ne se devine pas.""" + + def chaine(self): + return { + "config_database_name": "base", + "target_odoo_version": "18.0", + "state_4_upgrade_odoo_lst": [1, 2, 3, 4, 5, 6], + } + + def test_it_reads_the_chain_the_migration_wrote(self): + self.assertEqual( + "base_upgrade_17", + drift.previous_database("base_upgrade_18", self.chaine()), + ) + + def test_the_first_step_has_no_predecessor(self): + # La base de DÉPART ne porte pas de suffixe : lui retrancher 1 + # donnerait un nom qui n'existe pas. + self.assertIsNone(drift.previous_database("base", self.chaine())) + + def test_a_database_outside_the_chain_is_not_guessed(self): + self.assertIsNone( + drift.previous_database("une_autre_base", self.chaine()) + ) + + +class TestTheCommand(Base): + def lancer(self, argv): + tampon = io.StringIO() + with redirect_stdout(tampon): + code = drift.main(argv) + return code, tampon.getvalue() + + def setUp(self): + super().setUp() + self.ancien_require = lib_analyse.require_odoo_database + self.addCleanup( + setattr, lib_analyse, "require_odoo_database", self.ancien_require + ) + lib_analyse.require_odoo_database = lambda *a, **k: None + + def test_without_a_previous_step_it_is_a_tool_failure(self): + # Sortir 0 laisserait croire que la comparaison a eu lieu. + ancien = drift.previous_database + drift.previous_database = lambda *a, **k: None + self.addCleanup(setattr, drift, "previous_database", ancien) + code, _ = self.lancer(["-d", "base_upgrade_18"]) + self.assertEqual(2, code) + + def test_no_drift_exits_zero(self): + self.poser("avant", account_account=96) + self.poser("apres", account_account=96) + code, _ = self.lancer( + ["-d", "apres", "--before", "avant", "--no-color"] + ) + self.assertEqual(0, code) + + def test_drift_exits_one(self): + # 1 = des trouvailles, la convention des outils du dépôt. + self.poser("avant", account_account=96) + self.poser("apres", account_account=427) + code, _ = self.lancer( + ["-d", "apres", "--before", "avant", "--no-color"] + ) + self.assertEqual(1, code) + + def test_the_json_carries_both_counts(self): + import json + + self.poser("avant", account_account=96) + self.poser("apres", account_account=427) + _code, texte = self.lancer( + ["-d", "apres", "--before", "avant", "--json"] + ) + dct = json.loads(texte) + ligne = [x for x in dct["tables"] if x["key"] == "account"][0] + self.assertEqual(96, ligne["before"]) + self.assertEqual(427, ligne["after"]) + self.assertEqual(331, ligne["delta"]) + + +class TestItNeverWrites(unittest.TestCase): + """On compare deux bases de palier : l'une d'elles est parfois la + seule copie d'un état.""" + + def test_no_statement_of_the_tool_can_write(self): + """Sur le CODE, jamais sur la prose. + + Les docstrings de l'outil expliquent justement pourquoi il ne + faut pas opérer la base — « accrochés en ON DELETE SET NULL » — + et une garde qui lit le texte échoue sur le texte qui l'explique. + """ + import ast + import inspect + + arbre = ast.parse(inspect.getsource(drift)) + for noeud in ast.walk(arbre): + if isinstance( + noeud, (ast.Module, ast.FunctionDef, ast.ClassDef) + ) and ast.get_docstring(noeud): + noeud.body = noeud.body[1:] + code = ast.unparse(arbre).upper() + for interdit in ("UPDATE ", "DELETE ", "INSERT ", "DROP ", "ALTER "): + self.assertNotIn(interdit, code, interdit) + + def test_it_only_ever_selects(self): + import ast + import inspect + + arbre = ast.parse(inspect.getsource(drift)) + requetes = [ + n.value + for n in ast.walk(arbre) + if isinstance(n, ast.Constant) + and isinstance(n.value, str) + and "FROM " in n.value.upper() + ] + self.assertTrue(requetes) + for requete in requetes: + self.assertTrue( + requete.upper().lstrip().startswith("SELECT"), requete[:40] + ) + + +if __name__ == "__main__": + unittest.main()