diff --git a/script/analyse/analyse_custom_field.py b/script/analyse/analyse_custom_field.py new file mode 100644 index 0000000..480a180 --- /dev/null +++ b/script/analyse/analyse_custom_field.py @@ -0,0 +1,572 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Champs et modèles ajoutés hors module : Studio, ou faits à la main. + +Ce que l'outil répond : ce qu'un intégrateur devra reporter à la main lors +d'une montée de version, puisque rien ne le recréera. Un champ ``x_`` n'est +déclaré dans aucun fichier ; il ne vit que dans ``ir_model_fields``, et une +migration qui le perd perd aussi les données de sa colonne. + +Studio n'est pas nécessaire pour lire ça +----------------------------------------- +``web_studio`` est un module Enterprise, absent de ce dépôt. Les champs qu'il +crée restent pourtant de simples lignes de ``ir_model_fields`` avec +``state = 'manual'``, et une base migrée depuis une instance Enterprise garde +ses identifiants externes ``studio_customization``. Tout se lit en SQL. + +Attribuer un champ à Studio demande deux signaux, pas un +--------------------------------------------------------- +Un champ peut porter PLUSIEURS identifiants externes. Une jointure plate n'en +rendrait qu'un, choisi au hasard : Studio passerait inaperçu une fois sur +deux. Les modules sont donc agrégés, et l'appartenance testée sur l'ensemble. + +Le préfixe ``x_studio_`` est un indice de plus, jamais le seul : un champ créé +à la main en mode développeur s'appelle aussi ``x_quelque_chose``, et ce qui +le distingue est qu'il n'a AUCUN identifiant externe. + +Ce qui bloque, et ce qui ne fait que coûter +-------------------------------------------- +Un champ stocké dont la colonne physique manque empêche le registre de +charger : c'est un blocage. Un champ dont la relation pointe vers un modèle +disparu aussi. Le reste — des champs remplis à reporter, des champs vides à +supprimer avant de migrer — est du travail, pas une panne, et le rapport ne +les mélange pas. +""" + +import argparse +import json +import os +import sys +import textwrap + +new_path = os.path.normpath( + os.path.join(os.path.dirname(__file__), "..", "..") +) +sys.path.append(new_path) + +from script.analyse.lib_analyse import ( # noqa: E402 + AnalyseError, + backup_version, + column_types, + existing_columns, + json_query, + model_table, + normalise_arch, + public_tables, + read_backup, + require_odoo_database, + scalar_query, + t, + tr_col, +) + +STUDIO_MODULE = "studio_customization" + +# Un champ relationnel « vers plusieurs » n'a pas de colonne : il vit dans une +# table de relation. Ne pas l'exclure ferait rapporter chaque one2many et +# chaque many2many comme une colonne manquante. +NO_COLUMN_TYPES = ("one2many", "many2many") + +TOP_DEFAULT = 30 + + +def wrap_note(prefix, text, width=79): + """Replier une phrase à l'affichage, sans la découper en clés.""" + lines = textwrap.wrap(text, width=width - len(prefix)) or [""] + pad = " " * len(prefix) + return [prefix + lines[0]] + [pad + line for line in lines[1:]] + + +def origin_label(name): + """Libellé traduit d'une provenance. Épelé, pour que le contrôle voie.""" + return { + "studio": t("Studio"), + "handmade": t("Made by hand"), + "module": t("Declared by a module"), + }.get(name, name) + + +def blocker_label(name): + """Libellé traduit d'un blocage. Épelé, comme les provenances.""" + return { + "missing_column": t("stored, but its column is missing"), + "dangling_relation": t("points at a model that no longer exists"), + "model_gone": t("its model no longer exists"), + "table_unknown": t("its table could not be resolved"), + }.get(name, name) + + +def field_origin(row): + """D'où vient ce champ : Studio, fait à la main, ou déclaré par un module. + + Fonction pure, testable sur fixture. + """ + lst_module = row.get("xmlid_modules") or [] + if STUDIO_MODULE in lst_module: + return "studio" + if not lst_module: + return "handmade" + return "module" + + +def _field_rows(database, **kwargs): + """Les champs manuels, avec tout ce qui sert à les juger. + + ``state = 'manual'`` est le critère d'Odoo ; le motif sur le nom l'élargit + aux bases dont la contrainte n'a pas toujours été posée. Les deux, parce + qu'aucun n'est complet seul. + """ + cols = existing_columns(database, "ir_model_fields", **kwargs) + dct_type = column_types(database, "ir_model_fields", **kwargs) + + def col(column, absent): + return f"f.{column}" if column in cols else absent + + label = tr_col("f", "field_description", dct_type) + help_text = tr_col("f", "help", dct_type) + return json_query( + database, + rf""" + SELECT f.id AS id, + f.model AS model, + f.name AS name, + f.ttype AS ttype, + {col("relation", "NULL::text")} AS relation, + {col("related", "NULL::text")} AS related, + {col("required", "false")} AS required, + {col("readonly", "false")} AS readonly, + {col("store", "true")} AS store, + {col("index", "false")} AS indexed, + {col("translate", "false")} AS translate, + {col("company_dependent", "false")} AS company_dependent, + ({col("compute", "NULL")} IS NOT NULL + AND {col("compute", "''")} <> '') AS is_computed, + {label} AS label, + {help_text} AS help, + {col("state", "NULL::text")} AS state, + x.modules AS xmlid_modules, + {col("create_date", "NULL::timestamp")} AS create_date, + {col("write_date", "NULL::timestamp")} AS write_date + FROM ir_model_fields f + LEFT JOIN ( + SELECT res_id, array_agg(DISTINCT module) AS modules + FROM ir_model_data + WHERE model = 'ir.model.fields' + GROUP BY res_id + ) x ON x.res_id = f.id + WHERE {col("state", "''")} = 'manual' OR f.name LIKE 'x\_%' + ORDER BY f.model, f.name + """, + **kwargs, + ) + + +def _model_rows(database, **kwargs): + """Les modèles manuels — ceux que Studio crée comme objets personnalisés.""" + dct_type = column_types(database, "ir_model", **kwargs) + label = tr_col("m", "name", dct_type) + return json_query( + database, + rf""" + SELECT m.model AS model, + {label} AS description, + m.state AS state, + m.transient AS transient, + x.modules AS xmlid_modules + FROM ir_model m + LEFT JOIN ( + SELECT res_id, array_agg(DISTINCT module) AS modules + FROM ir_model_data + WHERE model = 'ir.model' + GROUP BY res_id + ) x ON x.res_id = m.id + WHERE m.state = 'manual' OR m.model LIKE 'x\_%' + ORDER BY m.model + """, + **kwargs, + ) + + +def _selection_rows(database, **kwargs): + """Valeurs de sélection des champs manuels, si la table existe. + + ``ir_model_fields_selection`` est apparue en cours de route : avant, les + valeurs vivaient dans une chaîne du champ. On sonde plutôt que de dater. + """ + if not scalar_query( + database, + "SELECT to_regclass('public.ir_model_fields_selection');", + **kwargs, + ): + return {} + rows = json_query( + database, + """ + SELECT f.model AS model, f.name AS name, s.value AS value + FROM ir_model_fields_selection s + JOIN ir_model_fields f ON f.id = s.field_id + WHERE f.state = 'manual' + ORDER BY f.model, f.name, s.sequence + """, + **kwargs, + ) + dct = {} + for row in rows: + dct.setdefault((row["model"], row["name"]), []).append(row["value"]) + return dct + + +def collect(database, config_path=None, timeout=120): + """Tout le travail. Donnée pure, sérialisable, aucun affichage.""" + kwargs = {"config_path": config_path, "timeout": timeout} + require_odoo_database(database, **kwargs) + odoo_version = scalar_query( + database, + "SELECT latest_version FROM ir_module_module WHERE name = 'base';", + **kwargs, + ) + + lst_field = _field_rows(database, **kwargs) + lst_model = _model_rows(database, **kwargs) + dct_selection = _selection_rows(database, **kwargs) + set_table = public_tables(database, **kwargs) + set_model = { + row["model"] + for row in json_query(database, "SELECT model FROM ir_model", **kwargs) + } + + # Les colonnes réelles, une sonde par table concernée seulement. + dct_columns = {} + for row in lst_field: + table = model_table(row["model"], known_tables=set_table) + row["table"] = table + if table and table not in dct_columns: + dct_columns[table] = existing_columns(database, table, **kwargs) + + lst_blocker = _judge( + lst_field, set_model, set_table, dct_columns, dct_selection + ) + return _result( + database, + odoo_version, + lst_field, + lst_model, + lst_blocker, + source="database", + ) + + +def _judge(lst_field, set_model, set_table, dct_columns, dct_selection): + """Attribuer et juger chaque champ. Renvoie la liste des bloquants. + + Partagé par la lecture d'une base et celle d'une sauvegarde : les deux + doivent conclure la même chose des mêmes faits, sinon le zip et la base + d'où il vient ne diraient pas pareil. + """ + lst_blocker = [] + for row in lst_field: + row["origin"] = field_origin(row) + row["selection"] = dct_selection.get((row["model"], row["name"])) + row["blocker"] = None + + if row["table"] is None: + # Table non résolue : un fait, pas une anomalie. Le modèle peut + # avoir un _table surchargé qu'on ne connaît pas, ou ne plus + # exister du tout — deux choses qu'on ne confond pas ici. + row["blocker"] = ( + "model_gone" + if row["model"] not in set_model + else "table_unknown" + ) + elif ( + row["store"] + and row["ttype"] not in NO_COLUMN_TYPES + and row["name"] not in dct_columns[row["table"]] + ): + # Un champ stocké sans sa colonne empêche le registre de charger. + row["blocker"] = "missing_column" + elif row["relation"] and row["relation"] not in set_model: + row["blocker"] = "dangling_relation" + + if row["blocker"] in ( + "missing_column", + "dangling_relation", + "model_gone", + ): + lst_blocker.append(row) + + return lst_blocker + + +def _result( + database, + odoo_version, + lst_field, + lst_model, + lst_blocker, + source="database", +): + """La donnée de sortie, une seule forme quelle que soit la provenance.""" + dct_origin = {"studio": 0, "handmade": 0, "module": 0} + for row in lst_field: + dct_origin[row["origin"]] += 1 + + return { + "tool": "analyse_custom_field", + "version": 1, + "database": database, + "source": source, + "odoo_version": odoo_version, + "n_fields": len(lst_field), + "n_models": len(lst_model), + "counts": { + **dct_origin, + "blockers": len(lst_blocker), + "models": len(lst_model), + }, + "fields": lst_field, + "models": lst_model, + "blockers": lst_blocker, + } + + +def collect_from_backup(zip_path): + """Même analyse, mais depuis une sauvegarde .zip, sans rien restaurer. + + Pourquoi cela existe : restaurer la sauvegarde d'une instance Enterprise + sur une installation Community échoue — Odoo veut charger des modules + qu'on n'a pas. Les champs Studio, eux, ne sont que des lignes de + `ir_model_fields`, et un `dump.sql` est du texte. On les lit donc là où + ils sont, plutôt que d'exiger une restauration impossible. + + Ce que la sauvegarde permet en moins : rien, pour cet outil. Le dump + contient les `CREATE TABLE`, donc même la colonne physique manquante — le + seul vrai bloquant — se détecte. + """ + manifest, dct_rows, dct_columns, _ = read_backup( + zip_path, + tables=( + "ir_model_fields", + "ir_model", + "ir_model_data", + "ir_module_module", + ), + with_columns=True, + ) + + # Les identifiants externes, agrégés par champ et par modèle — le même + # regroupement que fait le SQL, pour que Studio s'attribue pareil. + dct_xmlid = {} + for row in dct_rows["ir_model_data"]: + key = (row.get("model"), row.get("res_id")) + dct_xmlid.setdefault(key, set()).add(row.get("module")) + + lst_field = [] + for row in dct_rows["ir_model_fields"]: + name = row.get("name") or "" + if row.get("state") != "manual" and not name.startswith("x_"): + continue + lst_field.append( + { + "id": row.get("id"), + "model": row.get("model"), + "name": name, + "ttype": row.get("ttype"), + "relation": row.get("relation"), + "related": row.get("related"), + # Le dump rend « t »/« f » : PostgreSQL écrit les booléens + # ainsi dans un COPY, et « f » est une chaîne vraie en Python. + "store": row.get("store") != "f", + "translate": row.get("translate") == "t", + "state": row.get("state"), + # Un champ traduit est du jsonb à partir de 16.0. Depuis une + # base, tr_col le déballe côté SQL ; depuis un dump, la valeur + # arrive brute, et « {"en_US": "Code client"} » ne se lit pas. + # normalise_arch fait ce déballage, et c'est la même fonction + # des deux côtés — deux implémentations divergeraient. + "label": normalise_arch(row.get("field_description")), + "help": normalise_arch(row.get("help")), + "xmlid_modules": sorted( + dct_xmlid.get(("ir.model.fields", row.get("id"))) or [] + ), + "create_date": row.get("create_date"), + "write_date": row.get("write_date"), + } + ) + + lst_model = [ + { + "model": row.get("model"), + "description": normalise_arch(row.get("name")), + "state": row.get("state"), + "transient": row.get("transient") == "t", + "xmlid_modules": sorted( + dct_xmlid.get(("ir.model", row.get("id"))) or [] + ), + } + for row in dct_rows["ir_model"] + if row.get("state") == "manual" + or (row.get("model") or "").startswith("x_") + ] + + set_model = {row.get("model") for row in dct_rows["ir_model"]} + set_table = set(dct_columns) + for row in lst_field: + row["table"] = model_table(row["model"], known_tables=set_table) + + lst_blocker = _judge(lst_field, set_model, set_table, dct_columns, {}) + data = _result( + os.path.basename(zip_path), + backup_version(dct_rows, manifest), + lst_field, + lst_model, + lst_blocker, + source="backup", + ) + data["backup_path"] = zip_path + data["backup_db_name"] = manifest.get("db_name") + return data + + +def _field_block(lst_row, top): + """Une ligne par champ : modèle, nom, type, provenance.""" + lines = [ + f" {'model':<28}{'field':<30}{'type':<12}{t('origin')}", + ] + for row in lst_row[:top]: + lines.append( + f" {(row['model'] or '')[:27]:<28}{(row['name'] or '')[:29]:<30}" + f"{(row['ttype'] or '')[:11]:<12}{origin_label(row['origin'])}" + ) + if len(lst_row) > top: + lines.append(f" … {len(lst_row) - top} {t('more')}") + return lines + + +def render(data, verbose=False, top=TOP_DEFAULT, hints=True): + """Rapport texte. Fonction pure : donnée -> chaîne, testable sans base.""" + counts = data["counts"] + lines = [ + "", + f"🔬 {t('Fields added outside a module')} — {data['database']}" + f" (Odoo {data.get('odoo_version') or '?'}" + f"{', ' + t('from a backup') if data.get('source') == 'backup' else ''})", + "", + f" {t('Custom fields'):<30}: {data['n_fields']}", + ] + for name in ("studio", "handmade", "module"): + if counts.get(name): + lines.append(f" {origin_label(name):<30}: {counts[name]}") + if data["n_models"]: + lines.append(f" {t('Custom models'):<30}: {data['n_models']}") + + if not data["n_fields"] and not data["n_models"]: + lines += [ + "", + f"✅ {t('No field or model was added outside a module.')}", + ] + return "\n".join(lines) + "\n" + + if data["blockers"]: + lines += ["", f"── ❌ {t('Blocking')} ({len(data['blockers'])}) ──"] + for row in data["blockers"]: + lines.append( + f" {row['model']}.{row['name']} —" + f" {blocker_label(row['blocker'])}" + + (f" → {row['relation']}" if row.get("relation") else "") + ) + lines.append("") + lines += wrap_note( + " ", + t( + "A stored field without its column stops the registry from" + " loading, so the upgrade will not even start. Settle these" + " before anything else." + ), + ) + + lst_show = data["fields"] + if lst_show: + lines += [ + "", + f"── ⚠️ {t('To carry over by hand')} ({len(lst_show)}" + f"{', ' + str(len(data['blockers'])) + ' ' + t('blocking') if data['blockers'] else ''}) ──", + ] + lines += _field_block(lst_show, len(lst_show) if verbose else top) + + if data["models"]: + lines += ["", f"── 🧱 {t('Custom models')} ({len(data['models'])}) ──"] + for row in data["models"][: len(data["models"]) if verbose else top]: + lines.append( + f" {row['model']:<32}{(row.get('description') or '')[:40]}" + ) + + lines.append("") + lines += wrap_note( + " ", + t( + "Nothing declares these in a file, so no module will recreate" + " them. What a version upgrade keeps is what someone carried over." + ), + ) + if hints and not verbose: + lines += wrap_note( + " ℹ️ ", + t("Use -v to list them all, --json for the raw data."), + ) + return "\n".join(lines) + "\n" + + +def main(argv=None): + parser = argparse.ArgumentParser( + description=t( + "List the fields and models added outside a module — Studio or by" + " hand (read-only)." + ) + ) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("-d", "--database", help=t("database to inspect")) + source.add_argument( + "-z", + "--zip", + dest="backup", + help=t("Odoo backup .zip to inspect, without restoring it"), + ) + parser.add_argument( + "--top", + type=int, + default=TOP_DEFAULT, + help=t("how many to show (default: 30)"), + ) + parser.add_argument( + "-v", "--verbose", action="store_true", help=t("list every one") + ) + parser.add_argument("--json", action="store_true", help=t("output JSON")) + parser.add_argument( + "-c", "--config", default=None, help=t("path to an Odoo config file") + ) + config = parser.parse_args(argv) + + try: + if config.backup: + data = collect_from_backup(config.backup) + else: + data = collect(config.database, config_path=config.config) + except AnalyseError as exc: + print(f"❌ {exc}") + return 2 + except KeyboardInterrupt: + print(f"\n{t('Cancelled.')}") + return 2 + + if config.json: + print(json.dumps(data, indent=2, ensure_ascii=False, default=str)) + else: + print(render(data, verbose=config.verbose, top=config.top)) + return 1 if (data["fields"] or data["models"]) else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/analyse/analyse_diff_tui.py b/script/analyse/analyse_diff_tui.py new file mode 100644 index 0000000..9fc700c --- /dev/null +++ b/script/analyse/analyse_diff_tui.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Naviguer dans les différences entre une vue en base et celle du module. + +Lecture seule, sans exception. La touche « r » AFFICHE la commande de +réinitialisation, elle ne l'exécute pas : cet écran sert à décider, et décider +suppose d'avoir lu. Une vue personnalisée porte souvent un travail réel que +personne ne veut perdre d'un appui sur une touche. + +Pourquoi un seul DataTable à trois colonnes +-------------------------------------------- +Deux panneaux séparés ne défilent pas ensemble : il faudrait synchroniser +deux barres, et une ligne de gauche finirait en face de la mauvaise ligne de +droite — exactement l'erreur qu'un diff doit rendre impossible. Un seul +tableau porte « gauche | marque | droite » sur la même ligne, donc +l'alignement est structurel et non entretenu. Il donne aussi le curseur, la +sélection et l'événement de survol sans rien écrire. + +``TextArea`` a été écarté : c'est un éditeur, et il n'y a pas de coloration +XML sans tree-sitter, absent du venv. +""" + +import os +import sys + +new_path = os.path.normpath( + os.path.join(os.path.dirname(__file__), "..", "..") +) +sys.path.append(new_path) + +from script.analyse.lib_analyse import side_by_side, t # noqa: E402 + +CSS = """ +Screen { layout: vertical; } +#head { height: 3; padding: 0 1; background: $panel; color: $text; } +#body { height: 1fr; } +#views { width: 34; border-right: solid $accent; } +#diff { width: 1fr; } +""" + + +def diff_rows(finding): + """Les lignes en écart d'un constat, sans les lignes identiques. + + Le contexte est utile dans un diff unifié qu'on lit en entier ; ici on + saute d'un écart à l'autre, et les centaines de lignes identiques d'une + vue de site web ne feraient que les éloigner. + """ + return [ + (mark, left, right) + for mark, left, right in side_by_side( + finding.get("arch_ref"), finding.get("arch_db_text") + ) + if mark != " " + ] + + +def build_app(data): + """Construire l'application Textual. Importe Textual seulement ici. + + L'import vit dans la fonction pour que le module reste importable — et + donc testable — sur une machine sans Textual. + """ + from textual.app import App, ComposeResult + from textual.containers import Horizontal + from textual.widgets import DataTable, Footer, Header, Static + + class DiffApp(App): + CSS = globals()["CSS"] + BINDINGS = [ + ("q,escape", "quit", t("Quit")), + ("n", "only_diff", t("Differences only")), + ("w", "ignore_indent", t("Ignore indentation")), + ("c", "copy", t("Copy")), + ("r", "command", t("Reset command")), + ] + + def __init__(self, data): + super().__init__() + self.data = data + self.lst_finding = [ + row for row in data["findings"] if row.get("differs") + ] + self.only_diff = True + self.ignore_indent = False + self.intent = None + + def compose(self) -> ComposeResult: + yield Header() + yield Static("", id="head") + with Horizontal(id="body"): + yield DataTable(id="views", cursor_type="row") + yield DataTable(id="diff", cursor_type="row") + yield Footer() + + def on_mount(self): + self.title = t("Customised views") + table = self.query_one("#views", DataTable) + table.add_columns(t("view"), "+/-/≠") + for row in self.lst_finding: + stats = row.get("diff_stats") or {} + table.add_row( + (row.get("key") or str(row["id"]))[:26], + f"{stats.get('added', 0)}/{stats.get('removed', 0)}" + f"/{stats.get('changed', 0)}", + key=str(row["id"]), + ) + diff = self.query_one("#diff", DataTable) + diff.add_columns(t("module (file)"), " ", t("database")) + if self.lst_finding: + self._show(0) + + def _show(self, index): + row = self.lst_finding[index] + stats = row.get("diff_stats") or {} + self.query_one("#head", Static).update( + f"{row.get('key') or row['id']} · " + f"{row.get('arch_fs') or '—'}\n" + f"+{stats.get('added', 0)} -{stats.get('removed', 0)} " + f"≠{stats.get('changed', 0)} · " + f"{', '.join(row.get('reason') or []) or '—'}" + ) + diff = self.query_one("#diff", DataTable) + diff.clear() + for mark, left, right in side_by_side( + row.get("arch_ref"), row.get("arch_db_text") + ): + if self.only_diff and mark == " ": + continue + if self.ignore_indent: + left = (left or "").strip() + right = (right or "").strip() + diff.add_row(left or "", mark, right or "") + + def on_data_table_row_highlighted(self, event): + if event.data_table.id == "views" and self.lst_finding: + self._show(event.cursor_row) + + def _refresh(self): + table = self.query_one("#views", DataTable) + if self.lst_finding: + self._show(table.cursor_row) + + def action_only_diff(self): + self.only_diff = not self.only_diff + self._refresh() + + def action_ignore_indent(self): + self.ignore_indent = not self.ignore_indent + self._refresh() + + def action_copy(self): + table = self.query_one("#views", DataTable) + if not self.lst_finding: + return + row = self.lst_finding[table.cursor_row] + text = "\n".join( + f"{mark} {left or ''} | {right or ''}" + for mark, left, right in diff_rows(row) + ) + # Tronqué en gardant la FIN : c'est là que se trouve ce qu'on + # vient d'ajouter, donc ce qu'on cherche le plus souvent. + self.copy_to_clipboard(text[-100_000:]) + self.notify(t("Difference copied.")) + + def action_command(self): + """Rendre l'intention à l'appelant : l'écran n'écrit jamais.""" + table = self.query_one("#views", DataTable) + if not self.lst_finding: + return + self.intent = ("command", self.lst_finding[table.cursor_row]) + self.exit() + + return DiffApp(data) + + +def run_diff_tui(data, run_app=True): + """Ouvrir l'écran. Renvoie l'intention retenue, ou None. + + ``run_app=False`` construit l'application sans la lancer : c'est ce qui + permet de la tester sans terminal. + """ + app = build_app(data) + if not run_app: + return app + app.run() + return app.intent diff --git a/script/analyse/analyse_schema_size.py b/script/analyse/analyse_schema_size.py new file mode 100644 index 0000000..7c4037a --- /dev/null +++ b/script/analyse/analyse_schema_size.py @@ -0,0 +1,562 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Poids d'une base Odoo, et tables qui ne correspondent à plus rien. + +Ce que l'outil répond : combien pèse cette base, où est le gras, et quelles +tables ne sont réclamées par aucun modèle installé. Ces dernières sont les +reliquats de modules désinstallés sans ``DROP TABLE``, que chaque montée de +version recopie et redéploie sans que personne ne les regarde. + +Trois pièges évités, chacun pour une bonne raison +------------------------------------------------- +**Une table m2m n'a aucune ligne dans ``ir_model``.** Les recenser comme +orphelines ferait crier au loup sur ~200 tables d'une base ordinaire. Odoo +tient leur liste dans ``ir_model_relation``, on la lui demande. + +**Une table introuvable n'est pas une anomalie, c'est une inconnue.** Un +modèle dont le ``_table`` est surchargé — ``ir.actions.act_window`` vit dans +``ir_act_window`` — serait classé « sans table » par un +``replace('.', '_')`` naïf. ``lib_analyse.model_table()`` connaît les +surcharges, et ce qu'il ne résout pas est marqué inconnu, jamais orphelin. + +**Les modèles abstraits SONT dans ``ir_model``.** ``registry.py`` appelle +``_reflect_models()`` sur tous les modèles chargés, sans filtre sur +``_abstract`` ; il n'existe d'ailleurs aucune colonne ``abstract``. Des +centaines de modèles sans table sont donc parfaitement normaux : c'est un +fait rapporté, pas un constat. + +Le comptage de lignes est une estimation, et le dit +--------------------------------------------------- +``reltuples`` vient du dernier ``ANALYZE``. PostgreSQL 14 et suivants y +mettent ``-1`` quand la table n'a jamais été analysée — afficher ``0`` ferait +passer une table pleine pour une table vide. On affiche ``?``. Le compte exact +est derrière ``--exact`` parce qu'il coûte un balayage complet par table. +""" + +import argparse +import json +import os +import sys +import textwrap + +new_path = os.path.normpath( + os.path.join(os.path.dirname(__file__), "..", "..") +) +sys.path.append(new_path) + +from script.analyse.lib_analyse import ( # noqa: E402 + AnalyseError, + backup_version, + column_types, + json_query, + model_table, + normalise_arch, + quote_literal, + read_backup, + require_odoo_database, + scalar_query, + t, + tr_col, +) + +# Tables réelles qui n'appartiennent pas à Odoo : une extension PostgreSQL les +# pose dans le schéma public. Les compter comme orphelines enverrait +# l'utilisateur supprimer une table dont dépend PostGIS. +SYSTEM_TABLES = { + "spatial_ref_sys", # PostGIS +} + +TOP_DEFAULT = 20 + + +def wrap_note(prefix, text, width=79): + """Replier une phrase à la largeur du terminal, sans la découper en clés. + + Une phrase coupée en trois clés de traduction se replie correctement dans + la langue où elle a été écrite, et n'importe comment dans l'autre : l'ordre + des mots et la longueur diffèrent. La phrase reste donc entière côté + traduction, et c'est l'affichage qui la replie. + """ + lines = textwrap.wrap(text, width=width - len(prefix)) or [""] + pad = " " * len(prefix) + return [prefix + lines[0]] + [pad + line for line in lines[1:]] + + +def fmt_bytes(value): + """Taille lisible, en unités binaires — mêmes symboles en fr et en en.""" + if value is None: + return "?" + size = float(value) + for unit in ("B", "KiB", "MiB", "GiB", "TiB"): + if size < 1024 or unit == "TiB": + return ( + f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}" + ) + size /= 1024 + return f"{size:.1f} TiB" + + +def fmt_rows(value): + """Nombre de lignes, ou « ? » si la table n'a jamais été analysée. + + reltuples vaut -1 depuis PostgreSQL 14 quand aucun ANALYZE n'a tourné. + Avant, il valait 0 — indistinguable d'une table vide. On rend « ? » dans + les deux cas plutôt qu'un chiffre auquel personne ne devrait se fier. + """ + if value is None or value < 0: + return "?" + return f"{value:,}".replace(",", " ") + + +def _table_rows(database, **kwargs): + """Une ligne par table réelle du schéma public, avec son poids. + + relkind 'r' pour une table ordinaire, 'p' pour une partitionnée. Odoo n'en + partitionne pas, mais le compte ne doit pas devenir faux en silence le jour + où cela changera. + """ + return json_query( + database, + """ + SELECT c.relname AS table_name, + pg_total_relation_size(c.oid) AS total_bytes, + pg_table_size(c.oid) AS table_bytes, + pg_indexes_size(c.oid) AS index_bytes, + c.reltuples::bigint AS est_rows + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' AND c.relkind IN ('r', 'p') + ORDER BY pg_total_relation_size(c.oid) DESC + """, + **kwargs, + ) + + +def _model_rows(database, **kwargs): + """Modèles déclarés, avec leur description traduite. + + ir_model.name est un champ traduit : jsonb à partir de 16.0, texte avant. + tr_col décide sur le type réel de la colonne. + """ + dct_type = column_types(database, "ir_model", **kwargs) + label = tr_col("ir_model", "name", dct_type) + return json_query( + database, + f""" + SELECT model AS model, + {label} AS description, + state AS state, + transient AS transient + FROM ir_model + ORDER BY model + """, + **kwargs, + ) + + +def _relation_tables(database, **kwargs): + """Tables m2m qu'Odoo revendique, via ir_model_relation. + + Sans elles, toute table de relation passerait pour orpheline. La table peut + manquer sur une base très ancienne : on sonde plutôt que de supposer. + """ + if not scalar_query( + database, "SELECT to_regclass('public.ir_model_relation');", **kwargs + ): + return set(), False + rows = json_query( + database, "SELECT name AS name FROM ir_model_relation", **kwargs + ) + return {r["name"] for r in rows if r.get("name")}, True + + +def _exact_counts(database, lst_table, **kwargs): + """count(*) réel par table — un balayage complet chacune. + + format('%I') met les identifiants entre guillemets côté PostgreSQL : aucun + nom de table venu du catalogue ne peut casser la requête ni en détourner + le sens. + """ + if not lst_table: + return {} + values = ", ".join(f"({quote_literal(name)})" for name in lst_table) + rows = json_query( + database, + f""" + SELECT v.table_name AS table_name, + (xpath('/row/c/text()', + query_to_xml( + format('SELECT count(*) AS c FROM public.%I', + v.table_name), + false, true, '')))[1]::text::bigint AS exact_rows + FROM (VALUES {values}) AS v(table_name) + """, + **kwargs, + ) + return {r["table_name"]: r["exact_rows"] for r in rows} + + +def collect(database, exact=False, config_path=None, timeout=120): + """Tout le travail. Donnée pure, sérialisable, aucun affichage.""" + kwargs = {"config_path": config_path, "timeout": timeout} + require_odoo_database(database, **kwargs) + + db_bytes = scalar_query( + database, + "SELECT pg_database_size(current_database());", + **kwargs, + ) + odoo_version = scalar_query( + database, + "SELECT latest_version FROM ir_module_module WHERE name = 'base';", + **kwargs, + ) + + lst_table = _table_rows(database, **kwargs) + lst_model = _model_rows(database, **kwargs) + set_relation, has_relation_table = _relation_tables(database, **kwargs) + set_table = {row["table_name"] for row in lst_table} + + # Table -> modèle. Une table peut porter plusieurs modèles : les huit + # ir.actions.* partagent ir_actions. On garde le premier par ordre + # alphabétique, pour que deux exécutions disent la même chose. + dct_table_model = {} + lst_without_table = [] + for row in lst_model: + table = model_table(row["model"], known_tables=set_table) + if table is None: + lst_without_table.append( + { + "model": row["model"], + "description": row.get("description"), + "state": row.get("state"), + "transient": row.get("transient"), + } + ) + continue + dct_table_model.setdefault(table, row["model"]) + + lst_orphan = [] + for row in lst_table: + name = row["table_name"] + if name in dct_table_model: + row["model"] = dct_table_model[name] + row["origin"] = "model" + elif name in set_relation: + row["model"] = None + row["origin"] = "m2m" + elif name in SYSTEM_TABLES: + row["model"] = None + row["origin"] = "system" + else: + row["model"] = None + row["origin"] = "orphan" + lst_orphan.append(row) + row["exact_rows"] = None + + if exact: + dct_exact = _exact_counts( + database, [r["table_name"] for r in lst_table], **kwargs + ) + for row in lst_table: + row["exact_rows"] = dct_exact.get(row["table_name"]) + + return { + "tool": "analyse_schema_size", + "version": 1, + "database": database, + "odoo_version": odoo_version, + "db_bytes": int(db_bytes) if db_bytes else None, + "exact": exact, + "has_relation_table": has_relation_table, + "n_tables": len(lst_table), + "n_models": len(lst_model), + "tables": lst_table, + "orphan_tables": lst_orphan, + "models_without_table": lst_without_table, + "counts": { + "orphan_tables": len(lst_orphan), + "models_without_table": len(lst_without_table), + "m2m_tables": sum(1 for r in lst_table if r["origin"] == "m2m"), + }, + } + + +def collect_from_backup(zip_path): + """Même analyse, depuis une sauvegarde .zip, sans rien restaurer. + + Ce qu'une sauvegarde donne EN MIEUX : le nombre de lignes est exact, + compté dans le dump, là où une base rend l'estimation du dernier ANALYZE. + + Ce qu'elle ne peut pas donner : le poids sur le disque. Un dump ignore les + index et le ballonnement, et présenter le poids de ses données comme une + taille de table tromperait sur ce qui fait grossir une base. La colonne + affichée est donc « poids dans le dump », et elle est nommée ainsi. + """ + manifest, dct_rows, _, dct_census = read_backup( + zip_path, + tables=("ir_model", "ir_model_relation", "ir_module_module"), + census=True, + ) + set_table = set(dct_census) + set_relation = { + row.get("name") for row in dct_rows.get("ir_model_relation") or [] + } + has_relation_table = "ir_model_relation" in dct_census + + dct_table_model = {} + lst_without_table = [] + for row in dct_rows["ir_model"]: + table = model_table(row.get("model") or "", known_tables=set_table) + if table is None: + lst_without_table.append( + { + "model": row.get("model"), + "description": normalise_arch(row.get("name")), + "state": row.get("state"), + "transient": row.get("transient") == "t", + } + ) + continue + dct_table_model.setdefault(table, row.get("model")) + + lst_table, lst_orphan = [], [] + for name, census in sorted( + dct_census.items(), key=lambda kv: -kv[1]["dump_bytes"] + ): + row = { + "table_name": name, + # Aucune de ces trois-là ne se lit dans un dump : les laisser à + # None fait afficher « ? », ce qui est la vérité, plutôt qu'un + # zéro qui se lirait comme « cette table est vide ». + "total_bytes": None, + "table_bytes": None, + "index_bytes": None, + "dump_bytes": census["dump_bytes"], + "est_rows": census["rows"], + "exact_rows": census["rows"], + "model": dct_table_model.get(name), + "origin": "model", + } + if name in dct_table_model: + pass + elif name in set_relation: + row["origin"] = "m2m" + elif name in SYSTEM_TABLES: + row["origin"] = "system" + else: + row["origin"] = "orphan" + lst_orphan.append(row) + lst_table.append(row) + + return { + "tool": "analyse_schema_size", + "version": 1, + "database": os.path.basename(zip_path), + "source": "backup", + "backup_path": zip_path, + "odoo_version": backup_version(dct_rows, manifest), + "db_bytes": None, + "dump_bytes": sum(c["dump_bytes"] for c in dct_census.values()), + "exact": True, + "has_relation_table": has_relation_table, + "n_tables": len(lst_table), + "n_models": len(dct_rows["ir_model"]), + "tables": lst_table, + "orphan_tables": lst_orphan, + "models_without_table": lst_without_table, + "counts": { + "orphan_tables": len(lst_orphan), + "models_without_table": len(lst_without_table), + "m2m_tables": sum(1 for r in lst_table if r["origin"] == "m2m"), + }, + } + + +def _table_block(lst_row, exact, source="database"): + """Tableau aligné : une ligne par table, colonnes de largeur fixe. + + « heap » plutôt que « table » pour pg_table_size : la colonne « table » + porte déjà le nom, et le même mot pour deux choses dans le même tableau se + lit mal. Les quatre en-têtes techniques ne passent pas par t() — ils + s'écrivent pareil en français et en anglais, contrairement à « rows ». + """ + if source == "backup": + # Un dump n'a ni index ni ballonnement : afficher trois colonnes vides + # ferait croire à une mesure manquante plutôt qu'à une mesure qui + # n'existe pas. + lines = [f" {'table':<44}{t('in the dump'):>14}{t('rows'):>14}"] + for row in lst_row: + lines.append( + f" {row['table_name']:<44}" + f"{fmt_bytes(row.get('dump_bytes')):>14}" + f"{fmt_rows(row['exact_rows']):>14}" + ) + return lines + lines = [ + f" {'table':<34}{'total':>10}{'heap':>10}{'index':>10}" + f"{t('rows'):>14}" + ] + for row in lst_row: + count = row["exact_rows"] if exact else row["est_rows"] + lines.append( + f" {row['table_name']:<34}" + f"{fmt_bytes(row['total_bytes']):>10}" + f"{fmt_bytes(row['table_bytes']):>10}" + f"{fmt_bytes(row['index_bytes']):>10}" + f"{fmt_rows(count):>14}" + ) + return lines + + +def render(data, verbose=False, top=TOP_DEFAULT, hints=True): + """Rapport texte. Fonction pure : donnée -> chaîne, testable sans base. + + ``hints`` gouverne les conseils en ligne de commande (« utilisez -v », + « --exact »). Ils aident qui a tapé la commande ; ils insultent qui est + dans un menu, à qui l'on demande de sortir et de retaper autre chose. + L'appel depuis le menu les coupe et offre les mêmes actions comme choix. + """ + version = data.get("odoo_version") or "?" + lines = [ + "", + f"🔬 {t('Schema analysis')} — {data['database']} (Odoo {version}" + f"{', ' + t('from a backup') if data.get('source') == 'backup' else ''})", + "", + ( + f" {t('Weight in the dump'):<22}: " + f"{fmt_bytes(data.get('dump_bytes'))}" + if data.get("source") == "backup" + else f" {t('Database size'):<22}: {fmt_bytes(data['db_bytes'])}" + ), + f" {t('Tables'):<22}: {data['n_tables']}", + f" {t('Models'):<22}: {data['n_models']}", + ] + n_without = data["counts"]["models_without_table"] + if n_without: + lines.append( + f" {t('Models without table'):<22}: {n_without}" + f" ({t('abstract models have none, by design')})" + ) + if not data["has_relation_table"]: + lines += wrap_note( + "⚠️ ", + t( + "ir_model_relation is absent, so m2m tables cannot be told" + " apart from orphans: the list below is unreliable." + ), + ) + + lst_table = data["tables"] + shown = lst_table if verbose else lst_table[:top] + if shown: + label = ( + t("All tables, heaviest first") + if verbose + else f"{t('Heaviest tables')} ({len(shown)}/{len(lst_table)})" + ) + lines += ["", f"── {label} ──"] + lines += _table_block( + shown, data["exact"], data.get("source", "database") + ) + if hints and not verbose and len(lst_table) > len(shown): + lines.append(f" … {t('use -v to list them all')}") + elif not verbose and len(lst_table) > len(shown): + lines.append(f" … {len(lst_table) - len(shown)} {t('more')}") + + lst_orphan = data["orphan_tables"] + if not lst_orphan: + lines += ["", f"✅ {t('Every table belongs to an installed model.')}"] + else: + lines += [ + "", + f"── ⚠️ {t('Orphan tables')} ({len(lst_orphan)}) ──", + ] + lines += _table_block( + lst_orphan, data["exact"], data.get("source", "database") + ) + lines.append("") + lines += wrap_note( + " ", + t( + "No installed model claims these tables. They are usually left" + " over from modules uninstalled without DROP TABLE, and every" + " version upgrade carries them along." + ), + ) + lines += wrap_note( + " 💡 ", t("Check what they hold before dropping anything.") + ) + + if not data["exact"] and hints: + lines.append("") + lines += wrap_note( + " ℹ️ ", + t( + "Row counts are estimates from the last ANALYZE. Use --exact" + " for real counts, at the cost of one full scan per table." + ), + ) + return "\n".join(lines) + "\n" + + +def main(argv=None): + parser = argparse.ArgumentParser( + description=t( + "Report the size of an Odoo database and the tables no installed" + " model claims (read-only)." + ) + ) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("-d", "--database", help=t("database to inspect")) + source.add_argument( + "-z", + "--zip", + dest="backup", + help=t("Odoo backup .zip to inspect, without restoring it"), + ) + parser.add_argument( + "--exact", + action="store_true", + help=t("count rows exactly: one full scan per table"), + ) + parser.add_argument( + "--top", + type=int, + default=TOP_DEFAULT, + help=t("how many tables to show (default: 20)"), + ) + parser.add_argument( + "-v", "--verbose", action="store_true", help=t("list every table") + ) + parser.add_argument("--json", action="store_true", help=t("output JSON")) + parser.add_argument( + "-c", "--config", default=None, help=t("path to an Odoo config file") + ) + config = parser.parse_args(argv) + + try: + if config.backup: + data = collect_from_backup(config.backup) + else: + data = collect( + config.database, exact=config.exact, config_path=config.config + ) + except AnalyseError as exc: + print(f"❌ {exc}") + return 2 + except KeyboardInterrupt: + print(f"\n{t('Cancelled.')}") + return 2 + + if config.json: + print(json.dumps(data, indent=2, ensure_ascii=False)) + else: + print(render(data, verbose=config.verbose, top=config.top)) + return 1 if data["orphan_tables"] else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/analyse/analyse_view_custom.py b/script/analyse/analyse_view_custom.py new file mode 100644 index 0000000..fe31310 --- /dev/null +++ b/script/analyse/analyse_view_custom.py @@ -0,0 +1,858 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Inventaire des vues personnalisées d'une base Odoo, copies COW comprises. + +Ce que l'outil répond : parmi les milliers de vues d'une base, lesquelles ne +viennent pas telles quelles d'un module. Ce sont elles qu'une montée de version +peut casser, et elles seules qu'un intégrateur doit relire. + +Ce qu'il ne répond PAS encore +----------------------------- +Il ne compare pas l'arch en base à celle que déclare le module. Une vue portant +le drapeau ``arch_updated`` est donc dite **signalée**, pas **modifiée** : le +drapeau vient d'Odoo, mais il est incomplet dans les deux sens — un ``write`` +SQL direct ne l'arme pas, et ``reset_arch(mode='hard')`` l'efface. Conclure +demande de comparer, ce que fera l'outil suivant. Nommer « modifiée » ce qui +n'est que « signalée » serait une affirmation que rien ici ne soutient. + +Les copies COW, et ce qui les distingue de l'outillage existant +--------------------------------------------------------------- +Personnaliser une vue de site web ne la modifie pas : Odoo en fait une copie +liée à un ``website_id``. Quatre outils du dépôt s'en occupent déjà, chacun +pour une question de MIGRATION — ``check_cow_views.py`` prédit lesquelles +casseront à la version suivante, ``reset_stale_cow_views.py`` trouve celles qui +ont dérivé de leur jumelle et sait les réinitialiser, ``neutralize_cow_views.py`` +les met hors circuit, ``snapshot_cow_views.py`` compare un avant et un après. + +Aucun ne fait l'inventaire, et c'est le trou que celui-ci comble : combien de +vues sont personnalisées, par quel chemin, et lesquelles méritent un regard. +Il ne rejuge donc pas les copies COW — il les compte, dit si chacune a une +jumelle module, et renvoie vers l'outil qui tranche. + +Une vue, une seule catégorie +---------------------------- +Une copie COW peut aussi porter ``arch_updated`` ; une vue Studio peut être une +copie COW. Les classer plusieurs fois ferait un total supérieur au nombre de +vues, et un rapport dont les chiffres ne s'additionnent pas ne se lit pas. La +catégorie retenue est donc la plus spécifique, dans l'ordre de ``CATEGORIES``, +et le reste de ce qu'on sait vit dans ``reason``. +""" + +import argparse +import json +import os +import sys +import textwrap + +new_path = os.path.normpath( + os.path.join(os.path.dirname(__file__), "..", "..") +) +sys.path.append(new_path) + +from script.analyse.lib_analyse import ( # noqa: E402 + REPO_ROOT, + AnalyseError, + arch_differs, + backup_version, + column_types, + diff_stats, + existing_columns, + json_query, + normalise_arch, + odoo_shell_json, + read_backup, + require_odoo_database, + scalar_query, + side_by_side, + t, + tr_col, +) + +# Le script poussé dans « odoo-bin shell » pour obtenir l'arch de référence. +SHELL_SCRIPT = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "shell", "view_file_arch.py" +) + +# Le registre se charge par lots : la ligne de commande et la sortie restent +# bornées, et une vue cassée n'emporte que son lot. +BATCH = 100 + +# Modules d'identifiants externes qui ne sont pas des modules : Odoo y range ce +# qui vient d'un import, d'un export, ou de Studio. +STUDIO_MODULE = "studio_customization" +NOT_A_MODULE = ("__export__", "__import__", "__custom__") + +# L'ordre EST la précédence : la première catégorie qui s'applique gagne. Du +# plus spécifique au plus général, pour qu'une vue Studio copiée par le site web +# soit comptée comme copie COW — c'est ce qu'un intégrateur ira regarder en +# premier — et non comme une vue Studio de plus. +CATEGORIES = ( + "theme_installed", + "website_cow_copy", + "studio", + "imported_or_exported", + "ui_created", + "module_view_drifted", + "module_view_flagged", + "module_view", +) + +# Les catégories qui demandent un regard. « module_view » n'y est pas : une vue +# qui vient d'un module et que rien ne signale est le cas normal, et il compte +# pour l'écrasante majorité. +ACTIONABLE = ( + "module_view_drifted", + "module_view_flagged", + "ui_created", + "studio", + "imported_or_exported", + "website_cow_copy", + "theme_installed", +) + +TOP_DEFAULT = 20 + + +def wrap_note(prefix, text, width=79): + """Replier une phrase à l'affichage, sans la découper en clés.""" + lines = textwrap.wrap(text, width=width - len(prefix)) or [""] + pad = " " * len(prefix) + return [prefix + lines[0]] + [pad + line for line in lines[1:]] + + +def category_label(name): + """Libellé traduit d'une catégorie. + + Un `t(variable)` serait plus court, mais il rendrait le contrôle de + couverture aveugle : celui-ci relit les sources et ne voit que les appels + à littéral. Une clé manquante repasserait alors en silence, en anglais. + Les catégories sont donc épelées, une par une. + """ + return { + "theme_installed": t("From an installed theme"), + "website_cow_copy": t("Website copy (COW)"), + "studio": t("Made with Studio"), + "imported_or_exported": t("Imported or exported"), + "ui_created": t("Created from the interface"), + "module_view_drifted": t("From a module, silently drifted"), + "module_view_flagged": t("From a module, flagged as touched"), + "module_view": t("Straight from a module"), + }.get(name, name) + + +def classify(row): + """(catégorie, raisons) d'une vue. Fonction pure, testable sur fixture. + + ``raisons`` porte tout ce qu'on sait et que la catégorie ne dit pas : une + copie COW qui est aussi signalée le mentionne, sinon l'information se + perdrait au profit de la seule catégorie retenue. + """ + lst_module = row.get("xmlid_modules") or [] + has_xmlid = bool(lst_module) + lst_reason = [] + + if row.get("arch_updated"): + lst_reason.append("arch_updated") + if row.get("noupdate"): + lst_reason.append("noupdate") + if row.get("has_arch_prev"): + lst_reason.append("has_arch_prev") + if not row.get("active"): + lst_reason.append("inactive") + + if row.get("theme_template_id"): + return "theme_installed", lst_reason + if row.get("website_id"): + if not row.get("has_module_twin"): + lst_reason.append("no_module_twin") + return "website_cow_copy", lst_reason + if STUDIO_MODULE in lst_module: + return "studio", lst_reason + if any(module in NOT_A_MODULE for module in lst_module): + return "imported_or_exported", lst_reason + if not has_xmlid and not row.get("arch_fs"): + return "ui_created", lst_reason + # `arch_updated` SEUL fait basculer une vue de module. `noupdate` reste une + # raison, jamais un motif : toute vue déclarée dans un bloc + # le porte — les données de mail, d'account, de website + # en sont pleines — et rien n'y a été touché. L'y inclure noierait la + # catégorie qui compte sous des centaines de vues parfaitement normales. + if row.get("arch_fs") and row.get("arch_updated"): + return "module_view_flagged", lst_reason + return "module_view", lst_reason + + +def _view_rows(database, **kwargs): + """Une ligne par vue, sans son arch. + + L'arch n'est pas rapatriée : quelques milliers de vues dont certaines + dépassent 100 ko tiendraient dans une seule ligne de sortie psql, dupliquée + par json.loads. La taille et l'empreinte suffisent à cet inventaire ; la + comparaison, qui a besoin du contenu, ira le chercher pour les seules vues + retenues. + + Les identifiants externes sont AGRÉGÉS. Une jointure plate multiplierait + les lignes d'une vue qui en porte plusieurs, et un « premier trouvé » + déciderait au hasard si elle vient de Studio. + """ + cols = existing_columns(database, "ir_ui_view", **kwargs) + dct_type = column_types(database, "ir_ui_view", **kwargs) + + def col(column, absent): + """« v.colonne » si elle existe, sinon un littéral du bon type. + + Toutes les colonnes passent par ici, y compris celles qu'on croit + acquises comme create_uid : sur une base rognée ou anonymisée, une + seule colonne manquante fait échouer la requête entière, et l'outil + rendrait 2 là où il pouvait encore répondre. + """ + return f"v.{column}" if column in cols else absent + + name = tr_col("v", "name", dct_type) + has_website_col = "website_id" in cols + website = col("website_id", "NULL::integer") + # La CTE « twin » n'a pas l'alias « v » : il lui faut la colonne nue. Sans + # le module website, il n'y a aucune copie COW et toute vue à clé est sa + # propre référence — d'où le « TRUE ». + twin_filter = "website_id IS NULL" if has_website_col else "TRUE" + theme = col("theme_template_id", "NULL::integer") + arch_fs = col("arch_fs", "NULL::text") + arch_updated = col("arch_updated", "false") + arch_prev = "(v.arch_prev IS NOT NULL)" if "arch_prev" in cols else "false" + return json_query( + database, + f""" + WITH xid AS ( + SELECT res_id, + array_agg(DISTINCT module) AS modules, + array_agg(module || '.' || name ORDER BY module, name) + AS xmlids, + bool_or(noupdate) AS noupdate + FROM ir_model_data + WHERE model = 'ir.ui.view' + GROUP BY res_id + ), twin AS ( + SELECT DISTINCT key FROM ir_ui_view + WHERE key IS NOT NULL AND {twin_filter} + ) + SELECT v.id AS id, + {name} AS name, + {col("model", "NULL::text")} AS model, + {col("type", "NULL::text")} AS type, + {col("key", "NULL::text")} AS key, + {col("mode", "NULL::text")} AS mode, + {col("active", "true")} AS active, + {col("inherit_id", "NULL::integer")} AS inherit_id, + {arch_fs} AS arch_fs, + {arch_updated} AS arch_updated, + {arch_prev} AS has_arch_prev, + {website} AS website_id, + {theme} AS theme_template_id, + x.modules AS xmlid_modules, + x.xmlids AS xmlids, + COALESCE(x.noupdate, false) AS noupdate, + (v.key IS NOT NULL + AND EXISTS (SELECT 1 FROM twin WHERE twin.key = v.key)) + AS has_module_twin, + {col("create_uid", "NULL::integer")} AS create_uid, + {col("create_date", "NULL::timestamp")} AS create_date, + {col("write_uid", "NULL::integer")} AS write_uid, + {col("write_date", "NULL::timestamp")} AS write_date, + octet_length(v.arch_db::text) AS arch_bytes, + md5(v.arch_db::text) AS arch_md5 + FROM ir_ui_view v + LEFT JOIN xid x ON x.res_id = v.id + ORDER BY v.id + """, + **kwargs, + ) + + +def checkout_odoo_version(): + """Version Odoo de l'ARBRE SOURCE, qui n'est pas celle de la base.""" + try: + with open(os.path.join(REPO_ROOT, ".odoo-version")) as handle: + return handle.read().strip() + except OSError: + return None + + +def same_major(version_a, version_b): + """Deux versions ont-elles la même majeure ? « 18.0.1.3 » vs « 18.0 ».""" + if not version_a or not version_b: + return False + return version_a.split(".")[0] == version_b.split(".")[0] + + +def add_reference_arch(database, lst_finding, config_path=None, timeout=600): + """Compléter les constats avec l'arch que déclare le module. + + Ne s'adresse qu'aux vues qui ont un ``arch_fs`` : les autres n'ont aucune + contrepartie dans les sources, il n'y a rien à comparer. + + Renvoie ``(source, erreur)`` — « orm » si le registre a répondu, « none » + sinon, avec le message. Jamais d'échec silencieux : une comparaison qui n'a + pas eu lieu ne doit pas se lire comme une comparaison sans écart. + """ + lst_todo = [row for row in lst_finding if row.get("arch_fs")] + if not lst_todo: + return "none", None + + dct_by_id = {row["id"]: row for row in lst_todo} + lst_id = sorted(dct_by_id) + try: + for start in range(0, len(lst_id), BATCH): + chunk = lst_id[start : start + BATCH] + for answer in odoo_shell_json( + database, + SHELL_SCRIPT, + env={"VIEW_IDS": ",".join(str(i) for i in chunk)}, + timeout=timeout, + config_path=config_path, + ): + row = dct_by_id.get(answer["id"]) + if row is None: + continue + row["arch_ref"] = answer.get("arch_file") + row["arch_db_text"] = answer.get("arch_db") + row["arch_ref_error"] = answer.get("error") + except AnalyseError as exc: + return "none", str(exc) + + for row in lst_todo: + differs, comparable = arch_differs( + row.get("arch_ref"), row.get("arch_db_text") + ) + row["comparable"] = comparable + row["differs"] = differs + if comparable: + row["diff_stats"] = diff_stats( + side_by_side(row["arch_ref"], row["arch_db_text"]) + ) + return "orm", None + + +def collect( + database, + with_diff=False, + scope="flagged", + config_path=None, + timeout=120, + shell_timeout=600, +): + """Tout le travail. Donnée pure, sérialisable, aucun affichage.""" + kwargs = {"config_path": config_path, "timeout": timeout} + require_odoo_database(database, **kwargs) + odoo_version = scalar_query( + database, + "SELECT latest_version FROM ir_module_module WHERE name = 'base';", + **kwargs, + ) + has_website = bool( + scalar_query( + database, + "SELECT 1 FROM ir_module_module" + " WHERE name = 'website' AND state = 'installed';", + **kwargs, + ) + ) + + lst_view = _view_rows(database, **kwargs) + dct_count = {name: 0 for name in CATEGORIES} + lst_finding = [] + for row in lst_view: + category, lst_reason = classify(row) + row["category"] = category + row["reason"] = lst_reason + dct_count[category] += 1 + if category in ACTIONABLE: + lst_finding.append(row) + + arch_ref_source, arch_ref_error = "none", None + checkout = checkout_odoo_version() + if with_diff: + if not same_major(odoo_version, checkout): + # Le shell charge l'arbre du checkout, pas celui de la base : sur + # une base 13.0 avec un checkout 18.0 le registre ne chargera pas. + # Le dire en une seconde vaut mieux que trente secondes de + # chargement pour aboutir à la même conclusion. + arch_ref_error = ( + f"{t('Database is Odoo')} {odoo_version}," + f" {t('checkout is')} {checkout}" + ) + else: + # « flagged » ne compare que ce qui porte déjà un signe. C'est + # rapide, et aveugle au cas même que les drapeaux ratent : une vue + # réécrite en SQL direct n'arme pas arch_updated. « all » compare + # toute vue ayant un arch_fs et voit cette dérive silencieuse. + # « flagged » ne compare que ce qui porte déjà un signe : rapide, + # et ce qu'il rapporte est fiable. « all » compare toute vue ayant + # un arch_fs, ce qui trouve la dérive qu'aucun drapeau ne signale — + # une vue réécrite en SQL direct — mais au prix d'un plancher de + # bruit MESURÉ : sur une base 18.0 fraîchement installée, 160 des + # 974 vues à arch_fs diffèrent déjà. read_arch_from_file rend le + # XML brut du fichier, alors que la base porte l'arch APRÈS + # traitement au chargement : un attribut « groups » est consommé, + # un est appliqué. En « all », un + # écart est une piste, pas un verdict. + lst_candidate = ( + lst_finding + if scope == "flagged" + else [row for row in lst_view if row.get("arch_fs")] + ) + arch_ref_source, arch_ref_error = add_reference_arch( + database, + lst_candidate, + config_path=config_path, + timeout=shell_timeout, + ) + + # Une vue signalée dont la forme canonique égale celle du module n'a rien + # de modifié : le drapeau disait vrai sur « touchée », faux sur « autre ». + # C'est tout l'intérêt de comparer, alors elle quitte les constats. + n_identical = 0 + if arch_ref_source == "orm": + if scope != "flagged": + # Une vue sans drapeau dont l'arch diffère de son module a été + # réécrite sans passer par Odoo. C'est le constat que seule la + # comparaison peut produire, et le plus intéressant du lot. + known = {row["id"] for row in lst_finding} + for row in lst_view: + if row["id"] in known or not row.get("differs"): + continue + dct_count[row["category"]] -= 1 + row["category"] = "module_view_drifted" + row["reason"] = row["reason"] + ["differs_from_module"] + dct_count["module_view_drifted"] += 1 + lst_finding.append(row) + lst_kept = [] + for row in lst_finding: + if ( + row["category"] == "module_view_flagged" + and row.get("comparable") + and row.get("differs") is False + ): + row["category"] = "module_view" + row["reason"] = row["reason"] + ["identical_after_canonical"] + dct_count["module_view_flagged"] -= 1 + dct_count["module_view"] += 1 + n_identical += 1 + continue + lst_kept.append(row) + lst_finding = lst_kept + + return { + "tool": "analyse_view_custom", + "version": 1, + "database": database, + "odoo_version": odoo_version, + "checkout_version": checkout, + "has_website": has_website, + "compared_with_module_source": arch_ref_source == "orm", + "arch_ref_source": arch_ref_source, + "scope": scope, + "arch_ref_error": arch_ref_error, + "n_identical_after_canonical": n_identical, + "n_views": len(lst_view), + "counts": dct_count, + "findings": lst_finding, + } + + +def collect_from_backup(zip_path): + """Même inventaire, depuis une sauvegarde .zip, sans rien restaurer. + + Le classement est identique : il ne dépend que de colonnes que le dump + porte toutes. Ce qui manque est la COMPARAISON — l'arch de référence vient + de `read_arch_from_file`, donc d'un registre Odoo chargé, et un zip n'en a + pas. Le rapport le dit plutôt que de laisser croire à une absence d'écart. + """ + manifest, dct_rows, _, _ = read_backup( + zip_path, + tables=("ir_ui_view", "ir_model_data", "ir_module_module"), + ) + + dct_xmlid, dct_noupdate = {}, {} + for row in dct_rows["ir_model_data"]: + if row.get("model") != "ir.ui.view": + continue + res_id = row.get("res_id") + dct_xmlid.setdefault(res_id, set()).add(row.get("module")) + if row.get("noupdate") == "t": + dct_noupdate[res_id] = True + + # Une copie COW a une jumelle si une AUTRE vue, sans website_id, porte la + # même clé. Le même appariement que fait la CTE « twin » côté SQL. + set_twin = { + row.get("key") + for row in dct_rows["ir_ui_view"] + if row.get("key") and row.get("website_id") in (None, "") + } + + lst_view = [] + for row in dct_rows["ir_ui_view"]: + res_id = row.get("id") + website = row.get("website_id") + lst_view.append( + { + "id": int(res_id) if (res_id or "").isdigit() else res_id, + "name": normalise_arch(row.get("name")), + "model": row.get("model"), + "type": row.get("type"), + "key": row.get("key"), + "mode": row.get("mode"), + "active": row.get("active") != "f", + "arch_fs": row.get("arch_fs"), + "arch_updated": row.get("arch_updated") == "t", + "has_arch_prev": bool(row.get("arch_prev")), + "website_id": website if website not in (None, "") else None, + "theme_template_id": row.get("theme_template_id") or None, + "xmlid_modules": sorted(dct_xmlid.get(res_id) or []), + "noupdate": dct_noupdate.get(res_id, False), + "has_module_twin": bool( + row.get("key") and row.get("key") in set_twin + ), + "arch_bytes": len(row.get("arch_db") or ""), + } + ) + + dct_count = {name: 0 for name in CATEGORIES} + lst_finding = [] + for row in lst_view: + category, lst_reason = classify(row) + row["category"] = category + row["reason"] = lst_reason + dct_count[category] += 1 + if category in ACTIONABLE: + lst_finding.append(row) + + return { + "tool": "analyse_view_custom", + "version": 1, + "database": os.path.basename(zip_path), + "source": "backup", + "backup_path": zip_path, + "odoo_version": backup_version(dct_rows, manifest), + "checkout_version": checkout_odoo_version(), + "has_website": any(r.get("website_id") for r in lst_view), + "compared_with_module_source": False, + "arch_ref_source": "none", + "arch_ref_error": None, + "from_backup_no_registry": True, + "n_identical_after_canonical": 0, + "scope": "flagged", + "n_views": len(lst_view), + "counts": dct_count, + "findings": lst_finding, + } + + +def _finding_block(lst_row, top, hints=True): + """Une ligne par vue : clé, identifiant externe, poids, raisons.""" + lines = [ + f" {'id':>6} {'key / xml-id':<44}{'size':>9} {t('why')}", + ] + for row in lst_row[:top]: + label = row.get("key") or (row.get("xmlids") or [""])[0] or "—" + size = row.get("arch_bytes") + lines.append( + f" {row['id']:>6} {label[:44]:<44}" + f"{(str(size) + ' B') if size else '?':>9} " + f"{', '.join(row.get('reason') or []) or '—'}" + ) + if len(lst_row) > top: + lines.append(f" … {len(lst_row) - top} {t('more')}") + return lines + + +def render(data, verbose=False, top=TOP_DEFAULT, category=None, hints=True): + """Rapport texte. Fonction pure : donnée -> chaîne, testable sans base.""" + version = data.get("odoo_version") or "?" + counts = data["counts"] + lines = [ + "", + f"🔬 {t('Customised views')} — {data['database']} (Odoo {version}" + f"{', ' + t('from a backup') if data.get('source') == 'backup' else ''})", + "", + f" {t("Views"):<38}: {data['n_views']}", + ] + for name in CATEGORIES: + if counts.get(name): + lines.append(f" {category_label(name):<38}: {counts[name]}") + + n_finding = len(data["findings"]) + if not n_finding: + lines += [ + "", + f"✅ {t('Every view comes straight from a module.')}", + ] + return "\n".join(lines) + "\n" + + lst_show = data["findings"] + if category: + lst_show = [r for r in lst_show if r["category"] == category] + lines += [ + "", + f"── ⚠️ {t('Views that did not come straight from a module')}" + f" ({len(lst_show)}) ──", + ] + lines += _finding_block(lst_show, len(lst_show) if verbose else top) + + if counts.get("website_cow_copy"): + lines.append("") + lines += wrap_note( + " ", + t( + "Website copies are user data: Odoo copies a view instead of" + " editing it. Whether they will survive the next version is" + " another question, and these tools answer it:" + ), + ) + lines += [ + " ./script/odoo/migration/check_cow_views.py" + " -d DB -t odooXX.0", + " ./script/odoo/migration/reset_stale_cow_views.py -d DB", + ] + + lines.append("") + if data.get("compared_with_module_source"): + if data.get("scope") == "all": + lines += wrap_note( + " ⚠️ ", + t( + "In --scope all, a difference is a lead, not a verdict:" + " read_arch_from_file returns the raw file, while the" + " database holds the arch AFTER load-time processing." + " Measured on a freshly installed 18.0 database, 160 of" + " its 974 views already differ this way." + ), + ) + lines.append("") + if data.get("n_identical_after_canonical"): + lines += wrap_note( + " ✅ ", + f"{data['n_identical_after_canonical']} " + + t( + "views were flagged but hold exactly what their module" + " declares: only the comparison could tell." + ), + ) + lines += wrap_note( + " 💡 ", + t( + "To restore a view to what its module declares — this WRITES" + " to the database, so read the difference first:" + ), + ) + lines.append( + " echo \"env['ir.ui.view'].browse(ID).reset_arch('hard');" + ' env.cr.commit()" \\' + ) + lines.append( + f" | ./odoo_bin.sh shell -c ./config.conf" + f" -d {data['database']}" + ) + elif data.get("from_backup_no_registry"): + lines += wrap_note( + " ℹ️ ", + t( + "A backup holds no registry, so nothing was compared with the" + " module source. The classification above needs none; only the" + " differences do. Restore it, or run this on the database." + ), + ) + elif data.get("arch_ref_error"): + lines += wrap_note( + " ⚠️ ", + t("No reference arch, so nothing was compared: ") + + str(data["arch_ref_error"]), + ) + elif hints: + lines += wrap_note( + " ℹ️ ", + t( + "Flags say a view was touched, not how. They are incomplete" + " both ways: a direct SQL write does not set arch_updated," + " and reset_arch clears it. Comparing with the module source" + " is what settles it — add --diff." + ), + ) + else: + lines += wrap_note( + " ℹ️ ", + t( + "Flags say a view was touched, not how: only comparing with" + " the module source settles it." + ), + ) + return "\n".join(lines) + "\n" + + +def render_diff(row, width=78): + """Le diff d'une vue, côte à côte, pour la sortie texte. + + Calculé sur les arch BRUTES, pas sur les formes canoniques : la forme + canonique sert à décider s'il y a un écart, elle ne se relit pas. + """ + lines = [ + "", + f"── id={row['id']} {row.get('key') or '—'} " + f"({row.get('arch_fs') or '—'}) ──", + ] + half = (width - 4) // 2 + for mark, left, right in side_by_side( + row.get("arch_ref"), row.get("arch_db_text") + ): + if mark == " ": + continue + lines.append( + f" {mark} {(left or '')[:half]:<{half}} │ {(right or '')[:half]}" + ) + return lines + + +def open_tui(data): + """Ouvrir l'écran de navigation. False si on n'a pas pu — l'appelant imprime. + + Trois refus, trois raisons distinctes, et aucune n'est une panne : + rien à montrer, pas de terminal, ou Textual absent. Chacune se dit, plutôt + que d'ouvrir un écran vide ou de laisser des codes d'échappement dans un + fichier de sortie. + """ + lst_diff = [row for row in data["findings"] if row.get("differs")] + if not lst_diff: + 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 + + from script.analyse.analyse_diff_tui import run_diff_tui + + intent = run_diff_tui(data) + if intent and intent[0] == "command": + row = intent[1] + print(f"\n💡 {t('To restore this view to what its module declares:')}") + print( + f" echo \"env['ir.ui.view'].browse({row['id']})" + f".reset_arch('hard'); env.cr.commit()\" \\" + ) + print( + f" | ./odoo_bin.sh shell -c ./config.conf" + f" -d {data['database']}" + ) + return True + + +def main(argv=None): + parser = argparse.ArgumentParser( + description=t( + "List the views of an Odoo database that did not come straight" + " from a module, website copies included (read-only)." + ) + ) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("-d", "--database", help=t("database to inspect")) + source.add_argument( + "-z", + "--zip", + dest="backup", + help=t("Odoo backup .zip to inspect, without restoring it"), + ) + parser.add_argument( + "--category", + choices=CATEGORIES, + default=None, + help=t("only show this category"), + ) + parser.add_argument( + "--top", + type=int, + default=TOP_DEFAULT, + help=t("how many views to show (default: 20)"), + ) + parser.add_argument( + "-v", "--verbose", action="store_true", help=t("list every view") + ) + parser.add_argument( + "--diff", + action="store_true", + help=t("compare with the module source (opens an Odoo shell)"), + ) + parser.add_argument( + "--scope", + choices=("flagged", "all"), + default="flagged", + help=t("which views to compare (default: flagged)"), + ) + parser.add_argument( + "--strict", + action="store_true", + help=t("fail if the comparison could not be made"), + ) + parser.add_argument( + "--tui", + action="store_true", + help=t("browse the differences in a full-screen view"), + ) + parser.add_argument("--json", action="store_true", help=t("output JSON")) + parser.add_argument( + "-c", "--config", default=None, help=t("path to an Odoo config file") + ) + config = parser.parse_args(argv) + + try: + if config.backup: + data = collect_from_backup(config.backup) + else: + data = collect( + config.database, + with_diff=config.diff or config.tui, + scope=config.scope, + config_path=config.config, + ) + except AnalyseError as exc: + print(f"❌ {exc}") + return 2 + except KeyboardInterrupt: + print(f"\n{t('Cancelled.')}") + return 2 + + if config.strict and not data["compared_with_module_source"]: + print( + f"❌ {t('No reference arch, so nothing was compared: ')}" + f"{data.get('arch_ref_error') or ''}" + ) + return 2 + + if config.json: + print(json.dumps(data, indent=2, ensure_ascii=False, default=str)) + return 1 if data["findings"] else 0 + + if config.tui and open_tui(data): + return 1 if data["findings"] else 0 + + print( + render( + data, + verbose=config.verbose, + top=config.top, + category=config.category, + ) + ) + if config.verbose and data["compared_with_module_source"]: + for row in data["findings"]: + if row.get("differs"): + print("\n".join(render_diff(row))) + return 1 if data["findings"] else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/analyse/lib_analyse.py b/script/analyse/lib_analyse.py new file mode 100644 index 0000000..fb2c511 --- /dev/null +++ b/script/analyse/lib_analyse.py @@ -0,0 +1,872 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Socle commun des outils d'analyse d'une base Odoo, en lecture seule. + +Pourquoi psql en sous-processus plutôt que psycopg2 +--------------------------------------------------- +La raison est déjà écrite dans le dépôt, dans ``reset_stale_cow_views.py`` : +« Plain psql on purpose: this runs on databases whose Odoo registry does not +load, which is precisely when it is needed ». Une base 12.0 sur un checkout +18.0 ne charge pas son registre, et c'est exactement le moment où on veut +l'analyser. Accessoirement, psycopg2 n'est pas dans ``.venv.erplibre``, qui est +l'interpréteur de ces outils. + +La lecture seule est une garantie, pas une promesse +-------------------------------------------------- +``PGOPTIONS`` porte ``default_transaction_read_only=on`` : c'est **le serveur** +qui refuse toute écriture, pour toutes les transactions de la connexion. Un +``SET`` glissé dans le même ``-c`` ne suffirait pas — ``psql -c`` ouvre une +transaction implicite unique, et ``default_transaction_read_only`` ne vaut que +pour les transactions *suivantes*. + +Ne jamais deviner la forme du schéma +------------------------------------ +Douze versions d'Odoo se partagent ces tables. Les colonnes apparaissent, +changent de type (``text`` puis ``jsonb`` à partir de 16.0), ou n'existent que +si un module est installé. D'où ``existing_columns()`` et ``tr_col()`` : on +sonde avant d'écrire une requête, on ne date pas les colonnes de mémoire. + +Les sondes lisent ``pg_attribute``, pas ``information_schema`` : +``information_schema`` est filtré par les droits. Avec un rôle non +propriétaire, elle renverrait un ensemble vide, et l'analyse concluerait +« aucune colonne website, donc aucune vue COW » sans le moindre avertissement. +""" + +import configparser +import json +import os +import re +import subprocess +import sys + +new_path = os.path.normpath( + os.path.join(os.path.dirname(__file__), "..", "..") +) +sys.path.append(new_path) + +try: + from script.todo.todo_i18n import t +except Exception: # pragma: no cover - repli si i18n indisponible + + def t(key: str) -> str: + return key + + +REPO_ROOT = new_path + +# Une valeur littérale « False » dans config.conf veut dire « non défini » : +# c'est ainsi qu'Odoo écrit l'absence de valeur dans son fichier de config. +CONFIG_UNSET = ("false", "none", "") + +# Un nom de base voyage jusqu'à une commande shell (`odoo_bin.sh shell -d …`, +# lancée avec shell=True par execute.py). On le valide au lieu de compter sur +# l'échappement : la liste de caractères qu'une base Odoo utilise réellement +# est courte, et tout le reste est soit une erreur de frappe, soit une +# injection. +RE_DATABASE_NAME = re.compile(r"[A-Za-z0-9_.-]+") + +# Modèles dont la table N'EST PAS `_name.replace('.', '_')`. +# +# Dérivé des sources, pas écrit de mémoire : parcours AST de tous les `.py` de +# `odoo18.0/` et `addons/` (27 843 fichiers), en gardant les classes dont le +# `_table` diffère du défaut. Beaucoup de modules déclarent un `_table` égal au +# défaut — ce sont des déclarations sans effet, à ne pas confondre avec une +# surcharge. +# +# Sans cette table, `replace('.', '_')` échoue précisément sur les modèles les +# plus fréquents dans `ir_model_data` : `ir.actions.act_window` chercherait +# `ir_actions_act_window`, qui n'existe pas. +# +# La liste n'est pas la vérité, seulement ce qu'on sait : un modèle absent +# d'ici et dont la table est introuvable est classé « table inconnue » (un +# fait), jamais « table orpheline » (une anomalie). Pour la régénérer, refaire +# le parcours AST sur l'arbre courant. +MODEL_TABLE_OVERRIDE = { + "ir.actions.act_multi": "ir_actions", + "ir.actions.act_url": "ir_act_url", + "ir.actions.act_window": "ir_act_window", + "ir.actions.act_window.message": "ir_actions", + "ir.actions.act_window.view": "ir_act_window_view", + "ir.actions.act_window_close": "ir_actions", + "ir.actions.actions": "ir_actions", + "ir.actions.client": "ir_act_client", + "ir.actions.report": "ir_act_report_xml", + "ir.actions.server": "ir_act_server", + "project.task.stage.personal": "project_task_user_rel", + # `ir.actions.report.xml` est le nom d'avant 11.0 : les lignes + # `ir_model_data` d'une base ancienne le portent encore. + "ir.actions.report.xml": "ir_act_report_xml", +} + + +class AnalyseError(Exception): + """Échec de l'outil, pas un constat d'analyse. + + Distinction qui porte le code de retour : 2 pour « je n'ai pas pu + analyser », réservé à cette exception ; 1 pour « j'ai analysé et j'ai + trouvé des constats ». Les confondre rendrait une analyse en échec + indistinguable d'une base à problèmes. + """ + + +def valid_database_name(name): + """Le nom est-il un nom de base plausible, sûr à mettre dans une commande ?""" + return bool(name) and RE_DATABASE_NAME.fullmatch(name) is not None + + +def read_config(config_path=None): + """Lire config.conf, en repliant sur /etc/odoo/odoo.conf comme run.sh. + + Renvoie un dict des options, vide si aucun fichier n'est trouvé — l'absence + de config n'est pas une erreur : sur une installation native, psql se + connecte très bien par le socket unix sans aucun paramètre. + """ + lst_candidate = ( + [config_path] + if config_path + else [ + os.path.join(REPO_ROOT, "config.conf"), + "/etc/odoo/odoo.conf", + ] + ) + for path in lst_candidate: + if path and os.path.isfile(path): + parser = configparser.RawConfigParser() + try: + parser.read(path) + except configparser.Error: + continue + if parser.has_section("options"): + return dict(parser.items("options")) + return {} + + +def pg_env(config_path=None, timeout=120, overrides=None): + """Variables d'environnement pour psql : connexion + lecture seule. + + Les paramètres viennent de config.conf, pas d'une hypothèse « socket unix + et rôle = utilisateur système » : le dépôt lui-même livre + ``db_user = erplibre`` et un docker-compose.yml avec un mot de passe. + + ``PGOPTIONS`` est ce qui rend l'analyse incapable d'écrire, et borne la + durée d'une requête — un scan qui part en vrille ne bloque pas un menu. + """ + config = read_config(config_path) + my_env = os.environ.copy() + + dct_map = { + "db_host": "PGHOST", + "db_port": "PGPORT", + "db_user": "PGUSER", + "db_password": "PGPASSWORD", + "db_sslmode": "PGSSLMODE", + } + for key, var in dct_map.items(): + value = str(config.get(key, "")).strip() + if value.lower() not in CONFIG_UNSET: + my_env[var] = value + for var, value in (overrides or {}).items(): + if value: + my_env[var] = str(value) + + my_env["PGOPTIONS"] = ( + f"-c default_transaction_read_only=on -c statement_timeout={timeout}s" + ) + # Un ~/.psqlrc avec \timing ou \pset ajoute des lignes à la sortie et casse + # le parsing. -X l'ignore, mais PSQLRC vide protège aussi les appels qui + # oublieraient -X. + my_env["PSQLRC"] = "" + return my_env + + +def run_psql(database, sql, timeout=120, config_path=None, overrides=None): + """Exécuter du SQL et rendre la sortie brute, une ligne par enregistrement. + + ``-X`` ignore ~/.psqlrc, ``-w`` interdit l'invite de mot de passe (sans + lui, un mot de passe manquant bloque le menu TODO sans rien afficher), + ``ON_ERROR_STOP=1`` fait échouer au premier problème plutôt que de rendre + une sortie partielle qu'on prendrait pour un résultat. + """ + if not valid_database_name(database): + raise AnalyseError(f"{t('Invalid database name: ')}{database!r}") + cmd = [ + "psql", + "-X", + "-w", + "-v", + "ON_ERROR_STOP=1", + "-d", + database, + "-tAc", + sql, + ] + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout + 30, + cwd=REPO_ROOT, + env=pg_env(config_path, timeout=timeout, overrides=overrides), + ) + except FileNotFoundError as exc: + raise AnalyseError( + f"{t('psql is not installed or not in PATH.')}" + ) from exc + except subprocess.TimeoutExpired as exc: + raise AnalyseError( + f"{t('Query exceeded the timeout (s): ')}{timeout}" + ) from exc + if result.returncode: + raise AnalyseError( + f"{t('Cannot read from the database: ')}" + f"{result.stderr.strip() or result.returncode}" + ) + return result.stdout + + +def json_query(database, sql, **kwargs): + """Rendre le résultat d'un SELECT comme une liste de dicts. + + Le SQL est enveloppé côté PostgreSQL plutôt que découpé côté Python : une + arch de vue contient des retours de ligne et des « | », donc tout + séparateur maison finirait par couper au mauvais endroit. C'est le même + choix que ``snapshot_cow_views.py``. + + ``sql`` est un SELECT SANS point-virgule final : il devient une + sous-requête. + """ + inner = sql.strip().rstrip(";") + wrapped = ( + "SELECT COALESCE(json_agg(row_to_json(t))::text, '[]')" + f" FROM ({inner}) t;" + ) + raw = run_psql(database, wrapped, **kwargs).strip() + if not raw: + return [] + try: + return json.loads(raw) + except ValueError as exc: + raise AnalyseError(f"{t('Unreadable JSON from psql: ')}{exc}") from exc + + +def scalar_query(database, sql, **kwargs): + """Première valeur de la première ligne, ou None si aucune ligne.""" + raw = run_psql(database, sql, **kwargs).strip() + if not raw: + return None + return raw.splitlines()[0].strip() or None + + +def require_odoo_database(database, **kwargs): + """Refuser tout ce qui n'est pas une base Odoo, avant d'aller plus loin. + + Sans ce contrôle, ``-d postgres`` ou une base vide remonte un + « relation "ir_ui_view" does not exist » brut, qui ressemble à un bogue de + l'outil alors que c'est une erreur de saisie. + """ + found = scalar_query( + database, "SELECT to_regclass('public.ir_module_module');", **kwargs + ) + if not found: + raise AnalyseError(f"'{database}' {t('is not an Odoo database.')}") + return True + + +def database_version(database, **kwargs): + """Version Odoo de la BASE, qui n'est pas celle du checkout. + + Comparer les deux est ce qui évite d'ouvrir un shell Odoo pour rien : sur + une base 13.0 avec un checkout 18.0, le registre ne chargera pas, et mieux + vaut le dire tout de suite qu'après trente secondes de chargement. + """ + return scalar_query( + database, + "SELECT latest_version FROM ir_module_module WHERE name = 'base';", + **kwargs, + ) + + +def existing_columns(database, table, **kwargs): + """Colonnes réellement présentes, via pg_attribute (pas information_schema). + + Renvoie un ensemble vide si la table n'existe pas — les deux cas se + distinguent avec ``to_regclass`` si l'appelant en a besoin. + """ + sql = ( + "SELECT a.attname FROM pg_attribute a" + " JOIN pg_class c ON c.oid = a.attrelid" + " JOIN pg_namespace n ON n.oid = c.relnamespace" + f" WHERE n.nspname = 'public' AND c.relname = {quote_literal(table)}" + " AND a.attnum > 0 AND NOT a.attisdropped;" + ) + return { + line.strip() + for line in run_psql(database, sql, **kwargs).splitlines() + if line.strip() + } + + +def column_types(database, table, **kwargs): + """{colonne: type PostgreSQL} — dit `jsonb` là où 15.0 disait `text`.""" + sql = ( + "SELECT a.attname, format_type(a.atttypid, a.atttypmod)" + " FROM pg_attribute a" + " JOIN pg_class c ON c.oid = a.attrelid" + " JOIN pg_namespace n ON n.oid = c.relnamespace" + f" WHERE n.nspname = 'public' AND c.relname = {quote_literal(table)}" + " AND a.attnum > 0 AND NOT a.attisdropped;" + ) + dct_type = {} + for line in run_psql(database, sql, **kwargs).splitlines(): + if "|" in line: + name, _, kind = line.partition("|") + dct_type[name.strip()] = kind.strip() + return dct_type + + +def tr_col(table, column, dct_type, lang="en_US"): + """Fragment SQL lisant un champ traduit, quelle que soit la version. + + À partir de 16.0 un champ traduit est un ``jsonb`` ``{"en_US": "…"}`` ; + jusqu'à 15.0 c'est du texte. Un seul endroit décide, et il décide sur le + type réel de la colonne — pas sur un numéro de version, qu'il faudrait + connaître et qui mentirait sur une base à moitié migrée. + + ``dct_type`` vient de ``column_types()``. Une colonne inconnue rend NULL + plutôt que du SQL invalide : l'appelant verra un champ vide, pas une + requête qui explose. + """ + kind = (dct_type or {}).get(column) + if kind is None: + return "NULL::text" + qualified = f'"{table}"."{column}"' if table else f'"{column}"' + if kind == "jsonb": + return f"{qualified}->>{quote_literal(lang)}" + return f"{qualified}::text" + + +def quote_literal(value): + """Littéral SQL sûr : les quotes simples sont doublées. + + Nécessaire parce que ces requêtes sont assemblées en texte pour psql, sans + paramètres liés. Les seules valeurs concernées ici sont des noms de tables + et de colonnes venant du catalogue, mais un nom de table hérité peut + parfaitement porter une apostrophe. + """ + return "'" + str(value).replace("'", "''") + "'" + + +def model_table(model, known_tables=None): + """Table d'un modèle, ou None si elle est introuvable. + + None veut dire « je ne sais pas », jamais « il n'y en a pas » : c'est ce + qui empêche de classer un modèle à `_table` surchargé comme une anomalie. + ``known_tables`` est l'ensemble des tables réelles, quand l'appelant l'a. + """ + table = MODEL_TABLE_OVERRIDE.get(model, model.replace(".", "_")) + if known_tables is not None and table not in known_tables: + return None + return table + + +def public_tables(database, **kwargs): + """Tables réelles du schéma public, vues par le catalogue. + + ``relkind IN ('r', 'p')`` : 'r' pour une table ordinaire, 'p' pour une + table partitionnée. Odoo n'en partitionne pas, mais le jour où cela + changera, le compte ne doit pas devenir faux en silence. + """ + sql = ( + "SELECT c.relname FROM pg_class c" + " JOIN pg_namespace n ON n.oid = c.relnamespace" + " WHERE n.nspname = 'public' AND c.relkind IN ('r', 'p');" + ) + return { + line.strip() + for line in run_psql(database, sql, **kwargs).splitlines() + if line.strip() + } + + +JSON_BEGIN = "ANALYSE_JSON_BEGIN" +JSON_END = "ANALYSE_JSON_END" + + +def odoo_config_path(config_path=None): + """Chemin du fichier de configuration Odoo, comme run.sh le résout. + + Sans ``-c``, ``odoo_bin.sh`` ne passe AUCUNE configuration : ``addons_path`` + retombe sur le défaut d'Odoo, et le shell ne voit alors aucun des dépôts de + ``addons/``. ``read_arch_from_file`` ne trouverait plus un seul fichier et + rendrait une arch de référence vide pour toutes les vues — sans erreur. + C'est la panne la plus coûteuse de cet outillage, parce qu'elle est + silencieuse : le rapport dirait « aucun écart » sur une base pleine + d'écarts. + """ + for path in ( + config_path, + os.path.join(REPO_ROOT, "config.conf"), + "/etc/odoo/odoo.conf", + ): + if path and os.path.isfile(path): + return path + raise AnalyseError(t("No Odoo configuration file found.")) + + +def odoo_shell_json( + database, script_path, env=None, timeout=600, config_path=None +): + """Pousser un script dans « odoo-bin shell » et récupérer son JSON. + + Pas de ``shell=True`` : la commande est une liste, le script arrive par + l'entrée standard. Un nom de base n'a donc rien à échapper — il ne traverse + aucun interpréteur de commandes. + + 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 + une liste vide qu'on prendrait pour « rien à signaler ». + """ + if not valid_database_name(database): + raise AnalyseError(f"{t('Invalid database name: ')}{database!r}") + with open(script_path, "r", encoding="utf-8") as handle: + source = handle.read() + + my_env = os.environ.copy() + my_env.update(env or {}) + cmd = [ + os.path.join(REPO_ROOT, "odoo_bin.sh"), + "shell", + "-c", + odoo_config_path(config_path), + "-d", + database, + "--no-http", + ] + try: + result = subprocess.run( + cmd, + input=source, + capture_output=True, + text=True, + timeout=timeout, + cwd=REPO_ROOT, + env=my_env, + ) + except FileNotFoundError as exc: + raise AnalyseError(t("odoo_bin.sh not found.")) from exc + except subprocess.TimeoutExpired as exc: + raise AnalyseError( + f"{t('The Odoo shell exceeded the timeout (s): ')}{timeout}" + ) from exc + + output = result.stdout or "" + if JSON_BEGIN not in output or JSON_END not in output: + detail = (result.stderr or output).strip().splitlines()[-3:] + raise AnalyseError( + f"{t('The Odoo shell returned no result: ')}{' / '.join(detail)}" + ) + chunk = output.split(JSON_BEGIN, 1)[1].split(JSON_END, 1)[0] + try: + return json.loads(chunk.strip()) + except ValueError as exc: + raise AnalyseError( + f"{t('Unreadable JSON from the Odoo shell: ')}{exc}" + ) from exc + + +def normalise_arch(value): + """L'arch en chaîne, quel que soit le type de la colonne. + + ``arch_db`` est du texte jusqu'à 15.0 et du jsonb à partir de 16.0, avec + une entrée par langue. Reprise de ``reset_stale_cow_views.normalise_arch``, + à l'identique : deux implémentations de cette conversion finiraient par + diverger sur un cas limite, et c'est exactement le genre d'écart qui + ferait conclure « la vue a changé » sur une base qui n'a rien changé. + """ + if not isinstance(value, str): + return "" if value is None else str(value) + text = value.strip() + if text.startswith("{") and '"' in text: + try: + data = json.loads(text) + except ValueError: + return value + if isinstance(data, dict) and data: + for lang in ("en_US", *sorted(data)): + if lang in data and isinstance(data[lang], str): + return data[lang] + return value + + +# Attributs dont la valeur est un chemin ou une expression : l'espace y sépare +# des jetons, il se normalise, mais il ne se supprime pas. « //div[1] /span » +# et « //div[1]/span » ne désignent pas la même chose. +SPACING_ATTRS = ("expr", "position", "groups", "t-call", "class") + + +def canonical(arch): + """Forme canonique d'une arch, pour DÉCIDER s'il y a un écart. + + Sert à répondre « est-ce différent », jamais à afficher : ce qu'un humain + relit, c'est l'arch brute. + + Pourquoi pas ``etree.canonicalize()`` en un appel, alors que lxml est là : + son seul levier sur les espaces, ``strip_text``, s'applique à TOUS les + nœuds texte, y compris le contenu d'un ``t-esc`` ou d'un CDATA — donc il + masquerait des différences de contenu réelles. Et il n'offre aucun levier + sur les espaces à l'intérieur d'une valeur d'attribut, alors que c'est + précisément là que vit le bruit : un ``expr`` réindenté n'est pas une + modification. D'où ce parcours, qui trie les attributs comme le ferait + c14n, replie les espaces là où ils ne portent rien, et laisse le texte + tranquille partout ailleurs. + + Renvoie None si l'arch n'est pas du XML analysable — un écart ne se + conclut pas sur une comparaison qui n'a pas eu lieu. + """ + from lxml import etree + + text = normalise_arch(arch) + if not text.strip(): + return None + try: + parser = etree.XMLParser(remove_blank_text=True, remove_comments=True) + root = etree.fromstring(text.encode("utf-8"), parser=parser) + except etree.XMLSyntaxError: + return None + + def render(node): + lst_attr = [] + for key in sorted(node.attrib): + value = node.attrib[key] + if key in SPACING_ATTRS: + value = " ".join(value.split()) + lst_attr.append(f"{key}={value!r}") + head = node.tag + ("[" + ",".join(lst_attr) + "]" if lst_attr else "") + # Le texte n'est replié que sur ses bords : l'indentation autour d'un + # élément est de la mise en forme, l'espace À L'INTÉRIEUR d'un libellé + # ou d'un t-esc est du contenu. + own = (node.text or "").strip() + parts = [head] + ([f"#{own}" for _ in (1,) if own]) + parts += [render(child) for child in node] + tail = (node.tail or "").strip() + if tail: + parts.append(f"~{tail}") + return "(" + " ".join(parts) + ")" + + return render(root) + + +def arch_differs(left, right): + """(différent ?, comparable ?) entre deux arch. + + Deux réponses parce qu'il y a trois issues : identiques, différentes, et + « je n'ai pas pu comparer ». Confondre la troisième avec la première + ferait répondre « tout va bien » sur une vue au XML cassé, qui est + justement celle qu'il faut regarder. + """ + canon_left = canonical(left) + canon_right = canonical(right) + if canon_left is None or canon_right is None: + return None, False + return canon_left != canon_right, True + + +def side_by_side(left, right): + """[(marque, gauche, droite)] alignés, pour l'affichage côte à côte. + + Marque : ' ' identiques, '≠' remplacées, '-' seulement à gauche, '+' + seulement à droite. Consommé par le TUI ET par le rendu texte, pour que + les deux racontent la même chose. + """ + import difflib + + lst_left = normalise_arch(left).splitlines() + lst_right = normalise_arch(right).splitlines() + lst_row = [] + matcher = difflib.SequenceMatcher(None, lst_left, lst_right) + for tag, i1, i2, j1, j2 in matcher.get_opcodes(): + if tag == "equal": + for offset in range(i2 - i1): + lst_row.append( + (" ", lst_left[i1 + offset], lst_right[j1 + offset]) + ) + elif tag == "replace": + for offset in range(max(i2 - i1, j2 - j1)): + lst_row.append( + ( + "≠", + lst_left[i1 + offset] if i1 + offset < i2 else None, + lst_right[j1 + offset] if j1 + offset < j2 else None, + ) + ) + elif tag == "delete": + for offset in range(i1, i2): + lst_row.append(("-", lst_left[offset], None)) + elif tag == "insert": + for offset in range(j1, j2): + lst_row.append(("+", None, lst_right[offset])) + return lst_row + + +def diff_stats(lst_row): + """{added, removed, changed} depuis les lignes de side_by_side().""" + return { + "added": sum(1 for mark, _, _ in lst_row if mark == "+"), + "removed": sum(1 for mark, _, _ in lst_row if mark == "-"), + "changed": sum(1 for mark, _, _ in lst_row if mark == "≠"), + } + + +# --- Lire une sauvegarde .zip sans restaurer quoi que ce soit ---------------- +# +# Une sauvegarde Odoo est un zip contenant `manifest.json`, un `filestore/` et +# un `dump.sql` — un pg_dump TEXTE, avec ses blocs `COPY … FROM stdin;` et ses +# `CREATE TABLE`. Tout se lit donc sans PostgreSQL et sans Odoo. +# +# Ce que ça débloque : analyser la sauvegarde d'une instance Enterprise depuis +# une installation Community. La restaurer y échoue — Odoo veut charger des +# modules qu'on n'a pas — alors que les champs Studio, eux, ne sont que des +# lignes de `ir_model_fields` qu'on peut lire telles quelles. +# +# La lecture est en FLOT, une seule passe : un dump de production pèse des +# gigaoctets, et on n'en veut que trois tables. + +# Échappements de pg_dump dans un bloc COPY. `\N` (NULL) est traité à part : +# c'est une valeur, pas un caractère. +COPY_ESCAPE = { + "b": "\b", + "f": "\f", + "n": "\n", + "r": "\r", + "t": "\t", + "v": "\v", + "\\": "\\", +} + +# Lignes d'un CREATE TABLE qui ne déclarent pas une colonne. +NOT_A_COLUMN = ( + "CONSTRAINT", + "PRIMARY", + "UNIQUE", + "CHECK", + "FOREIGN", + "EXCLUDE", +) + + +def unescape_copy(value): + r"""Une valeur d'un bloc COPY, dés-échappée. « \N » devient None.""" + if value == "\\N": + return None + if "\\" not in value: + return value + out, index = [], 0 + while index < len(value): + char = value[index] + if char == "\\" and index + 1 < len(value): + out.append(COPY_ESCAPE.get(value[index + 1], value[index + 1])) + index += 2 + else: + out.append(char) + index += 1 + return "".join(out) + + +def find_member(archive, filename): + """Le membre nommé ``filename``, où qu'il soit dans l'archive. + + Cherché à la racine d'abord, puis n'importe où : selon qui fabrique la + sauvegarde, elle est mise à plat ou rangée sous un dossier. + """ + lst_name = archive.namelist() + if filename in lst_name: + return filename + for name in lst_name: + if name.rsplit("/", 1)[-1] == filename: + return name + return None + + +def backup_manifest(zip_path): + """Le manifest.json d'une sauvegarde, ou {} s'il n'y en a pas. + + Son absence n'est PAS une erreur. Une sauvegarde odoo.sh n'en contient + aucun — seulement ``dump.sql`` et ``filestore/`` — et refuser le fichier + pour cela reviendrait à refuser d'analyser précisément les bases qu'on ne + peut pas restaurer, ce qui est tout l'intérêt de lire un zip. + + Le manifeste ne portait de toute façon qu'une commodité : la version. Elle + se lit dans le dump lui-même, où elle est plus sûre — c'est la base qui + parle, pas un fichier écrit à côté. + """ + import zipfile + + try: + with zipfile.ZipFile(zip_path) as archive: + member = find_member(archive, "manifest.json") + if member is None: + return {} + with archive.open(member) as handle: + return json.load(handle) + except ValueError: + # Un manifeste illisible ne vaut pas mieux qu'un manifeste absent, et + # ne doit pas empêcher de lire le dump qui est à côté. + return {} + except (OSError, zipfile.BadZipFile) as exc: + raise AnalyseError(f"{t('Cannot read the backup: ')}{exc}") from exc + + +def backup_version(dct_rows, manifest=None): + """Version Odoo d'une sauvegarde, lue dans le dump avant le manifeste. + + ``ir_module_module.latest_version`` du module ``base`` est ce que la base + dit d'elle-même ; le manifeste n'est qu'un repli. + """ + for row in dct_rows.get("ir_module_module") or []: + if row.get("name") == "base" and row.get("latest_version"): + return row["latest_version"] + return (manifest or {}).get("version") + + +def read_backup(zip_path, tables=(), with_columns=(), census=False): + """Lire un dump.sql en flot. + + -> (manifest, {table: [lignes]}, {table: colonnes}, {table: recensement}) + + Une seule passe, quelle que soit la taille du dump : on ne garde en mémoire + que les tables demandées. + + ``census`` compte les lignes et pèse chaque table SANS la garder. Le + comptage est alors EXACT, là où une base rend une estimation d'après le + dernier ANALYZE. Le poids est celui des données dans le dump, pas sur le + disque : une sauvegarde ne sait rien des index ni du ballonnement, et + présenter l'un pour l'autre tromperait sur ce qui fait grossir une base. + """ + import io + import zipfile + + manifest = backup_manifest(zip_path) + set_want = set(tables) + # True = toutes les tables. Le dump les déclare toutes de toute façon, et + # on ne sait quelles tables comptent qu'APRÈS avoir lu les champs : les + # garder toutes coûte quelques centaines de kilo-octets et évite une + # seconde passe sur un fichier qui peut peser des gigaoctets. + all_cols = with_columns is True + set_cols = set() if all_cols else set(with_columns) + dct_rows = {name: [] for name in set_want} + dct_columns = {name: set() for name in set_cols} + dct_census = {} + + try: + archive = zipfile.ZipFile(zip_path) + except (OSError, zipfile.BadZipFile) as exc: + raise AnalyseError(f"{t('Cannot read the backup: ')}{exc}") from exc + with archive: + member = find_member(archive, "dump.sql") + if member is None: + # Le dump est la seule pièce indispensable : c'est LUI qui porte + # les données. Son absence est donc la vraie erreur, là où celle + # du manifeste n'en est pas une. + raise AnalyseError( + f"{t('This backup holds no dump.sql: ')}{zip_path}" + ) + with archive.open(member) as raw: + stream = io.TextIOWrapper(raw, encoding="utf-8", errors="replace") + for line in stream: + if line.startswith("COPY public."): + name = line[len("COPY public.") :].split(" ", 1)[0] + keep = name in set_want + if not keep and not census: + continue + header = line[line.index("(") + 1 : line.rindex(")")] + lst_col = [c.strip() for c in header.split(",")] + n_row, n_byte = 0, 0 + for row in stream: + if row.startswith("\\."): + break + n_row += 1 + n_byte += len(row) + if not keep: + continue + values = row.rstrip("\n").split("\t") + dct_rows[name].append( + dict( + zip( + lst_col, [unescape_copy(v) for v in values] + ) + ) + ) + if census: + dct_census[name] = { + "rows": n_row, + "dump_bytes": n_byte, + "columns": lst_col, + } + elif line.startswith("CREATE TABLE public."): + name = line[len("CREATE TABLE public.") :].split(" ", 1)[0] + if census: + dct_census.setdefault( + name, {"rows": 0, "dump_bytes": 0, "columns": []} + ) + for row in stream: + stripped = row.strip() + if stripped.startswith(");"): + break + if not all_cols and name not in set_cols: + continue + if stripped.upper().startswith(NOT_A_COLUMN): + continue + column = stripped.split(" ", 1)[0].strip('",') + if column: + dct_columns.setdefault(name, set()).add(column) + return manifest, dct_rows, dct_columns, dct_census + + +def _describe(): + """Dire ce qu'est ce fichier, et où sont les outils. + + Ce fichier porte un shebang et un nom qui ressemble à celui d'un outil ; + le lancer ne produisait rien du tout, ce qui se lit comme une panne plutôt + que comme « ce n'est pas un exécutable ». Il énumère donc ses voisins qui, + eux, se lancent — la liste vient du disque, elle ne peut pas se périmer + quand un outil s'ajoute. + """ + here = os.path.dirname(os.path.abspath(__file__)) + + def is_runnable(name): + """Ce fichier se lance-t-il vraiment ? + + Le nom ne suffit pas : `analyse_diff_tui.py` commence pareil et n'a + pas de point d'entrée. L'annoncer comme exécutable reproduirait le + défaut même que cette fonction corrige — promettre une commande qui + ne fait rien. On regarde donc s'il y a un bloc `__main__`. + """ + if not (name.startswith("analyse_") and name.endswith(".py")): + return False + try: + with open(os.path.join(here, name), encoding="utf-8") as handle: + return '__name__ == "__main__"' in handle.read() + except OSError: + return False + + lst_tool = sorted(name for name in os.listdir(here) if is_runnable(name)) + print( + f"📚 {os.path.basename(__file__)} — " + f"{t('shared library, nothing to run here.')}" + ) + print() + if lst_tool: + print(t("Runnable tools in this directory:")) + for tool in lst_tool: + print(f" ./script/analyse/{tool} -d ") + else: + print(t("No analysis tool here yet.")) + print() + print(f"{t('From the menu:')} make todo → Execute → Analyse") + + +if __name__ == "__main__": + _describe() diff --git a/script/analyse/shell/view_file_arch.py b/script/analyse/shell/view_file_arch.py new file mode 100644 index 0000000..771b59d --- /dev/null +++ b/script/analyse/shell/view_file_arch.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Arch de référence des vues, telle que le module la déclare. + +Ce fichier ne se lance PAS seul : il est poussé dans l'entrée standard d'un +``odoo-bin shell``, qui lui fournit ``env``. Lancé directement, il ne trouve +aucun ``env`` et ne fait rien. + +Pourquoi passer par l'ORM plutôt que relire le XML +-------------------------------------------------- +La question « à quoi comparer l'arch en base » a une réponse dans le code +d'Odoo : c'est ce que fait son propre bouton « Reset view », mode ``hard`` — +``view.with_context(read_arch_from_file=True, lang=None).arch``. + +Cette seule expression gère ce qu'une relecture du XML devrait réimplémenter : +localiser le fichier par ``arch_fs``, y trouver le bon nœud par identifiant +externe ou par identifiant court, suivre un ```` qui ne fait que +re-pointer, transformer un ``