[ADD] migration: read the stale SCSS, then fix it, without leaving the tool
The report named the attachment and printed a command to paste. Deciding « reset it » still meant accepting to lose one did not know what: the diff against the module file is the only thing resetting gives up. The tool now shows it and asks. --diff prints it, --tui browses it, --apply resets without asking; with no flag and a terminal it asks, and looking does not answer — the prompt comes back after each read. --apply writes the copies under private/ first: reset_asset deletes the attachment, so without that the customized lines would be nowhere. The migration runs it on the real terminal, or none of this would be reachable from there — the same pipe that was closing the TUI. --- FR --- [ADD] migration : lire le SCSS périmé, puis le corriger, sans sortir Le rapport nommait la pièce jointe et imprimait une commande à coller. Répondre « réinitialise » revenait encore à accepter de perdre on ne sait quoi : l'écart avec le fichier du module est la seule chose que la réinitialisation abandonne. L'outil le montre et pose la question. --diff l'imprime, --tui le parcourt, --apply réinitialise sans demander ; sans drapeau et devant un terminal il demande, et regarder ne répond pas — l'invite revient après chaque lecture. --apply écrit d'abord les copies sous private/ : reset_asset supprime la pièce jointe, sans quoi les lignes personnalisées ne seraient plus nulle part. La migration le lance sur le vrai terminal, sinon rien de tout cela n'y serait atteignable — le tube même qui fermait la TUI. Assisted-by: Claude Opus 5 (cherry picked from commit 9e6c88622a74f7be408a8b1c523838eeadda1da3)
This commit is contained in:
parent
4bc82b4caa
commit
26de9a109f
5 changed files with 739 additions and 50 deletions
|
|
@ -218,8 +218,29 @@ def defined_in_sources(version_dir, lst_module):
|
|||
return defined
|
||||
|
||||
|
||||
def module_file(base_url, version_dir):
|
||||
"""Le fichier du module que la personnalisation masque, ou None.
|
||||
|
||||
Le premier segment de l'URL est le nom du module ; le reste est son
|
||||
chemin. C'est ce fichier que reset_asset rendrait, donc c'est contre lui
|
||||
que se lit ce que la copie a réellement changé.
|
||||
"""
|
||||
parts = base_url.strip("/").split("/")
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
pattern = os.path.join(version_dir, "**", parts[0], *parts[1:])
|
||||
for candidate in glob.iglob(pattern, recursive=True):
|
||||
if os.path.isfile(candidate):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def analyse(database, version_dir, filestore=None):
|
||||
"""[(id, url, [variables absentes])] pour les personnalisations à risque."""
|
||||
"""Un dictionnaire par personnalisation à risque.
|
||||
|
||||
Le contenu et le fichier du module y sont joints : l'appelant qui veut
|
||||
montrer l'écart ne doit pas relire la base ni retrouver le chemin.
|
||||
"""
|
||||
filestore = filestore_dir(database, filestore)
|
||||
lst_module = installed_modules(database)
|
||||
defined = defined_in_sources(version_dir, lst_module)
|
||||
|
|
@ -233,11 +254,133 @@ def analyse(database, version_dir, filestore=None):
|
|||
if name not in own and name not in defined
|
||||
}
|
||||
)
|
||||
if missing:
|
||||
lst_finding.append((att_id, url, missing))
|
||||
if not missing:
|
||||
continue
|
||||
base_url, bundle = split_custom_url(url)
|
||||
path = module_file(base_url, version_dir)
|
||||
module_content = ""
|
||||
if path:
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as handle:
|
||||
module_content = handle.read()
|
||||
lst_finding.append(
|
||||
{
|
||||
"id": att_id,
|
||||
"url": url,
|
||||
"missing": missing,
|
||||
"custom": content,
|
||||
"base_url": base_url,
|
||||
"bundle": bundle,
|
||||
"module_path": path,
|
||||
"module_content": module_content,
|
||||
"version_dir": version_dir,
|
||||
"database": database,
|
||||
}
|
||||
)
|
||||
return lst_finding
|
||||
|
||||
|
||||
def render_diff(finding):
|
||||
"""Ce que la copie a changé, comparée au fichier du module de la cible.
|
||||
|
||||
C'est la seule chose qu'abandonner la copie ferait perdre. Sans elle, on
|
||||
répond « oui, réinitialise » sans savoir si l'on jette trois lignes ou
|
||||
une page entière.
|
||||
"""
|
||||
import difflib
|
||||
|
||||
lines = [
|
||||
f"── id={finding['id']} {finding['url']} ──",
|
||||
"",
|
||||
f" {t('Missing:')} "
|
||||
+ ", ".join("$" + name for name in finding["missing"]),
|
||||
"",
|
||||
]
|
||||
if not finding["module_path"]:
|
||||
lines += [
|
||||
f" {t('No module file of that name in')}"
|
||||
f" {finding['version_dir']} :",
|
||||
f" {t('the target no longer ships it, so there is nothing to')}",
|
||||
f" {t('fall back on. Read the copy before dropping it.')}",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
diff = list(
|
||||
difflib.unified_diff(
|
||||
finding["module_content"].splitlines(),
|
||||
finding["custom"].splitlines(),
|
||||
fromfile=finding["module_path"],
|
||||
tofile=f"custom id={finding['id']}",
|
||||
lineterm="",
|
||||
n=2,
|
||||
)
|
||||
)
|
||||
if len(diff) <= 2:
|
||||
lines.append(f" {t('The copy is identical to the module file.')}")
|
||||
return "\n".join(lines)
|
||||
lines += [f" {line}" for line in diff]
|
||||
plus = sum(1 for x in diff if x[:1] == "+" and not x.startswith("+++"))
|
||||
minus = sum(1 for x in diff if x[:1] == "-" and not x.startswith("---"))
|
||||
lines += [
|
||||
"",
|
||||
f" +{plus}/-{minus} {t('line(s): that is what resetting gives up.')}",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def reset_command(lst_finding, database, config_path="./config.conf"):
|
||||
"""La commande qui rend les fichiers de module. Rien n'est lancé ici."""
|
||||
lines = [f"./odoo_bin.sh shell -c {config_path} -d {database} <<'PY'"]
|
||||
for finding in lst_finding:
|
||||
lines.append(
|
||||
"env['web_editor.assets'].reset_asset"
|
||||
f"('{finding['base_url']}', '{finding['bundle']}')"
|
||||
)
|
||||
lines += ["env.cr.commit()", "PY"]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def apply_reset(lst_finding, database, config_path="./config.conf"):
|
||||
"""Rendre les fichiers de module. ÉCRIT en base — le seul endroit ici.
|
||||
|
||||
Réversible seulement au sens où la personnalisation peut être réécrite :
|
||||
reset_asset supprime la pièce jointe. D'où la sauvegarde préalable, qui
|
||||
n'est pas une politesse mais la condition pour dire « oui » sans risque.
|
||||
"""
|
||||
script = "\n".join(
|
||||
"env['web_editor.assets'].reset_asset"
|
||||
f"('{f['base_url']}', '{f['bundle']}')"
|
||||
for f in lst_finding
|
||||
)
|
||||
done = subprocess.run(
|
||||
["./odoo_bin.sh", "shell", "-c", config_path, "-d", database],
|
||||
input=script + "\nenv.cr.commit()\n",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return done.returncode, done.stdout + done.stderr
|
||||
|
||||
|
||||
def backup_custom(lst_finding, database):
|
||||
"""Écrire les copies sur disque AVANT de les abandonner.
|
||||
|
||||
reset_asset supprime la pièce jointe : sans ceci, les lignes réellement
|
||||
personnalisées ne seraient plus nulle part.
|
||||
"""
|
||||
directory = os.path.join(
|
||||
"private", "odoo", "migration", database, "scss_backup"
|
||||
)
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
lst_path = []
|
||||
for finding in lst_finding:
|
||||
name = f"{finding['id']}_" + finding["url"].strip("/").replace(
|
||||
"/", "_"
|
||||
)
|
||||
path = os.path.join(directory, name)
|
||||
with open(path, "w", encoding="utf-8") as handle:
|
||||
handle.write(finding["custom"])
|
||||
lst_path.append(path)
|
||||
return lst_path
|
||||
|
||||
|
||||
def render(lst_finding, database, version_dir):
|
||||
if not lst_finding:
|
||||
return (
|
||||
|
|
@ -248,29 +391,75 @@ def render(lst_finding, database, version_dir):
|
|||
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)}")
|
||||
for finding in lst_finding:
|
||||
lines.append(f" - id={finding['id']} {finding['url']}")
|
||||
lines.append(
|
||||
" " + ", ".join("$" + name for name in finding["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.')}",
|
||||
" " + line
|
||||
for line in reset_command(lst_finding, database).splitlines()
|
||||
]
|
||||
lines.append(
|
||||
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 prompt(lst_finding, database, config_path="./config.conf", ask=input):
|
||||
"""Montrer, puis proposer de corriger. Rend True si l'on a écrit.
|
||||
|
||||
Répondre « oui, réinitialise » sans avoir vu l'écart, c'est accepter de
|
||||
perdre on ne sait quoi. L'invite revient donc après chaque lecture :
|
||||
regarder ne répond pas à la question.
|
||||
"""
|
||||
while True:
|
||||
answer = (
|
||||
ask(
|
||||
f"💬 {t('What do you want to do with these customizations?')}"
|
||||
f" ({t('Enter = nothing')},"
|
||||
f" v = {t('what the copy changed')},"
|
||||
f" w = {t('full screen')},"
|
||||
f" a = {t('reset them onto the module file')}) : "
|
||||
)
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
if answer == "v":
|
||||
for finding in lst_finding:
|
||||
print(render_diff(finding))
|
||||
print()
|
||||
continue
|
||||
if answer == "w":
|
||||
try:
|
||||
from check_stale_scss_tui import run_tui
|
||||
except ImportError:
|
||||
print(f"ℹ️ {t('Full screen view unavailable.')}")
|
||||
continue
|
||||
if not run_tui(lst_finding):
|
||||
for finding in lst_finding:
|
||||
print(render_diff(finding))
|
||||
continue
|
||||
if answer == "a":
|
||||
lst_path = backup_custom(lst_finding, database)
|
||||
print(f"📦 {t('Saved before resetting')} :")
|
||||
for path in lst_path:
|
||||
print(f" {path}")
|
||||
status, output = apply_reset(lst_finding, database, config_path)
|
||||
print(output.strip()[-2000:])
|
||||
if status:
|
||||
print(f"❌ {t('Reset failed, nothing was changed.')}")
|
||||
return False
|
||||
print(f"✅ -> {t('Reset done.')}")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def split_custom_url(url):
|
||||
"""« /a/b.custom.web.assets_frontend.scss » -> (« /a/b.scss », bundle).
|
||||
|
||||
|
|
@ -287,8 +476,8 @@ def split_custom_url(url):
|
|||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Predict which customized SCSS will break on the target version"
|
||||
" (read-only)."
|
||||
"Predict which customized SCSS will break on the target version."
|
||||
" Read-only unless --apply."
|
||||
)
|
||||
)
|
||||
parser.add_argument("-d", "--database", required=True)
|
||||
|
|
@ -303,6 +492,27 @@ def main(argv=None):
|
|||
default=None,
|
||||
help="filestore directory (default: ~/.local/share/Odoo/filestore/<db>)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-c",
|
||||
"--config",
|
||||
default="./config.conf",
|
||||
help="Odoo config used by the shell for --apply",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--diff",
|
||||
action="store_true",
|
||||
help="print what each copy changed, without asking",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tui",
|
||||
action="store_true",
|
||||
help="browse the differences full screen",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--apply",
|
||||
action="store_true",
|
||||
help="reset them onto the module file (WRITES; saves a copy first)",
|
||||
)
|
||||
config = parser.parse_args(argv)
|
||||
|
||||
if not os.path.isdir(config.target_version):
|
||||
|
|
@ -318,8 +528,44 @@ def main(argv=None):
|
|||
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 not lst_finding:
|
||||
return 0
|
||||
|
||||
if config.diff:
|
||||
for finding in lst_finding:
|
||||
print(render_diff(finding))
|
||||
print()
|
||||
if config.tui:
|
||||
from check_stale_scss_tui import run_tui
|
||||
|
||||
if not run_tui(lst_finding):
|
||||
for finding in lst_finding:
|
||||
print(render_diff(finding))
|
||||
if config.apply:
|
||||
lst_path = backup_custom(lst_finding, config.database)
|
||||
print(f"📦 {t('Saved before resetting')} :")
|
||||
for path in lst_path:
|
||||
print(f" {path}")
|
||||
status, output = apply_reset(
|
||||
lst_finding, config.database, config.config
|
||||
)
|
||||
print(output.strip()[-2000:])
|
||||
if status:
|
||||
print(f"❌ {t('Reset failed, nothing was changed.')}")
|
||||
return 2
|
||||
print(f"✅ -> {t('Reset done.')}")
|
||||
return 0
|
||||
|
||||
# Aucun drapeau : on demande, plutôt que d'imprimer un rapport et de
|
||||
# laisser retrouver soi-même les deux arguments de reset_asset. Mais
|
||||
# seulement devant un terminal — dans un tube, une invite bloquerait
|
||||
# l'appelant sans que personne ne voie la question.
|
||||
if not (config.diff or config.tui) and sys.stdin.isatty():
|
||||
if prompt(lst_finding, config.database, config.config):
|
||||
return 0
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
207
script/odoo/migration/check_stale_scss_tui.py
Executable file
207
script/odoo/migration/check_stale_scss_tui.py
Executable file
|
|
@ -0,0 +1,207 @@
|
|||
#!/usr/bin/env python3
|
||||
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
|
||||
"""Full-screen browsing of the customized SCSS a version bump would break.
|
||||
|
||||
Two things have to be read before answering « reset it »: what the copy
|
||||
changed — the only thing resetting gives up — and which variables the target
|
||||
no longer defines, which is why it breaks at all. Space switches between them.
|
||||
|
||||
The text report says the same, but a 1000-line diff in a terminal scrollback
|
||||
is not something anyone reads before deciding.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.append(
|
||||
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
|
||||
)
|
||||
|
||||
from check_stale_scss import render_diff, reset_command # noqa: E402
|
||||
|
||||
try:
|
||||
from script.todo.todo_i18n import t
|
||||
except Exception: # pragma: no cover - repli si i18n indisponible
|
||||
|
||||
def t(key: str) -> str:
|
||||
return key
|
||||
|
||||
|
||||
CSS = """
|
||||
Screen { layout: vertical; }
|
||||
#head { height: 3; padding: 0 1; background: $panel; color: $text; }
|
||||
#body { height: 1fr; }
|
||||
#files { width: 42; border-right: solid $accent; }
|
||||
#pane { width: 1fr; padding: 0 1; }
|
||||
"""
|
||||
|
||||
|
||||
def render_missing(finding):
|
||||
"""Pourquoi ça casse : ce que la cible ne définit plus."""
|
||||
lines = [
|
||||
f"── id={finding['id']} {finding['url']} ──",
|
||||
"",
|
||||
f" {t('This copy uses variables that')} {finding['version_dir']}"
|
||||
f" {t('no longer defines')} :",
|
||||
"",
|
||||
]
|
||||
for name in finding["missing"]:
|
||||
lines.append(f" ${name}")
|
||||
lines += [
|
||||
"",
|
||||
f" {t('The copy was written against an older version and frozen')}",
|
||||
f" {t('there. The module has since renamed what it relies on.')}",
|
||||
"",
|
||||
f" {t('Resetting restores')} :",
|
||||
f" {finding['module_path'] or t('(nothing: the target no longer ships this file)')}",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_app(lst_finding):
|
||||
"""Build the application. Textual is imported here, not at module level.
|
||||
|
||||
The module stays importable — and therefore testable — on a machine
|
||||
without Textual, which is also what lets the caller fall back to the text
|
||||
report rather than fail.
|
||||
"""
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Horizontal, VerticalScroll
|
||||
from textual.widgets import DataTable, Footer, Header, Static
|
||||
|
||||
class StaleScssApp(App):
|
||||
CSS = globals()["CSS"]
|
||||
BINDINGS = [
|
||||
("q,escape", "quit", "Quit"),
|
||||
("space,tab", "toggle", "Diff / why"),
|
||||
("c", "copy", "Copy reset command"),
|
||||
]
|
||||
|
||||
def __init__(self, lst_finding):
|
||||
super().__init__()
|
||||
self.lst_finding = lst_finding
|
||||
self.why = False
|
||||
self.index = 0
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
yield Static("", id="head")
|
||||
with Horizontal(id="body"):
|
||||
yield DataTable(id="files", cursor_type="row")
|
||||
with VerticalScroll(id="pane"):
|
||||
yield Static("", id="content")
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self):
|
||||
self.title = "Customized SCSS at risk"
|
||||
table = self.query_one("#files", DataTable)
|
||||
table.add_columns("file", "+/-", "missing")
|
||||
for finding in self.lst_finding:
|
||||
table.add_row(
|
||||
os.path.basename(finding["url"])[:30],
|
||||
self._weight(finding),
|
||||
str(len(finding["missing"])),
|
||||
key=str(finding["id"]),
|
||||
)
|
||||
self._show()
|
||||
|
||||
def _weight(self, finding):
|
||||
"""Combien la copie s'écarte, pour trier d'un coup d'œil."""
|
||||
if not finding["module_path"]:
|
||||
return "—"
|
||||
import difflib
|
||||
|
||||
diff = list(
|
||||
difflib.unified_diff(
|
||||
finding["module_content"].splitlines(),
|
||||
finding["custom"].splitlines(),
|
||||
lineterm="",
|
||||
n=0,
|
||||
)
|
||||
)
|
||||
plus = sum(
|
||||
1 for x in diff if x[:1] == "+" and not x.startswith("+++")
|
||||
)
|
||||
minus = sum(
|
||||
1 for x in diff if x[:1] == "-" and not x.startswith("---")
|
||||
)
|
||||
return f"+{plus}/-{minus}"
|
||||
|
||||
def _show(self):
|
||||
if not self.lst_finding:
|
||||
return
|
||||
finding = self.lst_finding[self.index]
|
||||
which = "why it breaks" if self.why else "what the copy changed"
|
||||
self.query_one("#head", Static).update(
|
||||
f"{finding['url']} · id={finding['id']}\n"
|
||||
f"[{which}] — space to switch, c to copy the reset command"
|
||||
)
|
||||
render = render_missing if self.why else render_diff
|
||||
self.query_one("#content", Static).update(render(finding))
|
||||
|
||||
def on_data_table_row_highlighted(self, event):
|
||||
if event.data_table.id == "files" and self.lst_finding:
|
||||
self.index = event.cursor_row
|
||||
self._show()
|
||||
|
||||
def action_toggle(self):
|
||||
self.why = not self.why
|
||||
self._show()
|
||||
|
||||
def action_copy(self):
|
||||
"""Mettre la commande de réparation dans le presse-papier.
|
||||
|
||||
Sans cela il faut la recopier à la main depuis un plein écran,
|
||||
avec deux arguments dont une faute ne se voit pas.
|
||||
"""
|
||||
finding = self.lst_finding[self.index]
|
||||
command = reset_command([finding], finding["database"])
|
||||
try:
|
||||
self.copy_to_clipboard(command)
|
||||
self.notify(t("Reset command copied."))
|
||||
except Exception:
|
||||
self.notify(command, timeout=20)
|
||||
|
||||
return StaleScssApp(lst_finding)
|
||||
|
||||
|
||||
def run_tui(lst_finding, run_app=True):
|
||||
"""Ouvrir l'écran. False si on n'a pas pu — et alors on DIT pourquoi.
|
||||
|
||||
Trois refus, trois raisons, aucune n'est une panne : rien à montrer, pas
|
||||
de terminal, ou Textual absent. Se taire ferait réafficher le rapport
|
||||
texte à la place de l'écran demandé, sans rien qui distingue les deux.
|
||||
"""
|
||||
if not lst_finding:
|
||||
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.available():
|
||||
if not textual_setup.in_venv():
|
||||
print(
|
||||
f"ℹ️ {t('Textual is missing from this interpreter:')}"
|
||||
f" {sys.executable}"
|
||||
)
|
||||
print(f" {t('Run it with')} .venv.erplibre/bin/python3")
|
||||
if textual_setup and not textual_setup.ensure():
|
||||
return False
|
||||
try:
|
||||
app = build_app(lst_finding)
|
||||
except ImportError:
|
||||
print(
|
||||
f"ℹ️ {t('Textual is missing from this interpreter:')}"
|
||||
f" {sys.executable}"
|
||||
)
|
||||
return False
|
||||
if not run_app:
|
||||
return app
|
||||
app.run()
|
||||
return True
|
||||
|
|
@ -4822,6 +4822,90 @@ TRANSLATIONS = {
|
|||
"fr": "Lire les personnalisations ci-dessus avant de continuer.",
|
||||
"en": "Read the customizations above before continuing.",
|
||||
},
|
||||
"Missing:": {
|
||||
"fr": "Manquantes :",
|
||||
"en": "Missing:",
|
||||
},
|
||||
"No module file of that name in": {
|
||||
"fr": "Aucun fichier de module de ce nom dans",
|
||||
"en": "No module file of that name in",
|
||||
},
|
||||
"the target no longer ships it, so there is nothing to": {
|
||||
"fr": "la cible ne le livre plus, il n'y a donc rien sur quoi",
|
||||
"en": "the target no longer ships it, so there is nothing to",
|
||||
},
|
||||
"fall back on. Read the copy before dropping it.": {
|
||||
"fr": "retomber. Lire la copie avant de l'abandonner.",
|
||||
"en": "fall back on. Read the copy before dropping it.",
|
||||
},
|
||||
"The copy is identical to the module file.": {
|
||||
"fr": "La copie est identique au fichier du module.",
|
||||
"en": "The copy is identical to the module file.",
|
||||
},
|
||||
"line(s): that is what resetting gives up.": {
|
||||
"fr": "ligne(s) : c'est ce que la réinitialisation abandonne.",
|
||||
"en": "line(s): that is what resetting gives up.",
|
||||
},
|
||||
"What do you want to do with these customizations?": {
|
||||
"fr": "Que voulez-vous faire de ces personnalisations ?",
|
||||
"en": "What do you want to do with these customizations?",
|
||||
},
|
||||
"Enter = nothing": {
|
||||
"fr": "Entrée = rien",
|
||||
"en": "Enter = nothing",
|
||||
},
|
||||
"what the copy changed": {
|
||||
"fr": "ce que la copie a changé",
|
||||
"en": "what the copy changed",
|
||||
},
|
||||
"reset them onto the module file": {
|
||||
"fr": "les réinitialiser sur le fichier du module",
|
||||
"en": "reset them onto the module file",
|
||||
},
|
||||
"Full screen view unavailable.": {
|
||||
"fr": "Vue plein écran indisponible.",
|
||||
"en": "Full screen view unavailable.",
|
||||
},
|
||||
"Saved before resetting": {
|
||||
"fr": "Sauvegardé avant réinitialisation",
|
||||
"en": "Saved before resetting",
|
||||
},
|
||||
"Reset failed, nothing was changed.": {
|
||||
"fr": "La réinitialisation a échoué, rien n'a été modifié.",
|
||||
"en": "Reset failed, nothing was changed.",
|
||||
},
|
||||
"Reset done.": {
|
||||
"fr": "Réinitialisation faite.",
|
||||
"en": "Reset done.",
|
||||
},
|
||||
"This copy uses variables that": {
|
||||
"fr": "Cette copie utilise des variables que",
|
||||
"en": "This copy uses variables that",
|
||||
},
|
||||
"no longer defines": {
|
||||
"fr": "ne définit plus",
|
||||
"en": "no longer defines",
|
||||
},
|
||||
"The copy was written against an older version and frozen": {
|
||||
"fr": "La copie a été écrite contre une version antérieure et figée",
|
||||
"en": "The copy was written against an older version and frozen",
|
||||
},
|
||||
"there. The module has since renamed what it relies on.": {
|
||||
"fr": "là. Le module a depuis renommé ce dont elle dépend.",
|
||||
"en": "there. The module has since renamed what it relies on.",
|
||||
},
|
||||
"Resetting restores": {
|
||||
"fr": "La réinitialisation rend",
|
||||
"en": "Resetting restores",
|
||||
},
|
||||
"(nothing: the target no longer ships this file)": {
|
||||
"fr": "(rien : la cible ne livre plus ce fichier)",
|
||||
"en": "(nothing: the target no longer ships this file)",
|
||||
},
|
||||
"Reset command copied.": {
|
||||
"fr": "Commande de réinitialisation copiée.",
|
||||
"en": "Reset command copied.",
|
||||
},
|
||||
"Nothing to decide yet": {
|
||||
"fr": "Rien à décider pour l'instant",
|
||||
"en": "Nothing to decide yet",
|
||||
|
|
|
|||
|
|
@ -1645,17 +1645,13 @@ class TodoUpgrade:
|
|||
# une copie figée, et un palier renomme aussi des variables. Celle-ci
|
||||
# ne se voit qu'à l'écran d'une page — « Style error », sans dire quel
|
||||
# fichier ni depuis quand — et seulement une fois le palier passé.
|
||||
status_scss, _cmd, _out = self.todo_upgrade_execute(
|
||||
# Sur le VRAI terminal : l'outil pose lui-même ses questions — voir
|
||||
# l'écart, l'ouvrir en plein écran, réinitialiser — et un tube les
|
||||
# rendrait toutes injoignables, comme il fermait déjà la TUI.
|
||||
self.run_on_terminal(
|
||||
f"{PYTHON_BIN} ./script/odoo/migration/check_stale_scss.py"
|
||||
f" -d {database_name} -t odoo{start_version + 1}.0",
|
||||
get_output=True,
|
||||
wait_at_error=False,
|
||||
f" -d {database_name} -t odoo{start_version + 1}.0"
|
||||
)
|
||||
if status_scss == 1:
|
||||
self.ask_gate(
|
||||
f"💬 {t('Read the customizations above before continuing.')}"
|
||||
f" {t('(b = go back to a previous step)')} : "
|
||||
)
|
||||
|
||||
msg = "3 - Clean up database before data migration"
|
||||
self.print_step(msg)
|
||||
|
|
|
|||
|
|
@ -101,45 +101,175 @@ class TestTheReport(unittest.TestCase):
|
|||
)
|
||||
todo_i18n._current_lang = "en"
|
||||
|
||||
def finding(self, **override):
|
||||
data = {
|
||||
"id": 1110,
|
||||
"url": "/a/b.custom.web.assets_frontend.scss",
|
||||
"missing": ["o-theme-font-number"],
|
||||
"custom": "a { b: $o-theme-font-number; }",
|
||||
"base_url": "/a/b.scss",
|
||||
"bundle": "web.assets_frontend",
|
||||
"module_path": "odoo13.0/a/b.scss",
|
||||
"module_content": "a { b: 1; }",
|
||||
"version_dir": "odoo13.0",
|
||||
"database": "db",
|
||||
}
|
||||
data.update(override)
|
||||
return data
|
||||
|
||||
def test_nothing_at_risk_says_so(self):
|
||||
text = scss.render([], "db", "odoo13.0")
|
||||
self.assertIn("✅", text)
|
||||
|
||||
def test_a_finding_names_the_attachment_and_the_variable(self):
|
||||
text = scss.render(
|
||||
[
|
||||
(
|
||||
1110,
|
||||
"/a/b.custom.web.assets_frontend.scss",
|
||||
["o-theme-font-number"],
|
||||
)
|
||||
],
|
||||
"db",
|
||||
"odoo13.0",
|
||||
)
|
||||
text = scss.render([self.finding()], "db", "odoo13.0")
|
||||
self.assertIn("1110", text)
|
||||
self.assertIn("$o-theme-font-number", text)
|
||||
|
||||
def test_it_prints_a_command_that_can_be_pasted(self):
|
||||
# Un diagnostic sans le geste qui répare oblige à rechercher les deux
|
||||
# arguments de reset_asset au pire moment.
|
||||
text = scss.render(
|
||||
[(1110, "/a/b.custom.web.assets_frontend.scss", ["x"])],
|
||||
"mydb",
|
||||
"odoo13.0",
|
||||
)
|
||||
text = scss.render([self.finding()], "mydb", "odoo13.0")
|
||||
self.assertIn("reset_asset('/a/b.scss', 'web.assets_frontend')", text)
|
||||
self.assertIn("-d mydb", text)
|
||||
|
||||
def test_it_warns_before_dropping(self):
|
||||
text = scss.render(
|
||||
[(1110, "/a/b.custom.web.assets_frontend.scss", ["x"])],
|
||||
"db",
|
||||
"odoo13.0",
|
||||
)
|
||||
text = scss.render([self.finding()], "db", "odoo13.0")
|
||||
self.assertIn("real customization", text)
|
||||
|
||||
|
||||
class TestTheDiff(unittest.TestCase):
|
||||
"""Ce que la copie a changé : la seule chose que réinitialiser perd."""
|
||||
|
||||
def setUp(self):
|
||||
from script.todo import todo_i18n
|
||||
|
||||
self.addCleanup(
|
||||
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
|
||||
)
|
||||
todo_i18n._current_lang = "en"
|
||||
|
||||
def finding(self, **override):
|
||||
data = {
|
||||
"id": 1110,
|
||||
"url": "/a/b.custom.web.assets_frontend.scss",
|
||||
"missing": ["x"],
|
||||
"custom": "a { b: 2; }",
|
||||
"base_url": "/a/b.scss",
|
||||
"bundle": "web.assets_frontend",
|
||||
"module_path": "odoo13.0/a/b.scss",
|
||||
"module_content": "a { b: 1; }",
|
||||
"version_dir": "odoo13.0",
|
||||
"database": "db",
|
||||
}
|
||||
data.update(override)
|
||||
return data
|
||||
|
||||
def test_it_counts_what_would_be_lost(self):
|
||||
text = scss.render_diff(self.finding())
|
||||
self.assertIn("+1/-1", text)
|
||||
|
||||
def test_an_identical_copy_says_it_loses_nothing(self):
|
||||
text = scss.render_diff(self.finding(custom="a { b: 1; }"))
|
||||
self.assertIn("identical", text)
|
||||
|
||||
def test_a_missing_module_file_is_said_not_guessed(self):
|
||||
# Si la cible ne livre plus le fichier, il n'y a rien sur quoi
|
||||
# retomber : le taire ferait accepter une réinitialisation vide.
|
||||
text = scss.render_diff(self.finding(module_path=None))
|
||||
self.assertIn("no longer ships", text)
|
||||
|
||||
|
||||
class TestThePrompt(unittest.TestCase):
|
||||
"""Regarder ne doit pas répondre à la question."""
|
||||
|
||||
def setUp(self):
|
||||
from script.todo import todo_i18n
|
||||
|
||||
self.addCleanup(
|
||||
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
|
||||
)
|
||||
todo_i18n._current_lang = "en"
|
||||
|
||||
def finding(self):
|
||||
return {
|
||||
"id": 1,
|
||||
"url": "/a/b.custom.web.assets_frontend.scss",
|
||||
"missing": ["x"],
|
||||
"custom": "a { b: 2; }",
|
||||
"base_url": "/a/b.scss",
|
||||
"bundle": "web.assets_frontend",
|
||||
"module_path": "odoo13.0/a/b.scss",
|
||||
"module_content": "a { b: 1; }",
|
||||
"version_dir": "odoo13.0",
|
||||
"database": "db",
|
||||
}
|
||||
|
||||
def run_prompt(self, answers):
|
||||
import contextlib
|
||||
import io
|
||||
|
||||
seq = iter(answers)
|
||||
out = io.StringIO()
|
||||
with contextlib.redirect_stdout(out):
|
||||
wrote = scss.prompt(
|
||||
[self.finding()], "db", ask=lambda prompt: next(seq)
|
||||
)
|
||||
return wrote, out.getvalue()
|
||||
|
||||
def test_enter_writes_nothing(self):
|
||||
wrote, text = self.run_prompt([""])
|
||||
self.assertFalse(wrote)
|
||||
self.assertEqual(text, "")
|
||||
|
||||
def test_v_shows_the_diff_and_asks_again(self):
|
||||
# Sans le « asks again », montrer vaudrait réponse : on aurait vu le
|
||||
# diff et perdu la main sur la décision.
|
||||
wrote, text = self.run_prompt(["v", ""])
|
||||
self.assertFalse(wrote)
|
||||
self.assertIn("+1/-1", text)
|
||||
|
||||
def test_apply_saves_a_copy_before_writing(self):
|
||||
# reset_asset SUPPRIME la pièce jointe : sans sauvegarde préalable,
|
||||
# les lignes personnalisées ne seraient plus nulle part.
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
previous = os.getcwd()
|
||||
self.addCleanup(os.chdir, previous)
|
||||
os.chdir(tempfile.mkdtemp())
|
||||
seen = {}
|
||||
original = scss.apply_reset
|
||||
scss.apply_reset = lambda lst, db, cfg="./config.conf": (
|
||||
seen.update(called=True),
|
||||
(0, "ok"),
|
||||
)[1]
|
||||
self.addCleanup(setattr, scss, "apply_reset", original)
|
||||
wrote, text = self.run_prompt(["a"])
|
||||
self.assertTrue(wrote)
|
||||
self.assertTrue(seen.get("called"))
|
||||
import glob
|
||||
|
||||
saved = glob.glob("private/odoo/migration/db/scss_backup/*")
|
||||
self.assertEqual(len(saved), 1)
|
||||
with open(saved[0]) as handle:
|
||||
self.assertEqual(handle.read(), "a { b: 2; }")
|
||||
|
||||
def test_a_failed_reset_says_nothing_changed(self):
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
previous = os.getcwd()
|
||||
self.addCleanup(os.chdir, previous)
|
||||
os.chdir(tempfile.mkdtemp())
|
||||
original = scss.apply_reset
|
||||
scss.apply_reset = lambda lst, db, cfg="./config.conf": (1, "boom")
|
||||
self.addCleanup(setattr, scss, "apply_reset", original)
|
||||
wrote, text = self.run_prompt(["a"])
|
||||
self.assertFalse(wrote)
|
||||
self.assertIn("nothing was changed", text)
|
||||
|
||||
|
||||
class TestTheMigrationRunsIt(unittest.TestCase):
|
||||
def test_it_is_checked_at_the_same_place_as_the_cow_views(self):
|
||||
# Les deux prédictions valent avant le palier, pas après : c'est le
|
||||
|
|
@ -155,6 +285,32 @@ class TestTheMigrationRunsIt(unittest.TestCase):
|
|||
source.index("4 - Upgrade version with OpenUpgrade"),
|
||||
)
|
||||
|
||||
def test_it_gets_a_real_terminal(self):
|
||||
# L'outil pose lui-même ses questions et peut ouvrir un plein écran :
|
||||
# un tube les rendrait toutes injoignables, sans rien signaler.
|
||||
import inspect
|
||||
|
||||
from script.todo.todo_upgrade import TodoUpgrade
|
||||
|
||||
source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
|
||||
# On ne compare pas à une mise en forme — black la change — mais à
|
||||
# l'appel le plus proche EN AMONT : c'est lui qui exécute.
|
||||
avant = source[: source.index("check_stale_scss.py")]
|
||||
self.assertGreater(
|
||||
avant.rfind("run_on_terminal("),
|
||||
avant.rfind("todo_upgrade_execute("),
|
||||
"l'outil repasse par l'exécuteur à tube",
|
||||
)
|
||||
|
||||
|
||||
class TestThePromptStaysOutOfAPipe(unittest.TestCase):
|
||||
def test_it_only_asks_in_front_of_a_terminal(self):
|
||||
# Une invite dans un tube bloquerait l'appelant sur une question que
|
||||
# personne ne voit.
|
||||
with open(scss.__file__) as handle:
|
||||
source = handle.read()
|
||||
self.assertIn("sys.stdin.isatty()", source)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
Loading…
Reference in a new issue