[ADD] analyse: modules a database lacks vs the default package

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
This commit is contained in:
Mathieu Benoit 2026-08-21 04:01:01 -04:00
parent a2cedfa64b
commit 97d2ebec65
3 changed files with 1120 additions and 0 deletions

View file

@ -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<version>_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())

View file

@ -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.

View file

@ -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()