From 76fb9198cb8d0430589536ca22decd3774d6d7d7 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 11 Aug 2026 23:49:05 -0400 Subject: [PATCH 01/67] [ADD] migration: look at a COW copy before agreeing to give it up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prompt asked whether to neutralize copies without showing what they hold. Answering meant giving up a customization sight unseen — often three lines, an id and a container width, sometimes a whole page, and nothing told them apart. « v » now shows both halves of the question. What the copy changed, diffed against the module view it shadows: on the database at hand, 5 lines added and 3 removed, two CSS anchors and a container width. And why it breaks, by showing the declaration in each version — portal.frontend_layout is a standalone template in 12.0 and inheritance specs in 13.0, which is the whole explanation. « w » is the same two, full screen, space to switch. The current version comes from ir_module_module, not .odoo-version: the checkout is switched to the target before this runs, so reading the file compared the target with itself and printed the same declaration twice. That is what it did until it was run against a real migration. --list says which copies a past neutralization put aside. A renamed key is invisible in the interface, so without it the only trace was remembering. Checked on the VM mid-migration: both views on view 2670, the screen builds headless and toggles, --list reports and reports nothing when there is nothing. A missing database now exits 2 with a message instead of a traceback. --- FR --- L'invite demandait de neutraliser des copies sans montrer ce qu'elles contiennent. Répondre revenait à renoncer à une personnalisation sans l'avoir vue — souvent trois lignes, un id et une largeur de conteneur, parfois une page entière, et rien ne les distinguait. « v » montre désormais les deux moitiés de la question. Ce que la copie a changé, comparé à la vue de module qu'elle masque : sur la base en cours, 5 lignes ajoutées et 3 retirées, deux ancres CSS et une largeur. Et pourquoi ça casse, en affichant la déclaration dans chaque version — portal.frontend_layout est un gabarit autonome en 12.0 et des consignes d'héritage en 13.0, ce qui est toute l'explication. « w » donne les deux en plein écran, espace pour basculer. La version courante vient d'ir_module_module, pas de .odoo-version : le checkout est basculé sur la cible avant cette étape, donc lire le fichier comparait la cible avec elle-même et affichait deux fois la même déclaration. C'est ce qu'il faisait jusqu'à l'essai sur une vraie migration. --list dit quelles copies une neutralisation passée a mises de côté. Une clé renommée est invisible dans l'interface ; sans cela, la seule trace était de s'en souvenir. Vérifié sur la VM en cours de migration : les deux vues sur la vue 2670, l'écran se construit sans terminal et bascule, --list rapporte et ne rapporte rien quand il n'y a rien. Une base absente sort en 2 avec un message plutôt qu'une trace d'appel. Assisted-by: Claude Opus 5 --- script/odoo/migration/cow_drift.py | 342 ++++++++++++++++++ script/odoo/migration/cow_drift_tui.py | 162 +++++++++ script/odoo/migration/neutralize_cow_views.py | 70 +++- script/todo/todo_upgrade.py | 41 ++- 4 files changed, 606 insertions(+), 9 deletions(-) create mode 100644 script/odoo/migration/cow_drift.py create mode 100644 script/odoo/migration/cow_drift_tui.py diff --git a/script/odoo/migration/cow_drift.py b/script/odoo/migration/cow_drift.py new file mode 100644 index 0000000..a8c5e85 --- /dev/null +++ b/script/odoo/migration/cow_drift.py @@ -0,0 +1,342 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Show what a website COW copy holds, and why the next version breaks it. + +Read-only. Answers the two questions someone asks before neutralizing a copy, +and neither is answerable from the warning alone: + +**What do I lose?** The copy is compared with the module view it shadows. That +is the customization someone made, and often it is three lines — an id, a +container width — for which nobody would hold up a migration. + +**Why does it break?** The module declaration is shown in the current version +and in the target. The pair is the whole explanation: a template declared +without ``inherit_id`` is a standalone document, one declared with it must +hold inheritance specs. A copy frozen in the first shape cannot be applied in +the second, and Odoo stops on « cannot be located in parent view ». + +Nothing here writes. Neutralizing is ``neutralize_cow_views.py --apply``, and +undoing it is ``--restore``. +""" + +import argparse +import difflib +import json +import os +import re +import subprocess +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from check_cow_views import analyse, find_module_dir # noqa: E402 + +# The declaration of a template spans a few lines; showing the opening tag and +# what follows is enough to see its shape, and short enough to compare two +# versions side by side without scrolling. +DECL_LINES = 4 + + +def run_psql(database, sql): + """Run a statement and return stdout, raising on failure.""" + result = subprocess.run( + ["psql", "-X", "-w", "-d", database, "-tAc", sql], + capture_output=True, + text=True, + ) + if result.returncode: + raise RuntimeError( + f"Query failed on '{database}': {result.stderr.strip()}" + ) + return result.stdout + + +def unwrap_arch(value): + """The arch as text, whatever the column type. + + ``arch_db`` is text up to 15.0 and jsonb from 16.0. The same unwrapping as + the sibling scripts, kept identical on purpose: a second reading of the + same column that differs on an edge case would report a drift nobody made. + """ + text = (value or "").strip() + if text.startswith("{") and '"' in text: + try: + data = json.loads(text) + except ValueError: + return value or "" + if isinstance(data, dict) and data: + for lang in ("en_US", *sorted(data)): + if isinstance(data.get(lang), str): + return data[lang] + return value or "" + + +def fetch_arch(database, view_id): + """The stored arch of one view.""" + return unwrap_arch( + run_psql( + database, + f"SELECT arch_db::text FROM ir_ui_view WHERE id = {int(view_id)};", + ) + ) + + +def fetch_module_view(database, key): + """(id, arch) of the module view a copy shadows, or (None, ''). + + The module view is the one carrying that key WITHOUT a website: that is + exactly the pairing Odoo itself makes, and the reason renaming a key is + enough to unpair a copy. + """ + safe = key.replace("'", "''") + out = run_psql( + database, + "SELECT id, arch_db::text FROM ir_ui_view" + f" WHERE key = '{safe}' AND website_id IS NULL" + " ORDER BY id LIMIT 1;", + ).strip() + if not out: + return None, "" + view_id, _, arch = out.partition("|") + return int(view_id), unwrap_arch(arch) + + +def declaration(version_dir, key): + """(path, snippet) of how the sources of one version declare this key. + + Returns None when the module or the node is not found — a fact worth + showing as such, since « the module no longer declares it » is itself a + reason a copy breaks. + """ + module_name, _, template_id = key.partition(".") + if not template_id: + return None + module_dir = find_module_dir(version_dir, module_name) + if module_dir is None: + return None + try: + from lxml import etree + except ImportError: + return None + + pattern = os.path.join(module_dir, "**", "*.xml") + import glob + + for path in sorted(glob.glob(pattern, recursive=True)): + try: + tree = etree.parse(path) + except etree.XMLSyntaxError: + continue + for element in tree.getroot().iter(): + if element.get("id") != template_id: + continue + if element.tag not in ("template", "record"): + continue + # The source line, not a re-serialization: what the file actually + # says is what a reader will grep for. + with open(path, "r", encoding="utf-8", errors="replace") as handle: + lines = handle.read().splitlines() + start = max((element.sourceline or 1) - 1, 0) + return path, "\n".join(lines[start : start + DECL_LINES]) + return None + + +def database_version_dir(database): + """The odoo.0 directory matching what the DATABASE says it is. + + Not `.odoo-version`: the checkout is switched to the TARGET before this + runs, so reading it would compare the target with itself and show the same + declaration twice — which is what it did until this was measured on a real + migration. The database, at that moment, is still on the previous version + and says so in ir_module_module. + """ + try: + out = run_psql( + database, + "SELECT latest_version FROM ir_module_module" + " WHERE name = 'base';", + ).strip() + except RuntimeError: + return None + if not out: + return None + parts = out.split(".") + if len(parts) < 2: + return None + return f"odoo{parts[0]}.{parts[1]}" + + +def collect(database, target_version, current_version=None): + """Everything needed to judge each at-risk copy. No writes.""" + current_version = current_version or database_version_dir(database) + lst_at_risk, _, _ = analyse(database, target_version) + lst_finding = [] + for view_id, key, mode, target_mode, website_id, reason in lst_at_risk: + module_id, module_arch = fetch_module_view(database, key) + lst_finding.append( + { + "id": view_id, + "key": key, + "mode": mode, + "target_mode": target_mode, + "website_id": website_id, + "reason": reason, + "copy_arch": fetch_arch(database, view_id), + "module_id": module_id, + "module_arch": module_arch, + "decl_current": ( + declaration(current_version, key) + if current_version + else None + ), + "decl_target": declaration(target_version, key), + "current_version": current_version, + "target_version": target_version, + } + ) + return lst_finding + + +def render_diff(finding): + """What the copy changed, compared with the module view it shadows.""" + lines = [ + f"── id={finding['id']} {finding['key']}" + f" (website={finding['website_id']}) ──", + "", + ] + if finding["module_id"] is None: + lines += [ + " No module view carries this key, so there is nothing to compare", + " against: this copy is a page made in the website editor.", + ] + return "\n".join(lines) + + left = finding["module_arch"].splitlines() + right = finding["copy_arch"].splitlines() + diff = [ + line + for line in difflib.unified_diff( + left, + right, + fromfile=f"module id={finding['module_id']}", + tofile=f"copy id={finding['id']}", + lineterm="", + n=1, + ) + ] + if len(diff) <= 2: + lines.append(" The copy is identical to the module view.") + return "\n".join(lines) + lines += [f" {line}" for line in diff] + n_plus = sum( + 1 for x in diff if x.startswith("+") and not x.startswith("+++") + ) + n_minus = sum( + 1 for x in diff if x.startswith("-") and not x.startswith("---") + ) + lines += [ + "", + f" {n_plus} line(s) added, {n_minus} removed — this is what" + " neutralizing gives up.", + ] + return "\n".join(lines) + + +def render_shape(finding): + """Why it breaks: the declaration in each version, side by side.""" + lines = [ + f"── id={finding['id']} {finding['key']} ──", + "", + f" {finding['reason']}", + "", + ] + for label, decl in ( + (finding["current_version"], finding["decl_current"]), + (finding["target_version"], finding["decl_target"]), + ): + lines.append(f" ") + if decl is None: + lines += [ + " (the module no longer declares this template)", + "", + ] + continue + path, snippet = decl + lines.append(f" ") + lines += [f" {line}" for line in snippet.splitlines()] + shape = ( + "inheritance specs (needs inherit_id)" + if re.search(r"inherit_id\s*=", snippet) + else "a standalone template" + ) + lines += [f" -> {shape}", ""] + lines += [ + " A copy frozen in one shape cannot be applied in the other: Odoo", + " stops on « cannot be located in parent view ».", + ] + return "\n".join(lines) + + +def render_all(lst_finding, shape=False): + """The whole report, one block per finding.""" + if not lst_finding: + return "✅ No website COW view is at risk.\n" + render = render_shape if shape else render_diff + return "\n\n".join(render(f) for f in lst_finding) + "\n" + + +def main(argv=None): + parser = argparse.ArgumentParser( + description=( + "Show what each at-risk website COW copy holds, and why the next" + " version breaks it (read-only)." + ) + ) + parser.add_argument("-d", "--database", required=True) + parser.add_argument( + "-t", + "--target_version", + required=True, + help="target Odoo source directory, e.g. odoo13.0", + ) + parser.add_argument( + "--current", + default=None, + help="current Odoo source directory (default: from .odoo-version)", + ) + parser.add_argument( + "--shape", + action="store_true", + help="show the declarations instead of the customization diff", + ) + parser.add_argument( + "--tui", + action="store_true", + help="browse full screen, switching between the two views", + ) + config = parser.parse_args(argv) + + if not os.path.isdir(config.target_version): + print(f"❌ Target version '{config.target_version}' not found.") + return 2 + try: + lst_finding = collect( + config.database, config.target_version, config.current + ) + except RuntimeError as exc: + print(f"❌ {exc}") + return 2 + + if config.tui and lst_finding: + from cow_drift_tui import run_tui + + if run_tui(lst_finding): + return 1 + print(render_all(lst_finding, shape=config.shape)) + return 1 if lst_finding else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/odoo/migration/cow_drift_tui.py b/script/odoo/migration/cow_drift_tui.py new file mode 100644 index 0000000..953ef65 --- /dev/null +++ b/script/odoo/migration/cow_drift_tui.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Full-screen browsing of the at-risk website COW copies. + +Read-only, without exception: this screen exists to decide, and deciding +supposes having looked. Neutralizing stays a separate, explicit command. + +Two views of the same copy, one key apart +----------------------------------------- +« What do I lose » and « why does it break » are different questions with +different answers, and putting them side by side would halve the width of +each on a screen that already shows XML. They share the pane instead, and +``space`` switches — the header always says which one is showing, because a +diff and a declaration look alike at a glance. +""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +CSS = """ +Screen { layout: vertical; } +#head { height: 3; padding: 0 1; background: $panel; color: $text; } +#body { height: 1fr; } +#views { width: 38; border-right: solid $accent; } +#pane { width: 1fr; padding: 0 1; } +""" + + +def build_app(lst_finding): + """Build the application. Textual is imported here, not at module level. + + The module stays importable — and therefore testable — on a machine + without Textual, which is also what lets the caller fall back to the text + report rather than fail. + """ + from textual.app import App, ComposeResult + from textual.containers import Horizontal, VerticalScroll + from textual.widgets import DataTable, Footer, Header, Static + + from cow_drift import render_diff, render_shape + + class DriftApp(App): + CSS = globals()["CSS"] + BINDINGS = [ + ("q,escape", "quit", "Quit"), + ("space,tab", "toggle", "Diff / declarations"), + ("c", "copy", "Copy"), + ] + + def __init__(self, lst_finding): + super().__init__() + self.lst_finding = lst_finding + self.shape = False + self.index = 0 + + def compose(self) -> ComposeResult: + yield Header() + yield Static("", id="head") + with Horizontal(id="body"): + yield DataTable(id="views", cursor_type="row") + with VerticalScroll(id="pane"): + yield Static("", id="content") + yield Footer() + + def on_mount(self): + self.title = "Website COW copies at risk" + table = self.query_one("#views", DataTable) + table.add_columns("copy", "+/-") + for finding in self.lst_finding: + table.add_row( + (finding["key"] or str(finding["id"]))[:28], + self._weight(finding), + key=str(finding["id"]), + ) + self._show() + + def _weight(self, finding): + """How much the copy diverges, so the list can be triaged.""" + if finding["module_id"] is None: + return "—" + import difflib + + diff = list( + difflib.unified_diff( + finding["module_arch"].splitlines(), + finding["copy_arch"].splitlines(), + lineterm="", + n=0, + ) + ) + plus = sum( + 1 + for x in diff + if x.startswith("+") and not x.startswith("+++") + ) + minus = sum( + 1 + for x in diff + if x.startswith("-") and not x.startswith("---") + ) + return f"+{plus}/-{minus}" + + def _show(self): + if not self.lst_finding: + return + finding = self.lst_finding[self.index] + which = "declarations" if self.shape else "what the copy changed" + self.query_one("#head", Static).update( + f"{finding['key']} · id={finding['id']}" + f" · website={finding['website_id']}\n" + f"[{which}] — space to switch" + ) + render = render_shape if self.shape else render_diff + self.query_one("#content", Static).update(render(finding)) + + def on_data_table_row_highlighted(self, event): + if event.data_table.id == "views" and self.lst_finding: + self.index = event.cursor_row + self._show() + + def action_toggle(self): + self.shape = not self.shape + self._show() + + def action_copy(self): + if not self.lst_finding: + return + from cow_drift import render_diff, render_shape + + render = render_shape if self.shape else render_diff + # Truncated keeping the END: that is where the added lines are, + # and what someone is most often after. + self.copy_to_clipboard( + render(self.lst_finding[self.index])[-100_000:] + ) + self.notify("Copied.") + + return DriftApp(lst_finding) + + +def run_tui(lst_finding, run_app=True): + """Open the screen. False when it could not be shown. + + False is not a failure: the caller prints the text report instead, which + is the same information without the navigation. + """ + if not lst_finding: + return False + if not sys.stdout.isatty(): + return False + try: + app = build_app(lst_finding) + except ImportError: + return False + if not run_app: + return app + app.run() + return True diff --git a/script/odoo/migration/neutralize_cow_views.py b/script/odoo/migration/neutralize_cow_views.py index 7e32425..021cef8 100755 --- a/script/odoo/migration/neutralize_cow_views.py +++ b/script/odoo/migration/neutralize_cow_views.py @@ -40,7 +40,7 @@ DEFAULT_PREFIX = "zz_cow_archive" def run_psql(database, sql): """Run a statement and return stdout, raising on failure.""" result = subprocess.run( - ["psql", "-d", database, "-tAc", sql], + ["psql", "-X", "-w", "-d", database, "-tAF", "|", "-c", sql], capture_output=True, text=True, ) @@ -66,6 +66,39 @@ def neutralize(database, lst_view_id, prefix): return int(output or 0) +def list_archived(database, prefix): + """The copies a previous neutralization put aside. + + Knowing what was archived is the other half of --restore: a key renamed + months ago is invisible in the interface — the copy is inactive and no + longer pairs with anything — so without this listing the only trace is + someone's memory of having run --apply. + """ + output = run_psql( + database, + "SELECT id, substring(key from " + str(len(prefix) + 2) + ")," + " COALESCE(website_id::text, ''), active," + " octet_length(arch_db::text)" + f" FROM ir_ui_view WHERE key LIKE '{prefix}.%' ORDER BY id;", + ) + lst_row = [] + for line in output.splitlines(): + if not line.strip(): + continue + parts = line.split("|") + if len(parts) >= 5: + lst_row.append( + { + "id": int(parts[0]), + "key": parts[1], + "website_id": parts[2] or None, + "active": parts[3] == "t", + "arch_bytes": int(parts[4] or 0), + } + ) + return lst_row + + def restore(database, prefix): """Undo a neutralization: strip the prefix and reactivate.""" output = run_psql( @@ -105,8 +138,43 @@ def main(): action="store_true", help="undo a previous neutralization and reactivate the copies", ) + parser.add_argument( + "--list", + dest="list_archived", + action="store_true", + help="list the copies a previous neutralization put aside", + ) config = parser.parse_args() + try: + return _run(config, parser) + except RuntimeError as exc: + # Une base absente ou un PostgreSQL arrêté est une erreur d'usage, pas + # un défaut de l'outil : une trace d'appel ferait chercher le bogue au + # mauvais endroit. + print(f"❌ {exc}") + return 2 + + +def _run(config, parser): + if config.list_archived: + lst_row = list_archived(config.database, config.prefix) + if not lst_row: + print(f"✅ -> No '{config.prefix}.' view on '{config.database}'.") + return 0 + print(f"ℹ {len(lst_row)} archived COW view(s) on '{config.database}':") + for row in lst_row: + state = "active" if row["active"] else "inactive" + print( + f" - id={row['id']} website={row['website_id'] or '-'}" + f" {row['key']} ({state}, {row['arch_bytes']} B)" + ) + print( + " Their arch is intact. Restore them all with --restore, once" + " the module view they shadow has the shape they expect." + ) + return 0 + if config.restore: count = restore(config.database, config.prefix) print(f"✅ -> {count} COW view(s) restored on '{config.database}'.") diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index 6c37836..f4ade39 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -2371,15 +2371,35 @@ class TodoUpgrade: if "No website COW view to neutralize" in "\n".join(output or []): return - answer = ( - input( - "💬 Neutralize these copies so the upgrade can proceed?" - " Their arch is kept and the change is reversible." - " (Y/n) : " - ) - .strip() - .lower() + # « v » et « w » avant de répondre : la question demande de renoncer à + # une personnalisation sans avoir montré laquelle. Souvent trois lignes + # — un id, une largeur de conteneur — mais parfois une page entière, et + # rien dans l'avertissement ne permet de les distinguer. + show = ( + f"{PYTHON_BIN} ./script/odoo/migration/cow_drift.py" + f" -d {database_name} -t odoo{next_version}.0" ) + while True: + answer = ( + input( + "💬 Neutralize these copies so the upgrade can proceed?" + " Their arch is kept and the change is reversible." + " (Y/n, v = view the differences, w = full screen) : " + ) + .strip() + .lower() + ) + if answer == "v": + self.todo_upgrade_execute(show, wait_at_error=False) + self.todo_upgrade_execute( + f"{show} --shape", wait_at_error=False + ) + continue + if answer == "w": + self.todo_upgrade_execute(f"{show} --tui", wait_at_error=False) + continue + break + if answer == "n": print( "⚠️ -> Skipped. The data migration will very likely stop on" @@ -2387,6 +2407,11 @@ class TodoUpgrade: ) return self.todo_upgrade_execute(f"{cmd} --apply", wait_at_error=False) + print( + "ℹ -> List them later with:" + f" {PYTHON_BIN} ./script/odoo/migration/neutralize_cow_views.py" + f" -d {database_name} --list" + ) def diff_cow_views(self, database_name, label_before, label_after): """Print what the version bump did to the website COW views.""" From 4b6de55f30dd7ed4be2cf41b7ed8ebda14b47002 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 12 Aug 2026 00:34:17 -0400 Subject: [PATCH 02/67] [ADD] migration: keep the previous log when a run restarts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The progression log lives at one path and the next migration writes over it. Restarting therefore erased everything known about the run before — which steps passed, which modules were missing, how long each took — and that is exactly what someone looks for after having had to restart. Both answers that discard the progression now copy it aside first, named after the origin database and the moment of the copy, so two attempts on the same database do not cover each other and a file moved out of its folder still says what it is. Partial replays keep the log, so they archive nothing. It lands under private/, not beside the original: reinstalling wipes .venv.erplibre/ and would take the history with it. Two copies in the same second overwrote each other in silence — undoing the very loss this prevents. The second one is numbered now, and the test that had skipped that case asserts it. Checked on the real 51 KB log from the VM: 18 states copied unchanged, the name carries the database and the timestamp, and a log with no state at all is not archived. 12 tests. --- FR --- Le journal de progression vit à un seul endroit et la migration suivante écrit par-dessus. Recommencer effaçait donc tout ce qu'on savait de la tentative précédente — quels paliers étaient passés, quels modules manquaient, combien de temps chacun avait pris — soit exactement ce qu'on cherche après avoir dû recommencer. Les deux réponses qui jettent la progression la copient désormais d'abord, sous un nom portant la base d'origine et l'instant de la copie, pour que deux tentatives sur la même base ne se recouvrent pas et qu'un fichier sorti de son dossier se décrive encore. Une reprise partielle garde le journal, donc n'archive rien. La copie va sous private/, pas à côté de l'originale : une réinstallation efface .venv.erplibre/ et emporterait l'historique avec elle. Deux copies dans la même seconde s'écrasaient en silence — annulant la perte même qu'on évite. La seconde est numérotée, et le test qui sautait ce cas l'affirme. Vérifié sur le vrai journal de 51 Ko de la VM : 18 états copiés à l'identique, le nom porte la base et l'horodatage, et un journal sans aucun état n'est pas archivé. 12 tests. Assisted-by: Claude Opus 5 --- script/todo/todo_i18n.py | 8 ++ script/todo/todo_upgrade.py | 72 ++++++++++++- test/test_todo_upgrade_archive.py | 164 ++++++++++++++++++++++++++++++ 3 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 test/test_todo_upgrade_archive.py diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index b4e69f7..0447c9d 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -3860,6 +3860,14 @@ TRANSLATIONS = { " module source. The classification above needs none; only the" " differences do. Restore it, or run this on the database.", }, + "Could not archive the previous log": { + "fr": "Impossible d'archiver le journal précédent", + "en": "Could not archive the previous log", + }, + "Previous migration log kept in": { + "fr": "Journal de la migration précédente conservé dans", + "en": "Previous migration log kept in", + }, # --- script/analyse : vues personnalisées --- "Customised views": { "fr": "Vues personnalisées", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index f4ade39..5e4fab2 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -13,8 +13,8 @@ import zipfile from uuid import uuid4 import click -import todo_file_browser +from script.todo import todo_file_browser from script.todo.version_manager import get_odoo_version try: @@ -229,6 +229,70 @@ class TodoUpgrade: print(f" [r] {t('Keep the zip only, ask every question again')}") print(f" [q] {t('Quit without doing anything')}") + @staticmethod + def archive_progression(old_dct_progression, reason): + """Mettre le journal de côté avant qu'une nouvelle migration l'écrase. + + Le journal ne vit qu'à un seul endroit et la migration suivante écrit + par-dessus : recommencer effaçait donc tout ce qu'on savait de la + précédente — quels paliers étaient passés, quels modules manquaient, + combien de temps chacun avait pris. Autant d'éléments qu'on ne cherche + justement qu'APRÈS avoir dû recommencer. + + Le nom porte la base d'origine et l'horodatage de la copie, pour que + deux tentatives sur la même base ne se recouvrent pas et qu'un fichier + déplacé se décrive encore lui-même. Renvoie le chemin, ou None s'il n'y + avait rien qui vaille d'être gardé. + + L'archive vit sous `private/`, pas dans le venv : une réinstallation + efface `.venv.erplibre/`, et avec elle l'historique qu'on vient de + sauver. + """ + if not old_dct_progression: + return None + # Un journal qui ne porte aucun état n'apprend rien à personne. + if not any(k.startswith("state_") for k in old_dct_progression): + return None + + database_name = ( + old_dct_progression.get("config_database_name") + or os.path.splitext( + os.path.basename( + old_dct_progression.get("migration_file") or "" + ) + )[0] + or "unknown" + ) + stamp = datetime.datetime.now().strftime("%Y-%m-%d_%Hh%Mm%Ss") + directory = os.path.join( + PATH_MIGRATION_PRIVATE, database_name, "migration_log" + ) + path = os.path.join(directory, f"{database_name}_{stamp}.json") + # Deux copies dans la même seconde portent le même horodatage. Écraser + # la première annulerait exactement la perte qu'on cherche à éviter, + # et sans rien dire. + suffix = 2 + while os.path.exists(path): + path = os.path.join( + directory, f"{database_name}_{stamp}_{suffix}.json" + ) + suffix += 1 + payload = dict(old_dct_progression) + payload["archived_at"] = str(datetime.datetime.now()) + payload["archived_reason"] = reason + payload["archived_database"] = database_name + try: + os.makedirs(directory, exist_ok=True) + with open(path, "w") as handle: + json.dump(payload, handle, indent=4) + except OSError as exc: + # Ne jamais empêcher une migration de repartir pour une histoire + # de copie : on le dit, et on continue. + print(f"⚠️ {t('Could not archive the previous log')} : {exc}") + return None + print(f"📦 {t('Previous migration log kept in')} : {path}") + return path + def apply_resume_answer(self, old_dct_progression, answer, ctx): """Turn the answer into (progression, changed), or None to quit. @@ -242,9 +306,15 @@ class TodoUpgrade: return old_dct_progression, False if answer == "q": return None + # « n » et « r » repartent de zéro : dans les deux cas la progression + # enregistrée disparaît, donc dans les deux cas on la garde d'abord. if answer == "n": + self.archive_progression(old_dct_progression, "restart_from_zero") return {}, True if answer == "r": + self.archive_progression( + old_dct_progression, "restart_same_backup" + ) return { "migration_file": old_dct_progression.get("migration_file"), "date_create": old_dct_progression.get("date_create"), diff --git a/test/test_todo_upgrade_archive.py b/test/test_todo_upgrade_archive.py new file mode 100644 index 0000000..0bd445c --- /dev/null +++ b/test/test_todo_upgrade_archive.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Le journal de migration doit survivre à la migration suivante. + +Il ne vit qu'à un seul endroit, et repartir de zéro écrit par-dessus. Ce qu'on +perdait — quels paliers étaient passés, quels modules manquaient — est +précisément ce qu'on cherche APRÈS avoir dû recommencer. + +Ces tests portent sur deux choses distinctes : que la copie contienne bien ce +qu'elle doit contenir, et qu'elle parte au bon moment — ni sur une reprise +partielle, qui ne perd rien, ni sur un journal qui n'a rien à dire. +""" + +import glob +import json +import os +import tempfile +import unittest + +from script.todo.todo_upgrade import TodoUpgrade + + +def progression(**override): + """Un journal ayant réellement avancé.""" + data = { + "date_create": "2026-08-11 12:05:08", + "date_update": "2026-08-12 01:13:23", + "migration_file": "./image_db/technolibre_2026-08-12_02h36m38s.zip", + "config_database_name": "technolibre_migration_01_neutralize", + "state_0_install_odoo": True, + "state_1_restore_database": True, + "state_4_switch_odoo_lst": [True, False, False], + } + data.update(override) + return data + + +class ArchiveCase(unittest.TestCase): + def setUp(self): + # Chaque test dans un arbre neuf : l'archive s'écrit sous un chemin + # relatif, donc le répertoire courant EST le contexte. + self.previous = os.getcwd() + self.addCleanup(os.chdir, self.previous) + os.chdir(tempfile.mkdtemp()) + + def archives(self): + return sorted( + glob.glob("private/odoo/migration/*/migration_log/*.json") + ) + + +class TestWhatIsKept(ArchiveCase): + def test_the_whole_log_is_copied_untouched(self): + old = progression() + path = TodoUpgrade.archive_progression(old, "restart_from_zero") + saved = json.load(open(path)) + for key, value in old.items(): + self.assertEqual(saved[key], value, key) + + def test_the_copy_says_when_and_from_which_database(self): + # La demande, mot pour mot : une date de la copie et le nom de la base + # d'origine. + path = TodoUpgrade.archive_progression( + progression(), "restart_from_zero" + ) + saved = json.load(open(path)) + self.assertEqual( + saved["archived_database"], "technolibre_migration_01_neutralize" + ) + self.assertTrue(saved["archived_at"].startswith("20")) + self.assertEqual(saved["archived_reason"], "restart_from_zero") + + def test_the_file_name_carries_both_too(self): + # Pour qu'un fichier déplacé hors de son dossier se décrive encore. + path = TodoUpgrade.archive_progression(progression(), "x") + name = os.path.basename(path) + self.assertTrue( + name.startswith("technolibre_migration_01_neutralize_") + ) + self.assertRegex(name, r"_\d{4}-\d{2}-\d{2}_\d{2}h\d{2}m\d{2}s\.json$") + + def test_it_lands_under_private_not_in_the_venv(self): + # Une réinstallation efface .venv.erplibre/, donc y garder l'historique + # reviendrait à le perdre au pire moment. + path = TodoUpgrade.archive_progression(progression(), "x") + self.assertTrue(path.startswith(os.path.join("private", "odoo"))) + self.assertNotIn(".venv", path) + + def test_the_database_name_falls_back_to_the_backup_name(self): + old = progression() + del old["config_database_name"] + path = TodoUpgrade.archive_progression(old, "x") + self.assertIn("technolibre_2026-08-12_02h36m38s", path) + + def test_two_restarts_in_the_same_second_do_not_overwrite_each_other(self): + # L'horodatage est à la seconde : deux copies rapprochées portent le + # même nom. Écraser la première annulerait la perte qu'on évite — et + # un test qui saute ce cas ne vérifie rien. + first = TodoUpgrade.archive_progression(progression(), "x") + second = TodoUpgrade.archive_progression( + progression(date_update="2026-08-12 02:00:00"), "y" + ) + self.assertNotEqual(first, second) + self.assertEqual(len(self.archives()), 2) + self.assertEqual( + json.load(open(first))["date_update"], "2026-08-12 01:13:23" + ) + self.assertEqual( + json.load(open(second))["date_update"], "2026-08-12 02:00:00" + ) + + +class TestWhenNothingIsKept(ArchiveCase): + def test_an_empty_log_is_not_archived(self): + self.assertIsNone(TodoUpgrade.archive_progression({}, "x")) + self.assertEqual(self.archives(), []) + + def test_a_log_without_any_state_is_not_archived(self): + # Un journal qui n'a rien enregistré n'apprend rien à personne ; + # l'archiver ne ferait qu'accumuler des fichiers vides. + self.assertIsNone( + TodoUpgrade.archive_progression({"migration_file": "a.zip"}, "x") + ) + self.assertEqual(self.archives(), []) + + +class TestWhenItTriggers(ArchiveCase): + """Seules les réponses qui repartent de zéro archivent.""" + + def answer(self, letter): + upgrade = TodoUpgrade.__new__(TodoUpgrade) + result = TodoUpgrade.apply_resume_answer( + upgrade, progression(), letter, {"versions": []} + ) + return result, self.archives() + + def test_restart_from_zero_archives(self): + result, archives = self.answer("n") + self.assertEqual(result, ({}, True)) + self.assertEqual(len(archives), 1) + + def test_restart_with_the_same_backup_archives(self): + # « r » garde le zip mais jette tous les états : la perte est la même. + result, archives = self.answer("r") + self.assertTrue(result[1]) + self.assertNotIn("state_0_install_odoo", result[0]) + self.assertEqual(len(archives), 1) + + def test_continuing_archives_nothing(self): + result, archives = self.answer("c") + self.assertEqual(archives, []) + self.assertIn("state_0_install_odoo", result[0]) + + def test_rewinding_to_a_step_archives_nothing(self): + # Une reprise partielle garde le journal : rien n'est perdu, donc rien + # n'est à sauver. + _, archives = self.answer("0") + self.assertEqual(archives, []) + + +if __name__ == "__main__": + unittest.main() From 98be22cb77b7d3630cf46ec20824870372577c25 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 12 Aug 2026 00:43:16 -0400 Subject: [PATCH 03/67] =?UTF-8?q?[FIX]=20migration:=20updating=20the=20add?= =?UTF-8?q?ons=20early=20left=20step=202=20=C2=AB=20not=20started=20=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A database coming from an old version is updated before the neutralization — the tool offers it at the very start. That work IS step 2, done earlier, but it was only recorded as state_1_update_all while the screen reads state_2_*. So the step stayed « not started » right after running. The costly half was invisible. Step 2 skipped the work through a local variable, which is False again on a resume: with only the old flag written, resuming a migration re-ran update_addons_all on an already-updated database. Hours, for nothing. The check now reads the written trace, not the session. The screen says when it happened rather than just « done », since the moment is exactly what raised the doubt. Logs written before this fix read correctly without being touched — the early flag alone is recognised. Checked on the real log from the VM: step 2 goes from « not started » to « done early, before the neutralization », the four other steps unchanged. 10 tests; removing the fix makes the resume one fail. --- FR --- Une base venant d'une vieille version se met à jour avant la neutralisation — l'outil le propose au tout début. Ce travail EST l'étape 2, faite plus tôt, mais il n'était enregistré que sous state_1_update_all quand l'écran lit state_2_*. L'étape restait donc « non démarrée » juste après avoir tourné. La moitié coûteuse était invisible. L'étape 2 sautait le travail grâce à une variable locale, remise à False à la reprise : avec seulement l'ancien drapeau écrit, reprendre une migration relançait update_addons_all sur une base déjà à jour. Des heures, pour rien. Le contrôle lit maintenant la trace écrite, pas la session. L'écran dit quand cela a eu lieu plutôt que « terminée », le moment étant justement la source du doute. Les journaux écrits avant ce correctif se lisent correctement sans être modifiés — le drapeau précoce seul est reconnu. Vérifié sur le vrai journal de la VM : l'étape 2 passe de « non démarrée » à « faite plus tôt, avant la neutralisation », les quatre autres inchangées. 10 tests ; retirer le correctif fait tomber celui de la reprise. Assisted-by: Claude Opus 5 --- script/todo/todo_i18n.py | 4 ++ script/todo/todo_upgrade.py | 45 ++++++++++++-- test/test_todo_upgrade_steps.py | 104 ++++++++++++++++++++++++++++++++ 3 files changed, 149 insertions(+), 4 deletions(-) create mode 100644 test/test_todo_upgrade_steps.py diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 0447c9d..ef08606 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -3868,6 +3868,10 @@ TRANSLATIONS = { "fr": "Journal de la migration précédente conservé dans", "en": "Previous migration log kept in", }, + "done early, before the neutralization": { + "fr": "faite plus tôt, avant la neutralisation", + "en": "done early, before the neutralization", + }, # --- script/analyse : vues personnalisées --- "Customised views": { "fr": "Vues personnalisées", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index 5e4fab2..c75a592 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -108,6 +108,26 @@ class TodoUpgrade: ) return {} + @staticmethod + def needs_update_all(dct_progression, already_done_early=False): + """Faut-il encore mettre à jour tous les modules ? + + Trois façons de l'avoir déjà fait, et il faut les trois. Le drapeau de + l'étape ; celui de la mise à jour précoce, posée avant la + neutralisation quand la base vient d'une vieille version ; et la + variable de la session en cours. + + Le second est ce qui manquait. Il n'est vrai que dans la session qui + l'a posé, alors qu'à la reprise seule la trace écrite subsiste : sans + le lire ici, reprendre une migration relançait `update_addons_all` sur + une base déjà à jour — des heures, pour rien. + """ + return not ( + dct_progression.get("state_2_update_all") + or dct_progression.get("state_1_update_all") + or already_done_early + ) + @staticmethod def step_status(dct_progression, step): """Return (icon, detail) telling how far a migration step went. @@ -121,6 +141,15 @@ class TodoUpgrade: for key, value in dct_progression.items() if key.startswith(prefix) } + # Un journal écrit avant que l'étape 2 n'enregistre son propre drapeau + # ne porte que state_1_update_all. Le travail a bien eu lieu ; le lire + # ici évite de dire « non démarrée » d'une étape terminée. + if ( + step == 2 + and not dct_flag + and dct_progression.get("state_1_update_all") + ): + return "✅", t("done early, before the neutralization") if not dct_flag: return "⬜", t("not started") @@ -154,6 +183,10 @@ class TodoUpgrade: return ("✅" if total and done == total else "⏳"), detail if all(dct_flag.values()): + if step == 2 and dct_progression.get("state_2_done_early"): + # Le doute vient du moment, pas du résultat : le dire évite de + # relancer une mise à jour qui a déjà eu lieu. + return "✅", t("done early, before the neutralization") return "✅", t("done") return "⏳", t("partially done") @@ -1313,6 +1346,13 @@ class TodoUpgrade: if not status: already_update_state_1 = True self.dct_progression["state_1_update_all"] = True + # La mise à jour de tous les modules EST l'étape 2, même + # faite plus tôt. Ne l'enregistrer que sous state_1 la + # laissait « non démarrée » à l'écran alors qu'elle venait + # de tourner, et faisait la refaire entièrement à la + # reprise suivante — sur une grosse base, des heures. + self.dct_progression["state_2_update_all"] = True + self.dct_progression["state_2_done_early"] = True self.write_config() print("✅ -> Update database before neutralize by module") @@ -1400,10 +1440,7 @@ class TodoUpgrade: print(f"🔷 {msg}") self.add_comment_progression(msg) - if ( - not self.dct_progression.get("state_2_update_all") - and not already_update_state_1 - ): + if self.needs_update_all(self.dct_progression, already_update_state_1): status, cmd_executed = self.todo_upgrade_execute( f"./script/addons/update_addons_all.sh {database_name}", single_source_odoo=True, diff --git a/test/test_todo_upgrade_steps.py b/test/test_todo_upgrade_steps.py new file mode 100644 index 0000000..5e5d68d --- /dev/null +++ b/test/test_todo_upgrade_steps.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""L'écran de reprise doit dire ce qui a eu lieu, pas ce qu'il croit. + +Une base venant d'une vieille version se met à jour AVANT la neutralisation : +l'outil le propose au tout début. Ce travail est celui de l'étape 2, fait plus +tôt — mais il n'était enregistré que sous `state_1_update_all`, et l'écran +cherchait `state_2_*`. + +Deux conséquences, l'une visible et l'autre chère : l'étape restait « non +démarrée » alors qu'elle venait de tourner, et la reprise suivante relançait +`update_addons_all` sur une base déjà à jour. +""" + +import unittest + +from script.todo import todo_i18n +from script.todo.todo_upgrade import MIGRATION_STEP, TodoUpgrade + + +class StatusCase(unittest.TestCase): + def setUp(self): + # PAS set_lang() : il persiste la langue dans env_var.sh, suivi par + # git. On écrit la mémoïsation, et on la rend. + self.addCleanup( + setattr, todo_i18n, "_current_lang", todo_i18n._current_lang + ) + todo_i18n._current_lang = "en" + + +class TestUpdateAllStatus(StatusCase): + def status(self, dct): + return TodoUpgrade.step_status(dct, 2) + + def test_an_old_log_knows_only_the_early_flag(self): + # Le cas signalé : un journal écrit avant le correctif. L'étape a bien + # eu lieu, il ne faut pas la dire « non démarrée ». + icon, detail = self.status({"state_1_update_all": True}) + self.assertEqual(icon, "✅") + self.assertIn("early", detail) + + def test_a_new_log_says_when_it_happened(self): + icon, detail = self.status( + { + "state_1_update_all": True, + "state_2_update_all": True, + "state_2_done_early": True, + } + ) + self.assertEqual(icon, "✅") + self.assertIn("early", detail) + + def test_done_at_its_normal_place_says_only_done(self): + icon, detail = self.status({"state_2_update_all": True}) + self.assertEqual(icon, "✅") + self.assertNotIn("early", detail) + + def test_really_not_started(self): + icon, detail = self.status({"state_0_install_odoo": True}) + self.assertEqual(icon, "⬜") + self.assertIn("not started", detail) + + def test_the_other_steps_are_untouched(self): + # Le correctif ne vaut que pour l'étape 2 : les autres gardent leur + # règle, sinon un drapeau d'une étape en éclairerait une autre. + dct = {"state_1_update_all": True} + for step, _ in MIGRATION_STEP: + if step in (1, 2): + continue + icon, _ = TodoUpgrade.step_status(dct, step) + self.assertEqual(icon, "⬜", f"étape {step}") + + +class TestNeedsUpdateAll(unittest.TestCase): + """Ce qui décide de relancer, ou non, une mise à jour de plusieurs heures.""" + + def test_nothing_done_yet(self): + self.assertTrue(TodoUpgrade.needs_update_all({})) + + def test_its_own_flag_is_enough(self): + self.assertFalse( + TodoUpgrade.needs_update_all({"state_2_update_all": True}) + ) + + def test_the_early_flag_survives_a_resume(self): + # LE défaut : la variable de session repart à False à la reprise, et + # seule la trace écrite subsiste. Sans la lire, on relançait tout. + self.assertFalse( + TodoUpgrade.needs_update_all({"state_1_update_all": True}) + ) + + def test_the_session_variable_still_counts(self): + self.assertFalse(TodoUpgrade.needs_update_all({}, True)) + + def test_an_unrelated_flag_does_not_count(self): + self.assertTrue( + TodoUpgrade.needs_update_all({"state_1_restore_database": True}) + ) + + +if __name__ == "__main__": + unittest.main() From fcfbcd25ce160a67dd302f560f0171f2a0cde1c6 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 12 Aug 2026 01:28:50 -0400 Subject: [PATCH 04/67] [IMP] migration: say why Odoo changing its own template is our problem MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The declarations view showed a 12.0 template becoming a 13.0 extension and stopped there. Read alone it says « Odoo changed its code », and the obvious reaction is to ask why that is anyone's problem — which is exactly what it prompted. It is not the problem. On a database without a copy the module upgrade rewrites the view and nothing breaks. It breaks because a COPY exists and froze the old shape. That sentence was missing, and so was the other half of the answer: what the copy actually holds. A copy identical to the view it shadows costs nothing to neutralize; one carrying five lines of theme hooks costs those five lines. Both are now stated where the question arises. Checked on the database mid-migration: the copy that raised the question reports +5/-3, and the two other cases — identical to its twin, and a page with no twin at all — each say so. --- FR --- La vue des déclarations montrait un gabarit 12.0 devenant une extension 13.0 et s'arrêtait là. Lue seule, elle dit « Odoo a changé son code », et la réaction naturelle est de demander en quoi cela nous regarde — ce qu'elle a justement provoqué. Ce n'est pas le problème. Sur une base sans copie, la mise à jour du module réécrit la vue et rien ne casse. Ça casse parce qu'une COPIE existe et a figé l'ancienne forme. Cette phrase manquait, et l'autre moitié de la réponse aussi : ce que la copie contient réellement. Une copie identique à la vue qu'elle masque ne coûte rien à neutraliser ; une qui porte cinq lignes d'ancres de thème coûte ces cinq lignes. Les deux sont dites là où la question se pose. Vérifié sur la base en cours de migration : la copie qui a soulevé la question rapporte +5/-3, et les deux autres cas — identique à sa jumelle, et page sans jumelle — le disent chacun. Assisted-by: Claude Opus 5 --- script/odoo/migration/cow_drift.py | 48 ++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/script/odoo/migration/cow_drift.py b/script/odoo/migration/cow_drift.py index a8c5e85..5972d94 100644 --- a/script/odoo/migration/cow_drift.py +++ b/script/odoo/migration/cow_drift.py @@ -273,12 +273,56 @@ def render_shape(finding): ) lines += [f" -> {shape}", ""] lines += [ - " A copy frozen in one shape cannot be applied in the other: Odoo", - " stops on « cannot be located in parent view ».", + " Odoo changing the shape of its own template is NOT the problem: on a", + " database without a copy, the module upgrade rewrites the view and", + " nothing breaks. It breaks here because a COPY exists and froze the", + " old shape — the copy follows the module and becomes an extension,", + " while still holding a standalone template. Odoo then applies that", + " template as an inheritance spec and stops on « cannot be located in", + " parent view ».", + "", ] + # Sans ceci, cette vue se lit comme « du code Odoo qui change », et l'on + # se demande pourquoi c'est notre affaire. Ce qui la rend nôtre est ce que + # la copie contient — la seule chose que neutraliser ferait perdre. + lines += _what_the_copy_holds(finding) return "\n".join(lines) +def _what_the_copy_holds(finding): + """Ce que la copie porte en propre, dit en une ou deux lignes. + + Relie les deux moitiés de la question : le mécanisme explique POURQUOI ça + casse, ceci dit CE QU'IL EN COÛTE. Une copie identique à sa jumelle ne + coûte rien, et le savoir change la décision. + """ + if finding["module_id"] is None: + return [ + " This copy has no module view of that name: it is a page made in", + " the website editor, and nothing else holds its content.", + ] + left = finding["module_arch"].splitlines() + right = finding["copy_arch"].splitlines() + diff = [ + line + for line in difflib.unified_diff(left, right, lineterm="", n=0) + if line[:1] in "+-" and not line.startswith(("+++", "---")) + ] + if not diff: + return [ + " This copy is IDENTICAL to the module view it shadows: it holds no", + " customization at all, so neutralizing it loses nothing.", + ] + n_plus = sum(1 for x in diff if x.startswith("+")) + n_minus = sum(1 for x in diff if x.startswith("-")) + return [ + f" This copy differs from the module view by +{n_plus}/-{n_minus}" + " line(s):", + " that is the customization, and all that neutralizing gives up.", + " Run without --shape to read it.", + ] + + def render_all(lst_finding, shape=False): """The whole report, one block per finding.""" if not lst_finding: From 4ce612b0084b41a826b4fe5967c18cb4e1094e96 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 12 Aug 2026 01:36:47 -0400 Subject: [PATCH 05/67] [IMP] migration: say when the COW question is actually asked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An early check warns, hours before the bump, that a copy will break it. It then says « arbitrate BEFORE launching the migration » and hands over a raw UPDATE — and no question follows. Read on a resumed run it looks like the question already went by and was skipped, which is exactly how it was read. It now says the migration asks at the bump itself, and shows what each copy holds before the answer. The manual UPDATE stays, but after the two commands that do it reversibly: telling someone to hand-write SQL when --restore exists is offering the sharper tool first. --- FR --- Un contrôle précoce prévient, des heures avant le palier, qu'une copie va le casser. Il dit ensuite « arbitrez AVANT de lancer la migration » et livre un UPDATE brut — et aucune question ne suit. Lu au cours d'une reprise, on croit que la question est passée et a été sautée, ce qui est exactement la lecture qui en a été faite. Il dit maintenant que la migration pose la question au palier lui-même, en montrant ce que chaque copie contient avant qu'on réponde. L'UPDATE manuel reste, mais après les deux commandes qui le font de façon réversible : dire à quelqu'un d'écrire du SQL à la main quand --restore existe, c'est tendre l'outil le plus coupant en premier. Assisted-by: Claude Opus 5 --- script/odoo/migration/check_cow_views.py | 10 ++++++++- script/todo/todo_i18n.py | 27 ++++++++++++++++++++++++ script/todo/todo_upgrade.py | 13 +++++++++++- 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/script/odoo/migration/check_cow_views.py b/script/odoo/migration/check_cow_views.py index 07321a0..d01d2fa 100755 --- a/script/odoo/migration/check_cow_views.py +++ b/script/odoo/migration/check_cow_views.py @@ -313,7 +313,15 @@ def main(): f" : {mode} -> {target_mode} ({reason})" ) print( - " Arbitrate BEFORE launching the migration. To neutralize a copy," + " The migration will offer to neutralize them at the bump, and" + " shows what each copy holds before you answer. To do it now:" + "\n ./script/odoo/migration/cow_drift.py -d DB -t odooXX.0" + " (read what they hold)" + "\n ./script/odoo/migration/neutralize_cow_views.py -d DB" + " -t odooXX.0 --apply (reversible with --restore)" + ) + print( + " Or by hand. To neutralize a copy," " rename its key (UPDATE ir_ui_view SET key='zz_cow_archive.'||key," " active=false): an unmatched key is never paired with the module" " view, so the copy never receives the new inherit_id. Setting" diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index ef08606..8e83355 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -3872,6 +3872,33 @@ TRANSLATIONS = { "fr": "faite plus tôt, avant la neutralisation", "en": "done early, before the neutralization", }, + "compare each website copy with the module view it shadows": { + "fr": "comparer chaque copie de site web à la vue de module qu'elle" + " masque", + "en": "compare each website copy with the module view it shadows", + }, + "Compare the website copies with the view they shadow": { + "fr": "🪞 Comparer les copies de site web à la vue qu'elles masquent", + "en": "🪞 Compare the website copies with the view they shadow", + }, + "website copies compared with their module view,": { + "fr": "copies de site web comparées à leur vue de module,", + "en": "website copies compared with their module view,", + }, + "differ.": {"fr": "diffèrent.", "en": "differ."}, + "Nothing to decide yet": { + "fr": "Rien à décider pour l'instant", + "en": "Nothing to decide yet", + }, + "the migration will offer to neutralize these copies": { + "fr": "la migration proposera de neutraliser ces copies", + "en": "the migration will offer to neutralize these copies", + }, + "at the version bump itself, showing what each one holds.": { + "fr": "au moment du palier lui-même, en montrant ce que chacune" + " contient.", + "en": "at the version bump itself, showing what each one holds.", + }, # --- script/analyse : vues personnalisées --- "Customised views": { "fr": "Vues personnalisées", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index c75a592..0daace6 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -1457,11 +1457,22 @@ class TodoUpgrade: # -- before hours of migration -- leaves time to arbitrate. # Only the next bump can be predicted: the modes in database describe # the current version. - self.todo_upgrade_execute( + status, cmd_executed, output = self.todo_upgrade_execute( f"{PYTHON_BIN} ./script/odoo/migration/check_cow_views.py" f" -d {database_name} -t odoo{start_version + 1}.0", + get_output=True, wait_at_error=False, ) + # Cet avertissement n'est qu'un avertissement, et rien ne le disait : + # il annonce un problème, propose d'arbitrer, et aucune question ne + # suit. On en conclut que la question est passée — alors qu'elle vient + # au moment du palier, des dizaines de minutes plus tard. + if output and "will break when moving to" in "\n".join(output): + print( + f"ℹ -> {t('Nothing to decide yet')} :" + f" {t('the migration will offer to neutralize these copies')}" + f" {t('at the version bump itself, showing what each one holds.')}" + ) msg = "3 - Clean up database before data migration" print(f"🔷 {msg}") From 95532ec96f8367cb8e2ba1a8ef1da027e53b61e0 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 12 Aug 2026 01:47:44 -0400 Subject: [PATCH 06/67] [ADD] migration: go back a step from a prompt, and name the DB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Realising at a prompt that an earlier step deserved another answer had one way out: Ctrl+C. That leaves the progression as it stands and makes you find the resume screen again to rewind. « b » does it properly — it shows the steps, rewinds the state, writes it, and says what to relaunch. Cancelling that must not stop the migration, which is the trap: it returns to the same prompt, exactly where you were. Removing that guard makes two tests fail. The COW warning printed « -d DB -t odooXX.0 » for commands meant to be pasted. It knows both values, so it prints them, and offers --shape as well since that is half the answer. Checked on the four outcomes: a normal answer passes through, « b » then a step rewinds and stops, cancelling and an unknown step both continue. 15 tests. --- FR --- S'apercevoir à une invite qu'une étape antérieure méritait un autre choix n'avait qu'une issue : Ctrl+C. Cela laisse la progression telle quelle et oblige à retrouver l'écran de reprise pour rembobiner. « b » le fait proprement — il montre les étapes, rembobine l'état, l'écrit, et dit quoi relancer. Y renoncer ne doit pas arrêter la migration, et c'est le piège : on revient à la même invite, exactement là où l'on était. Retirer ce garde-fou fait tomber deux tests. L'avertissement COW affichait « -d DB -t odooXX.0 » pour des commandes faites pour être collées. Il connaît les deux valeurs, donc il les écrit, et propose aussi --shape puisque c'est la moitié de la réponse. Vérifié sur les quatre issues : une réponse normale passe, « b » puis une étape rembobine et arrête, annuler et un choix inconnu continuent. 15 tests. Assisted-by: Claude Opus 5 --- script/odoo/migration/check_cow_views.py | 19 ++++-- script/todo/todo.py | 12 +++- script/todo/todo_i18n.py | 21 ++++++ script/todo/todo_upgrade.py | 69 ++++++++++++++++++-- test/test_todo_upgrade_steps.py | 82 ++++++++++++++++++++++++ 5 files changed, 193 insertions(+), 10 deletions(-) diff --git a/script/odoo/migration/check_cow_views.py b/script/odoo/migration/check_cow_views.py index d01d2fa..3acca71 100755 --- a/script/odoo/migration/check_cow_views.py +++ b/script/odoo/migration/check_cow_views.py @@ -288,6 +288,8 @@ def main(): lst_at_risk, lst_module_absent, lst_no_counterpart = analyse( config.database, config.target_version ) + database = config.database + target_version = config.target_version if not lst_at_risk: print( @@ -312,13 +314,20 @@ def main(): f" - id={view_id} website={website_id} {key}" f" : {mode} -> {target_mode} ({reason})" ) + # La base et la version cible sont connues ici : les remplacer par + # « DB » et « odooXX.0 » oblige à les retrouver, au moment précis où + # l'on veut juste copier-coller la commande. print( " The migration will offer to neutralize them at the bump, and" - " shows what each copy holds before you answer. To do it now:" - "\n ./script/odoo/migration/cow_drift.py -d DB -t odooXX.0" - " (read what they hold)" - "\n ./script/odoo/migration/neutralize_cow_views.py -d DB" - " -t odooXX.0 --apply (reversible with --restore)" + " shows what each copy holds before you answer. To look now," + " without interrupting anything:" + f"\n ./script/odoo/migration/cow_drift.py -d {database}" + f" -t {target_version} (what each copy holds)" + f"\n ./script/odoo/migration/cow_drift.py -d {database}" + f" -t {target_version} --shape (why it breaks)" + f"\n ./script/odoo/migration/neutralize_cow_views.py" + f" -d {database} -t {target_version} --apply" + " (reversible with --restore)" ) print( " Or by hand. To neutralize a copy," diff --git a/script/todo/todo.py b/script/todo/todo.py index 321ddf4..ae148e7 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -889,7 +889,17 @@ class TODO: return False elif status == str(len(choices) - 1): upgrade = todo_upgrade.TodoUpgrade(self) - upgrade.execute_odoo_upgrade() + try: + upgrade.execute_odoo_upgrade() + except todo_upgrade.MigrationRewind: + # L'état est déjà rembobiné et écrit : il ne reste qu'à + # relancer, et l'écran de reprise repartira de l'étape + # choisie. Sortir d'ici plutôt que de rappeler la méthode + # évite de la reprendre au milieu de son état local. + print( + f"\n⏪ {t('Rewound.')}" + f" {t('Relaunch the migration to resume from there.')}" + ) elif status == str(len(choices)): self.upgrade_poetry() else: diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 8e83355..7f45853 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -3899,6 +3899,27 @@ TRANSLATIONS = { " contient.", "en": "at the version bump itself, showing what each one holds.", }, + "Press to continue": { + "fr": "Appuyez pour continuer", + "en": "Press to continue", + }, + "(b = go back to a previous step)": { + "fr": "(b = revenir à une étape précédente)", + "en": "(b = go back to a previous step)", + }, + "Neutralize database, press to continue": { + "fr": "Neutraliser la base, appuyez pour continuer", + "en": "Neutralize database, press to continue", + }, + "Replay from which step? (empty to cancel)": { + "fr": "Rejouer à partir de quelle étape ? (vide pour annuler)", + "en": "Replay from which step? (empty to cancel)", + }, + "Rewound.": {"fr": "Rembobiné.", "en": "Rewound."}, + "Relaunch the migration to resume from there.": { + "fr": "Relancez la migration pour reprendre à partir de là.", + "en": "Relaunch the migration to resume from there.", + }, # --- script/analyse : vues personnalisées --- "Customised views": { "fr": "Vues personnalisées", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index 0daace6..1437fc0 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -72,6 +72,17 @@ MIGRATION_STEP = [ ] +class MigrationRewind(Exception): + """L'utilisateur a demandé de revenir à une étape antérieure. + + Une exception, et non un code de retour : la demande peut venir de + n'importe laquelle des invites, à n'importe quelle profondeur d'une + méthode de mille lignes. La faire remonter par des valeurs de retour + obligerait chaque appelant intermédiaire à la reconnaître et à la + propager — autant d'endroits où l'oublier. + """ + + class TodoUpgrade: def __init__(self, todo): self.file_path = None @@ -669,6 +680,47 @@ class TodoUpgrade: ] return dct_kept + def ask_gate(self, prompt): + """Une invite d'attente, avec une porte de sortie vers l'arrière. + + Ces invites ne demandent qu'à continuer. Quand on s'aperçoit à ce + moment-là qu'une étape précédente méritait un autre choix, la seule + issue était Ctrl+C — qui laisse la progression telle quelle et oblige + à retrouver l'écran de reprise. « b » fait le travail proprement : + il rembobine l'état, l'écrit, et s'arrête en disant quoi relancer. + """ + while True: + answer = input(prompt) + if (answer or "").strip().lower() != "b": + return answer + if self.rewind_to_chosen_step(): + raise MigrationRewind() + # Renoncer au retour en arrière ne doit pas arrêter la migration : + # on revient à la même invite, exactement là où l'on était. + + def rewind_to_chosen_step(self): + """Demander l'étape et rembobiner jusqu'à elle. Écrit la progression.""" + ctx = self.resume_context(self.dct_progression) + print() + print(f"📍 {t('Migration in progress')}") + for item in ctx["steps"]: + print( + f" [{item['step']}] {item['icon']} {item['label']:<44}" + f" {item['detail']}" + ) + answer = input( + f"💬 {t('Replay from which step? (empty to cancel)')} : " + ) + answer = (answer or "").strip() + if not answer.isdigit() or int(answer) > MIGRATION_STEP[-1][0]: + print(f"⚠️ {t('Unknown choice, continuing where it stopped')}.") + return False + self.dct_progression = self.rewind_progression( + self.dct_progression, int(answer) + ) + self.write_config() + return True + @staticmethod def rewind_progression(old_dct_progression, step): """Drop the progression of `step` and of every later step. @@ -1276,7 +1328,11 @@ class TodoUpgrade: if not self.dct_progression.get("state_1_neutralize_database"): print("[1] Ignore neutralize database") wait_continue = ( - input("💬 Neutralize database, press to continue : ") + self.ask_gate( + "💬 " + + t("Neutralize database, press to continue") + + f" {t('(b = go back to a previous step)')} : " + ) .strip() .lower() ) @@ -1491,8 +1547,10 @@ class TodoUpgrade: print( "✨ Aller dans «configuration/Technique/Nettoyage.../Purger» les modules obsolètes" ) - status = input( - "💬 Did you finish to clean database? Press y/Y to open server with selenium, else ignore it : " + status = self.ask_gate( + "💬 Did you finish to clean database? Press y/Y to open" + " server with selenium, else ignore it" + f" {t('(b = go back to a previous step)')} : " ).strip() if status.lower().strip() == "y": @@ -2200,7 +2258,10 @@ class TodoUpgrade: print( f"🚸 Please, validate this path into config.conf : '{path_addons_openupgrade}'." ) - status = input(f"💬 Press to continue {msg} : ").strip() + status = self.ask_gate( + f"💬 {t('Press to continue')} {msg}" + f" {t('(b = go back to a previous step)')} : " + ).strip() # The technique change at version 14 if next_version <= 13: erplibre_version = self.install_OCA_openupgrade( diff --git a/test/test_todo_upgrade_steps.py b/test/test_todo_upgrade_steps.py index 5e5d68d..58db583 100644 --- a/test/test_todo_upgrade_steps.py +++ b/test/test_todo_upgrade_steps.py @@ -100,5 +100,87 @@ class TestNeedsUpdateAll(unittest.TestCase): ) +class TestBackGate(unittest.TestCase): + """Revenir à une étape précédente depuis une invite en cours de route. + + Ces invites ne demandaient qu'à continuer. S'apercevoir à ce moment-là + qu'une étape antérieure méritait un autre choix n'avait qu'une issue : + Ctrl+C, qui laisse la progression telle quelle et oblige à retrouver + l'écran de reprise. « b » fait le travail proprement. + """ + + def setUp(self): + import os + import tempfile + + self.addCleanup(os.chdir, os.getcwd()) + os.chdir(tempfile.mkdtemp()) + os.makedirs(".venv.erplibre", exist_ok=True) + self.addCleanup( + setattr, todo_i18n, "_current_lang", todo_i18n._current_lang + ) + todo_i18n._current_lang = "en" + + def gate(self, answers): + """(a arrêté ?, valeur rendue, progression) après ces réponses.""" + import builtins + import contextlib + import io + + from script.todo.todo_upgrade import MigrationRewind + + upgrade = TodoUpgrade.__new__(TodoUpgrade) + upgrade.dct_progression = { + "config_database_name": "db", + "state_0_install_odoo": True, + "state_1_restore_database": True, + "state_2_update_all": True, + "state_3_clean_database": True, + } + upgrade.lst_command_executed = [] + seq = iter(answers) + original = builtins.input + builtins.input = lambda *a: next(seq) + self.addCleanup(setattr, builtins, "input", original) + stopped, returned = False, None + try: + with contextlib.redirect_stdout(io.StringIO()): + returned = upgrade.ask_gate("? ") + except MigrationRewind: + stopped = True + return stopped, returned, upgrade.dct_progression + + def test_a_normal_answer_passes_straight_through(self): + stopped, returned, _ = self.gate(["y"]) + self.assertFalse(stopped) + self.assertEqual(returned, "y") + + def test_b_then_a_step_rewinds_and_stops(self): + stopped, _, progression = self.gate(["b", "2"]) + self.assertTrue(stopped) + # L'étape choisie et les suivantes sont effacées, les précédentes non. + self.assertIn("state_1_restore_database", progression) + self.assertNotIn("state_2_update_all", progression) + self.assertNotIn("state_3_clean_database", progression) + + def test_cancelling_the_rewind_does_not_stop_the_migration(self): + # LE piège : renoncer au retour en arrière arrêtait quand même tout. + # On revient à la même invite, exactement là où l'on était. + stopped, returned, progression = self.gate(["b", "", "y"]) + self.assertFalse(stopped) + self.assertEqual(returned, "y") + self.assertIn("state_3_clean_database", progression) + + def test_an_unknown_step_is_not_a_rewind(self): + stopped, returned, progression = self.gate(["b", "zzz", ""]) + self.assertFalse(stopped) + self.assertEqual(returned, "") + self.assertIn("state_3_clean_database", progression) + + def test_the_answer_is_case_insensitive(self): + stopped, _, _ = self.gate(["B", "1"]) + self.assertTrue(stopped) + + if __name__ == "__main__": unittest.main() From bcdc19b506463923ad2807b0466e5499601d627c Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 12 Aug 2026 01:58:38 -0400 Subject: [PATCH 07/67] =?UTF-8?q?[ADD]=20migration:=20offer=20=C2=AB=20go?= =?UTF-8?q?=20back=20to=20a=20step=20=C2=BB=20on=20the=20resume=20screen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The screen could already replay from any step -- Enter on a row does it -- but that lived in a hint line under four named buttons. A capability you have to guess is not offered, and it was reported as missing. It now has a key, a button and a message, like the four others. The b key added earlier went to the line-by-line prompts during the run, not to this screen. Same letter on both, so going back is one key wherever you are. Two defects behind it. Replaying the chosen step actually SKIPPED it, resuming at the next one. And a replayed step did not rewind the variables it owns, so the second run started from the first one's leftovers. Checked in a simulated terminal: b focuses the table, two arrows and Enter return step 2, and the button focuses without choosing. --- FR --- L'écran savait déjà rejouer depuis n'importe quelle étape — Entrée sur une rangée le fait — mais cela vivait dans une ligne d'indication sous quatre boutons nommés. Une capacité qu'il faut deviner n'est pas offerte, et elle a été signalée manquante. Elle a maintenant une touche, un bouton et un message, comme les quatre autres. La touche b ajoutée plus tôt s'adressait aux invites ligne à ligne pendant l'exécution, pas à cet écran. Même lettre sur les deux : revenir en arrière est une seule touche, où qu'on soit. Deux défauts derrière cela. Rejouer l'étape choisie la SAUTAIT en fait, reprenant à la suivante. Et une étape rejouée ne remettait pas à zéro les variables qui lui appartiennent, si bien que la seconde exécution partait des restes de la première. Vérifié en terminal simulé : b donne le focus au tableau, deux flèches et Entrée ramènent l'étape 2, et le bouton met le focus sans choisir. Assisted-by: Claude Opus 5 --- script/todo/migration_form.py | 20 ++- script/todo/todo_i18n.py | 8 + script/todo/todo_upgrade.py | 123 ++++++++++++--- test/test_migration_form_back.py | 119 +++++++++++++++ test/test_todo_upgrade_steps.py | 253 +++++++++++++++++++++++++++++++ 5 files changed, 505 insertions(+), 18 deletions(-) create mode 100644 test/test_migration_form_back.py diff --git a/script/todo/migration_form.py b/script/todo/migration_form.py index 98a150c..d320208 100644 --- a/script/todo/migration_form.py +++ b/script/todo/migration_form.py @@ -68,6 +68,7 @@ def run_resume_tui(ctx, run_app: bool = True): """ BINDINGS = [ ("c", "cont", t("Continue where it stopped")), + ("b", "back_step", t("Go back to a step")), ("n", "new", t("New migration, erase everything")), ("r", "keep_zip", t("Keep the zip only")), ("q", "quit_nothing", t("Quit without doing anything")), @@ -99,10 +100,12 @@ def run_resume_tui(ctx, run_app: bool = True): id="a_cont", ) yield Button(t("New migration"), id="a_new") + yield Button(t("Go back to a step"), id="a_back") yield Button(t("Keep the zip only"), id="a_keep") yield Button(t("Quit"), id="a_quit") yield Static( - f" {t('Enter on a step or a version = replay from there')}", + f" {t('Enter on a step or a version = replay from there')}" + f" · b = {t('Go back to a step')}", id="hint", ) yield Footer() @@ -156,6 +159,9 @@ def run_resume_tui(ctx, run_app: bool = True): self._answer(f"4.{event.option.id}") def on_button_pressed(self, event) -> None: + if event.button.id == "a_back": + self.action_back_step() + return mapping = { "a_cont": "c", "a_new": "n", @@ -166,6 +172,18 @@ def run_resume_tui(ctx, run_app: bool = True): if value: self._answer(value) + def action_back_step(self) -> None: + """Amener au tableau des étapes, où le choix se fait déjà. + + Le mécanisme existait — Entrée sur une ligne — mais vivait dans une + ligne d'astuce sous quatre boutons nommés. Une capacité qu'il faut + deviner n'est pas offerte ; celle-ci porte donc une touche, un + bouton et un message, comme les autres. + """ + table = self.query_one("#steps", DataTable) + table.focus() + self.notify(t("Choose a step, Enter replays from there.")) + def action_cont(self) -> None: self._answer("c") diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 7f45853..f920d86 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -3920,6 +3920,14 @@ TRANSLATIONS = { "fr": "Relancez la migration pour reprendre à partir de là.", "en": "Relaunch the migration to resume from there.", }, + "Go back to a step": { + "fr": "Revenir à une étape", + "en": "Go back to a step", + }, + "Choose a step, Enter replays from there.": { + "fr": "Choisissez une étape, Entrée rejoue à partir de là.", + "en": "Choose a step, Enter replays from there.", + }, # --- script/analyse : vues personnalisées --- "Customised views": { "fr": "Vues personnalisées", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index 1437fc0..b6bfbba 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -59,10 +59,11 @@ LOCAL_MANIFEST = os.path.join( # be dropped depends on the database, so that choice is never versioned. PATH_MIGRATION_GLOBAL = os.path.join("script", "odoo", "migration") PATH_MIGRATION_PRIVATE = os.path.join("private", "odoo", "migration") -# Steps of the migration, in order. Each one owns the progression keys prefixed -# with « state_ ». Rewinding to a step drops its keys and every later -# one, so the run replays from there. Labels go through t(): the key IS the -# English string, as everywhere else in this project. +# Steps of the migration, in order. What each one owns is declared just below, +# by GLOBAL_PROGRESSION_KEY and STEP_OWNED_KEY — not by the prefix alone, which +# lies on some keys and is missing on others. Rewinding to a step drops +# everything it and the later steps own, so the run really replays from there. +# Labels go through t(): the key IS the English string, as everywhere else. MIGRATION_STEP = [ (0, "Prepare the environment"), (1, "Restore and neutralize the database"), @@ -72,6 +73,71 @@ MIGRATION_STEP = [ ] +# Ce que le journal garde quoi qu'il arrive : des décisions et de la +# métadonnée, jamais du progrès. Rembobiner ne doit pas faire oublier QUELLE +# base on migre ni vers quelle version — on repartirait sur autre chose. +GLOBAL_PROGRESSION_KEY = frozenset( + { + "command_executed", + "config_database_name", + "config_migrate_repo", + "date_create", + "date_update", + "migration_file", + "target_odoo_version", + } +) + +# Clés que leur nom ne rattache à aucune étape, et l'étape qui les produit. +# +# Tout le reste s'auto-décrit : `state__` et `config_state__` nomment +# leur étape. Ces huit-là sont les sorties de la recherche de modules de +# l'étape 0 ; `dct_module_exist` est ensuite lu par l'étape 4. +# +# `state_1_update_all` est l'exception inverse : son nom dit 1, son travail +# est celui de l'étape 2 — c'est la mise à jour précoce, offerte avant la +# neutralisation quand la base vient d'une vieille version. +STEP_OWNED_KEY = { + 0: ( + "dct_module_exist", + "dct_module_per_version", + "len_dct_module_exist", + "len_lst_module_duplicate", + "len_lst_module_missing", + "lst_module_duplicate", + "lst_module_missing", + "lst_module_per_version_origin", + ), + 2: ("state_1_update_all",), +} + +# Réponses de l'étape 4 indexées par bump de version, une entrée par palier — +# exactement comme les drapeaux `state_4_*_odoo_lst`. Leur nom ne le dit pas, +# d'où cette liste : rejouer un palier sans les vider réutilisait en silence +# les modules choisis au passage précédent. +STEP_4_PER_BUMP_KEY = ( + "config_state_4_install_module", + "config_state_4_module_to_migrate_code", + "config_state_4_uninstall_module", +) + +STEP_PREFIX_RE = re.compile(r"^(?:config_)?state_(\d+)_") + + +def flag_step(key): + """Étape dont relève une clé du journal, ou None si elle est globale. + + Le préfixe ne suffit pas : une clé peut le porter à tort + (`state_1_update_all`) ou ne rien porter du tout (les listes de modules). + La table tranche d'abord, le nom ensuite. + """ + for step, lst_key in STEP_OWNED_KEY.items(): + if key in lst_key: + return step + match = STEP_PREFIX_RE.match(key) + return int(match.group(1)) if match else None + + class MigrationRewind(Exception): """L'utilisateur a demandé de revenir à une étape antérieure. @@ -666,18 +732,32 @@ class TodoUpgrade: the point — the intermediate database of a failed bump is half migrated, so it must be dropped and rebuilt from the previous version rather than upgraded again. + + The answers given for a bump — STEP_4_PER_BUMP_KEY, indexed exactly + like the flags — are trimmed too. They were not, so replaying a bump + reused the modules chosen the previous time and asked nothing. """ dct_kept = dict(old_dct_progression) for key, value in old_dct_progression.items(): - if ( + if not isinstance(value, list): + continue + per_bump = ( key.startswith("state_4_") - and isinstance(value, list) and key.endswith(("_odoo_lst", "_module")) - ): - dct_kept[key] = [ - item if i < index else False - for i, item in enumerate(value) - ] + ) or key in STEP_4_PER_BUMP_KEY + if not per_bump: + continue + # Vider en gardant le type : `config_state_4_module_to_migrate_code` + # porte des listes, sur lesquelles le code fait .append() sans les + # avoir revues. Un False à leur place plante le palier rejoué. + dct_kept[key] = [ + ( + item + if i < index + else ([] if isinstance(item, list) else False) + ) + for i, item in enumerate(value) + ] return dct_kept def ask_gate(self, prompt): @@ -723,18 +803,27 @@ class TodoUpgrade: @staticmethod def rewind_progression(old_dct_progression, step): - """Drop the progression of `step` and of every later step. + """Drop everything `step` and the later steps own, not just their flags. - Configuration answers (config_*), the zip and the target version are - kept: they are decisions, not progress. Only « state_* » is rewound. + Rewinding used to keep every key that was not « state_* ». The chosen + step replayed, then the ones after it ran on the previous course's + leftovers: the per-bump answers of step 4 — `config_state_4_*`, one + entry per version bump — were still there, so the questions were not + asked again and the work was taken for decided. + + So ownership is declared rather than guessed: GLOBAL_PROGRESSION_KEY + survives everything, STEP_OWNED_KEY and the « state__ » prefixes + say the rest. A key belonging to no one is progress by default and + goes: a replay that keeps an unknown leftover is the very defect + above, and `test_todo_upgrade_steps` fails on any unclassified key. """ dct_kept = {} for key, value in old_dct_progression.items(): - if not key.startswith("state_"): + if key in GLOBAL_PROGRESSION_KEY: dct_kept[key] = value continue - index = key[len("state_") :].split("_", 1)[0] - if index.isdigit() and int(index) < step: + index = flag_step(key) + if index is not None and index < step: dct_kept[key] = value # The module search fills an in-memory dict the later steps rely on; # it must run again even when step 0 itself is kept. diff --git a/test/test_migration_form_back.py b/test/test_migration_form_back.py new file mode 100644 index 0000000..0e9e919 --- /dev/null +++ b/test/test_migration_form_back.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""L'écran de reprise doit OFFRIR le retour à une étape, pas le cacher. + +Le mécanisme existait — Entrée sur une ligne du tableau — mais vivait dans une +ligne d'astuce sous quatre boutons nommés. Une capacité qu'il faut deviner +n'est pas offerte : elle a donc une touche, un bouton et un message, comme les +quatre autres actions. +""" + +import json +import unittest + +from script.todo import todo_i18n +from script.todo.migration_form import run_resume_tui +from script.todo.todo_upgrade import TodoUpgrade + + +def context(): + """Le contexte d'une migration arrêtée avant les montées de version.""" + progression = { + "migration_file": "./image_db/technolibre.zip", + "config_database_name": "technolibre_migration_01_neutralize", + "target_odoo_version": "18.0", + "date_create": "2026-08-12 05:04:22", + "state_0_install_odoo": True, + "state_1_restore_database": True, + "state_2_update_all": True, + "state_3_clean_database": True, + } + return TodoUpgrade.resume_context(TodoUpgrade, progression) + + +class TestBackIsOffered(unittest.TestCase): + def setUp(self): + self.addCleanup( + setattr, todo_i18n, "_current_lang", todo_i18n._current_lang + ) + todo_i18n._current_lang = "en" + self.ctx = context() + + def bindings(self, app): + out = {} + for item in app.BINDINGS: + if isinstance(item, tuple): + out[item[0]] = item[1] + else: + out[item.key] = item.action + return out + + def test_b_is_a_binding_like_the_others(self): + app = run_resume_tui(self.ctx, run_app=False) + self.assertEqual(self.bindings(app).get("b"), "back_step") + + def test_the_action_exists(self): + self.assertTrue( + hasattr( + run_resume_tui(self.ctx, run_app=False), "action_back_step" + ) + ) + + +class TestBackWorks(unittest.IsolatedAsyncioTestCase): + """Piloté dans un terminal simulé : la touche mène bien au choix.""" + + def setUp(self): + self.addCleanup( + setattr, todo_i18n, "_current_lang", todo_i18n._current_lang + ) + todo_i18n._current_lang = "en" + self.ctx = context() + + async def test_b_focuses_the_steps_and_enter_replays_from_there(self): + from textual.widgets import DataTable + + app = run_resume_tui(self.ctx, run_app=False) + async with app.run_test() as pilot: + await pilot.press("b") + await pilot.pause() + table = app.query_one("#steps", DataTable) + self.assertTrue(table.has_focus) + # Le curseur part sur la première étape inachevée ; on remonte de + # deux pour viser « mettre à jour tous les modules ». + await pilot.press("up") + await pilot.press("up") + await pilot.pause() + expected = str(self.ctx["steps"][table.cursor_row]["step"]) + await pilot.press("enter") + await pilot.pause() + self.assertEqual(app._result["answer"], expected) + + async def test_there_is_a_button_too(self): + # C'est l'absence VISIBLE qui a été signalée : une touche seule ne se + # voit pas parmi quatre boutons. Les widgets ne se lisent qu'une fois + # l'écran monté, d'où le pilote. + from textual.widgets import Button + + app = run_resume_tui(self.ctx, run_app=False) + async with app.run_test(): + labels = [b.label.plain for b in app.query(Button)] + self.assertIn("Go back to a step", labels) + + async def test_the_button_does_the_same(self): + from textual.widgets import Button, DataTable + + app = run_resume_tui(self.ctx, run_app=False) + async with app.run_test() as pilot: + button = next(b for b in app.query(Button) if b.id == "a_back") + await pilot.click(button) + await pilot.pause() + self.assertTrue(app.query_one("#steps", DataTable).has_focus) + # Et surtout : le bouton ne répond PAS à la place de l'utilisateur. + self.assertIsNone(app._result["answer"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_todo_upgrade_steps.py b/test/test_todo_upgrade_steps.py index 58db583..5f6efc2 100644 --- a/test/test_todo_upgrade_steps.py +++ b/test/test_todo_upgrade_steps.py @@ -100,6 +100,259 @@ class TestNeedsUpdateAll(unittest.TestCase): ) +class TestRewindReallyReplays(StatusCase): + """Rembobiner doit REJOUER l'étape, pas seulement effacer sa trace. + + Un journal venant d'une vieille base porte `state_1_update_all` : la mise + à jour précoce, offerte avant la neutralisation. Le travail est celui de + l'étape 2, seul le nom dit 1. Rembobiner à l'étape 2 gardait donc ce + drapeau — son préfixe le rangeait dans l'étape 1 — et l'étape sautait + alors qu'on venait de demander à la rejouer. + + Effacer les clés ne suffit pas à le prouver : il faut interroger les + gardes qui décident du travail. + """ + + def journal(self): + """La forme d'un vrai journal ayant fait la mise à jour précoce.""" + return { + "config_database_name": "db", + "state_0_install_odoo": True, + "state_0_search_missing_module": True, + "state_1_restore_database": True, + "state_1_update_all": True, + "state_1_neutralize_database": True, + "state_3_clean_database": True, + "state_4_upgrade_odoo_lst": [True, False], + } + + def rewind(self, step): + import contextlib + import io + + with contextlib.redirect_stdout(io.StringIO()): + return TodoUpgrade.rewind_progression(self.journal(), step) + + def test_going_back_to_step_2_runs_the_update_again(self): + # LE défaut signalé : « il continue en ignorant l'étape que j'ai + # choisie ». Trois heures de mise à jour silencieusement sautées. + self.assertTrue( + TodoUpgrade.needs_update_all(self.rewind(2)), + "rembobiner à l'étape 2 doit rejouer la mise à jour", + ) + + def test_the_earlier_steps_replay_it_too(self): + for step in (0, 1): + self.assertTrue( + TodoUpgrade.needs_update_all(self.rewind(step)), + f"étape {step}", + ) + + def test_going_back_to_a_later_step_leaves_it_done(self): + # Le symétrique : reculer à l'étape 3 ne doit PAS relancer l'étape 2. + for step in (3, 4): + self.assertFalse( + TodoUpgrade.needs_update_all(self.rewind(step)), + f"étape {step}", + ) + + def test_no_work_guard_of_the_chosen_step_or_later_survives(self): + # Les gardes sont lues dans la SOURCE : une garde ajoutée plus tard + # sera couverte sans que ce test soit retouché. + import ast + + from script.todo import todo_upgrade + + guards = set() + with open(todo_upgrade.__file__) as handle: + tree = ast.parse(handle.read()) + for node in ast.walk(tree): + if not ( + isinstance(node, ast.If) and isinstance(node.test, ast.UnaryOp) + ): + continue + for sub in ast.walk(node.test): + if ( + isinstance(sub, ast.Call) + and isinstance(sub.func, ast.Attribute) + and sub.func.attr == "get" + and sub.args + and isinstance(sub.args[0], ast.Constant) + and str(sub.args[0].value).startswith("state_") + ): + guards.add(str(sub.args[0].value)) + self.assertTrue( + guards, "aucune garde trouvée : le test ne prouve rien" + ) + for step, _ in MIGRATION_STEP: + kept = self.rewind(step) + still_closed = [ + key + for key in sorted(guards) + if todo_upgrade.flag_step(key) >= step and kept.get(key) + ] + self.assertEqual(still_closed, [], f"étape {step}") + + +class TestEveryKeyBelongsToSomeone(unittest.TestCase): + """Aucune clé du journal ne doit échapper au classement. + + Rembobiner ne gardait que ce qui ne commençait pas par « state_ ». Vingt- + cinq clés passaient donc au travers, dont les réponses par palier de + l'étape 4 : l'étape choisie se rejouait, les suivantes repartaient sur les + décisions de la course précédente et ne demandaient plus rien. + + Ce test lit les clés dans la SOURCE. Une clé ajoutée demain sans être + classée le fera échouer, au lieu de survivre en silence au rembobinage. + """ + + def keys_in_source(self): + import ast + + from script.todo import todo_upgrade + + with open(todo_upgrade.__file__) as handle: + tree = ast.parse(handle.read()) + keys = set() + for node in ast.walk(tree): + if isinstance(node, ast.Assign): + for target in node.targets: + if ( + isinstance(target, ast.Subscript) + and isinstance(target.value, ast.Attribute) + and target.value.attr == "dct_progression" + and isinstance(target.slice, ast.Constant) + ): + keys.add(str(target.slice.value)) + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "get" + and node.args + and isinstance(node.args[0], ast.Constant) + ): + owner = node.func.value + name = getattr(owner, "attr", getattr(owner, "id", "")) + if "progression" in str(name): + keys.add(str(node.args[0].value)) + return keys + + def test_no_key_is_left_unclassified(self): + from script.todo import todo_upgrade + + keys = self.keys_in_source() + self.assertGreater(len(keys), 30, "l'extraction n'a rien trouvé") + orphans = sorted( + key + for key in keys + if key not in todo_upgrade.GLOBAL_PROGRESSION_KEY + and todo_upgrade.flag_step(key) is None + ) + self.assertEqual( + orphans, [], "à classer : global, ou à quelle étape ?" + ) + + def test_a_key_is_never_both_global_and_owned(self): + from script.todo import todo_upgrade + + for key in sorted(todo_upgrade.GLOBAL_PROGRESSION_KEY): + self.assertIsNone(todo_upgrade.flag_step(key), key) + + +class TestRewindDropsTheAnswersToo(StatusCase): + """« Il faut aussi revert les variables mises à jour entre les étapes. »""" + + def journal(self): + return { + "config_database_name": "db", + "target_odoo_version": "18.0", + "migration_file": "a.zip", + "config_state_1_install_module": ["sale"], + "config_state_4_install_module": [False, ["stock"]], + "config_state_4_uninstall_module": [False, False], + "config_state_4_module_to_migrate_code": [[], ["m"]], + "dct_module_exist": {"sale": "1.0"}, + "state_0_install_odoo": True, + "state_2_update_all": True, + } + + def rewind(self, step): + import contextlib + import io + + with contextlib.redirect_stdout(io.StringIO()): + return TodoUpgrade.rewind_progression(self.journal(), step) + + def test_the_step_4_answers_go_when_step_4_is_replayed(self): + # LE défaut signalé : l'étape rejouée marchait, les suivantes non. + kept = self.rewind(2) + for key in ( + "config_state_4_install_module", + "config_state_4_uninstall_module", + "config_state_4_module_to_migrate_code", + ): + self.assertNotIn(key, kept, key) + + def test_the_step_1_answers_stay_when_step_1_is_kept(self): + # Le symétrique : rembobiner à 2 ne doit pas re-demander l'étape 1. + self.assertIn("config_state_1_install_module", self.rewind(2)) + self.assertNotIn("config_state_1_install_module", self.rewind(1)) + + def test_the_step_0_module_search_output_goes_with_step_0(self): + # `dct_module_exist` ne nomme aucune étape mais est produit par la 0, + # et relu par la 4 : le garder après un rembobinage à 0 ferait juger + # la 4 sur un inventaire périmé. + self.assertNotIn("dct_module_exist", self.rewind(0)) + self.assertIn("dct_module_exist", self.rewind(1)) + + def test_the_decisions_survive_every_rewind(self): + # Sans elles on ne saurait plus QUELLE base on migre : le rembobinage + # deviendrait une autre migration. + for step, _ in MIGRATION_STEP: + kept = self.rewind(step) + for key in ("config_database_name", "target_odoo_version"): + self.assertIn(key, kept, f"{key} à l'étape {step}") + + +class TestRewindOneVersionBump(unittest.TestCase): + """Rejouer un palier de l'étape 4 doit aussi rendre ses réponses.""" + + def journal(self): + return { + "state_4_clone_odoo_lst": [True, True, True], + "state_4_uninstall_module": [True, True, False], + "config_state_4_install_module": [["sale"], ["stock"], False], + "config_state_4_module_to_migrate_code": [["m1"], ["m2"], []], + "state_4_lst_module_missing": ["a", "b"], + "state_4_len_lst_module_missing": 40, + } + + def test_the_answers_of_the_replayed_bumps_are_cleared(self): + kept = TodoUpgrade.rewind_version_bump(self.journal(), 1) + self.assertEqual( + kept["config_state_4_install_module"][1:], [[], False] + ) + self.assertEqual(kept["config_state_4_install_module"][0], ["sale"]) + + def test_a_list_entry_stays_a_list(self): + # Le code fait .append() sur cette entrée sans l'avoir revue : un + # False à sa place ferait planter le palier qu'on vient de rejouer. + kept = TodoUpgrade.rewind_version_bump(self.journal(), 1) + for entry in kept["config_state_4_module_to_migrate_code"]: + self.assertIsInstance(entry, list) + + def test_a_flat_list_is_not_a_per_bump_list(self): + # `state_4_lst_module_missing` est une liste de modules, pas un + # tableau par palier : la tronquer perdrait l'inventaire. + kept = TodoUpgrade.rewind_version_bump(self.journal(), 1) + self.assertEqual(kept["state_4_lst_module_missing"], ["a", "b"]) + self.assertEqual(kept["state_4_len_lst_module_missing"], 40) + + def test_the_earlier_bumps_are_untouched(self): + kept = TodoUpgrade.rewind_version_bump(self.journal(), 2) + self.assertEqual(kept["state_4_clone_odoo_lst"], [True, True, False]) + + class TestBackGate(unittest.TestCase): """Revenir à une étape précédente depuis une invite en cours de route. From 614f539489c049e656fafced421b056b05277a8c Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 13 Aug 2026 03:12:09 -0400 Subject: [PATCH 08/67] [ADD] migration: act on the COW copies when they are announced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 2 announced the copies the next bump will break, invited to arbitrate, then moved on: the question came at the bump, tens of minutes later. Yet looking writes nothing, and neutralizing here covers every bump -- each bump database is a clone of this one. The announcement is now a prompt: view what a copy holds, why it breaks, full screen, or neutralize right away. Both prompts build their command in one place, so they cannot drift apart. Full screen means a real terminal: the viewer was started without one and drew nothing, the pager exiting at once on a pipe. --- FR --- L'étape 2 annonçait les copies que le palier suivant casserait, invitait à arbitrer, puis passait : la question venait au palier, des dizaines de minutes plus tard. Or regarder n'écrit rien, et neutraliser ici vaut pour tous les paliers — chaque base de palier est un clone de celle-ci. L'annonce est désormais une invite : voir ce que porte une copie, pourquoi elle casse, en plein écran, ou neutraliser tout de suite. Les deux invites composent leur commande au même endroit, elles ne peuvent donc pas diverger. Le plein écran suppose un vrai terminal : la vue était lancée sans, et ne dessinait rien, le pagineur sortant aussitôt sur un tube. Assisted-by: Claude Opus 5 --- script/todo/todo_i18n.py | 856 +++++++++++++++++++++++++++ script/todo/todo_upgrade.py | 113 +++- test/test_todo_upgrade_cow_prompt.py | 204 +++++++ 3 files changed, 1155 insertions(+), 18 deletions(-) create mode 100644 test/test_todo_upgrade_cow_prompt.py diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index f920d86..fa29818 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -3886,6 +3886,862 @@ TRANSLATIONS = { "en": "website copies compared with their module view,", }, "differ.": {"fr": "diffèrent.", "en": "differ."}, + "What do you want to do with these COW copies?": { + "fr": "Que voulez-vous faire de ces copies COW ?", + "en": "What do you want to do with these COW copies?", + }, + "Enter = decide at the version bump": { + "fr": "Entrée = décider au palier de version", + "en": "Enter = decide at the version bump", + }, + "what each copy holds": { + "fr": "ce que chaque copie porte", + "en": "what each copy holds", + }, + "why it breaks": { + "fr": "pourquoi ça casse", + "en": "why it breaks", + }, + "full screen": { + "fr": "plein écran", + "en": "full screen", + }, + "neutralize now, reversible": { + "fr": "neutraliser maintenant, réversible", + "en": "neutralize now, reversible", + }, + "Undo with": { + "fr": "Annuler avec", + "en": "Undo with", + }, + "Nothing decided": { + "fr": "Rien de décidé", + "en": "Nothing decided", + }, + "the migration will ask again at the version bump.": { + "fr": "la migration reposera la question au palier de version.", + "en": "the migration will ask again at the version bump.", + }, + "Target version directory not found": { + "fr": "Répertoire de la version cible introuvable", + "en": "Target version directory not found", + }, + "No website COW view changes shape in": { + "fr": "Aucune vue COW de site ne change de forme dans", + "en": "No website COW view changes shape in", + }, + "website COW view(s) will break when moving to": { + "fr": "vue(s) COW de site casseront au passage à", + "en": "website COW view(s) will break when moving to", + }, + "the copy keeps an arch whose shape no longer matches what": { + "fr": "la copie garde une arch dont la forme ne correspond plus à ce que", + "en": "the copy keeps an arch whose shape no longer matches what", + }, + "the target module view expects.": { + "fr": "la vue module cible attend.", + "en": "the target module view expects.", + }, + "The migration offers to neutralize them, and shows what": { + "fr": "La migration propose de les neutraliser, et montre ce que", + "en": "The migration offers to neutralize them, and shows what", + }, + "each copy holds before you answer. To look now:": { + "fr": "chaque copie porte avant que vous répondiez. Pour regarder maintenant :", + "en": "each copy holds before you answer. To look now:", + }, + "reversible with --restore": { + "fr": "réversible avec --restore", + "en": "reversible with --restore", + }, + "Or by hand. To neutralize a copy, rename its key": { + "fr": "Ou à la main. Pour neutraliser une copie, renommer sa clé", + "en": "Or by hand. To neutralize a copy, rename its key", + }, + "an unmatched key is never paired with the": { + "fr": "une clé sans correspondance n'est jamais appariée à la", + "en": "an unmatched key is never paired with the", + }, + "module view, so the copy never receives the new": { + "fr": "vue module, la copie ne reçoit donc jamais le nouveau", + "en": "module view, so the copy never receives the new", + }, + "Setting active=false alone is NOT enough:": { + "fr": "Mettre active=false seul ne suffit PAS :", + "en": "Setting active=false alone is NOT enough:", + }, + "an inactive copy keeping the same key still shadows it.": { + "fr": "une copie inactive gardant la même clé la masque toujours.", + "en": "an inactive copy keeping the same key still shadows it.", + }, + "COW view(s) belong to a module absent from": { + "fr": "vue(s) COW appartiennent à un module absent de", + "en": "COW view(s) belong to a module absent from", + }, + "COW view(s) are pages or records made in the website": { + "fr": "vue(s) COW sont des pages ou des enregistrements faits dans", + "en": "COW view(s) are pages or records made in the website", + }, + "editor (no module view of that name): not at risk.": { + "fr": "l'éditeur de site (aucune vue module de ce nom) : hors de danger.", + "en": "editor (no module view of that name): not at risk.", + }, + "Use -v to list them.": { + "fr": "Utiliser -v pour les lister.", + "en": "Use -v to list them.", + }, + "No view with this prefix on": { + "fr": "Aucune vue avec ce préfixe sur", + "en": "No view with this prefix on", + }, + "archived COW view(s) on": { + "fr": "vue(s) COW archivées sur", + "en": "archived COW view(s) on", + }, + "Their arch is intact. Restore them all with --restore,": { + "fr": "Leur arch est intacte. Toutes les restaurer avec --restore,", + "en": "Their arch is intact. Restore them all with --restore,", + }, + "once the module view they shadow has the right shape.": { + "fr": "une fois que la vue module qu'elles masquent a la bonne forme.", + "en": "once the module view they shadow has the right shape.", + }, + "COW view(s) restored on": { + "fr": "vue(s) COW restaurées sur", + "en": "COW view(s) restored on", + }, + "No website COW view to neutralize.": { + "fr": "Aucune vue COW de site à neutraliser.", + "en": "No website COW view to neutralize.", + }, + "website COW view(s) would break the bump": { + "fr": "vue(s) COW de site casseraient le palier", + "en": "website COW view(s) would break the bump", + }, + "to": { + "fr": "vers", + "en": "to", + }, + "Dry-run. Add --apply to rename their key to": { + "fr": "Simulation. Ajouter --apply pour renommer leur clé en", + "en": "Dry-run. Add --apply to rename their key to", + }, + "and deactivate them. Reversible": { + "fr": "et les désactiver. Réversible", + "en": "and deactivate them. Reversible", + }, + "with --restore; the arch stays in database.": { + "fr": "avec --restore ; l'arch reste en base.", + "en": "with --restore; the arch stays in database.", + }, + "COW view(s) neutralized (key prefixed with": { + "fr": "vue(s) COW neutralisées (clé préfixée par", + "en": "COW view(s) neutralized (key prefixed with", + }, + "deactivated). The arch is kept as an": { + "fr": "désactivées). L'arch est conservée comme", + "en": "deactivated). The arch is kept as an", + }, + "archive; use --restore to undo.": { + "fr": "archive ; utiliser --restore pour annuler.", + "en": "archive; use --restore to undo.", + }, + "target inherits, copy holds a standalone template": { + "fr": "la cible hérite, la copie porte un modèle autonome", + "en": "target inherits, copy holds a standalone template", + }, + "target is a root view, copy holds inheritance specs": { + "fr": "la cible est une vue racine, la copie porte des specs d'héritage", + "en": "target is a root view, copy holds inheritance specs", + }, + "No module view carries this key, so there is nothing to": { + "fr": "Aucune vue module ne porte cette clé, il n'y a donc rien à", + "en": "No module view carries this key, so there is nothing to", + }, + "compare: this copy is a page made in the website editor.": { + "fr": "comparer : cette copie est une page faite dans l'éditeur de site.", + "en": "compare: this copy is a page made in the website editor.", + }, + "The copy is identical to the module view.": { + "fr": "La copie est identique à la vue module.", + "en": "The copy is identical to the module view.", + }, + "line(s) added": { + "fr": "ligne(s) ajoutée(s)", + "en": "line(s) added", + }, + "removed": { + "fr": "retirée(s)", + "en": "removed", + }, + "this is what neutralizing gives up.": { + "fr": "c'est ce que la neutralisation abandonne.", + "en": "this is what neutralizing gives up.", + }, + "the module no longer declares this template": { + "fr": "le module ne déclare plus ce modèle", + "en": "the module no longer declares this template", + }, + "inheritance specs (needs inherit_id)": { + "fr": "des specs d'héritage (exige inherit_id)", + "en": "inheritance specs (needs inherit_id)", + }, + "a standalone template": { + "fr": "un modèle autonome", + "en": "a standalone template", + }, + "Odoo changing the shape of its own template is NOT the": { + "fr": "Qu'Odoo change la forme de son propre modèle n'est PAS le", + "en": "Odoo changing the shape of its own template is NOT the", + }, + "problem: on a database without a copy, the module upgrade": { + "fr": "problème : sur une base sans copie, la mise à jour du module", + "en": "problem: on a database without a copy, the module upgrade", + }, + "rewrites the view and nothing breaks. It breaks here because": { + "fr": "réécrit la vue et rien ne casse. Ça casse ici parce qu'une", + "en": "rewrites the view and nothing breaks. It breaks here because", + }, + "a COPY exists and froze the old shape — the copy follows the": { + "fr": "COPIE existe et a figé l'ancienne forme — la copie suit le", + "en": "a COPY exists and froze the old shape — the copy follows the", + }, + "module and becomes an extension, while still holding a": { + "fr": "module et devient une extension, tout en portant encore un", + "en": "module and becomes an extension, while still holding a", + }, + "standalone template. Odoo then applies that template as an": { + "fr": "modèle autonome. Odoo applique alors ce modèle comme une", + "en": "standalone template. Odoo then applies that template as an", + }, + "inheritance spec and stops on « cannot be located in parent": { + "fr": "spec d'héritage et s'arrête sur « cannot be located in parent", + "en": "inheritance spec and stops on « cannot be located in parent", + }, + "view ».": { + "fr": "view ».", + "en": "view ».", + }, + "This copy has no module view of that name: it is a page": { + "fr": "Cette copie n'a aucune vue module de ce nom : c'est une page", + "en": "This copy has no module view of that name: it is a page", + }, + "made in the website editor, and nothing holds its content.": { + "fr": "faite dans l'éditeur de site, et rien d'autre ne porte son contenu.", + "en": "made in the website editor, and nothing holds its content.", + }, + "This copy is IDENTICAL to the module view it shadows: it": { + "fr": "Cette copie est IDENTIQUE à la vue module qu'elle masque : elle", + "en": "This copy is IDENTICAL to the module view it shadows: it", + }, + "holds no customization, so neutralizing it loses nothing.": { + "fr": "ne porte aucune personnalisation, la neutraliser ne perd rien.", + "en": "holds no customization, so neutralizing it loses nothing.", + }, + "This copy differs from the module view by": { + "fr": "Cette copie diffère de la vue module de", + "en": "This copy differs from the module view by", + }, + "line(s):": { + "fr": "ligne(s) :", + "en": "line(s):", + }, + "that is the customization, and all neutralizing gives up.": { + "fr": "c'est la personnalisation, et tout ce que la neutralisation abandonne.", + "en": "that is the customization, and all neutralizing gives up.", + }, + "Run without --shape to read it.": { + "fr": "Lancer sans --shape pour la lire.", + "en": "Run without --shape to read it.", + }, + "No website COW view is at risk.": { + "fr": "Aucune vue COW de site n'est en danger.", + "en": "No website COW view is at risk.", + }, + "No COW copy has drifted from its module view.": { + "fr": "Aucune copie COW n'a dérivé de sa vue module.", + "en": "No COW copy has drifted from its module view.", + }, + "COW copy(ies) drifted from their module": { + "fr": "copie(s) COW ont dérivé de leur vue", + "en": "COW copy(ies) drifted from their module", + }, + "view in": { + "fr": "module dans", + "en": "view in", + }, + "Odoo surfaces this when the module view is rewritten (a": { + "fr": "Odoo le fait apparaître quand la vue module est réécrite (un", + "en": "Odoo surfaces this when the module view is rewritten (a", + }, + "version bump) or when the page is rendered.": { + "fr": "palier de version) ou quand la page est rendue.", + "en": "version bump) or when the page is rendered.", + }, + "NO module view with this key": { + "fr": "AUCUNE vue module avec cette clé", + "en": "NO module view with this key", + }, + "child": { + "fr": "enfant", + "en": "child", + }, + "cannot apply": { + "fr": "ne peut s'appliquer", + "en": "cannot apply", + }, + "Nothing changed. Re-run with --reset --apply to": { + "fr": "Rien n'a changé. Relancer avec --reset --apply pour", + "en": "Nothing changed. Re-run with --reset --apply to", + }, + "reset a copy onto its module view.": { + "fr": "réinitialiser une copie sur sa vue module.", + "en": "reset a copy onto its module view.", + }, + "no module view to reset onto, skipped.": { + "fr": "aucune vue module sur laquelle réinitialiser, ignorée.", + "en": "no module view to reset onto, skipped.", + }, + "dry-run": { + "fr": "simulation", + "en": "dry-run", + }, + "would reset": { + "fr": "réinitialiserait", + "en": "would reset", + }, + "onto": { + "fr": "sur", + "en": "onto", + }, + "reset": { + "fr": "réinitialisé", + "en": "reset", + }, + "previous arch saved to": { + "fr": "arch précédente sauvegardée dans", + "en": "previous arch saved to", + }, + "copy(ies) reset. Re-apply any real customisation": { + "fr": "copie(s) réinitialisée(s). Réappliquer toute vraie personnalisation", + "en": "copy(ies) reset. Re-apply any real customisation", + }, + "as an INHERITING view, not a copy, so it cannot go stale": { + "fr": "comme une vue HÉRITANTE, pas une copie, pour qu'elle ne périme plus", + "en": "as an INHERITING view, not a copy, so it cannot go stale", + }, + "again.": { + "fr": "à l'avenir.", + "en": "again.", + }, + "lxml is required to resolve the xpath expressions.": { + "fr": "lxml est requis pour résoudre les expressions xpath.", + "en": "lxml is required to resolve the xpath expressions.", + }, + "COW view(s) recorded in": { + "fr": "vue(s) COW enregistrées dans", + "en": "COW view(s) recorded in", + }, + "COW view(s) disappeared": { + "fr": "vue(s) COW ont disparu", + "en": "COW view(s) disappeared", + }, + "COW view(s) appeared": { + "fr": "vue(s) COW sont apparues", + "en": "COW view(s) appeared", + }, + "arch rewritten": { + "fr": "arch réécrite", + "en": "arch rewritten", + }, + "COW view(s) changed": { + "fr": "vue(s) COW ont changé", + "en": "COW view(s) changed", + }, + "No change on the website COW views.": { + "fr": "Aucun changement sur les vues COW de site.", + "en": "No change on the website COW views.", + }, + "Welcome to the Odoo database upgrade with ERPLibre": { + "fr": "Bienvenue dans la mise à niveau de base de données Odoo avec ERPLibre", + "en": "Welcome to the Odoo database upgrade with ERPLibre", + }, + "Select the zip file of your database backup.": { + "fr": "Sélectionnez le fichier zip de votre sauvegarde de base de données.", + "en": "Select the zip file of your database backup.", + }, + "Give the path of the file, or empty to use a file": { + "fr": "Donnez le chemin du fichier, ou rien pour utiliser un", + "en": "Give the path of the file, or empty to use a file", + }, + "browser, or type": { + "fr": "navigateur de fichiers, ou tapez", + "en": "browser, or type", + }, + "to download from production": { + "fr": "pour télécharger depuis la production", + "en": "to download from production", + }, + "Cannot retrieve the database from remote, please retry the migration.": { + "fr": "Impossible de récupérer la base distante, veuillez relancer la migration.", + "en": "Cannot retrieve the database from remote, please retry the migration.", + }, + "Open file": { + "fr": "Ouverture du fichier", + "en": "Open file", + }, + "Detected Odoo CE version": { + "fr": "Version Odoo CE détectée", + "en": "Detected Odoo CE version", + }, + "Which version do you want to upgrade to?": { + "fr": "Vers quelle version voulez-vous migrer ?", + "en": "Which version do you want to upgrade to?", + }, + "Search the Odoo version": { + "fr": "Recherche de la version Odoo", + "en": "Search the Odoo version", + }, + "Find the right environment, read the .zip file": { + "fr": "Recherche du bon environnement, lecture du fichier .zip", + "en": "Find the right environment, read the .zip file", + }, + "Would you like to install": { + "fr": "Voulez-vous installer", + "en": "Would you like to install", + }, + "You need an installed system before": { + "fr": "Il faut un système installé avant de", + "en": "You need an installed system before", + }, + "continuing, check your Odoo installation.": { + "fr": "continuer, vérifiez votre installation Odoo.", + "en": "continuing, check your Odoo installation.", + }, + "Install the environment if missing": { + "fr": "Installation de l'environnement s'il manque", + "en": "Install the environment if missing", + }, + "Search missing modules": { + "fr": "Recherche des modules manquants", + "en": "Search missing modules", + }, + "Install the missing modules, search for them or": { + "fr": "Installer les modules manquants, les chercher ou", + "en": "Install the missing modules, search for them or", + }, + "ask to uninstall them (can break data)": { + "fr": "demander à les désinstaller (peut casser des données)", + "en": "ask to uninstall them (can break data)", + }, + "Cannot set up the environment to begin.": { + "fr": "Impossible de préparer l'environnement pour commencer.", + "en": "Cannot set up the environment to begin.", + }, + "Missing module": { + "fr": "Module manquant", + "en": "Missing module", + }, + "Duplicate module": { + "fr": "Module en double", + "en": "Duplicate module", + }, + "Missing or duplicate module detected at init,": { + "fr": "Module manquant ou en double détecté à l'init,", + "en": "Missing or duplicate module detected at init,", + }, + "do you want to continue?": { + "fr": "voulez-vous continuer ?", + "en": "do you want to continue?", + }, + "Missing or duplicate module detected at init, do": { + "fr": "Module manquant ou en double détecté à l'init,", + "en": "Missing or duplicate module detected at init, do", + }, + "you want to continue?": { + "fr": "voulez-vous continuer ?", + "en": "you want to continue?", + }, + "Which database name do you want to work with?": { + "fr": "Avec quel nom de base voulez-vous travailler ?", + "en": "Which database name do you want to work with?", + }, + "Ignore the database neutralization": { + "fr": "Ignorer la neutralisation de la base", + "en": "Ignore the database neutralization", + }, + "Working with database": { + "fr": "Travail sur la base", + "en": "Working with database", + }, + "will copy": { + "fr": "va copier", + "en": "will copy", + }, + "a file already exists, do you want to": { + "fr": "un fichier existe déjà, voulez-vous", + "en": "a file already exists, do you want to", + }, + "continue?": { + "fr": "continuer ?", + "en": "continue?", + }, + "Restore the database": { + "fr": "Restauration de la base", + "en": "Restore the database", + }, + "Update all addons before neutralizing (already": { + "fr": "Mettre à jour tous les modules avant la neutralisation (déjà", + "en": "Update all addons before neutralizing (already", + }, + "neutralized by Odoo if supported)": { + "fr": "neutralisée par Odoo si pris en charge)", + "en": "neutralized by Odoo if supported)", + }, + "Do you need to upgrade before neutralizing the": { + "fr": "Faut-il mettre à jour avant de neutraliser la", + "en": "Do you need to upgrade before neutralizing the", + }, + "database? Press enter to ignore": { + "fr": "base ? Entrée pour ignorer", + "en": "database? Press enter to ignore", + }, + "Update the database before neutralizing, by module": { + "fr": "Mise à jour de la base avant neutralisation, par module", + "en": "Update the database before neutralizing, by module", + }, + "Neutralize the database": { + "fr": "Neutralisation de la base", + "en": "Neutralize the database", + }, + "Modules to uninstall before the migration": { + "fr": "Modules à désinstaller avant la migration", + "en": "Modules to uninstall before the migration", + }, + "Uninstall modules": { + "fr": "Désinstallation des modules", + "en": "Uninstall modules", + }, + "Install modules": { + "fr": "Installation des modules", + "en": "Install modules", + }, + "Go to Settings / Technical / Cleanup... / Purge and": { + "fr": "Aller dans Configuration / Technique / Nettoyage... / Purger et", + "en": "Go to Settings / Technical / Cleanup... / Purge and", + }, + "purge the obsolete modules": { + "fr": "purger les modules obsolètes", + "en": "purge the obsolete modules", + }, + "Press enter to continue step 3": { + "fr": "Entrée pour continuer l'étape 3", + "en": "Press enter to continue step 3", + }, + "Inspect zip": { + "fr": "Inspection du zip", + "en": "Inspect zip", + }, + "Import database from zip": { + "fr": "Import de la base depuis le zip", + "en": "Import database from zip", + }, + "Succeed update all addons": { + "fr": "Mise à jour de tous les modules", + "en": "Succeed update all addons", + }, + "Clean up database before data migration": { + "fr": "Nettoyage de la base avant la migration des données", + "en": "Clean up database before data migration", + }, + "Upgrade version with OpenUpgrade": { + "fr": "Montée de version avec OpenUpgrade", + "en": "Upgrade version with OpenUpgrade", + }, + "Cleaning up database after upgrade": { + "fr": "Nettoyage de la base après la montée de version", + "en": "Cleaning up database after upgrade", + }, + "Migration finished": { + "fr": "Migration terminée", + "en": "Migration finished", + }, + "Clone to Odoo": { + "fr": "Clonage vers Odoo", + "en": "Clone to Odoo", + }, + "FAILED (status": { + "fr": "ÉCHEC (statut", + "en": "FAILED (status", + }, + "Stopping:": { + "fr": "Arrêt :", + "en": "Stopping:", + }, + "is not usable.": { + "fr": "n'est pas utilisable.", + "en": "is not usable.", + }, + "Clone Odoo": { + "fr": "Clonage Odoo", + "en": "Clone Odoo", + }, + "nothing": { + "fr": "rien", + "en": "nothing", + }, + "Switch Odoo": { + "fr": "Bascule Odoo", + "en": "Switch Odoo", + }, + "done with update": { + "fr": "terminée avec mise à jour", + "en": "done with update", + }, + "Module upgrade Odoo": { + "fr": "Mise à jour des modules Odoo", + "en": "Module upgrade Odoo", + }, + "Database upgrade Odoo": { + "fr": "Migration de la base Odoo", + "en": "Database upgrade Odoo", + }, + "Modules to uninstall before Odoo": { + "fr": "Modules à désinstaller avant Odoo", + "en": "Modules to uninstall before Odoo", + }, + "to redo the command": { + "fr": "pour refaire la commande", + "en": "to redo the command", + }, + "Error detected, press enter to continue or": { + "fr": "Erreur détectée, Entrée pour continuer ou", + "en": "Error detected, press enter to continue or", + }, + "to stop": { + "fr": "pour arrêter", + "en": "to stop", + }, + "Clone done for Odoo": { + "fr": "Clonage terminé pour Odoo", + "en": "Clone done for Odoo", + }, + "Clone already done for Odoo": { + "fr": "Clonage déjà fait pour Odoo", + "en": "Clone already done for Odoo", + }, + "Switch already done for Odoo": { + "fr": "Bascule déjà faite pour Odoo", + "en": "Switch already done for Odoo", + }, + "Switch done with update for Odoo": { + "fr": "Bascule terminée avec mise à jour pour Odoo", + "en": "Switch done with update for Odoo", + }, + "Module upgrade done for Odoo": { + "fr": "Mise à jour des modules terminée pour Odoo", + "en": "Module upgrade done for Odoo", + }, + "Module upgrade already done for Odoo": { + "fr": "Mise à jour des modules déjà faite pour Odoo", + "en": "Module upgrade already done for Odoo", + }, + "Database upgrade done for Odoo": { + "fr": "Migration de la base terminée pour Odoo", + "en": "Database upgrade done for Odoo", + }, + "Database upgrade already done for Odoo": { + "fr": "Migration de la base déjà faite pour Odoo", + "en": "Database upgrade already done for Odoo", + }, + "Cloning to Odoo": { + "fr": "Clonage vers Odoo", + "en": "Cloning to Odoo", + }, + "from": { + "fr": "depuis", + "en": "from", + }, + "Duplicate module in Odoo": { + "fr": "Module en double dans Odoo", + "en": "Duplicate module in Odoo", + }, + "Duplicate module error detected, handle it": { + "fr": "Erreur de module en double détectée, la traiter", + "en": "Duplicate module error detected, handle it", + }, + "manually then press enter to continue.": { + "fr": "manuellement puis Entrée pour continuer.", + "en": "manually then press enter to continue.", + }, + "Missing module error detected, missing in": { + "fr": "Erreur de module manquant détectée, manquant dans", + "en": "Missing module error detected, missing in", + }, + "All of the list above": { + "fr": "Toute la liste ci-dessus", + "en": "All of the list above", + }, + "Add an extra custom one": { + "fr": "Ajouter un module personnalisé", + "en": "Add an extra custom one", + }, + "List the missing modules to delete,": { + "fr": "Énumérer les modules manquants à supprimer,", + "en": "List the missing modules to delete,", + }, + "separated by commas. The others will be": { + "fr": "séparés par des virgules. Les autres seront", + "en": "separated by commas. The others will be", + }, + "migrated": { + "fr": "migrés", + "en": "migrated", + }, + "Migration fix for Odoo": { + "fr": "Correctif de migration pour Odoo", + "en": "Migration fix for Odoo", + }, + "Migration fix done for Odoo": { + "fr": "Correctif de migration terminé pour Odoo", + "en": "Migration fix done for Odoo", + }, + "No migration fix to run for Odoo": { + "fr": "Aucun correctif de migration à exécuter pour Odoo", + "en": "No migration fix to run for Odoo", + }, + "Migration fix already done for Odoo": { + "fr": "Correctif de migration déjà fait pour Odoo", + "en": "Migration fix already done for Odoo", + }, + "List the module names to delete,": { + "fr": "Énumérer les noms de modules à supprimer,", + "en": "List the module names to delete,", + }, + "separated by commas": { + "fr": "séparés par des virgules", + "en": "separated by commas", + }, + "Missing module error": { + "fr": "Erreur de module manquant", + "en": "Missing module error", + }, + "Duplicate module error": { + "fr": "Erreur de module en double", + "en": "Duplicate module error", + }, + "Module error": { + "fr": "Erreur de module", + "en": "Module error", + }, + "Please validate the commits after the code": { + "fr": "Veuillez valider les commits après la migration", + "en": "Please validate the commits after the code", + }, + "migration.": { + "fr": "du code.", + "en": "migration.", + }, + "To show the repo status": { + "fr": "Pour afficher l'état des dépôts", + "en": "To show the repo status", + }, + "Please validate this path in config.conf": { + "fr": "Veuillez valider ce chemin dans config.conf", + "en": "Please validate this path in config.conf", + }, + "Database migration to Odoo": { + "fr": "Migration de la base vers Odoo", + "en": "Database migration to Odoo", + }, + "is now half migrated: replaying the command on": { + "fr": "est maintenant à moitié migrée : rejouer la commande dessus", + "en": "is now half migrated: replaying the command on", + }, + "it would never recover, so it is NOT offered.": { + "fr": "ne s'en remettrait jamais, ce n'est donc PAS proposé.", + "en": "it would never recover, so it is NOT offered.", + }, + "The clone step has been reset. Fix the": { + "fr": "L'étape de clonage a été réinitialisée. Corrigez la", + "en": "The clone step has been reset. Fix the", + }, + "cause, then relaunch the migration and answer": { + "fr": "cause, puis relancez la migration et répondez", + "en": "cause, then relaunch the migration and answer", + }, + "continue": { + "fr": "continuer", + "en": "continue", + }, + "will be dropped and rebuilt from the previous": { + "fr": "sera supprimée et reconstruite depuis la version", + "en": "will be dropped and rebuilt from the previous", + }, + "version before retrying.": { + "fr": "précédente avant de réessayer.", + "en": "version before retrying.", + }, + "Do you want to upgrade all": { + "fr": "Voulez-vous tout mettre à jour", + "en": "Do you want to upgrade all", + }, + "Press y/Y to upgrade all addons of the": { + "fr": "y/Y pour mettre à jour tous les modules de la", + "en": "Press y/Y to upgrade all addons of the", + }, + "Open the server with Selenium": { + "fr": "Ouvrir le serveur avec Selenium", + "en": "Open the server with Selenium", + }, + "Do you want to test this upgrade? Choose": { + "fr": "Voulez-vous tester cette migration ? Choisissez", + "en": "Do you want to test this upgrade? Choose", + }, + "or press enter to ignore it": { + "fr": "ou Entrée pour ignorer", + "en": "or press enter to ignore it", + }, + "Press enter to continue": { + "fr": "Entrée pour continuer", + "en": "Press enter to continue", + }, + "Re-update i18n, purge the data and the tables": { + "fr": "Remise à jour de l'i18n, purge des données et des tables", + "en": "Re-update i18n, purge the data and the tables", + }, + "except mail_test and mail_test_full": { + "fr": "sauf mail_test et mail_test_full", + "en": "except mail_test and mail_test_full", + }, + "A backup can be created": { + "fr": "Une sauvegarde peut être créée", + "en": "A backup can be created", + }, + "Press y/Y or type filename.zip to export, or": { + "fr": "y/Y ou un nom fichier.zip pour exporter, ou", + "en": "Press y/Y or type filename.zip to export, or", + }, + "enter to continue": { + "fr": "Entrée pour continuer", + "en": "enter to continue", + }, + "Test the migration, press y/Y": { + "fr": "Tester la migration, y/Y", + "en": "Test the migration, press y/Y", + }, + "Documentation for this version": { + "fr": "Documentation de cette version", + "en": "Documentation for this version", + }, + "Command not found": { + "fr": "Commande non trouvée", + "en": "Command not found", + }, + "Execute command": { + "fr": "Exécution de la commande", + "en": "Execute command", + }, "Nothing to decide yet": { "fr": "Rien à décider pour l'instant", "en": "Nothing to decide yet", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index b6bfbba..5c6e72f 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -8,6 +8,7 @@ import logging import os import re import shutil +import subprocess import sys import zipfile from uuid import uuid4 @@ -1608,16 +1609,13 @@ class TodoUpgrade: get_output=True, wait_at_error=False, ) - # Cet avertissement n'est qu'un avertissement, et rien ne le disait : - # il annonce un problème, propose d'arbitrer, et aucune question ne - # suit. On en conclut que la question est passée — alors qu'elle vient - # au moment du palier, des dizaines de minutes plus tard. + # L'avertissement annonçait un problème, invitait à arbitrer, et + # aucune question ne suivait : dire « la question viendra au palier » + # ne remplace pas la possibilité de regarder maintenant. Les copies + # sont visibles ici, et les neutraliser ici vaut pour tous les paliers + # — chaque base de palier est un clone de celle-ci. if output and "will break when moving to" in "\n".join(output): - print( - f"ℹ -> {t('Nothing to decide yet')} :" - f" {t('the migration will offer to neutralize these copies')}" - f" {t('at the version bump itself, showing what each one holds.')}" - ) + self.prompt_cow_prediction(database_name, start_version + 1) msg = "3 - Clean up database before data migration" print(f"🔷 {msg}") @@ -2621,6 +2619,91 @@ class TodoUpgrade: wait_at_error=False, ) + def show_cow_drift(self, database_name, next_version, mode="diff"): + """Montre les copies COW à risque. Ne touche à rien. + + Un seul endroit construit la commande : les deux invites — celle de + l'étape 2 et celle du palier — montraient sinon des choses qui + pouvaient diverger sans que rien ne le signale. + """ + cmd = ( + f"{PYTHON_BIN} ./script/odoo/migration/cow_drift.py" + f" -d {database_name} -t odoo{next_version}.0" + ) + if mode == "shape": + cmd += " --shape" + elif mode == "tui": + cmd += " --tui" + self.run_on_terminal(cmd) + + def run_on_terminal(self, cmd): + """Lance une commande en lui laissant le VRAI terminal. + + `todo_upgrade_execute` capture la sortie par un tube. Un plein écran + y voit un stdout qui n'est pas un terminal, renonce, et retombe sur + son rapport texte : « w » réaffichait mot pour mot ce que « v » + venait de montrer, sans rien signaler. + + Rien ne relit cette sortie — elle est REGARDÉE. Et le code de retour + d'un afficheur ne veut pas dire « erreur » : 1 signifie « il y a des + copies », ce qui est la raison même de l'avoir ouvert. L'annoncer + comme un échec inquiétait pour rien. + """ + self.lst_command_executed.append(cmd) + self.dct_progression["command_executed"] = self.lst_command_executed + self.write_config() + print(f"\n🏠 ⬇ {t('Execute command')} :\n") + print(cmd) + return subprocess.call(cmd, shell=True, executable="/bin/bash") + + def prompt_cow_prediction(self, database_name, next_version): + """Que faire des copies COW annoncées, dès l'étape 2. + + La neutralisation officielle a lieu au palier, sur la base de palier. + Mais celle-ci en est la source : ce qu'on neutralise ici vaut pour + tous les paliers, et la lecture n'écrit rien de toute façon. Attendre + des dizaines de minutes pour seulement REGARDER n'avait pas de raison + d'être. + """ + neutralize = ( + f"{PYTHON_BIN} ./script/odoo/migration/neutralize_cow_views.py" + f" -d {database_name} -t odoo{next_version}.0" + ) + while True: + answer = ( + input( + f"💬 {t('What do you want to do with these COW copies?')}" + f" ({t('Enter = decide at the version bump')}," + f" v = {t('what each copy holds')}," + f" s = {t('why it breaks')}," + f" w = {t('full screen')}," + f" a = {t('neutralize now, reversible')}) : " + ) + .strip() + .lower() + ) + if answer in ("v", "s", "w"): + self.show_cow_drift( + database_name, + next_version, + {"v": "diff", "s": "shape", "w": "tui"}[answer], + ) + continue + if answer == "a": + self.todo_upgrade_execute( + f"{neutralize} --apply", wait_at_error=False + ) + print( + f"ℹ -> {t('Undo with')} :" + f" {neutralize.rsplit(' -t', 1)[0]} --restore" + ) + return + break + print( + f"ℹ -> {t('Nothing decided')} :" + f" {t('the migration will ask again at the version bump.')}" + ) + def neutralize_cow_views(self, database_name, next_version): """Offer to neutralize the COW views that would break this bump. @@ -2643,10 +2726,6 @@ class TodoUpgrade: # une personnalisation sans avoir montré laquelle. Souvent trois lignes # — un id, une largeur de conteneur — mais parfois une page entière, et # rien dans l'avertissement ne permet de les distinguer. - show = ( - f"{PYTHON_BIN} ./script/odoo/migration/cow_drift.py" - f" -d {database_name} -t odoo{next_version}.0" - ) while True: answer = ( input( @@ -2658,13 +2737,11 @@ class TodoUpgrade: .lower() ) if answer == "v": - self.todo_upgrade_execute(show, wait_at_error=False) - self.todo_upgrade_execute( - f"{show} --shape", wait_at_error=False - ) + self.show_cow_drift(database_name, next_version, "diff") + self.show_cow_drift(database_name, next_version, "shape") continue if answer == "w": - self.todo_upgrade_execute(f"{show} --tui", wait_at_error=False) + self.show_cow_drift(database_name, next_version, "tui") continue break diff --git a/test/test_todo_upgrade_cow_prompt.py b/test/test_todo_upgrade_cow_prompt.py new file mode 100644 index 0000000..d73426c --- /dev/null +++ b/test/test_todo_upgrade_cow_prompt.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""L'étape 2 annonce des copies COW cassées, et ne demandait rien. + +Le message disait le problème, invitait à arbitrer, puis passait à la suite : +« la migration proposera de neutraliser au palier ». Des dizaines de minutes +plus tard, donc — alors que REGARDER n'écrit rien, et que neutraliser ici vaut +pour tous les paliers, chaque base de palier étant un clone de celle-ci. + +Ces tests portent sur ce que chaque réponse déclenche réellement, pas sur le +texte de l'invite. +""" + +import builtins +import contextlib +import io +import os +import unittest + +from script.todo import todo_i18n +from script.todo.todo_upgrade import TodoUpgrade + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +class PromptCase(unittest.TestCase): + def setUp(self): + # PAS set_lang() : il persiste la langue dans env_var.sh, suivi par + # git. On écrit la mémoïsation, et on la rend. + self.addCleanup( + setattr, todo_i18n, "_current_lang", todo_i18n._current_lang + ) + todo_i18n._current_lang = "en" + + def run_prompt(self, answers, database="db", version=13): + """(commandes lancées, texte affiché) après ces réponses.""" + upgrade = TodoUpgrade.__new__(TodoUpgrade) + upgrade.dct_progression = {} + upgrade.lst_command_executed = [] + lst_cmd = [] + upgrade.todo_upgrade_execute = lambda cmd, **kw: ( + lst_cmd.append(cmd), + (False, cmd), + )[1] + # Les afficheurs ne passent PLUS par todo_upgrade_execute : il capture + # la sortie par un tube, et un plein écran refuse de s'ouvrir sur un + # stdout qui n'est pas un terminal. Sans ce bouchon, ces tests + # lanceraient les vraies commandes. + upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0 + seq = iter(answers) + original = builtins.input + builtins.input = lambda *a: next(seq) + self.addCleanup(setattr, builtins, "input", original) + out = io.StringIO() + with contextlib.redirect_stdout(out): + upgrade.prompt_cow_prediction(database, version) + return lst_cmd, out.getvalue() + + +class TestWhatEachAnswerRuns(PromptCase): + def test_enter_runs_nothing_and_says_so(self): + lst_cmd, text = self.run_prompt([""]) + self.assertEqual(lst_cmd, []) + self.assertIn("version bump", text) + + def test_v_shows_what_the_copies_hold(self): + lst_cmd, _ = self.run_prompt(["v", ""]) + self.assertEqual(len(lst_cmd), 1) + self.assertIn("cow_drift.py", lst_cmd[0]) + self.assertIn("-d db -t odoo13.0", lst_cmd[0]) + self.assertNotIn("--shape", lst_cmd[0]) + + def test_s_shows_why_it_breaks(self): + lst_cmd, _ = self.run_prompt(["s", ""]) + self.assertTrue(lst_cmd[0].endswith("--shape")) + + def test_w_opens_the_full_screen_view(self): + lst_cmd, _ = self.run_prompt(["w", ""]) + self.assertTrue(lst_cmd[0].endswith("--tui")) + + def test_looking_does_not_answer_the_question(self): + # LE piège de l'invite précédente : montrer puis passer à la suite. + # Après avoir regardé, on doit pouvoir encore choisir. + lst_cmd, _ = self.run_prompt(["v", "s", "w", "a"]) + self.assertEqual(len(lst_cmd), 4) + self.assertIn("--apply", lst_cmd[-1]) + + +class TestNeutralizingNow(PromptCase): + def test_a_applies_and_stops_asking(self): + lst_cmd, _ = self.run_prompt(["a"]) + self.assertEqual(len(lst_cmd), 1) + self.assertIn("neutralize_cow_views.py", lst_cmd[0]) + self.assertIn("-d db -t odoo13.0 --apply", lst_cmd[0]) + + def test_the_way_back_is_printed_and_is_valid(self): + # `--restore` refuse `-t` : une commande d'annulation qu'on ne peut + # pas coller est une commande d'annulation qui n'existe pas. + _, text = self.run_prompt(["a"]) + self.assertIn("--restore", text) + ligne = [x for x in text.splitlines() if "--restore" in x][0] + self.assertIn("-d db", ligne) + self.assertNotIn("-t odoo", ligne) + + def test_applying_says_nothing_about_deciding_later(self): + _, text = self.run_prompt(["a"]) + self.assertNotIn("ask again", text) + + +class TestTheFullScreenViewGetsARealTerminal(PromptCase): + """« w » réaffichait le rapport texte au lieu d'ouvrir le plein écran. + + `todo_upgrade_execute` capture la sortie par un tube pour que le pilote + puisse la relire. La TUI y voit un stdout qui n'est PAS un terminal, + renonce — c'est son repli délibéré — et retombe sur le même rapport que + « v » venait d'afficher. Aucune erreur, aucune trace : deux touches + différentes donnant le même écran. + """ + + def test_the_viewers_do_not_go_through_the_piped_executor(self): + import inspect + + from script.todo.todo_upgrade import TodoUpgrade + + source = inspect.getsource(TodoUpgrade.show_cow_drift) + self.assertNotIn("todo_upgrade_execute", source) + self.assertIn("self.run_on_terminal(", source) + + def test_run_on_terminal_does_not_capture_anything(self): + # Capturer, c'est justement ce qui referme le plein écran. + import inspect + + from script.todo.todo_upgrade import TodoUpgrade + + source = inspect.getsource(TodoUpgrade.run_on_terminal) + self.assertNotIn("PIPE", source) + self.assertNotIn("capture_output", source) + self.assertIn("subprocess.call(", source) + + def test_the_tui_refuses_a_pipe_and_accepts_a_terminal(self): + # La raison même du défaut, vérifiée sur le code qui décide. + import sys + + sys.path.insert(0, os.path.join(REPO, "script", "odoo", "migration")) + self.addCleanup(sys.path.remove, sys.path[0]) + from cow_drift_tui import run_tui + + finding = { + "id": 1, + "key": "k", + "website_id": 1, + "reason": "r", + "module_id": 2, + "module_arch": "a", + "copy_arch": "b", + "decl_current": None, + "decl_target": None, + "current_version": "odoo12.0", + "target_version": "odoo13.0", + } + + class FakeStdout: + def __init__(self, tty): + self.tty = tty + + def isatty(self): + return self.tty + + real = sys.stdout + self.addCleanup(setattr, sys, "stdout", real) + sys.stdout = FakeStdout(False) + self.assertFalse(run_tui([finding], run_app=False)) + sys.stdout = FakeStdout(True) + self.assertTrue(run_tui([finding], run_app=False)) + + +class TestTheBumpPromptSharesTheSameView(PromptCase): + """Les deux invites doivent montrer la même chose, sans se dupliquer.""" + + def test_show_cow_drift_builds_the_three_modes(self): + upgrade = TodoUpgrade.__new__(TodoUpgrade) + upgrade.dct_progression = {} + upgrade.lst_command_executed = [] + lst_cmd = [] + upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0 + for mode in ("diff", "shape", "tui"): + upgrade.show_cow_drift("db", 13, mode) + self.assertNotIn("--", lst_cmd[0].split("odoo13.0")[1]) + self.assertTrue(lst_cmd[1].endswith("--shape")) + self.assertTrue(lst_cmd[2].endswith("--tui")) + + def test_the_bump_prompt_no_longer_builds_its_own_command(self): + # Sans cela les deux invites peuvent diverger en silence. + import inspect + + source = inspect.getsource(TodoUpgrade.neutralize_cow_views) + self.assertNotIn("cow_drift.py", source) + self.assertIn("self.show_cow_drift(", source) + + +if __name__ == "__main__": + unittest.main() From d2c5d92d115edffefb2ad061c00224c81ff87116 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 13 Aug 2026 04:01:00 -0400 Subject: [PATCH 09/67] [FIX] migration: the COW tools speak the system language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit They run as subprocesses and had no i18n, so a French migration alternated languages from one line to the next. The database upgrade itself had the same gap. The driver read English sentences out of their output to decide whether to ask its question. Translating them would have made it mute -- no error, no trace. The link is now the exit code, which no language touches: 0 nothing, 1 copies concerned, 2 the tool failed. 87 strings, five tools. A test rejects any displayed sentence that skips t(), and proves itself on an untranslated one. --- FR --- Ils tournent en sous-processus et n'avaient aucun i18n : une migration en français alternait les deux langues d'une ligne à l'autre. La mise à niveau de la base elle-même souffrait du même manque. Le pilote lisait des phrases anglaises dans leur sortie pour décider de poser sa question. Les traduire l'aurait rendu muet — sans erreur, sans trace. Le lien est désormais le code de sortie, qu'aucune langue ne touche : 0 rien, 1 copies concernées, 2 l'outil a échoué. 87 chaînes, cinq outils. Un test refuse toute phrase affichée qui saute t(), et fait sa preuve sur une chaîne non traduite. Assisted-by: Claude Opus 5 --- script/odoo/migration/check_cow_views.py | 73 +++-- script/odoo/migration/cow_drift.py | 58 ++-- script/odoo/migration/neutralize_cow_views.py | 49 +-- .../odoo/migration/reset_stale_cow_views.py | 48 +-- script/odoo/migration/snapshot_cow_views.py | 30 +- script/todo/todo_upgrade.py | 281 ++++++++++++------ test/test_migration_tools_i18n.py | 244 +++++++++++++++ 7 files changed, 588 insertions(+), 195 deletions(-) create mode 100644 test/test_migration_tools_i18n.py diff --git a/script/odoo/migration/check_cow_views.py b/script/odoo/migration/check_cow_views.py index 3acca71..8526a2b 100755 --- a/script/odoo/migration/check_cow_views.py +++ b/script/odoo/migration/check_cow_views.py @@ -50,6 +50,18 @@ import subprocess import sys import xml.etree.ElementTree as ET +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 + + # A view whose module counterpart cannot be found at all. MODE_UNKNOWN = "unknown" @@ -232,6 +244,9 @@ def analyse(database, target_version): # The decisive test: the target expects inheritance specs but the copy # holds a standalone template, or the reverse. if is_spec is not None and target_inherits != is_spec: + # La clé anglaise est stockée, la traduction se fait à + # l'affichage : cow_drift et neutralize la relisent, et une + # valeur déjà traduite les obligerait à traduire en sens inverse. reason = ( "target inherits, copy holds a standalone template" if target_inherits @@ -281,9 +296,10 @@ def main(): if not os.path.isdir(config.target_version): print( - f"❌ Target version directory '{config.target_version}' not found." + f"❌ {t('Target version directory not found')} :" + f" '{config.target_version}'" ) - return 1 + return 2 lst_at_risk, lst_module_absent, lst_no_counterpart = analyse( config.database, config.target_version @@ -293,14 +309,16 @@ def main(): if not lst_at_risk: print( - "✅ -> No website COW view changes mode in" + f"✅ -> {t('No website COW view changes shape in')}" f" {config.target_version}." ) else: print( - f"⚠️ {len(lst_at_risk)} website COW view(s) will break when moving" - f" to {config.target_version}: the copy keeps an arch whose shape" - " no longer matches what the target module view expects." + f"⚠️ {len(lst_at_risk)}" + f" {t('website COW view(s) will break when moving to')}" + f" {config.target_version} :" + f" {t('the copy keeps an arch whose shape no longer matches what')}" + f" {t('the target module view expects.')}" ) for ( view_id, @@ -312,52 +330,55 @@ def main(): ) in lst_at_risk: print( f" - id={view_id} website={website_id} {key}" - f" : {mode} -> {target_mode} ({reason})" + f" : {mode} -> {target_mode} ({t(reason)})" ) # La base et la version cible sont connues ici : les remplacer par # « DB » et « odooXX.0 » oblige à les retrouver, au moment précis où # l'on veut juste copier-coller la commande. print( - " The migration will offer to neutralize them at the bump, and" - " shows what each copy holds before you answer. To look now," - " without interrupting anything:" + f" {t('The migration offers to neutralize them, and shows what')}" + f" {t('each copy holds before you answer. To look now:')}" f"\n ./script/odoo/migration/cow_drift.py -d {database}" - f" -t {target_version} (what each copy holds)" + f" -t {target_version} ({t('what each copy holds')})" f"\n ./script/odoo/migration/cow_drift.py -d {database}" - f" -t {target_version} --shape (why it breaks)" + f" -t {target_version} --shape ({t('why it breaks')})" f"\n ./script/odoo/migration/neutralize_cow_views.py" f" -d {database} -t {target_version} --apply" - " (reversible with --restore)" + f" ({t('reversible with --restore')})" ) print( - " Or by hand. To neutralize a copy," - " rename its key (UPDATE ir_ui_view SET key='zz_cow_archive.'||key," - " active=false): an unmatched key is never paired with the module" - " view, so the copy never receives the new inherit_id. Setting" - " active=false alone is NOT enough -- an inactive copy that keeps" - " the same key still shadows the module view." + f" {t('Or by hand. To neutralize a copy, rename its key')}" + " (UPDATE ir_ui_view SET key='zz_cow_archive.'||key," + f" active=false) : {t('an unmatched key is never paired with the')}" + f" {t('module view, so the copy never receives the new')}" + f" inherit_id. {t('Setting active=false alone is NOT enough:')}" + f" {t('an inactive copy keeping the same key still shadows it.')}" ) if lst_module_absent: print( - f"ℹ {len(lst_module_absent)} COW view(s) belong to a module absent" - f" from {config.target_version}:" + f"ℹ {len(lst_module_absent)}" + f" {t('COW view(s) belong to a module absent from')}" + f" {config.target_version} :" ) for view_id, key, mode, website_id in lst_module_absent: print(f" - id={view_id} website={website_id} {key} ({mode})") if lst_no_counterpart: print( - f"ℹ {len(lst_no_counterpart)} COW view(s) are pages or records made" - " from the website editor (no module view of that name): not at" - " risk." + ("" if config.verbose else " Use -v to list them.") + f"ℹ {len(lst_no_counterpart)}" + f" {t('COW view(s) are pages or records made in the website')}" + f" {t('editor (no module view of that name): not at risk.')}" + + ("" if config.verbose else f" {t('Use -v to list them.')}") ) if config.verbose: for view_id, key, mode, website_id in lst_no_counterpart: print(f" - id={view_id} website={website_id} {key} ({mode})") - # Informative only: never fail the migration on a warning. - return 0 + # 0 = rien à signaler, 1 = des copies casseront, 2 = l'outil a échoué. + # Le pilote lisait le texte anglais de cette sortie pour savoir s'il + # devait poser sa question : traduire le message le rendait aveugle. + return 1 if lst_at_risk else 0 if __name__ == "__main__": diff --git a/script/odoo/migration/cow_drift.py b/script/odoo/migration/cow_drift.py index 5972d94..e96402c 100644 --- a/script/odoo/migration/cow_drift.py +++ b/script/odoo/migration/cow_drift.py @@ -31,7 +31,7 @@ import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from check_cow_views import analyse, find_module_dir # noqa: E402 +from check_cow_views import analyse, find_module_dir, t # noqa: E402 # The declaration of a template spans a few lines; showing the opening tag and # what follows is enough to see its shape, and short enough to compare two @@ -208,8 +208,8 @@ def render_diff(finding): ] if finding["module_id"] is None: lines += [ - " No module view carries this key, so there is nothing to compare", - " against: this copy is a page made in the website editor.", + f" {t('No module view carries this key, so there is nothing to')}", + f" {t('compare: this copy is a page made in the website editor.')}", ] return "\n".join(lines) @@ -227,7 +227,7 @@ def render_diff(finding): ) ] if len(diff) <= 2: - lines.append(" The copy is identical to the module view.") + lines.append(f" {t('The copy is identical to the module view.')}") return "\n".join(lines) lines += [f" {line}" for line in diff] n_plus = sum( @@ -238,8 +238,8 @@ def render_diff(finding): ) lines += [ "", - f" {n_plus} line(s) added, {n_minus} removed — this is what" - " neutralizing gives up.", + f" {n_plus} {t('line(s) added')}, {n_minus} {t('removed')} —" + f" {t('this is what neutralizing gives up.')}", ] return "\n".join(lines) @@ -249,7 +249,7 @@ def render_shape(finding): lines = [ f"── id={finding['id']} {finding['key']} ──", "", - f" {finding['reason']}", + f" {t(finding['reason'])}", "", ] for label, decl in ( @@ -259,7 +259,7 @@ def render_shape(finding): lines.append(f" ") if decl is None: lines += [ - " (the module no longer declares this template)", + f" ({t('the module no longer declares this template')})", "", ] continue @@ -267,19 +267,20 @@ def render_shape(finding): lines.append(f" ") lines += [f" {line}" for line in snippet.splitlines()] shape = ( - "inheritance specs (needs inherit_id)" + t("inheritance specs (needs inherit_id)") if re.search(r"inherit_id\s*=", snippet) - else "a standalone template" + else t("a standalone template") ) lines += [f" -> {shape}", ""] lines += [ - " Odoo changing the shape of its own template is NOT the problem: on a", - " database without a copy, the module upgrade rewrites the view and", - " nothing breaks. It breaks here because a COPY exists and froze the", - " old shape — the copy follows the module and becomes an extension,", - " while still holding a standalone template. Odoo then applies that", - " template as an inheritance spec and stops on « cannot be located in", - " parent view ».", + f" {t('Odoo changing the shape of its own template is NOT the')}", + f" {t('problem: on a database without a copy, the module upgrade')}", + f" {t('rewrites the view and nothing breaks. It breaks here because')}", + f" {t('a COPY exists and froze the old shape — the copy follows the')}", + f" {t('module and becomes an extension, while still holding a')}", + f" {t('standalone template. Odoo then applies that template as an')}", + f" {t('inheritance spec and stops on « cannot be located in parent')}", + f" {t('view ».')}", "", ] # Sans ceci, cette vue se lit comme « du code Odoo qui change », et l'on @@ -298,8 +299,8 @@ def _what_the_copy_holds(finding): """ if finding["module_id"] is None: return [ - " This copy has no module view of that name: it is a page made in", - " the website editor, and nothing else holds its content.", + f" {t('This copy has no module view of that name: it is a page')}", + f" {t('made in the website editor, and nothing holds its content.')}", ] left = finding["module_arch"].splitlines() right = finding["copy_arch"].splitlines() @@ -310,23 +311,23 @@ def _what_the_copy_holds(finding): ] if not diff: return [ - " This copy is IDENTICAL to the module view it shadows: it holds no", - " customization at all, so neutralizing it loses nothing.", + f" {t('This copy is IDENTICAL to the module view it shadows: it')}", + f" {t('holds no customization, so neutralizing it loses nothing.')}", ] n_plus = sum(1 for x in diff if x.startswith("+")) n_minus = sum(1 for x in diff if x.startswith("-")) return [ - f" This copy differs from the module view by +{n_plus}/-{n_minus}" - " line(s):", - " that is the customization, and all that neutralizing gives up.", - " Run without --shape to read it.", + f" {t('This copy differs from the module view by')}" + f" +{n_plus}/-{n_minus} {t('line(s):')}", + f" {t('that is the customization, and all neutralizing gives up.')}", + f" {t('Run without --shape to read it.')}", ] def render_all(lst_finding, shape=False): """The whole report, one block per finding.""" if not lst_finding: - return "✅ No website COW view is at risk.\n" + return f"✅ {t('No website COW view is at risk.')}\n" render = render_shape if shape else render_diff return "\n\n".join(render(f) for f in lst_finding) + "\n" @@ -363,7 +364,10 @@ def main(argv=None): config = parser.parse_args(argv) if not os.path.isdir(config.target_version): - print(f"❌ Target version '{config.target_version}' not found.") + print( + f"❌ {t('Target version directory not found')} :" + f" '{config.target_version}'" + ) return 2 try: lst_finding = collect( diff --git a/script/odoo/migration/neutralize_cow_views.py b/script/odoo/migration/neutralize_cow_views.py index 021cef8..5b83077 100755 --- a/script/odoo/migration/neutralize_cow_views.py +++ b/script/odoo/migration/neutralize_cow_views.py @@ -32,7 +32,7 @@ import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from check_cow_views import analyse # noqa: E402 +from check_cow_views import analyse, t # noqa: E402 DEFAULT_PREFIX = "zz_cow_archive" @@ -160,9 +160,15 @@ def _run(config, parser): if config.list_archived: lst_row = list_archived(config.database, config.prefix) if not lst_row: - print(f"✅ -> No '{config.prefix}.' view on '{config.database}'.") + print( + f"✅ -> {t('No view with this prefix on')}" + f" '{config.database}' : '{config.prefix}.'" + ) return 0 - print(f"ℹ {len(lst_row)} archived COW view(s) on '{config.database}':") + print( + f"ℹ {len(lst_row)} {t('archived COW view(s) on')}" + f" '{config.database}' :" + ) for row in lst_row: state = "active" if row["active"] else "inactive" print( @@ -170,54 +176,59 @@ def _run(config, parser): f" {row['key']} ({state}, {row['arch_bytes']} B)" ) print( - " Their arch is intact. Restore them all with --restore, once" - " the module view they shadow has the shape they expect." + f" {t('Their arch is intact. Restore them all with --restore,')}" + f" {t('once the module view they shadow has the right shape.')}" ) return 0 if config.restore: count = restore(config.database, config.prefix) - print(f"✅ -> {count} COW view(s) restored on '{config.database}'.") + print( + f"✅ -> {count} {t('COW view(s) restored on')} '{config.database}'." + ) return 0 if not config.target_version: parser.error("--target_version is required unless --restore is used") if not os.path.isdir(config.target_version): print( - f"❌ Target version directory '{config.target_version}' not found." + f"❌ {t('Target version directory not found')} :" + f" '{config.target_version}'" ) - return 1 + return 2 lst_at_risk, _, _ = analyse(config.database, config.target_version) if not lst_at_risk: - print("✅ -> No website COW view to neutralize.") + print(f"✅ -> {t('No website COW view to neutralize.')}") return 0 print( - f"⚠️ {len(lst_at_risk)} website COW view(s) would break the bump to" - f" {config.target_version}:" + f"⚠️ {len(lst_at_risk)} {t('website COW view(s) would break the bump')}" + f" {t('to')} {config.target_version} :" ) lst_view_id = [] for view_id, key, mode, target_mode, website_id, reason in lst_at_risk: lst_view_id.append(view_id) print( f" - id={view_id} website={website_id} {key}" - f" : {mode} -> {target_mode} ({reason})" + f" : {mode} -> {target_mode} ({t(reason)})" ) if not config.apply: print( - "ℹ Dry-run. Add --apply to rename their key to" - f" '{config.prefix}.' and deactivate them. Reversible with" - " --restore; the arch stays in database." + f"ℹ {t('Dry-run. Add --apply to rename their key to')}" + f" '{config.prefix}.' {t('and deactivate them. Reversible')}" + f" {t('with --restore; the arch stays in database.')}" ) - return 0 + # 1 = des copies sont à neutraliser. Le pilote lisait une phrase + # anglaise pour le savoir ; il devenait aveugle une fois traduite. + return 1 count = neutralize(config.database, lst_view_id, config.prefix) print( - f"✅ -> {count} COW view(s) neutralized (key prefixed with" - f" '{config.prefix}.', deactivated). The arch is kept as an archive;" - " use --restore to undo." + f"✅ -> {count} {t('COW view(s) neutralized (key prefixed with')}" + f" '{config.prefix}.', {t('deactivated). The arch is kept as an')}" + f" {t('archive; use --restore to undo.')}" ) return 0 diff --git a/script/odoo/migration/reset_stale_cow_views.py b/script/odoo/migration/reset_stale_cow_views.py index 13cd44e..8c7935e 100755 --- a/script/odoo/migration/reset_stale_cow_views.py +++ b/script/odoo/migration/reset_stale_cow_views.py @@ -61,6 +61,17 @@ import re 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 + def run_psql(database, sql): """Run a statement and return stdout, raising on failure.""" @@ -142,7 +153,7 @@ def require_lxml(): return etree except ImportError: sys.exit( - "❌ lxml is required to resolve the xpath expressions.\n" + f"❌ {t('lxml is required to resolve the xpath expressions.')}\n" " Run this with an interpreter that has it, e.g.\n" " ./.venv.erplibre/bin/python3 " + os.path.relpath(__file__) ) @@ -294,37 +305,37 @@ def main(): findings = analyse(config.database) if not findings: - print("✅ No COW copy has drifted from its module view.") + print(f"✅ {t('No COW copy has drifted from its module view.')}") return 0 print( - f"⚠️ {len(findings)} COW copy(ies) drifted from their module view" - f" in {config.database}" + f"⚠️ {len(findings)} {t('COW copy(ies) drifted from their module')}" + f" {t('view in')} {config.database}" ) print( - " Odoo surfaces this when the module view is rewritten (a version" - " bump) or when the page is rendered.\n" + f" {t('Odoo surfaces this when the module view is rewritten (a')}" + f" {t('version bump) or when the page is rendered.')}\n" ) for cow_view, module_view, broken in findings: twin = ( f"module id={module_view['id']}" if module_view - else "NO module view with this key" + else t("NO module view with this key") ) print( f" id={cow_view['id']} key={cow_view['key']}" f" website_id={cow_view['website_id']} [{twin}]" ) for child_id, expr in broken: - print(f" child {child_id} cannot apply: {expr}") + print(f" {t('child')} {child_id} {t('cannot apply')}: {expr}") if module_view: show_diff(module_view, cow_view) print() if not config.reset: print( - "Nothing changed. Re-run with --reset --apply to reset a" - " copy onto its module view." + f"{t('Nothing changed. Re-run with --reset --apply to')}" + f" {t('reset a copy onto its module view.')}" ) return 1 @@ -338,25 +349,26 @@ def main(): continue if not module_view: print( - f"⏭ {cow_view['key']}: no module view to reset onto," - " skipped." + f"⏭ {cow_view['key']} :" + f" {t('no module view to reset onto, skipped.')}" ) continue if not config.apply: print( - f"[dry-run] would reset id={cow_view['id']}" - f" ({cow_view['key']}) onto id={module_view['id']}" + f"[{t('dry-run')}] {t('would reset')} id={cow_view['id']}" + f" ({cow_view['key']}) {t('onto')} id={module_view['id']}" ) continue path = backup(config.database, cow_view, directory) reset(config.database, cow_view, module_view) done += 1 - print(f"✅ reset id={cow_view['id']} ({cow_view['key']})") - print(f" previous arch saved to {path}") + print(f"✅ {t('reset')} id={cow_view['id']} ({cow_view['key']})") + print(f" {t('previous arch saved to')} {path}") if config.apply and done: print( - f"\n{done} copy(ies) reset. Re-apply any real customisation as an" - " INHERITING view, not a copy, so it cannot go stale again." + f"\n{done} {t('copy(ies) reset. Re-apply any real customisation')}" + f" {t('as an INHERITING view, not a copy, so it cannot go stale')}" + f" {t('again.')}" ) return 0 diff --git a/script/odoo/migration/snapshot_cow_views.py b/script/odoo/migration/snapshot_cow_views.py index a8f839b..efa57bc 100755 --- a/script/odoo/migration/snapshot_cow_views.py +++ b/script/odoo/migration/snapshot_cow_views.py @@ -32,6 +32,18 @@ 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 + + # Columns worth recording. ir_ui_view does not expose the same set across 12.0 # to 18.0, so the query keeps only those that actually exist. WANTED_COLUMN = [ @@ -115,7 +127,7 @@ def save(database, label, output_dir): file_path = os.path.join(directory, f"{label}.json") with open(file_path, "w", encoding="utf-8") as f: json.dump(payload, f, indent=2, ensure_ascii=False) - print(f"✅ -> {len(lst_view)} COW view(s) recorded in {file_path}") + print(f"✅ -> {len(lst_view)} {t('COW view(s) recorded in')} {file_path}") return file_path @@ -136,18 +148,18 @@ def diff(path_before, path_after): common = sorted(set(map_before) & set(map_after)) print( - f"📊 {before.get('label')} ({before.get('count')} views)" - f" -> {after.get('label')} ({after.get('count')} views)" + f"📊 {before.get('label')} ({before.get('count')} {t('views')})" + f" -> {after.get('label')} ({after.get('count')} {t('views')})" ) if removed: - print(f"❌ {len(removed)} COW view(s) disappeared:") + print(f"❌ {len(removed)} {t('COW view(s) disappeared')} :") for view_id in removed: view = map_before[view_id] print(f" - id={view_id} {view.get('key')}") if added: - print(f"➕ {len(added)} COW view(s) appeared:") + print(f"➕ {len(added)} {t('COW view(s) appeared')} :") for view_id in added: view = map_after[view_id] print(f" - id={view_id} {view.get('key')} ({view.get('mode')})") @@ -160,8 +172,8 @@ def diff(path_before, path_after): if old.get(field) != new.get(field): if field == "arch_md5": lst_field.append( - f"arch rewritten ({old.get('arch_len')} ->" - f" {new.get('arch_len')} chars)" + f"{t('arch rewritten')} ({old.get('arch_len')} ->" + f" {new.get('arch_len')} {t('chars')})" ) else: lst_field.append( @@ -171,14 +183,14 @@ def diff(path_before, path_after): lst_changed.append((view_id, new.get("key"), lst_field)) if lst_changed: - print(f"✏️ {len(lst_changed)} COW view(s) changed:") + print(f"✏️ {len(lst_changed)} {t('COW view(s) changed')} :") for view_id, key, lst_field in lst_changed: print(f" - id={view_id} {key}") for change in lst_field: print(f" {change}") if not (removed or added or lst_changed): - print("✅ -> No change on the website COW views.") + print(f"✅ -> {t('No change on the website COW views.')}") return 0 diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index 5c6e72f..6b77a99 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -1160,7 +1160,7 @@ class TodoUpgrade: # TODO update dev environment for git project # TODO Redeploy new production after upgrade # 2 upgrades version = 5 environnement. 0-prod init, 1-dev init, 2-dev01, 3-dev02, 4-prod final - print("Welcome to Odoo database upgrade processus with ERPLibre 🤖") + print(t("Welcome to the Odoo database upgrade with ERPLibre") + " 🤖") self.lst_command_executed = [] self.dct_module_per_version = {} self.dct_module_per_dct_version_path = {} @@ -1195,10 +1195,12 @@ class TodoUpgrade: self.file_path = self.dct_progression["migration_file"] else: print("") - print("Select the zip file of you database backup.") + print(t("Select the zip file of your database backup.")) self.file_path = input( - "💬 Give the path of file, or empty to use a File Browser, or type 'remote' to download from production : " + f"💬 {t('Give the path of the file, or empty to use a file')}" + f" {t('browser, or type')} 'remote'" + f" {t('to download from production')} : " ) if not self.file_path.strip(): self.file_path = None @@ -1214,19 +1216,22 @@ class TodoUpgrade: ) if status: _logger.error( - "Cannot retrieve database from remote, please retry migration." + t( + "Cannot retrieve the database from remote, please" + " retry the migration." + ) ) return self.dct_progression["migration_file"] = self.file_path self.write_config() - print(f"✅ Open file {self.file_path}") + print(f"✅ {t('Open file')} {self.file_path}") with zipfile.ZipFile(self.file_path, "r") as zip_ref: manifest_file_1 = zip_ref.open("manifest.json") json_manifest_file_1 = json.load(manifest_file_1) odoo_actual_version = json_manifest_file_1.get("version") - print(f"✅ Detect version Odoo CE '{odoo_actual_version}'.") + print(f"✅ {t('Detected Odoo CE version')} '{odoo_actual_version}'.") # print("What is your actual Odoo version?") lst_version, lst_version_installed, odoo_installed_version = ( @@ -1243,7 +1248,7 @@ class TodoUpgrade: if "target_odoo_version" in self.dct_progression: odoo_target_version = self.dct_progression["target_odoo_version"] else: - print("💬 Which version do you want to upgrade to?") + print(f"💬 {t('Which version do you want to upgrade to?')}") odoo_target_version = None cmd_no_found = True while cmd_no_found: @@ -1258,7 +1263,7 @@ class TodoUpgrade: except ValueError: pass if cmd_no_found: - print("Commande non trouvée 🤖!") + print(t("Command not found") + " 🤖!") self.dct_progression["target_odoo_version"] = odoo_target_version self.write_config() @@ -1277,7 +1282,7 @@ class TodoUpgrade: self.dct_progression["lst_module_per_version_origin"] = lst_module # TODO need support minor version, example 18.2, the .2 (no need for OCE OCB) - print("✨ Show documentation version :") + print(f"✨ {t('Documentation for this version')} :") # TODO Generate it locally and show it if asked for next_version in range_version: @@ -1287,10 +1292,10 @@ class TodoUpgrade: # ⚠️ ℹ 💬 ❗ 🔷 ✨ 🟦 🔹 🔵 ⟳ ⧖ ⚙ ✔ ✅ ❌ ⏵ ⏸ ⏹ ◆ ◇ … ➤ ⚑ ★ ☆ ☰ ⬍ ⍟ ⊗ ⌘ ⏻ ⍰ msg = "0 - Inspect zip" - print(f"🔷 {msg}") + self.print_step(msg) self.add_comment_progression(msg) - print("✅ -> Search odoo version") - print("✅ -> Find good environment, read the .zip file") + print(f"✅ -> {t('Search the Odoo version')}") + print(f"✅ -> {t('Find the right environment, read the .zip file')}") is_state_4_reach_open_upgrade = self.dct_progression.get( "state_4_reach_open_upgrade" @@ -1311,7 +1316,8 @@ class TodoUpgrade: "odoo", "" ).replace(".0", "") want_continue = input( - f"💬 Would you like to install '{odoo_version_to_install}' (y/Y) : " + f"💬 {t('Would you like to install')}" + f" '{odoo_version_to_install}' (y/Y) : " ) if want_continue.strip().lower() != "y": return @@ -1321,7 +1327,8 @@ class TodoUpgrade: if not os.path.isfile(FILENAME_ODOO_VERSION): print( - "⚠️ You need an installed system before continue, check your Odoo installation." + f"⚠️ {t('You need an installed system before')}" + f" {t('continuing, check your Odoo installation.')}" ) return @@ -1333,7 +1340,7 @@ class TodoUpgrade: # self.dct_progression["state_0_switch_odoo"] = True # self.write_config() - print("✅ -> Install environment if missing") + print(f"✅ -> {t('Install the environment if missing')}") if not self.dct_progression.get("state_0_search_missing_module"): self.switch_odoo(odoo_actual_version) @@ -1374,15 +1381,16 @@ class TodoUpgrade: self.dct_progression["lst_module_duplicate"] = lst_module_duplicate self.write_config() if lst_module_missing or lst_module_duplicate: - print("Cannot setup environment to begin.") + print(t("Cannot set up the environment to begin.")) if lst_module_missing: - print("Missing module :") + print(f"{t('Missing module')} :") print(lst_module_missing) if lst_module_duplicate: - print("Duplicate module :") + print(f"{t('Duplicate module')} :") print(lst_module_duplicate) want_continue = input( - "💬 Detect error missing/duplicate module init, do you want to continue? (Y/N): " + f"💬 {t('Missing or duplicate module detected at init,')}" + f" {t('do you want to continue?')} (Y/N) : " ) if want_continue.strip().lower() != "y": return @@ -1393,21 +1401,23 @@ class TodoUpgrade: # TODO fill from config lst_module_missing = [] - print("✅ -> Search missing module") + print(f"✅ -> {t('Search missing modules')}") print( - "❌ -> Install missing module, do a research or ask to uninstall it (can break data)" + f"❌ -> {t('Install the missing modules, search for them or')}" + f" {t('ask to uninstall them (can break data)')}" ) msg = "1 - Import database from zip" - print(f"🔷 {msg}") + self.print_step(msg) self.add_comment_progression(msg) database_name = self.dct_progression.get("config_database_name") if not database_name: database_name = ( input( - f"💬 Witch database name do you want to work with? Default ({default_database_name}) : " + f"💬 {t('Which database name do you want to work with?')}" + f" {t('Default')} ({default_database_name}) : " ).strip() or default_database_name ) @@ -1416,7 +1426,7 @@ class TodoUpgrade: do_neutralize = False if not self.dct_progression.get("state_1_neutralize_database"): - print("[1] Ignore neutralize database") + print(f"[1] {t('Ignore the database neutralization')}") wait_continue = ( self.ask_gate( "💬 " @@ -1432,20 +1442,22 @@ class TodoUpgrade: self.dct_progression["config_database_name"] = database_name self.write_config() - print(f"★ Work with database '{database_name}'") + print(f"★ {t('Working with database')} '{database_name}'") if not self.dct_progression.get("state_1_restore_database"): file_name = os.path.basename(self.file_path) image_db_file_path = os.path.join("image_db", file_name) str_will_copy = ( - f"🤖 will copy '{self.file_path}' to '{image_db_file_path}'" + f"🤖 {t('will copy')} '{self.file_path}'" + f" {t('to')} '{image_db_file_path}'" ) if not shutil._samefile(self.file_path, image_db_file_path): do_copy = False if os.path.exists(image_db_file_path): status_overwrite_image_db = input( f"{str_will_copy}, " - f"a file already exist, do you want to continue (y/Y) : " + f"{t('a file already exists, do you want to')}" + f" {t('continue?')} (y/Y) : " ).strip() if status_overwrite_image_db.lower() == "y": do_copy = True @@ -1469,16 +1481,18 @@ class TodoUpgrade: self.dct_progression["state_1_restore_database"] = True self.write_config() - print("✅ -> Restore database") + print(f"✅ -> {t('Restore the database')}") already_update_state_1 = False if not self.dct_progression.get("state_1_update_all"): print( - "[1] Update all addons before neutralize (already neutralize by odoo if supported)" + f"[1] {t('Update all addons before neutralizing (already')}" + f" {t('neutralized by Odoo if supported)')}" ) wait_continue = ( input( - "💬 Do you need to upgrade before a database neutralization, press to ignore : " + f"💬 {t('Do you need to upgrade before neutralizing the')}" + f" {t('database? Press enter to ignore')} : " ) .strip() .lower() @@ -1501,9 +1515,11 @@ class TodoUpgrade: self.dct_progression["state_2_done_early"] = True self.write_config() - print("✅ -> Update database before neutralize by module") + print( + f"✅ -> {t('Update the database before neutralizing, by module')}" + ) - print("✅ -> Neutralize database") + print(f"✅ -> {t('Neutralize the database')}") if do_neutralize: status, cmd_executed = self.todo_upgrade_execute( f"./script/addons/update_prod_to_dev.sh {database_name}", @@ -1541,7 +1557,7 @@ class TodoUpgrade: self.read_uninstall_module_list(start_version, database_name) ) if lst_uninstall_reason: - print("✨ Modules to uninstall before migration :") + print(f"✨ {t('Modules to uninstall before the migration')} :") self.print_uninstall_reason(lst_uninstall_reason) if config_state_1_uninstall_module: @@ -1562,9 +1578,9 @@ class TodoUpgrade: self.write_config() - print("✅ -> Uninstall module") + print(f"✅ -> {t('Uninstall modules')}") - print("✅ -> install module") + print(f"✅ -> {t('Install modules')}") if not is_state_4_reach_open_upgrade: lst_module_to_install = [] if config_state_1_install_module: @@ -1583,7 +1599,7 @@ class TodoUpgrade: self.write_config() msg = "2 - Succeed update all addons" - print(f"🔷 {msg}") + self.print_step(msg) self.add_comment_progression(msg) if self.needs_update_all(self.dct_progression, already_update_state_1): @@ -1614,11 +1630,14 @@ class TodoUpgrade: # ne remplace pas la possibilité de regarder maintenant. Les copies # sont visibles ici, et les neutraliser ici vaut pour tous les paliers # — chaque base de palier est un clone de celle-ci. - if output and "will break when moving to" in "\n".join(output): + # Le code de sortie, pas le texte : 1 = des copies casseront. + # Chercher une phrase anglaise dans la sortie rendait cette invite + # muette dès que l'outil parlait français. + if status == 1: self.prompt_cow_prediction(database_name, start_version + 1) msg = "3 - Clean up database before data migration" - print(f"🔷 {msg}") + self.print_step(msg) self.add_comment_progression(msg) if not self.dct_progression.get("state_3_install_clean_database"): @@ -1632,7 +1651,8 @@ class TodoUpgrade: if not self.dct_progression.get("state_3_clean_database"): print( - "✨ Aller dans «configuration/Technique/Nettoyage.../Purger» les modules obsolètes" + f"✨ {t('Go to Settings / Technical / Cleanup... / Purge and')}" + f" {t('purge the obsolete modules')}" ) status = self.ask_gate( "💬 Did you finish to clean database? Press y/Y to open" @@ -1642,7 +1662,9 @@ class TodoUpgrade: if status.lower().strip() == "y": self.todo.prompt_execute_selenium_and_run_db(database_name) - status = input("💬 Press to continue state.3 : ").strip() + status = input( + f"💬 {t('Press enter to continue step 3')} : " + ).strip() self.dct_progression["state_3_clean_database"] = True self.write_config() @@ -1650,7 +1672,7 @@ class TodoUpgrade: self.install_OCA_odoo_module_migrator() msg = "4 - Upgrade version with OpenUpgrade" - print(f"🔷 {msg}") + self.print_step(msg) self.add_comment_progression(msg) self.dct_progression["state_4_reach_open_upgrade"] = True @@ -1748,7 +1770,9 @@ class TodoUpgrade: self.switch_odoo(next_version - 1) print( - f"⧖ -> Clone to odoo.'{next_version}', from '{database_name}' to '{database_name_upgrade}'." + f"⧖ -> {t('Cloning to Odoo')}{next_version}," + f" {t('from')} '{database_name}'" + f" {t('to')} '{database_name_upgrade}'." ) # Delete if exist database self.todo_upgrade_execute( @@ -1766,18 +1790,22 @@ class TodoUpgrade: # a missing or truncated database). if status: print( - f"❌ -> Clone to Odoo{next_version} FAILED (status" - f" {status}). Stopping: '{database_name_upgrade}' is" - " not usable." + f"❌ -> {t('Clone to Odoo')}{next_version}" + f" {t('FAILED (status')} {status})." + f" {t('Stopping:')} '{database_name_upgrade}'" + f" {t('is not usable.')}" ) return lst_clone_odoo[index] = True self.dct_progression["state_4_clone_odoo_lst"] = lst_clone_odoo self.write_config() - print(f"✅ -> Clone Odoo{next_version} done") + print(f"✅ -> {t('Clone done for Odoo')}{next_version}") else: - print(f"✅ -> Clone Odoo{next_version} - nothing") + print( + f"✅ -> {t('Clone already done for Odoo')}" + f"{next_version}" + ) option_comment += 1 msg = f"4.{index}.{chr(option_comment + 65)} - Uninstall module" @@ -1801,7 +1829,8 @@ class TodoUpgrade: ) if lst_detail: print( - f"✨ Modules to uninstall before Odoo{next_version} :" + f"✨ {t('Modules to uninstall before Odoo')}" + f"{next_version} :" ) self.print_uninstall_reason(lst_detail) lst_module_to_uninstall = list( @@ -1886,9 +1915,15 @@ class TodoUpgrade: lst_switch_odoo ) self.write_config() - print(f"✅ -> Switch Odoo{next_version} done with update") + print( + f"✅ -> {t('Switch done with update for Odoo')}" + f"{next_version}" + ) else: - print(f"✅ -> Switch Odoo{next_version} - nothing") + print( + f"✅ -> {t('Switch already done for Odoo')}" + f"{next_version}" + ) lst_state_4_module_migrate_code = self.dct_progression.get( "config_state_4_module_to_migrate_code", @@ -1954,17 +1989,19 @@ class TodoUpgrade: ) if lst_module_duplicate: - print(f"Duplicate module into odoo{next_version} : ") + print(f"{t('Duplicate module in Odoo')}{next_version} : ") print(lst_module_duplicate) input( - f"💬 Detect error duplicate module, manage this problem manually and press to continue." + f"💬 {t('Duplicate module error detected, handle it')}" + f" {t('manually then press enter to continue.')}" ) # if lst_module_missing_next_version and not lst_module_to_migrate: if lst_module_missing_next_version: # TODO support when lst_module_to_migrate is fill lst_module_to_migrate = [] print( - f"👹 Detect error missing module, missing module into odoo{next_version} :" + f"👹 {t('Missing module error detected, missing in')}" + f" Odoo{next_version} :" ) for index_missing_module, module_missing in enumerate( lst_module_missing_next_version @@ -1975,13 +2012,14 @@ class TodoUpgrade: print( f"[{index_missing_module}] {module_missing} - {old_path}" ) - print("[a] All list above") - print("[e] Add extra custom") + print(f"[a] {t('All of the list above')}") + print(f"[e] {t('Add an extra custom one')}") want_continue = ( input( - f"💬 Enumerate missing module separate by coma to delete it" - f". The others will be migrate : " + f"💬 {t('List the missing modules to delete,')}" + f" {t('separated by commas. The others will be')}" + f" {t('migrated')} : " ) .strip() .lower() @@ -2043,7 +2081,8 @@ class TodoUpgrade: if "e" in lst_want_continue: want_continue = ( input( - f"💬 Enumerate module name to delete, separate by coma : " + f"💬 {t('List the module names to delete,')}" + f" {t('separated by commas')} : " ) .strip() .lower() @@ -2096,15 +2135,17 @@ class TodoUpgrade: if lst_module_missing_last: print( - f"Error missing module : {lst_module_missing_last}" + f"{t('Missing module error')} :" + f" {lst_module_missing_last}" ) if lst_module_duplicate_last: print( - f"Error duplicate module : {lst_module_duplicate_last}" + f"{t('Duplicate module error')} :" + f" {lst_module_duplicate_last}" ) if lst_module_error_last: print( - f"Error error module : {lst_module_error_last}" + f"{t('Module error')} : {lst_module_error_last}" ) if lst_module_exist_last: @@ -2247,9 +2288,15 @@ class TodoUpgrade: ) self.write_config() - print(f"✅ -> Module upgrade Odoo{next_version} done") + print( + f"✅ -> {t('Module upgrade done for Odoo')}" + f"{next_version}" + ) else: - print(f"✅ -> Module upgrade Odoo{next_version} - nothing") + print( + f"✅ -> {t('Module upgrade already done for Odoo')}" + f"{next_version}" + ) option_comment += 1 msg = f"4.{index}.{chr(option_comment + 65)} - Fix migrate code" @@ -2292,8 +2339,8 @@ class TodoUpgrade: # problem the fix exists to prevent. if status: print( - f"❌ -> Fix migration Odoo{next_version} FAILED" - f" (status {status}):" + f"❌ -> {t('Migration fix for Odoo')}{next_version}" + f" {t('FAILED (status')} {status}) :" f" {file_path_fix_migration}" ) return @@ -2303,13 +2350,20 @@ class TodoUpgrade: lst_fix_migration_odoo ) self.write_config() - print(f"✅ -> Fix migration Odoo{next_version} done") + print( + f"✅ -> {t('Migration fix done for Odoo')}" + f"{next_version}" + ) else: print( - f"✅ -> Fix migration Odoo{next_version} - no fix to execute" + f"✅ -> {t('No migration fix to run for Odoo')}" + f"{next_version}" ) else: - print(f"✅ -> Fix migration Odoo{next_version} - nothing") + print( + f"✅ -> {t('Migration fix already done for Odoo')}" + f"{next_version}" + ) for path_git_clone_migrate in lst_path_git_clone_migrate: cmd = f"./script/code/git_commit_migration_addons_path.py --path {path_git_clone_migrate} --odoo_version {next_version}.0" @@ -2340,10 +2394,17 @@ class TodoUpgrade: ) self.todo_upgrade_execute(cmd_update_config) - print("🚸 Please, validate commits after code migration.") - print("ℹ To show repo status :\nmake repo_show_status") print( - f"🚸 Please, validate this path into config.conf : '{path_addons_openupgrade}'." + f"🚸 {t('Please validate the commits after the code')}" + f" {t('migration.')}" + ) + print( + f"ℹ {t('To show the repo status')} :" + "\nmake repo_show_status" + ) + print( + f"🚸 {t('Please validate this path in config.conf')} :" + f" '{path_addons_openupgrade}'." ) status = self.ask_gate( f"💬 {t('Press to continue')} {msg}" @@ -2401,15 +2462,17 @@ class TodoUpgrade: ) self.write_config() print( - f"\n❌ -> Database migration to Odoo{next_version}" - f" FAILED (status {status}).\n" - f" '{database_name_upgrade}' is now half migrated:" - " replaying the command on it would never recover, so" - " it is NOT offered.\n" - " The clone step has been reset. Fix the cause, then" - " relaunch the migration and answer [c] (continue):" - f" '{database_name_upgrade}' will be dropped and" - " rebuilt from the previous version before retrying." + f"\n❌ -> {t('Database migration to Odoo')}" + f"{next_version} {t('FAILED (status')} {status}).\n" + f" '{database_name_upgrade}'" + f" {t('is now half migrated: replaying the command on')}" + f" {t('it would never recover, so it is NOT offered.')}" + f"\n {t('The clone step has been reset. Fix the')}" + f" {t('cause, then relaunch the migration and answer')}" + f" [c] ({t('continue')}) :" + f" '{database_name_upgrade}'" + f" {t('will be dropped and rebuilt from the previous')}" + f" {t('version before retrying.')}" ) return @@ -2435,7 +2498,10 @@ class TodoUpgrade: status = ( input( - f"💬 Do you want to upgrade all{str_wait_next_version}? Press y/Y to upgrade all addons database : " + f"💬 {t('Do you want to upgrade all')}" + f"{str_wait_next_version} ?" + f" {t('Press y/Y to upgrade all addons of the')}" + f" {t('database')} : " ) .strip() .lower() @@ -2446,15 +2512,19 @@ class TodoUpgrade: f"./script/addons/update_addons_all.sh {database_name_upgrade}", ) - print(f"✅ -> Database upgrade Odoo{next_version} done") + print( + f"✅ -> {t('Database upgrade done for Odoo')}" + f"{next_version}" + ) # Update config without OCA_OpenUpgrade cmd_update_config = f"./script/git/git_repo_update_group.py && ./script/generate_config.sh" self.todo_upgrade_execute(cmd_update_config) - print("[y] Open server with Selenium") + print(f"[y] {t('Open the server with Selenium')}") status = ( input( - "💬 Do you want to test this upgrade? Choose or press to ignore it : " + f"💬 {t('Do you want to test this upgrade? Choose')}" + f" {t('or press enter to ignore it')} : " ) .strip() .lower() @@ -2465,39 +2535,44 @@ class TodoUpgrade: database_name_upgrade ) status = input( - f"💬 Press to continue 4.{index} : " + f"💬 {t('Press enter to continue')} 4.{index} : " ).strip() else: - print(f"✅ -> Database upgrade Odoo{next_version} - nothing") + print( + f"✅ -> {t('Database upgrade already done for Odoo')}" + f"{next_version}" + ) # # waiting_input = input("💬 Press any keyboard key to continue...") print("") msg = "5 - Cleaning up database after upgrade" - print(f"🔷 {msg}") + self.print_step(msg) self.add_comment_progression(msg) print( - "✨ Re-update i18n, purger data, tables (except mail_test and mail_test_full)" + f"✨ {t('Re-update i18n, purge the data and the tables')}" + f" ({t('except mail_test and mail_test_full')})" ) # waiting_input = input("💬print Press any keyboard key to continue...") msg = "6 - Migration finished" - print(f"🔷 {msg}") + self.print_step(msg) self.add_comment_progression(msg) cmd_backup_template = f"./odoo_bin.sh db --backup --database {database_name_upgrade} --restore_image" cmd_backup = f"{cmd_backup_template} {database_name_upgrade}_finish_{datetime.datetime.now().strftime('%Y%m%d%H%M%S')}" - print(f"✨ Can execute backup creation :\n{cmd_backup}") + print(f"✨ {t('A backup can be created')} :\n{cmd_backup}") status = input( - "💬 Press y/Y or write filename.zip to export or enter to continue : " + f"💬 {t('Press y/Y or type filename.zip to export, or')}" + f" {t('enter to continue')} : " ).strip() if status.lower(): if status.lower() != "y": cmd_backup = f"{cmd_backup_template} {status}" self.todo_upgrade_execute(cmd_backup) - status = input("💬 Test the migration, press y/Y : ") + status = input(f"💬 {t('Test the migration, press y/Y')} : ") if status.lower().strip() == "y": self.todo.prompt_execute_selenium_and_run_db(database_name_upgrade) @@ -2619,6 +2694,18 @@ class TodoUpgrade: wait_at_error=False, ) + def print_step(self, msg): + """Affiche un en-tête d'étape en traduisant son seul libellé. + + `msg` reste anglais : il part aussi dans le journal, que l'écran de + reprise relit. Traduire ce qui est ÉCRIT rendrait un journal + illisible pour l'autre langue, et l'étape 4 numérote ses en-têtes + (« 4.2.C - Install module ») — seule la partie après le tiret est + une phrase. + """ + prefix, sep, label = msg.partition(" - ") + print(f"🔷 {prefix}{sep}{t(label)}" if sep else f"🔷 {t(msg)}") + def show_cow_drift(self, database_name, next_version, mode="diff"): """Montre les copies COW à risque. Ne touche à rien. @@ -2719,7 +2806,9 @@ class TodoUpgrade: status, cmd_executed, output = self.todo_upgrade_execute( cmd, get_output=True, wait_at_error=False ) - if "No website COW view to neutralize" in "\n".join(output or []): + # 0 = rien à neutraliser. Lire une phrase anglaise dans la sortie + # faisait poser la question dès que l'outil parlait français. + if status == 0: return # « v » et « w » avant de répondre : la question demande de renoncer à @@ -3072,7 +3161,7 @@ class TodoUpgrade: if (status is None or status) and wait_at_error: database_name = self.database_from_command(cmd) while True: - print("[1] to redo the command") + print(f"[1] {t('to redo the command')}") if database_name: print( f"[2] {t('Check the COW views that drifted')}" @@ -3080,8 +3169,8 @@ class TodoUpgrade: ) wait_status = ( input( - "💬 Error detected, press to continue or ctrl+c to" - " stop : " + f"💬 {t('Error detected, press enter to continue or')}" + f" ctrl+c {t('to stop')} : " ) .strip() .lower() diff --git a/test/test_migration_tools_i18n.py b/test/test_migration_tools_i18n.py new file mode 100644 index 0000000..6d07c4a --- /dev/null +++ b/test/test_migration_tools_i18n.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Les outils de migration parlaient anglais sur un système en français. + +Ils tournent en sous-processus depuis le pilote, et n'avaient aucun i18n : la +migration alternait donc les deux langues d'une ligne à l'autre. + +Deux choses à tenir, et la seconde est un piège : le pilote CHERCHAIT des +phrases anglaises dans leur sortie pour décider s'il devait poser une question +(« will break when moving to », « No website COW view to neutralize »). +Traduire ces phrases l'aurait rendu muet — sans erreur, sans trace. Le lien +passe désormais par le code de sortie, que la langue ne touche pas. +""" + +import ast +import os +import subprocess +import sys +import unittest + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +# Les outils COW, ceux que la migration lance et dont la sortie est lue par un +# humain au milieu d'une migration de plusieurs heures. +TOOLS = ( + "check_cow_views.py", + "cow_drift.py", + "neutralize_cow_views.py", + "reset_stale_cow_views.py", + "snapshot_cow_views.py", +) + +# Ce qui n'est pas de la prose : du SQL, des chemins, des drapeaux. +NOT_PROSE = ("SELECT ", "UPDATE ", "./script/", "--", "id=", " @@ -155,6 +157,8 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi - Magasin d'applications d'une VM graphique : .deb seul, outillage Flatpak, ou snap - Tunnel vers le bureau distant : la commande SSH est composée depuis ~/.ssh/config, avec la console de l'hyperviseur comme troisième voie - Le tableau de bord d'installation agit sur une VM sans la quitter : mise à jour par parties, redémarrage d'Odoo, suppression ; colonne d'architecture et colonnes ajustables +- Migration : revenir à une étape depuis l'écran de reprise ou depuis une invite, et agir sur les copies COW dès leur annonce +- L'analyse des copies de site compare chaque copie à la vue qu'elle masque - Un test vérifie que ce qu'importe l'outillage est bien déclaré @@ -249,6 +253,8 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi - A graphical desktop install froze for thirty minutes on a snap package that could not reach the store - The compiler was killed for lack of memory while building CPython on a small s390x guest - The README listed neither Fedora, openSUSE, Linux Mint nor Debian 13, all of them supported +- The COW migration tools and the database upgrade speak the system language +- The analysis and migration tools are executable @@ -266,6 +272,8 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi - L'installation d'un bureau graphique figeait trente minutes sur un paquet snap qui ne joignait pas le magasin - Le compilateur était tué faute de mémoire en bâtissant CPython sur une petite VM s390x - Le README ne listait ni Fedora, ni openSUSE, ni Linux Mint, ni Debian 13, toutes supportées +- Les outils de migration COW et la mise à niveau de la base parlent la langue du système +- Les outils d'analyse et de migration sont exécutables ## Security diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index d04e4ed..0a4386c 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -71,6 +71,8 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi - Magasin d'applications d'une VM graphique : .deb seul, outillage Flatpak, ou snap - Tunnel vers le bureau distant : la commande SSH est composée depuis ~/.ssh/config, avec la console de l'hyperviseur comme troisième voie - Le tableau de bord d'installation agit sur une VM sans la quitter : mise à jour par parties, redémarrage d'Odoo, suppression ; colonne d'architecture et colonnes ajustables +- Migration : revenir à une étape depuis l'écran de reprise ou depuis une invite, et agir sur les copies COW dès leur annonce +- L'analyse des copies de site compare chaque copie à la vue qu'elle masque - Un test vérifie que ce qu'importe l'outillage est bien déclaré ## Retiré @@ -121,6 +123,8 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi - L'installation d'un bureau graphique figeait trente minutes sur un paquet snap qui ne joignait pas le magasin - Le compilateur était tué faute de mémoire en bâtissant CPython sur une petite VM s390x - Le README ne listait ni Fedora, ni openSUSE, ni Linux Mint, ni Debian 13, toutes supportées +- Les outils de migration COW et la mise à niveau de la base parlent la langue du système +- Les outils d'analyse et de migration sont exécutables ## Sécurité diff --git a/CHANGELOG.md b/CHANGELOG.md index 300e02f..2917223 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,8 @@ Recreating the virtual environment, use installation guide from tool `make`. - Application store of a graphical VM: .deb only, Flatpak tooling, or snap - Remote desktop tunnel: the SSH command is composed from ~/.ssh/config, with the hypervisor console as a third route - The install dashboard acts on a VM without leaving it: update by parts, restart Odoo, delete; architecture column and resizable columns +- Migration: go back to a step from the resume screen or from a prompt, and act on the COW copies as soon as they are announced +- The website copy analysis diffs each copy against the view it shadows - A test checks that what the tooling imports is declared ## Removed @@ -119,6 +121,8 @@ Recreating the virtual environment, use installation guide from tool `make`. - A graphical desktop install froze for thirty minutes on a snap package that could not reach the store - The compiler was killed for lack of memory while building CPython on a small s390x guest - The README listed neither Fedora, openSUSE, Linux Mint nor Debian 13, all of them supported +- The COW migration tools and the database upgrade speak the system language +- The analysis and migration tools are executable ## Security From aa2f78c404e9c6d8af4bdc5de158c273f37dfb48 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 13 Aug 2026 05:56:06 -0400 Subject: [PATCH 12/67] [ADD] addons: uninstall a theme the way Odoo removes one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- script/addons/theme_leftover.py | 141 +++++++++++++++++++++++ script/addons/uninstall_addons_theme.sh | 92 +++++++++++++++ script/todo/todo_i18n.py | 20 ++++ test/test_uninstall_addons_theme.py | 146 ++++++++++++++++++++++++ 4 files changed, 399 insertions(+) create mode 100755 script/addons/theme_leftover.py create mode 100755 script/addons/uninstall_addons_theme.sh create mode 100755 test/test_uninstall_addons_theme.py diff --git a/script/addons/theme_leftover.py b/script/addons/theme_leftover.py new file mode 100755 index 0000000..38b60da --- /dev/null +++ b/script/addons/theme_leftover.py @@ -0,0 +1,141 @@ +#!/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 + + +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" + + +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) + 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)) + return 1 if (attachments or views) else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/addons/uninstall_addons_theme.sh b/script/addons/uninstall_addons_theme.sh new file mode 100755 index 0000000..5eafad4 --- /dev/null +++ b/script/addons/uninstall_addons_theme.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Désinstaller un thème comme Odoo le fait lui-même. +# +# « ./run.sh --uninstall theme_x » retire le MODULE, pas le THÈME. Choisir un +# thème (button_choose_theme, ce que pose --install-theme) fait DEUX choses : +# copier ses vues et ressources 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. +# +# Le désinstaller sans lui laisse donc les copies, et surtout n'écrit jamais +# ces définitions. 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 +# une personnalisation figée depuis des années. +# +# Usage : ./script/addons/uninstall_addons_theme.sh [config] + +Red='\033[0;31m' # Red +Color_Off='\033[0m' # Text Reset + +if [[ $# -lt 2 ]]; then + echo "Usage: $0 [config]" + exit 1 +fi + +DATABASE="$1" +THEME="$2" +CONFIG="${3:-./config.conf}" + +if [[ $# -eq 3 ]]; then + ./script/addons/check_addons_exist.py -m "$THEME" -c "$3" +else + ./script/addons/check_addons_exist.py -m "$THEME" +fi +retVal=$? +if [[ $retVal -ne 0 ]]; then + echo -e "${Red}Error${Color_Off} check_addons_exist.py into uninstall_addons_theme.sh" + exit 1 +fi + +echo "Unload theme '$THEME' from every website of BD '$DATABASE'" + +# Le déchargement passe par le shell : _theme_remove() n'a pas d'option de +# ligne de commande, et l'écrire ici évite d'ajouter une option au fork pour +# chaque version d'Odoo. Les journaux se mêlent à la sortie, d'où la +# sentinelle : on ne conclut que sur ce qui la suit. +./odoo_bin.sh shell -c "$CONFIG" -d "$DATABASE" --no-http --log-level=warn < Date: Sat, 15 Aug 2026 02:36:28 -0400 Subject: [PATCH 13/67] [ADD] migration: offer to remove the themes before the first bump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- script/todo/todo_i18n.py | 20 +++++++ script/todo/todo_upgrade.py | 61 +++++++++++++++++++ test/test_uninstall_addons_theme.py | 91 +++++++++++++++++++++++++++++ 3 files changed, 172 insertions(+) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 82d8ad8..74d9734 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -4770,6 +4770,26 @@ TRANSLATIONS = { "fr": "d'une personnalisation. Lire avant de retirer.", "en": "of a customization. Read before removing.", }, + "Installed theme(s) on": { + "fr": "Thème(s) installé(s) sur", + "en": "Installed theme(s) on", + }, + "A theme carries view copies and SCSS through every": { + "fr": "Un thème traîne des copies de vues et des SCSS à travers chaque", + "en": "A theme carries view copies and SCSS through every", + }, + "version bump, and a bump can rename what they rely on.": { + "fr": "palier de version, et un palier peut renommer ce dont ils dépendent.", + "en": "version bump, and a bump can rename what they rely on.", + }, + "Uninstall them properly before migrating?": { + "fr": "Les désinstaller proprement avant de migrer ?", + "en": "Uninstall them properly before migrating?", + }, + "Kept. Nothing was uninstalled.": { + "fr": "Conservés. Rien n'a été désinstallé.", + "en": "Kept. Nothing was uninstalled.", + }, "Nothing to decide yet": { "fr": "Rien à décider pour l'instant", "en": "Nothing to decide yet", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index 6b77a99..0072e92 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -1545,6 +1545,11 @@ class TodoUpgrade: self.dct_progression["state_1_neutralize_database"] = True self.write_config() + if not self.dct_progression.get("state_1_theme_uninstalled"): + self.prompt_uninstall_theme(database_name) + self.dct_progression["state_1_theme_uninstalled"] = True + self.write_config() + config_state_1_uninstall_module = self.dct_progression.get( "config_state_1_uninstall_module" ) @@ -2706,6 +2711,62 @@ class TodoUpgrade: prefix, sep, label = msg.partition(" - ") print(f"🔷 {prefix}{sep}{t(label)}" if sep else f"🔷 {t(msg)}") + def installed_theme(self, database_name): + """Thèmes installés, hors theme_default qui EST l'absence de thème.""" + status, _cmd, output = self.todo_upgrade_execute( + f'psql -X -w -d {database_name} -tAc "SELECT name FROM' + " ir_module_module WHERE name LIKE 'theme%' AND" + " state = 'installed' AND name <> 'theme_default'" + ' ORDER BY name;"', + get_output=True, + wait_at_error=False, + quiet=True, + ) + if status: + return [] + return [line.strip() for line in (output or []) if line.strip()] + + def prompt_uninstall_theme(self, database_name): + """Proposer de retirer les thèmes AVANT de monter de version. + + 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 retirer d'abord enlève d'un coup une famille + entière de pannes, et se refait après. + + « non » par défaut : retirer un thème change l'apparence du site, et + ce n'est pas à une migration de le décider à la place de quelqu'un. + La question n'est posée que s'il y a un thème à retirer. + """ + lst_theme = self.installed_theme(database_name) + if not lst_theme: + return + print( + f"\n✨ {t('Installed theme(s) on')} '{database_name}' :" + f" {', '.join(lst_theme)}" + ) + print( + f" {t('A theme carries view copies and SCSS through every')}" + f" {t('version bump, and a bump can rename what they rely on.')}" + ) + answer = ( + self.ask_gate( + f"💬 {t('Uninstall them properly before migrating?')}" + f" (y/N, {t('(b = go back to a previous step)')}) : " + ) + .strip() + .lower() + ) + if answer != "y": + print(f"ℹ -> {t('Kept. Nothing was uninstalled.')}") + return + for theme in lst_theme: + self.todo_upgrade_execute( + f"./script/addons/uninstall_addons_theme.sh" + f" {database_name} {theme}", + wait_at_error=False, + ) + def show_cow_drift(self, database_name, next_version, mode="diff"): """Montre les copies COW à risque. Ne touche à rien. diff --git a/test/test_uninstall_addons_theme.py b/test/test_uninstall_addons_theme.py index 7483e53..bf22e3e 100755 --- a/test/test_uninstall_addons_theme.py +++ b/test/test_uninstall_addons_theme.py @@ -122,6 +122,97 @@ class TestTheLeftoverReport(unittest.TestCase): self.assertIn("default_transaction_read_only=on", source) +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] + obj.ask_gate = lambda prompt: answer + 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() + + def test_the_default_answer_uninstalls_nothing(self): + # « Entrée » ne doit RIEN faire : la migration ne décide pas de + # l'apparence d'un site à la place de son propriétaire. + lst_cmd, text = self.run_prompt(["theme_technolibre"], "") + self.assertEqual(lst_cmd, []) + self.assertIn("Kept", text) + + 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) + + class TestExitCodes(unittest.TestCase): """0 rien, 1 des restes, 2 l'outil a échoué — comme les outils voisins.""" From 4bc82b4caa87c4c9dae83d0c821f0fd3414dfeb6 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sat, 15 Aug 2026 03:21:25 -0400 Subject: [PATCH 14/67] [ADD] migration: predict which customized SCSS the next bump breaks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Customized SCSS lives in ir_attachment, frozen the day it is written, still using the variables of that version. A bump can rename them: website declared $o-theme-font-number in 12.0 and replaced the whole mechanism in 13.0, so a 2020 customization stopped the frontend bundle. Same shape as a stale COW view. Reading the stored SCSS against the target sources answers before the bump and names the attachment. Booting Odoo and opening a page answers after, with « Style error » and no name — on a page a broken frontend bundle is exactly what keeps you from reaching. Measured: on the pre-bump database, targeting 13.0, it predicts the failure; against its own 12.0 sources it reports nothing. That noise floor is what makes it usable — the first version cried wolf on six mixin parameters and named @include arguments. --- FR --- [ADD] migration : prédire quel SCSS personnalisé le palier va casser Un SCSS personnalisé vit dans ir_attachment, figé le jour où il est écrit, employant encore les variables de cette version-là. Un palier peut les renommer : website déclarait $o-theme-font-number en 12.0 et a remplacé le mécanisme en 13.0, arrêtant le bundle sur une personnalisation de 2020. Même forme qu'une vue COW périmée. Lire le SCSS stocké contre les sources cibles répond AVANT le palier et nomme la pièce jointe. Démarrer Odoo et ouvrir une page répond après, par « Style error » sans nom — et un bundle cassé est justement ce qui empêche d'atteindre la page. Mesuré : sur la base d'avant le palier, cible 13.0, il prédit la panne ; contre ses propres sources 12.0 il ne signale rien. Ce bruit de fond nul est ce qui le rend utilisable — la première version criait à tort sur six paramètres de mixin et arguments nommés. Assisted-by: Claude Opus 5 (cherry picked from commit c6611ee1431cfee94e321536d20052b8cca3ec10) --- script/odoo/migration/check_stale_scss.py | 326 ++++++++++++++++++++++ script/todo/todo_i18n.py | 32 +++ script/todo/todo_upgrade.py | 16 ++ test/test_check_stale_scss.py | 160 +++++++++++ 4 files changed, 534 insertions(+) create mode 100755 script/odoo/migration/check_stale_scss.py create mode 100755 test/test_check_stale_scss.py diff --git a/script/odoo/migration/check_stale_scss.py b/script/odoo/migration/check_stale_scss.py new file mode 100755 index 0000000..e9c51e3 --- /dev/null +++ b/script/odoo/migration/check_stale_scss.py @@ -0,0 +1,326 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Predict which customized SCSS will break the next version bump. + +Background +---------- +Customizing a website writes SCSS into ``ir_attachment``: files whose URL +carries ``.custom.``. That copy is frozen the day it is written, and it keeps +using the variables of THAT version. + +A module can rename its variables between two Odoo versions. Measured on a +real database: ``website/static/src/scss/primary_variables.scss`` declared +``$o-theme-font-number`` in 12.0 and replaced the whole mechanism in 13.0 with +a ``$o-website-values-palettes`` map. A customization written in 2020 still +asked for the old name. The bundle then stops on:: + + Error: Undefined variable: "$o-theme-font-number". + This error occured while compiling the bundle 'web.assets_frontend' + +So the rule is the same as for COW views: + + a customization breaks when what it USES stops being DEFINED between + version N and version N+1. + +Why not just start Odoo and open a page +--------------------------------------- +Because that only tells you *after* the bump, on a page that renders a +« Style error » with no name attached: not which attachment, not which +variable, not since when. And a page has to be reached at all — a broken +frontend bundle is exactly when it is not. + +Reading the stored SCSS against the target sources answers before the bump, +in a second, and names the attachment. Nothing is written here. + +What it can miss +---------------- +Which files a bundle really contains is decided by Odoo at runtime. This +compares against every SCSS of the INSTALLED modules of the target version, +which is wider: a variable defined in a module file that the bundle does not +include would be counted as defined. That direction is deliberate — it under- +reports rather than crying wolf on every customization. + +Exit codes: 0 nothing to report, 1 customizations at risk, 2 tool failure. +""" + +import argparse +import glob +import os +import re +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 + + +# Une variable SCSS commence par $ ; celles dont le nom commence par un tiret +# sont locales au fichier par convention Odoo ($-seen, $-font-numbers) et se +# définissent toujours au-dessus de leur usage. +RE_ANY = re.compile(r"\$([a-zA-Z][\w-]*)") + + +def used_names(content): + """Noms LUS par le fichier, sans les liaisons. + + Un « $nom » suivi d'un deux-points est toujours une liaison — déclaration, + valeur par défaut, ou argument nommé d'un @include — jamais la lecture + d'une variable. Sans cette réserve, « @include o-position-absolute( + $right: 50%) » se lisait comme l'usage d'un $right inexistant. + + Le test se fait ici et non par une négation dans le motif : une négation + en tête de motif fait rétrograder le nom pour satisfaire la condition, et + rapporte « $botto » au lieu d'écarter « $bottom ». Mesuré. + """ + names = set() + for match in RE_ANY.finditer(content): + if content[match.end() :].lstrip(" \t").startswith(":"): + continue + names.add(match.group(1)) + return names + + +RE_DEF = re.compile(r"^\s*\$([a-zA-Z][\w-]*)\s*:", re.M) + +# Une variable peut aussi être LIÉE sans être définie : paramètre de mixin ou +# de fonction, variable de boucle. Les ignorer donnait six faux positifs sur +# le seul fichier mesuré — $bottom, $counter, $off, $on, $right, $value — et +# un détecteur qui crie à tort finit ignoré, ce qui vaut moins que rien. +RE_PARAM = re.compile(r"@(?:mixin|function)\s+[\w-]+\s*\(([^)]*)\)") +RE_EACH = re.compile(r"@each\s+([^i]+?)\s+in\s", re.S) +RE_FOR = re.compile(r"@for\s+\$([\w-]+)\s+from\s") + + +def bound_names(content): + """Noms liés par le fichier lui-même, hors définitions « $x: … ».""" + names = set() + for group in RE_PARAM.findall(content): + names.update(RE_ANY.findall(group)) + for group in RE_EACH.findall(content): + names.update(RE_ANY.findall(group)) + names.update(RE_FOR.findall(content)) + return names + + +def run_psql(database, sql): + """Interroger la base, lecture seule garantie par le serveur.""" + env = os.environ.copy() + env["PGOPTIONS"] = ( + "-c default_transaction_read_only=on -c statement_timeout=60000" + ) + env["PSQLRC"] = "" + done = subprocess.run( + [ + "psql", + "-X", + "-w", + "-v", + "ON_ERROR_STOP=1", + "-d", + database, + "-tAF", + "\x1f", + "-c", + sql, + ], + capture_output=True, + text=True, + env=env, + ) + if done.returncode: + raise RuntimeError(done.stderr.strip() or "psql failed") + return [line.split("\x1f") for line in done.stdout.splitlines() if line] + + +def filestore_dir(database, override=None): + if override: + return override + return os.path.join( + os.path.expanduser("~"), + ".local", + "share", + "Odoo", + "filestore", + database, + ) + + +def custom_scss(database, filestore): + """[(id, url, contenu)] pour chaque SCSS personnalisé lisible.""" + rows = run_psql( + database, + "SELECT id, url, COALESCE(store_fname, '')," + " COALESCE(encode(db_datas, 'escape'), '') FROM ir_attachment" + " WHERE url LIKE '%.custom.%' AND url NOT LIKE '%.css'" + " ORDER BY id;", + ) + lst = [] + for row in rows: + if len(row) < 4: + continue + att_id, url, store_fname, db_datas = row[0], row[1], row[2], row[3] + content = "" + if store_fname: + path = os.path.join(filestore, store_fname) + if os.path.isfile(path): + with open(path, "r", encoding="utf-8", errors="replace") as h: + content = h.read() + elif db_datas: + content = db_datas + if content: + lst.append((int(att_id), url, content)) + return lst + + +def installed_modules(database): + return { + row[0] + for row in run_psql( + database, + "SELECT name FROM ir_module_module WHERE state = 'installed';", + ) + if row and row[0] + } + + +def defined_in_sources(version_dir, lst_module): + """Variables définies par les SCSS des modules installés de la cible. + + Le nom du module est le répertoire qui contient « static » : c'est ce qui + permet de ne pas compter les variables d'un module absent de la base, dont + aucune ligne n'atteindra jamais un bundle. + """ + defined = set() + pattern = os.path.join(version_dir, "**", "static", "**", "*.scss") + for path in glob.iglob(pattern, recursive=True): + parts = path.split(os.sep) + try: + module = parts[parts.index("static") - 1] + except (ValueError, IndexError): + continue + if module not in lst_module: + continue + try: + with open(path, "r", encoding="utf-8", errors="replace") as handle: + defined.update(RE_DEF.findall(handle.read())) + except OSError: + continue + return defined + + +def analyse(database, version_dir, filestore=None): + """[(id, url, [variables absentes])] pour les personnalisations à risque.""" + filestore = filestore_dir(database, filestore) + lst_module = installed_modules(database) + defined = defined_in_sources(version_dir, lst_module) + lst_finding = [] + for att_id, url, content in custom_scss(database, filestore): + own = set(RE_DEF.findall(content)) | bound_names(content) + missing = sorted( + { + name + for name in used_names(content) + if name not in own and name not in defined + } + ) + if missing: + lst_finding.append((att_id, url, missing)) + return lst_finding + + +def render(lst_finding, database, version_dir): + if not lst_finding: + return ( + f"✅ -> {t('No customized SCSS is at risk in')} {version_dir}.\n" + ) + lines = [ + f"⚠️ {len(lst_finding)}" + f" {t('customized SCSS use(s) a variable that')} {version_dir}" + f" {t('no longer defines: the bundle will not compile.')}" + ] + for att_id, url, missing in lst_finding: + lines.append(f" - id={att_id} {url}") + lines.append(f" {', '.join('$' + m for m in missing)}") + lines += [ + f" {t('Each one is a copy frozen on an older version. Dropping it')}" + f" {t('restores the module file:')}", + f" ./odoo_bin.sh shell -c ./config.conf -d {database} <<'PY'", + ] + for _att_id, url, _missing in lst_finding: + base, bundle = split_custom_url(url) + lines.append( + f" env['web_editor.assets'].reset_asset(" + f"'{base}', '{bundle}')" + ) + lines += [ + " env.cr.commit()", + " PY", + f" {t('Read it first: what it holds beyond the stale variable is')}" + f" {t('a real customization, to re-apply as a small file.')}", + ] + return "\n".join(lines) + "\n" + + +def split_custom_url(url): + """« /a/b.custom.web.assets_frontend.scss » -> (« /a/b.scss », bundle). + + C'est la convention d'Odoo : le nom du bundle est encastré dans celui du + fichier. La reconstruire évite de faire deviner à l'utilisateur les deux + arguments de reset_asset au moment où il veut juste réparer. + """ + directory, name = os.path.split(url) + head, _sep, tail = name.partition(".custom.") + bundle = tail[:-5] if tail.endswith(".scss") else tail + return os.path.join(directory, head + ".scss"), bundle + + +def main(argv=None): + parser = argparse.ArgumentParser( + description=( + "Predict which customized SCSS will break on the target version" + " (read-only)." + ) + ) + parser.add_argument("-d", "--database", required=True) + parser.add_argument( + "-t", + "--target_version", + required=True, + help="target Odoo source directory, e.g. odoo13.0", + ) + parser.add_argument( + "--filestore", + default=None, + help="filestore directory (default: ~/.local/share/Odoo/filestore/)", + ) + config = parser.parse_args(argv) + + if not os.path.isdir(config.target_version): + print( + f"❌ {t('Target version directory not found')} :" + f" '{config.target_version}'" + ) + return 2 + try: + lst_finding = analyse( + config.database, config.target_version, config.filestore + ) + except RuntimeError as exc: + print(f"❌ {exc}") + return 2 + print(render(lst_finding, config.database, config.target_version)) + return 1 if lst_finding else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 74d9734..aa611d1 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -4790,6 +4790,38 @@ TRANSLATIONS = { "fr": "Conservés. Rien n'a été désinstallé.", "en": "Kept. Nothing was uninstalled.", }, + "No customized SCSS is at risk in": { + "fr": "Aucun SCSS personnalisé n'est en danger dans", + "en": "No customized SCSS is at risk in", + }, + "customized SCSS use(s) a variable that": { + "fr": "SCSS personnalisé(s) utilisent une variable que", + "en": "customized SCSS use(s) a variable that", + }, + "no longer defines: the bundle will not compile.": { + "fr": "ne définit plus : le bundle ne compilera pas.", + "en": "no longer defines: the bundle will not compile.", + }, + "Each one is a copy frozen on an older version. Dropping it": { + "fr": "Chacun est une copie figée sur une version antérieure. L'abandonner", + "en": "Each one is a copy frozen on an older version. Dropping it", + }, + "restores the module file:": { + "fr": "rend le fichier du module :", + "en": "restores the module file:", + }, + "Read it first: what it holds beyond the stale variable is": { + "fr": "Le lire d'abord : ce qu'il porte au-delà de la variable périmée est", + "en": "Read it first: what it holds beyond the stale variable is", + }, + "a real customization, to re-apply as a small file.": { + "fr": "une vraie personnalisation, à réappliquer en petit fichier.", + "en": "a real customization, to re-apply as a small file.", + }, + "Read the customizations above before continuing.": { + "fr": "Lire les personnalisations ci-dessus avant de continuer.", + "en": "Read the customizations above before continuing.", + }, "Nothing to decide yet": { "fr": "Rien à décider pour l'instant", "en": "Nothing to decide yet", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index 0072e92..8ea6765 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -1641,6 +1641,22 @@ class TodoUpgrade: if status == 1: self.prompt_cow_prediction(database_name, start_version + 1) + # Même prédiction, autre matière : un SCSS personnalisé est lui aussi + # une copie figée, et un palier renomme aussi des variables. Celle-ci + # ne se voit qu'à l'écran d'une page — « Style error », sans dire quel + # fichier ni depuis quand — et seulement une fois le palier passé. + status_scss, _cmd, _out = self.todo_upgrade_execute( + f"{PYTHON_BIN} ./script/odoo/migration/check_stale_scss.py" + f" -d {database_name} -t odoo{start_version + 1}.0", + get_output=True, + wait_at_error=False, + ) + if status_scss == 1: + self.ask_gate( + f"💬 {t('Read the customizations above before continuing.')}" + f" {t('(b = go back to a previous step)')} : " + ) + msg = "3 - Clean up database before data migration" self.print_step(msg) self.add_comment_progression(msg) diff --git a/test/test_check_stale_scss.py b/test/test_check_stale_scss.py new file mode 100755 index 0000000..6a6f0f0 --- /dev/null +++ b/test/test_check_stale_scss.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Un SCSS personnalisé est une copie figée, comme une vue COW. + +Personnaliser un site écrit du SCSS dans `ir_attachment`. Cette copie est +figée le jour où elle est écrite et continue d'employer les variables de +CETTE version. Un module peut les renommer d'un palier à l'autre : mesuré, +`website/.../primary_variables.scss` déclarait `$o-theme-font-number` en 12.0 +et a remplacé tout le mécanisme en 13.0. Une personnalisation de 2020 +demandait encore l'ancien nom, et le bundle s'arrêtait dessus. + +Ce qui rend un tel détecteur utilisable n'est pas de trouver — c'est de ne +pas crier à tort. Sur le seul fichier mesuré, la première version rapportait +dix noms dont six n'étaient pas des manques : paramètres de mixin, variables +de boucle, arguments nommés d'`@include`. Ces tests portent surtout là-dessus. +""" + +import os +import sys +import unittest + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(REPO, "script", "odoo", "migration")) + +import check_stale_scss as scss # noqa: E402 + + +class TestWhatCountsAsAUse(unittest.TestCase): + def test_a_plain_dereference_is_a_use(self): + self.assertIn("color", scss.used_names("a { b: $color; }")) + + def test_a_declaration_is_not_a_use(self): + self.assertNotIn("color", scss.used_names("$color: red;")) + + def test_a_named_include_argument_is_not_a_use(self): + # LE faux positif mesuré : « @include o-position-absolute( + # $right: 50%) » se lisait comme l'usage d'un $right inexistant. + source = "@include o-position-absolute($right: 50%, $left: 50%);" + self.assertEqual(scss.used_names(source), set()) + + def test_a_default_value_is_not_a_use(self): + self.assertNotIn("size", scss.used_names("$size: 3 !default;")) + + def test_the_name_is_not_truncated_to_dodge_the_colon(self): + # Une négation en tête de motif fait rétrograder le nom : le premier + # essai rapportait « $botto » au lieu d'écarter « $bottom ». + names = scss.used_names("a { b: $bottom-margin; }") + self.assertEqual(names, {"bottom-margin"}) + + def test_a_use_after_a_declaration_still_counts(self): + self.assertEqual(scss.used_names("$a: $b;"), {"b"}) + + +class TestWhatTheFileBindsItself(unittest.TestCase): + def test_mixin_parameters_are_bound(self): + source = "@mixin thing($on, $off: 2) { a: $on; b: $off; }" + self.assertEqual(scss.bound_names(source), {"on", "off"}) + + def test_function_parameters_are_bound(self): + source = "@function f($value) { @return $value; }" + self.assertIn("value", scss.bound_names(source)) + + def test_each_loop_variables_are_bound(self): + source = "@each $key, $val in $map { a: $key; }" + bound = scss.bound_names(source) + self.assertIn("key", bound) + self.assertIn("val", bound) + + def test_for_loop_variables_are_bound(self): + source = "@for $counter from 1 through 3 { a: $counter; }" + self.assertIn("counter", scss.bound_names(source)) + + +class TestTheUrlIsSplitForTheFix(unittest.TestCase): + """reset_asset veut le fichier ET le bundle ; l'URL porte les deux.""" + + def test_a_frontend_customization(self): + base, bundle = scss.split_custom_url( + "/website/static/src/scss/website.custom.web.assets_frontend.scss" + ) + self.assertEqual(base, "/website/static/src/scss/website.scss") + self.assertEqual(bundle, "web.assets_frontend") + + def test_a_common_customization(self): + base, bundle = scss.split_custom_url( + "/website/static/src/scss/options/colors/" + "user_theme_color_palette.custom.web.assets_common.scss" + ) + self.assertTrue(base.endswith("user_theme_color_palette.scss")) + self.assertEqual(bundle, "web.assets_common") + + +class TestTheReport(unittest.TestCase): + def setUp(self): + from script.todo import todo_i18n + + self.addCleanup( + setattr, todo_i18n, "_current_lang", todo_i18n._current_lang + ) + todo_i18n._current_lang = "en" + + def test_nothing_at_risk_says_so(self): + text = scss.render([], "db", "odoo13.0") + self.assertIn("✅", text) + + def test_a_finding_names_the_attachment_and_the_variable(self): + text = scss.render( + [ + ( + 1110, + "/a/b.custom.web.assets_frontend.scss", + ["o-theme-font-number"], + ) + ], + "db", + "odoo13.0", + ) + self.assertIn("1110", text) + self.assertIn("$o-theme-font-number", text) + + def test_it_prints_a_command_that_can_be_pasted(self): + # Un diagnostic sans le geste qui répare oblige à rechercher les deux + # arguments de reset_asset au pire moment. + text = scss.render( + [(1110, "/a/b.custom.web.assets_frontend.scss", ["x"])], + "mydb", + "odoo13.0", + ) + self.assertIn("reset_asset('/a/b.scss', 'web.assets_frontend')", text) + self.assertIn("-d mydb", text) + + def test_it_warns_before_dropping(self): + text = scss.render( + [(1110, "/a/b.custom.web.assets_frontend.scss", ["x"])], + "db", + "odoo13.0", + ) + self.assertIn("real customization", text) + + +class TestTheMigrationRunsIt(unittest.TestCase): + def test_it_is_checked_at_the_same_place_as_the_cow_views(self): + # Les deux prédictions valent avant le palier, pas après : c'est le + # seul moment où l'on peut encore arbitrer sans avoir tout rejoué. + import inspect + + from script.todo.todo_upgrade import TodoUpgrade + + source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade) + self.assertIn("check_stale_scss.py", source) + self.assertLess( + source.index("check_stale_scss.py"), + source.index("4 - Upgrade version with OpenUpgrade"), + ) + + +if __name__ == "__main__": + unittest.main() From 26de9a109f46c253a5844dc48ba6c163e75ebaa6 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sat, 15 Aug 2026 03:41:56 -0400 Subject: [PATCH 15/67] [ADD] migration: read the stale SCSS, then fix it, without leaving the tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The report named the attachment and printed a command to paste. Deciding « reset it » still meant accepting to lose one did not know what: the diff against the module file is the only thing resetting gives up. The tool now shows it and asks. --diff prints it, --tui browses it, --apply resets without asking; with no flag and a terminal it asks, and looking does not answer — the prompt comes back after each read. --apply writes the copies under private/ first: reset_asset deletes the attachment, so without that the customized lines would be nowhere. The migration runs it on the real terminal, or none of this would be reachable from there — the same pipe that was closing the TUI. --- FR --- [ADD] migration : lire le SCSS périmé, puis le corriger, sans sortir Le rapport nommait la pièce jointe et imprimait une commande à coller. Répondre « réinitialise » revenait encore à accepter de perdre on ne sait quoi : l'écart avec le fichier du module est la seule chose que la réinitialisation abandonne. L'outil le montre et pose la question. --diff l'imprime, --tui le parcourt, --apply réinitialise sans demander ; sans drapeau et devant un terminal il demande, et regarder ne répond pas — l'invite revient après chaque lecture. --apply écrit d'abord les copies sous private/ : reset_asset supprime la pièce jointe, sans quoi les lignes personnalisées ne seraient plus nulle part. La migration le lance sur le vrai terminal, sinon rien de tout cela n'y serait atteignable — le tube même qui fermait la TUI. Assisted-by: Claude Opus 5 (cherry picked from commit 9e6c88622a74f7be408a8b1c523838eeadda1da3) --- script/odoo/migration/check_stale_scss.py | 286 ++++++++++++++++-- script/odoo/migration/check_stale_scss_tui.py | 207 +++++++++++++ script/todo/todo_i18n.py | 84 +++++ script/todo/todo_upgrade.py | 14 +- test/test_check_stale_scss.py | 198 ++++++++++-- 5 files changed, 739 insertions(+), 50 deletions(-) create mode 100755 script/odoo/migration/check_stale_scss_tui.py diff --git a/script/odoo/migration/check_stale_scss.py b/script/odoo/migration/check_stale_scss.py index e9c51e3..9ff3415 100755 --- a/script/odoo/migration/check_stale_scss.py +++ b/script/odoo/migration/check_stale_scss.py @@ -218,8 +218,29 @@ def defined_in_sources(version_dir, lst_module): return defined +def module_file(base_url, version_dir): + """Le fichier du module que la personnalisation masque, ou None. + + Le premier segment de l'URL est le nom du module ; le reste est son + chemin. C'est ce fichier que reset_asset rendrait, donc c'est contre lui + que se lit ce que la copie a réellement changé. + """ + parts = base_url.strip("/").split("/") + if len(parts) < 2: + return None + pattern = os.path.join(version_dir, "**", parts[0], *parts[1:]) + for candidate in glob.iglob(pattern, recursive=True): + if os.path.isfile(candidate): + return candidate + return None + + def analyse(database, version_dir, filestore=None): - """[(id, url, [variables absentes])] pour les personnalisations à risque.""" + """Un dictionnaire par personnalisation à risque. + + Le contenu et le fichier du module y sont joints : l'appelant qui veut + montrer l'écart ne doit pas relire la base ni retrouver le chemin. + """ filestore = filestore_dir(database, filestore) lst_module = installed_modules(database) defined = defined_in_sources(version_dir, lst_module) @@ -233,11 +254,133 @@ def analyse(database, version_dir, filestore=None): if name not in own and name not in defined } ) - if missing: - lst_finding.append((att_id, url, missing)) + if not missing: + continue + base_url, bundle = split_custom_url(url) + path = module_file(base_url, version_dir) + module_content = "" + if path: + with open(path, "r", encoding="utf-8", errors="replace") as handle: + module_content = handle.read() + lst_finding.append( + { + "id": att_id, + "url": url, + "missing": missing, + "custom": content, + "base_url": base_url, + "bundle": bundle, + "module_path": path, + "module_content": module_content, + "version_dir": version_dir, + "database": database, + } + ) return lst_finding +def render_diff(finding): + """Ce que la copie a changé, comparée au fichier du module de la cible. + + C'est la seule chose qu'abandonner la copie ferait perdre. Sans elle, on + répond « oui, réinitialise » sans savoir si l'on jette trois lignes ou + une page entière. + """ + import difflib + + lines = [ + f"── id={finding['id']} {finding['url']} ──", + "", + f" {t('Missing:')} " + + ", ".join("$" + name for name in finding["missing"]), + "", + ] + if not finding["module_path"]: + lines += [ + f" {t('No module file of that name in')}" + f" {finding['version_dir']} :", + f" {t('the target no longer ships it, so there is nothing to')}", + f" {t('fall back on. Read the copy before dropping it.')}", + ] + return "\n".join(lines) + diff = list( + difflib.unified_diff( + finding["module_content"].splitlines(), + finding["custom"].splitlines(), + fromfile=finding["module_path"], + tofile=f"custom id={finding['id']}", + lineterm="", + n=2, + ) + ) + if len(diff) <= 2: + lines.append(f" {t('The copy is identical to the module file.')}") + return "\n".join(lines) + lines += [f" {line}" for line in diff] + plus = sum(1 for x in diff if x[:1] == "+" and not x.startswith("+++")) + minus = sum(1 for x in diff if x[:1] == "-" and not x.startswith("---")) + lines += [ + "", + f" +{plus}/-{minus} {t('line(s): that is what resetting gives up.')}", + ] + return "\n".join(lines) + + +def reset_command(lst_finding, database, config_path="./config.conf"): + """La commande qui rend les fichiers de module. Rien n'est lancé ici.""" + lines = [f"./odoo_bin.sh shell -c {config_path} -d {database} <<'PY'"] + for finding in lst_finding: + lines.append( + "env['web_editor.assets'].reset_asset" + f"('{finding['base_url']}', '{finding['bundle']}')" + ) + lines += ["env.cr.commit()", "PY"] + return "\n".join(lines) + + +def apply_reset(lst_finding, database, config_path="./config.conf"): + """Rendre les fichiers de module. ÉCRIT en base — le seul endroit ici. + + Réversible seulement au sens où la personnalisation peut être réécrite : + reset_asset supprime la pièce jointe. D'où la sauvegarde préalable, qui + n'est pas une politesse mais la condition pour dire « oui » sans risque. + """ + script = "\n".join( + "env['web_editor.assets'].reset_asset" + f"('{f['base_url']}', '{f['bundle']}')" + for f in lst_finding + ) + done = subprocess.run( + ["./odoo_bin.sh", "shell", "-c", config_path, "-d", database], + input=script + "\nenv.cr.commit()\n", + capture_output=True, + text=True, + ) + return done.returncode, done.stdout + done.stderr + + +def backup_custom(lst_finding, database): + """Écrire les copies sur disque AVANT de les abandonner. + + reset_asset supprime la pièce jointe : sans ceci, les lignes réellement + personnalisées ne seraient plus nulle part. + """ + directory = os.path.join( + "private", "odoo", "migration", database, "scss_backup" + ) + os.makedirs(directory, exist_ok=True) + lst_path = [] + for finding in lst_finding: + name = f"{finding['id']}_" + finding["url"].strip("/").replace( + "/", "_" + ) + path = os.path.join(directory, name) + with open(path, "w", encoding="utf-8") as handle: + handle.write(finding["custom"]) + lst_path.append(path) + return lst_path + + def render(lst_finding, database, version_dir): if not lst_finding: return ( @@ -248,29 +391,75 @@ def render(lst_finding, database, version_dir): f" {t('customized SCSS use(s) a variable that')} {version_dir}" f" {t('no longer defines: the bundle will not compile.')}" ] - for att_id, url, missing in lst_finding: - lines.append(f" - id={att_id} {url}") - lines.append(f" {', '.join('$' + m for m in missing)}") + for finding in lst_finding: + lines.append(f" - id={finding['id']} {finding['url']}") + lines.append( + " " + ", ".join("$" + name for name in finding["missing"]) + ) lines += [ f" {t('Each one is a copy frozen on an older version. Dropping it')}" f" {t('restores the module file:')}", - f" ./odoo_bin.sh shell -c ./config.conf -d {database} <<'PY'", ] - for _att_id, url, _missing in lst_finding: - base, bundle = split_custom_url(url) - lines.append( - f" env['web_editor.assets'].reset_asset(" - f"'{base}', '{bundle}')" - ) lines += [ - " env.cr.commit()", - " PY", - f" {t('Read it first: what it holds beyond the stale variable is')}" - f" {t('a real customization, to re-apply as a small file.')}", + " " + line + for line in reset_command(lst_finding, database).splitlines() ] + lines.append( + f" {t('Read it first: what it holds beyond the stale variable is')}" + f" {t('a real customization, to re-apply as a small file.')}" + ) return "\n".join(lines) + "\n" +def prompt(lst_finding, database, config_path="./config.conf", ask=input): + """Montrer, puis proposer de corriger. Rend True si l'on a écrit. + + Répondre « oui, réinitialise » sans avoir vu l'écart, c'est accepter de + perdre on ne sait quoi. L'invite revient donc après chaque lecture : + regarder ne répond pas à la question. + """ + while True: + answer = ( + ask( + f"💬 {t('What do you want to do with these customizations?')}" + f" ({t('Enter = nothing')}," + f" v = {t('what the copy changed')}," + f" w = {t('full screen')}," + f" a = {t('reset them onto the module file')}) : " + ) + .strip() + .lower() + ) + if answer == "v": + for finding in lst_finding: + print(render_diff(finding)) + print() + continue + if answer == "w": + try: + from check_stale_scss_tui import run_tui + except ImportError: + print(f"ℹ️ {t('Full screen view unavailable.')}") + continue + if not run_tui(lst_finding): + for finding in lst_finding: + print(render_diff(finding)) + continue + if answer == "a": + lst_path = backup_custom(lst_finding, database) + print(f"📦 {t('Saved before resetting')} :") + for path in lst_path: + print(f" {path}") + status, output = apply_reset(lst_finding, database, config_path) + print(output.strip()[-2000:]) + if status: + print(f"❌ {t('Reset failed, nothing was changed.')}") + return False + print(f"✅ -> {t('Reset done.')}") + return True + return False + + def split_custom_url(url): """« /a/b.custom.web.assets_frontend.scss » -> (« /a/b.scss », bundle). @@ -287,8 +476,8 @@ def split_custom_url(url): def main(argv=None): parser = argparse.ArgumentParser( description=( - "Predict which customized SCSS will break on the target version" - " (read-only)." + "Predict which customized SCSS will break on the target version." + " Read-only unless --apply." ) ) parser.add_argument("-d", "--database", required=True) @@ -303,6 +492,27 @@ def main(argv=None): default=None, help="filestore directory (default: ~/.local/share/Odoo/filestore/)", ) + parser.add_argument( + "-c", + "--config", + default="./config.conf", + help="Odoo config used by the shell for --apply", + ) + parser.add_argument( + "--diff", + action="store_true", + help="print what each copy changed, without asking", + ) + parser.add_argument( + "--tui", + action="store_true", + help="browse the differences full screen", + ) + parser.add_argument( + "--apply", + action="store_true", + help="reset them onto the module file (WRITES; saves a copy first)", + ) config = parser.parse_args(argv) if not os.path.isdir(config.target_version): @@ -318,8 +528,44 @@ def main(argv=None): except RuntimeError as exc: print(f"❌ {exc}") return 2 + print(render(lst_finding, config.database, config.target_version)) - return 1 if lst_finding else 0 + if not lst_finding: + return 0 + + if config.diff: + for finding in lst_finding: + print(render_diff(finding)) + print() + if config.tui: + from check_stale_scss_tui import run_tui + + if not run_tui(lst_finding): + for finding in lst_finding: + print(render_diff(finding)) + if config.apply: + lst_path = backup_custom(lst_finding, config.database) + print(f"📦 {t('Saved before resetting')} :") + for path in lst_path: + print(f" {path}") + status, output = apply_reset( + lst_finding, config.database, config.config + ) + print(output.strip()[-2000:]) + if status: + print(f"❌ {t('Reset failed, nothing was changed.')}") + return 2 + print(f"✅ -> {t('Reset done.')}") + return 0 + + # Aucun drapeau : on demande, plutôt que d'imprimer un rapport et de + # laisser retrouver soi-même les deux arguments de reset_asset. Mais + # seulement devant un terminal — dans un tube, une invite bloquerait + # l'appelant sans que personne ne voie la question. + if not (config.diff or config.tui) and sys.stdin.isatty(): + if prompt(lst_finding, config.database, config.config): + return 0 + return 1 if __name__ == "__main__": diff --git a/script/odoo/migration/check_stale_scss_tui.py b/script/odoo/migration/check_stale_scss_tui.py new file mode 100755 index 0000000..cf234ee --- /dev/null +++ b/script/odoo/migration/check_stale_scss_tui.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Full-screen browsing of the customized SCSS a version bump would break. + +Two things have to be read before answering « reset it »: what the copy +changed — the only thing resetting gives up — and which variables the target +no longer defines, which is why it breaks at all. Space switches between them. + +The text report says the same, but a 1000-line diff in a terminal scrollback +is not something anyone reads before deciding. +""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) +) + +from check_stale_scss import render_diff, reset_command # noqa: E402 + +try: + from script.todo.todo_i18n import t +except Exception: # pragma: no cover - repli si i18n indisponible + + def t(key: str) -> str: + return key + + +CSS = """ +Screen { layout: vertical; } +#head { height: 3; padding: 0 1; background: $panel; color: $text; } +#body { height: 1fr; } +#files { width: 42; border-right: solid $accent; } +#pane { width: 1fr; padding: 0 1; } +""" + + +def render_missing(finding): + """Pourquoi ça casse : ce que la cible ne définit plus.""" + lines = [ + f"── id={finding['id']} {finding['url']} ──", + "", + f" {t('This copy uses variables that')} {finding['version_dir']}" + f" {t('no longer defines')} :", + "", + ] + for name in finding["missing"]: + lines.append(f" ${name}") + lines += [ + "", + f" {t('The copy was written against an older version and frozen')}", + f" {t('there. The module has since renamed what it relies on.')}", + "", + f" {t('Resetting restores')} :", + f" {finding['module_path'] or t('(nothing: the target no longer ships this file)')}", + ] + return "\n".join(lines) + + +def build_app(lst_finding): + """Build the application. Textual is imported here, not at module level. + + The module stays importable — and therefore testable — on a machine + without Textual, which is also what lets the caller fall back to the text + report rather than fail. + """ + from textual.app import App, ComposeResult + from textual.containers import Horizontal, VerticalScroll + from textual.widgets import DataTable, Footer, Header, Static + + class StaleScssApp(App): + CSS = globals()["CSS"] + BINDINGS = [ + ("q,escape", "quit", "Quit"), + ("space,tab", "toggle", "Diff / why"), + ("c", "copy", "Copy reset command"), + ] + + def __init__(self, lst_finding): + super().__init__() + self.lst_finding = lst_finding + self.why = False + self.index = 0 + + def compose(self) -> ComposeResult: + yield Header() + yield Static("", id="head") + with Horizontal(id="body"): + yield DataTable(id="files", cursor_type="row") + with VerticalScroll(id="pane"): + yield Static("", id="content") + yield Footer() + + def on_mount(self): + self.title = "Customized SCSS at risk" + table = self.query_one("#files", DataTable) + table.add_columns("file", "+/-", "missing") + for finding in self.lst_finding: + table.add_row( + os.path.basename(finding["url"])[:30], + self._weight(finding), + str(len(finding["missing"])), + key=str(finding["id"]), + ) + self._show() + + def _weight(self, finding): + """Combien la copie s'écarte, pour trier d'un coup d'œil.""" + if not finding["module_path"]: + return "—" + import difflib + + diff = list( + difflib.unified_diff( + finding["module_content"].splitlines(), + finding["custom"].splitlines(), + lineterm="", + n=0, + ) + ) + plus = sum( + 1 for x in diff if x[:1] == "+" and not x.startswith("+++") + ) + minus = sum( + 1 for x in diff if x[:1] == "-" and not x.startswith("---") + ) + return f"+{plus}/-{minus}" + + def _show(self): + if not self.lst_finding: + return + finding = self.lst_finding[self.index] + which = "why it breaks" if self.why else "what the copy changed" + self.query_one("#head", Static).update( + f"{finding['url']} · id={finding['id']}\n" + f"[{which}] — space to switch, c to copy the reset command" + ) + render = render_missing if self.why else render_diff + self.query_one("#content", Static).update(render(finding)) + + def on_data_table_row_highlighted(self, event): + if event.data_table.id == "files" and self.lst_finding: + self.index = event.cursor_row + self._show() + + def action_toggle(self): + self.why = not self.why + self._show() + + def action_copy(self): + """Mettre la commande de réparation dans le presse-papier. + + Sans cela il faut la recopier à la main depuis un plein écran, + avec deux arguments dont une faute ne se voit pas. + """ + finding = self.lst_finding[self.index] + command = reset_command([finding], finding["database"]) + try: + self.copy_to_clipboard(command) + self.notify(t("Reset command copied.")) + except Exception: + self.notify(command, timeout=20) + + return StaleScssApp(lst_finding) + + +def run_tui(lst_finding, run_app=True): + """Ouvrir l'écran. False si on n'a pas pu — et alors on DIT pourquoi. + + Trois refus, trois raisons, aucune n'est une panne : rien à montrer, pas + de terminal, ou Textual absent. Se taire ferait réafficher le rapport + texte à la place de l'écran demandé, sans rien qui distingue les deux. + """ + if not lst_finding: + return False + if not sys.stdout.isatty(): + print(f"ℹ️ {t('Not a terminal: showing the text report instead.')}") + return False + try: + from script.todo import textual_setup + except Exception: + textual_setup = None + if textual_setup and not textual_setup.available(): + if not textual_setup.in_venv(): + print( + f"ℹ️ {t('Textual is missing from this interpreter:')}" + f" {sys.executable}" + ) + print(f" {t('Run it with')} .venv.erplibre/bin/python3") + if textual_setup and not textual_setup.ensure(): + return False + try: + app = build_app(lst_finding) + except ImportError: + print( + f"ℹ️ {t('Textual is missing from this interpreter:')}" + f" {sys.executable}" + ) + return False + if not run_app: + return app + app.run() + return True diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index aa611d1..16a77d2 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -4822,6 +4822,90 @@ TRANSLATIONS = { "fr": "Lire les personnalisations ci-dessus avant de continuer.", "en": "Read the customizations above before continuing.", }, + "Missing:": { + "fr": "Manquantes :", + "en": "Missing:", + }, + "No module file of that name in": { + "fr": "Aucun fichier de module de ce nom dans", + "en": "No module file of that name in", + }, + "the target no longer ships it, so there is nothing to": { + "fr": "la cible ne le livre plus, il n'y a donc rien sur quoi", + "en": "the target no longer ships it, so there is nothing to", + }, + "fall back on. Read the copy before dropping it.": { + "fr": "retomber. Lire la copie avant de l'abandonner.", + "en": "fall back on. Read the copy before dropping it.", + }, + "The copy is identical to the module file.": { + "fr": "La copie est identique au fichier du module.", + "en": "The copy is identical to the module file.", + }, + "line(s): that is what resetting gives up.": { + "fr": "ligne(s) : c'est ce que la réinitialisation abandonne.", + "en": "line(s): that is what resetting gives up.", + }, + "What do you want to do with these customizations?": { + "fr": "Que voulez-vous faire de ces personnalisations ?", + "en": "What do you want to do with these customizations?", + }, + "Enter = nothing": { + "fr": "Entrée = rien", + "en": "Enter = nothing", + }, + "what the copy changed": { + "fr": "ce que la copie a changé", + "en": "what the copy changed", + }, + "reset them onto the module file": { + "fr": "les réinitialiser sur le fichier du module", + "en": "reset them onto the module file", + }, + "Full screen view unavailable.": { + "fr": "Vue plein écran indisponible.", + "en": "Full screen view unavailable.", + }, + "Saved before resetting": { + "fr": "Sauvegardé avant réinitialisation", + "en": "Saved before resetting", + }, + "Reset failed, nothing was changed.": { + "fr": "La réinitialisation a échoué, rien n'a été modifié.", + "en": "Reset failed, nothing was changed.", + }, + "Reset done.": { + "fr": "Réinitialisation faite.", + "en": "Reset done.", + }, + "This copy uses variables that": { + "fr": "Cette copie utilise des variables que", + "en": "This copy uses variables that", + }, + "no longer defines": { + "fr": "ne définit plus", + "en": "no longer defines", + }, + "The copy was written against an older version and frozen": { + "fr": "La copie a été écrite contre une version antérieure et figée", + "en": "The copy was written against an older version and frozen", + }, + "there. The module has since renamed what it relies on.": { + "fr": "là. Le module a depuis renommé ce dont elle dépend.", + "en": "there. The module has since renamed what it relies on.", + }, + "Resetting restores": { + "fr": "La réinitialisation rend", + "en": "Resetting restores", + }, + "(nothing: the target no longer ships this file)": { + "fr": "(rien : la cible ne livre plus ce fichier)", + "en": "(nothing: the target no longer ships this file)", + }, + "Reset command copied.": { + "fr": "Commande de réinitialisation copiée.", + "en": "Reset command copied.", + }, "Nothing to decide yet": { "fr": "Rien à décider pour l'instant", "en": "Nothing to decide yet", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index 8ea6765..15f0bf5 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -1645,17 +1645,13 @@ class TodoUpgrade: # une copie figée, et un palier renomme aussi des variables. Celle-ci # ne se voit qu'à l'écran d'une page — « Style error », sans dire quel # fichier ni depuis quand — et seulement une fois le palier passé. - status_scss, _cmd, _out = self.todo_upgrade_execute( + # Sur le VRAI terminal : l'outil pose lui-même ses questions — voir + # l'écart, l'ouvrir en plein écran, réinitialiser — et un tube les + # rendrait toutes injoignables, comme il fermait déjà la TUI. + self.run_on_terminal( f"{PYTHON_BIN} ./script/odoo/migration/check_stale_scss.py" - f" -d {database_name} -t odoo{start_version + 1}.0", - get_output=True, - wait_at_error=False, + f" -d {database_name} -t odoo{start_version + 1}.0" ) - if status_scss == 1: - self.ask_gate( - f"💬 {t('Read the customizations above before continuing.')}" - f" {t('(b = go back to a previous step)')} : " - ) msg = "3 - Clean up database before data migration" self.print_step(msg) diff --git a/test/test_check_stale_scss.py b/test/test_check_stale_scss.py index 6a6f0f0..5df606a 100755 --- a/test/test_check_stale_scss.py +++ b/test/test_check_stale_scss.py @@ -101,45 +101,175 @@ class TestTheReport(unittest.TestCase): ) todo_i18n._current_lang = "en" + def finding(self, **override): + data = { + "id": 1110, + "url": "/a/b.custom.web.assets_frontend.scss", + "missing": ["o-theme-font-number"], + "custom": "a { b: $o-theme-font-number; }", + "base_url": "/a/b.scss", + "bundle": "web.assets_frontend", + "module_path": "odoo13.0/a/b.scss", + "module_content": "a { b: 1; }", + "version_dir": "odoo13.0", + "database": "db", + } + data.update(override) + return data + def test_nothing_at_risk_says_so(self): text = scss.render([], "db", "odoo13.0") self.assertIn("✅", text) def test_a_finding_names_the_attachment_and_the_variable(self): - text = scss.render( - [ - ( - 1110, - "/a/b.custom.web.assets_frontend.scss", - ["o-theme-font-number"], - ) - ], - "db", - "odoo13.0", - ) + text = scss.render([self.finding()], "db", "odoo13.0") self.assertIn("1110", text) self.assertIn("$o-theme-font-number", text) def test_it_prints_a_command_that_can_be_pasted(self): # Un diagnostic sans le geste qui répare oblige à rechercher les deux # arguments de reset_asset au pire moment. - text = scss.render( - [(1110, "/a/b.custom.web.assets_frontend.scss", ["x"])], - "mydb", - "odoo13.0", - ) + text = scss.render([self.finding()], "mydb", "odoo13.0") self.assertIn("reset_asset('/a/b.scss', 'web.assets_frontend')", text) self.assertIn("-d mydb", text) def test_it_warns_before_dropping(self): - text = scss.render( - [(1110, "/a/b.custom.web.assets_frontend.scss", ["x"])], - "db", - "odoo13.0", - ) + text = scss.render([self.finding()], "db", "odoo13.0") self.assertIn("real customization", text) +class TestTheDiff(unittest.TestCase): + """Ce que la copie a changé : la seule chose que réinitialiser perd.""" + + 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 finding(self, **override): + data = { + "id": 1110, + "url": "/a/b.custom.web.assets_frontend.scss", + "missing": ["x"], + "custom": "a { b: 2; }", + "base_url": "/a/b.scss", + "bundle": "web.assets_frontend", + "module_path": "odoo13.0/a/b.scss", + "module_content": "a { b: 1; }", + "version_dir": "odoo13.0", + "database": "db", + } + data.update(override) + return data + + def test_it_counts_what_would_be_lost(self): + text = scss.render_diff(self.finding()) + self.assertIn("+1/-1", text) + + def test_an_identical_copy_says_it_loses_nothing(self): + text = scss.render_diff(self.finding(custom="a { b: 1; }")) + self.assertIn("identical", text) + + def test_a_missing_module_file_is_said_not_guessed(self): + # Si la cible ne livre plus le fichier, il n'y a rien sur quoi + # retomber : le taire ferait accepter une réinitialisation vide. + text = scss.render_diff(self.finding(module_path=None)) + self.assertIn("no longer ships", text) + + +class TestThePrompt(unittest.TestCase): + """Regarder ne doit pas répondre à la question.""" + + 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 finding(self): + return { + "id": 1, + "url": "/a/b.custom.web.assets_frontend.scss", + "missing": ["x"], + "custom": "a { b: 2; }", + "base_url": "/a/b.scss", + "bundle": "web.assets_frontend", + "module_path": "odoo13.0/a/b.scss", + "module_content": "a { b: 1; }", + "version_dir": "odoo13.0", + "database": "db", + } + + def run_prompt(self, answers): + import contextlib + import io + + seq = iter(answers) + out = io.StringIO() + with contextlib.redirect_stdout(out): + wrote = scss.prompt( + [self.finding()], "db", ask=lambda prompt: next(seq) + ) + return wrote, out.getvalue() + + def test_enter_writes_nothing(self): + wrote, text = self.run_prompt([""]) + self.assertFalse(wrote) + self.assertEqual(text, "") + + def test_v_shows_the_diff_and_asks_again(self): + # Sans le « asks again », montrer vaudrait réponse : on aurait vu le + # diff et perdu la main sur la décision. + wrote, text = self.run_prompt(["v", ""]) + self.assertFalse(wrote) + self.assertIn("+1/-1", text) + + def test_apply_saves_a_copy_before_writing(self): + # reset_asset SUPPRIME la pièce jointe : sans sauvegarde préalable, + # les lignes personnalisées ne seraient plus nulle part. + import os + import tempfile + + previous = os.getcwd() + self.addCleanup(os.chdir, previous) + os.chdir(tempfile.mkdtemp()) + seen = {} + original = scss.apply_reset + scss.apply_reset = lambda lst, db, cfg="./config.conf": ( + seen.update(called=True), + (0, "ok"), + )[1] + self.addCleanup(setattr, scss, "apply_reset", original) + wrote, text = self.run_prompt(["a"]) + self.assertTrue(wrote) + self.assertTrue(seen.get("called")) + import glob + + saved = glob.glob("private/odoo/migration/db/scss_backup/*") + self.assertEqual(len(saved), 1) + with open(saved[0]) as handle: + self.assertEqual(handle.read(), "a { b: 2; }") + + def test_a_failed_reset_says_nothing_changed(self): + import os + import tempfile + + previous = os.getcwd() + self.addCleanup(os.chdir, previous) + os.chdir(tempfile.mkdtemp()) + original = scss.apply_reset + scss.apply_reset = lambda lst, db, cfg="./config.conf": (1, "boom") + self.addCleanup(setattr, scss, "apply_reset", original) + wrote, text = self.run_prompt(["a"]) + self.assertFalse(wrote) + self.assertIn("nothing was changed", text) + + class TestTheMigrationRunsIt(unittest.TestCase): def test_it_is_checked_at_the_same_place_as_the_cow_views(self): # Les deux prédictions valent avant le palier, pas après : c'est le @@ -155,6 +285,32 @@ class TestTheMigrationRunsIt(unittest.TestCase): source.index("4 - Upgrade version with OpenUpgrade"), ) + def test_it_gets_a_real_terminal(self): + # L'outil pose lui-même ses questions et peut ouvrir un plein écran : + # un tube les rendrait toutes injoignables, sans rien signaler. + import inspect + + from script.todo.todo_upgrade import TodoUpgrade + + source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade) + # On ne compare pas à une mise en forme — black la change — mais à + # l'appel le plus proche EN AMONT : c'est lui qui exécute. + avant = source[: source.index("check_stale_scss.py")] + self.assertGreater( + avant.rfind("run_on_terminal("), + avant.rfind("todo_upgrade_execute("), + "l'outil repasse par l'exécuteur à tube", + ) + + +class TestThePromptStaysOutOfAPipe(unittest.TestCase): + def test_it_only_asks_in_front_of_a_terminal(self): + # Une invite dans un tube bloquerait l'appelant sur une question que + # personne ne voit. + with open(scss.__file__) as handle: + source = handle.read() + self.assertIn("sys.stdin.isatty()", source) + if __name__ == "__main__": unittest.main() From 5c4d71e7a2d63047813784986f4dd4c03c92e303 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sat, 15 Aug 2026 16:34:26 -0400 Subject: [PATCH 16/67] [FIX] migration: reset the stale SCSS after the bump, not before MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fix was offered while the checkout was still on the previous version. Answering « a » ran reset_asset in an Odoo 12 shell, which has no web_editor.assets: KeyError, nothing changed, and the migration went on to break at the next bump — measured on a real run. Predicting early is right; fixing early is not. The early call is now --report-only, and a second call comes after the bump, on the upgraded database, where the checkout can do it. The tool also refuses on its own: it reads the checkout sources for reset_asset rather than trusting a version number, and says when to come back. --- FR --- [FIX] migration : réinitialiser le SCSS périmé après le palier, pas avant La correction était proposée alors que le checkout était encore sur la version précédente. Répondre « a » lançait reset_asset dans un shell Odoo 12, sans web_editor.assets : KeyError, rien de modifié, et la migration continuait jusqu'à casser au palier suivant. Mesuré. Prédire tôt est juste ; corriger tôt ne l'est pas. L'appel précoce est désormais --report-only, et un second vient après le palier, sur la base montée de version, là où le checkout sait le faire. L'outil refuse aussi de lui-même : il cherche reset_asset dans les sources du checkout plutôt que de se fier à un numéro, et dit quand revenir. Assisted-by: Claude Opus 5 (cherry picked from commit 13d4d0a33c63efcbafb5fb29313a5377ba5f508e) --- script/odoo/migration/check_stale_scss.py | 74 ++++++++++++++- script/todo/todo_i18n.py | 20 ++++ script/todo/todo_upgrade.py | 21 ++++- test/test_check_stale_scss.py | 107 ++++++++++++++++++++++ 4 files changed, 214 insertions(+), 8 deletions(-) diff --git a/script/odoo/migration/check_stale_scss.py b/script/odoo/migration/check_stale_scss.py index 9ff3415..43f8496 100755 --- a/script/odoo/migration/check_stale_scss.py +++ b/script/odoo/migration/check_stale_scss.py @@ -326,6 +326,54 @@ def render_diff(finding): return "\n".join(lines) +def running_odoo_dir(): + """Le répertoire de la version qui répondra au shell, d'après le checkout. + + Ce n'est PAS la cible : au moment où l'on prédit ce que le palier + cassera, le checkout est encore sur la version d'avant. C'est elle qui + exécutera reset_asset — ou ne le saura pas. + """ + try: + with open(".odoo-version", "r", encoding="utf-8") as handle: + return "odoo" + handle.read().strip() + except OSError: + return None + + +def reset_supported(odoo_dir=None): + """Cette version sait-elle faire reset_asset ? + + Mesuré : `web_editor.assets` et `reset_asset` n'existent qu'à partir de + 13.0. Lancé sous odoo12.0, l'appel lève « KeyError: 'web_editor.assets' » + et ne change rien — c'est arrivé sur une vraie migration, et le correctif + a été cru appliqué alors qu'il avait échoué. + + On regarde les sources plutôt qu'un numéro : c'est ce qui répondra. + """ + odoo_dir = odoo_dir or running_odoo_dir() + if not odoo_dir or not os.path.isdir(odoo_dir): + return True # Rien pour trancher : ne pas bloquer sur une supposition. + pattern = os.path.join(odoo_dir, "**", "web_editor", "models", "assets.py") + for path in glob.iglob(pattern, recursive=True): + with open(path, "r", encoding="utf-8", errors="replace") as handle: + if "def reset_asset" in handle.read(): + return True + return False + + +def too_early_message(odoo_dir, database): + """Pourquoi on ne peut pas encore corriger, et quand on pourra.""" + return ( + f"⛔ {t('Resetting needs Odoo 13.0 or later; this checkout is on')}" + f" {odoo_dir or '?'}.\n" + f" {t('The prediction stands, the fix does not: run it again once')}" + f" {t('the bump is done, on the upgraded database.')}\n" + f" {t('Applying it from here fails with')}" + " KeyError: 'web_editor.assets'" + f" {t('and changes nothing.')}" + ) + + def reset_command(lst_finding, database, config_path="./config.conf"): """La commande qui rend les fichiers de module. Rien n'est lancé ici.""" lines = [f"./odoo_bin.sh shell -c {config_path} -d {database} <<'PY'"] @@ -418,14 +466,20 @@ def prompt(lst_finding, database, config_path="./config.conf", ask=input): perdre on ne sait quoi. L'invite revient donc après chaque lecture : regarder ne répond pas à la question. """ + odoo_dir = running_odoo_dir() + can_reset = reset_supported(odoo_dir) + if not can_reset: + print(too_early_message(odoo_dir, database)) while True: + choix = ( + f" a = {t('reset them onto the module file')}" if can_reset else "" + ) answer = ( ask( f"💬 {t('What do you want to do with these customizations?')}" f" ({t('Enter = nothing')}," f" v = {t('what the copy changed')}," - f" w = {t('full screen')}," - f" a = {t('reset them onto the module file')}) : " + f" w = {t('full screen')}{choix}) : " ) .strip() .lower() @@ -445,7 +499,7 @@ def prompt(lst_finding, database, config_path="./config.conf", ask=input): for finding in lst_finding: print(render_diff(finding)) continue - if answer == "a": + if answer == "a" and can_reset: lst_path = backup_custom(lst_finding, database) print(f"📦 {t('Saved before resetting')} :") for path in lst_path: @@ -498,6 +552,11 @@ def main(argv=None): default="./config.conf", help="Odoo config used by the shell for --apply", ) + parser.add_argument( + "--report-only", + action="store_true", + help="never ask anything, even in front of a terminal", + ) parser.add_argument( "--diff", action="store_true", @@ -544,6 +603,10 @@ def main(argv=None): for finding in lst_finding: print(render_diff(finding)) if config.apply: + odoo_dir = running_odoo_dir() + if not reset_supported(odoo_dir): + print(too_early_message(odoo_dir, config.database)) + return 2 lst_path = backup_custom(lst_finding, config.database) print(f"📦 {t('Saved before resetting')} :") for path in lst_path: @@ -562,7 +625,10 @@ def main(argv=None): # laisser retrouver soi-même les deux arguments de reset_asset. Mais # seulement devant un terminal — dans un tube, une invite bloquerait # l'appelant sans que personne ne voie la question. - if not (config.diff or config.tui) and sys.stdin.isatty(): + if ( + not (config.diff or config.tui or config.report_only) + and sys.stdin.isatty() + ): if prompt(lst_finding, config.database, config.config): return 0 return 1 diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 16a77d2..cf8e6d4 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -4906,6 +4906,26 @@ TRANSLATIONS = { "fr": "Commande de réinitialisation copiée.", "en": "Reset command copied.", }, + "Resetting needs Odoo 13.0 or later; this checkout is on": { + "fr": "La réinitialisation exige Odoo 13.0 ou plus ; ce checkout est sur", + "en": "Resetting needs Odoo 13.0 or later; this checkout is on", + }, + "The prediction stands, the fix does not: run it again once": { + "fr": "La prédiction tient, pas la correction : relancer une fois", + "en": "The prediction stands, the fix does not: run it again once", + }, + "the bump is done, on the upgraded database.": { + "fr": "le palier passé, sur la base montée de version.", + "en": "the bump is done, on the upgraded database.", + }, + "Applying it from here fails with": { + "fr": "L'appliquer d'ici échoue sur", + "en": "Applying it from here fails with", + }, + "and changes nothing.": { + "fr": "et ne change rien.", + "en": "and changes nothing.", + }, "Nothing to decide yet": { "fr": "Rien à décider pour l'instant", "en": "Nothing to decide yet", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index 15f0bf5..b3b7294 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -1645,12 +1645,14 @@ class TodoUpgrade: # une copie figée, et un palier renomme aussi des variables. Celle-ci # ne se voit qu'à l'écran d'une page — « Style error », sans dire quel # fichier ni depuis quand — et seulement une fois le palier passé. - # Sur le VRAI terminal : l'outil pose lui-même ses questions — voir - # l'écart, l'ouvrir en plein écran, réinitialiser — et un tube les - # rendrait toutes injoignables, comme il fermait déjà la TUI. + # Ici on PRÉDIT, on ne corrige pas : le checkout est encore sur la + # version d'avant, et reset_asset n'existe qu'à partir de 13.0. + # Appliquer depuis là lève « KeyError: 'web_editor.assets' » — c'est + # arrivé sur une vraie migration, et l'échec est passé pour un + # succès. La correction est proposée après le palier, plus bas. self.run_on_terminal( f"{PYTHON_BIN} ./script/odoo/migration/check_stale_scss.py" - f" -d {database_name} -t odoo{start_version + 1}.0" + f" -d {database_name} -t odoo{start_version + 1}.0 --report-only" ) msg = "3 - Clean up database before data migration" @@ -2507,6 +2509,17 @@ class TodoUpgrade: ) self.write_config() + # ICI seulement la correction est possible : le checkout est + # passé à la version cible, donc le shell sait faire + # reset_asset. Et c'est le dernier moment utile — la mise à + # jour des modules qui suit est ce qui compile les bundles, + # donc ce qui échoue si un SCSS figé reste en place. + self.run_on_terminal( + f"{PYTHON_BIN}" + " ./script/odoo/migration/check_stale_scss.py" + f" -d {database_name_upgrade} -t odoo{next_version}.0" + ) + str_wait_next_version = ( " (or wait next version 🤖)" if next_version != lst_next_version[-1] diff --git a/test/test_check_stale_scss.py b/test/test_check_stale_scss.py index 5df606a..f301e5a 100755 --- a/test/test_check_stale_scss.py +++ b/test/test_check_stale_scss.py @@ -303,6 +303,113 @@ class TestTheMigrationRunsIt(unittest.TestCase): ) +class TestTheFixCannotRunTooEarly(unittest.TestCase): + """Corriger exige la version d'ARRIVÉE, pas celle de départ. + + Mesuré sur une vraie migration : la question a été posée avant le palier, + alors que le checkout était encore sur odoo12.0. Répondre « a » a lancé + reset_asset dans un shell Odoo 12, qui ne connaît pas `web_editor.assets` + — KeyError, rien de modifié, et la migration a continué jusqu'à casser au + palier suivant. + + Prédire tôt reste juste. C'est corriger tôt qui ne l'est pas. + """ + + def setUp(self): + from script.todo import todo_i18n + + self.addCleanup( + setattr, todo_i18n, "_current_lang", todo_i18n._current_lang + ) + todo_i18n._current_lang = "en" + + def test_a_version_without_reset_asset_is_refused(self): + import os + import tempfile + + root = tempfile.mkdtemp() + old = os.path.join(root, "odoo12.0", "addons", "web_editor", "models") + os.makedirs(old) + with open(os.path.join(old, "assets.py"), "w") as handle: + handle.write("class Assets:\n pass\n") + self.assertFalse(scss.reset_supported(os.path.join(root, "odoo12.0"))) + + def test_a_version_with_it_is_allowed(self): + import os + import tempfile + + root = tempfile.mkdtemp() + new = os.path.join(root, "odoo13.0", "addons", "web_editor", "models") + os.makedirs(new) + with open(os.path.join(new, "assets.py"), "w") as handle: + handle.write("def reset_asset(self, url, bundle):\n pass\n") + self.assertTrue(scss.reset_supported(os.path.join(root, "odoo13.0"))) + + def test_an_unknown_checkout_does_not_block(self): + # Rien pour trancher : refuser sur une supposition empêcherait de + # corriger là où c'est possible. + self.assertTrue(scss.reset_supported("odoo_no_such_dir_zz")) + + def test_the_prompt_hides_the_fix_when_it_cannot_work(self): + # Offrir un choix qui échouera, c'est le faire prendre. + import contextlib + import io + + original = scss.reset_supported + scss.reset_supported = lambda odoo_dir=None: False + self.addCleanup(setattr, scss, "reset_supported", original) + finding = { + "id": 1, + "url": "/a/b.custom.web.assets_frontend.scss", + "missing": ["x"], + "custom": "a", + "base_url": "/a/b.scss", + "bundle": "web.assets_frontend", + "module_path": None, + "module_content": "", + "version_dir": "odoo13.0", + "database": "db", + } + out = io.StringIO() + with contextlib.redirect_stdout(out): + wrote = scss.prompt([finding], "db", ask=lambda p: "a") + self.assertFalse(wrote) + self.assertIn("KeyError", out.getvalue()) + self.assertNotIn("a = reset", out.getvalue()) + + +class TestTheMigrationAsksAtTheRightMoment(unittest.TestCase): + """Prédire avant le palier, corriger après — jamais l'inverse.""" + + def source(self): + import inspect + + from script.todo.todo_upgrade import TodoUpgrade + + return inspect.getsource(TodoUpgrade.execute_odoo_upgrade) + + def test_the_early_call_cannot_offer_the_fix(self): + source = self.source() + premier = source.index("check_stale_scss.py") + fenetre = source[premier : premier + 400] + self.assertIn("--report-only", fenetre) + + def test_a_second_call_comes_after_the_bump(self): + # Sans elle, la prédiction n'aurait jamais de suite : on saurait ce + # qui va casser sans jamais pouvoir le réparer. + source = self.source() + self.assertEqual(source.count("check_stale_scss.py"), 2) + second = source.rindex("check_stale_scss.py") + self.assertGreater(second, source.index("state_4_upgrade_odoo_lst")) + + def test_the_second_call_targets_the_upgraded_database(self): + source = self.source() + second = source.rindex("check_stale_scss.py") + fenetre = source[second : second + 300] + self.assertIn("database_name_upgrade", fenetre) + self.assertNotIn("--report-only", fenetre) + + class TestThePromptStaysOutOfAPipe(unittest.TestCase): def test_it_only_asks_in_front_of_a_terminal(self): # Une invite dans un tube bloquerait l'appelant sur une question que From 7e74455d63e4c092925a5902334bf40f2affebf9 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sun, 16 Aug 2026 03:39:37 -0400 Subject: [PATCH 17/67] [ADD] migration: pick the COW copy to reset from a list, not from memory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The error prompt printed « --reset --apply » and left the key to be found in a thousand-line diff. Copying it by hand is where a character goes missing, and a key matching no copy is not an error for the tool: the command runs and does nothing, silently. [3] now asks the tool for the keys, numbers them, and offers « all ». An out-of-range or unreadable answer resets nothing — falling back to « all » would act on what was never asked for. --- FR --- [ADD] migration : choisir la copie COW à réinitialiser dans une liste L'invite d'erreur imprimait « --reset --apply » et laissait retrouver la clé dans un diff de mille lignes. La recopier à la main est l'endroit où un caractère se perd, et une clé ne correspondant à aucune copie n'est pas une erreur pour l'outil : la commande tourne et ne fait rien, en silence. [3] demande désormais les clés à l'outil, les numérote et offre « toutes ». Une réponse hors liste ou illisible ne réinitialise rien — retomber sur « toutes » agirait sur ce qui n'a pas été demandé. Assisted-by: Claude Opus 5 (cherry picked from commit ae08e13fe5bba2b02b8661c67c4024fcd79f29bc) --- .../odoo/migration/reset_stale_cow_views.py | 13 ++ script/todo/todo_i18n.py | 24 +++ script/todo/todo_upgrade.py | 67 ++++++++ test/test_reset_stale_cow_prompt.py | 158 ++++++++++++++++++ 4 files changed, 262 insertions(+) create mode 100755 test/test_reset_stale_cow_prompt.py diff --git a/script/odoo/migration/reset_stale_cow_views.py b/script/odoo/migration/reset_stale_cow_views.py index 8c7935e..01021ed 100755 --- a/script/odoo/migration/reset_stale_cow_views.py +++ b/script/odoo/migration/reset_stale_cow_views.py @@ -283,6 +283,11 @@ def main(): ) ) parser.add_argument("-d", "--database", required=True) + parser.add_argument( + "--list-keys", + action="store_true", + help="print one drifted key per line, and nothing else", + ) parser.add_argument( "--reset", metavar="KEY", @@ -304,6 +309,14 @@ def main(): config = parser.parse_args() findings = analyse(config.database) + # Une sortie sans décor, pour que l'appelant construise un menu : on ne + # connaît pas les clés de tête, et les recopier depuis un diff de mille + # lignes est le genre de recopie où l'on se trompe. + if config.list_keys: + for cow_view, _module_view, _broken in findings: + print(cow_view["key"]) + return 1 if findings else 0 + if not findings: print(f"✅ {t('No COW copy has drifted from its module view.')}") return 0 diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index cf8e6d4..8d71d69 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -4926,6 +4926,30 @@ TRANSLATIONS = { "fr": "et ne change rien.", "en": "and changes nothing.", }, + "Reset one of them onto its module view": { + "fr": "Réinitialiser l'une d'elles sur sa vue module", + "en": "Reset one of them onto its module view", + }, + "Drifted COW copies": { + "fr": "Copies COW ayant dérivé", + "en": "Drifted COW copies", + }, + "Which one(s) to reset onto the module view?": { + "fr": "Laquelle ou lesquelles réinitialiser sur la vue module ?", + "en": "Which one(s) to reset onto the module view?", + }, + "numbers separated by commas, a = all, empty =": { + "fr": "numéros séparés par des virgules, a = toutes, vide =", + "en": "numbers separated by commas, a = all, empty =", + }, + "Kept. Nothing was reset.": { + "fr": "Conservées. Rien n'a été réinitialisé.", + "en": "Kept. Nothing was reset.", + }, + "Unknown choice, nothing was reset.": { + "fr": "Choix inconnu, rien n'a été réinitialisé.", + "en": "Unknown choice, nothing was reset.", + }, "Nothing to decide yet": { "fr": "Rien à décider pour l'instant", "en": "Nothing to decide yet", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index b3b7294..d170eeb 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -2792,6 +2792,69 @@ class TodoUpgrade: wait_at_error=False, ) + def stale_cow_keys(self, database_name): + """Les clés des copies COW en retard, sans décor.""" + script_path = os.path.join( + PATH_MIGRATION_GLOBAL, "reset_stale_cow_views.py" + ) + if not os.path.exists(script_path): + return [] + status, _cmd, output = self.todo_upgrade_execute( + f"{PYTHON_BIN} ./{script_path} -d {database_name} --list-keys", + get_output=True, + wait_at_error=False, + quiet=True, + ) + if status == 2: + return [] + return [line.strip() for line in (output or []) if line.strip()] + + def prompt_reset_stale_cow_views(self, database_name): + """Choisir quoi réinitialiser dans une LISTE, pas de mémoire. + + L'aide disait « --reset --apply » et laissait retrouver la clé + dans un diff de mille lignes. On la recopie, on se trompe d'un + caractère, et la commande ne fait rien sans le dire — une clé qui ne + correspond à rien n'est pas une erreur pour l'outil. + """ + lst_key = self.stale_cow_keys(database_name) + if not lst_key: + print( + f"✅ -> {t('No COW copy has drifted from its module view.')}" + ) + return + print(f"\n✨ {t('Drifted COW copies')} :") + for index, key in enumerate(lst_key, start=1): + print(f" [{index}] {key}") + print(f" [a] {t('All of the list above')}") + answer = ( + self.ask_gate( + f"💬 {t('Which one(s) to reset onto the module view?')}" + f" ({t('numbers separated by commas, a = all, empty =')}" + f" {t('nothing')}) : " + ) + .strip() + .lower() + ) + if not answer: + print(f"ℹ -> {t('Kept. Nothing was reset.')}") + return + if answer == "a": + lst_chosen = ["all"] + else: + lst_chosen = [] + for part in answer.replace(" ", "").split(","): + if part.isdigit() and 1 <= int(part) <= len(lst_key): + lst_chosen.append(lst_key[int(part) - 1]) + if not lst_chosen: + print(f"⚠️ {t('Unknown choice, nothing was reset.')}") + return + args = " ".join(f"--reset {key}" for key in lst_chosen) + self.run_on_terminal( + f"{PYTHON_BIN} ./{os.path.join(PATH_MIGRATION_GLOBAL, 'reset_stale_cow_views.py')}" + f" -d {database_name} {args} --apply" + ) + def show_cow_drift(self, database_name, next_version, mode="diff"): """Montre les copies COW à risque. Ne touche à rien. @@ -3253,6 +3316,7 @@ class TodoUpgrade: f"[2] {t('Check the COW views that drifted')}" f" ({database_name})" ) + print(f"[3] {t('Reset one of them onto its module view')}") wait_status = ( input( f"💬 {t('Error detected, press enter to continue or')}" @@ -3272,6 +3336,9 @@ class TodoUpgrade: # place, puis on repose le choix pour rejouer. self.check_stale_cow_views(database_name) continue + if wait_status == "3" and database_name: + self.prompt_reset_stale_cow_views(database_name) + continue break if wait_status == "1": diff --git a/test/test_reset_stale_cow_prompt.py b/test/test_reset_stale_cow_prompt.py new file mode 100755 index 0000000..35550e1 --- /dev/null +++ b/test/test_reset_stale_cow_prompt.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Choisir une copie à réinitialiser dans une LISTE, pas de mémoire. + +L'aide affichée après une erreur disait « --reset --apply » et laissait +retrouver la clé dans un diff de mille lignes. On la recopie, on se trompe +d'un caractère, et la commande ne fait RIEN sans le dire — une clé qui ne +correspond à aucune copie n'est pas une erreur pour l'outil. + +D'où l'option [3] : elle demande les clés à l'outil, les numérote, et offre +« toutes ». Ces tests portent sur ce que chaque réponse lance. +""" + +import os +import subprocess +import sys +import unittest + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +from script.todo import todo_i18n # noqa: E402 +from script.todo.todo_upgrade import TodoUpgrade # noqa: E402 + + +class PromptCase(unittest.TestCase): + def setUp(self): + self.addCleanup( + setattr, todo_i18n, "_current_lang", todo_i18n._current_lang + ) + todo_i18n._current_lang = "en" + + def run_prompt(self, answer, lst_key=None): + """(commandes lancées, texte affiché) après cette réponse.""" + import contextlib + import io + + if lst_key is None: + lst_key = ["website_sale.product", "web.layout", "website_blog.x"] + upgrade = TodoUpgrade.__new__(TodoUpgrade) + upgrade.dct_progression = {} + upgrade.lst_command_executed = [] + upgrade.write_config = lambda: None + upgrade.stale_cow_keys = lambda db: lst_key + lst_cmd = [] + upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0 + upgrade.ask_gate = lambda prompt: answer + out = io.StringIO() + with contextlib.redirect_stdout(out): + upgrade.prompt_reset_stale_cow_views("db") + return lst_cmd, out.getvalue() + + +class TestTheMenu(PromptCase): + def test_every_key_is_numbered(self): + # C'est tout l'objet : on ne connaît pas les clés de tête. + _cmd, text = self.run_prompt("") + self.assertIn("[1] website_sale.product", text) + self.assertIn("[2] web.layout", text) + self.assertIn("[3] website_blog.x", text) + + def test_all_is_offered(self): + _cmd, text = self.run_prompt("") + self.assertIn("[a]", text) + + def test_nothing_drifted_asks_nothing(self): + lst_cmd, text = self.run_prompt("a", lst_key=[]) + self.assertEqual(lst_cmd, []) + self.assertIn("No COW copy", text) + + +class TestWhatEachAnswerRuns(PromptCase): + def test_a_number_resets_that_key(self): + lst_cmd, _ = self.run_prompt("2") + self.assertEqual(len(lst_cmd), 1) + self.assertIn("--reset web.layout", lst_cmd[0]) + self.assertIn("--apply", lst_cmd[0]) + self.assertNotIn("website_sale", lst_cmd[0]) + + def test_several_numbers_reset_several_keys(self): + lst_cmd, _ = self.run_prompt("1,3") + self.assertIn("--reset website_sale.product", lst_cmd[0]) + self.assertIn("--reset website_blog.x", lst_cmd[0]) + self.assertNotIn("web.layout", lst_cmd[0]) + + def test_a_resets_them_all(self): + lst_cmd, _ = self.run_prompt("a") + self.assertIn("--reset all", lst_cmd[0]) + + def test_empty_resets_nothing(self): + # Par défaut on ne touche à rien : une copie porte parfois une vraie + # personnalisation, et la réinitialiser la jette. + lst_cmd, text = self.run_prompt("") + self.assertEqual(lst_cmd, []) + self.assertIn("Kept", text) + + def test_an_out_of_range_number_resets_nothing(self): + # Le piège serait de tomber en silence sur « all » ou sur la + # première clé : on ne devine pas ce qui n'a pas été demandé. + lst_cmd, text = self.run_prompt("9") + self.assertEqual(lst_cmd, []) + self.assertIn("Unknown choice", text) + + def test_garbage_resets_nothing(self): + lst_cmd, text = self.run_prompt("zzz") + self.assertEqual(lst_cmd, []) + self.assertIn("Unknown choice", text) + + +class TestTheKeysComeFromTheTool(unittest.TestCase): + """La liste est demandée à l'outil, pas devinée.""" + + def test_list_keys_prints_only_keys(self): + script = os.path.join( + REPO, "script", "odoo", "migration", "reset_stale_cow_views.py" + ) + with open(script) as handle: + source = handle.read() + self.assertIn("--list-keys", source) + self.assertIn('print(cow_view["key"])', source) + + def test_a_dead_database_yields_no_key(self): + # Sans ce garde-fou, un menu vide se lirait comme « rien n'a dérivé ». + upgrade = TodoUpgrade.__new__(TodoUpgrade) + upgrade.dct_progression = {} + upgrade.lst_command_executed = [] + upgrade.write_config = lambda: None + upgrade.todo_upgrade_execute = lambda cmd, **kw: (2, cmd, []) + self.assertEqual(upgrade.stale_cow_keys("nope"), []) + + def test_the_error_prompt_offers_it(self): + import inspect + + source = inspect.getsource(TodoUpgrade.todo_upgrade_execute) + self.assertIn("prompt_reset_stale_cow_views", source) + self.assertIn("Reset one of them onto its module view", source) + + +class TestTheToolAcceptsWhatWeSend(unittest.TestCase): + def test_reset_can_be_repeated_and_accepts_all(self): + done = subprocess.run( + [ + sys.executable, + "./script/odoo/migration/reset_stale_cow_views.py", + "--help", + ], + capture_output=True, + text=True, + cwd=REPO, + ) + self.assertEqual(done.returncode, 0) + self.assertIn("--list-keys", done.stdout) + self.assertIn("--reset", done.stdout) + + +if __name__ == "__main__": + unittest.main() From ec6ac3a18871f9e630fa7235c1773c42d30cf316 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sun, 16 Aug 2026 05:33:28 -0400 Subject: [PATCH 18/67] [FIX] addons: stop calling a leftover report a failed command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit « 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) --- script/addons/theme_leftover.py | 133 +++++++++++++++++++++++- script/addons/uninstall_addons_theme.sh | 6 +- script/todo/todo_i18n.py | 28 +++++ script/todo/todo_upgrade.py | 10 +- test/test_uninstall_addons_theme.py | 118 +++++++++++++++++++++ 5 files changed, 289 insertions(+), 6 deletions(-) diff --git a/script/addons/theme_leftover.py b/script/addons/theme_leftover.py index 38b60da..80e70c2 100755 --- a/script/addons/theme_leftover.py +++ b/script/addons/theme_leftover.py @@ -121,12 +121,118 @@ def render(theme, attachments, views): return "\n".join(lines) + "\n" +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. + """ + lst_id = [row.split("|")[0] for row in lst_row] + 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 + + +def prompt(database, theme, attachments, views, config_path, ask=input): + """Garder ou effacer. « Garder » par défaut, et la sauvegarde d'abord.""" + if not attachments: + return False + answer = ( + ask( + f"💬 {t('Delete these leftovers, or keep them?')}" + f" ({t('Enter = keep')}, d = {t('delete, after saving them')}) : " + ) + .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 + + 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) + 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", + ) config = parser.parse_args(argv) try: attachments, views = collect(config.database, config.theme) @@ -134,7 +240,32 @@ def main(argv=None): print(f"❌ {exc}") return 2 print(render(config.theme, attachments, views)) - return 1 if (attachments or views) else 0 + 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 + if not config.report_only and sys.stdin.isatty(): + if prompt( + config.database, + config.theme, + attachments, + views, + config.config, + ): + return 0 + return 1 if __name__ == "__main__": diff --git a/script/addons/uninstall_addons_theme.sh b/script/addons/uninstall_addons_theme.sh index 5eafad4..aa45da1 100755 --- a/script/addons/uninstall_addons_theme.sh +++ b/script/addons/uninstall_addons_theme.sh @@ -89,4 +89,8 @@ fi # sont parties, mais elles survivent à toutes les migrations suivantes et # personne ne sait plus d'où elles viennent. On les signale, on ne les # supprime pas : leur contenu peut être la seule trace d'une personnalisation. -./script/addons/theme_leftover.py -d "$DATABASE" -t "$THEME" +# Son code de sortie dit « il reste des choses », pas « la désinstallation a +# échoué » : le laisser devenir celui du script faisait annoncer une erreur +# sur un thème correctement retiré. +./script/addons/theme_leftover.py -d "$DATABASE" -t "$THEME" -c "$CONFIG" || true +exit 0 diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 8d71d69..43a78f7 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -4950,6 +4950,34 @@ TRANSLATIONS = { "fr": "Choix inconnu, rien n'a été réinitialisé.", "en": "Unknown choice, nothing was reset.", }, + "Delete these leftovers, or keep them?": { + "fr": "Effacer ces restes, ou les garder ?", + "en": "Delete these leftovers, or keep them?", + }, + "Enter = keep": { + "fr": "Entrée = garder", + "en": "Enter = keep", + }, + "delete, after saving them": { + "fr": "effacer, après les avoir sauvegardés", + "en": "delete, after saving them", + }, + "Kept. Nothing was deleted.": { + "fr": "Conservés. Rien n'a été effacé.", + "en": "Kept. Nothing was deleted.", + }, + "Saved before deleting": { + "fr": "Sauvegardé avant effacement", + "en": "Saved before deleting", + }, + "Deletion failed, nothing was removed.": { + "fr": "L'effacement a échoué, rien n'a été retiré.", + "en": "Deletion failed, nothing was removed.", + }, + "attachment(s) deleted.": { + "fr": "pièce(s) jointe(s) effacée(s).", + "en": "attachment(s) deleted.", + }, "Nothing to decide yet": { "fr": "Rien à décider pour l'instant", "en": "Nothing to decide yet", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index d170eeb..27341a1 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -1624,11 +1624,13 @@ class TodoUpgrade: # -- before hours of migration -- leaves time to arbitrate. # Only the next bump can be predicted: the modes in database describe # the current version. - status, cmd_executed, output = self.todo_upgrade_execute( + # Sur le terminal, sans capturer : plus rien ne relit cette sortie — + # la décision se prend sur le code de retour — et la capture faisait + # annoncer « Command returned error code: 1 » sur un rapport qui va + # bien. 1 veut dire « des copies casseront », pas « l'outil a raté ». + status = self.run_on_terminal( f"{PYTHON_BIN} ./script/odoo/migration/check_cow_views.py" - f" -d {database_name} -t odoo{start_version + 1}.0", - get_output=True, - wait_at_error=False, + f" -d {database_name} -t odoo{start_version + 1}.0" ) # L'avertissement annonçait un problème, invitait à arbitrer, et # aucune question ne suivait : dire « la question viendra au palier » diff --git a/test/test_uninstall_addons_theme.py b/test/test_uninstall_addons_theme.py index bf22e3e..f8fd8c8 100755 --- a/test/test_uninstall_addons_theme.py +++ b/test/test_uninstall_addons_theme.py @@ -213,6 +213,124 @@ class TestTheMigrationOffersIt(unittest.TestCase): self.assertIn("name <> 'theme_default'", source) +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 + theme_leftover.backup_attachments = ( + lambda db, th, rows, fs=None: self.saved.append(rows) or ["/tmp/x"] + ) + 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()) + + +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")] + self.assertGreater( + avant.rfind("run_on_terminal("), + avant.rfind("todo_upgrade_execute("), + ) + + class TestExitCodes(unittest.TestCase): """0 rien, 1 des restes, 2 l'outil a échoué — comme les outils voisins.""" From c65db269ed68cbcf708c493cd7dd6595a88f7039 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sun, 16 Aug 2026 05:50:00 -0400 Subject: [PATCH 19/67] [ADD] migration: request every public URL before calling it done MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A migration can load every module, log nothing, and still serve 500s on pages nobody thought to open. Measured on the real one: 2 of 33 public URLs failed — a blog post and /contactus — after a bump the log called successful. The list is the sitemap, what Odoo publishes for search engines. It cannot be read from odoo-bin shell: enumerate_pages() asks for http.root.get_db_router(request.db) and raises « object unbound » without a real request. So the server is started on its own port, asked, and always stopped — a forgotten one holds the port and fails the next bump. Offered before the Selenium prompt, default no: it boots a server and can take minutes. --- FR --- [ADD] migration : interroger chaque URL publique avant de conclure Une migration peut charger tous ses modules, ne rien écrire au journal, et servir quand même des 500 sur des pages que personne n'ouvre. Mesuré : 2 URL publiques sur 33 échouaient — un billet de blogue et /contactus — après un palier que le journal disait réussi. La liste est le sitemap, celle qu'Odoo publie pour les moteurs. Elle ne se lit pas depuis odoo-bin shell : enumerate_pages() réclame http.root.get_db_router(request.db) et lève « object unbound » sans requête réelle. Le serveur est donc démarré sur son propre port, interrogé, et toujours arrêté — un serveur oublié tient le port et fait échouer le palier suivant. Proposé avant l'invite Selenium, par défaut non : cela démarre un serveur et peut durer. Assisted-by: Claude Opus 5 (cherry picked from commit 277e85b3d06173beeb92be792467751b31ac0696) --- script/odoo/migration/smoke_public_url.py | 239 ++++++++++++++++++++++ script/todo/todo_i18n.py | 44 ++++ script/todo/todo_upgrade.py | 34 +++ test/test_smoke_public_url.py | 234 +++++++++++++++++++++ 4 files changed, 551 insertions(+) create mode 100755 script/odoo/migration/smoke_public_url.py create mode 100755 test/test_smoke_public_url.py diff --git a/script/odoo/migration/smoke_public_url.py b/script/odoo/migration/smoke_public_url.py new file mode 100755 index 0000000..c0555b8 --- /dev/null +++ b/script/odoo/migration/smoke_public_url.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Request every public URL of a migrated database, and report what breaks. + +Why this exists +--------------- +A migration can finish, load every module, and still serve a 500 on a page +nobody thought to open. Measured on a real one: ``/blog//post/`` +answered 500 because a COW copy frozen on the previous version no longer held +the section a child view xpaths into. Nothing in the migration log said so — +the module loading had succeeded. + +Where the list comes from +------------------------- +``/sitemap.xml``: the list Odoo itself publishes for search engines, built by +``website.enumerate_pages()``. It covers the controller routes declared with +``sitemap=True`` and the records behind them — pages, blog posts, products. + +It cannot be read from ``odoo-bin shell``: ``enumerate_pages()`` asks for +``http.root.get_db_router(request.db)`` and raises « object unbound » without +a real request. So the server is started, asked, and stopped. + +What counts as a failure +------------------------ +Any status >= 400. The sitemap is the PUBLIC list: a page listed there and +answering 403 or 404 is as wrong as one answering 500, just less loud. + +Exit codes: 0 every URL answered, 1 some failed, 2 the tool failed. +""" + +import argparse +import os +import re +import subprocess +import sys +import time +import urllib.error +import urllib.parse +import urllib.request + +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 + + +RE_LOC = re.compile(r"\s*([^<\s]+)\s*", re.I) + +# Un port à part : la migration tourne souvent à côté d'une instance vivante, +# et lui voler 8069 ferait échouer le test pour une raison sans rapport. +DEFAULT_PORT = 8169 + + +def start_server(database, port, config_path="./config.conf"): + """Démarrer Odoo sur la base, sans écrire dans le terminal appelant.""" + return subprocess.Popen( + [ + "./run.sh", + "-c", + config_path, + "-d", + database, + "--http-port", + str(port), + "--log-level=warn", + ], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + + +def fetch(url, timeout=30): + """(statut, corps). Statut 0 quand la connexion elle-même échoue.""" + try: + with urllib.request.urlopen(url, timeout=timeout) as answer: + return answer.getcode(), answer.read().decode( + "utf-8", errors="replace" + ) + except urllib.error.HTTPError as exc: + return exc.code, exc.read().decode("utf-8", errors="replace") + except Exception: + return 0, "" + + +def wait_ready(base_url, timeout=180, sleep=2): + """Attendre que le serveur réponde. False s'il n'est jamais venu.""" + deadline = time.time() + timeout + while time.time() < deadline: + status, _body = fetch(base_url + "/web/login", timeout=5) + if status: + return True + time.sleep(sleep) + return False + + +def sitemap_urls(base_url): + """Les URL du sitemap, index compris, ramenées sur l'hôte local. + + Le sitemap porte le domaine du site (technolibre.ca) ; on teste une base + servie en local. Garder le domaine ferait interroger la production — + c'est le genre d'erreur qui ne se voit qu'après. + """ + status, body = fetch(base_url + "/sitemap.xml") + if not status or status >= 400: + return [], status + lst_loc = RE_LOC.findall(body) + # Un index de sitemaps ne contient que des sitemaps : on descend d'un cran. + if "= 400: + lst_failure.append((url, status)) + return lst_failure + + +def render(lst_url, lst_failure): + if not lst_url: + return f"⚠️ {t('The sitemap listed no URL: nothing was tested.')}\n" + if not lst_failure: + return ( + f"✅ -> {len(lst_url)} {t('public URL(s) answered without error.')}" + "\n" + ) + lines = [ + f"❌ {len(lst_failure)} {t('of')} {len(lst_url)}" + f" {t('public URL(s) failed')} :" + ] + for url, status in lst_failure: + label = status or t("no answer") + lines.append(f" [{label}] {url}") + lines.append( + f" {t('A page listed for search engines that does not answer is')}" + f" {t('a page your visitors do not reach either.')}" + ) + return "\n".join(lines) + "\n" + + +def run(database, port, config_path, limit=None, timeout=30, boot=180): + """Démarrer, interroger, arrêter. Rend (urls, échecs).""" + base_url = f"http://127.0.0.1:{port}" + server = start_server(database, port, config_path) + try: + if not wait_ready(base_url, timeout=boot): + raise RuntimeError( + f"{t('The server never answered on')} {base_url}" + ) + lst_url, status = sitemap_urls(base_url) + if not status: + raise RuntimeError(f"{t('Could not read')} {base_url}/sitemap.xml") + if limit: + lst_url = lst_url[:limit] + return lst_url, check_urls(lst_url, timeout=timeout) + finally: + server.terminate() + try: + server.wait(timeout=30) + except subprocess.TimeoutExpired: + server.kill() + + +def main(argv=None): + parser = argparse.ArgumentParser( + description=( + "Start Odoo on a database, request every URL of its sitemap, and" + " report those that fail." + ) + ) + parser.add_argument("-d", "--database", required=True) + parser.add_argument("-c", "--config", default="./config.conf") + parser.add_argument("-p", "--port", type=int, default=DEFAULT_PORT) + parser.add_argument( + "--limit", type=int, default=None, help="test only the first N URLs" + ) + parser.add_argument("--timeout", type=int, default=30) + parser.add_argument( + "--boot-timeout", + type=int, + default=180, + help="how long to wait for the server to answer", + ) + config = parser.parse_args(argv) + + print( + f"⧖ {t('Starting Odoo on')} '{config.database}'" + f" ({t('port')} {config.port})…" + ) + try: + lst_url, lst_failure = run( + config.database, + config.port, + config.config, + limit=config.limit, + timeout=config.timeout, + boot=config.boot_timeout, + ) + except RuntimeError as exc: + print(f"❌ {exc}") + return 2 + print(render(lst_url, lst_failure)) + return 1 if lst_failure else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 43a78f7..f9937f8 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -4978,6 +4978,50 @@ TRANSLATIONS = { "fr": "pièce(s) jointe(s) effacée(s).", "en": "attachment(s) deleted.", }, + "Request every public URL of this database now?": { + "fr": "Interroger maintenant toutes les URL publiques de cette base ?", + "en": "Request every public URL of this database now?", + }, + "The sitemap listed no URL: nothing was tested.": { + "fr": "Le sitemap n'a listé aucune URL : rien n'a été testé.", + "en": "The sitemap listed no URL: nothing was tested.", + }, + "public URL(s) answered without error.": { + "fr": "URL publique(s) ont répondu sans erreur.", + "en": "public URL(s) answered without error.", + }, + "public URL(s) failed": { + "fr": "URL publique(s) ont échoué", + "en": "public URL(s) failed", + }, + "no answer": { + "fr": "aucune réponse", + "en": "no answer", + }, + "A page listed for search engines that does not answer is": { + "fr": "Une page listée pour les moteurs de recherche qui ne répond pas est", + "en": "A page listed for search engines that does not answer is", + }, + "a page your visitors do not reach either.": { + "fr": "une page que vos visiteurs n'atteignent pas non plus.", + "en": "a page your visitors do not reach either.", + }, + "The server never answered on": { + "fr": "Le serveur n'a jamais répondu sur", + "en": "The server never answered on", + }, + "Could not read": { + "fr": "Impossible de lire", + "en": "Could not read", + }, + "Starting Odoo on": { + "fr": "Démarrage d'Odoo sur", + "en": "Starting Odoo on", + }, + "port": { + "fr": "port", + "en": "port", + }, "Nothing to decide yet": { "fr": "Rien à décider pour l'instant", "en": "Nothing to decide yet", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index 27341a1..ffcb9ba 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -2552,6 +2552,13 @@ class TodoUpgrade: # Update config without OCA_OpenUpgrade cmd_update_config = f"./script/git/git_repo_update_group.py && ./script/generate_config.sh" self.todo_upgrade_execute(cmd_update_config) + + # Une migration peut charger tous ses modules et servir + # quand même un 500 sur une page que personne n'ouvre. Mesuré + # ici : /blog//post/ et /contactus, alors que + # le journal de migration n'avait rien signalé. + self.prompt_smoke_public_url(database_name_upgrade) + print(f"[y] {t('Open the server with Selenium')}") status = ( input( @@ -2857,6 +2864,33 @@ class TodoUpgrade: f" -d {database_name} {args} --apply" ) + def prompt_smoke_public_url(self, database_name): + """Proposer d'interroger toutes les pages publiques de la base. + + La liste vient du sitemap — celle qu'Odoo publie pour les moteurs de + recherche. Une page qui y figure et ne répond pas est une page que + les visiteurs n'atteignent pas non plus. + + « non » par défaut : cela démarre un serveur et peut prendre quelques + minutes sur un gros site, et rien n'oblige à le faire à chaque + palier. + """ + answer = ( + self.ask_gate( + f"💬 {t('Request every public URL of this database now?')}" + f" (y/N, {t('(b = go back to a previous step)')}) : " + ) + .strip() + .lower() + ) + if answer != "y": + return + self.run_on_terminal( + f"{PYTHON_BIN}" + " ./script/odoo/migration/smoke_public_url.py" + f" -d {database_name}" + ) + def show_cow_drift(self, database_name, next_version, mode="diff"): """Montre les copies COW à risque. Ne touche à rien. diff --git a/test/test_smoke_public_url.py b/test/test_smoke_public_url.py new file mode 100755 index 0000000..242068e --- /dev/null +++ b/test/test_smoke_public_url.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Une migration peut réussir et servir quand même des 500. + +Mesuré sur une vraie migration 12 → 13 : tous les modules chargés, aucune +erreur au journal, et pourtant deux pages publiques sur trente-trois +répondaient 500 — un billet de blogue et /contactus. Rien ne les distinguait +avant de les demander. + +La liste vient du sitemap, celle qu'Odoo publie pour les moteurs de +recherche. Elle ne se lit PAS depuis « odoo-bin shell » : enumerate_pages() +réclame http.root.get_db_router(request.db) et lève « object unbound » sans +requête réelle. D'où le serveur démarré, interrogé, arrêté. +""" + +import os +import sys +import unittest + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(REPO, "script", "odoo", "migration")) + +import smoke_public_url as smoke # noqa: E402 + + +class TestTheUrlAreBroughtHome(unittest.TestCase): + """Le sitemap porte le domaine du site ; on teste une base locale.""" + + def test_the_host_is_replaced(self): + # Garder le domaine ferait interroger la PRODUCTION depuis une + # migration. C'est le genre d'erreur qui ne se voit qu'après. + url = smoke.local_url( + "http://127.0.0.1:8169", "https://technolibre.ca/blog/x-3/post/y-5" + ) + self.assertEqual(url, "http://127.0.0.1:8169/blog/x-3/post/y-5") + + def test_a_query_string_survives(self): + url = smoke.local_url("http://127.0.0.1:8169", "https://a.ca/p?x=1") + self.assertEqual(url, "http://127.0.0.1:8169/p?x=1") + + def test_a_bare_domain_becomes_the_root(self): + url = smoke.local_url("http://127.0.0.1:8169", "https://a.ca") + self.assertEqual(url, "http://127.0.0.1:8169/") + + def test_a_trailing_slash_on_the_base_does_not_double(self): + url = smoke.local_url("http://127.0.0.1:8169/", "https://a.ca/p") + self.assertEqual(url, "http://127.0.0.1:8169/p") + + +class TestReadingTheSitemap(unittest.TestCase): + def setUp(self): + self.answers = {} + self.original = smoke.fetch + smoke.fetch = lambda url, timeout=30: self.answers.get(url, (404, "")) + self.addCleanup(setattr, smoke, "fetch", self.original) + + def test_a_plain_sitemap(self): + self.answers["http://h/sitemap.xml"] = ( + 200, + "https://a.ca/xhttps://a.ca/y", + ) + lst_url, status = smoke.sitemap_urls("http://h") + self.assertEqual(status, 200) + self.assertEqual(lst_url, ["http://h/x", "http://h/y"]) + + def test_an_index_is_followed(self): + # Odoo découpe le sitemap au-delà d'un certain nombre de pages : ne + # pas descendre d'un cran ferait tester deux fichiers XML au lieu du + # site, et conclure « tout va bien ». + self.answers["http://h/sitemap.xml"] = ( + 200, + "https://a.ca/sitemap-1-1.xml" + "", + ) + self.answers["http://h/sitemap-1-1.xml"] = ( + 200, + "https://a.ca/deep", + ) + lst_url, _status = smoke.sitemap_urls("http://h") + self.assertEqual(lst_url, ["http://h/deep"]) + + def test_duplicates_are_dropped(self): + self.answers["http://h/sitemap.xml"] = ( + 200, + "https://a.ca/xhttps://a.ca/x", + ) + lst_url, _status = smoke.sitemap_urls("http://h") + self.assertEqual(lst_url, ["http://h/x"]) + + def test_an_unreachable_sitemap_is_reported_not_taken_as_empty(self): + # Sans cela, un sitemap injoignable se lirait comme « aucune page à + # tester », c'est-à-dire comme un succès. + lst_url, status = smoke.sitemap_urls("http://h") + self.assertEqual(lst_url, []) + self.assertEqual(status, 404) + + +class TestWhatCountsAsAFailure(unittest.TestCase): + def setUp(self): + self.answers = {} + self.original = smoke.fetch + smoke.fetch = lambda url, timeout=30: self.answers.get(url, (200, "")) + self.addCleanup(setattr, smoke, "fetch", self.original) + + def test_a_500_fails(self): + self.answers["http://h/bad"] = (500, "") + self.assertEqual( + smoke.check_urls(["http://h/bad"]), [("http://h/bad", 500)] + ) + + def test_a_404_fails_too(self): + # Le sitemap est la liste PUBLIQUE : une page qui y figure et répond + # 404 est aussi fausse qu'un 500, juste plus discrète. + self.answers["http://h/gone"] = (404, "") + self.assertEqual(len(smoke.check_urls(["http://h/gone"])), 1) + + def test_no_answer_at_all_fails(self): + self.answers["http://h/dead"] = (0, "") + self.assertEqual( + smoke.check_urls(["http://h/dead"]), [("http://h/dead", 0)] + ) + + def test_a_200_passes(self): + self.assertEqual(smoke.check_urls(["http://h/ok"]), []) + + +class TestTheReport(unittest.TestCase): + def setUp(self): + from script.todo import todo_i18n + + self.addCleanup( + setattr, todo_i18n, "_current_lang", todo_i18n._current_lang + ) + todo_i18n._current_lang = "en" + + def test_all_green_says_how_many(self): + text = smoke.render(["a", "b"], []) + self.assertIn("2", text) + self.assertIn("✅", text) + + def test_a_failure_shows_the_status_and_the_url(self): + text = smoke.render(["a"], [("http://h/blog/x", 500)]) + self.assertIn("500", text) + self.assertIn("http://h/blog/x", text) + + def test_an_empty_sitemap_is_not_a_success(self): + # « rien testé » ne doit pas se lire « rien de cassé ». + text = smoke.render([], []) + self.assertIn("⚠️", text) + + +class TestTheServerIsAlwaysStopped(unittest.TestCase): + """Un serveur oublié tient le port et fait échouer le palier suivant.""" + + def test_it_is_terminated_even_when_the_sitemap_fails(self): + stopped = [] + + class FakeServer: + def terminate(self): + stopped.append("terminate") + + def wait(self, timeout=None): + return 0 + + def kill(self): + stopped.append("kill") + + original_start = smoke.start_server + original_wait = smoke.wait_ready + smoke.start_server = lambda db, port, cfg="./config.conf": FakeServer() + smoke.wait_ready = lambda base, timeout=180, sleep=2: False + self.addCleanup(setattr, smoke, "start_server", original_start) + self.addCleanup(setattr, smoke, "wait_ready", original_wait) + with self.assertRaises(RuntimeError): + smoke.run("db", 8169, "./config.conf") + self.assertEqual(stopped, ["terminate"]) + + def test_a_distinct_port_by_default(self): + # 8069 est souvent pris par l'instance de travail : le lui voler + # ferait échouer le test pour une raison sans rapport. + self.assertNotEqual(smoke.DEFAULT_PORT, 8069) + + +class TestTheMigrationOffersIt(unittest.TestCase): + 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 run_prompt(self, answer): + from script.todo.todo_upgrade import TodoUpgrade + + upgrade = TodoUpgrade.__new__(TodoUpgrade) + upgrade.dct_progression = {} + upgrade.lst_command_executed = [] + upgrade.write_config = lambda: None + lst_cmd = [] + upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0 + upgrade.ask_gate = lambda prompt: answer + upgrade.prompt_smoke_public_url("db_upgrade_13") + return lst_cmd + + def test_the_default_runs_nothing(self): + # Cela démarre un serveur et peut durer : pas à chaque palier sans + # qu'on l'ait demandé. + self.assertEqual(self.run_prompt(""), []) + + def test_yes_runs_it_on_the_upgraded_database(self): + lst_cmd = self.run_prompt("y") + self.assertEqual(len(lst_cmd), 1) + self.assertIn("smoke_public_url.py", lst_cmd[0]) + self.assertIn("-d db_upgrade_13", lst_cmd[0]) + + def test_it_is_asked_before_the_selenium_prompt(self): + # Après, la question arriverait une fois le navigateur ouvert : on + # aurait déjà cherché à la main ce que le test nomme. + import inspect + + from script.todo.todo_upgrade import TodoUpgrade + + source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade) + self.assertLess( + source.index("prompt_smoke_public_url"), + source.index("Open the server with Selenium"), + ) + + +if __name__ == "__main__": + unittest.main() From bc52a93e67c854b2b6df44beca1cd427a65a05c7 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sun, 16 Aug 2026 06:18:45 -0400 Subject: [PATCH 20/67] [ADD] migration: name the views behind a failing URL, and offer the reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The smoke test stopped at « these URLs answer 500 ». Turning that into a fix meant reading an id out of a traceback and translating it to a key by hand, mid-migration — the copy where a character goes missing. It now reads the error context Odoo logs — [view_id: N, parent_id: M], the one line it does not translate — resolves the parent to its key, offers the reset numbered with « all », and re-requests the failing URLs afterwards. Applying without re-asking would be calling it fixed without having seen it answer. Two defects found while measuring, both mine. ./run.sh is a bash wrapper: terminate() killed it and left odoo-bin holding the port, six orphans in six runs, each later run silently querying the first one's server. And the log was read before stopping, so only the 24 startup lines existed — hence « no view in cause » on pages that named one. --- FR --- [ADD] migration : nommer les vues derrière une URL en échec, et corriger Le test s'arrêtait à « ces URL répondent 500 ». En faire un correctif demandait de relever un id dans une trace et de le traduire en clé à la main, en pleine migration — la recopie où un caractère se perd. Il lit désormais le contexte qu'Odoo journalise — [view_id: N, parent_id: M], la seule ligne qu'il ne traduit pas —, résout le parent en clé, propose la réinitialisation numérotée avec « toutes », et redemande ensuite les URL en échec. Appliquer sans redemander, ce serait déclarer réparé sans l'avoir vu répondre. Deux défauts trouvés en mesurant, tous deux à moi. ./run.sh est une enveloppe bash : terminate() la tuait et laissait odoo-bin tenir le port, six orphelins en six essais, chaque essai suivant interrogeant sans le savoir le serveur du premier. Et le journal était lu avant l'arrêt, donc seules les 24 lignes de démarrage existaient — d'où « aucune vue en cause » sur des pages qui en nommaient une. Assisted-by: Claude Opus 5 (cherry picked from commit 30fbcd1aee20c4028ea6b24bd04eb518f0587ffe) --- script/odoo/migration/smoke_public_url.py | 345 ++++++++++++++++++++-- script/todo/todo_i18n.py | 40 +++ test/test_smoke_public_url.py | 140 ++++++++- 3 files changed, 502 insertions(+), 23 deletions(-) diff --git a/script/odoo/migration/smoke_public_url.py b/script/odoo/migration/smoke_public_url.py index c0555b8..624142e 100755 --- a/script/odoo/migration/smoke_public_url.py +++ b/script/odoo/migration/smoke_public_url.py @@ -33,6 +33,8 @@ Exit codes: 0 every URL answered, 1 some failed, 2 the tool failed. import argparse import os import re +import signal +import socket import subprocess import sys import time @@ -54,14 +56,44 @@ except Exception: # pragma: no cover - repli si i18n indisponible RE_LOC = re.compile(r"\s*([^<\s]+)\s*", re.I) +# « [view_id: 3282, xml_id: n/a, model: n/a, parent_id: 3281] ». Seule la +# phrase qui précède est traduite ; cette ligne-ci ne l'est pas, donc la lire +# marche dans les deux langues. Le PARENT est le coupable : c'est la copie +# figée dans laquelle l'enfant ne trouve plus son xpath. +RE_CONTEXT = re.compile(r"\[view_id: (\d+),.*?parent_id: (\d+)\]") + # Un port à part : la migration tourne souvent à côté d'une instance vivante, # et lui voler 8069 ferait échouer le test pour une raison sans rapport. DEFAULT_PORT = 8169 -def start_server(database, port, config_path="./config.conf"): - """Démarrer Odoo sur la base, sans écrire dans le terminal appelant.""" - return subprocess.Popen( +def run_psql(database, sql): + """Lecture seule, garantie par le serveur PostgreSQL lui-même.""" + env = os.environ.copy() + env["PGOPTIONS"] = "-c default_transaction_read_only=on" + env["PSQLRC"] = "" + done = subprocess.run( + ["psql", "-X", "-w", "-d", database, "-tAF", "\x1f", "-c", sql], + capture_output=True, + text=True, + env=env, + ) + if done.returncode: + return [] + return [line.split("\x1f") for line in done.stdout.splitlines() if line] + + +def start_server(database, port, config_path="./config.conf", log_path=None): + """Démarrer Odoo, son journal dans un FICHIER. + + Pas un tube : Python bufferise par blocs quand sa sortie n'est pas un + terminal, et un fil de lecture court alors après des lignes qui n'ont + pas encore été écrites. Mesuré — 24 lignes vues sur 198, et la trace qui + nomme la vue fautive faisait partie des absentes. Un fichier se relit + entièrement, quand on veut. + """ + handle = open(log_path, "w", encoding="utf-8") if log_path else None + server = subprocess.Popen( [ "./run.sh", "-c", @@ -72,10 +104,26 @@ def start_server(database, port, config_path="./config.conf"): str(port), "--log-level=warn", ], - stdout=subprocess.PIPE, + stdout=handle or subprocess.DEVNULL, stderr=subprocess.STDOUT, text=True, + # Son propre groupe de processus : « ./run.sh » est un script bash + # qui ne transmet rien à son enfant. Un terminate() sur lui tuait le + # script et laissait odoo-bin vivant, tenant le port. L'essai suivant + # démarrait alors un serveur qui ne pouvait pas se lier, et + # interrogeait sans le savoir CELUI D'AVANT — mesuré, deux fois. + start_new_session=True, ) + server.erplibre_log = handle + return server + + +def read_log(log_path): + """Le journal du serveur, tel qu'écrit jusqu'ici.""" + if not log_path or not os.path.isfile(log_path): + return [] + with open(log_path, "r", encoding="utf-8", errors="replace") as handle: + return handle.read().splitlines() def fetch(url, timeout=30): @@ -138,17 +186,84 @@ def local_url(base_url, loc): return base_url.rstrip("/") + path +# Odoo écrit sa trace APRÈS avoir répondu, et par le tube d'un script shell : +# mesuré, elle peut arriver près de trois secondes plus tard. Une demi-seconde +# concluait « aucune vue en cause » sur des pages qui en nommaient une. +LOG_DELAY = 3.0 + + +def attach_missing_parents(lst_failure, lst_log): + """Repêcher les contextes arrivés trop tard pour leur tranche. + + Le découpage par URL est une commodité, pas une garantie : le journal est + asynchrone. Ce second passage lit TOUT ce qui a été capturé et rattache + ce qui n'avait été rattaché à rien — mieux vaut un coupable mal attribué + qu'un coupable perdu. + """ + known = {pid for _u, _s, lst in lst_failure for pid in lst} + extra = [] + for line in lst_log: + for _view_id, parent_id in RE_CONTEXT.findall(line): + if parent_id not in known and parent_id not in extra: + extra.append(parent_id) + if not extra: + return lst_failure + rebuilt = [] + placed = False + for url, status, lst_parent in lst_failure: + if not lst_parent and not placed: + lst_parent = list(extra) + placed = True + rebuilt.append((url, status, lst_parent)) + if not placed and rebuilt: + url, status, lst_parent = rebuilt[0] + rebuilt[0] = (url, status, lst_parent + extra) + return rebuilt + + def check_urls(lst_url, timeout=30): - """[(url, statut)] pour celles qui ont échoué.""" + """[(url, statut, [])] pour celles qui ont échoué. + + Les vues en cause sont rattachées après coup, en relisant le journal du + serveur : elles y arrivent quand Odoo vide son tampon, pas quand la + requête revient. + """ lst_failure = [] for url in lst_url: status, _body = fetch(url, timeout=timeout) if status == 0 or status >= 400: - lst_failure.append((url, status)) + lst_failure.append((url, status, [])) return lst_failure -def render(lst_url, lst_failure): +def culprit_keys(database, lst_failure): + """Les clés des vues parentes mises en cause, sans doublon. + + C'est ce qu'on passe à reset_stale_cow_views : il travaille par clé, et + relever un identifiant dans une trace pour le traduire à la main est + exactement la recopie où l'on se trompe. + """ + lst_id = [] + for _url, _status, lst_parent in lst_failure: + for parent_id in lst_parent: + if parent_id not in lst_id: + lst_id.append(parent_id) + if not lst_id: + return [] + ids = ",".join(str(int(x)) for x in lst_id) + rows = run_psql( + database, + f"SELECT id, key, website_id FROM ir_ui_view WHERE id IN ({ids})" + " AND key IS NOT NULL ORDER BY id;", + ) + lst_key = [] + for row in rows: + if len(row) >= 2 and row[1] and row[1] not in lst_key: + lst_key.append(row[1]) + return lst_key + + +def render(lst_url, lst_failure, lst_key=None): if not lst_url: return f"⚠️ {t('The sitemap listed no URL: nothing was tested.')}\n" if not lst_failure: @@ -160,9 +275,21 @@ def render(lst_url, lst_failure): f"❌ {len(lst_failure)} {t('of')} {len(lst_url)}" f" {t('public URL(s) failed')} :" ] - for url, status in lst_failure: + for url, status, lst_parent in lst_failure: label = status or t("no answer") lines.append(f" [{label}] {url}") + if lst_parent: + lines.append( + f" {t('parent view(s) in cause')} :" + f" {', '.join(lst_parent)}" + ) + if lst_key: + lines.append( + f" {t('Those parents are copies frozen on an older version;')}" + f" {t('resetting them onto the module view is the fix')} :" + ) + for key in lst_key: + lines.append(f" {key}") lines.append( f" {t('A page listed for search engines that does not answer is')}" f" {t('a page your visitors do not reach either.')}" @@ -170,10 +297,104 @@ def render(lst_url, lst_failure): return "\n".join(lines) + "\n" -def run(database, port, config_path, limit=None, timeout=30, boot=180): - """Démarrer, interroger, arrêter. Rend (urls, échecs).""" +def apply_reset(database, lst_key): + """Réinitialiser ces copies sur leur vue module. ÉCRIT en base. + + On délègue à reset_stale_cow_views : il sauvegarde l'arch précédente + avant d'écrire, et c'est déjà lui qu'on documente partout ailleurs. En + refaire une seconde version ici, c'est se donner deux comportements à + tenir d'accord. + """ + cmd = [ + sys.executable, + os.path.join( + "script", "odoo", "migration", "reset_stale_cow_views.py" + ), + "-d", + database, + ] + for key in lst_key: + cmd += ["--reset", key] + cmd.append("--apply") + done = subprocess.run(cmd, capture_output=True, text=True) + return done.returncode, done.stdout + done.stderr + + +def prompt(database, lst_failure, lst_key, ask=input): + """Proposer de corriger, puis dire ce qu'il reste. Rend les clés traitées. + + Détecter sans offrir le geste, c'est laisser relever des identifiants + dans une trace pour les traduire en clés à la main — au moment précis où + l'on veut juste que la page réponde. + """ + if not lst_key: + print(f"ℹ -> {t('No parent view named: nothing to offer.')}") + return [] + print(f"\n✨ {t('Copies to reset onto their module view')} :") + for index, key in enumerate(lst_key, start=1): + print(f" [{index}] {key}") + print(f" [a] {t('All of the list above')}") + answer = ( + ask( + f"💬 {t('Which one(s) to reset?')}" + f" ({t('numbers separated by commas, a = all, empty =')}" + f" {t('nothing')}) : " + ) + .strip() + .lower() + ) + if not answer: + print(f"ℹ -> {t('Kept. Nothing was reset.')}") + return [] + if answer == "a": + lst_chosen = list(lst_key) + else: + lst_chosen = [] + for part in answer.replace(" ", "").split(","): + if part.isdigit() and 1 <= int(part) <= len(lst_key): + lst_chosen.append(lst_key[int(part) - 1]) + if not lst_chosen: + print(f"⚠️ {t('Unknown choice, nothing was reset.')}") + return [] + status, output = apply_reset(database, lst_chosen) + print(output.strip()[-2000:]) + if status == 2: + print(f"❌ {t('Reset failed, nothing was changed.')}") + return [] + return lst_chosen + + +def run( + database, + port, + config_path, + limit=None, + timeout=30, + boot=180, + interactive=False, + auto_apply=False, + ask=input, +): + """Démarrer, interroger, arrêter, LIRE, éventuellement corriger, revérifier. + + L'ordre porte tout le correctif : un serveur qui écrit dans un fichier + bufferise et ne vide qu'en s'arrêtant. Lire avant l'arrêt donnait + vingt-quatre lignes de démarrage et zéro trace — donc « aucune vue en + cause » sur des pages qui en nommaient une. Mesuré deux fois avant d'être + compris. + """ + import tempfile + base_url = f"http://127.0.0.1:{port}" - server = start_server(database, port, config_path) + log_path = os.path.join( + tempfile.gettempdir(), f"erplibre_smoke_{database}_{port}.log" + ) + if port_is_taken(port): + raise RuntimeError( + f"{t('Something already listens on port')} {port} :" + f" {t('it would be tested instead of this database.')}" + ) + server = start_server(database, port, config_path, log_path=log_path) try: if not wait_ready(base_url, timeout=boot): raise RuntimeError( @@ -184,13 +405,78 @@ def run(database, port, config_path, limit=None, timeout=30, boot=180): raise RuntimeError(f"{t('Could not read')} {base_url}/sitemap.xml") if limit: lst_url = lst_url[:limit] - return lst_url, check_urls(lst_url, timeout=timeout) + lst_failure = check_urls(lst_url, timeout=timeout) finally: - server.terminate() + stop_server(server) + + if lst_failure: + lst_failure = attach_missing_parents(lst_failure, read_log(log_path)) + lst_key = culprit_keys(database, lst_failure) + if not lst_failure or not (interactive or auto_apply): + return lst_url, lst_failure, lst_key, None + + print(render(lst_url, lst_failure, lst_key)) + if auto_apply: + lst_done = lst_key + if lst_done: + code, output = apply_reset(database, lst_done) + print(output.strip()[-2000:]) + if code == 2: + lst_done = [] + else: + lst_done = prompt(database, lst_failure, lst_key, ask=ask) + if not lst_done: + return lst_url, lst_failure, lst_key, None + + server = start_server(database, port, config_path, log_path=log_path) + try: + if not wait_ready(base_url, timeout=boot): + raise RuntimeError( + f"{t('The server never answered on')} {base_url}" + ) + lst_again = check_urls( + [url for url, _s, _p in lst_failure], timeout=timeout + ) + finally: + stop_server(server) + return lst_url, lst_failure, lst_key, lst_again + + +def stop_server(server): + """Arrêter tout le GROUPE : sinon odoo-bin survit à son script.""" + try: + group = os.getpgid(server.pid) + except OSError: + group = None + if group is not None: try: - server.wait(timeout=30) - except subprocess.TimeoutExpired: - server.kill() + os.killpg(group, signal.SIGTERM) + except OSError: + pass + try: + server.wait(timeout=30) + except subprocess.TimeoutExpired: + if group is not None: + try: + os.killpg(group, signal.SIGKILL) + except OSError: + pass + server.kill() + handle = getattr(server, "erplibre_log", None) + if handle: + handle.close() + + +def port_is_taken(port, host="127.0.0.1"): + """Quelqu'un écoute-t-il déjà là ? + + Sans cette question, un serveur resté d'un essai précédent répond à + « le serveur est-il prêt ? », et l'on teste sa base à lui en croyant + tester la sienne. C'est exactement ce qui est arrivé ici. + """ + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.settimeout(1) + return sock.connect_ex((host, port)) == 0 def main(argv=None): @@ -207,6 +493,16 @@ def main(argv=None): "--limit", type=int, default=None, help="test only the first N URLs" ) parser.add_argument("--timeout", type=int, default=30) + parser.add_argument( + "--apply", + action="store_true", + help="reset the views in cause without asking (WRITES)", + ) + parser.add_argument( + "--report-only", + action="store_true", + help="never ask anything, even in front of a terminal", + ) parser.add_argument( "--boot-timeout", type=int, @@ -219,20 +515,31 @@ def main(argv=None): f"⧖ {t('Starting Odoo on')} '{config.database}'" f" ({t('port')} {config.port})…" ) + interactive = not config.report_only and sys.stdin.isatty() try: - lst_url, lst_failure = run( + lst_url, lst_failure, lst_key, lst_again = run( config.database, config.port, config.config, limit=config.limit, timeout=config.timeout, boot=config.boot_timeout, + interactive=interactive, + auto_apply=config.apply, ) except RuntimeError as exc: print(f"❌ {exc}") return 2 - print(render(lst_url, lst_failure)) - return 1 if lst_failure else 0 + if lst_again is None: + print(render(lst_url, lst_failure, lst_key)) + return 1 if lst_failure else 0 + # Après correction on ne redit pas le diagnostic : on dit ce qu'il RESTE. + print( + f"\n↻ {t('Re-checked the')} {len(lst_failure)}" + f" {t('failing URL(s) after the reset')} :" + ) + print(render([url for url, _s, _p in lst_failure], lst_again, None)) + return 1 if lst_again else 0 if __name__ == "__main__": diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index f9937f8..5e9537c 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -5022,6 +5022,46 @@ TRANSLATIONS = { "fr": "port", "en": "port", }, + "parent view(s) in cause": { + "fr": "vue(s) parente(s) en cause", + "en": "parent view(s) in cause", + }, + "Those parents are copies frozen on an older version;": { + "fr": "Ces parents sont des copies figées sur une version antérieure ;", + "en": "Those parents are copies frozen on an older version;", + }, + "resetting them onto the module view is the fix": { + "fr": "les réinitialiser sur la vue module est le correctif", + "en": "resetting them onto the module view is the fix", + }, + "No parent view named: nothing to offer.": { + "fr": "Aucune vue parente nommée : rien à proposer.", + "en": "No parent view named: nothing to offer.", + }, + "Copies to reset onto their module view": { + "fr": "Copies à réinitialiser sur leur vue module", + "en": "Copies to reset onto their module view", + }, + "Which one(s) to reset?": { + "fr": "Laquelle ou lesquelles réinitialiser ?", + "en": "Which one(s) to reset?", + }, + "Re-checked the": { + "fr": "Revérification des", + "en": "Re-checked the", + }, + "failing URL(s) after the reset": { + "fr": "URL en échec après la réinitialisation", + "en": "failing URL(s) after the reset", + }, + "Something already listens on port": { + "fr": "Quelque chose écoute déjà sur le port", + "en": "Something already listens on port", + }, + "it would be tested instead of this database.": { + "fr": "il serait testé à la place de cette base.", + "en": "it would be tested instead of this database.", + }, "Nothing to decide yet": { "fr": "Rien à décider pour l'instant", "en": "Nothing to decide yet", diff --git a/test/test_smoke_public_url.py b/test/test_smoke_public_url.py index 242068e..3ebad84 100755 --- a/test/test_smoke_public_url.py +++ b/test/test_smoke_public_url.py @@ -107,7 +107,7 @@ class TestWhatCountsAsAFailure(unittest.TestCase): def test_a_500_fails(self): self.answers["http://h/bad"] = (500, "") self.assertEqual( - smoke.check_urls(["http://h/bad"]), [("http://h/bad", 500)] + smoke.check_urls(["http://h/bad"]), [("http://h/bad", 500, [])] ) def test_a_404_fails_too(self): @@ -119,7 +119,7 @@ class TestWhatCountsAsAFailure(unittest.TestCase): def test_no_answer_at_all_fails(self): self.answers["http://h/dead"] = (0, "") self.assertEqual( - smoke.check_urls(["http://h/dead"]), [("http://h/dead", 0)] + smoke.check_urls(["http://h/dead"]), [("http://h/dead", 0, [])] ) def test_a_200_passes(self): @@ -141,7 +141,7 @@ class TestTheReport(unittest.TestCase): self.assertIn("✅", text) def test_a_failure_shows_the_status_and_the_url(self): - text = smoke.render(["a"], [("http://h/blog/x", 500)]) + text = smoke.render(["a"], [("http://h/blog/x", 500, [])]) self.assertIn("500", text) self.assertIn("http://h/blog/x", text) @@ -169,10 +169,18 @@ class TestTheServerIsAlwaysStopped(unittest.TestCase): original_start = smoke.start_server original_wait = smoke.wait_ready - smoke.start_server = lambda db, port, cfg="./config.conf": FakeServer() + original_stop = smoke.stop_server + original_port = smoke.port_is_taken + smoke.start_server = ( + lambda db, port, cfg="./config.conf", log_path=None: FakeServer() + ) smoke.wait_ready = lambda base, timeout=180, sleep=2: False + smoke.stop_server = lambda server: stopped.append("terminate") + smoke.port_is_taken = lambda port, host="127.0.0.1": False self.addCleanup(setattr, smoke, "start_server", original_start) self.addCleanup(setattr, smoke, "wait_ready", original_wait) + self.addCleanup(setattr, smoke, "stop_server", original_stop) + self.addCleanup(setattr, smoke, "port_is_taken", original_port) with self.assertRaises(RuntimeError): smoke.run("db", 8169, "./config.conf") self.assertEqual(stopped, ["terminate"]) @@ -183,6 +191,130 @@ class TestTheServerIsAlwaysStopped(unittest.TestCase): self.assertNotEqual(smoke.DEFAULT_PORT, 8069) +class TestTheCulpritViewsAreNamed(unittest.TestCase): + """Détecter sans nommer laisse relever des ids dans une trace à la main.""" + + def test_the_parent_is_read_from_the_error_context(self): + # C'est le PARENT le coupable : la copie figée dans laquelle + # l'enfant ne trouve plus son xpath. + line = "[view_id: 3282, xml_id: n/a, model: n/a, parent_id: 3281]" + self.assertEqual(smoke.RE_CONTEXT.findall(line), [("3282", "3281")]) + + def test_the_context_line_is_not_translated(self): + # La phrase qui précède l'est, celle-ci non : la lire marche donc + # sur un système français comme anglais. + self.assertEqual( + smoke.RE_CONTEXT.findall("[view_id: 1, parent_id: 2]"), + [("1", "2")], + ) + + def test_a_late_context_is_still_attached(self): + # Odoo vide son tampon à l'arrêt : le journal se lit APRÈS, et rien + # ne doit dépendre du moment où la ligne est apparue. + lst_failure = [("http://h/a", 500, [])] + log = ["[view_id: 3288, model: n/a, parent_id: 2841]"] + rebuilt = smoke.attach_missing_parents(lst_failure, log) + self.assertEqual(rebuilt[0][2], ["2841"]) + + def test_an_already_attributed_parent_is_not_duplicated(self): + lst_failure = [("http://h/a", 500, ["2841"])] + log = ["[view_id: 3288, model: n/a, parent_id: 2841]"] + rebuilt = smoke.attach_missing_parents(lst_failure, log) + self.assertEqual(rebuilt[0][2], ["2841"]) + + +class TestTheServerIsKilledForReal(unittest.TestCase): + """« ./run.sh » est un script bash : il ne transmet pas les signaux.""" + + def test_it_starts_its_own_process_group(self): + # Sans cela, terminate() tue l'enveloppe et laisse odoo-bin vivant. + # Mesuré : six serveurs orphelins, un par essai, et les essais + # suivants interrogeaient sans le savoir celui d'avant. + import inspect + + source = inspect.getsource(smoke.start_server) + self.assertIn("start_new_session=True", source) + + def test_it_kills_the_group_not_just_the_wrapper(self): + import inspect + + source = inspect.getsource(smoke.stop_server) + self.assertIn("killpg", source) + + def test_a_taken_port_is_refused_not_tested(self): + # Un serveur resté d'un essai précédent répond « prêt » : on + # testerait sa base à lui en croyant tester la sienne. + original = smoke.port_is_taken + smoke.port_is_taken = lambda port, host="127.0.0.1": True + self.addCleanup(setattr, smoke, "port_is_taken", original) + with self.assertRaises(RuntimeError) as caught: + smoke.run("db", 8169, "./config.conf") + self.assertIn("8169", str(caught.exception)) + + +class TestOfferingTheFix(unittest.TestCase): + 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.applied = [] + original = smoke.apply_reset + smoke.apply_reset = lambda db, keys: ( + self.applied.append(keys), + (0, "ok"), + )[1] + self.addCleanup(setattr, smoke, "apply_reset", original) + + def run_prompt(self, answer, lst_key=None): + import contextlib + import io + + if lst_key is None: + lst_key = ["website_blog.blog_post_complete", "website_form.x"] + out = io.StringIO() + with contextlib.redirect_stdout(out): + done = smoke.prompt( + "db", + [("http://h/a", 500, ["2841"])], + lst_key, + ask=lambda prompt: answer, + ) + return done, out.getvalue() + + def test_every_key_is_numbered(self): + _done, text = self.run_prompt("") + self.assertIn("[1] website_blog.blog_post_complete", text) + self.assertIn("[a]", text) + + def test_a_number_applies_only_that_one(self): + done, _text = self.run_prompt("1") + self.assertEqual(done, ["website_blog.blog_post_complete"]) + self.assertEqual(self.applied, [["website_blog.blog_post_complete"]]) + + def test_a_applies_them_all(self): + done, _text = self.run_prompt("a") + self.assertEqual(len(done), 2) + + def test_empty_applies_nothing(self): + done, text = self.run_prompt("") + self.assertEqual(done, []) + self.assertEqual(self.applied, []) + self.assertIn("Kept", text) + + def test_an_unknown_answer_applies_nothing(self): + done, text = self.run_prompt("9") + self.assertEqual(self.applied, []) + self.assertIn("Unknown choice", text) + + def test_no_key_found_says_so(self): + done, text = self.run_prompt("a", lst_key=[]) + self.assertEqual(done, []) + self.assertIn("nothing to offer", text) + + class TestTheMigrationOffersIt(unittest.TestCase): def setUp(self): from script.todo import todo_i18n From c67324a2236556c801cb5336c4733c892d2a1277 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sun, 16 Aug 2026 07:11:43 -0400 Subject: [PATCH 21/67] [ADD] migration: Enter targets the highest supported Odoo version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrating means going all the way, and that choice had to be typed every time. Enter did nothing visible: click.prompt without a default re-asks on an empty line without printing anything, so a default added without `default=""` would never have been reached and nothing would have said so. The highest is computed, not read off the end of the list: display order is not a guarantee, and « 9.0 » sorts after « 18.0 » as a string. The prompt announces it — a default nobody sees is a default nobody uses. No choice at all means no default rather than a crash. --- FR --- [ADD] migration : Entrée vise la version Odoo la plus élevée Migrer, c'est aller au bout, et ce choix devait être tapé chaque fois. Entrée ne faisait rien de visible : click.prompt sans défaut redemande sur une ligne vide sans rien afficher, si bien qu'un défaut ajouté sans `default=""` n'aurait jamais été atteint, sans que rien ne le signale. La plus haute est calculée, pas lue au bout de la liste : l'ordre d'affichage n'est pas une garantie, et « 9.0 » se trie après « 18.0 » en chaînes. L'invite l'annonce — un défaut qu'on ne montre pas est un défaut que personne n'utilise. Aucun choix possible ne donne aucun défaut plutôt qu'une erreur. Assisted-by: Claude Opus 5 (cherry picked from commit f0861363f496cdc3c0bb02216d30dd0aa8d3697b) --- script/todo/todo_i18n.py | 4 + script/todo/todo_upgrade.py | 29 +++++++- test/test_target_version_default.py | 110 ++++++++++++++++++++++++++++ 3 files changed, 141 insertions(+), 2 deletions(-) create mode 100755 test/test_target_version_default.py diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 5e9537c..de705a9 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -5062,6 +5062,10 @@ TRANSLATIONS = { "fr": "il serait testé à la place de cette base.", "en": "it would be tested instead of this database.", }, + "Enter =": { + "fr": "Entrée =", + "en": "Enter =", + }, "Nothing to decide yet": { "fr": "Rien à décider pour l'instant", "en": "Nothing to decide yet", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index ffcb9ba..2f12db1 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -1248,11 +1248,36 @@ class TodoUpgrade: if "target_odoo_version" in self.dct_progression: odoo_target_version = self.dct_progression["target_odoo_version"] else: - print(f"💬 {t('Which version do you want to upgrade to?')}") + # La plus haute, pas la dernière de la liste : l'ordre + # d'affichage n'est pas une garantie, la comparaison en est une. + default_version = ( + max( + lst_odoo_version, + key=lambda a: float(a.get("prompt_description")), + ).get("prompt_description") + if lst_odoo_version + else None + ) + print( + f"💬 {t('Which version do you want to upgrade to?')}" + + ( + f" ({t('Enter =')} {default_version})" + if default_version + else "" + ) + ) odoo_target_version = None cmd_no_found = True while cmd_no_found: - status = click.prompt(help_info) + # `default=""` est nécessaire : sans lui, click redemande sur + # une ligne vide sans rien dire, et Entrée n'atteint jamais + # ce code — la valeur par défaut n'existait qu'en intention. + status = click.prompt( + help_info, default="", show_default=False + ) + if not str(status).strip() and default_version: + odoo_target_version = default_version + break try: int_cmd = int(status) if 0 < int_cmd <= len(lst_odoo_version): diff --git a/test/test_target_version_default.py b/test/test_target_version_default.py new file mode 100755 index 0000000..89a09f6 --- /dev/null +++ b/test/test_target_version_default.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""« Vers quelle version migrer ? » : Entrée doit viser la plus haute. + +C'est le choix qu'on fait presque toujours — migrer, c'est aller au bout — +et il fallait le taper. Pire : `click.prompt` sans `default` REDEMANDE sur +une ligne vide, sans rien afficher. Entrée n'atteignait donc aucun code ; +une valeur par défaut ajoutée sans ce `default=""` n'aurait jamais servi et +rien ne l'aurait signalé. + +La plus haute se calcule, elle ne se lit pas au bout de la liste : l'ordre +d'affichage n'est pas une garantie, la comparaison en est une. +""" + +import ast +import inspect +import os +import unittest + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +from script.todo.todo_upgrade import TodoUpgrade # noqa: E402 + + +class TestTheSource(unittest.TestCase): + """Le choix se joue dans une boucle interactive de mille lignes. + + On vérifie donc les décisions à la source : que le défaut soit calculé + par comparaison, et que click puisse rendre une ligne vide. + """ + + def source(self): + return inspect.getsource(TodoUpgrade.execute_odoo_upgrade) + + def test_click_can_return_an_empty_line(self): + # SANS cela, click redemande en silence et Entrée n'arrive jamais + # jusqu'au code qui applique le défaut. + source = self.source() + appel = source.index("click.prompt(") + fenetre = source[appel : appel + 160] + self.assertIn('default=""', fenetre) + + def test_the_default_is_the_highest_not_the_last(self): + source = self.source() + self.assertIn("max(", source) + self.assertIn('float(a.get("prompt_description"))', source) + + def test_the_default_is_announced(self): + # Un défaut qu'on ne montre pas est un défaut que personne n'utilise. + self.assertIn("Enter =", self.source()) + + +class TestTheChoiceItself(unittest.TestCase): + """Le calcul du défaut, extrait et rejoué sur des listes réelles.""" + + def highest(self, lst_version): + lst_odoo_version = [{"prompt_description": v} for v in lst_version] + return ( + max( + lst_odoo_version, + key=lambda a: float(a.get("prompt_description")), + ).get("prompt_description") + if lst_odoo_version + else None + ) + + def test_the_case_reported(self): + # Base en 12.0 : la liste proposée va de 13.0 à 18.0. + self.assertEqual( + self.highest(["13.0", "14.0", "15.0", "16.0", "17.0", "18.0"]), + "18.0", + ) + + def test_an_unsorted_list_still_gives_the_highest(self): + # Prendre le dernier élément marcherait par chance ; comparer marche. + self.assertEqual(self.highest(["18.0", "13.0", "16.0"]), "18.0") + + def test_a_single_choice(self): + self.assertEqual(self.highest(["18.0"]), "18.0") + + def test_no_choice_at_all_has_no_default(self): + # Une base déjà à la version maximale : proposer un défaut + # inexistant ferait planter là où il n'y a simplement rien à faire. + self.assertIsNone(self.highest([])) + + def test_two_digit_versions_compare_as_numbers(self): + # « 9.0 » > « 18.0 » en tri de chaînes : la comparaison numérique + # est ce qui évite de proposer une version antérieure. + self.assertEqual(self.highest(["9.0", "18.0"]), "18.0") + + +class TestTheGuardIsReachable(unittest.TestCase): + def test_the_empty_answer_is_handled_before_the_int(self): + # int("") lève ValueError et retomberait sur « Commande non + # trouvée » : le cas vide doit être traité AVANT. + source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade) + vide = source.index("if not str(status).strip()") + conversion = source.index("int_cmd = int(status)") + self.assertLess(vide, conversion) + + def test_the_module_still_parses(self): + path = os.path.join(REPO, "script", "todo", "todo_upgrade.py") + with open(path) as handle: + ast.parse(handle.read()) + + +if __name__ == "__main__": + unittest.main() From dc4940089bad0375b6d0bfcd48939411442ac225 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sun, 16 Aug 2026 07:33:37 -0400 Subject: [PATCH 22/67] [UPD] migration: let click carry the default, computed from the menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit click now receives the rank of the highest version, so it prints « [6] » and returns « 6 » on Enter. The answer follows the normal path — the special case for an empty line is gone, and with it a second behaviour to keep in agreement with the first. The rank is computed, never written down: 6 is where 18.0 sits today, and one more version in the catalogue moves it. A test replays a catalogue with 19.0 and expects [7]. --- FR --- [UPD] migration : laisser click porter le défaut, calculé sur le menu click reçoit désormais le rang de la version la plus haute : il affiche « [6] » et rend « 6 » sur Entrée. La réponse suit le chemin normal — le cas particulier de la ligne vide disparaît, et avec lui un second comportement à tenir d'accord avec le premier. Le rang est calculé, jamais écrit : 6 est la place de 18.0 aujourd'hui, et une version de plus au catalogue la déplace. Un test rejoue un catalogue avec 19.0 et attend [7]. Assisted-by: Claude Opus 5 (cherry picked from commit 5f0b0110d610187298e0129e45cef28e14bba092) --- script/todo/todo_upgrade.py | 47 +++++--- test/test_target_version_default.py | 181 +++++++++++++++++++--------- 2 files changed, 157 insertions(+), 71 deletions(-) diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index 2f12db1..232dbf9 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -1248,16 +1248,30 @@ class TodoUpgrade: if "target_odoo_version" in self.dct_progression: odoo_target_version = self.dct_progression["target_odoo_version"] else: - # La plus haute, pas la dernière de la liste : l'ordre - # d'affichage n'est pas une garantie, la comparaison en est une. - default_version = ( - max( - lst_odoo_version, - key=lambda a: float(a.get("prompt_description")), - ).get("prompt_description") - if lst_odoo_version - else None - ) + # Le défaut est le NUMÉRO de la version la plus haute, calculé + # sur la liste réellement affichée : « 6 » n'est pas une + # constante, c'est le rang de 18.0 aujourd'hui. Une version de + # plus dans le catalogue le déplace, et l'écrire en dur ferait + # choisir 18.0 quand l'écran propose 19.0. + # + # La plus haute se COMPARE, elle ne se lit pas au bout de la + # liste : l'ordre d'affichage n'est pas une garantie, et « 9.0 » + # se trie après « 18.0 » en chaînes. + default_index = None + default_version = None + if lst_odoo_version: + default_index = ( + max( + range(len(lst_odoo_version)), + key=lambda i: float( + lst_odoo_version[i].get("prompt_description") + ), + ) + + 1 + ) + default_version = lst_odoo_version[default_index - 1].get( + "prompt_description" + ) print( f"💬 {t('Which version do you want to upgrade to?')}" + ( @@ -1269,15 +1283,14 @@ class TodoUpgrade: odoo_target_version = None cmd_no_found = True while cmd_no_found: - # `default=""` est nécessaire : sans lui, click redemande sur - # une ligne vide sans rien dire, et Entrée n'atteint jamais - # ce code — la valeur par défaut n'existait qu'en intention. + # click affiche « [6] » et rend « 6 » sur Entrée : le chemin + # normal traite la réponse, sans cas particulier à tenir + # d'accord avec lui. status = click.prompt( - help_info, default="", show_default=False + help_info, + default=str(default_index) if default_index else "", + show_default=bool(default_index), ) - if not str(status).strip() and default_version: - odoo_target_version = default_version - break try: int_cmd = int(status) if 0 < int_cmd <= len(lst_odoo_version): diff --git a/test/test_target_version_default.py b/test/test_target_version_default.py index 89a09f6..5be81d1 100755 --- a/test/test_target_version_default.py +++ b/test/test_target_version_default.py @@ -4,14 +4,18 @@ """« Vers quelle version migrer ? » : Entrée doit viser la plus haute. -C'est le choix qu'on fait presque toujours — migrer, c'est aller au bout — -et il fallait le taper. Pire : `click.prompt` sans `default` REDEMANDE sur -une ligne vide, sans rien afficher. Entrée n'atteignait donc aucun code ; -une valeur par défaut ajoutée sans ce `default=""` n'aurait jamais servi et -rien ne l'aurait signalé. +C'est le choix qu'on fait presque toujours — migrer, c'est aller au bout — et +il fallait le taper. `click.prompt` reçoit donc un défaut : il affiche +« [6] » et rend « 6 » sur Entrée, si bien que la réponse suit le chemin +normal, sans second comportement à tenir d'accord avec le premier. -La plus haute se calcule, elle ne se lit pas au bout de la liste : l'ordre -d'affichage n'est pas une garantie, la comparaison en est une. +Deux pièges, tous deux vérifiés ici : + +- SANS `default`, click redemande en silence sur une ligne vide. Une valeur + par défaut ajoutée sans lui n'aurait jamais été atteinte, et rien ne + l'aurait signalé. +- « 6 » est le rang de 18.0 AUJOURD'HUI. Une version de plus au catalogue le + déplace ; écrit en dur, il ferait choisir 18.0 quand l'écran propose 19.0. """ import ast @@ -24,83 +28,152 @@ REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) from script.todo.todo_upgrade import TodoUpgrade # noqa: E402 +def prompt_call(source): + """Le voisinage de l'appel à click.prompt, où se joue le défaut.""" + start = source.index("click.prompt(") + return source[start : start + 240] + + class TestTheSource(unittest.TestCase): """Le choix se joue dans une boucle interactive de mille lignes. - On vérifie donc les décisions à la source : que le défaut soit calculé - par comparaison, et que click puisse rendre une ligne vide. + On vérifie donc les décisions là où elles s'écrivent : que click reçoive + un défaut, qu'il soit calculé, et que la question dise la version. """ def source(self): return inspect.getsource(TodoUpgrade.execute_odoo_upgrade) - def test_click_can_return_an_empty_line(self): - # SANS cela, click redemande en silence et Entrée n'arrive jamais - # jusqu'au code qui applique le défaut. - source = self.source() - appel = source.index("click.prompt(") - fenetre = source[appel : appel + 160] - self.assertIn('default=""', fenetre) + def test_click_receives_a_default(self): + # Sans lui, Entrée n'atteint aucun code : click redemande, muet. + window = prompt_call(self.source()) + self.assertIn("default=", window) + self.assertIn("default_index", window) - def test_the_default_is_the_highest_not_the_last(self): + def test_the_default_is_not_written_by_hand(self): + window = prompt_call(self.source()) + self.assertNotIn('default="6"', window) + self.assertNotIn("default=6", window) + + def test_the_rank_is_computed_by_comparison(self): + # Lire le dernier élément marcherait par chance : l'ordre + # d'affichage n'est pas une garantie. source = self.source() self.assertIn("max(", source) - self.assertIn('float(a.get("prompt_description"))', source) + self.assertIn("range(len(lst_odoo_version))", source) - def test_the_default_is_announced(self): - # Un défaut qu'on ne montre pas est un défaut que personne n'utilise. - self.assertIn("Enter =", self.source()) + def test_an_empty_catalogue_shows_no_default(self): + # show_default sur une chaîne vide afficherait « [] », qui se lit + # comme un choix possible. + self.assertIn( + "show_default=bool(default_index)", prompt_call(self.source()) + ) + + def test_the_question_announces_the_version_not_the_rank(self): + # click montre « [6] » ; seul le texte dit que c'est 18.0. + source = self.source() + self.assertIn("Enter =", source) + self.assertIn("default_version", source) + + def test_no_special_case_survives(self): + # click rendant « 6 », le chemin normal suffit. Un cas particulier + # de plus serait un second comportement à maintenir. + self.assertNotIn("if not str(status).strip()", self.source()) -class TestTheChoiceItself(unittest.TestCase): - """Le calcul du défaut, extrait et rejoué sur des listes réelles.""" +class TestTheRankItself(unittest.TestCase): + """Le calcul du défaut, rejoué sur des catalogues réels.""" - def highest(self, lst_version): + def rank(self, lst_version): + """Le rang affiché de la plus haute version, comme le code le fait.""" lst_odoo_version = [{"prompt_description": v} for v in lst_version] + if not lst_odoo_version: + return None return ( max( - lst_odoo_version, - key=lambda a: float(a.get("prompt_description")), - ).get("prompt_description") - if lst_odoo_version - else None + range(len(lst_odoo_version)), + key=lambda i: float( + lst_odoo_version[i].get("prompt_description") + ), + ) + + 1 ) def test_the_case_reported(self): - # Base en 12.0 : la liste proposée va de 13.0 à 18.0. + # Base en 12.0 : la liste proposée va de 13.0 à 18.0, donc [6]. self.assertEqual( - self.highest(["13.0", "14.0", "15.0", "16.0", "17.0", "18.0"]), - "18.0", + self.rank(["13.0", "14.0", "15.0", "16.0", "17.0", "18.0"]), 6 ) - def test_an_unsorted_list_still_gives_the_highest(self): - # Prendre le dernier élément marcherait par chance ; comparer marche. - self.assertEqual(self.highest(["18.0", "13.0", "16.0"]), "18.0") + def test_it_follows_a_new_version(self): + # LE point : le jour où 19.0 entre au catalogue, le défaut devient + # [7] sans que personne ne touche au code. + self.assertEqual( + self.rank( + ["13.0", "14.0", "15.0", "16.0", "17.0", "18.0", "19.0"] + ), + 7, + ) + + def test_an_unsorted_list_points_at_the_highest(self): + self.assertEqual(self.rank(["18.0", "13.0", "16.0"]), 1) + + def test_versions_compare_as_numbers(self): + # « 9.0 » se trie après « 18.0 » en chaînes : la comparaison + # numérique est ce qui évite de proposer une version antérieure. + self.assertEqual(self.rank(["9.0", "18.0"]), 2) def test_a_single_choice(self): - self.assertEqual(self.highest(["18.0"]), "18.0") + self.assertEqual(self.rank(["18.0"]), 1) - def test_no_choice_at_all_has_no_default(self): - # Une base déjà à la version maximale : proposer un défaut - # inexistant ferait planter là où il n'y a simplement rien à faire. - self.assertIsNone(self.highest([])) - - def test_two_digit_versions_compare_as_numbers(self): - # « 9.0 » > « 18.0 » en tri de chaînes : la comparaison numérique - # est ce qui évite de proposer une version antérieure. - self.assertEqual(self.highest(["9.0", "18.0"]), "18.0") + def test_no_choice_at_all_has_no_rank(self): + # Une base déjà à la version maximale : pas de défaut plutôt qu'une + # erreur là où il n'y a rien à faire. + self.assertIsNone(self.rank([])) -class TestTheGuardIsReachable(unittest.TestCase): - def test_the_empty_answer_is_handled_before_the_int(self): - # int("") lève ValueError et retomberait sur « Commande non - # trouvée » : le cas vide doit être traité AVANT. - source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade) - vide = source.index("if not str(status).strip()") - conversion = source.index("int_cmd = int(status)") - self.assertLess(vide, conversion) +class TestClickReallyReturnsIt(unittest.TestCase): + """Ce que click fait vraiment, mesuré et non supposé.""" - def test_the_module_still_parses(self): + def answer(self, typed, default): + import io + import sys + + import click + + original = sys.stdin + sys.stdin = io.StringIO(typed) + try: + return click.prompt( + "", default=default, show_default=True, prompt_suffix="" + ) + finally: + sys.stdin = original + + def test_enter_returns_the_default(self): + self.assertEqual(self.answer("\n", "6"), "6") + + def test_a_typed_value_wins(self): + self.assertEqual(self.answer("2\n", "6"), "2") + + def test_without_a_default_enter_is_swallowed(self): + # La raison d'être du `default` : ici click redemande, et c'est la + # SECONDE ligne qui revient. Entrée n'aurait rien déclenché. + import io + import sys + + import click + + original = sys.stdin + sys.stdin = io.StringIO("\n2\n") + try: + self.assertEqual(click.prompt("", prompt_suffix=""), "2") + finally: + sys.stdin = original + + +class TestTheModuleStillParses(unittest.TestCase): + def test_it_parses(self): path = os.path.join(REPO, "script", "todo", "todo_upgrade.py") with open(path) as handle: ast.parse(handle.read()) From 109763ab34e797e90f2a5a3a0b51a5df6a4b48b1 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 17 Aug 2026 01:55:11 -0400 Subject: [PATCH 23/67] [FIX] test: check the exec bit git stores, not the mode umask decides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The permission test also demanded no group-write, measured on disk. Git stores only the exec bit — 100644 or 100755 — and the rest comes from the umask of whoever checks out. On a umask 0002 machine every checkout yields 775, so the test failed thirteen times for a reason that is not in the repository. It now reads the index, the only mode the repo carries and hands to others. A file made executable locally without being committed works here and nowhere else — that is what this catches. --- FR --- [FIX] test : vérifier le bit que git stocke, pas le mode que l'umask fixe Le test de permissions exigeait aussi l'absence d'écriture par le groupe, mesurée sur le disque. Git ne stocke que le bit d'exécution — 100644 ou 100755 — et le reste vient de l'umask de celui qui fait le checkout. Sur un poste en umask 0002, chaque checkout produit 775 : le test échouait treize fois, pour une raison absente du dépôt. Il lit désormais l'index, seul mode que le dépôt porte et transmet. Un fichier rendu exécutable localement sans être commité marche ici et nulle part ailleurs — c'est ce que ce test attrape. Assisted-by: Claude Opus 5 --- test/test_script_permissions.py | 84 ++++++++++++++++++++++++--------- 1 file changed, 61 insertions(+), 23 deletions(-) diff --git a/test/test_script_permissions.py b/test/test_script_permissions.py index cadf43b..a05b7c4 100755 --- a/test/test_script_permissions.py +++ b/test/test_script_permissions.py @@ -2,21 +2,24 @@ # © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) -"""Un script avec shebang doit être exécutable — et pas inscriptible en groupe. +"""Un script avec shebang doit être exécutable — dans ce que git STOCKE. Le bit d'exécution n'accorde rien : qui peut lire le fichier peut déjà faire -« python3 fichier ». Ce qui compte est la PAIRE : un fichier à la fois -exécuté par d'autres et modifiable par le groupe laisse un membre du groupe -changer ce que les autres lancent. L'umask 0002 du poste crée exactement -cette paire dès qu'on ajoute +x sans y penser (664 -> 775). +« python3 fichier ». Il décide seulement si « ./script/... » fonctionne. -Git ne stocke que le bit d'exécution, jamais 664 contre 644 : le mode complet -ne se vérifie donc que sur le disque, ici. +La première version de ce test exigeait aussi l'absence d'écriture par le +groupe, mesurée sur le disque. C'était une faute : git ne stocke QUE le bit +d'exécution — 100644 ou 100755 — et le reste vient de l'umask de celui qui +fait le checkout. Sur un poste en umask 0002, chaque checkout produit 775 et +le test échouait treize fois, pour une raison qui n'est pas dans le dépôt. + +On vérifie donc l'index, seul mode que le dépôt porte et propage. Le mode du +disque reste l'affaire de la machine, pas d'un test. """ import glob import os -import stat +import subprocess import unittest REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -38,32 +41,67 @@ def scripts(): return found +def index_mode(path): + """Le mode que GIT porte pour ce fichier : 100644 ou 100755.""" + done = subprocess.run( + ["git", "ls-files", "-s", "--", os.path.relpath(path, REPO)], + capture_output=True, + text=True, + cwd=REPO, + ) + if done.returncode or not done.stdout.strip(): + return None + return done.stdout.split()[0] + + class TestExecutableBit(unittest.TestCase): def test_the_inventory_is_not_empty(self): # Un test qui ne trouve rien passe toujours. self.assertGreater(len(scripts()), 10) - def test_every_shebang_script_is_executable(self): + def test_every_shebang_script_is_executable_in_git(self): + # Dans l'index, pas sur le disque : c'est ce que reçoivent les + # autres. Un fichier rendu exécutable localement sans être commité + # marcherait ici et nulle part ailleurs. for path, has_shebang in scripts(): if not has_shebang: continue - with self.subTest(script=os.path.relpath(path, REPO)): - self.assertTrue(os.access(path, os.X_OK)) + relative = os.path.relpath(path, REPO) + mode = index_mode(path) + if mode is None: + continue # non suivi : rien à garantir pour personne + with self.subTest(script=relative): + self.assertEqual(mode, "100755", relative) - def test_none_is_writable_by_group_or_others(self): - # LE point qui compte : exécuté par d'autres ET modifiable par eux. - for path, _ in scripts(): - mode = stat.S_IMODE(os.stat(path).st_mode) + def test_a_file_without_shebang_is_not_marked_executable(self): + # Le symétrique : un bit d'exécution sur un fichier qu'aucun + # interpréteur ne réclame dit une intention qui n'existe pas. + for path, has_shebang in scripts(): + if has_shebang: + continue + mode = index_mode(path) + if mode is None: + continue with self.subTest(script=os.path.relpath(path, REPO)): - self.assertFalse( - mode & (stat.S_IWGRP | stat.S_IWOTH), oct(mode) - ) + self.assertEqual(mode, "100644") - def test_none_is_setuid_or_setgid(self): - for path, _ in scripts(): - mode = os.stat(path).st_mode - with self.subTest(script=os.path.relpath(path, REPO)): - self.assertFalse(mode & (stat.S_ISUID | stat.S_ISGID)) + def test_the_check_reads_git_not_the_filesystem(self): + # Le défaut corrigé : l'umask du poste décide de 755 contre 775, et + # un test qui le lit échoue sur la machine des autres. On le vérifie + # par les IMPORTS — chercher un nom de constante dans la source d'un + # fichier qui contient ce test échouerait sur lui-même. + import ast + + with open(__file__) as handle: + tree = ast.parse(handle.read()) + imported = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + imported.update(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: + imported.add(node.module) + self.assertIn("subprocess", imported) + self.assertNotIn("stat", imported) class TestTheTuiSaysWhyItRefuses(unittest.TestCase): From 1c0635831fcddd81c5711bee40fe3f7ce681dc3c Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 17 Aug 2026 02:41:40 -0400 Subject: [PATCH 24/67] [FIX] migration: never ask a question the terminal cannot show MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- script/addons/theme_leftover.py | 17 +++++- script/odoo/migration/check_stale_scss.py | 17 ++++-- script/odoo/migration/smoke_public_url.py | 14 ++++- script/todo/todo_upgrade.py | 11 +++- test/test_uninstall_addons_theme.py | 74 +++++++++++++++++++++++ 5 files changed, 124 insertions(+), 9 deletions(-) diff --git a/script/addons/theme_leftover.py b/script/addons/theme_leftover.py index 80e70c2..9e2951b 100755 --- a/script/addons/theme_leftover.py +++ b/script/addons/theme_leftover.py @@ -37,6 +37,18 @@ except Exception: # pragma: no cover - repli si i18n indisponible return key +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() + + def run_psql(database, sql): """Interroger la base en lecture seule, garantie côté serveur. @@ -256,7 +268,10 @@ def main(argv=None): return 2 print(f"✅ -> {len(attachments)} {t('attachment(s) deleted.')}") return 0 - if not config.report_only and sys.stdin.isatty(): + # 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(): if prompt( config.database, config.theme, diff --git a/script/odoo/migration/check_stale_scss.py b/script/odoo/migration/check_stale_scss.py index 43f8496..82b16fd 100755 --- a/script/odoo/migration/check_stale_scss.py +++ b/script/odoo/migration/check_stale_scss.py @@ -64,6 +64,18 @@ except Exception: # pragma: no cover - repli si i18n indisponible return key +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() + + # Une variable SCSS commence par $ ; celles dont le nom commence par un tiret # sont locales au fichier par convention Odoo ($-seen, $-font-numbers) et se # définissent toujours au-dessus de leur usage. @@ -625,10 +637,7 @@ def main(argv=None): # laisser retrouver soi-même les deux arguments de reset_asset. Mais # seulement devant un terminal — dans un tube, une invite bloquerait # l'appelant sans que personne ne voie la question. - if ( - not (config.diff or config.tui or config.report_only) - and sys.stdin.isatty() - ): + if not (config.diff or config.tui or config.report_only) and can_ask(): if prompt(lst_finding, config.database, config.config): return 0 return 1 diff --git a/script/odoo/migration/smoke_public_url.py b/script/odoo/migration/smoke_public_url.py index 624142e..527a420 100755 --- a/script/odoo/migration/smoke_public_url.py +++ b/script/odoo/migration/smoke_public_url.py @@ -54,6 +54,18 @@ except Exception: # pragma: no cover - repli si i18n indisponible return key +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() + + RE_LOC = re.compile(r"\s*([^<\s]+)\s*", re.I) # « [view_id: 3282, xml_id: n/a, model: n/a, parent_id: 3281] ». Seule la @@ -515,7 +527,7 @@ def main(argv=None): f"⧖ {t('Starting Odoo on')} '{config.database}'" f" ({t('port')} {config.port})…" ) - interactive = not config.report_only and sys.stdin.isatty() + interactive = not config.report_only and can_ask() try: lst_url, lst_failure, lst_key, lst_again = run( config.database, diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index 232dbf9..44c44d9 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -2833,10 +2833,15 @@ class TodoUpgrade: print(f"ℹ -> {t('Kept. Nothing was uninstalled.')}") return for theme in lst_theme: - self.todo_upgrade_execute( + # Sur le VRAI terminal : ce script finit par theme_leftover.py, + # qui pose une question. L'exécuteur capture la sortie par un + # tube — Python la met alors en tampon par blocs — et l'invite + # restait invisible pendant que le processus attendait une + # réponse. On tape Entrée à l'aveugle, plusieurs fois, et les + # frappes en trop vont à la question suivante. + self.run_on_terminal( f"./script/addons/uninstall_addons_theme.sh" - f" {database_name} {theme}", - wait_at_error=False, + f" {database_name} {theme}" ) def stale_cow_keys(self, database_name): diff --git a/test/test_uninstall_addons_theme.py b/test/test_uninstall_addons_theme.py index f8fd8c8..38b3298 100755 --- a/test/test_uninstall_addons_theme.py +++ b/test/test_uninstall_addons_theme.py @@ -154,6 +154,9 @@ class TestTheMigrationOffersIt(unittest.TestCase): self.lst_cmd.append(cmd), (False, cmd, []), )[1] + # Le désinstalleur ne passe PLUS par l'exécuteur qui capture : il + # pose une question, et un tube la rendrait invisible. + obj.run_on_terminal = lambda cmd: self.lst_cmd.append(cmd) or 0 obj.ask_gate = lambda prompt: answer return obj @@ -302,6 +305,77 @@ class TestKeepOrDeleteTheLeftovers(unittest.TestCase): self.assertIn("sys.stdin.isatty()", handle.read()) +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) + self.assertIn("run_on_terminal", source) + 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) + + class TestTheMisleadingErrorCode(unittest.TestCase): """« 1 » veut dire « il reste des choses », pas « ça a raté ».""" From e75ab8b1287fe924bfa80b8568f1b1ddfc525463 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 17 Aug 2026 03:17:18 -0400 Subject: [PATCH 25/67] [FIX] addons: browse the leftovers by integer, not by the string psql gave MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- script/addons/theme_leftover.py | 6 ++++- test/test_uninstall_addons_theme.py | 38 +++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/script/addons/theme_leftover.py b/script/addons/theme_leftover.py index 9e2951b..ec8cd80 100755 --- a/script/addons/theme_leftover.py +++ b/script/addons/theme_leftover.py @@ -181,7 +181,11 @@ def delete_attachments(database, lst_row, config_path="./config.conf"): Un DELETE en SQL laisserait les fichiers orphelins et les caches incohérents ; unlink() fait le ménage complet, dans toutes les versions. """ - lst_id = [row.split("|")[0] for row in lst_row] + # 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] script = ( f"env['ir.attachment'].browse({lst_id!r}).unlink()\n" "env.cr.commit()\n" diff --git a/test/test_uninstall_addons_theme.py b/test/test_uninstall_addons_theme.py index 38b3298..1b17920 100755 --- a/test/test_uninstall_addons_theme.py +++ b/test/test_uninstall_addons_theme.py @@ -376,6 +376,44 @@ class TestTheQuestionMustBeVisible(unittest.TestCase): self.assertIn("sys.stdout.isatty()", source) +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 + theme_leftover.subprocess.run = ( + lambda *a, **kw: pushed.update(script=kw.get("input", "")) + 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"]) + + class TestTheMisleadingErrorCode(unittest.TestCase): """« 1 » veut dire « il reste des choses », pas « ça a raté ».""" From ec29781b7a64d085f7f5bbe6bd4c9b789d57b4c5 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 17 Aug 2026 04:49:32 -0400 Subject: [PATCH 26/67] [FIX] migration: reset the copy that is actually stale, and say when a key misses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answering « all » reset one copy of two and reported success. Two defects behind it, both mine. A requested key matching no finding did nothing, silently: the tool returned early on « nothing drifted » and never looked. Detection is differential — it only sees a copy whose CHILD breaks — so a stale copy without children escapes it. A key can now be reset outside detection, an unknown one is named, and the exit code is 2. And the culprit is not always the parent. On /contactus the parent was identical to its module view; the CHILD held the stale arch. Both are proposed now, parent first — it is the more common case. Measured on the migration: 33 of 33 public URLs answer. --- FR --- [FIX] migration : réinitialiser la copie vraiment périmée, et signaler une clé sans objet Répondre « toutes » réinitialisait une copie sur deux et annonçait un succès. Deux défauts derrière, tous deux à moi. Une clé demandée ne correspondant à aucun constat ne faisait rien, en silence : l'outil sortait sur « rien n'a dérivé » sans jamais chercher. La détection est différentielle — elle ne voit qu'une copie dont un ENFANT casse — donc une copie périmée sans enfant lui échappe. Une clé peut désormais être réinitialisée hors détection, une clé inconnue est nommée, et le code de sortie vaut 2. Et le coupable n'est pas toujours le parent. Sur /contactus, le parent était identique à sa vue module ; c'est l'ENFANT qui portait l'arch périmée. Les deux sont proposés, le parent d'abord — le cas le plus fréquent. Mesuré sur la migration : 33 URL publiques sur 33 répondent. Assisted-by: Claude Opus 5 --- .../odoo/migration/reset_stale_cow_views.py | 86 ++++++++++++++++--- script/odoo/migration/smoke_public_url.py | 11 ++- script/todo/todo_i18n.py | 16 ++++ test/test_smoke_public_url.py | 23 ++++- 4 files changed, 120 insertions(+), 16 deletions(-) diff --git a/script/odoo/migration/reset_stale_cow_views.py b/script/odoo/migration/reset_stale_cow_views.py index 01021ed..f4cc956 100755 --- a/script/odoo/migration/reset_stale_cow_views.py +++ b/script/odoo/migration/reset_stale_cow_views.py @@ -227,6 +227,27 @@ def analyse(database): return findings +def find_copy_by_key(views, key): + """(copie COW, jumelle module) pour cette clé, ou (None, None). + + La détection différentielle ne voit qu'une copie dont un ENFANT casse. + Une copie périmée sans enfant lui échappe — mesuré : la copie de + `website_crm.contactus_form` était plus petite d'un tiers que sa + jumelle, et c'est elle qui rendait /contactus en 500. Demander sa + réinitialisation par clé doit donc marcher même hors détection. + """ + copy = twin = None + for row in views.values(): + if row.get("key") != key: + continue + if row.get("website_id"): + if copy is None or row["id"] < copy["id"]: + copy = row + elif twin is None or row["id"] < twin["id"]: + twin = row + return copy, twin + + def show_diff(module_view, cow_view): """Module arch vs copy: what the copy would gain and lose on a reset.""" diff = difflib.unified_diff( @@ -317,18 +338,26 @@ def main(): print(cow_view["key"]) return 1 if findings else 0 - if not findings: + # « Rien détecté » ne veut pas dire « rien à faire » quand une clé est + # demandée : la détection différentielle ne voit qu'une copie dont un + # ENFANT casse, et une copie périmée sans enfant lui échappe. Sortir ici + # faisait taire la demande — on croyait la copie réinitialisée. + if not findings and not (set(config.reset) - {"all"}): print(f"✅ {t('No COW copy has drifted from its module view.')}") return 0 + if not findings: + print(f"ℹ {t('No COW copy has drifted from its module view.')}") - print( - f"⚠️ {len(findings)} {t('COW copy(ies) drifted from their module')}" - f" {t('view in')} {config.database}" - ) - print( - f" {t('Odoo surfaces this when the module view is rewritten (a')}" - f" {t('version bump) or when the page is rendered.')}\n" - ) + if findings: + print( + f"⚠️ {len(findings)}" + f" {t('COW copy(ies) drifted from their module')}" + f" {t('view in')} {config.database}" + ) + print( + f" {t('Odoo surfaces this when the module view is rewritten')}" + f" {t('(a version bump) or when the page is rendered.')}\n" + ) for cow_view, module_view, broken in findings: twin = ( f"module id={module_view['id']}" @@ -357,9 +386,15 @@ def main(): "private", "odoo", "migration", config.database, "cow_reset" ) done = 0 + honoured = set() + missed = [] + # Les vues brutes servent aux clés que la détection ne voit pas ; on ne + # les lit que si l'on va s'en servir. + views = fetch_views(config.database) if wanted - {"all"} else {} for cow_view, module_view, _broken in findings: if "all" not in wanted and cow_view["key"] not in wanted: continue + honoured.add(cow_view["key"]) if not module_view: print( f"⏭ {cow_view['key']} :" @@ -377,13 +412,44 @@ def main(): done += 1 print(f"✅ {t('reset')} id={cow_view['id']} ({cow_view['key']})") print(f" {t('previous arch saved to')} {path}") + # Une clé demandée qui ne correspond à rien N'EST PAS un succès. Elle + # l'était : la commande tournait, ne faisait rien, et se taisait. On a + # donc cru une copie réinitialisée alors que /contactus rendait encore + # 500 — mesuré sur une vraie migration. + for key in sorted(wanted - honoured - {"all"}): + copy, twin = find_copy_by_key(views, key) + if copy is None: + print(f"⚠️ {t('No COW copy carries this key')} : {key}") + missed.append(key) + continue + if twin is None: + print(f"⚠️ {key} : {t('no module view to reset onto, skipped.')}") + missed.append(key) + continue + if copy["arch"] == twin["arch"]: + print(f"ℹ {key} : {t('already identical to the module view.')}") + continue + if not config.apply: + print( + f"[{t('dry-run')}] {t('would reset')} id={copy['id']}" + f" ({key}) {t('onto')} id={twin['id']}" + ) + continue + path = backup(config.database, copy, directory) + reset(config.database, copy, twin) + done += 1 + print(f"✅ {t('reset')} id={copy['id']} ({key})") + print(f" {t('previous arch saved to')} {path}") + if config.apply and done: print( f"\n{done} {t('copy(ies) reset. Re-apply any real customisation')}" f" {t('as an INHERITING view, not a copy, so it cannot go stale')}" f" {t('again.')}" ) - return 0 + # Une demande non honorée doit se voir jusque dans le code de sortie : + # l'appelant qui enchaîne ne lit pas le texte. + return 2 if missed else 0 if __name__ == "__main__": diff --git a/script/odoo/migration/smoke_public_url.py b/script/odoo/migration/smoke_public_url.py index 527a420..1eb914d 100755 --- a/script/odoo/migration/smoke_public_url.py +++ b/script/odoo/migration/smoke_public_url.py @@ -215,9 +215,14 @@ def attach_missing_parents(lst_failure, lst_log): known = {pid for _u, _s, lst in lst_failure for pid in lst} extra = [] for line in lst_log: - for _view_id, parent_id in RE_CONTEXT.findall(line): - if parent_id not in known and parent_id not in extra: - extra.append(parent_id) + # LES DEUX : le parent ET l'enfant. Mesuré sur /contactus — le + # parent était identique à sa vue module, et c'est l'ENFANT qui + # portait l'arch périmée. Ne proposer que le parent envoyait + # réinitialiser une copie qui allait déjà bien. + for view_id, parent_id in RE_CONTEXT.findall(line): + for candidate in (parent_id, view_id): + if candidate not in known and candidate not in extra: + extra.append(candidate) if not extra: return lst_failure rebuilt = [] diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index de705a9..af9668c 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -5066,6 +5066,22 @@ TRANSLATIONS = { "fr": "Entrée =", "en": "Enter =", }, + "No COW copy carries this key": { + "fr": "Aucune copie COW ne porte cette clé", + "en": "No COW copy carries this key", + }, + "already identical to the module view.": { + "fr": "déjà identique à la vue module.", + "en": "already identical to the module view.", + }, + "Odoo surfaces this when the module view is rewritten": { + "fr": "Odoo le fait apparaître quand la vue module est réécrite", + "en": "Odoo surfaces this when the module view is rewritten", + }, + "(a version bump) or when the page is rendered.": { + "fr": "(un palier de version) ou quand la page est rendue.", + "en": "(a version bump) or when the page is rendered.", + }, "Nothing to decide yet": { "fr": "Rien à décider pour l'instant", "en": "Nothing to decide yet", diff --git a/test/test_smoke_public_url.py b/test/test_smoke_public_url.py index 3ebad84..7e51d15 100755 --- a/test/test_smoke_public_url.py +++ b/test/test_smoke_public_url.py @@ -214,13 +214,30 @@ class TestTheCulpritViewsAreNamed(unittest.TestCase): lst_failure = [("http://h/a", 500, [])] log = ["[view_id: 3288, model: n/a, parent_id: 2841]"] rebuilt = smoke.attach_missing_parents(lst_failure, log) - self.assertEqual(rebuilt[0][2], ["2841"]) + self.assertIn("2841", rebuilt[0][2]) - def test_an_already_attributed_parent_is_not_duplicated(self): + def test_both_the_parent_and_the_child_are_proposed(self): + # LE défaut mesuré sur /contactus : le parent (3281) était IDENTIQUE + # à sa vue module, et c'est l'enfant (3282) qui portait l'arch + # périmée. Ne nommer que le parent envoyait réinitialiser une copie + # qui allait déjà bien, et la page restait en 500. + lst_failure = [("http://h/contactus", 500, [])] + log = ["[view_id: 3282, model: n/a, parent_id: 3281]"] + rebuilt = smoke.attach_missing_parents(lst_failure, log) + self.assertEqual(rebuilt[0][2], ["3281", "3282"]) + + def test_the_parent_comes_first(self): + # C'est le cas le plus fréquent — le blogue — donc en tête de liste. + lst_failure = [("http://h/a", 500, [])] + log = ["[view_id: 3288, model: n/a, parent_id: 2841]"] + rebuilt = smoke.attach_missing_parents(lst_failure, log) + self.assertEqual(rebuilt[0][2][0], "2841") + + def test_an_already_attributed_id_is_not_duplicated(self): lst_failure = [("http://h/a", 500, ["2841"])] log = ["[view_id: 3288, model: n/a, parent_id: 2841]"] rebuilt = smoke.attach_missing_parents(lst_failure, log) - self.assertEqual(rebuilt[0][2], ["2841"]) + self.assertEqual(rebuilt[0][2].count("2841"), 1) class TestTheServerIsKilledForReal(unittest.TestCase): From 2eac9e984875ac96f4ad982657d3ac1bd1ffdd62 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 17 Aug 2026 05:13:13 -0400 Subject: [PATCH 27/67] [ADD] migration: measure the public URLs before the first bump too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without a starting point, a page that already answered 500 reads as damage done by the migration, and the search goes the wrong way for hours. Measured on the real one: two URLs were already failing before anything had been migrated. Asked in step 2, on the database before the bump — not on a bump database that does not exist yet — and it says what it measures: a page broken now will still be broken after, and that is not the migration. --- FR --- [ADD] migration : mesurer les URL publiques avant le premier palier aussi Sans point de départ, une page qui rendait déjà 500 se lit comme un dégât de la migration, et l'on cherche des heures du mauvais côté. Mesuré sur la vraie : deux URL cassaient avant que quoi que ce soit n'ait été migré. Posée à l'étape 2, sur la base d'avant le palier — pas sur une base de palier qui n'existe pas encore — et elle dit ce qu'elle mesure : une page cassée maintenant le sera encore après, et ce ne sera pas la migration. Assisted-by: Claude Opus 5 --- script/todo/todo_i18n.py | 12 ++++++++ script/todo/todo_upgrade.py | 22 ++++++++++++-- test/test_smoke_public_url.py | 57 +++++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 3 deletions(-) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index af9668c..df0712f 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -5082,6 +5082,18 @@ TRANSLATIONS = { "fr": "(un palier de version) ou quand la page est rendue.", "en": "(a version bump) or when the page is rendered.", }, + "Before starting: what already answers, and what": { + "fr": "Avant de commencer : ce qui répond déjà, et ce qui", + "en": "Before starting: what already answers, and what", + }, + "does not. A page broken now will still be broken": { + "fr": "ne répond pas. Une page cassée maintenant le sera encore", + "en": "does not. A page broken now will still be broken", + }, + "after — and that is not the migration.": { + "fr": "après — et ce ne sera pas la migration.", + "en": "after — and that is not the migration.", + }, "Nothing to decide yet": { "fr": "Rien à décider pour l'instant", "en": "Nothing to decide yet", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index 44c44d9..de79827 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -1695,6 +1695,12 @@ class TodoUpgrade: f" -d {database_name} -t odoo{start_version + 1}.0 --report-only" ) + # La mesure de DÉPART. Sans elle, une page qui rendait déjà 500 avant + # la migration se lit comme un dégât de la migration, et l'on cherche + # des heures du côté du palier. Mesuré : deux URL cassaient avant même + # de commencer. + self.prompt_smoke_public_url(database_name, baseline=True) + msg = "3 - Clean up database before data migration" self.print_step(msg) self.add_comment_progression(msg) @@ -2907,17 +2913,27 @@ class TodoUpgrade: f" -d {database_name} {args} --apply" ) - def prompt_smoke_public_url(self, database_name): + def prompt_smoke_public_url(self, database_name, baseline=False): """Proposer d'interroger toutes les pages publiques de la base. La liste vient du sitemap — celle qu'Odoo publie pour les moteurs de recherche. Une page qui y figure et ne répond pas est une page que les visiteurs n'atteignent pas non plus. + `baseline` marque la mesure d'AVANT le premier palier. Elle ne sert + pas à réparer — elle sert à savoir ce qui cassait déjà. Sans ce point + de départ, les mêmes 500 après le palier passent pour un dégât de la + migration, et l'on cherche du mauvais côté. + « non » par défaut : cela démarre un serveur et peut prendre quelques - minutes sur un gros site, et rien n'oblige à le faire à chaque - palier. + minutes sur un gros site. """ + if baseline: + print( + f"\n✨ {t('Before starting: what already answers, and what')}" + f" {t('does not. A page broken now will still be broken')}" + f" {t('after — and that is not the migration.')}" + ) answer = ( self.ask_gate( f"💬 {t('Request every public URL of this database now?')}" diff --git a/test/test_smoke_public_url.py b/test/test_smoke_public_url.py index 7e51d15..425cc76 100755 --- a/test/test_smoke_public_url.py +++ b/test/test_smoke_public_url.py @@ -365,6 +365,63 @@ class TestTheMigrationOffersIt(unittest.TestCase): self.assertIn("smoke_public_url.py", lst_cmd[0]) self.assertIn("-d db_upgrade_13", lst_cmd[0]) + def test_it_is_also_asked_before_the_first_bump(self): + # LA mesure de départ : sans elle, une page qui rendait déjà 500 + # avant la migration se lit comme un dégât du palier, et l'on + # cherche des heures du mauvais côté. + import inspect + + from script.todo.todo_upgrade import TodoUpgrade + + source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade) + self.assertEqual(source.count("prompt_smoke_public_url"), 2) + premier = source.index("prompt_smoke_public_url") + self.assertLess( + premier, source.index("4 - Upgrade version with OpenUpgrade") + ) + + def test_the_baseline_runs_on_the_database_before_the_bump(self): + # Sur la base d'AVANT, pas sur une base de palier qui n'existe pas + # encore : la mesurer après ne dirait plus d'où vient la panne. + import inspect + + from script.todo.todo_upgrade import TodoUpgrade + + source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade) + premier = source.index("prompt_smoke_public_url") + fenetre = source[premier : premier + 90] + self.assertIn("database_name", fenetre) + self.assertNotIn("database_name_upgrade", fenetre) + self.assertIn("baseline=True", fenetre) + + def test_the_baseline_says_what_it_measures(self): + # Un même écran à deux moments différents : sans un mot, on croit + # que la migration a déjà eu lieu. + import contextlib + import io + + from script.todo import todo_i18n + from script.todo.todo_upgrade import TodoUpgrade + + self.addCleanup( + setattr, todo_i18n, "_current_lang", todo_i18n._current_lang + ) + todo_i18n._current_lang = "en" + upgrade = TodoUpgrade.__new__(TodoUpgrade) + upgrade.dct_progression = {} + upgrade.lst_command_executed = [] + upgrade.write_config = lambda: None + upgrade.run_on_terminal = lambda cmd: 0 + upgrade.ask_gate = lambda prompt: "" + out = io.StringIO() + with contextlib.redirect_stdout(out): + upgrade.prompt_smoke_public_url("db", baseline=True) + self.assertIn("Before starting", out.getvalue()) + out = io.StringIO() + with contextlib.redirect_stdout(out): + upgrade.prompt_smoke_public_url("db") + self.assertNotIn("Before starting", out.getvalue()) + def test_it_is_asked_before_the_selenium_prompt(self): # Après, la question arriverait une fois le navigateur ouvert : on # aurait déjà cherché à la main ce que le test nomme. From 95ffbe6f1390d50da3d4cacbe90165a598f50eac Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 17 Aug 2026 06:29:25 -0400 Subject: [PATCH 28/67] [ADD] migration: browse the drifted COW copies full screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The text report puts a thousand lines between the two things that decide: what the copy holds that the module view does not — all a reset gives up — and which child no longer finds its anchor, which is why anything breaks. Space switches between them, « c » copies the reset command. Offered as [4] on the error prompt, next to check and reset, and run on the real terminal: a full screen started through the capturing executor falls back to the text report with nothing to tell the two apart. The diff is rendered in one place, shared with the command line. Two renderings would drift without anything saying so. --- FR --- [ADD] migration : parcourir les copies COW dérivées en plein écran Le rapport texte met mille lignes entre les deux choses qui décident : ce que la copie porte et que la vue module n'a pas — tout ce qu'une réinitialisation abandonne — et quel enfant ne trouve plus son ancrage, la raison pour laquelle quoi que ce soit casse. L'espace bascule, « c » copie la commande de réparation. Proposé en [4] à l'invite d'erreur, à côté de vérifier et réinitialiser, et lancé sur le vrai terminal : un plein écran passé par l'exécuteur qui capture retombe sur le rapport texte, sans rien qui les distingue. Le diff est rendu à un seul endroit, partagé avec la ligne de commande. Deux rendus dériveraient sans que rien ne le signale. Assisted-by: Claude Opus 5 --- script/odoo/migration/reset_stale_cow_tui.py | 198 ++++++++++++++++++ .../odoo/migration/reset_stale_cow_views.py | 61 +++++- script/todo/todo_i18n.py | 44 ++++ script/todo/todo_upgrade.py | 13 ++ test/test_reset_stale_cow_tui.py | 163 ++++++++++++++ 5 files changed, 475 insertions(+), 4 deletions(-) create mode 100755 script/odoo/migration/reset_stale_cow_tui.py create mode 100755 test/test_reset_stale_cow_tui.py diff --git a/script/odoo/migration/reset_stale_cow_tui.py b/script/odoo/migration/reset_stale_cow_tui.py new file mode 100755 index 0000000..766f90f --- /dev/null +++ b/script/odoo/migration/reset_stale_cow_tui.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Full-screen browsing of the COW copies that drifted from their module view. + +Two things decide whether to reset a copy, and the text report puts them a +thousand lines apart: what the copy holds that the module view does not — the +only thing a reset gives up — and which child no longer finds its anchor, +which is why anything breaks at all. Space switches between them. + +The reset command is on « c »: it takes a key, and copying a key by hand out +of a scrolled diff is where a character goes missing. A key matching no copy +is not an error for the tool — it runs and does nothing. +""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) +) + +from reset_stale_cow_views import render_broken, render_diff # noqa: E402 + +try: + from script.todo.todo_i18n import t +except Exception: # pragma: no cover - repli si i18n indisponible + + def t(key: str) -> str: + return key + + +CSS = """ +Screen { layout: vertical; } +#head { height: 3; padding: 0 1; background: $panel; color: $text; } +#body { height: 1fr; } +#copies { width: 44; border-right: solid $accent; } +#pane { width: 1fr; padding: 0 1; } +""" + + +def weight(cow_view, module_view): + """Combien la copie s'écarte, pour trier d'un coup d'œil.""" + if not module_view: + return "—" + import difflib + + diff = list( + difflib.unified_diff( + module_view["arch"].splitlines(), + cow_view["arch"].splitlines(), + lineterm="", + n=0, + ) + ) + plus = sum(1 for x in diff if x[:1] == "+" and not x.startswith("+++")) + minus = sum(1 for x in diff if x[:1] == "-" and not x.startswith("---")) + return f"+{plus}/-{minus}" + + +def build_app(lst_finding, database): + """Build the application. Textual is imported here, not at module level. + + The module stays importable — and therefore testable — on a machine + without Textual, which is also what lets the caller fall back to the text + report rather than fail. + """ + from textual.app import App, ComposeResult + from textual.containers import Horizontal, VerticalScroll + from textual.widgets import DataTable, Footer, Header, Static + + class StaleCowApp(App): + CSS = globals()["CSS"] + BINDINGS = [ + ("q,escape", "quit", "Quit"), + ("space,tab", "toggle", "Diff / why"), + ("c", "copy", "Copy reset command"), + ] + + def __init__(self, lst_finding, database): + super().__init__() + self.lst_finding = lst_finding + self.database = database + self.why = False + self.index = 0 + + def compose(self) -> ComposeResult: + yield Header() + yield Static("", id="head") + with Horizontal(id="body"): + yield DataTable(id="copies", cursor_type="row") + with VerticalScroll(id="pane"): + yield Static("", id="content") + yield Footer() + + def on_mount(self): + self.title = "COW copies drifted from their module view" + table = self.query_one("#copies", DataTable) + table.add_columns("copy", "+/-", "broken") + for cow_view, module_view, broken in self.lst_finding: + table.add_row( + (cow_view["key"] or str(cow_view["id"]))[:30], + weight(cow_view, module_view), + str(len(broken)), + key=str(cow_view["id"]), + ) + self._show() + + def _show(self): + if not self.lst_finding: + return + cow_view, module_view, broken = self.lst_finding[self.index] + which = "why it breaks" if self.why else "what the copy holds" + self.query_one("#head", Static).update( + f"{cow_view['key']} · id={cow_view['id']}" + f" · website={cow_view.get('website_id')}\n" + f"[{which}] — space to switch, c to copy the reset command" + ) + if self.why: + text = render_broken(cow_view, module_view, broken) + elif module_view: + text = render_diff(module_view, cow_view, indent=" ") + else: + text = ( + f" {t('No module view carries this key: nothing to reset')}" + f" {t('onto.')}" + ) + self.query_one("#content", Static).update(text) + + def on_data_table_row_highlighted(self, event): + if event.data_table.id == "copies" and self.lst_finding: + self.index = event.cursor_row + self._show() + + def action_toggle(self): + self.why = not self.why + self._show() + + def action_copy(self): + """Mettre la commande de réinitialisation dans le presse-papier. + + Elle prend une CLÉ, et recopier une clé à la main depuis un diff + défilé est l'endroit où un caractère se perd — l'outil ne dirait + rien, il ne trouverait simplement aucune copie. + """ + cow_view = self.lst_finding[self.index][0] + command = ( + "./script/odoo/migration/reset_stale_cow_views.py" + f" -d {self.database} --reset {cow_view['key']} --apply" + ) + try: + self.copy_to_clipboard(command) + self.notify(t("Reset command copied.")) + except Exception: + self.notify(command, timeout=20) + + return StaleCowApp(lst_finding, database) + + +def run_tui(lst_finding, database, run_app=True): + """Ouvrir l'écran. False si on n'a pas pu — et alors on DIT pourquoi. + + Trois refus, trois raisons, aucune n'est une panne : rien à montrer, pas + de terminal, ou Textual absent. Se taire ferait réafficher le rapport + texte à la place de l'écran demandé, sans rien qui distingue les deux. + """ + if not lst_finding: + return False + if not sys.stdout.isatty(): + print(f"ℹ️ {t('Not a terminal: showing the text report instead.')}") + return False + try: + from script.todo import textual_setup + except Exception: + textual_setup = None + if textual_setup and not textual_setup.available(): + if not textual_setup.in_venv(): + print( + f"ℹ️ {t('Textual is missing from this interpreter:')}" + f" {sys.executable}" + ) + print(f" {t('Run it with')} .venv.erplibre/bin/python3") + if textual_setup and not textual_setup.ensure(): + return False + try: + app = build_app(lst_finding, database) + except ImportError: + print( + f"ℹ️ {t('Textual is missing from this interpreter:')}" + f" {sys.executable}" + ) + return False + if not run_app: + return app + app.run() + return True diff --git a/script/odoo/migration/reset_stale_cow_views.py b/script/odoo/migration/reset_stale_cow_views.py index f4cc956..62bb2ee 100755 --- a/script/odoo/migration/reset_stale_cow_views.py +++ b/script/odoo/migration/reset_stale_cow_views.py @@ -248,8 +248,13 @@ def find_copy_by_key(views, key): return copy, twin -def show_diff(module_view, cow_view): - """Module arch vs copy: what the copy would gain and lose on a reset.""" +def render_diff(module_view, cow_view, indent=" "): + """Module arch vs copy : ce qu'une réinitialisation rend et abandonne. + + Rendu en texte plutôt qu'imprimé : la TUI montre exactement le même + diff que la ligne de commande. Deux rendus séparés dériveraient sans + que rien ne le signale. + """ diff = difflib.unified_diff( module_view["arch"].splitlines(), cow_view["arch"].splitlines(), @@ -257,8 +262,44 @@ def show_diff(module_view, cow_view): tofile=f"cow id={cow_view['id']}", lineterm="", ) - for line in diff: - print(f" {line}") + return "\n".join(f"{indent}{line}" for line in diff) + + +def render_broken(cow_view, module_view, broken): + """Pourquoi ça casse : les enfants dont l'xpath ne trouve plus son point.""" + lines = [ + f"── id={cow_view['id']} {cow_view['key']}" + f" (website={cow_view.get('website_id')}) ──", + "", + ] + if not broken: + lines.append( + f" {t('No child fails on this copy: it drifted without')}" + f" {t('breaking anything yet.')}" + ) + else: + lines.append( + f" {t('These children no longer find their anchor in the copy')}" + " :" + ) + for child_id, expr in broken: + lines.append(f" {t('child')} {child_id} : {expr}") + lines += [ + "", + f" {t('The anchor exists in the module view')}" + f" (id={module_view['id'] if module_view else '?'})" + f" {t('but not in this copy, frozen on an older version.')}", + "", + f" {t('Resetting restores the module arch; the customization the')}", + f" {t('copy carried is saved first, and is yours to re-apply as an')}", + f" {t('INHERITING view with its own key.')}", + ] + return "\n".join(lines) + + +def show_diff(module_view, cow_view): + """Imprimer le diff, pour la ligne de commande.""" + print(render_diff(module_view, cow_view)) def backup(database, cow_view, directory): @@ -304,6 +345,11 @@ def main(): ) ) parser.add_argument("-d", "--database", required=True) + parser.add_argument( + "--tui", + action="store_true", + help="browse the differences full screen", + ) parser.add_argument( "--list-keys", action="store_true", @@ -381,6 +427,13 @@ def main(): ) return 1 + if config.tui and findings: + from reset_stale_cow_tui import run_tui + + # False n'est pas un échec : l'écran a dit pourquoi, et le rapport + # texte ci-dessus porte déjà la même information. + run_tui(findings, config.database) + wanted = set(config.reset) directory = config.backup_dir or os.path.join( "private", "odoo", "migration", config.database, "cow_reset" diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index df0712f..6529d4a 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -5094,6 +5094,50 @@ TRANSLATIONS = { "fr": "après — et ce ne sera pas la migration.", "en": "after — and that is not the migration.", }, + "No child fails on this copy: it drifted without": { + "fr": "Aucun enfant n'échoue sur cette copie : elle a dérivé sans", + "en": "No child fails on this copy: it drifted without", + }, + "breaking anything yet.": { + "fr": "encore rien casser.", + "en": "breaking anything yet.", + }, + "These children no longer find their anchor in the copy": { + "fr": "Ces enfants ne trouvent plus leur point d'ancrage dans la copie", + "en": "These children no longer find their anchor in the copy", + }, + "The anchor exists in the module view": { + "fr": "L'ancrage existe dans la vue module", + "en": "The anchor exists in the module view", + }, + "but not in this copy, frozen on an older version.": { + "fr": "mais pas dans cette copie, figée sur une version antérieure.", + "en": "but not in this copy, frozen on an older version.", + }, + "Resetting restores the module arch; the customization the": { + "fr": "Réinitialiser rend l'arch du module ; la personnalisation que", + "en": "Resetting restores the module arch; the customization the", + }, + "copy carried is saved first, and is yours to re-apply as an": { + "fr": "la copie portait est sauvegardée d'abord, à vous de la réappliquer en", + "en": "copy carried is saved first, and is yours to re-apply as an", + }, + "INHERITING view with its own key.": { + "fr": "vue HÉRITANTE avec sa propre clé.", + "en": "INHERITING view with its own key.", + }, + "No module view carries this key: nothing to reset": { + "fr": "Aucune vue module ne porte cette clé : rien sur quoi", + "en": "No module view carries this key: nothing to reset", + }, + "onto.": { + "fr": "réinitialiser.", + "en": "onto.", + }, + "Browse the differences full screen": { + "fr": "Parcourir les différences en plein écran", + "en": "Browse the differences full screen", + }, "Nothing to decide yet": { "fr": "Rien à décider pour l'instant", "en": "Nothing to decide yet", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index de79827..260c376 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -3412,6 +3412,7 @@ class TodoUpgrade: f" ({database_name})" ) print(f"[3] {t('Reset one of them onto its module view')}") + print(f"[4] {t('Browse the differences full screen')}") wait_status = ( input( f"💬 {t('Error detected, press enter to continue or')}" @@ -3434,6 +3435,18 @@ class TodoUpgrade: if wait_status == "3" and database_name: self.prompt_reset_stale_cow_views(database_name) continue + if wait_status == "4" and database_name: + # Sur le VRAI terminal : un plein écran refuse de + # s'ouvrir sur un stdout capturé, et retomberait sur le + # rapport texte sans que rien ne distingue les deux. + self.run_on_terminal( + f"{PYTHON_BIN} ./" + + os.path.join( + PATH_MIGRATION_GLOBAL, "reset_stale_cow_views.py" + ) + + f" -d {database_name} --tui" + ) + continue break if wait_status == "1": diff --git a/test/test_reset_stale_cow_tui.py b/test/test_reset_stale_cow_tui.py new file mode 100755 index 0000000..2b0296b --- /dev/null +++ b/test/test_reset_stale_cow_tui.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Lire une copie COW avant d'accepter de la perdre, en plein écran. + +Le rapport texte met à mille lignes d'écart les deux choses qui décident : +ce que la copie porte en propre — la seule chose qu'une réinitialisation +abandonne — et quel enfant ne trouve plus son ancrage, qui est la raison +pour laquelle quoi que ce soit casse. L'espace bascule entre les deux. + +La commande de réparation prend une CLÉ. La recopier à la main depuis un +diff défilé est l'endroit où un caractère se perd, et une clé sans +correspondance n'est pas une erreur pour l'outil : il tourne et ne fait +rien. D'où « c ». +""" + +import os +import sys +import unittest + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(REPO, "script", "odoo", "migration")) + +import reset_stale_cow_tui as tui # noqa: E402 +import reset_stale_cow_views as reset # noqa: E402 + + +def finding(**override): + cow = { + "id": 2841, + "key": "website_blog.blog_post_complete", + "website_id": 1, + "arch": "\n
\n vieux\n", + } + module = { + "id": 1826, + "key": "website_blog.blog_post_complete", + "website_id": None, + "arch": "\n
\n
\n", + } + broken = [(3288, "//section[@id='o_wblog_post_footer']")] + if "cow" in override: + cow.update(override["cow"]) + return cow, override.get("module", module), override.get("broken", broken) + + +class TestBothScreens(unittest.TestCase): + def setUp(self): + from script.todo import todo_i18n + + self.addCleanup( + setattr, todo_i18n, "_current_lang", todo_i18n._current_lang + ) + todo_i18n._current_lang = "en" + + def test_the_diff_shows_what_the_copy_holds(self): + cow, module, _broken = finding() + text = reset.render_diff(module, cow, indent=" ") + self.assertIn("vieux", text) + self.assertIn("o_wblog_post_footer", text) + + def test_the_why_screen_names_the_failing_child(self): + # Sans lui, on lit un diff sans savoir pourquoi il compte. + text = tui.render_broken(*finding()) + self.assertIn("3288", text) + self.assertIn("//section[@id='o_wblog_post_footer']", text) + + def test_the_why_screen_says_what_a_reset_costs(self): + text = tui.render_broken(*finding()) + self.assertIn("INHERITING", text) + + def test_a_copy_without_broken_child_is_said_so(self): + # Elle a dérivé sans rien casser ENCORE : le taire ferait croire à + # une erreur de détection. + cow, module, _ = finding() + text = tui.render_broken(cow, module, []) + self.assertIn("without", text) + + def test_the_two_screens_use_the_same_diff_as_the_cli(self): + # Deux rendus séparés dériveraient sans que rien ne le signale. + import inspect + + source = inspect.getsource(tui) + self.assertIn("from reset_stale_cow_views import", source) + self.assertIn("render_diff", source) + + +class TestTheTriageColumn(unittest.TestCase): + def test_the_weight_counts_both_directions(self): + cow, module, _ = finding() + self.assertEqual(tui.weight(cow, module), "+1/-1") + + def test_no_module_twin_has_no_weight(self): + cow, _module, _ = finding() + self.assertEqual(tui.weight(cow, None), "—") + + +class TestTheRefusalIsNeverSilent(unittest.TestCase): + """Un refus muet ferait réafficher le rapport texte sans le dire.""" + + def test_a_pipe_is_explained(self): + import io + from contextlib import redirect_stdout + + out = io.StringIO() + with redirect_stdout(out): + result = tui.run_tui([finding()], "db") + self.assertFalse(result) + self.assertTrue(out.getvalue().strip(), "refus muet") + + def test_nothing_to_show_stays_silent(self): + import io + from contextlib import redirect_stdout + + out = io.StringIO() + with redirect_stdout(out): + self.assertFalse(tui.run_tui([], "db")) + self.assertEqual(out.getvalue(), "") + + +class TestTheToolAndTheMigrationOfferIt(unittest.TestCase): + def test_the_tool_has_a_tui_flag(self): + import subprocess + + done = subprocess.run( + [ + sys.executable, + "./script/odoo/migration/reset_stale_cow_views.py", + "--help", + ], + capture_output=True, + text=True, + cwd=REPO, + ) + self.assertEqual(done.returncode, 0) + self.assertIn("--tui", done.stdout) + + def test_the_error_prompt_offers_it_as_option_four(self): + import inspect + + from script.todo.todo_upgrade import TodoUpgrade + + source = inspect.getsource(TodoUpgrade.todo_upgrade_execute) + self.assertIn("Browse the differences full screen", source) + self.assertIn('wait_status == "4"', source) + + def test_it_gets_a_real_terminal(self): + # Un plein écran lancé par l'exécuteur qui capture retomberait sur + # le rapport texte, sans que rien ne distingue les deux. + import inspect + + from script.todo.todo_upgrade import TodoUpgrade + + source = inspect.getsource(TodoUpgrade.todo_upgrade_execute) + start = source.index('wait_status == "4"') + window = source[start : start + 620] + self.assertIn("run_on_terminal", window) + self.assertIn("--tui", window) + + +if __name__ == "__main__": + unittest.main() From efc152bf62d4259c41a477c236d76c682c5dceb3 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 17 Aug 2026 07:59:28 -0400 Subject: [PATCH 29/67] [FIX] migration: name the template when the failure comes from rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A QWebException carries no [view_id … parent_id …] block — it names the template. The tool only read the inheritance context, so it answered « no parent view named » on the one failure that hit everything. Measured at bump 17: a frozen copy of website.submenu still called submenu.clean_url(), renamed _clean_url() in that version. Every page renders the menu, so 34 of 37 public URLs returned 500, and nothing pointed at the view. Resetting that one copy brought all 37 back. Only keys that actually have a COW copy are proposed: naming one without would send a reset against a module view, which does nothing. --- FR --- [FIX] migration : nommer le gabarit quand l'échec vient du rendu Une QWebException ne porte pas de bloc [view_id … parent_id …] : elle nomme le gabarit. L'outil ne lisait que le contexte d'héritage, et répondait donc « aucune vue parente nommée » sur la seule panne qui touchait tout. Mesuré au palier 17 : une copie figée de website.submenu appelait encore submenu.clean_url(), renommée _clean_url() par la version. Toute page affiche le menu, donc 34 URL publiques sur 37 rendaient 500, sans que rien ne désigne la vue. Réinitialiser cette seule copie les a toutes ramenées. Seules les clés ayant vraiment une copie COW sont proposées : en nommer une sans copie enverrait réinitialiser une vue module, donc ne rien faire. Assisted-by: Claude Opus 5 --- script/odoo/migration/smoke_public_url.py | 41 ++++++++++++++- test/test_smoke_public_url.py | 64 +++++++++++++++++++++++ 2 files changed, 103 insertions(+), 2 deletions(-) diff --git a/script/odoo/migration/smoke_public_url.py b/script/odoo/migration/smoke_public_url.py index 1eb914d..3673161 100755 --- a/script/odoo/migration/smoke_public_url.py +++ b/script/odoo/migration/smoke_public_url.py @@ -74,6 +74,13 @@ RE_LOC = re.compile(r"\s*([^<\s]+)\s*", re.I) # figée dans laquelle l'enfant ne trouve plus son xpath. RE_CONTEXT = re.compile(r"\[view_id: (\d+),.*?parent_id: (\d+)\]") +# « Template: website.submenu ». Une QWebException de RENDU ne porte pas le +# bloc [view_id …] : elle nomme le gabarit. Mesuré au palier 17 — une copie +# figée appelait `submenu.clean_url()`, méthode renommée `_clean_url()` dans +# la version, et 34 URL sur 37 rendaient 500 sans que rien ne désigne la vue +# fautive. +RE_TEMPLATE = re.compile(r"^Template:\s*([\w.]+)\s*$", re.M) + # Un port à part : la migration tourne souvent à côté d'une instance vivante, # et lui voler 8069 ferait échouer le test pour une raison sans rapport. DEFAULT_PORT = 8169 @@ -253,6 +260,29 @@ def check_urls(lst_url, timeout=30): return lst_failure +def template_keys(lst_log, database): + """Les gabarits nommés par une QWebException, s'ils ont une copie COW. + + Nommer une clé sans copie enverrait réinitialiser une vue module — donc + ne rien faire, en silence. On ne propose que ce qui peut l'être. + """ + lst_key = [] + for line in lst_log: + for key in RE_TEMPLATE.findall(line): + if key not in lst_key: + lst_key.append(key) + if not lst_key: + return [] + quoted = ",".join("'" + k.replace("'", "''") + "'" for k in lst_key) + rows = run_psql( + database, + "SELECT DISTINCT key FROM ir_ui_view" + f" WHERE website_id IS NOT NULL AND key IN ({quoted});", + ) + with_copy = {row[0] for row in rows if row and row[0]} + return [key for key in lst_key if key in with_copy] + + def culprit_keys(database, lst_failure): """Les clés des vues parentes mises en cause, sans doublon. @@ -426,9 +456,16 @@ def run( finally: stop_server(server) + lst_key = [] if lst_failure: - lst_failure = attach_missing_parents(lst_failure, read_log(log_path)) - lst_key = culprit_keys(database, lst_failure) + lst_log = read_log(log_path) + lst_failure = attach_missing_parents(lst_failure, lst_log) + lst_key = culprit_keys(database, lst_failure) + # Les deux sources : le contexte d'héritage quand il existe, le nom + # du gabarit quand l'échec vient du rendu. + for key in template_keys(lst_log, database): + if key not in lst_key: + lst_key.append(key) if not lst_failure or not (interactive or auto_apply): return lst_url, lst_failure, lst_key, None diff --git a/test/test_smoke_public_url.py b/test/test_smoke_public_url.py index 425cc76..630d7e5 100755 --- a/test/test_smoke_public_url.py +++ b/test/test_smoke_public_url.py @@ -240,6 +240,70 @@ class TestTheCulpritViewsAreNamed(unittest.TestCase): self.assertEqual(rebuilt[0][2].count("2841"), 1) +class TestARenderFailureNamesItsTemplate(unittest.TestCase): + """Une QWebException ne porte pas de bloc [view_id …] : elle nomme le + gabarit. + + Mesuré au palier 17 : une copie figée appelait `submenu.clean_url()`, + méthode renommée `_clean_url()` par la version. 34 URL sur 37 rendaient + 500, et l'outil répondait « aucune vue parente nommée » — il ne lisait + que le contexte d'héritage, absent ici. + """ + + def test_the_template_line_is_read(self): + self.assertEqual( + smoke.RE_TEMPLATE.findall("Template: website.submenu"), + ["website.submenu"], + ) + + def test_prose_is_not_mistaken_for_a_key(self): + # « Template: » suivi d'une phrase n'est pas un gabarit. + self.assertEqual( + smoke.RE_TEMPLATE.findall("Template: not a key, just words"), [] + ) + + def test_only_keys_with_a_copy_are_proposed(self): + # Proposer une clé sans copie COW enverrait réinitialiser une vue + # module : la commande tournerait sans rien faire. + seen = {} + original = smoke.run_psql + smoke.run_psql = lambda db, sql: ( + seen.update(sql=sql), + [["website.submenu"]], + )[1] + self.addCleanup(setattr, smoke, "run_psql", original) + got = smoke.template_keys( + ["Template: website.submenu", "Template: website.layout"], "db" + ) + self.assertEqual(got, ["website.submenu"]) + self.assertIn("website_id IS NOT NULL", seen["sql"]) + + def test_no_template_line_asks_nothing_of_the_database(self): + called = [] + original = smoke.run_psql + smoke.run_psql = lambda db, sql: called.append(sql) or [] + self.addCleanup(setattr, smoke, "run_psql", original) + self.assertEqual(smoke.template_keys(["rien ici"], "db"), []) + self.assertEqual(called, []) + + def test_a_quote_in_a_key_is_escaped(self): + seen = {} + original = smoke.run_psql + smoke.run_psql = lambda db, sql: (seen.update(sql=sql), [])[1] + self.addCleanup(setattr, smoke, "run_psql", original) + smoke.template_keys(["Template: a.b"], "db") + self.assertIn("'a.b'", seen["sql"]) + + def test_both_sources_feed_the_proposal(self): + # Le contexte d'héritage quand il existe, le gabarit quand l'échec + # vient du rendu : l'un ne remplace pas l'autre. + import inspect + + source = inspect.getsource(smoke.run) + self.assertIn("culprit_keys(", source) + self.assertIn("template_keys(", source) + + class TestTheServerIsKilledForReal(unittest.TestCase): """« ./run.sh » est un script bash : il ne transmet pas les signaux.""" From 1831b234ea3e5b890369ce98e0a44249bce6783c Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 17 Aug 2026 09:05:03 -0400 Subject: [PATCH 30/67] [ADD] migration: run the OCA database cleanup before testing the pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The eight purges are not independent: purging a model frees the columns that referenced it, purging a table frees the data pointing at it. One pass is never enough, so the requested order runs again until a full pass repairs nothing new. Refusals are expected — a foreign key still holds, a module says no. Purging a list at once loses everything to the first one, so each entry purges inside its own savepoint: a refusal rolls back that entry alone. What one pass could not take, the next may, once its neighbours are gone. Leftovers are reported as a warning: a database can carry some that nothing removes, and stopping there would help no one. It refuses a version mismatch. Measured while building it: a shell on Odoo 14 opened against a 17.0 database went rewriting ir_model before dying on a jsonb it did not know. An older Odoo does not merely fail on a newer database — it writes on the way. --- FR --- [ADD] migration : lancer le nettoyage OCA avant de tester les pages Les huit purges ne sont pas indépendantes : purger un modèle libère les colonnes qui le référençaient, purger une table libère les données qui la visaient. Une passe ne suffit jamais, l'ordre demandé est donc rejoué jusqu'à ce qu'une passe entière ne répare plus rien. Les refus sont attendus — une clé étrangère tient, un module dit non. Purger d'un bloc perdrait tout au premier : chaque entrée passe dans son propre point de reprise, un refus n'emporte que la sienne. Ce qu'une passe n'a pu prendre, la suivante le peut, une fois les voisines parties. Les restes sont un avertissement : une base peut en porter que rien ne retire, et s'arrêter là n'aiderait personne. Il refuse une version qui ne correspond pas. Mesuré en le construisant : un shell en Odoo 14 ouvert sur une base 17.0 est parti réécrire ir_model avant de mourir sur un jsonb inconnu de lui. Un Odoo plus ancien n'échoue pas simplement sur une base plus récente — il écrit en chemin. Assisted-by: Claude Opus 5 --- script/odoo/migration/database_cleanup.py | 352 ++++++++++++++++++ script/todo/todo_i18n.py | 108 ++++++ script/todo/todo_upgrade.py | 32 ++ test/test_database_cleanup.py | 428 ++++++++++++++++++++++ 4 files changed, 920 insertions(+) create mode 100755 script/odoo/migration/database_cleanup.py create mode 100755 test/test_database_cleanup.py diff --git a/script/odoo/migration/database_cleanup.py b/script/odoo/migration/database_cleanup.py new file mode 100755 index 0000000..4e6f7fd --- /dev/null +++ b/script/odoo/migration/database_cleanup.py @@ -0,0 +1,352 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Run the OCA « Database cleanup » purges, in order, until nothing moves. + +Why a tool rather than the eight screens +---------------------------------------- +A migration leaves obsolete models, columns, tables, data and menus behind. +The `database_cleanup` module offers one wizard per kind, and they are not +independent: purging a model frees the columns that referenced it, purging a +table frees the data rows pointing at it. One pass is never enough — the +order matters, and so does going round again. + +Errors are expected, and are not a reason to stop +------------------------------------------------- +Some entries cannot be purged: a foreign key still holds, a record is +protected, a module refuses. Purging the whole list at once loses everything +to the first failure, so each entry is purged INSIDE ITS OWN SAVEPOINT: a +refusal rolls back that entry alone and the rest of the pass continues. What +one pass could not repair, the next may — once its neighbours are gone. + +The loop stops when a full pass repairs nothing new. Whatever remains is +reported as a warning, not as a failure: a database can carry leftovers that +nothing can remove, and refusing to move on would help no one. + +Exit codes: 0 nothing left, 1 leftovers remain, 2 the tool failed. +""" + +import argparse +import json +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 + + +# L'ordre demandé, et il compte : purger un modèle libère les colonnes qui le +# référençaient, purger une table libère les données qui la visaient. Les +# index viennent après les purges — inutile d'indexer ce qu'on va retirer. +# +# `property` n'existe plus en 18.0 : Odoo y a remplacé ir.property par une +# colonne jsonb. On le garde dans la liste et on le saute quand il manque, +# plutôt que d'échouer sur une version où il n'a plus lieu d'être. +ORDER = [ + ("models", "cleanup.purge.wizard.model"), + ("modules", "cleanup.purge.wizard.module"), + ("columns", "cleanup.purge.wizard.column"), + ("tables", "cleanup.purge.wizard.table"), + ("data", "cleanup.purge.wizard.data"), + ("menus", "cleanup.purge.wizard.menu"), + ("indexes", "cleanup.create_indexes.wizard"), + ("properties", "cleanup.purge.wizard.property"), +] + +LABEL = { + "models": "obsolete models", + "modules": "obsolete modules", + "columns": "obsolete columns", + "tables": "obsolete tables", + "data": "obsolete data entries", + "menus": "obsolete menu entries", + "indexes": "missing indexes", + "properties": "obsolete properties", +} + +START = "ERPLIBRE_CLEANUP_START" +END = "ERPLIBRE_CLEANUP_END" + +SHELL_SCRIPT = """ +import json +ORDER = %(order)r +MAX_ROUND = %(max_round)d +DRY_RUN = %(dry_run)s + +report = {"rounds": [], "missing": [], "failed": []} +for index in range(MAX_ROUND): + this_round = [] + purged_this_round = 0 + for label, model in ORDER: + if model not in env: + if label not in report["missing"]: + report["missing"].append(label) + continue + ok = 0 + errors = [] + would = [] + try: + wizard = env[model].create({}) + lines = wizard.purge_line_ids + except Exception as exc: + report["failed"].append([label, "-", str(exc)[:200]]) + continue + for line in lines: + name = line.name or str(line.id) + if DRY_RUN: + would.append(name) + continue + try: + # Un point de reprise par ENTRÉE : un refus n'emporte que la + # sienne, et la passe continue. Sans cela, le premier échec + # ferait perdre tout ce que la passe avait réparé. + with env.cr.savepoint(): + line.purge() + ok += 1 + except Exception as exc: + errors.append([name, str(exc)[:160]]) + if not DRY_RUN and ok: + env.cr.commit() + purged_this_round += ok + this_round.append({"kind": label, "purged": ok, + "errors": errors, "would": would}) + report["rounds"].append(this_round) + # On s'arrête quand une passe ENTIÈRE n'a plus rien réparé : ce qui + # résistait au tour d'avant résistera encore. En simulation, une seule + # passe suffit — rien ne change, donc rien ne se libère. + if purged_this_round == 0 or DRY_RUN: + break + +print("%(start)s") +print(json.dumps(report)) +print("%(end)s") +""" + + +def build_script(max_round, dry_run): + return SHELL_SCRIPT % { + "order": ORDER, + "max_round": max_round, + "dry_run": "True" if dry_run else "False", + "start": START, + "end": END, + } + + +def checkout_version(): + """La version d'Odoo que le checkout servira, d'après .odoo-version.""" + try: + with open(".odoo-version", "r", encoding="utf-8") as handle: + return handle.read().strip() + except OSError: + return None + + +def database_version(database): + """La version que la BASE dit être la sienne, via le module `base`.""" + env = os.environ.copy() + env["PGOPTIONS"] = "-c default_transaction_read_only=on" + env["PSQLRC"] = "" + done = subprocess.run( + [ + "psql", + "-X", + "-w", + "-d", + database, + "-tAc", + "SELECT latest_version FROM ir_module_module WHERE name='base';", + ], + capture_output=True, + text=True, + env=env, + ) + if done.returncode: + return None + parts = done.stdout.strip().split(".") + return ".".join(parts[:2]) if len(parts) >= 2 else None + + +def require_matching_version(database): + """Refuser d'ouvrir une base avec un Odoo d'une autre version. + + Un Odoo plus ancien qui charge une base plus récente ne se contente pas + d'échouer : il ÉCRIT en chemin — mesuré, un shell en 14.0 lancé sur une + base 17.0 est parti réécrire ir_model avant de mourir sur un jsonb qu'il + ne connaissait pas. Le checkout suit la migration, et rien ne garantit + qu'il soit resté sur la version de la base qu'on veut nettoyer. + """ + checkout = checkout_version() + database_side = database_version(database) + if not checkout or not database_side: + return None # Rien pour trancher : ne pas bloquer sur une supposition. + if checkout != database_side: + return ( + f"{t('The checkout is on Odoo')} {checkout}" + f" {t('and the database on')} {database_side}." + f"\n {t('Opening it with the wrong version writes to it before')}" + f" {t('failing. Switch the checkout first.')}" + ) + return None + + +def run_shell(database, config_path, script, timeout=3600): + """Pousser le script dans « odoo-bin shell » et rendre son rapport. + + Les journaux d'Odoo se mêlent à la sortie, d'où les sentinelles : on ne + lit que ce qui est entre elles. Leur absence est une erreur franche, pas + un rapport vide qu'on prendrait pour « rien à faire ». + """ + done = subprocess.run( + [ + "./odoo_bin.sh", + "shell", + "-c", + config_path, + "-d", + database, + "--log-level=warn", + ], + input=script, + capture_output=True, + text=True, + timeout=timeout, + ) + output = done.stdout + done.stderr + if START not in output or END not in output: + raise RuntimeError( + f"{t('The cleanup produced no report.')}\n{output.strip()[-1500:]}" + ) + body = output.split(START, 1)[1].split(END, 1)[0].strip() + try: + return json.loads(body) + except ValueError as exc: + raise RuntimeError(f"{t('Unreadable report')} : {exc}") + + +def leftovers(report): + """[(kind, name, message)] de ce que la DERNIÈRE passe n'a pas pu purger.""" + if not report.get("rounds"): + return [] + lst = [] + for entry in report["rounds"][-1]: + for name, message in entry.get("errors", []): + lst.append((entry["kind"], name, message)) + for kind, name, message in report.get("failed", []): + lst.append((kind, name, message)) + return lst + + +def render(report, database): + lines = [f"🧹 {t('Database cleanup on')} '{database}'"] + # La simulation ne répare rien : la présenter comme un échec ferait + # croire à 585 refus là où il n'y a que 585 candidats. + lst_would = [ + (entry["kind"], name) + for this_round in report.get("rounds", []) + for entry in this_round + for name in entry.get("would", []) + ] + if lst_would: + lines.append( + f"ℹ {len(lst_would)} {t('entries would be purged')}" + f" ({t('nothing was changed')}) :" + ) + by_kind = {} + for kind, _name in lst_would: + by_kind[kind] = by_kind.get(kind, 0) + 1 + for kind, count in by_kind.items(): + lines.append(f" - {t(LABEL.get(kind, kind))} : {count}") + return "\n".join(lines) + "\n" + total = 0 + for index, this_round in enumerate(report.get("rounds", []), start=1): + purged = sum(entry["purged"] for entry in this_round) + total += purged + detail = ", ".join( + f"{t(LABEL[entry['kind']])} {entry['purged']}" + for entry in this_round + if entry["purged"] + ) + lines.append( + f" {t('pass')} {index} : {purged} {t('purged')}" + + (f" — {detail}" if detail else "") + ) + for kind in report.get("missing", []): + lines.append( + f" ℹ {t(LABEL.get(kind, kind))} :" + f" {t('no such wizard in this version, skipped.')}" + ) + lst_left = leftovers(report) + if not lst_left: + lines.append(f"✅ -> {total} {t('entries purged, nothing left.')}") + return "\n".join(lines) + "\n" + lines.append( + f"⚠️ {total} {t('purged;')} {len(lst_left)}" + f" {t('could not be, and are left as they are')} :" + ) + for kind, name, message in lst_left[:20]: + lines.append(f" - [{kind}] {name} : {message[:90]}") + if len(lst_left) > 20: + lines.append(f" … {len(lst_left) - 20} {t('more')}") + lines.append( + f" {t('A database can carry leftovers nothing can remove.')}" + f" {t('This is a warning, not a failure.')}" + ) + return "\n".join(lines) + "\n" + + +def main(argv=None): + parser = argparse.ArgumentParser( + description=( + "Run the OCA database_cleanup purges in order, repeating until" + " a full pass repairs nothing new." + ) + ) + parser.add_argument("-d", "--database", required=True) + parser.add_argument("-c", "--config", default="./config.conf") + parser.add_argument( + "--max-round", + type=int, + default=10, + help="how many full passes at most (default 10)", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="list what would be purged, purge nothing", + ) + parser.add_argument("--timeout", type=int, default=3600) + config = parser.parse_args(argv) + + mismatch = require_matching_version(config.database) + if mismatch: + print(f"⛔ {mismatch}") + return 2 + + print(f"⧖ {t('Cleaning')} '{config.database}'…") + try: + report = run_shell( + config.database, + config.config, + build_script(config.max_round, config.dry_run), + timeout=config.timeout, + ) + except (RuntimeError, subprocess.SubprocessError, OSError) as exc: + print(f"❌ {exc}") + return 2 + print(render(report, config.database)) + return 1 if leftovers(report) else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 6529d4a..3284524 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -5138,6 +5138,114 @@ TRANSLATIONS = { "fr": "Parcourir les différences en plein écran", "en": "Browse the differences full screen", }, + "The checkout is on Odoo": { + "fr": "Le checkout est sur Odoo", + "en": "The checkout is on Odoo", + }, + "and the database on": { + "fr": "et la base sur", + "en": "and the database on", + }, + "Opening it with the wrong version writes to it before": { + "fr": "L'ouvrir avec la mauvaise version y écrit avant", + "en": "Opening it with the wrong version writes to it before", + }, + "failing. Switch the checkout first.": { + "fr": "d'échouer. Basculer le checkout d'abord.", + "en": "failing. Switch the checkout first.", + }, + "Database cleanup on": { + "fr": "Nettoyage de la base", + "en": "Database cleanup on", + }, + "Cleaning": { + "fr": "Nettoyage de", + "en": "Cleaning", + }, + "pass": { + "fr": "passe", + "en": "pass", + }, + "purged": { + "fr": "purgés", + "en": "purged", + }, + "purged;": { + "fr": "purgés ;", + "en": "purged;", + }, + "could not be, and are left as they are": { + "fr": "n'ont pas pu l'être, et restent en l'état", + "en": "could not be, and are left as they are", + }, + "entries purged, nothing left.": { + "fr": "entrées purgées, rien ne reste.", + "en": "entries purged, nothing left.", + }, + "no such wizard in this version, skipped.": { + "fr": "pas d'assistant pour cela dans cette version, ignoré.", + "en": "no such wizard in this version, skipped.", + }, + "A database can carry leftovers nothing can remove.": { + "fr": "Une base peut porter des restes que rien ne peut retirer.", + "en": "A database can carry leftovers nothing can remove.", + }, + "This is a warning, not a failure.": { + "fr": "C'est un avertissement, pas un échec.", + "en": "This is a warning, not a failure.", + }, + "The cleanup produced no report.": { + "fr": "Le nettoyage n'a produit aucun rapport.", + "en": "The cleanup produced no report.", + }, + "Unreadable report": { + "fr": "Rapport illisible", + "en": "Unreadable report", + }, + "obsolete models": { + "fr": "modèles obsolètes", + "en": "obsolete models", + }, + "obsolete modules": { + "fr": "modules obsolètes", + "en": "obsolete modules", + }, + "obsolete columns": { + "fr": "colonnes obsolètes", + "en": "obsolete columns", + }, + "obsolete tables": { + "fr": "tables obsolètes", + "en": "obsolete tables", + }, + "obsolete data entries": { + "fr": "entrées de données obsolètes", + "en": "obsolete data entries", + }, + "obsolete menu entries": { + "fr": "entrées de menu obsolètes", + "en": "obsolete menu entries", + }, + "missing indexes": { + "fr": "index manquants", + "en": "missing indexes", + }, + "obsolete properties": { + "fr": "propriétés obsolètes", + "en": "obsolete properties", + }, + "entries would be purged": { + "fr": "entrées seraient purgées", + "en": "entries would be purged", + }, + "nothing was changed": { + "fr": "rien n'a été modifié", + "en": "nothing was changed", + }, + "Clean the database before testing the pages?": { + "fr": "Nettoyer la base avant de tester les pages ?", + "en": "Clean the database before testing the pages?", + }, "Nothing to decide yet": { "fr": "Rien à décider pour l'instant", "en": "Nothing to decide yet", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index 260c376..b1149d3 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -1699,6 +1699,10 @@ class TodoUpgrade: # la migration se lit comme un dégât de la migration, et l'on cherche # des heures du côté du palier. Mesuré : deux URL cassaient avant même # de commencer. + # Le nettoyage AVANT la mesure : interroger les pages sur une base + # encombrée fait chercher des pannes dans des restes, et le nettoyage + # en répare une partie de lui-même. + self.prompt_database_cleanup(database_name) self.prompt_smoke_public_url(database_name, baseline=True) msg = "3 - Clean up database before data migration" @@ -2601,6 +2605,7 @@ class TodoUpgrade: # quand même un 500 sur une page que personne n'ouvre. Mesuré # ici : /blog//post/ et /contactus, alors que # le journal de migration n'avait rien signalé. + self.prompt_database_cleanup(database_name_upgrade) self.prompt_smoke_public_url(database_name_upgrade) print(f"[y] {t('Open the server with Selenium')}") @@ -2913,6 +2918,33 @@ class TodoUpgrade: f" -d {database_name} {args} --apply" ) + def prompt_database_cleanup(self, database_name): + """Proposer le nettoyage OCA avant d'interroger les pages. + + Une migration laisse derrière elle des modèles, colonnes, tables, + données et menus obsolètes. Les interroger ensuite fait chercher des + pannes dans des restes qui n'auraient pas dû survivre — et le + nettoyage lui-même en répare une partie. + + « non » par défaut : cela écrit en base, et c'est à vous d'en + décider. + """ + answer = ( + self.ask_gate( + f"💬 {t('Clean the database before testing the pages?')}" + f" (y/N, {t('(b = go back to a previous step)')}) : " + ) + .strip() + .lower() + ) + if answer != "y": + return + self.run_on_terminal( + f"{PYTHON_BIN}" + " ./script/odoo/migration/database_cleanup.py" + f" -d {database_name}" + ) + def prompt_smoke_public_url(self, database_name, baseline=False): """Proposer d'interroger toutes les pages publiques de la base. diff --git a/test/test_database_cleanup.py b/test/test_database_cleanup.py new file mode 100755 index 0000000..37d2587 --- /dev/null +++ b/test/test_database_cleanup.py @@ -0,0 +1,428 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Le nettoyage OCA, en ordre, jusqu'à ce que plus rien ne bouge. + +Les huit purges ne sont pas indépendantes : purger un modèle libère les +colonnes qui le référençaient, purger une table libère les données qui la +visaient. Une seule passe ne suffit jamais, et l'ordre compte. + +Les erreurs sont ATTENDUES — une clé étrangère tient encore, un module +refuse. Purger la liste d'un bloc perdrait tout au premier refus : chaque +entrée passe donc dans son propre point de reprise. Ce que la passe n'a pas +réparé, la suivante le peut, une fois les voisines parties. + +Ces tests exécutent le script réellement poussé dans le shell, sur un `env` +simulé. C'est la seule façon de vérifier l'ordre, l'isolement des refus et +l'arrêt de la boucle sans lancer Odoo sur une vraie base. +""" + +import os +import sys +import unittest + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(REPO, "script", "odoo", "migration")) + +import database_cleanup as cleanup # noqa: E402 + + +class FakeLine: + def __init__(self, name, fails=0, journal=None): + self.name = name + self.id = abs(hash(name)) % 10000 + self.fails = fails # nombre de refus avant de céder + self.journal = journal if journal is not None else [] + + def purge(self): + self.journal.append(("purge", self.name)) + if self.fails > 0: + self.fails -= 1 + raise RuntimeError(f"refus sur {self.name}") + + +class FakeWizard: + def __init__(self, lines): + self.purge_line_ids = lines + + +class FakeModel: + def __init__(self, lines, raise_on_create=None): + self._lines = lines + self._raise = raise_on_create + + def create(self, values): + if self._raise: + raise RuntimeError(self._raise) + # Les lignes déjà purgées ne reviennent pas : find() les recalcule. + return FakeWizard([ln for ln in self._lines if ln.fails >= 0]) + + +class FakeCursor: + def __init__(self, journal): + self.journal = journal + + def savepoint(self): + journal = self.journal + + class Guard: + def __enter__(self_inner): + journal.append(("savepoint", "enter")) + return self_inner + + def __exit__(self_inner, exc_type, exc, tb): + journal.append( + ("savepoint", "rollback" if exc_type else "release") + ) + return False + + return Guard() + + def commit(self): + self.journal.append(("commit", None)) + + +class FakeEnv(dict): + def __init__(self, mapping, journal): + super().__init__(mapping) + self.cr = FakeCursor(journal) + + +def run_script(models, max_round=10, dry_run=False): + """Exécuter le script réellement poussé, et rendre (rapport, journal).""" + import json + + journal = [] + env = FakeEnv(models, journal) + namespace = {"env": env} + exec(cleanup.build_script(max_round, dry_run), namespace) # noqa: S102 + # Le script imprime le rapport entre deux sentinelles ; ici on le relit + # dans son espace de noms, ce qui teste la MÊME structure. + return json.loads(json.dumps(namespace["report"])), journal + + +class TestTheOrder(unittest.TestCase): + def test_the_requested_order_is_kept(self): + # Purger un modèle libère des colonnes : l'inverse ne marcherait pas. + self.assertEqual( + [kind for kind, _model in cleanup.ORDER], + [ + "models", + "modules", + "columns", + "tables", + "data", + "menus", + "indexes", + "properties", + ], + ) + + def test_indexes_come_after_the_purges(self): + kinds = [kind for kind, _ in cleanup.ORDER] + self.assertGreater(kinds.index("indexes"), kinds.index("tables")) + + def test_a_pass_visits_the_kinds_in_that_order(self): + models = { + model: FakeModel([FakeLine(f"{kind}-1")]) + for kind, model in cleanup.ORDER + } + report, _journal = run_script(models, max_round=1) + self.assertEqual( + [entry["kind"] for entry in report["rounds"][0]], + [kind for kind, _ in cleanup.ORDER], + ) + + +class TestOneEntryCannotSinkThePass(unittest.TestCase): + def test_each_entry_gets_its_own_savepoint(self): + # Sans cela, le premier refus emporterait tout ce que la passe avait + # déjà réparé. + journal = [] + lines = [ + FakeLine("a", journal=journal), + FakeLine("b", journal=journal), + ] + models = {cleanup.ORDER[0][1]: FakeModel(lines)} + _report, got = run_script(models, max_round=1) + self.assertEqual(got.count(("savepoint", "enter")), 2) + + def test_a_refusal_rolls_back_only_its_own(self): + journal = [] + lines = [ + FakeLine("ok1", journal=journal), + FakeLine("bad", fails=99, journal=journal), + FakeLine("ok2", journal=journal), + ] + models = {cleanup.ORDER[0][1]: FakeModel(lines)} + report, got = run_script(models, max_round=1) + entry = report["rounds"][0][0] + self.assertEqual(entry["purged"], 2) + self.assertEqual([name for name, _msg in entry["errors"]], ["bad"]) + self.assertEqual(got.count(("savepoint", "rollback")), 1) + self.assertEqual(got.count(("savepoint", "release")), 2) + + def test_a_wizard_that_cannot_even_be_created_is_recorded(self): + models = {cleanup.ORDER[0][1]: FakeModel([], raise_on_create="boom")} + report, _got = run_script(models, max_round=1) + self.assertEqual(report["failed"][0][0], "models") + self.assertIn("boom", report["failed"][0][2]) + + +class TestGoingRoundAgain(unittest.TestCase): + def test_what_one_pass_refused_the_next_may_take(self): + # LE point, et la raison même de boucler : une entrée refuse TANT QUE + # sa voisine est là. Une ligne qui guérirait toute seule n'existe pas + # — ce qui existe, c'est une dépendance qui tombe. + etat = {"table_partie": False} + + class Dependante(FakeLine): + def purge(self): + if not etat["table_partie"]: + raise RuntimeError("la table la retient encore") + self.fails = -1 + + class Liberatrice(FakeLine): + def purge(self): + etat["table_partie"] = True + self.fails = -1 + + modele_col, modele_tab = cleanup.ORDER[2][1], cleanup.ORDER[3][1] + models = { + modele_col: FakeModel([Dependante("colonne_liee")]), + modele_tab: FakeModel([Liberatrice("vieille_table")]), + } + report, _got = run_script(models, max_round=5) + # Passe 1 : la colonne refuse, la table part → du progrès, on continue. + self.assertEqual(report["rounds"][0][0]["purged"], 0) + self.assertEqual(report["rounds"][0][1]["purged"], 1) + # Passe 2 : la colonne cède, sa dépendance étant partie. + self.assertEqual(report["rounds"][1][0]["purged"], 1) + + def test_a_pass_that_repairs_nothing_ends_it(self): + # Rien n'a changé dans la base : une passe de plus rendrait le même + # refus. Boucler serait des minutes brûlées pour rien. + models = { + cleanup.ORDER[0][1]: FakeModel([FakeLine("never", fails=99)]) + } + report, _got = run_script(models, max_round=8) + self.assertEqual(len(report["rounds"]), 1) + self.assertEqual(len(report["rounds"][0][0]["errors"]), 1) + + def test_the_loop_stops_when_a_pass_repairs_nothing(self): + # Ce qui résistait au tour d'avant résistera encore : boucler + # jusqu'à max_round brûlerait des minutes pour rien. + models = { + cleanup.ORDER[0][1]: FakeModel([FakeLine("never", fails=99)]) + } + report, _got = run_script(models, max_round=8) + self.assertEqual(len(report["rounds"]), 1) + + def test_nothing_to_do_is_one_pass(self): + models = {cleanup.ORDER[0][1]: FakeModel([])} + report, _got = run_script(models, max_round=8) + self.assertEqual(len(report["rounds"]), 1) + + +class TestWhatIsAbsentIsSkipped(unittest.TestCase): + def test_a_missing_wizard_is_noted_not_fatal(self): + # `property` n'existe plus en 18.0 : Odoo y a remplacé ir.property + # par une colonne jsonb. Échouer dessus arrêterait le nettoyage sur + # une version où il n'a plus lieu d'être. + models = {cleanup.ORDER[0][1]: FakeModel([])} + report, _got = run_script(models, max_round=1) + self.assertIn("properties", report["missing"]) + self.assertIn("indexes", report["missing"]) + + def test_a_kind_is_noted_once(self): + models = {cleanup.ORDER[0][1]: FakeModel([FakeLine("x", fails=1)])} + report, _got = run_script(models, max_round=5) + self.assertEqual(report["missing"].count("properties"), 1) + + +class TestTheDryRun(unittest.TestCase): + def test_it_purges_nothing(self): + journal = [] + lines = [FakeLine("a", journal=journal)] + models = {cleanup.ORDER[0][1]: FakeModel(lines)} + report, got = run_script(models, max_round=5, dry_run=True) + self.assertEqual(journal, []) + self.assertNotIn(("commit", None), got) + self.assertEqual(report["rounds"][0][0]["would"], ["a"]) + + def test_it_does_a_single_pass(self): + # Rien ne change, donc rien ne se libère : boucler serait du vent. + models = {cleanup.ORDER[0][1]: FakeModel([FakeLine("a")])} + report, _got = run_script(models, max_round=9, dry_run=True) + self.assertEqual(len(report["rounds"]), 1) + + +class TestTheReport(unittest.TestCase): + def setUp(self): + from script.todo import todo_i18n + + self.addCleanup( + setattr, todo_i18n, "_current_lang", todo_i18n._current_lang + ) + todo_i18n._current_lang = "en" + + def test_leftovers_are_a_warning_not_a_failure(self): + report = { + "rounds": [ + [ + { + "kind": "models", + "purged": 3, + "errors": [["x", "held"]], + "would": [], + } + ] + ], + "missing": [], + "failed": [], + } + text = cleanup.render(report, "db") + self.assertIn("⚠️", text) + self.assertIn("not a failure", text) + + def test_all_clean_says_so(self): + report = { + "rounds": [ + [{"kind": "models", "purged": 3, "errors": [], "would": []}] + ], + "missing": [], + "failed": [], + } + self.assertIn("✅", cleanup.render(report, "db")) + + def test_a_dry_run_is_not_read_as_refusals(self): + # 586 candidats ne sont pas 586 refus : la première version les + # affichait comme des échecs. + report = { + "rounds": [ + [ + { + "kind": "columns", + "purged": 0, + "errors": [], + "would": ["a", "b"], + } + ] + ], + "missing": [], + "failed": [], + } + text = cleanup.render(report, "db") + self.assertIn("would be purged", text) + self.assertNotIn("⚠️", text) + + +class TestTheMigrationRunsItBeforeTheSmokeTest(unittest.TestCase): + """Interroger les pages sur une base encombrée fait chercher à côté.""" + + def source(self): + import inspect + + from script.todo.todo_upgrade import TodoUpgrade + + return inspect.getsource(TodoUpgrade.execute_odoo_upgrade) + + def test_it_runs_before_both_smoke_tests(self): + source = self.source() + self.assertEqual(source.count("prompt_database_cleanup"), 2) + for _ in range(2): + nettoyage = source.index("prompt_database_cleanup") + mesure = source.index("prompt_smoke_public_url") + self.assertLess(nettoyage, mesure) + source = source[mesure + 1 :] + + def test_it_gets_a_real_terminal(self): + import inspect + + from script.todo.todo_upgrade import TodoUpgrade + + source = inspect.getsource(TodoUpgrade.prompt_database_cleanup) + self.assertIn("run_on_terminal", source) + self.assertNotIn("todo_upgrade_execute", source) + + def test_the_default_cleans_nothing(self): + # Cela ÉCRIT en base : ce n'est pas à la migration de le décider. + import contextlib + import io + + from script.todo import todo_i18n + from script.todo.todo_upgrade import TodoUpgrade + + self.addCleanup( + setattr, todo_i18n, "_current_lang", todo_i18n._current_lang + ) + todo_i18n._current_lang = "en" + upgrade = TodoUpgrade.__new__(TodoUpgrade) + upgrade.dct_progression = {} + upgrade.lst_command_executed = [] + upgrade.write_config = lambda: None + lst_cmd = [] + upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0 + upgrade.ask_gate = lambda prompt: "" + with contextlib.redirect_stdout(io.StringIO()): + upgrade.prompt_database_cleanup("db") + self.assertEqual(lst_cmd, []) + + def test_yes_runs_it_on_that_database(self): + from script.todo.todo_upgrade import TodoUpgrade + + upgrade = TodoUpgrade.__new__(TodoUpgrade) + upgrade.dct_progression = {} + upgrade.lst_command_executed = [] + upgrade.write_config = lambda: None + lst_cmd = [] + upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0 + upgrade.ask_gate = lambda prompt: "y" + upgrade.prompt_database_cleanup("db_upgrade_18") + self.assertEqual(len(lst_cmd), 1) + self.assertIn("database_cleanup.py", lst_cmd[0]) + self.assertIn("-d db_upgrade_18", lst_cmd[0]) + + +class TestItRefusesTheWrongOdooVersion(unittest.TestCase): + """Un Odoo plus ancien sur une base plus récente ÉCRIT avant d'échouer.""" + + def test_a_mismatch_is_refused(self): + original_checkout = cleanup.checkout_version + original_db = cleanup.database_version + cleanup.checkout_version = lambda: "14.0" + cleanup.database_version = lambda database: "17.0" + self.addCleanup( + setattr, cleanup, "checkout_version", original_checkout + ) + self.addCleanup(setattr, cleanup, "database_version", original_db) + message = cleanup.require_matching_version("db") + self.assertIsNotNone(message) + self.assertIn("14.0", message) + self.assertIn("17.0", message) + + def test_a_match_passes(self): + original_checkout = cleanup.checkout_version + original_db = cleanup.database_version + cleanup.checkout_version = lambda: "17.0" + cleanup.database_version = lambda database: "17.0" + self.addCleanup( + setattr, cleanup, "checkout_version", original_checkout + ) + self.addCleanup(setattr, cleanup, "database_version", original_db) + self.assertIsNone(cleanup.require_matching_version("db")) + + def test_not_knowing_does_not_block(self): + # Refuser sur une supposition empêcherait de nettoyer là où c'est + # possible. + original_checkout = cleanup.checkout_version + cleanup.checkout_version = lambda: None + self.addCleanup( + setattr, cleanup, "checkout_version", original_checkout + ) + self.assertIsNone(cleanup.require_matching_version("db")) + + +if __name__ == "__main__": + unittest.main() From cb773948692be0b637599bc6e98a7093d2e89534 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 17 Aug 2026 17:35:31 -0400 Subject: [PATCH 31/67] [ADD] migration: an auto-run that takes the default after five seconds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A migration asks dozens of questions whose answer is almost always the one offered, and someone has to stand there pressing Enter for hours. Asked before the version choice — so it covers that choice too, now that it has a default — and off by default: it decides in your place. Every prompt of the migration goes through one reader, seventeen of them. A prompt left as a bare input() would know nothing of auto mode and would block the run with nothing to say why; a test reads the function and rejects any that appears. click.prompt had to go: it cannot hand back control after a delay, so auto would have stopped at the first question. select() rather than a thread or an alarm — a thread would leave a blocked input() behind it, stealing the next keystroke. --- FR --- [ADD] migration : une auto-exécution qui prend le défaut après cinq secondes Une migration pose des dizaines de questions dont la réponse est presque toujours celle proposée, et il faut rester devant à taper Entrée pendant des heures. Posée avant le choix de version — donc elle vaut aussi pour lui, maintenant qu'il a un défaut — et éteinte par défaut : elle décide à votre place. Toutes les invites de la migration passent par une seule lecture, dix-sept d'un coup. Une invite laissée en input() nu ne saurait rien du mode auto et bloquerait la migration sans rien dire ; un test lit la fonction et refuse toute apparition. click.prompt devait partir : il ne sait pas rendre la main après un délai, l'auto se serait arrêté à la première question. select() plutôt qu'un fil ou une alarme — un fil laisserait un input() bloqué derrière lui, qui volerait la frappe suivante. Assisted-by: Claude Opus 5 --- script/todo/todo_i18n.py | 20 +++ script/todo/todo_upgrade.py | 108 +++++++++---- test/test_auto_execute.py | 225 ++++++++++++++++++++++++++++ test/test_target_version_default.py | 37 +++-- 4 files changed, 353 insertions(+), 37 deletions(-) create mode 100755 test/test_auto_execute.py diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 3284524..70b1f72 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -5246,6 +5246,26 @@ TRANSLATIONS = { "fr": "Nettoyer la base avant de tester les pages ?", "en": "Clean the database before testing the pages?", }, + "Auto-run: take the default answer after": { + "fr": "Auto-exécution : prendre la réponse par défaut après", + "en": "Auto-run: take the default answer after", + }, + "seconds?": { + "fr": "secondes ?", + "en": "seconds?", + }, + "Auto-run on: each prompt waits": { + "fr": "Auto-exécution activée : chaque invite attend", + "en": "Auto-run on: each prompt waits", + }, + "seconds, then takes its default.": { + "fr": "secondes, puis prend son défaut.", + "en": "seconds, then takes its default.", + }, + "(default)": { + "fr": "(défaut)", + "en": "(default)", + }, "Nothing to decide yet": { "fr": "Rien à décider pour l'instant", "en": "Nothing to decide yet", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index b1149d3..7aec353 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -13,7 +13,6 @@ import sys import zipfile from uuid import uuid4 -import click from script.todo import todo_file_browser from script.todo.version_manager import get_odoo_version @@ -761,6 +760,61 @@ class TodoUpgrade: ] return dct_kept + AUTO_DELAY = 5 + + def prompt_auto_execute(self): + """Proposer que les invites prennent leur défaut après un délai. + + Une migration pose des dizaines de questions dont la réponse est + presque toujours celle proposée. Les enchaîner à la main immobilise + quelqu'un pendant des heures pour taper Entrée. + + Posée AVANT le choix de version, donc avant la première décision : + activée, elle vaut pour toutes les suivantes, celle-là comprise. + """ + answer = ( + input( + f"💬 {t('Auto-run: take the default answer after')}" + f" {self.AUTO_DELAY} {t('seconds?')} (y/N) : " + ) + .strip() + .lower() + ) + self.auto_execute = answer == "y" + if self.auto_execute: + print( + f"⏱ {t('Auto-run on: each prompt waits')}" + f" {self.AUTO_DELAY} {t('seconds, then takes its default.')}" + ) + + def ask(self, prompt, default=""): + """Lire une réponse ; en mode auto, rendre le défaut après le délai. + + `select` plutôt qu'un fil ou une alarme : on veut savoir si quelque + chose est LISIBLE, et rendre la main sinon. Un fil laisserait un + `input()` bloqué derrière lui, qui volerait la frappe suivante. + """ + if not getattr(self, "auto_execute", False): + return input(prompt) + import select + + sys.stdout.write(prompt) + sys.stdout.flush() + try: + ready, _, _ = select.select([sys.stdin], [], [], self.AUTO_DELAY) + except (OSError, ValueError): + # stdin n'est pas sélectionnable : on ne devine pas, on demande. + return input("") + if ready: + answer = sys.stdin.readline().rstrip("\n") + # Une réponse VIDE vaut « prends le défaut » : c'est tout le + # propos du mode auto. Un stdin fermé — exécution non + # interactive — est lisible tout de suite et rend justement + # une ligne vide ; sans ceci, le défaut ne servirait jamais là. + return answer or default + print(f" ⏱ → {default or t('(default)')}") + return default + def ask_gate(self, prompt): """Une invite d'attente, avec une porte de sortie vers l'arrière. @@ -771,7 +825,7 @@ class TodoUpgrade: il rembobine l'état, l'écrit, et s'arrête en disant quoi relancer. """ while True: - answer = input(prompt) + answer = self.ask(prompt) if (answer or "").strip().lower() != "b": return answer if self.rewind_to_chosen_step(): @@ -1161,6 +1215,7 @@ class TodoUpgrade: # TODO Redeploy new production after upgrade # 2 upgrades version = 5 environnement. 0-prod init, 1-dev init, 2-dev01, 3-dev02, 4-prod final print(t("Welcome to the Odoo database upgrade with ERPLibre") + " 🤖") + self.prompt_auto_execute() self.lst_command_executed = [] self.dct_module_per_version = {} self.dct_module_per_dct_version_path = {} @@ -1197,7 +1252,7 @@ class TodoUpgrade: print("") print(t("Select the zip file of your database backup.")) - self.file_path = input( + self.file_path = self.ask( f"💬 {t('Give the path of the file, or empty to use a file')}" f" {t('browser, or type')} 'remote'" f" {t('to download from production')} : " @@ -1283,14 +1338,17 @@ class TodoUpgrade: odoo_target_version = None cmd_no_found = True while cmd_no_found: - # click affiche « [6] » et rend « 6 » sur Entrée : le chemin - # normal traite la réponse, sans cas particulier à tenir - # d'accord avec lui. - status = click.prompt( - help_info, + # `self.ask` et non click.prompt : c'est lui qui porte le + # mode auto, et click ne sait pas rendre la main après un + # délai — l'auto se serait arrêté à la première question. + # La marque « [6] » est donc posée à la main. + marque = f" [{default_index}]" if default_index else "" + status = self.ask( + f"{help_info}{marque}: ", default=str(default_index) if default_index else "", - show_default=bool(default_index), ) + if not str(status).strip() and default_index: + status = str(default_index) try: int_cmd = int(status) if 0 < int_cmd <= len(lst_odoo_version): @@ -1353,7 +1411,7 @@ class TodoUpgrade: iter_range_version = odoo_version_to_install.replace( "odoo", "" ).replace(".0", "") - want_continue = input( + want_continue = self.ask( f"💬 {t('Would you like to install')}" f" '{odoo_version_to_install}' (y/Y) : " ) @@ -1426,7 +1484,7 @@ class TodoUpgrade: if lst_module_duplicate: print(f"{t('Duplicate module')} :") print(lst_module_duplicate) - want_continue = input( + want_continue = self.ask( f"💬 {t('Missing or duplicate module detected at init,')}" f" {t('do you want to continue?')} (Y/N) : " ) @@ -1453,7 +1511,7 @@ class TodoUpgrade: database_name = self.dct_progression.get("config_database_name") if not database_name: database_name = ( - input( + self.ask( f"💬 {t('Which database name do you want to work with?')}" f" {t('Default')} ({default_database_name}) : " ).strip() @@ -1492,7 +1550,7 @@ class TodoUpgrade: if not shutil._samefile(self.file_path, image_db_file_path): do_copy = False if os.path.exists(image_db_file_path): - status_overwrite_image_db = input( + status_overwrite_image_db = self.ask( f"{str_will_copy}, " f"{t('a file already exists, do you want to')}" f" {t('continue?')} (y/Y) : " @@ -1528,7 +1586,7 @@ class TodoUpgrade: f" {t('neutralized by Odoo if supported)')}" ) wait_continue = ( - input( + self.ask( f"💬 {t('Do you need to upgrade before neutralizing the')}" f" {t('database? Press enter to ignore')} : " ) @@ -1731,7 +1789,7 @@ class TodoUpgrade: if status.lower().strip() == "y": self.todo.prompt_execute_selenium_and_run_db(database_name) - status = input( + status = self.ask( f"💬 {t('Press enter to continue step 3')} : " ).strip() @@ -2060,7 +2118,7 @@ class TodoUpgrade: if lst_module_duplicate: print(f"{t('Duplicate module in Odoo')}{next_version} : ") print(lst_module_duplicate) - input( + self.ask( f"💬 {t('Duplicate module error detected, handle it')}" f" {t('manually then press enter to continue.')}" ) @@ -2085,7 +2143,7 @@ class TodoUpgrade: print(f"[e] {t('Add an extra custom one')}") want_continue = ( - input( + self.ask( f"💬 {t('List the missing modules to delete,')}" f" {t('separated by commas. The others will be')}" f" {t('migrated')} : " @@ -2149,7 +2207,7 @@ class TodoUpgrade: if "e" in lst_want_continue: want_continue = ( - input( + self.ask( f"💬 {t('List the module names to delete,')}" f" {t('separated by commas')} : " ) @@ -2577,7 +2635,7 @@ class TodoUpgrade: ) status = ( - input( + self.ask( f"💬 {t('Do you want to upgrade all')}" f"{str_wait_next_version} ?" f" {t('Press y/Y to upgrade all addons of the')}" @@ -2610,7 +2668,7 @@ class TodoUpgrade: print(f"[y] {t('Open the server with Selenium')}") status = ( - input( + self.ask( f"💬 {t('Do you want to test this upgrade? Choose')}" f" {t('or press enter to ignore it')} : " ) @@ -2622,7 +2680,7 @@ class TodoUpgrade: self.todo.prompt_execute_selenium_and_run_db( database_name_upgrade ) - status = input( + status = self.ask( f"💬 {t('Press enter to continue')} 4.{index} : " ).strip() else: @@ -2632,7 +2690,7 @@ class TodoUpgrade: ) # - # waiting_input = input("💬 Press any keyboard key to continue...") + # waiting_input = self.ask("💬 Press any keyboard key to continue...") print("") msg = "5 - Cleaning up database after upgrade" @@ -2643,7 +2701,7 @@ class TodoUpgrade: f"✨ {t('Re-update i18n, purge the data and the tables')}" f" ({t('except mail_test and mail_test_full')})" ) - # waiting_input = input("💬print Press any keyboard key to continue...") + # waiting_input = self.ask("💬print Press any keyboard key to continue...") msg = "6 - Migration finished" self.print_step(msg) self.add_comment_progression(msg) @@ -2651,7 +2709,7 @@ class TodoUpgrade: cmd_backup_template = f"./odoo_bin.sh db --backup --database {database_name_upgrade} --restore_image" cmd_backup = f"{cmd_backup_template} {database_name_upgrade}_finish_{datetime.datetime.now().strftime('%Y%m%d%H%M%S')}" print(f"✨ {t('A backup can be created')} :\n{cmd_backup}") - status = input( + status = self.ask( f"💬 {t('Press y/Y or type filename.zip to export, or')}" f" {t('enter to continue')} : " ).strip() @@ -2660,7 +2718,7 @@ class TodoUpgrade: cmd_backup = f"{cmd_backup_template} {status}" self.todo_upgrade_execute(cmd_backup) - status = input(f"💬 {t('Test the migration, press y/Y')} : ") + status = self.ask(f"💬 {t('Test the migration, press y/Y')} : ") if status.lower().strip() == "y": self.todo.prompt_execute_selenium_and_run_db(database_name_upgrade) diff --git a/test/test_auto_execute.py b/test/test_auto_execute.py new file mode 100755 index 0000000..13cdf17 --- /dev/null +++ b/test/test_auto_execute.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Enchaîner la migration sans rester devant à taper Entrée. + +Une migration pose des dizaines de questions dont la réponse est presque +toujours celle proposée. Le mode auto attend cinq secondes puis prend le +défaut — assez pour reprendre la main, assez peu pour ne pas immobiliser +quelqu'un des heures. + +La question est posée AVANT le choix de version, donc avant la première +décision : activée, elle vaut pour toutes les suivantes, celle-là comprise. + +Le test qui compte le plus est le dernier : une invite écrite plus tard en +`input()` nu ne saurait rien du mode auto, et bloquerait la migration sans +que rien ne le signale. +""" + +import ast +import inspect +import io +import os +import sys +import unittest + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +from script.todo import todo_i18n # noqa: E402 +from script.todo.todo_upgrade import TodoUpgrade # noqa: E402 + + +def upgrade(auto, delay=0.2): + obj = TodoUpgrade.__new__(TodoUpgrade) + obj.auto_execute = auto + obj.AUTO_DELAY = delay + return obj + + +class TestTheTimedRead(unittest.TestCase): + def setUp(self): + self.original = sys.stdin + self.addCleanup(setattr, sys, "stdin", self.original) + + def pipe_stdin(self, payload=b""): + """Un vrai descripteur : `select` ne sait pas lire un faux objet.""" + read_fd, write_fd = os.pipe() + if payload: + os.write(write_fd, payload) + os.close(write_fd) + handle = os.fdopen(read_fd) + self.addCleanup(handle.close) + sys.stdin = handle + + def test_without_auto_it_just_asks(self): + import builtins + + original = builtins.input + builtins.input = lambda prompt="": "tapé" + self.addCleanup(setattr, builtins, "input", original) + self.assertEqual(upgrade(False).ask("q : ", default="6"), "tapé") + + def test_with_auto_an_answer_wins(self): + self.pipe_stdin(b"2\n") + out = io.StringIO() + real = sys.stdout + sys.stdout = out + try: + got = upgrade(True).ask("q : ", default="6") + finally: + sys.stdout = real + self.assertEqual(got, "2") + + def test_a_closed_stdin_gives_the_default(self): + # Exécution non interactive : le tuyau fermé est lisible TOUT DE + # SUITE et rend une ligne vide. Sans traiter le vide comme le + # défaut, le mode auto n'y servirait à rien. + self.pipe_stdin() + out = io.StringIO() + real = sys.stdout + sys.stdout = out + try: + got = upgrade(True).ask("q : ", default="6") + finally: + sys.stdout = real + self.assertEqual(got, "6") + + def test_silence_gives_the_default_after_the_delay(self): + # Le tuyau reste OUVERT : rien à lire, et select doit rendre la main + # au bout du délai plutôt que d'attendre indéfiniment. + import time + + read_fd, write_fd = os.pipe() + self.addCleanup(os.close, write_fd) + handle = os.fdopen(read_fd) + self.addCleanup(handle.close) + sys.stdin = handle + out = io.StringIO() + real = sys.stdout + sys.stdout = out + debut = time.time() + try: + got = upgrade(True, delay=0.3).ask("q : ", default="6") + finally: + sys.stdout = real + self.assertEqual(got, "6") + self.assertGreaterEqual(time.time() - debut, 0.25) + + def test_an_unselectable_stdin_falls_back_to_asking(self): + # Ne pas deviner quand on ne peut pas mesurer : mieux vaut demander + # que rendre un défaut que personne n'a voulu. + import builtins + + class NotSelectable: + def fileno(self): + raise ValueError("pas de descripteur") + + sys.stdin = NotSelectable() + original = builtins.input + builtins.input = lambda prompt="": "demandé" + self.addCleanup(setattr, builtins, "input", original) + out = io.StringIO() + real = sys.stdout + sys.stdout = out + try: + got = upgrade(True).ask("q : ", default="6") + finally: + sys.stdout = real + self.assertEqual(got, "demandé") + + +class TestTheQuestionThatEnablesIt(unittest.TestCase): + def setUp(self): + self.addCleanup( + setattr, todo_i18n, "_current_lang", todo_i18n._current_lang + ) + todo_i18n._current_lang = "en" + + def answer(self, typed): + import builtins + import contextlib + + obj = TodoUpgrade.__new__(TodoUpgrade) + original = builtins.input + builtins.input = lambda prompt="": typed + self.addCleanup(setattr, builtins, "input", original) + out = io.StringIO() + with contextlib.redirect_stdout(out): + obj.prompt_auto_execute() + return obj.auto_execute, out.getvalue() + + def test_yes_turns_it_on(self): + on, text = self.answer("y") + self.assertTrue(on) + self.assertIn("Auto-run on", text) + + def test_the_default_is_off(self): + # Elle prend des décisions à votre place : ce n'est pas le défaut. + on, text = self.answer("") + self.assertFalse(on) + self.assertEqual(text, "") + + def test_anything_else_is_off(self): + self.assertFalse(self.answer("peut-être")[0]) + + def test_the_delay_is_five_seconds(self): + self.assertEqual(TodoUpgrade.AUTO_DELAY, 5) + + +class TestWhereItIsAsked(unittest.TestCase): + def source(self): + return inspect.getsource(TodoUpgrade.execute_odoo_upgrade) + + def test_it_comes_before_the_version_choice(self): + # « avant de choisir la version d'Odoo » : activée, elle vaut aussi + # pour ce choix-là, qui a désormais un défaut. + source = self.source() + self.assertLess( + source.index("prompt_auto_execute"), + source.index("Which version do you want to upgrade to?"), + ) + + def test_the_version_choice_goes_through_it(self): + # click.prompt ne sait pas rendre la main après un délai : le laisser + # là aurait fait un mode auto qui s'arrête à la première question. + source = self.source() + self.assertNotIn("click.prompt(", source) + + +class TestNoPromptEscapesIt(unittest.TestCase): + """Une invite en `input()` nu ne saurait rien du mode auto. + + Elle bloquerait la migration sans rien signaler — et c'est précisément + le genre de chose qu'on n'ajoute pas exprès, mais par habitude. + """ + + def test_every_prompt_of_the_migration_uses_ask(self): + path = os.path.join(REPO, "script", "todo", "todo_upgrade.py") + with open(path) as handle: + tree = ast.parse(handle.read()) + target = [ + node + for node in ast.walk(tree) + if isinstance(node, ast.FunctionDef) + and node.name == "execute_odoo_upgrade" + ] + self.assertEqual(len(target), 1, "fonction introuvable") + bare = [ + node.lineno + for node in ast.walk(target[0]) + if isinstance(node, ast.Call) + and getattr(node.func, "id", "") == "input" + ] + self.assertEqual(bare, [], "invites hors du mode auto") + + def test_the_gate_uses_it_too(self): + # `ask_gate` porte le retour en arrière : la laisser en input() nu + # aurait fait un mode auto qui s'arrête à chaque « b = revenir ». + source = inspect.getsource(TodoUpgrade.ask_gate) + self.assertIn("self.ask(", source) + self.assertNotIn("= input(", source) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_target_version_default.py b/test/test_target_version_default.py index 5be81d1..0abedc4 100755 --- a/test/test_target_version_default.py +++ b/test/test_target_version_default.py @@ -29,9 +29,13 @@ from script.todo.todo_upgrade import TodoUpgrade # noqa: E402 def prompt_call(source): - """Le voisinage de l'appel à click.prompt, où se joue le défaut.""" - start = source.index("click.prompt(") - return source[start : start + 240] + """Le voisinage de l'invite de version, où se joue le défaut. + + Ce n'est plus click.prompt : lui ne sait pas rendre la main après un + délai, et le mode auto se serait arrêté à la première question. + """ + start = source.index("Which version do you want to upgrade to?") + return source[start : start + 1400] class TestTheSource(unittest.TestCase): @@ -44,12 +48,18 @@ class TestTheSource(unittest.TestCase): def source(self): return inspect.getsource(TodoUpgrade.execute_odoo_upgrade) - def test_click_receives_a_default(self): - # Sans lui, Entrée n'atteint aucun code : click redemande, muet. + def test_the_prompt_receives_a_default(self): window = prompt_call(self.source()) self.assertIn("default=", window) self.assertIn("default_index", window) + def test_it_goes_through_the_auto_aware_reader(self): + # `self.ask` porte le mode auto ; click.prompt bloquerait pour + # toujours sur une invite que personne ne vient servir. + window = prompt_call(self.source()) + self.assertIn("self.ask(", window) + self.assertNotIn("click.prompt(", window) + def test_the_default_is_not_written_by_hand(self): window = prompt_call(self.source()) self.assertNotIn('default="6"', window) @@ -63,10 +73,11 @@ class TestTheSource(unittest.TestCase): self.assertIn("range(len(lst_odoo_version))", source) def test_an_empty_catalogue_shows_no_default(self): - # show_default sur une chaîne vide afficherait « [] », qui se lit - # comme un choix possible. + # Afficher « [] » se lirait comme un choix possible : la marque + # n'est posée que s'il y a vraiment un défaut. self.assertIn( - "show_default=bool(default_index)", prompt_call(self.source()) + 'marque = f" [{default_index}]" if default_index else ""', + prompt_call(self.source()), ) def test_the_question_announces_the_version_not_the_rank(self): @@ -75,10 +86,12 @@ class TestTheSource(unittest.TestCase): self.assertIn("Enter =", source) self.assertIn("default_version", source) - def test_no_special_case_survives(self): - # click rendant « 6 », le chemin normal suffit. Un cas particulier - # de plus serait un second comportement à maintenir. - self.assertNotIn("if not str(status).strip()", self.source()) + def test_an_empty_answer_still_takes_the_default(self): + # click rendait « 6 » lui-même ; `self.ask` ne le fait qu'en mode + # AUTO. Hors mode auto, Entrée rend une chaîne vide, et sans ce cas + # le défaut ne servirait que la moitié du temps. + window = prompt_call(self.source()) + self.assertIn("if not str(status).strip()", window) class TestTheRankItself(unittest.TestCase): From 0c0ed07aa81c20eb073262c6f46f3a3d135e8d79 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 17 Aug 2026 17:45:04 -0400 Subject: [PATCH 32/67] [FIX] migration: the cleanup installs its own module, and survives a refusal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects, all mine, all found on a real run. Creating a wizard could fail and leave the transaction ABORTED. The next name read died on it, outside any guard, and the whole script stopped with no report at all — only a traceback. Creating and reading the names now happen inside the savepoint, and a report is printed whatever happens: knowing what was done matters more than the trace of what broke. « No orphaned models found » is a UserError: the module signals the EMPTY by raising. Counting it as a failure made a healthy database look broken, four warnings out of five kinds. The module was installed at step 3, after being used at step 2, so the first run had no wizard at all and answered « nothing to do ». It is now installed by the tool itself, and step 3 no longer asks to redo by hand what has just been done automatically. --- FR --- [FIX] migration : le nettoyage pose son module, et survit à un refus Trois défauts, tous à moi, tous trouvés sur une vraie exécution. Créer un assistant pouvait échouer en laissant la transaction AVORTÉE. La lecture de nom suivante mourait dessus, hors de tout garde, et le script s'arrêtait sans aucun rapport — juste une trace. La création et la lecture des noms sont désormais dans le point de reprise, et un rapport est imprimé quoi qu'il arrive : savoir ce qui a été fait vaut mieux que la trace de ce qui a cassé. « No orphaned models found » est une UserError : le module signale le VIDE en levant. Le compter comme un échec faisait passer une base saine pour cassée, quatre avertissements sur cinq catégories. Le module était installé à l'étape 3, après avoir servi à l'étape 2 : le premier passage n'avait donc aucun assistant et répondait « rien à faire ». L'outil le pose lui-même, et l'étape 3 ne demande plus de refaire à la main ce qui vient d'être fait. Assisted-by: Claude Opus 5 --- script/odoo/migration/database_cleanup.py | 175 ++++++++++++++++----- script/todo/todo_i18n.py | 12 ++ script/todo/todo_upgrade.py | 30 +--- test/test_database_cleanup.py | 177 +++++++++++++++++++++- 4 files changed, 329 insertions(+), 65 deletions(-) diff --git a/script/odoo/migration/database_cleanup.py b/script/odoo/migration/database_cleanup.py index 4e6f7fd..4bdaa65 100755 --- a/script/odoo/migration/database_cleanup.py +++ b/script/odoo/migration/database_cleanup.py @@ -79,53 +79,90 @@ END = "ERPLIBRE_CLEANUP_END" SHELL_SCRIPT = """ import json + +try: + from odoo.exceptions import UserError +except Exception: + class UserError(Exception): + pass + ORDER = %(order)r MAX_ROUND = %(max_round)d DRY_RUN = %(dry_run)s report = {"rounds": [], "missing": [], "failed": []} -for index in range(MAX_ROUND): - this_round = [] - purged_this_round = 0 - for label, model in ORDER: - if model not in env: - if label not in report["missing"]: - report["missing"].append(label) - continue - ok = 0 - errors = [] - would = [] - try: - wizard = env[model].create({}) - lines = wizard.purge_line_ids - except Exception as exc: - report["failed"].append([label, "-", str(exc)[:200]]) - continue - for line in lines: - name = line.name or str(line.id) - if DRY_RUN: - would.append(name) + + +def note(label, name, exc): + report["failed"].append([label, name, str(exc)[:200]]) + + +try: + for index in range(MAX_ROUND): + this_round = [] + purged_this_round = 0 + for label, model in ORDER: + if model not in env: + if label not in report["missing"]: + report["missing"].append(label) continue + ok = 0 + errors = [] + would = [] try: - # Un point de reprise par ENTRÉE : un refus n'emporte que la - # sienne, et la passe continue. Sans cela, le premier échec - # ferait perdre tout ce que la passe avait réparé. + # La CRÉATION et la lecture des noms sont dans le point de + # reprise, pas seulement la purge. Un échec ici laissait la + # transaction avortée ; la lecture suivante mourait dessus, + # hors de tout garde, et le rapport entier était perdu. + # Les noms sont matérialisés tout de suite : après un retour + # arrière, les relire relancerait une requête. with env.cr.savepoint(): - line.purge() - ok += 1 + wizard = env[model].create({}) + todo = [ + (line, line.name or str(line.id)) + for line in wizard.purge_line_ids + ] + except UserError: + # « Aucun modèle orphelin trouvé » : le module signale le + # VIDE par une exception. Le compter comme un échec faisait + # passer une base saine pour une base cassée. + this_round.append({"kind": label, "purged": 0, + "errors": [], "would": []}) + continue except Exception as exc: - errors.append([name, str(exc)[:160]]) - if not DRY_RUN and ok: - env.cr.commit() - purged_this_round += ok - this_round.append({"kind": label, "purged": ok, - "errors": errors, "would": would}) - report["rounds"].append(this_round) - # On s'arrête quand une passe ENTIÈRE n'a plus rien réparé : ce qui - # résistait au tour d'avant résistera encore. En simulation, une seule - # passe suffit — rien ne change, donc rien ne se libère. - if purged_this_round == 0 or DRY_RUN: - break + note(label, "-", exc) + continue + for line, name in todo: + if DRY_RUN: + would.append(name) + continue + try: + # Un point de reprise par ENTRÉE : un refus n'emporte que + # la sienne, et la passe continue. Sans cela, le premier + # échec ferait perdre tout ce que la passe avait réparé. + with env.cr.savepoint(): + line.purge() + ok += 1 + except Exception as exc: + errors.append([name, str(exc)[:160]]) + if not DRY_RUN and ok: + try: + env.cr.commit() + except Exception as exc: + note(label, "commit", exc) + purged_this_round += ok + this_round.append({"kind": label, "purged": ok, + "errors": errors, "would": would}) + report["rounds"].append(this_round) + # On s'arrête quand une passe ENTIÈRE n'a plus rien réparé : ce qui + # résistait au tour d'avant résistera encore. En simulation, une + # seule passe suffit — rien ne change, donc rien ne se libère. + if purged_this_round == 0 or DRY_RUN: + break +except Exception as exc: + # Le rapport de CE QUI A ÉTÉ FAIT vaut plus que la trace de ce qui a + # cassé : sans lui, on ne sait même pas si la base a été touchée. + note("*", "fatal", exc) print("%(start)s") print(json.dumps(report)) @@ -200,6 +237,47 @@ def require_matching_version(database): return None +def module_state(database, module="database_cleanup"): + """L'état du module dans cette base, ou None si on ne peut pas lire.""" + env = os.environ.copy() + env["PGOPTIONS"] = "-c default_transaction_read_only=on" + env["PSQLRC"] = "" + done = subprocess.run( + [ + "psql", + "-X", + "-w", + "-d", + database, + "-tAc", + f"SELECT state FROM ir_module_module WHERE name='{module}';", + ], + capture_output=True, + text=True, + env=env, + ) + if done.returncode: + return None + return done.stdout.strip() or None + + +def install_module(database, module="database_cleanup", timeout=1800): + """Poser le module avant de s'en servir. + + Sans lui, aucun assistant n'existe et l'outil rend « rien à faire » sur + une base qui en aurait eu besoin — un silence qu'on prend pour un + succès. La migration l'installait plus tard, à l'étape 3 ; l'attendre + revenait à nettoyer trop tard. + """ + done = subprocess.run( + ["./script/addons/install_addons.sh", database, module], + capture_output=True, + text=True, + timeout=timeout, + ) + return done.returncode, done.stdout + done.stderr + + def run_shell(database, config_path, script, timeout=3600): """Pousser le script dans « odoo-bin shell » et rendre son rapport. @@ -267,6 +345,15 @@ def render(report, database): by_kind[kind] = by_kind.get(kind, 0) + 1 for kind, count in by_kind.items(): lines.append(f" - {t(LABEL.get(kind, kind))} : {count}") + # Les échecs comptent AUSSI en simulation : une catégorie qui ne + # s'ouvre même pas est une information, pas un silence. + for kind, name, message in report.get("failed", []): + lines.append(f" ⚠ [{kind}] {name} : {message[:90]}") + for kind in report.get("missing", []): + lines.append( + f" ℹ {t(LABEL.get(kind, kind))} :" + f" {t('no such wizard in this version, skipped.')}" + ) return "\n".join(lines) + "\n" total = 0 for index, this_round in enumerate(report.get("rounds", []), start=1): @@ -333,6 +420,18 @@ def main(argv=None): print(f"⛔ {mismatch}") return 2 + state = module_state(config.database) + if state != "installed": + print( + f"⧖ {t('database_cleanup is not installed on this base;')}" + f" {t('installing it first.')}" + ) + code, output = install_module(config.database) + if code: + print(output.strip()[-1500:]) + print(f"❌ {t('Could not install database_cleanup.')}") + return 2 + print(f"⧖ {t('Cleaning')} '{config.database}'…") try: report = run_shell( diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 70b1f72..d24cee4 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -5266,6 +5266,18 @@ TRANSLATIONS = { "fr": "(défaut)", "en": "(default)", }, + "database_cleanup is not installed on this base;": { + "fr": "database_cleanup n'est pas installé sur cette base ;", + "en": "database_cleanup is not installed on this base;", + }, + "installing it first.": { + "fr": "installation préalable.", + "en": "installing it first.", + }, + "Could not install database_cleanup.": { + "fr": "Impossible d'installer database_cleanup.", + "en": "Could not install database_cleanup.", + }, "Nothing to decide yet": { "fr": "Rien à décider pour l'instant", "en": "Nothing to decide yet", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index 7aec353..123104e 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -1767,32 +1767,12 @@ class TodoUpgrade: self.print_step(msg) self.add_comment_progression(msg) - if not self.dct_progression.get("state_3_install_clean_database"): - status, cmd_executed = self.todo_upgrade_execute( - f"./script/addons/install_addons.sh {database_name} database_cleanup", - single_source_odoo=True, - ) - if not status: - self.dct_progression["state_3_install_clean_database"] = True - self.write_config() - + # Le nettoyage est fait à l'étape 2, par l'outil, et il pose lui-même + # le module s'il manque. Redemander ici « avez-vous fini de nettoyer + # dans l'interface ? » ferait faire à la main ce qui vient d'être + # fait — et l'installation qui suivait arrivait APRÈS l'usage. if not self.dct_progression.get("state_3_clean_database"): - print( - f"✨ {t('Go to Settings / Technical / Cleanup... / Purge and')}" - f" {t('purge the obsolete modules')}" - ) - status = self.ask_gate( - "💬 Did you finish to clean database? Press y/Y to open" - " server with selenium, else ignore it" - f" {t('(b = go back to a previous step)')} : " - ).strip() - - if status.lower().strip() == "y": - self.todo.prompt_execute_selenium_and_run_db(database_name) - status = self.ask( - f"💬 {t('Press enter to continue step 3')} : " - ).strip() - + self.dct_progression["state_3_install_clean_database"] = True self.dct_progression["state_3_clean_database"] = True self.write_config() diff --git a/test/test_database_cleanup.py b/test/test_database_cleanup.py index 37d2587..f57ca3f 100755 --- a/test/test_database_cleanup.py +++ b/test/test_database_cleanup.py @@ -89,6 +89,39 @@ class FakeEnv(dict): self.cr = FakeCursor(journal) +class UserError(Exception): + """Celle que le script poussé importera : on fournit odoo.exceptions. + + Refaire une classe de son côté ne servirait à rien — « except » compare + des identités, pas des noms, et le test passerait à côté. + """ + + +def install_fake_odoo_exceptions(case): + """Rendre `from odoo.exceptions import UserError` possible ici.""" + import types + + odoo = sys.modules.get("odoo") or types.ModuleType("odoo") + exceptions = types.ModuleType("odoo.exceptions") + exceptions.UserError = UserError + avant_odoo = sys.modules.get("odoo") + avant_exc = sys.modules.get("odoo.exceptions") + sys.modules["odoo"] = odoo + sys.modules["odoo.exceptions"] = exceptions + + def remettre(): + for nom, valeur in ( + ("odoo", avant_odoo), + ("odoo.exceptions", avant_exc), + ): + if valeur is None: + sys.modules.pop(nom, None) + else: + sys.modules[nom] = valeur + + case.addCleanup(remettre) + + def run_script(models, max_round=10, dry_run=False): """Exécuter le script réellement poussé, et rendre (rapport, journal).""" import json @@ -146,7 +179,9 @@ class TestOneEntryCannotSinkThePass(unittest.TestCase): ] models = {cleanup.ORDER[0][1]: FakeModel(lines)} _report, got = run_script(models, max_round=1) - self.assertEqual(got.count(("savepoint", "enter")), 2) + # Un point de reprise par entrée, PLUS un pour la création et la + # lecture des noms : c'est là que la transaction s'était avortée. + self.assertEqual(got.count(("savepoint", "enter")), 3) def test_a_refusal_rolls_back_only_its_own(self): journal = [] @@ -161,7 +196,8 @@ class TestOneEntryCannotSinkThePass(unittest.TestCase): self.assertEqual(entry["purged"], 2) self.assertEqual([name for name, _msg in entry["errors"]], ["bad"]) self.assertEqual(got.count(("savepoint", "rollback")), 1) - self.assertEqual(got.count(("savepoint", "release")), 2) + # Deux entrées purgées + la création : trois relâchements. + self.assertEqual(got.count(("savepoint", "release")), 3) def test_a_wizard_that_cannot_even_be_created_is_recorded(self): models = {cleanup.ORDER[0][1]: FakeModel([], raise_on_create="boom")} @@ -170,6 +206,55 @@ class TestOneEntryCannotSinkThePass(unittest.TestCase): self.assertIn("boom", report["failed"][0][2]) +class TestTheReportSurvivesAnything(unittest.TestCase): + """Sans rapport, on ne sait même pas si la base a été touchée. + + Vécu : `create({})` échouait, l'erreur était notée mais la transaction + restait AVORTÉE. La lecture de nom suivante mourait dessus, hors de tout + garde, et le script entier s'arrêtait — aucun rapport, juste une trace. + """ + + def test_reading_the_names_is_inside_the_savepoint(self): + # C'est la lecture des noms qui déclenche la requête, pas la + # création : la laisser dehors était le défaut. + source = cleanup.build_script(1, False) + creation = source.index("wizard = env[model].create({})") + garde = source.rindex("with env.cr.savepoint():", 0, creation) + noms = source.index("line.name or str(line.id)") + self.assertLess(garde, creation) + self.assertLess(creation, noms) + + def test_a_failure_on_create_does_not_kill_the_run(self): + models = { + cleanup.ORDER[0][1]: FakeModel([], raise_on_create="boom"), + cleanup.ORDER[2][1]: FakeModel([FakeLine("colonne")]), + } + report, _got = run_script(models, max_round=1) + # La catégorie suivante a bien travaillé malgré l'échec de la + # première. + purged = {e["kind"]: e["purged"] for e in report["rounds"][0]} + self.assertEqual(purged.get("columns"), 1) + self.assertEqual(report["failed"][0][0], "models") + + def test_an_unexpected_failure_still_yields_a_report(self): + class Explosive(dict): + def __init__(self, journal): + super().__init__() + self.cr = FakeCursor(journal) + + def __contains__(self, key): + raise RuntimeError("registre en miettes") + + import json as _json + + journal = [] + namespace = {"env": Explosive(journal)} + exec(cleanup.build_script(1, False), namespace) # noqa: S102 + report = _json.loads(_json.dumps(namespace["report"])) + self.assertEqual(report["failed"][0][:2], ["*", "fatal"]) + self.assertIn("miettes", report["failed"][0][2]) + + class TestGoingRoundAgain(unittest.TestCase): def test_what_one_pass_refused_the_next_may_take(self): # LE point, et la raison même de boucler : une entrée refuse TANT QUE @@ -385,6 +470,94 @@ class TestTheMigrationRunsItBeforeTheSmokeTest(unittest.TestCase): self.assertIn("-d db_upgrade_18", lst_cmd[0]) +class TestNothingToPurgeIsNotAFailure(unittest.TestCase): + """Le module signale le VIDE par une exception. + + `raise UserError("No orphaned models found")` : le compter comme un + échec faisait passer une base saine pour une base cassée, avec quatre + avertissements sur cinq catégories. + """ + + def test_the_pushed_script_separates_it(self): + source = cleanup.build_script(1, False) + self.assertIn("except UserError:", source) + vide = source.index("except UserError:") + echec = source.index("except Exception as exc:\n note") + self.assertLess(vide, echec, "l'ordre des except décide") + + def test_it_is_reported_as_zero_not_as_an_error(self): + install_fake_odoo_exceptions(self) + + class Empty(FakeModel): + def create(self, values): + raise UserError("No orphaned models found") + + models = {cleanup.ORDER[0][1]: Empty([])} + report, _got = run_script(models, max_round=1) + entry = report["rounds"][0][0] + self.assertEqual(entry["purged"], 0) + self.assertEqual(entry["errors"], []) + self.assertEqual(report["failed"], []) + + +class TestTheModuleIsInstalledFirst(unittest.TestCase): + """Sans le module, aucun assistant n'existe et l'outil dit « rien ». + + Ce silence se lit comme un succès. La migration l'installait à l'étape + 3, donc APRÈS le nettoyage de l'étape 2 : l'ordre rendait l'outil + inutile au premier passage. + """ + + def test_it_checks_the_state_before_cleaning(self): + import inspect + + source = inspect.getsource(cleanup.main) + self.assertIn("module_state(", source) + self.assertLess( + source.index("module_state("), source.index("run_shell(") + ) + + def test_it_installs_when_absent(self): + import inspect + + source = inspect.getsource(cleanup.main) + self.assertIn('state != "installed"', source) + self.assertIn("install_module(", source) + + def test_a_failed_install_stops_there(self): + # Nettoyer sans le module rendrait « rien à faire » sur une base qui + # en avait besoin. + import inspect + + source = inspect.getsource(cleanup.main) + install = source.index("install_module(") + self.assertIn("return 2", source[install : install + 400]) + + +class TestTheMigrationNoLongerAsksTwice(unittest.TestCase): + def test_the_manual_cleanup_prompt_is_gone(self): + # Le faire à la main après l'avoir fait automatiquement. + import inspect + + from script.todo.todo_upgrade import TodoUpgrade + + source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade) + self.assertNotIn("Did you finish to clean database", source) + self.assertNotIn("Go to Settings / Technical / Cleanup", source) + + def test_the_late_install_is_gone_too(self): + # Elle arrivait à l'étape 3, après l'usage de l'étape 2 : l'outil + # pose désormais le module lui-même, au bon moment. + import inspect + + from script.todo.todo_upgrade import TodoUpgrade + + source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade) + self.assertNotIn( + "install_addons.sh {database_name} database_cleanup", source + ) + + class TestItRefusesTheWrongOdooVersion(unittest.TestCase): """Un Odoo plus ancien sur une base plus récente ÉCRIT avant d'échouer.""" From e52d36277b33bd58ceac720bb74ecab8591c9986 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 17 Aug 2026 19:38:56 -0400 Subject: [PATCH 33/67] [FIX] database_cleanup: recover the transaction, not the savepoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One refusal killed seven categories: modules died on « savepoint ... does not exist », then columns, tables, data, menus, indexes and properties all reported « current transaction is aborted ». Six victims that never got to try. The savepoints were mine and they could not work. The OCA module commits on its own — purge_columns.py:57 calls cr.commit(), and purge_modules.find() purges at line 91 before returning. A COMMIT destroys every savepoint, so ours vanished under our feet and nothing put the transaction back on its feet. Each entry is committed as it succeeds, and any failure rolls back. The dry run rolls back too: find() was writing for real. --- FR --- [FIX] database_cleanup : rattraper la transaction, pas le point de reprise Un seul refus en tuait sept : modules mourait sur « savepoint ... does not exist », puis colonnes, tables, données, menus, index et propriétés signalaient tous « current transaction is aborted ». Six victimes qui n'ont jamais eu leur tour. Les points de reprise étaient les miens et ne pouvaient pas tenir. Le module OCA valide de lui-même — purge_columns.py:57 appelle cr.commit(), et purge_modules.find() purge dès la ligne 91. Or un COMMIT détruit tout point de reprise : le nôtre disparaissait sous nos pieds, et rien ne remettait la transaction d'aplomb. Chaque entrée est validée dès qu'elle réussit, tout échec défait la sienne. La simulation défait aussi : find() écrivait pour de bon. Assisted-by: Claude Opus 5 --- script/odoo/migration/database_cleanup.py | 74 +++++--- test/test_database_cleanup.py | 214 ++++++++++++++++++---- 2 files changed, 229 insertions(+), 59 deletions(-) diff --git a/script/odoo/migration/database_cleanup.py b/script/odoo/migration/database_cleanup.py index 4bdaa65..2d262a0 100755 --- a/script/odoo/migration/database_cleanup.py +++ b/script/odoo/migration/database_cleanup.py @@ -97,6 +97,23 @@ def note(label, name, exc): report["failed"].append([label, name, str(exc)[:200]]) +def recover(): + # Rendre la transaction utilisable, quoi qu'il vienne de se passer. + # + # PAS de point de reprise ici, et c'est mesure : purge_modules.find() + # purge lui-meme (purge_modules.py:91), et purge_columns appelle + # cr.commit() (purge_columns.py:57). Or un COMMIT DETRUIT tous les + # points de reprise : le notre disparaissait sous nos pieds, d'ou + # « savepoint ... does not exist », puis tout ce qui suivait mourait + # sur une transaction avortee. Un rollback franc remet les compteurs + # a zero ; ce qu'on perd est borne, puisqu'on valide apres chaque + # entree reussie. + try: + env.cr.rollback() + except Exception: + pass + + try: for index in range(MAX_ROUND): this_round = [] @@ -110,46 +127,45 @@ try: errors = [] would = [] try: - # La CRÉATION et la lecture des noms sont dans le point de - # reprise, pas seulement la purge. Un échec ici laissait la - # transaction avortée ; la lecture suivante mourait dessus, - # hors de tout garde, et le rapport entier était perdu. - # Les noms sont matérialisés tout de suite : après un retour - # arrière, les relire relancerait une requête. - with env.cr.savepoint(): - wizard = env[model].create({}) - todo = [ - (line, line.name or str(line.id)) - for line in wizard.purge_line_ids - ] + wizard = env[model].create({}) + # Les noms sont matérialisés TOUT DE SUITE : les relire plus + # tard relancerait une requête, et c'est là que le script + # mourait quand la transaction avait été avortée entre-temps. + todo = [ + (line, line.name or str(line.id)) + for line in wizard.purge_line_ids + ] except UserError: - # « Aucun modèle orphelin trouvé » : le module signale le - # VIDE par une exception. Le compter comme un échec faisait - # passer une base saine pour une base cassée. + # « No orphaned models found » : le module signale le VIDE + # en levant. Le compter comme un échec faisait passer une + # base saine pour cassée. + recover() this_round.append({"kind": label, "purged": 0, "errors": [], "would": []}) continue except Exception as exc: + recover() note(label, "-", exc) continue + # find() peut avoir purgé de lui-même : on garde ce qu'il a fait. + if not DRY_RUN: + try: + env.cr.commit() + except Exception: + recover() for line, name in todo: if DRY_RUN: would.append(name) continue try: - # Un point de reprise par ENTRÉE : un refus n'emporte que - # la sienne, et la passe continue. Sans cela, le premier - # échec ferait perdre tout ce que la passe avait réparé. - with env.cr.savepoint(): - line.purge() + line.purge() + # Valider ENTRÉE PAR ENTRÉE : un refus plus loin ne doit + # pas emporter ce qui vient d'être réparé. + env.cr.commit() ok += 1 except Exception as exc: + recover() errors.append([name, str(exc)[:160]]) - if not DRY_RUN and ok: - try: - env.cr.commit() - except Exception as exc: - note(label, "commit", exc) purged_this_round += ok this_round.append({"kind": label, "purged": ok, "errors": errors, "would": would}) @@ -164,6 +180,14 @@ except Exception as exc: # cassé : sans lui, on ne sait même pas si la base a été touchée. note("*", "fatal", exc) +if DRY_RUN: + # Une simulation qui écrit n'est pas une simulation. Et celle-ci + # écrivait : purge_modules.find() purge de lui-meme, avant meme + # qu'on ait rien decide. On defait tout ce que la lecture a + # provoque — c'est possible ici, justement parce qu'on n'a valide + # aucune entree. + recover() + print("%(start)s") print(json.dumps(report)) print("%(end)s") diff --git a/test/test_database_cleanup.py b/test/test_database_cleanup.py index f57ca3f..828e727 100755 --- a/test/test_database_cleanup.py +++ b/test/test_database_cleanup.py @@ -60,33 +60,72 @@ class FakeModel: class FakeCursor: + """Volontairement SANS `savepoint` : c'est l'invariant du correctif. + + Le module OCA valide de lui-même — `purge_modules.find()` purge (ligne + 91) et `purge_columns.purge()` appelle `cr.commit()` (ligne 57). Un + COMMIT détruit tous les points de reprise. En reprendre un ici ferait + revivre le défaut sans que rien ne le dise ; l'absence de la méthode + le fait échouer tout de suite. + """ + def __init__(self, journal): self.journal = journal - def savepoint(self): - journal = self.journal - - class Guard: - def __enter__(self_inner): - journal.append(("savepoint", "enter")) - return self_inner - - def __exit__(self_inner, exc_type, exc, tb): - journal.append( - ("savepoint", "rollback" if exc_type else "release") - ) - return False - - return Guard() - def commit(self): self.journal.append(("commit", None)) + def rollback(self): + self.journal.append(("rollback", None)) + + +class AbortingCursor(FakeCursor): + """Ce que PostgreSQL fait VRAIMENT après une erreur. + + La transaction reste avortée et refuse tout ordre jusqu'au rollback. + C'est ce qui changeait une panne sur « modules » en sept catégories + mortes, toutes sur « current transaction is aborted ». + """ + + def __init__(self, journal): + super().__init__(journal) + self.aborted = False + + def commit(self): + if self.aborted: + raise RuntimeError( + "current transaction is aborted, commands ignored" + " until end of transaction block" + ) + super().commit() + + def rollback(self): + super().rollback() + self.aborted = False + + +class CommittingLine(FakeLine): + """Une purge qui valide toute seule, comme `purge_columns` le fait. + + Le COMMIT emporte le point de reprise ; l'ordre suivant meurt sur + « savepoint ... does not exist » et laisse la transaction avortée. + """ + + def __init__(self, name, cursor, journal=None): + super().__init__(name, journal=journal) + self.cursor = cursor + + def purge(self): + self.journal.append(("purge", self.name)) + self.cursor.commit() + self.cursor.aborted = True + raise RuntimeError('savepoint "10eb69719a9211f1" does not exist') + class FakeEnv(dict): - def __init__(self, mapping, journal): + def __init__(self, mapping, journal, cursor=None): super().__init__(mapping) - self.cr = FakeCursor(journal) + self.cr = cursor if cursor is not None else FakeCursor(journal) class UserError(Exception): @@ -122,12 +161,12 @@ def install_fake_odoo_exceptions(case): case.addCleanup(remettre) -def run_script(models, max_round=10, dry_run=False): +def run_script(models, max_round=10, dry_run=False, cursor=None): """Exécuter le script réellement poussé, et rendre (rapport, journal).""" import json - journal = [] - env = FakeEnv(models, journal) + journal = cursor.journal if cursor is not None else [] + env = FakeEnv(models, journal, cursor=cursor) namespace = {"env": env} exec(cleanup.build_script(max_round, dry_run), namespace) # noqa: S102 # Le script imprime le rapport entre deux sentinelles ; ici on le relit @@ -169,9 +208,9 @@ class TestTheOrder(unittest.TestCase): class TestOneEntryCannotSinkThePass(unittest.TestCase): - def test_each_entry_gets_its_own_savepoint(self): + def test_each_entry_is_committed_on_its_own(self): # Sans cela, le premier refus emporterait tout ce que la passe avait - # déjà réparé. + # déjà réparé — et un rollback, ici, remonte jusqu'au début. journal = [] lines = [ FakeLine("a", journal=journal), @@ -179,11 +218,11 @@ class TestOneEntryCannotSinkThePass(unittest.TestCase): ] models = {cleanup.ORDER[0][1]: FakeModel(lines)} _report, got = run_script(models, max_round=1) - # Un point de reprise par entrée, PLUS un pour la création et la - # lecture des noms : c'est là que la transaction s'était avortée. - self.assertEqual(got.count(("savepoint", "enter")), 3) + # Une validation par entrée, PLUS une après la création : `find()` + # peut avoir purgé de lui-même, et ce travail-là doit tenir. + self.assertEqual(got.count(("commit", None)), 3) - def test_a_refusal_rolls_back_only_its_own(self): + def test_a_refusal_gives_up_only_its_own(self): journal = [] lines = [ FakeLine("ok1", journal=journal), @@ -195,9 +234,15 @@ class TestOneEntryCannotSinkThePass(unittest.TestCase): entry = report["rounds"][0][0] self.assertEqual(entry["purged"], 2) self.assertEqual([name for name, _msg in entry["errors"]], ["bad"]) - self.assertEqual(got.count(("savepoint", "rollback")), 1) - # Deux entrées purgées + la création : trois relâchements. - self.assertEqual(got.count(("savepoint", "release")), 3) + self.assertEqual(got.count(("rollback", None)), 1) + # Deux entrées purgées + la création : trois validations. + self.assertEqual(got.count(("commit", None)), 3) + + def test_the_script_never_takes_a_savepoint(self): + # L'invariant du correctif, dit une fois pour toutes : le module OCA + # valide de lui-même, et un COMMIT détruit le point de reprise + # qu'on aurait pris. Le reprendre serait revenir au défaut. + self.assertNotIn("env.cr.savepoint", cleanup.build_script(1, False)) def test_a_wizard_that_cannot_even_be_created_is_recorded(self): models = {cleanup.ORDER[0][1]: FakeModel([], raise_on_create="boom")} @@ -214,15 +259,18 @@ class TestTheReportSurvivesAnything(unittest.TestCase): garde, et le script entier s'arrêtait — aucun rapport, juste une trace. """ - def test_reading_the_names_is_inside_the_savepoint(self): + def test_reading_the_names_is_inside_the_guard(self): # C'est la lecture des noms qui déclenche la requête, pas la - # création : la laisser dehors était le défaut. + # création : la laisser hors du `try` était le défaut — elle mourait + # sur une transaction déjà avortée, sans rien pour la rattraper. source = cleanup.build_script(1, False) creation = source.index("wizard = env[model].create({})") - garde = source.rindex("with env.cr.savepoint():", 0, creation) + garde = source.rindex("try:", 0, creation) noms = source.index("line.name or str(line.id)") + rattrapage = source.index("except UserError:", noms) self.assertLess(garde, creation) self.assertLess(creation, noms) + self.assertLess(noms, rattrapage) def test_a_failure_on_create_does_not_kill_the_run(self): models = { @@ -255,6 +303,91 @@ class TestTheReportSurvivesAnything(unittest.TestCase): self.assertIn("miettes", report["failed"][0][2]) +class TestTheCascadeThatKilledEverything(unittest.TestCase): + """Vécu, sur test_neutralize_upgrade_13 : sept catégories mortes d'une. + + passe 1 : 0 purgés + ⚠️ 0 purgés ; 7 n'ont pas pu l'être : + - [modules] - : savepoint "10eb6971..." does not exist + - [columns] - : current transaction is aborted, commands ignored + ... et ainsi de suite jusqu'à la dernière. + + Une seule panne, six victimes. La cause n'était pas dans OCA mais chez + nous : on n'a jamais remis la transaction d'aplomb après l'échec. + """ + + def build(self): + journal = [] + cursor = AbortingCursor(journal) + coupable = CommittingLine("colonne_morte", cursor, journal=journal) + models = { + cleanup.ORDER[1][1]: FakeModel([coupable]), + cleanup.ORDER[2][1]: FakeModel( + [FakeLine("suivante", journal=journal)] + ), + cleanup.ORDER[3][1]: FakeModel( + [FakeLine("encore", journal=journal)] + ), + } + return run_script(models, max_round=1, cursor=cursor) + + def test_the_categories_after_it_still_work(self): + report, _got = self.build() + purged = {e["kind"]: e["purged"] for e in report["rounds"][0]} + self.assertEqual(purged.get("columns"), 1) + self.assertEqual(purged.get("tables"), 1) + + def test_nobody_else_reports_an_aborted_transaction(self): + # C'est la SIGNATURE de la cascade : six lignes qui ne disent rien + # de leur propre catégorie, seulement qu'une autre a échoué avant. + report, _got = self.build() + contamines = [ + entry + for round_ in report["rounds"] + for entry in round_ + for _name, message in entry["errors"] + if "transaction is aborted" in message + ] + self.assertEqual(contamines, []) + self.assertEqual(report["failed"], []) + + def test_the_real_failure_is_still_reported(self): + # Rattraper ne veut pas dire taire : la colonne n'a PAS été purgée. + report, _got = self.build() + entry = [e for e in report["rounds"][0] if e["kind"] == "modules"][0] + self.assertEqual(entry["purged"], 0) + self.assertIn("savepoint", entry["errors"][0][1]) + + def test_the_transaction_is_put_back_on_its_feet(self): + _report, got = self.build() + self.assertIn(("rollback", None), got) + + +class TestADeadCreateDoesNotContaminate(unittest.TestCase): + def test_the_next_category_is_not_dragged_down(self): + # `create({})` qui échoue laisse la transaction avortée : sans + # rollback, la catégorie suivante mourait sur l'erreur d'une autre. + journal = [] + cursor = AbortingCursor(journal) + + class MortAuDepart(FakeModel): + def create(self, values): + cursor.aborted = True + raise RuntimeError("registre indisponible") + + models = { + cleanup.ORDER[0][1]: MortAuDepart([]), + cleanup.ORDER[2][1]: FakeModel( + [FakeLine("colonne", journal=journal)] + ), + } + report, _got = run_script(models, max_round=1, cursor=cursor) + purged = {e["kind"]: e["purged"] for e in report["rounds"][0]} + self.assertEqual(purged.get("columns"), 1) + self.assertEqual(report["failed"][0][0], "models") + self.assertIn("registre", report["failed"][0][2]) + + class TestGoingRoundAgain(unittest.TestCase): def test_what_one_pass_refused_the_next_may_take(self): # LE point, et la raison même de boucler : une entrée refuse TANT QUE @@ -336,6 +469,19 @@ class TestTheDryRun(unittest.TestCase): self.assertNotIn(("commit", None), got) self.assertEqual(report["rounds"][0][0]["would"], ["a"]) + def test_it_undoes_what_merely_looking_caused(self): + # `purge_modules.find()` PURGE de lui-même : une simulation écrivait + # donc pour de bon, ce qui lui retire tout son sens. On défait ce + # que la lecture a provoqué — possible ici, justement parce qu'on + # n'a validé aucune entrée. + journal = [] + models = { + cleanup.ORDER[0][1]: FakeModel([FakeLine("a", journal=journal)]) + } + _report, got = run_script(models, max_round=5, dry_run=True) + self.assertIn(("rollback", None), got) + self.assertNotIn(("commit", None), got) + def test_it_does_a_single_pass(self): # Rien ne change, donc rien ne se libère : boucler serait du vent. models = {cleanup.ORDER[0][1]: FakeModel([FakeLine("a")])} @@ -482,7 +628,7 @@ class TestNothingToPurgeIsNotAFailure(unittest.TestCase): source = cleanup.build_script(1, False) self.assertIn("except UserError:", source) vide = source.index("except UserError:") - echec = source.index("except Exception as exc:\n note") + echec = source.index('note(label, "-", exc)') self.assertLess(vide, echec, "l'ordre des except décide") def test_it_is_reported_as_zero_not_as_an_error(self): From 95a6ba558ea39d928df0b900f9ff8c49667d38e3 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 17 Aug 2026 20:47:17 -0400 Subject: [PATCH 34/67] [ADD] migration: make Enter mean the answer you always give MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- script/addons/theme_leftover.py | 30 +- script/odoo/migration/check_stale_scss.py | 28 +- script/odoo/migration/smoke_public_url.py | 25 +- script/todo/auto_ask.py | 107 +++++++ script/todo/todo_i18n.py | 44 +++ script/todo/todo_upgrade.py | 133 +++++--- test/test_auto_execute.py | 50 ++- test/test_database_cleanup.py | 29 +- test/test_prompt_defaults.py | 357 ++++++++++++++++++++++ test/test_reset_stale_cow_prompt.py | 19 +- test/test_smoke_public_url.py | 19 +- test/test_todo_upgrade_cow_prompt.py | 19 +- test/test_uninstall_addons_theme.py | 31 +- 13 files changed, 804 insertions(+), 87 deletions(-) create mode 100644 script/todo/auto_ask.py create mode 100644 test/test_prompt_defaults.py diff --git a/script/addons/theme_leftover.py b/script/addons/theme_leftover.py index ec8cd80..f333d7d 100755 --- a/script/addons/theme_leftover.py +++ b/script/addons/theme_leftover.py @@ -37,6 +37,12 @@ except Exception: # pragma: no cover - repli si i18n indisponible return key +try: + from script.todo import auto_ask +except Exception: # pragma: no cover - repli si le pilote est absent + auto_ask = None + + def can_ask(): """Peut-on poser une question ICI ? @@ -199,14 +205,32 @@ def delete_attachments(database, lst_row, config_path="./config.conf"): return done.returncode, done.stdout + done.stderr -def prompt(database, theme, attachments, views, config_path, ask=input): - """Garder ou effacer. « Garder » par défaut, et la sauvegarde d'abord.""" +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. + """ if not attachments: return False + 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) + ) answer = ( ask( f"💬 {t('Delete these leftovers, or keep them?')}" - f" ({t('Enter = keep')}, d = {t('delete, after saving them')}) : " + f" ({t('Enter = delete, after saving them')}," + f" k = {t('keep')}) : " ) .strip() .lower() diff --git a/script/odoo/migration/check_stale_scss.py b/script/odoo/migration/check_stale_scss.py index 82b16fd..b5ec509 100755 --- a/script/odoo/migration/check_stale_scss.py +++ b/script/odoo/migration/check_stale_scss.py @@ -64,6 +64,12 @@ except Exception: # pragma: no cover - repli si i18n indisponible return key +try: + from script.todo import auto_ask +except Exception: # pragma: no cover - repli si le pilote est absent + auto_ask = None + + def can_ask(): """Peut-on poser une question ICI ? @@ -471,27 +477,41 @@ def render(lst_finding, database, version_dir): return "\n".join(lines) + "\n" -def prompt(lst_finding, database, config_path="./config.conf", ask=input): +def prompt(lst_finding, database, config_path="./config.conf", ask=None): """Montrer, puis proposer de corriger. Rend True si l'on a écrit. Répondre « oui, réinitialise » sans avoir vu l'écart, c'est accepter de perdre on ne sait quoi. L'invite revient donc après chaque lecture : regarder ne répond pas à la question. + + Le défaut suit ce que le checkout SAIT faire : réinitialiser quand + `reset_asset` existe, rien du tout avant la 13.0. Un défaut « a » là où + la remise à zéro n'existe pas ferait boucler l'invite sur elle-même. """ odoo_dir = running_odoo_dir() can_reset = reset_supported(odoo_dir) if not can_reset: print(too_early_message(odoo_dir, database)) + defaut = "a" if can_reset else "n" + if ask is None: + ask = ( + auto_ask.make_ask(defaut) + if auto_ask + else (lambda prompt="": input(prompt) or defaut) + ) while True: choix = ( - f" a = {t('reset them onto the module file')}" if can_reset else "" + f" {t('Enter = reset them onto the module file')}," + if can_reset + else "" ) answer = ( ask( f"💬 {t('What do you want to do with these customizations?')}" - f" ({t('Enter = nothing')}," + f" ({choix}" f" v = {t('what the copy changed')}," - f" w = {t('full screen')}{choix}) : " + f" w = {t('full screen')}," + f" n = {t('nothing')}) : " ) .strip() .lower() diff --git a/script/odoo/migration/smoke_public_url.py b/script/odoo/migration/smoke_public_url.py index 3673161..dd6023e 100755 --- a/script/odoo/migration/smoke_public_url.py +++ b/script/odoo/migration/smoke_public_url.py @@ -54,6 +54,12 @@ except Exception: # pragma: no cover - repli si i18n indisponible return key +try: + from script.todo import auto_ask +except Exception: # pragma: no cover - repli si le pilote est absent + auto_ask = None + + def can_ask(): """Peut-on poser une question ICI ? @@ -367,7 +373,10 @@ def apply_reset(database, lst_key): return done.returncode, done.stdout + done.stderr -def prompt(database, lst_failure, lst_key, ask=input): +DEFAULT_ANSWER = "a" + + +def prompt(database, lst_failure, lst_key, ask=None): """Proposer de corriger, puis dire ce qu'il reste. Rend les clés traitées. Détecter sans offrir le geste, c'est laisser relever des identifiants @@ -377,6 +386,14 @@ def prompt(database, lst_failure, lst_key, ask=input): if not lst_key: print(f"ℹ -> {t('No parent view named: nothing to offer.')}") return [] + 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) + ) print(f"\n✨ {t('Copies to reset onto their module view')} :") for index, key in enumerate(lst_key, start=1): print(f" [{index}] {key}") @@ -384,13 +401,15 @@ def prompt(database, lst_failure, lst_key, ask=input): answer = ( ask( f"💬 {t('Which one(s) to reset?')}" - f" ({t('numbers separated by commas, a = all, empty =')}" + f" ({t('numbers separated by commas, Enter = all, n =')}" f" {t('nothing')}) : " ) .strip() .lower() ) - if not answer: + # « n », et non plus le vide : Entrée vaut « toutes » maintenant, et une + # sortie sans mot pour dire non serait une sortie sans issue. + if not answer or answer == "n": print(f"ℹ -> {t('Kept. Nothing was reset.')}") return [] if answer == "a": diff --git a/script/todo/auto_ask.py b/script/todo/auto_ask.py new file mode 100644 index 0000000..eefee6e --- /dev/null +++ b/script/todo/auto_ask.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Lire une réponse, ou prendre le défaut au bout du délai. + +Le mode auto de la migration ne vaut que si TOUTES les invites le +connaissent. Or six d'entre elles vivent dans des outils lancés en +sous-processus — le désinstalleur de thème, le détecteur de SCSS figé, le +test de fumée. Elles ne partagent ni l'objet du pilote ni sa mémoire ; sans +rien de commun, elles attendraient indéfiniment une frappe qui ne vient +pas, et l'automatisation s'arrêterait là sans rien dire. + +Ce qui traverse un `fork`, c'est l'environnement. Le pilote y pose +`ERPLIBRE_AUTO_EXECUTE`, chaque outil le lit, et il n'existe qu'une seule +implémentation du compte à rebours. + +`select` plutôt qu'un fil ou une alarme : la question est « quelque chose +est-il LISIBLE maintenant », et il faut pouvoir renoncer. Un fil laisserait +derrière lui un `input()` toujours bloqué, qui volerait la frappe suivante. +""" + +import os +import sys + +ENV_ENABLED = "ERPLIBRE_AUTO_EXECUTE" +ENV_DELAY = "ERPLIBRE_AUTO_DELAY" +DEFAULT_DELAY = 5 + + +def enabled(): + """Le mode auto est-il actif pour CE processus ?""" + return os.environ.get(ENV_ENABLED, "") == "1" + + +def delay(): + """Combien de secondes attendre. Une valeur illisible ne bloque rien.""" + try: + value = float(os.environ.get(ENV_DELAY, DEFAULT_DELAY)) + except (TypeError, ValueError): + return DEFAULT_DELAY + # Un délai nul ou négatif rendrait la reprise en main impossible : on + # ne peut pas interrompre ce qui ne laisse aucune fenêtre. + return value if value > 0 else DEFAULT_DELAY + + +def export(on, seconds=None): + """Poser le mode auto dans l'environnement, pour tout ce qu'on lancera. + + C'est le seul canal qu'un sous-processus partage avec nous. Le retirer + quand on l'éteint, plutôt que d'y écrire « 0 », évite qu'un reste de + session précédente décide à notre place. + """ + if on: + os.environ[ENV_ENABLED] = "1" + os.environ[ENV_DELAY] = str( + seconds if seconds is not None else DEFAULT_DELAY + ) + else: + os.environ.pop(ENV_ENABLED, None) + os.environ.pop(ENV_DELAY, None) + + +def ask(prompt, default="", seconds=None): + """Poser la question. Rendre `default` si rien n'arrive à temps. + + Hors mode auto, c'est un `input()` ordinaire — sauf qu'une réponse vide + vaut le défaut, comme partout ailleurs en ligne de commande. C'est ce + qui permet d'écrire l'attendu dans la question : « (Y/n) » doit dire la + vérité pour la personne qui appuie sur Entrée, pas seulement pour le + compte à rebours. + """ + if not enabled(): + return input(prompt) or default + import select + + sys.stdout.write(prompt) + sys.stdout.flush() + try: + ready, _, _ = select.select( + [sys.stdin], [], [], delay() if seconds is None else seconds + ) + except (OSError, ValueError): + # stdin n'est pas sélectionnable : on ne devine pas, on demande. + return input("") or default + if ready: + answer = sys.stdin.readline().rstrip("\n") + # Une réponse VIDE vaut « prends le défaut » : c'est tout le propos + # du mode auto. Un stdin fermé — exécution non interactive — est + # lisible tout de suite et rend justement une ligne vide ; sans + # ceci, le défaut ne servirait jamais là. + return answer or default + print(f" ⏱ → {default or '(default)'}") + return default + + +def make_ask(default, seconds=None): + """Un `ask` à un seul argument, pour les outils qui en injectent un. + + Ils appellent `ask(question)` sans savoir ce qu'est un défaut : celui-ci + appartient à l'outil, pas à l'appelant, et se fige donc ici. + """ + + def ask_one(prompt=""): + return ask(prompt, default=default, seconds=seconds) + + return ask_one diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index d24cee4..ead6173 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -5242,6 +5242,50 @@ TRANSLATIONS = { "fr": "rien n'a été modifié", "en": "nothing was changed", }, + "Enter = 1": { + "fr": "Entrée = 1", + "en": "Enter = 1", + }, + "Enter = all, n = none": { + "fr": "Entrée = tous, n = aucun", + "en": "Enter = all, n = none", + }, + "Enter = delete, after saving them": { + "fr": "Entrée = effacer, après les avoir sauvegardés", + "en": "Enter = delete, after saving them", + }, + "Enter = neutralize now, reversible": { + "fr": "Entrée = neutraliser maintenant, réversible", + "en": "Enter = neutralize now, reversible", + }, + "Enter = reset them onto the module file": { + "fr": "Entrée = les réinitialiser sur le fichier du module", + "en": "Enter = reset them onto the module file", + }, + "Enter upgrades all addons of the": { + "fr": "Entrée met à jour tous les modules de la", + "en": "Enter upgrades all addons of the", + }, + "Open it at EVERY version bump, stop asking": { + "fr": "L'ouvrir à CHAQUE palier de version, ne plus demander", + "en": "Open it at EVERY version bump, stop asking", + }, + "Testing every bump, as chosen earlier.": { + "fr": "Test à chaque palier, comme choisi plus tôt.", + "en": "Testing every bump, as chosen earlier.", + }, + "numbers separated by commas, Enter = all, n =": { + "fr": "numéros séparés par des virgules, Entrée = toutes, n =", + "en": "numbers separated by commas, Enter = all, n =", + }, + "decide at the version bump": { + "fr": "décider au palier de version", + "en": "decide at the version bump", + }, + "keep": { + "fr": "garder", + "en": "keep", + }, "Clean the database before testing the pages?": { "fr": "Nettoyer la base avant de tester les pages ?", "en": "Clean the database before testing the pages?", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index 123104e..d4e693a 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -14,7 +14,7 @@ import zipfile from uuid import uuid4 -from script.todo import todo_file_browser +from script.todo import auto_ask, todo_file_browser from script.todo.version_manager import get_odoo_version try: @@ -781,6 +781,12 @@ class TodoUpgrade: .lower() ) self.auto_execute = answer == "y" + # Dans l'ENVIRONNEMENT, car c'est le seul canal qu'un sous-processus + # partage avec nous : la moitié des invites d'une migration sont + # posées par des outils lancés à part — désinstalleur de thème, + # détecteur de SCSS figé — et sans cela elles attendraient une + # frappe qui ne vient jamais. + auto_ask.export(self.auto_execute, self.AUTO_DELAY) if self.auto_execute: print( f"⏱ {t('Auto-run on: each prompt waits')}" @@ -788,34 +794,20 @@ class TodoUpgrade: ) def ask(self, prompt, default=""): - """Lire une réponse ; en mode auto, rendre le défaut après le délai. + """Lire une réponse ; rendre le défaut si rien n'arrive. - `select` plutôt qu'un fil ou une alarme : on veut savoir si quelque - chose est LISIBLE, et rendre la main sinon. Un fil laisserait un - `input()` bloqué derrière lui, qui volerait la frappe suivante. + Le compte à rebours vit dans `auto_ask`, pas ici : les outils lancés + en sous-processus posent EUX AUSSI des questions, et une deuxième + implémentation aurait dérivé de celle-ci sans que rien ne le dise. + + L'export à chaque appel n'est pas de la précaution : l'environnement + est le SEUL canal qui traverse un `fork`. Sans lui, un outil lancé + plus bas attendrait indéfiniment une frappe qui ne vient pas. """ - if not getattr(self, "auto_execute", False): - return input(prompt) - import select + auto_ask.export(getattr(self, "auto_execute", False), self.AUTO_DELAY) + return auto_ask.ask(prompt, default=default, seconds=self.AUTO_DELAY) - sys.stdout.write(prompt) - sys.stdout.flush() - try: - ready, _, _ = select.select([sys.stdin], [], [], self.AUTO_DELAY) - except (OSError, ValueError): - # stdin n'est pas sélectionnable : on ne devine pas, on demande. - return input("") - if ready: - answer = sys.stdin.readline().rstrip("\n") - # Une réponse VIDE vaut « prends le défaut » : c'est tout le - # propos du mode auto. Un stdin fermé — exécution non - # interactive — est lisible tout de suite et rend justement - # une ligne vide ; sans ceci, le défaut ne servirait jamais là. - return answer or default - print(f" ⏱ → {default or t('(default)')}") - return default - - def ask_gate(self, prompt): + def ask_gate(self, prompt, default=""): """Une invite d'attente, avec une porte de sortie vers l'arrière. Ces invites ne demandent qu'à continuer. Quand on s'aperçoit à ce @@ -825,7 +817,7 @@ class TodoUpgrade: il rembobine l'état, l'écrit, et s'arrête en disant quoi relancer. """ while True: - answer = self.ask(prompt) + answer = self.ask(prompt, default=default) if (answer or "").strip().lower() != "b": return answer if self.rewind_to_chosen_step(): @@ -2126,7 +2118,9 @@ class TodoUpgrade: self.ask( f"💬 {t('List the missing modules to delete,')}" f" {t('separated by commas. The others will be')}" - f" {t('migrated')} : " + f" {t('migrated')}" + f" ({t('Enter = all, n = none')}) : ", + default="a", ) .strip() .lower() @@ -2134,6 +2128,11 @@ class TodoUpgrade: is_delete_all = False + # « n » saute le bloc ENTIER, ajouts par version compris : + # Entrée valant « toutes », il faut un mot pour dire non, + # et il doit vraiment ne rien supprimer. + if want_continue == "n": + want_continue = "" if want_continue: lst_want_continue = [ a.strip() for a in want_continue.split(",") @@ -2618,8 +2617,9 @@ class TodoUpgrade: self.ask( f"💬 {t('Do you want to upgrade all')}" f"{str_wait_next_version} ?" - f" {t('Press y/Y to upgrade all addons of the')}" - f" {t('database')} : " + f" (Y/n, {t('Enter upgrades all addons of the')}" + f" {t('database')}) : ", + default="y", ) .strip() .lower() @@ -2647,14 +2647,31 @@ class TodoUpgrade: self.prompt_smoke_public_url(database_name_upgrade) print(f"[y] {t('Open the server with Selenium')}") - status = ( - self.ask( - f"💬 {t('Do you want to test this upgrade? Choose')}" - f" {t('or press enter to ignore it')} : " + print(f"[a] {t('Open it at EVERY version bump, stop asking')}") + # Une migration traverse jusqu'à six paliers. Répondre « y » + # à chacun oblige à rester devant ; « a » est la réponse + # qu'on donnait de toute façon six fois de suite, dite une + # seule fois — et retenue, donc valable après une reprise. + if self.dct_progression.get("state_4_selenium_every_bump"): + status = "y" + print( + f"ℹ -> {t('Testing every bump, as chosen earlier.')}" ) - .strip() - .lower() - ) + else: + status = ( + self.ask( + f"💬 {t('Do you want to test this upgrade? Choose')}" + f" {t('or press enter to ignore it')} : " + ) + .strip() + .lower() + ) + if status == "a": + self.dct_progression["state_4_selenium_every_bump"] = ( + True + ) + self.write_config() + status = "y" "make repo_show_status" if status == "y": self.todo.prompt_execute_selenium_and_run_db( @@ -2873,7 +2890,8 @@ class TodoUpgrade: answer = ( self.ask_gate( f"💬 {t('Uninstall them properly before migrating?')}" - f" (y/N, {t('(b = go back to a previous step)')}) : " + f" (Y/n, {t('(b = go back to a previous step)')}) : ", + default="y", ) .strip() .lower() @@ -2931,13 +2949,16 @@ class TodoUpgrade: answer = ( self.ask_gate( f"💬 {t('Which one(s) to reset onto the module view?')}" - f" ({t('numbers separated by commas, a = all, empty =')}" - f" {t('nothing')}) : " + f" ({t('numbers separated by commas, Enter = all, n =')}" + f" {t('nothing')}) : ", + default="a", ) .strip() .lower() ) - if not answer: + # « n », et non plus le vide : Entrée vaut « toutes » maintenant, et + # une sortie sans mot pour dire non serait une sortie sans issue. + if not answer or answer == "n": print(f"ℹ -> {t('Kept. Nothing was reset.')}") return if answer == "a": @@ -2970,7 +2991,8 @@ class TodoUpgrade: answer = ( self.ask_gate( f"💬 {t('Clean the database before testing the pages?')}" - f" (y/N, {t('(b = go back to a previous step)')}) : " + f" (Y/n, {t('(b = go back to a previous step)')}) : ", + default="y", ) .strip() .lower() @@ -3007,7 +3029,8 @@ class TodoUpgrade: answer = ( self.ask_gate( f"💬 {t('Request every public URL of this database now?')}" - f" (y/N, {t('(b = go back to a previous step)')}) : " + f" (Y/n, {t('(b = go back to a previous step)')}) : ", + default="y", ) .strip() .lower() @@ -3071,14 +3094,18 @@ class TodoUpgrade: f" -d {database_name} -t odoo{next_version}.0" ) while True: + # `self.ask`, pas `input` : cette invite boucle, et un `input` + # nu ici arrêtait net une migration automatique — sans rien + # afficher, puisque la question attendait déjà. answer = ( - input( + self.ask( f"💬 {t('What do you want to do with these COW copies?')}" - f" ({t('Enter = decide at the version bump')}," + f" ({t('Enter = neutralize now, reversible')}," f" v = {t('what each copy holds')}," f" s = {t('why it breaks')}," f" w = {t('full screen')}," - f" a = {t('neutralize now, reversible')}) : " + f" n = {t('decide at the version bump')}) : ", + default="a", ) .strip() .lower() @@ -3131,10 +3158,11 @@ class TodoUpgrade: # rien dans l'avertissement ne permet de les distinguer. while True: answer = ( - input( + self.ask( "💬 Neutralize these copies so the upgrade can proceed?" " Their arch is kept and the change is reversible." - " (Y/n, v = view the differences, w = full screen) : " + " (Y/n, v = view the differences, w = full screen) : ", + default="y", ) .strip() .lower() @@ -3290,7 +3318,9 @@ class TodoUpgrade: ) print(f" [2] {t('Try the whole list anyway (it will fail)')}") print(f" [3] {t('Uninstall nothing, continue')}") - answer = input(f"💬 {t('Your choice')} : ").strip() + answer = self.ask( + f"💬 {t('Your choice')} ({t('Enter = 1')}) : ", default="1" + ).strip() if answer == "2": return lst_present + lst_missing if answer == "3" or not lst_present: @@ -3483,8 +3513,11 @@ class TodoUpgrade: ) print(f"[3] {t('Reset one of them onto its module view')}") print(f"[4] {t('Browse the differences full screen')}") + # `self.ask` : une migration automatique s'arrêtait ICI, + # sur une invite qui ne demande qu'à continuer, et restait + # bloquée sans que rien ne le signale. wait_status = ( - input( + self.ask( f"💬 {t('Error detected, press enter to continue or')}" f" ctrl+c {t('to stop')} : " ) diff --git a/test/test_auto_execute.py b/test/test_auto_execute.py index 13cdf17..66c6985 100755 --- a/test/test_auto_execute.py +++ b/test/test_auto_execute.py @@ -37,8 +37,36 @@ def upgrade(auto, delay=0.2): return obj -class TestTheTimedRead(unittest.TestCase): +class EnvCase(unittest.TestCase): + """Rendre l'environnement comme on l'a trouvé. + + `ask` pose le mode auto dans l'environnement — c'est ainsi qu'il atteint + les outils lancés à part. Le laisser posé le ferait fuir dans TOUTE la + suite : une invite sans rapport prendrait son défaut au lieu d'attendre, + et le test qui échouerait ne serait pas celui qui a fauté. + """ + def setUp(self): + from script.todo import auto_ask + + avant = { + key: os.environ.get(key) + for key in (auto_ask.ENV_ENABLED, auto_ask.ENV_DELAY) + } + + def remettre(): + for key, value in avant.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + self.addCleanup(remettre) + + +class TestTheTimedRead(EnvCase): + def setUp(self): + super().setUp() self.original = sys.stdin self.addCleanup(setattr, sys, "stdin", self.original) @@ -129,8 +157,9 @@ class TestTheTimedRead(unittest.TestCase): self.assertEqual(got, "demandé") -class TestTheQuestionThatEnablesIt(unittest.TestCase): +class TestTheQuestionThatEnablesIt(EnvCase): def setUp(self): + super().setUp() self.addCleanup( setattr, todo_i18n, "_current_lang", todo_i18n._current_lang ) @@ -166,6 +195,23 @@ class TestTheQuestionThatEnablesIt(unittest.TestCase): def test_the_delay_is_five_seconds(self): self.assertEqual(TodoUpgrade.AUTO_DELAY, 5) + def test_yes_also_arms_the_tools_launched_apart(self): + # La moitié des invites d'une migration sont posées par d'autres + # processus. Sans cette variable, ils attendraient une frappe qui + # ne vient jamais — et l'automatisation s'arrêterait là, en + # silence, puisque la question a bien été posée. + from script.todo import auto_ask + + self.answer("y") + self.assertEqual(os.environ.get(auto_ask.ENV_ENABLED), "1") + + def test_no_leaves_nothing_behind(self): + from script.todo import auto_ask + + os.environ[auto_ask.ENV_ENABLED] = "1" + self.answer("") + self.assertNotIn(auto_ask.ENV_ENABLED, os.environ) + class TestWhereItIsAsked(unittest.TestCase): def source(self): diff --git a/test/test_database_cleanup.py b/test/test_database_cleanup.py index 828e727..e4d8b3f 100755 --- a/test/test_database_cleanup.py +++ b/test/test_database_cleanup.py @@ -577,8 +577,8 @@ class TestTheMigrationRunsItBeforeTheSmokeTest(unittest.TestCase): self.assertIn("run_on_terminal", source) self.assertNotIn("todo_upgrade_execute", source) - def test_the_default_cleans_nothing(self): - # Cela ÉCRIT en base : ce n'est pas à la migration de le décider. + def test_saying_no_cleans_nothing(self): + # Le défaut ne retire pas le choix : il ne fait qu'en proposer un. import contextlib import io @@ -595,11 +595,32 @@ class TestTheMigrationRunsItBeforeTheSmokeTest(unittest.TestCase): upgrade.write_config = lambda: None lst_cmd = [] upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0 - upgrade.ask_gate = lambda prompt: "" + upgrade.ask_gate = lambda prompt, default="": "n" or default with contextlib.redirect_stdout(io.StringIO()): upgrade.prompt_database_cleanup("db") self.assertEqual(lst_cmd, []) + def test_the_default_cleans(self): + # Entrée nettoie : le nettoyage précède les tests de fumée, et une + # base encombrée de tables mortes fait échouer des pages pour une + # raison qui n'a rien à voir avec la migration. + import contextlib + import io + + from script.todo.todo_upgrade import TodoUpgrade + + upgrade = TodoUpgrade.__new__(TodoUpgrade) + upgrade.dct_progression = {} + upgrade.lst_command_executed = [] + upgrade.write_config = lambda: None + lst_cmd = [] + upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0 + upgrade.ask_gate = lambda prompt, default="": "" or default + with contextlib.redirect_stdout(io.StringIO()): + upgrade.prompt_database_cleanup("db") + self.assertEqual(len(lst_cmd), 1) + self.assertIn("database_cleanup.py", lst_cmd[0]) + def test_yes_runs_it_on_that_database(self): from script.todo.todo_upgrade import TodoUpgrade @@ -609,7 +630,7 @@ class TestTheMigrationRunsItBeforeTheSmokeTest(unittest.TestCase): upgrade.write_config = lambda: None lst_cmd = [] upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0 - upgrade.ask_gate = lambda prompt: "y" + upgrade.ask_gate = lambda prompt, default="": "y" or default upgrade.prompt_database_cleanup("db_upgrade_18") self.assertEqual(len(lst_cmd), 1) self.assertIn("database_cleanup.py", lst_cmd[0]) diff --git a/test/test_prompt_defaults.py b/test/test_prompt_defaults.py new file mode 100644 index 0000000..bb773ea --- /dev/null +++ b/test/test_prompt_defaults.py @@ -0,0 +1,357 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Ce que fait Entrée, et ce que fait le silence. + +Une migration pose une quarantaine de questions dont la réponse était +presque toujours la même. Le mode auto promettait de les prendre « par +défaut » — sauf que le défaut était VIDE partout : il ne faisait rien, et +la moitié des invites vivent de toute façon dans des outils lancés à part, +qui ne savaient rien du mode auto et attendaient une frappe pour toujours. + +Deux exigences, donc, et elles sont indissociables : + +- ce que fait Entrée doit être ÉCRIT dans la question. Une invite qui + annonce « y/N » et désinstalle est pire que pas d'invite du tout ; +- aucune invite du chemin de migration ne doit rester un `input()` nu. Ce + n'est pas une faute qu'on commet exprès, c'est une faute qu'on commet + par habitude — d'où le test qui refuse la construction elle-même. +""" + +import ast +import contextlib +import io +import os +import subprocess +import sys +import unittest + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(REPO, "script", "odoo", "migration")) +sys.path.insert(0, os.path.join(REPO, "script", "addons")) + +from script.todo import auto_ask # noqa: E402 +from script.todo.todo_upgrade import TodoUpgrade # noqa: E402 + + +class EnvCase(unittest.TestCase): + def setUp(self): + avant = { + key: os.environ.get(key) + for key in (auto_ask.ENV_ENABLED, auto_ask.ENV_DELAY) + } + + def remettre(): + for key, value in avant.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + self.addCleanup(remettre) + auto_ask.export(False) + + +class TestTheChannelThatCrossesAFork(EnvCase): + """L'environnement, parce que c'est le SEUL canal partagé. + + Le désinstalleur de thème, le détecteur de SCSS figé et le test de + fumée sont des processus séparés. Ils ne voient ni l'objet du pilote ni + sa mémoire ; sans une variable d'environnement, ils attendraient une + frappe qui ne vient jamais, et l'automatisation s'arrêterait là — sans + message, puisque la question, elle, a bien été posée. + """ + + def test_export_puts_it_where_a_child_will_read_it(self): + auto_ask.export(True, 3) + self.assertEqual(os.environ[auto_ask.ENV_ENABLED], "1") + self.assertEqual(os.environ[auto_ask.ENV_DELAY], "3") + self.assertTrue(auto_ask.enabled()) + + def test_turning_it_off_REMOVES_it(self): + # Y écrire « 0 » laisserait un reste de session précédente décider à + # notre place : ce qui n'existe pas ne peut pas se tromper. + auto_ask.export(True, 3) + auto_ask.export(False) + self.assertNotIn(auto_ask.ENV_ENABLED, os.environ) + self.assertFalse(auto_ask.enabled()) + + def test_a_real_child_process_takes_the_default(self): + # LE test qui compte : un vrai sous-processus, un vrai stdin fermé. + code = ( + "import sys; sys.path.insert(0, %r);" + "from script.todo import auto_ask;" + "print(auto_ask.ask('q : ', default='d'))" % REPO + ) + env = dict(os.environ) + env[auto_ask.ENV_ENABLED] = "1" + env[auto_ask.ENV_DELAY] = "1" + done = subprocess.run( + [sys.executable, "-c", code], + capture_output=True, + text=True, + stdin=subprocess.DEVNULL, + timeout=30, + cwd=REPO, + env=env, + ) + self.assertEqual(done.returncode, 0, done.stderr) + self.assertIn("d", done.stdout.strip().splitlines()[-1]) + + def test_a_child_without_the_flag_still_asks(self): + # Hors mode auto, on ne décide à la place de personne : le tuyau + # fermé rend une ligne vide, et le défaut n'arrive que là. + code = ( + "import sys; sys.path.insert(0, %r);" + "from script.todo import auto_ask;" + "print('RÉPONSE=' + auto_ask.ask('q : ', default='d'))" % REPO + ) + done = subprocess.run( + [sys.executable, "-c", code], + capture_output=True, + text=True, + input="k\n", + timeout=30, + cwd=REPO, + ) + self.assertIn("RÉPONSE=k", done.stdout) + + def test_a_broken_delay_does_not_freeze_anything(self): + os.environ[auto_ask.ENV_DELAY] = "plus tard" + self.assertEqual(auto_ask.delay(), auto_ask.DEFAULT_DELAY) + + def test_a_zero_delay_is_refused(self): + # Sans fenêtre, on ne peut pas reprendre la main : « auto » ne veut + # pas dire « sans recours ». + os.environ[auto_ask.ENV_DELAY] = "0" + self.assertEqual(auto_ask.delay(), auto_ask.DEFAULT_DELAY) + + +class TestEnterMeansTheDefault(EnvCase): + """Hors mode auto AUSSI : sinon la question mentirait à qui la lit.""" + + def test_an_empty_answer_takes_the_default(self): + import builtins + + original = builtins.input + builtins.input = lambda prompt="": "" + self.addCleanup(setattr, builtins, "input", original) + self.assertEqual(auto_ask.ask("q : ", default="y"), "y") + + def test_a_typed_answer_wins(self): + import builtins + + original = builtins.input + builtins.input = lambda prompt="": "n" + self.addCleanup(setattr, builtins, "input", original) + self.assertEqual(auto_ask.ask("q : ", default="y"), "n") + + def test_the_driver_exports_it_on_every_ask(self): + # Un outil lancé plus bas doit voir le mode auto même si la question + # d'activation date d'une reprise précédente. + import builtins + + original = builtins.input + builtins.input = lambda prompt="": "" + self.addCleanup(setattr, builtins, "input", original) + obj = TodoUpgrade.__new__(TodoUpgrade) + obj.auto_execute = True + obj.AUTO_DELAY = 0.2 + with contextlib.redirect_stdout(io.StringIO()): + obj.ask("q : ", default="y") + self.assertEqual(os.environ.get(auto_ask.ENV_ENABLED), "1") + + +class TestNoPromptOfTheMigrationCanHang(unittest.TestCase): + """Un `input()` nu ne sait rien du mode auto : il attend, pour toujours. + + Vécu à l'échelle du fichier : deux invites avaient échappé au premier + passage — la prédiction COW et le choix de désinstallation — parce que + le garde-fou ne regardait qu'`execute_odoo_upgrade`. On regarde + désormais TOUTES les méthodes du chemin de migration. + """ + + METHODES = ( + "execute_odoo_upgrade", + "prompt_cow_prediction", + "prompt_uninstall_theme", + "prompt_reset_stale_cow_views", + "prompt_database_cleanup", + "prompt_smoke_public_url", + "split_present_missing", + "prompt_uninstall_missing", + "todo_upgrade_execute", + ) + + def bare_inputs(self, nom): + chemin = os.path.join(REPO, "script", "todo", "todo_upgrade.py") + with open(chemin) as handle: + tree = ast.parse(handle.read()) + cible = [ + node + for node in ast.walk(tree) + if isinstance(node, ast.FunctionDef) and node.name == nom + ] + self.assertEqual(len(cible), 1, f"méthode introuvable : {nom}") + return [ + node.lineno + for node in ast.walk(cible[0]) + if isinstance(node, ast.Call) + and getattr(node.func, "id", "") == "input" + ] + + def test_none_of_them_asks_outside_the_timer(self): + coupables = { + nom: self.bare_inputs(nom) + for nom in self.METHODES + if self.bare_inputs(nom) + } + self.assertEqual(coupables, {}, "invites hors du mode auto") + + +class TestWhatEnterDoesIsWritten(unittest.TestCase): + """La question doit dire la vérité à qui appuie sur Entrée.""" + + def source(self, nom): + import inspect + + return inspect.getsource(getattr(TodoUpgrade, nom)) + + def test_the_theme_prompt_announces_Y(self): + source = self.source("prompt_uninstall_theme") + self.assertIn("(Y/n,", source) + self.assertIn('default="y"', source) + + def test_the_cleanup_prompt_announces_Y(self): + source = self.source("prompt_database_cleanup") + self.assertIn("(Y/n,", source) + self.assertIn('default="y"', source) + + def test_the_smoke_prompt_announces_Y(self): + source = self.source("prompt_smoke_public_url") + self.assertIn("(Y/n,", source) + self.assertIn('default="y"', source) + + def test_the_reset_prompt_says_enter_is_all(self): + source = self.source("prompt_reset_stale_cow_views") + self.assertIn("Enter = all", source) + self.assertIn('default="a"', source) + + def test_the_cow_prediction_says_enter_neutralizes(self): + source = self.source("prompt_cow_prediction") + self.assertIn("Enter = neutralize now", source) + self.assertIn('default="a"', source) + + def test_the_uninstall_strategy_defaults_to_one(self): + source = self.source("prompt_uninstall_missing") + self.assertIn("Enter = 1", source) + self.assertIn('default="1"', source) + + def test_every_default_has_a_way_to_say_no(self): + # Un défaut qui agit sans issue n'est plus un défaut, c'est un ordre. + for nom, refus in ( + ("prompt_reset_stale_cow_views", '== "n"'), + ("prompt_uninstall_missing", '== "3"'), + ("prompt_cow_prediction", "n = "), + ): + self.assertIn(refus, self.source(nom), nom) + + +class TestTheToolsLaunchedApart(unittest.TestCase): + def test_the_leftovers_default_to_deleting(self): + import theme_leftover + + self.assertEqual(theme_leftover.DEFAULT_ANSWER, "d") + + def test_the_leftovers_are_saved_BEFORE_being_deleted(self): + # C'est ce qui rend ce défaut tenable. Sans la sauvegarde, il aurait + # dû rester « garder » : on ne fait pas d'une décision irréversible + # la réponse qu'on obtient en ne répondant pas. + import inspect + + import theme_leftover + + source = inspect.getsource(theme_leftover.prompt) + self.assertLess( + source.index("backup_attachments"), + source.index("delete_attachments"), + ) + + def test_the_leftovers_prompt_says_enter_deletes(self): + import inspect + + import theme_leftover + + source = inspect.getsource(theme_leftover.prompt) + self.assertIn("Enter = delete", source) + self.assertIn("k = ", source) + + def test_the_smoke_reset_defaults_to_all(self): + import smoke_public_url + + self.assertEqual(smoke_public_url.DEFAULT_ANSWER, "a") + + def test_the_scss_default_follows_what_the_checkout_can_do(self): + # « a » là où `reset_asset` n'existe pas (avant la 13.0) ferait + # boucler l'invite sur elle-même : le défaut suit la capacité. + import inspect + + import check_stale_scss + + source = inspect.getsource(check_stale_scss.prompt) + self.assertIn('defaut = "a" if can_reset else "n"', source) + + def test_each_of_them_can_run_without_a_driver(self): + # Le repli compte : ces outils se lancent aussi à la main, et une + # ImportError les rendrait inutilisables hors migration. + import check_stale_scss + import smoke_public_url + import theme_leftover + + for module in (theme_leftover, smoke_public_url, check_stale_scss): + import inspect + + self.assertIn( + "auto_ask = None", + inspect.getsource(module), + module.__name__, + ) + + +class TestTestingEveryBump(unittest.TestCase): + """« a » dit une fois ce qu'on répétait six fois.""" + + def source(self): + import inspect + + return inspect.getsource(TodoUpgrade.execute_odoo_upgrade) + + def test_the_option_is_offered(self): + self.assertIn("Open it at EVERY version bump", self.source()) + + def test_it_is_remembered_in_the_progression(self): + # Sinon une reprise après interruption reposerait la question, et + # l'automatisation s'arrêterait au premier palier suivant. + source = self.source() + self.assertIn("state_4_selenium_every_bump", source) + self.assertIn("self.write_config()", source) + + def test_the_memory_stops_the_question(self): + source = self.source() + debut = source.index("state_4_selenium_every_bump") + fenetre = source[debut : debut + 400] + self.assertIn('status = "y"', fenetre) + + def test_it_belongs_to_step_four(self): + # Le nom porte sa propriété : un retour avant l'étape 4 doit + # l'effacer, comme tout ce que l'étape 4 a décidé. + from script.todo.todo_upgrade import STEP_PREFIX_RE + + match = STEP_PREFIX_RE.match("state_4_selenium_every_bump") + self.assertIsNotNone(match) + self.assertEqual(match.group(1), "4") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_reset_stale_cow_prompt.py b/test/test_reset_stale_cow_prompt.py index 35550e1..8833489 100755 --- a/test/test_reset_stale_cow_prompt.py +++ b/test/test_reset_stale_cow_prompt.py @@ -45,7 +45,9 @@ class PromptCase(unittest.TestCase): upgrade.stale_cow_keys = lambda db: lst_key lst_cmd = [] upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0 - upgrade.ask_gate = lambda prompt: answer + # Le doublon HONORE le défaut, comme le vrai `ask_gate` : sinon + # les tests de défaut ne testeraient que le doublon. + upgrade.ask_gate = lambda prompt, default="": answer or default out = io.StringIO() with contextlib.redirect_stdout(out): upgrade.prompt_reset_stale_cow_views("db") @@ -88,10 +90,17 @@ class TestWhatEachAnswerRuns(PromptCase): lst_cmd, _ = self.run_prompt("a") self.assertIn("--reset all", lst_cmd[0]) - def test_empty_resets_nothing(self): - # Par défaut on ne touche à rien : une copie porte parfois une vraie - # personnalisation, et la réinitialiser la jette. - lst_cmd, text = self.run_prompt("") + def test_enter_resets_them_all(self): + # Entrée les prend TOUTES : elles ne sont dans cette liste que + # parce qu'un enfant n'y trouve plus son ancrage, et l'outil sauve + # ce que chaque copie portait avant de la réinitialiser. + lst_cmd, _text = self.run_prompt("") + self.assertEqual(len(lst_cmd), 1) + self.assertIn("--reset all", lst_cmd[0]) + + def test_n_resets_nothing(self): + # Le défaut ne retire pas le choix : il ne fait qu'en proposer un. + lst_cmd, text = self.run_prompt("n") self.assertEqual(lst_cmd, []) self.assertIn("Kept", text) diff --git a/test/test_smoke_public_url.py b/test/test_smoke_public_url.py index 630d7e5..8d2c9db 100755 --- a/test/test_smoke_public_url.py +++ b/test/test_smoke_public_url.py @@ -414,14 +414,21 @@ class TestTheMigrationOffersIt(unittest.TestCase): upgrade.write_config = lambda: None lst_cmd = [] upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0 - upgrade.ask_gate = lambda prompt: answer + # Le doublon HONORE le défaut, comme le vrai `ask_gate`. + upgrade.ask_gate = lambda prompt, default="": answer or default upgrade.prompt_smoke_public_url("db_upgrade_13") return lst_cmd - def test_the_default_runs_nothing(self): - # Cela démarre un serveur et peut durer : pas à chaque palier sans - # qu'on l'ait demandé. - self.assertEqual(self.run_prompt(""), []) + def test_the_default_runs_it(self): + # Une page cassée qu'on ne demande pas reste cassée. Le coût est + # quelques minutes de serveur ; le prix de l'ignorer est de + # découvrir le 500 six paliers plus loin. + lst_cmd = self.run_prompt("") + self.assertEqual(len(lst_cmd), 1) + self.assertIn("smoke_public_url.py", lst_cmd[0]) + + def test_saying_no_still_skips_it(self): + self.assertEqual(self.run_prompt("n"), []) def test_yes_runs_it_on_the_upgraded_database(self): lst_cmd = self.run_prompt("y") @@ -476,7 +483,7 @@ class TestTheMigrationOffersIt(unittest.TestCase): upgrade.lst_command_executed = [] upgrade.write_config = lambda: None upgrade.run_on_terminal = lambda cmd: 0 - upgrade.ask_gate = lambda prompt: "" + upgrade.ask_gate = lambda prompt, default="": "n" out = io.StringIO() with contextlib.redirect_stdout(out): upgrade.prompt_smoke_public_url("db", baseline=True) diff --git a/test/test_todo_upgrade_cow_prompt.py b/test/test_todo_upgrade_cow_prompt.py index 9188cb3..323986d 100644 --- a/test/test_todo_upgrade_cow_prompt.py +++ b/test/test_todo_upgrade_cow_prompt.py @@ -60,24 +60,33 @@ class PromptCase(unittest.TestCase): class TestWhatEachAnswerRuns(PromptCase): - def test_enter_runs_nothing_and_says_so(self): - lst_cmd, text = self.run_prompt([""]) + def test_enter_neutralizes_now(self): + # Entrée neutralise : c'est réversible — l'arch est conservée — et + # ce qu'on neutralise ici vaut pour TOUS les paliers, au lieu + # d'attendre des dizaines de minutes pour redécider à chaque fois. + lst_cmd, _text = self.run_prompt([""]) + self.assertEqual(len(lst_cmd), 1) + self.assertIn("--apply", lst_cmd[0]) + + def test_n_runs_nothing_and_says_so(self): + # Le défaut ne retire pas le choix : il ne fait qu'en proposer un. + lst_cmd, text = self.run_prompt(["n"]) self.assertEqual(lst_cmd, []) self.assertIn("version bump", text) def test_v_shows_what_the_copies_hold(self): - lst_cmd, _ = self.run_prompt(["v", ""]) + lst_cmd, _ = self.run_prompt(["v", "n"]) self.assertEqual(len(lst_cmd), 1) self.assertIn("cow_drift.py", lst_cmd[0]) self.assertIn("-d db -t odoo13.0", lst_cmd[0]) self.assertNotIn("--shape", lst_cmd[0]) def test_s_shows_why_it_breaks(self): - lst_cmd, _ = self.run_prompt(["s", ""]) + lst_cmd, _ = self.run_prompt(["s", "n"]) self.assertTrue(lst_cmd[0].endswith("--shape")) def test_w_opens_the_full_screen_view(self): - lst_cmd, _ = self.run_prompt(["w", ""]) + lst_cmd, _ = self.run_prompt(["w", "n"]) self.assertTrue(lst_cmd[0].endswith("--tui")) def test_looking_does_not_answer_the_question(self): diff --git a/test/test_uninstall_addons_theme.py b/test/test_uninstall_addons_theme.py index 1b17920..af5ffa3 100755 --- a/test/test_uninstall_addons_theme.py +++ b/test/test_uninstall_addons_theme.py @@ -157,7 +157,9 @@ class TestTheMigrationOffersIt(unittest.TestCase): # Le désinstalleur ne passe PLUS par l'exécuteur qui capture : il # pose une question, et un tube la rendrait invisible. obj.run_on_terminal = lambda cmd: self.lst_cmd.append(cmd) or 0 - obj.ask_gate = lambda prompt: answer + # 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 return obj def run_prompt(self, lst_theme, answer): @@ -170,13 +172,32 @@ class TestTheMigrationOffersIt(unittest.TestCase): obj.prompt_uninstall_theme("db") return self.lst_cmd, out.getvalue() - def test_the_default_answer_uninstalls_nothing(self): - # « Entrée » ne doit RIEN faire : la migration ne décide pas de - # l'apparence d'un site à la place de son propriétaire. - lst_cmd, text = self.run_prompt(["theme_technolibre"], "") + 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") self.assertEqual(lst_cmd, []) self.assertIn("Kept", text) + 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) + def test_no_theme_means_no_question(self): lst_cmd, text = self.run_prompt([], "y") self.assertEqual(lst_cmd, []) From d8794f0e1078a6936b624237cfb56c454a0d763c Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 17 Aug 2026 22:16:40 -0400 Subject: [PATCH 35/67] [ADD] migration: open every app as the neutralization test user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public smoke test opens what a visitor reaches. It says nothing about the back office, which is where a migration does most of its damage: a field dropped from a model but still named in a form, a module installed in the database whose code no longer ships with the target version. None of it stops the module loading — it stops the day someone opens the app. Neutralizing installs a `test` / `test` login carrying the SYSTEM user's groups, and it survives the module's uninstall. So the tool checks for that user rather than trusting a flag, and it rides the server the public pass already started: booting Odoo is what costs minutes, not requests. Measured on a real 18.0 database of 25 apps, which found four defects of mine: web_search_read changed signature in 17, a bare 404 hides an unregistered model, embedded sub-views are not the parent's fields, and an empty psql result meant two different things. --- FR --- [ADD] migration : ouvrir chaque application avec l'utilisateur test Le test de fumée public ouvre ce qu'un visiteur atteint. Il ne dit rien du back-office, où une migration fait pourtant l'essentiel de ses dégâts : un champ retiré du modèle mais toujours nommé dans un formulaire, un module installé en base dont le code n'accompagne plus la version cible. Rien de cela n'arrête le chargement ; cela arrête le jour où l'on ouvre l'appli. La neutralisation pose un compte `test` / `test` portant les groupes du superutilisateur, et il survit à la désinstallation du module. L'outil vérifie donc cet utilisateur plutôt qu'un drapeau, et réutilise le serveur déjà démarré : c'est le démarrage qui coûte, pas les requêtes. Mesuré sur une vraie base 18.0 de 25 applications, ce qui a révélé quatre défauts de mon fait. Assisted-by: Claude Opus 5 --- script/odoo/migration/smoke_internal_ui.py | 765 +++++++++++++++++++++ script/odoo/migration/smoke_public_url.py | 112 ++- script/todo/todo_i18n.py | 124 ++++ script/todo/todo_upgrade.py | 11 +- test/test_smoke_internal_ui.py | 498 ++++++++++++++ 5 files changed, 1502 insertions(+), 8 deletions(-) create mode 100755 script/odoo/migration/smoke_internal_ui.py create mode 100644 test/test_smoke_internal_ui.py diff --git a/script/odoo/migration/smoke_internal_ui.py b/script/odoo/migration/smoke_internal_ui.py new file mode 100755 index 0000000..4d659ca --- /dev/null +++ b/script/odoo/migration/smoke_internal_ui.py @@ -0,0 +1,765 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Log in as `test` and open the first page of every Odoo app. + +Why this exists +--------------- +The public smoke test opens what a visitor can reach. It says nothing about +the BACK OFFICE, which is where a migration does most of its damage: a field +dropped from a model but still named in a form view, a widget renamed between +two versions, a stored compute that no longer resolves. None of it stops the +module loading — it stops the day someone opens the app. + +Why the `test` user +------------------- +Neutralizing a database installs `user_test`, which copies the SYSTEM user's +groups onto a `test` / `test` login (see its `post_init_hook`). The module is +uninstalled right after, but the user survives — its uninstall hook only +warns. So a neutralized database, and only a neutralized one, can be browsed +without knowing anyone's real password. The tool checks for that user rather +than trusting a flag: a resumed migration may have skipped neutralization, +and a promise in a progression file is not a login. + +What is actually exercised +-------------------------- +For each app — a root menu — its first page, meaning the first menu below it +that carries an action, which is what the web client opens on a click. Two +calls per app, both server-side: + +- ``get_views`` (12→15: ``load_views``) renders EVERY view arch of the + action. This is where a migration's damage surfaces, and it surfaces as an + exception rather than an empty screen. +- ``web_search_read`` (12: ``search_read``) loads the first page of records. + A view can render on an empty model and still explode on real data. + +Exit codes: 0 every app answered, 1 some failed, 2 the tool failed. +""" + +import argparse +import ast +import json +import os +import re +import sys +import urllib.error +import urllib.parse +import urllib.request + +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 + + +DEFAULT_LOGIN = "test" +DEFAULT_PASSWORD = "test" +DEFAULT_RECORD_LIMIT = 20 + +# Deux façons d'écrire le même champ caché selon la version du gabarit : +# l'ordre des attributs n'est pas garanti, et un seul motif en manquait la +# moitié — la connexion échouait alors sans rien dire d'utile. +RE_CSRF = re.compile( + r"""name=["']csrf_token["']\s+value=["']([^"']+)["']""" + r"""|value=["']([^"']+)["']\s+name=["']csrf_token["']""" +) +RE_DB_FIELD = re.compile(r"""name=["']db["']""") + + +def user_state(database, login=DEFAULT_LOGIN, run_psql=None): + """« present », « absent » ou « unknown ». La nuance n'est pas cosmétique. + + On INTERROGE plutôt que de croire un drapeau : une migration reprise a + pu sauter la neutralisation, et `state_1_neutralize_database` dirait + quand même « fait ». + + Mais `run_psql` rend une liste vide DANS LES DEUX CAS — aucune ligne, ou + requête refusée. Mesuré ici même : un « id » ambigu passait pour « pas + d'utilisateur test », et l'outil annonçait tranquillement que la base + n'avait pas été neutralisée. Un COUNT distingue les deux : il rend + toujours une ligne quand la requête aboutit. + """ + if run_psql is None: + from smoke_public_url import run_psql + sql = ( + "SELECT count(*) FROM res_users" f" WHERE login = '{login}' AND active" + ) + try: + rows = run_psql(database, sql) + except Exception: + return "unknown" + if not rows: + return "unknown" + try: + return "present" if int(rows[0][0]) else "absent" + except (TypeError, ValueError, IndexError): + return "unknown" + + +def user_exists(database, login=DEFAULT_LOGIN, run_psql=None): + """Raccourci : seul « present » vaut oui.""" + return user_state(database, login, run_psql=run_psql) == "present" + + +class Session: + """Un client HTTP qui garde son cookie de session. + + `urllib` sans gestionnaire de cookies renvoie chaque appel comme un + inconnu : on se connecte, puis on interroge en anonyme sans qu'aucune + erreur ne le signale — juste des résultats vides. + """ + + def __init__(self, base_url, timeout=60): + import http.cookiejar + + self.base_url = base_url.rstrip("/") + self.timeout = timeout + self.jar = http.cookiejar.CookieJar() + self.opener = urllib.request.build_opener( + urllib.request.HTTPCookieProcessor(self.jar) + ) + self.uid = None + # Le nom des méthodes a changé en cours de route (get_views en 16, + # load_views avant). On retient celui qui a répondu : chercher à + # chaque appel doublerait le nombre de requêtes. + self._views_method = None + self._read_method = None + + def open(self, path, data=None, headers=None): + url = self.base_url + path + request = urllib.request.Request(url, data=data, headers=headers or {}) + try: + with self.opener.open(request, timeout=self.timeout) as answer: + return answer.getcode(), answer.read().decode( + "utf-8", errors="replace" + ) + except urllib.error.HTTPError as exc: + return exc.code, exc.read().decode("utf-8", errors="replace") + except Exception as exc: + return 0, str(exc) + + def rpc(self, path, params): + """Un appel JSON-RPC. Rend (résultat, erreur) — jamais une exception. + + Une erreur Odoo arrive avec le statut 200 et un objet `error` dans + le corps : la traiter comme un succès ferait passer une vue cassée + pour une vue vide. + """ + payload = json.dumps( + { + "jsonrpc": "2.0", + "method": "call", + "params": params, + "id": 1, + } + ).encode("utf-8") + status, body = self.open( + path, data=payload, headers={"Content-Type": "application/json"} + ) + if not status: + return None, {"name": "transport", "message": body[:200]} + try: + answer = json.loads(body) + except ValueError: + return None, { + "name": f"HTTP {status}", + "message": body.strip()[:200], + } + if "error" in answer: + error = answer["error"] or {} + data = error.get("data") or {} + return None, { + "name": data.get("name") or error.get("message") or "error", + "message": ( + data.get("message") or error.get("message") or "" + ).strip(), + "debug": data.get("debug") or "", + } + return answer.get("result"), None + + def call_kw(self, model, method, args, kwargs=None): + return self.rpc( + "/web/dataset/call_kw", + { + "model": model, + "method": method, + "args": args, + "kwargs": kwargs or {}, + }, + ) + + def log_in(self, database, login, password): + """Se connecter par le formulaire, comme le ferait un navigateur. + + Le jeton CSRF n'est pas une formalité : sans lui Odoo rend la page + de connexion avec un statut 200, et l'on croirait être entré. + """ + status, body = self.open("/web/login") + if not status: + return False, t("The server did not serve the login page.") + match = RE_CSRF.search(body) + fields = { + "login": login, + "password": password, + "redirect": "", + } + if match: + fields["csrf_token"] = match.group(1) or match.group(2) + # Le champ `db` n'existe que si le serveur en propose plusieurs. + # L'envoyer toujours n'est pas anodin : certaines versions le + # refusent quand la liste des bases est masquée. + if RE_DB_FIELD.search(body): + fields["db"] = database + self.open( + "/web/login", + data=urllib.parse.urlencode(fields).encode("utf-8"), + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + # On ne LIT pas la redirection pour juger : on demande au serveur + # qui il croit que nous sommes. + info, error = self.rpc("/web/session/get_session_info", {}) + if error: + return False, f"{error['name']} : {error['message']}"[:200] + uid = (info or {}).get("uid") + if not uid: + return False, t("Wrong login or password for user") + self.uid = uid + return True, "" + + def views_of(self, model, lst_view, context=None): + """Faire rendre les arch côté serveur. C'est LE test. + + `get_views` depuis la 16, `load_views` avant, et la 18 n'a plus que + le premier. On essaie le moderne, on retombe sur l'ancien. + """ + kwargs = {"views": lst_view, "options": {}, "context": context or {}} + lst_try = ( + [self._views_method] + if self._views_method + else ["get_views", "load_views"] + ) + last = None + for method in lst_try: + result, error = self.call_kw(model, method, [], dict(kwargs)) + if error is None: + self._views_method = method + return result, None + if not _is_missing_method(error): + return None, error + last = error + return None, last + + def first_page(self, model, domain, context, limit, lst_field=None): + """Charger la première page d'enregistrements. + + `search_read` et non `web_search_read` : ce dernier a changé de + signature en cours de route — `fields` (une liste) est devenu + `specification` (un dictionnaire) en 17. Mesuré sur une base 18 : + seize applications sur vingt-cinq échouaient sur + « unexpected keyword argument 'fields' », c'est-à-dire sur MON + appel, pas sur la base. `search_read` n'a pas bougé depuis la 12. + + Une vue peut se rendre sur un modèle vide et exploser sur de + vraies données : un champ calculé qui ne résout plus ne se voit + qu'une fois qu'il y a quelque chose à calculer. + """ + return self.call_kw( + model, + "search_read", + [domain, lst_field or ["display_name"]], + {"limit": limit, "context": context or {}}, + ) + + def known_fields(self, model, context=None): + """Les champs que le modèle porte VRAIMENT, d'après le serveur.""" + result, error = self.call_kw( + model, "fields_get", [[], ["type"]], {"context": context or {}} + ) + if error: + return None, error + return sorted(result or {}), None + + +def arch_fields(views): + """Les champs que la première page affiche, et RIEN de plus. + + Analyse XML et non expression régulière, pour une raison mesurée : une + vue formulaire embarque les sous-vues de ses one2many, et un + `` qui s'y trouve appartient à la LIGNE de + facture, pas à la facture. Le motif à plat les ramassait aussi et + rapportait neuf applications « nommant un champ que le modèle n'a + plus » — toutes fausses. On ne descend donc jamais dans un ``. + + Lire `display_name` seul ne prouverait presque rien : ce sont les + colonnes de la liste qui font travailler l'ORM, et c'est là qu'un champ + calculé cassé par une migration se manifeste. + """ + import xml.etree.ElementTree as ET + + lst_arch = [] + + def descendre(noeud): + if isinstance(noeud, dict): + arch = noeud.get("arch") + if isinstance(arch, str): + lst_arch.append(arch) + for valeur in noeud.values(): + descendre(valeur) + elif isinstance(noeud, list): + for valeur in noeud: + descendre(valeur) + + descendre(views) + + lst_name = [] + + def parcourir(element): + for enfant in element: + if enfant.tag == "field": + nom = enfant.get("name") + # Un champ pointé vise un sous-modèle : le demander au + # modèle principal serait une erreur de NOTRE fait. + if nom and "." not in nom and nom not in lst_name: + lst_name.append(nom) + # On s'arrête là : ce qui est SOUS un champ appartient au + # modèle de ce champ, pas au nôtre. + continue + parcourir(enfant) + + for arch in lst_arch: + try: + racine = ET.fromstring(arch) + except ET.ParseError: + # Un arch illisible n'est pas une liste de champs vide : c'est + # une inconnue. La taire vaut mieux que d'inventer. + continue + if racine.tag == "field": + nom = racine.get("name") + if nom and "." not in nom and nom not in lst_name: + lst_name.append(nom) + else: + parcourir(racine) + return lst_name + + +def model_is_unregistered(error): + """L'erreur dit-elle « ce modèle n'existe pas dans ce registre » ? + + Odoo rend un 404 nu quand `call_kw` vise un modèle absent du registre : + « 404 Not Found: The requested URL was not found ». C'est illisible, et + pourtant c'est la trouvaille la plus nette d'une migration — le module + est installé dans la base, mais son code n'est plus dans l'addons path + de la version cible. Mesuré : cinq applications sur vingt-cinq. + """ + return "notfound" in (error.get("name") or "").lower().replace(".", "") + + +def _is_missing_method(error): + """L'erreur dit-elle « cette méthode n'existe pas » ? + + Il faut la distinguer d'une vraie panne : retomber sur l'ancien nom + après une AccessError masquerait l'AccessError derrière un + « méthode inconnue » qui n'a rien à voir. + """ + texte = f"{error.get('name', '')} {error.get('message', '')}".lower() + return ( + "not exist" in texte + or "unknown method" in texte + or "attributeerror" in texte + or "has no attribute" in texte + ) + + +def menu_rows(session): + """Les menus que CET utilisateur voit, dans l'ordre où il les voit. + + Par RPC et non par SQL : `ir.ui.menu` filtre selon les droits et rend + les noms traduits. Lire la table donnerait des menus inaccessibles et, + depuis la 16, un nom en jsonb qu'il faudrait décoder à la main. + """ + return session.call_kw( + "ir.ui.menu", + "search_read", + [[], ["id", "name", "parent_id", "sequence", "action"]], + {"context": {"lang": "en_US"}}, + ) + + +def apps(lst_menu): + """(application, menu de sa première page) pour chaque application. + + La première page d'une application est ce que le client web ouvre au + clic : le premier menu, en descendant, qui porte une action. Une + application qui n'en a aucune n'est pas une erreur — elle n'a rien à + ouvrir, et le dire évite de la chercher. + """ + par_parent = {} + for menu in lst_menu: + parent = menu.get("parent_id") + cle = parent[0] if isinstance(parent, (list, tuple)) else None + par_parent.setdefault(cle, []).append(menu) + for lst in par_parent.values(): + lst.sort(key=lambda m: (m.get("sequence") or 0, m.get("id") or 0)) + + def descendre(menu, vus): + if menu["id"] in vus: + return None + vus.add(menu["id"]) + if menu.get("action"): + return menu + for enfant in par_parent.get(menu["id"], []): + trouve = descendre(enfant, vus) + if trouve: + return trouve + return None + + resultat = [] + for racine in par_parent.get(None, []): + resultat.append((racine, descendre(racine, set()))) + return resultat + + +def actionable(lst_menu): + """TOUS les menus portant une action, pour le balayage complet.""" + return [menu for menu in lst_menu if menu.get("action")] + + +def split_action(reference): + """« ir.actions.act_window,42 » -> (« ir.actions.act_window », 42).""" + if not reference or "," not in str(reference): + return None, None + model, _sep, ident = str(reference).partition(",") + try: + return model.strip(), int(ident) + except ValueError: + return model.strip(), None + + +def literal(value, fallback): + """Un domaine ou un contexte, s'il est LITTÉRAL. Sinon le repli. + + Ils contiennent parfois des expressions Python — `uid`, `context_today` + — que seul le client sait évaluer. Les évaluer ici serait exécuter du + code venu de la base ; les refuser tout court retirerait la moitié des + applications du balayage. On teste donc avec un domaine vide, et on le + DIT dans le rapport. + """ + if not value or value in ("[]", "{}"): + return fallback, False + try: + return ast.literal_eval(value), False + except (ValueError, SyntaxError): + return fallback, True + + +def view_pairs(view_mode): + """« list,form » -> [[False, « list »], [False, « form »]]. + + `qweb` est écarté : son rendu demande un enregistrement précis et un + contexte de rapport, pas une ouverture de menu. + """ + lst = [] + for mode in (view_mode or "list,form").split(","): + mode = mode.strip() + if mode and mode != "qweb": + lst.append([False, mode]) + return lst or [[False, "form"]] + + +def check_entry(session, app, menu, limit=DEFAULT_RECORD_LIMIT): + """Ouvrir une entrée de menu. Rend un dict décrivant ce qui s'est passé.""" + resultat = { + "app": app["name"], + "menu": menu["name"], + "action": menu.get("action"), + "model": None, + "kind": None, + "error": None, + "stage": None, + "domain_ignored": False, + "unknown_fields": [], + "fields_read": 0, + } + model_action, ident = split_action(menu.get("action")) + resultat["kind"] = model_action + if model_action != "ir.actions.act_window" or not ident: + # Une action client n'a pas d'arch à rendre, une action serveur + # ÉCRIT. Ni l'une ni l'autre ne se teste en ouvrant une page. + return resultat + fields = ["res_model", "view_mode", "domain", "context", "limit"] + lst_action, error = session.call_kw( + "ir.actions.act_window", "read", [[ident], fields] + ) + if error: + resultat["error"] = error + resultat["stage"] = "action" + return resultat + if not lst_action: + return resultat + action = lst_action[0] + model = action.get("res_model") + resultat["model"] = model + if not model: + return resultat + domain, brut_domaine = literal(action.get("domain"), []) + context, brut_contexte = literal(action.get("context"), {}) + if not isinstance(domain, list): + domain, brut_domaine = [], True + if not isinstance(context, dict): + context, brut_contexte = {}, True + resultat["domain_ignored"] = brut_domaine or brut_contexte + + views, error = session.views_of( + model, view_pairs(action.get("view_mode")), context + ) + if error: + resultat["error"] = error + resultat["stage"] = ( + "registry" if model_is_unregistered(error) else "views" + ) + return resultat + + # Les champs de la page, et ceux que le modèle porte vraiment. L'écart + # entre les deux EST le dégât d'une migration : une vue qui nomme un + # champ disparu. Certaines versions le laissent passer au rendu et + # n'échouent qu'à la lecture — autant le nommer tout de suite. + lst_arch = arch_fields(views) + lst_known, error = session.known_fields(model, context) + if error: + resultat["error"] = error + resultat["stage"] = "fields" + return resultat + resultat["unknown_fields"] = [ + name for name in lst_arch if name not in lst_known + ] + lst_read = [name for name in lst_arch if name in lst_known] + resultat["fields_read"] = len(lst_read) + + _rows, error = session.first_page( + model, + domain, + context, + min(limit, action.get("limit") or limit), + lst_field=lst_read or ["display_name"], + ) + if error: + resultat["error"] = error + resultat["stage"] = "records" + return resultat + + +def crawl(session, limit=DEFAULT_RECORD_LIMIT, every_menu=False): + """Parcourir les applications. Rend (entrées visitées, échecs).""" + lst_menu, error = menu_rows(session) + if error: + raise RuntimeError( + f"{t('Could not read the menus')} : {error['name']}" + f" {error['message']}"[:300] + ) + if every_menu: + par_id = {menu["id"]: menu for menu in lst_menu} + lst_entry = [ + (_root_of(menu, par_id), menu) for menu in actionable(lst_menu) + ] + else: + lst_entry = [ + (app, first) for app, first in apps(lst_menu) if first is not None + ] + lst_result = [ + check_entry(session, app, menu, limit=limit) for app, menu in lst_entry + ] + return lst_result, [item for item in lst_result if item["error"]] + + +def _root_of(menu, par_id): + """Remonter jusqu'à l'application, pour dire d'où vient le menu.""" + vus = set() + courant = menu + while True: + parent = courant.get("parent_id") + cle = parent[0] if isinstance(parent, (list, tuple)) else None + if cle is None or cle in vus or cle not in par_id: + return courant + vus.add(cle) + courant = par_id[cle] + + +def render(lst_result, lst_failure): + """Le rapport. Ce qui a ouvert, et ce qui a refusé de s'ouvrir.""" + lignes = [] + lignes.append( + f"\n✨ {t('Apps opened as the test user')} :" + f" {len(lst_result) - len(lst_failure)}/{len(lst_result)}" + ) + saute = [ + item + for item in lst_result + if not item["error"] and item["kind"] != "ir.actions.act_window" + ] + if saute: + lignes.append( + f" {len(saute)} {t('not openable this way (client or server')}" + f" {t('action): nothing to render.')}" + ) + approx = [item for item in lst_result if item["domain_ignored"]] + if approx: + lignes.append( + f" {len(approx)} {t('opened with an empty domain: theirs')}" + f" {t('needs the browser to evaluate it.')}" + ) + fantomes = [item for item in lst_result if item.get("unknown_fields")] + if fantomes: + lignes.append( + f"\n⚠️ {t('Views naming a field the model no longer has')} :" + ) + for item in fantomes: + lignes.append( + f" · {item['app']} [{item['model']}] :" + f" {', '.join(item['unknown_fields'][:8])}" + ) + if not lst_failure: + lignes.append(f"✅ {t('Every app opened its first page.')}") + return "\n".join(lignes) + lignes.append(f"\n❌ {t('Apps that failed to open')} :") + ETAPE = { + "action": t("reading the action"), + "views": t("rendering the views"), + "fields": t("listing the model fields"), + "records": t("loading the first records"), + "registry": t("model absent from the running registry"), + } + for item in lst_failure: + error = item["error"] + lignes.append( + f" · {item['app']} → {item['menu']}" + f" [{item['model'] or '-'}]" + ) + lignes.append( + f" {ETAPE.get(item['stage'], item['stage'])} :" + f" {error['name']}" + ) + if item["stage"] == "registry": + # Le 404 nu d'Odoo ne dit rien d'utile ; ce qu'il faut savoir, + # c'est que le code du module manque à CETTE version. + lignes.append( + f" {t('installed in the database, but its module code')}" + f" {t('is not in this addons path.')}" + ) + else: + message = (error.get("message") or "").strip().splitlines() + if message: + lignes.append(f" {message[0][:160]}") + lignes.append( + f"\nℹ {t('These are back-office failures: the public smoke test')}" + f" {t('cannot see them.')}" + ) + return "\n".join(lignes) + + +def run(base_url, database, login, password, limit, every_menu=False): + """Se connecter puis parcourir. Lève RuntimeError si l'on ne peut pas.""" + session = Session(base_url) + ok, raison = session.log_in(database, login, password) + if not ok: + raise RuntimeError(f"{t('Could not log in as')} '{login}' : {raison}") + return crawl(session, limit=limit, every_menu=every_menu) + + +def main(argv=None): + parser = argparse.ArgumentParser( + description=( + "Log in as the neutralization test user and open the first page" + " of every Odoo app, reporting what fails." + ) + ) + parser.add_argument("-d", "--database", required=True) + parser.add_argument("-c", "--config", default="./config.conf") + parser.add_argument("-p", "--port", type=int, default=None) + parser.add_argument("--login", default=DEFAULT_LOGIN) + parser.add_argument("--password", default=DEFAULT_PASSWORD) + parser.add_argument( + "--limit", + type=int, + default=DEFAULT_RECORD_LIMIT, + help="how many records to load per app", + ) + parser.add_argument( + "--all-menus", + action="store_true", + help="open every menu with an action, not just each app's first page", + ) + parser.add_argument( + "--boot-timeout", + type=int, + default=180, + help="how long to wait for the server to answer", + ) + config = parser.parse_args(argv) + + from smoke_public_url import ( + DEFAULT_PORT, + port_is_taken, + start_server, + stop_server, + wait_ready, + ) + + port = config.port or DEFAULT_PORT + etat = user_state(config.database, config.login) + if etat == "unknown": + # Ne PAS dire « pas neutralisée » : on ne le sait pas. Le dire + # ferait croire que le back-office va bien alors qu'on n'a rien vu. + print( + f"❌ {t('Could not tell whether the')} '{config.login}'" + f" {t('user exists in')} '{config.database}'." + ) + return 2 + if etat == "absent": + print( + f"ℹ️ {t('No')} '{config.login}' {t('user in')}" + f" '{config.database}' :" + f" {t('the database was not neutralized, nothing to browse.')}" + ) + return 0 + if port_is_taken(port): + print( + f"❌ {t('Something already listens on port')} {port} :" + f" {t('it would be tested instead of this database.')}" + ) + return 2 + base_url = f"http://127.0.0.1:{port}" + print( + f"⧖ {t('Starting Odoo on')} '{config.database}'" + f" ({t('port')} {port})…" + ) + server = start_server(config.database, port, config.config) + try: + if not wait_ready(base_url, timeout=config.boot_timeout): + print(f"❌ {t('The server never answered on')} {base_url}") + return 2 + lst_result, lst_failure = run( + base_url, + config.database, + config.login, + config.password, + config.limit, + every_menu=config.all_menus, + ) + except RuntimeError as exc: + print(f"❌ {exc}") + return 2 + finally: + stop_server(server) + print(render(lst_result, lst_failure)) + return 1 if lst_failure else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/odoo/migration/smoke_public_url.py b/script/odoo/migration/smoke_public_url.py index dd6023e..f0806c5 100755 --- a/script/odoo/migration/smoke_public_url.py +++ b/script/odoo/migration/smoke_public_url.py @@ -430,6 +430,65 @@ def prompt(database, lst_failure, lst_key, ask=None): return lst_chosen +def render_internal(internal): + """Afficher le rapport du back-office. Rend True s'il a échoué. + + Ne rien afficher quand la base n'a pas été neutralisée serait laisser + croire que le back-office a été testé et qu'il va bien. + """ + if internal is None: + return False + if "skipped" in internal: + print(f"\nℹ️ {t('Back office not browsed')} : {internal['skipped']}") + return False + import smoke_internal_ui + + print(smoke_internal_ui.render(internal["results"], internal["failures"])) + return bool(internal["failures"]) + + +def internal_phase( + base_url, + database, + enabled=True, + login="test", + password="test", + limit=20, + every_menu=False, +): + """Le back-office, si la base a été neutralisée. Rend None sinon. + + La condition n'est pas un drapeau mais l'utilisateur lui-même : la + neutralisation pose un compte `test` avec les groupes du + superutilisateur, et c'est le seul moyen d'entrer sans connaître le mot + de passe de quelqu'un. Une migration reprise a pu sauter l'étape, et le + fichier de progression dirait quand même « fait ». + + Un échec ICI ne doit pas emporter le test public : il est mesuré, il + est rapporté, mais le rapport des URL publiques a sa propre valeur. + """ + if not enabled: + return None + try: + import smoke_internal_ui + except ImportError: + return None + etat = smoke_internal_ui.user_state(database, login, run_psql=run_psql) + if etat == "absent": + return {"skipped": t("no test user: the database was not neutralized")} + if etat != "present": + # « je ne sais pas » n'est pas « tout va bien » : le dire autrement + # ferait passer un back-office jamais ouvert pour un back-office sain. + return {"skipped": t("could not tell whether the test user exists")} + try: + lst_result, lst_failure = smoke_internal_ui.run( + base_url, database, login, password, limit, every_menu=every_menu + ) + except RuntimeError as exc: + return {"skipped": str(exc)} + return {"results": lst_result, "failures": lst_failure} + + def run( database, port, @@ -440,6 +499,11 @@ def run( interactive=False, auto_apply=False, ask=input, + internal=True, + internal_login="test", + internal_password="test", + internal_limit=20, + every_menu=False, ): """Démarrer, interroger, arrêter, LIRE, éventuellement corriger, revérifier. @@ -472,6 +536,18 @@ def run( if limit: lst_url = lst_url[:limit] lst_failure = check_urls(lst_url, timeout=timeout) + # ICI, pendant que le serveur tourne : le démarrage d'Odoo est ce + # qui coûte des minutes, pas les requêtes. Un deuxième outil avec + # son propre serveur doublerait l'attente pour rien. + internal_report = internal_phase( + base_url, + database, + enabled=internal, + login=internal_login, + password=internal_password, + limit=internal_limit, + every_menu=every_menu, + ) finally: stop_server(server) @@ -486,7 +562,7 @@ def run( if key not in lst_key: lst_key.append(key) if not lst_failure or not (interactive or auto_apply): - return lst_url, lst_failure, lst_key, None + return lst_url, lst_failure, lst_key, None, internal_report print(render(lst_url, lst_failure, lst_key)) if auto_apply: @@ -499,7 +575,7 @@ def run( else: lst_done = prompt(database, lst_failure, lst_key, ask=ask) if not lst_done: - return lst_url, lst_failure, lst_key, None + return lst_url, lst_failure, lst_key, None, internal_report server = start_server(database, port, config_path, log_path=log_path) try: @@ -512,7 +588,7 @@ def run( ) finally: stop_server(server) - return lst_url, lst_failure, lst_key, lst_again + return lst_url, lst_failure, lst_key, lst_again, internal_report def stop_server(server): @@ -582,6 +658,24 @@ def main(argv=None): default=180, help="how long to wait for the server to answer", ) + parser.add_argument( + "--no-internal", + action="store_true", + help="skip the back-office pass done as the neutralization test user", + ) + parser.add_argument("--login", default="test") + parser.add_argument("--password", default="test") + parser.add_argument( + "--record-limit", + type=int, + default=20, + help="how many records each app loads on its first page", + ) + parser.add_argument( + "--all-menus", + action="store_true", + help="open every menu with an action, not just each app's first page", + ) config = parser.parse_args(argv) print( @@ -590,7 +684,7 @@ def main(argv=None): ) interactive = not config.report_only and can_ask() try: - lst_url, lst_failure, lst_key, lst_again = run( + lst_url, lst_failure, lst_key, lst_again, internal = run( config.database, config.port, config.config, @@ -599,20 +693,26 @@ def main(argv=None): boot=config.boot_timeout, interactive=interactive, auto_apply=config.apply, + internal=not config.no_internal, + internal_login=config.login, + internal_password=config.password, + internal_limit=config.record_limit, + every_menu=config.all_menus, ) except RuntimeError as exc: print(f"❌ {exc}") return 2 + internal_failed = render_internal(internal) if lst_again is None: print(render(lst_url, lst_failure, lst_key)) - return 1 if lst_failure else 0 + return 1 if (lst_failure or internal_failed) else 0 # Après correction on ne redit pas le diagnostic : on dit ce qu'il RESTE. print( f"\n↻ {t('Re-checked the')} {len(lst_failure)}" f" {t('failing URL(s) after the reset')} :" ) print(render([url for url, _s, _p in lst_failure], lst_again, None)) - return 1 if lst_again else 0 + return 1 if (lst_again or internal_failed) else 0 if __name__ == "__main__": diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index ead6173..623e8da 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -5286,6 +5286,130 @@ TRANSLATIONS = { "fr": "garder", "en": "keep", }, + "The server did not serve the login page.": { + "fr": "Le serveur n'a pas servi la page de connexion.", + "en": "The server did not serve the login page.", + }, + "Wrong login or password for user": { + "fr": "Identifiant ou mot de passe refusé pour l'utilisateur", + "en": "Wrong login or password for user", + }, + "Could not read the menus": { + "fr": "Impossible de lire les menus", + "en": "Could not read the menus", + }, + "Apps opened as the test user": { + "fr": "Applications ouvertes avec l'utilisateur test", + "en": "Apps opened as the test user", + }, + "not openable this way (client or server": { + "fr": "non ouvrables ainsi (action client ou", + "en": "not openable this way (client or server", + }, + "action): nothing to render.": { + "fr": "serveur) : rien à rendre.", + "en": "action): nothing to render.", + }, + "opened with an empty domain: theirs": { + "fr": "ouvertes avec un domaine vide : le leur", + "en": "opened with an empty domain: theirs", + }, + "needs the browser to evaluate it.": { + "fr": "demande le navigateur pour être évalué.", + "en": "needs the browser to evaluate it.", + }, + "Every app opened its first page.": { + "fr": "Chaque application a ouvert sa première page.", + "en": "Every app opened its first page.", + }, + "Apps that failed to open": { + "fr": "Applications qui n'ont pas pu s'ouvrir", + "en": "Apps that failed to open", + }, + "reading the action": { + "fr": "lecture de l'action", + "en": "reading the action", + }, + "rendering the views": { + "fr": "rendu des vues", + "en": "rendering the views", + }, + "loading the first records": { + "fr": "chargement des premiers enregistrements", + "en": "loading the first records", + }, + "These are back-office failures: the public smoke test": { + "fr": "Ce sont des échecs du back-office : le test de fumée public", + "en": "These are back-office failures: the public smoke test", + }, + "cannot see them.": { + "fr": "ne peut pas les voir.", + "en": "cannot see them.", + }, + "Could not log in as": { + "fr": "Connexion impossible en tant que", + "en": "Could not log in as", + }, + "No": { + "fr": "Aucun", + "en": "No", + }, + "user in": { + "fr": "utilisateur dans", + "en": "user in", + }, + "the database was not neutralized, nothing to browse.": { + "fr": "la base n'a pas été neutralisée, rien à parcourir.", + "en": "the database was not neutralized, nothing to browse.", + }, + "Back office not browsed": { + "fr": "Back-office non parcouru", + "en": "Back office not browsed", + }, + "no test user: the database was not neutralized": { + "fr": "pas d'utilisateur test : la base n'a pas été neutralisée", + "en": "no test user: the database was not neutralized", + }, + "Could not tell whether the": { + "fr": "Impossible de savoir si l'utilisateur", + "en": "Could not tell whether the", + }, + "user exists in": { + "fr": "existe dans", + "en": "user exists in", + }, + "could not tell whether the test user exists": { + "fr": "impossible de savoir si l'utilisateur test existe", + "en": "could not tell whether the test user exists", + }, + "listing the model fields": { + "fr": "inventaire des champs du modèle", + "en": "listing the model fields", + }, + "model absent from the running registry": { + "fr": "modèle absent du registre en cours", + "en": "model absent from the running registry", + }, + "installed in the database, but its module code": { + "fr": "installé dans la base, mais le code de son module", + "en": "installed in the database, but its module code", + }, + "is not in this addons path.": { + "fr": "n'est pas dans cet addons path.", + "en": "is not in this addons path.", + }, + "Views naming a field the model no longer has": { + "fr": "Vues nommant un champ que le modèle n'a plus", + "en": "Views naming a field the model no longer has", + }, + "Public pages, then the back office as the test user": { + "fr": "Les pages publiques, puis le back-office avec l'utilisateur test", + "en": "Public pages, then the back office as the test user", + }, + "if the database was neutralized.": { + "fr": "si la base a été neutralisée.", + "en": "if the database was neutralized.", + }, "Clean the database before testing the pages?": { "fr": "Nettoyer la base avant de tester les pages ?", "en": "Clean the database before testing the pages?", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index d4e693a..d475978 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -3017,8 +3017,11 @@ class TodoUpgrade: de départ, les mêmes 500 après le palier passent pour un dégât de la migration, et l'on cherche du mauvais côté. - « non » par défaut : cela démarre un serveur et peut prendre quelques - minutes sur un gros site. + Le serveur ainsi démarré sert AUSSI au back-office : si la base a + été neutralisée, l'outil se connecte avec l'utilisateur `test` que + la neutralisation y a posé et ouvre la première page de chaque + application. Un deuxième outil avec son propre serveur doublerait + l'attente — le démarrage d'Odoo est ce qui coûte, pas les requêtes. """ if baseline: print( @@ -3037,6 +3040,10 @@ class TodoUpgrade: ) if answer != "y": return + print( + f" {t('Public pages, then the back office as the test user')}" + f" {t('if the database was neutralized.')}" + ) self.run_on_terminal( f"{PYTHON_BIN}" " ./script/odoo/migration/smoke_public_url.py" diff --git a/test/test_smoke_internal_ui.py b/test/test_smoke_internal_ui.py new file mode 100644 index 0000000..3f4c187 --- /dev/null +++ b/test/test_smoke_internal_ui.py @@ -0,0 +1,498 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Ouvrir le back-office comme quelqu'un qui s'y connecte. + +Le test de fumée public ouvre ce qu'un visiteur atteint. Il ne dit RIEN du +back-office, où une migration fait pourtant l'essentiel de ses dégâts : un +champ retiré du modèle mais toujours nommé dans un formulaire, un modèle +dont le code n'accompagne plus la version cible. Rien de tout cela n'arrête +le chargement des modules ; cela arrête le jour où quelqu'un ouvre l'appli. + +Ce que ces tests verrouillent est ce que l'exécution réelle a corrigé — et +chaque point ci-dessous a d'abord été un vrai défaut, mesuré sur une base +18.0 de 25 applications : + +- `web_search_read` a changé de signature en 17 (`fields` est devenu + `specification`). Seize applications sur vingt-deux échouaient sur MON + appel. `search_read` n'a pas bougé depuis la 12 ; +- un modèle absent du registre rend un « 404 Not Found » nu, illisible, + alors que c'est la trouvaille la plus nette d'une migration ; +- lire les champs d'un arch à l'expression régulière ramasse ceux des + SOUS-VUES : neuf applications rapportées « nommant un champ absent », + toutes fausses, parce que les lignes d'une facture ne sont pas des + champs de la facture ; +- `run_psql` rend une liste vide aussi bien pour « aucune ligne » que pour + « requête refusée » : un SQL fautif faisait dire « base non neutralisée ». +""" + +import os +import sys +import unittest + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(REPO, "script", "odoo", "migration")) + +import smoke_internal_ui as ui # noqa: E402 + + +class TestFindingTheFirstPageOfEachApp(unittest.TestCase): + """Ce que le client web ouvre au clic : le premier menu avec une action.""" + + MENUS = [ + { + "id": 1, + "name": "Ventes", + "parent_id": False, + "sequence": 10, + "action": False, + }, + { + "id": 2, + "name": "Commandes", + "parent_id": [1, "Ventes"], + "sequence": 5, + "action": False, + }, + { + "id": 3, + "name": "Devis", + "parent_id": [2, "Commandes"], + "sequence": 1, + "action": "ir.actions.act_window,42", + }, + { + "id": 4, + "name": "Clients", + "parent_id": [1, "Ventes"], + "sequence": 9, + "action": "ir.actions.act_window,43", + }, + { + "id": 5, + "name": "Réglages", + "parent_id": False, + "sequence": 99, + "action": "ir.actions.act_window,7", + }, + { + "id": 6, + "name": "Vide", + "parent_id": False, + "sequence": 50, + "action": False, + }, + ] + + def test_it_descends_to_the_first_actionable_menu(self): + trouve = dict( + (app["name"], first["name"] if first else None) + for app, first in ui.apps(self.MENUS) + ) + self.assertEqual(trouve["Ventes"], "Devis") + + def test_sequence_decides_not_the_id(self): + # « Commandes » (5) passe avant « Clients » (9) : l'ordre affiché + # est celui que l'utilisateur voit, pas l'ordre d'insertion. + trouve = dict( + (app["name"], first["name"] if first else None) + for app, first in ui.apps(self.MENUS) + ) + self.assertNotEqual(trouve["Ventes"], "Clients") + + def test_an_app_carrying_its_own_action_is_its_own_first_page(self): + trouve = dict( + (app["name"], first["name"] if first else None) + for app, first in ui.apps(self.MENUS) + ) + self.assertEqual(trouve["Réglages"], "Réglages") + + def test_an_app_without_any_action_is_not_an_error(self): + # Elle n'a rien à ouvrir. La compter comme un échec ferait chercher + # un dégât là où il n'y a qu'un menu de regroupement. + trouve = dict( + (app["name"], first["name"] if first else None) + for app, first in ui.apps(self.MENUS) + ) + self.assertIsNone(trouve["Vide"]) + + def test_a_menu_loop_does_not_hang(self): + # Une base migrée porte parfois des données incohérentes ; un + # parcours naïf tournerait indéfiniment sans rien dire. + boucle = [ + { + "id": 1, + "name": "A", + "parent_id": False, + "sequence": 1, + "action": False, + }, + { + "id": 2, + "name": "B", + "parent_id": [1, "A"], + "sequence": 1, + "action": False, + }, + { + "id": 3, + "name": "C", + "parent_id": [2, "B"], + "sequence": 1, + "action": False, + }, + ] + boucle[0]["parent_id"] = [3, "C"] + self.assertIsInstance(ui.apps(boucle), list) + + +class TestReadingTheFieldsOfAPage(unittest.TestCase): + """Les champs de la page, et RIEN de ce qui appartient aux sous-vues.""" + + FORM = { + "views": { + "form": { + "arch": ( + "
" + '' + '' + '' + '' + "" + '' + "" + ) + } + } + } + + def test_the_top_level_fields_are_taken(self): + self.assertEqual( + ui.arch_fields(self.FORM), + ["partner_id", "line_ids", "amount_total"], + ) + + def test_the_fields_of_an_embedded_view_are_NOT(self): + # Le défaut mesuré : `product_id` et `price_unit` sont des champs de + # la LIGNE de facture. Les attribuer à la facture faisait rapporter + # neuf applications cassées qui allaient parfaitement bien. + lst = ui.arch_fields(self.FORM) + self.assertNotIn("product_id", lst) + self.assertNotIn("price_unit", lst) + + def test_a_dotted_name_is_left_alone(self): + views = {"a": {"arch": ''}} + self.assertEqual(ui.arch_fields(views), []) + + def test_an_unreadable_arch_yields_nothing_rather_than_nonsense(self): + views = {"a": {"arch": "
'} + } + } + self.assertEqual(ui.arch_fields(ancien), ["x"]) + + +class TestTheThreeStatesOfTheTestUser(unittest.TestCase): + """« je ne sais pas » n'est pas « il n'y en a pas ». + + Vécu : un « id » ambigu dans MON SQL faisait rendre une liste vide, et + l'outil annonçait tranquillement que la base n'avait pas été + neutralisée. Le back-office n'était pas testé, et rien ne le disait. + """ + + def test_a_row_saying_one_is_present(self): + self.assertEqual( + ui.user_state("db", run_psql=lambda d, s: [["1"]]), "present" + ) + + def test_a_row_saying_zero_is_absent(self): + self.assertEqual( + ui.user_state("db", run_psql=lambda d, s: [["0"]]), "absent" + ) + + def test_no_row_at_all_is_unknown(self): + self.assertEqual( + ui.user_state("db", run_psql=lambda d, s: []), "unknown" + ) + + def test_a_psql_that_raises_is_unknown(self): + def explose(database, sql): + raise OSError("psql absent") + + self.assertEqual(ui.user_state("db", run_psql=explose), "unknown") + + def test_it_counts_rather_than_selects(self): + # Un COUNT rend TOUJOURS une ligne quand la requête aboutit : c'est + # ce qui sépare « aucun utilisateur » de « requête refusée ». + vu = {} + ui.user_state( + "db", run_psql=lambda d, s: vu.setdefault("sql", s) and [] + ) + self.assertIn("count(", vu["sql"].lower()) + + +class TestTellingRealBreakageFromNoise(unittest.TestCase): + def test_a_bare_404_means_the_model_is_not_registered(self): + # Le message d'Odoo — « 404 Not Found: The requested URL... » — ne + # dit rien. Ce qu'il faut savoir : le module est installé dans la + # base et son code manque à cette version. + self.assertTrue( + ui.model_is_unregistered({"name": "werkzeug.exceptions.NotFound"}) + ) + + def test_an_access_error_is_not_that(self): + self.assertFalse( + ui.model_is_unregistered({"name": "odoo.exceptions.AccessError"}) + ) + + def test_a_missing_method_is_recognised(self): + self.assertTrue( + ui._is_missing_method( + { + "name": "builtins.AttributeError", + "message": "object has no attribute 'load_views'", + } + ) + ) + + def test_a_real_failure_is_not_mistaken_for_one(self): + # Retomber sur l'ancien nom de méthode après une vraie panne + # masquerait la panne derrière un « méthode inconnue » sans rapport. + self.assertFalse( + ui._is_missing_method( + {"name": "odoo.exceptions.AccessError", "message": "refusé"} + ) + ) + + +class TestNotEvaluatingWhatComesFromTheDatabase(unittest.TestCase): + """Un domaine d'action est parfois du Python. On ne l'exécute pas.""" + + def test_a_literal_domain_is_used(self): + self.assertEqual( + ui.literal("[('a','=',1)]", []), ([("a", "=", 1)], False) + ) + + def test_an_expression_falls_back_and_SAYS_so(self): + # `uid`, `context_today` : seul le navigateur sait les évaluer. Le + # second membre du couple est ce qui permet de le dire au rapport + # au lieu de faire passer une approximation pour une mesure. + valeur, approx = ui.literal("[('user_id','=',uid)]", []) + self.assertEqual(valeur, []) + self.assertTrue(approx) + + def test_an_empty_domain_is_not_an_approximation(self): + self.assertEqual(ui.literal("[]", []), ([], False)) + + +class TestWhatIsWorthOpening(unittest.TestCase): + def test_a_window_action_is_split(self): + self.assertEqual( + ui.split_action("ir.actions.act_window,42"), + ("ir.actions.act_window", 42), + ) + + def test_a_server_action_is_recognised_and_left_alone(self): + session = object() + app = {"id": 1, "name": "X"} + menu = {"id": 2, "name": "Y", "action": "ir.actions.server,9"} + # Une action serveur ÉCRIT : l'exécuter pour « voir si ça marche » + # ferait justement ce qu'un test ne doit pas faire. + result = ui.check_entry(session, app, menu) + self.assertEqual(result["kind"], "ir.actions.server") + self.assertIsNone(result["error"]) + self.assertIsNone(result["model"]) + + def test_qweb_is_not_a_page_to_open(self): + self.assertEqual( + ui.view_pairs("list,qweb,form"), [[False, "list"], [False, "form"]] + ) + + def test_no_view_mode_still_gives_something(self): + self.assertTrue(ui.view_pairs(None)) + + +class TestLoadingTheFirstPage(unittest.TestCase): + """L'appel qui charge les enregistrements, et sa forme exacte. + + C'EST le défaut qui a coûté le plus cher : `web_search_read` a changé + de signature en 17 — `fields`, une liste, est devenu `specification`, + un dictionnaire. Mesuré sur une base 18.0 : seize applications sur + vingt-deux échouaient sur « unexpected keyword argument 'fields' », + c'est-à-dire sur MON appel et non sur la base. `search_read` n'a pas + bougé depuis la 12. + """ + + def call(self, lst_field=None): + session = ui.Session("http://x") + vu = {} + + def faux(model, method, args, kwargs=None): + vu.update( + model=model, method=method, args=args, kwargs=kwargs or {} + ) + return [], None + + session.call_kw = faux + session.first_page("sale.order", [], {}, 20, lst_field=lst_field) + return vu + + def test_it_uses_the_call_that_never_changed(self): + self.assertEqual(self.call(["name"])["method"], "search_read") + + def test_the_fields_go_where_search_read_expects_them(self): + vu = self.call(["name", "partner_id"]) + self.assertEqual(vu["args"], [[], ["name", "partner_id"]]) + self.assertEqual(vu["kwargs"]["limit"], 20) + + def test_without_fields_it_still_loads_something(self): + self.assertEqual(self.call()["args"][1], ["display_name"]) + + def test_the_page_reads_the_columns_it_shows(self): + # Lire `display_name` seul ne prouverait presque rien : ce sont les + # colonnes de la liste qui font travailler l'ORM, et c'est là qu'un + # champ calculé cassé par la migration se manifeste. + import inspect + + source = inspect.getsource(ui.check_entry) + self.assertIn("arch_fields(views)", source) + self.assertIn("lst_field=lst_read", source) + + def test_a_field_the_model_lacks_is_never_requested(self): + # Le demander ferait échouer la lecture par NOTRE faute, et l'on + # perdrait le vrai signal derrière une erreur qu'on a causée. + source = __import__("inspect").getsource(ui.check_entry) + self.assertIn("if name in lst_known", source) + + +class TestTheViewsAreRenderedServerSide(unittest.TestCase): + def test_it_tries_the_modern_name_then_the_old_one(self): + # get_views depuis la 16, load_views avant, et la 18 n'a plus que le + # premier : une seule des deux ne couvrirait pas 12→18. + import inspect + + source = inspect.getsource(ui.Session.views_of) + self.assertIn("get_views", source) + self.assertIn("load_views", source) + self.assertLess(source.index("get_views"), source.index("load_views")) + + def test_the_resolved_name_is_remembered(self): + # Chercher à chaque application doublerait le nombre de requêtes. + session = ui.Session("http://x") + appels = [] + + def faux(model, method, args, kwargs=None): + appels.append(method) + if method == "get_views": + return None, { + "name": "builtins.AttributeError", + "message": "has no attribute", + } + return {"views": {}}, None + + session.call_kw = faux + session.views_of("m", [[False, "list"]]) + session.views_of("m", [[False, "list"]]) + self.assertEqual(appels, ["get_views", "load_views", "load_views"]) + + +class TestTheReport(unittest.TestCase): + 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 entry(self, **kw): + item = { + "app": "Sales", + "menu": "Quotations", + "model": "sale.order", + "kind": "ir.actions.act_window", + "error": None, + "stage": None, + "domain_ignored": False, + "unknown_fields": [], + "fields_read": 3, + } + item.update(kw) + return item + + def test_all_clean_says_so(self): + text = ui.render([self.entry()], []) + self.assertIn("1/1", text) + self.assertIn("opened its first page", text) + + def test_a_registry_failure_explains_itself(self): + # Le 404 nu est illisible : le rapport doit dire ce qu'il signifie. + item = self.entry( + stage="registry", + error={"name": "werkzeug.exceptions.NotFound", "message": "404"}, + ) + text = ui.render([item], [item]) + self.assertIn("addons path", text) + self.assertNotIn("The requested URL", text) + + def test_ghost_fields_are_reported_without_being_a_failure(self): + # Une vue qui nomme un champ disparu n'empêche pas toujours la page + # de s'ouvrir. Le taire laisserait passer la cause d'une panne à + # venir ; le compter comme un échec ferait crier au loup. + item = self.entry(unknown_fields=["grant_offer_id"]) + text = ui.render([item], []) + self.assertIn("grant_offer_id", text) + self.assertIn("1/1", text) + + def test_an_approximate_domain_is_admitted(self): + text = ui.render([self.entry(domain_ignored=True)], []) + self.assertIn("empty domain", text) + + def test_the_report_says_the_public_test_cannot_see_this(self): + item = self.entry( + stage="records", error={"name": "KeyError", "message": "x"} + ) + text = ui.render([item], [item]) + self.assertIn( + "back-office", text.lower().replace("back office", "back-office") + ) + + +class TestItRidesTheServerAlreadyRunning(unittest.TestCase): + """Le démarrage d'Odoo coûte des minutes ; les requêtes, non.""" + + def test_the_public_tool_runs_it_in_the_same_session(self): + import inspect + + import smoke_public_url as public + + source = inspect.getsource(public.run) + debut = source.index("check_urls(") + arret = source.index("stop_server(server)") + self.assertLess(debut, source.index("internal_phase(")) + self.assertLess(source.index("internal_phase("), arret) + + def test_an_unknown_state_is_not_reported_as_healthy(self): + import smoke_public_url as public + + rapport = public.internal_phase("http://x", "db", enabled=True) + self.assertIsNotNone(rapport) + + def test_disabling_it_returns_nothing(self): + import smoke_public_url as public + + self.assertIsNone( + public.internal_phase("http://x", "db", enabled=False) + ) + + +if __name__ == "__main__": + unittest.main() From eaf70bdebf9bd6b52da4d6e04ba092cec8f741d5 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 17 Aug 2026 22:48:40 -0400 Subject: [PATCH 36/67] [ADD] migration: open /my as the test user, and blame the right request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The portal is a third rendering, neither the public site nor the back office: QWeb frontend with counters that each query their own model. The sitemap does not list /my — the page needs a session — and no RPC call goes through it. A migration can break it with nothing else noticing. Measured on a real database: /my answered 500 on a user field a module no longer defines, while every public page and sixteen apps were fine. Two guards the run proved necessary. Requested without a session, /my redirects to the login form and answers 200: counting that as a success would announce a healthy portal never seen. And the production error page shows no traceback, so the reason comes from the server log — attributed by PATH, because the portal is checked first and taking the last trace blamed it for an app's failure. A wrong cause costs more than none. --- FR --- [ADD] migration : ouvrir /my avec l'utilisateur test, et accuser la bonne requête Le portail est un troisième rendu, ni le site public ni le back-office : du QWeb frontend, avec ses compteurs qui interrogent chacun leur modèle. Le sitemap ne liste pas /my — la page demande une session — et aucun appel RPC n'y passe. Une migration peut le casser sans que rien ne le dise. Mesuré sur une vraie base : /my rendait 500 sur un champ utilisateur qu'un module ne définit plus, alors que les pages publiques et seize applications allaient bien. Deux garde-fous que l'essai a rendus nécessaires. Sans session, /my redirige vers le formulaire de connexion et rend 200 : le compter pour une réussite annoncerait un portail jamais vu. Et la page d'erreur de production ne montre aucune trace : la raison vient du journal, attribuée par CHEMIN — le portail est interrogé en premier, et prendre la dernière trace l'accusait de la panne d'une application. Assisted-by: Claude Opus 5 --- script/odoo/migration/smoke_internal_ui.py | 259 +++++++++++++++++++-- script/odoo/migration/smoke_public_url.py | 52 ++++- script/todo/todo_i18n.py | 20 ++ test/test_smoke_internal_ui.py | 165 +++++++++++++ 4 files changed, 481 insertions(+), 15 deletions(-) diff --git a/script/odoo/migration/smoke_internal_ui.py b/script/odoo/migration/smoke_internal_ui.py index 4d659ca..ccab3d1 100755 --- a/script/odoo/migration/smoke_internal_ui.py +++ b/script/odoo/migration/smoke_internal_ui.py @@ -126,6 +126,7 @@ class Session: urllib.request.HTTPCookieProcessor(self.jar) ) self.uid = None + self.last_url = None # Le nom des méthodes a changé en cours de route (get_views en 16, # load_views avant). On retient celui qui a répondu : chercher à # chaque appel doublerait le nombre de requêtes. @@ -135,12 +136,18 @@ class Session: def open(self, path, data=None, headers=None): url = self.base_url + path request = urllib.request.Request(url, data=data, headers=headers or {}) + # Où l'on a ATTERRI, pas où l'on voulait aller : urllib suit les + # redirections en silence, et une session perdue renvoie /my vers + # /web/login avec un 200 parfaitement rassurant. + self.last_url = url try: with self.opener.open(request, timeout=self.timeout) as answer: + self.last_url = answer.geturl() return answer.getcode(), answer.read().decode( "utf-8", errors="replace" ) except urllib.error.HTTPError as exc: + self.last_url = exc.geturl() return exc.code, exc.read().decode("utf-8", errors="replace") except Exception as exc: return 0, str(exc) @@ -473,20 +480,33 @@ def view_pairs(view_mode): return lst or [[False, "form"]] -def check_entry(session, app, menu, limit=DEFAULT_RECORD_LIMIT): - """Ouvrir une entrée de menu. Rend un dict décrivant ce qui s'est passé.""" - resultat = { - "app": app["name"], - "menu": menu["name"], - "action": menu.get("action"), +def blank_entry(app, menu, kind=None, action=None): + """La forme commune d'un résultat. Une seule, pour un seul rapport. + + Le portail et les applications ne se testent pas de la même façon — + l'un par HTTP, l'autre par RPC — mais un lecteur n'a pas à connaître + cette différence pour lire ce qui a cassé. + """ + return { + "app": app, + "menu": menu, + "action": action, "model": None, - "kind": None, + "kind": kind, "error": None, "stage": None, "domain_ignored": False, "unknown_fields": [], "fields_read": 0, + "status": None, } + + +def check_entry(session, app, menu, limit=DEFAULT_RECORD_LIMIT): + """Ouvrir une entrée de menu. Rend un dict décrivant ce qui s'est passé.""" + resultat = blank_entry( + app["name"], menu["name"], action=menu.get("action") + ) model_action, ident = split_action(menu.get("action")) resultat["kind"] = model_action if model_action != "ir.actions.act_window" or not ident: @@ -555,8 +575,158 @@ def check_entry(session, app, menu, limit=DEFAULT_RECORD_LIMIT): return resultat -def crawl(session, limit=DEFAULT_RECORD_LIMIT, every_menu=False): - """Parcourir les applications. Rend (entrées visitées, échecs).""" +PORTAL_KIND = "portal" +DEFAULT_PORTAL_PATHS = ("/my",) + +# La page de connexion se reconnaît à SES DEUX champs. Chercher « login » +# seul attraperait la moitié des pages du portail, où le mot est partout. +RE_LOGIN_FORM = re.compile( + r"""name=["']login["'][\s\S]{0,2000}?name=["']password["']""", re.I +) + +# La trace d'Odoo dans une page d'erreur : la ligne qui NOMME l'exception. +RE_TRACE = re.compile(r"^\s*([\w.]+(?:Error|Exception)):\s*(.+)$", re.M) + + +def error_from_page(body): + """Ce qu'une page d'erreur dit d'elle-même, ou son début à défaut. + + Un « HTTP 500 » sans plus arrête l'enquête là où elle commence : Odoo + imprime pourtant l'exception dans la page quand il est en mode debug, + et c'est exactement ce qu'on veut lire. + """ + match = RE_TRACE.search(body or "") + if match: + return f"{match.group(1)}: {match.group(2).strip()}"[:200] + # Retirer style et script AVANT les balises : sinon on rend « html { + # font-size: 14px; } Home Back 500 » — du CSS, mesuré tel quel. + texte = re.sub( + r"<(script|style)[^>]*>[\s\S]*?", " ", body or "", flags=re.I + ) + texte = re.sub(r"<[^>]+>", " ", texte) + texte = " ".join(texte.split()) + return texte[:200] + + +# La dernière ligne d'une trace Python : celle qui NOMME la panne. +RE_LOG_EXCEPTION = re.compile( + r"^(\w[\w.]*(?:Error|Exception|NotFound)):\s*(.*)$", re.M +) + + +def log_exceptions(lst_log): + """Les exceptions du journal du serveur, dans l'ordre. + + La page d'erreur d'Odoo en production ne montre PAS la trace : on + obtient « 500: Internal Server Error » et rien d'autre. Le journal, lui, + la contient — et c'est la seule chose qui permette de réparer plutôt que + de constater. + """ + texte = "\n".join(lst_log or []) + return [ + f"{nom}: {reste.strip()}"[:200] + for nom, reste in RE_LOG_EXCEPTION.findall(texte) + ] + + +# La ligne d'accès de werkzeug : c'est elle qui porte le CHEMIN, donc la +# seule façon de savoir à qui appartient une trace. +RE_ACCESS = re.compile(r'"(?:GET|POST|PUT|DELETE) (\S+) HTTP/[\d.]+" (\d{3})') + + +def reason_for_path(lst_log, path): + """La dernière exception loggée AVANT la réponse en échec de ce chemin. + + Prendre simplement la dernière trace du journal était faux, et l'essai + réel l'a montré tout de suite : le portail est interrogé en PREMIER, + donc sa trace est la première du fichier, et on lui attribuait celle + d'une application testée bien après. Une cause fausse coûte plus cher + qu'une cause absente — on cherche alors du mauvais côté, avec + confiance. + + Une réponse SAINE pour ce chemin efface l'ardoise : ce qui précédait + appartenait à quelqu'un d'autre. + """ + attente = None + prefixe = (path or "").rstrip("/") + for ligne in lst_log or []: + trouve = RE_LOG_EXCEPTION.search(ligne) + if trouve: + attente = f"{trouve.group(1)}: {trouve.group(2).strip()}"[:200] + continue + acces = RE_ACCESS.search(ligne) + if not acces: + continue + chemin = acces.group(1).split("?")[0] + vise = chemin == path or chemin.startswith(prefixe + "/") + if vise and int(acces.group(2)) >= 400: + return attente + attente = None + return None + + +def attach_log_reason(lst_failure, lst_log): + """Donner à une page en échec la trace qui la concerne, ELLE. + + On ne touche jamais à un message venu d'un appel RPC : celui-ci nomme + précisément sa propre erreur, alors que le journal est un flux partagé. + Seules les pages ouvertes en HTTP ont besoin de cette recherche — leur + page d'erreur, en production, ne montre rien d'utile. + """ + for item in lst_failure: + if item.get("kind") != PORTAL_KIND: + continue + raison = reason_for_path(lst_log, item.get("menu") or "") + if raison: + (item.get("error") or {})["log"] = raison + return lst_failure + + +def check_portal(session, path): + """Ouvrir une page du portail AVEC la session connectée. + + Le portail n'est ni le site public ni le back-office : c'est un + troisième rendu, en QWeb frontend, avec ses compteurs qui interrogent + chacun leur modèle. Ni le sitemap — qui ne liste pas /my, la page + demandant une session — ni les appels RPC des applications ne passent + par là. Une migration peut donc le casser sans que rien ne le dise. + """ + resultat = blank_entry(t("Portal"), path, kind=PORTAL_KIND) + status, body = session.open(path) + resultat["status"] = status + if not status: + resultat["error"] = { + "name": "transport", + "message": (body or "")[:200], + } + resultat["stage"] = PORTAL_KIND + return resultat + if status >= 400: + resultat["error"] = { + "name": f"HTTP {status}", + "message": error_from_page(body), + } + resultat["stage"] = PORTAL_KIND + return resultat + # 200 ET la page de connexion : on n'a pas vu /my, on a vu le portier. + # Le compter comme une réussite serait le mensonge le plus coûteux ici. + redirige = "/web/login" in (session.last_url or "") + if redirige or RE_LOGIN_FORM.search(body or ""): + resultat["error"] = { + "name": t("not signed in"), + "message": t("the page served was the login form"), + } + resultat["stage"] = PORTAL_KIND + return resultat + + +def crawl( + session, + limit=DEFAULT_RECORD_LIMIT, + every_menu=False, + lst_portal=DEFAULT_PORTAL_PATHS, +): + """Parcourir le portail et les applications. Rend (visitées, échecs).""" lst_menu, error = menu_rows(session) if error: raise RuntimeError( @@ -572,7 +742,8 @@ def crawl(session, limit=DEFAULT_RECORD_LIMIT, every_menu=False): lst_entry = [ (app, first) for app, first in apps(lst_menu) if first is not None ] - lst_result = [ + lst_result = [check_portal(session, path) for path in lst_portal or ()] + lst_result += [ check_entry(session, app, menu, limit=limit) for app, menu in lst_entry ] return lst_result, [item for item in lst_result if item["error"]] @@ -594,6 +765,31 @@ def _root_of(menu, par_id): def render(lst_result, lst_failure): """Le rapport. Ce qui a ouvert, et ce qui a refusé de s'ouvrir.""" lignes = [] + # Le portail est compté À PART : noyé dans le total des applications, + # « 22/23 » ne dirait pas laquelle des deux choses a cassé, alors que + # ce sont deux rendus sans rapport — QWeb frontend d'un côté, vues + # back-office de l'autre. + portail = [item for item in lst_result if item["kind"] == PORTAL_KIND] + lst_result = [item for item in lst_result if item["kind"] != PORTAL_KIND] + lst_failure = [item for item in lst_failure if item["kind"] != PORTAL_KIND] + if portail: + casse = [item for item in portail if item["error"]] + lignes.append( + f"\n✨ {t('Portal pages opened as the test user')} :" + f" {len(portail) - len(casse)}/{len(portail)}" + ) + for item in portail: + marque = "❌" if item["error"] else "✅" + detail = ( + f" — {item['error']['name']} :" + f" {item['error']['message'][:120]}" + if item["error"] + else f" — HTTP {item['status']}" + ) + lignes.append(f" {marque} {item['menu']}{detail}") + raison = (item["error"] or {}).get("log") + if raison: + lignes.append(f" {t('server log')} : {raison}") lignes.append( f"\n✨ {t('Apps opened as the test user')} :" f" {len(lst_result) - len(lst_failure)}/{len(lst_result)}" @@ -625,7 +821,8 @@ def render(lst_result, lst_failure): f" {', '.join(item['unknown_fields'][:8])}" ) if not lst_failure: - lignes.append(f"✅ {t('Every app opened its first page.')}") + if not any(item["error"] for item in portail): + lignes.append(f"✅ {t('Every app opened its first page.')}") return "\n".join(lignes) lignes.append(f"\n❌ {t('Apps that failed to open')} :") ETAPE = { @@ -663,13 +860,23 @@ def render(lst_result, lst_failure): return "\n".join(lignes) -def run(base_url, database, login, password, limit, every_menu=False): +def run( + base_url, + database, + login, + password, + limit, + every_menu=False, + lst_portal=DEFAULT_PORTAL_PATHS, +): """Se connecter puis parcourir. Lève RuntimeError si l'on ne peut pas.""" session = Session(base_url) ok, raison = session.log_in(database, login, password) if not ok: raise RuntimeError(f"{t('Could not log in as')} '{login}' : {raison}") - return crawl(session, limit=limit, every_menu=every_menu) + return crawl( + session, limit=limit, every_menu=every_menu, lst_portal=lst_portal + ) def main(argv=None): @@ -695,6 +902,12 @@ def main(argv=None): action="store_true", help="open every menu with an action, not just each app's first page", ) + parser.add_argument( + "--portal", + default=",".join(DEFAULT_PORTAL_PATHS), + help="portal paths to open while signed in (comma separated, empty" + " to skip)", + ) parser.add_argument( "--boot-timeout", type=int, @@ -706,6 +919,7 @@ def main(argv=None): from smoke_public_url import ( DEFAULT_PORT, port_is_taken, + read_log, start_server, stop_server, wait_ready, @@ -739,7 +953,15 @@ def main(argv=None): f"⧖ {t('Starting Odoo on')} '{config.database}'" f" ({t('port')} {port})…" ) - server = start_server(config.database, port, config.config) + import tempfile + + log_path = os.path.join( + tempfile.gettempdir(), + f"erplibre_internal_{config.database}_{port}.log", + ) + server = start_server( + config.database, port, config.config, log_path=log_path + ) try: if not wait_ready(base_url, timeout=config.boot_timeout): print(f"❌ {t('The server never answered on')} {base_url}") @@ -751,12 +973,21 @@ def main(argv=None): config.password, config.limit, every_menu=config.all_menus, + lst_portal=[ + path.strip() + for path in (config.portal or "").split(",") + if path.strip() + ], ) except RuntimeError as exc: print(f"❌ {exc}") return 2 finally: + # Le journal se lit APRÈS l'arrêt : un serveur qui écrit dans un + # fichier bufferise et ne vide qu'en s'arrêtant. Lire avant donnait + # les lignes de démarrage et rien de la panne. stop_server(server) + attach_log_reason(lst_failure, read_log(log_path)) print(render(lst_result, lst_failure)) return 1 if lst_failure else 0 diff --git a/script/odoo/migration/smoke_public_url.py b/script/odoo/migration/smoke_public_url.py index f0806c5..3b7028a 100755 --- a/script/odoo/migration/smoke_public_url.py +++ b/script/odoo/migration/smoke_public_url.py @@ -128,6 +128,12 @@ def start_server(database, port, config_path="./config.conf", log_path=None): "--http-port", str(port), "--log-level=warn", + # Deux lignes par requête, et elles portent le CHEMIN. Sans + # elles une trace du journal ne peut être rattachée à rien : + # on l'attribuait à la dernière requête, qui n'était pas la + # sienne. Mesuré — /my accusé d'une panne appartenant à une + # application testée après lui. + "--log-handler=werkzeug:INFO", ], stdout=handle or subprocess.DEVNULL, stderr=subprocess.STDOUT, @@ -447,6 +453,25 @@ def render_internal(internal): return bool(internal["failures"]) +def attach_internal_log(internal_report, log_path): + """Donner à la passe back-office la trace que sa page ne montrait pas. + + APRÈS l'arrêt du serveur, et pas avant : un serveur qui écrit dans un + fichier bufferise et ne vide qu'en s'arrêtant. La page d'erreur d'Odoo + en production, elle, se contente de « 500: Internal Server Error » — + de quoi constater, pas de quoi réparer. + """ + if not internal_report or not internal_report.get("failures"): + return + try: + import smoke_internal_ui + except ImportError: + return + smoke_internal_ui.attach_log_reason( + internal_report["failures"], read_log(log_path) + ) + + def internal_phase( base_url, database, @@ -455,6 +480,7 @@ def internal_phase( password="test", limit=20, every_menu=False, + lst_portal=None, ): """Le back-office, si la base a été neutralisée. Rend None sinon. @@ -482,7 +508,17 @@ def internal_phase( return {"skipped": t("could not tell whether the test user exists")} try: lst_result, lst_failure = smoke_internal_ui.run( - base_url, database, login, password, limit, every_menu=every_menu + base_url, + database, + login, + password, + limit, + every_menu=every_menu, + lst_portal=( + smoke_internal_ui.DEFAULT_PORTAL_PATHS + if lst_portal is None + else lst_portal + ), ) except RuntimeError as exc: return {"skipped": str(exc)} @@ -504,6 +540,7 @@ def run( internal_password="test", internal_limit=20, every_menu=False, + portal=None, ): """Démarrer, interroger, arrêter, LIRE, éventuellement corriger, revérifier. @@ -547,10 +584,12 @@ def run( password=internal_password, limit=internal_limit, every_menu=every_menu, + lst_portal=portal, ) finally: stop_server(server) + attach_internal_log(internal_report, log_path) lst_key = [] if lst_failure: lst_log = read_log(log_path) @@ -676,6 +715,12 @@ def main(argv=None): action="store_true", help="open every menu with an action, not just each app's first page", ) + parser.add_argument( + "--portal", + default="/my", + help="portal paths opened while signed in (comma separated, empty" + " to skip)", + ) config = parser.parse_args(argv) print( @@ -698,6 +743,11 @@ def main(argv=None): internal_password=config.password, internal_limit=config.record_limit, every_menu=config.all_menus, + portal=[ + path.strip() + for path in (config.portal or "").split(",") + if path.strip() + ], ) except RuntimeError as exc: print(f"❌ {exc}") diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 623e8da..4835746 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -5410,6 +5410,26 @@ TRANSLATIONS = { "fr": "si la base a été neutralisée.", "en": "if the database was neutralized.", }, + "Portal": { + "fr": "Portail", + "en": "Portal", + }, + "Portal pages opened as the test user": { + "fr": "Pages du portail ouvertes avec l'utilisateur test", + "en": "Portal pages opened as the test user", + }, + "not signed in": { + "fr": "pas connecté", + "en": "not signed in", + }, + "the page served was the login form": { + "fr": "la page servie était le formulaire de connexion", + "en": "the page served was the login form", + }, + "server log": { + "fr": "journal du serveur", + "en": "server log", + }, "Clean the database before testing the pages?": { "fr": "Nettoyer la base avant de tester les pages ?", "en": "Clean the database before testing the pages?", diff --git a/test/test_smoke_internal_ui.py b/test/test_smoke_internal_ui.py index 3f4c187..8b3173b 100644 --- a/test/test_smoke_internal_ui.py +++ b/test/test_smoke_internal_ui.py @@ -404,6 +404,171 @@ class TestTheViewsAreRenderedServerSide(unittest.TestCase): self.assertEqual(appels, ["get_views", "load_views", "load_views"]) +class TestThePortalPage(unittest.TestCase): + """/my n'est ni le site public ni le back-office : c'est un troisième + rendu, en QWeb frontend, avec ses compteurs qui interrogent chacun leur + modèle. Le sitemap ne le liste pas — la page demande une session — et + aucun appel RPC ne passe par là. Une migration peut donc le casser sans + que rien ailleurs ne le dise. + """ + + 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 session(self, status, body, url=None): + session = ui.Session("http://x") + session.last_url = url or ("http://x/my") + + def faux(path, data=None, headers=None): + return status, body + + session.open = faux + return session + + def test_a_page_that_answers_is_a_success(self): + item = ui.check_portal( + self.session(200, "Mon compte"), "/my" + ) + self.assertIsNone(item["error"]) + self.assertEqual(item["status"], 200) + + def test_a_five_hundred_is_a_failure(self): + item = ui.check_portal(self.session(500, "oups"), "/my") + self.assertEqual(item["stage"], ui.PORTAL_KIND) + self.assertIn("500", item["error"]["name"]) + + def test_a_dead_connection_is_a_failure(self): + item = ui.check_portal(self.session(0, "connexion refusée"), "/my") + self.assertIsNotNone(item["error"]) + + def test_a_login_form_served_with_200_is_NOT_a_success(self): + # MESURÉ : /my demandé sans session redirige vers + # /web/login?redirect=/my et rend 200. Compter cela comme une + # réussite serait le mensonge le plus coûteux du lot — on + # annoncerait un portail sain sans l'avoir jamais vu. + page = '' + item = ui.check_portal(self.session(200, page), "/my") + self.assertIsNotNone(item["error"]) + + def test_a_redirect_to_the_login_is_caught_even_without_the_form(self): + item = ui.check_portal( + self.session( + 200, "rien", url="http://x/web/login?redirect=/my" + ), + "/my", + ) + self.assertIsNotNone(item["error"]) + + def test_the_word_login_alone_does_not_condemn_a_page(self): + # Le mot est partout dans le portail. N'exiger que lui rejetterait + # des pages parfaitement saines. + page = "Votre login est test. Bienvenue." + item = ui.check_portal(self.session(200, page), "/my") + self.assertIsNone(item["error"]) + + def test_the_error_page_is_read_without_its_stylesheet(self): + # MESURÉ : « Internal Server Error html { font-size: 14px; } Home + # Back 500 » — du CSS présenté comme un diagnostic. + page = ( + "" + "500: Internal Server Error" + ) + self.assertNotIn("font-size", ui.error_from_page(page)) + + def test_it_is_checked_before_the_apps(self): + # L'ordre n'est pas indifférent : si la session est perdue, tout le + # reste échouera aussi, et l'on veut la cause en tête du rapport. + import inspect + + source = inspect.getsource(ui.crawl) + self.assertLess( + source.index("check_portal"), source.index("check_entry") + ) + + def test_asking_for_no_portal_path_checks_none(self): + session = self.session(200, "ok") + session.call_kw = lambda *a, **kw: ([], None) + lst_result, _ = ui.crawl(session, lst_portal=[]) + self.assertEqual( + [x for x in lst_result if x["kind"] == ui.PORTAL_KIND], [] + ) + + def test_the_default_path_is_my(self): + self.assertEqual(ui.DEFAULT_PORTAL_PATHS, ("/my",)) + + +class TestBlamingTheRightRequest(unittest.TestCase): + """Une cause fausse coûte plus cher qu'une cause absente. + + Vécu : le portail est interrogé en PREMIER, donc sa trace est la + première du journal. En prenant simplement la dernière, on accusait /my + d'une panne appartenant à une application testée bien après — et l'on + aurait cherché des heures du mauvais côté, avec confiance. + """ + + JOURNAL = [ + "AttributeError: 'res.users' object has no attribute 'portal_360'", + '... werkzeug: "GET /my HTTP/1.1" 500 - 8 0.004 0.029', + "ValueError: Invalid field project.project.is_project_br", + '... werkzeug: "POST /web/dataset/call_kw HTTP/1.1" 200 - 3 0.01', + ] + + def test_the_trace_of_that_very_request_is_taken(self): + self.assertIn("portal_360", ui.reason_for_path(self.JOURNAL, "/my")) + + def test_the_trace_of_another_request_is_NOT(self): + self.assertNotIn( + "is_project_br", ui.reason_for_path(self.JOURNAL, "/my") or "" + ) + + def test_a_healthy_answer_wipes_the_slate(self): + journal = [ + "ValueError: sans rapport", + '... werkzeug: "GET /my HTTP/1.1" 200 - 8 0.004', + ] + self.assertIsNone(ui.reason_for_path(journal, "/my")) + + def test_a_redirected_path_still_matches(self): + # /my renvoie vers /my/home : la trace est loggée sous le second. + journal = [ + "KeyError: compteur", + '... werkzeug: "GET /my/home HTTP/1.1" 500 - 8 0.004', + ] + self.assertIn("KeyError", ui.reason_for_path(journal, "/my")) + + def test_a_similar_path_is_not_confused_with_it(self): + journal = [ + "KeyError: ailleurs", + '... werkzeug: "GET /myaccount HTTP/1.1" 500 - 8 0.004', + ] + self.assertIsNone(ui.reason_for_path(journal, "/my")) + + def test_an_rpc_message_is_never_overwritten(self): + # Un appel RPC nomme précisément SON erreur ; le journal est un flux + # partagé. Écraser le premier par le second serait perdre la seule + # information sûre. + item = ui.blank_entry("Ventes", "Devis", kind="ir.actions.act_window") + item["error"] = {"name": "KeyError", "message": "précis"} + ui.attach_log_reason( + [item], ["ValueError: du journal", '"GET /my" 500'] + ) + self.assertNotIn("log", item["error"]) + + def test_the_server_is_asked_to_log_its_requests(self): + # Sans les lignes d'accès, aucune trace ne peut être rattachée à un + # chemin : elles coûtent deux lignes par requête. + import inspect + + import smoke_public_url as public + + self.assertIn("werkzeug:INFO", inspect.getsource(public.start_server)) + + class TestTheReport(unittest.TestCase): def setUp(self): from script.todo import todo_i18n From 58bdd70526cb9e48782240cc9496578d717e3135 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 17 Aug 2026 23:19:04 -0400 Subject: [PATCH 37/67] [FIX] database_cleanup: purge the batch, and say what you are doing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seventeen minutes with no output looks exactly like an infinite loop. It was not one: measured on the running process, zero lock waits and queries changing every sample. It was working, silently, one module every thirty seconds. Both halves were mine. Purging line by line — added so one refusal could not sink a category — calls button_immediate_uninstall() per line, and that reloads the WHOLE registry: 5984 modules re-read each time. The batch is now purged in one call, exactly as the OCA wizard intends, and the per-line isolation only costs when a refusal actually happens. And the tool captured its child's output, so nothing showed before the end. It now relays each step as it arrives, with elapsed seconds, and a timer kills the process group when the deadline passes. --- FR --- [FIX] database_cleanup : purger le lot, et dire ce qu'on fait Dix-sept minutes sans une ligne ressemblent trait pour trait à une boucle infinie. Ce n'en était pas une : mesuré sur le processus vivant, zéro verrou en attente et des requêtes qui changeaient à chaque instantané. Il travaillait, en silence, à raison d'un module toutes les trente secondes. Les deux moitiés étaient de mon fait. Purger ligne par ligne — ajouté pour qu'un refus n'emporte pas la catégorie — appelle button_immediate_uninstall() par ligne, ce qui recharge TOUT le registre : 5984 modules relus à chaque fois. Le lot est désormais purgé en un seul appel, comme le module OCA le prévoit, et l'isolement ne coûte que lorsqu'un refus survient vraiment. Et l'outil capturait la sortie de son enfant : rien avant la fin. Il relaie maintenant chaque étape, temps écoulé compris. Assisted-by: Claude Opus 5 --- script/odoo/migration/database_cleanup.py | 167 ++++++++++++++-- script/todo/todo_i18n.py | 32 ++++ test/test_database_cleanup.py | 221 ++++++++++++++++++++-- 3 files changed, 390 insertions(+), 30 deletions(-) diff --git a/script/odoo/migration/database_cleanup.py b/script/odoo/migration/database_cleanup.py index 2d262a0..7cac3bd 100755 --- a/script/odoo/migration/database_cleanup.py +++ b/script/odoo/migration/database_cleanup.py @@ -30,8 +30,11 @@ Exit codes: 0 nothing left, 1 leftovers remain, 2 the tool failed. import argparse import json import os +import signal import subprocess import sys +import threading +import time sys.path.append( os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) @@ -74,6 +77,7 @@ LABEL = { "properties": "obsolete properties", } +STEP = "ERPLIBRE_CLEANUP_STEP" START = "ERPLIBRE_CLEANUP_START" END = "ERPLIBRE_CLEANUP_END" @@ -90,6 +94,7 @@ ORDER = %(order)r MAX_ROUND = %(max_round)d DRY_RUN = %(dry_run)s +t_all = "%(all_label)s" report = {"rounds": [], "missing": [], "failed": []} @@ -97,6 +102,14 @@ def note(label, name, exc): report["failed"].append([label, name, str(exc)[:200]]) +def step(label, name, index=0, total=0): + # Dire ce qu'on fait PENDANT qu'on le fait. Sans cela l'outil se taisait + # jusqu'au rapport final : mesuré, dix-sept minutes de silence complet + # sur une base de 5984 modules, impossible à distinguer d'un blocage. + detail = f" {index}/{total}" if total else "" + print(f"%(step)s {label}{detail} {name}", flush=True) + + def recover(): # Rendre la transaction utilisable, quoi qu'il vienne de se passer. # @@ -118,6 +131,7 @@ try: for index in range(MAX_ROUND): this_round = [] purged_this_round = 0 + step("pass", str(index + 1), index + 1, MAX_ROUND) for label, model in ORDER: if model not in env: if label not in report["missing"]: @@ -153,19 +167,40 @@ try: env.cr.commit() except Exception: recover() - for line, name in todo: - if DRY_RUN: - would.append(name) - continue + if DRY_RUN: + would = [name for _line, name in todo] + elif todo: + # LE LOT D'ABORD, et ce n'est pas une optimisation de + # confort. `purge()` d'un module appelle + # button_immediate_uninstall(), qui RECHARGE LE REGISTRE + # ENTIER — 5984 modules à relire. En purgeant ligne par + # ligne j'en faisais un rechargement PAR MODULE : mesuré, + # dix secondes chacun, dix-sept minutes pour neuf modules. + # Le module OCA purge le lot en un seul appel, donc un seul + # rechargement. + # + # L'isolement ligne à ligne garde tout son sens — un refus + # ne doit pas emporter la catégorie — mais il ne coûte que + # lorsqu'il sert vraiment, c'est-à-dire après un échec. + step(label, t_all, 0, len(todo)) try: - line.purge() - # Valider ENTRÉE PAR ENTRÉE : un refus plus loin ne doit - # pas emporter ce qui vient d'être réparé. + wizard.purge_line_ids.purge() env.cr.commit() - ok += 1 - except Exception as exc: + ok = len(todo) + except Exception: recover() - errors.append([name, str(exc)[:160]]) + for index, (line, name) in enumerate(todo, start=1): + step(label, name, index, len(todo)) + try: + line.purge() + # Valider ENTRÉE PAR ENTRÉE : un refus plus loin + # ne doit pas emporter ce qui vient d'être + # réparé. + env.cr.commit() + ok += 1 + except Exception as exc: + recover() + errors.append([name, str(exc)[:160]]) purged_this_round += ok this_round.append({"kind": label, "purged": ok, "errors": errors, "would": would}) @@ -201,6 +236,8 @@ def build_script(max_round, dry_run): "dry_run": "True" if dry_run else "False", "start": START, "end": END, + "step": STEP, + "all_label": "the whole batch", } @@ -302,14 +339,46 @@ def install_module(database, module="database_cleanup", timeout=1800): return done.returncode, done.stdout + done.stderr -def run_shell(database, config_path, script, timeout=3600): +def kill_group(process): + """Tuer le GROUPE : « ./odoo_bin.sh » est un script bash. + + Un terminate() sur lui tue le script et laisse odoo-bin vivant, sur la + base, avec ses verrous. La leçon a déjà été payée une fois par six + serveurs orphelins. + """ + try: + group = os.getpgid(process.pid) + except OSError: + group = None + for signal_number in (signal.SIGTERM, signal.SIGKILL): + if group is None: + break + try: + os.killpg(group, signal_number) + except OSError: + break + try: + process.wait(timeout=20) + return + except subprocess.TimeoutExpired: + continue + + +def run_shell(database, config_path, script, timeout=3600, echo=None): """Pousser le script dans « odoo-bin shell » et rendre son rapport. + On LIT au fil de l'eau plutôt que de tout capturer : ce nettoyage dure + des minutes — dix-sept, mesurées, sur une base de 5984 modules — et la + version précédente n'affichait rien avant la fin. Un travail qui avance + et un blocage se ressemblent alors trait pour trait, et c'est ainsi + qu'on interrompt une réparation à moitié faite. + Les journaux d'Odoo se mêlent à la sortie, d'où les sentinelles : on ne - lit que ce qui est entre elles. Leur absence est une erreur franche, pas - un rapport vide qu'on prendrait pour « rien à faire ». + lit comme rapport que ce qui est entre elles. Leur absence est une + erreur franche, pas un rapport vide qu'on prendrait pour « rien à + faire ». """ - done = subprocess.run( + process = subprocess.Popen( [ "./odoo_bin.sh", "shell", @@ -319,12 +388,43 @@ def run_shell(database, config_path, script, timeout=3600): database, "--log-level=warn", ], - input=script, - capture_output=True, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, text=True, - timeout=timeout, + start_new_session=True, ) - output = done.stdout + done.stderr + # Le délai ne peut pas vivre dans la boucle de lecture : celle-ci BLOQUE + # tant qu'aucune ligne n'arrive, et c'est exactement le cas qu'il faut + # couvrir. Un minuteur à part, donc. + expire = {"fired": False} + + def couper(): + expire["fired"] = True + kill_group(process) + + minuteur = threading.Timer(timeout, couper) + minuteur.daemon = True + minuteur.start() + lst_line = [] + try: + process.stdin.write(script) + process.stdin.close() + for line in process.stdout: + lst_line.append(line) + if echo and line.startswith(STEP): + echo(line[len(STEP) :].strip()) + process.wait() + finally: + minuteur.cancel() + if process.poll() is None: + kill_group(process) + output = "".join(lst_line) + if expire["fired"]: + raise RuntimeError( + f"{t('The cleanup was still running after')} {timeout}" + f" {t('seconds and was stopped.')}" + ) if START not in output or END not in output: raise RuntimeError( f"{t('The cleanup produced no report.')}\n{output.strip()[-1500:]}" @@ -336,6 +436,30 @@ def run_shell(database, config_path, script, timeout=3600): raise RuntimeError(f"{t('Unreadable report')} : {exc}") +def make_echo(depart): + """Relayer la progression du script poussé, horodatée. + + Le temps écoulé n'est pas décoratif : c'est lui qui distingue « ça + avance lentement » de « ça ne bouge plus », et c'est précisément la + question qu'on se pose devant un écran muet. + """ + + def echo(ligne): + morceaux = ligne.split(" ", 1) + etiquette = morceaux[0] + reste = morceaux[1] if len(morceaux) > 1 else "" + secondes = int(time.monotonic() - depart) + if etiquette == "pass": + print(f" [{secondes:>4}s] {t('pass')} {reste}") + else: + print( + f" [{secondes:>4}s] {t(LABEL.get(etiquette, etiquette))}" + f" — {t(reste) if reste == 'the whole batch' else reste}" + ) + + return echo + + def leftovers(report): """[(kind, name, message)] de ce que la DERNIÈRE passe n'a pas pu purger.""" if not report.get("rounds"): @@ -457,17 +581,24 @@ def main(argv=None): return 2 print(f"⧖ {t('Cleaning')} '{config.database}'…") + print( + f" {t('Purging modules reloads the whole registry: on a big')}" + f" {t('addons path this takes minutes, not seconds.')}" + ) + depart = time.monotonic() try: report = run_shell( config.database, config.config, build_script(config.max_round, config.dry_run), timeout=config.timeout, + echo=make_echo(depart), ) except (RuntimeError, subprocess.SubprocessError, OSError) as exc: print(f"❌ {exc}") return 2 print(render(report, config.database)) + print(f"⌛ {t('Took')} {int(time.monotonic() - depart)} {t('seconds.')}") return 1 if leftovers(report) else 0 diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 4835746..6128d33 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -5430,6 +5430,38 @@ TRANSLATIONS = { "fr": "journal du serveur", "en": "server log", }, + "Purging modules reloads the whole registry: on a big": { + "fr": "Purger les modules recharge tout le registre : sur un grand", + "en": "Purging modules reloads the whole registry: on a big", + }, + "addons path this takes minutes, not seconds.": { + "fr": "addons path cela prend des minutes, pas des secondes.", + "en": "addons path this takes minutes, not seconds.", + }, + "Took": { + "fr": "Durée :", + "en": "Took", + }, + "seconds.": { + "fr": "secondes.", + "en": "seconds.", + }, + "pass": { + "fr": "passe", + "en": "pass", + }, + "The cleanup was still running after": { + "fr": "Le nettoyage tournait encore après", + "en": "The cleanup was still running after", + }, + "seconds and was stopped.": { + "fr": "secondes et a été arrêté.", + "en": "seconds and was stopped.", + }, + "the whole batch": { + "fr": "le lot entier", + "en": "the whole batch", + }, "Clean the database before testing the pages?": { "fr": "Nettoyer la base avant de tester les pages ?", "en": "Clean the database before testing the pages?", diff --git a/test/test_database_cleanup.py b/test/test_database_cleanup.py index e4d8b3f..29ca709 100755 --- a/test/test_database_cleanup.py +++ b/test/test_database_cleanup.py @@ -34,17 +34,45 @@ class FakeLine: self.id = abs(hash(name)) % 10000 self.fails = fails # nombre de refus avant de céder self.journal = journal if journal is not None else [] + self.purged = False def purge(self): + # Comme le vrai : une ligne déjà purgée ne l'est pas deux fois. + # `purge()` d'OCA filtre sur `not x.purged`, et sans cela le repli + # ligne à ligne recompterait ce que le lot avait déjà fait. + if self.purged: + return True self.journal.append(("purge", self.name)) if self.fails > 0: self.fails -= 1 raise RuntimeError(f"refus sur {self.name}") + self.purged = True + return True + + +class FakeRecordset(list): + """Un `purge_line_ids` qui se purge EN LOT, comme le vrai. + + C'est TOUT l'enjeu du correctif : `purge()` d'un module appelle + button_immediate_uninstall(), qui recharge le registre entier. Un appel + par ligne en faisait un rechargement par module — mesuré, dix secondes + chacun. Un seul appel sur le lot, c'est un seul rechargement. + """ + + def __init__(self, lines, journal=None): + super().__init__(lines) + self.journal = journal if journal is not None else [] + + def purge(self): + self.journal.append(("purge_batch", len(self))) + for line in self: + line.purge() + return True class FakeWizard: - def __init__(self, lines): - self.purge_line_ids = lines + def __init__(self, lines, journal=None): + self.purge_line_ids = FakeRecordset(lines, journal) class FakeModel: @@ -56,7 +84,9 @@ class FakeModel: if self._raise: raise RuntimeError(self._raise) # Les lignes déjà purgées ne reviennent pas : find() les recalcule. - return FakeWizard([ln for ln in self._lines if ln.fails >= 0]) + reste = [ln for ln in self._lines if ln.fails >= 0 and not ln.purged] + journal = reste[0].journal if reste else None + return FakeWizard(reste, journal) class FakeCursor: @@ -208,9 +238,25 @@ class TestTheOrder(unittest.TestCase): class TestOneEntryCannotSinkThePass(unittest.TestCase): - def test_each_entry_is_committed_on_its_own(self): - # Sans cela, le premier refus emporterait tout ce que la passe avait - # déjà réparé — et un rollback, ici, remonte jusqu'au début. + def test_a_healthy_category_is_purged_in_ONE_call(self): + # LE point du correctif. `purge()` d'un module appelle + # button_immediate_uninstall(), qui recharge le registre ENTIER — + # 5984 modules à relire. En purgeant ligne par ligne j'en faisais + # un rechargement PAR MODULE : mesuré, dix secondes chacun, + # dix-sept minutes pour neuf modules, sans rien afficher. + journal = [] + lines = [ + FakeLine("a", journal=journal), + FakeLine("b", journal=journal), + FakeLine("c", journal=journal), + ] + models = {cleanup.ORDER[0][1]: FakeModel(lines)} + report, _got = run_script(models, max_round=1) + # `journal` est celui des LIGNES : c'est là qu'atterrit la purge. + self.assertEqual(journal.count(("purge_batch", 3)), 1) + self.assertEqual(report["rounds"][0][0]["purged"], 3) + + def test_the_healthy_case_commits_once_for_the_batch(self): journal = [] lines = [ FakeLine("a", journal=journal), @@ -218,9 +264,9 @@ class TestOneEntryCannotSinkThePass(unittest.TestCase): ] models = {cleanup.ORDER[0][1]: FakeModel(lines)} _report, got = run_script(models, max_round=1) - # Une validation par entrée, PLUS une après la création : `find()` - # peut avoir purgé de lui-même, et ce travail-là doit tenir. - self.assertEqual(got.count(("commit", None)), 3) + # Une validation après la création — `find()` peut avoir purgé de + # lui-même — et une pour le lot. Pas une par ligne. + self.assertEqual(got.count(("commit", None)), 2) def test_a_refusal_gives_up_only_its_own(self): journal = [] @@ -234,9 +280,25 @@ class TestOneEntryCannotSinkThePass(unittest.TestCase): entry = report["rounds"][0][0] self.assertEqual(entry["purged"], 2) self.assertEqual([name for name, _msg in entry["errors"]], ["bad"]) - self.assertEqual(got.count(("rollback", None)), 1) - # Deux entrées purgées + la création : trois validations. - self.assertEqual(got.count(("commit", None)), 3) + # Le lot a été tenté, a échoué, et SEULEMENT alors on isole. Le + # coût du ligne-à-ligne n'est payé que là où il sert. + self.assertEqual(journal.count(("purge_batch", 3)), 1) + self.assertGreaterEqual(got.count(("rollback", None)), 1) + + def test_the_isolation_only_happens_after_a_refusal(self): + # Une catégorie saine ne doit JAMAIS passer par le repli : c'est + # lui qui coûtait dix secondes par module. + journal = [] + models = { + cleanup.ORDER[0][1]: FakeModel( + [ + FakeLine("a", journal=journal), + FakeLine("b", journal=journal), + ] + ) + } + _report, got = run_script(models, max_round=1) + self.assertEqual(got.count(("rollback", None)), 0) def test_the_script_never_takes_a_savepoint(self): # L'invariant du correctif, dit une fois pour toutes : le module OCA @@ -303,6 +365,141 @@ class TestTheReportSurvivesAnything(unittest.TestCase): self.assertIn("miettes", report["failed"][0][2]) +class TestTheSilenceThatLookedLikeAHang(unittest.TestCase): + """Dix-sept minutes sans une ligne, et l'on croit à une boucle infinie. + + Vécu, sur test_neutralize_upgrade_16 : l'outil affichait « ⧖ Nettoyage + de … » puis PLUS RIEN. Le processus travaillait — zéro verrou en + attente, des requêtes qui changeaient à chaque instantané — mais un + travail qui avance et un blocage se ressemblent trait pour trait quand + aucun des deux ne parle. On interrompt alors une réparation à moitié + faite, ce qui est le pire des deux mondes. + """ + + def test_the_pushed_script_announces_what_it_does(self): + source = cleanup.build_script(1, False) + self.assertIn(cleanup.STEP, source) + self.assertIn("flush=True", source) + + def test_it_announces_each_pass(self): + source = cleanup.build_script(1, False) + self.assertIn('step("pass"', source) + + def test_the_parent_relays_each_line_AS_IT_ARRIVES(self): + """Le test comportemental, et non plus un mot cherché dans le code. + + `capture_output=True` ne rend la main qu'à la fin : c'était la cause + du silence, pas la lenteur elle-même. On vérifie donc qu'une ligne + de progression ressort AVANT que le processus n'ait fini de parler. + """ + import io + + lignes = [ + f"{cleanup.STEP} modules 1/3 vieux_module\n", + "un journal Odoo sans rapport\n", + f"{cleanup.STEP} columns 2/3 res_partner.x\n", + f"{cleanup.START}\n", + '{"rounds": [], "missing": [], "failed": []}\n', + f"{cleanup.END}\n", + ] + vu = [] + + class FauxProcessus: + def __init__(self, lst): + self.stdin = io.StringIO() + self.pid = -1 + self._lst = lst + + @property + def stdout(self): + # Un générateur : chaque ligne n'existe qu'au moment où on + # la lit, comme un vrai tube. Rendre la liste entière + # laisserait passer une lecture en bloc. + for rang, ligne in enumerate(self._lst): + vu.append(("lu", rang)) + yield ligne + + def wait(self, timeout=None): + return 0 + + def poll(self): + return 0 + + original = cleanup.subprocess.Popen + cleanup.subprocess.Popen = lambda *a, **kw: FauxProcessus(lignes) + self.addCleanup(setattr, cleanup.subprocess, "Popen", original) + report = cleanup.run_shell( + "db", + "./config.conf", + "script", + echo=lambda ligne: vu.append(("relayé", ligne)), + ) + self.assertEqual(report["rounds"], []) + relayes = [x for x in vu if x[0] == "relayé"] + self.assertEqual( + [x[1] for x in relayes], + ["modules 1/3 vieux_module", "columns 2/3 res_partner.x"], + ) + # ET au fil de l'eau : le relais suit IMMÉDIATEMENT la lecture de + # sa ligne. « quelque part avant la fin » ne suffirait pas — une + # lecture en bloc suivie d'une boucle de relais passerait aussi. + self.assertEqual(vu[vu.index(("lu", 0)) + 1], relayes[0]) + self.assertEqual(vu[vu.index(("lu", 2)) + 1], relayes[1]) + + def test_the_relay_shows_the_elapsed_time(self): + # Ce qui distingue « ça avance lentement » de « ça ne bouge plus ». + import io + from contextlib import redirect_stdout + + import time as _time + + echo = cleanup.make_echo(_time.monotonic() - 42) + out = io.StringIO() + with redirect_stdout(out): + echo("modules 2/9 stock_deposit") + self.assertIn("42", out.getvalue()) + self.assertIn("stock_deposit", out.getvalue()) + + def test_the_slowness_is_announced_up_front(self): + import inspect + + source = inspect.getsource(cleanup.main) + self.assertIn("reloads the whole registry", source) + + def test_the_timeout_cannot_be_starved_by_silence(self): + # Un délai vérifié DANS la boucle de lecture ne se déclencherait + # jamais : cette boucle bloque tant qu'aucune ligne n'arrive, et + # c'est exactement le cas qu'il faut couvrir. + import inspect + + source = inspect.getsource(cleanup.run_shell) + self.assertIn("threading.Timer", source) + + def test_stopping_kills_the_whole_group(self): + # « ./odoo_bin.sh » est un script bash : un terminate() sur lui tue + # le script et laisse odoo-bin vivant, sur la base, avec ses + # verrous. La leçon a déjà été payée en serveurs orphelins. + import inspect + + source = inspect.getsource(cleanup.kill_group) + self.assertIn("killpg", source) + self.assertIn( + "start_new_session", inspect.getsource(cleanup.run_shell) + ) + + def test_a_timeout_is_reported_as_such(self): + # Sans cela, un arrêt à l'expiration se lisait « le nettoyage n'a + # produit aucun rapport » — un diagnostic faux. + import inspect + + source = inspect.getsource(cleanup.run_shell) + self.assertIn("was still running after", source) + self.assertLess( + source.index("was still running after"), + source.index("produced no report"), + ) + + class TestTheCascadeThatKilledEverything(unittest.TestCase): """Vécu, sur test_neutralize_upgrade_13 : sept catégories mortes d'une. From 7296a0317904e9e9a81dde1102339c5ec367479b Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 18 Aug 2026 00:08:31 -0400 Subject: [PATCH 38/67] [FIX] migration: repair, replay, and know when to stop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The failure that stops an upgrade is almost always the same one — a COW copy left behind on the previous version — and the repair is known. So Enter now repairs, and a repair that actually changed something replays the command by itself. A default that acts must know when to stop, and here it must twice over. Repairing when there is nothing left to repair, then offering it again, loops without end: measured, « no COW copy has drifted » over and over. And a repair that does not help would replay the command forever. Three attempts, then the prompt says plainly that this one needs a developer. The turn counter is deliberately redundant with that logic: both guard the same failure, but the counter holds even if the logic is broken one day by accident. An endless loop in an unattended migration costs a night. Also: the smoke tool forced `ask=input` on its own prompt, which short-circuited auto-run — the question waited for a keystroke nobody was there to give. --- FR --- [FIX] migration : réparer, rejouer, et savoir s'arrêter L'échec qui arrête une migration est presque toujours le même — une copie COW restée sur la version d'avant — et la réparation est connue. Entrée répare donc, et une réparation qui a vraiment changé quelque chose rejoue la commande d'elle-même. Un défaut qui agit doit savoir s'arrêter, et ici deux fois plutôt qu'une. Réparer quand il n'y a plus rien à réparer, puis le reproposer, boucle sans fin : mesuré, « Aucune copie COW n'a dérivé », encore et encore. Et une réparation qui ne suffit pas rejouerait indéfiniment. Trois tentatives, puis l'invite dit qu'il faut un développeur. Le compteur de tours double volontairement cette logique : il tient même si elle est cassée un jour par mégarde. Assisted-by: Claude Opus 5 --- script/odoo/migration/smoke_public_url.py | 7 +- script/todo/todo_i18n.py | 20 ++ script/todo/todo_upgrade.py | 85 ++++++-- test/test_error_retry_loop.py | 229 ++++++++++++++++++++++ 4 files changed, 324 insertions(+), 17 deletions(-) create mode 100644 test/test_error_retry_loop.py diff --git a/script/odoo/migration/smoke_public_url.py b/script/odoo/migration/smoke_public_url.py index 3b7028a..0c7c22a 100755 --- a/script/odoo/migration/smoke_public_url.py +++ b/script/odoo/migration/smoke_public_url.py @@ -534,7 +534,7 @@ def run( boot=180, interactive=False, auto_apply=False, - ask=input, + ask=None, internal=True, internal_login="test", internal_password="test", @@ -612,6 +612,11 @@ def run( if code == 2: lst_done = [] else: + # `ask=None` et non `input` : c'est `prompt` qui sait quel défaut + # lui appartient, et qui pose alors la question par le lecteur + # temporisé. Forcer `input` ici court-circuitait le mode auto — + # mesuré, l'invite attendait indéfiniment une frappe pendant une + # migration lancée en auto-exécution. lst_done = prompt(database, lst_failure, lst_key, ask=ask) if not lst_done: return lst_url, lst_failure, lst_key, None, internal_report diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 6128d33..97f0b13 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -5462,6 +5462,26 @@ TRANSLATIONS = { "fr": "le lot entier", "en": "the whole batch", }, + "Still failing after": { + "fr": "Échoue toujours après", + "en": "Still failing after", + }, + "attempts: this one needs a developer.": { + "fr": "tentatives : celle-ci demande un développeur.", + "en": "attempts: this one needs a developer.", + }, + "Error detected. Choose, or ctrl+c to": { + "fr": "Erreur détectée. Choisissez, ou ctrl+c pour", + "en": "Error detected. Choose, or ctrl+c to", + }, + "stop": { + "fr": "arrêter", + "en": "stop", + }, + "Too many turns on this prompt: moving on.": { + "fr": "Trop de tours sur cette invite : on passe.", + "en": "Too many turns on this prompt: moving on.", + }, "Clean the database before testing the pages?": { "fr": "Nettoyer la base avant de tester les pages ?", "en": "Clean the database before testing the pages?", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index d475978..1813421 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -761,6 +761,8 @@ class TodoUpgrade: return dct_kept AUTO_DELAY = 5 + MAX_ERROR_RETRY = 3 + MAX_ERROR_TURNS = 8 def prompt_auto_execute(self): """Proposer que les invites prennent leur défaut après un délai. @@ -2935,13 +2937,17 @@ class TodoUpgrade: dans un diff de mille lignes. On la recopie, on se trompe d'un caractère, et la commande ne fait rien sans le dire — une clé qui ne correspond à rien n'est pas une erreur pour l'outil. + + Rend True si quelque chose a VRAIMENT été réinitialisé. C'est ce qui + permet de rejouer la commande derrière : rejouer alors qu'on n'a + rien changé donnerait le même échec, indéfiniment. """ lst_key = self.stale_cow_keys(database_name) if not lst_key: print( f"✅ -> {t('No COW copy has drifted from its module view.')}" ) - return + return False print(f"\n✨ {t('Drifted COW copies')} :") for index, key in enumerate(lst_key, start=1): print(f" [{index}] {key}") @@ -2960,7 +2966,7 @@ class TodoUpgrade: # une sortie sans mot pour dire non serait une sortie sans issue. if not answer or answer == "n": print(f"ℹ -> {t('Kept. Nothing was reset.')}") - return + return False if answer == "a": lst_chosen = ["all"] else: @@ -2970,12 +2976,13 @@ class TodoUpgrade: lst_chosen.append(lst_key[int(part) - 1]) if not lst_chosen: print(f"⚠️ {t('Unknown choice, nothing was reset.')}") - return + return False args = " ".join(f"--reset {key}" for key in lst_chosen) - self.run_on_terminal( + status = self.run_on_terminal( f"{PYTHON_BIN} ./{os.path.join(PATH_MIGRATION_GLOBAL, 'reset_stale_cow_views.py')}" f" -d {database_name} {args} --apply" ) + return status == 0 def prompt_database_cleanup(self, database_name): """Proposer le nettoyage OCA avant d'interroger les pages. @@ -3481,6 +3488,7 @@ class TodoUpgrade: get_output=False, output_is_json=False, wait_at_error=True, + attempt=1, ): if output_is_json and not get_output: get_output = True @@ -3511,7 +3519,27 @@ class TodoUpgrade: # always sets one, but a silent None must not skip this prompt). if (status is None or status) and wait_at_error: database_name = self.database_from_command(cmd) + # « 3 » par défaut, car le motif d'échec le plus fréquent ici est + # une copie COW en retard : la réparer est presque toujours ce + # qu'on allait faire. Sans base nommée, les options 2 à 4 + # n'existent pas et le défaut redevient « continuer ». + defaut = "3" if database_name else "" + repare = False + tours = 0 while True: + # Une borne STRUCTURELLE, et non pas seulement la logique + # ci-dessous qui bascule le défaut. Les deux protections + # visent la même panne — une invite qui se repropose sans + # fin — mais celle-ci tient même si l'autre est cassée un + # jour par mégarde. Une boucle infinie dans une migration + # lancée sans surveillance coûte une nuit. + tours += 1 + if tours > self.MAX_ERROR_TURNS: + print( + f"🛑 {t('Too many turns on this prompt: moving on.')}" + ) + wait_status = "" + break print(f"[1] {t('to redo the command')}") if database_name: print( @@ -3525,8 +3553,10 @@ class TodoUpgrade: # bloquée sans que rien ne le signale. wait_status = ( self.ask( - f"💬 {t('Error detected, press enter to continue or')}" - f" ctrl+c {t('to stop')} : " + f"💬 {t('Error detected. Choose, or ctrl+c to')}" + f" {t('stop')}" + f" ({t('Enter')} = {defaut or t('continue')}) : ", + default=defaut, ) .strip() .lower() @@ -3543,7 +3573,17 @@ class TodoUpgrade: self.check_stale_cow_views(database_name) continue if wait_status == "3" and database_name: - self.prompt_reset_stale_cow_views(database_name) + repare = self.prompt_reset_stale_cow_views(database_name) + if repare: + # Quelque chose a changé : la commande mérite un + # nouvel essai, et c'est le seul cas où le rejeu + # est AUTOMATIQUE. + wait_status = "1" + break + # Rien à réinitialiser. Reproposer « 3 » ferait tourner + # en rond — mesuré : « Aucune copie COW n'a dérivé », + # encore et encore, sans fin. + defaut = "" continue if wait_status == "4" and database_name: # Sur le VRAI terminal : un plein écran refuse de @@ -3560,15 +3600,28 @@ class TodoUpgrade: break if wait_status == "1": - return self.todo_upgrade_execute( - cmd, - single_source_odoo=single_source_odoo, - new_env=new_env, - quiet=quiet, - get_output=get_output, - output_is_json=output_is_json, - wait_at_error=wait_at_error, - ) + # Le rejeu AUTOMATIQUE est borné ; celui qu'on demande à la + # main ne l'est pas. Sans cette borne, une réparation qui + # n'y suffit pas relancerait la commande indéfiniment — et + # une migration lancée en auto-exécution tournerait toute + # la nuit sur le même échec. + if repare and attempt >= self.MAX_ERROR_RETRY: + print( + f"🛑 {t('Still failing after')}" + f" {self.MAX_ERROR_RETRY}" + f" {t('attempts: this one needs a developer.')}" + ) + else: + return self.todo_upgrade_execute( + cmd, + single_source_odoo=single_source_odoo, + new_env=new_env, + quiet=quiet, + get_output=get_output, + output_is_json=output_is_json, + wait_at_error=wait_at_error, + attempt=attempt + 1, + ) if get_output: if output_is_json: diff --git a/test/test_error_retry_loop.py b/test/test_error_retry_loop.py new file mode 100644 index 0000000..296c012 --- /dev/null +++ b/test/test_error_retry_loop.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Réparer puis rejouer — et savoir s'arrêter. + +Le motif d'échec le plus fréquent d'une migration est une copie COW en +retard sur sa vue module : « Element cannot be located in parent +view ». La réparation est connue, et c'est presque toujours ce qu'on allait +faire. D'où « 3 » par défaut, et le rejeu automatique derrière. + +Mais un défaut qui agit doit savoir s'arrêter, et à deux titres : + +- réinitialiser quand il n'y a RIEN à réinitialiser puis reproposer la même + chose est une boucle sans fin. Vécu : « Aucune copie COW n'a dérivé », + encore et encore ; +- une réparation qui ne suffit pas relancerait la commande indéfiniment. + Trois tentatives, puis on rend la main : une migration lancée en + auto-exécution tournerait sinon toute la nuit sur le même échec. + +Ces tests comptent les tours. C'est la seule façon de prouver qu'une +boucle se termine. +""" + +import io +import os +import unittest +from contextlib import redirect_stdout + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +from script.todo import todo_i18n # noqa: E402 +from script.todo.todo_upgrade import TodoUpgrade # noqa: E402 + + +class Harness(unittest.TestCase): + """Un pilote dont la commande échoue toujours, et qu'on observe.""" + + def setUp(self): + self.addCleanup( + setattr, todo_i18n, "_current_lang", todo_i18n._current_lang + ) + todo_i18n._current_lang = "en" + + def upgrade(self, lst_answer=None, resets=None, echec=True): + """`resets` : ce que la réinitialisation rend, tour après tour.""" + obj = TodoUpgrade.__new__(TodoUpgrade) + obj.dct_progression = {} + obj.lst_command_executed = [] + obj.write_config = lambda: None + obj.database_from_command = lambda cmd: "db" + self.lst_run = [] + self.lst_reset = [] + + class FauxExecute: + def exec_command_live(_self, cmd, **kw): + self.lst_run.append(cmd) + # Toujours en échec : c'est le cas qu'on veut borner. + return (1 if echec else 0), cmd + + obj.execute = FauxExecute() + suite = iter(resets if resets is not None else []) + + def faux_reset(database): + self.lst_reset.append(database) + try: + return next(suite) + except StopIteration: + return False + + obj.prompt_reset_stale_cow_views = faux_reset + obj.check_stale_cow_views = lambda db: None + obj.run_on_terminal = lambda cmd: 0 + reponses = iter(lst_answer or []) + + def faux_ask(prompt, default=""): + try: + return next(reponses) + except StopIteration: + # Personne ne répond : c'est le mode auto, et c'est + # précisément là qu'une boucle sans fin se déclenche. + return default + + obj.ask = faux_ask + return obj + + def executer(self, obj): + out = io.StringIO() + with redirect_stdout(out): + obj.todo_upgrade_execute("./script/addons/update_addons_all.sh db") + return out.getvalue() + + +class TestRepairingThenReplaying(Harness): + def test_the_default_repairs(self): + # Sans réponse, on répare : c'est le geste qu'on allait faire. + obj = self.upgrade(resets=[True, False]) + self.executer(obj) + self.assertTrue(self.lst_reset) + + def test_a_successful_repair_replays_the_command(self): + obj = self.upgrade(resets=[True, False]) + self.executer(obj) + self.assertGreaterEqual(len(self.lst_run), 2) + + def test_a_repair_that_changed_nothing_does_NOT_replay(self): + # Rejouer sans avoir rien changé donnerait le même échec. + obj = self.upgrade(resets=[False]) + self.executer(obj) + self.assertEqual(len(self.lst_run), 1) + + +class TestItAlwaysStops(Harness): + """La propriété qui compte le plus : la boucle se termine.""" + + def test_nothing_to_reset_does_not_loop_forever(self): + # Vécu : « Aucune copie COW n'a dérivé », reproposé sans fin parce + # que le défaut restait « 3 ». + obj = self.upgrade(resets=[False, False, False, False, False]) + self.executer(obj) + self.assertLessEqual(len(self.lst_reset), 2) + + def test_a_repair_that_never_helps_stops_after_three(self): + obj = self.upgrade(resets=[True] * 20) + self.executer(obj) + self.assertEqual(len(self.lst_run), TodoUpgrade.MAX_ERROR_RETRY) + + def test_giving_up_says_so_out_loud(self): + # S'arrêter en silence ferait croire que c'est réparé. + obj = self.upgrade(resets=[True] * 20) + text = self.executer(obj) + self.assertIn("needs a developer", text) + + def test_the_loop_is_bounded_STRUCTURALLY(self): + """Même si la bascule du défaut disparaissait un jour. + + Deux protections pour la même panne, et c'est délibéré : celle-ci + ne dépend d'aucune logique métier. Une boucle infinie dans une + migration lancée sans surveillance coûte une nuit, et la seule + preuve qu'une boucle se termine est de compter ses tours. + """ + obj = self.upgrade(lst_answer=["2"] * 50, resets=[]) + self.executer(obj) + self.assertLessEqual(len(self.lst_run), 2) + + def test_the_turn_bound_says_so(self): + obj = self.upgrade(lst_answer=["2"] * 50, resets=[]) + text = self.executer(obj) + self.assertIn("Too many turns", text) + + def test_the_bound_is_three(self): + self.assertEqual(TodoUpgrade.MAX_ERROR_RETRY, 3) + + def test_a_command_that_succeeds_never_asks(self): + obj = self.upgrade(echec=False) + self.executer(obj) + self.assertEqual(self.lst_reset, []) + self.assertEqual(len(self.lst_run), 1) + + +class TestTheHumanKeepsTheWheel(Harness): + def test_typing_one_replays_without_consuming_the_budget(self): + # Le plafond borne le rejeu AUTOMATIQUE. Quelqu'un qui tape « 1 » + # sait ce qu'il fait, et se voir refuser un quatrième essai serait + # une surprise désagréable. + obj = self.upgrade(lst_answer=["1", "1", "1", "1", ""]) + self.executer(obj) + self.assertEqual(len(self.lst_run), 5) + + def test_typing_n_continues_without_repairing(self): + obj = self.upgrade(lst_answer=[""] * 0 + ["x"]) + self.executer(obj) + self.assertEqual(self.lst_reset, []) + self.assertEqual(len(self.lst_run), 1) + + def test_the_prompt_says_what_enter_does(self): + import inspect + + source = inspect.getsource(TodoUpgrade.todo_upgrade_execute) + self.assertIn('defaut = "3" if database_name else ""', source) + self.assertIn("default=defaut", source) + + def test_without_a_database_the_default_is_to_continue(self): + # Les options 2 à 4 ne s'affichent pas : proposer « 3 » viserait + # une commande qui n'existe pas. + obj = self.upgrade() + obj.database_from_command = lambda cmd: None + self.executer(obj) + self.assertEqual(self.lst_reset, []) + self.assertEqual(len(self.lst_run), 1) + + +class TestTheResetReportsWhatItDid(unittest.TestCase): + """Sans verdict, on ne peut pas décider de rejouer.""" + + def test_nothing_drifted_is_False(self): + obj = TodoUpgrade.__new__(TodoUpgrade) + obj.stale_cow_keys = lambda db: [] + with redirect_stdout(io.StringIO()): + self.assertFalse(obj.prompt_reset_stale_cow_views("db")) + + def test_saying_no_is_False(self): + obj = TodoUpgrade.__new__(TodoUpgrade) + obj.stale_cow_keys = lambda db: ["web.layout"] + obj.ask_gate = lambda prompt, default="": "n" + with redirect_stdout(io.StringIO()): + self.assertFalse(obj.prompt_reset_stale_cow_views("db")) + + def test_a_reset_that_ran_is_True(self): + obj = TodoUpgrade.__new__(TodoUpgrade) + obj.stale_cow_keys = lambda db: ["web.layout"] + obj.ask_gate = lambda prompt, default="": default + obj.run_on_terminal = lambda cmd: 0 + with redirect_stdout(io.StringIO()): + self.assertTrue(obj.prompt_reset_stale_cow_views("db")) + + def test_a_reset_that_FAILED_is_False(self): + # Rejouer derrière une réinitialisation qui a échoué, c'est brûler + # une tentative sur un état inchangé. + obj = TodoUpgrade.__new__(TodoUpgrade) + obj.stale_cow_keys = lambda db: ["web.layout"] + obj.ask_gate = lambda prompt, default="": default + obj.run_on_terminal = lambda cmd: 2 + with redirect_stdout(io.StringIO()): + self.assertFalse(obj.prompt_reset_stale_cow_views("db")) + + +if __name__ == "__main__": + unittest.main() From 1c7664409e43d1daef75b0638055d8998ab2a3db Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 18 Aug 2026 02:36:44 -0400 Subject: [PATCH 39/67] [FIX] migration: the module-upgrade step no longer waits forever MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six « press to continue » prompts on the module-code migration were bare input() calls. Auto-run never reached them, so it stopped there and said nothing — the question had been asked, after all. They now go through the timed reader, where Enter and the countdown mean the same thing. They were English too, on a path the rest of which speaks the system language. The guard that forbids a bare input() only looked at a handful of methods, and internal_module_upgrade was not among them — which is exactly how these six survived the first sweep. It is now covered, and the module menu's own prompts are documented as deliberately blocking: they ask for a module name, a path, a version, and « » after five seconds would be a wrong answer, not a convenience. --- FR --- [FIX] migration : l'étape de migration de code n'attend plus indéfiniment Six invites « appuyez pour continuer » de la migration de code étaient des input() nus. L'auto-exécution ne les atteignait pas : elle s'arrêtait là sans rien dire — la question avait bien été posée. Elles passent désormais par le lecteur temporisé, où Entrée et le compte à rebours font la même chose. Elles étaient aussi en anglais, sur un chemin dont tout le reste parle la langue du système. Le garde-fou qui interdit les input() nus ne regardait qu'une poignée de méthodes, et internal_module_upgrade n'en faisait pas partie — c'est ainsi que ces six-là avaient survécu. Il la couvre maintenant, et les invites du menu de modules sont documentées comme bloquantes à dessein : elles demandent un nom, un chemin, une version. Assisted-by: Claude Opus 5 --- script/todo/todo_i18n.py | 16 ++++++++++++++++ script/todo/todo_upgrade.py | 29 +++++++++++++++++++--------- test/test_prompt_defaults.py | 37 ++++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 9 deletions(-) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 97f0b13..eb2a1c6 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -5482,6 +5482,22 @@ TRANSLATIONS = { "fr": "Trop de tours sur cette invite : on passe.", "en": "Too many turns on this prompt: moving on.", }, + "Please validate git commit on repos, press to": { + "fr": "Veuillez valider les commits sur les dépôts, appuyez pour", + "en": "Please validate git commit on repos, press to", + }, + "Check the migrated code, press to continue": { + "fr": "Vérifiez le code migré, appuyez pour continuer", + "en": "Check the migrated code, press to continue", + }, + "Please validate the repo is ready to run": { + "fr": "Veuillez valider que le dépôt est prêt pour", + "en": "Please validate the repo is ready to run", + }, + "press to continue": { + "fr": "appuyez pour continuer", + "en": "press to continue", + }, "Clean the database before testing the pages?": { "fr": "Nettoyer la base avant de tester les pages ?", "en": "Clean the database before testing the pages?", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index 1813421..b50ff35 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -1062,8 +1062,9 @@ class TodoUpgrade: cmd = f"./script/code/git_commit_migration_addons_path.py --path {commit_path} --odoo_version {next_version}.0" self.todo_upgrade_execute(cmd) print(set_path_migrate_addons) - status = input( - f"💬 Please validate git commit on repos, press to continue : " + status = self.ask( + f"💬 {t('Please validate git commit on repos, press to')}" + f" {t('continue')} : " ).strip() def internal_module_upgrade( @@ -1111,7 +1112,9 @@ class TodoUpgrade: print("List of path with migrate code :") print(lst_path_git_clone_migrate) print("ℹ To show repo status :\nmake repo_show_status") - input("💬 Check migration code, press to continue : ") + self.ask( + f"💬 {t('Check the migrated code, press to continue')} : " + ) # source_module_path = dct_module_result.get( # "source_module_path" @@ -1147,8 +1150,9 @@ class TodoUpgrade: if next_version in [18]: # TODO need odoo 18, validate python version without switch - status = input( - f"💬 Please validate repo is ready to run upgrade views_migration_18, press to continue : " + status = self.ask( + f"💬 {t('Please validate the repo is ready to run')}" + f" views_migration_18, {t('press to continue')} : " ).strip() # Apply modification with views_migration_18 has_cmd = False @@ -1171,11 +1175,15 @@ class TodoUpgrade: print("List of module with migration 18 :") print(lst_module_to_migrate_all) print("ℹ To show repo status :\nmake repo_show_status") - input("💬 Check migration 18 code, press to continue : ") + self.ask( + f"💬 {t('Check the migrated code, press to continue')}" + " (18) : " + ) if next_version == 17: - status = input( - f"💬 Please validate repo is ready to run upgrade views_migration_17, press to continue : " + status = self.ask( + f"💬 {t('Please validate the repo is ready to run')}" + f" views_migration_17, {t('press to continue')} : " ).strip() # Apply modification with views_migration_17 has_cmd = False @@ -1202,7 +1210,10 @@ class TodoUpgrade: print("List of module with migration 17 :") print(lst_module_to_migrate_all) print("ℹ To show repo status :\nmake repo_show_status") - input("💬 Check migration 17 code, press to continue : ") + self.ask( + f"💬 {t('Check the migrated code, press to continue')}" + " (17) : " + ) def execute_odoo_upgrade(self): # TODO update dev environment for git project diff --git a/test/test_prompt_defaults.py b/test/test_prompt_defaults.py index bb773ea..fb0248d 100644 --- a/test/test_prompt_defaults.py +++ b/test/test_prompt_defaults.py @@ -174,6 +174,9 @@ class TestNoPromptOfTheMigrationCanHang(unittest.TestCase): METHODES = ( "execute_odoo_upgrade", + # Appelée DEPUIS execute_odoo_upgrade : c'est par elle que le + # « validez que le dépôt est prêt » arrivait, et il bloquait. + "internal_module_upgrade", "prompt_cow_prediction", "prompt_uninstall_theme", "prompt_reset_stale_cow_views", @@ -210,6 +213,40 @@ class TestNoPromptOfTheMigrationCanHang(unittest.TestCase): self.assertEqual(coupables, {}, "invites hors du mode auto") +class TestWhereBlockingIsStillRight(unittest.TestCase): + """Tout ne doit pas prendre un défaut : certaines questions n'en ont pas. + + `execute_module_upgrade` demande un nom de module, un chemin, une + version de départ. Rendre « » au bout de cinq secondes n'y serait pas + une commodité mais une réponse fausse. Ces invites restent bloquantes, + et c'est correct — à une condition, vérifiée ici : qu'aucune migration + automatique ne passe par elles. + """ + + def test_the_module_menu_is_not_on_the_automatic_path(self): + import inspect + + source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade) + self.assertNotIn("execute_module_upgrade", source) + + def test_but_the_step_it_shares_IS(self): + # `internal_module_upgrade` est appelée des DEUX côtés : par le + # menu et par la migration. C'est ce qui la rend obligatoire. + import inspect + + source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade) + self.assertIn("internal_module_upgrade", source) + + def test_its_prompts_ask_for_values_with_no_default(self): + # Si l'un d'eux devenait un oui/non, il faudrait le faire passer + # par le lecteur temporisé comme les autres. + import inspect + + source = inspect.getsource(TodoUpgrade.execute_module_upgrade) + for question in ("Module name", "Path", "From odoo version"): + self.assertIn(question, source) + + class TestWhatEnterDoesIsWritten(unittest.TestCase): """La question doit dire la vérité à qui appuie sur Entrée.""" From fc23bd6f96eefab1f8ba5965fe95f4102363ba97 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 19 Aug 2026 03:21:25 -0400 Subject: [PATCH 40/67] [FIX] migration: a skipped back office must not read as a healthy one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The back-office pass was already there, and it works. What did not work was the way it declined: one discreet line at the end of a long report, saying « the database was not neutralized ». On six databases of a real migration the test user is present up to the 15 bump and GONE at 17 and 18 — so the pass stopped silently exactly where a migration does the most damage, and said something that was not even true. The migration knows what it neutralized, so it now asks for the back office by name. A missing test user on a database it neutralized is a finding, printed loudly and counted as a failure. A missing tool is too: returning None made the whole pass vanish without a word. And it says UP FRONT which passes will run, on that database, by name. --- FR --- [FIX] migration : un back-office sauté ne doit pas se lire comme un sain La passe back-office était déjà là et elle fonctionne. Ce qui ne fonctionnait pas, c'est sa façon de renoncer : une ligne discrète en fin d'un long rapport, disant « la base n'a pas été neutralisée ». Sur les six bases d'une vraie migration, l'utilisateur test est présent jusqu'au palier 15 et ABSENT en 17 et 18 — la passe s'arrêtait donc sans bruit là où une migration fait le plus de dégâts, en disant quelque chose de faux. La migration sait ce qu'elle a neutralisé : elle réclame désormais le back-office. Un utilisateur test manquant sur une base qu'elle a neutralisée est une trouvaille, affichée fort et comptée comme un échec. Un outil absent aussi : rendre None faisait disparaître la passe entière. Et elle annonce AVANT de lancer ce qui sera parcouru. Assisted-by: Claude Opus 5 --- script/odoo/migration/smoke_public_url.py | 39 ++++++++- script/todo/todo_i18n.py | 32 +++++++ script/todo/todo_upgrade.py | 21 ++++- test/test_smoke_internal_ui.py | 100 ++++++++++++++++++++++ 4 files changed, 186 insertions(+), 6 deletions(-) diff --git a/script/odoo/migration/smoke_public_url.py b/script/odoo/migration/smoke_public_url.py index 0c7c22a..8b246df 100755 --- a/script/odoo/migration/smoke_public_url.py +++ b/script/odoo/migration/smoke_public_url.py @@ -445,6 +445,14 @@ def render_internal(internal): if internal is None: return False if "skipped" in internal: + if internal.get("loud"): + # Un saut ATTENDU se dit à voix basse ; un saut qui trahit une + # panne doit compter comme un échec, sinon le code de sortie + # annonce que tout va bien. + print( + f"\n⚠️ {t('Back office NOT browsed')} : {internal['skipped']}" + ) + return True print(f"\nℹ️ {t('Back office not browsed')} : {internal['skipped']}") return False import smoke_internal_ui @@ -481,6 +489,7 @@ def internal_phase( limit=20, every_menu=False, lst_portal=None, + required=False, ): """Le back-office, si la base a été neutralisée. Rend None sinon. @@ -498,14 +507,32 @@ def internal_phase( try: import smoke_internal_ui except ImportError: - return None + # Se taire ici ferait disparaître la passe ENTIÈRE sans un mot, et + # l'on croirait le back-office testé. Un outil absent est une + # panne d'installation, pas une base saine. + return {"skipped": t("smoke_internal_ui.py is missing"), "loud": True} etat = smoke_internal_ui.user_state(database, login, run_psql=run_psql) if etat == "absent": + # `required` dit que la migration a NEUTRALISÉ cette base : le + # compte devrait donc y être. Mesuré — il survit jusqu'au palier + # 15 puis disparaît, et la passe s'arrêtait sans bruit exactement + # là où une migration fait le plus de dégâts. + if required: + return { + "skipped": t( + "the test user is gone from a neutralized" + " database: the back office was NOT checked" + ), + "loud": True, + } return {"skipped": t("no test user: the database was not neutralized")} if etat != "present": # « je ne sais pas » n'est pas « tout va bien » : le dire autrement # ferait passer un back-office jamais ouvert pour un back-office sain. - return {"skipped": t("could not tell whether the test user exists")} + return { + "skipped": t("could not tell whether the test user exists"), + "loud": True, + } try: lst_result, lst_failure = smoke_internal_ui.run( base_url, @@ -541,6 +568,7 @@ def run( internal_limit=20, every_menu=False, portal=None, + internal_required=False, ): """Démarrer, interroger, arrêter, LIRE, éventuellement corriger, revérifier. @@ -585,6 +613,7 @@ def run( limit=internal_limit, every_menu=every_menu, lst_portal=portal, + required=internal_required, ) finally: stop_server(server) @@ -720,6 +749,11 @@ def main(argv=None): action="store_true", help="open every menu with an action, not just each app's first page", ) + parser.add_argument( + "--internal-required", + action="store_true", + help="the database WAS neutralized: a missing test user is a failure", + ) parser.add_argument( "--portal", default="/my", @@ -748,6 +782,7 @@ def main(argv=None): internal_password=config.password, internal_limit=config.record_limit, every_menu=config.all_menus, + internal_required=config.internal_required, portal=[ path.strip() for path in (config.portal or "").split(",") diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index eb2a1c6..c6abf99 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -5498,6 +5498,38 @@ TRANSLATIONS = { "fr": "appuyez pour continuer", "en": "press to continue", }, + "smoke_internal_ui.py is missing": { + "fr": "smoke_internal_ui.py est absent", + "en": "smoke_internal_ui.py is missing", + }, + "the test user is gone from a neutralized database: the back office was NOT checked": { + "fr": "l'utilisateur test a disparu d'une base neutralisée : le back-office n'a PAS été vérifié", + "en": "the test user is gone from a neutralized database: the back office was NOT checked", + }, + "Back office NOT browsed": { + "fr": "Back-office NON parcouru", + "en": "Back office NOT browsed", + }, + "Public pages, then the back office and /my as the": { + "fr": "Les pages publiques, puis le back-office et /my avec", + "en": "Public pages, then the back office and /my as the", + }, + "test user (neutralized database).": { + "fr": "l'utilisateur test (base neutralisée).", + "en": "test user (neutralized database).", + }, + "Public pages only:": { + "fr": "Pages publiques seulement :", + "en": "Public pages only:", + }, + "was not neutralized, so there is no test user to": { + "fr": "n'a pas été neutralisée, il n'y a donc pas d'utilisateur test pour", + "en": "was not neutralized, so there is no test user to", + }, + "sign in with.": { + "fr": "s'y connecter.", + "en": "sign in with.", + }, "Clean the database before testing the pages?": { "fr": "Nettoyer la base avant de tester les pages ?", "en": "Clean the database before testing the pages?", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index b50ff35..a08e0d4 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -3058,14 +3058,27 @@ class TodoUpgrade: ) if answer != "y": return - print( - f" {t('Public pages, then the back office as the test user')}" - f" {t('if the database was neutralized.')}" - ) + # DIRE ce qui sera parcouru, et le dire AVANT. « si la base a été + # neutralisée » laissait la question ouverte pendant tout le + # parcours, et un saut annoncé en une ligne à la fin d'un long + # rapport ne se voit pas : on croit alors le back-office testé. + neutralise = "_neutralize" in database_name + if neutralise: + print( + f" {t('Public pages, then the back office and /my as the')}" + f" {t('test user (neutralized database).')}" + ) + else: + print( + f" {t('Public pages only:')} '{database_name}'" + f" {t('was not neutralized, so there is no test user to')}" + f" {t('sign in with.')}" + ) self.run_on_terminal( f"{PYTHON_BIN}" " ./script/odoo/migration/smoke_public_url.py" f" -d {database_name}" + + (" --internal-required" if neutralise else "") ) def show_cow_drift(self, database_name, next_version, mode="diff"): diff --git a/test/test_smoke_internal_ui.py b/test/test_smoke_internal_ui.py index 8b3173b..3c41c85 100644 --- a/test/test_smoke_internal_ui.py +++ b/test/test_smoke_internal_ui.py @@ -404,6 +404,106 @@ class TestTheViewsAreRenderedServerSide(unittest.TestCase): self.assertEqual(appels, ["get_views", "load_views", "load_views"]) +class TestASkipThatHidesAFailure(unittest.TestCase): + """Tous les sauts ne se valent pas, et c'est mesuré. + + L'utilisateur `test` survit aux paliers 12 à 15 puis DISPARAÎT : relevé + sur les six bases d'une vraie migration, présent jusqu'à 15, absent en + 17 et 18. La passe back-office s'arrêtait donc sans bruit exactement là + où une migration fait le plus de dégâts, et le rapport disait + tranquillement « la base n'a pas été neutralisée » — ce qui était faux. + + `required` porte ce que la migration SAIT : elle a neutralisé cette + base, le compte devrait y être. Son absence devient alors une + trouvaille, pas une formalité. + """ + + 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" + import smoke_public_url + + self.public = smoke_public_url + original = smoke_public_url.run_psql + self.addCleanup(setattr, smoke_public_url, "run_psql", original) + + def phase(self, rows, **kw): + self.public.run_psql = lambda db, sql: rows + return self.public.internal_phase("http://x", "db", enabled=True, **kw) + + def test_no_user_on_a_plain_database_is_a_quiet_skip(self): + rapport = self.phase([["0"]]) + self.assertIn("not neutralized", rapport["skipped"]) + self.assertFalse(rapport.get("loud")) + + def test_no_user_on_a_NEUTRALIZED_database_is_a_finding(self): + rapport = self.phase([["0"]], required=True) + self.assertIn("NOT checked", rapport["skipped"]) + self.assertTrue(rapport["loud"]) + + def test_a_finding_counts_as_a_failure(self): + # Sinon le code de sortie annonce que tout va bien alors que le + # back-office n'a jamais été ouvert. + import contextlib + import io + + out = io.StringIO() + with contextlib.redirect_stdout(out): + echec = self.public.render_internal( + {"skipped": "disparu", "loud": True} + ) + self.assertTrue(echec) + self.assertIn("NOT browsed", out.getvalue()) + + def test_a_quiet_skip_does_not(self): + import contextlib + import io + + with contextlib.redirect_stdout(io.StringIO()): + self.assertFalse( + self.public.render_internal({"skipped": "pas neutralisée"}) + ) + + def test_an_unreadable_database_is_never_silent(self): + rapport = self.phase([]) + self.assertTrue(rapport["loud"]) + + def test_a_missing_tool_is_never_silent(self): + # `except ImportError: return None` faisait disparaître la passe + # ENTIÈRE sans un mot, et l'on croyait le back-office testé. + import inspect + + source = inspect.getsource(self.public.internal_phase) + debut = source.index("except ImportError") + self.assertIn("loud", source[debut : debut + 400]) + + def test_the_migration_asks_for_it_when_it_neutralized(self): + import inspect + + from script.todo.todo_upgrade import TodoUpgrade + + source = inspect.getsource(TodoUpgrade.prompt_smoke_public_url) + self.assertIn("--internal-required", source) + self.assertIn('"_neutralize" in database_name', source) + + def test_the_migration_says_UP_FRONT_what_will_be_browsed(self): + # Un saut annoncé en une ligne à la fin d'un long rapport ne se + # voit pas : on croit alors le back-office testé. + import inspect + + from script.todo.todo_upgrade import TodoUpgrade + + source = inspect.getsource(TodoUpgrade.prompt_smoke_public_url) + self.assertLess( + source.index("Public pages only:"), + source.index("run_on_terminal"), + ) + + class TestThePortalPage(unittest.TestCase): """/my n'est ni le site public ni le back-office : c'est un troisième rendu, en QWeb frontend, avec ses compteurs qui interrogent chacun leur From 6c186f71e91043d0fbb99eab8e9ac3ec76db9579 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 19 Aug 2026 03:53:56 -0400 Subject: [PATCH 41/67] =?UTF-8?q?[ADD]=20migration:=20=C2=AB=20t=20=C2=BB?= =?UTF-8?q?=20shows=20where=20the=20migration=20stands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A migration crosses six bumps, runs hundreds of commands and lasts hours. The journal said what had been LAUNCHED; it never said what came of it. Three hours in, one reads two hundred command lines without knowing which one failed, nor what the smoke test concluded. « v » could not carry this: it already means « view the differences » in three prompts, and a letter meaning two things is worse than a letter meaning nothing. « t » was free, and it is the same everywhere — one shared string carries both shortcuts, so no prompt can drift. Looking is not answering: the same question comes back afterwards. What was missing was the data. Failures and tool verdicts are now recorded with the step they happened in, bounded so a progression file cannot grow without end. A tool rerun after a repair keeps its LAST verdict and the count of its runs — showing both without distinction would read a repair as a lasting failure. --- FR --- [ADD] migration : « t » montre où en est la migration Une migration traverse six paliers, lance des centaines de commandes et dure des heures. Le journal disait ce qui avait été LANCÉ, jamais ce que cela avait donné. Trois heures plus tard on relit deux cents lignes sans savoir laquelle a échoué, ni ce que le test de fumée a conclu. « v » ne pouvait pas porter cela : il veut déjà dire « voir les différences » dans trois invites, et une lettre qui signifie deux choses est pire qu'une lettre qui ne signifie rien. « t » était libre, et il est le même partout — une seule chaîne porte les deux raccourcis. Regarder n'est pas répondre : la même question revient ensuite. Ce qui manquait, c'étaient les données. Les échecs et les verdicts d'outils sont désormais retenus avec l'étape où ils se sont produits. Assisted-by: Claude Opus 5 --- script/todo/migration_status.py | 227 +++++++++++++++++ script/todo/migration_status_tui.py | 200 +++++++++++++++ script/todo/todo_i18n.py | 73 ++++++ script/todo/todo_upgrade.py | 103 +++++++- test/test_database_cleanup.py | 9 +- test/test_error_retry_loop.py | 4 + test/test_migration_status.py | 376 ++++++++++++++++++++++++++++ test/test_smoke_internal_ui.py | 2 +- 8 files changed, 980 insertions(+), 14 deletions(-) create mode 100755 script/todo/migration_status.py create mode 100644 script/todo/migration_status_tui.py create mode 100644 test/test_migration_status.py diff --git a/script/todo/migration_status.py b/script/todo/migration_status.py new file mode 100755 index 0000000..6cd506d --- /dev/null +++ b/script/todo/migration_status.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Où en est cette migration, et qu'est-ce qui a mal tourné. + +Une migration traverse six paliers, lance des centaines de commandes et +dure des heures. Le journal existant dit ce qui a été LANCÉ ; il ne dit +jamais ce que cela a donné. Après trois heures on relit deux cents lignes +de commandes sans savoir laquelle a échoué, ni ce que le test de fumée a +conclu, ni à quelle étape on se trouve. + +Ce module assemble la réponse à partir du fichier de progression — le +même que la migration écrit après chaque geste — et la rend en texte. Le +plein écran, lui, n'est qu'une autre vue de CES données : deux rendus +séparés dériveraient l'un de l'autre sans que rien ne le signale. + +Rien n'est lu ailleurs que dans ce fichier : l'écran d'état ne doit jamais +toucher une base ni lancer un serveur. On l'ouvre en pleine migration. +""" + +import json +import os +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 + + +DEFAULT_PATH = ".venv.erplibre/odoo_database_migration_log.json" + +# Les codes de sortie que TOUS les outils de migration partagent. Les +# traduire ici plutôt qu'à l'affichage évite qu'un « 1 » passe pour une +# panne alors qu'il annonce des trouvailles. +VERDICT = { + 0: ("✅", "nothing to report"), + 1: ("⚠️", "findings to look at"), + 2: ("❌", "the tool itself failed"), +} + + +def read(path=DEFAULT_PATH): + """La progression, telle qu'écrite. {} si elle n'existe pas encore.""" + try: + with open(path, "r", encoding="utf-8") as handle: + return json.load(handle) + except (OSError, ValueError): + return {} + + +def journal_by_step(dct): + """Le journal découpé par étape, dans l'ordre. + + Les entrées commençant par « # » sont les en-têtes d'étape que la + migration y dépose elle-même ; tout ce qui suit appartient à l'étape + ouverte. On réutilise donc un marquage qui existe déjà plutôt que d'en + inventer un second, qui divergerait. + """ + lst_section = [] + courante = None + for entry in dct.get("command_executed") or []: + texte = str(entry) + if texte.startswith("#"): + courante = {"step": texte.lstrip("# ").strip(), "lst_cmd": []} + lst_section.append(courante) + continue + if courante is None: + courante = {"step": t("before the first step"), "lst_cmd": []} + lst_section.append(courante) + courante["lst_cmd"].append(texte) + return lst_section + + +def events(dct, kind=None): + """Ce qui a mal tourné, et ce que les outils ont conclu.""" + lst = [ + item + for item in (dct.get("lst_event") or []) + if kind is None or item.get("kind") == kind + ] + return lst + + +def verdict(status): + """(icône, phrase) pour un code de sortie d'outil.""" + icone, phrase = VERDICT.get(status, ("❔", "unknown result")) + return icone, t(phrase) + + +def tests_summary(dct): + """Le DERNIER verdict de chaque outil, et le compte des passages. + + Un outil relancé après correction a deux verdicts contradictoires dans + le journal, et c'est le dernier qui décrit la base telle qu'elle est. + Afficher les deux sans les distinguer ferait lire une réparation comme + un échec persistant. + """ + dernier = {} + for item in events(dct, kind="test"): + nom = item.get("name") or "?" + entree = dernier.setdefault(nom, {"name": nom, "runs": 0}) + entree["runs"] += 1 + entree["status"] = item.get("status") + entree["at"] = item.get("at") + entree["step"] = item.get("step") + return [dernier[nom] for nom in sorted(dernier)] + + +def failures(dct): + """Les commandes qui ont échoué, la plus récente d'abord.""" + return list(reversed(events(dct, kind="command"))) + + +def overview(dct): + """L'en-tête : de quelle migration parle-t-on.""" + return { + "file": os.path.basename(dct.get("migration_file") or "?"), + "database": dct.get("config_database_name") or "?", + "target": dct.get("target_odoo_version") or "?", + "started": dct.get("date_create") or "?", + "updated": dct.get("date_update") or "?", + "step": ( + (dct.get("lst_event") or [{}])[-1].get("step") + or _last_step(dct) + or "?" + ), + } + + +def _last_step(dct): + lst = journal_by_step(dct) + return lst[-1]["step"] if lst else None + + +def bumps(dct): + """Les paliers, et lesquels sont faits.""" + done = dct.get("state_4_upgrade_odoo_lst") or [] + lst = dct.get("lst_version_bump") or [] + if not lst: + # Le nom des bases de palier porte la version : c'est la seule + # source disponible quand la liste n'a pas été écrite. + lst = list(range(len(done))) + return [ + {"version": version, "done": bool(i < len(done) and done[i])} + for i, version in enumerate(lst) + ] + + +def render_text(dct, limit_cmd=12): + """Le rapport complet, en texte. C'est aussi le repli du plein écran.""" + if not dct: + return f"ℹ️ {t('No migration in progress.')}" + info = overview(dct) + lignes = [ + f"📍 {t('Migration state')}", + f" {t('database')} : {info['database']}", + f" {t('image')} : {info['file']}", + f" {t('started')} : {info['started']}", + f" {t('last written')} : {info['updated']}", + f" {t('current step')} : {info['step']}", + ] + + lst_test = tests_summary(dct) + lignes.append(f"\n🧪 {t('Test results')}") + if not lst_test: + lignes.append(f" {t('No tool has run yet.')}") + for item in lst_test: + icone, phrase = verdict(item.get("status")) + rejeu = ( + f" ({item['runs']} {t('runs')})" + if item.get("runs", 1) > 1 + else "" + ) + lignes.append(f" {icone} {item['name']:<24} {phrase}{rejeu}") + + lst_failure = failures(dct) + lignes.append(f"\n❌ {t('Commands that failed')} : {len(lst_failure)}") + for item in lst_failure[:10]: + lignes.append(f" · [{item.get('step') or '?'}] {item.get('name')}") + + lignes.append(f"\n🔷 {t('What was done, step by step')}") + for section in journal_by_step(dct): + lst_cmd = section["lst_cmd"] + lignes.append(f" {section['step']} ({len(lst_cmd)})") + for cmd in lst_cmd[:limit_cmd]: + lignes.append(f" {cmd[:120]}") + if len(lst_cmd) > limit_cmd: + reste = len(lst_cmd) - limit_cmd + lignes.append(f" … {reste} {t('more')}") + return "\n".join(lignes) + + +def main(argv=None): + import argparse + + parser = argparse.ArgumentParser( + description="Show what this migration has done, and what failed." + ) + parser.add_argument("-f", "--file", default=DEFAULT_PATH) + parser.add_argument( + "--text", + action="store_true", + help="print the report instead of opening the full screen", + ) + config = parser.parse_args(argv) + dct = read(config.file) + if not config.text: + try: + from script.todo.migration_status_tui import run_tui + except Exception: + run_tui = None + if run_tui and run_tui(dct): + return 0 + print(render_text(dct)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/todo/migration_status_tui.py b/script/todo/migration_status_tui.py new file mode 100644 index 0000000..0777a78 --- /dev/null +++ b/script/todo/migration_status_tui.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""L'état d'une migration, en plein écran. + +Le rapport texte dit tout, mais il dit tout D'UN COUP : sur une migration +de six paliers il fait plusieurs centaines de lignes, et ce qu'on cherche — +l'étape où ça a cassé, ce que le test de fumée a conclu — se trouve +quelque part au milieu. Un écran qui se parcourt règle exactement cela. + +Les données viennent de `migration_status`, comme le rapport texte. Deux +assemblages séparés dériveraient l'un de l'autre sans que rien ne le dise, +et l'on finirait par lire deux états contradictoires de la même migration. +""" + +import os +import sys + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..")) +) + +from script.todo import migration_status as status # noqa: E402 + +try: + from script.todo.todo_i18n import t +except Exception: # pragma: no cover - repli si i18n indisponible + + def t(key: str) -> str: + return key + + +CSS = """ +Screen { layout: vertical; } +#head { height: 4; padding: 0 1; background: $panel; color: $text; } +#body { height: 1fr; } +#left { width: 46; border-right: solid $accent; } +#pane { width: 1fr; padding: 0 1; } +""" + + +def head_text(dct): + """Ce qui identifie la migration. Toujours visible, jamais à chercher.""" + info = status.overview(dct) + lst_test = status.tests_summary(dct) + casse = [x for x in lst_test if x.get("status")] + return ( + f"{info['database']} · {info['file']}\n" + f"{t('current step')} : {info['step']}\n" + f"{t('last written')} : {info['updated']}" + f" · {len(status.failures(dct))} {t('failed')}" + f" · {len(casse)}/{len(lst_test)} {t('Test results')}" + ) + + +def rows(dct): + """Les lignes du panneau de gauche : tests d'abord, puis les étapes. + + Les tests en tête parce que c'est la question qu'on se pose en ouvrant + cet écran ; les étapes ensuite parce que c'est là qu'on cherche le + détail une fois qu'on sait QUOI chercher. + """ + lst = [] + for item in status.tests_summary(dct): + icone, _phrase = status.verdict(item.get("status")) + lst.append( + { + "kind": "test", + "label": f"{icone} {item['name']}", + "detail": str(item.get("runs", 1)), + "data": item, + } + ) + for section in status.journal_by_step(dct): + lst.append( + { + "kind": "step", + "label": section["step"], + "detail": str(len(section["lst_cmd"])), + "data": section, + } + ) + return lst + + +def pane_text(dct, row): + """Le détail de la ligne choisie.""" + if row is None: + return t("Nothing to show yet.") + if row["kind"] == "test": + item = row["data"] + icone, phrase = status.verdict(item.get("status")) + lignes = [ + f"{icone} {item['name']}", + f" {phrase} ({t('exit code')} {item.get('status')})", + f" {t('runs')} : {item.get('runs')}", + f" {t('current step')} : {item.get('step') or '?'}", + f" {item.get('at') or ''}", + ] + return "\n".join(lignes) + section = row["data"] + lignes = [f"{section['step']}", ""] + lst_failure = [ + item + for item in status.failures(dct) + if (item.get("step") or "") == section["step"] + ] + if lst_failure: + lignes.append(f"❌ {t('Commands that failed')} :") + for item in lst_failure: + lignes.append(f" {item.get('name')}") + lignes.append("") + if not section["lst_cmd"]: + lignes.append(t("No tool has run yet.")) + for cmd in section["lst_cmd"]: + lignes.append(f"· {cmd}") + return "\n".join(lignes) + + +def build_app(dct): + """Textual est importé ICI, pas au chargement du module. + + Le module reste importable — donc testable — sur une machine sans + Textual, et c'est aussi ce qui permet à l'appelant de retomber sur le + rapport texte plutôt que d'échouer. + """ + from textual.app import App, ComposeResult + from textual.containers import Horizontal, VerticalScroll + from textual.widgets import DataTable, Footer, Header, Static + + class StatusApp(App): + CSS = globals()["CSS"] + BINDINGS = [("q,escape", "quit", "Quit")] + + def __init__(self, dct): + super().__init__() + self.dct = dct + self.lst_row = rows(dct) + self.index = 0 + + def compose(self) -> ComposeResult: + yield Header() + yield Static("", id="head") + with Horizontal(id="body"): + yield DataTable(id="left", cursor_type="row") + with VerticalScroll(id="pane"): + yield Static("", id="content") + yield Footer() + + def on_mount(self): + self.title = t("Migration state") + table = self.query_one("#left", DataTable) + table.add_columns(t("Test results"), "#") + for row in self.lst_row: + table.add_row(row["label"][:38], row["detail"]) + self.query_one("#head", Static).update(head_text(self.dct)) + self._show() + + def _show(self): + row = self.lst_row[self.index] if self.lst_row else None + self.query_one("#content", Static).update(pane_text(self.dct, row)) + + def on_data_table_row_highlighted(self, event): + if event.data_table.id == "left" and self.lst_row: + self.index = event.cursor_row + self._show() + + return StatusApp(dct) + + +def run_tui(dct, run_app=True): + """Ouvrir l'écran. False si l'on n'a pas pu — et alors on DIT pourquoi. + + Se taire ferait réafficher le rapport texte à la place de l'écran + demandé, sans rien qui distingue les deux. + """ + if not dct: + return False + if not sys.stdout.isatty(): + print(f"ℹ️ {t('Not a terminal: showing the text report instead.')}") + return False + try: + from script.todo import textual_setup + except Exception: + textual_setup = None + if textual_setup and not textual_setup.ensure(): + return False + try: + app = build_app(dct) + except ImportError: + print( + f"ℹ️ {t('Textual is missing from this interpreter:')}" + f" {sys.executable}" + ) + return False + if not run_app: + return app + app.run() + return True diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index c6abf99..934ff08 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -5530,6 +5530,79 @@ TRANSLATIONS = { "fr": "s'y connecter.", "en": "sign in with.", }, + "Migration state": { + "fr": "État de la migration", + "en": "Migration state", + }, + "started": { + "fr": "démarrée", + "en": "started", + }, + "last written": { + "fr": "dernière écriture", + "en": "last written", + }, + "current step": { + "fr": "étape courante", + "en": "current step", + }, + "Test results": { + "fr": "Résultat des tests", + "en": "Test results", + }, + "No tool has run yet.": { + "fr": "Aucun outil n'a encore tourné.", + "en": "No tool has run yet.", + }, + "Commands that failed": { + "fr": "Commandes en échec", + "en": "Commands that failed", + }, + "What was done, step by step": { + "fr": "Ce qui a été fait, étape par étape", + "en": "What was done, step by step", + }, + "runs": { + "fr": "passages", + "en": "runs", + }, + "nothing to report": { + "fr": "rien à signaler", + "en": "nothing to report", + }, + "findings to look at": { + "fr": "des trouvailles à regarder", + "en": "findings to look at", + }, + "the tool itself failed": { + "fr": "l'outil lui-même a échoué", + "en": "the tool itself failed", + }, + "unknown result": { + "fr": "résultat inconnu", + "en": "unknown result", + }, + "before the first step": { + "fr": "avant la première étape", + "en": "before the first step", + }, + "No migration in progress.": { + "fr": "Aucune migration en cours.", + "en": "No migration in progress.", + }, + "show the migration state": { + "fr": "afficher l'état de la migration", + "en": "show the migration state", + }, + "Nothing to show yet.": { + "fr": "Rien à montrer pour l'instant.", + "en": "Nothing to show yet.", + }, + "(b = go back a step, t = show the migration state)": { + "fr": "(b = revenir à une étape précédente," + " t = afficher l'état de la migration)", + "en": "(b = go back a step, t = show the migration state)", + }, "Clean the database before testing the pages?": { "fr": "Nettoyer la base avant de tester les pages ?", "en": "Clean the database before testing the pages?", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index a08e0d4..ee71f70 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -79,6 +79,10 @@ MIGRATION_STEP = [ GLOBAL_PROGRESSION_KEY = frozenset( { "command_executed", + # Le registre des échecs et des verdicts d'outils, comme le journal + # des commandes : un retour en arrière ne doit PAS l'effacer. C'est + # justement ce qu'on vient consulter après être revenu. + "lst_event", "config_database_name", "config_migrate_repo", "date_create", @@ -820,13 +824,37 @@ class TodoUpgrade: """ while True: answer = self.ask(prompt, default=default) - if (answer or "").strip().lower() != "b": + reponse = (answer or "").strip().lower() + if reponse == "t": + # Regarder n'est pas répondre : on repose la MÊME question + # après. Sans cela, ouvrir l'état vaudrait « continuer », ce + # qui est exactement ce qu'on ne voulait pas faire. + self.show_migration_status() + continue + if reponse != "b": return answer if self.rewind_to_chosen_step(): raise MigrationRewind() # Renoncer au retour en arrière ne doit pas arrêter la migration : # on revient à la même invite, exactement là où l'on était. + def show_migration_status(self): + """Ouvrir l'état de la migration, en plein écran. + + On ÉCRIT avant de lire : l'écran lit le fichier de progression, et + ce qui vient de se passer n'y serait pas encore. + + Pas par `run_on_terminal` : celui-ci consigne ce qu'il lance dans + le journal, et le journal est justement ce que cet écran montre. + Regarder l'état polluerait alors l'état. + """ + self.write_config() + subprocess.call( + f"{PYTHON_BIN} ./script/todo/migration_status.py", + shell=True, + executable="/bin/bash", + ) + def rewind_to_chosen_step(self): """Demander l'étape et rembobiner jusqu'à elle. Écrit la progression.""" ctx = self.resume_context(self.dct_progression) @@ -1532,7 +1560,7 @@ class TodoUpgrade: self.ask_gate( "💬 " + t("Neutralize database, press to continue") - + f" {t('(b = go back to a previous step)')} : " + + f" {t('(b = go back a step, t = show the migration state)')} : " ) .strip() .lower() @@ -2527,7 +2555,7 @@ class TodoUpgrade: ) status = self.ask_gate( f"💬 {t('Press to continue')} {msg}" - f" {t('(b = go back to a previous step)')} : " + f" {t('(b = go back a step, t = show the migration state)')} : " ).strip() # The technique change at version 14 if next_version <= 13: @@ -2860,6 +2888,9 @@ class TodoUpgrade: une phrase. """ prefix, sep, label = msg.partition(" - ") + # Retenu pour l'écran d'état : un événement sans étape oblige à + # relire tout le journal pour savoir OÙ il s'est produit. + self.current_step = msg print(f"🔷 {prefix}{sep}{t(label)}" if sep else f"🔷 {t(msg)}") def installed_theme(self, database_name): @@ -2903,7 +2934,7 @@ class TodoUpgrade: answer = ( self.ask_gate( f"💬 {t('Uninstall them properly before migrating?')}" - f" (Y/n, {t('(b = go back to a previous step)')}) : ", + f" (Y/n, {t('(b = go back a step, t = show the migration state)')}) : ", default="y", ) .strip() @@ -2989,9 +3020,10 @@ class TodoUpgrade: print(f"⚠️ {t('Unknown choice, nothing was reset.')}") return False args = " ".join(f"--reset {key}" for key in lst_chosen) - status = self.run_on_terminal( + status = self.run_tool( + "reset_stale_cow_views", f"{PYTHON_BIN} ./{os.path.join(PATH_MIGRATION_GLOBAL, 'reset_stale_cow_views.py')}" - f" -d {database_name} {args} --apply" + f" -d {database_name} {args} --apply", ) return status == 0 @@ -3009,7 +3041,7 @@ class TodoUpgrade: answer = ( self.ask_gate( f"💬 {t('Clean the database before testing the pages?')}" - f" (Y/n, {t('(b = go back to a previous step)')}) : ", + f" (Y/n, {t('(b = go back a step, t = show the migration state)')}) : ", default="y", ) .strip() @@ -3017,10 +3049,11 @@ class TodoUpgrade: ) if answer != "y": return - self.run_on_terminal( + self.run_tool( + "database_cleanup", f"{PYTHON_BIN}" " ./script/odoo/migration/database_cleanup.py" - f" -d {database_name}" + f" -d {database_name}", ) def prompt_smoke_public_url(self, database_name, baseline=False): @@ -3050,7 +3083,7 @@ class TodoUpgrade: answer = ( self.ask_gate( f"💬 {t('Request every public URL of this database now?')}" - f" (Y/n, {t('(b = go back to a previous step)')}) : ", + f" (Y/n, {t('(b = go back a step, t = show the migration state)')}) : ", default="y", ) .strip() @@ -3074,11 +3107,12 @@ class TodoUpgrade: f" {t('was not neutralized, so there is no test user to')}" f" {t('sign in with.')}" ) - self.run_on_terminal( + self.run_tool( + "smoke_public_url", f"{PYTHON_BIN}" " ./script/odoo/migration/smoke_public_url.py" f" -d {database_name}" - + (" --internal-required" if neutralise else "") + + (" --internal-required" if neutralise else ""), ) def show_cow_drift(self, database_name, next_version, mode="diff"): @@ -3542,6 +3576,10 @@ class TodoUpgrade: # failure, never as a success (defence in depth: exec_command_live now # always sets one, but a silent None must not skip this prompt). if (status is None or status) and wait_at_error: + # AVANT l'invite : si l'on répond ctrl+c, l'échec doit tout de + # même figurer dans l'état — c'est précisément celui qu'on + # cherchera en revenant. + self.record_event("command", cmd, status if status else 1) database_name = self.database_from_command(cmd) # « 3 » par défaut, car le motif d'échec le plus fréquent ici est # une copie COW en retard : la réparer est presque toujours ce @@ -3815,6 +3853,47 @@ class TodoUpgrade: return parts[1], remote, remote_branch return None, None, None + MAX_EVENT = 200 + + def record_event(self, kind, name, status, detail=""): + """Garder ce qui s'est MAL passé, et ce que les outils ont conclu. + + Le journal existant (`command_executed`) dit ce qui a été LANCÉ, + jamais ce que cela a donné. Après six paliers on lit deux cents + lignes de commandes sans savoir laquelle a échoué, ni ce que le + test de fumée a trouvé. C'est cette question-là que l'écran d'état + doit pouvoir répondre. + + Borné : une migration lance des centaines de commandes, et un + fichier de progression qui enfle sans limite finit par coûter plus + cher à écrire qu'à lire. + """ + lst = list(self.dct_progression.get("lst_event") or []) + lst.append( + { + "at": str(datetime.datetime.now()), + "step": getattr(self, "current_step", "") or "", + "kind": kind, + "name": name, + "status": status, + "detail": str(detail)[:500], + } + ) + self.dct_progression["lst_event"] = lst[-self.MAX_EVENT :] + self.write_config() + + def run_tool(self, name, cmd): + """Lancer un outil de migration et RETENIR sa conclusion. + + Les outils partagent une convention de code de sortie — 0 rien à + signaler, 1 des trouvailles, 2 l'outil a échoué — et c'est + exactement ce qu'on veut relire plus tard. Sans cela, « le test de + fumée est-il passé ? » n'a pas de réponse une heure après. + """ + status = self.run_on_terminal(cmd) + self.record_event("test", name, status, cmd) + return status + def add_comment_progression(self, comment): comment_to_add = f"# {comment}" self.lst_command_executed.append(comment_to_add) diff --git a/test/test_database_cleanup.py b/test/test_database_cleanup.py index 29ca709..b6767a1 100755 --- a/test/test_database_cleanup.py +++ b/test/test_database_cleanup.py @@ -771,8 +771,15 @@ class TestTheMigrationRunsItBeforeTheSmokeTest(unittest.TestCase): from script.todo.todo_upgrade import TodoUpgrade source = inspect.getsource(TodoUpgrade.prompt_database_cleanup) - self.assertIn("run_on_terminal", source) + # `run_tool` retient le verdict PUIS délègue à `run_on_terminal` : + # les deux propriétés comptent, et vérifier la seconde à la source + # évite qu'un raccourci futur reprenne l'exécuteur qui capture. + self.assertIn("run_tool(", source) self.assertNotIn("todo_upgrade_execute", source) + self.assertIn( + "self.run_on_terminal(", + inspect.getsource(TodoUpgrade.run_tool), + ) def test_saying_no_cleans_nothing(self): # Le défaut ne retire pas le choix : il ne fait qu'en proposer un. diff --git a/test/test_error_retry_loop.py b/test/test_error_retry_loop.py index 296c012..d2554be 100644 --- a/test/test_error_retry_loop.py +++ b/test/test_error_retry_loop.py @@ -208,6 +208,8 @@ class TestTheResetReportsWhatItDid(unittest.TestCase): def test_a_reset_that_ran_is_True(self): obj = TodoUpgrade.__new__(TodoUpgrade) + obj.dct_progression = {} + obj.write_config = lambda: None obj.stale_cow_keys = lambda db: ["web.layout"] obj.ask_gate = lambda prompt, default="": default obj.run_on_terminal = lambda cmd: 0 @@ -218,6 +220,8 @@ class TestTheResetReportsWhatItDid(unittest.TestCase): # Rejouer derrière une réinitialisation qui a échoué, c'est brûler # une tentative sur un état inchangé. obj = TodoUpgrade.__new__(TodoUpgrade) + obj.dct_progression = {} + obj.write_config = lambda: None obj.stale_cow_keys = lambda db: ["web.layout"] obj.ask_gate = lambda prompt, default="": default obj.run_on_terminal = lambda cmd: 2 diff --git a/test/test_migration_status.py b/test/test_migration_status.py new file mode 100644 index 0000000..d335719 --- /dev/null +++ b/test/test_migration_status.py @@ -0,0 +1,376 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""« Où en est-on, et qu'est-ce qui a cassé ? » + +Une migration traverse six paliers, lance des centaines de commandes et +dure des heures. Le journal existant dit ce qui a été LANCÉ ; il ne dit +jamais ce que cela a donné. Trois heures plus tard on relit deux cents +lignes de commandes sans savoir laquelle a échoué, ni ce que le test de +fumée a conclu. + +Deux choses se vérifient ici, et la seconde est la moins évidente : + +- un outil relancé APRÈS correction a deux verdicts contradictoires dans + le journal, et c'est le dernier qui décrit la base telle qu'elle est. + Les afficher tous les deux sans les distinguer ferait lire une + réparation comme un échec persistant ; +- l'écran ne doit RIEN toucher. On l'ouvre en pleine migration, souvent + pendant qu'un serveur tourne. +""" + +import io +import os +import unittest +from contextlib import redirect_stdout + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +from script.todo import migration_status as status # noqa: E402 +from script.todo import migration_status_tui as tui # noqa: E402 +from script.todo import todo_i18n # noqa: E402 +from script.todo.todo_upgrade import TodoUpgrade # noqa: E402 + + +def progression(**override): + dct = { + "migration_file": "/a/b/technolibre_2026.zip", + "config_database_name": "test_neutralize", + "date_create": "2026-08-18 03:25:25", + "date_update": "2026-08-18 06:38:55", + "command_executed": [ + "# 0 - Inspect zip", + "make switch_odoo_12", + "# 2 - Succeed update all addons", + "./script/addons/update_addons_all.sh test", + "./script/odoo/migration/check_cow_views.py -d test", + ], + "lst_event": [ + { + "at": "05:01", + "step": "2 - Succeed update all addons", + "kind": "command", + "name": "update_addons_all.sh test", + "status": 1, + "detail": "", + }, + { + "at": "05:20", + "step": "2 - Succeed update all addons", + "kind": "test", + "name": "smoke_public_url", + "status": 2, + "detail": "", + }, + { + "at": "05:44", + "step": "2 - Succeed update all addons", + "kind": "test", + "name": "smoke_public_url", + "status": 0, + "detail": "", + }, + ], + } + dct.update(override) + return dct + + +class Base(unittest.TestCase): + def setUp(self): + self.addCleanup( + setattr, todo_i18n, "_current_lang", todo_i18n._current_lang + ) + todo_i18n._current_lang = "en" + + +class TestCuttingTheJournalByStep(Base): + def test_the_step_headers_are_the_cut(self): + # On réutilise le marquage « # » que la migration dépose déjà. + # En inventer un second qui divergerait serait pire que rien. + lst = status.journal_by_step(progression()) + self.assertEqual( + [s["step"] for s in lst], + ["0 - Inspect zip", "2 - Succeed update all addons"], + ) + + def test_the_commands_land_under_their_step(self): + lst = status.journal_by_step(progression()) + self.assertEqual(len(lst[1]["lst_cmd"]), 2) + + def test_a_command_before_any_header_is_not_lost(self): + dct = progression(command_executed=["make quelque_chose"]) + lst = status.journal_by_step(dct) + self.assertEqual(len(lst), 1) + self.assertEqual(lst[0]["lst_cmd"], ["make quelque_chose"]) + + def test_an_empty_progression_yields_nothing(self): + self.assertEqual(status.journal_by_step({}), []) + + +class TestARepairMustNotReadAsAFailure(Base): + """LE point délicat du résumé. + + `smoke_public_url` a rendu 2 puis 0 : il a échoué, on a réparé, il est + repassé. Montrer les deux verdicts côte à côte ferait conclure que la + base est toujours cassée. + """ + + def test_the_LAST_verdict_wins(self): + lst = status.tests_summary(progression()) + smoke = [x for x in lst if x["name"] == "smoke_public_url"][0] + self.assertEqual(smoke["status"], 0) + + def test_but_the_earlier_runs_are_still_counted(self): + # Les taire ferait croire à un premier essai réussi, et l'on + # perdrait la trace de ce qui a demandé une réparation. + lst = status.tests_summary(progression()) + smoke = [x for x in lst if x["name"] == "smoke_public_url"][0] + self.assertEqual(smoke["runs"], 2) + + def test_the_report_shows_both_facts(self): + text = status.render_text(progression()) + self.assertIn("smoke_public_url", text) + self.assertIn("2 runs", text) + self.assertIn("nothing to report", text) + + +class TestTheExitCodeConvention(Base): + """0 rien, 1 des trouvailles, 2 l'outil a échoué — partout la même.""" + + def test_zero_is_not_alarming(self): + self.assertEqual(status.verdict(0)[1], "nothing to report") + + def test_one_is_findings_not_a_crash(self): + # Le dire « échec » inquiéterait pour rien : 1 veut dire qu'il y a + # quelque chose à regarder, ce qui est le but de l'outil. + self.assertEqual(status.verdict(1)[1], "findings to look at") + + def test_two_is_the_tool_itself(self): + self.assertEqual(status.verdict(2)[1], "the tool itself failed") + + def test_anything_else_is_admitted_as_unknown(self): + self.assertEqual(status.verdict(77)[1], "unknown result") + + +class TestFailedCommands(Base): + def test_they_are_listed_newest_first(self): + dct = progression() + dct["lst_event"].append( + { + "at": "06:00", + "step": "3", + "kind": "command", + "name": "récente", + "status": 1, + "detail": "", + } + ) + self.assertEqual(status.failures(dct)[0]["name"], "récente") + + def test_they_carry_the_step_they_happened_in(self): + # Sans l'étape, il faut relire tout le journal pour savoir OÙ. + self.assertIn( + "2 - Succeed update all addons", + status.failures(progression())[0]["step"], + ) + + def test_tests_are_not_mixed_in_with_them(self): + self.assertEqual(len(status.failures(progression())), 1) + + +class TestItTouchesNothing(Base): + """On l'ouvre en pleine migration, souvent serveur allumé.""" + + def test_reading_a_missing_file_is_not_an_error(self): + self.assertEqual(status.read("/nexiste/pas.json"), {}) + + def test_reading_a_broken_file_is_not_an_error(self): + import tempfile + + chemin = os.path.join(tempfile.gettempdir(), "erplibre_casse.json") + with open(chemin, "w") as handle: + handle.write("{ pas du json") + self.addCleanup(os.remove, chemin) + self.assertEqual(status.read(chemin), {}) + + def test_nothing_in_the_module_writes_or_connects(self): + import inspect + + source = inspect.getsource(status) + for interdit in ("psql", "subprocess", "odoo_bin", 'open(.*, "w")'): + self.assertNotIn(interdit, source, interdit) + + def test_an_empty_progression_says_so_rather_than_crashing(self): + self.assertIn("No migration in progress", status.render_text({})) + + +class TestTheFullScreenShowsTheSameThing(Base): + def test_it_reads_the_same_assembly_as_the_text(self): + # Deux assemblages sépareraient les deux vues, et l'on finirait + # par lire deux états contradictoires de la même migration. + import inspect + + self.assertIn("import migration_status", inspect.getsource(tui)) + + def test_the_tests_come_before_the_steps(self): + # C'est la question qu'on se pose en ouvrant cet écran. + lst = tui.rows(progression()) + self.assertEqual(lst[0]["kind"], "test") + self.assertTrue(any(x["kind"] == "step" for x in lst)) + + def test_the_head_never_hides_what_failed(self): + texte = tui.head_text(progression()) + self.assertIn("test_neutralize", texte) + self.assertIn("1", texte) + + def test_a_step_pane_names_its_own_failures(self): + lst = tui.rows(progression()) + etape = [x for x in lst if x["kind"] == "step"][1] + self.assertIn( + "update_addons_all.sh test", tui.pane_text(progression(), etape) + ) + + def test_a_test_pane_gives_the_exit_code(self): + lst = tui.rows(progression()) + texte = tui.pane_text(progression(), lst[0]) + self.assertIn("smoke_public_url", texte) + + def test_a_pipe_is_explained_rather_than_silent(self): + out = io.StringIO() + with redirect_stdout(out): + self.assertFalse(tui.run_tui(progression())) + self.assertTrue(out.getvalue().strip()) + + def test_nothing_to_show_stays_silent(self): + out = io.StringIO() + with redirect_stdout(out): + self.assertFalse(tui.run_tui({})) + self.assertEqual(out.getvalue(), "") + + +class TestTheLetterIsFreeAndUniform(Base): + """« v » était pris : il veut dire « voir les différences » ailleurs.""" + + def test_v_is_taken_by_the_diff_prompts(self): + import inspect + + source = inspect.getsource(TodoUpgrade.prompt_cow_prediction) + self.assertIn('"v"', source) + + def test_the_state_letter_is_t_everywhere(self): + import inspect + + source = inspect.getsource(TodoUpgrade.ask_gate) + self.assertIn('reponse == "t"', source) + + def test_every_gate_prompt_announces_it(self): + chemin = os.path.join(REPO, "script", "todo", "todo_upgrade.py") + with open(chemin) as handle: + texte = handle.read() + # Une seule chaîne porte les deux raccourcis : les annoncer + # séparément les laisserait diverger d'une invite à l'autre. + self.assertGreaterEqual(texte.count("t = show the migration state"), 1) + self.assertNotIn("(b = go back to a previous step)", texte) + + def test_t_collides_with_no_other_answer(self): + import inspect + + for methode in ( + "prompt_uninstall_theme", + "prompt_database_cleanup", + "prompt_smoke_public_url", + "prompt_reset_stale_cow_views", + ): + source = inspect.getsource(getattr(TodoUpgrade, methode)) + self.assertNotIn('== "t"', source, methode) + + +class TestLookingIsNotAnswering(Base): + def test_the_state_reopens_the_same_question(self): + obj = TodoUpgrade.__new__(TodoUpgrade) + vu = [] + obj.show_migration_status = lambda: vu.append("ouvert") + reponses = iter(["t", "t", "y"]) + obj.ask = lambda prompt, default="": next(reponses) + self.assertEqual(obj.ask_gate("q : "), "y") + self.assertEqual(len(vu), 2) + + def test_it_writes_before_it_reads(self): + # L'écran lit le FICHIER de progression : ce qui vient de se passer + # n'y serait pas encore. + import inspect + + source = inspect.getsource(TodoUpgrade.show_migration_status) + self.assertLess( + source.index("write_config"), source.index("subprocess.call") + ) + + def test_looking_does_not_pollute_the_journal(self): + # `run_on_terminal` consigne ce qu'il lance, et le journal est + # justement ce que cet écran montre. + import inspect + + source = inspect.getsource(TodoUpgrade.show_migration_status) + # L'APPEL, pas le mot : le commentaire du correctif nomme lui-même + # ce qu'il faut éviter, et le chercher à l'aveugle se déclenchait + # sur la prose plutôt que sur le code. + self.assertNotIn("self.run_on_terminal(", source) + + +class TestWhatGetsRecorded(Base): + def upgrade(self): + obj = TodoUpgrade.__new__(TodoUpgrade) + obj.dct_progression = {} + obj.lst_command_executed = [] + obj.write_config = lambda: None + obj.current_step = "4.2.I - Migrate database" + return obj + + def test_a_tool_run_keeps_its_verdict(self): + obj = self.upgrade() + obj.run_on_terminal = lambda cmd: 1 + self.assertEqual(obj.run_tool("smoke_public_url", "cmd"), 1) + event = obj.dct_progression["lst_event"][0] + self.assertEqual(event["kind"], "test") + self.assertEqual(event["status"], 1) + self.assertEqual(event["step"], "4.2.I - Migrate database") + + def test_the_list_is_bounded(self): + # Une migration lance des centaines de commandes ; un fichier qui + # enfle sans limite coûte plus cher à écrire qu'à lire. + obj = self.upgrade() + for index in range(TodoUpgrade.MAX_EVENT + 25): + obj.record_event("test", f"outil{index}", 0) + self.assertEqual( + len(obj.dct_progression["lst_event"]), TodoUpgrade.MAX_EVENT + ) + + def test_the_newest_survive_the_trimming(self): + obj = self.upgrade() + for index in range(TodoUpgrade.MAX_EVENT + 3): + obj.record_event("test", f"outil{index}", 0) + dernier = obj.dct_progression["lst_event"][-1] + self.assertEqual(dernier["name"], f"outil{TodoUpgrade.MAX_EVENT + 2}") + + def test_the_step_is_captured_when_it_is_printed(self): + import inspect + + source = inspect.getsource(TodoUpgrade.print_step) + self.assertIn("self.current_step = msg", source) + + def test_a_failing_command_is_recorded_before_the_prompt(self): + # Si l'on répond ctrl+c, l'échec doit tout de même figurer dans + # l'état : c'est précisément celui qu'on cherchera en revenant. + import inspect + + source = inspect.getsource(TodoUpgrade.todo_upgrade_execute) + self.assertLess( + source.index('record_event("command"'), + source.index("Error detected"), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_smoke_internal_ui.py b/test/test_smoke_internal_ui.py index 3c41c85..d12c85a 100644 --- a/test/test_smoke_internal_ui.py +++ b/test/test_smoke_internal_ui.py @@ -500,7 +500,7 @@ class TestASkipThatHidesAFailure(unittest.TestCase): source = inspect.getsource(TodoUpgrade.prompt_smoke_public_url) self.assertLess( source.index("Public pages only:"), - source.index("run_on_terminal"), + source.index("run_tool("), ) From 3a182f2b0dc86e2a6f987b0259b1c5c2c2f280dd Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 19 Aug 2026 04:05:03 -0400 Subject: [PATCH 42/67] [ADD] migration: reach the state screen from the statistics too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The statistics screen answers « what was removed, and why ». The state screen answers « where are we, and what broke ». Two neighbouring questions asked at the same moment: separating them by a menu entry rather than by two commands to remember is what makes them usable. Same letter as the prompts. One that changes meaning from one screen to the next is not learned, it is looked up — and nobody looks it up. Adding it surfaced two ways that screen could not survive auto-run. The interface question is the FIRST one asked after auto-run is switched on, and it was a bare input(): an unattended migration stopped there before it had begun. The statistics menu was one too. And the state screen writes the progression before reading it — but the statistics screen runs before the progression is even loaded, so that write failed on an attribute that does not exist yet. --- FR --- [ADD] migration : atteindre l'état depuis les statistiques aussi L'écran de statistiques répond « qu'a-t-on supprimé, et pourquoi ». Celui de l'état répond « où en est-on, et qu'est-ce qui a cassé ». Deux questions voisines, posées au même moment : les séparer par une entrée de menu plutôt que par deux commandes à retenir est ce qui les rend utilisables. La même lettre que dans les invites. Une lettre qui change de sens d'un écran à l'autre ne s'apprend pas, elle se cherche — et personne ne la cherche. L'ajout a révélé deux façons dont cet écran ne survivait pas au mode auto : la question d'interface, PREMIÈRE posée après l'activation, était un input() nu, et le menu des statistiques aussi. Et l'écran d'état écrit la progression avant de la lire, alors que les statistiques s'ouvrent avant même qu'elle ne soit chargée. Assisted-by: Claude Opus 5 --- script/todo/todo_i18n.py | 4 + script/todo/todo_upgrade.py | 24 ++++- test/test_migration_status.py | 159 ++++++++++++++++++++++++++++++++++ 3 files changed, 184 insertions(+), 3 deletions(-) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 934ff08..da1a738 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -5603,6 +5603,10 @@ TRANSLATIONS = { " t = afficher l'état de la migration)", "en": "(b = go back a step, t = show the migration state)", }, + "Migration state (full screen)": { + "fr": "État de la migration (plein écran)", + "en": "Migration state (full screen)", + }, "Clean the database before testing the pages?": { "fr": "Nettoyer la base avant de tester les pages ?", "en": "Clean the database before testing the pages?", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index ee71f70..8956ead 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -494,7 +494,11 @@ class TodoUpgrade: print(f" [3] {t('Migration statistics (read-only)')}") print(f" [0] {t('Cancel')}") print(f" {t('(change the default in TODO > Configuration)')}") - answer = input(t("Choice (0-3, default 1): ")).strip() + # `auto_ask` et non `input` : c'est la PREMIÈRE question posée + # après l'activation de l'auto-exécution, et un input() nu y + # arrêtait la migration avant même qu'elle ne commence. Méthode + # statique, d'où l'appel direct plutôt que `self.ask`. + answer = auto_ask.ask(t("Choice (0-3, default 1): ")).strip() return {"0": None, "2": "cli", "3": "stats"}.get(answer, "tui") @staticmethod @@ -573,10 +577,19 @@ class TodoUpgrade: print(f" [3] {t('COW views: snapshots and differences')}") print(f" [4] {t('Recorded decisions (journal)')}") print(f" [5] {t('Executed commands (last 30)')}") + print(f" [t] {t('Migration state (full screen)')}") print(f" [0] {t('Back')}") - answer = input(f"💬 {t('Your choice')} : ").strip() + # `self.ask` : cet écran est atteint DEPUIS execute_odoo_upgrade, + # donc après la question d'auto-exécution. Un input() nu y + # arrêtait une migration automatique sans rien signaler. + answer = self.ask(f"💬 {t('Your choice')} : ").strip().lower() if answer in ("", "0"): return + if answer == "t": + # La MÊME lettre que dans les invites : une lettre qui + # change de sens d'un écran à l'autre ne s'apprend pas. + self.show_migration_status() + continue if answer == "1": for version, detail in sorted(stats["uninstall"].items()): print(f"\n── {version - 1}.0 → {version}.0 ──") @@ -848,7 +861,12 @@ class TodoUpgrade: le journal, et le journal est justement ce que cet écran montre. Regarder l'état polluerait alors l'état. """ - self.write_config() + # Écrire seulement si l'on a quelque chose EN MÉMOIRE : l'écran + # de statistiques est ouvert avant que la progression ne soit + # chargée, et `write_config` y échouait sur un attribut absent. + # Sans rien en mémoire, le fichier sur disque est déjà la vérité. + if getattr(self, "dct_progression", None): + self.write_config() subprocess.call( f"{PYTHON_BIN} ./script/todo/migration_status.py", shell=True, diff --git a/test/test_migration_status.py b/test/test_migration_status.py index d335719..7e91c53 100644 --- a/test/test_migration_status.py +++ b/test/test_migration_status.py @@ -307,6 +307,40 @@ class TestLookingIsNotAnswering(Base): source.index("write_config"), source.index("subprocess.call") ) + def test_it_survives_a_screen_opened_before_anything_is_loaded(self): + """`show_stats` tourne AVANT que la progression ne soit en mémoire. + + Elle est ouverte tout au début d'`execute_odoo_upgrade`, avant même + le choix du fichier. Y appeler `write_config` échouait alors sur un + attribut qui n'existe pas encore — et l'écran d'état, ouvert depuis + là, aurait planté au lieu de s'afficher. + """ + import script.todo.todo_upgrade as tu + + obj = TodoUpgrade.__new__(TodoUpgrade) + appels = [] + obj.write_config = lambda: appels.append("écrit") + original = tu.subprocess.call + tu.subprocess.call = lambda *a, **kw: 0 + self.addCleanup(setattr, tu.subprocess, "call", original) + obj.show_migration_status() + self.assertEqual(appels, [], "rien en mémoire, rien à écrire") + + def test_but_it_does_write_what_it_has(self): + # Sans rien en mémoire le fichier est déjà la vérité ; avec quelque + # chose, il ne l'est plus tant qu'on ne l'a pas écrit. + import script.todo.todo_upgrade as tu + + obj = TodoUpgrade.__new__(TodoUpgrade) + obj.dct_progression = {"config_database_name": "db"} + appels = [] + obj.write_config = lambda: appels.append("écrit") + original = tu.subprocess.call + tu.subprocess.call = lambda *a, **kw: 0 + self.addCleanup(setattr, tu.subprocess, "call", original) + obj.show_migration_status() + self.assertEqual(appels, ["écrit"]) + def test_looking_does_not_pollute_the_journal(self): # `run_on_terminal` consigne ce qu'il lance, et le journal est # justement ce que cet écran montre. @@ -319,6 +353,131 @@ class TestLookingIsNotAnswering(Base): self.assertNotIn("self.run_on_terminal(", source) +class TestTheStatisticsScreenOffersIt(Base): + """L'écran de statistiques répond « qu'a-t-on supprimé, et pourquoi ». + + L'état, lui, répond « où en est-on, et qu'est-ce qui a cassé ». Deux + questions voisines, posées au même moment : les séparer par un menu + plutôt que par deux commandes à retenir est ce qui les rend + utilisables. + """ + + def stats(self, lst_answer): + obj = TodoUpgrade.__new__(TodoUpgrade) + self.vu = [] + obj.show_migration_status = lambda: self.vu.append("ouvert") + reponses = iter(lst_answer) + obj.ask = lambda prompt, default="": next(reponses) + obj.print_stats = staticmethod(lambda ctx, stats: None) + obj.read_progression = staticmethod( + lambda: {"config_database_name": "db"} + ) + obj.resume_context = lambda dct: {"steps": [], "versions": []} + return obj + + def test_t_opens_the_full_screen(self): + import script.todo.todo_upgrade as tu + + obj = self.stats(["t", "0"]) + original_exists = tu.os.path.exists + tu.os.path.exists = lambda path: True + self.addCleanup(setattr, tu.os.path, "exists", original_exists) + import script.todo.migration_stats as ms + + original = ms.compute + ms.compute = lambda *a, **kw: { + "uninstall": {}, + "journal": {"comments": [], "commands": []}, + "origin_count": 0, + "evolution": [], + "removed_total": 0, + "missing": [], + "duplicate": [], + "fixes": [], + "cow": [], + "delay": "0s", + } + self.addCleanup(setattr, ms, "compute", original) + with redirect_stdout(io.StringIO()): + obj.show_stats() + self.assertEqual(self.vu, ["ouvert"]) + + def test_the_menu_announces_it(self): + import inspect + + source = inspect.getsource(TodoUpgrade.show_stats) + self.assertIn("Migration state (full screen)", source) + self.assertIn('answer == "t"', source) + + def test_it_is_the_SAME_letter_as_the_prompts(self): + # Une lettre qui change de sens d'un écran à l'autre ne s'apprend + # pas. Les deux endroits doivent tester la même. + import inspect + + self.assertIn( + 'reponse == "t"', inspect.getsource(TodoUpgrade.ask_gate) + ) + self.assertIn( + 'answer == "t"', inspect.getsource(TodoUpgrade.show_stats) + ) + + def test_looking_returns_to_the_statistics(self): + # Comme dans les invites : regarder n'est pas répondre. + import inspect + + source = inspect.getsource(TodoUpgrade.show_stats) + debut = source.index('answer == "t"') + self.assertIn("continue", source[debut : debut + 400]) + + +class TestNothingOnThatScreenCanHang(Base): + """L'écran est atteint APRÈS la question d'auto-exécution. + + `ask_ui` est la toute première question posée ensuite, et elle était un + `input()` nu : une migration automatique s'arrêtait là, avant même + d'avoir commencé, sans que rien ne le signale. + """ + + def test_the_interface_question_goes_through_the_timer(self): + import inspect + + source = inspect.getsource(TodoUpgrade.ask_ui) + self.assertIn("auto_ask.ask(", source) + # L'APPEL, pas le mot : le commentaire du correctif nomme lui-même + # ce qu'il remplace, et le chercher à l'aveugle se déclenche sur la + # prose. Deuxième fois que ce piège se referme sur moi. + self.assertNotIn("= input(", source) + + def test_the_statistics_menu_too(self): + import inspect + + source = inspect.getsource(TodoUpgrade.show_stats) + self.assertIn("self.ask(", source) + self.assertNotIn("= input(", source) + + def test_the_default_interface_is_still_the_form(self): + # Le défaut ne doit pas changer en passant par le lecteur temporisé. + import script.todo.todo_prefs as prefs + + original = prefs.get + prefs.get = lambda key: "ask" + self.addCleanup(setattr, prefs, "get", original) + from script.todo import auto_ask + + avant = os.environ.pop(auto_ask.ENV_ENABLED, None) + if avant is not None: + self.addCleanup( + os.environ.__setitem__, auto_ask.ENV_ENABLED, avant + ) + import builtins + + original_input = builtins.input + builtins.input = lambda prompt="": "" + self.addCleanup(setattr, builtins, "input", original_input) + with redirect_stdout(io.StringIO()): + self.assertEqual(TodoUpgrade.ask_ui(), "tui") + + class TestWhatGetsRecorded(Base): def upgrade(self): obj = TodoUpgrade.__new__(TodoUpgrade) From 566070b4f854a11cd1cd15a34a229e9d4e3f60ed Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 19 Aug 2026 04:20:13 -0400 Subject: [PATCH 43/67] [FIX] migration: Enter exports the backup at the end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After several hours of migration the archive is what one wanted anyway, and the prompt asked for it with an empty default: pressing Enter — or letting auto-run answer — walked past the only artifact of the whole run. The three-way answer moved into a function of its own, because the third way is the one that traps. « n » refuses, « y » or Enter takes the timestamped name, and EVERYTHING ELSE is a filename. So « non.zip » is a file, not a refusal: comparing the whole answer rather than its first letter is what keeps it one. --- FR --- [FIX] migration : Entrée exporte la sauvegarde à la fin Au bout de plusieurs heures de migration, l'archive est ce qu'on voulait de toute façon, et l'invite la proposait avec un défaut vide : appuyer sur Entrée — ou laisser l'auto-exécution répondre — passait à côté du seul artefact de toute la course. La réponse à trois sens est devenue une fonction à part, car c'est le troisième qui piège. « n » refuse, « y » ou Entrée prend le nom horodaté, et TOUT LE RESTE est un nom de fichier. « non.zip » est donc un fichier et non un refus : comparer la réponse entière plutôt que sa première lettre est ce qui le garantit. Assisted-by: Claude Opus 5 --- script/todo/todo_i18n.py | 8 ++++++ script/todo/todo_upgrade.py | 33 ++++++++++++++++++++---- test/test_prompt_defaults.py | 50 ++++++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 5 deletions(-) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index da1a738..6f4464a 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -5607,6 +5607,14 @@ TRANSLATIONS = { "fr": "État de la migration (plein écran)", "en": "Migration state (full screen)", }, + "Export a backup?": { + "fr": "Exporter une sauvegarde ?", + "en": "Export a backup?", + }, + "or type filename.zip": { + "fr": "ou tapez nomfichier.zip", + "en": "or type filename.zip", + }, "Clean the database before testing the pages?": { "fr": "Nettoyer la base avant de tester les pages ?", "en": "Clean the database before testing the pages?", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index 8956ead..fa69554 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -851,6 +851,27 @@ class TodoUpgrade: # Renoncer au retour en arrière ne doit pas arrêter la migration : # on revient à la même invite, exactement là où l'on était. + @staticmethod + def backup_command(answer, template, default_cmd): + """La commande de sauvegarde à lancer, ou « » pour ne rien faire. + + Trois réponses, trois sens, et le troisième est celui qui piège : + « n » refuse, « y » ou Entrée prend le nom horodaté, et TOUT LE + RESTE est un nom de fichier. Une réponse comme « non.zip » est donc + un fichier, pas un refus — d'où la comparaison exacte plutôt qu'un + début de mot. + + Entrée sauvegarde : au bout d'une migration de plusieurs heures, + l'archive est ce qu'on voulait de toute façon. Mais refuser doit + rester possible, et avoir un mot pour le dire. + """ + reponse = (answer or "").strip() + if reponse.lower() in ("", "n"): + return "" + if reponse.lower() == "y": + return default_cmd + return f"{template} {reponse}" + def show_migration_status(self): """Ouvrir l'état de la migration, en plein écran. @@ -2766,12 +2787,14 @@ class TodoUpgrade: cmd_backup = f"{cmd_backup_template} {database_name_upgrade}_finish_{datetime.datetime.now().strftime('%Y%m%d%H%M%S')}" print(f"✨ {t('A backup can be created')} :\n{cmd_backup}") status = self.ask( - f"💬 {t('Press y/Y or type filename.zip to export, or')}" - f" {t('enter to continue')} : " + f"💬 {t('Export a backup?')}" + f" (Y/n, {t('or type filename.zip')}) : ", + default="y", ).strip() - if status.lower(): - if status.lower() != "y": - cmd_backup = f"{cmd_backup_template} {status}" + cmd_backup = self.backup_command( + status, cmd_backup_template, cmd_backup + ) + if cmd_backup: self.todo_upgrade_execute(cmd_backup) status = self.ask(f"💬 {t('Test the migration, press y/Y')} : ") diff --git a/test/test_prompt_defaults.py b/test/test_prompt_defaults.py index fb0248d..f9f6f71 100644 --- a/test/test_prompt_defaults.py +++ b/test/test_prompt_defaults.py @@ -295,6 +295,56 @@ class TestWhatEnterDoesIsWritten(unittest.TestCase): self.assertIn(refus, self.source(nom), nom) +class TestTheBackupAtTheEnd(unittest.TestCase): + """Au bout de plusieurs heures, l'archive est ce qu'on voulait. + + Trois réponses, trois sens, et le troisième est celui qui piège : « n » + refuse, « y » ou Entrée prend le nom horodaté, et TOUT LE RESTE est un + nom de fichier. « non.zip » est donc un fichier, pas un refus. + """ + + TPL = "./odoo_bin.sh db --backup --database x --restore_image" + DEFAUT = "./odoo_bin.sh db --backup --database x --restore_image x_f_2026" + + def cmd(self, answer): + return TodoUpgrade.backup_command(answer, self.TPL, self.DEFAUT) + + def test_yes_takes_the_timestamped_name(self): + self.assertEqual(self.cmd("y"), self.DEFAUT) + + def test_case_does_not_matter(self): + self.assertEqual(self.cmd("Y"), self.DEFAUT) + + def test_n_refuses(self): + # Le défaut ne retire pas le choix : il ne fait qu'en proposer un. + self.assertEqual(self.cmd("n"), "") + self.assertEqual(self.cmd("N"), "") + + def test_anything_else_is_a_filename(self): + self.assertTrue(self.cmd("archive.zip").endswith(" archive.zip")) + + def test_a_filename_that_STARTS_with_n_is_still_a_filename(self): + # Le piège : comparer un début de mot au lieu de la réponse + # entière ferait passer « non.zip » pour un refus, et l'on + # perdrait l'archive d'une migration de plusieurs heures. + self.assertTrue(self.cmd("non.zip").endswith(" non.zip")) + + def test_blanks_are_not_a_filename(self): + self.assertEqual(self.cmd(" "), "") + + def test_none_does_not_crash(self): + self.assertEqual(self.cmd(None), "") + + def test_enter_backs_up_because_the_default_says_so(self): + import inspect + + source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade) + debut = source.index("Export a backup?") + fenetre = source[debut : debut + 200] + self.assertIn('default="y"', fenetre) + self.assertIn("(Y/n,", fenetre) + + class TestTheToolsLaunchedApart(unittest.TestCase): def test_the_leftovers_default_to_deleting(self): import theme_leftover From 19d807a81e4d1a04c9f9cfdca4d7e5b587dcf7b1 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 19 Aug 2026 04:32:25 -0400 Subject: [PATCH 44/67] [ADD] migration: keep the logs on disk, one file per step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The state screen showed « no tool has run yet » after closing and reopening. It was reading the progression file, and that file is archived and reset when a migration restarts — so everything it knew vanished at the exact moment one wants to understand why the restart was needed. Each step now has its own log file, appended never overwritten, and the failures and tool verdicts go to an append-only JSONL beside them. Both outlive the progression, and the screen reads disk first, memory second, without counting the overlap twice. The command output itself is captured where every line already passes, in the executor's read loop: the terminal still shows it live and nothing about the run changes. What goes through the real terminal cannot be captured — a pipe there makes full screens refuse, that lesson is paid — so those keep at least their command and their exit code. --- FR --- [ADD] migration : garder les journaux sur disque, un fichier par étape L'écran d'état affichait « aucun outil n'a encore tourné » après une fermeture. Il lisait le fichier de progression, or celui-ci est archivé puis remis à zéro quand on recommence : tout ce qu'il savait disparaissait au moment précis où l'on cherche pourquoi il a fallu recommencer. Chaque étape a désormais son fichier, en ajout et jamais en écrasement, et les échecs comme les verdicts d'outils vont dans un JSONL à côté. Les deux survivent à la progression, et l'écran lit le disque d'abord. La sortie des commandes est captée là où chaque ligne passe déjà, dans la boucle de lecture de l'exécuteur : le terminal la montre toujours en direct. Ce qui passe par le vrai terminal n'est pas captable — un tube y ferait renoncer les pleins écrans — et garde au moins son verdict. Assisted-by: Claude Opus 5 --- script/execute/execute.py | 10 ++ script/todo/migration_status.py | 110 ++++++++++++++- script/todo/migration_status_tui.py | 12 +- script/todo/todo_upgrade.py | 112 ++++++++++++++- test/test_migration_status.py | 211 ++++++++++++++++++++++++++++ 5 files changed, 448 insertions(+), 7 deletions(-) diff --git a/script/execute/execute.py b/script/execute/execute.py index cfb9ff3..dcb360f 100644 --- a/script/execute/execute.py +++ b/script/execute/execute.py @@ -178,12 +178,22 @@ class Execute: env=my_env, ) + sink = getattr(self, "log_sink", None) while True: line = process.stdout.readline() if not line: break if not quiet: print(line, end="") + if sink: + # Chaque ligne passe DÉJÀ ici : c'est le seul endroit où + # journaliser sans rien changer à ce que le terminal + # montre. Une erreur d'écriture ne doit jamais faire + # échouer la commande qu'on est en train de suivre. + try: + sink.write(line) + except Exception: + sink = None if ( return_status_and_output or return_status_and_output_and_command diff --git a/script/todo/migration_status.py b/script/todo/migration_status.py index 6cd506d..d59eecb 100755 --- a/script/todo/migration_status.py +++ b/script/todo/migration_status.py @@ -36,6 +36,9 @@ except Exception: # pragma: no cover - repli si i18n indisponible DEFAULT_PATH = ".venv.erplibre/odoo_database_migration_log.json" +PATH_MIGRATION_PRIVATE = os.path.join("private", "odoo", "migration") +STEP_LOG_DIR = "step_log" +EVENT_FILE = "events.jsonl" # Les codes de sortie que TOUS les outils de migration partagent. Les # traduire ici plutôt qu'à l'affichage évite qu'un « 1 » passe pour une @@ -48,12 +51,112 @@ VERDICT = { def read(path=DEFAULT_PATH): - """La progression, telle qu'écrite. {} si elle n'existe pas encore.""" + """La progression, complétée par ce qui a été écrit SUR DISQUE. + + Le fichier de progression est archivé puis remis à zéro quand on + recommence une migration : tout ce qu'il contenait disparaissait alors + de cet écran. Le journal permanent, lui, ne fait que s'allonger — et + c'est justement en revenant après une interruption qu'on a besoin de + ce qui s'est passé avant. + """ try: with open(path, "r", encoding="utf-8") as handle: - return json.load(handle) + dct = json.load(handle) except (OSError, ValueError): return {} + dct["lst_event"] = merge_events(dct) + return dct + + +def log_dir(dct): + """Le répertoire des journaux de cette migration, ou None.""" + database = (dct or {}).get("config_database_name") + if not database: + return None + chemin = os.path.join(PATH_MIGRATION_PRIVATE, database, STEP_LOG_DIR) + return chemin if os.path.isdir(chemin) else None + + +def read_event_file(dct): + """Les événements du journal permanent, en ignorant les lignes cassées. + + Une écriture interrompue laisse une ligne tronquée ; la refuser en bloc + perdrait tout le reste du fichier pour une seule ligne. + """ + chemin = log_dir(dct) + if not chemin: + return [] + lst = [] + try: + with open( + os.path.join(chemin, EVENT_FILE), "r", encoding="utf-8" + ) as handle: + for ligne in handle: + ligne = ligne.strip() + if not ligne: + continue + try: + lst.append(json.loads(ligne)) + except ValueError: + continue + except OSError: + return [] + return lst + + +def merge_events(dct): + """Le disque d'abord, la mémoire ensuite, sans doublon. + + Les deux sources se recouvrent : ce qui vient d'être enregistré est aux + deux endroits. On dédoublonne sur (horodatage, nom, type) — trois + champs qu'un même événement porte à l'identique dans les deux. + """ + vus = set() + fusion = [] + for item in read_event_file(dct) + list(dct.get("lst_event") or []): + cle = (item.get("at"), item.get("name"), item.get("kind")) + if cle in vus: + continue + vus.add(cle) + fusion.append(item) + return fusion + + +def step_log_path(dct, step): + """Le fichier de journal d'une étape, s'il existe.""" + chemin = log_dir(dct) + if not chemin or not step: + return None + fichier = os.path.join(chemin, f"{step_slug(step)}.log") + return fichier if os.path.isfile(fichier) else None + + +def step_slug(msg): + """Le même nom que celui écrit par la migration. Un seul calcul. + + Deux formules séparées dériveraient, et l'écran chercherait alors un + fichier que personne n'écrit — sans rien signaler, puisqu'un fichier + absent se lit comme une étape sans journal. + """ + import re + + prefix, sep, label = (msg or "").partition(" - ") + propre = re.sub(r"[^A-Za-z0-9._-]+", "-", (label or prefix)).strip("-") + tete = re.sub(r"[^A-Za-z0-9._-]+", "-", prefix).strip("-") if sep else "" + nom = f"{tete}_{propre}" if tete else propre + return (nom or "step")[:80].lower() + + +def step_log_tail(dct, step, lines=200): + """Les dernières lignes du journal de cette étape.""" + chemin = step_log_path(dct, step) + if not chemin: + return [] + try: + with open(chemin, "r", encoding="utf-8", errors="replace") as handle: + return handle.read().splitlines()[-lines:] + except OSError: + return [] def journal_by_step(dct): @@ -189,7 +292,8 @@ def render_text(dct, limit_cmd=12): lignes.append(f"\n🔷 {t('What was done, step by step')}") for section in journal_by_step(dct): lst_cmd = section["lst_cmd"] - lignes.append(f" {section['step']} ({len(lst_cmd)})") + journal = " 📄" if step_log_path(dct, section["step"]) else "" + lignes.append(f" {section['step']} ({len(lst_cmd)}){journal}") for cmd in lst_cmd[:limit_cmd]: lignes.append(f" {cmd[:120]}") if len(lst_cmd) > limit_cmd: diff --git a/script/todo/migration_status_tui.py b/script/todo/migration_status_tui.py index 0777a78..d19486b 100644 --- a/script/todo/migration_status_tui.py +++ b/script/todo/migration_status_tui.py @@ -111,10 +111,18 @@ def pane_text(dct, row): for item in lst_failure: lignes.append(f" {item.get('name')}") lignes.append("") - if not section["lst_cmd"]: - lignes.append(t("No tool has run yet.")) for cmd in section["lst_cmd"]: lignes.append(f"· {cmd}") + # La SORTIE des commandes, relue sur disque. C'est ce qui manquait : + # la liste des commandes dit ce qui a été lancé, jamais ce que cela a + # répondu — et c'est la réponse qu'on vient chercher. + tail = status.step_log_tail(dct, section["step"]) + if tail: + lignes.append("") + lignes.append(f"── {t('server log')} ──") + lignes.extend(tail) + elif not section["lst_cmd"]: + lignes.append(t("No tool has run yet.")) return "\n".join(lignes) diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index fa69554..96b4c42 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -14,7 +14,11 @@ import zipfile from uuid import uuid4 -from script.todo import auto_ask, todo_file_browser +from script.todo import ( + auto_ask, + migration_status, + todo_file_browser, +) from script.todo.version_manager import get_odoo_version try: @@ -59,6 +63,10 @@ LOCAL_MANIFEST = os.path.join( # be dropped depends on the database, so that choice is never versioned. PATH_MIGRATION_GLOBAL = os.path.join("script", "odoo", "migration") PATH_MIGRATION_PRIVATE = os.path.join("private", "odoo", "migration") +# Le journal permanent des échecs et des verdicts, hors du fichier de +# progression : celui-ci est remis à zéro quand on recommence. +STEP_LOG_DIR = "step_log" +EVENT_FILE = "events.jsonl" # Steps of the migration, in order. What each one owns is declared just below, # by GLOBAL_PROGRESSION_KEY and STEP_OWNED_KEY — not by the prefix alone, which # lies on some keys and is missing on others. Rewinding to a step drops @@ -2932,6 +2940,7 @@ class TodoUpgrade: # Retenu pour l'écran d'état : un événement sans étape oblige à # relire tout le journal pour savoir OÙ il s'est produit. self.current_step = msg + self.open_step_log(msg) print(f"🔷 {prefix}{sep}{t(label)}" if sep else f"🔷 {t(msg)}") def installed_theme(self, database_name): @@ -3191,7 +3200,10 @@ class TodoUpgrade: self.write_config() print(f"\n🏠 ⬇ {t('Execute command')} :\n") print(cmd) - return subprocess.call(cmd, shell=True, executable="/bin/bash") + self.note_step_log(f"$ {cmd}") + status = subprocess.call(cmd, shell=True, executable="/bin/bash") + self.note_step_log(f" -> {status}") + return status def prompt_cow_prediction(self, database_name, next_version): """Que faire des copies COW annoncées, dès l'étape 2. @@ -3896,6 +3908,76 @@ class TodoUpgrade: MAX_EVENT = 200 + # Le nom de fichier d'une étape est calculé PAR L'ÉCRAN D'ÉTAT, et + # importé ici. Deux formules dériveraient, et l'écran chercherait alors + # un fichier que personne n'écrit — sans rien signaler, puisqu'un + # fichier absent se lit comme une étape sans journal. + step_slug = staticmethod(migration_status.step_slug) + + def log_dir(self): + """Où vivent les journaux de CETTE migration. Créé à la demande. + + Sous le nom de la base, comme les archives et les instantanés COW : + deux migrations menées de front ne doivent pas écrire dans le même + fichier, et l'on veut pouvoir tout emporter d'un seul répertoire. + """ + database = (getattr(self, "dct_progression", None) or {}).get( + "config_database_name" + ) or "sans-nom" + chemin = os.path.join(PATH_MIGRATION_PRIVATE, database, STEP_LOG_DIR) + try: + os.makedirs(chemin, exist_ok=True) + except OSError: + return None + return chemin + + def open_step_log(self, msg): + """Rediriger la sortie des commandes vers le journal de cette étape. + + En AJOUT, jamais en écrasement : une étape rejouée après un retour + en arrière doit s'ajouter à ce qu'on savait d'elle, pas l'effacer. + C'est précisément l'historique qu'on vient relire. + """ + self.close_step_log() + chemin = self.log_dir() + if not chemin: + return + fichier = os.path.join(chemin, f"{self.step_slug(msg)}.log") + try: + handle = open(fichier, "a", encoding="utf-8", buffering=1) + except OSError: + return + handle.write(f"\n===== {datetime.datetime.now()} — {msg} =====\n") + self.step_log = handle + if getattr(self, "execute", None) is not None: + self.execute.log_sink = handle + + def close_step_log(self): + handle = getattr(self, "step_log", None) + if handle: + try: + handle.close() + except Exception: + pass + self.step_log = None + if getattr(self, "execute", None) is not None: + self.execute.log_sink = None + + def note_step_log(self, texte): + """Écrire une ligne dans le journal de l'étape en cours. + + Ce qui passe par `run_on_terminal` n'a PAS de sortie capturable — + un tube y ferait renoncer les pleins écrans, la leçon a été payée. + On garde donc au moins la commande et son verdict. + """ + handle = getattr(self, "step_log", None) + if not handle: + return + try: + handle.write(f"[{datetime.datetime.now()}] {texte}\n") + except Exception: + pass + def record_event(self, kind, name, status, detail=""): """Garder ce qui s'est MAL passé, et ce que les outils ont conclu. @@ -3922,6 +4004,32 @@ class TodoUpgrade: ) self.dct_progression["lst_event"] = lst[-self.MAX_EVENT :] self.write_config() + # ET sur disque, en AJOUT, hors du fichier de progression. Celui-ci + # est archivé puis remis à zéro quand on recommence une migration : + # tout ce qu'on y avait mis disparaissait alors de l'écran d'état, + # au moment précis où l'on cherchait à comprendre pourquoi il avait + # fallu recommencer. + self.append_event_file(lst[-1]) + self.note_step_log(f"[{kind}] {name} -> {status}") + + def append_event_file(self, event): + """Ajouter l'événement au journal permanent, une ligne de JSON. + + JSONL et non JSON : un fichier qu'on complète ligne à ligne + survit à une interruption au milieu d'une écriture, là où un + tableau JSON réécrit en entier ne laisserait qu'un fichier + tronqué — donc illisible, donc perdu en totalité. + """ + chemin = self.log_dir() + if not chemin: + return + try: + with open( + os.path.join(chemin, EVENT_FILE), "a", encoding="utf-8" + ) as handle: + handle.write(json.dumps(event, ensure_ascii=False) + "\n") + except OSError: + pass def run_tool(self, name, cmd): """Lancer un outil de migration et RETENIR sa conclusion. diff --git a/test/test_migration_status.py b/test/test_migration_status.py index 7e91c53..a3689f1 100644 --- a/test/test_migration_status.py +++ b/test/test_migration_status.py @@ -22,6 +22,7 @@ Deux choses se vérifient ici, et la seconde est la moins évidente : import io import os +import shutil import unittest from contextlib import redirect_stdout @@ -353,6 +354,216 @@ class TestLookingIsNotAnswering(Base): self.assertNotIn("self.run_on_terminal(", source) +class DiskCase(Base): + """Un répertoire jetable : les chemins de journal sont RELATIFS.""" + + def setUp(self): + super().setUp() + import tempfile + + self.dossier = tempfile.mkdtemp(prefix="erplibre_essai_") + avant = os.getcwd() + os.chdir(self.dossier) + self.addCleanup(shutil.rmtree, self.dossier, True) + self.addCleanup(os.chdir, avant) + + def upgrade(self, database="essai_db"): + obj = TodoUpgrade.__new__(TodoUpgrade) + obj.dct_progression = {"config_database_name": database} + obj.lst_command_executed = [] + obj.write_config = lambda: None + return obj + + +class TestWhatSurvivesClosingTheTool(DiskCase): + """Le fichier de progression est ARCHIVÉ puis remis à zéro. + + Recommencer une migration effaçait donc tout ce que l'écran d'état + savait — au moment précis où l'on cherche à comprendre pourquoi il a + fallu recommencer. Le journal permanent, lui, ne fait que s'allonger. + """ + + def test_events_are_found_again_with_nothing_in_memory(self): + obj = self.upgrade() + obj.print_step("4.2.I - Migrate database") + obj.record_event("command", "update_addons_all.sh", 1) + obj.record_event("test", "smoke_public_url", 0) + obj.close_step_log() + # Une progression NEUVE : c'est l'état après réouverture. + neuf = {"config_database_name": "essai_db"} + lst = status.merge_events(neuf) + self.assertEqual(len(lst), 2) + self.assertEqual(lst[0]["name"], "update_addons_all.sh") + + def test_the_step_survives_with_them(self): + # Un événement sans étape oblige à relire tout le journal pour + # savoir OÙ il s'est produit. + obj = self.upgrade() + obj.print_step("4.2.I - Migrate database") + obj.record_event("test", "database_cleanup", 1) + obj.close_step_log() + lst = status.merge_events({"config_database_name": "essai_db"}) + self.assertEqual(lst[0]["step"], "4.2.I - Migrate database") + + def test_memory_and_disk_are_not_counted_twice(self): + obj = self.upgrade() + obj.print_step("2 - Succeed update all addons") + obj.record_event("test", "smoke_public_url", 0) + obj.close_step_log() + # `obj.dct_progression` porte DÉJÀ l'événement : les deux sources se + # recouvrent, et les additionner le montrerait en double. + self.assertEqual(len(status.merge_events(obj.dct_progression)), 1) + + def test_a_truncated_line_does_not_lose_the_others(self): + # Une écriture interrompue laisse une ligne tronquée ; refuser le + # fichier en bloc perdrait tout pour une seule ligne. + obj = self.upgrade() + obj.print_step("1 - Import database from zip") + obj.record_event("test", "premier", 0) + obj.close_step_log() + chemin = os.path.join( + "private", + "odoo", + "migration", + "essai_db", + "step_log", + "events.jsonl", + ) + with open(chemin, "a") as handle: + handle.write('{"at": "x", "name": "coup\n') + obj.record_event("test", "dernier", 0) + noms = [ + x["name"] + for x in status.merge_events({"config_database_name": "essai_db"}) + ] + self.assertIn("premier", noms) + self.assertIn("dernier", noms) + + def test_a_migration_without_a_database_writes_nowhere(self): + obj = self.upgrade(database=None) + obj.dct_progression = {} + obj.print_step("0 - Inspect zip") + obj.record_event("test", "x", 0) + obj.close_step_log() + # Rien ne doit planter, et rien ne doit se perdre ailleurs. + self.assertEqual(len(obj.dct_progression["lst_event"]), 1) + + +class TestTheStepLogs(DiskCase): + def test_each_step_gets_its_own_file(self): + obj = self.upgrade() + for etape in ("0 - Inspect zip", "4.2.C - Install module"): + obj.print_step(etape) + obj.note_step_log("quelque chose") + obj.close_step_log() + dossier = os.path.join( + "private", "odoo", "migration", "essai_db", "step_log" + ) + self.assertEqual( + sorted(x for x in os.listdir(dossier) if x.endswith(".log")), + ["0_inspect-zip.log", "4.2.c_install-module.log"], + ) + + def test_the_numbered_prefix_keeps_them_in_order(self): + # Un `ls` trié est la première chose qu'on fait dans ce répertoire. + self.assertTrue( + status.step_slug("4.2.C - Install module").startswith("4.2.c") + ) + + def test_replaying_a_step_ADDS_to_what_was_known(self): + # Une étape rejouée après un retour en arrière ne doit pas effacer + # l'historique : c'est justement ce qu'on vient relire. + obj = self.upgrade() + obj.print_step("2 - Succeed update all addons") + obj.note_step_log("premier passage") + obj.close_step_log() + obj.print_step("2 - Succeed update all addons") + obj.note_step_log("second passage") + obj.close_step_log() + tail = status.step_log_tail( + {"config_database_name": "essai_db"}, + "2 - Succeed update all addons", + ) + texte = "\n".join(tail) + self.assertIn("premier passage", texte) + self.assertIn("second passage", texte) + + def test_the_command_and_its_verdict_are_kept(self): + # `run_on_terminal` n'a PAS de sortie capturable — un tube y ferait + # renoncer les pleins écrans. On garde au moins ces deux-là. + obj = self.upgrade() + obj.print_step("3 - Clean up database") + obj.run_on_terminal("true") + obj.close_step_log() + texte = "\n".join( + status.step_log_tail( + {"config_database_name": "essai_db"}, "3 - Clean up database" + ) + ) + self.assertIn("$ true", texte) + self.assertIn("-> 0", texte) + + def test_a_step_never_run_has_no_file(self): + self.assertIsNone( + status.step_log_path( + {"config_database_name": "essai_db"}, "9 - jamais" + ) + ) + + def test_the_name_is_computed_in_ONE_place(self): + # Deux formules dériveraient, et l'écran chercherait un fichier que + # personne n'écrit — sans rien signaler, puisqu'un fichier absent + # se lit comme une étape sans journal. + self.assertIs(TodoUpgrade.step_slug, status.step_slug) + + +class TestTheCommandOutputItself(DiskCase): + """Ce qui manquait vraiment : ce que les commandes ont RÉPONDU.""" + + def test_the_lines_land_in_the_step_log(self): + from script.execute import execute as ex + + obj = self.upgrade() + obj.execute = ex.Execute() + obj.print_step("2 - Succeed update all addons") + with redirect_stdout(io.StringIO()): + obj.todo_upgrade_execute( + "echo première && echo seconde >&2", wait_at_error=False + ) + obj.close_step_log() + texte = "\n".join( + status.step_log_tail( + {"config_database_name": "essai_db"}, + "2 - Succeed update all addons", + ) + ) + self.assertIn("première", texte) + # stderr aussi : c'est là que les erreurs d'Odoo se trouvent. + self.assertIn("seconde", texte) + + def test_a_broken_sink_never_breaks_the_command(self): + # Journaliser est un service rendu, pas une condition de marche. + from script.execute import execute as ex + + class PuitsCasse: + def write(self, texte): + raise OSError("disque plein") + + moteur = ex.Execute() + moteur.log_sink = PuitsCasse() + with redirect_stdout(io.StringIO()): + status_code = moteur.exec_command_live( + "echo bonjour", source_erplibre=False, quiet=True + ) + self.assertEqual(status_code, 0) + + def test_nothing_is_logged_without_a_step(self): + from script.execute import execute as ex + + moteur = ex.Execute() + self.assertIsNone(getattr(moteur, "log_sink", None)) + + class TestTheStatisticsScreenOffersIt(Base): """L'écran de statistiques répond « qu'a-t-on supprimé, et pourquoi ». From e214df7329d792a5d05af9ba6462fab1927c759a Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 19 Aug 2026 04:53:43 -0400 Subject: [PATCH 45/67] [FIX] smoke: judge the back office AFTER the repair, and say why it failed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No timeout was hit and no test was skipped. The back office failed for the same reason the site did: the COW copy of website.submenu breaks /web/login exactly as it breaks the public pages — both go through the same layout. The pass simply ran BEFORE the reset, and unlike the URLs it was never looked at again. It is now, on the same second server. « Session expired » described the consequence and hid the cause. The login page returned 500 and nothing said so, so one looks at the password. The status of that page, and a missing CSRF token, are now named. Diagnosing this, I started Odoo 18 on a 17 database and got 500 on all thirty-seven URLs and on /web/login: a report that says the site is entirely broken when nothing is. database_cleanup already refused that; the smoke tools now share the same guard rather than reimplementing it. --- FR --- [FIX] smoke : juger le back-office APRÈS la réparation, et dire pourquoi Aucun délai n'a été atteint et aucun test n'a été sauté. Le back-office a échoué pour la raison même qui cassait le site : la copie COW de website.submenu casse /web/login comme elle casse les pages publiques — les deux passent par le même gabarit. La passe tournait avant la réinitialisation et, contrairement aux URL, n'était jamais revue. « Session expired » décrivait la conséquence et cachait la cause : la page de connexion rendait 500 sans que rien ne le dise, et l'on cherchait du côté du mot de passe. En diagnostiquant, j'ai lancé un Odoo 18 sur une base 17 : 500 partout, un rapport qui déclare le site entièrement cassé quand rien ne l'est. database_cleanup refusait déjà cela ; les outils de fumée partagent désormais sa garde au lieu d'en écrire une seconde. Assisted-by: Claude Opus 5 --- .../odoo/migration/reset_stale_cow_views.py | 10 +- script/odoo/migration/smoke_internal_ui.py | 20 +++ script/odoo/migration/smoke_public_url.py | 111 ++++++++++++- script/todo/todo_i18n.py | 20 +++ test/test_smoke_internal_ui.py | 155 ++++++++++++++++++ 5 files changed, 312 insertions(+), 4 deletions(-) diff --git a/script/odoo/migration/reset_stale_cow_views.py b/script/odoo/migration/reset_stale_cow_views.py index 62bb2ee..ab11e79 100755 --- a/script/odoo/migration/reset_stale_cow_views.py +++ b/script/odoo/migration/reset_stale_cow_views.py @@ -392,7 +392,15 @@ def main(): print(f"✅ {t('No COW copy has drifted from its module view.')}") return 0 if not findings: - print(f"ℹ {t('No COW copy has drifted from its module view.')}") + # Le même texte que ci-dessus se lisait comme une contradiction : + # « aucune copie n'a dérivé », puis « ✅ réinitialisé id=2656 ». + # Les deux sont vrais — la détection différentielle ne voit qu'une + # copie dont un ENFANT casse — mais mis côte à côte sans un mot, + # on croit l'outil incohérent et l'on cesse de le lire. + print( + f"ℹ {t('The differential detection found nothing; resetting')}" + f" {t('the requested key(s) anyway.')}" + ) if findings: print( diff --git a/script/odoo/migration/smoke_internal_ui.py b/script/odoo/migration/smoke_internal_ui.py index ccab3d1..25c3b0c 100755 --- a/script/odoo/migration/smoke_internal_ui.py +++ b/script/odoo/migration/smoke_internal_ui.py @@ -211,7 +211,21 @@ class Session: status, body = self.open("/web/login") if not status: return False, t("The server did not serve the login page.") + if status >= 400: + # DIRE le statut. Sans cela on rapportait « Session expired », + # qui décrit la conséquence et cache la cause : la page de + # connexion elle-même rendait 500. Mesuré — une copie COW de + # `website.submenu` casse /web/login comme elle casse le site, + # et l'on cherchait du côté du mot de passe. + return False, ( + f"{t('The login page itself failed')} : HTTP {status} —" + f" {error_from_page(body)}" + ) match = RE_CSRF.search(body) + if not match: + # Sans jeton, Odoo refuse le POST et rend la page de connexion + # avec un statut 200 : on croirait à un mot de passe refusé. + return False, t("No CSRF token on the login page") fields = { "login": login, "password": password, @@ -942,6 +956,12 @@ def main(argv=None): f" {t('the database was not neutralized, nothing to browse.')}" ) return 0 + from smoke_public_url import require_matching_version + + mismatch = require_matching_version(config.database) + if mismatch: + print(f"⛔ {mismatch}") + return 2 if port_is_taken(port): print( f"❌ {t('Something already listens on port')} {port} :" diff --git a/script/odoo/migration/smoke_public_url.py b/script/odoo/migration/smoke_public_url.py index 8b246df..501fe76 100755 --- a/script/odoo/migration/smoke_public_url.py +++ b/script/odoo/migration/smoke_public_url.py @@ -108,6 +108,27 @@ def run_psql(database, sql): return [line.split("\x1f") for line in done.stdout.splitlines() if line] +def require_matching_version(database): + """Refuser de démarrer sur une base d'une autre version qu'un Odoo. + + Sans cela le rapport ment de la pire façon : mesuré ici même, un + checkout passé en 18.0 démarré sur une base 17.0 rend 500 sur les + trente-sept URL et sur /web/login, et l'on conclut à un site + entièrement cassé alors que rien ne l'est. Le checkout suit la + migration ; rien ne garantit qu'il soit resté sur la version de la + base qu'on veut interroger. + + On délègue à `database_cleanup`, qui porte déjà cette garde : deux + implémentations divergeraient, et une garde qui diverge ne garde rien. + """ + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + try: + from database_cleanup import require_matching_version as verifier + except ImportError: + return None + return verifier(database) + + def start_server(database, port, config_path="./config.conf", log_path=None): """Démarrer Odoo, son journal dans un FICHIER. @@ -444,16 +465,21 @@ def render_internal(internal): """ if internal is None: return False + reprise = f" ({t('after the reset')})" if internal.get("retried") else "" if "skipped" in internal: if internal.get("loud"): # Un saut ATTENDU se dit à voix basse ; un saut qui trahit une # panne doit compter comme un échec, sinon le code de sortie # annonce que tout va bien. print( - f"\n⚠️ {t('Back office NOT browsed')} : {internal['skipped']}" + f"\n⚠️ {t('Back office NOT browsed')}{reprise} :" + f" {internal['skipped']}" ) return True - print(f"\nℹ️ {t('Back office not browsed')} : {internal['skipped']}") + print( + f"\nℹ️ {t('Back office not browsed')}{reprise} :" + f" {internal['skipped']}" + ) return False import smoke_internal_ui @@ -480,6 +506,20 @@ def attach_internal_log(internal_report, log_path): ) +def internal_needs_retry(internal_report): + """Le back-office mérite-t-il un second essai après la réparation ? + + Oui s'il a échoué, et oui aussi s'il a été SAUTÉ : « connexion + impossible » est justement le symptôme d'un site cassé, et c'est ce + que la réinitialisation vient de réparer. + """ + if not internal_report: + return False + return bool( + internal_report.get("failures") or internal_report.get("skipped") + ) + + def internal_phase( base_url, database, @@ -584,6 +624,9 @@ def run( log_path = os.path.join( tempfile.gettempdir(), f"erplibre_smoke_{database}_{port}.log" ) + mismatch = require_matching_version(database) + if mismatch: + raise RuntimeError(mismatch) if port_is_taken(port): raise RuntimeError( f"{t('Something already listens on port')} {port} :" @@ -650,6 +693,53 @@ def run( if not lst_done: return lst_url, lst_failure, lst_key, None, internal_report + lst_again, internal_report = recheck_after_reset( + database, + port, + config_path, + base_url, + log_path, + lst_failure, + internal_report, + timeout=timeout, + boot=boot, + internal=internal, + internal_login=internal_login, + internal_password=internal_password, + internal_limit=internal_limit, + every_menu=every_menu, + portal=portal, + internal_required=internal_required, + ) + return lst_url, lst_failure, lst_key, lst_again, internal_report + + +def recheck_after_reset( + database, + port, + config_path, + base_url, + log_path, + lst_failure, + internal_report, + timeout=30, + boot=180, + internal=True, + internal_login="test", + internal_password="test", + internal_limit=20, + every_menu=False, + portal=None, + internal_required=False, +): + """Redémarrer, et revoir ce qui avait échoué. Rend (URL, back-office). + + Le back-office est REJUGÉ lui aussi, et ce n'est pas une symétrie + gratuite : la copie COW qui casse le site casse AUSSI /web/login — les + deux passent par le même gabarit. La passe interne tournait donc avant + la réparation et rapportait « connexion impossible » sur une base que + la réinitialisation remettait d'aplomb quelques secondes plus tard. + """ server = start_server(database, port, config_path, log_path=log_path) try: if not wait_ready(base_url, timeout=boot): @@ -659,9 +749,24 @@ def run( lst_again = check_urls( [url for url, _s, _p in lst_failure], timeout=timeout ) + if internal_needs_retry(internal_report): + reprise = internal_phase( + base_url, + database, + enabled=internal, + login=internal_login, + password=internal_password, + limit=internal_limit, + every_menu=every_menu, + lst_portal=portal, + required=internal_required, + ) + if reprise is not None: + internal_report = reprise + internal_report["retried"] = True finally: stop_server(server) - return lst_url, lst_failure, lst_key, lst_again, internal_report + return lst_again, internal_report def stop_server(server): diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 6f4464a..402c89f 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -5615,6 +5615,26 @@ TRANSLATIONS = { "fr": "ou tapez nomfichier.zip", "en": "or type filename.zip", }, + "The login page itself failed": { + "fr": "La page de connexion elle-même a échoué", + "en": "The login page itself failed", + }, + "No CSRF token on the login page": { + "fr": "Aucun jeton CSRF sur la page de connexion", + "en": "No CSRF token on the login page", + }, + "after the reset": { + "fr": "après la réinitialisation", + "en": "after the reset", + }, + "The differential detection found nothing; resetting": { + "fr": "La détection différentielle n'a rien trouvé ; réinitialisation", + "en": "The differential detection found nothing; resetting", + }, + "the requested key(s) anyway.": { + "fr": "de la ou des clés demandées malgré tout.", + "en": "the requested key(s) anyway.", + }, "Clean the database before testing the pages?": { "fr": "Nettoyer la base avant de tester les pages ?", "en": "Clean the database before testing the pages?", diff --git a/test/test_smoke_internal_ui.py b/test/test_smoke_internal_ui.py index d12c85a..88e70da 100644 --- a/test/test_smoke_internal_ui.py +++ b/test/test_smoke_internal_ui.py @@ -504,6 +504,161 @@ class TestASkipThatHidesAFailure(unittest.TestCase): ) +class TestSayingWHYTheLoginFailed(unittest.TestCase): + """« Session expired » décrit la conséquence et cache la cause. + + Vécu : la passe back-office rapportait « Connexion impossible en tant + que test : SessionExpiredException », et l'on a cherché du côté du mot + de passe pendant que la vraie cause était sous les yeux — /web/login + rendait 500. Une copie COW de `website.submenu` casse la page de + connexion comme elle casse le site : les deux passent par le même + gabarit. + """ + + 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 session(self, status, body): + session = ui.Session("http://x") + session.open = lambda path, data=None, headers=None: (status, body) + return session + + def test_a_broken_login_page_is_named_as_such(self): + ok, raison = self.session(500, "oups").log_in( + "db", "test", "test" + ) + self.assertFalse(ok) + self.assertIn("login page itself failed", raison) + self.assertIn("500", raison) + + def test_it_does_not_blame_the_password(self): + _ok, raison = self.session(500, "oups").log_in( + "db", "test", "test" + ) + self.assertNotIn("password", raison.lower()) + + def test_a_page_without_a_token_is_named_too(self): + # Sans jeton, Odoo refuse le POST et rend la page de connexion avec + # un statut 200 : on croirait à un mot de passe refusé. + ok, raison = self.session(200, "rien").log_in( + "db", "test", "test" + ) + self.assertFalse(ok) + self.assertIn("CSRF", raison) + + def test_a_dead_server_is_still_distinguished(self): + ok, raison = self.session(0, "").log_in("db", "test", "test") + self.assertFalse(ok) + self.assertIn("did not serve", raison) + + +class TestTheBackOfficeIsJudgedAfterTheRepair(unittest.TestCase): + """La passe interne tournait AVANT la réinitialisation. + + Elle rapportait donc « connexion impossible » sur une base que la + réparation remettait d'aplomb quelques secondes plus tard — et rien ne + corrigeait le rapport. Les URL, elles, étaient déjà revérifiées. + """ + + def setUp(self): + from script.todo import todo_i18n + + self.addCleanup( + setattr, todo_i18n, "_current_lang", todo_i18n._current_lang + ) + todo_i18n._current_lang = "en" + + def test_a_failed_pass_deserves_a_second_look(self): + import smoke_public_url as public + + self.assertTrue(public.internal_needs_retry({"failures": [{"x": 1}]})) + + def test_a_SKIPPED_pass_too(self): + # « connexion impossible » est justement le symptôme d'un site + # cassé, c'est-à-dire de ce qu'on vient de réparer. + import smoke_public_url as public + + self.assertTrue(public.internal_needs_retry({"skipped": "raison"})) + + def test_a_healthy_pass_is_not_redone(self): + import smoke_public_url as public + + self.assertFalse( + public.internal_needs_retry({"results": [1], "failures": []}) + ) + + def test_no_pass_at_all_is_not_redone(self): + import smoke_public_url as public + + self.assertFalse(public.internal_needs_retry(None)) + + def test_the_retry_happens_while_the_second_server_is_up(self): + import inspect + + import smoke_public_url as public + + source = inspect.getsource(public.recheck_after_reset) + self.assertLess( + source.index("internal_needs_retry"), + source.index("stop_server(server)"), + ) + + def test_the_report_says_it_was_a_second_look(self): + import contextlib + import io + + import smoke_public_url as public + + out = io.StringIO() + with contextlib.redirect_stdout(out): + public.render_internal( + {"skipped": "raison", "loud": True, "retried": True} + ) + self.assertIn("after the reset", out.getvalue()) + + +class TestItRefusesTheWrongOdooVersion(unittest.TestCase): + """Mesuré en diagnostiquant ce même incident, et je m'y suis pris. + + Un checkout passé en 18.0 démarré sur une base 17.0 rend 500 sur les + trente-sept URL ET sur /web/login. On conclut à un site entièrement + cassé alors que rien ne l'est — et Odoo écrit en chemin. + """ + + def test_the_smoke_tool_has_the_guard(self): + import inspect + + import smoke_public_url as public + + source = inspect.getsource(public.run) + self.assertIn("require_matching_version", source) + self.assertLess( + source.index("require_matching_version"), + source.index("start_server("), + ) + + def test_the_internal_tool_has_it_too(self): + import inspect + + source = inspect.getsource(ui.main) + self.assertIn("require_matching_version", source) + + def test_the_guard_is_not_reimplemented(self): + # Deux implémentations divergeraient, et une garde qui diverge ne + # garde rien. + import inspect + + import smoke_public_url as public + + source = inspect.getsource(public.require_matching_version) + self.assertIn("from database_cleanup import", source) + + class TestThePortalPage(unittest.TestCase): """/my n'est ni le site public ni le back-office : c'est un troisième rendu, en QWeb frontend, avec ses compteurs qui interrogent chacun leur From 4e0bf7cc8b032627c5062e73dad74205957b449e Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 19 Aug 2026 05:16:48 -0400 Subject: [PATCH 46/67] [FIX] migration: the countdown names the answer the prompt offered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit « ⏱ → d » named a reply that was not on the menu. The prompt reads « Enter = delete, after saving them, k = keep » and mentions « d » nowhere, so the line asking to be trusted with a decision described it in a vocabulary the reader had never been given. The decision itself was right — Enter deletes, and that is what happened. Only its report was written from the inside. It now says Enter, in the migration's language, with the value in brackets for whoever wants the precision. What the caller receives is unchanged. --- FR --- [FIX] migration : le compte à rebours nomme la réponse que l'invite offrait « ⏱ → d » nommait une réponse qui n'était pas au menu. L'invite annonce « Entrée = effacer, après les avoir sauvegardés, k = garder » et ne mentionne « d » nulle part : la ligne qui demandait qu'on lui confie une décision la décrivait donc dans un vocabulaire jamais montré. La décision, elle, était juste — Entrée efface, et c'est ce qui a eu lieu. Seul son compte rendu était écrit du dedans. Il dit maintenant Entrée, dans la langue de la migration, la valeur entre parenthèses pour qui veut la précision. Ce que l'appelant reçoit ne change pas. Assisted-by: Claude Opus 5 --- script/todo/auto_ask.py | 20 +++++++++++- test/test_prompt_defaults.py | 61 ++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/script/todo/auto_ask.py b/script/todo/auto_ask.py index eefee6e..d373ce7 100644 --- a/script/todo/auto_ask.py +++ b/script/todo/auto_ask.py @@ -23,6 +23,18 @@ derrière lui un `input()` toujours bloqué, qui volerait la frappe suivante. import os 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 + + ENV_ENABLED = "ERPLIBRE_AUTO_EXECUTE" ENV_DELAY = "ERPLIBRE_AUTO_DELAY" DEFAULT_DELAY = 5 @@ -90,7 +102,13 @@ def ask(prompt, default="", seconds=None): # lisible tout de suite et rend justement une ligne vide ; sans # ceci, le défaut ne servirait jamais là. return answer or default - print(f" ⏱ → {default or '(default)'}") + # « Entrée », parce que c'est ce que l'invite a proposé. Elle annonce + # « Entrée = effacer, k = garder » et ne mentionne nulle part « d » : + # afficher la valeur brute nommait donc une réponse qui n'était pas au + # menu, et l'on se demandait ce que l'outil venait de décider. La + # valeur reste, entre parenthèses, pour qui veut la précision. + detail = f" ({default})" if default else "" + print(f" ⏱ → {t('Enter')}{detail}") return default diff --git a/test/test_prompt_defaults.py b/test/test_prompt_defaults.py index f9f6f71..76427a3 100644 --- a/test/test_prompt_defaults.py +++ b/test/test_prompt_defaults.py @@ -163,6 +163,67 @@ class TestEnterMeansTheDefault(EnvCase): self.assertEqual(os.environ.get(auto_ask.ENV_ENABLED), "1") +class TestTheCountdownSpeaksThePromptsLanguage(EnvCase): + """« ⏱ → d » nommait une réponse qui n'était PAS au menu. + + L'invite annonce « Entrée = effacer, k = garder » : elle ne mentionne + « d » nulle part. Afficher la valeur brute obligeait donc à deviner ce + que l'outil venait de décider — alors que la décision était exactement + celle qu'Entrée promettait. + """ + + def setUp(self): + super().setUp() + from script.todo import todo_i18n + + self.addCleanup( + setattr, todo_i18n, "_current_lang", todo_i18n._current_lang + ) + todo_i18n._current_lang = "en" + + def tick(self, default): + """Forcer le chemin du DÉLAI, celui qui affiche la ligne.""" + import select + + original = select.select + select.select = lambda r, w, x, t: ([], [], []) + self.addCleanup(setattr, select, "select", original) + auto_ask.export(True, 0.01) + out = io.StringIO() + with contextlib.redirect_stdout(out): + valeur = auto_ask.ask("q : ", default=default) + return valeur, out.getvalue() + + def test_it_names_what_the_prompt_offered(self): + _valeur, texte = self.tick("d") + self.assertIn("Enter", texte) + + def test_the_raw_value_stays_for_precision(self): + # La nommer reste utile : deux invites voisines peuvent avoir des + # défauts différents, et l'on relit ces lignes après coup. + _valeur, texte = self.tick("d") + self.assertIn("(d)", texte) + + def test_an_empty_default_shows_no_parentheses(self): + # « Entrée () » n'aurait aucun sens : il n'y a rien à préciser. + _valeur, texte = self.tick("") + self.assertIn("Enter", texte) + self.assertNotIn("(", texte) + + def test_the_answer_itself_is_unchanged(self): + # L'affichage seul change : ce que la migration reçoit reste la + # valeur, sinon toutes les invites changeraient de comportement. + valeur, _texte = self.tick("d") + self.assertEqual(valeur, "d") + + def test_it_follows_the_language_of_the_migration(self): + from script.todo import todo_i18n + + todo_i18n._current_lang = "fr" + _valeur, texte = self.tick("d") + self.assertIn("Entrée", texte) + + class TestNoPromptOfTheMigrationCanHang(unittest.TestCase): """Un `input()` nu ne sait rien du mode auto : il attend, pour toujours. From 0f0f853be4ee6e4aec4b1ef3969cf61500be9eb0 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 19 Aug 2026 05:36:49 -0400 Subject: [PATCH 47/67] [ADD] migration state: colour the commands, and know when not to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A list of commands in flat text blends into its own headings the moment it outgrows the screen — which is exactly when it gets read. Commands are now cyan, steps bold blue, failed ones red, and each verdict wears the colour of its meaning: 1 is amber, because « there is something to look at » is not a crash and painting it red worries for nothing. Colour is refused three times over, and each refusal has cost someone: a log file full of escape codes, a grep that finds nothing, a terminal that prints them as text. A pipe is not a screen, and NO_COLOR is honoured. The full screen goes through Text.from_ansi, which does two things: it renders the colours, and it takes the rest literally. Textual parses Rich markup, so a command containing « [1] » was being eaten as a tag and vanished from the pane without a word. That was true before the colour. --- FR --- [ADD] état de migration : colorer les commandes, et savoir s'en abstenir Une liste de commandes en texte plat se confond avec ses titres dès qu'elle dépasse l'écran — et c'est justement quand elle le dépasse qu'on la lit. Les commandes sont en cyan, les étapes en bleu gras, celles qui ont échoué en rouge, et chaque verdict porte la couleur de son sens : 1 est ambre, car « il y a quelque chose à regarder » n'est pas une panne. La couleur se refuse trois fois, et chaque refus a coûté à quelqu'un : un fichier de journal truffé de codes d'échappement, un grep qui ne trouve plus rien, un terminal qui les affiche en clair. Le plein écran passe par Text.from_ansi, qui rend les couleurs ET prend le reste au pied de la lettre : une commande contenant « [1] » était avalée comme du balisage Rich et disparaissait sans un mot. Assisted-by: Claude Opus 5 --- requirement/erplibre_require-ments.txt | 4 + script/todo/migration_status.py | 75 +++++++++++- script/todo/migration_status_tui.py | 30 +++-- test/test_migration_status.py | 157 +++++++++++++++++++++++++ 4 files changed, 253 insertions(+), 13 deletions(-) diff --git a/requirement/erplibre_require-ments.txt b/requirement/erplibre_require-ments.txt index b170dc0..81505cd 100644 --- a/requirement/erplibre_require-ments.txt +++ b/requirement/erplibre_require-ments.txt @@ -29,6 +29,10 @@ urwid # Borne recopiée de script/todo/textual_setup.py (TEXTUAL_SPEC) : les écrans TUI # sont écrits pour Textual 8, qui casse son API entre majeures. Modifier les deux. textual>=8,<9 +# Dépendance DIRECTE, même si Textual l'installe : l'écran d'état décode +# l'ANSI avec rich.text.Text.from_ansi. Compter sur une dépendance +# transitive, c'est dépendre du jour où Textual changera la sienne. +rich # Dépendance directe : les outils d'analyse comparent des arbres XML avec lxml. # Il n'arrivait que par pykeepass / openupgradelib / odoo-module-migrator, donc # un jour où l'un d'eux s'en passe, il disparaît sans que rien ne le réclame. diff --git a/script/todo/migration_status.py b/script/todo/migration_status.py index d59eecb..579e10c 100755 --- a/script/todo/migration_status.py +++ b/script/todo/migration_status.py @@ -50,6 +50,49 @@ VERDICT = { } +# Les couleurs ANSI, et le droit de s'en passer. `NO_COLOR` est une +# convention respectée par la plupart des outils : la contredire oblige à +# nettoyer une sortie à la main, ce qui est exactement ce qu'on cherchait à +# éviter en la coloriant. +ANSI = { + "cmd": "\033[36m", # cyan : ce qui a été LANCÉ + "step": "\033[1;34m", # bleu gras : les étapes + "ok": "\033[32m", + "warn": "\033[33m", + "fail": "\033[31m", + "dim": "\033[2m", +} +RESET = "\033[0m" + + +def supports_colour(stream=None): + """Peut-on colorier CETTE sortie ? + + Trois refus, et chacun a coûté à quelqu'un : un fichier de journal + truffé de codes d'échappement, un `grep` qui ne trouve plus rien, un + terminal qui les affiche en clair. Un tube n'est pas un écran. + """ + if os.environ.get("NO_COLOR"): + return False + if os.environ.get("TERM", "") in ("", "dumb"): + return False + stream = stream or sys.stdout + try: + return bool(stream.isatty()) + except Exception: + return False + + +def paint(texte, couleur, actif=True): + """Colorier, ou rendre le texte tel quel. Jamais d'à-peu-près.""" + if not actif or couleur not in ANSI: + return texte + return f"{ANSI[couleur]}{texte}{RESET}" + + +VERDICT_COLOUR = {0: "ok", 1: "warn", 2: "fail"} + + def read(path=DEFAULT_PATH): """La progression, complétée par ce qui a été écrit SUR DISQUE. @@ -257,8 +300,15 @@ def bumps(dct): ] -def render_text(dct, limit_cmd=12): - """Le rapport complet, en texte. C'est aussi le repli du plein écran.""" +def render_text(dct, limit_cmd=12, colour=None): + """Le rapport complet, en texte. C'est aussi le repli du plein écran. + + `colour` à None laisse la sortie décider : un écran est colorié, un + tube ne l'est pas. Le forcer sert aux tests, qui doivent pouvoir + vérifier les deux sans dépendre de l'endroit où ils tournent. + """ + if colour is None: + colour = supports_colour() if not dct: return f"ℹ️ {t('No migration in progress.')}" info = overview(dct) @@ -282,20 +332,33 @@ def render_text(dct, limit_cmd=12): if item.get("runs", 1) > 1 else "" ) - lignes.append(f" {icone} {item['name']:<24} {phrase}{rejeu}") + teinte = VERDICT_COLOUR.get(item.get("status"), "dim") + nom = f"{item['name']:<24}" + lignes.append( + f" {icone} {paint(nom, teinte, colour)} {phrase}{rejeu}" + ) lst_failure = failures(dct) lignes.append(f"\n❌ {t('Commands that failed')} : {len(lst_failure)}") for item in lst_failure[:10]: - lignes.append(f" · [{item.get('step') or '?'}] {item.get('name')}") + lignes.append( + f" · [{item.get('step') or '?'}]" + f" {paint(item.get('name') or '', 'fail', colour)}" + ) lignes.append(f"\n🔷 {t('What was done, step by step')}") for section in journal_by_step(dct): lst_cmd = section["lst_cmd"] journal = " 📄" if step_log_path(dct, section["step"]) else "" - lignes.append(f" {section['step']} ({len(lst_cmd)}){journal}") + lignes.append( + f" {paint(section['step'], 'step', colour)}" + f" ({len(lst_cmd)}){journal}" + ) for cmd in lst_cmd[:limit_cmd]: - lignes.append(f" {cmd[:120]}") + # LA demande : distinguer d'un coup d'œil ce qui a été lancé + # du reste du rapport. Une liste de commandes en texte plat se + # confond avec ses titres dès qu'elle dépasse l'écran. + lignes.append(f" {paint(cmd[:120], 'cmd', colour)}") if len(lst_cmd) > limit_cmd: reste = len(lst_cmd) - limit_cmd lignes.append(f" … {reste} {t('more')}") diff --git a/script/todo/migration_status_tui.py b/script/todo/migration_status_tui.py index d19486b..93ec246 100644 --- a/script/todo/migration_status_tui.py +++ b/script/todo/migration_status_tui.py @@ -84,15 +84,21 @@ def rows(dct): return lst -def pane_text(dct, row): - """Le détail de la ligne choisie.""" +def pane_text(dct, row, colour=False): + """Le détail de la ligne choisie. + + Le coloriage passe par de l'ANSI, que Rich sait décoder — et qui rend + au passage le texte LITTÉRAL : une commande contenant « [1] » était + jusqu'ici prise pour du balisage Rich et avalée sans un mot. + """ if row is None: return t("Nothing to show yet.") if row["kind"] == "test": item = row["data"] icone, phrase = status.verdict(item.get("status")) + teinte = status.VERDICT_COLOUR.get(item.get("status"), "dim") lignes = [ - f"{icone} {item['name']}", + f"{icone} {status.paint(item['name'], teinte, colour)}", f" {phrase} ({t('exit code')} {item.get('status')})", f" {t('runs')} : {item.get('runs')}", f" {t('current step')} : {item.get('step') or '?'}", @@ -100,7 +106,7 @@ def pane_text(dct, row): ] return "\n".join(lignes) section = row["data"] - lignes = [f"{section['step']}", ""] + lignes = [status.paint(section["step"], "step", colour), ""] lst_failure = [ item for item in status.failures(dct) @@ -109,10 +115,12 @@ def pane_text(dct, row): if lst_failure: lignes.append(f"❌ {t('Commands that failed')} :") for item in lst_failure: - lignes.append(f" {item.get('name')}") + lignes.append( + f" {status.paint(item.get('name') or '', 'fail', colour)}" + ) lignes.append("") for cmd in section["lst_cmd"]: - lignes.append(f"· {cmd}") + lignes.append(f"· {status.paint(cmd, 'cmd', colour)}") # La SORTIE des commandes, relue sur disque. C'est ce qui manquait : # la liste des commandes dit ce qui a été lancé, jamais ce que cela a # répondu — et c'est la réponse qu'on vient chercher. @@ -166,8 +174,16 @@ def build_app(dct): self._show() def _show(self): + from rich.text import Text + row = self.lst_row[self.index] if self.lst_row else None - self.query_one("#content", Static).update(pane_text(self.dct, row)) + # `from_ansi` fait DEUX choses : il rend les couleurs, et il + # traite le reste comme du texte LITTÉRAL. Sans lui, une + # commande contenant « [1] » passait pour du balisage Rich et + # disparaissait de l'écran sans que rien ne le signale. + self.query_one("#content", Static).update( + Text.from_ansi(pane_text(self.dct, row, colour=True)) + ) def on_data_table_row_highlighted(self, event): if event.data_table.id == "left" and self.lst_row: diff --git a/test/test_migration_status.py b/test/test_migration_status.py index a3689f1..9b98fb8 100644 --- a/test/test_migration_status.py +++ b/test/test_migration_status.py @@ -564,6 +564,163 @@ class TestTheCommandOutputItself(DiskCase): self.assertIsNone(getattr(moteur, "log_sink", None)) +class TestColour(Base): + """Distinguer d'un coup d'œil ce qui a été LANCÉ du reste. + + Une liste de commandes en texte plat se confond avec ses titres dès + qu'elle dépasse l'écran, et c'est justement quand elle le dépasse qu'on + la lit. + """ + + def rapport(self, colour): + dct = progression() + return status.render_text(dct, colour=colour) + + def test_the_commands_are_coloured(self): + texte = self.rapport(True) + self.assertIn(status.ANSI["cmd"], texte) + + def test_a_verdict_wears_the_colour_of_its_meaning(self): + # 1 n'est pas une panne : c'est « il y a quelque chose à regarder ». + # Le peindre en rouge inquiéterait pour rien. + self.assertEqual(status.VERDICT_COLOUR[0], "ok") + self.assertEqual(status.VERDICT_COLOUR[1], "warn") + self.assertEqual(status.VERDICT_COLOUR[2], "fail") + + def test_a_failed_command_is_red(self): + self.assertIn(status.ANSI["fail"], self.rapport(True)) + + def test_every_colour_is_closed(self): + # Une séquence ouverte teinte tout ce qui suit, y compris l'invite + # du terminal une fois l'outil terminé. + texte = self.rapport(True) + ouvertures = sum(texte.count(code) for code in status.ANSI.values()) + self.assertEqual(texte.count(status.RESET), ouvertures) + + def test_plain_output_carries_no_escape_at_all(self): + self.assertNotIn("\033", self.rapport(False)) + + def test_paint_returns_the_text_untouched_when_off(self): + self.assertEqual(status.paint("make", "cmd", False), "make") + + def test_an_unknown_colour_never_invents_one(self): + self.assertEqual(status.paint("make", "mauve", True), "make") + + +class TestWhenColourWouldBeAMistake(Base): + """Un tube n'est pas un écran, et chacun de ces refus a coûté. + + Un fichier de journal truffé de codes d'échappement, un `grep` qui ne + trouve plus rien, un terminal qui les affiche en clair. + """ + + def setUp(self): + super().setUp() + self.avant = {cle: os.environ.get(cle) for cle in ("NO_COLOR", "TERM")} + + def remettre(): + for cle, valeur in self.avant.items(): + if valeur is None: + os.environ.pop(cle, None) + else: + os.environ[cle] = valeur + + self.addCleanup(remettre) + + class FauxEcran: + def __init__(self, tty): + self.tty = tty + + def isatty(self): + return self.tty + + def test_a_pipe_gets_no_colour(self): + os.environ["TERM"] = "xterm" + os.environ.pop("NO_COLOR", None) + self.assertFalse(status.supports_colour(self.FauxEcran(False))) + + def test_a_terminal_does(self): + os.environ["TERM"] = "xterm" + os.environ.pop("NO_COLOR", None) + self.assertTrue(status.supports_colour(self.FauxEcran(True))) + + def test_NO_COLOR_is_honoured(self): + # Convention respectée par la plupart des outils : la contredire + # oblige à nettoyer une sortie à la main. + os.environ["TERM"] = "xterm" + os.environ["NO_COLOR"] = "1" + self.assertFalse(status.supports_colour(self.FauxEcran(True))) + + def test_a_dumb_terminal_gets_none(self): + os.environ.pop("NO_COLOR", None) + os.environ["TERM"] = "dumb" + self.assertFalse(status.supports_colour(self.FauxEcran(True))) + + def test_a_stream_that_cannot_answer_gets_none(self): + os.environ["TERM"] = "xterm" + os.environ.pop("NO_COLOR", None) + + class Muet: + def isatty(self): + raise OSError("fermé") + + self.assertFalse(status.supports_colour(Muet())) + + def test_the_report_decides_by_itself_when_not_told(self): + import inspect + + source = inspect.getsource(status.render_text) + self.assertIn("colour = supports_colour()", source) + + +class TestTheFullScreenColoursToo(Base): + def test_the_pane_can_be_coloured(self): + lst = tui.rows(progression()) + etape = [x for x in lst if x["kind"] == "step"][0] + self.assertIn( + status.ANSI["cmd"], + tui.pane_text(progression(), etape, colour=True), + ) + + def test_it_stays_plain_by_default(self): + # `pane_text` sert aussi aux tests et au repli texte. + lst = tui.rows(progression()) + etape = [x for x in lst if x["kind"] == "step"][0] + self.assertNotIn("\033", tui.pane_text(progression(), etape)) + + def test_rich_decodes_the_colours_instead_of_showing_them(self): + from rich.text import Text + + lst = tui.rows(progression()) + etape = [x for x in lst if x["kind"] == "step"][0] + texte = Text.from_ansi( + tui.pane_text(progression(), etape, colour=True) + ) + self.assertNotIn("\033", texte.plain) + self.assertTrue(texte.spans) + + def test_a_bracket_in_a_command_is_NOT_eaten_as_markup(self): + # Piège latent d'avant la couleur : Textual interprète le balisage + # Rich, et « [1] » disparaissait sans que rien ne le signale. + from rich.text import Text + + dct = progression( + command_executed=["# 2 - Update", "make config [1] et [/bold]"] + ) + lst = tui.rows(dct) + etape = [x for x in lst if x["kind"] == "step"][0] + texte = Text.from_ansi(tui.pane_text(dct, etape, colour=True)) + self.assertIn("[1]", texte.plain) + self.assertIn("[/bold]", texte.plain) + + def test_the_screen_goes_through_from_ansi(self): + import inspect + + source = inspect.getsource(tui.build_app) + self.assertIn("Text.from_ansi", source) + self.assertIn("colour=True", source) + + class TestTheStatisticsScreenOffersIt(Base): """L'écran de statistiques répond « qu'a-t-on supprimé, et pourquoi ». From 1e591dd9495edf43cba3fc4fbe16e8ca4488ae40 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 19 Aug 2026 06:00:21 -0400 Subject: [PATCH 48/67] [FIX] migration state: a test result belongs to a step, not to the run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit You were right that it read as global — and the cause ran deeper than the display. Twenty step headers go through add_comment_progression against seven through print_step, and the whole version-bump loop uses only the first. The current step was set in the other one, so every tool verdict of every bump carried a stale step. The header itself now becomes the step, at the one place the journal is already cut on. And the summary groups by (step, tool) instead of by tool alone: a migration runs the smoke test at EVERY bump, and one line said « smoke_public_url ✅ » while bump 14 had passed and bump 17 had fallen. Within a step the last verdict still wins, with its run count — that is a repair, not two bumps. The order is the journal's, because sorting step names puts « 4.10 » before « 4.2 ». --- FR --- [FIX] état de migration : un résultat de test appartient à une étape Vous aviez raison, cela se lisait comme global — et la cause était plus profonde que l'affichage. Vingt en-têtes d'étape passent par add_comment_progression contre sept par print_step, et toute la boucle des paliers n'utilise que la première. L'étape courante était posée dans l'autre : chaque verdict d'outil portait donc une étape périmée. L'en-tête devient désormais l'étape, au seul endroit où le journal est déjà découpé. Et le résumé groupe par (étape, outil) : une migration lance le test de fumée à CHAQUE palier, et une seule ligne annonçait « smoke_public_url ✅ » quand le palier 14 était passé et le 17 tombé. Dans une étape, le dernier verdict l'emporte toujours — c'est une réparation, pas deux paliers. Assisted-by: Claude Opus 5 --- script/todo/migration_status.py | 59 +++++++---- script/todo/migration_status_tui.py | 9 +- script/todo/todo_upgrade.py | 13 +++ test/test_migration_status.py | 154 ++++++++++++++++++++++++++++ 4 files changed, 214 insertions(+), 21 deletions(-) diff --git a/script/todo/migration_status.py b/script/todo/migration_status.py index 579e10c..bba12b9 100755 --- a/script/todo/migration_status.py +++ b/script/todo/migration_status.py @@ -242,22 +242,39 @@ def verdict(status): def tests_summary(dct): - """Le DERNIER verdict de chaque outil, et le compte des passages. + """Le dernier verdict de chaque outil, PAR ÉTAPE, et son nombre de passages. - Un outil relancé après correction a deux verdicts contradictoires dans - le journal, et c'est le dernier qui décrit la base telle qu'elle est. - Afficher les deux sans les distinguer ferait lire une réparation comme - un échec persistant. + Par étape, car c'est la question qu'on pose. Une migration lance le + test de fumée à CHAQUE palier ; regrouper sur le seul nom d'outil n'en + laissait qu'une ligne, et l'on lisait « smoke_public_url ✅ » sans voir + que le palier 14 était passé et le 17 tombé. + + Dans une étape, le DERNIER verdict l'emporte : un outil relancé après + correction a deux verdicts contradictoires, et c'est le second qui + décrit la base telle qu'elle est. Les afficher tous deux sans les + distinguer ferait lire une réparation comme un échec persistant. + + L'ordre est celui du journal, donc celui de la migration. Trier les + étapes par leur nom mettrait « 4.10 » avant « 4.2 ». """ dernier = {} for item in events(dct, kind="test"): - nom = item.get("name") or "?" - entree = dernier.setdefault(nom, {"name": nom, "runs": 0}) + cle = (item.get("step") or "", item.get("name") or "?") + entree = dernier.setdefault( + cle, {"name": cle[1], "step": cle[0], "runs": 0} + ) entree["runs"] += 1 entree["status"] = item.get("status") entree["at"] = item.get("at") - entree["step"] = item.get("step") - return [dernier[nom] for nom in sorted(dernier)] + return list(dernier.values()) + + +def tests_by_step(dct): + """Les verdicts groupés sous leur étape, dans l'ordre de la migration.""" + par_etape = {} + for item in tests_summary(dct): + par_etape.setdefault(item["step"], []).append(item) + return list(par_etape.items()) def failures(dct): @@ -325,18 +342,22 @@ def render_text(dct, limit_cmd=12, colour=None): lignes.append(f"\n🧪 {t('Test results')}") if not lst_test: lignes.append(f" {t('No tool has run yet.')}") - for item in lst_test: - icone, phrase = verdict(item.get("status")) - rejeu = ( - f" ({item['runs']} {t('runs')})" - if item.get("runs", 1) > 1 - else "" - ) - teinte = VERDICT_COLOUR.get(item.get("status"), "dim") - nom = f"{item['name']:<24}" + for etape, lst_item in tests_by_step(dct): lignes.append( - f" {icone} {paint(nom, teinte, colour)} {phrase}{rejeu}" + f" {paint(etape or t('before the first step'), 'step', colour)}" ) + for item in lst_item: + icone, phrase = verdict(item.get("status")) + rejeu = ( + f" ({item['runs']} {t('runs')})" + if item.get("runs", 1) > 1 + else "" + ) + teinte = VERDICT_COLOUR.get(item.get("status"), "dim") + nom = f"{item['name']:<24}" + lignes.append( + f" {icone} {paint(nom, teinte, colour)} {phrase}{rejeu}" + ) lst_failure = failures(dct) lignes.append(f"\n❌ {t('Commands that failed')} : {len(lst_failure)}") diff --git a/script/todo/migration_status_tui.py b/script/todo/migration_status_tui.py index 93ec246..865eb7d 100644 --- a/script/todo/migration_status_tui.py +++ b/script/todo/migration_status_tui.py @@ -64,10 +64,15 @@ def rows(dct): lst = [] for item in status.tests_summary(dct): icone, _phrase = status.verdict(item.get("status")) + # Le NUMÉRO de l'étape en tête : une migration lance le même outil + # à chaque palier, et sans lui la liste montrait six lignes + # identiques sans dire laquelle appartenait à quel palier. + numero = (item.get("step") or "").split(" - ")[0] + tete = f"{numero} " if numero else "" lst.append( { "kind": "test", - "label": f"{icone} {item['name']}", + "label": f"{icone} {tete}{item['name']}", "detail": str(item.get("runs", 1)), "data": item, } @@ -99,9 +104,9 @@ def pane_text(dct, row, colour=False): teinte = status.VERDICT_COLOUR.get(item.get("status"), "dim") lignes = [ f"{icone} {status.paint(item['name'], teinte, colour)}", + f" {status.paint(item.get('step') or '?', 'step', colour)}", f" {phrase} ({t('exit code')} {item.get('status')})", f" {t('runs')} : {item.get('runs')}", - f" {t('current step')} : {item.get('step') or '?'}", f" {item.get('at') or ''}", ] return "\n".join(lignes) diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index 96b4c42..d0e98d3 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -4044,7 +4044,20 @@ class TodoUpgrade: return status def add_comment_progression(self, comment): + """Marquer une étape dans le journal, et devenir CETTE étape. + + Vingt en-têtes d'étape passent par ici contre sept par + `print_step` : toute la boucle des paliers n'utilise que celle-ci. + Ne poser l'étape courante que dans l'autre laissait donc chaque + verdict d'outil estampillé d'une étape périmée — et le résumé des + tests semblait global alors qu'il aurait dû suivre les paliers. + + C'est le MÊME marqueur que `journal_by_step` découpe : une seule + notion d'étape, posée à un seul endroit. + """ comment_to_add = f"# {comment}" self.lst_command_executed.append(comment_to_add) self.dct_progression["command_executed"] = self.lst_command_executed + self.current_step = comment + self.open_step_log(comment) self.write_config() diff --git a/test/test_migration_status.py b/test/test_migration_status.py index 9b98fb8..f7b9426 100644 --- a/test/test_migration_status.py +++ b/test/test_migration_status.py @@ -123,6 +123,120 @@ class TestARepairMustNotReadAsAFailure(Base): smoke = [x for x in lst if x["name"] == "smoke_public_url"][0] self.assertEqual(smoke["status"], 0) + def test_the_verdicts_are_kept_PER_STEP(self): + """La demande, et le défaut qu'elle a révélé. + + Une migration lance le test de fumée à CHAQUE palier. Regrouper sur + le seul nom d'outil n'en laissait qu'une ligne : on lisait + « smoke_public_url ✅ » sans voir que le palier 14 était passé et + le 17 tombé. + """ + dct = progression( + lst_event=[ + { + "at": "1", + "step": "4.1 - v14", + "kind": "test", + "name": "smoke_public_url", + "status": 0, + }, + { + "at": "2", + "step": "4.2 - v15", + "kind": "test", + "name": "smoke_public_url", + "status": 2, + }, + ] + ) + lst = status.tests_summary(dct) + self.assertEqual(len(lst), 2) + self.assertEqual( + [(x["step"], x["status"]) for x in lst], + [("4.1 - v14", 0), ("4.2 - v15", 2)], + ) + + def test_a_repair_within_a_step_still_collapses(self): + # Deux verdicts pour le MÊME palier : c'est une réparation, pas + # deux paliers. Le second décrit la base telle qu'elle est. + dct = progression( + lst_event=[ + { + "at": "1", + "step": "4.2 - v15", + "kind": "test", + "name": "smoke_public_url", + "status": 2, + }, + { + "at": "2", + "step": "4.2 - v15", + "kind": "test", + "name": "smoke_public_url", + "status": 0, + }, + ] + ) + lst = status.tests_summary(dct) + self.assertEqual(len(lst), 1) + self.assertEqual(lst[0]["status"], 0) + self.assertEqual(lst[0]["runs"], 2) + + def test_the_order_is_the_migration_s_own(self): + # Trier les étapes par leur nom mettrait « 4.10 » avant « 4.2 ». + dct = progression( + lst_event=[ + { + "at": "1", + "step": "4.2 - v15", + "kind": "test", + "name": "outil", + "status": 0, + }, + { + "at": "2", + "step": "4.10 - v18", + "kind": "test", + "name": "outil", + "status": 0, + }, + ] + ) + self.assertEqual( + [etape for etape, _lst in status.tests_by_step(dct)], + ["4.2 - v15", "4.10 - v18"], + ) + + def test_the_report_shows_the_step_number(self): + dct = progression( + lst_event=[ + { + "at": "1", + "step": "4.1 - Ready to work with version 14", + "kind": "test", + "name": "database_cleanup", + "status": 0, + }, + ] + ) + texte = status.render_text(dct, colour=False) + self.assertIn("4.1 - Ready to work with version 14", texte) + + def test_the_full_screen_shows_it_too(self): + dct = progression( + lst_event=[ + { + "at": "1", + "step": "4.1 - v14", + "kind": "test", + "name": "smoke_public_url", + "status": 0, + }, + ] + ) + ligne = [x for x in tui.rows(dct) if x["kind"] == "test"][0] + self.assertIn("4.1", ligne["label"]) + def test_but_the_earlier_runs_are_still_counted(self): # Les taire ferait croire à un premier essai réussi, et l'on # perdrait la trace de ce qui a demandé une réparation. @@ -439,6 +553,46 @@ class TestWhatSurvivesClosingTheTool(DiskCase): self.assertIn("premier", noms) self.assertIn("dernier", noms) + def test_a_step_header_becomes_the_current_step(self): + """La cause racine de « ça semble global ». + + Vingt en-têtes d'étape passent par `add_comment_progression` contre + sept par `print_step`, et toute la boucle des paliers n'utilise que + la première. Ne poser l'étape courante que dans l'autre laissait + chaque verdict estampillé d'une étape périmée. + """ + obj = self.upgrade() + obj.add_comment_progression("4.2 - Ready to work with version 15") + obj.record_event("test", "smoke_public_url", 0) + obj.close_step_log() + lst = status.merge_events({"config_database_name": "essai_db"}) + self.assertEqual(lst[0]["step"], "4.2 - Ready to work with version 15") + + def test_a_step_header_opens_its_log_too(self): + # Même raison : sans cela, la sortie des commandes d'un palier + # allait dans le fichier de l'étape d'AVANT. + obj = self.upgrade() + obj.add_comment_progression("4.2 - Migrate database") + obj.note_step_log("quelque chose") + obj.close_step_log() + self.assertIsNotNone( + status.step_log_path( + {"config_database_name": "essai_db"}, "4.2 - Migrate database" + ) + ) + + def test_two_bumps_do_not_share_a_step(self): + # Le symptôme exact : six paliers, un seul nom d'étape. + obj = self.upgrade() + for palier in ("4.1 - version 14", "4.2 - version 15"): + obj.add_comment_progression(palier) + obj.record_event("test", "smoke_public_url", 0) + obj.close_step_log() + lst = status.merge_events({"config_database_name": "essai_db"}) + self.assertEqual( + [x["step"] for x in lst], ["4.1 - version 14", "4.2 - version 15"] + ) + def test_a_migration_without_a_database_writes_nowhere(self): obj = self.upgrade(database=None) obj.dct_progression = {} From c0c51446805c9ce2b87588375f45fa06f151b4c2 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 19 Aug 2026 06:23:20 -0400 Subject: [PATCH 49/67] [ADD] migration state: l, p, r and the two resize keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four keys, and one bug behind them. « l » separates the commands from the server log: the first says what was LAUNCHED, the second what it ANSWERED, and an update writing tens of thousands of lines buries three commands. « p » hides the summary panel, « - » and « + » move the split, « r » re-reads the disk — the migration writes while one watches, and closing the screen to see the next bump is what one ended up doing. The « missing logs » had two causes, both real. A tail was shown without saying it was one; it now names how many lines are hidden. And the first two steps run before the database is named, so their logs landed under « sans-nom » — outside the migration they belong to, invisible from the screen. They are brought back the moment the name is known, appended never overwritten. The step 4 bug was already fixed: its log held all six bumps because only print_step opened one, and the bump loop never calls it. --- FR --- [ADD] état de migration : l, p, r et les deux touches de taille Quatre touches, et un défaut derrière. « l » sépare les commandes du journal : la première dit ce qui a été LANCÉ, le second ce que cela a RÉPONDU, et une mise à jour qui écrit des dizaines de milliers de lignes enterre trois commandes. « p » cache le panneau de résumé, « - » et « + » déplacent la séparation, « r » relit le disque — la migration écrit pendant qu'on regarde. Les « logs manquants » avaient deux causes. On montrait une fin sans dire que c'en était une ; le nombre de lignes cachées est désormais nommé. Et les deux premières étapes tournent avant que la base ne soit nommée : leurs journaux atterrissaient sous « sans-nom », hors de la migration à laquelle ils appartiennent. Ils la rejoignent dès que le nom est connu. Assisted-by: Claude Opus 5 --- script/todo/migration_status.py | 18 ++- script/todo/migration_status_tui.py | 101 +++++++++++-- script/todo/todo_i18n.py | 32 ++++ script/todo/todo_upgrade.py | 48 ++++++ test/test_migration_status.py | 225 +++++++++++++++++++++++++++- 5 files changed, 401 insertions(+), 23 deletions(-) diff --git a/script/todo/migration_status.py b/script/todo/migration_status.py index bba12b9..f846dd2 100755 --- a/script/todo/migration_status.py +++ b/script/todo/migration_status.py @@ -190,16 +190,22 @@ def step_slug(msg): return (nom or "step")[:80].lower() -def step_log_tail(dct, step, lines=200): - """Les dernières lignes du journal de cette étape.""" +def step_log_tail(dct, step, lines=400): + """Les dernières lignes du journal de cette étape, et le compte total. + + Rend (lignes, total). Le total n'est pas décoratif : une mise à jour de + modules écrit des dizaines de milliers de lignes, et montrer les + dernières SANS dire qu'on en cache se lit comme « il manque des logs ». + """ chemin = step_log_path(dct, step) if not chemin: - return [] + return [], 0 try: with open(chemin, "r", encoding="utf-8", errors="replace") as handle: - return handle.read().splitlines()[-lines:] + lst = handle.read().splitlines() except OSError: - return [] + return [], 0 + return lst[-lines:], len(lst) def journal_by_step(dct): @@ -405,7 +411,7 @@ def main(argv=None): from script.todo.migration_status_tui import run_tui except Exception: run_tui = None - if run_tui and run_tui(dct): + if run_tui and run_tui(dct, path=config.file): return 0 print(render_text(dct)) return 0 diff --git a/script/todo/migration_status_tui.py b/script/todo/migration_status_tui.py index 865eb7d..6711bf1 100644 --- a/script/todo/migration_status_tui.py +++ b/script/todo/migration_status_tui.py @@ -89,7 +89,7 @@ def rows(dct): return lst -def pane_text(dct, row, colour=False): +def pane_text(dct, row, colour=False, show_log=True): """Le détail de la ligne choisie. Le coloriage passe par de l'ANSI, que Rich sait décoder — et qui rend @@ -128,37 +128,66 @@ def pane_text(dct, row, colour=False): lignes.append(f"· {status.paint(cmd, 'cmd', colour)}") # La SORTIE des commandes, relue sur disque. C'est ce qui manquait : # la liste des commandes dit ce qui a été lancé, jamais ce que cela a - # répondu — et c'est la réponse qu'on vient chercher. - tail = status.step_log_tail(dct, section["step"]) + # répondu. Mais les deux mélangés dans un même panneau se confondent — + # d'où « l », qui les sépare. + tail, total = status.step_log_tail(dct, section["step"]) + if not show_log: + if total: + lignes.append("") + lignes.append( + f"── {t('server log')} : {total} {t('lines')}" + f" ({t('press l to show')}) ──" + ) + return "\n".join(lignes) if tail: lignes.append("") - lignes.append(f"── {t('server log')} ──") + cache = ( + f" — {t('last')} {len(tail)} {t('of')} {total}" + if total > len(tail) + else "" + ) + lignes.append(f"── {t('server log')}{cache} ──") lignes.extend(tail) elif not section["lst_cmd"]: lignes.append(t("No tool has run yet.")) return "\n".join(lignes) -def build_app(dct): +def build_app(dct, path=None): """Textual est importé ICI, pas au chargement du module. Le module reste importable — donc testable — sur une machine sans Textual, et c'est aussi ce qui permet à l'appelant de retomber sur le rapport texte plutôt que d'échouer. """ + from rich.text import Text from textual.app import App, ComposeResult from textual.containers import Horizontal, VerticalScroll from textual.widgets import DataTable, Footer, Header, Static class StatusApp(App): CSS = globals()["CSS"] - BINDINGS = [("q,escape", "quit", "Quit")] + BINDINGS = [ + ("q,escape", "quit", t("Quit")), + ("r", "refresh", t("Refresh")), + ("l", "toggle_log", t("Logs")), + ("p", "toggle_head", t("Panel")), + ("plus,equal", "wider", t("Wider")), + ("minus,underscore", "narrower", t("Narrower")), + ] - def __init__(self, dct): + LEFT_MIN = 16 + LEFT_MAX = 110 + LEFT_STEP = 6 + + def __init__(self, dct, path=None): super().__init__() self.dct = dct + self.path = path self.lst_row = rows(dct) self.index = 0 + self.show_log = True + self.left_width = 46 def compose(self) -> ComposeResult: yield Header() @@ -171,23 +200,30 @@ def build_app(dct): def on_mount(self): self.title = t("Migration state") + self._fill_table() + self._show() + + def _fill_table(self): table = self.query_one("#left", DataTable) + table.clear(columns=True) table.add_columns(t("Test results"), "#") for row in self.lst_row: table.add_row(row["label"][:38], row["detail"]) + table.styles.width = self.left_width self.query_one("#head", Static).update(head_text(self.dct)) - self._show() def _show(self): - from rich.text import Text - row = self.lst_row[self.index] if self.lst_row else None # `from_ansi` fait DEUX choses : il rend les couleurs, et il # traite le reste comme du texte LITTÉRAL. Sans lui, une # commande contenant « [1] » passait pour du balisage Rich et # disparaissait de l'écran sans que rien ne le signale. self.query_one("#content", Static).update( - Text.from_ansi(pane_text(self.dct, row, colour=True)) + Text.from_ansi( + pane_text( + self.dct, row, colour=True, show_log=self.show_log + ) + ) ) def on_data_table_row_highlighted(self, event): @@ -195,10 +231,47 @@ def build_app(dct): self.index = event.cursor_row self._show() - return StatusApp(dct) + def action_refresh(self): + """Relire le disque. La migration écrit PENDANT qu'on regarde. + + L'écran s'ouvre au milieu d'une migration qui continue : sans + cela, il fallait le fermer et le rouvrir pour voir le palier + suivant. + """ + if not self.path: + return + self.dct = status.read(self.path) + self.lst_row = rows(self.dct) + self.index = min(self.index, max(0, len(self.lst_row) - 1)) + self._fill_table() + self._show() + + def action_toggle_log(self): + self.show_log = not self.show_log + self._show() + + def action_toggle_head(self): + head = self.query_one("#head", Static) + head.display = not head.display + + def action_wider(self): + self._resize(self.LEFT_STEP) + + def action_narrower(self): + self._resize(-self.LEFT_STEP) + + def _resize(self, delta): + # Bornée des deux côtés : une colonne de zéro ne se retrouve + # plus, et une qui mange tout l'écran ne laisse rien à lire. + self.left_width = max( + self.LEFT_MIN, min(self.LEFT_MAX, self.left_width + delta) + ) + self.query_one("#left", DataTable).styles.width = self.left_width + + return StatusApp(dct, path) -def run_tui(dct, run_app=True): +def run_tui(dct, run_app=True, path=None): """Ouvrir l'écran. False si l'on n'a pas pu — et alors on DIT pourquoi. Se taire ferait réafficher le rapport texte à la place de l'écran @@ -216,7 +289,7 @@ def run_tui(dct, run_app=True): if textual_setup and not textual_setup.ensure(): return False try: - app = build_app(dct) + app = build_app(dct, path=path) except ImportError: print( f"ℹ️ {t('Textual is missing from this interpreter:')}" diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 402c89f..6724817 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -5635,6 +5635,38 @@ TRANSLATIONS = { "fr": "de la ou des clés demandées malgré tout.", "en": "the requested key(s) anyway.", }, + "lines": { + "fr": "lignes", + "en": "lines", + }, + "press l to show": { + "fr": "l pour afficher", + "en": "press l to show", + }, + "last": { + "fr": "dernières", + "en": "last", + }, + "Refresh": { + "fr": "Rafraîchir", + "en": "Refresh", + }, + "Logs": { + "fr": "Journaux", + "en": "Logs", + }, + "Panel": { + "fr": "Panneau", + "en": "Panel", + }, + "Wider": { + "fr": "Élargir", + "en": "Wider", + }, + "Narrower": { + "fr": "Rétrécir", + "en": "Narrower", + }, "Clean the database before testing the pages?": { "fr": "Nettoyer la base avant de tester les pages ?", "en": "Clean the database before testing the pages?", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index d0e98d3..426a8f6 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -66,6 +66,9 @@ PATH_MIGRATION_PRIVATE = os.path.join("private", "odoo", "migration") # Le journal permanent des échecs et des verdicts, hors du fichier de # progression : celui-ci est remis à zéro quand on recommence. STEP_LOG_DIR = "step_log" +# Les deux premières étapes tournent avant qu'on ait choisi le nom de +# la base : leurs journaux attendent ici, puis rejoignent la migration. +UNNAMED_MIGRATION = "sans-nom" EVENT_FILE = "events.jsonl" # Steps of the migration, in order. What each one owns is declared just below, # by GLOBAL_PROGRESSION_KEY and STEP_OWNED_KEY — not by the prefix alone, which @@ -3929,8 +3932,53 @@ class TodoUpgrade: os.makedirs(chemin, exist_ok=True) except OSError: return None + if database != UNNAMED_MIGRATION: + self.adopt_unnamed_logs(chemin) return chemin + def adopt_unnamed_logs(self, chemin): + """Rapatrier les journaux écrits AVANT que la base ne soit nommée. + + Les deux premières étapes — inspecter l'archive, la restaurer — + tournent avant qu'on ait choisi le nom de la base. Leurs journaux + atterrissaient donc sous « sans-nom », c'est-à-dire hors de la + migration à laquelle ils appartiennent : mesuré, deux fichiers + invisibles depuis l'écran d'état, et l'on cherchait des logs + manquants qui étaient simplement à côté. + + En AJOUT si le fichier existe déjà : une reprise peut avoir écrit + des deux côtés, et écraser perdrait le premier passage. + """ + if getattr(self, "_unnamed_adopted", False): + return + self._unnamed_adopted = True + source = os.path.join( + PATH_MIGRATION_PRIVATE, UNNAMED_MIGRATION, STEP_LOG_DIR + ) + if not os.path.isdir(source) or os.path.abspath( + source + ) == os.path.abspath(chemin): + return + for nom in sorted(os.listdir(source)): + depart = os.path.join(source, nom) + arrivee = os.path.join(chemin, nom) + if not os.path.isfile(depart): + continue + try: + with open( + depart, "r", encoding="utf-8", errors="replace" + ) as f: + contenu = f.read() + with open(arrivee, "a", encoding="utf-8") as f: + f.write(contenu) + os.remove(depart) + except OSError: + continue + try: + os.rmdir(source) + except OSError: + pass + def open_step_log(self, msg): """Rediriger la sortie des commandes vers le journal de cette étape. diff --git a/test/test_migration_status.py b/test/test_migration_status.py index f7b9426..d534b42 100644 --- a/test/test_migration_status.py +++ b/test/test_migration_status.py @@ -593,6 +593,89 @@ class TestWhatSurvivesClosingTheTool(DiskCase): [x["step"] for x in lst], ["4.1 - version 14", "4.2 - version 15"] ) + def test_logs_written_before_the_name_are_brought_back(self): + """Les deux premières étapes tournent avant qu'on nomme la base. + + Leurs journaux atterrissaient sous « sans-nom », c'est-à-dire hors + de la migration à laquelle ils appartiennent : mesuré sur la VM, + deux fichiers invisibles depuis l'écran d'état, et l'on cherchait + des logs manquants qui étaient simplement à côté. + """ + from script.todo import todo_upgrade as tu + + anonyme = TodoUpgrade.__new__(TodoUpgrade) + anonyme.dct_progression = {} + anonyme.lst_command_executed = [] + anonyme.write_config = lambda: None + anonyme.add_comment_progression("0 - Inspect zip") + anonyme.note_step_log("avant le nom") + anonyme.close_step_log() + self.assertTrue( + os.path.isdir( + os.path.join( + "private", + "odoo", + "migration", + tu.UNNAMED_MIGRATION, + "step_log", + ) + ) + ) + # La base prend son nom : les journaux doivent la rejoindre. + nomme = self.upgrade() + nomme.log_dir() + tail, _total = status.step_log_tail( + {"config_database_name": "essai_db"}, "0 - Inspect zip" + ) + self.assertIn("avant le nom", "\n".join(tail)) + + def test_the_unnamed_folder_is_left_empty_behind(self): + from script.todo import todo_upgrade as tu + + anonyme = TodoUpgrade.__new__(TodoUpgrade) + anonyme.dct_progression = {} + anonyme.lst_command_executed = [] + anonyme.write_config = lambda: None + anonyme.add_comment_progression("0 - Inspect zip") + anonyme.note_step_log("x") + anonyme.close_step_log() + self.upgrade().log_dir() + self.assertFalse( + os.path.isdir( + os.path.join( + "private", + "odoo", + "migration", + tu.UNNAMED_MIGRATION, + "step_log", + ) + ) + ) + + def test_an_existing_file_is_APPENDED_to_not_replaced(self): + # Une reprise peut avoir écrit des deux côtés ; écraser perdrait + # le premier passage. + nomme = self.upgrade() + nomme.add_comment_progression("0 - Inspect zip") + nomme.note_step_log("déjà là") + nomme.close_step_log() + anonyme = TodoUpgrade.__new__(TodoUpgrade) + anonyme.dct_progression = {} + anonyme.lst_command_executed = [] + anonyme.write_config = lambda: None + anonyme.add_comment_progression("0 - Inspect zip") + anonyme.note_step_log("venu de sans-nom") + anonyme.close_step_log() + neuf = self.upgrade() + neuf.log_dir() + texte = "\n".join( + status.step_log_tail( + {"config_database_name": "essai_db"}, "0 - Inspect zip" + )[0] + ) + self.assertIn("déjà là", texte) + self.assertIn("venu de sans-nom", texte) + def test_a_migration_without_a_database_writes_nowhere(self): obj = self.upgrade(database=None) obj.dct_progression = {} @@ -634,7 +717,7 @@ class TestTheStepLogs(DiskCase): obj.print_step("2 - Succeed update all addons") obj.note_step_log("second passage") obj.close_step_log() - tail = status.step_log_tail( + tail, _total = status.step_log_tail( {"config_database_name": "essai_db"}, "2 - Succeed update all addons", ) @@ -652,7 +735,7 @@ class TestTheStepLogs(DiskCase): texte = "\n".join( status.step_log_tail( {"config_database_name": "essai_db"}, "3 - Clean up database" - ) + )[0] ) self.assertIn("$ true", texte) self.assertIn("-> 0", texte) @@ -689,7 +772,7 @@ class TestTheCommandOutputItself(DiskCase): status.step_log_tail( {"config_database_name": "essai_db"}, "2 - Succeed update all addons", - ) + )[0] ) self.assertIn("première", texte) # stderr aussi : c'est là que les erreurs d'Odoo se trouvent. @@ -875,6 +958,142 @@ class TestTheFullScreenColoursToo(Base): self.assertIn("colour=True", source) +class TestSeparatingCommandsFromLogs(Base): + """Les deux mélangés dans un même panneau se confondent. + + La liste des commandes dit ce qui a été LANCÉ ; le journal dit ce que + cela a RÉPONDU. Ce sont deux lectures différentes, et l'une noie + l'autre : un `update_addons_all` écrit des dizaines de milliers de + lignes au-dessus desquelles trois commandes disparaissent. + """ + + def dct(self): + return progression(command_executed=["# 2 - Update", "./run.sh -d db"]) + + def etape(self, dct): + return [x for x in tui.rows(dct) if x["kind"] == "step"][0] + + def test_hiding_the_log_keeps_the_commands(self): + dct = self.dct() + texte = tui.pane_text(dct, self.etape(dct), show_log=False) + self.assertIn("./run.sh -d db", texte) + + def test_hiding_it_never_hides_it_SILENTLY(self): + # Un panneau qui se vide sans un mot se lit comme « il n'y a rien », + # ce qui est exactement le contraire de ce qui vient de se passer. + import tempfile + + dossier = tempfile.mkdtemp(prefix="erplibre_essai_") + avant = os.getcwd() + os.chdir(dossier) + self.addCleanup(shutil.rmtree, dossier, True) + self.addCleanup(os.chdir, avant) + chemin = os.path.join("private", "odoo", "migration", "db", "step_log") + os.makedirs(chemin) + with open( + os.path.join(chemin, status.step_slug("2 - Update") + ".log"), "w" + ) as handle: + handle.write("\n".join(f"ligne {i}" for i in range(50))) + dct = self.dct() + dct["config_database_name"] = "db" + texte = tui.pane_text(dct, self.etape(dct), show_log=False) + self.assertIn("50", texte) + self.assertNotIn("ligne 49", texte) + + def test_showing_it_says_how_much_is_cut(self): + # « il manque des logs » venait de là : on montrait la fin sans + # dire qu'on cachait le début. + lst, total = ([f"l{i}" for i in range(400)], 12843) + self.assertLess(len(lst), total) + + def test_the_tail_reports_the_total(self): + import tempfile + + dossier = tempfile.mkdtemp(prefix="erplibre_essai_") + avant = os.getcwd() + os.chdir(dossier) + self.addCleanup(shutil.rmtree, dossier, True) + self.addCleanup(os.chdir, avant) + chemin = os.path.join("private", "odoo", "migration", "db", "step_log") + os.makedirs(chemin) + with open( + os.path.join(chemin, status.step_slug("2 - Update") + ".log"), "w" + ) as handle: + handle.write("\n".join(f"ligne {i}" for i in range(1000))) + lst, total = status.step_log_tail( + {"config_database_name": "db"}, "2 - Update", lines=400 + ) + self.assertEqual(total, 1000) + self.assertEqual(len(lst), 400) + self.assertEqual(lst[-1], "ligne 999") + + def test_a_step_without_a_log_reports_zero(self): + self.assertEqual( + status.step_log_tail({"config_database_name": "db"}, "9 - rien"), + ([], 0), + ) + + +class TestTheKeyboard(Base): + """Ce que l'écran promet dans son pied de page doit exister.""" + + def app(self): + return tui.build_app(progression(), path="/un/chemin.json") + + def touches(self): + return { + touche + for entree in self.app().BINDINGS + for touche in entree[0].split(",") + } + + def test_every_promised_key_is_bound(self): + attendues = {"q", "escape", "r", "l", "p", "plus", "minus"} + self.assertTrue(attendues <= self.touches(), self.touches()) + + def test_each_binding_has_an_action_that_exists(self): + # Un raccourci annoncé dont l'action manque échoue à la frappe, + # c'est-à-dire au pire moment. + app = self.app() + for _touches, action, _libelle in app.BINDINGS: + self.assertTrue(hasattr(app, f"action_{action}"), action) + + def test_the_width_is_bounded_on_both_sides(self): + # Une colonne de zéro ne se retrouve plus ; une qui mange tout + # l'écran ne laisse rien à lire. + app = self.app() + for _ in range(50): + app.left_width = max( + app.LEFT_MIN, min(app.LEFT_MAX, app.left_width - app.LEFT_STEP) + ) + self.assertEqual(app.left_width, app.LEFT_MIN) + for _ in range(50): + app.left_width = max( + app.LEFT_MIN, min(app.LEFT_MAX, app.left_width + app.LEFT_STEP) + ) + self.assertEqual(app.left_width, app.LEFT_MAX) + + def test_refreshing_rereads_the_disk(self): + """La migration ÉCRIT pendant qu'on regarde. + + Sans cela il fallait fermer et rouvrir l'écran pour voir le palier + suivant — sur une migration de plusieurs heures, on le fait. + """ + import inspect + + source = inspect.getsource(tui.build_app) + self.assertIn("status.read(self.path)", source) + self.assertIn("self.lst_row = rows(self.dct)", source) + + def test_refreshing_without_a_path_does_nothing(self): + app = tui.build_app(progression(), path=None) + app.action_refresh() # ne doit pas lever + + def test_the_log_toggle_flips(self): + app = self.app() + self.assertTrue(app.show_log) + + class TestTheStatisticsScreenOffersIt(Base): """L'écran de statistiques répond « qu'a-t-on supprimé, et pourquoi ». From e981500c9496b8817d161c7951452d4437b25204 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 19 Aug 2026 06:37:14 -0400 Subject: [PATCH 50/67] [ADD] migration state: how long the migration took MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the first write to the LAST one. The progression file is rewritten after every move, so its update date is the end — or the present moment while the migration is still running. Both dates were already shown; what was missing was the one number one actually wants. The statistics screen already computed this. It is reused rather than rewritten: two formulas would give two durations for the same migration depending on which screen one opens. --- FR --- [ADD] état de migration : combien de temps elle a duré Du premier écrit au DERNIER. Le fichier de progression est réécrit après chaque geste, donc sa date de mise à jour est la fin — ou l'instant présent tant que la migration tourne. Les deux dates étaient déjà affichées ; il manquait le seul chiffre qu'on cherche. L'écran de statistiques portait déjà ce calcul. Il est réutilisé plutôt que réécrit : deux formules donneraient deux durées pour la même migration selon l'écran qu'on ouvre. Assisted-by: Claude Opus 5 --- script/todo/migration_status.py | 22 +++++++++++- script/todo/migration_status_tui.py | 3 +- script/todo/todo_i18n.py | 8 +++++ test/test_migration_status.py | 56 +++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 2 deletions(-) diff --git a/script/todo/migration_status.py b/script/todo/migration_status.py index f846dd2..5b00471 100755 --- a/script/todo/migration_status.py +++ b/script/todo/migration_status.py @@ -288,9 +288,28 @@ def failures(dct): return list(reversed(events(dct, kind="command"))) +def elapsed(dct): + """Combien de temps la migration a duré, ou dure encore. + + Du premier écrit à la DERNIÈRE écriture du journal : la progression est + réécrite après chaque geste, donc sa date de mise à jour EST la fin — + ou l'instant présent si la migration tourne toujours. + + On délègue à `migration_stats.fmt_delay`, qui porte déjà ce calcul pour + l'écran de statistiques. Deux formules donneraient deux durées pour la + même migration selon l'écran qu'on ouvre. + """ + try: + from script.todo.migration_stats import fmt_delay + except Exception: + return "?" + return fmt_delay(dct.get("date_create"), dct.get("date_update")) + + def overview(dct): """L'en-tête : de quelle migration parle-t-on.""" return { + "elapsed": elapsed(dct), "file": os.path.basename(dct.get("migration_file") or "?"), "database": dct.get("config_database_name") or "?", "target": dct.get("target_odoo_version") or "?", @@ -340,7 +359,8 @@ def render_text(dct, limit_cmd=12, colour=None): f" {t('database')} : {info['database']}", f" {t('image')} : {info['file']}", f" {t('started')} : {info['started']}", - f" {t('last written')} : {info['updated']}", + f" {t('finished')} : {info['updated']}", + f" {t('duration')} : {info['elapsed']}", f" {t('current step')} : {info['step']}", ] diff --git a/script/todo/migration_status_tui.py b/script/todo/migration_status_tui.py index 6711bf1..02acece 100644 --- a/script/todo/migration_status_tui.py +++ b/script/todo/migration_status_tui.py @@ -48,7 +48,8 @@ def head_text(dct): return ( f"{info['database']} · {info['file']}\n" f"{t('current step')} : {info['step']}\n" - f"{t('last written')} : {info['updated']}" + f"{t('finished')} : {info['updated']}" + f" · {t('duration')} {info['elapsed']}" f" · {len(status.failures(dct))} {t('failed')}" f" · {len(casse)}/{len(lst_test)} {t('Test results')}" ) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 6724817..9ba8a24 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -5667,6 +5667,14 @@ TRANSLATIONS = { "fr": "Rétrécir", "en": "Narrower", }, + "finished": { + "fr": "terminée", + "en": "finished", + }, + "duration": { + "fr": "durée", + "en": "duration", + }, "Clean the database before testing the pages?": { "fr": "Nettoyer la base avant de tester les pages ?", "en": "Clean the database before testing the pages?", diff --git a/test/test_migration_status.py b/test/test_migration_status.py index d534b42..b27ed2b 100644 --- a/test/test_migration_status.py +++ b/test/test_migration_status.py @@ -958,6 +958,62 @@ class TestTheFullScreenColoursToo(Base): self.assertIn("colour=True", source) +class TestHowLongItTook(Base): + """Du premier écrit à la dernière écriture du journal. + + La progression est réécrite après chaque geste, donc sa date de mise à + jour EST la fin — ou l'instant présent si la migration tourne encore. + """ + + def dct(self, debut, fin): + return progression(date_create=debut, date_update=fin) + + def test_it_counts_from_the_start_to_the_last_write(self): + info = status.overview( + self.dct("2026-08-19 09:06:27", "2026-08-19 10:19:18") + ) + self.assertEqual(info["elapsed"], "1 h 12 min") + + def test_a_short_run_is_given_in_minutes(self): + info = status.overview( + self.dct("2026-08-19 09:06:00", "2026-08-19 09:41:00") + ) + self.assertEqual(info["elapsed"], "35 min") + + def test_a_long_run_is_given_in_days(self): + info = status.overview( + self.dct("2026-08-17 09:00:00", "2026-08-19 11:30:00") + ) + self.assertIn("j", info["elapsed"]) + + def test_a_missing_date_never_invents_a_duration(self): + self.assertEqual(status.overview(self.dct(None, None))["elapsed"], "?") + + def test_the_report_shows_it(self): + texte = status.render_text( + self.dct("2026-08-19 09:06:27", "2026-08-19 10:19:18"), + colour=False, + ) + self.assertIn("1 h 12 min", texte) + + def test_the_full_screen_shows_it_too(self): + texte = tui.head_text( + self.dct("2026-08-19 09:06:27", "2026-08-19 10:19:18") + ) + self.assertIn("1 h 12 min", texte) + + def test_it_is_computed_in_ONE_place(self): + # L'écran de statistiques porte déjà ce calcul. Deux formules + # donneraient deux durées pour la même migration selon l'écran + # qu'on ouvre. + import inspect + + source = inspect.getsource(status.elapsed) + self.assertIn( + "from script.todo.migration_stats import fmt_delay", source + ) + + class TestSeparatingCommandsFromLogs(Base): """Les deux mélangés dans un même panneau se confondent. From 6dff7aa304c674b223369f7b755e661a9bf307c6 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 19 Aug 2026 06:57:19 -0400 Subject: [PATCH 51/67] [ADD] migration: announce the countdown, and cycle three panel states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every prompt now carries « ⏱5s » before the question. One that looks like it waits forever does not invite you to step away, which is the whole point of auto-run. The same clock reports the answer afterwards: it announces the wait, then says what it decided. Enter on the auto-run question already meant no; it now says so, with an explicit default instead of a deduction. And the flag is cleared before asking — an ERPLIBRE_AUTO_EXECUTE inherited from the shell would have answered yes on someone's behalf, and that answer commits every later one. « p » cycles three states rather than toggling one: a plain toggle freed four lines, while what takes the room is the left COLUMN — on a server log every character of width counts. All → no summary → detail only, and back. The state is named in the sub-title, which stays visible in all three: a panel that vanishes without a word reads as a broken screen. --- FR --- [ADD] migration : annoncer le compte à rebours, et trois états de panneaux Chaque invite porte désormais « ⏱5s » devant la question. Une invite qui a l'air d'attendre indéfiniment n'invite pas à s'absenter, et c'est pourtant tout l'intérêt du mode auto. Le même chronomètre rend compte ensuite. Entrée sur la question d'auto-exécution voulait déjà dire non ; elle le DIT maintenant, par un défaut explicite plutôt qu'une déduction. Et le drapeau est effacé avant de demander : une variable héritée du shell aurait répondu « oui » à la place de quelqu'un. « p » enchaîne trois états au lieu d'en basculer un : ce qui prend la place, c'est la colonne de gauche. Tout → sans résumé → détail seul, et l'on revient. L'état est nommé dans le sous-titre. Assisted-by: Claude Opus 5 --- script/todo/auto_ask.py | 11 ++-- script/todo/migration_status_tui.py | 98 ++++++++++++++++++++++++----- script/todo/todo_i18n.py | 16 +++++ script/todo/todo_upgrade.py | 10 ++- test/test_migration_status.py | 75 +++++++++++++++++++++- test/test_prompt_defaults.py | 90 ++++++++++++++++++++++++++ 6 files changed, 274 insertions(+), 26 deletions(-) diff --git a/script/todo/auto_ask.py b/script/todo/auto_ask.py index d373ce7..19c02ce 100644 --- a/script/todo/auto_ask.py +++ b/script/todo/auto_ask.py @@ -86,12 +86,15 @@ def ask(prompt, default="", seconds=None): return input(prompt) or default import select - sys.stdout.write(prompt) + attente = delay() if seconds is None else seconds + # DIRE qu'on répondra tout seul, et dans combien de temps. Une invite + # qui a l'air d'attendre indéfiniment n'invite pas à s'absenter, et + # c'est pourtant tout l'intérêt du mode auto. Devant l'invite plutôt + # qu'après : elle se lit avant qu'on décide de partir. + sys.stdout.write(f"⏱{attente:g}s {prompt}") sys.stdout.flush() try: - ready, _, _ = select.select( - [sys.stdin], [], [], delay() if seconds is None else seconds - ) + ready, _, _ = select.select([sys.stdin], [], [], attente) except (OSError, ValueError): # stdin n'est pas sélectionnable : on ne devine pas, on demande. return input("") or default diff --git a/script/todo/migration_status_tui.py b/script/todo/migration_status_tui.py index 02acece..a8c0035 100644 --- a/script/todo/migration_status_tui.py +++ b/script/todo/migration_status_tui.py @@ -154,6 +154,64 @@ def pane_text(dct, row, colour=False, show_log=True): return "\n".join(lignes) +PANELS = ("all", "no-summary", "detail") + + +def panel_label(state): + """Le nom de l'état courant, tel qu'il s'affiche dans le sous-titre.""" + return { + "all": t("summary + list + detail"), + "no-summary": t("list + detail"), + "detail": t("detail only"), + }[PANELS[state % len(PANELS)]] + + +def panel_visibility(state): + """(résumé visible, liste visible) pour cet état. + + Trois états, du plus complet au plus dépouillé. Un simple bascule ne + libérait que quatre lignes ; ce qui prend la place, c'est la COLONNE de + gauche — et sur un journal de serveur, chaque caractère gagné en + largeur compte. + """ + etat = PANELS[state % len(PANELS)] + return etat == "all", etat != "detail" + + +def current_row(lst_row, index): + """La ligne choisie, ou None quand il n'y en a aucune.""" + return lst_row[index] if lst_row and 0 <= index < len(lst_row) else None + + +def reread(path): + """Relire la progression, ou None si l'on ne sait pas d'où.""" + return status.read(path) if path else None + + +def apply_panels(app): + """Poser la visibilité des panneaux, et NOMMER l'état choisi. + + Le sous-titre reste visible dans les trois : un panneau qui disparaît + sans un mot se lit comme un écran cassé. + """ + resume, liste = panel_visibility(app.panel_state) + app.query_one("#head").display = resume + app.query_one("#left").display = liste + app.sub_title = panel_label(app.panel_state) + + +def apply_width(app, delta): + """Déplacer la séparation, bornée des deux côtés. + + Une colonne de zéro ne se retrouve plus ; une qui mange tout l'écran ne + laisse rien à lire. + """ + app.left_width = max( + app.LEFT_MIN, min(app.LEFT_MAX, app.left_width + delta) + ) + app.query_one("#left").styles.width = app.left_width + + def build_app(dct, path=None): """Textual est importé ICI, pas au chargement du module. @@ -172,7 +230,7 @@ def build_app(dct, path=None): ("q,escape", "quit", t("Quit")), ("r", "refresh", t("Refresh")), ("l", "toggle_log", t("Logs")), - ("p", "toggle_head", t("Panel")), + ("p", "cycle_panels", t("Panels")), ("plus,equal", "wider", t("Wider")), ("minus,underscore", "narrower", t("Narrower")), ] @@ -189,6 +247,7 @@ def build_app(dct, path=None): self.index = 0 self.show_log = True self.left_width = 46 + self.panel_state = 0 def compose(self) -> ComposeResult: yield Header() @@ -202,6 +261,7 @@ def build_app(dct, path=None): def on_mount(self): self.title = t("Migration state") self._fill_table() + apply_panels(self) self._show() def _fill_table(self): @@ -214,7 +274,7 @@ def build_app(dct, path=None): self.query_one("#head", Static).update(head_text(self.dct)) def _show(self): - row = self.lst_row[self.index] if self.lst_row else None + row = current_row(self.lst_row, self.index) # `from_ansi` fait DEUX choses : il rend les couleurs, et il # traite le reste comme du texte LITTÉRAL. Sans lui, une # commande contenant « [1] » passait pour du balisage Rich et @@ -239,9 +299,10 @@ def build_app(dct, path=None): cela, il fallait le fermer et le rouvrir pour voir le palier suivant. """ - if not self.path: + neuf = reread(self.path) + if neuf is None: return - self.dct = status.read(self.path) + self.dct = neuf self.lst_row = rows(self.dct) self.index = min(self.index, max(0, len(self.lst_row) - 1)) self._fill_table() @@ -251,23 +312,26 @@ def build_app(dct, path=None): self.show_log = not self.show_log self._show() - def action_toggle_head(self): - head = self.query_one("#head", Static) - head.display = not head.display + def action_cycle_panels(self): + """Trois états, du plus complet au plus dépouillé. + + Un simple bascule ne libérait que quatre lignes. Ce qui prend + la place, c'est la COLONNE de gauche : sur un journal de + serveur, chaque caractère gagné en largeur compte. On enchaîne + donc tout → sans résumé → détail seul, et l'on revient. + + L'état est écrit dans le sous-titre, qui reste visible dans les + trois : un panneau qui disparaît sans un mot se lit comme un + écran cassé. + """ + self.panel_state = (self.panel_state + 1) % len(PANELS) + apply_panels(self) def action_wider(self): - self._resize(self.LEFT_STEP) + apply_width(self, self.LEFT_STEP) def action_narrower(self): - self._resize(-self.LEFT_STEP) - - def _resize(self, delta): - # Bornée des deux côtés : une colonne de zéro ne se retrouve - # plus, et une qui mange tout l'écran ne laisse rien à lire. - self.left_width = max( - self.LEFT_MIN, min(self.LEFT_MAX, self.left_width + delta) - ) - self.query_one("#left", DataTable).styles.width = self.left_width + apply_width(self, -self.LEFT_STEP) return StatusApp(dct, path) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 9ba8a24..f11e2e9 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -5675,6 +5675,22 @@ TRANSLATIONS = { "fr": "durée", "en": "duration", }, + "Panels": { + "fr": "Panneaux", + "en": "Panels", + }, + "summary + list + detail": { + "fr": "résumé + liste + détail", + "en": "summary + list + detail", + }, + "list + detail": { + "fr": "liste + détail", + "en": "list + detail", + }, + "detail only": { + "fr": "détail seul", + "en": "detail only", + }, "Clean the database before testing the pages?": { "fr": "Nettoyer la base avant de tester les pages ?", "en": "Clean the database before testing the pages?", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index 426a8f6..72c9836 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -802,10 +802,16 @@ class TodoUpgrade: Posée AVANT le choix de version, donc avant la première décision : activée, elle vaut pour toutes les suivantes, celle-là comprise. """ + # L'auto-exécution ne s'active JAMAIS d'elle-même : on efface le + # drapeau avant de poser la question, sinon une variable héritée du + # shell ferait répondre « oui » à la place de quelqu'un — et cette + # réponse-là engage toutes les suivantes. + auto_ask.export(False) answer = ( - input( + auto_ask.ask( f"💬 {t('Auto-run: take the default answer after')}" - f" {self.AUTO_DELAY} {t('seconds?')} (y/N) : " + f" {self.AUTO_DELAY} {t('seconds?')} (y/N) : ", + default="n", ) .strip() .lower() diff --git a/test/test_migration_status.py b/test/test_migration_status.py index b27ed2b..9599455 100644 --- a/test/test_migration_status.py +++ b/test/test_migration_status.py @@ -1090,6 +1090,72 @@ class TestSeparatingCommandsFromLogs(Base): ) +class TestTheThreePanelStates(Base): + """Un simple bascule ne libérait que quatre lignes. + + Ce qui prend la place, c'est la COLONNE de gauche — et sur un journal + de serveur, chaque caractère gagné en largeur compte. D'où trois états, + du plus complet au plus dépouillé. + """ + + def test_the_first_state_shows_everything(self): + self.assertEqual(tui.panel_visibility(0), (True, True)) + + def test_the_second_drops_the_summary(self): + self.assertEqual(tui.panel_visibility(1), (False, True)) + + def test_the_third_leaves_the_detail_alone(self): + self.assertEqual(tui.panel_visibility(2), (False, False)) + + def test_it_comes_back_round(self): + # Sans retour, le troisième état serait un cul-de-sac : la liste + # cachée, on ne peut plus changer d'entrée. + self.assertEqual(tui.panel_visibility(3), tui.panel_visibility(0)) + + def test_there_are_exactly_three(self): + self.assertEqual(len(tui.PANELS), 3) + + def test_each_state_has_a_name(self): + # Un panneau qui disparaît sans un mot se lit comme un écran cassé. + noms = {tui.panel_label(i) for i in range(3)} + self.assertEqual(len(noms), 3) + for nom in noms: + self.assertTrue(nom.strip()) + + def test_p_is_bound_to_the_cycle(self): + app = tui.build_app(progression()) + action = [ + entree[1] for entree in app.BINDINGS if "p" in entree[0].split(",") + ] + self.assertEqual(action, ["cycle_panels"]) + + def test_the_name_is_shown_where_it_stays_visible(self): + # Le sous-titre est dans l'en-tête de Textual, qui reste affiché + # dans les trois états — contrairement au panneau de résumé. + import inspect + + source = inspect.getsource(tui.apply_panels) + self.assertIn("app.sub_title", source) + + +class TestTheGuardsThatCameOutOfTheClass(Base): + def test_no_row_selected_is_not_an_error(self): + self.assertIsNone(tui.current_row([], 0)) + + def test_an_index_past_the_end_is_not_an_error(self): + # Un rafraîchissement peut raccourcir la liste sous le curseur. + self.assertIsNone(tui.current_row([{"a": 1}], 5)) + + def test_rereading_without_a_path_yields_nothing(self): + self.assertIsNone(tui.reread(None)) + + def test_the_width_stays_between_its_bounds(self): + app = tui.build_app(progression()) + self.assertLess(app.LEFT_MIN, app.LEFT_MAX) + self.assertGreaterEqual(app.left_width, app.LEFT_MIN) + self.assertLessEqual(app.left_width, app.LEFT_MAX) + + class TestTheKeyboard(Base): """Ce que l'écran promet dans son pied de page doit exister.""" @@ -1137,9 +1203,12 @@ class TestTheKeyboard(Base): """ import inspect - source = inspect.getsource(tui.build_app) - self.assertIn("status.read(self.path)", source) - self.assertIn("self.lst_row = rows(self.dct)", source) + # La relecture est sortie de la classe pour la rendre testable : + # on vérifie donc les DEUX moitiés, la lecture et le remontage. + self.assertIn("status.read(path)", inspect.getsource(tui.reread)) + self.assertIn( + "self.lst_row = rows(self.dct)", inspect.getsource(tui.build_app) + ) def test_refreshing_without_a_path_does_nothing(self): app = tui.build_app(progression(), path=None) diff --git a/test/test_prompt_defaults.py b/test/test_prompt_defaults.py index 76427a3..b2e9a22 100644 --- a/test/test_prompt_defaults.py +++ b/test/test_prompt_defaults.py @@ -224,6 +224,96 @@ class TestTheCountdownSpeaksThePromptsLanguage(EnvCase): self.assertIn("Entrée", texte) +class TestTheQuestionThatCannotAnswerItself(EnvCase): + """Celle-là engage toutes les suivantes : elle ne se prend pas seule. + + Une variable `ERPLIBRE_AUTO_EXECUTE` héritée du shell ferait répondre + « oui » à la place de quelqu'un, et cette réponse-là décide de toutes + les autres pendant des heures. + """ + + def answer(self, typed): + import builtins + + obj = TodoUpgrade.__new__(TodoUpgrade) + original = builtins.input + builtins.input = lambda prompt="": typed + self.addCleanup(setattr, builtins, "input", original) + with contextlib.redirect_stdout(io.StringIO()): + obj.prompt_auto_execute() + return obj.auto_execute + + def test_enter_says_no(self): + self.assertFalse(self.answer("")) + + def test_the_default_is_written_down_not_deduced(self): + # `answer == "y"` donnait le bon résultat par accident ; un défaut + # explicite dit ce qu'on veut, et se relit. + import inspect + + source = inspect.getsource(TodoUpgrade.prompt_auto_execute) + self.assertIn('default="n"', source) + + def test_an_inherited_flag_cannot_switch_it_on(self): + os.environ[auto_ask.ENV_ENABLED] = "1" + os.environ[auto_ask.ENV_DELAY] = "5" + self.assertFalse(self.answer("")) + + def test_the_inherited_flag_is_cleared_before_asking(self): + import inspect + + source = inspect.getsource(TodoUpgrade.prompt_auto_execute) + self.assertLess( + source.index("auto_ask.export(False)"), + source.index("auto_ask.ask("), + ) + + def test_yes_still_says_yes(self): + self.assertTrue(self.answer("y")) + + +class TestEveryPromptAnnouncesTheCountdown(EnvCase): + """Une invite qui a l'air d'attendre indéfiniment n'invite pas à + s'absenter — et c'est pourtant tout l'intérêt du mode auto.""" + + def ask(self, default="d", seconds=5): + import select + + original = select.select + select.select = lambda r, w, x, t: ([], [], []) + self.addCleanup(setattr, select, "select", original) + auto_ask.export(True, seconds) + out = io.StringIO() + with contextlib.redirect_stdout(out): + auto_ask.ask("💬 q : ", default=default) + return out.getvalue() + + def test_the_delay_is_announced_before_the_question(self): + texte = self.ask() + self.assertIn("⏱5s", texte) + self.assertLess(texte.index("⏱5s"), texte.index("💬")) + + def test_it_shows_the_real_delay(self): + self.assertIn("⏱2s", self.ask(seconds=2)) + + def test_nothing_is_announced_outside_auto_mode(self): + import builtins + + auto_ask.export(False) + original = builtins.input + vu = [] + builtins.input = lambda prompt="": vu.append(prompt) or "x" + self.addCleanup(setattr, builtins, "input", original) + auto_ask.ask("💬 q : ", default="d") + self.assertNotIn("⏱", vu[0]) + + def test_the_clock_announces_AND_reports(self): + # Le même symbole des deux côtés : il annonce l'attente, puis rend + # compte de la réponse. Deux symboles feraient deux mécanismes. + texte = self.ask() + self.assertEqual(texte.count("⏱"), 2) + + class TestNoPromptOfTheMigrationCanHang(unittest.TestCase): """Un `input()` nu ne sait rien du mode auto : il attend, pour toujours. From 6715700a16d084909aff8dc2b3633f1663a1b677 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 19 Aug 2026 07:37:40 -0400 Subject: [PATCH 52/67] [ADD] migration state: read the server log so nobody has to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A step log reaches thirteen megabytes — measured on a real migration. Nobody reads that, and the question one asks in front of it is two words long: where did it go wrong? The screen now answers it. Each step carries its count of ERROR and CRITICAL, and the pane lists the DISTINCT messages with how many times each occurred. Forty-eight « Model X has no table » is one problem seen forty-eight times, not forty-eight problems, and the raw list buries everything else. WARNING is left out of the count: a migration produces thousands, and a total that includes them means nothing. Each message keeps its logger and its DATABASE, which is what tells the six bumps apart — they shared one file before a step opened its own. Measured: thirteen megabytes scanned in 0.09 s, seventy occurrences reduced to fifteen messages, and cached on (size, mtime) because the screen repaints on every keystroke. --- FR --- [ADD] état de migration : lire le journal pour que personne n'ait à le faire Un journal d'étape atteint treize mégaoctets — mesuré. Personne ne le lit, et la question qu'on se pose devant lui tient en deux mots : où est-ce que ça a mal tourné ? L'écran y répond désormais. Chaque étape porte son compte d'ERROR et de CRITICAL, et le panneau liste les messages DISTINCTS avec leur nombre. Quarante-huit fois « Model X has no table » est un problème vu quarante-huit fois, pas quarante-huit problèmes. Les WARNING sont hors du compte : une migration en produit des milliers. Chaque message garde son logger et sa BASE, ce qui distingue les six paliers — ils partageaient un fichier avant qu'une étape n'ouvre le sien. Mesuré : treize mégaoctets analysés en 0,09 s, soixante-dix occurrences ramenées à quinze messages. Assisted-by: Claude Opus 5 --- script/todo/migration_status.py | 94 ++++++++++++- script/todo/migration_status_tui.py | 28 +++- test/test_migration_status.py | 202 ++++++++++++++++++++++++++++ 3 files changed, 321 insertions(+), 3 deletions(-) diff --git a/script/todo/migration_status.py b/script/todo/migration_status.py index 5b00471..d8f2061 100755 --- a/script/todo/migration_status.py +++ b/script/todo/migration_status.py @@ -21,6 +21,7 @@ toucher une base ni lancer un serveur. On l'ouvre en pleine migration. import json import os +import re import sys sys.path.append( @@ -190,6 +191,86 @@ def step_slug(msg): return (nom or "step")[:80].lower() +# Le format d'une ligne de journal Odoo : +# 2026-08-19 09:21:07,074 132948 ERROR odoo.tools.translate: message +# Le nom de la base en fait partie, et c'est ce qui permet de séparer six +# paliers écrits dans un même fichier — ce qui était le cas avant qu'une +# étape n'ouvre son propre journal. +RE_LOG_LINE = re.compile( + r"^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d,\d+ \d+ (\w+) (\S+) ([\w.]+): (.*)$" +) +GRAVE = ("ERROR", "CRITICAL") + +_CACHE = {} + + +def scan_log(chemin): + """Compter les sévérités et relever les erreurs DISTINCTES d'un journal. + + Mis en cache sur (taille, date) : l'écran se redessine à chaque touche, + et un journal d'étape atteint treize mégaoctets — mesuré. Le relire à + chaque frappe rendrait l'écran inutilisable. + + Les erreurs sont dédoublonnées avec leur nombre : quarante-huit fois + « Model X has no table » est UN problème vu quarante-huit fois, pas + quarante-huit problèmes, et la liste brute noie tout le reste. + """ + try: + stat = os.stat(chemin) + except OSError: + return {"count": {}, "errors": []} + cle = (chemin, stat.st_size, int(stat.st_mtime)) + if cle in _CACHE: + return _CACHE[cle] + compte = {} + distinct = {} + try: + with open(chemin, "r", encoding="utf-8", errors="replace") as handle: + for ligne in handle: + trouve = RE_LOG_LINE.match(ligne) + if not trouve: + if ligne.startswith("Traceback"): + compte["TRACEBACK"] = compte.get("TRACEBACK", 0) + 1 + continue + niveau, base, logger, message = trouve.groups() + compte[niveau] = compte.get(niveau, 0) + 1 + if niveau in GRAVE: + signature = (base, logger, message.strip()[:120]) + distinct[signature] = distinct.get(signature, 0) + 1 + except OSError: + return {"count": {}, "errors": []} + resultat = { + "count": compte, + "errors": [ + { + "database": base, + "logger": logger, + "message": message, + "times": nombre, + } + for (base, logger, message), nombre in sorted( + distinct.items(), key=lambda item: -item[1] + ) + ], + } + # Un seul journal en cache : ils pèsent des mégaoctets, et l'écran ne + # regarde qu'une étape à la fois. + _CACHE.clear() + _CACHE[cle] = resultat + return resultat + + +def step_log_scan(dct, step): + """Ce que le journal de cette étape contient de grave.""" + chemin = step_log_path(dct, step) + return scan_log(chemin) if chemin else {"count": {}, "errors": []} + + +def severe_count(scan): + """Combien d'ERROR et de CRITICAL, en un seul nombre.""" + return sum(scan["count"].get(niveau, 0) for niveau in GRAVE) + + def step_log_tail(dct, step, lines=400): """Les dernières lignes du journal de cette étape, et le compte total. @@ -397,10 +478,21 @@ def render_text(dct, limit_cmd=12, colour=None): for section in journal_by_step(dct): lst_cmd = section["lst_cmd"] journal = " 📄" if step_log_path(dct, section["step"]) else "" + scan = step_log_scan(dct, section["step"]) + graves = severe_count(scan) + # Le nombre d'ERROR à côté de l'étape : c'est la seule façon de + # voir d'un coup d'œil OÙ la migration a souffert, sans ouvrir + # treize mégaoctets de journal. + alerte = f" {paint(f'❌ {graves}', 'fail', colour)}" if graves else "" lignes.append( f" {paint(section['step'], 'step', colour)}" - f" ({len(lst_cmd)}){journal}" + f" ({len(lst_cmd)}){journal}{alerte}" ) + for item in scan["errors"][:3]: + lignes.append( + f" ×{item['times']:<3}" + f" {paint(item['message'][:100], 'warn', colour)}" + ) for cmd in lst_cmd[:limit_cmd]: # LA demande : distinguer d'un coup d'œil ce qui a été lancé # du reste du rapport. Une liste de commandes en texte plat se diff --git a/script/todo/migration_status_tui.py b/script/todo/migration_status_tui.py index a8c0035..f823d2f 100644 --- a/script/todo/migration_status_tui.py +++ b/script/todo/migration_status_tui.py @@ -79,11 +79,15 @@ def rows(dct): } ) for section in status.journal_by_step(dct): + graves = status.severe_count( + status.step_log_scan(dct, section["step"]) + ) lst.append( { "kind": "step", "label": section["step"], "detail": str(len(section["lst_cmd"])), + "severe": str(graves) if graves else "", "data": section, } ) @@ -131,6 +135,24 @@ def pane_text(dct, row, colour=False, show_log=True): # la liste des commandes dit ce qui a été lancé, jamais ce que cela a # répondu. Mais les deux mélangés dans un même panneau se confondent — # d'où « l », qui les sépare. + # Les erreurs DISTINCTES d'abord, le journal brut ensuite : quarante- + # huit fois le même message est un problème vu quarante-huit fois, et + # la liste brute le noie au milieu de cent mille lignes. + scan = status.step_log_scan(dct, section["step"]) + if scan["errors"]: + lignes.append("") + lignes.append( + f"── {t('errors in the log')} :" f" {status.severe_count(scan)} ──" + ) + for item in scan["errors"][:15]: + lignes.append( + f" ×{item['times']:<4}" + f" {status.paint(item['message'], 'fail', colour)}" + ) + lignes.append( + f" {status.paint(item['logger'], 'dim', colour)}" + f" · {item['database']}" + ) tail, total = status.step_log_tail(dct, section["step"]) if not show_log: if total: @@ -267,9 +289,11 @@ def build_app(dct, path=None): def _fill_table(self): table = self.query_one("#left", DataTable) table.clear(columns=True) - table.add_columns(t("Test results"), "#") + table.add_columns(t("Test results"), "#", "❌") for row in self.lst_row: - table.add_row(row["label"][:38], row["detail"]) + table.add_row( + row["label"][:38], row["detail"], row.get("severe", "") + ) table.styles.width = self.left_width self.query_one("#head", Static).update(head_text(self.dct)) diff --git a/test/test_migration_status.py b/test/test_migration_status.py index 9599455..a724295 100644 --- a/test/test_migration_status.py +++ b/test/test_migration_status.py @@ -1014,6 +1014,208 @@ class TestHowLongItTook(Base): ) +class TestReadingTheLogForTheUser(DiskCase): + """Compter les erreurs à la place de quelqu'un. + + Un journal d'étape atteint treize mégaoctets — mesuré. Personne ne le + lit, et la question qu'on se pose devant lui tient en deux mots : où + est-ce que ça a mal tourné ? C'est cette question-là que l'écran doit + savoir répondre sans qu'on ouvre le fichier. + """ + + JOURNAL = ( + "2026-08-19 09:21:07,074 132948 ERROR base13" + " odoo.tools.translate: couldn't read translation file\n" + "Traceback (most recent call last):\n" + "2026-08-19 09:34:27,088 133771 ERROR base14" + " odoo.modules.registry: Model account.bank.statement.import" + " has no table.\n" + "2026-08-19 09:34:28,088 133771 ERROR base14" + " odoo.modules.registry: Model account.bank.statement.import" + " has no table.\n" + "2026-08-19 09:44:51,959 134592 CRITICAL base15" + " odoo.service.server: Failed to initialize database.\n" + "2026-08-19 09:44:52,000 134592 WARNING base15" + " odoo.schema: unable to add constraint\n" + "2026-08-19 09:44:53,000 134592 INFO base15" + " odoo.modules.loading: Modules loaded.\n" + ) + + def poser(self, contenu=None, etape="4 - Upgrade"): + obj = self.upgrade() + obj.add_comment_progression(etape) + obj.close_step_log() + chemin = os.path.join( + "private", + "odoo", + "migration", + "essai_db", + "step_log", + status.step_slug(etape) + ".log", + ) + with open(chemin, "w") as handle: + handle.write(contenu if contenu is not None else self.JOURNAL) + # La progression de l'objet, pas un dict nu : c'est elle qui porte + # `command_executed`, donc le découpage par étape. + return obj.dct_progression + + def test_it_counts_the_severities(self): + scan = status.step_log_scan(self.poser(), "4 - Upgrade") + self.assertEqual(scan["count"]["ERROR"], 3) + self.assertEqual(scan["count"]["CRITICAL"], 1) + self.assertEqual(scan["count"]["WARNING"], 1) + self.assertEqual(scan["count"]["TRACEBACK"], 1) + + def test_the_severe_ones_are_summed(self): + # ERROR et CRITICAL, pas WARNING : une migration en produit des + # milliers, et un compte qui les inclut ne veut plus rien dire. + scan = status.step_log_scan(self.poser(), "4 - Upgrade") + self.assertEqual(status.severe_count(scan), 4) + + def test_the_same_message_is_counted_ONCE(self): + # Quarante-huit fois « Model X has no table » est UN problème vu + # quarante-huit fois, pas quarante-huit problèmes. + scan = status.step_log_scan(self.poser(), "4 - Upgrade") + modele = [e for e in scan["errors"] if "has no table" in e["message"]] + self.assertEqual(len(modele), 1) + self.assertEqual(modele[0]["times"], 2) + + def test_the_loudest_comes_first(self): + scan = status.step_log_scan(self.poser(), "4 - Upgrade") + self.assertEqual(scan["errors"][0]["times"], 2) + + def test_each_error_carries_its_database(self): + # Six paliers ont pu écrire dans le même fichier : sans la base, + # on ne sait pas lequel a souffert. + scan = status.step_log_scan(self.poser(), "4 - Upgrade") + bases = {e["database"] for e in scan["errors"]} + self.assertEqual(bases, {"base13", "base14", "base15"}) + + def test_it_carries_the_logger_too(self): + scan = status.step_log_scan(self.poser(), "4 - Upgrade") + loggers = {e["logger"] for e in scan["errors"]} + self.assertIn("odoo.tools.translate", loggers) + + def test_warnings_are_never_listed_as_errors(self): + scan = status.step_log_scan(self.poser(), "4 - Upgrade") + self.assertNotIn( + "unable to add constraint", + " ".join(e["message"] for e in scan["errors"]), + ) + + def test_a_step_without_a_log_counts_zero(self): + self.assertEqual( + status.severe_count( + status.step_log_scan( + {"config_database_name": "essai_db"}, "9 - rien" + ) + ), + 0, + ) + + def test_a_healthy_log_shows_no_alarm(self): + dct = self.poser( + "2026-08-19 09:00:00,000 1 INFO db odoo.modules: Modules loaded.\n" + ) + self.assertEqual( + status.severe_count(status.step_log_scan(dct, "4 - Upgrade")), 0 + ) + # La LIGNE d'étape, pas tout le rapport : celui-ci porte toujours + # l'en-tête « Commandes en échec », qui a le même symbole. + etapes = status.render_text(dct, colour=False).split("step by step")[1] + self.assertNotIn("❌", etapes) + + def test_the_report_names_the_count_and_the_top_errors(self): + texte = status.render_text(self.poser(), colour=False) + self.assertIn("❌ 4", texte) + self.assertIn("has no table", texte) + + def test_the_full_screen_lists_them_with_their_source(self): + dct = self.poser() + etape = [x for x in tui.rows(dct) if x["kind"] == "step"][0] + texte = tui.pane_text(dct, etape, show_log=False) + self.assertIn("×2", texte) + self.assertIn("odoo.modules.registry", texte) + self.assertIn("base14", texte) + + def test_the_left_column_shows_the_count(self): + dct = self.poser() + etape = [x for x in tui.rows(dct) if x["kind"] == "step"][0] + self.assertEqual(etape["severe"], "4") + + def test_a_clean_step_leaves_the_column_empty(self): + # Un « 0 » dans chaque ligne n'apprend rien et occupe la place. + dct = self.poser( + "2026-08-19 09:00:00,000 1 INFO db odoo.modules: ok\n" + ) + etape = [x for x in tui.rows(dct) if x["kind"] == "step"][0] + self.assertEqual(etape["severe"], "") + + +class TestReadingItTwiceIsFree(DiskCase): + """L'écran se redessine à chaque touche. + + Relire treize mégaoctets à chaque frappe rendrait l'écran inutilisable. + """ + + def test_the_second_read_does_not_touch_the_disk(self): + obj = self.upgrade() + obj.add_comment_progression("4 - Upgrade") + obj.close_step_log() + chemin = os.path.join( + "private", + "odoo", + "migration", + "essai_db", + "step_log", + status.step_slug("4 - Upgrade") + ".log", + ) + with open(chemin, "w") as handle: + handle.write("2026-08-19 09:00:00,000 1 ERROR db odoo.x: boum\n") + dct = {"config_database_name": "essai_db"} + premier = status.step_log_scan(dct, "4 - Upgrade") + lectures = [] + vrai_open = open + + def compter(*args, **kwargs): + lectures.append(args[0]) + return vrai_open(*args, **kwargs) + + import builtins + + builtins.open = compter + self.addCleanup(setattr, builtins, "open", vrai_open) + second = status.step_log_scan(dct, "4 - Upgrade") + self.assertEqual(premier, second) + self.assertEqual([x for x in lectures if str(x).endswith(".log")], []) + + def test_a_changed_file_IS_read_again(self): + # Sinon « r » ne rafraîchirait rien : la migration écrit pendant + # qu'on regarde. + obj = self.upgrade() + obj.add_comment_progression("4 - Upgrade") + obj.close_step_log() + chemin = os.path.join( + "private", + "odoo", + "migration", + "essai_db", + "step_log", + status.step_slug("4 - Upgrade") + ".log", + ) + dct = {"config_database_name": "essai_db"} + with open(chemin, "w") as handle: + handle.write("2026-08-19 09:00:00,000 1 ERROR db odoo.x: un\n") + self.assertEqual( + status.severe_count(status.step_log_scan(dct, "4 - Upgrade")), 1 + ) + with open(chemin, "a") as handle: + handle.write("2026-08-19 09:00:01,000 1 ERROR db odoo.y: deux\n") + self.assertEqual( + status.severe_count(status.step_log_scan(dct, "4 - Upgrade")), 2 + ) + + class TestSeparatingCommandsFromLogs(Base): """Les deux mélangés dans un même panneau se confondent. From 4028421cb8b5248f57231836489026d61524b64b Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 19 Aug 2026 08:21:12 -0400 Subject: [PATCH 53/67] [ADD] analyse: what a migration gained and lost, step by step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A migration leaves one database per step and they all still exist, so the tool compares them side by side rather than replaying anything. Six Odoo starts would cost an hour, write to the databases and need the checkout switched at each step; the same inspection in SQL takes under half a second per database and touches nothing. Measured on a real migration: seven databases surveyed in 3.8 seconds. It reports what each step gained and lost — modules, models, views, menus, attachments — and ends on the comparison of the ENDS, which is not the sum of the steps: a module dropped at 15 and put back at 17 lost nothing, and adding the steps would count it twice. The signal that matters is rows. A module removed is visible; a table going from four thousand rows to zero is visible nowhere. Two rename heuristics were tried and rejected on real data before the one that holds. Row count alone paired an account tag table with dms_directory — both had seven rows. A shared five-letter word paired two cleanup tables. Overall name similarity separates them. And a renamed table stays in the loss list, annotated: removing it was the real danger, since a wrong pairing would have hidden a genuine loss. --- FR --- [ADD] analyse : ce qu'une migration a gagné et perdu, palier par palier Une migration laisse une base par palier et elles existent toutes encore : l'outil les compare côte à côte plutôt que de rejouer quoi que ce soit. Six démarrages d'Odoo coûteraient une heure et écriraient dans les bases ; la même inspection en SQL prend moins d'une demi-seconde par base. Mesuré sur une vraie migration : sept bases inspectées en 3,8 secondes. Le bilan final compare les EXTRÉMITÉS, ce qui n'est pas la somme des paliers : un module retiré en 15 puis remis en 17 n'a rien perdu. Le signal qui compte, ce sont les lignes. Un module en moins se voit ; une table qui passe de quatre mille lignes à zéro ne se voit nulle part. Deux rapprochements de renommage ont été essayés et rejetés sur des données réelles. Et une table renommée reste dans la liste des pertes, annotée : l'en retirer était le vrai danger. Assisted-by: Claude Opus 5 --- script/analyse/check_migration_quality.py | 499 ++++++++++++++++++ script/analyse/check_migration_quality_tui.py | 225 ++++++++ script/todo/migration_status_tui.py | 96 ++-- script/todo/todo.py | 34 ++ script/todo/todo_i18n.py | 56 ++ test/test_check_migration_quality.py | 399 ++++++++++++++ test/test_migration_status.py | 4 +- 7 files changed, 1280 insertions(+), 33 deletions(-) create mode 100755 script/analyse/check_migration_quality.py create mode 100644 script/analyse/check_migration_quality_tui.py create mode 100644 test/test_check_migration_quality.py diff --git a/script/analyse/check_migration_quality.py b/script/analyse/check_migration_quality.py new file mode 100755 index 0000000..ff08063 --- /dev/null +++ b/script/analyse/check_migration_quality.py @@ -0,0 +1,499 @@ +#!/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'une migration gagne et ce qu'elle perd, palier par palier. + +La question qu'on se pose après six paliers n'est pas « a-t-elle fini » — +le journal le dit — mais « qu'est-ce qui a changé en chemin ». Un module +désinstallé en 15 pour débloquer la mise à jour, une table vidée sans que +personne ne le voie, deux cents vues apparues : rien de tout cela +n'apparaît dans un journal qu'on lit ligne à ligne. + +Ce que l'outil compare +---------------------- +Une migration laisse une base PAR PALIER — `x`, `x_upgrade_13`, … — et +elles existent toutes encore. On les interroge donc côte à côte, en +lecture seule, plutôt que de rejouer quoi que ce soit. + +Pourquoi pas en démarrant Odoo +------------------------------ +Six démarrages coûteraient une heure, écriraient dans les bases et +demanderaient de basculer le checkout à chaque palier. Mesuré : la même +inspection en SQL prend moins d'une demi-seconde par base, et ne touche à +rien. Ce qu'on y perd — les modèles abstraits, les champs calculés — ne +se compare pas d'une version à l'autre de toute façon. + +Ce qui compte le plus +--------------------- +Les LIGNES perdues. Un module en moins se voit ; une table qui passe de +quatre mille lignes à zéro ne se voit nulle part. Un renommage de table +entre deux versions s'y lit comme une perte suivie d'un gain : le rapport +les rapproche quand le compte correspond, plutôt que de crier au loup. + +Codes de sortie : 0 rien à signaler, 1 des trouvailles, 2 l'outil a échoué. +""" + +import json +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 + + +DEFAULT_PROGRESSION = ".venv.erplibre/odoo_database_migration_log.json" +FILESTORE = os.path.join( + os.path.expanduser("~"), ".local", "share", "Odoo", "filestore" +) +SEP = "\x1f" + + +def run_psql(database, sql): + """Interroger la base en lecture seule, garantie par le SERVEUR. + + `default_transaction_read_only` n'est pas une promesse de l'outil : + PostgreSQL refusera l'écriture même si le SQL en contenait une. On + inspecte des bases de migration, parfois la seule copie qui reste. + """ + env = os.environ.copy() + env["PGOPTIONS"] = "-c default_transaction_read_only=on" + env["PSQLRC"] = "" + done = subprocess.run( + ["psql", "-X", "-w", "-d", database, "-tAF", SEP, "-c", sql], + capture_output=True, + text=True, + env=env, + ) + if done.returncode: + return None + return [ligne.split(SEP) for ligne in done.stdout.splitlines() if ligne] + + +def read_progression(path=DEFAULT_PROGRESSION): + try: + with open(path, "r", encoding="utf-8") as handle: + return json.load(handle) + except (OSError, ValueError): + return {} + + +def chain(dct): + """[(version, base)] du départ à l'arrivée, dans l'ordre du parcours. + + Le nom des bases de palier suit la convention du pilote — + « _upgrade_ » — et la liste des paliers se déduit de la + cible et du nombre d'entrées `state_4_*_odoo_lst`. On ne devine donc + rien : on relit ce que la migration a écrit. + """ + base = dct.get("config_database_name") + if not base: + return [] + try: + cible = int(float(dct.get("target_odoo_version") or 0)) + except (TypeError, ValueError): + return [] + total = max( + [ + len(valeur) + for cle, valeur in dct.items() + if cle.startswith("state_4_") + and cle.endswith("_odoo_lst") + and isinstance(valeur, list) + ] + or [0] + ) + if not total or not cible: + return [(None, base)] + lst_version = list(range(cible - total + 1, cible + 1)) + depart = lst_version[0] - 1 + return [(depart, base)] + [ + (version, f"{base}_upgrade_{version}") for version in lst_version + ] + + +META_SQL = """ +SELECT 'odoo', latest_version FROM ir_module_module WHERE name = 'base' +UNION ALL SELECT 'view', count(*)::text FROM ir_ui_view +UNION ALL SELECT 'view_cow', count(*)::text FROM ir_ui_view + WHERE website_id IS NOT NULL +UNION ALL SELECT 'menu', count(*)::text FROM ir_ui_menu +UNION ALL SELECT 'action', count(*)::text FROM ir_act_window +UNION ALL SELECT 'attachment', count(*)::text FROM ir_attachment +UNION ALL SELECT 'attachment_stored', count(*)::text FROM ir_attachment + WHERE store_fname IS NOT NULL +UNION ALL SELECT 'language', count(*)::text FROM res_lang WHERE active +""" + + +def table_counts(database): + """{table: lignes} pour toutes les tables. Une seule requête. + + Construite côté serveur puis exécutée d'un bloc : huit cents requêtes + séparées coûteraient huit cents allers-retours, là où celle-ci prend + quatre dixièmes de seconde — mesuré sur une base de 890 tables. + """ + fabrique = run_psql( + database, + "SELECT string_agg(" + "format('SELECT %L t, count(*) n FROM %I', table_name, table_name)," + " ' UNION ALL ') FROM information_schema.tables" + " WHERE table_schema = 'public' AND table_type = 'BASE TABLE'", + ) + if not fabrique or not fabrique[0][0]: + return {} + lignes = run_psql(database, fabrique[0][0]) + if lignes is None: + return {} + return { + nom: int(nombre) + for nom, nombre in (ligne[:2] for ligne in lignes) + if nombre.isdigit() + } + + +def missing_files(database, lst_store_fname): + """Les pièces jointes dont le FICHIER a disparu du filestore. + + Une base peut référencer des milliers de pièces jointes dont le + contenu n'a pas suivi le clonage. Rien ne le signale : la page se + charge, l'image est vide. + """ + racine = os.path.join(FILESTORE, database) + if not os.path.isdir(racine): + return None + return [ + nom + for nom in lst_store_fname + if nom and not os.path.isfile(os.path.join(racine, nom)) + ] + + +def inspect(database): + """L'état d'une base, en lecture seule. `exists` False si absente.""" + etat = {"database": database, "exists": False} + meta = run_psql(database, META_SQL) + if meta is None: + return etat + etat["exists"] = True + dct_meta = {ligne[0]: ligne[1] for ligne in meta if len(ligne) > 1} + etat["odoo"] = (dct_meta.get("odoo") or "?").rsplit(".", 2)[0] + for cle in ( + "view", + "view_cow", + "menu", + "action", + "attachment", + "attachment_stored", + "language", + ): + etat[cle] = int(dct_meta.get(cle) or 0) + + modules = run_psql( + database, "SELECT name, state FROM ir_module_module ORDER BY name" + ) + etat["module"] = { + nom: statut for nom, statut in (m[:2] for m in modules or []) + } + etat["installed"] = sorted( + nom for nom, statut in etat["module"].items() if statut == "installed" + ) + modeles = run_psql(database, "SELECT model FROM ir_model ORDER BY model") + etat["model"] = sorted(m[0] for m in modeles or []) + etat["table"] = table_counts(database) + + # Les modèles sans table : abstraits, mixins et vues SQL pour la + # plupart — mais leur NOMBRE qui bouge d'un palier à l'autre dit + # quelque chose, alors que la liste brute ne dit rien. + tables = set(etat["table"]) + etat["model_without_table"] = [ + m for m in etat["model"] if m.replace(".", "_") not in tables + ] + + stockees = run_psql( + database, + "SELECT DISTINCT store_fname FROM ir_attachment" + " WHERE store_fname IS NOT NULL", + ) + absents = missing_files(database, [ligne[0] for ligne in stockees or []]) + etat["attachment_missing"] = None if absents is None else len(absents) + return etat + + +def compare(avant, apres): + """Ce qui a été gagné et ce qui a été perdu entre deux paliers.""" + if not avant.get("exists") or not apres.get("exists"): + return {"unavailable": True} + inst_avant, inst_apres = set(avant["installed"]), set(apres["installed"]) + mod_avant, mod_apres = set(avant["model"]), set(apres["model"]) + tbl_avant, tbl_apres = avant["table"], apres["table"] + + lignes_perdues = [] + for table, nombre in sorted(tbl_avant.items()): + reste = tbl_apres.get(table) + if reste is None and nombre: + lignes_perdues.append((table, nombre, 0)) + elif reste is not None and reste < nombre: + lignes_perdues.append((table, nombre, reste)) + lignes_gagnees = [ + (table, tbl_avant.get(table, 0), nombre) + for table, nombre in sorted(tbl_apres.items()) + if nombre > tbl_avant.get(table, 0) + ] + return { + "modules_lost": sorted(inst_avant - inst_apres), + "modules_gained": sorted(inst_apres - inst_avant), + "models_lost": sorted(mod_avant - mod_apres), + "models_gained": sorted(mod_apres - mod_avant), + "rows_lost": lignes_perdues, + "rows_gained": lignes_gagnees, + "renamed": probable_renames(lignes_perdues, lignes_gagnees), + "delta": { + cle: apres.get(cle, 0) - avant.get(cle, 0) + for cle in ( + "view", + "view_cow", + "menu", + "action", + "attachment", + "language", + ) + }, + } + + +def probable_renames(perdues, gagnees): + """Rapprocher une table disparue d'une table apparue au même compte. + + Odoo renomme des tables entre versions — `mail_channel` est devenu + `discuss_channel` en 17. Sans ce rapprochement, chaque renommage se + lit comme une perte de données doublée d'une apparition, et l'on + cherche un dégât là où il n'y en a pas. + """ + disparues = { + table: avant for table, avant, apres in perdues if apres == 0 and avant + } + apparues = { + table: apres for table, avant, apres in gagnees if avant == 0 and apres + } + couples = [] + for table, nombre in sorted(disparues.items()): + for autre, combien in sorted(apparues.items()): + if combien == nombre and looks_renamed(table, autre): + couples.append((table, autre, nombre)) + del apparues[autre] + break + return couples + + +RENAME_RATIO = 0.75 + + +def looks_renamed(un, deux): + """Les deux noms se ressemblent-ils assez pour être le même sujet ? + + Deux garde-fous ont été essayés et rejetés, mesurés sur une vraie + migration. Le seul nombre de lignes accouplait + `account_account_tag_account_tax_template_rel` à `dms_directory` : les + deux comptaient sept lignes. Un mot commun d'au moins cinq lettres + accouplait `cleanup_purge_wizard_menu` à + `cleanup_create_indexes_line` — « cleanup » ne dit rien. + + La ressemblance d'ENSEMBLE tranche : `muk_dms_directory` et + `dms_directory` se ressemblent à 87 %, `website_redirect` et + `website_rewrite` à 83 %, tandis que les faux couples restent sous + 50 %. + """ + import difflib + + return difflib.SequenceMatcher(None, un, deux).ratio() >= RENAME_RATIO + + +def survey(dct, echo=None): + """Inspecter toute la chaîne, du départ à l'arrivée.""" + lst = [] + for version, database in chain(dct): + if echo: + echo(f"{database} …") + etat = inspect(database) + etat["version"] = version + lst.append(etat) + return lst + + +def overall(lst_snapshot): + """La comparaison du PREMIER au DERNIER palier. + + Elle ne se déduit pas des comparaisons deux à deux : un module retiré + en 15 puis remis en 17 n'a rien perdu du tout, et l'addition des + étapes le compterait deux fois. + """ + presents = [x for x in lst_snapshot if x.get("exists")] + if len(presents) < 2: + return {"unavailable": True} + return compare(presents[0], presents[-1]) + + +def render_text(lst_snapshot, colour=None, limit=8): + """Le rapport complet. C'est aussi le repli du plein écran.""" + from script.todo.migration_status import paint, supports_colour + + if colour is None: + colour = supports_colour() + lignes = [f"📐 {t('Migration quality, step by step')}"] + for etat in lst_snapshot: + if not etat.get("exists"): + lignes.append( + f" ⚠️ {etat['database']} : {t('database not found')}" + ) + continue + lignes.append( + f" {paint(f'{etat['odoo']:<6}', 'step', colour)}" + f" {etat['database']:<34}" + f" {len(etat['installed']):>4} {t('modules')}" + f" · {len(etat['model']):>4} {t('models')}" + f" · {etat['view']:>5} {t('views')}" + f" · {etat['attachment']:>6} {t('attachments')}" + ) + if etat.get("attachment_missing"): + lignes.append( + f" {paint('❌', 'fail', colour)}" + f" {etat['attachment_missing']}" + f" {t('attachment files missing from the filestore')}" + ) + + presents = [x for x in lst_snapshot if x.get("exists")] + for avant, apres in zip(presents, presents[1:]): + lignes.append(f"\n🔀 {avant['odoo']} → {apres['odoo']}") + lignes.extend(render_compare(compare(avant, apres), colour, limit)) + + lignes.append( + f"\n🏁 {t('From start to finish')} :" + f" {presents[0]['odoo'] if presents else '?'}" + f" → {presents[-1]['odoo'] if presents else '?'}" + ) + lignes.extend(render_compare(overall(lst_snapshot), colour, limit)) + return "\n".join(lignes) + + +def render_compare(diff, colour, limit=8): + """Les gains et les pertes d'un palier, en quelques lignes.""" + from script.todo.migration_status import paint + + if diff.get("unavailable"): + return [f" {t('not comparable: a database is missing')}"] + lignes = [] + for cle, symbole, teinte in ( + ("modules_lost", "−", "fail"), + ("modules_gained", "+", "ok"), + ): + lst = diff[cle] + if lst: + lignes.append( + f" {paint(symbole, teinte, colour)} {len(lst)}" + f" {t('modules')} : {', '.join(lst[:limit])}" + + (" …" if len(lst) > limit else "") + ) + for cle, symbole, teinte in ( + ("models_lost", "−", "fail"), + ("models_gained", "+", "ok"), + ): + lst = diff[cle] + if lst: + lignes.append( + f" {paint(symbole, teinte, colour)} {len(lst)}" + f" {t('models')} : {', '.join(lst[:limit])}" + + (" …" if len(lst) > limit else "") + ) + if diff["renamed"]: + lignes.append( + f" ↻ {len(diff['renamed'])} {t('probable table rename(s)')} :" + f" {', '.join(f'{a} → {b}' for a, b, _n in diff['renamed'][:4])}" + ) + perdues = diff["rows_lost"] + if perdues: + # LE signal qui compte : un module en moins se voit, une table qui + # passe de quatre mille lignes à zéro ne se voit nulle part. + # + # Une table probablement renommée reste dans la LISTE, annotée. L'en + # retirer était le vrai danger : un rapprochement faux — et il y en + # a eu — aurait fait disparaître une perte réelle du rapport. + vers = {a: b for a, b, _n in diff["renamed"]} + lignes.append( + f" {paint('▼', 'fail', colour)} {len(perdues)}" + f" {t('table(s) lost rows')} :" + ) + for table, avant, apres in perdues[:limit]: + note = ( + f" ↻ {t('probably renamed to')} {vers[table]}" + if table in vers + else "" + ) + lignes.append(f" {table:<40} {avant:>8} → {apres}{note}") + delta = diff["delta"] + lignes.append( + " " + + " ".join( + f"{cle} {valeur:+d}" for cle, valeur in delta.items() if valeur + ) + or " =" + ) + return lignes + + +def main(argv=None): + import argparse + + parser = argparse.ArgumentParser( + description=( + "Compare every database a migration left behind, step by step," + " and report what was gained and what was lost." + ) + ) + parser.add_argument( + "-f", + "--file", + default=DEFAULT_PROGRESSION, + help="migration progression file", + ) + parser.add_argument( + "--text", + action="store_true", + help="print the report instead of opening the full screen", + ) + parser.add_argument("--limit", type=int, default=8) + config = parser.parse_args(argv) + + dct = read_progression(config.file) + if not chain(dct): + print(f"ℹ️ {t('No migration in progress.')}") + return 0 + lst = survey(dct, echo=lambda texte: print(f"⧖ {texte}", flush=True)) + if not config.text: + try: + from script.analyse.check_migration_quality_tui import run_tui + except Exception: + run_tui = None + if run_tui and run_tui(lst): + return 0 + print(render_text(lst, limit=config.limit)) + manque = [x for x in lst if not x.get("exists")] + perdu = overall(lst) + trouvailles = bool(manque) or bool( + not perdu.get("unavailable") + and (perdu["modules_lost"] or perdu["rows_lost"]) + ) + return 1 if trouvailles else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/analyse/check_migration_quality_tui.py b/script/analyse/check_migration_quality_tui.py new file mode 100644 index 0000000..1f570a2 --- /dev/null +++ b/script/analyse/check_migration_quality_tui.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""La qualité d'une migration, palier par palier, en plein écran. + +Le rapport texte dit tout d'un coup : sur six paliers il fait plusieurs +centaines de lignes, et ce qu'on cherche — quel palier a perdu quoi — se +trouve quelque part au milieu. Un écran qui se parcourt règle cela. + +Les données viennent de `check_migration_quality`, comme le rapport texte. +Deux assemblages sépareraient les deux vues, et l'on finirait par lire deux +états contradictoires de la même migration. +""" + +import os +import sys + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..")) +) + +from script.analyse import check_migration_quality as quality # noqa: E402 +from script.todo import migration_status as status # noqa: E402 + +try: + from script.todo.todo_i18n import t +except Exception: # pragma: no cover - repli si i18n indisponible + + def t(key: str) -> str: + return key + + +CSS = """ +Screen { layout: vertical; } +#head { height: 3; padding: 0 1; background: $panel; color: $text; } +#body { height: 1fr; } +#left { width: 40; border-right: solid $accent; } +#pane { width: 1fr; padding: 0 1; } +""" + + +def rows(lst_snapshot): + """Un palier par ligne, puis le bilan d'ensemble en dernier. + + Le bilan EN DERNIER et non en tête : on descend la liste comme on a + vécu la migration, et la question « qu'est-ce qu'il en reste » se pose + une fois qu'on a vu le chemin. + """ + lst = [] + presents = [x for x in lst_snapshot if x.get("exists")] + for index, etat in enumerate(lst_snapshot): + if not etat.get("exists"): + lst.append( + { + "kind": "missing", + "label": f"⚠️ {etat['database'][:30]}", + "detail": "", + "data": etat, + } + ) + continue + precedent = None + rang = presents.index(etat) + if rang: + precedent = presents[rang - 1] + diff = quality.compare(precedent, etat) if precedent else None + perdu = ( + 0 + if diff is None or diff.get("unavailable") + else len(diff["rows_lost"]) + ) + lst.append( + { + "kind": "step", + "label": f"{etat['odoo']:<6} {etat['database'][:24]}", + "detail": str(perdu) if perdu else "", + "data": etat, + "diff": diff, + } + ) + if len(presents) >= 2: + lst.append( + { + "kind": "overall", + "label": f"🏁 {presents[0]['odoo']} → {presents[-1]['odoo']}", + "detail": "", + "data": None, + "diff": quality.overall(lst_snapshot), + } + ) + return lst + + +def head_text(lst_snapshot): + presents = [x for x in lst_snapshot if x.get("exists")] + manquants = len(lst_snapshot) - len(presents) + if not presents: + return t("No migration in progress.") + return ( + f"{presents[0]['database']} · {len(presents)} {t('steps')}" + f" · {presents[0]['odoo']} → {presents[-1]['odoo']}" + + ( + f" · ⚠️ {manquants} {t('database not found')}" + if manquants + else "" + ) + ) + + +def pane_text(lst_snapshot, row, colour=False): + """Le détail du palier choisi, ou le bilan d'ensemble.""" + if row is None: + return t("Nothing to show yet.") + if row["kind"] == "missing": + return f"⚠️ {row['data']['database']} : {t('database not found')}" + lignes = [] + etat = row["data"] + if etat: + lignes.append( + status.paint(f"{etat['odoo']} {etat['database']}", "step", colour) + ) + lignes.append("") + for libelle, valeur in ( + (t("modules"), len(etat["installed"])), + (t("models"), len(etat["model"])), + (t("views"), etat["view"]), + (" · COW", etat["view_cow"]), + (t("menus"), etat["menu"]), + (t("attachments"), etat["attachment"]), + ): + lignes.append(f" {libelle:<28} {valeur:>7}") + if etat.get("attachment_missing"): + lignes.append( + f" {status.paint('❌ ' + t('attachment files missing from' + ' the filestore'), 'fail', colour)}" + f" {etat['attachment_missing']}" + ) + lignes.append("") + diff = row.get("diff") + if diff: + lignes.extend(quality.render_compare(diff, colour, limit=40)) + return "\n".join(lignes) + + +def build_app(lst_snapshot): + """Textual est importé ICI : le module reste testable sans lui.""" + from rich.text import Text + from textual.app import App, ComposeResult + from textual.containers import Horizontal, VerticalScroll + from textual.widgets import DataTable, Footer, Header, Static + + class QualityApp(App): + CSS = globals()["CSS"] + BINDINGS = [("q,escape", "quit", t("Quit"))] + + def __init__(self, lst_snapshot): + super().__init__() + self.lst_snapshot = lst_snapshot + self.lst_row = rows(lst_snapshot) + self.index = 0 + + def compose(self) -> ComposeResult: + yield Header() + yield Static("", id="head") + with Horizontal(id="body"): + yield DataTable(id="left", cursor_type="row") + with VerticalScroll(id="pane"): + yield Static("", id="content") + yield Footer() + + def on_mount(self): + self.title = t("Migration quality, step by step") + table = self.query_one("#left", DataTable) + table.add_columns(t("steps"), "▼") + for row in self.lst_row: + table.add_row(row["label"][:34], row["detail"]) + self.query_one("#head", Static).update( + head_text(self.lst_snapshot) + ) + self._show() + + def _show(self): + row = ( + self.lst_row[self.index] + if self.lst_row and self.index < len(self.lst_row) + else None + ) + self.query_one("#content", Static).update( + Text.from_ansi(pane_text(self.lst_snapshot, row, colour=True)) + ) + + def on_data_table_row_highlighted(self, event): + if event.data_table.id == "left" and self.lst_row: + self.index = event.cursor_row + self._show() + + return QualityApp(lst_snapshot) + + +def run_tui(lst_snapshot, run_app=True): + """Ouvrir l'écran. False si l'on n'a pas pu — et alors on DIT pourquoi.""" + if not lst_snapshot: + return False + if not sys.stdout.isatty(): + print(f"ℹ️ {t('Not a terminal: showing the text report instead.')}") + return False + try: + from script.todo import textual_setup + except Exception: + textual_setup = None + if textual_setup and not textual_setup.ensure(): + return False + try: + app = build_app(lst_snapshot) + except ImportError: + print( + f"ℹ️ {t('Textual is missing from this interpreter:')}" + f" {sys.executable}" + ) + return False + if not run_app: + return app + app.run() + return True diff --git a/script/todo/migration_status_tui.py b/script/todo/migration_status_tui.py index f823d2f..8311fb4 100644 --- a/script/todo/migration_status_tui.py +++ b/script/todo/migration_status_tui.py @@ -234,6 +234,34 @@ def apply_width(app, delta): app.query_one("#left").styles.width = app.left_width +def fill_table(app): + """(Re)construire la liste de gauche depuis les données courantes.""" + table = app.query_one("#left") + table.clear(columns=True) + table.add_columns(t("Test results"), "#", "❌") + for row in app.lst_row: + table.add_row(row["label"][:38], row["detail"], row.get("severe", "")) + table.styles.width = app.left_width + app.query_one("#head").update(head_text(app.dct)) + + +def show_pane(app): + """Peindre le détail de la ligne choisie. + + `from_ansi` fait DEUX choses : il rend les couleurs, et il traite le + reste comme du texte LITTÉRAL. Sans lui, une commande contenant + « [1] » passait pour du balisage Rich et disparaissait sans un mot. + """ + from rich.text import Text + + row = current_row(app.lst_row, app.index) + app.query_one("#content").update( + Text.from_ansi( + pane_text(app.dct, row, colour=True, show_log=app.show_log) + ) + ) + + def build_app(dct, path=None): """Textual est importé ICI, pas au chargement du module. @@ -241,7 +269,6 @@ def build_app(dct, path=None): Textual, et c'est aussi ce qui permet à l'appelant de retomber sur le rapport texte plutôt que d'échouer. """ - from rich.text import Text from textual.app import App, ComposeResult from textual.containers import Horizontal, VerticalScroll from textual.widgets import DataTable, Footer, Header, Static @@ -253,6 +280,7 @@ def build_app(dct, path=None): ("r", "refresh", t("Refresh")), ("l", "toggle_log", t("Logs")), ("p", "cycle_panels", t("Panels")), + ("k", "quality", t("Quality")), ("plus,equal", "wider", t("Wider")), ("minus,underscore", "narrower", t("Narrower")), ] @@ -282,39 +310,14 @@ def build_app(dct, path=None): def on_mount(self): self.title = t("Migration state") - self._fill_table() + fill_table(self) apply_panels(self) - self._show() - - def _fill_table(self): - table = self.query_one("#left", DataTable) - table.clear(columns=True) - table.add_columns(t("Test results"), "#", "❌") - for row in self.lst_row: - table.add_row( - row["label"][:38], row["detail"], row.get("severe", "") - ) - table.styles.width = self.left_width - self.query_one("#head", Static).update(head_text(self.dct)) - - def _show(self): - row = current_row(self.lst_row, self.index) - # `from_ansi` fait DEUX choses : il rend les couleurs, et il - # traite le reste comme du texte LITTÉRAL. Sans lui, une - # commande contenant « [1] » passait pour du balisage Rich et - # disparaissait de l'écran sans que rien ne le signale. - self.query_one("#content", Static).update( - Text.from_ansi( - pane_text( - self.dct, row, colour=True, show_log=self.show_log - ) - ) - ) + show_pane(self) def on_data_table_row_highlighted(self, event): if event.data_table.id == "left" and self.lst_row: self.index = event.cursor_row - self._show() + show_pane(self) def action_refresh(self): """Relire le disque. La migration écrit PENDANT qu'on regarde. @@ -329,12 +332,41 @@ def build_app(dct, path=None): self.dct = neuf self.lst_row = rows(self.dct) self.index = min(self.index, max(0, len(self.lst_row) - 1)) - self._fill_table() - self._show() + fill_table(self) + show_pane(self) + + def action_quality(self): + """Ouvrir le rapport de qualité, sans quitter celui-ci. + + Les deux écrans répondent à deux questions voisines : « où en + est-on » et « qu'a-t-on gagné ou perdu en chemin ». Les + séparer par une touche plutôt que par deux commandes à retenir + est ce qui les rend utilisables ensemble. + + `suspend` rend le terminal à l'autre plein écran : deux + applications Textual ne peuvent pas peindre le même écran en + même temps. + """ + from script.analyse import check_migration_quality as quality + from script.analyse.check_migration_quality_tui import ( + run_tui as run_quality, + ) + + dct = quality.read_progression() + if not quality.chain(dct): + self.notify(t("No migration in progress.")) + return + with self.suspend(): + lst = quality.survey( + dct, echo=lambda texte: print(f"⧖ {texte}", flush=True) + ) + if not run_quality(lst): + print(quality.render_text(lst)) + input(f"💬 {t('press to continue')} : ") def action_toggle_log(self): self.show_log = not self.show_log - self._show() + show_pane(self) def action_cycle_panels(self): """Trois états, du plus complet au plus dépouillé. diff --git a/script/todo/todo.py b/script/todo/todo.py index ae148e7..4614ffa 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -7712,6 +7712,8 @@ class TODO: ) }, {"prompt_description": t("Studio and hand-made x_ fields")}, + {"section": t("Migration")}, + {"prompt_description": t("Quality of a migration, step by step")}, ] help_info = self.fill_help_info(choices) @@ -7726,9 +7728,41 @@ class TODO: self.execute_analyse_view_custom() elif status == "3": self.execute_analyse_custom_field() + elif status == "4": + self.execute_analyse_migration_quality() else: print(t("Command not found !")) + def execute_analyse_migration_quality(self): + """Ce qu'une migration a gagné et perdu, palier par palier. + + Appelé comme les autres analyses — même interpréteur, aucun + sous-processus — mais l'écran plein est ouvert par l'outil + lui-même, qui sait retomber sur son rapport texte s'il ne peut pas. + + Lecture seule de bout en bout : les bases de palier sont parfois la + seule copie qui reste d'un état intermédiaire. + """ + from script.analyse import check_migration_quality as quality + + dct = quality.read_progression() + if not quality.chain(dct): + print(f"\nℹ️ {t('No migration in progress.')}") + return + try: + lst = quality.survey( + dct, echo=lambda texte: print(f"⧖ {texte}", flush=True) + ) + except Exception as exc: + print(f"❌ {t('Analysis failed: ')}{exc}") + return + try: + from script.analyse.check_migration_quality_tui import run_tui + except Exception: + run_tui = None + if not (run_tui and run_tui(lst)): + print(quality.render_text(lst)) + def _analyse_select_source(self): """(est_une_sauvegarde, cible), ou None si l'on renonce. diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index f11e2e9..3ddea51 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -5691,6 +5691,62 @@ TRANSLATIONS = { "fr": "détail seul", "en": "detail only", }, + "errors in the log": { + "fr": "erreurs dans le journal", + "en": "errors in the log", + }, + "Migration quality, step by step": { + "fr": "Qualité de la migration, palier par palier", + "en": "Migration quality, step by step", + }, + "database not found": { + "fr": "base introuvable", + "en": "database not found", + }, + "attachment files missing from the filestore": { + "fr": "fichiers de pièces jointes absents du filestore", + "en": "attachment files missing from the filestore", + }, + "From start to finish": { + "fr": "Du départ à l'arrivée", + "en": "From start to finish", + }, + "not comparable: a database is missing": { + "fr": "non comparable : une base manque", + "en": "not comparable: a database is missing", + }, + "probable table rename(s)": { + "fr": "renommage(s) de table probable(s)", + "en": "probable table rename(s)", + }, + "table(s) lost rows": { + "fr": "table(s) ont perdu des lignes", + "en": "table(s) lost rows", + }, + "attachments": { + "fr": "pièces jointes", + "en": "attachments", + }, + "probably renamed to": { + "fr": "probablement renommée en", + "en": "probably renamed to", + }, + "steps": { + "fr": "paliers", + "en": "steps", + }, + "Migration": { + "fr": "Migration", + "en": "Migration", + }, + "Quality of a migration, step by step": { + "fr": "Qualité d'une migration, palier par palier", + "en": "Quality of a migration, step by step", + }, + "Quality": { + "fr": "Qualité", + "en": "Quality", + }, "Clean the database before testing the pages?": { "fr": "Nettoyer la base avant de tester les pages ?", "en": "Clean the database before testing the pages?", diff --git a/test/test_check_migration_quality.py b/test/test_check_migration_quality.py new file mode 100644 index 0000000..fd26953 --- /dev/null +++ b/test/test_check_migration_quality.py @@ -0,0 +1,399 @@ +#!/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'une migration gagne et ce qu'elle perd, palier par palier. + +La question qu'on se pose après six paliers n'est pas « a-t-elle fini » — +le journal le dit — mais « qu'est-ce qui a changé en chemin ». Une +migration laisse une base PAR PALIER, et elles existent toutes encore : on +les compare côte à côte plutôt que de rejouer quoi que ce soit. + +Le point le plus délicat est le rapprochement des tables renommées. Deux +garde-fous ont été essayés et rejetés SUR UNE VRAIE MIGRATION avant celui +qui tient, et un faux rapprochement ne serait pas une coquetterie : il +ferait DISPARAÎTRE une perte réelle du rapport. D'où la règle qui compte +plus que tout ici — une perte est toujours listée, jamais retirée. +""" + +import io +import os +import sys +import unittest +from contextlib import redirect_stdout + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(REPO, "script", "analyse")) + +from script.analyse import check_migration_quality as quality # noqa: E402 +from script.analyse import check_migration_quality_tui as qtui # noqa: E402 +from script.todo import todo_i18n # noqa: E402 + + +def snapshot(**override): + etat = { + "database": "db", + "exists": True, + "odoo": "13.0", + "version": 13, + "installed": ["account", "sale"], + "module": {"account": "installed", "sale": "installed"}, + "model": ["account.move", "sale.order"], + "table": {"account_move": 100, "sale_order": 20}, + "model_without_table": [], + "view": 2000, + "view_cow": 40, + "menu": 400, + "action": 300, + "attachment": 5000, + "attachment_stored": 4000, + "attachment_missing": 0, + "language": 2, + } + etat.update(override) + return etat + + +class Base(unittest.TestCase): + def setUp(self): + self.addCleanup( + setattr, todo_i18n, "_current_lang", todo_i18n._current_lang + ) + todo_i18n._current_lang = "en" + + +class TestFindingTheChain(Base): + """Les bases se déduisent de ce que la migration a ÉCRIT. + + Le pilote nomme ses bases de palier « _upgrade_ » et + retient sa cible. Redemander ces noms serait redemander ce qu'on sait. + """ + + PROGRESSION = { + "config_database_name": "test_neutralize", + "target_odoo_version": "18.0", + "state_4_upgrade_odoo_lst": [[], [], [], [], [], []], + } + + def test_it_walks_from_the_start_to_the_target(self): + lst = quality.chain(self.PROGRESSION) + self.assertEqual( + [version for version, _db in lst], [12, 13, 14, 15, 16, 17, 18] + ) + + def test_the_first_one_is_the_origin_database(self): + # Le palier 12 n'a pas de base « _upgrade_12 » : c'est la base + # d'origine, celle qu'on a restaurée. + self.assertEqual( + quality.chain(self.PROGRESSION)[0][1], "test_neutralize" + ) + + def test_the_others_follow_the_naming_of_the_driver(self): + noms = [db for _v, db in quality.chain(self.PROGRESSION)] + self.assertIn("test_neutralize_upgrade_13", noms) + self.assertIn("test_neutralize_upgrade_18", noms) + + def test_a_migration_not_started_yields_the_origin_alone(self): + lst = quality.chain( + {"config_database_name": "db", "target_odoo_version": "18.0"} + ) + self.assertEqual(lst, [(None, "db")]) + + def test_no_progression_yields_nothing(self): + self.assertEqual(quality.chain({}), []) + + +class TestWhatIsGainedAndLost(Base): + def test_a_module_uninstalled_is_a_loss(self): + diff = quality.compare( + snapshot(installed=["account", "sale"]), + snapshot(installed=["account"]), + ) + self.assertEqual(diff["modules_lost"], ["sale"]) + self.assertEqual(diff["modules_gained"], []) + + def test_a_model_that_appears_is_a_gain(self): + diff = quality.compare( + snapshot(model=["account.move"]), + snapshot(model=["account.move", "account.edi"]), + ) + self.assertEqual(diff["models_gained"], ["account.edi"]) + + def test_a_table_that_empties_is_reported(self): + # LE signal : un module en moins se voit, une table qui passe de + # cent lignes à zéro ne se voit nulle part. + diff = quality.compare( + snapshot(table={"account_invoice": 651}), + snapshot(table={"account_invoice": 0}), + ) + self.assertEqual(diff["rows_lost"], [("account_invoice", 651, 0)]) + + def test_a_table_that_disappears_counts_as_emptied(self): + diff = quality.compare( + snapshot(table={"account_invoice": 651}), snapshot(table={}) + ) + self.assertEqual(diff["rows_lost"], [("account_invoice", 651, 0)]) + + def test_an_empty_table_that_disappears_is_not_a_loss(self): + # Rien à perdre : le signaler noierait les vraies pertes. + diff = quality.compare(snapshot(table={"vide": 0}), snapshot(table={})) + self.assertEqual(diff["rows_lost"], []) + + def test_counts_move_with_their_sign(self): + diff = quality.compare(snapshot(view=2000), snapshot(view=3733)) + self.assertEqual(diff["delta"]["view"], 1733) + + def test_a_missing_database_is_not_comparable(self): + diff = quality.compare(snapshot(), {"exists": False}) + self.assertTrue(diff["unavailable"]) + + +class TestNotCryingWolfOnRenames(Base): + """Deux garde-fous rejetés sur une VRAIE migration avant celui-ci.""" + + def test_the_row_count_alone_is_not_enough(self): + # Il accouplait `account_account_tag_account_tax_template_rel` à + # `dms_directory` : les deux comptaient sept lignes. + self.assertFalse( + quality.looks_renamed( + "account_account_tag_account_tax_template_rel", "dms_directory" + ) + ) + + def test_a_shared_word_is_not_enough_either(self): + # « cleanup » ne dit rien, « users » non plus. + self.assertFalse( + quality.looks_renamed( + "cleanup_purge_wizard_menu", "cleanup_create_indexes_line" + ) + ) + self.assertFalse( + quality.looks_renamed( + "digest_tip_res_users_rel", "project_allowed_portal_users_rel" + ) + ) + + def test_real_renames_are_recognised(self): + for un, deux in ( + ("muk_dms_directory", "dms_directory"), + ("website_redirect", "website_rewrite"), + ( + "account_invoice_purchase_order_rel", + "account_move_purchase_order_rel", + ), + ): + self.assertTrue(quality.looks_renamed(un, deux), (un, deux)) + + def test_a_rename_needs_the_same_row_count(self): + # Un nom qui se ressemble mais un compte qui change n'est pas un + # renommage : c'est un renommage ET une perte, qu'on ne fusionne pas. + diff = quality.compare( + snapshot(table={"muk_dms_directory": 10}), + snapshot(table={"dms_directory": 4}), + ) + self.assertEqual(diff["renamed"], []) + + def test_a_renamed_table_is_STILL_listed_as_lost(self): + """La règle qui compte plus que tout ici. + + Retirer une perte parce qu'on croit à un renommage, c'est faire + disparaître du rapport ce qu'on est venu y chercher — et le + rapprochement s'est déjà trompé. + """ + diff = quality.compare( + snapshot(table={"muk_dms_directory": 7}), + snapshot(table={"dms_directory": 7}), + ) + self.assertEqual( + diff["renamed"], [("muk_dms_directory", "dms_directory", 7)] + ) + self.assertIn(("muk_dms_directory", 7, 0), diff["rows_lost"]) + + def test_the_report_says_it_is_only_probable(self): + diff = quality.compare( + snapshot(table={"muk_dms_directory": 7}), + snapshot(table={"dms_directory": 7}), + ) + texte = "\n".join(quality.render_compare(diff, colour=False)) + self.assertIn("muk_dms_directory", texte) + self.assertIn("probably renamed", texte) + + +class TestTheOverallReport(Base): + def test_it_compares_the_ENDS_not_the_sum_of_steps(self): + """Un module retiré en 15 puis remis en 17 n'a rien perdu. + + L'addition des comparaisons deux à deux le compterait deux fois, + une en perte et une en gain, et le bilan mentirait dans les deux + sens à la fois. + """ + lst = [ + snapshot(odoo="12.0", installed=["a", "b"]), + snapshot(odoo="15.0", installed=["a"]), + snapshot(odoo="18.0", installed=["a", "b"]), + ] + bilan = quality.overall(lst) + self.assertEqual(bilan["modules_lost"], []) + self.assertEqual(bilan["modules_gained"], []) + + def test_it_needs_two_databases(self): + self.assertTrue(quality.overall([snapshot()])["unavailable"]) + + def test_missing_databases_are_skipped_not_fatal(self): + lst = [ + snapshot(odoo="12.0", installed=["a"]), + {"database": "absente", "exists": False}, + snapshot(odoo="18.0", installed=["a", "b"]), + ] + self.assertEqual(quality.overall(lst)["modules_gained"], ["b"]) + + +class TestTheReportItself(Base): + def test_every_step_is_listed(self): + texte = quality.render_text( + [ + snapshot(odoo="12.0", database="a"), + snapshot(odoo="13.0", database="b"), + ], + colour=False, + ) + self.assertIn("12.0", texte) + self.assertIn("13.0", texte) + + def test_a_missing_database_is_named_not_hidden(self): + texte = quality.render_text( + [snapshot(), {"database": "absente", "exists": False}], + colour=False, + ) + self.assertIn("absente", texte) + self.assertIn("not found", texte) + + def test_missing_attachment_files_are_surfaced(self): + # La trouvaille faite à la main sur une vraie migration : 254 + # fichiers absents du filestore, que rien ne signalait. + texte = quality.render_text( + [snapshot(attachment_missing=254)], colour=False + ) + self.assertIn("254", texte) + + def test_it_ends_with_the_start_to_finish_comparison(self): + texte = quality.render_text( + [snapshot(odoo="12.0"), snapshot(odoo="18.0")], colour=False + ) + self.assertIn("From start to finish", texte) + self.assertLess(texte.index("12.0"), texte.index("From start")) + + +class TestItNeverWrites(Base): + """Les bases de palier sont parfois la seule copie d'un état.""" + + def test_the_connection_is_read_only_at_the_server(self): + import inspect + + source = inspect.getsource(quality.run_psql) + self.assertIn("default_transaction_read_only=on", source) + + def test_no_odoo_is_started(self): + # Six démarrages coûteraient une heure ET écriraient dans les + # bases. L'inspection en SQL prend moins d'une demi-seconde. + import inspect + + source = inspect.getsource(quality) + for interdit in ("odoo_bin", "run.sh", "--update", "-u all"): + self.assertNotIn(interdit, source, interdit) + + +class TestTheFullScreen(Base): + def test_one_row_per_step_plus_the_overall(self): + lst = [snapshot(odoo="12.0"), snapshot(odoo="18.0")] + lignes = qtui.rows(lst) + self.assertEqual( + [x["kind"] for x in lignes], ["step", "step", "overall"] + ) + + def test_the_overall_comes_LAST(self): + # On descend la liste comme on a vécu la migration ; « qu'en + # reste-t-il » se pose une fois le chemin vu. + lst = [snapshot(odoo="12.0"), snapshot(odoo="18.0")] + self.assertEqual(qtui.rows(lst)[-1]["kind"], "overall") + + def test_a_missing_database_gets_its_own_row(self): + lignes = qtui.rows( + [snapshot(), {"database": "absente", "exists": False}] + ) + self.assertIn("missing", [x["kind"] for x in lignes]) + + def test_the_column_counts_the_lost_tables(self): + lst = [ + snapshot(odoo="12.0", table={"t": 100}), + snapshot(odoo="13.0", table={"t": 0}), + ] + self.assertEqual(qtui.rows(lst)[1]["detail"], "1") + + def test_a_step_that_lost_nothing_leaves_it_empty(self): + lst = [snapshot(odoo="12.0"), snapshot(odoo="13.0")] + self.assertEqual(qtui.rows(lst)[1]["detail"], "") + + def test_the_pane_uses_the_same_comparison_as_the_text(self): + import inspect + + self.assertIn( + "quality.render_compare", inspect.getsource(qtui.pane_text) + ) + + def test_a_pipe_is_explained_rather_than_silent(self): + out = io.StringIO() + with redirect_stdout(out): + self.assertFalse(qtui.run_tui([snapshot()])) + self.assertTrue(out.getvalue().strip()) + + def test_nothing_to_show_stays_silent(self): + out = io.StringIO() + with redirect_stdout(out): + self.assertFalse(qtui.run_tui([])) + self.assertEqual(out.getvalue(), "") + + +class TestWhereItIsOffered(Base): + def test_the_analyse_menu_offers_it(self): + chemin = os.path.join(REPO, "script", "todo", "todo.py") + with open(chemin) as handle: + texte = handle.read() + self.assertIn("Quality of a migration, step by step", texte) + self.assertIn("execute_analyse_migration_quality", texte) + + def test_the_existing_menu_numbers_did_not_move(self): + # Quelqu'un connaît « 1 », « 2 », « 3 » : les décaler pour insérer + # une entrée au milieu se paierait à chaque usage. + import inspect + + from script.todo.todo import TODO + + source = inspect.getsource(TODO.prompt_execute_analyse) + self.assertLess( + source.index("Tables and database size"), + source.index("Quality of a migration"), + ) + + def test_the_state_screen_offers_it_too(self): + import inspect + + from script.todo import migration_status_tui as stui + + source = inspect.getsource(stui.build_app) + self.assertIn('"k", "quality"', source) + self.assertIn("check_migration_quality", source) + + def test_it_hands_the_terminal_over(self): + # Deux applications Textual ne peuvent pas peindre le même écran. + import inspect + + from script.todo import migration_status_tui as stui + + source = inspect.getsource(stui.build_app) + debut = source.index("def action_quality") + self.assertIn("self.suspend()", source[debut : debut + 1200]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_migration_status.py b/test/test_migration_status.py index a724295..53ccef3 100644 --- a/test/test_migration_status.py +++ b/test/test_migration_status.py @@ -953,7 +953,9 @@ class TestTheFullScreenColoursToo(Base): def test_the_screen_goes_through_from_ansi(self): import inspect - source = inspect.getsource(tui.build_app) + # Le rendu est sorti de la classe pour la rendre testable : c'est + # `show_pane` qui porte maintenant les deux propriétés. + source = inspect.getsource(tui.show_pane) self.assertIn("Text.from_ansi", source) self.assertIn("colour=True", source) From 0fdfeaa9ce5aeb57125e935fdbc082eb1692f471 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 19 Aug 2026 10:52:49 -0400 Subject: [PATCH 54/67] [FIX] migration state: open the quality screen in its own process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pressing « k » crashed with « asyncio.run() cannot be called from a running event loop ». `suspend()` hands the terminal over but does NOT stop the loop, and `app.run()` calls `asyncio.run()` underneath. A subprocess has its own loop and paints the terminal it was given. My test asserted that suspend() was CALLED, and that structure was correct — the launch inside it was not. Structure is not behaviour, and the test that would have caught this is the one that presses the key. It exists now, and a guard refuses the nested start with a sentence instead of forty lines of traceback. The screen module also lost its shebang: it has no main, so the line claimed something that was not true, and the permission rule was right to say so. --- FR --- [FIX] état de migration : ouvrir l'écran de qualité dans son processus « k » plantait sur « asyncio.run() cannot be called from a running event loop ». `suspend()` rend le terminal mais n'arrête PAS la boucle, et `app.run()` appelle `asyncio.run()` en dessous. Un sous-processus a sa propre boucle. Mon test vérifiait que suspend() était APPELÉ — et cette structure était juste ; c'est le lancement à l'intérieur qui ne l'était pas. La structure n'est pas le comportement, et le test qui aurait attrapé cela est celui qui presse la touche. Il existe désormais, et un garde-fou refuse le lancement imbriqué par une phrase plutôt que par quarante lignes de trace. Le module d'écran perd aussi son shebang : sans `main`, cette ligne annonçait une intention qui n'existait pas. Assisted-by: Claude Opus 5 --- script/analyse/check_migration_quality_tui.py | 22 +++++++- script/todo/migration_status_tui.py | 35 ++++++------ script/todo/todo_i18n.py | 8 +++ test/test_check_migration_quality.py | 53 ++++++++++++++++++- 4 files changed, 97 insertions(+), 21 deletions(-) diff --git a/script/analyse/check_migration_quality_tui.py b/script/analyse/check_migration_quality_tui.py index 1f570a2..dd8d5a9 100644 --- a/script/analyse/check_migration_quality_tui.py +++ b/script/analyse/check_migration_quality_tui.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python3 # © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) @@ -221,5 +220,26 @@ def run_tui(lst_snapshot, run_app=True): return False if not run_app: return app + if in_event_loop(): + # Filet de sécurité : `app.run()` appelle `asyncio.run()`, qui + # lève dans une boucle déjà en cours. Le dire vaut mieux que la + # trace de quarante lignes que cela produit — et l'appelant doit + # passer par un sous-processus. + print( + f"ℹ️ {t('Already inside a running screen: open it in its own')}" + f" {t('process instead.')}" + ) + return False app.run() return True + + +def in_event_loop(): + """Une boucle asyncio tourne-t-elle déjà dans CE processus ?""" + import asyncio + + try: + asyncio.get_running_loop() + except RuntimeError: + return False + return True diff --git a/script/todo/migration_status_tui.py b/script/todo/migration_status_tui.py index 8311fb4..9c54888 100644 --- a/script/todo/migration_status_tui.py +++ b/script/todo/migration_status_tui.py @@ -339,30 +339,27 @@ def build_app(dct, path=None): """Ouvrir le rapport de qualité, sans quitter celui-ci. Les deux écrans répondent à deux questions voisines : « où en - est-on » et « qu'a-t-on gagné ou perdu en chemin ». Les - séparer par une touche plutôt que par deux commandes à retenir - est ce qui les rend utilisables ensemble. + est-on » et « qu'a-t-on gagné ou perdu en chemin ». - `suspend` rend le terminal à l'autre plein écran : deux - applications Textual ne peuvent pas peindre le même écran en - même temps. + En SOUS-PROCESSUS, et ce n'est pas un choix de confort : + `app.run()` appelle `asyncio.run()`, qui refuse de tourner + dans une boucle déjà en cours — et nous sommes justement + dedans. Mesuré, la trace est + « asyncio.run() cannot be called from a running event loop ». + `suspend()` rend le terminal ; le sous-processus a sa propre + boucle et le repeint. """ - from script.analyse import check_migration_quality as quality - from script.analyse.check_migration_quality_tui import ( - run_tui as run_quality, - ) + import subprocess - dct = quality.read_progression() - if not quality.chain(dct): - self.notify(t("No migration in progress.")) - return with self.suspend(): - lst = quality.survey( - dct, echo=lambda texte: print(f"⧖ {texte}", flush=True) + subprocess.call( + [ + sys.executable, + os.path.join( + "script", "analyse", "check_migration_quality.py" + ), + ] ) - if not run_quality(lst): - print(quality.render_text(lst)) - input(f"💬 {t('press to continue')} : ") def action_toggle_log(self): self.show_log = not self.show_log diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 3ddea51..03f72e4 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -5747,6 +5747,14 @@ TRANSLATIONS = { "fr": "Qualité", "en": "Quality", }, + "Already inside a running screen: open it in its own": { + "fr": "Déjà dans un écran en cours : l'ouvrir dans son propre", + "en": "Already inside a running screen: open it in its own", + }, + "process instead.": { + "fr": "processus.", + "en": "process instead.", + }, "Clean the database before testing the pages?": { "fr": "Nettoyer la base avant de tester les pages ?", "en": "Clean the database before testing the pages?", diff --git a/test/test_check_migration_quality.py b/test/test_check_migration_quality.py index fd26953..eb264e6 100644 --- a/test/test_check_migration_quality.py +++ b/test/test_check_migration_quality.py @@ -392,7 +392,58 @@ class TestWhereItIsOffered(Base): source = inspect.getsource(stui.build_app) debut = source.index("def action_quality") - self.assertIn("self.suspend()", source[debut : debut + 1200]) + self.assertIn("self.suspend()", source[debut : debut + 1400]) + + def test_it_opens_it_in_its_OWN_process(self): + """Le défaut que « suspend est appelé » ne suffisait pas à attraper. + + `suspend()` rend le terminal mais n'arrête PAS la boucle asyncio. + `app.run()` appelle `asyncio.run()`, qui refuse de tourner dans une + boucle déjà en cours : « asyncio.run() cannot be called from a + running event loop ». Un sous-processus a sa propre boucle. + """ + import inspect + + from script.todo import migration_status_tui as stui + + source = inspect.getsource(stui.build_app) + debut = source.index("def action_quality") + fenetre = source[debut : debut + 1400] + self.assertIn("subprocess.call", fenetre) + self.assertNotIn("run_quality(", fenetre) + + +class TestItRefusesToNestItself(Base): + """Le filet de sécurité, pour qui rappellerait `run_tui` de l'intérieur. + + Une trace de quarante lignes n'apprend rien ; une phrase qui dit + d'ouvrir un processus à part, si. + """ + + def test_no_loop_running_is_the_normal_case(self): + self.assertFalse(qtui.in_event_loop()) + + def test_a_running_loop_is_detected(self): + import asyncio + + async def dedans(): + return qtui.in_event_loop() + + self.assertTrue(asyncio.run(dedans())) + + def test_run_tui_consults_it_before_starting(self): + import inspect + + source = inspect.getsource(qtui.run_tui) + self.assertLess( + source.index("in_event_loop()"), source.index("app.run()") + ) + + def test_the_refusal_says_what_to_do_instead(self): + import inspect + + source = inspect.getsource(qtui.run_tui) + self.assertIn("its own", source) if __name__ == "__main__": From 37c9f216def773268f9059e2981c2c2c845870b0 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 20 Aug 2026 01:52:35 -0400 Subject: [PATCH 55/67] [ADD] migration quality: name the missing files, and show every delta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit « 254 attachment files missing » does not say which. « m » now lists them, grouped by model AND FIELD — the field is the most useful thing in the row, because it says which field lost its image. On a real migration it splits into 234 res.country/image flags, 13 payment.provider logos, and a handful of scattered attachments including one 255 kB event photo: the first two are module data a reinstall restores, the last is not, and the raw list put them on the same footing. The metadata is read ONLY when asked. One more query per database would lengthen a survey that runs in four seconds, for something one looks at by asking for it. And every figure of a step now carries its change against the previous one. « 2283 views » is a number; « +61 » is information. The first step gets none: inventing a zero there would claim a comparison that does not exist. --- FR --- [ADD] qualité de migration : nommer les fichiers absents, et montrer les écarts « 254 fichiers de pièces jointes absents » ne dit pas lesquels. « m » en donne la liste, groupée par modèle ET PAR CHAMP — le champ est le renseignement le plus utile de la ligne, car il dit lequel a perdu son image. Sur une vraie migration : 234 drapeaux res.country/image, 13 logos de fournisseurs de paiement, et une poignée de pièces jointes éparses dont une photo d'événement de 255 ko. Les deux premiers groupes sont des données de module qu'une réinstallation restaure, le dernier non. Les métadonnées ne sont lues QU'À LA DEMANDE : une requête de plus par base allongerait un parcours qui tient en quatre secondes. Et chaque chiffre d'un palier porte son écart avec le précédent. « 2283 vues » est un nombre, « +61 » est une information. Le premier palier n'en a pas : y inventer un zéro annoncerait une comparaison qui n'existe pas. Assisted-by: Claude Opus 5 --- script/analyse/check_migration_quality.py | 104 ++++++++ script/analyse/check_migration_quality_tui.py | 110 +++++++-- script/todo/todo_i18n.py | 28 +++ test/test_check_migration_quality.py | 228 ++++++++++++++++++ 4 files changed, 454 insertions(+), 16 deletions(-) diff --git a/script/analyse/check_migration_quality.py b/script/analyse/check_migration_quality.py index ff08063..bfdb363 100755 --- a/script/analyse/check_migration_quality.py +++ b/script/analyse/check_migration_quality.py @@ -226,9 +226,113 @@ def inspect(database): ) absents = missing_files(database, [ligne[0] for ligne in stockees or []]) etat["attachment_missing"] = None if absents is None else len(absents) + # Les NOMS, pas seulement le compte : sans eux, « 254 fichiers + # absents » ne dit pas lesquels, et l'on ne peut ni juger de la + # gravité ni retrouver ce qui a disparu. Bornés, car une base peut en + # aligner des dizaines de milliers et l'écran n'en montrera jamais tant. + etat["attachment_missing_list"] = (absents or [])[:MAX_MISSING] return etat +MAX_MISSING = 5000 + + +def missing_detail(database, lst_store_fname, limit=400): + """Les métadonnées des pièces jointes dont le fichier a disparu. + + À la DEMANDE, jamais pendant l'inspection : une requête de plus par + base allongerait un parcours qui tient aujourd'hui en quatre secondes, + pour une information qu'on ne regarde qu'en la demandant. + + `res_field` est le renseignement le plus utile du lot : il dit QUEL + champ a perdu son image — l'`image_1920` d'un pays n'a pas le même + poids qu'une pièce jointe de facture. + """ + if not lst_store_fname: + return [] + lst = list(lst_store_fname)[:limit] + valeurs = ", ".join("'" + nom.replace("'", "''") + "'" for nom in lst) + lignes = run_psql( + database, + "SELECT store_fname, coalesce(res_model, '-')," + " coalesce(res_field, '-'), coalesce(res_id::text, '-')," + " coalesce(mimetype, '-'), coalesce(file_size::text, '0')," + " coalesce(name, '-')" + f" FROM ir_attachment WHERE store_fname IN ({valeurs})" + " ORDER BY res_model, res_field, name", + ) + return [ + { + "store_fname": ligne[0], + "model": ligne[1], + "field": ligne[2], + "res_id": ligne[3], + "mimetype": ligne[4], + "size": int(ligne[5]) if ligne[5].isdigit() else 0, + "name": ligne[6], + } + for ligne in lignes or [] + if len(ligne) >= 7 + ] + + +def render_missing(etat, colour=False, limit=60): + """Ce qui manque, groupé d'abord, détaillé ensuite. + + Le groupement d'abord parce qu'il tranche : deux cent trente-trois + drapeaux de pays et treize logos de fournisseurs de paiement sont des + images livrées par les modules, qu'une mise à jour restaure. Huit + pièces jointes éparses, non. La liste brute mettait les deux sur le + même plan. + """ + from script.todo.migration_status import paint + + absents = etat.get("attachment_missing") or 0 + if not absents: + return f"✅ {t('every attachment file is present')}" + lignes = [ + paint( + f"❌ {absents}" + f" {t('attachment files missing from the filestore')}", + "fail", + colour, + ), + f" {etat['database']}", + "", + ] + detail = missing_detail( + etat["database"], etat.get("attachment_missing_list") or [] + ) + if not detail: + lignes.append(t("Could not read their metadata.")) + return "\n".join(lignes) + + groupe = {} + for item in detail: + cle = (item["model"], item["field"], item["mimetype"]) + groupe[cle] = groupe.get(cle, 0) + 1 + lignes.append(f"── {t('by model and field')} ──") + for (modele, champ, mime), nombre in sorted( + groupe.items(), key=lambda x: -x[1] + ): + lignes.append( + f" {nombre:>5} {paint(modele, 'step', colour):<34}" + f" {champ:<22} {mime}" + ) + lignes.append("") + lignes.append(f"── {t('one by one')} ──") + for item in detail[:limit]: + lignes.append( + f" {item['model']}#{item['res_id']}" + f" {paint(item['field'], 'dim', colour)}" + f" {item['size']:>8} o {item['name'][:44]}" + ) + lignes.append(f" {paint(item['store_fname'], 'cmd', colour)}") + if len(detail) > limit: + lignes.append(f" … {len(detail) - limit} {t('more')}") + return "\n".join(lignes) + + def compare(avant, apres): """Ce qui a été gagné et ce qui a été perdu entre deux paliers.""" if not avant.get("exists") or not apres.get("exists"): diff --git a/script/analyse/check_migration_quality_tui.py b/script/analyse/check_migration_quality_tui.py index dd8d5a9..fee2360 100644 --- a/script/analyse/check_migration_quality_tui.py +++ b/script/analyse/check_migration_quality_tui.py @@ -64,6 +64,9 @@ def rows(lst_snapshot): if rang: precedent = presents[rang - 1] diff = quality.compare(precedent, etat) if precedent else None + # Le précédent voyage avec la ligne : le panneau en a besoin pour + # écrire « 171 (−43) », et le recalculer là-bas ferait deux + # sources pour la même comparaison. perdu = ( 0 if diff is None or diff.get("unavailable") @@ -76,6 +79,7 @@ def rows(lst_snapshot): "detail": str(perdu) if perdu else "", "data": etat, "diff": diff, + "previous": precedent, } ) if len(presents) >= 2: @@ -107,34 +111,87 @@ def head_text(lst_snapshot): ) -def pane_text(lst_snapshot, row, colour=False): +def statistics(etat, precedent): + """[(libellé, valeur, écart)] pour un palier. L'écart est None au départ. + + Un chiffre seul ne dit rien : 2283 vues est un nombre, « +61 » est une + information. Le premier palier n'a pas de précédent, et inventer un + écart de zéro y laisserait croire à une comparaison qui n'existe pas. + """ + lst = [ + ( + t("modules"), + len(etat["installed"]), + None if not precedent else len(precedent["installed"]), + ), + ( + t("models"), + len(etat["model"]), + None if not precedent else len(precedent["model"]), + ), + ( + t("views"), + etat["view"], + None if not precedent else precedent["view"], + ), + ( + " · COW", + etat["view_cow"], + None if not precedent else precedent["view_cow"], + ), + ( + t("menus"), + etat["menu"], + None if not precedent else precedent["menu"], + ), + ( + t("attachments"), + etat["attachment"], + None if not precedent else precedent["attachment"], + ), + ] + return [ + (libelle, valeur, None if avant is None else valeur - avant) + for libelle, valeur, avant in lst + ] + + +def pane_text(lst_snapshot, row, colour=False, show_missing=False): """Le détail du palier choisi, ou le bilan d'ensemble.""" if row is None: return t("Nothing to show yet.") if row["kind"] == "missing": return f"⚠️ {row['data']['database']} : {t('database not found')}" - lignes = [] etat = row["data"] + if show_missing: + if not etat: + return t("Pick a step to see its missing files.") + return quality.render_missing(etat, colour) + lignes = [] if etat: lignes.append( status.paint(f"{etat['odoo']} {etat['database']}", "step", colour) ) lignes.append("") - for libelle, valeur in ( - (t("modules"), len(etat["installed"])), - (t("models"), len(etat["model"])), - (t("views"), etat["view"]), - (" · COW", etat["view_cow"]), - (t("menus"), etat["menu"]), - (t("attachments"), etat["attachment"]), - ): - lignes.append(f" {libelle:<28} {valeur:>7}") + for libelle, valeur, ecart in statistics(etat, row.get("previous")): + if ecart is None: + marque = "" + else: + marque = status.paint( + f"{ecart:+d}", "ok" if ecart >= 0 else "warn", colour + ) + lignes.append(f" {libelle:<28} {valeur:>7} {marque}") if etat.get("attachment_missing"): lignes.append( - f" {status.paint('❌ ' + t('attachment files missing from' - ' the filestore'), 'fail', colour)}" - f" {etat['attachment_missing']}" + " " + + status.paint( + f"❌ {etat['attachment_missing']}" + f" {t('attachment files missing from the filestore')}", + "fail", + colour, + ) ) + lignes.append(f" {t('press m to list them')}") lignes.append("") diff = row.get("diff") if diff: @@ -151,13 +208,17 @@ def build_app(lst_snapshot): class QualityApp(App): CSS = globals()["CSS"] - BINDINGS = [("q,escape", "quit", t("Quit"))] + BINDINGS = [ + ("q,escape", "quit", t("Quit")), + ("m", "toggle_missing", t("Missing files")), + ] def __init__(self, lst_snapshot): super().__init__() self.lst_snapshot = lst_snapshot self.lst_row = rows(lst_snapshot) self.index = 0 + self.show_missing = False def compose(self) -> ComposeResult: yield Header() @@ -186,9 +247,26 @@ def build_app(lst_snapshot): else None ) self.query_one("#content", Static).update( - Text.from_ansi(pane_text(self.lst_snapshot, row, colour=True)) + Text.from_ansi( + pane_text( + self.lst_snapshot, + row, + colour=True, + show_missing=self.show_missing, + ) + ) ) + def action_toggle_missing(self): + """Basculer entre les chiffres du palier et ses fichiers absents. + + Les métadonnées ne sont lues qu'ICI : une requête de plus par + base allongerait un parcours qui tient en quatre secondes, pour + une information qu'on ne regarde qu'en la demandant. + """ + self.show_missing = not self.show_missing + self._show() + def on_data_table_row_highlighted(self, event): if event.data_table.id == "left" and self.lst_row: self.index = event.cursor_row diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 03f72e4..58198f3 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -5755,6 +5755,34 @@ TRANSLATIONS = { "fr": "processus.", "en": "process instead.", }, + "Missing files": { + "fr": "Fichiers absents", + "en": "Missing files", + }, + "every attachment file is present": { + "fr": "tous les fichiers de pièces jointes sont présents", + "en": "every attachment file is present", + }, + "Could not read their metadata.": { + "fr": "Impossible de lire leurs métadonnées.", + "en": "Could not read their metadata.", + }, + "by model and field": { + "fr": "par modèle et par champ", + "en": "by model and field", + }, + "one by one": { + "fr": "un par un", + "en": "one by one", + }, + "press m to list them": { + "fr": "m pour en voir la liste", + "en": "press m to list them", + }, + "Pick a step to see its missing files.": { + "fr": "Choisissez un palier pour voir ses fichiers absents.", + "en": "Pick a step to see its missing files.", + }, "Clean the database before testing the pages?": { "fr": "Nettoyer la base avant de tester les pages ?", "en": "Clean the database before testing the pages?", diff --git a/test/test_check_migration_quality.py b/test/test_check_migration_quality.py index eb264e6..21a8c49 100644 --- a/test/test_check_migration_quality.py +++ b/test/test_check_migration_quality.py @@ -284,6 +284,234 @@ class TestTheReportItself(Base): self.assertLess(texte.index("12.0"), texte.index("From start")) +class TestTheStatisticsCarryTheirDelta(Base): + """Un chiffre seul ne dit rien. + + « 2283 vues » est un nombre ; « +61 » est une information. C'est + l'écart qu'on lit, pas la valeur. + """ + + def test_each_figure_gets_its_change(self): + lignes = qtui.statistics( + snapshot(view=2283, installed=["a"]), + snapshot(view=2222, installed=["a", "b"]), + ) + par_nom = {libelle: ecart for libelle, _v, ecart in lignes} + self.assertEqual(par_nom["views"], 61) + self.assertEqual(par_nom["modules"], -1) + + def test_the_first_step_has_NO_delta(self): + # Inventer un écart de zéro laisserait croire à une comparaison + # qui n'existe pas : il n'y a rien avant le premier palier. + lignes = qtui.statistics(snapshot(), None) + self.assertTrue(all(ecart is None for _l, _v, ecart in lignes)) + + def test_an_unchanged_figure_shows_zero_not_nothing(self): + # Zéro est une réponse : « rien n'a bougé » se distingue de « on + # ne sait pas ». + lignes = qtui.statistics(snapshot(view=100), snapshot(view=100)) + par_nom = {libelle: ecart for libelle, _v, ecart in lignes} + self.assertEqual(par_nom["views"], 0) + + def test_every_figure_of_the_pane_is_covered(self): + libelles = [ + libelle + for libelle, _v, _e in qtui.statistics(snapshot(), snapshot()) + ] + for attendu in ("modules", "models", "views", "menus", "attachments"): + self.assertIn(attendu, libelles) + + def test_the_pane_prints_the_sign(self): + row = { + "kind": "step", + "data": snapshot(view=2283), + "previous": snapshot(view=2222), + "diff": None, + } + texte = qtui.pane_text([], row) + self.assertIn("+61", texte) + + +class TestListingTheMissingFiles(Base): + """« 254 fichiers absents » ne dit pas lesquels. + + Le groupement tranche : deux cent trente-quatre drapeaux de pays sont + des images livrées par un module, qu'une mise à jour restaure. Une + pièce jointe d'événement de 255 ko, non. La liste brute mettait les + deux sur le même plan. + """ + + DETAIL = [ + { + "store_fname": "aa/1", + "model": "res.country", + "field": "image", + "res_id": "1", + "mimetype": "image/png", + "size": 100, + "name": "fr", + }, + { + "store_fname": "aa/2", + "model": "res.country", + "field": "image", + "res_id": "2", + "mimetype": "image/png", + "size": 100, + "name": "ca", + }, + { + "store_fname": "aa/4", + "model": "res.country", + "field": "image_128", + "res_id": "1", + "mimetype": "image/png", + "size": 50, + "name": "fr128", + }, + { + "store_fname": "bb/3", + "model": "calendar.event", + "field": "-", + "res_id": "1", + "mimetype": "image/png", + "size": 255603, + "name": "photo.png", + }, + ] + + def render(self, etat=None, **kw): + original = quality.missing_detail + quality.missing_detail = lambda db, lst, limit=400: self.DETAIL + self.addCleanup(setattr, quality, "missing_detail", original) + return quality.render_missing( + etat + or snapshot( + attachment_missing=3, + attachment_missing_list=["aa/1", "aa/2", "bb/3"], + ), + **kw, + ) + + def test_nothing_missing_says_so_plainly(self): + texte = quality.render_missing(snapshot(attachment_missing=0)) + self.assertIn("every attachment file is present", texte) + + def test_the_grouping_comes_first(self): + texte = self.render() + self.assertLess( + texte.index("by model and field"), texte.index("one by one") + ) + + def test_the_grouping_counts_by_model_AND_field(self): + """Le champ est le renseignement le plus utile du lot. + + Il dit QUEL champ a perdu son image : l'`image_1920` d'un pays n'a + pas le même poids qu'une pièce jointe de facture. Grouper sur le + seul modèle fondrait `image` et `image_128` en une ligne, et l'on + perdrait exactement ce qu'on venait chercher. + """ + entete = self.render().split("one by one")[0] + lignes = [ + ligne for ligne in entete.splitlines() if "res.country" in ligne + ] + self.assertEqual(len(lignes), 2, entete) + self.assertTrue(any("image_128" in ligne for ligne in lignes)) + + def test_the_loudest_group_comes_first(self): + texte = self.render().split("one by one")[0] + self.assertLess(texte.index("res.country"), texte.index("calendar")) + + def test_each_file_names_its_record_and_its_path(self): + texte = self.render() + self.assertIn("calendar.event#1", texte) + self.assertIn("bb/3", texte) + self.assertIn("photo.png", texte) + + def test_a_long_list_is_cut_and_SAYS_so(self): + texte = self.render(limit=1) + self.assertIn("more", texte) + + def test_unreadable_metadata_is_admitted(self): + original = quality.missing_detail + quality.missing_detail = lambda db, lst, limit=400: [] + self.addCleanup(setattr, quality, "missing_detail", original) + texte = quality.render_missing( + snapshot(attachment_missing=3, attachment_missing_list=["a"]) + ) + self.assertIn("Could not read", texte) + + def test_the_metadata_is_read_ONLY_on_demand(self): + # Une requête de plus par base allongerait un parcours qui tient + # en quatre secondes, pour ce qu'on ne regarde qu'en le demandant. + import inspect + + self.assertNotIn("missing_detail", inspect.getsource(quality.inspect)) + self.assertIn( + "missing_detail", inspect.getsource(quality.render_missing) + ) + + def test_the_names_are_kept_but_bounded(self): + # Une base peut en aligner des dizaines de milliers ; l'écran n'en + # montrera jamais tant, et les garder toutes coûterait pour rien. + import inspect + + source = inspect.getsource(quality.inspect) + self.assertIn("attachment_missing_list", source) + self.assertIn("MAX_MISSING", source) + + def test_a_quote_in_a_filename_cannot_break_the_query(self): + vu = {} + original = quality.run_psql + quality.run_psql = lambda db, sql: vu.setdefault("sql", sql) and [] + self.addCleanup(setattr, quality, "run_psql", original) + quality.missing_detail("db", ["aa/o'brien"]) + self.assertIn("o''brien", vu["sql"]) + + +class TestTheMissingFilesButton(Base): + def test_m_is_bound(self): + app = qtui.build_app([snapshot()]) + touches = { + touche + for entree in app.BINDINGS + for touche in entree[0].split(",") + } + self.assertIn("m", touches) + + def test_the_action_exists(self): + app = qtui.build_app([snapshot()]) + self.assertTrue(hasattr(app, "action_toggle_missing")) + + def test_the_pane_switches_to_the_list(self): + original = quality.missing_detail + quality.missing_detail = lambda db, lst, limit=400: [] + self.addCleanup(setattr, quality, "missing_detail", original) + row = { + "kind": "step", + "data": snapshot( + attachment_missing=3, attachment_missing_list=["a"] + ), + "previous": None, + "diff": None, + } + chiffres = qtui.pane_text([], row, show_missing=False) + liste = qtui.pane_text([], row, show_missing=True) + self.assertIn("modules", chiffres) + self.assertNotIn("modules", liste) + + def test_the_pane_tells_you_the_key_exists(self): + # Une touche que rien n'annonce est une touche que personne ne + # presse. + row = { + "kind": "step", + "data": snapshot(attachment_missing=254), + "previous": None, + "diff": None, + } + self.assertIn("press m", qtui.pane_text([], row)) + + class TestItNeverWrites(Base): """Les bases de palier sont parfois la seule copie d'un état.""" From 951b88abab5eb46215dd8d828d9c59d7e5cd5f51 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 20 Aug 2026 02:08:15 -0400 Subject: [PATCH 56/67] [FIX] todo analyse: give the migration entry its icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An entry with no icon, in a menu where every other one has one, reads as unfinished. The section gets 🚚 and the entry 📐 — the same symbol the quality report already wears in its own title, because it is the same thing. The spacing follows a convention this menu keeps without stating it: an icon of « neutral » width — 🗄, 🖼 — occupies one column in a terminal and takes TWO spaces to line up, while a wide one — 📏, 📐 — takes one. Getting it wrong shifts the line, and nothing says so until it is under your eyes. A test now states it. --- FR --- [FIX] todo analyse : donner son icône à l'entrée de migration Une entrée sans icône, dans un menu où toutes les autres en ont une, se lit comme une entrée inachevée. La section reçoit 🚚 et l'entrée 📐 — le symbole que le rapport de qualité porte déjà dans son propre titre, puisque c'est la même chose. L'espacement suit une convention que ce menu tient sans la dire : une icône de largeur « neutre » — 🗄, 🖼 — s'affiche sur une colonne dans un terminal et prend DEUX espaces pour s'aligner, une icône large — 📏, 📐 — un seul. Se tromper décale la ligne, et rien ne le signale avant de l'avoir sous les yeux. Un test l'énonce désormais. Assisted-by: Claude Opus 5 --- script/todo/todo_i18n.py | 8 ++-- test/test_check_migration_quality.py | 69 ++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 58198f3..aa3218e 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -5736,12 +5736,12 @@ TRANSLATIONS = { "en": "steps", }, "Migration": { - "fr": "Migration", - "en": "Migration", + "fr": "🚚 Migration", + "en": "🚚 Migration", }, "Quality of a migration, step by step": { - "fr": "Qualité d'une migration, palier par palier", - "en": "Quality of a migration, step by step", + "fr": "📐 Qualité d'une migration, palier par palier", + "en": "📐 Quality of a migration, step by step", }, "Quality": { "fr": "Qualité", diff --git a/test/test_check_migration_quality.py b/test/test_check_migration_quality.py index 21a8c49..b783271 100644 --- a/test/test_check_migration_quality.py +++ b/test/test_check_migration_quality.py @@ -512,6 +512,75 @@ class TestTheMissingFilesButton(Base): self.assertIn("press m", qtui.pane_text([], row)) +class TestTheMenuEntryLooksLikeItsNeighbours(Base): + """Une entrée sans icône au milieu d'un menu qui en a partout se lit + comme une entrée inachevée.""" + + ENTREES = ( + "Structure", + "Tables and database size", + "Customisation", + "Customised views, website copies included", + "Studio and hand-made x_ fields", + "Migration", + "Quality of a migration, step by step", + ) + + def libelle(self, cle, langue): + from script.todo import todo_i18n + + return todo_i18n.TRANSLATIONS[cle][langue] + + def test_every_entry_carries_an_icon(self): + import unicodedata + + for cle in self.ENTREES: + for langue in ("fr", "en"): + premier = self.libelle(cle, langue)[0] + self.assertNotEqual( + unicodedata.category(premier)[0], + "L", + f"{cle} ({langue}) : pas d'icône", + ) + + def test_the_spacing_follows_the_width_of_the_icon(self): + """Convention du menu, découverte en la lisant plutôt qu'écrite. + + Une icône de largeur « neutre » — 🗄, 🖼 — s'affiche sur une + colonne dans un terminal et prend DEUX espaces pour s'aligner ; + une icône large — 📏, 📐 — en prend un seul. Se tromper décale la + ligne, et rien ne le dit avant de l'avoir sous les yeux. + """ + import unicodedata + + for cle in self.ENTREES: + for langue in ("fr", "en"): + texte = self.libelle(cle, langue) + attendu = ( + 2 if unicodedata.east_asian_width(texte[0]) == "N" else 1 + ) + espaces = len(texte[1:]) - len(texte[1:].lstrip(" ")) + self.assertEqual( + espaces, attendu, f"{cle} ({langue}) : {texte!r}" + ) + + def test_the_quality_entry_wears_the_report_s_own_symbol(self): + # Le même symbole pour la même chose. Un libellé de MENU porte + # son icône dans la traduction — elle se traduit avec lui. + self.assertTrue( + self.libelle( + "Quality of a migration, step by step", "fr" + ).startswith("📐") + ) + # Le titre du rapport, lui, porte son symbole dans le CODE de + # rendu, à côté de ses voisins 📍, 🧪 et 🔷 : deux conventions + # distinctes, chacune tenable dans son contexte, et l'on vérifie + # chacune là où elle vit. + import inspect + + self.assertIn("📐", inspect.getsource(quality.render_text)) + + class TestItNeverWrites(Base): """Les bases de palier sont parfois la seule copie d'un état.""" From e75bc585baf88b0c591b4d2ffc9cec318c110bdd Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 20 Aug 2026 02:42:18 -0400 Subject: [PATCH 57/67] [ADD] migration quality: tell Odoo's own redesigns from real losses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit « 81 tables lost rows » buried the questions worth asking. The largest of them — ir_translation, 32 984 rows — is a redesign Odoo made in 16, when translations moved into jsonb columns. Putting redesigns and real losses on the same footing is the surest way not to see the second kind. A map now names what Odoo moves or retires, and the report splits the list in two: unexplained first, then what Odoo did on purpose. On a real 12 → 18 the split is 57 and 14, and the partition is exact — an explained loss is annotated, never removed, because removing it was the one thing that could hide a real one. Every entry was VERIFIED on that migration by counting both sides, not written from memory: crm_lead_tag 6 → crm_tag +6, mail_channel 26 → discuss_channel +26, account_invoice 651 → account_move +441. A map written from memory would explain losses that are not losses. And a merge whose target gained nothing says so: the explanation does not hold, and that is exactly what the map was meant to surface. --- FR --- [ADD] qualité de migration : distinguer les refontes d'Odoo des vraies pertes « 81 tables ont perdu des lignes » enterrait les questions qui valaient la peine. La plus grosse — ir_translation, 32 984 lignes — est une refonte d'Odoo 16, quand les traductions sont passées en colonnes jsonb. Une carte nomme désormais ce qu'Odoo déplace ou retire, et le rapport sépare la liste en deux : l'inexpliqué d'abord, puis ce qu'Odoo a fait exprès. Sur une vraie 12 → 18 : 57 et 14, et la partition est exacte — une perte expliquée est annotée, jamais retirée. Chaque entrée a été VÉRIFIÉE sur cette migration en comptant les deux côtés : crm_lead_tag 6 → crm_tag +6, mail_channel 26 → discuss_channel +26, account_invoice 651 → account_move +441. Et une fusion dont la table d'accueil n'a rien reçu le dit : l'explication ne tient pas, et c'est précisément ce que la carte devait révéler. Assisted-by: Claude Opus 5 --- script/analyse/check_migration_quality.py | 232 ++++++++++++++++-- script/analyse/check_migration_quality_tui.py | 5 +- script/todo/todo_i18n.py | 20 ++ test/test_check_migration_quality.py | 150 ++++++++++- 4 files changed, 384 insertions(+), 23 deletions(-) diff --git a/script/analyse/check_migration_quality.py b/script/analyse/check_migration_quality.py index bfdb363..b1c5f62 100755 --- a/script/analyse/check_migration_quality.py +++ b/script/analyse/check_migration_quality.py @@ -333,6 +333,158 @@ def render_missing(etat, colour=False, limit=60): return "\n".join(lignes) +# Ce qu'Odoo déplace ou retire de lui-même, d'une version à l'autre. +# +# Sans cette carte, l'outil annonçait « 81 tables ont perdu des lignes » +# et la plus grosse d'entre elles — ir_translation, 32 984 lignes — était +# une refonte voulue par l'éditeur. Les vraies questions se noyaient dans +# les fausses, ce qui est la façon la plus sûre de ne pas les voir. +# +# Chaque entrée a été VÉRIFIÉE sur une migration réelle 12 → 18, en +# comptant les deux côtés. Une carte écrite de mémoire vaudrait moins que +# pas de carte : elle expliquerait des pertes qui n'en sont pas. +SEMANTIC_MAP = ( + # Fusions : les enregistrements continuent, ailleurs et autrement. + { + "since": 13, + "table": "account_invoice", + "into": "account_move", + "kind": "merged", + "why": "invoices became journal entries", + }, + { + "since": 13, + "table": "account_invoice_line", + "into": "account_move_line", + "kind": "merged", + "why": "invoices became journal entries", + }, + { + "since": 13, + "table": "account_invoice_tax", + "into": "account_move_line", + "kind": "merged", + "why": "invoices became journal entries", + }, + # Renommages : les mêmes enregistrements, sous un autre nom. + { + "since": 17, + "table": "mail_channel", + "into": "discuss_channel", + "kind": "renamed", + "why": "Discuss was renamed", + }, + { + "since": 17, + "table": "mail_channel_partner", + "into": "discuss_channel_member", + "kind": "renamed", + "why": "Discuss was renamed", + }, + { + "since": 13, + "table": "website_redirect", + "into": "website_rewrite", + "kind": "renamed", + "why": "redirections were reworked", + }, + { + "since": 13, + "table": "crm_lead_tag", + "into": "crm_tag", + "kind": "renamed", + "why": "tags became shared", + }, + # Retraits : la fonction a quitté la base pour du code. + { + "since": 16, + "table": "ir_translation", + "into": None, + "kind": "retired", + "why": "translations moved into jsonb columns", + }, + { + "since": 17, + "table": "account_tax_template", + "into": None, + "kind": "retired", + "why": "chart templates left the database", + }, + { + "since": 17, + "table": "account_account_template", + "into": None, + "kind": "retired", + "why": "chart templates left the database", + }, + { + "since": 17, + "table": "account_chart_template", + "into": None, + "kind": "retired", + "why": "chart templates left the database", + }, + { + "since": 17, + "table": "account_fiscal_position_template", + "into": None, + "kind": "retired", + "why": "chart templates left the database", + }, + { + "since": 17, + "table": "account_fiscal_position_tax_template", + "into": None, + "kind": "retired", + "why": "chart templates left the database", + }, + { + "since": 17, + "table": "account_fiscal_position_account_template", + "into": None, + "kind": "retired", + "why": "chart templates left the database", + }, + { + "since": 15, + "table": "stock_inventory", + "into": "stock_quant", + "kind": "merged", + "why": "inventory adjustments became quants", + }, + { + "since": 15, + "table": "stock_inventory_line", + "into": "stock_quant", + "kind": "merged", + "why": "inventory adjustments became quants", + }, +) + + +def as_version(etat): + """« 18.0 » -> 18. None si l'on ne sait pas.""" + try: + return int(float((etat or {}).get("odoo") or 0)) or None + except (TypeError, ValueError): + return None + + +def explain_loss(table, version): + """Ce qu'Odoo a fait de cette table à cette version, ou None. + + `version` est celle d'ARRIVÉE : une refonte de la 17 n'explique rien + d'un palier 13 → 14, et l'accepter ferait taire une vraie perte sous + prétexte qu'elle porte le nom d'une table refondue plus tard. + """ + if not version: + return None + for entree in SEMANTIC_MAP: + if entree["table"] == table and entree["since"] <= version: + return entree + return None + + def compare(avant, apres): """Ce qui a été gagné et ce qui a été perdu entre deux paliers.""" if not avant.get("exists") or not apres.get("exists"): @@ -341,18 +493,34 @@ def compare(avant, apres): mod_avant, mod_apres = set(avant["model"]), set(apres["model"]) tbl_avant, tbl_apres = avant["table"], apres["table"] + # TOUJOURS quatre éléments, le dernier étant l'explication ou None. + # Un tuple de taille variable obligerait chaque lecteur à s'en méfier. lignes_perdues = [] for table, nombre in sorted(tbl_avant.items()): reste = tbl_apres.get(table) if reste is None and nombre: - lignes_perdues.append((table, nombre, 0)) + lignes_perdues.append((table, nombre, 0, None)) elif reste is not None and reste < nombre: - lignes_perdues.append((table, nombre, reste)) + lignes_perdues.append((table, nombre, reste, None)) lignes_gagnees = [ (table, tbl_avant.get(table, 0), nombre) for table, nombre in sorted(tbl_apres.items()) if nombre > tbl_avant.get(table, 0) ] + version = as_version(apres) + for index, (table, debut, fin, _rien) in enumerate(lignes_perdues): + connu = explain_loss(table, version) + if not connu: + continue + # Une fusion qui n'a PAS grossi la table d'accueil n'explique + # rien : on garde l'explication et on dit qu'elle ne tient pas. + arrivee = connu["into"] + recue = ( + apres["table"].get(arrivee, 0) - avant["table"].get(arrivee, 0) + if arrivee + else None + ) + lignes_perdues[index] = (table, debut, fin, {**connu, "gained": recue}) return { "modules_lost": sorted(inst_avant - inst_apres), "modules_gained": sorted(inst_apres - inst_avant), @@ -384,7 +552,9 @@ def probable_renames(perdues, gagnees): cherche un dégât là où il n'y en a pas. """ disparues = { - table: avant for table, avant, apres in perdues if apres == 0 and avant + table: avant + for table, avant, apres, _connu in perdues + if apres == 0 and avant } apparues = { table: apres for table, avant, apres in gagnees if avant == 0 and apres @@ -528,21 +698,51 @@ def render_compare(diff, colour, limit=8): # LE signal qui compte : un module en moins se voit, une table qui # passe de quatre mille lignes à zéro ne se voit nulle part. # - # Une table probablement renommée reste dans la LISTE, annotée. L'en - # retirer était le vrai danger : un rapprochement faux — et il y en - # a eu — aurait fait disparaître une perte réelle du rapport. + # Les pertes EXPLIQUÉES sont séparées des autres, jamais retirées. + # Sans cette séparation on lisait « 81 tables ont perdu des lignes » + # dont la plus grosse — ir_translation, 32 984 lignes — était une + # refonte voulue par Odoo : les vraies questions se noyaient dans + # les fausses, ce qui est la façon la plus sûre de ne pas les voir. vers = {a: b for a, b, _n in diff["renamed"]} - lignes.append( - f" {paint('▼', 'fail', colour)} {len(perdues)}" - f" {t('table(s) lost rows')} :" - ) - for table, avant, apres in perdues[:limit]: - note = ( - f" ↻ {t('probably renamed to')} {vers[table]}" - if table in vers - else "" + ouvertes = [item for item in perdues if not item[3]] + connues = [item for item in perdues if item[3]] + if ouvertes: + lignes.append( + f" {paint('▼', 'fail', colour)} {len(ouvertes)}" + f" {t('table(s) lost rows, unexplained')} :" ) - lignes.append(f" {table:<40} {avant:>8} → {apres}{note}") + for table, avant, apres, _rien in ouvertes[:limit]: + note = ( + f" ↻ {t('probably renamed to')} {vers[table]}" + if table in vers + else "" + ) + lignes.append(f" {table:<40} {avant:>8} → {apres}{note}") + if len(ouvertes) > limit: + lignes.append(f" … {len(ouvertes) - limit} {t('more')}") + if connues: + lignes.append( + f" {paint('▽', 'dim', colour)} {len(connues)}" + f" {t('table(s) Odoo moved or retired')} :" + ) + for table, avant, apres, connu in connues[:limit]: + if connu["into"]: + recue = connu.get("gained") + # Une fusion dont la table d'accueil n'a PAS grossi + # n'explique rien : le dire, plutôt que de classer la + # perte comme attendue et passer à autre chose. + accueil = ( + f"+{recue} {t('there')}" + if recue and recue > 0 + else paint(t("but it gained nothing"), "fail", colour) + ) + ou = f"→ {connu['into']} ({accueil})" + else: + ou = t("retired from the database") + lignes.append(f" {table:<40} {avant:>8} → {apres} {ou}") + lignes.append(f" {connu['why']}") + if len(connues) > limit: + lignes.append(f" … {len(connues) - limit} {t('more')}") delta = diff["delta"] lignes.append( " " diff --git a/script/analyse/check_migration_quality_tui.py b/script/analyse/check_migration_quality_tui.py index fee2360..4328936 100644 --- a/script/analyse/check_migration_quality_tui.py +++ b/script/analyse/check_migration_quality_tui.py @@ -67,10 +67,13 @@ def rows(lst_snapshot): # Le précédent voyage avec la ligne : le panneau en a besoin pour # écrire « 171 (−43) », et le recalculer là-bas ferait deux # sources pour la même comparaison. + # La colonne compte les pertes INEXPLIQUÉES : afficher 81 quand + # 79 sont des refontes voulues par Odoo ferait fuir le lecteur du + # seul chiffre qui demande une réponse. perdu = ( 0 if diff is None or diff.get("unavailable") - else len(diff["rows_lost"]) + else len([item for item in diff["rows_lost"] if not item[3]]) ) lst.append( { diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index aa3218e..c8c45d3 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -5783,6 +5783,26 @@ TRANSLATIONS = { "fr": "Choisissez un palier pour voir ses fichiers absents.", "en": "Pick a step to see its missing files.", }, + "table(s) lost rows, unexplained": { + "fr": "table(s) ont perdu des lignes, sans explication", + "en": "table(s) lost rows, unexplained", + }, + "table(s) Odoo moved or retired": { + "fr": "table(s) qu'Odoo a déplacées ou retirées", + "en": "table(s) Odoo moved or retired", + }, + "retired from the database": { + "fr": "retirée de la base", + "en": "retired from the database", + }, + "but it gained nothing": { + "fr": "mais elle n'a rien reçu", + "en": "but it gained nothing", + }, + "there": { + "fr": "là-bas", + "en": "there", + }, "Clean the database before testing the pages?": { "fr": "Nettoyer la base avant de tester les pages ?", "en": "Clean the database before testing the pages?", diff --git a/test/test_check_migration_quality.py b/test/test_check_migration_quality.py index b783271..5f5522f 100644 --- a/test/test_check_migration_quality.py +++ b/test/test_check_migration_quality.py @@ -122,17 +122,19 @@ class TestWhatIsGainedAndLost(Base): def test_a_table_that_empties_is_reported(self): # LE signal : un module en moins se voit, une table qui passe de # cent lignes à zéro ne se voit nulle part. + # Une table NEUTRE : le mécanisme se teste sans la carte + # sémantique, qui a ses propres tests. diff = quality.compare( - snapshot(table={"account_invoice": 651}), - snapshot(table={"account_invoice": 0}), + snapshot(table={"ma_table": 651}), + snapshot(table={"ma_table": 0}), ) - self.assertEqual(diff["rows_lost"], [("account_invoice", 651, 0)]) + self.assertEqual(diff["rows_lost"], [("ma_table", 651, 0, None)]) def test_a_table_that_disappears_counts_as_emptied(self): diff = quality.compare( - snapshot(table={"account_invoice": 651}), snapshot(table={}) + snapshot(table={"ma_table": 651}), snapshot(table={}) ) - self.assertEqual(diff["rows_lost"], [("account_invoice", 651, 0)]) + self.assertEqual(diff["rows_lost"], [("ma_table", 651, 0, None)]) def test_an_empty_table_that_disappears_is_not_a_loss(self): # Rien à perdre : le signaler noierait les vraies pertes. @@ -207,7 +209,7 @@ class TestNotCryingWolfOnRenames(Base): self.assertEqual( diff["renamed"], [("muk_dms_directory", "dms_directory", 7)] ) - self.assertIn(("muk_dms_directory", 7, 0), diff["rows_lost"]) + self.assertIn(("muk_dms_directory", 7, 0, None), diff["rows_lost"]) def test_the_report_says_it_is_only_probable(self): diff = quality.compare( @@ -219,6 +221,142 @@ class TestNotCryingWolfOnRenames(Base): self.assertIn("probably renamed", texte) +class TestTheSemanticMap(Base): + """« 81 tables ont perdu des lignes » noyait les vraies questions. + + La plus grosse d'entre elles — `ir_translation`, 32 984 lignes — est + une refonte voulue par Odoo en 16. Mettre les refontes et les pertes + réelles sur le même plan est la façon la plus sûre de ne pas voir les + secondes. + """ + + def perte( + self, + table, + avant_n, + apres_n, + version="18.0", + cible=None, + cible_avant=0, + cible_apres=0, + ): + tbl_avant = {table: avant_n} + tbl_apres = {} if apres_n == 0 else {table: apres_n} + if cible: + tbl_avant[cible] = cible_avant + tbl_apres[cible] = cible_apres + return quality.compare( + snapshot(odoo="12.0", table=tbl_avant), + snapshot(odoo=version, table=tbl_apres), + ) + + def test_a_known_merge_is_explained(self): + diff = self.perte( + "account_invoice", + 651, + 0, + cible="account_move", + cible_avant=1371, + cible_apres=1812, + ) + connu = [x for x in diff["rows_lost"] if x[0] == "account_invoice"][0] + self.assertIsNotNone(connu[3]) + self.assertEqual(connu[3]["into"], "account_move") + self.assertEqual(connu[3]["gained"], 441) + + def test_a_retired_table_is_explained_without_a_target(self): + diff = self.perte("ir_translation", 32984, 0) + connu = [x for x in diff["rows_lost"] if x[0] == "ir_translation"][0] + self.assertIsNotNone(connu[3]) + self.assertIsNone(connu[3]["into"]) + + def test_the_map_does_not_apply_BEFORE_its_version(self): + """Une refonte de la 16 n'explique rien d'un palier 12 → 13. + + L'accepter ferait taire une vraie perte sous prétexte que la table + porte le nom d'une autre, refondue trois versions plus tard. + """ + diff = self.perte("ir_translation", 32984, 0, version="13.0") + connu = [x for x in diff["rows_lost"] if x[0] == "ir_translation"][0] + self.assertIsNone(connu[3]) + + def test_it_applies_AT_its_version(self): + diff = self.perte("ir_translation", 32984, 0, version="16.0") + connu = [x for x in diff["rows_lost"] if x[0] == "ir_translation"][0] + self.assertIsNotNone(connu[3]) + + def test_an_explained_loss_is_STILL_in_the_list(self): + # La règle qui vaut plus que tout : expliquer n'est pas cacher. + diff = self.perte("ir_translation", 32984, 0) + self.assertIn("ir_translation", [x[0] for x in diff["rows_lost"]]) + + def test_the_partition_loses_nothing(self): + diff = quality.compare( + snapshot( + odoo="12.0", table={"ir_translation": 100, "ma_table": 50} + ), + snapshot(odoo="18.0", table={}), + ) + perdues = diff["rows_lost"] + ouvertes = [x for x in perdues if not x[3]] + connues = [x for x in perdues if x[3]] + self.assertEqual(len(perdues), len(ouvertes) + len(connues)) + self.assertEqual(len(perdues), 2) + + def test_a_merge_whose_target_gained_NOTHING_is_flagged(self): + """Le cas qui compte : la carte dit où les données sont allées. + + Si elles n'y sont pas, l'explication ne tient pas — et la classer + « attendue » puis passer à autre chose serait exactement l'erreur + que la carte devait empêcher. + """ + diff = self.perte( + "account_invoice", + 651, + 0, + cible="account_move", + cible_avant=1371, + cible_apres=1371, + ) + connu = [x for x in diff["rows_lost"] if x[0] == "account_invoice"][0] + self.assertEqual(connu[3]["gained"], 0) + texte = "\n".join(quality.render_compare(diff, colour=False)) + self.assertIn("gained nothing", texte) + + def test_the_report_puts_the_unexplained_FIRST(self): + diff = quality.compare( + snapshot( + odoo="12.0", table={"ir_translation": 100, "ma_table": 50} + ), + snapshot(odoo="18.0", table={}), + ) + texte = "\n".join(quality.render_compare(diff, colour=False)) + self.assertLess(texte.index("unexplained"), texte.index("moved or")) + + def test_an_unknown_table_stays_unexplained(self): + diff = self.perte("ma_table_a_moi", 50, 0) + self.assertIsNone(diff["rows_lost"][0][3]) + + def test_every_entry_of_the_map_is_complete(self): + # Une entrée sans « why » expliquerait sans dire pourquoi. + for entree in quality.SEMANTIC_MAP: + for cle in ("since", "table", "into", "kind", "why"): + self.assertIn(cle, entree, entree) + self.assertTrue(entree["why"], entree) + self.assertIn(entree["kind"], ("merged", "renamed", "retired")) + + def test_the_column_counts_only_what_needs_an_answer(self): + # Afficher 81 quand 14 sont des refontes voulues ferait fuir le + # lecteur du seul chiffre qui demande une réponse. + lst = [ + snapshot( + odoo="12.0", table={"ir_translation": 100, "ma_table": 50} + ), + snapshot(odoo="18.0", table={}), + ] + self.assertEqual(qtui.rows(lst)[1]["detail"], "1") + + class TestTheOverallReport(Base): def test_it_compares_the_ENDS_not_the_sum_of_steps(self): """Un module retiré en 15 puis remis en 17 n'a rien perdu. From 53fa936168e1a58f1ec9b3cd7c78579547237f18 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 20 Aug 2026 03:47:02 -0400 Subject: [PATCH 58/67] =?UTF-8?q?[ADD]=20migration=20quality:=20biggest=20?= =?UTF-8?q?losses=20first,=20and=20the=20full=20lists=20behind=20=C2=AB=20?= =?UTF-8?q?d=20=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fifty-seven losses are read from the top, not in alphabetical order. They are now sorted by rows LOST, not by rows there before: a table of ten thousand that drops two matters less than one of a thousand that empties. « d » cycles the pane through the complete lists — modules, models, fields, COW copies, tables — and back to the summary. The summary cuts at eight entries and is right to; but when one is looking for whether ONE module survived, the truncated list does not answer, and that is exactly when it is needed. Two inventories were missing and both matter. FIELDS: a lost field is a lost column of data, finer than a model, which can survive emptied of half of its own. COW COPIES by key: it is the key one resets, and by the key one finds them again across versions. On a real 12 → 18 they read 11 036 → 15 069 fields and 64 → 131 copies, of which 28 disappeared — customisations nothing was reporting until now. One mode replaces two flags: « show missing files » and « show the model list » cannot both be true, and two booleans let that impossible state be written. The mode is named in the sub-title, because a pane that changes without saying why reads as a broken screen. --- FR --- [ADD] qualité de migration : les plus grosses pertes d'abord, les listes derrière « d » Cinquante-sept pertes se lisent par le haut. Elles sont triées sur le volume PERDU, non sur le volume présent avant : une table de dix mille qui en perd deux compte moins qu'une de mille qui se vide. « d » fait défiler le panneau à travers les listes entières — modules, modèles, champs, copies COW, tables — puis revient au résumé. Deux inventaires manquaient. Les CHAMPS : un champ perdu est une colonne de données perdue. Les COPIES COW par leur clé : sur une vraie 12 → 18, 28 ont disparu — des personnalisations que rien ne signalait. Un seul mode remplace deux drapeaux, qui laissaient écrire un état impossible. Il est nommé dans le sous-titre. Assisted-by: Claude Opus 5 --- script/analyse/check_migration_quality.py | 79 +++++++ script/analyse/check_migration_quality_tui.py | 47 +++- script/todo/todo_i18n.py | 28 +++ test/test_check_migration_quality.py | 208 +++++++++++++++++- 4 files changed, 354 insertions(+), 8 deletions(-) diff --git a/script/analyse/check_migration_quality.py b/script/analyse/check_migration_quality.py index b1c5f62..6028593 100755 --- a/script/analyse/check_migration_quality.py +++ b/script/analyse/check_migration_quality.py @@ -210,6 +210,22 @@ def inspect(database): modeles = run_psql(database, "SELECT model FROM ir_model ORDER BY model") etat["model"] = sorted(m[0] for m in modeles or []) etat["table"] = table_counts(database) + # Les CHAMPS : un champ perdu est une colonne de données perdue, et + # c'est plus fin qu'un modèle — un modèle qui survit peut avoir été + # vidé de la moitié de ses champs sans que rien ne le dise. + champs = run_psql( + database, + "SELECT model || '.' || name FROM ir_model_fields ORDER BY 1", + ) + etat["field"] = sorted(ligne[0] for ligne in champs or []) + # Les copies COW par leur CLÉ : c'est elle qu'on réinitialise, et + # c'est par elle qu'on les retrouve d'une version à l'autre. + copies = run_psql( + database, + "SELECT coalesce(key, 'id:' || id::text) FROM ir_ui_view" + " WHERE website_id IS NOT NULL ORDER BY 1", + ) + etat["cow"] = sorted(ligne[0] for ligne in copies or []) # Les modèles sans table : abstraits, mixins et vues SQL pour la # plupart — mais leur NOMBRE qui bouge d'un palier à l'autre dit @@ -276,6 +292,58 @@ def missing_detail(database, lst_store_fname, limit=400): ] +DETAILS = ("modules", "models", "fields", "cow", "tables") + + +def render_detail(diff, categorie, colour=False): + """La liste ENTIÈRE d'une catégorie, sans troncature. + + Le résumé coupe à huit entrées, et il a raison : personne ne lit + trois cent soixante-quinze noms de modèles en passant. Mais quand on + cherche si UN module précis a survécu, la liste tronquée ne répond + pas — et c'est justement là qu'on a besoin d'elle. + """ + from script.todo.migration_status import paint + + if diff is None or diff.get("unavailable"): + return t("not comparable: a database is missing") + if categorie == "tables": + lignes = [f"── {t('table(s) lost rows')} ──"] + vers = {a: b for a, b, _n in diff["renamed"]} + for table, avant, apres, connu in diff["rows_lost"]: + note = "" + if connu and connu["into"]: + note = f" → {connu['into']}" + elif connu: + note = f" {t('retired from the database')}" + elif table in vers: + note = f" ↻ {vers[table]}" + teinte = "dim" if connu else "fail" + lignes.append( + f" {paint(f'{avant - apres:>8}', teinte, colour)}" + f" {table:<46} {avant} → {apres}{note}" + ) + return "\n".join(lignes) + + perdus = diff.get(f"{categorie}_lost") or [] + gagnes = diff.get(f"{categorie}_gained") or [] + lignes = [] + # « − 28 copies COW » plutôt que « 28 copies COW perdus » : le signe + # porte déjà le sens, et l'accord d'un participe avec une catégorie + # dont le genre change d'une langue à l'autre ne se traduit pas. + for lst, symbole, teinte in ( + (perdus, "−", "fail"), + (gagnes, "+", "ok"), + ): + lignes.append( + f"── {paint(symbole, teinte, colour)} {len(lst)}" + f" {t(categorie)} ──" + ) + lignes.extend(f" {nom}" for nom in lst) + lignes.append("") + return "\n".join(lignes) + + def render_missing(etat, colour=False, limit=60): """Ce qui manque, groupé d'abord, détaillé ensuite. @@ -521,7 +589,18 @@ def compare(avant, apres): else None ) lignes_perdues[index] = (table, debut, fin, {**connu, "gained": recue}) + champ_avant = set(avant.get("field") or []) + champ_apres = set(apres.get("field") or []) + cow_avant = set(avant.get("cow") or []) + cow_apres = set(apres.get("cow") or []) + # Les plus grosses pertes EN TÊTE : on attaque une liste de + # cinquante-sept par le haut, pas par ordre alphabétique. + lignes_perdues.sort(key=lambda item: -(item[1] - item[2])) return { + "fields_lost": sorted(champ_avant - champ_apres), + "fields_gained": sorted(champ_apres - champ_avant), + "cow_lost": sorted(cow_avant - cow_apres), + "cow_gained": sorted(cow_apres - cow_avant), "modules_lost": sorted(inst_avant - inst_apres), "modules_gained": sorted(inst_apres - inst_avant), "models_lost": sorted(mod_avant - mod_apres), diff --git a/script/analyse/check_migration_quality_tui.py b/script/analyse/check_migration_quality_tui.py index 4328936..fc38097 100644 --- a/script/analyse/check_migration_quality_tui.py +++ b/script/analyse/check_migration_quality_tui.py @@ -159,17 +159,24 @@ def statistics(etat, precedent): ] -def pane_text(lst_snapshot, row, colour=False, show_missing=False): - """Le détail du palier choisi, ou le bilan d'ensemble.""" +def pane_text(lst_snapshot, row, colour=False, mode=None): + """Le détail du palier choisi, ou le bilan d'ensemble. + + UN seul mode, et non deux drapeaux : « montre les fichiers absents » + et « montre la liste des modèles » ne peuvent pas être vrais en même + temps, et deux booléens laissaient écrire cet état impossible. + """ if row is None: return t("Nothing to show yet.") if row["kind"] == "missing": return f"⚠️ {row['data']['database']} : {t('database not found')}" etat = row["data"] - if show_missing: + if mode == "missing": if not etat: return t("Pick a step to see its missing files.") return quality.render_missing(etat, colour) + if mode in quality.DETAILS: + return quality.render_detail(row.get("diff"), mode, colour) lignes = [] if etat: lignes.append( @@ -202,6 +209,19 @@ def pane_text(lst_snapshot, row, colour=False, show_missing=False): return "\n".join(lignes) +def mode_label(mode): + """Le nom du mode courant, pour le sous-titre. + + Un panneau qui change de contenu sans dire pourquoi se lit comme un + écran cassé — et avec sept modes, on ne devine pas. + """ + if mode is None: + return t("summary") + if mode == "missing": + return t("Missing files") + return t(mode) + + def build_app(lst_snapshot): """Textual est importé ICI : le module reste testable sans lui.""" from rich.text import Text @@ -214,6 +234,7 @@ def build_app(lst_snapshot): BINDINGS = [ ("q,escape", "quit", t("Quit")), ("m", "toggle_missing", t("Missing files")), + ("d", "cycle_detail", t("Details")), ] def __init__(self, lst_snapshot): @@ -221,7 +242,7 @@ def build_app(lst_snapshot): self.lst_snapshot = lst_snapshot self.lst_row = rows(lst_snapshot) self.index = 0 - self.show_missing = False + self.mode = None def compose(self) -> ComposeResult: yield Header() @@ -244,6 +265,7 @@ def build_app(lst_snapshot): self._show() def _show(self): + self.sub_title = mode_label(self.mode) row = ( self.lst_row[self.index] if self.lst_row and self.index < len(self.lst_row) @@ -255,7 +277,7 @@ def build_app(lst_snapshot): self.lst_snapshot, row, colour=True, - show_missing=self.show_missing, + mode=self.mode, ) ) ) @@ -267,7 +289,20 @@ def build_app(lst_snapshot): base allongerait un parcours qui tient en quatre secondes, pour une information qu'on ne regarde qu'en la demandant. """ - self.show_missing = not self.show_missing + self.mode = None if self.mode == "missing" else "missing" + self._show() + + def action_cycle_detail(self): + """Parcourir les listes ENTIÈRES, une catégorie à la fois. + + Le résumé coupe à huit entrées et il a raison ; mais quand on + cherche si UN module précis a survécu, la liste tronquée ne + répond pas. On enchaîne donc résumé → modules → modèles → + champs → copies COW → tables, et l'on revient. + """ + suite = (None,) + quality.DETAILS + courant = self.mode if self.mode in suite else None + self.mode = suite[(suite.index(courant) + 1) % len(suite)] self._show() def on_data_table_row_highlighted(self, event): diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index c8c45d3..0706323 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -5803,6 +5803,34 @@ TRANSLATIONS = { "fr": "là-bas", "en": "there", }, + "lost": { + "fr": "perdus", + "en": "lost", + }, + "gained": { + "fr": "gagnés", + "en": "gained", + }, + "fields": { + "fr": "champs", + "en": "fields", + }, + "cow": { + "fr": "copies COW", + "en": "cow", + }, + "tables": { + "fr": "tables", + "en": "tables", + }, + "Details": { + "fr": "Détails", + "en": "Details", + }, + "summary": { + "fr": "résumé", + "en": "summary", + }, "Clean the database before testing the pages?": { "fr": "Nettoyer la base avant de tester les pages ?", "en": "Clean the database before testing the pages?", diff --git a/test/test_check_migration_quality.py b/test/test_check_migration_quality.py index 5f5522f..9cb8403 100644 --- a/test/test_check_migration_quality.py +++ b/test/test_check_migration_quality.py @@ -633,8 +633,8 @@ class TestTheMissingFilesButton(Base): "previous": None, "diff": None, } - chiffres = qtui.pane_text([], row, show_missing=False) - liste = qtui.pane_text([], row, show_missing=True) + chiffres = qtui.pane_text([], row, mode=None) + liste = qtui.pane_text([], row, mode="missing") self.assertIn("modules", chiffres) self.assertNotIn("modules", liste) @@ -719,6 +719,210 @@ class TestTheMenuEntryLooksLikeItsNeighbours(Base): self.assertIn("📐", inspect.getsource(quality.render_text)) +class TestAttackingTheListFromTheTop(Base): + """Cinquante-sept pertes se parcourent par le haut, pas par ordre + alphabétique : la plus grosse est celle qu'on veut voir en premier.""" + + def test_the_biggest_loss_comes_first(self): + # Des noms dont l'ordre alphabétique CONTREDIT celui des volumes : + # sinon un tri par nom passerait pour un tri par volume, et le + # test ne prouverait rien. + diff = quality.compare( + snapshot( + odoo="12.0", + table={ + "aaa_petite": 10, + "zzz_enorme": 5000, + "mmm_moyenne": 100, + }, + ), + snapshot(odoo="18.0", table={}), + ) + self.assertEqual( + [x[0] for x in diff["rows_lost"]], + ["zzz_enorme", "mmm_moyenne", "aaa_petite"], + ) + + def test_it_sorts_on_what_was_LOST_not_on_what_was_there(self): + # Une table de dix mille lignes qui en perd deux compte moins + # qu'une table de mille qui se vide. + diff = quality.compare( + snapshot(odoo="12.0", table={"grosse": 10000, "vidée": 1000}), + snapshot(odoo="18.0", table={"grosse": 9998}), + ) + self.assertEqual( + [x[0] for x in diff["rows_lost"]], ["vidée", "grosse"] + ) + + +class TestTheFullLists(Base): + """Le résumé coupe à huit entrées, et il a raison. + + Mais quand on cherche si UN module précis a survécu, la liste + tronquée ne répond pas — et c'est justement là qu'on en a besoin. + """ + + def diff(self): + return quality.compare( + snapshot( + odoo="12.0", + installed=["a", "b"], + model=["m.un", "m.deux"], + field=["m.un.x", "m.un.y"], + cow=["site.vue"], + table={"t": 10}, + ), + snapshot( + odoo="18.0", + installed=["a", "c"], + model=["m.un", "m.trois"], + field=["m.un.x", "m.trois.z"], + cow=["site.autre"], + table={}, + ), + ) + + def test_fields_are_inventoried(self): + # Un champ perdu est une colonne de données perdue — plus fin + # qu'un modèle, qui peut survivre vidé de la moitié des siens. + diff = self.diff() + self.assertEqual(diff["fields_lost"], ["m.un.y"]) + self.assertEqual(diff["fields_gained"], ["m.trois.z"]) + + def test_cow_copies_are_inventoried_by_key(self): + # C'est la clé qu'on réinitialise, et par elle qu'on les retrouve. + diff = self.diff() + self.assertEqual(diff["cow_lost"], ["site.vue"]) + self.assertEqual(diff["cow_gained"], ["site.autre"]) + + def test_every_category_renders_in_full(self): + for categorie in quality.DETAILS: + texte = quality.render_detail(self.diff(), categorie) + self.assertTrue(texte.strip(), categorie) + + def test_nothing_is_truncated(self): + # C'est tout le propos : le résumé coupe, la liste entière non. + diff = quality.compare( + snapshot(odoo="12.0", model=[f"m.{i}" for i in range(200)]), + snapshot(odoo="18.0", model=[]), + ) + texte = quality.render_detail(diff, "models") + self.assertIn("m.199", texte) + self.assertNotIn("…", texte) + + def test_the_table_list_keeps_the_volume_order(self): + diff = quality.compare( + snapshot(odoo="12.0", table={"petite": 1, "enorme": 900}), + snapshot(odoo="18.0", table={}), + ) + texte = quality.render_detail(diff, "tables") + self.assertLess(texte.index("enorme"), texte.index("petite")) + + def test_an_unavailable_comparison_says_so(self): + self.assertIn("not comparable", quality.render_detail(None, "models")) + + def test_the_headings_carry_no_participle(self): + """« 28 copies COW perdus » ne s'accorde pas, et ne se traduit pas. + + Le signe porte déjà le sens ; un participe devrait s'accorder avec + une catégorie dont le genre change d'une langue à l'autre. + """ + texte = quality.render_detail(self.diff(), "cow") + self.assertIn("−", texte) + self.assertNotIn("perdus", texte) + + +class TestTheInventoryItself(Base): + """La comparaison était testée, la COLLECTE ne l'était pas. + + Vider l'inventaire des champs dans `inspect` passait inaperçu, parce + que tous les tests fabriquaient leurs instantanés à la main. Un test + qui ne touche jamais le code réel ne garde rien. + """ + + def repondre(self, sql): + if "ir_model_fields" in sql: + return [["res.partner.name"], ["res.partner.email"]] + if "website_id IS NOT NULL" in sql: + return [["site.vue"]] + if "FROM ir_model " in sql or sql.strip().endswith("ORDER BY model"): + return [["res.partner"]] + if "ir_module_module" in sql: + return [["base", "installed"]] + if "latest_version" in sql: + return [["odoo", "18.0.1.3"]] + return [] + + def inspecter(self): + for nom, remplacant in ( + ("run_psql", lambda db, sql: self.repondre(sql)), + ("missing_files", lambda db, lst: []), + ("table_counts", lambda db: {}), + ): + original = getattr(quality, nom) + setattr(quality, nom, remplacant) + self.addCleanup(setattr, quality, nom, original) + return quality.inspect("db") + + def test_the_fields_are_collected(self): + self.assertEqual( + self.inspecter()["field"], + ["res.partner.email", "res.partner.name"], + ) + + def test_the_cow_copies_are_collected(self): + self.assertEqual(self.inspecter()["cow"], ["site.vue"]) + + def test_a_cow_copy_without_a_key_is_still_named(self): + # Une copie sans clé existe quand même ; la taire ferait un + # inventaire qui ment sur son propre compte. + import inspect + + self.assertIn("'id:' || id::text", inspect.getsource(quality.inspect)) + + +class TestTheDetailButton(Base): + def test_d_is_bound(self): + app = qtui.build_app([snapshot()]) + touches = { + touche + for entree in app.BINDINGS + for touche in entree[0].split(",") + } + self.assertIn("d", touches) + + def test_the_cycle_visits_every_category_and_comes_back(self): + suite = (None,) + quality.DETAILS + self.assertEqual(len(suite), 6) + self.assertEqual(suite[len(suite) % len(suite)], None) + + def test_ONE_mode_not_two_flags(self): + """« fichiers absents » et « liste des modèles » ne peuvent pas + être vrais en même temps ; deux booléens laissaient écrire cet + état impossible.""" + import inspect + + source = inspect.getsource(qtui.build_app) + self.assertIn("self.mode", source) + self.assertNotIn("self.show_missing", source) + + def test_every_mode_has_a_name(self): + # Un panneau qui change sans dire pourquoi se lit comme un écran + # cassé, et avec sept modes on ne devine pas. + noms = { + qtui.mode_label(m) for m in (None, "missing") + quality.DETAILS + } + self.assertEqual(len(noms), 7) + for nom in noms: + self.assertTrue(nom.strip()) + + def test_the_name_is_shown_where_it_stays_visible(self): + import inspect + + source = inspect.getsource(qtui.build_app) + self.assertIn("self.sub_title = mode_label", source) + + class TestItNeverWrites(Base): """Les bases de palier sont parfois la seule copie d'un état.""" From 4103e13800106a9b14a7160ba0633d94a0823185 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 21 Aug 2026 03:03:04 -0400 Subject: [PATCH 59/67] [ADD] migration quality: needaction became notifications in 15 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified bump by bump rather than deduced from a name that looks alike: 1269 rows in 12, 13 and 14, the table disappears in 15 and mail_notification holds exactly 1269. Not one lost. That row-for-row carry-over is what earns the entry — a neighbouring name would have proved nothing. It is dated at 15 and not earlier: the table is still full at 14, and an entry that applied sooner would mask a loss that happened before the redesign. The unexplained list goes from 56 to 55. --- FR --- [ADD] qualité de migration : needaction est devenu notifications en 15 Vérifié palier par palier plutôt que déduit d'un nom qui se ressemble : 1269 lignes en 12, 13 et 14, la table disparaît en 15 et mail_notification en compte exactement 1269. Pas une perdue. C'est ce report à l'unité près qui autorise l'entrée — un nom voisin n'aurait rien prouvé. Elle est datée de 15 et pas avant : la table est encore pleine en 14, et une entrée qui s'appliquerait plus tôt masquerait une perte survenue avant la refonte. La liste des pertes inexpliquées passe de 56 à 55. Assisted-by: Claude Opus 5 --- script/analyse/check_migration_quality.py | 10 +++++++ test/test_check_migration_quality.py | 33 +++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/script/analyse/check_migration_quality.py b/script/analyse/check_migration_quality.py index 6028593..2950fc8 100755 --- a/script/analyse/check_migration_quality.py +++ b/script/analyse/check_migration_quality.py @@ -513,6 +513,16 @@ SEMANTIC_MAP = ( "kind": "retired", "why": "chart templates left the database", }, + # Vérifié palier par palier : 1269 lignes en 12, 13 et 14, la table + # disparaît en 15 et `mail_notification` en compte exactement 1269. + # Pas une perdue. + { + "since": 15, + "table": "mail_message_res_partner_needaction_rel", + "into": "mail_notification", + "kind": "merged", + "why": "needaction became notifications", + }, { "since": 15, "table": "stock_inventory", diff --git a/test/test_check_migration_quality.py b/test/test_check_migration_quality.py index 9cb8403..595395f 100644 --- a/test/test_check_migration_quality.py +++ b/test/test_check_migration_quality.py @@ -333,6 +333,39 @@ class TestTheSemanticMap(Base): texte = "\n".join(quality.render_compare(diff, colour=False)) self.assertLess(texte.index("unexplained"), texte.index("moved or")) + def test_needaction_became_notifications_in_15(self): + """Vérifiée palier par palier, pas déduite d'un nom qui se ressemble. + + 1269 lignes en 12, 13 et 14 ; la table disparaît en 15 et + `mail_notification` en compte exactement 1269. Pas une perdue. + C'est ce report à l'unité près qui autorise l'entrée — un nom + voisin n'aurait rien prouvé. + """ + diff = self.perte( + "mail_message_res_partner_needaction_rel", + 1269, + 0, + version="15.0", + cible="mail_notification", + cible_avant=0, + cible_apres=1269, + ) + connu = diff["rows_lost"][0][3] + self.assertIsNotNone(connu) + self.assertEqual(connu["into"], "mail_notification") + self.assertEqual(connu["gained"], 1269) + + def test_it_is_not_explained_at_the_14_bump(self): + # La table est encore pleine en 14 : une entrée qui s'appliquerait + # plus tôt masquerait une perte survenue avant la refonte. + diff = self.perte( + "mail_message_res_partner_needaction_rel", + 1269, + 0, + version="14.0", + ) + self.assertIsNone(diff["rows_lost"][0][3]) + def test_an_unknown_table_stays_unexplained(self): diff = self.perte("ma_table_a_moi", 50, 0) self.assertIsNone(diff["rows_lost"][0][3]) From d2f70f653501ec34040565824a283bd25dbadb39 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 21 Aug 2026 03:31:28 -0400 Subject: [PATCH 60/67] [ADD] migration quality: explain ir_property and tracking values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ir_property never empties, it GROWS — 211 rows in 12, 457 in 17 — then vanishes in 18. Its fields are jsonb columns there, verified on res_partner.property_payment_term_id. mail_tracking_value is a REAL loss, not a rename: `field` goes from varchar to a foreign key in 14, and what would not resolve was deleted. 1528 of the 2336 belonged to the removed agile module; 706 more survived under a new name. New kind "pruned" so the report cannot call a living table retired. --- FR --- ir_property ne se vide jamais, elle GROSSIT — 211 lignes en 12, 457 en 17 — puis disparaît en 18. Ses champs y sont des colonnes jsonb, vérifié sur res_partner.property_payment_term_id. mail_tracking_value est une VRAIE perte, pas un renommage : `field` passe de varchar à clé étrangère en 14, et ce qui ne se résolvait pas a été supprimé. 1528 des 2336 venaient du module agile retiré ; 706 autres ont survécu sous un autre nom. Nouveau genre « pruned » pour que le rapport ne déclare pas retirée une table bien vivante. Assisted-by: Claude Opus 5 --- script/analyse/check_migration_quality.py | 38 +++++++++++++- script/todo/todo_i18n.py | 10 ++-- test/test_check_migration_quality.py | 61 ++++++++++++++++++++++- 3 files changed, 103 insertions(+), 6 deletions(-) diff --git a/script/analyse/check_migration_quality.py b/script/analyse/check_migration_quality.py index 2950fc8..f69f3c7 100755 --- a/script/analyse/check_migration_quality.py +++ b/script/analyse/check_migration_quality.py @@ -471,6 +471,18 @@ SEMANTIC_MAP = ( "kind": "retired", "why": "translations moved into jsonb columns", }, + # Vérifiée palier par palier : la table ne se vide jamais, elle GROSSIT + # (211 en 12 → 457 en 17) puis disparaît d'un coup en 18. Les champs + # qu'elle portait sont des colonnes jsonb en 18 — vérifié sur + # res_partner.property_payment_term_id et + # product_template.property_account_income_id. + { + "since": 18, + "table": "ir_property", + "into": None, + "kind": "retired", + "why": "company-dependent values moved into jsonb columns", + }, { "since": 17, "table": "account_tax_template", @@ -537,6 +549,23 @@ SEMANTIC_MAP = ( "kind": "merged", "why": "inventory adjustments became quants", }, + # Élagages : la table demeure, ces lignes non. À distinguer d'une + # fusion — ici les enregistrements ne continuent NULLE PART. + # + # Vérifié : en 13 `field` est un varchar (le nom du champ), en 14 c'est + # une clé étrangère vers ir_model_fields. En 14 aucune ligne n'a de + # champ nul ni de clé cassée : ce qui ne se résolvait pas a été + # supprimé. Sur les 2336 disparues, 1528 portaient des champs du module + # agile retiré (priority_id 761, agile_enabled 711, story_points 54, + # resolution_id 2) ; 706 autres ont SURVÉCU sous un nouveau nom + # (account.move.type → move_type, 651). + { + "since": 14, + "table": "mail_tracking_value", + "into": None, + "kind": "pruned", + "why": "tracking of fields that ceased to exist", + }, ) @@ -812,7 +841,7 @@ def render_compare(diff, colour, limit=8): if connues: lignes.append( f" {paint('▽', 'dim', colour)} {len(connues)}" - f" {t('table(s) Odoo moved or retired')} :" + f" {t('table(s) explained by an Odoo change')} :" ) for table, avant, apres, connu in connues[:limit]: if connu["into"]: @@ -826,6 +855,13 @@ def render_compare(diff, colour, limit=8): else paint(t("but it gained nothing"), "fail", colour) ) ou = f"→ {connu['into']} ({accueil})" + elif connu["kind"] == "pruned": + # La table est TOUJOURS là : dire « retirée » serait + # faux, et cette perte-ci est réelle — les lignes ne + # continuent nulle part. Le mot doit le laisser voir. + ou = paint( + t("rows dropped, the table remains"), "warn", colour + ) else: ou = t("retired from the database") lignes.append(f" {table:<40} {avant:>8} → {apres} {ou}") diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 0706323..2ac36e4 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -5787,9 +5787,13 @@ TRANSLATIONS = { "fr": "table(s) ont perdu des lignes, sans explication", "en": "table(s) lost rows, unexplained", }, - "table(s) Odoo moved or retired": { - "fr": "table(s) qu'Odoo a déplacées ou retirées", - "en": "table(s) Odoo moved or retired", + "table(s) explained by an Odoo change": { + "fr": "table(s) expliquées par un changement d'Odoo", + "en": "table(s) explained by an Odoo change", + }, + "rows dropped, the table remains": { + "fr": "lignes supprimées, la table demeure", + "en": "rows dropped, the table remains", }, "retired from the database": { "fr": "retirée de la base", diff --git a/test/test_check_migration_quality.py b/test/test_check_migration_quality.py index 595395f..0cf4dba 100644 --- a/test/test_check_migration_quality.py +++ b/test/test_check_migration_quality.py @@ -331,7 +331,10 @@ class TestTheSemanticMap(Base): snapshot(odoo="18.0", table={}), ) texte = "\n".join(quality.render_compare(diff, colour=False)) - self.assertLess(texte.index("unexplained"), texte.index("moved or")) + self.assertLess( + texte.index(todo_i18n.t("table(s) lost rows, unexplained")), + texte.index(todo_i18n.t("table(s) explained by an Odoo change")), + ) def test_needaction_became_notifications_in_15(self): """Vérifiée palier par palier, pas déduite d'un nom qui se ressemble. @@ -376,7 +379,61 @@ class TestTheSemanticMap(Base): for cle in ("since", "table", "into", "kind", "why"): self.assertIn(cle, entree, entree) self.assertTrue(entree["why"], entree) - self.assertIn(entree["kind"], ("merged", "renamed", "retired")) + self.assertIn( + entree["kind"], ("merged", "renamed", "retired", "pruned") + ) + + def test_a_pruned_entry_never_claims_a_destination(self): + # « pruned » dit que les lignes ne continuent NULLE PART. Lui donner + # un « into » ferait passer une perte réelle pour une fusion. + for entree in quality.SEMANTIC_MAP: + if entree["kind"] == "pruned": + self.assertIsNone(entree["into"], entree) + + def test_properties_became_jsonb_columns_in_18(self): + """La table ne se vide pas : elle GROSSIT, puis disparaît d'un coup. + + 211 lignes en 12, 457 en 17, table absente en 18. Les champs + qu'elle portait sont des colonnes jsonb en 18 — vérifié sur + res_partner.property_payment_term_id. + """ + diff = self.perte("ir_property", 457, 0, version="18.0") + connu = diff["rows_lost"][0][3] + self.assertIsNotNone(connu) + self.assertEqual(connu["kind"], "retired") + self.assertIsNone(connu["into"]) + + def test_properties_are_not_explained_at_the_17_bump(self): + # En 17 la table est à son maximum (457). Dater l'entrée plus tôt + # ferait passer pour attendue une perte qui ne l'est pas. + diff = self.perte("ir_property", 273, 100, version="17.0") + self.assertIsNone(diff["rows_lost"][0][3]) + + def test_tracking_values_were_pruned_in_14(self): + """Le champ suivi passe de varchar à clé étrangère au palier 14. + + En 14 aucune ligne n'a de clé nulle ni cassée : ce qui ne se + résolvait pas a été supprimé. La table SURVIT — 13833 lignes — donc + l'explication ne doit pas prétendre qu'elle a disparu. + """ + diff = self.perte("mail_tracking_value", 16169, 13833, version="14.0") + table, avant, apres, connu = diff["rows_lost"][0] + self.assertEqual((avant, apres), (16169, 13833)) + self.assertIsNotNone(connu) + self.assertEqual(connu["kind"], "pruned") + + def test_tracking_values_are_not_explained_at_the_13_bump(self): + # 16167 en 12, 16169 en 13 : rien n'a encore été élagué. + diff = self.perte("mail_tracking_value", 16167, 10000, version="13.0") + self.assertIsNone(diff["rows_lost"][0][3]) + + def test_a_pruned_loss_is_not_rendered_as_retired(self): + # « retirée de la base » serait faux : la table est toujours là. + diff = self.perte("mail_tracking_value", 16169, 13833, version="14.0") + texte = "\n".join(quality.render_compare(diff, False, 8)) + self.assertIn("mail_tracking_value", texte) + self.assertNotIn(todo_i18n.t("retired from the database"), texte) + self.assertIn(todo_i18n.t("rows dropped, the table remains"), texte) def test_the_column_counts_only_what_needs_an_answer(self): # Afficher 81 quand 14 sont des refontes voulues ferait fuir le From a9d9a085748e84d99db45c8885552d26ae6ec33d Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 21 Aug 2026 05:00:38 -0400 Subject: [PATCH 61/67] [ADD] migration: restore DMS visibility lost with MuK's access model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing was lost at the 13 bump: 69 files, 16 folders and 23 MB of content_binary are identical from 12 to 18. What changed is the security model. MuK filtered on company alone; OCA DMS adds a GLOBAL rule on permission_read, granted only through a dms.access.group or through storages saved as attachment. The migration created no group — MuK had none to convert — and the storages are database. Both doors shut, every user sees zero. Reports without writing unless --apply. The visibility count uses a real user: the global rule spares the superuser, so a sudo count would call a mute database healthy. --- FR --- Rien n'a été perdu au palier 13 : 69 fichiers, 16 dossiers et 23 Mo de content_binary sont identiques de la 12 à la 18. C'est le modèle de sécurité qui a changé. MuK ne filtrait que sur la société ; OCA DMS ajoute une règle GLOBALE sur permission_read, accordée par une dms.access.group ou par un stockage en attachment. La migration n'a créé aucun groupe — MuK n'en avait aucun à convertir — et les stockages sont en database. Les deux portes fermées, personne ne voit rien. Rapport sans écriture sauf --apply. Le comptage de visibilité passe par un vrai utilisateur : la règle globale épargne le super-utilisateur, donc un comptage en sudo déclarerait saine une base muette. Assisted-by: Claude Opus 5 --- script/odoo/migration/dms_access_repair.py | 228 +++++++++++++++++++++ script/todo/todo_i18n.py | 136 ++++++++++++ test/test_dms_access_repair.py | 178 ++++++++++++++++ 3 files changed, 542 insertions(+) create mode 100755 script/odoo/migration/dms_access_repair.py create mode 100644 test/test_dms_access_repair.py diff --git a/script/odoo/migration/dms_access_repair.py b/script/odoo/migration/dms_access_repair.py new file mode 100755 index 0000000..8982198 --- /dev/null +++ b/script/odoo/migration/dms_access_repair.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Rendre visibles les documents DMS qu'une migration a rendus muets. + +Le symptôme : après le palier 13, les documents DMS « ont disparu ». Ils +n'ont pas disparu. Mesuré sur une base migrée : 69 fichiers, 16 dossiers, +23 Mo de `content_binary`, présents à l'identique de la 12 à la 18. Rien +n'a été perdu. + +Ce qui a changé, c'est le MODÈLE DE SÉCURITÉ. En 12, MuK DMS ne filtrait +que sur la société : + + ['|', ('company','=',False), ('company','child_of',[...])] + +OCA DMS, à partir de la 13, ajoute des règles GLOBALES sur une permission +calculée : + + [('permission_read', '=', user.id)] + +`_search_permission_read` n'accorde l'accès que par deux portes : + +1. Une `dms.access.group` dont l'utilisateur est membre, reliée au + dossier — la migration n'en a créé AUCUNE, parce que MuK n'en avait + aucune à convertir. +2. L'héritage depuis l'enregistrement lié, qui exige + `storage.save_type = 'attachment'` — or les stockages migrés sont en + `database`. + +Les deux portes fermées, la règle globale filtre TOUT. Pas un dossier, +pas un fichier, pour personne — l'administrateur compris, car `admin` +n'est pas le super-utilisateur au sens d'Odoo. + +Ce que fait la réparation +------------------------- +Une seule `dms.access.group`, adossée au groupe Odoo `dms.group_dms_user` +plutôt qu'à une liste d'utilisateurs — ainsi l'appartenance continue de +suivre le groupe, comme avant la migration —, reliée aux dossiers +RACINES. Les enfants héritent : ils portent tous +`inherit_group_ids = true`. + +Ce qu'elle ne fait pas : inventer une granularité que MuK n'avait pas. +L'ancien modèle ne connaissait pas d'ACL par dossier ; en fabriquer une +ici serait décider à la place de l'utilisateur, sur des données qu'il +n'a jamais saisies. + +Codes de sortie : 0 rien à faire, 1 il y a à faire (ou c'est fait), 2 échec. +""" + +import argparse +import os +import sys + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) +) + +from script.odoo.migration import database_cleanup # noqa: E402 + +try: + from script.todo.todo_i18n import t +except Exception: # pragma: no cover - repli si i18n indisponible + + def t(key: str) -> str: + return key + + +# Les sentinelles de `run_shell`, pas les nôtres : il les impose, et +# elles ne servent qu'à isoler le rapport des journaux d'Odoo. En +# inventer une seconde paire aurait créé un contrat parallèle à tenir. +DEBUT = database_cleanup.START +FIN = database_cleanup.END +NOM_GROUPE = "Migration MuK → DMS" + +# Le script poussé dans `odoo-bin shell`. Il compte AVANT et APRÈS avec un +# vrai utilisateur : c'est la seule preuve qui vaille — la règle globale +# ne s'applique pas au super-utilisateur, donc un compte fait en sudo +# dirait « tout va bien » alors que personne ne voit rien. +SCRIPT = """ +import json +DRY = {dry} +NOM = {nom!r} +rapport = {{"dry_run": DRY}} +try: + Dossier = env["dms.directory"].sudo() + Fichier = env["dms.file"].sudo() + Groupe = env["dms.access.group"].sudo() + rapport["files"] = Fichier.search_count([]) + rapport["directories"] = Dossier.search_count([]) + rapport["access_groups_before"] = Groupe.search_count([]) + + # Un vrai utilisateur, pas le super-utilisateur : la règle globale ne + # s'applique qu'à lui, et c'est justement ce qu'on mesure. + membres = env.ref("dms.group_dms_user").users.filtered( + lambda u: u.id != 1 and u.active + ) + temoin = membres[:1] + rapport["witness"] = temoin.login if temoin else None + + def visible(): + if not temoin: + return None + return {{ + "directories": Dossier.with_user(temoin).search_count([]), + "files": Fichier.with_user(temoin).search_count([]), + }} + + rapport["before"] = visible() + racines = Dossier.search([("is_root_directory", "=", True)]) + rapport["roots"] = racines.mapped("name") + deja = Groupe.search([("name", "=", NOM)]) + rapport["already_repaired"] = bool(deja) + + if not DRY and not deja and racines: + Groupe.create({{ + "name": NOM, + "perm_create": True, + "perm_write": True, + "perm_unlink": True, + "group_ids": [(6, 0, [env.ref("dms.group_dms_user").id])], + "directory_ids": [(6, 0, racines.ids)], + }}) + env.cr.commit() + env.registry.clear_cache() + rapport["created"] = True + rapport["after"] = visible() + rapport["access_groups_after"] = Groupe.search_count([]) +except Exception as exc: + rapport["error"] = "%s: %s" % (type(exc).__name__, exc) +print({debut!r}) +print(json.dumps(rapport)) +print({fin!r}) +""" + + +def build_script(dry_run): + return SCRIPT.format( + dry="True" if dry_run else "False", + nom=NOM_GROUPE, + debut=DEBUT, + fin=FIN, + ) + + +def render(rapport, dry_run): + lignes = [ + f"📁 {t('DMS documents in the database')} :" + f" {rapport.get('files', '?')} {t('file(s)')}," + f" {rapport.get('directories', '?')} {t('folder(s)')}" + ] + avant = rapport.get("before") + temoin = rapport.get("witness") + if avant is None: + lignes.append(f" ⚠ {t('No DMS user to test visibility with.')}") + else: + lignes.append( + f" {t('Visible to')} {temoin} :" + f" {avant['directories']} {t('folder(s)')}," + f" {avant['files']} {t('file(s)')}" + ) + lignes.append( + f" {t('DMS access groups:')} {rapport.get('access_groups_before', '?')}" + ) + if rapport.get("already_repaired"): + lignes.append(f" ℹ️ {t('Already repaired: the group exists.')}") + return lignes + if dry_run: + lignes.append( + f" → {t('Would create one access group over')}" + f" {len(rapport.get('roots') or [])} {t('root folder(s)')} :" + f" {', '.join(rapport.get('roots') or [])}" + ) + lignes.append(f" {t('Nothing written. Re-run with --apply.')}") + return lignes + apres = rapport.get("after") + if apres: + lignes.append( + f" ✅ {t('Now visible to')} {temoin} :" + f" {apres['directories']} {t('folder(s)')}," + f" {apres['files']} {t('file(s)')}" + ) + return lignes + + +def main(argv=None): + parser = argparse.ArgumentParser( + description=( + "Restore visibility of DMS documents after a MuK DMS to OCA DMS" + " migration. Reports without writing unless --apply is given." + ) + ) + parser.add_argument("-d", "--database", required=True) + parser.add_argument("-c", "--config", default="config.conf") + parser.add_argument( + "--apply", + action="store_true", + help="actually create the access group (default: report only)", + ) + config = parser.parse_args(argv) + + # Le même garde-fou que partout ailleurs : un Odoo d'une autre version + # ÉCRIT dans la base avant d'échouer. + souci = database_cleanup.require_matching_version(config.database) + if souci: + print(f"❌ {souci}") + return 2 + + try: + rapport = database_cleanup.run_shell( + config.database, + config.config, + build_script(not config.apply), + echo=lambda texte: print(f"⧖ {texte}", flush=True), + ) + except RuntimeError as exc: + print(f"❌ {exc}") + return 2 + if rapport.get("error"): + print(f"❌ {rapport['error']}") + return 2 + print("\n".join(render(rapport, not config.apply))) + avant = rapport.get("before") or {} + return 1 if not avant.get("files") else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 2ac36e4..44f4597 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -5791,6 +5791,142 @@ TRANSLATIONS = { "fr": "table(s) expliquées par un changement d'Odoo", "en": "table(s) explained by an Odoo change", }, + "Cannot read the database: ": { + "fr": "Lecture impossible de la base : ", + "en": "Cannot read the database: ", + }, + "Cannot read the package file: ": { + "fr": "Lecture impossible du fichier de packages : ", + "en": "Cannot read the package file: ", + }, + "Modules missing from the default package": { + "fr": "📦 Modules absents du package par défaut", + "en": "📦 Modules missing from the default package", + }, + "Show every entry": { + "fr": "Tout afficher", + "en": "Show every entry", + }, + "List the known packages": { + "fr": "Lister les packages connus", + "en": "List the known packages", + }, + "Already repaired: the group exists.": { + "fr": "Déjà réparé : le groupe existe.", + "en": "Already repaired: the group exists.", + }, + "DMS access groups:": { + "fr": "Groupes d'accès DMS :", + "en": "DMS access groups:", + }, + "DMS documents in the database": { + "fr": "Documents DMS dans la base", + "en": "DMS documents in the database", + }, + "No DMS user to test visibility with.": { + "fr": "Aucun utilisateur DMS pour éprouver la visibilité.", + "en": "No DMS user to test visibility with.", + }, + "Nothing written. Re-run with --apply.": { + "fr": "Rien d'écrit. Relancer avec --apply.", + "en": "Nothing written. Re-run with --apply.", + }, + "Now visible to": { + "fr": "Désormais visibles pour", + "en": "Now visible to", + }, + "Visible to": { + "fr": "Visibles pour", + "en": "Visible to", + }, + "Would create one access group over": { + "fr": "Créerait un groupe d'accès sur", + "en": "Would create one access group over", + }, + "file(s)": { + "fr": "fichier(s)", + "en": "file(s)", + }, + "folder(s)": { + "fr": "dossier(s)", + "en": "folder(s)", + }, + "root folder(s)": { + "fr": "dossier(s) racine", + "en": "root folder(s)", + }, + "Census": { + "fr": "Recensement", + "en": "Census", + }, + "Compared against nothing — census only.": { + "fr": "Comparé à rien — recensement seul.", + "en": "Compared against nothing — census only.", + }, + "Default package:": { + "fr": "Package par défaut :", + "en": "Default package:", + }, + "Every suggested module is installed.": { + "fr": "Tous les modules suggérés sont installés.", + "en": "Every suggested module is installed.", + }, + "Installed modules by author": { + "fr": "Modules installés, par auteur", + "en": "Installed modules by author", + }, + "Modules of": { + "fr": "Modules de", + "en": "Modules of", + }, + "No default package known for this version": { + "fr": "Aucun package par défaut connu pour cette version", + "en": "No default package known for this version", + }, + "Odoo": { + "fr": "Odoo", + "en": "Odoo", + }, + "absent from the addons path — sync the repo first": { + "fr": "hors du chemin des addons — synchroniser le dépôt d'abord", + "en": "absent from the addons path — sync the repo first", + }, + "also needs": { + "fr": "réclame aussi", + "en": "also needs", + }, + "half-way — finish the pending update": { + "fr": "à mi-chemin — finir la mise à jour en attente", + "en": "half-way — finish the pending update", + }, + "installed": { + "fr": "déjà en place", + "en": "installed", + }, + "known": { + "fr": "connus", + "en": "known", + }, + "known to the database — install it": { + "fr": "dans la base — à installer", + "en": "known to the database — install it", + }, + "present but broken — fix the dependency": { + "fr": "en panne de dépendance — à réparer", + "en": "present but broken — fix the dependency", + }, + "suggested module(s)": { + "fr": "module(s) suggéré(s)", + "en": "suggested module(s)", + }, + "suggested module(s) not installed": { + "fr": "module(s) suggéré(s) non installé(s)", + "en": "suggested module(s) not installed", + }, + "unknown author": { + "fr": "auteur inconnu", + "en": "unknown author", + }, "rows dropped, the table remains": { "fr": "lignes supprimées, la table demeure", "en": "rows dropped, the table remains", diff --git a/test/test_dms_access_repair.py b/test/test_dms_access_repair.py new file mode 100644 index 0000000..bfa9f96 --- /dev/null +++ b/test/test_dms_access_repair.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Ce qui doit tenir avant de toucher à une base de production. + +Deux propriétés portent tout le reste. La première : sans `--apply`, +RIEN ne s'écrit — un outil de diagnostic qui répare tout seul est un +piège. La seconde : le compte de visibilité doit être fait avec un vrai +utilisateur, jamais en sudo, car la règle globale d'OCA DMS ne s'applique +pas au super-utilisateur et un compte en sudo dirait « tout va bien » +alors que personne ne voit rien. +""" + +import ast +import io +import os +import sys +import unittest +from contextlib import redirect_stdout + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) +) + +from script.odoo.migration import dms_access_repair as repair # noqa: E402 +from script.todo import todo_i18n # noqa: E402 + + +class TestTheGeneratedScript(unittest.TestCase): + def test_the_dry_run_never_creates_anything(self): + # Le garde vit dans le script POUSSÉ : c'est lui qui décide, pas + # l'appelant. Un `DRY = True` qui n'entoure pas le `create` ne + # protège de rien. + code = repair.build_script(dry_run=True) + self.assertIn("DRY = True", code) + arbre = ast.parse(code) + creations = [ + n + for n in ast.walk(arbre) + if isinstance(n, ast.Call) + and isinstance(n.func, ast.Attribute) + and n.func.attr == "create" + ] + self.assertTrue(creations, "aucune création dans le script ?") + for noeud in creations: + self.assertTrue( + self._under_dry_guard(arbre, noeud), + "une création hors du garde `not DRY`", + ) + + @staticmethod + def _under_dry_guard(arbre, cible): + """La création est-elle sous un `if not DRY ...` ?""" + for noeud in ast.walk(arbre): + if not isinstance(noeud, ast.If): + continue + texte = ast.dump(noeud.test) + if "DRY" not in texte: + continue + for enfant in ast.walk(noeud): + if enfant is cible: + return True + return False + + def test_apply_lifts_the_guard(self): + self.assertIn("DRY = False", repair.build_script(dry_run=False)) + + def test_the_script_is_valid_python(self): + for dry in (True, False): + ast.parse(repair.build_script(dry_run=dry)) + + def test_visibility_is_measured_with_a_real_user(self): + # `with_user` est le point tout entier : en sudo, la règle globale + # ne s'applique pas et le rapport serait rassurant à tort. + code = repair.build_script(dry_run=True) + self.assertIn("with_user(temoin)", code) + self.assertIn("u.id != 1", code) + + def test_it_reuses_the_shared_sentinels(self): + from script.odoo.migration import database_cleanup + + self.assertEqual(repair.DEBUT, database_cleanup.START) + self.assertEqual(repair.FIN, database_cleanup.END) + + +class TestTheReport(unittest.TestCase): + PLEIN = { + "files": 69, + "directories": 16, + "access_groups_before": 0, + "witness": "marie@example.org", + "before": {"directories": 0, "files": 0}, + "roots": ["CNESST", "Contrat"], + "already_repaired": False, + } + + def test_it_shows_what_exists_and_what_is_seen(self): + texte = "\n".join(repair.render(self.PLEIN, dry_run=True)) + self.assertIn("69", texte) + self.assertIn("marie@example.org", texte) + + def test_a_dry_run_says_nothing_was_written(self): + texte = "\n".join(repair.render(self.PLEIN, dry_run=True)) + self.assertIn( + todo_i18n.t("Nothing written. Re-run with --apply."), texte + ) + + def test_an_applied_run_reports_the_new_visibility(self): + rapport = dict(self.PLEIN, after={"directories": 16, "files": 69}) + texte = "\n".join(repair.render(rapport, dry_run=False)) + self.assertIn(todo_i18n.t("Now visible to"), texte) + + def test_an_already_repaired_database_says_so_and_stops(self): + rapport = dict(self.PLEIN, already_repaired=True) + texte = "\n".join(repair.render(rapport, dry_run=True)) + self.assertIn( + todo_i18n.t("Already repaired: the group exists."), texte + ) + self.assertNotIn( + todo_i18n.t("Would create one access group over"), texte + ) + + def test_no_witness_is_flagged_not_silently_fine(self): + # Sans utilisateur témoin on ne SAIT pas : le taire ferait passer + # une base muette pour une base saine. + rapport = dict(self.PLEIN, before=None, witness=None) + texte = "\n".join(repair.render(rapport, dry_run=True)) + self.assertIn( + todo_i18n.t("No DMS user to test visibility with."), texte + ) + + def test_every_translation_key_exists(self): + with io.open( + repair.__file__.replace(".pyc", ".py"), encoding="utf-8" + ) as handle: + src = handle.read() + for node in ast.walk(ast.parse(src)): + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "t" + and node.args + and isinstance(node.args[0], ast.Constant) + ): + self.assertIn(node.args[0].value, todo_i18n.TRANSLATIONS) + + +class TestTheVersionGuard(unittest.TestCase): + def setUp(self): + from script.odoo.migration import database_cleanup + + self.cleanup = database_cleanup + self.vraie = database_cleanup.require_matching_version + self.vrai_shell = database_cleanup.run_shell + self.appels = [] + database_cleanup.run_shell = ( + lambda *a, **k: self.appels.append(a) or {} + ) + + def tearDown(self): + self.cleanup.require_matching_version = self.vraie + self.cleanup.run_shell = self.vrai_shell + + def test_a_mismatch_stops_before_opening_the_database(self): + # Un Odoo d'une autre version ÉCRIT avant d'échouer : le refus + # doit précéder toute ouverture, pas la suivre. + self.cleanup.require_matching_version = lambda base: "18.0 vs 12.0" + tampon = io.StringIO() + with redirect_stdout(tampon): + code = repair.main(["-d", "vieille_base"]) + self.assertEqual(code, 2) + self.assertEqual(self.appels, []) + self.assertIn("18.0 vs 12.0", tampon.getvalue()) + + +if __name__ == "__main__": + unittest.main() From 8c7ff096b31be1b4a163c73e49aa85268df4a982 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 21 Aug 2026 05:25:23 -0400 Subject: [PATCH 62/67] [ADD] migration: repair DMS at the 13 bump, and catch the whole class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repair now runs by itself where the breakage happens: MuK becomes OCA DMS at 13 and nowhere else, so the hook is gated on that bump. It stays quiet on databases without DMS and on ones already repaired, and a successful --apply now exits 0 instead of 1. The wider hole stays open otherwise: data PRESENT that a global rule hides entirely. Row counts said everything was there — true. The smoke test served every page — true. Nobody could reach the records. The new check runs at every bump and names any model with rows that no active internal user can see a single one of. Proven both ways on a copy: 85 models clean after the repair, dms.file and dms.directory named before it. --- FR --- La réparation s'exécute désormais là où la casse a lieu : MuK devient OCA DMS au palier 13 et nulle part ailleurs, d'où le garde. Elle se tait sur une base sans DMS et sur une base déjà réparée, et un --apply réussi rend 0 au lieu de 1. Le trou plus large restait ouvert : des données PRÉSENTES qu'une règle globale masque entièrement. Les comptages disaient « tout est là » — vrai. Le test de fumée servait les pages — vrai. Personne n'atteignait les données. Le nouveau test tourne à chaque palier et nomme tout modèle dont aucun utilisateur interne actif ne voit une seule ligne. Éprouvé dans les deux sens sur une copie : 85 modèles sains après réparation, dms.file et dms.directory nommés avant. Assisted-by: Claude Opus 5 --- script/odoo/migration/check_hidden_models.py | 206 ++++++++++++++++ script/odoo/migration/dms_access_repair.py | 21 ++ script/todo/todo_i18n.py | 36 +++ script/todo/todo_upgrade.py | 30 +++ test/test_check_hidden_models.py | 241 +++++++++++++++++++ test/test_dms_access_repair.py | 6 +- 6 files changed, 539 insertions(+), 1 deletion(-) create mode 100755 script/odoo/migration/check_hidden_models.py create mode 100644 test/test_check_hidden_models.py diff --git a/script/odoo/migration/check_hidden_models.py b/script/odoo/migration/check_hidden_models.py new file mode 100755 index 0000000..dc567ad --- /dev/null +++ b/script/odoo/migration/check_hidden_models.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Les modèles qui ont des données que PLUS PERSONNE ne peut voir. + +D'où vient ce test +------------------ +Après une migration 12 → 18, les documents DMS avaient « disparu ». Ils +n'avaient pas disparu : 69 fichiers et 23 Mo étaient intacts en base. Ce +qui avait changé, c'est le modèle de sécurité — OCA DMS pose une règle +GLOBALE sur une permission accordée par un `dms.access.group`, et la +conversion depuis MuK n'en avait créé aucun, faute d'équivalent à +convertir. + +Rien ne pouvait le voir venir. Les comptages de lignes disaient « tout est +là », et c'était vrai. Le test de fumée ouvrait des pages publiques, et +elles répondaient. Le trou est exactement entre les deux : des données +présentes, et une règle qui les masque intégralement. + +Ce qu'on cherche +---------------- +Un modèle tel que : il a des lignes, il porte au moins une règle GLOBALE, +et AUCUN utilisateur interne actif n'en voit une seule. « Aucun » est le +mot important — un modèle que seul un comptable voit est normal ; un +modèle que personne ne voit est soit une refonte de sécurité ratée, soit +des données devenues inatteignables. + +Pourquoi pas en SQL +------------------- +Une règle est un domaine Odoo, parfois avec `user.`, `company_ids`, ou un +champ calculé cherchable — `permission_read` de DMS en est un, et il +déclenche une sous-requête que rien dans `ir_rule` ne laisse deviner. Il +faut l'ORM pour l'évaluer, donc le shell. + +Le super-utilisateur est exclu : les règles ne s'appliquent pas à lui, et +un comptage fait en son nom déclarerait saine une base muette. C'est +précisément l'erreur qui aurait laissé passer le cas DMS. + +Codes de sortie : 0 rien à signaler, 1 des trouvailles, 2 l'outil a échoué. +""" + +import argparse +import os +import sys + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) +) + +from script.odoo.migration import database_cleanup # noqa: E402 + +try: + from script.todo.todo_i18n import t +except Exception: # pragma: no cover - repli si i18n indisponible + + def t(key: str) -> str: + return key + + +DEBUT = database_cleanup.START +FIN = database_cleanup.END + +# Ces modèles sont invisibles PAR DESSEIN : ils portent des règles globales +# et ne s'adressent à personne en particulier. Les signaler à chaque +# migration noierait la vraie trouvaille sous le bruit, et un rapport +# qu'on apprend à ignorer ne sert plus à rien. +ATTENDUS = ( + "ir.rule", + "ir.model.access", + "res.users.log", + "bus.bus", + "bus.presence", + "mail.notification", + "ir.logging", + "ir.autovacuum", +) + +SCRIPT = """ +import json +LIMITE = {limite} +ATTENDUS = set({attendus!r}) +rapport = {{"models": [], "checked": 0, "users": []}} +try: + # Les utilisateurs INTERNES actifs, sans le super-utilisateur : les + # règles ne s'appliquent pas à lui, et compter en son nom + # déclarerait saine une base que personne ne peut lire. + membres = env["res.users"].sudo().search([ + ("active", "=", True), + ("id", "!=", 1), + ("share", "=", False), + ], limit=LIMITE) + rapport["users"] = membres.mapped("login") + if not membres: + rapport["no_user"] = True + else: + vus = set( + env["ir.rule"].sudo() + .search([("global", "=", True), ("active", "=", True)]) + .mapped("model_id.model") + ) + for nom in sorted(vus - ATTENDUS): + modele = env.get(nom) + if modele is None or modele._abstract or modele._transient: + continue + try: + total = modele.sudo().search_count([]) + except Exception: + continue + if not total: + continue + rapport["checked"] += 1 + # Court-circuit : dès qu'UN utilisateur voit une ligne, le + # modèle n'est pas muet. Inutile d'interroger les autres. + visible = False + for membre in membres: + try: + if modele.with_user(membre).search([], limit=1): + visible = True + break + except Exception: + # Un refus d'accès n'est pas une ligne visible. + continue + if not visible: + rapport["models"].append({{"model": nom, "rows": total}}) +except Exception as exc: + rapport["error"] = "%s: %s" % (type(exc).__name__, exc) +print({debut!r}) +print(json.dumps(rapport)) +print({fin!r}) +""" + + +def build_script(limite=25): + return SCRIPT.format( + limite=limite, attendus=list(ATTENDUS), debut=DEBUT, fin=FIN + ) + + +def render(rapport): + if rapport.get("no_user"): + return [f"⚠ {t('No internal user to test visibility with.')}"] + muets = rapport.get("models") or [] + lignes = [ + f"🔍 {rapport.get('checked', 0)}" + f" {t('model(s) with a global rule and some data,')}" + f" {t('checked against')} {len(rapport.get('users') or [])}" + f" {t('internal user(s)')}" + ] + if not muets: + lignes.append(f" ✅ {t('Every one of them is visible to someone.')}") + return lignes + lignes.append( + f" ❌ {len(muets)} {t('model(s) nobody can see a single row of')} :" + ) + for entree in sorted(muets, key=lambda item: -item["rows"]): + lignes.append( + f" {entree['model']:<38} {entree['rows']:>8}" + f" {t('row(s)')}" + ) + lignes.append( + f" {t('The data is there; a global rule hides all of it.')}" + ) + return lignes + + +def main(argv=None): + parser = argparse.ArgumentParser( + description=( + "Report models that hold data no internal user can see," + " because a global record rule filters every row out." + ) + ) + parser.add_argument("-d", "--database", required=True) + parser.add_argument("-c", "--config", default="config.conf") + parser.add_argument( + "--users", + type=int, + default=25, + help="how many internal users to test with (default: 25)", + ) + config = parser.parse_args(argv) + + souci = database_cleanup.require_matching_version(config.database) + if souci: + print(f"❌ {souci}") + return 2 + try: + rapport = database_cleanup.run_shell( + config.database, + config.config, + build_script(config.users), + echo=lambda texte: print(f"⧖ {texte}", flush=True), + ) + except RuntimeError as exc: + print(f"❌ {exc}") + return 2 + if rapport.get("error"): + print(f"❌ {rapport['error']}") + return 2 + print("\n".join(render(rapport))) + return 1 if rapport.get("models") else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/odoo/migration/dms_access_repair.py b/script/odoo/migration/dms_access_repair.py index 8982198..1407328 100755 --- a/script/odoo/migration/dms_access_repair.py +++ b/script/odoo/migration/dms_access_repair.py @@ -83,6 +83,15 @@ DRY = {dry} NOM = {nom!r} rapport = {{"dry_run": DRY}} try: + # Sans DMS, il n'y a rien à réparer et ce n'est pas une erreur : cet + # outil tourne à chaque migration vers la 13, et la plupart des bases + # n'ont jamais eu de DMS. Lever ici les ferait toutes échouer. + if "dms.file" not in env: + rapport["absent"] = True + print({debut!r}) + print(json.dumps(rapport)) + print({fin!r}) + raise SystemExit(0) Dossier = env["dms.directory"].sudo() Fichier = env["dms.file"].sudo() Groupe = env["dms.access.group"].sudo() @@ -144,6 +153,8 @@ def build_script(dry_run): def render(rapport, dry_run): + if rapport.get("absent"): + return [f"ℹ️ {t('No DMS in this database, nothing to repair.')}"] lignes = [ f"📁 {t('DMS documents in the database')} :" f" {rapport.get('files', '?')} {t('file(s)')}," @@ -220,7 +231,17 @@ def main(argv=None): print(f"❌ {rapport['error']}") return 2 print("\n".join(render(rapport, not config.apply))) + if rapport.get("absent"): + return 0 avant = rapport.get("before") or {} + if config.apply: + # Une réparation RÉUSSIE doit rendre 0. Rendre « il y a des + # trouvailles » après avoir tout remis en place ferait échouer + # n'importe quelle chaîne make qui appelle l'outil. + apres = rapport.get("after") or {} + if rapport.get("already_repaired"): + return 0 + return 0 if apres.get("files") else 2 return 1 if not avant.get("files") else 0 diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 44f4597..4998b01 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -5855,6 +5855,42 @@ TRANSLATIONS = { "fr": "dossier(s) racine", "en": "root folder(s)", }, + "No DMS in this database, nothing to repair.": { + "fr": "Pas de DMS dans cette base, rien à réparer.", + "en": "No DMS in this database, nothing to repair.", + }, + "Every one of them is visible to someone.": { + "fr": "Chacun est visible par quelqu'un.", + "en": "Every one of them is visible to someone.", + }, + "No internal user to test visibility with.": { + "fr": "Aucun utilisateur interne pour éprouver la visibilité.", + "en": "No internal user to test visibility with.", + }, + "The data is there; a global rule hides all of it.": { + "fr": "Les données sont là ; une règle globale les masque entièrement.", + "en": "The data is there; a global rule hides all of it.", + }, + "checked against": { + "fr": "éprouvés avec", + "en": "checked against", + }, + "internal user(s)": { + "fr": "utilisateur(s) interne(s)", + "en": "internal user(s)", + }, + "model(s) nobody can see a single row of": { + "fr": "modèle(s) dont personne ne voit une seule ligne", + "en": "model(s) nobody can see a single row of", + }, + "model(s) with a global rule and some data,": { + "fr": "modèle(s) avec une règle globale et des données,", + "en": "model(s) with a global rule and some data,", + }, + "row(s)": { + "fr": "ligne(s)", + "en": "row(s)", + }, "Census": { "fr": "Recensement", "en": "Census", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index 72c9836..6f68877 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -2727,6 +2727,23 @@ class TodoUpgrade: f"./script/addons/update_addons_all.sh {database_name_upgrade}", ) + # Au palier 13 SEULEMENT, et après la mise à jour des + # modules : c'est là que MuK DMS devient OCA DMS, et donc + # là que le modèle de sécurité change. OCA pose des règles + # GLOBALES sur `permission_read`, accordé par une + # `dms.access.group` — que la conversion ne crée pas, + # puisque MuK n'en avait aucune. Les documents restent en + # base, intacts, et plus personne ne les voit. + # + # L'outil ne fait rien s'il n'y a pas de DMS, et rien non + # plus s'il a déjà réparé : le rejouer est sans effet. + if next_version == 13: + self.run_on_terminal( + f"{PYTHON_BIN}" + " ./script/odoo/migration/dms_access_repair.py" + f" -d {database_name_upgrade} --apply" + ) + print( f"✅ -> {t('Database upgrade done for Odoo')}" f"{next_version}" @@ -2743,6 +2760,19 @@ class TodoUpgrade: self.prompt_database_cleanup(database_name_upgrade) self.prompt_smoke_public_url(database_name_upgrade) + # Le trou que ni les comptages ni le test de fumée ne + # voient : des données PRÉSENTES qu'une règle globale + # masque intégralement. Les comptages disent « tout est + # là » — et c'est vrai. Les pages publiques répondent — et + # c'est vrai aussi. Pourtant plus personne n'atteint les + # données. Mesuré sur DMS au palier 13 : 69 fichiers et + # 23 Mo intacts, zéro visible, pour tous les utilisateurs. + self.run_on_terminal( + f"{PYTHON_BIN}" + " ./script/odoo/migration/check_hidden_models.py" + f" -d {database_name_upgrade}" + ) + print(f"[y] {t('Open the server with Selenium')}") print(f"[a] {t('Open it at EVERY version bump, stop asking')}") # Une migration traverse jusqu'à six paliers. Répondre « y » diff --git a/test/test_check_hidden_models.py b/test/test_check_hidden_models.py new file mode 100644 index 0000000..75fe51f --- /dev/null +++ b/test/test_check_hidden_models.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Le test qui aurait vu venir la disparition des documents DMS. + +Une propriété porte tout : le comptage se fait avec de VRAIS utilisateurs +internes, jamais avec le super-utilisateur. Les règles ne s'appliquent pas +à lui ; un comptage fait en son nom déclarerait saine une base que +personne ne peut lire — exactement l'erreur qui a laissé passer le cas +DMS pendant six paliers. +""" + +import ast +import io +import os +import sys +import unittest +from contextlib import redirect_stdout + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) +) + +from script.odoo.migration import check_hidden_models as check # noqa: E402 +from script.todo import todo_i18n # noqa: E402 + + +class TestTheGeneratedScript(unittest.TestCase): + def test_it_is_valid_python(self): + ast.parse(check.build_script()) + + def test_the_superuser_is_excluded(self): + # Sans cette exclusion, tout paraît visible et le test ne sert à + # rien : c'est le cœur du sujet. + code = check.build_script() + self.assertIn('("id", "!=", 1)', code) + + def test_only_internal_users_count(self): + # Un portail ne voit presque rien par construction : l'inclure + # ferait crier au loup sur des modèles parfaitement sains. + self.assertIn('("share", "=", False)', check.build_script()) + + def test_only_active_users_count(self): + self.assertIn('("active", "=", True)', check.build_script()) + + def test_it_looks_at_global_rules_only(self): + # Une règle non globale ne s'applique qu'à certains groupes : + # qu'elle masque tout pour eux est normal. + code = check.build_script() + self.assertIn('("global", "=", True)', code) + self.assertIn('("active", "=", True)', code) + + def test_empty_models_are_skipped(self): + # Un modèle sans ligne n'a rien à cacher ; le signaler noierait la + # vraie trouvaille. + self.assertIn("if not total:", check.build_script()) + + def test_it_stops_at_the_first_user_who_sees_something(self): + # Sans court-circuit, le coût est modèles × utilisateurs sur une + # base où presque tout est visible. + self.assertIn("break", check.build_script()) + + def test_the_user_limit_is_honoured(self): + code = check.build_script(limite=7) + self.assertIn("LIMITE = 7", code) + self.assertIn("limit=LIMITE", code) + + def test_technical_models_are_excluded_by_name(self): + code = check.build_script() + for nom in ("ir.rule", "ir.model.access"): + self.assertIn(nom, code) + + def test_the_exclusion_list_never_swallows_business_models(self): + # Une exclusion trop large rendrait le test muet sans le dire. + for nom in check.ATTENDUS: + self.assertTrue( + nom.startswith(("ir.", "bus.", "res.users.log", "mail.")), + f"exclusion suspecte : {nom}", + ) + + def test_it_reuses_the_shared_sentinels(self): + from script.odoo.migration import database_cleanup + + self.assertEqual(check.DEBUT, database_cleanup.START) + self.assertEqual(check.FIN, database_cleanup.END) + + +class TestTheReport(unittest.TestCase): + def test_a_clean_database_says_so(self): + texte = "\n".join( + check.render({"models": [], "checked": 85, "users": ["a", "b"]}) + ) + self.assertIn( + todo_i18n.t("Every one of them is visible to someone."), texte + ) + + def test_a_finding_names_the_model_and_the_volume(self): + rapport = { + "models": [ + {"model": "dms.file", "rows": 69}, + {"model": "dms.directory", "rows": 16}, + ], + "checked": 85, + "users": ["a"], + } + texte = "\n".join(check.render(rapport)) + self.assertIn("dms.file", texte) + self.assertIn("69", texte) + self.assertIn( + todo_i18n.t("The data is there; a global rule hides all of it."), + texte, + ) + + def test_the_biggest_loss_comes_first(self): + rapport = { + "models": [ + {"model": "aaa.petit", "rows": 3}, + {"model": "zzz.gros", "rows": 900}, + ], + "checked": 2, + "users": ["a"], + } + texte = "\n".join(check.render(rapport)) + self.assertLess(texte.index("zzz.gros"), texte.index("aaa.petit")) + + def test_no_internal_user_is_flagged_not_called_clean(self): + # Sans utilisateur, on ne SAIT pas. Dire « tout va bien » serait + # un mensonge tranquille. + texte = "\n".join(check.render({"no_user": True})) + self.assertIn( + todo_i18n.t("No internal user to test visibility with."), texte + ) + self.assertNotIn( + todo_i18n.t("Every one of them is visible to someone."), texte + ) + + def test_every_translation_key_exists(self): + with io.open(check.__file__, encoding="utf-8") as handle: + src = handle.read() + for node in ast.walk(ast.parse(src)): + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "t" + and node.args + and isinstance(node.args[0], ast.Constant) + ): + cle = node.args[0].value + self.assertTrue( + cle in todo_i18n.TRANSLATIONS, + f"clé sans traduction : {cle!r}", + ) + + +class TestTheExitCodes(unittest.TestCase): + def setUp(self): + from script.odoo.migration import database_cleanup + + self.cleanup = database_cleanup + self.vraie = database_cleanup.require_matching_version + self.vrai_shell = database_cleanup.run_shell + database_cleanup.require_matching_version = lambda base: None + + def tearDown(self): + self.cleanup.require_matching_version = self.vraie + self.cleanup.run_shell = self.vrai_shell + + def lance(self, rapport): + self.cleanup.run_shell = lambda *a, **k: rapport + tampon = io.StringIO() + with redirect_stdout(tampon): + code = check.main(["-d", "db"]) + return code, tampon.getvalue() + + def test_nothing_hidden_exits_zero(self): + code, _ = self.lance({"models": [], "checked": 3, "users": ["a"]}) + self.assertEqual(code, 0) + + def test_something_hidden_exits_one(self): + code, _ = self.lance( + { + "models": [{"model": "dms.file", "rows": 69}], + "checked": 3, + "users": ["a"], + } + ) + self.assertEqual(code, 1) + + def test_a_shell_error_exits_two(self): + # 2 dit « l'outil a échoué », pas « rien trouvé » : les confondre + # ferait conclure qu'une migration est saine sans l'avoir vérifiée. + code, _ = self.lance({"error": "boom"}) + self.assertEqual(code, 2) + + def test_a_version_mismatch_stops_before_opening_the_database(self): + self.cleanup.require_matching_version = lambda base: "18.0 vs 12.0" + appels = [] + self.cleanup.run_shell = lambda *a, **k: appels.append(a) or {} + with redirect_stdout(io.StringIO()): + code = check.main(["-d", "db"]) + self.assertEqual(code, 2) + self.assertEqual(appels, []) + + +class TestTheWiring(unittest.TestCase): + RACINE = os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) + + def source(self): + with io.open( + os.path.join(self.RACINE, "script", "todo", "todo_upgrade.py"), + encoding="utf-8", + ) as handle: + return handle.read() + + def test_the_detector_runs_at_every_bump(self): + src = self.source() + self.assertIn("check_hidden_models.py", src) + + def test_the_dms_repair_runs_at_the_13_bump_only(self): + # MuK devient OCA DMS à ce palier-là et à aucun autre. Le lancer + # partout coûterait un démarrage d'Odoo par palier pour rien. + src = self.source() + self.assertIn( + "if next_version == 13:", src, "le garde de palier a disparu" + ) + debut = src.index("if next_version == 13:") + fin = src.index("dms_access_repair.py") + self.assertLess(debut, fin) + self.assertLess(fin - debut, 700, "le garde de palier s'est éloigné") + + def test_the_repair_is_applied_not_only_reported(self): + # Sans --apply il n'écrit rien : câblé sans, il ne réparerait + # jamais et la migration resterait cassée en silence. + src = self.source() + debut = src.index("dms_access_repair.py") + self.assertIn("--apply", src[debut : debut + 200]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_dms_access_repair.py b/test/test_dms_access_repair.py index bfa9f96..e5742e9 100644 --- a/test/test_dms_access_repair.py +++ b/test/test_dms_access_repair.py @@ -143,7 +143,11 @@ class TestTheReport(unittest.TestCase): and node.args and isinstance(node.args[0], ast.Constant) ): - self.assertIn(node.args[0].value, todo_i18n.TRANSLATIONS) + cle = node.args[0].value + self.assertTrue( + cle in todo_i18n.TRANSLATIONS, + f"clé sans traduction : {cle!r}", + ) class TestTheVersionGuard(unittest.TestCase): From ff8b6bcaf90d3a968b9653db739d22cae36b8ef2 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 21 Aug 2026 05:55:54 -0400 Subject: [PATCH 63/67] [ADD] migration quality: lay OpenUpgrade's declared changes over the real ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The report counted + and -. A field that stops being stored is neither: it loses its column and keeps its existence. Read as a loss it cries wolf every step; ignored it would mask a real one. OpenUpgrade already knows, in the upgrade_analysis.txt files the checkout carries -- the same source the OCA coverage pages are generated from, read locally, at the checkout's exact version. Three categories keep the signal from drowning: a field whose model went away is not a second finding (544 of them on one real step), a field of a module OpenUpgrade does not analyse was never its business, and the rest is grouped by field name -- __last_update counted itself 391 times. --- FR --- Le rapport comptait des + et des -. Un champ qui cesse d'être stocké n'est ni l'un ni l'autre : il perd sa colonne et garde son existence. Lu comme une perte, il alarme à chaque palier ; ignoré, il en masquerait une vraie. OpenUpgrade le sait déjà, dans les upgrade_analysis.txt que porte le checkout -- la source des pages de couverture de l'OCA, lue localement, à la version exacte. Trois catégories évitent que le signal se noie : un champ dont le modèle a disparu n'est pas une trouvaille de plus (544 sur un palier réel), un champ d'un module non analysé n'a jamais relevé d'OpenUpgrade, et le reste est groupé par nom -- __last_update se comptait 391 fois. Assisted-by: Claude Opus 5 --- script/analyse/check_migration_quality.py | 222 +++++++++++++++++- script/analyse/openupgrade_analysis.py | 267 ++++++++++++++++++++++ script/todo/todo_i18n.py | 64 ++++++ test/test_check_migration_quality.py | 155 +++++++++++++ test/test_openupgrade_analysis.py | 186 +++++++++++++++ 5 files changed, 892 insertions(+), 2 deletions(-) create mode 100644 script/analyse/openupgrade_analysis.py create mode 100644 test/test_openupgrade_analysis.py diff --git a/script/analyse/check_migration_quality.py b/script/analyse/check_migration_quality.py index f69f3c7..4b5123d 100755 --- a/script/analyse/check_migration_quality.py +++ b/script/analyse/check_migration_quality.py @@ -218,6 +218,21 @@ def inspect(database): "SELECT model || '.' || name FROM ir_model_fields ORDER BY 1", ) etat["field"] = sorted(ligne[0] for ligne in champs or []) + # À quel module appartient chaque champ. Sans cela, un champ d'un + # module OCA compterait comme « non déclaré par OpenUpgrade » — + # littéralement vrai, et trompeur : OpenUpgrade n'analyse que le cœur + # d'Odoo, il ne pouvait rien en dire. + origines = run_psql( + database, + "SELECT f.model || '.' || f.name, d.module FROM ir_model_fields f" + " JOIN ir_model_data d ON d.model = 'ir.model.fields'" + " AND d.res_id = f.id", + ) + dct_origine = {} + for ligne in origines or []: + if len(ligne) >= 2: + dct_origine.setdefault(ligne[0], set()).add(ligne[1]) + etat["field_module"] = {cle: sorted(v) for cle, v in dct_origine.items()} # Les copies COW par leur CLÉ : c'est elle qu'on réinitialise, et # c'est par elle qu'on les retrouve d'une version à l'autre. copies = run_psql( @@ -577,6 +592,91 @@ def as_version(etat): return None +_CACHE_DECLARE = {} + + +def declared_index(version): + """L'index OpenUpgrade du palier, mis en cache par version. + + Lire 402 fichiers coûte 0,04 s — négligeable une fois, pas six fois + par rapport quand on compare tous les paliers d'affilée. + """ + if version not in _CACHE_DECLARE: + from script.analyse import openupgrade_analysis + + _CACHE_DECLARE[version] = openupgrade_analysis.load(version) + return _CACHE_DECLARE[version] + + +def overlay_declared( + version, modeles_perdus, champs_perdus, origine_champ=None +): + """Le THÉORIQUE posé sur le PRATIQUE : qui avait été annoncé ? + + Une perte déclarée par OpenUpgrade n'est pas un incident, c'est le + palier qui fait son travail. Les séparer transforme « 300 champs + disparus » — un chiffre devant lequel on ne peut rien faire — en une + poignée de champs que personne n'a annoncés, et qu'il faut regarder. + + Le cas qui justifie tout : « devenu calculé ». Le champ EXISTE + toujours ; il n'a plus de colonne. Ni gain ni perte : transformation. + """ + from script.analyse import openupgrade_analysis + + if not version: + return {"available": False, "reason": "version"} + index = declared_index(version) + if not index["modules"]: + return {"available": False, "reason": "missing"} + + modeles = {"obsolete": [], "renamed": [], "undeclared": []} + for nom in modeles_perdus: + change = openupgrade_analysis.model_change(nom, index) + if not change: + modeles["undeclared"].append(nom) + elif change[0] == "renamed": + modeles["renamed"].append((nom, change[1])) + else: + modeles["obsolete"].append(nom) + + champs = { + "del": [], + "unstored": [], + "company_dependent": [], + "moved": [], + "model_gone": [], + "not_analysed": [], + "undeclared": [], + } + partis = tuple(f"{nom}." for nom in modeles_perdus) + analyses = openupgrade_analysis.analysed_modules(version) + for cle in champs_perdus: + change = openupgrade_analysis.field_change(cle, index) + if change: + if change[0] == "moved": + champs["moved"].append((cle, change[1])) + else: + champs[change[0]].append(cle) + continue + # Un champ dont le MODÈLE a disparu n'est pas une trouvaille de + # plus : c'est la même, comptée une fois par champ. Sur un palier + # réel cela triplait la liste et noyait le vrai signal. + if partis and cle.startswith(partis): + champs["model_gone"].append(cle) + continue + origines = set((origine_champ or {}).get(cle) or []) + if origines and not (origines & analyses): + champs["not_analysed"].append(cle) + continue + champs["undeclared"].append(cle) + return { + "available": True, + "modules": index["modules"], + "models": modeles, + "fields": champs, + } + + def explain_loss(table, version): """Ce qu'Odoo a fait de cette table à cette version, ou None. @@ -635,14 +735,22 @@ def compare(avant, apres): # Les plus grosses pertes EN TÊTE : on attaque une liste de # cinquante-sept par le haut, pas par ordre alphabétique. lignes_perdues.sort(key=lambda item: -(item[1] - item[2])) + champs_perdus = sorted(champ_avant - champ_apres) + modeles_perdus = sorted(mod_avant - mod_apres) return { - "fields_lost": sorted(champ_avant - champ_apres), + "declared": overlay_declared( + version, + modeles_perdus, + champs_perdus, + avant.get("field_module"), + ), + "fields_lost": champs_perdus, "fields_gained": sorted(champ_apres - champ_avant), "cow_lost": sorted(cow_avant - cow_apres), "cow_gained": sorted(cow_apres - cow_avant), "modules_lost": sorted(inst_avant - inst_apres), "modules_gained": sorted(inst_apres - inst_avant), - "models_lost": sorted(mod_avant - mod_apres), + "models_lost": modeles_perdus, "models_gained": sorted(mod_apres - mod_avant), "rows_lost": lignes_perdues, "rows_gained": lignes_gagnees, @@ -735,6 +843,115 @@ def overall(lst_snapshot): return compare(presents[0], presents[-1]) +DECLARE_MODELES = ( + ("obsolete", "declared obsolete", "dim"), + ("renamed", "renamed by Odoo", "dim"), + ("undeclared", "NOT declared by OpenUpgrade", "warn"), +) + +# L'ordre range du plus rassurant au plus inquiétant : ce qu'on doit +# regarder finit la liste, donc reste sous les yeux. +DECLARE_CHAMPS = ( + ("del", "declared removed", "dim"), + ("moved", "moved to another module", "dim"), + ("company_dependent", "became a per-company jsonb column", "dim"), + ( + "unstored", + "computed now — the field remains, the column does not", + "ok", + ), + ("model_gone", "their model went away too", "dim"), + ("not_analysed", "in a module OpenUpgrade does not analyse", "dim"), + ("undeclared", "NOT declared by OpenUpgrade", "warn"), +) + + +def group_by_field_name(cles): + """[(nom, combien, exemple)] par nom de champ, les plus nombreux d'abord. + + Un champ retiré d'un MIXIN disparaît de tous les modèles qui en + héritent : `__last_update` s'est ainsi compté 580 fois au palier 17. + C'est UN changement. Listé modèle par modèle, il remplissait l'écran + et cachait les vingt autres ; groupé, il tient sur une ligne. + """ + par_nom = {} + for cle in cles: + nom = cle.rsplit(".", 1)[-1] if "." in cle else cle + par_nom.setdefault(nom, []).append(cle) + return sorted( + ((nom, len(lst), sorted(lst)[0]) for nom, lst in par_nom.items()), + key=lambda item: (-item[1], item[0]), + ) + + +def render_declared(declare, colour, limit=8): + """Le théorique posé sur le pratique. Rien si on ne peut pas le lire. + + Se taire quand l'analyse manque plutôt que d'afficher des zéros : + « 0 déclaré » et « analyse introuvable » se ressemblent à l'œil et ne + veulent pas du tout dire la même chose. + """ + from script.todo.migration_status import paint + + if not declare.get("available"): + if declare.get("reason") == "missing": + return [ + f" {paint('⇄', 'dim', colour)}" + f" {t('No OpenUpgrade analysis for this step.')}" + ] + return [] + lignes = [ + f" {paint('⇄', 'dim', colour)} {t('Against OpenUpgrade')}" + f" ({declare['modules']} {t('core module(s) analysed')})" + ] + for titre, groupes, table in ( + (t("model(s) lost"), declare["models"], DECLARE_MODELES), + (t("field(s) lost"), declare["fields"], DECLARE_CHAMPS), + ): + total = sum(len(groupes.get(cle) or []) for cle, _l, _c in table) + if not total: + continue + lignes.append(f" {total} {titre} :") + for cle, libelle, teinte in table: + trouves = groupes.get(cle) or [] + if not trouves: + continue + lignes.append( + f" {paint(str(len(trouves)).rjust(5), teinte, colour)}" + f" {t(libelle)}" + ) + if cle not in ("undeclared", "unstored"): + # Ces deux-là seuls méritent des noms : l'un parce qu'il + # faut aller voir, l'autre parce qu'on croirait à une + # perte. Nommer les autres noierait le rapport. + continue + plats = [ + element[0] if isinstance(element, tuple) else element + for element in trouves + ] + if table is DECLARE_CHAMPS: + groupes_nom = group_by_field_name(plats) + for nom, combien, exemple in groupes_nom[:limit]: + suffixe = ( + f" × {combien} {t('model(s)')}" if combien > 1 else "" + ) + montre = exemple if combien == 1 else nom + lignes.append(f" {montre}{suffixe}") + if len(groupes_nom) > limit: + lignes.append( + f" … {len(groupes_nom) - limit}" + f" {t('other field name(s)')}" + ) + else: + for nom in plats[:limit]: + lignes.append(f" {nom}") + if len(plats) > limit: + lignes.append( + f" … {len(plats) - limit} {t('more')}" + ) + return lignes + + def render_text(lst_snapshot, colour=None, limit=8): """Le rapport complet. C'est aussi le repli du plein écran.""" from script.todo.migration_status import paint, supports_colour @@ -868,6 +1085,7 @@ def render_compare(diff, colour, limit=8): lignes.append(f" {connu['why']}") if len(connues) > limit: lignes.append(f" … {len(connues) - limit} {t('more')}") + lignes.extend(render_declared(diff.get("declared") or {}, colour, limit)) delta = diff["delta"] lignes.append( " " diff --git a/script/analyse/openupgrade_analysis.py b/script/analyse/openupgrade_analysis.py new file mode 100644 index 0000000..ff27f61 --- /dev/null +++ b/script/analyse/openupgrade_analysis.py @@ -0,0 +1,267 @@ +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Ce qu'OpenUpgrade DÉCLARE changer, pour l'opposer à ce qui a changé. + +Le rapport de qualité comptait des « + » et des « − ». C'est vrai mais +insuffisant : un champ qui cesse d'être stocké disparaît de la base sans +rien perdre — il est calculé maintenant. Compté comme une perte, il +alarme ; ignoré, il masquerait une vraie perte. Il faut un troisième +état, et OpenUpgrade le connaît déjà. + +Chaque module cœur d'Odoo porte, dans OpenUpgrade, un fichier +`upgrade_analysis.txt` qui liste ce que le palier change : + + ---Models in module 'account'--- + obsolete model account.unreconcile [transient] + new model product.combo (renamed from pos.combo in module point_of_sale) + ---Fields in module 'account'--- + account / account.account / code (char) : not stored anymore + account / account.cash.rounding / loss_account_id (many2one) : needs + conversion to v18-style company dependent + +C'est la même information que la page « coverage analysis » publiée par +l'OCA — elle est engendrée depuis ces fichiers — mais lue dans le dépôt, +donc sans réseau, et à la version exacte du checkout. + +Ce qu'on n'y trouvera pas +------------------------- +Ces fichiers ne couvrent que les modules CŒUR d'Odoo. Un champ d'un +module OCA ou maison n'y sera jamais, et son absence ne veut donc pas +dire « perte non déclarée » au même titre. Le rapport le dit plutôt que +de laisser conclure. +""" + +import os +import re + +REPO_ROOT = os.path.normpath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..") +) + +# `new model x.y [transient]` ou `new model x.y (renamed from a.b in module m)` +MODELE_NEUF = re.compile( + r"^new model (\S+)(?:\s+\(renamed from (\S+) in module (\S+)\))?" +) +MODELE_OBSOLETE = re.compile(r"^obsolete model (\S+)") +# `module / model / champ (type) : description` — le type est absent sur +# les pseudo-champs comme `_order`, et le « : » n'est pas toujours espacé. +CHAMP = re.compile( + r"^(\S+)\s+/\s+(\S+)\s+/\s+(\S+)\s*(?:\(([^)]*)\))?\s*:\s*(.*)$" +) +# `module is now 'sale' ('point_of_sale')` +DEPLACE = re.compile(r"module is now '([^']+)'") + +SECTIONS = { + "Models": "model", + "Fields": "field", + "XML records": "xml", +} + + +def scripts_dir(version, root=None): + """Où vivent les analyses pour ce palier d'ARRIVÉE.""" + return os.path.join( + root or REPO_ROOT, + f"odoo{version}.0", + "OCA_OpenUpgrade", + "openupgrade_scripts", + "scripts", + ) + + +def analysis_files(version, root=None): + """Les `upgrade_analysis.txt` de ce palier, triés. + + On filtre sur le dossier de version : à côté des `18.0.1.0` vivent un + `tests` et, en 14, un `0.0` — les lire mélangerait les paliers. + """ + base = scripts_dir(version, root) + trouves = [] + if not os.path.isdir(base): + return trouves + prefixe = f"{version}." + for module in sorted(os.listdir(base)): + dossier = os.path.join(base, module) + if not os.path.isdir(dossier): + continue + for release in sorted(os.listdir(dossier)): + if not release.startswith(prefixe): + continue + chemin = os.path.join(dossier, release, "upgrade_analysis.txt") + if os.path.isfile(chemin): + trouves.append(chemin) + return trouves + + +_CACHE_MODULES = {} + + +def analysed_modules(version, root=None): + """Les modules cœur qu'OpenUpgrade analyse à ce palier. + + Les noms de dossiers SONT la liste. Elle sert à distinguer « aucune + déclaration » de « hors de son champ » : OpenUpgrade ne dit rien d'un + module OCA, et le compter comme non déclaré accuserait à tort. + """ + if version in _CACHE_MODULES: + return _CACHE_MODULES[version] + base = scripts_dir(version, root) + trouves = set() + if os.path.isdir(base): + trouves = { + nom + for nom in os.listdir(base) + if os.path.isdir(os.path.join(base, nom)) + } + if root is None: + _CACHE_MODULES[version] = trouves + return trouves + + +def blank_index(): + return { + "models_new": set(), + "models_obsolete": set(), + "models_renamed": {}, + "fields_new": set(), + "fields_del": set(), + "fields_unstored": set(), + "fields_stored": set(), + "fields_moved": {}, + "fields_company_dependent": set(), + "fields_other": {}, + "xml_new": 0, + "xml_del": 0, + "modules": 0, + } + + +def classify(description): + """La catégorie d'un changement de champ, d'après sa description. + + L'ordre compte : « NEW relation: … » commence par NEW, et + « not stored anymore » contient « stored ». On teste donc du plus + précis au plus général plutôt que par appartenance de sous-chaîne. + """ + texte = description.strip() + if texte.startswith("NEW"): + return "new" + if texte.startswith("DEL"): + return "del" + if texte.startswith("not stored anymore"): + return "unstored" + if texte.startswith("is now stored"): + return "stored" + if "needs conversion to" in texte and "company dependent" in texte: + return "company_dependent" + if DEPLACE.search(texte): + return "moved" + return "other" + + +def parse(texte, index=None): + """Lire UN fichier d'analyse dans l'index (créé au besoin).""" + index = blank_index() if index is None else index + section = None + for ligne in texte.splitlines(): + entete = re.match(r"^---(.+?) in module '([^']+)'---", ligne) + if entete: + section = SECTIONS.get(entete.group(1)) + continue + if not ligne.strip(): + continue + if section == "model": + _read_model(ligne, index) + elif section == "field": + _read_field(ligne, index) + elif section == "xml": + if ligne.startswith("NEW"): + index["xml_new"] += 1 + elif ligne.startswith("DEL"): + index["xml_del"] += 1 + return index + + +def _read_model(ligne, index): + trouve = MODELE_NEUF.match(ligne) + if trouve: + index["models_new"].add(trouve.group(1)) + if trouve.group(2): + # Le renommage se lit du côté du NOUVEAU modèle : c'est là + # qu'OpenUpgrade note d'où il vient. L'ancien n'apparaît nulle + # part comme « obsolete », donc sans cette ligne sa + # disparition passerait pour une perte sèche. + index["models_renamed"][trouve.group(2)] = trouve.group(1) + return + trouve = MODELE_OBSOLETE.match(ligne) + if trouve: + index["models_obsolete"].add(trouve.group(1)) + + +def _read_field(ligne, index): + trouve = CHAMP.match(ligne) + if not trouve: + return + _module, modele, champ, _type, description = trouve.groups() + if champ.startswith("_"): + # `_order`, `_sql_constraints` : ce ne sont pas des champs, et les + # compter fausserait le rapprochement avec `ir_model_fields`. + return + cle = f"{modele}.{champ}" + genre = classify(description) + if genre == "new": + index["fields_new"].add(cle) + elif genre == "del": + index["fields_del"].add(cle) + elif genre == "unstored": + index["fields_unstored"].add(cle) + elif genre == "stored": + index["fields_stored"].add(cle) + elif genre == "company_dependent": + index["fields_company_dependent"].add(cle) + elif genre == "moved": + index["fields_moved"][cle] = DEPLACE.search(description).group(1) + else: + index["fields_other"].setdefault(cle, []).append(description.strip()) + + +def load(version, root=None): + """L'index complet d'un palier. `modules` à 0 = rien de lisible.""" + index = blank_index() + for chemin in analysis_files(version, root): + try: + with open(chemin, "r", encoding="utf-8") as handle: + parse(handle.read(), index) + except OSError: + continue + index["modules"] += 1 + return index + + +def model_change(nom, index): + """Ce qu'OpenUpgrade déclare pour ce modèle disparu, ou None.""" + if nom in index["models_renamed"]: + return ("renamed", index["models_renamed"][nom]) + if nom in index["models_obsolete"]: + return ("obsolete", None) + return None + + +def field_change(cle, index): + """Ce qu'OpenUpgrade déclare pour ce champ disparu, ou None. + + « unstored » est le cas qui justifie tout l'outil : le champ EXISTE + toujours, il n'a plus de colonne parce qu'il est calculé. Ce n'est ni + un gain ni une perte — c'est une transformation, et la confondre avec + une perte fait crier au loup à chaque palier. + """ + if cle in index["fields_del"]: + return ("del", None) + if cle in index["fields_unstored"]: + return ("unstored", None) + if cle in index["fields_company_dependent"]: + return ("company_dependent", None) + if cle in index["fields_moved"]: + return ("moved", index["fields_moved"][cle]) + return None diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 4998b01..a7ff7cf 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -5891,6 +5891,70 @@ TRANSLATIONS = { "fr": "ligne(s)", "en": "row(s)", }, + "Against OpenUpgrade": { + "fr": "Face à OpenUpgrade", + "en": "Against OpenUpgrade", + }, + "NOT declared by OpenUpgrade": { + "fr": "NON déclarés par OpenUpgrade", + "en": "NOT declared by OpenUpgrade", + }, + "No OpenUpgrade analysis for this step.": { + "fr": "Pas d'analyse OpenUpgrade pour ce palier.", + "en": "No OpenUpgrade analysis for this step.", + }, + "became a per-company jsonb column": { + "fr": "passés en colonne jsonb par société", + "en": "became a per-company jsonb column", + }, + "computed now — the field remains, the column does not": { + "fr": "devenus calculés — le champ demeure, la colonne non", + "en": "computed now — the field remains, the column does not", + }, + "core module(s) analysed": { + "fr": "module(s) cœur analysé(s)", + "en": "core module(s) analysed", + }, + "declared obsolete": { + "fr": "déclarés obsolètes", + "en": "declared obsolete", + }, + "declared removed": { + "fr": "déclarés supprimés", + "en": "declared removed", + }, + "field(s) lost": { + "fr": "champ(s) perdu(s)", + "en": "field(s) lost", + }, + "model(s) lost": { + "fr": "modèle(s) perdu(s)", + "en": "model(s) lost", + }, + "moved to another module": { + "fr": "déplacés vers un autre module", + "en": "moved to another module", + }, + "renamed by Odoo": { + "fr": "renommés par Odoo", + "en": "renamed by Odoo", + }, + "their model went away too": { + "fr": "leur modèle a disparu aussi", + "en": "their model went away too", + }, + "in a module OpenUpgrade does not analyse": { + "fr": "dans un module qu'OpenUpgrade n'analyse pas", + "en": "in a module OpenUpgrade does not analyse", + }, + "model(s)": { + "fr": "modèle(s)", + "en": "model(s)", + }, + "other field name(s)": { + "fr": "autre(s) nom(s) de champ", + "en": "other field name(s)", + }, "Census": { "fr": "Recensement", "en": "Census", diff --git a/test/test_check_migration_quality.py b/test/test_check_migration_quality.py index 0cf4dba..169d825 100644 --- a/test/test_check_migration_quality.py +++ b/test/test_check_migration_quality.py @@ -1175,5 +1175,160 @@ class TestItRefusesToNestItself(Base): self.assertIn("its own", source) +class TestTheOpenUpgradeOverlay(unittest.TestCase): + """Le théorique posé sur le pratique. + + Le rapport comptait des « + » et des « − ». Un champ devenu calculé + n'est ni l'un ni l'autre : il perd sa colonne et garde son existence. + Sans troisième état, on alarmait à chaque palier. + """ + + INDEX = { + "models_new": {"product.combo"}, + "models_obsolete": {"account.unreconcile"}, + "models_renamed": {"pos.combo": "product.combo"}, + "fields_new": set(), + "fields_del": {"account.journal.secure_sequence_id"}, + "fields_unstored": {"account.account.code"}, + "fields_stored": set(), + "fields_moved": {"pos.combo.base_price": "product"}, + "fields_company_dependent": {"account.cash.rounding.loss_account_id"}, + "fields_other": {}, + "xml_new": 0, + "xml_del": 0, + "modules": 402, + } + + def setUp(self): + quality._CACHE_DECLARE[18] = self.INDEX + from script.analyse import openupgrade_analysis as oa + + self.vrai = oa.analysed_modules + oa.analysed_modules = lambda version, root=None: {"account", "sale"} + self.oa = oa + + def tearDown(self): + quality._CACHE_DECLARE.pop(18, None) + self.oa.analysed_modules = self.vrai + + def pose(self, modeles=(), champs=(), origines=None): + return quality.overlay_declared( + 18, list(modeles), list(champs), origines + ) + + def test_a_declared_obsolete_model_is_not_a_finding(self): + d = self.pose(modeles=["account.unreconcile"]) + self.assertEqual(d["models"]["obsolete"], ["account.unreconcile"]) + self.assertEqual(d["models"]["undeclared"], []) + + def test_a_renamed_model_names_its_destination(self): + d = self.pose(modeles=["pos.combo"]) + self.assertEqual( + d["models"]["renamed"], [("pos.combo", "product.combo")] + ) + + def test_an_unknown_model_stays_undeclared(self): + d = self.pose(modeles=["ma.bebelle"]) + self.assertEqual(d["models"]["undeclared"], ["ma.bebelle"]) + + def test_a_field_that_became_computed_is_its_own_category(self): + # NI perte NI gain : le champ demeure, la colonne non. C'est tout + # l'objet de l'ajout. + d = self.pose(champs=["account.account.code"]) + self.assertEqual(d["fields"]["unstored"], ["account.account.code"]) + self.assertEqual(d["fields"]["undeclared"], []) + + def test_declared_removals_and_moves_land_apart(self): + d = self.pose( + champs=[ + "account.journal.secure_sequence_id", + "pos.combo.base_price", + "account.cash.rounding.loss_account_id", + ] + ) + self.assertEqual( + d["fields"]["del"], ["account.journal.secure_sequence_id"] + ) + self.assertEqual( + d["fields"]["moved"], [("pos.combo.base_price", "product")] + ) + self.assertEqual( + d["fields"]["company_dependent"], + ["account.cash.rounding.loss_account_id"], + ) + + def test_a_field_whose_model_vanished_is_not_counted_again(self): + # 544 champs pour un seul modèle disparu, mesuré sur un vrai + # palier : listés un par un, ils cachaient les vingt vraies + # trouvailles. + d = self.pose( + modeles=["account.unreconcile"], + champs=["account.unreconcile.name"], + ) + self.assertEqual( + d["fields"]["model_gone"], ["account.unreconcile.name"] + ) + self.assertEqual(d["fields"]["undeclared"], []) + + def test_a_field_of_an_unanalysed_module_is_not_accused(self): + # OpenUpgrade n'analyse que le cœur : dire « non déclaré » d'un + # champ OCA est vrai à la lettre et faux en esprit. + d = self.pose( + champs=["x.mon_champ"], + origines={"x.mon_champ": ["mon_module_oca"]}, + ) + self.assertEqual(d["fields"]["not_analysed"], ["x.mon_champ"]) + self.assertEqual(d["fields"]["undeclared"], []) + + def test_a_core_field_with_no_declaration_SURVIVES_as_undeclared(self): + # Le signal ne doit pas se faire absorber par les catégories + # rassurantes : c'est la seule ligne qui demande un examen. + d = self.pose( + champs=["account.account.mystere"], + origines={"account.account.mystere": ["account"]}, + ) + self.assertEqual( + d["fields"]["undeclared"], ["account.account.mystere"] + ) + + def test_no_analysis_is_said_not_shown_as_zero(self): + # « 0 déclaré » et « analyse absente » se ressemblent à l'œil. + quality._CACHE_DECLARE[18] = dict(self.INDEX, modules=0) + d = self.pose(modeles=["x.y"]) + self.assertFalse(d["available"]) + self.assertEqual(d["reason"], "missing") + texte = "\n".join(quality.render_declared(d, False)) + self.assertIn( + todo_i18n.t("No OpenUpgrade analysis for this step."), texte + ) + + def test_an_unknown_version_declares_nothing_quietly(self): + d = quality.overlay_declared(None, ["x.y"], []) + self.assertFalse(d["available"]) + self.assertEqual(quality.render_declared(d, False), []) + + +class TestGroupingByFieldName(unittest.TestCase): + def test_one_mixin_field_is_one_finding(self): + # `__last_update` s'est compté 391 fois sur un vrai palier : c'est + # UN changement. + groupes = quality.group_by_field_name( + [f"modele{i}.zz_last_update" for i in range(391)] + + ["account.account.aa_autre"] + ) + self.assertEqual(groupes[0][0], "zz_last_update") + self.assertEqual(groupes[0][1], 391) + + def test_the_most_widespread_comes_first(self): + groupes = quality.group_by_field_name( + ["a.zzz", "b.zzz", "c.zzz", "d.aaa"] + ) + self.assertEqual([nom for nom, _n, _e in groupes], ["zzz", "aaa"]) + + def test_a_lone_field_keeps_its_full_key_as_example(self): + groupes = quality.group_by_field_name(["account.account.seul"]) + self.assertEqual(groupes[0], ("seul", 1, "account.account.seul")) + + if __name__ == "__main__": unittest.main() diff --git a/test/test_openupgrade_analysis.py b/test/test_openupgrade_analysis.py new file mode 100644 index 0000000..839ad54 --- /dev/null +++ b/test/test_openupgrade_analysis.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Lire ce qu'OpenUpgrade déclare, sans le déformer. + +La catégorie qui porte tout l'outil est « unstored » : le champ EXISTE +encore, il n'a plus de colonne parce qu'il est calculé. Le ranger avec +les suppressions ferait crier au loup à chaque palier ; l'oublier +masquerait une vraie perte. +""" + +import os +import shutil +import sys +import tempfile +import unittest + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) +) + +from script.analyse import openupgrade_analysis as oa # noqa: E402 + +EXEMPLE = """---Models in module 'account'--- +obsolete model account.unreconcile [transient] +new model account.code.mapping [sql_view] +new model product.combo (renamed from pos.combo in module point_of_sale) +---Fields in module 'account'--- +account / account.account / _order : _order is now 'code' ('id') +account / account.account / code (char) : not stored anymore +account / account.account / code_store (char) : NEW +account / account.journal / secure_sequence_id (many2one) : DEL relation: ir.sequence +account / account.account / company_id (many2one) : is now stored +account / account.cash.rounding / loss_account_id (many2one) : needs conversion to v18-style company dependent +account / pos.combo / base_price (float) : module is now 'product' ('point_of_sale') +account / account.journal / type (selection) : selection_keys added: [credit] +---XML records in module 'account'--- +NEW ir.ui.view: account.view_x +DEL ir.ui.view: account.view_y +DEL ir.ui.view: account.view_z +""" + + +class TestParsing(unittest.TestCase): + def setUp(self): + self.index = oa.parse(EXEMPLE) + + def test_obsolete_and_new_models(self): + self.assertIn("account.unreconcile", self.index["models_obsolete"]) + self.assertIn("account.code.mapping", self.index["models_new"]) + + def test_a_rename_is_read_from_the_NEW_side(self): + # OpenUpgrade ne note le renommage que sur le nouveau modèle ; + # l'ancien n'apparaît jamais comme « obsolete ». Sans cette + # lecture, sa disparition passerait pour une perte sèche. + self.assertEqual( + self.index["models_renamed"].get("pos.combo"), "product.combo" + ) + + def test_each_field_lands_in_its_category(self): + for cle, categorie in ( + ("account.account.code", "fields_unstored"), + ("account.account.code_store", "fields_new"), + ("account.journal.secure_sequence_id", "fields_del"), + ("account.account.company_id", "fields_stored"), + ( + "account.cash.rounding.loss_account_id", + "fields_company_dependent", + ), + ): + self.assertIn(cle, self.index[categorie], f"{cle} → {categorie}") + + def test_a_moved_field_keeps_its_new_module(self): + self.assertEqual( + self.index["fields_moved"].get("pos.combo.base_price"), "product" + ) + + def test_pseudo_fields_are_not_fields(self): + # `_order` n'est pas dans `ir_model_fields` : le compter fausserait + # le rapprochement avec la base. + for cle in list(self.index["fields_other"]) + list( + self.index["fields_new"] + ): + self.assertNotIn("._order", cle) + + def test_an_unclassified_change_is_kept_not_dropped(self): + # « selection_keys added » n'a pas de catégorie : le perdre + # silencieusement ferait mentir les totaux. + self.assertIn("account.journal.type", self.index["fields_other"]) + + def test_xml_records_are_counted(self): + self.assertEqual(self.index["xml_new"], 1) + self.assertEqual(self.index["xml_del"], 2) + + +class TestClassify(unittest.TestCase): + def test_new_with_details_is_still_new(self): + self.assertEqual( + oa.classify("NEW relation: res.company, required"), "new" + ) + + def test_not_stored_is_not_confused_with_stored(self): + # « not stored anymore » CONTIENT « stored » : tester par + # appartenance de sous-chaîne inverserait le sens. + self.assertEqual(oa.classify("not stored anymore"), "unstored") + self.assertEqual(oa.classify("is now stored"), "stored") + + def test_an_unknown_wording_falls_back_to_other(self): + self.assertEqual(oa.classify("something brand new"), "other") + + +class TestFindingTheFiles(unittest.TestCase): + def setUp(self): + self.racine = tempfile.mkdtemp() + base = os.path.join( + self.racine, + "odoo18.0", + "OCA_OpenUpgrade", + "openupgrade_scripts", + "scripts", + ) + for module, release in ( + ("account", "18.0.1.0"), + ("account", "17.0.1.0"), + ("sale", "18.0.2.0"), + ("tests", "18.0.1.0"), + ): + dossier = os.path.join(base, module, release) + os.makedirs(dossier) + with open( + os.path.join(dossier, "upgrade_analysis.txt"), + "w", + encoding="utf-8", + ) as handle: + handle.write(EXEMPLE) + + def tearDown(self): + shutil.rmtree(self.racine) + + def test_only_the_target_version_is_read(self): + # À côté des `18.0.x` vivent des dossiers d'autres paliers : les + # mélanger attribuerait à la 18 des changements de la 17. + trouves = oa.analysis_files(18, self.racine) + self.assertTrue(all("18.0" in c for c in trouves), trouves) + self.assertEqual(len(trouves), 3) + + def test_a_missing_checkout_is_empty_not_a_crash(self): + self.assertEqual(oa.analysis_files(13, self.racine), []) + self.assertEqual(oa.load(13, self.racine)["modules"], 0) + + def test_the_module_list_comes_from_the_directories(self): + modules = oa.analysed_modules(18, self.racine) + self.assertIn("account", modules) + self.assertIn("sale", modules) + + def test_loading_counts_the_modules_it_read(self): + self.assertEqual(oa.load(18, self.racine)["modules"], 3) + + +class TestTheRealCheckout(unittest.TestCase): + """Le dépôt lui-même, quand il est là. Sinon on ne prétend rien.""" + + def setUp(self): + if not oa.analysis_files(18): + self.skipTest("OCA_OpenUpgrade absent du checkout 18.0") + + def test_it_reads_hundreds_of_core_modules(self): + index = oa.load(18) + self.assertGreater(index["modules"], 300) + + def test_it_finds_the_company_dependent_conversion(self): + # C'est la refonte qui a fait disparaître `ir_property` en 18 : + # si l'outil ne la voit pas, il ne sert à rien sur ce palier-là. + index = oa.load(18) + self.assertTrue( + any( + cle.startswith("account.cash.rounding.") + for cle in index["fields_company_dependent"] + ), + sorted(index["fields_company_dependent"])[:5], + ) + + +if __name__ == "__main__": + unittest.main() From 068022db82f634ef64fda371bc476e6e2a7a0a64 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sat, 22 Aug 2026 00:46:42 -0400 Subject: [PATCH 64/67] [ADD] migration: repair views whose stored type contradicts their parent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Odoo refused to load a database: it validated a arch with tree rules. The view is not a COW copy -- the COW tools were right to say they had nothing to reset -- its stored `type` simply lies. Nothing would ever have fixed it. In ir_ui_view.py, `type` is filled in `create`, and only when absent; `write` never recomputes it. A wrong value stays wrong, and `-u module` fails on the validation it causes before it could rewrite anything. Plain SQL, no ORM: the registry is what will not load, and a repair that needed Odoo to fix what stops Odoo would be useless. The rule is Odoo's own -- an inherited view takes its parent's type -- and it holds: zero disagreement on a pristine 18 install and on four migrated databases, exactly one on the database that refused to load. --- FR --- Odoo refusait de charger une base : il validait un arch avec les règles d'un tree. La vue n'est pas une copie COW — les outils COW avaient raison de dire qu'ils n'avaient rien à réinitialiser — c'est son `type` stocké qui ment. Rien ne l'aurait jamais réparé. Dans ir_ui_view.py, `type` est rempli dans `create`, et seulement s'il est absent ; `write` ne le recalcule jamais. Une valeur fausse le reste, et `-u module` échoue sur la validation qu'elle provoque avant de pouvoir réécrire quoi que ce soit. En SQL, sans ORM : c'est le registre qui ne charge plus, et une réparation qui aurait besoin d'Odoo ne servirait à rien. La règle est celle d'Odoo — une vue héritée prend le type de son parent — et elle tient : zéro écart sur une 18 neuve et sur quatre bases migrées, exactement un sur celle qui refusait de charger. Assisted-by: Claude Opus 5 --- script/odoo/migration/fix_view_type.py | 203 +++++++++++++++++++ script/todo/todo_i18n.py | 216 ++++++++++++++++++++ script/todo/todo_upgrade.py | 265 ++++++++++++++++++------- test/test_error_retry_loop.py | 8 +- test/test_fix_view_type.py | 264 ++++++++++++++++++++++++ test/test_migration_status.py | 8 +- test/test_reset_stale_cow_prompt.py | 8 +- test/test_reset_stale_cow_tui.py | 16 +- 8 files changed, 910 insertions(+), 78 deletions(-) create mode 100755 script/odoo/migration/fix_view_type.py create mode 100644 test/test_fix_view_type.py diff --git a/script/odoo/migration/fix_view_type.py b/script/odoo/migration/fix_view_type.py new file mode 100755 index 0000000..5022cea --- /dev/null +++ b/script/odoo/migration/fix_view_type.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Une vue dont le `type` stocké contredit son héritage. + +Le symptôme est brutal : Odoo refuse de charger la base. + + Le nœud racine d'une vue tree devrait être , et non + view: ir.ui.view(637,) view.parent: ir.ui.view(636,) + +La 637 hérite d'une vue `search` et son `type` en base dit `tree`. Odoo +valide alors un `` avec les règles d'un ``, et échoue. + +Pourquoi aucune mise à jour ne le répare +---------------------------------------- +`type` n'est PAS un champ calculé. Dans `ir_ui_view.py`, il est rempli +dans `create` — et seulement s'il est absent : + + if not values.get('type'): + if values.get('inherit_id'): + values['type'] = self.browse(values['inherit_id']).type + +`write` ne le recalcule jamais. Une valeur fausse reste donc fausse pour +toujours, et `-u module` ne la corrigera pas — il échoue avant, sur la +validation qu'elle provoque. + +Pourquoi en SQL et pas par l'ORM +-------------------------------- +Parce que le registre ne charge plus. Un outil qui aurait besoin d'Odoo +pour réparer ce qui empêche Odoo de démarrer ne servirait à rien. + +Ce que la règle garantit +------------------------ +Une vue héritée prend le type de son ancêtre — c'est la règle d'Odoo +lui-même, citée plus haut. Vérifié : zéro écart sur une installation 18 +neuve et sur quatre bases migrées, un seul sur celle qui refusait de +charger. Le détecteur ne crie pas au loup. + +Codes de sortie : 0 rien à signaler, 1 des trouvailles, 2 l'outil a échoué. +""" + +import argparse +import os +import subprocess +import sys + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) +) + +try: + from script.todo.todo_i18n import t +except Exception: # pragma: no cover - repli si i18n indisponible + + def t(key: str) -> str: + return key + + +SEP = "\x1f" + +# La profondeur borne la récursion : un `inherit_id` circulaire ferait +# tourner PostgreSQL sans fin, et une chaîne de vues n'a jamais vingt +# maillons. +PROFONDEUR_MAX = 20 + +DETECTION = f""" +WITH RECURSIVE racine AS ( + SELECT id, type, 0 AS prof FROM ir_ui_view WHERE inherit_id IS NULL + UNION ALL + SELECT v.id, r.type, r.prof + 1 + FROM ir_ui_view v JOIN racine r ON v.inherit_id = r.id + WHERE r.prof < {PROFONDEUR_MAX} +) +SELECT v.id::text, v.type, r.type, + coalesce(v.mode, '-'), + coalesce(d.module || '.' || d.name, '-'), + coalesce(v.model, '-') +FROM ir_ui_view v +JOIN racine r ON r.id = v.id +LEFT JOIN ir_model_data d + ON d.model = 'ir.ui.view' AND d.res_id = v.id +WHERE v.inherit_id IS NOT NULL AND v.type <> r.type +ORDER BY v.id +""" + + +def run_psql(database, sql, read_only=True): + """Interroger la base. En lecture seule sauf demande explicite.""" + env = os.environ.copy() + if read_only: + env["PGOPTIONS"] = "-c default_transaction_read_only=on" + env["PSQLRC"] = "" + done = subprocess.run( + ["psql", "-X", "-w", "-d", database, "-tAF", SEP, "-c", sql], + capture_output=True, + text=True, + env=env, + ) + if done.returncode: + return None + return [ligne.split(SEP) for ligne in done.stdout.splitlines() if ligne] + + +def find(database): + """Les vues fautives. None si la base ne répond pas.""" + lignes = run_psql(database, DETECTION) + if lignes is None: + return None + return [ + { + "id": int(ligne[0]), + "type": ligne[1], + "expected": ligne[2], + "mode": ligne[3], + "xmlid": ligne[4], + "model": ligne[5], + } + for ligne in lignes + if len(ligne) >= 6 + ] + + +def repair_sql(vues): + """Le SQL de correction, ou "" s'il n'y a rien à corriger. + + Un `CASE` plutôt qu'un ordre par vue : une seule instruction, donc + une seule transaction, donc pas de base à moitié réparée si l'on + coupe au milieu. + """ + if not vues: + return "" + cas = " ".join( + f"WHEN {vue['id']} THEN '{vue['expected']}'" for vue in vues + ) + liste = ", ".join(str(vue["id"]) for vue in vues) + return ( + f"UPDATE ir_ui_view SET type = CASE id {cas} END" + f" WHERE id IN ({liste})" + ) + + +def render(vues, applique=False): + if not vues: + return [f"✅ {t('Every view type agrees with its inheritance.')}"] + lignes = [ + f"🩹 {len(vues)} {t('view(s) whose stored type contradicts')}" + f" {t('their inheritance')} :" + ] + for vue in vues: + lignes.append( + f" {vue['id']:<7} {vue['xmlid'][:46]:<48}" + f" {vue['type']} → {vue['expected']} {vue['model']}" + ) + if applique: + lignes.append(f" ✅ {t('Corrected.')}") + else: + lignes.append( + f" {t('No module update will fix this: the type is set once,')}" + f" {t('at creation. Re-run with --apply.')}" + ) + return lignes + + +def main(argv=None): + parser = argparse.ArgumentParser( + description=( + "Report views whose stored type contradicts the type of the" + " view they inherit from, and optionally fix them." + ) + ) + parser.add_argument("-d", "--database", required=True) + parser.add_argument( + "--apply", + action="store_true", + help="actually correct the type (default: report only)", + ) + config = parser.parse_args(argv) + + vues = find(config.database) + if vues is None: + print(f"❌ {t('Cannot read the database: ')}{config.database}") + return 2 + if not vues: + print("\n".join(render(vues))) + return 0 + if not config.apply: + print("\n".join(render(vues))) + return 1 + sql = repair_sql(vues) + if run_psql(config.database, sql, read_only=False) is None: + print(f"❌ {t('The correction failed.')}") + return 2 + reste = find(config.database) + if reste: + print(f"❌ {t('The correction failed.')}") + return 2 + print("\n".join(render(vues, applique=True))) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index a7ff7cf..db7d39b 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -5955,6 +5955,222 @@ TRANSLATIONS = { "fr": "autre(s) nom(s) de champ", "en": "other field name(s)", }, + "Filestore of": { + "fr": "Filestore de", + "en": "Filestore of", + }, + "file(s) missing": { + "fr": "fichier(s) absent(s)", + "en": "file(s) missing", + }, + "file(s) on disk": { + "fr": "fichier(s) sur disque", + "en": "file(s) on disk", + }, + "file(s) sit in nested filestores Odoo never reads.": { + "fr": "fichier(s) échoué(s) dans des filestores imbriqués qu'Odoo ne lit jamais.", + "en": "file(s) sit in nested filestores Odoo never reads.", + }, + "intact in another database's filestore": { + "fr": "intacts dans le filestore d'une autre base", + "en": "intact in another database's filestore", + }, + "its field no longer exists — nothing reads it": { + "fr": "leur champ n'existe plus — rien ne les lit", + "en": "its field no longer exists — nothing reads it", + }, + "nowhere to be found — truly lost": { + "fr": "introuvables — réellement perdus", + "en": "nowhere to be found — truly lost", + }, + "still in a backup zip": { + "fr": "encore dans une sauvegarde zip", + "en": "still in a backup zip", + }, + "stored attachment(s)": { + "fr": "pièce(s) jointe(s) stockée(s)", + "en": "stored attachment(s)", + }, + "stranded in a nested filestore Odoo never reads": { + "fr": "échoués dans un filestore imbriqué qu'Odoo ne lit pas", + "en": "stranded in a nested filestore Odoo never reads", + }, + "Files": { + "fr": "Fichiers", + "en": "Files", + }, + "Attachment files missing from the filestore": { + "fr": "🗄 Fichiers de pièces jointes absents du filestore", + "en": "🗄 Attachment files missing from the filestore", + }, + "Scanning filestores and backups…": { + "fr": "Examen des filestores et des sauvegardes…", + "en": "Scanning filestores and backups…", + }, + "Filestore restored:": { + "fr": "Filestore restauré :", + "en": "Filestore restored:", + }, + "file(s) in place": { + "fr": "fichier(s) en place", + "en": "file(s) in place", + }, + "Filestore restore looks wrong for": { + "fr": "Restauration du filestore suspecte pour", + "en": "Filestore restore looks wrong for", + }, + "landed in a nested filestore Odoo never reads": { + "fr": "atterris dans un filestore imbriqué qu'Odoo ne lit jamais", + "en": "landed in a nested filestore Odoo never reads", + }, + "To fix:": { + "fr": "Pour corriger :", + "en": "To fix:", + }, + "never landed at all": { + "fr": "ne sont arrivés nulle part", + "en": "never landed at all", + }, + "record still exists": { + "fr": "l'enregistrement existe encore", + "en": "record still exists", + }, + "record is gone — nothing will miss it": { + "fr": "l'enregistrement a disparu — personne ne la cherchera", + "en": "record is gone — nothing will miss it", + }, + "Delete these": { + "fr": "Supprimer ces", + "en": "Delete these", + }, + "Directory:": { + "fr": "Dossier :", + "en": "Directory:", + }, + "No nested filestore to tidy.": { + "fr": "Pas de filestore imbriqué à ranger.", + "en": "No nested filestore to tidy.", + }, + "Nothing to purge.": { + "fr": "Rien à purger.", + "en": "Nothing to purge.", + }, + "Nothing was deleted.": { + "fr": "Rien n'a été supprimé.", + "en": "Nothing was deleted.", + }, + "Nothing was moved.": { + "fr": "Rien n'a été déplacé.", + "en": "Nothing was moved.", + }, + "The purge failed.": { + "fr": "La purge a échoué.", + "en": "The purge failed.", + }, + "attachment row(s) deleted.": { + "fr": "ligne(s) de pièce jointe supprimée(s).", + "en": "attachment row(s) deleted.", + }, + "attachment row(s) for good?": { + "fr": "ligne(s) de pièce jointe, définitivement ?", + "en": "attachment row(s) for good?", + }, + "duplicate(s) removed": { + "fr": "doublon(s) supprimé(s)", + "en": "duplicate(s) removed", + }, + "file(s) to move up": { + "fr": "fichier(s) à remonter", + "en": "file(s) to move up", + }, + "moved up": { + "fr": "remonté(s)", + "en": "moved up", + }, + "pure duplicate(s) to delete": { + "fr": "doublon(s) pur(s) à supprimer", + "en": "pure duplicate(s) to delete", + }, + "🧹 Purge attachments whose field no longer exists": { + "fr": "🧹 Purger les pièces jointes dont le champ n'existe plus", + "en": "🧹 Purge attachments whose field no longer exists", + }, + "🧹 Tidy the nested filestore Odoo never reads": { + "fr": "🧹 Ranger le filestore imbriqué qu'Odoo ne lit pas", + "en": "🧹 Tidy the nested filestore Odoo never reads", + }, + "The purge ran but said nothing.": { + "fr": "La purge a tourné sans rien annoncer.", + "en": "The purge ran but said nothing.", + }, + "row(s) whose field is gone still hold their file": { + "fr": "ligne(s) à champ disparu retiennent encore leur fichier", + "en": "row(s) whose field is gone still hold their file", + }, + "file(s) sit in a nested filestore Odoo never reads.": { + "fr": "fichier(s) échoué(s) dans un filestore imbriqué qu'Odoo ne lit jamais.", + "en": "file(s) sit in a nested filestore Odoo never reads.", + }, + "such file(s) sit in OTHER databases filestores.": { + "fr": "fichier(s) de ce genre dorment dans les filestores d'AUTRES bases.", + "en": "such file(s) sit in OTHER databases filestores.", + }, + "Could not inspect the attachments: ": { + "fr": "Inspection des pièces jointes impossible : ", + "en": "Could not inspect the attachments: ", + }, + "Delete them?": { + "fr": "Les supprimer ?", + "en": "Delete them?", + }, + "No attachment points at a field that is gone.": { + "fr": "Aucune pièce jointe ne pointe vers un champ disparu.", + "en": "No attachment points at a field that is gone.", + }, + "attachment(s) point at a field that no longer exists": { + "fr": "pièce(s) jointe(s) pointent vers un champ qui n'existe plus", + "en": "attachment(s) point at a field that no longer exists", + }, + "Corrected.": { + "fr": "Corrigé.", + "en": "Corrected.", + }, + "Every view type agrees with its inheritance.": { + "fr": "Tous les types de vue s'accordent avec leur héritage.", + "en": "Every view type agrees with its inheritance.", + }, + "No module update will fix this: the type is set once,": { + "fr": "Aucune mise à jour de module ne corrigera cela : le type est posé une fois,", + "en": "No module update will fix this: the type is set once,", + }, + "The correction failed.": { + "fr": "La correction a échoué.", + "en": "The correction failed.", + }, + "at creation. Re-run with --apply.": { + "fr": "à la création. Relancer avec --apply.", + "en": "at creation. Re-run with --apply.", + }, + "their inheritance": { + "fr": "leur héritage", + "en": "their inheritance", + }, + "view(s) whose stored type contradicts": { + "fr": "vue(s) dont le type stocké contredit", + "en": "view(s) whose stored type contradicts", + }, + "Fix views whose type contradicts their": { + "fr": "Corriger les vues dont le type contredit leur", + "en": "Fix views whose type contradicts their", + }, + "inheritance": { + "fr": "héritage", + "en": "inheritance", + }, + "Correct them?": { + "fr": "Les corriger ?", + "en": "Correct them?", + }, "Census": { "fr": "Recensement", "en": "Census", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index 6f68877..2ace1bc 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -2848,6 +2848,195 @@ class TodoUpgrade: if status.lower().strip() == "y": self.todo.prompt_execute_selenium_and_run_db(database_name_upgrade) + def _prompt_on_error(self, database_name, defaut, repare): + """Le menu proposé quand une commande échoue. Rend (choix, réparé). + + Extrait de `todo_upgrade_execute` : à cinq entrées la méthode + appelante passait le seuil de complexité, et une boucle d'invite + se relit mieux seule que noyée dans l'exécution d'une commande. + """ + tours = 0 + while True: + # Une borne STRUCTURELLE, et non pas seulement la logique + # ci-dessous qui bascule le défaut. Les deux protections + # visent la même panne — une invite qui se repropose sans + # fin — mais celle-ci tient même si l'autre est cassée un + # jour par mégarde. Une boucle infinie dans une migration + # lancée sans surveillance coûte une nuit. + tours += 1 + if tours > self.MAX_ERROR_TURNS: + print(f"🛑 {t('Too many turns on this prompt: moving on.')}") + wait_status = "" + break + print(f"[1] {t('to redo the command')}") + if database_name: + print( + f"[2] {t('Check the COW views that drifted')}" + f" ({database_name})" + ) + print(f"[3] {t('Reset one of them onto its module view')}") + print(f"[4] {t('Browse the differences full screen')}") + print( + f"[5] {t('Fix views whose type contradicts their')}" + f" {t('inheritance')}" + ) + # `self.ask` : une migration automatique s'arrêtait ICI, + # sur une invite qui ne demande qu'à continuer, et restait + # bloquée sans que rien ne le signale. + wait_status = ( + self.ask( + f"💬 {t('Error detected. Choose, or ctrl+c to')}" + f" {t('stop')}" + f" ({t('Enter')} = {defaut or t('continue')}) : ", + default=defaut, + ) + .strip() + .lower() + ) + + # psycopg2.errors.UndefinedTable: relation "discuss_channel" does not exist + # LIGNE 1 : SELECT "discuss_channel"."id" FROM "discuss_channel" WHERE (... + + if wait_status == "2" and database_name: + # Le motif d'échec le plus fréquent ici est « Element + # cannot be located in parent view » : une copie + # COW en retard sur sa vue module. On propose l'outil sur + # place, puis on repose le choix pour rejouer. + self.check_stale_cow_views(database_name) + continue + if wait_status == "3" and database_name: + repare = self.prompt_reset_stale_cow_views(database_name) + if repare: + # Quelque chose a changé : la commande mérite un + # nouvel essai, et c'est le seul cas où le rejeu + # est AUTOMATIQUE. + wait_status = "1" + break + # Rien à réinitialiser. Reproposer « 3 » ferait tourner + # en rond — mesuré : « Aucune copie COW n'a dérivé », + # encore et encore, sans fin. + defaut = "" + continue + if wait_status == "5" and database_name: + # Rien à voir avec les COW : ici la vue n'est PAS une + # copie, c'est son `type` stocké qui ment. Odoo ne le + # recalcule jamais — il le pose à la création — donc + # aucune mise à jour ne le réparera, et le chargement + # échoue avant d'y arriver. + repare = self.prompt_fix_view_type(database_name) + if repare: + wait_status = "1" + break + defaut = "" + continue + if wait_status == "4" and database_name: + # Sur le VRAI terminal : un plein écran refuse de + # s'ouvrir sur un stdout capturé, et retomberait sur le + # rapport texte sans que rien ne distingue les deux. + self.run_on_terminal( + f"{PYTHON_BIN} ./" + + os.path.join( + PATH_MIGRATION_GLOBAL, "reset_stale_cow_views.py" + ) + + f" -d {database_name} --tui" + ) + continue + break + + return wait_status, repare + + def prompt_fix_view_type(self, database): + """Corriger les vues dont le `type` contredit leur héritage. + + Rend True si quelque chose a changé — c'est ce qui autorise le + rejeu automatique de la commande. + + En SQL, sans l'ORM : le registre ne charge plus, et c'est + précisément ce qu'on répare. Un outil qui aurait besoin d'Odoo + pour réparer ce qui empêche Odoo de démarrer ne servirait à rien. + """ + outil = os.path.join(PATH_MIGRATION_GLOBAL, "fix_view_type.py") + status, _cmd = self.todo_upgrade_execute( + f"{PYTHON_BIN} ./{outil} -d {database}" + ) + if status != 1: + # 0 : rien à corriger. 2 : l'outil a échoué. Ni l'un ni + # l'autre ne justifie de rejouer la commande. + return False + reponse = ( + self.ask(f"💬 {t('Correct them?')} (Y/n) : ", default="y") + .strip() + .lower() + ) + if reponse not in ("y", "yes", "o"): + return False + status, _cmd = self.todo_upgrade_execute( + f"{PYTHON_BIN} ./{outil} -d {database} --apply" + ) + return status == 0 + + def prompt_purge_dead_attachments(self, database): + """Effacer les pièces jointes dont le champ n'existe plus. + + ICI et pas entre les paliers. Mesuré : entre deux versions, deux + à onze champs disparaissent puis REVIENNENT — `hr.employee.phone`, + `account.move.statement_id`. « Le champ n'existe plus » est donc + un état transitoire tant que la migration court, et purger + dessus, c'est trancher sur ce qui va se rétablir. + + Le gain d'un nettoyage par palier serait nul de toute façon : + mesuré, 1881 lignes apparaissent au palier 13 et le compte ne + bouge plus ensuite. Une passe finale les prend toutes. + + Après la sauvegarde ? Non, AVANT : celle qui suit capturera + l'état nettoyé, et qui veut garder l'état d'avant n'a qu'à + refuser ici puis relancer l'outil depuis le menu Analyse. + """ + from script.analyse import check_filestore as filestore + + try: + rapport = filestore.audit(database) + except Exception as exc: + print(f"⚠ {t('Could not inspect the attachments: ')}{exc}") + return + if rapport.get("unavailable"): + return + sql = filestore.purge_dead_sql(rapport) + combien = len(rapport.get("dead_ids") or []) + if not sql: + print(f"✅ {t('No attachment points at a field that is gone.')}") + return + poids = rapport.get("dead_kept_size", 0) // 1024 + print() + print( + f"🕳 {combien}" + f" {t('attachment(s) point at a field that no longer exists')}" + f" ({poids} ko)" + ) + for texte in filestore.summarise( + rapport["groups"]["dead_field"] + (rapport.get("dead_kept") or []) + )[:6]: + print(f" {texte}") + reponse = ( + self.ask(f"💬 {t('Delete them?')} (Y/n) : ", default="y") + .strip() + .lower() + ) + if reponse not in ("y", "yes", "o"): + print(f"ℹ️ {t('Nothing was deleted.')}") + return + status, _cmd, sortie = self.todo_upgrade_execute( + f'psql -d {database} -c "{sql}"', get_output=True + ) + if status: + print(f"❌ {t('The purge failed.')}") + return + efface = filestore.rows_deleted(sortie) + if efface is None: + print(f"⚠ {t('The purge ran but said nothing.')}") + return + print(f"✅ {efface} {t('attachment row(s) deleted.')}") + def get_rename_module(self, lst_module, next_version): path_search = f"odoo{next_version}.0/OCA_OpenUpgrade/" status, cmd_executed, lst_output = self.todo_upgrade_execute( @@ -3679,79 +3868,9 @@ class TodoUpgrade: # n'existent pas et le défaut redevient « continuer ». defaut = "3" if database_name else "" repare = False - tours = 0 - while True: - # Une borne STRUCTURELLE, et non pas seulement la logique - # ci-dessous qui bascule le défaut. Les deux protections - # visent la même panne — une invite qui se repropose sans - # fin — mais celle-ci tient même si l'autre est cassée un - # jour par mégarde. Une boucle infinie dans une migration - # lancée sans surveillance coûte une nuit. - tours += 1 - if tours > self.MAX_ERROR_TURNS: - print( - f"🛑 {t('Too many turns on this prompt: moving on.')}" - ) - wait_status = "" - break - print(f"[1] {t('to redo the command')}") - if database_name: - print( - f"[2] {t('Check the COW views that drifted')}" - f" ({database_name})" - ) - print(f"[3] {t('Reset one of them onto its module view')}") - print(f"[4] {t('Browse the differences full screen')}") - # `self.ask` : une migration automatique s'arrêtait ICI, - # sur une invite qui ne demande qu'à continuer, et restait - # bloquée sans que rien ne le signale. - wait_status = ( - self.ask( - f"💬 {t('Error detected. Choose, or ctrl+c to')}" - f" {t('stop')}" - f" ({t('Enter')} = {defaut or t('continue')}) : ", - default=defaut, - ) - .strip() - .lower() - ) - - # psycopg2.errors.UndefinedTable: relation "discuss_channel" does not exist - # LIGNE 1 : SELECT "discuss_channel"."id" FROM "discuss_channel" WHERE (... - - if wait_status == "2" and database_name: - # Le motif d'échec le plus fréquent ici est « Element - # cannot be located in parent view » : une copie - # COW en retard sur sa vue module. On propose l'outil sur - # place, puis on repose le choix pour rejouer. - self.check_stale_cow_views(database_name) - continue - if wait_status == "3" and database_name: - repare = self.prompt_reset_stale_cow_views(database_name) - if repare: - # Quelque chose a changé : la commande mérite un - # nouvel essai, et c'est le seul cas où le rejeu - # est AUTOMATIQUE. - wait_status = "1" - break - # Rien à réinitialiser. Reproposer « 3 » ferait tourner - # en rond — mesuré : « Aucune copie COW n'a dérivé », - # encore et encore, sans fin. - defaut = "" - continue - if wait_status == "4" and database_name: - # Sur le VRAI terminal : un plein écran refuse de - # s'ouvrir sur un stdout capturé, et retomberait sur le - # rapport texte sans que rien ne distingue les deux. - self.run_on_terminal( - f"{PYTHON_BIN} ./" - + os.path.join( - PATH_MIGRATION_GLOBAL, "reset_stale_cow_views.py" - ) - + f" -d {database_name} --tui" - ) - continue - break + wait_status, repare = self._prompt_on_error( + database_name, defaut, repare + ) if wait_status == "1": # Le rejeu AUTOMATIQUE est borné ; celui qu'on demande à la diff --git a/test/test_error_retry_loop.py b/test/test_error_retry_loop.py index d2554be..437ee62 100644 --- a/test/test_error_retry_loop.py +++ b/test/test_error_retry_loop.py @@ -176,7 +176,13 @@ class TestTheHumanKeepsTheWheel(Harness): def test_the_prompt_says_what_enter_does(self): import inspect - source = inspect.getsource(TodoUpgrade.todo_upgrade_execute) + # Le menu d'erreur vit dans `_prompt_on_error`, extrait de + # `todo_upgrade_execute` quand celui-ci a passé le seuil de + # complexité. Lire les deux : c'est le CHEMIN d'erreur qu'on + # éprouve, pas une méthode en particulier. + source = inspect.getsource( + TodoUpgrade.todo_upgrade_execute + ) + inspect.getsource(TodoUpgrade._prompt_on_error) self.assertIn('defaut = "3" if database_name else ""', source) self.assertIn("default=defaut", source) diff --git a/test/test_fix_view_type.py b/test/test_fix_view_type.py new file mode 100644 index 0000000..43263b5 --- /dev/null +++ b/test/test_fix_view_type.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Une vue dont le `type` stocké contredit son héritage. + +Deux propriétés portent l'outil. La première : il travaille en SQL, sans +l'ORM — le registre ne charge plus, et c'est justement ce qu'on répare. +La seconde : il ne corrige QUE ce qu'Odoo lui-même déclare faux — une +vue héritée prend le type de son ancêtre, règle citée dans `create`. +Vérifié sur une installation neuve et quatre bases migrées : zéro écart. +""" + +import ast +import io +import os +import sys +import unittest +from contextlib import redirect_stdout + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) +) + +from script.odoo.migration import fix_view_type as fvt # noqa: E402 +from script.todo import todo_i18n # noqa: E402 + + +def vue(vid=637, actuel="tree", attendu="search", xmlid="event.x"): + return { + "id": vid, + "type": actuel, + "expected": attendu, + "mode": "primary", + "xmlid": xmlid, + "model": "event.registration", + } + + +class TestTheDetectionQuery(unittest.TestCase): + def test_it_only_looks_at_inherited_views(self): + # Une vue racine tient son type de sa balise : la comparer à + # elle-même n'apprendrait rien. + self.assertIn("v.inherit_id IS NOT NULL", fvt.DETECTION) + + def test_it_compares_against_the_ROOT_ancestor(self): + # Pas le parent direct : une chaîne de trois vues doit remonter + # jusqu'à celle qui porte la balise. + self.assertIn("WITH RECURSIVE", fvt.DETECTION) + self.assertIn("v.type <> r.type", fvt.DETECTION) + + def test_the_recursion_is_bounded(self): + # Un `inherit_id` circulaire ferait tourner PostgreSQL sans fin. + self.assertIn(f"r.prof < {fvt.PROFONDEUR_MAX}", fvt.DETECTION) + self.assertGreater(fvt.PROFONDEUR_MAX, 5) + + def test_it_never_writes(self): + for mot in ("UPDATE", "DELETE", "INSERT", "DROP"): + self.assertNotIn(mot, fvt.DETECTION.upper().replace("UPDATED", "")) + + +class TestTheRepairSql(unittest.TestCase): + def test_it_sets_each_view_to_its_ancestor_type(self): + sql = fvt.repair_sql([vue(637, "tree", "search")]) + self.assertIn("WHEN 637 THEN 'search'", sql) + self.assertIn("WHERE id IN (637)", sql) + + def test_several_views_travel_in_ONE_statement(self): + # Une seule instruction, donc une seule transaction : couper au + # milieu ne laisse pas une base à moitié réparée. + sql = fvt.repair_sql( + [vue(1, "tree", "search"), vue(2, "form", "kanban")] + ) + self.assertEqual(sql.count("UPDATE"), 1) + self.assertIn("WHEN 1 THEN 'search'", sql) + self.assertIn("WHEN 2 THEN 'kanban'", sql) + + def test_nothing_to_fix_gives_no_sql(self): + self.assertEqual(fvt.repair_sql([]), "") + + def test_it_targets_the_id_never_a_rebuilt_domain(self): + sql = fvt.repair_sql([vue()]) + self.assertNotIn("inherit_id", sql) + self.assertNotIn("arch_db", sql) + self.assertNotIn("arch =", sql) + self.assertEqual(sql.count("SET "), 1) + self.assertIn("SET type =", sql) + + +class TestTheReport(unittest.TestCase): + def test_a_clean_database_says_so(self): + texte = "\n".join(fvt.render([])) + self.assertIn( + todo_i18n.t("Every view type agrees with its inheritance."), texte + ) + + def test_a_finding_names_the_view_and_both_types(self): + texte = "\n".join(fvt.render([vue(637, "tree", "search")])) + self.assertIn("637", texte) + self.assertIn("tree → search", texte) + self.assertIn("event.x", texte) + + def test_it_says_no_module_update_will_help(self): + # C'est le renseignement qui évite de perdre une heure à + # relancer `-u module` en boucle. + texte = "\n".join(fvt.render([vue()])) + self.assertIn( + todo_i18n.t( + "No module update will fix this: the type is set once," + ), + texte, + ) + + def test_an_applied_run_does_not_repeat_the_advice(self): + texte = "\n".join(fvt.render([vue()], applique=True)) + self.assertIn(todo_i18n.t("Corrected."), texte) + self.assertNotIn( + todo_i18n.t("at creation. Re-run with --apply."), texte + ) + + +class TestTheExitCodes(unittest.TestCase): + def setUp(self): + self.vrai = fvt.run_psql + self.ecritures = [] + + def tearDown(self): + fvt.run_psql = self.vrai + + def branche(self, avant, apres=None): + """`avant` puis `apres` : ce que la détection rend, avant/après.""" + etat = {"tour": 0} + + def faux(database, sql, read_only=True): + if not read_only: + self.ecritures.append(sql) + return [] + etat["tour"] += 1 + lot = avant if etat["tour"] == 1 or apres is None else apres + if lot is None: + return None + return [ + [ + str(v["id"]), + v["type"], + v["expected"], + v["mode"], + v["xmlid"], + v["model"], + ] + for v in lot + ] + + fvt.run_psql = faux + + def lance(self, argv): + tampon = io.StringIO() + with redirect_stdout(tampon): + code = fvt.main(argv) + return code, tampon.getvalue() + + def test_nothing_wrong_exits_zero(self): + self.branche([]) + code, _ = self.lance(["-d", "db"]) + self.assertEqual(code, 0) + self.assertEqual(self.ecritures, []) + + def test_a_finding_exits_one_and_writes_nothing(self): + # Sans --apply, l'outil DIAGNOSTIQUE. Réparer tout seul serait un + # piège dans un outil qu'on lance pour comprendre. + self.branche([vue()]) + code, _ = self.lance(["-d", "db"]) + self.assertEqual(code, 1) + self.assertEqual(self.ecritures, []) + + def test_apply_fixes_and_exits_zero(self): + self.branche([vue()], apres=[]) + code, sortie = self.lance(["-d", "db", "--apply"]) + self.assertEqual(code, 0) + self.assertEqual(len(self.ecritures), 1) + self.assertIn(todo_i18n.t("Corrected."), sortie) + + def test_a_correction_that_did_not_take_exits_two(self): + # Relire APRÈS : annoncer « corrigé » sans vérifier ferait + # relancer la migration sur le même mur. + self.branche([vue()], apres=[vue()]) + code, _ = self.lance(["-d", "db", "--apply"]) + self.assertEqual(code, 2) + + def test_an_unreadable_database_exits_two(self): + self.branche(None) + code, _ = self.lance(["-d", "db"]) + self.assertEqual(code, 2) + + +class TestTheWiring(unittest.TestCase): + RACINE = os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) + + def source(self): + with io.open( + os.path.join(self.RACINE, "script", "todo", "todo_upgrade.py"), + encoding="utf-8", + ) as handle: + return handle.read() + + def test_the_error_menu_offers_the_entry(self): + src = self.source() + self.assertIn("Fix views whose type contradicts their", src) + self.assertIn('if wait_status == "5" and database_name:', src) + self.assertIn("def prompt_fix_view_type", src) + + def test_it_runs_the_tool_twice_report_then_apply(self): + src = self.source() + debut = src.index("def prompt_fix_view_type") + fin = src.index("def prompt_purge_dead_attachments") + bloc = src[debut:fin] + self.assertEqual(bloc.count("fix_view_type.py"), 1) + self.assertIn("--apply", bloc) + + def test_only_a_finding_leads_to_the_question(self): + # 0 « rien à corriger » et 2 « l'outil a échoué » ne justifient + # ni la question ni le rejeu de la commande. + src = self.source() + debut = src.index("def prompt_fix_view_type") + fin = src.index("def prompt_purge_dead_attachments") + self.assertIn("if status != 1:", src[debut:fin]) + + def test_every_menu_entry_has_its_branch(self): + src = self.source() + debut = src.index("def _prompt_on_error") + fin = src.index("def prompt_fix_view_type") + bloc = src[debut:fin] + for rang in ("1", "2", "3", "4", "5"): + self.assertTrue( + f'f"[{rang}] ' in bloc, f"entrée [{rang}] absente du menu" + ) + for rang in ("2", "3", "4", "5"): + self.assertTrue( + f'wait_status == "{rang}"' in bloc, + f"aiguillage [{rang}] absent", + ) + + +class TestTranslations(unittest.TestCase): + def test_every_key_exists(self): + with io.open(fvt.__file__, encoding="utf-8") as handle: + arbre = ast.parse(handle.read()) + for node in ast.walk(arbre): + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "t" + and node.args + and isinstance(node.args[0], ast.Constant) + ): + cle = node.args[0].value + self.assertTrue( + cle in todo_i18n.TRANSLATIONS, + f"clé sans traduction : {cle!r}", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_migration_status.py b/test/test_migration_status.py index 53ccef3..62c486c 100644 --- a/test/test_migration_status.py +++ b/test/test_migration_status.py @@ -1594,7 +1594,13 @@ class TestWhatGetsRecorded(Base): # l'état : c'est précisément celui qu'on cherchera en revenant. import inspect - source = inspect.getsource(TodoUpgrade.todo_upgrade_execute) + # Le menu d'erreur vit dans `_prompt_on_error`, extrait de + # `todo_upgrade_execute` quand celui-ci a passé le seuil de + # complexité. Lire les deux : c'est le CHEMIN d'erreur qu'on + # éprouve, pas une méthode en particulier. + source = inspect.getsource( + TodoUpgrade.todo_upgrade_execute + ) + inspect.getsource(TodoUpgrade._prompt_on_error) self.assertLess( source.index('record_event("command"'), source.index("Error detected"), diff --git a/test/test_reset_stale_cow_prompt.py b/test/test_reset_stale_cow_prompt.py index 8833489..c12fd8f 100755 --- a/test/test_reset_stale_cow_prompt.py +++ b/test/test_reset_stale_cow_prompt.py @@ -141,7 +141,13 @@ class TestTheKeysComeFromTheTool(unittest.TestCase): def test_the_error_prompt_offers_it(self): import inspect - source = inspect.getsource(TodoUpgrade.todo_upgrade_execute) + # Le menu d'erreur vit dans `_prompt_on_error`, extrait de + # `todo_upgrade_execute` quand celui-ci a passé le seuil de + # complexité. Lire les deux : c'est le CHEMIN d'erreur qu'on + # éprouve, pas une méthode en particulier. + source = inspect.getsource( + TodoUpgrade.todo_upgrade_execute + ) + inspect.getsource(TodoUpgrade._prompt_on_error) self.assertIn("prompt_reset_stale_cow_views", source) self.assertIn("Reset one of them onto its module view", source) diff --git a/test/test_reset_stale_cow_tui.py b/test/test_reset_stale_cow_tui.py index 2b0296b..629a272 100755 --- a/test/test_reset_stale_cow_tui.py +++ b/test/test_reset_stale_cow_tui.py @@ -141,7 +141,13 @@ class TestTheToolAndTheMigrationOfferIt(unittest.TestCase): from script.todo.todo_upgrade import TodoUpgrade - source = inspect.getsource(TodoUpgrade.todo_upgrade_execute) + # Le menu d'erreur vit dans `_prompt_on_error`, extrait de + # `todo_upgrade_execute` quand celui-ci a passé le seuil de + # complexité. Lire les deux : c'est le CHEMIN d'erreur qu'on + # éprouve, pas une méthode en particulier. + source = inspect.getsource( + TodoUpgrade.todo_upgrade_execute + ) + inspect.getsource(TodoUpgrade._prompt_on_error) self.assertIn("Browse the differences full screen", source) self.assertIn('wait_status == "4"', source) @@ -152,7 +158,13 @@ class TestTheToolAndTheMigrationOfferIt(unittest.TestCase): from script.todo.todo_upgrade import TodoUpgrade - source = inspect.getsource(TodoUpgrade.todo_upgrade_execute) + # Le menu d'erreur vit dans `_prompt_on_error`, extrait de + # `todo_upgrade_execute` quand celui-ci a passé le seuil de + # complexité. Lire les deux : c'est le CHEMIN d'erreur qu'on + # éprouve, pas une méthode en particulier. + source = inspect.getsource( + TodoUpgrade.todo_upgrade_execute + ) + inspect.getsource(TodoUpgrade._prompt_on_error) start = source.index('wait_status == "4"') window = source[start : start + 620] self.assertIn("run_on_terminal", window) From 89ef6a97fd9e7fdb7bbfdf1f0abfcc501a517cad Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sat, 22 Aug 2026 00:57:46 -0400 Subject: [PATCH 65/67] [FIX] migration: exit 1 means findings, not failure, for the view fixer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entry [5] never asked "Correct them?". The tool ran, printed its diagnosis, exited 1 -- and todo_upgrade_execute read that as a failure and reopened ITS error menu on top of ours. The menu looped and nothing was ever corrected. wait_at_error=False on both calls, as the COW tool next door already does. The fake in the test now models the real contract: it refuses to return a non-zero status when the flag is missing, exactly as the real one refuses by recursing into its prompt. --- FR --- L'entrée [5] ne posait jamais la question « Les corriger ? ». L'outil tournait, affichait son diagnostic, sortait 1 — et todo_upgrade_execute y lisait un échec et rouvrait SON menu d'erreur par-dessus le nôtre. Le menu tournait en rond et rien n'était corrigé. wait_at_error=False sur les deux appels, comme le fait déjà l'outil COW juste à côté. Le faux du test modèle désormais le vrai contrat : il refuse de rendre un statut non nul quand le drapeau manque, comme le vrai refuse en repartant dans son invite. Assisted-by: Claude Opus 5 --- script/todo/todo_upgrade.py | 11 ++++-- test/test_fix_view_type.py | 69 +++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index 2ace1bc..e88354a 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -2956,8 +2956,14 @@ class TodoUpgrade: pour réparer ce qui empêche Odoo de démarrer ne servirait à rien. """ outil = os.path.join(PATH_MIGRATION_GLOBAL, "fix_view_type.py") + # `wait_at_error=False` est OBLIGATOIRE : pour cet outil, 1 veut + # dire « des trouvailles », pas « échec ». Sans ce drapeau, + # `todo_upgrade_execute` y lit une panne et rouvre SON menu + # d'erreur par-dessus le nôtre — mesuré : la question « Les + # corriger ? » n'était jamais posée et le menu tournait en rond. status, _cmd = self.todo_upgrade_execute( - f"{PYTHON_BIN} ./{outil} -d {database}" + f"{PYTHON_BIN} ./{outil} -d {database}", + wait_at_error=False, ) if status != 1: # 0 : rien à corriger. 2 : l'outil a échoué. Ni l'un ni @@ -2971,7 +2977,8 @@ class TodoUpgrade: if reponse not in ("y", "yes", "o"): return False status, _cmd = self.todo_upgrade_execute( - f"{PYTHON_BIN} ./{outil} -d {database} --apply" + f"{PYTHON_BIN} ./{outil} -d {database} --apply", + wait_at_error=False, ) return status == 0 diff --git a/test/test_fix_view_type.py b/test/test_fix_view_type.py index 43263b5..e28926f 100644 --- a/test/test_fix_view_type.py +++ b/test/test_fix_view_type.py @@ -241,6 +241,75 @@ class TestTheWiring(unittest.TestCase): ) +class TestTheToolIsCalledWithTheRightContract(unittest.TestCase): + """Pour cet outil, 1 veut dire « trouvailles », pas « échec ». + + `todo_upgrade_execute` ouvre SON menu d'erreur sur tout statut non + nul. Sans `wait_at_error=False`, ce menu se superpose au nôtre : la + question « Les corriger ? » n'est jamais posée, et l'on tourne en + rond. C'est arrivé en vrai, sur une migration bloquée. + """ + + def setUp(self): + from script.todo import auto_ask + from script.todo.todo_upgrade import TodoUpgrade + + self.auto_ask = auto_ask + self.vrai_ask = auto_ask.ask + self.obj = TodoUpgrade.__new__(TodoUpgrade) + self.appels = [] + + def faux(cmd, **kw): + self.appels.append((cmd, kw)) + statut = 0 if "--apply" in cmd else 1 + if statut and kw.get("wait_at_error", True): + # Ce que fait le VRAI : il n'en revient pas avec le + # statut, il repart dans son propre menu. + raise AssertionError( + "menu d'erreur imbriqué : wait_at_error manquant" + ) + return statut, cmd + + self.obj.todo_upgrade_execute = faux + self.obj.ask = lambda prompt, default="": "y" + + def tearDown(self): + self.auto_ask.ask = self.vrai_ask + + def test_the_report_call_does_not_reopen_the_error_menu(self): + with redirect_stdout(io.StringIO()): + self.obj.prompt_fix_view_type("db") + self.assertTrue(self.appels) + self.assertIs(self.appels[0][1].get("wait_at_error"), False) + + def test_the_apply_call_does_not_either(self): + with redirect_stdout(io.StringIO()): + self.obj.prompt_fix_view_type("db") + self.assertEqual(len(self.appels), 2) + self.assertIn("--apply", self.appels[1][0]) + self.assertIs(self.appels[1][1].get("wait_at_error"), False) + + def test_it_returns_true_only_when_the_apply_succeeded(self): + with redirect_stdout(io.StringIO()): + self.assertTrue(self.obj.prompt_fix_view_type("db")) + + def test_refusing_runs_no_apply(self): + self.obj.ask = lambda prompt, default="": "n" + with redirect_stdout(io.StringIO()): + self.assertFalse(self.obj.prompt_fix_view_type("db")) + self.assertEqual(len(self.appels), 1) + + def test_nothing_to_fix_asks_nothing(self): + demandes = [] + self.obj.ask = ( + lambda prompt, default="": demandes.append(prompt) or "y" + ) + self.obj.todo_upgrade_execute = lambda cmd, **kw: (0, cmd) + with redirect_stdout(io.StringIO()): + self.assertFalse(self.obj.prompt_fix_view_type("db")) + self.assertEqual(demandes, []) + + class TestTranslations(unittest.TestCase): def test_every_key_exists(self): with io.open(fvt.__file__, encoding="utf-8") as handle: From 300238fe8360db8205ac4e5e0a9dfd4b8725a3b4 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sat, 22 Aug 2026 01:52:53 -0400 Subject: [PATCH 66/67] [ADD] migration: offer the theme uninstall where the theme is blamed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The question was asked once, at step one, and a flag kept it from ever returning. But a theme only becomes incompatible at a given bump, hours later -- asking at the start could not cover it. Entry [6] appears when TWO conditions hold: a theme is installed, and the recent output names it. One condition alone would offer to wreck the site's design over an error that has nothing to do with it. Also, the 17 to 18 rename OpenUpgrade declares and never applies: forum.post / tag_ids : column1 is now 'forum_post_id' ('forum_id') website_forum/18.0.1.2/ holds only an analysis, no script, so the load dies on a foreign key to a column that does not exist and leaves the database half migrated. --- FR --- La question était posée une fois, à l'étape 1, et un drapeau l'empêchait de revenir. Or un thème ne devient incompatible qu'à un palier donné, des heures plus tard : la poser au départ ne pouvait pas suffire. L'entrée [6] paraît quand DEUX conditions tiennent — un thème installé, et la sortie récente qui le nomme. Une seule offrirait de casser le design du site pour une panne étrangère. Et le renommage 17 → 18 qu'OpenUpgrade déclare sans jamais l'appliquer : forum.post / tag_ids : column1 is now 'forum_post_id' ('forum_id') website_forum/18.0.1.2/ ne porte qu'une analyse, aucun script : le chargement meurt sur une clé étrangère vers une colonne absente et laisse la base à moitié migrée. Assisted-by: Claude Opus 5 --- .../fix_migration_odoo170_to_odoo180.sql | 36 ++++ script/todo/todo_i18n.py | 4 + script/todo/todo_upgrade.py | 59 ++++++ test/test_error_retry_loop.py | 17 +- test/test_theme_on_error.py | 184 ++++++++++++++++++ 5 files changed, 299 insertions(+), 1 deletion(-) create mode 100644 script/odoo/migration/fix_migration_odoo170_to_odoo180.sql create mode 100644 test/test_theme_on_error.py diff --git a/script/odoo/migration/fix_migration_odoo170_to_odoo180.sql b/script/odoo/migration/fix_migration_odoo170_to_odoo180.sql new file mode 100644 index 0000000..65993f4 --- /dev/null +++ b/script/odoo/migration/fix_migration_odoo170_to_odoo180.sql @@ -0,0 +1,36 @@ +-- © 2021-2026 TechnoLibre (http://www.technolibre.ca) +-- License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +-- +-- Correctifs à appliquer AVANT qu'OpenUpgrade ne migre vers Odoo 18. +-- +-- En SQL et non en Python : ce fichier tourne sur une base encore en +-- 17, que le code de la 18 ne saurait pas charger. + +-- forum_tag_rel : la colonne qui pointe vers forum.post s'appelait +-- `forum_id` — un nom trompeur, elle ne désignait pas un forum. La 18 la +-- nomme `forum_post_id`. +-- +-- OpenUpgrade le DÉCLARE dans son analyse : +-- website_forum / forum.post / tag_ids (many2many) +-- : column1 is now 'forum_post_id' ('forum_id') [forum_tag_rel] +-- mais `website_forum/18.0.1.2/` ne contient aucun script : rien ne +-- l'applique. Le chargement casse alors sur +-- column "forum_post_id" referenced in foreign key constraint does not exist +-- et la base reste à moitié migrée. +-- +-- Les deux conditions rendent l'ordre rejouable : rien à faire si la +-- table n'existe pas (website_forum non installé) ni si le renommage a +-- déjà eu lieu. +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'forum_tag_rel' AND column_name = 'forum_id' + ) AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'forum_tag_rel' AND column_name = 'forum_post_id' + ) THEN + ALTER TABLE forum_tag_rel RENAME COLUMN forum_id TO forum_post_id; + RAISE NOTICE 'forum_tag_rel.forum_id renommee en forum_post_id'; + END IF; +END $$; diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index db7d39b..1154f65 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -6171,6 +6171,10 @@ TRANSLATIONS = { "fr": "Les corriger ?", "en": "Correct them?", }, + "Uninstall the theme(s) the error names": { + "fr": "Désinstaller le(s) thème(s) que l'erreur nomme", + "en": "Uninstall the theme(s) the error names", + }, "Census": { "fr": "Recensement", "en": "Census", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index e88354a..8dd444c 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -2855,6 +2855,9 @@ class TodoUpgrade: appelante passait le seuil de complexité, et une boucle d'invite se relit mieux seule que noyée dans l'exécution d'une commande. """ + # Une fois, avant la boucle : la détection lit un journal et + # interroge la base, et la boucle peut tourner huit fois. + themes = self.theme_blamed_by_the_error(database_name) tours = 0 while True: # Une borne STRUCTURELLE, et non pas seulement la logique @@ -2880,6 +2883,11 @@ class TodoUpgrade: f"[5] {t('Fix views whose type contradicts their')}" f" {t('inheritance')}" ) + if themes: + print( + f"[6] {t('Uninstall the theme(s) the error names')}" + f" : {', '.join(themes)}" + ) # `self.ask` : une migration automatique s'arrêtait ICI, # sur une invite qui ne demande qu'à continuer, et restait # bloquée sans que rien ne le signale. @@ -2917,6 +2925,17 @@ class TodoUpgrade: # encore et encore, sans fin. defaut = "" continue + if wait_status == "6" and themes: + # Le thème parti, la commande mérite un nouvel essai — + # et `repare` autorise le rejeu automatique borné. + for theme in themes: + self.run_on_terminal( + f"./script/addons/uninstall_addons_theme.sh" + f" {database_name} {theme}" + ) + wait_status = "1" + repare = True + break if wait_status == "5" and database_name: # Rien à voir avec les COW : ici la vue n'est PAS une # copie, c'est son `type` stocké qui ment. Odoo ne le @@ -3193,6 +3212,46 @@ class TodoUpgrade: return [] return [line.strip() for line in (output or []) if line.strip()] + def step_log_tail(self, octets=65536): + """La fin du journal de l'étape en cours, ou "". + + Bornée, et lue depuis la FIN : une mise à jour de modules écrit + des dizaines de milliers de lignes, et charger tout le fichier + pour en regarder vingt coûterait plus que l'erreur qu'on cherche. + """ + chemin = self.log_dir() + etape = getattr(self, "current_step", "") + if not chemin or not etape: + return "" + fichier = os.path.join(chemin, f"{self.step_slug(etape)}.log") + try: + with open(fichier, "rb") as handle: + handle.seek(0, os.SEEK_END) + depart = max(0, handle.tell() - octets) + handle.seek(depart) + return handle.read().decode("utf-8", errors="replace") + except OSError: + return "" + + def theme_blamed_by_the_error(self, database_name): + """Les thèmes installés que la sortie récente MET EN CAUSE. + + Deux conditions, pas une. Un thème doit être installé — sinon il + n'y a rien à retirer — ET son nom doit figurer dans ce que la + commande vient d'écrire. Proposer la désinstallation à chaque + échec reviendrait à offrir de casser le design du site pour une + panne qui n'a rien à voir. + """ + if not database_name: + return [] + installes = self.installed_theme(database_name) + if not installes: + return [] + journal = self.step_log_tail() + if not journal: + return [] + return [nom for nom in installes if nom in journal] + def prompt_uninstall_theme(self, database_name): """Proposer de retirer les thèmes AVANT de monter de version. diff --git a/test/test_error_retry_loop.py b/test/test_error_retry_loop.py index 437ee62..4729f13 100644 --- a/test/test_error_retry_loop.py +++ b/test/test_error_retry_loop.py @@ -54,9 +54,24 @@ class Harness(unittest.TestCase): class FauxExecute: def exec_command_live(_self, cmd, **kw): + # Une interrogation de service — la détection de thème — + # n'est pas un rejeu de la commande : l'inscrire dans + # `lst_run` doublerait tous les comptes de cette suite. + if "ir_module_module" in cmd: + if kw.get("return_status_and_output_and_command"): + return 0, cmd, [] + return 0, cmd self.lst_run.append(cmd) # Toujours en échec : c'est le cas qu'on veut borner. - return (1 if echec else 0), cmd + statut = 1 if echec else 0 + # La FORME du retour suit les drapeaux, comme le vrai : + # un faux qui rend toujours deux valeurs casse dès qu'un + # appelant demande la sortie. + if kw.get("return_status_and_output_and_command"): + return statut, cmd, [] + if kw.get("return_status_and_output"): + return statut, [] + return statut, cmd obj.execute = FauxExecute() suite = iter(resets if resets is not None else []) diff --git a/test/test_theme_on_error.py b/test/test_theme_on_error.py new file mode 100644 index 0000000..1b61111 --- /dev/null +++ b/test/test_theme_on_error.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Proposer de retirer un thème SEULEMENT quand il est en cause. + +La propriété qui porte tout : deux conditions, jamais une seule. Un +thème doit être installé — sinon il n'y a rien à retirer — ET son nom +doit figurer dans ce que la commande vient d'écrire. Offrir la +désinstallation à chaque échec reviendrait à proposer de casser le +design du site pour une panne qui n'a rien à voir. + +La question était déjà posée à l'étape 1, une fois, et un drapeau +l'empêchait de revenir. Or un thème devient incompatible à un PALIER +précis, des heures plus tard : la poser au départ ne pouvait pas suffire. +""" + +import io +import os +import sys +import tempfile +import unittest + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) +) + +from script.todo import todo_i18n # noqa: E402 +from script.todo.todo_upgrade import TodoUpgrade # noqa: E402 + + +class TestTheDetection(unittest.TestCase): + def setUp(self): + self.obj = TodoUpgrade.__new__(TodoUpgrade) + self.dossier = tempfile.mkdtemp() + self.obj.log_dir = lambda: self.dossier + self.obj.current_step = "4.5.J - Migrate database" + self.obj.step_slug = staticmethod(lambda msg: "etape") + self.obj.installed_theme = lambda base: ["theme_buzzy"] + + def journal(self, texte): + with io.open( + os.path.join(self.dossier, "etape.log"), "w", encoding="utf-8" + ) as handle: + handle.write(texte) + + def test_a_theme_named_by_the_error_is_blamed(self): + self.journal( + "ERROR ... /addons/odoo_design-themes/theme_buzzy/data/x.xml" + ) + self.assertEqual( + self.obj.theme_blamed_by_the_error("db"), ["theme_buzzy"] + ) + + def test_an_error_that_never_mentions_it_blames_nothing(self): + # C'est LA protection : une erreur de compte ne doit pas faire + # proposer d'effacer le design du site. + self.journal("ERROR relation discuss_channel does not exist") + self.assertEqual(self.obj.theme_blamed_by_the_error("db"), []) + + def test_no_theme_installed_blames_nothing(self): + self.obj.installed_theme = lambda base: [] + self.journal("theme_buzzy partout dans le journal") + self.assertEqual(self.obj.theme_blamed_by_the_error("db"), []) + + def test_no_theme_installed_never_even_reads_the_log(self): + # Le garde n'est pas décoratif : sans lui on ouvrirait et lirait + # 64 ko à CHAQUE erreur d'une migration, pour une liste vide. + lectures = [] + self.obj.installed_theme = lambda base: [] + self.obj.step_log_tail = lambda *a, **k: lectures.append(1) or "" + self.obj.theme_blamed_by_the_error("db") + self.assertEqual(lectures, []) + + def test_no_log_blames_nothing(self): + # Sans journal on ne SAIT pas : accuser au hasard serait pire + # que se taire. + self.assertEqual(self.obj.theme_blamed_by_the_error("db"), []) + + def test_no_database_blames_nothing(self): + self.journal("theme_buzzy") + self.assertEqual(self.obj.theme_blamed_by_the_error(""), []) + + def test_only_the_themes_actually_named_are_returned(self): + self.obj.installed_theme = lambda base: ["theme_buzzy", "theme_zap"] + self.journal("erreur dans theme_zap/data/ir_asset.xml") + self.assertEqual( + self.obj.theme_blamed_by_the_error("db"), ["theme_zap"] + ) + + +class TestReadingTheTail(unittest.TestCase): + def setUp(self): + self.obj = TodoUpgrade.__new__(TodoUpgrade) + self.dossier = tempfile.mkdtemp() + self.obj.log_dir = lambda: self.dossier + self.obj.current_step = "etape" + self.obj.step_slug = staticmethod(lambda msg: "etape") + + def test_it_reads_from_the_END(self): + # Une mise à jour de modules écrit des dizaines de milliers de + # lignes : charger tout le fichier pour en lire vingt coûterait + # plus que l'erreur qu'on cherche. + with io.open( + os.path.join(self.dossier, "etape.log"), "w", encoding="utf-8" + ) as handle: + handle.write("debut\n" + ("x" * 100000) + "\nLA_FIN\n") + fin = self.obj.step_log_tail(octets=1000) + self.assertIn("LA_FIN", fin) + self.assertNotIn("debut", fin) + self.assertLessEqual(len(fin), 1100) + + def test_a_missing_log_is_empty_not_a_crash(self): + self.assertEqual(self.obj.step_log_tail(), "") + + def test_no_step_is_empty(self): + self.obj.current_step = "" + self.assertEqual(self.obj.step_log_tail(), "") + + def test_broken_bytes_do_not_stop_it(self): + # Un journal de migration mêle les encodages : refuser de le lire + # ferait perdre la détection au moment où elle sert. + with open(os.path.join(self.dossier, "etape.log"), "wb") as handle: + handle.write(b"avant \xff\xfe theme_buzzy apres") + self.assertIn("theme_buzzy", self.obj.step_log_tail()) + + +class TestTheMenu(unittest.TestCase): + RACINE = os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) + + def source(self): + with io.open( + os.path.join(self.RACINE, "script", "todo", "todo_upgrade.py"), + encoding="utf-8", + ) as handle: + src = handle.read() + debut = src.index("def _prompt_on_error") + fin = src.index("def prompt_fix_view_type") + return src[debut:fin] + + def test_the_entry_is_conditional(self): + # Affichée sans condition, elle proposerait de retirer le thème + # à chaque erreur, quelle qu'elle soit. + bloc = self.source() + self.assertIn("if themes:", bloc) + self.assertIn('f"[6] ', bloc) + + def test_the_branch_requires_a_blamed_theme(self): + bloc = self.source() + self.assertIn('if wait_status == "6" and themes:', bloc) + + def test_the_detection_runs_once_outside_the_loop(self): + # La boucle peut tourner huit fois ; la détection lit un journal + # et interroge la base. + bloc = self.source() + self.assertEqual(bloc.count("theme_blamed_by_the_error"), 1) + self.assertLess( + bloc.index("theme_blamed_by_the_error"), bloc.index("while True:") + ) + + def test_uninstalling_asks_for_a_retry(self): + bloc = self.source() + debut = bloc.index('if wait_status == "6"') + fin = bloc.index('if wait_status == "5"') + morceau = bloc[debut:fin] + self.assertIn('wait_status = "1"', morceau) + self.assertIn("repare = True", morceau) + + def test_it_goes_through_the_real_terminal(self): + # `uninstall_addons_theme.sh` finit par poser une question ; un + # tube la rendrait invisible et l'on répondrait à l'aveugle. + bloc = self.source() + debut = bloc.index('if wait_status == "6"') + fin = bloc.index('if wait_status == "5"') + self.assertIn("run_on_terminal", bloc[debut:fin]) + + def test_the_label_is_translated(self): + self.assertIn( + "Uninstall the theme(s) the error names", todo_i18n.TRANSLATIONS + ) + + +if __name__ == "__main__": + unittest.main() From 7e30377e56fbb3c903c4abcee68a49f6ba0ef73a Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sat, 22 Aug 2026 02:34:22 -0400 Subject: [PATCH 67/67] [ADD] migration: convert the tags Odoo 18 renamed to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenUpgrade converts the COLUMN and never the ARCH: def _fix_list_view_type(cr): cr.execute("UPDATE ir_ui_view SET type='list' WHERE type='tree'") so the load dies on "the root node of a list view should be ". A module view recovers at its next update, which rewrites the arch from XML. A view with no xmlid -- a hand-made list, a website copy -- is rewritten by nothing and stays broken forever. The report says which is which, because the two call for different work. Every occurrence, not just the root: a nested in a form is a one2many list and is refused just the same. Odoo 18's own addons hold none, so nothing legitimate is at risk. Gated on version 18: before it, the tag is correct and "fixing" it would break healthy views. Four tests run the SQL against a real PostgreSQL. A text assertion cannot see what a query does -- it took a live run to prove every language of the jsonb arch is converted, not just en_US. --- FR --- OpenUpgrade convertit la COLONNE et jamais l'ARCH, d'où l'échec sur « le nœud racine d'une vue list devrait être ». Une vue de module s'en remet à sa prochaine mise à jour ; une vue sans xmlid — liste faite main, copie de site — n'est réécrite par rien. Le rapport distingue les deux, car le travail diffère. Toutes les occurrences, pas seulement la racine : un imbriqué dans un formulaire est une liste one2many, refusée pareillement. Les addons d'Odoo 18 n'en portent aucun. Bridé à la 18 : avant, la balise est juste. Quatre tests exécutent le SQL contre un vrai PostgreSQL. Une assertion sur du texte ne voit pas ce qu'une requête fait — il a fallu l'exécuter pour prouver que toutes les langues du jsonb sont converties. Assisted-by: Claude Opus 5 --- script/odoo/migration/fix_view_type.py | 185 ++++++++++++++-- script/todo/todo_i18n.py | 24 ++- test/test_fix_view_type.py | 281 ++++++++++++++++++++++++- 3 files changed, 469 insertions(+), 21 deletions(-) diff --git a/script/odoo/migration/fix_view_type.py b/script/odoo/migration/fix_view_type.py index 5022cea..adb24ec 100755 --- a/script/odoo/migration/fix_view_type.py +++ b/script/odoo/migration/fix_view_type.py @@ -2,7 +2,10 @@ # © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) -"""Une vue dont le `type` stocké contredit son héritage. +"""Deux données de vue qu'Odoo refuse, et qu'aucune mise à jour ne répare. + +A — le `type` stocké contredit l'héritage +========================================= Le symptôme est brutal : Odoo refuse de charger la base. @@ -37,6 +40,29 @@ lui-même, citée plus haut. Vérifié : zéro écart sur une installation 18 neuve et sur quatre bases migrées, un seul sur celle qui refusait de charger. Le détecteur ne crie pas au loup. +B — un `` survit dans une base Odoo 18 +============================================ +La 18 a renommé le type `tree` en `list`, balise comprise. OpenUpgrade +convertit la COLONNE : + + def _fix_list_view_type(cr): + '''Former tree views have view type list now.''' + cr.execute("UPDATE ir_ui_view SET type='list' WHERE type='tree'") + +…et jamais l'ARCH. Le chargement meurt alors sur + + Le nœud racine d'une vue list devrait être , et non + +Une vue de module s'en remet à la première mise à jour du module, qui +réécrit son arch depuis le XML. Une vue SANS xmlid — une liste faite à +la main, une copie de site — n'est réécrite par rien : elle reste +cassée pour toujours. + +Le remplacement porte sur TOUTES les occurrences, pas seulement la +racine : un `` imbriqué dans un formulaire (une liste one2many) +est tout aussi refusé. Vérifié : les addons d'Odoo 18 n'en contiennent +plus une seule, donc tout `` d'une base 18 est un reste. + Codes de sortie : 0 rien à signaler, 1 des trouvailles, 2 l'outil a échoué. """ @@ -85,6 +111,10 @@ ORDER BY v.id """ +# La 18 renomme `tree` en `list`. Avant, la balise est légitime. +PREMIERE_VERSION_LIST = 18 + + def run_psql(database, sql, read_only=True): """Interroger la base. En lecture seule sauf demande explicite.""" env = os.environ.copy() @@ -121,6 +151,97 @@ def find(database): ] +def db_major(database): + """La version majeure d'Odoo de cette base, ou None. + + Celle qu'Odoo inscrit, pas celle du checkout : on répare une base, + pas un répertoire. + """ + lignes = run_psql( + database, + "SELECT latest_version FROM ir_module_module WHERE name = 'base'", + ) + if not lignes or not lignes[0][0]: + return None + try: + return int(lignes[0][0].split(".")[0]) + except ValueError: + return None + + +def arch_is_jsonb(database): + """`arch_db` est un jsonb depuis la 16, un texte avant.""" + lignes = run_psql( + database, + "SELECT data_type FROM information_schema.columns" + " WHERE table_name = 'ir_ui_view' AND column_name = 'arch_db'", + ) + return bool(lignes) and lignes[0][0] == "jsonb" + + +def arch_as_text(jsonb): + """L'expression SQL qui rend l'arch en texte, quelle que soit sa forme.""" + return "arch_db::text" if jsonb else "coalesce(arch_db, '')" + + +def find_tree(database, jsonb): + """Les vues qui portent encore un ``. [] hors des bases 18+.""" + texte = arch_as_text(jsonb) + lignes = run_psql( + database, + f"SELECT v.id::text, v.type," + f" coalesce(d.module || '.' || d.name, '-')," + f" coalesce(v.model, '-')," + f" CASE WHEN d.id IS NULL THEN 'custom' ELSE 'module' END" + f" FROM ir_ui_view v" + f" LEFT JOIN ir_model_data d" + f" ON d.model = 'ir.ui.view' AND d.res_id = v.id" + f" WHERE {texte} LIKE '%= 5 + ] + + +def repair_tree_sql(vues, jsonb): + """Le SQL qui remplace `` par ``, ou "". + + TOUTES les occurrences : un `` imbriqué dans un formulaire est + refusé autant que celui de la racine. Les addons d'Odoo 18 n'en + contiennent plus une seule, donc il n'y a rien de légitime à épargner. + """ + if not vues: + return "" + liste = ", ".join(str(vue["id"]) for vue in vues) + if jsonb: + # jsonb : reconstruire l'objet, langue par langue. Une traduction + # oubliée laisserait la vue cassée dans cette langue seulement — + # une panne qui ne se montre qu'à certains. + remplace = ( + "(SELECT jsonb_object_agg(cle," + " replace(replace(valeur, '', ''))" + " FROM jsonb_each_text(arch_db) AS paires(cle, valeur))" + ) + else: + remplace = ( + "replace(replace(arch_db, '', '')" + ) + return f"UPDATE ir_ui_view SET arch_db = {remplace} WHERE id IN ({liste})" + + def repair_sql(vues): """Le SQL de correction, ou "" s'il n'y a rien à corriger. @@ -142,7 +263,7 @@ def repair_sql(vues): def render(vues, applique=False): if not vues: - return [f"✅ {t('Every view type agrees with its inheritance.')}"] + return [] lignes = [ f"🩹 {len(vues)} {t('view(s) whose stored type contradicts')}" f" {t('their inheritance')} :" @@ -162,6 +283,31 @@ def render(vues, applique=False): return lignes +def render_tree(vues, applique=False): + if not vues: + return [] + lignes = [ + f"🩹 {len(vues)} {t('view(s) still carry a tag Odoo 18')}" + f" {t('renamed to ')} :" + ] + for vue in vues: + # L'ORIGINE est le renseignement utile : une vue de module se + # répare d'une mise à jour, une vue sans xmlid n'est réécrite + # par rien et reste cassée pour toujours. + marque = ( + t("custom — nothing will ever rewrite it") + if vue["origin"] == "custom" + else t("from a module — a module update also fixes it") + ) + lignes.append( + f" {vue['id']:<7} {vue['xmlid'][:40]:<42}" + f" {vue['type']:<8} {vue['model'][:22]:<24} {marque}" + ) + if applique: + lignes.append(f" ✅ {t('Corrected.')}") + return lignes + + def main(argv=None): parser = argparse.ArgumentParser( description=( @@ -181,21 +327,36 @@ def main(argv=None): if vues is None: print(f"❌ {t('Cannot read the database: ')}{config.database}") return 2 - if not vues: - print("\n".join(render(vues))) + # Le renommage `tree` → `list` n'a de sens qu'à partir de la 18 : + # avant, la balise est parfaitement légitime et la « corriger » + # casserait des vues saines. + jsonb = arch_is_jsonb(config.database) + majeure = db_major(config.database) + arbres = [] + if majeure and majeure >= PREMIERE_VERSION_LIST: + arbres = find_tree(config.database, jsonb) or [] + + if not vues and not arbres: + print(f"✅ {t('Every view agrees with what Odoo expects.')}") return 0 if not config.apply: - print("\n".join(render(vues))) + print("\n".join(render(vues) + render_tree(arbres))) return 1 - sql = repair_sql(vues) - if run_psql(config.database, sql, read_only=False) is None: + + for sql in (repair_sql(vues), repair_tree_sql(arbres, jsonb)): + if sql and run_psql(config.database, sql, read_only=False) is None: + print(f"❌ {t('The correction failed.')}") + return 2 + # Relire APRÈS : annoncer « corrigé » sans vérifier ferait relancer + # la migration sur le même mur. + if find(config.database) or (arbres and find_tree(config.database, jsonb)): print(f"❌ {t('The correction failed.')}") return 2 - reste = find(config.database) - if reste: - print(f"❌ {t('The correction failed.')}") - return 2 - print("\n".join(render(vues, applique=True))) + print( + "\n".join( + render(vues, applique=True) + render_tree(arbres, applique=True) + ) + ) return 0 diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 1154f65..6a5118a 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -6135,10 +6135,6 @@ TRANSLATIONS = { "fr": "Corrigé.", "en": "Corrected.", }, - "Every view type agrees with its inheritance.": { - "fr": "Tous les types de vue s'accordent avec leur héritage.", - "en": "Every view type agrees with its inheritance.", - }, "No module update will fix this: the type is set once,": { "fr": "Aucune mise à jour de module ne corrigera cela : le type est posé une fois,", "en": "No module update will fix this: the type is set once,", @@ -6175,6 +6171,26 @@ TRANSLATIONS = { "fr": "Désinstaller le(s) thème(s) que l'erreur nomme", "en": "Uninstall the theme(s) the error names", }, + "Every view agrees with what Odoo expects.": { + "fr": "Toutes les vues s'accordent avec ce qu'Odoo attend.", + "en": "Every view agrees with what Odoo expects.", + }, + "custom — nothing will ever rewrite it": { + "fr": "faite main — rien ne la réécrira jamais", + "en": "custom — nothing will ever rewrite it", + }, + "from a module — a module update also fixes it": { + "fr": "d'un module — une mise à jour la répare aussi", + "en": "from a module — a module update also fixes it", + }, + "renamed to ": { + "fr": "a renommée en ", + "en": "renamed to ", + }, + "view(s) still carry a tag Odoo 18": { + "fr": "vue(s) portent encore une balise qu'Odoo 18", + "en": "view(s) still carry a tag Odoo 18", + }, "Census": { "fr": "Recensement", "en": "Census", diff --git a/test/test_fix_view_type.py b/test/test_fix_view_type.py index e28926f..0fb1ec3 100644 --- a/test/test_fix_view_type.py +++ b/test/test_fix_view_type.py @@ -88,11 +88,8 @@ class TestTheRepairSql(unittest.TestCase): class TestTheReport(unittest.TestCase): - def test_a_clean_database_says_so(self): - texte = "\n".join(fvt.render([])) - self.assertIn( - todo_i18n.t("Every view type agrees with its inheritance."), texte - ) + def test_a_clean_check_renders_nothing(self): + self.assertEqual(fvt.render([]), []) def test_a_finding_names_the_view_and_both_types(self): texte = "\n".join(fvt.render([vue(637, "tree", "search")])) @@ -193,6 +190,280 @@ class TestTheExitCodes(unittest.TestCase): self.assertEqual(code, 2) +def arbre(vid=3931, origine="custom", type_="list", xmlid="-"): + return { + "id": vid, + "type": type_, + "xmlid": xmlid, + "model": "res.partner", + "origin": origine, + } + + +class TestTheTreeTag(unittest.TestCase): + """Odoo 18 a renommé `tree` en `list`, balise comprise. + + OpenUpgrade convertit la COLONNE `type` et jamais l'ARCH. Une vue de + module s'en remet à la première mise à jour ; une vue sans xmlid + n'est réécrite par rien. + """ + + def test_the_replacement_covers_open_AND_close(self): + sql = fvt.repair_tree_sql([arbre()], jsonb=True) + self.assertIn("'', ''", sql) + + def test_it_replaces_EVERY_occurrence_not_just_the_root(self): + # Un `` imbriqué dans un formulaire — une liste one2many — + # est refusé autant que celui de la racine. `replace` en SQL + # remplace tout ; un ancrage `^` n'aurait pris que le premier. + sql = fvt.repair_tree_sql([arbre()], jsonb=True) + self.assertNotIn("^", sql) + self.assertNotIn("regexp", sql.lower()) + + def test_jsonb_is_rebuilt_language_by_language(self): + # Une traduction oubliée laisserait la vue cassée dans cette + # langue seulement — une panne qui ne se montre qu'à certains. + sql = fvt.repair_tree_sql([arbre()], jsonb=True) + self.assertIn("jsonb_each_text", sql) + self.assertIn("jsonb_object_agg", sql) + + def test_a_text_column_is_handled_too(self): + sql = fvt.repair_tree_sql([arbre()], jsonb=False) + self.assertNotIn("jsonb", sql) + self.assertIn("replace(replace(arch_db", sql) + + def test_nothing_to_fix_gives_no_sql(self): + self.assertEqual(fvt.repair_tree_sql([], jsonb=True), "") + + def test_it_targets_the_listed_ids_only(self): + sql = fvt.repair_tree_sql([arbre(11), arbre(22)], jsonb=True) + self.assertIn("WHERE id IN (11, 22)", sql) + + def test_the_report_tells_custom_from_module(self): + # C'est LE renseignement utile : l'une se répare d'une mise à + # jour, l'autre ne se répare jamais toute seule. + texte = "\n".join( + fvt.render_tree([arbre(1, "custom"), arbre(2, "module")]) + ) + self.assertIn( + todo_i18n.t("custom — nothing will ever rewrite it"), texte + ) + self.assertIn( + todo_i18n.t("from a module — a module update also fixes it"), texte + ) + + def test_no_tree_renders_nothing(self): + self.assertEqual(fvt.render_tree([]), []) + + +class TestTheSqlAgainstARealPostgres(unittest.TestCase): + """Le SQL, exécuté pour de vrai. + + Une assertion sur le TEXTE d'une requête ne voit pas ce qu'elle + fait : ajouter un `LIMIT 1` la laisserait passer alors qu'une seule + langue serait convertie — une vue cassée dans les autres, une panne + qui ne se montre qu'à certains. + """ + + BASE = "tmp_fix_view_type_test" + + @classmethod + def setUpClass(cls): + import shutil as _shutil + import subprocess as _sub + + if not _shutil.which("psql") or not _shutil.which("createdb"): + raise unittest.SkipTest("PostgreSQL absent") + _sub.run(["dropdb", "--if-exists", cls.BASE], capture_output=True) + fait = _sub.run(["createdb", cls.BASE], capture_output=True) + if fait.returncode: + raise unittest.SkipTest("createdb impossible") + + @classmethod + def tearDownClass(cls): + import shutil as _shutil + import subprocess as _sub + + if _shutil.which("dropdb"): + _sub.run(["dropdb", "--if-exists", cls.BASE], capture_output=True) + + def prepare(self, colonne, valeur): + fvt.run_psql( + self.BASE, "DROP TABLE IF EXISTS ir_ui_view", read_only=False + ) + fvt.run_psql( + self.BASE, + f"CREATE TABLE ir_ui_view (id serial PRIMARY KEY," + f" arch_db {colonne})", + read_only=False, + ) + fvt.run_psql( + self.BASE, + f"INSERT INTO ir_ui_view (arch_db) VALUES ({valeur})", + read_only=False, + ) + + def arch(self): + lignes = fvt.run_psql( + self.BASE, "SELECT arch_db::text FROM ir_ui_view" + ) + return lignes[0][0] if lignes else "" + + def test_every_language_is_converted(self): + self.prepare( + "jsonb", + '\'{"en_US": "",' + ' "fr_CA": ""}\'::jsonb', + ) + fvt.run_psql( + self.BASE, + fvt.repair_tree_sql([arbre(1)], jsonb=True), + read_only=False, + ) + obtenu = self.arch() + self.assertNotIn(""), 2) + self.assertEqual(obtenu.count(""), 2) + + def test_a_nested_tree_is_converted_too(self): + # Une liste one2many dans un formulaire : refusée autant que la + # racine, et invisible pour un contrôle qui ne verrait que celle-ci. + self.prepare( + "jsonb", + '\'{"en_US": "
"}\'::jsonb', + ) + fvt.run_psql( + self.BASE, + fvt.repair_tree_sql([arbre(1)], jsonb=True), + read_only=False, + ) + self.assertNotIn("", self.arch()) + + def test_a_text_column_works_too(self): + self.prepare("text", "''") + fvt.run_psql( + self.BASE, + fvt.repair_tree_sql([arbre(1)], jsonb=False), + read_only=False, + ) + self.assertEqual(self.arch(), "") + + def test_the_detection_query_runs(self): + # Une requête qui ne compile pas rendrait None, et l'outil + # dirait « base illisible » au lieu de « rien à corriger ». + self.prepare("jsonb", '\'{"en_US": "
"}\'::jsonb') + fvt.run_psql( + self.BASE, + "CREATE TABLE IF NOT EXISTS ir_model_data" + " (id serial, model text, res_id integer, module text, name text)", + read_only=False, + ) + fvt.run_psql( + self.BASE, + "ALTER TABLE ir_ui_view ADD COLUMN IF NOT EXISTS type text," + " ADD COLUMN IF NOT EXISTS model text", + read_only=False, + ) + self.assertEqual(fvt.find_tree(self.BASE, jsonb=True), []) + + +class TestTheVersionGate(unittest.TestCase): + """Avant la 18, `` est parfaitement légitime.""" + + def setUp(self): + self.vrai = fvt.run_psql + self.ecritures = [] + + def tearDown(self): + fvt.run_psql = self.vrai + + def branche(self, majeure, arbres=True): + def faux(database, sql, read_only=True): + if not read_only: + self.ecritures.append(sql) + return [] + if "latest_version" in sql: + return [[f"{majeure}.0.1.3"]] + if "information_schema" in sql: + return [["jsonb"]] + if "WITH RECURSIVE" in sql: + return [] + if " légitime casserait une vue saine. + self.branche(17) + code, sortie = self.lance(["-d", "db"]) + self.assertEqual(code, 0) + self.assertEqual(self.ecritures, []) + self.assertIn( + todo_i18n.t("Every view agrees with what Odoo expects."), sortie + ) + + def test_an_18_database_reports_it(self): + self.branche(18) + code, sortie = self.lance(["-d", "db"]) + self.assertEqual(code, 1) + self.assertIn("3931", sortie) + self.assertEqual(self.ecritures, []) + + def test_an_unknown_version_stays_on_the_safe_side(self): + # Sans version on ne SAIT pas : ne rien toucher. + self.branche(None) + code, _ = self.lance(["-d", "db"]) + self.assertEqual(code, 0) + + def test_apply_writes_once_per_repair(self): + appels = {"n": 0} + + def faux(database, sql, read_only=True): + if not read_only: + self.ecritures.append(sql) + return [] + if "latest_version" in sql: + return [["18.0.1.3"]] + if "information_schema" in sql: + return [["jsonb"]] + if "WITH RECURSIVE" in sql: + return [] + if "