diff --git a/.claude/rules/04-code-conventions.md b/.claude/rules/04-code-conventions.md index 4001c2f..b692e75 100644 --- a/.claude/rules/04-code-conventions.md +++ b/.claude/rules/04-code-conventions.md @@ -16,6 +16,40 @@ indentations par type de fichier. caractères au plus. Tags réellement utilisés : `[UPD]`, `[FIX]`, `[ADD]`, `[IMP]`, `[REF]`. +### Le sujet + +Le sujet est lu cent fois pour une fois que le corps l'est — `git log +--oneline`, un blame, une note de version, un bisect. Il a une seule tâche : +dire **sur quoi porte le code**. + +L'épreuve : le lire seul, sans diff ni corps. Sait-on quelle partie du système +est en jeu, et ce qui y est désormais différent ? Sinon il n'est pas fini. + +Nommer la chose, puis ce qui change pour elle. Le symptôme, le message d'erreur +cité et la métaphore sont des PREUVES, et une preuve va dans le corps — un +sujet bâti sur elles se lit bien et n'apprend rien. La portée dit OÙ, les mots +après le deux-points doivent dire QUOI. + +Le sujet résume le commit ENTIER, pas sa plus grosse pièce. S'il lui faut un +« et » entre deux choses sans rapport, c'étaient deux commits. + +Si le travail n'entre décidément pas dans une phrase de 72 caractères, ne pas +en écrire une amputée : des **mots-clés qui résument**, séparés par des +virgules, en disent plus dans la même place — `[FIX] proxmox : pmxcfs à terre, +pvesm muet, diagnostic à la source`. C'est un repli, pas un défaut : la phrase +reste préférable quand elle tient. + +Un garde-fou refuse le mécanique — tag absent, plus de 72 caractères, sujet +qui s'ouvre sur une citation : + +```bash +git config core.hooksPath script/git/hooks # une fois par clone +git commit --no-verify # exception légitime +``` + +Le mode d'emploi complet, avec des exemples avant/après pris dans l'historique +de ce dépôt, est dans `conf/template_claude_commands_commit.md`. + ### Tout commit assisté par IA Trois exigences, sans exception — `AI_POLICY.md` en donne la raison : diff --git a/conf/template_claude_commands_commit.md b/conf/template_claude_commands_commit.md index e97ca07..01fd745 100644 --- a/conf/template_claude_commands_commit.md +++ b/conf/template_claude_commands_commit.md @@ -90,6 +90,57 @@ The same body, translated. Assisted-by: {MODEL} ``` +### The subject line + +The subject is read a hundred times for every time the body is read: in +`git log --oneline`, in a blame, in a release note, in a bisect. It has one +job — say what the code is about. + +**The test.** Read the subject alone, with no diff and no body. Can you say +which part of the system it concerns, and what is now different about it? If +not, it is not finished. + +**Name the thing, then what changed about it.** The symptom, the quoted error +and the metaphor are EVIDENCE, and evidence belongs in the body. A subject +built on them reads well and tells the next reader nothing: + +| Instead of | Write | +|-----------|-------| +| `[FIX] nettoyage : les enfants s'en vont avec leur rebond` | `[FIX] nettoyage : les entrées ssh qui rebondissent par une VM effacée` | +| `[FIX] proxmox : « il manque le stockage » était le symptôme, pas la cause` | `[FIX] proxmox : signaler pmxcfs à terre, et non « aucun stockage »` | +| `[FIX] migration: un module fautif n'emporte plus tout le lot` | `[FIX] migration: isoler l'échec d'un module dans la désinstallation` | + +The scope is not the subject. `proxmox` says WHERE; the words after the colon +must say WHAT. A subject that works with its scope removed is usually the +right one. + +**Summarise the whole commit, not its largest piece.** When the work has two +faces — a guard moved and the check that proves it, a screen and the service +under it — the subject covers both or the commit should have been two. If the +only honest subject needs an `and` joining two unrelated things, split it. + +**It must be complete in 72 characters.** A subject cut mid-phrase by +`--oneline` has failed at the one place it is read most. Write it to fit +rather than trimming it afterwards: drop the adjectives, keep the nouns. + +When the work genuinely will not fit in a sentence, do not write an amputated +one — write **keywords that summarise**. A comma-separated list of the nouns +that matter says more in the space than half a sentence does: + +``` +[FIX] proxmox : pmxcfs à terre, pvesm muet, diagnostic à la source +[ADD] migration : copies de site, index doublés, réglages perdus +``` + +That form is a fallback, not a default. Prefer the sentence when it fits. + +**The guard rail.** `script/git/hooks/commit-msg` refuses a subject with no +tag, one over 72 characters, and one opening on a quotation. Install it with +`git config core.hooksPath script/git/hooks`; `git commit --no-verify` passes +a legitimate exception. It checks only what is mechanical — whether the +subject says what the code is about stays a judgement, and the test above is +how you make it. + ### Keep it short The body answers one question: why was this necessary. Stop once it is diff --git a/manifest/git_manifest_odoo13.0.xml b/manifest/git_manifest_odoo13.0.xml index b7c0b2f..be38851 100644 --- a/manifest/git_manifest_odoo13.0.xml +++ b/manifest/git_manifest_odoo13.0.xml @@ -2,6 +2,7 @@ + @@ -240,6 +241,13 @@ revision="13.0" groups="addons,odoo13.0" /> + + @@ -244,6 +245,13 @@ revision="15.0" groups="addons,odoo15.0" /> + /dev/null & + EL_WARMUP_PID=$! + # Elle ne doit pas survivre au serveur qu'elle réveille. Un Ctrl-C sur + # run.sh doit tout emporter. + trap '[ -n "${EL_WARMUP_PID}" ] && kill "${EL_WARMUP_PID}" 2>/dev/null' EXIT +fi + if [ "$ODOO_MODE_TEST" = "true" ]; then ./odoo_bin.sh -c "${CONFIG_PATH}" --limit-time-real 99999 --limit-time-cpu 99999 --limit-memory-hard=0 --log-level=test --test-enable --no-http --stop-after-init "${EL_DB[@]}" "${EL_ARGS[@]}" else diff --git a/script/analyse/anonymize.py b/script/analyse/anonymize.py index 63be592..e9ab434 100755 --- a/script/analyse/anonymize.py +++ b/script/analyse/anonymize.py @@ -106,6 +106,24 @@ CHAMPS_INTERDITS = frozenset( ) CHAMPS_CONNEXION = frozenset({"login", "password"}) +# Des `char` d'Odoo qui portent une STRUCTURE, pas du texte. Odoo les +# reparse, et un mot y fait lever le serveur entier : +# parent_path → `int(id) for id in parent_path.split('/')` +# (base/models/res_company.py:117, models.py:203) +# days_next_month → `int(self.days_next_month)` +# (account/models/account_payment_term.py:321) +# `parent_path` existe sur tout modèle `_parent_store` — sept dans une +# base ordinaire. La sonde de contenu ci-dessous les retrouverait, mais +# les nommer coûte moins cher qu'une requête et ne dépend pas des données +# présentes le jour où l'on passe. +CHAMPS_STRUCTURES = frozenset({"parent_path", "days_next_month"}) + +# Un chemin d'identifiants : « 1/ », « 1/7/12/ ». Le motif exige les +# barres obliques — sans elles, un numéro de téléphone tout en chiffres +# serait pris pour une structure et échapperait à l'anonymisation, ce +# qui serait un défaut de confidentialité, pas de robustesse. +MOTIF_CHEMIN = r"^[0-9]+(/[0-9]+)*/$" + # Le point de départ du mode hybride : ce qui porte des données # personnelles dans une base Odoo ordinaire. MODELES_PAR_DEFAUT = ( @@ -197,12 +215,19 @@ def champ_retenu(champ, inclure_connexion=False): if champ["name"].endswith("_id") or champ["name"].endswith("_ids"): # Une relation qui aurait échappé au filtre de ttype. return False + if champ["name"] in CHAMPS_STRUCTURES: + return False if champ.get("checked"): - # Une contrainte CHECK dit ce que la colonne a le droit de valoir. - # Mesuré : crm_lead.probability doit rester entre 0 et 100, et un - # tirage à 1000 fait échouer l'UPDATE — donc, transaction unique - # oblige, TOUTE l'anonymisation. Lire l'expression du CHECK pour - # tirer dedans serait deviner ; on s'abstient et on le dit. + # Une contrainte CHECK hors de portée. Mesuré, et la distinction + # compte : sur un NOMBRE toute contrainte borne la valeur — + # crm_lead.probability entre 0 et 100, credit * debit = 0 — et un + # tirage à 1000 fait échouer l'UPDATE, donc toute l'anonymisation. + # Sur du TEXTE, presque toutes ne garantissent que la non-nullité : + # `res_partner.name` n'exige d'être non nul que pour un contact, ce + # qu'un mot satisfait. Les écarter TOUTES laissait le champ le plus + # important de la base intact — une anonymisation qui n'anonymisait + # pas les noms. La requête ne lève donc ce drapeau, pour du texte, + # que sur les contraintes de FORME. return False if champ["ttype"] in TYPES_NOMBRE and champ.get("pg_type") == "jsonb": # Mesuré sur res_partner.credit_limit : un `float` d'Odoo peut @@ -233,22 +258,46 @@ def litteral(texte): return "'" + str(texte).replace("'", "''") + "'" +def ident(nom): + """Un identifiant SQL, cité. + + Odoo laisse nommer un champ `user`, `order` ou `group` : ce sont des + mots réservés de PostgreSQL, et un identifiant nu fait échouer + l'analyse syntaxique — donc, transaction unique oblige, TOUTE + l'anonymisation. Mesuré en liste noire sur une base réelle : + « syntax error at or near "user" ». Les citer coûte deux caractères + et ferme la question pour tous les noms à venir. + """ + return '"' + str(nom).replace('"', '""') + '"' + + def expression_texte(champ, mots): """Le SQL qui remplace un champ texte, en préservant les NULL. Un NULL qui deviendrait un mot créerait de la donnée là où il n'y en avait pas : la copie mentirait dans l'autre sens. """ - nom = champ["name"] - liste = mots_pour(nom, mots) + nom = ident(champ["name"]) + liste = mots_pour(champ["name"], mots) # Les parenthèses ne sont pas décoratives : PostgreSQL refuse # d'indexer un constructeur ARRAY[...] directement. tableau = "(ARRAY[" + ",".join(litteral(m) for m in liste) + "])" - tirage = f"{tableau}[(id % {len(liste)}) + 1]" + tirage = f'{tableau}[("id" % {len(liste)}) + 1]' + borne = champ.get("max_len") if champ.get("unique"): # Deux lignes qui reçoivent le même mot feraient échouer TOUT - # l'UPDATE sur une colonne unique. - tirage = f"{tirage} || '-' || id::text" + # l'UPDATE sur une colonne unique. L'identifiant vient EN TÊTE + # quand la colonne est bornée : c'est lui qui porte l'unicité, et + # une troncature par la droite doit le laisser intact. + if borne: + tirage = f"\"id\"::text || '-' || {tirage}" + else: + tirage = f"{tirage} || '-' || \"id\"::text" + if borne: + # varchar(n) : mesuré, 13 colonnes sont bornées sur une base + # réelle, dont des codes à 1, 2 et 3 caractères. « jonquille » + # dans un varchar(3) fait échouer l'UPDATE entier. + tirage = f"left({tirage}, {borne})" if champ.get("pg_type") == "jsonb": # Un objet par langue depuis Odoo 17 : on le reconstruit clé à # clé. Écrire une chaîne par-dessus détruirait la colonne. @@ -261,12 +310,40 @@ def expression_texte(champ, mots): def expression_nombre(champ): - """Le SQL qui remplace un nombre : au hasard, entre 0 et 1000.""" - nom = champ["name"] - if champ["ttype"] == "integer": - tirage = "floor(random() * 1001)::integer" + """Le SQL qui remplace un nombre, DANS l'étendue de la colonne. + + 0 à 1000 était l'intention, et c'est faux pour tout nombre qui porte + un sens borné. Mesuré : `resource.calendar.attendance.hour_from` est + un `float` qui vaut une heure de la journée — 8,00 à 13,00 dans la + base d'origine. Un tirage à 957 fait lever Odoo : + + time(int(integral), ...) → ValueError: hour must be in 0..23 + resource/models/utils.py:45 + + Aucune contrainte PostgreSQL ne dit cela : la borne vit dans le code. + La seule que les DONNÉES déclarent est leur propre étendue, et c'est + celle qu'on respecte. Elle protège aussi les pourcentages, les taux et + les quantités, sans qu'il faille les nommer un par un. + + Sans étendue connue — colonne vide, ou sonde impossible — on retombe + sur 0 à 1000. + """ + nom = ident(champ["name"]) + bas, haut = champ.get("borne_min"), champ.get("borne_max") + entier = champ["ttype"] == "integer" + if bas is None or haut is None: + tirage = ( + "floor(random() * 1001)::integer" + if entier + else "round((random() * 1000)::numeric, 2)" + ) + elif entier: + # +1 pour que la borne haute soit atteignable ; si bas == haut, + # le tirage rend cette valeur, ce qui est sans risque : une + # colonne constante ne porte aucune information à masquer. + tirage = f"floor({bas} + random() * ({haut} - {bas} + 1))::integer" else: - tirage = "round((random() * 1000)::numeric, 2)" + tirage = f"round(({bas} + random() * ({haut} - {bas}))::numeric, 2)" return f"CASE WHEN {nom} IS NULL THEN NULL ELSE {tirage} END" @@ -275,14 +352,13 @@ def sql_pour_table(table, champs, mots): morceaux = [] for champ in champs: if champ["ttype"] in TYPES_TEXTE: - morceaux.append( - f"{champ['name']} = {expression_texte(champ, mots)}" - ) + valeur = expression_texte(champ, mots) else: - morceaux.append(f"{champ['name']} = {expression_nombre(champ)}") + valeur = expression_nombre(champ) + morceaux.append(f"{ident(champ['name'])} = {valeur}") if not morceaux: return None - return f"UPDATE {table} SET " + ", ".join(morceaux) + ";" + return f"UPDATE {ident(table)} SET " + ", ".join(morceaux) + ";" def table_de(modele): @@ -311,7 +387,19 @@ SELECT f.model || '\x1f' || f.name || '\x1f' || f.ttype || '\x1f' WHERE k.conrelid = c.oid AND k.contype = 'c' AND a.attnum = ANY(k.conkey) - ) THEN '1' ELSE '0' END + AND ( + -- Sur un NOMBRE, toute contrainte borne la valeur : + -- `credit * debit = 0`, `amount >= 0`. On s'abstient. + f.ttype IN ('integer','float','monetary') + -- Sur du TEXTE, presque toutes ne garantissent que la + -- non-nullité, ce qu'un mot satisfait. Seules celles + -- qui contraignent la FORME sont hors de portée. + OR pg_get_constraintdef(k.oid) ~ + 'char_length|~~|jsonb_typeof|similar to' + ) + ) THEN '1' ELSE '0' END || '\x1f' + || CASE WHEN a.atttypmod > 4 + THEN (a.atttypmod - 4)::text ELSE '' END FROM ir_model_fields f JOIN pg_class c ON c.relname = replace(f.model, '.', '_') AND c.relkind = 'r' @@ -333,7 +421,7 @@ def inspect(database, config_path=None): champs = [] for ligne in brut.splitlines(): parts = ligne.split(SEP) - if len(parts) != 6: + if len(parts) != 7: continue champs.append( { @@ -343,6 +431,11 @@ def inspect(database, config_path=None): "pg_type": parts[3], "unique": parts[4] == "1", "checked": parts[5] == "1", + # varchar(n) : n, sinon None. Mesuré sur une base réelle, + # 13 colonnes sont bornées — dont des codes à 1, 2 et 3 + # caractères. Y écrire « jonquille » fait échouer tout + # l'UPDATE, et donc toute l'anonymisation. + "max_len": int(parts[6]) if parts[6].isdigit() else None, } ) return champs @@ -383,6 +476,119 @@ def plan( return etapes +def sonder_colonnes(database, etapes, config_path=None): + """Regarder ce que les colonnes CONTIENNENT. Rendre (écartées, bornes). + + Deux mesures, une seule requête par table — sur une liste noire de 410 + modèles, la différence est de 410 allers-retours au lieu de 1 300. + + ÉCARTÉES — les colonnes texte dont TOUT le contenu est un chemin + d'identifiants. Le filet général, là où `CHAMPS_STRUCTURES` ne nomme + que le connu : un module maison peut poser le sien sans lui donner ce + nom. Une seule valeur non conforme suffit à garder la colonne — mieux + vaut anonymiser une colonne douteuse que taire une donnée personnelle. + + BORNES — l'étendue réelle de chaque colonne numérique. C'est la seule + borne que les données déclarent, et elle vaut pour toutes celles que + le code d'Odoo impose sans que PostgreSQL en sache rien : heures de la + journée, pourcentages, taux. + """ + ecartees, bornes = {}, {} + for etape in etapes: + textes = [ + champ + for champ in etape["fields"] + if champ["ttype"] in TYPES_TEXTE and champ["pg_type"] != "jsonb" + ] + nombres = [ + champ + for champ in etape["fields"] + if champ["ttype"] in TYPES_NOMBRE and champ["pg_type"] != "jsonb" + ] + if not textes and not nombres: + continue + morceaux = [] + for champ in textes: + nom = ident(champ["name"]) + morceaux.append( + f"count(*) FILTER (WHERE {nom} IS NOT NULL AND {nom} <> '')" + f" || ':' || count(*) FILTER (WHERE {nom} ~ {litteral(MOTIF_CHEMIN)})" + ) + for champ in nombres: + nom = ident(champ["name"]) + morceaux.append( + f"coalesce(min({nom})::text, '') || ':'" + f" || coalesce(max({nom})::text, '')" + ) + sql = ( + "SELECT " + + " || '\x1f' || ".join(morceaux) + + f" FROM {ident(table_de(etape['model']))}" + ) + try: + brut = lib_analyse.run_psql( + database, sql, config_path=config_path + ).strip() + except Exception: # noqa: BLE001 - une table illisible ne bloque pas + continue + mesures = brut.split(SEP) + if len(mesures) != len(textes) + len(nombres): + continue + for champ, mesure in zip(textes, mesures): + try: + remplies, chemins = (int(x) for x in mesure.split(":")) + except ValueError: + continue + if remplies and remplies == chemins: + ecartees.setdefault(etape["model"], []).append(champ["name"]) + for champ, mesure in zip(nombres, mesures[len(textes) :]): + bas, _, haut = mesure.partition(":") + if nombre_valide(bas) and nombre_valide(haut): + bornes.setdefault(etape["model"], {})[champ["name"]] = ( + bas, + haut, + ) + return ecartees, bornes + + +def nombre_valide(texte): + """Un littéral numérique, et rien d'autre. + + Ces valeurs viennent de la base mais retournent dans du SQL : on ne + les recopie qu'après avoir vérifié qu'elles sont bien des nombres. + """ + try: + float(texte) + except (TypeError, ValueError): + return False + return True + + +def appliquer_sondes(etapes, ecartees, bornes, mots): + """Refaire le plan sans les écartées, et avec les bornes mesurées.""" + propre = [] + for etape in etapes: + exclues = set(ecartees.get(etape["model"], ())) + mesures = bornes.get(etape["model"], {}) + gardes = [] + for champ in etape["fields"]: + if champ["name"] in exclues: + continue + borne = mesures.get(champ["name"]) + gardes.append( + {**champ, "borne_min": borne[0], "borne_max": borne[1]} + if borne + else champ + ) + # Pas de garde sur une liste vide : `sql_pour_table` rend None, et + # le `if sql` ci-dessous l'écarte. Deux vérifications pour la même + # chose se contredisent un jour. + sql = sql_pour_table(table_de(etape["model"]), gardes, mots) + if sql: + propre.append({**etape, "fields": gardes, "sql": sql}) + return propre + + def render(etapes, applique=False, verbeux=False): """Le rapport. Il dit ce qui est ÉCARTÉ autant que ce qui est pris.""" if not etapes: @@ -446,29 +652,56 @@ def ecrire(database, etapes, config_path=None, timeout=900): redéclarer ici, ce serait accepter qu'elle diverge un jour. """ import subprocess + import tempfile env = lib_analyse.pg_env(config_path, timeout=timeout) env["PGOPTIONS"] = f"-c statement_timeout={timeout}s" sql = "\n".join(etape["sql"] for etape in etapes) - done = subprocess.run( - [ - "psql", - "-X", - "-w", - "-1", - "-v", - "ON_ERROR_STOP=1", - "-d", - database, - "-tA", - "-c", - sql, - ], - capture_output=True, - text=True, - env=env, - timeout=timeout + 60, + + # PAR FICHIER, jamais par `-c`. Linux plafonne un seul argument à + # MAX_ARG_STRLEN — 32 pages, soit 131 072 octets. Mesuré sur une base + # réelle : le mode hybride tient dans 58 Ko et passait, la liste noire + # produit 342 Ko sur 410 modèles et rendait « OSError: [Errno 7] + # Argument list too long ». Le mode qui couvre le plus est justement + # celui qui cassait. + # + # `-f` plutôt que l'entrée standard : `--single-transaction` n'est + # documenté qu'avec `-c` ou `-f`, et c'est lui qui garantit le tout + # ou rien. Le perdre en silence serait pire que le message d'erreur. + handle = tempfile.NamedTemporaryFile( + mode="w", + suffix=".sql", + prefix="el_anonymize_", + encoding="utf-8", + delete=False, ) + try: + handle.write(sql) + handle.close() + done = subprocess.run( + [ + "psql", + "-X", + "-w", + "-1", + "-v", + "ON_ERROR_STOP=1", + "-d", + database, + "-tA", + "-f", + handle.name, + ], + capture_output=True, + text=True, + env=env, + timeout=timeout + 60, + ) + finally: + try: + os.unlink(handle.name) + except OSError: + pass if done.returncode: detail = (done.stderr or "").strip().splitlines() return detail[0][:200] if detail else "psql" @@ -534,6 +767,20 @@ def main(argv=None): args.include_logins, mots, ) + # La sonde AVANT le rendu : la marche à blanc doit montrer ce que + # `--apply` ferait, pas une approximation plus large. + ecartees, bornes = sonder_colonnes(args.database, etapes, args.config) + etapes = appliquer_sondes(etapes, ecartees, bornes, mots) + if ecartees: + combien = sum(len(v) for v in ecartees.values()) + print( + f"🧭 {combien} {t('column(s) hold identifier paths and are left')}" + f" {t('alone:')}" + ) + for modele in sorted(ecartees): + print(f" {modele} : {', '.join(sorted(ecartees[modele]))}") + print() + if not args.apply: print(render(etapes, applique=False, verbeux=args.verbose)) return 1 if etapes else 0 diff --git a/script/analyse/check_chart_drift.py b/script/analyse/check_chart_drift.py new file mode 100755 index 0000000..364ae16 --- /dev/null +++ b/script/analyse/check_chart_drift.py @@ -0,0 +1,376 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Ce qu'une montée de version ajoute au référentiel comptable, sans le dire. + +Le cas qui a fait écrire cet outil, mesuré sur une migration 12 → 18 : + + palier 17 : 96 comptes, 32 taxes, 14 positions, 0 groupe + palier 18 : 427 comptes, 64 taxes, 28 positions, 168 groupes + +Le script de migration de `l10n_ca` recharge le plan canadien `ca_2023` +par `try_loading(...)` SANS `force_create=False` — 31 des 33 scripts de +localisation du noyau le passent, trois l'oublient. Les codes du client +(sa propre numérotation) ne recouvrent celui du gabarit que sur 15 codes +sur 341 : les 326 autres ont été CRÉÉS. + +Rien n'est détruit — les écritures ne bougent pas, les journaux gardent +leurs comptes. Mais trois écrans se remplissent de doublons, et surtout +les 168 groupes RECLASSENT les comptes du client : un compte fournisseurs +de 1067 écritures apparaît sous « Residential Mortgage Loans ». + +Pourquoi une comparaison, et non un compte absolu +------------------------------------------------- +« 427 comptes » ne dit rien : un plan peut légitimement en compter mille. +Ce qui se juge, c'est l'ÉCART entre deux paliers de la même migration, et +la signature qui l'accompagne — des comptes ajoutés sans une écriture, +des noms qui deviennent homonymes, une grille de regroupement qui +apparaît là où il n'y en avait aucune. + +L'outil ne répare rien. Il ne peut pas : la clé naturelle de suppression +attrape aussi des comptes du client réappariés par code, et douze des +comptes ajoutés sont accrochés en ON DELETE SET NULL. La bonne réponse +est de rejouer le palier avec le correctif, pas d'opérer la base livrée. +""" + +from __future__ import annotations + +import os +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 + + +from script.analyse import check_migration_quality as quality # noqa: E402 +from script.analyse import lib_analyse # noqa: E402 + +COULEURS = { + "broken": "\033[31m", + "watch": "\033[33m", + "ok": "\033[32m", + "step": "\033[36m", + "dim": "\033[90m", +} +RESET = "\033[0m" + + +def paint(texte, genre, colour): + """Teinter, ou rendre le texte tel quel quand la couleur est coupée.""" + if not colour: + return texte + return f"{COULEURS.get(genre, '')}{texte}{RESET}" + + +# Ce que le référentiel comptable porte, et ce qu'un gonflement y veut +# dire. `gravity` suit la règle du dépôt : `broken` pour ce qui est faux +# en soi, `watch` pour ce qui mérite un regard. +TABLES = ( + { + "key": "account", + "table": "account_account", + "title": "Accounts in the chart", + "why": "A reloaded chart template adds every account it could not" + " match by code. They carry no entry, and they bury the" + " customer's own chart.", + "gravity": "broken", + }, + { + "key": "tax", + "table": "account_tax", + "title": "Taxes", + "why": "Reloaded taxes are not renamed '[old]': the duplicates are" + " indistinguishable in the interface.", + "gravity": "broken", + }, + { + "key": "fiscal_position", + "table": "account_fiscal_position", + "title": "Fiscal positions", + "why": "Each province then appears twice, under the same name.", + "gravity": "broken", + }, + { + "key": "group", + "table": "account_group", + "title": "Account groups", + "why": "Groups are matched to accounts by code PREFIX: a grid that" + " appears reclassifies the customer's own accounts, and the trial" + " balance in hierarchy mode follows.", + "gravity": "broken", + }, + { + "key": "journal", + "table": "account_journal", + "title": "Journals", + "why": "A journal added by a template reload competes with the" + " customer's own.", + "gravity": "watch", + }, +) + + +def compte(database, table, **kwargs): + """Combien de lignes, ou None si la table n'existe pas.""" + existe = lib_analyse.scalar_query( + database, + f"SELECT (to_regclass('public.{table}') IS NOT NULL)::text;", + **kwargs, + ) + if (existe or "").strip().lower() != "true": + return None + brut = lib_analyse.scalar_query( + database, f"SELECT count(*) FROM {table};", **kwargs + ) + try: + return int(brut) + except (TypeError, ValueError): + return None + + +def comptes_sans_ecriture(database, **kwargs): + """(comptes, ceux qui ne portent aucune écriture). + + C'est la signature d'un gabarit rechargé : un plan vécu a des comptes + vides, mais pas trois cents d'un coup. + """ + lignes = lib_analyse.json_query( + database, + "SELECT count(*) AS total," + " count(*) FILTER (WHERE NOT EXISTS (" + " SELECT 1 FROM account_move_line l WHERE l.account_id = a.id" + " )) AS sans_ecriture" + " FROM account_account a", + **kwargs, + ) + if not lignes: + return None, None + return lignes[0].get("total"), lignes[0].get("sans_ecriture") + + +def homonymes(database, table, **kwargs): + """Combien de noms portés par plus d'une ligne. + + Le rechargement ne renomme pas ce qu'il double : dans l'interface, + deux lignes du même nom ne se distinguent pas. + """ + brut = lib_analyse.scalar_query( + database, + "SELECT coalesce(sum(n - 1), 0) FROM (" + f" SELECT count(*) AS n FROM {table}" + " GROUP BY name->>'en_US' HAVING count(*) > 1) x;", + **kwargs, + ) + try: + return int(brut) + except (TypeError, ValueError): + return None + + +def reclasses(database, **kwargs): + """Combien de comptes tombent sous un groupe, par préfixe de code. + + Rejoue ce que fait `_compute_account_group` : le groupe dont le + préfixe encadre le code, le plus spécifique d'abord. + """ + brut = lib_analyse.scalar_query( + database, + "SELECT count(*) FROM (" + " SELECT a.id, a.code_store->>'1' AS code FROM account_account a" + ") c JOIN LATERAL (" + " SELECT ag.id FROM account_group ag" + " WHERE ag.code_prefix_start <=" + " left(c.code, char_length(ag.code_prefix_start))" + " AND ag.code_prefix_end >=" + " left(c.code, char_length(ag.code_prefix_end))" + " ORDER BY char_length(ag.code_prefix_start) DESC, ag.id" + " LIMIT 1) g ON true;", + **kwargs, + ) + try: + return int(brut) + except (TypeError, ValueError): + return None + + +def previous_database(database, dct=None): + """Le palier qui précède celui-ci, lu dans la chaîne de la migration. + + On ne le devine pas depuis le nom : la base de DÉPART ne porte pas de + suffixe, et retrancher 1 à « _upgrade_18 » donnerait « _upgrade_17 » + même quand la chaîne n'est pas contiguë. + """ + dct = quality.read_progression() if dct is None else dct + chaine = quality.chain(dct) + for rang, (_version, base) in enumerate(chaine): + if base == database and rang: + return chaine[rang - 1][1] + return None + + +def inspect(after, before, **kwargs): + """Ce que le dernier palier ajoute au référentiel du précédent.""" + rapport = {"before": before, "after": after, "tables": [], "signes": {}} + for entree in TABLES: + avant = compte(before, entree["table"], **kwargs) + apres = compte(after, entree["table"], **kwargs) + rapport["tables"].append( + { + "key": entree["key"], + "table": entree["table"], + "before": avant, + "after": apres, + "delta": ( + None if avant is None or apres is None else apres - avant + ), + } + ) + + total, vides = comptes_sans_ecriture(after, **kwargs) + rapport["signes"]["accounts"] = total + rapport["signes"]["accounts_without_entry"] = vides + for table, clef in ( + ("account_tax", "tax_homonyms"), + ("account_fiscal_position", "fiscal_position_homonyms"), + ): + rapport["signes"][clef] = homonymes(after, table, **kwargs) + if compte(after, "account_group", **kwargs): + rapport["signes"]["accounts_regrouped"] = reclasses(after, **kwargs) + return rapport + + +def judge(rapport): + """[(entrée, écart)] pour ce qui a gonflé, du plus grave au moins.""" + par_clef = {e["key"]: e for e in TABLES} + trouve = [ + (par_clef[ligne["key"]], ligne) + for ligne in rapport["tables"] + if ligne["delta"] and ligne["delta"] > 0 + ] + ordre = {"broken": 0, "watch": 1} + trouve.sort( + key=lambda paire: ( + ordre.get(paire[0]["gravity"], 2), + -paire[1]["delta"], + ) + ) + return trouve + + +def render(rapport, colour=True): + """Le rapport lisible. L'écart d'abord, ce qu'il signifie ensuite.""" + lignes = [ + f"📚 {t('Accounting reference data, one step to the next')}", + paint(f" {rapport['before']} → {rapport['after']}", "dim", colour), + "", + ] + trouve = judge(rapport) + if not trouve: + lignes.append( + paint( + f"✅ {t('Nothing was added to the reference data.')}", + "ok", + colour, + ) + ) + return "\n".join(lignes) + + for entree, ligne in trouve: + icone = "❌" if entree["gravity"] == "broken" else "⚠" + lignes.append( + paint( + f"{icone} {('+' + str(ligne['delta'])).rjust(6)}" + f" {t(entree['title'])}" + f" ({ligne['before']} → {ligne['after']})", + entree["gravity"], + colour, + ) + ) + lignes.append(paint(f" {t(entree['why'])}", "dim", colour)) + lignes.append("") + + signes = rapport["signes"] + lignes.append(paint(f" {t('What it looks like')}", "step", colour)) + vides = signes.get("accounts_without_entry") + if vides: + lignes.append( + f" {vides} / {signes.get('accounts')}" + f" {t('accounts carry no entry at all')}" + ) + for clef, phrase in ( + ("tax_homonyms", "taxes share a name with another"), + ( + "fiscal_position_homonyms", + "fiscal positions share a name with another", + ), + ): + if signes.get(clef): + lignes.append(f" {signes[clef]} {t(phrase)}") + if signes.get("accounts_regrouped"): + lignes.append( + f" {signes['accounts_regrouped']}" + f" {t('accounts fall under a group by code prefix')}" + ) + lignes.append("") + lignes.append( + paint( + f" {t('Replay the step with the fix; do not operate the')}" + f" {t('delivered database.')}", + "dim", + colour, + ) + ) + return "\n".join(lignes).rstrip() + + +def main(argv=None): + import argparse + import json + + parser = argparse.ArgumentParser( + description=t("What a version bump adds to the accounting data."), + ) + parser.add_argument("-d", "--database", required=True) + parser.add_argument( + "--before", + help=t("the step to compare against (default: the previous one)"), + ) + parser.add_argument("-c", "--config", help="odoo config file") + parser.add_argument("--json", action="store_true") + parser.add_argument("--no-color", action="store_true") + args = parser.parse_args(argv) + + before = args.before or previous_database(args.database) + if not before: + print( + f"❌ {t('No previous step found: name it with --before.')}", + file=sys.stderr, + ) + return 2 + for base in (before, args.database): + try: + lib_analyse.require_odoo_database(base, config_path=args.config) + except Exception as exc: # noqa: BLE001 + print(f"❌ {exc}", file=sys.stderr) + return 2 + + rapport = inspect(args.database, before, config_path=args.config) + if args.json: + print(json.dumps(rapport, indent=2, ensure_ascii=False)) + else: + colour = sys.stdout.isatty() and not args.no_color + print(render(rapport, colour)) + return 1 if judge(rapport) else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/analyse/check_manifest_gaps.py b/script/analyse/check_manifest_gaps.py new file mode 100755 index 0000000..da7c3f8 --- /dev/null +++ b/script/analyse/check_manifest_gaps.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Un dépôt absent d'un palier fait disparaître ses modules en chemin. + +Une migration 12 → 18 traverse SIX paliers, et à chacun le checkout +bascule sur le manifeste de cette version. Un dépôt d'addons qui manque +au manifeste du 17 n'existe pas sur disque pendant l'étape 17 : Odoo +déclare ses modules introuvables, et le pilote propose alors de les +effacer de la base. La question « veux-tu supprimer le module manquant » +arrive au milieu de six paliers, on répond oui, et la fonctionnalité du +client part sans que rien n'ait échoué. + +Ce qui distingue un trou d'une absence légitime +----------------------------------------------- +Beaucoup de dépôts n'ont tout simplement pas de branche pour une +version : les déclarer serait une erreur. Le seul signal qui vaille est +donc le TROU — présent avant, présent après, absent au milieu — CONFIRMÉ +par l'existence de la branche en amont. + +Mesuré sur ce dépôt : 35 trous, dont 19 sont de vraies omissions. Les +seize autres n'ont pas la branche en amont et sont donc corrects. Un +outil qui aurait signalé les 35 aurait eu 46 % de bruit, et un rapport +qui fait peur pour rien finit ignoré en entier. + +Quinze des dix-neuf partagent le même motif : déclarés en 16 et en 18, +absents du 17, la branche 17.0 existant en amont. Un lot ajouté pour la +18 sans rétro-portage — exactement le genre d'oubli qu'aucune relecture +ne voit et qu'un compte de trous rend évident. + +Pourquoi le réseau est OPTIONNEL +-------------------------------- +Confirmer demande un `git ls-remote` par dépôt. Sans `--upstream`, +l'outil liste les trous et dit qu'il ne les a pas départagés — c'est +honnête et instantané. Avec, il tranche. +""" + +from __future__ import annotations + +import glob +import os +import re +import subprocess +import sys +import xml.etree.ElementTree as ET +from concurrent.futures import ThreadPoolExecutor + +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 + + +REPO_ROOT = os.path.normpath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..") +) +DOSSIER = os.path.join(REPO_ROOT, "manifest") +MOTIF_FICHIER = re.compile(r"git_manifest_odoo(\d+\.\d+)\.xml$") +MOTIF_BRANCHE = re.compile(r"refs/heads/(\d+\.\d+)$") +DELAI_RESEAU = 60 +PARALLELE = 8 + +COULEURS = { + "broken": "\033[31m", + "watch": "\033[33m", + "ok": "\033[32m", + "step": "\033[36m", + "dim": "\033[90m", +} +RESET = "\033[0m" + + +def paint(texte, genre, colour): + """Teinter, ou rendre le texte tel quel quand la couleur est coupée.""" + if not colour: + return texte + return f"{COULEURS.get(genre, '')}{texte}{RESET}" + + +def rang(version): + """« 9.0 » avant « 10.0 » : trier des versions comme du texte ment.""" + return tuple(int(x) for x in version.split(".")) + + +def versions(dossier=DOSSIER): + """Les versions qui ont un manifeste, lues sur le disque. + + Les DÉDUIRE plutôt que les écrire : une version ajoutée au dépôt doit + entrer dans l'analyse sans qu'on pense à toucher ici. Les manifestes + `_dev` sont écartés — ils complètent le principal, et un dépôt qui + n'est que dans le `_dev` n'est pas censé être partout. + """ + trouves = {} + for chemin in glob.glob(os.path.join(dossier, "git_manifest_odoo*.xml")): + found = MOTIF_FICHIER.search(os.path.basename(chemin)) + if found: + trouves[found.group(1)] = chemin + return [(v, trouves[v]) for v in sorted(trouves, key=rang)] + + +def declarations(dossier=DOSSIER): + """{dépôt: {version: url}} — qui est déclaré où, et d'où il vient. + + L'URL se recompose du `fetch` de son remote et du nom du projet ; + c'est elle qui permettra d'aller demander à l'amont s'il a la branche. + """ + par_depot = {} + for version, chemin in versions(dossier): + try: + racine = ET.parse(chemin).getroot() + except ET.ParseError: + continue + fetch = { + r.get("name"): r.get("fetch") or "" for r in racine.iter("remote") + } + for projet in racine.iter("project"): + nom = projet.get("name") + if not nom: + continue + base = fetch.get(projet.get("remote"), "") + url = base.rstrip("/") + "/" + nom if base else "" + par_depot.setdefault(nom, {})[version] = url + return par_depot + + +def gaps(par_depot, toutes_versions=None): + """[(dépôt, présentes, manquantes, url)] — les trous, et eux seuls. + + Un trou est un manque ENTRE deux présences. Ce qui manque avant la + première ou après la dernière n'en est pas un : un dépôt né en 16 n'a + rien à faire en 12, et l'exiger crierait sur presque tout le fichier. + """ + ordre = toutes_versions or [v for v, _ in versions()] + lst = [] + for nom, par_version in sorted(par_depot.items()): + presentes = sorted(par_version, key=rang) + debut, fin = ordre.index(presentes[0]), ordre.index(presentes[-1]) + manquantes = [ + v for v in ordre[debut : fin + 1] if v not in par_version + ] + if manquantes: + url = par_version[presentes[-1]] or par_version[presentes[0]] + lst.append((nom, presentes, manquantes, url)) + return lst + + +def branches(url, delai=DELAI_RESEAU): + """Les branches de version publiées en amont, ou None si injoignable. + + None et l'ensemble vide ne disent PAS la même chose : « je n'ai pas + pu demander » n'est pas « il n'y en a pas », et confondre les deux + transformerait une coupure réseau en absolution générale. + """ + if not url: + return None + try: + done = subprocess.run( + ["git", "ls-remote", "--heads", url], + capture_output=True, + text=True, + timeout=delai, + ) + except (OSError, subprocess.SubprocessError): + return None + if done.returncode: + return None + trouvees = set() + for ligne in done.stdout.splitlines(): + found = MOTIF_BRANCHE.search(ligne.strip()) + if found: + trouvees.add(found.group(1)) + return trouvees + + +def judge(lst_gaps, verifier=False, lecteur=branches, parallele=PARALLELE): + """[(dépôt, manquantes, omissions, etat)] — trancher, ou avouer. + + `etat` vaut "omission" quand l'amont a la branche, "legitime" quand + il ne l'a pas, "inconnu" quand on n'a pas demandé ou pas pu. + """ + if not verifier: + return [(n, m, [], "inconnu") for n, _p, m, _u in lst_gaps] + + with ThreadPoolExecutor(max_workers=parallele) as pool: + amonts = list(pool.map(lambda g: lecteur(g[3]), lst_gaps)) + + resultat = [] + for (nom, _presentes, manquantes, _url), amont in zip(lst_gaps, amonts): + if amont is None: + resultat.append((nom, manquantes, [], "inconnu")) + continue + omissions = [v for v in manquantes if v in amont] + resultat.append( + ( + nom, + manquantes, + omissions, + "omission" if omissions else "legitime", + ) + ) + return resultat + + +def render(juges, verifie, colour=True): + """Le rapport lisible. Les omissions d'abord, le reste en sourdine.""" + lignes = [f"🗺 {t('Repositories missing from a step')}", ""] + omissions = [j for j in juges if j[3] == "omission"] + inconnus = [j for j in juges if j[3] == "inconnu"] + legitimes = [j for j in juges if j[3] == "legitime"] + + if not juges: + lignes.append( + paint( + f"✅ {t('Every repository is declared without a hole.')}", + "ok", + colour, + ) + ) + return "\n".join(lignes) + + for nom, _manq, omis, _etat in omissions: + lignes.append(paint(f"❌ {nom}", "broken", colour)) + lignes.append( + paint( + f" {t('missing from')} {', '.join(omis)}" + f" — {t('upstream has the branch')}", + "dim", + colour, + ) + ) + + if omissions: + lignes.append("") + lignes.append( + paint( + f" {t('Add the entry to')}" + f" manifest/git_manifest_odoo.xml", + "dim", + colour, + ) + ) + + if inconnus: + lignes.append("") + mot = ( + t("not asked: run with --upstream") + if not verifie + else t("upstream unreachable") + ) + lignes.append( + paint( + f"❔ {len(inconnus)} {t('holes not settled')} — {mot}", + "watch", + colour, + ) + ) + for nom, manq, _o, _e in inconnus: + lignes.append( + paint(f" {nom} — {', '.join(manq)}", "dim", colour) + ) + + if legitimes: + lignes.append("") + lignes.append( + paint( + f"✅ {len(legitimes)} {t('holes are legitimate: no such branch upstream')}", + "ok", + colour, + ) + ) + return "\n".join(lignes) + + +def main(argv=None): + import argparse + import json + + parser = argparse.ArgumentParser( + description=t("Repositories declared for some steps but not others."), + ) + parser.add_argument( + "--upstream", + action="store_true", + help=t("ask each remote whether the branch exists (network)"), + ) + parser.add_argument("--manifest-dir", default=DOSSIER) + parser.add_argument("--json", action="store_true") + parser.add_argument("--no-color", action="store_true") + args = parser.parse_args(argv) + + if not os.path.isdir(args.manifest_dir): + print(f"❌ {args.manifest_dir}", file=sys.stderr) + return 2 + + lst = gaps( + declarations(args.manifest_dir), + [v for v, _ in versions(args.manifest_dir)], + ) + juges = judge(lst, verifier=args.upstream) + + if args.json: + print( + json.dumps( + { + "checked_upstream": args.upstream, + "gaps": [ + { + "repository": n, + "missing": m, + "omissions": o, + "state": e, + } + for n, m, o, e in juges + ], + }, + indent=2, + ensure_ascii=False, + ) + ) + else: + colour = sys.stdout.isatty() and not args.no_color + print(render(juges, args.upstream, colour)) + return 1 if any(j[3] == "omission" for j in juges) else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/analyse/check_migration_quality.py b/script/analyse/check_migration_quality.py index 5dba90a..8c5ee59 100755 --- a/script/analyse/check_migration_quality.py +++ b/script/analyse/check_migration_quality.py @@ -36,6 +36,7 @@ Codes de sortie : 0 rien à signaler, 1 des trouvailles, 2 l'outil a échoué. import json import os +import re import subprocess import sys @@ -87,6 +88,316 @@ def read_progression(path=DEFAULT_PROGRESSION): return {} +# Où la migration laisse une trace, et ce qu'on y trouve. Les DEUX +# chemins sont montrés à l'écran même quand le second n'existe pas : sans +# `logfile` dans config.conf, Odoo écrit sur le terminal et sa sortie est +# perdue à la fermeture. Ne rien dire laisserait chercher un fichier qui +# n'a jamais été écrit. +JOURNAL_ODOO = "log/odoo.log" +MOTIFS_ERREUR = ("CRITICAL", "ERROR", "Traceback") + + +def read_events(dct): + """Les verdicts que la migration a laissés, dans l'ordre où ils sont venus. + + `command_executed` dit ce qui a été LANCÉ ; `lst_event` dit ce que + cela a RENDU. C'est la seule trace persistante d'un échec : la sortie + d'Odoo, elle, part sur le terminal et disparaît avec lui. + """ + lst = [] + for brut in dct.get("lst_event") or []: + if not isinstance(brut, dict): + continue + try: + statut = int(brut.get("status") or 0) + except (TypeError, ValueError): + statut = 0 + lst.append( + { + "at": str(brut.get("at") or ""), + "step": str(brut.get("step") or ""), + "kind": str(brut.get("kind") or ""), + "name": str(brut.get("name") or ""), + "status": statut, + "detail": str(brut.get("detail") or ""), + } + ) + return lst + + +def failures(events): + """Ceux qui n'ont pas rendu zéro, et seulement ceux-là. + + On garde `kind == "test"` : les entrées `command` à 1 appartiennent au + dialogue du pilote — « voulez-vous effacer le module manquant » — et + signaler un choix comme un échec ferait ignorer la liste entière. + """ + return [e for e in events if e["status"] and e["kind"] == "test"] + + +FICHIER_VERSION = ".odoo-version" + + +def checkout_version(racine=None): + """La version d'Odoo sur laquelle le checkout est posé, ou None. + + Un entier, pas « 18.0 » : c'est ce que porte le nom des bases de + palier et ce que la chaîne de migration rend, et deux formes du même + fait finissent toujours par diverger. + """ + base = racine or os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) + try: + with open( + os.path.join(base, FICHIER_VERSION), encoding="utf-8" + ) as handle: + return int(float(handle.read().strip())) + except (OSError, ValueError): + return None + + +def verdicts(events): + """TOUS les verdicts de test, réussis compris. + + N'afficher que les échecs répondait à « qu'est-ce qui a raté » et à + rien d'autre. Or la question qu'on se pose devant une base migrée est + « qu'a-t-on vérifié », et un test réussi au palier 17 est une preuve + au même titre qu'un raté au 14 — c'est même la seule façon de voir + qu'un échec du 14 a été RATTRAPÉ ensuite. + """ + return [e for e in events if e["kind"] == "test"] + + +def version_of(database, dct): + """La version d'Odoo de cette base, lue dans la chaîne de la migration. + + Plus sûr que le suffixe du nom : la base de DÉPART n'en porte pas — + « test_neutralize » ne dit pas 12.0 — et son palier retombait alors + sur le compteur du pilote, qui affichait « 2 ». + """ + for version, base in chain(dct): + if base == database: + return version + return None + + +# Ce que la migration écrit dans le journal d'une étape autour d'un test : +# +# [2026-08-26 03:19:44.166204] $ .venv…/python3 ./script/…/smoke.py -d … +# [2026-08-26 03:19:59.846406] -> 1 +# [2026-08-26 03:19:59.847489] [test] smoke_public_url -> 1 +# +# Entre le « $ » et le « -> », RIEN : mesuré sur trois exécutions du même +# test, la sortie de l'outil n'est pas capturée. Ce qui explique l'échec +# est donc AVANT, dans ce qu'Odoo écrivait juste avant qu'on le teste — +# et c'est pour cela que l'extrait remonte, au lieu de descendre. +MARQUEUR_COMMANDE = "] $ " +MOTIF_HORODATAGE = re.compile(r"^\[(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d)") + + +def _horodatage(ligne): + """La seconde portée par cette ligne de journal, ou "".""" + found = MOTIF_HORODATAGE.match(ligne) + return found.group(1) if found else "" + + +def event_tool(event): + """Le script que ce verdict a lancé — « smoke_public_url.py ».""" + for mot in (event.get("detail") or "").split(): + if mot.endswith(".py"): + return os.path.basename(mot) + return "" + + +def event_excerpt(lignes, event, avant=18): + """(extrait, rang de la commande) — le passage qui entoure ce verdict. + + Quand la migration a été rejouée, le même test apparaît plusieurs fois + dans le même fichier. On retient donc l'occurrence la plus PROCHE de + l'horodatage du verdict, et non la dernière : la dernière appartient + peut-être à une exécution qui n'est pas celle qu'on regarde. + """ + outil = event_tool(event) + if not outil or not lignes: + return [], None + base = event_database(event) + quand = (event.get("at") or "")[:19] + candidats = [ + rang + for rang, ligne in enumerate(lignes) + if MARQUEUR_COMMANDE in ligne + and outil in ligne + and (not base or base in ligne) + ] + if not candidats: + return [], None + avant_le_verdict = [ + rang for rang in candidats if _horodatage(lignes[rang]) <= quand + ] + rang = (avant_le_verdict or candidats)[-1] + fin = rang + for suivant in range(rang, min(rang + 400, len(lignes))): + if "[test] " in lignes[suivant] and event["name"] in lignes[suivant]: + fin = suivant + break + return lignes[max(0, rang - avant) : fin + 1], rang + + +def excerpt_has_output(extrait, rang_relatif): + """La sortie de l'outil est-elle DANS l'extrait, ou seulement le cadre ? + + Avant que le pilote ne capture, le journal ne portait que « $ … » + suivi de « -> code », sans une ligne entre les deux. Le dire était + juste ; le dire encore quand la sortie est là serait un mensonge, et + le panneau enverrait chercher ailleurs ce qu'il a sous les yeux. + """ + for ligne in extrait[rang_relatif + 1 :]: + depouille = ligne.strip() + if not depouille: + continue + if MARQUEUR_COMMANDE in ligne: + continue + if depouille.startswith("-> ") or depouille.startswith("[test] "): + continue + if "] -> " in ligne or "] [test] " in ligne: + continue + return True + return False + + +def event_database(event): + """La base sur laquelle ce verdict portait, lue dans sa commande.""" + for mot in event.get("detail", "").split(): + if mot.startswith("test_") or "_upgrade_" in mot: + return mot + morceaux = event.get("detail", "").split("-d ") + if len(morceaux) > 1: + return morceaux[1].split()[0] + return "" + + +def event_step(event, database=""): + """Le palier Odoo que ce verdict concernait — « 14 », « 18 ». + + Le nom de la base le porte — « …_upgrade_14 » — et c'est plus sûr que + le champ `step`, dont la numérotation (« 4.1.I ») compte les ÉTAPES du + pilote et non les versions d'Odoo : elles sont décalées d'un rang. + """ + base = database or event_database(event) + if "_upgrade_" in base: + suffixe = base.rsplit("_upgrade_", 1)[1] + if suffixe.isdigit(): + return suffixe + return (event.get("step") or "").split(" ")[0][:5] + + +def log_sources(path=DEFAULT_PROGRESSION, journal=JOURNAL_ODOO): + """[(rôle, chemin, existe, ce qu'on y lit)] — la carte des traces. + + Documenter les chemins EST la fonctionnalité : il a fallu une question + pour découvrir que les verdicts vivaient dans `lst_event`, et que la + sortie d'Odoo n'était écrite nulle part. + """ + return [ + ( + t("Migration progression"), + path, + os.path.isfile(path), + t("steps, commands and their verdicts (lst_event)"), + ), + ( + t("Odoo log"), + journal, + os.path.isfile(journal), + t("set logfile= in config.conf, else output is lost"), + ), + ] + + +def scan_log(path=JOURNAL_ODOO, limite=12): + """{motif: compte} et les dernières lignes fautives d'un journal Odoo. + + On lit la QUEUE : un journal de migration pèse des dizaines de + mégaoctets et ce qu'on cherche est ce qui a échoué en dernier. + """ + rapport = { + "path": path, + "exists": os.path.isfile(path), + "counts": {}, + "lines": [], + } + if not rapport["exists"]: + return rapport + try: + with open(path, "rb") as handle: + handle.seek(0, os.SEEK_END) + debut = max(0, handle.tell() - 2_000_000) + handle.seek(debut) + queue = handle.read().decode("utf-8", "replace") + except OSError: + return rapport + for motif in MOTIFS_ERREUR: + rapport["counts"][motif] = queue.count(motif) + for ligne in queue.splitlines(): + if any(motif in ligne for motif in MOTIFS_ERREUR): + rapport["lines"].append(ligne.strip()[:200]) + rapport["lines"] = rapport["lines"][-limite:] + return rapport + + +# La revue : ce qu'on vérifie, dans l'ordre, et ce que chaque étape prouve. +# Elle est ÉCRITE ici plutôt que dans une documentation à côté, parce +# qu'une liste de contrôle qu'il faut aller chercher n'est pas suivie. +REVUE = ( + ( + "Did the migration reach the end?", + "lst_event, and the six state_4_*_lst flags at 6/6", + None, + ), + ( + "Does Odoo load the database?", + "./odoo_bin.sh shell -c ./config.conf -d {db} --no-http", + "shell", + ), + ( + "What did the migration leave behind?", + "script/analyse/check_migration_residue.py -d {db}", + "residue", + ), + ( + "Is this copy safe to open?", + "script/analyse/check_instance_state.py -d {db} --expect copy", + "state", + ), + ( + "Do the public pages still answer?", + "script/odoo/migration/smoke_public_url.py -d {db}", + "smoke", + ), + ( + "Is anything left that cleanup could not drop?", + "script/odoo/migration/database_cleanup.py -d {db}", + "cleanup", + ), + ( + "Will every step find its addons?", + "script/analyse/check_manifest_gaps.py --upstream", + "manifest", + ), + ( + "Is any module still on a pre-18 view type?", + "script/analyse/check_view_type_tree.py", + "viewtype", + ), + ( + "Did the last step add to the accounting data?", + "script/analyse/check_chart_drift.py -d {db}", + "chartdrift", + ), +) + + def chain(dct): """[(version, base)] du départ à l'arrivée, dans l'ordre du parcours. diff --git a/script/analyse/check_migration_quality_tui.py b/script/analyse/check_migration_quality_tui.py index fc38097..23a0df0 100644 --- a/script/analyse/check_migration_quality_tui.py +++ b/script/analyse/check_migration_quality_tui.py @@ -12,7 +12,10 @@ Deux assemblages sépareraient les deux vues, et l'on finirait par lire deux états contradictoires de la même migration. """ +import io import os +import shlex +import subprocess import sys sys.path.append( @@ -30,6 +33,13 @@ except Exception: # pragma: no cover - repli si i18n indisponible return key +# Les commandes se lancent depuis la RACINE : les chemins qu'elles portent +# — « script/odoo/migration/… » — y sont relatifs, et les lancer d'ailleurs +# les rend introuvables. +REPO_ROOT = os.path.normpath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..") +) + CSS = """ Screen { layout: vertical; } #head { height: 3; padding: 0 1; background: $panel; color: $text; } @@ -39,12 +49,17 @@ Screen { layout: vertical; } """ -def rows(lst_snapshot): - """Un palier par ligne, puis le bilan d'ensemble en dernier. +def rows(lst_snapshot, dct=None): + """Un palier par ligne, le bilan, puis les trois sections de revue. - Le bilan EN DERNIER et non en tête : on descend la liste comme on a - vécu la migration, et la question « qu'est-ce qu'il en reste » se pose - une fois qu'on a vu le chemin. + Le bilan APRÈS les paliers et non en tête : on descend la liste comme + on a vécu la migration, et la question « qu'est-ce qu'il en reste » se + pose une fois qu'on a vu le chemin. Ce qui suit — verdicts, où lire, + quoi vérifier — répond à « et maintenant ». + + `dct` se passe pour éviter la lecture du fichier de progression : + autrement la liste dépend de ce qu'une migration a laissé sur CE + poste, et deux exécutions ne rendent pas la même chose. """ lst = [] presents = [x for x in lst_snapshot if x.get("exists")] @@ -95,9 +110,149 @@ def rows(lst_snapshot): "diff": quality.overall(lst_snapshot), } ) + lst.extend(extra_rows(presents, dct)) return lst +def extra_rows(presents, dct=None): + """Les trois sections sous les paliers : succès, validation, revue. + + Elles vivent dans la MÊME table que les paliers plutôt que dans un + second panneau : on descend la colonne une seule fois, et l'ordre dit + la démarche — ce que la migration a rendu, où le lire, quoi vérifier + ensuite. + + Un séparateur n'est PAS sélectionnable au sens où il n'affiche qu'un + titre ; le laisser dans la liste garde l'index de la table aligné sur + celui des lignes, ce qu'une liste filtrée perdrait. + """ + dct = quality.read_progression() if dct is None else dct + cible = presents[-1]["database"] if presents else "" + lst = [] + + # ── Verdicts ── + evenements = quality.read_events(dct) + tous = quality.verdicts(evenements) + ratés = quality.failures(evenements) + lst.append( + { + "kind": "header", + "label": f"── {t('Verdicts')} ──", + "detail": ( + f"{len(ratés)}/{len(tous)}" if ratés else f"✅ {len(tous)}" + ), + } + ) + if not tous: + lst.append( + { + "kind": "verdict-none", + "label": f" {t('no verdict recorded')}", + "detail": "", + } + ) + for event in tous: + base = quality.event_database(event) + # Le PALIER dans le libellé : « smoke_public_url » sept fois de + # suite ne dit pas lequel on regarde, et c'est la seule chose + # qu'on veut savoir en parcourant la colonne. Il vient de la + # CHAÎNE de la migration, car la base de départ ne le porte pas + # dans son nom — « test_neutralize » ne dit pas 12.0. + version = quality.version_of(base, dct) + palier = str(version) if version else quality.event_step(event, base) + icone, _teinte = status.verdict_mark(event["status"]) + lst.append( + { + "kind": "verdict", + "label": f" {icone} {palier:<5} {event['name'][:18]}", + "detail": "▶", + "event": event, + "database": base or cible, + "step": palier, + "log": status.step_log_path(dct, event.get("step")), + "command": rerun_command(event, base or cible), + "switch": switch_needed(base or cible, dct), + "capture": run_log_path( + "verdict_%s_%s" % (event["name"], base or cible) + ), + } + ) + + # ── Validation ── + lst.append( + {"kind": "header", "label": f"── {t('Validation')} ──", "detail": ""} + ) + for role, chemin, existe, _quoi in quality.log_sources(): + lst.append( + { + "kind": "source", + "label": f" {'📄' if existe else '∅'} {role[:24]}", + "detail": "", + "path": chemin, + } + ) + lst.append( + { + "kind": "logscan", + "label": f" 🔎 {t('errors in the log')}", + "detail": "", + } + ) + + # ── Revue ── + lst.append( + {"kind": "header", "label": f"── {t('Review')} ──", "detail": ""} + ) + for rang, (question, commande, clef) in enumerate(quality.REVUE, 1): + lst.append( + { + "kind": "review", + "label": f" {rang}. {t(question)[:26]}", + "detail": "▶" if clef else "", + "question": question, + "command": ( + commande.format(db=cible) + if clef and (cible or "{db}" not in commande) + else "" + ), + # Les étapes qui nomment une base ont le même écueil que + # les verdicts : le checkout doit être au bon palier. + "switch": ( + switch_needed(cible, dct) if "{db}" in commande else None + ), + # Le « shell » est un REPL : le passer par un tube lui + # ferait perdre son invite. Les sept autres écrivent et + # s'arrêtent, on peut donc en garder une copie. + "capture": ( + run_log_path(f"review_{clef}") + if clef and clef != "shell" + else None + ), + } + ) + return lst + + +def rerun_command(event, database): + """La commande qui rejoue ce verdict, ou "" si on ne sait pas la refaire. + + On la RECONSTRUIT depuis le nom de l'outil plutôt que de rejouer le + `detail` tel quel : celui-ci porte le chemin d'un venv de palier, qui + n'est plus celui du checkout courant. + """ + if not database: + return "" + outils = { + "smoke_public_url": "script/odoo/migration/smoke_public_url.py", + "database_cleanup": "script/odoo/migration/database_cleanup.py", + "check_hidden_models": "script/odoo/migration/check_hidden_models.py", + } + chemin = outils.get(event.get("name", "")) + if not chemin: + return "" + return f"{chemin} -d {database}" + + def head_text(lst_snapshot): presents = [x for x in lst_snapshot if x.get("exists")] manquants = len(lst_snapshot) - len(presents) @@ -159,6 +314,319 @@ def statistics(etat, precedent): ] +EXTRA_KINDS = ( + "header", + "verdict", + "verdict-none", + "source", + "logscan", + "review", +) + + +def extra_pane(row, colour=False): + """Le panneau des trois sections ajoutées sous les paliers.""" + genre = row["kind"] + if genre == "header": + return t("Pick a line below.") + if genre == "verdict": + return verdict_pane(row, colour) + if genre == "verdict-none": + return "\n".join( + [ + t("The migration recorded no verdict."), + "", + t("Verdicts live in lst_event of the progression file,"), + t("not in command_executed, which only lists what ran."), + ] + ) + if genre == "source": + return source_pane(row, colour) + if genre == "logscan": + return logscan_pane(colour) + if genre == "review": + return review_pane(row, colour) + return "" + + +# Ce que le chiffre veut dire, pour TOUS les outils — la convention est +# écrite dans todo_upgrade.run_tool. Le sens PROPRE à chaque outil, lui, +# se dit plus bas : « 1 » n'a pas la même gravité pour un test de fumée +# que pour un nettoyage. +SENS_DU_CODE = { + 0: "nothing to report", + 1: "findings", + 2: "the tool failed", +} + + +def log_excerpt_lines(row, colour=False): + """Le passage du journal d'étape qui entoure ce verdict. + + Ce que le journal contient VRAIMENT, dit sans détour : la commande et + son code, pas la sortie de l'outil. Le pilote l'explique — ce qui + passe par `run_on_terminal` n'a pas de sortie capturable, un tube y + ferait renoncer les pleins écrans. Ce qui précède la commande est donc + tout ce qu'on a, et c'est déjà ce qu'Odoo écrivait au moment du test. + + Le taire enverrait chercher un fichier plus complet qui n'existe pas. + """ + chemin = row.get("log") + if not chemin: + return [ + status.paint(f" ── {t('step log')} ──", "step", colour), + f" {t('no log file for this step')}", + "", + ] + try: + with io.open(chemin, encoding="utf-8", errors="replace") as handle: + brut = handle.read().splitlines() + except OSError: + return [] + extrait, rang = quality.event_excerpt(brut, row.get("event") or {}) + lignes = [ + status.paint(f" ── {t('step log')} ──", "step", colour), + status.paint(f" {chemin}", "dim", colour), + status.paint(f" {len(brut)} {t('lines in all')}", "dim", colour), + "", + ] + if not extrait: + lignes.append(f" {t('this verdict is not in it')}") + lignes.append("") + return lignes + for ligne in extrait: + lignes.append(f" {ligne[:150]}") + lignes.append("") + # Ne le dire que si c'est vrai : depuis que le pilote capture, la + # sortie EST là, et répéter qu'elle manque enverrait la chercher + # ailleurs alors qu'on l'a sous les yeux. + rang_relatif = None + if rang is not None: + debut = max(0, rang - 18) + rang_relatif = rang - debut + if rang_relatif is None or not quality.excerpt_has_output( + extrait, rang_relatif + ): + for phrase in ( + "the tool output is not in there: it goes to the", + "terminal, which a pipe would make full-screen tools", + "give up. What precedes is what Odoo was writing.", + ): + lignes.append(status.paint(f" {t(phrase)}", "dim", colour)) + lignes.append("") + return lignes + + +def switch_needed(database, dct): + """(version, cible make) si le checkout n'est pas au palier de cette base. + + Lancer un outil sur une base d'un autre palier n'est pas seulement + inutile : Odoo ÉCRIT dedans avant d'échouer. Le test de fumée s'en + garde déjà et sort en 2 ; l'écran, lui, peut proposer la bascule au + lieu de laisser relancer trois fois la même erreur. + """ + version = quality.version_of(database, dct) + if not version: + return None + courante = quality.checkout_version() + if not courante or courante == version: + return None + return (version, "switch_odoo_%d" % version) + + +def verdict_pane(row, colour=False): + """Ce qu'un verdict dit, ce que son code signifie, et ce qu'on en lit.""" + event = row.get("event") or {} + rate = bool(event.get("status")) + icone, teinte = status.verdict_mark(event.get("status")) + lignes = [ + status.paint(f"{icone} {event.get('name')}", teinte, colour), + "", + # Le palier est la version d'ODOO, pas le compteur du pilote : + # « 4.1.I » désigne la première étape du quatrième bloc, et la + # migration en est alors au palier 14. Les afficher tous deux sous + # le même mot faisait lire « palier 4.1 », qui n'existe pas. + f" {t('step'):<10} {quality.event_step(event)}" + f" ({event.get('step')})", + f" {t('when'):<10} {event.get('at')[:19]}", + f" {t('status'):<10} {event.get('status')}" + f" ({t(SENS_DU_CODE.get(event.get('status'), 'the tool failed'))})", + "", + f" {t('what it ran')}", + f" {event.get('detail', '')[:150]}", + "", + ] + sens = { + "smoke_public_url": t( + "1 means a public page failed — not merely a finding." + ), + "database_cleanup": t( + "1 means leftovers remain that it could not drop." + ), + }.get(event.get("name"), "") + if sens and rate: + lignes.append(f" {sens}") + lignes.append("") + lignes.extend(log_excerpt_lines(row, colour)) + lignes.extend(run_log_lines(row, colour)) + if row.get("command") and row.get("switch"): + version, cible = row["switch"] + lignes.append( + status.paint( + f" ⚠ {t('the checkout is on Odoo')}" + f" {quality.checkout_version()}.0," + f" {t('this database is on')} {version}.0", + "warn", + colour, + ) + ) + lignes.append( + f" {t('r offers to run')} « make {cible} » {t('first')}" + ) + lignes.append("") + if row.get("command"): + lignes.append( + status.paint( + f" ▶ {t('press r to run it again')}", "step", colour + ) + ) + lignes.append(f" {row['command']}") + lignes.append("") + lignes.append( + f" {t('The screen steps aside, the test takes the terminal,')}" + ) + lignes.append(f" {t('and you come back with Enter.')}") + else: + lignes.append(f" {t('No known way to replay this one.')}") + return "\n".join(lignes) + + +def source_pane(row, colour=False): + """Où la migration laisse ses traces, et ce que chacune contient.""" + lignes = [status.paint(t("Where the traces live"), "step", colour), ""] + for role, chemin, existe, quoi in quality.log_sources(): + marque = "📄" if existe else "∅" + teinte = "ok" if existe else "warn" + lignes.append(f" {marque} {status.paint(role, teinte, colour)}") + lignes.append(f" {chemin}") + lignes.append(f" {quoi}") + if not existe: + lignes.append(f" {t('missing: nothing was written here')}") + lignes.append("") + lignes.append(f" {t('To read the verdicts by hand:')}") + lignes.append(" python3 - <<'PY'") + lignes.append(" import json, io") + lignes.append( + " d = json.load(io.open(" + f"'{quality.DEFAULT_PROGRESSION}', encoding='utf-8'))" + ) + lignes.append(" for e in d['lst_event']:") + lignes.append(" if e.get('status'):") + lignes.append(" print(e['step'], e['name'], e['status'])") + lignes.append(" PY") + return "\n".join(lignes) + + +def logscan_pane(colour=False): + """Les erreurs du journal d'Odoo — ou pourquoi il n'y en a pas.""" + rapport = quality.scan_log() + lignes = [status.paint(t("Errors in the Odoo log"), "step", colour), ""] + lignes.append(f" {rapport['path']}") + if not rapport["exists"]: + lignes.append("") + lignes.append( + status.paint( + f" ∅ {t('This file does not exist.')}", "warn", colour + ) + ) + lignes.append("") + lignes.append( + f" {t('config.conf has an empty logfile=, so Odoo writes to')}" + ) + lignes.append( + f" {t('the terminal and its output dies with it. To keep it:')}" + ) + lignes.append("") + lignes.append( + f" logfile = {os.path.abspath(quality.JOURNAL_ODOO)}" + ) + return "\n".join(lignes) + lignes.append("") + for motif, combien in rapport["counts"].items(): + teinte = "fail" if combien else "ok" + lignes.append( + f" {status.paint(motif.ljust(10), teinte, colour)} {combien}" + ) + if rapport["lines"]: + lignes.append("") + lignes.append(f" {t('last offending lines')}") + for ligne in rapport["lines"]: + lignes.append(f" {ligne}") + return "\n".join(lignes) + + +def run_log_lines(row, colour=False): + """Ce que la dernière exécution de cette ligne a écrit, ou l'invitation. + + Le panneau défile — c'est un VerticalScroll — donc on n'ampute pas : + montrer vingt lignes d'un rapport qui en fait deux cents obligerait à + le relancer dans un terminal pour lire la suite, et l'écran n'aurait + servi qu'à donner envie. + """ + chemin = row.get("capture") + if not chemin: + return [] + lst, total = read_run_log(chemin) + if not lst: + if not row.get("command"): + return [] + return [ + status.paint(f" ── {t('last run')} ──", "step", colour), + status.paint( + f" {t('never run from here yet')}", "dim", colour + ), + "", + ] + lignes = [ + status.paint(f" ── {t('last run')} ──", "step", colour), + status.paint(f" {chemin}", "dim", colour), + status.paint(f" {total} {t('lines in all')}", "dim", colour), + "", + ] + for ligne in lst: + lignes.append(f" {ligne}") + lignes.append("") + return lignes + + +def review_pane(row, colour=False): + """Une étape de la revue : ce qu'elle prouve, et ce qu'elle ne prouve pas.""" + lignes = [ + status.paint(t(row.get("question", "")), "step", colour), + "", + ] + if row.get("command"): + lignes.append(f" {row['command']}") + lignes.append("") + lignes.append( + status.paint(f" ▶ {t('press r to run it')}", "step", colour) + ) + else: + lignes.append(f" {t('Read it in the Verdicts section above.')}") + lignes.extend(run_log_lines(row, colour)) + lignes.append(f" {t('What none of these can see')}") + lignes.append( + f" {t('They all read the DATABASE. A module that kept a')}" + ) + lignes.append( + f" {t('pre-18 view type, or an image URL Odoo no longer')}" + ) + lignes.append( + f" {t('accepts, breaks at runtime on a perfectly sound one.')}" + ) + return "\n".join(lignes) + + def pane_text(lst_snapshot, row, colour=False, mode=None): """Le détail du palier choisi, ou le bilan d'ensemble. @@ -168,6 +636,8 @@ def pane_text(lst_snapshot, row, colour=False, mode=None): """ if row is None: return t("Nothing to show yet.") + if row["kind"] in EXTRA_KINDS: + return extra_pane(row, colour) if row["kind"] == "missing": return f"⚠️ {row['data']['database']} : {t('database not found')}" etat = row["data"] @@ -222,6 +692,143 @@ def mode_label(mode): return t(mode) +# La sortie d'une exécution lancée DEPUIS l'écran, gardée pour être +# relue. Celle des tests de la migration ne l'était pas — le pilote lance +# par `run_on_terminal`, qui n'a pas de sortie capturable — mais ce que +# l'on lance soi-même, on peut le garder. +REVIEW_LOG_DIR = os.path.join(".venv.erplibre", "screen_log") + + +def run_log_path(clef): + """Le fichier où garder la sortie de cette commande, ou None. + + Une clé par ligne de l'écran, et non par commande : deux exécutions + de la même étape doivent se remplacer, pas s'empiler. On veut « ce + que ça donne maintenant », jamais un historique qu'il faudrait trier. + """ + if not clef: + return None + propre = "".join(c if c.isalnum() or c in "._-" else "_" for c in clef) + return os.path.join(REPO_ROOT, REVIEW_LOG_DIR, propre + ".log") + + +def read_run_log(chemin, limite=4000): + """(lignes, total) de la derniere execution, ou ([], 0).""" + if not chemin: + return [], 0 + try: + with io.open(chemin, encoding="utf-8", errors="replace") as handle: + lst = handle.read().splitlines() + except OSError: + return [], 0 + return lst[-limite:], len(lst) + + +def ask_yes(question, defaut=True): + """Poser une question fermée dans le terminal rendu. Entrée = défaut. + + `input()` et non une boîte de dialogue : on est DÉJÀ sorti de l'écran + quand la question se pose, et y rentrer pour un oui/non ferait + clignoter tout l'affichage entre deux commandes. + """ + suffixe = " [O/n] " if defaut else " [o/N] " + try: + reponse = input(question + suffixe).strip().lower() + except (EOFError, KeyboardInterrupt): + return False + if not reponse: + return defaut + return reponse[0] in ("o", "y") + + +def run_with_switch(command, switch, wait=True, capture=None): + """Lancer, en proposant d'abord de basculer le checkout s'il le faut. + + Sans cela on relance trois fois la même erreur : le test de fumée + refuse d'ouvrir une base 16 avec un checkout 18 — « l'ouvrir avec la + mauvaise version y écrit avant d'échouer » — et rend 2 sans rien + faire. La bascule est la seule suite possible, autant la proposer. + + On DEMANDE au lieu de basculer d'office : `make switch_odoo_16` change + le checkout entier, et quelqu'un peut être en train d'y travailler. + """ + if switch: + version, cible = switch + print() + print( + status.paint( + f"⚠ {t('the checkout is on Odoo')}" + f" {quality.checkout_version()}.0," + f" {t('this database is on')} {version}.0", + "warn", + True, + ) + ) + print( + f" {t('opening it with the wrong version writes before it fails.')}" + ) + print() + if not ask_yes(f" {t('run')} « make {cible} » {t('first?')}"): + print(f" {t('left as is — the test will refuse and return 2.')}") + else: + code, _tourne = run_in_terminal(f"make {cible}", wait=False) + if code: + print(status.paint(f" ✗ make {cible} → {code}", "fail", True)) + if not ask_yes(f" {t('run the test anyway?')}", defaut=False): + return None, False + return run_in_terminal(command, wait=wait, capture=capture) + + +def run_in_terminal(command, wait=True, capture=None): + """Rendre le terminal au test, puis le reprendre. (code, a_tourné). + + L'écran DOIT s'effacer : `smoke_public_url` monte une instance Odoo et + écrit des centaines de lignes ; les capturer les cacherait, et les + laisser passer par-dessus la TUI la déchirerait. Textual sait se + retirer — `suspend()` chez l'appelant — et c'est le seul moment où ce + processus peut lancer autre chose sans se battre pour l'affichage. + + L'attente d'Entrée n'est pas une politesse : sans elle, l'écran se + reconstruit par-dessus le résultat avant qu'on ait pu le lire. + """ + if not command: + return None, False + argv = command.split() + if argv[0].endswith(".py"): + argv = [sys.executable] + argv + print() + print(f"▶ {' '.join(argv)}") + print("─" * 72) + if capture: + # `tee` garde une copie SANS rien cacher : la sortie continue + # d'aller au terminal en direct. Le prix est la couleur — les + # outils la coupent quand stdout n'est plus un terminal — et + # c'est un bon prix pour un journal qu'on relira dans l'écran. + # `pipefail` pour que le code rendu soit celui de la commande, + # et non celui de `tee`, qui réussit toujours. + os.makedirs(os.path.dirname(capture), exist_ok=True) + argv = [ + "bash", + "-o", + "pipefail", + "-c", + "%s 2>&1 | tee %s" % (shlex.join(argv), shlex.quote(capture)), + ] + try: + code = subprocess.run(argv, cwd=REPO_ROOT).returncode + except OSError as exc: + print(f"❌ {exc}") + code = None + print("─" * 72) + print(f"↩ {t('exit code:')} {code}") + if wait: + try: + input(t("Press Enter to go back to the screen…")) + except (EOFError, KeyboardInterrupt): + pass + return code, True + + def build_app(lst_snapshot): """Textual est importé ICI : le module reste testable sans lui.""" from rich.text import Text @@ -235,6 +842,12 @@ def build_app(lst_snapshot): ("q,escape", "quit", t("Quit")), ("m", "toggle_missing", t("Missing files")), ("d", "cycle_detail", t("Details")), + ("r,enter", "run_selected", t("Run")), + # La table garde les flèches et pgup/pgdn pour ses lignes ; + # ces deux-là ne lui appartiennent pas et restent donc + # disponibles pour le panneau. + ("ctrl+u", "pane_up", t("Pane up")), + ("ctrl+d", "pane_down", t("Pane down")), ] def __init__(self, lst_snapshot): @@ -305,6 +918,36 @@ def build_app(lst_snapshot): self.mode = suite[(suite.index(courant) + 1) % len(suite)] self._show() + def action_run_selected(self): + """Rejouer le test de la ligne choisie, hors de l'écran. + + `suspend()` rend le terminal pour de bon : le test peut monter + son instance Odoo et écrire ce qu'il veut. Rien n'est capturé, + donc rien n'est caché. + """ + row = ( + self.lst_row[self.index] + if self.lst_row and self.index < len(self.lst_row) + else None + ) + commande = row.get("command") if row else "" + if not commande: + # Une ligne sans commande n'est pas une erreur : la plupart + # n'en ont pas. Un bip dit « rien ici » sans interrompre. + self.bell() + return + with self.suspend(): + run_with_switch( + commande, row.get("switch"), capture=row.get("capture") + ) + self.refresh() + + def action_pane_up(self): + self.query_one("#pane", VerticalScroll).scroll_page_up() + + def action_pane_down(self): + self.query_one("#pane", VerticalScroll).scroll_page_down() + def on_data_table_row_highlighted(self, event): if event.data_table.id == "left" and self.lst_row: self.index = event.cursor_row diff --git a/script/analyse/check_migration_residue.py b/script/analyse/check_migration_residue.py index f2ef0e3..65d0538 100755 --- a/script/analyse/check_migration_residue.py +++ b/script/analyse/check_migration_residue.py @@ -27,7 +27,6 @@ finit par être ignoré en entier. Ne restent ici que les constats qui se jugent SANS point de comparaison, parce qu'ils sont faux en eux-mêmes. Les mêmes bases, mêmes mesures : - res_lang.active à NULL 0 → 9 un booléen NULL est un bug ir_model_relation sans table 0 → 68 la table m2m est nommée, elle n'existe pas index doublés convention 17 0 → 414 Odoo 17 renomme, sans @@ -35,9 +34,30 @@ parce qu'ils sont faux en eux-mêmes. Les mêmes bases, mêmes mesures : liste de prix par défaut absente 0 → 1 `product` installé, son xmlid pas là -Zéro avant, non nul après : aucun de ces trois ne peut s'expliquer +Zéro avant, non nul après : aucun de ceux-là ne peut s'expliquer autrement que par la migration. +« Zéro avant, non nul après » ne suffit pourtant pas +---------------------------------------------------- +Un quatrième contrôle a vécu ici et n'y est plus : `res_lang.active` à +NULL, 0 avant et 9 après. Le chiffre était juste, la conclusion fausse. + +Mesuré palier par palier : 0, 1, 2, 3, 5, 8, 9 — les NULL arrivent avec +les langues que CHAQUE version ajoute au catalogue. Et c'est Odoo +lui-même qui les écrit : `active = fields.Boolean()` sans défaut +(res_lang.py:64) et un `res.lang.csv` sans colonne `active` — l'INSERT ne +porte pas la colonne, PostgreSQL y met NULL. + +Aucune conséquence, vérifiée dans la source de la 18 : un domaine +`('active','=',False)` compile en `(IS NULL OR = FALSE)` +(models.py:3217-3222), l'action du menu Langues porte `active_test: False` +(res_lang_views.xml:136), le tri passe par `COALESCE(active, FALSE)` +(models.py:5692) et la lecture rend `bool(value)` (fields.py:1515). NULL +et FALSE sont indiscernables partout. + +La leçon : la croissance mesurée doit AUSSI être inexplicable autrement. +Ici elle s'expliquait très bien. + Un quatrième a été RETIRÉ après vérification -------------------------------------------- « ir_model_relation nomme une table absente » : 0 avant, 68 après, le @@ -74,10 +94,13 @@ except Exception: # pragma: no cover - repli si i18n indisponible return key +from script.analyse import check_migration_quality as quality # noqa: E402 from script.analyse import lib_analyse # noqa: E402 +from script.todo import migration_status as status # noqa: E402 COULEURS = { "broken": "\033[31m", + "step": "\033[36m", "watch": "\033[33m", "ok": "\033[32m", "dim": "\033[90m", @@ -105,15 +128,6 @@ CONTROLES = ( "gravity": "broken", "repair": "script/todo/todo_upgrade.py (uninstall_one_by_one)", }, - { - "key": "lang_active_null", - "title": "Languages whose active flag is NULL", - "why": "A boolean that is neither true nor false: the language is" - " listed nowhere and cannot be re-enabled from the interface.", - "sql": "SELECT count(*) FROM res_lang WHERE active IS NULL", - "gravity": "broken", - "repair": None, - }, { "key": "duplicate_index", "title": "Indexes duplicated by the Odoo 17 renaming", @@ -139,9 +153,22 @@ CONTROLES = ( # chercher l'xmlid signalait donc une base parfaitement saine, et # aurait signalé de même celle d'un client qui a créé la sienne à # la main. + # + # Et seulement si la FONCTIONNALITÉ est active, c'est-à-dire si + # `base.group_user` implique `product.group_product_pricelist` — + # la question exacte que pose la case des réglages. Sans elle, + # l'absence de liste est normale ; signaler quand même menait à + # créer une liste dans une base qui n'en veut pas, et Odoo + # prévenait alors à chaque ouverture des réglages qu'il allait + # l'archiver. "sql": "SELECT CASE WHEN EXISTS (SELECT 1 FROM ir_module_module" " WHERE name='product' AND state='installed')" " AND to_regclass('public.product_pricelist') IS NOT NULL" + " AND EXISTS (SELECT 1 FROM res_groups_implied_rel r" + " JOIN ir_model_data u ON u.model='res.groups' AND u.res_id=r.gid" + " AND u.module='base' AND u.name='group_user'" + " JOIN ir_model_data g ON g.model='res.groups' AND g.res_id=r.hid" + " AND g.module='product' AND g.name='group_product_pricelist')" " AND NOT EXISTS (SELECT 1 FROM product_pricelist)" " THEN 1 ELSE 0 END", "gravity": "broken", @@ -174,7 +201,7 @@ CONTROLES = ( "sql": "SELECT count(*) FROM ir_model_data d WHERE NOT EXISTS" " (SELECT 1 FROM ir_model m WHERE m.model = d.model)", "gravity": "broken", - "repair": "script/analyse/database_cleanup.py", + "repair": "script/odoo/migration/database_cleanup.py", }, { "key": "attachment_field_gone", @@ -231,6 +258,134 @@ def judge(resultats): return trouve, illisibles +def famille(nom): + """La base d'origine dont ce nom est un palier. + + « test_neutralize_upgrade_14 » et « …_upgrade_18 » sont deux paliers + de la MÊME migration. Interroger la base 18 doit montrer l'échec du + palier 14 : c'est le seul endroit où il subsiste. + """ + return nom.rsplit("_upgrade_", 1)[0] if "_upgrade_" in nom else nom + + +def verdicts(database, path=None): + """(tous, ratés) pour cette base — lus dans le FICHIER, pas en SQL. + + Un test de fumée qui échoue ne laisse aucune trace en base : rien + n'est écrit, rien n'est cassé, la requête suivante répond. Les + contrôles ci-dessus sont donc structurellement aveugles à ce type + d'échec, et c'était la moitié de ce qu'une migration peut rater. + """ + dct = quality.read_progression(path or quality.DEFAULT_PROGRESSION) + lignee = famille(database) + tous = [ + e + for e in quality.read_events(dct) + if famille(quality.event_database(e)) == lignee + ] + return tous, quality.failures(tous) + + +def extrait_du_journal(dct, event, colour=True, avant=6): + """(lignes, la sortie de l'outil y est-elle) autour de ce verdict. + + La commande seule ne dit pas POURQUOI. Le journal, lui, garde ce + qu'Odoo écrivait au moment du test — et c'est tout ce qu'on a : la + sortie de l'outil n'y est pas, parce qu'elle passe par le terminal, + qu'un tube ferait renoncer aux outils en plein écran. Le pilote le + documente à l'endroit où il l'écrit. + + Silencieux quand il n'y a rien à montrer : une ligne « pas de + journal » par verdict noierait les quatre qui comptent. + """ + chemin = status.step_log_path(dct, event.get("step")) + if not chemin or avant <= 0: + return [], False + try: + with open(chemin, "r", encoding="utf-8", errors="replace") as handle: + brut = handle.read().splitlines() + except OSError: + return [], False + extrait, rang = quality.event_excerpt(brut, event, avant=avant) + if not extrait: + return [], False + lignes = [paint(f" {chemin}", "dim", colour)] + for ligne in extrait: + lignes.append(paint(f" {ligne[:150]}", "dim", colour)) + debut = max(0, rang - avant) if rang is not None else 0 + avec_sortie = rang is not None and quality.excerpt_has_output( + extrait, rang - debut + ) + return lignes, avec_sortie + + +def verdicts_block(database, colour=True, path=None, lignes_avant=6): + """La section « Verdicts », ou rien du tout s'il n'y en a pas. + + Silencieuse quand le fichier n'existe pas : devant la sauvegarde d'un + client, il n'y a jamais eu de migration locale, et annoncer l'absence + d'un fichier qu'on n'attendait pas ne renseigne personne. + """ + chemin = path or quality.DEFAULT_PROGRESSION + tous, ratés = verdicts(database, chemin) + if not tous: + return [] + lignes = [ + "", + paint(f"🚦 {t('Verdicts the migration recorded')}", "step", colour), + ] + lignes.append(paint(f" {t('recorded in')} {chemin}", "dim", colour)) + lignes.append("") + if not ratés: + lignes.append( + paint( + f"✅ {str(len(tous)).rjust(6)} {t('checks, all passed')}", + "ok", + colour, + ) + ) + dct = quality.read_progression(chemin) + sortie_presente = False + for event in ratés: + version = quality.version_of(quality.event_database(event), dct) + palier = str(version) if version else quality.event_step(event) + icone, teinte = status.verdict_mark(event["status"]) + lignes.append( + paint( + f"{icone} {palier.rjust(6)} {event['name']}", + "broken" if teinte == "fail" else "watch", + colour, + ) + ) + lignes.append( + paint(f" {event['detail'][:120]}", "dim", colour) + ) + bloc, avec_sortie = extrait_du_journal( + dct, event, colour, lignes_avant + ) + sortie_presente = sortie_presente or avec_sortie + lignes.extend(bloc) + lignes.append("") + lignes.append( + paint( + f" {t('These come from the file, not the database:')}" + f" {t('the exit code ignores them.')}", + "dim", + colour, + ) + ) + if ratés and lignes_avant > 0 and not sortie_presente: + lignes.append( + paint( + f" {t('the tool output is not in the step log: it goes')}" + f" {t('to the terminal and dies with it.')}", + "dim", + colour, + ) + ) + return lignes + + def render(database, resultats, version=None, colour=True): """Le rapport lisible. Chaque constat dit quoi lancer pour le réparer.""" trouve, illisibles = judge(resultats) @@ -253,7 +408,7 @@ def render(database, resultats, version=None, colour=True): colour, ) ) - return "\n".join(lignes) + return "\n".join(lignes + verdicts_block(database, colour)).rstrip() for controle, combien in trouve: icone = "❌" if controle["gravity"] == "broken" else "⚠" @@ -282,7 +437,7 @@ def render(database, resultats, version=None, colour=True): ) ) lignes.append(paint(f" {erreur}", "dim", colour)) - return "\n".join(lignes).rstrip() + return "\n".join(lignes + verdicts_block(database, colour)).rstrip() def main(argv=None): @@ -316,6 +471,7 @@ def main(argv=None): trouve, illisibles = judge(resultats) if args.json: + tous_verdicts, verdicts_ratés = verdicts(args.database) print( json.dumps( { @@ -324,6 +480,11 @@ def main(argv=None): "checks": resultats, "found": [c["key"] for c, _ in trouve], "unreadable": [c["key"] for c, _ in illisibles], + "verdicts": tous_verdicts, + "verdicts_failed": [ + {"step": quality.event_step(e), "name": e["name"]} + for e in verdicts_ratés + ], }, indent=2, ensure_ascii=False, diff --git a/script/analyse/check_view_type_tree.py b/script/analyse/check_view_type_tree.py new file mode 100755 index 0000000..51f3db9 --- /dev/null +++ b/script/analyse/check_view_type_tree.py @@ -0,0 +1,525 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Le type de vue « tree » qui survit dans un module, et casse à l'usage. + +Odoo 18 a supprimé le type `tree`. La sélection de `ir.ui.view.type` est +`list, form, graph, pivot, calendar, kanban, search, qweb` +(ir_ui_view.py:153), et le noyau ne porte AUCUNE conversion de +compatibilité — vérifié par balayage : la seule occurrence hors `etree` +est `odoo/tests/form.py:261`, sans rapport. + +Pourquoi cet outil-ci ne lit pas la base +---------------------------------------- +C'est l'angle mort de tous les autres. Un module resté sur `` ne +s'installe pas : il n'y a donc RIEN à lire en base, et un rapport tiré de +la base dit « tout va bien » avec assurance. Le défaut est sur le DISQUE, +et il revient à chaque resynchronisation de Google Repo. + +Le piège, et pourquoi ni grep ni regex +-------------------------------------- +« tree » est d'abord un morceau d'IDENTIFIANT. Sur 465 occurrences +mesurées dans `odoo18.0/addons`, une soixantaine cassent ; le reste est +un id de ``, un ``, un `env.ref()`, un nom de +variable. Et ce n'est pas de la négligence : le noyau 18 a gardé ses +propres identifiants historiques en ne renommant que les balises — +`account.view_invoice_tree` existe toujours, son arch en ``. + +La démonstration tient en deux lignes du même dépôt : + + openeducat_admission/models/admission.py:420 + (tree_view and tree_view.id or False, 'list') ← sain + openeducat_fees/models/student.py:142 + (tree_view and tree_view.id or False, 'tree') ← cassé + +Même nom de variable, même forme. Seul le littéral en POSITION de type +décide. D'où la règle : lxml et `ast`, jamais la ligne brute. Le bénéfice +est double — les commentaires disparaissent d'eux-mêmes, puisque ni l'un +ni l'autre ne construit de nœud pour eux. + +Trois portes avant tout motif +----------------------------- +Ce sont elles qui font le tri, pas les motifs. + +1. VERSION — rien à faire sous la 18 : `tree` y est valide. +2. MODULE — un fichier ne compte que sous un `__manifest__.py`, lu par + `literal_eval`. `installable: False` disqualifie le module entier. +3. CHARGEMENT — un XML doit figurer dans `data` ou `demo` ; un Python + doit être ATTEIGNABLE depuis `__init__.py`. Un fichier que personne ne + charge ne casse rien, et `literal_eval` fait disparaître d'elle-même + toute entrée commentée du manifeste. + +Ce que la balise en sous-vue fait vraiment +------------------------------------------ +Un `` sous un x2many n'est pas ignoré poliment : `ir_ui_view.py` +ne le reconnaît pas, donc ne le RETIRE pas, et ses `` repartent +dans la validation avec le modèle du formulaire PARENT. Le message parle +alors d'un champ inexistant et ne prononce jamais le mot « tree ». +""" + +from __future__ import annotations + +import ast +import io +import os +import sys +import warnings + +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 + + +REPO_ROOT = os.path.normpath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..") +) +FICHIER_VERSION = ".odoo-version" +PREMIERE_VERSION_SANS_TREE = (18, 0) +MANIFESTE = "__manifest__.py" + +COULEURS = { + "broken": "\033[31m", + "watch": "\033[33m", + "ok": "\033[32m", + "step": "\033[36m", + "dim": "\033[90m", +} +RESET = "\033[0m" + + +def paint(texte, genre, colour): + """Teinter, ou rendre le texte tel quel quand la couleur est coupée.""" + if not colour: + return texte + return f"{COULEURS.get(genre, '')}{texte}{RESET}" + + +def version_active(racine=REPO_ROOT): + """(majeur, mineur) du checkout, ou None si illisible.""" + try: + with io.open( + os.path.join(racine, FICHIER_VERSION), encoding="utf-8" + ) as handle: + brut = handle.read().strip() + except OSError: + return None + morceaux = brut.split(".") + try: + return (int(morceaux[0]), int(morceaux[1])) + except (IndexError, ValueError): + return None + + +def concerne(version): + """La 18 et au-delà. Sous elle, `tree` est valide — au pire déprécié.""" + return bool(version) and version >= PREMIERE_VERSION_SANS_TREE + + +def lire_manifeste(chemin): + """Le dict du manifeste, par `literal_eval` — jamais par import. + + Un `__manifest__.py` est une expression, pas un programme : l'évaluer + exécuterait du code d'un dépôt tiers pour lire quatre clés. Et + `literal_eval` fait disparaître d'elle-même toute entrée commentée. + """ + try: + with io.open(chemin, encoding="utf-8") as handle: + valeur = ast.literal_eval(handle.read()) + except (OSError, ValueError, SyntaxError, MemoryError, RecursionError): + return None + return valeur if isinstance(valeur, dict) else None + + +def modules(racine): + """[(chemin du module, manifeste)] — ceux qu'Odoo installerait. + + `installable: False` disqualifie : Odoo ne le liste même pas, il ne + peut casser ni à l'installation ni à l'exécution. + """ + lst = [] + for dossier, sous, fichiers in os.walk(racine): + if MANIFESTE not in fichiers: + continue + sous[:] = [] # un module ne contient pas un autre module + manifeste = lire_manifeste(os.path.join(dossier, MANIFESTE)) + if manifeste is None: + continue + if manifeste.get("installable", True) is False: + continue + lst.append((dossier, manifeste)) + return sorted(lst) + + +def xml_charges(module, manifeste): + """Les XML que le manifeste demande de charger, et qui existent.""" + lst, absents = [], [] + for clef in ("data", "demo"): + for relatif in manifeste.get(clef) or []: + if not isinstance(relatif, str) or not relatif.endswith(".xml"): + continue + chemin = os.path.join(module, relatif) + (lst if os.path.isfile(chemin) else absents).append(chemin) + return lst, absents + + +def py_atteignables(module): + """Les .py qu'un `import` du module finit par charger. + + On SUIT les `from . import x`, plutôt que de ramasser tout le dossier : + un fichier que personne n'importe ne s'exécute jamais, et le signaler + ferait crier sur des brouillons laissés là. + """ + vus, atteints = set(), [] + pile = [module] + while pile: + paquet = pile.pop() + init = os.path.join(paquet, "__init__.py") + if init in vus or not os.path.isfile(init): + continue + vus.add(init) + atteints.append(init) + try: + with io.open(init, encoding="utf-8") as handle: + arbre = ast.parse(handle.read()) + except (OSError, SyntaxError, ValueError): + continue + for noeud in ast.walk(arbre): + noms = [] + if isinstance(noeud, ast.ImportFrom) and noeud.level: + if noeud.module: + noms.append(noeud.module) + noms += [a.name for a in noeud.names] + elif isinstance(noeud, ast.Import): + noms = [a.name.split(".")[0] for a in noeud.names] + for nom in noms: + relatif = nom.replace(".", os.sep) + fichier = os.path.join(paquet, relatif + ".py") + dossier = os.path.join(paquet, relatif) + if os.path.isfile(fichier) and fichier not in vus: + vus.add(fichier) + atteints.append(fichier) + elif os.path.isdir(dossier): + pile.append(dossier) + return [c for c in atteints if os.sep + "tests" + os.sep not in c] + + +def _modes(texte): + """« form, tree » → ['form', 'tree'] — le découpage est obligatoire. + + Un simple `in` attraperait aussi un futur « treemap », et raterait + l'espace après la virgule que les vues réelles écrivent. + """ + return [x.strip() for x in (texte or "").split(",")] + + +def ligne_de_la_balise(chemin, sourceline, balise="tree"): + """lxml rend la ligne où FINIT la balise ouvrante ; on remonte. + + Mesuré : sur une balise étalée sur six lignes, `sourceline` disait 66 + quand `. + if not isinstance(el.tag, str): + continue + if el.tag == "tree": + lst.append( + ( + ligne_de_la_balise(chemin, el.sourceline), + "xml_balise", + "", + "", + ) + ) + continue + expr = el.get("expr") + if expr: + segments = expr.replace("//", "/").split("/") + for segment in segments: + if segment.split("[")[0] == "tree": + lst.append((el.sourceline, "xml_xpath", expr[:70], "list")) + break + if el.tag != "field": + continue + nom = el.get("name") + texte = (el.text or "").strip() + if nom == "view_mode" and "tree" in _modes(texte): + lst.append( + ( + el.sourceline, + "xml_view_mode", + texte[:60], + texte.replace("tree", "list"), + ) + ) + elif nom == "type" and texte == "tree": + lst.append((el.sourceline, "xml_type", texte, "list")) + return lst + + +def _est_tuple_de_vue(noeud): + """(id, 'tree') — un couple dont le SECOND membre est le type. + + Le premier membre est un id, False, un Name ou une expression : jamais + une chaîne. Sans cette condition, la règle crierait sur les couples de + compatibilité ('list', 'tree'), qui sont du code défensif correct. + """ + if not isinstance(noeud, (ast.Tuple, ast.List)) or len(noeud.elts) != 2: + return False + premier, second = noeud.elts + if not isinstance(second, ast.Constant) or second.value != "tree": + return False + return not ( + isinstance(premier, ast.Constant) and isinstance(premier.value, str) + ) + + +def constats_py(chemin): + """[(ligne, motif, extrait, correction)] pour UN fichier python.""" + try: + with io.open(chemin, encoding="utf-8") as handle: + source = handle.read() + with warnings.catch_warnings(): + warnings.simplefilter("ignore", SyntaxWarning) + arbre = ast.parse(source) + except (OSError, SyntaxError, ValueError): + return None + lst = [] + for noeud in ast.walk(arbre): + if _est_tuple_de_vue(noeud): + lst.append( + (noeud.lineno, "py_views", "(…, 'tree')", "(…, 'list')") + ) + continue + if not isinstance(noeud, ast.Dict): + continue + paires = { + clef.value: valeur + for clef, valeur in zip(noeud.keys, noeud.values) + if isinstance(clef, ast.Constant) and isinstance(clef.value, str) + } + est_action = ( + isinstance(paires.get("type"), ast.Constant) + and paires["type"].value == "ir.actions.act_window" + ) + for clef in ("view_mode", "view_type"): + valeur = paires.get(clef) + if not isinstance(valeur, ast.Constant): + continue + if not isinstance(valeur.value, str): + continue + if "tree" not in _modes(valeur.value): + continue + # `view_type` sur un dict d'act_window est un vestige d'Odoo 12 + # que la 18 ignore sans bruit. Ailleurs — un `code.generator. + # view`, dont la sélection ne propose que `list` — il lève. + if clef == "view_type" and est_action: + continue + lst.append( + ( + valeur.lineno, + "py_" + clef, + valeur.value[:60], + valeur.value.replace("tree", "list"), + ) + ) + return lst + + +def inspect(racine, sous_dossier="addons"): + """{'findings': […], 'unreadable': […], 'missing': […]} pour un checkout.""" + version = version_active(racine) + rapport = { + "version": version, + "scanned": 0, + "findings": [], + "unreadable": [], + "missing": [], + } + if not concerne(version): + return rapport + base = os.path.join(racine, "odoo%d.%d" % version, sous_dossier) + if not os.path.isdir(base): + return rapport + for module, manifeste in modules(base): + rapport["scanned"] += 1 + court = os.path.basename(module) + charges, absents = xml_charges(module, manifeste) + for chemin in absents: + rapport["missing"].append((court, os.path.relpath(chemin, racine))) + for chemin in charges: + lst = constats_xml(chemin) + if lst is None: + rapport["unreadable"].append( + (court, os.path.relpath(chemin, racine)) + ) + continue + for ligne, motif, extrait, correction in lst: + rapport["findings"].append( + ( + court, + os.path.relpath(chemin, racine), + ligne, + motif, + extrait, + correction, + ) + ) + for chemin in py_atteignables(module): + lst = constats_py(chemin) + if lst is None: + rapport["unreadable"].append( + (court, os.path.relpath(chemin, racine)) + ) + continue + for ligne, motif, extrait, correction in lst: + rapport["findings"].append( + ( + court, + os.path.relpath(chemin, racine), + ligne, + motif, + extrait, + correction, + ) + ) + return rapport + + +def render(rapport, colour=True): + """Le rapport lisible, groupé par module.""" + lignes = [f"🌲 {t('View type tree, removed in Odoo 18')}", ""] + if not concerne(rapport["version"]): + lignes.append( + paint( + f" {t('nothing to do below 18.0 — tree is valid there')}", + "dim", + colour, + ) + ) + return "\n".join(lignes) + + trouves = rapport["findings"] + if not trouves: + lignes.append( + paint( + f"✅ {t('No module carries it.')}" + f" ({rapport['scanned']} {t('modules read')})", + "ok", + colour, + ) + ) + par_module = {} + for module, chemin, ligne, motif, extrait, correction in trouves: + par_module.setdefault(module, []).append( + (chemin, ligne, motif, extrait, correction) + ) + for module in sorted(par_module): + lst = par_module[module] + lignes.append( + paint(f"❌ {str(len(lst)).rjust(4)} {module}", "broken", colour) + ) + for chemin, ligne, motif, extrait, correction in sorted(lst): + lignes.append(paint(f" {chemin}:{ligne}", "dim", colour)) + lignes.append(f" {extrait} → {correction}") + lignes.append("") + + # Deux constats voisins, jamais mélangés aux « tree » : ils ont une + # autre cause et une autre réparation. + for clef, titre, genre in ( + ("missing", "listed in the manifest, absent from disk", "broken"), + ("unreadable", "listed in the manifest, unparsable", "watch"), + ): + if rapport[clef]: + lignes.append(paint(f"{t(titre)} :", genre, colour)) + for module, chemin in sorted(rapport[clef]): + lignes.append(paint(f" {chemin}", "dim", colour)) + lignes.append("") + return "\n".join(lignes).rstrip() + + +def main(argv=None): + import argparse + import json + + parser = argparse.ArgumentParser( + description=t("Modules still carrying the removed tree view type."), + ) + parser.add_argument("--root", default=REPO_ROOT) + parser.add_argument("--json", action="store_true") + parser.add_argument("--no-color", action="store_true") + args = parser.parse_args(argv) + + if not os.path.isdir(args.root): + print(f"❌ {args.root}", file=sys.stderr) + return 2 + + rapport = inspect(args.root) + if args.json: + print( + json.dumps( + { + "odoo": ( + "%d.%d" % rapport["version"] + if rapport["version"] + else None + ), + "modules_read": rapport["scanned"], + "findings": [ + { + "module": m, + "file": f, + "line": ligne, + "pattern": motif, + "found": extrait, + "replace_with": corr, + } + for m, f, ligne, motif, extrait, corr in rapport[ + "findings" + ] + ], + "missing_files": [f for _m, f in rapport["missing"]], + "unparsable_files": [f for _m, f in rapport["unreadable"]], + }, + indent=2, + ensure_ascii=False, + ) + ) + else: + colour = sys.stdout.isatty() and not args.no_color + print(render(rapport, colour)) + return 1 if rapport["findings"] or rapport["missing"] else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/database/db_duplicate.py b/script/database/db_duplicate.py new file mode 100755 index 0000000..101a70d --- /dev/null +++ b/script/database/db_duplicate.py @@ -0,0 +1,347 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Dupliquer une base, et la neutraliser si on le demande. + +Pourquoi passer par Odoo plutôt que par `CREATE DATABASE … TEMPLATE` +------------------------------------------------------------------- +La commande PostgreSQL copie les tables, et RIEN d'autre. Odoo, lui, fait +quatre choses de plus que `exp_duplicate_database` réunit : + +1. Il coupe les connexions ouvertes sur la source. Sans cela PostgreSQL + refuse : « is being accessed by other users » — un shell Odoo laissé + ouvert suffit à faire échouer la copie. +2. Il RÉGÉNÈRE le `database.uuid`. Deux bases qui partagent le leur se + présentent comme la même instance au service de garantie d'Odoo. +3. Il copie le FILESTORE. Sans lui, chaque pièce jointe de la copie + pointe vers un fichier qui n'existe pas — 57 Mo perdus en silence sur + la base qui a servi à écrire ceci. +4. Il neutralise pour de bon, quand on le demande. + +Ce que « neutraliser » veut dire, mesuré +---------------------------------------- +Le dépôt a trois modules maison — `disable_mail_server`, +`disable_auto_backup`, `disable_payment_provider` — qui font trois gestes. +Aucun ne pose le drapeau, et l'un d'eux ouvre une porte : supprimer tous +les `ir.mail_server` fait retomber Odoo sur le `smtp_server` du fichier +de configuration. + +`neutralize_database` d'Odoo exécute les 73 fichiers `neutralize.sql` +livrés par les modules INSTALLÉS. Mesuré sur une base migrée 12 → 18 : + + database.is_neutralized ABSENT → true + crons actifs 33 → 1 (l'autovacuum, voulu) + serveurs de courriel 0 → 1 le bouchon « invalid » + clé Stripe présente 1 → 0 + +Deux techniques, selon la version +--------------------------------- +`neutralize.py` n'existe qu'à partir d'Odoo 16 ; avant, +`exp_duplicate_database` ne prend que deux arguments. De 12 à 15 on +retombe donc sur la technique du dépôt : `update_prod_to_dev.sh`, qui +installe puis désinstalle `user_test`, `disable_mail_server`, +`disable_auto_backup` et `disable_payment_provider`. + +Les deux n'obtiennent PAS la même chose, et il faut le savoir : + + Odoo ≥ 16 script 12→15 + database.is_neutralized posé non posé + crons désactivés oui non + serveur de courriel bouchon supprimés (*) + clés de paiement mises à laissées + NULL en place + compte test/test non oui + +(*) supprimer tous les `ir.mail_server` fait retomber Odoo sur le +`smtp_server` du fichier de configuration ; le bouchon d'Odoo existe +précisément pour boucher ce trou-là. + +La technique employée est ANNONCÉE à chaque exécution : une copie dont +on ignore par quel chemin elle est passée ne se juge pas. +""" + +from __future__ import annotations + +import argparse +import os +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 + + +REPO_ROOT = os.path.normpath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..") +) + +# La neutralisation d'Odoo n'existe pas avant cette version. +PREMIERE_VERSION_NEUTRALISABLE = 16.0 + +# Comment on neutralise, selon ce que la version sait faire. +NEUTRALISATION_ODOO = "odoo" +NEUTRALISATION_SCRIPT = "script" +SCRIPT_PROD_TO_DEV = "./script/addons/update_prod_to_dev.sh" + + +def lire_version(nom): + """Le contenu d'un fichier `.xxx-version`, ou None.""" + chemin = os.path.join(REPO_ROOT, nom) + try: + with open(chemin, "r", encoding="utf-8") as handle: + return handle.read().strip() or None + except OSError: + return None + + +def chemins_odoo(): + """(python du venv, racine d'Odoo) — résolus comme odoo_bin.sh le fait. + + Le nom du venv porte les DEUX versions, Odoo et Python : le composer + de tête revient à se tromper un jour sur l'une des deux. + """ + erplibre = lire_version(".erplibre-version") + odoo = lire_version(".odoo-version") + if not erplibre or not odoo: + return None, None + python = os.path.join(REPO_ROOT, f".venv.{erplibre}", "bin", "python") + racine = os.path.join(REPO_ROOT, f"odoo{odoo}", "odoo") + return python, racine + + +# « rien n'a été passé » et « on m'a passé une version illisible » sont +# deux questions différentes, et un `None` ne doit pas se faire prendre +# pour l'autre : l'appelant dont la lecture a échoué se verrait répondre +# sur la version du checkout, c'est-à-dire sur autre chose. +_NON_FOURNI = object() + + +def supporte_neutralisation(version=_NON_FOURNI): + """La version sait-elle neutraliser ? Sans argument : celle du checkout.""" + if version is _NON_FOURNI: + version = lire_version(".odoo-version") + try: + return float(version) >= PREMIERE_VERSION_NEUTRALISABLE + except (TypeError, ValueError): + return False + + +def technique_neutralisation(version=_NON_FOURNI): + """Par quel chemin neutraliser, pour cette version. + + On ne REFUSE plus sous 12→15 : le dépôt a sa technique, et une copie + imparfaitement neutralisée vaut mieux qu'une copie brute. Ce qui + compte est de dire laquelle a servi. + """ + if supporte_neutralisation(version): + return NEUTRALISATION_ODOO + return NEUTRALISATION_SCRIPT + + +def nom_valide(nom): + """Un nom de base acceptable — c'est aussi ce qui entre dans du SQL.""" + if not nom or len(nom) > 63: + return False + if not (nom[0].isalpha() or nom[0] == "_"): + return False + return all(c.isalnum() or c in "_-$" for c in nom) + + +def script_python(source, cible, neutraliser, config): + """Le programme qu'on remet à l'interpréteur d'Odoo. + + `parse_config` est indispensable : sans lui, `Registry.new` ne sait ni + où se connecter ni où sont les addons, et la duplication échoue après + avoir DÉJÀ créé la base. + """ + return ( + "import sys\n" + "sys.path.insert(0, %r)\n" + "import odoo\n" + "odoo.tools.config.parse_config(['-c', %r])\n" + "from odoo.service.db import exp_duplicate_database\n" + "exp_duplicate_database(%r, %r%s)\n" + % ( + chemins_odoo()[1], + config, + source, + cible, + ", True" if neutraliser else "", + ) + ) + + +def bases_existantes(): + """Les bases que PostgreSQL expose, ou None si on n'a pas pu demander.""" + try: + fait = subprocess.run( + [ + "psql", + "-X", + "-w", + "-tA", + "-d", + "postgres", + "-c", + "SELECT datname FROM pg_database WHERE NOT datistemplate", + ], + capture_output=True, + text=True, + timeout=60, + ) + except (OSError, subprocess.SubprocessError): + return None + if fait.returncode: + return None + return { + ligne.strip() for ligne in fait.stdout.splitlines() if ligne.strip() + } + + +def verifier(source, cible, neutraliser): + """Ce qui empêche de commencer. Rendre la liste des refus.""" + refus = [] + for nom, role in ((source, "source"), (cible, "cible")): + if not nom_valide(nom): + refus.append(f"{t('Invalid database name: ')}{nom!r} ({role})") + if source == cible: + refus.append(t("The source and the copy cannot share a name.")) + bases = bases_existantes() + if bases is not None: + if source not in bases: + refus.append(f"{t('No such database: ')}{source}") + if cible in bases: + # Écraser une base sans le dire est la faute qu'on ne rattrape + # pas : le contenu d'avant n'existe plus nulle part. + refus.append(f"{t('This database already exists: ')}{cible}") + if neutraliser and technique_neutralisation() == NEUTRALISATION_SCRIPT: + # PAS un refus : de 12 à 15 on passe par update_prod_to_dev.sh. + # Le script doit exister, sinon la copie sortirait brute en se + # croyant neutralisée — le seul cas vraiment dangereux. + if not os.path.isfile(os.path.join(REPO_ROOT, SCRIPT_PROD_TO_DEV)): + refus.append(f"{t('Missing script: ')}{SCRIPT_PROD_TO_DEV}") + return refus + + +def neutraliser_par_script(cible, timeout=3600): + """La technique du dépôt, pour Odoo 12 à 15. Rendre (code, sortie). + + `update_prod_to_dev.sh` installe puis désinstalle quatre modules + maison. Il tourne depuis la racine du dépôt : les chemins qu'il + contient sont relatifs, et l'appeler d'ailleurs le fait échouer sur + `install_addons_dev.sh` introuvable. + """ + fait = subprocess.run( + ["bash", SCRIPT_PROD_TO_DEV, cible], + cwd=REPO_ROOT, + capture_output=True, + text=True, + timeout=timeout, + ) + if fait.returncode: + return fait.returncode, (fait.stderr or fait.stdout).strip() + return 0, (fait.stdout or "").strip() + + +def dupliquer( + source, cible, neutraliser=False, config="config.conf", timeout=3600 +): + """Faire la copie, puis la neutraliser par le chemin de la version. + + La copie d'abord, la neutralisation ensuite : si la seconde échoue, + la base existe et l'on sait qu'elle est BRUTE. L'inverse — une base + à moitié neutralisée — ne se distinguerait pas d'une base traitée. + """ + python, racine = chemins_odoo() + if not python or not os.path.isfile(python): + return 2, t("Cannot find the virtualenv for this checkout.") + if not racine or not os.path.isdir(racine): + return 2, t("Cannot find the Odoo source for this checkout.") + + par_odoo = neutraliser and ( + technique_neutralisation() == NEUTRALISATION_ODOO + ) + fait = subprocess.run( + [python, "-c", script_python(source, cible, par_odoo, config)], + cwd=REPO_ROOT, + capture_output=True, + text=True, + timeout=timeout, + ) + if fait.returncode: + return fait.returncode, (fait.stderr or fait.stdout).strip() + + if neutraliser and not par_odoo: + code, sortie = neutraliser_par_script(cible, timeout=timeout) + if code: + return code, sortie + return 0, (fait.stdout or "").strip() + + +def main(argv=None): + parser = argparse.ArgumentParser( + description=t("Duplicate a database, filestore included."), + ) + parser.add_argument("-s", "--source", required=True) + parser.add_argument("-d", "--database", required=True, help=t("the copy")) + parser.add_argument("-c", "--config", default="config.conf") + parser.add_argument( + "--neutralize", + action="store_true", + help=t("disable crons, outgoing mail and payment providers"), + ) + args = parser.parse_args(argv) + + refus = verifier(args.source, args.database, args.neutralize) + if refus: + for ligne in refus: + print(f"❌ {ligne}", file=sys.stderr) + return 2 + + technique = technique_neutralisation() + if args.neutralize: + # DIRE par quel chemin : les deux ne posent pas les mêmes gestes, + # et une copie dont on ignore le traitement ne se juge pas. + comment = ( + t("Odoo's own neutralisation") + if technique == NEUTRALISATION_ODOO + else f"{SCRIPT_PROD_TO_DEV} ({t('Odoo')}" + f" {lire_version('.odoo-version')})" + ) + print(f"🧬 {args.source} → {args.database} — {comment}") + else: + print(f"🧬 {args.source} → {args.database}") + code, sortie = dupliquer( + args.source, args.database, args.neutralize, args.config + ) + if code: + print(f"❌ {t('The duplication failed.')}", file=sys.stderr) + if sortie: + print(sortie[-2000:], file=sys.stderr) + return 2 + print(f"✅ {t('Done:')} {args.database}") + if args.neutralize and technique == NEUTRALISATION_SCRIPT: + print( + f"ℹ️ {t('This route does not set database.is_neutralized, does')}" + f" {t('not disable crons, and leaves payment keys in place.')}" + ) + print(f"ℹ️ {t('You can log in with test / test.')}") + if not args.neutralize: + print( + f"⚠️ {t('NOT neutralised: its crons run, its mail leaves and')}" + f" {t('its payment providers can charge.')}" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/git/commit_msg_lib.py b/script/git/commit_msg_lib.py new file mode 100644 index 0000000..2c9366d --- /dev/null +++ b/script/git/commit_msg_lib.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Le sujet d'un commit tient-il la convention ? + +La convention est dans `.claude/rules/04-code-conventions.md`, son mode +d'emploi dans `conf/template_claude_commands_commit.md`. Ce module en vérifie +la part MÉCANIQUE : le tag, la longueur, et le sujet qui s'ouvre sur une +citation. Le reste — « ce sujet dit-il sur quoi porte le code » — est un +jugement, et aucun hook ne le rendra. + +Compté en CARACTÈRES et non en octets : « préchauffer » pèse 12 caractères et +14 octets, et une limite en octets refuserait des sujets français conformes. +""" + +MAX = 72 + +TAGS = ("ADD", "FIX", "UPD", "IMP", "REF", "REM", "MOV", "I18N") + +# Ce que git écrit lui-même, ou ce qu'un rebase consomme : hors convention. +GENERATED = ("Merge ", "Revert ", "fixup!", "squash!", "amend!") + +# Un sujet qui s'ouvre sur une citation cite un écran. C'est une PREUVE, et une +# preuve va dans le corps — le sujet doit nommer la cause. +QUOTES = ("«", '"', "'", "`", "“", "‘") + + +def subject_of(message: str) -> str: + """La première ligne utile : ni commentaire, ni ligne vide.""" + for line in message.split("\n"): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + return stripped + return "" + + +def check(message: str) -> list: + """Rend la liste des problèmes. Vide si le sujet passe.""" + subject = subject_of(message) + if not subject or subject.startswith(GENERATED): + return [] + + problems = [] + + tag = None + for candidate in TAGS: + if subject.startswith(f"[{candidate}]"): + tag = candidate + break + if tag is None: + problems.append( + f"le sujet doit commencer par un tag : {', '.join('[%s]' % t for t in TAGS)}" + ) + + if len(subject) > MAX: + problems.append( + f"le sujet fait {len(subject)} caractères, {MAX} au plus.\n" + " Ne le tronquez pas : à cette longueur, écrivez des MOTS-CLÉS\n" + " qui résument plutôt qu'une phrase amputée. « proxmox : pmxcfs,\n" + " stockage, diagnostic » vaut mieux qu'une phrase coupée net." + ) + + rest = subject.split(":", 1)[1].strip() if ":" in subject else "" + if rest.startswith(QUOTES): + problems.append( + "le sujet s'ouvre sur une citation. Un message d'écran est une\n" + " preuve : elle va dans le corps. Le sujet nomme la cause." + ) + + return problems diff --git a/script/git/hooks/commit-msg b/script/git/hooks/commit-msg new file mode 100755 index 0000000..7674ef7 --- /dev/null +++ b/script/git/hooks/commit-msg @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Hook commit-msg : refuse un sujet hors convention. + +Installation : + + git config core.hooksPath script/git/hooks + +Contournement pour un cas légitime : `git commit --no-verify`. +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from commit_msg_lib import check # noqa: E402 + + +def main() -> int: + if len(sys.argv) < 2: + print("commit-msg : aucun fichier de message reçu", file=sys.stderr) + return 1 + path = Path(sys.argv[1]) + try: + message = path.read_text(encoding="utf-8") + except OSError as exc: + print(f"commit-msg : {exc}", file=sys.stderr) + return 1 + + problems = check(message) + if not problems: + return 0 + + print("\n ✗ sujet de commit hors convention\n", file=sys.stderr) + for problem in problems: + print(f" - {problem}", file=sys.stderr) + print( + "\n La règle : .claude/rules/04-code-conventions.md" + "\n Le mode d'emploi : conf/template_claude_commands_commit.md" + "\n Passer outre pour un cas légitime : git commit --no-verify\n", + file=sys.stderr, + ) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/lib_odoo_rc.sh b/script/lib_odoo_rc.sh new file mode 100755 index 0000000..ecd575f --- /dev/null +++ b/script/lib_odoo_rc.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +# +# Quel fichier de configuration Odoo doit lire, quand personne ne le dit. +# +# Sans cela, Odoo retombe sur ~/.odoorc — un fichier PERSONNEL, hors du +# dépôt, que rien ne synchronise avec `config.conf`. Mesuré : un ~/.odoorc +# portant un mot de passe maître haché faisait échouer +# « odoo_bin.sh db --drop » par AccessDenied, alors que db_restore.py +# venait de lire `admin_passwd = admin` dans config.conf et d'en conclure +# qu'aucun mot de passe n'était nécessaire. Les deux avaient raison : ils +# ne parlaient pas du même fichier. +# +# La précédence d'Odoo est la même en 12 et en 18 (tools/config.py) : +# +# self.config_file or opt.config or ODOO_RC or OPENERP_SERVER or ~/.odoorc +# +# Poser ODOO_RC ne retire donc rien à personne : un « -c » explicite +# l'emporte toujours, et un ODOO_RC déjà posé n'est pas écrasé. +# +# L'ordre des candidats est celui de db_restore.py, pour que la +# vérification qu'il fait porte sur le fichier qu'Odoo lira vraiment. + +odoo_rc_resolve() { + if [[ -n "${ODOO_RC:-}" ]]; then + return 0 + fi + local racine="${1:-$(pwd)}" + local candidat + for candidat in "${racine}/config.conf" /etc/odoo/odoo.conf; do + if [[ -f "${candidat}" ]]; then + export ODOO_RC="${candidat}" + return 0 + fi + done + return 0 +} diff --git a/script/odoo/migration/fix_migration_odoo170_to_odoo180.sql b/script/odoo/migration/fix_migration_odoo170_to_odoo180.sql index 65993f4..e58f32e 100644 --- a/script/odoo/migration/fix_migration_odoo170_to_odoo180.sql +++ b/script/odoo/migration/fix_migration_odoo170_to_odoo180.sql @@ -34,3 +34,85 @@ BEGIN RAISE NOTICE 'forum_tag_rel.forum_id renommee en forum_post_id'; END IF; END $$; + +-- account_root : une VUE SQL qu'Odoo 17 créait pour le modèle +-- `account.root` (son init() bâtissait la vue sur account_account.code), +-- et que la 18 a orphelinée sans la retirer. En 18 le modèle porte +-- `_auto = False` et `_table_query = '0'` : son nom n'entre plus dans +-- aucune requête, et registry.py exclut ces modèles du contrôle des +-- tables manquantes — Odoo ne la recréera donc jamais. +-- +-- Elle n'est pas seulement morte, elle est FAUSSE : bâtie sur la colonne +-- `code` que l'ORM 18 n'écrit plus, elle rendait 14 racines là où la +-- donnée vivante (code_store) en portait 31. Un objet du schéma public +-- qui répond faux, sans avertir, à qui l'interroge à la main ou par un +-- outil décisionnel. +-- +-- Elle est AUSSI l'unique épingle de deux colonnes héritées : +-- database_cleanup a purgé 110 colonnes orphelines au palier 18 et n'a +-- échoué que sur account_account.code et .company_id, dont elle dépend. +-- On retire donc l'obstacle ICI, et l'outil déjà en place finit sa passe +-- APRÈS le chargement en 18. +-- +-- On ne supprime SURTOUT PAS les colonnes ici : le post-migration +-- d'OpenUpgrade les lit encore pour remplir code_store et company_ids, +-- et il tourne après ce fichier. +DO $$ +DECLARE + oid_vue oid; + oid_table oid; + nb_dependants int; +BEGIN + SELECT c.oid INTO oid_vue + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' + AND c.relname = 'account_root' + AND c.relkind = 'v'; + IF oid_vue IS NULL THEN + -- Ni vue, ni base sans le module account : rien à faire. C'est + -- aussi ce qui rend l'ordre rejouable après un premier passage. + RETURN; + END IF; + + oid_table := to_regclass('public.account_account'); + IF oid_table IS NULL THEN + RAISE NOTICE 'account_root sans account_account : laissee en place'; + RETURN; + END IF; + + -- La vue morte est CELLE QUI LIT account_account.code. Le contrôle + -- est structurel et non textuel : si une version future d'Odoo + -- recree un account_root d'une autre forme, on n'y touche pas. + IF NOT EXISTS ( + SELECT 1 + FROM pg_depend d + JOIN pg_rewrite r ON r.oid = d.objid + JOIN pg_attribute a ON a.attrelid = d.refobjid + AND a.attnum = d.refobjsubid + WHERE r.ev_class = oid_vue + AND d.refobjid = oid_table + AND a.attname = 'code' + ) THEN + RAISE NOTICE 'account_root ne lit pas account_account.code : laissee en place'; + RETURN; + END IF; + + -- Rien ne doit en dependre. Le jour ou quelque chose en dependrait, + -- APPRENDRE plutot que detruire : on le dit et on passe. Jamais de + -- CASCADE, et jamais d echec — ce fichier tourne sous ON_ERROR_STOP. + SELECT count(DISTINCT ev.oid) INTO nb_dependants + FROM pg_depend d + JOIN pg_rewrite r ON r.oid = d.objid + JOIN pg_class ev ON ev.oid = r.ev_class + WHERE d.refobjid = oid_vue + AND ev.oid <> oid_vue; + IF nb_dependants > 0 THEN + RAISE NOTICE 'account_root : % objet(s) en dependent, laissee en place', + nb_dependants; + RETURN; + END IF; + + DROP VIEW public.account_root; + RAISE NOTICE 'vue morte account_root supprimee (Odoo 18 ne la recree pas)'; +END $$; diff --git a/script/odoo/migration/restore_config_defaults.py b/script/odoo/migration/restore_config_defaults.py index e40cf92..e887351 100755 --- a/script/odoo/migration/restore_config_defaults.py +++ b/script/odoo/migration/restore_config_defaults.py @@ -74,12 +74,31 @@ try: if "product.pricelist" in env: Liste = env["product.pricelist"].sudo().with_context(active_test=False) rapport["pricelist_before"] = Liste.search_count([]) - # Le groupe décide : sans lui la fonctionnalité est masquée et - # l'absence de liste est normale, pas une perte. - rapport["pricelist_group"] = env.user.has_group( - "product.group_product_pricelist" + # Ce qui décide, c'est la FONCTIONNALITÉ, pas l'appartenance de + # celui qui exécute. `has_group` était vrai ici parce que six + # utilisateurs sont membres directs du groupe — hérité d'un + # palier de migration — alors que la case de configuration était + # décochée. On créait donc une liste de prix dans une base dont + # la fonctionnalité est éteinte, et Odoo prévenait à chaque + # ouverture des réglages qu'il allait l'archiver. + # + # `res.config.settings` lit ce que `base.group_user` IMPLIQUE + # (res_config.py : « which groups are implied by the group + # Employee ») : c'est la même question qu'on pose ici. + fonction = env.ref("product.group_product_pricelist", False) + employe = env.ref("base.group_user", False) + rapport["pricelist_group"] = bool( + fonction and employe and fonction in employe.implied_ids ) - if not DRY and rapport["pricelist_group"]: + # Et seulement s'il n'y en a AUCUNE. `_activate_or_create_pricelists` + # ne compte pas une liste PARTAGÉE (company_id vide) comme + # appartenant à la société : appelée alors qu'une liste existe, elle + # en ajoute une seconde. Mesuré sur une migration où la liste avait + # traversé les six paliers — la réparation a créé un doublon vide à + # côté d'elle. Cet outil a son détecteur ; il le consulte. + if not DRY and rapport["pricelist_group"] and not rapport[ + "pricelist_before" + ]: Societe._activate_or_create_pricelists() env.cr.commit() rapport["pricelist_after"] = Liste.search_count([]) diff --git a/script/odoo/warmup_http.py b/script/odoo/warmup_http.py new file mode 100755 index 0000000..0dd3dca --- /dev/null +++ b/script/odoo/warmup_http.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Réveiller l'instance dès qu'elle écoute, puis disparaître. + +Odoo ne charge le registre d'une base qu'à la PREMIÈRE requête qui la +concerne. Sur une base migrée, ce premier chargement prend des dizaines de +secondes — et c'est la personne qui ouvre la page qui les attend. Lancée +en parallèle de `run.sh`, cette sonde prend ce temps à sa place. + +Trois règles, et elles tiennent au fait qu'elle tourne À CÔTÉ du serveur +-------------------------------------------------------------------- +1. Elle se TAIT. Une seule ligne, au démarrage, pour dire quelle adresse + elle a choisie — c'est ce qui permet de comprendre après coup un + réveil qui n'a pas eu lieu. Ensuite plus rien : ni échec, ni trace. + Un serveur qu'on arrête au bout de dix secondes est un cas ordinaire, + pas une erreur, et un message d'erreur dans le journal de `run.sh` + ferait chercher une panne qui n'existe pas. +2. Elle s'arrête à la PREMIÈRE réponse, quelle qu'elle soit. Un 303 vers + /web/login, un 404, un 500 : tous prouvent que le registre est chargé, + ce qui est l'objet. Seul le refus de connexion ne compte pas. +3. Elle meurt au bout de deux minutes. Le réveil est un confort ; une + sonde qui survit à son serveur en est un autre, mauvais. + +Où elle trouve le port +---------------------- +D'abord la ligne de commande — `-p 8090` l'emporte sur tout le reste, +c'est ce que la personne vient de taper. Puis `config.conf`, que `run.sh` +a déjà résolu et qu'elle reçoit tel quel. Le journal d'exécution ne sert +qu'en dernier recours : Odoo y écrit « HTTP service (werkzeug) running on +127.0.0.1:8069 » une fois qu'il écoute, ce qui arrive parfois APRÈS le +premier essai — mais c'est aussi la seule source exacte quand le port +demandé était déjà pris. +""" + +from __future__ import annotations + +import os +import re +import sys +import time + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..")) +) + +from script.analyse import lib_analyse # noqa: E402 + +DELAI_TOTAL = 120.0 +DELAI_ENTRE_ESSAIS = 1.0 +DELAI_REQUETE = 10.0 +PORT_PAR_DEFAUT = 8069 +HOTE_PAR_DEFAUT = "127.0.0.1" + +# « HTTP service (werkzeug) running on 127.0.0.1:8069 » +MOTIF_JOURNAL = re.compile( + r"HTTP service \(werkzeug\) running on ([^:\s]+):(\d+)" +) + + +def port_de_la_ligne(argv): + """Le port demandé sur la ligne de commande, ou None. + + `-p 8090`, `--http-port 8090` et `--http-port=8090` : les trois + formes existent, et Odoo les accepte toutes. + """ + for index, argument in enumerate(argv): + if argument in ("-p", "--http-port") and index + 1 < len(argv): + valeur = argv[index + 1] + if valeur.isdigit(): + return int(valeur) + if argument.startswith("--http-port="): + valeur = argument.split("=", 1)[1] + if valeur.isdigit(): + return int(valeur) + return None + + +def adresse_du_journal(chemin, taille=8192): + """(hôte, port) lus dans la fin du journal, ou (None, None). + + On ne lit que la queue : un journal de migration pèse des mégaoctets, + et la ligne qu'on cherche est la dernière écrite. + """ + if not chemin or not os.path.isfile(chemin): + return None, None + try: + with open(chemin, "rb") as handle: + handle.seek(0, os.SEEK_END) + debut = max(0, handle.tell() - taille) + handle.seek(debut) + queue = handle.read().decode("utf-8", "replace") + except OSError: + return None, None + trouve = MOTIF_JOURNAL.findall(queue) + if not trouve: + return None, None + hote, port = trouve[-1] + return hote, int(port) + + +def adresse(argv=(), config_path=None, journal=None): + """(hôte, port) à réveiller, dans l'ordre de ce qui fait autorité.""" + config = lib_analyse.read_config(config_path) + + port = port_de_la_ligne(argv) + hote = None + if port is None: + hote, port = adresse_du_journal(journal) + if port is None: + brut = str(config.get("http_port", "")).strip() + port = int(brut) if brut.isdigit() else PORT_PAR_DEFAUT + + if not hote: + interface = str(config.get("http_interface", "")).strip() + # Une interface vide veut dire « toutes » : on se parle à soi-même. + # 0.0.0.0 n'est pas une adresse de destination. + if interface.lower() in ("", "false", "none", "0.0.0.0", "::"): + hote = HOTE_PAR_DEFAUT + else: + hote = interface + return hote, port + + +def base_a_reveiller(argv=(), config_path=None): + """La base dont on veut le registre, ou None. + + `-d` d'abord : c'est celle que `run.sh` vient de choisir. Sinon + `db_name` de la configuration, qu'Odoo utiliserait de toute façon. + """ + for index, argument in enumerate(argv): + if argument in ("-d", "--database") and index + 1 < len(argv): + return argv[index + 1] + if argument.startswith("--database="): + return argument.split("=", 1)[1] + valeur = str( + lib_analyse.read_config(config_path).get("db_name", "") + ).strip() + return valeur if valeur.lower() not in ("", "false", "none") else None + + +def url(hote, port, database=None): + """L'adresse à demander. + + `/web/login` plutôt que `/` : elle ne redirige pas vers un tableau de + bord, ne crée pas de session, et suffit à faire charger le registre. + Le paramètre `db` dit LAQUELLE quand plusieurs bases répondent. + """ + base = f"http://{hote}:{port}/web/login" + if database: + from urllib.parse import quote + + return f"{base}?db={quote(database)}" + return base + + +def sonder( + cible, + delai_total=DELAI_TOTAL, + entre_essais=DELAI_ENTRE_ESSAIS, + requete=DELAI_REQUETE, + horloge=time.monotonic, + dormir=time.sleep, +): + """Demander jusqu'à obtenir une réponse. Rendre True si elle est venue. + + N'importe quel code HTTP est une réussite : 303, 404 ou 500 prouvent + tous que le serveur a répondu, donc que le registre est chargé. Seul + le refus de connexion fait recommencer. + """ + import urllib.error + import urllib.request + + fin = horloge() + delai_total + while horloge() < fin: + try: + with urllib.request.urlopen(cible, timeout=requete): + return True + except urllib.error.HTTPError: + # Le serveur a répondu, et c'est tout ce qu'on demandait. + return True + except Exception: # noqa: BLE001 - un serveur pas encore prêt + pass + dormir(entre_essais) + return False + + +def main(argv=None): + """Toujours rendre 0 : personne ne doit lire ce code de retour. + + La sonde tourne en arrière-plan d'un `run.sh` qui, lui, rapporte + l'état d'Odoo. Un code non nul finirait dans un journal et ferait + croire à une panne du serveur. + """ + import argparse + + parser = argparse.ArgumentParser(description="Warm up the HTTP registry.") + parser.add_argument("-c", "--config") + parser.add_argument("-d", "--database") + parser.add_argument("--log") + parser.add_argument("--timeout", type=float, default=DELAI_TOTAL) + parser.add_argument("--quiet", action="store_true") + parser.add_argument("odoo_args", nargs="*") + # `parse_known_args` et non `parse_args` : les arguments d'Odoo nous + # traversent, et l'un d'eux ressemblera un jour à une option qu'on ne + # connaît pas. `parse_args` sortirait alors par SystemExit — ce qui + # romprait la seule promesse qui compte ici : ne jamais gêner. + args, restes = parser.parse_known_args(argv) + args.odoo_args = list(args.odoo_args) + list(restes) + + try: + hote, port = adresse(args.odoo_args, args.config, args.log) + database = args.database or base_a_reveiller( + args.odoo_args, args.config + ) + cible = url(hote, port, database) + if not args.quiet: + print(f"🔥 warmup: {cible}", flush=True) + sonder(cible, delai_total=args.timeout) + except Exception: # noqa: BLE001 - le réveil est un confort, pas un dû + pass + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/test/run_unit_test.sh b/script/test/run_unit_test.sh index 6aadf94..57772ab 100755 --- a/script/test/run_unit_test.sh +++ b/script/test/run_unit_test.sh @@ -16,12 +16,19 @@ # # ./script/test/run_unit_test.sh [fichiers...] # -# NOMMER UN NOUVEAU FICHIER : sans argument, ce script ne prend que les -# préfixes listés plus bas (test_qemu_, test_todo_, test_proxmox_…) — le reste -# de test/ demande une base de données. Un fichier hors préfixe tombe donc dans -# le même silence qu'un fichier absent : douze tests écrits, jamais lancés, et -# un total qui n'a pas bougé. Choisir le préfixe de sa famille, ou l'ajouter -# ici. +# TOUT test/test_*.py, et non une liste de préfixes. La liste disait prendre +# test_qemu_, test_todo_, test_proxmox_… « le reste demandant une base de +# données ». Mesuré : les 3670 tests du répertoire passent avec PostgreSQL +# injoignable, et la liste laissait 2400 d'entre eux hors de la suite — +# écrits, verts, jamais lancés. Un glob n'oublie personne ; une liste, si. +# +# Un fichier n'est vu que s'il finit par le bloc habituel : +# +# if __name__ == "__main__": +# unittest.main() +# +# EN DERNIER, sinon tout ce qui suit est défini après l'appel et ne tourne +# jamais. C'est arrivé quatre fois ici, pour 87 tests. set -uo pipefail Red='\033[0;31m' @@ -47,11 +54,8 @@ fi FILES=("$@") if [[ ${#FILES[@]} -eq 0 ]]; then - # Aucun argument : tout ce que le dépôt sait tester sans base de données. - mapfile -t FILES < <(ls test/test_qemu_*.py test/test_mobile_*.py \ - test/test_todo_*.py test/test_install_*.py test/test_run_*.py \ - test/test_proxmox_*.py \ - 2>/dev/null) + # Aucun argument : tout le répertoire. Mesuré sans PostgreSQL. + mapfile -t FILES < <(ls test/test_*.py 2>/dev/null) fi fail=0 diff --git a/script/todo/auto_ask.py b/script/todo/auto_ask.py index 19c02ce..1750dd1 100644 --- a/script/todo/auto_ask.py +++ b/script/todo/auto_ask.py @@ -37,7 +37,7 @@ except Exception: # pragma: no cover - repli si i18n indisponible ENV_ENABLED = "ERPLIBRE_AUTO_EXECUTE" ENV_DELAY = "ERPLIBRE_AUTO_DELAY" -DEFAULT_DELAY = 5 +DEFAULT_DELAY = 15 def enabled(): diff --git a/script/todo/database_manager.py b/script/todo/database_manager.py index a6e51ba..cee81cf 100644 --- a/script/todo/database_manager.py +++ b/script/todo/database_manager.py @@ -199,6 +199,76 @@ class DatabaseManager: source_erplibre=False, ) + def duplicate_database(self) -> None: + """Copier une base, et proposer de la neutraliser. + + On passe par `db_duplicate.py`, donc par Odoo, et non par un + `CREATE DATABASE … TEMPLATE` : lui seul coupe les connexions + ouvertes sur la source, régénère le `database.uuid`, copie le + filestore et sait neutraliser pour de bon. + + La neutralisation est proposée par DÉFAUT. Mesuré sur trois + migrations de suite : la copie gardait 33 crons actifs, aucun + serveur de courriel — donc le repli sur `smtp_server` de la + configuration — et une clé de paiement vivante. Le défaut à + « oui » est le seul qui protège celui qui appuie sur Entrée. + """ + source = self.select_database() + if not source: + return + defaut = f"{source}_neutralize" + cible = input( + f"\U0001f4ac {t('Name of the copy (default=')}{defaut}) : " + ).strip() + cible = cible or defaut + + reponse = ( + input(f"\U0001f4ac {t('Neutralize the copy (Y/n)? ')}") + .strip() + .lower() + ) + neutraliser = reponse != "n" + if not neutraliser: + print( + f"⚠️ {t('The copy will keep its scheduled actions, its')}" + f" {t('outgoing mail and its payment providers.')}" + ) + + commande = ( + f"python3 ./script/database/db_duplicate.py" + f" -s {source} -d {cible}" + ) + if neutraliser: + commande += " --neutralize" + status, _ = self._execute.exec_command_live( + commande, + return_status_and_output=True, + single_source_erplibre=True, + source_erplibre=False, + ) + if status: + print(f"❌ {t('The duplication failed.')}") + return + # RELIRE plutôt que croire : c'est le contrôle qui manquait aux + # trois modules maison, dont aucun ne posait le drapeau. + if neutraliser: + self._report_neutralize(cible) + + @staticmethod + def _report_neutralize(database: str) -> None: + """Dire ce que la neutralisation a réellement pris.""" + try: + from script.analyse import monitoring + + print() + print( + monitoring.neutralize_report( + monitoring.neutralize_state(database), colour=True + ) + ) + except Exception as exc: # noqa: BLE001 - un rapport, pas le sujet + print(f"ℹ️ {t('Cannot read the copy back: ')}{exc}") + def create_backup_from_database( self, show_remote_list: bool = True ) -> None: diff --git a/script/todo/migration_status.py b/script/todo/migration_status.py index d8f2061..c736f2d 100755 --- a/script/todo/migration_status.py +++ b/script/todo/migration_status.py @@ -93,6 +93,24 @@ def paint(texte, couleur, actif=True): VERDICT_COLOUR = {0: "ok", 1: "warn", 2: "fail"} +# La convention des outils de migration, écrite dans todo_upgrade.run_tool : +# « 0 rien à signaler, 1 des trouvailles, 2 l'outil a échoué ». L'icône vit +# à côté de la couleur pour qu'elles ne puissent pas dire deux choses +# différentes — un écran affichait ❌ sur un 1, alors que database_cleanup +# imprime lui-même « This is a warning, not a failure ». +VERDICT_ICON = {0: "✅", 1: "⚠", 2: "❌"} + + +def verdict_mark(statut): + """(icône, couleur) pour ce code de sortie. Inconnu ⇒ traité comme un échec.""" + try: + code = int(statut or 0) + except (TypeError, ValueError): + code = 2 + if code not in VERDICT_ICON: + code = 2 + return VERDICT_ICON[code], VERDICT_COLOUR[code] + def read(path=DEFAULT_PATH): """La progression, complétée par ce qui a été écrit SUR DISQUE. diff --git a/script/todo/todo.py b/script/todo/todo.py index 0a5fb21..08fca71 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -2893,6 +2893,8 @@ class TODO( }, {"section": t("Restore")}, {"prompt_description": t("Restore from backup (.zip)")}, + {"section": t("Duplicate")}, + {"prompt_description": t("Duplicate a database")}, {"section": t("Danger zone")}, {"prompt_description": t("Erase a database")}, ] @@ -2910,6 +2912,8 @@ class TODO( elif status == "3": self.db_manager.restore_from_database() elif status == "4": + self.db_manager.duplicate_database() + elif status == "5": self.db_manager.drop_database() else: print(t("Command not found !")) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index d12f08e..9214823 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -3499,8 +3499,8 @@ TRANSLATIONS = { "en": "vendors document two, not more.", }, "Measured at level 4: 36x slower, then a frozen kernel.": { - "fr": "Mesuré au niveau 4 : 36x plus lent, puis noyau gelé.", - "en": "Measured at level 4: 36x slower, then a frozen kernel.", + "fr": "Mesuré au niveau 4 : 36x plus lent, et 12 vCPU gelaient.", + "en": "Measured at level 4: 36x slower, and 12 vCPU froze.", }, "vCPU capped to": { "fr": "vCPU borné à", @@ -6977,6 +6977,485 @@ TRANSLATIONS = { "fr": "Qualité de la migration, palier par palier", "en": "Migration quality, step by step", }, + # --- Écran de qualité : Verdicts, Validation, Revue --- + # --- Journal d'étape et bascule de version --- + # --- Dérive du référentiel comptable --- + "Did the last step add to the accounting data?": { + "fr": "Le dernier palier a-t-il gonflé le référentiel comptable ?", + "en": "Did the last step add to the accounting data?", + }, + "Accounting reference data, one step to the next": { + "fr": "Référentiel comptable, d'un palier au suivant", + "en": "Accounting reference data, one step to the next", + }, + "Nothing was added to the reference data.": { + "fr": "Rien n'a été ajouté au référentiel.", + "en": "Nothing was added to the reference data.", + }, + "Accounts in the chart": { + "fr": "Comptes du plan comptable", + "en": "Accounts in the chart", + }, + "Taxes": { + "fr": "Taxes", + "en": "Taxes", + }, + "Fiscal positions": { + "fr": "Positions fiscales", + "en": "Fiscal positions", + }, + "Account groups": { + "fr": "Groupes de comptes", + "en": "Account groups", + }, + "Journals": { + "fr": "Journaux", + "en": "Journals", + }, + "What it looks like": { + "fr": "À quoi cela ressemble", + "en": "What it looks like", + }, + "accounts carry no entry at all": { + "fr": "comptes ne portent aucune écriture", + "en": "accounts carry no entry at all", + }, + "taxes share a name with another": { + "fr": "taxes portent le nom d'une autre", + "en": "taxes share a name with another", + }, + "fiscal positions share a name with another": { + "fr": "positions fiscales portent le nom d'une autre", + "en": "fiscal positions share a name with another", + }, + "accounts fall under a group by code prefix": { + "fr": "comptes tombent sous un groupe par préfixe de code", + "en": "accounts fall under a group by code prefix", + }, + "Replay the step with the fix; do not operate the": { + "fr": "Rejouer le palier avec le correctif ; ne pas opérer la", + "en": "Replay the step with the fix; do not operate the", + }, + "delivered database.": { + "fr": "base livrée.", + "en": "delivered database.", + }, + "What a version bump adds to the accounting data.": { + "fr": "Ce qu'une montée de version ajoute au référentiel comptable.", + "en": "What a version bump adds to the accounting data.", + }, + "the step to compare against (default: the previous one)": { + "fr": "le palier de comparaison (défaut : le précédent)", + "en": "the step to compare against (default: the previous one)", + }, + "No previous step found: name it with --before.": { + "fr": "Aucun palier précédent trouvé : le nommer avec --before.", + "en": "No previous step found: name it with --before.", + }, + "A reloaded chart template adds every account it could not match by code. They carry no entry, and they bury the customer's own chart.": { + "fr": "Un gabarit de plan rechargé ajoute chaque compte qu'il n'a pu apparier par son code. Ils ne portent aucune écriture, et noient le plan du client.", + "en": "A reloaded chart template adds every account it could not match by code. They carry no entry, and they bury the customer's own chart.", + }, + "Reloaded taxes are not renamed '[old]': the duplicates are indistinguishable in the interface.": { + "fr": "Les taxes rechargées ne sont pas renommées « [old] » : les doublons ne se distinguent pas dans l'interface.", + "en": "Reloaded taxes are not renamed '[old]': the duplicates are indistinguishable in the interface.", + }, + "Each province then appears twice, under the same name.": { + "fr": "Chaque province apparaît alors deux fois, sous le même nom.", + "en": "Each province then appears twice, under the same name.", + }, + "Groups are matched to accounts by code PREFIX: a grid that appears reclassifies the customer's own accounts, and the trial balance in hierarchy mode follows.": { + "fr": "Les groupes s'apparient aux comptes par PRÉFIXE de code : une grille qui apparaît reclasse les comptes du client, et la balance en mode hiérarchie suit.", + "en": "Groups are matched to accounts by code PREFIX: a grid that appears reclassifies the customer's own accounts, and the trial balance in hierarchy mode follows.", + }, + "A journal added by a template reload competes with the customer's own.": { + "fr": "Un journal ajouté par un rechargement concurrence celui du client.", + "en": "A journal added by a template reload competes with the customer's own.", + }, + "findings": { + "fr": "des trouvailles", + "en": "findings", + }, + "the tool failed": { + "fr": "l'outil a échoué", + "en": "the tool failed", + }, + "capture unavailable": { + "fr": "capture indisponible", + "en": "capture unavailable", + }, + "last run": { + "fr": "dernière exécution", + "en": "last run", + }, + "never run from here yet": { + "fr": "jamais lancée depuis ici", + "en": "never run from here yet", + }, + "Pane up": { + "fr": "Panneau ↑", + "en": "Pane up", + }, + "Pane down": { + "fr": "Panneau ↓", + "en": "Pane down", + }, + "the tool output is not in the step log: it goes": { + "fr": "la sortie de l'outil n'est pas dans le journal d'étape : elle part", + "en": "the tool output is not in the step log: it goes", + }, + "to the terminal and dies with it.": { + "fr": "sur le terminal et meurt avec lui.", + "en": "to the terminal and dies with it.", + }, + "r offers to run": { + "fr": "r propose de lancer", + "en": "r offers to run", + }, + "first": { + "fr": "d'abord", + "en": "first", + }, + "step log": { + "fr": "journal de l'étape", + "en": "step log", + }, + "no log file for this step": { + "fr": "aucun journal pour cette étape", + "en": "no log file for this step", + }, + "lines in all": { + "fr": "lignes en tout", + "en": "lines in all", + }, + "this verdict is not in it": { + "fr": "ce verdict n'y figure pas", + "en": "this verdict is not in it", + }, + "the tool output is not in there: it goes to the": { + "fr": "la sortie de l'outil n'y est pas : elle part sur le", + "en": "the tool output is not in there: it goes to the", + }, + "terminal, which a pipe would make full-screen tools": { + "fr": "terminal, qu'un tube ferait renoncer aux outils en", + "en": "terminal, which a pipe would make full-screen tools", + }, + "give up. What precedes is what Odoo was writing.": { + "fr": "plein écran. Ce qui précède est ce qu'Odoo écrivait.", + "en": "give up. What precedes is what Odoo was writing.", + }, + "the checkout is on Odoo": { + "fr": "le checkout est sur Odoo", + "en": "the checkout is on Odoo", + }, + "this database is on": { + "fr": "cette base est en", + "en": "this database is on", + }, + "opening it with the wrong version writes before it fails.": { + "fr": "l'ouvrir avec la mauvaise version y écrit avant d'échouer.", + "en": "opening it with the wrong version writes before it fails.", + }, + "run": { + "fr": "lancer", + "en": "run", + }, + "first?": { + "fr": "d'abord ?", + "en": "first?", + }, + "left as is — the test will refuse and return 2.": { + "fr": "laissé tel quel — le test refusera et rendra 2.", + "en": "left as is — the test will refuse and return 2.", + }, + "run the test anyway?": { + "fr": "lancer le test quand même ?", + "en": "run the test anyway?", + }, + # --- Trous dans les manifestes Google Repo --- + # --- Type de vue « tree » resté dans un module --- + "Is any module still on a pre-18 view type?": { + "fr": "Un module est-il resté sur un type de vue d'avant 18 ?", + "en": "Is any module still on a pre-18 view type?", + }, + "View type tree, removed in Odoo 18": { + "fr": "Type de vue tree, supprimé dans Odoo 18", + "en": "View type tree, removed in Odoo 18", + }, + "nothing to do below 18.0 — tree is valid there": { + "fr": "rien à faire sous la 18.0 — tree y est valide", + "en": "nothing to do below 18.0 — tree is valid there", + }, + "No module carries it.": { + "fr": "Aucun module ne le porte.", + "en": "No module carries it.", + }, + "modules read": { + "fr": "modules lus", + "en": "modules read", + }, + "listed in the manifest, absent from disk": { + "fr": "listés au manifeste, absents du disque", + "en": "listed in the manifest, absent from disk", + }, + "listed in the manifest, unparsable": { + "fr": "listés au manifeste, illisibles", + "en": "listed in the manifest, unparsable", + }, + "Modules still carrying the removed tree view type.": { + "fr": "Modules portant encore le type de vue tree, supprimé.", + "en": "Modules still carrying the removed tree view type.", + }, + "Will every step find its addons?": { + "fr": "Chaque palier trouvera-t-il ses addons ?", + "en": "Will every step find its addons?", + }, + "Repositories missing from a step": { + "fr": "Dépôts absents d'un palier", + "en": "Repositories missing from a step", + }, + "Every repository is declared without a hole.": { + "fr": "Chaque dépôt est déclaré sans trou.", + "en": "Every repository is declared without a hole.", + }, + "missing from": { + "fr": "absent de", + "en": "missing from", + }, + "upstream has the branch": { + "fr": "la branche existe en amont", + "en": "upstream has the branch", + }, + "Add the entry to": { + "fr": "Ajouter l'entrée dans", + "en": "Add the entry to", + }, + "holes not settled": { + "fr": "trous non départagés", + "en": "holes not settled", + }, + "not asked: run with --upstream": { + "fr": "pas demandé : relancer avec --upstream", + "en": "not asked: run with --upstream", + }, + "upstream unreachable": { + "fr": "amont injoignable", + "en": "upstream unreachable", + }, + "holes are legitimate: no such branch upstream": { + "fr": "trous légitimes : pas de telle branche en amont", + "en": "holes are legitimate: no such branch upstream", + }, + "Repositories declared for some steps but not others.": { + "fr": "Dépôts déclarés pour certains paliers et pas d'autres.", + "en": "Repositories declared for some steps but not others.", + }, + "ask each remote whether the branch exists (network)": { + "fr": "demander à chaque amont si la branche existe (réseau)", + "en": "ask each remote whether the branch exists (network)", + }, + "Verdicts the migration recorded": { + "fr": "Verdicts enregistrés par la migration", + "en": "Verdicts the migration recorded", + }, + "recorded in": { + "fr": "consignés dans", + "en": "recorded in", + }, + "These come from the file, not the database:": { + "fr": "Ils viennent du fichier, non de la base :", + "en": "These come from the file, not the database:", + }, + "the exit code ignores them.": { + "fr": "le code de sortie ne les compte pas.", + "en": "the exit code ignores them.", + }, + # Les six questions de la revue : t() les reçoit par + # variable, un scan des littéraux ne les voyait pas. + "Did the migration reach the end?": { + "fr": "La migration est-elle allée jusqu'au bout ?", + "en": "Did the migration reach the end?", + }, + "Does Odoo load the database?": { + "fr": "Odoo charge-t-il la base ?", + "en": "Does Odoo load the database?", + }, + "What did the migration leave behind?": { + "fr": "Qu'est-ce que la migration a laissé derrière ?", + "en": "What did the migration leave behind?", + }, + "Is this copy safe to open?": { + "fr": "Cette copie est-elle sans danger à ouvrir ?", + "en": "Is this copy safe to open?", + }, + "Do the public pages still answer?": { + "fr": "Les pages publiques répondent-elles encore ?", + "en": "Do the public pages still answer?", + }, + "Is anything left that cleanup could not drop?": { + "fr": "Reste-t-il ce que le nettoyage n'a pu supprimer ?", + "en": "Is anything left that cleanup could not drop?", + }, + "Verdicts": { + "fr": "Verdicts", + "en": "Verdicts", + }, + "Validation": { + "fr": "Validation", + "en": "Validation", + }, + "Review": { + "fr": "Revue de la migration", + "en": "Review", + }, + "no verdict recorded": { + "fr": "aucun verdict enregistré", + "en": "no verdict recorded", + }, + "checks, all passed": { + "fr": "tests, tous réussis", + "en": "checks, all passed", + }, + "step": { + "fr": "palier", + "en": "step", + }, + "when": { + "fr": "quand", + "en": "when", + }, + "status": { + "fr": "statut", + "en": "status", + }, + "what it ran": { + "fr": "ce qu'il a lancé", + "en": "what it ran", + }, + "1 means a public page failed — not merely a finding.": { + "fr": "1 signifie qu'une page publique a échoué — pas un simple constat.", + "en": "1 means a public page failed — not merely a finding.", + }, + "1 means leftovers remain that it could not drop.": { + "fr": "1 signifie qu'il reste des objets qu'il n'a pu supprimer.", + "en": "1 means leftovers remain that it could not drop.", + }, + "press r to run it again": { + "fr": "appuie sur r pour le relancer", + "en": "press r to run it again", + }, + "The screen steps aside, the test takes the terminal,": { + "fr": "L'écran s'efface, le test prend le terminal,", + "en": "The screen steps aside, the test takes the terminal,", + }, + "and you come back with Enter.": { + "fr": "et tu reviens avec Entrée.", + "en": "and you come back with Enter.", + }, + "No known way to replay this one.": { + "fr": "Aucun moyen connu de rejouer celui-ci.", + "en": "No known way to replay this one.", + }, + "The migration recorded no verdict.": { + "fr": "La migration n'a enregistré aucun verdict.", + "en": "The migration recorded no verdict.", + }, + "Every recorded check returned zero.": { + "fr": "Chaque test enregistré a rendu zéro.", + "en": "Every recorded check returned zero.", + }, + "Where the traces live": { + "fr": "Où vivent les traces", + "en": "Where the traces live", + }, + "missing: nothing was written here": { + "fr": "absent : rien n'a été écrit ici", + "en": "missing: nothing was written here", + }, + "To read the verdicts by hand:": { + "fr": "Pour lire les verdicts à la main :", + "en": "To read the verdicts by hand:", + }, + "Verdicts live in lst_event of the progression file,": { + "fr": "Les verdicts vivent dans lst_event du fichier de", + "en": "Verdicts live in lst_event of the progression file,", + }, + "not in command_executed, which only lists what ran.": { + "fr": "progression — command_executed ne liste que l'exécuté.", + "en": "not in command_executed, which only lists what ran.", + }, + "Errors in the Odoo log": { + "fr": "Erreurs dans le journal d'Odoo", + "en": "Errors in the Odoo log", + }, + "This file does not exist.": { + "fr": "Ce fichier n'existe pas.", + "en": "This file does not exist.", + }, + "config.conf has an empty logfile=, so Odoo writes to": { + "fr": "config.conf a un logfile= vide : Odoo écrit dans le", + "en": "config.conf has an empty logfile=, so Odoo writes to", + }, + "the terminal and its output dies with it. To keep it:": { + "fr": "terminal et sa sortie meurt avec lui. Pour la garder :", + "en": "the terminal and its output dies with it. To keep it:", + }, + "last offending lines": { + "fr": "dernières lignes fautives", + "en": "last offending lines", + }, + "press r to run it": { + "fr": "appuie sur r pour le lancer", + "en": "press r to run it", + }, + "Read it in the Verdicts section above.": { + "fr": "À lire dans la section Verdicts ci-dessus.", + "en": "Read it in the Verdicts section above.", + }, + "What none of these can see": { + "fr": "Ce qu'aucun d'eux ne voit", + "en": "What none of these can see", + }, + "They all read the DATABASE. A module that kept a": { + "fr": "Tous lisent la BASE. Un module resté sur un type de", + "en": "They all read the DATABASE. A module that kept a", + }, + "pre-18 view type, or an image URL Odoo no longer": { + "fr": "vue d'avant 18, ou une URL d'image qu'Odoo", + "en": "pre-18 view type, or an image URL Odoo no longer", + }, + "accepts, breaks at runtime on a perfectly sound one.": { + "fr": "refuse désormais, casse à l'exécution sur une base saine.", + "en": "accepts, breaks at runtime on a perfectly sound one.", + }, + "exit code:": { + "fr": "code de sortie :", + "en": "exit code:", + }, + "Press Enter to go back to the screen…": { + "fr": "Appuie sur Entrée pour revenir à l'écran…", + "en": "Press Enter to go back to the screen…", + }, + "Pick a line below.": { + "fr": "Choisis une ligne ci-dessous.", + "en": "Pick a line below.", + }, + "Migration progression": { + "fr": "Progression de la migration", + "en": "Migration progression", + }, + "Odoo log": { + "fr": "Journal d'Odoo", + "en": "Odoo log", + }, + "steps, commands and their verdicts (lst_event)": { + "fr": "paliers, commandes et leurs verdicts (lst_event)", + "en": "steps, commands and their verdicts (lst_event)", + }, + "set logfile= in config.conf, else output is lost": { + "fr": "poser logfile= dans config.conf, sinon la sortie est perdue", + "en": "set logfile= in config.conf, else output is lost", + }, "database not found": { "fr": "base introuvable", "en": "database not found", @@ -10008,6 +10487,114 @@ TRANSLATIONS = { "fr": "Mode inconnu :", "en": "Unknown mode:", }, + "Duplicate": { + "fr": "Copie", + "en": "Copy", + }, + "Duplicate a database": { + "fr": "Dupliquer une base de données", + "en": "Duplicate a database", + }, + "Name of the copy (default=": { + "fr": "Nom de la copie (défaut=", + "en": "Name of the copy (default=", + }, + "Neutralize the copy (Y/n)? ": { + "fr": "Neutraliser la copie (O/n) ? ", + "en": "Neutralize the copy (Y/n)? ", + }, + "The copy will keep its scheduled actions, its": { + "fr": "La copie gardera ses actions planifiées, son", + "en": "The copy will keep its scheduled actions, its", + }, + "outgoing mail and its payment providers.": { + "fr": "courriel sortant et ses fournisseurs de paiement.", + "en": "outgoing mail and its payment providers.", + }, + "The duplication failed.": { + "fr": "La duplication a échoué.", + "en": "The duplication failed.", + }, + "Cannot read the copy back: ": { + "fr": "Impossible de relire la copie : ", + "en": "Cannot read the copy back: ", + }, + "Duplicate a database, filestore included.": { + "fr": "Dupliquer une base, filestore compris.", + "en": "Duplicate a database, filestore included.", + }, + "the copy": { + "fr": "la copie", + "en": "the copy", + }, + "disable crons, outgoing mail and payment providers": { + "fr": "désactiver les crons, le courriel sortant et les fournisseurs de paiement", + "en": "disable crons, outgoing mail and payment providers", + }, + "The source and the copy cannot share a name.": { + "fr": "La source et la copie ne peuvent pas porter le même nom.", + "en": "The source and the copy cannot share a name.", + }, + "No such database: ": { + "fr": "Base introuvable : ", + "en": "No such database: ", + }, + "This database already exists: ": { + "fr": "Cette base existe déjà : ", + "en": "This database already exists: ", + }, + "cannot neutralise: that arrived in Odoo 16.": { + "fr": "ne sait pas neutraliser : c'est arrivé avec Odoo 16.", + "en": "cannot neutralise: that arrived in Odoo 16.", + }, + "Cannot find the virtualenv for this checkout.": { + "fr": "Venv introuvable pour ce checkout.", + "en": "Cannot find the virtualenv for this checkout.", + }, + "Cannot find the Odoo source for this checkout.": { + "fr": "Sources Odoo introuvables pour ce checkout.", + "en": "Cannot find the Odoo source for this checkout.", + }, + "neutralised": { + "fr": "neutralisée", + "en": "neutralised", + }, + "Done:": { + "fr": "Fait :", + "en": "Done:", + }, + "NOT neutralised: its crons run, its mail leaves and": { + "fr": "PAS neutralisée : ses crons tournent, son courriel part et", + "en": "NOT neutralised: its crons run, its mail leaves and", + }, + "its payment providers can charge.": { + "fr": "ses fournisseurs de paiement peuvent débiter.", + "en": "its payment providers can charge.", + }, + "Missing script: ": { + "fr": "Script introuvable : ", + "en": "Missing script: ", + }, + "Odoo's own neutralisation": { + "fr": "la neutralisation d'Odoo lui-même", + "en": "Odoo's own neutralisation", + }, + "This route does not set database.is_neutralized, does": { + "fr": "Ce chemin ne pose pas database.is_neutralized, ne", + "en": "This route does not set database.is_neutralized, does", + }, + "not disable crons, and leaves payment keys in place.": { + "fr": "désactive pas les crons, et laisse les clés de paiement en place.", + "en": "not disable crons, and leaves payment keys in place.", + }, + "column(s) hold identifier paths and are left": { + "fr": "colonne(s) portent des chemins d'identifiants et sont laissées", + "en": "column(s) hold identifier paths and are left", + }, + "alone:": { + "fr": "intactes :", + "en": "alone:", + }, } diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index 908099a..235bb54 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -164,6 +164,39 @@ class MigrationRewind(Exception): """ +# Le pilote suffixe le nom choisi : « _neutralize » à l'étape 1, puis +# « _upgrade_ » à chaque palier. PostgreSQL tronque un +# identifiant à 63 octets — deux paliers finiraient alors sur le MÊME +# nom, et le second écraserait le premier sans rien dire. +SUFFIXE_MAX = len("_neutralize_upgrade_18") +NOM_BASE_MAX = 63 - SUFFIXE_MAX + + +def database_name_from_file(chemin, defaut="test", limite=NOM_BASE_MAX): + """Le nom de base que suggère le fichier de sauvegarde. + + « test » ne disait rien de ce qu'on migrait : trois migrations de + suite portaient le même nom, et retrouver laquelle avait échoué + demandait de relire le journal. Le fichier, lui, porte déjà le nom du + client — c'est la seule information qu'on ait à ce moment-là. + + Le nom est ASSAINI, car il finit dans un `createdb` et dans des noms + de fichiers : minuscules, et tout ce qui n'est ni lettre ni chiffre + devient un « _ ». Un nom qui commence par un chiffre reçoit un + préfixe — PostgreSQL refuse un identifiant nu qui en commence un. + """ + nom = os.path.basename(chemin or "") + if nom.lower().endswith(".zip"): + nom = nom[: -len(".zip")] + nom = re.sub(r"[^a-z0-9]+", "_", nom.lower()) + nom = re.sub(r"_{2,}", "_", nom).strip("_") + if not nom: + return defaut + if nom[0].isdigit(): + nom = "db_" + nom + return nom[:limite].rstrip("_") or defaut + + class TodoUpgrade: def __init__(self, todo): self.file_path = None @@ -1370,6 +1403,13 @@ class TodoUpgrade: self.dct_progression["migration_file"] = self.file_path self.write_config() + # Le téléchargement distant a déjà nommé la base — le serveur sait + # mieux que le nom du fichier. Sinon, c'est le fichier qui parle. + if default_database_name == "test" and self.file_path: + default_database_name = database_name_from_file( + self.file_path, default_database_name + ) + print(f"✅ {t('Open file')} {self.file_path}") with zipfile.ZipFile(self.file_path, "r") as zip_ref: manifest_file_1 = zip_ref.open("manifest.json") @@ -1813,7 +1853,7 @@ class TodoUpgrade: # la décision se prend sur le code de retour — et la capture faisait # annoncer « Command returned error code: 1 » sur un rapport qui va # bien. 1 veut dire « des copies casseront », pas « l'outil a raté ». - status = self.run_on_terminal( + status = self.run_captured( f"{PYTHON_BIN} ./script/odoo/migration/check_cow_views.py" f" -d {database_name} -t odoo{start_version + 1}.0" ) @@ -1837,7 +1877,9 @@ class TodoUpgrade: # Appliquer depuis là lève « KeyError: 'web_editor.assets' » — c'est # arrivé sur une vraie migration, et l'échec est passé pour un # succès. La correction est proposée après le palier, plus bas. - self.run_on_terminal( + # « --report-only » éteint l'invite de l'outil : rien ici + # n'attend de réponse, tout est bon à garder. + self.run_captured( f"{PYTHON_BIN} ./script/odoo/migration/check_stale_scss.py" f" -d {database_name} -t odoo{start_version + 1}.0 --report-only" ) @@ -2761,7 +2803,7 @@ class TodoUpgrade: # L'outil ne fait rien s'il n'y a pas de DMS, et rien non # plus s'il a déjà réparé : le rejouer est sans effet. if next_version == 13: - self.run_on_terminal( + self.run_captured( f"{PYTHON_BIN}" " ./script/odoo/migration/dms_access_repair.py" f" -d {database_name_upgrade} --apply" @@ -2798,10 +2840,11 @@ class TodoUpgrade: # c'est vrai aussi. Pourtant plus personne n'atteint les # données. Mesuré sur DMS au palier 13 : 69 fichiers et # 23 Mo intacts, zéro visible, pour tous les utilisateurs. - self.run_on_terminal( + self.run_tool( + "check_hidden_models", f"{PYTHON_BIN}" " ./script/odoo/migration/check_hidden_models.py" - f" -d {database_name_upgrade}" + f" -d {database_name_upgrade}", ) print(f"[y] {t('Open the server with Selenium')}") @@ -2961,7 +3004,7 @@ class TodoUpgrade: # Le thème parti, la commande mérite un nouvel essai — # et `repare` autorise le rejeu automatique borné. for theme in themes: - self.run_on_terminal( + self.run_captured( f"./script/addons/uninstall_addons_theme.sh" f" {database_name} {theme}" ) @@ -3348,13 +3391,14 @@ class TodoUpgrade: print(f"ℹ -> {t('Kept. Nothing was uninstalled.')}") return for theme in lst_theme: - # Sur le VRAI terminal : ce script finit par theme_leftover.py, - # qui pose une question. L'exécuteur capture la sortie par un - # tube — Python la met alors en tampon par blocs — et l'invite - # restait invisible pendant que le processus attendait une - # réponse. On tape Entrée à l'aveugle, plusieurs fois, et les - # frappes en trop vont à la question suivante. - self.run_on_terminal( + # Ce script finit par theme_leftover.py, qui pose une + # question. Un TUBE la rendrait invisible — Python met alors + # sa sortie en tampon par blocs — et l'on taperait Entrée à + # l'aveugle, les frappes en trop allant à la question + # suivante. Le pseudo-terminal de `run_captured` n'a pas ce + # défaut : l'invite s'affiche comme sur un terminal, et l'on + # garde tout de même le journal de la désinstallation. + self.run_captured( f"./script/addons/uninstall_addons_theme.sh" f" {database_name} {theme}" ) @@ -3536,13 +3580,106 @@ class TodoUpgrade: cmd += " --tui" self.run_on_terminal(cmd) - def run_on_terminal(self, cmd): - """Lance une commande en lui laissant le VRAI terminal. + # Les séquences ANSI d'un outil qui se croit — à juste titre — sur un + # terminal. Elles n'ont aucun sens dans un fichier qu'on relira, et + # une seule d'entre elles suffit à rendre l'extrait illisible. + RE_ANSI = re.compile(r"\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07]*\x07") - `todo_upgrade_execute` capture la sortie par un tube. Un plein écran - y voit un stdout qui n'est pas un terminal, renonce, et retombe sur - son rapport texte : « w » réaffichait mot pour mot ce que « v » - venait de montrer, sans rien signaler. + def run_captured(self, cmd): + """Lancer en gardant la sortie, SANS que la commande s'en aperçoive. + + Un tube aurait suffi à capturer — et aurait changé le programme. + `smoke_public_url` demande `can_ask()`, qui exige stdin ET stdout + sur un terminal (smoke_public_url.py:63-72) : derrière un tube il + cesse d'offrir la réparation des vues COW, en silence. C'est + exactement la classe de régression que `run_on_terminal` évitait + en ne capturant rien du tout. + + Un pseudo-terminal lève le dilemme : l'enfant voit un vrai + terminal — mesuré, `isatty()` rend True des deux côtés — la + réponse tapée lui parvient, son code de sortie est intact, et la + sortie passe tout de même entre nos mains avant d'aller à l'écran. + + Ce qui est écrit dans le journal est nettoyé : les séquences ANSI + retirées, les secrets caviardés par le MÊME filtre que + `exec_command_live`, et les fins de ligne du terminal ramenées à + « \n ». Le terminal, lui, reçoit les octets tels quels. + """ + import pty + + self.lst_command_executed.append(cmd) + self.dct_progression["command_executed"] = self.lst_command_executed + self.write_config() + print(f"\n🏠 ⬇ {t('Execute command')} :\n") + print(cmd) + self.note_step_log(f"$ {cmd}") + + handle = getattr(self, "step_log", None) + if not handle: + # Sans journal ouvert il n'y a rien à gagner et un pty à + # payer : on retombe sur le chemin ordinaire. + status = subprocess.call(cmd, shell=True, executable="/bin/bash") + self.note_step_log(f" -> {status}") + return status + + reste = bytearray() + + def ecrire(morceau, dernier=False): + """Journaliser par LIGNES ENTIÈRES. + + Une séquence ANSI coupée entre deux lectures laisserait ses + débris dans le fichier ; on n'écrit donc qu'une fois la ligne + complète, et le reliquat part à la fermeture. + """ + reste.extend(morceau) + *lignes, tail = bytes(reste).split(b"\n") + del reste[:] + if dernier and tail: + lignes.append(tail) + else: + reste.extend(tail) + for ligne in lignes: + texte = ligne.decode("utf-8", "replace").replace("\r", "") + try: + handle.write( + execute.redact_secrets(self.RE_ANSI.sub("", texte)) + + "\n" + ) + except Exception: + return + + def lire(fd): + morceau = os.read(fd, 4096) + if morceau: + ecrire(morceau) + return morceau + + try: + brut = pty.spawn(["/bin/bash", "-c", cmd], lire) + status = os.waitstatus_to_exitcode(brut) + except Exception as exc: # noqa: BLE001 - un pty peut manquer + # Jamais au prix de l'exécution : mieux vaut la commande sans + # son journal que pas de commande du tout. + print(f"⚠ {t('capture unavailable')} : {exc}") + status = subprocess.call(cmd, shell=True, executable="/bin/bash") + else: + ecrire(b"", dernier=True) + self.note_step_log(f" -> {status}") + return status + + def run_on_terminal(self, cmd): + """Le terminal, tel quel, sans rien garder — pour les PLEINS ÉCRANS. + + C'est devenu l'exception : `run_captured` garde la sortie sans que + la commande s'en aperçoive, et presque tout passe par lui. Deux + appels restent ici, et pour une raison mesurée. + + `pty.spawn` crée son terminal en 0×0, et l'enfant lit sa taille + AVANT que le premier octet ne nous parvienne — donc avant qu'on + puisse la corriger. Vérifié : un programme lancé ainsi voit + « columns=0, lines=0 ». Sans conséquence pour un outil qui écrit + des lignes — aucun de ceux qu'on capture ne lit la largeur — mais + une application Textual s'y disposerait sur rien. Rien ne relit cette sortie — elle est REGARDÉE. Et le code de retour d'un afficheur ne veut pas dire « erreur » : 1 signifie « il y a des @@ -4524,7 +4661,7 @@ class TodoUpgrade: exactement ce qu'on veut relire plus tard. Sans cela, « le test de fumée est-il passé ? » n'a pas de réponse une heure après. """ - status = self.run_on_terminal(cmd) + status = self.run_captured(cmd) self.record_event("test", name, status, cmd) return status diff --git a/test/test_analyse_lib.py b/test/test_analyse_lib.py index c41894f..6517ec3 100644 --- a/test/test_analyse_lib.py +++ b/test/test_analyse_lib.py @@ -132,6 +132,14 @@ class TestPgEnv(unittest.TestCase): def test_literal_false_means_unset(self): # Odoo écrit « False » dans config.conf pour dire « pas de valeur ». # L'exporter tel quel ferait chercher un hôte nommé « False ». + # + # L'environnement est VIDÉ de ses PG* : `pg_env` part de + # `os.environ`, et un PGHOST exporté par le shell de qui lance les + # tests les ferait échouer sans que rien du code n'ait changé. + for cle in ("PGHOST", "PGPORT", "PGPASSWORD"): + if cle in os.environ: + ancien = os.environ.pop(cle) + self.addCleanup(os.environ.__setitem__, cle, ancien) path = self._write( "[options]\ndb_host = False\ndb_port = False\n" "db_password = False\ndb_user = erplibre\n" @@ -306,10 +314,6 @@ class TestRunPsqlGuards(unittest.TestCase): self.assertIn("Invalid database name", str(caught.exception)) -if __name__ == "__main__": - unittest.main() - - class TestCanonical(unittest.TestCase): """Ce qui décide s'il y a un écart. Le bruit se joue ici.""" @@ -520,3 +524,7 @@ class TestBackupWithoutManifest(unittest.TestCase): def test_manifest_is_the_fallback_when_the_dump_says_nothing(self): self.assertEqual(L.backup_version({}, {"version": "16.0"}), "16.0") self.assertIsNone(L.backup_version({}, {})) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_analyse_view_custom.py b/test/test_analyse_view_custom.py index a85a275..274f978 100644 --- a/test/test_analyse_view_custom.py +++ b/test/test_analyse_view_custom.py @@ -266,10 +266,6 @@ class TestRender(unittest.TestCase): self.assertNotEqual(english, french) -if __name__ == "__main__": - unittest.main() - - class TestCowTwinDiff(unittest.TestCase): """Comparer une copie de site web à la vue de module qu'elle masque. @@ -358,3 +354,7 @@ class TestCowTwinDiff(unittest.TestCase): ) self.assertEqual(n, 0) self.assertNotIn("differs", row) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_anonymize.py b/test/test_anonymize.py index c7eb172..28a3210 100644 --- a/test/test_anonymize.py +++ b/test/test_anonymize.py @@ -178,9 +178,9 @@ class TestTheSqlItWrites(unittest.TestCase): def test_a_unique_column_gets_the_id_appended(self): """Deux lignes au même mot feraient échouer TOUT l'UPDATE.""" sql = anon.expression_texte(champ("ref", unique=True), ["a"]) - self.assertIn("id::text", sql) + self.assertIn('"id"::text', sql) self.assertNotIn( - "id::text", anon.expression_texte(champ("ref"), ["a"]) + '"id"::text', anon.expression_texte(champ("ref"), ["a"]) ) def test_a_translated_column_is_rebuilt_key_by_key(self): @@ -364,5 +364,534 @@ class TestTheRefusalToWrite(unittest.TestCase): self.assertEqual(appels, []) +class TestTheSqlNeverTravelsThroughArgv(unittest.TestCase): + """La panne signalée : « OSError: [Errno 7] Argument list too long ». + + Linux plafonne UN SEUL argument à MAX_ARG_STRLEN — 32 pages, soit + 131 072 octets. Mesuré sur une base réelle : le mode hybride produit + 58 Ko de SQL et passait, la liste noire en produit 342 Ko sur 410 + modèles et cassait. Le mode qui couvre le plus était celui qui + échouait, donc celui qu'aucun de mes essais n'exerçait. + + Le rendu de `render` reste borné, lui ; c'est bien l'exécution qu'il + faut regarder, et pas seulement le plan. + """ + + def _executer(self, etapes): + """Lancer `ecrire` avec un faux psql, et rendre ce qu'il a reçu.""" + import script.analyse.anonymize as module + + vu = {} + vrai_run = module.__dict__.get("subprocess") + + class FauxFait: + returncode = 0 + stdout = "" + stderr = "" + + import subprocess as vrai_subprocess + + def espion(cmd, **kwargs): + vu["cmd"] = list(cmd) + chemin = cmd[cmd.index("-f") + 1] if "-f" in cmd else None + if chemin: + with open(chemin, encoding="utf-8") as handle: + vu["fichier"] = handle.read() + vu["chemin"] = chemin + return FauxFait() + + vrai_env = anon.lib_analyse.pg_env + anon.lib_analyse.pg_env = lambda *a, **k: {"PATH": "/usr/bin"} + vrai_subprocess_run = vrai_subprocess.run + vrai_subprocess.run = espion + try: + erreur = anon.ecrire("une_base", etapes) + finally: + vrai_subprocess.run = vrai_subprocess_run + anon.lib_analyse.pg_env = vrai_env + del vrai_run + vu["erreur"] = erreur + return vu + + def _gros_plan(self, combien=400): + """Un plan de la taille de celui qui cassait.""" + etapes = [] + for index in range(combien): + champ = { + "model": "m.%d" % index, + "name": "name", + "ttype": "char", + "pg_type": "character varying", + "unique": False, + "checked": False, + } + etapes.append( + { + "model": champ["model"], + "fields": [champ], + "sql": anon.sql_pour_table("m_%d" % index, [champ], None), + } + ) + return etapes + + def test_no_single_argument_comes_close_to_the_kernel_limit(self): + vu = self._executer(self._gros_plan()) + plus_gros = max(len(a) for a in vu["cmd"]) + self.assertLess( + plus_gros, + 4096, + "un argument porte le SQL : c'est ce qui rendait E2BIG", + ) + + def test_the_sql_goes_through_a_file_not_through_c(self): + vu = self._executer(self._gros_plan(3)) + self.assertIn("-f", vu["cmd"]) + self.assertNotIn("-c", vu["cmd"]) + self.assertIn('UPDATE "m_0"', vu["fichier"]) + + def test_the_single_transaction_survives_the_change(self): + """`--single-transaction` n'est documenté qu'avec -c ou -f : passer + par l'entrée standard l'aurait perdu en silence.""" + vu = self._executer(self._gros_plan(2)) + self.assertIn("-1", vu["cmd"]) + self.assertIn("ON_ERROR_STOP=1", vu["cmd"]) + + def test_the_temporary_file_does_not_survive(self): + import os as vrai_os + + vu = self._executer(self._gros_plan(2)) + self.assertFalse(vrai_os.path.exists(vu["chemin"])) + + +class TestABoundedColumnIsNeverOverflowed(unittest.TestCase): + """`value too long for type character varying(3)`. + + Mesuré sur une base réelle : 13 colonnes texte portent une longueur + déclarée, dont des codes à 1, 2 et 3 caractères — `res.country.code`, + `account.journal.code`. Y écrire « jonquille » fait échouer l'UPDATE, + et comme l'écriture est transactionnelle, TOUTE l'anonymisation. + + Le mode hybride ne touchait aucune de ces colonnes ; la liste noire, + si. Le mode qui couvre le plus est celui qui cassait. + """ + + def _champ(self, **kw): + base = { + "model": "m", + "name": "code", + "ttype": "char", + "pg_type": "character varying", + "unique": False, + "checked": False, + "max_len": None, + } + base.update(kw) + return base + + def test_a_bounded_column_is_truncated(self): + sql = anon.expression_texte(self._champ(max_len=3), ["jonquille"]) + self.assertIn("left(", sql) + self.assertIn(", 3)", sql) + + def test_an_unbounded_column_is_left_alone(self): + sql = anon.expression_texte(self._champ(), ["jonquille"]) + self.assertNotIn("left(", sql) + + def test_a_bounded_unique_column_keeps_the_id_in_front(self): + """Tronquer par la droite doit laisser l'identifiant intact : + c'est lui qui porte l'unicité.""" + sql = anon.expression_texte( + self._champ(max_len=8, unique=True), ["jonquille"] + ) + self.assertIn("left(\"id\"::text || '-'", sql) + + def test_an_unbounded_unique_column_keeps_the_old_shape(self): + sql = anon.expression_texte(self._champ(unique=True), ["jonquille"]) + self.assertTrue(sql.rstrip().endswith("|| '-' || \"id\"::text END")) + + def test_the_length_is_read_from_the_database_not_guessed(self): + """`atttypmod` est la seule source : une longueur devinée serait + fausse dès qu'un module en change une.""" + self.assertIn("atttypmod", anon.REQUETE_CHAMPS) + + +class TestReservedWordsCannotBreakTheStatement(unittest.TestCase): + """`syntax error at or near "user"`. + + Odoo laisse nommer un champ `user`, `order` ou `group`. Un identifiant + nu fait alors échouer l'analyse syntaxique — et l'écriture étant + transactionnelle, c'est toute l'anonymisation qui tombe. Trouvé en + liste noire sur une base réelle, jamais en mode hybride : les quinze + modèles par défaut n'en portent aucun. + """ + + def _champ(self, nom): + return { + "model": "m", + "name": nom, + "ttype": "char", + "pg_type": "character varying", + "unique": False, + "checked": False, + "max_len": None, + } + + def test_a_column_named_like_a_keyword_is_quoted(self): + for nom in ("user", "order", "group", "check", "references", "limit"): + sql = anon.sql_pour_table("t", [self._champ(nom)], ["a"]) + self.assertIn(f'"{nom}" =', sql, nom) + self.assertNotIn(f" {nom} =", sql, nom) + + def test_a_table_named_like_a_keyword_is_quoted(self): + sql = anon.sql_pour_table("order", [self._champ("name")], ["a"]) + self.assertTrue(sql.startswith('UPDATE "order" SET')) + + def test_the_row_identifier_is_quoted_too(self): + """Une seule règle vaut mieux que deux : tout identifiant est cité.""" + sql = anon.expression_texte(self._champ("name"), ["a", "b"]) + self.assertIn('"id"', sql) + + def test_a_quote_inside_an_identifier_cannot_escape(self): + self.assertEqual(anon.ident('a"b'), '"a""b"') + + +class TestACheckDoesNotSilenceTheMainField(unittest.TestCase): + """La règle « écarter toute colonne sous CHECK » était trop large. + + Mesuré : `res_partner.name` porte + CHECK ((type='contact' AND name IS NOT NULL) OR type<>'contact') + — une garantie de non-nullité, qu'un mot satisfait. L'écarter rendait + une anonymisation qui n'anonymisait pas les noms, en annonçant 255 + colonnes écrites. Le pire des deux mondes : silencieux et faux. + + Sur un NOMBRE la distinction s'inverse : `credit * debit = 0` et + `amount >= 0` bornent la valeur, et un tirage à 1000 les viole. + """ + + def test_the_query_tells_numbers_from_text(self): + """La règle vit dans le SQL : c'est là qu'elle se vérifie.""" + requete = anon.REQUETE_CHAMPS + self.assertIn("f.ttype IN ('integer','float','monetary')", requete) + self.assertIn("pg_get_constraintdef", requete) + + def test_only_shape_constraints_disqualify_text(self): + for motif in ("char_length", "~~", "jsonb_typeof"): + self.assertIn(motif, anon.REQUETE_CHAMPS, motif) + + def test_a_field_the_query_cleared_is_anonymised(self): + """`checked=False` doit suffire : aucune seconde barrière cachée.""" + champ = { + "model": "res.partner", + "name": "name", + "ttype": "char", + "pg_type": "character varying", + "unique": False, + "checked": False, + "max_len": None, + } + self.assertTrue(anon.champ_retenu(champ)) + + def test_a_field_the_query_flagged_is_left_alone(self): + champ = { + "model": "account.move.line", + "name": "credit", + "ttype": "monetary", + "pg_type": "numeric", + "unique": False, + "checked": True, + "max_len": None, + } + self.assertFalse(anon.champ_retenu(champ)) + + +class TestStructuredCharFieldsSurvive(unittest.TestCase): + """`ValueError: invalid literal for int() with base 10: 'bruyere'`. + + Odoo déclare `parent_path` en `char`, mais y range un CHEMIN + D'IDENTIFIANTS — « 1/7/12/ » — qu'il reparse : + + int(id) for id in company.parent_path.split('/') + base/models/res_company.py:117, models.py:203 et :221 + + Y écrire un mot fait lever le serveur au premier chargement de page. + Mesuré : sept modèles `_parent_store` dans une base ordinaire — + res.company, product.category, stock.location, hr.department, + website.menu, account.analytic.plan, helpdesk.ticket.category. + + Même chose pour `days_next_month`, qu'Odoo passe à `int()` + (account/models/account_payment_term.py:321). + """ + + def _champ(self, nom): + return { + "model": "res.company", + "name": nom, + "ttype": "char", + "pg_type": "character varying", + "unique": False, + "checked": False, + "max_len": None, + } + + def test_the_known_parsers_are_named(self): + self.assertIn("parent_path", anon.CHAMPS_STRUCTURES) + self.assertIn("days_next_month", anon.CHAMPS_STRUCTURES) + + def test_they_are_never_touched_whatever_the_model(self): + for nom in anon.CHAMPS_STRUCTURES: + self.assertFalse(anon.champ_retenu(self._champ(nom)), nom) + + def test_a_phone_number_is_not_mistaken_for_a_structure(self): + """Le motif EXIGE les barres obliques. Sans elles, un numéro tout + en chiffres passerait pour une structure et échapperait à + l'anonymisation — un défaut de confidentialité, pas de robustesse. + """ + import re + + motif = re.compile(anon.MOTIF_CHEMIN) + for valeur in ("5141234567", "0", "42", "1234-5678"): + self.assertIsNone(motif.match(valeur), valeur) + for valeur in ("1/", "1/7/12/", "3/4/"): + self.assertIsNotNone(motif.match(valeur), valeur) + + def test_the_probe_asks_once_per_table_not_once_per_column(self): + """Sur 410 modèles, la différence est de 410 allers-retours au + lieu de 863.""" + appels = [] + + def espion(database, sql, config_path=None): + appels.append(sql) + return "0:0\x1f0:0" + + vrai = anon.lib_analyse.run_psql + anon.lib_analyse.run_psql = espion + etapes = [ + { + "model": "res.company", + "fields": [self._champ("name"), self._champ("street")], + "sql": "x", + } + ] + try: + anon.sonder_colonnes("base", etapes) + finally: + anon.lib_analyse.run_psql = vrai + self.assertEqual(len(appels), 1) + self.assertIn('"name"', appels[0]) + self.assertIn('"street"', appels[0]) + + def test_one_counter_example_is_enough_to_keep_a_column(self): + """Mieux vaut anonymiser une colonne douteuse que taire une + donnée personnelle.""" + + def espion(database, sql, config_path=None): + # 10 valeurs remplies, 9 seulement sont des chemins. + return "10:9" + + vrai = anon.lib_analyse.run_psql + anon.lib_analyse.run_psql = espion + etapes = [ + {"model": "m", "fields": [self._champ("chemin")], "sql": "x"} + ] + try: + ecartees, _ = anon.sonder_colonnes("base", etapes) + finally: + anon.lib_analyse.run_psql = vrai + self.assertEqual(ecartees, {}) + + def test_an_all_paths_column_is_dropped_from_the_plan(self): + def espion(database, sql, config_path=None): + # Une mesure PAR COLONNE : la sonde compte ce qu'elle + # reçoit et renonce si le compte ne tombe pas juste. + return "10:10\x1f10:10" + + vrai = anon.lib_analyse.run_psql + anon.lib_analyse.run_psql = espion + etapes = [ + { + "model": "m", + "fields": [self._champ("chemin"), self._champ("nom")], + "sql": "x", + } + ] + try: + ecartees, _ = anon.sonder_colonnes("base", etapes) + finally: + anon.lib_analyse.run_psql = vrai + # Les deux colonnes rendent le même compte ici : c'est le principe + # qu'on vérifie, pas la ligne exacte. + self.assertIn("m", ecartees) + propre = anon.appliquer_sondes(etapes, {"m": ["chemin"]}, {}, None) + self.assertEqual([c["name"] for c in propre[0]["fields"]], ["nom"]) + self.assertNotIn('"chemin"', propre[0]["sql"]) + + def test_a_model_entirely_dropped_leaves_no_empty_statement(self): + etapes = [ + {"model": "m", "fields": [self._champ("chemin")], "sql": "x"} + ] + self.assertEqual( + anon.appliquer_sondes(etapes, {"m": ["chemin"]}, {}, None), [] + ) + + +class TestANumberKeepsItsMeaningfulRange(unittest.TestCase): + """`ValueError: hour must be in 0..23`. + + `resource.calendar.attendance.hour_from` est un `float` qui vaut une + heure de la journée — 8,00 à 13,00 dans la base d'origine. Un tirage + à 957 fait lever Odoo au premier affichage d'un employé : + + time(int(integral), ...) resource/models/utils.py:45 + + Aucune contrainte PostgreSQL ne dit cela : la borne vit dans le code. + La seule que les DONNÉES déclarent est leur propre étendue, et c'est + la seule qu'on puisse respecter sans nommer les champs un par un. + """ + + def _champ(self, ttype="float", **kw): + base = { + "model": "resource.calendar.attendance", + "name": "hour_from", + "ttype": ttype, + "pg_type": "numeric", + "unique": False, + "checked": False, + "max_len": None, + } + base.update(kw) + return base + + def test_a_measured_range_bounds_the_draw(self): + sql = anon.expression_nombre( + self._champ(borne_min="8.0", borne_max="13.0") + ) + self.assertIn("8.0 + random() * (13.0 - 8.0)", sql) + self.assertNotIn("1000", sql) + + def test_an_integer_can_reach_its_upper_bound(self): + sql = anon.expression_nombre( + self._champ(ttype="integer", borne_min="0", borne_max="5") + ) + self.assertIn("(5 - 0 + 1)", sql) + + def test_without_a_range_it_falls_back_on_a_thousand(self): + self.assertIn("1000", anon.expression_nombre(self._champ())) + self.assertIn( + "1001", anon.expression_nombre(self._champ(ttype="integer")) + ) + + def test_a_half_known_range_is_not_used(self): + """Une borne sans l'autre ne borne rien.""" + for kw in ({"borne_min": "8.0"}, {"borne_max": "13.0"}): + self.assertIn("1000", anon.expression_nombre(self._champ(**kw))) + + def test_a_value_that_is_not_a_number_never_reaches_the_sql(self): + """Ces bornes viennent de la base et retournent dans du SQL.""" + self.assertTrue(anon.nombre_valide("8.0")) + self.assertTrue(anon.nombre_valide("-3")) + for mauvais in ("8.0); DROP TABLE x; --", "", None, "huit"): + self.assertFalse(anon.nombre_valide(mauvais), mauvais) + + def test_the_probe_reads_the_bounds(self): + recu = {} + + def espion(database, sql, config_path=None): + recu["sql"] = sql + return "8.0:13.0" + + vrai = anon.lib_analyse.run_psql + anon.lib_analyse.run_psql = espion + etapes = [{"model": "m", "fields": [self._champ()], "sql": "x"}] + try: + _, bornes = anon.sonder_colonnes("base", etapes) + finally: + anon.lib_analyse.run_psql = vrai + self.assertIn("min(", recu["sql"]) + self.assertIn("max(", recu["sql"]) + self.assertEqual(bornes["m"]["hour_from"], ("8.0", "13.0")) + + def test_an_empty_column_yields_no_bound(self): + def espion(database, sql, config_path=None): + return ":" + + vrai = anon.lib_analyse.run_psql + anon.lib_analyse.run_psql = espion + etapes = [{"model": "m", "fields": [self._champ()], "sql": "x"}] + try: + _, bornes = anon.sonder_colonnes("base", etapes) + finally: + anon.lib_analyse.run_psql = vrai + self.assertEqual(bornes, {}) + + def test_the_bounds_reach_the_generated_sql(self): + etapes = [{"model": "m", "fields": [self._champ()], "sql": "x"}] + propre = anon.appliquer_sondes( + etapes, {}, {"m": {"hour_from": ("8.0", "13.0")}}, None + ) + self.assertIn("8.0 + random()", propre[0]["sql"]) + + +class TestTheProbeDistrustsWhatItReads(unittest.TestCase): + """Ce que la sonde reçoit repart dans du SQL : elle le vérifie. + + Deux mutations ont survécu au premier tour, et les deux disaient la + même chose : j'éprouvais les fonctions de contrôle isolément sans + vérifier que la sonde s'en sert. Une garde qu'on n'exerce pas ne + garde rien. + """ + + def _champ(self, nom="hour_from", ttype="float"): + return { + "model": "m", + "name": nom, + "ttype": ttype, + "pg_type": "numeric", + "unique": False, + "checked": False, + "max_len": None, + } + + def _sonder(self, reponse, champs): + def espion(database, sql, config_path=None): + return reponse + + vrai = anon.lib_analyse.run_psql + anon.lib_analyse.run_psql = espion + etapes = [{"model": "m", "fields": champs, "sql": "x"}] + try: + return anon.sonder_colonnes("base", etapes) + finally: + anon.lib_analyse.run_psql = vrai + + def test_a_bound_that_is_not_a_number_is_refused(self): + """La base peut rendre autre chose qu'un nombre ; ces valeurs + retournent telles quelles dans un littéral SQL.""" + for reponse in ("abc:def", "8.0); DROP TABLE x; --:13", ":13"): + _, bornes = self._sonder(reponse, [self._champ()]) + self.assertEqual(bornes, {}, reponse) + + def test_a_valid_bound_still_passes(self): + _, bornes = self._sonder("8.0:13.0", [self._champ()]) + self.assertEqual(bornes["m"]["hour_from"], ("8.0", "13.0")) + + def test_a_short_answer_is_refused_whole(self): + """Moins de mesures que de colonnes : `zip` tronquerait en silence + et attribuerait la mesure d'une colonne à une autre.""" + champs = [self._champ("a"), self._champ("b"), self._champ("c")] + ecartees, bornes = self._sonder("8.0:13.0", champs) + self.assertEqual(bornes, {}) + self.assertEqual(ecartees, {}) + + def test_a_long_answer_is_refused_too(self): + champs = [self._champ("a")] + _, bornes = self._sonder("8.0:13.0\x1f1.0:2.0", champs) + self.assertEqual(bornes, {}) + + def test_the_exact_count_is_accepted(self): + champs = [self._champ("a"), self._champ("b")] + _, bornes = self._sonder("8.0:13.0\x1f1.0:2.0", champs) + self.assertEqual(len(bornes["m"]), 2) + + if __name__ == "__main__": unittest.main() diff --git a/test/test_check_addons_exist.py b/test/test_check_addons_exist.py index d0711f1..374d31d 100644 --- a/test/test_check_addons_exist.py +++ b/test/test_check_addons_exist.py @@ -177,3 +177,7 @@ class TestMainBadConfig(unittest.TestCase): ): result = main() self.assertEqual(result, -1) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_check_chart_drift.py b/test/test_check_chart_drift.py new file mode 100644 index 0000000..6e68636 --- /dev/null +++ b/test/test_check_chart_drift.py @@ -0,0 +1,338 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Ce qu'une montée de version ajoute au référentiel comptable. + +Mesuré sur une migration 12 → 18 réelle, entre les deux derniers paliers : + + comptes 96 → 427 taxes 32 → 64 + groupes 0 → 168 positions 14 → 28 + +Le script de migration de `l10n_ca` recharge le plan `ca_2023` sans +`force_create=False` ; les codes du client ne recouvrent le gabarit que +sur 15 des 341, et les 326 autres sont créés. Rien n'est détruit — mais +les 168 groupes reclassent les comptes du client par PRÉFIXE de code, et +un compte fournisseurs de 1067 écritures se retrouve sous « Residential +Mortgage Loans ». + +Ce qui se teste ici n'est pas le comptage — psql le fait — mais le +JUGEMENT : un compte absolu ne dit rien (un plan peut légitimement porter +mille comptes), seul l'écart entre deux paliers de la même migration se +juge. Et la comparaison doit rester muette quand rien n'a bougé, sinon +personne ne la lira deux fois. +""" + +import io +import os +import sys +import unittest +from contextlib import redirect_stdout + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, REPO) + +from script.analyse import check_chart_drift as drift # noqa: E402 +from script.analyse import lib_analyse # noqa: E402 + + +class Base(unittest.TestCase): + """psql bouchonné : chaque base rend les comptes qu'on lui prête.""" + + def setUp(self): + self.vues = [] + self.ancien = lib_analyse.run_psql + self.addCleanup(setattr, lib_analyse, "run_psql", self.ancien) + self.tables = {} + self.sans_ecriture = (0, 0) + self.homonymes = 0 + self.reclasses = 0 + lib_analyse.run_psql = self.repondre + + def poser(self, base, **compteurs): + self.tables[base] = compteurs + + def repondre(self, database, sql, **kwargs): + self.vues.append((database, sql)) + compteurs = self.tables.get(database, {}) + if "to_regclass" in sql and "count(*)" not in sql: + table = sql.split("public.")[1].split("'")[0] + return "true" if table in compteurs else "false" + if "account_move_line l WHERE l.account_id" in sql: + total, vides = self.sans_ecriture + return '[{"total": %d, "sans_ecriture": %d}]' % (total, vides) + if "HAVING count(*) > 1" in sql: + return str(self.homonymes) + if "code_prefix_start" in sql: + return str(self.reclasses) + for table, valeur in compteurs.items(): + if f"FROM {table};" in sql: + return str(valeur) + return "" + + +class TestWhatItCompares(Base): + def test_it_reads_both_steps(self): + self.poser("avant", account_account=96) + self.poser("apres", account_account=427) + drift.inspect("apres", "avant") + bases = {base for base, _sql in self.vues} + self.assertEqual({"avant", "apres"}, bases) + + def test_a_table_that_does_not_exist_is_not_a_zero(self): + # Sans le module account, la table manque : dire « 0 → 0 » ferait + # croire à une mesure, et « 0 → 400 » à une explosion. + self.poser("avant") + self.poser("apres", account_account=427) + rapport = drift.inspect("apres", "avant") + ligne = [x for x in rapport["tables"] if x["key"] == "account"][0] + self.assertIsNone(ligne["before"]) + self.assertIsNone(ligne["delta"]) + + def test_every_declared_table_is_measured(self): + self.poser("avant", account_account=1) + self.poser("apres", account_account=1) + rapport = drift.inspect("apres", "avant") + self.assertEqual( + {e["key"] for e in drift.TABLES}, + {x["key"] for x in rapport["tables"]}, + ) + + +class TestWhatItJudges(Base): + def rapport(self, avant, apres): + self.poser("avant", **avant) + self.poser("apres", **apres) + return drift.inspect("apres", "avant") + + def test_nothing_added_is_nothing_to_report(self): + rapport = self.rapport( + {"account_account": 96}, {"account_account": 96} + ) + self.assertEqual([], drift.judge(rapport)) + + def test_a_chart_that_quadruples_is_a_finding(self): + rapport = self.rapport( + {"account_account": 96}, {"account_account": 427} + ) + trouve = drift.judge(rapport) + self.assertEqual(1, len(trouve)) + self.assertEqual(331, trouve[0][1]["delta"]) + + def test_a_chart_that_SHRINKS_is_not_reported_here(self): + # Ce n'est pas le sujet de cet outil, et le dire ferait deux + # constats pour deux causes différentes sous un seul nom. + rapport = self.rapport( + {"account_account": 427}, {"account_account": 96} + ) + self.assertEqual([], drift.judge(rapport)) + + def test_groups_appearing_from_nothing_are_judged(self): + # Zéro groupe puis cent soixante-huit : c'est la grille du gabarit, + # et elle reclasse les comptes du client. + rapport = self.rapport({"account_group": 0}, {"account_group": 168}) + self.assertEqual("group", drift.judge(rapport)[0][0]["key"]) + + def test_gravity_wins_over_size(self): + rapport = self.rapport( + {"account_journal": 8, "account_account": 96}, + {"account_journal": 28, "account_account": 99}, + ) + trouve = drift.judge(rapport) + cles = [entree["key"] for entree, _ in trouve] + self.assertEqual("account", cles[0], "la gravite doit primer") + self.assertEqual("journal", cles[-1]) + # Et l'écart le plus GROS est bien celui qu'on relègue. + deltas = dict(zip(cles, [ligne["delta"] for _e, ligne in trouve])) + self.assertGreater(deltas["journal"], deltas["account"]) + + def test_at_equal_gravity_the_bigger_gap_comes_first(self): + rapport = self.rapport( + {"account_account": 96, "account_tax": 32}, + {"account_account": 427, "account_tax": 64}, + ) + cles = [entree["key"] for entree, _ in drift.judge(rapport)] + self.assertEqual(["account", "tax"], cles) + + def test_every_entry_says_why_it_matters(self): + for entree in drift.TABLES: + self.assertTrue(entree["why"].strip(), entree["key"]) + self.assertIn(entree["gravity"], ("broken", "watch")) + + +class TestTheReport(Base): + def test_it_names_both_steps(self): + self.poser("base_upgrade_17", account_account=96) + self.poser("base_upgrade_18", account_account=427) + rapport = drift.inspect("base_upgrade_18", "base_upgrade_17") + texte = drift.render(rapport, colour=False) + self.assertIn("base_upgrade_17", texte) + self.assertIn("base_upgrade_18", texte) + + def test_it_shows_the_before_and_the_after_not_just_the_delta(self): + # « +331 » seul ne dit pas si l'on part de 96 ou de 4000. + self.poser("avant", account_account=96) + self.poser("apres", account_account=427) + texte = drift.render(drift.inspect("apres", "avant"), colour=False) + self.assertIn("+331", texte) + self.assertIn("96", texte) + self.assertIn("427", texte) + + def test_a_quiet_migration_says_so_and_stops(self): + self.poser("avant", account_account=96) + self.poser("apres", account_account=96) + texte = drift.render(drift.inspect("apres", "avant"), colour=False) + self.assertIn( + drift.t("Nothing was added to the reference data."), texte + ) + self.assertNotIn("❌", texte) + + def test_it_points_at_the_replay_not_at_surgery(self): + # La clé naturelle de suppression attrape aussi des comptes du + # client, et douze des ajoutés sont accrochés en SET NULL. + self.poser("avant", account_account=96) + self.poser("apres", account_account=427) + texte = drift.render(drift.inspect("apres", "avant"), colour=False) + self.assertIn( + drift.t("Replay the step with the fix; do not operate the"), + texte, + ) + + def test_the_shape_is_shown_when_there_is_something_to_show(self): + self.sans_ecriture = (427, 404) + self.homonymes = 15 + self.reclasses = 422 + self.poser("avant", account_account=96) + self.poser("apres", account_account=427, account_group=168) + texte = drift.render(drift.inspect("apres", "avant"), colour=False) + self.assertIn("404", texte) + self.assertIn("422", texte) + + +class TestFindingThePreviousStep(unittest.TestCase): + """Le palier précédent se LIT dans la chaîne, il ne se devine pas.""" + + def chaine(self): + return { + "config_database_name": "base", + "target_odoo_version": "18.0", + "state_4_upgrade_odoo_lst": [1, 2, 3, 4, 5, 6], + } + + def test_it_reads_the_chain_the_migration_wrote(self): + self.assertEqual( + "base_upgrade_17", + drift.previous_database("base_upgrade_18", self.chaine()), + ) + + def test_the_first_step_has_no_predecessor(self): + # La base de DÉPART ne porte pas de suffixe : lui retrancher 1 + # donnerait un nom qui n'existe pas. + self.assertIsNone(drift.previous_database("base", self.chaine())) + + def test_a_database_outside_the_chain_is_not_guessed(self): + self.assertIsNone( + drift.previous_database("une_autre_base", self.chaine()) + ) + + +class TestTheCommand(Base): + def lancer(self, argv): + tampon = io.StringIO() + with redirect_stdout(tampon): + code = drift.main(argv) + return code, tampon.getvalue() + + def setUp(self): + super().setUp() + self.ancien_require = lib_analyse.require_odoo_database + self.addCleanup( + setattr, lib_analyse, "require_odoo_database", self.ancien_require + ) + lib_analyse.require_odoo_database = lambda *a, **k: None + + def test_without_a_previous_step_it_is_a_tool_failure(self): + # Sortir 0 laisserait croire que la comparaison a eu lieu. + ancien = drift.previous_database + drift.previous_database = lambda *a, **k: None + self.addCleanup(setattr, drift, "previous_database", ancien) + code, _ = self.lancer(["-d", "base_upgrade_18"]) + self.assertEqual(2, code) + + def test_no_drift_exits_zero(self): + self.poser("avant", account_account=96) + self.poser("apres", account_account=96) + code, _ = self.lancer( + ["-d", "apres", "--before", "avant", "--no-color"] + ) + self.assertEqual(0, code) + + def test_drift_exits_one(self): + # 1 = des trouvailles, la convention des outils du dépôt. + self.poser("avant", account_account=96) + self.poser("apres", account_account=427) + code, _ = self.lancer( + ["-d", "apres", "--before", "avant", "--no-color"] + ) + self.assertEqual(1, code) + + def test_the_json_carries_both_counts(self): + import json + + self.poser("avant", account_account=96) + self.poser("apres", account_account=427) + _code, texte = self.lancer( + ["-d", "apres", "--before", "avant", "--json"] + ) + dct = json.loads(texte) + ligne = [x for x in dct["tables"] if x["key"] == "account"][0] + self.assertEqual(96, ligne["before"]) + self.assertEqual(427, ligne["after"]) + self.assertEqual(331, ligne["delta"]) + + +class TestItNeverWrites(unittest.TestCase): + """On compare deux bases de palier : l'une d'elles est parfois la + seule copie d'un état.""" + + def test_no_statement_of_the_tool_can_write(self): + """Sur le CODE, jamais sur la prose. + + Les docstrings de l'outil expliquent justement pourquoi il ne + faut pas opérer la base — « accrochés en ON DELETE SET NULL » — + et une garde qui lit le texte échoue sur le texte qui l'explique. + """ + import ast + import inspect + + arbre = ast.parse(inspect.getsource(drift)) + for noeud in ast.walk(arbre): + if isinstance( + noeud, (ast.Module, ast.FunctionDef, ast.ClassDef) + ) and ast.get_docstring(noeud): + noeud.body = noeud.body[1:] + code = ast.unparse(arbre).upper() + for interdit in ("UPDATE ", "DELETE ", "INSERT ", "DROP ", "ALTER "): + self.assertNotIn(interdit, code, interdit) + + def test_it_only_ever_selects(self): + import ast + import inspect + + arbre = ast.parse(inspect.getsource(drift)) + requetes = [ + n.value + for n in ast.walk(arbre) + if isinstance(n, ast.Constant) + and isinstance(n.value, str) + and "FROM " in n.value.upper() + ] + self.assertTrue(requetes) + for requete in requetes: + self.assertTrue( + requete.upper().lstrip().startswith("SELECT"), requete[:40] + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_check_manifest_gaps.py b/test/test_check_manifest_gaps.py new file mode 100644 index 0000000..6dd3d94 --- /dev/null +++ b/test/test_check_manifest_gaps.py @@ -0,0 +1,391 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Le trou dans un manifeste, et ce qui le distingue d'une absence normale. + +Beaucoup de dépôts n'ont pas de branche pour toutes les versions d'Odoo. +Les signaler tous ferait 46 % de bruit — mesuré : 35 trous, 19 vraies +omissions. L'outil ne vaut que par ce tri, et c'est donc lui qu'on teste. + +Aucun test ne touche au réseau : `judge` reçoit son lecteur d'amont, et +les tests lui en donnent un qui répond de mémoire. Un test qui appelle +GitHub échoue dans un train, et l'on finit par ne plus le lancer. +""" + +import io +import os +import sys +import tempfile +import unittest +from contextlib import redirect_stdout + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, REPO) + +from script.analyse import check_manifest_gaps as gaps # noqa: E402 + +MANIFESTE = """ + + + +%s + +""" + +PROJET = """ """ + + +def projet(nom, version, remote="OCA"): + court = nom.replace(".git", "") + return PROJET % (nom, version, court, remote, version, version) + + +class Base(unittest.TestCase): + def setUp(self): + self.dossier = tempfile.mkdtemp() + self.addCleanup(__import__("shutil").rmtree, self.dossier) + + def ecrire(self, version, noms, remote="OCA"): + corps = "\n".join(projet(n, version, remote) for n in noms) + chemin = os.path.join( + self.dossier, "git_manifest_odoo%s.xml" % version + ) + with io.open(chemin, "w", encoding="utf-8") as handle: + handle.write(MANIFESTE % corps) + return chemin + + def trous(self): + ordre = [v for v, _ in gaps.versions(self.dossier)] + return gaps.gaps(gaps.declarations(self.dossier), ordre) + + +class TestReadingTheManifests(Base): + def test_the_versions_come_from_the_files_not_a_list(self): + # Une version ajoutée au dépôt doit entrer dans l'analyse sans + # qu'on pense à toucher le code. + for v in ("12.0", "14.0", "18.0"): + self.ecrire(v, ["a.git"]) + self.assertEqual( + ["12.0", "14.0", "18.0"], + [v for v, _ in gaps.versions(self.dossier)], + ) + + def test_ten_comes_after_nine_not_before(self): + # Trier des versions comme du texte met « 10.0 » avant « 9.0 ». + self.assertLess(gaps.rang("9.0"), gaps.rang("10.0")) + + def test_the_dev_manifests_are_left_out(self): + # Ils COMPLÈTENT le principal : un dépôt qui n'est que là n'a + # aucune raison d'être partout, et le compter créerait des trous + # imaginaires dans toutes les versions. + self.ecrire("14.0", ["a.git"]) + with io.open( + os.path.join(self.dossier, "git_manifest_odoo14.0_dev.xml"), + "w", + encoding="utf-8", + ) as handle: + handle.write(MANIFESTE % projet("b.git", "14.0")) + self.assertEqual(["14.0"], [v for v, _ in gaps.versions(self.dossier)]) + + def test_the_url_is_rebuilt_from_its_remote(self): + self.ecrire("14.0", ["donation.git"]) + dct = gaps.declarations(self.dossier) + self.assertEqual( + "https://github.com/OCA/donation.git", + dct["donation.git"]["14.0"], + ) + + def test_an_unparsable_manifest_does_not_stop_the_others(self): + self.ecrire("14.0", ["a.git"]) + with io.open( + os.path.join(self.dossier, "git_manifest_odoo16.0.xml"), + "w", + encoding="utf-8", + ) as handle: + handle.write(" 1", + "[2026-08-26 03:19:59.847489] [test] smoke_public_url -> 1", + "2026-08-26 03:20:00,000 INFO odoo: après", + ] + ) + texte = qtui.extra_pane(self.ligne_verdict(chemin)) + self.assertIn("INFO odoo: avant", texte) + self.assertIn("[test] smoke_public_url -> 1", texte) + self.assertNotIn("INFO odoo: après", texte) + + def test_it_says_the_tool_output_is_absent(self): + # Sans cela on cherche un fichier plus complet qui n'existe pas : + # ce qui passe par run_on_terminal n'a pas de sortie capturable. + chemin = self.journal( + [ + "[2026-08-26 03:19:44.166204] $ ./script/odoo/migration/" + "smoke_public_url.py -d test_neutralize_upgrade_14", + "[2026-08-26 03:19:59.847489] [test] smoke_public_url -> 1", + ] + ) + texte = qtui.extra_pane(self.ligne_verdict(chemin)) + self.assertIn( + qtui.t("the tool output is not in there: it goes to the"), texte + ) + + def test_the_path_and_the_size_are_documented(self): + chemin = self.journal(["a"] * 40) + texte = qtui.extra_pane(self.ligne_verdict(chemin)) + self.assertIn(chemin, texte) + self.assertIn("40", texte) + + def test_it_stays_quiet_once_the_output_is_actually_there(self): + # Le pilote capture désormais. Répéter que la sortie manque + # enverrait la chercher ailleurs alors qu'on l'a sous les yeux. + chemin = self.journal( + [ + "[2026-08-26 03:19:44.166204] $ ./script/odoo/migration/" + "smoke_public_url.py -d test_neutralize_upgrade_14", + "⧖ Démarrage d'Odoo…", + "❌ 3 URL sur 37 rendent 500", + "[2026-08-26 03:19:59.847489] [test] smoke_public_url -> 1", + ] + ) + texte = qtui.extra_pane(self.ligne_verdict(chemin)) + self.assertIn("3 URL sur 37", texte) + self.assertNotIn( + qtui.t("the tool output is not in there: it goes to the"), texte + ) + + def test_the_frame_alone_is_not_mistaken_for_output(self): + # « $ … » puis « -> 1 » : c'est le cadre, pas la sortie. + extrait = [ + "[2026-08-26 03:19:44.166204] $ ./script/x.py -d base", + "[2026-08-26 03:19:59.846406] -> 1", + "[2026-08-26 03:19:59.847489] [test] smoke_public_url -> 1", + ] + self.assertFalse(quality.excerpt_has_output(extrait, 0)) + + def test_one_line_of_output_is_enough(self): + extrait = [ + "[2026-08-26 03:19:44.166204] $ ./script/x.py -d base", + "une seule ligne dite par l'outil", + "[2026-08-26 03:19:59.846406] -> 1", + ] + self.assertTrue(quality.excerpt_has_output(extrait, 0)) + + def test_blank_lines_are_not_output(self): + extrait = [ + "[2026-08-26 03:19:44.166204] $ ./script/x.py -d base", + "", + " ", + "[2026-08-26 03:19:59.846406] -> 1", + ] + self.assertFalse(quality.excerpt_has_output(extrait, 0)) + + def test_what_came_before_the_command_is_not_its_output(self): + # Le contexte d'Odoo précède la commande ; le compter dirait + # « la sortie est là » sur tous les anciens journaux. + extrait = [ + "odoo: ce qui tournait avant", + "[2026-08-26 03:19:44.166204] $ ./script/x.py -d base", + "[2026-08-26 03:19:59.846406] -> 1", + ] + self.assertFalse(quality.excerpt_has_output(extrait, 1)) + + def test_no_log_at_all_says_so(self): + ligne = self.ligne_verdict(None) + ligne["log"] = None + texte = qtui.extra_pane(ligne) + self.assertIn(qtui.t("no log file for this step"), texte) + + def test_a_verdict_absent_from_the_log_says_so(self): + chemin = self.journal(["rien à voir", "vraiment rien"]) + texte = qtui.extra_pane(self.ligne_verdict(chemin)) + self.assertIn(qtui.t("this verdict is not in it"), texte) + + def test_the_tool_is_read_from_the_command_not_from_the_name(self): + # Le nom du test se retrouve dans d'AUTRES lignes du journal — une + # cible make qui le mentionne, par exemple. Chercher le nom au + # lieu du script y accroche la mauvaise commande, et l'extrait + # montre alors un passage qui n'a rien à voir. + chemin = self.journal( + [ + "[2026-08-26 03:19:44.000000] $ ./script/odoo/migration/" + "database_cleanup.py -d test_neutralize_upgrade_14", + "[2026-08-26 03:19:50.000000] [test] database_cleanup -> 0", + "[2026-08-26 03:19:52.000000] $ make database_cleanup_all", + "[2026-08-26 03:19:55.000000] -> 0", + ] + ) + with io.open(chemin, encoding="utf-8") as handle: + brut = handle.read().splitlines() + _extrait, rang = quality.event_excerpt( + brut, + evenement( + name="database_cleanup", + at="2026-08-26 03:19:56.000000", + detail="./script/odoo/migration/database_cleanup.py" + " -d test_neutralize_upgrade_14", + ), + ) + self.assertEqual(0, rang) + + def test_a_verdict_launched_by_no_script_matches_nothing(self): + # Sans « .py » dans la commande on ne sait pas quoi chercher ; + # deviner accrocherait la première ligne venue. + chemin = self.journal( + ["[2026-08-26 03:19:44.000000] $ make quelque_chose"] + ) + with io.open(chemin, encoding="utf-8") as handle: + brut = handle.read().splitlines() + _extrait, rang = quality.event_excerpt( + brut, evenement(name="quelque_chose", detail="make quelque_chose") + ) + self.assertIsNone(rang) + + def test_a_replayed_migration_shows_the_matching_run(self): + # Le même test apparaît plusieurs fois : la dernière occurrence + # peut appartenir à une exécution qui n'est pas celle qu'on + # regarde. On retient celle qui précède l'horodatage du verdict. + chemin = self.journal( + [ + "[2026-08-23 00:43:59.331479] $ ./script/odoo/migration/" + "smoke_public_url.py -d test_neutralize_upgrade_14", + "[2026-08-23 00:44:19.776150] [test] smoke_public_url -> 1", + "premiere execution finie", + "[2026-08-26 03:19:44.166204] $ ./script/odoo/migration/" + "smoke_public_url.py -d test_neutralize_upgrade_14", + "[2026-08-26 03:19:59.847489] [test] smoke_public_url -> 1", + ] + ) + with io.open(chemin, encoding="utf-8") as handle: + brut = handle.read().splitlines() + _extrait, rang = quality.event_excerpt( + brut, evenement(at="2026-08-23 00:44:19.776150") + ) + self.assertEqual(0, rang) + + +class TestKeepingWhatARunWrote(Base): + """Ce que la migration ne pouvait pas garder, l'écran le peut. + + Le pilote lance par `run_on_terminal`, qui n'a pas de sortie + capturable — un tube y ferait renoncer les pleins écrans. Mais ce que + l'on lance SOI-MÊME depuis l'écran, on peut en garder une copie, et + la relire sans relancer. + """ + + def setUp(self): + super().setUp() + import shutil + import tempfile + + self.dossier = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.dossier) + + def fichier(self, contenu): + chemin = os.path.join(self.dossier, "run.log") + with io.open(chemin, "w", encoding="utf-8") as handle: + handle.write(contenu) + return chemin + + def test_one_file_per_screen_line_not_per_run(self): + # Deux exécutions de la même étape se remplacent : on veut « ce + # que ça donne maintenant », pas un historique à trier. + self.assertEqual( + qtui.run_log_path("review_residue"), + qtui.run_log_path("review_residue"), + ) + self.assertNotEqual( + qtui.run_log_path("review_residue"), + qtui.run_log_path("review_state"), + ) + + def test_a_key_that_could_escape_the_directory_cannot(self): + chemin = os.path.realpath(qtui.run_log_path("../../etc/passwd")) + dossier = os.path.realpath( + os.path.join(qtui.REPO_ROOT, qtui.REVIEW_LOG_DIR) + ) + self.assertEqual(dossier, os.path.dirname(chemin)) + self.assertNotIn(os.sep, os.path.basename(chemin)) + + def test_no_key_means_no_file(self): + self.assertIsNone(qtui.run_log_path("")) + + def test_a_missing_file_reads_as_nothing(self): + lst, total = qtui.read_run_log( + os.path.join(self.dossier, "jamais_ecrit.log") + ) + self.assertEqual(([], 0), (lst, total)) + + def test_the_total_is_told_even_when_the_tail_is_kept(self): + # Montrer la fin SANS dire qu'on en cache se lit « il manque des + # lignes ». + chemin = self.fichier("\n".join("l%d" % i for i in range(50))) + lst, total = qtui.read_run_log(chemin, limite=10) + self.assertEqual(10, len(lst)) + self.assertEqual(50, total) + + def test_the_whole_output_is_shown_not_a_sample(self): + # Le panneau DÉFILE : amputer obligerait à relancer dans un + # terminal pour lire la suite. + chemin = self.fichier("\n".join("ligne %d" % i for i in range(300))) + row = { + "kind": "review", + "question": "Does Odoo load the database?", + "command": "x", + "capture": chemin, + } + texte = qtui.extra_pane(row) + self.assertIn("ligne 0", texte) + self.assertIn("ligne 299", texte) + + def test_a_line_never_run_says_so(self): + row = { + "kind": "review", + "question": "Does Odoo load the database?", + "command": "x", + "capture": os.path.join(self.dossier, "absent.log"), + } + self.assertIn(qtui.t("never run from here yet"), qtui.extra_pane(row)) + + def test_a_line_with_nothing_to_run_promises_no_log(self): + row = { + "kind": "review", + "question": "Did the migration reach the end?", + "command": "", + "capture": os.path.join(self.dossier, "absent.log"), + } + self.assertNotIn( + qtui.t("never run from here yet"), qtui.extra_pane(row) + ) + + def test_the_repl_step_is_not_captured(self): + # Le passer par un tube lui ferait perdre son invite. + lignes = qtui.rows([snapshot(odoo="18.0")], {}) + shell = [ + row + for row in lignes + if row["kind"] == "review" + and "shell" in (row.get("command") or "") + ] + self.assertTrue(shell) + self.assertIsNone(shell[0].get("capture")) + + def test_every_other_runnable_step_is_captured(self): + lignes = qtui.rows([snapshot(odoo="18.0")], {}) + lancables = [ + row + for row in lignes + if row["kind"] == "review" and row.get("command") + ] + self.assertTrue(lancables) + sans = [r for r in lancables if not r.get("capture")] + self.assertEqual(1, len(sans), [r.get("command") for r in sans]) + + +class TestSwitchingTheCheckoutFirst(Base): + """Lancer un outil sur une base d'un autre palier ÉCRIT dedans.""" + + def dct(self): + return { + "config_database_name": "base", + "target_odoo_version": "18.0", + "state_4_upgrade_odoo_lst": [1, 2, 3, 4, 5, 6], + } + + def test_the_same_version_needs_no_switch(self): + courante = quality.checkout_version() + self.assertIsNone( + qtui.switch_needed("base_upgrade_%d" % courante, self.dct()) + ) + + def test_another_tier_names_its_make_target(self): + courante = quality.checkout_version() + autre = 16 if courante != 16 else 15 + besoin = qtui.switch_needed("base_upgrade_%d" % autre, self.dct()) + self.assertEqual((autre, "switch_odoo_%d" % autre), besoin) + + def test_a_database_outside_the_chain_asks_for_nothing(self): + # On ne sait pas à quel palier elle est : proposer une bascule au + # hasard basculerait le checkout pour rien. + self.assertIsNone(qtui.switch_needed("une_autre_base", self.dct())) + + def test_the_panel_warns_before_the_key_not_after(self): + courante = quality.checkout_version() + autre = 16 if courante != 16 else 15 + ligne = { + "kind": "verdict", + "label": "", + "event": evenement(), + "log": None, + "command": "script/odoo/migration/smoke_public_url.py -d x", + "switch": (autre, "switch_odoo_%d" % autre), + } + texte = qtui.extra_pane(ligne) + self.assertIn("switch_odoo_%d" % autre, texte) + self.assertLess( + texte.index("switch_odoo_%d" % autre), + texte.index(qtui.t("press r to run it again")), + ) + + +class TestTheVerdictPanel(Base): + def panneau(self, **champs): + lst = qtui.extra_rows([], {"lst_event": [evenement(**champs)]}) + ligne = [r for r in lst if r["kind"] == "verdict"][0] + return qtui.extra_pane(ligne) + + def test_the_step_shown_is_the_odoo_version(self): + # « 4.1.I » est la première étape du quatrième bloc du pilote ; la + # migration en est alors au palier 14. Afficher le compteur sous + # le mot « palier » faisait lire une version qui n'existe pas. + texte = self.panneau() + self.assertIn(f"{qtui.t('step'):<10} 14", texte) + + def test_the_pilot_counter_is_still_there_in_brackets(self): + # Il sert à retrouver l'entrée dans le journal ; le perdre + # obligerait à compter les étapes à la main. + self.assertIn("(4.1.I - Migrate database)", self.panneau()) + + def test_what_a_status_of_one_means_is_spelled_out(self): + # « status 1 » ne dit pas si c'est un constat ou une panne. + texte = self.panneau() + self.assertIn( + qtui.t("1 means a public page failed — not merely a finding."), + texte, + ) + + def test_a_tool_we_cannot_replay_says_so(self): + texte = self.panneau(name="un_outil_inconnu") + self.assertIn(qtui.t("No known way to replay this one."), texte) + self.assertNotIn(qtui.t("press r to run it again"), texte) + + +class TestReplayingAVerdict(Base): + def test_a_known_tool_is_rebuilt_from_its_name(self): + self.assertEqual( + "script/odoo/migration/smoke_public_url.py -d base", + qtui.rerun_command(evenement(), "base"), + ) + + def test_the_recorded_command_is_not_replayed_as_is(self): + # Le `detail` porte le venv du PALIER — « .venv.erplibre/bin/python3 » + # d'alors — qui n'est plus celui du checkout courant. + commande = qtui.rerun_command(evenement(), "base") + self.assertNotIn(".venv", commande) + self.assertNotIn("--internal-required", commande) + + def test_an_unknown_tool_offers_nothing(self): + self.assertEqual( + "", qtui.rerun_command(evenement(name="autre_chose"), "base") + ) + + def test_without_a_database_there_is_nothing_to_run(self): + self.assertEqual("", qtui.rerun_command(evenement(), "")) + + +class TestRunningATestFromTheScreen(Base): + """Le test prend le terminal, puis le rend.""" + + def setUp(self): + super().setUp() + self.appels = [] + + class FauxRetour: + returncode = 0 + + def faux_run(argv, cwd=None): + self.appels.append((argv, cwd)) + return FauxRetour() + + faux = type("M", (), {"run": staticmethod(faux_run)}) + self.addCleanup(setattr, qtui, "subprocess", qtui.subprocess) + qtui.subprocess = faux + + def lancer(self, commande, wait=False): + tampon = io.StringIO() + with redirect_stdout(tampon): + resultat = qtui.run_in_terminal(commande, wait=wait) + return resultat, tampon.getvalue() + + def test_nothing_to_run_runs_nothing(self): + (code, tourné), _sortie = self.lancer("") + self.assertIsNone(code) + self.assertFalse(tourné) + self.assertEqual([], self.appels) + + def test_a_python_tool_runs_with_this_checkout_interpreter(self): + # Le lancer avec le python du système le priverait des dépendances + # d'ERPLibre : l'outil échouerait à l'import, pas sur la base. + self.lancer("script/odoo/migration/smoke_public_url.py -d base") + argv, _cwd = self.appels[0] + self.assertEqual(sys.executable, argv[0]) + + def test_it_runs_from_the_repository_root(self): + # Les chemins que la commande porte y sont relatifs ; ailleurs, + # ils sont introuvables. + self.lancer("script/odoo/migration/smoke_public_url.py -d base") + _argv, cwd = self.appels[0] + self.assertEqual(qtui.REPO_ROOT, cwd) + self.assertTrue( + os.path.isdir(os.path.join(cwd, "script", "odoo", "migration")) + ) + + def test_a_plain_command_keeps_its_own_program(self): + self.lancer("./odoo_bin.sh shell -d base") + argv, _cwd = self.appels[0] + self.assertEqual("./odoo_bin.sh", argv[0]) + + def test_the_exit_code_is_shown_not_swallowed(self): + (code, _t), sortie = self.lancer("./odoo_bin.sh shell") + self.assertEqual(0, code) + self.assertIn(qtui.t("exit code:"), sortie) + + def test_a_missing_program_is_reported_not_raised(self): + def explose(argv, cwd=None): + raise OSError("No such file or directory") + + qtui.subprocess = type("M", (), {"run": staticmethod(explose)}) + (code, tourné), sortie = self.lancer("absent.sh") + self.assertIsNone(code) + self.assertTrue(tourné) + self.assertIn("No such file", sortie) + + if __name__ == "__main__": unittest.main() diff --git a/test/test_check_stale_scss.py b/test/test_check_stale_scss.py index f301e5a..f0d70d9 100755 --- a/test/test_check_stale_scss.py +++ b/test/test_check_stale_scss.py @@ -285,22 +285,38 @@ 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. + def test_no_call_goes_through_the_piped_executor(self): + # L'outil pose lui-même ses questions : un TUBE les rendrait + # injoignables, sans rien signaler — Python met sa sortie en + # tampon par blocs et l'invite reste invisible. + # + # Les DEUX voies terminal conviennent : `run_on_terminal` laisse + # le vrai terminal, `run_captured` en fabrique un et garde une + # copie. Seul `todo_upgrade_execute` bufferise. 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", - ) + depart = 0 + vus = 0 + while True: + rang = source.find("check_stale_scss.py", depart) + if rang < 0: + break + vus += 1 + avant = source[:rang] + # L'appel le plus proche EN AMONT : c'est lui qui exécute. + terminal = max( + avant.rfind("run_on_terminal("), avant.rfind("run_captured(") + ) + self.assertGreater( + terminal, + avant.rfind("todo_upgrade_execute("), + "l'outil repasse par l'exécuteur à tube", + ) + depart = rang + 1 + self.assertEqual(2, vus) class TestTheFixCannotRunTooEarly(unittest.TestCase): diff --git a/test/test_check_view_type_tree.py b/test/test_check_view_type_tree.py new file mode 100644 index 0000000..e9450bc --- /dev/null +++ b/test/test_check_view_type_tree.py @@ -0,0 +1,491 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""« tree » est d'abord un IDENTIFIANT, et rarement un type de vue. + +Sur 465 occurrences du mot dans odoo18.0/addons, quatre-vingts cassent. +Le reste est un id de , un , un env.ref(), un +nom de variable — et le noyau 18 lui-même a gardé ses anciens ids en ne +renommant que les balises : account.view_invoice_tree existe toujours. + +Un détecteur qui ancre sur le mot rend quatre cents lignes fausses et ne +sera pas relancé une seconde fois. C'est donc le TRI qu'on teste ici, pas +la détection : chaque exclusion a son test, et il y en a plus que de +motifs. +""" + +import io +import os +import shutil +import sys +import tempfile +import unittest +from contextlib import redirect_stdout + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, REPO) + +from script.analyse import check_view_type_tree as arbre # noqa: E402 + + +class Base(unittest.TestCase): + """Un checkout jetable : une version, un module, ce qu'on y met.""" + + def setUp(self): + self.racine = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.racine) + self.version("18.0") + self.addons = os.path.join(self.racine, "odoo18.0", "addons") + os.makedirs(self.addons) + + def version(self, texte): + with io.open( + os.path.join(self.racine, arbre.FICHIER_VERSION), + "w", + encoding="utf-8", + ) as handle: + handle.write(texte + "\n") + + def module(self, nom="essai", manifeste=None, init="from . import models"): + chemin = os.path.join(self.addons, "depot", nom) + os.makedirs(chemin, exist_ok=True) + dct = {"name": nom, "version": "18.0.1.0.0", "data": []} + dct.update(manifeste or {}) + self.ecrire(os.path.join(chemin, arbre.MANIFESTE), repr(dct)) + if init is not None: + self.ecrire(os.path.join(chemin, "__init__.py"), init) + return chemin + + def ecrire(self, chemin, contenu): + os.makedirs(os.path.dirname(chemin), exist_ok=True) + with io.open(chemin, "w", encoding="utf-8") as handle: + handle.write(contenu) + return chemin + + def constats(self): + return arbre.inspect(self.racine)["findings"] + + def motifs(self): + return sorted(c[3] for c in self.constats()) + + +class TestTheVersionGate(Base): + def test_below_18_nothing_is_a_defect(self): + # `tree` y est valide, au pire déprécié en 17. Crier dessus + # ferait rejeter le rapport en entier. + self.assertFalse(arbre.concerne((17, 0))) + self.assertFalse(arbre.concerne((12, 0))) + + def test_18_and_beyond_are_judged(self): + self.assertTrue(arbre.concerne((18, 0))) + self.assertTrue(arbre.concerne((19, 0))) + + def test_an_unreadable_version_judges_nothing(self): + os.remove(os.path.join(self.racine, arbre.FICHIER_VERSION)) + self.assertIsNone(arbre.version_active(self.racine)) + self.assertEqual([], self.constats()) + + def test_a_nonsense_version_judges_nothing(self): + self.version("develop") + self.assertIsNone(arbre.version_active(self.racine)) + + +class TestTheModuleGate(Base): + def test_a_manifest_is_read_never_executed(self): + # Un __manifest__.py vient d'un dépôt tiers : l'exécuter pour lire + # quatre clés donnerait la main à n'importe qui. + chemin = self.ecrire( + os.path.join(self.racine, "m", arbre.MANIFESTE), + "__import__('os').system('touch /tmp/jamais')\n", + ) + self.assertIsNone(arbre.lire_manifeste(chemin)) + + def test_a_module_that_odoo_would_not_list_is_skipped(self): + # `installable: False` : il ne peut casser ni à l'installation ni + # à l'exécution. + module = self.module( + manifeste={"installable": False, "data": ["v.xml"]} + ) + self.ecrire( + os.path.join(module, "v.xml"), + "" + "", + ) + self.assertEqual([], self.constats()) + + def test_a_module_that_is_installable_is_read(self): + module = self.module(manifeste={"data": ["v.xml"]}) + self.ecrire( + os.path.join(module, "v.xml"), + "" + "", + ) + self.assertEqual(["xml_balise"], self.motifs()) + + def test_a_directory_without_a_manifest_is_not_a_module(self): + os.makedirs(os.path.join(self.addons, "depot", "pas_un_module")) + self.assertEqual([], arbre.modules(self.addons)) + + +class TestTheLoadingGate(Base): + def test_an_xml_the_manifest_does_not_load_is_ignored(self): + # Personne ne le charge : il ne casse rien, et le signaler ferait + # crier sur des brouillons laissés là. + module = self.module(manifeste={"data": []}) + self.ecrire( + os.path.join(module, "orphelin.xml"), "" + ) + self.assertEqual([], self.constats()) + + def test_a_demo_file_counts_like_a_data_file(self): + module = self.module(manifeste={"demo": ["d.xml"]}) + self.ecrire(os.path.join(module, "d.xml"), "") + self.assertEqual(["xml_balise"], self.motifs()) + + def test_a_python_nobody_imports_is_ignored(self): + module = self.module(init="") + self.ecrire( + os.path.join(module, "brouillon.py"), + "A = [(1, 'tree')]\n", + ) + self.assertEqual([], self.constats()) + + def test_a_python_reached_through_the_init_is_read(self): + module = self.module(init="from . import models") + self.ecrire(os.path.join(module, "models.py"), "A = [(1, 'tree')]\n") + self.assertEqual(["py_views"], self.motifs()) + + def test_a_module_imported_for_its_function_is_still_followed(self): + # « from .hooks import post_init_hook » importe une FONCTION ; + # chercher post_init_hook.py manquait tous les hooks.py, et avec + # eux vingt-sept constats réels. + module = self.module(init="from .hooks import post_init_hook") + self.ecrire( + os.path.join(module, "hooks.py"), + "def post_init_hook(env):\n" + " env['code.generator.view'].create({'view_type': 'tree'})\n", + ) + self.assertEqual(["py_view_type"], self.motifs()) + + def test_a_subpackage_is_walked(self): + module = self.module(init="from . import models") + self.ecrire( + os.path.join(module, "models", "__init__.py"), "from . import x" + ) + self.ecrire( + os.path.join(module, "models", "x.py"), "A = [(1, 'tree')]\n" + ) + self.assertEqual(["py_views"], self.motifs()) + + def test_a_test_file_is_never_judged(self): + module = self.module(init="from . import tests") + self.ecrire( + os.path.join(module, "tests", "__init__.py"), "from . import t" + ) + self.ecrire( + os.path.join(module, "tests", "t.py"), "A = [(1, 'tree')]\n" + ) + self.assertEqual([], self.constats()) + + +class TestTheXmlPatterns(Base): + def vue(self, corps, manifeste=None): + module = self.module(manifeste=manifeste or {"data": ["v.xml"]}) + self.ecrire(os.path.join(module, "v.xml"), corps) + return self.constats() + + def test_a_tree_tag_is_caught(self): + self.assertEqual( + ["xml_balise"], + [ + c[3] + for c in self.vue( + "" + ) + ], + ) + + def test_the_closing_tag_is_not_a_second_defect(self): + # Elle est la moitié du même : les compter séparément double le + # total et fait croire à deux corrections. + self.assertEqual(1, len(self.vue(""))) + + def test_a_commented_tree_is_not_a_defect(self): + # Aucun geste explicite : lxml ne construit pas d'élément pour le + # contenu d'un commentaire. C'est l'argument décisif contre grep. + self.assertEqual([], self.vue("")) + + def test_a_record_id_that_contains_tree_is_not_a_defect(self): + # Le noyau 18 a gardé account.view_invoice_tree, arch en . + self.assertEqual( + [], + self.vue( + "" + "account.move.tree" + "" + "" + ), + ) + + def test_a_view_mode_naming_tree_is_caught(self): + constats = self.vue( + "tree,form" + ) + self.assertEqual(["xml_view_mode"], [c[3] for c in constats]) + self.assertEqual("list,form", constats[0][5]) + + def test_a_space_after_the_comma_does_not_hide_it(self): + self.assertEqual( + ["xml_view_mode"], + [ + c[3] + for c in self.vue( + "form, tree" + ) + ], + ) + + def test_a_mode_that_merely_starts_with_tree_is_not_caught(self): + # Un substring attraperait « treemap ». + self.assertEqual( + [], + self.vue( + "treemap,form" + ), + ) + + def test_an_explicit_type_field_is_caught(self): + self.assertEqual( + ["xml_type"], + [ + c[3] + for c in self.vue( + "tree" + ) + ], + ) + + def test_an_xpath_aiming_at_a_tree_is_caught(self): + self.assertEqual( + ["xml_xpath"], + [ + c[3] + for c in self.vue( + "" + ) + ], + ) + + def test_an_xpath_naming_tree_inside_a_predicate_is_not_caught(self): + self.assertEqual( + [], + self.vue( + "" + ), + ) + + def test_an_unparsable_file_is_reported_apart(self): + # Autre cause, autre réparation : ne pas le mêler aux « tree ». + module = self.module(manifeste={"data": ["v.xml"]}) + self.ecrire(os.path.join(module, "v.xml"), "\n\n" + "\n" + ) + self.assertEqual(2, constats[0][2]) + + +class TestThePythonPatterns(Base): + def code(self, source): + module = self.module(init="from . import models") + self.ecrire(os.path.join(module, "models.py"), source) + return self.constats() + + def test_a_view_tuple_typed_tree_is_caught(self): + self.assertEqual( + ["py_views"], + [ + c[3] + for c in self.code( + "def f(res):\n return [(res and res.id or False, 'tree')]\n" + ) + ], + ) + + def test_a_pair_of_two_strings_is_defensive_code_not_a_defect(self): + # ('list', 'tree') couvre les deux noms de balise ; en 18 seule la + # branche list se produit, l'entrée tree reste morte. + self.assertEqual([], self.code("A = ('list', 'tree')\n")) + + def test_the_variable_name_decides_nothing(self): + # Le piège le plus tentant : les deux formes sont à quelques + # lignes d'écart dans le même dépôt et ne diffèrent que par le + # littéral. Odoo ne lit pas les noms de variables. + self.assertEqual([], self.code("tree_view = [(1, 'list')]\n")) + + def test_a_dict_view_mode_is_caught(self): + self.assertEqual( + ["py_view_mode"], + [c[3] for c in self.code("A = {'view_mode': 'tree,form'}\n")], + ) + + def test_a_view_mode_without_the_action_type_is_still_caught(self): + # Plusieurs actions réelles omettent la clé `type` et sont + # pourtant renvoyées telles quelles au client. + self.assertEqual( + ["py_view_mode"], + [ + c[3] + for c in self.code( + "A = {'name': 'x', 'view_mode': 'form,tree,kanban'}\n" + ) + ], + ) + + def test_view_type_on_an_act_window_is_a_harmless_leftover(self): + # Vestige d'Odoo 12 : la 18 ne lit que view_mode / views / view_ids + # et ignore la clé surnuméraire sans bruit. + self.assertEqual( + [], + self.code( + "A = {'type': 'ir.actions.act_window', 'view_type': 'tree'}\n" + ), + ) + + def test_view_type_anywhere_else_is_a_live_selection(self): + # code.generator.view.view_type ne propose plus que « list » : + # la valeur tree lève au post_init_hook. + self.assertEqual( + ["py_view_type"], + [ + c[3] + for c in self.code( + "A = {'view_type': 'tree', 'view_name': 'x'}\n" + ) + ], + ) + + def test_a_comparison_against_tree_is_not_a_defect(self): + # Une comparaison ne fabrique jamais un type de vue : au pire du + # code mort. La compter gonflerait le rapport de lignes que + # personne ne peut réparer isolément. + self.assertEqual( + [], + self.code( + "def f(v):\n return v != 'tree' and v in ('list', 'tree')\n" + ), + ) + + def test_a_tree_used_as_a_data_structure_is_not_a_defect(self): + self.assertEqual( + [], + self.code( + "import lxml.etree as etree\n" + "def f(d):\n return d['tree'], etree.tostring\n" + ), + ) + + def test_the_unix_command_named_tree_is_not_a_defect(self): + self.assertEqual([], self.code("cmd = 'tree'\nPKGS = ['tree']\n")) + + def test_a_file_that_does_not_parse_is_reported_apart(self): + module = self.module(init="from . import models") + self.ecrire(os.path.join(module, "models.py"), "def f(:\n") + rapport = arbre.inspect(self.racine) + self.assertEqual([], rapport["findings"]) + self.assertEqual(1, len(rapport["unreadable"])) + + +class TestTheCommand(Base): + def lancer(self, argv): + tampon = io.StringIO() + with redirect_stdout(tampon): + code = arbre.main(argv) + return code, tampon.getvalue() + + def test_a_missing_root_is_a_tool_failure(self): + code, _ = self.lancer(["--root", os.path.join(self.racine, "absent")]) + self.assertEqual(2, code) + + def test_a_clean_checkout_exits_zero(self): + self.module() + code, texte = self.lancer(["--root", self.racine, "--no-color"]) + self.assertEqual(0, code) + self.assertIn(arbre.t("No module carries it."), texte) + + def test_a_finding_exits_one(self): + module = self.module(manifeste={"data": ["v.xml"]}) + self.ecrire(os.path.join(module, "v.xml"), "") + code, _ = self.lancer(["--root", self.racine, "--no-color"]) + self.assertEqual(1, code) + + def test_below_18_it_says_so_instead_of_staying_silent(self): + self.version("17.0") + _code, texte = self.lancer(["--root", self.racine, "--no-color"]) + self.assertIn( + arbre.t("nothing to do below 18.0 — tree is valid there"), texte + ) + + def test_the_json_says_what_to_replace_it_with(self): + import json + + module = self.module(manifeste={"data": ["v.xml"]}) + self.ecrire( + os.path.join(module, "v.xml"), + "tree,form", + ) + _code, texte = self.lancer(["--root", self.racine, "--json"]) + dct = json.loads(texte) + self.assertEqual("18.0", dct["odoo"]) + self.assertEqual("list,form", dct["findings"][0]["replace_with"]) + + +class TestAgainstTheRealCheckout(unittest.TestCase): + """Sur le vrai arbre — c'est là que le tri se prouve.""" + + @classmethod + def setUpClass(cls): + cls.rapport = arbre.inspect(REPO) + + def test_it_reads_the_whole_addon_tree(self): + if not arbre.concerne(arbre.version_active(REPO)): + self.skipTest("checkout sous la 18.0") + self.assertGreater(self.rapport["scanned"], 1000) + + def test_it_finds_the_line_that_broke_a_kanban(self): + # « View types not defined tree found in act_window action 444 », + # au clic sur action_view_ticket depuis le kanban de projet. + if not arbre.concerne(arbre.version_active(REPO)): + self.skipTest("checkout sous la 18.0") + vus = [ + (f, ligne) + for _m, f, ligne, _p, _e, _c in self.rapport["findings"] + if "helpdesk_mgmt_project" in f + ] + self.assertTrue(vus, "le défaut rencontré en production n'est pas vu") + + def test_the_core_odoo_identifiers_are_not_reported(self): + # account.view_invoice_tree et ses semblables vivent dans le noyau + # 18 avec une arch en : les signaler serait ~400 lignes + # fausses, et le rapport ne serait pas relancé. + for _m, fichier, _l, _p, _e, _c in self.rapport["findings"]: + self.assertNotIn("odoo18.0/odoo/", fichier) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_code_generator_tools.py b/test/test_code_generator_tools.py index 4549d0d..7adff06 100644 --- a/test/test_code_generator_tools.py +++ b/test/test_code_generator_tools.py @@ -197,3 +197,7 @@ class TestArgsTypeParam(unittest.TestCase): def test_selection_has_selection(self): self.assertIn("selection", ARGS_TYPE_PARAM["Selection"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_database_cleanup.py b/test/test_database_cleanup.py index b6767a1..a97003e 100755 --- a/test/test_database_cleanup.py +++ b/test/test_database_cleanup.py @@ -771,15 +771,14 @@ class TestTheMigrationRunsItBeforeTheSmokeTest(unittest.TestCase): from script.todo.todo_upgrade import TodoUpgrade source = inspect.getsource(TodoUpgrade.prompt_database_cleanup) - # `run_tool` retient le verdict PUIS délègue à `run_on_terminal` : - # les deux propriétés comptent, et vérifier la seconde à la source - # évite qu'un raccourci futur reprenne l'exécuteur qui capture. self.assertIn("run_tool(", source) self.assertNotIn("todo_upgrade_execute", source) - self.assertIn( - "self.run_on_terminal(", - inspect.getsource(TodoUpgrade.run_tool), - ) + capture = inspect.getsource(TodoUpgrade.run_captured) + self.assertIn("run_captured", inspect.getsource(TodoUpgrade.run_tool)) + # Un pseudo-terminal, jamais un tube : derrière un tube + # `can_ask()` rend False et l'outil cesse de poser sa question. + self.assertIn("pty.spawn", capture) + self.assertNotIn("subprocess.PIPE", capture) def test_saying_no_cleans_nothing(self): # Le défaut ne retire pas le choix : il ne fait qu'en proposer un. @@ -798,7 +797,7 @@ class TestTheMigrationRunsItBeforeTheSmokeTest(unittest.TestCase): upgrade.lst_command_executed = [] upgrade.write_config = lambda: None lst_cmd = [] - upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0 + upgrade.run_captured = lambda cmd: lst_cmd.append(cmd) or 0 upgrade.ask_gate = lambda prompt, default="": "n" or default with contextlib.redirect_stdout(io.StringIO()): upgrade.prompt_database_cleanup("db") @@ -818,7 +817,7 @@ class TestTheMigrationRunsItBeforeTheSmokeTest(unittest.TestCase): upgrade.lst_command_executed = [] upgrade.write_config = lambda: None lst_cmd = [] - upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0 + upgrade.run_captured = lambda cmd: lst_cmd.append(cmd) or 0 upgrade.ask_gate = lambda prompt, default="": "" or default with contextlib.redirect_stdout(io.StringIO()): upgrade.prompt_database_cleanup("db") @@ -833,7 +832,7 @@ class TestTheMigrationRunsItBeforeTheSmokeTest(unittest.TestCase): upgrade.lst_command_executed = [] upgrade.write_config = lambda: None lst_cmd = [] - upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0 + upgrade.run_captured = lambda cmd: lst_cmd.append(cmd) or 0 upgrade.ask_gate = lambda prompt, default="": "y" or default upgrade.prompt_database_cleanup("db_upgrade_18") self.assertEqual(len(lst_cmd), 1) diff --git a/test/test_database_tools.py b/test/test_database_tools.py index f85675c..9797e01 100644 --- a/test/test_database_tools.py +++ b/test/test_database_tools.py @@ -144,3 +144,7 @@ class TestCompareDatabaseApplicationLogic(unittest.TestCase): s2 = {a["name"] for a in r2} self.assertEqual(s1.difference(s2), {"mod1"}) self.assertEqual(len(s2.difference(s1)), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_db_duplicate.py b/test/test_db_duplicate.py new file mode 100644 index 0000000..6fc2a9d --- /dev/null +++ b/test/test_db_duplicate.py @@ -0,0 +1,257 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Dupliquer : ce qui se refuse AVANT de créer quoi que ce soit. + +Une duplication rate de deux façons. Elle peut échouer — c'est le cas +bénin, on recommence. Ou elle peut RÉUSSIR sur la mauvaise base : écraser +une copie qui contenait autre chose, ou rendre une copie qui n'est pas +neutralisée alors qu'on l'a demandé. Ces deux-là ne se rattrapent pas, et +c'est ce que ce fichier épingle. + +La neutralisation n'existe qu'à partir d'Odoo 16 : `neutralize.py` est +absent avant, et `exp_duplicate_database` n'y prend que deux arguments. +Demander la neutralisation sous Odoo 15 doit être REFUSÉ, jamais ignoré +en silence — une copie qu'on croit neutralisée est pire qu'une copie +qu'on sait brute. +""" + +import unittest + +from script.database import db_duplicate as dup + + +class TestWhatANameMayBe(unittest.TestCase): + """Le nom entre dans du SQL par `database_identifier` : il se filtre.""" + + def test_ordinary_names_pass(self): + for nom in ("chezlepro", "el_essai", "a", "base-2024", "_interne"): + self.assertTrue(dup.nom_valide(nom), nom) + + def test_a_name_that_could_carry_sql_is_refused(self): + for nom in ('a"; DROP DATABASE x; --', "a b", "a'b", "a;b", "a(b)"): + self.assertFalse(dup.nom_valide(nom), nom) + + def test_an_empty_or_oversized_name_is_refused(self): + self.assertFalse(dup.nom_valide("")) + self.assertFalse(dup.nom_valide(None)) + self.assertFalse(dup.nom_valide("x" * 64)) + self.assertTrue(dup.nom_valide("x" * 63)) + + def test_a_name_starting_with_a_digit_is_refused(self): + self.assertFalse(dup.nom_valide("2024_base")) + + +class TestWhatIsRefusedBeforeAnythingIsCreated(unittest.TestCase): + def setUp(self): + # On ne veut pas d'un PostgreSQL réel : le sujet est la décision, + # pas la connexion. `None` = « je n'ai pas pu demander », ce qui + # doit laisser passer plutôt que d'inventer un refus. + self._vrai = dup.bases_existantes + dup.bases_existantes = lambda: {"source", "deja_la"} + self.addCleanup(setattr, dup, "bases_existantes", self._vrai) + + def test_the_same_name_twice_is_refused(self): + refus = dup.verifier("source", "source", False) + self.assertTrue(any("nom" in r or "name" in r for r in refus)) + + def test_an_unknown_source_is_refused(self): + self.assertTrue(dup.verifier("pas_la", "neuve", False)) + + def test_overwriting_an_existing_database_is_refused(self): + """Le contenu d'avant n'existerait plus nulle part.""" + refus = dup.verifier("source", "deja_la", False) + self.assertTrue(refus) + self.assertTrue(any("deja_la" in r for r in refus)) + + def test_a_plain_duplication_passes(self): + self.assertEqual(dup.verifier("source", "neuve", False), []) + + def test_when_postgres_cannot_be_asked_we_do_not_invent_a_refusal(self): + dup.bases_existantes = lambda: None + self.assertEqual(dup.verifier("source", "neuve", False), []) + + +class TestNeutralisationIsRefusedBeforeOdoo16(unittest.TestCase): + def test_the_boundary_is_sixteen(self): + self.assertFalse(dup.supporte_neutralisation("15.0")) + self.assertTrue(dup.supporte_neutralisation("16.0")) + self.assertTrue(dup.supporte_neutralisation("18.0")) + + def test_an_unreadable_version_is_treated_as_unsupported(self): + """Dans le doute, refuser : le silence serait un faux calme.""" + for version in (None, "", "quatorze"): + self.assertFalse(dup.supporte_neutralisation(version)) + + def test_an_old_checkout_falls_back_instead_of_refusing(self): + """De 12 à 15, le dépôt a sa technique : on l'emploie.""" + self._vrai = dup.bases_existantes + dup.bases_existantes = lambda: {"source"} + vraie_version = dup.lire_version + dup.lire_version = lambda nom: "15.0" + try: + refus = dup.verifier("source", "neuve", True) + technique = dup.technique_neutralisation() + finally: + dup.bases_existantes = self._vrai + dup.lire_version = vraie_version + self.assertEqual( + refus, [], "la demande a été refusée au lieu de basculer" + ) + self.assertEqual(technique, dup.NEUTRALISATION_SCRIPT) + + def test_the_route_is_chosen_by_the_version(self): + for version, attendu in ( + ("12.0", dup.NEUTRALISATION_SCRIPT), + ("15.0", dup.NEUTRALISATION_SCRIPT), + ("16.0", dup.NEUTRALISATION_ODOO), + ("18.0", dup.NEUTRALISATION_ODOO), + ): + self.assertEqual( + dup.technique_neutralisation(version), attendu, version + ) + + def test_a_missing_script_is_the_one_case_still_refused(self): + """Sinon la copie sortirait brute en se croyant neutralisée.""" + self._vrai = dup.bases_existantes + dup.bases_existantes = lambda: {"source"} + vraie_version = dup.lire_version + vrai_isfile = dup.os.path.isfile + dup.lire_version = lambda nom: "14.0" + dup.os.path.isfile = lambda chemin: False + try: + refus = dup.verifier("source", "neuve", True) + finally: + dup.bases_existantes = self._vrai + dup.lire_version = vraie_version + dup.os.path.isfile = vrai_isfile + self.assertTrue(refus) + self.assertTrue(any("update_prod_to_dev" in r for r in refus)) + + +class TestTheProgramHandedToOdoo(unittest.TestCase): + """Ce qu'on exécute, et surtout ce qu'on n'exécute pas par mégarde.""" + + def test_it_parses_the_config_before_duplicating(self): + """Sans parse_config, Registry.new échoue APRÈS avoir créé la base.""" + code = dup.script_python("a", "b", False, "config.conf") + self.assertIn("parse_config", code) + self.assertLess( + code.index("parse_config"), code.index("exp_duplicate") + ) + + def test_odoo_is_not_asked_to_neutralise_when_it_cannot(self): + """Sous 12→15, exp_duplicate_database ne prend que deux arguments : + lui en passer un troisième ferait échouer la duplication.""" + # `lire_version` sert AUSSI à composer le venv : lui faire dire + # « 14.0 » partout rendait le chemin introuvable et `dupliquer` + # sortait avant d'appeler quoi que ce soit — un test vert pour + # rien. On ne détourne donc que la version d'Odoo. + vraie = dup.lire_version + appels = [] + dup.lire_version = lambda nom: ( + "14.0" if nom == ".odoo-version" else vraie(nom) + ) + vrais_chemins = dup.chemins_odoo + dup.chemins_odoo = lambda: ("/usr/bin/python3", "/tmp") + vrai_run = dup.subprocess.run + + class Faux: + returncode = 0 + stdout = "" + stderr = "" + + def espion(cmd, **kw): + appels.append(cmd) + return Faux() + + dup.subprocess.run = espion + try: + dup.dupliquer("a", "b", neutraliser=True) + finally: + dup.lire_version = vraie + dup.chemins_odoo = vrais_chemins + dup.subprocess.run = vrai_run + programme = [c for c in appels if "-c" in c] + self.assertTrue(programme) + self.assertNotIn("True", programme[0][-1]) + # …et le script du dépôt a bien été lancé ensuite. + self.assertTrue( + any( + dup.SCRIPT_PROD_TO_DEV in " ".join(map(str, c)) for c in appels + ) + ) + + def test_a_failing_script_makes_the_whole_thing_fail(self): + """Le cas vraiment dangereux : la copie existe, le script a + échoué, et l'on annoncerait « neutralisée ». Avaler ce code de + retour rendrait une base brute indiscernable d'une base traitée. + """ + vraie = dup.lire_version + dup.lire_version = lambda nom: ( + "14.0" if nom == ".odoo-version" else vraie(nom) + ) + vrais_chemins = dup.chemins_odoo + dup.chemins_odoo = lambda: ("/usr/bin/python3", "/tmp") + vrai_run = dup.subprocess.run + + class Reponse: + def __init__(self, code, sortie=""): + self.returncode = code + self.stdout = sortie + self.stderr = sortie + + def espion(cmd, **kw): + # La duplication réussit, le script échoue. + if dup.SCRIPT_PROD_TO_DEV in " ".join(map(str, cmd)): + return Reponse(1, "install_addons_dev.sh a échoué") + return Reponse(0) + + dup.subprocess.run = espion + try: + code, sortie = dup.dupliquer("a", "b", neutraliser=True) + finally: + dup.lire_version = vraie + dup.chemins_odoo = vrais_chemins + dup.subprocess.run = vrai_run + self.assertNotEqual(code, 0, "l'échec du script a été avalé") + self.assertIn("install_addons_dev", sortie) + + def test_neutralisation_is_passed_only_when_asked(self): + self.assertIn("'a', 'b', True", dup.script_python("a", "b", True, "c")) + self.assertIn("'a', 'b')", dup.script_python("a", "b", False, "c")) + + def test_the_names_travel_as_python_literals(self): + """Ils sont interpolés dans du code : `repr` et rien d'autre.""" + code = dup.script_python("a'b", "c", False, "config.conf") + self.assertIn(repr("a'b"), code) + + def test_the_odoo_path_is_read_from_the_version_files(self): + """Composer le nom de tête, c'est se tromper un jour sur l'une + des deux versions qu'il porte.""" + import inspect + + source = inspect.getsource(dup.chemins_odoo) + self.assertIn(".erplibre-version", source) + self.assertIn(".odoo-version", source) + + +class TestTheContractOfTheCommandLine(unittest.TestCase): + def test_a_refusal_exits_two_and_creates_nothing(self): + appels = [] + vrai = dup.dupliquer + dup.dupliquer = lambda *a, **k: appels.append(a) or (0, "") + vraies_bases = dup.bases_existantes + dup.bases_existantes = lambda: {"source", "deja_la"} + try: + code = dup.main(["-s", "source", "-d", "deja_la"]) + finally: + dup.dupliquer = vrai + dup.bases_existantes = vraies_bases + self.assertEqual(code, 2) + self.assertEqual(appels, [], "la duplication a été tentée quand même") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_docker_update_version.py b/test/test_docker_update_version.py index 2df796b..fe47931 100644 --- a/test/test_docker_update_version.py +++ b/test/test_docker_update_version.py @@ -119,3 +119,7 @@ class TestEditDockerProd(unittest.TestCase): for line in from_lines: self.assertIn("base:v3", line) os.unlink(path) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_error_retry_loop.py b/test/test_error_retry_loop.py index 4729f13..3613bea 100644 --- a/test/test_error_retry_loop.py +++ b/test/test_error_retry_loop.py @@ -85,7 +85,7 @@ class Harness(unittest.TestCase): obj.prompt_reset_stale_cow_views = faux_reset obj.check_stale_cow_views = lambda db: None - obj.run_on_terminal = lambda cmd: 0 + obj.run_captured = lambda cmd: 0 reponses = iter(lst_answer or []) def faux_ask(prompt, default=""): @@ -233,7 +233,7 @@ class TestTheResetReportsWhatItDid(unittest.TestCase): obj.write_config = lambda: None obj.stale_cow_keys = lambda db: ["web.layout"] obj.ask_gate = lambda prompt, default="": default - obj.run_on_terminal = lambda cmd: 0 + obj.run_captured = lambda cmd: 0 with redirect_stdout(io.StringIO()): self.assertTrue(obj.prompt_reset_stale_cow_views("db")) @@ -245,7 +245,7 @@ class TestTheResetReportsWhatItDid(unittest.TestCase): obj.write_config = lambda: None obj.stale_cow_keys = lambda db: ["web.layout"] obj.ask_gate = lambda prompt, default="": default - obj.run_on_terminal = lambda cmd: 2 + obj.run_captured = lambda cmd: 2 with redirect_stdout(io.StringIO()): self.assertFalse(obj.prompt_reset_stale_cow_views("db")) diff --git a/test/test_fix_account_root.py b/test/test_fix_account_root.py new file mode 100644 index 0000000..498e50b --- /dev/null +++ b/test/test_fix_account_root.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Retirer la vue morte account_root, et seulement quand c'est elle. + +Odoo 17 créait une VUE SQL pour le modèle `account.root` ; la 18 l'a +orphelinée sans la retirer — son modèle porte `_auto = False` et +`_table_query = '0'`, donc son nom n'entre plus dans aucune requête, et +registry.py exclut ces modèles du contrôle des tables manquantes. + +Elle n'est pas seulement morte, elle est FAUSSE : bâtie sur la colonne +`code` que l'ORM 18 n'écrit plus, elle rendait 14 racines là où la donnée +vivante en portait 31. Mesuré sur une migration 12 → 18 réelle. + +Et elle est l'unique épingle de deux colonnes héritées : database_cleanup +a purgé 110 colonnes orphelines au palier 18 et n'a échoué que sur +account_account.code et .company_id, dont la vue dépend. + +Ce que ce fichier garde, c'est la PRUDENCE du geste. On supprime une vue +sur une base client : les quatre refus comptent autant que la suppression. +""" + +import io +import os +import subprocess +import unittest + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +FIX = os.path.join( + REPO, "script", "odoo", "migration", "fix_migration_odoo170_to_odoo180.sql" +) +BASE = "_erplibre_test_account_root" + + +def psql_disponible(): + try: + done = subprocess.run( + ["psql", "-X", "-w", "-l"], capture_output=True, timeout=20 + ) + return done.returncode == 0 + except (OSError, subprocess.SubprocessError): + return False + + +@unittest.skipUnless(psql_disponible(), "pas de PostgreSQL joignable") +class Base(unittest.TestCase): + """Une base jetable, remontée à neuf pour chaque cas.""" + + @classmethod + def setUpClass(cls): + subprocess.run(["dropdb", "--if-exists", BASE], capture_output=True) + done = subprocess.run(["createdb", BASE], capture_output=True) + if done.returncode: + raise unittest.SkipTest( + "createdb refusé : " + done.stderr.decode() + ) + + @classmethod + def tearDownClass(cls): + subprocess.run(["dropdb", "--if-exists", BASE], capture_output=True) + + def setUp(self): + self.sql( + "DROP VIEW IF EXISTS autre_vue", + "DROP VIEW IF EXISTS account_root", + "DROP TABLE IF EXISTS account_root", + "DROP TABLE IF EXISTS account_account", + ) + + def sql(self, *ordres): + argv = ["psql", "-X", "-w", "-q", "-d", BASE] + for ordre in ordres: + argv += ["-c", ordre] + done = subprocess.run(argv, capture_output=True, text=True) + self.assertEqual(0, done.returncode, done.stderr) + return done + + def lire(self, requete): + done = subprocess.run( + ["psql", "-X", "-w", "-tA", "-d", BASE, "-c", requete], + capture_output=True, + text=True, + ) + self.assertEqual(0, done.returncode, done.stderr) + return done.stdout.strip() + + def vue_presente(self): + return ( + self.lire( + "SELECT (to_regclass('public.account_root') IS NOT NULL)::text" + ) + == "true" + ) + + def appliquer(self): + """Le fichier tel que la migration le lance : ON_ERROR_STOP=1.""" + done = subprocess.run( + [ + "psql", + "-X", + "-w", + "-q", + "-v", + "ON_ERROR_STOP=1", + "-d", + BASE, + "-f", + FIX, + ], + capture_output=True, + text=True, + ) + return done.returncode, done.stderr + + def poser_la_vue_morte(self): + self.sql( + "CREATE TABLE account_account (" + " id serial PRIMARY KEY, code varchar, company_id int)", + "CREATE VIEW account_root AS SELECT DISTINCT" + " ascii(code::text) AS id, left(code::text, 2) AS name," + " company_id FROM account_account WHERE code::text <> ''", + ) + + +class TestItRemovesTheDeadView(Base): + def test_the_view_is_gone_and_psql_is_happy(self): + self.poser_la_vue_morte() + self.assertTrue(self.vue_presente()) + code, _err = self.appliquer() + self.assertEqual(0, code) + self.assertFalse(self.vue_presente()) + + def test_it_says_what_it_did(self): + # Une suppression silencieuse sur une base client est une + # suppression qu'on ne retrouve pas six mois plus tard. + self.poser_la_vue_morte() + _code, err = self.appliquer() + self.assertIn("account_root supprimee", err) + + def test_running_it_twice_changes_nothing(self): + # Le fichier est rejoué à chaque reprise de migration. + self.poser_la_vue_morte() + self.appliquer() + code, err = self.appliquer() + self.assertEqual(0, code) + self.assertNotIn("NOTICE", err) + + +class TestWhatItRefusesToTouch(Base): + """Les refus comptent autant que la suppression.""" + + def test_a_view_that_does_not_read_code_is_left_alone(self): + # Si une version future d'Odoo recrée un account_root d'une autre + # forme, il ne nous appartient pas. Le contrôle est STRUCTUREL — + # pg_depend — et non un examen du texte de la définition. + self.sql( + "CREATE TABLE account_account (" + " id serial PRIMARY KEY, code varchar, code_store jsonb)", + "CREATE VIEW account_root AS SELECT id, code_store" + " FROM account_account", + ) + code, err = self.appliquer() + self.assertEqual(0, code) + self.assertTrue(self.vue_presente()) + self.assertIn("ne lit pas account_account.code", err) + + def test_a_view_something_depends_on_is_left_alone(self): + # Apprendre plutôt que détruire : on le dit, et on passe. Jamais + # de CASCADE. + self.poser_la_vue_morte() + self.sql("CREATE VIEW autre_vue AS SELECT * FROM account_root") + code, err = self.appliquer() + self.assertEqual(0, code) + self.assertTrue(self.vue_presente()) + self.assertIn("1 objet(s) en dependent", err) + + def test_a_table_named_account_root_is_never_dropped(self): + # relkind = 'v' : une TABLE de ce nom porterait des données. + self.sql( + "CREATE TABLE account_account (" + " id serial PRIMARY KEY, code varchar)", + "CREATE TABLE account_root (id int, name varchar)", + ) + code, _err = self.appliquer() + self.assertEqual(0, code) + self.assertEqual( + "r", + self.lire( + "SELECT relkind FROM pg_class" + " WHERE relname = 'account_root'" + ), + ) + + def test_a_database_without_account_stays_silent(self): + # Le module account n'est pas installé partout, et le fichier + # tourne sur toutes les bases. + code, err = self.appliquer() + self.assertEqual(0, code) + self.assertNotIn("account_root", err) + + +class TestItNeverTouchesTheColumns(unittest.TestCase): + """Le post-migration d'OpenUpgrade les lit APRÈS ce fichier. + + Il remplit code_store depuis `code` et company_ids depuis `company_id`. + Les retirer ici casserait les deux. Leur suppression revient à + database_cleanup, une fois la base chargée en 18. + """ + + def sans_commentaire(self): + """Le fichier dépouillé de sa prose. + + Les gardes portent sur le CODE : les commentaires disent justement + « on ne supprime pas les colonnes » et « jamais de CASCADE », et + les lire suffisait à faire échouer les tests qui l'exigent. + """ + with io.open(FIX, encoding="utf-8") as handle: + source = handle.read() + return "\n".join( + ligne + for ligne in source.split("\n") + if not ligne.strip().startswith("--") + ) + + def test_the_file_carries_no_drop_column(self): + self.assertNotIn("DROP COLUMN", self.sans_commentaire().upper()) + + def test_the_only_drop_is_the_view(self): + drops = [ + ligne.strip() + for ligne in self.sans_commentaire().split("\n") + if "DROP " in ligne.upper() + ] + self.assertEqual(["DROP VIEW public.account_root;"], drops) + + def test_it_never_uses_cascade(self): + self.assertNotIn("CASCADE", self.sans_commentaire().upper()) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_format_file_to_commit.py b/test/test_format_file_to_commit.py index fae5593..db5f51c 100644 --- a/test/test_format_file_to_commit.py +++ b/test/test_format_file_to_commit.py @@ -138,3 +138,7 @@ class TestGetModifiedFiles(unittest.TestCase): mock_run.side_effect = subprocess.CalledProcessError(1, "git") files = get_modified_files() self.assertIsNone(files) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_git_commit_msg.py b/test/test_git_commit_msg.py new file mode 100644 index 0000000..0cd7d62 --- /dev/null +++ b/test/test_git_commit_msg.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Le garde-fou du sujet de commit refuse-t-il ce qu'il doit, et rien d'autre ? + +Un hook qui refuse trop est désinstallé dans la semaine. Ces tests pèsent donc +autant les refus que les acceptations : un « Merge branch » que git écrit +lui-même, un sujet français de 70 caractères dont les accents pèsent 2 octets, +un fixup de rebase — tout cela doit passer. + +La part de la convention qu'aucun hook ne juge — « ce sujet dit-il sur quoi +porte le code » — n'est pas testée ici parce qu'elle n'est pas vérifiable. +""" +import os +import subprocess +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "script", "git")) + +from commit_msg_lib import MAX, check, subject_of # noqa: E402 + +HOOK = os.path.join( + os.path.dirname(__file__), "..", "script", "git", "hooks", "commit-msg" +) + + +class TestCeQuiPasse(unittest.TestCase): + def test_un_sujet_conforme(self): + self.assertEqual([], check("[FIX] proxmox : signaler pmxcfs à terre")) + + def test_les_huit_tags_de_la_convention(self): + for tag in ("ADD", "FIX", "UPD", "IMP", "REF", "REM", "MOV", "I18N"): + self.assertEqual([], check(f"[{tag}] portée : quelque chose"), tag) + + def test_un_sujet_de_72_caracteres_pile(self): + sujet = "[FIX] portée : " + "a" * (MAX - len("[FIX] portée : ")) + self.assertEqual(MAX, len(sujet)) + self.assertEqual([], check(sujet)) + + def test_les_accents_comptent_pour_un_caractere(self): + """« é » pèse 2 octets : une limite en octets refuserait ce sujet.""" + sujet = "[FIX] déploiement : " + "é" * (MAX - len("[FIX] déploiement : ")) + self.assertEqual(MAX, len(sujet)) + self.assertGreater(len(sujet.encode("utf-8")), MAX) + self.assertEqual([], check(sujet)) + + def test_ce_que_git_ecrit_lui_meme(self): + for genere in ( + "Merge branch 'develop' into master", + "Merge remote-tracking branch 'origin/develop'", + "Revert \"[FIX] portée : quelque chose\"", + "fixup! [FIX] portée : quelque chose", + "squash! [FIX] portée : quelque chose", + ): + self.assertEqual([], check(genere), genere) + + def test_un_message_vide_ou_commente(self): + """Un commit abandonné : git s'en occupe, le hook ne s'en mêle pas.""" + self.assertEqual([], check("")) + self.assertEqual([], check("# Please enter the commit message\n#\n")) + + def test_le_sujet_est_la_premiere_ligne_utile(self): + self.assertEqual( + "[FIX] portée : sujet", + subject_of("#\n# commentaire\n\n[FIX] portée : sujet\n\ncorps\n"), + ) + + +class TestCeQuiEstRefuse(unittest.TestCase): + def test_un_sujet_sans_tag(self): + problemes = check("corriger le pont NAT") + self.assertEqual(1, len(problemes)) + self.assertIn("tag", problemes[0]) + + def test_un_tag_inconnu(self): + self.assertTrue(check("[WIP] portée : quelque chose")) + + def test_un_sujet_de_73_caracteres(self): + sujet = "[FIX] portée : " + "a" * (MAX + 1 - len("[FIX] portée : ")) + self.assertEqual(MAX + 1, len(sujet)) + problemes = check(sujet) + self.assertEqual(1, len(problemes)) + self.assertIn("73 caractères", problemes[0]) + + def test_le_message_de_longueur_enseigne_les_mots_cles(self): + """Tronquer est le réflexe et c'est le mauvais : le hook doit le dire.""" + probleme = check("[FIX] portée : " + "a" * 80)[0] + self.assertIn("MOTS-CLÉS", probleme) + self.assertIn("Ne le tronquez pas", probleme) + + def test_un_sujet_qui_ouvre_sur_une_citation(self): + for ouvrant in ("«", '"', "'", "`", "“"): + problemes = check(f"[FIX] proxmox : {ouvrant}il manque le stockage{ouvrant}") + self.assertEqual(1, len(problemes), ouvrant) + self.assertIn("citation", problemes[0]) + + def test_une_citation_ailleurs_dans_le_sujet_passe(self): + """Seule l'OUVERTURE est refusée : citer en fin de sujet reste permis.""" + self.assertEqual( + [], check("[FIX] proxmox : pmxcfs à terre, dit « aucun stockage »") + ) + + def test_deux_problemes_sont_rapportes_ensemble(self): + problemes = check("proxmox : « un sujet sans tag et beaucoup trop long » " + "a" * 40) + self.assertEqual(3, len(problemes)) + + +class TestLeHookLuiMeme(unittest.TestCase): + """Le module peut être juste et le hook faux : on l'exécute vraiment.""" + + def _lancer(self, message): + with tempfile.NamedTemporaryFile( + "w", suffix=".txt", delete=False, encoding="utf-8" + ) as fh: + fh.write(message) + chemin = fh.name + try: + return subprocess.run( + [sys.executable, HOOK, chemin], + capture_output=True, + text=True, + ) + finally: + os.unlink(chemin) + + def test_il_sort_en_zero_sur_un_sujet_conforme(self): + r = self._lancer("[FIX] portée : quelque chose\n") + self.assertEqual(0, r.returncode, r.stderr) + + def test_il_sort_en_un_et_dit_pourquoi(self): + r = self._lancer("[FIX] portée : " + "a" * 80 + "\n") + self.assertEqual(1, r.returncode) + self.assertIn("hors convention", r.stderr) + self.assertIn("MOTS-CLÉS", r.stderr) + + def test_il_nomme_le_contournement(self): + """Sans issue annoncée, un hook se contourne en le supprimant.""" + r = self._lancer("pas de tag\n") + self.assertIn("--no-verify", r.stderr) + + def test_sans_argument_il_echoue_proprement(self): + r = subprocess.run( + [sys.executable, HOOK], capture_output=True, text=True + ) + self.assertEqual(1, r.returncode) + self.assertIn("aucun fichier", r.stderr) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/test/test_iscompatible.py b/test/test_iscompatible.py index cf605cb..6061287 100644 --- a/test/test_iscompatible.py +++ b/test/test_iscompatible.py @@ -92,3 +92,7 @@ class TestIsCompatible(unittest.TestCase): def test_not_equal_false(self): self.assertFalse(iscompatible("foo!=1.0.0", Version("1.0.0"))) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_mail_imap_transport.py b/test/test_mail_imap_transport.py index 603c5ba..1633a53 100644 --- a/test/test_mail_imap_transport.py +++ b/test/test_mail_imap_transport.py @@ -434,3 +434,7 @@ class TestConnect(unittest.TestCase): with patch("imaplib.IMAP4_SSL", side_effect=OSError("injoignable")): with self.assertRaises(ImapError): connect(self._account("ssl"), "hunter2") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_migration_status.py b/test/test_migration_status.py index 62c486c..1cbcfc1 100644 --- a/test/test_migration_status.py +++ b/test/test_migration_status.py @@ -1548,6 +1548,41 @@ class TestNothingOnThatScreenCanHang(Base): self.assertEqual(TodoUpgrade.ask_ui(), "tui") +class TestWhatTheExitCodeMeans(Base): + """0 rien à signaler, 1 des trouvailles, 2 l'outil a échoué. + + La convention est écrite dans todo_upgrade.run_tool, et elle n'est pas + décorative : `database_cleanup` imprime lui-même « This is a warning, + not a failure » avant de rendre 1. Un écran qui peint ce 1 en rouge + contredit l'outil, et l'on finit par ignorer les deux. + """ + + def test_zero_is_green(self): + self.assertEqual(("✅", "ok"), status.verdict_mark(0)) + + def test_one_is_a_finding_not_a_failure(self): + icone, teinte = status.verdict_mark(1) + self.assertEqual("⚠", icone) + self.assertEqual("warn", teinte) + self.assertNotEqual("fail", teinte) + + def test_two_is_the_tool_itself_failing(self): + self.assertEqual(("❌", "fail"), status.verdict_mark(2)) + + def test_an_unknown_code_is_treated_as_a_failure(self): + # Un code qu'on ne sait pas lire ne doit pas passer pour un succès. + for inconnu in (3, 127, -1, "oui", None, ""): + icone, teinte = status.verdict_mark(inconnu) + if inconnu in (None, ""): + self.assertEqual("ok", teinte, inconnu) + else: + self.assertEqual("fail", teinte, inconnu) + + def test_the_icon_and_the_colour_cannot_drift(self): + # Elles vivent au même endroit précisément pour cela. + self.assertEqual(set(status.VERDICT_ICON), set(status.VERDICT_COLOUR)) + + class TestWhatGetsRecorded(Base): def upgrade(self): obj = TodoUpgrade.__new__(TodoUpgrade) @@ -1559,7 +1594,7 @@ class TestWhatGetsRecorded(Base): def test_a_tool_run_keeps_its_verdict(self): obj = self.upgrade() - obj.run_on_terminal = lambda cmd: 1 + obj.run_captured = lambda cmd: 1 self.assertEqual(obj.run_tool("smoke_public_url", "cmd"), 1) event = obj.dct_progression["lst_event"][0] self.assertEqual(event["kind"], "test") diff --git a/test/test_monitoring.py b/test/test_monitoring.py index eb6e9a7..ab3dacc 100644 --- a/test/test_monitoring.py +++ b/test/test_monitoring.py @@ -19,13 +19,13 @@ le DIRE. Une analyse muette qu'on prend pour rassurante est pire que pas d'analyse : c'est la faute que ce dépôt a déjà corrigée trois fois. """ +import io import os import sys import unittest -sys.path.insert( - 0, os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) -) +REPO = os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) +sys.path.insert(0, REPO) from script.analyse import check_migration_residue as residue # noqa: E402 from script.analyse import monitoring # noqa: E402 @@ -189,9 +189,9 @@ class TestWhatAMigrationLeftBehind(unittest.TestCase): def test_broken_is_read_before_watch(self): resultats = {c["key"]: 0 for c in residue.CONTROLES} resultats["duplicate_index"] = 400 # watch - resultats["lang_active_null"] = 1 # broken + resultats["stuck_modules"] = 1 # broken trouve, _ = residue.judge(resultats) - self.assertEqual(trouve[0][0]["key"], "lang_active_null") + self.assertEqual(trouve[0][0]["key"], "stuck_modules") def test_a_check_that_could_not_run_is_not_a_check_that_found_nothing( self, @@ -246,14 +246,10 @@ class TestWhatAMigrationLeftBehind(unittest.TestCase): def test_the_exit_code_separates_nothing_from_something(self): vide = {c["key"]: 0 for c in residue.CONTROLES} self.assertEqual(residue.judge(vide)[0], []) - plein = dict(vide, lang_active_null=3) + plein = dict(vide, stuck_modules=3) self.assertTrue(residue.judge(plein)[0]) -if __name__ == "__main__": - unittest.main() - - class TestTheScreenAnswersKeys(unittest.TestCase): """Presser les touches, et pas seulement lire les fonctions pures. @@ -346,3 +342,414 @@ class TestTheSourceMenuIsWrittenTwice(unittest.TestCase): """La provenance la plus directe, et l'ordre du menu d'à côté.""" self.assertEqual(self.affiches[0], "1") self.assertIn("A local database", self.corps.split("if answer")[0]) + + +class TestAMissingPricelistOnlyCountsWhenTheFeatureIsOn(unittest.TestCase): + """Une liste de prix absente n'est un défaut que si on l'a demandée. + + Mesuré : six utilisateurs étaient membres DIRECTS de + `product.group_product_pricelist` — hérité d'un palier de migration — + alors que `base.group_user` ne l'impliquait pas. La case des réglages + était donc décochée, et la réparation créait quand même une liste. + Odoo prévenait ensuite à chaque ouverture des réglages qu'il allait + l'archiver. + + `res.config.settings` lit ce que `base.group_user` IMPLIQUE ; le + contrôle pose désormais la même question. + """ + + def _controle(self): + for controle in residue.CONTROLES: + if controle["key"] == "missing_pricelist": + return controle + raise AssertionError("contrôle introuvable") + + def test_it_asks_whether_the_feature_is_implied(self): + sql = " ".join(self._controle()["sql"].split()) + self.assertIn("res_groups_implied_rel", sql) + self.assertIn("group_product_pricelist", sql) + self.assertIn("group_user", sql) + + def test_it_does_not_settle_for_direct_membership(self): + """`res_groups_users_rel` dirait « quelqu'un est dans le groupe », + ce qui était vrai et menait au faux positif.""" + self.assertNotIn("res_groups_users_rel", self._controle()["sql"]) + + def test_it_still_requires_the_module_and_the_table(self): + sql = " ".join(self._controle()["sql"].split()) + self.assertIn("ir_module_module", sql) + self.assertIn("to_regclass", sql) + + +class TestTheRepairAsksItsOwnDetector(unittest.TestCase): + """Une réparation qui n'écoute pas son détecteur fabrique des doublons. + + Mesuré sur une migration de bout en bout : la liste de prix avait + traversé les six paliers, PARTAGÉE entre sociétés (company_id vide). + `_activate_or_create_pricelists` ne compte pas une liste partagée + comme appartenant à la société — elle en a donc créé une seconde, + vide, à côté de celle du client. + + Le détecteur `pricelist_missing`, lui, disait déjà « rien ne manque ». + Il fallait que la réparation le lui demande. + """ + + def _source(self): + from pathlib import Path + + chemin = ( + Path(__file__).resolve().parent.parent + / "script" + / "odoo" + / "migration" + / "restore_config_defaults.py" + ) + return chemin.read_text(encoding="utf-8") + + def _garde(self): + """Le texte qui précède l'APPEL, pas sa mention dans la docstring. + + `index` trouvait la PREMIÈRE occurrence — celle de l'en-tête du + module, qui explique justement ce que fait cette méthode. Le test + s'évaluait alors sur un extrait de prose et échouait. `rindex` + prend la dernière, qui est l'appel. + """ + source = self._source() + debut = source.rindex("_activate_or_create_pricelists()") + return source[max(0, debut - 400) : debut] + + def test_the_repair_is_gated_on_an_empty_count(self): + self.assertIn("pricelist_before", self._garde()) + + def test_the_feature_condition_is_still_there(self): + self.assertIn("pricelist_group", self._garde()) + + def test_the_feature_is_read_from_the_implication(self): + """Et non de l'appartenance de celui qui exécute.""" + source = self._source() + self.assertIn("implied_ids", source) + self.assertNotIn( + 'env.user.has_group(\n "product.group_product_pricelist"', + source, + ) + + +def verdict(**champs): + """Un événement du journal de progression, forme réelle.""" + brut = { + "at": "2026-08-26 03:19:59.846453", + "step": "4.1.I - Migrate database", + "kind": "test", + "name": "smoke_public_url", + "status": 1, + "detail": ".venv.erplibre/bin/python3" + " ./script/odoo/migration/smoke_public_url.py" + " -d test_neutralize_upgrade_14 --internal-required", + } + brut.update(champs) + return brut + + +class TestTheStepLogInTheReport(unittest.TestCase): + """La commande seule ne dit pas POURQUOI.""" + + def setUp(self): + import json + import shutil + import tempfile + + self.dossier = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.dossier) + self.json = json + self.progression = os.path.join(self.dossier, "progression.json") + # `step_log_path` cherche sous private/odoo/migration//step_log + self.ancien = residue.status.PATH_MIGRATION_PRIVATE + residue.status.PATH_MIGRATION_PRIVATE = os.path.join( + self.dossier, "private" + ) + self.addCleanup( + setattr, + residue.status, + "PATH_MIGRATION_PRIVATE", + self.ancien, + ) + + def ecrire_progression(self, evenements): + with io.open(self.progression, "w", encoding="utf-8") as handle: + self.json.dump( + { + "lst_event": evenements, + "config_database_name": "test_neutralize", + "target_odoo_version": "18.0", + "state_4_upgrade_odoo_lst": [1, 2, 3, 4, 5, 6], + }, + handle, + ) + return self.progression + + def ecrire_journal(self, step, lignes): + dossier = os.path.join( + residue.status.PATH_MIGRATION_PRIVATE, + "test_neutralize", + "step_log", + ) + os.makedirs(dossier, exist_ok=True) + chemin = os.path.join(dossier, residue.status.step_slug(step) + ".log") + with io.open(chemin, "w", encoding="utf-8") as handle: + handle.write("\n".join(lignes) + "\n") + return chemin + + def bloc(self, **kw): + return "\n".join( + residue.verdicts_block( + "test_neutralize_upgrade_14", False, self.progression, **kw + ) + ) + + def test_the_passage_around_the_failure_is_shown(self): + self.ecrire_progression([verdict()]) + self.ecrire_journal( + "4.1.I - Migrate database", + [ + "odoo: ce qui precedait", + "[2026-08-26 03:19:44.166204] $ .venv.erplibre/bin/python3" + " ./script/odoo/migration/smoke_public_url.py" + " -d test_neutralize_upgrade_14 --internal-required", + "[2026-08-26 03:19:59.847489] [test] smoke_public_url -> 1", + "odoo: ce qui suivait", + ], + ) + texte = self.bloc() + self.assertIn("odoo: ce qui precedait", texte) + self.assertIn("[test] smoke_public_url -> 1", texte) + self.assertNotIn("odoo: ce qui suivait", texte) + + def test_the_log_path_is_named(self): + self.ecrire_progression([verdict()]) + chemin = self.ecrire_journal( + "4.1.I - Migrate database", + [ + "[2026-08-26 03:19:44.166204] $ ./script/odoo/migration/" + "smoke_public_url.py -d test_neutralize_upgrade_14", + "[2026-08-26 03:19:59.847489] [test] smoke_public_url -> 1", + ], + ) + self.assertIn(chemin, self.bloc()) + + def test_no_step_log_stays_silent(self): + # Une ligne « pas de journal » par verdict noierait ceux qui + # comptent. + self.ecrire_progression([verdict()]) + texte = self.bloc() + self.assertIn("smoke_public_url", texte) + self.assertNotIn("step_log", texte) + + def test_asking_for_none_shows_none(self): + self.ecrire_progression([verdict()]) + self.ecrire_journal( + "4.1.I - Migrate database", + [ + "[2026-08-26 03:19:44.166204] $ ./script/odoo/migration/" + "smoke_public_url.py -d test_neutralize_upgrade_14", + "[2026-08-26 03:19:59.847489] [test] smoke_public_url -> 1", + ], + ) + self.assertNotIn("[test]", self.bloc(lignes_avant=0)) + + def test_the_report_stops_saying_it_once_the_output_is_captured(self): + # Depuis que le pilote capture par pseudo-terminal, la sortie est + # dans le journal : le dire encore serait faux. + self.ecrire_progression([verdict()]) + self.ecrire_journal( + "4.1.I - Migrate database", + [ + "[2026-08-26 03:19:44.166204] $ .venv.erplibre/bin/python3" + " ./script/odoo/migration/smoke_public_url.py" + " -d test_neutralize_upgrade_14 --internal-required", + "❌ 3 URL sur 37 rendent 500", + "[2026-08-26 03:19:59.847489] [test] smoke_public_url -> 1", + ], + ) + texte = self.bloc() + self.assertIn("3 URL sur 37", texte) + self.assertNotIn( + residue.t("the tool output is not in the step log: it goes"), + texte, + ) + + def test_it_says_the_tool_output_is_elsewhere(self): + # Sans cela on cherche dans le journal une sortie qui n'y a + # jamais été écrite. + self.ecrire_progression([verdict()]) + self.assertIn( + residue.t("the tool output is not in the step log: it goes"), + self.bloc(), + ) + + def test_an_all_green_run_does_not_carry_that_warning(self): + self.ecrire_progression([verdict(status=0)]) + self.assertNotIn( + residue.t("the tool output is not in the step log: it goes"), + self.bloc(), + ) + + def test_the_step_shown_is_the_odoo_version(self): + # « 4.1.I » est le compteur du pilote ; la migration en est au 14. + self.ecrire_progression([verdict()]) + texte = self.bloc() + self.assertIn("14 smoke_public_url", texte) + + +class TestTheControlsThemselves(unittest.TestCase): + """Un contrôle retiré ne doit pas survivre dans les tests. + + « res_lang.active à NULL » a vécu ici avec la gravité « broken », et + c'était un faux constat : Odoo écrit lui-même ce NULL, faute de défaut + sur `active = fields.Boolean()`. Deux tests le nommaient encore après + son retrait — ils passaient, en construisant un dictionnaire avec une + clé inconnue de personne. + """ + + def test_every_key_a_test_names_still_exists(self): + import re + + connues = {c["key"] for c in residue.CONTROLES} + with io.open(__file__, encoding="utf-8") as handle: + source = handle.read() + nommees = set(re.findall(r'resultats\["([a-z_]+)"\]', source)) + nommees |= set(re.findall(r"dict\(vide, ([a-z_]+)=", source)) + self.assertTrue(nommees) + self.assertEqual(set(), nommees - connues) + + def test_no_control_promises_a_repair_tool_that_is_missing(self): + import os + + for controle in residue.CONTROLES: + morceaux = (controle["repair"] or "").split() + chemin = morceaux[0] if morceaux else "" + if chemin.startswith("script/"): + self.assertTrue( + os.path.isfile(os.path.join(REPO, chemin)), + f"{controle['key']} → {chemin}", + ) + + +class TestTheVerdictsSection(unittest.TestCase): + """Ce que les contrôles SQL ne peuvent structurellement pas voir. + + Un test de fumée qui échoue n'écrit rien en base : rien n'est cassé, + la requête suivante répond. Aucun contrôle lisant la base ne le + retrouvera jamais — et c'était la moitié de ce qu'une migration peut + rater. + """ + + def setUp(self): + import json + import shutil + import tempfile + + self.dossier = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.dossier) + self.chemin = os.path.join(self.dossier, "progression.json") + self.json = json + + def ecrire(self, evenements): + with io.open(self.chemin, "w", encoding="utf-8") as handle: + self.json.dump({"lst_event": evenements}, handle) + return self.chemin + + def test_two_steps_of_one_migration_are_the_same_lineage(self): + # Interroger la base 18 doit montrer l'échec du palier 14 : c'est + # le seul endroit où il subsiste. + self.assertEqual( + residue.famille("test_neutralize_upgrade_14"), + residue.famille("test_neutralize_upgrade_18"), + ) + + def test_a_plain_name_is_its_own_lineage(self): + self.assertEqual("copy_chezlepro3", residue.famille("copy_chezlepro3")) + + def test_another_migration_verdicts_are_not_shown(self): + # Deux migrations partagent le fichier. Attribuer l'échec de + # l'une à l'autre enverrait chercher une panne qui n'existe pas. + chemin = self.ecrire([verdict()]) + _tous, ratés = residue.verdicts("autre_client_upgrade_18", chemin) + self.assertEqual([], ratés) + + def test_the_failure_of_an_earlier_step_is_shown(self): + chemin = self.ecrire([verdict()]) + _tous, ratés = residue.verdicts("test_neutralize_upgrade_18", chemin) + self.assertEqual(["smoke_public_url"], [e["name"] for e in ratés]) + + def test_a_step_is_named_by_its_odoo_version(self): + chemin = self.ecrire([verdict()]) + texte = "\n".join( + residue.verdicts_block("test_neutralize_upgrade_18", False, chemin) + ) + self.assertIn("14", texte) + self.assertIn("smoke_public_url", texte) + + def test_no_file_says_nothing_at_all(self): + # Devant la sauvegarde d'un client, il n'y a jamais eu de + # migration locale : annoncer l'absence d'un fichier qu'on + # n'attendait pas ne renseigne personne. + absent = os.path.join(self.dossier, "jamais_ecrit.json") + self.assertEqual([], residue.verdicts_block("base", False, absent)) + + def test_all_green_is_stated_not_left_silent(self): + chemin = self.ecrire([verdict(status=0), verdict(status=0)]) + texte = "\n".join( + residue.verdicts_block("test_neutralize_upgrade_18", False, chemin) + ) + self.assertIn("2", texte) + self.assertIn(residue.t("checks, all passed"), texte) + + def test_the_report_says_these_are_not_from_the_database(self): + # Sans cette phrase, un verdict d'il y a quatre paliers se lirait + # comme un défaut présent de la base qu'on a sous les yeux. + chemin = self.ecrire([verdict()]) + texte = "\n".join( + residue.verdicts_block("test_neutralize_upgrade_18", False, chemin) + ) + self.assertIn( + residue.t("These come from the file, not the database:"), texte + ) + + def test_a_past_verdict_does_not_become_a_finding(self): + # Le code de sortie dit « la BASE porte un défaut ». Y compter un + # verdict passé rendrait 1 pour toujours, et le pilote traiterait + # une base saine comme cassée à chaque appel. + vide = {c["key"]: 0 for c in residue.CONTROLES} + trouve, _illisibles = residue.judge(vide) + self.assertEqual([], trouve) + + def test_the_clean_report_still_carries_the_verdicts(self): + # Le cas qui compte : aucun résidu en base, et pourtant un test + # de fumée a échoué en chemin. Le rapport « rien trouvé » ne doit + # pas être le dernier mot. + chemin = self.ecrire([verdict()]) + ancien = residue.quality.DEFAULT_PROGRESSION + residue.quality.DEFAULT_PROGRESSION = chemin + self.addCleanup( + setattr, residue.quality, "DEFAULT_PROGRESSION", ancien + ) + vide = {c["key"]: 0 for c in residue.CONTROLES} + texte = residue.render( + "test_neutralize_upgrade_18", vide, colour=False + ) + self.assertIn(residue.t("None of the checks found anything."), texte) + self.assertIn("smoke_public_url", texte) + + def test_the_colour_it_asks_for_exists(self): + # `paint` retombe sur une chaîne vide suivie d'un RESET quand le + # genre est inconnu : cela n'annule rien et ne teinte rien. + chemin = self.ecrire([verdict()]) + for ligne in residue.verdicts_block( + "test_neutralize_upgrade_18", True, chemin + ): + if ligne and ligne.endswith(residue.RESET): + self.assertNotEqual(residue.RESET, ligne.strip()) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_odoo_rc.py b/test/test_odoo_rc.py new file mode 100644 index 0000000..1529ddb --- /dev/null +++ b/test/test_odoo_rc.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Quel fichier de configuration Odoo lit, quand personne ne le dit. + +Sans ODOO_RC, Odoo retombe sur `~/.odoorc` — un fichier PERSONNEL que +rien ne synchronise avec le `config.conf` du dépôt. Mesuré sur une vraie +migration : un `~/.odoorc` portant un mot de passe maître haché faisait +échouer « odoo_bin.sh db --drop » par AccessDenied, alors que +`db_restore.py` venait de lire « admin_passwd = admin » dans config.conf +et d'en conclure qu'aucun mot de passe n'était nécessaire. + +Les deux avaient raison. Ils ne parlaient pas du même fichier — et rien, +nulle part, ne les confrontait. Le drop échouait, le clone butait ensuite +sur « database already exists », et la boucle de reprise faisait passer +les deux pour un accident. Huit fois par migration. +""" + +import io +import os +import subprocess +import unittest + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +LIB = os.path.join(REPO, "script", "lib_odoo_rc.sh") + + +class Base(unittest.TestCase): + def resoudre(self, racine, environ=None): + """Ce que la résolution pose dans ODOO_RC, pour cette racine.""" + env = dict(os.environ) + env.pop("ODOO_RC", None) + env.update(environ or {}) + done = subprocess.run( + [ + "bash", + "-c", + f'source {LIB!r}; odoo_rc_resolve "$1"; echo "${{ODOO_RC:-}}"', + "bash", + racine, + ], + capture_output=True, + text=True, + env=env, + ) + self.assertEqual(0, done.returncode, done.stderr) + return done.stdout.strip() + + +class TestWhichFileIsChosen(Base): + def setUp(self): + import shutil + import tempfile + + self.dossier = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.dossier) + + def config(self): + chemin = os.path.join(self.dossier, "config.conf") + with io.open(chemin, "w", encoding="utf-8") as handle: + handle.write("[options]\nadmin_passwd = admin\n") + return chemin + + def test_the_repository_config_wins_over_the_personal_one(self): + # C'est tout le propos : ~/.odoorc n'est plus consulté. + self.assertEqual(self.config(), self.resoudre(self.dossier)) + + def test_an_explicit_choice_is_never_overridden(self): + # Odoo lit ODOO_RC avant ~/.odoorc, et « -c » avant ODOO_RC : + # écraser une valeur posée exprès retirerait ce choix. + self.config() + self.assertEqual( + "/ailleurs/odoo.conf", + self.resoudre(self.dossier, {"ODOO_RC": "/ailleurs/odoo.conf"}), + ) + + def test_without_a_repository_config_nothing_is_forced(self): + # Sur une machine sans config.conf ni /etc/odoo/odoo.conf, on + # laisse Odoo faire ce qu'il a toujours fait. + vide = self.resoudre(self.dossier) + self.assertIn(vide, ("", "/etc/odoo/odoo.conf")) + + def test_it_never_fails_the_command_that_sourced_it(self): + # Elle est appelée au tout début d'odoo_bin.sh : y échouer + # empêcherait de lancer Odoo, pour une question de configuration. + done = subprocess.run( + ["bash", "-c", f"source {LIB!r}; odoo_rc_resolve /inexistant"], + capture_output=True, + text=True, + env={k: v for k, v in os.environ.items() if k != "ODOO_RC"}, + ) + self.assertEqual(0, done.returncode, done.stderr) + + +class TestTheEntryPointUsesIt(unittest.TestCase): + def source(self, nom): + with io.open(os.path.join(REPO, nom), encoding="utf-8") as handle: + return handle.read() + + def test_odoo_bin_resolves_before_running_odoo(self): + source = self.source("odoo_bin.sh") + self.assertIn("odoo_rc_resolve", source) + self.assertLess( + source.index("odoo_rc_resolve"), source.index("odoo-bin") + ) + + def test_the_order_matches_what_db_restore_checks(self): + # `db_restore.py` lit ./config.conf puis /etc/odoo/odoo.conf pour + # décider s'il faut un mot de passe maître. Si Odoo en lisait un + # autre, cette vérification ne vérifierait rien. + lib = self.source(os.path.join("script", "lib_odoo_rc.sh")) + restore = self.source( + os.path.join("script", "database", "db_restore.py") + ) + for candidat in ("config.conf", "/etc/odoo/odoo.conf"): + self.assertIn(candidat, lib, candidat) + self.assertIn(candidat, restore, candidat) + self.assertLess( + lib.index("config.conf"), lib.index("/etc/odoo/odoo.conf") + ) + self.assertLess( + restore.index("config.conf"), + restore.index("/etc/odoo/odoo.conf"), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_prompt_defaults.py b/test/test_prompt_defaults.py index b2e9a22..e466206 100644 --- a/test/test_prompt_defaults.py +++ b/test/test_prompt_defaults.py @@ -224,6 +224,111 @@ class TestTheCountdownSpeaksThePromptsLanguage(EnvCase): self.assertIn("Entrée", texte) +class TestHowLongTheCountdownLasts(EnvCase): + """Cinq secondes ne suffisaient pas à LIRE la question. + + Le compte à rebours n'existe pas pour aller vite : il existe pour + qu'on puisse s'absenter. Trop court, il fait l'inverse — on répond par + réflexe, ou l'on subit un défaut qu'on n'a pas eu le temps de lire. + """ + + def test_the_default_leaves_time_to_read_and_decide(self): + self.assertGreaterEqual(auto_ask.DEFAULT_DELAY, 15) + + def test_the_environment_still_wins(self): + # C'est le seul canal qui traverse un fork : une valeur posée par + # le pilote doit primer sur celle écrite ici. + os.environ[auto_ask.ENV_DELAY] = "3" + self.assertEqual(3, auto_ask.delay()) + + +class TestTheDatabaseNameTheFileSuggests(unittest.TestCase): + """« test » ne disait rien de ce qu'on migrait. + + Trois migrations de suite portaient le même nom, et retrouver + laquelle avait échoué demandait de relire le journal. Le fichier de + sauvegarde porte déjà le nom du client — c'est la seule information + disponible au moment où la question se pose. + """ + + def nom(self, chemin, **kw): + from script.todo.todo_upgrade import database_name_from_file + + return database_name_from_file(chemin, **kw) + + def test_the_zip_extension_goes_away(self): + self.assertEqual("chezlepro3", self.nom("image_db/chezlepro3.zip")) + + def test_an_uppercase_extension_goes_away_too(self): + self.assertEqual("client", self.nom("image_db/CLIENT.ZIP")) + + def test_a_real_backup_name_survives_whole(self): + self.assertEqual( + "technolibre_2026_08_26_02h41m23s", + self.nom("image_db/technolibre_2026-08-26_02h41m23s.zip"), + ) + + def test_a_file_without_extension_still_names_a_base(self): + # Une sauvegarde peut être posée sans « .zip » ; elle s'ouvre + # quand même comme une archive. + self.assertEqual("technolibre", self.nom("image_db/technolibre")) + + def test_spaces_and_punctuation_become_underscores(self): + # Le nom finit dans un createdb et dans des noms de fichiers. + self.assertEqual( + "client_final_2026", self.nom("image_db/Client Final (2026).zip") + ) + + def test_it_never_starts_with_a_digit(self): + # PostgreSQL refuse un identifiant nu qui commence par un chiffre. + nom = self.nom("image_db/2026-backup.zip") + self.assertFalse(nom[0].isdigit(), nom) + + def test_nothing_usable_falls_back_to_the_default(self): + for chemin in ("", None, "image_db/.zip", "image_db/---.zip"): + self.assertEqual("test", self.nom(chemin)) + + def test_a_long_name_leaves_room_for_the_suffixes(self): + # Le pilote ajoute « _neutralize » puis « _upgrade_ ». + # PostgreSQL tronque à 63 octets : sans marge, deux paliers + # finiraient sur le MÊME nom et le second écraserait le premier. + from script.todo.todo_upgrade import database_name_from_file + + nom = database_name_from_file("image_db/" + "a" * 90 + ".zip") + complet = nom + "_neutralize_upgrade_18" + self.assertLessEqual(len(complet), 63, complet) + + def test_it_does_not_end_on_a_separator(self): + nom = self.nom("image_db/" + "a" * 40 + "-suite.zip") + self.assertFalse(nom.endswith("_"), nom) + + +class TestTheMigrationUsesThatName(unittest.TestCase): + def test_the_prompt_offers_what_the_file_suggests(self): + import inspect + + from script.todo.todo_upgrade import TodoUpgrade + + source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade) + pose = source.index("database_name_from_file(") + invite = source.index("Which database name do you want to work with?") + # Il faut le calculer AVANT de le proposer. + self.assertLess(pose, invite) + + def test_a_remote_download_keeps_the_name_the_server_gave(self): + # Le serveur sait mieux que le nom du fichier téléchargé. + import inspect + + from script.todo.todo_upgrade import TodoUpgrade + + source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade) + fenetre = source[ + source.index("database_name_from_file(") + - 400 : source.index("database_name_from_file(") + ] + self.assertIn('default_database_name == "test"', fenetre) + + class TestTheQuestionThatCannotAnswerItself(EnvCase): """Celle-là engage toutes les suivantes : elle ne se prend pas seule. diff --git a/test/test_reset_stale_cow_prompt.py b/test/test_reset_stale_cow_prompt.py index c12fd8f..1639cde 100755 --- a/test/test_reset_stale_cow_prompt.py +++ b/test/test_reset_stale_cow_prompt.py @@ -44,7 +44,7 @@ class PromptCase(unittest.TestCase): upgrade.write_config = lambda: None upgrade.stale_cow_keys = lambda db: lst_key lst_cmd = [] - upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0 + upgrade.run_captured = lambda cmd: lst_cmd.append(cmd) or 0 # Le doublon HONORE le défaut, comme le vrai `ask_gate` : sinon # les tests de défaut ne testeraient que le doublon. upgrade.ask_gate = lambda prompt, default="": answer or default diff --git a/test/test_smoke_public_url.py b/test/test_smoke_public_url.py index 3c0977c..3eea61b 100755 --- a/test/test_smoke_public_url.py +++ b/test/test_smoke_public_url.py @@ -433,7 +433,7 @@ class TestTheMigrationOffersIt(unittest.TestCase): upgrade.lst_command_executed = [] upgrade.write_config = lambda: None lst_cmd = [] - upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0 + upgrade.run_captured = lambda cmd: lst_cmd.append(cmd) or 0 # Le doublon HONORE le défaut, comme le vrai `ask_gate`. upgrade.ask_gate = lambda prompt, default="": answer or default upgrade.prompt_smoke_public_url("db_upgrade_13") @@ -502,7 +502,7 @@ class TestTheMigrationOffersIt(unittest.TestCase): upgrade.dct_progression = {} upgrade.lst_command_executed = [] upgrade.write_config = lambda: None - upgrade.run_on_terminal = lambda cmd: 0 + upgrade.run_captured = lambda cmd: 0 upgrade.ask_gate = lambda prompt, default="": "n" out = io.StringIO() with contextlib.redirect_stdout(out): diff --git a/test/test_suite_reaches_every_file.py b/test/test_suite_reaches_every_file.py new file mode 100644 index 0000000..a1d6d56 --- /dev/null +++ b/test/test_suite_reaches_every_file.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Un test qui ne tourne pas est pire qu'un test absent. + +Absent, on le sait. Vert sans avoir tourné, il rassure. Le dépôt a +rencontré les deux formes de ce silence le même jour : + + · `unittest.main()` posé au MILIEU d'un fichier. Python exécute de haut + en bas : l'appel part, découvre ce qui est défini jusque-là, et sort. + Tout ce qui suit n'existe pas encore. Quatre fichiers, 87 tests. + + · Aucun bloc `__main__` du tout. Le fichier ne fait rien quand on le + lance, le lanceur compte zéro test, et le total ne bouge pas. Huit + fichiers, 174 tests. + +À quoi s'ajoutait une liste de préfixes dans le lanceur, qui laissait 2400 +tests hors de la suite. Trois façons différentes d'obtenir le même +résultat : du vert qui ne prouve rien. + +Ce fichier est la garde. Il ne lit pas ce que les tests vérifient — il +vérifie qu'ils PEUVENT être vérifiés. +""" + +import ast +import glob +import io +import os +import unittest + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +LANCEUR = os.path.join(REPO, "script", "test", "run_unit_test.sh") + + +def fichiers_de_test(): + return sorted(glob.glob(os.path.join(REPO, "test", "test_*.py"))) + + +def bloc_main(arbre): + """L'indice du `if __name__ == "__main__":` du module, ou None.""" + for rang, noeud in enumerate(arbre.body): + if ( + isinstance(noeud, ast.If) + and isinstance(noeud.test, ast.Compare) + and getattr(noeud.test.left, "id", "") == "__name__" + ): + return rang + return None + + +class TestEveryFileCanRun(unittest.TestCase): + def setUp(self): + self.fichiers = fichiers_de_test() + self.assertTrue(self.fichiers, "aucun fichier de test trouvé") + + def test_every_file_has_a_way_to_run_itself(self): + # Le lanceur exécute chaque fichier comme un programme. Sans ce + # bloc, il ne fait rien et le lanceur compte zéro — sans erreur. + for chemin in self.fichiers: + arbre = ast.parse(io.open(chemin, encoding="utf-8").read()) + self.assertIsNotNone( + bloc_main(arbre), + f"{os.path.basename(chemin)} : pas de bloc __main__", + ) + + def test_the_run_call_is_the_last_thing_in_the_file(self): + # Au milieu, il coupe le fichier en deux et la seconde moitié + # n'est jamais définie au moment où les tests sont découverts. + for chemin in self.fichiers: + arbre = ast.parse(io.open(chemin, encoding="utf-8").read()) + rang = bloc_main(arbre) + if rang is None: + continue + suivants = arbre.body[rang + 1 :] + self.assertEqual( + [], + [type(n).__name__ for n in suivants], + f"{os.path.basename(chemin)} : du code après unittest.main()", + ) + + def test_every_file_actually_declares_a_test(self): + for chemin in self.fichiers: + arbre = ast.parse(io.open(chemin, encoding="utf-8").read()) + combien = sum( + 1 + for noeud in ast.walk(arbre) + if isinstance(noeud, (ast.FunctionDef, ast.AsyncFunctionDef)) + and noeud.name.startswith("test_") + ) + self.assertTrue( + combien, f"{os.path.basename(chemin)} : aucun test dedans" + ) + + +class TestTheRunnerLeavesNobodyOut(unittest.TestCase): + """Une liste de préfixes oublie ; un glob, non.""" + + def setUp(self): + self.source = io.open(LANCEUR, encoding="utf-8").read() + + def test_it_takes_the_whole_directory(self): + self.assertIn("ls test/test_*.py", self.source) + + def test_it_does_not_pick_families_by_name(self): + # Le défaut d'origine : un fichier hors préfixe tombait dans le + # même silence qu'un fichier absent. + for prefixe in ("test/test_qemu_*.py", "test/test_todo_*.py"): + self.assertNotIn(prefixe, self.source, prefixe) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_theme_on_error.py b/test/test_theme_on_error.py index 1b61111..ee97f67 100644 --- a/test/test_theme_on_error.py +++ b/test/test_theme_on_error.py @@ -169,10 +169,17 @@ class TestTheMenu(unittest.TestCase): def test_it_goes_through_the_real_terminal(self): # `uninstall_addons_theme.sh` finit par poser une question ; un # tube la rendrait invisible et l'on répondrait à l'aveugle. + # `run_captured` fabrique un pseudo-terminal : l'invite s'affiche + # comme sur un vrai, et le journal de désinstallation est gardé. bloc = self.source() debut = bloc.index('if wait_status == "6"') fin = bloc.index('if wait_status == "5"') - self.assertIn("run_on_terminal", bloc[debut:fin]) + fenetre = bloc[debut:fin] + self.assertTrue( + "run_captured" in fenetre or "run_on_terminal" in fenetre, + fenetre[:120], + ) + self.assertNotIn("todo_upgrade_execute", fenetre) def test_the_label_is_translated(self): self.assertIn( diff --git a/test/test_todo_menu.py b/test/test_todo_menu.py index f88855f..5c38677 100644 --- a/test/test_todo_menu.py +++ b/test/test_todo_menu.py @@ -380,6 +380,38 @@ class TestAnalyseMenuNumbering(MenuCoherence, unittest.TestCase): } +class TestDatabaseMenuNumbering(MenuCoherence, unittest.TestCase): + """Le menu Database, qui manie des bases entières. + + Il n'avait aucun garde, et c'est celui où une renumérotation coûte le + plus cher : sa dernière entrée EFFACE une base. Insérer « Dupliquer » + avant elle décale « Effacer » de [4] à [5] — si le dispatch ne suit + pas, taper [4] efface au lieu de copier. + """ + + SOURCE = TODO_DIR / "todo.py" + ENTRY = "def prompt_execute_database(self):" + END = "def prompt_execute_analyse(self):" + MINIMUM = 4 + + # Ce menu délègue à `self.db_manager.methode()`, pas à `self.methode()`. + # Le motif du socle ne voit que la forme courte : sans cette surcharge + # il lit ZÉRO dispatch et ne compare plus rien — un garde qui passe au + # vert sans rien garder. + RE_DISPATCH_CALL = re.compile( + r'(?:el)?if status == "(\d+)":\s*\n(?:\s*#.*\n)*' + r"\s*(?:status = )?self\.(?:\w+\.)*(\w+)\(" + ) + + EXPECTED = { + "Create backup": "create_backup_from_database", + "Download database": "download_database_backup_cli", + "Restore from backup": "restore_from_database", + "Duplicate a database": "duplicate_database", + "Erase a database": "drop_database", + } + + class TestProxmoxMenuNumbering(MenuCoherence, unittest.TestCase): """Le menu Proxmox : dix-huit entrées, le même piège. diff --git a/test/test_todo_upgrade_capture.py b/test/test_todo_upgrade_capture.py new file mode 100644 index 0000000..65cc94d --- /dev/null +++ b/test/test_todo_upgrade_capture.py @@ -0,0 +1,348 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Capturer la sortie d'un test SANS que le test s'en aperçoive. + +Le journal d'étape gardait la commande et son code, jamais ce que la +commande avait écrit. Mesuré sur une vraie migration : « $ … +smoke_public_url.py … » suivi IMMÉDIATEMENT de « -> 1 », zéro ligne entre +les deux, trois fois de suite. L'écran d'analyse ne pouvait donc rien +montrer de ce qui avait échoué. + +Un tube aurait suffi à capturer, et aurait changé le programme : +`smoke_public_url` appelle `can_ask()`, qui exige stdin ET stdout sur un +terminal (smoke_public_url.py:63-72). Derrière un tube il cesse d'offrir +la réparation des vues COW — en silence, ce qui est le pire. + +D'où le pseudo-terminal. Ce fichier vérifie les deux moitiés de la +promesse : que la sortie arrive dans le journal, et que l'enfant continue +de voir un vrai terminal. +""" + +import io +import os +import shutil +import sys +import tempfile +import unittest +from contextlib import redirect_stdout + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, REPO) + +from script.todo.todo_upgrade import TodoUpgrade # noqa: E402 + + +class Pilote: + """Le strict nécessaire : `run_captured` ne touche à rien d'autre. + + Instancier le vrai pilote demanderait une configuration, une base et + un dépôt Odoo. La méthode, elle, n'a besoin que d'un journal ouvert et + de deux listes — c'est ce qui la rend testable. + """ + + RE_ANSI = TodoUpgrade.RE_ANSI + run_captured = TodoUpgrade.run_captured + + def __init__(self, handle): + self.step_log = handle + self.lst_command_executed = [] + self.dct_progression = {} + self.notes = [] + + def write_config(self): + pass + + def note_step_log(self, texte): + self.notes.append(texte) + if self.step_log: + self.step_log.write(texte + "\n") + + +class Base(unittest.TestCase): + def setUp(self): + self.dossier = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.dossier) + self.chemin = os.path.join(self.dossier, "etape.log") + + def outil(self, source): + chemin = os.path.join(self.dossier, "outil.py") + with io.open(chemin, "w", encoding="utf-8") as handle: + handle.write(source) + return f"{sys.executable} {chemin}" + + def lancer(self, cmd, avec_journal=True): + muet = os.open(os.devnull, os.O_WRONLY) + garde = os.dup(1) + os.dup2(muet, 1) + try: + return self._lancer(cmd, avec_journal) + finally: + os.dup2(garde, 1) + os.close(garde) + os.close(muet) + + def _lancer(self, cmd, avec_journal=True): + tampon = io.StringIO() + if not avec_journal: + pilote = Pilote(None) + with redirect_stdout(tampon): + code = pilote.run_captured(cmd) + return code, "", pilote + with io.open( + self.chemin, "w", encoding="utf-8", buffering=1 + ) as handle: + pilote = Pilote(handle) + with redirect_stdout(tampon): + code = pilote.run_captured(cmd) + with io.open(self.chemin, encoding="utf-8") as handle: + return code, handle.read(), pilote + + +class TestTheChildStillSeesATerminal(Base): + """La moitié qui n'a l'air de rien, et qui décide de tout.""" + + def test_stdout_is_a_terminal_for_the_child(self): + # Derrière un tube, `can_ask()` rend False et le test de fumée + # cesse d'offrir la réparation, sans le dire. + _code, journal, _p = self.lancer( + self.outil( + "import sys\nprint('T' if sys.stdout.isatty() else 'F')\n" + ) + ) + self.assertIn("\nT\n", "\n" + journal) + + def test_stdin_is_a_terminal_for_the_child(self): + _code, journal, _p = self.lancer( + self.outil( + "import sys\nprint('T' if sys.stdin.isatty() else 'F')\n" + ) + ) + self.assertIn("\nT\n", "\n" + journal) + + def test_the_exit_code_survives(self): + # C'est lui qui devient le verdict : 0 rien, 1 des trouvailles, + # 2 l'outil a échoué. Le perdre viderait `record_event` de sens. + code, _journal, _p = self.lancer( + self.outil("import sys\nsys.exit(4)\n") + ) + self.assertEqual(4, code) + + def test_a_zero_stays_a_zero(self): + code, _journal, _p = self.lancer(self.outil("pass\n")) + self.assertEqual(0, code) + + +class TestWhatTheLogKeeps(Base): + def test_the_output_is_there(self): + _code, journal, _p = self.lancer( + self.outil("print('la ligne qui manquait')\n") + ) + self.assertIn("la ligne qui manquait", journal) + + def test_the_command_and_its_code_frame_it(self): + _code, journal, _p = self.lancer(self.outil("print('milieu')\n")) + lignes = journal.splitlines() + self.assertTrue(lignes[0].startswith("$ ")) + self.assertIn("milieu", journal) + self.assertTrue(lignes[-1].strip().startswith("-> ")) + + def test_colour_is_stripped_from_the_file(self): + # L'enfant se croit — à juste titre — sur un terminal, donc il + # colorie. Une seule séquence suffit à rendre l'extrait illisible. + _code, journal, _p = self.lancer( + self.outil("print('\\x1b[31mrouge\\x1b[0m nu')\n") + ) + self.assertIn("rouge nu", journal) + self.assertNotIn("\x1b", journal) + + def test_a_sequence_split_across_two_reads_leaves_no_debris(self): + # On n'écrit qu'une fois la ligne complète : autrement les débris + # d'une séquence coupée restent dans le fichier. + source = ( + "import sys, time\n" + "sys.stdout.write('\\x1b[3')\n" + "sys.stdout.flush()\n" + "time.sleep(0.2)\n" + "sys.stdout.write('1mtardif\\x1b[0m\\n')\n" + "sys.stdout.flush()\n" + ) + _code, journal, _p = self.lancer(self.outil(source)) + self.assertIn("tardif", journal) + self.assertNotIn("\x1b", journal) + + def test_secrets_go_through_the_same_filter_as_everything_else(self): + _code, journal, _p = self.lancer( + self.outil("print('outil --db_password TRESSECRET -d base')\n") + ) + self.assertNotIn("TRESSECRET", journal) + self.assertIn("--db_password", journal) + + def test_the_last_line_without_a_newline_is_not_lost(self): + _code, journal, _p = self.lancer( + self.outil( + "import sys\nsys.stdout.write('sans saut final')\n" + "sys.stdout.flush()\n" + ) + ) + self.assertIn("sans saut final", journal) + + def test_carriage_returns_do_not_survive(self): + # Un terminal en pose à chaque ligne ; dans un fichier ils + # produisent des lignes qui s'écrasent à la lecture. + _code, journal, _p = self.lancer(self.outil("print('normale')\n")) + self.assertNotIn("\r", journal) + + +class TestWithoutAnOpenLog(Base): + """Sans journal, rien à gagner et un pty à payer.""" + + def test_the_command_still_runs_and_returns_its_code(self): + code, _journal, _p = self.lancer( + self.outil("import sys\nsys.exit(5)\n"), avec_journal=False + ) + self.assertEqual(5, code) + + def test_no_terminal_is_fabricated_when_there_is_nothing_to_keep(self): + """Sans journal, `run_captured` doit valoir `run_on_terminal`. + + Fabriquer un pseudo-terminal changerait ce que la commande voit — + couleur, invites, mise en page — pour un journal qui n'existe pas. + L'enfant écrit son verdict dans un fichier À CÔTÉ, car sa sortie + standard est précisément ce qu'on est en train de mesurer. + """ + temoin = os.path.join(self.dossier, "temoin.txt") + cmd = self.outil( + "import io, sys\n" + f"io.open({temoin!r}, 'w').write(str(sys.stdout.isatty()))\n" + ) + self.lancer(cmd, avec_journal=False) + with io.open(temoin, encoding="utf-8") as handle: + self.assertEqual("False", handle.read()) + + def test_a_terminal_IS_fabricated_when_there_is_a_log(self): + # Le pendant : c'est ce qui permet à l'outil de poser sa question. + temoin = os.path.join(self.dossier, "temoin2.txt") + cmd = self.outil( + "import io, sys\n" + f"io.open({temoin!r}, 'w').write(str(sys.stdout.isatty()))\n" + ) + self.lancer(cmd, avec_journal=True) + with io.open(temoin, encoding="utf-8") as handle: + self.assertEqual("True", handle.read()) + + def test_the_command_is_still_recorded(self): + _code, _journal, pilote = self.lancer( + self.outil("pass\n"), avec_journal=False + ) + self.assertEqual(1, len(pilote.lst_command_executed)) + self.assertEqual( + pilote.lst_command_executed, + pilote.dct_progression["command_executed"], + ) + + +class TestTheDriverUsesIt(unittest.TestCase): + """Le câblage : quels appels passent par la capture, et lesquels non.""" + + def setUp(self): + import inspect + + self.source = inspect.getsource(TodoUpgrade) + + def test_every_tool_verdict_goes_through_the_capture(self): + import inspect + + run_tool = inspect.getsource(TodoUpgrade.run_tool) + self.assertIn("run_captured", run_tool) + self.assertNotIn("run_on_terminal", run_tool) + + def test_the_hidden_models_check_is_a_recorded_verdict(self): + # Il suit la même convention de code de sortie que les autres et + # tournait pourtant sans que rien ne retienne sa conclusion. + avant = self.source.index("check_hidden_models.py") + debut = self.source.rindex("self.run_", 0, avant) + self.assertTrue( + self.source[debut:avant].startswith("self.run_tool("), + self.source[debut:avant][:60], + ) + + def test_only_full_screen_paths_stay_outside_the_capture(self): + """La frontière, épinglée par ce qu'elle laisse dehors. + + Elle a une raison mesurée : `pty.spawn` crée son terminal en 0×0 + et l'enfant lit sa taille avant le premier octet. Sans effet sur + un outil qui écrit des lignes ; fatal pour une application qui se + dispose. Tout le reste doit donc être capturé. + """ + import re + + lignes = self.source.split("\n") + dehors = [] + for rang, ligne in enumerate(lignes): + if "self.run_on_terminal(" not in ligne: + continue + suite = " ".join(x.strip() for x in lignes[rang : rang + 7]) + trouve = re.search(r"(\w+\.(?:py|sh))", suite) + if trouve: + dehors.append((trouve.group(1), "--tui" in suite)) + self.assertTrue(dehors, "plus aucun appel : la garde ne garde rien") + for outil, tui in dehors: + # Soit il demande explicitement un plein écran, soit son + # invite peut en ouvrir un — check_stale_scss le fait sur « w ». + self.assertTrue( + tui or outil == "check_stale_scss.py", + f"{outil} n'est pas un plein écran : il devrait être capturé", + ) + + def test_the_tools_captured_do_not_read_the_terminal_width(self): + # C'est ce qui rend le 0×0 sans conséquence pour eux. Si l'un s'y + # mettait, sa mise en page partirait sur une largeur nulle. + import glob + + for chemin in glob.glob( + os.path.join(REPO, "script", "odoo", "migration", "*.py") + ): + if os.path.basename(chemin) in ( + "check_stale_scss.py", + "reset_stale_cow_views.py", + ): + continue + with io.open(chemin, encoding="utf-8") as handle: + source = handle.read() + if "run_captured" in source: + continue + self.assertNotIn( + "get_terminal_size", source, os.path.basename(chemin) + ) + + def test_the_full_screen_path_still_captures_nothing(self): + # C'est sa raison d'être : un plein écran derrière un tube + # renonce et retombe sur son rapport texte. + import inspect + + import ast + + import textwrap + + arbre = ast.parse( + textwrap.dedent(inspect.getsource(TodoUpgrade.run_on_terminal)) + ) + corps = arbre.body[0].body + if isinstance(corps[0], ast.Expr) and isinstance( + corps[0].value, ast.Constant + ): + corps = corps[1:] + code = "\n".join(ast.unparse(noeud) for noeud in corps) + self.assertIn("subprocess.call", code) + self.assertNotIn("pty", code) + + def test_the_viewer_of_cow_copies_keeps_its_full_screen(self): + avant = self.source.index("cow_drift.py") + suite = self.source[avant : avant + 600] + self.assertIn("self.run_on_terminal(cmd)", suite) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_uninstall_addons_theme.py b/test/test_uninstall_addons_theme.py index af5ffa3..b95778f 100755 --- a/test/test_uninstall_addons_theme.py +++ b/test/test_uninstall_addons_theme.py @@ -156,7 +156,7 @@ class TestTheMigrationOffersIt(unittest.TestCase): )[1] # Le désinstalleur ne passe PLUS par l'exécuteur qui capture : il # pose une question, et un tube la rendrait invisible. - obj.run_on_terminal = lambda cmd: self.lst_cmd.append(cmd) or 0 + obj.run_captured = lambda cmd: self.lst_cmd.append(cmd) or 0 # Le doublon HONORE le défaut, comme le vrai `ask_gate` : sinon # les tests de défaut ne testeraient que le doublon. obj.ask_gate = lambda prompt, default="": answer or default @@ -347,7 +347,10 @@ class TestTheQuestionMustBeVisible(unittest.TestCase): from script.todo.todo_upgrade import TodoUpgrade source = inspect.getsource(TodoUpgrade.prompt_uninstall_theme) - self.assertIn("run_on_terminal", source) + # Un pseudo-terminal convient : l'invite reste visible, et la + # sortie est gardée pour l'écran d'analyse. Seul le tube de + # `todo_upgrade_execute` la rendrait muette. + self.assertIn("run_captured", source) self.assertNotIn("todo_upgrade_execute", source) def test_asking_needs_stdout_not_just_stdin(self): @@ -458,10 +461,10 @@ class TestTheMisleadingErrorCode(unittest.TestCase): source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade) avant = source[: source.index("check_cow_views.py")] - self.assertGreater( - avant.rfind("run_on_terminal("), - avant.rfind("todo_upgrade_execute("), + terminal = max( + avant.rfind("run_on_terminal("), avant.rfind("run_captured(") ) + self.assertGreater(terminal, avant.rfind("todo_upgrade_execute(")) class TestExitCodes(unittest.TestCase): diff --git a/test/test_version.py b/test/test_version.py index 2d48072..dffbb19 100644 --- a/test/test_version.py +++ b/test/test_version.py @@ -241,3 +241,7 @@ class TestUpdatePrintLog(unittest.TestCase): update = Update() update.execute_log = ["entry1", "entry2"] update.print_log() + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_warmup_http.py b/test/test_warmup_http.py new file mode 100644 index 0000000..faf1aaf --- /dev/null +++ b/test/test_warmup_http.py @@ -0,0 +1,331 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Le réveil HTTP : il doit se taire, et ne jamais gêner. + +Cette sonde tourne à côté du serveur, pas devant lui. Tout ce qu'elle +peut faire de mal, elle le ferait en parlant : un message d'erreur dans +le journal de `run.sh` ferait chercher une panne d'Odoo qui n'existe pas, +et un code de retour non nul ferait échouer un démarrage réussi. + +D'où les deux propriétés que ce fichier épingle avant les autres : elle +rend TOUJOURS 0, et elle n'écrit qu'une ligne — l'adresse choisie, parce +que c'est la seule chose qui permette de comprendre après coup un réveil +qui n'a pas eu lieu. + +Le troisième sujet est l'option elle-même. `--erplibre-disable-warmup-http` +doit être RETIRÉE avant `odoo_bin.sh` : Odoo meurt sur « no such option », +et le démarrage entier tombe pour un confort. +""" + +import io +import unittest +from pathlib import Path + +from script.odoo import warmup_http as warmup + +RUN_SH = Path(__file__).resolve().parent.parent / "run.sh" + + +class TestWhereItKnocks(unittest.TestCase): + """Le port, dans l'ordre de ce qui fait autorité.""" + + def test_the_command_line_wins_over_everything(self): + for argv in ( + ["-p", "8090"], + ["--http-port", "8090"], + ["--http-port=8090"], + ): + self.assertEqual(warmup.port_de_la_ligne(argv), 8090, argv) + + def test_a_port_that_is_not_a_number_is_ignored(self): + self.assertIsNone(warmup.port_de_la_ligne(["-p", "huit-mille"])) + self.assertIsNone(warmup.port_de_la_ligne(["-p"])) + + def test_without_anything_it_falls_back_on_the_default(self): + vrai = warmup.lib_analyse.read_config + warmup.lib_analyse.read_config = lambda *a, **k: {} + try: + self.assertEqual( + warmup.adresse(), + (warmup.HOTE_PAR_DEFAUT, warmup.PORT_PAR_DEFAUT), + ) + finally: + warmup.lib_analyse.read_config = vrai + + def test_an_empty_interface_means_ourselves(self): + """`http_interface =` veut dire « toutes » ; 0.0.0.0 n'est pas une + adresse de destination.""" + vrai = warmup.lib_analyse.read_config + for valeur in ("", "False", "0.0.0.0", "::"): + warmup.lib_analyse.read_config = lambda *a, v=valeur, **k: { + "http_interface": v + } + try: + self.assertEqual(warmup.adresse()[0], "127.0.0.1", valeur) + finally: + warmup.lib_analyse.read_config = vrai + + def test_a_real_interface_is_used_as_is(self): + vrai = warmup.lib_analyse.read_config + warmup.lib_analyse.read_config = lambda *a, **k: { + "http_interface": "10.0.0.5", + "http_port": "8071", + } + try: + self.assertEqual(warmup.adresse(), ("10.0.0.5", 8071)) + finally: + warmup.lib_analyse.read_config = vrai + + +class TestReadingTheExecutionLog(unittest.TestCase): + """La seule source exacte quand le port demandé était déjà pris.""" + + def _journal(self, contenu): + import tempfile + + handle = tempfile.NamedTemporaryFile( + mode="w", suffix=".log", delete=False, encoding="utf-8" + ) + handle.write(contenu) + handle.close() + self.addCleanup(lambda: Path(handle.name).unlink(missing_ok=True)) + return handle.name + + def test_it_reads_the_address_odoo_announced(self): + chemin = self._journal( + "blah\nHTTP service (werkzeug) running on 127.0.0.1:8075\nblah\n" + ) + self.assertEqual( + warmup.adresse_du_journal(chemin), ("127.0.0.1", 8075) + ) + + def test_the_last_line_wins(self): + """Un redémarrage laisse les deux ; la dernière est la vraie.""" + chemin = self._journal( + "HTTP service (werkzeug) running on 127.0.0.1:8069\n" + "HTTP service (werkzeug) running on 127.0.0.1:8070\n" + ) + self.assertEqual(warmup.adresse_du_journal(chemin)[1], 8070) + + def test_a_missing_or_silent_log_says_nothing(self): + self.assertEqual(warmup.adresse_du_journal(None), (None, None)) + self.assertEqual(warmup.adresse_du_journal("/pas/la"), (None, None)) + self.assertEqual( + warmup.adresse_du_journal(self._journal("rien\n")), (None, None) + ) + + def test_the_command_line_still_wins_over_the_log(self): + chemin = self._journal( + "HTTP service (werkzeug) running on 127.0.0.1:8075\n" + ) + self.assertEqual( + warmup.adresse(["-p", "8090"], journal=chemin)[1], 8090 + ) + + +class TestWhichDatabase(unittest.TestCase): + def test_the_one_run_sh_just_chose(self): + self.assertEqual(warmup.base_a_reveiller(["-d", "ma_base"]), "ma_base") + self.assertEqual( + warmup.base_a_reveiller(["--database=ma_base"]), "ma_base" + ) + + def test_otherwise_the_one_odoo_would_use(self): + vrai = warmup.lib_analyse.read_config + warmup.lib_analyse.read_config = lambda *a, **k: {"db_name": "prod"} + try: + self.assertEqual(warmup.base_a_reveiller([]), "prod") + finally: + warmup.lib_analyse.read_config = vrai + + def test_db_name_false_is_not_a_database(self): + vrai = warmup.lib_analyse.read_config + for valeur in ("False", "", "None"): + warmup.lib_analyse.read_config = lambda *a, v=valeur, **k: { + "db_name": v + } + try: + self.assertIsNone(warmup.base_a_reveiller([]), valeur) + finally: + warmup.lib_analyse.read_config = vrai + + def test_the_name_is_escaped_in_the_url(self): + self.assertIn("db=ma%20base", warmup.url("127.0.0.1", 8069, "ma base")) + + def test_without_a_database_the_url_carries_no_parameter(self): + self.assertEqual( + warmup.url("127.0.0.1", 8069), "http://127.0.0.1:8069/web/login" + ) + + +class TestWhenItStops(unittest.TestCase): + """N'importe quelle réponse suffit ; seul le refus fait recommencer.""" + + def _sonder(self, reponses, delai=10.0): + import urllib.error + import urllib.request + + essais = [] + temps = [0.0] + + def faux_urlopen(cible, timeout=None): + essais.append(cible) + resultat = ( + reponses.pop(0) if reponses else ConnectionRefusedError() + ) + if isinstance(resultat, Exception): + raise resultat + return io.BytesIO(b"") + + vrai = urllib.request.urlopen + urllib.request.urlopen = faux_urlopen + try: + venu = warmup.sonder( + "http://x", + delai_total=delai, + entre_essais=0, + horloge=lambda: temps[0], + dormir=lambda s: temps.__setitem__(0, temps[0] + 1), + ) + finally: + urllib.request.urlopen = vrai + return venu, essais + + def test_a_first_answer_ends_it(self): + venu, essais = self._sonder([None]) + self.assertTrue(venu) + self.assertEqual(len(essais), 1) + + def test_an_http_error_is_an_answer(self): + """303, 404, 500 : tous prouvent que le registre est chargé.""" + import urllib.error + + erreur = urllib.error.HTTPError("u", 500, "boom", {}, None) + venu, essais = self._sonder([erreur]) + self.assertTrue(venu) + self.assertEqual(len(essais), 1) + + def test_a_refused_connection_makes_it_try_again(self): + venu, essais = self._sonder([ConnectionRefusedError(), None]) + self.assertTrue(venu) + self.assertEqual(len(essais), 2) + + def test_it_gives_up_at_the_deadline(self): + venu, essais = self._sonder([], delai=5.0) + self.assertFalse(venu) + self.assertEqual(len(essais), 5) + + def test_the_default_deadline_is_two_minutes(self): + self.assertEqual(warmup.DELAI_TOTAL, 120.0) + + +class TestItNeverGetsInTheWay(unittest.TestCase): + """La propriété qui compte le plus : elle ne peut pas nuire.""" + + def test_it_always_returns_zero(self): + vrai = warmup.sonder + warmup.sonder = lambda *a, **k: (_ for _ in ()).throw( + RuntimeError("x") + ) + try: + self.assertEqual(warmup.main(["--quiet"]), 0) + finally: + warmup.sonder = vrai + + def test_a_broken_config_does_not_stop_it(self): + vrai = warmup.lib_analyse.read_config + warmup.lib_analyse.read_config = lambda *a, **k: (_ for _ in ()).throw( + OSError("illisible") + ) + try: + self.assertEqual(warmup.main(["--quiet"]), 0) + finally: + warmup.lib_analyse.read_config = vrai + + def test_it_announces_the_address_it_chose(self): + """La seule ligne qu'elle écrit, et celle qui rend le silence + compréhensible plus tard.""" + import contextlib + + vrai = warmup.sonder + warmup.sonder = lambda *a, **k: True + sortie = io.StringIO() + try: + with contextlib.redirect_stdout(sortie): + warmup.main(["-d", "ma_base", "--", "-p", "8090"]) + finally: + warmup.sonder = vrai + texte = sortie.getvalue() + self.assertEqual(len(texte.strip().splitlines()), 1) + self.assertIn("ma_base", texte) + + def test_an_option_it_does_not_know_cannot_kill_it(self): + """Les arguments d'Odoo nous traversent : l'un d'eux ressemblera un + jour à une option inconnue, et `parse_args` sortirait par SystemExit. + """ + vrai = warmup.sonder + warmup.sonder = lambda *a, **k: True + try: + self.assertEqual(warmup.main(["--quiet", "--option-a-odoo"]), 0) + finally: + warmup.sonder = vrai + + def test_quiet_says_nothing_at_all(self): + import contextlib + + vrai = warmup.sonder + warmup.sonder = lambda *a, **k: True + sortie = io.StringIO() + try: + with contextlib.redirect_stdout(sortie): + warmup.main(["--quiet"]) + finally: + warmup.sonder = vrai + self.assertEqual(sortie.getvalue(), "") + + +class TestHowRunShWiresIt(unittest.TestCase): + """Une option qu'Odoo ne connaît pas le tue au démarrage.""" + + @classmethod + def setUpClass(cls): + cls.source = RUN_SH.read_text(encoding="utf-8") + + def test_the_flag_is_stripped_and_never_forwarded(self): + bloc = self.source[ + self.source.index("--erplibre-disable-warmup-http)") : + ][:120] + self.assertNotIn("EL_ARGS+=", bloc) + + def test_no_http_disables_it_but_still_reaches_odoo(self): + """Rien à réveiller, mais Odoo doit garder son option.""" + bloc = self.source[ + self.source.index("--no-http|--stop-after-init)") : + ][:200] + self.assertIn("EL_WARMUP=0", bloc) + self.assertIn('EL_ARGS+=("$1")', bloc) + + def test_it_runs_in_parallel(self): + lancement = [ + texte + for texte in self.source.splitlines() + if texte.strip().endswith("&") and "2>/dev/null" in texte + ] + self.assertTrue(lancement, "le réveil ne part pas en arrière-plan") + + def test_it_does_not_survive_the_server(self): + self.assertIn("trap", self.source) + self.assertIn("EL_WARMUP_PID", self.source) + + def test_its_own_errors_are_swallowed(self): + self.assertIn("2>/dev/null &", self.source) + + def test_odoo_arguments_are_separated_by_a_double_dash(self): + """Sans `--`, un « -c » destiné à Odoo serait lu comme le nôtre.""" + self.assertIn('-- "${EL_ARGS[@]}" 2>/dev/null &', self.source) + + +if __name__ == "__main__": + unittest.main()