From abb605aad36c6876673e348ff11dccad6ac92f1f Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 25 Aug 2026 03:55:46 -0400 Subject: [PATCH] [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.