From f2ba74fd5953deb82871e7c32100def3ae43e9f7 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 24 Sep 2026 13:29:11 -0400 Subject: [PATCH 01/17] [IMP] format : un formateur par contexte, ruff pour l'outillage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit « make format » donnait isort et black à tout fichier Python : la cible py37, plus petit dénominateur commun de six dépôts d'addons, imposait son style au code de ce dépôt. Le contexte décide désormais — les modules Odoo gardent isort et black, le reste passe par ruff, réglé dans .ruff.toml, comme la norme OCA depuis qu'elle a quitté black. Les dépôts rapatriés sous script/ en sont écartés, chemin nommé compris. La cible y reste py310 : dès 3.14 ruff écrit « except A, B: » sans parenthèses, que le python3 des hooks ne sait pas lire. Vérifié : un fichier reçoit deux normes selon son chemin, et aucun dépôt rapatrié n'est touché. --- EN --- "make format" gave isort and black to every Python file: the py37 target, lowest common denominator of six addons repositories, imposed its style on this repository's code. Context now decides — Odoo modules keep isort and black, everything else goes through ruff, set in .ruff.toml, as the OCA standard does since it left black. The repositories checked out under script/ are excluded, a named path included. The target stays py310 there: from 3.14 on, ruff writes "except A, B:" unparenthesised, which the python3 of the hooks cannot read. Checked: one file gets two standards by its path, and no checked-out repository is touched. Assisted-by: Claude Opus 5 --- .ruff.toml | 37 ++++++++++++++++++++++++ CHANGELOG.base.md | 4 +++ CHANGELOG.fr.md | 2 ++ CHANGELOG.md | 2 ++ Makefile | 6 ++-- requirement/erplibre_require-ments.txt | 3 ++ script/maintenance/black.sh | 4 ++- script/maintenance/format_python.sh | 39 +++++++++++++++++++++++--- 8 files changed, 89 insertions(+), 8 deletions(-) create mode 100644 .ruff.toml diff --git a/.ruff.toml b/.ruff.toml new file mode 100644 index 0000000..e7228b0 --- /dev/null +++ b/.ruff.toml @@ -0,0 +1,37 @@ +# Formatage et tri des imports de l'OUTILLAGE ERPLibre — script/, test/, +# long_test/ et les scripts de la racine. +# +# ruff et non black : black 24.8.0 ne connaît aucune cible au-delà de py313, +# quand l'outillage tourne sur conf/python-erplibre-version, et son tri +# d'imports remplace isort sans second outil. C'est aussi ce que la norme OCA +# emploie depuis qu'elle a quitté black. +# +# Les modules Odoo n'obéissent PAS à ce fichier : chaque dépôt d'addons porte +# sa propre norme. script/maintenance/format_python.sh aiguille entre les deux. +# +# target-version borne ce que le formateur ÉCRIT, et non ce qui exécute le +# code. py310 et non la version du venv : PEP 758 autorise depuis 3.14 +# « except A, B: » sans parenthèses, et ruff l'écrirait — or les hooks de +# script/git/hooks portent « #!/usr/bin/env python3 » et tournent donc sur le +# Python du système, qu'une distribution livre encore en 3.10. +target-version = "py310" +# 79, la largeur que le dépôt tient déjà. La norme OCA en retient 88, déclarée +# dans les dépôts d'addons qui la suivent. +line-length = 79 + +# Des dépôts SÉPARÉS, rapatriés par Google Repo : ils portent leur propre +# configuration, et les reformater écrirait dans l'historique d'autrui. +# force-exclude : sans lui, l'exclusion ne vaut que pour les fichiers que ruff +# DÉCOUVRE, et un chemin nommé en argument y échappe — or format_python.sh +# passe les fichiers un par un. +force-exclude = true +extend-exclude = [ + "addons", + "odoo*", + "script/OCA_maintainer-tools", + "script/OCA_odoo-module-migrator", +] + +[lint] +# Le tri des imports seul : le lint du dépôt reste décrit par .flake8. +select = ["I"] diff --git a/CHANGELOG.base.md b/CHANGELOG.base.md index d8cab78..3d12634 100644 --- a/CHANGELOG.base.md +++ b/CHANGELOG.base.md @@ -154,6 +154,8 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - A VM deployed with the cache upstream cut — QEMU form, Proxmox VE, or `deploy_qemu.py --offline` — has npm's security audit turned off (`NPM_CONFIG_AUDIT=false`): it queries a remote service no cache can replay, and failed on every offline install. An online VM keeps its audit - Verifying a downloaded image no longer needs `--verify`: it runs by default for every distribution that publishes a sum, and `--no-verify` is what skips it — to be kept for offline runs, where a substituted image would otherwise pass unremarked - `--bios` is refused on an image with no BIOS boot sector, and says why. Forced there, it gave a VM reported « running » with a silent console — the very failure that flag exists to avoid elsewhere +- `make format` picks the formatter from each file's context: an Odoo module keeps isort and black on `py37`, the series still supported going down that far, while this repository's own tooling goes through ruff, configured once in `.ruff.toml`. ruff follows CPython's versions, where black 24.8.0 stops at `py313`, and its import sorting replaces isort; it is also what the OCA standard uses since it left black +- The repositories that Google Repo checks out under `script/` are excluded from that formatting, a named path included: reformatting them would write in someone else's history. `target-version` stays at `py310` there, because the git hooks carry `#!/usr/bin/env python3` and a distribution still ships 3.10 — from 3.14 on, ruff would write `except A, B:` without parentheses @@ -170,6 +172,8 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Une VM déployée l'amont du cache coupé — formulaire QEMU, Proxmox VE, ou `deploy_qemu.py --offline` — a l'audit de sécurité de npm désactivé (`NPM_CONFIG_AUDIT=false`) : il interroge un service qu'aucun cache ne rejoue, et échouait à chaque installation hors ligne. Une VM en ligne garde son audit - Vérifier une image téléchargée ne demande plus `--verify` : c'est le défaut pour toute distribution qui publie une somme, et `--no-verify` est ce qui la saute — à réserver aux essais hors ligne, où une image substituée passerait autrement sans un mot - `--bios` est refusé sur une image sans secteur d'amorçage BIOS, et dit pourquoi. Forcé là, il donnait une VM « running » à console muette — la panne même que ce drapeau évite ailleurs +- `make format` choisit le formateur d'après le contexte de chaque fichier : un module Odoo garde isort et black en `py37`, la série la plus ancienne encore supportée, quand l'outillage de ce dépôt passe par ruff, réglé une fois dans `.ruff.toml`. ruff suit les versions de CPython, là où black 24.8.0 s'arrête à `py313`, et son tri d'imports remplace isort ; c'est aussi ce qu'emploie la norme OCA depuis qu'elle a quitté black +- Les dépôts que Google Repo rapatrie sous `script/` sont écartés de ce formatage, chemin nommé compris : les reformater écrirait dans l'historique d'autrui. `target-version` y reste à `py310`, parce que les hooks git portent `#!/usr/bin/env python3` et qu'une distribution livre encore 3.10 — à partir de 3.14, ruff écrirait `except A, B:` sans parenthèses ## Fixed diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index f0bc7dd..5729d9c 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -77,6 +77,8 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Une VM déployée l'amont du cache coupé — formulaire QEMU, Proxmox VE, ou `deploy_qemu.py --offline` — a l'audit de sécurité de npm désactivé (`NPM_CONFIG_AUDIT=false`) : il interroge un service qu'aucun cache ne rejoue, et échouait à chaque installation hors ligne. Une VM en ligne garde son audit - Vérifier une image téléchargée ne demande plus `--verify` : c'est le défaut pour toute distribution qui publie une somme, et `--no-verify` est ce qui la saute — à réserver aux essais hors ligne, où une image substituée passerait autrement sans un mot - `--bios` est refusé sur une image sans secteur d'amorçage BIOS, et dit pourquoi. Forcé là, il donnait une VM « running » à console muette — la panne même que ce drapeau évite ailleurs +- `make format` choisit le formateur d'après le contexte de chaque fichier : un module Odoo garde isort et black en `py37`, la série la plus ancienne encore supportée, quand l'outillage de ce dépôt passe par ruff, réglé une fois dans `.ruff.toml`. ruff suit les versions de CPython, là où black 24.8.0 s'arrête à `py313`, et son tri d'imports remplace isort ; c'est aussi ce qu'emploie la norme OCA depuis qu'elle a quitté black +- Les dépôts que Google Repo rapatrie sous `script/` sont écartés de ce formatage, chemin nommé compris : les reformater écrirait dans l'historique d'autrui. `target-version` y reste à `py310`, parce que les hooks git portent `#!/usr/bin/env python3` et qu'une distribution livre encore 3.10 — à partir de 3.14, ruff écrirait `except A, B:` sans parenthèses ## Corrigé diff --git a/CHANGELOG.md b/CHANGELOG.md index 011a0f0..934c3a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,8 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - A VM deployed with the cache upstream cut — QEMU form, Proxmox VE, or `deploy_qemu.py --offline` — has npm's security audit turned off (`NPM_CONFIG_AUDIT=false`): it queries a remote service no cache can replay, and failed on every offline install. An online VM keeps its audit - Verifying a downloaded image no longer needs `--verify`: it runs by default for every distribution that publishes a sum, and `--no-verify` is what skips it — to be kept for offline runs, where a substituted image would otherwise pass unremarked - `--bios` is refused on an image with no BIOS boot sector, and says why. Forced there, it gave a VM reported « running » with a silent console — the very failure that flag exists to avoid elsewhere +- `make format` picks the formatter from each file's context: an Odoo module keeps isort and black on `py37`, the series still supported going down that far, while this repository's own tooling goes through ruff, configured once in `.ruff.toml`. ruff follows CPython's versions, where black 24.8.0 stops at `py313`, and its import sorting replaces isort; it is also what the OCA standard uses since it left black +- The repositories that Google Repo checks out under `script/` are excluded from that formatting, a named path included: reformatting them would write in someone else's history. `target-version` stays at `py310` there, because the git hooks carry `#!/usr/bin/env python3` and a distribution still ships 3.10 — from 3.14 on, ruff would write `except A, B:` without parentheses ## Fixed diff --git a/Makefile b/Makefile index 3132070..e4f6e8e 100644 --- a/Makefile +++ b/Makefile @@ -222,12 +222,12 @@ format_code_generator_template: .PHONY: format_script format_script: - #.venv.erplibre/bin/isort --profile black -l 79 ./script/ --gitignore - ./script/maintenance/black.sh ./script/ + .venv.erplibre/bin/ruff check --select I --fix ./script/ + .venv.erplibre/bin/ruff format ./script/ .PHONY: format_script_isort_only format_script_isort_only: - .venv.erplibre/bin/isort --profile black -l 79 ./script/ --gitignore + .venv.erplibre/bin/ruff check --select I --fix ./script/ ######### # log # diff --git a/requirement/erplibre_require-ments.txt b/requirement/erplibre_require-ments.txt index dd21a07..eb84a6e 100644 --- a/requirement/erplibre_require-ments.txt +++ b/requirement/erplibre_require-ments.txt @@ -16,6 +16,9 @@ selenium uvloop python-randomword-fr isort +# Formatage et tri des imports de l'outillage : il suit les versions de +# CPython, là où black 24.8.0 s'arrête à py313. Voir .ruff.toml. +ruff pykeepass cryptography keyring diff --git a/script/maintenance/black.sh b/script/maintenance/black.sh index 2205edd..2f04d90 100755 --- a/script/maintenance/black.sh +++ b/script/maintenance/black.sh @@ -2,7 +2,9 @@ Red='\033[0;31m' # Red Color_Off='\033[0m' # Text Reset -# This will format all python file +# Les MODULES ODOO, et eux seuls : les addons des séries encore supportées +# descendent jusqu'à Python 3.7, d'où la cible. L'outillage du dépôt passe par +# ruff — voir format_python.sh, qui aiguille, et .ruff.toml. # argument 1: directory or file to format source ./.venv.erplibre/bin/activate black -l 79 --preview -t py37 "$@" diff --git a/script/maintenance/format_python.sh b/script/maintenance/format_python.sh index d685032..d07141d 100755 --- a/script/maintenance/format_python.sh +++ b/script/maintenance/format_python.sh @@ -1,5 +1,36 @@ #!/usr/bin/env bash -source ./.venv.erplibre/bin/activate -isort --profile black -l 79 "$@" -#./.venv.erplibre/bin/isort --profile black -l 79 "$@" -./script/maintenance/black.sh "$@" +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +# +# Un fichier Python, le formateur de SON contexte. +# +# Un module Odoo et un script d'outillage ne suivent pas la même norme et ne +# tournent pas sur le même interpréteur : les addons vivent dans des dépôts +# séparés, dont la communauté fixe le style, quand script/ n'engage que ce +# dépôt. Un réglage unique servait donc mal les deux. +# +# addons/…, odoo*/addons/… isort + black, cible py37 : les séries d'Odoo +# encore supportées descendent jusque-là. +# tout le reste ruff, réglé par .ruff.toml à la racine. + +VENV="$(xargs < conf/python-erplibre-venv 2> /dev/null)" +RUFF="./${VENV}/bin/ruff" + +for fichier in "$@"; do + case "${fichier}" in + ./addons/* | addons/* | ./odoo*/addons/* | odoo*/addons/*) + "./${VENV}/bin/isort" --profile black -l 79 "${fichier}" + ./script/maintenance/black.sh "${fichier}" + ;; + *) + if [[ ! -x "${RUFF}" ]]; then + echo "ruff absent de ${VENV} : ./script/install/install_erplibre.sh" >&2 + exit 1 + fi + # Le tri des imports d'abord : il déplace des lignes que le formatage + # remet ensuite à la bonne largeur. + "${RUFF}" check --select I --fix --quiet "${fichier}" + "${RUFF}" format --quiet "${fichier}" + ;; + esac +done From 5dda3de6b89b244c2b0e5a523c475ec7576e17ca Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 24 Sep 2026 13:30:19 -0400 Subject: [PATCH 02/17] [ADD] format : une cible pour test/ et long_test/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aucune cible ne formatait les tests : « make format_all » couvre script/ et les six dépôts d'addons, et rien d'autre. Seul « make format » les atteignait, et seulement ceux qu'un diff signalait, si bien que 75 fichiers sur 210 ne suivaient aucune norme — de la dérive silencieuse dans un arbre qui sert de référence au reste. Les tests tournent dans le venv d'outillage : ils suivent donc ruff, comme script/, et la cible entre dans format_all. Vérifié : « make -n format_test » nomme bien ruff, et format_all l'inclut. --- EN --- No target formatted the tests: "make format_all" covers script/ and the six addons repositories, nothing else. Only "make format" reached them, and only those a diff reported, so that 75 files out of 210 followed no standard — silent drift in a tree the rest takes as reference. The tests run in the tooling venv: they therefore follow ruff, like script/, and the target joins format_all. Checked: "make -n format_test" names ruff, and format_all includes it. Assisted-by: Claude Opus 5 --- Makefile | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index e4f6e8e..a5c0aa6 100644 --- a/Makefile +++ b/Makefile @@ -188,8 +188,10 @@ format: .PHONY: format_all format_all: - parallel ::: "./script/make.sh format_code_generator" "./script/make.sh format_code_generator_template" "./script/make.sh format_script" "./script/make.sh format_erplibre_addons" "./script/make.sh format_supported_addons" + parallel ::: "./script/make.sh format_code_generator" "./script/make.sh format_code_generator_template" "./script/make.sh format_script" "./script/make.sh format_test" "./script/make.sh format_erplibre_addons" "./script/make.sh format_supported_addons" +# Les dépôts d'addons sont nommés, jamais leur chemin : il dépend du manifeste, +# et chaque version d'Odoo n'en rapatrie qu'une partie. Voir format_addons.sh. .PHONY: format_code_generator format_code_generator: .venv.erplibre/bin/isort --profile black -l 79 ./addons/TechnoLibre_odoo-code-generator/ @@ -225,6 +227,14 @@ format_script: .venv.erplibre/bin/ruff check --select I --fix ./script/ .venv.erplibre/bin/ruff format ./script/ +# Les tests suivent la norme de l'outillage : ils tournent dans le même venv, +# et rien ne les formatait — seul « make format » les touchait, et seulement +# s'ils étaient modifiés. +.PHONY: format_test +format_test: + .venv.erplibre/bin/ruff check --select I --fix ./test/ ./long_test/ + .venv.erplibre/bin/ruff format ./test/ ./long_test/ + .PHONY: format_script_isort_only format_script_isort_only: .venv.erplibre/bin/ruff check --select I --fix ./script/ From da2dc63a19c5e8448461ad8672390cf65869a5aa Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 24 Sep 2026 13:30:47 -0400 Subject: [PATCH 03/17] =?UTF-8?q?[FIX]=20outillage=20:=20d=C3=A9simbriquer?= =?UTF-8?q?=20les=20f-strings=20que=20seul=203.12=20sait=20lire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cinq fichiers employaient des f-strings PEP 701 — guillemets imbriqués du même type, backslash dans l'expression, expression sur plusieurs lignes — que Python refuse avant 3.12. Or les hooks de script/git/hooks portent « #!/usr/bin/env python3 » et tournent donc sur l'interpréteur du système, qu'une distribution livre encore en 3.10 : la SyntaxError tombe au chargement, sans qu'aucun garde puisse nommer la commande à taper. Les chaînes traduites gardent leur texte à l'octet près, sans quoi leur clé se perdrait. Les commentaires de ces fichiers passent du récit au présent, comme la règle le demande de ce qu'on touche. Vérifié : tout l'arbre Python parse sous 3.10, 3.11, 3.12 et 3.14. --- EN --- Five files used PEP 701 f-strings — quotes of the same kind nested, a backslash in the expression, an expression spanning lines — which Python refuses before 3.12. The hooks in script/git/hooks carry "#!/usr/bin/env python3" and so run on the system interpreter, which a distribution still ships as 3.10: the SyntaxError lands at load, before any guard can name the command to type. Translated strings keep their text byte for byte, else their key would be lost. The comments of those files move from tale to present tense, as the rule asks of what one touches. Checked: the whole Python tree parses under 3.10, 3.11, 3.12 and 3.14. Assisted-by: Claude Opus 5 --- script/analyse/analyse_view_custom.py | 6 +-- script/analyse/check_migration_quality.py | 57 ++++++++++---------- script/systemd/install_daemon.py | 15 +++--- script/todo/assistant_menu.py | 15 +++--- script/todo/qemu_install.py | 63 ++++++++++++----------- 5 files changed, 77 insertions(+), 79 deletions(-) diff --git a/script/analyse/analyse_view_custom.py b/script/analyse/analyse_view_custom.py index 7fdb22d..3f1d584 100755 --- a/script/analyse/analyse_view_custom.py +++ b/script/analyse/analyse_view_custom.py @@ -502,8 +502,8 @@ def collect( # et ce qu'il rapporte est fiable. « all » compare toute vue ayant # un arch_fs, ce qui trouve la dérive qu'aucun drapeau ne signale — # une vue réécrite en SQL direct — mais au prix d'un plancher de - # bruit MESURÉ : sur une base 18.0 fraîchement installée, 160 des - # 974 vues à arch_fs diffèrent déjà. read_arch_from_file rend le + # bruit : sur une base 18.0 fraîchement installée, 160 des 974 + # vues à arch_fs diffèrent déjà. read_arch_from_file rend le # XML brut du fichier, alors que la base porte l'arch APRÈS # traitement au chargement : un attribut « groups » est consommé, # un est appliqué. En « all », un @@ -707,7 +707,7 @@ def render(data, verbose=False, top=TOP_DEFAULT, category=None, hints=True): f"🔬 {t('Customised views')} — {data['database']} (Odoo {version}" f"{', ' + t('from a backup') if data.get('source') == 'backup' else ''})", "", - f" {t("Views"):<38}: {data['n_views']}", + f" {t('Views'):<38}: {data['n_views']}", ] for name in CATEGORIES: if counts.get(name): diff --git a/script/analyse/check_migration_quality.py b/script/analyse/check_migration_quality.py index 8c5ee59..835db07 100755 --- a/script/analyse/check_migration_quality.py +++ b/script/analyse/check_migration_quality.py @@ -19,9 +19,8 @@ lecture seule, plutôt que de rejouer quoi que ce soit. Pourquoi pas en démarrant Odoo ------------------------------ Six démarrages coûteraient une heure, écriraient dans les bases et -demanderaient de basculer le checkout à chaque palier. Mesuré : la même -inspection en SQL prend moins d'une demi-seconde par base, et ne touche à -rien. Ce qu'on y perd — les modèles abstraits, les champs calculés — ne +demanderaient de basculer le checkout à chaque palier. La même inspection +en SQL prend moins d'une demi-seconde par base, et ne touche à rien. Ce qu'on y perd — les modèles abstraits, les champs calculés — ne se compare pas d'une version à l'autre de toute façon. Ce qui compte le plus @@ -184,12 +183,12 @@ def version_of(database, dct): # 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 +# [] $ .venv…/python3 ./script/…/smoke.py -d … +# [] -> 1 +# [] [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 +# Entre le « $ » et le « -> », RIEN : 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 = "] $ " @@ -451,7 +450,7 @@ def table_counts(database): Construite côté serveur puis exécutée d'un bloc : huit cents requêtes séparées coûteraient huit cents allers-retours, là où celle-ci prend - quatre dixièmes de seconde — mesuré sur une base de 890 tables. + quatre dixièmes de seconde sur une base de 890 tables. """ fabrique = run_psql( database, @@ -546,11 +545,11 @@ def inspect(database): etat["field_module"] = {cle: sorted(v) for cle, v in dct_origine.items()} # Lesquels PORTENT une donnée. Un champ `store=false` n'a jamais eu # de colonne : sa disparition ne perd rien, et il pesait jusqu'à 90 % - # du seau « non déclarés par OpenUpgrade ». Mesuré au palier 16 → 17 : - # `__last_update` à lui seul comptait pour 397 des 565. + # du seau « non déclarés par OpenUpgrade ». Au palier 16 → 17, + # `__last_update` compte à lui seul pour 397 des 565. # - # `store` existe de la 12 à la 18 et n'est jamais NULL — vérifié sur - # les sept bases — donc le renseignement est fiable partout. + # `store` existe de la 12 à la 18 et n'y est jamais NULL : le + # renseignement est donc fiable partout. stockes = run_psql( database, "SELECT model || '.' || name FROM ir_model_fields WHERE store", @@ -560,8 +559,8 @@ def inspect(database): # # Une pièce jointe dont `res_field` ne nomme aucun champ vivant est # DÉJÀ illisible : Odoo lève un KeyError en la contrôlant. Ce ne sont - # pas des données, ce sont des débris. Mesuré sur une chaîne 12 → 18 : - # la dette naît aux paliers 13 et 14, reste gelée pendant trois + # pas des données, ce sont des débris. Sur une chaîne 12 → 18, la + # dette naît aux paliers 13 et 14, reste gelée pendant trois # paliers, et la 18 ramasse 452 lignes d'un coup — qui se lisent # alors comme 452 pertes. # @@ -700,8 +699,7 @@ def render_detail(diff, categorie, colour=False): (gagnes, "+", "ok"), ): lignes.append( - f"── {paint(symbole, teinte, colour)} {len(lst)}" - f" {t(categorie)} ──" + f"── {paint(symbole, teinte, colour)} {len(lst)} {t(categorie)} ──" ) lignes.extend(f" {nom}" for nom in lst) lignes.append("") @@ -724,8 +722,7 @@ def render_missing(etat, colour=False, limit=60): return f"✅ {t('every attachment file is present')}" lignes = [ paint( - f"❌ {absents}" - f" {t('attachment files missing from the filestore')}", + f"❌ {absents} {t('attachment files missing from the filestore')}", "fail", colour, ), @@ -1019,8 +1016,8 @@ def overlay_declared( champs["model_gone"].append(cle) continue # AVANT « non analysé » : « sans donnée propre » est une raison - # plus forte que « hors du champ d'OpenUpgrade ». Mesuré au - # palier 16 → 17, le placement avant fait tomber `not_analysed` + # plus forte que « hors du champ d'OpenUpgrade ». Au palier + # 16 → 17, le placement avant fait tomber `not_analysed` # de 181 à 47 sans changer `undeclared` — le seau résiduel se # réduit alors au risque réel : des champs qui AVAIENT des # données, dans des modules dont OpenUpgrade ne peut rien dire. @@ -1256,11 +1253,10 @@ RENAME_RATIO = 0.75 def looks_renamed(un, deux): """Les deux noms se ressemblent-ils assez pour être le même sujet ? - Deux garde-fous ont été essayés et rejetés, mesurés sur une vraie - migration. Le seul nombre de lignes accouplait - `account_account_tag_account_tax_template_rel` à `dms_directory` : les - deux comptaient sept lignes. Un mot commun d'au moins cinq lettres - accouplait `cleanup_purge_wizard_menu` à + Deux garde-fous plus simples ne tiennent pas. Le seul nombre de lignes + accouple `account_account_tag_account_tax_template_rel` à + `dms_directory` dès qu'ils en comptent autant. Un mot commun d'au moins + cinq lettres accouple `cleanup_purge_wizard_menu` à `cleanup_create_indexes_line` — « cleanup » ne dit rien. La ressemblance d'ENSEMBLE tranche : `muk_dms_directory` et @@ -1315,9 +1311,9 @@ SANS_DONNEE_PROPRE = ("id",) # Pourquoi une pièce jointe a disparu — DÉDUIT, jamais déclaré. # # SEMANTIC_MAP ne peut pas porter ceci : elle nomme une TABLE, et la -# cause n'est pas la table, ce sont ces lignes-là. Mesuré sur une chaîne -# 12 → 18 : des 516 lignes parties au palier 18, 452 avaient perdu leur -# champ et 63 leur enregistrement. Une entrée « ir_attachment / pruned » +# cause n'est pas la table, ce sont ces lignes-là. Sur une chaîne 12 → 18, +# des 516 lignes parties au palier 18, 452 ont perdu leur champ et 63 leur +# enregistrement. Une entrée « ir_attachment / pruned » # aurait rangé les 516 sous « perte attendue » — et la 517e avec. ATTACHMENT_KIND = ( ("field_debt", "their field was already gone before this step", "dim"), @@ -1443,8 +1439,9 @@ def render_text(lst_snapshot, colour=None, limit=8): f" ⚠️ {etat['database']} : {t('database not found')}" ) continue + etape = paint(f"{etat['odoo']:<6}", "step", colour) lignes.append( - f" {paint(f'{etat['odoo']:<6}', 'step', colour)}" + f" {etape}" f" {etat['database']:<34}" f" {len(etat['installed']):>4} {t('modules')}" f" · {len(etat['model']):>4} {t('models')}" diff --git a/script/systemd/install_daemon.py b/script/systemd/install_daemon.py index 0369940..ca90639 100755 --- a/script/systemd/install_daemon.py +++ b/script/systemd/install_daemon.py @@ -13,10 +13,10 @@ from pathlib import Path # # Lancé seul, systemd doit EXÉCUTER le fichier, et l'échoue en « 203/EXEC » # dans quatre cas au moins : bit x absent, shebang qui ne résout pas, /home -# monté noexec, SELinux refusant l'execve. Vécu sur openSUSE s390x — le -# processus mourait en 3 ms, sans jamais entrer dans le script, ce qui rend le -# diagnostic très pénible : aucune sortie, et un code qui ressemble à une -# erreur d'application. +# monté noexec, SELinux refusant l'execve. L'échec est alors MUET : le +# processus meurt avant d'entrer dans le script, sans une ligne de sortie, et +# 203 ressemble à une erreur d'application — le diagnostic part donc dans la +# mauvaise direction. # # Passé à bash, run.sh n'est plus qu'une DONNÉE lue : les quatre causes # disparaissent ensemble, y compris noexec et SELinux, qui ne portent que sur @@ -116,10 +116,9 @@ def main(): args.config_name or f"erplibre_{el_user}_{os.path.basename(os.getcwd())}" ) - exec_param = ( - " " - + f" {w_cmd("-d", args.database or "")} {w_cmd("-p", args.port or "")}".strip() - ) + db_param = w_cmd("-d", args.database or "") + port_param = w_cmd("-p", args.port or "") + exec_param = " " + f" {db_param} {port_param}".strip() # Render the systemd service file content unit_content = UNIT_TEMPLATE.format( diff --git a/script/todo/assistant_menu.py b/script/todo/assistant_menu.py index 0852065..7439189 100644 --- a/script/todo/assistant_menu.py +++ b/script/todo/assistant_menu.py @@ -32,6 +32,7 @@ Le dépôt n'a ni pager, ni progression sur place : la sortie s'ajoute ligne à ligne. Une réponse longue se ferme sur une ligne de pied, jamais sur un défilement piloté. """ + from __future__ import annotations import os @@ -386,8 +387,7 @@ class AssistantMenuMixin: choices = [ { "prompt_description": ( - f"{t('Here (127.0.0.1)')}" - f" ({t('11 ports, instant')})" + f"{t('Here (127.0.0.1)')} ({t('11 ports, instant')})" ) }, { @@ -805,9 +805,8 @@ class AssistantMenuMixin: f" {t('Look somewhere else')} · {t('Type an address')}" f" · {t('Carry on with the OpenAI API (key from the vault)')}" ) - print( - f" 💡 {t('A local server: \"ollama serve\" listens on 11434.')}" - ) + astuce = t('A local server: "ollama serve" listens on 11434.') + print(f" 💡 {astuce}") def _llm_server_card(self): """Ce que le serveur en usage annonce savoir faire. @@ -1051,8 +1050,7 @@ class AssistantMenuMixin: if session is None: return commande = ( - f"{shlex.quote(chemin)} --resume" - f" {shlex.quote(session.session_id)}" + f"{shlex.quote(chemin)} --resume {shlex.quote(session.session_id)}" ) if not getattr(self.execute, "cmd_source_default", ""): print(t("No terminal can be opened here. Paste this command:")) @@ -1097,8 +1095,7 @@ class AssistantMenuMixin: 1 for _, verdict, _ in self._llm_apparier(gpts) if verdict == "ok" ) return ( - f"{t('gpt tools')} ({len(gpts)}," - f" {compatibles} {t('compatible')})" + f"{t('gpt tools')} ({len(gpts)}, {compatibles} {t('compatible')})" ) def _llm_apparier(self, gpts): diff --git a/script/todo/qemu_install.py b/script/todo/qemu_install.py index 5daf1ac..7bc009e 100644 --- a/script/todo/qemu_install.py +++ b/script/todo/qemu_install.py @@ -506,10 +506,12 @@ class QemuInstallMixin: # ERREUR, alors que son étape finale écrit encore l'autorité, les # variables et le sudoers : attendre l'unité, faute de quoi elles # arrivent après cette session, qui vivra sans elles. - + attente_cloud_final() + "; " + + attente_cloud_final() + + "; " # Les variables du cache sont écrites par cloud-init PENDANT # l'attente : cette session, ouverte avant, ne les a pas reçues. - + cache_env_reload() + "; " + + cache_env_reload() + + "; " # ICI, et nulle part avant. Le faisceau que ces exports désignent # est écrit par cloud-init lui aussi : mesuré sur une VM, la # session ssh est acceptée une seconde avant qu'il existe, donc @@ -599,31 +601,28 @@ class QemuInstallMixin: # Miroirs openSUSE préférés, du plus proche au dernier recours. Le # redirecteur officiel n'est PAS géographique pour cette distribution : - # mesuré depuis Montréal sur les métadonnées oss s390x (15 Mo), - # download.opensuse.org met 23,8 s — il sert depuis l'Europe — contre - # 2,7 s pour mirrors.rit.edu. Les trois familles dnf, elles, choisissent - # déjà un miroir canadien toutes seules ; rien à faire de ce côté. + # download.opensuse.org sert depuis l'Europe, là où un miroir + # nord-américain répond en une fraction du temps sur les mêmes + # métadonnées. Les trois familles dnf, elles, choisissent déjà un miroir + # proche toutes seules ; rien à faire de ce côté. # # Chaque miroir est SONDÉ sur le chemin de l'architecture ET du produit # courants, puis le premier qui répond gagne. C'est nécessaire : aucun ne - # réplique tout. Relevé le 2026-08-12 — - # csclub Leap oui, Tumbleweed non (404) - # rit.edu zsystems oui ; injoignable ce jour-là (curl 7) - # leaseweb Tumbleweed x86_64 et Leap oui, ports zsystems non - # D'où plusieurs entrées plutôt qu'une : avec la seule rit.edu, sa panne - # renvoyait tout le monde sur download.opensuse.org, servi d'Europe. - # Ordonnées par proximité de Montréal. Aucun sondage concluant : on garde - # les dépôts de l'image, donc le comportement d'avant. + # réplique tout — l'un sert Leap mais pas Tumbleweed, l'autre les ports + # zsystems mais pas x86_64. D'où plusieurs entrées plutôt qu'une : avec un + # seul miroir, sa panne renvoie tout le monde sur le redirecteur, servi + # d'Europe. Aucun sondage concluant : on garde les dépôts de l'image, + # donc le comportement d'avant. _QEMU_ZYPPER_MIRRORS = ( "https://mirror.csclub.uwaterloo.ca/opensuse", "https://mirrors.rit.edu/opensuse", "https://mirror.us.leaseweb.net/opensuse", ) - # Miroirs Arch canadiens, du plus rapide au suivant. Mesuré depuis - # Montréal sur extra.db : quantum5 2,0 s, xenyth 7,1 s, contre 8,0 s pour - # geo.mirror.pkgbuild.com — le miroir « géographique » officiel n'est donc - # pas le meilleur ici. Arch n'est proposé qu'en amd64 dans le catalogue, + # Miroirs Arch canadiens, du plus rapide au suivant. Le miroir + # « géographique » officiel, geo.mirror.pkgbuild.com, n'est pas le plus + # rapide depuis l'Amérique du Nord : d'où une liste explicite plutôt que + # lui. Arch n'est proposé qu'en amd64 dans le catalogue, # et ces deux-là ne servent que x86_64 (Arch Linux ARM a ses propres # miroirs) : la garde d'architecture le dit quand même. _QEMU_PACMAN_MIRRORS = ( @@ -1219,13 +1218,13 @@ class QemuInstallMixin: # couvre les quatre gestionnaires (Arch l'a dans extra, Debian et Ubuntu ne # l'ont qu'en snap — coupé ici —, Fedora et openSUSE pas du tout). # - # La ligne COMMUNITY, et non le produit unifié. Mesuré dans une VM : - # « code=PCC&latest » sert maintenant pycharm-2025.3, le build unifié, qui + # La ligne COMMUNITY, et non le produit unifié. + # « code=PCC&latest » sert le build unifié, qui # s'arrête sur sa licence — son journal dit « NoValidIdeLicense » puis # « Get licenses: request requires authentication », et le projet ne # s'ouvre jamais. Aucune ouverture, donc aucun .idea, donc rien à - # configurer ensuite. Community ne demande aucun compte, et elle est - # toujours publiée et corrigée : 2025.2.6.2 date du 2026-07-29. + # configurer ensuite. Community ne demande aucun compte, et elle reste + # publiée et corrigée. # # Aucun numéro figé ici : on prend la plus récente archive # « pycharm-community- » du flux officiel des versions, pour @@ -1248,7 +1247,7 @@ class QemuInstallMixin: # INDÉPENDANTS, l'un ne se déduit pas de l'autre, et le flux updates.xml de # Google ne publie ni l'un ni l'autre. On lit donc l'URL sur la page # officielle, qui la porte en clair, et on retombe sur celle-ci si la page - # change de forme. Relevée et vérifiée (HTTP 200) le 2026-08-17. + # change de forme. Ce repli est une URL figée : elle vieillit. _QEMU_ANDROID_URL = ( "https://dl.google.com/dl/android/studio/ide-zips/2026.1.3.8/" "android-studio-quail3-patch1-linux.tar.gz" @@ -1310,6 +1309,12 @@ class QemuInstallMixin: Tout le bloc est gardé : un IDE qui ne s'installe pas ne doit pas faire échouer l'installation d'ERPLibre, qui elle a duré une heure.""" el_dir = self._qemu_install_dir(prod) + # Hors de la f-string : une expression sur deux lignes dans ses + # accolades ne parse qu'à partir de 3.12 (PEP 701). + idea_note = t( + "open the project once and close PyCharm; the .idea " + "it writes is what the install configures" + ) return ( f'echo "== {t("Installing PyCharm (long)")} =="; ' "{ " @@ -1378,8 +1383,7 @@ class QemuInstallMixin: # PyCharm n'a évidemment jamais ouvert le dépôt. + f'echo " {t("PyCharm installed:")} /opt/pycharm ' f'({t("command")} pycharm, {t("project")} {el_dir})"; ' - f'echo " {t("open the project once and close PyCharm; the .idea " - "it writes is what the install configures")}"; ' + f'echo " {idea_note}"; ' f'}} || echo " ⚠ {t("PyCharm not installed (see above)")}"; ' ) @@ -1676,8 +1680,9 @@ class QemuInstallMixin: # Aucune n'est empaquetée par une distribution : on passe donc par le site. # # L'archive dépend de la version de GNOME Shell, et ce n'est pas une - # précaution de principe : mesuré le 2026-08-17, le même point d'entrée - # sert gTile v59 pour GNOME 46, v62 pour GNOME 48 et v52 pour GNOME 3.38. + # précaution de principe : le même point d'entrée sert une archive + # différente selon la version — gTile v59 pour GNOME 46, v62 pour + # GNOME 48, v52 pour GNOME 3.38. # Une URL figée poserait donc, tôt ou tard, une archive faite pour une # autre version. # @@ -2097,8 +2102,8 @@ class QemuInstallMixin: # peut rendre 0 sans avoir rien produit. # DEUX emplacements, et il faut les deux. Avec une ABI injectée, # AGP écrit dans « intermediates/apk/debug » et non dans - # « outputs/apk/debug » : mesuré, une compilation RÉUSSIE était - # rapportée « aucun APK produit » parce que je ne regardais que le + # « outputs/apk/debug » : une compilation RÉUSSIE se rapporte + # « aucun APK produit » dès que le contrôle ne regarde que le # second. Un contrôle qui cherche au mauvais endroit ne vaut pas # mieux que pas de contrôle. f"apk=$(ls {el_dir}/mobile/erplibre_home_mobile/android/app/build" From 7cdcd8a07a38de147f0b2486fc3e7f54d6076fd3 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 24 Sep 2026 13:31:09 -0400 Subject: [PATCH 04/17] =?UTF-8?q?[ADD]=20garde-fou=20:=20signaler=20au=20c?= =?UTF-8?q?ommit=20un=20source=20trop=20r=C3=A9cent=20pour=20le=20d=C3=A9p?= =?UTF-8?q?=C3=B4t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une syntaxe plus récente que le Python du dépôt ne casse qu'au chargement, et aucun outil en place ne la voyait : la cible de black borne ce qu'il ÉCRIT, jamais ce qu'il accepte, et flake8 analyse avec l'interpréteur courant. Le contrôle compile les fichiers indexés avec un vrai interpréteur de la version déclarée, trouvé sans réseau par mise, pyenv ou le PATH, et se prend lui-même quand il y tourne déjà. Sans interpréteur, il DIT qu'il n'a pas vérifié plutôt que de laisser croire le contraire. Le hook relaie, sans bloquer le commit, et écarte les dépôts rapatriés dont ce dépôt ne répond pas. Vérifié : les f-strings PEP 701 de la révision précédente sont signalées, rien ne l'est sur l'arbre courant, et l'absence du fichier de version se dit. --- EN --- A syntax newer than the repository's Python only breaks at load, and no tool in place saw it: black's target bounds what it WRITES, never what it accepts, and flake8 parses with the running interpreter. The check compiles staged files with a real interpreter of the declared version, found without network through mise, pyenv or the PATH, and takes itself when it already runs under it. Without one, it SAYS it did not check rather than implying it did. The hook relays, without blocking the commit, and skips the checked-out repositories this one does not answer for. Checked: the PEP 701 f-strings of the previous revision are reported, nothing is on the current tree, and a missing version file is announced. Assisted-by: Claude Opus 5 --- .claude/skills/erplibre-deployment/SKILL.md | 4 + CHANGELOG.base.md | 2 + CHANGELOG.fr.md | 1 + CHANGELOG.md | 1 + script/analyse/check_python_version.py | 164 +++++++++++++++++ script/git/hooks/pre-commit | 39 +++- script/todo/todo_i18n.py | 18 ++ test/test_check_python_version.py | 188 ++++++++++++++++++++ 8 files changed, 416 insertions(+), 1 deletion(-) create mode 100755 script/analyse/check_python_version.py create mode 100644 test/test_check_python_version.py diff --git a/.claude/skills/erplibre-deployment/SKILL.md b/.claude/skills/erplibre-deployment/SKILL.md index 552e18d..b2f6688 100644 --- a/.claude/skills/erplibre-deployment/SKILL.md +++ b/.claude/skills/erplibre-deployment/SKILL.md @@ -36,6 +36,10 @@ Un seul fichier décide : `script/install/lib_python_provider.sh`. mise n'est jamais installé automatiquement — `make install_mise` porte cette décision. Pas de binaire mise pour s390x à ce jour : cette architecture reste sur pyenv. +Le hook `pre-commit` relaie `script/analyse/check_python_version.py` : il +signale le source qui ne parse pas sous cette version, sans bloquer, et dit +quand aucun interpréteur de cette version n'était là pour vérifier. + ## Paquets Python `EL_PIP_PROVIDER` (dans `env_var.sh`) vaut `auto`, `uv` ou `pip`. `auto` prend diff --git a/CHANGELOG.base.md b/CHANGELOG.base.md index 3d12634..371aedb 100644 --- a/CHANGELOG.base.md +++ b/CHANGELOG.base.md @@ -81,6 +81,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - The install log carries what the host decided before launching: the download cache authority placed or refused, the bypass, the mirror. Those lines were said on a console that scrolls away, while the file reopened after a failure held only the symptom — on a guest with no trust store, a refused certificate, hundreds of derivations to build and six hundred lines of errors, without a word on the cause - One entry of the download cache can be forgotten from the menu, under « Age and cleanup ». The binary could already do it; the menu offered only the two bulk purges, neither of which reaches a single object — « erase what has not served » never reaches one the service rejuvenates each time it serves it, and « erase everything » costs the whole cache for one file. `--detient` runs first and is the preview: same line, same key, nothing modified - `erplibre_go_qemu_cache --recle` stores a cache's objects again under the current key, without downloading anything, and merges the copies a mirror carried under several paths. Objects written under the former key rule stay on disk but become UNREACHABLE, so the service asks upstream for them again and the space they hold serves no one: on a store of 12 764 objects, 5 419 were in that case — 9.11 GiB — and merging the duplicates returned about 3.37 GiB. The service must be stopped, the body being renamed before its meta, and `--dry-run` only counts what would move. A status-only entry is left alone, its key carrying the host rather than the path +- The `pre-commit` hook runs `check_python_version.py` on staged files: it reports source that does not parse under the Python of `conf/python-erplibre-version`, without blocking the commit, and says when no such interpreter was there to check. Neither black nor flake8 sees that fault — black's target bounds what it writes, never what it accepts @@ -134,6 +135,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Le journal d'installation porte ce que l'hôte a décidé avant de lancer : l'autorité du cache de téléchargement posée ou refusée, l'exception, le miroir. Ces lignes se disaient sur une console qui défile, pendant que le fichier qu'on rouvre après un échec ne portait que le symptôme — sur un invité sans magasin de confiance, un certificat refusé, des centaines de dérivations à construire et six cents lignes d'erreurs, sans un mot sur la cause - Une entrée du cache de téléchargement s'oublie depuis le menu, sous « Âge et nettoyage ». Le binaire savait déjà le faire ; le menu n'offrait que les deux purges en gros, dont aucune ne vise un objet — « effacer ce qui n'a plus servi » n'atteint jamais celui que le service rajeunit chaque fois qu'il le rend, et « tout effacer » coûte le cache entier pour un fichier. `--detient` passe d'abord et fait l'aperçu : même ligne, même clé, sans rien modifier - `erplibre_go_qemu_cache --recle` range à nouveau les objets d'un cache sous la clé courante, sans rien retélécharger, et fond les copies qu'un miroir portait sous plusieurs chemins. Les objets écrits sous l'ancienne règle de clé restent sur le disque mais deviennent INTROUVABLES, si bien que le service les redemande à l'amont et que la place qu'ils tiennent ne sert plus personne : sur un magasin de 12 764 objets, 5 419 étaient dans ce cas — 9,11 Gio — et la fusion des doublons a rendu environ 3,37 Gio. Le service doit être arrêté, le corps étant renommé avant son méta, et `--dry-run` ne fait que compter ce qui bougerait. Un statut seul n'est pas touché, sa clé portant l'hôte et non le chemin +- Le hook `pre-commit` lance `check_python_version.py` sur les fichiers indexés : il signale le source qui ne parse pas sous le Python de `conf/python-erplibre-version`, sans bloquer le commit, et dit quand aucun interpréteur de cette version n'était là pour vérifier. Ni black ni flake8 ne voient ce défaut — la cible de black borne ce qu'il écrit, jamais ce qu'il accepte ## Changed diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 5729d9c..6bc6481 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -61,6 +61,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Le journal d'installation porte ce que l'hôte a décidé avant de lancer : l'autorité du cache de téléchargement posée ou refusée, l'exception, le miroir. Ces lignes se disaient sur une console qui défile, pendant que le fichier qu'on rouvre après un échec ne portait que le symptôme — sur un invité sans magasin de confiance, un certificat refusé, des centaines de dérivations à construire et six cents lignes d'erreurs, sans un mot sur la cause - Une entrée du cache de téléchargement s'oublie depuis le menu, sous « Âge et nettoyage ». Le binaire savait déjà le faire ; le menu n'offrait que les deux purges en gros, dont aucune ne vise un objet — « effacer ce qui n'a plus servi » n'atteint jamais celui que le service rajeunit chaque fois qu'il le rend, et « tout effacer » coûte le cache entier pour un fichier. `--detient` passe d'abord et fait l'aperçu : même ligne, même clé, sans rien modifier - `erplibre_go_qemu_cache --recle` range à nouveau les objets d'un cache sous la clé courante, sans rien retélécharger, et fond les copies qu'un miroir portait sous plusieurs chemins. Les objets écrits sous l'ancienne règle de clé restent sur le disque mais deviennent INTROUVABLES, si bien que le service les redemande à l'amont et que la place qu'ils tiennent ne sert plus personne : sur un magasin de 12 764 objets, 5 419 étaient dans ce cas — 9,11 Gio — et la fusion des doublons a rendu environ 3,37 Gio. Le service doit être arrêté, le corps étant renommé avant son méta, et `--dry-run` ne fait que compter ce qui bougerait. Un statut seul n'est pas touché, sa clé portant l'hôte et non le chemin +- Le hook `pre-commit` lance `check_python_version.py` sur les fichiers indexés : il signale le source qui ne parse pas sous le Python de `conf/python-erplibre-version`, sans bloquer le commit, et dit quand aucun interpréteur de cette version n'était là pour vérifier. Ni black ni flake8 ne voient ce défaut — la cible de black borne ce qu'il écrit, jamais ce qu'il accepte ## Modifié diff --git a/CHANGELOG.md b/CHANGELOG.md index 934c3a7..9fd79b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - The install log carries what the host decided before launching: the download cache authority placed or refused, the bypass, the mirror. Those lines were said on a console that scrolls away, while the file reopened after a failure held only the symptom — on a guest with no trust store, a refused certificate, hundreds of derivations to build and six hundred lines of errors, without a word on the cause - One entry of the download cache can be forgotten from the menu, under « Age and cleanup ». The binary could already do it; the menu offered only the two bulk purges, neither of which reaches a single object — « erase what has not served » never reaches one the service rejuvenates each time it serves it, and « erase everything » costs the whole cache for one file. `--detient` runs first and is the preview: same line, same key, nothing modified - `erplibre_go_qemu_cache --recle` stores a cache's objects again under the current key, without downloading anything, and merges the copies a mirror carried under several paths. Objects written under the former key rule stay on disk but become UNREACHABLE, so the service asks upstream for them again and the space they hold serves no one: on a store of 12 764 objects, 5 419 were in that case — 9.11 GiB — and merging the duplicates returned about 3.37 GiB. The service must be stopped, the body being renamed before its meta, and `--dry-run` only counts what would move. A status-only entry is left alone, its key carrying the host rather than the path +- The `pre-commit` hook runs `check_python_version.py` on staged files: it reports source that does not parse under the Python of `conf/python-erplibre-version`, without blocking the commit, and says when no such interpreter was there to check. Neither black nor flake8 sees that fault — black's target bounds what it writes, never what it accepts ## Changed diff --git a/script/analyse/check_python_version.py b/script/analyse/check_python_version.py new file mode 100755 index 0000000..7ae509b --- /dev/null +++ b/script/analyse/check_python_version.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Ce source parse-t-il sous le Python de conf/python-erplibre-version ? + +Un fichier dont la syntaxe dépasse cet interpréteur ne casse qu'au chargement, +et black ne le voit pas : sa cible borne ce qu'il ÉCRIT, jamais ce qu'il +accepte. L'interpréteur courant compile les fichiers quand sa majeure.mineure +convient, sinon un Python déjà installé le fait ; l'outil n'installe rien et +sort en 0. +""" + +import argparse +import glob +import os +import re +import shutil +import subprocess +import sys +from subprocess import PIPE + +RACINE = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..")) +VERSION = os.path.join(RACINE, "conf", "python-erplibre-version") +# Les dépôts que Google Repo rapatrie sous script/ portent leur propre norme et +# leur propre Python : le dépôt ne répond pas de leur source. Même liste que +# l'extend-exclude de .ruff.toml, pour que balayer un répertoire à la main +# juge exactement ce que le formatage touche. +EXCLU = re.compile( + r"(^|/)(\.git|\.venv[^/]*|node_modules|\.repo|__pycache__|addons" + r"|OCA_maintainer-tools|OCA_odoo-module-migrator)/" +) + +try: + sys.path.append(RACINE) + from script.todo.todo_i18n import t +except Exception: # pragma: no cover - repli si i18n indisponible + t = str + + +# Lu par l'interpréteur visé : n'emploie que de la syntaxe ancienne. +SONDE = r""" +import sys +for nom in sys.argv[1:]: + try: + with open(nom, "rb") as fh: + compile(fh.read(), nom, "exec", dont_inherit=True) + except Exception as exc: + ligne = getattr(exc, "lineno", None) or 1 + print("%s\t%s\t%s" % (nom, ligne, getattr(exc, "msg", exc))) +""" + + +def lance(cmd, **options): + """La sortie standard de cmd, vide sur échec ; l'erreur standard passe.""" + try: + fin = subprocess.run(cmd, stdout=PIPE, errors="replace", **options) + except (OSError, subprocess.SubprocessError): + return "" + return fin.stdout if fin.returncode == 0 else "" + + +def version_voulue(): + """La première ligne ni vide ni commentée du fichier version, ou None.""" + if not os.path.isfile(VERSION): + return None + with open(VERSION, encoding="utf-8") as fh: + lignes = [ligne.strip() for ligne in fh] + return next((x for x in lignes if x and not x.startswith("#")), None) + + +def interpreteur(version): + """Un python `version` déjà là : celui qui tourne, mise, pyenv, le PATH. + + La majeure.mineure suffit : elle seule décide de la grammaire acceptée. + Lancé depuis .venv.erplibre, l'outil se prend donc lui-même et ne cherche + nulle part ailleurs.""" + majeure_mineure = ".".join(version.split(".")[:2]) + if ".".join(map(str, sys.version_info[:2])) == majeure_mineure: + return sys.executable + if shutil.which("mise"): + # MISE_OFFLINE : résoudre la version sans interroger le réseau. + env = dict(os.environ, MISE_OFFLINE="1") + commande = ["mise", "where", "python@" + version] + prefixe = lance(commande, env=env, stderr=subprocess.DEVNULL) + exe = os.path.join(prefixe.strip(), "bin", "python3") + if prefixe and os.access(exe, os.X_OK): + return exe + pyenv = os.environ.get("PYENV_ROOT") or os.path.expanduser("~/.pyenv") + motif = os.path.join(pyenv, "versions", version + "*", "bin", "python") + trouves = sorted(p for p in glob.glob(motif) if os.access(p, os.X_OK)) + if trouves: + return trouves[-1] + return shutil.which("python" + majeure_mineure) + + +def est_python(chemin): + """Suffixe .py, ou fichier sans suffixe dont le hashbang nomme python.""" + if EXCLU.search(chemin) or not os.path.isfile(chemin): + return False + if os.path.splitext(chemin)[1]: + return chemin.endswith(".py") + with open(chemin, "rb") as fh: + premiere = fh.readline(200) + return premiere.startswith(b"#!") and b"python" in premiere + + +def fichiers(args): + """Les fichiers Python désignés : l'index git, ou les chemins parcourus.""" + if args.staged: + git = ["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR"] + noms = lance(git, cwd=RACINE).splitlines() + chemins = [os.path.relpath(os.path.join(RACINE, n)) for n in noms] + return [c for c in chemins if est_python(c)] + trouves = [] + for chemin in args.paths: + trouves += [chemin] if est_python(chemin) else [] + for base, dossiers, noms in os.walk(chemin): + dossiers[:] = [d for d in dossiers if not EXCLU.search(d + "/")] + complets = (os.path.join(base, n) for n in sorted(noms)) + trouves += [c for c in complets if est_python(c)] + return trouves + + +def main(argv=None): + parser = argparse.ArgumentParser( + description=t("does this source parse under the repository Python") + ) + parser.add_argument("paths", nargs="*") + parser.add_argument( + "--staged", + action="store_true", + help=t("only the files added to the git index"), + ) + args = parser.parse_args(argv) + if not args.staged and not args.paths: + parser.error(t("give a path, or --staged")) + version = version_voulue() + if not version: + avis = t("no conf/python-erplibre-version: nothing checked") + print(avis, file=sys.stderr) + return 0 + chemins = fichiers(args) + exe = chemins and interpreteur(version) + if chemins and not exe: + avis = t("not checked (no Python %s): mise install python@%s") + print(avis % (version, version), file=sys.stderr) + if not exe: + return 0 + fichier, refuses = None, 0 + for ligne in lance([exe, "-c", SONDE, *chemins]).splitlines(): + nom, numero, message = ligne.split("\t", 2) + if nom != fichier: + fichier, refuses = nom, refuses + 1 + print(nom) + print(f" 🔴 {numero:>5} {message}") + if refuses: + bilan = t("%s file(s) refused by Python %s") + print("\n" + bilan % (refuses, version)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/git/hooks/pre-commit b/script/git/hooks/pre-commit index 520e64f..d5ae56a 100755 --- a/script/git/hooks/pre-commit +++ b/script/git/hooks/pre-commit @@ -7,6 +7,9 @@ Il INFORME et ne refuse jamais — le nettoyage se fait au fur et à mesure, sur les fichiers qu'on touche déjà, et non en une passe qui réécrirait le dépôt. Un hook qui bloquerait sur du style se ferait désinstaller la même semaine. +Il relaie aussi check_python_version.py, sans bloquer davantage : le source +indexé qui ne parse pas sous le Python de conf/python-erplibre-version. + Installation : git config core.hooksPath script/git/hooks @@ -15,6 +18,7 @@ Il sort toujours en 0. Pour l'interroger à la main, sur un fichier ou un répertoire : python3 script/analyse/check_comment_hygiene.py script/todo/todo.py + python3 script/analyse/check_python_version.py script/todo/todo.py """ import subprocess import sys @@ -22,12 +26,13 @@ from pathlib import Path RACINE = Path(__file__).resolve().parents[3] OUTIL = RACINE / "script" / "analyse" / "check_comment_hygiene.py" +VERSION = RACINE / "script" / "analyse" / "check_python_version.py" # Au-delà, le rapport cesse d'être une invitation et devient un mur. PLAFOND = 12 -def main() -> int: +def commentaires() -> int: if not OUTIL.is_file(): return 0 @@ -76,5 +81,37 @@ def main() -> int: return 0 +def syntaxe() -> None: + """Relaie les refus de syntaxe, ou l'avis d'un contrôle absent.""" + if not VERSION.is_file(): + return + try: + sortie = subprocess.run( + [sys.executable, str(VERSION), "--staged"], + capture_output=True, + text=True, + cwd=str(RACINE), + timeout=30, + ) + except (OSError, subprocess.SubprocessError): + return + lignes = (sortie.stdout + sortie.stderr).strip().split("\n") + if not lignes[0]: + return + trop = len(lignes) - PLAFOND + if trop > 0: + lignes = lignes[:PLAFOND] + [f" … et {trop} lignes de plus"] + print("\n ⓘ syntaxe Python du dépôt\n", file=sys.stderr) + for ligne in lignes: + print(f" {ligne}", file=sys.stderr) + print("\n Le commit n'est PAS bloqué.\n", file=sys.stderr) + + +def main() -> int: + commentaires() + syntaxe() + return 0 + + if __name__ == "__main__": sys.exit(main()) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 87db79b..8116e1c 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -9502,6 +9502,24 @@ TRANSLATIONS = { "fr": "donner un chemin, ou --staged", "en": "give a path, or --staged", }, + # check_python_version.py partage « only the files added to the git + # index » et « give a path, or --staged » avec check_comment_hygiene.py. + "does this source parse under the repository Python": { + "fr": "ce source parse-t-il sous le Python du dépôt", + "en": "does this source parse under the repository Python", + }, + "no conf/python-erplibre-version: nothing checked": { + "fr": "aucun conf/python-erplibre-version : rien vérifié", + "en": "no conf/python-erplibre-version: nothing checked", + }, + "not checked (no Python %s): mise install python@%s": { + "fr": "NON vérifié (aucun Python %s ici) : mise install python@%s", + "en": "not checked (no Python %s): mise install python@%s", + }, + "%s file(s) refused by Python %s": { + "fr": "%s fichier(s) refusé(s) par Python %s", + "en": "%s file(s) refused by Python %s", + }, "unknown result": { "fr": "résultat inconnu", "en": "unknown result", diff --git a/test/test_check_python_version.py b/test/test_check_python_version.py new file mode 100644 index 0000000..538af41 --- /dev/null +++ b/test/test_check_python_version.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Le contrôle voit-il ce que le Python du dépôt refuse ? + +Ce contrôle existe parce qu'aucun autre outil ne voit ce défaut : la cible de +black borne ce qu'il ÉCRIT, jamais ce qu'il accepte, et flake8 analyse avec +l'interpréteur courant. Une syntaxe que la version déclarée ne connaît pas ne +casse donc qu'au chargement, loin du commit qui l'a introduite. + +Sa portée suit conf/python-erplibre-version : il ne signale que ce que CETTE +version refuse. Le dépôt déclarant aujourd'hui la plus récente des versions +publiées, seuls un source cassé et un source écrit pour plus récent encore lui +échappent — d'où les tests qui déclarent une version ancienne pour éprouver le +mécanisme lui-même. + +Deux exigences que ces tests gardent, et qu'il serait facile de perdre : + +- SANS interpréteur de la version voulue, le contrôle DIT qu'il n'a pas + vérifié. Un outil qui rend 0 en silence laisse croire qu'il a regardé. +- Les dépôts rapatriés sous script/ sont écartés même quand leur chemin est + NOMMÉ : sans « force-exclude », l'exclusion ne vaudrait qu'à la découverte, + et le contrôle irait juger l'historique d'autrui. +""" + +import contextlib +import io +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +RACINE = Path(__file__).resolve().parents[1] +OUTIL = RACINE / "script/analyse/check_python_version.py" + +sys.path.insert(0, str(RACINE / "script" / "analyse")) + +import check_python_version as controle # noqa: E402 + +# Cas INVENTÉS, jamais copiés d'un fichier du dépôt : un exemple qui illustre +# un interdit ne se prend pas dans le parc. +# +# Une f-string PEP 701 — des guillemets doubles dans une f-string à guillemets +# doubles — que Python refuse avant 3.12 et accepte depuis. +PEP_701 = 'x = f"{"a"}"\n' +# Cassé sous toute version. +CASSE = "def f(:\n pass\n" + + +@contextlib.contextmanager +def version_declaree(valeur): + """Fait dire au dépôt qu'il vise `valeur`, le temps d'un test.""" + with tempfile.TemporaryDirectory() as coin: + fichier = Path(coin) / "python-erplibre-version" + fichier.write_text(valeur, encoding="utf-8") + ancien, controle.VERSION = controle.VERSION, str(fichier) + try: + yield + finally: + controle.VERSION = ancien + + +def juge(contenu, version): + """Ce que l'outil imprime sur un fichier portant `contenu`.""" + with tempfile.TemporaryDirectory() as coin: + cible = Path(coin) / "echantillon.py" + cible.write_text(contenu, encoding="utf-8") + sortie = io.StringIO() + with version_declaree(version), contextlib.redirect_stdout(sortie): + code = controle.main([str(cible)]) + return code, sortie.getvalue() + + +def interpreteur_absent(version): + return controle.interpreteur(version) is None + + +class TestCeQuIlVoit(unittest.TestCase): + def test_un_source_casse_est_signale(self): + version = controle.version_voulue() + if interpreteur_absent(version): + self.skipTest(f"aucun Python {version} ici") + code, sortie = juge(CASSE, version) + self.assertIn("echantillon.py", sortie) + self.assertIn("🔴", sortie) + self.assertEqual(0, code, "il informe, il ne bloque pas") + + def test_une_syntaxe_plus_recente_que_la_version_declaree(self): + """Le mécanisme même : PEP 701 refusé quand le dépôt vise 3.10.""" + if interpreteur_absent("3.10"): + self.skipTest("aucun Python 3.10 ici") + _, sortie = juge(PEP_701, "3.10") + self.assertIn("echantillon.py", sortie) + + def test_la_meme_syntaxe_passe_sous_une_version_qui_la_connait(self): + if interpreteur_absent("3.12"): + self.skipTest("aucun Python 3.12 ici") + _, sortie = juge(PEP_701, "3.12") + self.assertEqual("", sortie.strip()) + + def test_un_source_ordinaire_ne_dit_rien(self): + version = controle.version_voulue() + if interpreteur_absent(version): + self.skipTest(f"aucun Python {version} ici") + _, sortie = juge("import os\n\nprint(os.sep)\n", version) + self.assertEqual("", sortie.strip()) + + def test_le_depot_lui_meme_passe(self): + fin = subprocess.run( + [sys.executable, str(OUTIL), "script/"], + cwd=RACINE, + capture_output=True, + text=True, + ) + self.assertEqual("", fin.stdout.strip(), fin.stdout) + self.assertEqual(0, fin.returncode) + + +class TestCeQuIlReconnait(unittest.TestCase): + def test_un_executable_sans_suffixe_au_hashbang_python(self): + """Les deux hooks du dépôt n'ont pas de suffixe : ils comptent.""" + with tempfile.TemporaryDirectory() as coin: + hook = Path(coin) / "pre-commit" + hook.write_text( + "#!/usr/bin/env python3\nx = 1\n", encoding="utf-8" + ) + self.assertTrue(controle.est_python(str(hook))) + + def test_un_fichier_sans_suffixe_ni_hashbang_python_est_ignore(self): + with tempfile.TemporaryDirectory() as coin: + texte = Path(coin) / "LISEZMOI" + texte.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + self.assertFalse(controle.est_python(str(texte))) + + def test_un_depot_rapatrie_est_ecarte_meme_nomme(self): + self.assertFalse( + controle.est_python("script/OCA_maintainer-tools/tools/config.py") + ) + self.assertFalse(controle.est_python("addons/module/models/x.py")) + + +class TestQuandIlNePeutPasVerifier(unittest.TestCase): + def test_sans_interpreteur_il_dit_qu_il_n_a_pas_verifie(self): + """Le silence ferait croire à un contrôle qui n'a pas eu lieu.""" + aveugle = dict( + os.environ, + PATH="/nonexistent", + PYENV_ROOT="/nonexistent", + MISE_DATA_DIR="/nonexistent", + ) + with tempfile.TemporaryDirectory() as coin: + cible = Path(coin) / "echantillon.py" + cible.write_text(CASSE, encoding="utf-8") + fin = subprocess.run( + [sys.executable, str(OUTIL), str(cible)], + cwd=RACINE, + capture_output=True, + text=True, + env=aveugle, + ) + if fin.stdout.strip(): + self.skipTest("un interpréteur de la version reste joignable ici") + self.assertTrue(fin.stderr.strip(), "l'avertissement manque") + self.assertEqual(0, fin.returncode) + + def test_sans_fichier_de_version_rien_n_est_affirme(self): + with tempfile.TemporaryDirectory() as coin: + ancien = controle.VERSION + controle.VERSION = str(Path(coin) / "absent") + try: + self.assertIsNone(controle.version_voulue()) + finally: + controle.VERSION = ancien + + +class TestLaVersionLue(unittest.TestCase): + def test_la_premiere_ligne_utile_est_retenue(self): + with version_declaree("# un commentaire\n\n3.14.7\n"): + self.assertEqual("3.14.7", controle.version_voulue()) + + def test_le_depot_declare_bien_une_version(self): + self.assertRegex(controle.version_voulue(), r"^\d+\.\d+") + + +if __name__ == "__main__": + unittest.main() From 42e10bcced0bc70f34c855846e4d0321f0202516 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 24 Sep 2026 13:31:32 -0400 Subject: [PATCH 05/17] [UPD] install : le venv d'outillage passe en Python 3.14.7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit install_erplibre.sh bâtissait .venv.erplibre avec le python3 du système, quelle que soit sa version : un 3.10 y donnait un venv que l'outillage ne sait pas charger. Il délègue à install_venv.sh, qui obtient la version par mise, pyenv ou la distribution, et rebâtit un venv hors service. Le PATCH ne borne que le venv d'Odoo, dont le pyproject exige « >=3.12.10,<3.13 » : l'exiger ailleurs écartait un Python de distribution d'un cran en retard — NixOS 25.11 livre 3.14.2 — et faisait compiler CPython pour rien. Le module NixOS déclare désormais les DEUX Python, substitués depuis les fichiers de version. Vérifié : 15 tests sur des venvs factices, conservés, rebâtis ou refusés selon leur état, et un chemin sans pyvenv.cfg garde son contenu. --- EN --- install_erplibre.sh built .venv.erplibre with the system python3, whatever its version: a 3.10 gave a venv the tooling cannot load. It delegates to install_venv.sh, which obtains the version through mise, pyenv or the distribution, and rebuilds a venv out of service. The PATCH bounds only Odoo's venv, whose pyproject requires ">=3.12.10,<3.13": requiring it elsewhere turned away a distribution Python one step behind — NixOS 25.11 ships 3.14.2 — and compiled CPython for nothing. The NixOS module now declares BOTH Pythons, substituted from the version files. Checked: 15 tests on stub venvs, kept, rebuilt or refused by their state, and a path without pyvenv.cfg keeps its content. Assisted-by: Claude Opus 5 --- .claude/skills/erplibre-deployment/SKILL.md | 22 ++ CHANGELOG.base.md | 8 + CHANGELOG.fr.md | 4 + CHANGELOG.md | 4 + Makefile | 16 +- README.base.md | 11 + README.fr.md | 6 + README.md | 5 + conf/nixos/erplibre.nix | 23 +- conf/python-erplibre-version | 2 +- doc/WINDOWS_INSTALLATION.base.md | 8 + doc/WINDOWS_INSTALLATION.fr.md | 4 + doc/WINDOWS_INSTALLATION.md | 4 + docker/Dockerfile.prod.pkg | 4 + docker/README.base.md | 10 + docker/README.fr.md | 5 + docker/README.md | 5 + script/install/install_erplibre.sh | 62 ++++- script/install/install_git_repo.sh | 42 ++- script/install/install_nixos_dependency.sh | 18 ++ script/install/install_venv.sh | 145 +++++++++- script/install/lib_python_provider.sh | 55 +++- script/todo/source_todo.sh | 45 +--- test/test_install_nixos.py | 31 ++- test/test_install_venv_rebuild.py | 278 ++++++++++++++++++++ 25 files changed, 739 insertions(+), 78 deletions(-) create mode 100644 test/test_install_venv_rebuild.py diff --git a/.claude/skills/erplibre-deployment/SKILL.md b/.claude/skills/erplibre-deployment/SKILL.md index b2f6688..c72f257 100644 --- a/.claude/skills/erplibre-deployment/SKILL.md +++ b/.claude/skills/erplibre-deployment/SKILL.md @@ -36,9 +36,31 @@ Un seul fichier décide : `script/install/lib_python_provider.sh`. mise n'est jamais installé automatiquement — `make install_mise` porte cette décision. Pas de binaire mise pour s390x à ce jour : cette architecture reste sur pyenv. +## Le Python de l'outillage + +`conf/python-erplibre-version` (3.14.7) donne l'interpréteur de +`.venv.erplibre`, le venv d'outillage, distinct du venv Odoo (3.12.10 pour +Odoo 18.0). C'est la seule version que `script/` vise : là où une distribution +ne la porte pas, pyenv la compile. + +Le PATCH ne borne que le venv d'Odoo, dont le pyproject exige +`>=3.12.10,<3.13`. Pour l'outillage, la majeure.mineure suffit : exiger le +patch écarterait le Python d'une distribution d'un cran en retard — NixOS 25.11 +livre 3.14.2 — et ferait compiler CPython pour rien. + +`install_erplibre.sh` passe par `install_venv.sh`, donc par +`EL_PYTHON_PROVIDER`. Un venv dont `bin/python` n'est pas compatible (même +majeure.mineure, patch au moins égal) est DÉTRUIT puis rebâti : ce qui y avait +été posé à la main part avec lui. Le venv d'Odoo, lui, n'est rebâti que s'il +est HORS SERVICE : une simple différence de version le laisse en place, parce +que le rebâtir refait une installation Poetry entière. Un répertoire sans +`pyvenv.cfg` n'est jamais effacé. + Le hook `pre-commit` relaie `script/analyse/check_python_version.py` : il signale le source qui ne parse pas sous cette version, sans bloquer, et dit quand aucun interpréteur de cette version n'était là pour vérifier. +L'image Docker de production bâtit `.venv.erplibre` sur le Python d'Odoo de son +image de base, et s'arrête si ce Python ne sait pas lire `script/`. ## Paquets Python diff --git a/CHANGELOG.base.md b/CHANGELOG.base.md index 371aedb..c214a8b 100644 --- a/CHANGELOG.base.md +++ b/CHANGELOG.base.md @@ -156,6 +156,10 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - A VM deployed with the cache upstream cut — QEMU form, Proxmox VE, or `deploy_qemu.py --offline` — has npm's security audit turned off (`NPM_CONFIG_AUDIT=false`): it queries a remote service no cache can replay, and failed on every offline install. An online VM keeps its audit - Verifying a downloaded image no longer needs `--verify`: it runs by default for every distribution that publishes a sum, and `--no-verify` is what skips it — to be kept for offline runs, where a substituted image would otherwise pass unremarked - `--bios` is refused on an image with no BIOS boot sector, and says why. Forced there, it gave a VM reported « running » with a silent console — the very failure that flag exists to avoid elsewhere +- The tooling virtual environment `.venv.erplibre` runs Python 3.14.7, independently of the Odoo one (3.12.10 for Odoo 18.0). `install_erplibre.sh` builds it through `install_venv.sh` and `EL_PYTHON_PROVIDER` instead of the system `python3` +- `.venv.erplibre` on an incompatible Python is DELETED and rebuilt; whatever was installed in it by hand goes with it. Odoo's venv is kept when it merely differs in version, and rebuilt only when it is unusable: rebuilding it redoes a whole Poetry install. A directory without `pyvenv.cfg` is never deleted +- The production Docker image builds `.venv.erplibre` on Odoo's Python and stops when that Python cannot parse `script/` +- The PATCH bounds only Odoo's venv, whose pyproject requires `>=3.12.10,<3.13`. For the tooling one, the major.minor is enough: requiring the patch turned away a distribution's Python one step behind — NixOS 25.11 ships 3.14.2 where conf asks 3.14.7 — and made pyenv COMPILE CPython for a difference nothing needs - `make format` picks the formatter from each file's context: an Odoo module keeps isort and black on `py37`, the series still supported going down that far, while this repository's own tooling goes through ruff, configured once in `.ruff.toml`. ruff follows CPython's versions, where black 24.8.0 stops at `py313`, and its import sorting replaces isort; it is also what the OCA standard uses since it left black - The repositories that Google Repo checks out under `script/` are excluded from that formatting, a named path included: reformatting them would write in someone else's history. `target-version` stays at `py310` there, because the git hooks carry `#!/usr/bin/env python3` and a distribution still ships 3.10 — from 3.14 on, ruff would write `except A, B:` without parentheses @@ -174,6 +178,10 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Une VM déployée l'amont du cache coupé — formulaire QEMU, Proxmox VE, ou `deploy_qemu.py --offline` — a l'audit de sécurité de npm désactivé (`NPM_CONFIG_AUDIT=false`) : il interroge un service qu'aucun cache ne rejoue, et échouait à chaque installation hors ligne. Une VM en ligne garde son audit - Vérifier une image téléchargée ne demande plus `--verify` : c'est le défaut pour toute distribution qui publie une somme, et `--no-verify` est ce qui la saute — à réserver aux essais hors ligne, où une image substituée passerait autrement sans un mot - `--bios` est refusé sur une image sans secteur d'amorçage BIOS, et dit pourquoi. Forcé là, il donnait une VM « running » à console muette — la panne même que ce drapeau évite ailleurs +- L'environnement virtuel d'outillage `.venv.erplibre` tourne en Python 3.14.7, indépendamment de celui d'Odoo (3.12.10 pour Odoo 18.0). `install_erplibre.sh` le bâtit par `install_venv.sh` et `EL_PYTHON_PROVIDER` plutôt qu'avec le `python3` du système +- `.venv.erplibre` sur un Python incompatible est SUPPRIMÉ puis rebâti ; ce qu'on y avait posé à la main part avec lui. Le venv d'Odoo est conservé quand seule sa version diffère, et rebâti seulement s'il est hors service : le rebâtir refait une installation Poetry entière. Un répertoire sans `pyvenv.cfg` n'est jamais effacé +- L'image Docker de production bâtit `.venv.erplibre` sur le Python d'Odoo et s'arrête quand ce Python ne sait pas lire `script/` +- Le PATCH ne borne que le venv d'Odoo, dont le pyproject exige `>=3.12.10,<3.13`. Pour celui de l'outillage, la majeure.mineure suffit : exiger le patch écartait le Python d'une distribution d'un cran en retard — NixOS 25.11 livre 3.14.2 quand conf demande 3.14.7 — et faisait COMPILER CPython à pyenv pour une différence que rien ne réclame - `make format` choisit le formateur d'après le contexte de chaque fichier : un module Odoo garde isort et black en `py37`, la série la plus ancienne encore supportée, quand l'outillage de ce dépôt passe par ruff, réglé une fois dans `.ruff.toml`. ruff suit les versions de CPython, là où black 24.8.0 s'arrête à `py313`, et son tri d'imports remplace isort ; c'est aussi ce qu'emploie la norme OCA depuis qu'elle a quitté black - Les dépôts que Google Repo rapatrie sous `script/` sont écartés de ce formatage, chemin nommé compris : les reformater écrirait dans l'historique d'autrui. `target-version` y reste à `py310`, parce que les hooks git portent `#!/usr/bin/env python3` et qu'une distribution livre encore 3.10 — à partir de 3.14, ruff écrirait `except A, B:` sans parenthèses diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 6bc6481..d1a9e56 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -78,6 +78,10 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Une VM déployée l'amont du cache coupé — formulaire QEMU, Proxmox VE, ou `deploy_qemu.py --offline` — a l'audit de sécurité de npm désactivé (`NPM_CONFIG_AUDIT=false`) : il interroge un service qu'aucun cache ne rejoue, et échouait à chaque installation hors ligne. Une VM en ligne garde son audit - Vérifier une image téléchargée ne demande plus `--verify` : c'est le défaut pour toute distribution qui publie une somme, et `--no-verify` est ce qui la saute — à réserver aux essais hors ligne, où une image substituée passerait autrement sans un mot - `--bios` est refusé sur une image sans secteur d'amorçage BIOS, et dit pourquoi. Forcé là, il donnait une VM « running » à console muette — la panne même que ce drapeau évite ailleurs +- L'environnement virtuel d'outillage `.venv.erplibre` tourne en Python 3.14.7, indépendamment de celui d'Odoo (3.12.10 pour Odoo 18.0). `install_erplibre.sh` le bâtit par `install_venv.sh` et `EL_PYTHON_PROVIDER` plutôt qu'avec le `python3` du système +- `.venv.erplibre` sur un Python incompatible est SUPPRIMÉ puis rebâti ; ce qu'on y avait posé à la main part avec lui. Le venv d'Odoo est conservé quand seule sa version diffère, et rebâti seulement s'il est hors service : le rebâtir refait une installation Poetry entière. Un répertoire sans `pyvenv.cfg` n'est jamais effacé +- L'image Docker de production bâtit `.venv.erplibre` sur le Python d'Odoo et s'arrête quand ce Python ne sait pas lire `script/` +- Le PATCH ne borne que le venv d'Odoo, dont le pyproject exige `>=3.12.10,<3.13`. Pour celui de l'outillage, la majeure.mineure suffit : exiger le patch écartait le Python d'une distribution d'un cran en retard — NixOS 25.11 livre 3.14.2 quand conf demande 3.14.7 — et faisait COMPILER CPython à pyenv pour une différence que rien ne réclame - `make format` choisit le formateur d'après le contexte de chaque fichier : un module Odoo garde isort et black en `py37`, la série la plus ancienne encore supportée, quand l'outillage de ce dépôt passe par ruff, réglé une fois dans `.ruff.toml`. ruff suit les versions de CPython, là où black 24.8.0 s'arrête à `py313`, et son tri d'imports remplace isort ; c'est aussi ce qu'emploie la norme OCA depuis qu'elle a quitté black - Les dépôts que Google Repo rapatrie sous `script/` sont écartés de ce formatage, chemin nommé compris : les reformater écrirait dans l'historique d'autrui. `target-version` y reste à `py310`, parce que les hooks git portent `#!/usr/bin/env python3` et qu'une distribution livre encore 3.10 — à partir de 3.14, ruff écrirait `except A, B:` sans parenthèses diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fd79b3..eb715ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,10 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - A VM deployed with the cache upstream cut — QEMU form, Proxmox VE, or `deploy_qemu.py --offline` — has npm's security audit turned off (`NPM_CONFIG_AUDIT=false`): it queries a remote service no cache can replay, and failed on every offline install. An online VM keeps its audit - Verifying a downloaded image no longer needs `--verify`: it runs by default for every distribution that publishes a sum, and `--no-verify` is what skips it — to be kept for offline runs, where a substituted image would otherwise pass unremarked - `--bios` is refused on an image with no BIOS boot sector, and says why. Forced there, it gave a VM reported « running » with a silent console — the very failure that flag exists to avoid elsewhere +- The tooling virtual environment `.venv.erplibre` runs Python 3.14.7, independently of the Odoo one (3.12.10 for Odoo 18.0). `install_erplibre.sh` builds it through `install_venv.sh` and `EL_PYTHON_PROVIDER` instead of the system `python3` +- `.venv.erplibre` on an incompatible Python is DELETED and rebuilt; whatever was installed in it by hand goes with it. Odoo's venv is kept when it merely differs in version, and rebuilt only when it is unusable: rebuilding it redoes a whole Poetry install. A directory without `pyvenv.cfg` is never deleted +- The production Docker image builds `.venv.erplibre` on Odoo's Python and stops when that Python cannot parse `script/` +- The PATCH bounds only Odoo's venv, whose pyproject requires `>=3.12.10,<3.13`. For the tooling one, the major.minor is enough: requiring the patch turned away a distribution's Python one step behind — NixOS 25.11 ships 3.14.2 where conf asks 3.14.7 — and made pyenv COMPILE CPython for a difference nothing needs - `make format` picks the formatter from each file's context: an Odoo module keeps isort and black on `py37`, the series still supported going down that far, while this repository's own tooling goes through ruff, configured once in `.ruff.toml`. ruff follows CPython's versions, where black 24.8.0 stops at `py313`, and its import sorting replaces isort; it is also what the OCA standard uses since it left black - The repositories that Google Repo checks out under `script/` are excluded from that formatting, a named path included: reformatting them would write in someone else's history. `target-version` stays at `py310` there, because the git hooks carry `#!/usr/bin/env python3` and a distribution still ships 3.10 — from 3.14 on, ruff would write `except A, B:` without parentheses diff --git a/Makefile b/Makefile index a5c0aa6..7a4992e 100644 --- a/Makefile +++ b/Makefile @@ -129,6 +129,8 @@ install_uv: echo "Ajoutez ce repertoire au PATH, ou installez uv globalement."; \ fi +# Telecharge puis execute : dans « curl | sh », le statut est celui de sh, +# qui rend 0 sur une entree vide et masque un telechargement rate. .PHONY: install_mise install_mise: @if command -v mise >/dev/null 2>&1; then \ @@ -139,7 +141,19 @@ install_mise: echo "mise ne publie pas de binaire s390x : on reste sur pyenv."; \ else \ echo "Installation de mise depuis https://mise.run"; \ - curl -fsSL https://mise.run | sh; \ + installateur="$$(mktemp)" || exit 1; \ + if ! curl -fsSL -o "$$installateur" https://mise.run; then \ + rm -f "$$installateur"; \ + echo "Telechargement de l installateur mise impossible" \ + "(reseau ou cache) : mise n est pas pose." >&2; \ + exit 1; \ + fi; \ + if ! sh "$$installateur"; then \ + rm -f "$$installateur"; \ + echo "L installateur de mise a echoue (voir ci-dessus)." >&2; \ + exit 1; \ + fi; \ + rm -f "$$installateur"; \ echo "Ajoutez ~/.local/bin a votre PATH, puis relancez l installation."; \ fi diff --git a/README.base.md b/README.base.md index fad2699..70a9ad6 100644 --- a/README.base.md +++ b/README.base.md @@ -86,10 +86,21 @@ Suivez-nous sur Mastodon : https://fosstodon.org/@erplibre Switch between versions with `make switch_odoo_18`, `make switch_odoo_16`, etc. +The Python in that table is the one of the Odoo virtual environment. The +tooling virtual environment `.venv.erplibre` (TODO, `repo`, formatters) runs +its own interpreter, **3.14.7**, set by `conf/python-erplibre-version`. Where a +distribution does not carry it, pyenv compiles it. + Changez de version avec `make switch_odoo_18`, `make switch_odoo_16`, etc. +Le Python de ce tableau est celui de l'environnement virtuel Odoo. +L'environnement virtuel d'outillage `.venv.erplibre` (TODO, `repo`, formateurs) +tourne sur son propre interpréteur, **3.14.7**, fixé par +`conf/python-erplibre-version`. Là où une distribution ne le porte pas, pyenv +le compile. + # Supported platforms diff --git a/README.fr.md b/README.fr.md index 0b0e333..e942d48 100644 --- a/README.fr.md +++ b/README.fr.md @@ -44,6 +44,12 @@ Suivez-nous sur Mastodon : https://fosstodon.org/@erplibre Changez de version avec `make switch_odoo_18`, `make switch_odoo_16`, etc. +Le Python de ce tableau est celui de l'environnement virtuel Odoo. +L'environnement virtuel d'outillage `.venv.erplibre` (TODO, `repo`, formateurs) +tourne sur son propre interpréteur, **3.14.7**, fixé par +`conf/python-erplibre-version`. Là où une distribution ne le porte pas, pyenv +le compile. + # Plateformes supportées diff --git a/README.md b/README.md index 037695a..2a7232d 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,11 @@ Follow us on Mastodon : https://fosstodon.org/@erplibre Switch between versions with `make switch_odoo_18`, `make switch_odoo_16`, etc. +The Python in that table is the one of the Odoo virtual environment. The +tooling virtual environment `.venv.erplibre` (TODO, `repo`, formatters) runs +its own interpreter, **3.14.7**, set by `conf/python-erplibre-version`. Where a +distribution does not carry it, pyenv compiles it. + # Supported platforms diff --git a/conf/nixos/erplibre.nix b/conf/nixos/erplibre.nix index f0aac4a..345ed75 100644 --- a/conf/nixos/erplibre.nix +++ b/conf/nixos/erplibre.nix @@ -83,17 +83,26 @@ }; # ── Les outils ─────────────────────────────────────────────────────────── - # python3.12 : la version qu'attend ERPLibre (.python-odoo-version). envfs - # la rend visible en /usr/bin/python3.12, où lib_python_provider.sh la - # cherche — ni mise ni pyenv n'ont alors à télécharger quoi que ce soit. + # Les DEUX Python du dépôt, substitués depuis ses fichiers de version : + # celui d'Odoo (.python-odoo-version) et celui de l'outillage + # (conf/python-erplibre-version), qui ne sont plus la même version. envfs + # les rend visibles en /usr/bin/python3.X, où lib_python_provider.sh les + # cherche — ni mise ni pyenv n'ont alors rien à compiler. Déclarer le seul + # Python d'Odoo laissait pyenv bâtir l'autre, et la compilation de CPython + # s'arrête ici sur « Modules/_cursesmodule.o ». + # + # Le second marqueur est VIDE quand les deux versions coïncident : nommer + # deux fois le même paquet ferait entrer en collision deux chemins + # identiques dans le profil. # # Les sorties « .dev » portent les en-têtes : sans elles, une roue absente # du dépôt amont devrait se compiler et ne trouverait ni libpq-fe.h ni # openssl/ssl.h. Elles ne servent qu'à ce cas, et ne coûtent que du disque. environment.systemPackages = with pkgs; [ - python312 - python312Packages.pip - python312Packages.virtualenv + @EL_PY_ODOO_PKG@ + @EL_PY_ODOO_PKG@Packages.pip + @EL_PY_ODOO_PKG@Packages.virtualenv + @EL_PY_TOOLS_PKG@ uv nodejs_22 postgresql @@ -336,7 +345,7 @@ # venv, donc avec le python du système. Son échec est silencieux # (« 2>/dev/null ») : sans elle, la première page ouverte attendrait le # chargement du registre sans que rien ne le dise. - path = with pkgs; [ bash python312 ]; + path = with pkgs; [ bash @EL_PY_ODOO_PKG@ ]; # L'unité est déclarée par le module, donc démarrée par la # reconstruction — qui a lieu PENDANT « make install_os », alors que la # source d'Odoo n'arrive qu'à « make install_odoo_18 ». Sans condition, diff --git a/conf/python-erplibre-version b/conf/python-erplibre-version index d367df0..38f27a2 100644 --- a/conf/python-erplibre-version +++ b/conf/python-erplibre-version @@ -1 +1 @@ -3.12.10 \ No newline at end of file +3.14.7 \ No newline at end of file diff --git a/doc/WINDOWS_INSTALLATION.base.md b/doc/WINDOWS_INSTALLATION.base.md index 44201e4..27de3d9 100644 --- a/doc/WINDOWS_INSTALLATION.base.md +++ b/doc/WINDOWS_INSTALLATION.base.md @@ -439,6 +439,10 @@ If these last steps to set up your development environment were unsuccessful, fo ## Manual Installation +This system interpreter only needs to reach `make` (Python 3.10 or later). +`.venv.erplibre` is built on its own version, `conf/python-erplibre-version`, +through `EL_PYTHON_PROVIDER` (mise or pyenv). + ### Install Python 3.10.14 You can delete the files that are left over in your home directory regarding the python installation when the steps have been completed succesfully. @@ -447,6 +451,10 @@ Si ces dernières étapes pour configurer votre environnement de développement ## Installation manuelle +Cet interpréteur système ne sert qu'à atteindre `make` (Python 3.10 ou plus). +`.venv.erplibre` est bâti sur sa propre version, `conf/python-erplibre-version`, +par `EL_PYTHON_PROVIDER` (mise ou pyenv). + ### Installer Python 3.10.14 Vous pouvez supprimer les fichiers restants dans votre répertoire personnel concernant l'installation de Python une fois les étapes complétées avec succès. diff --git a/doc/WINDOWS_INSTALLATION.fr.md b/doc/WINDOWS_INSTALLATION.fr.md index a6a2b49..03abb25 100644 --- a/doc/WINDOWS_INSTALLATION.fr.md +++ b/doc/WINDOWS_INSTALLATION.fr.md @@ -236,6 +236,10 @@ Si ces dernières étapes pour configurer votre environnement de développement ## Installation manuelle +Cet interpréteur système ne sert qu'à atteindre `make` (Python 3.10 ou plus). +`.venv.erplibre` est bâti sur sa propre version, `conf/python-erplibre-version`, +par `EL_PYTHON_PROVIDER` (mise ou pyenv). + ### Installer Python 3.10.14 Vous pouvez supprimer les fichiers restants dans votre répertoire personnel concernant l'installation de Python une fois les étapes complétées avec succès. diff --git a/doc/WINDOWS_INSTALLATION.md b/doc/WINDOWS_INSTALLATION.md index 912ef51..440ec76 100644 --- a/doc/WINDOWS_INSTALLATION.md +++ b/doc/WINDOWS_INSTALLATION.md @@ -236,6 +236,10 @@ If these last steps to set up your development environment were unsuccessful, fo ## Manual Installation +This system interpreter only needs to reach `make` (Python 3.10 or later). +`.venv.erplibre` is built on its own version, `conf/python-erplibre-version`, +through `EL_PYTHON_PROVIDER` (mise or pyenv). + ### Install Python 3.10.14 You can delete the files that are left over in your home directory regarding the python installation when the steps have been completed succesfully. diff --git a/docker/Dockerfile.prod.pkg b/docker/Dockerfile.prod.pkg index 5d848f3..d560b18 100644 --- a/docker/Dockerfile.prod.pkg +++ b/docker/Dockerfile.prod.pkg @@ -37,8 +37,12 @@ RUN cd $ODOO_PREFIX && \ ls -lha /ERPLibre && \ python -m venv .venv.$ERPLIBRE_VERSION +# Le venv des outils prend le python de l'image, celui d'Odoo : le RUN +# s'arrete ici si ce python ne sait pas lire la syntaxe de script/. RUN cd $ODOO_PREFIX && \ ls -lha /ERPLibre && \ + { python -m compileall -q script/ || \ + { echo "Le python de l'image ($(python -V 2>&1)) ne lit pas script/ : prendre une version Odoo plus recente." ; exit 1 ; } ; } && \ python -m venv .venv.erplibre && \ source ./.venv.erplibre/bin/activate && \ pip3 install -r requirement/erplibre_require-ments.txt diff --git a/docker/README.base.md b/docker/README.base.md index 3df3924..cab75f0 100644 --- a/docker/README.base.md +++ b/docker/README.base.md @@ -70,11 +70,21 @@ docker build -f Dockerfile.prod.pkg -t technolibre/erplibre:12.0-pkg . ``` + +`.venv.erplibre` is built on the image's own Python, Odoo's, without mise or +pyenv. The build stops when that Python cannot parse `script/`, which happens +with a deprecated Odoo version. + ### Running ERPLibre using Docker-Compose Go at the root of this git project. + +`.venv.erplibre` est bâti sur le Python de l'image, celui d'Odoo, sans mise ni +pyenv. La construction s'arrête quand ce Python ne sait pas lire `script/`, ce +qui arrive avec une version d'Odoo dépréciée. + ### Exécuter ERPLibre avec Docker-Compose Allez à la racine de ce projet git. diff --git a/docker/README.fr.md b/docker/README.fr.md index f2f3e47..23c7597 100644 --- a/docker/README.fr.md +++ b/docker/README.fr.md @@ -34,6 +34,11 @@ docker build -f Dockerfile.base -t technolibre/erplibre-base:12.0 . docker build -f Dockerfile.prod.pkg -t technolibre/erplibre:12.0-pkg . ``` + +`.venv.erplibre` est bâti sur le Python de l'image, celui d'Odoo, sans mise ni +pyenv. La construction s'arrête quand ce Python ne sait pas lire `script/`, ce +qui arrive avec une version d'Odoo dépréciée. + ### Exécuter ERPLibre avec Docker-Compose Allez à la racine de ce projet git. diff --git a/docker/README.md b/docker/README.md index 5535ec7..e411725 100644 --- a/docker/README.md +++ b/docker/README.md @@ -34,6 +34,11 @@ docker build -f Dockerfile.base -t technolibre/erplibre-base:12.0 . docker build -f Dockerfile.prod.pkg -t technolibre/erplibre:12.0-pkg . ``` + +`.venv.erplibre` is built on the image's own Python, Odoo's, without mise or +pyenv. The build stops when that Python cannot parse `script/`, which happens +with a deprecated Odoo version. + ### Running ERPLibre using Docker-Compose Go at the root of this git project. diff --git a/script/install/install_erplibre.sh b/script/install/install_erplibre.sh index d74178a..2025ee1 100755 --- a/script/install/install_erplibre.sh +++ b/script/install/install_erplibre.sh @@ -1,6 +1,58 @@ #!/usr/bin/env bash -# TODO deprecated, this is moved into install_locally.sh to be sure venv is installed and force take specified supported version -python3 -m venv .venv.erplibre -source .venv.erplibre/bin/activate -pip3 install -r requirement/erplibre_require-ments.txt -npm install +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +# +# Pose le venv d'OUTILLAGE d'ERPLibre, sans qu'une version d'Odoo soit +# choisie : chemin et version se lisent dans conf/, versionné, et non par +# env_var.sh, qui exige .python-odoo-version. Se lance depuis la racine du +# dépôt. + +CONF_VENV="conf/python-erplibre-venv" +CONF_PYTHON_VERSION="conf/python-erplibre-version" + +for conf_file in "${CONF_VENV}" "${CONF_PYTHON_VERSION}"; do + if [[ ! -f "${conf_file}" ]]; then + echo "Configuration introuvable : ${conf_file}" + echo " Ce script se lance depuis la racine du depot :" + echo " cd && ./script/install/install_erplibre.sh" + exit 1 + fi +done + +# « xargs » retire espaces et retour de ligne autour de la valeur. +VENV_ERPLIBRE_PATH=$(xargs < "${CONF_VENV}") +PYTHON_ERPLIBRE_VERSION=$(xargs < "${CONF_PYTHON_VERSION}") + +echo -e "Install ${VENV_ERPLIBRE_PATH} with ${PYTHON_ERPLIBRE_VERSION}" +if ! ./script/install/install_venv.sh \ + "ERPLibre" "${VENV_ERPLIBRE_PATH}" "${PYTHON_ERPLIBRE_VERSION}"; then + echo "Echec de creation de ${VENV_ERPLIBRE_PATH}, arret." + exit 1 +fi + +# Un venv rebâti n'a plus bin/repo ; install_git_repo.sh ne le pose que s'il +# manque. TODO s'en passe : seules les synchronisations de dépôts l'exigent, +# donc un échec (hors ligne) est signalé sans arrêter l'installation. +if ! ./script/install/install_git_repo.sh; then + echo "git-repo n'est pas pose dans ${VENV_ERPLIBRE_PATH} : 'repo sync' en a besoin." + echo " Rejouez quand le reseau le permet : ./script/install/install_git_repo.sh" +fi + +# el_pip_install vise le venv par son chemin : aucune activation n'est requise. +# shellcheck source=script/install/lib_pip_provider.sh +. ./script/install/lib_pip_provider.sh +if ! el_pip_install "${VENV_ERPLIBRE_PATH}" \ + -r requirement/erplibre_require-ments.txt; then + echo "Echec d'installation des dependances Python dans ${VENV_ERPLIBRE_PATH}." + echo " Relancez : ./script/install/install_erplibre.sh" + exit 1 +fi + +# Prettier ne sert qu'au formatage XML (« make format ») : son échec est +# signalé sans faire échouer une installation Python complète. +if ! npm install; then + echo "npm install a echoue : prettier et son greffon XML ne sont pas poses." + echo " ${VENV_ERPLIBRE_PATH} est utilisable ; seul 'make format' les reclame." + echo " Rejouez quand le reseau le permet : npm install" +fi +exit 0 diff --git a/script/install/install_git_repo.sh b/script/install/install_git_repo.sh index 54c7214..41304a6 100755 --- a/script/install/install_git_repo.sh +++ b/script/install/install_git_repo.sh @@ -1,14 +1,44 @@ #!/usr/bin/env bash -VENV_ERPLIBRE_PATH=$(cat "conf/python-erplibre-venv" | xargs) +# Se lance depuis la racine du dépôt : ailleurs, le chemin de venv serait vide +# et la cible deviendrait « /bin/repo ». +CONF_VENV="conf/python-erplibre-venv" +if [[ ! -f "${CONF_VENV}" ]]; then + echo "Configuration introuvable : ${CONF_VENV}" + echo " cd && ./script/install/install_git_repo.sh" + exit 1 +fi +VENV_ERPLIBRE_PATH=$(xargs < "${CONF_VENV}") VENV_REPO_PATH=${VENV_ERPLIBRE_PATH}/bin/repo # Install git-repo if missing if [[ ! -f ${VENV_REPO_PATH} ]]; then - echo "\n---- Install git-repo from Google APIS ----" - curl https://storage.googleapis.com/git-repo-downloads/repo > ${VENV_REPO_PATH} - chmod +x ${VENV_REPO_PATH} - sed -i 1d ${VENV_REPO_PATH} + if [[ ! -d "${VENV_ERPLIBRE_PATH}/bin" ]]; then + echo "Venv ${VENV_ERPLIBRE_PATH} absent : posez-le avant git-repo." + echo " ./script/install/install_erplibre.sh" + exit 1 + fi + echo -e "\n---- Install git-repo from Google APIS ----" + # Téléchargé à côté, contrôlé, puis déplacé : sans « -f », curl écrit la + # page d'une erreur HTTP et rend 0 ; un corps vide passe le statut. + REPO_TMP="$(mktemp)" || exit 1 + if ! curl -fsSL -o "${REPO_TMP}" \ + https://storage.googleapis.com/git-repo-downloads/repo \ + || [[ ! -s "${REPO_TMP}" ]]; then + echo "Telechargement de git-repo impossible ou vide (reseau ou cache) :" + echo " ${VENV_REPO_PATH} n'est pas pose." + rm -f "${REPO_TMP}" + exit 1 + fi + # Le hashbang du venv remplace celui du python3 du système. + sed -i 1d "${REPO_TMP}" PYTHON_HASHBANG="#!./${VENV_ERPLIBRE_PATH}/bin/python" - sed -i "1 i ${PYTHON_HASHBANG}" ${VENV_REPO_PATH} + sed -i "1 i ${PYTHON_HASHBANG}" "${REPO_TMP}" + if ! mv "${REPO_TMP}" "${VENV_REPO_PATH}"; then + echo "Mise en place de ${VENV_REPO_PATH} impossible." + rm -f "${REPO_TMP}" + exit 1 + fi + # mktemp crée en 0600. + chmod 755 "${VENV_REPO_PATH}" fi diff --git a/script/install/install_nixos_dependency.sh b/script/install/install_nixos_dependency.sh index 36a3b1c..af37ff4 100755 --- a/script/install/install_nixos_dependency.sh +++ b/script/install/install_nixos_dependency.sh @@ -37,6 +37,21 @@ EL_CA_BUNDLE=/var/lib/erplibre/ca-bundle.crt [ -r "${EL_CA_BUNDLE}" ] || EL_CA_BUNDLE="" EL_LOCALE=${EL_LOCALE:-$(lire_seed locale)} EL_TZ=${EL_TZ:-$(lire_seed timezone)} +# « 3.12.10 » donne « python312 », le nom du paquet nixpkgs. Les deux versions +# du dépôt sont déclarées : celle d'Odoo et celle de l'outillage. Le second est +# VIDE quand elles coïncident, un même paquet nommé deux fois entrant en +# collision dans le profil. +el_nix_python_pkg() { + local v + v="$(xargs < "$1" 2> /dev/null)" + [ -n "${v}" ] || return 0 + echo "python$(echo "${v}" | cut -d. -f1)$(echo "${v}" | cut -d. -f2)" +} +EL_PY_ODOO_PKG="$(el_nix_python_pkg .python-odoo-version)" +EL_PY_ODOO_PKG="${EL_PY_ODOO_PKG:-python312}" +EL_PY_TOOLS_PKG="$(el_nix_python_pkg conf/python-erplibre-version)" +[ "${EL_PY_TOOLS_PKG}" = "${EL_PY_ODOO_PKG}" ] && EL_PY_TOOLS_PKG="" + MODULE_SRC="conf/nixos/erplibre.nix" MODULE_DST="/etc/nixos/erplibre.nix" CONFIG="/etc/nixos/configuration.nix" @@ -57,9 +72,12 @@ echo -e "\n---- Module ERPLibre pour NixOS ----" sed -e "s/@EL_USER@/${EL_USER}/g" -e "s#@EL_DIR@#${EL_DIR}#g" \ -e "s/@EL_LOCALE@/${EL_LOCALE}/g" -e "s#@EL_TZ@#${EL_TZ}#g" \ -e "s#@EL_CA_BUNDLE@#${EL_CA_BUNDLE}#g" \ + -e "s/@EL_PY_ODOO_PKG@/${EL_PY_ODOO_PKG}/g" \ + -e "s/@EL_PY_TOOLS_PKG@/${EL_PY_TOOLS_PKG}/g" \ "${MODULE_SRC}" | sudo tee "${MODULE_DST}" > /dev/null echo " posé : ${MODULE_DST} (compte ${EL_USER}, dépôt ${EL_DIR})" echo " autorité du cache : ${EL_CA_BUNDLE:-aucune}" +echo " Python : ${EL_PY_ODOO_PKG} (Odoo)${EL_PY_TOOLS_PKG:+, ${EL_PY_TOOLS_PKG} (outillage)}" echo " régional : locale « ${EL_LOCALE:-non demandée} »," \ "fuseau « ${EL_TZ:-non demandé} »" diff --git a/script/install/install_venv.sh b/script/install/install_venv.sh index 4f4dc8f..3785d11 100755 --- a/script/install/install_venv.sh +++ b/script/install/install_venv.sh @@ -9,8 +9,13 @@ fi # Assign arguments to variables CONTEXT="$1" -VENV_PATH="$2" +# Sans « / » final : « rm -rf lien/ » viderait la cible d'un lien. +VENV_PATH="${2%"${2##*[!/]}"}" PYTHON_VERSION="$3" +if [ -z "${VENV_PATH}" ]; then + echo "Error: Venv_Path '$2' designates the root." + exit 1 +fi # Display variables (for verification) echo "Context: $CONTEXT" @@ -18,20 +23,140 @@ echo "Venv Path: $VENV_PATH" echo "Python Version: $PYTHON_VERSION" # Le CHOIX du fournisseur (mise ou pyenv) vit dans la bibliothèque : ce script -# ne connaît qu'un chemin d'interpréteur. C'est ce qui permet d'en ajouter un -# troisième sans toucher ici. +# ne connaît qu'un chemin d'interpréteur. # shellcheck source=script/install/lib_python_provider.sh . ./script/install/lib_python_provider.sh -PYTHON_EXEC="$(el_python_exec "${PYTHON_VERSION}")" +# Ce qui borne le PATCH, et pour qui. +# +# Le venv d'Odoo est contraint par son pyproject (« >=3.12.10,<3.13 ») : un +# patch inférieur y serait refusé par Poetry. Rien n'épingle celui de +# l'outillage — exiger le patch y écarte le Python des distributions dès +# qu'il est d'un cran en retard, et force pyenv à compiler CPython pour rien. +EXIGENCE="patch" +[ "${CONTEXT}" = "ERPLibre" ] && EXIGENCE="mineure" + +# Version RÉELLE d'un venv : celle que rend son interpréteur, jamais celle +# qu'annonce pyvenv.cfg, figée à la création et que rien ne revalide. Rien +# n'est rendu quand bin/python ne s'exécute plus — un venv bâti sur un CPython +# depuis retiré garde un lien mort, et c'est justement un venv à rebâtir. +el_venv_python_version() { + local py="$1/bin/python" + [ -x "${py}" ] || return 1 + "${py}" -c 'import platform;print(platform.python_version())' 2> /dev/null +} + +# Le venv est-il HORS SERVICE — non pas d'une autre version, mais inutilisable +# tel quel ? Ce qui suit ne se répare pas : on rebâtit, quel que soit l'appelant. +el_venv_hors_service() { + local py="$1/bin/python" + if [[ ! -f "$1/pyvenv.cfg" ]]; then + echo "aucun pyvenv.cfg" + elif [[ -z "$(el_venv_python_version "$1")" ]]; then + echo "son interpreteur ne s'execute plus" + elif [[ ! -f "$1/bin/activate" ]] || ! "${py}" -m pip --version > /dev/null 2>&1; then + # Ce que laisse un « python -m venv » sans ensurepip. + echo "bin/activate ou pip manquant" + fi +} + +# Le venv tourne, mais pas sur la version demandée. La version est celle que +# rend bin/python, pas celle de pyvenv.cfg, figée à la création. Compatible et +# non identique : un python de distribution d'un patch plus récent convient, et +# l'égalité stricte rebâtirait à chaque appel. +el_venv_version_autre() { + if ! el_python_is_compatible \ + "$1/bin/python" "${PYTHON_VERSION}" "${EXIGENCE}"; then + echo "bin/python en $(el_venv_python_version "$1"), ${PYTHON_VERSION} exige" + fi +} + +# Ce qui oblige à rebâtir, ou rien. +# +# Une version qui diffère ne vaut destruction que pour le venv d'OUTILLAGE : +# le rebâtir coûte une installation pip, quand celui d'Odoo représente une +# installation Poetry entière — des centaines de paquets, dont certains se +# compilent. Un écart de patch ne l'empêche pas de servir ; on le dit, et on le +# laisse. Hors service, en revanche, il ne sert plus personne. +el_venv_a_rebatir() { + local cause + cause="$(el_venv_hors_service "$1")" + if [[ -n "${cause}" ]]; then + echo "${cause}" + return + fi + cause="$(el_venv_version_autre "$1")" + if [[ -n "${cause}" ]] && [[ "${CONTEXT}" == "ERPLibre" ]]; then + echo "${cause}" + fi +} + +# Un checkout MONTÉ depuis une autre machine porte le venv de cette machine. +# Son interpréteur ne démarre pas ici — un CPython précompilé cherche sa +# bibliothèque standard sous le préfixe de sa construction —, ce qui le fait +# passer pour défectueux. Le détruire effacerait, à travers le réseau, +# l'installation de la machine à qui il appartient. +# findmnt vient d'util-linux : absent ailleurs, le contrôle s'abstient plutôt +# que de refuser à tort. +el_venv_est_distant() { + local fs + command -v findmnt > /dev/null 2>&1 || return 1 + fs="$(findmnt -n -o FSTYPE --target "$1" 2> /dev/null)" + case "${fs}" in + fuse* | sshfs | nfs* | cifs | smb*) return 0 ;; + *) return 1 ;; + esac +} + +PYTHON_EXEC="$(el_python_exec "${PYTHON_VERSION}" "${EXIGENCE}")" if [[ -z "${PYTHON_EXEC}" ]] || [[ ! -x "${PYTHON_EXEC}" ]]; then echo "Aucun interpreteur Python ${PYTHON_VERSION} n'a pu etre obtenu." echo " Fournisseur demande : ${EL_PYTHON_PROVIDER:-auto}" - echo " Voir 'make install_mise', ou installez pyenv." + if command -v mise > /dev/null 2>&1; then + echo " Voir : mise install python@${PYTHON_VERSION} (reseau requis)" + else + echo " Voir 'make install_mise', ou installez pyenv." + fi exit 1 fi echo "Interpreteur retenu : ${PYTHON_EXEC}" +# L'interpréteur est obtenu AVANT toute destruction : un provisionnement en +# échec laisse le venv en place. +if [[ -d ${VENV_PATH} ]]; then + REASON="$(el_venv_a_rebatir "${VENV_PATH}")" + CONSERVE="$(el_venv_version_autre "${VENV_PATH}")" + if [[ -z "${REASON}" ]] && [[ -n "${CONSERVE}" ]]; then + echo "Virtual environment ${VENV_PATH} conserve : ${CONSERVE}." + echo " Le rebatir refait toute son installation. Si vous le voulez :" + echo " rm -rf ${VENV_PATH} puis relancez." + fi + # rmdir n'écarte qu'un répertoire vide, ce que laisse une création + # interrompue ; seul un pyvenv.cfg autorise le « rm -rf ». + if [[ -n "${REASON}" ]] && ! rmdir "${VENV_PATH}" 2> /dev/null; then + if [[ ! -f "${VENV_PATH}/pyvenv.cfg" ]]; then + echo "Refus de detruire ${VENV_PATH} : ce n'est pas un venv (${REASON})." + echo " Ecartez-le vous-meme, puis relancez." + exit 1 + fi + if el_venv_est_distant "${VENV_PATH}"; then + echo "Refus de detruire ${VENV_PATH} : il est sur un systeme de" + echo " fichiers monte a distance, donc il appartient a une autre" + echo " machine (${REASON} vu d'ici)." + echo " Installez SUR cette machine-la, pas au travers du montage." + exit 1 + fi + echo "DESTRUCTION de ${VENV_PATH} : ${REASON}." + echo " Ce qui y a ete pose a la main part avec lui ; il est rebati." + if ! rm -rf "${VENV_PATH}"; then + echo "Destruction de ${VENV_PATH} en echec, probablement faute de droits :" + echo " le venv est peut-etre partiellement detruit. Rien n'est rebati." + echo " Corrigez les droits, puis : rm -rf ${VENV_PATH} et relancez." + exit 1 + fi + fi +fi + if [[ ! -d ${VENV_PATH} ]]; then echo -e "\n---- Create Virtual environment Python ----" if ! "${PYTHON_EXEC}" -m venv "${VENV_PATH}"; then @@ -39,3 +164,13 @@ if [[ ! -d ${VENV_PATH} ]]; then exit 1 fi fi + +# Contrôle final : l'appelant enchaîne sur ce venv, un 0 doit le garantir. +# Hors service seulement : un venv d'une autre version qu'on vient de conserver +# sciemment reste utilisable. +REASON="$(el_venv_hors_service "${VENV_PATH}")" +if [[ ! -d ${VENV_PATH} ]] || [[ -n "${REASON}" ]]; then + echo "Virtual environment ${VENV_PATH} inutilisable : ${REASON:-absent}." + exit 1 +fi +echo "Virtual environment ${VENV_PATH} pret." diff --git a/script/install/lib_python_provider.sh b/script/install/lib_python_provider.sh index 7865e7a..fea7a58 100755 --- a/script/install/lib_python_provider.sh +++ b/script/install/lib_python_provider.sh @@ -55,22 +55,29 @@ el_python_is_version() { [ "${got}" = "${want}" ] } -# Vrai si l'exécutable CONVIENT : même majeure.mineure, et patch au moins -# égal au demandé. C'est exactement ce qu'exige le pyproject — « >=3.12.10, -# <3.13 » — et non l'égalité stricte que testait el_python_is_version. +# Vrai si l'exécutable CONVIENT. Deux exigences, selon ce qui borne l'appelant. # -# La distinction n'est pas théorique. Tumbleweed s390x livre python312 en -# 3.12.13 : parfaitement utilisable, mais rejeté par l'égalité, ce qui forçait -# pyenv à COMPILER CPython — et gcc 15.2 s'y arrête sur une erreur interne -# dans Parser/parser.c, un fichier généré de quarante mille lignes. +# patch même majeure.mineure, et patch au moins égal. C'est ce que le +# pyproject d'Odoo demande — « >=3.12.10,<3.13 » —, et Poetry +# refuserait un patch inférieur. +# mineure même majeure.mineure, quel que soit le patch. RIEN n'épingle le +# patch du venv d'OUTILLAGE : l'exiger écarte le Python des +# distributions dès qu'il est d'un patch en retard — NixOS 25.11 +# livre 3.14.2 quand conf/ demande 3.14.7 — et force pyenv à +# COMPILER CPython pour une différence qui ne gêne personne. +# +# L'égalité stricte, elle, rejetterait aussi un patch plus RÉCENT : c'est +# el_python_is_version, et elle ne sert qu'aux fournisseurs qui posent une +# version nommée. el_python_is_compatible() { - local exe="$1" want="$2" got + local exe="$1" want="$2" exigence="${3:-patch}" got [ -x "${exe}" ] || return 1 got="$("${exe}" -c 'import platform;print(platform.python_version())' \ 2> /dev/null)" [ -n "${got}" ] || return 1 # Même majeure.mineure : 3.13 ne convient pas à un pyproject borné à <3.13. [ "${got%.*}" = "${want%.*}" ] || return 1 + [ "${exigence}" = "mineure" ] && return 0 # Patch au moins égal, comparé en version et non en chaîne (3.12.9 < 3.12.10). [ "$(printf '%s\n%s\n' "${want}" "${got}" | sort -V | head -1)" = "${want}" ] } @@ -81,7 +88,7 @@ el_python_is_compatible() { # prend des dizaines de minutes quand il aboutit. Le nom suit la convention de # toutes les distributions, « python3.12 ». el_distro_python_exec() { - local want="$1" exe + local want="$1" exigence="${2:-patch}" exe # Des chemins SYSTEME, jamais « command -v » : dans un venv activé celui-ci # rend le python DU VENV, et l'on bâtirait un venv depuis un venv. Le PATH # d'une session interactive n'a rien à faire dans cette décision. @@ -94,7 +101,7 @@ el_distro_python_exec() { # pas, et la boucle passe a la suite sans rien couter. for exe in "/run/current-system/sw/bin/python${want%.*}" \ "/usr/bin/python${want%.*}" "/usr/local/bin/python${want%.*}"; do - if el_python_is_compatible "${exe}" "${want}"; then + if el_python_is_compatible "${exe}" "${want}" "${exigence}"; then echo "${exe}" return 0 fi @@ -175,10 +182,31 @@ el_pyenv_install() { echo "${exe}" } +# Annonce la compilation pyenv AVANT de la subir, et le geste qui l'évite : +# poser mise, ou lire ce que mise reproche quand il est déjà là. Muet si pyenv +# porte déjà la version, puisque rien ne sera compilé. +el_warn_pyenv_fallback() { + local version="$1" + [ -d "$(el_pyenv_root)/versions/${version}" ] && return 0 + if command -v mise > /dev/null 2>&1; then + echo "mise n'a pas pu fournir Python ${version} : repli sur pyenv," >&2 + echo " qui COMPILE CPython. Pour lire ce que mise reproche :" >&2 + echo " mise install python@${version} (reseau requis)" >&2 + else + echo "mise est absent : pyenv va etre pose, puis COMPILER CPython" >&2 + echo " ${version} -- quelques minutes, bien plus sous emulation, et il" >&2 + echo " lui faut une douzaine de -dev (openssl, zlib, readline, sqlite," >&2 + echo " bzip2, xz, tk). Pour l'eviter : Ctrl+C, puis" >&2 + echo " make install_mise" >&2 + echo " qui pose un CPython precompile en quelques secondes, et relancez." >&2 + fi +} + # API publique : imprime le chemin absolu d'un interpréteur de cette version, # ou rien (et rend non nul) si aucun fournisseur n'y parvient. el_python_exec() { - local version="$1" provider="${EL_PYTHON_PROVIDER:-auto}" exe + local version="$1" exigence="${2:-patch}" + local provider="${EL_PYTHON_PROVIDER:-auto}" exe # 1) Déjà présent ? On ne réinstalle rien et on ne touche pas au réseau. # C'est ce qui rend le changement sans effet pour une installation @@ -205,7 +233,7 @@ el_python_exec() { # Uniquement en mode « auto » : demander mise ou pyenv explicitement doit # être respecté, sinon le réglage ne veut plus rien dire. if [ "${provider}" = "auto" ] \ - && exe="$(el_distro_python_exec "${version}")"; then + && exe="$(el_distro_python_exec "${version}" "${exigence}")"; then echo "Python $("${exe}" -V 2>&1 | awk '{print $2}') de la distribution :" \ "aucune compilation." >&2 echo "${exe}" @@ -220,8 +248,7 @@ el_python_exec() { return 0 fi [ "${provider}" = "mise" ] && return 1 - command -v mise > /dev/null 2>&1 \ - && echo "mise n'a pas pu fournir Python ${version} : repli sur pyenv." >&2 + el_warn_pyenv_fallback "${version}" fi el_pyenv_install "${version}" } diff --git a/script/todo/source_todo.sh b/script/todo/source_todo.sh index 4a31863..f922e2c 100755 --- a/script/todo/source_todo.sh +++ b/script/todo/source_todo.sh @@ -1,36 +1,13 @@ -#!/bin/bash -DIR_VENV_ERPLIBRE_EXIST=1 -DIR_VENV_ERPLIBRE=".venv.erplibre" -# If not exist, create it and do installation -# Can be in conflict with ./script/install_locally.sh +#!/usr/bin/env bash +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +# +# Auto-installation de TODO : install_erplibre.sh pose ou rebâtit le venv +# d'outillage, puis TODO repart sur l'interpréteur de ce venv. +# Se lance depuis la racine du dépôt. -if [[ ! -d "$DIR_VENV_ERPLIBRE" ]]; then - DIR_VENV_ERPLIBRE_EXIST=0 - echo "$DIR_VENV_ERPLIBRE does not exist." - # Le test d'origine etait inoperant : le tilde entre guillemets n'est pas - # etendu, et « -d » testait un FICHIER comme un repertoire. La condition - # etait donc toujours vraie, la commande echouait en « No such file or - # directory », et le repli « python -m venv » ne servait jamais. - # shellcheck source=script/install/lib_python_provider.sh - . ./script/install/lib_python_provider.sh - EL_PY_WANT="$(< ./conf/python-erplibre-version)" - # Ici on ne PROVISIONNE pas : ce script s'execute au demarrage de TODO, il - # doit rester rapide et hors reseau. On prend ce qui est deja pose, sinon - # le python du systeme. - EL_PY_EXEC="$(el_pyenv_exec_path "${EL_PY_WANT}")" - el_python_is_version "${EL_PY_EXEC}" "${EL_PY_WANT}" \ - || EL_PY_EXEC="$(el_mise_exec_path "${EL_PY_WANT}" 2> /dev/null)" - if [[ -n "${EL_PY_EXEC}" ]] && [[ -x "${EL_PY_EXEC}" ]]; then - "${EL_PY_EXEC}" -m venv $DIR_VENV_ERPLIBRE - else - python3 -m venv $DIR_VENV_ERPLIBRE - fi +if ! ./script/install/install_erplibre.sh; then + echo "Installation d'ERPLibre en echec : TODO ne demarre pas." + exit 1 fi - -# If exist, source it and start installation -source ./.venv.erplibre/bin/activate -if [[ $DIR_VENV_ERPLIBRE_EXIST -eq 0 ]]; then - pip install -r requirement/erplibre_require-ments.txt -fi - -./script/todo/todo.py +exec "./$(xargs < conf/python-erplibre-venv)/bin/python" ./script/todo/todo.py "$@" diff --git a/test/test_install_nixos.py b/test/test_install_nixos.py index a74f4bd..5094733 100644 --- a/test/test_install_nixos.py +++ b/test/test_install_nixos.py @@ -153,10 +153,31 @@ class LeModule(unittest.TestCase): with self.subTest(lib=lib): self.assertIn(lib, self.src) - def test_the_python_matches_what_the_repository_wants(self): - voulu = (RACINE / ".python-odoo-version").read_text().strip() - majeur, mineur = voulu.split(".")[:2] - self.assertIn(f"python{majeur}{mineur}", self.src) + def test_no_python_version_is_written_by_hand(self): + """Une version écrite ici dériverait des fichiers du dépôt au premier + changement — et déclarer le seul Python d'Odoo laissait pyenv bâtir + celui de l'outillage, dont la compilation s'arrête sur + « Modules/_cursesmodule.o ».""" + self.assertNotRegex(self.src, r"python3\d\d") + self.assertIn("@EL_PY_ODOO_PKG@", self.src) + self.assertIn("@EL_PY_TOOLS_PKG@", self.src) + + def test_both_pythons_of_the_repository_are_declared(self): + """Le module les reçoit du script, qui les lit dans les deux fichiers + de version.""" + script = SCRIPT.read_text(encoding="utf-8") + self.assertIn(".python-odoo-version", script) + self.assertIn("conf/python-erplibre-version", script) + + def test_the_second_package_is_empty_when_both_agree(self): + """Nommer deux fois le même paquet ferait entrer en collision deux + chemins identiques dans le profil.""" + script = SCRIPT.read_text(encoding="utf-8") + self.assertRegex( + script, + r'EL_PY_TOOLS_PKG\}"?\s*=\s*"?\$\{EL_PY_ODOO_PKG' + r'|\[ "\$\{EL_PY_TOOLS_PKG\}" = "\$\{EL_PY_ODOO_PKG\}" \]', + ) def test_the_database_role_is_substituted(self): """Le nom du compte varie d'un déploiement à l'autre ; l'écrire en dur @@ -335,7 +356,7 @@ class LeServiceEstDeclare(unittest.TestCase): """Une unité ne reçoit pas le PATH d'une session. run.sh lance des scripts dont le shebang est « env bash » : env est dans le PATH par défaut, bash non, et run.sh s'arrête avant Odoo.""" - self.assertIn("path = with pkgs; [ bash python312 ];", self.src) + self.assertIn("path = with pkgs; [ bash @EL_PY_ODOO_PKG@ ];", self.src) def test_the_repository_is_not_guessed(self): """Le service lance le dépôt QUI A POSÉ le module. Écrire diff --git a/test/test_install_venv_rebuild.py b/test/test_install_venv_rebuild.py new file mode 100644 index 0000000..58de48a --- /dev/null +++ b/test/test_install_venv_rebuild.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""install_venv.sh détruit un venv : sous quelles conditions, exactement ? + +C'est le seul endroit du dépôt qui efface le travail de quelqu'un. Un venv +porte ce qu'on y a posé à la main — bin/repo, uv, des paquets — et le nom +.venv.erplibre ne dit pas sa version : rien ne distingue de l'extérieur celui +qu'il faut rebâtir de celui qu'il faut garder. + +Ces tests pèsent donc les REFUS autant que les destructions : + +- un répertoire sans pyvenv.cfg n'est pas un venv et garde son contenu ; +- un venv sur un système de fichiers monté appartient à une autre machine, et + l'effacer le ferait à travers le réseau ; +- un venv utilisable est conservé, sans quoi chaque installation rebâtirait + ce qui marche. + +L'interpréteur est un bouchon annoncé par PYENV_ROOT : les tests restent hors +réseau, et rien ne dépend de ce que cette machine a installé. +""" + +import os +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + +RACINE = Path(__file__).resolve().parents[1] +SCRIPT = RACINE / "script/install/install_venv.sh" + +VOULUE = "9.99.0" +AUTRE = "9.98.0" + +# Un python qui suffit à ce que le script lui demande : sa version, la création +# d'un venv, et la présence de pip. +BOUCHON = """#!/bin/sh +VERSION=__VERSION__ +case "$1" in + -V|--version) echo "Python ${VERSION}" ;; + -c) echo "${VERSION}" ;; + -m) + case "$2" in + venv) + mkdir -p "$3/bin" || exit 1 + printf 'version = %s\\n' "${VERSION}" > "$3/pyvenv.cfg" + sed "s/^VERSION=.*/VERSION=${VERSION}/" "$0" > "$3/bin/python" + chmod +x "$3/bin/python" + : > "$3/bin/activate" + ;; + pip) echo "pip 99.0 from nowhere" ;; + *) exit 1 ;; + esac + ;; + *) exit 1 ;; +esac +exit 0 +""" + + +def pose_bouchon(chemin, version): + chemin.parent.mkdir(parents=True, exist_ok=True) + chemin.write_text( + BOUCHON.replace("__VERSION__", version), encoding="utf-8" + ) + chemin.chmod(0o755) + + +class BancInstallVenv(unittest.TestCase): + """Un PYENV_ROOT bouchonné, et un répertoire de travail jetable.""" + + def setUp(self): + self.coin = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, self.coin, ignore_errors=True) + pose_bouchon( + self.coin / "pyenv" / "versions" / VOULUE / "bin" / "python", + VOULUE, + ) + self.bin = self.coin / "bin" + self.bin.mkdir() + + def env(self): + return dict( + os.environ, + PYENV_ROOT=str(self.coin / "pyenv"), + PATH=f"{self.bin}:{os.environ['PATH']}", + EL_PYTHON_PROVIDER="pyenv", + ) + + def lance(self, chemin_venv, contexte="ERPLibre"): + return subprocess.run( + [str(SCRIPT), contexte, str(chemin_venv), VOULUE], + cwd=RACINE, + capture_output=True, + text=True, + env=self.env(), + ) + + def venv_factice(self, nom, version): + """Un venv complet, comme « python -m venv » le laisse.""" + chemin = self.coin / nom + (chemin / "bin").mkdir(parents=True) + (chemin / "pyvenv.cfg").write_text(f"version = {version}\n") + pose_bouchon(chemin / "bin" / "python", version) + (chemin / "bin" / "activate").touch() + (chemin / "TEMOIN").write_text("pose a la main\n") + return chemin + + +class TestCeQuIlConserve(BancInstallVenv): + def test_un_venv_a_la_bonne_version_est_garde(self): + venv = self.venv_factice("bon", VOULUE) + fin = self.lance(venv) + self.assertEqual(0, fin.returncode, fin.stdout + fin.stderr) + self.assertTrue( + (venv / "TEMOIN").exists(), "il a été rebâti pour rien" + ) + + def test_un_chemin_neuf_est_simplement_cree(self): + venv = self.coin / "neuf" + fin = self.lance(venv) + self.assertEqual(0, fin.returncode, fin.stdout + fin.stderr) + self.assertTrue((venv / "pyvenv.cfg").exists()) + + +class TestCeQuIlDetruit(BancInstallVenv): + def test_un_venv_d_une_autre_version_est_rebati(self): + venv = self.venv_factice("perime", AUTRE) + fin = self.lance(venv) + self.assertEqual(0, fin.returncode, fin.stdout + fin.stderr) + self.assertIn("DESTRUCTION", fin.stdout) + self.assertFalse((venv / "TEMOIN").exists()) + self.assertIn(VOULUE, (venv / "pyvenv.cfg").read_text()) + + def test_la_destruction_est_annoncee_avant_d_avoir_lieu(self): + """Dans un journal de milliers de lignes, l'avis doit précéder.""" + venv = self.venv_factice("perime", AUTRE) + fin = self.lance(venv) + self.assertLess( + fin.stdout.index("DESTRUCTION"), + fin.stdout.index("Create Virtual environment"), + ) + + def test_un_venv_sans_pip_est_rebati(self): + """Ce que laisse « python -m venv » quand ensurepip manque.""" + venv = self.venv_factice("sans_pip", VOULUE) + (venv / "bin" / "activate").unlink() + fin = self.lance(venv) + self.assertEqual(0, fin.returncode, fin.stdout + fin.stderr) + self.assertFalse((venv / "TEMOIN").exists()) + + +class TestLExigenceDePatch(BancInstallVenv): + """Le patch ne borne que le venv d'Odoo, contraint par son pyproject. + + Sur le venv d'outillage, l'exiger écarterait le Python des distributions + dès qu'il est d'un cran en retard — et ferait compiler CPython pour une + différence que rien ne réclame.""" + + PATCH_PLUS_BAS = "9.99.0" + DEMANDE = "9.99.4" + + def setUp(self): + super().setUp() + pose_bouchon( + self.coin / "pyenv" / "versions" / self.DEMANDE / "bin" / "python", + self.DEMANDE, + ) + + def joue(self, contexte, venv): + return subprocess.run( + [str(SCRIPT), contexte, str(venv), self.DEMANDE], + cwd=RACINE, + capture_output=True, + text=True, + env=self.env(), + ) + + def test_l_outillage_garde_un_patch_plus_ancien(self): + venv = self.venv_factice("outils", self.PATCH_PLUS_BAS) + fin = self.joue("ERPLibre", venv) + self.assertEqual(0, fin.returncode, fin.stdout + fin.stderr) + self.assertTrue((venv / "TEMOIN").exists(), fin.stdout) + + def test_odoo_refuse_un_patch_plus_ancien(self): + """Poetry refuserait un patch inférieur à ce que borne le pyproject.""" + venv = self.venv_factice("odoo", self.PATCH_PLUS_BAS) + fin = self.joue("Odoo", venv) + self.assertIn("conserve", fin.stdout + fin.stderr) + + def test_une_autre_mineure_ne_passe_jamais(self): + venv = self.venv_factice("mineure", "9.98.0") + fin = self.joue("ERPLibre", venv) + self.assertIn("DESTRUCTION", fin.stdout) + + +class TestCeQueLeContexteDecide(BancInstallVenv): + """Rebâtir le venv d'outillage coûte une installation pip ; rebâtir celui + d'Odoo en refait une de Poetry entière. L'écart de version ne vaut donc + destruction que pour le premier.""" + + def test_le_venv_odoo_d_une_autre_version_est_conserve(self): + venv = self.venv_factice("odoo", AUTRE) + fin = self.lance(venv, contexte="Odoo") + self.assertEqual(0, fin.returncode, fin.stdout + fin.stderr) + self.assertIn("conserve", fin.stdout) + self.assertTrue((venv / "TEMOIN").exists()) + + def test_le_venv_odoo_hors_service_est_rebati_quand_meme(self): + """Une autre version se tolère ; un interpréteur mort, non.""" + venv = self.venv_factice("odoo_casse", VOULUE) + (venv / "bin" / "python").write_text("#!/bin/sh\nexit 1\n") + fin = self.lance(venv, contexte="Odoo") + self.assertEqual(0, fin.returncode, fin.stdout + fin.stderr) + self.assertIn("DESTRUCTION", fin.stdout) + self.assertFalse((venv / "TEMOIN").exists()) + + +class TestCeQuIlRefuse(BancInstallVenv): + def test_un_repertoire_sans_pyvenv_cfg_garde_son_contenu(self): + pas_un_venv = self.coin / "donnees" + (pas_un_venv / "sous").mkdir(parents=True) + (pas_un_venv / "sous" / "PRECIEUX").write_text("ne pas effacer\n") + fin = self.lance(pas_un_venv) + self.assertEqual(1, fin.returncode) + self.assertIn("Refus de detruire", fin.stdout) + self.assertTrue((pas_un_venv / "sous" / "PRECIEUX").exists()) + + def test_un_venv_monte_a_distance_est_epargne(self): + """Il appartient à une autre machine : l'effacer traverse le réseau.""" + faux_findmnt = self.bin / "findmnt" + faux_findmnt.write_text("#!/bin/sh\necho fuse.sshfs\n") + faux_findmnt.chmod(0o755) + venv = self.venv_factice("monte", AUTRE) + fin = self.lance(venv) + self.assertEqual(1, fin.returncode) + self.assertIn("monte a distance", fin.stdout) + self.assertTrue((venv / "TEMOIN").exists()) + + def test_un_repertoire_vide_ne_demande_pas_d_intervention(self): + """Rien à perdre : rmdir suffit, et l'installation continue.""" + vide = self.coin / "vide" + vide.mkdir() + fin = self.lance(vide) + self.assertEqual(0, fin.returncode, fin.stdout + fin.stderr) + self.assertNotIn("Refus", fin.stdout) + + def test_la_racine_est_refusee(self): + fin = subprocess.run( + [str(SCRIPT), "ERPLibre", "/", VOULUE], + cwd=RACINE, + capture_output=True, + text=True, + env=self.env(), + ) + self.assertEqual(1, fin.returncode) + self.assertNotIn("DESTRUCTION", fin.stdout) + + +class TestSansInterpreteur(BancInstallVenv): + def test_aucune_destruction_quand_l_interpreteur_manque(self): + """L'interpréteur est obtenu AVANT : sinon on reste sans venv.""" + venv = self.venv_factice("perime", AUTRE) + env = dict(self.env(), PYENV_ROOT=str(self.coin / "nulle_part")) + fin = subprocess.run( + [str(SCRIPT), "ERPLibre", str(venv), VOULUE], + cwd=RACINE, + capture_output=True, + text=True, + env=env, + ) + self.assertEqual(1, fin.returncode) + self.assertTrue((venv / "TEMOIN").exists()) + + +if __name__ == "__main__": + unittest.main() From 7a10f268df3746f304c4051a81c5dc30a903d25a Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 24 Sep 2026 13:32:16 -0400 Subject: [PATCH 06/17] =?UTF-8?q?[FIX]=20d=C3=A9marrage=20:=20choisir=20un?= =?UTF-8?q?=20interpr=C3=A9teur=20capable=20de=20lire=20TODO?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit install.sh et make lançaient todo.py par son hashbang, donc par le python3 du PATH, sans rien garantir de sa version : une syntaxe plus récente l'arrête en SyntaxError au chargement, et le poste hors d'âge perd tout moyen de lancer l'installation qui l'en sortirait. L'ordre est désormais le venv d'outillage, sinon le python3 du PATH s'il suffit, sinon l'installation. TODO lui-même se relance dans le venv, vérifie version et modules avant ses imports, et propose l'installation en terminal — ce que « make » sur un clone neuf promet depuis toujours sans le tenir. Vérifié : venv à jour, venv périmé, systèmes en 3.10, 3.14 et 3.15 vont chacun où il faut ; un import du module ne relance rien et lève comme avant. --- EN --- install.sh and make ran todo.py through its hashbang, so through the python3 of the PATH, with no guarantee about its version: a newer syntax stops it with a SyntaxError at load, and the ageing machine loses every way to start the install that would rescue it. The order is now the tools venv, else the python3 of the PATH when it suffices, else the install. TODO itself relaunches in the venv, checks version and modules before its imports, and offers the install in a terminal — what "make" on a fresh clone has always promised without keeping. Checked: fresh venv, stale venv, systems on 3.10, 3.14 and 3.15 each go where they should; importing the module relaunches nothing and raises as before. Assisted-by: Claude Opus 5 --- .claude/skills/erplibre-deployment/SKILL.md | 5 ++ CHANGELOG.base.md | 2 + CHANGELOG.fr.md | 1 + CHANGELOG.md | 1 + README.base.md | 27 ++++++ README.fr.md | 13 +++ README.md | 12 +++ conf/make.todo.Makefile | 4 +- install.sh | 68 ++++++++++++++- script/todo/todo.py | 95 +++++++++++++++++++++ 10 files changed, 226 insertions(+), 2 deletions(-) diff --git a/.claude/skills/erplibre-deployment/SKILL.md b/.claude/skills/erplibre-deployment/SKILL.md index c72f257..8499cd6 100644 --- a/.claude/skills/erplibre-deployment/SKILL.md +++ b/.claude/skills/erplibre-deployment/SKILL.md @@ -56,9 +56,14 @@ est HORS SERVICE : une simple différence de version le laisse en place, parce que le rebâtir refait une installation Poetry entière. Un répertoire sans `pyvenv.cfg` n'est jamais effacé. +`make` / `make todo` hors venv : TODO se relance dans `.venv.erplibre` ; si le +venv manque, il propose `install_erplibre.sh` en terminal, ou nomme la +commande. + Le hook `pre-commit` relaie `script/analyse/check_python_version.py` : il signale le source qui ne parse pas sous cette version, sans bloquer, et dit quand aucun interpréteur de cette version n'était là pour vérifier. + L'image Docker de production bâtit `.venv.erplibre` sur le Python d'Odoo de son image de base, et s'arrête si ce Python ne sait pas lire `script/`. diff --git a/CHANGELOG.base.md b/CHANGELOG.base.md index c214a8b..6aec6a6 100644 --- a/CHANGELOG.base.md +++ b/CHANGELOG.base.md @@ -158,6 +158,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `--bios` is refused on an image with no BIOS boot sector, and says why. Forced there, it gave a VM reported « running » with a silent console — the very failure that flag exists to avoid elsewhere - The tooling virtual environment `.venv.erplibre` runs Python 3.14.7, independently of the Odoo one (3.12.10 for Odoo 18.0). `install_erplibre.sh` builds it through `install_venv.sh` and `EL_PYTHON_PROVIDER` instead of the system `python3` - `.venv.erplibre` on an incompatible Python is DELETED and rebuilt; whatever was installed in it by hand goes with it. Odoo's venv is kept when it merely differs in version, and rebuilt only when it is unusable: rebuilding it redoes a whole Poetry install. A directory without `pyvenv.cfg` is never deleted +- `make` and `make todo` go through `install.sh`, which picks an interpreter able to READ the code before running it: `.venv.erplibre` on the right version, else a recent enough system `python3`, else the install. A system older than `conf/python-erplibre-version` would otherwise stop on a syntax error raised before any guard could name the command to type. TODO then relaunches itself in `.venv.erplibre`, or offers to run `install_erplibre.sh` in a terminal - The production Docker image builds `.venv.erplibre` on Odoo's Python and stops when that Python cannot parse `script/` - The PATCH bounds only Odoo's venv, whose pyproject requires `>=3.12.10,<3.13`. For the tooling one, the major.minor is enough: requiring the patch turned away a distribution's Python one step behind — NixOS 25.11 ships 3.14.2 where conf asks 3.14.7 — and made pyenv COMPILE CPython for a difference nothing needs - `make format` picks the formatter from each file's context: an Odoo module keeps isort and black on `py37`, the series still supported going down that far, while this repository's own tooling goes through ruff, configured once in `.ruff.toml`. ruff follows CPython's versions, where black 24.8.0 stops at `py313`, and its import sorting replaces isort; it is also what the OCA standard uses since it left black @@ -180,6 +181,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `--bios` est refusé sur une image sans secteur d'amorçage BIOS, et dit pourquoi. Forcé là, il donnait une VM « running » à console muette — la panne même que ce drapeau évite ailleurs - L'environnement virtuel d'outillage `.venv.erplibre` tourne en Python 3.14.7, indépendamment de celui d'Odoo (3.12.10 pour Odoo 18.0). `install_erplibre.sh` le bâtit par `install_venv.sh` et `EL_PYTHON_PROVIDER` plutôt qu'avec le `python3` du système - `.venv.erplibre` sur un Python incompatible est SUPPRIMÉ puis rebâti ; ce qu'on y avait posé à la main part avec lui. Le venv d'Odoo est conservé quand seule sa version diffère, et rebâti seulement s'il est hors service : le rebâtir refait une installation Poetry entière. Un répertoire sans `pyvenv.cfg` n'est jamais effacé +- `make` et `make todo` passent par `install.sh`, qui choisit un interpréteur capable de LIRE le code avant de le lancer : `.venv.erplibre` s'il porte la bonne version, sinon le `python3` du système s'il est assez récent, sinon l'installation. Un système plus ancien que `conf/python-erplibre-version` s'arrêterait autrement sur une erreur de syntaxe levée avant qu'aucun garde puisse nommer la commande à taper. TODO se relance ensuite dans `.venv.erplibre`, ou propose de lancer `install_erplibre.sh` en terminal - L'image Docker de production bâtit `.venv.erplibre` sur le Python d'Odoo et s'arrête quand ce Python ne sait pas lire `script/` - Le PATCH ne borne que le venv d'Odoo, dont le pyproject exige `>=3.12.10,<3.13`. Pour celui de l'outillage, la majeure.mineure suffit : exiger le patch écartait le Python d'une distribution d'un cran en retard — NixOS 25.11 livre 3.14.2 quand conf demande 3.14.7 — et faisait COMPILER CPython à pyenv pour une différence que rien ne réclame - `make format` choisit le formateur d'après le contexte de chaque fichier : un module Odoo garde isort et black en `py37`, la série la plus ancienne encore supportée, quand l'outillage de ce dépôt passe par ruff, réglé une fois dans `.ruff.toml`. ruff suit les versions de CPython, là où black 24.8.0 s'arrête à `py313`, et son tri d'imports remplace isort ; c'est aussi ce qu'emploie la norme OCA depuis qu'elle a quitté black diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index d1a9e56..8f7c503 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -80,6 +80,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `--bios` est refusé sur une image sans secteur d'amorçage BIOS, et dit pourquoi. Forcé là, il donnait une VM « running » à console muette — la panne même que ce drapeau évite ailleurs - L'environnement virtuel d'outillage `.venv.erplibre` tourne en Python 3.14.7, indépendamment de celui d'Odoo (3.12.10 pour Odoo 18.0). `install_erplibre.sh` le bâtit par `install_venv.sh` et `EL_PYTHON_PROVIDER` plutôt qu'avec le `python3` du système - `.venv.erplibre` sur un Python incompatible est SUPPRIMÉ puis rebâti ; ce qu'on y avait posé à la main part avec lui. Le venv d'Odoo est conservé quand seule sa version diffère, et rebâti seulement s'il est hors service : le rebâtir refait une installation Poetry entière. Un répertoire sans `pyvenv.cfg` n'est jamais effacé +- `make` et `make todo` passent par `install.sh`, qui choisit un interpréteur capable de LIRE le code avant de le lancer : `.venv.erplibre` s'il porte la bonne version, sinon le `python3` du système s'il est assez récent, sinon l'installation. Un système plus ancien que `conf/python-erplibre-version` s'arrêterait autrement sur une erreur de syntaxe levée avant qu'aucun garde puisse nommer la commande à taper. TODO se relance ensuite dans `.venv.erplibre`, ou propose de lancer `install_erplibre.sh` en terminal - L'image Docker de production bâtit `.venv.erplibre` sur le Python d'Odoo et s'arrête quand ce Python ne sait pas lire `script/` - Le PATCH ne borne que le venv d'Odoo, dont le pyproject exige `>=3.12.10,<3.13`. Pour celui de l'outillage, la majeure.mineure suffit : exiger le patch écartait le Python d'une distribution d'un cran en retard — NixOS 25.11 livre 3.14.2 quand conf demande 3.14.7 — et faisait COMPILER CPython à pyenv pour une différence que rien ne réclame - `make format` choisit le formateur d'après le contexte de chaque fichier : un module Odoo garde isort et black en `py37`, la série la plus ancienne encore supportée, quand l'outillage de ce dépôt passe par ruff, réglé une fois dans `.ruff.toml`. ruff suit les versions de CPython, là où black 24.8.0 s'arrête à `py313`, et son tri d'imports remplace isort ; c'est aussi ce qu'emploie la norme OCA depuis qu'elle a quitté black diff --git a/CHANGELOG.md b/CHANGELOG.md index eb715ea..8973cd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `--bios` is refused on an image with no BIOS boot sector, and says why. Forced there, it gave a VM reported « running » with a silent console — the very failure that flag exists to avoid elsewhere - The tooling virtual environment `.venv.erplibre` runs Python 3.14.7, independently of the Odoo one (3.12.10 for Odoo 18.0). `install_erplibre.sh` builds it through `install_venv.sh` and `EL_PYTHON_PROVIDER` instead of the system `python3` - `.venv.erplibre` on an incompatible Python is DELETED and rebuilt; whatever was installed in it by hand goes with it. Odoo's venv is kept when it merely differs in version, and rebuilt only when it is unusable: rebuilding it redoes a whole Poetry install. A directory without `pyvenv.cfg` is never deleted +- `make` and `make todo` go through `install.sh`, which picks an interpreter able to READ the code before running it: `.venv.erplibre` on the right version, else a recent enough system `python3`, else the install. A system older than `conf/python-erplibre-version` would otherwise stop on a syntax error raised before any guard could name the command to type. TODO then relaunches itself in `.venv.erplibre`, or offers to run `install_erplibre.sh` in a terminal - The production Docker image builds `.venv.erplibre` on Odoo's Python and stops when that Python cannot parse `script/` - The PATCH bounds only Odoo's venv, whose pyproject requires `>=3.12.10,<3.13`. For the tooling one, the major.minor is enough: requiring the patch turned away a distribution's Python one step behind — NixOS 25.11 ships 3.14.2 where conf asks 3.14.7 — and made pyenv COMPILE CPython for a difference nothing needs - `make format` picks the formatter from each file's context: an Odoo module keeps isort and black on `py37`, the series still supported going down that far, while this repository's own tooling goes through ruff, configured once in `.ruff.toml`. ruff follows CPython's versions, where black 24.8.0 stops at `py313`, and its import sorting replaces isort; it is also what the OCA standard uses since it left black diff --git a/README.base.md b/README.base.md index 70a9ad6..6f1e80e 100644 --- a/README.base.md +++ b/README.base.md @@ -254,6 +254,33 @@ make +`make` and `./install.sh` start TODO through the interpreter that can read it: +`.venv.erplibre` when it carries the right version, otherwise the system +`python3` when it is recent enough, otherwise the install itself — a system +older than `conf/python-erplibre-version` cannot parse the code, so it goes +straight to the install rather than stopping on a syntax error. When the +environment is missing but the system Python suffices, TODO offers to run +`./script/install/install_erplibre.sh` (in a terminal) or prints that command. The install builds the environment +through `EL_PYTHON_PROVIDER` (mise or pyenv); an existing `.venv.erplibre` on +another Python version is DELETED and rebuilt, and whatever was installed in it +by hand goes with it. + + + +`make` et `./install.sh` lancent TODO par l'interpréteur capable de le lire : +`.venv.erplibre` quand il porte la bonne version, sinon le `python3` du système +s'il est assez récent, sinon l'installation elle-même — un système plus ancien +que `conf/python-erplibre-version` ne sait pas analyser le code, donc on va +droit à l'installation plutôt que de s'arrêter sur une erreur de syntaxe. Quand +l'environnement manque mais que le Python du système suffit, TODO propose de +lancer `./script/install/install_erplibre.sh` (dans un terminal) ou affiche +cette commande. L'installation bâtit l'environnement par +`EL_PYTHON_PROVIDER` (mise ou pyenv) ; un `.venv.erplibre` existant sur une +autre version de Python est SUPPRIMÉ puis rebâti, et ce qu'on y avait posé à la +main part avec lui. + + + ### Manually Into Ubuntu, minimal dependency: diff --git a/README.fr.md b/README.fr.md index e942d48..0bdb9d3 100644 --- a/README.fr.md +++ b/README.fr.md @@ -126,6 +126,19 @@ make ``` +`make` et `./install.sh` lancent TODO par l'interpréteur capable de le lire : +`.venv.erplibre` quand il porte la bonne version, sinon le `python3` du système +s'il est assez récent, sinon l'installation elle-même — un système plus ancien +que `conf/python-erplibre-version` ne sait pas analyser le code, donc on va +droit à l'installation plutôt que de s'arrêter sur une erreur de syntaxe. Quand +l'environnement manque mais que le Python du système suffit, TODO propose de +lancer `./script/install/install_erplibre.sh` (dans un terminal) ou affiche +cette commande. L'installation bâtit l'environnement par +`EL_PYTHON_PROVIDER` (mise ou pyenv) ; un `.venv.erplibre` existant sur une +autre version de Python est SUPPRIMÉ puis rebâti, et ce qu'on y avait posé à la +main part avec lui. + + ### Manuellement Sous Ubuntu, dépendance minimale : diff --git a/README.md b/README.md index 2a7232d..1b63d4b 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,18 @@ make ``` +`make` and `./install.sh` start TODO through the interpreter that can read it: +`.venv.erplibre` when it carries the right version, otherwise the system +`python3` when it is recent enough, otherwise the install itself — a system +older than `conf/python-erplibre-version` cannot parse the code, so it goes +straight to the install rather than stopping on a syntax error. When the +environment is missing but the system Python suffices, TODO offers to run +`./script/install/install_erplibre.sh` (in a terminal) or prints that command. The install builds the environment +through `EL_PYTHON_PROVIDER` (mise or pyenv); an existing `.venv.erplibre` on +another Python version is DELETED and rebuilt, and whatever was installed in it +by hand goes with it. + + ### Manually Into Ubuntu, minimal dependency: diff --git a/conf/make.todo.Makefile b/conf/make.todo.Makefile index bd88161..5902887 100644 --- a/conf/make.todo.Makefile +++ b/conf/make.todo.Makefile @@ -2,6 +2,8 @@ # TODO # ######## +# Par install.sh, et non todo.py directement : lui seul choisit un interpréteur +# capable de LIRE le code avant de le lancer, et pose le venv s'il manque. .PHONY: todo todo: - ./script/todo/todo.py + ./install.sh diff --git a/install.sh b/install.sh index ff04bef..f98789a 100755 --- a/install.sh +++ b/install.sh @@ -1,3 +1,69 @@ #!/usr/bin/env bash +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +# +# Point d'entrée de TODO : choisir un interpréteur capable de LIRE le code +# avant de le lui donner. +# +# todo.py porte le hashbang « python3 », donc le Python du système. Celui-ci +# est libre d'être plus vieux que conf/python-erplibre-version, qui est la +# seule version que le dépôt vise : une syntaxe qu'il ne connaît pas l'arrête +# en SyntaxError au chargement, AVANT le garde de todo.py qui aurait su nommer +# le geste. Un système hors d'âge n'a alors plus aucun moyen de lancer +# l'installation qui le tirerait de là. +# +# D'où l'ordre : le venv d'outillage, qui porte la bonne version ; sinon le +# Python du système s'il est assez récent pour lire le code, et le garde de +# todo.py prend le relais ; sinon l'installation, seule issue. -./script/todo/todo.py +cd "$(dirname "$0")" || exit 1 + +VENV="$(xargs < conf/python-erplibre-venv 2> /dev/null)" +VOULUE="$(xargs < conf/python-erplibre-version 2> /dev/null)" +ATTENDUE="${VOULUE%.*}" + +# Majeure.mineure de l'exécutable : elle seule décide de la grammaire acceptée. +# Rien n'est rendu quand la sortie n'a pas la forme « Python X.Y… » : un +# interpréteur en panne écrit son diagnostic, et le premier mot venu passerait +# sinon pour un numéro de version que « sort -V » jugerait assez récent. +el_mineure() { + "$1" -V 2>&1 | awk '$1 == "Python" { + split($2, v, ".") + if (v[1] ~ /^[0-9]+$/ && v[2] ~ /^[0-9]+$/) print v[1] "." v[2] + }' +} + +# Vrai si la version lue atteint au moins celle attendue, comparée en version +# et non en chaîne : « 3.9 » est une chaîne plus grande que « 3.14 ». +el_assez_recent() { + [ -n "$1" ] && [ -n "${ATTENDUE}" ] \ + && [ "$(printf '%s\n%s\n' "${ATTENDUE}" "$1" | sort -V | head -1)" \ + = "${ATTENDUE}" ] +} + +PYTHON_VENV="./${VENV}/bin/python" +if [ -n "${VENV}" ] && [ -x "${PYTHON_VENV}" ] \ + && el_assez_recent "$(el_mineure "${PYTHON_VENV}")"; then + exec "${PYTHON_VENV}" ./script/todo/todo.py "$@" +fi + +# Un venv dont l'interpréteur ne rend pas sa version ne démarrera pas +# davantage TODO : c'est le cas d'un checkout monté depuis une autre machine, +# dont le venv cherche sa bibliothèque standard là où elle n'est pas. Le dire, +# et couper la relance de todo.py, qui sinon se remplacerait par ce python mort +# et rendrait son pavé d'initialisation au lieu d'un message. +if [ -x "${PYTHON_VENV}" ] && [ -z "$(el_mineure "${PYTHON_VENV}")" ]; then + echo "${PYTHON_VENV} ne demarre pas : ce venv a ete bati ailleurs." + echo " Rebatissez-le ici : ./script/install/install_erplibre.sh" + export EL_TODO_VENV_RELAUNCHED=1 +fi + +if command -v python3 > /dev/null 2>&1 \ + && el_assez_recent "$(el_mineure python3)"; then + exec ./script/todo/todo.py "$@" +fi + +echo "Python ${VOULUE:-du depot} est requis pour lire le code de TODO ;" +echo " ce systeme livre $(el_mineure python3 2> /dev/null || echo 'aucun python3')." +echo " Installation du venv d'outillage, qui le pose :" +exec ./script/todo/source_todo.sh "$@" diff --git a/script/todo/todo.py b/script/todo/todo.py index fde32b5..a9a872c 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -5,6 +5,7 @@ import ast import configparser import datetime +import importlib.util import inspect import json import logging @@ -24,6 +25,98 @@ new_path = os.path.normpath( ) sys.path.append(new_path) + +# Garde d'amorçage, joué seulement quand todo.py est le programme lancé : il +# relance dans le venv d'outillage, ou refuse avec le geste qui le bâtit, au +# lieu d'une trace brute à l'import. Il parse sous 3.7 pour parler à un vieux +# python. Messages en anglais hors t() : todo_i18n n'est pas encore importé. +RELAUNCH_ENV = "EL_TODO_VENV_RELAUNCHED" +# Modules tiers que charge l'import de ce fichier et de ses mixins : un seul +# absent fait lever l'import, ou boucler crash_diagnostic. Ceux qu'un menu +# n'importe qu'à l'usage n'y figurent pas, ils ne bloquent pas le démarrage. +REQUIRED_MODULES = ( + "click colorama dotenv humanize openai pykeepass urwid".split() +) +INSTALL_CMD = "./script/install/install_erplibre.sh" + + +def _read_conf(name, fallback): + """Rend la première ligne non vide et non commentée de conf/.""" + try: + with open(os.path.join(new_path, "conf", name), encoding="utf-8") as f: + for line in f: + line = line.strip() + if line and not line.startswith("#"): + return line + except (OSError, UnicodeDecodeError): + pass + sys.stderr.write("conf/%s unreadable, assuming %s\n" % (name, fallback)) + return fallback + + +VENV_ERPLIBRE = _read_conf("python-erplibre-venv", ".venv.erplibre") +VENV_DIR = os.path.join(new_path, VENV_ERPLIBRE) +VENV_PYTHON = os.path.join(VENV_DIR, "bin", "python") + + +def _in_venv(): + # sys.prefix et non le binaire : /bin/python est un lien vers sa base. + return os.path.realpath(sys.prefix) == os.path.realpath(VENV_DIR) + + +def _relaunch(marker): + """Remplace le processus par le python du venv ; OSError si impossible.""" + env = dict(os.environ) + env[RELAUNCH_ENV] = marker + argv = [VENV_PYTHON, os.path.abspath(__file__)] + sys.argv[1:] + os.execve(VENV_PYTHON, argv, env) + + +def _bootstrap(): + # La relance passe avant la version : un python trop vieux avec un venv + # sain doit relancer, pas refuser. + relaunched = os.environ.get(RELAUNCH_ENV) + if not relaunched and not _in_venv() and os.access(VENV_PYTHON, os.X_OK): + try: + _relaunch("1") + except OSError: + pass + voulue = _read_conf("python-erplibre-version", "3.14") + missing = [m for m in REQUIRED_MODULES if not importlib.util.find_spec(m)] + if sys.version_info[:2] >= tuple(map(int, voulue.split(".")[:2])): + if not missing: + os.environ.pop(RELAUNCH_ENV, None) + return + reason = "missing modules: %s" % ", ".join(missing) + else: + reason = "Python %s is older than %s" % ( + sys.version.split()[0], + voulue, + ) + print("TODO cannot start from %s: %s." % (sys.executable, reason)) + print("Install the tools virtualenv %s with:" % VENV_ERPLIBRE) + print("\n %s\n" % INSTALL_CMD) + # Amorçage de « make » sur un clone neuf : proposer l'installation, une fois. + if relaunched != "installed" and os.isatty(0) and os.isatty(1): + try: + answer = input("Run it now? [y/o/N] ") + except (EOFError, KeyboardInterrupt): + answer = "" + if answer.strip().lower() in ("y", "yes", "o", "oui"): + if subprocess.call([INSTALL_CMD], cwd=new_path) != 0: + print("Installation failed, see above.") + else: + try: + _relaunch("installed") + except OSError: + print("Installed, but %s does not start." % VENV_PYTHON) + sys.exit(1) + + +if __name__ == "__main__": + _bootstrap() + + from script.config import config_file from script.execute import execute from script.todo import dev_tools, ssh_config, todo_install, todo_prefs @@ -5601,6 +5694,8 @@ class TODO( from pykeepass import PyKeePass except ImportError: print("Rerun and exit") + if _in_venv(): # la relance tournerait dans ce même venv + sys.exit(1) self.execute.exec_command_live(cmd, source_erplibre=True) sys.exit(1) print("No error") From 5411b102a9a9ecf489e5f226e908508d8e16a398 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 24 Sep 2026 13:32:28 -0400 Subject: [PATCH 07/17] =?UTF-8?q?[ADD]=20d=C3=A9ploiement=20:=20un=20proxy?= =?UTF-8?q?=20SOCKS=20par=20SSH,=20et=20son=20mode=20d'emploi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit « -L » relaie UN service ; « -D » ouvre un relais SOCKS, par lequel le navigateur atteint n'importe quelle destination depuis la machine distante — une interface qui n'écoute que sur sa boucle locale, un hôte de son réseau sans route depuis ici. Le port par défaut est 1080, modifiable, et contrôlé libre avant d'ouvrir. La saisie de l'adresse est celle de sshfs, désormais partagée : l'alias de ~/.ssh/config part tel quel à ssh, faute de quoi son ProxyJump se perdrait et une VM imbriquée deviendrait injoignable. Le réglage de Firefox s'affiche AVANT le lancement, la commande ne rendant la main qu'au Ctrl+C. Vérifié : 9 tests neufs, et la numérotation du menu que deux autres gardent. --- EN --- "-L" relays ONE service; "-D" opens a SOCKS relay, through which the browser reaches any destination from the remote machine — an interface listening only on its loopback, a host of its network with no route from here. The default port is 1080, changeable, and checked free before opening. The address prompt is sshfs's, now shared: the ~/.ssh/config alias goes to ssh as is, failing which its ProxyJump would be lost and a nested VM become unreachable. The Firefox settings print BEFORE the launch, the command only returning on Ctrl+C. Checked: 9 new tests, and the menu numbering two others guard. Assisted-by: Claude Opus 5 --- script/todo/todo.py | 169 ++++++++++++++++++++++------------ script/todo/todo_i18n.py | 75 +++++++++++---- test/test_proxmox_deploy.py | 4 +- test/test_qemu_cache_menu.py | 26 +++--- test/test_todo_socks_proxy.py | 129 ++++++++++++++++++++++++++ 5 files changed, 316 insertions(+), 87 deletions(-) create mode 100644 test/test_todo_socks_proxy.py diff --git a/script/todo/todo.py b/script/todo/todo.py index a9a872c..884cd03 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -139,7 +139,6 @@ from script.todo.version_manager import get_odoo_version from script.todo.vpn_menu import VpnMenuMixin ERROR_LOG_PATH = ".erplibre.error.txt" -VENV_ERPLIBRE = ".venv.erplibre" ENABLE_CRASH = False CRASH_E = None # Support mobile ERPLibre @@ -480,13 +479,11 @@ class TODO( ) # TODO detect last version supported # cmd_intern = "./script/install/install_erplibre.sh" - # TODO maybe update q to only install erplibre from install_locally - # TODO problem installing with q, the script depend on odoo key_i = 0 commands_begin = { "q": ( "q", - "q: ERPLibre only with system python without Odoo", + "q: ERPLibre only without Odoo, with the required Python", "./script/install/install_erplibre.sh", ), "w": ( @@ -1057,6 +1054,7 @@ class TODO( "SSH port forwarding (open Odoo in the browser)" ) }, + {"prompt_description": t("Configure a SOCKS proxy over SSH")}, {"section": t("Remote & services")}, {"prompt_description": t("SSH (remote host)...")}, { @@ -1100,16 +1098,18 @@ class TODO( elif status == "3": self._deploy_port_forward() elif status == "4": - self.prompt_execute_deploy_ssh() + self._deploy_socks_proxy() elif status == "5": - self.prompt_execute_qemu() + self.prompt_execute_deploy_ssh() elif status == "6": - self.prompt_execute_proxmox() + self.prompt_execute_qemu() elif status == "7": - self._deploy_ntfy_server() + self.prompt_execute_proxmox() elif status == "8": - self.prompt_execute_qemu_cache() + self._deploy_ntfy_server() elif status == "9": + self.prompt_execute_qemu_cache() + elif status == "10": self.prompt_execute_vpn() else: print(t("Command not found !")) @@ -1648,8 +1648,7 @@ class TODO( # l'agent au lieu de les remplacer, et le serveur coupe après # 5 essais infructueux. block += ( - f" IdentityFile {identity_file}\n" - f" IdentitiesOnly yes\n" + f" IdentityFile {identity_file}\n IdentitiesOnly yes\n" ) if proxy_jump: block += f" ProxyJump {proxy_jump}\n" @@ -1810,15 +1809,11 @@ class TODO( print(f"{t('Directory already exists: ')}{target_path}") return print(t("Cloning ERPLibre...")) - cmd = ( - "git clone" - " https://github.com/erplibre/erplibre" - f" {target_path}" - ) + cmd = f"git clone https://github.com/erplibre/erplibre {target_path}" print(f"{t('Will execute:')} {cmd}") try: self.execute.exec_command_live(cmd, source_erplibre=False) - print(f"{t('ERPLibre cloned successfully to: ')}" f"{target_path}") + print(f"{t('ERPLibre cloned successfully to: ')}{target_path}") except Exception as e: print(f"{t('Error cloning ERPLibre: ')}{e}") @@ -2385,27 +2380,32 @@ class TODO( ) print(f" → {t('Update ~/.ssh/config, or check the server is up.')}") - def _configure_sshfs(self): + def _ask_ssh_target(self): + """Demande OÙ se connecter, à la main ou depuis ~/.ssh/config. + + Rend (cible, utilisateur, hôte, nom, depuis_config), ou None si l'on + renonce. La CIBLE est ce qu'on passe à ssh : l'alias quand il vient du + fichier de configuration, pour que son User et son ProxyJump + s'appliquent — un « user@hôte » écrit à la main les perdrait, et une VM + imbriquée sans route directe deviendrait injoignable. + + DEPUIS_CONFIG distingue les deux, que le nom seul ne sépare pas : + l'appelant n'interroge ~/.ssh/config que pour une adresse qui en vient. + """ import getpass - import re - from datetime import datetime print(f"\n{t('SSH address input method')}") print(f"[1] {t('Manual entry')}") print(f"[2] {t('From ~/.ssh/config')}") choice = input(t("Your choice (1/2): ")).strip() - user = None - hostname = None - ssh_name = None - if choice == "2": ssh_config_path = os.path.expanduser("~/.ssh/config") hosts = self._ssh_config_entries(ssh_config_path) if not hosts: print(t("No SSH hosts found in ~/.ssh/config")) - return + return None print() for i, (host, info) in enumerate(hosts, 1): @@ -2417,36 +2417,98 @@ class TODO( if u: desc += f" [{u}]" print(f"[{i}] {desc}") - sel = input(t("Select SSH host number: ")).strip() try: idx = int(sel) - 1 if idx < 0 or idx >= len(hosts): print(t("Invalid selection!")) - return + return None except ValueError: print(t("Invalid selection!")) - return + return None host_name, host_info = hosts[idx] hostname = host_info.get("hostname", host_name) user = host_info.get("user", getpass.getuser()) - ssh_name = host_name - target = f"{host_name}:/" + return host_name, user, hostname, host_name, True + + ssh_host = input(t("SSH host (e.g.: user@192.168.1.100): ")).strip() + if not ssh_host: + print(t("SSH host is required!")) + return None + if "@" in ssh_host: + user, hostname = ssh_host.split("@", 1) else: - ssh_host = input( - t("SSH host (e.g.: user@192.168.1.100): ") - ).strip() - if not ssh_host: - print(t("SSH host is required!")) + hostname = ssh_host + user = getpass.getuser() + return f"{user}@{hostname}", user, hostname, hostname, False + + def _deploy_socks_proxy(self): + """Ouvre un proxy SOCKS qui fait sortir le navigateur PAR la machine + distante. + + « -D » n'ouvre pas un tunnel vers UN service, comme « -L », mais un + relais SOCKS : le navigateur y envoie n'importe quelle destination, et + c'est la machine distante qui l'atteint. De quoi lire une interface + qui n'écoute que sur sa boucle locale, ou joindre un hôte de son + réseau sans route depuis ici. + + « -N » n'ouvre aucun shell — rien à exécuter là-bas —, et « -C » + comprime, ce qui se sent sur une liaison lente. + """ + print(f"\n🧦 {t('SOCKS proxy over SSH')}") + choisi = self._ask_ssh_target() + if not choisi: + return + cible = choisi[0] + + raw = input(f"{t('SOCKS port (default:')} 1080): ").strip() + port = raw if raw.isdigit() else "1080" + + if not self._port_is_free(port): + print(f" ⚠ {t('Local port already in use:')} {port}") + if not self._is_yes(input(t("Try anyway? (y/N): "))): return - if "@" in ssh_host: - user, hostname = ssh_host.split("@", 1) - else: - hostname = ssh_host - user = getpass.getuser() - ssh_name = hostname - target = f"{user}@{hostname}:/" + + cmd = f"ssh -D {port} -N -C {shlex.quote(cible)}" + print(f"\n {t('Will execute:')} {cmd}") + # Le mode d'emploi passe AVANT : la commande ne rend la main qu'au + # Ctrl+C, et c'est pendant qu'elle tourne qu'on règle le navigateur. + self._print_socks_help(port) + print(f" {t('Ctrl+C closes the tunnel.')}\n") + try: + self.execute.exec_command_live(cmd, source_erplibre=False) + except KeyboardInterrupt: + pass + print(f"\n {t('Tunnel closed.')}") + + @staticmethod + def _print_socks_help(port): + """Le réglage du navigateur, qu'aucune commande ne fait à sa place.""" + # Les deux libellés qui portent des guillemets sortent de la + # f-string : les y laisser en réutiliserait le délimiteur, ce que + # Python n'accepte qu'à partir de 3.12. + choix = t('then choose "Manual proxy configuration":') + dns = t('Tick "Proxy DNS when using SOCKS v5"') + print(f"\n ── {t('Firefox configuration')} ──") + print(f" {t('Settings, then Network Settings and Settings...,')}") + print(f" {choix}\n") + print(f" {t('SOCKS host:')} 127.0.0.1, {t('port')} {port}") + print(f" {t('Tick SOCKS v5')}") + print(f" {dns}") + print(f"\n {t('Domain names are then resolved on the remote side,')}") + print(f" {t('which reaches internal names such as localhost, or')}") + print(f" {t('hosts of the remote network.')}\n") + + def _configure_sshfs(self): + import re + from datetime import datetime + + choisi = self._ask_ssh_target() + if not choisi: + return + cible, user, hostname, ssh_name, depuis_config = choisi + target = f"{cible}:/" safe_name = re.sub(r"[^a-zA-Z0-9_-]", "_", ssh_name) timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") @@ -2462,7 +2524,7 @@ class TODO( # et lui qu'on peut interroger en cas d'échec. Une saisie manuelle est # rendue telle quelle — si elle contient un « + », c'est un chaînage # demandé exprès. - alias = ssh_name if choice == "2" else "" + alias = ssh_name if depuis_config else "" if alias: cmd, bypassed = self._sshfs_command(alias, mount_point) else: @@ -2491,7 +2553,7 @@ class TODO( return print(f"{t('Mounted on: ')}{mount_point}") print("mount | grep sshfs") - print(f"{t('To unmount: ')}" f"fusermount -u {mount_point}") + print(f"{t('To unmount: ')}fusermount -u {mount_point}") print(f"nautilus {mount_point}/home/{user}") def _get_ssh_params(self): @@ -3204,9 +3266,7 @@ class TODO( def _deploy_git_server(self, production_ready=False, action="all"): print(t("Starting git server deployment...")) - cmd = ( - "python3 ./script/git/git_local_server.py -v" f" --action {action}" - ) + cmd = f"python3 ./script/git/git_local_server.py -v --action {action}" if production_ready: cmd += " --production-ready" self.execute.exec_command_live( @@ -3275,8 +3335,7 @@ class TODO( }, { "prompt_description": t( - "Todo Generate Code - Code by the OCA rules at high" - " effort" + "Todo Generate Code - Code by the OCA rules at high effort" ) }, {"prompt_description": t("Show installed custom commands")}, @@ -3343,7 +3402,7 @@ class TODO( name = f[:-3] # remove .md print(f" /{name:<30} {date_str}") print("-" * 50) - print(f"{t('Total:')}" f" {len(files)}") + print(f"{t('Total:')} {len(files)}") def _claude_context_root(self): """La racine du dépôt, deux niveaux au-dessus de ce fichier.""" @@ -3462,8 +3521,7 @@ class TODO( chemin_hooks = self._git_hooks_path(racine) print( - f"{t('Git hooks'):<22}" - f" {chemin_hooks or t('hook not installed')}" + f"{t('Git hooks'):<22} {chemin_hooks or t('hook not installed')}" ) if chemin_hooks: absolu = os.path.join(racine, chemin_hooks) @@ -5358,8 +5416,7 @@ class TODO( # Step 2: Install modules print(f"\n--- {t('Installing modules')}: {modules_to_install} ---") cmd_install = ( - f"./script/addons/install_addons.sh" - f" {db_name} {modules_to_install}" + f"./script/addons/install_addons.sh {db_name} {modules_to_install}" ) self.execute.exec_command_live( cmd_install, @@ -5487,9 +5544,7 @@ class TODO( env_input = "" while env_input not in environments and env_input != "0": if env_input: - print( - f"{t('Error, cannot understand value')}" f" '{env_input}'" - ) + print(f"{t('Error, cannot understand value')} '{env_input}'") env_input = input(str_input).strip() if env_input == "0": diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 8116e1c..d9f1474 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -286,6 +286,55 @@ TRANSLATIONS = { "fr": "Méthode de saisie de l'adresse SSH", "en": "SSH address input method", }, + # Proxy SOCKS sur SSH (menu Déploiement › Local) + "Configure a SOCKS proxy over SSH": { + "fr": "🧦 Configurer Proxy vers SSH", + "en": "🧦 Configure a SOCKS proxy over SSH", + }, + "SOCKS proxy over SSH": { + "fr": "Proxy SOCKS par SSH", + "en": "SOCKS proxy over SSH", + }, + "SOCKS port (default:": { + "fr": "Port SOCKS (défaut :", + "en": "SOCKS port (default:", + }, + "Firefox configuration": { + "fr": "Configuration dans Firefox", + "en": "Firefox configuration", + }, + "Settings, then Network Settings and Settings...,": { + "fr": "Paramètres, puis Paramètres réseau et Paramètres…,", + "en": "Settings, then Network Settings and Settings...,", + }, + 'then choose "Manual proxy configuration":': { + "fr": "puis choisir « Configuration manuelle du proxy » :", + "en": 'then choose "Manual proxy configuration":', + }, + "SOCKS host:": { + "fr": "Hôte SOCKS :", + "en": "SOCKS host:", + }, + "Tick SOCKS v5": { + "fr": "Cocher SOCKS v5", + "en": "Tick SOCKS v5", + }, + 'Tick "Proxy DNS when using SOCKS v5"': { + "fr": "Cocher « Utiliser un DNS distant lorsque SOCKS v5 est actif »", + "en": 'Tick "Proxy DNS when using SOCKS v5"', + }, + "Domain names are then resolved on the remote side,": { + "fr": "Les noms de domaine sont alors résolus côté machine distante,", + "en": "Domain names are then resolved on the remote side,", + }, + "which reaches internal names such as localhost, or": { + "fr": "ce qui atteint des noms internes comme localhost, ou des", + "en": "which reaches internal names such as localhost, or", + }, + "hosts of the remote network.": { + "fr": "hôtes du réseau de cette machine.", + "en": "hosts of the remote network.", + }, "Manual entry": { "fr": "Saisie manuelle", "en": "Manual entry", @@ -5231,8 +5280,7 @@ TRANSLATIONS = { "fr": "Choix (numéro ou nom, vide = amd64) :", "en": "Choice (number or name, blank = amd64):", }, - "s390x is emulated (TCG): boot and install are much " - "slower than x86.": { + "s390x is emulated (TCG): boot and install are much slower than x86.": { "fr": "s390x est émulé (TCG) : le boot et l'installation sont bien " "plus lents que x86.", "en": "s390x is emulated (TCG): boot and install are much " @@ -6227,8 +6275,7 @@ TRANSLATIONS = { }, "The VM cannot boot while 3D stays in its definition.": { "fr": ( - "La VM ne démarrera pas tant que la 3D reste dans sa" - " définition." + "La VM ne démarrera pas tant que la 3D reste dans sa définition." ), "en": "The VM cannot boot while 3D stays in its definition.", }, @@ -7293,8 +7340,7 @@ TRANSLATIONS = { "fr": "cache de téléchargement : MAC de l'hôte introuvable", "en": "download cache: host MAC not found", }, - "no trust store for this distribution, its downloads " - "will fail": { + "no trust store for this distribution, its downloads will fail": { "fr": ( "pas de magasin de confiance pour cette distribution, ses " "téléchargements échoueront" @@ -11350,8 +11396,7 @@ TRANSLATIONS = { "these before anything else.", }, "Use -v to list them all, --json for the raw data.": { - "fr": "Utilisez -v pour tout afficher, --json pour la donnée " - "brute.", + "fr": "Utilisez -v pour tout afficher, --json pour la donnée brute.", "en": "Use -v to list them all, --json for the raw data.", }, "List the fields and models added outside a module — " @@ -12184,8 +12229,7 @@ TRANSLATIONS = { " (backend" ), "en": ( - "the system keyring would store the password in plaintext" - " (backend" + "the system keyring would store the password in plaintext (backend" ), }, "mail_err_keyring_plaintext_hint": { @@ -13329,12 +13373,12 @@ TRANSLATIONS = { "en": "Which technology?", }, "VPN - Create a profile from a site preset": { - "fr": "\U0001F3DB VPN - Créer un profil à partir d'un préréglage de site", - "en": "\U0001F3DB VPN - Create a profile from a site preset", + "fr": "\U0001f3db VPN - Créer un profil à partir d'un préréglage de site", + "en": "\U0001f3db VPN - Create a profile from a site preset", }, "VPN - Import an AnyConnect profile (.xml)": { - "fr": "\U0001F4E5 VPN - Importer un profil AnyConnect (.xml)", - "en": "\U0001F4E5 VPN - Import an AnyConnect profile (.xml)", + "fr": "\U0001f4e5 VPN - Importer un profil AnyConnect (.xml)", + "en": "\U0001f4e5 VPN - Import an AnyConnect profile (.xml)", }, "An AnyConnect profile usually sits in" " /opt/cisco/secureclient/vpn/profile/ (or .../anyconnect/profile/).": { @@ -14127,8 +14171,7 @@ TRANSLATIONS = { }, "Sweeping the network reaches machines you did not name.": { "fr": ( - "Balayer le réseau atteint des machines que tu n'as pas" - " nommées." + "Balayer le réseau atteint des machines que tu n'as pas nommées." ), "en": "Sweeping the network reaches machines you did not name.", }, diff --git a/test/test_proxmox_deploy.py b/test/test_proxmox_deploy.py index e159706..f3fe6ca 100644 --- a/test/test_proxmox_deploy.py +++ b/test/test_proxmox_deploy.py @@ -666,11 +666,11 @@ class TestLeMenu(unittest.TestCase): def test_the_dispatch_follows_the_list(self): src = open("script/todo/todo.py", encoding="utf-8").read() self.assertIn( - 'elif status == "6":\n self.prompt_execute_proxmox()', + 'elif status == "7":\n self.prompt_execute_proxmox()', src, ) self.assertIn( - 'elif status == "7":\n self._deploy_ntfy_server()', + 'elif status == "8":\n self._deploy_ntfy_server()', src, ) diff --git a/test/test_qemu_cache_menu.py b/test/test_qemu_cache_menu.py index 5709a08..05b0845 100644 --- a/test/test_qemu_cache_menu.py +++ b/test/test_qemu_cache_menu.py @@ -178,17 +178,17 @@ class TestEntreeDuCache(unittest.TestCase): def test_entree_dispatchee(self): self.assertRegex( self.corps, - r'elif status == "8":\s*\n\s*self\.prompt_execute_qemu_cache\(\)', + r'elif status == "9":\s*\n\s*self\.prompt_execute_qemu_cache\(\)', "l'entrée 8 ne mène pas au sous-menu du cache", ) - def test_vpn_decale_en_neuf(self): - """L'entrée insérée pousse le VPN : sans quoi deux entrées se - partagent le numéro 8 et la seconde est inatteignable.""" + def test_vpn_reste_le_dernier(self): + """Toute entrée insérée avant lui le pousse : sans quoi deux entrées + partagent un numéro, et la seconde est inatteignable.""" self.assertRegex( self.corps, - r'elif status == "9":\s*\n\s*self\.prompt_execute_vpn\(\)', - "le VPN n'a pas été décalé en 9", + r'elif status == "10":\s*\n\s*self\.prompt_execute_vpn\(\)', + "le VPN n'est plus la dernière entrée du menu", ) def test_numeros_sans_trou_ni_doublon(self): @@ -749,9 +749,10 @@ class TestLAssistantDesTests(unittest.TestCase): # « click.confirm » et « longtest_menu.click.confirm » sont le MÊME # objet : un seul mock les couvre, et c'est ce qui rend le compte # d'appels lisible — une question en tout, pas une par essai. - with mock.patch( - "click.prompt", side_effect=lambda *a, **k: next(it) - ), mock.patch("click.confirm", return_value=True) as confirme: + with ( + mock.patch("click.prompt", side_effect=lambda *a, **k: next(it)), + mock.patch("click.confirm", return_value=True) as confirme, + ): Faux()._cache_assistant() return lancees, confirme @@ -1118,9 +1119,10 @@ class TestLesReglagesDuNettoyage(unittest.TestCase): faux = menu.QemuCacheMenuMixin.__new__(menu.QemuCacheMenuMixin) faux.execute = mock.MagicMock() - with mock.patch.object( - menu.cache_offline, "reglage", return_value="" - ), contextlib.redirect_stdout(io.StringIO()): + with ( + mock.patch.object(menu.cache_offline, "reglage", return_value=""), + contextlib.redirect_stdout(io.StringIO()), + ): for a_blanc in (True, False): faux._cache_nettoyage_lancer(a_blanc=a_blanc) faux.execute.exec_command_live.assert_not_called() diff --git a/test/test_todo_socks_proxy.py b/test/test_todo_socks_proxy.py new file mode 100644 index 0000000..0650430 --- /dev/null +++ b/test/test_todo_socks_proxy.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Le proxy SOCKS ouvre-t-il le bon tunnel, et le dit-il assez ? + +« -D » ne relaie pas UN service comme « -L » : il ouvre un relais SOCKS, par +lequel le navigateur atteint n'importe quelle destination depuis la machine +distante. Trois choses décident si la commande sert à quelque chose, et ces +tests les gardent : + +- l'ALIAS de ~/.ssh/config est passé tel quel à ssh. Le remplacer par + « user@hôte » perdrait son ProxyJump, et une VM imbriquée sans route directe + deviendrait injoignable ; +- le port choisi se retrouve dans la commande ET dans le mode d'emploi du + navigateur, sans quoi l'utilisateur règle Firefox sur un port qui n'écoute + pas ; +- le mode d'emploi passe AVANT le lancement : la commande ne rend la main + qu'au Ctrl+C, et c'est pendant qu'elle tourne qu'on règle le navigateur. +""" + +import builtins +import contextlib +import io +import sys +import unittest +from pathlib import Path + +RACINE = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(RACINE)) + +from script.todo.todo import TODO # noqa: E402 + + +class ExecuteFactice: + def __init__(self): + self.commandes = [] + + def exec_command_live(self, cmd, **kwargs): + self.commandes.append(cmd) + + +class BancProxy(unittest.TestCase): + def joue( + self, + reponses, + cible=("vm-essai", "u", "10.0.0.1", "vm-essai", True), + ): + """Déroule la commande sur des réponses écrites d'avance.""" + todo = TODO.__new__(TODO) + todo.execute = ExecuteFactice() + todo._ask_ssh_target = lambda: cible + suite = iter(reponses) + ancien = builtins.input + builtins.input = lambda *a, **k: next(suite, "") + sortie = io.StringIO() + try: + with contextlib.redirect_stdout(sortie): + todo._deploy_socks_proxy() + finally: + builtins.input = ancien + return todo.execute.commandes, sortie.getvalue() + + +class TestLaCommande(BancProxy): + def test_le_port_par_defaut_est_1080(self): + commandes, _ = self.joue([""]) + self.assertEqual(["ssh -D 1080 -N -C vm-essai"], commandes) + + def test_le_port_se_change(self): + commandes, _ = self.joue(["9050"]) + self.assertEqual(["ssh -D 9050 -N -C vm-essai"], commandes) + + def test_un_port_qui_n_est_pas_un_nombre_retombe_sur_1080(self): + commandes, _ = self.joue(["mille-quatre-vingts"]) + self.assertEqual(["ssh -D 1080 -N -C vm-essai"], commandes) + + def test_l_alias_ssh_est_passe_tel_quel(self): + """Le remplacer par user@hôte perdrait son ProxyJump.""" + commandes, _ = self.joue( + [""], cible=("bond", "u", "10.0.0.2", "bond", True) + ) + self.assertIn(" bond", commandes[0]) + self.assertNotIn("@", commandes[0]) + + def test_renoncer_a_l_adresse_ne_lance_rien(self): + todo = TODO.__new__(TODO) + todo.execute = ExecuteFactice() + todo._ask_ssh_target = lambda: None + with contextlib.redirect_stdout(io.StringIO()): + todo._deploy_socks_proxy() + self.assertEqual([], todo.execute.commandes) + + +class TestLeModeDEmploi(BancProxy): + def test_il_nomme_le_port_choisi(self): + _, sortie = self.joue(["9050"]) + self.assertIn("127.0.0.1", sortie) + self.assertIn("9050", sortie) + self.assertNotIn("1080", sortie) + + def test_il_precede_le_lancement(self): + """La commande ne rend la main qu'au Ctrl+C.""" + _, sortie = self.joue([""]) + self.assertLess(sortie.index("127.0.0.1"), sortie.index("Ctrl+C")) + + def test_il_parle_du_dns_distant(self): + _, sortie = self.joue([""]) + self.assertIn("SOCKS v5", sortie) + self.assertIn("DNS", sortie.upper()) + + +class TestLeMenu(unittest.TestCase): + def test_l_entree_ferme_la_section_locale(self): + """Quatrième, et la suite glisse : le VPN passe de 9 à 10.""" + source = (RACINE / "script/todo/todo.py").read_text(encoding="utf-8") + debut = source.index("def prompt_execute_deploy(self)") + menu = source[debut : source.index("def prompt_execute_deploy_ssh")] + self.assertIn( + 'elif status == "4":\n self._deploy_socks_proxy()', + menu, + ) + self.assertIn( + 'elif status == "10":\n self.prompt_execute_vpn()', + menu, + ) + + +if __name__ == "__main__": + unittest.main() From f33aa8a64f11f80cba54e1c744d8eeee4985caed Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 24 Sep 2026 13:32:41 -0400 Subject: [PATCH 08/17] [FIX] format : rendre repo facultatif, et montrer ce qu'il a dit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un échec de « repo forall » arrêtait tout : la liste des fichiers locaux, déjà obtenue, était jetée, et « make format » rendait 0 sans avoir formaté une ligne. Sa sortie d'erreur, capturée puis jetée, emportait la seule explication, et seule l'exception Python restait à l'écran. Google Repo n'ajoute pourtant que les dépôts rapatriés : il est joué sans check, ce qu'il a écrit est relayé, et les fichiers locaux sont formatés quand même. « Rien à formater » se dit au lieu d'une sortie muette, et un échec du dépôt lui-même rend non nul. Vérifié : avec un repo bouchonné qui échoue, son message s'affiche et le fichier local est formaté. --- EN --- A failing "repo forall" stopped everything: the local file list, already gathered, was dropped, and "make format" returned 0 without formatting a line. Its error output, captured then thrown away, carried the only explanation, and only the Python exception stayed on screen. Google Repo only adds the checked out repositories: it runs without check, whatever it wrote is relayed, and local files are formatted anyway. "Nothing to format" is said instead of a silent exit, and a failure of the repository itself returns non-zero. Checked: with a stub repo that fails, its message shows and the local file is formatted. Assisted-by: Claude Opus 5 --- script/maintenance/format_file_to_commit.py | 29 ++++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/script/maintenance/format_file_to_commit.py b/script/maintenance/format_file_to_commit.py index 0e23409..309c112 100755 --- a/script/maintenance/format_file_to_commit.py +++ b/script/maintenance/format_file_to_commit.py @@ -46,18 +46,33 @@ def get_modified_files(): lst_lines = [(".", lines_local)] + lines_project = [] if lst_cmd_git_status_repo: print(" ".join(lst_cmd_git_status_repo)) + # Google Repo est FACULTATIF ici : il n'ajoute que les dépôts + # rapatriés. Son échec laissait pourtant tomber la liste locale + # déjà obtenue, et « make format » rendait 0 sans avoir formaté + # une ligne. Il est donc joué sans check, et ce qu'il a écrit est + # RELAYÉ : capturée puis jetée, sa sortie d'erreur emportait la + # seule explication — le hashbang de bin/repo est relatif, si bien + # qu'un lancement hors de la racine échoue sans dire pourquoi. result = subprocess.run( lst_cmd_git_status_repo, capture_output=True, text=True, - check=True, ) - lines_project = result.stdout.strip().split("\n\n") - else: - lines_project = [] + if result.returncode: + print( + f"repo forall a rendu {result.returncode} : les depots" + " rapatries sont ignores, les fichiers locaux sont" + " formates quand meme." + ) + for flux in (result.stderr, result.stdout): + if flux and flux.strip(): + print(f" {flux.strip()}") + else: + lines_project = result.stdout.strip().split("\n\n") if os.path.isfile(".odoo-version"): with open(".odoo-version") as txt: @@ -163,3 +178,9 @@ if __name__ == "__main__": if status != 0: print(output) sys.exit(status) + elif files is None: + # get_modified_files a échoué sur le dépôt lui-même : rendre 0 + # laisserait croire que tout est formaté. + sys.exit(1) + else: + print("Aucun fichier modifie a formater.") From 4734c682a94b9451befc7170f526e73fc6b0cbab Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 24 Sep 2026 13:33:01 -0400 Subject: [PATCH 09/17] [FIX] format : trouver les addons et leur appliquer la norme OCA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les six cibles d'addons visaient « ./addons// », une disposition que seul un manifeste produit : la production les range sous « odoo/addons/ ». isort s'arrêtait donc sur une trace pour un chemin inexistant, et « make format_all » échouait sans avoir formaté un module. Les dépôts sont maintenant NOMMÉS, cherchés sous les deux dispositions, et celui qu'un manifeste ne rapatrie pas s'annonce puis est ignoré. Le formateur est celui de la communauté, qui a quitté black : ruff en 88 colonnes, avec les sections d'imports « odoo » et « odoo.addons » qu'isort ne sait pas produire. Vérifié : trois des six dépôts sont présents ici, les autres s'annoncent absents sans faire échouer la cible. --- EN --- The six addons targets aimed at "./addons//", a layout only one manifest produces: production files them under "odoo/addons/". isort therefore stopped on a traceback for a non-existent path, and "make format_all" failed without formatting a module. Repositories are now NAMED, looked up under both layouts, and one a manifest does not check out announces itself then is skipped. The formatter is the community's, which left black: ruff at 88 columns, with the "odoo" and "odoo.addons" import sections isort cannot produce. Checked: three of the six repositories are present here, the others announce themselves absent without failing the target. Assisted-by: Claude Opus 5 --- Makefile | 27 ++++------- conf/ruff.addons.toml | 53 +++++++++++++++++++++ script/maintenance/format_addons.sh | 74 +++++++++++++++++++++++++++++ 3 files changed, 136 insertions(+), 18 deletions(-) create mode 100644 conf/ruff.addons.toml create mode 100755 script/maintenance/format_addons.sh diff --git a/Makefile b/Makefile index 7a4992e..51b7256 100644 --- a/Makefile +++ b/Makefile @@ -208,34 +208,25 @@ format_all: # et chaque version d'Odoo n'en rapatrie qu'une partie. Voir format_addons.sh. .PHONY: format_code_generator format_code_generator: - .venv.erplibre/bin/isort --profile black -l 79 ./addons/TechnoLibre_odoo-code-generator/ - ./script/maintenance/black.sh ./addons/TechnoLibre_odoo-code-generator/ - ./script/maintenance/prettier_xml.sh ./addons/TechnoLibre_odoo-code-generator/ + ./script/maintenance/format_addons.sh --xml TechnoLibre_odoo-code-generator .PHONY: format_erplibre_addons format_erplibre_addons: - .venv.erplibre/bin/isort --profile black -l 79 ./addons/ERPLibre_erplibre_addons/ - ./script/maintenance/black.sh ./addons/ERPLibre_erplibre_addons/ - ./script/maintenance/prettier_xml.sh ./addons/ERPLibre_erplibre_addons/ - .venv.erplibre/bin/isort --profile black -l 79 ./addons/ERPLibre_erplibre_theme_addons/ - ./script/maintenance/black.sh ./addons/ERPLibre_erplibre_theme_addons/ - #./script/maintenance/prettier_xml.sh ./addons/ERPLibre_erplibre_theme_addons/ + ./script/maintenance/format_addons.sh --xml ERPLibre_erplibre_addons + ./script/maintenance/format_addons.sh ERPLibre_erplibre_theme_addons .PHONY: format_supported_addons format_supported_addons: - .venv.erplibre/bin/isort --profile black -l 79 ./addons/MathBenTech_erplibre-family-management/ - ./script/maintenance/black.sh ./addons/MathBenTech_erplibre-family-management/ - #./script/maintenance/prettier_xml.sh ./addons/MathBenTech_erplibre-family-management/ - .venv.erplibre/bin/isort --profile black -l 79 ./addons/MathBenTech_odoo-business-spending-management-quebec-canada/ - ./script/maintenance/black.sh ./addons/MathBenTech_odoo-business-spending-management-quebec-canada/ - #./script/maintenance/prettier_xml.sh ./addons/MathBenTech_erplibre-family-management/ + ./script/maintenance/format_addons.sh MathBenTech_erplibre-family-management \ + MathBenTech_odoo-business-spending-management-quebec-canada .PHONY: format_code_generator_template format_code_generator_template: - .venv.erplibre/bin/isort --profile black -l 79 ./addons/TechnoLibre_odoo-code-generator-template/ - ./script/maintenance/black.sh ./addons/TechnoLibre_odoo-code-generator-template/ - #./script/maintenance/prettier_xml.sh ./addons/TechnoLibre_odoo-code-generator-template/ + ./script/maintenance/format_addons.sh TechnoLibre_odoo-code-generator-template +# L'outillage passe par ruff, réglé une fois dans .ruff.toml, qui écarte les +# dépôts rapatriés sous script/. Les addons gardent black : voir +# script/maintenance/format_python.sh. .PHONY: format_script format_script: .venv.erplibre/bin/ruff check --select I --fix ./script/ diff --git a/conf/ruff.addons.toml b/conf/ruff.addons.toml new file mode 100644 index 0000000..44fc8d7 --- /dev/null +++ b/conf/ruff.addons.toml @@ -0,0 +1,53 @@ +# Norme OCA pour les MODULES ODOO, calquée sur le gabarit +# oca-addons-repo-template — celui que porte tout dépôt d'addons de la +# communauté, et qu'on lit dans odoo18.0/OCA_OpenUpgrade/.ruff.toml. +# +# OCA a quitté black : ruff-format formate, et ruff remplace d'un coup isort, +# flake8 et pyupgrade. L'outillage d'ERPLibre suit sa propre norme, plus +# étroite — voir .ruff.toml à la racine, et format_python.sh qui aiguille. +# +# Un dépôt d'addons qui porte SON PROPRE .ruff.toml gagne sur ce fichier : +# format_addons.sh le laisse décider chez lui. + +# La série Odoo décide, comme chez OCA, qui fixe une cible par branche. Cette +# valeur n'est qu'un repli : format_addons.sh passe celle de +# .python-odoo-version, la seule qui dise ce qui EXÉCUTE ces modules. +target-version = "py310" +# 88, la largeur d'OCA, et non les 79 de l'outillage. +line-length = 88 + +[lint] +extend-select = [ + "B", # bugbear + "C90", # complexité + "E501", # ligne trop longue + "I", # tri des imports + "UP", # pyupgrade +] + +[lint.per-file-ignores] +# Un __init__.py d'addon liste ses modules : ni inutilisés, ni à trier. +"__init__.py" = ["F401", "I001"] +# Un manifeste est un dictionnaire nu, que bugbear prend pour une expression +# sans effet. +"__manifest__.py" = ["B018"] + +[lint.isort] +# Ce qu'isort ne sait pas faire : « odoo » et « odoo.addons » sont deux +# sections à elles seules, avant le code du module. +section-order = [ + "future", + "standard-library", + "third-party", + "odoo", + "odoo-addons", + "first-party", + "local-folder", +] + +[lint.isort.sections] +"odoo" = ["odoo"] +"odoo-addons" = ["odoo.addons"] + +[lint.mccabe] +max-complexity = 16 diff --git a/script/maintenance/format_addons.sh b/script/maintenance/format_addons.sh new file mode 100755 index 0000000..0962ace --- /dev/null +++ b/script/maintenance/format_addons.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +# +# Formate un dépôt d'addons NOMMÉ, où qu'il soit rapatrié, et se tait s'il +# n'est pas là. +# +# Le chemin dépend du manifeste : la production range les addons sous +# « odoo/addons/ », le manifeste de développement sous « addons/ ». +# Les cibles du Makefile ne connaissaient que la seconde forme, si bien +# qu'elles lançaient isort sur un chemin inexistant — une trace Python, et +# « make format_all » qui échoue sans avoir rien formaté. +# +# Un dépôt absent n'est pas une erreur : chaque version d'Odoo n'en rapatrie +# qu'une partie. On le dit, et on passe au suivant. +# +# Le formateur est celui de la communauté : ruff, réglé par +# conf/ruff.addons.toml, qui reprend le gabarit oca-addons-repo-template. +# +# format_addons.sh [--xml] ... +# --xml passe aussi prettier sur les vues, quand le dépôt s'y prête + +AVEC_XML=0 +if [[ "$1" == "--xml" ]]; then + AVEC_XML=1 + shift +fi + +VENV="$(xargs < conf/python-erplibre-venv 2> /dev/null)" +ODOO="$(xargs < .odoo-version 2> /dev/null)" + +# La cible de syntaxe suit la SÉRIE, comme chez OCA, qui en fixe une par +# branche : ici celle d'Odoo, lue dans .python-odoo-version. La figer +# rejetterait le code qu'une série récente autorise — Odoo 18 tourne en 3.12, +# où une f-string accepte un backslash que 3.10 refuse. +PY_ODOO="$(xargs < .python-odoo-version 2> /dev/null)" +CIBLE=() +if [[ "${PY_ODOO}" =~ ^([0-9]+)\.([0-9]+) ]]; then + CIBLE=(--target-version "py${BASH_REMATCH[1]}${BASH_REMATCH[2]}") +fi + +for nom in "$@"; do + chemin="" + for candidat in "odoo${ODOO}/addons/${nom}" "addons/${nom}"; do + if [[ -n "${ODOO}" || "${candidat}" == addons/* ]] \ + && [[ -d "${candidat}" ]]; then + chemin="${candidat}" + break + fi + done + if [[ -z "${chemin}" ]]; then + echo "${nom} : absent de ce checkout, ignore." + continue + fi + echo "---- ${chemin} ----" + # La norme du dépôt s'il en porte une — c'est ainsi qu'OCA la distribue —, + # sinon celle du gabarit OCA que ce dépôt-ci conserve. + if [[ -f "${chemin}/.ruff.toml" ]] || [[ -f "${chemin}/pyproject.toml" ]]; then + config=() + else + config=(--config conf/ruff.addons.toml) + fi + # Le tri des imports SEUL : « --select I » remplace la sélection du + # fichier de configuration, dont le lint complet relève d'un autre chantier + # — il rend des centaines d'avertissements, et ses correctifs touchent au + # comportement, pas à la mise en forme. + "./${VENV}/bin/ruff" check "${config[@]}" "${CIBLE[@]}" --select I --fix \ + --quiet "${chemin}/" || exit 1 + "./${VENV}/bin/ruff" format "${config[@]}" "${CIBLE[@]}" --quiet \ + "${chemin}/" || exit 1 + if [[ ${AVEC_XML} -eq 1 ]]; then + ./script/maintenance/prettier_xml.sh "${chemin}/" || exit 1 + fi +done From 44bc1b093c1adf7689293cf5f4e81f578a284d74 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 24 Sep 2026 13:33:36 -0400 Subject: [PATCH 10/17] =?UTF-8?q?[FIX]=20qemu=20:=20reprendre=20un=20t?= =?UTF-8?q?=C3=A9l=C3=A9chargement=20d'image=20coup=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une image de VM pèse un demi-gigaoctet : une coupure y est bien plus probable que sur une page. Le contrôle de complétude existait — sans lui un .part tronqué passait pour une image, donnant un qcow2 valide mais VIDE — mais il jetait les octets reçus et renvoyait l'utilisateur à un « curl -C - » tapé à la main. Le transfert se reprend désormais sur le même miroir, trois essais, par un « Range » qui demande la suite ; on ne change de miroir qu'ensuite. Un serveur qui ignore le Range rend le fichier entier, et l'on repart alors de zéro plutôt que de doubler les octets déjà là. Vérifié : 5 tests contre un serveur qui coupe au tiers, image reconstituée à l'octet près, et le .part tronqué part quand les reprises s'épuisent. --- EN --- A VM image weighs half a gigabyte: an interruption is far likelier there than on a page. The completeness check existed — without it a truncated .part passed for an image, giving a valid but EMPTY qcow2 — but it threw away the bytes received and sent the user back to a hand-typed "curl -C -". The transfer now resumes on the same mirror, three attempts, through a "Range" asking for the rest; only then does it change mirror. A server ignoring the Range returns the whole file, and we restart from zero rather than doubling the bytes already there. Checked: 5 tests against a server cutting at one third, image reassembled byte for byte, and the truncated .part goes when the retries run out. Assisted-by: Claude Opus 5 --- script/qemu/deploy_qemu.py | 63 +++++++++++---- test/test_qemu_download_resume.py | 128 ++++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+), 15 deletions(-) create mode 100644 test/test_qemu_download_resume.py diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index 46ab3e1..8f6a5c2 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -1531,7 +1531,21 @@ def ensure_emulator( DOWNLOAD_TIMEOUT = 30 -def _download_one(url: str, tmp: Path, timeout: int) -> None: +# Reprises d'un transfert COUPÉ, sur le même miroir, avant d'en changer. +# Une image de VM pèse un demi-gigaoctet : une coupure y est bien plus +# probable que sur une page, et repartir de zéro à chaque fois peut ne jamais +# aboutir. Trois essais, puis le miroir suivant. +REPRISES_MAX = 3 + + +class TelechargementTronque(OSError): + """Le serveur a annoncé une taille, et la connexion a coupé avant. + + Distinguée d'une erreur réseau ordinaire parce qu'elle se REPREND : les + octets déjà écrits restent bons, et un « Range » demande la suite.""" + + +def _download_one(url: str, tmp: Path, timeout: int, depuis: int = 0) -> None: """Télécharge url -> tmp en streaming, avec timeout et barre de %. Lève une exception en cas d'échec réseau (miroir suivant à essayer).""" is_tty = sys.stdout.isatty() @@ -1568,7 +1582,7 @@ def _download_one(url: str, tmp: Path, timeout: int) -> None: # il était validé comme « complet » -> qcow2 valide mais VIDE (juste # l'en-tête) -> VM qui ne boote pas, et cache empoisonné réutilisé ensuite. if total > 0 and done < total: - raise OSError( + raise TelechargementTronque( f"téléchargement incomplet : {done}/{total} octets reçus " "(connexion interrompue)" ) @@ -1607,19 +1621,38 @@ def download_image( for i, url in enumerate(urls, 1): tag = "" if len(urls) == 1 else f" (miroir {i}/{len(urls)})" print(f" Téléchargement{tag} : {url}", flush=True) - try: - _download_one(url, tmp, timeout) - tmp.replace(dest) - return - except urllib.error.HTTPError as exc: # image absente sur ce miroir - tmp.unlink(missing_ok=True) - had_404 = had_404 or exc.code == 404 - print(f"\n Échec : HTTP {exc.code}", flush=True) - errors.append(f"{url} -> HTTP {exc.code}") - except Exception as exc: # réseau/timeout : miroir suivant - tmp.unlink(missing_ok=True) - print(f"\n Échec : {exc}", flush=True) - errors.append(f"{url} -> {exc}") + # Une coupure se REPREND sur le même miroir avant d'en changer : + # les octets déjà écrits restent bons, et le .part survit entre deux + # essais. Tout autre échec — 404, timeout, réseau — passe au miroir + # suivant sans insister. + for essai in range(1, REPRISES_MAX + 1): + depuis = tmp.stat().st_size if tmp.exists() else 0 + try: + _download_one(url, tmp, timeout, depuis if essai > 1 else 0) + tmp.replace(dest) + return + except TelechargementTronque as exc: + if essai < REPRISES_MAX: + recus = tmp.stat().st_size if tmp.exists() else 0 + print( + f"\n Coupé à {recus} octets, reprise" + f" {essai}/{REPRISES_MAX - 1}...", + flush=True, + ) + continue + tmp.unlink(missing_ok=True) + print(f"\n Échec : {exc}", flush=True) + errors.append(f"{url} -> {exc}") + except urllib.error.HTTPError as exc: # absente de ce miroir + tmp.unlink(missing_ok=True) + had_404 = had_404 or exc.code == 404 + print(f"\n Échec : HTTP {exc.code}", flush=True) + errors.append(f"{url} -> HTTP {exc.code}") + except Exception as exc: # réseau/timeout : miroir suivant + tmp.unlink(missing_ok=True) + print(f"\n Échec : {exc}", flush=True) + errors.append(f"{url} -> {exc}") + break hint = ( "\n Image introuvable (404) : cette version est probablement EOL et" " a été retirée du miroir. Choisissez une version LTS encore" diff --git a/test/test_qemu_download_resume.py b/test/test_qemu_download_resume.py new file mode 100644 index 0000000..ce57e44 --- /dev/null +++ b/test/test_qemu_download_resume.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Un transfert coupé se reprend-il, et le fichier est-il intact ? + +Une image de VM pèse un demi-gigaoctet : une coupure y est bien plus probable +que sur une page, et repartir de zéro à chaque fois peut ne jamais aboutir. Le +contrôle de complétude existait déjà — sans lui un .part tronqué passait pour +une image, donnant un qcow2 valide mais VIDE, et une VM qui ne démarre pas — +mais il jetait les octets reçus. + +Deux pièges que ces tests gardent : + +- les octets déjà écrits sont CONSERVÉS entre deux essais, et la suite est + demandée par « Range ». Les jeter rendrait chaque reprise aussi longue que + le premier essai ; +- un serveur qui IGNORE « Range » rend 200 et le fichier entier : il faut + alors repartir de zéro, sans quoi les octets déjà là seraient doublés et + l'image illisible. +""" + +import http.server +import sys +import tempfile +import threading +import unittest +from pathlib import Path + +RACINE = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(RACINE)) + +from script.qemu import deploy_qemu as d # noqa: E402 + +CONTENU = b"".join(bytes([i % 251]) for i in range(60_000)) + + +class Serveur: + """Un serveur qui coupe ses `coupures` premiers transferts au tiers.""" + + def __init__(self, coupures, honore_range=True): + self.restantes = coupures + self.honore_range = honore_range + self.demandes = [] + contexte = self + + class Poignee(http.server.BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, *args): + pass + + def do_GET(self): + rng = self.headers.get("Range") + contexte.demandes.append(rng) + debut = 0 + if rng and contexte.honore_range: + debut = int(rng.split("=")[1].split("-")[0]) + self.send_response(206) + self.send_header( + "Content-Range", + f"bytes {debut}-{len(CONTENU) - 1}/{len(CONTENU)}", + ) + else: + self.send_response(200) + reste = CONTENU[debut:] + self.send_header("Content-Length", str(len(reste))) + self.end_headers() + if contexte.restantes > 0: + contexte.restantes -= 1 + self.wfile.write(reste[: len(reste) // 3]) + else: + self.wfile.write(reste) + + self.httpd = http.server.HTTPServer(("127.0.0.1", 0), Poignee) + threading.Thread(target=self.httpd.serve_forever, daemon=True).start() + + @property + def url(self): + return f"http://127.0.0.1:{self.httpd.server_address[1]}/image.qcow2" + + def arrete(self): + self.httpd.shutdown() + + +class TestLaReprise(unittest.TestCase): + def telecharge(self, serveur): + self.addCleanup(serveur.arrete) + with tempfile.TemporaryDirectory() as coin: + dest = Path(coin) / "image.qcow2" + d.download_image([serveur.url], dest, dry_run=False, timeout=10) + return dest.read_bytes() + + def test_une_coupure_est_reprise(self): + serveur = Serveur(coupures=1) + self.assertEqual(CONTENU, self.telecharge(serveur)) + + def test_deux_coupures_sont_reprises(self): + serveur = Serveur(coupures=2) + self.assertEqual(CONTENU, self.telecharge(serveur)) + + def test_la_suite_est_demandee_par_range(self): + """Sans Range, la reprise retéléchargerait tout.""" + serveur = Serveur(coupures=1) + self.telecharge(serveur) + self.assertIsNone(serveur.demandes[0]) + self.assertTrue(serveur.demandes[1].startswith("bytes=")) + + def test_un_serveur_qui_ignore_range_repart_de_zero(self): + """Sinon les octets déjà là seraient doublés, et l'image illisible.""" + serveur = Serveur(coupures=1, honore_range=False) + self.assertEqual(CONTENU, self.telecharge(serveur)) + + def test_au_dela_des_reprises_le_miroir_suivant(self): + """Trois essais, puis on change de miroir plutôt que d'insister.""" + serveur = Serveur(coupures=d.REPRISES_MAX) + self.addCleanup(serveur.arrete) + with tempfile.TemporaryDirectory() as coin: + dest = Path(coin) / "image.qcow2" + with self.assertRaises(SystemExit): + d.download_image([serveur.url], dest, False, timeout=10) + self.assertFalse( + dest.with_suffix(dest.suffix + ".part").exists(), + "le .part tronqué doit partir, il empoisonnerait le cache", + ) + + +if __name__ == "__main__": + unittest.main() From 1342117e2582d4393ce0232bffc3969fbecafdb5 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 24 Sep 2026 13:33:49 -0400 Subject: [PATCH 11/17] =?UTF-8?q?[FIX]=20cache=20qemu=20:=20ne=20d=C3=A9si?= =?UTF-8?q?gner=20un=20faisceau=20que=20s'il=20existe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les variables du cache — PIP_CERT et ses voisines — recevaient le chemin canonique de la famille de paquets, sans que rien ne vérifie qu'il soit là. Une image peut ne pas le porter : sur une Fedora récente, « /etc/pki/tls/certs/ca-bundle.crt » manque alors que le faisceau extrait existe. pip refuse alors TOUT téléchargement, y compris ce qui n'a rien à voir avec le cache, et l'installation s'arrête sur des paquets publics. Le faisceau de la famille est essayé d'abord, les autres connus ensuite ; aucun trouvé n'écrit aucune variable, et pip garde son propre jeu de certificats. Vérifié : la commande produite parcourt les quatre chemins avant de renoncer. --- EN --- The cache variables — PIP_CERT and its neighbours — received the canonical path of the package family, with nothing checking it was there. An image may not carry it: on a recent Fedora, "/etc/pki/tls/certs/ca-bundle.crt" is missing while the extracted bundle exists. pip then refuses EVERY download, including what has nothing to do with the cache, and the install stops on public packages. The family bundle is tried first, the other known ones next; none found writes no variable, and pip keeps its own certificate set. Checked: the produced command walks the four paths before giving up. Assisted-by: Claude Opus 5 --- script/qemu/deploy_qemu.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index 8f6a5c2..aec00af 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -3284,6 +3284,18 @@ CACHE_TRUST = { # disparaît alors que la VM garde sa variable. CACHE_ENV_VARS = ("PIP_CERT", "REQUESTS_CA_BUNDLE", "NODE_EXTRA_CA_CERTS") +# Les faisceaux connus, essayés dans cet ordre quand celui de la famille +# manque. Une image ne porte pas toujours le chemin canonique de sa +# distribution : sur une Fedora récente, « /etc/pki/tls/certs/ca-bundle.crt » +# peut ne pas exister alors que le faisceau extrait, lui, est là. Or une +# variable qui vise un fichier ABSENT fait échouer pip sur « Could not find a +# suitable TLS CA certificate bundle » — tout casse, au lieu de rien. +CA_BUNDLE_CANDIDATS = ( + "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", + "/etc/ssl/certs/ca-certificates.crt", + "/etc/ssl/ca-bundle.pem", +) + # Ce qu'une VM déployée l'amont du cache coupé reçoit en plus. L'audit de npm # interroge un service distant qu'aucun cache ne peut rejouer : hors ligne il # échoue à chaque installation sans rien vérifier. La variable reste dans la @@ -3641,10 +3653,15 @@ def cache_commands(args: argparse.Namespace) -> list[str]: return nix_trust_commands() _, commande, faisceau = CACHE_TRUST[famille] commandes = [f"{commande} || true"] + # Le faisceau de la famille d'abord, les autres connus ensuite : aucun + # trouvé, aucune variable écrite, et pip garde alors son propre jeu de + # certificats plutôt que de refuser tout téléchargement. + candidats = " ".join(dict.fromkeys((faisceau,) + CA_BUNDLE_CANDIDATS)) for var in CACHE_ENV_VARS: commandes.append( - f"sh -c 'grep -q ^{var}= /etc/environment" - f" || echo {var}={faisceau} >> /etc/environment'" + f'sh -c \'for f in {candidats}; do [ -r "$f" ] || continue;' + f" grep -q ^{var}= /etc/environment" + f" || echo {var}=$f >> /etc/environment; break; done'" ) gardees = list(CACHE_ENV_VARS) if getattr(args, "offline", False): From e39221af017379a9177b1c2a768bdbb3962a282d Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 24 Sep 2026 13:34:04 -0400 Subject: [PATCH 12/17] [REM] qemu : abandonner Debian 11 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Son LTS est terminé, et sa suite de sécurité n'est ni servie ni archivée : security.debian.org publie encore un index qui nomme des paquets dont le pool ne porte plus le fichier, et archive.debian.org ne connaît pas bullseye. apt s'arrête donc avant d'installer git, sur une version qui ne recevra plus rien. Elle quitte le catalogue de déploiement et le script de dépendances, où la variable qui la distinguait n'a plus d'usage. Debian 12 et 13 restent, et l'image de la 13 est toujours le dernier point de version — sans rien à changer ici quand le suivant paraît. Vérifié : plus aucune trace de bullseye dans script/, hors la base des conteneurs, traitée à part. --- EN --- Its LTS has ended, and its security suite is neither served nor archived: security.debian.org still publishes an index naming packages whose pool no longer holds the file, and archive.debian.org does not know bullseye. apt therefore stops before installing git, on a version that will receive nothing more. It leaves the deployment catalogue and the dependency script, where the variable telling it apart has no use left. Debian 12 and 13 stay, and 13's image is always the latest point release — with nothing to change here when the next one appears. Checked: no trace of bullseye left in script/, beyond the container base, handled separately. Assisted-by: Claude Opus 5 --- CHANGELOG.base.md | 2 ++ CHANGELOG.fr.md | 1 + CHANGELOG.md | 1 + script/install/install_debian_dependency.sh | 14 ++------- script/qemu/deploy_qemu.py | 34 ++++++++++++--------- script/todo/qemu_deploy.py | 20 +++--------- script/todo/qemu_menu.py | 2 +- 7 files changed, 32 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.base.md b/CHANGELOG.base.md index 6aec6a6..38ecb59 100644 --- a/CHANGELOG.base.md +++ b/CHANGELOG.base.md @@ -160,6 +160,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `.venv.erplibre` on an incompatible Python is DELETED and rebuilt; whatever was installed in it by hand goes with it. Odoo's venv is kept when it merely differs in version, and rebuilt only when it is unusable: rebuilding it redoes a whole Poetry install. A directory without `pyvenv.cfg` is never deleted - `make` and `make todo` go through `install.sh`, which picks an interpreter able to READ the code before running it: `.venv.erplibre` on the right version, else a recent enough system `python3`, else the install. A system older than `conf/python-erplibre-version` would otherwise stop on a syntax error raised before any guard could name the command to type. TODO then relaunches itself in `.venv.erplibre`, or offers to run `install_erplibre.sh` in a terminal - The production Docker image builds `.venv.erplibre` on Odoo's Python and stops when that Python cannot parse `script/` +- Debian 11 is dropped from the deployment catalogue: its LTS ended, and its security suite is neither served nor archived — the index it still publishes names packages whose pool no longer holds the file, so apt stops before installing git. Debian 13 takes its place, its cloud image always being the latest point release - The PATCH bounds only Odoo's venv, whose pyproject requires `>=3.12.10,<3.13`. For the tooling one, the major.minor is enough: requiring the patch turned away a distribution's Python one step behind — NixOS 25.11 ships 3.14.2 where conf asks 3.14.7 — and made pyenv COMPILE CPython for a difference nothing needs - `make format` picks the formatter from each file's context: an Odoo module keeps isort and black on `py37`, the series still supported going down that far, while this repository's own tooling goes through ruff, configured once in `.ruff.toml`. ruff follows CPython's versions, where black 24.8.0 stops at `py313`, and its import sorting replaces isort; it is also what the OCA standard uses since it left black - The repositories that Google Repo checks out under `script/` are excluded from that formatting, a named path included: reformatting them would write in someone else's history. `target-version` stays at `py310` there, because the git hooks carry `#!/usr/bin/env python3` and a distribution still ships 3.10 — from 3.14 on, ruff would write `except A, B:` without parentheses @@ -183,6 +184,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `.venv.erplibre` sur un Python incompatible est SUPPRIMÉ puis rebâti ; ce qu'on y avait posé à la main part avec lui. Le venv d'Odoo est conservé quand seule sa version diffère, et rebâti seulement s'il est hors service : le rebâtir refait une installation Poetry entière. Un répertoire sans `pyvenv.cfg` n'est jamais effacé - `make` et `make todo` passent par `install.sh`, qui choisit un interpréteur capable de LIRE le code avant de le lancer : `.venv.erplibre` s'il porte la bonne version, sinon le `python3` du système s'il est assez récent, sinon l'installation. Un système plus ancien que `conf/python-erplibre-version` s'arrêterait autrement sur une erreur de syntaxe levée avant qu'aucun garde puisse nommer la commande à taper. TODO se relance ensuite dans `.venv.erplibre`, ou propose de lancer `install_erplibre.sh` en terminal - L'image Docker de production bâtit `.venv.erplibre` sur le Python d'Odoo et s'arrête quand ce Python ne sait pas lire `script/` +- Debian 11 quitte le catalogue de déploiement : son LTS est terminé, et sa suite de sécurité n'est ni servie ni archivée — l'index qu'elle publie encore nomme des paquets dont le pool ne porte plus le fichier, et apt s'arrête avant d'installer git. Debian 13 la remplace, son image cloud étant toujours le dernier point de version - Le PATCH ne borne que le venv d'Odoo, dont le pyproject exige `>=3.12.10,<3.13`. Pour celui de l'outillage, la majeure.mineure suffit : exiger le patch écartait le Python d'une distribution d'un cran en retard — NixOS 25.11 livre 3.14.2 quand conf demande 3.14.7 — et faisait COMPILER CPython à pyenv pour une différence que rien ne réclame - `make format` choisit le formateur d'après le contexte de chaque fichier : un module Odoo garde isort et black en `py37`, la série la plus ancienne encore supportée, quand l'outillage de ce dépôt passe par ruff, réglé une fois dans `.ruff.toml`. ruff suit les versions de CPython, là où black 24.8.0 s'arrête à `py313`, et son tri d'imports remplace isort ; c'est aussi ce qu'emploie la norme OCA depuis qu'elle a quitté black - Les dépôts que Google Repo rapatrie sous `script/` sont écartés de ce formatage, chemin nommé compris : les reformater écrirait dans l'historique d'autrui. `target-version` y reste à `py310`, parce que les hooks git portent `#!/usr/bin/env python3` et qu'une distribution livre encore 3.10 — à partir de 3.14, ruff écrirait `except A, B:` sans parenthèses diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 8f7c503..25fef03 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -82,6 +82,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `.venv.erplibre` sur un Python incompatible est SUPPRIMÉ puis rebâti ; ce qu'on y avait posé à la main part avec lui. Le venv d'Odoo est conservé quand seule sa version diffère, et rebâti seulement s'il est hors service : le rebâtir refait une installation Poetry entière. Un répertoire sans `pyvenv.cfg` n'est jamais effacé - `make` et `make todo` passent par `install.sh`, qui choisit un interpréteur capable de LIRE le code avant de le lancer : `.venv.erplibre` s'il porte la bonne version, sinon le `python3` du système s'il est assez récent, sinon l'installation. Un système plus ancien que `conf/python-erplibre-version` s'arrêterait autrement sur une erreur de syntaxe levée avant qu'aucun garde puisse nommer la commande à taper. TODO se relance ensuite dans `.venv.erplibre`, ou propose de lancer `install_erplibre.sh` en terminal - L'image Docker de production bâtit `.venv.erplibre` sur le Python d'Odoo et s'arrête quand ce Python ne sait pas lire `script/` +- Debian 11 quitte le catalogue de déploiement : son LTS est terminé, et sa suite de sécurité n'est ni servie ni archivée — l'index qu'elle publie encore nomme des paquets dont le pool ne porte plus le fichier, et apt s'arrête avant d'installer git. Debian 13 la remplace, son image cloud étant toujours le dernier point de version - Le PATCH ne borne que le venv d'Odoo, dont le pyproject exige `>=3.12.10,<3.13`. Pour celui de l'outillage, la majeure.mineure suffit : exiger le patch écartait le Python d'une distribution d'un cran en retard — NixOS 25.11 livre 3.14.2 quand conf demande 3.14.7 — et faisait COMPILER CPython à pyenv pour une différence que rien ne réclame - `make format` choisit le formateur d'après le contexte de chaque fichier : un module Odoo garde isort et black en `py37`, la série la plus ancienne encore supportée, quand l'outillage de ce dépôt passe par ruff, réglé une fois dans `.ruff.toml`. ruff suit les versions de CPython, là où black 24.8.0 s'arrête à `py313`, et son tri d'imports remplace isort ; c'est aussi ce qu'emploie la norme OCA depuis qu'elle a quitté black - Les dépôts que Google Repo rapatrie sous `script/` sont écartés de ce formatage, chemin nommé compris : les reformater écrirait dans l'historique d'autrui. `target-version` y reste à `py310`, parce que les hooks git portent `#!/usr/bin/env python3` et qu'une distribution livre encore 3.10 — à partir de 3.14, ruff écrirait `except A, B:` sans parenthèses diff --git a/CHANGELOG.md b/CHANGELOG.md index 8973cd1..eb4e25f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,6 +82,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `.venv.erplibre` on an incompatible Python is DELETED and rebuilt; whatever was installed in it by hand goes with it. Odoo's venv is kept when it merely differs in version, and rebuilt only when it is unusable: rebuilding it redoes a whole Poetry install. A directory without `pyvenv.cfg` is never deleted - `make` and `make todo` go through `install.sh`, which picks an interpreter able to READ the code before running it: `.venv.erplibre` on the right version, else a recent enough system `python3`, else the install. A system older than `conf/python-erplibre-version` would otherwise stop on a syntax error raised before any guard could name the command to type. TODO then relaunches itself in `.venv.erplibre`, or offers to run `install_erplibre.sh` in a terminal - The production Docker image builds `.venv.erplibre` on Odoo's Python and stops when that Python cannot parse `script/` +- Debian 11 is dropped from the deployment catalogue: its LTS ended, and its security suite is neither served nor archived — the index it still publishes names packages whose pool no longer holds the file, so apt stops before installing git. Debian 13 takes its place, its cloud image always being the latest point release - The PATCH bounds only Odoo's venv, whose pyproject requires `>=3.12.10,<3.13`. For the tooling one, the major.minor is enough: requiring the patch turned away a distribution's Python one step behind — NixOS 25.11 ships 3.14.2 where conf asks 3.14.7 — and made pyenv COMPILE CPython for a difference nothing needs - `make format` picks the formatter from each file's context: an Odoo module keeps isort and black on `py37`, the series still supported going down that far, while this repository's own tooling goes through ruff, configured once in `.ruff.toml`. ruff follows CPython's versions, where black 24.8.0 stops at `py313`, and its import sorting replaces isort; it is also what the OCA standard uses since it left black - The repositories that Google Repo checks out under `script/` are excluded from that formatting, a named path included: reformatting them would write in someone else's history. `target-version` stays at `py310` there, because the git hooks carry `#!/usr/bin/env python3` and a distribution still ships 3.10 — from 3.14 on, ruff would write `except A, B:` without parentheses diff --git a/script/install/install_debian_dependency.sh b/script/install/install_debian_dependency.sh index 908f523..480950a 100755 --- a/script/install/install_debian_dependency.sh +++ b/script/install/install_debian_dependency.sh @@ -33,14 +33,12 @@ if [[ -r /etc/os-release ]]; then # Sous-shell : « source » importerait NAME, PRETTY_NAME et le reste dans # un script qui n'en veut pas. UBUNTU_VERSION=$(. /etc/os-release && echo "${VERSION_ID}") - DEBIAN_VERSION=$(. /etc/os-release && echo "${VERSION_CODENAME}") OS=$(. /etc/os-release && echo "${ID}") # lsb_release rend « Ubuntu » et « Debian » ; os-release rend « ubuntu » et # « debian ». Les comparaisons plus bas attendent la première forme. OS="${OS^}" else UBUNTU_VERSION=$(lsb_release -rs) - DEBIAN_VERSION=$(lsb_release -cs) OS=$(lsb_release -si) fi @@ -73,15 +71,9 @@ elif [[ "${OS}" == "Linuxmint" ]]; then # gdebi etait appele sans fichier. Mint 22.x repose sur noble : meme .deb. WKHTMLTOX_X64=https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-3/wkhtmltox_0.12.6.1-3.jammy_amd64.deb elif [[ "${OS}" == "Debian" ]]; then - if [ "bullseye" == "${DEBIAN_VERSION}" ]; then - WKHTMLTOX_X64=https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-3/wkhtmltox_0.12.6.1-3.bullseye_amd64.deb - else - # bookworm (12), trixie (13) et au-delà : wkhtmltopdf ne publie pas de - # build au-delà de « bookworm » -> on prend bookworm (le plus récent). - # Le build « bullseye » (Debian 11) échouait à s'installer sur trixie - # (gdebi : dépendances incompatibles). - WKHTMLTOX_X64=https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-3/wkhtmltox_0.12.6.1-3.bookworm_amd64.deb - fi + # bookworm (12), trixie (13) et au-delà : wkhtmltopdf ne publie pas de build + # au-delà de « bookworm » -> on prend bookworm, le plus récent. + WKHTMLTOX_X64=https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-3/wkhtmltox_0.12.6.1-3.bookworm_amd64.deb elif [[ "${OS}" == *"Ubuntu"* ]]; then echo "Your version of Ubuntu is not supported, only support 24.04, 25.10 and 26.04" WKHTMLTOX_X64=https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-3/wkhtmltox_0.12.6.1-3.jammy_amd64.deb diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index aec00af..c76dc93 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -43,6 +43,7 @@ Exemples sudo ./script/qemu/deploy_qemu.py /var/lib/libvirt/images/iso/noble.img --name test-vm --memory 8192 --vcpus 8 --disk-size 120G --ask-password --force """ + from __future__ import annotations import argparse @@ -100,7 +101,6 @@ UBUNTU_VERSIONS: dict[str, tuple[str, str, int, str]] = { "26.04": ("resolute", "ubuntu26.04", 3072, "20G"), } DEBIAN_VERSIONS: dict[str, tuple[str, str, int, str]] = { - "11": ("bullseye", "debian11", 1024, "20G"), "12": ("bookworm", "debian12", 1024, "20G"), "13": ("trixie", "debian13", 1024, "20G"), } @@ -238,8 +238,7 @@ S390X_DISTROS: tuple[str, ...] = ( # miroirs tiers. Seule la 43 est servie par dl.fedoraproject.org — vérifié. ARCH_ONLY_VERSIONS: dict[str, dict[str, tuple[str, ...]]] = { # Debian sur s390x passe par debian-installer, dont les images sont - # publiées pour bookworm et trixie — vérifié. bullseye est écartée : elle - # est en fin de vie et son installateur n'a pas été éprouvé ici. + # publiées pour bookworm et trixie — vérifié. "s390x": {"fedora": ("43",), "debian": ("12", "13")}, } @@ -596,9 +595,7 @@ def resolve_fedora_url(version: str, arch: str, dry_run: bool) -> str: for base in bases: index = f"{base}/{version}/Cloud/{a}/images/" try: - with urllib.request.urlopen( - index, timeout=30 - ) as resp: # noqa: S310 + with urllib.request.urlopen(index, timeout=30) as resp: # noqa: S310 html = resp.read().decode(errors="replace") except Exception as exc: # pragma: no cover - dépend du réseau last_err = str(exc) @@ -1128,8 +1125,7 @@ def ensure_libvirt_service(runner: Runner) -> None: return if shutil.which("systemctl"): print( - " Démarrage du démon libvirt" - " (systemctl enable --now libvirtd)…" + " Démarrage du démon libvirt (systemctl enable --now libvirtd)…" ) runner.run( ["systemctl", "enable", "--now", "libvirtd"], @@ -1547,16 +1543,26 @@ class TelechargementTronque(OSError): def _download_one(url: str, tmp: Path, timeout: int, depuis: int = 0) -> None: """Télécharge url -> tmp en streaming, avec timeout et barre de %. - Lève une exception en cas d'échec réseau (miroir suivant à essayer).""" + + `depuis` reprend un .part laissé par une coupure. Lève une exception en + cas d'échec réseau (miroir suivant à essayer).""" is_tty = sys.stdout.isatty() last_pct = -1 - req = urllib.request.Request( - url, headers={"User-Agent": "erplibre-qemu-deploy"} - ) + entetes = {"User-Agent": "erplibre-qemu-deploy"} + if depuis > 0: + entetes["Range"] = f"bytes={depuis}-" + req = urllib.request.Request(url, headers=entetes) with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 + # Un serveur qui IGNORE le Range rend 200 et le fichier ENTIER : on + # repart alors de zéro, sans quoi les octets déjà là seraient doublés + # et l'image illisible. + reprise = depuis > 0 and getattr(resp, "status", 200) == 206 + done = depuis if reprise else 0 total = int(resp.headers.get("Content-Length", 0) or 0) - done = 0 - with open(tmp, "wb") as fh: + if total > 0: + # En reprise, l'en-tête ne compte que ce qui RESTE. + total += done + with open(tmp, "ab" if reprise else "wb") as fh: while True: chunk = resp.read(1 << 16) if not chunk: diff --git a/script/todo/qemu_deploy.py b/script/todo/qemu_deploy.py index 0d0a847..772b48d 100644 --- a/script/todo/qemu_deploy.py +++ b/script/todo/qemu_deploy.py @@ -160,7 +160,8 @@ class QemuDeployMixin: chain.append(f"{{ {after_cmd} }}") install_chain = " && ".join(chain) return ( - "set -e; " + self._qemu_cloud_init_wait() + "set -e; " + + self._qemu_cloud_init_wait() # Coupé AVANT les apt-get ci-dessous : sinon apt-daily peut reprendre # le verrou entre l'attente cloud-init et l'installation. + no_auto_upgrade @@ -187,17 +188,6 @@ class QemuDeployMixin: # retarderait le démarrage sans laisser de trace dans le suivi. f"PKGS='curl git make{self._qemu_editor_suffix()}'; " "if command -v apt-get >/dev/null 2>&1; then " - # Au 1er boot, cloud-init (install qemu-guest-agent) et/ou - # apt-daily.service tiennent le verrou apt. IMPORTANT : - # « DPkg::Lock::Timeout » NE couvre PAS le verrou - # /var/lib/apt/lists/lock -> « apt-get update » échouait AUSSITÔT - # (« Could not get lock … lists/lock ») -> lists vides -> « Unable - # to locate package git ». On RÉESSAIE donc update jusqu'à ce que - # le verrou se libère (et les lists soient peuplées), borné à ~5 min. - # Bornée par le TEMPS : trente essais valent cinq minutes quand - # chacun échoue en une seconde sur un verrou, mais des heures - # quand le cache répond 504 sur chaque index et qu'un essai dure - # des minutes. "fin=$(( $(date +%s) + 300 )); " "until sudo apt-get -o DPkg::Lock::Timeout=120 update -qq; do " '[ "$(date +%s)" -ge "$fin" ] && break; ' @@ -276,7 +266,7 @@ class QemuDeployMixin: # peu : l'installation choisit ensuite le Python d'Odoo, que le # module déclare et que le profil du système porte, cherché avant # celui de l'utilisateur. - + "elif command -v nix-env >/dev/null 2>&1; then " + + "elif command -v nix-env >/dev/null 2>&1; then " "nix-env -f '' -iA git gnumake curl python3" f"{self._qemu_editor_suffix()}; " # Le PATH de cette commande distante a été figé à l'ouverture du @@ -585,9 +575,7 @@ class QemuDeployMixin: "-o ConnectTimeout=15" ) cmd = f"ssh {ssh_opts} erplibre@{ip} {shlex.quote(remote)}" - print( - f"\n 📦 {name} ({ip}): {t('installing ERPLibre')} " f"({branch})" - ) + print(f"\n 📦 {name} ({ip}): {t('installing ERPLibre')} ({branch})") print(f" {t('Will execute:')} {cmd}") self.execute.exec_command_live(cmd, source_erplibre=False) diff --git a/script/todo/qemu_menu.py b/script/todo/qemu_menu.py index fe2f92b..42a5f9c 100644 --- a/script/todo/qemu_menu.py +++ b/script/todo/qemu_menu.py @@ -37,7 +37,7 @@ class QemuMenuMixin: # deploy_qemu.py ; ceci ne sert qu'au sélecteur interactif. _QEMU_DISTROS = { "ubuntu": (["24.04", "25.10", "26.04"], "24.04"), - "debian": (["11", "12", "13"], "12"), + "debian": (["12", "13"], "12"), "fedora": (["41", "42", "43", "44"], "42"), "almalinux": (["9", "10"], "9"), "rocky": (["9", "10"], "10"), From a1f10659625daa849347019690bf333eb59f2b41 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 24 Sep 2026 13:34:22 -0400 Subject: [PATCH 13/17] [UPD] docker : une seule base, bookworm, pour toutes les versions d'Odoo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Odoo 13, 14 et 15 bâtissaient encore sur bullseye, dont le dépôt de sécurité est démantelé. Rien ne les y obligeait : la base est « python:-slim- », donc l'interpréteur vient de l'image officielle et non de Debian — les variantes bookworm existent jusqu'à 3.7.17. L'aiguillage portait en outre une branche « buster » INATTEIGNABLE, sa condition étant la négation de celle qui la précédait ; elle part avec le reste. Le build de wkhtmltopdf suit la version : celui de bullseye réclame libssl1.1, absente de bookworm. Vérifié : les images 3.7.17 et 3.8.20 slim-bookworm existent, le .deb bookworm rend l'empreinte déclarée, et ses quinze dépendances sont dans bookworm. --- EN --- Odoo 13, 14 and 15 still built on bullseye, whose security repository is dismantled. Nothing required it: the base is "python:-slim-", so the interpreter comes from the official image and not from Debian — the bookworm variants exist down to 3.7.17. The switch also carried an UNREACHABLE "buster" branch, its condition being the negation of the one before it; it goes with the rest. The wkhtmltopdf build follows the suite: bullseye's requires libssl1.1, absent from bookworm. Checked: the 3.7.17 and 3.8.20 slim-bookworm images exist, the bookworm .deb matches the declared checksum, and its fifteen dependencies are in bookworm. Assisted-by: Claude Opus 5 --- CHANGELOG.base.md | 2 + CHANGELOG.fr.md | 1 + CHANGELOG.md | 1 + script/docker/docker_build.sh | 30 +++++------- test/test_docker_base_bookworm.py | 81 +++++++++++++++++++++++++++++++ 5 files changed, 97 insertions(+), 18 deletions(-) create mode 100644 test/test_docker_base_bookworm.py diff --git a/CHANGELOG.base.md b/CHANGELOG.base.md index 38ecb59..bba60ed 100644 --- a/CHANGELOG.base.md +++ b/CHANGELOG.base.md @@ -161,6 +161,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `make` and `make todo` go through `install.sh`, which picks an interpreter able to READ the code before running it: `.venv.erplibre` on the right version, else a recent enough system `python3`, else the install. A system older than `conf/python-erplibre-version` would otherwise stop on a syntax error raised before any guard could name the command to type. TODO then relaunches itself in `.venv.erplibre`, or offers to run `install_erplibre.sh` in a terminal - The production Docker image builds `.venv.erplibre` on Odoo's Python and stops when that Python cannot parse `script/` - Debian 11 is dropped from the deployment catalogue: its LTS ended, and its security suite is neither served nor archived — the index it still publishes names packages whose pool no longer holds the file, so apt stops before installing git. Debian 13 takes its place, its cloud image always being the latest point release +- Every container image is built on bookworm, whatever the Odoo version. The base is `python:-slim-`: the interpreter comes from the official image, never from Debian, and the bookworm variants exist down to 3.7.17. The wkhtmltopdf build follows the suite — bullseye's requires libssl1.1, absent from bookworm - The PATCH bounds only Odoo's venv, whose pyproject requires `>=3.12.10,<3.13`. For the tooling one, the major.minor is enough: requiring the patch turned away a distribution's Python one step behind — NixOS 25.11 ships 3.14.2 where conf asks 3.14.7 — and made pyenv COMPILE CPython for a difference nothing needs - `make format` picks the formatter from each file's context: an Odoo module keeps isort and black on `py37`, the series still supported going down that far, while this repository's own tooling goes through ruff, configured once in `.ruff.toml`. ruff follows CPython's versions, where black 24.8.0 stops at `py313`, and its import sorting replaces isort; it is also what the OCA standard uses since it left black - The repositories that Google Repo checks out under `script/` are excluded from that formatting, a named path included: reformatting them would write in someone else's history. `target-version` stays at `py310` there, because the git hooks carry `#!/usr/bin/env python3` and a distribution still ships 3.10 — from 3.14 on, ruff would write `except A, B:` without parentheses @@ -185,6 +186,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `make` et `make todo` passent par `install.sh`, qui choisit un interpréteur capable de LIRE le code avant de le lancer : `.venv.erplibre` s'il porte la bonne version, sinon le `python3` du système s'il est assez récent, sinon l'installation. Un système plus ancien que `conf/python-erplibre-version` s'arrêterait autrement sur une erreur de syntaxe levée avant qu'aucun garde puisse nommer la commande à taper. TODO se relance ensuite dans `.venv.erplibre`, ou propose de lancer `install_erplibre.sh` en terminal - L'image Docker de production bâtit `.venv.erplibre` sur le Python d'Odoo et s'arrête quand ce Python ne sait pas lire `script/` - Debian 11 quitte le catalogue de déploiement : son LTS est terminé, et sa suite de sécurité n'est ni servie ni archivée — l'index qu'elle publie encore nomme des paquets dont le pool ne porte plus le fichier, et apt s'arrête avant d'installer git. Debian 13 la remplace, son image cloud étant toujours le dernier point de version +- Toutes les images de conteneur reposent sur bookworm, quelle que soit la version d'Odoo. La base est `python:-slim-` : l'interpréteur vient de l'image officielle, jamais de Debian, et les variantes bookworm existent jusqu'à 3.7.17. Le build de wkhtmltopdf suit la version — celui de bullseye réclame libssl1.1, absente de bookworm - Le PATCH ne borne que le venv d'Odoo, dont le pyproject exige `>=3.12.10,<3.13`. Pour celui de l'outillage, la majeure.mineure suffit : exiger le patch écartait le Python d'une distribution d'un cran en retard — NixOS 25.11 livre 3.14.2 quand conf demande 3.14.7 — et faisait COMPILER CPython à pyenv pour une différence que rien ne réclame - `make format` choisit le formateur d'après le contexte de chaque fichier : un module Odoo garde isort et black en `py37`, la série la plus ancienne encore supportée, quand l'outillage de ce dépôt passe par ruff, réglé une fois dans `.ruff.toml`. ruff suit les versions de CPython, là où black 24.8.0 s'arrête à `py313`, et son tri d'imports remplace isort ; c'est aussi ce qu'emploie la norme OCA depuis qu'elle a quitté black - Les dépôts que Google Repo rapatrie sous `script/` sont écartés de ce formatage, chemin nommé compris : les reformater écrirait dans l'historique d'autrui. `target-version` y reste à `py310`, parce que les hooks git portent `#!/usr/bin/env python3` et qu'une distribution livre encore 3.10 — à partir de 3.14, ruff écrirait `except A, B:` sans parenthèses diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 25fef03..c59cd77 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -83,6 +83,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `make` et `make todo` passent par `install.sh`, qui choisit un interpréteur capable de LIRE le code avant de le lancer : `.venv.erplibre` s'il porte la bonne version, sinon le `python3` du système s'il est assez récent, sinon l'installation. Un système plus ancien que `conf/python-erplibre-version` s'arrêterait autrement sur une erreur de syntaxe levée avant qu'aucun garde puisse nommer la commande à taper. TODO se relance ensuite dans `.venv.erplibre`, ou propose de lancer `install_erplibre.sh` en terminal - L'image Docker de production bâtit `.venv.erplibre` sur le Python d'Odoo et s'arrête quand ce Python ne sait pas lire `script/` - Debian 11 quitte le catalogue de déploiement : son LTS est terminé, et sa suite de sécurité n'est ni servie ni archivée — l'index qu'elle publie encore nomme des paquets dont le pool ne porte plus le fichier, et apt s'arrête avant d'installer git. Debian 13 la remplace, son image cloud étant toujours le dernier point de version +- Toutes les images de conteneur reposent sur bookworm, quelle que soit la version d'Odoo. La base est `python:-slim-` : l'interpréteur vient de l'image officielle, jamais de Debian, et les variantes bookworm existent jusqu'à 3.7.17. Le build de wkhtmltopdf suit la version — celui de bullseye réclame libssl1.1, absente de bookworm - Le PATCH ne borne que le venv d'Odoo, dont le pyproject exige `>=3.12.10,<3.13`. Pour celui de l'outillage, la majeure.mineure suffit : exiger le patch écartait le Python d'une distribution d'un cran en retard — NixOS 25.11 livre 3.14.2 quand conf demande 3.14.7 — et faisait COMPILER CPython à pyenv pour une différence que rien ne réclame - `make format` choisit le formateur d'après le contexte de chaque fichier : un module Odoo garde isort et black en `py37`, la série la plus ancienne encore supportée, quand l'outillage de ce dépôt passe par ruff, réglé une fois dans `.ruff.toml`. ruff suit les versions de CPython, là où black 24.8.0 s'arrête à `py313`, et son tri d'imports remplace isort ; c'est aussi ce qu'emploie la norme OCA depuis qu'elle a quitté black - Les dépôts que Google Repo rapatrie sous `script/` sont écartés de ce formatage, chemin nommé compris : les reformater écrirait dans l'historique d'autrui. `target-version` y reste à `py310`, parce que les hooks git portent `#!/usr/bin/env python3` et qu'une distribution livre encore 3.10 — à partir de 3.14, ruff écrirait `except A, B:` sans parenthèses diff --git a/CHANGELOG.md b/CHANGELOG.md index eb4e25f..0586dcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,6 +83,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `make` and `make todo` go through `install.sh`, which picks an interpreter able to READ the code before running it: `.venv.erplibre` on the right version, else a recent enough system `python3`, else the install. A system older than `conf/python-erplibre-version` would otherwise stop on a syntax error raised before any guard could name the command to type. TODO then relaunches itself in `.venv.erplibre`, or offers to run `install_erplibre.sh` in a terminal - The production Docker image builds `.venv.erplibre` on Odoo's Python and stops when that Python cannot parse `script/` - Debian 11 is dropped from the deployment catalogue: its LTS ended, and its security suite is neither served nor archived — the index it still publishes names packages whose pool no longer holds the file, so apt stops before installing git. Debian 13 takes its place, its cloud image always being the latest point release +- Every container image is built on bookworm, whatever the Odoo version. The base is `python:-slim-`: the interpreter comes from the official image, never from Debian, and the bookworm variants exist down to 3.7.17. The wkhtmltopdf build follows the suite — bullseye's requires libssl1.1, absent from bookworm - The PATCH bounds only Odoo's venv, whose pyproject requires `>=3.12.10,<3.13`. For the tooling one, the major.minor is enough: requiring the patch turned away a distribution's Python one step behind — NixOS 25.11 ships 3.14.2 where conf asks 3.14.7 — and made pyenv COMPILE CPython for a difference nothing needs - `make format` picks the formatter from each file's context: an Odoo module keeps isort and black on `py37`, the series still supported going down that far, while this repository's own tooling goes through ruff, configured once in `.ruff.toml`. ruff follows CPython's versions, where black 24.8.0 stops at `py313`, and its import sorting replaces isort; it is also what the OCA standard uses since it left black - The repositories that Google Repo checks out under `script/` are excluded from that formatting, a named path included: reformatting them would write in someone else's history. `target-version` stays at `py310` there, because the git hooks carry `#!/usr/bin/env python3` and a distribution still ships 3.10 — from 3.14 on, ruff would write `except A, B:` without parentheses diff --git a/script/docker/docker_build.sh b/script/docker/docker_build.sh index a615fd2..2572666 100755 --- a/script/docker/docker_build.sh +++ b/script/docker/docker_build.sh @@ -9,9 +9,6 @@ ODOO_VERSION=$(cat .odoo-version | xargs) PYTHON_VERSION=$(cat .python-odoo-version | xargs) POETRY_VERSION=$(cat .poetry-version | xargs) -IS_DEBIAN_BOOKWORM=true -IS_DEBIAN_BULLSEYE=true -# or IS_DEBIAN_BUSTER ARGS="" IS_RELEASE=false IS_RELEASE_ALPHA=false @@ -37,20 +34,12 @@ for arg in "$@"; do elif [ "$arg" == "--odoo_16" ]; then output_version=$(python ./script/version/get_version.py --odoo_version 16.0) elif [ "$arg" == "--odoo_15" ]; then - IS_DEBIAN_BOOKWORM=false - IS_DEBIAN_BULLSEYE=false output_version=$(python ./script/version/get_version.py --odoo_version 15.0) elif [ "$arg" == "--odoo_14" ]; then - IS_DEBIAN_BOOKWORM=false - IS_DEBIAN_BULLSEYE=false output_version=$(python ./script/version/get_version.py --odoo_version 14.0) elif [ "$arg" == "--odoo_13" ]; then - IS_DEBIAN_BOOKWORM=false - IS_DEBIAN_BULLSEYE=false output_version=$(python ./script/version/get_version.py --odoo_version 13.0) elif [ "$arg" == "--odoo_12" ]; then - IS_DEBIAN_BOOKWORM=false - IS_DEBIAN_BULLSEYE=false output_version=$(python ./script/version/get_version.py --odoo_version 12.0) fi done @@ -100,13 +89,18 @@ cd docker ARGS="${ARGS} --build-arg WORKING_BRANCH=$(git rev-parse --abbrev-ref HEAD) --build-arg WORKING_HASH=$(git rev-parse --verify HEAD)" -if [ "$IS_DEBIAN_BOOKWORM" == true ]; then - ARGS="${ARGS} --build-arg DEBIAN_NAME=bookworm --build-arg URL_WKHTMLTOX=github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-3/wkhtmltox_0.12.6.1-3.bookworm_amd64.deb --build-arg SHA1SUM_WKTHMLTOX=e9f95436298c77cc9406bd4bbd242f4771d0a4b2" -elif [ "$IS_DEBIAN_BOOKWORM" != true ]; then - ARGS="${ARGS} --build-arg DEBIAN_NAME=bullseye --build-arg URL_WKHTMLTOX=github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-2/wkhtmltox_0.12.6.1-2.bullseye_amd64.deb --build-arg SHA1SUM_WKTHMLTOX=cecbf5a6abbd68d324a7cd6c51ec843d71e98951" -elif [ "$IS_DEBIAN_BULLSEYE" != true ]; then - ARGS="${ARGS} --build-arg DEBIAN_NAME=buster --build-arg URL_WKHTMLTOX=github.com/wkhtmltopdf/packaging/releases/download/0.12.6-1/wkhtmltox_0.12.6-1.buster_amd64.deb --build-arg SHA1SUM_WKTHMLTOX=d9f259a67e05e1c221d48b504453645e6c491fab" -fi +# UNE seule base, bookworm, pour toutes les versions d'Odoo. +# +# La base est « python:-slim- » : le Python vient de l'image +# officielle, jamais de Debian. Changer de nom de version ne change donc pas +# l'interpréteur, et les variantes bookworm existent jusqu'à 3.7.17 — vérifié +# sur le registre. Rien n'obligeait les vieux Odoo à rester sur bullseye, dont +# le dépôt de sécurité est aujourd'hui démantelé. +# +# Le build de wkhtmltopdf suit la version : celui de bullseye réclame +# libssl1.1, absente de bookworm ; celui de bookworm réclame libssl3, et ses +# quinze dépendances y sont toutes — vérifié dans l'index. +ARGS="${ARGS} --build-arg DEBIAN_NAME=bookworm --build-arg URL_WKHTMLTOX=github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-3/wkhtmltox_0.12.6.1-3.bookworm_amd64.deb --build-arg SHA1SUM_WKTHMLTOX=e9f95436298c77cc9406bd4bbd242f4771d0a4b2" set -e # Build base diff --git a/test/test_docker_base_bookworm.py b/test/test_docker_base_bookworm.py new file mode 100644 index 0000000..0613d32 --- /dev/null +++ b/test/test_docker_base_bookworm.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Les conteneurs reposent-ils tous sur la même base ? + +La base est « python:-slim- » : le Python vient +de l'image officielle, jamais de Debian. Changer de nom de version ne change +donc pas l'interpréteur, et rien n'obligeait les vieux Odoo à rester sur +bullseye — dont le dépôt de sécurité est aujourd'hui démantelé. + +Ce que ces tests gardent : + +- une seule base, pour toutes les versions d'Odoo. L'aiguillage précédent + portait une branche « buster » INATTEIGNABLE : sa condition était la + négation de celle qui la précédait ; +- le build de wkhtmltopdf suit la version. Celui de bullseye réclame + libssl1.1, absente de bookworm : un .deb mal apparié s'installe puis ne se + lance pas, et l'impression PDF échoue à l'exécution, pas à la construction ; +- l'empreinte accompagne l'URL. Le Dockerfile la vérifie avant d'installer, + et une URL changée sans son empreinte ferait échouer la construction. +""" + +import re +import unittest +from pathlib import Path + +RACINE = Path(__file__).resolve().parents[1] +SCRIPT = (RACINE / "script/docker/docker_build.sh").read_text(encoding="utf-8") +DOCKERFILE = (RACINE / "docker/Dockerfile.base").read_text(encoding="utf-8") + + +class TestUneSeuleBase(unittest.TestCase): + def test_aucune_version_debian_anterieure(self): + for ancienne in ("bullseye", "buster", "stretch"): + with self.subTest(version=ancienne): + self.assertNotIn(f"DEBIAN_NAME={ancienne}", SCRIPT) + + def test_la_base_est_bookworm(self): + self.assertIn("--build-arg DEBIAN_NAME=bookworm", SCRIPT) + + def test_elle_est_posee_une_seule_fois(self): + """Plusieurs branches redonneraient un aiguillage à entretenir.""" + self.assertEqual(1, SCRIPT.count("--build-arg DEBIAN_NAME=")) + + def test_plus_aucun_drapeau_de_version(self): + self.assertNotIn("IS_DEBIAN_", SCRIPT) + + +class TestWkhtmltopdf(unittest.TestCase): + def test_le_build_suit_la_version_de_la_base(self): + """Le .deb de bullseye réclame libssl1.1, absente de bookworm.""" + urls = re.findall(r"URL_WKHTMLTOX=(\S+)", SCRIPT) + self.assertTrue(urls) + for url in urls: + with self.subTest(url=url): + self.assertIn("bookworm", url) + + def test_l_empreinte_accompagne_l_url(self): + self.assertEqual( + SCRIPT.count("URL_WKHTMLTOX="), + SCRIPT.count("SHA1SUM_WKTHMLTOX="), + ) + + def test_le_dockerfile_verifie_l_empreinte_avant_d_installer(self): + """Sans ce contrôle, une page d'erreur HTML s'installerait comme + un paquet.""" + verif = DOCKERFILE.index("sha1sum -c -") + pose = DOCKERFILE.index( + "apt-get install -y --no-install-recommends ./wkhtmltox.deb" + ) + self.assertLess(verif, pose) + + def test_le_defaut_du_dockerfile_s_accorde_au_script(self): + """Une construction lancée sans argument ne doit pas changer de base.""" + self.assertIn("ARG DEBIAN_NAME=bookworm", DOCKERFILE) + attendue = re.search(r"URL_WKHTMLTOX=(\S+)", SCRIPT).group(1) + self.assertIn(attendue, DOCKERFILE) + + +if __name__ == "__main__": + unittest.main() From cb1ff8dcb2d64222c05e7b496e263d674ea828cf Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 24 Sep 2026 13:34:23 -0400 Subject: [PATCH 14/17] [REM] doc : retirer la recette libssl1.1 de la FAQ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Elle faisait ajouter bullseye-security pour y prendre libssl1.1 sur Debian 13. Ce dépôt n'existe plus : son index est encore publié mais son pool ne porte plus les fichiers, et rien n'en a été archivé. La recette ne pouvait donc que donner une source morte, puis une erreur qui n'en désigne pas la cause. Elle était en outre inutile : le build de wkhtmltopdf qu'on installe là-bas est celui de bookworm, qui réclame libssl3 — que trixie fournit sous le nom libssl3t64, en déclarant « Provides: libssl3 ». L'installation se résout donc seule. La section qui suit garde l'issue de secours, un service en conteneur. Vérifié : plus aucune mention de bullseye ni de libssl1.1 dans la FAQ. --- EN --- It had the reader add bullseye-security to take libssl1.1 from it on Debian 13. That repository is gone: its index is still published but its pool no longer holds the files, and nothing was archived. The recipe could therefore only yield a dead source, then an error naming none of its cause. It was useless besides: the wkhtmltopdf build installed there is bookworm's, which requires libssl3 — which trixie supplies as libssl3t64, declaring "Provides: libssl3". The install resolves on its own. The next section keeps the fallback, a containerised service. Checked: no mention of bullseye or libssl1.1 left in the FAQ. Assisted-by: Claude Opus 5 --- doc/FAQ.base.md | 19 ------------------- doc/FAQ.fr.md | 12 ------------ doc/FAQ.md | 12 ------------ 3 files changed, 43 deletions(-) diff --git a/doc/FAQ.base.md b/doc/FAQ.base.md index e259cc6..279d16d 100644 --- a/doc/FAQ.base.md +++ b/doc/FAQ.base.md @@ -33,25 +33,6 @@ key : report.url value : http://127.0.0.1:8069 ``` - -### wkthmltopdf installation debian 13 and more - -You need libssl1.1, it's deprecated. - - -### Installation de wkthmltopdf sur Debian 13 et plus - -Vous avez besoin de libssl1.1, qui est déprécié. - - -```bash -echo "deb http://security.debian.org/debian-security bullseye-security main" | sudo tee /etc/apt/sources.list.d/bullseye-security.list -sudo apt update -sudo apt install libssl1.1 -sudo rm /etc/apt/sources.list.d/bullseye-security.list -sudo apt update -``` - ### wkthmltopdf installation alternative diff --git a/doc/FAQ.fr.md b/doc/FAQ.fr.md index fa1e05a..8f14c0a 100644 --- a/doc/FAQ.fr.md +++ b/doc/FAQ.fr.md @@ -15,18 +15,6 @@ key : report.url value : http://127.0.0.1:8069 ``` -### Installation de wkthmltopdf sur Debian 13 et plus - -Vous avez besoin de libssl1.1, qui est déprécié. - -```bash -echo "deb http://security.debian.org/debian-security bullseye-security main" | sudo tee /etc/apt/sources.list.d/bullseye-security.list -sudo apt update -sudo apt install libssl1.1 -sudo rm /etc/apt/sources.list.d/bullseye-security.list -sudo apt update -``` - ### Installation alternative de wkthmltopdf Si vous ne pouvez pas installer wkhtmltopdf, vous pouvez exécuter un Docker et mettre à jour la configuration, consultez le dépôt https://github.com/acsone/kwkhtmltopdf diff --git a/doc/FAQ.md b/doc/FAQ.md index 2f325ea..0ae8239 100644 --- a/doc/FAQ.md +++ b/doc/FAQ.md @@ -15,18 +15,6 @@ key : report.url value : http://127.0.0.1:8069 ``` -### wkthmltopdf installation debian 13 and more - -You need libssl1.1, it's deprecated. - -```bash -echo "deb http://security.debian.org/debian-security bullseye-security main" | sudo tee /etc/apt/sources.list.d/bullseye-security.list -sudo apt update -sudo apt install libssl1.1 -sudo rm /etc/apt/sources.list.d/bullseye-security.list -sudo apt update -``` - ### wkthmltopdf installation alternative If you cannot install wkhtmltopdf, you can run a docker and update configuration, check repo https://github.com/acsone/kwkhtmltopdf From d1e1c2a81298d85b7823fe331492e9a13e0a0367 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 24 Sep 2026 13:34:36 -0400 Subject: [PATCH 15/17] [FIX] version : nommer le Python d'Odoo et celui de l'outillage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit « make version » lit .python-odoo-version et l'affichait sous le libellé nu « Python version ». Tant que les deux venvs partageaient leur interpréteur, le libellé suffisait ; depuis qu'ils divergent, il laisse croire que le dépôt n'a qu'une version de Python, et le chiffre affiché n'est pas celui du venv dans lequel l'utilisateur travaille. Les deux sont désormais nommées, celle d'Odoo comme celle de l'outillage. Seule la sortie change : cette valeur ne participe à aucun identifiant, et l'absence du fichier reste sans conséquence. Vérifié : les deux versions s'affichent, puis la conclusion reste la même. --- EN --- "make version" reads .python-odoo-version and printed it under the bare label "Python version". While both venvs shared their interpreter the label was enough; now that they diverge, it suggests the repository has a single Python version, and the figure shown is not that of the venv the user works in. Both are now named, Odoo's and the tooling's. Only the output changes: this value takes part in no identifier, and a missing file stays without consequence. Checked: both versions print, then the conclusion stays the same. Assisted-by: Claude Opus 5 --- script/version/update_env_version.py | 44 ++++++++++++++++++---------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/script/version/update_env_version.py b/script/version/update_env_version.py index def0085..0353b44 100755 --- a/script/version/update_env_version.py +++ b/script/version/update_env_version.py @@ -53,6 +53,10 @@ INSTALLED_ODOO_VERSION_FILE = ".repo/installed_odoo_version.txt" VERSION_ERPLIBRE_FILE = os.path.join(".erplibre-version") VERSION_ODOO_FILE = os.path.join(".odoo-version") VERSION_POETRY_FILE = os.path.join(".poetry-version") +# Le Python de l'OUTILLAGE, distinct de celui d'Odoo depuis que .venv.erplibre +# ne partage plus son interpréteur : il ne participe à aucun identifiant de +# version, il n'est là que pour être dit. +VERSION_PYTHON_ERPLIBRE_FILE = os.path.join("conf", "python-erplibre-version") ADDONS_PATH = os.path.join("addons") MOBILE_PATH = os.path.join("mobile", "erplibre_home_mobile") VENV_TEMPLATE_FILE = ".venv.%s" @@ -232,7 +236,14 @@ class Update: self.mobile_active = os.path.isdir(MOBILE_PATH) # Show actual version - _logger.info(f"Python version: {self.python_version}") + # « d'Odoo » nommé : le venv d'outillage tourne sur une AUTRE version, + # et un libellé nu laissait croire que le dépôt n'en a qu'une. + _logger.info(f"Python version (Odoo): {self.python_version}") + if os.path.exists(VERSION_PYTHON_ERPLIBRE_FILE): + with open(VERSION_PYTHON_ERPLIBRE_FILE) as txt: + _logger.info( + f"Python version (outillage): {txt.read().strip()}" + ) _logger.info(f"Odoo version: {self.odoo_version}") _logger.info(f"Poetry version: {poetry_version}") _logger.info( @@ -482,7 +493,9 @@ class Update: ) else: with_extra = get_version_extra(self.new_version_odoo) - manifest_script = "./script/manifest/update_manifest_local_dev.sh" + manifest_script = ( + "./script/manifest/update_manifest_local_dev.sh" + ) if with_extra: manifest_script += " --with_extra" status = os.system(manifest_script) @@ -603,15 +616,16 @@ class Update: existing = [p.strip() for p in value.split(",") if p.strip()] missing = [p for p in extra_paths if p not in existing] if missing: - lines[i] = "addons_path = " + ",".join(existing + missing) + "\n" + lines[i] = ( + "addons_path = " + ",".join(existing + missing) + "\n" + ) changed = True break if changed: with open(config_path, "w", encoding="utf-8") as f: f.writelines(lines) _logger.info( - "Added extra addons to config.conf: " - + ", ".join(extra_paths) + "Added extra addons to config.conf: " + ", ".join(extra_paths) ) else: _logger.info("Extra addons already present in config.conf.") @@ -857,17 +871,17 @@ def main(): update.add_extra_to_config_conf() return exit_code - # TODO ignore this if installation fail + # TODO ignore this if installation fail - # TODO this cause an error at first execution, need to source ./.venv.erplibre/bin/activate and rerun - # subprocess.run(['source', './.venv.erplibre/bin/activate'], shell=True) - # subprocess.run(['make', 'config_gen_all']) - # status = os.system(f"make config_gen_all") - # - # if not status: - # print("Please run:") - # print("source ./.venv.erplibre/bin/activate") - # print("make config_gen_all") + # TODO this cause an error at first execution, need to source ./.venv.erplibre/bin/activate and rerun + # subprocess.run(['source', './.venv.erplibre/bin/activate'], shell=True) + # subprocess.run(['make', 'config_gen_all']) + # status = os.system(f"make config_gen_all") + # + # if not status: + # print("Please run:") + # print("source ./.venv.erplibre/bin/activate") + # print("make config_gen_all") def die(cond, message, code=1): From a88af94687c9e67ddbcc1b41e3b24d12e4ef190f Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 24 Sep 2026 13:34:36 -0400 Subject: [PATCH 16/17] [UPD] gitignore : tasks/ et .erplibre-state.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tasks/ porte les notes de travail que la convention garde hors du dépôt, et .erplibre-state.json l'état de ce qu'un checkout a installé, écrit par erplibre_state.py. Non ignorés, un « git add -A » les emportait. --- EN --- tasks/ holds the working notes the convention keeps out of the repository, and .erplibre-state.json the state of what a checkout has installed, written by erplibre_state.py. Left unignored, a "git add -A" swept them in. Assisted-by: Claude Opus 5 --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index 46dfb35..8436e1b 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,8 @@ manifest/default.dev.xml poetry.lock pyproject.toml .erplibre-semver-version +# State of what this checkout has installed. +.erplibre-state.json .erplibre-version .odoo-version .poetry-version @@ -50,3 +52,6 @@ screencasts private/todo/todo_override_private.json .erplibre.error.txt /mobile/erplibre_home_mobile/ + +# Working notes, kept out of tracked files by the code conventions. +tasks/ From 3f2b61bf179b4632571f9773731a7baf1c866ade Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 24 Sep 2026 13:49:27 -0400 Subject: [PATCH 17/17] [UPD] changelog : le proxy SOCKS, la reprise d'image, les deux Python MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cinq livraisons de cette branche manquaient à la liste du non-publié : le proxy SOCKS du menu de déploiement et la cible qui formate les tests, côté ajouts ; la reprise d'un téléchargement coupé, le faisceau de certificats désigné seulement s'il existe, et « make version » qui nomme les deux Python, côté corrections. Un lecteur qui choisit de mettre à jour n'y voyait donc ni la commande neuve ni les trois pannes levées. Vérifié : les quatre sections du non-publié comptent autant de puces en français qu'en anglais, et les deux fichiers générés suivent leur source. --- EN --- Five of this branch's deliveries were missing from the unreleased list: the SOCKS proxy of the deployment menu and the target formatting the tests, on the added side; the resume of an interrupted download, the certificate bundle named only when it exists, and "make version" naming both Pythons, on the fixed side. A reader choosing whether to upgrade saw neither the new command nor the three failures lifted. Checked: the four unreleased sections carry as many bullets in French as in English, and both generated files follow their source. Assisted-by: Claude Opus 5 --- CHANGELOG.base.md | 10 ++++++++++ CHANGELOG.fr.md | 5 +++++ CHANGELOG.md | 5 +++++ 3 files changed, 20 insertions(+) diff --git a/CHANGELOG.base.md b/CHANGELOG.base.md index bba60ed..3cec36b 100644 --- a/CHANGELOG.base.md +++ b/CHANGELOG.base.md @@ -82,6 +82,8 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - One entry of the download cache can be forgotten from the menu, under « Age and cleanup ». The binary could already do it; the menu offered only the two bulk purges, neither of which reaches a single object — « erase what has not served » never reaches one the service rejuvenates each time it serves it, and « erase everything » costs the whole cache for one file. `--detient` runs first and is the preview: same line, same key, nothing modified - `erplibre_go_qemu_cache --recle` stores a cache's objects again under the current key, without downloading anything, and merges the copies a mirror carried under several paths. Objects written under the former key rule stay on disk but become UNREACHABLE, so the service asks upstream for them again and the space they hold serves no one: on a store of 12 764 objects, 5 419 were in that case — 9.11 GiB — and merging the duplicates returned about 3.37 GiB. The service must be stopped, the body being renamed before its meta, and `--dry-run` only counts what would move. A status-only entry is left alone, its key carrying the host rather than the path - The `pre-commit` hook runs `check_python_version.py` on staged files: it reports source that does not parse under the Python of `conf/python-erplibre-version`, without blocking the commit, and says when no such interpreter was there to check. Neither black nor flake8 sees that fault — black's target bounds what it writes, never what it accepts +- `Deploy › Local › [4]` opens a SOCKS proxy over SSH — `ssh -D`, port 1080 by default — so the browser reaches, FROM the remote machine, an interface listening only on its loopback or a host of its network. The address comes from `~/.ssh/config` or by hand; an alias is passed to ssh as is, so its `ProxyJump` still applies and a nested VM stays reachable. The Firefox settings print before the tunnel opens, the command only returning on Ctrl+C +- `make format_test` formats `test/` and `long_test/`, which no target covered: 75 files out of 210 followed no standard, and only a file a diff reported was ever touched @@ -136,6 +138,8 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Une entrée du cache de téléchargement s'oublie depuis le menu, sous « Âge et nettoyage ». Le binaire savait déjà le faire ; le menu n'offrait que les deux purges en gros, dont aucune ne vise un objet — « effacer ce qui n'a plus servi » n'atteint jamais celui que le service rajeunit chaque fois qu'il le rend, et « tout effacer » coûte le cache entier pour un fichier. `--detient` passe d'abord et fait l'aperçu : même ligne, même clé, sans rien modifier - `erplibre_go_qemu_cache --recle` range à nouveau les objets d'un cache sous la clé courante, sans rien retélécharger, et fond les copies qu'un miroir portait sous plusieurs chemins. Les objets écrits sous l'ancienne règle de clé restent sur le disque mais deviennent INTROUVABLES, si bien que le service les redemande à l'amont et que la place qu'ils tiennent ne sert plus personne : sur un magasin de 12 764 objets, 5 419 étaient dans ce cas — 9,11 Gio — et la fusion des doublons a rendu environ 3,37 Gio. Le service doit être arrêté, le corps étant renommé avant son méta, et `--dry-run` ne fait que compter ce qui bougerait. Un statut seul n'est pas touché, sa clé portant l'hôte et non le chemin - Le hook `pre-commit` lance `check_python_version.py` sur les fichiers indexés : il signale le source qui ne parse pas sous le Python de `conf/python-erplibre-version`, sans bloquer le commit, et dit quand aucun interpréteur de cette version n'était là pour vérifier. Ni black ni flake8 ne voient ce défaut — la cible de black borne ce qu'il écrit, jamais ce qu'il accepte +- `Déploiement › Local › [4]` ouvre un proxy SOCKS par SSH — `ssh -D`, port 1080 par défaut — pour que le navigateur atteigne, DEPUIS la machine distante, une interface qui n'écoute que sur sa boucle locale ou un hôte de son réseau. L'adresse vient de `~/.ssh/config` ou de la saisie ; un alias part tel quel à ssh, si bien que son `ProxyJump` s'applique encore et qu'une VM imbriquée reste joignable. Le réglage de Firefox s'affiche avant l'ouverture du tunnel, la commande ne rendant la main qu'au Ctrl+C +- `make format_test` formate `test/` et `long_test/`, qu'aucune cible ne couvrait : 75 fichiers sur 210 ne suivaient aucune norme, et seul un fichier signalé par un diff était touché ## Changed @@ -255,6 +259,9 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - The download cache no longer re-fetches a package it already holds because a mirror files it under another path. A mirror prefixes the path as it pleases — « /rocky/10.2/… », « /mirror/rocky-linux/10.2/… », « /pub/archive/fedora/… » — and the whole path gave two keys for the same bytes: over a log of 7099 delivered names, 1124 lived under several paths and 3.18 GiB went back upstream for nothing. Only the last six segments count now, empty ones falling with them. Six is the smallest collision-free bound: a Debian path carries exactly six, so five would serve Ubuntu's package for Debian's, which bears the same name for other bytes. Pacman packages stop at four, their paths being shorter than the common bound, which drops the mirror's prefix and keeps the repository name. A store filled before this change is brought over by `--recle` - A kept image that has gone stale is fetched once more instead of ending the deployment. A distribution's « latest » directory moves with each point release and the published sum stops describing the image on disk, with no byte corrupted: the check deleted it and exited, losing a whole campaign — three VMs — to a staleness one download repairs. A second mismatch is on freshly downloaded bytes, stops everything and deletes the image; called with no mirror list, and for unreachable sums, nothing changes - `long_test/qemu_cache.py` no longer fails a campaign where the cache served everything. A URL counts as « already seen » only if the first VM obtained its bytes: one package lives under two paths depending on the mirror, and the first VM can get a « 504 » on one — upstream judged mute — then be served from disk by the other, so nothing was stored under the first path and the second VM's honest download was counted a fault. A missing status counts as delivered, older logs not always writing it, and the new-files line names both of its causes instead of blaming Arch whatever the system measured +- An interrupted image download resumes on the same mirror, three attempts, by a `Range` asking for the rest, instead of throwing away what it received: a VM image weighs half a gigabyte, and a truncated `.part` used to send the reader back to a hand-typed `curl -C -`. A server ignoring the Range returns the whole file, and the transfer restarts from zero rather than doubling the bytes already there +- The cache's certificate variables name a bundle only when the file exists. A recent Fedora lacks `/etc/pki/tls/certs/ca-bundle.crt`, and pointing pip at a missing path made it refuse EVERY download, including what has nothing to do with the cache. None found writes no variable, and pip keeps its own certificate set +- `make version` names both Pythons, Odoo's and the tooling's. A bare label suggested the repository had one, and the figure shown was not that of the venv the reader works in @@ -316,6 +323,9 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Le cache de téléchargement ne reprend plus à l'amont un paquet qu'il détient déjà parce qu'un miroir le range sous un autre chemin. Un miroir préfixe le chemin à sa guise — « /rocky/10.2/… », « /mirror/rocky-linux/10.2/… », « /pub/archive/fedora/… » — et le chemin entier donnait deux clés pour les mêmes octets : sur un journal de 7099 noms livrés, 1124 vivaient sous plusieurs chemins et 3,18 Gio repartaient à l'amont pour rien. Seuls les six derniers segments comptent désormais, les segments vides tombant avec eux. Six est la plus petite borne sans collision : un chemin Debian en porte exactement six, si bien que cinq servirait le paquet d'Ubuntu pour celui de Debian, qui porte le même nom pour d'autres octets. Les paquets pacman s'arrêtent à quatre, leurs chemins étant plus courts que la borne commune, ce qui retire le préfixe du miroir et garde le nom du dépôt. Un magasin rempli avant ce changement se rattrape par `--recle` - Une image gardée devenue périmée est reprise une fois au lieu d'arrêter le déploiement. Le répertoire « latest » d'une distribution avance à chaque version mineure et la somme publiée cesse de décrire l'image du disque, sans qu'un octet soit corrompu : la vérification la supprimait puis sortait, perdant une campagne entière — trois VM — pour une péremption qu'un seul téléchargement répare. Un second écart porte sur des octets fraîchement téléchargés, arrête tout et supprime l'image ; appelée sans liste de miroirs, et pour des sommes injoignables, rien ne change - `long_test/qemu_cache.py` ne fait plus échouer une campagne où le cache a tout servi. Une URL n'est « déjà vue » que si la première VM en a obtenu les octets : un même paquet vit sous deux chemins selon le miroir, et la première VM peut recevoir « 504 » sur l'un — amont jugé muet — puis être servie du disque par l'autre, si bien que rien n'était rangé sous le premier chemin et que le téléchargement honnête de la seconde y était compté en faute. Un statut absent vaut livré, les journaux d'avant ne l'écrivant pas toujours, et la ligne des fichiers neufs nomme ses deux causes au lieu d'imputer à Arch quelle que soit la distribution mesurée +- Un téléchargement d'image coupé se reprend sur le même miroir, trois essais, par un `Range` qui demande la suite, au lieu de jeter ce qu'il a reçu : une image de VM pèse un demi-gigaoctet, et un `.part` tronqué renvoyait le lecteur à un `curl -C -` tapé à la main. Un serveur qui ignore le Range rend le fichier entier, et le transfert repart alors de zéro plutôt que de doubler les octets déjà là +- Les variables de certificat du cache ne désignent un faisceau que s'il existe. Une Fedora récente n'a pas `/etc/pki/tls/certs/ca-bundle.crt`, et viser un chemin absent faisait refuser à pip TOUT téléchargement, y compris ce qui n'a rien à voir avec le cache. Aucun trouvé n'écrit aucune variable, et pip garde son propre jeu de certificats +- `make version` nomme les deux Python, celui d'Odoo et celui de l'outillage. Un libellé nu laissait croire que le dépôt n'en a qu'un, et le chiffre affiché n'était pas celui du venv dans lequel on travaille ## Removed diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index c59cd77..e33b43f 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -62,6 +62,8 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Une entrée du cache de téléchargement s'oublie depuis le menu, sous « Âge et nettoyage ». Le binaire savait déjà le faire ; le menu n'offrait que les deux purges en gros, dont aucune ne vise un objet — « effacer ce qui n'a plus servi » n'atteint jamais celui que le service rajeunit chaque fois qu'il le rend, et « tout effacer » coûte le cache entier pour un fichier. `--detient` passe d'abord et fait l'aperçu : même ligne, même clé, sans rien modifier - `erplibre_go_qemu_cache --recle` range à nouveau les objets d'un cache sous la clé courante, sans rien retélécharger, et fond les copies qu'un miroir portait sous plusieurs chemins. Les objets écrits sous l'ancienne règle de clé restent sur le disque mais deviennent INTROUVABLES, si bien que le service les redemande à l'amont et que la place qu'ils tiennent ne sert plus personne : sur un magasin de 12 764 objets, 5 419 étaient dans ce cas — 9,11 Gio — et la fusion des doublons a rendu environ 3,37 Gio. Le service doit être arrêté, le corps étant renommé avant son méta, et `--dry-run` ne fait que compter ce qui bougerait. Un statut seul n'est pas touché, sa clé portant l'hôte et non le chemin - Le hook `pre-commit` lance `check_python_version.py` sur les fichiers indexés : il signale le source qui ne parse pas sous le Python de `conf/python-erplibre-version`, sans bloquer le commit, et dit quand aucun interpréteur de cette version n'était là pour vérifier. Ni black ni flake8 ne voient ce défaut — la cible de black borne ce qu'il écrit, jamais ce qu'il accepte +- `Déploiement › Local › [4]` ouvre un proxy SOCKS par SSH — `ssh -D`, port 1080 par défaut — pour que le navigateur atteigne, DEPUIS la machine distante, une interface qui n'écoute que sur sa boucle locale ou un hôte de son réseau. L'adresse vient de `~/.ssh/config` ou de la saisie ; un alias part tel quel à ssh, si bien que son `ProxyJump` s'applique encore et qu'une VM imbriquée reste joignable. Le réglage de Firefox s'affiche avant l'ouverture du tunnel, la commande ne rendant la main qu'au Ctrl+C +- `make format_test` formate `test/` et `long_test/`, qu'aucune cible ne couvrait : 75 fichiers sur 210 ne suivaient aucune norme, et seul un fichier signalé par un diff était touché ## Modifié @@ -148,6 +150,9 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Le cache de téléchargement ne reprend plus à l'amont un paquet qu'il détient déjà parce qu'un miroir le range sous un autre chemin. Un miroir préfixe le chemin à sa guise — « /rocky/10.2/… », « /mirror/rocky-linux/10.2/… », « /pub/archive/fedora/… » — et le chemin entier donnait deux clés pour les mêmes octets : sur un journal de 7099 noms livrés, 1124 vivaient sous plusieurs chemins et 3,18 Gio repartaient à l'amont pour rien. Seuls les six derniers segments comptent désormais, les segments vides tombant avec eux. Six est la plus petite borne sans collision : un chemin Debian en porte exactement six, si bien que cinq servirait le paquet d'Ubuntu pour celui de Debian, qui porte le même nom pour d'autres octets. Les paquets pacman s'arrêtent à quatre, leurs chemins étant plus courts que la borne commune, ce qui retire le préfixe du miroir et garde le nom du dépôt. Un magasin rempli avant ce changement se rattrape par `--recle` - Une image gardée devenue périmée est reprise une fois au lieu d'arrêter le déploiement. Le répertoire « latest » d'une distribution avance à chaque version mineure et la somme publiée cesse de décrire l'image du disque, sans qu'un octet soit corrompu : la vérification la supprimait puis sortait, perdant une campagne entière — trois VM — pour une péremption qu'un seul téléchargement répare. Un second écart porte sur des octets fraîchement téléchargés, arrête tout et supprime l'image ; appelée sans liste de miroirs, et pour des sommes injoignables, rien ne change - `long_test/qemu_cache.py` ne fait plus échouer une campagne où le cache a tout servi. Une URL n'est « déjà vue » que si la première VM en a obtenu les octets : un même paquet vit sous deux chemins selon le miroir, et la première VM peut recevoir « 504 » sur l'un — amont jugé muet — puis être servie du disque par l'autre, si bien que rien n'était rangé sous le premier chemin et que le téléchargement honnête de la seconde y était compté en faute. Un statut absent vaut livré, les journaux d'avant ne l'écrivant pas toujours, et la ligne des fichiers neufs nomme ses deux causes au lieu d'imputer à Arch quelle que soit la distribution mesurée +- Un téléchargement d'image coupé se reprend sur le même miroir, trois essais, par un `Range` qui demande la suite, au lieu de jeter ce qu'il a reçu : une image de VM pèse un demi-gigaoctet, et un `.part` tronqué renvoyait le lecteur à un `curl -C -` tapé à la main. Un serveur qui ignore le Range rend le fichier entier, et le transfert repart alors de zéro plutôt que de doubler les octets déjà là +- Les variables de certificat du cache ne désignent un faisceau que s'il existe. Une Fedora récente n'a pas `/etc/pki/tls/certs/ca-bundle.crt`, et viser un chemin absent faisait refuser à pip TOUT téléchargement, y compris ce qui n'a rien à voir avec le cache. Aucun trouvé n'écrit aucune variable, et pip garde son propre jeu de certificats +- `make version` nomme les deux Python, celui d'Odoo et celui de l'outillage. Un libellé nu laissait croire que le dépôt n'en a qu'un, et le chiffre affiché n'était pas celui du venv dans lequel on travaille ## Retiré diff --git a/CHANGELOG.md b/CHANGELOG.md index 0586dcb..b840d72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,8 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - One entry of the download cache can be forgotten from the menu, under « Age and cleanup ». The binary could already do it; the menu offered only the two bulk purges, neither of which reaches a single object — « erase what has not served » never reaches one the service rejuvenates each time it serves it, and « erase everything » costs the whole cache for one file. `--detient` runs first and is the preview: same line, same key, nothing modified - `erplibre_go_qemu_cache --recle` stores a cache's objects again under the current key, without downloading anything, and merges the copies a mirror carried under several paths. Objects written under the former key rule stay on disk but become UNREACHABLE, so the service asks upstream for them again and the space they hold serves no one: on a store of 12 764 objects, 5 419 were in that case — 9.11 GiB — and merging the duplicates returned about 3.37 GiB. The service must be stopped, the body being renamed before its meta, and `--dry-run` only counts what would move. A status-only entry is left alone, its key carrying the host rather than the path - The `pre-commit` hook runs `check_python_version.py` on staged files: it reports source that does not parse under the Python of `conf/python-erplibre-version`, without blocking the commit, and says when no such interpreter was there to check. Neither black nor flake8 sees that fault — black's target bounds what it writes, never what it accepts +- `Deploy › Local › [4]` opens a SOCKS proxy over SSH — `ssh -D`, port 1080 by default — so the browser reaches, FROM the remote machine, an interface listening only on its loopback or a host of its network. The address comes from `~/.ssh/config` or by hand; an alias is passed to ssh as is, so its `ProxyJump` still applies and a nested VM stays reachable. The Firefox settings print before the tunnel opens, the command only returning on Ctrl+C +- `make format_test` formats `test/` and `long_test/`, which no target covered: 75 files out of 210 followed no standard, and only a file a diff reported was ever touched ## Changed @@ -148,6 +150,9 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - The download cache no longer re-fetches a package it already holds because a mirror files it under another path. A mirror prefixes the path as it pleases — « /rocky/10.2/… », « /mirror/rocky-linux/10.2/… », « /pub/archive/fedora/… » — and the whole path gave two keys for the same bytes: over a log of 7099 delivered names, 1124 lived under several paths and 3.18 GiB went back upstream for nothing. Only the last six segments count now, empty ones falling with them. Six is the smallest collision-free bound: a Debian path carries exactly six, so five would serve Ubuntu's package for Debian's, which bears the same name for other bytes. Pacman packages stop at four, their paths being shorter than the common bound, which drops the mirror's prefix and keeps the repository name. A store filled before this change is brought over by `--recle` - A kept image that has gone stale is fetched once more instead of ending the deployment. A distribution's « latest » directory moves with each point release and the published sum stops describing the image on disk, with no byte corrupted: the check deleted it and exited, losing a whole campaign — three VMs — to a staleness one download repairs. A second mismatch is on freshly downloaded bytes, stops everything and deletes the image; called with no mirror list, and for unreachable sums, nothing changes - `long_test/qemu_cache.py` no longer fails a campaign where the cache served everything. A URL counts as « already seen » only if the first VM obtained its bytes: one package lives under two paths depending on the mirror, and the first VM can get a « 504 » on one — upstream judged mute — then be served from disk by the other, so nothing was stored under the first path and the second VM's honest download was counted a fault. A missing status counts as delivered, older logs not always writing it, and the new-files line names both of its causes instead of blaming Arch whatever the system measured +- An interrupted image download resumes on the same mirror, three attempts, by a `Range` asking for the rest, instead of throwing away what it received: a VM image weighs half a gigabyte, and a truncated `.part` used to send the reader back to a hand-typed `curl -C -`. A server ignoring the Range returns the whole file, and the transfer restarts from zero rather than doubling the bytes already there +- The cache's certificate variables name a bundle only when the file exists. A recent Fedora lacks `/etc/pki/tls/certs/ca-bundle.crt`, and pointing pip at a missing path made it refuse EVERY download, including what has nothing to do with the cache. None found writes no variable, and pip keeps its own certificate set +- `make version` names both Pythons, Odoo's and the tooling's. A bare label suggested the repository had one, and the figure shown was not that of the venv the reader works in ## Removed