Merge branch 'migration_anonymisation_pricelist'
[ADD] migration : copie anonymisée, verdicts remontés, dérives dépistées Vingt-deux commits. Une copie de production s'ouvre désormais sans rien atteindre au dehors : `exp_duplicate_database` d'Odoo coupe la source, régénère l'uuid, emporte le filestore et neutralise — depuis la 16 seulement. En deçà, un script du dépôt prend le relais plutôt que de refuser, et l'exécution annonce lequel a servi, car les deux ne posent pas les mêmes gestes. L'anonymisation passe sa liste noire entière — le SQL par un fichier, un argument ne portant que 131 072 octets — et n'épargne plus les noms qu'elle devait masquer. Elle laisse intacts les chemins d'identifiants qu'Odoo repasse à `int()`, et tire ses nombres dans l'étendue mesurée de leur colonne : la fiche masquée s'ouvre encore. Une migration ratée ne passe plus pour réussie. Les verdicts de chaque palier, de la 12 à la 18, remontent dans un écran de qualité, avec le passage de journal qui les explique et la sortie des outils telle qu'ils l'écrivent : un pseudo-terminal la capture sans faire taire les invites conditionnées par `isatty`, ANSI retiré et secrets caviardés. Un code 1 y dit des trouvailles, non un échec. Trois dérives muettes deviennent dépistables : un dépôt d'addons absent d'un palier, un type de vue supprimé qui survit dans des sources qu'aucune base ne montre, un plan comptable qui gonfle sous un gabarit rechargé. La liste de prix cesse d'être dupliquée à vide : la décision se prend sur ce que `base.group_user` implique et non sur les groupes de l'exécutant, et une liste partagée entre sociétés compte enfin pour chacune. Le reste tient la chaîne : `odoo_bin.sh` lit le config.conf du dépôt par ODOO_RC, les bases se nomment sans collision, le registre se précharge, le lanceur de tests n'oublie plus de fichier, et un hook tient le sujet des commits. --- EN --- Twenty-two commits. A production copy now opens without reaching anything outside it: Odoo's `exp_duplicate_database` cuts the source, regenerates the uuid, carries the filestore over and neutralises — from 16 onwards only. Below it a repository script takes over rather than refusing, and the run announces which one served, because the two do not make the same gestures. Anonymisation runs its whole blacklist through — the SQL by way of a file, a single argument carrying no more than 131,072 bytes — and no longer spares the names it was meant to mask. It leaves alone the identifier paths Odoo passes back through `int()`, and draws its numbers from their own column's measured range: the masked record still opens. A failed migration no longer passes for a successful one. Every step's verdicts, 12 through 18, surface in a quality screen, with the log passage that explains them and the tools' output as they wrote it: a pseudo-terminal captures it without silencing the prompts conditioned on `isatty`, ANSI stripped and secrets redacted. There, exit code 1 means findings, not failure. Three silent drifts become detectable: an addons repository missing from one step, a removed view type surviving in sources no database shows, a chart of accounts swelling under a reloaded template. The pricelist stops being duplicated empty: the decision is taken on what `base.group_user` implies rather than on the runner's groups, and a pricelist shared across companies at last counts for each of them. The rest holds the chain: `odoo_bin.sh` reads the repository's config.conf through ODOO_RC, databases are named without collision, the registry is preloaded, the test runner no longer skips a file, and a hook holds commit subjects. Assisted-by: Claude Opus 5
This commit is contained in:
commit
960918c939
60 changed files with 9434 additions and 163 deletions
|
|
@ -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 :
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
<manifest>
|
||||
<remote name="CybroOdoo" fetch="https://github.com/CybroOdoo/" />
|
||||
<remote name="ERPLibre" fetch="https://github.com/ERPLibre/" />
|
||||
<remote name="ERPLibre_origin_MathBenTech" fetch="https://github.com/ERPLibre/" />
|
||||
<remote name="MathBenTech" fetch="https://github.com/MathBenTech/" />
|
||||
<remote name="ERPLibre_origin_TechnoLibre" fetch="https://github.com/ERPLibre/" />
|
||||
<remote name="Numigi" fetch="https://github.com/Numigi/" />
|
||||
|
|
@ -240,6 +241,13 @@
|
|||
revision="13.0"
|
||||
groups="addons,odoo13.0"
|
||||
/>
|
||||
<project
|
||||
name="development.git"
|
||||
path="odoo13.0/addons/MathBenTech_development"
|
||||
remote="ERPLibre_origin_MathBenTech"
|
||||
revision="13.0"
|
||||
groups="addons,odoo13.0,base"
|
||||
/>
|
||||
<project
|
||||
name="donation.git"
|
||||
path="odoo13.0/addons/OCA_donation"
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
<manifest>
|
||||
<remote name="CybroOdoo" fetch="https://github.com/CybroOdoo/" />
|
||||
<remote name="ERPLibre" fetch="https://github.com/ERPLibre/" />
|
||||
<remote name="ERPLibre_origin_MathBenTech" fetch="https://github.com/ERPLibre/" />
|
||||
<remote name="MathBenTech" fetch="https://github.com/MathBenTech/" />
|
||||
<remote name="ERPLibre_origin_TechnoLibre" fetch="https://github.com/ERPLibre/" />
|
||||
<remote name="Numigi" fetch="https://github.com/Numigi/" />
|
||||
|
|
@ -244,6 +245,13 @@
|
|||
revision="15.0"
|
||||
groups="addons,odoo15.0"
|
||||
/>
|
||||
<project
|
||||
name="development.git"
|
||||
path="odoo15.0/addons/MathBenTech_development"
|
||||
remote="ERPLibre_origin_MathBenTech"
|
||||
revision="15.0"
|
||||
groups="addons,odoo15.0,base"
|
||||
/>
|
||||
<project
|
||||
name="donation.git"
|
||||
path="odoo15.0/addons/OCA_donation"
|
||||
|
|
|
|||
|
|
@ -2,6 +2,11 @@
|
|||
# This is required to change environment for the running Odoo
|
||||
source ./.venv.$(< .erplibre-version)/bin/activate
|
||||
|
||||
# Le config.conf du DÉPÔT, pas le ~/.odoorc de qui lance. Un « -c »
|
||||
# explicite l'emporte toujours : Odoo lit opt.config avant ODOO_RC.
|
||||
source ./script/lib_odoo_rc.sh
|
||||
odoo_rc_resolve "$(pwd)"
|
||||
|
||||
ODOO_PATH="$(pwd)/odoo$(< .odoo-version)"
|
||||
#export PATH=$ODOO_PATH:$PATH
|
||||
#echo $PATH
|
||||
|
|
|
|||
29
run.sh
29
run.sh
|
|
@ -17,6 +17,7 @@ fi
|
|||
# main : Odoo ne les connaît pas et mourrait sur « no such option ».
|
||||
# --auto-erplibre arme le choix de la base à démarrer
|
||||
# --no-cli-erplibre interdit le menu, sans interdire le choix
|
||||
# --erplibre-disable-warmup-http n'envoie pas la requête de réveil
|
||||
# Sans AUCUN argument, le choix s'arme de lui-même : c'est « make run »,
|
||||
# quelqu'un devant son terminal. Ce défaut-là reste timide — il exige un
|
||||
# terminal des deux côtés — parce que systemd lance lui aussi run.sh sans
|
||||
|
|
@ -25,6 +26,7 @@ EL_ARGS=()
|
|||
EL_AUTO=0
|
||||
EL_AUTO_EXPLICITE=0
|
||||
EL_NO_CLI=0
|
||||
EL_WARMUP=1
|
||||
[ $# -eq 0 ] && EL_AUTO=1
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
|
|
@ -33,6 +35,13 @@ while [ $# -gt 0 ]; do
|
|||
EL_AUTO_EXPLICITE=1
|
||||
;;
|
||||
--no-cli-erplibre) EL_NO_CLI=1 ;;
|
||||
--erplibre-disable-warmup-http) EL_WARMUP=0 ;;
|
||||
# Rien à réveiller si personne n'écoute. On les laisse passer à Odoo,
|
||||
# on se contente de ne pas sonder.
|
||||
--no-http|--stop-after-init)
|
||||
EL_WARMUP=0
|
||||
EL_ARGS+=("$1")
|
||||
;;
|
||||
# Tout le reste passe tel quel, y compris les arguments vides et ceux
|
||||
# qui portent des espaces : un tableau, jamais une chaîne reconstruite.
|
||||
*) EL_ARGS+=("$1") ;;
|
||||
|
|
@ -58,6 +67,26 @@ if [ "${EL_AUTO}" = "1" ] && [ -f "${EL_LIB}" ]; then
|
|||
fi
|
||||
fi
|
||||
|
||||
# Le réveil, EN PARALLÈLE. Odoo ne charge le registre d'une base qu'à la
|
||||
# première requête qui la concerne ; sur une base migrée cela prend des
|
||||
# dizaines de secondes, et c'est la personne qui ouvre la page qui les
|
||||
# attend. La sonde prend ce temps à sa place, se tait, et meurt avec nous.
|
||||
EL_WARMUP_PID=""
|
||||
EL_WARMUP_BIN="./script/odoo/warmup_http.py"
|
||||
if [ "${EL_WARMUP}" = "1" ] && [ "$ODOO_MODE_TEST" != "true" ] \
|
||||
&& [ -f "${EL_WARMUP_BIN}" ]; then
|
||||
EL_WARMUP_DB=()
|
||||
[ -n "${EL_DB_NAME}" ] && EL_WARMUP_DB=(-d "${EL_DB_NAME}")
|
||||
# `--` sépare NOS options de celles d'Odoo : sans lui, un « -c » destiné
|
||||
# à Odoo serait lu comme le nôtre.
|
||||
python3 "${EL_WARMUP_BIN}" -c "${CONFIG_PATH}" "${EL_WARMUP_DB[@]}" \
|
||||
-- "${EL_ARGS[@]}" 2>/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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
376
script/analyse/check_chart_drift.py
Executable file
376
script/analyse/check_chart_drift.py
Executable file
|
|
@ -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())
|
||||
334
script/analyse/check_manifest_gaps.py
Executable file
334
script/analyse/check_manifest_gaps.py
Executable file
|
|
@ -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 <project> entry to')}"
|
||||
f" manifest/git_manifest_odoo<version>.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())
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
525
script/analyse/check_view_type_tree.py
Executable file
525
script/analyse/check_view_type_tree.py
Executable file
|
|
@ -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 `<tree>` 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 `<record>`, un `<field name="name">`, 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 `<list>`.
|
||||
|
||||
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 `<tree>` sous un x2many n'est pas ignoré poliment : `ir_ui_view.py`
|
||||
ne le reconnaît pas, donc ne le RETIRE pas, et ses `<field>` 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 `<tree` commençait à 61. Citer la fin envoie chercher au mauvais
|
||||
endroit.
|
||||
"""
|
||||
try:
|
||||
with io.open(chemin, encoding="utf-8", errors="replace") as handle:
|
||||
lignes = handle.readlines()
|
||||
except OSError:
|
||||
return sourceline
|
||||
for rang in range(min(sourceline, len(lignes)) - 1, -1, -1):
|
||||
if "<" + balise in lignes[rang]:
|
||||
return rang + 1
|
||||
return sourceline
|
||||
|
||||
|
||||
def constats_xml(chemin):
|
||||
"""[(ligne, motif, extrait, correction)] pour UN fichier de vues."""
|
||||
from lxml import etree
|
||||
|
||||
try:
|
||||
arbre = etree.parse(chemin)
|
||||
except (etree.XMLSyntaxError, OSError):
|
||||
return None # illisible : c'est un constat à part, pas un « tree »
|
||||
lst = []
|
||||
for el in arbre.iter():
|
||||
# Ce test, et lui seul, écarte commentaires et instructions de
|
||||
# traitement : lxml ne construit pas d'élément pour un <!-- -->.
|
||||
if not isinstance(el.tag, str):
|
||||
continue
|
||||
if el.tag == "tree":
|
||||
lst.append(
|
||||
(
|
||||
ligne_de_la_balise(chemin, el.sourceline),
|
||||
"xml_balise",
|
||||
"<tree>",
|
||||
"<list>",
|
||||
)
|
||||
)
|
||||
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())
|
||||
347
script/database/db_duplicate.py
Executable file
347
script/database/db_duplicate.py
Executable file
|
|
@ -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())
|
||||
71
script/git/commit_msg_lib.py
Normal file
71
script/git/commit_msg_lib.py
Normal file
|
|
@ -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
|
||||
48
script/git/hooks/commit-msg
Executable file
48
script/git/hooks/commit-msg
Executable file
|
|
@ -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())
|
||||
38
script/lib_odoo_rc.sh
Executable file
38
script/lib_odoo_rc.sh
Executable file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 $$;
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
|
|
|
|||
227
script/odoo/warmup_http.py
Executable file
227
script/odoo/warmup_http.py
Executable file
|
|
@ -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())
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 !"))
|
||||
|
|
|
|||
|
|
@ -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 <project> entry to": {
|
||||
"fr": "Ajouter l'entrée <project> dans",
|
||||
"en": "Add the <project> 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:",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -164,6 +164,39 @@ class MigrationRewind(Exception):
|
|||
"""
|
||||
|
||||
|
||||
# Le pilote suffixe le nom choisi : « _neutralize » à l'étape 1, puis
|
||||
# « _upgrade_<version> » à 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -177,3 +177,7 @@ class TestMainBadConfig(unittest.TestCase):
|
|||
):
|
||||
result = main()
|
||||
self.assertEqual(result, -1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
338
test/test_check_chart_drift.py
Normal file
338
test/test_check_chart_drift.py
Normal file
|
|
@ -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()
|
||||
391
test/test_check_manifest_gaps.py
Normal file
391
test/test_check_manifest_gaps.py
Normal file
|
|
@ -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 = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<manifest>
|
||||
<remote name="OCA" fetch="https://github.com/OCA/" />
|
||||
<remote name="autre" fetch="https://example.org/eq/" />
|
||||
%s
|
||||
</manifest>
|
||||
"""
|
||||
|
||||
PROJET = """ <project
|
||||
name="%s"
|
||||
path="odoo%s/addons/%s"
|
||||
remote="%s"
|
||||
revision="%s"
|
||||
groups="addons,odoo%s"
|
||||
/>"""
|
||||
|
||||
|
||||
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("<manifest><project")
|
||||
self.assertIn("a.git", gaps.declarations(self.dossier))
|
||||
|
||||
|
||||
class TestWhatCountsAsAHole(Base):
|
||||
def test_absent_in_the_middle_is_a_hole(self):
|
||||
self.ecrire("16.0", ["sale-channel.git"])
|
||||
self.ecrire("17.0", [])
|
||||
self.ecrire("18.0", ["sale-channel.git"])
|
||||
trous = self.trous()
|
||||
self.assertEqual(1, len(trous))
|
||||
self.assertEqual(["17.0"], trous[0][2])
|
||||
|
||||
def test_absent_before_the_first_is_not_a_hole(self):
|
||||
# Un dépôt né en 16 n'a rien à faire en 12 ; l'exiger crierait
|
||||
# sur presque tout le fichier.
|
||||
self.ecrire("12.0", [])
|
||||
self.ecrire("16.0", ["neuf.git"])
|
||||
self.ecrire("18.0", ["neuf.git"])
|
||||
self.assertEqual([], self.trous())
|
||||
|
||||
def test_absent_after_the_last_is_not_a_hole(self):
|
||||
# Un dépôt abandonné après la 14 non plus.
|
||||
self.ecrire("12.0", ["vieux.git"])
|
||||
self.ecrire("14.0", ["vieux.git"])
|
||||
self.ecrire("18.0", [])
|
||||
self.assertEqual([], self.trous())
|
||||
|
||||
def test_a_single_appearance_is_never_a_hole(self):
|
||||
self.ecrire("12.0", [])
|
||||
self.ecrire("14.0", ["seul.git"])
|
||||
self.ecrire("18.0", [])
|
||||
self.assertEqual([], self.trous())
|
||||
|
||||
def test_several_missing_steps_are_all_named(self):
|
||||
self.ecrire("12.0", ["long.git"])
|
||||
self.ecrire("13.0", [])
|
||||
self.ecrire("14.0", [])
|
||||
self.ecrire("15.0", [])
|
||||
self.ecrire("16.0", ["long.git"])
|
||||
self.assertEqual(["13.0", "14.0", "15.0"], self.trous()[0][2])
|
||||
|
||||
|
||||
class TestSettlingAHole(Base):
|
||||
"""Le tri qui fait tout l'intérêt de l'outil."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.ecrire("16.0", ["a.git", "b.git"])
|
||||
self.ecrire("17.0", [])
|
||||
self.ecrire("18.0", ["a.git", "b.git"])
|
||||
self.appels = []
|
||||
|
||||
def lecteur(self, reponses):
|
||||
def lire(url):
|
||||
self.appels.append(url)
|
||||
return reponses.get(url.rsplit("/", 1)[-1])
|
||||
|
||||
return lire
|
||||
|
||||
def test_without_asking_nothing_is_settled(self):
|
||||
juges = gaps.judge(self.trous(), verifier=False)
|
||||
self.assertEqual({"inconnu"}, {j[3] for j in juges})
|
||||
self.assertEqual([], self.appels)
|
||||
|
||||
def test_a_branch_upstream_makes_it_an_omission(self):
|
||||
juges = gaps.judge(
|
||||
self.trous(),
|
||||
verifier=True,
|
||||
lecteur=self.lecteur({"a.git": {"17.0"}, "b.git": {"17.0"}}),
|
||||
)
|
||||
self.assertEqual({"omission"}, {j[3] for j in juges})
|
||||
|
||||
def test_no_branch_upstream_makes_it_legitimate(self):
|
||||
juges = gaps.judge(
|
||||
self.trous(),
|
||||
verifier=True,
|
||||
lecteur=self.lecteur({"a.git": {"16.0", "18.0"}, "b.git": set()}),
|
||||
)
|
||||
self.assertEqual({"legitime"}, {j[3] for j in juges})
|
||||
|
||||
def test_unreachable_is_not_absolution(self):
|
||||
# None dit « je n'ai pas pu demander », l'ensemble vide dit « il
|
||||
# n'y en a pas ». Les confondre transformerait une coupure réseau
|
||||
# en rapport tout vert.
|
||||
juges = gaps.judge(
|
||||
self.trous(),
|
||||
verifier=True,
|
||||
lecteur=self.lecteur({"a.git": None, "b.git": None}),
|
||||
)
|
||||
self.assertEqual({"inconnu"}, {j[3] for j in juges})
|
||||
|
||||
def test_each_repository_is_asked_once(self):
|
||||
gaps.judge(
|
||||
self.trous(),
|
||||
verifier=True,
|
||||
lecteur=self.lecteur({"a.git": set(), "b.git": set()}),
|
||||
)
|
||||
self.assertEqual(len(self.appels), len(set(self.appels)))
|
||||
self.assertEqual(2, len(self.appels))
|
||||
|
||||
|
||||
class TestAskingTheRemote(unittest.TestCase):
|
||||
"""La seule fonction qui parle au réseau — bouchonnée, jamais appelée."""
|
||||
|
||||
def setUp(self):
|
||||
self.vrai = gaps.subprocess
|
||||
self.addCleanup(setattr, gaps, "subprocess", self.vrai)
|
||||
|
||||
def repondre(self, sortie="", code=0, leve=None):
|
||||
essai = self
|
||||
|
||||
class Faux:
|
||||
@staticmethod
|
||||
def run(argv, capture_output=None, text=None, timeout=None):
|
||||
essai.argv = argv
|
||||
if leve:
|
||||
raise leve
|
||||
return type("R", (), {"returncode": code, "stdout": sortie})
|
||||
|
||||
Faux.SubprocessError = self.vrai.SubprocessError
|
||||
Faux.TimeoutExpired = self.vrai.TimeoutExpired
|
||||
gaps.subprocess = Faux
|
||||
|
||||
def test_no_url_asks_nothing_and_settles_nothing(self):
|
||||
# Sans URL on ne peut pas demander ; rendre un ensemble vide
|
||||
# dirait « l'amont n'a pas la branche », ce qu'on ignore.
|
||||
self.repondre()
|
||||
self.assertIsNone(gaps.branches(""))
|
||||
self.assertFalse(hasattr(self, "argv"))
|
||||
|
||||
def test_only_version_branches_are_kept(self):
|
||||
# « master » et « main » ne sont pas des paliers d'Odoo.
|
||||
self.repondre(
|
||||
"abc\trefs/heads/16.0\n"
|
||||
"def\trefs/heads/master\n"
|
||||
"012\trefs/heads/17.0\n"
|
||||
"345\trefs/heads/feature/x\n"
|
||||
)
|
||||
self.assertEqual({"16.0", "17.0"}, gaps.branches("https://x/y.git"))
|
||||
|
||||
def test_a_failed_call_is_not_settled(self):
|
||||
self.repondre(code=128)
|
||||
self.assertIsNone(gaps.branches("https://x/y.git"))
|
||||
|
||||
def test_a_timeout_is_not_settled(self):
|
||||
self.repondre(leve=self.vrai.TimeoutExpired("git", 1))
|
||||
self.assertIsNone(gaps.branches("https://x/y.git"))
|
||||
|
||||
def test_a_missing_git_is_not_settled(self):
|
||||
self.repondre(leve=OSError("git introuvable"))
|
||||
self.assertIsNone(gaps.branches("https://x/y.git"))
|
||||
|
||||
def test_a_repository_with_no_version_branch_is_settled_as_empty(self):
|
||||
# Là, on a DEMANDÉ et la réponse est « aucune » : c'est un
|
||||
# verdict, pas une ignorance.
|
||||
self.repondre("abc\trefs/heads/master\n")
|
||||
self.assertEqual(set(), gaps.branches("https://x/y.git"))
|
||||
|
||||
def test_it_only_reads_never_clones(self):
|
||||
self.repondre("")
|
||||
gaps.branches("https://x/y.git")
|
||||
self.assertEqual(
|
||||
["git", "ls-remote", "--heads", "https://x/y.git"], self.argv
|
||||
)
|
||||
|
||||
|
||||
class TestTheReport(Base):
|
||||
def rapport(self, juges, verifie=True):
|
||||
return gaps.render(juges, verifie, colour=False)
|
||||
|
||||
def test_an_omission_names_the_repository_and_the_step(self):
|
||||
texte = self.rapport(
|
||||
[("sale-channel.git", ["17.0"], ["17.0"], "omission")]
|
||||
)
|
||||
self.assertIn("sale-channel.git", texte)
|
||||
self.assertIn("17.0", texte)
|
||||
|
||||
def test_an_omission_says_what_to_do(self):
|
||||
texte = self.rapport([("a.git", ["17.0"], ["17.0"], "omission")])
|
||||
self.assertIn("git_manifest_odoo", texte)
|
||||
|
||||
def test_a_legitimate_hole_is_counted_not_listed(self):
|
||||
# Seize lignes de « tout va bien » noieraient les dix-neuf qui
|
||||
# comptent.
|
||||
texte = self.rapport([("a.git", ["13.0"], [], "legitime")])
|
||||
self.assertNotIn("a.git", texte)
|
||||
self.assertIn("1", texte)
|
||||
|
||||
def test_offline_says_it_did_not_ask(self):
|
||||
texte = self.rapport(
|
||||
[("a.git", ["17.0"], [], "inconnu")], verifie=False
|
||||
)
|
||||
self.assertIn(gaps.t("not asked: run with --upstream"), texte)
|
||||
|
||||
def test_unreachable_does_not_read_like_offline(self):
|
||||
texte = self.rapport(
|
||||
[("a.git", ["17.0"], [], "inconnu")], verifie=True
|
||||
)
|
||||
self.assertIn(gaps.t("upstream unreachable"), texte)
|
||||
|
||||
def test_nothing_at_all_is_stated(self):
|
||||
self.assertIn(
|
||||
gaps.t("Every repository is declared without a hole."),
|
||||
self.rapport([]),
|
||||
)
|
||||
|
||||
|
||||
class TestTheCommand(Base):
|
||||
def lancer(self, argv):
|
||||
tampon = io.StringIO()
|
||||
with redirect_stdout(tampon):
|
||||
code = gaps.main(argv)
|
||||
return code, tampon.getvalue()
|
||||
|
||||
def test_a_missing_directory_is_a_tool_failure(self):
|
||||
code, _ = self.lancer(
|
||||
["--manifest-dir", os.path.join(self.dossier, "absent")]
|
||||
)
|
||||
self.assertEqual(2, code)
|
||||
|
||||
def test_no_hole_exits_zero(self):
|
||||
self.ecrire("16.0", ["a.git"])
|
||||
self.ecrire("18.0", ["a.git"])
|
||||
code, _ = self.lancer(["--manifest-dir", self.dossier, "--no-color"])
|
||||
self.assertEqual(0, code)
|
||||
|
||||
def test_an_unsettled_hole_alone_does_not_exit_one(self):
|
||||
# Sans --upstream on ne SAIT pas : sortir 1 ferait échouer une
|
||||
# chaîne d'intégration sur une question qu'on n'a pas posée.
|
||||
self.ecrire("16.0", ["a.git"])
|
||||
self.ecrire("17.0", [])
|
||||
self.ecrire("18.0", ["a.git"])
|
||||
code, texte = self.lancer(
|
||||
["--manifest-dir", self.dossier, "--no-color"]
|
||||
)
|
||||
self.assertEqual(0, code)
|
||||
self.assertIn("a.git", texte)
|
||||
|
||||
def test_the_json_carries_the_state_of_each_hole(self):
|
||||
import json
|
||||
|
||||
self.ecrire("16.0", ["a.git"])
|
||||
self.ecrire("17.0", [])
|
||||
self.ecrire("18.0", ["a.git"])
|
||||
_code, texte = self.lancer(["--manifest-dir", self.dossier, "--json"])
|
||||
dct = json.loads(texte)
|
||||
self.assertFalse(dct["checked_upstream"])
|
||||
self.assertEqual("a.git", dct["gaps"][0]["repository"])
|
||||
self.assertEqual("inconnu", dct["gaps"][0]["state"])
|
||||
|
||||
|
||||
class TestAgainstTheRealManifests(unittest.TestCase):
|
||||
"""Sur le vrai dossier — sans réseau, donc sans trancher."""
|
||||
|
||||
def test_the_repository_manifests_parse(self):
|
||||
lst = gaps.versions()
|
||||
self.assertGreaterEqual(len(lst), 7)
|
||||
dct = gaps.declarations()
|
||||
self.assertGreater(len(dct), 100)
|
||||
|
||||
def test_development_is_declared_at_every_step_it_spans(self):
|
||||
# Les paliers 13 et 15 lui manquaient, et une migration 12 → 18
|
||||
# les traverse tous les deux.
|
||||
dct = gaps.declarations()
|
||||
presentes = set(dct.get("development.git", {}))
|
||||
for version in (
|
||||
"12.0",
|
||||
"13.0",
|
||||
"14.0",
|
||||
"15.0",
|
||||
"16.0",
|
||||
"17.0",
|
||||
"18.0",
|
||||
):
|
||||
self.assertIn(version, presentes, version)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -1025,26 +1025,71 @@ class TestItNeverWrites(Base):
|
|||
def test_no_odoo_is_started(self):
|
||||
# Six démarrages coûteraient une heure ET écriraient dans les
|
||||
# bases. L'inspection en SQL prend moins d'une demi-seconde.
|
||||
#
|
||||
# La garde porte sur le CODE, fonction par fonction, et non sur le
|
||||
# texte du fichier : la revue CITE « ./odoo_bin.sh shell » comme
|
||||
# étape à faire soi-même, et interdire le mot interdirait de le
|
||||
# nommer. Ce qu'on veut garantir est que rien ici ne le LANCE.
|
||||
import inspect
|
||||
|
||||
source = inspect.getsource(quality)
|
||||
for interdit in ("odoo_bin", "run.sh", "--update", "-u all"):
|
||||
self.assertNotIn(interdit, source, interdit)
|
||||
for nom, objet in vars(quality).items():
|
||||
if not inspect.isfunction(objet):
|
||||
continue
|
||||
if objet.__module__ != quality.__name__:
|
||||
continue
|
||||
source = inspect.getsource(objet)
|
||||
for interdit in ("odoo_bin", "run.sh", "--update", "-u all"):
|
||||
self.assertNotIn(interdit, source, f"{nom} → {interdit}")
|
||||
|
||||
def test_the_only_process_it_launches_is_psql(self):
|
||||
# Complément du précédent : une donnée peut nommer un programme,
|
||||
# un `subprocess.run` le lance. Il n'y en a qu'un, et c'est psql.
|
||||
import ast
|
||||
import inspect
|
||||
|
||||
arbre = ast.parse(inspect.getsource(quality))
|
||||
lances = [
|
||||
n
|
||||
for n in ast.walk(arbre)
|
||||
if isinstance(n, ast.Call)
|
||||
and isinstance(n.func, ast.Attribute)
|
||||
and n.func.attr == "run"
|
||||
and getattr(n.func.value, "id", "") == "subprocess"
|
||||
]
|
||||
self.assertEqual(1, len(lances))
|
||||
premier = lances[0].args[0]
|
||||
self.assertEqual("psql", premier.elts[0].value)
|
||||
|
||||
|
||||
class TestTheFullScreen(Base):
|
||||
def test_one_row_per_step_plus_the_overall(self):
|
||||
def test_one_row_per_step_then_the_overall(self):
|
||||
lst = [snapshot(odoo="12.0"), snapshot(odoo="18.0")]
|
||||
lignes = qtui.rows(lst)
|
||||
self.assertEqual(
|
||||
[x["kind"] for x in lignes], ["step", "step", "overall"]
|
||||
)
|
||||
lignes = qtui.rows(lst, {})
|
||||
debut = [x["kind"] for x in lignes][:3]
|
||||
self.assertEqual(debut, ["step", "step", "overall"])
|
||||
|
||||
def test_the_overall_comes_LAST(self):
|
||||
def test_the_overall_closes_the_steps(self):
|
||||
# On descend la liste comme on a vécu la migration ; « qu'en
|
||||
# reste-t-il » se pose une fois le chemin vu.
|
||||
# reste-t-il » se pose une fois le chemin vu. Ce qui suit — les
|
||||
# verdicts, où les lire, quoi vérifier — répond à « et après ».
|
||||
lst = [snapshot(odoo="12.0"), snapshot(odoo="18.0")]
|
||||
self.assertEqual(qtui.rows(lst)[-1]["kind"], "overall")
|
||||
genres = [x["kind"] for x in qtui.rows(lst, {})]
|
||||
rang = genres.index("overall")
|
||||
self.assertEqual(set(genres[:rang]), {"step"})
|
||||
self.assertNotIn("step", genres[rang:])
|
||||
|
||||
def test_the_review_sections_come_after_the_overall(self):
|
||||
lst = [snapshot(odoo="12.0"), snapshot(odoo="18.0")]
|
||||
genres = [x["kind"] for x in qtui.rows(lst, {})]
|
||||
apres = set(genres[genres.index("overall") + 1 :])
|
||||
self.assertTrue(apres <= set(qtui.EXTRA_KINDS) | {"header"}, apres)
|
||||
|
||||
def test_the_screen_does_not_depend_on_this_machine(self):
|
||||
# Sans `dct`, la liste refléterait ce qu'une migration a laissé
|
||||
# sur CE poste : deux exécutions ne rendraient pas la même chose.
|
||||
lst = [snapshot(odoo="12.0"), snapshot(odoo="18.0")]
|
||||
avec = qtui.rows(lst, {"lst_event": []})
|
||||
self.assertNotIn("verdict", [x["kind"] for x in avec])
|
||||
|
||||
def test_a_missing_database_gets_its_own_row(self):
|
||||
lignes = qtui.rows(
|
||||
|
|
@ -1587,5 +1632,882 @@ class TestWhyAnAttachmentWentAway(Base):
|
|||
self.assertNotEqual("ir_attachment", entree.get("table"))
|
||||
|
||||
|
||||
def evenement(**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 TestTheVerdictsAreReadFromTheFile(Base):
|
||||
"""`lst_event` est la SEULE trace persistante d'un échec.
|
||||
|
||||
`command_executed` ne dit que ce qui a été lancé. La sortie d'Odoo,
|
||||
elle, part sur le terminal et meurt avec lui.
|
||||
"""
|
||||
|
||||
def test_a_status_written_as_text_still_counts(self):
|
||||
# Le pilote écrit parfois le code en chaîne ; le comparer à zéro
|
||||
# sans le convertir ferait passer "0" pour un échec.
|
||||
lus = quality.read_events({"lst_event": [evenement(status="1")]})
|
||||
self.assertEqual(1, lus[0]["status"])
|
||||
|
||||
def test_a_zero_written_as_text_is_not_a_failure(self):
|
||||
lus = quality.read_events({"lst_event": [evenement(status="0")]})
|
||||
self.assertEqual([], quality.failures(lus))
|
||||
|
||||
def test_an_unreadable_status_is_read_as_success(self):
|
||||
# Mieux vaut taire un verdict illisible que crier un faux échec.
|
||||
lus = quality.read_events({"lst_event": [evenement(status="oui")]})
|
||||
self.assertEqual(0, lus[0]["status"])
|
||||
|
||||
def test_an_entry_that_is_not_a_record_is_skipped(self):
|
||||
lus = quality.read_events({"lst_event": ["cassé", evenement()]})
|
||||
self.assertEqual(1, len(lus))
|
||||
|
||||
def test_no_list_at_all_reads_as_no_verdict(self):
|
||||
self.assertEqual([], quality.read_events({}))
|
||||
self.assertEqual([], quality.read_events({"lst_event": None}))
|
||||
|
||||
def test_a_dialogue_answer_is_not_a_failed_test(self):
|
||||
# Les entrées `command` à 1 sont les réponses du pilote — « veux-tu
|
||||
# effacer le module manquant ». Les compter comme des échecs
|
||||
# noierait les vrais sous une liste qu'on cesse de lire.
|
||||
lus = quality.read_events(
|
||||
{
|
||||
"lst_event": [
|
||||
evenement(kind="command", name="./odoo_bin.sh db --clone"),
|
||||
evenement(kind="test"),
|
||||
]
|
||||
}
|
||||
)
|
||||
ratés = quality.failures(lus)
|
||||
self.assertEqual(["smoke_public_url"], [e["name"] for e in ratés])
|
||||
|
||||
|
||||
class TestWhichStepAVerdictBelongsTo(Base):
|
||||
def test_the_database_is_read_from_the_command(self):
|
||||
self.assertEqual(
|
||||
"test_neutralize_upgrade_14",
|
||||
quality.event_database(evenement()),
|
||||
)
|
||||
|
||||
def test_a_command_without_database_yields_nothing(self):
|
||||
self.assertEqual("", quality.event_database(evenement(detail="")))
|
||||
|
||||
def test_the_step_comes_from_the_database_not_the_counter(self):
|
||||
# `step` compte les ÉTAPES du pilote — « 4.1.I » — et non les
|
||||
# versions d'Odoo : les deux sont décalées d'un rang. Afficher
|
||||
# « 4.1 » à côté de « palier » nommerait la mauvaise version.
|
||||
self.assertEqual("14", quality.event_step(evenement()))
|
||||
|
||||
def test_without_a_step_database_it_falls_back_to_the_counter(self):
|
||||
seul = evenement(detail="", step="4.1.I - Migrate database")
|
||||
self.assertEqual("4.1.I", quality.event_step(seul))
|
||||
|
||||
|
||||
class TestTheMapOfTraces(Base):
|
||||
"""Documenter les chemins EST la fonctionnalité."""
|
||||
|
||||
def test_both_places_are_named_even_when_one_is_empty(self):
|
||||
sources = quality.log_sources(
|
||||
path="rien.json", journal="rien_non_plus.log"
|
||||
)
|
||||
self.assertEqual(2, len(sources))
|
||||
for _role, _chemin, existe, _quoi in sources:
|
||||
self.assertFalse(existe)
|
||||
|
||||
def test_the_missing_journal_is_shown_not_hidden(self):
|
||||
# Le taire laisserait chercher un fichier qu'Odoo n'a jamais
|
||||
# écrit, faute de `logfile=` dans config.conf.
|
||||
chemins = [c for _r, c, _e, _q in quality.log_sources()]
|
||||
self.assertIn(quality.JOURNAL_ODOO, chemins)
|
||||
|
||||
|
||||
class TestScanningTheOdooLog(Base):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
import tempfile
|
||||
|
||||
self.dossier = tempfile.mkdtemp()
|
||||
self.addCleanup(__import__("shutil").rmtree, self.dossier)
|
||||
self.journal = os.path.join(self.dossier, "odoo.log")
|
||||
|
||||
def ecrire(self, texte):
|
||||
with io.open(self.journal, "w", encoding="utf-8") as handle:
|
||||
handle.write(texte)
|
||||
|
||||
def test_a_missing_file_is_not_an_error(self):
|
||||
rapport = quality.scan_log(os.path.join(self.dossier, "absent.log"))
|
||||
self.assertFalse(rapport["exists"])
|
||||
self.assertEqual([], rapport["lines"])
|
||||
|
||||
def test_each_pattern_is_counted(self):
|
||||
self.ecrire("INFO ok\nERROR boum\nCRITICAL pire\nTraceback (x)\n")
|
||||
rapport = quality.scan_log(self.journal)
|
||||
for motif in quality.MOTIFS_ERREUR:
|
||||
self.assertEqual(1, rapport["counts"][motif], motif)
|
||||
|
||||
def test_a_clean_log_counts_zero_and_shows_nothing(self):
|
||||
self.ecrire("INFO tout va bien\nINFO encore\n")
|
||||
rapport = quality.scan_log(self.journal)
|
||||
self.assertEqual([], rapport["lines"])
|
||||
self.assertEqual(0, rapport["counts"]["ERROR"])
|
||||
|
||||
def test_only_the_last_lines_are_kept(self):
|
||||
# Un journal de migration pèse des dizaines de mégaoctets ; ce
|
||||
# qu'on cherche est ce qui a échoué en DERNIER.
|
||||
self.ecrire("".join("ERROR %d\n" % i for i in range(50)))
|
||||
rapport = quality.scan_log(self.journal, limite=3)
|
||||
self.assertEqual(3, len(rapport["lines"]))
|
||||
self.assertIn("ERROR 49", rapport["lines"][-1])
|
||||
|
||||
|
||||
class TestTheReviewChecklist(Base):
|
||||
"""Une liste de contrôle qu'il faut aller chercher n'est pas suivie."""
|
||||
|
||||
def test_every_step_names_a_question(self):
|
||||
for question, _commande, _clef in quality.REVUE:
|
||||
self.assertTrue(question.endswith("?"), question)
|
||||
|
||||
def test_a_runnable_step_either_names_a_database_or_reads_the_checkout(
|
||||
self,
|
||||
):
|
||||
# Une seule étape lit le CHECKOUT et non une base ; toutes les
|
||||
# autres doivent nommer la leur, sinon elles s'exécuteraient sur
|
||||
# celle du fichier de configuration, qui n'est pas la migrée.
|
||||
sans_base = 0
|
||||
for _q, commande, clef in quality.REVUE:
|
||||
if not clef:
|
||||
continue
|
||||
if "{db}" in commande:
|
||||
continue
|
||||
sans_base += 1
|
||||
self.assertIn("script/analyse/", commande, commande)
|
||||
self.assertTrue(sans_base)
|
||||
|
||||
def test_the_checkout_step_needs_no_database(self):
|
||||
# C'est l'angle mort des six autres : elles lisent toutes la BASE
|
||||
# et ne peuvent pas voir un dépôt d'addons absent d'un palier.
|
||||
sans_base = [
|
||||
(q, c) for q, c, k in quality.REVUE if k and "{db}" not in c
|
||||
]
|
||||
self.assertTrue(sans_base)
|
||||
for _question, commande in sans_base:
|
||||
self.assertTrue(commande.startswith("script/analyse/"), commande)
|
||||
|
||||
def test_the_first_step_has_nothing_to_run(self):
|
||||
# « La migration est-elle allée jusqu'au bout » se lit dans le
|
||||
# fichier ; aucune commande ne le rejoue.
|
||||
self.assertIsNone(quality.REVUE[0][2])
|
||||
|
||||
|
||||
class TestTheThreeExtraSections(Base):
|
||||
"""Elles vivent dans la MÊME table que les paliers."""
|
||||
|
||||
def entetes(self, lst):
|
||||
return [r["label"] for r in lst if r["kind"] == "header"]
|
||||
|
||||
def test_the_sections_come_in_the_order_of_the_work(self):
|
||||
lst = qtui.extra_rows([], {})
|
||||
self.assertEqual(
|
||||
[
|
||||
f"── {qtui.t('Verdicts')} ──",
|
||||
f"── {qtui.t('Validation')} ──",
|
||||
f"── {qtui.t('Review')} ──",
|
||||
],
|
||||
self.entetes(lst),
|
||||
)
|
||||
|
||||
def test_no_verdict_at_all_says_so(self):
|
||||
lst = qtui.extra_rows([], {})
|
||||
self.assertIn("verdict-none", [r["kind"] for r in lst])
|
||||
|
||||
def test_a_failed_verdict_names_its_step_in_the_label(self):
|
||||
# « smoke_public_url » quatre fois de suite ne dit pas lequel a
|
||||
# échoué, et c'est la seule chose qu'on veut savoir.
|
||||
lst = qtui.extra_rows([], {"lst_event": [evenement()]})
|
||||
ligne = [r for r in lst if r["kind"] == "verdict"][0]
|
||||
self.assertIn("14", ligne["label"])
|
||||
self.assertIn("smoke_public_url", ligne["label"])
|
||||
|
||||
def test_a_failed_verdict_offers_a_way_to_replay_it(self):
|
||||
lst = qtui.extra_rows([], {"lst_event": [evenement()]})
|
||||
ligne = [r for r in lst if r["kind"] == "verdict"][0]
|
||||
self.assertEqual("▶", ligne["detail"])
|
||||
self.assertTrue(ligne["command"])
|
||||
|
||||
def premier_entete(self, lst):
|
||||
return [r for r in lst if r["kind"] == "header"][0]
|
||||
|
||||
def test_review_steps_target_the_last_database(self):
|
||||
presents = [
|
||||
{"database": "base_upgrade_17", "exists": True},
|
||||
{"database": "base_upgrade_18", "exists": True},
|
||||
]
|
||||
lst = qtui.extra_rows(presents, {})
|
||||
commandes = [
|
||||
r["command"] for r in lst if r["kind"] == "review" and r["command"]
|
||||
]
|
||||
self.assertTrue(commandes)
|
||||
nommant_une_base = [c for c in commandes if " -d " in c]
|
||||
self.assertTrue(nommant_une_base)
|
||||
for commande in nommant_une_base:
|
||||
self.assertIn("base_upgrade_18", commande)
|
||||
for commande in commandes:
|
||||
self.assertNotIn("{db}", commande)
|
||||
|
||||
def test_without_a_database_only_the_db_free_step_can_be_run(self):
|
||||
# La revue du checkout — manifestes, types de vue — se lance
|
||||
# justement AVANT qu'une migration existe. Exiger une base la
|
||||
# rendait inerte au seul moment où elle sert.
|
||||
lst = qtui.extra_rows([], {})
|
||||
lancables = [r for r in lst if r["kind"] == "review" and r["command"]]
|
||||
attendu = [c for _q, c, k in quality.REVUE if k and "{db}" not in c]
|
||||
self.assertEqual(len(attendu), len(lancables))
|
||||
self.assertTrue(lancables)
|
||||
for ligne in lancables:
|
||||
self.assertNotIn("{db}", ligne["command"])
|
||||
self.assertNotIn(" -d ", ligne["command"])
|
||||
|
||||
def test_a_step_that_names_a_database_stays_silent_without_one(self):
|
||||
lst = qtui.extra_rows([], {})
|
||||
for ligne in [r for r in lst if r["kind"] == "review"]:
|
||||
if "{db}" in dict((q, c) for q, c, _k in quality.REVUE).get(
|
||||
ligne["question"], ""
|
||||
):
|
||||
self.assertEqual("", ligne["command"], ligne["question"])
|
||||
|
||||
|
||||
class TestEveryVerdictIsListed(Base):
|
||||
"""N'afficher que les échecs répondait à une autre question.
|
||||
|
||||
Devant une base migrée on se demande « qu'a-t-on vérifié », pas
|
||||
seulement « qu'est-ce qui a raté ». Et c'est la seule façon de voir
|
||||
qu'un échec du palier 14 a été RATTRAPÉ au 17.
|
||||
"""
|
||||
|
||||
def journal(self, *statuts):
|
||||
lst = []
|
||||
for rang, statut in enumerate(statuts):
|
||||
version = 13 + rang
|
||||
lst.append(
|
||||
evenement(
|
||||
status=statut,
|
||||
detail=(
|
||||
"./script/odoo/migration/smoke_public_url.py"
|
||||
" -d base_upgrade_%d" % version
|
||||
),
|
||||
)
|
||||
)
|
||||
return {
|
||||
"lst_event": lst,
|
||||
"config_database_name": "base",
|
||||
"target_odoo_version": "18.0",
|
||||
"state_4_upgrade_odoo_lst": [1, 2, 3, 4, 5, 6],
|
||||
}
|
||||
|
||||
def test_a_passing_verdict_gets_its_own_line(self):
|
||||
lst = qtui.extra_rows([], self.journal(0, 0))
|
||||
lignes = [r for r in lst if r["kind"] == "verdict"]
|
||||
self.assertEqual(2, len(lignes))
|
||||
for ligne in lignes:
|
||||
self.assertIn("✅", ligne["label"])
|
||||
|
||||
def test_a_finding_and_a_success_sit_side_by_side(self):
|
||||
lst = qtui.extra_rows([], self.journal(1, 0))
|
||||
icones = [
|
||||
r["label"].strip().split()[0]
|
||||
for r in lst
|
||||
if r["kind"] == "verdict"
|
||||
]
|
||||
self.assertEqual(["⚠", "✅"], icones)
|
||||
|
||||
def test_the_header_counts_failures_over_the_total(self):
|
||||
lst = qtui.extra_rows([], self.journal(1, 0, 0))
|
||||
entete = [r for r in lst if r["kind"] == "header"][0]
|
||||
self.assertEqual("1/3", entete["detail"])
|
||||
|
||||
def test_an_all_green_run_says_how_many(self):
|
||||
lst = qtui.extra_rows([], self.journal(0, 0, 0))
|
||||
entete = [r for r in lst if r["kind"] == "header"][0]
|
||||
self.assertIn("3", entete["detail"])
|
||||
self.assertIn("✅", entete["detail"])
|
||||
|
||||
def test_the_starting_database_gets_its_real_version(self):
|
||||
# Elle ne porte pas « _upgrade_ » dans son nom : le palier
|
||||
# retombait alors sur le compteur du pilote et affichait « 2 ».
|
||||
dct = {
|
||||
"lst_event": [
|
||||
evenement(
|
||||
status=0,
|
||||
step="2 - Update all addons",
|
||||
detail="./script/odoo/migration/smoke_public_url.py -d base",
|
||||
)
|
||||
],
|
||||
"config_database_name": "base",
|
||||
"target_odoo_version": "18.0",
|
||||
"state_4_upgrade_odoo_lst": [1, 2, 3, 4, 5, 6],
|
||||
}
|
||||
ligne = [
|
||||
r for r in qtui.extra_rows([], dct) if r["kind"] == "verdict"
|
||||
][0]
|
||||
self.assertIn("12", ligne["label"])
|
||||
self.assertNotIn(" 2 ", ligne["label"])
|
||||
|
||||
def test_a_dialogue_answer_is_still_not_a_verdict(self):
|
||||
# Les entrées `command` à 1 sont les réponses du pilote ; les
|
||||
# lister ferait sept faux échecs par migration.
|
||||
dct = self.journal(0)
|
||||
dct["lst_event"].append(
|
||||
evenement(
|
||||
kind="command", name="./odoo_bin.sh db --clone", status=1
|
||||
)
|
||||
)
|
||||
lignes = [
|
||||
r for r in qtui.extra_rows([], dct) if r["kind"] == "verdict"
|
||||
]
|
||||
self.assertEqual(1, len(lignes))
|
||||
|
||||
|
||||
class TestTheScreenObeysTheConvention(Base):
|
||||
"""L'écran peignait en ❌ ce que l'outil appelle un avertissement."""
|
||||
|
||||
def journal(self, statut):
|
||||
return {
|
||||
"lst_event": [
|
||||
evenement(
|
||||
status=statut,
|
||||
name="database_cleanup",
|
||||
detail="./script/odoo/migration/database_cleanup.py"
|
||||
" -d base_upgrade_18",
|
||||
)
|
||||
],
|
||||
"config_database_name": "base",
|
||||
"target_odoo_version": "18.0",
|
||||
"state_4_upgrade_odoo_lst": [1, 2, 3, 4, 5, 6],
|
||||
}
|
||||
|
||||
def ligne(self, statut):
|
||||
lst = qtui.extra_rows([], self.journal(statut))
|
||||
return [r for r in lst if r["kind"] == "verdict"][0]
|
||||
|
||||
def test_findings_are_a_warning_in_the_column(self):
|
||||
# database_cleanup imprime « This is a warning, not a failure »
|
||||
# avant de rendre 1 : l'écran doit dire la même chose.
|
||||
self.assertIn("⚠", self.ligne(1)["label"])
|
||||
self.assertNotIn("❌", self.ligne(1)["label"])
|
||||
|
||||
def test_a_tool_that_failed_is_still_red(self):
|
||||
self.assertIn("❌", self.ligne(2)["label"])
|
||||
|
||||
def test_nothing_to_report_stays_green(self):
|
||||
self.assertIn("✅", self.ligne(0)["label"])
|
||||
|
||||
def test_the_panel_header_agrees_with_the_column(self):
|
||||
for statut in (0, 1, 2):
|
||||
ligne = self.ligne(statut)
|
||||
icone = ligne["label"].strip().split()[0]
|
||||
self.assertTrue(
|
||||
qtui.extra_pane(ligne).startswith(icone),
|
||||
(statut, icone, qtui.extra_pane(ligne)[:20]),
|
||||
)
|
||||
|
||||
def test_the_panel_spells_out_what_the_number_means(self):
|
||||
# « statut 1 » ne dit rien à qui ne connaît pas la convention.
|
||||
texte = qtui.extra_pane(self.ligne(1))
|
||||
self.assertIn(qtui.t("findings"), texte)
|
||||
|
||||
def test_the_meaning_covers_the_whole_convention(self):
|
||||
self.assertEqual({0, 1, 2}, set(qtui.SENS_DU_CODE))
|
||||
|
||||
|
||||
class TestTheStepLogInThePanel(Base):
|
||||
"""Ce que le journal contient vraiment, dit sans détour."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
self.dossier = tempfile.mkdtemp()
|
||||
self.addCleanup(shutil.rmtree, self.dossier)
|
||||
|
||||
def journal(self, lignes):
|
||||
chemin = os.path.join(self.dossier, "etape.log")
|
||||
with io.open(chemin, "w", encoding="utf-8") as handle:
|
||||
handle.write("\n".join(lignes) + "\n")
|
||||
return chemin
|
||||
|
||||
def ligne_verdict(self, chemin, **champs):
|
||||
return {
|
||||
"kind": "verdict",
|
||||
"label": "",
|
||||
"event": evenement(**champs),
|
||||
"log": chemin,
|
||||
"command": "script/odoo/migration/smoke_public_url.py -d x",
|
||||
}
|
||||
|
||||
def test_the_passage_around_the_verdict_is_shown(self):
|
||||
chemin = self.journal(
|
||||
[
|
||||
"2026-08-26 03:19:00,000 INFO odoo: avant",
|
||||
"[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.846406] -> 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()
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
491
test/test_check_view_type_tree.py
Normal file
491
test/test_check_view_type_tree.py
Normal file
|
|
@ -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 <record>, un <field name="name">, 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"),
|
||||
"<odoo><record model='ir.ui.view'><field name='arch'>"
|
||||
"<tree/></field></record></odoo>",
|
||||
)
|
||||
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"),
|
||||
"<odoo><record model='ir.ui.view'><field name='arch'>"
|
||||
"<tree/></field></record></odoo>",
|
||||
)
|
||||
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"), "<odoo><tree/></odoo>"
|
||||
)
|
||||
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"), "<odoo><tree/></odoo>")
|
||||
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(
|
||||
"<odoo><tree><field name='a'/></tree></odoo>"
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
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("<odoo><tree/></odoo>")))
|
||||
|
||||
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("<odoo><!-- <tree/> --></odoo>"))
|
||||
|
||||
def test_a_record_id_that_contains_tree_is_not_a_defect(self):
|
||||
# Le noyau 18 a gardé account.view_invoice_tree, arch en <list>.
|
||||
self.assertEqual(
|
||||
[],
|
||||
self.vue(
|
||||
"<odoo><record id='view_invoice_tree' model='ir.ui.view'>"
|
||||
"<field name='name'>account.move.tree</field>"
|
||||
"<field name='arch' type='xml'><list/></field>"
|
||||
"</record></odoo>"
|
||||
),
|
||||
)
|
||||
|
||||
def test_a_view_mode_naming_tree_is_caught(self):
|
||||
constats = self.vue(
|
||||
"<odoo><field name='view_mode'>tree,form</field></odoo>"
|
||||
)
|
||||
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(
|
||||
"<odoo><field name='view_mode'>form, tree</field></odoo>"
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
def test_a_mode_that_merely_starts_with_tree_is_not_caught(self):
|
||||
# Un substring attraperait « treemap ».
|
||||
self.assertEqual(
|
||||
[],
|
||||
self.vue(
|
||||
"<odoo><field name='view_mode'>treemap,form</field></odoo>"
|
||||
),
|
||||
)
|
||||
|
||||
def test_an_explicit_type_field_is_caught(self):
|
||||
self.assertEqual(
|
||||
["xml_type"],
|
||||
[
|
||||
c[3]
|
||||
for c in self.vue(
|
||||
"<odoo><field name='type'>tree</field></odoo>"
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
def test_an_xpath_aiming_at_a_tree_is_caught(self):
|
||||
self.assertEqual(
|
||||
["xml_xpath"],
|
||||
[
|
||||
c[3]
|
||||
for c in self.vue(
|
||||
"<odoo><xpath expr=\"//tree/field[@name='a']\"/></odoo>"
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
def test_an_xpath_naming_tree_inside_a_predicate_is_not_caught(self):
|
||||
self.assertEqual(
|
||||
[],
|
||||
self.vue(
|
||||
"<odoo><xpath expr=\"//list[@name='tree_view']\"/></odoo>"
|
||||
),
|
||||
)
|
||||
|
||||
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"), "<odoo><tree")
|
||||
rapport = arbre.inspect(self.racine)
|
||||
self.assertEqual([], rapport["findings"])
|
||||
self.assertEqual(1, len(rapport["unreadable"]))
|
||||
|
||||
def test_a_file_listed_but_absent_is_reported_apart(self):
|
||||
# Il rend le module ininstallable, et cela n'a rien à voir avec
|
||||
# un type de vue.
|
||||
self.module(manifeste={"data": ["jamais_ecrit.xml"]})
|
||||
rapport = arbre.inspect(self.racine)
|
||||
self.assertEqual(1, len(rapport["missing"]))
|
||||
|
||||
def test_the_line_reported_is_where_the_tag_opens(self):
|
||||
# lxml rend la ligne où la balise ouvrante FINIT ; mesuré, il
|
||||
# disait 66 quand <tree commençait à 61.
|
||||
constats = self.vue(
|
||||
"<odoo>\n<tree\n string='x'\n create='0'\n>\n"
|
||||
"<field name='a'/></tree>\n</odoo>"
|
||||
)
|
||||
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"), "<odoo><tree/></odoo>")
|
||||
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"),
|
||||
"<odoo><field name='view_mode'>tree,form</field></odoo>",
|
||||
)
|
||||
_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 <list> : 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()
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
257
test/test_db_duplicate.py
Normal file
257
test/test_db_duplicate.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
||||
|
|
|
|||
242
test/test_fix_account_root.py
Normal file
242
test/test_fix_account_root.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
152
test/test_git_commit_msg.py
Normal file
152
test/test_git_commit_msg.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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/<base>/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()
|
||||
|
|
|
|||
129
test/test_odoo_rc.py
Normal file
129
test/test_odoo_rc.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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_<version> ».
|
||||
# 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
113
test/test_suite_reaches_every_file.py
Normal file
113
test/test_suite_reaches_every_file.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
348
test/test_todo_upgrade_capture.py
Normal file
348
test/test_todo_upgrade_capture.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -241,3 +241,7 @@ class TestUpdatePrintLog(unittest.TestCase):
|
|||
update = Update()
|
||||
update.execute_log = ["entry1", "entry2"]
|
||||
update.print_log()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
331
test/test_warmup_http.py
Normal file
331
test/test_warmup_http.py
Normal file
|
|
@ -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()
|
||||
Loading…
Reference in a new issue