[ADD] migration: repair views whose stored type contradicts their parent
Odoo refused to load a database: it validated a <search> arch with tree rules. The view is not a COW copy -- the COW tools were right to say they had nothing to reset -- its stored `type` simply lies. Nothing would ever have fixed it. In ir_ui_view.py, `type` is filled in `create`, and only when absent; `write` never recomputes it. A wrong value stays wrong, and `-u module` fails on the validation it causes before it could rewrite anything. Plain SQL, no ORM: the registry is what will not load, and a repair that needed Odoo to fix what stops Odoo would be useless. The rule is Odoo's own -- an inherited view takes its parent's type -- and it holds: zero disagreement on a pristine 18 install and on four migrated databases, exactly one on the database that refused to load. --- FR --- Odoo refusait de charger une base : il validait un arch <search> avec les règles d'un tree. La vue n'est pas une copie COW — les outils COW avaient raison de dire qu'ils n'avaient rien à réinitialiser — c'est son `type` stocké qui ment. Rien ne l'aurait jamais réparé. Dans ir_ui_view.py, `type` est rempli dans `create`, et seulement s'il est absent ; `write` ne le recalcule jamais. Une valeur fausse le reste, et `-u module` échoue sur la validation qu'elle provoque avant de pouvoir réécrire quoi que ce soit. En SQL, sans ORM : c'est le registre qui ne charge plus, et une réparation qui aurait besoin d'Odoo ne servirait à rien. La règle est celle d'Odoo — une vue héritée prend le type de son parent — et elle tient : zéro écart sur une 18 neuve et sur quatre bases migrées, exactement un sur celle qui refusait de charger. Assisted-by: Claude Opus 5
This commit is contained in:
parent
ff8b6bcaf9
commit
068022db82
8 changed files with 910 additions and 78 deletions
203
script/odoo/migration/fix_view_type.py
Executable file
203
script/odoo/migration/fix_view_type.py
Executable file
|
|
@ -0,0 +1,203 @@
|
|||
#!/usr/bin/env python3
|
||||
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
|
||||
"""Une vue dont le `type` stocké contredit son héritage.
|
||||
|
||||
Le symptôme est brutal : Odoo refuse de charger la base.
|
||||
|
||||
Le nœud racine d'une vue tree devrait être <tree>, et non <search>
|
||||
view: ir.ui.view(637,) view.parent: ir.ui.view(636,)
|
||||
|
||||
La 637 hérite d'une vue `search` et son `type` en base dit `tree`. Odoo
|
||||
valide alors un `<search>` avec les règles d'un `<tree>`, et échoue.
|
||||
|
||||
Pourquoi aucune mise à jour ne le répare
|
||||
----------------------------------------
|
||||
`type` n'est PAS un champ calculé. Dans `ir_ui_view.py`, il est rempli
|
||||
dans `create` — et seulement s'il est absent :
|
||||
|
||||
if not values.get('type'):
|
||||
if values.get('inherit_id'):
|
||||
values['type'] = self.browse(values['inherit_id']).type
|
||||
|
||||
`write` ne le recalcule jamais. Une valeur fausse reste donc fausse pour
|
||||
toujours, et `-u module` ne la corrigera pas — il échoue avant, sur la
|
||||
validation qu'elle provoque.
|
||||
|
||||
Pourquoi en SQL et pas par l'ORM
|
||||
--------------------------------
|
||||
Parce que le registre ne charge plus. Un outil qui aurait besoin d'Odoo
|
||||
pour réparer ce qui empêche Odoo de démarrer ne servirait à rien.
|
||||
|
||||
Ce que la règle garantit
|
||||
------------------------
|
||||
Une vue héritée prend le type de son ancêtre — c'est la règle d'Odoo
|
||||
lui-même, citée plus haut. Vérifié : zéro écart sur une installation 18
|
||||
neuve et sur quatre bases migrées, un seul sur celle qui refusait de
|
||||
charger. Le détecteur ne crie pas au loup.
|
||||
|
||||
Codes de sortie : 0 rien à signaler, 1 des trouvailles, 2 l'outil a échoué.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
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"
|
||||
|
||||
# La profondeur borne la récursion : un `inherit_id` circulaire ferait
|
||||
# tourner PostgreSQL sans fin, et une chaîne de vues n'a jamais vingt
|
||||
# maillons.
|
||||
PROFONDEUR_MAX = 20
|
||||
|
||||
DETECTION = f"""
|
||||
WITH RECURSIVE racine AS (
|
||||
SELECT id, type, 0 AS prof FROM ir_ui_view WHERE inherit_id IS NULL
|
||||
UNION ALL
|
||||
SELECT v.id, r.type, r.prof + 1
|
||||
FROM ir_ui_view v JOIN racine r ON v.inherit_id = r.id
|
||||
WHERE r.prof < {PROFONDEUR_MAX}
|
||||
)
|
||||
SELECT v.id::text, v.type, r.type,
|
||||
coalesce(v.mode, '-'),
|
||||
coalesce(d.module || '.' || d.name, '-'),
|
||||
coalesce(v.model, '-')
|
||||
FROM ir_ui_view v
|
||||
JOIN racine r ON r.id = v.id
|
||||
LEFT JOIN ir_model_data d
|
||||
ON d.model = 'ir.ui.view' AND d.res_id = v.id
|
||||
WHERE v.inherit_id IS NOT NULL AND v.type <> r.type
|
||||
ORDER BY v.id
|
||||
"""
|
||||
|
||||
|
||||
def run_psql(database, sql, read_only=True):
|
||||
"""Interroger la base. En lecture seule sauf demande explicite."""
|
||||
env = os.environ.copy()
|
||||
if read_only:
|
||||
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 find(database):
|
||||
"""Les vues fautives. None si la base ne répond pas."""
|
||||
lignes = run_psql(database, DETECTION)
|
||||
if lignes is None:
|
||||
return None
|
||||
return [
|
||||
{
|
||||
"id": int(ligne[0]),
|
||||
"type": ligne[1],
|
||||
"expected": ligne[2],
|
||||
"mode": ligne[3],
|
||||
"xmlid": ligne[4],
|
||||
"model": ligne[5],
|
||||
}
|
||||
for ligne in lignes
|
||||
if len(ligne) >= 6
|
||||
]
|
||||
|
||||
|
||||
def repair_sql(vues):
|
||||
"""Le SQL de correction, ou "" s'il n'y a rien à corriger.
|
||||
|
||||
Un `CASE` plutôt qu'un ordre par vue : une seule instruction, donc
|
||||
une seule transaction, donc pas de base à moitié réparée si l'on
|
||||
coupe au milieu.
|
||||
"""
|
||||
if not vues:
|
||||
return ""
|
||||
cas = " ".join(
|
||||
f"WHEN {vue['id']} THEN '{vue['expected']}'" for vue in vues
|
||||
)
|
||||
liste = ", ".join(str(vue["id"]) for vue in vues)
|
||||
return (
|
||||
f"UPDATE ir_ui_view SET type = CASE id {cas} END"
|
||||
f" WHERE id IN ({liste})"
|
||||
)
|
||||
|
||||
|
||||
def render(vues, applique=False):
|
||||
if not vues:
|
||||
return [f"✅ {t('Every view type agrees with its inheritance.')}"]
|
||||
lignes = [
|
||||
f"🩹 {len(vues)} {t('view(s) whose stored type contradicts')}"
|
||||
f" {t('their inheritance')} :"
|
||||
]
|
||||
for vue in vues:
|
||||
lignes.append(
|
||||
f" {vue['id']:<7} {vue['xmlid'][:46]:<48}"
|
||||
f" {vue['type']} → {vue['expected']} {vue['model']}"
|
||||
)
|
||||
if applique:
|
||||
lignes.append(f" ✅ {t('Corrected.')}")
|
||||
else:
|
||||
lignes.append(
|
||||
f" {t('No module update will fix this: the type is set once,')}"
|
||||
f" {t('at creation. Re-run with --apply.')}"
|
||||
)
|
||||
return lignes
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Report views whose stored type contradicts the type of the"
|
||||
" view they inherit from, and optionally fix them."
|
||||
)
|
||||
)
|
||||
parser.add_argument("-d", "--database", required=True)
|
||||
parser.add_argument(
|
||||
"--apply",
|
||||
action="store_true",
|
||||
help="actually correct the type (default: report only)",
|
||||
)
|
||||
config = parser.parse_args(argv)
|
||||
|
||||
vues = find(config.database)
|
||||
if vues is None:
|
||||
print(f"❌ {t('Cannot read the database: ')}{config.database}")
|
||||
return 2
|
||||
if not vues:
|
||||
print("\n".join(render(vues)))
|
||||
return 0
|
||||
if not config.apply:
|
||||
print("\n".join(render(vues)))
|
||||
return 1
|
||||
sql = repair_sql(vues)
|
||||
if run_psql(config.database, sql, read_only=False) is None:
|
||||
print(f"❌ {t('The correction failed.')}")
|
||||
return 2
|
||||
reste = find(config.database)
|
||||
if reste:
|
||||
print(f"❌ {t('The correction failed.')}")
|
||||
return 2
|
||||
print("\n".join(render(vues, applique=True)))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -5955,6 +5955,222 @@ TRANSLATIONS = {
|
|||
"fr": "autre(s) nom(s) de champ",
|
||||
"en": "other field name(s)",
|
||||
},
|
||||
"Filestore of": {
|
||||
"fr": "Filestore de",
|
||||
"en": "Filestore of",
|
||||
},
|
||||
"file(s) missing": {
|
||||
"fr": "fichier(s) absent(s)",
|
||||
"en": "file(s) missing",
|
||||
},
|
||||
"file(s) on disk": {
|
||||
"fr": "fichier(s) sur disque",
|
||||
"en": "file(s) on disk",
|
||||
},
|
||||
"file(s) sit in nested filestores Odoo never reads.": {
|
||||
"fr": "fichier(s) échoué(s) dans des filestores imbriqués qu'Odoo ne lit jamais.",
|
||||
"en": "file(s) sit in nested filestores Odoo never reads.",
|
||||
},
|
||||
"intact in another database's filestore": {
|
||||
"fr": "intacts dans le filestore d'une autre base",
|
||||
"en": "intact in another database's filestore",
|
||||
},
|
||||
"its field no longer exists — nothing reads it": {
|
||||
"fr": "leur champ n'existe plus — rien ne les lit",
|
||||
"en": "its field no longer exists — nothing reads it",
|
||||
},
|
||||
"nowhere to be found — truly lost": {
|
||||
"fr": "introuvables — réellement perdus",
|
||||
"en": "nowhere to be found — truly lost",
|
||||
},
|
||||
"still in a backup zip": {
|
||||
"fr": "encore dans une sauvegarde zip",
|
||||
"en": "still in a backup zip",
|
||||
},
|
||||
"stored attachment(s)": {
|
||||
"fr": "pièce(s) jointe(s) stockée(s)",
|
||||
"en": "stored attachment(s)",
|
||||
},
|
||||
"stranded in a nested filestore Odoo never reads": {
|
||||
"fr": "échoués dans un filestore imbriqué qu'Odoo ne lit pas",
|
||||
"en": "stranded in a nested filestore Odoo never reads",
|
||||
},
|
||||
"Files": {
|
||||
"fr": "Fichiers",
|
||||
"en": "Files",
|
||||
},
|
||||
"Attachment files missing from the filestore": {
|
||||
"fr": "🗄 Fichiers de pièces jointes absents du filestore",
|
||||
"en": "🗄 Attachment files missing from the filestore",
|
||||
},
|
||||
"Scanning filestores and backups…": {
|
||||
"fr": "Examen des filestores et des sauvegardes…",
|
||||
"en": "Scanning filestores and backups…",
|
||||
},
|
||||
"Filestore restored:": {
|
||||
"fr": "Filestore restauré :",
|
||||
"en": "Filestore restored:",
|
||||
},
|
||||
"file(s) in place": {
|
||||
"fr": "fichier(s) en place",
|
||||
"en": "file(s) in place",
|
||||
},
|
||||
"Filestore restore looks wrong for": {
|
||||
"fr": "Restauration du filestore suspecte pour",
|
||||
"en": "Filestore restore looks wrong for",
|
||||
},
|
||||
"landed in a nested filestore Odoo never reads": {
|
||||
"fr": "atterris dans un filestore imbriqué qu'Odoo ne lit jamais",
|
||||
"en": "landed in a nested filestore Odoo never reads",
|
||||
},
|
||||
"To fix:": {
|
||||
"fr": "Pour corriger :",
|
||||
"en": "To fix:",
|
||||
},
|
||||
"never landed at all": {
|
||||
"fr": "ne sont arrivés nulle part",
|
||||
"en": "never landed at all",
|
||||
},
|
||||
"record still exists": {
|
||||
"fr": "l'enregistrement existe encore",
|
||||
"en": "record still exists",
|
||||
},
|
||||
"record is gone — nothing will miss it": {
|
||||
"fr": "l'enregistrement a disparu — personne ne la cherchera",
|
||||
"en": "record is gone — nothing will miss it",
|
||||
},
|
||||
"Delete these": {
|
||||
"fr": "Supprimer ces",
|
||||
"en": "Delete these",
|
||||
},
|
||||
"Directory:": {
|
||||
"fr": "Dossier :",
|
||||
"en": "Directory:",
|
||||
},
|
||||
"No nested filestore to tidy.": {
|
||||
"fr": "Pas de filestore imbriqué à ranger.",
|
||||
"en": "No nested filestore to tidy.",
|
||||
},
|
||||
"Nothing to purge.": {
|
||||
"fr": "Rien à purger.",
|
||||
"en": "Nothing to purge.",
|
||||
},
|
||||
"Nothing was deleted.": {
|
||||
"fr": "Rien n'a été supprimé.",
|
||||
"en": "Nothing was deleted.",
|
||||
},
|
||||
"Nothing was moved.": {
|
||||
"fr": "Rien n'a été déplacé.",
|
||||
"en": "Nothing was moved.",
|
||||
},
|
||||
"The purge failed.": {
|
||||
"fr": "La purge a échoué.",
|
||||
"en": "The purge failed.",
|
||||
},
|
||||
"attachment row(s) deleted.": {
|
||||
"fr": "ligne(s) de pièce jointe supprimée(s).",
|
||||
"en": "attachment row(s) deleted.",
|
||||
},
|
||||
"attachment row(s) for good?": {
|
||||
"fr": "ligne(s) de pièce jointe, définitivement ?",
|
||||
"en": "attachment row(s) for good?",
|
||||
},
|
||||
"duplicate(s) removed": {
|
||||
"fr": "doublon(s) supprimé(s)",
|
||||
"en": "duplicate(s) removed",
|
||||
},
|
||||
"file(s) to move up": {
|
||||
"fr": "fichier(s) à remonter",
|
||||
"en": "file(s) to move up",
|
||||
},
|
||||
"moved up": {
|
||||
"fr": "remonté(s)",
|
||||
"en": "moved up",
|
||||
},
|
||||
"pure duplicate(s) to delete": {
|
||||
"fr": "doublon(s) pur(s) à supprimer",
|
||||
"en": "pure duplicate(s) to delete",
|
||||
},
|
||||
"🧹 Purge attachments whose field no longer exists": {
|
||||
"fr": "🧹 Purger les pièces jointes dont le champ n'existe plus",
|
||||
"en": "🧹 Purge attachments whose field no longer exists",
|
||||
},
|
||||
"🧹 Tidy the nested filestore Odoo never reads": {
|
||||
"fr": "🧹 Ranger le filestore imbriqué qu'Odoo ne lit pas",
|
||||
"en": "🧹 Tidy the nested filestore Odoo never reads",
|
||||
},
|
||||
"The purge ran but said nothing.": {
|
||||
"fr": "La purge a tourné sans rien annoncer.",
|
||||
"en": "The purge ran but said nothing.",
|
||||
},
|
||||
"row(s) whose field is gone still hold their file": {
|
||||
"fr": "ligne(s) à champ disparu retiennent encore leur fichier",
|
||||
"en": "row(s) whose field is gone still hold their file",
|
||||
},
|
||||
"file(s) sit in a nested filestore Odoo never reads.": {
|
||||
"fr": "fichier(s) échoué(s) dans un filestore imbriqué qu'Odoo ne lit jamais.",
|
||||
"en": "file(s) sit in a nested filestore Odoo never reads.",
|
||||
},
|
||||
"such file(s) sit in OTHER databases filestores.": {
|
||||
"fr": "fichier(s) de ce genre dorment dans les filestores d'AUTRES bases.",
|
||||
"en": "such file(s) sit in OTHER databases filestores.",
|
||||
},
|
||||
"Could not inspect the attachments: ": {
|
||||
"fr": "Inspection des pièces jointes impossible : ",
|
||||
"en": "Could not inspect the attachments: ",
|
||||
},
|
||||
"Delete them?": {
|
||||
"fr": "Les supprimer ?",
|
||||
"en": "Delete them?",
|
||||
},
|
||||
"No attachment points at a field that is gone.": {
|
||||
"fr": "Aucune pièce jointe ne pointe vers un champ disparu.",
|
||||
"en": "No attachment points at a field that is gone.",
|
||||
},
|
||||
"attachment(s) point at a field that no longer exists": {
|
||||
"fr": "pièce(s) jointe(s) pointent vers un champ qui n'existe plus",
|
||||
"en": "attachment(s) point at a field that no longer exists",
|
||||
},
|
||||
"Corrected.": {
|
||||
"fr": "Corrigé.",
|
||||
"en": "Corrected.",
|
||||
},
|
||||
"Every view type agrees with its inheritance.": {
|
||||
"fr": "Tous les types de vue s'accordent avec leur héritage.",
|
||||
"en": "Every view type agrees with its inheritance.",
|
||||
},
|
||||
"No module update will fix this: the type is set once,": {
|
||||
"fr": "Aucune mise à jour de module ne corrigera cela : le type est posé une fois,",
|
||||
"en": "No module update will fix this: the type is set once,",
|
||||
},
|
||||
"The correction failed.": {
|
||||
"fr": "La correction a échoué.",
|
||||
"en": "The correction failed.",
|
||||
},
|
||||
"at creation. Re-run with --apply.": {
|
||||
"fr": "à la création. Relancer avec --apply.",
|
||||
"en": "at creation. Re-run with --apply.",
|
||||
},
|
||||
"their inheritance": {
|
||||
"fr": "leur héritage",
|
||||
"en": "their inheritance",
|
||||
},
|
||||
"view(s) whose stored type contradicts": {
|
||||
"fr": "vue(s) dont le type stocké contredit",
|
||||
"en": "view(s) whose stored type contradicts",
|
||||
},
|
||||
"Fix views whose type contradicts their": {
|
||||
"fr": "Corriger les vues dont le type contredit leur",
|
||||
"en": "Fix views whose type contradicts their",
|
||||
},
|
||||
"inheritance": {
|
||||
"fr": "héritage",
|
||||
"en": "inheritance",
|
||||
},
|
||||
"Correct them?": {
|
||||
"fr": "Les corriger ?",
|
||||
"en": "Correct them?",
|
||||
},
|
||||
"Census": {
|
||||
"fr": "Recensement",
|
||||
"en": "Census",
|
||||
|
|
|
|||
|
|
@ -2848,6 +2848,195 @@ class TodoUpgrade:
|
|||
if status.lower().strip() == "y":
|
||||
self.todo.prompt_execute_selenium_and_run_db(database_name_upgrade)
|
||||
|
||||
def _prompt_on_error(self, database_name, defaut, repare):
|
||||
"""Le menu proposé quand une commande échoue. Rend (choix, réparé).
|
||||
|
||||
Extrait de `todo_upgrade_execute` : à cinq entrées la méthode
|
||||
appelante passait le seuil de complexité, et une boucle d'invite
|
||||
se relit mieux seule que noyée dans l'exécution d'une commande.
|
||||
"""
|
||||
tours = 0
|
||||
while True:
|
||||
# Une borne STRUCTURELLE, et non pas seulement la logique
|
||||
# ci-dessous qui bascule le défaut. Les deux protections
|
||||
# visent la même panne — une invite qui se repropose sans
|
||||
# fin — mais celle-ci tient même si l'autre est cassée un
|
||||
# jour par mégarde. Une boucle infinie dans une migration
|
||||
# lancée sans surveillance coûte une nuit.
|
||||
tours += 1
|
||||
if tours > self.MAX_ERROR_TURNS:
|
||||
print(f"🛑 {t('Too many turns on this prompt: moving on.')}")
|
||||
wait_status = ""
|
||||
break
|
||||
print(f"[1] {t('to redo the command')}")
|
||||
if database_name:
|
||||
print(
|
||||
f"[2] {t('Check the COW views that drifted')}"
|
||||
f" ({database_name})"
|
||||
)
|
||||
print(f"[3] {t('Reset one of them onto its module view')}")
|
||||
print(f"[4] {t('Browse the differences full screen')}")
|
||||
print(
|
||||
f"[5] {t('Fix views whose type contradicts their')}"
|
||||
f" {t('inheritance')}"
|
||||
)
|
||||
# `self.ask` : une migration automatique s'arrêtait ICI,
|
||||
# sur une invite qui ne demande qu'à continuer, et restait
|
||||
# bloquée sans que rien ne le signale.
|
||||
wait_status = (
|
||||
self.ask(
|
||||
f"💬 {t('Error detected. Choose, or ctrl+c to')}"
|
||||
f" {t('stop')}"
|
||||
f" ({t('Enter')} = {defaut or t('continue')}) : ",
|
||||
default=defaut,
|
||||
)
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
|
||||
# psycopg2.errors.UndefinedTable: relation "discuss_channel" does not exist
|
||||
# LIGNE 1 : SELECT "discuss_channel"."id" FROM "discuss_channel" WHERE (...
|
||||
|
||||
if wait_status == "2" and database_name:
|
||||
# Le motif d'échec le plus fréquent ici est « Element
|
||||
# <xpath …> cannot be located in parent view » : une copie
|
||||
# COW en retard sur sa vue module. On propose l'outil sur
|
||||
# place, puis on repose le choix pour rejouer.
|
||||
self.check_stale_cow_views(database_name)
|
||||
continue
|
||||
if wait_status == "3" and database_name:
|
||||
repare = self.prompt_reset_stale_cow_views(database_name)
|
||||
if repare:
|
||||
# Quelque chose a changé : la commande mérite un
|
||||
# nouvel essai, et c'est le seul cas où le rejeu
|
||||
# est AUTOMATIQUE.
|
||||
wait_status = "1"
|
||||
break
|
||||
# Rien à réinitialiser. Reproposer « 3 » ferait tourner
|
||||
# en rond — mesuré : « Aucune copie COW n'a dérivé »,
|
||||
# encore et encore, sans fin.
|
||||
defaut = ""
|
||||
continue
|
||||
if wait_status == "5" and database_name:
|
||||
# Rien à voir avec les COW : ici la vue n'est PAS une
|
||||
# copie, c'est son `type` stocké qui ment. Odoo ne le
|
||||
# recalcule jamais — il le pose à la création — donc
|
||||
# aucune mise à jour ne le réparera, et le chargement
|
||||
# échoue avant d'y arriver.
|
||||
repare = self.prompt_fix_view_type(database_name)
|
||||
if repare:
|
||||
wait_status = "1"
|
||||
break
|
||||
defaut = ""
|
||||
continue
|
||||
if wait_status == "4" and database_name:
|
||||
# Sur le VRAI terminal : un plein écran refuse de
|
||||
# s'ouvrir sur un stdout capturé, et retomberait sur le
|
||||
# rapport texte sans que rien ne distingue les deux.
|
||||
self.run_on_terminal(
|
||||
f"{PYTHON_BIN} ./"
|
||||
+ os.path.join(
|
||||
PATH_MIGRATION_GLOBAL, "reset_stale_cow_views.py"
|
||||
)
|
||||
+ f" -d {database_name} --tui"
|
||||
)
|
||||
continue
|
||||
break
|
||||
|
||||
return wait_status, repare
|
||||
|
||||
def prompt_fix_view_type(self, database):
|
||||
"""Corriger les vues dont le `type` contredit leur héritage.
|
||||
|
||||
Rend True si quelque chose a changé — c'est ce qui autorise le
|
||||
rejeu automatique de la commande.
|
||||
|
||||
En SQL, sans l'ORM : le registre ne charge plus, et c'est
|
||||
précisément ce qu'on répare. Un outil qui aurait besoin d'Odoo
|
||||
pour réparer ce qui empêche Odoo de démarrer ne servirait à rien.
|
||||
"""
|
||||
outil = os.path.join(PATH_MIGRATION_GLOBAL, "fix_view_type.py")
|
||||
status, _cmd = self.todo_upgrade_execute(
|
||||
f"{PYTHON_BIN} ./{outil} -d {database}"
|
||||
)
|
||||
if status != 1:
|
||||
# 0 : rien à corriger. 2 : l'outil a échoué. Ni l'un ni
|
||||
# l'autre ne justifie de rejouer la commande.
|
||||
return False
|
||||
reponse = (
|
||||
self.ask(f"💬 {t('Correct them?')} (Y/n) : ", default="y")
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
if reponse not in ("y", "yes", "o"):
|
||||
return False
|
||||
status, _cmd = self.todo_upgrade_execute(
|
||||
f"{PYTHON_BIN} ./{outil} -d {database} --apply"
|
||||
)
|
||||
return status == 0
|
||||
|
||||
def prompt_purge_dead_attachments(self, database):
|
||||
"""Effacer les pièces jointes dont le champ n'existe plus.
|
||||
|
||||
ICI et pas entre les paliers. Mesuré : entre deux versions, deux
|
||||
à onze champs disparaissent puis REVIENNENT — `hr.employee.phone`,
|
||||
`account.move.statement_id`. « Le champ n'existe plus » est donc
|
||||
un état transitoire tant que la migration court, et purger
|
||||
dessus, c'est trancher sur ce qui va se rétablir.
|
||||
|
||||
Le gain d'un nettoyage par palier serait nul de toute façon :
|
||||
mesuré, 1881 lignes apparaissent au palier 13 et le compte ne
|
||||
bouge plus ensuite. Une passe finale les prend toutes.
|
||||
|
||||
Après la sauvegarde ? Non, AVANT : celle qui suit capturera
|
||||
l'état nettoyé, et qui veut garder l'état d'avant n'a qu'à
|
||||
refuser ici puis relancer l'outil depuis le menu Analyse.
|
||||
"""
|
||||
from script.analyse import check_filestore as filestore
|
||||
|
||||
try:
|
||||
rapport = filestore.audit(database)
|
||||
except Exception as exc:
|
||||
print(f"⚠ {t('Could not inspect the attachments: ')}{exc}")
|
||||
return
|
||||
if rapport.get("unavailable"):
|
||||
return
|
||||
sql = filestore.purge_dead_sql(rapport)
|
||||
combien = len(rapport.get("dead_ids") or [])
|
||||
if not sql:
|
||||
print(f"✅ {t('No attachment points at a field that is gone.')}")
|
||||
return
|
||||
poids = rapport.get("dead_kept_size", 0) // 1024
|
||||
print()
|
||||
print(
|
||||
f"🕳 {combien}"
|
||||
f" {t('attachment(s) point at a field that no longer exists')}"
|
||||
f" ({poids} ko)"
|
||||
)
|
||||
for texte in filestore.summarise(
|
||||
rapport["groups"]["dead_field"] + (rapport.get("dead_kept") or [])
|
||||
)[:6]:
|
||||
print(f" {texte}")
|
||||
reponse = (
|
||||
self.ask(f"💬 {t('Delete them?')} (Y/n) : ", default="y")
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
if reponse not in ("y", "yes", "o"):
|
||||
print(f"ℹ️ {t('Nothing was deleted.')}")
|
||||
return
|
||||
status, _cmd, sortie = self.todo_upgrade_execute(
|
||||
f'psql -d {database} -c "{sql}"', get_output=True
|
||||
)
|
||||
if status:
|
||||
print(f"❌ {t('The purge failed.')}")
|
||||
return
|
||||
efface = filestore.rows_deleted(sortie)
|
||||
if efface is None:
|
||||
print(f"⚠ {t('The purge ran but said nothing.')}")
|
||||
return
|
||||
print(f"✅ {efface} {t('attachment row(s) deleted.')}")
|
||||
|
||||
def get_rename_module(self, lst_module, next_version):
|
||||
path_search = f"odoo{next_version}.0/OCA_OpenUpgrade/"
|
||||
status, cmd_executed, lst_output = self.todo_upgrade_execute(
|
||||
|
|
@ -3679,79 +3868,9 @@ class TodoUpgrade:
|
|||
# n'existent pas et le défaut redevient « continuer ».
|
||||
defaut = "3" if database_name else ""
|
||||
repare = False
|
||||
tours = 0
|
||||
while True:
|
||||
# Une borne STRUCTURELLE, et non pas seulement la logique
|
||||
# ci-dessous qui bascule le défaut. Les deux protections
|
||||
# visent la même panne — une invite qui se repropose sans
|
||||
# fin — mais celle-ci tient même si l'autre est cassée un
|
||||
# jour par mégarde. Une boucle infinie dans une migration
|
||||
# lancée sans surveillance coûte une nuit.
|
||||
tours += 1
|
||||
if tours > self.MAX_ERROR_TURNS:
|
||||
print(
|
||||
f"🛑 {t('Too many turns on this prompt: moving on.')}"
|
||||
)
|
||||
wait_status = ""
|
||||
break
|
||||
print(f"[1] {t('to redo the command')}")
|
||||
if database_name:
|
||||
print(
|
||||
f"[2] {t('Check the COW views that drifted')}"
|
||||
f" ({database_name})"
|
||||
)
|
||||
print(f"[3] {t('Reset one of them onto its module view')}")
|
||||
print(f"[4] {t('Browse the differences full screen')}")
|
||||
# `self.ask` : une migration automatique s'arrêtait ICI,
|
||||
# sur une invite qui ne demande qu'à continuer, et restait
|
||||
# bloquée sans que rien ne le signale.
|
||||
wait_status = (
|
||||
self.ask(
|
||||
f"💬 {t('Error detected. Choose, or ctrl+c to')}"
|
||||
f" {t('stop')}"
|
||||
f" ({t('Enter')} = {defaut or t('continue')}) : ",
|
||||
default=defaut,
|
||||
)
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
|
||||
# psycopg2.errors.UndefinedTable: relation "discuss_channel" does not exist
|
||||
# LIGNE 1 : SELECT "discuss_channel"."id" FROM "discuss_channel" WHERE (...
|
||||
|
||||
if wait_status == "2" and database_name:
|
||||
# Le motif d'échec le plus fréquent ici est « Element
|
||||
# <xpath …> cannot be located in parent view » : une copie
|
||||
# COW en retard sur sa vue module. On propose l'outil sur
|
||||
# place, puis on repose le choix pour rejouer.
|
||||
self.check_stale_cow_views(database_name)
|
||||
continue
|
||||
if wait_status == "3" and database_name:
|
||||
repare = self.prompt_reset_stale_cow_views(database_name)
|
||||
if repare:
|
||||
# Quelque chose a changé : la commande mérite un
|
||||
# nouvel essai, et c'est le seul cas où le rejeu
|
||||
# est AUTOMATIQUE.
|
||||
wait_status = "1"
|
||||
break
|
||||
# Rien à réinitialiser. Reproposer « 3 » ferait tourner
|
||||
# en rond — mesuré : « Aucune copie COW n'a dérivé »,
|
||||
# encore et encore, sans fin.
|
||||
defaut = ""
|
||||
continue
|
||||
if wait_status == "4" and database_name:
|
||||
# Sur le VRAI terminal : un plein écran refuse de
|
||||
# s'ouvrir sur un stdout capturé, et retomberait sur le
|
||||
# rapport texte sans que rien ne distingue les deux.
|
||||
self.run_on_terminal(
|
||||
f"{PYTHON_BIN} ./"
|
||||
+ os.path.join(
|
||||
PATH_MIGRATION_GLOBAL, "reset_stale_cow_views.py"
|
||||
)
|
||||
+ f" -d {database_name} --tui"
|
||||
)
|
||||
continue
|
||||
break
|
||||
wait_status, repare = self._prompt_on_error(
|
||||
database_name, defaut, repare
|
||||
)
|
||||
|
||||
if wait_status == "1":
|
||||
# Le rejeu AUTOMATIQUE est borné ; celui qu'on demande à la
|
||||
|
|
|
|||
|
|
@ -176,7 +176,13 @@ class TestTheHumanKeepsTheWheel(Harness):
|
|||
def test_the_prompt_says_what_enter_does(self):
|
||||
import inspect
|
||||
|
||||
source = inspect.getsource(TodoUpgrade.todo_upgrade_execute)
|
||||
# Le menu d'erreur vit dans `_prompt_on_error`, extrait de
|
||||
# `todo_upgrade_execute` quand celui-ci a passé le seuil de
|
||||
# complexité. Lire les deux : c'est le CHEMIN d'erreur qu'on
|
||||
# éprouve, pas une méthode en particulier.
|
||||
source = inspect.getsource(
|
||||
TodoUpgrade.todo_upgrade_execute
|
||||
) + inspect.getsource(TodoUpgrade._prompt_on_error)
|
||||
self.assertIn('defaut = "3" if database_name else ""', source)
|
||||
self.assertIn("default=defaut", source)
|
||||
|
||||
|
|
|
|||
264
test/test_fix_view_type.py
Normal file
264
test/test_fix_view_type.py
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
#!/usr/bin/env python3
|
||||
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
|
||||
"""Une vue dont le `type` stocké contredit son héritage.
|
||||
|
||||
Deux propriétés portent l'outil. La première : il travaille en SQL, sans
|
||||
l'ORM — le registre ne charge plus, et c'est justement ce qu'on répare.
|
||||
La seconde : il ne corrige QUE ce qu'Odoo lui-même déclare faux — une
|
||||
vue héritée prend le type de son ancêtre, règle citée dans `create`.
|
||||
Vérifié sur une installation neuve et quatre bases migrées : zéro écart.
|
||||
"""
|
||||
|
||||
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.odoo.migration import fix_view_type as fvt # noqa: E402
|
||||
from script.todo import todo_i18n # noqa: E402
|
||||
|
||||
|
||||
def vue(vid=637, actuel="tree", attendu="search", xmlid="event.x"):
|
||||
return {
|
||||
"id": vid,
|
||||
"type": actuel,
|
||||
"expected": attendu,
|
||||
"mode": "primary",
|
||||
"xmlid": xmlid,
|
||||
"model": "event.registration",
|
||||
}
|
||||
|
||||
|
||||
class TestTheDetectionQuery(unittest.TestCase):
|
||||
def test_it_only_looks_at_inherited_views(self):
|
||||
# Une vue racine tient son type de sa balise : la comparer à
|
||||
# elle-même n'apprendrait rien.
|
||||
self.assertIn("v.inherit_id IS NOT NULL", fvt.DETECTION)
|
||||
|
||||
def test_it_compares_against_the_ROOT_ancestor(self):
|
||||
# Pas le parent direct : une chaîne de trois vues doit remonter
|
||||
# jusqu'à celle qui porte la balise.
|
||||
self.assertIn("WITH RECURSIVE", fvt.DETECTION)
|
||||
self.assertIn("v.type <> r.type", fvt.DETECTION)
|
||||
|
||||
def test_the_recursion_is_bounded(self):
|
||||
# Un `inherit_id` circulaire ferait tourner PostgreSQL sans fin.
|
||||
self.assertIn(f"r.prof < {fvt.PROFONDEUR_MAX}", fvt.DETECTION)
|
||||
self.assertGreater(fvt.PROFONDEUR_MAX, 5)
|
||||
|
||||
def test_it_never_writes(self):
|
||||
for mot in ("UPDATE", "DELETE", "INSERT", "DROP"):
|
||||
self.assertNotIn(mot, fvt.DETECTION.upper().replace("UPDATED", ""))
|
||||
|
||||
|
||||
class TestTheRepairSql(unittest.TestCase):
|
||||
def test_it_sets_each_view_to_its_ancestor_type(self):
|
||||
sql = fvt.repair_sql([vue(637, "tree", "search")])
|
||||
self.assertIn("WHEN 637 THEN 'search'", sql)
|
||||
self.assertIn("WHERE id IN (637)", sql)
|
||||
|
||||
def test_several_views_travel_in_ONE_statement(self):
|
||||
# Une seule instruction, donc une seule transaction : couper au
|
||||
# milieu ne laisse pas une base à moitié réparée.
|
||||
sql = fvt.repair_sql(
|
||||
[vue(1, "tree", "search"), vue(2, "form", "kanban")]
|
||||
)
|
||||
self.assertEqual(sql.count("UPDATE"), 1)
|
||||
self.assertIn("WHEN 1 THEN 'search'", sql)
|
||||
self.assertIn("WHEN 2 THEN 'kanban'", sql)
|
||||
|
||||
def test_nothing_to_fix_gives_no_sql(self):
|
||||
self.assertEqual(fvt.repair_sql([]), "")
|
||||
|
||||
def test_it_targets_the_id_never_a_rebuilt_domain(self):
|
||||
sql = fvt.repair_sql([vue()])
|
||||
self.assertNotIn("inherit_id", sql)
|
||||
self.assertNotIn("arch_db", sql)
|
||||
self.assertNotIn("arch =", sql)
|
||||
self.assertEqual(sql.count("SET "), 1)
|
||||
self.assertIn("SET type =", sql)
|
||||
|
||||
|
||||
class TestTheReport(unittest.TestCase):
|
||||
def test_a_clean_database_says_so(self):
|
||||
texte = "\n".join(fvt.render([]))
|
||||
self.assertIn(
|
||||
todo_i18n.t("Every view type agrees with its inheritance."), texte
|
||||
)
|
||||
|
||||
def test_a_finding_names_the_view_and_both_types(self):
|
||||
texte = "\n".join(fvt.render([vue(637, "tree", "search")]))
|
||||
self.assertIn("637", texte)
|
||||
self.assertIn("tree → search", texte)
|
||||
self.assertIn("event.x", texte)
|
||||
|
||||
def test_it_says_no_module_update_will_help(self):
|
||||
# C'est le renseignement qui évite de perdre une heure à
|
||||
# relancer `-u module` en boucle.
|
||||
texte = "\n".join(fvt.render([vue()]))
|
||||
self.assertIn(
|
||||
todo_i18n.t(
|
||||
"No module update will fix this: the type is set once,"
|
||||
),
|
||||
texte,
|
||||
)
|
||||
|
||||
def test_an_applied_run_does_not_repeat_the_advice(self):
|
||||
texte = "\n".join(fvt.render([vue()], applique=True))
|
||||
self.assertIn(todo_i18n.t("Corrected."), texte)
|
||||
self.assertNotIn(
|
||||
todo_i18n.t("at creation. Re-run with --apply."), texte
|
||||
)
|
||||
|
||||
|
||||
class TestTheExitCodes(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.vrai = fvt.run_psql
|
||||
self.ecritures = []
|
||||
|
||||
def tearDown(self):
|
||||
fvt.run_psql = self.vrai
|
||||
|
||||
def branche(self, avant, apres=None):
|
||||
"""`avant` puis `apres` : ce que la détection rend, avant/après."""
|
||||
etat = {"tour": 0}
|
||||
|
||||
def faux(database, sql, read_only=True):
|
||||
if not read_only:
|
||||
self.ecritures.append(sql)
|
||||
return []
|
||||
etat["tour"] += 1
|
||||
lot = avant if etat["tour"] == 1 or apres is None else apres
|
||||
if lot is None:
|
||||
return None
|
||||
return [
|
||||
[
|
||||
str(v["id"]),
|
||||
v["type"],
|
||||
v["expected"],
|
||||
v["mode"],
|
||||
v["xmlid"],
|
||||
v["model"],
|
||||
]
|
||||
for v in lot
|
||||
]
|
||||
|
||||
fvt.run_psql = faux
|
||||
|
||||
def lance(self, argv):
|
||||
tampon = io.StringIO()
|
||||
with redirect_stdout(tampon):
|
||||
code = fvt.main(argv)
|
||||
return code, tampon.getvalue()
|
||||
|
||||
def test_nothing_wrong_exits_zero(self):
|
||||
self.branche([])
|
||||
code, _ = self.lance(["-d", "db"])
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(self.ecritures, [])
|
||||
|
||||
def test_a_finding_exits_one_and_writes_nothing(self):
|
||||
# Sans --apply, l'outil DIAGNOSTIQUE. Réparer tout seul serait un
|
||||
# piège dans un outil qu'on lance pour comprendre.
|
||||
self.branche([vue()])
|
||||
code, _ = self.lance(["-d", "db"])
|
||||
self.assertEqual(code, 1)
|
||||
self.assertEqual(self.ecritures, [])
|
||||
|
||||
def test_apply_fixes_and_exits_zero(self):
|
||||
self.branche([vue()], apres=[])
|
||||
code, sortie = self.lance(["-d", "db", "--apply"])
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(len(self.ecritures), 1)
|
||||
self.assertIn(todo_i18n.t("Corrected."), sortie)
|
||||
|
||||
def test_a_correction_that_did_not_take_exits_two(self):
|
||||
# Relire APRÈS : annoncer « corrigé » sans vérifier ferait
|
||||
# relancer la migration sur le même mur.
|
||||
self.branche([vue()], apres=[vue()])
|
||||
code, _ = self.lance(["-d", "db", "--apply"])
|
||||
self.assertEqual(code, 2)
|
||||
|
||||
def test_an_unreadable_database_exits_two(self):
|
||||
self.branche(None)
|
||||
code, _ = self.lance(["-d", "db"])
|
||||
self.assertEqual(code, 2)
|
||||
|
||||
|
||||
class TestTheWiring(unittest.TestCase):
|
||||
RACINE = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
def source(self):
|
||||
with io.open(
|
||||
os.path.join(self.RACINE, "script", "todo", "todo_upgrade.py"),
|
||||
encoding="utf-8",
|
||||
) as handle:
|
||||
return handle.read()
|
||||
|
||||
def test_the_error_menu_offers_the_entry(self):
|
||||
src = self.source()
|
||||
self.assertIn("Fix views whose type contradicts their", src)
|
||||
self.assertIn('if wait_status == "5" and database_name:', src)
|
||||
self.assertIn("def prompt_fix_view_type", src)
|
||||
|
||||
def test_it_runs_the_tool_twice_report_then_apply(self):
|
||||
src = self.source()
|
||||
debut = src.index("def prompt_fix_view_type")
|
||||
fin = src.index("def prompt_purge_dead_attachments")
|
||||
bloc = src[debut:fin]
|
||||
self.assertEqual(bloc.count("fix_view_type.py"), 1)
|
||||
self.assertIn("--apply", bloc)
|
||||
|
||||
def test_only_a_finding_leads_to_the_question(self):
|
||||
# 0 « rien à corriger » et 2 « l'outil a échoué » ne justifient
|
||||
# ni la question ni le rejeu de la commande.
|
||||
src = self.source()
|
||||
debut = src.index("def prompt_fix_view_type")
|
||||
fin = src.index("def prompt_purge_dead_attachments")
|
||||
self.assertIn("if status != 1:", src[debut:fin])
|
||||
|
||||
def test_every_menu_entry_has_its_branch(self):
|
||||
src = self.source()
|
||||
debut = src.index("def _prompt_on_error")
|
||||
fin = src.index("def prompt_fix_view_type")
|
||||
bloc = src[debut:fin]
|
||||
for rang in ("1", "2", "3", "4", "5"):
|
||||
self.assertTrue(
|
||||
f'f"[{rang}] ' in bloc, f"entrée [{rang}] absente du menu"
|
||||
)
|
||||
for rang in ("2", "3", "4", "5"):
|
||||
self.assertTrue(
|
||||
f'wait_status == "{rang}"' in bloc,
|
||||
f"aiguillage [{rang}] absent",
|
||||
)
|
||||
|
||||
|
||||
class TestTranslations(unittest.TestCase):
|
||||
def test_every_key_exists(self):
|
||||
with io.open(fvt.__file__, encoding="utf-8") as handle:
|
||||
arbre = ast.parse(handle.read())
|
||||
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)
|
||||
):
|
||||
cle = node.args[0].value
|
||||
self.assertTrue(
|
||||
cle in todo_i18n.TRANSLATIONS,
|
||||
f"clé sans traduction : {cle!r}",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -1594,7 +1594,13 @@ class TestWhatGetsRecorded(Base):
|
|||
# l'état : c'est précisément celui qu'on cherchera en revenant.
|
||||
import inspect
|
||||
|
||||
source = inspect.getsource(TodoUpgrade.todo_upgrade_execute)
|
||||
# Le menu d'erreur vit dans `_prompt_on_error`, extrait de
|
||||
# `todo_upgrade_execute` quand celui-ci a passé le seuil de
|
||||
# complexité. Lire les deux : c'est le CHEMIN d'erreur qu'on
|
||||
# éprouve, pas une méthode en particulier.
|
||||
source = inspect.getsource(
|
||||
TodoUpgrade.todo_upgrade_execute
|
||||
) + inspect.getsource(TodoUpgrade._prompt_on_error)
|
||||
self.assertLess(
|
||||
source.index('record_event("command"'),
|
||||
source.index("Error detected"),
|
||||
|
|
|
|||
|
|
@ -141,7 +141,13 @@ class TestTheKeysComeFromTheTool(unittest.TestCase):
|
|||
def test_the_error_prompt_offers_it(self):
|
||||
import inspect
|
||||
|
||||
source = inspect.getsource(TodoUpgrade.todo_upgrade_execute)
|
||||
# Le menu d'erreur vit dans `_prompt_on_error`, extrait de
|
||||
# `todo_upgrade_execute` quand celui-ci a passé le seuil de
|
||||
# complexité. Lire les deux : c'est le CHEMIN d'erreur qu'on
|
||||
# éprouve, pas une méthode en particulier.
|
||||
source = inspect.getsource(
|
||||
TodoUpgrade.todo_upgrade_execute
|
||||
) + inspect.getsource(TodoUpgrade._prompt_on_error)
|
||||
self.assertIn("prompt_reset_stale_cow_views", source)
|
||||
self.assertIn("Reset one of them onto its module view", source)
|
||||
|
||||
|
|
|
|||
|
|
@ -141,7 +141,13 @@ class TestTheToolAndTheMigrationOfferIt(unittest.TestCase):
|
|||
|
||||
from script.todo.todo_upgrade import TodoUpgrade
|
||||
|
||||
source = inspect.getsource(TodoUpgrade.todo_upgrade_execute)
|
||||
# Le menu d'erreur vit dans `_prompt_on_error`, extrait de
|
||||
# `todo_upgrade_execute` quand celui-ci a passé le seuil de
|
||||
# complexité. Lire les deux : c'est le CHEMIN d'erreur qu'on
|
||||
# éprouve, pas une méthode en particulier.
|
||||
source = inspect.getsource(
|
||||
TodoUpgrade.todo_upgrade_execute
|
||||
) + inspect.getsource(TodoUpgrade._prompt_on_error)
|
||||
self.assertIn("Browse the differences full screen", source)
|
||||
self.assertIn('wait_status == "4"', source)
|
||||
|
||||
|
|
@ -152,7 +158,13 @@ class TestTheToolAndTheMigrationOfferIt(unittest.TestCase):
|
|||
|
||||
from script.todo.todo_upgrade import TodoUpgrade
|
||||
|
||||
source = inspect.getsource(TodoUpgrade.todo_upgrade_execute)
|
||||
# Le menu d'erreur vit dans `_prompt_on_error`, extrait de
|
||||
# `todo_upgrade_execute` quand celui-ci a passé le seuil de
|
||||
# complexité. Lire les deux : c'est le CHEMIN d'erreur qu'on
|
||||
# éprouve, pas une méthode en particulier.
|
||||
source = inspect.getsource(
|
||||
TodoUpgrade.todo_upgrade_execute
|
||||
) + inspect.getsource(TodoUpgrade._prompt_on_error)
|
||||
start = source.index('wait_status == "4"')
|
||||
window = source[start : start + 620]
|
||||
self.assertIn("run_on_terminal", window)
|
||||
|
|
|
|||
Loading…
Reference in a new issue