[ADD] migration : détecter le type de vue tree resté dans les sources

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 <list>. 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 <list>. 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)
This commit is contained in:
Mathieu Benoit 2026-08-27 02:36:58 -04:00
parent 599401b6c4
commit 8fe0bafe10
5 changed files with 1067 additions and 9 deletions

View file

@ -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",
),
)

View file

@ -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 `<tree>` 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 `<record>`, un `<field name="name">`, 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 `<list>`.
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 `<tree>` sous un x2many n'est pas ignoré poliment : `ir_ui_view.py`
ne le reconnaît pas, donc ne le RETIRE pas, et ses `<field>` 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 `<tree` commençait à 61. Citer la fin envoie chercher au mauvais
endroit.
"""
try:
with io.open(chemin, encoding="utf-8", errors="replace") as handle:
lignes = handle.readlines()
except OSError:
return sourceline
for rang in range(min(sourceline, len(lignes)) - 1, -1, -1):
if "<" + balise in lignes[rang]:
return rang + 1
return sourceline
def constats_xml(chemin):
"""[(ligne, motif, extrait, correction)] pour UN fichier de vues."""
from lxml import etree
try:
arbre = etree.parse(chemin)
except (etree.XMLSyntaxError, OSError):
return None # illisible : c'est un constat à part, pas un « tree »
lst = []
for el in arbre.iter():
# Ce test, et lui seul, écarte commentaires et instructions de
# traitement : lxml ne construit pas d'élément pour un <!-- -->.
if not isinstance(el.tag, str):
continue
if el.tag == "tree":
lst.append(
(
ligne_de_la_balise(chemin, el.sourceline),
"xml_balise",
"<tree>",
"<list>",
)
)
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())

View file

@ -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?",

View file

@ -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([], {})

View file

@ -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 <record>, un <field name="name">, 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"),
"<odoo><record model='ir.ui.view'><field name='arch'>"
"<tree/></field></record></odoo>",
)
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"),
"<odoo><record model='ir.ui.view'><field name='arch'>"
"<tree/></field></record></odoo>",
)
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"), "<odoo><tree/></odoo>"
)
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"), "<odoo><tree/></odoo>")
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(
"<odoo><tree><field name='a'/></tree></odoo>"
)
],
)
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("<odoo><tree/></odoo>")))
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("<odoo><!-- <tree/> --></odoo>"))
def test_a_record_id_that_contains_tree_is_not_a_defect(self):
# Le noyau 18 a gardé account.view_invoice_tree, arch en <list>.
self.assertEqual(
[],
self.vue(
"<odoo><record id='view_invoice_tree' model='ir.ui.view'>"
"<field name='name'>account.move.tree</field>"
"<field name='arch' type='xml'><list/></field>"
"</record></odoo>"
),
)
def test_a_view_mode_naming_tree_is_caught(self):
constats = self.vue(
"<odoo><field name='view_mode'>tree,form</field></odoo>"
)
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(
"<odoo><field name='view_mode'>form, tree</field></odoo>"
)
],
)
def test_a_mode_that_merely_starts_with_tree_is_not_caught(self):
# Un substring attraperait « treemap ».
self.assertEqual(
[],
self.vue(
"<odoo><field name='view_mode'>treemap,form</field></odoo>"
),
)
def test_an_explicit_type_field_is_caught(self):
self.assertEqual(
["xml_type"],
[
c[3]
for c in self.vue(
"<odoo><field name='type'>tree</field></odoo>"
)
],
)
def test_an_xpath_aiming_at_a_tree_is_caught(self):
self.assertEqual(
["xml_xpath"],
[
c[3]
for c in self.vue(
"<odoo><xpath expr=\"//tree/field[@name='a']\"/></odoo>"
)
],
)
def test_an_xpath_naming_tree_inside_a_predicate_is_not_caught(self):
self.assertEqual(
[],
self.vue(
"<odoo><xpath expr=\"//list[@name='tree_view']\"/></odoo>"
),
)
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"), "<odoo><tree")
rapport = arbre.inspect(self.racine)
self.assertEqual([], rapport["findings"])
self.assertEqual(1, len(rapport["unreadable"]))
def test_a_file_listed_but_absent_is_reported_apart(self):
# Il rend le module ininstallable, et cela n'a rien à voir avec
# un type de vue.
self.module(manifeste={"data": ["jamais_ecrit.xml"]})
rapport = arbre.inspect(self.racine)
self.assertEqual(1, len(rapport["missing"]))
def test_the_line_reported_is_where_the_tag_opens(self):
# lxml rend la ligne où la balise ouvrante FINIT ; mesuré, il
# disait 66 quand <tree commençait à 61.
constats = self.vue(
"<odoo>\n<tree\n string='x'\n create='0'\n>\n"
"<field name='a'/></tree>\n</odoo>"
)
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"), "<odoo><tree/></odoo>")
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"),
"<odoo><field name='view_mode'>tree,form</field></odoo>",
)
_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 <list> : 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()