327 lines
11 KiB
Python
327 lines
11 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||
|
|
|
||
|
|
"""Predict which customized SCSS will break the next version bump.
|
||
|
|
|
||
|
|
Background
|
||
|
|
----------
|
||
|
|
Customizing a website writes SCSS into ``ir_attachment``: files whose URL
|
||
|
|
carries ``.custom.``. That copy is frozen the day it is written, and it keeps
|
||
|
|
using the variables of THAT version.
|
||
|
|
|
||
|
|
A module can rename its variables between two Odoo versions. Measured on a
|
||
|
|
real database: ``website/static/src/scss/primary_variables.scss`` declared
|
||
|
|
``$o-theme-font-number`` in 12.0 and replaced the whole mechanism in 13.0 with
|
||
|
|
a ``$o-website-values-palettes`` map. A customization written in 2020 still
|
||
|
|
asked for the old name. The bundle then stops on::
|
||
|
|
|
||
|
|
Error: Undefined variable: "$o-theme-font-number".
|
||
|
|
This error occured while compiling the bundle 'web.assets_frontend'
|
||
|
|
|
||
|
|
So the rule is the same as for COW views:
|
||
|
|
|
||
|
|
a customization breaks when what it USES stops being DEFINED between
|
||
|
|
version N and version N+1.
|
||
|
|
|
||
|
|
Why not just start Odoo and open a page
|
||
|
|
---------------------------------------
|
||
|
|
Because that only tells you *after* the bump, on a page that renders a
|
||
|
|
« Style error » with no name attached: not which attachment, not which
|
||
|
|
variable, not since when. And a page has to be reached at all — a broken
|
||
|
|
frontend bundle is exactly when it is not.
|
||
|
|
|
||
|
|
Reading the stored SCSS against the target sources answers before the bump,
|
||
|
|
in a second, and names the attachment. Nothing is written here.
|
||
|
|
|
||
|
|
What it can miss
|
||
|
|
----------------
|
||
|
|
Which files a bundle really contains is decided by Odoo at runtime. This
|
||
|
|
compares against every SCSS of the INSTALLED modules of the target version,
|
||
|
|
which is wider: a variable defined in a module file that the bundle does not
|
||
|
|
include would be counted as defined. That direction is deliberate — it under-
|
||
|
|
reports rather than crying wolf on every customization.
|
||
|
|
|
||
|
|
Exit codes: 0 nothing to report, 1 customizations at risk, 2 tool failure.
|
||
|
|
"""
|
||
|
|
|
||
|
|
import argparse
|
||
|
|
import glob
|
||
|
|
import os
|
||
|
|
import re
|
||
|
|
import subprocess
|
||
|
|
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
|
||
|
|
|
||
|
|
|
||
|
|
# Une variable SCSS commence par $ ; celles dont le nom commence par un tiret
|
||
|
|
# sont locales au fichier par convention Odoo ($-seen, $-font-numbers) et se
|
||
|
|
# définissent toujours au-dessus de leur usage.
|
||
|
|
RE_ANY = re.compile(r"\$([a-zA-Z][\w-]*)")
|
||
|
|
|
||
|
|
|
||
|
|
def used_names(content):
|
||
|
|
"""Noms LUS par le fichier, sans les liaisons.
|
||
|
|
|
||
|
|
Un « $nom » suivi d'un deux-points est toujours une liaison — déclaration,
|
||
|
|
valeur par défaut, ou argument nommé d'un @include — jamais la lecture
|
||
|
|
d'une variable. Sans cette réserve, « @include o-position-absolute(
|
||
|
|
$right: 50%) » se lisait comme l'usage d'un $right inexistant.
|
||
|
|
|
||
|
|
Le test se fait ici et non par une négation dans le motif : une négation
|
||
|
|
en tête de motif fait rétrograder le nom pour satisfaire la condition, et
|
||
|
|
rapporte « $botto » au lieu d'écarter « $bottom ». Mesuré.
|
||
|
|
"""
|
||
|
|
names = set()
|
||
|
|
for match in RE_ANY.finditer(content):
|
||
|
|
if content[match.end() :].lstrip(" \t").startswith(":"):
|
||
|
|
continue
|
||
|
|
names.add(match.group(1))
|
||
|
|
return names
|
||
|
|
|
||
|
|
|
||
|
|
RE_DEF = re.compile(r"^\s*\$([a-zA-Z][\w-]*)\s*:", re.M)
|
||
|
|
|
||
|
|
# Une variable peut aussi être LIÉE sans être définie : paramètre de mixin ou
|
||
|
|
# de fonction, variable de boucle. Les ignorer donnait six faux positifs sur
|
||
|
|
# le seul fichier mesuré — $bottom, $counter, $off, $on, $right, $value — et
|
||
|
|
# un détecteur qui crie à tort finit ignoré, ce qui vaut moins que rien.
|
||
|
|
RE_PARAM = re.compile(r"@(?:mixin|function)\s+[\w-]+\s*\(([^)]*)\)")
|
||
|
|
RE_EACH = re.compile(r"@each\s+([^i]+?)\s+in\s", re.S)
|
||
|
|
RE_FOR = re.compile(r"@for\s+\$([\w-]+)\s+from\s")
|
||
|
|
|
||
|
|
|
||
|
|
def bound_names(content):
|
||
|
|
"""Noms liés par le fichier lui-même, hors définitions « $x: … »."""
|
||
|
|
names = set()
|
||
|
|
for group in RE_PARAM.findall(content):
|
||
|
|
names.update(RE_ANY.findall(group))
|
||
|
|
for group in RE_EACH.findall(content):
|
||
|
|
names.update(RE_ANY.findall(group))
|
||
|
|
names.update(RE_FOR.findall(content))
|
||
|
|
return names
|
||
|
|
|
||
|
|
|
||
|
|
def run_psql(database, sql):
|
||
|
|
"""Interroger la base, lecture seule garantie par le serveur."""
|
||
|
|
env = os.environ.copy()
|
||
|
|
env["PGOPTIONS"] = (
|
||
|
|
"-c default_transaction_read_only=on -c statement_timeout=60000"
|
||
|
|
)
|
||
|
|
env["PSQLRC"] = ""
|
||
|
|
done = subprocess.run(
|
||
|
|
[
|
||
|
|
"psql",
|
||
|
|
"-X",
|
||
|
|
"-w",
|
||
|
|
"-v",
|
||
|
|
"ON_ERROR_STOP=1",
|
||
|
|
"-d",
|
||
|
|
database,
|
||
|
|
"-tAF",
|
||
|
|
"\x1f",
|
||
|
|
"-c",
|
||
|
|
sql,
|
||
|
|
],
|
||
|
|
capture_output=True,
|
||
|
|
text=True,
|
||
|
|
env=env,
|
||
|
|
)
|
||
|
|
if done.returncode:
|
||
|
|
raise RuntimeError(done.stderr.strip() or "psql failed")
|
||
|
|
return [line.split("\x1f") for line in done.stdout.splitlines() if line]
|
||
|
|
|
||
|
|
|
||
|
|
def filestore_dir(database, override=None):
|
||
|
|
if override:
|
||
|
|
return override
|
||
|
|
return os.path.join(
|
||
|
|
os.path.expanduser("~"),
|
||
|
|
".local",
|
||
|
|
"share",
|
||
|
|
"Odoo",
|
||
|
|
"filestore",
|
||
|
|
database,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def custom_scss(database, filestore):
|
||
|
|
"""[(id, url, contenu)] pour chaque SCSS personnalisé lisible."""
|
||
|
|
rows = run_psql(
|
||
|
|
database,
|
||
|
|
"SELECT id, url, COALESCE(store_fname, ''),"
|
||
|
|
" COALESCE(encode(db_datas, 'escape'), '') FROM ir_attachment"
|
||
|
|
" WHERE url LIKE '%.custom.%' AND url NOT LIKE '%.css'"
|
||
|
|
" ORDER BY id;",
|
||
|
|
)
|
||
|
|
lst = []
|
||
|
|
for row in rows:
|
||
|
|
if len(row) < 4:
|
||
|
|
continue
|
||
|
|
att_id, url, store_fname, db_datas = row[0], row[1], row[2], row[3]
|
||
|
|
content = ""
|
||
|
|
if store_fname:
|
||
|
|
path = os.path.join(filestore, store_fname)
|
||
|
|
if os.path.isfile(path):
|
||
|
|
with open(path, "r", encoding="utf-8", errors="replace") as h:
|
||
|
|
content = h.read()
|
||
|
|
elif db_datas:
|
||
|
|
content = db_datas
|
||
|
|
if content:
|
||
|
|
lst.append((int(att_id), url, content))
|
||
|
|
return lst
|
||
|
|
|
||
|
|
|
||
|
|
def installed_modules(database):
|
||
|
|
return {
|
||
|
|
row[0]
|
||
|
|
for row in run_psql(
|
||
|
|
database,
|
||
|
|
"SELECT name FROM ir_module_module WHERE state = 'installed';",
|
||
|
|
)
|
||
|
|
if row and row[0]
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def defined_in_sources(version_dir, lst_module):
|
||
|
|
"""Variables définies par les SCSS des modules installés de la cible.
|
||
|
|
|
||
|
|
Le nom du module est le répertoire qui contient « static » : c'est ce qui
|
||
|
|
permet de ne pas compter les variables d'un module absent de la base, dont
|
||
|
|
aucune ligne n'atteindra jamais un bundle.
|
||
|
|
"""
|
||
|
|
defined = set()
|
||
|
|
pattern = os.path.join(version_dir, "**", "static", "**", "*.scss")
|
||
|
|
for path in glob.iglob(pattern, recursive=True):
|
||
|
|
parts = path.split(os.sep)
|
||
|
|
try:
|
||
|
|
module = parts[parts.index("static") - 1]
|
||
|
|
except (ValueError, IndexError):
|
||
|
|
continue
|
||
|
|
if module not in lst_module:
|
||
|
|
continue
|
||
|
|
try:
|
||
|
|
with open(path, "r", encoding="utf-8", errors="replace") as handle:
|
||
|
|
defined.update(RE_DEF.findall(handle.read()))
|
||
|
|
except OSError:
|
||
|
|
continue
|
||
|
|
return defined
|
||
|
|
|
||
|
|
|
||
|
|
def analyse(database, version_dir, filestore=None):
|
||
|
|
"""[(id, url, [variables absentes])] pour les personnalisations à risque."""
|
||
|
|
filestore = filestore_dir(database, filestore)
|
||
|
|
lst_module = installed_modules(database)
|
||
|
|
defined = defined_in_sources(version_dir, lst_module)
|
||
|
|
lst_finding = []
|
||
|
|
for att_id, url, content in custom_scss(database, filestore):
|
||
|
|
own = set(RE_DEF.findall(content)) | bound_names(content)
|
||
|
|
missing = sorted(
|
||
|
|
{
|
||
|
|
name
|
||
|
|
for name in used_names(content)
|
||
|
|
if name not in own and name not in defined
|
||
|
|
}
|
||
|
|
)
|
||
|
|
if missing:
|
||
|
|
lst_finding.append((att_id, url, missing))
|
||
|
|
return lst_finding
|
||
|
|
|
||
|
|
|
||
|
|
def render(lst_finding, database, version_dir):
|
||
|
|
if not lst_finding:
|
||
|
|
return (
|
||
|
|
f"✅ -> {t('No customized SCSS is at risk in')} {version_dir}.\n"
|
||
|
|
)
|
||
|
|
lines = [
|
||
|
|
f"⚠️ {len(lst_finding)}"
|
||
|
|
f" {t('customized SCSS use(s) a variable that')} {version_dir}"
|
||
|
|
f" {t('no longer defines: the bundle will not compile.')}"
|
||
|
|
]
|
||
|
|
for att_id, url, missing in lst_finding:
|
||
|
|
lines.append(f" - id={att_id} {url}")
|
||
|
|
lines.append(f" {', '.join('$' + m for m in missing)}")
|
||
|
|
lines += [
|
||
|
|
f" {t('Each one is a copy frozen on an older version. Dropping it')}"
|
||
|
|
f" {t('restores the module file:')}",
|
||
|
|
f" ./odoo_bin.sh shell -c ./config.conf -d {database} <<'PY'",
|
||
|
|
]
|
||
|
|
for _att_id, url, _missing in lst_finding:
|
||
|
|
base, bundle = split_custom_url(url)
|
||
|
|
lines.append(
|
||
|
|
f" env['web_editor.assets'].reset_asset("
|
||
|
|
f"'{base}', '{bundle}')"
|
||
|
|
)
|
||
|
|
lines += [
|
||
|
|
" env.cr.commit()",
|
||
|
|
" PY",
|
||
|
|
f" {t('Read it first: what it holds beyond the stale variable is')}"
|
||
|
|
f" {t('a real customization, to re-apply as a small file.')}",
|
||
|
|
]
|
||
|
|
return "\n".join(lines) + "\n"
|
||
|
|
|
||
|
|
|
||
|
|
def split_custom_url(url):
|
||
|
|
"""« /a/b.custom.web.assets_frontend.scss » -> (« /a/b.scss », bundle).
|
||
|
|
|
||
|
|
C'est la convention d'Odoo : le nom du bundle est encastré dans celui du
|
||
|
|
fichier. La reconstruire évite de faire deviner à l'utilisateur les deux
|
||
|
|
arguments de reset_asset au moment où il veut juste réparer.
|
||
|
|
"""
|
||
|
|
directory, name = os.path.split(url)
|
||
|
|
head, _sep, tail = name.partition(".custom.")
|
||
|
|
bundle = tail[:-5] if tail.endswith(".scss") else tail
|
||
|
|
return os.path.join(directory, head + ".scss"), bundle
|
||
|
|
|
||
|
|
|
||
|
|
def main(argv=None):
|
||
|
|
parser = argparse.ArgumentParser(
|
||
|
|
description=(
|
||
|
|
"Predict which customized SCSS will break on the target version"
|
||
|
|
" (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(
|
||
|
|
"--filestore",
|
||
|
|
default=None,
|
||
|
|
help="filestore directory (default: ~/.local/share/Odoo/filestore/<db>)",
|
||
|
|
)
|
||
|
|
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 = analyse(
|
||
|
|
config.database, config.target_version, config.filestore
|
||
|
|
)
|
||
|
|
except RuntimeError as exc:
|
||
|
|
print(f"❌ {exc}")
|
||
|
|
return 2
|
||
|
|
print(render(lst_finding, config.database, config.target_version))
|
||
|
|
return 1 if lst_finding else 0
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
sys.exit(main())
|