From 8fe0bafe10af3cf3cdb2e0e04efeed6e3e208133 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 27 Aug 2026 02:36:58 -0400 Subject: [PATCH] =?UTF-8?q?[ADD]=20migration=20:=20d=C3=A9tecter=20le=20ty?= =?UTF-8?q?pe=20de=20vue=20tree=20rest=C3=A9=20dans=20les=20sources?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Odoo 18 a supprimé le type de vue tree, sans conversion : un module resté dessus ne s'installe pas, ou casse au clic (« View types not defined tree found in act_window »). Les autres outils de la revue lisent la BASE, où un module jamais installé ne laisse rien. Le mot reste un identifiant valide : sur 465 occurrences, 80 cassent — le noyau 18 garde account.view_invoice_tree en . D'où lxml et ast, jamais de regex : seule la position du littéral décide. Trois portes filtrent avant tout motif : version, module installable, fichier chargé. Balayage : 80 constats sur 2654 modules, 26 touchés. --- EN --- Odoo 18 removed the tree view type with no shim: a module still on it fails to install, or breaks on click ("View types not defined tree found in act_window"). The other review tools read the DATABASE, where a module that never installed leaves nothing. The word is still a valid identifier: of 465 occurrences, 80 break — the 18 core keeps account.view_invoice_tree in . Hence lxml and ast, never regex: only the literal's position decides. Three gates filter before any pattern: version, installable module, loaded file. Sweep: 80 findings over 2654 modules, 26 affected. Assisted-by: Claude Opus 5 (cherry picked from commit 38a91039d111b3fa6de32edf315c5caae8553c11) --- script/analyse/check_migration_quality.py | 5 + script/analyse/check_view_type_tree.py | 525 ++++++++++++++++++++++ script/todo/todo_i18n.py | 33 ++ test/test_check_migration_quality.py | 22 +- test/test_check_view_type_tree.py | 491 ++++++++++++++++++++ 5 files changed, 1067 insertions(+), 9 deletions(-) create mode 100755 script/analyse/check_view_type_tree.py create mode 100644 test/test_check_view_type_tree.py diff --git a/script/analyse/check_migration_quality.py b/script/analyse/check_migration_quality.py index 69e9850..b2de944 100755 --- a/script/analyse/check_migration_quality.py +++ b/script/analyse/check_migration_quality.py @@ -253,6 +253,11 @@ REVUE = ( "script/analyse/check_manifest_gaps.py --upstream", "manifest", ), + ( + "Is any module still on a pre-18 view type?", + "script/analyse/check_view_type_tree.py", + "viewtype", + ), ) diff --git a/script/analyse/check_view_type_tree.py b/script/analyse/check_view_type_tree.py new file mode 100755 index 0000000..51f3db9 --- /dev/null +++ b/script/analyse/check_view_type_tree.py @@ -0,0 +1,525 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Le type de vue « tree » qui survit dans un module, et casse à l'usage. + +Odoo 18 a supprimé le type `tree`. La sélection de `ir.ui.view.type` est +`list, form, graph, pivot, calendar, kanban, search, qweb` +(ir_ui_view.py:153), et le noyau ne porte AUCUNE conversion de +compatibilité — vérifié par balayage : la seule occurrence hors `etree` +est `odoo/tests/form.py:261`, sans rapport. + +Pourquoi cet outil-ci ne lit pas la base +---------------------------------------- +C'est l'angle mort de tous les autres. Un module resté sur `` ne +s'installe pas : il n'y a donc RIEN à lire en base, et un rapport tiré de +la base dit « tout va bien » avec assurance. Le défaut est sur le DISQUE, +et il revient à chaque resynchronisation de Google Repo. + +Le piège, et pourquoi ni grep ni regex +-------------------------------------- +« tree » est d'abord un morceau d'IDENTIFIANT. Sur 465 occurrences +mesurées dans `odoo18.0/addons`, une soixantaine cassent ; le reste est +un id de ``, un ``, un `env.ref()`, un nom de +variable. Et ce n'est pas de la négligence : le noyau 18 a gardé ses +propres identifiants historiques en ne renommant que les balises — +`account.view_invoice_tree` existe toujours, son arch en ``. + +La démonstration tient en deux lignes du même dépôt : + + openeducat_admission/models/admission.py:420 + (tree_view and tree_view.id or False, 'list') ← sain + openeducat_fees/models/student.py:142 + (tree_view and tree_view.id or False, 'tree') ← cassé + +Même nom de variable, même forme. Seul le littéral en POSITION de type +décide. D'où la règle : lxml et `ast`, jamais la ligne brute. Le bénéfice +est double — les commentaires disparaissent d'eux-mêmes, puisque ni l'un +ni l'autre ne construit de nœud pour eux. + +Trois portes avant tout motif +----------------------------- +Ce sont elles qui font le tri, pas les motifs. + +1. VERSION — rien à faire sous la 18 : `tree` y est valide. +2. MODULE — un fichier ne compte que sous un `__manifest__.py`, lu par + `literal_eval`. `installable: False` disqualifie le module entier. +3. CHARGEMENT — un XML doit figurer dans `data` ou `demo` ; un Python + doit être ATTEIGNABLE depuis `__init__.py`. Un fichier que personne ne + charge ne casse rien, et `literal_eval` fait disparaître d'elle-même + toute entrée commentée du manifeste. + +Ce que la balise en sous-vue fait vraiment +------------------------------------------ +Un `` sous un x2many n'est pas ignoré poliment : `ir_ui_view.py` +ne le reconnaît pas, donc ne le RETIRE pas, et ses `` repartent +dans la validation avec le modèle du formulaire PARENT. Le message parle +alors d'un champ inexistant et ne prononce jamais le mot « tree ». +""" + +from __future__ import annotations + +import ast +import io +import os +import sys +import warnings + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..")) +) + +try: + from script.todo.todo_i18n import t +except Exception: # pragma: no cover - repli si i18n indisponible + + def t(key: str) -> str: + return key + + +REPO_ROOT = os.path.normpath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..") +) +FICHIER_VERSION = ".odoo-version" +PREMIERE_VERSION_SANS_TREE = (18, 0) +MANIFESTE = "__manifest__.py" + +COULEURS = { + "broken": "\033[31m", + "watch": "\033[33m", + "ok": "\033[32m", + "step": "\033[36m", + "dim": "\033[90m", +} +RESET = "\033[0m" + + +def paint(texte, genre, colour): + """Teinter, ou rendre le texte tel quel quand la couleur est coupée.""" + if not colour: + return texte + return f"{COULEURS.get(genre, '')}{texte}{RESET}" + + +def version_active(racine=REPO_ROOT): + """(majeur, mineur) du checkout, ou None si illisible.""" + try: + with io.open( + os.path.join(racine, FICHIER_VERSION), encoding="utf-8" + ) as handle: + brut = handle.read().strip() + except OSError: + return None + morceaux = brut.split(".") + try: + return (int(morceaux[0]), int(morceaux[1])) + except (IndexError, ValueError): + return None + + +def concerne(version): + """La 18 et au-delà. Sous elle, `tree` est valide — au pire déprécié.""" + return bool(version) and version >= PREMIERE_VERSION_SANS_TREE + + +def lire_manifeste(chemin): + """Le dict du manifeste, par `literal_eval` — jamais par import. + + Un `__manifest__.py` est une expression, pas un programme : l'évaluer + exécuterait du code d'un dépôt tiers pour lire quatre clés. Et + `literal_eval` fait disparaître d'elle-même toute entrée commentée. + """ + try: + with io.open(chemin, encoding="utf-8") as handle: + valeur = ast.literal_eval(handle.read()) + except (OSError, ValueError, SyntaxError, MemoryError, RecursionError): + return None + return valeur if isinstance(valeur, dict) else None + + +def modules(racine): + """[(chemin du module, manifeste)] — ceux qu'Odoo installerait. + + `installable: False` disqualifie : Odoo ne le liste même pas, il ne + peut casser ni à l'installation ni à l'exécution. + """ + lst = [] + for dossier, sous, fichiers in os.walk(racine): + if MANIFESTE not in fichiers: + continue + sous[:] = [] # un module ne contient pas un autre module + manifeste = lire_manifeste(os.path.join(dossier, MANIFESTE)) + if manifeste is None: + continue + if manifeste.get("installable", True) is False: + continue + lst.append((dossier, manifeste)) + return sorted(lst) + + +def xml_charges(module, manifeste): + """Les XML que le manifeste demande de charger, et qui existent.""" + lst, absents = [], [] + for clef in ("data", "demo"): + for relatif in manifeste.get(clef) or []: + if not isinstance(relatif, str) or not relatif.endswith(".xml"): + continue + chemin = os.path.join(module, relatif) + (lst if os.path.isfile(chemin) else absents).append(chemin) + return lst, absents + + +def py_atteignables(module): + """Les .py qu'un `import` du module finit par charger. + + On SUIT les `from . import x`, plutôt que de ramasser tout le dossier : + un fichier que personne n'importe ne s'exécute jamais, et le signaler + ferait crier sur des brouillons laissés là. + """ + vus, atteints = set(), [] + pile = [module] + while pile: + paquet = pile.pop() + init = os.path.join(paquet, "__init__.py") + if init in vus or not os.path.isfile(init): + continue + vus.add(init) + atteints.append(init) + try: + with io.open(init, encoding="utf-8") as handle: + arbre = ast.parse(handle.read()) + except (OSError, SyntaxError, ValueError): + continue + for noeud in ast.walk(arbre): + noms = [] + if isinstance(noeud, ast.ImportFrom) and noeud.level: + if noeud.module: + noms.append(noeud.module) + noms += [a.name for a in noeud.names] + elif isinstance(noeud, ast.Import): + noms = [a.name.split(".")[0] for a in noeud.names] + for nom in noms: + relatif = nom.replace(".", os.sep) + fichier = os.path.join(paquet, relatif + ".py") + dossier = os.path.join(paquet, relatif) + if os.path.isfile(fichier) and fichier not in vus: + vus.add(fichier) + atteints.append(fichier) + elif os.path.isdir(dossier): + pile.append(dossier) + return [c for c in atteints if os.sep + "tests" + os.sep not in c] + + +def _modes(texte): + """« form, tree » → ['form', 'tree'] — le découpage est obligatoire. + + Un simple `in` attraperait aussi un futur « treemap », et raterait + l'espace après la virgule que les vues réelles écrivent. + """ + return [x.strip() for x in (texte or "").split(",")] + + +def ligne_de_la_balise(chemin, sourceline, balise="tree"): + """lxml rend la ligne où FINIT la balise ouvrante ; on remonte. + + Mesuré : sur une balise étalée sur six lignes, `sourceline` disait 66 + quand `. + if not isinstance(el.tag, str): + continue + if el.tag == "tree": + lst.append( + ( + ligne_de_la_balise(chemin, el.sourceline), + "xml_balise", + "", + "", + ) + ) + continue + expr = el.get("expr") + if expr: + segments = expr.replace("//", "/").split("/") + for segment in segments: + if segment.split("[")[0] == "tree": + lst.append((el.sourceline, "xml_xpath", expr[:70], "list")) + break + if el.tag != "field": + continue + nom = el.get("name") + texte = (el.text or "").strip() + if nom == "view_mode" and "tree" in _modes(texte): + lst.append( + ( + el.sourceline, + "xml_view_mode", + texte[:60], + texte.replace("tree", "list"), + ) + ) + elif nom == "type" and texte == "tree": + lst.append((el.sourceline, "xml_type", texte, "list")) + return lst + + +def _est_tuple_de_vue(noeud): + """(id, 'tree') — un couple dont le SECOND membre est le type. + + Le premier membre est un id, False, un Name ou une expression : jamais + une chaîne. Sans cette condition, la règle crierait sur les couples de + compatibilité ('list', 'tree'), qui sont du code défensif correct. + """ + if not isinstance(noeud, (ast.Tuple, ast.List)) or len(noeud.elts) != 2: + return False + premier, second = noeud.elts + if not isinstance(second, ast.Constant) or second.value != "tree": + return False + return not ( + isinstance(premier, ast.Constant) and isinstance(premier.value, str) + ) + + +def constats_py(chemin): + """[(ligne, motif, extrait, correction)] pour UN fichier python.""" + try: + with io.open(chemin, encoding="utf-8") as handle: + source = handle.read() + with warnings.catch_warnings(): + warnings.simplefilter("ignore", SyntaxWarning) + arbre = ast.parse(source) + except (OSError, SyntaxError, ValueError): + return None + lst = [] + for noeud in ast.walk(arbre): + if _est_tuple_de_vue(noeud): + lst.append( + (noeud.lineno, "py_views", "(…, 'tree')", "(…, 'list')") + ) + continue + if not isinstance(noeud, ast.Dict): + continue + paires = { + clef.value: valeur + for clef, valeur in zip(noeud.keys, noeud.values) + if isinstance(clef, ast.Constant) and isinstance(clef.value, str) + } + est_action = ( + isinstance(paires.get("type"), ast.Constant) + and paires["type"].value == "ir.actions.act_window" + ) + for clef in ("view_mode", "view_type"): + valeur = paires.get(clef) + if not isinstance(valeur, ast.Constant): + continue + if not isinstance(valeur.value, str): + continue + if "tree" not in _modes(valeur.value): + continue + # `view_type` sur un dict d'act_window est un vestige d'Odoo 12 + # que la 18 ignore sans bruit. Ailleurs — un `code.generator. + # view`, dont la sélection ne propose que `list` — il lève. + if clef == "view_type" and est_action: + continue + lst.append( + ( + valeur.lineno, + "py_" + clef, + valeur.value[:60], + valeur.value.replace("tree", "list"), + ) + ) + return lst + + +def inspect(racine, sous_dossier="addons"): + """{'findings': […], 'unreadable': […], 'missing': […]} pour un checkout.""" + version = version_active(racine) + rapport = { + "version": version, + "scanned": 0, + "findings": [], + "unreadable": [], + "missing": [], + } + if not concerne(version): + return rapport + base = os.path.join(racine, "odoo%d.%d" % version, sous_dossier) + if not os.path.isdir(base): + return rapport + for module, manifeste in modules(base): + rapport["scanned"] += 1 + court = os.path.basename(module) + charges, absents = xml_charges(module, manifeste) + for chemin in absents: + rapport["missing"].append((court, os.path.relpath(chemin, racine))) + for chemin in charges: + lst = constats_xml(chemin) + if lst is None: + rapport["unreadable"].append( + (court, os.path.relpath(chemin, racine)) + ) + continue + for ligne, motif, extrait, correction in lst: + rapport["findings"].append( + ( + court, + os.path.relpath(chemin, racine), + ligne, + motif, + extrait, + correction, + ) + ) + for chemin in py_atteignables(module): + lst = constats_py(chemin) + if lst is None: + rapport["unreadable"].append( + (court, os.path.relpath(chemin, racine)) + ) + continue + for ligne, motif, extrait, correction in lst: + rapport["findings"].append( + ( + court, + os.path.relpath(chemin, racine), + ligne, + motif, + extrait, + correction, + ) + ) + return rapport + + +def render(rapport, colour=True): + """Le rapport lisible, groupé par module.""" + lignes = [f"🌲 {t('View type tree, removed in Odoo 18')}", ""] + if not concerne(rapport["version"]): + lignes.append( + paint( + f" {t('nothing to do below 18.0 — tree is valid there')}", + "dim", + colour, + ) + ) + return "\n".join(lignes) + + trouves = rapport["findings"] + if not trouves: + lignes.append( + paint( + f"✅ {t('No module carries it.')}" + f" ({rapport['scanned']} {t('modules read')})", + "ok", + colour, + ) + ) + par_module = {} + for module, chemin, ligne, motif, extrait, correction in trouves: + par_module.setdefault(module, []).append( + (chemin, ligne, motif, extrait, correction) + ) + for module in sorted(par_module): + lst = par_module[module] + lignes.append( + paint(f"❌ {str(len(lst)).rjust(4)} {module}", "broken", colour) + ) + for chemin, ligne, motif, extrait, correction in sorted(lst): + lignes.append(paint(f" {chemin}:{ligne}", "dim", colour)) + lignes.append(f" {extrait} → {correction}") + lignes.append("") + + # Deux constats voisins, jamais mélangés aux « tree » : ils ont une + # autre cause et une autre réparation. + for clef, titre, genre in ( + ("missing", "listed in the manifest, absent from disk", "broken"), + ("unreadable", "listed in the manifest, unparsable", "watch"), + ): + if rapport[clef]: + lignes.append(paint(f"{t(titre)} :", genre, colour)) + for module, chemin in sorted(rapport[clef]): + lignes.append(paint(f" {chemin}", "dim", colour)) + lignes.append("") + return "\n".join(lignes).rstrip() + + +def main(argv=None): + import argparse + import json + + parser = argparse.ArgumentParser( + description=t("Modules still carrying the removed tree view type."), + ) + parser.add_argument("--root", default=REPO_ROOT) + parser.add_argument("--json", action="store_true") + parser.add_argument("--no-color", action="store_true") + args = parser.parse_args(argv) + + if not os.path.isdir(args.root): + print(f"❌ {args.root}", file=sys.stderr) + return 2 + + rapport = inspect(args.root) + if args.json: + print( + json.dumps( + { + "odoo": ( + "%d.%d" % rapport["version"] + if rapport["version"] + else None + ), + "modules_read": rapport["scanned"], + "findings": [ + { + "module": m, + "file": f, + "line": ligne, + "pattern": motif, + "found": extrait, + "replace_with": corr, + } + for m, f, ligne, motif, extrait, corr in rapport[ + "findings" + ] + ], + "missing_files": [f for _m, f in rapport["missing"]], + "unparsable_files": [f for _m, f in rapport["unreadable"]], + }, + indent=2, + ensure_ascii=False, + ) + ) + else: + colour = sys.stdout.isatty() and not args.no_color + print(render(rapport, colour)) + return 1 if rapport["findings"] or rapport["missing"] else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 980588a..2bc1f0c 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -6979,6 +6979,39 @@ TRANSLATIONS = { }, # --- Écran de qualité : Verdicts, Validation, Revue --- # --- Trous dans les manifestes Google Repo --- + # --- Type de vue « tree » resté dans un module --- + "Is any module still on a pre-18 view type?": { + "fr": "Un module est-il resté sur un type de vue d'avant 18 ?", + "en": "Is any module still on a pre-18 view type?", + }, + "View type tree, removed in Odoo 18": { + "fr": "Type de vue tree, supprimé dans Odoo 18", + "en": "View type tree, removed in Odoo 18", + }, + "nothing to do below 18.0 — tree is valid there": { + "fr": "rien à faire sous la 18.0 — tree y est valide", + "en": "nothing to do below 18.0 — tree is valid there", + }, + "No module carries it.": { + "fr": "Aucun module ne le porte.", + "en": "No module carries it.", + }, + "modules read": { + "fr": "modules lus", + "en": "modules read", + }, + "listed in the manifest, absent from disk": { + "fr": "listés au manifeste, absents du disque", + "en": "listed in the manifest, absent from disk", + }, + "listed in the manifest, unparsable": { + "fr": "listés au manifeste, illisibles", + "en": "listed in the manifest, unparsable", + }, + "Modules still carrying the removed tree view type.": { + "fr": "Modules portant encore le type de vue tree, supprimé.", + "en": "Modules still carrying the removed tree view type.", + }, "Will every step find its addons?": { "fr": "Chaque palier trouvera-t-il ses addons ?", "en": "Will every step find its addons?", diff --git a/test/test_check_migration_quality.py b/test/test_check_migration_quality.py index 4d5dee6..af71a94 100644 --- a/test/test_check_migration_quality.py +++ b/test/test_check_migration_quality.py @@ -1793,7 +1793,7 @@ class TestTheReviewChecklist(Base): continue sans_base += 1 self.assertIn("script/analyse/", commande, commande) - self.assertEqual(1, sans_base) + self.assertTrue(sans_base) def test_the_checkout_step_needs_no_database(self): # C'est l'angle mort des six autres : elles lisent toutes la BASE @@ -1801,8 +1801,9 @@ class TestTheReviewChecklist(Base): sans_base = [ (q, c) for q, c, k in quality.REVUE if k and "{db}" not in c ] - self.assertEqual(1, len(sans_base)) - self.assertIn("check_manifest_gaps", sans_base[0][1]) + self.assertTrue(sans_base) + for _question, commande in sans_base: + self.assertTrue(commande.startswith("script/analyse/"), commande) def test_the_first_step_has_nothing_to_run(self): # « La migration est-elle allée jusqu'au bout » se lit dans le @@ -1880,14 +1881,17 @@ class TestTheThreeExtraSections(Base): self.assertNotIn("{db}", commande) def test_without_a_database_only_the_db_free_step_can_be_run(self): - # La revue du checkout — les manifestes — se lance justement AVANT - # qu'une migration existe. Exiger une base la rendait inerte au - # seul moment où elle sert. + # La revue du checkout — manifestes, types de vue — se lance + # justement AVANT qu'une migration existe. Exiger une base la + # rendait inerte au seul moment où elle sert. lst = qtui.extra_rows([], {}) lancables = [r for r in lst if r["kind"] == "review" and r["command"]] - self.assertEqual(1, len(lancables)) - self.assertNotIn("{db}", lancables[0]["command"]) - self.assertNotIn(" -d ", lancables[0]["command"]) + attendu = [c for _q, c, k in quality.REVUE if k and "{db}" not in c] + self.assertEqual(len(attendu), len(lancables)) + self.assertTrue(lancables) + for ligne in lancables: + self.assertNotIn("{db}", ligne["command"]) + self.assertNotIn(" -d ", ligne["command"]) def test_a_step_that_names_a_database_stays_silent_without_one(self): lst = qtui.extra_rows([], {}) diff --git a/test/test_check_view_type_tree.py b/test/test_check_view_type_tree.py new file mode 100644 index 0000000..e9450bc --- /dev/null +++ b/test/test_check_view_type_tree.py @@ -0,0 +1,491 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""« tree » est d'abord un IDENTIFIANT, et rarement un type de vue. + +Sur 465 occurrences du mot dans odoo18.0/addons, quatre-vingts cassent. +Le reste est un id de , un , un env.ref(), un +nom de variable — et le noyau 18 lui-même a gardé ses anciens ids en ne +renommant que les balises : account.view_invoice_tree existe toujours. + +Un détecteur qui ancre sur le mot rend quatre cents lignes fausses et ne +sera pas relancé une seconde fois. C'est donc le TRI qu'on teste ici, pas +la détection : chaque exclusion a son test, et il y en a plus que de +motifs. +""" + +import io +import os +import shutil +import sys +import tempfile +import unittest +from contextlib import redirect_stdout + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, REPO) + +from script.analyse import check_view_type_tree as arbre # noqa: E402 + + +class Base(unittest.TestCase): + """Un checkout jetable : une version, un module, ce qu'on y met.""" + + def setUp(self): + self.racine = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.racine) + self.version("18.0") + self.addons = os.path.join(self.racine, "odoo18.0", "addons") + os.makedirs(self.addons) + + def version(self, texte): + with io.open( + os.path.join(self.racine, arbre.FICHIER_VERSION), + "w", + encoding="utf-8", + ) as handle: + handle.write(texte + "\n") + + def module(self, nom="essai", manifeste=None, init="from . import models"): + chemin = os.path.join(self.addons, "depot", nom) + os.makedirs(chemin, exist_ok=True) + dct = {"name": nom, "version": "18.0.1.0.0", "data": []} + dct.update(manifeste or {}) + self.ecrire(os.path.join(chemin, arbre.MANIFESTE), repr(dct)) + if init is not None: + self.ecrire(os.path.join(chemin, "__init__.py"), init) + return chemin + + def ecrire(self, chemin, contenu): + os.makedirs(os.path.dirname(chemin), exist_ok=True) + with io.open(chemin, "w", encoding="utf-8") as handle: + handle.write(contenu) + return chemin + + def constats(self): + return arbre.inspect(self.racine)["findings"] + + def motifs(self): + return sorted(c[3] for c in self.constats()) + + +class TestTheVersionGate(Base): + def test_below_18_nothing_is_a_defect(self): + # `tree` y est valide, au pire déprécié en 17. Crier dessus + # ferait rejeter le rapport en entier. + self.assertFalse(arbre.concerne((17, 0))) + self.assertFalse(arbre.concerne((12, 0))) + + def test_18_and_beyond_are_judged(self): + self.assertTrue(arbre.concerne((18, 0))) + self.assertTrue(arbre.concerne((19, 0))) + + def test_an_unreadable_version_judges_nothing(self): + os.remove(os.path.join(self.racine, arbre.FICHIER_VERSION)) + self.assertIsNone(arbre.version_active(self.racine)) + self.assertEqual([], self.constats()) + + def test_a_nonsense_version_judges_nothing(self): + self.version("develop") + self.assertIsNone(arbre.version_active(self.racine)) + + +class TestTheModuleGate(Base): + def test_a_manifest_is_read_never_executed(self): + # Un __manifest__.py vient d'un dépôt tiers : l'exécuter pour lire + # quatre clés donnerait la main à n'importe qui. + chemin = self.ecrire( + os.path.join(self.racine, "m", arbre.MANIFESTE), + "__import__('os').system('touch /tmp/jamais')\n", + ) + self.assertIsNone(arbre.lire_manifeste(chemin)) + + def test_a_module_that_odoo_would_not_list_is_skipped(self): + # `installable: False` : il ne peut casser ni à l'installation ni + # à l'exécution. + module = self.module( + manifeste={"installable": False, "data": ["v.xml"]} + ) + self.ecrire( + os.path.join(module, "v.xml"), + "" + "", + ) + self.assertEqual([], self.constats()) + + def test_a_module_that_is_installable_is_read(self): + module = self.module(manifeste={"data": ["v.xml"]}) + self.ecrire( + os.path.join(module, "v.xml"), + "" + "", + ) + self.assertEqual(["xml_balise"], self.motifs()) + + def test_a_directory_without_a_manifest_is_not_a_module(self): + os.makedirs(os.path.join(self.addons, "depot", "pas_un_module")) + self.assertEqual([], arbre.modules(self.addons)) + + +class TestTheLoadingGate(Base): + def test_an_xml_the_manifest_does_not_load_is_ignored(self): + # Personne ne le charge : il ne casse rien, et le signaler ferait + # crier sur des brouillons laissés là. + module = self.module(manifeste={"data": []}) + self.ecrire( + os.path.join(module, "orphelin.xml"), "" + ) + self.assertEqual([], self.constats()) + + def test_a_demo_file_counts_like_a_data_file(self): + module = self.module(manifeste={"demo": ["d.xml"]}) + self.ecrire(os.path.join(module, "d.xml"), "") + self.assertEqual(["xml_balise"], self.motifs()) + + def test_a_python_nobody_imports_is_ignored(self): + module = self.module(init="") + self.ecrire( + os.path.join(module, "brouillon.py"), + "A = [(1, 'tree')]\n", + ) + self.assertEqual([], self.constats()) + + def test_a_python_reached_through_the_init_is_read(self): + module = self.module(init="from . import models") + self.ecrire(os.path.join(module, "models.py"), "A = [(1, 'tree')]\n") + self.assertEqual(["py_views"], self.motifs()) + + def test_a_module_imported_for_its_function_is_still_followed(self): + # « from .hooks import post_init_hook » importe une FONCTION ; + # chercher post_init_hook.py manquait tous les hooks.py, et avec + # eux vingt-sept constats réels. + module = self.module(init="from .hooks import post_init_hook") + self.ecrire( + os.path.join(module, "hooks.py"), + "def post_init_hook(env):\n" + " env['code.generator.view'].create({'view_type': 'tree'})\n", + ) + self.assertEqual(["py_view_type"], self.motifs()) + + def test_a_subpackage_is_walked(self): + module = self.module(init="from . import models") + self.ecrire( + os.path.join(module, "models", "__init__.py"), "from . import x" + ) + self.ecrire( + os.path.join(module, "models", "x.py"), "A = [(1, 'tree')]\n" + ) + self.assertEqual(["py_views"], self.motifs()) + + def test_a_test_file_is_never_judged(self): + module = self.module(init="from . import tests") + self.ecrire( + os.path.join(module, "tests", "__init__.py"), "from . import t" + ) + self.ecrire( + os.path.join(module, "tests", "t.py"), "A = [(1, 'tree')]\n" + ) + self.assertEqual([], self.constats()) + + +class TestTheXmlPatterns(Base): + def vue(self, corps, manifeste=None): + module = self.module(manifeste=manifeste or {"data": ["v.xml"]}) + self.ecrire(os.path.join(module, "v.xml"), corps) + return self.constats() + + def test_a_tree_tag_is_caught(self): + self.assertEqual( + ["xml_balise"], + [ + c[3] + for c in self.vue( + "" + ) + ], + ) + + def test_the_closing_tag_is_not_a_second_defect(self): + # Elle est la moitié du même : les compter séparément double le + # total et fait croire à deux corrections. + self.assertEqual(1, len(self.vue(""))) + + def test_a_commented_tree_is_not_a_defect(self): + # Aucun geste explicite : lxml ne construit pas d'élément pour le + # contenu d'un commentaire. C'est l'argument décisif contre grep. + self.assertEqual([], self.vue("")) + + def test_a_record_id_that_contains_tree_is_not_a_defect(self): + # Le noyau 18 a gardé account.view_invoice_tree, arch en . + self.assertEqual( + [], + self.vue( + "" + "account.move.tree" + "" + "" + ), + ) + + def test_a_view_mode_naming_tree_is_caught(self): + constats = self.vue( + "tree,form" + ) + self.assertEqual(["xml_view_mode"], [c[3] for c in constats]) + self.assertEqual("list,form", constats[0][5]) + + def test_a_space_after_the_comma_does_not_hide_it(self): + self.assertEqual( + ["xml_view_mode"], + [ + c[3] + for c in self.vue( + "form, tree" + ) + ], + ) + + def test_a_mode_that_merely_starts_with_tree_is_not_caught(self): + # Un substring attraperait « treemap ». + self.assertEqual( + [], + self.vue( + "treemap,form" + ), + ) + + def test_an_explicit_type_field_is_caught(self): + self.assertEqual( + ["xml_type"], + [ + c[3] + for c in self.vue( + "tree" + ) + ], + ) + + def test_an_xpath_aiming_at_a_tree_is_caught(self): + self.assertEqual( + ["xml_xpath"], + [ + c[3] + for c in self.vue( + "" + ) + ], + ) + + def test_an_xpath_naming_tree_inside_a_predicate_is_not_caught(self): + self.assertEqual( + [], + self.vue( + "" + ), + ) + + def test_an_unparsable_file_is_reported_apart(self): + # Autre cause, autre réparation : ne pas le mêler aux « tree ». + module = self.module(manifeste={"data": ["v.xml"]}) + self.ecrire(os.path.join(module, "v.xml"), "\n\n" + "\n" + ) + self.assertEqual(2, constats[0][2]) + + +class TestThePythonPatterns(Base): + def code(self, source): + module = self.module(init="from . import models") + self.ecrire(os.path.join(module, "models.py"), source) + return self.constats() + + def test_a_view_tuple_typed_tree_is_caught(self): + self.assertEqual( + ["py_views"], + [ + c[3] + for c in self.code( + "def f(res):\n return [(res and res.id or False, 'tree')]\n" + ) + ], + ) + + def test_a_pair_of_two_strings_is_defensive_code_not_a_defect(self): + # ('list', 'tree') couvre les deux noms de balise ; en 18 seule la + # branche list se produit, l'entrée tree reste morte. + self.assertEqual([], self.code("A = ('list', 'tree')\n")) + + def test_the_variable_name_decides_nothing(self): + # Le piège le plus tentant : les deux formes sont à quelques + # lignes d'écart dans le même dépôt et ne diffèrent que par le + # littéral. Odoo ne lit pas les noms de variables. + self.assertEqual([], self.code("tree_view = [(1, 'list')]\n")) + + def test_a_dict_view_mode_is_caught(self): + self.assertEqual( + ["py_view_mode"], + [c[3] for c in self.code("A = {'view_mode': 'tree,form'}\n")], + ) + + def test_a_view_mode_without_the_action_type_is_still_caught(self): + # Plusieurs actions réelles omettent la clé `type` et sont + # pourtant renvoyées telles quelles au client. + self.assertEqual( + ["py_view_mode"], + [ + c[3] + for c in self.code( + "A = {'name': 'x', 'view_mode': 'form,tree,kanban'}\n" + ) + ], + ) + + def test_view_type_on_an_act_window_is_a_harmless_leftover(self): + # Vestige d'Odoo 12 : la 18 ne lit que view_mode / views / view_ids + # et ignore la clé surnuméraire sans bruit. + self.assertEqual( + [], + self.code( + "A = {'type': 'ir.actions.act_window', 'view_type': 'tree'}\n" + ), + ) + + def test_view_type_anywhere_else_is_a_live_selection(self): + # code.generator.view.view_type ne propose plus que « list » : + # la valeur tree lève au post_init_hook. + self.assertEqual( + ["py_view_type"], + [ + c[3] + for c in self.code( + "A = {'view_type': 'tree', 'view_name': 'x'}\n" + ) + ], + ) + + def test_a_comparison_against_tree_is_not_a_defect(self): + # Une comparaison ne fabrique jamais un type de vue : au pire du + # code mort. La compter gonflerait le rapport de lignes que + # personne ne peut réparer isolément. + self.assertEqual( + [], + self.code( + "def f(v):\n return v != 'tree' and v in ('list', 'tree')\n" + ), + ) + + def test_a_tree_used_as_a_data_structure_is_not_a_defect(self): + self.assertEqual( + [], + self.code( + "import lxml.etree as etree\n" + "def f(d):\n return d['tree'], etree.tostring\n" + ), + ) + + def test_the_unix_command_named_tree_is_not_a_defect(self): + self.assertEqual([], self.code("cmd = 'tree'\nPKGS = ['tree']\n")) + + def test_a_file_that_does_not_parse_is_reported_apart(self): + module = self.module(init="from . import models") + self.ecrire(os.path.join(module, "models.py"), "def f(:\n") + rapport = arbre.inspect(self.racine) + self.assertEqual([], rapport["findings"]) + self.assertEqual(1, len(rapport["unreadable"])) + + +class TestTheCommand(Base): + def lancer(self, argv): + tampon = io.StringIO() + with redirect_stdout(tampon): + code = arbre.main(argv) + return code, tampon.getvalue() + + def test_a_missing_root_is_a_tool_failure(self): + code, _ = self.lancer(["--root", os.path.join(self.racine, "absent")]) + self.assertEqual(2, code) + + def test_a_clean_checkout_exits_zero(self): + self.module() + code, texte = self.lancer(["--root", self.racine, "--no-color"]) + self.assertEqual(0, code) + self.assertIn(arbre.t("No module carries it."), texte) + + def test_a_finding_exits_one(self): + module = self.module(manifeste={"data": ["v.xml"]}) + self.ecrire(os.path.join(module, "v.xml"), "") + code, _ = self.lancer(["--root", self.racine, "--no-color"]) + self.assertEqual(1, code) + + def test_below_18_it_says_so_instead_of_staying_silent(self): + self.version("17.0") + _code, texte = self.lancer(["--root", self.racine, "--no-color"]) + self.assertIn( + arbre.t("nothing to do below 18.0 — tree is valid there"), texte + ) + + def test_the_json_says_what_to_replace_it_with(self): + import json + + module = self.module(manifeste={"data": ["v.xml"]}) + self.ecrire( + os.path.join(module, "v.xml"), + "tree,form", + ) + _code, texte = self.lancer(["--root", self.racine, "--json"]) + dct = json.loads(texte) + self.assertEqual("18.0", dct["odoo"]) + self.assertEqual("list,form", dct["findings"][0]["replace_with"]) + + +class TestAgainstTheRealCheckout(unittest.TestCase): + """Sur le vrai arbre — c'est là que le tri se prouve.""" + + @classmethod + def setUpClass(cls): + cls.rapport = arbre.inspect(REPO) + + def test_it_reads_the_whole_addon_tree(self): + if not arbre.concerne(arbre.version_active(REPO)): + self.skipTest("checkout sous la 18.0") + self.assertGreater(self.rapport["scanned"], 1000) + + def test_it_finds_the_line_that_broke_a_kanban(self): + # « View types not defined tree found in act_window action 444 », + # au clic sur action_view_ticket depuis le kanban de projet. + if not arbre.concerne(arbre.version_active(REPO)): + self.skipTest("checkout sous la 18.0") + vus = [ + (f, ligne) + for _m, f, ligne, _p, _e, _c in self.rapport["findings"] + if "helpdesk_mgmt_project" in f + ] + self.assertTrue(vus, "le défaut rencontré en production n'est pas vu") + + def test_the_core_odoo_identifiers_are_not_reported(self): + # account.view_invoice_tree et ses semblables vivent dans le noyau + # 18 avec une arch en : les signaler serait ~400 lignes + # fausses, et le rapport ne serait pas relancé. + for _m, fichier, _l, _p, _e, _c in self.rapport["findings"]: + self.assertNotIn("odoo18.0/odoo/", fichier) + + +if __name__ == "__main__": + unittest.main()