erplibre/script/odoo/migration/cow_drift.py
Mathieu Benoit 0870696701 [FIX] script: make the analysis and migration tools executable
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
2026-08-22 07:23:59 -04:00

390 lines
13 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)
"""Show what a website COW copy holds, and why the next version breaks it.
Read-only. Answers the two questions someone asks before neutralizing a copy,
and neither is answerable from the warning alone:
**What do I lose?** The copy is compared with the module view it shadows. That
is the customization someone made, and often it is three lines — an id, a
container width — for which nobody would hold up a migration.
**Why does it break?** The module declaration is shown in the current version
and in the target. The pair is the whole explanation: a template declared
without ``inherit_id`` is a standalone document, one declared with it must
hold inheritance specs. A copy frozen in the first shape cannot be applied in
the second, and Odoo stops on « cannot be located in parent view ».
Nothing here writes. Neutralizing is ``neutralize_cow_views.py --apply``, and
undoing it is ``--restore``.
"""
import argparse
import difflib
import json
import os
import re
import subprocess
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from check_cow_views import analyse, find_module_dir, t # noqa: E402
# The declaration of a template spans a few lines; showing the opening tag and
# what follows is enough to see its shape, and short enough to compare two
# versions side by side without scrolling.
DECL_LINES = 4
def run_psql(database, sql):
"""Run a statement and return stdout, raising on failure."""
result = subprocess.run(
["psql", "-X", "-w", "-d", database, "-tAc", sql],
capture_output=True,
text=True,
)
if result.returncode:
raise RuntimeError(
f"Query failed on '{database}': {result.stderr.strip()}"
)
return result.stdout
def unwrap_arch(value):
"""The arch as text, whatever the column type.
``arch_db`` is text up to 15.0 and jsonb from 16.0. The same unwrapping as
the sibling scripts, kept identical on purpose: a second reading of the
same column that differs on an edge case would report a drift nobody made.
"""
text = (value or "").strip()
if text.startswith("{") and '"' in text:
try:
data = json.loads(text)
except ValueError:
return value or ""
if isinstance(data, dict) and data:
for lang in ("en_US", *sorted(data)):
if isinstance(data.get(lang), str):
return data[lang]
return value or ""
def fetch_arch(database, view_id):
"""The stored arch of one view."""
return unwrap_arch(
run_psql(
database,
f"SELECT arch_db::text FROM ir_ui_view WHERE id = {int(view_id)};",
)
)
def fetch_module_view(database, key):
"""(id, arch) of the module view a copy shadows, or (None, '').
The module view is the one carrying that key WITHOUT a website: that is
exactly the pairing Odoo itself makes, and the reason renaming a key is
enough to unpair a copy.
"""
safe = key.replace("'", "''")
out = run_psql(
database,
"SELECT id, arch_db::text FROM ir_ui_view"
f" WHERE key = '{safe}' AND website_id IS NULL"
" ORDER BY id LIMIT 1;",
).strip()
if not out:
return None, ""
view_id, _, arch = out.partition("|")
return int(view_id), unwrap_arch(arch)
def declaration(version_dir, key):
"""(path, snippet) of how the sources of one version declare this key.
Returns None when the module or the node is not found — a fact worth
showing as such, since « the module no longer declares it » is itself a
reason a copy breaks.
"""
module_name, _, template_id = key.partition(".")
if not template_id:
return None
module_dir = find_module_dir(version_dir, module_name)
if module_dir is None:
return None
try:
from lxml import etree
except ImportError:
return None
pattern = os.path.join(module_dir, "**", "*.xml")
import glob
for path in sorted(glob.glob(pattern, recursive=True)):
try:
tree = etree.parse(path)
except etree.XMLSyntaxError:
continue
for element in tree.getroot().iter():
if element.get("id") != template_id:
continue
if element.tag not in ("template", "record"):
continue
# The source line, not a re-serialization: what the file actually
# says is what a reader will grep for.
with open(path, "r", encoding="utf-8", errors="replace") as handle:
lines = handle.read().splitlines()
start = max((element.sourceline or 1) - 1, 0)
return path, "\n".join(lines[start : start + DECL_LINES])
return None
def database_version_dir(database):
"""The odoo<x>.0 directory matching what the DATABASE says it is.
Not `.odoo-version`: the checkout is switched to the TARGET before this
runs, so reading it would compare the target with itself and show the same
declaration twice — which is what it did until this was measured on a real
migration. The database, at that moment, is still on the previous version
and says so in ir_module_module.
"""
try:
out = run_psql(
database,
"SELECT latest_version FROM ir_module_module"
" WHERE name = 'base';",
).strip()
except RuntimeError:
return None
if not out:
return None
parts = out.split(".")
if len(parts) < 2:
return None
return f"odoo{parts[0]}.{parts[1]}"
def collect(database, target_version, current_version=None):
"""Everything needed to judge each at-risk copy. No writes."""
current_version = current_version or database_version_dir(database)
lst_at_risk, _, _ = analyse(database, target_version)
lst_finding = []
for view_id, key, mode, target_mode, website_id, reason in lst_at_risk:
module_id, module_arch = fetch_module_view(database, key)
lst_finding.append(
{
"id": view_id,
"key": key,
"mode": mode,
"target_mode": target_mode,
"website_id": website_id,
"reason": reason,
"copy_arch": fetch_arch(database, view_id),
"module_id": module_id,
"module_arch": module_arch,
"decl_current": (
declaration(current_version, key)
if current_version
else None
),
"decl_target": declaration(target_version, key),
"current_version": current_version,
"target_version": target_version,
}
)
return lst_finding
def render_diff(finding):
"""What the copy changed, compared with the module view it shadows."""
lines = [
f"── id={finding['id']} {finding['key']}"
f" (website={finding['website_id']}) ──",
"",
]
if finding["module_id"] is None:
lines += [
f" {t('No module view carries this key, so there is nothing to')}",
f" {t('compare: this copy is a page made in the website editor.')}",
]
return "\n".join(lines)
left = finding["module_arch"].splitlines()
right = finding["copy_arch"].splitlines()
diff = [
line
for line in difflib.unified_diff(
left,
right,
fromfile=f"module id={finding['module_id']}",
tofile=f"copy id={finding['id']}",
lineterm="",
n=1,
)
]
if len(diff) <= 2:
lines.append(f" {t('The copy is identical to the module view.')}")
return "\n".join(lines)
lines += [f" {line}" for line in diff]
n_plus = sum(
1 for x in diff if x.startswith("+") and not x.startswith("+++")
)
n_minus = sum(
1 for x in diff if x.startswith("-") and not x.startswith("---")
)
lines += [
"",
f" {n_plus} {t('line(s) added')}, {n_minus} {t('removed')} —"
f" {t('this is what neutralizing gives up.')}",
]
return "\n".join(lines)
def render_shape(finding):
"""Why it breaks: the declaration in each version, side by side."""
lines = [
f"── id={finding['id']} {finding['key']} ──",
"",
f" {t(finding['reason'])}",
"",
]
for label, decl in (
(finding["current_version"], finding["decl_current"]),
(finding["target_version"], finding["decl_target"]),
):
lines.append(f" <!-- {label or '?'} -->")
if decl is None:
lines += [
f" ({t('the module no longer declares this template')})",
"",
]
continue
path, snippet = decl
lines.append(f" <!-- {path} -->")
lines += [f" {line}" for line in snippet.splitlines()]
shape = (
t("inheritance specs (needs inherit_id)")
if re.search(r"inherit_id\s*=", snippet)
else t("a standalone template")
)
lines += [f" -> {shape}", ""]
lines += [
f" {t('Odoo changing the shape of its own template is NOT the')}",
f" {t('problem: on a database without a copy, the module upgrade')}",
f" {t('rewrites the view and nothing breaks. It breaks here because')}",
f" {t('a COPY exists and froze the old shape — the copy follows the')}",
f" {t('module and becomes an extension, while still holding a')}",
f" {t('standalone template. Odoo then applies that template as an')}",
f" {t('inheritance spec and stops on « cannot be located in parent')}",
f" {t('view ».')}",
"",
]
# Sans ceci, cette vue se lit comme « du code Odoo qui change », et l'on
# se demande pourquoi c'est notre affaire. Ce qui la rend nôtre est ce que
# la copie contient — la seule chose que neutraliser ferait perdre.
lines += _what_the_copy_holds(finding)
return "\n".join(lines)
def _what_the_copy_holds(finding):
"""Ce que la copie porte en propre, dit en une ou deux lignes.
Relie les deux moitiés de la question : le mécanisme explique POURQUOI ça
casse, ceci dit CE QU'IL EN COÛTE. Une copie identique à sa jumelle ne
coûte rien, et le savoir change la décision.
"""
if finding["module_id"] is None:
return [
f" {t('This copy has no module view of that name: it is a page')}",
f" {t('made in the website editor, and nothing holds its content.')}",
]
left = finding["module_arch"].splitlines()
right = finding["copy_arch"].splitlines()
diff = [
line
for line in difflib.unified_diff(left, right, lineterm="", n=0)
if line[:1] in "+-" and not line.startswith(("+++", "---"))
]
if not diff:
return [
f" {t('This copy is IDENTICAL to the module view it shadows: it')}",
f" {t('holds no customization, so neutralizing it loses nothing.')}",
]
n_plus = sum(1 for x in diff if x.startswith("+"))
n_minus = sum(1 for x in diff if x.startswith("-"))
return [
f" {t('This copy differs from the module view by')}"
f" +{n_plus}/-{n_minus} {t('line(s):')}",
f" {t('that is the customization, and all neutralizing gives up.')}",
f" {t('Run without --shape to read it.')}",
]
def render_all(lst_finding, shape=False):
"""The whole report, one block per finding."""
if not lst_finding:
return f"✅ {t('No website COW view is at risk.')}\n"
render = render_shape if shape else render_diff
return "\n\n".join(render(f) for f in lst_finding) + "\n"
def main(argv=None):
parser = argparse.ArgumentParser(
description=(
"Show what each at-risk website COW copy holds, and why the next"
" version breaks it (read-only)."
)
)
parser.add_argument("-d", "--database", required=True)
parser.add_argument(
"-t",
"--target_version",
required=True,
help="target Odoo source directory, e.g. odoo13.0",
)
parser.add_argument(
"--current",
default=None,
help="current Odoo source directory (default: from .odoo-version)",
)
parser.add_argument(
"--shape",
action="store_true",
help="show the declarations instead of the customization diff",
)
parser.add_argument(
"--tui",
action="store_true",
help="browse full screen, switching between the two views",
)
config = parser.parse_args(argv)
if not os.path.isdir(config.target_version):
print(
f"❌ {t('Target version directory not found')} :"
f" '{config.target_version}'"
)
return 2
try:
lst_finding = collect(
config.database, config.target_version, config.current
)
except RuntimeError as exc:
print(f"❌ {exc}")
return 2
if config.tui and lst_finding:
from cow_drift_tui import run_tui
if run_tui(lst_finding):
return 1
print(render_all(lst_finding, shape=config.shape))
return 1 if lst_finding else 0
if __name__ == "__main__":
sys.exit(main())