Nine of the thirteen carried a shebang without the bit, so only four could be run by their own path. Set to 755, not chmod +x: two were 664 under the 0002 umask and would have become 775 — executed by others while a group member could still rewrite them. That pairing is the only real risk here; the bit alone grants nothing, since reading the file is enough to run python3 on it. Being runnable opens a path without the venv: the shebang resolves to the system python3, which has no Textual. --tui fell back to the text report saying nothing. It now names the interpreter and the venv. --- FR --- [FIX] script : rendre exécutables les outils d'analyse et de migration Neuf des treize portaient un shebang sans le bit ; quatre seulement se lançaient par leur chemin. Mis à 755, pas chmod +x : deux étaient en 664 sous l'umask 0002 et seraient passés à 775 — exécutés par d'autres alors qu'un membre du groupe pouvait encore les réécrire. C'est la seule vraie prise ici ; le bit seul n'accorde rien, lire le fichier suffit déjà à lancer python3 dessus. Devenir lançable ouvre un chemin sans le venv : le shebang résout le python du système, sans Textual. --tui retombait sur le rapport texte sans rien dire. Il nomme désormais l'interpréteur et le venv. Assisted-by: Claude Opus 5
872 lines
33 KiB
Python
Executable file
872 lines
33 KiB
Python
Executable file
#!/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()
|