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
390 lines
13 KiB
Python
Executable file
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())
|