[FIX] migration: reset the stale SCSS after the bump, not before
The fix was offered while the checkout was still on the previous version. Answering « a » ran reset_asset in an Odoo 12 shell, which has no web_editor.assets: KeyError, nothing changed, and the migration went on to break at the next bump — measured on a real run. Predicting early is right; fixing early is not. The early call is now --report-only, and a second call comes after the bump, on the upgraded database, where the checkout can do it. The tool also refuses on its own: it reads the checkout sources for reset_asset rather than trusting a version number, and says when to come back. --- FR --- [FIX] migration : réinitialiser le SCSS périmé après le palier, pas avant La correction était proposée alors que le checkout était encore sur la version précédente. Répondre « a » lançait reset_asset dans un shell Odoo 12, sans web_editor.assets : KeyError, rien de modifié, et la migration continuait jusqu'à casser au palier suivant. Mesuré. Prédire tôt est juste ; corriger tôt ne l'est pas. L'appel précoce est désormais --report-only, et un second vient après le palier, sur la base montée de version, là où le checkout sait le faire. L'outil refuse aussi de lui-même : il cherche reset_asset dans les sources du checkout plutôt que de se fier à un numéro, et dit quand revenir. Assisted-by: Claude Opus 5 (cherry picked from commit 13d4d0a33c63efcbafb5fb29313a5377ba5f508e)
This commit is contained in:
parent
26de9a109f
commit
5c4d71e7a2
4 changed files with 214 additions and 8 deletions
|
|
@ -326,6 +326,54 @@ def render_diff(finding):
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def running_odoo_dir():
|
||||||
|
"""Le répertoire de la version qui répondra au shell, d'après le checkout.
|
||||||
|
|
||||||
|
Ce n'est PAS la cible : au moment où l'on prédit ce que le palier
|
||||||
|
cassera, le checkout est encore sur la version d'avant. C'est elle qui
|
||||||
|
exécutera reset_asset — ou ne le saura pas.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(".odoo-version", "r", encoding="utf-8") as handle:
|
||||||
|
return "odoo" + handle.read().strip()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def reset_supported(odoo_dir=None):
|
||||||
|
"""Cette version sait-elle faire reset_asset ?
|
||||||
|
|
||||||
|
Mesuré : `web_editor.assets` et `reset_asset` n'existent qu'à partir de
|
||||||
|
13.0. Lancé sous odoo12.0, l'appel lève « KeyError: 'web_editor.assets' »
|
||||||
|
et ne change rien — c'est arrivé sur une vraie migration, et le correctif
|
||||||
|
a été cru appliqué alors qu'il avait échoué.
|
||||||
|
|
||||||
|
On regarde les sources plutôt qu'un numéro : c'est ce qui répondra.
|
||||||
|
"""
|
||||||
|
odoo_dir = odoo_dir or running_odoo_dir()
|
||||||
|
if not odoo_dir or not os.path.isdir(odoo_dir):
|
||||||
|
return True # Rien pour trancher : ne pas bloquer sur une supposition.
|
||||||
|
pattern = os.path.join(odoo_dir, "**", "web_editor", "models", "assets.py")
|
||||||
|
for path in glob.iglob(pattern, recursive=True):
|
||||||
|
with open(path, "r", encoding="utf-8", errors="replace") as handle:
|
||||||
|
if "def reset_asset" in handle.read():
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def too_early_message(odoo_dir, database):
|
||||||
|
"""Pourquoi on ne peut pas encore corriger, et quand on pourra."""
|
||||||
|
return (
|
||||||
|
f"⛔ {t('Resetting needs Odoo 13.0 or later; this checkout is on')}"
|
||||||
|
f" {odoo_dir or '?'}.\n"
|
||||||
|
f" {t('The prediction stands, the fix does not: run it again once')}"
|
||||||
|
f" {t('the bump is done, on the upgraded database.')}\n"
|
||||||
|
f" {t('Applying it from here fails with')}"
|
||||||
|
" KeyError: 'web_editor.assets'"
|
||||||
|
f" {t('and changes nothing.')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def reset_command(lst_finding, database, config_path="./config.conf"):
|
def reset_command(lst_finding, database, config_path="./config.conf"):
|
||||||
"""La commande qui rend les fichiers de module. Rien n'est lancé ici."""
|
"""La commande qui rend les fichiers de module. Rien n'est lancé ici."""
|
||||||
lines = [f"./odoo_bin.sh shell -c {config_path} -d {database} <<'PY'"]
|
lines = [f"./odoo_bin.sh shell -c {config_path} -d {database} <<'PY'"]
|
||||||
|
|
@ -418,14 +466,20 @@ def prompt(lst_finding, database, config_path="./config.conf", ask=input):
|
||||||
perdre on ne sait quoi. L'invite revient donc après chaque lecture :
|
perdre on ne sait quoi. L'invite revient donc après chaque lecture :
|
||||||
regarder ne répond pas à la question.
|
regarder ne répond pas à la question.
|
||||||
"""
|
"""
|
||||||
|
odoo_dir = running_odoo_dir()
|
||||||
|
can_reset = reset_supported(odoo_dir)
|
||||||
|
if not can_reset:
|
||||||
|
print(too_early_message(odoo_dir, database))
|
||||||
while True:
|
while True:
|
||||||
|
choix = (
|
||||||
|
f" a = {t('reset them onto the module file')}" if can_reset else ""
|
||||||
|
)
|
||||||
answer = (
|
answer = (
|
||||||
ask(
|
ask(
|
||||||
f"💬 {t('What do you want to do with these customizations?')}"
|
f"💬 {t('What do you want to do with these customizations?')}"
|
||||||
f" ({t('Enter = nothing')},"
|
f" ({t('Enter = nothing')},"
|
||||||
f" v = {t('what the copy changed')},"
|
f" v = {t('what the copy changed')},"
|
||||||
f" w = {t('full screen')},"
|
f" w = {t('full screen')}{choix}) : "
|
||||||
f" a = {t('reset them onto the module file')}) : "
|
|
||||||
)
|
)
|
||||||
.strip()
|
.strip()
|
||||||
.lower()
|
.lower()
|
||||||
|
|
@ -445,7 +499,7 @@ def prompt(lst_finding, database, config_path="./config.conf", ask=input):
|
||||||
for finding in lst_finding:
|
for finding in lst_finding:
|
||||||
print(render_diff(finding))
|
print(render_diff(finding))
|
||||||
continue
|
continue
|
||||||
if answer == "a":
|
if answer == "a" and can_reset:
|
||||||
lst_path = backup_custom(lst_finding, database)
|
lst_path = backup_custom(lst_finding, database)
|
||||||
print(f"📦 {t('Saved before resetting')} :")
|
print(f"📦 {t('Saved before resetting')} :")
|
||||||
for path in lst_path:
|
for path in lst_path:
|
||||||
|
|
@ -498,6 +552,11 @@ def main(argv=None):
|
||||||
default="./config.conf",
|
default="./config.conf",
|
||||||
help="Odoo config used by the shell for --apply",
|
help="Odoo config used by the shell for --apply",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--report-only",
|
||||||
|
action="store_true",
|
||||||
|
help="never ask anything, even in front of a terminal",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--diff",
|
"--diff",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
|
|
@ -544,6 +603,10 @@ def main(argv=None):
|
||||||
for finding in lst_finding:
|
for finding in lst_finding:
|
||||||
print(render_diff(finding))
|
print(render_diff(finding))
|
||||||
if config.apply:
|
if config.apply:
|
||||||
|
odoo_dir = running_odoo_dir()
|
||||||
|
if not reset_supported(odoo_dir):
|
||||||
|
print(too_early_message(odoo_dir, config.database))
|
||||||
|
return 2
|
||||||
lst_path = backup_custom(lst_finding, config.database)
|
lst_path = backup_custom(lst_finding, config.database)
|
||||||
print(f"📦 {t('Saved before resetting')} :")
|
print(f"📦 {t('Saved before resetting')} :")
|
||||||
for path in lst_path:
|
for path in lst_path:
|
||||||
|
|
@ -562,7 +625,10 @@ def main(argv=None):
|
||||||
# laisser retrouver soi-même les deux arguments de reset_asset. Mais
|
# laisser retrouver soi-même les deux arguments de reset_asset. Mais
|
||||||
# seulement devant un terminal — dans un tube, une invite bloquerait
|
# seulement devant un terminal — dans un tube, une invite bloquerait
|
||||||
# l'appelant sans que personne ne voie la question.
|
# l'appelant sans que personne ne voie la question.
|
||||||
if not (config.diff or config.tui) and sys.stdin.isatty():
|
if (
|
||||||
|
not (config.diff or config.tui or config.report_only)
|
||||||
|
and sys.stdin.isatty()
|
||||||
|
):
|
||||||
if prompt(lst_finding, config.database, config.config):
|
if prompt(lst_finding, config.database, config.config):
|
||||||
return 0
|
return 0
|
||||||
return 1
|
return 1
|
||||||
|
|
|
||||||
|
|
@ -4906,6 +4906,26 @@ TRANSLATIONS = {
|
||||||
"fr": "Commande de réinitialisation copiée.",
|
"fr": "Commande de réinitialisation copiée.",
|
||||||
"en": "Reset command copied.",
|
"en": "Reset command copied.",
|
||||||
},
|
},
|
||||||
|
"Resetting needs Odoo 13.0 or later; this checkout is on": {
|
||||||
|
"fr": "La réinitialisation exige Odoo 13.0 ou plus ; ce checkout est sur",
|
||||||
|
"en": "Resetting needs Odoo 13.0 or later; this checkout is on",
|
||||||
|
},
|
||||||
|
"The prediction stands, the fix does not: run it again once": {
|
||||||
|
"fr": "La prédiction tient, pas la correction : relancer une fois",
|
||||||
|
"en": "The prediction stands, the fix does not: run it again once",
|
||||||
|
},
|
||||||
|
"the bump is done, on the upgraded database.": {
|
||||||
|
"fr": "le palier passé, sur la base montée de version.",
|
||||||
|
"en": "the bump is done, on the upgraded database.",
|
||||||
|
},
|
||||||
|
"Applying it from here fails with": {
|
||||||
|
"fr": "L'appliquer d'ici échoue sur",
|
||||||
|
"en": "Applying it from here fails with",
|
||||||
|
},
|
||||||
|
"and changes nothing.": {
|
||||||
|
"fr": "et ne change rien.",
|
||||||
|
"en": "and changes nothing.",
|
||||||
|
},
|
||||||
"Nothing to decide yet": {
|
"Nothing to decide yet": {
|
||||||
"fr": "Rien à décider pour l'instant",
|
"fr": "Rien à décider pour l'instant",
|
||||||
"en": "Nothing to decide yet",
|
"en": "Nothing to decide yet",
|
||||||
|
|
|
||||||
|
|
@ -1645,12 +1645,14 @@ class TodoUpgrade:
|
||||||
# une copie figée, et un palier renomme aussi des variables. Celle-ci
|
# une copie figée, et un palier renomme aussi des variables. Celle-ci
|
||||||
# ne se voit qu'à l'écran d'une page — « Style error », sans dire quel
|
# ne se voit qu'à l'écran d'une page — « Style error », sans dire quel
|
||||||
# fichier ni depuis quand — et seulement une fois le palier passé.
|
# fichier ni depuis quand — et seulement une fois le palier passé.
|
||||||
# Sur le VRAI terminal : l'outil pose lui-même ses questions — voir
|
# Ici on PRÉDIT, on ne corrige pas : le checkout est encore sur la
|
||||||
# l'écart, l'ouvrir en plein écran, réinitialiser — et un tube les
|
# version d'avant, et reset_asset n'existe qu'à partir de 13.0.
|
||||||
# rendrait toutes injoignables, comme il fermait déjà la TUI.
|
# Appliquer depuis là lève « KeyError: 'web_editor.assets' » — c'est
|
||||||
|
# arrivé sur une vraie migration, et l'échec est passé pour un
|
||||||
|
# succès. La correction est proposée après le palier, plus bas.
|
||||||
self.run_on_terminal(
|
self.run_on_terminal(
|
||||||
f"{PYTHON_BIN} ./script/odoo/migration/check_stale_scss.py"
|
f"{PYTHON_BIN} ./script/odoo/migration/check_stale_scss.py"
|
||||||
f" -d {database_name} -t odoo{start_version + 1}.0"
|
f" -d {database_name} -t odoo{start_version + 1}.0 --report-only"
|
||||||
)
|
)
|
||||||
|
|
||||||
msg = "3 - Clean up database before data migration"
|
msg = "3 - Clean up database before data migration"
|
||||||
|
|
@ -2507,6 +2509,17 @@ class TodoUpgrade:
|
||||||
)
|
)
|
||||||
self.write_config()
|
self.write_config()
|
||||||
|
|
||||||
|
# ICI seulement la correction est possible : le checkout est
|
||||||
|
# passé à la version cible, donc le shell sait faire
|
||||||
|
# reset_asset. Et c'est le dernier moment utile — la mise à
|
||||||
|
# jour des modules qui suit est ce qui compile les bundles,
|
||||||
|
# donc ce qui échoue si un SCSS figé reste en place.
|
||||||
|
self.run_on_terminal(
|
||||||
|
f"{PYTHON_BIN}"
|
||||||
|
" ./script/odoo/migration/check_stale_scss.py"
|
||||||
|
f" -d {database_name_upgrade} -t odoo{next_version}.0"
|
||||||
|
)
|
||||||
|
|
||||||
str_wait_next_version = (
|
str_wait_next_version = (
|
||||||
" (or wait next version 🤖)"
|
" (or wait next version 🤖)"
|
||||||
if next_version != lst_next_version[-1]
|
if next_version != lst_next_version[-1]
|
||||||
|
|
|
||||||
|
|
@ -303,6 +303,113 @@ class TestTheMigrationRunsIt(unittest.TestCase):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheFixCannotRunTooEarly(unittest.TestCase):
|
||||||
|
"""Corriger exige la version d'ARRIVÉE, pas celle de départ.
|
||||||
|
|
||||||
|
Mesuré sur une vraie migration : la question a été posée avant le palier,
|
||||||
|
alors que le checkout était encore sur odoo12.0. Répondre « a » a lancé
|
||||||
|
reset_asset dans un shell Odoo 12, qui ne connaît pas `web_editor.assets`
|
||||||
|
— KeyError, rien de modifié, et la migration a continué jusqu'à casser au
|
||||||
|
palier suivant.
|
||||||
|
|
||||||
|
Prédire tôt reste juste. C'est corriger tôt qui ne l'est pas.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
from script.todo import todo_i18n
|
||||||
|
|
||||||
|
self.addCleanup(
|
||||||
|
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
|
||||||
|
)
|
||||||
|
todo_i18n._current_lang = "en"
|
||||||
|
|
||||||
|
def test_a_version_without_reset_asset_is_refused(self):
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
root = tempfile.mkdtemp()
|
||||||
|
old = os.path.join(root, "odoo12.0", "addons", "web_editor", "models")
|
||||||
|
os.makedirs(old)
|
||||||
|
with open(os.path.join(old, "assets.py"), "w") as handle:
|
||||||
|
handle.write("class Assets:\n pass\n")
|
||||||
|
self.assertFalse(scss.reset_supported(os.path.join(root, "odoo12.0")))
|
||||||
|
|
||||||
|
def test_a_version_with_it_is_allowed(self):
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
root = tempfile.mkdtemp()
|
||||||
|
new = os.path.join(root, "odoo13.0", "addons", "web_editor", "models")
|
||||||
|
os.makedirs(new)
|
||||||
|
with open(os.path.join(new, "assets.py"), "w") as handle:
|
||||||
|
handle.write("def reset_asset(self, url, bundle):\n pass\n")
|
||||||
|
self.assertTrue(scss.reset_supported(os.path.join(root, "odoo13.0")))
|
||||||
|
|
||||||
|
def test_an_unknown_checkout_does_not_block(self):
|
||||||
|
# Rien pour trancher : refuser sur une supposition empêcherait de
|
||||||
|
# corriger là où c'est possible.
|
||||||
|
self.assertTrue(scss.reset_supported("odoo_no_such_dir_zz"))
|
||||||
|
|
||||||
|
def test_the_prompt_hides_the_fix_when_it_cannot_work(self):
|
||||||
|
# Offrir un choix qui échouera, c'est le faire prendre.
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
|
||||||
|
original = scss.reset_supported
|
||||||
|
scss.reset_supported = lambda odoo_dir=None: False
|
||||||
|
self.addCleanup(setattr, scss, "reset_supported", original)
|
||||||
|
finding = {
|
||||||
|
"id": 1,
|
||||||
|
"url": "/a/b.custom.web.assets_frontend.scss",
|
||||||
|
"missing": ["x"],
|
||||||
|
"custom": "a",
|
||||||
|
"base_url": "/a/b.scss",
|
||||||
|
"bundle": "web.assets_frontend",
|
||||||
|
"module_path": None,
|
||||||
|
"module_content": "",
|
||||||
|
"version_dir": "odoo13.0",
|
||||||
|
"database": "db",
|
||||||
|
}
|
||||||
|
out = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(out):
|
||||||
|
wrote = scss.prompt([finding], "db", ask=lambda p: "a")
|
||||||
|
self.assertFalse(wrote)
|
||||||
|
self.assertIn("KeyError", out.getvalue())
|
||||||
|
self.assertNotIn("a = reset", out.getvalue())
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheMigrationAsksAtTheRightMoment(unittest.TestCase):
|
||||||
|
"""Prédire avant le palier, corriger après — jamais l'inverse."""
|
||||||
|
|
||||||
|
def source(self):
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
from script.todo.todo_upgrade import TodoUpgrade
|
||||||
|
|
||||||
|
return inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
|
||||||
|
|
||||||
|
def test_the_early_call_cannot_offer_the_fix(self):
|
||||||
|
source = self.source()
|
||||||
|
premier = source.index("check_stale_scss.py")
|
||||||
|
fenetre = source[premier : premier + 400]
|
||||||
|
self.assertIn("--report-only", fenetre)
|
||||||
|
|
||||||
|
def test_a_second_call_comes_after_the_bump(self):
|
||||||
|
# Sans elle, la prédiction n'aurait jamais de suite : on saurait ce
|
||||||
|
# qui va casser sans jamais pouvoir le réparer.
|
||||||
|
source = self.source()
|
||||||
|
self.assertEqual(source.count("check_stale_scss.py"), 2)
|
||||||
|
second = source.rindex("check_stale_scss.py")
|
||||||
|
self.assertGreater(second, source.index("state_4_upgrade_odoo_lst"))
|
||||||
|
|
||||||
|
def test_the_second_call_targets_the_upgraded_database(self):
|
||||||
|
source = self.source()
|
||||||
|
second = source.rindex("check_stale_scss.py")
|
||||||
|
fenetre = source[second : second + 300]
|
||||||
|
self.assertIn("database_name_upgrade", fenetre)
|
||||||
|
self.assertNotIn("--report-only", fenetre)
|
||||||
|
|
||||||
|
|
||||||
class TestThePromptStaysOutOfAPipe(unittest.TestCase):
|
class TestThePromptStaysOutOfAPipe(unittest.TestCase):
|
||||||
def test_it_only_asks_in_front_of_a_terminal(self):
|
def test_it_only_asks_in_front_of_a_terminal(self):
|
||||||
# Une invite dans un tube bloquerait l'appelant sur une question que
|
# Une invite dans un tube bloquerait l'appelant sur une question que
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue