[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)
|
|
|
|
|
|
|
|
|
|
|
|
"""Ce qu'un thème laisse derrière lui une fois désinstallé. Lecture seule.
|
|
|
|
|
|
|
|
|
|
|
|
Décharger un thème retire ses copies de vues et rend au site sa configuration
|
|
|
|
|
|
par défaut. Restent des pièces jointes portant son chemin — SCSS compilé,
|
|
|
|
|
|
images téléversées dans ses dossiers — et parfois des vues dont la clé le
|
|
|
|
|
|
nomme encore.
|
|
|
|
|
|
|
|
|
|
|
|
Elles ne cassent rien tant que le module est parti : plus personne ne les
|
|
|
|
|
|
inclut dans un bundle. Mais elles traversent toutes les migrations suivantes,
|
|
|
|
|
|
et l'on finit par tomber sur un `/theme_x/static/...` dont le module n'existe
|
|
|
|
|
|
plus nulle part, sans savoir si c'est grave.
|
|
|
|
|
|
|
|
|
|
|
|
Rien n'est supprimé ici. Le contenu d'une pièce jointe peut être la seule
|
|
|
|
|
|
trace d'une personnalisation, et c'est une décision, pas un ménage.
|
|
|
|
|
|
|
|
|
|
|
|
Codes de sortie : 0 rien à signaler, 1 des restes, 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
|
|
|
|
|
|
|
|
|
|
|
|
|
[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
|
|
|
|
try:
|
|
|
|
|
|
from script.todo import auto_ask
|
|
|
|
|
|
except Exception: # pragma: no cover - repli si le pilote est absent
|
|
|
|
|
|
auto_ask = None
|
|
|
|
|
|
|
|
|
|
|
|
|
[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
|
|
|
|
def can_ask():
|
|
|
|
|
|
"""Peut-on poser une question ICI ?
|
|
|
|
|
|
|
|
|
|
|
|
Il faut deux choses, pas une : de quoi LIRE la réponse (stdin sur un
|
|
|
|
|
|
terminal) et de quoi MONTRER la question (stdout aussi). Ne tester que
|
|
|
|
|
|
stdin laisse poser une invite qui part dans un tube : elle reste en
|
|
|
|
|
|
tampon, invisible, pendant que le processus attend — on croit à un
|
|
|
|
|
|
blocage et l'on tape Entrée à l'aveugle.
|
|
|
|
|
|
"""
|
|
|
|
|
|
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
|
|
|
|
|
|
|
|
|
|
|
[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
|
|
|
|
def run_psql(database, sql):
|
|
|
|
|
|
"""Interroger la base en lecture seule, garantie côté serveur.
|
|
|
|
|
|
|
|
|
|
|
|
`default_transaction_read_only` est posé par le serveur pour toute la
|
|
|
|
|
|
session : ce n'est pas une promesse de l'outil, c'est PostgreSQL qui
|
|
|
|
|
|
refusera l'écriture même si le SQL en contenait une.
|
|
|
|
|
|
"""
|
|
|
|
|
|
env = os.environ.copy()
|
|
|
|
|
|
env["PGOPTIONS"] = (
|
|
|
|
|
|
"-c default_transaction_read_only=on -c statement_timeout=30000"
|
|
|
|
|
|
)
|
|
|
|
|
|
env["PSQLRC"] = ""
|
|
|
|
|
|
done = subprocess.run(
|
|
|
|
|
|
[
|
|
|
|
|
|
"psql",
|
|
|
|
|
|
"-X",
|
|
|
|
|
|
"-w",
|
|
|
|
|
|
"-v",
|
|
|
|
|
|
"ON_ERROR_STOP=1",
|
|
|
|
|
|
"-d",
|
|
|
|
|
|
database,
|
|
|
|
|
|
"-tAF",
|
|
|
|
|
|
"|",
|
|
|
|
|
|
"-c",
|
|
|
|
|
|
sql,
|
|
|
|
|
|
],
|
|
|
|
|
|
capture_output=True,
|
|
|
|
|
|
text=True,
|
|
|
|
|
|
env=env,
|
|
|
|
|
|
)
|
|
|
|
|
|
if done.returncode:
|
|
|
|
|
|
raise RuntimeError(done.stderr.strip() or "psql failed")
|
|
|
|
|
|
return [line for line in done.stdout.splitlines() if line]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def quote_literal(value):
|
|
|
|
|
|
return "'" + value.replace("'", "''") + "'"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def collect(database, theme):
|
|
|
|
|
|
"""Pièces jointes et vues qui nomment encore ce thème."""
|
|
|
|
|
|
like = quote_literal(f"%/{theme}/%")
|
|
|
|
|
|
key_like = quote_literal(f"{theme}.%")
|
|
|
|
|
|
attachments = run_psql(
|
|
|
|
|
|
database,
|
|
|
|
|
|
"SELECT id, COALESCE(url, name), create_date::date FROM ir_attachment"
|
|
|
|
|
|
f" WHERE url LIKE {like} ORDER BY id;",
|
|
|
|
|
|
)
|
|
|
|
|
|
views = run_psql(
|
|
|
|
|
|
database,
|
|
|
|
|
|
"SELECT id, key, website_id FROM ir_ui_view"
|
|
|
|
|
|
f" WHERE key LIKE {key_like} ORDER BY id;",
|
|
|
|
|
|
)
|
|
|
|
|
|
return attachments, views
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def render(theme, attachments, views):
|
|
|
|
|
|
if not attachments and not views:
|
|
|
|
|
|
return f"✅ -> {t('No leftover for theme')} '{theme}'.\n"
|
|
|
|
|
|
lines = []
|
|
|
|
|
|
if attachments:
|
|
|
|
|
|
lines.append(
|
|
|
|
|
|
f"ℹ {len(attachments)} {t('attachment(s) still under')}"
|
|
|
|
|
|
f" /{theme}/ :"
|
|
|
|
|
|
)
|
|
|
|
|
|
for row in attachments[:20]:
|
|
|
|
|
|
lines.append(f" - {row}")
|
|
|
|
|
|
if len(attachments) > 20:
|
|
|
|
|
|
lines.append(f" … {len(attachments) - 20} {t('more')}")
|
|
|
|
|
|
if views:
|
|
|
|
|
|
lines.append(
|
|
|
|
|
|
f"ℹ {len(views)} {t('view(s) whose key still names it')} :"
|
|
|
|
|
|
)
|
|
|
|
|
|
for row in views[:20]:
|
|
|
|
|
|
lines.append(f" - {row}")
|
|
|
|
|
|
if len(views) > 20:
|
|
|
|
|
|
lines.append(f" … {len(views) - 20} {t('more')}")
|
|
|
|
|
|
lines.append(
|
|
|
|
|
|
f" {t('Nothing was deleted: their content may be the only trace')}"
|
|
|
|
|
|
f" {t('of a customization. Read before removing.')}"
|
|
|
|
|
|
)
|
|
|
|
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
|
|
|
|
|
|
|
[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
|
|
|
|
def backup_attachments(database, theme, lst_row, filestore=None):
|
|
|
|
|
|
"""Écrire le contenu des pièces jointes AVANT de les supprimer.
|
|
|
|
|
|
|
|
|
|
|
|
C'est la condition pour pouvoir répondre « efface ». Sans elle, on
|
|
|
|
|
|
détruirait ce dont on vient d'écrire qu'il peut être la seule trace
|
|
|
|
|
|
d'une personnalisation.
|
|
|
|
|
|
"""
|
|
|
|
|
|
base = filestore or os.path.join(
|
|
|
|
|
|
os.path.expanduser("~"),
|
|
|
|
|
|
".local",
|
|
|
|
|
|
"share",
|
|
|
|
|
|
"Odoo",
|
|
|
|
|
|
"filestore",
|
|
|
|
|
|
database,
|
|
|
|
|
|
)
|
|
|
|
|
|
directory = os.path.join(
|
|
|
|
|
|
"private", "odoo", "migration", database, "theme_backup", theme
|
|
|
|
|
|
)
|
|
|
|
|
|
os.makedirs(directory, exist_ok=True)
|
|
|
|
|
|
lst_saved = []
|
|
|
|
|
|
for row in lst_row:
|
|
|
|
|
|
att_id = row.split("|")[0]
|
|
|
|
|
|
rows = run_psql(
|
|
|
|
|
|
database,
|
|
|
|
|
|
"SELECT COALESCE(store_fname, '') FROM ir_attachment"
|
|
|
|
|
|
f" WHERE id = {int(att_id)};",
|
|
|
|
|
|
)
|
|
|
|
|
|
store_fname = rows[0].strip() if rows else ""
|
|
|
|
|
|
if not store_fname:
|
|
|
|
|
|
continue
|
|
|
|
|
|
source = os.path.join(base, store_fname)
|
|
|
|
|
|
if not os.path.isfile(source):
|
|
|
|
|
|
continue
|
|
|
|
|
|
target = os.path.join(
|
|
|
|
|
|
directory, f"{att_id}_" + os.path.basename(row.split("|")[1])
|
|
|
|
|
|
)
|
|
|
|
|
|
with open(source, "rb") as src, open(target, "wb") as dst:
|
|
|
|
|
|
dst.write(src.read())
|
|
|
|
|
|
lst_saved.append(target)
|
|
|
|
|
|
return lst_saved
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def delete_attachments(database, lst_row, config_path="./config.conf"):
|
|
|
|
|
|
"""Supprimer par le shell d'Odoo, pour qu'il gère aussi le filestore.
|
|
|
|
|
|
|
|
|
|
|
|
Un DELETE en SQL laisserait les fichiers orphelins et les caches
|
|
|
|
|
|
incohérents ; unlink() fait le ménage complet, dans toutes les versions.
|
|
|
|
|
|
"""
|
[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
|
|
|
|
# int(), pas la chaîne du psql : browse(['4457']) fait échouer Odoo sur
|
|
|
|
|
|
# « n'a pas les identifiants (('4457',)) et a des identifiants
|
|
|
|
|
|
# supplémentaires ((4457,)) » — il compare des chaînes à des entiers.
|
|
|
|
|
|
# Mesuré sur une vraie base, l'effacement n'a rien retiré.
|
|
|
|
|
|
lst_id = [int(row.split("|")[0]) for row in lst_row]
|
[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
|
|
|
|
script = (
|
|
|
|
|
|
f"env['ir.attachment'].browse({lst_id!r}).unlink()\n"
|
|
|
|
|
|
"env.cr.commit()\n"
|
|
|
|
|
|
)
|
|
|
|
|
|
done = subprocess.run(
|
|
|
|
|
|
["./odoo_bin.sh", "shell", "-c", config_path, "-d", database],
|
|
|
|
|
|
input=script,
|
|
|
|
|
|
capture_output=True,
|
|
|
|
|
|
text=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
return done.returncode, done.stdout + done.stderr
|
|
|
|
|
|
|
|
|
|
|
|
|
[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
|
|
|
|
DEFAULT_ANSWER = "d"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def prompt(database, theme, attachments, views, config_path, ask=None):
|
|
|
|
|
|
"""Effacer ou garder. Effacer par défaut, et la sauvegarde D'ABORD.
|
|
|
|
|
|
|
|
|
|
|
|
Ce qui rend ce défaut tenable, c'est l'ordre : le contenu part dans un
|
|
|
|
|
|
fichier avant que la base ne soit touchée. Sans cette sauvegarde, le
|
|
|
|
|
|
défaut aurait dû rester « garder » — on ne fait pas d'une décision
|
|
|
|
|
|
irréversible la réponse que l'on obtient en ne répondant pas.
|
|
|
|
|
|
"""
|
[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
|
|
|
|
if not attachments:
|
|
|
|
|
|
return False
|
[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
|
|
|
|
if ask is None:
|
|
|
|
|
|
# Lancé à part par la migration : sans lecteur temporisé, cette
|
|
|
|
|
|
# question arrêtait net une exécution automatique.
|
|
|
|
|
|
ask = (
|
|
|
|
|
|
auto_ask.make_ask(DEFAULT_ANSWER)
|
|
|
|
|
|
if auto_ask
|
|
|
|
|
|
else (lambda prompt="": input(prompt) or DEFAULT_ANSWER)
|
|
|
|
|
|
)
|
[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
|
|
|
|
answer = (
|
|
|
|
|
|
ask(
|
|
|
|
|
|
f"💬 {t('Delete these leftovers, or keep them?')}"
|
[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
|
|
|
|
f" ({t('Enter = delete, after saving them')},"
|
|
|
|
|
|
f" k = {t('keep')}) : "
|
[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
|
|
|
|
)
|
|
|
|
|
|
.strip()
|
|
|
|
|
|
.lower()
|
|
|
|
|
|
)
|
|
|
|
|
|
if answer != "d":
|
|
|
|
|
|
print(f"ℹ -> {t('Kept. Nothing was deleted.')}")
|
|
|
|
|
|
return False
|
|
|
|
|
|
lst_saved = backup_attachments(database, theme, attachments)
|
|
|
|
|
|
print(f"📦 {t('Saved before deleting')} : {len(lst_saved)}")
|
|
|
|
|
|
if lst_saved:
|
|
|
|
|
|
print(f" {os.path.dirname(lst_saved[0])}")
|
|
|
|
|
|
status, output = delete_attachments(database, attachments, config_path)
|
|
|
|
|
|
print(output.strip()[-1500:])
|
|
|
|
|
|
if status:
|
|
|
|
|
|
print(f"❌ {t('Deletion failed, nothing was removed.')}")
|
|
|
|
|
|
return False
|
|
|
|
|
|
print(f"✅ -> {len(attachments)} {t('attachment(s) deleted.')}")
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
[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
|
|
|
|
def main(argv=None):
|
|
|
|
|
|
parser = argparse.ArgumentParser(
|
|
|
|
|
|
description=("List what an uninstalled theme left behind (read-only).")
|
|
|
|
|
|
)
|
|
|
|
|
|
parser.add_argument("-d", "--database", required=True)
|
|
|
|
|
|
parser.add_argument("-t", "--theme", required=True)
|
[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
|
|
|
|
parser.add_argument(
|
|
|
|
|
|
"-c",
|
|
|
|
|
|
"--config",
|
|
|
|
|
|
default="./config.conf",
|
|
|
|
|
|
help="Odoo config used by the shell for --delete",
|
|
|
|
|
|
)
|
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
|
"--delete",
|
|
|
|
|
|
action="store_true",
|
|
|
|
|
|
help="delete them (WRITES; saves their content first)",
|
|
|
|
|
|
)
|
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
|
"--report-only",
|
|
|
|
|
|
action="store_true",
|
|
|
|
|
|
help="never ask anything, even in front of a terminal",
|
|
|
|
|
|
)
|
[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
|
|
|
|
config = parser.parse_args(argv)
|
|
|
|
|
|
try:
|
|
|
|
|
|
attachments, views = collect(config.database, config.theme)
|
|
|
|
|
|
except RuntimeError as exc:
|
|
|
|
|
|
print(f"❌ {exc}")
|
|
|
|
|
|
return 2
|
|
|
|
|
|
print(render(config.theme, attachments, views))
|
[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
|
|
|
|
if not attachments and not views:
|
|
|
|
|
|
return 0
|
|
|
|
|
|
if config.delete:
|
|
|
|
|
|
lst_saved = backup_attachments(
|
|
|
|
|
|
config.database, config.theme, attachments
|
|
|
|
|
|
)
|
|
|
|
|
|
print(f"📦 {t('Saved before deleting')} : {len(lst_saved)}")
|
|
|
|
|
|
status, output = delete_attachments(
|
|
|
|
|
|
config.database, attachments, config.config
|
|
|
|
|
|
)
|
|
|
|
|
|
print(output.strip()[-1500:])
|
|
|
|
|
|
if status:
|
|
|
|
|
|
print(f"❌ {t('Deletion failed, nothing was removed.')}")
|
|
|
|
|
|
return 2
|
|
|
|
|
|
print(f"✅ -> {len(attachments)} {t('attachment(s) deleted.')}")
|
|
|
|
|
|
return 0
|
[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
|
|
|
|
# Voir ET pouvoir répondre : une invite dont la sortie part dans un
|
|
|
|
|
|
# tube reste invisible — Python bufferise par blocs — pendant que le
|
|
|
|
|
|
# processus attend. Mesuré : l'utilisateur tape Entrée à l'aveugle.
|
|
|
|
|
|
if not config.report_only and can_ask():
|
[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
|
|
|
|
if prompt(
|
|
|
|
|
|
config.database,
|
|
|
|
|
|
config.theme,
|
|
|
|
|
|
attachments,
|
|
|
|
|
|
views,
|
|
|
|
|
|
config.config,
|
|
|
|
|
|
):
|
|
|
|
|
|
return 0
|
|
|
|
|
|
return 1
|
[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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
sys.exit(main())
|