Merge branch 'stage/improve_erplibre_todo'

[UPD] outillage : un Python à lui, un formateur par contexte, des gardes

17 commits. Le venv d'outillage prenait le python3 du système, quel qu'il
soit : sur une machine en 3.10, TODO mourait sur une SyntaxError avant tout
message, et rien ne permettait plus de lancer l'installation qui en serait
sorti. Il a désormais sa version, distincte de celle d'Odoo, et le patch ne
borne que cette dernière — l'exiger partout écartait un Python de
distribution d'un cran en retard et faisait compiler CPython pour rien. Le
formatage suit le contexte : ruff ici, la norme OCA pour les modules Odoo.
Debian 11 part, sa suite de sécurité n'étant ni servie ni archivée.
Vérifié : 6764 tests verts, et l'arbre Python parse de 3.10 à 3.14.

--- EN ---

17 commits. The tooling venv took the system python3, whatever it was: on a
3.10 machine TODO died on a SyntaxError before any message, and nothing could
start the install that would have rescued it. It now has its own version,
distinct from Odoo's, and the patch bounds only the latter — requiring it
everywhere turned away a distribution Python one step behind and compiled
CPython for nothing. Formatting follows context: ruff here, the OCA standard
for Odoo modules. Debian 11 goes, its security suite being neither served nor
archived.
Checked: 6764 tests green, and the Python tree parses from 3.10 to 3.14.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-09-24 14:40:17 -04:00
commit e496b107a6
59 changed files with 2413 additions and 413 deletions

View file

@ -36,6 +36,37 @@ 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é.
`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/`.
## Paquets Python
`EL_PIP_PROVIDER` (dans `env_var.sh`) vaut `auto`, `uv` ou `pip`. `auto` prend

5
.gitignore vendored
View file

@ -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/

37
.ruff.toml Normal file
View file

@ -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"]

View file

@ -81,6 +81,9 @@ 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
- `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
<!-- [fr] -->
@ -134,6 +137,9 @@ 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
- `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é
<!-- [en] -->
## Changed
@ -154,6 +160,15 @@ 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
- `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:<version>-slim-<suite>`: 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
<!-- [fr] -->
@ -170,6 +185,15 @@ 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é
- `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:<version>-slim-<version Debian>` : 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
<!-- [en] -->
## Fixed
@ -235,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
<!-- [fr] -->
@ -296,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
<!-- [en] -->
## Removed

View file

@ -61,6 +61,9 @@ 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
- `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é
@ -77,6 +80,15 @@ 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é
- `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:<version>-slim-<version Debian>` : 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
## Corrigé
@ -138,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é

View file

@ -61,6 +61,9 @@ 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
- `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
@ -77,6 +80,15 @@ 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
- `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:<version>-slim-<suite>`: 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
## Fixed
@ -138,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

View file

@ -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
@ -188,46 +202,47 @@ 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/
./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/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/
# 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/isort --profile black -l 79 ./script/ --gitignore
.venv.erplibre/bin/ruff check --select I --fix ./script/
#########
# log #

View file

@ -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.
<!-- [fr] -->
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.
<!-- [en] -->
# Supported platforms
@ -243,6 +254,33 @@ make
<!-- [en] -->
`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.
<!-- [fr] -->
`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.
<!-- [en] -->
### Manually
Into Ubuntu, minimal dependency:

View file

@ -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
@ -120,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 :

View file

@ -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
@ -119,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:

View file

@ -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

View file

@ -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,

View file

@ -1 +1 @@
3.12.10
3.14.7

53
conf/ruff.addons.toml Normal file
View file

@ -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

View file

@ -33,25 +33,6 @@ key : report.url
value : http://127.0.0.1:8069
```
<!-- [en] -->
### wkthmltopdf installation debian 13 and more
You need libssl1.1, it's deprecated.
<!-- [fr] -->
### Installation de wkthmltopdf sur Debian 13 et plus
Vous avez besoin de libssl1.1, qui est déprécié.
<!-- [common] -->
```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
```
<!-- [en] -->
### wkthmltopdf installation alternative

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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

View file

@ -70,11 +70,21 @@ docker build -f Dockerfile.prod.pkg -t technolibre/erplibre:12.0-pkg .
```
<!-- [en] -->
`.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.
<!-- [fr] -->
`.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.

View file

@ -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.

View file

@ -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.

View file

@ -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 "$@"

View file

@ -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

View file

@ -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 <xpath position="attributes"> 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):

View file

@ -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
# [<horodatage>] $ .venv…/python3 ./script/…/smoke.py -d …
# [<horodatage>] -> 1
# [<horodatage>] [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')}"

View file

@ -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())

View file

@ -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:<version>-slim-<nom> » : 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

View file

@ -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())

View file

@ -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

View file

@ -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 <racine du depot> && ./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

View file

@ -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 <racine du depot> && ./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

View file

@ -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é} »"

View file

@ -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."

View file

@ -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}"
}

View file

@ -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 "$@"

View file

@ -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<version>/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] <nom de dépôt>...
# --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

View file

@ -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.")

View file

@ -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

View file

@ -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"],
@ -1531,18 +1527,42 @@ 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)."""
`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:
@ -1568,7 +1588,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 +1627,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"
@ -3251,6 +3290,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
@ -3608,10 +3659,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):

View file

@ -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(

View file

@ -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):

View file

@ -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 '<nixpkgs>' -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)

View file

@ -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"

View file

@ -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"),

View file

@ -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 "$@"

View file

@ -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/<name>."""
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 : <venv>/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
@ -46,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
@ -387,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": (
@ -964,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)...")},
{
@ -1007,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 !"))
@ -1555,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"
@ -1717,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}")
@ -2292,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):
@ -2324,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")
@ -2369,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:
@ -2398,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):
@ -3111,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(
@ -3182,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")},
@ -3250,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."""
@ -3369,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)
@ -5265,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,
@ -5394,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":
@ -5601,6 +5749,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")

View file

@ -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"
@ -9502,6 +9548,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",
@ -11332,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 — "
@ -12166,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": {
@ -13311,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/).": {
@ -14109,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.",
},

View file

@ -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):

View file

@ -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()

View file

@ -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:<version>-slim-<nom de version Debian> » : 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()

View file

@ -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

View file

@ -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()

View file

@ -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,
)

View file

@ -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()

View file

@ -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()

View file

@ -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()