[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:
parent
a2cedfa64b
commit
97d2ebec65
3 changed files with 1120 additions and 0 deletions
499
script/analyse/check_module_package.py
Normal file
499
script/analyse/check_module_package.py
Normal 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())
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
570
test/test_check_module_package.py
Normal file
570
test/test_check_module_package.py
Normal 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()
|
||||
Loading…
Reference in a new issue