From 97d2ebec65638d8765f815c2622c38bf458c7b45 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 21 Aug 2026 04:01:01 -0400 Subject: [PATCH] [ADD] analyse: modules a database lacks vs the default package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit image_db.py --check_addons_exist asks whether a module is on DISK. Nothing asked whether a given DATABASE has it. After six migration steps, that is the question: the 18 instance grown from 12 has only 4 of the 15 modules odoo18.0_base ships. Five verdicts, not one "missing": an available module installs, an unknown one needs the addons path repaired first, an uninstallable one has a broken dependency no install will work around. shortdesc is varchar up to 15 and jsonb after, and step databases of both shapes appear in one session — so the column type is read, never assumed. --- FR --- image_db.py --check_addons_exist demande si un module est sur le DISQUE. Personne ne demandait si une BASE donnée l'a. Après six paliers, c'est pourtant la question : l'instance 18 issue de la 12 n'a que 4 des 15 modules d'odoo18.0_base. Cinq verdicts, pas un « manquant » : un module disponible s'installe, un inconnu exige d'abord de réparer le chemin des addons, un cassé a une dépendance qu'aucune installation ne contournera. shortdesc est varchar jusqu'en 15 et jsonb ensuite, et les deux formes se présentent dans la même session — le type est lu, jamais supposé. Assisted-by: Claude Opus 5 --- script/analyse/check_module_package.py | 499 ++++++++++++++++++++++ script/todo/todo.py | 51 +++ test/test_check_module_package.py | 570 +++++++++++++++++++++++++ 3 files changed, 1120 insertions(+) create mode 100644 script/analyse/check_module_package.py create mode 100644 test/test_check_module_package.py diff --git a/script/analyse/check_module_package.py b/script/analyse/check_module_package.py new file mode 100644 index 0000000..f93451f --- /dev/null +++ b/script/analyse/check_module_package.py @@ -0,0 +1,499 @@ +#!/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 base n'a pas, alors que l'installation par défaut l'aurait. + +`conf/module_list_image_db_odoo.json` dit ce qu'ERPLibre embarque : le +package `odoo18.0_base` suggère quinze modules. Rien ne vérifiait qu'une +base DONNÉE les a. C'est pourtant la question qu'on se pose devant une +instance migrée depuis la 12 : elle a traversé six paliers, des modules +ont été désinstallés en chemin pour débloquer une mise à jour, et +personne ne sait plus ce qui manque par rapport à une installation neuve. + +Pourquoi pas `image_db.py --check_addons_exist` +----------------------------------------------- +Il répond à une autre question : le module est-il sur le DISQUE. Un +module peut être présent dans le chemin des addons et absent de la base, +ou l'inverse — connu de la base parce qu'il y fut installé, mais son code +n'est plus là. Les deux outils sont complémentaires, aucun ne remplace +l'autre. + +Cinq verdicts, pas un seul « manquant » +--------------------------------------- +Les confondre rendrait le rapport inutile, car l'action diffère à chaque +fois : un module `available` s'installe d'un clic ; un module `unknown` +demande d'abord de réparer le chemin des addons ; un `uninstallable` a +une dépendance cassée qu'aucune installation ne contournera. Un rapport +qui dit « 7 modules manquants » sans distinguer ces cas oblige à tout +reprendre à la main. + +Lecture seule, garantie par le serveur — on inspecte parfois des bases de +migration dont c'est la seule copie. + +Codes de sortie : 0 rien à signaler, 1 des trouvailles, 2 l'outil a échoué. +""" + +import json +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 + + +SEP = "\x1f" + +# Résolu depuis CE fichier, pas depuis le répertoire courant : l'outil est +# lancé aussi bien depuis la racine du dépôt que depuis le menu TODO ou un +# /tmp, et un chemin relatif le faisait échouer en disant « fichier de +# packages illisible » — un diagnostic qui envoyait chercher au mauvais +# endroit. +REPO_ROOT = os.path.normpath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..") +) +PACKAGE_FILE = os.path.join( + REPO_ROOT, "conf", "module_list_image_db_odoo.json" +) + +# L'ordre EST la gravité : c'est lui qui décide de la lecture du rapport. +VERDICTS = ("unknown", "uninstallable", "pending", "available", "installed") + +# Les états d'`ir_module_module` qu'Odoo peut porter, rangés par verdict. +# Une base en cours de mise à jour reste dans « to install » ou « to +# remove » : ce n'est ni installé ni disponible, c'est inachevé, et le +# dire évite de proposer d'installer ce qui est déjà en route. +ETAT_VERS_VERDICT = { + "installed": "installed", + "to upgrade": "installed", + "to install": "pending", + "to remove": "pending", + "uninstalled": "available", + "uninstallable": "uninstallable", +} + +ICONE = { + "installed": "✅", + "available": "○", + "pending": "⏳", + "uninstallable": "⛔", + "unknown": "❌", +} + + +def run_psql(database, sql): + """Interroger la base en lecture seule, garantie par le SERVEUR. + + `default_transaction_read_only` n'est pas une promesse de l'outil : + PostgreSQL refusera l'écriture même si le SQL en contenait une. + """ + env = os.environ.copy() + env["PGOPTIONS"] = "-c default_transaction_read_only=on" + env["PSQLRC"] = "" + done = subprocess.run( + ["psql", "-X", "-w", "-d", database, "-tAF", SEP, "-c", sql], + capture_output=True, + text=True, + env=env, + ) + if done.returncode: + return None + return [ligne.split(SEP) for ligne in done.stdout.splitlines() if ligne] + + +def read_packages(path=PACKAGE_FILE): + """Le fichier des packages, ou {} s'il est illisible.""" + try: + with open(path, "r", encoding="utf-8") as handle: + dct = json.load(handle) + except (OSError, ValueError): + return {} + return dct if isinstance(dct, dict) else {} + + +def package_chain(name, packages): + """De la racine à `name`, en suivant `base`. + + Un package hérite du sien : `odoo12.0_website` est construit SUR + l'image `odoo12.0_base`, donc ses modules s'ajoutent à ceux-là. Ne + lire que le maillon nommé sous-estimerait ce qu'une installation + embarque, et l'outil déclarerait « rien ne manque » à tort. + + Une boucle dans `base` ferait tourner l'outil indéfiniment : on + s'arrête au premier nom déjà vu plutôt que de faire confiance au + fichier. + """ + chaine = [] + vus = set() + courant = name + # Borné par le NOMBRE de packages : une chaîne ne peut pas être plus + # longue que ce qui existe. La terminaison ne dépend donc pas de la + # garde `vus` — retirer l'une des deux donne un résultat FAUX, que le + # test attrape, au lieu d'un outil qui pend, que rien n'attrape. + for _ in range(len(packages) + 1): + if not courant or courant not in packages or courant in vus: + break + vus.add(courant) + chaine.append(courant) + courant = (packages[courant] or {}).get("base") or "" + chaine.reverse() + return chaine + + +def package_modules(name, packages): + """{module: package qui le suggère}, héritage compris. + + Le package d'ORIGINE est retenu, pas le dernier vu : savoir qu'un + module vient de `odoo16.0_base` et non de `odoo16.0_website` dit s'il + est fondamental ou accessoire. + """ + trouve = {} + for maillon in package_chain(name, packages): + for groupe in (packages[maillon] or {}).get("image_list") or []: + for module in (groupe or {}).get("module") or []: + trouve.setdefault(module, maillon) + return trouve + + +def db_version(database): + """« 18.0 » d'après le module `base`, ou None. + + C'est la version qu'Odoo lui-même inscrit, pas celle du checkout : une + base de palier 15 lue depuis un checkout 18 doit se comparer au + package 15, sans quoi le rapport nommerait des modules qui n'existaient + pas encore. + """ + lignes = run_psql( + database, + "SELECT latest_version FROM ir_module_module WHERE name = 'base'", + ) + if not lignes or not lignes[0][0]: + return None + morceaux = lignes[0][0].split(".") + if len(morceaux) < 2: + return None + return f"{morceaux[0]}.{morceaux[1]}" + + +def default_package(version): + """« 18.0 » -> « odoo18.0_base ». None si l'on ne sait pas.""" + return f"odoo{version}_base" if version else None + + +def column_types(database, table): + """{colonne: type} pour une table. {} si la base ne répond pas.""" + lignes = run_psql( + database, + "SELECT column_name, data_type FROM information_schema.columns" + f" WHERE table_name = '{table}'", + ) + return {ligne[0]: ligne[1] for ligne in lignes or [] if len(ligne) >= 2} + + +def as_text(colonne, types): + """Lire une colonne en texte, qu'elle soit varchar ou jsonb. + + Les champs traduisibles d'Odoo sont passés en jsonb à la 16 : + `shortdesc` est un varchar en 12-15 et un `{"en_US": "…"}` ensuite. + Cet outil vise les bases de PALIER d'une migration, donc les deux + formes se présentent dans la même session — supposer l'une fait + échouer la requête entière sur l'autre, et l'outil déclare alors la + base illisible alors qu'elle se porte bien. + """ + if types.get(colonne) == "jsonb": + return ( + f"coalesce({colonne} ->> 'en_US', {colonne} ->> 'fr_FR'," + f" {colonne} ->> 'fr_CA', '')" + ) + return f"coalesce({colonne}, '')" + + +def census(database): + """{module: (état, résumé, application, auteur)} pour TOUTE la base. + + None si la base ne répond pas — à distinguer d'une base vide, qui + rendrait un dictionnaire vide et ne veut pas dire la même chose. + """ + types = column_types(database, "ir_module_module") + if "name" not in types: + return None + lignes = run_psql( + database, + f"SELECT name, state, {as_text('shortdesc', types)}," + " case when application then '1' else '0' end," + f" {as_text('author', types)} FROM ir_module_module", + ) + if lignes is None: + return None + return { + ligne[0]: (ligne[1], ligne[2], ligne[3] == "1", ligne[4]) + for ligne in lignes + if len(ligne) >= 5 + } + + +def dependencies(database): + """{module: [ce dont il dépend]}. {} si la table ne répond pas.""" + lignes = run_psql( + database, + "SELECT m.name, d.name FROM ir_module_module_dependency d" + " JOIN ir_module_module m ON m.id = d.module_id", + ) + if not lignes: + return {} + dct = {} + for ligne in lignes: + if len(ligne) >= 2: + dct.setdefault(ligne[0], []).append(ligne[1]) + return dct + + +def verdict_of(module, connus): + """Le verdict d'un module suggéré, d'après ce que la base en sait.""" + if module not in connus: + return "unknown" + return ETAT_VERS_VERDICT.get(connus[module][0], "uninstallable") + + +def blocking_dependencies(module, connus, depend): + """Ce qu'installer `module` réclamerait et que la base n'a pas. + + Un module « disponible » ne l'est pas toujours vraiment : si trois de + ses dépendances sont absentes du chemin des addons, l'installer + échouera. Le dire ici évite de le découvrir en cliquant. + """ + manquantes = [] + for nom in sorted(set(depend.get(module) or [])): + if verdict_of(nom, connus) in ("unknown", "uninstallable"): + manquantes.append(nom) + return manquantes + + +def audit(database, package=None, packages=None, path=PACKAGE_FILE): + """Tout ce que le rapport a besoin de savoir, en une passe.""" + packages = read_packages(path) if packages is None else packages + connus = census(database) + if connus is None: + return {"unavailable": True, "database": database} + version = db_version(database) + nom = package or default_package(version) + suggere = package_modules(nom, packages) if nom else {} + depend = dependencies(database) + + lignes = [] + for module in sorted(suggere): + verdict = verdict_of(module, connus) + etat = connus.get(module, ("", "", False, "")) + lignes.append( + { + "module": module, + "from": suggere[module], + "verdict": verdict, + "state": etat[0], + "shortdesc": etat[1], + "needs": ( + blocking_dependencies(module, connus, depend) + if verdict == "available" + else [] + ), + } + ) + par_etat = {} + for etat, _desc, _app, _auteur in connus.values(): + par_etat[etat] = par_etat.get(etat, 0) + 1 + installes = { + nom_mod + for nom_mod, valeur in connus.items() + if valeur[0] in ("installed", "to upgrade") + } + return { + "database": database, + "version": version, + "package": nom, + "package_known": bool(nom and nom in packages), + "chain": package_chain(nom, packages) if nom else [], + "lines": lignes, + "by_state": par_etat, + "total": len(connus), + "installed": sorted(installes), + "extra": sorted(installes - set(suggere)), + "authors": authors_of(connus, installes), + } + + +def authors_of(connus, installes): + """[(auteur, combien)] parmi les modules installés, les gros d'abord. + + « L'ensemble des modules » d'une base ne se lit pas en listant trois + cents noms : l'auteur dit d'où ils viennent — Odoo, OCA, ou la maison. + """ + compte = {} + for nom in installes: + auteur = (connus[nom][3] or t("unknown author")).strip() + compte[auteur] = compte.get(auteur, 0) + 1 + return sorted(compte.items(), key=lambda item: (-item[1], item[0])) + + +def missing(rapport): + """Les lignes qui réclament une action, les plus graves en tête.""" + ordre = {verdict: rang for rang, verdict in enumerate(VERDICTS)} + return sorted( + ( + ligne + for ligne in rapport.get("lines") or [] + if ligne["verdict"] != "installed" + ), + key=lambda ligne: (ordre[ligne["verdict"]], ligne["module"]), + ) + + +def render(rapport, limit=0): + """Le rapport, en clair. `limit` borne les listes longues (0 = tout).""" + if rapport.get("unavailable"): + return [ + f"❌ {t('Cannot read the database: ')}{rapport['database']}", + ] + lignes = [ + f"📦 {t('Modules of')} {rapport['database']}" + f" ({t('Odoo')} {rapport['version'] or '?'})", + ] + if not rapport["package_known"]: + # Ne pas se taire : un package inconnu rend TOUT module « manquant », + # et l'on croirait à une base vide plutôt qu'à un nom mal choisi. + lignes.append( + f" ⚠ {t('No default package known for this version')}" + f" ({rapport['package'] or '?'})" + ) + lignes.append(f" {t('Compared against nothing — census only.')}") + else: + lignes.append( + f" {t('Default package:')} {' → '.join(rapport['chain'])}" + f" ({len(rapport['lines'])} {t('suggested module(s)')})" + ) + lignes.append("") + + lignes.append(f" {t('Census')} : {rapport['total']} {t('known')}") + for etat, combien in sorted( + rapport["by_state"].items(), key=lambda item: -item[1] + ): + lignes.append(f" {etat:<16} {combien:>5}") + if rapport["authors"]: + lignes.append("") + lignes.append(f" {t('Installed modules by author')} :") + for auteur, combien in rapport["authors"][: limit or None]: + lignes.append(f" {auteur[:52]:<54} {combien:>4}") + lignes.append("") + + absents = missing(rapport) + if not absents: + if rapport["package_known"]: + lignes.append(f" ✅ {t('Every suggested module is installed.')}") + return lignes + + lignes.append( + f" {len(absents)} {t('suggested module(s) not installed')} :" + ) + for verdict in VERDICTS: + groupe = [ligne for ligne in absents if ligne["verdict"] == verdict] + if not groupe: + continue + lignes.append( + f" {ICONE[verdict]} {len(groupe)} {t(EXPLICATION[verdict])}" + ) + for ligne in groupe[: limit or None]: + detail = ( + f" — {ligne['shortdesc'][:44]}" if ligne["shortdesc"] else "" + ) + lignes.append(f" {ligne['module']:<34}{detail}") + if ligne["needs"]: + lignes.append( + f" ↳ {t('also needs')} :" + f" {', '.join(ligne['needs'][:6])}" + ) + if limit and len(groupe) > limit: + lignes.append(f" … {len(groupe) - limit} {t('more')}") + return lignes + + +# Ce qu'il faut FAIRE, pas seulement ce que c'est : un rapport qui nomme +# l'état sans nommer le geste laisse le travail entier à faire. +EXPLICATION = { + "unknown": "absent from the addons path — sync the repo first", + "uninstallable": "present but broken — fix the dependency", + "pending": "half-way — finish the pending update", + "available": "known to the database — install it", + "installed": "installed", +} + + +def main(argv=None): + import argparse + + parser = argparse.ArgumentParser( + description=( + "List every module of a database and report which ones the" + " default ERPLibre package suggests but the database lacks." + ) + ) + parser.add_argument("-d", "--database", help="database to inspect") + parser.add_argument( + "-p", + "--package", + help="package to compare against (default: odoo_base)", + ) + parser.add_argument( + "--file", default=PACKAGE_FILE, help="package definition file" + ) + parser.add_argument( + "--list-packages", + action="store_true", + help="print every known package and exit", + ) + parser.add_argument( + "--limit", + type=int, + default=0, + help="cap long lists (0 = no cap)", + ) + parser.add_argument("--json", action="store_true", help="machine output") + config = parser.parse_args(argv) + + packages = read_packages(config.file) + if not packages: + print(f"❌ {t('Cannot read the package file: ')}{config.file}") + return 2 + if config.list_packages: + for nom in sorted(packages): + combien = len(package_modules(nom, packages)) + marque = ( + " (disabled)" if (packages[nom] or {}).get("disable") else "" + ) + print(f"{nom:<46} {combien:>4} module(s){marque}") + return 0 + if not config.database: + parser.error("--database is required (or use --list-packages)") + + rapport = audit(config.database, package=config.package, packages=packages) + if rapport.get("unavailable"): + print(f"❌ {t('Cannot read the database: ')}{config.database}") + return 2 + if config.json: + print( + json.dumps(rapport, indent=2, sort_keys=True, ensure_ascii=False) + ) + else: + print("\n".join(render(rapport, limit=config.limit))) + return 1 if missing(rapport) else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/todo/todo.py b/script/todo/todo.py index 3d19aa2..7db7f6a 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -10185,6 +10185,12 @@ class TODO: {"prompt_description": t("Studio and hand-made x_ fields")}, {"section": t("Migration")}, {"prompt_description": t("Quality of a migration, step by step")}, + {"section": t("Modules")}, + { + "prompt_description": t( + "Modules missing from the default package" + ) + }, ] help_info = self.fill_help_info(choices) @@ -10201,9 +10207,54 @@ class TODO: self.execute_analyse_custom_field() elif status == "4": self.execute_analyse_migration_quality() + elif status == "5": + self.execute_analyse_module_package() else: print(t("Command not found !")) + def execute_analyse_module_package(self): + """Ce que la base n'a pas, alors que l'installation par défaut l'a. + + Pas de choix « sauvegarde .zip » ici, contrairement aux autres + analyses : l'outil interroge `ir_module_module`, qu'un zip + n'expose pas sans restauration. Proposer l'option pour la refuser + ensuite ferait perdre le temps de la choisir. + """ + from script.analyse import check_module_package as modules + + database = self._analyse_select_database() + if not database: + return + try: + rapport = modules.audit(database) + except Exception as exc: + print(f"❌ {t('Analysis failed: ')}{exc}") + return + if rapport.get("unavailable"): + print(f"❌ {t('Cannot read the database: ')}{database}") + return + print("\n".join(modules.render(rapport, limit=8))) + + def handler(rank): + if rank == 1: + print("\n".join(modules.render(rapport, limit=0))) + elif rank == 2: + for nom in sorted(modules.read_packages()): + print(f" {nom}") + else: + self._analyse_export_json( + rapport, os.path.basename(database), "module_package" + ) + + self._analyse_follow_up( + [ + {"prompt_description": t("Show every entry")}, + {"prompt_description": t("List the known packages")}, + {"prompt_description": t("Export as JSON")}, + ], + handler, + ) + def execute_analyse_migration_quality(self): """Ce qu'une migration a gagné et perdu, palier par palier. diff --git a/test/test_check_module_package.py b/test/test_check_module_package.py new file mode 100644 index 0000000..beb2a59 --- /dev/null +++ b/test/test_check_module_package.py @@ -0,0 +1,570 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Ce que l'outil doit garantir avant qu'on lui fasse confiance. + +Deux propriétés valent tous les autres tests. La première : un module +absent n'est pas UNE catégorie — dire « manquant » là où il fallait dire +« absent du chemin des addons » envoie installer ce qui ne peut pas +l'être. La seconde : l'outil vise des bases 12 à 18 dans la même session, +et `shortdesc` y est tantôt varchar tantôt jsonb ; se tromper fait +échouer la requête entière et l'outil déclare la base illisible alors +qu'elle se porte bien. +""" + +import ast +import io +import os +import sys +import unittest +from contextlib import redirect_stdout + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) +) + +from script.analyse import check_module_package as modules # noqa: E402 +from script.todo import todo_i18n # noqa: E402 + +PACKAGES = { + "odoo18.0_base": { + "base": "", + "image_list": [{"module": ["queue_job", "web_dark_mode"]}], + }, + "odoo18.0_website": { + "base": "odoo18.0_base", + "image_list": [{"module": ["website_extra", "queue_job"]}], + }, + "boucle_a": {"base": "boucle_b", "image_list": [{"module": ["a"]}]}, + "boucle_b": {"base": "boucle_a", "image_list": [{"module": ["b"]}]}, +} + + +class TestThePackageFile(unittest.TestCase): + def test_the_path_is_absolute(self): + # Un chemin relatif ne marchait que depuis la racine du dépôt : + # lancé du menu TODO ou d'ailleurs, l'outil disait « fichier de + # packages illisible » et envoyait chercher au mauvais endroit. + self.assertTrue(os.path.isabs(modules.PACKAGE_FILE)) + + def test_the_real_file_parses(self): + reel = modules.read_packages() + self.assertIn("odoo18.0_base", reel) + + def test_an_unreadable_file_is_empty_not_a_crash(self): + self.assertEqual(modules.read_packages("/nowhere/at/all.json"), {}) + + def test_a_file_that_is_not_a_dict_is_refused(self): + chemin = os.path.join(os.path.dirname(__file__), "..", "README.md") + self.assertEqual(modules.read_packages(chemin), {}) + + +class TestTheChain(unittest.TestCase): + def test_it_runs_root_first(self): + self.assertEqual( + modules.package_chain("odoo18.0_website", PACKAGES), + ["odoo18.0_base", "odoo18.0_website"], + ) + + def test_a_root_is_alone(self): + self.assertEqual( + modules.package_chain("odoo18.0_base", PACKAGES), ["odoo18.0_base"] + ) + + def test_an_unknown_name_gives_nothing(self): + self.assertEqual(modules.package_chain("pas_moi", PACKAGES), []) + + def test_a_cycle_stops_instead_of_spinning(self): + # Sans garde, `base` circulaire ferait tourner l'outil sans fin. + chaine = modules.package_chain("boucle_a", PACKAGES) + self.assertEqual(sorted(chaine), ["boucle_a", "boucle_b"]) + + +class TestWhatAPackageSuggests(unittest.TestCase): + def test_it_inherits_from_its_base(self): + trouve = modules.package_modules("odoo18.0_website", PACKAGES) + self.assertEqual( + sorted(trouve), ["queue_job", "web_dark_mode", "website_extra"] + ) + + def test_it_keeps_the_package_of_ORIGIN(self): + # Savoir qu'un module vient de la base et non de l'extension dit + # s'il est fondamental ou accessoire ; garder le dernier vu + # attribuerait tout à la feuille. + trouve = modules.package_modules("odoo18.0_website", PACKAGES) + self.assertEqual(trouve["queue_job"], "odoo18.0_base") + self.assertEqual(trouve["website_extra"], "odoo18.0_website") + + def test_the_real_18_package_has_its_fifteen(self): + reel = modules.read_packages() + self.assertEqual( + len(modules.package_modules("odoo18.0_base", reel)), 15 + ) + + +class TestTheVersion(unittest.TestCase): + def test_it_keeps_two_components(self): + self.assertEqual(modules.default_package("18.0"), "odoo18.0_base") + + def test_no_version_gives_no_package(self): + self.assertIsNone(modules.default_package(None)) + + +class TestReadingAColumn(unittest.TestCase): + """`shortdesc` est varchar jusqu'en 15, jsonb ensuite.""" + + def test_a_plain_column_is_read_plainly(self): + sql = modules.as_text("shortdesc", {"shortdesc": "character varying"}) + self.assertNotIn("->>", sql) + self.assertIn("shortdesc", sql) + + def test_a_jsonb_column_is_extracted(self): + sql = modules.as_text("shortdesc", {"shortdesc": "jsonb"}) + self.assertIn("->>", sql) + self.assertIn("en_US", sql) + + def test_an_unknown_column_is_treated_as_plain(self): + self.assertNotIn("->>", modules.as_text("shortdesc", {})) + + +class TestTheVerdicts(unittest.TestCase): + CONNUS = { + "installe": ("installed", "", False, "Odoo"), + "a_maj": ("to upgrade", "", False, "Odoo"), + "dispo": ("uninstalled", "", False, "OCA"), + "casse": ("uninstallable", "", False, "OCA"), + "en_cours": ("to install", "", False, "OCA"), + } + + def test_each_state_maps_to_its_verdict(self): + for nom, attendu in ( + ("installe", "installed"), + ("a_maj", "installed"), + ("dispo", "available"), + ("casse", "uninstallable"), + ("en_cours", "pending"), + ): + self.assertEqual( + modules.verdict_of(nom, self.CONNUS), attendu, nom + ) + + def test_a_module_the_database_never_heard_of_is_unknown(self): + # Le distinguer de « disponible » est tout l'intérêt : il n'y a + # rien à installer tant que le chemin des addons est incomplet. + self.assertEqual( + modules.verdict_of("jamais_vu", self.CONNUS), "unknown" + ) + + def test_an_unexpected_state_is_not_silently_installed(self): + # Un état qu'Odoo ajouterait demain ne doit pas passer pour + # installé : mieux vaut le signaler que de le taire. + etrange = {"x": ("something_new", "", False, "")} + self.assertNotEqual(modules.verdict_of("x", etrange), "installed") + + +class TestBlockingDependencies(unittest.TestCase): + CONNUS = { + "moi": ("uninstalled", "", False, ""), + "ok": ("installed", "", False, ""), + "dispo": ("uninstalled", "", False, ""), + "casse": ("uninstallable", "", False, ""), + } + + def test_only_what_truly_blocks_is_listed(self): + depend = {"moi": ["ok", "dispo", "casse", "absent"]} + self.assertEqual( + modules.blocking_dependencies("moi", self.CONNUS, depend), + ["absent", "casse"], + ) + + def test_no_dependency_is_no_problem(self): + self.assertEqual( + modules.blocking_dependencies("moi", self.CONNUS, {}), [] + ) + + +class FakeBase: + """Une base qui répond, sans PostgreSQL. + + On aiguille sur le SQL plutôt que sur l'ordre des appels : un test qui + compte les appels casse dès qu'on réordonne le code sans rien changer + au comportement. + """ + + def __init__( + self, + connus, + depend=None, + jsonb=False, + muette=False, + sans_colonnes=False, + sans_recensement=False, + ): + self.connus = connus + self.depend = depend or {} + self.jsonb = jsonb + self.muette = muette + self.sans_colonnes = sans_colonnes + self.sans_recensement = sans_recensement + self.vues = [] + + def __call__(self, database, sql): + self.vues.append(sql) + if self.muette: + return None + if "information_schema.columns" in sql: + if self.sans_colonnes: + return [] + desc = "jsonb" if self.jsonb else "character varying" + return [ + ["name", "character varying"], + ["shortdesc", desc], + ["author", "character varying"], + ["state", "character varying"], + ] + if "latest_version" in sql: + return [["18.0.1.3"]] + if "ir_module_module_dependency" in sql: + return [ + [mod, dep] for mod, lst in self.depend.items() for dep in lst + ] + if "FROM ir_module_module" in sql: + if self.sans_recensement: + return None + return [ + [nom, etat, desc, "1" if app else "0", auteur] + for nom, (etat, desc, app, auteur) in self.connus.items() + ] + return [] + + +CONNUS = { + "queue_job": ("uninstalled", "Job Queue", False, "OCA"), + "web_dark_mode": ("installed", "Dark Mode", False, "OCA"), + "base": ("installed", "Base", False, "Odoo S.A."), + "casse": ("uninstallable", "Cassé", False, "OCA"), +} + + +class TestTheAudit(unittest.TestCase): + def setUp(self): + self.vrai = modules.run_psql + + def tearDown(self): + modules.run_psql = self.vrai + + def audite(self, connus=None, depend=None, jsonb=False, package=None): + modules.run_psql = FakeBase( + CONNUS if connus is None else connus, depend, jsonb + ) + return modules.audit("db", package=package, packages=PACKAGES) + + def test_it_finds_the_package_from_the_database_version(self): + rapport = self.audite() + self.assertEqual(rapport["version"], "18.0") + self.assertEqual(rapport["package"], "odoo18.0_base") + self.assertTrue(rapport["package_known"]) + + def test_it_classifies_each_suggested_module(self): + rapport = self.audite() + verdicts = { + ligne["module"]: ligne["verdict"] for ligne in rapport["lines"] + } + self.assertEqual( + verdicts, {"queue_job": "available", "web_dark_mode": "installed"} + ) + + def test_a_module_absent_from_the_database_is_unknown(self): + rapport = self.audite( + connus={"base": ("installed", "", False, "Odoo")} + ) + verdicts = { + ligne["module"]: ligne["verdict"] for ligne in rapport["lines"] + } + self.assertEqual(verdicts["queue_job"], "unknown") + + def test_it_reports_what_installing_would_still_need(self): + rapport = self.audite( + depend={"queue_job": ["casse", "base", "fantome"]} + ) + ligne = [x for x in rapport["lines"] if x["module"] == "queue_job"][0] + self.assertEqual(ligne["needs"], ["casse", "fantome"]) + + def test_a_jsonb_database_is_read_not_refused(self): + # Sur une base 16+, `shortdesc` est jsonb. Une requête écrite pour + # du varchar y échoue ENTIÈREMENT et l'outil déclarerait la base + # illisible. + rapport = self.audite(jsonb=True) + self.assertFalse(rapport.get("unavailable")) + recensement = [ + s + for s in modules.run_psql.vues + if "FROM ir_module_module" in s + and "dependency" not in s + and "latest_version" not in s + ] + self.assertTrue(any("->>" in s for s in recensement), recensement) + + def test_a_silent_database_is_unavailable_not_empty(self): + # Rendre un rapport vide ferait croire à une base sans modules. + modules.run_psql = FakeBase({}, muette=True) + self.assertTrue(modules.audit("db", packages=PACKAGES)["unavailable"]) + + def test_a_database_without_the_module_table_is_unavailable(self): + # Pas de table `ir_module_module` : ce n'est pas une base Odoo. + modules.run_psql = FakeBase(CONNUS, sans_colonnes=True) + self.assertTrue(modules.audit("db", packages=PACKAGES)["unavailable"]) + + def test_a_census_query_that_fails_is_unavailable(self): + # Les colonnes répondent, le recensement non — l'autre garde. Les + # tester ensemble laissait chacune masquer la panne de l'autre. + modules.run_psql = FakeBase(CONNUS, sans_recensement=True) + self.assertTrue(modules.audit("db", packages=PACKAGES)["unavailable"]) + + def test_the_census_counts_every_module_not_only_the_suggested(self): + rapport = self.audite() + self.assertEqual(rapport["total"], len(CONNUS)) + self.assertEqual(rapport["by_state"]["installed"], 2) + + def test_extras_are_what_is_installed_beyond_the_package(self): + rapport = self.audite() + self.assertIn("base", rapport["extra"]) + self.assertNotIn("web_dark_mode", rapport["extra"]) + + def test_an_unknown_package_is_flagged_not_silently_empty(self): + rapport = self.audite(package="jamais_defini") + self.assertFalse(rapport["package_known"]) + self.assertEqual(rapport["lines"], []) + + +class TestTheOrderOfTheReport(unittest.TestCase): + def ligne(self, module, verdict): + return { + "module": module, + "verdict": verdict, + "from": "p", + "state": "", + "shortdesc": "", + "needs": [], + } + + def test_the_worst_comes_first(self): + # « unknown » demande de réparer le chemin des addons avant tout + # le reste : le lire en dernier ferait installer dans le vide. + rapport = { + "lines": [ + self.ligne("aaa", "available"), + self.ligne("zzz", "unknown"), + self.ligne("mmm", "uninstallable"), + self.ligne("bbb", "installed"), + ] + } + self.assertEqual( + [x["module"] for x in modules.missing(rapport)], + ["zzz", "mmm", "aaa"], + ) + + def test_installed_modules_never_appear(self): + rapport = {"lines": [self.ligne("z", "installed")]} + self.assertEqual(modules.missing(rapport), []) + + def test_every_verdict_has_a_rank_and_an_icon_and_a_wording(self): + # Un verdict sans rang ferait planter le tri ; sans libellé, le + # rapport nommerait l'état sans nommer le geste. + for verdict in modules.VERDICTS: + self.assertIn(verdict, modules.ICONE) + self.assertIn(verdict, modules.EXPLICATION) + for verdict in modules.ETAT_VERS_VERDICT.values(): + self.assertIn(verdict, modules.VERDICTS) + + +class TestTheRendering(unittest.TestCase): + def rapport(self, **extra): + base = { + "database": "db", + "version": "18.0", + "package": "odoo18.0_base", + "package_known": True, + "chain": ["odoo18.0_base"], + "lines": [], + "by_state": {"installed": 2}, + "total": 2, + "installed": ["base"], + "extra": [], + "authors": [("Odoo S.A.", 2)], + } + base.update(extra) + return base + + def test_a_clean_database_says_so(self): + texte = "\n".join(modules.render(self.rapport())) + self.assertIn( + todo_i18n.t("Every suggested module is installed."), texte + ) + + def test_an_unknown_package_never_claims_success(self): + # Comparé à rien, TOUT semble installé : le dire serait un + # mensonge tranquille, le pire des rapports. + texte = "\n".join( + modules.render(self.rapport(package_known=False, package="?")) + ) + self.assertNotIn( + todo_i18n.t("Every suggested module is installed."), texte + ) + self.assertIn( + todo_i18n.t("No default package known for this version"), texte + ) + + def test_a_missing_module_names_the_action(self): + ligne = { + "module": "queue_job", + "verdict": "unknown", + "from": "p", + "state": "", + "shortdesc": "Job Queue", + "needs": [], + } + texte = "\n".join(modules.render(self.rapport(lines=[ligne]))) + self.assertIn("queue_job", texte) + self.assertIn( + todo_i18n.t("absent from the addons path — sync the repo first"), + texte, + ) + + def test_an_unreadable_database_renders_without_crashing(self): + texte = "\n".join( + modules.render({"unavailable": True, "database": "x"}) + ) + self.assertIn("x", texte) + + def test_the_limit_caps_the_list_and_says_how_many_were_hidden(self): + lignes = [ + { + "module": f"m{i}", + "verdict": "available", + "from": "p", + "state": "", + "shortdesc": "", + "needs": [], + } + for i in range(10) + ] + texte = "\n".join(modules.render(self.rapport(lines=lignes), limit=3)) + self.assertIn("m0", texte) + self.assertNotIn("m9", texte) + self.assertIn(f"7 {todo_i18n.t('more')}", texte) + + +class TestTheCommandLine(unittest.TestCase): + def setUp(self): + self.vrai = modules.run_psql + + def tearDown(self): + modules.run_psql = self.vrai + + @classmethod + def setUpClass(cls): + import json + import tempfile + + cls.dossier = tempfile.TemporaryDirectory() + cls.fichier = os.path.join(cls.dossier.name, "packages.json") + with io.open(cls.fichier, "w", encoding="utf-8") as handle: + json.dump(PACKAGES, handle) + + @classmethod + def tearDownClass(cls): + cls.dossier.cleanup() + + def lance(self, argv, connus=CONNUS, muette=False): + modules.run_psql = FakeBase(connus, muette=muette) + argv = list(argv) + ["--file", self.fichier] + tampon = io.StringIO() + with redirect_stdout(tampon): + code = modules.main(argv) + return code, tampon.getvalue() + + def test_nothing_missing_exits_zero(self): + code, _ = self.lance( + ["-d", "db", "-p", "odoo18.0_base"], + connus={ + "web_dark_mode": ("installed", "", False, ""), + "queue_job": ("installed", "", False, ""), + }, + ) + self.assertEqual(code, 0) + + def test_a_finding_exits_one(self): + code, sortie = self.lance(["-d", "db"]) + self.assertEqual(code, 1) + self.assertIn("queue_job", sortie) + + def test_an_unreadable_database_exits_two(self): + # 2 dit « l'outil a échoué », pas « rien trouvé » : un script qui + # confond les deux conclurait que tout va bien. + code, _ = self.lance(["-d", "db"], muette=True) + self.assertEqual(code, 2) + + def test_listing_packages_needs_no_database(self): + code, sortie = self.lance(["--list-packages"]) + self.assertEqual(code, 0) + self.assertIn("odoo18.0_base", sortie) + + def test_json_output_is_parseable(self): + import json + + code, sortie = self.lance(["-d", "db", "--json"]) + self.assertEqual(code, 1) + self.assertEqual(json.loads(sortie)["package"], "odoo18.0_base") + + +class TestTheWiring(unittest.TestCase): + RACINE = os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) + + def source(self, chemin): + with io.open(os.path.join(self.RACINE, chemin), encoding="utf-8") as f: + return f.read() + + def test_every_translation_key_exists(self): + # Une clé absente s'affiche en anglais au milieu du français, et + # rien ne le signale à l'exécution. + src = self.source("script/analyse/check_module_package.py") + arbre = ast.parse(src) + cles = set() + for node in ast.walk(arbre): + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "t" + and node.args + and isinstance(node.args[0], ast.Constant) + ): + cles.add(node.args[0].value) + cles.update(modules.EXPLICATION.values()) + manquantes = [c for c in cles if c not in todo_i18n.TRANSLATIONS] + self.assertEqual(manquantes, []) + + def test_the_menu_offers_the_entry_and_dispatches_it(self): + src = self.source("script/todo/todo.py") + self.assertIn("Modules missing from the default package", src) + self.assertIn("self.execute_analyse_module_package()", src) + self.assertIn("def execute_analyse_module_package", src) + + def test_the_menu_has_as_many_entries_as_branches(self): + # Ajouter une entrée sans son aiguillage donne « Command not + # found » sur un choix que le menu vient d'afficher. + src = self.source("script/todo/todo.py") + debut = src.index("def prompt_execute_analyse") + fin = src.index("def execute_analyse_module_package") + self.assertLess(debut, fin) + bloc = src[debut:fin] + entrees = bloc.count('"prompt_description"') + branches = sum( + f'status == "{n}"' in bloc for n in range(1, entrees + 2) + ) + self.assertEqual(entrees, branches) + + +if __name__ == "__main__": + unittest.main()