[ADD] analyse: inspect an Odoo database without restoring it

A read-only toolkit answering « what is in this database, and what will
break on a version bump »: schema size and tables no model claims,
customised views compared with their module source, and the x_ fields
Studio left behind. It reads a backup zip directly, so a 40 GB dump needs
no restore, and it reaches every answer from the TODO menu.

--- FR ---

Une boîte à outils en lecture seule qui répond à « qu'y a-t-il dans cette
base, et qu'est-ce qui cassera à la montée de version » : taille du schéma
et tables qu'aucun modèle ne réclame, vues personnalisées comparées à leur
source, champs x_ laissés par Studio. Elle lit un zip de sauvegarde tel
quel, donc sans restaurer, et tout s'atteint depuis le menu TODO.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-10 03:10:50 -04:00
parent e8ee8cc17f
commit b1c1095977
14 changed files with 5499 additions and 795 deletions

View file

@ -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())

View file

@ -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

View file

@ -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())

View file

@ -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
# <odoo noupdate="1"> 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 <xpath position="attributes"> 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())

View file

@ -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 <database>")
else:
print(t("No analysis tool here yet."))
print()
print(f"{t('From the menu:')} make todo → Execute → Analyse")
if __name__ == "__main__":
_describe()

View file

@ -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 ``<record>`` qui ne fait que
re-pointer, transformer un ``<template>`` en ``<t t-name>``, résoudre les
``%(xmlid)s`` en identifiants réels. Réécrire tout cela, c'est se tromper
autrement qu'Odoo.
Aucune écriture
---------------
``odoo/cli/shell.py`` fait un ``rollback`` après exécution. Le ``rollback``
final ici est une ceinture par-dessus cette bretelle ; il n'y a aucun
``commit``, et il n'y en aura pas.
"""
import json
import os
VIEW_IDS = os.environ.get("VIEW_IDS", "")
LANG = os.environ.get("ANALYSE_LANG") or None
lst_id = [int(part) for part in VIEW_IDS.split(",") if part.strip().isdigit()]
lst_out = []
if lst_id:
views = env["ir.ui.view"].with_context(active_test=False).browse(lst_id)
for view in views.exists():
try:
# lang=None demande la valeur brute, non traduite : comparer une
# arch traduite à une arch source ferait ressortir chaque terme
# traduit comme une différence.
reference = view.with_context(
read_arch_from_file=True, lang=LANG
).arch
error = None
except Exception as exc: # une vue cassée ne doit pas tuer le lot
reference, error = None, f"{type(exc).__name__}: {exc}"
try:
stored = view.with_context(lang=LANG).arch
except Exception as exc:
stored, error = None, error or f"{type(exc).__name__}: {exc}"
lst_out.append(
{
"id": view.id,
"xml_id": view.xml_id or view.key or "",
"arch_db": stored,
"arch_file": reference,
"error": error,
}
)
print("ANALYSE_JSON_BEGIN")
print(json.dumps(lst_out))
print("ANALYSE_JSON_END")
env.cr.rollback()

View file

@ -242,6 +242,35 @@ class DatabaseManager:
print(file_name) print(file_name)
return file_name return file_name
def select_backup_path(self, start=None) -> str | None:
"""Faire choisir une sauvegarde .zip, au parcours ou au chemin tapé.
Les deux, parce que ni l'un ni l'autre ne suffit : le parcours part
d'`image_db/` et n'aide pas si la sauvegarde vient d'ailleurs ; le
chemin tapé oblige à le connaître. Le parcours d'abord, et une saisie
directe si l'on en sort sans rien choisir.
"""
directory = start or os.path.join(os.getcwd(), "image_db")
if todo_file_browser is not None and os.path.isdir(directory):
self._dir_path = ""
browser = todo_file_browser.FileBrowser(
directory, self._on_dir_selected
)
browser.run_main_frame()
if self._dir_path and os.path.isfile(self._dir_path):
print(self._dir_path)
return self._dir_path
answer = input(
t("Path to the backup .zip (empty to cancel): ")
).strip()
if not answer:
return None
path = os.path.expanduser(answer)
if not os.path.isfile(path):
print(f"❌ {t('No such file: ')}{path}")
return None
return path
def download_database_backup_cli( def download_database_backup_cli(
self, show_remote_list: bool = True self, show_remote_list: bool = True
) -> tuple[int, str, str]: ) -> tuple[int, str, str]:

File diff suppressed because it is too large Load diff

View file

@ -35,10 +35,6 @@ TRANSLATIONS = {
"fr": "📦 Installation", "fr": "📦 Installation",
"en": "📦 Install", "en": "📦 Install",
}, },
"Question": {
"fr": "❓ Question",
"en": "❓ Question",
},
"Fork - Open TODO in a new tab": { "Fork - Open TODO in a new tab": {
"fr": "🔀 Fork - Ouvre TODO dans une nouvelle tabulation", "fr": "🔀 Fork - Ouvre TODO dans une nouvelle tabulation",
"en": "🔀 Fork - Open TODO in a new tab", "en": "🔀 Fork - Open TODO in a new tab",
@ -3129,6 +3125,577 @@ TRANSLATIONS = {
"recorded runs": {"fr": "runs enregistrés", "en": "recorded runs"}, "recorded runs": {"fr": "runs enregistrés", "en": "recorded runs"},
"runs erased": {"fr": "runs effacés", "en": "runs erased"}, "runs erased": {"fr": "runs effacés", "en": "runs erased"},
"Cancelled.": {"fr": "Annulé.", "en": "Cancelled."}, "Cancelled.": {"fr": "Annulé.", "en": "Cancelled."},
# --- Menu Execute › Analyse ---
# L'émoji vit dans la valeur, jamais dans la clé. Deux clés distinctes,
# comme « Data » / « Database » : l'entrée de menu porte l'émoji,
# l'étiquette du fil d'Ariane est nue — _todo_telemetry_tui traduit le
# dernier segment du chemin, et un émoji y détonnerait.
"Analyse - Odoo database analysis": {
"fr": "🔬 Analyse - Analyse de base de données Odoo",
"en": "🔬 Analyse - Odoo database analysis",
},
"Analyse": {"fr": "Analyse", "en": "Analysis"},
"Analyse a database, without ever writing to it!": {
"fr": "Analyser une base, sans jamais y écrire !",
"en": "Analyse a database, without ever writing to it!",
},
# Les icônes du sous-menu Analyse. Elles sont réservées d'avance pour la
# famille entière — 🧱 index, 🎈 ballonnement, 📎 pièces jointes,
# 🔗 ir_model_data — pour qu'ajouter un outil ne demande pas d'en
# rechercher une libre, et que deux outils voisins ne se ressemblent pas.
"Structure": {"fr": "🗄 Structure", "en": "🗄 Structure"},
"Tables and database size": {
"fr": "📏 Tables et poids de la base",
"en": "📏 Tables and database size",
},
"Customisation": {"fr": "🎭 Personnalisation", "en": "🎭 Customisation"},
"Customised views, website copies included": {
"fr": "🖼 Vues personnalisées, copies de site web comprises",
"en": "🖼 Customised views, website copies included",
},
"Analysis failed: ": {
"fr": "L'analyse a échoué : ",
"en": "Analysis failed: ",
},
"Full list and JSON output:": {
"fr": "Liste complète et sortie JSON :",
"en": "Full list and JSON output:",
},
"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.": {
"fr": "Une sauvegarde n'a pas de registre, donc rien n'a été comparé"
" avec la source du module. Le classement ci-dessus n'en a pas besoin"
" ; seuls les écarts en ont. Restaurez-la, ou lancez ceci sur la base.",
"en": "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.",
},
# --- script/analyse : vues personnalisées ---
"Customised views": {
"fr": "Vues personnalisées",
"en": "Customised views",
},
"Views": {"fr": "Vues", "en": "Views"},
"why": {"fr": "pourquoi", "en": "why"},
"more": {"fr": "de plus", "en": "more"},
"From an installed theme": {
"fr": "Posée par un thème installé",
"en": "From an installed theme",
},
"Website copy (COW)": {
"fr": "Copie de site web (COW)",
"en": "Website copy (COW)",
},
"Made with Studio": {"fr": "Faite avec Studio", "en": "Made with Studio"},
"Imported or exported": {
"fr": "Importée ou exportée",
"en": "Imported or exported",
},
"Created from the interface": {
"fr": "Créée depuis l'interface",
"en": "Created from the interface",
},
"From a module, flagged as touched": {
"fr": "D'un module, signalée comme retouchée",
"en": "From a module, flagged as touched",
},
"Straight from a module": {
"fr": "Telle quelle depuis un module",
"en": "Straight from a module",
},
"Every view comes straight from a module.": {
"fr": "Toutes les vues viennent telles quelles d'un module.",
"en": "Every view comes straight from a module.",
},
"Views that did not come straight from a module": {
"fr": "Vues qui ne viennent pas telles quelles d'un module",
"en": "Views that did not come straight from a module",
},
"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:": {
"fr": "Les copies de site web sont des données utilisateur : Odoo"
" copie une vue au lieu de la modifier. Savoir si elles survivront à"
" la prochaine version est une autre question, et ces outils y"
" répondent :",
"en": "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:",
},
"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.": {
"fr": "Les drapeaux disent qu'une vue a été touchée, pas comment. Ils"
" sont incomplets dans les deux sens : un write SQL direct n'arme pas"
" arch_updated, et reset_arch l'efface. Seule la comparaison avec la"
" source du module tranche.",
"en": "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.",
},
"List the views of an Odoo database that did not come straight from a"
" module, website copies included (read-only).": {
"fr": "Lister les vues d'une base Odoo qui ne viennent pas telles"
" quelles d'un module, copies de site web comprises (lecture seule).",
"en": "List the views of an Odoo database that did not come straight"
" from a module, website copies included (read-only).",
},
"only show this category": {
"fr": "n'afficher que cette catégorie",
"en": "only show this category",
},
"how many views to show (default: 20)": {
"fr": "nombre de vues à afficher (défaut : 20)",
"en": "how many views to show (default: 20)",
},
"list every view": {
"fr": "afficher toutes les vues",
"en": "list every view",
},
"which views to compare (default: flagged)": {
"fr": "quelles vues comparer (défaut : celles qui sont signalées)",
"en": "which views to compare (default: flagged)",
},
"From a module, silently drifted": {
"fr": "D'un module, dérivée en silence",
"en": "From a module, silently drifted",
},
"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.": {
"fr": "En --scope all, un écart est une piste, pas un verdict :"
" read_arch_from_file rend le fichier brut, alors que la base porte"
" l'arch APRÈS traitement au chargement. Mesuré sur une base 18.0"
" fraîchement installée, 160 de ses 974 vues diffèrent déjà ainsi.",
"en": "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.",
},
"Compare with the module source? (Y/n): ": {
"fr": "Comparer avec la source du module ? (O/n) : ",
"en": "Compare with the module source? (Y/n): ",
},
# --- Menu Analyse : les actions « aller plus loin ». Ce qui était
# conseillé en options de ligne de commande est devenu des entrées :
# dire « utilisez -v » à quelqu'un qui est dans un menu, c'est lui
# demander d'en sortir pour obtenir ce que le menu pouvait offrir.
"Browse the differences (TUI)": {
"fr": "🖥 Naviguer dans les écarts (plein écran)",
"en": "🖥 Browse the differences (TUI)",
},
"Compare every view (slower, noisier)": {
"fr": "🔬 Comparer toutes les vues (plus long, plus bruyant)",
"en": "🔬 Compare every view (slower, noisier)",
},
"Compare first, then browse.": {
"fr": "Comparez d'abord, vous naviguerez ensuite.",
"en": "Compare first, then browse.",
},
"Compare the flagged views with the module source": {
"fr": "🔍 Comparer les vues signalées avec la source du module",
"en": "🔍 Compare the flagged views with the module source",
},
"Count rows exactly (full scan)": {
"fr": "🔢 Compter les lignes exactement (balayage complet)",
"en": "🔢 Count rows exactly (full scan)",
},
"Counting rows exactly, one scan per table…": {
"fr": "Comptage exact des lignes, un balayage par table…",
"en": "Counting rows exactly, one scan per table…",
},
"Export as JSON": {"fr": "💾 Exporter en JSON", "en": "💾 Export as JSON"},
"Go further": {"fr": "🔎 Aller plus loin", "en": "🔎 Go further"},
"Loading the Odoo registry, this takes a moment…": {
"fr": "Chargement du registre Odoo, cela prend un moment…",
"en": "Loading the Odoo registry, this takes a moment…",
},
"No view differs from its module source.": {
"fr": "Aucune vue ne diffère de la source de son module.",
"en": "No view differs from its module source.",
},
"Show every table": {
"fr": "📜 Afficher toutes les tables",
"en": "📜 Show every table",
},
"Show every view": {
"fr": "📜 Afficher toutes les vues",
"en": "📜 Show every view",
},
"Written to: ": {"fr": "Écrit dans : ", "en": "Written to: "},
"Flags say a view was touched, not how: only comparing with the module"
" source settles it.": {
"fr": "Les drapeaux disent qu'une vue a été touchée, pas comment :"
" seule la comparaison avec la source du module tranche.",
"en": "Flags say a view was touched, not how: only comparing with the"
" module source settles it.",
},
"blocking": {"fr": "bloquants", "en": "blocking"},
"Studio and hand-made x_ fields": {
"fr": "🧩 Champs x_ (Studio et faits à la main)",
"en": "🧩 Studio and hand-made x_ fields",
},
"Show every field": {
"fr": "📜 Afficher tous les champs",
"en": "📜 Show every field",
},
"A database": {"fr": "💾 Une base de données", "en": "💾 A database"},
"A backup .zip, without restoring it": {
"fr": "🗜 Une sauvegarde .zip, sans la restaurer",
"en": "🗜 A backup .zip, without restoring it",
},
"Path to the backup .zip (empty to cancel): ": {
"fr": "Chemin de la sauvegarde .zip (vide pour annuler) : ",
"en": "Path to the backup .zip (empty to cancel): ",
},
"No such file: ": {"fr": "Fichier introuvable : ", "en": "No such file: "},
"from a backup": {"fr": "depuis une sauvegarde", "en": "from a backup"},
"Odoo backup .zip to inspect, without restoring it": {
"fr": "sauvegarde Odoo .zip à examiner, sans la restaurer",
"en": "Odoo backup .zip to inspect, without restoring it",
},
"Not an Odoo backup (no manifest.json): ": {
"fr": "Pas une sauvegarde Odoo (aucun manifest.json) : ",
"en": "Not an Odoo backup (no manifest.json): ",
},
"Cannot read the backup: ": {
"fr": "Lecture impossible de la sauvegarde : ",
"en": "Cannot read the backup: ",
},
"This backup holds no dump.sql: ": {
"fr": "Cette sauvegarde ne contient aucun dump.sql : ",
"en": "This backup holds no dump.sql: ",
},
# --- script/analyse : champs et modèles hors module ---
"Nothing declares these in a file, so no module will "
"recreate them. What a version upgrade keeps is what "
"someone carried over.": {
"fr": "Rien ne les déclare dans un fichier, donc aucun module "
"ne les recréera. Ce qu'une montée de version conserve "
"est ce que quelqu'un a reporté.",
"en": "Nothing declares these in a file, so no module will "
"recreate them. What a version upgrade keeps is what "
"someone carried over.",
},
"A stored field without its column stops the registry "
"from loading, so the upgrade will not even start. Settle "
"these before anything else.": {
"fr": "Un champ stocké sans sa colonne empêche le registre de "
"charger, donc la montée de version ne démarrera même "
"pas. Réglez cela avant tout le reste.",
"en": "A stored field without its column stops the registry "
"from loading, so the upgrade will not even start. Settle "
"these before anything else.",
},
"Use -v to list them all, --json for the raw data.": {
"fr": "Utilisez -v pour tout afficher, --json pour la donnée "
"brute.",
"en": "Use -v to list them all, --json for the raw data.",
},
"List the fields and models added outside a module — "
"Studio or by hand (read-only).": {
"fr": "Lister les champs et modèles ajoutés hors module — "
"Studio ou faits à la main (lecture seule).",
"en": "List the fields and models added outside a module — "
"Studio or by hand (read-only).",
},
"how many to show (default: 30)": {
"fr": "nombre à afficher (défaut : 30)",
"en": "how many to show (default: 30)",
},
"list every one": {"fr": "tout afficher", "en": "list every one"},
"Studio": {"fr": "Studio", "en": "Studio"},
"Made by hand": {"fr": "Fait à la main", "en": "Made by hand"},
"Declared by a module": {
"fr": "Déclaré par un module",
"en": "Declared by a module",
},
"stored, but its column is missing": {
"fr": "stocké, mais sa colonne manque",
"en": "stored, but its column is missing",
},
"points at a model that no longer exists": {
"fr": "pointe vers un modèle qui n'existe plus",
"en": "points at a model that no longer exists",
},
"its model no longer exists": {
"fr": "son modèle n'existe plus",
"en": "its model no longer exists",
},
"its table could not be resolved": {
"fr": "sa table n'a pas pu être résolue",
"en": "its table could not be resolved",
},
"origin": {"fr": "provenance", "en": "origin"},
"Fields added outside a module": {
"fr": "Champs ajoutés hors module",
"en": "Fields added outside a module",
},
"Custom fields": {"fr": "Champs personnalisés", "en": "Custom fields"},
"Custom models": {"fr": "Modèles personnalisés", "en": "Custom models"},
"No field or model was added outside a module.": {
"fr": "Aucun champ ni modèle n'a été ajouté hors module.",
"en": "No field or model was added outside a module.",
},
"Blocking": {"fr": "Bloquant", "en": "Blocking"},
"To carry over by hand": {
"fr": "À reporter à la main",
"en": "To carry over by hand",
},
# --- script/analyse : comparaison et navigation des écarts ---
"Differences only": {
"fr": "Écarts seuls",
"en": "Differences only",
},
"Ignore indentation": {
"fr": "Ignorer l'indentation",
"en": "Ignore indentation",
},
"Copy": {
"fr": "Copier",
"en": "Copy",
},
"Reset command": {
"fr": "Commande de réinitialisation",
"en": "Reset command",
},
"module (file)": {
"fr": "module (fichier)",
"en": "module (file)",
},
"database": {
"fr": "base",
"en": "database",
},
"Difference copied.": {
"fr": "Écart copié.",
"en": "Difference copied.",
},
"No Odoo configuration file found.": {
"fr": "Aucun fichier de configuration Odoo trouvé.",
"en": "No Odoo configuration file found.",
},
"odoo_bin.sh not found.": {
"fr": "odoo_bin.sh est introuvable.",
"en": "odoo_bin.sh not found.",
},
"The Odoo shell returned no result: ": {
"fr": "Le shell Odoo n'a rien renvoyé : ",
"en": "The Odoo shell returned no result: ",
},
"The Odoo shell exceeded the timeout (s): ": {
"fr": "Le shell Odoo a dépassé le délai imparti (s) : ",
"en": "The Odoo shell exceeded the timeout (s): ",
},
"Unreadable JSON from the Odoo shell: ": {
"fr": "JSON illisible en sortie du shell Odoo : ",
"en": "Unreadable JSON from the Odoo shell: ",
},
"To restore a view to what its module declares — this "
"WRITES to the database, so read the difference first:": {
"fr": "Pour rendre à une vue ce que déclare son module — ceci "
"ÉCRIT dans la base, lisez donc l'écart d'abord :",
"en": "To restore a view to what its module declares — this "
"WRITES to the database, so read the difference first:",
},
"compare with the module source (opens an Odoo shell)": {
"fr": "comparer avec la source du module (ouvre un shell Odoo)",
"en": "compare with the module source (opens an Odoo shell)",
},
"fail if the comparison could not be made": {
"fr": "échouer si la comparaison n'a pas pu être faite",
"en": "fail if the comparison could not be made",
},
"browse the differences in a full-screen view": {
"fr": "naviguer dans les écarts en plein écran",
"en": "browse the differences in a full-screen view",
},
"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.": {
"fr": "Les drapeaux disent qu'une vue a été touchée, pas comment. "
"Ils sont incomplets dans les deux sens : un write SQL "
"direct n'arme pas arch_updated, et reset_arch l'efface. "
"Seule la comparaison avec la source du module tranche — "
"ajoutez --diff.",
"en": "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.",
},
"Database is Odoo": {
"fr": "La base est en Odoo",
"en": "Database is Odoo",
},
"checkout is": {
"fr": "le checkout est en",
"en": "checkout is",
},
"views were flagged but hold exactly what their module "
"declares: only the comparison could tell.": {
"fr": "vues étaient signalées mais portent exactement ce que "
"déclare leur module : seule la comparaison pouvait le "
"dire.",
"en": "views were flagged but hold exactly what their module "
"declares: only the comparison could tell.",
},
"No reference arch, so nothing was compared: ": {
"fr": "Aucune arch de référence, donc rien n'a été comparé : ",
"en": "No reference arch, so nothing was compared: ",
},
"Not a terminal: showing the text report instead.": {
"fr": "Pas un terminal : affichage du rapport texte à la place.",
"en": "Not a terminal: showing the text report instead.",
},
"To restore this view to what its module declares:": {
"fr": "Pour rendre à cette vue ce que déclare son module :",
"en": "To restore this view to what its module declares:",
},
# --- script/analyse : erreurs du socle ---
"Invalid database name: ": {
"fr": "Nom de base invalide : ",
"en": "Invalid database name: ",
},
"Cannot read from the database: ": {
"fr": "Lecture impossible dans la base : ",
"en": "Cannot read from the database: ",
},
"is not an Odoo database.": {
"fr": "n'est pas une base Odoo.",
"en": "is not an Odoo database.",
},
"psql is not installed or not in PATH.": {
"fr": "psql n'est pas installé, ou absent du PATH.",
"en": "psql is not installed or not in PATH.",
},
"Query exceeded the timeout (s): ": {
"fr": "La requête a dépassé le délai imparti (s) : ",
"en": "Query exceeded the timeout (s): ",
},
"Unreadable JSON from psql: ": {
"fr": "JSON illisible en sortie de psql : ",
"en": "Unreadable JSON from psql: ",
},
"shared library, nothing to run here.": {
"fr": "bibliothèque partagée, rien à lancer ici.",
"en": "shared library, nothing to run here.",
},
"Runnable tools in this directory:": {
"fr": "Outils exécutables de ce répertoire :",
"en": "Runnable tools in this directory:",
},
"No analysis tool here yet.": {
"fr": "Aucun outil d'analyse ici pour l'instant.",
"en": "No analysis tool here yet.",
},
"From the menu:": {"fr": "Depuis le menu :", "en": "From the menu:"},
"in the dump": {"fr": "dans le dump", "en": "in the dump"},
"Weight in the dump": {
"fr": "Poids dans le dump",
"en": "Weight in the dump",
},
# --- script/analyse : poids du schéma ---
"Schema analysis": {
"fr": "Analyse du schéma",
"en": "Schema analysis",
},
"Database size": {
"fr": "Poids de la base",
"en": "Database size",
},
"Tables": {"fr": "Tables", "en": "Tables"},
"Models": {"fr": "Modèles", "en": "Models"},
"rows": {"fr": "lignes", "en": "rows"},
"Models without table": {
"fr": "Modèles sans table",
"en": "Models without table",
},
"abstract models have none, by design": {
"fr": "les modèles abstraits n'en ont pas, par construction",
"en": "abstract models have none, by design",
},
"ir_model_relation is absent, so m2m tables cannot be told apart from"
" orphans: the list below is unreliable.": {
"fr": "ir_model_relation est absente : impossible de distinguer une"
" table m2m d'une orpheline, la liste ci-dessous n'est pas fiable.",
"en": "ir_model_relation is absent, so m2m tables cannot be told apart"
" from orphans: the list below is unreliable.",
},
"Heaviest tables": {
"fr": "Tables les plus lourdes",
"en": "Heaviest tables",
},
"All tables, heaviest first": {
"fr": "Toutes les tables, de la plus lourde à la plus légère",
"en": "All tables, heaviest first",
},
"use -v to list them all": {
"fr": "utilisez -v pour toutes les afficher",
"en": "use -v to list them all",
},
"Every table belongs to an installed model.": {
"fr": "Chaque table appartient à un modèle installé.",
"en": "Every table belongs to an installed model.",
},
"Orphan tables": {
"fr": "Tables orphelines",
"en": "Orphan tables",
},
"No installed model claims these tables. They are usually left over from"
" modules uninstalled without DROP TABLE, and every version upgrade"
" carries them along.": {
"fr": "Aucun modèle installé ne réclame ces tables. Ce sont le plus"
" souvent des reliquats de modules désinstallés sans DROP TABLE, que"
" chaque montée de version transporte avec elle.",
"en": "No installed model claims these tables. They are usually left"
" over from modules uninstalled without DROP TABLE, and every version"
" upgrade carries them along.",
},
"Check what they hold before dropping anything.": {
"fr": "Regardez ce qu'elles contiennent avant de supprimer quoi que ce"
" soit.",
"en": "Check what they hold before dropping anything.",
},
"Row counts are estimates from the last ANALYZE. Use --exact for real"
" counts, at the cost of one full scan per table.": {
"fr": "Les nombres de lignes sont estimés d'après le dernier ANALYZE."
" Utilisez --exact pour un comptage réel, au prix d'un balayage"
" complet par table.",
"en": "Row counts are estimates from the last ANALYZE. Use --exact for"
" real counts, at the cost of one full scan per table.",
},
"Report the size of an Odoo database and the tables no installed model"
" claims (read-only).": {
"fr": "Rapporter le poids d'une base Odoo et les tables qu'aucun"
" modèle installé ne réclame (lecture seule).",
"en": "Report the size of an Odoo database and the tables no installed"
" model claims (read-only).",
},
"database to inspect": {
"fr": "base à examiner",
"en": "database to inspect",
},
"count rows exactly: one full scan per table": {
"fr": "compter les lignes exactement : un balayage complet par table",
"en": "count rows exactly: one full scan per table",
},
"how many tables to show (default: 20)": {
"fr": "nombre de tables à afficher (défaut : 20)",
"en": "how many tables to show (default: 20)",
},
"list every table": {
"fr": "afficher toutes les tables",
"en": "list every table",
},
"output JSON": {"fr": "sortie JSON", "en": "output JSON"},
"path to an Odoo config file": {
"fr": "chemin d'un fichier de configuration Odoo",
"en": "path to an Odoo config file",
},
} }

View file

@ -0,0 +1,212 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Champs hors module : l'attribution et le rendu, sans base.
Ce qui décide d'un blocage vit dans `collect()`, qui a besoin de PostgreSQL.
Il s'éprouve sur une base synthétique portant les cinq cas qui comptent :
* un champ Studio dont la colonne existe — rien à signaler ;
* un champ fait main SANS colonne — bloquant, le registre ne chargerait pas ;
* un one2many non stocké — PAS un bloquant, il n'a jamais de colonne ;
* un champ sur `ir.actions.act_window`, dont la table est `ir_act_window` —
`replace('.', '_')` crierait à la colonne manquante ;
* un many2one vers un modèle absent — bloquant.
Attendu : 6 champs, 2 bloquants, 1 modèle manuel. Rejoué ensuite sur la forme
12.0 — `company_dependent` retirée, `field_description` en text, pas de
`ir_model_fields_selection` — pour la même sortie.
"""
import unittest
from script.analyse import analyse_custom_field as A
from script.todo import todo_i18n
def field(**override):
row = {
"id": 1,
"model": "res.partner",
"name": "x_champ",
"ttype": "char",
"relation": None,
"store": True,
"xmlid_modules": None,
"origin": "handmade",
"blocker": None,
}
row.update(override)
return row
class TestFieldOrigin(unittest.TestCase):
def test_no_xmlid_at_all_means_hand_made(self):
# Un champ créé en mode développeur n'a AUCUNE ligne ir_model_data.
# C'est ce qui le distingue, pas son nom : il s'appelle « x_… » lui
# aussi.
self.assertEqual(A.field_origin(field(xmlid_modules=None)), "handmade")
self.assertEqual(A.field_origin(field(xmlid_modules=[])), "handmade")
def test_studio_module_means_studio(self):
self.assertEqual(
A.field_origin(field(xmlid_modules=["studio_customization"])),
"studio",
)
def test_studio_seen_among_several_xmlids(self):
# 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. D'où l'agrégat côté SQL.
self.assertEqual(
A.field_origin(
field(xmlid_modules=["aaa", "studio_customization"])
),
"studio",
)
def test_a_module_xmlid_means_a_module(self):
self.assertEqual(
A.field_origin(field(xmlid_modules=["mon_module"])), "module"
)
def test_the_x_studio_prefix_is_never_the_only_signal(self):
# Le préfixe ressemble à Studio mais ne prouve rien : sans identifiant
# externe, le champ a été fait à la main.
self.assertEqual(
A.field_origin(field(name="x_studio_faux", xmlid_modules=None)),
"handmade",
)
class TestLabels(unittest.TestCase):
def setUp(self):
# PAS `set_lang()` : il PERSISTE la langue dans ./env_var.sh, un
# fichier suivi par git. Un test qui l'appelle modifie l'arbre de
# travail et laisse la langue changée pour tout ce qui suit —
# `_current_lang = None` ne défait que la mémoïsation, pas le
# fichier, et la résolution suivante relit celui-ci. On écrit donc
# la mémoïsation directement, et on rend la valeur trouvée.
self.addCleanup(
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
)
todo_i18n._current_lang = "en"
def test_every_origin_has_a_label(self):
for name in ("studio", "handmade", "module"):
self.assertNotEqual(A.origin_label(name), name)
def test_every_blocker_has_a_label(self):
for name in (
"missing_column",
"dangling_relation",
"model_gone",
"table_unknown",
):
self.assertNotEqual(A.blocker_label(name), name)
def test_unknown_key_falls_back_to_itself(self):
self.assertEqual(A.origin_label("inconnu"), "inconnu")
class TestNoColumnTypes(unittest.TestCase):
def test_to_many_fields_have_no_column_by_design(self):
# Le faux positif à ne pas produire : un one2many n'a jamais de
# colonne, il vit dans une table de relation. Le compter manquant
# ferait un bloquant sur chaque relation d'une base ordinaire.
self.assertIn("one2many", A.NO_COLUMN_TYPES)
self.assertIn("many2many", A.NO_COLUMN_TYPES)
self.assertNotIn("char", A.NO_COLUMN_TYPES)
self.assertNotIn("many2one", A.NO_COLUMN_TYPES)
class TestRender(unittest.TestCase):
def setUp(self):
# PAS `set_lang()` : il PERSISTE la langue dans ./env_var.sh, un
# fichier suivi par git. Un test qui l'appelle modifie l'arbre de
# travail et laisse la langue changée pour tout ce qui suit —
# `_current_lang = None` ne défait que la mémoïsation, pas le
# fichier, et la résolution suivante relit celui-ci. On écrit donc
# la mémoïsation directement, et on rend la valeur trouvée.
self.addCleanup(
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
)
todo_i18n._current_lang = "en"
def data(self, **override):
fields = [
field(id=1, name="x_studio_code", origin="studio"),
field(
id=2,
name="x_fait_main",
origin="handmade",
blocker="missing_column",
),
]
data = {
"tool": "analyse_custom_field",
"version": 1,
"database": "prod_18",
"odoo_version": "18.0.1.3",
"n_fields": 2,
"n_models": 1,
"counts": {
"studio": 1,
"handmade": 1,
"module": 0,
"blockers": 1,
"models": 1,
},
"fields": fields,
"models": [{"model": "x_contrat", "description": "Contrat"}],
"blockers": [fields[1]],
}
data.update(override)
return data
def test_blockers_come_first_and_say_why(self):
out = A.render(self.data())
self.assertIn("Blocking (1)", out)
self.assertIn("stored, but its column is missing", out)
self.assertLess(out.index("Blocking"), out.index("To carry over"))
def test_says_the_registry_will_not_load(self):
self.assertIn("stops the registry from loading", A.render(self.data()))
def test_no_blocker_block_when_there_is_none(self):
data = self.data(blockers=[])
data["counts"]["blockers"] = 0
self.assertNotIn("Blocking", A.render(data))
def test_counts_say_how_many_of_the_list_are_blocking(self):
# Les bloquants sont AUSSI dans la liste à reporter : le dire, sinon
# deux populations dont les comptes ne s'additionnent pas.
self.assertIn("(2, 1 blocking)", A.render(self.data()))
def test_clean_database_says_so(self):
data = self.data(
n_fields=0, n_models=0, fields=[], models=[], blockers=[]
)
out = A.render(data)
self.assertIn("No field or model was added outside a module.", out)
def test_says_nothing_will_recreate_them(self):
# La raison d'être de l'outil : ces champs ne sont dans aucun fichier.
self.assertIn("no module will recreate", A.render(self.data()))
def test_hint_is_absent_in_verbose_and_in_the_menu(self):
self.assertIn("Use -v", A.render(self.data()))
self.assertNotIn("Use -v", A.render(self.data(), verbose=True))
self.assertNotIn("Use -v", A.render(self.data(), hints=False))
def test_french_differs(self):
english = A.render(self.data())
todo_i18n._current_lang = "fr" # cf. setUp : pas de persistance
french = A.render(self.data())
self.assertIn("Fait à la main", french)
self.assertNotEqual(english, french)
if __name__ == "__main__":
unittest.main()

522
test/test_analyse_lib.py Normal file
View file

@ -0,0 +1,522 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Socle d'analyse : ce qui se teste SANS base de données.
Tout ici est une fonction pure ou une fonction dont le seul effet est de
construire du SQL ou un environnement. C'est délibéré : ces tests doivent
tourner sur une machine sans PostgreSQL, donc dans la suite du dépôt.
Ce qui exige une vraie base — `require_odoo_database`, `database_version`,
`existing_columns`, `column_types`, `public_tables` — n'est pas ici : le test
ne peut pas fabriquer sa base, puisque créer une base est une écriture et que
l'outillage est en lecture seule par construction.
Ces cinq fonctions s'éprouvent à la main sur une base synthétique, ce qui
prend une minute et n'exige aucun Odoo installé :
createdb erplibre_analyse_selftest
psql -d erplibre_analyse_selftest -c "
CREATE TABLE ir_module_module (id serial PRIMARY KEY, name varchar,
state varchar, latest_version varchar);
INSERT INTO ir_module_module (name, state, latest_version)
VALUES ('base', 'installed', '18.0.1.3');
-- arch_db en jsonb : la forme >= 16.0. Le « | » et le saut de ligne
-- sont là exprès : ils prouvent que json_query ne coupe pas dessus.
CREATE TABLE ir_ui_view (id serial PRIMARY KEY, name jsonb,
arch_db jsonb, website_id integer, arch_fs varchar);
INSERT INTO ir_ui_view (name, arch_db) VALUES
('{\"en_US\":\"Avec un | et un saut\\nde ligne\"}',
'{\"en_US\":\"<form/>\"}');
-- une colonne text : la forme <= 15.0, pour éprouver tr_col des deux côtés
CREATE TABLE res_partner (id serial PRIMARY KEY, ref text);
-- table dont le nom NE dérive PAS du modèle ir.actions.act_window
CREATE TABLE ir_act_window (id serial PRIMARY KEY, res_model varchar);"
Attendu : `database_version` rend « 18.0.1.3 » ; `column_types` distingue
`jsonb` de `text` ; `tr_col` produit `->>'en_US'` d'un côté et `::text` de
l'autre ; `model_table('ir.actions.act_window')` rend `ir_act_window` ;
`require_odoo_database` refuse une base sans `ir_module_module` ; et un
`CREATE TABLE` passé à `run_psql` est refusé par le serveur, pas par nous.
Puis `dropdb erplibre_analyse_selftest`.
"""
import os
import tempfile
import unittest
from script.analyse import lib_analyse as L
from script.todo import todo_i18n
class TestValidDatabaseName(unittest.TestCase):
def test_accepts_real_names(self):
for name in ("test", "prod_18", "client.prod", "a-b_c.1"):
self.assertTrue(L.valid_database_name(name), name)
def test_rejects_a_traceback_line(self):
# Le cas qui a motivé le contrôle : un PostgreSQL injoignable faisait
# remonter sa trace d'appel comme si c'était une liste de bases.
self.assertFalse(
L.valid_database_name("Traceback (most recent call last):")
)
def test_rejects_shell_injection(self):
for name in ("a; DROP DATABASE b", "a b", "$(id)", "a|b", "a'b"):
self.assertFalse(L.valid_database_name(name), name)
def test_rejects_empty(self):
self.assertFalse(L.valid_database_name(""))
self.assertFalse(L.valid_database_name(None))
class TestReadConfig(unittest.TestCase):
def _write(self, body):
handle = tempfile.NamedTemporaryFile(
"w", suffix=".conf", delete=False, encoding="utf-8"
)
handle.write(body)
handle.close()
self.addCleanup(os.unlink, handle.name)
return handle.name
def test_reads_the_options_section(self):
path = self._write("[options]\ndb_user = erplibre\ndb_port = 5433\n")
config = L.read_config(path)
self.assertEqual(config["db_user"], "erplibre")
self.assertEqual(config["db_port"], "5433")
def test_missing_file_is_not_an_error(self):
# Sur une installation native, psql se connecte par le socket unix sans
# aucun paramètre : l'absence de config est un cas normal.
self.assertEqual(L.read_config("/nowhere/absent.conf"), {})
def test_file_without_options_section(self):
path = self._write("[other]\nfoo = bar\n")
self.assertEqual(L.read_config(path), {})
class TestPgEnv(unittest.TestCase):
def _write(self, body):
handle = tempfile.NamedTemporaryFile(
"w", suffix=".conf", delete=False, encoding="utf-8"
)
handle.write(body)
handle.close()
self.addCleanup(os.unlink, handle.name)
return handle.name
def test_read_only_and_timeout_are_always_set(self):
env = L.pg_env("/nowhere/absent.conf", timeout=42)
self.assertIn("default_transaction_read_only=on", env["PGOPTIONS"])
self.assertIn("statement_timeout=42s", env["PGOPTIONS"])
def test_psqlrc_is_neutralised(self):
# Un ~/.psqlrc avec \timing ajoute des lignes à la sortie et casse le
# parsing JSON.
self.assertEqual(L.pg_env("/nowhere/absent.conf")["PSQLRC"], "")
def test_config_values_become_pg_variables(self):
path = self._write(
"[options]\ndb_host = pg.example.org\ndb_port = 5433\n"
"db_user = erplibre\ndb_password = s3cret\ndb_sslmode = require\n"
)
env = L.pg_env(path)
self.assertEqual(env["PGHOST"], "pg.example.org")
self.assertEqual(env["PGPORT"], "5433")
self.assertEqual(env["PGUSER"], "erplibre")
self.assertEqual(env["PGPASSWORD"], "s3cret")
self.assertEqual(env["PGSSLMODE"], "require")
def test_literal_false_means_unset(self):
# Odoo écrit « False » dans config.conf pour dire « pas de valeur ».
# L'exporter tel quel ferait chercher un hôte nommé « False ».
path = self._write(
"[options]\ndb_host = False\ndb_port = False\n"
"db_password = False\ndb_user = erplibre\n"
)
env = L.pg_env(path)
self.assertNotIn("PGHOST", env)
self.assertNotIn("PGPORT", env)
self.assertNotIn("PGPASSWORD", env)
self.assertEqual(env["PGUSER"], "erplibre")
def test_overrides_win_over_the_config(self):
path = self._write("[options]\ndb_host = pg.example.org\n")
env = L.pg_env(path, overrides={"PGHOST": "127.0.0.1"})
self.assertEqual(env["PGHOST"], "127.0.0.1")
class TestQuoteLiteral(unittest.TestCase):
def test_wraps_in_single_quotes(self):
self.assertEqual(L.quote_literal("ir_ui_view"), "'ir_ui_view'")
def test_doubles_embedded_quotes(self):
self.assertEqual(L.quote_literal("a'b"), "'a''b'")
class TestTrCol(unittest.TestCase):
"""Le fragment SQL d'un champ traduit, décidé sur le TYPE réel.
Pas sur un numéro de version : une base à moitié migrée porte les deux
formes, et un numéro de version mentirait.
"""
def test_jsonb_column_is_unpacked(self):
# La forme >= 16.0 : {"en_US": "..."}
got = L.tr_col("ir_ui_view", "arch_db", {"arch_db": "jsonb"})
self.assertEqual(got, '"ir_ui_view"."arch_db"->>\'en_US\'')
def test_text_column_is_cast(self):
# La forme <= 15.0 : du texte
got = L.tr_col("ir_ui_view", "arch_db", {"arch_db": "text"})
self.assertEqual(got, '"ir_ui_view"."arch_db"::text')
def test_character_varying_is_cast_too(self):
got = L.tr_col("ir_model", "name", {"name": "character varying"})
self.assertEqual(got, '"ir_model"."name"::text')
def test_unknown_column_yields_null_not_broken_sql(self):
# Une colonne absente doit donner un champ vide, pas une requête qui
# explose : c'est ce qui permet de sonder puis d'interroger d'un trait.
self.assertEqual(L.tr_col("ir_ui_view", "absente", {}), "NULL::text")
self.assertEqual(L.tr_col("ir_ui_view", "absente", None), "NULL::text")
def test_other_language(self):
got = L.tr_col("ir_model", "name", {"name": "jsonb"}, lang="fr_CA")
self.assertEqual(got, '"ir_model"."name"->>\'fr_CA\'')
class TestModelTable(unittest.TestCase):
def test_default_derivation(self):
self.assertEqual(L.model_table("res.partner"), "res_partner")
def test_override_is_applied(self):
# replace('.', '_') donnerait ir_actions_act_window, qui n'existe pas.
self.assertEqual(
L.model_table("ir.actions.act_window"), "ir_act_window"
)
def test_unknown_table_returns_none_not_a_guess(self):
# 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 abstrait comme une anomalie.
self.assertIsNone(
L.model_table("mail.thread", known_tables={"res_partner"})
)
def test_known_table_passes_through(self):
self.assertEqual(
L.model_table("res.partner", known_tables={"res_partner"}),
"res_partner",
)
class TestModelTableOverrideIntegrity(unittest.TestCase):
"""La table de surcharges ne doit pas accumuler d'entrées inutiles.
Beaucoup de modules déclarent un `_table` égal au défaut. Recopier une
telle déclaration ici serait du poids mort qui donne l'illusion d'une
surcharge — et c'est exactement la confusion qui a fait annoncer 22
surcharges là où il y en a 11.
"""
def test_no_entry_equals_the_default(self):
for model, table in L.MODEL_TABLE_OVERRIDE.items():
self.assertNotEqual(
table,
model.replace(".", "_"),
f"'{model}' n'est pas une surcharge : son _table est le défaut",
)
def test_tables_look_like_table_names(self):
for model, table in L.MODEL_TABLE_OVERRIDE.items():
self.assertRegex(table, r"^[a-z][a-z0-9_]*$", model)
class TestJsonQuery(unittest.TestCase):
"""L'enveloppe json_agg, sans base : on intercepte run_psql."""
def setUp(self):
self.seen = []
self.reply = "[]"
self.original = L.run_psql
def fake_run_psql(database, sql, **kwargs):
self.seen.append((database, sql))
return self.reply
L.run_psql = fake_run_psql
self.addCleanup(setattr, L, "run_psql", self.original)
def test_wraps_the_select(self):
L.json_query("db", "SELECT id FROM ir_ui_view")
_, sql = self.seen[0]
self.assertIn("json_agg(row_to_json(t))", sql)
self.assertIn("FROM (SELECT id FROM ir_ui_view) t", sql)
def test_strips_a_trailing_semicolon(self):
# Un « ; » resté dans la sous-requête produirait du SQL invalide.
L.json_query("db", "SELECT id FROM ir_ui_view;")
_, sql = self.seen[0]
self.assertNotIn(";) t", sql)
def test_empty_output_is_an_empty_list(self):
self.reply = " \n"
self.assertEqual(L.json_query("db", "SELECT 1"), [])
def test_pipes_and_newlines_survive(self):
# La raison d'être de l'enveloppe : une arch XML contient des « | » et
# des sauts de ligne, donc tout séparateur maison couperait au mauvais
# endroit.
self.reply = '[{"arch": "a | b\\nc"}]'
rows = L.json_query("db", "SELECT 1")
self.assertEqual(rows, [{"arch": "a | b\nc"}])
def test_unreadable_output_raises_analyse_error(self):
self.reply = "ERREUR: quelque chose"
with self.assertRaises(L.AnalyseError):
L.json_query("db", "SELECT 1")
class TestRunPsqlGuards(unittest.TestCase):
"""`_current_lang` est un état de MODULE, pas un état de test.
Ce test passait seul et tombait dans la suite : un autre fichier avait
laissé la langue en français, et le message n'est traduit que depuis
qu'une traduction existe. Toute assertion sur un texte affiché doit donc
fixer la langue, sinon elle dépend de l'ordre des tests.
"""
def setUp(self):
# PAS `set_lang()` : il PERSISTE la langue dans ./env_var.sh, un
# fichier suivi par git. Un test qui l'appelle modifie l'arbre de
# travail et laisse la langue changée pour tout ce qui suit —
# `_current_lang = None` ne défait que la mémoïsation, pas le
# fichier, et la résolution suivante relit celui-ci. On écrit donc
# la mémoïsation directement, et on rend la valeur trouvée.
self.addCleanup(
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
)
todo_i18n._current_lang = "en"
def test_hostile_database_name_never_reaches_psql(self):
with self.assertRaises(L.AnalyseError) as caught:
L.run_psql("a; DROP DATABASE b", "SELECT 1;")
self.assertIn("Invalid database name", str(caught.exception))
if __name__ == "__main__":
unittest.main()
class TestCanonical(unittest.TestCase):
"""Ce qui décide s'il y a un écart. Le bruit se joue ici."""
def test_indentation_is_not_a_change(self):
# Le cas mesuré sur une vraie base : une arch ré-indentée n'est pas
# une modification, et c'est l'erreur que fait `has_diff` d'Odoo —
# une comparaison de chaînes brutes — dans son propre assistant.
left = "<form><field name='a'/></form>"
right = "<form>\n <field name='a'/>\n</form>"
self.assertEqual(L.canonical(left), L.canonical(right))
self.assertEqual(L.arch_differs(left, right), (False, True))
def test_attribute_order_is_not_a_change(self):
self.assertEqual(
L.canonical("<t a='1' b='2'/>"), L.canonical("<t b='2' a='1'/>")
)
def test_comments_are_not_a_change(self):
self.assertEqual(
L.canonical("<form><!-- note --><field/></form>"),
L.canonical("<form><field/></form>"),
)
def test_an_added_field_is_a_change(self):
left = "<form><field name='a'/></form>"
right = "<form><field name='a'/><field name='x_custom'/></form>"
self.assertEqual(L.arch_differs(left, right), (True, True))
def test_changed_text_is_a_change(self):
# L'espace DANS un libellé est du contenu, pas de la mise en forme.
self.assertEqual(
L.arch_differs("<t>Total</t>", "<t>Grand total</t>"), (True, True)
)
def test_xpath_expression_spacing_is_normalised(self):
# L'espace y sépare des jetons : il se replie, il ne disparaît pas.
self.assertEqual(
L.canonical('<xpath expr="//div[1] /span"/>'),
L.canonical('<xpath expr="//div[1] /span"/>'),
)
def test_a_different_xpath_target_is_a_change(self):
self.assertNotEqual(
L.canonical('<xpath expr="//div/span"/>'),
L.canonical('<xpath expr="//div[1]/span"/>'),
)
def test_broken_xml_is_not_comparable(self):
# Ni « identique » ni « différent » : une comparaison qui n'a pas eu
# lieu ne doit pas se lire comme une comparaison sans écart.
self.assertIsNone(L.canonical("<form><field></form>"))
self.assertEqual(
L.arch_differs("<form/>", "<form><field>"), (None, False)
)
def test_empty_arch_is_not_comparable(self):
self.assertIsNone(L.canonical(""))
self.assertIsNone(L.canonical(None))
def test_jsonb_column_is_unwrapped_first(self):
self.assertEqual(
L.canonical('{"en_US": "<form/>"}'), L.canonical("<form/>")
)
class TestSideBySide(unittest.TestCase):
def test_identical_lines_are_aligned(self):
rows = L.side_by_side("a\nb", "a\nb")
self.assertEqual([mark for mark, _, _ in rows], [" ", " "])
def test_insert_has_no_left_side(self):
rows = L.side_by_side("a", "a\nb")
self.assertEqual(rows[-1], ("+", None, "b"))
def test_delete_has_no_right_side(self):
rows = L.side_by_side("a\nb", "a")
self.assertEqual(rows[-1], ("-", "b", None))
def test_replace_keeps_both_sides_on_one_row(self):
# Le point de l'affichage côte à côte : les deux versions d'une même
# ligne se lisent l'une en face de l'autre, sans rien à synchroniser.
rows = L.side_by_side("a", "b")
self.assertEqual(rows, [("≠", "a", "b")])
def test_stats_count_each_kind(self):
rows = L.side_by_side("a\nb\nc", "a\nB\nc\nd")
self.assertEqual(
L.diff_stats(rows), {"added": 1, "removed": 0, "changed": 1}
)
class TestUnescapeCopy(unittest.TestCase):
r"""Les valeurs d'un bloc COPY d'un dump.sql.
C'est ce qui permet de lire une sauvegarde sans la restaurer. Une erreur
ici corrompt silencieusement une valeur — un « \n » laissé littéral dans
une aide de champ, un NULL pris pour la chaîne « \N ».
"""
def test_backslash_n_is_null_not_a_string(self):
self.assertIsNone(L.unescape_copy("\\N"))
# Mais « \N » AU MILIEU d'une valeur reste du texte.
self.assertEqual(L.unescape_copy("a\\Nb"), "aNb")
def test_plain_value_passes_through(self):
self.assertEqual(L.unescape_copy("x_studio_code"), "x_studio_code")
self.assertEqual(L.unescape_copy(""), "")
def test_newline_and_tab_are_restored(self):
# Une aide de champ multi-lignes arrive échappée : la laisser telle
# quelle mettrait « \n » littéral dans le rapport.
self.assertEqual(L.unescape_copy("a\\nb"), "a\nb")
self.assertEqual(L.unescape_copy("a\\tb"), "a\tb")
self.assertEqual(L.unescape_copy("a\\rb"), "a\rb")
def test_escaped_backslash(self):
self.assertEqual(L.unescape_copy("a\\\\b"), "a\\b")
def test_unknown_escape_keeps_the_character(self):
self.assertEqual(L.unescape_copy("a\\qb"), "aqb")
def test_trailing_backslash_is_not_an_index_error(self):
self.assertEqual(L.unescape_copy("a\\"), "a\\")
class TestNotAColumn(unittest.TestCase):
def test_constraint_lines_are_not_columns(self):
# Un CREATE TABLE mêle colonnes et contraintes ; prendre le premier
# mot d'une ligne CONSTRAINT donnerait une colonne qui n'existe pas,
# et un champ x_ passerait pour ayant sa colonne.
for word in ("CONSTRAINT", "PRIMARY", "CHECK", "FOREIGN", "UNIQUE"):
self.assertTrue(f"{word} foo".upper().startswith(L.NOT_A_COLUMN))
self.assertFalse(
"name character varying".upper().startswith(L.NOT_A_COLUMN)
)
class TestBackupWithoutManifest(unittest.TestCase):
"""Une sauvegarde odoo.sh n'a pas de manifest.json.
Refuser le fichier pour cela revenait à 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 qu'une commodité, la version, et la base
la dit elle-même dans ir_module_module.
"""
def _zip(self, entries):
import zipfile
handle = tempfile.NamedTemporaryFile(suffix=".zip", delete=False)
handle.close()
self.addCleanup(os.unlink, handle.name)
with zipfile.ZipFile(handle.name, "w") as archive:
for name, body in entries.items():
archive.writestr(name, body)
return handle.name
DUMP = (
"CREATE TABLE public.ir_module_module (\n"
" id integer NOT NULL,\n"
" name character varying\n"
");\n"
"COPY public.ir_module_module (id, name, latest_version) "
"FROM stdin;\n"
"1\tbase\tsaas~19.2.1.3\n"
"\\.\n"
)
def test_missing_manifest_is_not_an_error(self):
path = self._zip({"dump.sql": self.DUMP})
self.assertEqual(L.backup_manifest(path), {})
def test_unreadable_manifest_does_not_block_the_dump(self):
path = self._zip({"dump.sql": self.DUMP, "manifest.json": "{ nope"})
self.assertEqual(L.backup_manifest(path), {})
_, rows, _, _ = L.read_backup(path, tables=("ir_module_module",))
self.assertEqual(len(rows["ir_module_module"]), 1)
def test_a_missing_dump_is_still_refused(self):
# Le dump est la seule pièce indispensable : c'est lui qui porte les
# données. Son absence reste une vraie erreur.
path = self._zip({"manifest.json": "{}"})
with self.assertRaises(L.AnalyseError):
L.read_backup(path, tables=("ir_model",))
def test_dump_is_found_under_a_directory(self):
path = self._zip({"sauvegarde/dump.sql": self.DUMP})
_, rows, _, _ = L.read_backup(path, tables=("ir_module_module",))
self.assertEqual(len(rows["ir_module_module"]), 1)
def test_version_comes_from_the_dump(self):
path = self._zip({"dump.sql": self.DUMP})
_, rows, _, _ = L.read_backup(path, tables=("ir_module_module",))
self.assertEqual(L.backup_version(rows), "saas~19.2.1.3")
def test_the_dump_wins_over_the_manifest(self):
# Le manifeste est écrit à côté de la base ; la base, elle, se
# décrit elle-même. En cas de désaccord, on croit la base.
path = self._zip(
{"dump.sql": self.DUMP, "manifest.json": '{"version": "12.0"}'}
)
manifest, rows, _, _ = L.read_backup(
path, tables=("ir_module_module",)
)
self.assertEqual(manifest.get("version"), "12.0")
self.assertEqual(L.backup_version(rows, manifest), "saas~19.2.1.3")
def test_manifest_is_the_fallback_when_the_dump_says_nothing(self):
self.assertEqual(L.backup_version({}, {"version": "16.0"}), "16.0")
self.assertIsNone(L.backup_version({}, {}))

View file

@ -0,0 +1,225 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Poids du schéma : ce qui se teste sans base.
Le classement d'une table (modèle / m2m / système / orpheline) vit dans
`collect()`, qui interroge PostgreSQL. Il s'éprouve sur la base synthétique
décrite dans le docstring de `test_analyse_lib.py`, augmentée des quatre cas
qui comptent : une table m2m sans ligne `ir_model` mais listée dans
`ir_model_relation`, un modèle à `_table` surchargé, un modèle abstrait, et
une vraie orpheline. Attendu : une seule orpheline, la vraie.
Ici, les fonctions pures — mise en forme et rendu. `render()` est une
fonction de la donnée vers le texte, donc tout le rapport se teste sur un
dictionnaire écrit à la main.
"""
import unittest
from script.analyse import analyse_schema_size as A
from script.todo import todo_i18n
def fixture(**override):
"""Un résultat de collect() minimal, que chaque test tord à sa guise."""
data = {
"tool": "analyse_schema_size",
"version": 1,
"database": "prod_18",
"odoo_version": "18.0.1.3",
"db_bytes": 12884901888,
"exact": False,
"has_relation_table": True,
"n_tables": 3,
"n_models": 4,
"tables": [
{
"table_name": "mail_message",
"total_bytes": 3221225472,
"table_bytes": 2254857830,
"index_bytes": 943718400,
"est_rows": 4183221,
"exact_rows": None,
"model": "mail.message",
"origin": "model",
},
{
"table_name": "res_groups_users_rel",
"total_bytes": 16384,
"table_bytes": 8192,
"index_bytes": 8192,
"est_rows": -1,
"exact_rows": None,
"model": None,
"origin": "m2m",
},
{
"table_name": "old_module_thing",
"total_bytes": 431916544,
"table_bytes": 400000000,
"index_bytes": 31916544,
"est_rows": 90211,
"exact_rows": None,
"model": None,
"origin": "orphan",
},
],
"orphan_tables": [],
"models_without_table": [],
"counts": {
"orphan_tables": 0,
"models_without_table": 0,
"m2m_tables": 1,
},
}
data["orphan_tables"] = [
r for r in data["tables"] if r["origin"] == "orphan"
]
data["counts"]["orphan_tables"] = len(data["orphan_tables"])
data.update(override)
return data
class TestFmtBytes(unittest.TestCase):
def test_zero_and_bytes_have_no_decimal(self):
self.assertEqual(A.fmt_bytes(0), "0 B")
self.assertEqual(A.fmt_bytes(512), "512 B")
def test_scales_to_binary_units(self):
self.assertEqual(A.fmt_bytes(1024), "1.0 KiB")
self.assertEqual(A.fmt_bytes(1024**2), "1.0 MiB")
self.assertEqual(A.fmt_bytes(1024**3), "1.0 GiB")
self.assertEqual(A.fmt_bytes(1024**4), "1.0 TiB")
def test_beyond_tebibyte_stays_in_tebibytes(self):
self.assertEqual(A.fmt_bytes(5 * 1024**4), "5.0 TiB")
def test_none_is_a_question_mark(self):
self.assertEqual(A.fmt_bytes(None), "?")
class TestFmtRows(unittest.TestCase):
def test_never_analyzed_is_not_zero(self):
# PostgreSQL >= 14 met reltuples à -1 quand aucun ANALYZE n'a tourné.
# Afficher « 0 » ferait passer une table pleine pour une table vide.
self.assertEqual(A.fmt_rows(-1), "?")
self.assertEqual(A.fmt_rows(None), "?")
def test_zero_stays_zero(self):
# Une table réellement vide et analysée doit dire 0, pas « ? ».
self.assertEqual(A.fmt_rows(0), "0")
def test_groups_thousands_with_spaces(self):
self.assertEqual(A.fmt_rows(4183221), "4 183 221")
class TestWrapNote(unittest.TestCase):
def test_short_text_is_one_line(self):
self.assertEqual(A.wrap_note(" ", "court"), [" court"])
def test_continuation_lines_align_under_the_first(self):
lines = A.wrap_note(" 💡 ", "mot " * 40)
self.assertGreater(len(lines), 1)
self.assertTrue(lines[0].startswith(" 💡 "))
for line in lines[1:]:
self.assertTrue(line.startswith(" " * len(" 💡 ")), repr(line))
def test_respects_the_width(self):
for line in A.wrap_note(" ", "mot " * 60, width=50):
self.assertLessEqual(len(line), 50, repr(line))
def test_empty_text_does_not_crash(self):
self.assertEqual(A.wrap_note(" ", ""), [" "])
class TestRender(unittest.TestCase):
"""Le rapport, sur une donnée écrite à la main.
`set_lang` est fixé : `_current_lang` est un état de module alimenté par
une préférence utilisateur, donc sans cela le test dépendrait de la langue
de la machine.
"""
def setUp(self):
# PAS `set_lang()` : il PERSISTE la langue dans ./env_var.sh, un
# fichier suivi par git. Un test qui l'appelle modifie l'arbre de
# travail et laisse la langue changée pour tout ce qui suit —
# `_current_lang = None` ne défait que la mémoïsation, pas le
# fichier, et la résolution suivante relit celui-ci. On écrit donc
# la mémoïsation directement, et on rend la valeur trouvée.
self.addCleanup(
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
)
todo_i18n._current_lang = "en"
def test_reports_the_orphan_and_not_the_m2m(self):
# Le faux positif à éviter : une table m2m n'a aucune ligne ir_model,
# et sur une base ordinaire il y en a des centaines.
out = A.render(fixture())
self.assertIn("Orphan tables (1)", out)
orphan_block = out.split("Orphan tables")[1]
self.assertIn("old_module_thing", orphan_block)
self.assertNotIn("res_groups_users_rel", orphan_block)
def test_no_orphan_says_so_plainly(self):
data = fixture(orphan_tables=[])
data["counts"]["orphan_tables"] = 0
out = A.render(data)
self.assertIn("Every table belongs to an installed model.", out)
self.assertNotIn("Orphan tables", out)
def test_models_without_table_is_a_fact_not_a_finding(self):
# Les modèles abstraits sont dans ir_model et n'ont pas de table : des
# centaines sur une base ordinaire. La ligne doit l'expliquer.
data = fixture(models_without_table=[{"model": "mail.thread"}])
data["counts"]["models_without_table"] = 1
out = A.render(data)
self.assertIn("Models without table", out)
self.assertIn("abstract models have none", out)
def test_models_without_table_line_is_absent_when_zero(self):
self.assertNotIn("Models without table", A.render(fixture()))
def test_warns_when_m2m_cannot_be_told_apart(self):
# Sans ir_model_relation, chaque table m2m passerait pour orpheline :
# le rapport doit dire qu'il n'est pas fiable, pas se taire.
out = A.render(fixture(has_relation_table=False))
self.assertIn("ir_model_relation is absent", out)
def test_estimate_warning_only_without_exact(self):
self.assertIn("estimates from the last ANALYZE", A.render(fixture()))
self.assertNotIn(
"estimates from the last ANALYZE",
A.render(fixture(exact=True)),
)
def test_top_limits_the_table_list(self):
out = A.render(fixture(), top=1)
self.assertIn("Heaviest tables (1/3)", out)
self.assertIn("use -v to list them all", out)
def test_verbose_lists_everything(self):
out = A.render(fixture(), verbose=True)
self.assertIn("All tables, heaviest first", out)
self.assertNotIn("use -v to list them all", out)
def test_never_analyzed_shows_a_question_mark(self):
out = A.render(fixture(), verbose=True)
row = [l for l in out.splitlines() if "res_groups_users_rel" in l][0]
self.assertTrue(row.rstrip().endswith("?"), repr(row))
def test_unknown_odoo_version_does_not_crash(self):
self.assertIn("Odoo ?", A.render(fixture(odoo_version=None)))
def test_french_differs_from_english(self):
english = A.render(fixture())
todo_i18n._current_lang = "fr" # cf. plus haut : pas de persistance
french = A.render(fixture())
self.assertIn("Tables orphelines", french)
self.assertNotEqual(english, french)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,270 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Vues personnalisées : le classement, sans base.
`classify()` est une fonction pure de la ligne SQL vers une catégorie. C'est
là que se joue tout ce qui distingue un constat d'un faux positif, donc c'est
là que porte l'essentiel de ces tests.
La collecte s'éprouve sur une base synthétique portant les sept catégories, y
compris deux copies COW — l'une avec sa jumelle module, l'autre sans, qui est
une page faite dans l'éditeur web. Attendu : 11 vues, 8 constats, et des
comptes dont la somme fait exactement 11.
"""
import unittest
from script.analyse import analyse_view_custom as A
from script.todo import todo_i18n
def view(**override):
"""Une ligne de vue telle que la rend la requête, tout à zéro."""
row = {
"id": 1,
"name": "Une vue",
"key": None,
"arch_fs": None,
"arch_updated": False,
"noupdate": False,
"has_arch_prev": False,
"active": True,
"website_id": None,
"theme_template_id": None,
"xmlid_modules": None,
"xmlids": None,
"has_module_twin": False,
"arch_bytes": 100,
}
row.update(override)
return row
class TestClassify(unittest.TestCase):
def test_plain_module_view(self):
row = view(arch_fs="base/views/x.xml", xmlid_modules=["base"])
self.assertEqual(A.classify(row)[0], "module_view")
def test_module_view_flagged_by_arch_updated(self):
row = view(
arch_fs="sale/views/y.xml",
xmlid_modules=["sale"],
arch_updated=True,
)
category, reasons = A.classify(row)
self.assertEqual(category, "module_view_flagged")
self.assertIn("arch_updated", reasons)
def test_noupdate_alone_is_not_a_finding(self):
"""Le faux positif à ne pas réintroduire.
Toute vue déclarée dans un bloc <odoo noupdate="1"> porte ce drapeau —
les données de mail, account et website en sont pleines — sans que
personne n'y ait touché. La compter noierait la catégorie qui compte.
"""
row = view(
arch_fs="mail/data/z.xml", xmlid_modules=["mail"], noupdate=True
)
category, reasons = A.classify(row)
self.assertEqual(category, "module_view")
self.assertNotIn(category, A.ACTIONABLE)
# L'information n'est pas perdue pour autant.
self.assertIn("noupdate", reasons)
def test_website_copy(self):
row = view(key="website.layout", website_id=1, has_module_twin=True)
category, reasons = A.classify(row)
self.assertEqual(category, "website_cow_copy")
self.assertNotIn("no_module_twin", reasons)
def test_website_copy_without_a_twin_is_a_page_from_the_editor(self):
row = view(key="website.page_1", website_id=1, has_module_twin=False)
category, reasons = A.classify(row)
self.assertEqual(category, "website_cow_copy")
self.assertIn("no_module_twin", reasons)
def test_studio(self):
row = view(xmlid_modules=["studio_customization"])
self.assertEqual(A.classify(row)[0], "studio")
def test_studio_seen_among_several_xmlids(self):
"""Une vue peut porter plusieurs identifiants externes.
Une jointure plate n'en rendrait qu'un, choisi au hasard : Studio
passerait inaperçu une fois sur deux. D'où l'agrégat côté SQL, dont
ceci vérifie que le classement sait se servir.
"""
row = view(xmlid_modules=["aaa_module", "studio_customization"])
self.assertEqual(A.classify(row)[0], "studio")
def test_imported_or_exported(self):
for module in ("__export__", "__import__", "__custom__"):
row = view(xmlid_modules=[module])
self.assertEqual(
A.classify(row)[0], "imported_or_exported", module
)
def test_created_from_the_interface(self):
self.assertEqual(A.classify(view())[0], "ui_created")
def test_theme(self):
self.assertEqual(
A.classify(view(theme_template_id=42))[0], "theme_installed"
)
def test_precedence_website_beats_studio(self):
# Une vue Studio copiée par le site web se regarde d'abord comme une
# copie : c'est ce qui décide si elle survivra à la montée de version.
row = view(
website_id=1,
has_module_twin=True,
xmlid_modules=["studio_customization"],
)
self.assertEqual(A.classify(row)[0], "website_cow_copy")
def test_precedence_theme_beats_website(self):
row = view(theme_template_id=7, website_id=1)
self.assertEqual(A.classify(row)[0], "theme_installed")
def test_reasons_accumulate(self):
row = view(
arch_fs="x.xml",
xmlid_modules=["base"],
arch_updated=True,
noupdate=True,
has_arch_prev=True,
active=False,
)
reasons = A.classify(row)[1]
self.assertEqual(
reasons, ["arch_updated", "noupdate", "has_arch_prev", "inactive"]
)
def test_the_category_is_always_a_known_one(self):
for row in (
view(),
view(theme_template_id=1),
view(website_id=1),
view(xmlid_modules=["base"], arch_fs="x.xml"),
view(xmlid_modules=["studio_customization"]),
):
self.assertIn(A.classify(row)[0], A.CATEGORIES)
class TestCategoryTables(unittest.TestCase):
"""Les trois tables de catégories doivent rester d'accord entre elles."""
def test_every_category_has_a_label(self):
# PAS `set_lang()` : il PERSISTE la langue dans ./env_var.sh, un
# fichier suivi par git. Un test qui l'appelle modifie l'arbre de
# travail et laisse la langue changée pour tout ce qui suit —
# `_current_lang = None` ne défait que la mémoïsation, pas le
# fichier, et la résolution suivante relit celui-ci. On écrit donc
# la mémoïsation directement, et on rend la valeur trouvée.
self.addCleanup(
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
)
todo_i18n._current_lang = "en"
for name in A.CATEGORIES:
self.assertNotEqual(
A.category_label(name),
name,
f"'{name}' n'a pas de libellé traduit",
)
def test_actionable_is_a_subset_of_categories(self):
self.assertEqual(set(A.ACTIONABLE) - set(A.CATEGORIES), set())
def test_plain_module_views_are_never_a_finding(self):
self.assertNotIn("module_view", A.ACTIONABLE)
class TestRender(unittest.TestCase):
def setUp(self):
# PAS `set_lang()` : il PERSISTE la langue dans ./env_var.sh, un
# fichier suivi par git. Un test qui l'appelle modifie l'arbre de
# travail et laisse la langue changée pour tout ce qui suit —
# `_current_lang = None` ne défait que la mémoïsation, pas le
# fichier, et la résolution suivante relit celui-ci. On écrit donc
# la mémoïsation directement, et on rend la valeur trouvée.
self.addCleanup(
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
)
todo_i18n._current_lang = "en"
def data(self, **override):
rows = [
view(id=2, key="sale.view_order_form", arch_updated=True),
view(id=5, key="website.layout", website_id=1),
]
for row in rows:
row["category"], row["reason"] = A.classify(row)
data = {
"tool": "analyse_view_custom",
"version": 1,
"database": "prod_18",
"odoo_version": "18.0.1.3",
"has_website": True,
"compared_with_module_source": False,
"n_views": 40,
"counts": {name: 0 for name in A.CATEGORIES},
"findings": rows,
}
data["counts"]["website_cow_copy"] = 1
data["counts"]["module_view"] = 38
data["counts"]["ui_created"] = 1
data.update(override)
return data
def test_lists_the_findings(self):
out = A.render(self.data())
self.assertIn("sale.view_order_form", out)
self.assertIn("website.layout", out)
def test_points_at_the_existing_cow_tools(self):
# L'inventaire ne rejuge pas les copies : il renvoie vers les outils
# qui tranchent, plutôt que de refaire leur travail à moitié.
out = A.render(self.data())
self.assertIn("check_cow_views.py", out)
self.assertIn("reset_stale_cow_views.py", out)
def test_no_cow_note_without_cow_views(self):
data = self.data()
data["counts"]["website_cow_copy"] = 0
self.assertNotIn("check_cow_views.py", A.render(data))
def test_says_the_flags_are_not_a_verdict(self):
# Sans comparaison, « signalée » n'est pas « modifiée » : le rapport
# doit le dire, sinon il promet plus qu'il ne sait.
self.assertIn("Flags say a view was touched", A.render(self.data()))
def test_clean_database_says_so(self):
data = self.data(findings=[])
out = A.render(data)
self.assertIn("Every view comes straight from a module.", out)
self.assertNotIn("check_cow_views.py", out)
def test_top_truncates_and_says_how_many_are_hidden(self):
out = A.render(self.data(), top=1)
self.assertIn("more", out)
def test_verbose_shows_everything(self):
self.assertNotIn("more", A.render(self.data(), verbose=True))
def test_category_filter(self):
out = A.render(self.data(), category="website_cow_copy")
self.assertIn("website.layout", out)
self.assertNotIn("sale.view_order_form", out)
def test_french_differs(self):
english = A.render(self.data())
todo_i18n._current_lang = "fr" # cf. plus haut : pas de persistance
french = A.render(self.data())
self.assertIn("Copie de site web", french)
self.assertNotEqual(english, french)
if __name__ == "__main__":
unittest.main()

199
test/test_todo_menu.py Normal file
View file

@ -0,0 +1,199 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Le menu Execute : les numéros affichés mènent-ils où ils le disent ?
Le menu est écrit deux fois — une f-string qui affiche « [7] … », et une
chaîne d'`elif status == "7"` qui dispatche. Rien ne les reliait : insérer une
entrée au milieu oblige à décaler les deux à la main, et une seule erreur
envoie l'utilisateur dans le mauvais écran sans que rien ne proteste.
Ce test relit les deux et les apparie. Il ne juge pas le contenu du menu :
ajouter, retirer ou réordonner reste libre, tant que l'affichage et le
dispatch racontent la même histoire.
"""
import ast
import re
import unittest
from pathlib import Path
TODO_PY = (
Path(__file__).resolve().parent.parent / "script" / "todo" / "todo.py"
)
# « [12] {t("Deploy - …")} » en début de ligne, dans la f-string du menu.
RE_SHOWN = re.compile(r'^\[(\d+)\] \{t\("([^"]+)"\)\}', re.M)
# « elif status == "12": » suivi de « status = self.prompt_execute_deploy() »
RE_DISPATCH = re.compile(
r'elif status == "(\d+)":\s*\n\s*status = self\.(\w+)\(\)'
)
def prompt_execute_source():
"""Le corps de prompt_execute(), affichage et dispatch compris."""
source = TODO_PY.read_text(encoding="utf-8")
start = source.index("def prompt_execute(self):")
end = source.index("def prompt_install(self):", start)
return source[start:end]
class TestExecuteMenuNumbering(unittest.TestCase):
def setUp(self):
self.body = prompt_execute_source()
# [0] Retour est traité par le « if status == "0" » qui précède la
# chaîne d'elif : il s'affiche mais n'a pas de branche de dispatch.
self.shown = [
(int(num), label)
for num, label in RE_SHOWN.findall(self.body)
if num != "0"
]
self.dispatch = [
(int(num), method)
for num, method in RE_DISPATCH.findall(self.body)
]
def test_the_menu_was_actually_parsed(self):
# Si la forme du menu change, ce test doit tomber ici plutôt que de
# déclarer « tout va bien » sur une liste vide.
self.assertGreater(len(self.shown), 5)
self.assertEqual(len(self.shown), len(self.dispatch))
def test_zero_is_handled_before_the_elif_chain(self):
self.assertIn('if status == "0":', self.body)
def test_numbering_is_contiguous_from_one(self):
numbers = [num for num, _ in self.shown]
self.assertEqual(numbers, list(range(1, len(numbers) + 1)))
def test_every_shown_entry_has_the_matching_dispatch(self):
self.assertEqual(
[num for num, _ in self.shown],
[num for num, _ in self.dispatch],
)
def test_no_dispatch_branch_is_unreachable(self):
shown = {num for num, _ in self.shown}
for num, method in self.dispatch:
self.assertIn(
num,
shown,
f"la branche [{num}] -> {method} n'est affichée nulle part",
)
# Chaque entrée du menu et la méthode qu'elle DOIT atteindre, par le début
# de son libellé. Sans cette table, le test ne vérifie que l'alignement des
# numéros — et laisse passer le défaut même qu'une renumérotation produit :
# une entrée qui garde son rang mais atterrit dans le mauvais écran.
#
# Une renumérotation, l'opération risquée, ne touche PAS cette table. Ajouter
# ou retirer une entrée demande d'y toucher, et c'est voulu : c'est le seul
# moment où quelqu'un doit dire où mène la nouvelle entrée.
EXPECTED = {
"Code": "prompt_execute_code",
"Config": "prompt_execute_config",
"Run": "prompt_execute_instance",
"Test": "prompt_execute_test",
"Process": "prompt_execute_process",
"Database": "prompt_execute_database",
"Analyse": "prompt_execute_analyse",
"Git": "prompt_execute_git",
"Doc": "prompt_execute_doc",
"GPT code": "prompt_execute_gpt_code",
"Automation": "prompt_execute_function",
"Deploy": "prompt_execute_deploy",
"Network": "prompt_execute_network",
"Security": "prompt_execute_security",
"Language": "_change_language",
}
def _entry_key(self, label):
"""« Doc - Documentation search » -> « Doc »."""
return label.split(" - ", 1)[0].strip()
def test_every_entry_reaches_the_method_it_names(self):
dct_dispatch = dict(self.dispatch)
for num, label in self.shown:
key = self._entry_key(label)
self.assertIn(
key,
self.EXPECTED,
f"entrée [{num}] « {label} » absente de EXPECTED :"
" déclarez où elle mène",
)
self.assertEqual(
dct_dispatch.get(num),
self.EXPECTED[key],
f"[{num}] « {label} » mène à"
f" {dct_dispatch.get(num)} au lieu de {self.EXPECTED[key]}",
)
def test_expected_table_has_no_stale_entry(self):
# Une entrée retirée du menu doit sortir d'EXPECTED, sinon la table
# devient un cimetière qui ne protège plus rien.
shown_keys = {self._entry_key(label) for _, label in self.shown}
self.assertEqual(set(self.EXPECTED) - shown_keys, set())
class TestMenuLabels(unittest.TestCase):
"""Toute méthode de menu doit avoir son étiquette de fil d'Ariane.
Sans elle, `_menu_header` n'affiche pas le segment et
`todo_telemetry.build_code_tree` traite le menu comme une COMMANDE :
il apparaît en feuille, sous son nom de méthode brut. Trois menus en
souffrent déjà — la liste est figée ici pour que le nombre ne grandisse
pas, pas pour bénir ce qu'elle contient.
"""
KNOWN_MISSING = {
"prompt_execute_test",
"prompt_execute_network",
"prompt_execute_security",
}
def setUp(self):
source = TODO_PY.read_text(encoding="utf-8")
tree = ast.parse(source)
cls = next(n for n in ast.walk(tree) if isinstance(n, ast.ClassDef))
self.labels = set()
for node in cls.body:
if not isinstance(node, ast.Assign):
continue
if not any(
isinstance(tg, ast.Name) and tg.id == "_MENU_LABELS"
for tg in node.targets
):
continue
self.labels = {
key.value
for key in node.value.keys
if isinstance(key, ast.Constant)
}
self.dispatched = {
method
for _, method in RE_DISPATCH.findall(prompt_execute_source())
}
def test_menu_labels_was_parsed(self):
self.assertIn("prompt_execute", self.labels)
def test_analyse_menu_has_a_breadcrumb_label(self):
self.assertIn("prompt_execute_analyse", self.labels)
def test_no_new_menu_forgets_its_label(self):
submenus = {
method
for method in self.dispatched
if method.startswith("prompt_execute_")
}
missing = submenus - self.labels - self.KNOWN_MISSING
self.assertEqual(
missing,
set(),
f"menus sans étiquette dans _MENU_LABELS : {sorted(missing)}",
)
if __name__ == "__main__":
unittest.main()