[ADD] addons: uninstall a theme the way Odoo removes one
There was no counterpart to install_addons_theme.sh, so themes were
removed with a plain --uninstall. That takes out the module, not the
theme: it skips _theme_remove(), whose first act is
_reset_default_config() — the call that writes font-number and its
three neighbours into user_values.scss.
Measured on a real 12 -> 13 migration: web.assets_frontend stopped on
« Undefined variable: $o-theme-font-number ». Odoo 12 defined it in
option_font_body_*, dropped in 13.0; only the theme still redefined
it, so removing the theme exposed a frozen 2020 customization.
theme_leftover.py then reports what unloading does not take — 15
attachments on the measured database. It deletes nothing: their
content may be the only trace of a customization.
--- FR ---
[ADD] addons : désinstaller un thème comme Odoo le retire
install_addons_theme.sh n'avait pas de symétrique : on retirait donc
les thèmes par un --uninstall nu. Cela enlève le module, pas le thème
— cela saute _theme_remove(), dont le premier geste est
_reset_default_config(), l'appel qui écrit font-number et ses trois
voisines dans user_values.scss.
Mesuré sur une vraie migration 12 → 13 : web.assets_frontend s'arrête
sur « Undefined variable: $o-theme-font-number ». Odoo 12 la
définissait dans option_font_body_*, supprimés en 13.0 ; seul le thème
la redéfinissait, et le retirer a mis à nu un SCSS figé en 2020.
theme_leftover.py signale ensuite ce que le déchargement ne prend pas
— 15 pièces jointes sur la base mesurée. Il ne supprime rien : leur
contenu peut être la seule trace d'une personnalisation.
Assisted-by: Claude Opus 5
(cherry picked from commit 16a7b1e1333752e2ef603d66f47374a22590d665)
2026-08-13 05:56:06 -04:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
|
|
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
|
|
|
|
|
|
"""Désinstaller un thème n'est pas désinstaller son module.
|
|
|
|
|
|
|
|
|
|
`--install-theme` appelle `button_choose_theme()`, qui fait deux choses :
|
|
|
|
|
copier les vues et ressources du thème dans chaque site, et écrire dans
|
|
|
|
|
`user_values.scss` une personnalisation qui DÉFINIT `$o-theme-font-number`
|
|
|
|
|
et ses trois voisines. Le chemin de retrait d'Odoo, `_theme_remove()`, défait
|
|
|
|
|
les deux — et son premier geste est `_reset_default_config()`, celui qui écrit
|
|
|
|
|
ces définitions.
|
|
|
|
|
|
|
|
|
|
Un `--uninstall` nu saute tout cela. Mesuré sur une migration réelle 12 → 13 :
|
|
|
|
|
le bundle `web.assets_frontend` s'arrête sur « Undefined variable:
|
|
|
|
|
$o-theme-font-number ». La variable venait des fichiers `option_font_body_*`
|
|
|
|
|
d'Odoo 12, supprimés en 13.0 ; seul le thème la redéfinissait encore, et le
|
|
|
|
|
retirer a mis à nu un SCSS personnalisé figé depuis 2020.
|
|
|
|
|
|
|
|
|
|
Ces tests portent sur ce que le script fait, pas sur son texte.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
import subprocess
|
|
|
|
|
import sys
|
|
|
|
|
import unittest
|
|
|
|
|
|
|
|
|
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
|
SCRIPT = os.path.join(REPO, "script", "addons", "uninstall_addons_theme.sh")
|
|
|
|
|
|
|
|
|
|
sys.path.insert(0, os.path.join(REPO, "script", "addons"))
|
|
|
|
|
import theme_leftover # noqa: E402
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTheScriptShape(unittest.TestCase):
|
|
|
|
|
def source(self):
|
|
|
|
|
with open(SCRIPT) as handle:
|
|
|
|
|
return handle.read()
|
|
|
|
|
|
|
|
|
|
def test_it_exists_and_is_executable(self):
|
|
|
|
|
self.assertTrue(os.access(SCRIPT, os.X_OK))
|
|
|
|
|
|
|
|
|
|
def test_it_parses(self):
|
|
|
|
|
done = subprocess.run(
|
|
|
|
|
["bash", "-n", SCRIPT], capture_output=True, text=True
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(done.returncode, 0, done.stderr)
|
|
|
|
|
|
|
|
|
|
def test_it_goes_through_theme_remove(self):
|
|
|
|
|
# LE point : sans cet appel, le script ne serait qu'un --uninstall
|
|
|
|
|
# sous un autre nom, et laisserait la même panne derrière lui.
|
|
|
|
|
self.assertIn("_theme_remove(website)", self.source())
|
|
|
|
|
|
|
|
|
|
def test_it_walks_every_website(self):
|
|
|
|
|
# Un site par thème : n'en traiter qu'un laisserait les autres avec
|
|
|
|
|
# des copies dont le module est parti.
|
|
|
|
|
source = self.source()
|
|
|
|
|
self.assertIn('env["website"].search([])', source)
|
|
|
|
|
|
|
|
|
|
def test_it_still_uninstalls_the_module(self):
|
|
|
|
|
self.assertIn("--uninstall", self.source())
|
|
|
|
|
|
|
|
|
|
def test_it_mirrors_the_installer_checks(self):
|
|
|
|
|
# Même garde-fou que install_addons_theme.sh : un nom de module
|
|
|
|
|
# inexistant doit s'arrêter avant de toucher la base.
|
|
|
|
|
self.assertIn("check_addons_exist.py", self.source())
|
|
|
|
|
|
|
|
|
|
def test_a_missing_argument_stops_before_anything(self):
|
|
|
|
|
done = subprocess.run(
|
|
|
|
|
["bash", SCRIPT, "onlydb"],
|
|
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
|
|
|
|
cwd=REPO,
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(done.returncode, 1)
|
|
|
|
|
self.assertIn("Usage", done.stdout + done.stderr)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTheLeftoverReport(unittest.TestCase):
|
|
|
|
|
"""Ce que le déchargement ne prend pas, et qu'il faut au moins savoir."""
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
# PAS set_lang() : il persiste la langue dans env_var.sh, suivi par
|
|
|
|
|
# git. On épingle la mémoïsation — sans quoi ces tests liraient la
|
|
|
|
|
# langue du poste, et passeraient ou non selon la machine.
|
|
|
|
|
from script.todo import todo_i18n
|
|
|
|
|
|
|
|
|
|
self.addCleanup(
|
|
|
|
|
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
|
|
|
|
|
)
|
|
|
|
|
todo_i18n._current_lang = "en"
|
|
|
|
|
|
|
|
|
|
def test_nothing_left_is_said_plainly(self):
|
|
|
|
|
text = theme_leftover.render("theme_x", [], [])
|
|
|
|
|
self.assertIn("✅", text)
|
|
|
|
|
|
|
|
|
|
def test_attachments_are_listed_with_their_date(self):
|
|
|
|
|
rows = ["4457|/theme_x/static/a.scss|2021-03-04"]
|
|
|
|
|
text = theme_leftover.render("theme_x", rows, [])
|
|
|
|
|
self.assertIn("4457", text)
|
|
|
|
|
self.assertIn("2021-03-04", text)
|
|
|
|
|
|
|
|
|
|
def test_a_long_list_says_how_many_it_hid(self):
|
|
|
|
|
# Tronquer sans le dire se lit comme « c'est tout ».
|
|
|
|
|
rows = [f"{i}|/theme_x/a{i}.scss|2021-01-01" for i in range(30)]
|
|
|
|
|
text = theme_leftover.render("theme_x", rows, [])
|
|
|
|
|
self.assertIn("10", text)
|
|
|
|
|
|
|
|
|
|
def test_it_never_offers_to_delete(self):
|
|
|
|
|
# Le contenu d'une pièce jointe peut être la seule trace d'une
|
|
|
|
|
# personnalisation : c'est une décision, pas un ménage.
|
|
|
|
|
text = theme_leftover.render("theme_x", ["1|/theme_x/a|d"], [])
|
|
|
|
|
self.assertIn("Nothing was deleted", text)
|
|
|
|
|
|
|
|
|
|
def test_the_sql_escapes_a_quote_in_the_theme_name(self):
|
|
|
|
|
self.assertEqual(theme_leftover.quote_literal("a'b"), "'a''b'")
|
|
|
|
|
|
|
|
|
|
def test_the_query_is_read_only_on_the_server_side(self):
|
|
|
|
|
# Pas une promesse de l'outil : PostgreSQL refuse l'écriture.
|
|
|
|
|
with open(theme_leftover.__file__) as handle:
|
|
|
|
|
source = handle.read()
|
|
|
|
|
self.assertIn("default_transaction_read_only=on", source)
|
|
|
|
|
|
|
|
|
|
|
[ADD] migration: offer to remove the themes before the first bump
A theme carries view copies and SCSS through every version bump, and a
bump can rename what they rely on. Removing it first drops a whole
family of failures, and it can be put back afterwards.
Asked in step 1, before OpenUpgrade runs, and only when a theme is
actually installed. Default is no: removing a theme changes how a site
looks, and a migration does not decide that for its owner.
theme_default is not offered — it IS the absence of a theme.
--- FR ---
[ADD] migration : proposer de retirer les thèmes avant le premier palier
Un thème traîne des copies de vues et des SCSS à travers chaque palier
de version, et un palier peut renommer ce dont ils dépendent. Le
retirer d'abord enlève une famille entière de pannes, et se refait
ensuite.
Posée à l'étape 1, avant OpenUpgrade, et seulement s'il y a vraiment un
thème installé. Par défaut non : retirer un thème change l'apparence
d'un site, et ce n'est pas à une migration de trancher cela à la place
de son propriétaire. theme_default n'est pas proposé — il EST
l'absence de thème.
Assisted-by: Claude Opus 5
(cherry picked from commit dcbee87a6b920408c315f7354a096b0ecfabcf06)
2026-08-15 02:36:28 -04:00
|
|
|
class TestTheMigrationOffersIt(unittest.TestCase):
|
|
|
|
|
"""La question doit venir AVANT le premier palier, et par défaut non.
|
|
|
|
|
|
|
|
|
|
Un thème installé traverse la migration : ses copies de vues et ses SCSS
|
|
|
|
|
suivent chaque palier, et chaque palier peut renommer ce dont ils
|
|
|
|
|
dépendent. Le proposer tôt retire d'un coup une famille de pannes.
|
|
|
|
|
|
|
|
|
|
Par défaut non : retirer un thème change l'apparence d'un site, et ce
|
|
|
|
|
n'est pas à une migration de trancher cela à la place de quelqu'un.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
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 upgrade(self, lst_theme, answer):
|
|
|
|
|
from script.todo.todo_upgrade import TodoUpgrade
|
|
|
|
|
|
|
|
|
|
obj = TodoUpgrade.__new__(TodoUpgrade)
|
|
|
|
|
obj.dct_progression = {}
|
|
|
|
|
obj.lst_command_executed = []
|
|
|
|
|
obj.write_config = lambda: None
|
|
|
|
|
obj.installed_theme = lambda db: lst_theme
|
|
|
|
|
self.lst_cmd = []
|
|
|
|
|
obj.todo_upgrade_execute = lambda cmd, **kw: (
|
|
|
|
|
self.lst_cmd.append(cmd),
|
|
|
|
|
(False, cmd, []),
|
|
|
|
|
)[1]
|
[FIX] migration: never ask a question the terminal cannot show
The theme uninstaller ends by asking what to do with the leftovers, and
the migration ran it through the capturing executor. Its stdout was a
pipe, Python buffers by blocks, so the prompt stayed invisible while the
process waited. It reads as a freeze: you press Enter blind, the first
keystroke answers unseen and the extra ones fall into the next question.
Two locks, because one is not enough. The migration runs the script on
the real terminal. And the three tools that ask now require BOTH ends:
something to read the answer from, and something to show the question
on. Guarding on stdin alone was the bug — measured, stdin tty=True with
stdout tty=False, and the question was asked into a pipe.
--- FR ---
[FIX] migration : ne jamais poser une question que le terminal ne montre pas
Le désinstalleur de thème finit par demander quoi faire des restes, et
la migration le lançait par l'exécuteur qui capture. Sa sortie partait
dans un tube, Python bufferise par blocs, et l'invite restait invisible
pendant l'attente. Cela se lit comme un blocage : on tape Entrée à
l'aveugle, la première frappe répond sans être vue et les suivantes
tombent dans la question d'après.
Deux verrous, car un seul ne suffit pas. La migration lance le script
sur le vrai terminal. Et les trois outils qui questionnent exigent
désormais les DEUX bouts : de quoi lire la réponse, et de quoi montrer
la question. Ne garder que stdin était le défaut — mesuré, stdin
tty=True et stdout tty=False, la question partait dans un tube.
Assisted-by: Claude Opus 5
2026-08-17 02:41:40 -04:00
|
|
|
# Le désinstalleur ne passe PLUS par l'exécuteur qui capture : il
|
|
|
|
|
# pose une question, et un tube la rendrait invisible.
|
[ADD] migration : garder la sortie des tests, par pseudo-terminal
Le journal d'étape notait la commande et son code de retour, jamais ce
qu'elle avait écrit : l'écran d'analyse ne pouvait rien montrer d'un
échec. Un tube aurait capturé et changé le programme — smoke_public_url
appelle can_ask(), qui exige stdin ET stdout sur un terminal, et derrière
un tube il cesse en silence d'offrir la réparation des vues COW. Un
pseudo-terminal lève le dilemme : l'enfant voit un vrai terminal, la
réponse tapée lui parvient, le code de retour survit. Neuf exécutions y
passent, dont check_hidden_models, qui tournait sans verdict retenu ;
deux restent dehors, pty.spawn naît en 0×0 et un plein écran s'y perdrait.
--- EN ---
The step log recorded the command and its exit code, never what it
wrote: the analysis screen could show nothing of a failure. A pipe would
have captured and changed the program — smoke_public_url calls can_ask(),
which requires stdin AND stdout to be terminals, and behind a pipe it
silently stops offering the COW view repair. A pty settles it: the child
sees a real terminal, a typed answer reaches it, the exit code survives.
Nine runs go through it, including check_hidden_models, which ran with no
verdict recorded; two stay out, pty.spawn starts at 0×0 and a full-screen
app would lay out on nothing.
Assisted-by: Claude Opus 5
(cherry picked from commit 81e9227502a0d16e6409338cf958495a5f8e3827)
2026-08-27 05:54:24 -04:00
|
|
|
obj.run_captured = lambda cmd: self.lst_cmd.append(cmd) or 0
|
[ADD] migration: make Enter mean the answer you always give
Auto-run promised to "take the default after five seconds", but every
default was EMPTY: it took nothing. Worse, half the prompts of a
migration are asked by separate processes — the theme uninstaller, the
stale-SCSS detector, the smoke test. They knew nothing of auto-run and
waited forever for a keystroke that never came.
The countdown now lives in one file and travels through the ENVIRONMENT,
the only channel a fork shares. Enter takes the default everywhere, not
only under auto-run: a prompt that prints (Y/n) and does otherwise is
worse than no prompt. Every text was rewritten to say what Enter does,
and every default kept an explicit way out.
Two defaults now write. Both are tenable only because the backup runs
first, and a test locks that ORDER rather than trusting a promise.
--- FR ---
[ADD] migration : faire d'Entrée la réponse qu'on donne toujours
L'auto-exécution promettait « le défaut après cinq secondes », mais tous
les défauts étaient VIDES : elle ne prenait rien. Pire, la moitié des
invites d'une migration sont posées par d'autres processus — le
désinstalleur de thème, le détecteur de SCSS figé, le test de fumée. Ils
ignoraient l'auto-exécution et attendaient sans fin une frappe.
Le compte à rebours tient désormais dans un seul fichier et voyage par
l'ENVIRONNEMENT, le seul canal qu'un fork partage. Entrée vaut le défaut
partout, pas seulement en auto : une invite qui affiche (Y/n) et fait
l'inverse est pire que pas d'invite. Chaque texte dit ce que fait Entrée,
et chaque défaut garde une issue explicite.
Deux défauts écrivent. Ils ne tiennent que parce que la sauvegarde passe
d'abord, et un test verrouille cet ORDRE plutôt qu'une promesse.
Assisted-by: Claude Opus 5
2026-08-17 20:47:17 -04:00
|
|
|
# Le doublon HONORE le défaut, comme le vrai `ask_gate` : sinon
|
|
|
|
|
# les tests de défaut ne testeraient que le doublon.
|
|
|
|
|
obj.ask_gate = lambda prompt, default="": answer or default
|
[ADD] migration: offer to remove the themes before the first bump
A theme carries view copies and SCSS through every version bump, and a
bump can rename what they rely on. Removing it first drops a whole
family of failures, and it can be put back afterwards.
Asked in step 1, before OpenUpgrade runs, and only when a theme is
actually installed. Default is no: removing a theme changes how a site
looks, and a migration does not decide that for its owner.
theme_default is not offered — it IS the absence of a theme.
--- FR ---
[ADD] migration : proposer de retirer les thèmes avant le premier palier
Un thème traîne des copies de vues et des SCSS à travers chaque palier
de version, et un palier peut renommer ce dont ils dépendent. Le
retirer d'abord enlève une famille entière de pannes, et se refait
ensuite.
Posée à l'étape 1, avant OpenUpgrade, et seulement s'il y a vraiment un
thème installé. Par défaut non : retirer un thème change l'apparence
d'un site, et ce n'est pas à une migration de trancher cela à la place
de son propriétaire. theme_default n'est pas proposé — il EST
l'absence de thème.
Assisted-by: Claude Opus 5
(cherry picked from commit dcbee87a6b920408c315f7354a096b0ecfabcf06)
2026-08-15 02:36:28 -04:00
|
|
|
return obj
|
|
|
|
|
|
|
|
|
|
def run_prompt(self, lst_theme, answer):
|
|
|
|
|
import contextlib
|
|
|
|
|
import io
|
|
|
|
|
|
|
|
|
|
obj = self.upgrade(lst_theme, answer)
|
|
|
|
|
out = io.StringIO()
|
|
|
|
|
with contextlib.redirect_stdout(out):
|
|
|
|
|
obj.prompt_uninstall_theme("db")
|
|
|
|
|
return self.lst_cmd, out.getvalue()
|
|
|
|
|
|
[ADD] migration: make Enter mean the answer you always give
Auto-run promised to "take the default after five seconds", but every
default was EMPTY: it took nothing. Worse, half the prompts of a
migration are asked by separate processes — the theme uninstaller, the
stale-SCSS detector, the smoke test. They knew nothing of auto-run and
waited forever for a keystroke that never came.
The countdown now lives in one file and travels through the ENVIRONMENT,
the only channel a fork shares. Enter takes the default everywhere, not
only under auto-run: a prompt that prints (Y/n) and does otherwise is
worse than no prompt. Every text was rewritten to say what Enter does,
and every default kept an explicit way out.
Two defaults now write. Both are tenable only because the backup runs
first, and a test locks that ORDER rather than trusting a promise.
--- FR ---
[ADD] migration : faire d'Entrée la réponse qu'on donne toujours
L'auto-exécution promettait « le défaut après cinq secondes », mais tous
les défauts étaient VIDES : elle ne prenait rien. Pire, la moitié des
invites d'une migration sont posées par d'autres processus — le
désinstalleur de thème, le détecteur de SCSS figé, le test de fumée. Ils
ignoraient l'auto-exécution et attendaient sans fin une frappe.
Le compte à rebours tient désormais dans un seul fichier et voyage par
l'ENVIRONNEMENT, le seul canal qu'un fork partage. Entrée vaut le défaut
partout, pas seulement en auto : une invite qui affiche (Y/n) et fait
l'inverse est pire que pas d'invite. Chaque texte dit ce que fait Entrée,
et chaque défaut garde une issue explicite.
Deux défauts écrivent. Ils ne tiennent que parce que la sauvegarde passe
d'abord, et un test verrouille cet ORDRE plutôt qu'une promesse.
Assisted-by: Claude Opus 5
2026-08-17 20:47:17 -04:00
|
|
|
def test_the_default_answer_uninstalls_them(self):
|
|
|
|
|
# « Entrée » désinstalle : c'est la réponse qu'on donnait à chaque
|
|
|
|
|
# migration, et un thème traversé sans être retiré est justement ce
|
|
|
|
|
# qui casse au palier suivant.
|
|
|
|
|
lst_cmd, _text = self.run_prompt(["theme_technolibre"], "")
|
|
|
|
|
self.assertEqual(len(lst_cmd), 1)
|
|
|
|
|
self.assertIn("uninstall_addons_theme.sh", lst_cmd[0])
|
|
|
|
|
|
|
|
|
|
def test_saying_no_still_keeps_them(self):
|
|
|
|
|
# Le défaut ne doit pas retirer le choix : il ne fait qu'en proposer
|
|
|
|
|
# un. Sans cette issue, la question ne serait plus une question.
|
|
|
|
|
lst_cmd, text = self.run_prompt(["theme_technolibre"], "n")
|
[ADD] migration: offer to remove the themes before the first bump
A theme carries view copies and SCSS through every version bump, and a
bump can rename what they rely on. Removing it first drops a whole
family of failures, and it can be put back afterwards.
Asked in step 1, before OpenUpgrade runs, and only when a theme is
actually installed. Default is no: removing a theme changes how a site
looks, and a migration does not decide that for its owner.
theme_default is not offered — it IS the absence of a theme.
--- FR ---
[ADD] migration : proposer de retirer les thèmes avant le premier palier
Un thème traîne des copies de vues et des SCSS à travers chaque palier
de version, et un palier peut renommer ce dont ils dépendent. Le
retirer d'abord enlève une famille entière de pannes, et se refait
ensuite.
Posée à l'étape 1, avant OpenUpgrade, et seulement s'il y a vraiment un
thème installé. Par défaut non : retirer un thème change l'apparence
d'un site, et ce n'est pas à une migration de trancher cela à la place
de son propriétaire. theme_default n'est pas proposé — il EST
l'absence de thème.
Assisted-by: Claude Opus 5
(cherry picked from commit dcbee87a6b920408c315f7354a096b0ecfabcf06)
2026-08-15 02:36:28 -04:00
|
|
|
self.assertEqual(lst_cmd, [])
|
|
|
|
|
self.assertIn("Kept", text)
|
|
|
|
|
|
[ADD] migration: make Enter mean the answer you always give
Auto-run promised to "take the default after five seconds", but every
default was EMPTY: it took nothing. Worse, half the prompts of a
migration are asked by separate processes — the theme uninstaller, the
stale-SCSS detector, the smoke test. They knew nothing of auto-run and
waited forever for a keystroke that never came.
The countdown now lives in one file and travels through the ENVIRONMENT,
the only channel a fork shares. Enter takes the default everywhere, not
only under auto-run: a prompt that prints (Y/n) and does otherwise is
worse than no prompt. Every text was rewritten to say what Enter does,
and every default kept an explicit way out.
Two defaults now write. Both are tenable only because the backup runs
first, and a test locks that ORDER rather than trusting a promise.
--- FR ---
[ADD] migration : faire d'Entrée la réponse qu'on donne toujours
L'auto-exécution promettait « le défaut après cinq secondes », mais tous
les défauts étaient VIDES : elle ne prenait rien. Pire, la moitié des
invites d'une migration sont posées par d'autres processus — le
désinstalleur de thème, le détecteur de SCSS figé, le test de fumée. Ils
ignoraient l'auto-exécution et attendaient sans fin une frappe.
Le compte à rebours tient désormais dans un seul fichier et voyage par
l'ENVIRONNEMENT, le seul canal qu'un fork partage. Entrée vaut le défaut
partout, pas seulement en auto : une invite qui affiche (Y/n) et fait
l'inverse est pire que pas d'invite. Chaque texte dit ce que fait Entrée,
et chaque défaut garde une issue explicite.
Deux défauts écrivent. Ils ne tiennent que parce que la sauvegarde passe
d'abord, et un test verrouille cet ORDRE plutôt qu'une promesse.
Assisted-by: Claude Opus 5
2026-08-17 20:47:17 -04:00
|
|
|
def test_the_question_says_that_enter_uninstalls(self):
|
|
|
|
|
# Une invite qui annonce « y/N » et fait l'inverse est pire que pas
|
|
|
|
|
# d'invite du tout.
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
from script.todo.todo_upgrade import TodoUpgrade
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(TodoUpgrade.prompt_uninstall_theme)
|
|
|
|
|
self.assertIn("(Y/n,", source)
|
|
|
|
|
self.assertNotIn("(y/N,", source)
|
|
|
|
|
|
[ADD] migration: offer to remove the themes before the first bump
A theme carries view copies and SCSS through every version bump, and a
bump can rename what they rely on. Removing it first drops a whole
family of failures, and it can be put back afterwards.
Asked in step 1, before OpenUpgrade runs, and only when a theme is
actually installed. Default is no: removing a theme changes how a site
looks, and a migration does not decide that for its owner.
theme_default is not offered — it IS the absence of a theme.
--- FR ---
[ADD] migration : proposer de retirer les thèmes avant le premier palier
Un thème traîne des copies de vues et des SCSS à travers chaque palier
de version, et un palier peut renommer ce dont ils dépendent. Le
retirer d'abord enlève une famille entière de pannes, et se refait
ensuite.
Posée à l'étape 1, avant OpenUpgrade, et seulement s'il y a vraiment un
thème installé. Par défaut non : retirer un thème change l'apparence
d'un site, et ce n'est pas à une migration de trancher cela à la place
de son propriétaire. theme_default n'est pas proposé — il EST
l'absence de thème.
Assisted-by: Claude Opus 5
(cherry picked from commit dcbee87a6b920408c315f7354a096b0ecfabcf06)
2026-08-15 02:36:28 -04:00
|
|
|
def test_no_theme_means_no_question(self):
|
|
|
|
|
lst_cmd, text = self.run_prompt([], "y")
|
|
|
|
|
self.assertEqual(lst_cmd, [])
|
|
|
|
|
self.assertEqual(text, "")
|
|
|
|
|
|
|
|
|
|
def test_yes_runs_the_proper_uninstaller(self):
|
|
|
|
|
lst_cmd, _ = self.run_prompt(["theme_technolibre"], "y")
|
|
|
|
|
self.assertEqual(len(lst_cmd), 1)
|
|
|
|
|
self.assertIn(
|
|
|
|
|
"uninstall_addons_theme.sh db theme_technolibre", lst_cmd[0]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_every_theme_is_offered_together(self):
|
|
|
|
|
lst_cmd, _ = self.run_prompt(["theme_a", "theme_b"], "Y")
|
|
|
|
|
self.assertEqual(len(lst_cmd), 2)
|
|
|
|
|
|
|
|
|
|
def test_it_is_asked_before_the_first_bump(self):
|
|
|
|
|
# L'ordre est le point : posée après le premier palier, la question
|
|
|
|
|
# arrive quand les copies ont déjà traversé un renommage.
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
from script.todo.todo_upgrade import TodoUpgrade
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
|
|
|
|
|
appel = source.index("prompt_uninstall_theme")
|
|
|
|
|
palier = source.index("4 - Upgrade version with OpenUpgrade")
|
|
|
|
|
self.assertLess(appel, palier)
|
|
|
|
|
|
|
|
|
|
def test_theme_default_is_not_a_theme_to_remove(self):
|
|
|
|
|
# theme_default EST l'absence de thème : le proposer au retrait
|
|
|
|
|
# ferait poser une question sans objet à chaque migration.
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
from script.todo.todo_upgrade import TodoUpgrade
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(TodoUpgrade.installed_theme)
|
|
|
|
|
self.assertIn("name <> 'theme_default'", source)
|
|
|
|
|
|
|
|
|
|
|
[FIX] addons: stop calling a leftover report a failed command
« Command returned error code: 1 » followed a theme that was removed
correctly. 1 is this toolkit's « there are findings », but the report
was the uninstaller's last command, so its code became the script's;
and the COW check was still read through the capturing executor, which
announces any non-zero code as an error.
The leftovers can now be dealt with instead of only listed: keep by
default, or delete after their content is written to private/. Listing
fifteen attachments and stopping there meant composing an unlink() by
hand, mid-migration, from identifiers read off a screen.
--- FR ---
[FIX] addons : cesser d'appeler « erreur » un rapport de restes
« Command returned error code: 1 » suivait un thème correctement
retiré. 1 veut dire « il y a des constats » dans cet outillage, mais le
rapport était la dernière commande du désinstalleur, donc son code
devenait celui du script ; et la vérification COW passait encore par
l'exécuteur qui capture, lequel annonce tout code non nul comme une
erreur.
Les restes peuvent désormais être traités, pas seulement listés :
garder par défaut, ou effacer après écriture de leur contenu sous
private/. Lister quinze pièces jointes et s'arrêter là revenait à faire
composer un unlink() à la main, en pleine migration.
Assisted-by: Claude Opus 5
(cherry picked from commit 304ce32b2680e3d01e38c3224bb85c2798cd6f53)
2026-08-16 05:33:28 -04:00
|
|
|
class TestKeepOrDeleteTheLeftovers(unittest.TestCase):
|
|
|
|
|
"""Signaler sans offrir le geste oblige à le composer soi-même.
|
|
|
|
|
|
|
|
|
|
Le rapport listait quinze pièces jointes et s'arrêtait là. Les effacer
|
|
|
|
|
demandait de retrouver les identifiants et d'écrire un unlink() à la
|
|
|
|
|
main — au milieu d'une migration, c'est ce qu'on ne fait pas.
|
|
|
|
|
|
|
|
|
|
« Garder » reste le défaut, et rien n'est effacé sans avoir été écrit sur
|
|
|
|
|
disque d'abord : c'est la condition pour pouvoir répondre « efface ».
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
self.rows = ["4457|/theme_x/static/a.scss|2021-03-04"]
|
|
|
|
|
self.saved = []
|
|
|
|
|
self.deleted = []
|
|
|
|
|
self.original_backup = theme_leftover.backup_attachments
|
|
|
|
|
self.original_delete = theme_leftover.delete_attachments
|
[REF] format : passer l'outillage et les tests sous ruff
Le formateur de ce dépôt est ruff depuis qu'il remplace black, qui ne connaît
aucune cible au-delà de py313 ; ce passage applique sa norme à l'arbre entier,
d'un coup, pour qu'aucun commit de fond n'ait à porter du style. L'écart tient
presque entièrement aux chaînes coupées à la main que ruff recolle quand elles
tiennent sur une ligne, et aux « with » multiples qu'il regroupe : aucune
valeur ne change, et les clés de traduction non plus.
Vérifié : la suite unitaire reste verte après le passage, et le contrôle de
syntaxe ne signale rien.
--- EN ---
This repository's formatter is ruff since it replaced black, which knows no
target beyond py313; this pass applies its standard to the whole tree at once,
so that no substantive commit has to carry style. The difference is almost
entirely the hand-split strings ruff joins back when they fit on one line, and
the multiple "with" it merges: no value changes, nor do the translation keys.
Checked: the unit suite stays green after the pass, and the syntax check
reports nothing.
Assisted-by: Claude Opus 5
2026-09-24 13:30:31 -04:00
|
|
|
theme_leftover.backup_attachments = lambda db, th, rows, fs=None: (
|
|
|
|
|
self.saved.append(rows) or ["/tmp/x"]
|
[FIX] addons: stop calling a leftover report a failed command
« Command returned error code: 1 » followed a theme that was removed
correctly. 1 is this toolkit's « there are findings », but the report
was the uninstaller's last command, so its code became the script's;
and the COW check was still read through the capturing executor, which
announces any non-zero code as an error.
The leftovers can now be dealt with instead of only listed: keep by
default, or delete after their content is written to private/. Listing
fifteen attachments and stopping there meant composing an unlink() by
hand, mid-migration, from identifiers read off a screen.
--- FR ---
[FIX] addons : cesser d'appeler « erreur » un rapport de restes
« Command returned error code: 1 » suivait un thème correctement
retiré. 1 veut dire « il y a des constats » dans cet outillage, mais le
rapport était la dernière commande du désinstalleur, donc son code
devenait celui du script ; et la vérification COW passait encore par
l'exécuteur qui capture, lequel annonce tout code non nul comme une
erreur.
Les restes peuvent désormais être traités, pas seulement listés :
garder par défaut, ou effacer après écriture de leur contenu sous
private/. Lister quinze pièces jointes et s'arrêter là revenait à faire
composer un unlink() à la main, en pleine migration.
Assisted-by: Claude Opus 5
(cherry picked from commit 304ce32b2680e3d01e38c3224bb85c2798cd6f53)
2026-08-16 05:33:28 -04:00
|
|
|
)
|
|
|
|
|
theme_leftover.delete_attachments = (
|
|
|
|
|
lambda db, rows, cfg="./config.conf": (
|
|
|
|
|
self.deleted.append(rows),
|
|
|
|
|
(0, "ok"),
|
|
|
|
|
)[1]
|
|
|
|
|
)
|
|
|
|
|
self.addCleanup(
|
|
|
|
|
setattr,
|
|
|
|
|
theme_leftover,
|
|
|
|
|
"backup_attachments",
|
|
|
|
|
self.original_backup,
|
|
|
|
|
)
|
|
|
|
|
self.addCleanup(
|
|
|
|
|
setattr, theme_leftover, "delete_attachments", self.original_delete
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def run_prompt(self, answer, rows=None):
|
|
|
|
|
import contextlib
|
|
|
|
|
import io
|
|
|
|
|
|
|
|
|
|
out = io.StringIO()
|
|
|
|
|
with contextlib.redirect_stdout(out):
|
|
|
|
|
done = theme_leftover.prompt(
|
|
|
|
|
"db",
|
|
|
|
|
"theme_x",
|
|
|
|
|
self.rows if rows is None else rows,
|
|
|
|
|
[],
|
|
|
|
|
"./config.conf",
|
|
|
|
|
ask=lambda prompt: answer,
|
|
|
|
|
)
|
|
|
|
|
return done, out.getvalue()
|
|
|
|
|
|
|
|
|
|
def test_enter_keeps_them(self):
|
|
|
|
|
done, text = self.run_prompt("")
|
|
|
|
|
self.assertFalse(done)
|
|
|
|
|
self.assertEqual(self.deleted, [])
|
|
|
|
|
self.assertIn("Kept", text)
|
|
|
|
|
|
|
|
|
|
def test_d_saves_before_deleting(self):
|
|
|
|
|
# L'ORDRE est le point : effacer d'abord rendrait la sauvegarde vide.
|
|
|
|
|
done, _ = self.run_prompt("d")
|
|
|
|
|
self.assertTrue(done)
|
|
|
|
|
self.assertEqual(len(self.saved), 1)
|
|
|
|
|
self.assertEqual(len(self.deleted), 1)
|
|
|
|
|
|
|
|
|
|
def test_a_failed_deletion_says_nothing_was_removed(self):
|
|
|
|
|
theme_leftover.delete_attachments = (
|
|
|
|
|
lambda db, rows, cfg="./config.conf": (1, "boom")
|
|
|
|
|
)
|
|
|
|
|
done, text = self.run_prompt("d")
|
|
|
|
|
self.assertFalse(done)
|
|
|
|
|
self.assertIn("nothing was removed", text)
|
|
|
|
|
|
|
|
|
|
def test_no_leftover_asks_nothing(self):
|
|
|
|
|
done, text = self.run_prompt("d", rows=[])
|
|
|
|
|
self.assertFalse(done)
|
|
|
|
|
self.assertEqual(text, "")
|
|
|
|
|
|
|
|
|
|
def test_the_prompt_stays_out_of_a_pipe(self):
|
|
|
|
|
with open(theme_leftover.__file__) as handle:
|
|
|
|
|
self.assertIn("sys.stdin.isatty()", handle.read())
|
|
|
|
|
|
|
|
|
|
|
[FIX] migration: never ask a question the terminal cannot show
The theme uninstaller ends by asking what to do with the leftovers, and
the migration ran it through the capturing executor. Its stdout was a
pipe, Python buffers by blocks, so the prompt stayed invisible while the
process waited. It reads as a freeze: you press Enter blind, the first
keystroke answers unseen and the extra ones fall into the next question.
Two locks, because one is not enough. The migration runs the script on
the real terminal. And the three tools that ask now require BOTH ends:
something to read the answer from, and something to show the question
on. Guarding on stdin alone was the bug — measured, stdin tty=True with
stdout tty=False, and the question was asked into a pipe.
--- FR ---
[FIX] migration : ne jamais poser une question que le terminal ne montre pas
Le désinstalleur de thème finit par demander quoi faire des restes, et
la migration le lançait par l'exécuteur qui capture. Sa sortie partait
dans un tube, Python bufferise par blocs, et l'invite restait invisible
pendant l'attente. Cela se lit comme un blocage : on tape Entrée à
l'aveugle, la première frappe répond sans être vue et les suivantes
tombent dans la question d'après.
Deux verrous, car un seul ne suffit pas. La migration lance le script
sur le vrai terminal. Et les trois outils qui questionnent exigent
désormais les DEUX bouts : de quoi lire la réponse, et de quoi montrer
la question. Ne garder que stdin était le défaut — mesuré, stdin
tty=True et stdout tty=False, la question partait dans un tube.
Assisted-by: Claude Opus 5
2026-08-17 02:41:40 -04:00
|
|
|
class TestTheQuestionMustBeVisible(unittest.TestCase):
|
|
|
|
|
"""Une invite qu'on ne voit pas est une invite à laquelle on répond mal.
|
|
|
|
|
|
|
|
|
|
Le script se termine par theme_leftover.py, qui pose une question. Lancé
|
|
|
|
|
par l'exécuteur qui CAPTURE la sortie, son stdout est un tube : Python
|
|
|
|
|
bufferise par blocs et l'invite reste invisible pendant que le processus
|
|
|
|
|
attend. Vécu — on croit à un blocage, on tape Entrée plusieurs fois, la
|
|
|
|
|
première frappe répond à l'aveugle et les suivantes vont à la question
|
|
|
|
|
d'après.
|
|
|
|
|
|
|
|
|
|
Deux verrous, car un seul ne suffit pas : la migration lance ce script
|
|
|
|
|
sur le vrai terminal, ET l'outil refuse de questionner s'il ne peut pas
|
|
|
|
|
se faire voir.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def test_the_migration_runs_it_on_the_real_terminal(self):
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
from script.todo.todo_upgrade import TodoUpgrade
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(TodoUpgrade.prompt_uninstall_theme)
|
[ADD] migration : garder la sortie des tests, par pseudo-terminal
Le journal d'étape notait la commande et son code de retour, jamais ce
qu'elle avait écrit : l'écran d'analyse ne pouvait rien montrer d'un
échec. Un tube aurait capturé et changé le programme — smoke_public_url
appelle can_ask(), qui exige stdin ET stdout sur un terminal, et derrière
un tube il cesse en silence d'offrir la réparation des vues COW. Un
pseudo-terminal lève le dilemme : l'enfant voit un vrai terminal, la
réponse tapée lui parvient, le code de retour survit. Neuf exécutions y
passent, dont check_hidden_models, qui tournait sans verdict retenu ;
deux restent dehors, pty.spawn naît en 0×0 et un plein écran s'y perdrait.
--- EN ---
The step log recorded the command and its exit code, never what it
wrote: the analysis screen could show nothing of a failure. A pipe would
have captured and changed the program — smoke_public_url calls can_ask(),
which requires stdin AND stdout to be terminals, and behind a pipe it
silently stops offering the COW view repair. A pty settles it: the child
sees a real terminal, a typed answer reaches it, the exit code survives.
Nine runs go through it, including check_hidden_models, which ran with no
verdict recorded; two stay out, pty.spawn starts at 0×0 and a full-screen
app would lay out on nothing.
Assisted-by: Claude Opus 5
(cherry picked from commit 81e9227502a0d16e6409338cf958495a5f8e3827)
2026-08-27 05:54:24 -04:00
|
|
|
# Un pseudo-terminal convient : l'invite reste visible, et la
|
|
|
|
|
# sortie est gardée pour l'écran d'analyse. Seul le tube de
|
|
|
|
|
# `todo_upgrade_execute` la rendrait muette.
|
|
|
|
|
self.assertIn("run_captured", source)
|
[FIX] migration: never ask a question the terminal cannot show
The theme uninstaller ends by asking what to do with the leftovers, and
the migration ran it through the capturing executor. Its stdout was a
pipe, Python buffers by blocks, so the prompt stayed invisible while the
process waited. It reads as a freeze: you press Enter blind, the first
keystroke answers unseen and the extra ones fall into the next question.
Two locks, because one is not enough. The migration runs the script on
the real terminal. And the three tools that ask now require BOTH ends:
something to read the answer from, and something to show the question
on. Guarding on stdin alone was the bug — measured, stdin tty=True with
stdout tty=False, and the question was asked into a pipe.
--- FR ---
[FIX] migration : ne jamais poser une question que le terminal ne montre pas
Le désinstalleur de thème finit par demander quoi faire des restes, et
la migration le lançait par l'exécuteur qui capture. Sa sortie partait
dans un tube, Python bufferise par blocs, et l'invite restait invisible
pendant l'attente. Cela se lit comme un blocage : on tape Entrée à
l'aveugle, la première frappe répond sans être vue et les suivantes
tombent dans la question d'après.
Deux verrous, car un seul ne suffit pas. La migration lance le script
sur le vrai terminal. Et les trois outils qui questionnent exigent
désormais les DEUX bouts : de quoi lire la réponse, et de quoi montrer
la question. Ne garder que stdin était le défaut — mesuré, stdin
tty=True et stdout tty=False, la question partait dans un tube.
Assisted-by: Claude Opus 5
2026-08-17 02:41:40 -04:00
|
|
|
self.assertNotIn("todo_upgrade_execute", source)
|
|
|
|
|
|
|
|
|
|
def test_asking_needs_stdout_not_just_stdin(self):
|
|
|
|
|
# LE défaut : ne tester que stdin laisse poser une question dans un
|
|
|
|
|
# tube. Il faut de quoi LIRE la réponse ET MONTRER la question.
|
|
|
|
|
import io
|
|
|
|
|
import sys
|
|
|
|
|
|
|
|
|
|
class Fake(io.StringIO):
|
|
|
|
|
def __init__(self, tty):
|
|
|
|
|
super().__init__()
|
|
|
|
|
self.tty = tty
|
|
|
|
|
|
|
|
|
|
def isatty(self):
|
|
|
|
|
return self.tty
|
|
|
|
|
|
|
|
|
|
real_in, real_out = sys.stdin, sys.stdout
|
|
|
|
|
self.addCleanup(setattr, sys, "stdin", real_in)
|
|
|
|
|
self.addCleanup(setattr, sys, "stdout", real_out)
|
|
|
|
|
for stdin_tty, stdout_tty, expected in (
|
|
|
|
|
(True, True, True),
|
|
|
|
|
(True, False, False), # le cas mesuré
|
|
|
|
|
(False, True, False),
|
|
|
|
|
(False, False, False),
|
|
|
|
|
):
|
|
|
|
|
sys.stdin, sys.stdout = Fake(stdin_tty), Fake(stdout_tty)
|
|
|
|
|
got = theme_leftover.can_ask()
|
|
|
|
|
sys.stdin, sys.stdout = real_in, real_out
|
|
|
|
|
with self.subTest(stdin=stdin_tty, stdout=stdout_tty):
|
|
|
|
|
self.assertEqual(got, expected)
|
|
|
|
|
|
|
|
|
|
def test_the_other_tools_guard_the_same_way(self):
|
|
|
|
|
# Trois outils posent des questions ; un seul corrigé laisserait le
|
|
|
|
|
# même piège ailleurs.
|
|
|
|
|
import os
|
|
|
|
|
|
|
|
|
|
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
|
for relative in (
|
|
|
|
|
"script/odoo/migration/smoke_public_url.py",
|
|
|
|
|
"script/odoo/migration/check_stale_scss.py",
|
|
|
|
|
"script/addons/theme_leftover.py",
|
|
|
|
|
):
|
|
|
|
|
with open(os.path.join(REPO_ROOT, relative)) as handle:
|
|
|
|
|
source = handle.read()
|
|
|
|
|
with self.subTest(tool=relative):
|
|
|
|
|
self.assertIn("def can_ask()", source)
|
|
|
|
|
self.assertIn("sys.stdout.isatty()", source)
|
|
|
|
|
|
|
|
|
|
|
[FIX] addons: browse the leftovers by integer, not by the string psql gave
Deleting the leftovers failed on « the database search does not have the
ids (('4457',)) and has extra ids ((4457,)) ». psql returns text; the ids
went into browse() as strings, Odoo compared them against integers, found
nothing and refused the whole batch.
Nothing was lost: the backup runs before the deletion, and the tool said
plainly that nothing was removed. Measured after the fact — 15 files
saved, 15 attachments still in database.
--- FR ---
[FIX] addons : parcourir les restes par entier, pas par la chaîne de psql
L'effacement échouait sur « la recherche en base n'a pas les identifiants
(('4457',)) et a des identifiants supplémentaires ((4457,)) ». psql rend
du texte ; les identifiants partaient dans browse() en chaînes, Odoo les
comparait à des entiers, ne trouvait rien et refusait tout le lot.
Rien n'a été perdu : la sauvegarde précède l'effacement, et l'outil a dit
franchement que rien n'avait été retiré. Vérifié après coup — 15 fichiers
sauvegardés, 15 pièces jointes toujours en base.
Assisted-by: Claude Opus 5
2026-08-17 03:17:18 -04:00
|
|
|
class TestTheIdentifiersSentToOdoo(unittest.TestCase):
|
|
|
|
|
"""browse() veut des ENTIERS ; psql rend des chaînes.
|
|
|
|
|
|
|
|
|
|
Mesuré sur une vraie base : browse(['4457']) fait échouer Odoo sur
|
|
|
|
|
« la recherche en base n'a pas les identifiants (('4457',)) et a des
|
|
|
|
|
identifiants supplémentaires ((4457,)) ». Il compare des chaînes à des
|
|
|
|
|
entiers, ne retrouve rien, et refuse. L'effacement n'a rien retiré —
|
|
|
|
|
heureusement, la sauvegarde était déjà faite.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def test_the_script_browses_integers(self):
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(theme_leftover.delete_attachments)
|
|
|
|
|
self.assertIn("int(row.split", source)
|
|
|
|
|
|
|
|
|
|
def test_the_pushed_script_carries_no_quoted_id(self):
|
|
|
|
|
# Le rendu exact de ce qui part dans le shell : une seule apostrophe
|
|
|
|
|
# autour d'un identifiant et Odoo refuse tout le lot.
|
|
|
|
|
pushed = {}
|
|
|
|
|
original = theme_leftover.subprocess.run
|
[REF] format : passer l'outillage et les tests sous ruff
Le formateur de ce dépôt est ruff depuis qu'il remplace black, qui ne connaît
aucune cible au-delà de py313 ; ce passage applique sa norme à l'arbre entier,
d'un coup, pour qu'aucun commit de fond n'ait à porter du style. L'écart tient
presque entièrement aux chaînes coupées à la main que ruff recolle quand elles
tiennent sur une ligne, et aux « with » multiples qu'il regroupe : aucune
valeur ne change, et les clés de traduction non plus.
Vérifié : la suite unitaire reste verte après le passage, et le contrôle de
syntaxe ne signale rien.
--- EN ---
This repository's formatter is ruff since it replaced black, which knows no
target beyond py313; this pass applies its standard to the whole tree at once,
so that no substantive commit has to carry style. The difference is almost
entirely the hand-split strings ruff joins back when they fit on one line, and
the multiple "with" it merges: no value changes, nor do the translation keys.
Checked: the unit suite stays green after the pass, and the syntax check
reports nothing.
Assisted-by: Claude Opus 5
2026-09-24 13:30:31 -04:00
|
|
|
theme_leftover.subprocess.run = lambda *a, **kw: (
|
|
|
|
|
pushed.update(script=kw.get("input", ""))
|
[FIX] addons: browse the leftovers by integer, not by the string psql gave
Deleting the leftovers failed on « the database search does not have the
ids (('4457',)) and has extra ids ((4457,)) ». psql returns text; the ids
went into browse() as strings, Odoo compared them against integers, found
nothing and refused the whole batch.
Nothing was lost: the backup runs before the deletion, and the tool said
plainly that nothing was removed. Measured after the fact — 15 files
saved, 15 attachments still in database.
--- FR ---
[FIX] addons : parcourir les restes par entier, pas par la chaîne de psql
L'effacement échouait sur « la recherche en base n'a pas les identifiants
(('4457',)) et a des identifiants supplémentaires ((4457,)) ». psql rend
du texte ; les identifiants partaient dans browse() en chaînes, Odoo les
comparait à des entiers, ne trouvait rien et refusait tout le lot.
Rien n'a été perdu : la sauvegarde précède l'effacement, et l'outil a dit
franchement que rien n'avait été retiré. Vérifié après coup — 15 fichiers
sauvegardés, 15 pièces jointes toujours en base.
Assisted-by: Claude Opus 5
2026-08-17 03:17:18 -04:00
|
|
|
or type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})()
|
|
|
|
|
)
|
|
|
|
|
self.addCleanup(setattr, theme_leftover.subprocess, "run", original)
|
|
|
|
|
theme_leftover.delete_attachments(
|
|
|
|
|
"db", ["4457|/theme_x/a.scss|2021-03-04", "4768|/b.css|d"]
|
|
|
|
|
)
|
|
|
|
|
self.assertIn("browse([4457, 4768])", pushed["script"])
|
|
|
|
|
self.assertNotIn("'4457'", pushed["script"])
|
|
|
|
|
|
|
|
|
|
def test_a_non_numeric_row_is_not_sent_silently(self):
|
|
|
|
|
# Mieux vaut échouer ici que pousser un script qu'Odoo refusera.
|
|
|
|
|
with self.assertRaises(ValueError):
|
|
|
|
|
theme_leftover.delete_attachments("db", ["zz|/a|d"])
|
|
|
|
|
|
|
|
|
|
|
[FIX] addons: stop calling a leftover report a failed command
« Command returned error code: 1 » followed a theme that was removed
correctly. 1 is this toolkit's « there are findings », but the report
was the uninstaller's last command, so its code became the script's;
and the COW check was still read through the capturing executor, which
announces any non-zero code as an error.
The leftovers can now be dealt with instead of only listed: keep by
default, or delete after their content is written to private/. Listing
fifteen attachments and stopping there meant composing an unlink() by
hand, mid-migration, from identifiers read off a screen.
--- FR ---
[FIX] addons : cesser d'appeler « erreur » un rapport de restes
« Command returned error code: 1 » suivait un thème correctement
retiré. 1 veut dire « il y a des constats » dans cet outillage, mais le
rapport était la dernière commande du désinstalleur, donc son code
devenait celui du script ; et la vérification COW passait encore par
l'exécuteur qui capture, lequel annonce tout code non nul comme une
erreur.
Les restes peuvent désormais être traités, pas seulement listés :
garder par défaut, ou effacer après écriture de leur contenu sous
private/. Lister quinze pièces jointes et s'arrêter là revenait à faire
composer un unlink() à la main, en pleine migration.
Assisted-by: Claude Opus 5
(cherry picked from commit 304ce32b2680e3d01e38c3224bb85c2798cd6f53)
2026-08-16 05:33:28 -04:00
|
|
|
class TestTheMisleadingErrorCode(unittest.TestCase):
|
|
|
|
|
"""« 1 » veut dire « il reste des choses », pas « ça a raté »."""
|
|
|
|
|
|
|
|
|
|
def test_the_uninstaller_does_not_fail_on_leftovers(self):
|
|
|
|
|
# Le rapport était la dernière commande du script : son code
|
|
|
|
|
# devenait celui du script, et la migration annonçait une erreur sur
|
|
|
|
|
# un thème correctement retiré.
|
|
|
|
|
with open(SCRIPT) as handle:
|
|
|
|
|
source = handle.read()
|
|
|
|
|
self.assertIn("theme_leftover.py", source)
|
|
|
|
|
queue = source[source.index("theme_leftover.py") :]
|
|
|
|
|
self.assertIn("|| true", queue)
|
|
|
|
|
self.assertIn("exit 0", queue)
|
|
|
|
|
|
|
|
|
|
def test_the_cow_check_no_longer_goes_through_the_capturing_executor(self):
|
|
|
|
|
# exec_command_live imprime « Command returned error code: 1 » dès
|
|
|
|
|
# qu'un code non nul sort, y compris sur un rapport qui va bien.
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
from script.todo.todo_upgrade import TodoUpgrade
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
|
|
|
|
|
avant = source[: source.index("check_cow_views.py")]
|
[ADD] migration : garder la sortie des tests, par pseudo-terminal
Le journal d'étape notait la commande et son code de retour, jamais ce
qu'elle avait écrit : l'écran d'analyse ne pouvait rien montrer d'un
échec. Un tube aurait capturé et changé le programme — smoke_public_url
appelle can_ask(), qui exige stdin ET stdout sur un terminal, et derrière
un tube il cesse en silence d'offrir la réparation des vues COW. Un
pseudo-terminal lève le dilemme : l'enfant voit un vrai terminal, la
réponse tapée lui parvient, le code de retour survit. Neuf exécutions y
passent, dont check_hidden_models, qui tournait sans verdict retenu ;
deux restent dehors, pty.spawn naît en 0×0 et un plein écran s'y perdrait.
--- EN ---
The step log recorded the command and its exit code, never what it
wrote: the analysis screen could show nothing of a failure. A pipe would
have captured and changed the program — smoke_public_url calls can_ask(),
which requires stdin AND stdout to be terminals, and behind a pipe it
silently stops offering the COW view repair. A pty settles it: the child
sees a real terminal, a typed answer reaches it, the exit code survives.
Nine runs go through it, including check_hidden_models, which ran with no
verdict recorded; two stay out, pty.spawn starts at 0×0 and a full-screen
app would lay out on nothing.
Assisted-by: Claude Opus 5
(cherry picked from commit 81e9227502a0d16e6409338cf958495a5f8e3827)
2026-08-27 05:54:24 -04:00
|
|
|
terminal = max(
|
|
|
|
|
avant.rfind("run_on_terminal("), avant.rfind("run_captured(")
|
[FIX] addons: stop calling a leftover report a failed command
« Command returned error code: 1 » followed a theme that was removed
correctly. 1 is this toolkit's « there are findings », but the report
was the uninstaller's last command, so its code became the script's;
and the COW check was still read through the capturing executor, which
announces any non-zero code as an error.
The leftovers can now be dealt with instead of only listed: keep by
default, or delete after their content is written to private/. Listing
fifteen attachments and stopping there meant composing an unlink() by
hand, mid-migration, from identifiers read off a screen.
--- FR ---
[FIX] addons : cesser d'appeler « erreur » un rapport de restes
« Command returned error code: 1 » suivait un thème correctement
retiré. 1 veut dire « il y a des constats » dans cet outillage, mais le
rapport était la dernière commande du désinstalleur, donc son code
devenait celui du script ; et la vérification COW passait encore par
l'exécuteur qui capture, lequel annonce tout code non nul comme une
erreur.
Les restes peuvent désormais être traités, pas seulement listés :
garder par défaut, ou effacer après écriture de leur contenu sous
private/. Lister quinze pièces jointes et s'arrêter là revenait à faire
composer un unlink() à la main, en pleine migration.
Assisted-by: Claude Opus 5
(cherry picked from commit 304ce32b2680e3d01e38c3224bb85c2798cd6f53)
2026-08-16 05:33:28 -04:00
|
|
|
)
|
[ADD] migration : garder la sortie des tests, par pseudo-terminal
Le journal d'étape notait la commande et son code de retour, jamais ce
qu'elle avait écrit : l'écran d'analyse ne pouvait rien montrer d'un
échec. Un tube aurait capturé et changé le programme — smoke_public_url
appelle can_ask(), qui exige stdin ET stdout sur un terminal, et derrière
un tube il cesse en silence d'offrir la réparation des vues COW. Un
pseudo-terminal lève le dilemme : l'enfant voit un vrai terminal, la
réponse tapée lui parvient, le code de retour survit. Neuf exécutions y
passent, dont check_hidden_models, qui tournait sans verdict retenu ;
deux restent dehors, pty.spawn naît en 0×0 et un plein écran s'y perdrait.
--- EN ---
The step log recorded the command and its exit code, never what it
wrote: the analysis screen could show nothing of a failure. A pipe would
have captured and changed the program — smoke_public_url calls can_ask(),
which requires stdin AND stdout to be terminals, and behind a pipe it
silently stops offering the COW view repair. A pty settles it: the child
sees a real terminal, a typed answer reaches it, the exit code survives.
Nine runs go through it, including check_hidden_models, which ran with no
verdict recorded; two stay out, pty.spawn starts at 0×0 and a full-screen
app would lay out on nothing.
Assisted-by: Claude Opus 5
(cherry picked from commit 81e9227502a0d16e6409338cf958495a5f8e3827)
2026-08-27 05:54:24 -04:00
|
|
|
self.assertGreater(terminal, avant.rfind("todo_upgrade_execute("))
|
[FIX] addons: stop calling a leftover report a failed command
« Command returned error code: 1 » followed a theme that was removed
correctly. 1 is this toolkit's « there are findings », but the report
was the uninstaller's last command, so its code became the script's;
and the COW check was still read through the capturing executor, which
announces any non-zero code as an error.
The leftovers can now be dealt with instead of only listed: keep by
default, or delete after their content is written to private/. Listing
fifteen attachments and stopping there meant composing an unlink() by
hand, mid-migration, from identifiers read off a screen.
--- FR ---
[FIX] addons : cesser d'appeler « erreur » un rapport de restes
« Command returned error code: 1 » suivait un thème correctement
retiré. 1 veut dire « il y a des constats » dans cet outillage, mais le
rapport était la dernière commande du désinstalleur, donc son code
devenait celui du script ; et la vérification COW passait encore par
l'exécuteur qui capture, lequel annonce tout code non nul comme une
erreur.
Les restes peuvent désormais être traités, pas seulement listés :
garder par défaut, ou effacer après écriture de leur contenu sous
private/. Lister quinze pièces jointes et s'arrêter là revenait à faire
composer un unlink() à la main, en pleine migration.
Assisted-by: Claude Opus 5
(cherry picked from commit 304ce32b2680e3d01e38c3224bb85c2798cd6f53)
2026-08-16 05:33:28 -04:00
|
|
|
|
|
|
|
|
|
[ADD] addons: uninstall a theme the way Odoo removes one
There was no counterpart to install_addons_theme.sh, so themes were
removed with a plain --uninstall. That takes out the module, not the
theme: it skips _theme_remove(), whose first act is
_reset_default_config() — the call that writes font-number and its
three neighbours into user_values.scss.
Measured on a real 12 -> 13 migration: web.assets_frontend stopped on
« Undefined variable: $o-theme-font-number ». Odoo 12 defined it in
option_font_body_*, dropped in 13.0; only the theme still redefined
it, so removing the theme exposed a frozen 2020 customization.
theme_leftover.py then reports what unloading does not take — 15
attachments on the measured database. It deletes nothing: their
content may be the only trace of a customization.
--- FR ---
[ADD] addons : désinstaller un thème comme Odoo le retire
install_addons_theme.sh n'avait pas de symétrique : on retirait donc
les thèmes par un --uninstall nu. Cela enlève le module, pas le thème
— cela saute _theme_remove(), dont le premier geste est
_reset_default_config(), l'appel qui écrit font-number et ses trois
voisines dans user_values.scss.
Mesuré sur une vraie migration 12 → 13 : web.assets_frontend s'arrête
sur « Undefined variable: $o-theme-font-number ». Odoo 12 la
définissait dans option_font_body_*, supprimés en 13.0 ; seul le thème
la redéfinissait, et le retirer a mis à nu un SCSS figé en 2020.
theme_leftover.py signale ensuite ce que le déchargement ne prend pas
— 15 pièces jointes sur la base mesurée. Il ne supprime rien : leur
contenu peut être la seule trace d'une personnalisation.
Assisted-by: Claude Opus 5
(cherry picked from commit 16a7b1e1333752e2ef603d66f47374a22590d665)
2026-08-13 05:56:06 -04:00
|
|
|
class TestExitCodes(unittest.TestCase):
|
|
|
|
|
"""0 rien, 1 des restes, 2 l'outil a échoué — comme les outils voisins."""
|
|
|
|
|
|
|
|
|
|
def test_a_dead_database_is_a_tool_failure(self):
|
|
|
|
|
done = subprocess.run(
|
|
|
|
|
[
|
|
|
|
|
sys.executable,
|
|
|
|
|
os.path.join(REPO, "script", "addons", "theme_leftover.py"),
|
|
|
|
|
"-d",
|
|
|
|
|
"erplibre_no_such_database_zz",
|
|
|
|
|
"-t",
|
|
|
|
|
"theme_x",
|
|
|
|
|
],
|
|
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
|
|
|
|
cwd=REPO,
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(done.returncode, 2, done.stdout)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
unittest.main()
|