[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
2026-08-10 03:10:50 -04:00
|
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
|
|
|
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
|
|
|
|
|
|
|
|
"""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 = [
|
[REF] format : passer l'outillage et les tests sous ruff
Le formateur de ce dépôt est ruff depuis qu'il remplace black, qui ne connaît
aucune cible au-delà de py313 ; ce passage applique sa norme à l'arbre entier,
d'un coup, pour qu'aucun commit de fond n'ait à porter du style. L'écart tient
presque entièrement aux chaînes coupées à la main que ruff recolle quand elles
tiennent sur une ligne, et aux « with » multiples qu'il regroupe : aucune
valeur ne change, et les clés de traduction non plus.
Vérifié : la suite unitaire reste verte après le passage, et le contrôle de
syntaxe ne signale rien.
--- EN ---
This repository's formatter is ruff since it replaced black, which knows no
target beyond py313; this pass applies its standard to the whole tree at once,
so that no substantive commit has to carry style. The difference is almost
entirely the hand-split strings ruff joins back when they fit on one line, and
the multiple "with" it merges: no value changes, nor do the translation keys.
Checked: the unit suite stays green after the pass, and the syntax check
reports nothing.
Assisted-by: Claude Opus 5
2026-09-24 13:30:31 -04:00
|
|
|
|
f" {'table':<34}{'total':>10}{'heap':>10}{'index':>10}{t('rows'):>14}"
|
[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
2026-08-10 03:10:50 -04:00
|
|
|
|
]
|
|
|
|
|
|
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())
|