erplibre/script/analyse/anonymize.py
Mathieu Benoit 2437a9b5ea [FIX] analyse: ne pas écrire de mots dans les champs qui portent des ids
Odoo déclare `parent_path` en `char` mais y range un chemin d'identifiants
— « 1/7/12/ » — reparsé aussitôt par int() : un mot écrit là casse le premier
chargement de page. Sept modèles `_parent_store` en portent un dans une base
ordinaire, et account_payment_term passe de même `days_next_month` à int().

L'anonymisation écarte les deux noms connus, puis sonde le contenu : une colonne
dont chaque valeur est un chemin reste intacte, même dans un module maison. Les
barres obliques sont exigées : un numéro tout en chiffres y échapperait.

Vérifié sur une base de production : les sept modèles chargent, les
partenaires sont anonymisés.

--- EN ---

Odoo declares `parent_path` as `char` but stores an identifier path in it —
« 1/7/12/ » — parsed right back with int(): a word written there breaks the
first page load. Seven `_parent_store` models carry one in an ordinary
database, and account_payment_term likewise passes `days_next_month` to int().

Anonymisation skips the two known names, then probes the content: a column
whose every value is a path is left alone, even in an in-house module. The
slashes are required: a number of pure digits would escape it.

Verified on a production database: the seven models load, partners are
anonymised.

Assisted-by: Claude Opus 5
(cherry picked from commit b47c94524d75172e7afce96e62f3f15b7ad6e225)
2026-08-29 02:11:04 -04:00

725 lines
26 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)
"""Remplacer les données d'une copie par des données sans intérêt.
Aucune IA, aucun appel réseau : des mots pris dans une liste et des
nombres tirés au hasard, écrits par des UPDATE SQL. Ce qui rend la chose
délicate n'est pas le remplacement, c'est de savoir CE QU'ON N'A PAS LE
DROIT DE TOUCHER.
Quatre pièges, tous mesurés sur une base réelle
-----------------------------------------------
1. « Tous les champs string » n'existe pas. 505 champs `selection` sont
stockés en varchar : `res.partner.lang`, `sale.order.invoice_status`.
Y écrire un mot au hasard casse l'ORM, pas la confidentialité. On ne
se fie donc jamais au type PostgreSQL seul, mais au `ttype` que
`ir_model_fields` déclare.
2. 2693 champs `many2one` sont des ENTIERS. Les tirer au hasard
mélangerait toutes les relations de la base. Les nombres qu'on touche
sont ceux qu'Odoo appelle integer, float ou monetary — jamais une
relation.
3. 194 champs texte sont en `jsonb` depuis Odoo 17, un objet par langue.
Écrire une chaîne par-dessus détruit la colonne ; on reconstruit
l'objet, clé par clé. C'est le piège qui a déjà coûté un /contact
réparé en anglais et resté cassé en français.
4. 301 contraintes d'unicité. Deux lignes qui reçoivent le même mot font
échouer tout l'UPDATE. Sur une colonne unique, l'identifiant est
collé au mot.
Ce qu'on ne touche jamais
-------------------------
Les modèles `ir.*` — vues, champs, xmlid : la base ne s'ouvrirait plus.
Les langues, devises et pays : ce ne sont pas des données personnelles,
et les casser casse les adresses et les montants. Cette liste est un
PLANCHER : aucune liste blanche ne la lève.
`res.users.login` et le mot de passe restent en place par défaut. On
anonymise pour POUVOIR partager une copie utilisable ; personne ne
pourrait plus s'y connecter. `--include-logins` pour l'autre choix.
Rien n'est écrit sans `--apply` ET `--confirm <nom de la base>`.
"""
from __future__ import annotations
import os
import sys
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
)
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - repli si i18n indisponible
def t(key: str) -> str:
return key
from script.analyse import lib_analyse # noqa: E402
TYPES_TEXTE = ("char", "text", "html")
TYPES_NOMBRE = ("integer", "float", "monetary")
# Le plancher : aucune liste blanche ne le lève.
PREFIXES_INTERDITS = ("ir.",)
MODELES_INTERDITS = frozenset(
{
"res.groups",
"res.lang",
"res.currency",
"res.currency.rate",
"res.country",
"res.country.state",
"res.country.group",
"res.config.settings",
"base.language.install",
"decimal.precision",
"uom.uom",
"uom.category",
}
)
CHAMPS_INTERDITS = frozenset(
{
"id",
"create_uid",
"write_uid",
"create_date",
"write_date",
"sequence",
"active",
"display_name",
"__last_update",
"arch_db",
"arch_fs",
"key",
"model",
"res_model",
"res_field",
"state",
"color",
"company_id",
}
)
CHAMPS_CONNEXION = frozenset({"login", "password"})
# Des `char` d'Odoo qui portent une STRUCTURE, pas du texte. Odoo les
# reparse, et un mot y fait lever le serveur entier :
# parent_path → `int(id) for id in parent_path.split('/')`
# (base/models/res_company.py:117, models.py:203)
# days_next_month → `int(self.days_next_month)`
# (account/models/account_payment_term.py:321)
# `parent_path` existe sur tout modèle `_parent_store` — sept dans une
# base ordinaire. La sonde de contenu ci-dessous les retrouverait, mais
# les nommer coûte moins cher qu'une requête et ne dépend pas des données
# présentes le jour où l'on passe.
CHAMPS_STRUCTURES = frozenset({"parent_path", "days_next_month"})
# Un chemin d'identifiants : « 1/ », « 1/7/12/ ». Le motif exige les
# barres obliques — sans elles, un numéro de téléphone tout en chiffres
# serait pris pour une structure et échapperait à l'anonymisation, ce
# qui serait un défaut de confidentialité, pas de robustesse.
MOTIF_CHEMIN = r"^[0-9]+(/[0-9]+)*/$"
# Le point de départ du mode hybride : ce qui porte des données
# personnelles dans une base Odoo ordinaire.
MODELES_PAR_DEFAUT = (
"res.partner",
"res.users",
"res.company",
"res.bank",
"res.partner.bank",
"crm.lead",
"hr.employee",
"mail.message",
"mail.tracking.value",
"account.move",
"sale.order",
"purchase.order",
"project.task",
"calendar.event",
"survey.user_input",
)
MOTS_PAR_DEFAUT = (
"alouette",
"bruyere",
"cascade",
"dolmen",
"erable",
"fougere",
"givre",
"hameau",
"iris",
"jonquille",
"lichen",
"marais",
"nenuphar",
"orme",
"pinson",
"roseau",
"sureau",
"tourbe",
"varech",
"zephyr",
)
MODES = ("whitelist", "blacklist", "hybrid")
def modele_interdit(modele):
"""Le plancher, en une question."""
if any(modele.startswith(p) for p in PREFIXES_INTERDITS):
return True
return modele in MODELES_INTERDITS
def choisir_modeles(tous, mode, whitelist=(), blacklist=(), defauts=None):
"""Quels modèles anonymiser, selon le mode.
whitelist : ceux-là et rien d'autre.
blacklist : tous SAUF ceux-là.
hybrid : la liste par défaut, plus la blanche, moins la noire —
c'est le mode utile en pratique : on part de ce qui porte
des données personnelles et on ajuste aux marges.
Le plancher s'applique aux trois : un modèle `ir.*` ne passe par
aucun chemin, même nommé explicitement.
"""
tous = set(tous)
blanche, noire = set(whitelist), set(blacklist)
if mode == "whitelist":
choisis = blanche & tous
elif mode == "blacklist":
choisis = tous - noire
elif mode == "hybrid":
base = set(MODELES_PAR_DEFAUT if defauts is None else defauts)
choisis = ((base | blanche) - noire) & tous
else:
raise ValueError(f"{t('Unknown mode:')} {mode}")
return sorted(m for m in choisis if not modele_interdit(m))
def champ_retenu(champ, inclure_connexion=False):
"""Ce champ-là se remplace-t-il ?
`champ` : dict avec model, name, ttype, pg_type, unique.
"""
if champ["name"] in CHAMPS_INTERDITS:
return False
if champ["name"] in CHAMPS_CONNEXION and not inclure_connexion:
return False
if champ["name"].endswith("_id") or champ["name"].endswith("_ids"):
# Une relation qui aurait échappé au filtre de ttype.
return False
if champ["name"] in CHAMPS_STRUCTURES:
return False
if champ.get("checked"):
# Une contrainte CHECK hors de portée. Mesuré, et la distinction
# compte : sur un NOMBRE toute contrainte borne la valeur —
# crm_lead.probability entre 0 et 100, credit * debit = 0 — et un
# tirage à 1000 fait échouer l'UPDATE, donc toute l'anonymisation.
# Sur du TEXTE, presque toutes ne garantissent que la non-nullité :
# `res_partner.name` n'exige d'être non nul que pour un contact, ce
# qu'un mot satisfait. Les écarter TOUTES laissait le champ le plus
# important de la base intact — une anonymisation qui n'anonymisait
# pas les noms. La requête ne lève donc ce drapeau, pour du texte,
# que sur les contraintes de FORME.
return False
if champ["ttype"] in TYPES_NOMBRE and champ.get("pg_type") == "jsonb":
# Mesuré sur res_partner.credit_limit : un `float` d'Odoo peut
# vivre dans un jsonb par société. Y écrire un nombre nu ferait
# échouer l'UPDATE — et donc, transaction unique oblige, TOUTE
# l'anonymisation. On s'abstient plutôt que de deviner sa forme.
return False
return champ["ttype"] in TYPES_TEXTE + TYPES_NOMBRE
def mots_pour(nom_champ, mots):
"""La liste de mots à utiliser pour ce champ.
`mots` peut être une simple liste — la même partout — ou un
dictionnaire par nom de champ avec une entrée `*` en repli. Le
dictionnaire permet de garder des courriels qui ressemblent à des
courriels, ce qu'une liste unique ne sait pas faire.
"""
if isinstance(mots, dict):
choix = mots.get(nom_champ) or mots.get("*") or MOTS_PAR_DEFAUT
else:
choix = mots or MOTS_PAR_DEFAUT
return tuple(str(m) for m in choix) or MOTS_PAR_DEFAUT
def litteral(texte):
"""Un littéral SQL. Le seul endroit où du texte entre dans la requête."""
return "'" + str(texte).replace("'", "''") + "'"
def ident(nom):
"""Un identifiant SQL, cité.
Odoo laisse nommer un champ `user`, `order` ou `group` : ce sont des
mots réservés de PostgreSQL, et un identifiant nu fait échouer
l'analyse syntaxique — donc, transaction unique oblige, TOUTE
l'anonymisation. Mesuré en liste noire sur une base réelle :
« syntax error at or near "user" ». Les citer coûte deux caractères
et ferme la question pour tous les noms à venir.
"""
return '"' + str(nom).replace('"', '""') + '"'
def expression_texte(champ, mots):
"""Le SQL qui remplace un champ texte, en préservant les NULL.
Un NULL qui deviendrait un mot créerait de la donnée là où il n'y en
avait pas : la copie mentirait dans l'autre sens.
"""
nom = ident(champ["name"])
liste = mots_pour(champ["name"], mots)
# Les parenthèses ne sont pas décoratives : PostgreSQL refuse
# d'indexer un constructeur ARRAY[...] directement.
tableau = "(ARRAY[" + ",".join(litteral(m) for m in liste) + "])"
tirage = f'{tableau}[("id" % {len(liste)}) + 1]'
borne = champ.get("max_len")
if champ.get("unique"):
# Deux lignes qui reçoivent le même mot feraient échouer TOUT
# l'UPDATE sur une colonne unique. L'identifiant vient EN TÊTE
# quand la colonne est bornée : c'est lui qui porte l'unicité, et
# une troncature par la droite doit le laisser intact.
if borne:
tirage = f"\"id\"::text || '-' || {tirage}"
else:
tirage = f"{tirage} || '-' || \"id\"::text"
if borne:
# varchar(n) : mesuré, 13 colonnes sont bornées sur une base
# réelle, dont des codes à 1, 2 et 3 caractères. « jonquille »
# dans un varchar(3) fait échouer l'UPDATE entier.
tirage = f"left({tirage}, {borne})"
if champ.get("pg_type") == "jsonb":
# Un objet par langue depuis Odoo 17 : on le reconstruit clé à
# clé. Écrire une chaîne par-dessus détruirait la colonne.
return (
f"CASE WHEN {nom} IS NULL THEN NULL ELSE"
f" (SELECT jsonb_object_agg(kv.key, {tirage})"
f" FROM jsonb_each_text({nom}) AS kv) END"
)
return f"CASE WHEN {nom} IS NULL THEN NULL ELSE {tirage} END"
def expression_nombre(champ):
"""Le SQL qui remplace un nombre : au hasard, entre 0 et 1000."""
nom = ident(champ["name"])
if champ["ttype"] == "integer":
tirage = "floor(random() * 1001)::integer"
else:
tirage = "round((random() * 1000)::numeric, 2)"
return f"CASE WHEN {nom} IS NULL THEN NULL ELSE {tirage} END"
def sql_pour_table(table, champs, mots):
"""Un seul UPDATE par table : toutes ses colonnes d'un coup."""
morceaux = []
for champ in champs:
if champ["ttype"] in TYPES_TEXTE:
valeur = expression_texte(champ, mots)
else:
valeur = expression_nombre(champ)
morceaux.append(f"{ident(champ['name'])} = {valeur}")
if not morceaux:
return None
return f"UPDATE {ident(table)} SET " + ", ".join(morceaux) + ";"
def table_de(modele):
"""Le nom de table qu'Odoo donne à ce modèle."""
return modele.replace(".", "_")
SEP = "\x1f"
# On croise TROIS sources, et c'est la raison d'être de cette requête :
# `ir_model_fields` dit ce qu'Odoo croit (le ttype, seul capable de
# distinguer un `selection` d'un vrai texte), `pg_attribute` dit ce que
# PostgreSQL a vraiment (jsonb ou varchar), et `pg_constraint` dit ce qui
# doit rester unique. Aucune des trois ne suffit seule.
REQUETE_CHAMPS = """
SELECT f.model || '\x1f' || f.name || '\x1f' || f.ttype || '\x1f'
|| a.atttypid::regtype::text || '\x1f'
|| CASE WHEN EXISTS (
SELECT 1 FROM pg_constraint k
WHERE k.conrelid = c.oid
AND k.contype IN ('u', 'p')
AND a.attnum = ANY(k.conkey)
) THEN '1' ELSE '0' END || '\x1f'
|| CASE WHEN EXISTS (
SELECT 1 FROM pg_constraint k
WHERE k.conrelid = c.oid
AND k.contype = 'c'
AND a.attnum = ANY(k.conkey)
AND (
-- Sur un NOMBRE, toute contrainte borne la valeur :
-- `credit * debit = 0`, `amount >= 0`. On s'abstient.
f.ttype IN ('integer','float','monetary')
-- Sur du TEXTE, presque toutes ne garantissent que la
-- non-nullité, ce qu'un mot satisfait. Seules celles
-- qui contraignent la FORME sont hors de portée.
OR pg_get_constraintdef(k.oid) ~
'char_length|~~|jsonb_typeof|similar to'
)
) THEN '1' ELSE '0' END || '\x1f'
|| CASE WHEN a.atttypmod > 4
THEN (a.atttypmod - 4)::text ELSE '' END
FROM ir_model_fields f
JOIN pg_class c ON c.relname = replace(f.model, '.', '_')
AND c.relkind = 'r'
JOIN pg_attribute a ON a.attrelid = c.oid
AND a.attname = f.name
AND a.attnum > 0
AND NOT a.attisdropped
WHERE f.store
AND f.ttype IN ('char','text','html','integer','float','monetary')
ORDER BY f.model, f.name
"""
def inspect(database, config_path=None):
"""Tous les champs remplaçables de la base, avec leurs trois vérités."""
brut = lib_analyse.run_psql(
database, REQUETE_CHAMPS, config_path=config_path
)
champs = []
for ligne in brut.splitlines():
parts = ligne.split(SEP)
if len(parts) != 7:
continue
champs.append(
{
"model": parts[0],
"name": parts[1],
"ttype": parts[2],
"pg_type": parts[3],
"unique": parts[4] == "1",
"checked": parts[5] == "1",
# varchar(n) : n, sinon None. Mesuré sur une base réelle,
# 13 colonnes sont bornées — dont des codes à 1, 2 et 3
# caractères. Y écrire « jonquille » fait échouer tout
# l'UPDATE, et donc toute l'anonymisation.
"max_len": int(parts[6]) if parts[6].isdigit() else None,
}
)
return champs
def plan(
champs,
mode,
whitelist=(),
blacklist=(),
inclure_connexion=False,
mots=None,
):
"""Ce qui sera écrit, table par table — avant d'écrire quoi que ce soit.
Rendu séparément de l'exécution pour que le mode « à blanc » montre
EXACTEMENT ce que `--apply` ferait, et non une approximation.
"""
modeles = choisir_modeles(
{c["model"] for c in champs}, mode, whitelist, blacklist
)
retenus = set(modeles)
par_modele = {}
for champ in champs:
if champ["model"] not in retenus:
continue
if not champ_retenu(champ, inclure_connexion):
continue
par_modele.setdefault(champ["model"], []).append(champ)
etapes = []
for modele in modeles:
liste = par_modele.get(modele)
if not liste:
continue
sql = sql_pour_table(table_de(modele), liste, mots)
if sql:
etapes.append({"model": modele, "fields": liste, "sql": sql})
return etapes
def colonnes_structurees(database, etapes, config_path=None):
"""Les colonnes texte dont TOUT le contenu est un chemin d'identifiants.
Le filet général, là où `CHAMPS_STRUCTURES` ne nomme que le connu :
un module maison peut poser son propre champ de chemin, et il ne
portera pas ce nom-là. On regarde donc ce que la colonne CONTIENT.
Une colonne est écartée seulement si elle est NON VIDE et que toutes
ses valeurs sont des chemins. Un seul contre-exemple suffit à la
garder : mieux vaut anonymiser une colonne douteuse que taire une
donnée personnelle.
Une requête par TABLE, pas par colonne : sur une liste noire de 410
modèles, la différence est de 410 allers-retours au lieu de 863.
"""
ecartees = {}
for etape in etapes:
textes = [
champ
for champ in etape["fields"]
if champ["ttype"] in TYPES_TEXTE and champ["pg_type"] != "jsonb"
]
if not textes:
continue
morceaux = []
for champ in textes:
nom = ident(champ["name"])
morceaux.append(
f"count(*) FILTER (WHERE {nom} IS NOT NULL AND {nom} <> '')"
f" || ':' || count(*) FILTER (WHERE {nom} ~ {litteral(MOTIF_CHEMIN)})"
)
sql = (
"SELECT "
+ " || '\x1f' || ".join(morceaux)
+ f" FROM {ident(table_de(etape['model']))}"
)
try:
brut = lib_analyse.run_psql(
database, sql, config_path=config_path
).strip()
except Exception: # noqa: BLE001 - une table illisible ne bloque pas
continue
for champ, mesure in zip(textes, brut.split(SEP)):
try:
remplies, chemins = (int(x) for x in mesure.split(":"))
except ValueError:
continue
if remplies and remplies == chemins:
ecartees.setdefault(etape["model"], []).append(champ["name"])
return ecartees
def sans_structurees(etapes, ecartees, mots):
"""Refaire le plan sans les colonnes que la sonde a écartées."""
if not ecartees:
return etapes
propre = []
for etape in etapes:
exclues = set(ecartees.get(etape["model"], ()))
gardes = [c for c in etape["fields"] if c["name"] not in exclues]
# Pas de garde sur une liste vide : `sql_pour_table` rend None, et
# le `if sql` ci-dessous l'écarte. Deux vérifications pour la même
# chose se contredisent un jour.
sql = sql_pour_table(table_de(etape["model"]), gardes, mots)
if sql:
propre.append({**etape, "fields": gardes, "sql": sql})
return propre
def render(etapes, applique=False, verbeux=False):
"""Le rapport. Il dit ce qui est ÉCARTÉ autant que ce qui est pris."""
if not etapes:
return f"✅ {t('Nothing to anonymise with these lists.')}"
total = sum(len(e["fields"]) for e in etapes)
tete = (
f"🎭 {len(etapes)} {t('model(s)')}, {total} {t('column(s)')}"
f" — {t('written') if applique else t('dry run, nothing written')}"
)
lignes = [tete, ""]
for etape in etapes:
textes = [f for f in etape["fields"] if f["ttype"] in TYPES_TEXTE]
nombres = [f for f in etape["fields"] if f["ttype"] in TYPES_NOMBRE]
traduits = [f for f in textes if f["pg_type"] == "jsonb"]
uniques = [f for f in etape["fields"] if f["unique"]]
detail = f"{len(textes)} {t('text')}, {len(nombres)} {t('numeric')}"
if traduits:
detail += f", {len(traduits)} {t('translated (jsonb)')}"
if uniques:
detail += f", {len(uniques)} {t('unique')}"
lignes.append(f" {etape['model']:<34} {detail}")
if verbeux:
for champ in etape["fields"]:
lignes.append(
f" {champ['name']:<30} {champ['ttype']}"
f" / {champ['pg_type']}"
)
if not applique:
lignes.append("")
lignes.append(f" {t('Use --apply --confirm <database> to write.')}")
return "\n".join(lignes)
def charger_mots(chemin):
"""Lire un fichier Python qui déclare MOTS.
Une liste — les mêmes mots partout — ou un dictionnaire par nom de
champ avec un repli `*`. Aucun réseau, aucun modèle : des mots.
"""
if not chemin:
return None
espace = {}
with open(chemin, "r", encoding="utf-8") as handle:
exec(compile(handle.read(), chemin, "exec"), espace) # noqa: S102
mots = espace.get("MOTS")
if not mots:
raise ValueError(f"{t('This file declares no MOTS:')} {chemin}")
return mots
def ecrire(database, etapes, config_path=None, timeout=900):
"""Écrire les UPDATE — TOUS, ou AUCUN. None si tout a réussi.
Une seule transaction (`-1`) et `ON_ERROR_STOP=1`. Sans cela, une
collision d'unicité au dixième modèle laisserait une base à moitié
anonymisée — c'est-à-dire une base dont plus personne ne peut dire ce
qui est vrai, et que rien ne rattrape sinon une restauration.
On reprend `pg_env` pour la connexion — hôte, port, mot de passe lus
dans config.conf — et l'on ne lève QUE la lecture seule. La
redéclarer ici, ce serait accepter qu'elle diverge un jour.
"""
import subprocess
import tempfile
env = lib_analyse.pg_env(config_path, timeout=timeout)
env["PGOPTIONS"] = f"-c statement_timeout={timeout}s"
sql = "\n".join(etape["sql"] for etape in etapes)
# PAR FICHIER, jamais par `-c`. Linux plafonne un seul argument à
# MAX_ARG_STRLEN — 32 pages, soit 131 072 octets. Mesuré sur une base
# réelle : le mode hybride tient dans 58 Ko et passait, la liste noire
# produit 342 Ko sur 410 modèles et rendait « OSError: [Errno 7]
# Argument list too long ». Le mode qui couvre le plus est justement
# celui qui cassait.
#
# `-f` plutôt que l'entrée standard : `--single-transaction` n'est
# documenté qu'avec `-c` ou `-f`, et c'est lui qui garantit le tout
# ou rien. Le perdre en silence serait pire que le message d'erreur.
handle = tempfile.NamedTemporaryFile(
mode="w",
suffix=".sql",
prefix="el_anonymize_",
encoding="utf-8",
delete=False,
)
try:
handle.write(sql)
handle.close()
done = subprocess.run(
[
"psql",
"-X",
"-w",
"-1",
"-v",
"ON_ERROR_STOP=1",
"-d",
database,
"-tA",
"-f",
handle.name,
],
capture_output=True,
text=True,
env=env,
timeout=timeout + 60,
)
finally:
try:
os.unlink(handle.name)
except OSError:
pass
if done.returncode:
detail = (done.stderr or "").strip().splitlines()
return detail[0][:200] if detail else "psql"
return None
def main(argv=None):
import argparse
parser = argparse.ArgumentParser(
description=t("Replace the data of a COPY with meaningless data."),
)
parser.add_argument("-d", "--database", required=True)
parser.add_argument("-c", "--config", help="odoo config file")
parser.add_argument("--mode", choices=MODES, default="hybrid")
parser.add_argument(
"--models", default="", help=t("comma separated, adds to the mode")
)
parser.add_argument(
"--exclude", default="", help=t("comma separated, removed from it")
)
parser.add_argument("--words", help=t("python file declaring MOTS"))
parser.add_argument("--include-logins", action="store_true")
parser.add_argument("--apply", action="store_true")
parser.add_argument(
"--confirm",
default="",
help=t("repeat the database name; --apply refuses without it"),
)
parser.add_argument("--verbose", action="store_true")
args = parser.parse_args(argv)
# Le garde-fou, AVANT toute lecture : personne ne doit découvrir en
# route qu'il a lancé cela sur la mauvaise base.
if args.apply and args.confirm != args.database:
print(
f"❌ {t('Refusing to write: --confirm must repeat')}"
f" '{args.database}'.",
file=sys.stderr,
)
return 2
try:
lib_analyse.require_odoo_database(
args.database, config_path=args.config
)
except Exception as exc: # noqa: BLE001
print(f"❌ {exc}", file=sys.stderr)
return 2
try:
mots = charger_mots(args.words)
champs = inspect(args.database, args.config)
except Exception as exc: # noqa: BLE001
print(f"❌ {exc}", file=sys.stderr)
return 2
etapes = plan(
champs,
args.mode,
[m.strip() for m in args.models.split(",") if m.strip()],
[m.strip() for m in args.exclude.split(",") if m.strip()],
args.include_logins,
mots,
)
# La sonde AVANT le rendu : la marche à blanc doit montrer ce que
# `--apply` ferait, pas une approximation plus large.
ecartees = colonnes_structurees(args.database, etapes, args.config)
etapes = sans_structurees(etapes, ecartees, mots)
if ecartees:
combien = sum(len(v) for v in ecartees.values())
print(
f"🧭 {combien} {t('column(s) hold identifier paths and are left')}"
f" {t('alone:')}"
)
for modele in sorted(ecartees):
print(f" {modele} : {', '.join(sorted(ecartees[modele]))}")
print()
if not args.apply:
print(render(etapes, applique=False, verbeux=args.verbose))
return 1 if etapes else 0
erreur = ecrire(args.database, etapes, args.config)
if erreur:
print(f"❌ {t('Nothing was written:')} {erreur}", file=sys.stderr)
return 2
print(render(etapes, applique=True, verbeux=args.verbose))
return 0
if __name__ == "__main__":
sys.exit(main())