[FIX] migration: name the template when the failure comes from rendering

A QWebException carries no [view_id … parent_id …] block — it names the
template. The tool only read the inheritance context, so it answered
« no parent view named » on the one failure that hit everything.

Measured at bump 17: a frozen copy of website.submenu still called
submenu.clean_url(), renamed _clean_url() in that version. Every page
renders the menu, so 34 of 37 public URLs returned 500, and nothing
pointed at the view. Resetting that one copy brought all 37 back.

Only keys that actually have a COW copy are proposed: naming one
without would send a reset against a module view, which does nothing.

--- FR ---

[FIX] migration : nommer le gabarit quand l'échec vient du rendu

Une QWebException ne porte pas de bloc [view_id … parent_id …] : elle
nomme le gabarit. L'outil ne lisait que le contexte d'héritage, et
répondait donc « aucune vue parente nommée » sur la seule panne qui
touchait tout.

Mesuré au palier 17 : une copie figée de website.submenu appelait
encore submenu.clean_url(), renommée _clean_url() par la version. Toute
page affiche le menu, donc 34 URL publiques sur 37 rendaient 500, sans
que rien ne désigne la vue. Réinitialiser cette seule copie les a
toutes ramenées.

Seules les clés ayant vraiment une copie COW sont proposées : en nommer
une sans copie enverrait réinitialiser une vue module, donc ne rien
faire.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-17 07:59:28 -04:00
parent 95ffbe6f13
commit efc152bf62
2 changed files with 103 additions and 2 deletions

View file

@ -74,6 +74,13 @@ RE_LOC = re.compile(r"<loc>\s*([^<\s]+)\s*</loc>", re.I)
# figée dans laquelle l'enfant ne trouve plus son xpath.
RE_CONTEXT = re.compile(r"\[view_id: (\d+),.*?parent_id: (\d+)\]")
# « Template: website.submenu ». Une QWebException de RENDU ne porte pas le
# bloc [view_id …] : elle nomme le gabarit. Mesuré au palier 17 — une copie
# figée appelait `submenu.clean_url()`, méthode renommée `_clean_url()` dans
# la version, et 34 URL sur 37 rendaient 500 sans que rien ne désigne la vue
# fautive.
RE_TEMPLATE = re.compile(r"^Template:\s*([\w.]+)\s*$", re.M)
# Un port à part : la migration tourne souvent à côté d'une instance vivante,
# et lui voler 8069 ferait échouer le test pour une raison sans rapport.
DEFAULT_PORT = 8169
@ -253,6 +260,29 @@ def check_urls(lst_url, timeout=30):
return lst_failure
def template_keys(lst_log, database):
"""Les gabarits nommés par une QWebException, s'ils ont une copie COW.
Nommer une clé sans copie enverrait réinitialiser une vue module — donc
ne rien faire, en silence. On ne propose que ce qui peut l'être.
"""
lst_key = []
for line in lst_log:
for key in RE_TEMPLATE.findall(line):
if key not in lst_key:
lst_key.append(key)
if not lst_key:
return []
quoted = ",".join("'" + k.replace("'", "''") + "'" for k in lst_key)
rows = run_psql(
database,
"SELECT DISTINCT key FROM ir_ui_view"
f" WHERE website_id IS NOT NULL AND key IN ({quoted});",
)
with_copy = {row[0] for row in rows if row and row[0]}
return [key for key in lst_key if key in with_copy]
def culprit_keys(database, lst_failure):
"""Les clés des vues parentes mises en cause, sans doublon.
@ -426,9 +456,16 @@ def run(
finally:
stop_server(server)
lst_key = []
if lst_failure:
lst_failure = attach_missing_parents(lst_failure, read_log(log_path))
lst_key = culprit_keys(database, lst_failure)
lst_log = read_log(log_path)
lst_failure = attach_missing_parents(lst_failure, lst_log)
lst_key = culprit_keys(database, lst_failure)
# Les deux sources : le contexte d'héritage quand il existe, le nom
# du gabarit quand l'échec vient du rendu.
for key in template_keys(lst_log, database):
if key not in lst_key:
lst_key.append(key)
if not lst_failure or not (interactive or auto_apply):
return lst_url, lst_failure, lst_key, None

View file

@ -240,6 +240,70 @@ class TestTheCulpritViewsAreNamed(unittest.TestCase):
self.assertEqual(rebuilt[0][2].count("2841"), 1)
class TestARenderFailureNamesItsTemplate(unittest.TestCase):
"""Une QWebException ne porte pas de bloc [view_id …] : elle nomme le
gabarit.
Mesuré au palier 17 : une copie figée appelait `submenu.clean_url()`,
méthode renommée `_clean_url()` par la version. 34 URL sur 37 rendaient
500, et l'outil répondait « aucune vue parente nommée » — il ne lisait
que le contexte d'héritage, absent ici.
"""
def test_the_template_line_is_read(self):
self.assertEqual(
smoke.RE_TEMPLATE.findall("Template: website.submenu"),
["website.submenu"],
)
def test_prose_is_not_mistaken_for_a_key(self):
# « Template: » suivi d'une phrase n'est pas un gabarit.
self.assertEqual(
smoke.RE_TEMPLATE.findall("Template: not a key, just words"), []
)
def test_only_keys_with_a_copy_are_proposed(self):
# Proposer une clé sans copie COW enverrait réinitialiser une vue
# module : la commande tournerait sans rien faire.
seen = {}
original = smoke.run_psql
smoke.run_psql = lambda db, sql: (
seen.update(sql=sql),
[["website.submenu"]],
)[1]
self.addCleanup(setattr, smoke, "run_psql", original)
got = smoke.template_keys(
["Template: website.submenu", "Template: website.layout"], "db"
)
self.assertEqual(got, ["website.submenu"])
self.assertIn("website_id IS NOT NULL", seen["sql"])
def test_no_template_line_asks_nothing_of_the_database(self):
called = []
original = smoke.run_psql
smoke.run_psql = lambda db, sql: called.append(sql) or []
self.addCleanup(setattr, smoke, "run_psql", original)
self.assertEqual(smoke.template_keys(["rien ici"], "db"), [])
self.assertEqual(called, [])
def test_a_quote_in_a_key_is_escaped(self):
seen = {}
original = smoke.run_psql
smoke.run_psql = lambda db, sql: (seen.update(sql=sql), [])[1]
self.addCleanup(setattr, smoke, "run_psql", original)
smoke.template_keys(["Template: a.b"], "db")
self.assertIn("'a.b'", seen["sql"])
def test_both_sources_feed_the_proposal(self):
# Le contexte d'héritage quand il existe, le gabarit quand l'échec
# vient du rendu : l'un ne remplace pas l'autre.
import inspect
source = inspect.getsource(smoke.run)
self.assertIn("culprit_keys(", source)
self.assertIn("template_keys(", source)
class TestTheServerIsKilledForReal(unittest.TestCase):
"""« ./run.sh » est un script bash : il ne transmet pas les signaux."""