erplibre/script/analyse/analyse_view_custom.py

997 lines
37 KiB
Python
Raw Normal View History

#!/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
[ADD] analyse: diff each website copy against the view it shadows The analysis counted 62 website copies and pointed at other tools to judge them. But the comparison that matters for a copy needs no registry at all: both arches are in the database, paired by key — the very pairing Odoo makes. So it works where the module-source comparison is refused, on a database whose version differs from the checkout, and on a backup zip. On the 12.0 database at hand, where the other comparison cannot run: 47 of the 62 copies have a twin, all 47 compared in under a second, 12 differ. The other 35 are byte-for-byte identical to their module view — they carry no customization at all, which is what decides whether neutralizing costs anything. The fields are named as the module comparison names them, so the full-screen browser and the text report work without knowing which comparison produced the data. A copy without a twin is left alone: it is a page made in the editor, with nothing to compare against. Checked on that database and on a real backup; 8 tests, including that re-indentation alone is not a difference and that a missing arch yields no verdict rather than « identical ». --- FR --- L'analyse comptait 62 copies de site web et renvoyait vers d'autres outils pour les juger. Or la comparaison qui compte pour une copie n'a besoin d'aucun registre : les deux arch sont dans la base, appariées par la clé — exactement l'appariement que fait Odoo. Elle marche donc là où celle avec la source du module est refusée : une base dont la version diffère du checkout, et une sauvegarde zip. Sur la base 12.0 en cours, où l'autre comparaison ne peut pas tourner : 47 des 62 copies ont une jumelle, les 47 comparées en moins d'une seconde, 12 diffèrent. Les 35 autres sont identiques octet pour octet à leur vue de module — elles ne portent aucune personnalisation, ce qui décide si les neutraliser coûte quelque chose. Les champs portent les noms de la comparaison avec le module, donc l'écran de navigation et le rapport texte marchent sans savoir laquelle des deux a produit la donnée. Une copie sans jumelle est laissée telle quelle : c'est une page faite dans l'éditeur, il n'y a rien à quoi la comparer. Vérifié sur cette base et sur une vraie sauvegarde ; 8 tests, dont qu'une ré-indentation seule n'est pas un écart et qu'une arch absente ne rend aucun verdict plutôt que « identique ». Assisted-by: Claude Opus 5
2026-08-12 00:54:11 -04:00
def attach_cow_twin_diff(lst_finding, dct_twin, dct_copy_arch):
"""Comparer chaque copie COW à la vue de module qu'elle masque.
C'est LA comparaison qui compte pour une copie, et elle n'a besoin d'aucun
registre : les deux côtés sont dans la base, appariés par leur clé —
exactement l'appariement que fait Odoo, et la raison pour laquelle
renommer une clé suffit à désapparier une copie.
Elle marche donc là où la comparaison avec la source du module est
impossible : une base dont la version diffère du checkout, et une
sauvegarde .zip, qui portent l'une comme l'autre les deux arch.
Une copie sans jumelle est laissée telle quelle : c'est une page faite
dans l'éditeur web, il n'y a rien à quoi la comparer.
Fonction pure — les deux provenances lui passent leurs dictionnaires et
concluent donc la même chose des mêmes faits. Renvoie le nombre de copies
comparées.
"""
n_compared = 0
for row in lst_finding:
if row["category"] != "website_cow_copy":
continue
twin = dct_twin.get(row.get("key"))
if not twin:
continue
twin_id, twin_arch = twin
copy_arch = dct_copy_arch.get(row["id"])
if copy_arch is None:
continue
differs, comparable = arch_differs(twin_arch, copy_arch)
# Mêmes noms de champs que la comparaison avec la source du module :
# l'écran de navigation et le rendu texte marchent alors sans savoir
# laquelle des deux a produit la donnée.
row["arch_ref"] = twin_arch
row["arch_db_text"] = copy_arch
row["twin_id"] = twin_id
row["comparable"] = comparable
row["differs"] = differs
if comparable:
row["diff_stats"] = diff_stats(side_by_side(twin_arch, copy_arch))
n_compared += 1
return n_compared
def _cow_twin_arch(database, lst_key, **kwargs):
"""{clé: (id, arch)} des vues de module masquées par ces copies."""
if not lst_key:
return {}
values = ", ".join(
"'" + k.replace("'", "''") + "'" for k in sorted(set(lst_key))
)
rows = json_query(
database,
f"""
SELECT DISTINCT ON (v.key)
v.key AS key,
v.id AS id,
v.arch_db::text AS arch
FROM ir_ui_view v
WHERE v.key IN ({values}) AND v.website_id IS NULL
ORDER BY v.key, v.id
""",
**kwargs,
)
return {r["key"]: (r["id"], normalise_arch(r["arch"])) for r in rows}
def _cow_copy_arch(database, lst_id, **kwargs):
"""{id: arch} des seules copies retenues.
Rapatrier l'arch de toutes les vues ferait une ligne de sortie de plusieurs
centaines de mégaoctets ; celle des seules copies COW en fait quelques-uns.
"""
if not lst_id:
return {}
ids = ", ".join(str(int(i)) for i in lst_id)
rows = json_query(
database,
"SELECT id AS id, arch_db::text AS arch"
f" FROM ir_ui_view WHERE id IN ({ids})",
**kwargs,
)
return {r["id"]: normalise_arch(r["arch"]) for r in rows}
def add_cow_twin_diff(database, lst_finding, **kwargs):
"""Comparer les copies COW d'une BASE à leur jumelle. Deux requêtes."""
lst_copy = [
row
for row in lst_finding
if row["category"] == "website_cow_copy" and row.get("has_module_twin")
]
if not lst_copy:
return 0
dct_twin = _cow_twin_arch(
database, [row["key"] for row in lst_copy if row.get("key")], **kwargs
)
dct_copy = _cow_copy_arch(
database, [row["id"] for row in lst_copy], **kwargs
)
return attach_cow_twin_diff(lst_finding, dct_twin, dct_copy)
def collect(
database,
with_diff=False,
[ADD] analyse: diff each website copy against the view it shadows The analysis counted 62 website copies and pointed at other tools to judge them. But the comparison that matters for a copy needs no registry at all: both arches are in the database, paired by key — the very pairing Odoo makes. So it works where the module-source comparison is refused, on a database whose version differs from the checkout, and on a backup zip. On the 12.0 database at hand, where the other comparison cannot run: 47 of the 62 copies have a twin, all 47 compared in under a second, 12 differ. The other 35 are byte-for-byte identical to their module view — they carry no customization at all, which is what decides whether neutralizing costs anything. The fields are named as the module comparison names them, so the full-screen browser and the text report work without knowing which comparison produced the data. A copy without a twin is left alone: it is a page made in the editor, with nothing to compare against. Checked on that database and on a real backup; 8 tests, including that re-indentation alone is not a difference and that a missing arch yields no verdict rather than « identical ». --- FR --- L'analyse comptait 62 copies de site web et renvoyait vers d'autres outils pour les juger. Or la comparaison qui compte pour une copie n'a besoin d'aucun registre : les deux arch sont dans la base, appariées par la clé — exactement l'appariement que fait Odoo. Elle marche donc là où celle avec la source du module est refusée : une base dont la version diffère du checkout, et une sauvegarde zip. Sur la base 12.0 en cours, où l'autre comparaison ne peut pas tourner : 47 des 62 copies ont une jumelle, les 47 comparées en moins d'une seconde, 12 diffèrent. Les 35 autres sont identiques octet pour octet à leur vue de module — elles ne portent aucune personnalisation, ce qui décide si les neutraliser coûte quelque chose. Les champs portent les noms de la comparaison avec le module, donc l'écran de navigation et le rapport texte marchent sans savoir laquelle des deux a produit la donnée. Une copie sans jumelle est laissée telle quelle : c'est une page faite dans l'éditeur, il n'y a rien à quoi la comparer. Vérifié sur cette base et sur une vraie sauvegarde ; 8 tests, dont qu'une ré-indentation seule n'est pas un écart et qu'une arch absente ne rend aucun verdict plutôt que « identique ». Assisted-by: Claude Opus 5
2026-08-12 00:54:11 -04:00
with_cow_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)
[ADD] analyse: diff each website copy against the view it shadows The analysis counted 62 website copies and pointed at other tools to judge them. But the comparison that matters for a copy needs no registry at all: both arches are in the database, paired by key — the very pairing Odoo makes. So it works where the module-source comparison is refused, on a database whose version differs from the checkout, and on a backup zip. On the 12.0 database at hand, where the other comparison cannot run: 47 of the 62 copies have a twin, all 47 compared in under a second, 12 differ. The other 35 are byte-for-byte identical to their module view — they carry no customization at all, which is what decides whether neutralizing costs anything. The fields are named as the module comparison names them, so the full-screen browser and the text report work without knowing which comparison produced the data. A copy without a twin is left alone: it is a page made in the editor, with nothing to compare against. Checked on that database and on a real backup; 8 tests, including that re-indentation alone is not a difference and that a missing arch yields no verdict rather than « identical ». --- FR --- L'analyse comptait 62 copies de site web et renvoyait vers d'autres outils pour les juger. Or la comparaison qui compte pour une copie n'a besoin d'aucun registre : les deux arch sont dans la base, appariées par la clé — exactement l'appariement que fait Odoo. Elle marche donc là où celle avec la source du module est refusée : une base dont la version diffère du checkout, et une sauvegarde zip. Sur la base 12.0 en cours, où l'autre comparaison ne peut pas tourner : 47 des 62 copies ont une jumelle, les 47 comparées en moins d'une seconde, 12 diffèrent. Les 35 autres sont identiques octet pour octet à leur vue de module — elles ne portent aucune personnalisation, ce qui décide si les neutraliser coûte quelque chose. Les champs portent les noms de la comparaison avec le module, donc l'écran de navigation et le rapport texte marchent sans savoir laquelle des deux a produit la donnée. Une copie sans jumelle est laissée telle quelle : c'est une page faite dans l'éditeur, il n'y a rien à quoi la comparer. Vérifié sur cette base et sur une vraie sauvegarde ; 8 tests, dont qu'une ré-indentation seule n'est pas un écart et qu'une arch absente ne rend aucun verdict plutôt que « identique ». Assisted-by: Claude Opus 5
2026-08-12 00:54:11 -04:00
n_cow_compared = 0
if with_cow_diff:
# Indépendant de la voie ORM : les deux arch sont en base, donc ceci
# marche même quand la version du checkout interdit l'autre.
n_cow_compared = add_cow_twin_diff(database, lst_finding, **kwargs)
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,
[ADD] analyse: diff each website copy against the view it shadows The analysis counted 62 website copies and pointed at other tools to judge them. But the comparison that matters for a copy needs no registry at all: both arches are in the database, paired by key — the very pairing Odoo makes. So it works where the module-source comparison is refused, on a database whose version differs from the checkout, and on a backup zip. On the 12.0 database at hand, where the other comparison cannot run: 47 of the 62 copies have a twin, all 47 compared in under a second, 12 differ. The other 35 are byte-for-byte identical to their module view — they carry no customization at all, which is what decides whether neutralizing costs anything. The fields are named as the module comparison names them, so the full-screen browser and the text report work without knowing which comparison produced the data. A copy without a twin is left alone: it is a page made in the editor, with nothing to compare against. Checked on that database and on a real backup; 8 tests, including that re-indentation alone is not a difference and that a missing arch yields no verdict rather than « identical ». --- FR --- L'analyse comptait 62 copies de site web et renvoyait vers d'autres outils pour les juger. Or la comparaison qui compte pour une copie n'a besoin d'aucun registre : les deux arch sont dans la base, appariées par la clé — exactement l'appariement que fait Odoo. Elle marche donc là où celle avec la source du module est refusée : une base dont la version diffère du checkout, et une sauvegarde zip. Sur la base 12.0 en cours, où l'autre comparaison ne peut pas tourner : 47 des 62 copies ont une jumelle, les 47 comparées en moins d'une seconde, 12 diffèrent. Les 35 autres sont identiques octet pour octet à leur vue de module — elles ne portent aucune personnalisation, ce qui décide si les neutraliser coûte quelque chose. Les champs portent les noms de la comparaison avec le module, donc l'écran de navigation et le rapport texte marchent sans savoir laquelle des deux a produit la donnée. Une copie sans jumelle est laissée telle quelle : c'est une page faite dans l'éditeur, il n'y a rien à quoi la comparer. Vérifié sur cette base et sur une vraie sauvegarde ; 8 tests, dont qu'une ré-indentation seule n'est pas un écart et qu'une arch absente ne rend aucun verdict plutôt que « identique ». Assisted-by: Claude Opus 5
2026-08-12 00:54:11 -04:00
"n_cow_compared": n_cow_compared,
"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)
[ADD] analyse: diff each website copy against the view it shadows The analysis counted 62 website copies and pointed at other tools to judge them. But the comparison that matters for a copy needs no registry at all: both arches are in the database, paired by key — the very pairing Odoo makes. So it works where the module-source comparison is refused, on a database whose version differs from the checkout, and on a backup zip. On the 12.0 database at hand, where the other comparison cannot run: 47 of the 62 copies have a twin, all 47 compared in under a second, 12 differ. The other 35 are byte-for-byte identical to their module view — they carry no customization at all, which is what decides whether neutralizing costs anything. The fields are named as the module comparison names them, so the full-screen browser and the text report work without knowing which comparison produced the data. A copy without a twin is left alone: it is a page made in the editor, with nothing to compare against. Checked on that database and on a real backup; 8 tests, including that re-indentation alone is not a difference and that a missing arch yields no verdict rather than « identical ». --- FR --- L'analyse comptait 62 copies de site web et renvoyait vers d'autres outils pour les juger. Or la comparaison qui compte pour une copie n'a besoin d'aucun registre : les deux arch sont dans la base, appariées par la clé — exactement l'appariement que fait Odoo. Elle marche donc là où celle avec la source du module est refusée : une base dont la version diffère du checkout, et une sauvegarde zip. Sur la base 12.0 en cours, où l'autre comparaison ne peut pas tourner : 47 des 62 copies ont une jumelle, les 47 comparées en moins d'une seconde, 12 diffèrent. Les 35 autres sont identiques octet pour octet à leur vue de module — elles ne portent aucune personnalisation, ce qui décide si les neutraliser coûte quelque chose. Les champs portent les noms de la comparaison avec le module, donc l'écran de navigation et le rapport texte marchent sans savoir laquelle des deux a produit la donnée. Une copie sans jumelle est laissée telle quelle : c'est une page faite dans l'éditeur, il n'y a rien à quoi la comparer. Vérifié sur cette base et sur une vraie sauvegarde ; 8 tests, dont qu'une ré-indentation seule n'est pas un écart et qu'une arch absente ne rend aucun verdict plutôt que « identique ». Assisted-by: Claude Opus 5
2026-08-12 00:54:11 -04:00
# Le dump porte les arch des deux côtés : la comparaison COW est donc
# possible depuis un zip, là où celle avec la source du module ne l'est
# pas faute de registre.
dct_twin = {}
for row in dct_rows["ir_ui_view"]:
if row.get("key") and row.get("website_id") in (None, ""):
dct_twin.setdefault(
row["key"],
(row.get("id"), normalise_arch(row.get("arch_db"))),
)
dct_copy = {
(int(r.get("id")) if (r.get("id") or "").isdigit() else r.get("id")): (
normalise_arch(r.get("arch_db"))
)
for r in dct_rows["ir_ui_view"]
}
n_cow_compared = attach_cow_twin_diff(lst_finding, dct_twin, dct_copy)
return {
"tool": "analyse_view_custom",
"version": 1,
"database": os.path.basename(zip_path),
"source": "backup",
[ADD] analyse: diff each website copy against the view it shadows The analysis counted 62 website copies and pointed at other tools to judge them. But the comparison that matters for a copy needs no registry at all: both arches are in the database, paired by key — the very pairing Odoo makes. So it works where the module-source comparison is refused, on a database whose version differs from the checkout, and on a backup zip. On the 12.0 database at hand, where the other comparison cannot run: 47 of the 62 copies have a twin, all 47 compared in under a second, 12 differ. The other 35 are byte-for-byte identical to their module view — they carry no customization at all, which is what decides whether neutralizing costs anything. The fields are named as the module comparison names them, so the full-screen browser and the text report work without knowing which comparison produced the data. A copy without a twin is left alone: it is a page made in the editor, with nothing to compare against. Checked on that database and on a real backup; 8 tests, including that re-indentation alone is not a difference and that a missing arch yields no verdict rather than « identical ». --- FR --- L'analyse comptait 62 copies de site web et renvoyait vers d'autres outils pour les juger. Or la comparaison qui compte pour une copie n'a besoin d'aucun registre : les deux arch sont dans la base, appariées par la clé — exactement l'appariement que fait Odoo. Elle marche donc là où celle avec la source du module est refusée : une base dont la version diffère du checkout, et une sauvegarde zip. Sur la base 12.0 en cours, où l'autre comparaison ne peut pas tourner : 47 des 62 copies ont une jumelle, les 47 comparées en moins d'une seconde, 12 diffèrent. Les 35 autres sont identiques octet pour octet à leur vue de module — elles ne portent aucune personnalisation, ce qui décide si les neutraliser coûte quelque chose. Les champs portent les noms de la comparaison avec le module, donc l'écran de navigation et le rapport texte marchent sans savoir laquelle des deux a produit la donnée. Une copie sans jumelle est laissée telle quelle : c'est une page faite dans l'éditeur, il n'y a rien à quoi la comparer. Vérifié sur cette base et sur une vraie sauvegarde ; 8 tests, dont qu'une ré-indentation seule n'est pas un écart et qu'une arch absente ne rend aucun verdict plutôt que « identique ». Assisted-by: Claude Opus 5
2026-08-12 00:54:11 -04:00
"n_cow_compared": n_cow_compared,
"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)"),
)
[ADD] analyse: diff each website copy against the view it shadows The analysis counted 62 website copies and pointed at other tools to judge them. But the comparison that matters for a copy needs no registry at all: both arches are in the database, paired by key — the very pairing Odoo makes. So it works where the module-source comparison is refused, on a database whose version differs from the checkout, and on a backup zip. On the 12.0 database at hand, where the other comparison cannot run: 47 of the 62 copies have a twin, all 47 compared in under a second, 12 differ. The other 35 are byte-for-byte identical to their module view — they carry no customization at all, which is what decides whether neutralizing costs anything. The fields are named as the module comparison names them, so the full-screen browser and the text report work without knowing which comparison produced the data. A copy without a twin is left alone: it is a page made in the editor, with nothing to compare against. Checked on that database and on a real backup; 8 tests, including that re-indentation alone is not a difference and that a missing arch yields no verdict rather than « identical ». --- FR --- L'analyse comptait 62 copies de site web et renvoyait vers d'autres outils pour les juger. Or la comparaison qui compte pour une copie n'a besoin d'aucun registre : les deux arch sont dans la base, appariées par la clé — exactement l'appariement que fait Odoo. Elle marche donc là où celle avec la source du module est refusée : une base dont la version diffère du checkout, et une sauvegarde zip. Sur la base 12.0 en cours, où l'autre comparaison ne peut pas tourner : 47 des 62 copies ont une jumelle, les 47 comparées en moins d'une seconde, 12 diffèrent. Les 35 autres sont identiques octet pour octet à leur vue de module — elles ne portent aucune personnalisation, ce qui décide si les neutraliser coûte quelque chose. Les champs portent les noms de la comparaison avec le module, donc l'écran de navigation et le rapport texte marchent sans savoir laquelle des deux a produit la donnée. Une copie sans jumelle est laissée telle quelle : c'est une page faite dans l'éditeur, il n'y a rien à quoi la comparer. Vérifié sur cette base et sur une vraie sauvegarde ; 8 tests, dont qu'une ré-indentation seule n'est pas un écart et qu'une arch absente ne rend aucun verdict plutôt que « identique ». Assisted-by: Claude Opus 5
2026-08-12 00:54:11 -04:00
parser.add_argument(
"--cow-diff",
dest="cow_diff",
action="store_true",
help=t("compare each website copy with the module view it shadows"),
)
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,
[ADD] analyse: diff each website copy against the view it shadows The analysis counted 62 website copies and pointed at other tools to judge them. But the comparison that matters for a copy needs no registry at all: both arches are in the database, paired by key — the very pairing Odoo makes. So it works where the module-source comparison is refused, on a database whose version differs from the checkout, and on a backup zip. On the 12.0 database at hand, where the other comparison cannot run: 47 of the 62 copies have a twin, all 47 compared in under a second, 12 differ. The other 35 are byte-for-byte identical to their module view — they carry no customization at all, which is what decides whether neutralizing costs anything. The fields are named as the module comparison names them, so the full-screen browser and the text report work without knowing which comparison produced the data. A copy without a twin is left alone: it is a page made in the editor, with nothing to compare against. Checked on that database and on a real backup; 8 tests, including that re-indentation alone is not a difference and that a missing arch yields no verdict rather than « identical ». --- FR --- L'analyse comptait 62 copies de site web et renvoyait vers d'autres outils pour les juger. Or la comparaison qui compte pour une copie n'a besoin d'aucun registre : les deux arch sont dans la base, appariées par la clé — exactement l'appariement que fait Odoo. Elle marche donc là où celle avec la source du module est refusée : une base dont la version diffère du checkout, et une sauvegarde zip. Sur la base 12.0 en cours, où l'autre comparaison ne peut pas tourner : 47 des 62 copies ont une jumelle, les 47 comparées en moins d'une seconde, 12 diffèrent. Les 35 autres sont identiques octet pour octet à leur vue de module — elles ne portent aucune personnalisation, ce qui décide si les neutraliser coûte quelque chose. Les champs portent les noms de la comparaison avec le module, donc l'écran de navigation et le rapport texte marchent sans savoir laquelle des deux a produit la donnée. Une copie sans jumelle est laissée telle quelle : c'est une page faite dans l'éditeur, il n'y a rien à quoi la comparer. Vérifié sur cette base et sur une vraie sauvegarde ; 8 tests, dont qu'une ré-indentation seule n'est pas un écart et qu'une arch absente ne rend aucun verdict plutôt que « identique ». Assisted-by: Claude Opus 5
2026-08-12 00:54:11 -04:00
with_diff=config.diff,
with_cow_diff=config.cow_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())