Merge branch 'develop_release_10_aout_2026'

- update changelog
- inspect odoo database for analysing to detect studio and custom field,
  database size. Show diff over TUI
- odoo migration check COW file, it's update file Copie-of-write
  different from coding file, this can crash over migration
- fix git repo init
- increase stability when create VM with QEMU
- Support ERPLibre mail client
- Improve keepass usage and fix file browser into TODO
This commit is contained in:
Mathieu Benoit 2026-08-16 04:38:55 -04:00
commit 3a68003430
92 changed files with 29547 additions and 288 deletions

View file

@ -57,6 +57,38 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
- ERPLibre Home Mobile Application, use TODO to compile, deploy it and personalize it
- Support Selenium grid from selenium_lib.py
- Add addons OnlyOffice, Cetmix, OCA automation, OCA shopfloor
- Deploy ERPLibre VMs with QEMU/KVM from cloud images (Ubuntu, Debian, Fedora,
Arch) on amd64, arm64 and s390x, with a menu to list, test, resize, delete
and clean up
- Textual interfaces: install dashboard, VM deployment form, migration resume
screen; Textual is installed on demand
- Navigation telemetry for TODO, as a tree, a kanban or a list
- Migration tools for the website copy-on-write views: predict, snapshot,
diff, neutralize and reset
- Read-only analysis toolkit for an Odoo database
- SSH configuration with recursive ProxyJump, port forwarding, and
registration of the QEMU hosts in virt-manager
- NTFY self-hosted push notification server
- Generative AI policy, adopting the OCA one
- Claude Code agents and commands
- Local git server to share code between machines
- Unit tests for the configuration, the refactoring and the uncovered
components, with a bilingual test plan
- Read and send email from the TODO CLI, over IMAP and SMTP
- The database analysis reads a backup zip directly, without restoring it
- RTK management menu
- AI assistant tools menu, with the Claude Code commit command
- Deploy menu: clone ERPLibre on a remote host, configure sshfs, and make
targets for SSH deployment
- Database backup and erase commands, and a clearer restore naming
- Git patch, git remote and vim configuration from the menu
- Security check of the Python environment
- Odoo 18 reads STL files (OpenCAD)
- Mobile: whisper.cpp and sentencepiece in the manifest, a mobile test script,
and the Odoo sync API contract
- FAQ entry on wkhtmltopdf for recent distributions
- brin_advisor and brin_cluster: recommend and apply the right PostgreSQL
index for an Odoo model
<!-- [fr] -->
@ -76,6 +108,40 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
- Application mobile ERPLibre Home, utiliser TODO pour compiler, déployer et personnaliser
- Support de la grille Selenium depuis selenium_lib.py
- Ajout des addons OnlyOffice, Cetmix, OCA automation, OCA shopfloor
- Déploiement de VM ERPLibre en QEMU/KVM depuis des images cloud (Ubuntu,
Debian, Fedora, Arch) en amd64, arm64 et s390x, avec un menu pour lister,
tester, redimensionner, supprimer et nettoyer
- Interfaces Textual : tableau de bord d'installation, formulaire de
déploiement de VM, écran de reprise de migration ; Textual s'installe à la
demande
- Télémétrie de navigation pour TODO, en arbre, en kanban ou en liste
- Outils de migration pour les vues copy-on-write du site web : prévoir,
photographier, comparer, neutraliser et réinitialiser
- Boîte à outils d'analyse en lecture seule d'une base Odoo
- Configuration SSH avec ProxyJump récursif, redirection de port et
enregistrement des hôtes QEMU dans virt-manager
- Serveur de notifications NTFY auto-hébergé
- Politique d'IA générative, adoptant celle de l'OCA
- Agents et commandes Claude Code
- Serveur git local pour partager du code entre machines
- Tests unitaires pour la configuration, la refactorisation et les composants
non couverts, avec un plan de test bilingue
- Lecture et envoi de courriel depuis le CLI TODO, en IMAP et SMTP
- L'analyse de base lit un zip de sauvegarde tel quel, sans le restaurer
- Menu de gestion RTK
- Menu d'outils d'assistance IA, avec la commande de commit Claude Code
- Menu de déploiement : cloner ERPLibre sur un hôte distant, configurer sshfs,
et des cibles make pour le déploiement SSH
- Commandes de sauvegarde et d'effacement de base, et un nommage plus clair à
la restauration
- Correctif git, dépôt distant git et configuration vim depuis le menu
- Vérification de sécurité de l'environnement Python
- Odoo 18 lit les fichiers STL (OpenCAD)
- Mobile : whisper.cpp et sentencepiece au manifeste, un script de test mobile,
et le contrat d'API de synchronisation Odoo
- Entrée de FAQ sur wkhtmltopdf pour les distributions récentes
- brin_advisor et brin_cluster : recommander et appliquer le bon index
PostgreSQL pour un modèle Odoo
<!-- [en] -->
## Changed
@ -86,12 +152,95 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
- Docker support postgresql 18
- Format script search diff file into each repository
- Support neutralize database from Odoo
- Installation supports Fedora, Debian, Ubuntu and Arch Linux
- Repository sync and poetry install run in parallel, up to 50 % faster on a
slow connection
- CybroOdoo extra modules become opt-in, tracked per Odoo version
- Node.js 22, required by Capacitor 8 for the mobile application
- Poetry and repo are quiet by default; EL_VERBOSE restores the output
- TODO menus grouped into sections with icons, and English text used as the
i18n key
- Documentation is bilingual, generated from the .base.md sources
- A VM inherits the timezone of the host that creates it
- First boot no longer waits on snapd, locale generation or the guest agent
- apt picks the fastest reachable mirror before the official archive
- Selenium: download through a network hub, SVG to PNG, error detection,
multiple clicks and updated drivers
- Odoo can run on a custom database; queue_job setup and SSH forwarding
options in the menu
- Killing a process by port asks before acting, with an interactive menu
- LinuxMint 22.3 supported
- Odoo 18 dependencies: flanker, orjson, python-magic, tldextract, PyYAML
- Copyright year updated to 2026
<!-- [fr] -->
- Support Docker postgresql 18
- Script de formatage recherche les fichiers diff dans chaque dépôt
- Support de la neutralisation de base de données depuis Odoo
- L'installation prend en charge Fedora, Debian, Ubuntu et Arch Linux
- La synchronisation des dépôts et l'installation poetry tournent en
parallèle, jusqu'à 50 % plus rapide sur une connexion lente
- Les modules extra CybroOdoo deviennent optionnels, suivis par version d'Odoo
- Node.js 22, exigé par Capacitor 8 pour l'application mobile
- Poetry et repo sont silencieux par défaut ; EL_VERBOSE rétablit la sortie
- Menus TODO regroupés en sections avec icônes, et texte anglais utilisé comme
clé i18n
- Documentation bilingue, générée depuis les sources .base.md
- Une VM hérite du fuseau horaire de l'hôte qui la crée
- Le premier démarrage n'attend plus snapd, la génération de locales ni l'agent
- apt prend le miroir joignable le plus rapide avant le dépôt officiel
- Selenium : téléchargement via un hub réseau, SVG vers PNG, détection
d'erreurs, clics multiples et pilotes à jour
- Odoo peut tourner sur une base personnalisée ; configuration de queue_job et
options de redirection SSH dans le menu
- Tuer un processus par son port demande confirmation, avec un menu interactif
- LinuxMint 22.3 pris en charge
- Dépendances Odoo 18 : flanker, orjson, python-magic, tldextract, PyYAML
- Année de copyright portée à 2026
<!-- [en] -->
## Fixed
<!-- [fr] -->
## Corrigé
<!-- [en] -->
- A failed installation is no longer reported as a success: the exit code is
propagated through the whole chain
- --with_extra now applies to an already-installed environment
- The addons path no longer points at a repository the Odoo 18 manifest never
clones
- repo init receives a branch name, so a fresh install no longer fails
- The install monitor follows a VM whose DHCP lease changes
- Installation on Debian 13, Fedora and Ubuntu 26.04: apt lock, wkhtmltopdf,
SELinux and the missing C compiler
- Documentation accents and the parallel markdown generation
<!-- [fr] -->
- Une installation en échec n'est plus rapportée comme réussie : le code de
sortie remonte toute la chaîne
- --with_extra s'applique désormais à un environnement déjà installé
- Le chemin d'addons ne pointe plus vers un dépôt que le manifeste Odoo 18 ne
clone jamais
- repo init reçoit un nom de branche, une installation neuve n'échoue plus
- Le suivi d'installation suit une VM dont le bail DHCP change
- Installation sur Debian 13, Fedora et Ubuntu 26.04 : verrou apt, wkhtmltopdf,
SELinux et le compilateur C manquant
- Accents de la documentation et génération markdown en parallèle
<!-- [en] -->
## Security
<!-- [fr] -->
## Sécurité
<!-- [en] -->
- Passwords and tokens are redacted before a command is displayed or logged
<!-- [fr] -->
- Les mots de passe et jetons sont caviardés avant l'affichage ou la
journalisation d'une commande
<!-- [common] -->

View file

@ -31,12 +31,84 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
- Application mobile ERPLibre Home, utiliser TODO pour compiler, déployer et personnaliser
- Support de la grille Selenium depuis selenium_lib.py
- Ajout des addons OnlyOffice, Cetmix, OCA automation, OCA shopfloor
- Déploiement de VM ERPLibre en QEMU/KVM depuis des images cloud (Ubuntu,
Debian, Fedora, Arch) en amd64, arm64 et s390x, avec un menu pour lister,
tester, redimensionner, supprimer et nettoyer
- Interfaces Textual : tableau de bord d'installation, formulaire de
déploiement de VM, écran de reprise de migration ; Textual s'installe à la
demande
- Télémétrie de navigation pour TODO, en arbre, en kanban ou en liste
- Outils de migration pour les vues copy-on-write du site web : prévoir,
photographier, comparer, neutraliser et réinitialiser
- Boîte à outils d'analyse en lecture seule d'une base Odoo
- Configuration SSH avec ProxyJump récursif, redirection de port et
enregistrement des hôtes QEMU dans virt-manager
- Serveur de notifications NTFY auto-hébergé
- Politique d'IA générative, adoptant celle de l'OCA
- Agents et commandes Claude Code
- Serveur git local pour partager du code entre machines
- Tests unitaires pour la configuration, la refactorisation et les composants
non couverts, avec un plan de test bilingue
- Lecture et envoi de courriel depuis le CLI TODO, en IMAP et SMTP
- L'analyse de base lit un zip de sauvegarde tel quel, sans le restaurer
- Menu de gestion RTK
- Menu d'outils d'assistance IA, avec la commande de commit Claude Code
- Menu de déploiement : cloner ERPLibre sur un hôte distant, configurer sshfs,
et des cibles make pour le déploiement SSH
- Commandes de sauvegarde et d'effacement de base, et un nommage plus clair à
la restauration
- Correctif git, dépôt distant git et configuration vim depuis le menu
- Vérification de sécurité de l'environnement Python
- Odoo 18 lit les fichiers STL (OpenCAD)
- Mobile : whisper.cpp et sentencepiece au manifeste, un script de test mobile,
et le contrat d'API de synchronisation Odoo
- Entrée de FAQ sur wkhtmltopdf pour les distributions récentes
- brin_advisor et brin_cluster : recommander et appliquer le bon index
PostgreSQL pour un modèle Odoo
## Modifié
- Support Docker postgresql 18
- Script de formatage recherche les fichiers diff dans chaque dépôt
- Support de la neutralisation de base de données depuis Odoo
- L'installation prend en charge Fedora, Debian, Ubuntu et Arch Linux
- La synchronisation des dépôts et l'installation poetry tournent en
parallèle, jusqu'à 50 % plus rapide sur une connexion lente
- Les modules extra CybroOdoo deviennent optionnels, suivis par version d'Odoo
- Node.js 22, exigé par Capacitor 8 pour l'application mobile
- Poetry et repo sont silencieux par défaut ; EL_VERBOSE rétablit la sortie
- Menus TODO regroupés en sections avec icônes, et texte anglais utilisé comme
clé i18n
- Documentation bilingue, générée depuis les sources .base.md
- Une VM hérite du fuseau horaire de l'hôte qui la crée
- Le premier démarrage n'attend plus snapd, la génération de locales ni l'agent
- apt prend le miroir joignable le plus rapide avant le dépôt officiel
- Selenium : téléchargement via un hub réseau, SVG vers PNG, détection
d'erreurs, clics multiples et pilotes à jour
- Odoo peut tourner sur une base personnalisée ; configuration de queue_job et
options de redirection SSH dans le menu
- Tuer un processus par son port demande confirmation, avec un menu interactif
- LinuxMint 22.3 pris en charge
- Dépendances Odoo 18 : flanker, orjson, python-magic, tldextract, PyYAML
- Année de copyright portée à 2026
## Corrigé
- Une installation en échec n'est plus rapportée comme réussie : le code de
sortie remonte toute la chaîne
- --with_extra s'applique désormais à un environnement déjà installé
- Le chemin d'addons ne pointe plus vers un dépôt que le manifeste Odoo 18 ne
clone jamais
- repo init reçoit un nom de branche, une installation neuve n'échoue plus
- Le suivi d'installation suit une VM dont le bail DHCP change
- Installation sur Debian 13, Fedora et Ubuntu 26.04 : verrou apt, wkhtmltopdf,
SELinux et le compilateur C manquant
- Accents de la documentation et génération markdown en parallèle
## Sécurité
- Les mots de passe et jetons sont caviardés avant l'affichage ou la
journalisation d'une commande
## [1.6.0] - 2025-04-25

View file

@ -31,12 +31,81 @@ Recreating the virtual environment, use installation guide from tool `make`.
- ERPLibre Home Mobile Application, use TODO to compile, deploy it and personalize it
- Support Selenium grid from selenium_lib.py
- Add addons OnlyOffice, Cetmix, OCA automation, OCA shopfloor
- Deploy ERPLibre VMs with QEMU/KVM from cloud images (Ubuntu, Debian, Fedora,
Arch) on amd64, arm64 and s390x, with a menu to list, test, resize, delete
and clean up
- Textual interfaces: install dashboard, VM deployment form, migration resume
screen; Textual is installed on demand
- Navigation telemetry for TODO, as a tree, a kanban or a list
- Migration tools for the website copy-on-write views: predict, snapshot,
diff, neutralize and reset
- Read-only analysis toolkit for an Odoo database
- SSH configuration with recursive ProxyJump, port forwarding, and
registration of the QEMU hosts in virt-manager
- NTFY self-hosted push notification server
- Generative AI policy, adopting the OCA one
- Claude Code agents and commands
- Local git server to share code between machines
- Unit tests for the configuration, the refactoring and the uncovered
components, with a bilingual test plan
- Read and send email from the TODO CLI, over IMAP and SMTP
- The database analysis reads a backup zip directly, without restoring it
- RTK management menu
- AI assistant tools menu, with the Claude Code commit command
- Deploy menu: clone ERPLibre on a remote host, configure sshfs, and make
targets for SSH deployment
- Database backup and erase commands, and a clearer restore naming
- Git patch, git remote and vim configuration from the menu
- Security check of the Python environment
- Odoo 18 reads STL files (OpenCAD)
- Mobile: whisper.cpp and sentencepiece in the manifest, a mobile test script,
and the Odoo sync API contract
- FAQ entry on wkhtmltopdf for recent distributions
- brin_advisor and brin_cluster: recommend and apply the right PostgreSQL
index for an Odoo model
## Changed
- Docker support postgresql 18
- Format script search diff file into each repository
- Support neutralize database from Odoo
- Installation supports Fedora, Debian, Ubuntu and Arch Linux
- Repository sync and poetry install run in parallel, up to 50 % faster on a
slow connection
- CybroOdoo extra modules become opt-in, tracked per Odoo version
- Node.js 22, required by Capacitor 8 for the mobile application
- Poetry and repo are quiet by default; EL_VERBOSE restores the output
- TODO menus grouped into sections with icons, and English text used as the
i18n key
- Documentation is bilingual, generated from the .base.md sources
- A VM inherits the timezone of the host that creates it
- First boot no longer waits on snapd, locale generation or the guest agent
- apt picks the fastest reachable mirror before the official archive
- Selenium: download through a network hub, SVG to PNG, error detection,
multiple clicks and updated drivers
- Odoo can run on a custom database; queue_job setup and SSH forwarding
options in the menu
- Killing a process by port asks before acting, with an interactive menu
- LinuxMint 22.3 supported
- Odoo 18 dependencies: flanker, orjson, python-magic, tldextract, PyYAML
- Copyright year updated to 2026
## Fixed
- A failed installation is no longer reported as a success: the exit code is
propagated through the whole chain
- --with_extra now applies to an already-installed environment
- The addons path no longer points at a repository the Odoo 18 manifest never
clones
- repo init receives a branch name, so a fresh install no longer fails
- The install monitor follows a VM whose DHCP lease changes
- Installation on Debian 13, Fedora and Ubuntu 26.04: apt lock, wkhtmltopdf,
SELinux and the missing C compiler
- Documentation accents and the parallel markdown generation
## Security
- Passwords and tokens are redacted before a command is displayed or logged
## [1.6.0] - 2025-04-25

678
doc/EMAIL.base.md Normal file
View file

@ -0,0 +1,678 @@
<!---------------------------->
<!-- multilingual suffix: en, fr -->
<!-- no suffix: en -->
<!---------------------------->
<!-- [en] -->
# Mail client
A mail client built into the TODO CLI: several accounts, IMAP + SMTP, and a
local cache — so you can read and answer email without leaving
`./script/todo/todo.py`.
Every `Mail > ...` path below is shorthand for
`TODO > [3] Assistant > [2] Mail - Read and send email > ...` — the full path
is spelled out once, in "Adding an account".
<!-- [fr] -->
# Client courriel
Un client courriel intégré au CLI TODO : plusieurs comptes, IMAP + SMTP, et
un cache local — pour lire et répondre à son courriel sans quitter
`./script/todo/todo.py`.
Chaque chemin `Courriel > ...` ci-dessous est un raccourci pour
`TODO > [3] Assistant > [2] Courriel - Lire et envoyer du courriel > ...` — le
chemin complet est écrit une fois, dans « Ajouter un compte ».
<!-- [en] -->
## Prerequisites
Four Python packages, already listed in `requirement/erplibre_require-ments.txt`
(the `.venv.erplibre` environment, not an Odoo venv):
- `cryptography` — seals the local cache in `encrypted` and `ephemeral` mode.
- `keyring` — the system keyring, one of the two places a password can live.
- `pykeepass` — the KDBX vault, the other place, and the one the client tries
first.
- `textual` — the terminal UI itself. Without it, "Open the mail client
(TUI)" prints a message and does nothing; the rest of the menu (accounts,
sync, cache) still works.
Install them with:
<!-- [fr] -->
## Prérequis
Quatre paquets Python, déjà listés dans
`requirement/erplibre_require-ments.txt` (l'environnement `.venv.erplibre`,
pas un venv Odoo) :
- `cryptography` — scelle le cache local en mode `encrypted` et `ephemeral`.
- `keyring` — le trousseau système, l'un des deux endroits où peut vivre un
mot de passe.
- `pykeepass` — le coffre KDBX, l'autre endroit, celui que le client essaie
en premier.
- `textual` — l'interface terminal elle-même. Sans lui, « Ouvrir le client
courriel (TUI) » affiche un message et ne fait rien ; le reste du menu
(comptes, synchronisation, cache) fonctionne quand même.
Installez-les avec :
<!-- [common] -->
```bash
.venv.erplibre/bin/pip install -r requirement/erplibre_require-ments.txt
```
<!-- [en] -->
### App passwords for Gmail, Outlook and iCloud
Phase 1 speaks plain IMAP/SMTP login only — no OAuth yet (that is phase 2).
Gmail, Outlook and iCloud have all closed that door to the account's real
password, so each of these three presets requires an **app password**
instead:
| Provider | Where to generate it |
|---|---|
| Gmail | Enable 2-step verification, then [myaccount.google.com](https://myaccount.google.com/security) > Security > App passwords |
| Outlook / Microsoft 365 | [account.microsoft.com](https://account.microsoft.com/security) > Security > Advanced security options > App passwords |
| iCloud | [account.apple.com](https://account.apple.com/) > Sign-In and Security > App-Specific Passwords |
Use that generated password when account setup asks for one — never the
account's normal password. The "Standard server" preset (generic IMAP/SMTP)
does not need one.
<!-- [fr] -->
### Mots de passe d'application pour Gmail, Outlook et iCloud
La phase 1 ne parle qu'IMAP/SMTP en authentification simple — pas encore
OAuth (ça, c'est la phase 2). Gmail, Outlook et iCloud ont tous les trois
fermé cette porte au vrai mot de passe du compte : chacun de ces trois
préréglages exige donc un **mot de passe d'application** à la place :
| Fournisseur | Où le générer |
|---|---|
| Gmail | Activez la validation en deux étapes, puis [myaccount.google.com](https://myaccount.google.com/security) > Sécurité > Mots de passe des applications |
| Outlook / Microsoft 365 | [account.microsoft.com](https://account.microsoft.com/security) > Sécurité > Options de sécurité avancées > Mots de passe d'application |
| iCloud | [account.apple.com](https://account.apple.com/) > Connexion et sécurité > Mots de passe spécifiques aux applications |
Utilisez ce mot de passe généré quand la configuration du compte en demande
un — jamais le mot de passe normal du compte. Le préréglage « Serveur
standard » (IMAP/SMTP générique) n'en a pas besoin.
<!-- [en] -->
## Adding an account
Menu path: `TODO > [3] Assistant > [2] Mail - Read and send email > [2]
Accounts > [2] Add an account`.
The prompts, in order:
1. **Short account name** — becomes both the folder name under
`~/.erplibre/mail/` and the vault reference, so it cannot contain `/` or
start with a dot.
2. **Email address**.
3. **Display name** (optional) — shown in the `From:` header as
`Display Name <email>`.
4. **Provider** — a number from the printed list: Gmail, Outlook, iCloud, or
"Standard server" (generic IMAP/SMTP).
5. If you picked "Standard server", the **IMAP host** and **SMTP host** are
asked next; the other presets fill these in for you.
6. If the preset requires an app password, its note is printed here as a
reminder.
7. **Password** — typed hidden (`getpass`), then stored — never written to
`accounts.json`.
Where the password goes: at the password step, the client hands off to the
CLI's shared **KDBX manager** — the same one already used for the OpenAI
key and Odoo credentials. It reads `kdbx.path` / `kdbx.password` from the
TODO config (`script/todo/todo.json`, overridable in
`private/todo/todo_override.json` / `private/todo/todo_override_private.json`).
If `kdbx.path` isn't set yet, a graphical file picker pops up asking you to
choose an existing `.kdbx` file — it needs a display, and cancelling it (or
running headless) fails account creation with "le fichier kdbx n'a pas pu
être ouvert" (French — see Troubleshooting). **Set `kdbx.path` (and
`kdbx.password`, to skip the prompt) before adding your first account**,
pointing at a `.kdbx` vault you already have (create one with KeePassXC or
similar). The system keyring is only ever used for an account whose
`secret_ref` already points at one — the menu itself always writes new
accounts into the KDBX vault.
`accounts.json` (at `~/.erplibre/mail/accounts.json`) only ever holds a
`secret_ref` such as `kdbx:ERPLibre/Mail/perso` — a pointer, never the
secret. It is safe to read, edit by hand, or check into a private backup.
<!-- [fr] -->
## Ajouter un compte
Chemin de menu : `TODO > [3] Assistant > [2] Courriel - Lire et envoyer du
courriel > [2] Comptes > [2] Ajouter un compte`.
Les questions, dans l'ordre :
1. **Nom court du compte** — devient à la fois le nom de dossier sous
`~/.erplibre/mail/` et la référence dans le coffre : il ne peut donc pas
contenir `/` ni commencer par un point.
2. **Adresse courriel**.
3. **Nom affiché** (facultatif) — apparaît dans l'en-tête `De :` comme
`Nom affiché <email>`.
4. **Fournisseur** — un numéro dans la liste affichée : Gmail, Outlook,
iCloud, ou « Serveur standard » (IMAP/SMTP générique).
5. Si vous choisissez « Serveur standard », le **serveur IMAP** puis le
**serveur SMTP** sont demandés ensuite ; les autres préréglages les
remplissent déjà pour vous.
6. Si le préréglage exige un mot de passe d'application, sa note s'affiche
ici en rappel.
7. **Mot de passe** — saisi masqué (`getpass`), puis rangé dans le coffre —
jamais écrit dans `accounts.json`.
Où va le mot de passe : à l'étape du mot de passe, le client passe par le
**gestionnaire KDBX** partagé du CLI — le même que pour la clé OpenAI et
les identifiants Odoo. Il lit `kdbx.path` / `kdbx.password` dans la
configuration TODO (`script/todo/todo.json`, surchargeable dans
`private/todo/todo_override.json` / `private/todo/todo_override_private.json`).
Si `kdbx.path` n'est pas encore réglé, une fenêtre de sélection de fichier
s'ouvre pour choisir un `.kdbx` existant — il faut un affichage graphique,
et l'annuler (ou lancer le CLI sans affichage) fait échouer la création du
compte avec « le fichier kdbx n'a pas pu être ouvert » (voir Dépannage).
**Réglez `kdbx.path` (et `kdbx.password`, pour éviter l'invite) avant
d'ajouter votre premier compte**, en pointant vers un coffre `.kdbx` que
vous avez déjà (créez-en un avec KeePassXC ou équivalent). Le trousseau
système ne sert que pour un compte dont la `secret_ref` le désigne déjà —
le menu écrit toujours les nouveaux comptes dans le coffre KDBX.
`accounts.json` (dans `~/.erplibre/mail/accounts.json`) ne contient jamais
qu'une `secret_ref` du genre `kdbx:ERPLibre/Mail/perso` — une référence,
jamais le secret. Il est sans danger à lire, à éditer à la main, ou à
mettre dans une sauvegarde privée.
<!-- [en] -->
## The three cache modes
Every account keeps a local cache — a small SQLite database plus one file
per downloaded message — so the inbox stays readable offline. Three modes
control what that cache leaves on disk:
| Mode | What's on disk | Encryption key |
|---|---|---|
| `clear` (default) | `~/.erplibre/mail/<account>/cache.db` and `.eml` files, readable as plain text | none |
| `encrypted` | same location, but sender, recipients, subject, snippet, message-id and message bodies are sealed with AES-256-GCM | generated once, stored in the vault next to the password (`.../cache-key`) |
| `ephemeral` | under `/dev/shm/erplibre-mail-<pid>/<account>/` (or the system temp dir if `/dev/shm` isn't writable), sealed the same way as `encrypted` | generated fresh in RAM at every run, never written anywhere, and the whole directory is removed when the session closes |
Even in `clear` mode, the technical fields the SQL needs to sort and
filter — UID, folder, date, flags, size — are always plain; only the
person-identifying fields (and the message body) are ever sealed, and only
in `encrypted`/`ephemeral`.
Set the **general default** at `Mail > [4] Cache > [1] Default cache mode`;
it is the `mail_cache_mode` preference (default `clear`). **Override it per
account** at `Mail > [4] Cache > [2] Cache mode of one account` — this
writes the account's `cache_mode` field in `accounts.json`; leaving it at
`null` there means "inherit the general default."
`Mail > [4] Cache > [3] Cache size and purge` lists every account's
effective mode and disk usage, and can erase one account's cache entirely
(after confirmation) — the next sync rebuilds it from scratch.
<!-- [fr] -->
## Les trois modes de cache
Chaque compte garde un cache local — une petite base SQLite plus un fichier
par message téléchargé — pour que la boîte de réception reste lisible hors
ligne. Trois modes contrôlent ce que ce cache laisse sur le disque :
| Mode | Ce qui reste sur le disque | Clé de chiffrement |
|---|---|---|
| `clear` (par défaut) | `~/.erplibre/mail/<compte>/cache.db` et les fichiers `.eml`, lisibles en clair | aucune |
| `encrypted` | même emplacement, mais l'expéditeur, les destinataires, le sujet, l'extrait, le Message-ID et le corps des messages sont scellés en AES-256-GCM | générée une fois, rangée dans le coffre à côté du mot de passe (`.../cache-key`) |
| `ephemeral` | sous `/dev/shm/erplibre-mail-<pid>/<compte>/` (ou le dossier temporaire système si `/dev/shm` n'est pas inscriptible), scellé comme `encrypted` | tirée en RAM à chaque lancement, jamais écrite nulle part, et tout le dossier est effacé à la fermeture de la session |
Même en mode `clear`, les champs techniques dont le SQL a besoin pour trier
et filtrer — UID, dossier, date, drapeaux, taille — restent toujours en
clair ; seuls les champs qui identifient des personnes (et le corps du
message) sont scellés, et seulement en `encrypted`/`ephemeral`.
Réglez le **défaut général** dans `Courriel > [4] Cache > [1] Mode de cache
par défaut` ; c'est la préférence `mail_cache_mode` (défaut `clear`).
**Surchargez-le par compte** dans `Courriel > [4] Cache > [2] Mode de cache
d'un compte` — ceci écrit le champ `cache_mode` du compte dans
`accounts.json` ; le laisser à `null` là-bas veut dire « hérite du défaut
général ».
`Courriel > [4] Cache > [3] Taille du cache et purge` liste le mode
effectif et l'espace disque de chaque compte, et peut effacer entièrement le
cache d'un compte (après confirmation) — la prochaine synchronisation le
reconstruit à partir de zéro.
<!-- [en] -->
## The TUI
`Mail > [1] Open the mail client (TUI)` opens a three-pane screen: an
account/folder tree on the left, the message list in the middle, and a
preview pane on the right, with a status line at the bottom.
| Key | Action |
|---|---|
| `↑` `↓` `Tab` | move within a pane / move focus between panes (Textual defaults) |
| `h` | open the help window: every shortcut plus a few notes, closed with `Escape` |
| `z` | toggle full-screen preview (hides the folder tree and the message list) |
| `Escape` | leave full-screen |
| `v` | cycle the layout: columns, split, stacked |
| `+` / `-` | grow / shrink the pane that has focus |
| `0` | back to the default pane sizes |
| `r` | sync the account of the currently selected folder (all its folders) |
| `Shift+R` | sync every account |
| `/` | open the search field (filters the currently visible list only — locally, over subject/from/to/snippet; it does not search the server) |
| `s` / `u` | mark the selected message seen / unseen |
| `c` | compose a new message |
| `a` / `Shift+A` | reply / reply all |
| `f` | forward |
| `w` | save the message's **first** attachment to `~/Téléchargements` (created if missing) |
| `n` | add an account without leaving the client |
| `l` | show the tail of `~/.erplibre/mail.log` and this session's sync errors |
| `q` | quit |
This table is written by hand and can fall behind the code; the `h` window
cannot. It builds its list from the application's own key bindings every time
it opens, so it is the reference if the two ever disagree.
The bars between the panes can also be dragged with the mouse, and pane sizes
are remembered per layout.
The footer's key hints, like the help window, follow the CLI's chosen
language, as do the account tree, the message list and the preview text.
<!-- [fr] -->
## Le TUI
`Courriel > [1] Ouvrir le client courriel (TUI)` ouvre un écran en trois
volets : l'arbre comptes/dossiers à gauche, la liste des messages au
centre, et un aperçu à droite, avec une ligne de statut en bas.
| Touche | Action |
|---|---|
| `↑` `↓` `Tab` | se déplacer dans un volet / changer de volet (comportement par défaut de Textual) |
| `h` | ouvre la fenêtre d'aide : tous les raccourcis et quelques repères, fermée par `Échap` |
| `z` | plein écran sur l'aperçu (masque l'arbre et la liste) |
| `Échap` | quitter le plein écran |
| `v` | change de disposition : colonnes, partagée, empilée |
| `+` / `-` | agrandir / rétrécir le volet qui a le focus |
| `0` | revenir aux tailles de volets par défaut |
| `r` | synchronise le compte du dossier actuellement sélectionné (tous ses dossiers) |
| `Shift+R` | synchronise tous les comptes |
| `/` | ouvre le champ de recherche (filtre seulement la liste déjà affichée — localement, sur sujet/de/à/extrait ; ne cherche pas sur le serveur) |
| `s` / `u` | marquer le message sélectionné lu / non lu |
| `c` | écrire un nouveau message |
| `a` / `Shift+A` | répondre / répondre à tous |
| `f` | transférer |
| `w` | enregistrer la **première** pièce jointe du message dans `~/Téléchargements` (créé s'il n'existe pas) |
| `n` | ajouter un compte sans quitter le client |
| `l` | affiche la fin de `~/.erplibre/mail.log` et les erreurs de synchronisation de la session |
| `q` | quitter |
Ce tableau est écrit à la main et peut prendre du retard sur le code ; la
fenêtre `h`, elle, ne le peut pas : elle construit sa liste depuis les
liaisons de l'application à chaque ouverture. En cas de désaccord entre les
deux, c'est elle qui a raison.
Les barres entre les volets se glissent aussi à la souris, et les tailles
sont retenues par disposition.
Les indices de touches du pied d'écran, comme la fenêtre d'aide, suivent la
langue choisie dans le CLI, tout comme l'arbre des comptes, la liste et le
texte d'aperçu.
<!-- [en] -->
## Writing a message
`c` opens the compose form: `To`, `Cc`, `Subject`, an `Attachments` field
(semicolon-separated file paths — a comma is legal in a filename, so only
`;` splits entries; there is no file picker, type the paths), and a
multi-line body. `e` sends the body out to `$EDITOR` (or
`nano` if unset) and reads it back; if the editor is missing or exits with
an error, the body you had is kept untouched. `Ctrl+S` (or the Send button)
delivers the message; `Escape` discards the draft — there is no
save-as-draft.
`a` (reply) and `Shift+A` (reply all) prefill `To`/`Cc`/`Subject`/
`In-Reply-To`/`References` and quote the original message in the body. `f`
(forward) prefills the `Fwd:` subject and **attaches the original message**
automatically, as a `message/rfc822` attachment; the body itself starts
empty — write your own note above the attached original.
Reply, reply-all and forward all need the original message's body
available — from the cache, or fetched live if the account is online; with
neither, you get "No message selected." / "No message to forward."
Sending requires the account to be online (composing offline fails with
"Account offline: cannot send." — there is no offline outbox). Once sent, a
copy is filed into the account's Sent folder over IMAP; if that filing step
fails, the status line says so, but the message has already left — it is
not resent.
<!-- [fr] -->
## Écrire un message
`c` ouvre le formulaire : `À`, `Cc`, `Objet`, un champ `Pièces jointes`
(chemins de fichiers séparés par un point-virgule — une virgule est légale
dans un nom de fichier, donc seul `;` sépare les entrées ; il n'y a pas de
sélecteur de fichier, tapez les chemins), et un corps multi-lignes.
`e` envoie le corps vers `$EDITOR` (ou `nano` si non défini) et le relit ;
si l'éditeur manque ou sort en erreur, le texte de départ est conservé tel
quel. `Ctrl+S` (ou le bouton Envoyer) remet le message ; `Échap` abandonne
le brouillon — il n'y a pas d'enregistrement en brouillon.
`a` (répondre) et `Shift+A` (répondre à tous) préremplissent `À`/`Cc`/
`Objet`/`In-Reply-To`/`References` et citent le message d'origine dans le
corps. `f` (transférer) préremplit l'objet en `Fwd:` et **rattache le
message d'origine** automatiquement, en pièce jointe `message/rfc822` ; le
corps, lui, part vide — écrivez votre propre mot au-dessus du message
joint.
Répondre, répondre à tous et transférer ont tous besoin du corps du message
d'origine — depuis le cache, ou récupéré en direct si le compte est en
ligne ; sans l'un ou l'autre, vous obtenez « Aucun message sélectionné. » /
« Aucun message à transférer. ».
Envoyer exige que le compte soit en ligne (écrire hors ligne échoue avec
« Compte hors ligne : envoi impossible. » — il n'y a pas de file d'attente
hors ligne). Une fois envoyé, une copie est classée dans le dossier
Envoyés du compte par IMAP ; si ce classement échoue, la ligne de statut le
dit, mais le message est déjà parti — il n'est pas renvoyé.
<!-- [en] -->
## Synchronization
A sync pass is incremental: only UIDs above the last known one are
fetched, message bodies are never downloaded during a pass (only headers),
and bodies are fetched on demand when you open a message. Flags
(read/unread, etc.) of already-known messages are re-checked on every
pass, so a message read elsewhere shows up correctly here too.
Sync happens:
- **At launch** — opening the TUI kicks off one background sync of every
account.
- **On demand** — `r` (current account) / `Shift+R` (all accounts) inside
the TUI, or `Mail > [3] Synchronise now` from the CLI menu (prints a
per-account summary to the terminal).
- **Automatically, every `mail_refresh_sec` seconds** (default 300 = 5
minutes; 0 disables it) — **but only while the TUI is open**. Close it
and the timer goes with it; nothing syncs in the background afterward.
If the server reports a changed `UIDVALIDITY` for a folder (its UIDs no
longer mean what they used to — typically after a server-side migration),
that folder's cache is purged and resynced from scratch automatically;
there is currently no on-screen notice when this happens beyond the folder
briefly emptying and refilling.
<!-- [fr] -->
## Synchronisation
Une passe de synchronisation est incrémentale : seuls les UID supérieurs au
dernier connu sont demandés, le corps des messages n'est jamais téléchargé
pendant une passe (seulement les en-têtes), et les corps sont récupérés à
la demande à l'ouverture d'un message. Les drapeaux (lu/non lu, etc.) des
messages déjà connus sont revérifiés à chaque passe, donc un message lu
ailleurs apparaît correctement lu ici aussi.
La synchronisation a lieu :
- **Au lancement** — ouvrir le TUI déclenche une synchronisation de tous
les comptes en arrière-plan.
- **À la demande** — `r` (compte courant) / `Shift+R` (tous les comptes)
dans le TUI, ou `Courriel > [3] Synchroniser maintenant` depuis le menu
CLI (affiche un résumé par compte dans le terminal).
- **Automatiquement, toutes les `mail_refresh_sec` secondes** (défaut 300 =
5 minutes ; 0 la désactive) — **mais seulement tant que le TUI est
ouvert**. Fermez-le et la minuterie part avec lui ; rien ne se
synchronise en arrière-plan ensuite.
Si le serveur annonce un `UIDVALIDITY` changé pour un dossier (ses UID ne
veulent plus dire ce qu'ils disaient — typiquement après une migration
côté serveur), le cache de ce dossier est purgé et resynchronisé à partir
de zéro automatiquement ; il n'y a actuellement aucun avis à l'écran
au-delà du dossier qui se vide puis se remplit à nouveau brièvement.
<!-- [en] -->
## Where the files live
| Path | Contents |
|---|---|
| `~/.erplibre/mail/accounts.json` | account list — servers, presets, cache mode, and a `secret_ref` pointer; never a password (mode 0600) |
| `~/.erplibre/mail/<account>/cache.db` | that account's SQLite cache (mode 0600, parent directory 0700) |
| `~/.erplibre/mail/<account>/<folder>/<uid>.eml` (or `.eml.enc` when sealed) | one file per downloaded message body |
| `/dev/shm/erplibre-mail-<pid>/<account>/` | an `ephemeral` account's cache while the process is alive; removed when it exits (a sweep at every startup also clears directories left behind by a killed process) |
<!-- [fr] -->
## Où sont les fichiers
| Chemin | Contenu |
|---|---|
| `~/.erplibre/mail/accounts.json` | la liste des comptes — serveurs, préréglages, mode de cache, et une référence `secret_ref` ; jamais un mot de passe (mode 0600) |
| `~/.erplibre/mail/<compte>/cache.db` | le cache SQLite de ce compte (mode 0600, dossier parent 0700) |
| `~/.erplibre/mail/<compte>/<dossier>/<uid>.eml` (ou `.eml.enc` s'il est scellé) | un fichier par corps de message téléchargé |
| `/dev/shm/erplibre-mail-<pid>/<compte>/` | le cache d'un compte `ephemeral` pendant que le processus vit ; effacé à sa sortie (un balayage au démarrage nettoie aussi ce qu'un processus tué aurait laissé) |
<!-- [en] -->
## Troubleshooting
Error messages raised by the mail package itself (`secrets.py`,
`store.py`, `crypto.py`, `accounts.py`, `smtp_send.py`,
`imap_transport.py`, `imap_sync.py`) now go through the CLI's translation
layer, the same as the menu prompts and TUI labels: running the CLI in
English shows them in English. The wording below is quoted in French, this
document's reference language; expect the matching English wording when
`EL_LANG=en`.
**"Connection failed: ..." when adding or testing an account.**
`Mail > [2] Accounts > [5] Test an account connection` prints the server's
exact error and then asks for the password again — up to 3 attempts. The
password in the vault is only overwritten *after* a successful connection,
so a typo never destroys a working password. If the account is Gmail,
Outlook or iCloud, check first that you used an app password (see
"Prerequisites" above), not the account's normal one. Opening the TUI
itself does not retry automatically: an account with a rejected password
gets a ⚠ marker; if it had synced successfully before, its already-cached
folders stay visible and readable, they just stop refreshing — only a
brand-new account (nothing synced yet) shows no folders at all. Either
way, go run "Test an account connection" to fix it.
**"le fichier kdbx n'a pas pu être ouvert" when adding an account.**
The shared KDBX vault isn't configured yet, its file picker was cancelled,
or the CLI is running without a display to show that picker. Set
`kdbx.path` (and `kdbx.password`) as described in "Adding an account"
above, then try again.
**"le trousseau du système écrirait le mot de passe en clair (backend
...)".**
`keyring`'s active backend isn't one of the ones known to actually
encrypt — this happens over SSH, in a container, or on a machine with no
desktop session, where `keyring` silently falls back to a plaintext file
store. The client refuses rather than pretend that's safe. Use the KDBX
vault instead (see above), or run somewhere a real keyring is unlocked.
**"Install textual for the mail client (pip)."**
`textual` isn't installed. `Mail > [1] Open the mail client (TUI)` just
prints this and returns; every other menu entry (accounts, sync, cache)
still works without it.
**The folder cache says it changed (`UIDVALIDITY`).**
Nothing to do — the client purges and resyncs that folder by itself the
next time it syncs. Expect the message list to empty briefly and refill.
**"cache illisible, purgez-le et resynchronisez : ...".**
The account's `cache.db` is corrupt. `Mail > [4] Cache > [3] Cache size and
purge` may itself fail to open the same broken file; if so, delete the
account's cache directory by hand and resync:
<!-- [fr] -->
## Dépannage
Les messages d'erreur qui viennent du paquet courriel lui-même
(`secrets.py`, `store.py`, `crypto.py`, `accounts.py`, `smtp_send.py`,
`imap_transport.py`, `imap_sync.py`) passent maintenant par la couche de
traduction du CLI, comme les invites de menu et les libellés du TUI :
lancer le CLI en anglais les affiche en anglais. Le libellé ci-dessous est
cité en français, la langue de référence de ce document ; attendez-vous au
libellé anglais correspondant avec `EL_LANG=en`.
**« Connexion échouée : ... » en ajoutant ou en testant un compte.**
`Courriel > [2] Comptes > [5] Tester la connexion d'un compte` affiche
l'erreur exacte du serveur puis redemande le mot de passe — jusqu'à 3
tentatives. Le mot de passe dans le coffre n'est écrasé qu'*après* une
connexion réussie, donc une faute de frappe ne détruit jamais un mot de
passe qui fonctionnait. Si le compte est Gmail, Outlook ou iCloud,
vérifiez d'abord que vous avez utilisé un mot de passe d'application (voir
« Prérequis » plus haut), pas le mot de passe normal du compte. Ouvrir le
TUI lui-même ne relance pas cette demande automatiquement : un compte au
mot de passe refusé porte un ⚠ ; s'il avait déjà synchronisé avec succès,
ses dossiers déjà en cache restent visibles et lisibles, ils cessent
seulement de se rafraîchir — seul un compte tout neuf (rien de
synchronisé encore) n'affiche aucun dossier du tout. Dans tous les cas,
passez par « Tester la connexion d'un compte » pour corriger.
**« le fichier kdbx n'a pas pu être ouvert » en ajoutant un compte.**
Le coffre KDBX partagé n'est pas encore configuré, sa fenêtre de sélection
de fichier a été annulée, ou le CLI tourne sans affichage pour la montrer.
Réglez `kdbx.path` (et `kdbx.password`) comme décrit dans « Ajouter un
compte » plus haut, puis réessayez.
**« le trousseau du système écrirait le mot de passe en clair (backend
...) ».**
Le backend actif de `keyring` n'est pas de ceux qu'on sait vraiment
chiffrer — ça arrive en SSH, dans un conteneur, ou sur une machine sans
session graphique, où `keyring` retombe silencieusement sur un fichier en
clair. Le client refuse plutôt que de faire semblant que c'est sûr.
Utilisez le coffre KDBX à la place (voir plus haut), ou lancez-le là où un
vrai trousseau est déverrouillé.
**« Installez textual pour le client courriel (pip). »**
`textual` n'est pas installé. `Courriel > [1] Ouvrir le client courriel
(TUI)` affiche seulement ce message et revient au menu ; tout le reste
(comptes, synchronisation, cache) fonctionne quand même sans lui.
**Le cache d'un dossier signale qu'il a changé (`UIDVALIDITY`).**
Rien à faire — le client purge et resynchronise ce dossier tout seul à la
prochaine synchronisation. La liste des messages se vide puis se remplit
brièvement.
**« cache illisible, purgez-le et resynchronisez : ... ».**
Le `cache.db` du compte est corrompu. `Courriel > [4] Cache > [3] Taille
du cache et purge` peut lui-même échouer à ouvrir ce même fichier cassé ;
le cas échéant, effacez à la main le dossier de cache du compte et
resynchronisez :
<!-- [common] -->
```bash
rm -rf ~/.erplibre/mail/<account>/
```
<!-- [en] -->
## Testing against a real server
Almost every mail test uses an in-memory double. A double only produces what
its author imagined, which is how three protocol bugs reached users. So there
is also a **sandbox**: a real IMAP server (Twisted) and a real SMTP server
(aiosmtpd) that a test starts on an ephemeral loopback port, talks to over
real TCP, and kills when it finishes — pass or fail.
The point is not conformance. A well-behaved server proves little; this one
can **misbehave on purpose**. A test declares the exact bytes a message is
made of — raw 8-bit header bytes, an `unknown-8bit` charset — and can drop the
connection or refuse a command mid-sync. Adding a new hostile behaviour is a
small subclass in `test/mail_sandbox.py`, not a new server.
These tests do **not** run in the fast loop. Without `twisted` and `aiosmtpd`
the whole file skips visibly. Run them deliberately:
<!-- [fr] -->
## Tester contre un vrai serveur
Presque tous les tests courriel passent par un double en mémoire. Un double ne
produit que ce que son auteur avait imaginé — c'est par là que trois bugs de
protocole sont arrivés jusqu'aux utilisateurs. D'où un **bac à sable** : un
vrai serveur IMAP (Twisted) et un vrai serveur SMTP (aiosmtpd), qu'un test
démarre sur un port éphémère de la boucle locale, à qui il parle en vrai TCP,
et qu'il tue en terminant — qu'il réussisse ou qu'il échoue.
Le but n'est pas la conformité. Un serveur poli ne prouve pas grand-chose ;
celui-ci sait **se conduire mal exprès**. Un test déclare les octets exacts
d'un message — en-tête en 8 bits bruts, charset `unknown-8bit` — et peut
couper la connexion ou refuser une commande en pleine synchronisation.
Ajouter une nouvelle méchanceté est une petite sous-classe dans
`test/mail_sandbox.py`, pas un nouveau serveur.
Ces tests ne tournent **pas** dans la boucle rapide. Sans `twisted` ni
`aiosmtpd`, tout le fichier se saute visiblement. Pour les lancer
volontairement :
<!-- [common] -->
```bash
.venv.erplibre/bin/python -m unittest discover -s test \
-p test_mail_live_server.py -v
```
<!-- [en] -->
What it does **not** cover, and will not pretend to:
- **`SPECIAL-USE`** — Twisted announces only `IMAP4REV1 NAMESPACE IDLE`. The
bug where a sent message was filed under a guessed folder name instead of
the one the server announced is therefore out of reach. Implementing the
extension in the sandbox would only test our own assumption about it, which
is the exact failure this sandbox exists to escape.
- **No provider quirk** — Gmail's label-as-folder model, Microsoft's OAuth,
Apple app passwords: none of it is exercised. The sandbox is a plain
RFC 3501 server, not a stand-in for a specific provider.
- **No TLS** — the sandbox talks in the clear on `127.0.0.1`. `starttls` and
`ssl` code paths are not exercised here.
- **Nothing leaves the machine** — no external host, no OS keyring, no
`~/.erplibre`, no real credentials, and never a fixed port.
<!-- [fr] -->
Ce qu'il ne couvre **pas**, et ne fera pas semblant de couvrir :
- **`SPECIAL-USE`** — Twisted n'annonce que `IMAP4REV1 NAMESPACE IDLE`. Le bug
du message classé sous un nom de dossier deviné plutôt que sous celui
annoncé par le serveur reste donc hors de portée. Implémenter l'extension
dans le bac à sable ne testerait que notre propre supposition à son sujet —
précisément l'erreur que ce bac à sable existe pour éviter.
- **Aucune particularité de fournisseur** — les dossiers-étiquettes de Gmail,
OAuth chez Microsoft, les mots de passe d'application d'Apple : rien de tout
cela n'est exercé. Le bac à sable est un serveur RFC 3501 ordinaire, pas la
doublure d'un fournisseur précis.
- **Pas de TLS** — le bac à sable parle en clair sur `127.0.0.1`. Les chemins
`starttls` et `ssl` ne sont pas exercés ici.
- **Rien ne quitte la machine** — aucun hôte externe, aucun trousseau système,
aucun `~/.erplibre`, aucun identifiant réel, et jamais un port fixe.
<!-- [en] -->
## Phase 1 limits
- **No OAuth** — Gmail, Outlook and iCloud need an app password (see
above); OAuth is phase 2.
- **No statistics** — no read/unread counters or activity dashboards beyond
the per-folder unseen count shown in the folder tree.
- **No server-side search** — `/` filters only what's already synced to the
local cache.
- **No offline outbox** — sending requires the account to be online; there
is no queue that flushes once you're back online.
See the [design spec](../docs/superpowers/specs/2026-08-02-email-tui-design.md)
for what the following phases add.
<!-- [fr] -->
## Limites de la phase 1
- **Pas d'OAuth** — Gmail, Outlook et iCloud demandent un mot de passe
d'application (voir plus haut) ; OAuth arrive en phase 2.
- **Pas de statistiques** — aucun compteur lu/non lu global ni tableau de
bord d'activité, au-delà du compte de non-lus par dossier affiché dans
l'arbre.
- **Pas de recherche côté serveur** — `/` ne filtre que ce qui est déjà
synchronisé dans le cache local.
- **Pas de file d'attente hors ligne** — l'envoi exige que le compte soit
en ligne ; rien ne se met en attente pour partir au retour du réseau.
Voir le [spec de conception](../docs/superpowers/specs/2026-08-02-email-tui-design.md)
pour ce qu'apportent les phases suivantes.

340
doc/EMAIL.fr.md Normal file
View file

@ -0,0 +1,340 @@
# Client courriel
Un client courriel intégré au CLI TODO : plusieurs comptes, IMAP + SMTP, et
un cache local — pour lire et répondre à son courriel sans quitter
`./script/todo/todo.py`.
Chaque chemin `Courriel > ...` ci-dessous est un raccourci pour
`TODO > [3] Assistant > [2] Courriel - Lire et envoyer du courriel > ...` — le
chemin complet est écrit une fois, dans « Ajouter un compte ».
## Prérequis
Quatre paquets Python, déjà listés dans
`requirement/erplibre_require-ments.txt` (l'environnement `.venv.erplibre`,
pas un venv Odoo) :
- `cryptography` — scelle le cache local en mode `encrypted` et `ephemeral`.
- `keyring` — le trousseau système, l'un des deux endroits où peut vivre un
mot de passe.
- `pykeepass` — le coffre KDBX, l'autre endroit, celui que le client essaie
en premier.
- `textual` — l'interface terminal elle-même. Sans lui, « Ouvrir le client
courriel (TUI) » affiche un message et ne fait rien ; le reste du menu
(comptes, synchronisation, cache) fonctionne quand même.
Installez-les avec :
```bash
.venv.erplibre/bin/pip install -r requirement/erplibre_require-ments.txt
```
### Mots de passe d'application pour Gmail, Outlook et iCloud
La phase 1 ne parle qu'IMAP/SMTP en authentification simple — pas encore
OAuth (ça, c'est la phase 2). Gmail, Outlook et iCloud ont tous les trois
fermé cette porte au vrai mot de passe du compte : chacun de ces trois
préréglages exige donc un **mot de passe d'application** à la place :
| Fournisseur | Où le générer |
|---|---|
| Gmail | Activez la validation en deux étapes, puis [myaccount.google.com](https://myaccount.google.com/security) > Sécurité > Mots de passe des applications |
| Outlook / Microsoft 365 | [account.microsoft.com](https://account.microsoft.com/security) > Sécurité > Options de sécurité avancées > Mots de passe d'application |
| iCloud | [account.apple.com](https://account.apple.com/) > Connexion et sécurité > Mots de passe spécifiques aux applications |
Utilisez ce mot de passe généré quand la configuration du compte en demande
un — jamais le mot de passe normal du compte. Le préréglage « Serveur
standard » (IMAP/SMTP générique) n'en a pas besoin.
## Ajouter un compte
Chemin de menu : `TODO > [3] Assistant > [2] Courriel - Lire et envoyer du
courriel > [2] Comptes > [2] Ajouter un compte`.
Les questions, dans l'ordre :
1. **Nom court du compte** — devient à la fois le nom de dossier sous
`~/.erplibre/mail/` et la référence dans le coffre : il ne peut donc pas
contenir `/` ni commencer par un point.
2. **Adresse courriel**.
3. **Nom affiché** (facultatif) — apparaît dans l'en-tête `De :` comme
`Nom affiché <email>`.
4. **Fournisseur** — un numéro dans la liste affichée : Gmail, Outlook,
iCloud, ou « Serveur standard » (IMAP/SMTP générique).
5. Si vous choisissez « Serveur standard », le **serveur IMAP** puis le
**serveur SMTP** sont demandés ensuite ; les autres préréglages les
remplissent déjà pour vous.
6. Si le préréglage exige un mot de passe d'application, sa note s'affiche
ici en rappel.
7. **Mot de passe** — saisi masqué (`getpass`), puis rangé dans le coffre —
jamais écrit dans `accounts.json`.
Où va le mot de passe : à l'étape du mot de passe, le client passe par le
**gestionnaire KDBX** partagé du CLI — le même que pour la clé OpenAI et
les identifiants Odoo. Il lit `kdbx.path` / `kdbx.password` dans la
configuration TODO (`script/todo/todo.json`, surchargeable dans
`private/todo/todo_override.json` / `private/todo/todo_override_private.json`).
Si `kdbx.path` n'est pas encore réglé, une fenêtre de sélection de fichier
s'ouvre pour choisir un `.kdbx` existant — il faut un affichage graphique,
et l'annuler (ou lancer le CLI sans affichage) fait échouer la création du
compte avec « le fichier kdbx n'a pas pu être ouvert » (voir Dépannage).
**Réglez `kdbx.path` (et `kdbx.password`, pour éviter l'invite) avant
d'ajouter votre premier compte**, en pointant vers un coffre `.kdbx` que
vous avez déjà (créez-en un avec KeePassXC ou équivalent). Le trousseau
système ne sert que pour un compte dont la `secret_ref` le désigne déjà —
le menu écrit toujours les nouveaux comptes dans le coffre KDBX.
`accounts.json` (dans `~/.erplibre/mail/accounts.json`) ne contient jamais
qu'une `secret_ref` du genre `kdbx:ERPLibre/Mail/perso` — une référence,
jamais le secret. Il est sans danger à lire, à éditer à la main, ou à
mettre dans une sauvegarde privée.
## Les trois modes de cache
Chaque compte garde un cache local — une petite base SQLite plus un fichier
par message téléchargé — pour que la boîte de réception reste lisible hors
ligne. Trois modes contrôlent ce que ce cache laisse sur le disque :
| Mode | Ce qui reste sur le disque | Clé de chiffrement |
|---|---|---|
| `clear` (par défaut) | `~/.erplibre/mail/<compte>/cache.db` et les fichiers `.eml`, lisibles en clair | aucune |
| `encrypted` | même emplacement, mais l'expéditeur, les destinataires, le sujet, l'extrait, le Message-ID et le corps des messages sont scellés en AES-256-GCM | générée une fois, rangée dans le coffre à côté du mot de passe (`.../cache-key`) |
| `ephemeral` | sous `/dev/shm/erplibre-mail-<pid>/<compte>/` (ou le dossier temporaire système si `/dev/shm` n'est pas inscriptible), scellé comme `encrypted` | tirée en RAM à chaque lancement, jamais écrite nulle part, et tout le dossier est effacé à la fermeture de la session |
Même en mode `clear`, les champs techniques dont le SQL a besoin pour trier
et filtrer — UID, dossier, date, drapeaux, taille — restent toujours en
clair ; seuls les champs qui identifient des personnes (et le corps du
message) sont scellés, et seulement en `encrypted`/`ephemeral`.
Réglez le **défaut général** dans `Courriel > [4] Cache > [1] Mode de cache
par défaut` ; c'est la préférence `mail_cache_mode` (défaut `clear`).
**Surchargez-le par compte** dans `Courriel > [4] Cache > [2] Mode de cache
d'un compte` — ceci écrit le champ `cache_mode` du compte dans
`accounts.json` ; le laisser à `null` là-bas veut dire « hérite du défaut
général ».
`Courriel > [4] Cache > [3] Taille du cache et purge` liste le mode
effectif et l'espace disque de chaque compte, et peut effacer entièrement le
cache d'un compte (après confirmation) — la prochaine synchronisation le
reconstruit à partir de zéro.
## Le TUI
`Courriel > [1] Ouvrir le client courriel (TUI)` ouvre un écran en trois
volets : l'arbre comptes/dossiers à gauche, la liste des messages au
centre, et un aperçu à droite, avec une ligne de statut en bas.
| Touche | Action |
|---|---|
| `↑` `↓` `Tab` | se déplacer dans un volet / changer de volet (comportement par défaut de Textual) |
| `h` | ouvre la fenêtre d'aide : tous les raccourcis et quelques repères, fermée par `Échap` |
| `z` | plein écran sur l'aperçu (masque l'arbre et la liste) |
| `Échap` | quitter le plein écran |
| `v` | change de disposition : colonnes, partagée, empilée |
| `+` / `-` | agrandir / rétrécir le volet qui a le focus |
| `0` | revenir aux tailles de volets par défaut |
| `r` | synchronise le compte du dossier actuellement sélectionné (tous ses dossiers) |
| `Shift+R` | synchronise tous les comptes |
| `/` | ouvre le champ de recherche (filtre seulement la liste déjà affichée — localement, sur sujet/de/à/extrait ; ne cherche pas sur le serveur) |
| `s` / `u` | marquer le message sélectionné lu / non lu |
| `c` | écrire un nouveau message |
| `a` / `Shift+A` | répondre / répondre à tous |
| `f` | transférer |
| `w` | enregistrer la **première** pièce jointe du message dans `~/Téléchargements` (créé s'il n'existe pas) |
| `n` | ajouter un compte sans quitter le client |
| `l` | affiche la fin de `~/.erplibre/mail.log` et les erreurs de synchronisation de la session |
| `q` | quitter |
Ce tableau est écrit à la main et peut prendre du retard sur le code ; la
fenêtre `h`, elle, ne le peut pas : elle construit sa liste depuis les
liaisons de l'application à chaque ouverture. En cas de désaccord entre les
deux, c'est elle qui a raison.
Les barres entre les volets se glissent aussi à la souris, et les tailles
sont retenues par disposition.
Les indices de touches du pied d'écran, comme la fenêtre d'aide, suivent la
langue choisie dans le CLI, tout comme l'arbre des comptes, la liste et le
texte d'aperçu.
## Écrire un message
`c` ouvre le formulaire : `À`, `Cc`, `Objet`, un champ `Pièces jointes`
(chemins de fichiers séparés par un point-virgule — une virgule est légale
dans un nom de fichier, donc seul `;` sépare les entrées ; il n'y a pas de
sélecteur de fichier, tapez les chemins), et un corps multi-lignes.
`e` envoie le corps vers `$EDITOR` (ou `nano` si non défini) et le relit ;
si l'éditeur manque ou sort en erreur, le texte de départ est conservé tel
quel. `Ctrl+S` (ou le bouton Envoyer) remet le message ; `Échap` abandonne
le brouillon — il n'y a pas d'enregistrement en brouillon.
`a` (répondre) et `Shift+A` (répondre à tous) préremplissent `À`/`Cc`/
`Objet`/`In-Reply-To`/`References` et citent le message d'origine dans le
corps. `f` (transférer) préremplit l'objet en `Fwd:` et **rattache le
message d'origine** automatiquement, en pièce jointe `message/rfc822` ; le
corps, lui, part vide — écrivez votre propre mot au-dessus du message
joint.
Répondre, répondre à tous et transférer ont tous besoin du corps du message
d'origine — depuis le cache, ou récupéré en direct si le compte est en
ligne ; sans l'un ou l'autre, vous obtenez « Aucun message sélectionné. » /
« Aucun message à transférer. ».
Envoyer exige que le compte soit en ligne (écrire hors ligne échoue avec
« Compte hors ligne : envoi impossible. » — il n'y a pas de file d'attente
hors ligne). Une fois envoyé, une copie est classée dans le dossier
Envoyés du compte par IMAP ; si ce classement échoue, la ligne de statut le
dit, mais le message est déjà parti — il n'est pas renvoyé.
## Synchronisation
Une passe de synchronisation est incrémentale : seuls les UID supérieurs au
dernier connu sont demandés, le corps des messages n'est jamais téléchargé
pendant une passe (seulement les en-têtes), et les corps sont récupérés à
la demande à l'ouverture d'un message. Les drapeaux (lu/non lu, etc.) des
messages déjà connus sont revérifiés à chaque passe, donc un message lu
ailleurs apparaît correctement lu ici aussi.
La synchronisation a lieu :
- **Au lancement** — ouvrir le TUI déclenche une synchronisation de tous
les comptes en arrière-plan.
- **À la demande** — `r` (compte courant) / `Shift+R` (tous les comptes)
dans le TUI, ou `Courriel > [3] Synchroniser maintenant` depuis le menu
CLI (affiche un résumé par compte dans le terminal).
- **Automatiquement, toutes les `mail_refresh_sec` secondes** (défaut 300 =
5 minutes ; 0 la désactive) — **mais seulement tant que le TUI est
ouvert**. Fermez-le et la minuterie part avec lui ; rien ne se
synchronise en arrière-plan ensuite.
Si le serveur annonce un `UIDVALIDITY` changé pour un dossier (ses UID ne
veulent plus dire ce qu'ils disaient — typiquement après une migration
côté serveur), le cache de ce dossier est purgé et resynchronisé à partir
de zéro automatiquement ; il n'y a actuellement aucun avis à l'écran
au-delà du dossier qui se vide puis se remplit à nouveau brièvement.
## Où sont les fichiers
| Chemin | Contenu |
|---|---|
| `~/.erplibre/mail/accounts.json` | la liste des comptes — serveurs, préréglages, mode de cache, et une référence `secret_ref` ; jamais un mot de passe (mode 0600) |
| `~/.erplibre/mail/<compte>/cache.db` | le cache SQLite de ce compte (mode 0600, dossier parent 0700) |
| `~/.erplibre/mail/<compte>/<dossier>/<uid>.eml` (ou `.eml.enc` s'il est scellé) | un fichier par corps de message téléchargé |
| `/dev/shm/erplibre-mail-<pid>/<compte>/` | le cache d'un compte `ephemeral` pendant que le processus vit ; effacé à sa sortie (un balayage au démarrage nettoie aussi ce qu'un processus tué aurait laissé) |
## Dépannage
Les messages d'erreur qui viennent du paquet courriel lui-même
(`secrets.py`, `store.py`, `crypto.py`, `accounts.py`, `smtp_send.py`,
`imap_transport.py`, `imap_sync.py`) passent maintenant par la couche de
traduction du CLI, comme les invites de menu et les libellés du TUI :
lancer le CLI en anglais les affiche en anglais. Le libellé ci-dessous est
cité en français, la langue de référence de ce document ; attendez-vous au
libellé anglais correspondant avec `EL_LANG=en`.
**« Connexion échouée : ... » en ajoutant ou en testant un compte.**
`Courriel > [2] Comptes > [5] Tester la connexion d'un compte` affiche
l'erreur exacte du serveur puis redemande le mot de passe — jusqu'à 3
tentatives. Le mot de passe dans le coffre n'est écrasé qu'*après* une
connexion réussie, donc une faute de frappe ne détruit jamais un mot de
passe qui fonctionnait. Si le compte est Gmail, Outlook ou iCloud,
vérifiez d'abord que vous avez utilisé un mot de passe d'application (voir
« Prérequis » plus haut), pas le mot de passe normal du compte. Ouvrir le
TUI lui-même ne relance pas cette demande automatiquement : un compte au
mot de passe refusé porte un ⚠ ; s'il avait déjà synchronisé avec succès,
ses dossiers déjà en cache restent visibles et lisibles, ils cessent
seulement de se rafraîchir — seul un compte tout neuf (rien de
synchronisé encore) n'affiche aucun dossier du tout. Dans tous les cas,
passez par « Tester la connexion d'un compte » pour corriger.
**« le fichier kdbx n'a pas pu être ouvert » en ajoutant un compte.**
Le coffre KDBX partagé n'est pas encore configuré, sa fenêtre de sélection
de fichier a été annulée, ou le CLI tourne sans affichage pour la montrer.
Réglez `kdbx.path` (et `kdbx.password`) comme décrit dans « Ajouter un
compte » plus haut, puis réessayez.
**« le trousseau du système écrirait le mot de passe en clair (backend
...) ».**
Le backend actif de `keyring` n'est pas de ceux qu'on sait vraiment
chiffrer — ça arrive en SSH, dans un conteneur, ou sur une machine sans
session graphique, où `keyring` retombe silencieusement sur un fichier en
clair. Le client refuse plutôt que de faire semblant que c'est sûr.
Utilisez le coffre KDBX à la place (voir plus haut), ou lancez-le là où un
vrai trousseau est déverrouillé.
**« Installez textual pour le client courriel (pip). »**
`textual` n'est pas installé. `Courriel > [1] Ouvrir le client courriel
(TUI)` affiche seulement ce message et revient au menu ; tout le reste
(comptes, synchronisation, cache) fonctionne quand même sans lui.
**Le cache d'un dossier signale qu'il a changé (`UIDVALIDITY`).**
Rien à faire — le client purge et resynchronise ce dossier tout seul à la
prochaine synchronisation. La liste des messages se vide puis se remplit
brièvement.
**« cache illisible, purgez-le et resynchronisez : ... ».**
Le `cache.db` du compte est corrompu. `Courriel > [4] Cache > [3] Taille
du cache et purge` peut lui-même échouer à ouvrir ce même fichier cassé ;
le cas échéant, effacez à la main le dossier de cache du compte et
resynchronisez :
```bash
rm -rf ~/.erplibre/mail/<account>/
```
## Tester contre un vrai serveur
Presque tous les tests courriel passent par un double en mémoire. Un double ne
produit que ce que son auteur avait imaginé — c'est par là que trois bugs de
protocole sont arrivés jusqu'aux utilisateurs. D'où un **bac à sable** : un
vrai serveur IMAP (Twisted) et un vrai serveur SMTP (aiosmtpd), qu'un test
démarre sur un port éphémère de la boucle locale, à qui il parle en vrai TCP,
et qu'il tue en terminant — qu'il réussisse ou qu'il échoue.
Le but n'est pas la conformité. Un serveur poli ne prouve pas grand-chose ;
celui-ci sait **se conduire mal exprès**. Un test déclare les octets exacts
d'un message — en-tête en 8 bits bruts, charset `unknown-8bit` — et peut
couper la connexion ou refuser une commande en pleine synchronisation.
Ajouter une nouvelle méchanceté est une petite sous-classe dans
`test/mail_sandbox.py`, pas un nouveau serveur.
Ces tests ne tournent **pas** dans la boucle rapide. Sans `twisted` ni
`aiosmtpd`, tout le fichier se saute visiblement. Pour les lancer
volontairement :
```bash
.venv.erplibre/bin/python -m unittest discover -s test \
-p test_mail_live_server.py -v
```
Ce qu'il ne couvre **pas**, et ne fera pas semblant de couvrir :
- **`SPECIAL-USE`** — Twisted n'annonce que `IMAP4REV1 NAMESPACE IDLE`. Le bug
du message classé sous un nom de dossier deviné plutôt que sous celui
annoncé par le serveur reste donc hors de portée. Implémenter l'extension
dans le bac à sable ne testerait que notre propre supposition à son sujet —
précisément l'erreur que ce bac à sable existe pour éviter.
- **Aucune particularité de fournisseur** — les dossiers-étiquettes de Gmail,
OAuth chez Microsoft, les mots de passe d'application d'Apple : rien de tout
cela n'est exercé. Le bac à sable est un serveur RFC 3501 ordinaire, pas la
doublure d'un fournisseur précis.
- **Pas de TLS** — le bac à sable parle en clair sur `127.0.0.1`. Les chemins
`starttls` et `ssl` ne sont pas exercés ici.
- **Rien ne quitte la machine** — aucun hôte externe, aucun trousseau système,
aucun `~/.erplibre`, aucun identifiant réel, et jamais un port fixe.
## Limites de la phase 1
- **Pas d'OAuth** — Gmail, Outlook et iCloud demandent un mot de passe
d'application (voir plus haut) ; OAuth arrive en phase 2.
- **Pas de statistiques** — aucun compteur lu/non lu global ni tableau de
bord d'activité, au-delà du compte de non-lus par dossier affiché dans
l'arbre.
- **Pas de recherche côté serveur** — `/` ne filtre que ce qui est déjà
synchronisé dans le cache local.
- **Pas de file d'attente hors ligne** — l'envoi exige que le compte soit
en ligne ; rien ne se met en attente pour partir au retour du réseau.
Voir le [spec de conception](../docs/superpowers/specs/2026-08-02-email-tui-design.md)
pour ce qu'apportent les phases suivantes.

318
doc/EMAIL.md Normal file
View file

@ -0,0 +1,318 @@
# Mail client
A mail client built into the TODO CLI: several accounts, IMAP + SMTP, and a
local cache — so you can read and answer email without leaving
`./script/todo/todo.py`.
Every `Mail > ...` path below is shorthand for
`TODO > [3] Assistant > [2] Mail - Read and send email > ...` — the full path
is spelled out once, in "Adding an account".
## Prerequisites
Four Python packages, already listed in `requirement/erplibre_require-ments.txt`
(the `.venv.erplibre` environment, not an Odoo venv):
- `cryptography` — seals the local cache in `encrypted` and `ephemeral` mode.
- `keyring` — the system keyring, one of the two places a password can live.
- `pykeepass` — the KDBX vault, the other place, and the one the client tries
first.
- `textual` — the terminal UI itself. Without it, "Open the mail client
(TUI)" prints a message and does nothing; the rest of the menu (accounts,
sync, cache) still works.
Install them with:
```bash
.venv.erplibre/bin/pip install -r requirement/erplibre_require-ments.txt
```
### App passwords for Gmail, Outlook and iCloud
Phase 1 speaks plain IMAP/SMTP login only — no OAuth yet (that is phase 2).
Gmail, Outlook and iCloud have all closed that door to the account's real
password, so each of these three presets requires an **app password**
instead:
| Provider | Where to generate it |
|---|---|
| Gmail | Enable 2-step verification, then [myaccount.google.com](https://myaccount.google.com/security) > Security > App passwords |
| Outlook / Microsoft 365 | [account.microsoft.com](https://account.microsoft.com/security) > Security > Advanced security options > App passwords |
| iCloud | [account.apple.com](https://account.apple.com/) > Sign-In and Security > App-Specific Passwords |
Use that generated password when account setup asks for one — never the
account's normal password. The "Standard server" preset (generic IMAP/SMTP)
does not need one.
## Adding an account
Menu path: `TODO > [3] Assistant > [2] Mail - Read and send email > [2]
Accounts > [2] Add an account`.
The prompts, in order:
1. **Short account name** — becomes both the folder name under
`~/.erplibre/mail/` and the vault reference, so it cannot contain `/` or
start with a dot.
2. **Email address**.
3. **Display name** (optional) — shown in the `From:` header as
`Display Name <email>`.
4. **Provider** — a number from the printed list: Gmail, Outlook, iCloud, or
"Standard server" (generic IMAP/SMTP).
5. If you picked "Standard server", the **IMAP host** and **SMTP host** are
asked next; the other presets fill these in for you.
6. If the preset requires an app password, its note is printed here as a
reminder.
7. **Password** — typed hidden (`getpass`), then stored — never written to
`accounts.json`.
Where the password goes: at the password step, the client hands off to the
CLI's shared **KDBX manager** — the same one already used for the OpenAI
key and Odoo credentials. It reads `kdbx.path` / `kdbx.password` from the
TODO config (`script/todo/todo.json`, overridable in
`private/todo/todo_override.json` / `private/todo/todo_override_private.json`).
If `kdbx.path` isn't set yet, a graphical file picker pops up asking you to
choose an existing `.kdbx` file — it needs a display, and cancelling it (or
running headless) fails account creation with "le fichier kdbx n'a pas pu
être ouvert" (French — see Troubleshooting). **Set `kdbx.path` (and
`kdbx.password`, to skip the prompt) before adding your first account**,
pointing at a `.kdbx` vault you already have (create one with KeePassXC or
similar). The system keyring is only ever used for an account whose
`secret_ref` already points at one — the menu itself always writes new
accounts into the KDBX vault.
`accounts.json` (at `~/.erplibre/mail/accounts.json`) only ever holds a
`secret_ref` such as `kdbx:ERPLibre/Mail/perso` — a pointer, never the
secret. It is safe to read, edit by hand, or check into a private backup.
## The three cache modes
Every account keeps a local cache — a small SQLite database plus one file
per downloaded message — so the inbox stays readable offline. Three modes
control what that cache leaves on disk:
| Mode | What's on disk | Encryption key |
|---|---|---|
| `clear` (default) | `~/.erplibre/mail/<account>/cache.db` and `.eml` files, readable as plain text | none |
| `encrypted` | same location, but sender, recipients, subject, snippet, message-id and message bodies are sealed with AES-256-GCM | generated once, stored in the vault next to the password (`.../cache-key`) |
| `ephemeral` | under `/dev/shm/erplibre-mail-<pid>/<account>/` (or the system temp dir if `/dev/shm` isn't writable), sealed the same way as `encrypted` | generated fresh in RAM at every run, never written anywhere, and the whole directory is removed when the session closes |
Even in `clear` mode, the technical fields the SQL needs to sort and
filter — UID, folder, date, flags, size — are always plain; only the
person-identifying fields (and the message body) are ever sealed, and only
in `encrypted`/`ephemeral`.
Set the **general default** at `Mail > [4] Cache > [1] Default cache mode`;
it is the `mail_cache_mode` preference (default `clear`). **Override it per
account** at `Mail > [4] Cache > [2] Cache mode of one account` — this
writes the account's `cache_mode` field in `accounts.json`; leaving it at
`null` there means "inherit the general default."
`Mail > [4] Cache > [3] Cache size and purge` lists every account's
effective mode and disk usage, and can erase one account's cache entirely
(after confirmation) — the next sync rebuilds it from scratch.
## The TUI
`Mail > [1] Open the mail client (TUI)` opens a three-pane screen: an
account/folder tree on the left, the message list in the middle, and a
preview pane on the right, with a status line at the bottom.
| Key | Action |
|---|---|
| `↑` `↓` `Tab` | move within a pane / move focus between panes (Textual defaults) |
| `h` | open the help window: every shortcut plus a few notes, closed with `Escape` |
| `z` | toggle full-screen preview (hides the folder tree and the message list) |
| `Escape` | leave full-screen |
| `v` | cycle the layout: columns, split, stacked |
| `+` / `-` | grow / shrink the pane that has focus |
| `0` | back to the default pane sizes |
| `r` | sync the account of the currently selected folder (all its folders) |
| `Shift+R` | sync every account |
| `/` | open the search field (filters the currently visible list only — locally, over subject/from/to/snippet; it does not search the server) |
| `s` / `u` | mark the selected message seen / unseen |
| `c` | compose a new message |
| `a` / `Shift+A` | reply / reply all |
| `f` | forward |
| `w` | save the message's **first** attachment to `~/Téléchargements` (created if missing) |
| `n` | add an account without leaving the client |
| `l` | show the tail of `~/.erplibre/mail.log` and this session's sync errors |
| `q` | quit |
This table is written by hand and can fall behind the code; the `h` window
cannot. It builds its list from the application's own key bindings every time
it opens, so it is the reference if the two ever disagree.
The bars between the panes can also be dragged with the mouse, and pane sizes
are remembered per layout.
The footer's key hints, like the help window, follow the CLI's chosen
language, as do the account tree, the message list and the preview text.
## Writing a message
`c` opens the compose form: `To`, `Cc`, `Subject`, an `Attachments` field
(semicolon-separated file paths — a comma is legal in a filename, so only
`;` splits entries; there is no file picker, type the paths), and a
multi-line body. `e` sends the body out to `$EDITOR` (or
`nano` if unset) and reads it back; if the editor is missing or exits with
an error, the body you had is kept untouched. `Ctrl+S` (or the Send button)
delivers the message; `Escape` discards the draft — there is no
save-as-draft.
`a` (reply) and `Shift+A` (reply all) prefill `To`/`Cc`/`Subject`/
`In-Reply-To`/`References` and quote the original message in the body. `f`
(forward) prefills the `Fwd:` subject and **attaches the original message**
automatically, as a `message/rfc822` attachment; the body itself starts
empty — write your own note above the attached original.
Reply, reply-all and forward all need the original message's body
available — from the cache, or fetched live if the account is online; with
neither, you get "No message selected." / "No message to forward."
Sending requires the account to be online (composing offline fails with
"Account offline: cannot send." — there is no offline outbox). Once sent, a
copy is filed into the account's Sent folder over IMAP; if that filing step
fails, the status line says so, but the message has already left — it is
not resent.
## Synchronization
A sync pass is incremental: only UIDs above the last known one are
fetched, message bodies are never downloaded during a pass (only headers),
and bodies are fetched on demand when you open a message. Flags
(read/unread, etc.) of already-known messages are re-checked on every
pass, so a message read elsewhere shows up correctly here too.
Sync happens:
- **At launch** — opening the TUI kicks off one background sync of every
account.
- **On demand** — `r` (current account) / `Shift+R` (all accounts) inside
the TUI, or `Mail > [3] Synchronise now` from the CLI menu (prints a
per-account summary to the terminal).
- **Automatically, every `mail_refresh_sec` seconds** (default 300 = 5
minutes; 0 disables it) — **but only while the TUI is open**. Close it
and the timer goes with it; nothing syncs in the background afterward.
If the server reports a changed `UIDVALIDITY` for a folder (its UIDs no
longer mean what they used to — typically after a server-side migration),
that folder's cache is purged and resynced from scratch automatically;
there is currently no on-screen notice when this happens beyond the folder
briefly emptying and refilling.
## Where the files live
| Path | Contents |
|---|---|
| `~/.erplibre/mail/accounts.json` | account list — servers, presets, cache mode, and a `secret_ref` pointer; never a password (mode 0600) |
| `~/.erplibre/mail/<account>/cache.db` | that account's SQLite cache (mode 0600, parent directory 0700) |
| `~/.erplibre/mail/<account>/<folder>/<uid>.eml` (or `.eml.enc` when sealed) | one file per downloaded message body |
| `/dev/shm/erplibre-mail-<pid>/<account>/` | an `ephemeral` account's cache while the process is alive; removed when it exits (a sweep at every startup also clears directories left behind by a killed process) |
## Troubleshooting
Error messages raised by the mail package itself (`secrets.py`,
`store.py`, `crypto.py`, `accounts.py`, `smtp_send.py`,
`imap_transport.py`, `imap_sync.py`) now go through the CLI's translation
layer, the same as the menu prompts and TUI labels: running the CLI in
English shows them in English. The wording below is quoted in French, this
document's reference language; expect the matching English wording when
`EL_LANG=en`.
**"Connection failed: ..." when adding or testing an account.**
`Mail > [2] Accounts > [5] Test an account connection` prints the server's
exact error and then asks for the password again — up to 3 attempts. The
password in the vault is only overwritten *after* a successful connection,
so a typo never destroys a working password. If the account is Gmail,
Outlook or iCloud, check first that you used an app password (see
"Prerequisites" above), not the account's normal one. Opening the TUI
itself does not retry automatically: an account with a rejected password
gets a ⚠ marker; if it had synced successfully before, its already-cached
folders stay visible and readable, they just stop refreshing — only a
brand-new account (nothing synced yet) shows no folders at all. Either
way, go run "Test an account connection" to fix it.
**"le fichier kdbx n'a pas pu être ouvert" when adding an account.**
The shared KDBX vault isn't configured yet, its file picker was cancelled,
or the CLI is running without a display to show that picker. Set
`kdbx.path` (and `kdbx.password`) as described in "Adding an account"
above, then try again.
**"le trousseau du système écrirait le mot de passe en clair (backend
...)".**
`keyring`'s active backend isn't one of the ones known to actually
encrypt — this happens over SSH, in a container, or on a machine with no
desktop session, where `keyring` silently falls back to a plaintext file
store. The client refuses rather than pretend that's safe. Use the KDBX
vault instead (see above), or run somewhere a real keyring is unlocked.
**"Install textual for the mail client (pip)."**
`textual` isn't installed. `Mail > [1] Open the mail client (TUI)` just
prints this and returns; every other menu entry (accounts, sync, cache)
still works without it.
**The folder cache says it changed (`UIDVALIDITY`).**
Nothing to do — the client purges and resyncs that folder by itself the
next time it syncs. Expect the message list to empty briefly and refill.
**"cache illisible, purgez-le et resynchronisez : ...".**
The account's `cache.db` is corrupt. `Mail > [4] Cache > [3] Cache size and
purge` may itself fail to open the same broken file; if so, delete the
account's cache directory by hand and resync:
```bash
rm -rf ~/.erplibre/mail/<account>/
```
## Testing against a real server
Almost every mail test uses an in-memory double. A double only produces what
its author imagined, which is how three protocol bugs reached users. So there
is also a **sandbox**: a real IMAP server (Twisted) and a real SMTP server
(aiosmtpd) that a test starts on an ephemeral loopback port, talks to over
real TCP, and kills when it finishes — pass or fail.
The point is not conformance. A well-behaved server proves little; this one
can **misbehave on purpose**. A test declares the exact bytes a message is
made of — raw 8-bit header bytes, an `unknown-8bit` charset — and can drop the
connection or refuse a command mid-sync. Adding a new hostile behaviour is a
small subclass in `test/mail_sandbox.py`, not a new server.
These tests do **not** run in the fast loop. Without `twisted` and `aiosmtpd`
the whole file skips visibly. Run them deliberately:
```bash
.venv.erplibre/bin/python -m unittest discover -s test \
-p test_mail_live_server.py -v
```
What it does **not** cover, and will not pretend to:
- **`SPECIAL-USE`** — Twisted announces only `IMAP4REV1 NAMESPACE IDLE`. The
bug where a sent message was filed under a guessed folder name instead of
the one the server announced is therefore out of reach. Implementing the
extension in the sandbox would only test our own assumption about it, which
is the exact failure this sandbox exists to escape.
- **No provider quirk** — Gmail's label-as-folder model, Microsoft's OAuth,
Apple app passwords: none of it is exercised. The sandbox is a plain
RFC 3501 server, not a stand-in for a specific provider.
- **No TLS** — the sandbox talks in the clear on `127.0.0.1`. `starttls` and
`ssl` code paths are not exercised here.
- **Nothing leaves the machine** — no external host, no OS keyring, no
`~/.erplibre`, no real credentials, and never a fixed port.
## Phase 1 limits
- **No OAuth** — Gmail, Outlook and iCloud need an app password (see
above); OAuth is phase 2.
- **No statistics** — no read/unread counters or activity dashboards beyond
the per-folder unseen count shown in the folder tree.
- **No server-side search** — `/` filters only what's already synced to the
local cache.
- **No offline outbox** — sending requires the account to be online; there
is no queue that flushes once you're back online.
See the [design spec](../docs/superpowers/specs/2026-08-02-email-tui-design.md)
for what the following phases add.

View file

@ -33,3 +33,11 @@ TODO: having the DB variable configurable
<!-- [fr] -->
À FAIRE : rendre la variable DB configurable
<!-- [en] -->
See also: [EMAIL.md](EMAIL.md) — the mail client built into the TODO CLI
(`Assistant > Mail`).
<!-- [fr] -->
Voir aussi : [EMAIL.fr.md](EMAIL.fr.md) — le client courriel intégré au CLI
TODO (`Assistant > Courriel`).

View file

@ -11,4 +11,7 @@ Une base de données est installée mais on n'arrive pas à l'exécuter
HEALTHCHECK CMD curl --fail http://localhost:8069/web || exit 1
À FAIRE : rendre la variable DB configurable
À FAIRE : rendre la variable DB configurable
Voir aussi : [EMAIL.fr.md](EMAIL.fr.md) — le client courriel intégré au CLI
TODO (`Assistant > Courriel`).

View file

@ -12,3 +12,6 @@ A database is installed but cannot be executed
HEALTHCHECK CMD curl --fail http://localhost:8069/web || exit 1
TODO: having the DB variable configurable
See also: [EMAIL.md](EMAIL.md) — the mail client built into the TODO CLI
(`Assistant > Mail`).

5
private/.gitignore vendored
View file

@ -1 +1,6 @@
*.kdbx
# Per-database migration lists (modules to uninstall/install before a version
# bump). They describe one specific database, never a shared default, so they
# must not be versioned. Shared defaults belong to script/odoo/migration/.
odoo/

View file

@ -17,6 +17,8 @@ uvloop
python-randomword-fr
isort
pykeepass
cryptography
keyring
click
aioshutil
python-magic
@ -52,6 +54,14 @@ virtualenv==20.36.1
git+https://github.com/psf/black.git@24.8.0
pre-commit
# Bac à sable du client courriel : un VRAI serveur SMTP et un VRAI serveur
# IMAP, sur loopback, pour tester ce qu'un faux transport ne peut pas —
# littéraux IMAP, en-têtes 8 bits, coupures en plein FETCH. Utilisés
# UNIQUEMENT par les tests, jamais par le client lui-même : les tests
# concernés s'ignorent d'eux-mêmes si ces paquets manquent.
aiosmtpd
twisted
odoo-module-migrator
# Ignore because need installation system

View file

@ -0,0 +1,572 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Champs et modèles ajoutés hors module : Studio, ou faits à la main.
Ce que l'outil répond : ce qu'un intégrateur devra reporter à la main lors
d'une montée de version, puisque rien ne le recréera. Un champ ``x_`` n'est
déclaré dans aucun fichier ; il ne vit que dans ``ir_model_fields``, et une
migration qui le perd perd aussi les données de sa colonne.
Studio n'est pas nécessaire pour lire ça
-----------------------------------------
``web_studio`` est un module Enterprise, absent de ce dépôt. Les champs qu'il
crée restent pourtant de simples lignes de ``ir_model_fields`` avec
``state = 'manual'``, et une base migrée depuis une instance Enterprise garde
ses identifiants externes ``studio_customization``. Tout se lit en SQL.
Attribuer un champ à Studio demande deux signaux, pas un
---------------------------------------------------------
Un champ peut porter PLUSIEURS identifiants externes. Une jointure plate n'en
rendrait qu'un, choisi au hasard : Studio passerait inaperçu une fois sur
deux. Les modules sont donc agrégés, et l'appartenance testée sur l'ensemble.
Le préfixe ``x_studio_`` est un indice de plus, jamais le seul : un champ créé
à la main en mode développeur s'appelle aussi ``x_quelque_chose``, et ce qui
le distingue est qu'il n'a AUCUN identifiant externe.
Ce qui bloque, et ce qui ne fait que coûter
--------------------------------------------
Un champ stocké dont la colonne physique manque empêche le registre de
charger : c'est un blocage. Un champ dont la relation pointe vers un modèle
disparu aussi. Le reste — des champs remplis à reporter, des champs vides à
supprimer avant de migrer — est du travail, pas une panne, et le rapport ne
les mélange pas.
"""
import argparse
import json
import os
import sys
import textwrap
new_path = os.path.normpath(
os.path.join(os.path.dirname(__file__), "..", "..")
)
sys.path.append(new_path)
from script.analyse.lib_analyse import ( # noqa: E402
AnalyseError,
backup_version,
column_types,
existing_columns,
json_query,
model_table,
normalise_arch,
public_tables,
read_backup,
require_odoo_database,
scalar_query,
t,
tr_col,
)
STUDIO_MODULE = "studio_customization"
# Un champ relationnel « vers plusieurs » n'a pas de colonne : il vit dans une
# table de relation. Ne pas l'exclure ferait rapporter chaque one2many et
# chaque many2many comme une colonne manquante.
NO_COLUMN_TYPES = ("one2many", "many2many")
TOP_DEFAULT = 30
def wrap_note(prefix, text, width=79):
"""Replier une phrase à l'affichage, sans la découper en clés."""
lines = textwrap.wrap(text, width=width - len(prefix)) or [""]
pad = " " * len(prefix)
return [prefix + lines[0]] + [pad + line for line in lines[1:]]
def origin_label(name):
"""Libellé traduit d'une provenance. Épelé, pour que le contrôle voie."""
return {
"studio": t("Studio"),
"handmade": t("Made by hand"),
"module": t("Declared by a module"),
}.get(name, name)
def blocker_label(name):
"""Libellé traduit d'un blocage. Épelé, comme les provenances."""
return {
"missing_column": t("stored, but its column is missing"),
"dangling_relation": t("points at a model that no longer exists"),
"model_gone": t("its model no longer exists"),
"table_unknown": t("its table could not be resolved"),
}.get(name, name)
def field_origin(row):
"""D'où vient ce champ : Studio, fait à la main, ou déclaré par un module.
Fonction pure, testable sur fixture.
"""
lst_module = row.get("xmlid_modules") or []
if STUDIO_MODULE in lst_module:
return "studio"
if not lst_module:
return "handmade"
return "module"
def _field_rows(database, **kwargs):
"""Les champs manuels, avec tout ce qui sert à les juger.
``state = 'manual'`` est le critère d'Odoo ; le motif sur le nom l'élargit
aux bases dont la contrainte n'a pas toujours été posée. Les deux, parce
qu'aucun n'est complet seul.
"""
cols = existing_columns(database, "ir_model_fields", **kwargs)
dct_type = column_types(database, "ir_model_fields", **kwargs)
def col(column, absent):
return f"f.{column}" if column in cols else absent
label = tr_col("f", "field_description", dct_type)
help_text = tr_col("f", "help", dct_type)
return json_query(
database,
rf"""
SELECT f.id AS id,
f.model AS model,
f.name AS name,
f.ttype AS ttype,
{col("relation", "NULL::text")} AS relation,
{col("related", "NULL::text")} AS related,
{col("required", "false")} AS required,
{col("readonly", "false")} AS readonly,
{col("store", "true")} AS store,
{col("index", "false")} AS indexed,
{col("translate", "false")} AS translate,
{col("company_dependent", "false")} AS company_dependent,
({col("compute", "NULL")} IS NOT NULL
AND {col("compute", "''")} <> '') AS is_computed,
{label} AS label,
{help_text} AS help,
{col("state", "NULL::text")} AS state,
x.modules AS xmlid_modules,
{col("create_date", "NULL::timestamp")} AS create_date,
{col("write_date", "NULL::timestamp")} AS write_date
FROM ir_model_fields f
LEFT JOIN (
SELECT res_id, array_agg(DISTINCT module) AS modules
FROM ir_model_data
WHERE model = 'ir.model.fields'
GROUP BY res_id
) x ON x.res_id = f.id
WHERE {col("state", "''")} = 'manual' OR f.name LIKE 'x\_%'
ORDER BY f.model, f.name
""",
**kwargs,
)
def _model_rows(database, **kwargs):
"""Les modèles manuels — ceux que Studio crée comme objets personnalisés."""
dct_type = column_types(database, "ir_model", **kwargs)
label = tr_col("m", "name", dct_type)
return json_query(
database,
rf"""
SELECT m.model AS model,
{label} AS description,
m.state AS state,
m.transient AS transient,
x.modules AS xmlid_modules
FROM ir_model m
LEFT JOIN (
SELECT res_id, array_agg(DISTINCT module) AS modules
FROM ir_model_data
WHERE model = 'ir.model'
GROUP BY res_id
) x ON x.res_id = m.id
WHERE m.state = 'manual' OR m.model LIKE 'x\_%'
ORDER BY m.model
""",
**kwargs,
)
def _selection_rows(database, **kwargs):
"""Valeurs de sélection des champs manuels, si la table existe.
``ir_model_fields_selection`` est apparue en cours de route : avant, les
valeurs vivaient dans une chaîne du champ. On sonde plutôt que de dater.
"""
if not scalar_query(
database,
"SELECT to_regclass('public.ir_model_fields_selection');",
**kwargs,
):
return {}
rows = json_query(
database,
"""
SELECT f.model AS model, f.name AS name, s.value AS value
FROM ir_model_fields_selection s
JOIN ir_model_fields f ON f.id = s.field_id
WHERE f.state = 'manual'
ORDER BY f.model, f.name, s.sequence
""",
**kwargs,
)
dct = {}
for row in rows:
dct.setdefault((row["model"], row["name"]), []).append(row["value"])
return dct
def collect(database, config_path=None, timeout=120):
"""Tout le travail. Donnée pure, sérialisable, aucun affichage."""
kwargs = {"config_path": config_path, "timeout": timeout}
require_odoo_database(database, **kwargs)
odoo_version = scalar_query(
database,
"SELECT latest_version FROM ir_module_module WHERE name = 'base';",
**kwargs,
)
lst_field = _field_rows(database, **kwargs)
lst_model = _model_rows(database, **kwargs)
dct_selection = _selection_rows(database, **kwargs)
set_table = public_tables(database, **kwargs)
set_model = {
row["model"]
for row in json_query(database, "SELECT model FROM ir_model", **kwargs)
}
# Les colonnes réelles, une sonde par table concernée seulement.
dct_columns = {}
for row in lst_field:
table = model_table(row["model"], known_tables=set_table)
row["table"] = table
if table and table not in dct_columns:
dct_columns[table] = existing_columns(database, table, **kwargs)
lst_blocker = _judge(
lst_field, set_model, set_table, dct_columns, dct_selection
)
return _result(
database,
odoo_version,
lst_field,
lst_model,
lst_blocker,
source="database",
)
def _judge(lst_field, set_model, set_table, dct_columns, dct_selection):
"""Attribuer et juger chaque champ. Renvoie la liste des bloquants.
Partagé par la lecture d'une base et celle d'une sauvegarde : les deux
doivent conclure la même chose des mêmes faits, sinon le zip et la base
d'où il vient ne diraient pas pareil.
"""
lst_blocker = []
for row in lst_field:
row["origin"] = field_origin(row)
row["selection"] = dct_selection.get((row["model"], row["name"]))
row["blocker"] = None
if row["table"] is None:
# Table non résolue : un fait, pas une anomalie. Le modèle peut
# avoir un _table surchargé qu'on ne connaît pas, ou ne plus
# exister du tout — deux choses qu'on ne confond pas ici.
row["blocker"] = (
"model_gone"
if row["model"] not in set_model
else "table_unknown"
)
elif (
row["store"]
and row["ttype"] not in NO_COLUMN_TYPES
and row["name"] not in dct_columns[row["table"]]
):
# Un champ stocké sans sa colonne empêche le registre de charger.
row["blocker"] = "missing_column"
elif row["relation"] and row["relation"] not in set_model:
row["blocker"] = "dangling_relation"
if row["blocker"] in (
"missing_column",
"dangling_relation",
"model_gone",
):
lst_blocker.append(row)
return lst_blocker
def _result(
database,
odoo_version,
lst_field,
lst_model,
lst_blocker,
source="database",
):
"""La donnée de sortie, une seule forme quelle que soit la provenance."""
dct_origin = {"studio": 0, "handmade": 0, "module": 0}
for row in lst_field:
dct_origin[row["origin"]] += 1
return {
"tool": "analyse_custom_field",
"version": 1,
"database": database,
"source": source,
"odoo_version": odoo_version,
"n_fields": len(lst_field),
"n_models": len(lst_model),
"counts": {
**dct_origin,
"blockers": len(lst_blocker),
"models": len(lst_model),
},
"fields": lst_field,
"models": lst_model,
"blockers": lst_blocker,
}
def collect_from_backup(zip_path):
"""Même analyse, mais depuis une sauvegarde .zip, sans rien restaurer.
Pourquoi cela existe : restaurer la sauvegarde d'une instance Enterprise
sur une installation Community échoue — Odoo veut charger des modules
qu'on n'a pas. Les champs Studio, eux, ne sont que des lignes de
`ir_model_fields`, et un `dump.sql` est du texte. On les lit donc là où
ils sont, plutôt que d'exiger une restauration impossible.
Ce que la sauvegarde permet en moins : rien, pour cet outil. Le dump
contient les `CREATE TABLE`, donc même la colonne physique manquante — le
seul vrai bloquant — se détecte.
"""
manifest, dct_rows, dct_columns, _ = read_backup(
zip_path,
tables=(
"ir_model_fields",
"ir_model",
"ir_model_data",
"ir_module_module",
),
with_columns=True,
)
# Les identifiants externes, agrégés par champ et par modèle — le même
# regroupement que fait le SQL, pour que Studio s'attribue pareil.
dct_xmlid = {}
for row in dct_rows["ir_model_data"]:
key = (row.get("model"), row.get("res_id"))
dct_xmlid.setdefault(key, set()).add(row.get("module"))
lst_field = []
for row in dct_rows["ir_model_fields"]:
name = row.get("name") or ""
if row.get("state") != "manual" and not name.startswith("x_"):
continue
lst_field.append(
{
"id": row.get("id"),
"model": row.get("model"),
"name": name,
"ttype": row.get("ttype"),
"relation": row.get("relation"),
"related": row.get("related"),
# Le dump rend « t »/« f » : PostgreSQL écrit les booléens
# ainsi dans un COPY, et « f » est une chaîne vraie en Python.
"store": row.get("store") != "f",
"translate": row.get("translate") == "t",
"state": row.get("state"),
# Un champ traduit est du jsonb à partir de 16.0. Depuis une
# base, tr_col le déballe côté SQL ; depuis un dump, la valeur
# arrive brute, et « {"en_US": "Code client"} » ne se lit pas.
# normalise_arch fait ce déballage, et c'est la même fonction
# des deux côtés — deux implémentations divergeraient.
"label": normalise_arch(row.get("field_description")),
"help": normalise_arch(row.get("help")),
"xmlid_modules": sorted(
dct_xmlid.get(("ir.model.fields", row.get("id"))) or []
),
"create_date": row.get("create_date"),
"write_date": row.get("write_date"),
}
)
lst_model = [
{
"model": row.get("model"),
"description": normalise_arch(row.get("name")),
"state": row.get("state"),
"transient": row.get("transient") == "t",
"xmlid_modules": sorted(
dct_xmlid.get(("ir.model", row.get("id"))) or []
),
}
for row in dct_rows["ir_model"]
if row.get("state") == "manual"
or (row.get("model") or "").startswith("x_")
]
set_model = {row.get("model") for row in dct_rows["ir_model"]}
set_table = set(dct_columns)
for row in lst_field:
row["table"] = model_table(row["model"], known_tables=set_table)
lst_blocker = _judge(lst_field, set_model, set_table, dct_columns, {})
data = _result(
os.path.basename(zip_path),
backup_version(dct_rows, manifest),
lst_field,
lst_model,
lst_blocker,
source="backup",
)
data["backup_path"] = zip_path
data["backup_db_name"] = manifest.get("db_name")
return data
def _field_block(lst_row, top):
"""Une ligne par champ : modèle, nom, type, provenance."""
lines = [
f" {'model':<28}{'field':<30}{'type':<12}{t('origin')}",
]
for row in lst_row[:top]:
lines.append(
f" {(row['model'] or '')[:27]:<28}{(row['name'] or '')[:29]:<30}"
f"{(row['ttype'] or '')[:11]:<12}{origin_label(row['origin'])}"
)
if len(lst_row) > top:
lines.append(f" … {len(lst_row) - top} {t('more')}")
return lines
def render(data, verbose=False, top=TOP_DEFAULT, hints=True):
"""Rapport texte. Fonction pure : donnée -> chaîne, testable sans base."""
counts = data["counts"]
lines = [
"",
f"🔬 {t('Fields added outside a module')} — {data['database']}"
f" (Odoo {data.get('odoo_version') or '?'}"
f"{', ' + t('from a backup') if data.get('source') == 'backup' else ''})",
"",
f" {t('Custom fields'):<30}: {data['n_fields']}",
]
for name in ("studio", "handmade", "module"):
if counts.get(name):
lines.append(f" {origin_label(name):<30}: {counts[name]}")
if data["n_models"]:
lines.append(f" {t('Custom models'):<30}: {data['n_models']}")
if not data["n_fields"] and not data["n_models"]:
lines += [
"",
f"✅ {t('No field or model was added outside a module.')}",
]
return "\n".join(lines) + "\n"
if data["blockers"]:
lines += ["", f"── ❌ {t('Blocking')} ({len(data['blockers'])}) ──"]
for row in data["blockers"]:
lines.append(
f" {row['model']}.{row['name']} —"
f" {blocker_label(row['blocker'])}"
+ (f" → {row['relation']}" if row.get("relation") else "")
)
lines.append("")
lines += wrap_note(
" ",
t(
"A stored field without its column stops the registry from"
" loading, so the upgrade will not even start. Settle these"
" before anything else."
),
)
lst_show = data["fields"]
if lst_show:
lines += [
"",
f"── ⚠️ {t('To carry over by hand')} ({len(lst_show)}"
f"{', ' + str(len(data['blockers'])) + ' ' + t('blocking') if data['blockers'] else ''}) ──",
]
lines += _field_block(lst_show, len(lst_show) if verbose else top)
if data["models"]:
lines += ["", f"── 🧱 {t('Custom models')} ({len(data['models'])}) ──"]
for row in data["models"][: len(data["models"]) if verbose else top]:
lines.append(
f" {row['model']:<32}{(row.get('description') or '')[:40]}"
)
lines.append("")
lines += wrap_note(
" ",
t(
"Nothing declares these in a file, so no module will recreate"
" them. What a version upgrade keeps is what someone carried over."
),
)
if hints and not verbose:
lines += wrap_note(
" ℹ️ ",
t("Use -v to list them all, --json for the raw data."),
)
return "\n".join(lines) + "\n"
def main(argv=None):
parser = argparse.ArgumentParser(
description=t(
"List the fields and models added outside a module — Studio or by"
" hand (read-only)."
)
)
source = parser.add_mutually_exclusive_group(required=True)
source.add_argument("-d", "--database", help=t("database to inspect"))
source.add_argument(
"-z",
"--zip",
dest="backup",
help=t("Odoo backup .zip to inspect, without restoring it"),
)
parser.add_argument(
"--top",
type=int,
default=TOP_DEFAULT,
help=t("how many to show (default: 30)"),
)
parser.add_argument(
"-v", "--verbose", action="store_true", help=t("list every one")
)
parser.add_argument("--json", action="store_true", help=t("output JSON"))
parser.add_argument(
"-c", "--config", default=None, help=t("path to an Odoo config file")
)
config = parser.parse_args(argv)
try:
if config.backup:
data = collect_from_backup(config.backup)
else:
data = collect(config.database, config_path=config.config)
except AnalyseError as exc:
print(f"❌ {exc}")
return 2
except KeyboardInterrupt:
print(f"\n{t('Cancelled.')}")
return 2
if config.json:
print(json.dumps(data, indent=2, ensure_ascii=False, default=str))
else:
print(render(data, verbose=config.verbose, top=config.top))
return 1 if (data["fields"] or data["models"]) else 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,189 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Naviguer dans les différences entre une vue en base et celle du module.
Lecture seule, sans exception. La touche « r » AFFICHE la commande de
réinitialisation, elle ne l'exécute pas : cet écran sert à décider, et décider
suppose d'avoir lu. Une vue personnalisée porte souvent un travail réel que
personne ne veut perdre d'un appui sur une touche.
Pourquoi un seul DataTable à trois colonnes
--------------------------------------------
Deux panneaux séparés ne défilent pas ensemble : il faudrait synchroniser
deux barres, et une ligne de gauche finirait en face de la mauvaise ligne de
droite — exactement l'erreur qu'un diff doit rendre impossible. Un seul
tableau porte « gauche | marque | droite » sur la même ligne, donc
l'alignement est structurel et non entretenu. Il donne aussi le curseur, la
sélection et l'événement de survol sans rien écrire.
``TextArea`` a été écarté : c'est un éditeur, et il n'y a pas de coloration
XML sans tree-sitter, absent du venv.
"""
import os
import sys
new_path = os.path.normpath(
os.path.join(os.path.dirname(__file__), "..", "..")
)
sys.path.append(new_path)
from script.analyse.lib_analyse import side_by_side, t # noqa: E402
CSS = """
Screen { layout: vertical; }
#head { height: 3; padding: 0 1; background: $panel; color: $text; }
#body { height: 1fr; }
#views { width: 34; border-right: solid $accent; }
#diff { width: 1fr; }
"""
def diff_rows(finding):
"""Les lignes en écart d'un constat, sans les lignes identiques.
Le contexte est utile dans un diff unifié qu'on lit en entier ; ici on
saute d'un écart à l'autre, et les centaines de lignes identiques d'une
vue de site web ne feraient que les éloigner.
"""
return [
(mark, left, right)
for mark, left, right in side_by_side(
finding.get("arch_ref"), finding.get("arch_db_text")
)
if mark != " "
]
def build_app(data):
"""Construire l'application Textual. Importe Textual seulement ici.
L'import vit dans la fonction pour que le module reste importable — et
donc testable — sur une machine sans Textual.
"""
from textual.app import App, ComposeResult
from textual.containers import Horizontal
from textual.widgets import DataTable, Footer, Header, Static
class DiffApp(App):
CSS = globals()["CSS"]
BINDINGS = [
("q,escape", "quit", t("Quit")),
("n", "only_diff", t("Differences only")),
("w", "ignore_indent", t("Ignore indentation")),
("c", "copy", t("Copy")),
("r", "command", t("Reset command")),
]
def __init__(self, data):
super().__init__()
self.data = data
self.lst_finding = [
row for row in data["findings"] if row.get("differs")
]
self.only_diff = True
self.ignore_indent = False
self.intent = None
def compose(self) -> ComposeResult:
yield Header()
yield Static("", id="head")
with Horizontal(id="body"):
yield DataTable(id="views", cursor_type="row")
yield DataTable(id="diff", cursor_type="row")
yield Footer()
def on_mount(self):
self.title = t("Customised views")
table = self.query_one("#views", DataTable)
table.add_columns(t("view"), "+/-/≠")
for row in self.lst_finding:
stats = row.get("diff_stats") or {}
table.add_row(
(row.get("key") or str(row["id"]))[:26],
f"{stats.get('added', 0)}/{stats.get('removed', 0)}"
f"/{stats.get('changed', 0)}",
key=str(row["id"]),
)
diff = self.query_one("#diff", DataTable)
diff.add_columns(t("module (file)"), " ", t("database"))
if self.lst_finding:
self._show(0)
def _show(self, index):
row = self.lst_finding[index]
stats = row.get("diff_stats") or {}
self.query_one("#head", Static).update(
f"{row.get('key') or row['id']} · "
f"{row.get('arch_fs') or '—'}\n"
f"+{stats.get('added', 0)} -{stats.get('removed', 0)} "
f"≠{stats.get('changed', 0)} · "
f"{', '.join(row.get('reason') or []) or '—'}"
)
diff = self.query_one("#diff", DataTable)
diff.clear()
for mark, left, right in side_by_side(
row.get("arch_ref"), row.get("arch_db_text")
):
if self.only_diff and mark == " ":
continue
if self.ignore_indent:
left = (left or "").strip()
right = (right or "").strip()
diff.add_row(left or "", mark, right or "")
def on_data_table_row_highlighted(self, event):
if event.data_table.id == "views" and self.lst_finding:
self._show(event.cursor_row)
def _refresh(self):
table = self.query_one("#views", DataTable)
if self.lst_finding:
self._show(table.cursor_row)
def action_only_diff(self):
self.only_diff = not self.only_diff
self._refresh()
def action_ignore_indent(self):
self.ignore_indent = not self.ignore_indent
self._refresh()
def action_copy(self):
table = self.query_one("#views", DataTable)
if not self.lst_finding:
return
row = self.lst_finding[table.cursor_row]
text = "\n".join(
f"{mark} {left or ''} | {right or ''}"
for mark, left, right in diff_rows(row)
)
# Tronqué en gardant la FIN : c'est là que se trouve ce qu'on
# vient d'ajouter, donc ce qu'on cherche le plus souvent.
self.copy_to_clipboard(text[-100_000:])
self.notify(t("Difference copied."))
def action_command(self):
"""Rendre l'intention à l'appelant : l'écran n'écrit jamais."""
table = self.query_one("#views", DataTable)
if not self.lst_finding:
return
self.intent = ("command", self.lst_finding[table.cursor_row])
self.exit()
return DiffApp(data)
def run_diff_tui(data, run_app=True):
"""Ouvrir l'écran. Renvoie l'intention retenue, ou None.
``run_app=False`` construit l'application sans la lancer : c'est ce qui
permet de la tester sans terminal.
"""
app = build_app(data)
if not run_app:
return app
app.run()
return app.intent

View file

@ -0,0 +1,562 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Poids d'une base Odoo, et tables qui ne correspondent à plus rien.
Ce que l'outil répond : combien pèse cette base, où est le gras, et quelles
tables ne sont réclamées par aucun modèle installé. Ces dernières sont les
reliquats de modules désinstallés sans ``DROP TABLE``, que chaque montée de
version recopie et redéploie sans que personne ne les regarde.
Trois pièges évités, chacun pour une bonne raison
-------------------------------------------------
**Une table m2m n'a aucune ligne dans ``ir_model``.** Les recenser comme
orphelines ferait crier au loup sur ~200 tables d'une base ordinaire. Odoo
tient leur liste dans ``ir_model_relation``, on la lui demande.
**Une table introuvable n'est pas une anomalie, c'est une inconnue.** Un
modèle dont le ``_table`` est surchargé — ``ir.actions.act_window`` vit dans
``ir_act_window`` — serait classé « sans table » par un
``replace('.', '_')`` naïf. ``lib_analyse.model_table()`` connaît les
surcharges, et ce qu'il ne résout pas est marqué inconnu, jamais orphelin.
**Les modèles abstraits SONT dans ``ir_model``.** ``registry.py`` appelle
``_reflect_models()`` sur tous les modèles chargés, sans filtre sur
``_abstract`` ; il n'existe d'ailleurs aucune colonne ``abstract``. Des
centaines de modèles sans table sont donc parfaitement normaux : c'est un
fait rapporté, pas un constat.
Le comptage de lignes est une estimation, et le dit
---------------------------------------------------
``reltuples`` vient du dernier ``ANALYZE``. PostgreSQL 14 et suivants y
mettent ``-1`` quand la table n'a jamais été analysée — afficher ``0`` ferait
passer une table pleine pour une table vide. On affiche ``?``. Le compte exact
est derrière ``--exact`` parce qu'il coûte un balayage complet par table.
"""
import argparse
import json
import os
import sys
import textwrap
new_path = os.path.normpath(
os.path.join(os.path.dirname(__file__), "..", "..")
)
sys.path.append(new_path)
from script.analyse.lib_analyse import ( # noqa: E402
AnalyseError,
backup_version,
column_types,
json_query,
model_table,
normalise_arch,
quote_literal,
read_backup,
require_odoo_database,
scalar_query,
t,
tr_col,
)
# Tables réelles qui n'appartiennent pas à Odoo : une extension PostgreSQL les
# pose dans le schéma public. Les compter comme orphelines enverrait
# l'utilisateur supprimer une table dont dépend PostGIS.
SYSTEM_TABLES = {
"spatial_ref_sys", # PostGIS
}
TOP_DEFAULT = 20
def wrap_note(prefix, text, width=79):
"""Replier une phrase à la largeur du terminal, sans la découper en clés.
Une phrase coupée en trois clés de traduction se replie correctement dans
la langue où elle a été écrite, et n'importe comment dans l'autre : l'ordre
des mots et la longueur diffèrent. La phrase reste donc entière côté
traduction, et c'est l'affichage qui la replie.
"""
lines = textwrap.wrap(text, width=width - len(prefix)) or [""]
pad = " " * len(prefix)
return [prefix + lines[0]] + [pad + line for line in lines[1:]]
def fmt_bytes(value):
"""Taille lisible, en unités binaires — mêmes symboles en fr et en en."""
if value is None:
return "?"
size = float(value)
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
if size < 1024 or unit == "TiB":
return (
f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
)
size /= 1024
return f"{size:.1f} TiB"
def fmt_rows(value):
"""Nombre de lignes, ou « ? » si la table n'a jamais été analysée.
reltuples vaut -1 depuis PostgreSQL 14 quand aucun ANALYZE n'a tourné.
Avant, il valait 0 — indistinguable d'une table vide. On rend « ? » dans
les deux cas plutôt qu'un chiffre auquel personne ne devrait se fier.
"""
if value is None or value < 0:
return "?"
return f"{value:,}".replace(",", " ")
def _table_rows(database, **kwargs):
"""Une ligne par table réelle du schéma public, avec son poids.
relkind 'r' pour une table ordinaire, 'p' pour une partitionnée. Odoo n'en
partitionne pas, mais le compte ne doit pas devenir faux en silence le jour
où cela changera.
"""
return json_query(
database,
"""
SELECT c.relname AS table_name,
pg_total_relation_size(c.oid) AS total_bytes,
pg_table_size(c.oid) AS table_bytes,
pg_indexes_size(c.oid) AS index_bytes,
c.reltuples::bigint AS est_rows
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relkind IN ('r', 'p')
ORDER BY pg_total_relation_size(c.oid) DESC
""",
**kwargs,
)
def _model_rows(database, **kwargs):
"""Modèles déclarés, avec leur description traduite.
ir_model.name est un champ traduit : jsonb à partir de 16.0, texte avant.
tr_col décide sur le type réel de la colonne.
"""
dct_type = column_types(database, "ir_model", **kwargs)
label = tr_col("ir_model", "name", dct_type)
return json_query(
database,
f"""
SELECT model AS model,
{label} AS description,
state AS state,
transient AS transient
FROM ir_model
ORDER BY model
""",
**kwargs,
)
def _relation_tables(database, **kwargs):
"""Tables m2m qu'Odoo revendique, via ir_model_relation.
Sans elles, toute table de relation passerait pour orpheline. La table peut
manquer sur une base très ancienne : on sonde plutôt que de supposer.
"""
if not scalar_query(
database, "SELECT to_regclass('public.ir_model_relation');", **kwargs
):
return set(), False
rows = json_query(
database, "SELECT name AS name FROM ir_model_relation", **kwargs
)
return {r["name"] for r in rows if r.get("name")}, True
def _exact_counts(database, lst_table, **kwargs):
"""count(*) réel par table — un balayage complet chacune.
format('%I') met les identifiants entre guillemets côté PostgreSQL : aucun
nom de table venu du catalogue ne peut casser la requête ni en détourner
le sens.
"""
if not lst_table:
return {}
values = ", ".join(f"({quote_literal(name)})" for name in lst_table)
rows = json_query(
database,
f"""
SELECT v.table_name AS table_name,
(xpath('/row/c/text()',
query_to_xml(
format('SELECT count(*) AS c FROM public.%I',
v.table_name),
false, true, '')))[1]::text::bigint AS exact_rows
FROM (VALUES {values}) AS v(table_name)
""",
**kwargs,
)
return {r["table_name"]: r["exact_rows"] for r in rows}
def collect(database, exact=False, config_path=None, timeout=120):
"""Tout le travail. Donnée pure, sérialisable, aucun affichage."""
kwargs = {"config_path": config_path, "timeout": timeout}
require_odoo_database(database, **kwargs)
db_bytes = scalar_query(
database,
"SELECT pg_database_size(current_database());",
**kwargs,
)
odoo_version = scalar_query(
database,
"SELECT latest_version FROM ir_module_module WHERE name = 'base';",
**kwargs,
)
lst_table = _table_rows(database, **kwargs)
lst_model = _model_rows(database, **kwargs)
set_relation, has_relation_table = _relation_tables(database, **kwargs)
set_table = {row["table_name"] for row in lst_table}
# Table -> modèle. Une table peut porter plusieurs modèles : les huit
# ir.actions.* partagent ir_actions. On garde le premier par ordre
# alphabétique, pour que deux exécutions disent la même chose.
dct_table_model = {}
lst_without_table = []
for row in lst_model:
table = model_table(row["model"], known_tables=set_table)
if table is None:
lst_without_table.append(
{
"model": row["model"],
"description": row.get("description"),
"state": row.get("state"),
"transient": row.get("transient"),
}
)
continue
dct_table_model.setdefault(table, row["model"])
lst_orphan = []
for row in lst_table:
name = row["table_name"]
if name in dct_table_model:
row["model"] = dct_table_model[name]
row["origin"] = "model"
elif name in set_relation:
row["model"] = None
row["origin"] = "m2m"
elif name in SYSTEM_TABLES:
row["model"] = None
row["origin"] = "system"
else:
row["model"] = None
row["origin"] = "orphan"
lst_orphan.append(row)
row["exact_rows"] = None
if exact:
dct_exact = _exact_counts(
database, [r["table_name"] for r in lst_table], **kwargs
)
for row in lst_table:
row["exact_rows"] = dct_exact.get(row["table_name"])
return {
"tool": "analyse_schema_size",
"version": 1,
"database": database,
"odoo_version": odoo_version,
"db_bytes": int(db_bytes) if db_bytes else None,
"exact": exact,
"has_relation_table": has_relation_table,
"n_tables": len(lst_table),
"n_models": len(lst_model),
"tables": lst_table,
"orphan_tables": lst_orphan,
"models_without_table": lst_without_table,
"counts": {
"orphan_tables": len(lst_orphan),
"models_without_table": len(lst_without_table),
"m2m_tables": sum(1 for r in lst_table if r["origin"] == "m2m"),
},
}
def collect_from_backup(zip_path):
"""Même analyse, depuis une sauvegarde .zip, sans rien restaurer.
Ce qu'une sauvegarde donne EN MIEUX : le nombre de lignes est exact,
compté dans le dump, là où une base rend l'estimation du dernier ANALYZE.
Ce qu'elle ne peut pas donner : le poids sur le disque. Un dump ignore les
index et le ballonnement, et présenter le poids de ses données comme une
taille de table tromperait sur ce qui fait grossir une base. La colonne
affichée est donc « poids dans le dump », et elle est nommée ainsi.
"""
manifest, dct_rows, _, dct_census = read_backup(
zip_path,
tables=("ir_model", "ir_model_relation", "ir_module_module"),
census=True,
)
set_table = set(dct_census)
set_relation = {
row.get("name") for row in dct_rows.get("ir_model_relation") or []
}
has_relation_table = "ir_model_relation" in dct_census
dct_table_model = {}
lst_without_table = []
for row in dct_rows["ir_model"]:
table = model_table(row.get("model") or "", known_tables=set_table)
if table is None:
lst_without_table.append(
{
"model": row.get("model"),
"description": normalise_arch(row.get("name")),
"state": row.get("state"),
"transient": row.get("transient") == "t",
}
)
continue
dct_table_model.setdefault(table, row.get("model"))
lst_table, lst_orphan = [], []
for name, census in sorted(
dct_census.items(), key=lambda kv: -kv[1]["dump_bytes"]
):
row = {
"table_name": name,
# Aucune de ces trois-là ne se lit dans un dump : les laisser à
# None fait afficher « ? », ce qui est la vérité, plutôt qu'un
# zéro qui se lirait comme « cette table est vide ».
"total_bytes": None,
"table_bytes": None,
"index_bytes": None,
"dump_bytes": census["dump_bytes"],
"est_rows": census["rows"],
"exact_rows": census["rows"],
"model": dct_table_model.get(name),
"origin": "model",
}
if name in dct_table_model:
pass
elif name in set_relation:
row["origin"] = "m2m"
elif name in SYSTEM_TABLES:
row["origin"] = "system"
else:
row["origin"] = "orphan"
lst_orphan.append(row)
lst_table.append(row)
return {
"tool": "analyse_schema_size",
"version": 1,
"database": os.path.basename(zip_path),
"source": "backup",
"backup_path": zip_path,
"odoo_version": backup_version(dct_rows, manifest),
"db_bytes": None,
"dump_bytes": sum(c["dump_bytes"] for c in dct_census.values()),
"exact": True,
"has_relation_table": has_relation_table,
"n_tables": len(lst_table),
"n_models": len(dct_rows["ir_model"]),
"tables": lst_table,
"orphan_tables": lst_orphan,
"models_without_table": lst_without_table,
"counts": {
"orphan_tables": len(lst_orphan),
"models_without_table": len(lst_without_table),
"m2m_tables": sum(1 for r in lst_table if r["origin"] == "m2m"),
},
}
def _table_block(lst_row, exact, source="database"):
"""Tableau aligné : une ligne par table, colonnes de largeur fixe.
« heap » plutôt que « table » pour pg_table_size : la colonne « table »
porte déjà le nom, et le même mot pour deux choses dans le même tableau se
lit mal. Les quatre en-têtes techniques ne passent pas par t() — ils
s'écrivent pareil en français et en anglais, contrairement à « rows ».
"""
if source == "backup":
# Un dump n'a ni index ni ballonnement : afficher trois colonnes vides
# ferait croire à une mesure manquante plutôt qu'à une mesure qui
# n'existe pas.
lines = [f" {'table':<44}{t('in the dump'):>14}{t('rows'):>14}"]
for row in lst_row:
lines.append(
f" {row['table_name']:<44}"
f"{fmt_bytes(row.get('dump_bytes')):>14}"
f"{fmt_rows(row['exact_rows']):>14}"
)
return lines
lines = [
f" {'table':<34}{'total':>10}{'heap':>10}{'index':>10}"
f"{t('rows'):>14}"
]
for row in lst_row:
count = row["exact_rows"] if exact else row["est_rows"]
lines.append(
f" {row['table_name']:<34}"
f"{fmt_bytes(row['total_bytes']):>10}"
f"{fmt_bytes(row['table_bytes']):>10}"
f"{fmt_bytes(row['index_bytes']):>10}"
f"{fmt_rows(count):>14}"
)
return lines
def render(data, verbose=False, top=TOP_DEFAULT, hints=True):
"""Rapport texte. Fonction pure : donnée -> chaîne, testable sans base.
``hints`` gouverne les conseils en ligne de commande (« utilisez -v »,
« --exact »). Ils aident qui a tapé la commande ; ils insultent qui est
dans un menu, à qui l'on demande de sortir et de retaper autre chose.
L'appel depuis le menu les coupe et offre les mêmes actions comme choix.
"""
version = data.get("odoo_version") or "?"
lines = [
"",
f"🔬 {t('Schema analysis')} — {data['database']} (Odoo {version}"
f"{', ' + t('from a backup') if data.get('source') == 'backup' else ''})",
"",
(
f" {t('Weight in the dump'):<22}: "
f"{fmt_bytes(data.get('dump_bytes'))}"
if data.get("source") == "backup"
else f" {t('Database size'):<22}: {fmt_bytes(data['db_bytes'])}"
),
f" {t('Tables'):<22}: {data['n_tables']}",
f" {t('Models'):<22}: {data['n_models']}",
]
n_without = data["counts"]["models_without_table"]
if n_without:
lines.append(
f" {t('Models without table'):<22}: {n_without}"
f" ({t('abstract models have none, by design')})"
)
if not data["has_relation_table"]:
lines += wrap_note(
"⚠️ ",
t(
"ir_model_relation is absent, so m2m tables cannot be told"
" apart from orphans: the list below is unreliable."
),
)
lst_table = data["tables"]
shown = lst_table if verbose else lst_table[:top]
if shown:
label = (
t("All tables, heaviest first")
if verbose
else f"{t('Heaviest tables')} ({len(shown)}/{len(lst_table)})"
)
lines += ["", f"── {label} ──"]
lines += _table_block(
shown, data["exact"], data.get("source", "database")
)
if hints and not verbose and len(lst_table) > len(shown):
lines.append(f" … {t('use -v to list them all')}")
elif not verbose and len(lst_table) > len(shown):
lines.append(f" … {len(lst_table) - len(shown)} {t('more')}")
lst_orphan = data["orphan_tables"]
if not lst_orphan:
lines += ["", f"✅ {t('Every table belongs to an installed model.')}"]
else:
lines += [
"",
f"── ⚠️ {t('Orphan tables')} ({len(lst_orphan)}) ──",
]
lines += _table_block(
lst_orphan, data["exact"], data.get("source", "database")
)
lines.append("")
lines += wrap_note(
" ",
t(
"No installed model claims these tables. They are usually left"
" over from modules uninstalled without DROP TABLE, and every"
" version upgrade carries them along."
),
)
lines += wrap_note(
" 💡 ", t("Check what they hold before dropping anything.")
)
if not data["exact"] and hints:
lines.append("")
lines += wrap_note(
" ℹ️ ",
t(
"Row counts are estimates from the last ANALYZE. Use --exact"
" for real counts, at the cost of one full scan per table."
),
)
return "\n".join(lines) + "\n"
def main(argv=None):
parser = argparse.ArgumentParser(
description=t(
"Report the size of an Odoo database and the tables no installed"
" model claims (read-only)."
)
)
source = parser.add_mutually_exclusive_group(required=True)
source.add_argument("-d", "--database", help=t("database to inspect"))
source.add_argument(
"-z",
"--zip",
dest="backup",
help=t("Odoo backup .zip to inspect, without restoring it"),
)
parser.add_argument(
"--exact",
action="store_true",
help=t("count rows exactly: one full scan per table"),
)
parser.add_argument(
"--top",
type=int,
default=TOP_DEFAULT,
help=t("how many tables to show (default: 20)"),
)
parser.add_argument(
"-v", "--verbose", action="store_true", help=t("list every table")
)
parser.add_argument("--json", action="store_true", help=t("output JSON"))
parser.add_argument(
"-c", "--config", default=None, help=t("path to an Odoo config file")
)
config = parser.parse_args(argv)
try:
if config.backup:
data = collect_from_backup(config.backup)
else:
data = collect(
config.database, exact=config.exact, config_path=config.config
)
except AnalyseError as exc:
print(f"❌ {exc}")
return 2
except KeyboardInterrupt:
print(f"\n{t('Cancelled.')}")
return 2
if config.json:
print(json.dumps(data, indent=2, ensure_ascii=False))
else:
print(render(data, verbose=config.verbose, top=config.top))
return 1 if data["orphan_tables"] else 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,858 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Inventaire des vues personnalisées d'une base Odoo, copies COW comprises.
Ce que l'outil répond : parmi les milliers de vues d'une base, lesquelles ne
viennent pas telles quelles d'un module. Ce sont elles qu'une montée de version
peut casser, et elles seules qu'un intégrateur doit relire.
Ce qu'il ne répond PAS encore
-----------------------------
Il ne compare pas l'arch en base à celle que déclare le module. Une vue portant
le drapeau ``arch_updated`` est donc dite **signalée**, pas **modifiée** : le
drapeau vient d'Odoo, mais il est incomplet dans les deux sens — un ``write``
SQL direct ne l'arme pas, et ``reset_arch(mode='hard')`` l'efface. Conclure
demande de comparer, ce que fera l'outil suivant. Nommer « modifiée » ce qui
n'est que « signalée » serait une affirmation que rien ici ne soutient.
Les copies COW, et ce qui les distingue de l'outillage existant
---------------------------------------------------------------
Personnaliser une vue de site web ne la modifie pas : Odoo en fait une copie
liée à un ``website_id``. Quatre outils du dépôt s'en occupent déjà, chacun
pour une question de MIGRATION — ``check_cow_views.py`` prédit lesquelles
casseront à la version suivante, ``reset_stale_cow_views.py`` trouve celles qui
ont dérivé de leur jumelle et sait les réinitialiser, ``neutralize_cow_views.py``
les met hors circuit, ``snapshot_cow_views.py`` compare un avant et un après.
Aucun ne fait l'inventaire, et c'est le trou que celui-ci comble : combien de
vues sont personnalisées, par quel chemin, et lesquelles méritent un regard.
Il ne rejuge donc pas les copies COW — il les compte, dit si chacune a une
jumelle module, et renvoie vers l'outil qui tranche.
Une vue, une seule catégorie
----------------------------
Une copie COW peut aussi porter ``arch_updated`` ; une vue Studio peut être une
copie COW. Les classer plusieurs fois ferait un total supérieur au nombre de
vues, et un rapport dont les chiffres ne s'additionnent pas ne se lit pas. La
catégorie retenue est donc la plus spécifique, dans l'ordre de ``CATEGORIES``,
et le reste de ce qu'on sait vit dans ``reason``.
"""
import argparse
import json
import os
import sys
import textwrap
new_path = os.path.normpath(
os.path.join(os.path.dirname(__file__), "..", "..")
)
sys.path.append(new_path)
from script.analyse.lib_analyse import ( # noqa: E402
REPO_ROOT,
AnalyseError,
arch_differs,
backup_version,
column_types,
diff_stats,
existing_columns,
json_query,
normalise_arch,
odoo_shell_json,
read_backup,
require_odoo_database,
scalar_query,
side_by_side,
t,
tr_col,
)
# Le script poussé dans « odoo-bin shell » pour obtenir l'arch de référence.
SHELL_SCRIPT = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "shell", "view_file_arch.py"
)
# Le registre se charge par lots : la ligne de commande et la sortie restent
# bornées, et une vue cassée n'emporte que son lot.
BATCH = 100
# Modules d'identifiants externes qui ne sont pas des modules : Odoo y range ce
# qui vient d'un import, d'un export, ou de Studio.
STUDIO_MODULE = "studio_customization"
NOT_A_MODULE = ("__export__", "__import__", "__custom__")
# L'ordre EST la précédence : la première catégorie qui s'applique gagne. Du
# plus spécifique au plus général, pour qu'une vue Studio copiée par le site web
# soit comptée comme copie COW — c'est ce qu'un intégrateur ira regarder en
# premier — et non comme une vue Studio de plus.
CATEGORIES = (
"theme_installed",
"website_cow_copy",
"studio",
"imported_or_exported",
"ui_created",
"module_view_drifted",
"module_view_flagged",
"module_view",
)
# Les catégories qui demandent un regard. « module_view » n'y est pas : une vue
# qui vient d'un module et que rien ne signale est le cas normal, et il compte
# pour l'écrasante majorité.
ACTIONABLE = (
"module_view_drifted",
"module_view_flagged",
"ui_created",
"studio",
"imported_or_exported",
"website_cow_copy",
"theme_installed",
)
TOP_DEFAULT = 20
def wrap_note(prefix, text, width=79):
"""Replier une phrase à l'affichage, sans la découper en clés."""
lines = textwrap.wrap(text, width=width - len(prefix)) or [""]
pad = " " * len(prefix)
return [prefix + lines[0]] + [pad + line for line in lines[1:]]
def category_label(name):
"""Libellé traduit d'une catégorie.
Un `t(variable)` serait plus court, mais il rendrait le contrôle de
couverture aveugle : celui-ci relit les sources et ne voit que les appels
à littéral. Une clé manquante repasserait alors en silence, en anglais.
Les catégories sont donc épelées, une par une.
"""
return {
"theme_installed": t("From an installed theme"),
"website_cow_copy": t("Website copy (COW)"),
"studio": t("Made with Studio"),
"imported_or_exported": t("Imported or exported"),
"ui_created": t("Created from the interface"),
"module_view_drifted": t("From a module, silently drifted"),
"module_view_flagged": t("From a module, flagged as touched"),
"module_view": t("Straight from a module"),
}.get(name, name)
def classify(row):
"""(catégorie, raisons) d'une vue. Fonction pure, testable sur fixture.
``raisons`` porte tout ce qu'on sait et que la catégorie ne dit pas : une
copie COW qui est aussi signalée le mentionne, sinon l'information se
perdrait au profit de la seule catégorie retenue.
"""
lst_module = row.get("xmlid_modules") or []
has_xmlid = bool(lst_module)
lst_reason = []
if row.get("arch_updated"):
lst_reason.append("arch_updated")
if row.get("noupdate"):
lst_reason.append("noupdate")
if row.get("has_arch_prev"):
lst_reason.append("has_arch_prev")
if not row.get("active"):
lst_reason.append("inactive")
if row.get("theme_template_id"):
return "theme_installed", lst_reason
if row.get("website_id"):
if not row.get("has_module_twin"):
lst_reason.append("no_module_twin")
return "website_cow_copy", lst_reason
if STUDIO_MODULE in lst_module:
return "studio", lst_reason
if any(module in NOT_A_MODULE for module in lst_module):
return "imported_or_exported", lst_reason
if not has_xmlid and not row.get("arch_fs"):
return "ui_created", lst_reason
# `arch_updated` SEUL fait basculer une vue de module. `noupdate` reste une
# raison, jamais un motif : toute vue déclarée dans un bloc
# <odoo noupdate="1"> le porte — les données de mail, d'account, de website
# en sont pleines — et rien n'y a été touché. L'y inclure noierait la
# catégorie qui compte sous des centaines de vues parfaitement normales.
if row.get("arch_fs") and row.get("arch_updated"):
return "module_view_flagged", lst_reason
return "module_view", lst_reason
def _view_rows(database, **kwargs):
"""Une ligne par vue, sans son arch.
L'arch n'est pas rapatriée : quelques milliers de vues dont certaines
dépassent 100 ko tiendraient dans une seule ligne de sortie psql, dupliquée
par json.loads. La taille et l'empreinte suffisent à cet inventaire ; la
comparaison, qui a besoin du contenu, ira le chercher pour les seules vues
retenues.
Les identifiants externes sont AGRÉGÉS. Une jointure plate multiplierait
les lignes d'une vue qui en porte plusieurs, et un « premier trouvé »
déciderait au hasard si elle vient de Studio.
"""
cols = existing_columns(database, "ir_ui_view", **kwargs)
dct_type = column_types(database, "ir_ui_view", **kwargs)
def col(column, absent):
"""« v.colonne » si elle existe, sinon un littéral du bon type.
Toutes les colonnes passent par ici, y compris celles qu'on croit
acquises comme create_uid : sur une base rognée ou anonymisée, une
seule colonne manquante fait échouer la requête entière, et l'outil
rendrait 2 là où il pouvait encore répondre.
"""
return f"v.{column}" if column in cols else absent
name = tr_col("v", "name", dct_type)
has_website_col = "website_id" in cols
website = col("website_id", "NULL::integer")
# La CTE « twin » n'a pas l'alias « v » : il lui faut la colonne nue. Sans
# le module website, il n'y a aucune copie COW et toute vue à clé est sa
# propre référence — d'où le « TRUE ».
twin_filter = "website_id IS NULL" if has_website_col else "TRUE"
theme = col("theme_template_id", "NULL::integer")
arch_fs = col("arch_fs", "NULL::text")
arch_updated = col("arch_updated", "false")
arch_prev = "(v.arch_prev IS NOT NULL)" if "arch_prev" in cols else "false"
return json_query(
database,
f"""
WITH xid AS (
SELECT res_id,
array_agg(DISTINCT module) AS modules,
array_agg(module || '.' || name ORDER BY module, name)
AS xmlids,
bool_or(noupdate) AS noupdate
FROM ir_model_data
WHERE model = 'ir.ui.view'
GROUP BY res_id
), twin AS (
SELECT DISTINCT key FROM ir_ui_view
WHERE key IS NOT NULL AND {twin_filter}
)
SELECT v.id AS id,
{name} AS name,
{col("model", "NULL::text")} AS model,
{col("type", "NULL::text")} AS type,
{col("key", "NULL::text")} AS key,
{col("mode", "NULL::text")} AS mode,
{col("active", "true")} AS active,
{col("inherit_id", "NULL::integer")} AS inherit_id,
{arch_fs} AS arch_fs,
{arch_updated} AS arch_updated,
{arch_prev} AS has_arch_prev,
{website} AS website_id,
{theme} AS theme_template_id,
x.modules AS xmlid_modules,
x.xmlids AS xmlids,
COALESCE(x.noupdate, false) AS noupdate,
(v.key IS NOT NULL
AND EXISTS (SELECT 1 FROM twin WHERE twin.key = v.key))
AS has_module_twin,
{col("create_uid", "NULL::integer")} AS create_uid,
{col("create_date", "NULL::timestamp")} AS create_date,
{col("write_uid", "NULL::integer")} AS write_uid,
{col("write_date", "NULL::timestamp")} AS write_date,
octet_length(v.arch_db::text) AS arch_bytes,
md5(v.arch_db::text) AS arch_md5
FROM ir_ui_view v
LEFT JOIN xid x ON x.res_id = v.id
ORDER BY v.id
""",
**kwargs,
)
def checkout_odoo_version():
"""Version Odoo de l'ARBRE SOURCE, qui n'est pas celle de la base."""
try:
with open(os.path.join(REPO_ROOT, ".odoo-version")) as handle:
return handle.read().strip()
except OSError:
return None
def same_major(version_a, version_b):
"""Deux versions ont-elles la même majeure ? « 18.0.1.3 » vs « 18.0 »."""
if not version_a or not version_b:
return False
return version_a.split(".")[0] == version_b.split(".")[0]
def add_reference_arch(database, lst_finding, config_path=None, timeout=600):
"""Compléter les constats avec l'arch que déclare le module.
Ne s'adresse qu'aux vues qui ont un ``arch_fs`` : les autres n'ont aucune
contrepartie dans les sources, il n'y a rien à comparer.
Renvoie ``(source, erreur)`` — « orm » si le registre a répondu, « none »
sinon, avec le message. Jamais d'échec silencieux : une comparaison qui n'a
pas eu lieu ne doit pas se lire comme une comparaison sans écart.
"""
lst_todo = [row for row in lst_finding if row.get("arch_fs")]
if not lst_todo:
return "none", None
dct_by_id = {row["id"]: row for row in lst_todo}
lst_id = sorted(dct_by_id)
try:
for start in range(0, len(lst_id), BATCH):
chunk = lst_id[start : start + BATCH]
for answer in odoo_shell_json(
database,
SHELL_SCRIPT,
env={"VIEW_IDS": ",".join(str(i) for i in chunk)},
timeout=timeout,
config_path=config_path,
):
row = dct_by_id.get(answer["id"])
if row is None:
continue
row["arch_ref"] = answer.get("arch_file")
row["arch_db_text"] = answer.get("arch_db")
row["arch_ref_error"] = answer.get("error")
except AnalyseError as exc:
return "none", str(exc)
for row in lst_todo:
differs, comparable = arch_differs(
row.get("arch_ref"), row.get("arch_db_text")
)
row["comparable"] = comparable
row["differs"] = differs
if comparable:
row["diff_stats"] = diff_stats(
side_by_side(row["arch_ref"], row["arch_db_text"])
)
return "orm", None
def collect(
database,
with_diff=False,
scope="flagged",
config_path=None,
timeout=120,
shell_timeout=600,
):
"""Tout le travail. Donnée pure, sérialisable, aucun affichage."""
kwargs = {"config_path": config_path, "timeout": timeout}
require_odoo_database(database, **kwargs)
odoo_version = scalar_query(
database,
"SELECT latest_version FROM ir_module_module WHERE name = 'base';",
**kwargs,
)
has_website = bool(
scalar_query(
database,
"SELECT 1 FROM ir_module_module"
" WHERE name = 'website' AND state = 'installed';",
**kwargs,
)
)
lst_view = _view_rows(database, **kwargs)
dct_count = {name: 0 for name in CATEGORIES}
lst_finding = []
for row in lst_view:
category, lst_reason = classify(row)
row["category"] = category
row["reason"] = lst_reason
dct_count[category] += 1
if category in ACTIONABLE:
lst_finding.append(row)
arch_ref_source, arch_ref_error = "none", None
checkout = checkout_odoo_version()
if with_diff:
if not same_major(odoo_version, checkout):
# Le shell charge l'arbre du checkout, pas celui de la base : sur
# une base 13.0 avec un checkout 18.0 le registre ne chargera pas.
# Le dire en une seconde vaut mieux que trente secondes de
# chargement pour aboutir à la même conclusion.
arch_ref_error = (
f"{t('Database is Odoo')} {odoo_version},"
f" {t('checkout is')} {checkout}"
)
else:
# « flagged » ne compare que ce qui porte déjà un signe. C'est
# rapide, et aveugle au cas même que les drapeaux ratent : une vue
# réécrite en SQL direct n'arme pas arch_updated. « all » compare
# toute vue ayant un arch_fs et voit cette dérive silencieuse.
# « flagged » ne compare que ce qui porte déjà un signe : rapide,
# 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
# 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
# écart est une piste, pas un verdict.
lst_candidate = (
lst_finding
if scope == "flagged"
else [row for row in lst_view if row.get("arch_fs")]
)
arch_ref_source, arch_ref_error = add_reference_arch(
database,
lst_candidate,
config_path=config_path,
timeout=shell_timeout,
)
# Une vue signalée dont la forme canonique égale celle du module n'a rien
# de modifié : le drapeau disait vrai sur « touchée », faux sur « autre ».
# C'est tout l'intérêt de comparer, alors elle quitte les constats.
n_identical = 0
if arch_ref_source == "orm":
if scope != "flagged":
# Une vue sans drapeau dont l'arch diffère de son module a été
# réécrite sans passer par Odoo. C'est le constat que seule la
# comparaison peut produire, et le plus intéressant du lot.
known = {row["id"] for row in lst_finding}
for row in lst_view:
if row["id"] in known or not row.get("differs"):
continue
dct_count[row["category"]] -= 1
row["category"] = "module_view_drifted"
row["reason"] = row["reason"] + ["differs_from_module"]
dct_count["module_view_drifted"] += 1
lst_finding.append(row)
lst_kept = []
for row in lst_finding:
if (
row["category"] == "module_view_flagged"
and row.get("comparable")
and row.get("differs") is False
):
row["category"] = "module_view"
row["reason"] = row["reason"] + ["identical_after_canonical"]
dct_count["module_view_flagged"] -= 1
dct_count["module_view"] += 1
n_identical += 1
continue
lst_kept.append(row)
lst_finding = lst_kept
return {
"tool": "analyse_view_custom",
"version": 1,
"database": database,
"odoo_version": odoo_version,
"checkout_version": checkout,
"has_website": has_website,
"compared_with_module_source": arch_ref_source == "orm",
"arch_ref_source": arch_ref_source,
"scope": scope,
"arch_ref_error": arch_ref_error,
"n_identical_after_canonical": n_identical,
"n_views": len(lst_view),
"counts": dct_count,
"findings": lst_finding,
}
def collect_from_backup(zip_path):
"""Même inventaire, depuis une sauvegarde .zip, sans rien restaurer.
Le classement est identique : il ne dépend que de colonnes que le dump
porte toutes. Ce qui manque est la COMPARAISON — l'arch de référence vient
de `read_arch_from_file`, donc d'un registre Odoo chargé, et un zip n'en a
pas. Le rapport le dit plutôt que de laisser croire à une absence d'écart.
"""
manifest, dct_rows, _, _ = read_backup(
zip_path,
tables=("ir_ui_view", "ir_model_data", "ir_module_module"),
)
dct_xmlid, dct_noupdate = {}, {}
for row in dct_rows["ir_model_data"]:
if row.get("model") != "ir.ui.view":
continue
res_id = row.get("res_id")
dct_xmlid.setdefault(res_id, set()).add(row.get("module"))
if row.get("noupdate") == "t":
dct_noupdate[res_id] = True
# Une copie COW a une jumelle si une AUTRE vue, sans website_id, porte la
# même clé. Le même appariement que fait la CTE « twin » côté SQL.
set_twin = {
row.get("key")
for row in dct_rows["ir_ui_view"]
if row.get("key") and row.get("website_id") in (None, "")
}
lst_view = []
for row in dct_rows["ir_ui_view"]:
res_id = row.get("id")
website = row.get("website_id")
lst_view.append(
{
"id": int(res_id) if (res_id or "").isdigit() else res_id,
"name": normalise_arch(row.get("name")),
"model": row.get("model"),
"type": row.get("type"),
"key": row.get("key"),
"mode": row.get("mode"),
"active": row.get("active") != "f",
"arch_fs": row.get("arch_fs"),
"arch_updated": row.get("arch_updated") == "t",
"has_arch_prev": bool(row.get("arch_prev")),
"website_id": website if website not in (None, "") else None,
"theme_template_id": row.get("theme_template_id") or None,
"xmlid_modules": sorted(dct_xmlid.get(res_id) or []),
"noupdate": dct_noupdate.get(res_id, False),
"has_module_twin": bool(
row.get("key") and row.get("key") in set_twin
),
"arch_bytes": len(row.get("arch_db") or ""),
}
)
dct_count = {name: 0 for name in CATEGORIES}
lst_finding = []
for row in lst_view:
category, lst_reason = classify(row)
row["category"] = category
row["reason"] = lst_reason
dct_count[category] += 1
if category in ACTIONABLE:
lst_finding.append(row)
return {
"tool": "analyse_view_custom",
"version": 1,
"database": os.path.basename(zip_path),
"source": "backup",
"backup_path": zip_path,
"odoo_version": backup_version(dct_rows, manifest),
"checkout_version": checkout_odoo_version(),
"has_website": any(r.get("website_id") for r in lst_view),
"compared_with_module_source": False,
"arch_ref_source": "none",
"arch_ref_error": None,
"from_backup_no_registry": True,
"n_identical_after_canonical": 0,
"scope": "flagged",
"n_views": len(lst_view),
"counts": dct_count,
"findings": lst_finding,
}
def _finding_block(lst_row, top, hints=True):
"""Une ligne par vue : clé, identifiant externe, poids, raisons."""
lines = [
f" {'id':>6} {'key / xml-id':<44}{'size':>9} {t('why')}",
]
for row in lst_row[:top]:
label = row.get("key") or (row.get("xmlids") or [""])[0] or "—"
size = row.get("arch_bytes")
lines.append(
f" {row['id']:>6} {label[:44]:<44}"
f"{(str(size) + ' B') if size else '?':>9} "
f"{', '.join(row.get('reason') or []) or '—'}"
)
if len(lst_row) > top:
lines.append(f" … {len(lst_row) - top} {t('more')}")
return lines
def render(data, verbose=False, top=TOP_DEFAULT, category=None, hints=True):
"""Rapport texte. Fonction pure : donnée -> chaîne, testable sans base."""
version = data.get("odoo_version") or "?"
counts = data["counts"]
lines = [
"",
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']}",
]
for name in CATEGORIES:
if counts.get(name):
lines.append(f" {category_label(name):<38}: {counts[name]}")
n_finding = len(data["findings"])
if not n_finding:
lines += [
"",
f"✅ {t('Every view comes straight from a module.')}",
]
return "\n".join(lines) + "\n"
lst_show = data["findings"]
if category:
lst_show = [r for r in lst_show if r["category"] == category]
lines += [
"",
f"── ⚠️ {t('Views that did not come straight from a module')}"
f" ({len(lst_show)}) ──",
]
lines += _finding_block(lst_show, len(lst_show) if verbose else top)
if counts.get("website_cow_copy"):
lines.append("")
lines += wrap_note(
" ",
t(
"Website copies are user data: Odoo copies a view instead of"
" editing it. Whether they will survive the next version is"
" another question, and these tools answer it:"
),
)
lines += [
" ./script/odoo/migration/check_cow_views.py"
" -d DB -t odooXX.0",
" ./script/odoo/migration/reset_stale_cow_views.py -d DB",
]
lines.append("")
if data.get("compared_with_module_source"):
if data.get("scope") == "all":
lines += wrap_note(
" ⚠️ ",
t(
"In --scope all, a difference is a lead, not a verdict:"
" read_arch_from_file returns the raw file, while the"
" database holds the arch AFTER load-time processing."
" Measured on a freshly installed 18.0 database, 160 of"
" its 974 views already differ this way."
),
)
lines.append("")
if data.get("n_identical_after_canonical"):
lines += wrap_note(
" ✅ ",
f"{data['n_identical_after_canonical']} "
+ t(
"views were flagged but hold exactly what their module"
" declares: only the comparison could tell."
),
)
lines += wrap_note(
" 💡 ",
t(
"To restore a view to what its module declares — this WRITES"
" to the database, so read the difference first:"
),
)
lines.append(
" echo \"env['ir.ui.view'].browse(ID).reset_arch('hard');"
' env.cr.commit()" \\'
)
lines.append(
f" | ./odoo_bin.sh shell -c ./config.conf"
f" -d {data['database']}"
)
elif data.get("from_backup_no_registry"):
lines += wrap_note(
" ℹ️ ",
t(
"A backup holds no registry, so nothing was compared with the"
" module source. The classification above needs none; only the"
" differences do. Restore it, or run this on the database."
),
)
elif data.get("arch_ref_error"):
lines += wrap_note(
" ⚠️ ",
t("No reference arch, so nothing was compared: ")
+ str(data["arch_ref_error"]),
)
elif hints:
lines += wrap_note(
" ℹ️ ",
t(
"Flags say a view was touched, not how. They are incomplete"
" both ways: a direct SQL write does not set arch_updated,"
" and reset_arch clears it. Comparing with the module source"
" is what settles it — add --diff."
),
)
else:
lines += wrap_note(
" ℹ️ ",
t(
"Flags say a view was touched, not how: only comparing with"
" the module source settles it."
),
)
return "\n".join(lines) + "\n"
def render_diff(row, width=78):
"""Le diff d'une vue, côte à côte, pour la sortie texte.
Calculé sur les arch BRUTES, pas sur les formes canoniques : la forme
canonique sert à décider s'il y a un écart, elle ne se relit pas.
"""
lines = [
"",
f"── id={row['id']} {row.get('key') or '—'} "
f"({row.get('arch_fs') or '—'}) ──",
]
half = (width - 4) // 2
for mark, left, right in side_by_side(
row.get("arch_ref"), row.get("arch_db_text")
):
if mark == " ":
continue
lines.append(
f" {mark} {(left or '')[:half]:<{half}} │ {(right or '')[:half]}"
)
return lines
def open_tui(data):
"""Ouvrir l'écran de navigation. False si on n'a pas pu — l'appelant imprime.
Trois refus, trois raisons distinctes, et aucune n'est une panne :
rien à montrer, pas de terminal, ou Textual absent. Chacune se dit, plutôt
que d'ouvrir un écran vide ou de laisser des codes d'échappement dans un
fichier de sortie.
"""
lst_diff = [row for row in data["findings"] if row.get("differs")]
if not lst_diff:
return False
if not sys.stdout.isatty():
print(f"ℹ️ {t('Not a terminal: showing the text report instead.')}")
return False
try:
from script.todo import textual_setup
except Exception:
textual_setup = None
if textual_setup and not textual_setup.ensure():
return False
from script.analyse.analyse_diff_tui import run_diff_tui
intent = run_diff_tui(data)
if intent and intent[0] == "command":
row = intent[1]
print(f"\n💡 {t('To restore this view to what its module declares:')}")
print(
f" echo \"env['ir.ui.view'].browse({row['id']})"
f".reset_arch('hard'); env.cr.commit()\" \\"
)
print(
f" | ./odoo_bin.sh shell -c ./config.conf"
f" -d {data['database']}"
)
return True
def main(argv=None):
parser = argparse.ArgumentParser(
description=t(
"List the views of an Odoo database that did not come straight"
" from a module, website copies included (read-only)."
)
)
source = parser.add_mutually_exclusive_group(required=True)
source.add_argument("-d", "--database", help=t("database to inspect"))
source.add_argument(
"-z",
"--zip",
dest="backup",
help=t("Odoo backup .zip to inspect, without restoring it"),
)
parser.add_argument(
"--category",
choices=CATEGORIES,
default=None,
help=t("only show this category"),
)
parser.add_argument(
"--top",
type=int,
default=TOP_DEFAULT,
help=t("how many views to show (default: 20)"),
)
parser.add_argument(
"-v", "--verbose", action="store_true", help=t("list every view")
)
parser.add_argument(
"--diff",
action="store_true",
help=t("compare with the module source (opens an Odoo shell)"),
)
parser.add_argument(
"--scope",
choices=("flagged", "all"),
default="flagged",
help=t("which views to compare (default: flagged)"),
)
parser.add_argument(
"--strict",
action="store_true",
help=t("fail if the comparison could not be made"),
)
parser.add_argument(
"--tui",
action="store_true",
help=t("browse the differences in a full-screen view"),
)
parser.add_argument("--json", action="store_true", help=t("output JSON"))
parser.add_argument(
"-c", "--config", default=None, help=t("path to an Odoo config file")
)
config = parser.parse_args(argv)
try:
if config.backup:
data = collect_from_backup(config.backup)
else:
data = collect(
config.database,
with_diff=config.diff or config.tui,
scope=config.scope,
config_path=config.config,
)
except AnalyseError as exc:
print(f"❌ {exc}")
return 2
except KeyboardInterrupt:
print(f"\n{t('Cancelled.')}")
return 2
if config.strict and not data["compared_with_module_source"]:
print(
f"❌ {t('No reference arch, so nothing was compared: ')}"
f"{data.get('arch_ref_error') or ''}"
)
return 2
if config.json:
print(json.dumps(data, indent=2, ensure_ascii=False, default=str))
return 1 if data["findings"] else 0
if config.tui and open_tui(data):
return 1 if data["findings"] else 0
print(
render(
data,
verbose=config.verbose,
top=config.top,
category=config.category,
)
)
if config.verbose and data["compared_with_module_source"]:
for row in data["findings"]:
if row.get("differs"):
print("\n".join(render_diff(row)))
return 1 if data["findings"] else 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,872 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Socle commun des outils d'analyse d'une base Odoo, en lecture seule.
Pourquoi psql en sous-processus plutôt que psycopg2
---------------------------------------------------
La raison est déjà écrite dans le dépôt, dans ``reset_stale_cow_views.py`` :
« Plain psql on purpose: this runs on databases whose Odoo registry does not
load, which is precisely when it is needed ». Une base 12.0 sur un checkout
18.0 ne charge pas son registre, et c'est exactement le moment où on veut
l'analyser. Accessoirement, psycopg2 n'est pas dans ``.venv.erplibre``, qui est
l'interpréteur de ces outils.
La lecture seule est une garantie, pas une promesse
--------------------------------------------------
``PGOPTIONS`` porte ``default_transaction_read_only=on`` : c'est **le serveur**
qui refuse toute écriture, pour toutes les transactions de la connexion. Un
``SET`` glissé dans le même ``-c`` ne suffirait pas — ``psql -c`` ouvre une
transaction implicite unique, et ``default_transaction_read_only`` ne vaut que
pour les transactions *suivantes*.
Ne jamais deviner la forme du schéma
------------------------------------
Douze versions d'Odoo se partagent ces tables. Les colonnes apparaissent,
changent de type (``text`` puis ``jsonb`` à partir de 16.0), ou n'existent que
si un module est installé. D'où ``existing_columns()`` et ``tr_col()`` : on
sonde avant d'écrire une requête, on ne date pas les colonnes de mémoire.
Les sondes lisent ``pg_attribute``, pas ``information_schema`` :
``information_schema`` est filtré par les droits. Avec un rôle non
propriétaire, elle renverrait un ensemble vide, et l'analyse concluerait
« aucune colonne website, donc aucune vue COW » sans le moindre avertissement.
"""
import configparser
import json
import os
import re
import subprocess
import sys
new_path = os.path.normpath(
os.path.join(os.path.dirname(__file__), "..", "..")
)
sys.path.append(new_path)
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - repli si i18n indisponible
def t(key: str) -> str:
return key
REPO_ROOT = new_path
# Une valeur littérale « False » dans config.conf veut dire « non défini » :
# c'est ainsi qu'Odoo écrit l'absence de valeur dans son fichier de config.
CONFIG_UNSET = ("false", "none", "")
# Un nom de base voyage jusqu'à une commande shell (`odoo_bin.sh shell -d …`,
# lancée avec shell=True par execute.py). On le valide au lieu de compter sur
# l'échappement : la liste de caractères qu'une base Odoo utilise réellement
# est courte, et tout le reste est soit une erreur de frappe, soit une
# injection.
RE_DATABASE_NAME = re.compile(r"[A-Za-z0-9_.-]+")
# Modèles dont la table N'EST PAS `_name.replace('.', '_')`.
#
# Dérivé des sources, pas écrit de mémoire : parcours AST de tous les `.py` de
# `odoo18.0/` et `addons/` (27 843 fichiers), en gardant les classes dont le
# `_table` diffère du défaut. Beaucoup de modules déclarent un `_table` égal au
# défaut — ce sont des déclarations sans effet, à ne pas confondre avec une
# surcharge.
#
# Sans cette table, `replace('.', '_')` échoue précisément sur les modèles les
# plus fréquents dans `ir_model_data` : `ir.actions.act_window` chercherait
# `ir_actions_act_window`, qui n'existe pas.
#
# La liste n'est pas la vérité, seulement ce qu'on sait : un modèle absent
# d'ici et dont la table est introuvable est classé « table inconnue » (un
# fait), jamais « table orpheline » (une anomalie). Pour la régénérer, refaire
# le parcours AST sur l'arbre courant.
MODEL_TABLE_OVERRIDE = {
"ir.actions.act_multi": "ir_actions",
"ir.actions.act_url": "ir_act_url",
"ir.actions.act_window": "ir_act_window",
"ir.actions.act_window.message": "ir_actions",
"ir.actions.act_window.view": "ir_act_window_view",
"ir.actions.act_window_close": "ir_actions",
"ir.actions.actions": "ir_actions",
"ir.actions.client": "ir_act_client",
"ir.actions.report": "ir_act_report_xml",
"ir.actions.server": "ir_act_server",
"project.task.stage.personal": "project_task_user_rel",
# `ir.actions.report.xml` est le nom d'avant 11.0 : les lignes
# `ir_model_data` d'une base ancienne le portent encore.
"ir.actions.report.xml": "ir_act_report_xml",
}
class AnalyseError(Exception):
"""Échec de l'outil, pas un constat d'analyse.
Distinction qui porte le code de retour : 2 pour « je n'ai pas pu
analyser », réservé à cette exception ; 1 pour « j'ai analysé et j'ai
trouvé des constats ». Les confondre rendrait une analyse en échec
indistinguable d'une base à problèmes.
"""
def valid_database_name(name):
"""Le nom est-il un nom de base plausible, sûr à mettre dans une commande ?"""
return bool(name) and RE_DATABASE_NAME.fullmatch(name) is not None
def read_config(config_path=None):
"""Lire config.conf, en repliant sur /etc/odoo/odoo.conf comme run.sh.
Renvoie un dict des options, vide si aucun fichier n'est trouvé — l'absence
de config n'est pas une erreur : sur une installation native, psql se
connecte très bien par le socket unix sans aucun paramètre.
"""
lst_candidate = (
[config_path]
if config_path
else [
os.path.join(REPO_ROOT, "config.conf"),
"/etc/odoo/odoo.conf",
]
)
for path in lst_candidate:
if path and os.path.isfile(path):
parser = configparser.RawConfigParser()
try:
parser.read(path)
except configparser.Error:
continue
if parser.has_section("options"):
return dict(parser.items("options"))
return {}
def pg_env(config_path=None, timeout=120, overrides=None):
"""Variables d'environnement pour psql : connexion + lecture seule.
Les paramètres viennent de config.conf, pas d'une hypothèse « socket unix
et rôle = utilisateur système » : le dépôt lui-même livre
``db_user = erplibre`` et un docker-compose.yml avec un mot de passe.
``PGOPTIONS`` est ce qui rend l'analyse incapable d'écrire, et borne la
durée d'une requête — un scan qui part en vrille ne bloque pas un menu.
"""
config = read_config(config_path)
my_env = os.environ.copy()
dct_map = {
"db_host": "PGHOST",
"db_port": "PGPORT",
"db_user": "PGUSER",
"db_password": "PGPASSWORD",
"db_sslmode": "PGSSLMODE",
}
for key, var in dct_map.items():
value = str(config.get(key, "")).strip()
if value.lower() not in CONFIG_UNSET:
my_env[var] = value
for var, value in (overrides or {}).items():
if value:
my_env[var] = str(value)
my_env["PGOPTIONS"] = (
f"-c default_transaction_read_only=on -c statement_timeout={timeout}s"
)
# Un ~/.psqlrc avec \timing ou \pset ajoute des lignes à la sortie et casse
# le parsing. -X l'ignore, mais PSQLRC vide protège aussi les appels qui
# oublieraient -X.
my_env["PSQLRC"] = ""
return my_env
def run_psql(database, sql, timeout=120, config_path=None, overrides=None):
"""Exécuter du SQL et rendre la sortie brute, une ligne par enregistrement.
``-X`` ignore ~/.psqlrc, ``-w`` interdit l'invite de mot de passe (sans
lui, un mot de passe manquant bloque le menu TODO sans rien afficher),
``ON_ERROR_STOP=1`` fait échouer au premier problème plutôt que de rendre
une sortie partielle qu'on prendrait pour un résultat.
"""
if not valid_database_name(database):
raise AnalyseError(f"{t('Invalid database name: ')}{database!r}")
cmd = [
"psql",
"-X",
"-w",
"-v",
"ON_ERROR_STOP=1",
"-d",
database,
"-tAc",
sql,
]
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout + 30,
cwd=REPO_ROOT,
env=pg_env(config_path, timeout=timeout, overrides=overrides),
)
except FileNotFoundError as exc:
raise AnalyseError(
f"{t('psql is not installed or not in PATH.')}"
) from exc
except subprocess.TimeoutExpired as exc:
raise AnalyseError(
f"{t('Query exceeded the timeout (s): ')}{timeout}"
) from exc
if result.returncode:
raise AnalyseError(
f"{t('Cannot read from the database: ')}"
f"{result.stderr.strip() or result.returncode}"
)
return result.stdout
def json_query(database, sql, **kwargs):
"""Rendre le résultat d'un SELECT comme une liste de dicts.
Le SQL est enveloppé côté PostgreSQL plutôt que découpé côté Python : une
arch de vue contient des retours de ligne et des « | », donc tout
séparateur maison finirait par couper au mauvais endroit. C'est le même
choix que ``snapshot_cow_views.py``.
``sql`` est un SELECT SANS point-virgule final : il devient une
sous-requête.
"""
inner = sql.strip().rstrip(";")
wrapped = (
"SELECT COALESCE(json_agg(row_to_json(t))::text, '[]')"
f" FROM ({inner}) t;"
)
raw = run_psql(database, wrapped, **kwargs).strip()
if not raw:
return []
try:
return json.loads(raw)
except ValueError as exc:
raise AnalyseError(f"{t('Unreadable JSON from psql: ')}{exc}") from exc
def scalar_query(database, sql, **kwargs):
"""Première valeur de la première ligne, ou None si aucune ligne."""
raw = run_psql(database, sql, **kwargs).strip()
if not raw:
return None
return raw.splitlines()[0].strip() or None
def require_odoo_database(database, **kwargs):
"""Refuser tout ce qui n'est pas une base Odoo, avant d'aller plus loin.
Sans ce contrôle, ``-d postgres`` ou une base vide remonte un
« relation "ir_ui_view" does not exist » brut, qui ressemble à un bogue de
l'outil alors que c'est une erreur de saisie.
"""
found = scalar_query(
database, "SELECT to_regclass('public.ir_module_module');", **kwargs
)
if not found:
raise AnalyseError(f"'{database}' {t('is not an Odoo database.')}")
return True
def database_version(database, **kwargs):
"""Version Odoo de la BASE, qui n'est pas celle du checkout.
Comparer les deux est ce qui évite d'ouvrir un shell Odoo pour rien : sur
une base 13.0 avec un checkout 18.0, le registre ne chargera pas, et mieux
vaut le dire tout de suite qu'après trente secondes de chargement.
"""
return scalar_query(
database,
"SELECT latest_version FROM ir_module_module WHERE name = 'base';",
**kwargs,
)
def existing_columns(database, table, **kwargs):
"""Colonnes réellement présentes, via pg_attribute (pas information_schema).
Renvoie un ensemble vide si la table n'existe pas — les deux cas se
distinguent avec ``to_regclass`` si l'appelant en a besoin.
"""
sql = (
"SELECT a.attname FROM pg_attribute a"
" JOIN pg_class c ON c.oid = a.attrelid"
" JOIN pg_namespace n ON n.oid = c.relnamespace"
f" WHERE n.nspname = 'public' AND c.relname = {quote_literal(table)}"
" AND a.attnum > 0 AND NOT a.attisdropped;"
)
return {
line.strip()
for line in run_psql(database, sql, **kwargs).splitlines()
if line.strip()
}
def column_types(database, table, **kwargs):
"""{colonne: type PostgreSQL} — dit `jsonb` là où 15.0 disait `text`."""
sql = (
"SELECT a.attname, format_type(a.atttypid, a.atttypmod)"
" FROM pg_attribute a"
" JOIN pg_class c ON c.oid = a.attrelid"
" JOIN pg_namespace n ON n.oid = c.relnamespace"
f" WHERE n.nspname = 'public' AND c.relname = {quote_literal(table)}"
" AND a.attnum > 0 AND NOT a.attisdropped;"
)
dct_type = {}
for line in run_psql(database, sql, **kwargs).splitlines():
if "|" in line:
name, _, kind = line.partition("|")
dct_type[name.strip()] = kind.strip()
return dct_type
def tr_col(table, column, dct_type, lang="en_US"):
"""Fragment SQL lisant un champ traduit, quelle que soit la version.
À partir de 16.0 un champ traduit est un ``jsonb`` ``{"en_US": "…"}`` ;
jusqu'à 15.0 c'est du texte. Un seul endroit décide, et il décide sur le
type réel de la colonne — pas sur un numéro de version, qu'il faudrait
connaître et qui mentirait sur une base à moitié migrée.
``dct_type`` vient de ``column_types()``. Une colonne inconnue rend NULL
plutôt que du SQL invalide : l'appelant verra un champ vide, pas une
requête qui explose.
"""
kind = (dct_type or {}).get(column)
if kind is None:
return "NULL::text"
qualified = f'"{table}"."{column}"' if table else f'"{column}"'
if kind == "jsonb":
return f"{qualified}->>{quote_literal(lang)}"
return f"{qualified}::text"
def quote_literal(value):
"""Littéral SQL sûr : les quotes simples sont doublées.
Nécessaire parce que ces requêtes sont assemblées en texte pour psql, sans
paramètres liés. Les seules valeurs concernées ici sont des noms de tables
et de colonnes venant du catalogue, mais un nom de table hérité peut
parfaitement porter une apostrophe.
"""
return "'" + str(value).replace("'", "''") + "'"
def model_table(model, known_tables=None):
"""Table d'un modèle, ou None si elle est introuvable.
None veut dire « je ne sais pas », jamais « il n'y en a pas » : c'est ce
qui empêche de classer un modèle à `_table` surchargé comme une anomalie.
``known_tables`` est l'ensemble des tables réelles, quand l'appelant l'a.
"""
table = MODEL_TABLE_OVERRIDE.get(model, model.replace(".", "_"))
if known_tables is not None and table not in known_tables:
return None
return table
def public_tables(database, **kwargs):
"""Tables réelles du schéma public, vues par le catalogue.
``relkind IN ('r', 'p')`` : 'r' pour une table ordinaire, 'p' pour une
table partitionnée. Odoo n'en partitionne pas, mais le jour où cela
changera, le compte ne doit pas devenir faux en silence.
"""
sql = (
"SELECT c.relname FROM pg_class c"
" JOIN pg_namespace n ON n.oid = c.relnamespace"
" WHERE n.nspname = 'public' AND c.relkind IN ('r', 'p');"
)
return {
line.strip()
for line in run_psql(database, sql, **kwargs).splitlines()
if line.strip()
}
JSON_BEGIN = "ANALYSE_JSON_BEGIN"
JSON_END = "ANALYSE_JSON_END"
def odoo_config_path(config_path=None):
"""Chemin du fichier de configuration Odoo, comme run.sh le résout.
Sans ``-c``, ``odoo_bin.sh`` ne passe AUCUNE configuration : ``addons_path``
retombe sur le défaut d'Odoo, et le shell ne voit alors aucun des dépôts de
``addons/``. ``read_arch_from_file`` ne trouverait plus un seul fichier et
rendrait une arch de référence vide pour toutes les vues — sans erreur.
C'est la panne la plus coûteuse de cet outillage, parce qu'elle est
silencieuse : le rapport dirait « aucun écart » sur une base pleine
d'écarts.
"""
for path in (
config_path,
os.path.join(REPO_ROOT, "config.conf"),
"/etc/odoo/odoo.conf",
):
if path and os.path.isfile(path):
return path
raise AnalyseError(t("No Odoo configuration file found."))
def odoo_shell_json(
database, script_path, env=None, timeout=600, config_path=None
):
"""Pousser un script dans « odoo-bin shell » et récupérer son JSON.
Pas de ``shell=True`` : la commande est une liste, le script arrive par
l'entrée standard. Un nom de base n'a donc rien à échapper — il ne traverse
aucun interpréteur de commandes.
Les journaux d'Odoo se mêlent à la sortie, d'où les sentinelles : on ne
lit que ce qui est entre elles. Leur absence est une erreur franche, pas
une liste vide qu'on prendrait pour « rien à signaler ».
"""
if not valid_database_name(database):
raise AnalyseError(f"{t('Invalid database name: ')}{database!r}")
with open(script_path, "r", encoding="utf-8") as handle:
source = handle.read()
my_env = os.environ.copy()
my_env.update(env or {})
cmd = [
os.path.join(REPO_ROOT, "odoo_bin.sh"),
"shell",
"-c",
odoo_config_path(config_path),
"-d",
database,
"--no-http",
]
try:
result = subprocess.run(
cmd,
input=source,
capture_output=True,
text=True,
timeout=timeout,
cwd=REPO_ROOT,
env=my_env,
)
except FileNotFoundError as exc:
raise AnalyseError(t("odoo_bin.sh not found.")) from exc
except subprocess.TimeoutExpired as exc:
raise AnalyseError(
f"{t('The Odoo shell exceeded the timeout (s): ')}{timeout}"
) from exc
output = result.stdout or ""
if JSON_BEGIN not in output or JSON_END not in output:
detail = (result.stderr or output).strip().splitlines()[-3:]
raise AnalyseError(
f"{t('The Odoo shell returned no result: ')}{' / '.join(detail)}"
)
chunk = output.split(JSON_BEGIN, 1)[1].split(JSON_END, 1)[0]
try:
return json.loads(chunk.strip())
except ValueError as exc:
raise AnalyseError(
f"{t('Unreadable JSON from the Odoo shell: ')}{exc}"
) from exc
def normalise_arch(value):
"""L'arch en chaîne, quel que soit le type de la colonne.
``arch_db`` est du texte jusqu'à 15.0 et du jsonb à partir de 16.0, avec
une entrée par langue. Reprise de ``reset_stale_cow_views.normalise_arch``,
à l'identique : deux implémentations de cette conversion finiraient par
diverger sur un cas limite, et c'est exactement le genre d'écart qui
ferait conclure « la vue a changé » sur une base qui n'a rien changé.
"""
if not isinstance(value, str):
return "" if value is None else str(value)
text = value.strip()
if text.startswith("{") and '"' in text:
try:
data = json.loads(text)
except ValueError:
return value
if isinstance(data, dict) and data:
for lang in ("en_US", *sorted(data)):
if lang in data and isinstance(data[lang], str):
return data[lang]
return value
# Attributs dont la valeur est un chemin ou une expression : l'espace y sépare
# des jetons, il se normalise, mais il ne se supprime pas. « //div[1] /span »
# et « //div[1]/span » ne désignent pas la même chose.
SPACING_ATTRS = ("expr", "position", "groups", "t-call", "class")
def canonical(arch):
"""Forme canonique d'une arch, pour DÉCIDER s'il y a un écart.
Sert à répondre « est-ce différent », jamais à afficher : ce qu'un humain
relit, c'est l'arch brute.
Pourquoi pas ``etree.canonicalize()`` en un appel, alors que lxml est là :
son seul levier sur les espaces, ``strip_text``, s'applique à TOUS les
nœuds texte, y compris le contenu d'un ``t-esc`` ou d'un CDATA — donc il
masquerait des différences de contenu réelles. Et il n'offre aucun levier
sur les espaces à l'intérieur d'une valeur d'attribut, alors que c'est
précisément là que vit le bruit : un ``expr`` réindenté n'est pas une
modification. D'où ce parcours, qui trie les attributs comme le ferait
c14n, replie les espaces là où ils ne portent rien, et laisse le texte
tranquille partout ailleurs.
Renvoie None si l'arch n'est pas du XML analysable — un écart ne se
conclut pas sur une comparaison qui n'a pas eu lieu.
"""
from lxml import etree
text = normalise_arch(arch)
if not text.strip():
return None
try:
parser = etree.XMLParser(remove_blank_text=True, remove_comments=True)
root = etree.fromstring(text.encode("utf-8"), parser=parser)
except etree.XMLSyntaxError:
return None
def render(node):
lst_attr = []
for key in sorted(node.attrib):
value = node.attrib[key]
if key in SPACING_ATTRS:
value = " ".join(value.split())
lst_attr.append(f"{key}={value!r}")
head = node.tag + ("[" + ",".join(lst_attr) + "]" if lst_attr else "")
# Le texte n'est replié que sur ses bords : l'indentation autour d'un
# élément est de la mise en forme, l'espace À L'INTÉRIEUR d'un libellé
# ou d'un t-esc est du contenu.
own = (node.text or "").strip()
parts = [head] + ([f"#{own}" for _ in (1,) if own])
parts += [render(child) for child in node]
tail = (node.tail or "").strip()
if tail:
parts.append(f"~{tail}")
return "(" + " ".join(parts) + ")"
return render(root)
def arch_differs(left, right):
"""(différent ?, comparable ?) entre deux arch.
Deux réponses parce qu'il y a trois issues : identiques, différentes, et
« je n'ai pas pu comparer ». Confondre la troisième avec la première
ferait répondre « tout va bien » sur une vue au XML cassé, qui est
justement celle qu'il faut regarder.
"""
canon_left = canonical(left)
canon_right = canonical(right)
if canon_left is None or canon_right is None:
return None, False
return canon_left != canon_right, True
def side_by_side(left, right):
"""[(marque, gauche, droite)] alignés, pour l'affichage côte à côte.
Marque : ' ' identiques, '≠' remplacées, '-' seulement à gauche, '+'
seulement à droite. Consommé par le TUI ET par le rendu texte, pour que
les deux racontent la même chose.
"""
import difflib
lst_left = normalise_arch(left).splitlines()
lst_right = normalise_arch(right).splitlines()
lst_row = []
matcher = difflib.SequenceMatcher(None, lst_left, lst_right)
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
if tag == "equal":
for offset in range(i2 - i1):
lst_row.append(
(" ", lst_left[i1 + offset], lst_right[j1 + offset])
)
elif tag == "replace":
for offset in range(max(i2 - i1, j2 - j1)):
lst_row.append(
(
"≠",
lst_left[i1 + offset] if i1 + offset < i2 else None,
lst_right[j1 + offset] if j1 + offset < j2 else None,
)
)
elif tag == "delete":
for offset in range(i1, i2):
lst_row.append(("-", lst_left[offset], None))
elif tag == "insert":
for offset in range(j1, j2):
lst_row.append(("+", None, lst_right[offset]))
return lst_row
def diff_stats(lst_row):
"""{added, removed, changed} depuis les lignes de side_by_side()."""
return {
"added": sum(1 for mark, _, _ in lst_row if mark == "+"),
"removed": sum(1 for mark, _, _ in lst_row if mark == "-"),
"changed": sum(1 for mark, _, _ in lst_row if mark == "≠"),
}
# --- Lire une sauvegarde .zip sans restaurer quoi que ce soit ----------------
#
# Une sauvegarde Odoo est un zip contenant `manifest.json`, un `filestore/` et
# un `dump.sql` — un pg_dump TEXTE, avec ses blocs `COPY … FROM stdin;` et ses
# `CREATE TABLE`. Tout se lit donc sans PostgreSQL et sans Odoo.
#
# Ce que ça débloque : analyser la sauvegarde d'une instance Enterprise depuis
# une installation Community. La restaurer y échoue — Odoo veut charger des
# modules qu'on n'a pas — alors que les champs Studio, eux, ne sont que des
# lignes de `ir_model_fields` qu'on peut lire telles quelles.
#
# La lecture est en FLOT, une seule passe : un dump de production pèse des
# gigaoctets, et on n'en veut que trois tables.
# Échappements de pg_dump dans un bloc COPY. `\N` (NULL) est traité à part :
# c'est une valeur, pas un caractère.
COPY_ESCAPE = {
"b": "\b",
"f": "\f",
"n": "\n",
"r": "\r",
"t": "\t",
"v": "\v",
"\\": "\\",
}
# Lignes d'un CREATE TABLE qui ne déclarent pas une colonne.
NOT_A_COLUMN = (
"CONSTRAINT",
"PRIMARY",
"UNIQUE",
"CHECK",
"FOREIGN",
"EXCLUDE",
)
def unescape_copy(value):
r"""Une valeur d'un bloc COPY, dés-échappée. « \N » devient None."""
if value == "\\N":
return None
if "\\" not in value:
return value
out, index = [], 0
while index < len(value):
char = value[index]
if char == "\\" and index + 1 < len(value):
out.append(COPY_ESCAPE.get(value[index + 1], value[index + 1]))
index += 2
else:
out.append(char)
index += 1
return "".join(out)
def find_member(archive, filename):
"""Le membre nommé ``filename``, où qu'il soit dans l'archive.
Cherché à la racine d'abord, puis n'importe où : selon qui fabrique la
sauvegarde, elle est mise à plat ou rangée sous un dossier.
"""
lst_name = archive.namelist()
if filename in lst_name:
return filename
for name in lst_name:
if name.rsplit("/", 1)[-1] == filename:
return name
return None
def backup_manifest(zip_path):
"""Le manifest.json d'une sauvegarde, ou {} s'il n'y en a pas.
Son absence n'est PAS une erreur. Une sauvegarde odoo.sh n'en contient
aucun — seulement ``dump.sql`` et ``filestore/`` — et refuser le fichier
pour cela reviendrait à refuser d'analyser précisément les bases qu'on ne
peut pas restaurer, ce qui est tout l'intérêt de lire un zip.
Le manifeste ne portait de toute façon qu'une commodité : la version. Elle
se lit dans le dump lui-même, où elle est plus sûre — c'est la base qui
parle, pas un fichier écrit à côté.
"""
import zipfile
try:
with zipfile.ZipFile(zip_path) as archive:
member = find_member(archive, "manifest.json")
if member is None:
return {}
with archive.open(member) as handle:
return json.load(handle)
except ValueError:
# Un manifeste illisible ne vaut pas mieux qu'un manifeste absent, et
# ne doit pas empêcher de lire le dump qui est à côté.
return {}
except (OSError, zipfile.BadZipFile) as exc:
raise AnalyseError(f"{t('Cannot read the backup: ')}{exc}") from exc
def backup_version(dct_rows, manifest=None):
"""Version Odoo d'une sauvegarde, lue dans le dump avant le manifeste.
``ir_module_module.latest_version`` du module ``base`` est ce que la base
dit d'elle-même ; le manifeste n'est qu'un repli.
"""
for row in dct_rows.get("ir_module_module") or []:
if row.get("name") == "base" and row.get("latest_version"):
return row["latest_version"]
return (manifest or {}).get("version")
def read_backup(zip_path, tables=(), with_columns=(), census=False):
"""Lire un dump.sql en flot.
-> (manifest, {table: [lignes]}, {table: colonnes}, {table: recensement})
Une seule passe, quelle que soit la taille du dump : on ne garde en mémoire
que les tables demandées.
``census`` compte les lignes et pèse chaque table SANS la garder. Le
comptage est alors EXACT, là où une base rend une estimation d'après le
dernier ANALYZE. Le poids est celui des données dans le dump, pas sur le
disque : une sauvegarde ne sait rien des index ni du ballonnement, et
présenter l'un pour l'autre tromperait sur ce qui fait grossir une base.
"""
import io
import zipfile
manifest = backup_manifest(zip_path)
set_want = set(tables)
# True = toutes les tables. Le dump les déclare toutes de toute façon, et
# on ne sait quelles tables comptent qu'APRÈS avoir lu les champs : les
# garder toutes coûte quelques centaines de kilo-octets et évite une
# seconde passe sur un fichier qui peut peser des gigaoctets.
all_cols = with_columns is True
set_cols = set() if all_cols else set(with_columns)
dct_rows = {name: [] for name in set_want}
dct_columns = {name: set() for name in set_cols}
dct_census = {}
try:
archive = zipfile.ZipFile(zip_path)
except (OSError, zipfile.BadZipFile) as exc:
raise AnalyseError(f"{t('Cannot read the backup: ')}{exc}") from exc
with archive:
member = find_member(archive, "dump.sql")
if member is None:
# Le dump est la seule pièce indispensable : c'est LUI qui porte
# les données. Son absence est donc la vraie erreur, là où celle
# du manifeste n'en est pas une.
raise AnalyseError(
f"{t('This backup holds no dump.sql: ')}{zip_path}"
)
with archive.open(member) as raw:
stream = io.TextIOWrapper(raw, encoding="utf-8", errors="replace")
for line in stream:
if line.startswith("COPY public."):
name = line[len("COPY public.") :].split(" ", 1)[0]
keep = name in set_want
if not keep and not census:
continue
header = line[line.index("(") + 1 : line.rindex(")")]
lst_col = [c.strip() for c in header.split(",")]
n_row, n_byte = 0, 0
for row in stream:
if row.startswith("\\."):
break
n_row += 1
n_byte += len(row)
if not keep:
continue
values = row.rstrip("\n").split("\t")
dct_rows[name].append(
dict(
zip(
lst_col, [unescape_copy(v) for v in values]
)
)
)
if census:
dct_census[name] = {
"rows": n_row,
"dump_bytes": n_byte,
"columns": lst_col,
}
elif line.startswith("CREATE TABLE public."):
name = line[len("CREATE TABLE public.") :].split(" ", 1)[0]
if census:
dct_census.setdefault(
name, {"rows": 0, "dump_bytes": 0, "columns": []}
)
for row in stream:
stripped = row.strip()
if stripped.startswith(");"):
break
if not all_cols and name not in set_cols:
continue
if stripped.upper().startswith(NOT_A_COLUMN):
continue
column = stripped.split(" ", 1)[0].strip('",')
if column:
dct_columns.setdefault(name, set()).add(column)
return manifest, dct_rows, dct_columns, dct_census
def _describe():
"""Dire ce qu'est ce fichier, et où sont les outils.
Ce fichier porte un shebang et un nom qui ressemble à celui d'un outil ;
le lancer ne produisait rien du tout, ce qui se lit comme une panne plutôt
que comme « ce n'est pas un exécutable ». Il énumère donc ses voisins qui,
eux, se lancent — la liste vient du disque, elle ne peut pas se périmer
quand un outil s'ajoute.
"""
here = os.path.dirname(os.path.abspath(__file__))
def is_runnable(name):
"""Ce fichier se lance-t-il vraiment ?
Le nom ne suffit pas : `analyse_diff_tui.py` commence pareil et n'a
pas de point d'entrée. L'annoncer comme exécutable reproduirait le
défaut même que cette fonction corrige — promettre une commande qui
ne fait rien. On regarde donc s'il y a un bloc `__main__`.
"""
if not (name.startswith("analyse_") and name.endswith(".py")):
return False
try:
with open(os.path.join(here, name), encoding="utf-8") as handle:
return '__name__ == "__main__"' in handle.read()
except OSError:
return False
lst_tool = sorted(name for name in os.listdir(here) if is_runnable(name))
print(
f"📚 {os.path.basename(__file__)} — "
f"{t('shared library, nothing to run here.')}"
)
print()
if lst_tool:
print(t("Runnable tools in this directory:"))
for tool in lst_tool:
print(f" ./script/analyse/{tool} -d <database>")
else:
print(t("No analysis tool here yet."))
print()
print(f"{t('From the menu:')} make todo → Execute → Analyse")
if __name__ == "__main__":
_describe()

View file

@ -0,0 +1,70 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Arch de référence des vues, telle que le module la déclare.
Ce fichier ne se lance PAS seul : il est poussé dans l'entrée standard d'un
``odoo-bin shell``, qui lui fournit ``env``. Lancé directement, il ne trouve
aucun ``env`` et ne fait rien.
Pourquoi passer par l'ORM plutôt que relire le XML
--------------------------------------------------
La question « à quoi comparer l'arch en base » a une réponse dans le code
d'Odoo : c'est ce que fait son propre bouton « Reset view », mode ``hard`` —
``view.with_context(read_arch_from_file=True, lang=None).arch``.
Cette seule expression gère ce qu'une relecture du XML devrait réimplémenter :
localiser le fichier par ``arch_fs``, y trouver le bon nœud par identifiant
externe ou par identifiant court, suivre un ``<record>`` qui ne fait que
re-pointer, transformer un ``<template>`` en ``<t t-name>``, résoudre les
``%(xmlid)s`` en identifiants réels. Réécrire tout cela, c'est se tromper
autrement qu'Odoo.
Aucune écriture
---------------
``odoo/cli/shell.py`` fait un ``rollback`` après exécution. Le ``rollback``
final ici est une ceinture par-dessus cette bretelle ; il n'y a aucun
``commit``, et il n'y en aura pas.
"""
import json
import os
VIEW_IDS = os.environ.get("VIEW_IDS", "")
LANG = os.environ.get("ANALYSE_LANG") or None
lst_id = [int(part) for part in VIEW_IDS.split(",") if part.strip().isdigit()]
lst_out = []
if lst_id:
views = env["ir.ui.view"].with_context(active_test=False).browse(lst_id)
for view in views.exists():
try:
# lang=None demande la valeur brute, non traduite : comparer une
# arch traduite à une arch source ferait ressortir chaque terme
# traduit comme une différence.
reference = view.with_context(
read_arch_from_file=True, lang=LANG
).arch
error = None
except Exception as exc: # une vue cassée ne doit pas tuer le lot
reference, error = None, f"{type(exc).__name__}: {exc}"
try:
stored = view.with_context(lang=LANG).arch
except Exception as exc:
stored, error = None, error or f"{type(exc).__name__}: {exc}"
lst_out.append(
{
"id": view.id,
"xml_id": view.xml_id or view.key or "",
"arch_db": stored,
"arch_file": reference,
"error": error,
}
)
print("ANALYSE_JSON_BEGIN")
print(json.dumps(lst_out))
print("ANALYSE_JSON_END")
env.cr.rollback()

View file

@ -57,6 +57,42 @@ class ConfigFile:
config_data = config_data.get(param)
return config_data
def set_config_value(self, keys: list[str], value: Any) -> None:
"""Écrit `value` sous le chemin `keys` dans
CONFIG_OVERRIDE_PRIVATE_FILE.
C'est le seul des trois fichiers fusionnés par `get_config` qui
soit gitignored (`git check-ignore` le confirme ; `private/` lui-
même est un dossier versionné, et CONFIG_OVERRIDE_FILE ne l'est
pas) — donc le seul où une valeur personnelle comme `kdbx.path`
peut être écrite sans finir commitée.
Fusionne avec le contenu existant plutôt que de l'écraser, et
écrit de façon atomique : fichier temporaire créé en 0600 dans le
même dossier, puis `os.replace` (qui hérite du mode de la source).
Le fichier réel n'est donc jamais vu à moitié écrit, et un fichier
déjà présent avec des permissions trop larges se retrouve corrigé.
"""
data: Dict[str, Any] = {}
if os.path.exists(CONFIG_OVERRIDE_PRIVATE_FILE):
with open(CONFIG_OVERRIDE_PRIVATE_FILE) as cfg:
data = json.load(cfg)
node = data
for key in keys[:-1]:
node = node.setdefault(key, {})
node[keys[-1]] = value
parent = os.path.dirname(CONFIG_OVERRIDE_PRIVATE_FILE) or "."
os.makedirs(parent, exist_ok=True)
os.chmod(parent, 0o700)
tmp_path = f"{CONFIG_OVERRIDE_PRIVATE_FILE}.tmp"
fd = os.open(tmp_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as tmp_file:
json.dump(data, tmp_file, indent=2, ensure_ascii=False)
os.replace(tmp_path, CONFIG_OVERRIDE_PRIVATE_FILE)
def get_logo_ascii_file_path(self) -> str:
return LOGO_ASCII_FILE

View file

@ -146,7 +146,16 @@ class Execute:
"You cannot execute Odoo command if no version is"
f" installed. Command : {redact_secrets(command)}"
)
return -1
# Return the SAME shape the caller asked for. A bare int here
# made callers doing « status, cmd = exec_command_live(...) »
# crash with ValueError instead of seeing the failure.
if return_status_and_output_and_command:
return 1, command, []
if return_status_and_command:
return 1, command
if return_status_and_output:
return 1, []
return 1
command = f"source ./.venv.{source_odoo}/bin/activate && {command}"
if new_window and self.cmd_source_default:
command = self.cmd_source_default % command
@ -191,10 +200,16 @@ class Execute:
if process.returncode != 0 and not quiet:
print("Command returned error code:" f" {process.returncode}")
# An exception MUST report a failure. exit_code stays None otherwise,
# and None is falsy: callers testing « if not status: » would mark the
# step as done, and « if status and wait_at_error » would skip the error
# prompt. A crashed command was therefore recorded as a success.
except FileNotFoundError:
exit_code = 1
if not quiet:
print(f"Error: Command '{redact_secrets(command)}' not found.")
except Exception as e:
exit_code = 1
if not quiet:
print(f"An error occurred: {redact_secrets(str(e))}")
process_end_time = time.time()

View file

@ -44,7 +44,28 @@ fi
# Generate local manifest
.venv.erplibre/bin/python ./script/git/git_merge_repo_manifest.py --output .repo/local_manifests/erplibre_manifest.xml --with_OCA
.venv.erplibre/bin/repo init -u git://127.0.0.1:9418/ -b $(git rev-parse --verify HEAD) -m ${MANIFEST_TARGET} "$@"
# Révision du manifeste : un NOM DE BRANCHE, jamais un SHA nu.
#
# « repo init -b <sha> » échoue sur un espace de travail NEUF : repo doit y
# cloner .repo/manifests et ne sait pas résoudre un commit en révision de
# manifeste. Il abandonne sur « unparseable HEAD », puis repo sync sur
# « manifest not found ». Mesuré : -b main réussit, -b <sha> échoue.
# Le piège est qu'un .repo DÉJÀ initialisé accepte le SHA — le défaut reste
# donc invisible sur toute machine ayant réussi un init une fois, et ne frappe
# que les installations neuves : les 4 VM du 2026-08-07 ont toutes échoué là.
MANIFEST_REV=$(git symbolic-ref --quiet --short HEAD || true)
if [ -z "${MANIFEST_REV}" ]; then
# HEAD détaché : prendre une branche qui contient ce commit plutôt que de
# retomber sur le SHA, qui ne marcherait pas.
MANIFEST_REV=$(git branch --contains HEAD --format='%(refname:short)' 2>/dev/null | grep -v '[()]' | head -1)
fi
if [ -z "${MANIFEST_REV}" ]; then
echo "Erreur : impossible de déterminer une branche pour repo init." >&2
echo " HEAD est détaché et aucune branche ne contient ce commit." >&2
exit 1
fi
.venv.erplibre/bin/repo init -u git://127.0.0.1:9418/ -b "${MANIFEST_REV}" -m ${MANIFEST_TARGET} "$@"
.venv.erplibre/bin/repo sync -c -j "$JOBS" ${REPO_VERBOSE} -m ${MANIFEST_TARGET}
# Daemon cleanup handled by the EXIT trap above (tolerant of an already-dead PID).

View file

@ -21,5 +21,46 @@ source ./.venv.odoo15.0_python3.8.20/bin/activate && cat ./script/odoo/migration
<!-- [en] -->
Check [uninstall_module_list_odoo140_to_odoo150.txt](uninstall_module_list_odoo140_to_odoo150.txt)
## Module lists to uninstall
Before a version bump, the migration uninstalls the modules listed in
`uninstall_module_list_odoo<from>_to_odoo<to>.txt`. Two locations are read, the
private one first, and the results are merged (duplicates dropped):
1. `private/odoo/migration/<database>/uninstall_module_list_odooXX0_to_odooYY0.txt`
— specific to ONE database, not versioned. Which modules must be dropped
depends on the data, so this is where nearly every entry belongs.
2. `script/odoo/migration/uninstall_module_list_odooXX0_to_odooYY0.txt`
— shared defaults, versioned, valid for every database.
Syntax: one module per line, with a justification after `#`. Commas and several
names per line are accepted; blank lines and full-line comments are ignored.
A module without a stated reason is flagged at runtime: removing a module is a
decision someone must be able to review later.
<!-- [fr] -->
Consultez [uninstall_module_list_odoo140_to_odoo150.txt](uninstall_module_list_odoo140_to_odoo150.txt)
## Listes de modules à désinstaller
Avant une montée de version, la migration désinstalle les modules listés dans
`uninstall_module_list_odoo<depuis>_to_odoo<vers>.txt`. Deux emplacements sont
lus, le privé d'abord, puis fusionnés (doublons éliminés) :
1. `private/odoo/migration/<base>/uninstall_module_list_odooXX0_to_odooYY0.txt`
— propre à UNE base de données, non versionné. Les modules à supprimer
dépendent des données : c'est ici que va la quasi-totalité des entrées.
2. `script/odoo/migration/uninstall_module_list_odooXX0_to_odooYY0.txt`
— valeurs par défaut partagées, versionnées, valables pour toute base.
Syntaxe : un module par ligne, avec une justification après `#`. Les virgules et
plusieurs noms par ligne sont acceptés ; lignes vides et commentaires pleine
ligne sont ignorés. Un module sans raison est signalé à l'exécution : supprimer
un module est une décision qui doit pouvoir être relue plus tard.
<!-- [common] -->
```
queue_job # blocks 12->13, trigger queue_job_notify
mgmtsystem_hazard # not ported to 13.0
web_syncer # dropped upstream
```

View file

@ -7,4 +7,27 @@ Exécutez ce script lors de la migration de base de données. Exemple :
source ./.venv.odoo15.0_python3.8.20/bin/activate && cat ./script/odoo/migration/fix_migration_odoo140_to_odoo150.py | ./odoo15.0/odoo/odoo-bin shell -d DATABASE
```
Consultez [uninstall_module_list_odoo140_to_odoo150.txt](uninstall_module_list_odoo140_to_odoo150.txt)
Consultez [uninstall_module_list_odoo140_to_odoo150.txt](uninstall_module_list_odoo140_to_odoo150.txt)
## Listes de modules à désinstaller
Avant une montée de version, la migration désinstalle les modules listés dans
`uninstall_module_list_odoo<depuis>_to_odoo<vers>.txt`. Deux emplacements sont
lus, le privé d'abord, puis fusionnés (doublons éliminés) :
1. `private/odoo/migration/<base>/uninstall_module_list_odooXX0_to_odooYY0.txt`
— propre à UNE base de données, non versionné. Les modules à supprimer
dépendent des données : c'est ici que va la quasi-totalité des entrées.
2. `script/odoo/migration/uninstall_module_list_odooXX0_to_odooYY0.txt`
— valeurs par défaut partagées, versionnées, valables pour toute base.
Syntaxe : un module par ligne, avec une justification après `#`. Les virgules et
plusieurs noms par ligne sont acceptés ; lignes vides et commentaires pleine
ligne sont ignorés. Un module sans raison est signalé à l'exécution : supprimer
un module est une décision qui doit pouvoir être relue plus tard.
```
queue_job # blocks 12->13, trigger queue_job_notify
mgmtsystem_hazard # not ported to 13.0
web_syncer # dropped upstream
```

View file

@ -8,3 +8,26 @@ source ./.venv.odoo15.0_python3.8.20/bin/activate && cat ./script/odoo/migration
```
Check [uninstall_module_list_odoo140_to_odoo150.txt](uninstall_module_list_odoo140_to_odoo150.txt)
## Module lists to uninstall
Before a version bump, the migration uninstalls the modules listed in
`uninstall_module_list_odoo<from>_to_odoo<to>.txt`. Two locations are read, the
private one first, and the results are merged (duplicates dropped):
1. `private/odoo/migration/<database>/uninstall_module_list_odooXX0_to_odooYY0.txt`
— specific to ONE database, not versioned. Which modules must be dropped
depends on the data, so this is where nearly every entry belongs.
2. `script/odoo/migration/uninstall_module_list_odooXX0_to_odooYY0.txt`
— shared defaults, versioned, valid for every database.
Syntax: one module per line, with a justification after `#`. Commas and several
names per line are accepted; blank lines and full-line comments are ignored.
A module without a stated reason is flagged at runtime: removing a module is a
decision someone must be able to review later.
```
queue_job # blocks 12->13, trigger queue_job_notify
mgmtsystem_hazard # not ported to 13.0
web_syncer # dropped upstream
```

View file

@ -0,0 +1,347 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Predict which website COW views will break on the next version bump.
Background
----------
When a website view is customized, Odoo makes a copy-on-write (COW) copy tied
to a website_id. That copy freezes the arch AND the structure of the module
view it was copied from.
A module view can change structure between two Odoo versions. Example measured
on a real 12.0 database: ``portal.frontend_layout`` is declared ``primary`` in
12.0 (a full QWeb template) and becomes an ``extension`` in 13.0
(``inherit_id="web.frontend_layout"`` + xpath). During the upgrade the COW copy
follows the module and becomes an extension, but keeps its 12.0 full-template
arch. Odoo then applies the ``<t t-name=...>`` root as an inheritance spec,
cannot find it in the parent, and the whole upgrade stops on::
ValueError: Element '<t ... t-name="portal.frontend_layout">'
cannot be located in parent view
So the rule is:
a COW view breaks when the shape its arch must have changes between
version N and version N+1.
The discriminant is NOT ``mode``. What decides the required shape is whether
the target declares an ``inherit_id``: if it does, the arch must be inheritance
specs (``<data>``, ``<xpath>``, ``position=``); if it does not, the arch must be
a standalone template. Comparing ``mode`` alone misses a real case: a view
moving from a root template to ``inherit_id`` + ``primary="True"`` keeps
``mode='primary'`` on both sides yet still has to change shape, and the copy
still breaks.
That is predictable *before* starting a multi-hour migration: the stored arch is
in the database, and the required shape is declared in the target version
sources. This script compares the two and reports the views at risk.
It only reads: no database write, no source modification.
"""
import argparse
import glob
import json
import os
import re
import subprocess
import sys
import xml.etree.ElementTree as ET
# A view whose module counterpart cannot be found at all.
MODE_UNKNOWN = "unknown"
# arch_db is text up to 15.0 and jsonb from 16.0 ({"en_US": "<data>..."}).
RE_XML_DECLARATION = re.compile(r"<\?xml.*?\?>", re.DOTALL)
RE_FIRST_TAG = re.compile(r"<\s*([A-Za-z_][\w.:-]*)")
# Tags that carry inheritance specs rather than a standalone template.
SPEC_ROOT_TAG = ("data", "xpath")
def query_cow_views(database):
"""Return [(id, key, mode, website_id, arch)] for every website COW view."""
sql = (
"SELECT id, COALESCE(key, ''), mode, website_id,"
" replace(left(COALESCE(arch_db::text, ''), 400), chr(10), ' ')"
" FROM ir_ui_view WHERE website_id IS NOT NULL ORDER BY id;"
)
result = subprocess.run(
["psql", "-d", database, "-tAF", "|", "-c", sql],
capture_output=True,
text=True,
)
if result.returncode:
raise RuntimeError(
f"Cannot read views from '{database}': {result.stderr.strip()}"
)
lst_view = []
for line in result.stdout.splitlines():
if not line.strip():
continue
view_id, key, mode, website_id, arch = line.split("|", 4)
lst_view.append((int(view_id), key, mode, website_id, arch))
return lst_view
def arch_is_inheritance_spec(arch):
"""True when the arch holds inheritance specs, not a standalone template.
This is the real discriminant, not ``mode``. A view declared with an
``inherit_id`` must hold specs (``<data>``, ``<xpath>``, or an element with
a ``position``); a root view holds a full template (``<t t-name=...>``,
``<form>``, ...). A copy that keeps the wrong form for what the target
version expects is exactly what raises « cannot be located in parent view ».
"""
if not arch:
return None
# From 16.0 arch_db is jsonb: take any translation, the structure is shared.
if arch.lstrip().startswith("{"):
try:
translations = json.loads(arch)
arch = next(iter(translations.values()), "")
except (ValueError, StopIteration):
# Truncated jsonb: fall through and look at the raw text.
pass
arch = RE_XML_DECLARATION.sub("", arch or "")
match = RE_FIRST_TAG.search(arch)
if not match:
return None
if match.group(1).lower() in SPEC_ROOT_TAG:
return True
# <field name="x" position="after"> style specs.
return "position=" in arch[: match.end() + 200]
def load_renamed_modules(odoo_version):
"""Return {old_module: new_module} from the target OpenUpgrade apriori.py.
Without this a module renamed upstream looks absent, and every view of that
module is misreported as « module gone » instead of being checked.
"""
pattern = os.path.join(odoo_version, "**", "apriori.py")
for file_path in sorted(glob.glob(pattern, recursive=True)):
data_vars = {}
try:
with open(file_path, "r", encoding="utf-8") as f:
exec(f.read(), data_vars) # noqa: S102 - upstream data file
except Exception:
# A broken or exotic apriori.py must not stop the whole report.
continue
renamed = data_vars.get("renamed_modules")
if isinstance(renamed, dict) and renamed:
return renamed
return {}
def find_module_dir(odoo_version, module_name):
"""Locate a module directory inside an odoo<version> tree."""
lst_pattern = [
os.path.join(odoo_version, "odoo", "addons", module_name),
os.path.join(odoo_version, "odoo", "odoo", "addons", module_name),
os.path.join(odoo_version, "addons", "*", module_name),
]
for pattern in lst_pattern:
for path in sorted(glob.glob(pattern)):
if os.path.isdir(path):
return path
return None
def declared_view_shape(module_dir, template_id):
"""Return (mode, inherits) for a view declared in the sources, else None.
``inherits`` is what really matters: a declared inherit_id means the arch
must be inheritance specs. ``mode`` is kept because it is still worth
reporting, but it is NOT a reliable discriminant: a view moving from a root
template to « inherit_id + primary="True" » keeps mode='primary' on both
sides while its arch shape has to change.
"""
pattern = os.path.join(module_dir, "**", "*.xml")
for file_path in sorted(glob.glob(pattern, recursive=True)):
try:
root = ET.parse(file_path).getroot()
except ET.ParseError:
continue
for element in root.iter():
if element.get("id") != template_id:
continue
if element.tag == "template":
inherits = bool(element.get("inherit_id"))
if str(element.get("primary", "")).lower() in ("true", "1"):
return "primary", inherits
return ("extension" if inherits else "primary"), inherits
if (
element.tag == "record"
and element.get("model") == "ir.ui.view"
):
mode = None
inherits = False
for field in element.findall("field"):
if field.get("name") == "mode":
mode = (field.text or "").strip()
elif field.get("name") == "inherit_id":
inherits = True
if mode:
return mode, inherits
return ("extension" if inherits else "primary"), inherits
return None
def analyse(database, target_version):
"""Sort COW views into three buckets by comparing with the target sources.
- at_risk : the module view changes mode -> the copy will break
- module_absent : the module itself is gone in the target version
- no_counterpart : no module view with that id, so it is a page or a record
created from the editor. Normal, and not at risk.
"""
lst_at_risk = []
lst_module_absent = []
lst_no_counterpart = []
cache_shape = {}
renamed_modules = load_renamed_modules(target_version)
for view_id, key, mode, website_id, arch in query_cow_views(database):
if not key or "." not in key:
continue
if key not in cache_shape:
module_name, _, template_id = key.partition(".")
module_dir = find_module_dir(target_version, module_name)
if module_dir is None and module_name in renamed_modules:
module_dir = find_module_dir(
target_version, renamed_modules[module_name]
)
if module_dir is None:
cache_shape[key] = MODE_UNKNOWN
else:
cache_shape[key] = declared_view_shape(module_dir, template_id)
shape = cache_shape[key]
if shape == MODE_UNKNOWN:
lst_module_absent.append((view_id, key, mode, website_id))
continue
if shape is None:
lst_no_counterpart.append((view_id, key, mode, website_id))
continue
target_mode, target_inherits = shape
is_spec = arch_is_inheritance_spec(arch)
# The decisive test: the target expects inheritance specs but the copy
# holds a standalone template, or the reverse.
if is_spec is not None and target_inherits != is_spec:
reason = (
"target inherits, copy holds a standalone template"
if target_inherits
else "target is a root view, copy holds inheritance specs"
)
lst_at_risk.append(
(view_id, key, mode, target_mode, website_id, reason)
)
elif target_mode != mode:
# Shape is fine but the mode moves: worth reporting, less severe.
lst_at_risk.append(
(
view_id,
key,
mode,
target_mode,
website_id,
"mode changes, arch shape unchanged",
)
)
return lst_at_risk, lst_module_absent, lst_no_counterpart
def main():
parser = argparse.ArgumentParser(
description=(
"Report website COW views that will break on the next Odoo"
" version bump (read-only)."
)
)
parser.add_argument(
"-d", "--database", required=True, help="database to inspect"
)
parser.add_argument(
"-t",
"--target_version",
required=True,
help="target Odoo source directory, e.g. odoo13.0",
)
parser.add_argument(
"-v",
"--verbose",
action="store_true",
help="also list the editor-made pages, which are not at risk",
)
config = parser.parse_args()
if not os.path.isdir(config.target_version):
print(
f"❌ Target version directory '{config.target_version}' not found."
)
return 1
lst_at_risk, lst_module_absent, lst_no_counterpart = analyse(
config.database, config.target_version
)
if not lst_at_risk:
print(
"✅ -> No website COW view changes mode in"
f" {config.target_version}."
)
else:
print(
f"⚠️ {len(lst_at_risk)} website COW view(s) will break when moving"
f" to {config.target_version}: the copy keeps an arch whose shape"
" no longer matches what the target module view expects."
)
for (
view_id,
key,
mode,
target_mode,
website_id,
reason,
) in lst_at_risk:
print(
f" - id={view_id} website={website_id} {key}"
f" : {mode} -> {target_mode} ({reason})"
)
print(
" Arbitrate BEFORE launching the migration. To neutralize a copy,"
" rename its key (UPDATE ir_ui_view SET key='zz_cow_archive.'||key,"
" active=false): an unmatched key is never paired with the module"
" view, so the copy never receives the new inherit_id. Setting"
" active=false alone is NOT enough -- an inactive copy that keeps"
" the same key still shadows the module view."
)
if lst_module_absent:
print(
f"ℹ {len(lst_module_absent)} COW view(s) belong to a module absent"
f" from {config.target_version}:"
)
for view_id, key, mode, website_id in lst_module_absent:
print(f" - id={view_id} website={website_id} {key} ({mode})")
if lst_no_counterpart:
print(
f"ℹ {len(lst_no_counterpart)} COW view(s) are pages or records made"
" from the website editor (no module view of that name): not at"
" risk." + ("" if config.verbose else " Use -v to list them.")
)
if config.verbose:
for view_id, key, mode, website_id in lst_no_counterpart:
print(f" - id={view_id} website={website_id} {key} ({mode})")
# Informative only: never fail the migration on a warning.
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,39 @@
-- © 2021-2026 TechnoLibre (http://www.technolibre.ca)
-- License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
--
-- Odoo 13.0 -> 14.0 : hand the « group_fiscal_year » security group over to
-- the module that owns it in 14.0.
--
-- In 13.0 the group « Allow to define fiscal years of more or less than a
-- year » is declared by the core « account » module, so the database holds it
-- as account.group_fiscal_year. In 14.0 core account no longer declares it and
-- om_account_accountant (odoomates) does. During the upgrade that module finds
-- no XML id of its own, tries to CREATE the group, and hits:
--
-- duplicate key value violates unique constraint "res_groups_name_uniq"
-- Key (category_id, name)=(9, Allow to define fiscal years ...) already exists
--
-- Renaming the XML id makes Odoo UPDATE the existing row instead of creating a
-- duplicate. The record id is untouched, so any user assignment, access right
-- or record rule pointing at the group survives.
--
-- Runs through psql, not the Odoo shell: at this point the database is still
-- 13.0 and loading it with the 14.0 registry is exactly what fails.
UPDATE ir_model_data
SET module = 'om_account_accountant'
WHERE model = 'res.groups'
AND module = 'account'
AND name = 'group_fiscal_year'
-- Only when that module is actually part of this database.
AND EXISTS (
SELECT 1 FROM ir_module_module
WHERE name = 'om_account_accountant'
AND state IN ('installed', 'to upgrade', 'to install')
)
-- Idempotent: do nothing if the target XML id already exists.
AND NOT EXISTS (
SELECT 1 FROM ir_model_data
WHERE module = 'om_account_accountant'
AND name = 'group_fiscal_year'
);

View file

@ -0,0 +1,158 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Neutralize the website COW views that would break a version bump.
How it works
------------
``key`` is the only thing that pairs a website copy with the module view it
came from. A copy whose key matches nothing is never paired, so it never
receives the new ``inherit_id``, never changes shape, and never takes part in
any view combination. Renaming the key is therefore enough to take a copy out
of the way::
UPDATE ir_ui_view SET key = '<prefix>.' || key, active = false WHERE id = ?
Setting ``active = false`` alone would NOT work: an inactive copy that keeps
the same key still shadows the module view.
Nothing is deleted, so ``inherit_id ondelete='restrict'`` and the
``website_page`` foreign keys are never touched, and the 12.0 arch stays in
database as a readable archive. ``--restore`` puts everything back.
Plain psql on purpose: this must run on a database that has not been migrated
yet, where starting an Odoo shell of the target version is not guaranteed.
"""
import argparse
import os
import subprocess
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from check_cow_views import analyse # noqa: E402
DEFAULT_PREFIX = "zz_cow_archive"
def run_psql(database, sql):
"""Run a statement and return stdout, raising on failure."""
result = subprocess.run(
["psql", "-d", database, "-tAc", sql],
capture_output=True,
text=True,
)
if result.returncode:
raise RuntimeError(
f"Query failed on '{database}': {result.stderr.strip()}"
)
return result.stdout.strip()
def neutralize(database, lst_view_id, prefix):
"""Rename the key of the given views and deactivate them."""
if not lst_view_id:
return 0
ids = ",".join(str(view_id) for view_id in lst_view_id)
output = run_psql(
database,
"WITH updated AS ("
f" UPDATE ir_ui_view SET key = '{prefix}.' || key, active = false"
f" WHERE id IN ({ids}) AND key NOT LIKE '{prefix}.%'"
" RETURNING 1) SELECT count(*) FROM updated;",
)
return int(output or 0)
def restore(database, prefix):
"""Undo a neutralization: strip the prefix and reactivate."""
output = run_psql(
database,
"WITH updated AS ("
f" UPDATE ir_ui_view SET key = substring(key from {len(prefix) + 2}),"
" active = true"
f" WHERE key LIKE '{prefix}.%'"
" RETURNING 1) SELECT count(*) FROM updated;",
)
return int(output or 0)
def main():
parser = argparse.ArgumentParser(
description=(
"Neutralize the website COW views that would break a version"
" bump, by renaming their key. Dry-run unless --apply."
)
)
parser.add_argument("-d", "--database", required=True)
parser.add_argument(
"-t",
"--target_version",
help="target Odoo source directory, e.g. odoo13.0",
)
parser.add_argument(
"--prefix",
default=DEFAULT_PREFIX,
help=f"archive prefix for the key (default: {DEFAULT_PREFIX})",
)
parser.add_argument(
"--apply", action="store_true", help="actually write to the database"
)
parser.add_argument(
"--restore",
action="store_true",
help="undo a previous neutralization and reactivate the copies",
)
config = parser.parse_args()
if config.restore:
count = restore(config.database, config.prefix)
print(f"✅ -> {count} COW view(s) restored on '{config.database}'.")
return 0
if not config.target_version:
parser.error("--target_version is required unless --restore is used")
if not os.path.isdir(config.target_version):
print(
f"❌ Target version directory '{config.target_version}' not found."
)
return 1
lst_at_risk, _, _ = analyse(config.database, config.target_version)
if not lst_at_risk:
print("✅ -> No website COW view to neutralize.")
return 0
print(
f"⚠️ {len(lst_at_risk)} website COW view(s) would break the bump to"
f" {config.target_version}:"
)
lst_view_id = []
for view_id, key, mode, target_mode, website_id, reason in lst_at_risk:
lst_view_id.append(view_id)
print(
f" - id={view_id} website={website_id} {key}"
f" : {mode} -> {target_mode} ({reason})"
)
if not config.apply:
print(
"ℹ Dry-run. Add --apply to rename their key to"
f" '{config.prefix}.<key>' and deactivate them. Reversible with"
" --restore; the arch stays in database."
)
return 0
count = neutralize(config.database, lst_view_id, config.prefix)
print(
f"✅ -> {count} COW view(s) neutralized (key prefixed with"
f" '{config.prefix}.', deactivated). The arch is kept as an archive;"
" use --restore to undo."
)
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,365 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Find the website COW copies that have gone stale, and reset them.
The problem
-----------
A COW copy freezes the module view it was copied from. Version after version,
the MODULE view is modernised while the copy is not — it is user data, nothing
rewrites it. The copy keeps working right up to the moment a CHILD view does an
``xpath`` onto something the module added, renamed or re-anchored::
Element '<xpath expr="//head/script[@id='web.layout.odooscript']">'
cannot be located in parent view
Observed on ``web.layout`` during 14.0 -> 15.0: the copy dated from 12.0, where
the script tag had no ``id`` and QWeb still said ``t-raw``. It had survived
until then only because the 14.0 module xpath carried a fallback
(``//head/script[@id='…'] | //head/script[last()]``) that 15.0 removed.
What this checks
----------------
Not a heuristic on deprecated syntax, and not an absolute one either: the
DRIFT between a copy and its module twin. For every active child of a COW
copy, each ``xpath`` expression is resolved twice — against the module twin
and against the copy. An expression the twin can satisfy and the copy cannot
is an anchor the copy has lost.
The comparison has to be differential. Odoo resolves an xpath against the
COMBINED arch of the whole inheritance chain, so « does not resolve in this
parent » proves nothing on its own — a child of ``website.layout`` targets
``//header``, which comes from an ancestor.
When it bites
-------------
A reported copy is not necessarily failing right now. Odoo re-validates a COW
copy when its module twin is REWRITTEN — which is what a version bump does —
or when the page is rendered for that website. So a finding is a breakage
already present in the data, waiting for the next bump to surface it. That is
precisely when it is cheap to fix.
Resetting
---------
``--reset`` copies the MODULE arch over the stale copy. The previous arch is
always written to a backup file first, and the diff is printed, because a copy
can hold a genuine customisation — one line among fifty of drift. Read the
diff, reset, then re-apply what mattered as an INHERITING view rather than a
full copy, so the next version bump cannot make it stale again.
Plain psql on purpose: this runs on databases whose Odoo registry does not
load, which is precisely when it is needed.
"""
import argparse
import datetime
import difflib
import json
import os
import re
import subprocess
import sys
def run_psql(database, sql):
"""Run a statement and return stdout, raising on failure."""
result = subprocess.run(
["psql", "-d", database, "-tAc", sql],
capture_output=True,
text=True,
)
if result.returncode:
raise RuntimeError(
f"Query failed on '{database}': {result.stderr.strip()}"
)
return result.stdout
def normalise_arch(value):
"""The arch as a string, whatever the column type.
Odoo stores ``arch_db`` as text up to 15.0 and as jsonb (one entry per
language) from 16.0. Casting to text gives ``{"en_US": "<t …"}`` in the
second case, so unwrap it — the structure is the same in every language,
and the structure is all this tool looks at.
"""
if not isinstance(value, str):
return "" if value is None else str(value)
text = value.strip()
if text.startswith("{") and '"' in text:
try:
data = json.loads(text)
except ValueError:
return value
if isinstance(data, dict) and data:
for lang in ("en_US", *sorted(data)):
if lang in data and isinstance(data[lang], str):
return data[lang]
return value
def fetch_views(database):
"""Every view that matters: COW copies, their module twin, their children.
One query rather than one per view — these databases hold thousands of
views. The result comes back as JSON: an arch is multi-line XML, so any
line-based format would need a record separator the data could forge.
"""
raw = run_psql(
database,
"SELECT COALESCE(json_agg(json_build_object("
"'id', id, 'key', COALESCE(key, ''), 'inherit_id', inherit_id,"
"'website_id', website_id, 'active', active,"
"'arch', arch_db::text))::text, '[]')"
" FROM ir_ui_view WHERE arch_db IS NOT NULL",
)
rows = {}
for item in json.loads(raw.strip() or "[]"):
item["arch"] = normalise_arch(item.get("arch"))
rows[item["id"]] = item
return rows
XPATH_RE = re.compile(r"<xpath\b[^>]*\bexpr=([\"'])(.*?)\1", re.S)
def child_xpaths(arch):
"""The xpath expressions a view applies to its parent."""
return [match.group(2) for match in XPATH_RE.finditer(arch)]
def require_lxml():
"""The XPath engine, or a loud failure.
Never degrade to « everything resolves » when lxml is missing: a checker
that cannot check must not answer « all clean ». Odoo's own venvs all ship
lxml; the bare system python3 usually does not.
"""
try:
from lxml import etree
return etree
except ImportError:
sys.exit(
"❌ lxml is required to resolve the xpath expressions.\n"
" Run this with an interpreter that has it, e.g.\n"
" ./.venv.erplibre/bin/python3 " + os.path.relpath(__file__)
)
def resolve(etree, arch, expr):
"""True if `expr` matches something in `arch`.
An arch that does not parse, or an expression lxml cannot evaluate, counts
as resolvable: this tool reports views that will CERTAINLY break, and must
never invent a failure it cannot substantiate.
"""
try:
tree = etree.fromstring(arch.encode("utf-8"))
except etree.XMLSyntaxError:
return True
try:
return bool(tree.xpath(expr))
except etree.XPathEvalError:
return True
def analyse(database):
"""[(copy, module_twin, [(child_id, failing_expr), ...])] — the COW copies
that have DRIFTED away from their module twin.
The test is differential, and it has to be. Odoo resolves an xpath against
the COMBINED arch of the whole inheritance chain, not against the parent's
own arch, so « does not resolve here » proves nothing on its own: a child
of ``website.layout`` legitimately targets ``//header``, which comes from
an ancestor. Comparing the copy with its module twin removes that whole
class of noise:
resolves in the module twin, not in the copy -> the copy lost an
anchor: real drift
resolves in neither -> the anchor lives
further up the chain
resolves in both -> nothing to see
Inactive children are skipped: Odoo never applies them, so they cannot
break a load.
"""
etree = require_lxml()
views = fetch_views(database)
module_by_key = {
v["key"]: v
for v in views.values()
if v["website_id"] is None and v["key"]
}
children = {}
for view in views.values():
if view["inherit_id"]:
children.setdefault(view["inherit_id"], []).append(view)
findings = []
for view in sorted(views.values(), key=lambda v: v["id"]):
twin = module_by_key.get(view["key"])
if view["website_id"] is None or twin is None:
continue
broken = []
for child in children.get(view["id"], []):
if not child.get("active", True):
continue
for expr in child_xpaths(child["arch"]):
if resolve(etree, twin["arch"], expr) and not resolve(
etree, view["arch"], expr
):
broken.append((child["id"], expr))
if broken:
findings.append((view, twin, broken))
return findings
def show_diff(module_view, cow_view):
"""Module arch vs copy: what the copy would gain and lose on a reset."""
diff = difflib.unified_diff(
module_view["arch"].splitlines(),
cow_view["arch"].splitlines(),
fromfile=f"module id={module_view['id']}",
tofile=f"cow id={cow_view['id']}",
lineterm="",
)
for line in diff:
print(f" {line}")
def backup(database, cow_view, directory):
"""Store the arch about to be replaced, and return the file path."""
os.makedirs(directory, exist_ok=True)
stamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
path = os.path.join(
directory, f"{cow_view['key']}_{cow_view['id']}_{stamp}.json"
)
with open(path, "w", encoding="utf-8") as fh:
json.dump(
{
"database": database,
"id": cow_view["id"],
"key": cow_view["key"],
"website_id": cow_view["website_id"],
"saved_at": stamp,
"arch_db": cow_view["arch"],
},
fh,
indent=2,
ensure_ascii=False,
)
return path
def reset(database, cow_view, module_view):
"""Copy the module arch over the stale copy. Returns rows updated."""
sql = (
"UPDATE ir_ui_view c SET arch_db = m.arch_db "
"FROM ir_ui_view m "
f"WHERE c.id = {cow_view['id']} AND m.id = {module_view['id']}"
)
run_psql(database, sql)
return 1
def main():
parser = argparse.ArgumentParser(
description=(
"Report the website COW copies whose children can no longer be"
" applied, and optionally reset them onto the module view."
)
)
parser.add_argument("-d", "--database", required=True)
parser.add_argument(
"--reset",
metavar="KEY",
action="append",
default=[],
help="reset this key onto its module view ('all' for every finding)",
)
parser.add_argument(
"--apply",
action="store_true",
help="really write; without it --reset only shows what it would do",
)
parser.add_argument(
"--backup-dir",
default=None,
help="where the replaced arch is saved"
" (default private/odoo/migration/<db>/cow_reset)",
)
config = parser.parse_args()
findings = analyse(config.database)
if not findings:
print("✅ No COW copy has drifted from its module view.")
return 0
print(
f"⚠️ {len(findings)} COW copy(ies) drifted from their module view"
f" in {config.database}"
)
print(
" Odoo surfaces this when the module view is rewritten (a version"
" bump) or when the page is rendered.\n"
)
for cow_view, module_view, broken in findings:
twin = (
f"module id={module_view['id']}"
if module_view
else "NO module view with this key"
)
print(
f" id={cow_view['id']} key={cow_view['key']}"
f" website_id={cow_view['website_id']} [{twin}]"
)
for child_id, expr in broken:
print(f" child {child_id} cannot apply: {expr}")
if module_view:
show_diff(module_view, cow_view)
print()
if not config.reset:
print(
"Nothing changed. Re-run with --reset <key> --apply to reset a"
" copy onto its module view."
)
return 1
wanted = set(config.reset)
directory = config.backup_dir or os.path.join(
"private", "odoo", "migration", config.database, "cow_reset"
)
done = 0
for cow_view, module_view, _broken in findings:
if "all" not in wanted and cow_view["key"] not in wanted:
continue
if not module_view:
print(
f"⏭ {cow_view['key']}: no module view to reset onto,"
" skipped."
)
continue
if not config.apply:
print(
f"[dry-run] would reset id={cow_view['id']}"
f" ({cow_view['key']}) onto id={module_view['id']}"
)
continue
path = backup(config.database, cow_view, directory)
reset(config.database, cow_view, module_view)
done += 1
print(f"✅ reset id={cow_view['id']} ({cow_view['key']})")
print(f" previous arch saved to {path}")
if config.apply and done:
print(
f"\n{done} copy(ies) reset. Re-apply any real customisation as an"
" INHERITING view, not a copy, so it cannot go stale again."
)
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,213 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Snapshot the website COW views, and diff two snapshots.
Why
---
A version bump rewrites website views in ways nobody announces: OpenUpgrade
converts Bootstrap markup on every ``website_id IS NOT NULL`` view, modules
rewrite the copies they own, and some copies simply disappear. Without a
before/after record, "the site looks wrong" is unanswerable.
Taking a snapshot before and after each jump turns that into a diff: which copy
lost its arch, which changed mode, which was renamed, which vanished.
Snapshots hold customer template content, so they belong under ``private/``
and are never versioned.
Usage::
snapshot_cow_views.py -d <database> --label before_13
snapshot_cow_views.py -d <database> --label after_13
snapshot_cow_views.py --diff <before.json> <after.json>
"""
import argparse
import datetime
import hashlib
import json
import os
import subprocess
import sys
# Columns worth recording. ir_ui_view does not expose the same set across 12.0
# to 18.0, so the query keeps only those that actually exist.
WANTED_COLUMN = [
"id",
"key",
"name",
"type",
"mode",
"active",
"priority",
"website_id",
"inherit_id",
"arch_fs",
"arch_updated",
]
DEFAULT_DIR = os.path.join("private", "odoo", "migration")
def run_psql(database, sql):
"""Run a read-only query and return stdout."""
result = subprocess.run(
["psql", "-d", database, "-tAc", sql],
capture_output=True,
text=True,
)
if result.returncode:
raise RuntimeError(
f"Query failed on '{database}': {result.stderr.strip()}"
)
return result.stdout
def existing_columns(database):
"""Column names of ir_ui_view present in this database."""
output = run_psql(
database,
"SELECT column_name FROM information_schema.columns"
" WHERE table_name = 'ir_ui_view';",
)
return {line.strip() for line in output.splitlines() if line.strip()}
def collect(database):
"""Return the list of COW views as plain dicts, arch included.
The rows come back as JSON straight from Postgres: an arch holds newlines
and pipes, so no hand-made separator survives it.
"""
available = existing_columns(database)
lst_column = [name for name in WANTED_COLUMN if name in available]
select = ", ".join(lst_column) + ", arch_db::text AS arch_db"
output = run_psql(
database,
"SELECT COALESCE(json_agg(row_to_json(t)), '[]'::json) FROM ("
f" SELECT {select} FROM ir_ui_view"
" WHERE website_id IS NOT NULL ORDER BY id) t;",
)
lst_view = json.loads(output or "[]")
for view in lst_view:
arch = view.pop("arch_db", None) or ""
view["arch_md5"] = hashlib.md5(arch.encode("utf-8")).hexdigest()
view["arch_len"] = len(arch)
view["arch_db"] = arch
return lst_view
def save(database, label, output_dir):
"""Write a snapshot and return its path."""
directory = output_dir or os.path.join(
DEFAULT_DIR, database, "cow_snapshots"
)
os.makedirs(directory, exist_ok=True)
lst_view = collect(database)
payload = {
"database": database,
"label": label,
"taken_at": datetime.datetime.now().isoformat(timespec="seconds"),
"count": len(lst_view),
"views": lst_view,
}
file_path = os.path.join(directory, f"{label}.json")
with open(file_path, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=2, ensure_ascii=False)
print(f"✅ -> {len(lst_view)} COW view(s) recorded in {file_path}")
return file_path
def load(file_path):
with open(file_path, "r", encoding="utf-8") as f:
return json.load(f)
def diff(path_before, path_after):
"""Print what changed between two snapshots."""
before = load(path_before)
after = load(path_after)
map_before = {view["id"]: view for view in before["views"]}
map_after = {view["id"]: view for view in after["views"]}
removed = sorted(set(map_before) - set(map_after))
added = sorted(set(map_after) - set(map_before))
common = sorted(set(map_before) & set(map_after))
print(
f"📊 {before.get('label')} ({before.get('count')} views)"
f" -> {after.get('label')} ({after.get('count')} views)"
)
if removed:
print(f"❌ {len(removed)} COW view(s) disappeared:")
for view_id in removed:
view = map_before[view_id]
print(f" - id={view_id} {view.get('key')}")
if added:
print(f"➕ {len(added)} COW view(s) appeared:")
for view_id in added:
view = map_after[view_id]
print(f" - id={view_id} {view.get('key')} ({view.get('mode')})")
lst_changed = []
for view_id in common:
old, new = map_before[view_id], map_after[view_id]
lst_field = []
for field in ("key", "mode", "inherit_id", "active", "arch_md5"):
if old.get(field) != new.get(field):
if field == "arch_md5":
lst_field.append(
f"arch rewritten ({old.get('arch_len')} ->"
f" {new.get('arch_len')} chars)"
)
else:
lst_field.append(
f"{field}: {old.get(field)} -> {new.get(field)}"
)
if lst_field:
lst_changed.append((view_id, new.get("key"), lst_field))
if lst_changed:
print(f"✏️ {len(lst_changed)} COW view(s) changed:")
for view_id, key, lst_field in lst_changed:
print(f" - id={view_id} {key}")
for change in lst_field:
print(f" {change}")
if not (removed or added or lst_changed):
print("✅ -> No change on the website COW views.")
return 0
def main():
parser = argparse.ArgumentParser(
description="Snapshot website COW views, or diff two snapshots."
)
parser.add_argument("-d", "--database", help="database to snapshot")
parser.add_argument(
"-l", "--label", help="snapshot name, e.g. before_13 or after_13"
)
parser.add_argument(
"-o", "--output_dir", help="where to write (default: private/...)"
)
parser.add_argument(
"--diff",
nargs=2,
metavar=("BEFORE", "AFTER"),
help="compare two snapshot files instead of taking one",
)
config = parser.parse_args()
if config.diff:
return diff(*config.diff)
if not config.database or not config.label:
parser.error("--database and --label are required to take a snapshot")
save(config.database, config.label, config.output_dir)
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -377,9 +377,56 @@ sudo modprobe -r kvm_intel && sudo modprobe kvm_intel # ou / or reboot
```
<!-- [en] -->
On **s390x and arm64** the parameter lives on the `kvm` module itself, not on
`kvm_intel` / `kvm_amd` — and `/sys/module/kvm/parameters/nested` does not even
exist on x86. Reading the wrong file returns a reassuring `0` that commands
nothing:
<!-- [fr] -->
Sur **s390x et arm64**, le paramètre vit sur le module `kvm` lui-même, et non
sur `kvm_intel` / `kvm_amd` — et `/sys/module/kvm/parameters/nested` n'existe
même pas sur x86. Lire le mauvais fichier renvoie un `0` rassurant qui ne
commande rien :
<!-- [common] -->
```bash
echo "options kvm nested=1" | sudo tee /etc/modprobe.d/kvm-nested.conf
sudo modprobe -r kvm && sudo modprobe kvm # ou / or reboot
```
<!-- [en] -->
`nested` on a machine means « let MY guests run VMs ». To accelerate a VM
created on host H, the setting belongs to the hypervisor **above** H, not to H
itself. The one command that settles it, run on H:
<!-- [fr] -->
`nested` sur une machine signifie « j'autorise MES invités à faire tourner des
VM ». Pour accélérer une VM créée sur l'hôte H, le réglage appartient à
l'hyperviseur **au-dessus** de H, pas à H. La commande qui tranche, sur H :
<!-- [common] -->
```bash
ls -l /dev/kvm # absent -> pas d'imbrication, tout sera émulé
```
<!-- [en] -->
Measured on an s390x host that was itself a KVM guest without nesting:
`/dev/kvm` absent, `virsh dumpxml` showing `<domain type='qemu'>`, and a
7 min 30 boot instead of well under a minute. `systemd-detect-virt` inside the
VM is **not** proof of acceleration — on s390x, QEMU fabricates the STSI answer
and reports `kvm` even under TCG. Only `<domain type=…>` on the host is
conclusive.
If nesting is unavailable, QEMU still runs via software emulation (TCG) — it
works but is slow.
<!-- [fr] -->
Mesuré sur un hôte s390x lui-même invité KVM sans imbrication : `/dev/kvm`
absent, `virsh dumpxml` affichant `<domain type='qemu'>`, et un démarrage de
7 min 30 au lieu de bien moins d'une minute. `systemd-detect-virt` dans la VM
ne prouve **pas** l'accélération — sur s390x, QEMU fabrique la réponse STSI et
annonce `kvm` même en TCG. Seul `<domain type=…>` sur l'hôte fait foi.
### Bridge for external access
A NAT VM is isolated; a **bridged** VM gets an IP directly on the LAN,

View file

@ -207,6 +207,37 @@ echo "options kvm_intel nested=1" | sudo tee /etc/modprobe.d/kvm-nested.conf
sudo modprobe -r kvm_intel && sudo modprobe kvm_intel # ou / or reboot
```
Sur **s390x et arm64**, le paramètre vit sur le module `kvm` lui-même, et non
sur `kvm_intel` / `kvm_amd` — et `/sys/module/kvm/parameters/nested` n'existe
même pas sur x86. Lire le mauvais fichier renvoie un `0` rassurant qui ne
commande rien :
```bash
echo "options kvm nested=1" | sudo tee /etc/modprobe.d/kvm-nested.conf
sudo modprobe -r kvm && sudo modprobe kvm # ou / or reboot
```
`nested` sur une machine signifie « j'autorise MES invités à faire tourner des
VM ». Pour accélérer une VM créée sur l'hôte H, le réglage appartient à
l'hyperviseur **au-dessus** de H, pas à H. La commande qui tranche, sur H :
```bash
ls -l /dev/kvm # absent -> pas d'imbrication, tout sera émulé
```
Mesuré sur un hôte s390x lui-même invité KVM sans imbrication : `/dev/kvm`
absent, `virsh dumpxml` affichant `<domain type='qemu'>`, et un démarrage de
7 min 30 au lieu de bien moins d'une minute. `systemd-detect-virt` dans la VM
ne prouve **pas** l'accélération — sur s390x, QEMU fabrique la réponse STSI et
annonce `kvm` même en TCG. Seul `<domain type=…>` sur l'hôte fait foi.
### Bridge for external access
A NAT VM is isolated; a **bridged** VM gets an IP directly on the LAN,
reachable by any machine. On the KVM host, create a bridge `br0` over the
physical NIC (**wired only** — Wi-Fi cannot be bridged). netplan (Ubuntu
server) — replace `enp3s0` with your interface:
Si l'imbrication est indisponible, QEMU tourne quand même en émulation
logicielle (TCG) — ça marche mais c'est lent.

View file

@ -197,16 +197,34 @@ echo "options kvm_intel nested=1" | sudo tee /etc/modprobe.d/kvm-nested.conf
sudo modprobe -r kvm_intel && sudo modprobe kvm_intel # ou / or reboot
```
On **s390x and arm64** the parameter lives on the `kvm` module itself, not on
`kvm_intel` / `kvm_amd` — and `/sys/module/kvm/parameters/nested` does not even
exist on x86. Reading the wrong file returns a reassuring `0` that commands
nothing:
```bash
echo "options kvm nested=1" | sudo tee /etc/modprobe.d/kvm-nested.conf
sudo modprobe -r kvm && sudo modprobe kvm # ou / or reboot
```
`nested` on a machine means « let MY guests run VMs ». To accelerate a VM
created on host H, the setting belongs to the hypervisor **above** H, not to H
itself. The one command that settles it, run on H:
```bash
ls -l /dev/kvm # absent -> pas d'imbrication, tout sera émulé
```
Measured on an s390x host that was itself a KVM guest without nesting:
`/dev/kvm` absent, `virsh dumpxml` showing `<domain type='qemu'>`, and a
7 min 30 boot instead of well under a minute. `systemd-detect-virt` inside the
VM is **not** proof of acceleration — on s390x, QEMU fabricates the STSI answer
and reports `kvm` even under TCG. Only `<domain type=…>` on the host is
conclusive.
If nesting is unavailable, QEMU still runs via software emulation (TCG) — it
works but is slow.
### Bridge for external access
A NAT VM is isolated; a **bridged** VM gets an IP directly on the LAN,
reachable by any machine. On the KVM host, create a bridge `br0` over the
physical NIC (**wired only** — Wi-Fi cannot be bridged). netplan (Ubuntu
server) — replace `enp3s0` with your interface:
```yaml
# /etc/netplan/01-br0.yaml
network:

View file

@ -1163,6 +1163,130 @@ def hash_password(plain: str) -> str:
return out.stdout.strip()
# Miroirs apt, du plus rapide au dernier recours. cloud-init prend le PREMIER
# joignable de la liste « search » : l'ordre est donc la priorité.
#
# Mesuré depuis Montréal sur l'index main/s390x (1,6 Mo) :
# ports.ubuntu.com 1,61 s 1,0 Mo/s
# mirror.csclub.uwaterloo.ca 0,32 s 5,2 Mo/s
# mirror.us.leaseweb.net 0,88 s 1,9 Mo/s
#
# Le chemin diffère selon l'architecture : les arches « ports » (s390x, arm64,
# ppc64el, riscv64) ne sont PAS sur archive.ubuntu.com, et amd64 n'est pas sur
# ports.ubuntu.com. Une seule liste servirait donc la moitié des cas en 404.
APT_MIRRORS_PORTS = [
"http://mirror.csclub.uwaterloo.ca/ubuntu-ports",
"http://mirror.us.leaseweb.net/ubuntu-ports",
"http://ports.ubuntu.com/ubuntu-ports",
]
APT_MIRRORS_MAIN = [
"http://mirror.csclub.uwaterloo.ca/ubuntu",
"http://mirror.us.leaseweb.net/ubuntu",
"http://archive.ubuntu.com/ubuntu",
]
PORTS_ARCHES = ("s390x", "arm64", "aarch64", "ppc64el", "riscv64")
def apt_mirror_lines(arch: str, override: str | None = None) -> list[str]:
"""Bloc « apt: » du cloud-config, ou [] si rien à écrire.
Ubuntu seulement : Debian, Fedora et Arch ont leurs propres dépôts, et
« search » y écrirait des URI qui n'existent pas.
"""
mirrors = (
[override]
if override
else (APT_MIRRORS_PORTS if arch in PORTS_ARCHES else APT_MIRRORS_MAIN)
)
lines = ["apt:", " primary:", " - arches: [default]", " search:"]
lines += [f" - {m}" for m in mirrors]
# La sécurité suit le même dépôt pour les arches ports ; sur amd64 elle a
# son propre hôte, que les miroirs répliquent sous le même chemin.
lines += [" security:", " - arches: [default]", " search:"]
lines += [f" - {m}" for m in mirrors]
return lines
def kvm_available() -> bool:
"""L'accélération matérielle est-elle réellement utilisable ici ?
« Même architecture que l'hôte » ne suffit PAS à conclure à KVM : dans une
VM sans virtualisation imbriquée, libvirt bascule SILENCIEUSEMENT en TCG.
Mesuré sur erplibre01, lui-même invité KVM : une VM s390x sur hôte s390x
est sortie en « <domain type='qemu'> », soit de l'émulation intégrale — et
un démarrage de 7 min 30 au lieu de quelques dizaines de secondes, sans
que rien ne le signale.
/dev/kvm est le test que fait QEMU lui-même. Mais l'ACCÈS n'est concluant
que si on est root : libvirt, lui, tourne en root et se moque de notre
appartenance au groupe kvm. Tester nos propres droits en non-root ferait
crier « pas de KVM » à un utilisateur simplement hors du groupe.
"""
if not os.path.exists("/dev/kvm"):
return False
if os.geteuid() == 0:
return os.access("/dev/kvm", os.R_OK | os.W_OK)
return True
def nested_module() -> str:
"""Module noyau portant le paramètre « nested » sur CET hôte.
Le nom change selon l'architecture, et se tromper de module fait lire un
« 0 » rassurant sur un fichier qui ne commande rien : s390x et arm64
l'exposent sur « kvm », x86 sur « kvm_intel » ou « kvm_amd » selon le
fabricant — et /sys/module/kvm/parameters/nested n'y existe même pas.
"""
arch = host_arch()
if arch != "amd64":
return "kvm"
try:
info = Path("/proc/cpuinfo").read_text(errors="replace")
except OSError:
return "kvm_intel"
return "kvm_amd" if " svm" in info else "kvm_intel"
def host_timezone() -> str:
"""Fuseau de l'hôte, au format zoneinfo (« America/Montreal »).
Défaut des VM déployées : une VM qui hérite du fuseau de la machine qui la
crée horodate ses journaux, ses commits et ses bases comme son opérateur.
Sans cela elle démarre en UTC, ce qui ne se voit qu'après coup — des commits
à +0000 alors que tout le reste du dépôt est en -0400.
Trois sources, de la plus fiable à la plus rustique ; « UTC » en dernier
recours plutôt qu'une exception, car un fuseau indéterminable ne doit pas
empêcher un déploiement.
"""
try:
out = subprocess.run(
["timedatectl", "show", "-p", "Timezone", "--value"],
capture_output=True,
text=True,
timeout=5,
).stdout.strip()
if out:
return out
except (OSError, subprocess.SubprocessError):
pass
try:
tz = Path("/etc/timezone").read_text(encoding="utf-8").strip()
if tz:
return tz
except OSError:
pass
try:
# /etc/localtime est un lien vers /usr/share/zoneinfo/<Zone>
target = Path("/etc/localtime").resolve()
parts = target.parts
if "zoneinfo" in parts:
return "/".join(parts[parts.index("zoneinfo") + 1 :])
except OSError:
pass
return "UTC"
def build_cloud_config(
args: argparse.Namespace, pw_hash: str | None, ssh_keys: list[str]
) -> str:
@ -1189,6 +1313,11 @@ def build_cloud_config(
lines.append(f"ssh_pwauth: {'true' if pw_hash else 'false'}")
lines.append(f"locale: {args.locale}")
lines.append(f"timezone: {args.timezone}")
if getattr(args, "distro", "ubuntu") == "ubuntu":
lines += apt_mirror_lines(
getattr(args, "arch", "amd64"), getattr(args, "apt_mirror", None)
)
lines += [
"keyboard:",
f" layout: {args.keyboard_layout}",
@ -1220,15 +1349,41 @@ def build_cloud_config(
"runcmd:",
" - systemctl enable --now ssh 2>/dev/null"
" || systemctl enable --now sshd 2>/dev/null || true",
# qemu-guest-agent : installé + activé APRÈS sshd (donc SSH reste
# disponible tout de suite, sans attendre le réseau). Tout est
# « || true » : si l'installation échoue (réseau lent/absent), le boot
# n'est pas bloqué. La plupart des images cloud l'incluent déjà.
# Rafraîchit d'abord l'index (les images cloud n'ont PAS de listes apt
# -> sinon « Unable to locate package »), puis installe. timeout : un
# miroir lent ne bloque JAMAIS cloud-init. Non fatal (|| true).
# Sous-shell ( ) et NON accolades { } : « { » est un indicateur YAML.
" - (command -v apt-get >/dev/null && (timeout 120 apt-get update -qq"
# Getty sur la console qui EXISTE VRAIMENT.
#
# Sur s390x, l'image attend /dev/ttysclp0 (généré depuis la ligne de
# commande noyau) alors que le périphérique réellement présent porte un
# autre nom : « Timed out waiting for device dev-ttysclp0.device », puis
# « Dependency failed for serial-getty@ttysclp0 ». La console affiche
# donc tout le démarrage mais n'offre AUCUNE invite de connexion — elle
# est en lecture seule par accident, et c'est justement le seul recours
# quand SSH ne répond pas. On active le getty sur le premier
# périphérique console présent. Ailleurs (x86/arm64) ttyS0 existe et son
# getty tourne déjà : « enable --now » n'y change rien.
" - for d in ttysclp0 sclp_line0 hvc0 ttyS0; do"
" test -c /dev/$d && systemctl enable --now serial-getty@$d.service"
" 2>/dev/null && break; done || true",
# qemu-guest-agent : installé APRÈS sshd, et surtout HORS de cloud-init.
#
# Mesuré sur Ubuntu 26.04 s390x : « apt-get install qemu-guest-agent »
# tire liburing2, ubuntu-helper-virt-hwe et ubuntu-virt depuis
# ports.ubuntu.com, et cloud-final tourne 9 min 47. Or le suivi
# d'installation attend « cloud-init status: done » : dix minutes
# d'attente pour un paquet accessoire, avant même de commencer le
# travail utile.
#
# systemd-run --no-block rend la main tout de suite : cloud-init termine
# en quelques secondes et l'agent apparaît quand il apparaît. On saute
# aussi l'installation quand qemu-ga est déjà là, ce qui est le cas de
# beaucoup d'images. Repli en ligne si systemd-run manque.
" - command -v qemu-ga >/dev/null 2>&1 ||"
" systemd-run --no-block --unit=erplibre-qga --collect"
" /bin/sh -c 'command -v apt-get >/dev/null && { apt-get update -qq"
" || true; apt-get install -y qemu-guest-agent; }"
" || command -v dnf >/dev/null && dnf install -y qemu-guest-agent"
" || command -v pacman >/dev/null && pacman -Sy --noconfirm"
" qemu-guest-agent' 2>/dev/null"
" || (command -v apt-get >/dev/null && (timeout 120 apt-get update -qq"
" || true; timeout 300 apt-get install -y qemu-guest-agent)) ||"
" (command -v dnf >/dev/null && timeout 300 dnf install -y"
" qemu-guest-agent) || (command -v pacman >/dev/null && timeout 300"
@ -1429,7 +1584,25 @@ def virt_install(
runner: Runner,
) -> None:
# Émulée (TCG, pas de KVM) si l'arch demandée diffère de celle de l'hôte.
# Deux causes d'émulation, à ne pas confondre : une architecture étrangère
# (voulue, on la choisit), et une absence de KVM (subie, et invisible). La
# seconde ne se déduit PAS de l'architecture — dans une VM sans
# virtualisation imbriquée, libvirt retombe en TCG sans rien dire.
emulated = args.arch != host_arch()
if not emulated and not kvm_available():
emulated = True
print(
"⚠ KVM indisponible (/dev/kvm) : cette VM sera ÉMULÉE, donc TRÈS"
" lente."
)
print(
" Cause habituelle : l'hôte est lui-même une VM sans"
" virtualisation imbriquée."
)
print(
" Vérifier : systemd-detect-virt et"
' virsh capabilities | grep "domain type"'
)
# s390x n'a pas de port série ISA : la console est SCLP (ttysclp0), et non
# ttyS0. Ailleurs (x86/arm64), console série classique.
console_target = "sclp" if args.arch == "s390x" else "serial"
@ -1730,6 +1903,24 @@ def build_parser() -> argparse.ArgumentParser:
default="fr_CA.UTF-8",
help="Locale (défaut : fr_CA.UTF-8).",
)
g_cloud.add_argument(
"--apt-mirror",
metavar="URI",
help=(
"Miroir apt unique (Ubuntu). Par défaut, cloud-init essaie dans"
" l'ordre les miroirs les plus rapides mesurés puis le dépôt"
" officiel."
),
)
g_cloud.add_argument(
"--timezone",
default=host_timezone(),
metavar="ZONE",
help=(
"Fuseau horaire de la VM, au format zoneinfo "
f"(défaut : celui de l'hôte, {host_timezone()})."
),
)
g_cloud.add_argument(
"--keyboard-layout",
default="ca",

View file

@ -9,8 +9,16 @@ Execute it with `./script/todo/todo.py` or `make todo`.
For a new project, copy todo_example.json to private/todo/todo_override.json | private/todo/todo_override_private.json and edit it.
The `mail/` package is the mail client reachable from `Assistant > Mail`:
several IMAP/SMTP accounts, a local cache, and a Textual TUI. See
[../../doc/EMAIL.md](../../doc/EMAIL.md).
<!-- [fr] -->
TODO est un robot assistant pour utiliser ERPLibre
Exécutez-le avec `./script/todo/todo.py` ou `make todo`.
Pour un nouveau projet, copiez todo_example.json vers private/todo/todo_override.json | private/todo/todo_override_private.json et modifiez-le.
Le paquet `mail/` est le client courriel accessible depuis
`Assistant > Courriel` : plusieurs comptes IMAP/SMTP, un cache local, et un
TUI Textual. Voir [../../doc/EMAIL.fr.md](../../doc/EMAIL.fr.md).

View file

@ -2,4 +2,8 @@
TODO est un robot assistant pour utiliser ERPLibre
Exécutez-le avec `./script/todo/todo.py` ou `make todo`.
Pour un nouveau projet, copiez todo_example.json vers private/todo/todo_override.json | private/todo/todo_override_private.json et modifiez-le.
Pour un nouveau projet, copiez todo_example.json vers private/todo/todo_override.json | private/todo/todo_override_private.json et modifiez-le.
Le paquet `mail/` est le client courriel accessible depuis
`Assistant > Courriel` : plusieurs comptes IMAP/SMTP, un cache local, et un
TUI Textual. Voir [../../doc/EMAIL.fr.md](../../doc/EMAIL.fr.md).

View file

@ -3,3 +3,7 @@ TODO is an assistant robot to use ERPLibre
Execute it with `./script/todo/todo.py` or `make todo`.
For a new project, copy todo_example.json to private/todo/todo_override.json | private/todo/todo_override_private.json and edit it.
The `mail/` package is the mail client reachable from `Assistant > Mail`:
several IMAP/SMTP accounts, a local cache, and a Textual TUI. See
[../../doc/EMAIL.md](../../doc/EMAIL.md).

View file

@ -242,6 +242,35 @@ class DatabaseManager:
print(file_name)
return file_name
def select_backup_path(self, start=None) -> str | None:
"""Faire choisir une sauvegarde .zip, au parcours ou au chemin tapé.
Les deux, parce que ni l'un ni l'autre ne suffit : le parcours part
d'`image_db/` et n'aide pas si la sauvegarde vient d'ailleurs ; le
chemin tapé oblige à le connaître. Le parcours d'abord, et une saisie
directe si l'on en sort sans rien choisir.
"""
directory = start or os.path.join(os.getcwd(), "image_db")
if todo_file_browser is not None and os.path.isdir(directory):
self._dir_path = ""
browser = todo_file_browser.FileBrowser(
directory, self._on_dir_selected
)
browser.run_main_frame()
if self._dir_path and os.path.isfile(self._dir_path):
print(self._dir_path)
return self._dir_path
answer = input(
t("Path to the backup .zip (empty to cancel): ")
).strip()
if not answer:
return None
path = os.path.expanduser(answer)
if not os.path.isfile(path):
print(f"❌ {t('No such file: ')}{path}")
return None
return path
def download_database_backup_cli(
self, show_remote_list: bool = True
) -> tuple[int, str, str]:

View file

@ -14,18 +14,23 @@ try:
from tkinter import filedialog
from pykeepass import PyKeePass
from pykeepass.exceptions import CredentialsError
except ModuleNotFoundError:
PyKeePass = None
tk = None
filedialog = None
class CredentialsError(Exception):
"""Jamais levée ici : sans pykeepass, `get_kdbx` sort avant d'ouvrir
quoi que ce soit. Définie pour que le `except` reste écrivable."""
class KdbxManager:
def __init__(self, config_file) -> None:
self._config_file = config_file
self._kdbx = None
def get_kdbx(self):
def get_kdbx(self, attempts: int = 3):
if self._kdbx:
return self._kdbx
@ -47,20 +52,42 @@ class KdbxManager:
)
return None
kdbx_password = self._config_file.get_config_value(
["kdbx", "password"]
)
if not kdbx_password:
kdbx_password = getpass.getpass(prompt=t("enter_password"))
if PyKeePass is None:
_logger.error("pykeepass is not installed")
return None
kp = PyKeePass(kdbx_file_path, password=kdbx_password)
if kp:
self._kdbx = kp
return kp
kdbx_password = self._config_file.get_config_value(
["kdbx", "password"]
)
if kdbx_password:
# Mot de passe pris dans la configuration : personne à qui
# redemander, mais il peut être faux — le dire au lieu de
# laisser remonter une trace de la bibliothèque.
try:
self._kdbx = PyKeePass(kdbx_file_path, password=kdbx_password)
except CredentialsError:
print(t("kdbx_wrong_password"))
return None
return self._kdbx
# Saisie interactive. Un mot de passe refusé est le cas NORMAL ici,
# pas une panne : la bibliothèque lève `CredentialsError` et, sans
# ce rattrapage, la trace remontait jusqu'à tuer le CLI. On nomme
# aussi le coffre — l'invite ne disait pas DE QUOI elle parlait.
print(f"{t('kdbx_vault_is')} {kdbx_file_path}")
for _ in range(attempts):
password = getpass.getpass(prompt=t("kdbx_ask_password"))
if not password:
print(t("kdbx_give_up"))
return None
try:
self._kdbx = PyKeePass(kdbx_file_path, password=password)
except CredentialsError:
print(t("kdbx_wrong_password"))
continue
return self._kdbx
print(t("kdbx_give_up"))
return None
def get_extra_command_user(
self, kdbx_key: str | list | None

View file

@ -0,0 +1,11 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Client courriel du CLI TODO.
Le paquet est découpé par responsabilité : `crypto` scelle, `secrets` garde
les mots de passe, `accounts` décrit les comptes, `store` cache localement,
`imap_sync` synchronise, `smtp_send` envoie, `tui` affiche, `menu` branche le
tout sur le CLI. Aucun de ces modules n'importe `todo.py` ; c'est `todo.py`
qui importe `menu`.
"""

View file

@ -0,0 +1,62 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""La logique d'ajout de compte et de mise en place du coffre, sans UI.
`menu._add_account` et `menu._ensure_kdbx` mêlaient cette logique — pas
triviale, elle annule l'écriture du secret si la sauvegarde du compte échoue
— à des `input()`/`getpass.getpass()`. Le CLI et le TUI ont chacun leur façon
de demander l'information, mais doivent appeler exactement le même code une
fois qu'ils l'ont : sinon les deux copies dérivent. Ce module ne connaît ni
`input`, ni Textual, ni aucune bibliothèque d'interface.
"""
from __future__ import annotations
import os
from script.todo.mail import accounts as mail_accounts
from script.todo.mail.accounts import AccountError
from script.todo.mail.secrets import SecretError, create_kdbx
from script.todo.todo_i18n import t
# Chemin proposé par défaut pour un kdbx nouvellement créé. `private/` est un
# dossier versionné, mais `private/.gitignore` y ignore déjà `*.kdbx` : c'est
# la convention du dépôt pour les fichiers de coffre.
DEFAULT_KDBX_PATH = "private/erplibre.kdbx"
def save_new_account(secret_store, accounts, account, password) -> None:
"""Écrit le mot de passe puis sauvegarde `accounts` (qui doit déjà
contenir `account`, à la place voulue par l'appelant).
Si la sauvegarde échoue, le secret est retiré du coffre avant que
l'exception ne remonte : l'y laisser sous une référence qu'aucune
configuration ne désigne en ferait un déchet invisible.
"""
secret_store.set(account.secret_ref, password)
try:
mail_accounts.save(accounts)
except (AccountError, OSError):
try:
secret_store.delete(account.secret_ref)
except SecretError:
pass
raise
def kdbx_is_configured(config_file) -> bool:
"""Vrai si un kdbx est déjà désigné dans la configuration."""
return bool(config_file.get_config_value(["kdbx", "path"]))
def create_vault(config_file, path: str, password: str) -> None:
"""Crée un nouveau kdbx à `path`, puis l'enregistre comme coffre actif."""
create_kdbx(path, password)
config_file.set_config_value(["kdbx", "path"], path)
def use_existing_vault(config_file, path: str) -> None:
"""Adopte un kdbx déjà présent sur disque comme coffre actif."""
if not path or not os.path.isfile(path):
raise SecretError(f"{t('mail_kdbx_path_not_found')} {path}")
config_file.set_config_value(["kdbx", "path"], path)

View file

@ -0,0 +1,285 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Les comptes courriel : description, préréglages, fichier de config.
`accounts.json` ne contient QUE ce qui n'est pas secret. Le mot de passe vit
dans le coffre (voir `secrets.py`) et le fichier n'en garde qu'une référence.
Le fichier reste donc lisible, éditable à la main et réparable, sans devenir
un endroit d'où une fuite ferait mal.
Les préréglages `gmail`, `outlook` et `icloud` supposent un MOT DE PASSE
D'APPLICATION : l'authentification simple ne passe plus autrement chez ces
fournisseurs. C'est la limite assumée de la phase 1 ; la phase 2 apporte OAuth.
"""
from __future__ import annotations
import json
import os
from dataclasses import asdict, dataclass, field
from pathlib import Path
from script.todo.todo_i18n import t
SCHEMA_VERSION = 1
SECURITIES = ("ssl", "starttls", "none")
PRESETS: dict[str, dict] = {
"gmail": {
"label": "Google / Gmail",
"imap": {"host": "imap.gmail.com", "port": 993, "security": "ssl"},
"smtp": {
"host": "smtp.gmail.com",
"port": 587,
"security": "starttls",
},
"sent_folder": "[Gmail]/Sent Mail",
"app_password": True,
"note_key": "mail_preset_note_gmail",
},
"outlook": {
"label": "Microsoft / Outlook",
"imap": {
"host": "outlook.office365.com",
"port": 993,
"security": "ssl",
},
"smtp": {
"host": "smtp.office365.com",
"port": 587,
"security": "starttls",
},
"sent_folder": "Sent Items",
"app_password": True,
"note_key": "mail_preset_note_outlook",
},
"icloud": {
"label": "Apple / iCloud",
"imap": {"host": "imap.mail.me.com", "port": 993, "security": "ssl"},
"smtp": {
"host": "smtp.mail.me.com",
"port": 587,
"security": "starttls",
},
"sent_folder": "Sent Messages",
"app_password": True,
"note_key": "mail_preset_note_icloud",
},
"generic": {
"label": "Serveur standard (IMAP/SMTP)",
"imap": {"host": "", "port": 993, "security": "ssl"},
"smtp": {"host": "", "port": 587, "security": "starttls"},
"sent_folder": "Sent",
"app_password": False,
"note_key": "mail_preset_note_generic",
},
}
class AccountError(Exception):
"""Configuration de compte invalide, illisible ou en conflit."""
@dataclass
class ServerConf:
host: str
port: int
security: str
user: str
def __post_init__(self) -> None:
if self.security not in SECURITIES:
raise AccountError(
f"{t('mail_err_unknown_security')} {self.security!r}"
f" {t('mail_err_expected')} {SECURITIES})"
)
@dataclass
class Account:
name: str
email: str
imap: ServerConf
smtp: ServerConf
secret_ref: str
display_name: str = ""
preset: str = "generic"
cache_mode: str | None = None
sent_folder: str = "Sent"
enabled: bool = True
def __post_init__(self) -> None:
if not self.name:
raise AccountError(t("mail_err_account_needs_name"))
if (
"/" in self.name
or os.sep in self.name
or self.name.startswith(".")
):
raise AccountError(
f"{t('mail_err_invalid_account_name')} {self.name!r}"
f" {t('mail_err_account_name_reason')}"
)
if self.cache_mode not in (None, "clear", "encrypted", "ephemeral"):
raise AccountError(
f"{t('mail_err_unknown_cache_mode')} {self.cache_mode!r}"
)
def cache_key_ref(self) -> str:
"""Référence de la clé de chiffrement, distincte du mot de passe."""
return f"{self.secret_ref}/cache-key"
def from_header(self) -> str:
return (
f"{self.display_name} <{self.email}>"
if self.display_name
else self.email
)
def to_dict(self) -> dict:
data = asdict(self)
return data
@classmethod
def from_dict(cls, d: dict) -> "Account":
try:
return cls(
name=d["name"],
email=d["email"],
imap=ServerConf(**d["imap"]),
smtp=ServerConf(**d["smtp"]),
secret_ref=d["secret_ref"],
display_name=d.get("display_name", ""),
preset=d.get("preset", "generic"),
cache_mode=d.get("cache_mode"),
sent_folder=d.get("sent_folder", "Sent"),
enabled=d.get("enabled", True),
)
except (KeyError, TypeError) as exc:
raise AccountError(
f"{t('mail_err_account_unreadable')} {exc}"
) from exc
def accounts_path() -> Path:
return Path(os.path.expanduser("~/.erplibre/mail/accounts.json"))
def _prepare_parent(path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
os.chmod(path.parent, 0o700)
def _write_private(path: Path, text: str) -> None:
"""Écrit `text` dans un fichier créé en 0600 dès sa création.
Écrire puis `chmod` laisserait le fichier — mot de passe absent, mais
`secret_ref` et adresses y sont — lisible à l'umask du process le temps
entre les deux appels. `os.open` avec le mode dès l'ouverture ferme cette
fenêtre ; le `chmod` qui suit corrige aussi un fichier déjà là écrit par
un umask permissif.
"""
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as handle:
handle.write(text)
os.chmod(path, 0o600)
def account_from_preset(
name: str,
email: str,
preset_key: str,
*,
user: str | None = None,
display_name: str = "",
vault: str = "kdbx",
) -> Account:
preset = PRESETS.get(preset_key)
if preset is None:
raise AccountError(f"{t('mail_err_unknown_preset')} {preset_key!r}")
login = user or email
ref = (
f"kdbx:ERPLibre/Mail/{name}" if vault == "kdbx" else f"keyring:{name}"
)
return Account(
name=name,
email=email,
display_name=display_name,
preset=preset_key,
imap=ServerConf(user=login, **preset["imap"]),
smtp=ServerConf(user=login, **preset["smtp"]),
secret_ref=ref,
cache_mode=None,
sent_folder=preset["sent_folder"],
enabled=True,
)
def load(path: Path | None = None) -> list[Account]:
path = Path(path) if path else accounts_path()
if not path.exists():
return []
try:
data = json.loads(path.read_text())
except ValueError as exc:
raise AccountError(
f"{path} {t('mail_err_not_valid_json')} {exc}"
) from exc
if not isinstance(data, dict):
raise AccountError(
f"{path} {t('mail_err_should_contain_json_object')}"
)
return [Account.from_dict(d) for d in data.get("accounts", [])]
def save(accounts: list[Account], path: Path | None = None) -> None:
path = Path(path) if path else accounts_path()
names = [a.name for a in accounts]
duplicates = {n for n in names if names.count(n) > 1}
if duplicates:
raise AccountError(
f"{t('mail_err_duplicate_account_names')} {sorted(duplicates)}"
)
_prepare_parent(path)
payload = {
"version": SCHEMA_VERSION,
"default_account": names[0] if names else None,
"accounts": [a.to_dict() for a in accounts],
}
_write_private(path, json.dumps(payload, ensure_ascii=False, indent=2))
def find(accounts: list[Account], name: str) -> Account | None:
return next((a for a in accounts if a.name == name), None)
def write_template(path: Path | None = None, force: bool = False) -> Path:
"""Écrit un accounts.json d'exemple, un compte désactivé par préréglage.
JSON n'a pas de commentaires : les explications passent par des clés
`_comment`, que `Account.from_dict` ignore.
"""
path = Path(path) if path else accounts_path()
if path.exists() and not force:
raise AccountError(f"{path} {t('mail_err_already_exists_relaunch')}")
examples = []
for key, preset in PRESETS.items():
acc = account_from_preset(
f"exemple-{key}", f"vous@exemple.ca", key
).to_dict()
acc["enabled"] = False
acc["_comment"] = t(preset["note_key"])
examples.append(acc)
payload = {
"version": SCHEMA_VERSION,
"_comment": (
"Modèle ERPLibre. Aucun mot de passe ici : `secret_ref` pointe"
" vers le coffre. Passez `enabled` à true une fois rempli."
" `cache_mode` à null hérite du réglage général."
),
"default_account": None,
"accounts": examples,
}
_prepare_parent(path)
_write_private(path, json.dumps(payload, ensure_ascii=False, indent=2))
return path

View file

@ -0,0 +1,28 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Décoder des octets dont le nom de charset vient du serveur, sans jamais lever.
Un charset annoncé par un en-tête (`Content-Type`, un mot encodé RFC 2047)
peut être n'importe quelle chaîne — y compris une étiquette que Python ne
reconnaît pas, comme `unknown-8bit`, que certains MTA posent sur un en-tête
8 bits mal formé. `bytes.decode(charset, errors="replace")` lève quand même
`LookupError` dans ce cas : la RECHERCHE du codec échoue AVANT que `errors`
ne soit consulté — `errors="replace"` ne protège donc de rien ici.
Cette fonction a été réinventée quatre fois dans ce paquet
(`imap_transport.decode_header_value`, `imap_sync.snippet_from_raw`,
`smtp_send._plain_text`, `tui_text._decode_part`) avant d'être extraite ici :
un charset non fiable ne doit jamais faire tomber l'affichage ou la
synchronisation d'un message entier.
"""
from __future__ import annotations
def decode_bytes(payload: bytes, charset: str | None) -> str:
"""`payload` décodé avec `charset`, replié sur UTF-8 si `charset` est
absent ou inconnu de Python."""
try:
return payload.decode(charset or "utf-8", "replace")
except LookupError:
return payload.decode("utf-8", "replace")

119
script/todo/mail/crypto.py Normal file
View file

@ -0,0 +1,119 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Scellement du cache courriel.
Une enveloppe AUTO-DESCRIPTIVE précède chaque donnée : le premier octet-paire
dit comment lire la suite. Conséquence voulue : une base écrite en clair reste
lisible après passage en mode chiffré, et l'inverse échoue bruyamment plutôt
que de rendre du charabia.
clair b"P0" + donnees
chiffre b"E1" + nonce(12) + AES-256-GCM(chiffre || tag)
"""
from __future__ import annotations
import os
from script.todo.todo_i18n import t
CLEAR_MAGIC = b"P0"
SEALED_MAGIC = b"E1"
NONCE_LEN = 12
KEY_LEN = 32
class CryptoError(Exception):
"""Clé absente, clé fausse, enveloppe inconnue ou donnée altérée."""
def new_key() -> bytes:
"""Une clé AES-256 tirée du générateur du système."""
return os.urandom(KEY_LEN)
class MailCrypto:
"""Interface commune. `open` sait toujours lire une enveloppe en clair."""
def seal(self, data: bytes) -> bytes:
raise NotImplementedError
def open(self, blob: bytes) -> bytes:
raise NotImplementedError
@staticmethod
def _split(blob: bytes) -> tuple[bytes, bytes]:
if not isinstance(blob, (bytes, bytearray)) or len(blob) < 2:
raise CryptoError(t("mail_err_envelope_too_short"))
return bytes(blob[:2]), bytes(blob[2:])
class NullCrypto(MailCrypto):
"""Mode `clear` : on marque, on ne chiffre pas."""
def seal(self, data: bytes) -> bytes:
return CLEAR_MAGIC + data
def open(self, blob: bytes) -> bytes:
magic, body = self._split(blob)
if magic == CLEAR_MAGIC:
return body
if magic == SEALED_MAGIC:
raise CryptoError(t("mail_err_sealed_in_clear_mode"))
raise CryptoError(f"{t('mail_err_unknown_envelope')} {magic!r}")
class AesGcmCrypto(MailCrypto):
"""Modes `encrypted` et `ephemeral` : AES-256-GCM, nonce neuf à chaque appel."""
def __init__(self, key: bytes) -> None:
if not isinstance(key, (bytes, bytearray)) or len(key) != KEY_LEN:
raise CryptoError(
f"{t('mail_err_key_wrong_length')} {KEY_LEN}"
f" {t('mail_err_octets_unit')}"
)
try:
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
except ImportError as exc: # pragma: no cover - dépendance absente
raise CryptoError(
t("mail_err_cryptography_not_installed")
) from exc
self._aes = AESGCM(bytes(key))
self._invalid_tag = InvalidTag
def seal(self, data: bytes) -> bytes:
nonce = os.urandom(NONCE_LEN)
return SEALED_MAGIC + nonce + self._aes.encrypt(nonce, data, None)
def open(self, blob: bytes) -> bytes:
magic, body = self._split(blob)
if magic == CLEAR_MAGIC:
return body
if magic != SEALED_MAGIC:
raise CryptoError(f"{t('mail_err_unknown_envelope')} {magic!r}")
nonce, payload = body[:NONCE_LEN], body[NONCE_LEN:]
try:
return self._aes.decrypt(nonce, payload, None)
except self._invalid_tag as exc:
raise CryptoError(t("mail_err_decrypt_refused")) from exc
except ValueError as exc:
raise CryptoError(
f"{t('mail_err_envelope_unreadable')} {exc}"
) from exc
# Toute autre exception remonte telle quelle : un bug de programmation
# ne doit JAMAIS se déguiser en « mauvaise clé ».
def build_crypto(mode: str, key: bytes | None) -> MailCrypto:
"""La boîte qui correspond au mode de cache d'un compte."""
if mode == "clear":
return NullCrypto()
if mode in ("encrypted", "ephemeral"):
if key is None:
raise CryptoError(
f"{t('mail_err_mode_prefix')} {mode}"
f" {t('mail_err_mode_requires_key')}"
)
return AesGcmCrypto(key)
raise CryptoError(f"{t('mail_err_unknown_cache_mode')} {mode}")

View file

@ -0,0 +1,256 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Le moteur de synchronisation, séparé de ce qui parle vraiment IMAP.
`Syncer` ne connaît qu'un PROTOCOLE (`ImapTransport`). C'est ce qui permet de
l'exercer entièrement contre un serveur en mémoire, sans réseau ni compte, et
c'est ce qui garde le décodage verbeux d'`imaplib` dans son propre fichier.
Une passe est incrémentale par construction : on demande les UID strictement
supérieurs au dernier connu. Le seul cas qui force une reprise à zéro est le
changement d'UIDVALIDITY — le serveur annonce alors que ses UID ne veulent
plus rien dire, et garder l'ancien cache produirait des messages faux.
Les corps ne descendent JAMAIS pendant une passe : une boîte de 20 000
messages doit se synchroniser en secondes, pas en gigaoctets.
"""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass, field
from typing import Protocol
from script.todo.mail.charset import decode_bytes
from script.todo.mail.store import MessageMeta
SNIPPET_LEN = 200
_logger = logging.getLogger(__name__)
@dataclass
class FolderInfo:
name: str
display: str = ""
role: str | None = None
# `\Noselect` / `\NonExistent` : un NIVEAU de la hiérarchie, pas une
# boîte. Gmail expose « [Gmail] » ainsi, simple parent de « [Gmail]/Sent
# Mail » et consorts. Le SELECTionner répond NO — c'est normal, et le
# serveur le dit d'avance dans les drapeaux de LIST.
selectable: bool = True
@dataclass
class SelectInfo:
uidvalidity: int
uidnext: int
exists: int
@dataclass
class HeaderInfo:
uid: int
date: int
size: int
flags: str
msgid: str
frm: str
to: str
subject: str
@dataclass
class SyncReport:
folders: int = 0
new_messages: int = 0
purged: list = field(default_factory=list)
errors: list = field(default_factory=list)
class ImapTransport(Protocol):
"""Ce dont le moteur a besoin. `imap_transport.ImaplibTransport` l'implémente."""
def list_folders(self) -> list[FolderInfo]: ...
def select(self, folder: str) -> SelectInfo: ...
def search_uids(self, since_uid: int) -> list[int]: ...
def fetch_headers(self, uids: list[int]) -> list[HeaderInfo]: ...
def fetch_flags(self, uids: list[int]) -> list[tuple[int, str]]: ...
def fetch_body(self, uid: int) -> bytes: ...
def store_flags(
self, uid: int, add: list[str], remove: list[str]
) -> None: ...
def append(self, folder: str, raw: bytes, flags: list[str]) -> None: ...
def logout(self) -> None: ...
def _chunks(items: list, size: int):
for start in range(0, len(items), size):
yield items[start : start + size]
def snippet_from_raw(raw: bytes, length: int = SNIPPET_LEN) -> str:
"""Les premiers mots du corps, pour la colonne d'aperçu de la liste."""
import email
try:
msg = email.message_from_bytes(raw)
except Exception:
return ""
part = msg
if msg.is_multipart():
part = next(
(p for p in msg.walk() if p.get_content_type() == "text/plain"),
None,
)
if part is None:
return ""
try:
payload = part.get_payload(decode=True) or b""
except Exception:
return ""
# `decode_bytes` (voir sa docstring) : un charset mal étiqueté ne doit
# pas faire tomber l'ouverture de la boîte.
text = decode_bytes(payload, part.get_content_charset())
return " ".join(text.split())[:length]
class Syncer:
"""Une passe de synchronisation, et le téléchargement d'un corps à la demande."""
BATCH = 200
FLAG_REFRESH = 500
def __init__(self, store, transport: ImapTransport) -> None:
self.store = store
self.transport = transport
def sync(self, progress=None) -> SyncReport:
report = SyncReport()
for folder in self.transport.list_folders():
try:
self._sync_folder(folder, report, progress)
except Exception as exc:
# Un dossier qui refuse ne doit pas priver l'utilisateur des autres.
_logger.exception("sync du dossier %r a échoué", folder.name)
report.errors.append(f"{folder.name} : {exc}")
report.folders += 1
return report
def sync_one(self, folder_name: str) -> SyncReport:
"""Une passe limitée à `folder_name`, par son nom seul.
Sert à `deliver()` (`tui.py`) juste après un APPEND réussi dans
Envoyés, pour que le message parti apparaisse sans attendre la
prochaine passe complète (design, ligne 308) — sans fabriquer de
ligne locale : c'est le serveur qui attribue l'UID, en inventer un
entrerait en collision avec un futur message réel. Ne connaissant
que le nom, on passe un `FolderInfo` sans `display`/`role` ; le
COALESCE de `store.upsert_folder` garde ceux déjà appris d'un LIST
complet.
Ne lève jamais, à l'image de `sync()` par dossier : cette sync est
un confort, pas une garantie — un envoi déjà réussi ne doit jamais
se lire comme un échec parce que cette relecture a raté.
"""
report = SyncReport()
try:
self._sync_folder(FolderInfo(name=folder_name), report, None)
except Exception as exc:
_logger.exception(
"sync ciblée du dossier %r a échoué", folder_name
)
report.errors.append(f"{folder_name} : {exc}")
report.folders = 1
return report
def _sync_folder(
self, folder: FolderInfo, report: SyncReport, progress
) -> None:
fid = self.store.upsert_folder(
folder.name, folder.display, folder.role
)
if not folder.selectable:
# Enregistré ci-dessus pour rester dans l'arbre — c'est un
# niveau de hiérarchie visible — mais on ne va pas plus loin :
# le SELECT répondrait NO, et cette erreur salissait CHAQUE
# synchronisation Gmail sans rien signaler d'anormal. Un
# journal qui crie sur du normal fait rater ce qui ne l'est pas.
return
info = self.transport.select(folder.name)
state = self.store.folder_state(folder.name) or {}
known_validity = state.get("uidvalidity")
if known_validity is not None and known_validity != info.uidvalidity:
self.store.purge_folder(folder.name)
report.purged.append(folder.name)
state = self.store.folder_state(folder.name) or {}
self.store.set_folder_state(
folder.name, uidvalidity=info.uidvalidity, uidnext=info.uidnext
)
last_uid = state.get("last_uid") or 0
uids = self.transport.search_uids(last_uid + 1)
done = 0
for batch in _chunks(uids, self.BATCH):
headers = self.transport.fetch_headers(batch)
self.store.upsert_messages(
fid,
[
MessageMeta(
uid=h.uid,
date=h.date,
size=h.size,
flags=h.flags,
msgid=h.msgid,
frm=h.frm,
to=h.to,
subject=h.subject,
snippet="",
)
for h in headers
],
)
report.new_messages += len(headers)
done += len(batch)
if progress:
progress(folder.name, done, len(uids))
if uids:
self.store.set_folder_state(folder.name, last_uid=max(uids))
# Les drapeaux des messages déjà connus changent sans que l'UID bouge :
# un « lu » ailleurs ne serait jamais vu sans cette relecture.
known = self.store.known_uids(fid, self.FLAG_REFRESH)
if known:
for uid, flags in self.transport.fetch_flags(known):
self.store.update_flags(fid, uid, flags)
self.store.set_folder_state(
folder.name,
total=info.exists,
unseen=self.store.count_unseen(fid),
synced_at=int(time.time()),
)
def fetch_body(self, folder_name: str, uid: int) -> bytes:
"""Le corps, du cache s'il y est, du serveur sinon."""
cached = self.store.read_body(folder_name, uid)
if cached is not None:
return cached
self.transport.select(folder_name)
raw = self.transport.fetch_body(uid)
self.store.write_body(folder_name, uid, raw)
state = self.store.folder_state(folder_name)
if state:
self.store.set_snippet(state["id"], uid, snippet_from_raw(raw))
return raw

View file

@ -0,0 +1,339 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""La seule couche qui parle vraiment IMAP.
Choix qui explique tout le reste du fichier : on ne décode PAS `ENVELOPE`.
`BODY.PEEK[HEADER.FIELDS (...)]` rend des en-têtes RFC822 bruts, que le module
`email` de la stdlib sait déjà lire — encodages, mots encodés, dates comprises.
Analyser `ENVELOPE` à la main coûterait cent lignes de plus, toutes fausses
sur un cas limite ou l'autre.
`BODY.PEEK` et non `BODY` : lire un message dans le TUI ne doit pas le marquer
lu sur le serveur à l'insu de l'utilisateur.
"""
from __future__ import annotations
import email
import email.utils
import re
from email.header import decode_header
from email.parser import BytesHeaderParser
from script.todo.mail.charset import decode_bytes
from script.todo.mail.imap_sync import FolderInfo, HeaderInfo, SelectInfo
from script.todo.todo_i18n import t
HEADER_FIELDS = "FROM TO SUBJECT DATE MESSAGE-ID"
SPECIAL_USE = {
"\\Sent": "sent",
"\\Drafts": "drafts",
"\\Trash": "trash",
"\\Junk": "junk",
"\\Archive": "archive",
"\\All": "archive",
}
_UID_RE = re.compile(rb"UID\s+(\d+)")
_SIZE_RE = re.compile(rb"RFC822\.SIZE\s+(\d+)")
_FLAGS_RE = re.compile(rb"FLAGS\s+\(([^)]*)\)")
_LIST_RE = re.compile(rb'^\(([^)]*)\)\s+("[^"]*"|NIL)\s+(.*)$')
class ImapError(Exception):
"""Le serveur a refusé, ou a répondu quelque chose d'inattendu."""
def decode_header_value(raw: str | None) -> str:
"""Un en-tête RFC 2047 rendu en texte lisible, sans jamais lever."""
if not raw:
return ""
try:
parts = decode_header(raw)
except Exception:
# `raw` vient du serveur : un en-tête mal formé ne doit jamais faire
# tomber l'affichage d'un message.
return str(raw)
out = []
for value, charset in parts:
if isinstance(value, bytes):
out.append(decode_bytes(value, charset))
else:
out.append(value)
return "".join(out).strip()
def decode_mailbox(name: str) -> str:
"""Nom de boîte en UTF-7 modifié (RFC 3501) rendu lisible.
Sur entrée invalide on rend le nom d'origine : un affichage imparfait vaut
mieux qu'un dossier qu'on n'arrive plus à sélectionner.
"""
if "&" not in name:
return name
try:
out = []
for chunk in name.split("&"):
if not out:
out.append(chunk)
continue
encoded, sep, rest = chunk.partition("-")
if not sep:
raise ValueError(t("mail_err_unterminated_ampersand"))
if encoded == "":
out.append("&" + rest)
else:
pad = "=" * (-len(encoded) % 4)
decoded = (encoded.replace(",", "/") + pad).encode("ascii")
import base64
out.append(
base64.b64decode(decoded).decode("utf-16-be") + rest
)
return "".join(out)
except Exception:
# Nom de boîte mal formé : on garde l'original plutôt que de perdre
# l'accès au dossier pour une simple erreur d'affichage.
return name
def parse_list_line(line: bytes) -> FolderInfo:
"""Une ligne de réponse LIST → nom, nom affichable, rôle."""
match = _LIST_RE.match(line.strip())
if not match:
raw_name = line.decode("utf-8", "replace").strip().strip('"')
return FolderInfo(name=raw_name, display=decode_mailbox(raw_name))
flags = match.group(1).decode("ascii", "replace").split()
name = match.group(3).decode("utf-8", "replace").strip().strip('"')
role = next((SPECIAL_USE[f] for f in flags if f in SPECIAL_USE), None)
if role is None and name.upper() == "INBOX":
role = "inbox"
bas = {f.lower() for f in flags}
return FolderInfo(
name=name,
display=decode_mailbox(name),
role=role,
selectable=not (bas & {"\\noselect", "\\nonexistent"}),
)
def parse_fetch_headers(data: list) -> list[HeaderInfo]:
"""Réponse FETCH d'en-têtes → une liste de `HeaderInfo`."""
parser = BytesHeaderParser()
out = []
for index, item in enumerate(data):
if not isinstance(item, tuple) or len(item) < 2:
continue
prefix, raw_headers = item[0], item[1]
# Les attributs peuvent SUIVRE le littéral au lieu de le précéder :
# RFC 3501 n'impose aucun ordre, et `imaplib` rend alors la fin de la
# ligne dans l'entrée suivante, hors du tuple. Ne lire que le préfixe
# ferait disparaître le message SANS erreur — et comme le moteur
# avance `last_uid` derrière, il ne serait jamais réessayé.
trailer = b""
if index + 1 < len(data) and isinstance(
data[index + 1], (bytes, bytearray)
):
trailer = bytes(data[index + 1])
meta = bytes(prefix) + b" " + trailer
uid_match = _UID_RE.search(meta)
if not uid_match:
continue
size_match = _SIZE_RE.search(meta)
flags_match = _FLAGS_RE.search(meta)
msg = parser.parsebytes(raw_headers)
date_raw = msg.get("Date")
try:
# `str()` d'abord : un `Date:` porteur d'octets 8 bits bruts —
# vu en boîte réelle — fait renvoyer un `Header` et non une
# chaîne, et `parsedate_to_datetime` y lève un AttributeError
# que ce `except` ne rattrapait pas. Une seule date illisible
# emportait alors la synchro du dossier ENTIER.
stamp = (
int(
email.utils.parsedate_to_datetime(
str(date_raw)
).timestamp()
)
if date_raw
else 0
)
except (TypeError, ValueError, AttributeError, OverflowError):
# Une date est une commodité d'affichage : aucune valeur
# d'en-tête ne justifie de perdre le message.
stamp = 0
out.append(
HeaderInfo(
uid=int(uid_match.group(1)),
date=stamp,
size=int(size_match.group(1)) if size_match else 0,
flags=(
flags_match.group(1).decode("ascii", "replace")
if flags_match
else ""
),
msgid=(msg.get("Message-ID") or "").strip(),
frm=decode_header_value(msg.get("From")),
to=decode_header_value(msg.get("To")),
subject=decode_header_value(msg.get("Subject")),
)
)
return out
class ImaplibTransport:
"""`ImapTransport` réalisé sur `imaplib`. Le client est injecté : les tests
passent un double, la production passe une connexion TLS."""
def __init__(self, client) -> None:
self.client = client
@staticmethod
def _ok(result, label: str):
status, data = result
if status != "OK":
raise ImapError(
f"{label} {t('mail_err_server_replied')} {status} ({data!r})"
)
return data
def list_folders(self) -> list[FolderInfo]:
data = self._ok(self.client.list(), "LIST")
return [parse_list_line(line) for line in data if line]
def select(self, folder: str) -> SelectInfo:
data = self._ok(self.client.select(f'"{folder}"'), f"SELECT {folder}")
exists = int(data[0]) if data and data[0] else 0
return SelectInfo(
uidvalidity=int(
self._first(self.client.response("UIDVALIDITY")) or 0
),
uidnext=int(self._first(self.client.response("UIDNEXT")) or 0),
exists=exists,
)
@staticmethod
def _first(response) -> bytes | None:
_, data = response
return data[0] if data and data[0] else None
def search_uids(self, since_uid: int) -> list[int]:
data = self._ok(
self.client.uid("SEARCH", None, f"UID {since_uid}:*"), "SEARCH"
)
raw = (data[0] or b"").split()
# `UID n:*` rend toujours au moins un UID, même inférieur à n quand la
# boîte est plus courte : on refiltre côté client.
return [int(u) for u in raw if int(u) >= since_uid]
def fetch_headers(self, uids: list[int]) -> list[HeaderInfo]:
if not uids:
return []
spec = f"(UID FLAGS RFC822.SIZE BODY.PEEK[HEADER.FIELDS ({HEADER_FIELDS})])"
data = self._ok(
self.client.uid("FETCH", ",".join(str(u) for u in uids), spec),
"FETCH HEADERS",
)
return parse_fetch_headers(data)
def fetch_flags(self, uids: list[int]) -> list[tuple[int, str]]:
if not uids:
return []
data = self._ok(
self.client.uid(
"FETCH", ",".join(str(u) for u in uids), "(UID FLAGS)"
),
"FETCH FLAGS",
)
out = []
for line in data:
raw = line[0] if isinstance(line, tuple) else line
if not isinstance(raw, (bytes, bytearray)):
continue
uid_match = _UID_RE.search(raw)
flags_match = _FLAGS_RE.search(raw)
if uid_match:
out.append(
(
int(uid_match.group(1)),
(
flags_match.group(1).decode("ascii", "replace")
if flags_match
else ""
),
)
)
return out
def fetch_body(self, uid: int) -> bytes:
data = self._ok(
self.client.uid("FETCH", str(uid), "(BODY.PEEK[])"), "FETCH BODY"
)
for item in data:
if isinstance(item, tuple) and len(item) >= 2:
return item[1]
raise ImapError(f"{t('mail_err_no_body_for_uid')} {uid}")
def store_flags(self, uid: int, add: list[str], remove: list[str]) -> None:
if add:
self._ok(
self.client.uid(
"STORE", str(uid), "+FLAGS", f"({' '.join(add)})"
),
"STORE +FLAGS",
)
if remove:
self._ok(
self.client.uid(
"STORE", str(uid), "-FLAGS", f"({' '.join(remove)})"
),
"STORE -FLAGS",
)
def append(self, folder: str, raw: bytes, flags: list[str]) -> None:
self._ok(
self.client.append(
f'"{folder}"', f"({' '.join(flags)})", None, raw
),
f"APPEND {folder}",
)
def logout(self) -> None:
"""Fermer proprement est souhaitable, pas indispensable : on n'échoue
jamais sur la sortie."""
try:
self.client.logout()
except Exception:
# Best-effort : la connexion peut déjà être fermée par le serveur.
pass
def connect(account, password: str) -> ImaplibTransport:
"""Ouvre une connexion TLS et se connecte. Lève `ImapError` sur refus."""
import imaplib
conf = account.imap
try:
if conf.security == "ssl":
client = imaplib.IMAP4_SSL(conf.host, conf.port, timeout=30)
else:
client = imaplib.IMAP4(conf.host, conf.port, timeout=30)
if conf.security == "starttls":
client.starttls()
client.login(conf.user, password)
except UnicodeEncodeError as exc:
# `imaplib` encode la commande LOGIN en ASCII : un mot de passe
# accentué n'atteint même pas le serveur. Ce cas sort AVANT le
# rattrapage général, dont le préfixe dit « refusée » — or personne
# n'a rien refusé, et l'ancien message « ordinal not in range(128) »
# accusait le serveur d'un refus qu'il n'a jamais prononcé.
raise ImapError(t("mail_err_password_not_ascii")) from exc
except Exception as exc:
# Toute panne réseau ou d'authentification devient une seule erreur
# de haut niveau, pour un message utile à l'utilisateur.
raise ImapError(
f"{t('mail_err_imap_connection_prefix')} {conf.host}"
f" {t('mail_err_connection_refused_suffix')} {exc}"
) from exc
return ImaplibTransport(client)

550
script/todo/mail/menu.py Normal file
View file

@ -0,0 +1,550 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Les entrées de menu du client courriel.
Ce module est le SEUL point de contact entre le paquet `mail` et le CLI :
`todo.py` importe `prompt_execute_mail` et rien d'autre. Le sens de la
dépendance est volontaire — `mail` ne doit jamais importer `todo`.
"""
from __future__ import annotations
import getpass
import logging
import os
import shutil
from pathlib import Path
import click
from script.todo import todo_prefs
from script.todo.mail import account_setup
from script.todo.mail import accounts as mail_accounts
from script.todo.mail.accounts import PRESETS, AccountError
from script.todo.mail.secrets import SecretError, SecretStore
from script.todo.mail.store import Store, StoreError, resolve_mode
from script.todo.todo_i18n import t
CACHE_MODES = ("clear", "encrypted", "ephemeral")
# Chemin proposé par défaut pour un kdbx nouvellement créé — voir
# `account_setup.py`, la seule source de vérité, aussi utilisée par le TUI.
_DEFAULT_KDBX_PATH = account_setup.DEFAULT_KDBX_PATH
# `True` une fois `_configure_mail_logging` passée : évite d'empiler un
# second `FileHandler` si l'utilisateur rouvre le menu courriel plusieurs
# fois dans le même processus `todo`.
_LOG_CONFIGURED = False
def mail_log_path() -> Path:
"""Le chemin du journal du paquet `mail` — SOURCE UNIQUE, pour que
`_configure_mail_logging` (ci-dessous) et `tui.LogScreen` (touche `l`,
qui affiche sa fin) ne puissent jamais en dériver deux formules
différentes."""
return Path(os.path.expanduser("~/.erplibre")) / "mail.log"
def _configure_mail_logging() -> None:
"""Branche le journal du paquet `mail` sur `mail_log_path()`.
Les modules du paquet (`imap_sync.py`, `tui.py`, ...) ne font
qu'appeler `_logger.exception(...)` : brancher un GESTIONNAIRE est le
travail de L'APPLICATION, pas d'une bibliothèque — c'est pourquoi cette
fonction vit ici, au seul point d'entrée du paquet (voir le docstring du
module), et jamais dans `mail/*.py`. Jamais vers la console non plus :
Textual possède le terminal pendant tout le TUI, et une ligne de log qui
s'y mêlerait corromprait l'affichage.
"""
global _LOG_CONFIGURED
if _LOG_CONFIGURED:
return
log_path = mail_log_path()
log_path.parent.mkdir(parents=True, exist_ok=True)
handler = logging.FileHandler(log_path)
handler.setFormatter(
logging.Formatter("%(asctime)s %(name)s %(levelname)s %(message)s")
)
# Sur le logger PARENT de tout le paquet (`script.todo.mail`, préfixe
# commun de `script.todo.mail.tui`, `script.todo.mail.imap_sync`, ...) :
# un seul gestionnaire couvre tous les modules, sans qu'aucun d'eux
# n'ait à en connaître l'existence.
logger = logging.getLogger("script.todo.mail")
logger.addHandler(handler)
logger.setLevel(logging.INFO)
# `todo.py` appelle `logging.basicConfig()` à l'IMPORT (ligne 68), ce qui
# pose un `StreamHandler` sur le logger RACINE. `propagate` vaut `True`
# par défaut : sans cette ligne, chaque `_logger.exception(...)` du
# paquet remonterait AUSSI jusqu'à ce gestionnaire — donc sur le
# terminal que Textual possède pendant tout le TUI, silencieusement.
# Constaté pour de vrai : la suite complète l'a fait fuir dans la sortie
# pointillée d'`unittest` dès qu'un fichier de test important déjà
# `script.todo.todo` tournait avant les tests courriel dans le même
# processus.
logger.propagate = False
_LOG_CONFIGURED = True
def secret_store_for(todo) -> SecretStore:
"""Le coffre du CLI : son kdbx s'il en a un, sinon le trousseau système."""
manager = getattr(todo, "kdbx_manager", None)
return SecretStore(kdbx_manager=manager, use_keyring=True)
def cache_summary(accounts, base=None, prefs_get=None) -> list[dict]:
"""Nom, mode effectif et taille sur disque, pour l'écran de cache."""
rows = []
for account in accounts:
mode = resolve_mode(account, prefs_get)
size = 0
try:
root = Store(account, mode=mode, base=base).root
if root.is_dir():
size = sum(
p.stat().st_size for p in root.rglob("*") if p.is_file()
)
except Exception:
# Un cache absent, un lien symbolique refusé ou un disque
# inaccessible ne doivent pas empêcher d'afficher les AUTRES
# comptes : la taille retombe simplement à zéro pour celui-ci.
size = 0
rows.append({"name": account.name, "mode": mode, "size": size})
return rows
def _load_accounts():
try:
return mail_accounts.load()
except AccountError as exc:
print(exc)
return []
def prompt_execute_mail(todo) -> None:
_configure_mail_logging()
while True:
help_info = f"""{todo._menu_header()}
[1] {t("mail_open_tui")}
[2] {t("mail_accounts_menu")}
[3] {t("mail_sync_now")}
[4] {t("mail_cache_menu")}
[0] {t("Back")}"""
status = click.prompt(help_info)
print()
if status == "0":
return
if status == "1":
_open_tui(todo)
elif status == "2":
prompt_mail_accounts(todo)
elif status == "3":
_sync_now(todo)
elif status == "4":
prompt_mail_cache(todo)
else:
print(t("Command not found !"))
def _open_tui(todo) -> None:
from script.todo.mail.tui import open_sessions, run_tui
accounts = _load_accounts()
secrets = secret_store_for(todo)
sessions = open_sessions(accounts, secrets)
try:
# Un TUI sans aucun compte n'est plus une impasse : `config_file` et
# `secrets` lui permettent d'en créer un depuis l'écran d'ajout.
run_tui(
sessions=sessions,
config_file=todo.config_file,
secret_store=secrets,
)
finally:
for session in sessions:
session.close()
def _sync_now(todo) -> None:
from script.todo.mail.tui import open_sessions
accounts = _load_accounts()
if not accounts:
print(t("mail_no_account"))
return
sessions = open_sessions(accounts, secret_store_for(todo))
try:
for session in sessions:
if not session.online:
print(f"{session.account.name} : {session.error}")
continue
report = session.sync()
print(
f"{session.account.name} : {report.new_messages}"
f" {t('mail_new_messages')}"
)
for error in report.errors:
print(f" {error}")
if report.purged:
print(
f" {t('mail_folders_resynced')}"
f" {', '.join(report.purged)}"
)
finally:
for session in sessions:
session.close()
def prompt_mail_accounts(todo) -> None:
while True:
help_info = f"""{todo._menu_header()}
[1] {t("mail_account_list")}
[2] {t("mail_account_add")}
[3] {t("mail_account_delete")}
[4] {t("mail_account_template")}
[5] {t("mail_account_test")}
[0] {t("Back")}"""
status = click.prompt(help_info)
print()
if status == "0":
return
if status == "1":
_list_accounts()
elif status == "2":
_add_account(todo)
elif status == "3":
_delete_account(todo)
elif status == "4":
_write_template()
elif status == "5":
_test_account(todo)
else:
print(t("Command not found !"))
def _list_accounts() -> None:
accounts = _load_accounts()
if not accounts:
print(t("mail_no_account"))
return
for account in accounts:
mark = "" if account.enabled else " (désactivé)"
print(
f" {account.name}{mark} — {account.email}"
f" — {account.imap.host} / {account.smtp.host}"
)
def _ensure_kdbx(todo) -> bool:
"""Vrai si un kdbx est utilisable pour la suite de `_add_account`.
Si `kdbx.path` est déjà configuré, ne pose aucune question — c'est le
cas courant après la première utilisation. Sinon, offre les deux choix
promis par la conception : créer un nouveau `.kdbx` ou en choisir un
existant.
"""
if todo.config_file.get_config_value(["kdbx", "path"]):
return True
print(t("mail_kdbx_none_configured"))
print(f" [1] {t('mail_kdbx_menu_create')}")
print(f" [2] {t('mail_kdbx_menu_choose')}")
print(f" [0] {t('mail_kdbx_menu_cancel')}")
choice = input(t("mail_kdbx_ask_choice")).strip()
if choice == "1":
return _create_kdbx_interactive(todo)
if choice == "2":
return _choose_kdbx_interactive(todo)
return False
def _create_kdbx_interactive(todo) -> bool:
prompt = f"{t('mail_kdbx_ask_path_new')} [{_DEFAULT_KDBX_PATH}]: "
path = input(prompt).strip() or _DEFAULT_KDBX_PATH
password = getpass.getpass(t("mail_kdbx_ask_password"))
confirm = getpass.getpass(t("mail_kdbx_ask_password_confirm"))
if password != confirm:
print(t("mail_kdbx_password_mismatch"))
return False
try:
account_setup.create_vault(todo.config_file, path, password)
except SecretError as exc:
print(exc)
return False
print(f"{t('mail_kdbx_created')} {path}")
return True
def _choose_kdbx_interactive(todo) -> bool:
path = input(t("mail_kdbx_ask_path_existing")).strip()
try:
account_setup.use_existing_vault(todo.config_file, path)
except SecretError:
print(f"{t('mail_kdbx_path_not_found')} {path}")
return False
print(f"{t('mail_kdbx_path_recorded')} {path}")
return True
def _add_account(todo) -> None:
if not _ensure_kdbx(todo):
return
store = secret_store_for(todo)
if not store.available_backends():
print(t("mail_no_vault"))
return
name = input(t("mail_ask_name")).strip()
email_addr = input(t("mail_ask_email")).strip()
display = input(t("mail_ask_display_name")).strip()
keys = list(PRESETS)
for index, key in enumerate(keys, start=1):
print(f" [{index}] {PRESETS[key]['label']}")
choice = input(t("mail_ask_preset")).strip()
try:
preset_key = keys[int(choice) - 1]
except (ValueError, IndexError):
preset_key = "generic"
vault = "kdbx" if "kdbx" in store.available_backends() else "keyring"
try:
account = mail_accounts.account_from_preset(
name, email_addr, preset_key, display_name=display, vault=vault
)
except AccountError as exc:
print(exc)
return
if preset_key == "generic":
account.imap.host = input(t("mail_ask_imap_host")).strip()
account.smtp.host = input(t("mail_ask_smtp_host")).strip()
attendu_app = bool(PRESETS[preset_key]["app_password"])
if attendu_app:
print(t("mail_app_password_note"))
print(f" {t(PRESETS[preset_key]['note_key'])}")
password = getpass.getpass(
t("mail_ask_app_password" if attendu_app else "mail_ask_password")
)
existing = [a for a in _load_accounts() if a.name != account.name]
try:
account_setup.save_new_account(
store, existing + [account], account, password
)
except (SecretError, AccountError, OSError) as exc:
# Une exception qui remonte ici tuerait le menu ; `save_new_account`
# a déjà annulé l'écriture du secret si la sauvegarde a échoué.
print(exc)
return
print(t("mail_account_saved"))
def _pick_account(prompt_key="mail_ask_account"):
accounts = _load_accounts()
if not accounts:
print(t("mail_no_account"))
return None, []
for index, account in enumerate(accounts, start=1):
print(f" [{index}] {account.name}")
choice = input(t(prompt_key)).strip()
try:
return accounts[int(choice) - 1], accounts
except (ValueError, IndexError):
return None, accounts
def _delete_account(todo) -> None:
account, accounts = _pick_account()
if account is None:
return
try:
secret_store_for(todo).delete(account.secret_ref)
except SecretError:
# Le secret peut avoir déjà disparu : ce n'est pas une raison de
# garder le compte dans la configuration.
pass
mail_accounts.save([a for a in accounts if a.name != account.name])
print(t("mail_account_deleted"))
def _write_template() -> None:
try:
path = mail_accounts.write_template()
except AccountError as exc:
print(exc)
return
print(f"{t('mail_template_written')} {path}")
def _looks_like_auth_failure(cause) -> bool:
"""Le serveur a-t-il RÉPONDU, ou n'est-il rien revenu ?
La question n'est pas « le message ressemble-t-il à un refus » : la
première version cherchait « invalid credentials » et compagnie, et a
manqué le cas le plus clair qui soit — Gmail répond
« [ALERT] Application-specific password required » une fois la double
authentification active, sans employer aucun de ces mots. Une liste de
libellés attendus est toujours en retard sur les serveurs réels.
On teste donc l'inverse, qui est structurel : `imaplib` lève
`IMAP4.error` quand le SERVEUR a parlé, et un `OSError` (délai,
coupure, DNS) quand rien n'est revenu. Seul ce second cas fait taire la
note. Une cause inconnue l'affiche : c'est un conseil, pas un verdict —
le donner à tort coûte une ligne, le taire à tort coûte la panne.
"""
if cause is None:
return True
origine = getattr(cause, "__cause__", None)
if isinstance(origine, OSError):
return False
# Sans exception d'origine (une chaîne, un test), on retombe sur les
# formulations qui disent explicitement que rien n'est revenu.
bas = str(cause).lower()
return not any(
muet in bas
for muet in ("timed out", "timeout", "unreachable", "not known")
)
def retry_password(
todo, account, attempts: int = 3, connect_fn=None, cause=None
) -> bool:
"""Redemande le mot de passe jusqu'à ce qu'il passe. Vrai si le coffre a
été mis à jour.
L'écriture n'a lieu QU'APRÈS une connexion réussie : remplacer un mot de
passe valide par une faute de frappe serait pire que l'échec initial.
"""
if connect_fn is None:
from script.todo.mail.imap_transport import connect as connect_fn
store = secret_store_for(todo)
# C'est ICI que la note sert, pas seulement à l'ajout du compte : on
# vient de se faire refuser et on redemande un mot de passe. Gmail,
# Outlook et iCloud répondent « Invalid credentials » au mot de passe
# habituel exactement comme à une faute de frappe — sans cette ligne,
# l'invite pousse à retaper le même, et à se le faire refuser autant de
# fois qu'on le redemande.
preset = PRESETS.get(account.preset, {})
attendu_app = bool(preset.get("app_password"))
if attendu_app and _looks_like_auth_failure(cause):
print(t("mail_app_password_note"))
print(f" {t(preset['note_key'])}")
# L'invite elle-même nomme ce qu'on attend. « Mot de passe : » invitait
# à saisir CELUI DU COMPTE, que ces fournisseurs refusent — la note
# au-dessus se lit une fois, l'invite se relit à chaque tentative.
invite = t("mail_ask_app_password" if attendu_app else "mail_ask_password")
for _ in range(attempts):
password = getpass.getpass(invite)
if not password:
return False
try:
transport = connect_fn(account, password)
except Exception as exc:
# `connect_fn` peut lever `ImapError` ou n'importe quelle erreur
# réseau brute (`OSError`, ...) : les deux méritent la même
# invite à ressaisir, pas un plantage du menu.
print(f"{t('mail_connection_failed')} {exc}")
continue
transport.logout()
store.set(account.secret_ref, password)
print(t("mail_connection_ok"))
return True
return False
def _test_account(todo) -> None:
from script.todo.mail.imap_transport import connect
account, _ = _pick_account()
if account is None:
return
# Le coffre peut refuser de s'ouvrir — mauvais mot de passe KeePass,
# fichier absent, coffre non configuré. C'est un renoncement de
# l'utilisateur ou une erreur qu'il peut corriger, pas de quoi faire
# tomber le CLI : on le dit et on revient au menu.
try:
password = secret_store_for(todo).get(account.secret_ref)
except SecretError as exc:
print(f"{t('mail_connection_failed')} {exc}")
return
if not password:
print(t("mail_no_password_stored"))
return
try:
transport = connect(account, password)
folders = transport.list_folders()
transport.logout()
except Exception as exc:
# Connexion refusée, mot de passe expiré, ou LIST qui échoue : dans
# tous les cas, offrir de resaisir le mot de passe plutôt que de
# faire tomber le menu.
print(f"{t('mail_connection_failed')} {exc}")
retry_password(todo, account, cause=exc)
return
print(f"{t('mail_connection_ok')} {len(folders)}")
def prompt_mail_cache(todo) -> None:
while True:
current = todo_prefs.get("mail_cache_mode", "clear")
help_info = f"""{todo._menu_header()}
[1] {t("mail_cache_default_mode")} ({current})
[2] {t("mail_cache_account_mode")}
[3] {t("mail_cache_size_purge")}
[0] {t("Back")}"""
status = click.prompt(help_info)
print()
if status == "0":
return
if status == "1":
mode = input(t("mail_ask_mode")).strip()
if mode in CACHE_MODES:
todo_prefs.set("mail_cache_mode", mode)
else:
print(t("Command not found !"))
elif status == "2":
account, accounts = _pick_account()
if account is None:
continue
mode = input(t("mail_ask_mode")).strip()
account.cache_mode = mode if mode in CACHE_MODES else None
mail_accounts.save(accounts)
elif status == "3":
_cache_size_and_purge(todo)
else:
print(t("Command not found !"))
def _cache_size_and_purge(todo) -> None:
accounts = _load_accounts()
if not accounts:
print(t("mail_no_account"))
return
for row in cache_summary(accounts):
print(f" {row['name']} — {row['mode']} — {row['size'] // 1024} ko")
account, _ = _pick_account()
if account is None:
return
if input(t("mail_purge_confirm")).strip().lower() not in ("o", "y"):
return
store = Store(account, secrets=secret_store_for(todo))
try:
store.open()
except StoreError as exc:
# Le cache est illisible : on ne peut pas le vider par SQL, mais c'est
# exactement le cas où l'utilisateur a besoin qu'il disparaisse.
print(exc)
shutil.rmtree(store.root, ignore_errors=True)
print(t("mail_purged"))
return
try:
store.purge_all()
finally:
store.close()
print(t("mail_purged"))

178
script/todo/mail/secrets.py Normal file
View file

@ -0,0 +1,178 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Où vivent les mots de passe courriel.
Deux coffres, dans cet ordre : le kdbx du dépôt (déjà utilisé par le CLI pour
OpenAI et les comptes Odoo), puis le trousseau du système.
Le trousseau système n'est accepté QUE si son backend chiffre vraiment. Sans
service de secrets — SSH, conteneur, poste sans session graphique — `keyring`
retombe sur `keyrings.alt`, qui écrit le mot de passe en clair dans un fichier.
L'accepter en silence serait un piège, donc on refuse et on le dit.
Référence de secret : "<coffre>:<chemin>"
kdbx:ERPLibre/Mail/perso -> groupe ERPLibre > Mail, entrée perso
kdbx:ERPLibre/Mail/perso/cache-key -> ... entrée cache-key
keyring:perso -> service "erplibre-mail", user perso
"""
from __future__ import annotations
import logging
from script.todo.todo_i18n import t
_logger = logging.getLogger(__name__)
KEYRING_SERVICE = "erplibre-mail"
# Backends dont on sait qu'ils chiffrent. Liste blanche volontaire : un
# backend inconnu est refusé, parce qu'on ne peut pas prouver qu'il chiffre.
SAFE_BACKENDS = {
("keyring.backends.SecretService", "Keyring"),
("keyring.backends.macOS", "Keyring"),
("keyring.backends.Windows", "WinVaultKeyring"),
("keyring.backends.kwallet", "DBusKeyring"),
}
class SecretError(Exception):
"""Aucun coffre utilisable, ou référence malformée."""
def keyring_backend_name() -> str:
"""Nom pleinement qualifié du backend keyring actif, "" s'il est absent."""
try:
import keyring
except ImportError:
return ""
backend = keyring.get_keyring()
cls = type(backend)
return f"{cls.__module__}.{cls.__qualname__}"
def keyring_is_safe() -> bool:
"""Vrai seulement si le backend actif chiffre pour de bon."""
try:
import keyring
except ImportError:
return False
cls = type(keyring.get_keyring())
return (cls.__module__, cls.__qualname__) in SAFE_BACKENDS
def create_kdbx(path: str, password: str) -> None:
"""Crée une base KeePass vide. Refuse d'écraser un fichier existant."""
import os
from pykeepass import create_database
if os.path.exists(path):
raise SecretError(f"{t('mail_err_file_already_exists')} {path}")
parent = os.path.dirname(os.path.abspath(path))
os.makedirs(parent, exist_ok=True)
os.chmod(parent, 0o700)
# `create_database` écrit d'abord un fichier `.tmp` — `construct` l'ouvre
# avec `open(filename, "w+b")`, donc à l'umask du process — avant un
# `shutil.move` vers `path` (pykeepass évite ainsi de corrompre une base
# existante en cas d'échec). Un `os.open` sur `path` ne fermerait donc
# PAS la fenêtre : c'est ce fichier intermédiaire, invisible d'ici, qui
# porterait le coffre en clair. Resserrer l'umask le temps de l'appel
# couvre les deux fichiers.
previous_umask = os.umask(0o077)
try:
create_database(path, password=password)
finally:
os.umask(previous_umask)
os.chmod(path, 0o600)
class SecretStore:
"""Lecture et écriture de secrets, par référence."""
def __init__(self, kdbx_manager=None, use_keyring: bool = True) -> None:
self._kdbx_manager = kdbx_manager
self._use_keyring = use_keyring
# -- API publique ---------------------------------------------------
def available_backends(self) -> list[str]:
found = []
if self._kdbx_manager is not None:
found.append("kdbx")
if self._use_keyring and keyring_is_safe():
found.append("keyring")
return found
def get(self, ref: str) -> str | None:
scheme, path = self._parse(ref)
if scheme == "kdbx":
entry = self._kdbx_entry(path, create=False)
return entry.password if entry else None
return self._keyring_call("get_password", path)
def set(self, ref: str, secret: str) -> None:
scheme, path = self._parse(ref)
if scheme == "kdbx":
entry = self._kdbx_entry(path, create=True)
entry.password = secret
self._kdbx().save()
return
self._keyring_call("set_password", path, secret)
def delete(self, ref: str) -> None:
scheme, path = self._parse(ref)
if scheme == "kdbx":
entry = self._kdbx_entry(path, create=False)
if entry:
self._kdbx().delete_entry(entry)
self._kdbx().save()
return
self._keyring_call("delete_password", path)
# -- Détail ---------------------------------------------------------
@staticmethod
def _parse(ref: str) -> tuple[str, str]:
scheme, sep, path = (ref or "").partition(":")
if not sep or scheme not in ("kdbx", "keyring") or not path:
raise SecretError(f"{t('mail_err_invalid_secret_ref')} {ref!r}")
return scheme, path
def _kdbx(self):
if self._kdbx_manager is None:
raise SecretError(t("mail_err_no_kdbx_configured"))
kp = self._kdbx_manager.get_kdbx()
if kp is None:
raise SecretError(t("mail_err_kdbx_unreadable"))
return kp
def _kdbx_entry(self, path: str, create: bool):
"""`path` = "Groupe/SousGroupe/Titre". Crée les groupes au besoin."""
kp = self._kdbx()
*group_names, title = path.split("/")
group = kp.root_group
for name in group_names:
found = next((g for g in group.subgroups if g.name == name), None)
if found is None:
if not create:
return None
found = kp.add_group(group, name)
group = found
entry = next((e for e in group.entries if e.title == title), None)
if entry is None and create:
entry = kp.add_entry(group, title, "", "")
return entry
def _keyring_call(self, func_name: str, *args):
if not self._use_keyring:
raise SecretError(t("mail_err_no_vault_available"))
if not keyring_is_safe():
raise SecretError(
f"{t('mail_err_keyring_plaintext')}"
f" {keyring_backend_name() or 'absent'})."
f" {t('mail_err_keyring_plaintext_hint')}"
)
import keyring
return getattr(keyring, func_name)(KEYRING_SERVICE, *args)

View file

@ -0,0 +1,293 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Construire un message et le remettre à un serveur SMTP.
`EmailMessage` fait le gros du travail : encodage des en-têtes accentués,
choix du transfert, structure multipart. On se contente de décider QUOI mettre
dedans — et surtout de ne pas mettre le Cci dans les en-têtes, où il cesserait
d'être caché tout en restant destinataire d'enveloppe.
`date` et `msgid` sont injectables pour que les tests soient déterministes ;
en production on laisse la stdlib les produire.
"""
from __future__ import annotations
import mimetypes
from email.message import EmailMessage
from email.utils import formatdate, getaddresses, make_msgid
from pathlib import Path
from typing import Protocol
from script.todo.mail.charset import decode_bytes
from script.todo.todo_i18n import t
MAX_QUOTE_LINES = 200
class SmtpError(Exception):
"""Message impossible à construire, ou serveur qui refuse."""
def _as_list(value) -> list[str]:
if not value:
return []
if isinstance(value, str):
return [value]
return list(value)
def _addresses(*header_values) -> list[str]:
pairs = getaddresses([v for v in header_values if v])
return [addr for _, addr in pairs if addr]
def build_message(
account,
to,
subject: str,
body: str,
*,
cc=None,
bcc=None,
attachments=None,
in_reply_to: str | None = None,
references: str | None = None,
date: str | None = None,
msgid: str | None = None,
) -> EmailMessage:
to_list = _as_list(to)
cc_list = _as_list(cc)
bcc_list = _as_list(bcc)
if not (to_list or cc_list or bcc_list):
raise SmtpError(t("mail_err_message_needs_recipient"))
msg = EmailMessage()
msg["From"] = account.from_header()
if to_list:
msg["To"] = ", ".join(to_list)
if cc_list:
msg["Cc"] = ", ".join(cc_list)
msg["Subject"] = subject
msg["Date"] = date or formatdate(localtime=True)
msg["Message-ID"] = msgid or make_msgid(
domain=account.email.split("@")[-1]
)
if in_reply_to:
msg["In-Reply-To"] = in_reply_to
if references:
msg["References"] = references
msg.set_content(body)
# Le Cci ne va PAS dans les en-têtes : il ne vit que dans l'enveloppe
# SMTP, que `recipients()` reconstitue.
if bcc_list:
msg["X-ERPLibre-Bcc"] = ", ".join(bcc_list)
for path in attachments or []:
_attach_file(msg, Path(path))
return msg
def _attach_file(msg: EmailMessage, path: Path) -> None:
if not path.is_file():
raise SmtpError(f"{t('mail_err_attachment_missing')} {path}")
guessed, _ = mimetypes.guess_type(path.name)
maintype, _, subtype = (guessed or "application/octet-stream").partition(
"/"
)
msg.add_attachment(
path.read_bytes(),
maintype=maintype,
subtype=subtype or "octet-stream",
filename=path.name,
)
def _plain_text(message) -> str:
"""Le texte d'un message, quel que soit son emballage."""
if isinstance(message, EmailMessage):
part = message.get_body(("plain",))
if part is not None:
return part.get_content()
if message.is_multipart():
for part in message.walk():
if part.get_content_type() == "text/plain":
payload = part.get_payload(decode=True) or b""
# `decode_bytes` (voir sa docstring) : un charset mal
# étiqueté ne doit pas faire tomber la réponse ou le
# transfert.
return decode_bytes(payload, part.get_content_charset())
return ""
payload = message.get_payload(decode=True)
if payload is None:
return str(message.get_payload())
return decode_bytes(payload, message.get_content_charset())
def _quote(text: str) -> str:
lines = text.splitlines()[:MAX_QUOTE_LINES]
return "\n".join(f"> {line}" for line in lines)
def build_reply(
account,
message,
body: str,
*,
reply_all: bool = False,
date: str | None = None,
msgid: str | None = None,
) -> EmailMessage:
subject = message.get("Subject", "")
if not subject.lower().startswith("re:"):
subject = f"Re: {subject}"
to = [message.get("Reply-To") or message.get("From", "")]
cc = []
if reply_all:
mine = account.email.lower()
others = [
addr
for addr in _addresses(message.get("To"), message.get("Cc"))
if addr.lower() != mine
]
already = {a.lower() for a in _addresses(*to)}
cc = [a for a in others if a.lower() not in already]
parent_id = (message.get("Message-ID") or "").strip()
references = " ".join(
part
for part in [(message.get("References") or "").strip(), parent_id]
if part
)
return build_message(
account,
to,
subject,
f"{body}\n\n{_quote(_plain_text(message))}\n",
cc=cc,
in_reply_to=parent_id or None,
references=references or None,
date=date,
msgid=msgid,
)
def build_forward(
account,
message,
to,
body: str,
*,
date: str | None = None,
msgid: str | None = None,
) -> EmailMessage:
subject = message.get("Subject", "")
if not subject.lower().startswith("fwd:"):
subject = f"Fwd: {subject}"
msg = build_message(account, to, subject, body, date=date, msgid=msgid)
forwarded = message
if not isinstance(forwarded, EmailMessage):
import email
forwarded = email.message_from_bytes(
message.as_bytes(), _class=EmailMessage
)
# `add_attachment` dispatche vers `set_message_content` pour un `Message` :
# ce gestionnaire n'accepte pas `maintype` (toujours "message" pour lui),
# seulement `subtype` — le passer lève `TypeError` à chaque appel.
msg.add_attachment(forwarded, subtype="rfc822")
return msg
def recipients(msg) -> list[str]:
"""Les destinataires d'enveloppe : To, Cc et le Cci gardé à part."""
seen, out = set(), []
for addr in _addresses(
msg.get("To"), msg.get("Cc"), msg.get("X-ERPLibre-Bcc")
):
low = addr.lower()
if low not in seen:
seen.add(low)
out.append(addr)
return out
class SmtpTransport(Protocol):
def send_message(
self, msg, from_addr: str, to_addrs: list[str]
) -> None: ...
def quit(self) -> None: ...
class SmtplibTransport:
def __init__(self, client) -> None:
self.client = client
def send_message(self, msg, from_addr: str, to_addrs: list[str]) -> None:
self.client.send_message(msg, from_addr=from_addr, to_addrs=to_addrs)
def quit(self) -> None:
try:
self.client.quit()
except Exception:
# Best-effort : la connexion peut déjà être fermée par le serveur.
pass
def connect(account, password: str) -> SmtplibTransport:
import smtplib
conf = account.smtp
try:
if conf.security == "ssl":
client = smtplib.SMTP_SSL(conf.host, conf.port, timeout=30)
else:
client = smtplib.SMTP(conf.host, conf.port, timeout=30)
if conf.security == "starttls":
client.starttls()
client.login(conf.user, password)
except Exception as exc:
# Toute panne réseau ou d'authentification devient une seule erreur
# de haut niveau, pour un message utile à l'utilisateur.
raise SmtpError(
f"{t('mail_err_smtp_connection_prefix')} {conf.host}"
f" {t('mail_err_connection_refused_suffix')} {exc}"
) from exc
return SmtplibTransport(client)
def send(account, msg, transport: SmtpTransport) -> list[str]:
"""Remet le message. Rend les destinataires servis, lève `SmtpError` sinon."""
to_addrs = recipients(msg)
if not to_addrs:
raise SmtpError(t("mail_err_no_recipient_nothing_sent"))
outgoing = without_bcc(msg)
try:
transport.send_message(outgoing, account.email, to_addrs)
except Exception as exc:
# Le serveur peut refuser pour mille raisons (auth, quota,
# destinataire rejeté) : une seule erreur de haut niveau, avec le
# texte du serveur conservé pour l'utilisateur.
raise SmtpError(f"{t('mail_err_send_refused')} {exc}") from exc
return to_addrs
def without_bcc(msg):
"""Une copie sans le porte-Cci interne.
PUBLIQUE à dessein : `send()` n'est pas le seul chemin par lequel le
message quitte la machine. La copie déposée dans le dossier Envoyés part
par IMAP, et si elle gardait `X-ERPLibre-Bcc` le Cci serait lisible sur le
serveur — la même fuite, par une autre porte.
"""
if msg.get("X-ERPLibre-Bcc") is None:
return msg
import copy
clone = copy.deepcopy(msg)
del clone["X-ERPLibre-Bcc"]
return clone

624
script/todo/mail/store.py Normal file
View file

@ -0,0 +1,624 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Le cache courriel local : une base SQLite et des fichiers .eml par compte.
Une racine par compte, jamais une base partagée : c'est ce qui permet à un
compte d'être éphémère pendant qu'un autre persiste, sans mélanger deux modes
de chiffrement dans les mêmes lignes.
Ce qui reste EN CLAIR dans la base — uid, dossier, date, drapeaux, taille —
est exactement ce dont le SQL a besoin pour trier et filtrer. Ce qui identifie
des personnes — expéditeur, destinataires, sujet, extrait, Message-ID — est
scellé. Le Message-ID a en plus un haché salé par la clé, pour qu'on puisse
recoller les fils de discussion sans le lire.
"""
from __future__ import annotations
import base64
import functools
import hashlib
import os
import shutil
import sqlite3
import stat
import threading
import urllib.parse
from dataclasses import dataclass
from pathlib import Path
from script.todo.mail.crypto import build_crypto, new_key
from script.todo.todo_i18n import t
SCHEMA_VERSION = 1
EPHEMERAL_PREFIX = "erplibre-mail-"
VALID_MODES = ("clear", "encrypted", "ephemeral")
SCHEMA = """
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT
);
CREATE TABLE IF NOT EXISTS folders (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
display TEXT,
role TEXT,
uidvalidity INTEGER,
uidnext INTEGER,
last_uid INTEGER NOT NULL DEFAULT 0,
total INTEGER NOT NULL DEFAULT 0,
unseen INTEGER NOT NULL DEFAULT 0,
synced_at INTEGER
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY,
folder_id INTEGER NOT NULL REFERENCES folders(id) ON DELETE CASCADE,
uid INTEGER NOT NULL,
date INTEGER,
size INTEGER,
flags TEXT,
has_body INTEGER NOT NULL DEFAULT 0,
msgid_hash TEXT,
sealed_msgid BLOB,
sealed_from BLOB,
sealed_to BLOB,
sealed_subject BLOB,
sealed_snippet BLOB,
UNIQUE(folder_id, uid)
);
CREATE INDEX IF NOT EXISTS idx_msg_date ON messages(folder_id, date DESC);
"""
class StoreError(Exception):
"""Cache inutilisable : clé manquante, base corrompue, disque refusé."""
def _locked(method):
"""Sérialise l'accès à la connexion SQLite.
`check_same_thread=False` lève l'interdiction de la stdlib, mais ne rend
pas la connexion sûre pour autant : c'est CE verrou qui la rend sûre. Le
TUI synchronise dans un thread de travail pendant que l'écran lit le cache
depuis le thread principal — les deux se croisent vraiment, ce n'est pas
une précaution théorique.
"""
@functools.wraps(method)
def wrapper(self, *args, **kwargs):
with self._lock:
return method(self, *args, **kwargs)
return wrapper
@dataclass
class MessageMeta:
uid: int
date: int
size: int
flags: str
msgid: str
frm: str
to: str
subject: str
snippet: str
has_body: bool = False
def resolve_mode(account, prefs_get=None) -> str:
"""Le mode du compte, sinon le défaut général, sinon `clear`.
Un défaut général illisible ne doit pas empêcher d'ouvrir le cache :
on retombe sur le mode le plus permissif, jamais sur une erreur.
"""
if account.cache_mode in VALID_MODES:
return account.cache_mode
if prefs_get is None:
from script.todo import todo_prefs
prefs_get = todo_prefs.get
general = prefs_get("mail_cache_mode", "clear")
return general if general in VALID_MODES else "clear"
def default_base() -> Path:
return Path(os.path.expanduser("~/.erplibre/mail"))
def ephemeral_base() -> Path:
"""/dev/shm quand il est inscriptible, sinon le dossier temporaire."""
shm = Path("/dev/shm")
if shm.is_dir() and os.access(shm, os.W_OK):
return shm
import tempfile
return Path(tempfile.gettempdir())
def cache_root(account, mode: str, base: Path | None = None) -> Path:
if mode == "ephemeral":
base = Path(base) if base else ephemeral_base()
return base / f"{EPHEMERAL_PREFIX}{os.getpid()}" / account.name
base = Path(base) if base else default_base()
return base / account.name
# Noms qui, seuls, désigneraient autre chose que le dossier voulu.
DEGENERATE_DIRNAMES = {"": "_", ".": "%2E", "..": "%2E%2E"}
def folder_dirname(imap_name: str) -> str:
"""Un nom de dossier IMAP transformé en nom de dossier de fichiers.
`quote` avec `safe=""` échappe tous les séparateurs, donc le résultat est
toujours UN seul composant de chemin : « A/B » ne peut pas créer deux
niveaux.
Mais `quote` n'encode JAMAIS le point — la stdlib garde toujours
« _.-~ » — et c'est voulu : beaucoup de serveurs IMAP séparent leur
hiérarchie par des points, et « INBOX.Sent » doit rester lisible sur le
disque. Le prix à payer est que « . » et « .. » traverseraient tels
quels, puisque `racine / ".."` remonte d'un cran. Ces trois cas
dégénérés sont donc les seuls réécrits.
"""
quoted = urllib.parse.quote(imap_name, safe="")
return DEGENERATE_DIRNAMES.get(quoted, quoted)
def _assert_private_dir(path: Path) -> None:
"""Refuse un dossier qu'on ne possède pas, ou qui est un lien symbolique."""
info = os.lstat(path)
if stat.S_ISLNK(info.st_mode):
raise StoreError(f"{path} {t('mail_err_symlink_refused')}")
if info.st_uid != os.getuid():
raise StoreError(f"{path} {t('mail_err_owned_by_other_user')}")
def sweep_orphan_ephemeral(base: Path | None = None) -> int:
"""Efface les caches éphémères dont le processus n'existe plus.
`atexit` et les gestionnaires de signaux couvrent les sorties normales ;
un SIGKILL, lui, laisse un résidu. Ce balayage au démarrage est le filet.
"""
base = Path(base) if base else ephemeral_base()
removed = 0
if not base.is_dir():
return 0
for path in base.glob(f"{EPHEMERAL_PREFIX}*"):
if not path.is_dir():
continue
raw_pid = path.name[len(EPHEMERAL_PREFIX) :]
if not raw_pid.isdigit():
continue
pid = int(raw_pid)
try:
os.kill(pid, 0)
except ProcessLookupError:
shutil.rmtree(path, ignore_errors=True)
removed += 1
except PermissionError:
# Le PID existe et appartient à quelqu'un d'autre : on n'y touche pas.
continue
return removed
class Store:
"""Le cache d'UN compte. À ouvrir, à fermer, éventuellement à effacer."""
def __init__(
self,
account,
*,
mode: str | None = None,
key: bytes | None = None,
secrets=None,
base: Path | None = None,
) -> None:
self.account = account
self.mode = mode or resolve_mode(account)
self.root = cache_root(account, self.mode, base)
self._key = key
self._secrets = secrets
self._conn: sqlite3.Connection | None = None
self._crypto = None
self._lock = threading.RLock()
# -- Cycle de vie ---------------------------------------------------
@_locked
def open(self) -> None:
if self._conn is not None:
return
self._crypto = build_crypto(self.mode, self._resolve_key())
self._prepare_root()
db_path = self.root / "cache.db"
conn = None
try:
# `check_same_thread=False` parce que le TUI synchronise dans un
# thread de travail : sans ça, la première passe lèverait
# ProgrammingError. La sûreté vient du verrou, pas de ce drapeau.
conn = sqlite3.connect(db_path, check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
conn.executescript(SCHEMA)
conn.execute(
"INSERT OR IGNORE INTO meta(key, value) VALUES('schema_version', ?)",
(str(SCHEMA_VERSION),),
)
conn.commit()
except sqlite3.DatabaseError as exc:
if conn is not None:
conn.close()
raise StoreError(
f"{t('mail_err_cache_unreadable')} {db_path} ({exc})"
) from exc
# Publié SEULEMENT une fois le schéma en place. `sqlite3.connect` est
# paresseux : une base corrompue n'échoue qu'à `executescript`, donc
# affecter `self._conn` plus tôt laisserait un open() raté derrière lui
# un handle sans schéma — et le open() suivant, voyant `_conn` non nul,
# réussirait en silence sur une base inutilisable.
self._conn = conn
if db_path.exists():
os.chmod(db_path, 0o600)
@_locked
def close(self) -> None:
if self._conn is None:
return
try:
self._conn.commit()
except sqlite3.Error:
# Fermer prime sur sauver : un commit refusé ne doit pas laisser la
# connexion ouverte pour toujours.
pass
finally:
self._conn.close()
self._conn = None
def cleanup(self) -> None:
"""Efface la racine du compte. Appelé à la sortie en mode éphémère.
On n'efface QUE le dossier du compte : le dossier par PID est partagé
avec les autres comptes éphémères du même processus, et l'effacer
détruirait leurs caches vivants. Il ne part que s'il est vide.
"""
self.close()
if self.mode != "ephemeral":
return
shutil.rmtree(self.root, ignore_errors=True)
parent = self.root.parent
if parent.name.startswith(EPHEMERAL_PREFIX):
try:
parent.rmdir()
except OSError:
# Un autre compte éphémère l'occupe encore : c'est normal.
pass
def __enter__(self) -> "Store":
self.open()
return self
def __exit__(self, *exc) -> None:
self.close()
def _prepare_root(self) -> None:
"""Crée la racine, en 0700 à chaque niveau qui nous appartient.
`mkdir(parents=True)` crée les dossiers intermédiaires SANS appliquer
le mode — c'est documenté dans la stdlib. En éphémère la racine vit
sous `/dev/shm`, qui est en 1777 et partagé avec tous les utilisateurs
locaux : un dossier par PID laissé à l'umask y rendrait les noms de
comptes lisibles par n'importe qui, et un dossier pré-créé par un tiers
à un chemin devinable lui permettrait de glisser un lien symbolique
sous `write_body`.
"""
parent = self.root.parent
if self.mode == "ephemeral":
parent.parent.mkdir(parents=True, exist_ok=True)
parent.mkdir(mode=0o700, exist_ok=True)
_assert_private_dir(parent)
else:
parent.mkdir(parents=True, exist_ok=True)
os.chmod(parent, 0o700)
self.root.mkdir(parents=True, exist_ok=True)
os.chmod(self.root, 0o700)
def _resolve_key(self) -> bytes | None:
if self.mode == "clear":
return None
if self._key is not None:
return self._key
if self.mode == "ephemeral":
# Tirée ici, gardée en RAM, jamais écrite : c'est tout l'intérêt.
self._key = new_key()
return self._key
if self._secrets is None:
raise StoreError(
f"{t('mail_err_mode_prefix')} {self.mode}"
f" {t('mail_err_mode_requires_key_no_vault')}"
)
ref = self.account.cache_key_ref()
stored = self._secrets.get(ref)
if stored is None:
self._key = new_key()
self._secrets.set(ref, base64.b64encode(self._key).decode())
else:
self._key = base64.b64decode(stored)
return self._key
# -- Scellement -----------------------------------------------------
def _seal(self, text: str) -> bytes:
return self._crypto.seal((text or "").encode("utf-8"))
def _open(self, blob) -> str:
if blob is None:
return ""
return self._crypto.open(bytes(blob)).decode("utf-8", "replace")
def _msgid_hash(self, msgid: str) -> str:
salt = self._key or b"clear"
return hashlib.sha256(salt + (msgid or "").encode("utf-8")).hexdigest()
def _db(self) -> sqlite3.Connection:
if self._conn is None:
raise StoreError(t("mail_err_cache_not_open"))
return self._conn
# -- Dossiers -------------------------------------------------------
@_locked
def upsert_folder(
self,
name: str,
display: str = "",
role: str | None = None,
uidvalidity: int | None = None,
uidnext: int | None = None,
) -> int:
db = self._db()
db.execute(
"INSERT INTO folders(name, display, role, uidvalidity, uidnext)"
" VALUES(?,?,?,?,?)"
" ON CONFLICT(name) DO UPDATE SET"
" display = COALESCE(excluded.display, folders.display),"
" role = COALESCE(excluded.role, folders.role),"
" uidvalidity = COALESCE(excluded.uidvalidity, folders.uidvalidity),"
" uidnext = COALESCE(excluded.uidnext, folders.uidnext)",
(name, display or None, role, uidvalidity, uidnext),
)
db.commit()
# `display` vaut NULL tant qu'aucun nom affichable n'est connu : c'est
# ce qui rend le COALESCE vivant, donc ce qui permet à une resync qui
# ne repasse que le nom IMAP de NE PAS écraser un libellé déjà décodé.
# Les lecteurs retombent sur `name` (voir mailbox_refs, tâche 9).
return db.execute(
"SELECT id FROM folders WHERE name = ?", (name,)
).fetchone()[0]
@_locked
def folders(self) -> list[dict]:
return [
dict(r)
for r in self._db().execute("SELECT * FROM folders ORDER BY name")
]
@_locked
def folder_state(self, name: str) -> dict | None:
row = (
self._db()
.execute("SELECT * FROM folders WHERE name = ?", (name,))
.fetchone()
)
return dict(row) if row else None
@_locked
def set_folder_state(self, name: str, **fields) -> None:
allowed = {
"last_uid",
"total",
"unseen",
"uidvalidity",
"uidnext",
"synced_at",
"role",
"display",
}
unknown = set(fields) - allowed
if unknown:
raise StoreError(
f"{t('mail_err_unknown_folder_fields')} {sorted(unknown)}"
)
if not fields:
return
sets = ", ".join(f"{k} = ?" for k in fields)
db = self._db()
db.execute(
f"UPDATE folders SET {sets} WHERE name = ?",
(*fields.values(), name),
)
db.commit()
@_locked
def purge_folder(self, name: str) -> None:
db = self._db()
row = db.execute(
"SELECT id FROM folders WHERE name = ?", (name,)
).fetchone()
if row:
db.execute("DELETE FROM messages WHERE folder_id = ?", (row[0],))
db.execute(
"UPDATE folders SET last_uid = 0, total = 0, unseen = 0"
" WHERE id = ?",
(row[0],),
)
db.commit()
shutil.rmtree(self.root / folder_dirname(name), ignore_errors=True)
# -- Messages -------------------------------------------------------
@_locked
def upsert_messages(self, folder_id: int, metas: list[MessageMeta]) -> int:
db = self._db()
rows = [
(
folder_id,
m.uid,
m.date,
m.size,
m.flags,
self._msgid_hash(m.msgid),
self._seal(m.msgid),
self._seal(m.frm),
self._seal(m.to),
self._seal(m.subject),
self._seal(m.snippet),
)
for m in metas
]
db.executemany(
"INSERT INTO messages(folder_id, uid, date, size, flags,"
" msgid_hash, sealed_msgid, sealed_from, sealed_to,"
" sealed_subject, sealed_snippet)"
" VALUES(?,?,?,?,?,?,?,?,?,?,?)"
" ON CONFLICT(folder_id, uid) DO UPDATE SET"
" date = excluded.date, size = excluded.size,"
" flags = excluded.flags, msgid_hash = excluded.msgid_hash,"
" sealed_msgid = excluded.sealed_msgid,"
" sealed_from = excluded.sealed_from,"
" sealed_to = excluded.sealed_to,"
" sealed_subject = excluded.sealed_subject,"
" sealed_snippet = excluded.sealed_snippet",
rows,
)
db.commit()
return len(rows)
@_locked
def update_flags(self, folder_id: int, uid: int, flags: str) -> None:
db = self._db()
db.execute(
"UPDATE messages SET flags = ? WHERE folder_id = ? AND uid = ?",
(flags, folder_id, uid),
)
db.commit()
def _row_to_meta(self, row) -> MessageMeta:
return MessageMeta(
uid=row["uid"],
date=row["date"],
size=row["size"],
flags=row["flags"] or "",
msgid=self._open(row["sealed_msgid"]),
frm=self._open(row["sealed_from"]),
to=self._open(row["sealed_to"]),
subject=self._open(row["sealed_subject"]),
snippet=self._open(row["sealed_snippet"]),
has_body=bool(row["has_body"]),
)
@_locked
def list_messages(
self, folder_id: int, limit: int = 500, offset: int = 0
) -> list[MessageMeta]:
rows = (
self._db()
.execute(
"SELECT * FROM messages WHERE folder_id = ?"
" ORDER BY date DESC, uid DESC LIMIT ? OFFSET ?",
(folder_id, limit, offset),
)
.fetchall()
)
return [self._row_to_meta(r) for r in rows]
@_locked
def known_uids(self, folder_id: int, last_n: int = 500) -> list[int]:
rows = (
self._db()
.execute(
"SELECT uid FROM messages WHERE folder_id = ?"
" ORDER BY uid DESC LIMIT ?",
(folder_id, last_n),
)
.fetchall()
)
return [r[0] for r in rows]
@_locked
def count_unseen(self, folder_id: int) -> int:
"""Les non-lus. `flags` est en clair, donc c'est du SQL, pas du déchiffrement."""
return (
self._db()
.execute(
"SELECT COUNT(*) FROM messages"
" WHERE folder_id = ? AND flags NOT LIKE '%\\Seen%' ESCAPE '\\'",
(folder_id,),
)
.fetchone()[0]
)
@_locked
def set_snippet(self, folder_id: int, uid: int, text: str) -> None:
"""L'extrait n'existe qu'une fois le corps téléchargé : ENVELOPE ne le donne pas."""
db = self._db()
db.execute(
"UPDATE messages SET sealed_snippet = ?"
" WHERE folder_id = ? AND uid = ?",
(self._seal(text), folder_id, uid),
)
db.commit()
# -- Corps ----------------------------------------------------------
def _body_path(self, folder_name: str, uid: int) -> Path:
suffix = ".eml" if self.mode == "clear" else ".eml.enc"
return self.root / folder_dirname(folder_name) / f"{uid}{suffix}"
@_locked
def write_body(self, folder_name: str, uid: int, raw: bytes) -> None:
path = self._body_path(folder_name, uid)
path.parent.mkdir(parents=True, exist_ok=True)
os.chmod(path.parent, 0o700)
# `write_bytes` puis `chmod` laisserait le corps du message — scellé,
# mais destiné à rester privé même déchiffré — lisible à l'umask du
# process le temps entre les deux appels : le fichier est donc créé
# DÉJÀ en 0600.
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "wb") as handle:
handle.write(self._crypto.seal(raw))
os.chmod(path, 0o600)
db = self._db()
db.execute(
"UPDATE messages SET has_body = 1 WHERE uid = ? AND folder_id ="
" (SELECT id FROM folders WHERE name = ?)",
(uid, folder_name),
)
db.commit()
@_locked
def read_body(self, folder_name: str, uid: int) -> bytes | None:
path = self._body_path(folder_name, uid)
if not path.exists():
return None
return self._crypto.open(path.read_bytes())
# -- Entretien ------------------------------------------------------
@_locked
def size_bytes(self) -> int:
return sum(
p.stat().st_size for p in self.root.rglob("*") if p.is_file()
)
@_locked
def purge_all(self) -> None:
db = self._db()
db.execute("DELETE FROM messages")
db.execute("DELETE FROM folders")
db.commit()
for child in self.root.iterdir():
if child.is_dir():
shutil.rmtree(child, ignore_errors=True)

2701
script/todo/mail/tui.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,218 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Tout ce que le TUI calcule avant d'afficher.
Ces fonctions sont volontairement hors de `tui.py` : elles n'ont besoin
d'aucun widget, donc elles se testent en une ligne. Le fichier de l'application
n'a plus qu'à composer des cadres et à appeler ces fonctions.
Un courriel arrive rarement dans la forme qu'on espère : corps vide, HTML seul,
charset menteur, pièce jointe sans nom. Aucune de ces fonctions ne lève ; au
pire elles rendent une chaîne vide. Un message illisible doit s'afficher mal,
pas faire tomber la boîte de réception.
"""
from __future__ import annotations
import datetime
import email
import email.policy
import html as html_module
import re
import unicodedata
from dataclasses import dataclass
from script.todo.mail.charset import decode_bytes
_SCRIPT_STYLE_RE = re.compile(
r"<(script|style)\b.*?</\1>", re.IGNORECASE | re.DOTALL
)
_BR_RE = re.compile(r"<br\s*/?>", re.IGNORECASE)
_BLOCK_RE = re.compile(
r"</(p|div|tr|li|h[1-6]|table|blockquote)>", re.IGNORECASE
)
_TAG_RE = re.compile(r"<[^>]+>")
_BLANKS_RE = re.compile(r"\n{3,}")
@dataclass
class Attachment:
filename: str
content_type: str
size: int
index: int
def html_to_text(html: str) -> str:
"""Du HTML rendu lisible, sans dépendance externe.
Ce n'est pas un moteur de rendu : on veut lire un courriel, pas afficher
une page. Scripts et styles disparaissent, les blocs deviennent des sauts
de ligne, le reste est du texte.
"""
if not html:
return ""
text = _SCRIPT_STYLE_RE.sub("", html)
text = _BR_RE.sub("\n", text)
text = _BLOCK_RE.sub("\n", text)
text = _TAG_RE.sub("", text)
text = html_module.unescape(text)
lines = [line.strip() for line in text.splitlines()]
return _BLANKS_RE.sub("\n\n", "\n".join(lines)).strip()
def _decode_part(part) -> str:
try:
payload = part.get_payload(decode=True)
except Exception:
# Broad except: parsing can fail in many ways; see module docstring.
return ""
if payload is None:
return ""
# `decode_bytes` (see its docstring): an unrecognised charset name must
# not bring down the whole message display.
return decode_bytes(payload, part.get_content_charset())
def extract_body(raw: bytes) -> tuple[str, list[Attachment]]:
"""Le texte affichable d'un message, et la liste de ses pièces jointes."""
try:
msg = email.message_from_bytes(raw, policy=email.policy.default)
except Exception:
# Broad except: see module docstring.
return raw.decode("utf-8", "replace"), []
plain, html, attachments = "", "", []
index = 0
for part in msg.walk() if msg.is_multipart() else [msg]:
if part.get_content_maintype() == "multipart":
continue
disposition = (part.get_content_disposition() or "").lower()
ctype = part.get_content_type()
if disposition == "attachment" or (
disposition == "inline" and not ctype.startswith("text/")
):
try:
payload = part.get_payload(decode=True) or b""
except Exception:
# Broad except: see module docstring.
payload = b""
attachments.append(
Attachment(
filename=part.get_filename()
or f"piece-jointe-{index + 1}",
content_type=ctype,
size=len(payload),
index=index,
)
)
index += 1
continue
if ctype == "text/plain" and not plain:
plain = _decode_part(part)
elif ctype == "text/html" and not html:
html = _decode_part(part)
if plain:
return plain, attachments
if html:
return html_to_text(html), attachments
return "", attachments
def short_addr(value: str) -> str:
"""« Alice Tremblay <a@y.ca> » → « Alice Tremblay ». Sinon l'adresse."""
if not value:
return ""
from email.utils import getaddresses
pairs = getaddresses([value])
if not pairs:
return value.strip()
name, addr = pairs[0]
return (name or addr).strip()
def truncate(text: str, width: int) -> str:
"""Coupé à `width` caractères au plus, ellipse comprise."""
text = text or ""
if width <= 0:
return ""
if len(text) <= width:
return text
return text[: width - 1] + "…"
def format_date(epoch: int, now: int) -> str:
"""Aujourd'hui → l'heure. Cette année → jour-mois. Avant → la date pleine."""
if not epoch:
return ""
try:
stamp = datetime.datetime.fromtimestamp(epoch)
today = datetime.datetime.fromtimestamp(now)
except (OSError, OverflowError, ValueError):
# `fromtimestamp` lève hors de la plage représentable — OSError ou
# OverflowError selon l'ampleur. Une date aberrante vient d'un
# en-tête, donc d'une source non fiable : elle doit s'afficher vide,
# pas faire tomber la liste des messages.
return ""
if stamp.date() == today.date():
return stamp.strftime("%H:%M")
if stamp.year == today.year:
return stamp.strftime("%m-%d")
return stamp.strftime("%Y-%m-%d")
def format_date_full(epoch: int) -> str:
"""La date pleine, jour et heure, lisible sans le contexte de la liste.
`format_date` est compact À DESSEIN pour la colonne de la liste ;
l'aperçu d'un message veut savoir QUAND il a été envoyé, sans avoir à
deviner l'année à partir de la date du jour. Même garde que
`format_date` : un en-tête vient d'une source non fiable, une date
aberrante doit rendre une chaîne vide, jamais lever.
"""
if not epoch:
return ""
try:
stamp = datetime.datetime.fromtimestamp(epoch)
except (OSError, OverflowError, ValueError):
# Voir `format_date` : `fromtimestamp` lève hors de la plage
# représentable — OSError ou OverflowError selon l'ampleur.
return ""
return stamp.strftime("%Y-%m-%d %H:%M")
def format_size(size: int) -> str:
size = size or 0
if size < 1024:
return f"{size} o"
if size < 1024 * 1024:
return f"{size / 1024:.1f} ko"
return f"{size / (1024 * 1024):.1f} Mo"
def is_unread(flags: str | None) -> bool:
return "\\seen" not in (flags or "").lower()
def _fold(text: str) -> str:
"""Sans accents ni casse : « revise » doit trouver « révisé »."""
stripped = unicodedata.normalize("NFKD", text or "")
return "".join(c for c in stripped if not unicodedata.combining(c)).lower()
def filter_messages(metas: list, query: str) -> list:
"""Filtre incrémental sur ce que le cache contient déjà.
Volontairement local : la recherche côté serveur est une fonction de la
phase 3, celle-ci doit répondre à chaque frappe sans réseau.
"""
if not query:
return list(metas)
needle = _fold(query)
return [
m
for m in metas
if needle in _fold(f"{m.subject} {m.frm} {m.to} {m.snippet}")
]

View file

@ -0,0 +1,186 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Écran de reprise de migration Odoo, en TUI.
Pendant de `qemu_deploy_form` pour l'outil de migration. Deux interfaces
posent la MÊME première question — « où en est-on, et par où reprend-on ? » —
et renvoient les MÊMES chaînes de réponse (« c », « n », « r », « q »,
« 0 »..« 4 », « 4.<version> ») que `TodoUpgrade.apply_resume_answer` traduit
en progression. La décision est donc écrite une seule fois.
- run_resume_tui(ctx, run_app=True) : renvoie la réponse, ou None pour
retomber sur les invites en ligne.
`ctx` vient de `TodoUpgrade.resume_context()` : pure donnée, aucun accès à la
base ni au disque depuis l'affichage.
"""
from __future__ import annotations
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - repli si i18n indisponible
def t(key: str) -> str:
return key
def version_line(versions):
"""« 13✓ 14✓ 15 16 17 18 » — l'avancement des montées de version."""
return " ".join(
f"{v['version']}{'✓' if v['done'] else ''}" for v in versions
)
def next_version(versions):
"""Première version pas encore migrée : celle que « continuer » reprend."""
for item in versions:
if not item["done"]:
return item["version"]
return None
def run_resume_tui(ctx, run_app: bool = True):
"""Écran de reprise. Renvoie la réponse choisie, ou None si annulé.
`run_app=False` renvoie l'instance sans la lancer (tests headless)."""
from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical
from textual.widgets import (
Button,
DataTable,
Footer,
Header,
OptionList,
Static,
)
from textual.widgets.option_list import Option
result = {"answer": None}
class Resume(App):
CSS = """
#head { height: auto; padding: 0 1; color: $text-muted; }
#steps { height: auto; max-height: 12; border: solid $accent; }
#bumps { height: auto; max-height: 10; border: solid $panel; }
.grouptitle { color: $accent; text-style: bold; padding: 1 1 0 1; }
#actions { height: auto; padding: 1 1 0 1; }
#hint { height: auto; color: $text-muted; padding: 0 1; }
"""
BINDINGS = [
("c", "cont", t("Continue where it stopped")),
("n", "new", t("New migration, erase everything")),
("r", "keep_zip", t("Keep the zip only")),
("q", "quit_nothing", t("Quit without doing anything")),
("escape", "quit_nothing", t("Quit without doing anything")),
]
def compose(self) -> ComposeResult:
yield Header()
yield Static(
f" {t('File'):<9}: {ctx['file']}\n"
f" {t('Database'):<9}: {ctx['database']}"
f" · {t('Target')} : {ctx['target']}\n"
f" {t('Started'):<9}: {ctx['started']}",
id="head",
)
yield Static(f"{t('Steps')}", classes="grouptitle")
yield DataTable(id="steps")
if ctx["versions"]:
yield Static(
f"{t('Version bumps')} ({version_line(ctx['versions'])})",
classes="grouptitle",
)
yield OptionList(id="bumps")
with Vertical():
with Horizontal(id="actions"):
yield Button(
t("Continue where it stopped"),
variant="primary",
id="a_cont",
)
yield Button(t("New migration"), id="a_new")
yield Button(t("Keep the zip only"), id="a_keep")
yield Button(t("Quit"), id="a_quit")
yield Static(
f" {t('Enter on a step or a version = replay from there')}",
id="hint",
)
yield Footer()
def on_mount(self) -> None:
self.title = t("Migration in progress")
table = self.query_one("#steps", DataTable)
table.cursor_type = "row"
table.add_columns("", "", t("Step"), t("Detail"))
for item in ctx["steps"]:
table.add_row(
f"[{item['step']}]",
item["icon"],
item["label"],
item["detail"],
)
# Curseur sur la première étape inachevée : c'est là que ça a
# calé, donc là qu'on veut probablement rejouer.
for index, item in enumerate(ctx["steps"]):
if item["icon"] != "✅":
table.move_cursor(row=index)
break
if ctx["versions"]:
bumps = self.query_one("#bumps", OptionList)
for item in ctx["versions"]:
mark = "✓" if item["done"] else " "
bumps.add_option(
Option(
f" {mark} Odoo {item['version']}.0 — "
f"{t('rebuilds the intermediate database')}",
id=str(item["version"]),
)
)
upcoming = next_version(ctx["versions"])
if upcoming is not None:
bumps.highlighted = [
v["version"] for v in ctx["versions"]
].index(upcoming)
# -- choix ------------------------------------------------------ #
def _answer(self, value):
result["answer"] = value
self.exit()
def on_data_table_row_selected(self, event) -> None:
index = event.cursor_row
if 0 <= index < len(ctx["steps"]):
self._answer(str(ctx["steps"][index]["step"]))
def on_option_list_option_selected(self, event) -> None:
self._answer(f"4.{event.option.id}")
def on_button_pressed(self, event) -> None:
mapping = {
"a_cont": "c",
"a_new": "n",
"a_keep": "r",
"a_quit": "q",
}
value = mapping.get(event.button.id)
if value:
self._answer(value)
def action_cont(self) -> None:
self._answer("c")
def action_new(self) -> None:
self._answer("n")
def action_keep_zip(self) -> None:
self._answer("r")
def action_quit_nothing(self) -> None:
self._answer("q")
app = Resume()
app._result = result # lecture par les tests headless
if not run_app:
return app
app.run()
return result["answer"]

View file

@ -0,0 +1,201 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Statistiques d'une migration Odoo, en lecture seule.
Ne touche NI la base NI le fichier de progression : tout se déduit du journal
de migration et des traces laissées sous `private/odoo/migration/<base>/`.
C'est ce qui permet de consulter l'état d'une migration en cours depuis une
autre session sans risquer de la perturber.
`compute()` reçoit le contexte déjà construit par
`TodoUpgrade.resume_context()` — étapes et montées de version — pour ne pas
réimplémenter une seconde fois la lecture des clés « state_* », qui
divergerait de l'écran de reprise.
"""
from __future__ import annotations
import datetime
import glob
import json
import os
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - repli si i18n indisponible
def t(key: str) -> str:
return key
def _as_int(value):
"""Clé de version en entier. JSON transforme les clés numériques en
chaînes : « 13 » et 13 désignent la même version."""
try:
return int(float(value))
except (TypeError, ValueError):
return None
def fmt_delay(start, end):
"""Écart lisible entre deux horodatages « str(datetime) »."""
try:
a = datetime.datetime.fromisoformat(str(start))
b = datetime.datetime.fromisoformat(str(end))
except (TypeError, ValueError):
return "?"
secs = int(abs((b - a).total_seconds()))
days, rest = divmod(secs, 86400)
hours, rest = divmod(rest, 3600)
minutes = rest // 60
if days:
return f"{days} j {hours:02d} h"
if hours:
return f"{hours} h {minutes:02d} min"
return f"{minutes} min"
def module_evolution(dct_progression):
"""[(version, nb_modules, delta)] du plus ancien au plus récent.
Montre où les modules disparaissent : un saut qui en perd 15 d'un coup
n'a pas le même sens qu'un saut qui n'en perd aucun.
"""
raw = dct_progression.get("dct_module_per_version") or {}
rows = []
for key, value in raw.items():
version = _as_int(key)
if version is not None and isinstance(value, list):
rows.append((version, len(value)))
rows.sort()
out = []
previous = None
for version, count in rows:
out.append(
(version, count, None if previous is None else count - previous)
)
previous = count
return out
def cow_snapshots(private_dir, database_name):
"""Instantanés de vues COW enregistrés, du plus ancien au plus récent."""
directory = os.path.join(private_dir, database_name, "cow_snapshots")
out = []
for path in sorted(glob.glob(os.path.join(directory, "*.json"))):
try:
with open(path, encoding="utf-8") as fh:
data = json.load(fh)
except (OSError, ValueError):
continue
out.append(
{
"label": data.get("label") or os.path.basename(path)[:-5],
"count": data.get("count"),
"taken_at": data.get("taken_at") or "?",
"path": path,
}
)
out.sort(key=lambda item: item["taken_at"])
return out
def fix_hooks(ctx, global_dir):
"""Correctifs de migration disponibles, et lesquels ont tourné."""
applied = ctx.get("_fix_applied") or []
out = []
for index, item in enumerate(ctx.get("versions") or []):
target = item["version"]
stem = f"fix_migration_odoo{(target - 1) * 10}_to_odoo{target * 10}"
found = [
os.path.basename(p)
for ext in (".sql", ".py")
for p in [os.path.join(global_dir, stem + ext)]
if os.path.exists(p)
]
if not found:
continue
out.append(
{
"version": target,
"file": found[0],
"applied": bool(index < len(applied) and applied[index]),
}
)
return out
def journal(dct_progression):
"""Commandes exécutées et décisions annotées (les lignes « # »)."""
lst = dct_progression.get("command_executed") or []
comments = [
c[2:].strip() for c in lst if isinstance(c, str) and c.startswith("# ")
]
commands = [
c for c in lst if isinstance(c, str) and not c.startswith("# ")
]
return {"commands": commands, "comments": comments}
def compute(
dct_progression,
ctx,
database_name,
read_uninstall,
private_dir,
global_dir,
):
"""Rassemble toutes les statistiques.
`read_uninstall(version, base)` est la lecture de liste de TodoUpgrade :
elle résout ELLE-MÊME ses chemins privé puis global, ce qui garantit
qu'on affiche exactement la liste qui serait appliquée. `private_dir` ne
sert donc qu'aux instantanés COW, et `global_dir` qu'aux correctifs."""
versions = [item["version"] for item in (ctx.get("versions") or [])]
uninstall = {}
for target in versions:
try:
_lst, detail = read_uninstall(target - 1, database_name)
except Exception:
detail = []
if detail:
uninstall[target] = detail
evolution = module_evolution(dct_progression)
origin = dct_progression.get("lst_module_per_version_origin") or []
return {
"delay": fmt_delay(
dct_progression.get("date_create"),
dct_progression.get("date_update"),
),
"updated": dct_progression.get("date_update") or "?",
"evolution": evolution,
"origin_count": len(origin) if isinstance(origin, list) else 0,
"missing": dct_progression.get("lst_module_missing") or [],
"duplicate": dct_progression.get("lst_module_duplicate") or [],
"uninstall": uninstall,
"removed_total": sum(len(v) for v in uninstall.values()),
"cow": cow_snapshots(private_dir, database_name),
"fixes": fix_hooks(
dict(
ctx,
_fix_applied=dct_progression.get(
"state_4_fix_migration_odoo_lst"
)
or [],
),
global_dir,
),
"journal": journal(dct_progression),
}
def flat_module_list(uninstall):
"""Tous les modules supprimés, dédupliqués, prêts à copier-coller."""
seen = []
for detail in uninstall.values():
for item in detail:
name = item[0] if isinstance(item, (list, tuple)) else item
if name not in seen:
seen.append(name)
return seen

View file

@ -163,6 +163,7 @@ def build_spec(vms, domains, form):
"vms": [vm for vm in vms if vm["name"] not in known],
"existing": [vm["name"] for vm in vms if vm["name"] in known],
"ssh_key": form["ssh_key"],
"timezone": form.get("timezone", ""),
"install": form["install"],
"add_ssh_config": form["add_ssh_config"],
"parallelism": form["parallelism"],
@ -416,6 +417,12 @@ def run_deploy_form(ctx, run_app: bool = True):
value=defaults.get("monitor", True),
id="f_monitor",
)
yield Static(t("Timezone"), classes="grouptitle")
yield Input(
value=ctx.get("timezone") or "",
placeholder=t("Timezone for the VMs"),
id="f_tz",
)
yield Static("SSH", classes="grouptitle")
yield Input(
value=ctx.get("ssh_key") or "",
@ -631,6 +638,11 @@ def run_deploy_form(ctx, run_app: bool = True):
else f"x{self.profile}"
),
"ssh_key": os.path.expanduser(key) if key else "",
# Un champ vidé retombe sur le fuseau de l'hôte plutôt que sur
# rien : sans valeur, la VM démarrerait en UTC.
"timezone": self.query_one("#f_tz", Input).value.strip()
or ctx.get("timezone")
or "",
"install": install,
"add_ssh_config": self.query_one("#f_sshcfg", Checkbox).value,
"parallelism": self.query_one("#f_par", Select).value,

View file

@ -16,6 +16,7 @@ from __future__ import annotations
import asyncio
import json
import os
import re
import shlex
import shutil
import socket
@ -73,7 +74,9 @@ def list_install_runs() -> list:
return runs
def _launch_one(ip: str, remote_cmd: str, log_path: str) -> None:
def _launch_one(
ip: str, remote_cmd: str, log_path: str, name: str = ""
) -> None:
"""Lance une install SSH DÉTACHÉE : attend le sshd, exécute, journalise
la sortie puis écrit le marqueur de fin avec le code de sortie."""
# Sonde de disponibilité : on attend que sshd réponde ET que cloud-init
@ -95,19 +98,72 @@ def _launch_one(ip: str, remote_cmd: str, log_path: str) -> None:
msg_wait = t("Waiting for the VM to start (boot + cloud-init)")
msg_slow = t("(an emulated architecture can be slow; this is normal)")
msg_ready = t("VM ready - starting the ERPLibre install")
# L'IP est RÉSOLUE À CHAQUE TOUR, jamais figée. Au 1er boot la VM prend un
# bail sous le nom par défaut de l'image, puis cloud-init pose le vrai nom
# d'hôte et le client DHCP en redemande un AUTRE. L'adresse connue au
# lancement devient donc morte en cours de route, et l'attente échouait
# 20 minutes durant sur une VM parfaitement saine (vécu : bail .247 périmé
# pendant que la VM vivait en .248).
#
# L'agent invité fait foi : il répond depuis l'intérieur, là où le bail
# dnsmasq garde les deux adresses sans dire laquelle est vivante. « sudo -n »
# car ce script tourne DÉTACHÉ : une demande de mot de passe le bloquerait
# sans que personne ne la voie. Sans réponse, on garde l'adresse courante.
# L'agent ne suffit PAS comme source unique : son paquet s'installe hors de
# cloud-init pour ne pas retarder le démarrage, donc il arrive tard — et
# pendant tout ce temps la ré-résolution ne renvoyait rien et gardait
# l'adresse morte. C'est le défaut qui a fait échouer le premier correctif.
#
# Repli sur les baux : dnsmasq les garde tous les deux sans dire lequel est
# vivant, on tranche donc en TESTANT le port 22 — le seul critère qui compte
# ici, puisque c'est par là que l'installation passera. Le bail périmé ne
# répond pas, le bon répond.
# virsh SANS sudo d'abord. Ce script tourne détaché, sans tty : « sudo -n »
# y échoue dès que l'hôte exige une authentification interactive — vécu sur
# erplibre01 (« sudo-rs: interactive authentication is required »), et la
# ré-résolution restait alors muette sans laisser la moindre trace.
# Appartenir au groupe libvirt suffit pour joindre qemu:///system, ce que
# « deploy_qemu.py --setup-host » configure déjà. sudo -n reste en repli
# pour les hôtes où le groupe manque.
name_q = shlex.quote(name) if name else ""
vsh = (
'vsh() { virsh --connect qemu:///system "$@" 2>/dev/null '
'|| sudo -n virsh --connect qemu:///system "$@" 2>/dev/null; }; '
)
refresh = (
(
f"n=$(vsh domifaddr {name_q} --source agent "
"| grep -oE '([0-9]{1,3}\\.){3}[0-9]{1,3}' "
"| grep -v '^127\\.' | head -1); "
'if [ -z "$n" ]; then '
f"for c in $(vsh domifaddr {name_q} --source lease "
"| grep -oE '([0-9]{1,3}\\.){3}[0-9]{1,3}' "
"| grep -v '^127\\.'); do "
'timeout 2 bash -c "echo > /dev/tcp/$c/22" 2>/dev/null '
'&& n="$c"; done; fi; '
'[ -n "$n" ] && ip="$n"; '
)
if name
else ""
)
wrapper = (
f"ip={shlex.quote(ip)}; "
f"{vsh if name else ''}"
f"echo {shlex.quote('== ' + msg_wait + ' ==')} >> {log_q}; "
f"echo {shlex.quote(' ' + msg_slow)} >> {log_q}; "
f"for i in $(seq 1 240); do "
f"st=$(ssh {SSH_OPTS} -o BatchMode=yes erplibre@{ip} "
f"{refresh}"
f'st=$(ssh {SSH_OPTS} -o BatchMode=yes "erplibre@$ip" '
f"{shlex.quote(ci_probe)} 2>/dev/null); "
f'case "$st" in '
f"*done*|*disabled*|*error*|*degraded*|*nocloudinit*) break;; "
f"esac; "
f'if [ $((i % 6)) -eq 0 ]; then echo " ... $((i*5))s" >> {log_q}; fi; '
f"if [ $((i % 6)) -eq 0 ]; then "
f'echo " ... $((i*5))s ($ip)" >> {log_q}; fi; '
f"sleep 5; done; "
f"echo {shlex.quote('== ' + msg_ready + ' ==')} >> {log_q}; "
f"ssh {SSH_OPTS} erplibre@{ip} {shlex.quote(remote_cmd)} "
f'echo " → $ip" >> {log_q}; '
f'ssh {SSH_OPTS} "erplibre@$ip" {shlex.quote(remote_cmd)} '
f">> {log_q} 2>&1; "
f'echo "{EXIT_MARKER} $?" >> {log_q}'
)
@ -156,7 +212,7 @@ def launch_installs(vms: list[dict], branch: str, remote_cmd: str) -> str:
# En-tête d'emblée (date/distro/version/arch) : le log n'est jamais
# vide, l'utilisateur voit tout de suite QUOI s'installe.
Path(log_path).write_text(_log_header(vm, branch, when))
_launch_one(vm["ip"], remote_cmd, log_path)
_launch_one(vm["ip"], remote_cmd, log_path, vm["name"])
entries.append(
{
"name": vm["name"],
@ -594,6 +650,57 @@ def browser_install_command(browser="w3m") -> list | None:
return None
def virsh_ip(name: str) -> str:
"""Adresse ACTUELLE d'une VM, ou '' si indéterminable.
Même logique que la sonde du wrapper détaché, et pour la même raison : le
bail que la VM prend au premier démarrage sous le nom par défaut de l'image
est remplacé dès que cloud-init pose le vrai nom d'hôte. L'agent invité fait
foi ; sans lui, on départage les baux en testant le port 22.
virsh SANS sudo d'abord (groupe libvirt), « sudo -n » en repli : sur un hôte
exigeant une authentification interactive, sudo échoue et ne doit pas
empêcher la lecture.
"""
def run(source):
for pre in ([], ["sudo", "-n"]):
try:
res = subprocess.run(
pre
+ [
"virsh",
"--connect",
"qemu:///system",
"domifaddr",
name,
"--source",
source,
],
capture_output=True,
text=True,
timeout=10,
env={**os.environ, "LC_ALL": "C", "LANG": "C"},
)
except (OSError, subprocess.SubprocessError):
continue
if res.returncode == 0:
return res.stdout
return ""
found = re.findall(r"\b(\d{1,3}(?:\.\d{1,3}){3})/", run("agent"))
for ip in found:
if not ip.startswith("127."):
return ip
# Sans agent : plusieurs baux possibles, dont un périmé. Le port 22 tranche.
for ip in re.findall(r"\b(\d{1,3}(?:\.\d{1,3}){3})/", run("lease")):
if ip.startswith("127."):
continue
if _port_open(ip, 22):
return ip
return ""
def virsh_domstates() -> dict:
"""{nom: état} de tous les domaines libvirt (« virsh list --all »). Sert à
détecter une VM EN PAUSE ou EFFACÉE pendant le suivi. Un seul appel virsh
@ -705,6 +812,7 @@ def run_monitor(manifest_path: str, run_app: bool = True):
BINDINGS = [
("q", "quit", "Quitter (détaché)"),
("s", "ssh", "SSH"),
("v", "console", "Console (virsh)"),
("w", "web", "Web (navigateur CLI)"),
("f", "follow", "Suivre"),
("c", "copy_log", "Copier log"),
@ -829,9 +937,9 @@ def run_monitor(manifest_path: str, run_app: bool = True):
bar = self.query_one("#sshbar", Static)
if vm:
bar.update(
f" {vm['ssh']} (s = SSH · w = web :8069 · "
"c = copier le log · d = détails erreurs · "
"Maj+glisser = sélectionner)\n"
f" {vm['ssh']} (s = SSH · v = console · "
"w = web :8069 · c = copier le log · "
"d = détails erreurs · Maj+glisser = sélectionner)\n"
f" Log : {vm['log']}"
)
@ -1100,6 +1208,24 @@ def run_monitor(manifest_path: str, run_app: bool = True):
for vm in vms:
self._domstate[vm["name"]] = states.get(vm["name"], "gone")
# L'adresse est relue au même rythme. Le processus détaché suivait
# déjà la VM quand son bail changeait, mais les VUES gardaient celle
# du lancement : la barre proposait « ssh erplibre@…222 » alors que
# l'installation parlait à …223, et la touche « s » y menait aussi.
# Rafraîchir ici plutôt que dans un tick à part évite un second
# appel virsh par VM — celui-ci est déjà le relevé lent.
changed = False
for vm in vms:
if self._domstate.get(vm["name"]) == "gone":
continue
ip = await asyncio.to_thread(virsh_ip, vm["name"])
if ip and ip != vm.get("ip"):
vm["ip"] = ip
vm["ssh"] = f"ssh erplibre@{ip}"
changed = True
if changed:
self._refresh_ssh()
# -- events --------------------------------------------------------- #
def on_data_table_row_highlighted(self, event) -> None:
# DEBOUNCE : RowHighlighted se déclenche à CHAQUE mouvement du
@ -1133,6 +1259,34 @@ def run_monitor(manifest_path: str, run_app: bool = True):
with self.suspend():
os.system(f"ssh {SSH_OPTS} erplibre@{vm['ip']} || true")
def action_console(self) -> None:
"""Console série de la VM, sans quitter le suivi.
Le seul recours quand SSH ne répond pas : elle ne dépend ni du
réseau de la VM, ni de sshd, ni d'une IP — donc elle montre un
démarrage bloqué, un cloud-init encore en cours ou un réseau sans
bail, que le suivi ne peut que constater de loin.
« suspend() » rend le terminal avant d'appeler virsh : sudo peut y
demander son mot de passe et la console prendre le clavier, ce qui
casserait l'affichage si Textual le tenait encore.
"""
vm = self._vm_by_name(self._selected)
if not vm:
return
name = shlex.quote(vm["name"])
with self.suspend():
# La console n'affiche que ce qui arrive APRÈS l'attachement :
# sur une VM déjà démarrée l'écran reste noir tant qu'on n'a
# rien envoyé. On le dit, plutôt que de laisser croire à un gel.
print(f"\n→ virsh console {vm['name']}")
print(
" Écran vide ? Appuyez sur Entrée : la console ne montre"
" que la sortie qui suit l'attachement."
)
print(" Ctrl+] puis Entrée pour revenir au suivi.\n")
os.system(f"sudo virsh console {name} || true")
def action_web(self) -> None:
"""Ouvre l'UI web de la VM (Odoo :8069) dans un navigateur CLI
(browsh/carbonyl/w3m/links/elinks/lynx). Surtout utile une fois

View file

@ -6,6 +6,7 @@ import ast
import configparser
import datetime
import getpass
import grp
import inspect
import json
import logging
@ -26,9 +27,9 @@ sys.path.append(new_path)
from script.config import config_file
from script.execute import execute
from script.todo import todo_prefs
from script.todo.database_manager import DatabaseManager
from script.todo.kdbx_manager import KdbxManager
from script.todo import todo_prefs
from script.todo.todo_i18n import get_lang, lang_is_configured, set_lang, t
from script.todo.version_manager import get_odoo_version
@ -129,7 +130,7 @@ class TODO:
help_info = f"""{self._menu_header()}
[1] {t("Execute")}
[2] {t("Install")}
[3] {t("Question")}
[3] {t("Assistant")}
[4] {t("Fork - Open TODO in a new tab")}
[5] {t("Navigation telemetry (TUI)")}
[6] {t("Configuration")}
@ -156,7 +157,7 @@ class TODO:
elif status == "2":
self.prompt_install()
elif status == "3":
self.execute_prompt_ia()
self.prompt_assistant()
elif status == "4":
# cmd = (
# f"gnome-terminal --tab -- bash -c 'source"
@ -176,7 +177,27 @@ class TODO:
print(status)
# manipuler()
def execute_prompt_ia(self):
def prompt_assistant(self):
"""Ce qui s'adresse à l'humain : poser une question, lire son courriel."""
from script.todo.mail.menu import prompt_execute_mail
while True:
help_info = f"""{self._menu_header()}
[1] {t("mail_ai_question")}
[2] {t("mail_menu")}
[0] {t("Back")}"""
status = click.prompt(help_info)
print()
if status == "0":
return
if status == "1":
self._assistant_question()
elif status == "2":
prompt_execute_mail(self)
else:
print(t("Command not found !"))
def _assistant_question(self):
while True:
help_info = f"""{self._menu_header()}
[0] {t("Back")}
@ -215,22 +236,23 @@ class TODO:
── {t("Data")} ──
[6] {t("Database - Database tools")}
[7] {t("Analyse - Odoo database analysis")}
── {t("Sources & documentation")} ──
[7] {t("Git - Git tools")}
[8] {t("Doc - Documentation search")}
[8] {t("Git - Git tools")}
[9] {t("Doc - Documentation search")}
── {t("AI & automation")} ──
[9] {t("GPT code - AI assistant tools")}
[10] {t("Automation - Demonstration of developed features")}
[10] {t("GPT code - AI assistant tools")}
[11] {t("Automation - Demonstration of developed features")}
── {t("Deployment, network & security")} ──
[11] {t("Deploy - Deploy ERPLibre locally")}
[12] {t("Network - Network tools")}
[13] {t("Security - Dependency security audit")}
[12] {t("Deploy - Deploy ERPLibre locally")}
[13] {t("Network - Network tools")}
[14] {t("Security - Dependency security audit")}
── {t("Preferences")} ──
[14] {t("Language - Change language / Changer la langue")}
[15] {t("Language - Change language / Changer la langue")}
[0] {t("Back")}
"""
while True:
@ -263,34 +285,38 @@ class TODO:
if status is not False:
return
elif status == "7":
status = self.prompt_execute_git()
status = self.prompt_execute_analyse()
if status is not False:
return
elif status == "8":
status = self.prompt_execute_doc()
status = self.prompt_execute_git()
if status is not False:
return
elif status == "9":
status = self.prompt_execute_gpt_code()
status = self.prompt_execute_doc()
if status is not False:
return
elif status == "10":
status = self.prompt_execute_function()
status = self.prompt_execute_gpt_code()
if status is not False:
return
elif status == "11":
status = self.prompt_execute_deploy()
status = self.prompt_execute_function()
if status is not False:
return
elif status == "12":
status = self.prompt_execute_network()
status = self.prompt_execute_deploy()
if status is not False:
return
elif status == "13":
status = self.prompt_execute_security()
status = self.prompt_execute_network()
if status is not False:
return
elif status == "14":
status = self.prompt_execute_security()
if status is not False:
return
elif status == "15":
status = self._change_language()
if status is not False:
return
@ -519,7 +545,12 @@ class TODO:
print(f"{t('Will execute:')} {bash_command}")
self.execute.exec_command_live(bash_command, source_erplibre=False)
command = instance.get("Command:")
# Clé de CONFIGURATION, pas une chaîne d'interface : le passage aux
# clés i18n en texte anglais (4fc15c3) a renommé celle-ci en
# « Command: », le libellé affiché. Plus aucune entrée de todo.json
# ne correspondait, et « Open ERPLibre with TODO 🤖 » ne faisait
# plus rien — sans erreur, puisque le `if` était simplement faux.
command = instance.get("command")
if command:
self.prompt_execute_selenium(
command=command, extra_cmd_web_login=extra_cmd_web_login
@ -535,12 +566,13 @@ class TODO:
_MENU_LABELS = {
"run": "TODO",
"prompt_execute": "Execute",
"execute_prompt_ia": "Question",
"prompt_assistant": "Assistant",
"prompt_install": "Install",
"prompt_execute_function": "Automation",
"prompt_execute_code": "Code",
"prompt_execute_config": "Config",
"prompt_execute_database": "Database",
"prompt_execute_analyse": "Analyse",
"prompt_execute_doc": "Doc",
"prompt_execute_git": "Git",
"prompt_execute_git_local_server": "Git local server",
@ -582,9 +614,8 @@ class TODO:
"""Ouvre le TUI de télémétrie (arbre/Kanban). Une commande choisie est
exécutée au retour (hors du TUI) ; on propose ensuite de REVENIR (l'état
et la position du curseur sont restaurés) ou de quitter."""
from script.todo.todo_telemetry import run_tui
from script.todo import textual_setup
from script.todo.todo_telemetry import run_tui
if not textual_setup.ensure():
return
@ -1307,17 +1338,34 @@ class TODO:
# La première ligne est indispensable : sans virsh, la boucle ne tourne
# simplement pas et la sonde sortirait VIDE avec un code 0 — impossible
# alors de distinguer « pas de QEMU ici » de « QEMU présent, aucune VM ».
# Sonde exécutée à DISTANCE, dans une session SSH non interactive.
#
# « sudo virsh » y échoue dès que l'hôte demande un mot de passe — vécu sur
# erplibre01 (sudo-rs) — et la sonde répondait alors « pas de QEMU » sur une
# machine qui en fait tourner. On essaie donc virsh SANS sudo d'abord, via
# qemu:///system : appartenir au groupe libvirt suffit, sans tty.
#
# « --connect qemu:///system » est indispensable dans ce cas : sans lui, un
# utilisateur non root tombe sur qemu:///session, qui répond correctement…
# une liste VIDE. On aurait alors « QEMU présent, aucune VM », ce qui est
# pire qu'une erreur puisque c'est plausible.
#
# Trois réponses et non deux : « denied » distingue « virsh est là mais
# inaccessible » de « pas de QEMU ici », deux situations qui appellent des
# gestes opposés.
_QEMU_SSH_PROBE = (
'vsh() { virsh --connect qemu:///system "$@" 2>/dev/null '
'|| sudo -n virsh --connect qemu:///system "$@" 2>/dev/null; }; '
"if ! command -v virsh >/dev/null 2>&1; then "
"printf 'LIBVIRT\\tno\\n'; exit 0; fi; "
"vms=$(sudo virsh list --all --name 2>/dev/null) || "
"{ printf 'LIBVIRT\\tno\\n'; exit 0; }; "
"vms=$(vsh list --all --name) || "
"{ printf 'LIBVIRT\\tdenied\\n'; exit 0; }; "
"printf 'LIBVIRT\\tyes\\n'; "
"for n in $vms; do "
'ip=$(sudo virsh domifaddr "$n" --source lease 2>/dev/null '
'ip=$(vsh domifaddr "$n" --source lease '
"| grep -oE '([0-9]{1,3}\\.){3}[0-9]{1,3}' | head -1); "
'if [ -z "$ip" ]; then '
'ip=$(sudo virsh domifaddr "$n" --source agent 2>/dev/null '
'ip=$(vsh domifaddr "$n" --source agent '
"| grep -oE '([0-9]{1,3}\\.){3}[0-9]{1,3}' "
"| grep -v '^127\\.' | head -1); fi; "
'printf "%s\\t%s\\n" "$n" "$ip"; done'
@ -1750,17 +1798,22 @@ class TODO:
detail = (res.stderr or "").strip().splitlines()
message = detail[-1] if detail else f"exit {res.returncode}"
return self._ssh_error_kind(res.stderr), message
has_libvirt = False
libvirt = "no"
found = []
for line in res.stdout.splitlines():
parts = line.strip().split("\t")
if len(parts) != 2 or not parts[0]:
continue
if parts[0] == "LIBVIRT":
has_libvirt = parts[1].strip() == "yes"
libvirt = parts[1].strip()
continue
found.append((parts[0], parts[1].strip()))
return ("ok" if has_libvirt else "nolibvirt"), found
if libvirt == "yes":
return "ok", found
# « denied » : virsh est installé mais refuse de répondre — sudo
# interactif, ou utilisateur hors du groupe libvirt. Le confondre avec
# « pas de QEMU » envoyait chercher un problème qui n'existe pas.
return ("denied" if libvirt == "denied" else "nolibvirt"), found
def _qemu_ssh_walk(self, roots, max_depth):
"""Descend le parc depuis `roots` et écrit un ProxyJump par niveau.
@ -1837,8 +1890,18 @@ class TODO:
)
print(f" ⏭ {parent}: {label} — {found}")
continue
has_libvirt = status == "ok"
if not has_libvirt:
if status == "denied":
# virsh est là mais ne répond pas : c'est un DROIT qui
# manque, pas un logiciel. Le dire, et donner le geste.
print(
f" 🔒 {parent}: "
f"{t('virsh present but not accessible')}"
)
print(
f" {t('Add the user to the libvirt group there:')}"
)
continue
if status != "ok":
print(f" · {parent}: {t('no QEMU/libvirt here')}")
continue
# Une machine avec QEMU vaut sa connexion virt-manager, même
@ -3850,8 +3913,9 @@ class TODO:
`timeout` : délai max PAR VM (borne l'attente d'une VM sans IP). Un
BATTEMENT toutes les 30 s liste les VM encore en attente -> jamais de
silence prolongé qui donne l'impression d'un blocage."""
from concurrent.futures import ThreadPoolExecutor, as_completed
from concurrent.futures import ThreadPoolExecutor
from concurrent.futures import TimeoutError as _FTimeout
from concurrent.futures import as_completed
labels = labels or {}
print(
@ -4182,7 +4246,15 @@ class TODO:
# enregistre Odoo comme service systemd (enable + start). Pas pour
# « ERPLibre seul », « mobile » ni « Déploiement ».
if "install_odoo" in final_cmd:
final_cmd = f"{final_cmd} && {self._qemu_odoo_service_cmd(prod)}"
# Le snippet de service est une SUITE d'instructions séparées par
# « ; ». Collé tel quel après « && », l'opérateur ne lie que la
# première : tout le reste s'exécute même quand le make a échoué, et
# comme « systemctl enable » réussit, la commande distante rend 0 —
# l'install était rapportée ✅ alors qu'elle avait échoué. « set -e »
# ne rattrape pas : il n'interrompt pas sur un maillon d'une liste
# « && ». Les accolades font porter le && sur le bloc entier.
svc = self._qemu_odoo_service_cmd(prod).strip().rstrip(";")
final_cmd = f"{final_cmd} && {{ {svc}; }}"
# VM de DÉVELOPPEMENT uniquement : couper les mises à jour automatiques.
# Vécu sur erplibre-ubuntu-2404 : unattended-upgrades s'est déclenché en
# pleine migration Odoo 12->13 et a redémarré le cluster PostgreSQL
@ -4207,6 +4279,15 @@ class TODO:
"sudo systemctl disable --now dnf-automatic.timer "
"dnf-automatic-install.timer >/dev/null 2>&1 || true; "
"fi; "
# snapd : 57 s sur le CHEMIN CRITIQUE du démarrage, mesurés par
# « systemd-analyze critical-chain » sur une VM s390x —
# multi-user.target attend snapd.seeded. Aucune VM ERPLibre
# n'installe de snap : c'est du temps payé pour rien, à chaque
# démarrage. On désactive plutôt que désinstaller, pour rester
# réversible d'un « systemctl enable ».
"sudo systemctl disable --now snapd.seeded.service "
"snapd.service snapd.socket snapd.apparmor.service "
">/dev/null 2>&1 || true; "
)
return (
"set -e; "
@ -4762,7 +4843,19 @@ class TODO:
print(f" {t('Parallelism:')} {spec['parallelism']} {t('at a time')}")
def _qemu_build_deploy_parts(
self, d, v, arch, name, eram, evcpus, disk, ssh_key, branch, dry_run
self,
d,
v,
arch,
name,
eram,
evcpus,
disk,
ssh_key,
branch,
dry_run,
timezone=None,
locale=None,
):
"""Construit la commande deploy_qemu.py d'UNE VM (utilisée pour l'aperçu
dry-run ET le déploiement réel)."""
@ -4789,6 +4882,13 @@ class TODO:
parts += ["--arch", arch]
if ssh_key:
parts += ["--ssh-key", ssh_key]
if timezone:
# Toujours explicite, jamais implicite : la commande affichée en
# dry-run doit produire la même VM si on la rejoue depuis une autre
# machine, dont le fuseau serait différent.
parts += ["--timezone", timezone]
if locale:
parts += ["--locale", locale]
if branch:
# ERPLibre dépasse le minimum : +5 Go de disque.
bigger = self._parse_disk_gb(disk) + self.ERPLIBRE_EXTRA_DISK_GB
@ -4814,6 +4914,8 @@ class TODO:
spec.get("ssh_key"),
install["branch"] if install else None,
dry_run=dry_run,
timezone=spec.get("timezone"),
locale=spec.get("locale"),
)
# ---------------------------------------------------------------- #
@ -4877,6 +4979,112 @@ class TODO:
existing = [vm["name"] for vm in vms if vm["name"] in known]
return pending, existing
def _qemu_check_libvirt_group(self):
"""Prévient si virsh n'est pas joignable sans sudo, et propose de régler.
Le suivi d'installation tourne DÉTACHÉ, sans tty : il ne peut pas
répondre à une demande de mot de passe. « sudo -n » y échoue sur tout
hôte exigeant une authentification interactive (vécu sur erplibre01 avec
sudo-rs), et la VM devient alors introuvable dès que son bail DHCP
change. Le groupe libvirt est la seule voie qui n'exige ni root ni tty.
Vérifié AVANT de créer quoi que ce soit : découvrir le problème après
vingt minutes d'installation coûte bien plus cher qu'une question ici.
"""
probe = subprocess.run(
["virsh", "--connect", "qemu:///system", "list", "--name"],
capture_output=True,
text=True,
)
if probe.returncode == 0:
return # joignable sans sudo : rien à signaler
user = getpass.getuser()
# Être dans /etc/group ne suffit pas : les groupes d'un processus sont
# figés à l'ouverture de session. Distinguer les deux cas évite de
# proposer un usermod déjà fait, et dit la vraie action attendue.
try:
declared = user in grp.getgrnam("libvirt").gr_mem
except KeyError:
declared = False
try:
active = grp.getgrnam("libvirt").gr_gid in os.getgroups()
except KeyError:
active = False
print(f"\n⚠ {t('virsh cannot reach qemu:///system without sudo.')}")
print(f" {t('The install monitor runs detached and cannot type a')}")
print(
f" {t('password: it would lose the VM when its lease moves.')}"
)
if declared and not active:
print(
f"\n {t('You are in the libvirt group, but this session')}"
)
print(f" {t('predates it. Log out and back in, or run:')}")
print(f" newgrp libvirt")
return
if active:
# Groupe présent mais virsh échoue quand même : libvirtd arrêté,
# socket absente… La cause n'est pas le groupe, ne pas la maquiller.
print(f"\n {t('Group is active, so the cause is elsewhere:')}")
print(f" {(probe.stderr or '').strip()[:200]}")
return
cmd = f"sudo usermod -aG libvirt {shlex.quote(user)}"
print(f"\n {t('Add your user to the libvirt group?')}")
print(f" {cmd}")
if not self._is_yes_default_yes(input(t("Run it now? (Y/n): "))):
return
if os.system(cmd) != 0:
print(f" ⚠ {t('Command failed.')}")
return
print(f"\n✅ {t('Added. Log out and back in for it to take effect,')}")
print(f" {t('or start a new shell with: newgrp libvirt')}")
def _qemu_check_kvm(self):
"""Prévient quand les VM seront ÉMULÉES faute de KVM.
« Même architecture que l'hôte » ne veut pas dire accélérée : dans une
VM sans virtualisation imbriquée, libvirt bascule en TCG sans le dire.
Mesuré : une VM s390x sur un hôte s390x lui-même invité KVM est sortie
en « <domain type='qemu'> » et a démarré en 7 min 30. Le savoir avant
d'attendre vaut mieux que de chercher la cause après."""
try:
mod = self._qemu_import_module()
if mod.kvm_available():
return
except Exception:
return
try:
module = mod.nested_module()
except Exception:
module = "kvm"
print(f"\n⚠ {t('KVM is unavailable: the VMs will be EMULATED.')}")
print(f" {t('A boot then takes 10-15 min, not under a minute.')}")
print(
f" {t('Cause: /dev/kvm is missing. This host is itself a VM')}"
)
print(
f" {t('whose hypervisor does not expose nested virtualization.')}"
)
print(f"\n {t('To fix it ON THE PARENT HYPERVISOR, not here:')}")
print(
f' echo "options {module} nested=1"'
f" | sudo tee /etc/modprobe.d/kvm-nested.conf"
)
print(f" sudo modprobe -r {module} && sudo modprobe {module}")
print(
f" {t('then set this VM to the host-passthrough CPU mode and')}"
)
print(
f" {t('stop it and start it again - a reboot is not enough.')}"
)
print(
f"\n {t('Without access to that hypervisor, nothing to do here.')}"
)
def _qemu_ask_ui(self):
"""Interface du déploiement : formulaire TUI ou invites en ligne.
La préférence peut trancher d'avance (menu Configuration) ; « ask »
@ -4927,6 +5135,7 @@ class TODO:
"branches": self._qemu_branch_list() or ["master"],
"install_profiles": self._qemu_install_profiles(),
"ssh_key": self._qemu_default_ssh_key(),
"timezone": self._qemu_host_timezone(),
"host_cpu": os.cpu_count() or 2,
"free_ram": self._host_free_ram_mb(),
"base_vcpus": self._QEMU_BASE_VCPUS,
@ -4962,6 +5171,9 @@ class TODO:
if last:
print(last)
self._qemu_check_libvirt_group()
self._qemu_check_kvm()
if self._qemu_ask_ui() == "tui":
spec = self._qemu_deploy_form(mod, dry_run)
if spec is None:
@ -5197,6 +5409,41 @@ class TODO:
)
print(f" ⚠ {warn}")
def _qemu_host_timezone(self):
"""Fuseau de l'hôte. Défini une seule fois, dans deploy_qemu.py, qui
est aussi ce qui l'écrit dans le cloud-config : l'invite ne peut donc
pas proposer un défaut différent de celui réellement appliqué."""
try:
mod = self._qemu_import_module()
return mod.host_timezone()
except Exception:
return "UTC"
def _qemu_ask_timezone(self):
"""Fuseau des VM à créer, celui de l'hôte par défaut.
Une VM qui hérite du fuseau de son opérateur horodate ses journaux et
ses bases comme lui ; en UTC l'écart ne se remarque qu'après coup."""
default = self._qemu_host_timezone()
answer = input(f"{t('Timezone for the VMs')} ({default}): ").strip()
if not answer:
return default
# Un fuseau inconnu ne casse pas cloud-init : il l'ignore en silence et
# la VM reste en UTC. Mieux vaut le refuser ici que le découvrir plus
# tard sur des horodatages faux.
if not os.path.exists(os.path.join("/usr/share/zoneinfo", answer)):
print(f"⚠ {t('Unknown timezone, keeping')} {default}")
return default
return answer
def _qemu_ask_locale(self):
"""Locale des VM. « C.UTF-8 » par défaut : les autres déclenchent un
locale-gen dans l'invité, mesuré à 36 s sur s390x — payé à chaque
déploiement pour un confort dont une VM jetable n'a pas besoin."""
default = "C.UTF-8"
answer = input(f"{t('Locale for the VMs')} ({default}): ").strip()
return answer or default
def _qemu_collect_options_cli(self, vms, res_label):
"""Invites en ligne : clé SSH, installation ERPLibre, ~/.ssh/config,
parallélisme, puis récapitulatif et confirmation.
@ -5218,6 +5465,9 @@ class TODO:
if ssh_key:
ssh_key = os.path.expanduser(ssh_key)
timezone = self._qemu_ask_timezone()
locale = self._qemu_ask_locale()
# 4) Option : installer ERPLibre dans ~/git/erplibre de chaque VM.
install = None
ans = input(
@ -5280,6 +5530,8 @@ class TODO:
"vms": pending,
"existing": existing,
"ssh_key": ssh_key,
"timezone": timezone,
"locale": locale,
"install": install,
"add_ssh_config": add_ssh_config,
"parallelism": parallelism,
@ -6417,6 +6669,314 @@ class TODO:
else:
print(t("Command not found !"))
def prompt_execute_analyse(self):
"""Analyses en lecture seule d'une base Odoo.
Aucune entrée de ce menu n'écrit : la connexion psql est ouverte avec
`default_transaction_read_only=on`, donc c'est le serveur qui refuse
toute écriture, pas une promesse du code.
"""
print(f"🤖 {t('Analyse a database, without ever writing to it!')}")
choices = [
{"section": t("Structure")},
{"prompt_description": t("Tables and database size")},
{"section": t("Customisation")},
{
"prompt_description": t(
"Customised views, website copies included"
)
},
{"prompt_description": t("Studio and hand-made x_ fields")},
]
help_info = self.fill_help_info(choices)
while True:
status = click.prompt(help_info)
print()
if status == "0":
return False
elif status == "1":
self.execute_analyse_schema_size()
elif status == "2":
self.execute_analyse_view_custom()
elif status == "3":
self.execute_analyse_custom_field()
else:
print(t("Command not found !"))
def _analyse_select_source(self):
"""(est_une_sauvegarde, cible), ou None si l'on renonce.
La sauvegarde n'est pas un cas dégradé : restaurer celle d'une
instance Enterprise sur une installation Community échoue — Odoo veut
charger des modules qu'on n'a pas — donc c'est souvent la SEULE façon
de lire ce qu'elle contient.
"""
print()
print(f"[1] {t('A database')}")
print(f"[2] {t('A backup .zip, without restoring it')}")
print(f"[0] {t('Back')}")
answer = click.prompt(t("Command:"))
print()
if answer == "1":
database = self._analyse_select_database()
return (False, database) if database else None
if answer == "2":
path = self.db_manager.select_backup_path()
return (True, path) if path else None
return None
def _analyse_select_database(self):
"""Faire choisir la base à analyser, ou None si on abandonne."""
database = self.db_manager.select_database()
return database or None
def _analyse_json_path(self, database, tool):
"""Où écrire un export JSON. Le dossier est créé au besoin."""
directory = os.path.join("private", "analyse", database)
os.makedirs(directory, exist_ok=True)
return os.path.join(directory, f"{tool}.json")
def _analyse_export_json(self, data, database, tool):
"""Écrire le résultat brut, et dire où.
Sous `private/`, qui n'est pas versionné par convention : un rapport
d'analyse porte des noms de vues, de champs et de sociétés du client.
"""
path = self._analyse_json_path(database, tool)
with open(path, "w", encoding="utf-8") as handle:
json.dump(data, handle, indent=2, ensure_ascii=False, default=str)
print(f"✅ {t('Written to: ')}{path}")
def _analyse_follow_up(self, choices, handler):
"""Boucle « aller plus loin » après une analyse.
Le rapport suggérait « utilisez -v », « --exact », « ajoutez --diff ».
Dans un menu, c'est demander à l'utilisateur de sortir et de retaper
une commande pour obtenir ce que le menu pouvait lui offrir. Les
options sont donc devenues des entrées, et les conseils en ligne de
commande ne s'affichent plus que dans la vraie ligne de commande.
"""
help_info = self.fill_help_info(
[{"section": t("Go further")}] + choices
)
while True:
status = click.prompt(help_info)
print()
if status == "0":
return
try:
rank = int(status)
except ValueError:
rank = 0
if not 1 <= rank <= len(choices):
print(t("Command not found !"))
continue
if handler(rank) is False:
return
def execute_analyse_schema_size(self):
"""Poids de la base et tables qu'aucun modèle installé ne réclame.
L'outil est importé et appelé, pas lancé en sous-processus : `todo.py`
tourne déjà sous le même interpréteur, donc le sous-processus
n'apporterait aucun isolement et coûterait un second démarrage.
Contrepartie assumée de cet appel direct : une exception remonterait
dans la boucle du menu et ferait sortir du TODO. D'où le `try`.
"""
from script.analyse import analyse_schema_size as analyse
target = self._analyse_select_source()
if not target:
return
is_backup, database = target
state = {"data": None, "exact": is_backup}
def run(exact=False):
try:
state["data"] = (
analyse.collect_from_backup(database)
if is_backup
else analyse.collect(database, exact=exact)
)
state["exact"] = exact or is_backup
except Exception as exc:
print(f"❌ {t('Analysis failed: ')}{exc}")
return False
return True
if not run():
return
print(analyse.render(state["data"], hints=False))
def handler(rank):
data = state["data"]
if rank == 1:
print(analyse.render(data, verbose=True, hints=False))
elif rank == 2:
print(f"⏳ {t('Counting rows exactly, one scan per table…')}")
if run(exact=True):
print(analyse.render(state["data"], hints=False))
else:
self._analyse_export_json(
data, os.path.basename(database), "schema_size"
)
self._analyse_follow_up(
[
{"prompt_description": t("Show every table")},
{"prompt_description": t("Count rows exactly (full scan)")},
{"prompt_description": t("Export as JSON")},
],
handler,
)
def execute_analyse_view_custom(self):
"""Vues qui ne viennent pas telles quelles d'un module, COW comprises."""
from script.analyse import analyse_view_custom as analyse
target = self._analyse_select_source()
if not target:
return
is_backup, database = target
state = {"data": None}
def run(**kwargs):
try:
state["data"] = (
analyse.collect_from_backup(database)
if is_backup
else analyse.collect(database, **kwargs)
)
except Exception as exc:
print(f"❌ {t('Analysis failed: ')}{exc}")
return False
return True
if not run():
return
print(analyse.render(state["data"], hints=False))
if not state["data"]["findings"]:
return
# Comparer exige un registre Odoo chargé. Une sauvegarde n'en a pas,
# et rien ne peut l'y ajouter : proposer quand même la comparaison
# ferait trois entrées qui ne répondent pas, et une quatrième qui
# réclamerait indéfiniment une comparaison impossible.
can_compare = not is_backup
state["tried"] = False
def compare(scope):
print(f"⏳ {t('Loading the Odoo registry, this takes a moment…')}")
if not run(with_diff=True, scope=scope):
return
state["tried"] = True
data = state["data"]
print(analyse.render(data, hints=False))
if data["compared_with_module_source"] and not [
row for row in data["findings"] if row.get("differs")
]:
print(f"✅ {t('No view differs from its module source.')}")
def browse():
"""Ouvrir l'écran, ou dire précisément ce qui l'en empêche.
Trois raisons distinctes, trois messages. Répondre « comparez
d'abord » à quelqu'un qui vient de comparer lui reproche ce que
l'outil n'a pas pu faire, et le laisse recommencer sans fin.
"""
data = state["data"]
if not state["tried"]:
print(f"ℹ️ {t('Compare first, then browse.')}")
elif not data.get("compared_with_module_source"):
print(
f"⚠️ {t('No reference arch, so nothing was compared: ')}"
f"{data.get('arch_ref_error') or ''}"
)
elif not [row for row in data["findings"] if row.get("differs")]:
print(f"✅ {t('No view differs from its module source.')}")
elif not analyse.open_tui(data):
print(analyse.render(data, verbose=True, hints=False))
lst_choice = [{"prompt_description": t("Show every view")}]
if can_compare:
lst_choice += [
{
"prompt_description": t(
"Compare the flagged views with the module source"
)
},
{
"prompt_description": t(
"Compare every view (slower, noisier)"
)
},
{"prompt_description": t("Browse the differences (TUI)")},
]
lst_choice.append({"prompt_description": t("Export as JSON")})
def handler(rank):
data = state["data"]
if rank == 1:
print(analyse.render(data, verbose=True, hints=False))
elif not can_compare or rank == len(lst_choice):
self._analyse_export_json(
data, os.path.basename(database), "view_custom"
)
elif rank == 2:
compare("flagged")
elif rank == 3:
compare("all")
elif rank == 4:
browse()
self._analyse_follow_up(lst_choice, handler)
def execute_analyse_custom_field(self):
"""Champs et modèles ajoutés hors module : Studio, ou faits à la main.
Deux provenances, parce que la plus utile est souvent la sauvegarde :
restaurer celle d'une instance Enterprise sur une installation
Community échoue — Odoo veut charger des modules qu'on n'a pas — alors
que les champs Studio ne sont que des lignes de `ir_model_fields`, et
qu'un dump.sql est du texte.
"""
from script.analyse import analyse_custom_field as analyse
target = self._analyse_select_source()
if not target:
return
is_backup, database = target
try:
data = (
analyse.collect_from_backup(database)
if is_backup
else analyse.collect(database)
)
except Exception as exc:
print(f"❌ {t('Analysis failed: ')}{exc}")
return
print(analyse.render(data, hints=False))
if not data["fields"] and not data["models"]:
return
def handler(rank):
if rank == 1:
print(analyse.render(data, verbose=True, hints=False))
else:
self._analyse_export_json(
data, os.path.basename(database), "custom_field"
)
self._analyse_follow_up(
[
{"prompt_description": t("Show every field")},
{"prompt_description": t("Export as JSON")},
],
handler,
)
def prompt_execute_process(self):
print(f"🤖 {t('Manage execution processes!')}")
choices = [
@ -6669,6 +7229,8 @@ class TODO:
{"prompt_description": t("Test a module")},
{"prompt_description": t("Test a module with code coverage")},
{"prompt_description": t("ERPLibre unit tests")},
{"prompt_description": t("Mail unit tests")},
{"prompt_description": t("Analyse unit tests")},
]
help_info = self.fill_help_info(choices)
@ -6683,6 +7245,10 @@ class TODO:
self.execute_test_module(coverage=True)
elif status == "3":
self.execute_unit_tests()
elif status == "4":
self.execute_unit_tests("test_mail*.py")
elif status == "5":
self.execute_unit_tests("test_analyse*.py")
else:
print(t("Command not found !"))
@ -6775,11 +7341,24 @@ class TODO:
single_source_erplibre=True,
)
def execute_unit_tests(self):
def execute_unit_tests(self, pattern="test_*.py"):
"""Lance `unittest discover` sur un SOUS-ENSEMBLE de la suite.
Le motif est le seul paramètre : la suite complète dure plusieurs
minutes, dominées par les tests TUI montés, et attendre tout pour
vérifier un coin précis décourage de lancer les tests du tout. Une
entrée de menu supplémentaire coûte donc un motif, pas une méthode.
"""
print(f"\n--- {t('Running unit tests')} ---")
# `-u` : unittest écrit son verdict sur STDERR, les `print()` des
# tests sur STDOUT. Capturés ensemble, stderr passe sans tampon
# tandis que stdout est tamponné par blocs — tout le stdout se
# déversait donc APRÈS le « OK », qui se retrouvait noyé au milieu
# de la sortie au lieu d'en être le dernier mot. Sans tampon, les
# deux flux s'entrelacent dans l'ordre réel.
cmd = (
".venv.erplibre/bin/python -m unittest discover"
" -s test -p 'test_*.py' -v"
".venv.erplibre/bin/python -u -m unittest discover"
f" -s test -p '{pattern}' -v"
)
status_code, output = self.execute.exec_command_live(
cmd,
@ -7297,7 +7876,11 @@ if __name__ == "__main__":
if ENABLE_CRASH:
todo.crash_diagnostic(CRASH_E)
todo.run()
except KeyboardInterrupt:
except (KeyboardInterrupt, click.exceptions.Abort):
# click.prompt() raises Abort (not a KeyboardInterrupt subclass) on
# both Ctrl+C and Ctrl+D/EOF. run() only catches it for its own
# top-level prompt; every submenu's click.prompt() would otherwise
# let Abort escape here as an uncaught exception.
print(t("Keyboard interrupt"))
finally:
end_time = time.time()

View file

@ -61,21 +61,42 @@ class FileBrowser(urwid.WidgetWrap):
self.refresh_list()
def select_directory(self, button):
"""Selects a file and calls the callback function."""
"""Selects a directory, and closes the browser."""
self.callback(self.current_path)
exit_program()
def select_file(self, button):
"""Selects a file and calls the callback function."""
"""Selects a file, and closes the browser.
The callback records the choice; leaving the loop is what ENDS the
browser. Without it the selection worked and nothing seemed to happen:
the screen stayed up, no key closed it, and only Ctrl+C got out — so
the browser looked frozen at the exact moment it had done its job.
"""
filename = button.label
selected_file_path = os.path.join(self.current_path, filename)
self.callback(selected_file_path)
exit_program()
def unhandled_input(self, key):
"""Leaving without choosing has to be possible.
Every other way out of this browser selects something. A caller that
offers an alternative — typing a path — can only be reached by
cancelling, so cancelling has to exist.
"""
if key in ("q", "Q", "esc"):
exit_program()
def run_main_frame(self):
main_frame = urwid.Frame(
body=self,
header=urwid.Text(("header", f"Navigate: {self.current_path}")),
footer=urwid.Text(
("footer", "Use arrow keys to navigate and Enter to select.")
(
"footer",
"Arrow keys to navigate, Enter to select, q to cancel.",
)
),
)
@ -88,7 +109,9 @@ class FileBrowser(urwid.WidgetWrap):
("bold", "bold", "black"),
]
loop = urwid.MainLoop(main_frame, palette)
loop = urwid.MainLoop(
main_frame, palette, unhandled_input=self.unhandled_input
)
loop.run()

File diff suppressed because it is too large Load diff

View file

@ -30,6 +30,26 @@ DEFAULTS = {
"qemu_deploy_progress": "cli",
# Interface de la migration Odoo : "ask" / "tui" / "cli".
"migration_ui": "ask",
# Cache courriel : mode par DÉFAUT. Un compte peut le surcharger via
# sa clé `cache_mode` dans accounts.json ; `null` là-bas veut dire
# « hérite d'ici ». Valeurs : clear | encrypted | ephemeral.
"mail_cache_mode": "clear",
# Rafraîchissement automatique des boîtes, en secondes, ACTIF seulement
# tant que le TUI courriel est à l'écran. 0 désactive.
"mail_refresh_sec": 300,
# Disposition des volets du client courriel (touche `v`). Voir
# `script.todo.mail.tui.MAIL_LAYOUTS` pour les valeurs valides ;
# `resolve_layout` y retombe sur "columns" si la valeur stockée n'en fait
# plus partie.
"mail_layout": "columns",
# Tailles personnalisées des volets (`+`/`-`/`0`, et la souris de la
# tâche suivante), UNE entrée PAR disposition : {"<layout>": {"folders":
# <cellules>, "list_pane": <cellules>}}. Une disposition ou un volet
# absent de ce dictionnaire veut dire « pas encore personnalisé » — la
# feuille de style de la disposition décide seule. Voir
# `script.todo.mail.tui.resolve_pane_sizes`, qui retombe sur {} pour
# toute valeur absente ou corrompue.
"mail_pane_sizes": {},
}

File diff suppressed because it is too large Load diff

832
test/mail_sandbox.py Normal file
View file

@ -0,0 +1,832 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Un vrai serveur IMAP et un vrai serveur SMTP, jetables, pour les tests.
Pourquoi : tous les autres tests courriel passent par un double
(`FakeImapTransport`, `MagicMock`). Un double ne produit que ce qu'on avait
imaginé en l'écrivant — c'est précisément par là que des bugs de protocole
sont passés jusqu'à l'utilisateur. Ce module ouvre de VRAIES sockets sur
127.0.0.1 pour que le client soit exercé sur du vrai TCP.
L'intérêt n'est pas la conformité : un serveur poli ne prouve pas grand-chose.
L'intérêt est de pouvoir SE CONDUIRE MAL à la demande — servir un en-tête en
octets 8 bits, un charset `unknown-8bit`, une connexion coupée en plein FETCH.
Ajouter une méchanceté doit rester une petite addition (une sous-classe de
`Fault`, ou de simples octets déclarés par le test), jamais un nouveau serveur.
## Le réacteur Twisted ne se redémarre pas
`reactor.run()` ne peut être appelé qu'UNE fois par processus ; après
`reactor.stop()` il refuse de repartir. `unittest` enchaîne les tests dans un
seul processus : « un réacteur par test » échouerait dès le deuxième test, et
la panne ressemble à un blocage, pas à une erreur claire.
D'où le choix ici : UN seul réacteur, démarré à la demande dans un fil de
fond, et JAMAIS arrêté avant la fin du processus. Un test n'ouvre et ne ferme
qu'un port d'écoute (`reactor.listenTCP` / `port.stopListening`). L'état
propre par test ne vient donc pas du réacteur — il vient des objets : chaque
test construit ses propres boîtes et ses propres messages, et rien n'est
partagé entre deux tests. Les tests passent donc dans n'importe quel ordre et
un par un.
`aiosmtpd.Controller` porte sa propre boucle asyncio dans un fil et n'a pas ce
problème ; il ne sait en revanche pas se lier au port 0 tel quel, voir
`SmtpSandbox`.
## Sécurité
Rien ici ne sort de la machine : on se lie à 127.0.0.1 sur le port 0 (l'OS
choisit), jamais sur un port fixe qui entrerait en collision avec ce qui
écoute déjà. Aucun trousseau, aucun `~/.erplibre`, aucun identifiant réel.
"""
from __future__ import annotations
import atexit
import email
import logging
import re
import socket
import threading
import unittest
import warnings
from dataclasses import dataclass, field
from io import BytesIO
from twisted.cred import checkers, portal
from twisted.internet import protocol
from twisted.internet.threads import blockingCallFromThread
from twisted.mail import imap4
from zope.interface import implementer
from script.todo.mail.accounts import Account, ServerConf
REACTOR_START_TIMEOUT = 10
SERVER_STOP_TIMEOUT = 10
USER = "moi"
PASSWORD = "secret"
# Tous les bacs à sable actuellement à l'écoute. Sert de preuve de non-fuite :
# à la fin d'un test l'ensemble doit être revenu à ce qu'il était (voir
# `TestSandboxLifecycle` dans `test_mail_live_server.py`).
LIVE_SERVERS: set = set()
# --------------------------------------------------------------------------
# Le réacteur, un seul, dans un fil de fond
# --------------------------------------------------------------------------
_reactor_lock = threading.Lock()
_reactor_thread: threading.Thread | None = None
def reactor_in_thread():
"""Le réacteur global, en marche dans un fil de fond.
Idempotent : le premier appel le démarre, les suivants le retrouvent. On
ne l'arrête qu'à la sortie du processus (`atexit`), parce qu'un réacteur
arrêté ne repart jamais.
"""
global _reactor_thread
from twisted.internet import reactor
with _reactor_lock:
if _reactor_thread is None:
running = threading.Event()
reactor.callWhenRunning(running.set)
_reactor_thread = threading.Thread(
target=reactor.run,
kwargs={"installSignalHandlers": False},
name="mail-sandbox-reactor",
daemon=True,
)
_reactor_thread.start()
if not running.wait(REACTOR_START_TIMEOUT):
raise RuntimeError(
"le réacteur Twisted n'a pas démarré en"
f" {REACTOR_START_TIMEOUT}s"
)
atexit.register(_stop_reactor)
return reactor
def _stop_reactor() -> None:
"""Arrêt de fin de processus. Le fil est `daemon` : même si le réacteur
reste coincé, il n'empêchera pas Python de sortir."""
global _reactor_thread
from twisted.internet import reactor
thread, _reactor_thread = _reactor_thread, None
if thread is None:
return
try:
reactor.callFromThread(reactor.stop)
except Exception:
# Le réacteur peut déjà être mort : la sortie du processus ne doit
# jamais échouer là-dessus.
return
thread.join(SERVER_STOP_TIMEOUT)
# --------------------------------------------------------------------------
# Les méchancetés
# --------------------------------------------------------------------------
@dataclass
class Fault:
"""Une panne serveur déclenchée par une commande cliente.
Ajouter une méchanceté = une sous-classe de trois lignes. `command` dit
sur quelle commande elle se déclenche (`b"FETCH"`, `b"SELECT"`...),
`after` combien d'occurrences on laisse passer avant de frapper — c'est
ce qui permet de couper la connexion au milieu d'une passe plutôt qu'à
son premier mot — et `strike()` fait le mal.
Le déclenchement est compté, pas chronométré : aucun test ne dépend
d'une durée, donc aucun ne devient instable sur une machine chargée.
"""
command: bytes
after: int = 0
fired: int = field(default=0, init=False)
def matches(self, command: bytes) -> bool:
if command.upper() != self.command.upper():
return False
self.fired += 1
return self.fired > self.after
def strike(self, server, tag) -> None:
raise NotImplementedError
@dataclass
class DropConnection(Fault):
"""Le serveur raccroche sans un mot, la commande restée sans réponse.
C'est la panne réseau ordinaire — coupure Wi-Fi, pare-feu, serveur qui
redémarre — et celle qu'aucun double n'a jamais produite, puisqu'un
double répond toujours.
"""
def strike(self, server, tag) -> None:
server.transport.abortConnection()
@dataclass
class RefuseCommand(Fault):
"""Le serveur répond NO. Un dossier qu'on n'a pas le droit de lire, un
quota dépassé : le client doit continuer sur les autres dossiers."""
text: bytes = b"Sandbox refuses this command"
def strike(self, server, tag) -> None:
server.sendNegativeResponse(tag, self.text)
# --------------------------------------------------------------------------
# Le contenu servi : des octets, tels que le test les déclare
# --------------------------------------------------------------------------
def _as_text(value) -> str:
return (
value.decode("ascii", "replace") if isinstance(value, bytes) else value
)
def _as_bytes(value) -> bytes:
return (
value.encode("ascii", "replace") if isinstance(value, str) else value
)
def _split_header_lines(raw: bytes) -> list[bytes]:
"""Les lignes d'en-tête de `raw`, repliements compris, terminées en CRLF.
On accepte le LF seul en entrée : c'est ce que rend `as_bytes()`, donc ce
que le client dépose vraiment par APPEND. Découper sur le seul CRLF
rendrait alors le message ENTIER comme un unique en-tête, sans rien lever.
Le terminateur rendu, lui, est toujours CRLF — c'est le format du fil.
"""
head = re.split(rb"\r?\n\r?\n", raw, maxsplit=1)[0]
lines: list[bytes] = []
for line in re.split(rb"\r?\n", head):
if not line:
continue
if line[:1] in (b" ", b"\t") and lines:
lines[-1] += b"\r\n" + line
else:
lines.append(line)
return [line + b"\r\n" for line in lines]
@implementer(imap4.IMessage, imap4.IMessageFile)
class SandboxMessage:
"""Un message servi VERBATIM, tel que le test l'a écrit.
`IMessageFile` (une seule méthode, `open()`) est ce qui rend possible de
servir des octets hostiles : sur un FETCH du message entier, Twisted
recopie ce flux tel quel au lieu de repasser par son chemin MIME, qui
finit en `networkString()` → `.encode("ascii")` et refuserait tout octet
8 bits.
"""
def __init__(self, uid: int, raw: bytes, flags=(), internal_date=None):
self.uid = uid
self.raw = raw
self.flags = list(flags)
self.internal_date = internal_date or b"06-Aug-2026 10:00:00 +0000"
self.parsed = email.message_from_bytes(raw)
# -- IMessagePart / IMessage ----------------------------------------
def getUID(self) -> int:
return self.uid
def getFlags(self) -> list:
return list(self.flags)
def getInternalDate(self) -> bytes:
return self.internal_date
def getHeaders(self, negate, *names) -> dict:
"""Les en-têtes en `str` — ce que Twisted attend (il fait
`v.splitlines()`, donc surtout pas un `email.header.Header`).
Les NOMS demandés, eux, arrivent en OCTETS depuis le serveur
(`IMAP4Server.spew_body` passe `part.header.fields`), alors que les
recherches internes (`search_SUBJECT`...) les passent en `str`. Une
comparaison sur un seul des deux types rend un dictionnaire vide —
sans erreur, et donc sans rien pour la faire remarquer : le client ne
voit qu'un message sans sujet ni date.
"""
wanted = {_as_text(n).upper() for n in names}
return {
key: str(value)
for key, value in self.parsed.items()
if (
(key.upper() not in wanted)
if negate
else (key.upper() in wanted)
)
}
def raw_header_block(self, negate, fields) -> bytes:
"""Les mêmes en-têtes, mais en OCTETS bruts.
Twisted ne sait pas les servir : `_formatHeaders` finit par
`networkString()`, donc `.encode("ascii")`, et lève sur le moindre
octet 8 bits. Or c'est exactement ce qu'un vrai serveur nous a
envoyé le jour du bug. `SandboxIMAP4Server.spew_body` bascule ici
quand le bloc n'est pas ASCII (voir sa docstring).
"""
wanted = {_as_bytes(f).upper() for f in fields}
out = []
for line in _split_header_lines(self.raw):
name = line.split(b":", 1)[0].strip().upper()
if (name not in wanted) if negate else (name in wanted):
out.append(line)
return b"".join(out) + b"\r\n"
def open(self):
return BytesIO(self.raw)
def getBodyFile(self):
parts = re.split(rb"\r?\n\r?\n", self.raw, maxsplit=1)
return BytesIO(parts[1] if len(parts) > 1 else b"")
def getSize(self) -> int:
return len(self.raw)
def isMultipart(self) -> bool:
return self.parsed.is_multipart()
def getSubPart(self, part):
raise TypeError("le bac à sable ne sert pas de sous-partie")
@implementer(imap4.IMailbox, imap4.IMailboxInfo)
class SandboxMailbox:
def __init__(self, name: str, uidvalidity: int = 42):
self.name = name
self.uidvalidity = uidvalidity
self.messages: list[SandboxMessage] = []
self.listeners: list = []
self.appended: list[tuple[bytes, tuple]] = []
# -- écriture par le test -------------------------------------------
def deliver(self, raw: bytes, flags=(), uid: int | None = None):
message = SandboxMessage(
uid if uid is not None else self.getUIDNext(), raw, flags
)
self.messages.append(message)
return message
def _max_uid(self) -> int:
return max((m.uid for m in self.messages), default=0)
# -- IMailbox --------------------------------------------------------
def getFlags(self) -> list:
return ["\\Seen", "\\Answered", "\\Flagged", "\\Deleted", "\\Draft"]
def getUIDValidity(self) -> int:
return self.uidvalidity
def getUIDNext(self) -> int:
return self._max_uid() + 1
def getUID(self, message: int) -> int:
return self.messages[message - 1].uid
def getMessageCount(self) -> int:
return len(self.messages)
def getRecentCount(self) -> int:
return 0
def getUnseenCount(self) -> int:
return sum(1 for m in self.messages if "\\Seen" not in m.flags)
def isWriteable(self) -> bool:
return True
def getHierarchicalDelimiter(self) -> str:
return "."
def requestStatus(self, names):
return imap4.statusRequestHelper(self, names)
def addListener(self, listener) -> None:
self.listeners.append(listener)
def removeListener(self, listener) -> None:
if listener in self.listeners:
self.listeners.remove(listener)
def addMessage(self, body, flags=(), date=None):
"""APPEND. Doit rendre un `Deferred` et non un entier : Twisted fait
`d.addCallback(...)` sur le résultat sans le passer par
`maybeDeferred`, et un entier y devient un « Server error encountered
while opening mailbox » — un message qui désigne le mauvais coupable.
"""
from twisted.internet import defer
raw = body.read() if hasattr(body, "read") else body
self.appended.append((raw, tuple(flags)))
self.deliver(raw, flags)
return defer.succeed(len(self.messages))
def fetch(self, messages, uid):
"""`messages` est un `MessageSet` : il faut lui donner sa borne haute
avant de l'interroger, sinon `*` ne veut rien dire."""
if uid:
messages.last = self._max_uid()
return [(m.uid, m) for m in self.messages if m.uid in messages]
messages.last = len(self.messages)
return [
(index + 1, m)
for index, m in enumerate(self.messages)
if index + 1 in messages
]
def store(self, messages, flags, mode, uid):
out = {}
for number, message in self.fetch(messages, uid):
current = set(message.flags)
if mode < 0:
current -= set(flags)
elif mode > 0:
current |= set(flags)
else:
current = set(flags)
message.flags = sorted(current)
out[number] = message.flags
return out
def expunge(self) -> list:
return []
def destroy(self) -> None:
pass
@implementer(imap4.IAccount)
class SandboxIMAPAccount:
def __init__(self):
self.boxes: dict[str, SandboxMailbox] = {}
def add(self, name: str, uidvalidity: int = 42) -> SandboxMailbox:
box = SandboxMailbox(name, uidvalidity)
self.boxes[name] = box
return box
def _key(self, path: str) -> str:
# INBOX est insensible à la casse (RFC 3501), le reste ne l'est pas.
return "INBOX" if path.upper() == "INBOX" else path
def listMailboxes(self, ref, wildcard):
return list(self.boxes.items())
def select(self, path, rw=True):
return self.boxes.get(self._key(path))
def create(self, path):
self.add(self._key(path))
return True
def delete(self, path):
self.boxes.pop(self._key(path), None)
def rename(self, old, new):
self.boxes[self._key(new)] = self.boxes.pop(self._key(old))
def isSubscribed(self, name):
return True
def subscribe(self, name):
return True
def unsubscribe(self, name):
return True
@implementer(portal.IRealm)
class _SandboxRealm:
def __init__(self, account: SandboxIMAPAccount):
self.account = account
def requestAvatar(self, avatarId, mind, *interfaces):
return imap4.IAccount, self.account, lambda: None
# --------------------------------------------------------------------------
# Le serveur IMAP
# --------------------------------------------------------------------------
class SandboxIMAP4Server(imap4.IMAP4Server):
def __init__(self, sandbox: "ImapSandbox"):
# `IMAP4Server.__init__` prend (chal, contextFactory, scheduler) et
# NON un portal : celui-ci s'affecte après coup.
super().__init__()
self.sandbox = sandbox
def connectionMade(self):
self.sandbox.connections.add(self)
super().connectionMade()
def connectionLost(self, reason):
self.sandbox.connections.discard(self)
super().connectionLost(reason)
def dispatchCommand(self, tag, cmd, rest, uid=None):
"""Le seul point où les méchancetés s'insèrent.
`UID FETCH ...` passe ici deux fois — une pour `UID`, une pour le
`FETCH` interne — ce qui permet à un `Fault` de viser précisément
l'une ou l'autre.
"""
for fault in self.sandbox.faults:
if fault.matches(cmd):
fault.strike(self, tag)
return None
return super().dispatchCommand(tag, cmd, rest, uid)
def spew_body(self, part, id, msg, _w=None, _f=None):
"""Sert les en-têtes en octets bruts quand ils ne sont pas ASCII.
Par défaut on laisse faire Twisted : le bac à sable est un serveur
POLI, et les tests doivent traverser son vrai code. Mais son
`_formatHeaders` se termine par `networkString()` — un `.encode(
"ascii")` — et lève sur le moindre octet 8 bits, que tout vrai
serveur transmet pourtant sans broncher. Dans ce seul cas on écrit
le littéral nous-mêmes, avec les octets déclarés par le test. Le
cadrage du littéral reste celui de Twisted (`imap4._literal`).
"""
block = None
if part.header is not None:
raw = getattr(msg, "raw_header_block", None)
if raw is not None:
block = raw(part.header.negate, part.header.fields)
if block is None or _is_ascii(block):
return super().spew_body(part, id, msg, _w, _f)
write = _w if _w is not None else self.transport.write
write(bytes(part) + b" " + imap4._literal(block))
return None
def _is_ascii(data: bytes) -> bool:
try:
data.decode("ascii")
except UnicodeDecodeError:
return False
return True
class _SandboxFactory(protocol.Factory):
def __init__(self, sandbox: "ImapSandbox"):
self.sandbox = sandbox
def buildProtocol(self, addr):
server = SandboxIMAP4Server(self.sandbox)
server.factory = self
server.portal = self.sandbox.portal
return server
class ImapSandbox:
"""Un serveur IMAP jetable, sur un port éphémère de 127.0.0.1.
Usage :
imap = ImapSandbox()
imap.folder("INBOX").deliver(RAW_BYTES, flags=["\\\\Seen"])
imap.fail(DropConnection(b"FETCH", after=1))
imap.start()
...
imap.stop()
`MailSandboxCase.imap_server()` fait tout cela et branche l'arrêt sur
`addCleanup`, qui s'exécute même quand le test échoue.
"""
def __init__(self):
self.account = SandboxIMAPAccount()
self.faults: list[Fault] = []
self.connections: set = set()
self.port = 0
self._listening = None
checker = checkers.InMemoryUsernamePasswordDatabaseDontUse()
checker.addUser(USER.encode(), PASSWORD.encode())
self.portal = portal.Portal(_SandboxRealm(self.account))
self.portal.registerChecker(checker)
# -- déclaration du contenu -----------------------------------------
def folder(self, name: str, uidvalidity: int = 42) -> SandboxMailbox:
return self.account.boxes.get(name) or self.account.add(
name, uidvalidity
)
def fail(self, fault: Fault) -> Fault:
self.faults.append(fault)
return fault
# -- cycle de vie -----------------------------------------------------
def start(self) -> "ImapSandbox":
reactor = reactor_in_thread()
self._listening = blockingCallFromThread(
reactor,
reactor.listenTCP,
0,
_SandboxFactory(self),
interface="127.0.0.1",
)
self.port = self._listening.getHost().port
LIVE_SERVERS.add(self)
return self
def stop(self) -> None:
"""Ferme le port ET coupe les connexions encore ouvertes.
`stopListening` seul cesse d'ACCEPTER : une session cliente restée
ouverte garderait un descripteur et un protocole vivants d'un test à
l'autre.
"""
listening, self._listening = self._listening, None
LIVE_SERVERS.discard(self)
if listening is None:
return
from twisted.internet import reactor
def close():
for server in list(self.connections):
server.transport.abortConnection()
return listening.stopListening()
blockingCallFromThread(reactor, close)
# --------------------------------------------------------------------------
# Le serveur SMTP
# --------------------------------------------------------------------------
@dataclass
class SentMessage:
"""Ce qui est VRAIMENT sorti : l'enveloppe et les octets sur le fil."""
mail_from: str
rcpt_tos: list
content: bytes
def headers(self):
return email.message_from_bytes(self.content)
class _CaptureHandler:
def __init__(self):
self.messages: list[SentMessage] = []
async def handle_DATA(self, server, session, envelope):
self.messages.append(
SentMessage(
mail_from=envelope.mail_from,
rcpt_tos=list(envelope.rcpt_tos),
content=bytes(envelope.content),
)
)
return "250 Message accepted for delivery"
class SmtpSandbox:
"""Un serveur SMTP jetable qui capture ce qu'on lui remet.
`aiosmtpd.Controller` ne sait pas se lier au port 0 : après `start()` il
rouvre une connexion de vérification vers `self.port`, qui vaut encore 0.
On relit le vrai numéro sur la socket avant cette vérification — c'est le
seul point à corriger.
"""
def __init__(self, *, require_auth: bool = False):
from aiosmtpd.controller import Controller
from aiosmtpd.smtp import AuthResult, LoginPassword
# Deux bruits d'`aiosmtpd` sans objet ici, et qui masqueraient les
# vraies pannes dans la sortie des tests : un WARNING à chaque
# authentification réussie (« Session.login_data is deprecated »), et
# un avertissement sur AUTH sans TLS — justifié en production, sans
# objet pour un serveur qu'on vient de démarrer soi-même sur la
# boucle locale. Le filtre est posé ici et non à l'import : le
# lanceur `unittest` réinitialise `warnings.filters` avant de courir.
logging.getLogger("mail.log").setLevel(logging.ERROR)
warnings.filterwarnings(
"ignore",
message="Requiring AUTH while not requiring TLS",
category=UserWarning,
)
def authenticate(server, session, envelope, mechanism, auth_data):
ok = isinstance(auth_data, LoginPassword) and (
auth_data.login == USER.encode()
and auth_data.password == PASSWORD.encode()
)
if ok:
return AuthResult(success=True, auth_data=auth_data)
# `handled` vaut True PAR DÉFAUT, et veut dire « j'ai déjà répondu
# au client moi-même ». Un simple `AuthResult(success=False)`
# laisse donc `aiosmtpd` muet : le client attend une réponse qui
# ne vient jamais et le test se bloque jusqu'au délai de la
# socket, sans rien dire de la cause.
return AuthResult(success=False, handled=False)
class _Port0Controller(Controller):
def _trigger_server(self):
if self.port == 0 and self.server is not None:
self.port = self.server.sockets[0].getsockname()[1]
super()._trigger_server()
self.handler = _CaptureHandler()
self.controller = _Port0Controller(
self.handler,
hostname="127.0.0.1",
port=0,
authenticator=authenticate,
auth_required=require_auth,
auth_require_tls=False,
)
self.port = 0
self._running = False
@property
def messages(self) -> list[SentMessage]:
return self.handler.messages
def start(self) -> "SmtpSandbox":
# Marqué vivant AVANT de démarrer : `Controller.start()` lance déjà
# son fil avant de pouvoir échouer, et le nettoyage doit passer
# derrière lui même dans ce cas-là.
self._running = True
LIVE_SERVERS.add(self)
self.controller.start()
self.port = self.controller.port
return self
def stop(self) -> None:
"""Idempotent : un test peut vouloir tuer son serveur en plein
milieu, et le nettoyage repassera derrière lui de toute façon."""
if not self._running:
return
self._running = False
LIVE_SERVERS.discard(self)
self.controller.stop(no_assert=True)
# --------------------------------------------------------------------------
# Le compte, et le socle de test
# --------------------------------------------------------------------------
def sandbox_account(
imap_port: int = 0,
smtp_port: int = 0,
*,
name: str = "bac-a-sable",
address: str = "moi@example.ca",
display_name: str = "",
sent_folder: str = "INBOX.Sent",
) -> Account:
"""Un `Account` réel pointé sur les serveurs jetables.
`security="none"` : on parle en clair sur la boucle locale, à un serveur
qu'on vient de démarrer soi-même. Rien de tout cela ne quitte la machine.
"""
return Account(
name=name,
email=address,
display_name=display_name,
preset="generic",
imap=ServerConf(
host="127.0.0.1", port=imap_port, security="none", user=USER
),
smtp=ServerConf(
host="127.0.0.1", port=smtp_port, security="none", user=USER
),
secret_ref="kdbx:ERPLibre/Mail/bac-a-sable",
cache_mode="clear",
sent_folder=sent_folder,
)
def close_imap_client(transport) -> None:
"""Ferme la socket cliente, quoi qu'il soit arrivé pendant le test.
`ImaplibTransport.logout()` est best-effort : sur une connexion déjà
morte — exactement ce que `DropConnection` produit — `imaplib.logout()`
lève avant d'atteindre son propre `shutdown()`, et le descripteur reste
ouvert jusqu'au ramasse-miettes. Acceptable dans le TUI, pas dans une
suite de tests où il s'accumulerait.
"""
transport.logout()
try:
transport.client.shutdown()
except Exception:
# Déjà fermée : c'est le cas normal quand `logout()` a réussi.
pass
def port_is_closed(port: int, timeout: float = 0.5) -> bool:
"""Vrai si plus rien n'écoute sur ce port de la boucle locale."""
with socket.socket() as probe:
probe.settimeout(timeout)
return probe.connect_ex(("127.0.0.1", port)) != 0
class MailSandboxCase(unittest.TestCase):
"""Le socle : tout serveur démarré ici meurt avec le test.
L'arrêt passe par `addCleanup`, enregistré AVANT le démarrage :
`unittest` l'exécute quel que soit le sort du test — succès, échec ou
erreur — et même si `start()` lève à mi-chemin. Une socket d'écoute
oubliée ou un fil coincé empoisonneraient toute la suite.
"""
def imap_server(self) -> ImapSandbox:
sandbox = ImapSandbox()
self.addCleanup(sandbox.stop)
return sandbox.start()
def smtp_server(self, **kwargs) -> SmtpSandbox:
sandbox = SmtpSandbox(**kwargs)
self.addCleanup(sandbox.stop)
return sandbox.start()
def temp_store(self, account):
"""Un cache SQLite dans un dossier temporaire, jamais le vrai."""
import tempfile
from pathlib import Path
from script.todo.mail.store import Store
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
store = Store(account, mode="clear", base=Path(tmp.name))
self.addCleanup(store.close)
store.open()
return store
def imap_transport(self, sandbox: ImapSandbox, account=None):
"""Le VRAI client (`imap_transport.connect`), branché sur le bac à
sable — connexion et LOGIN compris."""
from script.todo.mail import imap_transport
account = account or sandbox_account(imap_port=sandbox.port)
transport = imap_transport.connect(account, PASSWORD)
self.addCleanup(close_imap_client, transport)
return transport

View file

@ -0,0 +1,212 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Champs hors module : l'attribution et le rendu, sans base.
Ce qui décide d'un blocage vit dans `collect()`, qui a besoin de PostgreSQL.
Il s'éprouve sur une base synthétique portant les cinq cas qui comptent :
* un champ Studio dont la colonne existe — rien à signaler ;
* un champ fait main SANS colonne — bloquant, le registre ne chargerait pas ;
* un one2many non stocké — PAS un bloquant, il n'a jamais de colonne ;
* un champ sur `ir.actions.act_window`, dont la table est `ir_act_window` —
`replace('.', '_')` crierait à la colonne manquante ;
* un many2one vers un modèle absent — bloquant.
Attendu : 6 champs, 2 bloquants, 1 modèle manuel. Rejoué ensuite sur la forme
12.0 — `company_dependent` retirée, `field_description` en text, pas de
`ir_model_fields_selection` — pour la même sortie.
"""
import unittest
from script.analyse import analyse_custom_field as A
from script.todo import todo_i18n
def field(**override):
row = {
"id": 1,
"model": "res.partner",
"name": "x_champ",
"ttype": "char",
"relation": None,
"store": True,
"xmlid_modules": None,
"origin": "handmade",
"blocker": None,
}
row.update(override)
return row
class TestFieldOrigin(unittest.TestCase):
def test_no_xmlid_at_all_means_hand_made(self):
# Un champ créé en mode développeur n'a AUCUNE ligne ir_model_data.
# C'est ce qui le distingue, pas son nom : il s'appelle « x_… » lui
# aussi.
self.assertEqual(A.field_origin(field(xmlid_modules=None)), "handmade")
self.assertEqual(A.field_origin(field(xmlid_modules=[])), "handmade")
def test_studio_module_means_studio(self):
self.assertEqual(
A.field_origin(field(xmlid_modules=["studio_customization"])),
"studio",
)
def test_studio_seen_among_several_xmlids(self):
# Un champ peut porter plusieurs identifiants externes. Une jointure
# plate n'en rendrait qu'un, choisi au hasard : Studio passerait
# inaperçu une fois sur deux. D'où l'agrégat côté SQL.
self.assertEqual(
A.field_origin(
field(xmlid_modules=["aaa", "studio_customization"])
),
"studio",
)
def test_a_module_xmlid_means_a_module(self):
self.assertEqual(
A.field_origin(field(xmlid_modules=["mon_module"])), "module"
)
def test_the_x_studio_prefix_is_never_the_only_signal(self):
# Le préfixe ressemble à Studio mais ne prouve rien : sans identifiant
# externe, le champ a été fait à la main.
self.assertEqual(
A.field_origin(field(name="x_studio_faux", xmlid_modules=None)),
"handmade",
)
class TestLabels(unittest.TestCase):
def setUp(self):
# PAS `set_lang()` : il PERSISTE la langue dans ./env_var.sh, un
# fichier suivi par git. Un test qui l'appelle modifie l'arbre de
# travail et laisse la langue changée pour tout ce qui suit —
# `_current_lang = None` ne défait que la mémoïsation, pas le
# fichier, et la résolution suivante relit celui-ci. On écrit donc
# la mémoïsation directement, et on rend la valeur trouvée.
self.addCleanup(
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
)
todo_i18n._current_lang = "en"
def test_every_origin_has_a_label(self):
for name in ("studio", "handmade", "module"):
self.assertNotEqual(A.origin_label(name), name)
def test_every_blocker_has_a_label(self):
for name in (
"missing_column",
"dangling_relation",
"model_gone",
"table_unknown",
):
self.assertNotEqual(A.blocker_label(name), name)
def test_unknown_key_falls_back_to_itself(self):
self.assertEqual(A.origin_label("inconnu"), "inconnu")
class TestNoColumnTypes(unittest.TestCase):
def test_to_many_fields_have_no_column_by_design(self):
# Le faux positif à ne pas produire : un one2many n'a jamais de
# colonne, il vit dans une table de relation. Le compter manquant
# ferait un bloquant sur chaque relation d'une base ordinaire.
self.assertIn("one2many", A.NO_COLUMN_TYPES)
self.assertIn("many2many", A.NO_COLUMN_TYPES)
self.assertNotIn("char", A.NO_COLUMN_TYPES)
self.assertNotIn("many2one", A.NO_COLUMN_TYPES)
class TestRender(unittest.TestCase):
def setUp(self):
# PAS `set_lang()` : il PERSISTE la langue dans ./env_var.sh, un
# fichier suivi par git. Un test qui l'appelle modifie l'arbre de
# travail et laisse la langue changée pour tout ce qui suit —
# `_current_lang = None` ne défait que la mémoïsation, pas le
# fichier, et la résolution suivante relit celui-ci. On écrit donc
# la mémoïsation directement, et on rend la valeur trouvée.
self.addCleanup(
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
)
todo_i18n._current_lang = "en"
def data(self, **override):
fields = [
field(id=1, name="x_studio_code", origin="studio"),
field(
id=2,
name="x_fait_main",
origin="handmade",
blocker="missing_column",
),
]
data = {
"tool": "analyse_custom_field",
"version": 1,
"database": "prod_18",
"odoo_version": "18.0.1.3",
"n_fields": 2,
"n_models": 1,
"counts": {
"studio": 1,
"handmade": 1,
"module": 0,
"blockers": 1,
"models": 1,
},
"fields": fields,
"models": [{"model": "x_contrat", "description": "Contrat"}],
"blockers": [fields[1]],
}
data.update(override)
return data
def test_blockers_come_first_and_say_why(self):
out = A.render(self.data())
self.assertIn("Blocking (1)", out)
self.assertIn("stored, but its column is missing", out)
self.assertLess(out.index("Blocking"), out.index("To carry over"))
def test_says_the_registry_will_not_load(self):
self.assertIn("stops the registry from loading", A.render(self.data()))
def test_no_blocker_block_when_there_is_none(self):
data = self.data(blockers=[])
data["counts"]["blockers"] = 0
self.assertNotIn("Blocking", A.render(data))
def test_counts_say_how_many_of_the_list_are_blocking(self):
# Les bloquants sont AUSSI dans la liste à reporter : le dire, sinon
# deux populations dont les comptes ne s'additionnent pas.
self.assertIn("(2, 1 blocking)", A.render(self.data()))
def test_clean_database_says_so(self):
data = self.data(
n_fields=0, n_models=0, fields=[], models=[], blockers=[]
)
out = A.render(data)
self.assertIn("No field or model was added outside a module.", out)
def test_says_nothing_will_recreate_them(self):
# La raison d'être de l'outil : ces champs ne sont dans aucun fichier.
self.assertIn("no module will recreate", A.render(self.data()))
def test_hint_is_absent_in_verbose_and_in_the_menu(self):
self.assertIn("Use -v", A.render(self.data()))
self.assertNotIn("Use -v", A.render(self.data(), verbose=True))
self.assertNotIn("Use -v", A.render(self.data(), hints=False))
def test_french_differs(self):
english = A.render(self.data())
todo_i18n._current_lang = "fr" # cf. setUp : pas de persistance
french = A.render(self.data())
self.assertIn("Fait à la main", french)
self.assertNotEqual(english, french)
if __name__ == "__main__":
unittest.main()

522
test/test_analyse_lib.py Normal file
View file

@ -0,0 +1,522 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Socle d'analyse : ce qui se teste SANS base de données.
Tout ici est une fonction pure ou une fonction dont le seul effet est de
construire du SQL ou un environnement. C'est délibéré : ces tests doivent
tourner sur une machine sans PostgreSQL, donc dans la suite du dépôt.
Ce qui exige une vraie base — `require_odoo_database`, `database_version`,
`existing_columns`, `column_types`, `public_tables` — n'est pas ici : le test
ne peut pas fabriquer sa base, puisque créer une base est une écriture et que
l'outillage est en lecture seule par construction.
Ces cinq fonctions s'éprouvent à la main sur une base synthétique, ce qui
prend une minute et n'exige aucun Odoo installé :
createdb erplibre_analyse_selftest
psql -d erplibre_analyse_selftest -c "
CREATE TABLE ir_module_module (id serial PRIMARY KEY, name varchar,
state varchar, latest_version varchar);
INSERT INTO ir_module_module (name, state, latest_version)
VALUES ('base', 'installed', '18.0.1.3');
-- arch_db en jsonb : la forme >= 16.0. Le « | » et le saut de ligne
-- sont là exprès : ils prouvent que json_query ne coupe pas dessus.
CREATE TABLE ir_ui_view (id serial PRIMARY KEY, name jsonb,
arch_db jsonb, website_id integer, arch_fs varchar);
INSERT INTO ir_ui_view (name, arch_db) VALUES
('{\"en_US\":\"Avec un | et un saut\\nde ligne\"}',
'{\"en_US\":\"<form/>\"}');
-- une colonne text : la forme <= 15.0, pour éprouver tr_col des deux côtés
CREATE TABLE res_partner (id serial PRIMARY KEY, ref text);
-- table dont le nom NE dérive PAS du modèle ir.actions.act_window
CREATE TABLE ir_act_window (id serial PRIMARY KEY, res_model varchar);"
Attendu : `database_version` rend « 18.0.1.3 » ; `column_types` distingue
`jsonb` de `text` ; `tr_col` produit `->>'en_US'` d'un côté et `::text` de
l'autre ; `model_table('ir.actions.act_window')` rend `ir_act_window` ;
`require_odoo_database` refuse une base sans `ir_module_module` ; et un
`CREATE TABLE` passé à `run_psql` est refusé par le serveur, pas par nous.
Puis `dropdb erplibre_analyse_selftest`.
"""
import os
import tempfile
import unittest
from script.analyse import lib_analyse as L
from script.todo import todo_i18n
class TestValidDatabaseName(unittest.TestCase):
def test_accepts_real_names(self):
for name in ("test", "prod_18", "client.prod", "a-b_c.1"):
self.assertTrue(L.valid_database_name(name), name)
def test_rejects_a_traceback_line(self):
# Le cas qui a motivé le contrôle : un PostgreSQL injoignable faisait
# remonter sa trace d'appel comme si c'était une liste de bases.
self.assertFalse(
L.valid_database_name("Traceback (most recent call last):")
)
def test_rejects_shell_injection(self):
for name in ("a; DROP DATABASE b", "a b", "$(id)", "a|b", "a'b"):
self.assertFalse(L.valid_database_name(name), name)
def test_rejects_empty(self):
self.assertFalse(L.valid_database_name(""))
self.assertFalse(L.valid_database_name(None))
class TestReadConfig(unittest.TestCase):
def _write(self, body):
handle = tempfile.NamedTemporaryFile(
"w", suffix=".conf", delete=False, encoding="utf-8"
)
handle.write(body)
handle.close()
self.addCleanup(os.unlink, handle.name)
return handle.name
def test_reads_the_options_section(self):
path = self._write("[options]\ndb_user = erplibre\ndb_port = 5433\n")
config = L.read_config(path)
self.assertEqual(config["db_user"], "erplibre")
self.assertEqual(config["db_port"], "5433")
def test_missing_file_is_not_an_error(self):
# Sur une installation native, psql se connecte par le socket unix sans
# aucun paramètre : l'absence de config est un cas normal.
self.assertEqual(L.read_config("/nowhere/absent.conf"), {})
def test_file_without_options_section(self):
path = self._write("[other]\nfoo = bar\n")
self.assertEqual(L.read_config(path), {})
class TestPgEnv(unittest.TestCase):
def _write(self, body):
handle = tempfile.NamedTemporaryFile(
"w", suffix=".conf", delete=False, encoding="utf-8"
)
handle.write(body)
handle.close()
self.addCleanup(os.unlink, handle.name)
return handle.name
def test_read_only_and_timeout_are_always_set(self):
env = L.pg_env("/nowhere/absent.conf", timeout=42)
self.assertIn("default_transaction_read_only=on", env["PGOPTIONS"])
self.assertIn("statement_timeout=42s", env["PGOPTIONS"])
def test_psqlrc_is_neutralised(self):
# Un ~/.psqlrc avec \timing ajoute des lignes à la sortie et casse le
# parsing JSON.
self.assertEqual(L.pg_env("/nowhere/absent.conf")["PSQLRC"], "")
def test_config_values_become_pg_variables(self):
path = self._write(
"[options]\ndb_host = pg.example.org\ndb_port = 5433\n"
"db_user = erplibre\ndb_password = s3cret\ndb_sslmode = require\n"
)
env = L.pg_env(path)
self.assertEqual(env["PGHOST"], "pg.example.org")
self.assertEqual(env["PGPORT"], "5433")
self.assertEqual(env["PGUSER"], "erplibre")
self.assertEqual(env["PGPASSWORD"], "s3cret")
self.assertEqual(env["PGSSLMODE"], "require")
def test_literal_false_means_unset(self):
# Odoo écrit « False » dans config.conf pour dire « pas de valeur ».
# L'exporter tel quel ferait chercher un hôte nommé « False ».
path = self._write(
"[options]\ndb_host = False\ndb_port = False\n"
"db_password = False\ndb_user = erplibre\n"
)
env = L.pg_env(path)
self.assertNotIn("PGHOST", env)
self.assertNotIn("PGPORT", env)
self.assertNotIn("PGPASSWORD", env)
self.assertEqual(env["PGUSER"], "erplibre")
def test_overrides_win_over_the_config(self):
path = self._write("[options]\ndb_host = pg.example.org\n")
env = L.pg_env(path, overrides={"PGHOST": "127.0.0.1"})
self.assertEqual(env["PGHOST"], "127.0.0.1")
class TestQuoteLiteral(unittest.TestCase):
def test_wraps_in_single_quotes(self):
self.assertEqual(L.quote_literal("ir_ui_view"), "'ir_ui_view'")
def test_doubles_embedded_quotes(self):
self.assertEqual(L.quote_literal("a'b"), "'a''b'")
class TestTrCol(unittest.TestCase):
"""Le fragment SQL d'un champ traduit, décidé sur le TYPE réel.
Pas sur un numéro de version : une base à moitié migrée porte les deux
formes, et un numéro de version mentirait.
"""
def test_jsonb_column_is_unpacked(self):
# La forme >= 16.0 : {"en_US": "..."}
got = L.tr_col("ir_ui_view", "arch_db", {"arch_db": "jsonb"})
self.assertEqual(got, '"ir_ui_view"."arch_db"->>\'en_US\'')
def test_text_column_is_cast(self):
# La forme <= 15.0 : du texte
got = L.tr_col("ir_ui_view", "arch_db", {"arch_db": "text"})
self.assertEqual(got, '"ir_ui_view"."arch_db"::text')
def test_character_varying_is_cast_too(self):
got = L.tr_col("ir_model", "name", {"name": "character varying"})
self.assertEqual(got, '"ir_model"."name"::text')
def test_unknown_column_yields_null_not_broken_sql(self):
# Une colonne absente doit donner un champ vide, pas une requête qui
# explose : c'est ce qui permet de sonder puis d'interroger d'un trait.
self.assertEqual(L.tr_col("ir_ui_view", "absente", {}), "NULL::text")
self.assertEqual(L.tr_col("ir_ui_view", "absente", None), "NULL::text")
def test_other_language(self):
got = L.tr_col("ir_model", "name", {"name": "jsonb"}, lang="fr_CA")
self.assertEqual(got, '"ir_model"."name"->>\'fr_CA\'')
class TestModelTable(unittest.TestCase):
def test_default_derivation(self):
self.assertEqual(L.model_table("res.partner"), "res_partner")
def test_override_is_applied(self):
# replace('.', '_') donnerait ir_actions_act_window, qui n'existe pas.
self.assertEqual(
L.model_table("ir.actions.act_window"), "ir_act_window"
)
def test_unknown_table_returns_none_not_a_guess(self):
# None veut dire « je ne sais pas », jamais « il n'y en a pas » : c'est
# ce qui empêche de classer un modèle abstrait comme une anomalie.
self.assertIsNone(
L.model_table("mail.thread", known_tables={"res_partner"})
)
def test_known_table_passes_through(self):
self.assertEqual(
L.model_table("res.partner", known_tables={"res_partner"}),
"res_partner",
)
class TestModelTableOverrideIntegrity(unittest.TestCase):
"""La table de surcharges ne doit pas accumuler d'entrées inutiles.
Beaucoup de modules déclarent un `_table` égal au défaut. Recopier une
telle déclaration ici serait du poids mort qui donne l'illusion d'une
surcharge — et c'est exactement la confusion qui a fait annoncer 22
surcharges là où il y en a 11.
"""
def test_no_entry_equals_the_default(self):
for model, table in L.MODEL_TABLE_OVERRIDE.items():
self.assertNotEqual(
table,
model.replace(".", "_"),
f"'{model}' n'est pas une surcharge : son _table est le défaut",
)
def test_tables_look_like_table_names(self):
for model, table in L.MODEL_TABLE_OVERRIDE.items():
self.assertRegex(table, r"^[a-z][a-z0-9_]*$", model)
class TestJsonQuery(unittest.TestCase):
"""L'enveloppe json_agg, sans base : on intercepte run_psql."""
def setUp(self):
self.seen = []
self.reply = "[]"
self.original = L.run_psql
def fake_run_psql(database, sql, **kwargs):
self.seen.append((database, sql))
return self.reply
L.run_psql = fake_run_psql
self.addCleanup(setattr, L, "run_psql", self.original)
def test_wraps_the_select(self):
L.json_query("db", "SELECT id FROM ir_ui_view")
_, sql = self.seen[0]
self.assertIn("json_agg(row_to_json(t))", sql)
self.assertIn("FROM (SELECT id FROM ir_ui_view) t", sql)
def test_strips_a_trailing_semicolon(self):
# Un « ; » resté dans la sous-requête produirait du SQL invalide.
L.json_query("db", "SELECT id FROM ir_ui_view;")
_, sql = self.seen[0]
self.assertNotIn(";) t", sql)
def test_empty_output_is_an_empty_list(self):
self.reply = " \n"
self.assertEqual(L.json_query("db", "SELECT 1"), [])
def test_pipes_and_newlines_survive(self):
# La raison d'être de l'enveloppe : une arch XML contient des « | » et
# des sauts de ligne, donc tout séparateur maison couperait au mauvais
# endroit.
self.reply = '[{"arch": "a | b\\nc"}]'
rows = L.json_query("db", "SELECT 1")
self.assertEqual(rows, [{"arch": "a | b\nc"}])
def test_unreadable_output_raises_analyse_error(self):
self.reply = "ERREUR: quelque chose"
with self.assertRaises(L.AnalyseError):
L.json_query("db", "SELECT 1")
class TestRunPsqlGuards(unittest.TestCase):
"""`_current_lang` est un état de MODULE, pas un état de test.
Ce test passait seul et tombait dans la suite : un autre fichier avait
laissé la langue en français, et le message n'est traduit que depuis
qu'une traduction existe. Toute assertion sur un texte affiché doit donc
fixer la langue, sinon elle dépend de l'ordre des tests.
"""
def setUp(self):
# PAS `set_lang()` : il PERSISTE la langue dans ./env_var.sh, un
# fichier suivi par git. Un test qui l'appelle modifie l'arbre de
# travail et laisse la langue changée pour tout ce qui suit —
# `_current_lang = None` ne défait que la mémoïsation, pas le
# fichier, et la résolution suivante relit celui-ci. On écrit donc
# la mémoïsation directement, et on rend la valeur trouvée.
self.addCleanup(
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
)
todo_i18n._current_lang = "en"
def test_hostile_database_name_never_reaches_psql(self):
with self.assertRaises(L.AnalyseError) as caught:
L.run_psql("a; DROP DATABASE b", "SELECT 1;")
self.assertIn("Invalid database name", str(caught.exception))
if __name__ == "__main__":
unittest.main()
class TestCanonical(unittest.TestCase):
"""Ce qui décide s'il y a un écart. Le bruit se joue ici."""
def test_indentation_is_not_a_change(self):
# Le cas mesuré sur une vraie base : une arch ré-indentée n'est pas
# une modification, et c'est l'erreur que fait `has_diff` d'Odoo —
# une comparaison de chaînes brutes — dans son propre assistant.
left = "<form><field name='a'/></form>"
right = "<form>\n <field name='a'/>\n</form>"
self.assertEqual(L.canonical(left), L.canonical(right))
self.assertEqual(L.arch_differs(left, right), (False, True))
def test_attribute_order_is_not_a_change(self):
self.assertEqual(
L.canonical("<t a='1' b='2'/>"), L.canonical("<t b='2' a='1'/>")
)
def test_comments_are_not_a_change(self):
self.assertEqual(
L.canonical("<form><!-- note --><field/></form>"),
L.canonical("<form><field/></form>"),
)
def test_an_added_field_is_a_change(self):
left = "<form><field name='a'/></form>"
right = "<form><field name='a'/><field name='x_custom'/></form>"
self.assertEqual(L.arch_differs(left, right), (True, True))
def test_changed_text_is_a_change(self):
# L'espace DANS un libellé est du contenu, pas de la mise en forme.
self.assertEqual(
L.arch_differs("<t>Total</t>", "<t>Grand total</t>"), (True, True)
)
def test_xpath_expression_spacing_is_normalised(self):
# L'espace y sépare des jetons : il se replie, il ne disparaît pas.
self.assertEqual(
L.canonical('<xpath expr="//div[1] /span"/>'),
L.canonical('<xpath expr="//div[1] /span"/>'),
)
def test_a_different_xpath_target_is_a_change(self):
self.assertNotEqual(
L.canonical('<xpath expr="//div/span"/>'),
L.canonical('<xpath expr="//div[1]/span"/>'),
)
def test_broken_xml_is_not_comparable(self):
# Ni « identique » ni « différent » : une comparaison qui n'a pas eu
# lieu ne doit pas se lire comme une comparaison sans écart.
self.assertIsNone(L.canonical("<form><field></form>"))
self.assertEqual(
L.arch_differs("<form/>", "<form><field>"), (None, False)
)
def test_empty_arch_is_not_comparable(self):
self.assertIsNone(L.canonical(""))
self.assertIsNone(L.canonical(None))
def test_jsonb_column_is_unwrapped_first(self):
self.assertEqual(
L.canonical('{"en_US": "<form/>"}'), L.canonical("<form/>")
)
class TestSideBySide(unittest.TestCase):
def test_identical_lines_are_aligned(self):
rows = L.side_by_side("a\nb", "a\nb")
self.assertEqual([mark for mark, _, _ in rows], [" ", " "])
def test_insert_has_no_left_side(self):
rows = L.side_by_side("a", "a\nb")
self.assertEqual(rows[-1], ("+", None, "b"))
def test_delete_has_no_right_side(self):
rows = L.side_by_side("a\nb", "a")
self.assertEqual(rows[-1], ("-", "b", None))
def test_replace_keeps_both_sides_on_one_row(self):
# Le point de l'affichage côte à côte : les deux versions d'une même
# ligne se lisent l'une en face de l'autre, sans rien à synchroniser.
rows = L.side_by_side("a", "b")
self.assertEqual(rows, [("≠", "a", "b")])
def test_stats_count_each_kind(self):
rows = L.side_by_side("a\nb\nc", "a\nB\nc\nd")
self.assertEqual(
L.diff_stats(rows), {"added": 1, "removed": 0, "changed": 1}
)
class TestUnescapeCopy(unittest.TestCase):
r"""Les valeurs d'un bloc COPY d'un dump.sql.
C'est ce qui permet de lire une sauvegarde sans la restaurer. Une erreur
ici corrompt silencieusement une valeur — un « \n » laissé littéral dans
une aide de champ, un NULL pris pour la chaîne « \N ».
"""
def test_backslash_n_is_null_not_a_string(self):
self.assertIsNone(L.unescape_copy("\\N"))
# Mais « \N » AU MILIEU d'une valeur reste du texte.
self.assertEqual(L.unescape_copy("a\\Nb"), "aNb")
def test_plain_value_passes_through(self):
self.assertEqual(L.unescape_copy("x_studio_code"), "x_studio_code")
self.assertEqual(L.unescape_copy(""), "")
def test_newline_and_tab_are_restored(self):
# Une aide de champ multi-lignes arrive échappée : la laisser telle
# quelle mettrait « \n » littéral dans le rapport.
self.assertEqual(L.unescape_copy("a\\nb"), "a\nb")
self.assertEqual(L.unescape_copy("a\\tb"), "a\tb")
self.assertEqual(L.unescape_copy("a\\rb"), "a\rb")
def test_escaped_backslash(self):
self.assertEqual(L.unescape_copy("a\\\\b"), "a\\b")
def test_unknown_escape_keeps_the_character(self):
self.assertEqual(L.unescape_copy("a\\qb"), "aqb")
def test_trailing_backslash_is_not_an_index_error(self):
self.assertEqual(L.unescape_copy("a\\"), "a\\")
class TestNotAColumn(unittest.TestCase):
def test_constraint_lines_are_not_columns(self):
# Un CREATE TABLE mêle colonnes et contraintes ; prendre le premier
# mot d'une ligne CONSTRAINT donnerait une colonne qui n'existe pas,
# et un champ x_ passerait pour ayant sa colonne.
for word in ("CONSTRAINT", "PRIMARY", "CHECK", "FOREIGN", "UNIQUE"):
self.assertTrue(f"{word} foo".upper().startswith(L.NOT_A_COLUMN))
self.assertFalse(
"name character varying".upper().startswith(L.NOT_A_COLUMN)
)
class TestBackupWithoutManifest(unittest.TestCase):
"""Une sauvegarde odoo.sh n'a pas de manifest.json.
Refuser le fichier pour cela revenait à refuser d'analyser précisément
les bases qu'on ne peut pas restaurer — ce qui est tout l'intérêt de lire
un zip. Le manifeste ne portait qu'une commodité, la version, et la base
la dit elle-même dans ir_module_module.
"""
def _zip(self, entries):
import zipfile
handle = tempfile.NamedTemporaryFile(suffix=".zip", delete=False)
handle.close()
self.addCleanup(os.unlink, handle.name)
with zipfile.ZipFile(handle.name, "w") as archive:
for name, body in entries.items():
archive.writestr(name, body)
return handle.name
DUMP = (
"CREATE TABLE public.ir_module_module (\n"
" id integer NOT NULL,\n"
" name character varying\n"
");\n"
"COPY public.ir_module_module (id, name, latest_version) "
"FROM stdin;\n"
"1\tbase\tsaas~19.2.1.3\n"
"\\.\n"
)
def test_missing_manifest_is_not_an_error(self):
path = self._zip({"dump.sql": self.DUMP})
self.assertEqual(L.backup_manifest(path), {})
def test_unreadable_manifest_does_not_block_the_dump(self):
path = self._zip({"dump.sql": self.DUMP, "manifest.json": "{ nope"})
self.assertEqual(L.backup_manifest(path), {})
_, rows, _, _ = L.read_backup(path, tables=("ir_module_module",))
self.assertEqual(len(rows["ir_module_module"]), 1)
def test_a_missing_dump_is_still_refused(self):
# Le dump est la seule pièce indispensable : c'est lui qui porte les
# données. Son absence reste une vraie erreur.
path = self._zip({"manifest.json": "{}"})
with self.assertRaises(L.AnalyseError):
L.read_backup(path, tables=("ir_model",))
def test_dump_is_found_under_a_directory(self):
path = self._zip({"sauvegarde/dump.sql": self.DUMP})
_, rows, _, _ = L.read_backup(path, tables=("ir_module_module",))
self.assertEqual(len(rows["ir_module_module"]), 1)
def test_version_comes_from_the_dump(self):
path = self._zip({"dump.sql": self.DUMP})
_, rows, _, _ = L.read_backup(path, tables=("ir_module_module",))
self.assertEqual(L.backup_version(rows), "saas~19.2.1.3")
def test_the_dump_wins_over_the_manifest(self):
# Le manifeste est écrit à côté de la base ; la base, elle, se
# décrit elle-même. En cas de désaccord, on croit la base.
path = self._zip(
{"dump.sql": self.DUMP, "manifest.json": '{"version": "12.0"}'}
)
manifest, rows, _, _ = L.read_backup(
path, tables=("ir_module_module",)
)
self.assertEqual(manifest.get("version"), "12.0")
self.assertEqual(L.backup_version(rows, manifest), "saas~19.2.1.3")
def test_manifest_is_the_fallback_when_the_dump_says_nothing(self):
self.assertEqual(L.backup_version({}, {"version": "16.0"}), "16.0")
self.assertIsNone(L.backup_version({}, {}))

View file

@ -0,0 +1,225 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Poids du schéma : ce qui se teste sans base.
Le classement d'une table (modèle / m2m / système / orpheline) vit dans
`collect()`, qui interroge PostgreSQL. Il s'éprouve sur la base synthétique
décrite dans le docstring de `test_analyse_lib.py`, augmentée des quatre cas
qui comptent : une table m2m sans ligne `ir_model` mais listée dans
`ir_model_relation`, un modèle à `_table` surchargé, un modèle abstrait, et
une vraie orpheline. Attendu : une seule orpheline, la vraie.
Ici, les fonctions pures — mise en forme et rendu. `render()` est une
fonction de la donnée vers le texte, donc tout le rapport se teste sur un
dictionnaire écrit à la main.
"""
import unittest
from script.analyse import analyse_schema_size as A
from script.todo import todo_i18n
def fixture(**override):
"""Un résultat de collect() minimal, que chaque test tord à sa guise."""
data = {
"tool": "analyse_schema_size",
"version": 1,
"database": "prod_18",
"odoo_version": "18.0.1.3",
"db_bytes": 12884901888,
"exact": False,
"has_relation_table": True,
"n_tables": 3,
"n_models": 4,
"tables": [
{
"table_name": "mail_message",
"total_bytes": 3221225472,
"table_bytes": 2254857830,
"index_bytes": 943718400,
"est_rows": 4183221,
"exact_rows": None,
"model": "mail.message",
"origin": "model",
},
{
"table_name": "res_groups_users_rel",
"total_bytes": 16384,
"table_bytes": 8192,
"index_bytes": 8192,
"est_rows": -1,
"exact_rows": None,
"model": None,
"origin": "m2m",
},
{
"table_name": "old_module_thing",
"total_bytes": 431916544,
"table_bytes": 400000000,
"index_bytes": 31916544,
"est_rows": 90211,
"exact_rows": None,
"model": None,
"origin": "orphan",
},
],
"orphan_tables": [],
"models_without_table": [],
"counts": {
"orphan_tables": 0,
"models_without_table": 0,
"m2m_tables": 1,
},
}
data["orphan_tables"] = [
r for r in data["tables"] if r["origin"] == "orphan"
]
data["counts"]["orphan_tables"] = len(data["orphan_tables"])
data.update(override)
return data
class TestFmtBytes(unittest.TestCase):
def test_zero_and_bytes_have_no_decimal(self):
self.assertEqual(A.fmt_bytes(0), "0 B")
self.assertEqual(A.fmt_bytes(512), "512 B")
def test_scales_to_binary_units(self):
self.assertEqual(A.fmt_bytes(1024), "1.0 KiB")
self.assertEqual(A.fmt_bytes(1024**2), "1.0 MiB")
self.assertEqual(A.fmt_bytes(1024**3), "1.0 GiB")
self.assertEqual(A.fmt_bytes(1024**4), "1.0 TiB")
def test_beyond_tebibyte_stays_in_tebibytes(self):
self.assertEqual(A.fmt_bytes(5 * 1024**4), "5.0 TiB")
def test_none_is_a_question_mark(self):
self.assertEqual(A.fmt_bytes(None), "?")
class TestFmtRows(unittest.TestCase):
def test_never_analyzed_is_not_zero(self):
# PostgreSQL >= 14 met reltuples à -1 quand aucun ANALYZE n'a tourné.
# Afficher « 0 » ferait passer une table pleine pour une table vide.
self.assertEqual(A.fmt_rows(-1), "?")
self.assertEqual(A.fmt_rows(None), "?")
def test_zero_stays_zero(self):
# Une table réellement vide et analysée doit dire 0, pas « ? ».
self.assertEqual(A.fmt_rows(0), "0")
def test_groups_thousands_with_spaces(self):
self.assertEqual(A.fmt_rows(4183221), "4 183 221")
class TestWrapNote(unittest.TestCase):
def test_short_text_is_one_line(self):
self.assertEqual(A.wrap_note(" ", "court"), [" court"])
def test_continuation_lines_align_under_the_first(self):
lines = A.wrap_note(" 💡 ", "mot " * 40)
self.assertGreater(len(lines), 1)
self.assertTrue(lines[0].startswith(" 💡 "))
for line in lines[1:]:
self.assertTrue(line.startswith(" " * len(" 💡 ")), repr(line))
def test_respects_the_width(self):
for line in A.wrap_note(" ", "mot " * 60, width=50):
self.assertLessEqual(len(line), 50, repr(line))
def test_empty_text_does_not_crash(self):
self.assertEqual(A.wrap_note(" ", ""), [" "])
class TestRender(unittest.TestCase):
"""Le rapport, sur une donnée écrite à la main.
`set_lang` est fixé : `_current_lang` est un état de module alimenté par
une préférence utilisateur, donc sans cela le test dépendrait de la langue
de la machine.
"""
def setUp(self):
# PAS `set_lang()` : il PERSISTE la langue dans ./env_var.sh, un
# fichier suivi par git. Un test qui l'appelle modifie l'arbre de
# travail et laisse la langue changée pour tout ce qui suit —
# `_current_lang = None` ne défait que la mémoïsation, pas le
# fichier, et la résolution suivante relit celui-ci. On écrit donc
# la mémoïsation directement, et on rend la valeur trouvée.
self.addCleanup(
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
)
todo_i18n._current_lang = "en"
def test_reports_the_orphan_and_not_the_m2m(self):
# Le faux positif à éviter : une table m2m n'a aucune ligne ir_model,
# et sur une base ordinaire il y en a des centaines.
out = A.render(fixture())
self.assertIn("Orphan tables (1)", out)
orphan_block = out.split("Orphan tables")[1]
self.assertIn("old_module_thing", orphan_block)
self.assertNotIn("res_groups_users_rel", orphan_block)
def test_no_orphan_says_so_plainly(self):
data = fixture(orphan_tables=[])
data["counts"]["orphan_tables"] = 0
out = A.render(data)
self.assertIn("Every table belongs to an installed model.", out)
self.assertNotIn("Orphan tables", out)
def test_models_without_table_is_a_fact_not_a_finding(self):
# Les modèles abstraits sont dans ir_model et n'ont pas de table : des
# centaines sur une base ordinaire. La ligne doit l'expliquer.
data = fixture(models_without_table=[{"model": "mail.thread"}])
data["counts"]["models_without_table"] = 1
out = A.render(data)
self.assertIn("Models without table", out)
self.assertIn("abstract models have none", out)
def test_models_without_table_line_is_absent_when_zero(self):
self.assertNotIn("Models without table", A.render(fixture()))
def test_warns_when_m2m_cannot_be_told_apart(self):
# Sans ir_model_relation, chaque table m2m passerait pour orpheline :
# le rapport doit dire qu'il n'est pas fiable, pas se taire.
out = A.render(fixture(has_relation_table=False))
self.assertIn("ir_model_relation is absent", out)
def test_estimate_warning_only_without_exact(self):
self.assertIn("estimates from the last ANALYZE", A.render(fixture()))
self.assertNotIn(
"estimates from the last ANALYZE",
A.render(fixture(exact=True)),
)
def test_top_limits_the_table_list(self):
out = A.render(fixture(), top=1)
self.assertIn("Heaviest tables (1/3)", out)
self.assertIn("use -v to list them all", out)
def test_verbose_lists_everything(self):
out = A.render(fixture(), verbose=True)
self.assertIn("All tables, heaviest first", out)
self.assertNotIn("use -v to list them all", out)
def test_never_analyzed_shows_a_question_mark(self):
out = A.render(fixture(), verbose=True)
row = [l for l in out.splitlines() if "res_groups_users_rel" in l][0]
self.assertTrue(row.rstrip().endswith("?"), repr(row))
def test_unknown_odoo_version_does_not_crash(self):
self.assertIn("Odoo ?", A.render(fixture(odoo_version=None)))
def test_french_differs_from_english(self):
english = A.render(fixture())
todo_i18n._current_lang = "fr" # cf. plus haut : pas de persistance
french = A.render(fixture())
self.assertIn("Tables orphelines", french)
self.assertNotEqual(english, french)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,270 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Vues personnalisées : le classement, sans base.
`classify()` est une fonction pure de la ligne SQL vers une catégorie. C'est
là que se joue tout ce qui distingue un constat d'un faux positif, donc c'est
là que porte l'essentiel de ces tests.
La collecte s'éprouve sur une base synthétique portant les sept catégories, y
compris deux copies COW — l'une avec sa jumelle module, l'autre sans, qui est
une page faite dans l'éditeur web. Attendu : 11 vues, 8 constats, et des
comptes dont la somme fait exactement 11.
"""
import unittest
from script.analyse import analyse_view_custom as A
from script.todo import todo_i18n
def view(**override):
"""Une ligne de vue telle que la rend la requête, tout à zéro."""
row = {
"id": 1,
"name": "Une vue",
"key": None,
"arch_fs": None,
"arch_updated": False,
"noupdate": False,
"has_arch_prev": False,
"active": True,
"website_id": None,
"theme_template_id": None,
"xmlid_modules": None,
"xmlids": None,
"has_module_twin": False,
"arch_bytes": 100,
}
row.update(override)
return row
class TestClassify(unittest.TestCase):
def test_plain_module_view(self):
row = view(arch_fs="base/views/x.xml", xmlid_modules=["base"])
self.assertEqual(A.classify(row)[0], "module_view")
def test_module_view_flagged_by_arch_updated(self):
row = view(
arch_fs="sale/views/y.xml",
xmlid_modules=["sale"],
arch_updated=True,
)
category, reasons = A.classify(row)
self.assertEqual(category, "module_view_flagged")
self.assertIn("arch_updated", reasons)
def test_noupdate_alone_is_not_a_finding(self):
"""Le faux positif à ne pas réintroduire.
Toute vue déclarée dans un bloc <odoo noupdate="1"> porte ce drapeau —
les données de mail, account et website en sont pleines — sans que
personne n'y ait touché. La compter noierait la catégorie qui compte.
"""
row = view(
arch_fs="mail/data/z.xml", xmlid_modules=["mail"], noupdate=True
)
category, reasons = A.classify(row)
self.assertEqual(category, "module_view")
self.assertNotIn(category, A.ACTIONABLE)
# L'information n'est pas perdue pour autant.
self.assertIn("noupdate", reasons)
def test_website_copy(self):
row = view(key="website.layout", website_id=1, has_module_twin=True)
category, reasons = A.classify(row)
self.assertEqual(category, "website_cow_copy")
self.assertNotIn("no_module_twin", reasons)
def test_website_copy_without_a_twin_is_a_page_from_the_editor(self):
row = view(key="website.page_1", website_id=1, has_module_twin=False)
category, reasons = A.classify(row)
self.assertEqual(category, "website_cow_copy")
self.assertIn("no_module_twin", reasons)
def test_studio(self):
row = view(xmlid_modules=["studio_customization"])
self.assertEqual(A.classify(row)[0], "studio")
def test_studio_seen_among_several_xmlids(self):
"""Une vue peut porter plusieurs identifiants externes.
Une jointure plate n'en rendrait qu'un, choisi au hasard : Studio
passerait inaperçu une fois sur deux. D'où l'agrégat côté SQL, dont
ceci vérifie que le classement sait se servir.
"""
row = view(xmlid_modules=["aaa_module", "studio_customization"])
self.assertEqual(A.classify(row)[0], "studio")
def test_imported_or_exported(self):
for module in ("__export__", "__import__", "__custom__"):
row = view(xmlid_modules=[module])
self.assertEqual(
A.classify(row)[0], "imported_or_exported", module
)
def test_created_from_the_interface(self):
self.assertEqual(A.classify(view())[0], "ui_created")
def test_theme(self):
self.assertEqual(
A.classify(view(theme_template_id=42))[0], "theme_installed"
)
def test_precedence_website_beats_studio(self):
# Une vue Studio copiée par le site web se regarde d'abord comme une
# copie : c'est ce qui décide si elle survivra à la montée de version.
row = view(
website_id=1,
has_module_twin=True,
xmlid_modules=["studio_customization"],
)
self.assertEqual(A.classify(row)[0], "website_cow_copy")
def test_precedence_theme_beats_website(self):
row = view(theme_template_id=7, website_id=1)
self.assertEqual(A.classify(row)[0], "theme_installed")
def test_reasons_accumulate(self):
row = view(
arch_fs="x.xml",
xmlid_modules=["base"],
arch_updated=True,
noupdate=True,
has_arch_prev=True,
active=False,
)
reasons = A.classify(row)[1]
self.assertEqual(
reasons, ["arch_updated", "noupdate", "has_arch_prev", "inactive"]
)
def test_the_category_is_always_a_known_one(self):
for row in (
view(),
view(theme_template_id=1),
view(website_id=1),
view(xmlid_modules=["base"], arch_fs="x.xml"),
view(xmlid_modules=["studio_customization"]),
):
self.assertIn(A.classify(row)[0], A.CATEGORIES)
class TestCategoryTables(unittest.TestCase):
"""Les trois tables de catégories doivent rester d'accord entre elles."""
def test_every_category_has_a_label(self):
# PAS `set_lang()` : il PERSISTE la langue dans ./env_var.sh, un
# fichier suivi par git. Un test qui l'appelle modifie l'arbre de
# travail et laisse la langue changée pour tout ce qui suit —
# `_current_lang = None` ne défait que la mémoïsation, pas le
# fichier, et la résolution suivante relit celui-ci. On écrit donc
# la mémoïsation directement, et on rend la valeur trouvée.
self.addCleanup(
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
)
todo_i18n._current_lang = "en"
for name in A.CATEGORIES:
self.assertNotEqual(
A.category_label(name),
name,
f"'{name}' n'a pas de libellé traduit",
)
def test_actionable_is_a_subset_of_categories(self):
self.assertEqual(set(A.ACTIONABLE) - set(A.CATEGORIES), set())
def test_plain_module_views_are_never_a_finding(self):
self.assertNotIn("module_view", A.ACTIONABLE)
class TestRender(unittest.TestCase):
def setUp(self):
# PAS `set_lang()` : il PERSISTE la langue dans ./env_var.sh, un
# fichier suivi par git. Un test qui l'appelle modifie l'arbre de
# travail et laisse la langue changée pour tout ce qui suit —
# `_current_lang = None` ne défait que la mémoïsation, pas le
# fichier, et la résolution suivante relit celui-ci. On écrit donc
# la mémoïsation directement, et on rend la valeur trouvée.
self.addCleanup(
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
)
todo_i18n._current_lang = "en"
def data(self, **override):
rows = [
view(id=2, key="sale.view_order_form", arch_updated=True),
view(id=5, key="website.layout", website_id=1),
]
for row in rows:
row["category"], row["reason"] = A.classify(row)
data = {
"tool": "analyse_view_custom",
"version": 1,
"database": "prod_18",
"odoo_version": "18.0.1.3",
"has_website": True,
"compared_with_module_source": False,
"n_views": 40,
"counts": {name: 0 for name in A.CATEGORIES},
"findings": rows,
}
data["counts"]["website_cow_copy"] = 1
data["counts"]["module_view"] = 38
data["counts"]["ui_created"] = 1
data.update(override)
return data
def test_lists_the_findings(self):
out = A.render(self.data())
self.assertIn("sale.view_order_form", out)
self.assertIn("website.layout", out)
def test_points_at_the_existing_cow_tools(self):
# L'inventaire ne rejuge pas les copies : il renvoie vers les outils
# qui tranchent, plutôt que de refaire leur travail à moitié.
out = A.render(self.data())
self.assertIn("check_cow_views.py", out)
self.assertIn("reset_stale_cow_views.py", out)
def test_no_cow_note_without_cow_views(self):
data = self.data()
data["counts"]["website_cow_copy"] = 0
self.assertNotIn("check_cow_views.py", A.render(data))
def test_says_the_flags_are_not_a_verdict(self):
# Sans comparaison, « signalée » n'est pas « modifiée » : le rapport
# doit le dire, sinon il promet plus qu'il ne sait.
self.assertIn("Flags say a view was touched", A.render(self.data()))
def test_clean_database_says_so(self):
data = self.data(findings=[])
out = A.render(data)
self.assertIn("Every view comes straight from a module.", out)
self.assertNotIn("check_cow_views.py", out)
def test_top_truncates_and_says_how_many_are_hidden(self):
out = A.render(self.data(), top=1)
self.assertIn("more", out)
def test_verbose_shows_everything(self):
self.assertNotIn("more", A.render(self.data(), verbose=True))
def test_category_filter(self):
out = A.render(self.data(), category="website_cow_copy")
self.assertIn("website.layout", out)
self.assertNotIn("sale.view_order_form", out)
def test_french_differs(self):
english = A.render(self.data())
todo_i18n._current_lang = "fr" # cf. plus haut : pas de persistance
french = A.render(self.data())
self.assertIn("Copie de site web", french)
self.assertNotEqual(english, french)
if __name__ == "__main__":
unittest.main()

View file

@ -20,33 +20,23 @@ class TestDeepMergeWithLists(unittest.TestCase):
self.assertEqual(result, {})
def test_dest_only(self):
result = self.cfg.deep_merge_with_lists(
{"a": 1, "b": 2}, {}
)
result = self.cfg.deep_merge_with_lists({"a": 1, "b": 2}, {})
self.assertEqual(result, {"a": 1, "b": 2})
def test_src_only(self):
result = self.cfg.deep_merge_with_lists(
{}, {"a": 1, "b": 2}
)
result = self.cfg.deep_merge_with_lists({}, {"a": 1, "b": 2})
self.assertEqual(result, {"a": 1, "b": 2})
def test_simple_merge(self):
result = self.cfg.deep_merge_with_lists(
{"a": 1}, {"b": 2}
)
result = self.cfg.deep_merge_with_lists({"a": 1}, {"b": 2})
self.assertEqual(result, {"a": 1, "b": 2})
def test_src_overrides_dest_string(self):
result = self.cfg.deep_merge_with_lists(
{"a": "old"}, {"a": "new"}
)
result = self.cfg.deep_merge_with_lists({"a": "old"}, {"a": "new"})
self.assertEqual(result, {"a": "new"})
def test_empty_src_string_keeps_dest(self):
result = self.cfg.deep_merge_with_lists(
{"a": "old"}, {"a": ""}
)
result = self.cfg.deep_merge_with_lists({"a": "old"}, {"a": ""})
self.assertEqual(result, {"a": "old"})
def test_nested_dict_merge(self):
@ -80,9 +70,7 @@ class TestDeepMergeWithLists(unittest.TestCase):
self.assertEqual(dest, {"a": {"x": 1}})
def test_src_overrides_non_string_non_dict_non_list(self):
result = self.cfg.deep_merge_with_lists(
{"a": 1}, {"a": 2}
)
result = self.cfg.deep_merge_with_lists({"a": 1}, {"a": 2})
self.assertEqual(result, {"a": 2})
@ -106,9 +94,7 @@ class TestGetConfig(unittest.TestCase):
base_path = self._write_json(
"base.json", {"instance": [{"name": "test"}]}
)
with patch(
"script.config.config_file.CONFIG_FILE", base_path
), patch(
with patch("script.config.config_file.CONFIG_FILE", base_path), patch(
"script.config.config_file.CONFIG_OVERRIDE_FILE",
os.path.join(self.tmpdir, "nonexistent1.json"),
), patch(
@ -120,9 +106,7 @@ class TestGetConfig(unittest.TestCase):
def test_get_config_returns_none_for_missing_key(self):
base_path = self._write_json("base.json", {"a": 1})
with patch(
"script.config.config_file.CONFIG_FILE", base_path
), patch(
with patch("script.config.config_file.CONFIG_FILE", base_path), patch(
"script.config.config_file.CONFIG_OVERRIDE_FILE",
os.path.join(self.tmpdir, "nonexistent1.json"),
), patch(
@ -141,9 +125,7 @@ class TestGetConfig(unittest.TestCase):
"override.json",
{"instance": [{"name": "override"}]},
)
with patch(
"script.config.config_file.CONFIG_FILE", base_path
), patch(
with patch("script.config.config_file.CONFIG_FILE", base_path), patch(
"script.config.config_file.CONFIG_OVERRIDE_FILE",
override_path,
), patch(
@ -166,9 +148,7 @@ class TestGetConfig(unittest.TestCase):
"private.json",
{"data": {"key": "private_val"}},
)
with patch(
"script.config.config_file.CONFIG_FILE", base_path
), patch(
with patch("script.config.config_file.CONFIG_FILE", base_path), patch(
"script.config.config_file.CONFIG_OVERRIDE_FILE",
os.path.join(self.tmpdir, "nonexistent.json"),
), patch(
@ -191,9 +171,7 @@ class TestGetConfig(unittest.TestCase):
"private.json",
{"items": [3], "meta": {"a": "private"}},
)
with patch(
"script.config.config_file.CONFIG_FILE", base_path
), patch(
with patch("script.config.config_file.CONFIG_FILE", base_path), patch(
"script.config.config_file.CONFIG_OVERRIDE_FILE",
override_path,
), patch(
@ -207,9 +185,7 @@ class TestGetConfig(unittest.TestCase):
self.assertEqual(result_items, [1, 3, 2])
# Dict merge: {a: base} + {a: private} = {a: private}
# then {a: private} + {b: override} = {a: private, b: override}
self.assertEqual(
result_meta, {"a": "private", "b": "override"}
)
self.assertEqual(result_meta, {"a": "private", "b": "override"})
def test_no_config_files_exist(self):
with patch(
@ -226,6 +202,99 @@ class TestGetConfig(unittest.TestCase):
self.assertIsNone(result)
class TestSetConfigValue(unittest.TestCase):
"""`set_config_value` est le pendant écriture de `get_config_value` :
seul `CONFIG_OVERRIDE_PRIVATE_FILE` est gitignored (vérifié avec
`git check-ignore`), donc c'est le seul des trois fichiers où écrire un
chemin personnel (ex. `kdbx.path`) sans risquer de le committer.
"""
def setUp(self):
self.cfg = ConfigFile()
self.tmp = tempfile.TemporaryDirectory()
# Sous un sous-dossier qui n'existe pas encore, comme le vrai
# `private/todo/` d'un checkout neuf.
self.private_path = os.path.join(
self.tmp.name, "private", "todo", "todo_override_private.json"
)
self.patcher = patch(
"script.config.config_file.CONFIG_OVERRIDE_PRIVATE_FILE",
self.private_path,
)
self.patcher.start()
def tearDown(self):
self.patcher.stop()
self.tmp.cleanup()
def test_creates_missing_file_and_directory(self):
self.cfg.set_config_value(["kdbx", "path"], "/x/y.kdbx")
self.assertTrue(os.path.exists(self.private_path))
with open(self.private_path) as f:
data = json.load(f)
self.assertEqual(data, {"kdbx": {"path": "/x/y.kdbx"}})
def test_nested_key_creation(self):
self.cfg.set_config_value(["a", "b", "c"], "v")
with open(self.private_path) as f:
data = json.load(f)
self.assertEqual(data, {"a": {"b": {"c": "v"}}})
def test_preserves_existing_unrelated_content(self):
os.makedirs(os.path.dirname(self.private_path))
with open(self.private_path, "w") as f:
json.dump({"other": {"key": "kept"}}, f)
self.cfg.set_config_value(["kdbx", "path"], "/x/y.kdbx")
with open(self.private_path) as f:
data = json.load(f)
self.assertEqual(
data,
{"other": {"key": "kept"}, "kdbx": {"path": "/x/y.kdbx"}},
)
def test_overwrites_only_the_targeted_key(self):
self.cfg.set_config_value(["kdbx", "path"], "/first.kdbx")
self.cfg.set_config_value(["kdbx", "path"], "/second.kdbx")
with open(self.private_path) as f:
data = json.load(f)
self.assertEqual(data, {"kdbx": {"path": "/second.kdbx"}})
def test_file_mode_is_0600(self):
self.cfg.set_config_value(["kdbx", "path"], "/x/y.kdbx")
mode = os.stat(self.private_path).st_mode & 0o777
self.assertEqual(mode, 0o600)
def test_directory_mode_is_0700(self):
self.cfg.set_config_value(["kdbx", "path"], "/x/y.kdbx")
mode = os.stat(os.path.dirname(self.private_path)).st_mode & 0o777
self.assertEqual(mode, 0o700)
def test_existing_file_with_looser_mode_is_corrected(self):
os.makedirs(os.path.dirname(self.private_path))
with open(self.private_path, "w") as f:
json.dump({}, f)
os.chmod(self.private_path, 0o644)
self.cfg.set_config_value(["kdbx", "path"], "/x/y.kdbx")
mode = os.stat(self.private_path).st_mode & 0o777
self.assertEqual(mode, 0o600)
def test_round_trips_through_get_config_value(self):
self.cfg.set_config_value(["kdbx", "path"], "/round/trip.kdbx")
with patch(
"script.config.config_file.CONFIG_FILE",
os.path.join(self.tmp.name, "nonexistent_base.json"),
), patch(
"script.config.config_file.CONFIG_OVERRIDE_FILE",
os.path.join(self.tmp.name, "nonexistent_override.json"),
):
result = self.cfg.get_config_value(["kdbx", "path"])
self.assertEqual(result, "/round/trip.kdbx")
class TestGetConfigValue(unittest.TestCase):
def setUp(self):
self.cfg = ConfigFile()
@ -236,9 +305,7 @@ class TestGetConfigValue(unittest.TestCase):
"get_config",
return_value={"level1": {"level2": "found"}},
):
result = self.cfg.get_config_value(
["root", "level1", "level2"]
)
result = self.cfg.get_config_value(["root", "level1", "level2"])
self.assertEqual(result, "found")
def test_single_key(self):

View file

@ -130,7 +130,11 @@ class TestExecCommandLive(unittest.TestCase):
source_odoo="",
quiet=True,
)
self.assertEqual(result, -1)
# `1`, pas `-1` : e24b185 a rendu à ce chemin la FORME que
# l'appelant demande (un tuple s'il en attend un) et en a profité
# pour donner un vrai code de sortie. Aucun appelant ne compare à
# -1, qui n'est d'ailleurs pas un code de sortie valide.
self.assertEqual(result, 1)
def test_single_source_odoo_with_version(self):
status, cmd = self.exe.exec_command_live(

117
test/test_kdbx_manager.py Normal file
View file

@ -0,0 +1,117 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Ouverture du coffre KeePass depuis le CLI.
Signalé à l'usage : une mauvaise saisie affichait une trace `construct` de
quarante lignes, puis `pykeepass.exceptions.CredentialsError`, et tuait le
CLI — `make: *** Error 1`. L'invite disait par ailleurs `enter_password`,
la clé i18n brute, sans nommer ce qu'elle demandait.
Un mot de passe refusé est le cas NORMAL de cette fonction : elle doit le
dire, laisser recommencer, et laisser partir.
"""
import os
import tempfile
import unittest
from unittest.mock import MagicMock, patch
from pykeepass import create_database
from script.todo.kdbx_manager import KdbxManager
class KdbxCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.chemin = os.path.join(self.tmp.name, "coffre.kdbx")
create_database(self.chemin, password="bon")
def tearDown(self):
self.tmp.cleanup()
def _manager(self, mot_de_passe_configure=None):
config = MagicMock()
config.get_config_value.side_effect = lambda keys: (
self.chemin if keys == ["kdbx", "path"] else mot_de_passe_configure
)
return KdbxManager(config)
def _saisies(self, *reponses):
"""Renvoie (résultat, texte affiché) pour une suite de saisies."""
vues = []
with patch("getpass.getpass", side_effect=list(reponses)), patch(
"builtins.print",
side_effect=lambda *a: vues.append(" ".join(map(str, a))),
):
resultat = self._manager().get_kdbx()
return resultat, "\n".join(vues)
class TestWrongPasswordIsToldNotRaised(KdbxCase):
def test_three_refusals_give_up_without_raising(self):
resultat, vu = self._saisies("faux1", "faux2", "faux3")
self.assertIsNone(resultat)
self.assertEqual(vu.count("Mot de passe incorrect"), 3)
def test_the_message_names_keepass_not_the_library_error(self):
"""« Invalid credentials » ne dit pas DE QUOI on parle : l'erreur
d'authentification du serveur de courriel a exactement le même
libellé. Le mot « KeePass » est ce qui les distingue."""
_, vu = self._saisies("faux", "")
self.assertIn("KeePass", vu)
self.assertNotIn("CredentialsError", vu)
self.assertNotIn("Traceback", vu)
def test_the_prompt_says_which_vault_it_wants_to_open(self):
"""L'invite affichait `enter_password`, une clé i18n absente de la
table. Elle doit nommer le fichier : plusieurs coffres peuvent
exister, et rien ne disait lequel était demandé."""
_, vu = self._saisies("faux", "")
self.assertIn(self.chemin, vu)
self.assertNotIn("enter_password", vu)
class TestGivingUpIsPossible(KdbxCase):
def test_an_empty_entry_gives_up_immediately(self):
"""Sans porte de sortie, la seule façon de quitter était de tuer le
programme — ce que la trace faisait, mais par accident."""
resultat, vu = self._saisies("")
self.assertIsNone(resultat)
self.assertIn("abandonne", vu)
def test_giving_up_asks_only_once(self):
appels = []
with patch(
"getpass.getpass", side_effect=lambda **k: appels.append(1) or ""
), patch("builtins.print"):
self._manager().get_kdbx()
self.assertEqual(len(appels), 1)
class TestRecoveryAndSuccess(KdbxCase):
def test_a_wrong_try_does_not_prevent_a_later_good_one(self):
"""Le contrôle POSITIF : sans lui, une fonction qui refuserait
TOUJOURS passerait les tests ci-dessus."""
resultat, _ = self._saisies("faux", "bon")
self.assertIsNotNone(resultat)
def test_a_good_password_opens_the_vault_at_once(self):
resultat, _ = self._saisies("bon")
self.assertIsNotNone(resultat)
def test_a_wrong_password_from_the_configuration_is_reported(self):
"""Chemin sans saisie : personne à qui redemander, mais la trace ne
doit pas remonter pour autant."""
vues = []
with patch(
"builtins.print",
side_effect=lambda *a: vues.append(" ".join(map(str, a))),
):
resultat = self._manager("mauvais").get_kdbx()
self.assertIsNone(resultat)
self.assertIn("KeePass", "\n".join(vues))
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,149 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
import os
import tempfile
import unittest
from unittest.mock import MagicMock, patch
from script.todo.mail.account_setup import (
create_vault,
kdbx_is_configured,
save_new_account,
use_existing_vault,
)
from script.todo.mail.accounts import account_from_preset
from script.todo.mail.secrets import SecretError
class FakeConfigFile:
"""Un `config_file` minimal : seuls `get_config_value`/`set_config_value`
sont utilisés par `account_setup`, pas besoin du vrai `ConfigFile`."""
def __init__(self):
self._values: dict = {}
def get_config_value(self, keys):
node = self._values
for key in keys:
if not isinstance(node, dict) or key not in node:
return None
node = node[key]
return node
def set_config_value(self, keys, value):
node = self._values
for key in keys[:-1]:
node = node.setdefault(key, {})
node[keys[-1]] = value
class TestSaveNewAccountRollsBack(unittest.TestCase):
"""C'est la couture qu'un défaut réel emprunterait : un secret orphelin
sous une référence qu'aucune configuration ne désigne, invisible."""
def setUp(self):
self.account = account_from_preset("perso", "a@x.ca", "generic")
self.vault = MagicMock()
def test_rollback_deletes_the_secret_when_save_raises(self):
with patch(
"script.todo.mail.account_setup.mail_accounts.save",
side_effect=OSError("disque plein"),
):
with self.assertRaises(OSError):
save_new_account(
self.vault, [self.account], self.account, "hunter2"
)
self.vault.set.assert_called_once_with(
self.account.secret_ref, "hunter2"
)
self.vault.delete.assert_called_once_with(self.account.secret_ref)
def test_rollback_survives_a_vault_that_cannot_delete_either(self):
"""Le secret peut avoir déjà disparu du coffre : ce n'est pas une
raison de masquer l'échec de la sauvegarde initiale."""
self.vault.delete.side_effect = SecretError("introuvable")
with patch(
"script.todo.mail.account_setup.mail_accounts.save",
side_effect=OSError("disque plein"),
):
with self.assertRaises(OSError):
save_new_account(
self.vault, [self.account], self.account, "hunter2"
)
def test_successful_save_leaves_the_secret_in_place(self):
with patch(
"script.todo.mail.account_setup.mail_accounts.save"
) as mock_save:
save_new_account(
self.vault, [self.account], self.account, "hunter2"
)
mock_save.assert_called_once_with([self.account])
self.vault.set.assert_called_once_with(
self.account.secret_ref, "hunter2"
)
self.vault.delete.assert_not_called()
class TestKdbxIsConfigured(unittest.TestCase):
def test_false_when_nothing_is_configured(self):
self.assertFalse(kdbx_is_configured(FakeConfigFile()))
def test_false_on_the_empty_string(self):
config = FakeConfigFile()
config.set_config_value(["kdbx", "path"], "")
self.assertFalse(kdbx_is_configured(config))
def test_true_once_a_path_is_set(self):
config = FakeConfigFile()
config.set_config_value(["kdbx", "path"], "/already/there.kdbx")
self.assertTrue(kdbx_is_configured(config))
class TestCreateVault(unittest.TestCase):
def test_creates_a_real_kdbx_and_persists_its_path(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "new.kdbx")
config = FakeConfigFile()
create_vault(config, path, "hunter2")
self.assertTrue(os.path.isfile(path))
self.assertEqual(config.get_config_value(["kdbx", "path"]), path)
def test_refuses_to_overwrite_an_existing_file(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "already.kdbx")
with open(path, "wb") as handle:
handle.write(b"already there")
config = FakeConfigFile()
with self.assertRaises(SecretError):
create_vault(config, path, "hunter2")
self.assertIsNone(config.get_config_value(["kdbx", "path"]))
class TestUseExistingVault(unittest.TestCase):
def test_refuses_a_nonexistent_file(self):
config = FakeConfigFile()
with self.assertRaises(SecretError):
use_existing_vault(config, "/nope/does-not-exist.kdbx")
self.assertIsNone(config.get_config_value(["kdbx", "path"]))
def test_refuses_an_empty_path(self):
config = FakeConfigFile()
with self.assertRaises(SecretError):
use_existing_vault(config, "")
def test_accepts_and_persists_an_existing_file(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "existing.kdbx")
with open(path, "wb") as handle:
handle.write(b"not a real kdbx, just a file")
config = FakeConfigFile()
use_existing_vault(config, path)
self.assertEqual(config.get_config_value(["kdbx", "path"]), path)
if __name__ == "__main__":
unittest.main()

233
test/test_mail_accounts.py Normal file
View file

@ -0,0 +1,233 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
import json
import os
import stat
import tempfile
import unittest
from pathlib import Path
from script.todo.mail.accounts import (
PRESETS,
Account,
AccountError,
account_from_preset,
find,
load,
save,
write_template,
)
class TestPresets(unittest.TestCase):
def test_four_presets(self):
self.assertEqual(
set(PRESETS), {"gmail", "outlook", "icloud", "generic"}
)
def test_gmail_servers(self):
self.assertEqual(PRESETS["gmail"]["imap"]["host"], "imap.gmail.com")
self.assertEqual(PRESETS["gmail"]["imap"]["port"], 993)
self.assertEqual(PRESETS["gmail"]["smtp"]["host"], "smtp.gmail.com")
self.assertEqual(PRESETS["gmail"]["smtp"]["port"], 587)
def test_security_values_are_known(self):
for key, preset in PRESETS.items():
for proto in ("imap", "smtp"):
self.assertIn(
preset[proto]["security"],
("ssl", "starttls", "none"),
f"{key}.{proto}",
)
def test_app_password_flag(self):
self.assertTrue(PRESETS["gmail"]["app_password"])
self.assertTrue(PRESETS["icloud"]["app_password"])
self.assertFalse(PRESETS["generic"]["app_password"])
class TestAccountFromPreset(unittest.TestCase):
def test_fills_servers_and_user(self):
acc = account_from_preset("perso", "moi@gmail.com", "gmail")
self.assertEqual(acc.imap.host, "imap.gmail.com")
self.assertEqual(acc.imap.user, "moi@gmail.com")
self.assertEqual(acc.smtp.user, "moi@gmail.com")
def test_user_override(self):
acc = account_from_preset(
"perso", "moi@x.ca", "generic", user="login-different"
)
self.assertEqual(acc.imap.user, "login-different")
def test_secret_ref_defaults_to_kdbx(self):
acc = account_from_preset("perso", "moi@x.ca", "generic")
self.assertEqual(acc.secret_ref, "kdbx:ERPLibre/Mail/perso")
def test_secret_ref_keyring(self):
acc = account_from_preset(
"perso", "moi@x.ca", "generic", vault="keyring"
)
self.assertEqual(acc.secret_ref, "keyring:perso")
def test_cache_key_ref(self):
acc = account_from_preset("perso", "moi@x.ca", "generic")
self.assertEqual(
acc.cache_key_ref(), "kdbx:ERPLibre/Mail/perso/cache-key"
)
def test_cache_mode_inherits_by_default(self):
self.assertIsNone(
account_from_preset("perso", "moi@x.ca", "generic").cache_mode
)
def test_unknown_preset_raises(self):
with self.assertRaises(AccountError):
account_from_preset("perso", "moi@x.ca", "aol")
def test_empty_name_raises(self):
with self.assertRaises(AccountError):
account_from_preset("", "moi@x.ca", "generic")
def test_name_with_slash_raises(self):
"""Le nom sert de segment de chemin et de référence kdbx."""
with self.assertRaises(AccountError):
account_from_preset("per/so", "moi@x.ca", "generic")
class TestRoundtrip(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.path = Path(self.tmp.name) / "accounts.json"
def tearDown(self):
self.tmp.cleanup()
def test_save_then_load(self):
acc = account_from_preset("perso", "moi@gmail.com", "gmail")
save([acc], self.path)
loaded = load(self.path)
self.assertEqual(len(loaded), 1)
self.assertEqual(loaded[0].to_dict(), acc.to_dict())
def test_file_is_0600(self):
save([account_from_preset("perso", "moi@x.ca", "generic")], self.path)
mode = stat.S_IMODE(os.stat(self.path).st_mode)
self.assertEqual(mode, 0o600)
def test_parent_dir_is_0700(self):
nested = Path(self.tmp.name) / "mail" / "accounts.json"
save([account_from_preset("perso", "moi@x.ca", "generic")], nested)
mode = stat.S_IMODE(os.stat(nested.parent).st_mode)
self.assertEqual(mode, 0o700)
def test_no_window_at_the_process_umask(self):
"""`write_text` puis `chmod` laisserait le fichier lisible à l'umask
du process le temps entre les deux appels. Au moment où `chmod` est
appelé, le fichier doit déjà être en 0600 — la preuve qu'il n'a
jamais existé autrement."""
from unittest.mock import patch
seen = []
original_chmod = os.chmod
def spy(path, mode):
if Path(path) == self.path:
seen.append(stat.S_IMODE(os.stat(path).st_mode))
return original_chmod(path, mode)
with patch("os.chmod", side_effect=spy):
save(
[account_from_preset("perso", "moi@x.ca", "generic")],
self.path,
)
self.assertEqual(seen, [0o600])
def test_load_missing_file_returns_empty(self):
self.assertEqual(load(Path(self.tmp.name) / "absent.json"), [])
def test_no_password_key_is_written(self):
save([account_from_preset("perso", "moi@x.ca", "generic")], self.path)
raw = self.path.read_text()
self.assertNotIn("password", raw.lower())
def test_corrupt_json_raises(self):
self.path.write_text("{ pas du json")
with self.assertRaises(AccountError):
load(self.path)
def test_duplicate_name_raises_on_save(self):
acc = account_from_preset("perso", "moi@x.ca", "generic")
with self.assertRaises(AccountError):
save([acc, acc], self.path)
def test_find(self):
accs = [
account_from_preset("perso", "a@x.ca", "generic"),
account_from_preset("travail", "b@x.ca", "generic"),
]
self.assertEqual(find(accs, "travail").email, "b@x.ca")
self.assertIsNone(find(accs, "absent"))
class TestTemplate(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.path = Path(self.tmp.name) / "accounts.json"
def tearDown(self):
self.tmp.cleanup()
def test_writes_valid_json(self):
write_template(self.path)
data = json.loads(self.path.read_text())
self.assertEqual(data["version"], 1)
def test_has_one_example_per_preset(self):
write_template(self.path)
data = json.loads(self.path.read_text())
presets = {a["preset"] for a in data["accounts"]}
self.assertEqual(presets, set(PRESETS))
def test_examples_are_disabled(self):
"""Un modèle ne doit rien tenter de synchroniser tel quel."""
write_template(self.path)
data = json.loads(self.path.read_text())
self.assertTrue(all(not a["enabled"] for a in data["accounts"]))
def test_carries_comments(self):
write_template(self.path)
data = json.loads(self.path.read_text())
self.assertIn("_comment", data)
def test_refuses_to_overwrite(self):
self.path.write_text("{}")
with self.assertRaises(AccountError):
write_template(self.path)
def test_force_overwrites(self):
self.path.write_text("{}")
write_template(self.path, force=True)
self.assertIn("accounts", json.loads(self.path.read_text()))
def test_no_window_at_the_process_umask(self):
from unittest.mock import patch
seen = []
original_chmod = os.chmod
def spy(path, mode):
if Path(path) == self.path:
seen.append(stat.S_IMODE(os.stat(path).st_mode))
return original_chmod(path, mode)
with patch("os.chmod", side_effect=spy):
write_template(self.path)
self.assertEqual(seen, [0o600])
if __name__ == "__main__":
unittest.main()

44
test/test_mail_charset.py Normal file
View file

@ -0,0 +1,44 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
import unittest
from script.todo.mail.charset import decode_bytes
class TestDecodeBytes(unittest.TestCase):
def test_known_charset(self):
self.assertEqual(decode_bytes("café".encode("utf-8"), "utf-8"), "café")
def test_missing_charset_falls_back_to_utf8(self):
self.assertEqual(decode_bytes("café".encode("utf-8"), None), "café")
def test_empty_charset_falls_back_to_utf8(self):
self.assertEqual(decode_bytes("café".encode("utf-8"), ""), "café")
def test_unknown_8bit_falls_back_to_utf8(self):
"""La valeur réelle qui a fait tomber la synchro d'un dossier entier
(voir `imap_transport.decode_header_value` et le rapport de tâche)."""
self.assertEqual(
decode_bytes("café".encode("utf-8"), "unknown-8bit"), "café"
)
def test_charset_with_a_stray_quote_falls_back(self):
self.assertEqual(
decode_bytes("café".encode("utf-8"), 'unknown-8bit"'), "café"
)
def test_bogus_charset_name_falls_back(self):
self.assertEqual(
decode_bytes("café".encode("utf-8"), "bogus-charset-xyz"), "café"
)
def test_wrong_but_known_charset_replaces_undecodable_bytes(self):
# ascii connu, mais incapable de décoder un octet accentué : c'est
# `errors="replace"`, pas le repli `LookupError`, qui doit agir ici.
self.assertIn("<EFBFBD>", decode_bytes(b"caf\xe9", "ascii"))
if __name__ == "__main__":
unittest.main()

1382
test/test_mail_compose.py Normal file

File diff suppressed because it is too large Load diff

120
test/test_mail_crypto.py Normal file
View file

@ -0,0 +1,120 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
import unittest
from script.todo.mail.crypto import (
CLEAR_MAGIC,
SEALED_MAGIC,
AesGcmCrypto,
CryptoError,
NullCrypto,
build_crypto,
new_key,
)
class TestNullCrypto(unittest.TestCase):
def test_roundtrip(self):
box = NullCrypto()
self.assertEqual(box.open(box.seal(b"bonjour")), b"bonjour")
def test_envelope_is_marked_clear(self):
self.assertTrue(NullCrypto().seal(b"x").startswith(CLEAR_MAGIC))
def test_cannot_open_sealed_blob(self):
blob = AesGcmCrypto(new_key()).seal(b"secret")
with self.assertRaises(CryptoError):
NullCrypto().open(blob)
class TestAesGcmCrypto(unittest.TestCase):
def setUp(self):
self.key = new_key()
def test_key_is_32_bytes(self):
self.assertEqual(len(self.key), 32)
def test_roundtrip(self):
box = AesGcmCrypto(self.key)
self.assertEqual(box.open(box.seal(b"bonjour")), b"bonjour")
def test_envelope_is_marked_sealed(self):
self.assertTrue(
AesGcmCrypto(self.key).seal(b"x").startswith(SEALED_MAGIC)
)
def test_ciphertext_hides_plaintext(self):
blob = AesGcmCrypto(self.key).seal(b"sujet confidentiel")
self.assertNotIn(b"confidentiel", blob)
def test_nonce_differs_each_call(self):
box = AesGcmCrypto(self.key)
self.assertNotEqual(box.seal(b"meme texte"), box.seal(b"meme texte"))
def test_wrong_key_raises(self):
blob = AesGcmCrypto(self.key).seal(b"secret")
with self.assertRaises(CryptoError):
AesGcmCrypto(new_key()).open(blob)
def test_tampered_blob_raises(self):
blob = bytearray(AesGcmCrypto(self.key).seal(b"secret"))
blob[-1] ^= 0xFF
with self.assertRaises(CryptoError):
AesGcmCrypto(self.key).open(bytes(blob))
def test_reads_clear_blob(self):
"""Une base écrite en clair reste lisible après passage en chiffré."""
clear = NullCrypto().seal(b"ancien")
self.assertEqual(AesGcmCrypto(self.key).open(clear), b"ancien")
def test_rejects_bad_key_length(self):
with self.assertRaises(CryptoError):
AesGcmCrypto(b"trop court")
def test_rejects_unknown_magic(self):
with self.assertRaises(CryptoError):
AesGcmCrypto(self.key).open(b"ZZdonnees")
def test_unexpected_error_is_not_disguised_as_a_bad_key(self):
"""Un bug de programmation doit remonter tel quel, pas en CryptoError."""
box = AesGcmCrypto(self.key)
blob = box.seal(b"secret")
class Boom:
# AESGCM est adossé à Rust : `decrypt` y est en lecture seule.
# On remplace donc l'objet entier, pas sa méthode.
def decrypt(self, *args, **kwargs):
raise RuntimeError("bug interne")
box._aes = Boom()
with self.assertRaises(RuntimeError):
box.open(blob)
class TestBuildCrypto(unittest.TestCase):
def test_clear_mode(self):
self.assertIsInstance(build_crypto("clear", None), NullCrypto)
def test_encrypted_mode(self):
self.assertIsInstance(
build_crypto("encrypted", new_key()), AesGcmCrypto
)
def test_ephemeral_mode(self):
self.assertIsInstance(
build_crypto("ephemeral", new_key()), AesGcmCrypto
)
def test_encrypted_without_key_raises(self):
with self.assertRaises(CryptoError):
build_crypto("encrypted", None)
def test_unknown_mode_raises(self):
with self.assertRaises(CryptoError):
build_crypto("magique", None)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,436 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
import unittest
from unittest.mock import MagicMock, patch
from script.todo.mail.accounts import account_from_preset
from script.todo.mail.imap_transport import (
ImapError,
ImaplibTransport,
connect,
decode_header_value,
decode_mailbox,
parse_fetch_headers,
parse_list_line,
)
class TestDecodeHeaderValue(unittest.TestCase):
def test_plain(self):
self.assertEqual(decode_header_value("Bonjour"), "Bonjour")
def test_encoded_word_base64(self):
self.assertEqual(
decode_header_value("=?UTF-8?B?RGV2aXMgcsOpdmlzw6k=?="),
"Devis révisé",
)
def test_encoded_word_quoted_printable(self):
self.assertEqual(
decode_header_value("=?UTF-8?Q?Devis_r=C3=A9vis=C3=A9?="),
"Devis révisé",
)
def test_mixed_parts(self):
self.assertEqual(
decode_header_value("Re: =?UTF-8?B?ZGV2aXM=?="), "Re: devis"
)
def test_none_is_empty(self):
self.assertEqual(decode_header_value(None), "")
def test_broken_encoding_does_not_raise(self):
self.assertIsInstance(decode_header_value("=?UTF-8?B?!!!?="), str)
def test_unknown_8bit_charset_does_not_raise(self):
"""Étiquette réelle vue en usage : certains MTA la posent sur un
en-tête 8 bits mal formé. Python ne connaît pas ce nom de codec :
`bytes.decode("unknown-8bit", "replace")` lève `LookupError` à la
recherche du codec, avant que `errors="replace"` ne serve. Un seul
message ainsi étiqueté faisait échouer toute la synchronisation du
dossier (`_sync_folder` catch par dossier, donc le dossier entier
n'était jamais marqué synchronisé)."""
self.assertEqual(
decode_header_value("=?unknown-8bit?Q?Bonjour?="), "Bonjour"
)
class TestDecodeMailbox(unittest.TestCase):
def test_ascii_unchanged(self):
self.assertEqual(decode_mailbox("INBOX"), "INBOX")
def test_modified_utf7(self):
self.assertEqual(decode_mailbox("&AMk-l&AOk-ments"), "Éléments")
def test_ampersand_escape(self):
self.assertEqual(decode_mailbox("A&-B"), "A&B")
def test_broken_input_returns_original(self):
self.assertEqual(decode_mailbox("&&&"), "&&&")
class TestParseListLine(unittest.TestCase):
def test_plain_inbox(self):
info = parse_list_line(b'(\\HasNoChildren) "/" "INBOX"')
self.assertEqual(info.name, "INBOX")
self.assertEqual(info.role, "inbox")
def test_sent_role_from_special_use(self):
info = parse_list_line(
b'(\\HasNoChildren \\Sent) "/" "[Gmail]/Sent Mail"'
)
self.assertEqual(info.name, "[Gmail]/Sent Mail")
self.assertEqual(info.role, "sent")
def test_trash_role(self):
self.assertEqual(
parse_list_line(b'(\\Trash) "/" "Corbeille"').role, "trash"
)
def test_drafts_role(self):
self.assertEqual(
parse_list_line(b'(\\Drafts) "/" "Drafts"').role, "drafts"
)
def test_junk_role(self):
self.assertEqual(parse_list_line(b'(\\Junk) "/" "Spam"').role, "junk")
def test_archive_role(self):
self.assertEqual(
parse_list_line(b'(\\Archive) "/" "Archive"').role, "archive"
)
def test_noselect_containers_are_marked_unselectable(self):
"""« [Gmail] » est un NIVEAU de hiérarchie, pas une boîte : le
serveur l'annonce dans les drapeaux de LIST, il suffit de l'écouter
plutôt que de traiter son nom comme un cas particulier."""
for ligne in (
b'(\\HasChildren \\Noselect) "/" "[Gmail]"',
b'(\\NonExistent \\HasChildren) "/" "Vieux"',
):
self.assertFalse(parse_list_line(ligne).selectable, ligne)
def test_ordinary_folders_stay_selectable(self):
"""Le contrôle négatif : marquer TOUT comme non sélectionnable
passerait le test ci-dessus, et ne synchroniserait plus rien."""
for ligne in (
b'(\\HasNoChildren) "/" "INBOX"',
b'(\\HasNoChildren \\Sent) "/" "[Gmail]/Sent Mail"',
):
self.assertTrue(parse_list_line(ligne).selectable, ligne)
def test_no_special_use_has_no_role(self):
self.assertIsNone(
parse_list_line(b'(\\HasNoChildren) "/" "Projets"').role
)
def test_unquoted_name(self):
self.assertEqual(
parse_list_line(b'(\\HasNoChildren) "/" Projets').name, "Projets"
)
def test_display_is_decoded(self):
info = parse_list_line(b'(\\HasNoChildren) "/" "&AMk-l&AOk-ments"')
self.assertEqual(info.display, "Éléments")
HEADERS_1 = (
b"1 (UID 101 RFC822.SIZE 420 FLAGS (\\Seen) BODY[HEADER.FIELDS "
b"(FROM TO SUBJECT DATE MESSAGE-ID)] {160}",
b"From: Alice <alice@x.ca>\r\n"
b"To: moi@x.ca\r\n"
b"Subject: =?UTF-8?B?RGV2aXMgcsOpdmlzw6k=?=\r\n"
b"Date: Fri, 01 Aug 2026 10:41:00 +0000\r\n"
b"Message-ID: <abc@x.ca>\r\n\r\n",
)
HEADERS_2 = (
b"2 (UID 102 RFC822.SIZE 12 FLAGS () BODY[HEADER.FIELDS "
b"(FROM TO SUBJECT DATE MESSAGE-ID)] {40}",
b"From: bob@x.ca\r\nSubject: CR\r\n\r\n",
)
# Même message, mais le serveur place les attributs APRÈS le littéral.
# `imaplib` rend alors la fin de ligne dans une entrée séparée.
HEADERS_TRAILING = (
b"3 (BODY[HEADER.FIELDS (FROM TO SUBJECT DATE MESSAGE-ID)] {42}",
b"From: carl@x.ca\r\nSubject: Facture\r\n\r\n",
)
TRAILING_ATTRS = b" UID 103 RFC822.SIZE 99 FLAGS (\\Answered))"
class TestParseFetchHeaders(unittest.TestCase):
def test_single_message(self):
got = parse_fetch_headers([HEADERS_1, b")"])
self.assertEqual(len(got), 1)
self.assertEqual(got[0].uid, 101)
def test_size_and_flags(self):
got = parse_fetch_headers([HEADERS_1, b")"])[0]
self.assertEqual(got.size, 420)
self.assertEqual(got.flags, "\\Seen")
def test_subject_is_decoded(self):
self.assertEqual(
parse_fetch_headers([HEADERS_1, b")"])[0].subject, "Devis révisé"
)
def test_from_and_to(self):
got = parse_fetch_headers([HEADERS_1, b")"])[0]
self.assertEqual(got.frm, "Alice <alice@x.ca>")
self.assertEqual(got.to, "moi@x.ca")
def test_msgid(self):
self.assertEqual(
parse_fetch_headers([HEADERS_1, b")"])[0].msgid, "<abc@x.ca>"
)
def test_raw_8bit_bytes_in_the_date_do_not_lose_the_message(self):
"""Signalé depuis une VRAIE boîte : un `Date:` porteur d'octets 8
bits fait renvoyer un `Header` et non une chaîne, et
`parsedate_to_datetime` y lève un AttributeError que le `except`
d'origine ne rattrapait pas — la synchro du dossier ENTIER tombait
sur un seul message. Une date est une commodité d'affichage : elle
ne vaut pas la perte du message."""
entetes = (
b"From: a@x.ca\r\n"
b"Subject: essai\r\n"
b"Date: Wed, 06 Ao\xfbt 2026 10:00:00 +0000\r\n\r\n"
)
data = [
(
b"1 (UID 42 RFC822.SIZE 100 FLAGS (\\Seen) "
b"BODY[HEADER.FIELDS (DATE FROM TO SUBJECT MESSAGE-ID)] "
b"{%d}" % len(entetes),
entetes,
),
b")",
]
infos = parse_fetch_headers(data)
self.assertEqual(len(infos), 1)
self.assertEqual(infos[0].uid, 42)
self.assertEqual(infos[0].date, 0)
self.assertEqual(infos[0].subject, "essai")
def test_date_is_epoch(self):
self.assertEqual(
parse_fetch_headers([HEADERS_1, b")"])[0].date, 1785580860
)
def test_missing_date_is_zero(self):
self.assertEqual(parse_fetch_headers([HEADERS_2, b")"])[0].date, 0)
def test_missing_to_is_empty(self):
self.assertEqual(parse_fetch_headers([HEADERS_2, b")"])[0].to, "")
def test_several_messages(self):
got = parse_fetch_headers([HEADERS_1, b")", HEADERS_2, b")"])
self.assertEqual([m.uid for m in got], [101, 102])
def test_non_tuple_entries_are_skipped(self):
self.assertEqual(parse_fetch_headers([b")", None]), [])
def test_attributes_after_the_literal_are_read(self):
"""RFC 3501 n'impose pas l'ordre : sinon le message disparaît."""
got = parse_fetch_headers([HEADERS_TRAILING, TRAILING_ATTRS])
self.assertEqual(len(got), 1)
self.assertEqual(got[0].uid, 103)
def test_attributes_after_the_literal_keep_flags_and_size(self):
got = parse_fetch_headers([HEADERS_TRAILING, TRAILING_ATTRS])[0]
self.assertEqual(got.flags, "\\Answered")
self.assertEqual(got.size, 99)
self.assertEqual(got.subject, "Facture")
def test_both_orders_in_one_response(self):
got = parse_fetch_headers(
[HEADERS_1, b")", HEADERS_TRAILING, TRAILING_ATTRS]
)
self.assertEqual([m.uid for m in got], [101, 103])
class TestTransport(unittest.TestCase):
def setUp(self):
self.client = MagicMock()
self.transport = ImaplibTransport(self.client)
def test_list_folders(self):
self.client.list.return_value = (
"OK",
[b'(\\HasNoChildren) "/" "INBOX"', b'(\\Sent) "/" "Sent"'],
)
names = [f.name for f in self.transport.list_folders()]
self.assertEqual(names, ["INBOX", "Sent"])
def test_list_failure_raises(self):
self.client.list.return_value = ("NO", [b"refuse"])
with self.assertRaises(ImapError):
self.transport.list_folders()
def test_select_reads_uidvalidity_and_uidnext(self):
self.client.select.return_value = ("OK", [b"42"])
self.client.response.side_effect = lambda k: {
"UIDVALIDITY": ("OK", [b"7"]),
"UIDNEXT": ("OK", [b"103"]),
}[k]
info = self.transport.select("INBOX")
self.assertEqual(
(info.uidvalidity, info.uidnext, info.exists), (7, 103, 42)
)
def test_select_failure_raises(self):
self.client.select.return_value = ("NO", [b"pas de boite"])
with self.assertRaises(ImapError):
self.transport.select("ABSENT")
def test_search_uids(self):
self.client.uid.return_value = ("OK", [b"101 102 103"])
self.assertEqual(self.transport.search_uids(101), [101, 102, 103])
def test_search_empty(self):
self.client.uid.return_value = ("OK", [b""])
self.assertEqual(self.transport.search_uids(1), [])
def test_fetch_headers_empty_list_skips_network(self):
self.assertEqual(self.transport.fetch_headers([]), [])
self.client.uid.assert_not_called()
def test_fetch_body(self):
self.client.uid.return_value = (
"OK",
[(b"1 (UID 101 BODY[] {5}", b"corps"), b")"],
)
self.assertEqual(self.transport.fetch_body(101), b"corps")
def test_fetch_body_failure_raises(self):
self.client.uid.return_value = ("NO", [b"refuse"])
with self.assertRaises(ImapError):
self.transport.fetch_body(101)
def test_store_flags_add_and_remove(self):
self.client.uid.return_value = ("OK", [b""])
self.transport.store_flags(101, ["\\Seen"], ["\\Flagged"])
calls = [c.args for c in self.client.uid.call_args_list]
self.assertIn(("STORE", "101", "+FLAGS", "(\\Seen)"), calls)
self.assertIn(("STORE", "101", "-FLAGS", "(\\Flagged)"), calls)
def test_logout_is_forgiving(self):
self.client.logout.side_effect = OSError("déjà fermé")
self.transport.logout() # ne doit pas lever
class TestFetchFlags(unittest.TestCase):
def setUp(self):
self.client = MagicMock()
self.transport = ImaplibTransport(self.client)
def test_empty_list_skips_the_network(self):
self.assertEqual(self.transport.fetch_flags([]), [])
self.client.uid.assert_not_called()
def test_parses_bare_lines(self):
self.client.uid.return_value = (
"OK",
[b"1 (UID 101 FLAGS (\\Seen))", b"2 (UID 102 FLAGS ())"],
)
self.assertEqual(
self.transport.fetch_flags([101, 102]),
[(101, "\\Seen"), (102, "")],
)
def test_skips_entries_without_a_uid(self):
self.client.uid.return_value = (
"OK",
[b")", None, b"1 (UID 101 FLAGS (\\Seen))"],
)
self.assertEqual(self.transport.fetch_flags([101]), [(101, "\\Seen")])
def test_failure_raises(self):
self.client.uid.return_value = ("NO", [b"refuse"])
with self.assertRaises(ImapError):
self.transport.fetch_flags([101])
class TestAppend(unittest.TestCase):
def setUp(self):
self.client = MagicMock()
self.transport = ImaplibTransport(self.client)
def test_quotes_the_folder_and_joins_the_flags(self):
self.client.append.return_value = ("OK", [b"fait"])
self.transport.append("Sent Items", b"brut", ["\\Seen"])
self.client.append.assert_called_once_with(
'"Sent Items"', "(\\Seen)", None, b"brut"
)
def test_failure_raises(self):
self.client.append.return_value = ("NO", [b"refuse"])
with self.assertRaises(ImapError):
self.transport.append("Sent", b"brut", [])
class TestConnect(unittest.TestCase):
"""`connect` est le code le plus sensible au protocole du fichier.
Aucun de ces tests ne joint le réseau : `imaplib` est remplacé.
"""
def _account(self, security):
account = account_from_preset("perso", "moi@x.ca", "generic")
account.imap.host = "imap.x.ca"
account.imap.port = 993
account.imap.security = security
return account
def test_ssl_branch(self):
client = MagicMock()
with patch("imaplib.IMAP4_SSL", return_value=client) as ctor:
transport = connect(self._account("ssl"), "hunter2")
ctor.assert_called_once_with("imap.x.ca", 993, timeout=30)
client.login.assert_called_once_with("moi@x.ca", "hunter2")
self.assertIsInstance(transport, ImaplibTransport)
def test_starttls_branch_upgrades(self):
client = MagicMock()
with patch("imaplib.IMAP4", return_value=client) as ctor:
connect(self._account("starttls"), "hunter2")
client.starttls.assert_called_once()
ctor.assert_called_once_with("imap.x.ca", 993, timeout=30)
def test_plain_branch_does_not_upgrade(self):
client = MagicMock()
with patch("imaplib.IMAP4", return_value=client):
connect(self._account("none"), "hunter2")
client.starttls.assert_not_called()
def test_login_failure_becomes_an_imap_error(self):
client = MagicMock()
client.login.side_effect = OSError("530 refus")
with patch("imaplib.IMAP4_SSL", return_value=client):
with self.assertRaises(ImapError) as ctx:
connect(self._account("ssl"), "mauvais")
self.assertIn("530", str(ctx.exception))
def test_a_non_ascii_password_says_it_never_left(self):
"""`imaplib` encode LOGIN en ASCII : un mot de passe accentué
n'atteint pas le serveur. Le message général dit « refusée », ce qui
accuserait le serveur d'un refus qu'il n'a pas prononcé — et
enverrait chercher la panne du mauvais côté du réseau."""
client = MagicMock()
client.login.side_effect = UnicodeEncodeError(
"ascii", "motdepassé", 10, 11, "ordinal not in range(128)"
)
with patch("imaplib.IMAP4_SSL", return_value=client):
with self.assertRaises(ImapError) as ctx:
connect(self._account("ssl"), "motdepassé")
message = str(ctx.exception)
self.assertIn("ASCII", message)
self.assertNotIn("refusée", message)
self.assertNotIn("ordinal", message)
def test_connection_failure_becomes_an_imap_error(self):
with patch("imaplib.IMAP4_SSL", side_effect=OSError("injoignable")):
with self.assertRaises(ImapError):
connect(self._account("ssl"), "hunter2")

View file

@ -0,0 +1,670 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Les tests courriel qui parlent à un VRAI serveur.
Tous les autres tests du paquet `mail` passent par un double. Ceux-ci ouvrent
une socket sur 127.0.0.1, vers un serveur IMAP (Twisted) et un serveur SMTP
(aiosmtpd) démarrés puis tués par le test lui-même — voir `mail_sandbox.py`.
Ce qu'on cherche n'est PAS la conformité : c'est de reproduire ce qu'un
double n'aurait jamais produit, parce que son auteur ne l'avait pas imaginé.
Chaque classe ci-dessous nomme le risque réel qu'elle couvre.
Ils NE tournent PAS dans la boucle rapide : sans `twisted` ni `aiosmtpd`,
tout le fichier se saute proprement. Pour les lancer volontairement :
.venv.erplibre/bin/python -m unittest discover -s test \\
-p test_mail_live_server.py -v
"""
import email.utils
import threading
import unittest
import unittest.mock
try:
import aiosmtpd # noqa: F401
import twisted # noqa: F401
SANDBOX_MISSING = ""
except ImportError as exc: # pragma: no cover - dépend de l'installation
SANDBOX_MISSING = str(exc)
if SANDBOX_MISSING: # pragma: no cover - dépend de l'installation
MailSandboxCase = unittest.TestCase
else:
from mail_sandbox import (
LIVE_SERVERS,
PASSWORD,
DropConnection,
MailSandboxCase,
RefuseCommand,
port_is_closed,
sandbox_account,
)
requires_servers = unittest.skipIf(
bool(SANDBOX_MISSING),
f"serveurs de test absents ({SANDBOX_MISSING})"
" : pip install -r requirement/erplibre_require-ments.txt",
)
# Le message qui a cassé la production. Deux méchancetés en une : un mot
# encodé RFC 2047 qui annonce `unknown-8bit`, étiquette qu'aucun codec Python
# ne connaît, et un `From` qui porte des octets 8 bits BRUTS, sans encodage
# d'aucune sorte. Les deux existent en vrai ; aucun double ne les produisait.
HOSTILE = (
b"From: Ren\xe9 Lavall\xe9e <rene@example.org>\r\n"
b"To: moi@example.ca\r\n"
b"Subject: =?unknown-8bit?Q?sujet_h=E9rit=E9?=\r\n"
b"Date: Wed, 06 Aug 2026 10:00:00 +0000\r\n"
b"Message-ID: <hostile-1@example.org>\r\n"
b'Content-Type: text/plain; charset="unknown-8bit"\r\n'
b"\r\n"
b"Bonjour, corps accentu\xe9.\r\n"
)
def polite(uid: int, subject: str = "Devis") -> bytes:
"""Un message ordinaire, servi par le chemin normal de Twisted."""
return (
f"From: Alice <alice@example.org>\r\n"
f"To: moi@example.ca\r\n"
f"Subject: {subject}\r\n"
f"Date: Wed, 06 Aug 2026 10:0{uid}:00 +0000\r\n"
f"Message-ID: <poli-{uid}@example.org>\r\n"
f"\r\n"
f"Corps du message {uid}.\r\n"
).encode()
@requires_servers
class TestUnknown8bitDoesNotAbortSync(MailSandboxCase):
"""Bug 1 : `unknown encoding: unknown-8bit` arrêtait la sync d'un dossier
pour de bon — le dossier échouait à chaque passe, donc son `last_uid`
n'avançait jamais et l'utilisateur ne voyait plus rien arriver.
Le risque couvert : un en-tête qu'aucun codec Python ne sait lire ne doit
pas coûter le dossier. Ici l'étiquette arrive VRAIMENT du réseau, décodée
par `imaplib` puis `parse_fetch_headers`, et non fabriquée par le test.
"""
def setUp(self):
self.imap = self.imap_server()
self.imap.folder("INBOX").deliver(HOSTILE, flags=["\\Seen"])
self.account = sandbox_account(imap_port=self.imap.port)
self.store = self.temp_store(self.account)
self.transport = self.imap_transport(self.imap, self.account)
def sync(self):
from script.todo.mail.imap_sync import Syncer
return Syncer(self.store, self.transport).sync()
def stored(self):
state = self.store.folder_state("INBOX")
return self.store.list_messages(state["id"])
def test_sync_reports_no_error(self):
self.assertEqual(self.sync().errors, [])
def test_the_message_is_stored(self):
self.sync()
self.assertEqual(len(self.stored()), 1)
def test_the_unknown_charset_is_substituted_not_fatal(self):
"""`charset.decode_bytes` remplace ce qu'il ne sait pas lire. Sans ce
garde-fou, `decode_header` lève `LookupError` et le message n'existe
pas du tout."""
self.sync()
subject = self.stored()[0].subject
self.assertTrue(subject.startswith("sujet h"))
self.assertIn("<EFBFBD>", subject)
def test_raw_8bit_header_bytes_survive_the_wire(self):
"""Le `From` part en octets 8 bits bruts, sans encodage : c'est ce
que fait un vrai MTA relâché, et ce que Twisted refuse de reformater
(voir `SandboxIMAP4Server.spew_body`)."""
self.sync()
self.assertIn("Lavall", self.stored()[0].frm)
self.assertIn("rene@example.org", self.stored()[0].frm)
def test_the_body_still_yields_a_snippet(self):
"""Le corps porte le même charset inconnu : ouvrir le message ne doit
pas échouer non plus."""
from script.todo.mail.imap_sync import Syncer
syncer = Syncer(self.store, self.transport)
syncer.sync()
raw = syncer.fetch_body("INBOX", 1)
self.assertIn(b"Bonjour", raw)
self.assertIn("Bonjour", self.stored()[0].snippet)
def test_the_bytes_are_served_verbatim(self):
"""Le corps rendu par le serveur est OCTET POUR OCTET celui déclaré :
si le bac à sable réécrivait quoi que ce soit, il ne prouverait plus
rien sur le vrai réseau."""
from script.todo.mail.imap_sync import Syncer
syncer = Syncer(self.store, self.transport)
syncer.sync()
self.assertEqual(syncer.fetch_body("INBOX", 1), HOSTILE)
@requires_servers
class TestWhatLeavesBySmtp(MailSandboxCase):
"""Le risque couvert : le Cci ne doit JAMAIS apparaître dans un en-tête.
C'est la propriété de sécurité que tout le plan protège, et jusqu'ici
elle n'était vérifiée que sur l'objet `EmailMessage` que nous tenions en
main. Ici on l'affirme sur les OCTETS reçus à l'autre bout de la socket —
ce que le serveur a vraiment lu.
"""
def setUp(self):
from script.todo.mail import smtp_send
self.smtp = self.smtp_server()
self.account = sandbox_account(
smtp_port=self.smtp.port,
address="moi@example.ca",
display_name="Mathieu Benoit",
)
self.transport = smtp_send.connect(self.account, PASSWORD)
self.addCleanup(self.transport.quit)
self.msg = smtp_send.build_message(
self.account,
["alice@example.org"],
"Devis daté d'août",
"Bonjour Alice",
cc=["copie@example.org"],
bcc=["cache@example.org"],
date="Fri, 01 Aug 2026 10:41:00 +0000",
msgid="<fixe@erplibre>",
)
self.served = smtp_send.send(self.account, self.msg, self.transport)
@property
def sent(self):
return self.smtp.messages[0]
def test_the_server_received_exactly_one_message(self):
self.assertEqual(len(self.smtp.messages), 1)
def test_bcc_is_absent_from_the_bytes_that_left(self):
self.assertNotIn(b"cache@example.org", self.sent.content)
self.assertNotIn(b"Bcc", self.sent.content)
self.assertNotIn(b"X-ERPLibre-Bcc", self.sent.content)
def test_bcc_is_absent_from_every_header(self):
parsed = self.sent.headers()
self.assertIsNone(parsed.get("Bcc"))
self.assertIsNone(parsed.get("X-ERPLibre-Bcc"))
def test_bcc_is_served_by_the_envelope(self):
"""Caché ne veut pas dire non livré : le Cci ne vit que dans
l'enveloppe SMTP, que le serveur nous rend ici telle qu'il l'a
reçue."""
self.assertIn("cache@example.org", self.sent.rcpt_tos)
self.assertEqual(
sorted(self.sent.rcpt_tos),
sorted(
["alice@example.org", "copie@example.org", "cache@example.org"]
),
)
def test_the_envelope_sender_is_the_account(self):
self.assertEqual(self.sent.mail_from, "moi@example.ca")
def test_send_reports_the_recipients_it_served(self):
self.assertEqual(sorted(self.served), sorted(self.sent.rcpt_tos))
def test_nothing_8bit_left_on_the_wire(self):
"""Un en-tête accentué doit partir encodé RFC 2047. Sorti brut, il
serait mutilé par le premier relais venu — et c'est exactement le
genre d'octet qui nous est revenu en `unknown-8bit`."""
headers = self.sent.content.split(b"\r\n\r\n", 1)[0]
headers.decode("ascii") # lève si un octet 8 bits a fui
def test_the_accented_subject_arrives_intact(self):
self.assertEqual(
self.sent.headers()["Subject"],
"Devis =?utf-8?q?dat=C3=A9_d=27ao=C3=BBt?=",
)
from script.todo.mail.imap_transport import decode_header_value
self.assertEqual(
decode_header_value(self.sent.headers()["Subject"]),
"Devis daté d'août",
)
def test_the_visible_headers_are_the_ones_we_built(self):
parsed = self.sent.headers()
self.assertEqual(parsed["From"], "Mathieu Benoit <moi@example.ca>")
self.assertEqual(parsed["To"], "alice@example.org")
self.assertEqual(parsed["Cc"], "copie@example.org")
self.assertEqual(parsed["Message-ID"], "<fixe@erplibre>")
@requires_servers
class TestSmtpRefusals(MailSandboxCase):
"""Le risque couvert : un refus du serveur doit devenir une `SmtpError`
lisible, jamais une exception brute remontée dans le TUI."""
def test_a_wrong_password_is_an_smtp_error(self):
"""Le serveur EXIGE l'authentification et la refuse pour de vrai —
un 535 sur le fil, pas une exception injectée.
La sous-classe ne change rien au protocole : elle ne sert qu'à
retenir la connexion pour la fermer. `smtp_send.connect()` ne ferme
pas sa socket quand `login()` échoue — elle traîne jusqu'au
ramasse-miettes, ce qui est bénin dans le TUI mais laisserait ici un
`ResourceWarning` attaché à un test au hasard.
"""
import smtplib
from script.todo.mail.smtp_send import SmtpError, connect
opened = []
class Recording(smtplib.SMTP):
def __init__(inner, *args, **kwargs):
super().__init__(*args, **kwargs)
opened.append(inner)
smtp = self.smtp_server(require_auth=True)
account = sandbox_account(smtp_port=smtp.port)
with unittest.mock.patch("smtplib.SMTP", Recording):
with self.assertRaises(SmtpError):
connect(account, "mauvais-mot-de-passe")
for client in opened:
client.close()
def test_a_closed_port_is_an_smtp_error(self):
"""Le serveur est démarré puis tué : le port est fermé pour de bon,
et personne d'autre n'écoute dessus."""
from script.todo.mail.smtp_send import SmtpError, connect
smtp = self.smtp_server()
port = smtp.port
smtp.stop()
account = sandbox_account(smtp_port=port)
with self.assertRaises(SmtpError):
connect(account, PASSWORD)
@requires_servers
class TestConnectionDroppedMidSync(MailSandboxCase):
"""Le risque couvert : une coupure en pleine passe ne doit ni faire
tomber le TUI, ni PERDRE des messages en avançant `last_uid` sur des
en-têtes jamais reçus. Aucun double ne coupe jamais la ligne : il répond
toujours.
"""
def setUp(self):
self.imap = self.imap_server()
inbox = self.imap.folder("INBOX")
for uid in (1, 2, 3):
inbox.deliver(polite(uid), uid=uid)
self.account = sandbox_account(imap_port=self.imap.port)
self.store = self.temp_store(self.account)
def syncer(self):
from script.todo.mail.imap_sync import Syncer
return Syncer(self.store, self.imap_transport(self.imap, self.account))
def test_a_drop_is_reported_not_raised(self):
self.imap.fail(DropConnection(b"FETCH"))
with self.assertLogs("script.todo.mail.imap_sync", level="ERROR"):
report = self.syncer().sync()
self.assertEqual(len(report.errors), 1)
self.assertIn("INBOX", report.errors[0])
def test_a_drop_before_the_headers_loses_nothing(self):
"""La coupure tombe sur le tout premier FETCH : rien n'a été stocké,
donc `last_uid` ne doit pas avoir bougé — sinon les trois messages
seraient sautés pour toujours."""
self.imap.fail(DropConnection(b"FETCH"))
with self.assertLogs("script.todo.mail.imap_sync", level="ERROR"):
self.syncer().sync()
state = self.store.folder_state("INBOX")
self.assertEqual(state["last_uid"], 0)
def test_the_next_pass_recovers_every_message(self):
self.imap.fail(DropConnection(b"FETCH"))
with self.assertLogs("script.todo.mail.imap_sync", level="ERROR"):
self.syncer().sync()
self.imap.faults.clear()
report = self.syncer().sync()
self.assertEqual(report.errors, [])
self.assertEqual(report.new_messages, 3)
state = self.store.folder_state("INBOX")
self.assertEqual(
sorted(m.uid for m in self.store.list_messages(state["id"])),
[1, 2, 3],
)
def test_a_drop_after_the_headers_keeps_what_arrived(self):
"""Cette fois la coupure tombe sur le FETCH des drapeaux, après que
les en-têtes soient descendus : le travail déjà fait doit rester."""
self.imap.fail(DropConnection(b"FETCH", after=1))
with self.assertLogs("script.todo.mail.imap_sync", level="ERROR"):
report = self.syncer().sync()
self.assertEqual(report.new_messages, 3)
state = self.store.folder_state("INBOX")
self.assertEqual(len(self.store.list_messages(state["id"])), 3)
def test_a_refused_folder_does_not_cost_the_others(self):
"""Un NO sur un dossier — droits, quota, boîte verrouillée — laisse
le reste de la boîte utilisable. Ici le refus vient du serveur, pas
d'une exception que le test aurait injectée."""
self.imap.folder("INBOX.Archive").deliver(polite(9), uid=9)
self.imap.fail(
RefuseCommand(b"SELECT", after=1, text=b"Mailbox locked")
)
with self.assertLogs("script.todo.mail.imap_sync", level="ERROR"):
report = self.syncer().sync()
self.assertEqual(report.folders, 2)
self.assertEqual(len(report.errors), 1)
self.assertEqual(report.new_messages, 3)
@requires_servers
class TestSentCopyLandsOnTheServer(MailSandboxCase):
"""Bug 3 : la liste montrait un état périmé après un envoi.
Le chemin réel est APPEND puis `sync_one` sur le dossier Envoyés. Le
risque couvert : la copie doit exister CHEZ LE SERVEUR, sous l'UID que le
serveur attribue — pas un UID inventé localement, qui entrerait en
collision avec un futur message réel — et elle ne doit pas rouvrir la
porte du Cci par ce second chemin.
"""
def setUp(self):
from script.todo.mail import smtp_send
self.imap = self.imap_server()
self.sent_box = self.imap.folder("INBOX.Sent")
self.sent_box.deliver(polite(1, "Un envoi précédent"), uid=1)
self.account = sandbox_account(
imap_port=self.imap.port, sent_folder="INBOX.Sent"
)
self.store = self.temp_store(self.account)
self.transport = self.imap_transport(self.imap, self.account)
self.msg = smtp_send.build_message(
self.account,
["alice@example.org"],
"Copie classée",
"Bonjour",
bcc=["cache@example.org"],
date="Fri, 01 Aug 2026 10:41:00 +0000",
msgid="<copie@erplibre>",
)
def file_the_copy(self):
"""Exactement ce que fait `deliver()` dans `tui.py` : `without_bcc`
puis APPEND, puis une sync ciblée."""
from script.todo.mail.imap_sync import Syncer
from script.todo.mail.smtp_send import without_bcc
self.transport.append(
self.account.sent_folder,
without_bcc(self.msg).as_bytes(),
["\\Seen"],
)
return Syncer(self.store, self.transport).sync_one(
self.account.sent_folder
)
def test_the_server_accepted_the_append(self):
self.file_the_copy()
self.assertEqual(len(self.sent_box.appended), 1)
def test_the_copy_the_server_stored_carries_no_bcc(self):
"""Assertion sur les octets DÉPOSÉS, pas sur notre objet en mémoire :
c'est la seconde porte par laquelle le Cci pourrait fuir."""
self.file_the_copy()
stored, _flags = self.sent_box.appended[0]
self.assertNotIn(b"cache@example.org", stored)
self.assertNotIn(b"X-ERPLibre-Bcc", stored)
def test_the_flags_we_asked_for_reached_the_server(self):
self.file_the_copy()
_stored, flags = self.sent_box.appended[0]
self.assertIn("\\Seen", flags)
def test_the_copy_shows_up_in_the_list(self):
report = self.file_the_copy()
self.assertEqual(report.errors, [])
state = self.store.folder_state("INBOX.Sent")
subjects = {m.subject for m in self.store.list_messages(state["id"])}
self.assertIn("Copie classée", subjects)
def test_the_uid_comes_from_the_server(self):
"""La boîte contenait déjà un message : la copie doit porter l'UID 2,
celui que le serveur a attribué."""
self.file_the_copy()
state = self.store.folder_state("INBOX.Sent")
stored = {
m.subject: m.uid for m in self.store.list_messages(state["id"])
}
self.assertEqual(stored["Copie classée"], 2)
@requires_servers
class TestFolderListing(MailSandboxCase):
"""Le risque couvert : les noms de dossiers viennent d'une réponse LIST
réelle, avec son délimiteur, ses guillemets et son UTF-7 modifié, pas
d'une chaîne que le test aurait écrite dans le format qui l'arrange.
C'est ce que le bac à sable donne gratuitement : Twisted encode de
lui-même le nom accentué en UTF-7 modifié (RFC 3501), un aller-retour
qu'aucun double n'avait jamais fait faire à `decode_mailbox`.
"""
ACCENTED = "INBOX.Courriels envoyés"
def setUp(self):
self.imap = self.imap_server()
for name in ("INBOX", "INBOX.Sent", self.ACCENTED):
self.imap.folder(name).deliver(polite(1), uid=1)
self.transport = self.imap_transport(self.imap)
def test_every_folder_is_listed(self):
self.assertEqual(len(self.transport.list_folders()), 3)
def test_the_accented_name_arrives_in_modified_utf7(self):
"""Le nom BRUT est celui du fil — c'est lui qu'il faudra renvoyer au
serveur pour sélectionner le dossier, pas sa version lisible."""
names = {f.name for f in self.transport.list_folders()}
self.assertIn("INBOX.Courriels envoy&AOk-s", names)
def test_the_accented_name_is_decoded_for_display(self):
display = {f.name: f.display for f in self.transport.list_folders()}
self.assertEqual(display["INBOX.Courriels envoy&AOk-s"], self.ACCENTED)
def test_inbox_gets_its_role_from_its_name(self):
roles = {f.name: f.role for f in self.transport.list_folders()}
self.assertEqual(roles["INBOX"], "inbox")
def test_no_special_use_role_is_announced(self):
"""LACUNE ASSUMÉE — bug 2 (le dossier Envoyés annoncé par le serveur)
reste hors de portée : Twisted n'annonce que
`IMAP4REV1 NAMESPACE IDLE`, sans `SPECIAL-USE`. Implémenter
l'extension nous-mêmes reviendrait à tester notre propre supposition
sur elle — précisément l'erreur que ce bac à sable existe pour
éviter. Ce test verrouille la lacune au lieu de la maquiller : le
jour où un serveur de test l'annoncera, il échouera et rappellera
qu'il y a mieux à écrire."""
self.assertNotIn(
"SPECIAL-USE",
str(self.transport.client.capabilities).upper(),
)
roles = {f.role for f in self.transport.list_folders()}
self.assertEqual(roles - {"inbox"}, {None})
@requires_servers
class TestSandboxLifecycle(MailSandboxCase):
"""Le risque couvert : une socket d'écoute oubliée ou un fil coincé
empoisonneraient toute la suite. Ces tests-ci vérifient le bac à sable
lui-même, pas le client."""
def test_ports_are_ephemeral_and_distinct(self):
first, second = self.imap_server(), self.imap_server()
self.assertNotEqual(first.port, 0)
self.assertNotEqual(first.port, second.port)
def test_a_failing_test_still_closes_its_servers(self):
"""La preuve de non-fuite : on fait ÉCHOUER un test à l'intérieur du
test, et on vérifie que ses serveurs sont morts quand même."""
ports = {}
class Doomed(MailSandboxCase):
def runTest(inner):
ports["imap"] = inner.imap_server().port
ports["smtp"] = inner.smtp_server().port
inner.fail("échec provoqué")
before = set(LIVE_SERVERS)
result = unittest.TestResult()
Doomed().run(result)
self.assertEqual(len(result.failures), 1)
self.assertEqual(set(LIVE_SERVERS), before)
self.assertTrue(port_is_closed(ports["imap"]))
self.assertTrue(port_is_closed(ports["smtp"]))
def test_an_open_client_session_does_not_survive_the_test(self):
"""`stopListening` seul cesse d'ACCEPTER : une session cliente restée
ouverte garderait un descripteur vivant d'un test à l'autre."""
ports = {}
class LeavesAClientBehind(MailSandboxCase):
def runTest(inner):
sandbox = inner.imap_server()
sandbox.folder("INBOX")
ports["imap"] = sandbox.port
inner.imap_transport(sandbox) # jamais fermée par le test
result = unittest.TestResult()
LeavesAClientBehind().run(result)
self.assertEqual(result.errors, [])
self.assertTrue(port_is_closed(ports["imap"]))
def test_only_one_reactor_thread_exists(self):
"""Le cœur du choix de conception : le réacteur Twisted ne se
redémarre pas, donc il n'y en a qu'UN pour toute la session, quel que
soit le nombre de serveurs démarrés."""
self.imap_server()
self.imap_server()
reactors = [
th
for th in threading.enumerate()
if th.name == "mail-sandbox-reactor"
]
self.assertEqual(len(reactors), 1)
def test_smtp_threads_do_not_pile_up(self):
"""`aiosmtpd` porte sa propre boucle asyncio dans un fil : chaque
serveur arrêté doit rendre le sien."""
before = threading.active_count()
for _ in range(3):
self.smtp_server().stop()
self.assertLessEqual(threading.active_count(), before)
@requires_servers
class TestSandboxServesWhatWasDeclared(MailSandboxCase):
"""Le bac à sable n'est utile que s'il ne réécrit rien. Si ces
assertions-là tombent, tous les autres tests de ce fichier ne prouvent
plus rien sur le vrai réseau."""
def selected(self, imap, folder="INBOX"):
transport = self.imap_transport(imap)
transport.select(folder)
return transport
def test_a_polite_message_goes_through_twisteds_own_path(self):
"""Par défaut le serveur est POLI : les en-têtes ASCII sont formatés
par Twisted, pas par nous (voir `SandboxIMAP4Server.spew_body`)."""
imap = self.imap_server()
imap.folder("INBOX").deliver(polite(1, "Devis"), uid=1)
headers = self.selected(imap).fetch_headers([1])
self.assertEqual(headers[0].subject, "Devis")
self.assertEqual(headers[0].msgid, "<poli-1@example.org>")
def test_flags_and_size_come_from_the_server(self):
imap = self.imap_server()
raw = polite(1)
imap.folder("INBOX").deliver(raw, flags=["\\Seen"], uid=1)
headers = self.selected(imap).fetch_headers([1])
self.assertIn("\\Seen", headers[0].flags)
self.assertEqual(headers[0].size, len(raw))
def test_the_date_is_parsed_from_the_served_header(self):
imap = self.imap_server()
imap.folder("INBOX").deliver(polite(1), uid=1)
headers = self.selected(imap).fetch_headers([1])
expected = int(
email.utils.parsedate_to_datetime(
"Wed, 06 Aug 2026 10:01:00 +0000"
).timestamp()
)
self.assertEqual(headers[0].date, expected)
def test_only_the_requested_uids_come_back(self):
imap = self.imap_server()
for uid in (1, 2, 3):
imap.folder("INBOX").deliver(polite(uid), uid=uid)
headers = self.selected(imap).fetch_headers([2])
self.assertEqual([h.uid for h in headers], [2])
def test_lf_only_line_endings_are_still_split_correctly(self):
"""`EmailMessage.as_bytes()` — ce que le client dépose par APPEND —
termine ses lignes en LF SEUL, alors que le fil IMAP est en CRLF.
Le message commence par un `Received` que le client ne demande pas,
comme tout message ayant traversé un relais. Un découpage qui
n'attendrait que du CRLF verrait UNE seule ligne, nommée `Received`,
donc hors du filtre : le bloc rendu serait VIDE, sans rien lever.
L'octet 8 bits dans `From` force le chemin verbatim, le seul où ce
découpage sert.
"""
imap = self.imap_server()
raw = (
"Received: from relais.example.org by example.ca; "
"Wed, 06 Aug 2026 10:01:00 +0000\n"
"From: Ren\xe9 <rene@example.org>\n"
"To: moi@example.ca\n"
"Subject: Devis\n"
"Date: Wed, 06 Aug 2026 10:01:00 +0000\n"
"Message-ID: <lf-1@example.org>\n"
"\n"
"Corps.\n"
).encode("latin-1")
imap.folder("INBOX").deliver(raw, uid=1)
headers = self.selected(imap).fetch_headers([1])
self.assertEqual(headers[0].msgid, "<lf-1@example.org>")
self.assertEqual(headers[0].subject, "Devis")
self.assertIn("rene@example.org", headers[0].frm)
def test_search_only_returns_uids_at_or_above_the_mark(self):
imap = self.imap_server()
for uid in (1, 2, 3):
imap.folder("INBOX").deliver(polite(uid), uid=uid)
self.assertEqual(self.selected(imap).search_uids(2), [2, 3])
def test_store_flags_reaches_the_server(self):
imap = self.imap_server()
box = imap.folder("INBOX")
box.deliver(polite(1), uid=1)
transport = self.selected(imap)
transport.store_flags(1, ["\\Seen"], [])
self.assertEqual(transport.fetch_flags([1]), [(1, "\\Seen")])
self.assertEqual(box.messages[0].flags, ["\\Seen"])
if __name__ == "__main__":
unittest.main()

821
test/test_mail_menu.py Normal file
View file

@ -0,0 +1,821 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
import json
import logging
import os
import tempfile
import unittest
from pathlib import Path
from script.todo.mail.accounts import account_from_preset
from script.todo.mail.menu import cache_summary
from script.todo.mail.store import Store
class TestCacheSummary(unittest.TestCase):
# Sans injection, `resolve_mode` retombe sur les VRAIES préférences de la
# machine, et `todo_prefs` crée `~/.erplibre` au passage.
CLEAR = staticmethod(lambda k, d=None: "clear")
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.base = Path(self.tmp.name)
self.accounts = [
account_from_preset("perso", "a@x.ca", "generic"),
account_from_preset("travail", "b@x.ca", "generic"),
]
def tearDown(self):
self.tmp.cleanup()
def test_one_row_per_account(self):
rows = cache_summary(
self.accounts, base=self.base, prefs_get=self.CLEAR
)
self.assertEqual([r["name"] for r in rows], ["perso", "travail"])
def test_reports_the_effective_mode(self):
self.accounts[0].cache_mode = "encrypted"
rows = cache_summary(
self.accounts, base=self.base, prefs_get=lambda k, d=None: "clear"
)
self.assertEqual(rows[0]["mode"], "encrypted")
self.assertEqual(rows[1]["mode"], "clear")
def test_size_is_zero_before_any_sync(self):
rows = cache_summary(
self.accounts, base=self.base, prefs_get=self.CLEAR
)
self.assertEqual(rows[0]["size"], 0)
def test_size_grows_with_the_cache(self):
store = Store(self.accounts[0], mode="clear", base=self.base)
store.open()
store.upsert_folder("INBOX")
store.write_body("INBOX", 1, b"x" * 4096)
store.close()
rows = cache_summary(
self.accounts, base=self.base, prefs_get=self.CLEAR
)
self.assertGreater(rows[0]["size"], 4000)
def test_missing_cache_does_not_raise(self):
rows = cache_summary(
self.accounts,
base=self.base / "inexistant",
prefs_get=self.CLEAR,
)
self.assertEqual(len(rows), 2)
class TestAddAccountRollsBack(unittest.TestCase):
"""Une sauvegarde ratée ne doit pas laisser le mot de passe dans le coffre."""
def test_failed_save_removes_the_orphan_secret(self):
from unittest.mock import MagicMock, patch
import script.todo.mail.menu as menu
vault = MagicMock()
vault.available_backends.return_value = ["kdbx"]
with patch.object(
menu, "secret_store_for", return_value=vault
), patch.object(
menu.mail_accounts, "save", side_effect=OSError("disque plein")
), patch.object(
menu, "_load_accounts", return_value=[]
), patch(
"builtins.input",
side_effect=[
"perso",
"moi@x.ca",
"",
"4",
"imap.x.ca",
"smtp.x.ca",
],
), patch(
"getpass.getpass", return_value="hunter2"
):
menu._add_account(MagicMock())
vault.set.assert_called_once()
vault.delete.assert_called_once_with("kdbx:ERPLibre/Mail/perso")
def test_failed_save_does_not_crash_the_menu(self):
from unittest.mock import MagicMock, patch
import script.todo.mail.menu as menu
vault = MagicMock()
vault.available_backends.return_value = ["kdbx"]
with patch.object(
menu, "secret_store_for", return_value=vault
), patch.object(
menu.mail_accounts, "save", side_effect=OSError("disque plein")
), patch.object(
menu, "_load_accounts", return_value=[]
), patch(
"builtins.input",
side_effect=[
"perso",
"moi@x.ca",
"",
"4",
"imap.x.ca",
"smtp.x.ca",
],
), patch(
"getpass.getpass", return_value="hunter2"
):
menu._add_account(MagicMock()) # ne doit pas lever
class TestOpenTuiAllowsEmptyAccounts(unittest.TestCase):
"""Le TUI sait désormais créer un compte depuis son propre écran : le
refus historique de s'ouvrir sans compte (`mail_no_account`) défait
exactement la fonctionnalité que cette tâche ajoute."""
def test_opens_with_zero_accounts_instead_of_refusing(self):
from unittest.mock import MagicMock, patch
import script.todo.mail.menu as menu
todo = MagicMock()
with patch.object(menu, "_load_accounts", return_value=[]), patch(
"script.todo.mail.tui.open_sessions", return_value=[]
) as mock_open, patch("script.todo.mail.tui.run_tui") as mock_run:
menu._open_tui(todo)
mock_open.assert_called_once()
mock_run.assert_called_once()
def test_passes_config_file_and_the_secret_store_through(self):
"""Sans ça, l'écran d'ajout de compte du TUI n'aurait ni où écrire
le chemin du kdbx, ni de coffre pour y déposer le mot de passe."""
from unittest.mock import MagicMock, patch
import script.todo.mail.menu as menu
todo = MagicMock()
secrets = MagicMock()
with patch.object(
menu, "_load_accounts", return_value=[]
), patch.object(menu, "secret_store_for", return_value=secrets), patch(
"script.todo.mail.tui.open_sessions", return_value=[]
), patch(
"script.todo.mail.tui.run_tui"
) as mock_run:
menu._open_tui(todo)
_, kwargs = mock_run.call_args
self.assertIs(kwargs["config_file"], todo.config_file)
self.assertIs(kwargs["secret_store"], secrets)
class TestSyncNowSurfacesResync(unittest.TestCase):
"""`report.purged` (dossiers vidés car l'UIDVALIDITY a changé) doit
atteindre l'utilisateur — il ne suffit pas qu'il soit calculé et testé
dans `imap_sync.py`, encore faut-il qu'un appelant l'affiche."""
def test_purged_folders_are_printed(self):
import io
from contextlib import redirect_stdout
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import script.todo.mail.menu as menu
account = account_from_preset("perso", "a@x.ca", "generic")
class FakeSession:
def __init__(self):
self.account = account
self.online = True
self.error = ""
def sync(self):
return SimpleNamespace(
new_messages=1, errors=[], purged=["INBOX"]
)
def close(self):
pass
buf = io.StringIO()
with patch.object(
menu, "_load_accounts", return_value=[account]
), patch(
"script.todo.mail.tui.open_sessions",
return_value=[FakeSession()],
), redirect_stdout(
buf
):
menu._sync_now(MagicMock())
self.assertIn("INBOX", buf.getvalue())
class TestMailLogFile(unittest.TestCase):
"""Aucun gestionnaire n'existait nulle part dans `script/todo/mail/`
avant ce correctif : les modules journalisent (`_logger.exception(...)`),
mais brancher un gestionnaire est le travail de L'APPLICATION — ici,
`prompt_execute_mail`, le seul point d'entrée du paquet. Jamais vers la
console : Textual possède le terminal pendant tout le TUI.
"""
def setUp(self):
self.fake_home = tempfile.TemporaryDirectory()
self._old_home = os.environ.get("HOME")
os.environ["HOME"] = self.fake_home.name
def tearDown(self):
import script.todo.mail.menu as menu
logger = logging.getLogger("script.todo.mail")
for handler in list(logger.handlers):
logger.removeHandler(handler)
handler.close()
logger.propagate = True
menu._LOG_CONFIGURED = False
if self._old_home is None:
os.environ.pop("HOME", None)
else:
os.environ["HOME"] = self._old_home
self.fake_home.cleanup()
def test_creates_the_log_file_under_home(self):
import script.todo.mail.menu as menu
menu._configure_mail_logging()
logging.getLogger("script.todo.mail.tui").error("boum")
log_path = Path(self.fake_home.name) / ".erplibre" / "mail.log"
self.assertTrue(log_path.exists())
self.assertIn("boum", log_path.read_text())
def test_is_idempotent(self):
import script.todo.mail.menu as menu
menu._configure_mail_logging()
menu._configure_mail_logging()
logger = logging.getLogger("script.todo.mail")
file_handlers = [
h for h in logger.handlers if isinstance(h, logging.FileHandler)
]
self.assertEqual(len(file_handlers), 1)
def test_never_installs_a_console_handler(self):
import script.todo.mail.menu as menu
menu._configure_mail_logging()
logger = logging.getLogger("script.todo.mail")
non_file_stream_handlers = [
h
for h in logger.handlers
if isinstance(h, logging.StreamHandler)
and not isinstance(h, logging.FileHandler)
]
self.assertEqual(non_file_stream_handlers, [])
def test_never_leaks_to_the_console_via_root_propagation(self):
"""`script/todo/todo.py:68` calls `logging.basicConfig()` at
import time, which installs a `StreamHandler` on the ROOT logger.
`propagate` defaults to `True`: without disabling it explicitly,
every `_logger.exception(...)` in the mail package would ALSO
reach that root handler — straight onto the terminal Textual owns
for the whole TUI. `test_never_installs_a_console_handler` above
cannot catch this: it only inspects `script.todo.mail`'s OWN
handlers, never what a PARENT logger does with a propagated
record.
Reproduced for real in the full suite: this exact leak showed up,
unprompted, in the middle of `unittest`'s dotted progress output
the first time the mail tests ran in a process where
`script.todo.todo` (and its `basicConfig`) had already been
imported by an earlier test file.
"""
import io
import script.todo.mail.menu as menu
import script.todo.todo # noqa: F401 - installe le handler racine
root = logging.getLogger()
buf = io.StringIO()
capture = logging.StreamHandler(buf)
root.addHandler(capture)
try:
menu._configure_mail_logging()
logging.getLogger("script.todo.mail.tui").error("ne doit pas fuir")
finally:
root.removeHandler(capture)
self.assertEqual(buf.getvalue(), "")
def test_prompt_execute_mail_configures_logging_before_the_loop(self):
"""`prompt_execute_mail` est le seul point d'entrée du paquet
`mail` : c'est là, et nulle part ailleurs, que le gestionnaire doit
être branché."""
from unittest.mock import MagicMock, patch
import script.todo.mail.menu as menu
with patch("click.prompt", return_value="0"):
menu.prompt_execute_mail(MagicMock())
logger = logging.getLogger("script.todo.mail")
self.assertTrue(
any(isinstance(h, logging.FileHandler) for h in logger.handlers)
)
class TestCacheSizeAndPurge(unittest.TestCase):
"""`_cache_size_and_purge` doit survivre à un coffre absent et à un
cache corrompu — les deux tuaient tout le CLI avant ce correctif.
`Store(account)` sans `base` retombe sur `~/.erplibre/mail` : on détourne
`$HOME`, comme `TestComposeScreenMounted` (test_mail_compose.py), plutôt
que d'ajouter un paramètre `base` qu'aucun appelant réel n'utilise.
"""
def setUp(self):
self.fake_home = tempfile.TemporaryDirectory()
self._old_home = os.environ.get("HOME")
os.environ["HOME"] = self.fake_home.name
def tearDown(self):
if self._old_home is None:
os.environ.pop("HOME", None)
else:
os.environ["HOME"] = self._old_home
self.fake_home.cleanup()
def test_purges_a_healthy_encrypted_account_without_raising(self):
from unittest.mock import MagicMock, patch
import script.todo.mail.menu as menu
account = account_from_preset("perso", "a@x.ca", "generic")
account.cache_mode = "encrypted"
vault = {}
secrets = MagicMock()
secrets.get.side_effect = vault.get
secrets.set.side_effect = lambda ref, value: vault.__setitem__(
ref, value
)
with patch.object(
menu, "_load_accounts", return_value=[account]
), patch.object(menu, "secret_store_for", return_value=secrets), patch(
"builtins.input", side_effect=["1", "o"]
):
menu._cache_size_and_purge(MagicMock()) # ne doit pas lever
def test_corrupted_cache_is_removed_from_disk_instead_of_crashing(self):
from unittest.mock import MagicMock, patch
import script.todo.mail.menu as menu
account = account_from_preset("perso", "a@x.ca", "generic")
store = Store(account)
store.root.mkdir(parents=True, exist_ok=True)
(store.root / "cache.db").write_bytes(b"pas une base sqlite" * 50)
with patch.object(
menu, "_load_accounts", return_value=[account]
), patch.object(
menu, "secret_store_for", return_value=MagicMock()
), patch(
"builtins.input", side_effect=["1", "o"]
):
menu._cache_size_and_purge(MagicMock()) # ne doit pas lever
self.assertFalse(store.root.exists())
class TestEnsureKdbx(unittest.TestCase):
"""`_ensure_kdbx` doit tenir la promesse de la conception (lignes
204-207 du design) : créer un nouveau kdbx ou en choisir un existant
quand aucun n'est configuré. Un vrai `ConfigFile`, pointé vers un
dossier temporaire, sert de `todo.config_file` : on veut vérifier que
`set_config_value` est réellement câblé, pas seulement appelé sur un
mock.
"""
def setUp(self):
from types import SimpleNamespace
from unittest.mock import patch as mock_patch
from script.config.config_file import ConfigFile
self.tmp = tempfile.TemporaryDirectory()
self.private_path = os.path.join(
self.tmp.name, "private", "todo", "todo_override_private.json"
)
# Rejoue la forme du vrai `script/todo/todo.json`, qui déclare déjà
# une section "kdbx" avec path/password vides (ce squelette existe
# justement pour que `get_config_value(["kdbx", "path"])` renvoie
# toujours une chaîne, jamais None, quand rien n'est configuré) —
# sans quoi l'absence totale de fichiers ferait planter
# `get_config_value` (`"path" in None`), un cas que la vraie
# configuration versionnée n'expose jamais.
base_path = os.path.join(self.tmp.name, "base.json")
with open(base_path, "w") as f:
json.dump({"kdbx": {"path": "", "password": ""}}, f)
self.patchers = [
mock_patch(
"script.config.config_file.CONFIG_OVERRIDE_PRIVATE_FILE",
self.private_path,
),
mock_patch(
"script.config.config_file.CONFIG_FILE",
base_path,
),
mock_patch(
"script.config.config_file.CONFIG_OVERRIDE_FILE",
os.path.join(self.tmp.name, "nonexistent_override.json"),
),
]
for patcher in self.patchers:
patcher.start()
self.todo = SimpleNamespace(config_file=ConfigFile())
def tearDown(self):
for patcher in self.patchers:
patcher.stop()
self.tmp.cleanup()
def test_skips_the_prompt_when_already_configured(self):
from unittest.mock import patch as mock_patch
import script.todo.mail.menu as menu
self.todo.config_file.set_config_value(
["kdbx", "path"], "/already/there.kdbx"
)
with mock_patch("builtins.input") as mock_input:
result = menu._ensure_kdbx(self.todo)
self.assertTrue(result)
mock_input.assert_not_called()
def test_create_writes_a_real_kdbx_and_records_its_path(self):
from unittest.mock import patch as mock_patch
import script.todo.mail.menu as menu
kdbx_path = os.path.join(self.tmp.name, "new.kdbx")
with mock_patch(
"builtins.input", side_effect=["1", kdbx_path]
), mock_patch("getpass.getpass", side_effect=["hunter2", "hunter2"]):
result = menu._ensure_kdbx(self.todo)
self.assertTrue(result)
self.assertTrue(os.path.isfile(kdbx_path))
self.assertEqual(
self.todo.config_file.get_config_value(["kdbx", "path"]),
kdbx_path,
)
def test_mismatched_passwords_are_refused_and_nothing_is_created(self):
from unittest.mock import patch as mock_patch
import script.todo.mail.menu as menu
kdbx_path = os.path.join(self.tmp.name, "new.kdbx")
with mock_patch(
"builtins.input", side_effect=["1", kdbx_path]
), mock_patch(
"getpass.getpass", side_effect=["hunter2", "somethingelse"]
):
result = menu._ensure_kdbx(self.todo)
self.assertFalse(result)
self.assertFalse(os.path.exists(kdbx_path))
# Le squelette du vrai `todo.json` donne "" (pas None) tant que
# rien n'a été configuré — c'est la valeur falsy que teste
# `_ensure_kdbx`, peu importe sa forme exacte.
self.assertFalse(
self.todo.config_file.get_config_value(["kdbx", "path"])
)
def test_choosing_a_nonexistent_file_is_refused(self):
from unittest.mock import patch as mock_patch
import script.todo.mail.menu as menu
missing_path = os.path.join(self.tmp.name, "nope.kdbx")
with mock_patch("builtins.input", side_effect=["2", missing_path]):
result = menu._ensure_kdbx(self.todo)
self.assertFalse(result)
# Le squelette du vrai `todo.json` donne "" (pas None) tant que
# rien n'a été configuré — c'est la valeur falsy que teste
# `_ensure_kdbx`, peu importe sa forme exacte.
self.assertFalse(
self.todo.config_file.get_config_value(["kdbx", "path"])
)
def test_choosing_an_existing_file_records_its_path(self):
from unittest.mock import patch as mock_patch
import script.todo.mail.menu as menu
existing_path = os.path.join(self.tmp.name, "existing.kdbx")
Path(existing_path).write_bytes(b"not a real kdbx, just a file")
with mock_patch("builtins.input", side_effect=["2", existing_path]):
result = menu._ensure_kdbx(self.todo)
self.assertTrue(result)
self.assertEqual(
self.todo.config_file.get_config_value(["kdbx", "path"]),
existing_path,
)
def test_cancel_creates_neither_file_nor_account(self):
from unittest.mock import patch as mock_patch
import script.todo.mail.menu as menu
with mock_patch.object(
menu.mail_accounts, "save"
) as mock_save, mock_patch("builtins.input", side_effect=["0"]):
menu._add_account(self.todo)
mock_save.assert_not_called()
# Le squelette du vrai `todo.json` donne "" (pas None) tant que
# rien n'a été configuré — c'est la valeur falsy que teste
# `_ensure_kdbx`, peu importe sa forme exacte.
self.assertFalse(
self.todo.config_file.get_config_value(["kdbx", "path"])
)
class TestMailKeysAreTranslated(unittest.TestCase):
"""Le seul filet contre une clé oubliée.
`t()` rend la clé elle-même quand elle est absente : rien n'échoue, et
l'interface affiche « mail_body_error » en toutes lettres à l'utilisateur.
Aucun autre test ne peut attraper ça.
"""
def test_every_key_used_in_the_mail_package_is_declared(self):
import re
from pathlib import Path
import script.todo.mail as mail_pkg
from script.todo.todo_i18n import TRANSLATIONS
pattern = re.compile(r"""(?<![A-Za-z_])t\((["'])(mail_[a-z_]+)\1\)""")
used = set()
for path in Path(mail_pkg.__file__).parent.glob("*.py"):
used |= {m.group(2) for m in pattern.finditer(path.read_text())}
self.assertTrue(
used, "aucune clé trouvée : le motif ne correspond plus"
)
missing = sorted(used - set(TRANSLATIONS))
self.assertEqual(
missing, [], f"clés utilisées mais non traduites : {missing}"
)
class TestTodoWiring(unittest.TestCase):
def test_todo_exposes_prompt_assistant(self):
from script.todo.todo import TODO
self.assertTrue(hasattr(TODO, "prompt_assistant"))
def test_todo_keeps_the_ai_question(self):
from script.todo.todo import TODO
self.assertTrue(hasattr(TODO, "_assistant_question"))
def test_assistant_key_is_translated(self):
from script.todo.todo_i18n import TRANSLATIONS
self.assertIn("Assistant", TRANSLATIONS)
self.assertNotIn("Question", TRANSLATIONS)
def test_one_dispatches_to_assistant_question_only(self):
"""`hasattr` seul ne verrait pas deux branches de menu échangées —
on pilote `click.prompt` et on vérifie que `[1]` appelle
`_assistant_question`, PAS `prompt_execute_mail`.
`_menu_header()` enregistre aussi une télémétrie best-effort dans
`~/.erplibre` : on la neutralise, sinon ce test écrirait pour de
vrai sur la machine.
"""
from unittest.mock import patch
from script.todo.todo import TODO
todo = TODO()
with patch.object(TODO, "_assistant_question") as mock_question, patch(
"script.todo.mail.menu.prompt_execute_mail"
) as mock_mail, patch("click.prompt", side_effect=["1", "0"]), patch(
"script.todo.todo_telemetry.record"
):
todo.prompt_assistant()
mock_question.assert_called_once_with()
mock_mail.assert_not_called()
def test_two_dispatches_to_mail_only(self):
"""Symétrique : `[2]` appelle `prompt_execute_mail`, PAS
`_assistant_question`."""
from unittest.mock import patch
from script.todo.todo import TODO
todo = TODO()
with patch.object(TODO, "_assistant_question") as mock_question, patch(
"script.todo.mail.menu.prompt_execute_mail"
) as mock_mail, patch("script.todo.todo_telemetry.record"), patch(
"click.prompt", side_effect=["2", "0"]
):
todo.prompt_assistant()
mock_mail.assert_called_once_with(todo)
mock_question.assert_not_called()
class TestRetryPassword(unittest.TestCase):
def setUp(self):
self.account = account_from_preset("perso", "a@x.ca", "generic")
self.vault = {}
def _todo(self):
from unittest.mock import MagicMock
return MagicMock()
def test_a_timeout_does_not_blame_the_password(self):
"""Le serveur n'a RIEN dit : la commande est partie, aucune réponse.
Accuser le mot de passe envoie chercher un mot de passe
d'application pour un problème qui est ailleurs — signalé à
l'usage, sur un « The read operation timed out » de Gmail."""
lignes = self._lignes_affichees(
"gmail",
cause="connexion IMAP refusée : The read operation timed out",
)
self.assertNotIn("mot de passe d'application", lignes)
def _invite(self, preset_key):
"""L'invite EXACTE affichée par `getpass`, pas ce qui la précède."""
from unittest.mock import patch
from script.todo.mail.menu import retry_password
vues = []
with patch(
"getpass.getpass", side_effect=lambda p="": vues.append(p) or ""
), patch("script.todo.mail.menu.secret_store_for"), patch(
"builtins.print"
):
retry_password(
self._todo(),
account_from_preset("essai", "a@x.ca", preset_key),
connect_fn=lambda a, p: None,
)
return vues[0]
def test_the_prompt_itself_asks_for_the_app_password(self):
"""La note se lit une fois ; l'invite se relit à CHAQUE tentative.
« Mot de passe : » invitait à saisir celui du compte, que ces
fournisseurs refusent."""
self.assertIn("application", self._invite("gmail"))
def test_a_generic_provider_keeps_the_plain_prompt(self):
self.assertNotIn("application", self._invite("generic"))
def test_the_note_gives_the_address_not_a_menu_path(self):
"""Google cache cette page : un chemin de menu ne suffit pas, et
les intitulés changent. L'URL, elle, se colle."""
lignes = self._lignes_affichees("gmail")
self.assertIn("https://myaccount.google.com/apppasswords", lignes)
def test_googles_own_wording_is_recognised(self):
"""Le cas qui a manqué : une fois la double authentification
active, Gmail répond « [ALERT] Application-specific password
required » — sans « invalid credentials » ni « authentication
failed ». Une liste de libellés attendus est toujours en retard sur
les serveurs réels."""
lignes = self._lignes_affichees(
"gmail",
cause=(
"b'[ALERT] Application-specific password required:"
" https://support.google.com/accounts/answer/185833'"
),
)
self.assertIn("mot de passe d'application", lignes)
def test_an_unknown_refusal_still_shows_the_note(self):
"""La note est un CONSEIL, pas un verdict : la taire à tort coûte
la panne, la donner à tort coûte une ligne. Un serveur dont on ne
connaît pas la formulation doit donc l'obtenir."""
lignes = self._lignes_affichees(
"gmail", cause="b'[NO] something we have never seen before'"
)
self.assertIn("mot de passe d'application", lignes)
def test_an_explicit_refusal_still_blames_the_password(self):
"""Le contrôle symétrique : restreindre l'affichage ne doit pas
l'avoir supprimé dans le cas où il sert."""
lignes = self._lignes_affichees(
"gmail", cause="b'[AUTHENTICATIONFAILED] Invalid credentials'"
)
self.assertIn("mot de passe d'application", lignes)
def _lignes_affichees(self, preset_key, cause=None):
"""Ce que l'utilisateur LIT avant qu'on lui redemande son mot de
passe, quand la connexion vient d'être refusée."""
from unittest.mock import patch
from script.todo.mail.menu import retry_password
compte = account_from_preset("essai", "a@x.ca", preset_key)
vues = []
with patch("getpass.getpass", return_value=""), patch(
"script.todo.mail.menu.secret_store_for"
), patch(
"builtins.print",
side_effect=lambda *a: vues.append(" ".join(map(str, a))),
):
retry_password(
self._todo(),
compte,
connect_fn=lambda a, p: None,
cause=cause,
)
return "\n".join(vues)
def test_a_provider_needing_an_app_password_says_so_before_reasking(self):
"""Gmail répond « Invalid credentials » au mot de passe habituel
exactement comme à une faute de frappe. Sans cette note, l'invite
pousse à retaper le même — et à se le faire refuser trois fois."""
lignes = self._lignes_affichees("gmail")
self.assertIn("mot de passe d'application", lignes)
# L'instruction PRÉCISE, pas seulement le constat : sans elle, on
# sait qu'il faut autre chose sans savoir où le prendre.
self.assertIn("myaccount.google.com", lignes)
def test_a_generic_provider_says_nothing_of_the_kind(self):
"""Le contrôle négatif : sans lui, une note affichée à TOUS
passerait ce test aussi bien."""
self.assertNotIn(
"mot de passe d'application", self._lignes_affichees("generic")
)
def test_updates_the_vault_after_a_good_password(self):
from unittest.mock import patch
from script.todo.mail.menu import retry_password
class OkTransport:
def logout(self):
pass
with patch("getpass.getpass", return_value="bon"), patch(
"script.todo.mail.menu.secret_store_for"
) as store:
store.return_value.set.side_effect = self.vault.__setitem__
ok = retry_password(
self._todo(),
self.account,
connect_fn=lambda a, p: OkTransport(),
)
self.assertTrue(ok)
self.assertEqual(self.vault[self.account.secret_ref], "bon")
def test_does_not_touch_the_vault_when_every_try_fails(self):
from unittest.mock import patch
from script.todo.mail.menu import retry_password
def refuse(account, password):
raise OSError("530 refus")
with patch("getpass.getpass", return_value="faux"), patch(
"script.todo.mail.menu.secret_store_for"
) as store:
store.return_value.set.side_effect = self.vault.__setitem__
ok = retry_password(
self._todo(), self.account, attempts=2, connect_fn=refuse
)
self.assertFalse(ok)
self.assertEqual(self.vault, {})
def test_empty_input_gives_up(self):
from unittest.mock import patch
from script.todo.mail.menu import retry_password
with patch("getpass.getpass", return_value=""):
self.assertFalse(
retry_password(
self._todo(), self.account, connect_fn=lambda a, p: None
)
)
if __name__ == "__main__":
unittest.main()

229
test/test_mail_secrets.py Normal file
View file

@ -0,0 +1,229 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
import os
import stat
import tempfile
import unittest
from unittest.mock import MagicMock, patch
from script.todo.mail.secrets import (
SecretError,
SecretStore,
create_kdbx,
keyring_is_safe,
)
class FakeKeyringBackend:
"""Imite un backend keyring, sans toucher au trousseau de la machine."""
def __init__(self, name="keyring.backends.SecretService.Keyring"):
self.__class__.__module__ = name.rsplit(".", 1)[0]
self._name = name
self.store = {}
class TestKeyringSafety(unittest.TestCase):
def _with_backend(self, module_name, class_name):
backend = MagicMock()
type(backend).__module__ = module_name
type(backend).__qualname__ = class_name
return backend
def test_secretservice_is_safe(self):
backend = self._with_backend(
"keyring.backends.SecretService", "Keyring"
)
with patch("keyring.get_keyring", return_value=backend):
self.assertTrue(keyring_is_safe())
def test_macos_is_safe(self):
backend = self._with_backend("keyring.backends.macOS", "Keyring")
with patch("keyring.get_keyring", return_value=backend):
self.assertTrue(keyring_is_safe())
def test_windows_is_safe(self):
backend = self._with_backend(
"keyring.backends.Windows", "WinVaultKeyring"
)
with patch("keyring.get_keyring", return_value=backend):
self.assertTrue(keyring_is_safe())
def test_plaintext_alt_is_refused(self):
backend = self._with_backend("keyrings.alt.file", "PlaintextKeyring")
with patch("keyring.get_keyring", return_value=backend):
self.assertFalse(keyring_is_safe())
def test_fail_backend_is_refused(self):
backend = self._with_backend("keyring.backends.fail", "Keyring")
with patch("keyring.get_keyring", return_value=backend):
self.assertFalse(keyring_is_safe())
def test_unknown_backend_is_refused(self):
"""Par défaut on refuse : un backend qu'on ne connaît pas peut écrire en clair."""
backend = self._with_backend("un.paquet.inconnu", "Keyring")
with patch("keyring.get_keyring", return_value=backend):
self.assertFalse(keyring_is_safe())
class TestCreateKdbxPermissions(unittest.TestCase):
"""`create_database` (pykeepass) écrit d'abord un fichier `.tmp` via
`construct`, avec `open(filename, "w+b")` — donc à l'umask du process —
avant de le déplacer sur la cible. Resserrer l'umask le temps de l'appel
est donc la seule façon de fermer cette fenêtre : un `os.open` sur la
cible ne verrait jamais ce fichier intermédiaire."""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.path = os.path.join(self.tmp.name, "nested", "test.kdbx")
def tearDown(self):
self.tmp.cleanup()
def test_file_is_0600(self):
create_kdbx(self.path, "motdepasse")
mode = stat.S_IMODE(os.stat(self.path).st_mode)
self.assertEqual(mode, 0o600)
def test_parent_dir_is_0700(self):
create_kdbx(self.path, "motdepasse")
mode = stat.S_IMODE(os.stat(os.path.dirname(self.path)).st_mode)
self.assertEqual(mode, 0o700)
def test_restores_the_process_umask(self):
before = os.umask(0o022)
os.umask(before) # `os.umask` ne peut que remplacer : on relit puis
# on rétablit exactement ce qu'on avait, sans jamais l'avoir changé
# pour de vrai entre les deux appels.
create_kdbx(self.path, "motdepasse")
after = os.umask(before)
os.umask(after)
self.assertEqual(after, before)
def test_umask_is_tightened_while_the_file_is_built(self):
"""La preuve directe : PENDANT `create_database`, l'umask doit être
resserré, sinon le fichier `.tmp` intermédiaire existe, même
brièvement, à l'umask permissif du process."""
seen = {}
def fake_create_database(path, password=None):
seen["umask"] = os.umask(0)
os.umask(seen["umask"])
with open(path, "wb"):
pass
with patch(
"pykeepass.create_database", side_effect=fake_create_database
):
create_kdbx(self.path, "motdepasse")
self.assertEqual(seen["umask"], 0o077)
class TestKdbxRoundtrip(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.path = os.path.join(self.tmp.name, "test.kdbx")
create_kdbx(self.path, "motdepasse")
from pykeepass import PyKeePass
self.kp = PyKeePass(self.path, password="motdepasse")
manager = MagicMock()
manager.get_kdbx.return_value = self.kp
self.store = SecretStore(kdbx_manager=manager, use_keyring=False)
def tearDown(self):
self.tmp.cleanup()
def test_created_file_exists(self):
self.assertTrue(os.path.exists(self.path))
def test_set_then_get(self):
self.store.set("kdbx:ERPLibre/Mail/perso", "hunter2")
self.assertEqual(self.store.get("kdbx:ERPLibre/Mail/perso"), "hunter2")
def test_set_creates_nested_groups(self):
self.store.set("kdbx:ERPLibre/Mail/travail", "s3cr3t")
groups = [g.name for g in self.kp.groups]
self.assertIn("ERPLibre", groups)
self.assertIn("Mail", groups)
def test_set_twice_overwrites(self):
self.store.set("kdbx:ERPLibre/Mail/perso", "ancien")
self.store.set("kdbx:ERPLibre/Mail/perso", "nouveau")
self.assertEqual(self.store.get("kdbx:ERPLibre/Mail/perso"), "nouveau")
def test_get_missing_returns_none(self):
self.assertIsNone(self.store.get("kdbx:ERPLibre/Mail/absent"))
def test_delete(self):
self.store.set("kdbx:ERPLibre/Mail/perso", "hunter2")
self.store.delete("kdbx:ERPLibre/Mail/perso")
self.assertIsNone(self.store.get("kdbx:ERPLibre/Mail/perso"))
def test_binary_key_survives_base64(self):
"""La clé de cache est stockée en base64 : 32 octets bruts doivent revenir intacts."""
import base64
raw = bytes(range(32))
self.store.set(
"kdbx:ERPLibre/Mail/perso/cache-key",
base64.b64encode(raw).decode(),
)
got = self.store.get("kdbx:ERPLibre/Mail/perso/cache-key")
self.assertEqual(base64.b64decode(got), raw)
class TestKeyringBranch(unittest.TestCase):
def setUp(self):
self.store = SecretStore(kdbx_manager=None, use_keyring=True)
def test_set_and_get_through_keyring(self):
vault = {}
with patch(
"script.todo.mail.secrets.keyring_is_safe", return_value=True
), patch(
"keyring.set_password",
side_effect=lambda s, u, p: vault.__setitem__((s, u), p),
), patch(
"keyring.get_password", side_effect=lambda s, u: vault.get((s, u))
):
self.store.set("keyring:perso", "hunter2")
self.assertEqual(self.store.get("keyring:perso"), "hunter2")
def test_refuses_unsafe_backend(self):
# `keyring.get_keyring` est patché AUSSI : le message d'erreur passe par
# keyring_backend_name(), qui interrogerait sinon le vrai trousseau.
with patch(
"script.todo.mail.secrets.keyring_is_safe", return_value=False
), patch("keyring.get_keyring", return_value=MagicMock()):
with self.assertRaises(SecretError) as ctx:
self.store.set("keyring:perso", "hunter2")
# Traduit : on compare à la clé i18n elle-même, pas au mot français,
# pour que le test suive la langue active plutôt que de la figer.
from script.todo.todo_i18n import t
self.assertIn(t("mail_err_keyring_plaintext"), str(ctx.exception))
class TestRefParsing(unittest.TestCase):
def setUp(self):
self.store = SecretStore(kdbx_manager=None, use_keyring=False)
def test_unknown_scheme_raises(self):
with self.assertRaises(SecretError):
self.store.get("magique:perso")
def test_missing_scheme_raises(self):
with self.assertRaises(SecretError):
self.store.get("perso")
def test_no_backend_available_raises(self):
with self.assertRaises(SecretError):
self.store.set("keyring:perso", "x")
if __name__ == "__main__":
unittest.main()

511
test/test_mail_send.py Normal file
View file

@ -0,0 +1,511 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
import email
import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
from script.todo.mail.accounts import account_from_preset
from script.todo.mail.smtp_send import (
SmtpError,
build_forward,
build_message,
build_reply,
connect,
recipients,
send,
without_bcc,
)
FIXED_DATE = "Fri, 01 Aug 2026 10:41:00 +0000"
FIXED_MSGID = "<fixe@erplibre>"
def account():
return account_from_preset(
"perso", "moi@x.ca", "generic", display_name="Mathieu Benoit"
)
def original(subject="Devis", frm="Alice <alice@y.ca>", to="moi@x.ca", cc=""):
raw = (
f"From: {frm}\r\nTo: {to}\r\n"
+ (f"Cc: {cc}\r\n" if cc else "")
+ f"Subject: {subject}\r\n"
f"Message-ID: <origine@y.ca>\r\n"
f"Date: {FIXED_DATE}\r\n\r\nLe corps d'origine.\r\n"
)
return email.message_from_string(raw)
class FakeSmtp:
def __init__(self, fail=False):
self.sent = []
self.quit_called = False
self.fail = fail
def send_message(self, msg, from_addr, to_addrs):
if self.fail:
raise OSError("550 destinataire refusé")
self.sent.append((msg, from_addr, list(to_addrs)))
def quit(self):
self.quit_called = True
class TestBuildMessage(unittest.TestCase):
def setUp(self):
self.acc = account()
def build(self, **kw):
kw.setdefault("date", FIXED_DATE)
kw.setdefault("msgid", FIXED_MSGID)
return build_message(self.acc, "alice@y.ca", "Devis", "Bonjour", **kw)
def test_from_uses_display_name(self):
self.assertEqual(self.build()["From"], "Mathieu Benoit <moi@x.ca>")
def test_from_without_display_name(self):
acc = account_from_preset("perso", "moi@x.ca", "generic")
msg = build_message(
acc, "a@y.ca", "S", "B", date=FIXED_DATE, msgid=FIXED_MSGID
)
self.assertEqual(msg["From"], "moi@x.ca")
def test_to_and_subject(self):
msg = self.build()
self.assertEqual(msg["To"], "alice@y.ca")
self.assertEqual(msg["Subject"], "Devis")
def test_body(self):
self.assertIn("Bonjour", self.build().get_content())
def test_accented_subject_survives(self):
msg = build_message(
self.acc,
"a@y.ca",
"Devis révisé",
"B",
date=FIXED_DATE,
msgid=FIXED_MSGID,
)
reparsed = email.message_from_bytes(msg.as_bytes())
from email.header import decode_header
# RFC 2047 permet d'encoder seulement le segment non-ASCII d'un
# en-tête ; `decode_header` rend alors PLUSIEURS morceaux
# ("Devis ", puis "révisé" encodé), qu'il faut tous rejoindre pour
# retrouver le texte d'origine — ne lire que le premier le tronque.
text = "".join(
(
chunk.decode(charset or "utf-8")
if isinstance(chunk, bytes)
else chunk
)
for chunk, charset in decode_header(reparsed["Subject"])
)
self.assertEqual(text, "Devis révisé")
def test_multiple_recipients(self):
msg = self.build(cc=["bob@y.ca", "carl@y.ca"])
self.assertEqual(msg["Cc"], "bob@y.ca, carl@y.ca")
def test_bcc_is_not_in_headers(self):
"""Un Cci qui part dans les en-têtes n'est plus un Cci."""
msg = self.build(bcc=["secret@y.ca"])
self.assertIsNone(msg["Bcc"])
def test_bcc_is_still_a_recipient(self):
msg = self.build(bcc=["secret@y.ca"])
self.assertIn("secret@y.ca", recipients(msg))
def test_message_id_present(self):
self.assertEqual(self.build()["Message-ID"], FIXED_MSGID)
def test_generated_message_id_when_absent(self):
msg = build_message(self.acc, "a@y.ca", "S", "B", date=FIXED_DATE)
self.assertTrue(msg["Message-ID"].startswith("<"))
def test_empty_recipient_raises(self):
with self.assertRaises(SmtpError):
build_message(self.acc, "", "S", "B")
class TestAttachments(unittest.TestCase):
def setUp(self):
self.acc = account()
self.tmp = tempfile.TemporaryDirectory()
self.pdf = Path(self.tmp.name) / "devis.pdf"
self.pdf.write_bytes(b"%PDF-1.4 faux")
def tearDown(self):
self.tmp.cleanup()
def test_message_becomes_multipart(self):
msg = build_message(
self.acc,
"a@y.ca",
"S",
"B",
attachments=[self.pdf],
date=FIXED_DATE,
msgid=FIXED_MSGID,
)
self.assertTrue(msg.is_multipart())
def test_filename_is_kept(self):
msg = build_message(
self.acc,
"a@y.ca",
"S",
"B",
attachments=[self.pdf],
date=FIXED_DATE,
msgid=FIXED_MSGID,
)
names = [p.get_filename() for p in msg.iter_attachments()]
self.assertEqual(names, ["devis.pdf"])
def test_content_type_is_guessed(self):
msg = build_message(
self.acc,
"a@y.ca",
"S",
"B",
attachments=[self.pdf],
date=FIXED_DATE,
msgid=FIXED_MSGID,
)
part = next(msg.iter_attachments())
self.assertEqual(part.get_content_type(), "application/pdf")
def test_unknown_extension_falls_back_to_octet_stream(self):
blob = Path(self.tmp.name) / "donnees.zzz"
blob.write_bytes(b"\x00\x01")
msg = build_message(
self.acc,
"a@y.ca",
"S",
"B",
attachments=[blob],
date=FIXED_DATE,
msgid=FIXED_MSGID,
)
part = next(msg.iter_attachments())
self.assertEqual(part.get_content_type(), "application/octet-stream")
def test_missing_file_raises(self):
with self.assertRaises(SmtpError):
build_message(
self.acc,
"a@y.ca",
"S",
"B",
attachments=[Path(self.tmp.name) / "absent.pdf"],
date=FIXED_DATE,
msgid=FIXED_MSGID,
)
def test_body_still_readable(self):
msg = build_message(
self.acc,
"a@y.ca",
"S",
"Bonjour Alice",
attachments=[self.pdf],
date=FIXED_DATE,
msgid=FIXED_MSGID,
)
self.assertIn("Bonjour Alice", msg.get_body(("plain",)).get_content())
class TestReply(unittest.TestCase):
def setUp(self):
self.acc = account()
def reply(self, orig=None, **kw):
kw.setdefault("date", FIXED_DATE)
kw.setdefault("msgid", FIXED_MSGID)
return build_reply(self.acc, orig or original(), "Ma réponse", **kw)
def test_subject_gets_re_prefix(self):
self.assertEqual(self.reply()["Subject"], "Re: Devis")
def test_subject_not_prefixed_twice(self):
self.assertEqual(
self.reply(original(subject="Re: Devis"))["Subject"], "Re: Devis"
)
def test_existing_re_case_insensitive(self):
self.assertEqual(
self.reply(original(subject="RE: Devis"))["Subject"], "RE: Devis"
)
def test_recipient_is_the_sender(self):
self.assertEqual(self.reply()["To"], "Alice <alice@y.ca>")
def test_reply_to_header_wins(self):
orig = original()
orig["Reply-To"] = "equipe@y.ca"
self.assertEqual(self.reply(orig)["To"], "equipe@y.ca")
def test_in_reply_to(self):
self.assertEqual(self.reply()["In-Reply-To"], "<origine@y.ca>")
def test_references_starts_the_chain(self):
self.assertEqual(self.reply()["References"], "<origine@y.ca>")
def test_references_extends_the_chain(self):
orig = original()
orig["References"] = "<premier@y.ca> <second@y.ca>"
self.assertEqual(
self.reply(orig)["References"],
"<premier@y.ca> <second@y.ca> <origine@y.ca>",
)
def test_reply_all_adds_the_others(self):
orig = original(to="moi@x.ca, bob@y.ca", cc="carl@y.ca")
msg = self.reply(orig, reply_all=True)
joined = f"{msg['To']} {msg['Cc']}"
self.assertIn("bob@y.ca", joined)
self.assertIn("carl@y.ca", joined)
def test_reply_all_drops_my_own_address(self):
orig = original(to="moi@x.ca, bob@y.ca")
msg = self.reply(orig, reply_all=True)
self.assertNotIn("moi@x.ca", f"{msg['To']} {msg['Cc'] or ''}")
def test_original_is_quoted(self):
self.assertIn("> Le corps d'origine.", self.reply().get_content())
def test_survives_an_unrecognised_charset(self):
"""Un charset mal étiqueté ne doit pas faire tomber la réponse.
`str.decode(charset, "replace")` lève `LookupError` si le codec est
inconnu : l'erreur survient à la recherche du codec, AVANT que
`errors="replace"` ne serve. Un seul message mal étiqueté ne doit
pas empêcher d'y répondre.
"""
raw = (
"From: Alice <alice@y.ca>\r\nTo: moi@x.ca\r\n"
"Subject: Devis\r\nMessage-ID: <origine@y.ca>\r\n"
f"Date: {FIXED_DATE}\r\n"
"Content-Type: text/plain; charset=bogus-charset-xyz\r\n\r\n"
"Le corps d'origine.\r\n"
)
orig = email.message_from_string(raw)
msg = self.reply(orig)
self.assertIn("Le corps d'origine.", msg.get_content())
def test_survives_unknown_8bit(self):
"""Étiquette réelle observée en usage, pas seulement un charset
inventé (voir `script/todo/mail/charset.py`)."""
raw = (
"From: Alice <alice@y.ca>\r\nTo: moi@x.ca\r\n"
"Subject: Devis\r\nMessage-ID: <origine@y.ca>\r\n"
f"Date: {FIXED_DATE}\r\n"
"Content-Type: text/plain; charset=unknown-8bit\r\n\r\n"
"Le corps d'origine.\r\n"
)
orig = email.message_from_string(raw)
msg = self.reply(orig)
self.assertIn("Le corps d'origine.", msg.get_content())
class TestForward(unittest.TestCase):
def setUp(self):
self.acc = account()
def forward(self, **kw):
kw.setdefault("date", FIXED_DATE)
kw.setdefault("msgid", FIXED_MSGID)
return build_forward(
self.acc, original(), "bob@z.ca", "Pour info", **kw
)
def test_subject_gets_fwd_prefix(self):
self.assertEqual(self.forward()["Subject"], "Fwd: Devis")
def test_recipient(self):
self.assertEqual(self.forward()["To"], "bob@z.ca")
def test_original_attached_as_rfc822(self):
types = [
p.get_content_type() for p in self.forward().iter_attachments()
]
self.assertIn("message/rfc822", types)
def test_no_in_reply_to(self):
"""Transférer n'est pas répondre : le fil ne doit pas se greffer."""
self.assertIsNone(self.forward()["In-Reply-To"])
class TestRecipients(unittest.TestCase):
def test_collects_to_cc_and_bcc(self):
msg = build_message(
account(),
"a@y.ca",
"S",
"B",
cc=["b@y.ca"],
bcc=["c@y.ca"],
date=FIXED_DATE,
msgid=FIXED_MSGID,
)
self.assertEqual(
sorted(recipients(msg)), ["a@y.ca", "b@y.ca", "c@y.ca"]
)
def test_strips_display_names(self):
msg = build_message(
account(),
"Alice <a@y.ca>",
"S",
"B",
date=FIXED_DATE,
msgid=FIXED_MSGID,
)
self.assertEqual(recipients(msg), ["a@y.ca"])
def test_deduplicates(self):
msg = build_message(
account(),
"a@y.ca",
"S",
"B",
cc=["a@y.ca"],
date=FIXED_DATE,
msgid=FIXED_MSGID,
)
self.assertEqual(recipients(msg), ["a@y.ca"])
class TestSend(unittest.TestCase):
def setUp(self):
self.acc = account()
self.msg = build_message(
self.acc,
"a@y.ca",
"S",
"B",
bcc=["c@y.ca"],
date=FIXED_DATE,
msgid=FIXED_MSGID,
)
def test_passes_envelope_from_and_recipients(self):
smtp = FakeSmtp()
served = send(self.acc, self.msg, smtp)
_, from_addr, to_addrs = smtp.sent[0]
self.assertEqual(from_addr, "moi@x.ca")
self.assertEqual(sorted(to_addrs), ["a@y.ca", "c@y.ca"])
self.assertEqual(sorted(served), ["a@y.ca", "c@y.ca"])
def test_failure_is_wrapped(self):
with self.assertRaises(SmtpError):
send(self.acc, self.msg, FakeSmtp(fail=True))
def test_failure_message_keeps_the_server_wording(self):
with self.assertRaises(SmtpError) as ctx:
send(self.acc, self.msg, FakeSmtp(fail=True))
self.assertIn("550", str(ctx.exception))
def test_no_recipient_raises_before_the_network(self):
msg = build_message(
self.acc, "a@y.ca", "S", "B", date=FIXED_DATE, msgid=FIXED_MSGID
)
del msg["To"]
smtp = FakeSmtp()
with self.assertRaises(SmtpError):
send(self.acc, msg, smtp)
self.assertEqual(smtp.sent, [])
class TestWithoutBcc(unittest.TestCase):
"""La copie qui part vers Envoyés emprunte IMAP, pas SMTP : elle doit
être assainie elle aussi, sinon le Cci est lisible sur le serveur."""
def setUp(self):
self.msg = build_message(
account(),
"a@y.ca",
"Devis",
"Bonjour",
bcc=["secret@y.ca"],
date=FIXED_DATE,
msgid=FIXED_MSGID,
)
def test_copy_has_no_internal_bcc_header(self):
self.assertIsNone(without_bcc(self.msg)["X-ERPLibre-Bcc"])
def test_bcc_address_is_absent_from_the_serialised_copy(self):
self.assertNotIn(b"secret@y.ca", without_bcc(self.msg).as_bytes())
def test_original_is_left_untouched(self):
without_bcc(self.msg)
self.assertIn("secret@y.ca", recipients(self.msg))
def test_message_without_bcc_is_returned_as_is(self):
plain = build_message(
account(), "a@y.ca", "S", "B", date=FIXED_DATE, msgid=FIXED_MSGID
)
self.assertIs(without_bcc(plain), plain)
def test_body_and_headers_survive(self):
copy = without_bcc(self.msg)
self.assertEqual(copy["Subject"], "Devis")
self.assertIn("Bonjour", copy.get_content())
class TestConnect(unittest.TestCase):
"""`connect` choisit la branche SSL/STARTTLS et convertit les erreurs.
Aucun de ces tests ne joint le réseau : `smtplib` est remplacé.
"""
def _account(self, security):
acc = account_from_preset("perso", "moi@x.ca", "generic")
acc.smtp.host = "smtp.x.ca"
acc.smtp.port = 465 if security == "ssl" else 587
acc.smtp.security = security
return acc
def test_ssl_branch(self):
client = MagicMock()
with patch("smtplib.SMTP_SSL", return_value=client) as ctor:
connect(self._account("ssl"), "hunter2")
ctor.assert_called_once_with("smtp.x.ca", 465, timeout=30)
client.login.assert_called_once_with("moi@x.ca", "hunter2")
def test_starttls_branch_upgrades(self):
client = MagicMock()
with patch("smtplib.SMTP", return_value=client):
connect(self._account("starttls"), "hunter2")
client.starttls.assert_called_once()
def test_plain_branch_does_not_upgrade(self):
client = MagicMock()
with patch("smtplib.SMTP", return_value=client):
connect(self._account("none"), "hunter2")
client.starttls.assert_not_called()
def test_login_failure_becomes_an_smtp_error(self):
client = MagicMock()
client.login.side_effect = OSError("535 refus")
with patch("smtplib.SMTP_SSL", return_value=client):
with self.assertRaises(SmtpError) as ctx:
connect(self._account("ssl"), "mauvais")
self.assertIn("535", str(ctx.exception))
def test_connection_failure_becomes_an_smtp_error(self):
with patch("smtplib.SMTP_SSL", side_effect=OSError("injoignable")):
with self.assertRaises(SmtpError):
connect(self._account("ssl"), "hunter2")
if __name__ == "__main__":
unittest.main()

636
test/test_mail_store.py Normal file
View file

@ -0,0 +1,636 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
import os
import tempfile
import unittest
from pathlib import Path
from script.todo.mail.accounts import account_from_preset
from script.todo.mail.crypto import CryptoError, new_key
from script.todo.mail.store import (
EPHEMERAL_PREFIX,
MessageMeta,
Store,
StoreError,
cache_root,
folder_dirname,
resolve_mode,
sweep_orphan_ephemeral,
)
def meta(uid, subject="Sujet", frm="a@x.ca", date=1000, flags=""):
return MessageMeta(
uid=uid,
date=date,
size=42,
flags=flags,
msgid=f"<{uid}@x.ca>",
frm=frm,
to="moi@x.ca",
subject=subject,
snippet="debut du corps",
)
class TestResolveMode(unittest.TestCase):
def test_account_override_wins(self):
acc = account_from_preset("perso", "a@x.ca", "generic")
acc.cache_mode = "encrypted"
self.assertEqual(
resolve_mode(acc, lambda k, d=None: "clear"), "encrypted"
)
def test_falls_back_to_general_default(self):
acc = account_from_preset("perso", "a@x.ca", "generic")
self.assertEqual(
resolve_mode(acc, lambda k, d=None: "ephemeral"), "ephemeral"
)
def test_unknown_general_default_falls_back_to_clear(self):
acc = account_from_preset("perso", "a@x.ca", "generic")
self.assertEqual(
resolve_mode(acc, lambda k, d=None: "magique"), "clear"
)
class TestFolderDirname(unittest.TestCase):
def test_slash_is_escaped(self):
self.assertNotIn("/", folder_dirname("[Gmail]/Sent Mail"))
def test_is_reversible_enough_to_be_unique(self):
self.assertNotEqual(folder_dirname("A/B"), folder_dirname("A_B"))
def test_traversal_collapses_to_one_component(self):
self.assertNotIn("/", folder_dirname("../../etc"))
def test_degenerate_names_cannot_designate_the_parent(self):
"""`racine / ".."` remonterait d'un cran : ces noms sont réécrits."""
for hostile in ("", ".", ".."):
self.assertNotIn(folder_dirname(hostile), ("", ".", ".."))
def test_dotted_hierarchy_stays_readable(self):
"""Le point sépare la hiérarchie chez beaucoup de serveurs IMAP."""
self.assertEqual(folder_dirname("INBOX.Sent"), "INBOX.Sent")
class TestCacheRoot(unittest.TestCase):
def test_persistent_modes_use_base(self):
acc = account_from_preset("perso", "a@x.ca", "generic")
with tempfile.TemporaryDirectory() as tmp:
root = cache_root(acc, "clear", Path(tmp))
self.assertEqual(root, Path(tmp) / "perso")
def test_ephemeral_root_carries_the_pid(self):
acc = account_from_preset("perso", "a@x.ca", "generic")
with tempfile.TemporaryDirectory() as tmp:
root = cache_root(acc, "ephemeral", Path(tmp))
self.assertIn(f"{EPHEMERAL_PREFIX}{os.getpid()}", str(root))
class StoreCase(unittest.TestCase):
"""Socle commun : un compte, une base temporaire, mode paramétrable."""
mode = "clear"
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.account = account_from_preset("perso", "moi@x.ca", "generic")
self.key = new_key() if self.mode != "clear" else None
self.store = Store(
self.account,
mode=self.mode,
key=self.key,
base=Path(self.tmp.name),
)
self.store.open()
def tearDown(self):
self.store.close()
self.tmp.cleanup()
class TestSchema(StoreCase):
def test_db_file_created(self):
self.assertTrue((self.store.root / "cache.db").exists())
def test_root_is_0700(self):
import stat
self.assertEqual(stat.S_IMODE(os.stat(self.store.root).st_mode), 0o700)
def test_reopen_is_idempotent(self):
self.store.close()
again = Store(
self.account,
mode=self.mode,
key=self.key,
base=Path(self.tmp.name),
)
again.open()
again.close()
class TestFolders(StoreCase):
def test_upsert_returns_id(self):
fid = self.store.upsert_folder("INBOX", "INBOX", "inbox", 1, 10)
self.assertIsInstance(fid, int)
def test_upsert_twice_keeps_same_id(self):
first = self.store.upsert_folder("INBOX")
second = self.store.upsert_folder("INBOX")
self.assertEqual(first, second)
def test_folder_state(self):
self.store.upsert_folder("INBOX", uidvalidity=7)
state = self.store.folder_state("INBOX")
self.assertEqual(state["uidvalidity"], 7)
self.assertEqual(state["last_uid"], 0)
def test_set_folder_state(self):
self.store.upsert_folder("INBOX")
self.store.set_folder_state("INBOX", last_uid=99, unseen=3)
state = self.store.folder_state("INBOX")
self.assertEqual(state["last_uid"], 99)
self.assertEqual(state["unseen"], 3)
def test_unknown_folder_state_is_none(self):
self.assertIsNone(self.store.folder_state("ABSENT"))
def test_folders_lists_them(self):
self.store.upsert_folder("INBOX")
self.store.upsert_folder("Sent")
self.assertEqual(
{f["name"] for f in self.store.folders()}, {"INBOX", "Sent"}
)
def test_display_is_null_until_one_is_known(self):
"""NULL veut dire « inconnu » : le lecteur retombe sur le nom IMAP."""
self.store.upsert_folder("INBOX")
self.assertIsNone(self.store.folder_state("INBOX")["display"])
def test_partial_upsert_keeps_the_display_name(self):
"""Une resync qui ne repasse que le nom IMAP ne doit rien écraser."""
self.store.upsert_folder("INBOX", "Boîte de réception")
self.store.upsert_folder("INBOX")
self.assertEqual(
self.store.folder_state("INBOX")["display"], "Boîte de réception"
)
class TestMessages(StoreCase):
def setUp(self):
super().setUp()
self.fid = self.store.upsert_folder("INBOX")
def test_upsert_then_list(self):
self.store.upsert_messages(self.fid, [meta(1), meta(2)])
got = self.store.list_messages(self.fid)
self.assertEqual({m.uid for m in got}, {1, 2})
def test_subject_survives_roundtrip(self):
self.store.upsert_messages(self.fid, [meta(1, subject="Devis révisé")])
self.assertEqual(
self.store.list_messages(self.fid)[0].subject, "Devis révisé"
)
def test_upsert_same_uid_updates(self):
self.store.upsert_messages(self.fid, [meta(1, subject="ancien")])
self.store.upsert_messages(self.fid, [meta(1, subject="nouveau")])
got = self.store.list_messages(self.fid)
self.assertEqual(len(got), 1)
self.assertEqual(got[0].subject, "nouveau")
def test_sorted_by_date_desc(self):
self.store.upsert_messages(
self.fid, [meta(1, date=100), meta(2, date=300), meta(3, date=200)]
)
self.assertEqual(
[m.uid for m in self.store.list_messages(self.fid)], [2, 3, 1]
)
def test_update_flags(self):
self.store.upsert_messages(self.fid, [meta(1)])
self.store.update_flags(self.fid, 1, "\\Seen")
self.assertEqual(self.store.list_messages(self.fid)[0].flags, "\\Seen")
def test_known_uids(self):
self.store.upsert_messages(self.fid, [meta(1), meta(2), meta(3)])
self.assertEqual(sorted(self.store.known_uids(self.fid)), [1, 2, 3])
def test_limit_and_offset(self):
self.store.upsert_messages(
self.fid, [meta(i, date=i) for i in range(1, 6)]
)
self.assertEqual(
[m.uid for m in self.store.list_messages(self.fid, limit=2)],
[5, 4],
)
self.assertEqual(
[
m.uid
for m in self.store.list_messages(self.fid, limit=2, offset=2)
],
[3, 2],
)
class TestBodies(StoreCase):
def test_write_then_read(self):
self.store.upsert_folder("INBOX")
self.store.write_body("INBOX", 1, b"From: a@x.ca\r\n\r\nBonjour")
self.assertEqual(
self.store.read_body("INBOX", 1), b"From: a@x.ca\r\n\r\nBonjour"
)
def test_missing_body_is_none(self):
self.assertIsNone(self.store.read_body("INBOX", 404))
def test_has_body_flag_is_set(self):
fid = self.store.upsert_folder("INBOX")
self.store.upsert_messages(fid, [meta(1)])
self.store.write_body("INBOX", 1, b"corps")
self.assertTrue(self.store.list_messages(fid)[0].has_body)
def test_folder_with_slash(self):
self.store.upsert_folder("[Gmail]/Sent Mail")
self.store.write_body("[Gmail]/Sent Mail", 1, b"corps")
self.assertEqual(
self.store.read_body("[Gmail]/Sent Mail", 1), b"corps"
)
def test_no_window_at_the_process_umask(self):
"""`write_bytes` puis `chmod` laisserait le corps du message lisible
à l'umask du process le temps entre les deux appels. Au moment où
`chmod` est appelé, le fichier doit déjà être en 0600."""
import stat
from unittest.mock import patch
self.store.upsert_folder("INBOX")
path = self.store._body_path("INBOX", 1)
seen = []
original_chmod = os.chmod
def spy(target, mode):
if Path(target) == path:
seen.append(stat.S_IMODE(os.stat(target).st_mode))
return original_chmod(target, mode)
with patch("os.chmod", side_effect=spy):
self.store.write_body("INBOX", 1, b"corps")
self.assertEqual(seen, [0o600])
class TestPurge(StoreCase):
def test_purge_folder_drops_rows_and_files(self):
fid = self.store.upsert_folder("INBOX")
self.store.upsert_messages(fid, [meta(1)])
self.store.write_body("INBOX", 1, b"corps")
self.store.purge_folder("INBOX")
self.assertEqual(self.store.list_messages(fid), [])
self.assertIsNone(self.store.read_body("INBOX", 1))
def test_purge_folder_resets_last_uid(self):
self.store.upsert_folder("INBOX")
self.store.set_folder_state("INBOX", last_uid=50)
self.store.purge_folder("INBOX")
self.assertEqual(self.store.folder_state("INBOX")["last_uid"], 0)
def test_purge_all(self):
fid = self.store.upsert_folder("INBOX")
self.store.upsert_messages(fid, [meta(1)])
self.store.purge_all()
self.assertEqual(self.store.folders(), [])
def test_size_bytes_grows(self):
before = self.store.size_bytes()
self.store.upsert_folder("INBOX")
self.store.write_body("INBOX", 1, b"x" * 5000)
self.assertGreater(self.store.size_bytes(), before)
class TestEncryptedStore(TestMessages):
"""Le même contrat, en chiffré : rien ne doit changer du point de vue de l'appelant."""
mode = "encrypted"
def test_subject_absent_from_db_file(self):
self.store.upsert_messages(self.fid, [meta(1, subject="CONFIDENTIEL")])
self.store.close()
raw = (self.store.root / "cache.db").read_bytes()
self.assertNotIn(b"CONFIDENTIEL", raw)
self.store.open()
def test_body_file_is_encrypted(self):
self.store.write_body("INBOX", 1, b"TEXTE SECRET")
path = next((self.store.root).rglob("*.eml*"))
self.assertNotIn(b"TEXTE SECRET", path.read_bytes())
def test_date_stays_queryable_in_clear(self):
"""Le tri doit rester du SQL : la date n'est pas scellée."""
self.store.upsert_messages(self.fid, [meta(1, date=12345)])
rows = self.store._conn.execute(
"SELECT date FROM messages WHERE uid = 1"
).fetchall()
self.assertEqual(rows[0][0], 12345)
class TestWrongKey(unittest.TestCase):
def test_reopening_with_another_key_raises(self):
with tempfile.TemporaryDirectory() as tmp:
acc = account_from_preset("perso", "a@x.ca", "generic")
first = Store(acc, mode="encrypted", key=new_key(), base=Path(tmp))
first.open()
fid = first.upsert_folder("INBOX")
first.upsert_messages(fid, [meta(1, subject="secret")])
first.close()
second = Store(
acc, mode="encrypted", key=new_key(), base=Path(tmp)
)
second.open()
with self.assertRaises(CryptoError):
second.list_messages(fid)
second.close()
class TestEphemeral(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.account = account_from_preset("perso", "a@x.ca", "generic")
def tearDown(self):
self.tmp.cleanup()
def test_cleanup_removes_everything(self):
store = Store(
self.account,
mode="ephemeral",
key=new_key(),
base=Path(self.tmp.name),
)
store.open()
root = store.root
store.write_body("INBOX", 1, b"corps")
self.assertTrue(root.exists())
store.close()
store.cleanup()
self.assertFalse(root.exists())
def test_sweep_removes_dead_pid_dirs(self):
base = Path(self.tmp.name)
dead = base / f"{EPHEMERAL_PREFIX}999999999"
dead.mkdir()
alive = base / f"{EPHEMERAL_PREFIX}{os.getpid()}"
alive.mkdir()
removed = sweep_orphan_ephemeral(base)
self.assertEqual(removed, 1)
self.assertFalse(dead.exists())
self.assertTrue(alive.exists())
def test_sweep_ignores_foreign_dirs(self):
base = Path(self.tmp.name)
(base / "autre-chose").mkdir()
self.assertEqual(sweep_orphan_ephemeral(base), 0)
self.assertTrue((base / "autre-chose").exists())
class TestCorruptDatabase(unittest.TestCase):
"""Un open() raté ne doit pas laisser l'objet porteur d'un handle cassé."""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.account = account_from_preset("perso", "a@x.ca", "generic")
root = Path(self.tmp.name) / "perso"
root.mkdir(parents=True)
(root / "cache.db").write_bytes(b"ceci n'est pas une base sqlite" * 40)
def tearDown(self):
self.tmp.cleanup()
def test_open_raises_store_error(self):
store = Store(self.account, mode="clear", base=Path(self.tmp.name))
with self.assertRaises(StoreError):
store.open()
def test_failed_open_does_not_publish_the_connection(self):
"""Sinon le open() suivant réussirait en silence sur une base sans schéma."""
store = Store(self.account, mode="clear", base=Path(self.tmp.name))
with self.assertRaises(StoreError):
store.open()
with self.assertRaises(StoreError):
store.open()
class TestEphemeralIsolation(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.base = Path(self.tmp.name)
self.a = account_from_preset("perso", "a@x.ca", "generic")
self.b = account_from_preset("travail", "b@x.ca", "generic")
def tearDown(self):
self.tmp.cleanup()
def _store(self, account):
store = Store(account, mode="ephemeral", key=new_key(), base=self.base)
store.open()
return store
def test_cleanup_spares_the_sibling_account(self):
"""Le dossier par PID est partagé : l'effacer tuerait le voisin."""
first, second = self._store(self.a), self._store(self.b)
first.write_body("INBOX", 1, b"corps a")
second.write_body("INBOX", 1, b"corps b")
first.cleanup()
self.assertFalse(first.root.exists())
self.assertTrue(second.root.exists())
self.assertEqual(second.read_body("INBOX", 1), b"corps b")
second.cleanup()
def test_last_cleanup_removes_the_pid_directory(self):
first, second = self._store(self.a), self._store(self.b)
pid_dir = first.root.parent
first.cleanup()
self.assertTrue(pid_dir.exists())
second.cleanup()
self.assertFalse(pid_dir.exists())
def test_pid_directory_is_0700(self):
"""/dev/shm est en 1777 : le dossier par PID ne doit rien laisser voir."""
import stat as stat_module
store = self._store(self.a)
mode = stat_module.S_IMODE(os.stat(store.root.parent).st_mode)
self.assertEqual(mode, 0o700)
store.cleanup()
def test_symlinked_pid_directory_is_refused(self):
"""Un tiers peut pré-créer le chemin : on refuse de le suivre."""
target = self.base / "ailleurs"
target.mkdir()
(self.base / f"{EPHEMERAL_PREFIX}{os.getpid()}").symlink_to(target)
store = Store(self.a, mode="ephemeral", key=new_key(), base=self.base)
with self.assertRaises(StoreError):
store.open()
class TestKeyPersistence(unittest.TestCase):
"""Le seul chemin du module qui écrit de la matière de clé sur disque."""
class FakeVault:
def __init__(self):
self.data = {}
def get(self, ref):
return self.data.get(ref)
def set(self, ref, value):
self.data[ref] = value
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.base = Path(self.tmp.name)
self.account = account_from_preset("perso", "a@x.ca", "generic")
self.vault = self.FakeVault()
def tearDown(self):
self.tmp.cleanup()
def _store(self):
store = Store(
self.account, mode="encrypted", secrets=self.vault, base=self.base
)
store.open()
return store
def test_first_open_stores_a_key_under_the_cache_key_ref(self):
self._store().close()
self.assertIn(self.account.cache_key_ref(), self.vault.data)
def test_stored_key_is_base64_of_32_bytes(self):
import base64
self._store().close()
raw = base64.b64decode(self.vault.data[self.account.cache_key_ref()])
self.assertEqual(len(raw), 32)
def test_second_store_reuses_the_stored_key(self):
first = self._store()
fid = first.upsert_folder("INBOX")
first.upsert_messages(fid, [meta(1, subject="Devis")])
first.close()
second = self._store()
self.assertEqual(second.list_messages(fid)[0].subject, "Devis")
second.close()
def test_key_is_not_regenerated_on_reopen(self):
self._store().close()
stored = self.vault.data[self.account.cache_key_ref()]
self._store().close()
self.assertEqual(self.vault.data[self.account.cache_key_ref()], stored)
def test_key_never_lands_in_the_cache_file(self):
import base64
store = self._store()
fid = store.upsert_folder("INBOX")
store.upsert_messages(fid, [meta(1)])
root = store.root
store.close()
raw = base64.b64decode(self.vault.data[self.account.cache_key_ref()])
blob = (root / "cache.db").read_bytes()
self.assertNotIn(raw, blob)
self.assertNotIn(base64.b64encode(raw), blob)
class TestThreadSafety(unittest.TestCase):
"""Le TUI synchronise dans un thread de travail pendant que l'écran lit.
Sans `check_same_thread=False` ET le verrou, la toute première passe de
synchronisation lèverait `sqlite3.ProgrammingError`.
"""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.account = account_from_preset("perso", "a@x.ca", "generic")
self.store = Store(
self.account, mode="clear", base=Path(self.tmp.name)
)
self.store.open()
self.fid = self.store.upsert_folder("INBOX")
def tearDown(self):
self.store.close()
self.tmp.cleanup()
def test_read_from_another_thread(self):
import threading
erreurs = []
def worker():
try:
self.store.folders()
except Exception as exc:
erreurs.append(f"{type(exc).__name__}: {exc}")
thread = threading.Thread(target=worker)
thread.start()
thread.join()
self.assertEqual(erreurs, [])
def test_write_from_another_thread(self):
import threading
erreurs = []
def worker():
try:
self.store.upsert_messages(self.fid, [meta(1)])
except Exception as exc:
erreurs.append(f"{type(exc).__name__}: {exc}")
thread = threading.Thread(target=worker)
thread.start()
thread.join()
self.assertEqual(erreurs, [])
self.assertEqual(len(self.store.list_messages(self.fid)), 1)
def test_concurrent_writers_all_land(self):
"""Le verrou sérialise : aucun upsert ne doit se perdre."""
import threading
def worker(start):
self.store.upsert_messages(
self.fid, [meta(uid) for uid in range(start, start + 20)]
)
threads = [
threading.Thread(target=worker, args=(base,))
for base in (1, 101, 201, 301)
]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
self.assertEqual(
len(self.store.list_messages(self.fid, limit=500)), 80
)
class TestKeyRequired(unittest.TestCase):
def test_encrypted_without_key_or_secrets_raises(self):
with tempfile.TemporaryDirectory() as tmp:
acc = account_from_preset("perso", "a@x.ca", "generic")
store = Store(acc, mode="encrypted", base=Path(tmp))
with self.assertRaises(StoreError):
store.open()
if __name__ == "__main__":
unittest.main()

418
test/test_mail_sync.py Normal file
View file

@ -0,0 +1,418 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
import tempfile
import unittest
from pathlib import Path
from script.todo.mail.accounts import account_from_preset
from script.todo.mail.imap_sync import (
FolderInfo,
HeaderInfo,
SelectInfo,
Syncer,
)
from script.todo.mail.store import Store
class FakeImapTransport:
"""Un serveur IMAP en mémoire : assez pour exercer tout le moteur."""
def __init__(self, folders=None):
# {nom: {"uidvalidity": int, "messages": {uid: HeaderInfo},
# "bodies": {uid: bytes}}}
self.folders = folders or {}
self.selected = None
self.appended = []
self.stored_flags = []
self.logged_out = False
self.select_errors = set()
# -- helpers de test ------------------------------------------------
def add(self, folder, uid, subject="Sujet", flags="", body=b"corps"):
f = self.folders.setdefault(
folder, {"uidvalidity": 1, "messages": {}, "bodies": {}}
)
f["messages"][uid] = HeaderInfo(
uid=uid,
date=1000 + uid,
size=len(body),
flags=flags,
msgid=f"<{uid}@x.ca>",
frm="alice@x.ca",
to="moi@x.ca",
subject=subject,
)
f["bodies"][uid] = body
# -- protocole ------------------------------------------------------
def list_folders(self):
return [FolderInfo(name=n) for n in sorted(self.folders)]
def select(self, folder):
if folder in self.select_errors:
raise OSError(f"select refusé sur {folder}")
self.selected = folder
f = self.folders[folder]
uids = list(f["messages"])
return SelectInfo(
uidvalidity=f["uidvalidity"],
uidnext=(max(uids) + 1) if uids else 1,
exists=len(uids),
)
def search_uids(self, since_uid):
f = self.folders[self.selected]
return sorted(u for u in f["messages"] if u >= since_uid)
def fetch_headers(self, uids):
f = self.folders[self.selected]
return [f["messages"][u] for u in uids if u in f["messages"]]
def fetch_flags(self, uids):
f = self.folders[self.selected]
return [
(u, f["messages"][u].flags) for u in uids if u in f["messages"]
]
def fetch_body(self, uid):
return self.folders[self.selected]["bodies"][uid]
def store_flags(self, uid, add, remove):
self.stored_flags.append((uid, tuple(add), tuple(remove)))
def append(self, folder, raw, flags):
self.appended.append((folder, raw, tuple(flags)))
def logout(self):
self.logged_out = True
class SyncCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.account = account_from_preset("perso", "moi@x.ca", "generic")
self.store = Store(
self.account, mode="clear", base=Path(self.tmp.name)
)
self.store.open()
self.imap = FakeImapTransport()
self.syncer = Syncer(self.store, self.imap)
def tearDown(self):
self.store.close()
self.tmp.cleanup()
def folder_id(self, name):
return self.store.folder_state(name)["id"]
class TestFirstSync(SyncCase):
def test_creates_folders(self):
self.imap.add("INBOX", 1)
self.imap.add("Sent", 1)
report = self.syncer.sync()
self.assertEqual(report.folders, 2)
self.assertEqual(
{f["name"] for f in self.store.folders()}, {"INBOX", "Sent"}
)
def test_stores_messages(self):
self.imap.add("INBOX", 1, subject="Devis")
self.imap.add("INBOX", 2, subject="Facture")
report = self.syncer.sync()
self.assertEqual(report.new_messages, 2)
subjects = {
m.subject
for m in self.store.list_messages(self.folder_id("INBOX"))
}
self.assertEqual(subjects, {"Devis", "Facture"})
def test_records_last_uid(self):
self.imap.add("INBOX", 7)
self.imap.add("INBOX", 9)
self.syncer.sync()
self.assertEqual(self.store.folder_state("INBOX")["last_uid"], 9)
def test_records_uidvalidity(self):
self.imap.add("INBOX", 1)
self.syncer.sync()
self.assertEqual(self.store.folder_state("INBOX")["uidvalidity"], 1)
def test_counts_unseen(self):
self.imap.add("INBOX", 1, flags="\\Seen")
self.imap.add("INBOX", 2, flags="")
self.syncer.sync()
self.assertEqual(self.store.folder_state("INBOX")["unseen"], 1)
def test_empty_folder_is_fine(self):
self.imap.folders["INBOX"] = {
"uidvalidity": 1,
"messages": {},
"bodies": {},
}
report = self.syncer.sync()
self.assertEqual(report.new_messages, 0)
def test_no_body_downloaded_during_sync(self):
self.imap.add("INBOX", 1)
self.syncer.sync()
self.assertFalse(
self.store.list_messages(self.folder_id("INBOX"))[0].has_body
)
class TestIncrementalSync(SyncCase):
def test_second_pass_fetches_only_new(self):
self.imap.add("INBOX", 1)
self.syncer.sync()
self.imap.add("INBOX", 2)
report = self.syncer.sync()
self.assertEqual(report.new_messages, 1)
def test_nothing_new_reports_zero(self):
self.imap.add("INBOX", 1)
self.syncer.sync()
self.assertEqual(self.syncer.sync().new_messages, 0)
def test_flags_are_refreshed(self):
self.imap.add("INBOX", 1, flags="")
self.syncer.sync()
self.imap.folders["INBOX"]["messages"][1].flags = "\\Seen"
self.syncer.sync()
self.assertEqual(
self.store.list_messages(self.folder_id("INBOX"))[0].flags,
"\\Seen",
)
class TestUidValidity(SyncCase):
def test_change_purges_and_resyncs(self):
self.imap.add("INBOX", 1, subject="ancien")
self.syncer.sync()
# Le serveur a rebâti la boîte : mêmes UID, autres messages.
self.imap.folders["INBOX"]["uidvalidity"] = 2
self.imap.folders["INBOX"]["messages"][1].subject = "nouveau"
report = self.syncer.sync()
self.assertIn("INBOX", report.purged)
got = self.store.list_messages(self.folder_id("INBOX"))
self.assertEqual(len(got), 1)
self.assertEqual(got[0].subject, "nouveau")
def test_same_uidvalidity_does_not_purge(self):
self.imap.add("INBOX", 1)
self.syncer.sync()
self.assertEqual(self.syncer.sync().purged, [])
class TestBatching(SyncCase):
def test_large_folder_is_fetched_in_batches(self):
for uid in range(1, 451):
self.imap.add("INBOX", uid)
calls = []
original = self.imap.fetch_headers
def spy(uids):
calls.append(len(uids))
return original(uids)
self.imap.fetch_headers = spy
report = self.syncer.sync()
self.assertEqual(report.new_messages, 450)
self.assertEqual(calls, [200, 200, 50])
class TestNoselectContainers(SyncCase):
"""Signalé depuis un vrai Gmail : « [Gmail] » n'est pas une boîte mais
un NIVEAU de la hiérarchie, marqué `\\Noselect` dans la réponse LIST.
Le SELECTionner répond NO, et cette erreur salissait chaque
synchronisation. Un journal qui crie sur du normal fait rater ce qui ne
l'est pas.
"""
def _sync_avec_conteneur(self):
transport = FakeImapTransport()
transport.add("[Gmail]/Sent Mail", 1, subject="Envoyé")
reels = transport.list_folders()
transport.list_folders = (
lambda: [FolderInfo(name="[Gmail]", selectable=False)] + reels
)
# Le serveur RÉPONDRAIT NO : si le moteur tente quand même, le
# test doit le voir échouer, pas passer par chance.
transport.select_errors.add("[Gmail]")
syncer = Syncer(self.store, transport)
return transport, syncer.sync()
def test_a_container_produces_no_error(self):
_, report = self._sync_avec_conteneur()
self.assertEqual(report.errors, [])
def test_the_container_stays_visible_in_the_tree(self):
"""On le saute, on ne l'efface pas : c'est un niveau que
l'utilisateur voit dans l'arbre des dossiers."""
self._sync_avec_conteneur()
noms = [f["name"] for f in self.store.folders()]
self.assertIn("[Gmail]", noms)
def test_its_selectable_children_are_still_synced(self):
"""Le contrôle qui compte : sauter le parent ne doit pas sauter ce
qu'il contient."""
self._sync_avec_conteneur()
messages = self.store.list_messages(
self.folder_id("[Gmail]/Sent Mail")
)
self.assertEqual(len(messages), 1)
class TestErrors(SyncCase):
def test_failing_folder_does_not_stop_the_others(self):
self.imap.add("INBOX", 1)
self.imap.add("Archives", 1)
self.imap.select_errors.add("Archives")
report = self.syncer.sync()
self.assertEqual(report.new_messages, 1)
self.assertEqual(len(report.errors), 1)
self.assertIn("Archives", report.errors[0])
def test_failing_folder_is_logged(self):
"""`report.errors` seul ne suffit pas : avant ce correctif, rien
dans `script/todo/mail/` ne journalisait quoi que ce soit (à part un
`_logger` déclaré mais jamais utilisé dans `secrets.py`), donc une
panne perdue au-delà de la ligne de statut ne laissait AUCUNE
trace."""
self.imap.add("INBOX", 1)
self.imap.add("Archives", 1)
self.imap.select_errors.add("Archives")
with self.assertLogs("script.todo.mail.imap_sync", level="ERROR"):
self.syncer.sync()
class TestSyncOne(SyncCase):
"""`sync_one` : la sync ciblée qu'utilise `deliver()` (`tui.py`) juste
après un APPEND réussi dans Envoyés, pour que le message parti
apparaisse sans attendre la prochaine passe complète (voir
`docs/superpowers/specs/2026-08-02-email-tui-design.md`, ligne 308)."""
def test_syncs_only_the_named_folder(self):
self.imap.add("INBOX", 1)
self.imap.add("Sent", 1)
report = self.syncer.sync_one("Sent")
self.assertEqual(report.new_messages, 1)
self.assertIsNone(self.store.folder_state("INBOX"))
def test_report_covers_a_single_folder(self):
self.imap.add("Sent", 1)
self.assertEqual(self.syncer.sync_one("Sent").folders, 1)
def test_stores_the_message(self):
self.imap.add("Sent", 1, subject="Devis")
self.syncer.sync_one("Sent")
subjects = {
m.subject for m in self.store.list_messages(self.folder_id("Sent"))
}
self.assertEqual(subjects, {"Devis"})
def test_does_not_raise_when_the_folder_refuses(self):
self.imap.folders["Sent"] = {
"uidvalidity": 1,
"messages": {},
"bodies": {},
}
self.imap.select_errors.add("Sent")
report = self.syncer.sync_one("Sent") # ne doit pas lever
self.assertEqual(len(report.errors), 1)
self.assertIn("Sent", report.errors[0])
def test_failure_is_logged(self):
self.imap.folders["Sent"] = {
"uidvalidity": 1,
"messages": {},
"bodies": {},
}
self.imap.select_errors.add("Sent")
with self.assertLogs("script.todo.mail.imap_sync", level="ERROR"):
self.syncer.sync_one("Sent")
def test_does_not_erase_a_previously_known_display_or_role(self):
"""`sync_one` ne connaît que le nom du dossier : il ne doit pas
écraser le libellé/rôle déjà appris d'un LIST complet (voir le
COALESCE dans `store.upsert_folder`)."""
self.imap.add("Sent", 1)
self.syncer.sync() # premier passage : enregistre display/role
self.store.upsert_folder("Sent", "Envoyés", "sent")
self.syncer.sync_one("Sent")
state = self.store.folder_state("Sent")
self.assertEqual(state["display"], "Envoyés")
self.assertEqual(state["role"], "sent")
class TestProgress(SyncCase):
def test_callback_receives_folder_and_counts(self):
self.imap.add("INBOX", 1)
self.imap.add("INBOX", 2)
seen = []
self.syncer.sync(
progress=lambda name, done, total: seen.append((name, done, total))
)
self.assertEqual(seen[-1], ("INBOX", 2, 2))
class TestFetchBody(SyncCase):
def test_downloads_and_caches(self):
self.imap.add("INBOX", 1, body=b"From: a@x.ca\r\n\r\nBonjour Alice")
self.syncer.sync()
raw = self.syncer.fetch_body("INBOX", 1)
self.assertIn(b"Bonjour Alice", raw)
self.assertEqual(self.store.read_body("INBOX", 1), raw)
def test_second_call_uses_the_cache(self):
self.imap.add("INBOX", 1, body=b"corps")
self.syncer.sync()
self.syncer.fetch_body("INBOX", 1)
self.imap.fetch_body = lambda uid: self.fail("le réseau a été rappelé")
self.assertEqual(self.syncer.fetch_body("INBOX", 1), b"corps")
def test_marks_has_body(self):
self.imap.add("INBOX", 1)
self.syncer.sync()
self.syncer.fetch_body("INBOX", 1)
self.assertTrue(
self.store.list_messages(self.folder_id("INBOX"))[0].has_body
)
def test_snippet_survives_an_unknown_charset(self):
"""Un charset bidon ne doit pas faire tomber l'ouverture du message."""
from script.todo.mail.imap_sync import snippet_from_raw
raw = (
b'Content-Type: text/plain; charset="bogus-charset-xyz"\r\n\r\n'
b"Bonjour Alice"
)
self.assertIn("Bonjour", snippet_from_raw(raw))
def test_snippet_survives_unknown_8bit(self):
"""Étiquette réelle observée en usage (voir `decode_header_value` /
`script/todo/mail/charset.py`), pas seulement un charset inventé."""
from script.todo.mail.imap_sync import snippet_from_raw
raw = (
b'Content-Type: text/plain; charset="unknown-8bit"\r\n\r\n'
b"Bonjour Alice"
)
self.assertIn("Bonjour", snippet_from_raw(raw))
def test_fills_the_snippet(self):
self.imap.add(
"INBOX", 1, body=b"Subject: Devis\r\n\r\nBonjour, voici le devis."
)
self.syncer.sync()
self.syncer.fetch_body("INBOX", 1)
snippet = self.store.list_messages(self.folder_id("INBOX"))[0].snippet
self.assertIn("Bonjour", snippet)
if __name__ == "__main__":
unittest.main()

431
test/test_mail_tui.py Normal file
View file

@ -0,0 +1,431 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
import tempfile
import unittest
from pathlib import Path
from script.todo.mail.accounts import account_from_preset
from script.todo.mail.tui import (
MailboxRef,
Session,
mailbox_refs,
open_sessions,
)
class FailingConnect:
def __init__(self, message="serveur injoignable"):
self.message = message
def __call__(self, account, password):
raise OSError(self.message)
class FakeTransport:
def list_folders(self):
return []
def logout(self):
pass
class FakeSecrets:
def __init__(self, password="hunter2"):
self.password = password
def get(self, ref):
return self.password
def set(self, ref, value):
self.password = value
class SessionCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.base = Path(self.tmp.name)
self.accounts = [
account_from_preset("perso", "moi@x.ca", "generic"),
account_from_preset("travail", "moi@y.ca", "generic"),
]
# Épinglé : sans ça, resolve_mode lirait les préférences réelles de la
# machine et le test dépendrait de ~/.erplibre.
for account in self.accounts:
account.cache_mode = "clear"
def tearDown(self):
self.tmp.cleanup()
class TestOpenSessions(SessionCase):
def test_one_session_per_account(self):
sessions = open_sessions(
self.accounts,
FakeSecrets(),
base=self.base,
connect_fn=lambda a, p: FakeTransport(),
)
self.assertEqual(
[s.account.name for s in sessions], ["perso", "travail"]
)
for s in sessions:
s.close()
def test_disabled_account_is_skipped(self):
self.accounts[1].enabled = False
sessions = open_sessions(
self.accounts,
FakeSecrets(),
base=self.base,
connect_fn=lambda a, p: FakeTransport(),
)
self.assertEqual([s.account.name for s in sessions], ["perso"])
for s in sessions:
s.close()
def test_cache_opens_even_when_the_network_fails(self):
"""Réseau coupé : la boîte doit rester consultable."""
sessions = open_sessions(
self.accounts,
FakeSecrets(),
base=self.base,
connect_fn=FailingConnect(),
)
self.assertTrue(all(not s.online for s in sessions))
self.assertTrue(all(s.store is not None for s in sessions))
for s in sessions:
s.close()
def test_network_error_is_kept_for_display(self):
sessions = open_sessions(
self.accounts,
FakeSecrets(),
base=self.base,
connect_fn=FailingConnect("530 refus"),
)
self.assertIn("530", sessions[0].error)
for s in sessions:
s.close()
def test_missing_password_marks_offline(self):
class NoSecret:
def get(self, ref):
return None
sessions = open_sessions(
self.accounts,
NoSecret(),
base=self.base,
connect_fn=lambda a, p: FakeTransport(),
)
self.assertFalse(sessions[0].online)
for s in sessions:
s.close()
def test_online_when_everything_works(self):
sessions = open_sessions(
self.accounts,
FakeSecrets(),
base=self.base,
connect_fn=lambda a, p: FakeTransport(),
)
self.assertTrue(all(s.online for s in sessions))
for s in sessions:
s.close()
class TestEphemeralCleanupOnSignals(SessionCase):
"""`atexit` ne s'exécute pas sur un signal : sans un gestionnaire
`SIGINT`/`SIGTERM` dédié, un cache éphémère survivrait à un `kill` ou un
Ctrl+C — exactement ce que ce mode promet d'éviter.
On déclenche le gestionnaire installé directement plutôt que d'envoyer un
vrai signal au processus de test, et on restaure l'ancien gestionnaire
dans `tearDown` pour ne pas polluer le reste de la suite.
Piège vécu : quand la disposition précédente n'est PAS appelable
(`SIG_DFL`, le cas par défaut), le gestionnaire se renvoie maintenant
POUR DE VRAI le signal après le nettoyage — sinon il l'avalerait (voir
`test_sigterm_actually_terminates_the_process`). Appeler ce gestionnaire
directement avec la disposition par défaut en place tuerait donc le
processus de test lui-même : `test_sigterm_removes_the_ephemeral_root`
installe d'abord un `_previous` factice et appelable pour rester une
invocation directe sûre, et laisse la vraie fin de processus au test
suivant, seul endroit sûr pour l'observer (un sous-processus dédié).
"""
def setUp(self):
super().setUp()
import signal
self._orig_sigint = signal.getsignal(signal.SIGINT)
self._orig_sigterm = signal.getsignal(signal.SIGTERM)
def tearDown(self):
import signal
signal.signal(signal.SIGINT, self._orig_sigint)
signal.signal(signal.SIGTERM, self._orig_sigterm)
super().tearDown()
def test_sigterm_removes_the_ephemeral_root(self):
import signal
# Un `_previous` factice mais appelable : la branche de repli qui
# renvoie le signal pour de vrai (cas `SIG_DFL`) n'est PAS sûre à
# emprunter ici, puisqu'on invoque le gestionnaire directement dans
# le processus de test — elle est couverte séparément, en
# sous-processus, par `test_sigterm_actually_terminates_the_process`.
signal.signal(signal.SIGTERM, lambda signum, frame: None)
for account in self.accounts:
account.cache_mode = "ephemeral"
sessions = open_sessions(
self.accounts,
FakeSecrets(),
base=self.base,
connect_fn=lambda a, p: FakeTransport(),
)
roots = [s.store.root for s in sessions]
self.assertTrue(all(r.exists() for r in roots))
handler = signal.getsignal(signal.SIGTERM)
handler(signal.SIGTERM, None)
self.assertFalse(any(r.exists() for r in roots))
def test_sigterm_actually_terminates_the_process(self):
"""Un test qui ne vérifie QUE le nettoyage ne peut pas distinguer
« nettoyé puis sorti » de « nettoyé puis resté vivant » — c'est
exactement cette distinction que la régression a ratée : le
gestionnaire chaîné n'appelait le précédent handler que s'il était
`callable`, or la disposition par défaut de SIGTERM (`SIG_DFL`) est
l'entier 0, pas un appelable — le signal était donc avalé.
On lance un vrai sous-processus, on lui envoie SIGTERM pour de
vrai (pas un appel direct du handler), et on vérifie qu'il MEURT.
"""
import signal
import subprocess
import sys
repo_root = Path(__file__).resolve().parent.parent
with tempfile.TemporaryDirectory() as base_dir:
script = f"""
import os
import signal
import sys
import time
sys.path.insert(0, {str(repo_root)!r})
from script.todo.mail.accounts import account_from_preset
from script.todo.mail.tui import open_sessions
class FakeTransport:
def list_folders(self):
return []
def logout(self):
pass
class FakeSecrets:
def get(self, ref):
return "hunter2"
def set(self, ref, value):
pass
account = account_from_preset("perso", "moi@x.ca", "generic")
account.cache_mode = "ephemeral"
sessions = open_sessions(
[account],
FakeSecrets(),
base={str(base_dir)!r},
connect_fn=lambda a, p: FakeTransport(),
)
print(str(sessions[0].store.root), flush=True)
os.kill(os.getpid(), signal.SIGTERM)
# Ne doit JAMAIS s'imprimer : y arriver veut dire que le signal a été avalé.
time.sleep(2)
print("FAILURE: still alive after SIGTERM", flush=True)
"""
script_path = Path(base_dir) / "sigterm_child.py"
script_path.write_text(script)
result = subprocess.run(
[sys.executable, str(script_path)],
capture_output=True,
text=True,
timeout=10,
)
lines = [
line for line in result.stdout.splitlines() if line.strip()
]
self.assertTrue(
lines, f"aucune sortie de l'enfant : {result.stderr}"
)
root = Path(lines[0])
self.assertNotIn("still alive", result.stdout)
self.assertEqual(result.returncode, -signal.SIGTERM)
self.assertFalse(root.exists())
class TestMailboxRefs(SessionCase):
def setUp(self):
super().setUp()
self.sessions = open_sessions(
self.accounts,
FakeSecrets(),
base=self.base,
connect_fn=lambda a, p: FakeTransport(),
)
def tearDown(self):
for s in self.sessions:
s.close()
super().tearDown()
def test_empty_when_no_folder(self):
self.assertEqual(mailbox_refs(self.sessions), [])
def test_lists_folders_of_every_account(self):
self.sessions[0].store.upsert_folder("INBOX", "INBOX", "inbox")
self.sessions[1].store.upsert_folder("INBOX", "INBOX", "inbox")
refs = mailbox_refs(self.sessions)
self.assertEqual(
[(r.account_name, r.folder_name) for r in refs],
[("perso", "INBOX"), ("travail", "INBOX")],
)
def test_inbox_comes_first(self):
store = self.sessions[0].store
store.upsert_folder("Archives", "Archives", None)
store.upsert_folder("INBOX", "INBOX", "inbox")
names = [r.folder_name for r in mailbox_refs(self.sessions)]
self.assertEqual(names[0], "INBOX")
def test_carries_unseen_count(self):
store = self.sessions[0].store
store.upsert_folder("INBOX", "INBOX", "inbox")
store.set_folder_state("INBOX", unseen=4)
self.assertEqual(mailbox_refs(self.sessions)[0].unseen, 4)
def test_display_falls_back_to_name(self):
self.sessions[0].store.upsert_folder("Projets")
self.assertEqual(mailbox_refs(self.sessions)[0].display, "Projets")
class TestBrokenCache(SessionCase):
"""Un cache illisible sur UN compte ne doit pas couler les autres."""
def _corrupt(self, name):
root = self.base / name
root.mkdir(parents=True, exist_ok=True)
(root / "cache.db").write_bytes(b"pas une base sqlite" * 50)
def _open(self):
return open_sessions(
self.accounts,
FakeSecrets(),
base=self.base,
connect_fn=lambda a, p: FakeTransport(),
)
def test_the_other_accounts_still_open(self):
self._corrupt("perso")
sessions = self._open()
self.assertEqual(
[s.account.name for s in sessions], ["perso", "travail"]
)
self.assertIsNone(sessions[0].store)
self.assertIsNotNone(sessions[1].store)
for session in sessions:
session.close()
def test_the_broken_account_keeps_its_error(self):
self._corrupt("perso")
sessions = self._open()
self.assertIn("cache.db", sessions[0].error)
for session in sessions:
session.close()
def test_the_broken_account_is_offline(self):
self._corrupt("perso")
sessions = self._open()
self.assertFalse(sessions[0].online)
for session in sessions:
session.close()
def test_mailbox_refs_skips_it_without_raising(self):
self._corrupt("perso")
sessions = self._open()
sessions[1].store.upsert_folder("INBOX", "INBOX", "inbox")
refs = mailbox_refs(sessions)
self.assertEqual([r.account_name for r in refs], ["travail"])
for session in sessions:
session.close()
def test_closing_a_broken_session_does_not_raise(self):
self._corrupt("perso")
sessions = self._open()
for session in sessions:
session.close()
class TestImportsWithoutTextual(unittest.TestCase):
def test_module_imports_without_textual(self):
"""Le module doit rester utilisable là où Textual n'est pas installé."""
import script.todo.mail.tui as tui
self.assertTrue(hasattr(tui, "run_tui"))
class TestSaveAttachment(unittest.TestCase):
RAW = (
b'Content-Type: multipart/mixed; boundary="B"\r\n\r\n'
b"--B\r\nContent-Type: text/plain\r\n\r\ncorps\r\n"
b"--B\r\nContent-Type: application/pdf\r\n"
b'Content-Disposition: attachment; filename="devis.pdf"\r\n\r\n'
b"%PDF\r\n--B--\r\n"
)
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
def tearDown(self):
self.tmp.cleanup()
def test_writes_the_file(self):
from script.todo.mail.tui import save_attachment
target = save_attachment(self.RAW, 0, self.tmp.name)
self.assertTrue(target.exists())
self.assertEqual(target.name, "devis.pdf")
def test_unknown_index_raises(self):
from script.todo.mail.tui import save_attachment
with self.assertRaises(ValueError):
save_attachment(self.RAW, 7, self.tmp.name)
def test_filename_cannot_escape_the_directory(self):
"""Le nom vient du message : il ne doit jamais écrire ailleurs."""
from script.todo.mail.tui import save_attachment
hostile = self.RAW.replace(b'"devis.pdf"', b'"../../evade.pdf"')
target = save_attachment(hostile, 0, self.tmp.name)
self.assertEqual(target.parent, Path(self.tmp.name))
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,879 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Ajout de compte depuis le TUI : `n` (ou le nœud "+ Ajouter un compte")
ouvre le formulaire, et un compte sauvegardé doit devenir utilisable sans
redémarrer — présent à la fois dans `self.sessions` ET dans l'arbre.
Comme `test_mail_compose.py` : `on_mount` lit `todo_prefs`, qui crée
`~/.erplibre` s'il est absent, et l'écran d'ajout lit/écrit
`~/.erplibre/mail/accounts.json` par les mêmes fonctions que le CLI. `$HOME`
est donc détourné vers un dossier jetable pour tout le module.
"""
import os
import tempfile
import unittest
from pathlib import Path
from script.todo.mail import accounts as mail_accounts
class FakeConfigFile:
"""Un `config_file` minimal — seuls `get_config_value`/`set_config_value`
sont utilisés par `account_setup`, pas besoin du vrai `ConfigFile`."""
def __init__(self):
self._values: dict = {}
def get_config_value(self, keys):
node = self._values
for key in keys:
if not isinstance(node, dict) or key not in node:
return None
node = node[key]
return node
def set_config_value(self, keys, value):
node = self._values
for key in keys[:-1]:
node = node.setdefault(key, {})
node[keys[-1]] = value
class FakeSecretStore:
"""Coffre en mémoire : suffisant pour vérifier ce que le formulaire y
écrit, sans toucher ni pykeepass ni le trousseau système."""
def __init__(self):
self._data: dict = {}
def available_backends(self):
return ["kdbx"]
def get(self, ref):
return self._data.get(ref)
def set(self, ref, value):
self._data[ref] = value
def delete(self, ref):
self._data.pop(ref, None)
class FakeTransport:
def list_folders(self):
return []
def logout(self):
pass
class TuiAccountCase(unittest.IsolatedAsyncioTestCase):
def setUp(self):
# Ces tests comparent des libellés d'arbre en français : ils fixent
# donc la langue au lieu d'hériter de celle que le fichier précédent
# a laissée, sinon ils passent seuls et échouent dans la suite
# complète — ce qui est arrivé.
#
# On écrit la mémoïsation directement : `set_lang()` PERSISTE la
# langue dans ./env_var.sh, un fichier suivi par git, donc l'appeler
# depuis un test modifierait l'arbre de travail.
from script.todo import todo_i18n
self._old_lang = todo_i18n._current_lang
todo_i18n._current_lang = "fr"
self.fake_home = tempfile.TemporaryDirectory()
self._old_home = os.environ.get("HOME")
os.environ["HOME"] = self.fake_home.name
self.cache_dir = tempfile.TemporaryDirectory()
self.config_file = FakeConfigFile()
# Un kdbx déjà configuré : le flux saute `VaultScreen` et va droit à
# `AccountScreen`, ce que couvrent les tests de cette classe.
self.config_file.set_config_value(
["kdbx", "path"], "/already/configured.kdbx"
)
self.secret_store = FakeSecretStore()
def tearDown(self):
from script.todo import todo_i18n
# Rendre la langue telle qu'on l'a trouvée : ne pas reproduire sur
# les tests suivants la fuite qui a cassé ceux-ci.
todo_i18n._current_lang = self._old_lang
if self._old_home is None:
os.environ.pop("HOME", None)
else:
os.environ["HOME"] = self._old_home
self.fake_home.cleanup()
self.cache_dir.cleanup()
async def _mounted_app(self, sessions=None):
# `run_tui(run_app=False, ...)` ne renvoie rien : capter la première
# `App` construite, comme `test_mail_compose.py`.
import textual.app
from script.todo.mail.tui import run_tui
captured = []
orig_init = textual.app.App.__init__
def capturing_init(app_self, *a, **kw):
orig_init(app_self, *a, **kw)
captured.append(app_self)
textual.app.App.__init__ = capturing_init
try:
run_tui(
run_app=False,
sessions=sessions or [],
config_file=self.config_file,
secret_store=self.secret_store,
connect_fn=lambda account, password: FakeTransport(),
base=Path(self.cache_dir.name),
)
finally:
textual.app.App.__init__ = orig_init
return captured[-1]
class TestAccountNodeAndBinding(TuiAccountCase):
async def test_add_account_leaf_is_at_the_bottom_of_the_tree(self):
from textual.widgets import Tree
app = await self._mounted_app()
async with app.run_test() as pilot:
await pilot.pause()
tree = app.query_one("#folders", Tree)
labels = [str(child.label) for child in tree.root.children]
self.assertTrue(
any("Ajouter un compte" in label for label in labels)
)
async def test_n_opens_the_account_form(self):
from textual.screen import ModalScreen
from textual.widgets import Input
app = await self._mounted_app()
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("n")
await pilot.pause()
self.assertIsInstance(app.screen, ModalScreen)
# Le kdbx est déjà configuré : c'est `AccountScreen`, pas
# `VaultScreen`, qui doit s'ouvrir — la présence de `#acc_name`
# le distingue sans exposer les classes imbriquées.
self.assertIsNotNone(app.screen.query_one("#acc_name", Input))
async def test_add_account_node_opens_the_same_screen_as_n(self):
from textual.screen import ModalScreen
from textual.widgets import Input, Tree
app = await self._mounted_app()
async with app.run_test() as pilot:
await pilot.pause()
tree = app.query_one("#folders", Tree)
add_node = next(
child
for child in tree.root.children
if "Ajouter un compte" in str(child.label)
)
tree.select_node(add_node)
tree.action_select_cursor()
await pilot.pause()
self.assertIsInstance(app.screen, ModalScreen)
self.assertIsNotNone(app.screen.query_one("#acc_name", Input))
class TestAccountScreenSavesAndGoesLive(TuiAccountCase):
"""La couture qui compte : un compte sauvegardé doit apparaître dans
`self.sessions` ET dans l'arbre, sans redémarrer le TUI."""
async def test_valid_submission_is_saved_and_usable_immediately(self):
from textual.screen import ModalScreen
from textual.widgets import Input, Select, Tree
app = await self._mounted_app()
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("n")
await pilot.pause()
screen = app.screen
screen.query_one("#acc_name", Input).value = "perso"
screen.query_one("#acc_email", Input).value = "moi@x.ca"
screen.query_one("#acc_password", Input).value = "hunter2"
self.assertEqual(
screen.query_one("#acc_preset", Select).value, "generic"
)
screen.query_one("#acc_imap", Input).value = "imap.x.ca"
screen.query_one("#acc_smtp", Input).value = "smtp.x.ca"
await pilot.press("ctrl+s")
await pilot.pause()
await app.workers.wait_for_complete()
await pilot.pause()
# L'écran s'est refermé.
self.assertNotIsInstance(app.screen, ModalScreen)
# Le fichier de comptes le confirme.
saved = mail_accounts.load()
self.assertEqual([a.name for a in saved], ["perso"])
self.assertEqual(
self.secret_store.get(saved[0].secret_ref), "hunter2"
)
# ET le TUI en tient une session utilisable tout de suite.
self.assertEqual(len(app.sessions), 1)
self.assertEqual(app.sessions[0].account.name, "perso")
# ET l'arbre la montre, sans redémarrer.
tree = app.query_one("#folders", Tree)
labels = [str(child.label) for child in tree.root.children]
self.assertTrue(any("perso" in label for label in labels))
async def test_cancel_creates_nothing(self):
from textual.screen import ModalScreen
from textual.widgets import Input
app = await self._mounted_app()
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("n")
await pilot.pause()
app.screen.query_one("#acc_name", Input).value = "perso"
await pilot.press("escape")
await pilot.pause()
self.assertNotIsInstance(app.screen, ModalScreen)
self.assertEqual(mail_accounts.load(), [])
self.assertEqual(app.sessions, [])
async def test_invalid_name_is_refused_and_the_screen_stays_open(self):
from textual.screen import ModalScreen
from textual.widgets import Input, Static
app = await self._mounted_app()
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("n")
await pilot.pause()
screen = app.screen
# `/` est refusé par `Account.__post_init__` : le message doit
# se lire sur l'écran, pas remonter en exception.
screen.query_one("#acc_name", Input).value = "per/so"
screen.query_one("#acc_email", Input).value = "moi@x.ca"
screen.query_one("#acc_password", Input).value = "hunter2"
await pilot.press("ctrl+s")
await pilot.pause()
self.assertIsInstance(app.screen, ModalScreen)
status = screen.query_one("#account_status", Static)
self.assertTrue(str(status.content))
self.assertEqual(mail_accounts.load(), [])
async def test_missing_password_is_refused_with_a_message(self):
from textual.screen import ModalScreen
from textual.widgets import Input, Static
app = await self._mounted_app()
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("n")
await pilot.pause()
screen = app.screen
screen.query_one("#acc_name", Input).value = "perso"
screen.query_one("#acc_email", Input).value = "moi@x.ca"
await pilot.press("ctrl+s")
await pilot.pause()
self.assertIsInstance(app.screen, ModalScreen)
status = screen.query_one("#account_status", Static)
self.assertTrue(str(status.content))
self.assertEqual(mail_accounts.load(), [])
class TestVaultScreenFirst(TuiAccountCase):
"""Sans kdbx configuré, `VaultScreen` s'ouvre avant `AccountScreen`, et
l'annuler annule tout le flux."""
def setUp(self):
super().setUp()
# Ce groupe teste justement l'ABSENCE de configuration.
self.config_file = FakeConfigFile()
async def test_no_kdbx_configured_opens_vault_screen_first(self):
from textual.screen import ModalScreen
from textual.widgets import Input
app = await self._mounted_app()
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("n")
await pilot.pause()
self.assertIsInstance(app.screen, ModalScreen)
self.assertIsNotNone(app.screen.query_one("#vault_path", Input))
async def test_cancelling_the_vault_cancels_the_whole_flow(self):
from textual.screen import ModalScreen
app = await self._mounted_app()
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("n")
await pilot.pause()
await pilot.press("escape")
await pilot.pause()
self.assertNotIsInstance(app.screen, ModalScreen)
self.assertIsNone(
self.config_file.get_config_value(["kdbx", "path"])
)
async def test_creating_the_vault_then_proceeds_to_the_account_form(self):
from textual.screen import ModalScreen
from textual.widgets import Input
app = await self._mounted_app()
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("n")
await pilot.pause()
vault_path = os.path.join(self.cache_dir.name, "new.kdbx")
app.screen.query_one("#vault_path", Input).value = vault_path
app.screen.query_one("#vault_password", Input).value = "hunter2"
app.screen.query_one("#vault_password_confirm", Input).value = (
"hunter2"
)
await pilot.click("#vault_create")
await pilot.pause()
self.assertIsInstance(app.screen, ModalScreen)
self.assertIsNotNone(app.screen.query_one("#acc_name", Input))
self.assertTrue(os.path.isfile(vault_path))
self.assertEqual(
self.config_file.get_config_value(["kdbx", "path"]),
vault_path,
)
async def test_mismatched_vault_passwords_are_refused(self):
from textual.screen import ModalScreen
from textual.widgets import Input, Static
app = await self._mounted_app()
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("n")
await pilot.pause()
vault_path = os.path.join(self.cache_dir.name, "new.kdbx")
app.screen.query_one("#vault_path", Input).value = vault_path
app.screen.query_one("#vault_password", Input).value = "hunter2"
app.screen.query_one("#vault_password_confirm", Input).value = (
"autrechose"
)
await pilot.click("#vault_create")
await pilot.pause()
self.assertIsInstance(app.screen, ModalScreen)
status = app.screen.query_one("#vault_status", Static)
self.assertTrue(str(status.content))
self.assertFalse(os.path.isfile(vault_path))
class TestVaultScreenSurvivesDiskErrors(TuiAccountCase):
"""`_create`/`_choose` do real disk I/O (`create_kdbx`,
`ConfigFile.set_config_value`) and only caught `SecretError` — a plain
`OSError` (disque plein, permission refusée) escaped into Textual's own
handler, which renders every local, including the plaintext vault
password sitting right there in the same frame."""
def setUp(self):
super().setUp()
# Ce groupe teste justement l'ABSENCE de configuration : c'est
# `VaultScreen`, pas `AccountScreen`, qui est en cause ici.
self.config_file = FakeConfigFile()
async def test_create_vault_oserror_keeps_the_screen_open(self):
from unittest.mock import patch
from textual.screen import ModalScreen
from textual.widgets import Input, Static
app = await self._mounted_app()
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("n")
await pilot.pause()
vault_path = os.path.join(self.cache_dir.name, "new.kdbx")
app.screen.query_one("#vault_path", Input).value = vault_path
app.screen.query_one("#vault_password", Input).value = "hunter2"
app.screen.query_one("#vault_password_confirm", Input).value = (
"hunter2"
)
with patch(
"script.todo.mail.account_setup.create_vault",
side_effect=OSError("disque plein"),
):
await pilot.click("#vault_create")
await pilot.pause()
# Toujours un ModalScreen : ni plantage, ni fermeture d'écran.
self.assertIsInstance(app.screen, ModalScreen)
status = app.screen.query_one("#vault_status", Static)
self.assertTrue(str(status.content))
self.assertFalse(os.path.isfile(vault_path))
async def test_create_reports_an_unopenable_vault_itself(self):
"""`_create` crée le fichier PUIS l'ouvre tout de suite,
symétriquement à `_choose` : un coffre créé mais inouvrable
(mauvaise entropie, corruption immédiate, ...) doit se signaler ICI,
pas plus tard sur `AccountScreen` — l'utilisateur peut encore agir
sur le coffre à cet instant précis."""
from unittest.mock import patch
from pykeepass.exceptions import CredentialsError
from textual.screen import ModalScreen
from textual.widgets import Input, Static
app = await self._mounted_app()
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("n")
await pilot.pause()
vault_path = os.path.join(self.cache_dir.name, "new.kdbx")
app.screen.query_one("#vault_path", Input).value = vault_path
app.screen.query_one("#vault_password", Input).value = "hunter2"
app.screen.query_one("#vault_password_confirm", Input).value = (
"hunter2"
)
# `create_vault` (donc `create_kdbx`) tourne pour de vrai — le
# fichier existe. Seule l'OUVERTURE qui suit échoue.
with patch(
"pykeepass.PyKeePass",
side_effect=CredentialsError("mauvais mot de passe"),
):
await pilot.click("#vault_create")
await pilot.pause()
# Toujours `VaultScreen` : `#vault_path` n'existe que là,
# `AccountScreen` ne l'a jamais poussé.
self.assertIsInstance(app.screen, ModalScreen)
self.assertIsNotNone(app.screen.query_one("#vault_path", Input))
status = app.screen.query_one("#vault_status", Static)
self.assertTrue(str(status.content))
self.assertTrue(os.path.isfile(vault_path))
async def test_use_existing_vault_oserror_keeps_the_screen_open(self):
from unittest.mock import patch
from textual.screen import ModalScreen
from textual.widgets import Input, Static
app = await self._mounted_app()
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("n")
await pilot.pause()
existing_path = os.path.join(self.cache_dir.name, "existing.kdbx")
with open(existing_path, "wb") as handle:
handle.write(b"not a real kdbx, just a file")
app.screen.query_one("#vault_path", Input).value = existing_path
app.screen.query_one("#vault_password", Input).value = "hunter2"
with patch(
"script.todo.mail.account_setup.use_existing_vault",
side_effect=OSError("permission refusée"),
):
await pilot.click("#vault_choose")
await pilot.pause()
self.assertIsInstance(app.screen, ModalScreen)
status = app.screen.query_one("#vault_status", Static)
self.assertTrue(str(status.content))
async def test_guard_covers_the_first_statement_in_create(self):
"""Protection structurelle (round 3) : trois manches ont chacune
trouvé un appel qui tournait hors garde parce que le `try` ouvrait
trop tard. Ce test casse la TOUTE PREMIÈRE lecture faite à
l'intérieur du `try` de `_create` (`#vault_path`) — s'il repasse au
rouge, c'est que le `try` a de nouveau été repoussé plus bas."""
from textual.screen import ModalScreen
from textual.widgets import Input, Static
app = await self._mounted_app()
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("n")
await pilot.pause()
screen = app.screen
orig_query_one = screen.query_one
def failing_query_one(selector, *args, **kwargs):
if selector == "#vault_path":
raise RuntimeError("échec simulé sur la 1re lecture")
return orig_query_one(selector, *args, **kwargs)
screen.query_one = failing_query_one
await pilot.click("#vault_create")
await pilot.pause()
self.assertIsInstance(app.screen, ModalScreen)
status = orig_query_one("#vault_status", Static)
self.assertTrue(str(status.content))
async def test_guard_covers_the_first_statement_in_choose(self):
"""Même protection que ci-dessus, pour `_choose`."""
from textual.screen import ModalScreen
from textual.widgets import Input, Static
app = await self._mounted_app()
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("n")
await pilot.pause()
screen = app.screen
orig_query_one = screen.query_one
def failing_query_one(selector, *args, **kwargs):
if selector == "#vault_path":
raise RuntimeError("échec simulé sur la 1re lecture")
return orig_query_one(selector, *args, **kwargs)
screen.query_one = failing_query_one
await pilot.click("#vault_choose")
await pilot.pause()
self.assertIsInstance(app.screen, ModalScreen)
status = orig_query_one("#vault_status", Static)
self.assertTrue(str(status.content))
class TestAccountScreenSurvivesDiskErrors(TuiAccountCase):
"""`action_save` reads `mail_accounts.load()` to compute `existing`
BEFORE its own try/except — an `OSError` there (accounts.json illisible)
escaped uncaught, with the plaintext account password still a local in
that same frame."""
async def test_load_oserror_keeps_the_screen_open(self):
from unittest.mock import patch
from textual.screen import ModalScreen
from textual.widgets import Input, Static
app = await self._mounted_app()
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("n")
await pilot.pause()
screen = app.screen
screen.query_one("#acc_name", Input).value = "perso"
screen.query_one("#acc_email", Input).value = "moi@x.ca"
screen.query_one("#acc_password", Input).value = "hunter2"
screen.query_one("#acc_imap", Input).value = "imap.x.ca"
screen.query_one("#acc_smtp", Input).value = "smtp.x.ca"
with patch(
"script.todo.mail.accounts.load",
side_effect=OSError("disque plein"),
):
await pilot.press("ctrl+s")
await pilot.pause()
self.assertIsInstance(app.screen, ModalScreen)
status = screen.query_one("#account_status", Static)
self.assertTrue(str(status.content))
self.assertEqual(app.sessions, [])
async def test_pykeepass_error_during_save_keeps_the_screen_open(self):
"""`secret_store.set()` peut atteindre `PyKeePass(...)` pour la
première fois ici (coffre tout juste créé sans ouverture immédiate,
avant le fix `_create`, ou coffre modifié entre-temps) : pykeepass
lève `CredentialsError`/`HeaderChecksumError`/..., qui ne sont PAS
des `OSError` — le guard doit rester `except Exception` pour ne
jamais laisser passer `password` en clair vers la traceback de
Textual."""
from pykeepass.exceptions import CredentialsError
from textual.screen import ModalScreen
from textual.widgets import Input, Static
def raise_credentials_error(ref, value):
raise CredentialsError("mauvais mot de passe")
self.secret_store.set = raise_credentials_error
app = await self._mounted_app()
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("n")
await pilot.pause()
screen = app.screen
screen.query_one("#acc_name", Input).value = "perso"
screen.query_one("#acc_email", Input).value = "moi@x.ca"
screen.query_one("#acc_password", Input).value = "hunter2"
screen.query_one("#acc_imap", Input).value = "imap.x.ca"
screen.query_one("#acc_smtp", Input).value = "smtp.x.ca"
await pilot.press("ctrl+s")
await pilot.pause()
self.assertIsInstance(app.screen, ModalScreen)
status = screen.query_one("#account_status", Static)
self.assertTrue(str(status.content))
self.assertEqual(app.sessions, [])
self.assertEqual(mail_accounts.load(), [])
async def test_available_backends_error_keeps_the_screen_open(self):
"""Le cinquième chemin (round 3) : `self.secret_store
.available_backends()` tournait hors de toute garde, alors que
`password` était déjà une variable locale. Le vrai
`keyring.core.load_config()` peut lever `ModuleNotFoundError` ou
`AttributeError` sur un backend configuré mais cassé — ni l'une ni
l'autre n'est une `OSError`."""
from textual.screen import ModalScreen
from textual.widgets import Input, Static
def raise_broken_backend():
raise ModuleNotFoundError("backend keyring introuvable")
self.secret_store.available_backends = raise_broken_backend
app = await self._mounted_app()
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("n")
await pilot.pause()
screen = app.screen
screen.query_one("#acc_name", Input).value = "perso"
screen.query_one("#acc_email", Input).value = "moi@x.ca"
screen.query_one("#acc_password", Input).value = "hunter2"
screen.query_one("#acc_imap", Input).value = "imap.x.ca"
screen.query_one("#acc_smtp", Input).value = "smtp.x.ca"
await pilot.press("ctrl+s")
await pilot.pause()
self.assertIsInstance(app.screen, ModalScreen)
status = screen.query_one("#account_status", Static)
self.assertTrue(str(status.content))
self.assertEqual(app.sessions, [])
async def test_guard_covers_the_first_statement_in_action_save(self):
"""Protection structurelle (round 3) : casse la TOUTE PREMIÈRE
lecture faite à l'intérieur du `try` d'`action_save` (`#acc_name`)
— si ce test repasse au rouge, c'est que le `try` a de nouveau été
repoussé après cette ligne."""
from textual.screen import ModalScreen
from textual.widgets import Static
app = await self._mounted_app()
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("n")
await pilot.pause()
screen = app.screen
orig_query_one = screen.query_one
def failing_query_one(selector, *args, **kwargs):
if selector == "#acc_name":
raise RuntimeError("échec simulé sur la 1re lecture")
return orig_query_one(selector, *args, **kwargs)
screen.query_one = failing_query_one
await pilot.press("ctrl+s")
await pilot.pause()
self.assertIsInstance(app.screen, ModalScreen)
status = orig_query_one("#account_status", Static)
self.assertTrue(str(status.content))
class TestPasswordClearedBeforeDismiss(TuiAccountCase):
"""Round 4 : `password`/`confirm` ne sont pas scopés au `try` par
Python — ils restent des noms liés dans le cadre de
`_create`/`_choose`/`action_save` jusqu'au retour de la fonction, garde
ou pas. Round 3 affirmait, à tort, que le `try` "s'étend jusqu'au
dismiss compris" ; il ne l'atteint pas textuellement.
Vérifié avant d'écrire quoi que ce soit (Textual 8.2.8,
`textual/screen.py:130` et `textual/message_pump.py:507-519,695-704`,
puis confirmé empiriquement par un script autonome) :
`Screen.dismiss()` ne rappelle PAS son callback de résultat
directement — `ResultCallback.__call__` fait
`self.requester.call_next(self.callback, result)`, qui EMPILE l'appel
pour le cycle de message SUIVANT. Ce callback (`_after_account_added`/
`_after_vault_screen`) tourne donc APRÈS que cette méthode soit
retournée pour de bon, dans un cadre d'appel disjoint — une exception
qui y survient n'inclut PAS le cadre de `action_save`/`_create`/
`_choose` dans sa traceback (vérifié : marcher `exc.__traceback__`
depuis un tel échec ne trouve jamais ce cadre). Le "sixième chemin" tel
que décrit (le cadre appelant vivant pendant un callback synchrone)
n'est donc pas démontré sur cette version de Textual.
Ce qui reste vrai et vaut la peine d'être gardé : `password`/`confirm`
n'ont plus aucun usage après le `try`, et les mettre à `None` avant
`dismiss()` ne coûte rien — de la défense en profondeur, pas la
fermeture d'une fuite démontrée. Ces tests vérifient donc la propriété
réelle du code : au moment où `dismiss()` est appelé, le mot de passe
n'est plus dans les locales de l'appelant — sans prétendre qu'un
callback en aval y aurait accès de toute façon."""
def setUp(self):
super().setUp()
# Par défaut : `_create`/`_choose` exigent l'ABSENCE de kdbx
# configuré. Le test `action_save` reconfigure un kdbx localement
# avant de monter l'app.
self.config_file = FakeConfigFile()
async def test_password_is_cleared_before_dismiss_in_action_save(self):
import sys
from textual.widgets import Input
self.config_file.set_config_value(
["kdbx", "path"], "/already/configured.kdbx"
)
app = await self._mounted_app()
captured = {}
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("n")
await pilot.pause()
screen = app.screen
screen.query_one("#acc_name", Input).value = "perso"
screen.query_one("#acc_email", Input).value = "moi@x.ca"
screen.query_one("#acc_password", Input).value = "hunter2"
screen.query_one("#acc_imap", Input).value = "imap.x.ca"
screen.query_one("#acc_smtp", Input).value = "smtp.x.ca"
orig_dismiss = type(screen).dismiss
def spying_dismiss(self_screen, result=None):
# Le cadre de l'APPELANT de `dismiss()` est `action_save`
# lui-même : c'est exactement ce qu'on veut inspecter.
caller = sys._getframe(1)
captured["password"] = caller.f_locals.get(
"password", "absent-des-locales"
)
return orig_dismiss(self_screen, result)
screen.dismiss = spying_dismiss.__get__(screen)
await pilot.press("ctrl+s")
await pilot.pause()
self.assertIn("password", captured)
self.assertIsNone(captured["password"])
async def test_password_is_cleared_before_dismiss_in_create(self):
import sys
from textual.widgets import Input
app = await self._mounted_app()
captured = {}
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("n")
await pilot.pause()
screen = app.screen
vault_path = os.path.join(self.cache_dir.name, "new.kdbx")
screen.query_one("#vault_path", Input).value = vault_path
screen.query_one("#vault_password", Input).value = "hunter2"
screen.query_one("#vault_password_confirm", Input).value = (
"hunter2"
)
orig_dismiss = type(screen).dismiss
def spying_dismiss(self_screen, result=None):
caller = sys._getframe(1)
captured["password"] = caller.f_locals.get(
"password", "absent-des-locales"
)
captured["confirm"] = caller.f_locals.get(
"confirm", "absent-des-locales"
)
return orig_dismiss(self_screen, result)
screen.dismiss = spying_dismiss.__get__(screen)
await pilot.click("#vault_create")
await pilot.pause()
self.assertIn("password", captured)
self.assertIsNone(captured["password"])
self.assertIsNone(captured["confirm"])
async def test_password_is_cleared_before_dismiss_in_choose(self):
import sys
from pykeepass import create_database
from textual.widgets import Input
vault_path = os.path.join(self.cache_dir.name, "existing.kdbx")
create_database(vault_path, password="hunter2")
app = await self._mounted_app()
captured = {}
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("n")
await pilot.pause()
screen = app.screen
screen.query_one("#vault_path", Input).value = vault_path
screen.query_one("#vault_password", Input).value = "hunter2"
orig_dismiss = type(screen).dismiss
def spying_dismiss(self_screen, result=None):
caller = sys._getframe(1)
captured["password"] = caller.f_locals.get(
"password", "absent-des-locales"
)
return orig_dismiss(self_screen, result)
screen.dismiss = spying_dismiss.__get__(screen)
await pilot.click("#vault_choose")
await pilot.pause()
self.assertIn("password", captured)
self.assertIsNone(captured["password"])
if __name__ == "__main__":
unittest.main()

627
test/test_mail_tui_help.py Normal file
View file

@ -0,0 +1,627 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""La fenêtre d'aide, touche `h` : les raccourcis du client et quelques
repères, fermée par Échap.
Le piège que ce fichier existe pour interdire : une aide qui MENT. La liste
des touches est engendrée depuis `MailApp.BINDINGS` ; le test central
(`TestHelpIsGeneratedFromBindings`) vérifie donc l'ÉCRAN contre cette liste,
liaison par liaison, sans jamais répéter une touche en dur — ajouter une
liaison demain la fait apparaître sans toucher à ce fichier, tandis qu'une
liste recopiée à la main dans l'écran d'aide le ferait échouer.
Comme `test_mail_tui_splitter.py` : ce qui compte se mesure sur
l'application montée pour de vrai, et sur ce qui est RÉELLEMENT rendu
(`Compositor.render_strips`), jamais sur un attribut interne de l'écran.
"""
import os
import re
import tempfile
import unittest
from pathlib import Path
from script.todo import todo_i18n
from script.todo.mail.accounts import account_from_preset
from script.todo.mail.store import Store
from script.todo.mail.tui import Session
def collapse(text: str) -> str:
"""Le texte, espaces (et retours à la ligne) réduits à un seul espace.
Une description longue se replie sur plusieurs lignes DANS sa colonne :
la chercher telle quelle dans le rendu échouerait alors sur un simple
repli, pas sur une vraie absence. Rich replie aux limites de mots, donc
cette normalisation la reconstitue.
"""
return re.sub(r"\s+", " ", text)
class HelpCase(unittest.IsolatedAsyncioTestCase):
"""Monte `MailApp` pour de vrai, `$HOME` détourné vers un dossier
jetable — même motif que `test_mail_tui_log.py` : `on_mount` lit
`todo_prefs`, qui crée `~/.erplibre` s'il est absent.
La langue est posée en écrivant `todo_i18n._current_lang`, JAMAIS par
`todo_i18n.set_lang` : celle-ci réécrit `./env_var.sh`, un fichier réel
du dépôt (`EL_LANG="fr"`) — un test ne doit pas changer la langue du
poste de qui le lance. Elle est posée AVANT de monter l'application :
`MailApp` est défini À L'INTÉRIEUR de `run_tui`, donc les `t()` de ses
`BINDINGS` sont évalués à CET appel, pas à l'import du module.
"""
def setUp(self):
self.fake_home = tempfile.TemporaryDirectory()
self._old_home = os.environ.get("HOME")
os.environ["HOME"] = self.fake_home.name
self._old_lang = todo_i18n._current_lang
self.cache_dir = tempfile.TemporaryDirectory()
self.account = account_from_preset("perso", "moi@x.ca", "generic")
self.account.cache_mode = "clear"
self.store = Store(
self.account, mode="clear", base=Path(self.cache_dir.name)
)
self.store.open()
self.session = Session(self.account, self.store, None, password="x")
def tearDown(self):
self.store.close()
todo_i18n._current_lang = self._old_lang
if self._old_home is None:
os.environ.pop("HOME", None)
else:
os.environ["HOME"] = self._old_home
self.fake_home.cleanup()
self.cache_dir.cleanup()
async def _mounted_app(self, lang: str = "fr"):
import textual.app
from script.todo.mail.tui import run_tui
todo_i18n._current_lang = lang
captured = []
orig_init = textual.app.App.__init__
def capturing_init(app_self, *a, **kw):
orig_init(app_self, *a, **kw)
captured.append(app_self)
textual.app.App.__init__ = capturing_init
try:
run_tui(run_app=False, sessions=[self.session])
finally:
textual.app.App.__init__ = orig_init
return captured[-1]
def screen_lines(self, app) -> list[str]:
"""Les lignes RÉELLEMENT à l'écran.
`Compositor.render_strips` (`textual/_compositor.py:1185`, vérifié
dans la source de Textual 8.2.8) compose tous les widgets visibles de
l'écran courant et rend une bande par ligne — c'est exactement ce que
le terminal recevrait. Un `Static.render()` dirait, lui, ce que le
widget A ENVIE d'afficher, y compris quand il est hors du cadre ou
caché derrière autre chose.
"""
return [strip.text for strip in app.screen._compositor.render_strips()]
def shortcut_rows(self, lines: list[str]) -> list[str]:
"""Les lignes du TABLEAU des raccourcis, découpées entre ses deux
titres — jamais l'écran entier : la prose, elle, nomme aussi des
touches, et un test qui ne saurait pas les distinguer passerait pour
de mauvaises raisons.
"""
from script.todo.todo_i18n import t
start = next(
index
for index, line in enumerate(lines)
if t("mail_help_keys_heading") in line
)
end = next(
index
for index, line in enumerate(lines)
if t("mail_help_notes_heading") in line
)
return [
line.strip() for line in lines[start + 1 : end] if line.strip()
]
def shown_bindings(self, app) -> list:
from textual.binding import Binding
return [
binding
for binding in Binding.make_bindings(app.BINDINGS)
if binding.show
]
class TestHelpOpensAndCloses(HelpCase):
async def test_h_opens_the_help_window(self):
from textual.screen import ModalScreen
from script.todo.todo_i18n import t
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.press("h")
await pilot.pause()
self.assertIsInstance(app.screen, ModalScreen)
self.assertIn(
t("mail_help_title"),
collapse("\n".join(self.screen_lines(app))),
)
async def test_escape_closes_it_and_takes_its_text_off_the_screen(self):
from textual.screen import ModalScreen
from script.todo.todo_i18n import t
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.press("h")
await pilot.pause()
self.assertIsInstance(app.screen, ModalScreen)
await pilot.press("escape")
await pilot.pause()
self.assertNotIsInstance(app.screen, ModalScreen)
# Fermée pour de vrai : plus rien de l'aide n'est rendu — un
# `dismiss()` qui laisserait l'écran empilé passerait le premier
# test (« ce n'est plus un ModalScreen ») sans passer celui-ci.
self.assertNotIn(
t("mail_help_title"),
collapse("\n".join(self.screen_lines(app))),
)
async def test_h_pressed_twice_does_not_stack_a_second_help_window(self):
"""Un Échap doit suffire à sortir, même après avoir tapé `h` deux
fois — ce que fait quelqu'un qui ne sait plus où il en est, donc
exactement le public de cette fenêtre.
C'est aussi le garde-fou de l'absence de `priority=True` sur `h`
(voir `MailApp.BINDINGS`) : sans priorité, les liaisons de `MailApp`
ne sont plus consultées dès qu'un écran modal est posé, donc le
second `h` ne fait rien. AVEC une priorité, elles le sont encore
(`App._check_bindings` lit alors la chaîne NON tronquée,
`app.py:3978`), une deuxième aide s'empile, et cet Échap n'en
referme qu'une : l'aide resterait à l'écran. Mesuré dans les deux
sens — le raisonnement seul s'est déjà trompé une fois sur ce
point.
"""
from textual.screen import ModalScreen
from script.todo.todo_i18n import t
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.press("h")
await pilot.pause()
await pilot.press("h")
await pilot.pause()
await pilot.press("escape")
await pilot.pause()
self.assertNotIn(
t("mail_help_title"),
collapse("\n".join(self.screen_lines(app))),
"un second `h` a empilé une deuxième fenêtre d'aide",
)
self.assertNotIsInstance(app.screen, ModalScreen)
class TestHelpIsGeneratedFromBindings(HelpCase):
"""Le test qui vaut ce fichier : l'aide est vérifiée CONTRE
`MailApp.BINDINGS`, jamais contre une liste écrite ici.
"""
async def test_every_shown_binding_is_on_screen_with_its_description(self):
app = await self._mounted_app()
# Fenêtre haute : l'aide défile (voir `TestHelpFitsASmallWindow`),
# or ce test-ci veut voir TOUTES les lignes à la fois.
async with app.run_test(size=(100, 45)) as pilot:
await app.workers.wait_for_complete()
await pilot.press("h")
await pilot.pause()
bindings = self.shown_bindings(app)
# Garde-fou : une liste vide (ou amputée) ferait passer la boucle
# ci-dessous sans rien vérifier du tout.
self.assertGreaterEqual(len(bindings), 15)
rows = self.shortcut_rows(self.screen_lines(app))
joined = collapse(" ".join(rows))
for binding in bindings:
key_display = app.get_key_display(binding)
self.assertTrue(
any(row.startswith(key_display) for row in rows),
f"touche {binding.key!r} absente de l'aide",
)
self.assertIn(
collapse(binding.description),
joined,
f"description de {binding.key!r} absente de l'aide",
)
async def test_the_table_has_exactly_one_row_per_shown_binding(self):
"""Une liaison MANQUANTE, mais aussi une touche EN TROP (celle que
laisserait une liste recopiée après le retrait d'une liaison) : le
compte des lignes du tableau attrape les deux. Vrai parce que la
fenêtre de test est assez large pour qu'aucune description ne se
replie sur une deuxième ligne.
"""
app = await self._mounted_app()
async with app.run_test(size=(100, 45)) as pilot:
await app.workers.wait_for_complete()
await pilot.press("h")
await pilot.pause()
rows = self.shortcut_rows(self.screen_lines(app))
self.assertEqual(len(rows), len(self.shown_bindings(app)))
async def test_a_hidden_binding_is_not_listed_as_a_shortcut(self):
"""`escape` (« Retour », le plein écran) est `show=False` : le pied
d'écran ne la montre pas, l'aide non plus. Son rôle DANS cette
fenêtre — fermer — est dit en prose, pas emprunté à cette liaison.
"""
from script.todo.todo_i18n import t
app = await self._mounted_app()
async with app.run_test(size=(100, 45)) as pilot:
await app.workers.wait_for_complete()
await pilot.press("h")
await pilot.pause()
rows = self.shortcut_rows(self.screen_lines(app))
self.assertNotIn(t("mail_back_binding"), " ".join(rows))
# La prose, elle, dit bien comment sortir.
screen = collapse("\n".join(self.screen_lines(app)))
self.assertIn(collapse(t("mail_help_close_hint")), screen)
async def test_symbol_keys_are_shown_as_symbols_not_as_words(self):
"""`Binding("plus", ...)` doit se lire `+`, pas « plus » — c'est ce
que l'utilisateur presse.
"""
app = await self._mounted_app()
async with app.run_test(size=(100, 45)) as pilot:
await app.workers.wait_for_complete()
await pilot.press("h")
await pilot.pause()
rows = self.shortcut_rows(self.screen_lines(app))
bindings = {b.key: b for b in self.shown_bindings(app)}
for key, symbol in (
("plus", "+"),
("minus", "-"),
("slash", "/"),
):
description = bindings[key].description
row = next(row for row in rows if description in row)
self.assertTrue(
row.startswith(symbol),
f"{key!r} affichée « {row} » au lieu de « {symbol} »",
)
self.assertNotIn(key, row)
async def test_upper_and_lower_case_keys_stay_distinguishable(self):
"""`r`/`R` et `a`/`A` sont quatre actions différentes : une aide qui
les afficherait pareil enverrait l'utilisateur presser la mauvaise.
"""
app = await self._mounted_app()
async with app.run_test(size=(100, 45)) as pilot:
await app.workers.wait_for_complete()
await pilot.press("h")
await pilot.pause()
rows = self.shortcut_rows(self.screen_lines(app))
bindings = {b.key: b for b in self.shown_bindings(app)}
for lower, upper in (("r", "R"), ("a", "A")):
lower_row = next(row for row in rows if row.startswith(lower))
upper_row = next(row for row in rows if row.startswith(upper))
self.assertNotEqual(lower_row, upper_row)
self.assertIn(bindings[lower].description, lower_row)
self.assertIn(bindings[upper].description, upper_row)
class TestNoBindingFiresUnderAModalScreen(HelpCase):
"""La classe de bogues dont `h` et `z` ne sont que deux cas : SOUS un
écran modal, AUCUNE liaison de `MailApp` ne doit se déclencher.
Textual ne tronque la chaîne de liaisons au dernier écran modal que pour
les liaisons SANS priorité (`Screen._modal_binding_chain`,
`screen.py:449`, lue par `App._check_bindings`, `app.py:3978`). Une
priorité posée sur n'importe quelle liaison de `MailApp` la ferait donc
tourner pendant qu'un modal est à l'écran — mesuré : `z` y pose
`fullscreen` sur `#panes` SANS que rien ne bouge (le modal couvre), et
la classe est encore là après le renvoi du modal.
Un test par touche ne garderait la classe que jusqu'où va notre patience
à recopier. Ces deux-ci partent donc de `MailApp.BINDINGS` — la même
source que le tableau de l'aide — et couvrent gratuitement la prochaine
liaison ajoutée.
"""
# `escape` est la seule touche exclue : sous un modal, elle regarde
# LÉGITIMEMENT le modal (c'est sa liaison à lui qui la sert, et c'est
# ainsi qu'on referme l'aide). Toutes les autres liaisons de `MailApp`
# sont dans la boucle, sans exception — un jour où l'une d'elles
# demanderait un traitement à part, c'est un signal à remonter, pas une
# ligne à ajouter ici.
KEYS_LEFT_TO_THE_MODAL = ("escape",)
def _keys_under_test(self, app) -> list[tuple[str, str]]:
"""Les couples (touche, nom de l'action attendue), dans l'ordre de
`MailApp.BINDINGS`."""
from textual.binding import Binding
pairs = []
for binding in Binding.make_bindings(app.BINDINGS):
# `Binding("home,ctrl+a", ...)` est légal chez Textual : une
# liaison peut porter plusieurs touches.
for key in binding.key.split(","):
if key and key not in self.KEYS_LEFT_TO_THE_MODAL:
pairs.append((key, f"action_{binding.action}"))
return pairs
def _spy_on_every_action(self, app) -> list[str]:
"""Remplace chaque `action_*` visée par une liaison de `MailApp` par
un mouchard qui n'appelle PAS l'action réelle.
Deux raisons de ne pas rappeler l'original : `q` mettrait fin à
l'application au milieu du test, et `c` empilerait un écran
d'écriture qui avalerait les touches suivantes — la boucle
s'arrêterait de mesurer après la première fuite au lieu de toutes
les lister. Textual résout une action par
`getattr(namespace, f"action_{nom}")`, donc poser l'attribut sur
l'INSTANCE suffit à intercepter.
"""
from textual.binding import Binding
fired: list[str] = []
for binding in Binding.make_bindings(app.BINDINGS):
name = f"action_{binding.action}"
def spy(_name=name):
fired.append(_name)
setattr(app, name, spy)
return fired
async def test_the_keys_do_fire_when_no_modal_is_open(self):
"""Le contrôle POSITIF, sans lequel le test suivant passerait aussi
bien si `pilot.press` n'envoyait rien du tout.
Les mêmes touches, la même boucle, mais sans écran modal : chacune
doit déclencher SON action. Ce test garde aussi le mécanisme du
mouchard lui-même (une action nommée autrement qu'en
`action_<nom>` ne serait pas interceptée, et se verrait ici).
"""
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
pairs = self._keys_under_test(app)
fired = self._spy_on_every_action(app)
for key, _action in pairs:
await pilot.press(key)
await pilot.pause()
# Liste ORDONNÉE, pas un compte : une touche muette compensée
# par une autre qui tirerait deux fois passerait un simple
# décompte, et le contrôle ne contrôlerait plus rien.
self.assertEqual(fired, [action for _key, action in pairs])
async def test_no_binding_fires_while_the_help_is_open(self):
"""La garantie : aucune de ces touches ne déclenche quoi que ce soit
pendant que l'aide est posée.
"""
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.press("h")
await pilot.pause()
# Le mouchard est posé APRÈS l'ouverture : `action_show_help`
# doit avoir tourné pour de vrai, c'est lui qui met le modal en
# place.
fired = self._spy_on_every_action(app)
for key, _action in self._keys_under_test(app):
await pilot.press(key)
await pilot.pause()
self.assertEqual(
fired, [], f"des liaisons ont tourné sous le modal : {fired}"
)
async def test_the_client_underneath_is_untouched(self):
"""Le même parcours, mais avec les VRAIES actions en place : ce que
les mouchards ne peuvent pas montrer, c'est l'état laissé derrière.
Trois mesures, celles que la tâche 26 a prises à la main : l'aide est
toujours l'écran actif, `#panes` n'a pas pris la classe `fullscreen`
(l'effet SILENCIEUX qu'un `z` prioritaire produirait), et un SEUL
Échap ramène le client entier — trois volets affichés.
"""
from textual.screen import ModalScreen
from script.todo.todo_i18n import t
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.press("h")
await pilot.pause()
for key, _action in self._keys_under_test(app):
await pilot.press(key)
await pilot.pause()
self.assertIn(
t("mail_help_title"),
collapse("\n".join(self.screen_lines(app))),
)
self.assertFalse(
app.query_one("#panes").has_class("fullscreen"),
"une touche a basculé le plein écran sous le modal",
)
await pilot.press("escape")
await pilot.pause()
self.assertNotIsInstance(app.screen, ModalScreen)
for pane in ("#folders", "#list_pane", "#preview"):
self.assertTrue(
app.query_one(pane).display,
f"{pane} a disparu après le passage sous le modal",
)
class TestHelpDoesNotStealTyping(HelpCase):
async def test_h_typed_in_the_search_box_types_an_h(self):
"""Taper « h » dans la recherche doit écrire un « h », jamais ouvrir
l'aide.
Ce test fixe le COMPORTEMENT, pas le mécanisme : il passe aussi avec
`priority=True` sur `h` (mesuré), parce que `Screen._binding_chain`
(`screen.py:428-435`, Textual 8.2.8) retire les liaisons de tout
caractère imprimable dès que le widget focalisé déclare pouvoir le
consommer (`Input.check_consume_key`) — avant la répartition, y
compris prioritaire. Ce qui interdit vraiment la priorité est écrit
là où elle serait posée (`MailApp.BINDINGS`), et gardé par
`test_h_pressed_twice_does_not_stack_a_second_help_window`.
"""
from textual.screen import ModalScreen
from textual.widgets import Input
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.press("slash")
await pilot.pause()
self.assertIsInstance(app.focused, Input)
await pilot.press("h")
await pilot.pause()
self.assertEqual(app.query_one("#search", Input).value, "h")
self.assertNotIsInstance(app.screen, ModalScreen)
class TestHelpFitsASmallWindow(HelpCase):
async def test_nothing_is_out_of_reach_in_a_small_terminal(self):
"""~19 raccourcis plus la prose ne tiennent pas dans 20 lignes : ce
qui dépasse doit rester ATTEIGNABLE en défilant, jamais coupé pour
toujours — c'est exactement la partie basse de la liste, donc les
touches ajoutées en DERNIER, qu'une fenêtre sans ascenseur
perdrait.
"""
from script.todo.todo_i18n import t
app = await self._mounted_app()
async with app.run_test(size=(80, 20)) as pilot:
await app.workers.wait_for_complete()
await pilot.press("h")
await pilot.pause()
body = app.screen.query_one("#help_body")
# Sans ça, le test passerait aussi sur une fenêtre où tout tient
# déjà — il ne prouverait plus rien du défilement.
self.assertGreater(body.max_scroll_y, 0)
first_screen = collapse(" ".join(self.screen_lines(app)))
self.assertNotIn(collapse(t("mail_help_close_hint")), first_screen)
seen = first_screen
for _ in range(40):
if body.scroll_offset.y >= body.max_scroll_y:
break
body.scroll_relative(y=4, animate=False)
await pilot.pause()
seen += " " + collapse(" ".join(self.screen_lines(app)))
for binding in self.shown_bindings(app):
self.assertIn(
collapse(binding.description),
seen,
f"« {binding.description} » reste inatteignable",
)
self.assertIn(collapse(t("mail_help_close_hint")), seen)
class TestBindingDescriptionsAreTranslated(HelpCase):
async def test_every_description_comes_from_the_translation_table(self):
"""Aucune description de `MailApp` ne doit rester écrite en dur.
Formulation retenue : chaque description doit être la valeur `fr` ET
`en` d'UNE MÊME clé `mail_*` de `TRANSLATIONS`. Deux formulations
plus simples ont été écartées :
- « la description diffère entre fr et en » serait FAUSSE pour une
traduction légitimement identique dans les deux langues
(« Sync »).
- « la description est une valeur du dictionnaire » passerait pour
une chaîne en dur comme « Quitter », qui est aussi la valeur `fr`
de la clé générale `Quit`.
Celle-ci n'a pas ces trous : une description en dur donnerait la même
chaîne française dans les DEUX langues, et il faudrait pour la
laisser passer une clé `mail_*` dont le `fr` et l'`en` valent tous
deux ce français-là.
"""
from textual.binding import Binding
from script.todo.todo_i18n import TRANSLATIONS
app_fr = await self._mounted_app(lang="fr")
app_en = await self._mounted_app(lang="en")
fr_bindings = list(Binding.make_bindings(app_fr.BINDINGS))
en_bindings = list(Binding.make_bindings(app_en.BINDINGS))
self.assertGreaterEqual(len(fr_bindings), 15)
self.assertEqual(len(fr_bindings), len(en_bindings))
for fr_binding, en_binding in zip(fr_bindings, en_bindings):
self.assertEqual(fr_binding.key, en_binding.key)
matches = [
key
for key, entry in TRANSLATIONS.items()
if key.startswith("mail_")
and entry.get("fr") == fr_binding.description
and entry.get("en") == en_binding.description
]
self.assertTrue(
matches,
f"description de {fr_binding.key!r} non traduite :"
f" {fr_binding.description!r} (fr) /"
f" {en_binding.description!r} (en)",
)
async def test_the_help_window_is_in_english_under_en(self):
"""La conséquence visible de la conversion : sous `en`, l'aide —
dont TOUT le contenu vient de ces descriptions — est en anglais.
"""
from script.todo.todo_i18n import t
app = await self._mounted_app(lang="en")
async with app.run_test(size=(100, 45)) as pilot:
await app.workers.wait_for_complete()
await pilot.press("h")
await pilot.pause()
screen = collapse("\n".join(self.screen_lines(app)))
self.assertIn(t("mail_help_title"), screen)
self.assertIn(t("mail_add_account_binding"), screen)
self.assertNotIn("Nouveau compte", screen)
self.assertIn(collapse(t("mail_help_sync")), screen)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,405 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Dispositions de volets commutables (touche `v`) : `columns` (défaut,
dossiers | liste | aperçu), `split` (dossiers à gauche ; liste au-dessus de
l'aperçu) et `stacked` (les trois empilés).
`resolve_layout`/`next_layout` sont des fonctions pures, testées sans écran.
Le reste — la classe CSS réellement posée sur `#panes`, la persistance dans
`todo_prefs`, et surtout la NON-perturbation de ce que l'utilisateur regarde
— n'a de sens que sur l'application montée pour de vrai, comme
`test_mail_tui_refresh.py`.
"""
import os
import tempfile
import unittest
from pathlib import Path
from script.todo.mail.accounts import account_from_preset
from script.todo.mail.store import MessageMeta, Store
from script.todo.mail.tui import (
MAIL_LAYOUTS,
Session,
next_layout,
resolve_layout,
)
class TestResolveLayout(unittest.TestCase):
def test_known_value_is_kept(self):
self.assertEqual(resolve_layout("split"), "split")
def test_unknown_value_falls_back_to_columns(self):
self.assertEqual(resolve_layout("bogus"), "columns")
def test_empty_value_falls_back_to_columns(self):
self.assertEqual(resolve_layout(""), "columns")
def test_none_falls_back_to_columns(self):
self.assertEqual(resolve_layout(None), "columns")
class TestNextLayout(unittest.TestCase):
def test_cycles_in_declared_order(self):
ids = [layout_id for layout_id, _ in MAIL_LAYOUTS]
self.assertEqual(ids, ["columns", "split", "stacked"])
self.assertEqual(next_layout("columns"), "split")
self.assertEqual(next_layout("split"), "stacked")
def test_wraps_around_after_the_last(self):
self.assertEqual(next_layout("stacked"), "columns")
def test_an_invalid_current_value_starts_the_cycle_from_the_first(self):
self.assertEqual(next_layout("bogus"), "split")
class LayoutCase(unittest.IsolatedAsyncioTestCase):
"""Monte `MailApp` pour de vrai, `$HOME` détourné — même motif que
`test_mail_tui_refresh.py` : `on_mount` lit `todo_prefs`, qui crée
`~/.erplibre` s'il est absent.
"""
def setUp(self):
self.fake_home = tempfile.TemporaryDirectory()
self._old_home = os.environ.get("HOME")
os.environ["HOME"] = self.fake_home.name
self.cache_dir = tempfile.TemporaryDirectory()
self.account = account_from_preset("perso", "moi@x.ca", "generic")
self.account.cache_mode = "clear"
self.store = Store(
self.account, mode="clear", base=Path(self.cache_dir.name)
)
self.store.open()
# Pas de syncer : ces tests portent sur la disposition et la
# persistance de l'état affiché, jamais sur le réseau.
self.session = Session(self.account, self.store, None, password="x")
def tearDown(self):
self.store.close()
if self._old_home is None:
os.environ.pop("HOME", None)
else:
os.environ["HOME"] = self._old_home
self.fake_home.cleanup()
self.cache_dir.cleanup()
def _seed_two_messages(self):
fid = self.store.upsert_folder("INBOX", "INBOX", "inbox")
self.store.upsert_messages(
fid,
[
MessageMeta(
uid=1,
date=1_000,
size=10,
flags="",
msgid="<1@x.ca>",
frm="a@x.ca",
to="moi@x.ca",
subject="Un",
snippet="",
),
MessageMeta(
uid=2,
date=2_000,
size=10,
flags="",
msgid="<2@x.ca>",
frm="b@x.ca",
to="moi@x.ca",
subject="Deux",
snippet="",
),
],
)
async def _mounted_app(self, sessions=None):
import textual.app
from script.todo.mail.tui import run_tui
sessions = sessions if sessions is not None else [self.session]
captured = []
orig_init = textual.app.App.__init__
def capturing_init(app_self, *a, **kw):
orig_init(app_self, *a, **kw)
captured.append(app_self)
textual.app.App.__init__ = capturing_init
try:
run_tui(run_app=False, sessions=sessions)
finally:
textual.app.App.__init__ = orig_init
return captured[-1]
class TestDefaultLayout(LayoutCase):
async def test_columns_is_the_default_on_first_run(self):
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
panes = app.query_one("#panes")
self.assertTrue(panes.has_class("layout-columns"))
self.assertEqual(app.mail_layout, "columns")
class TestCycleLayout(LayoutCase):
async def test_v_cycles_through_every_layout_and_wraps(self):
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
panes = app.query_one("#panes")
await pilot.press("v")
await pilot.pause()
self.assertTrue(panes.has_class("layout-split"))
self.assertFalse(panes.has_class("layout-columns"))
await pilot.press("v")
await pilot.pause()
self.assertTrue(panes.has_class("layout-stacked"))
self.assertFalse(panes.has_class("layout-split"))
await pilot.press("v")
await pilot.pause()
self.assertTrue(panes.has_class("layout-columns"))
self.assertFalse(panes.has_class("layout-stacked"))
async def test_the_binding_is_translated_in_the_footer(self):
from script.todo.todo_i18n import t
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
active = app.screen.active_bindings["v"]
self.assertEqual(
active.binding.description, t("mail_layout_binding")
)
async def test_switching_reports_the_new_layout_translated(self):
from textual.widgets import Static
from script.todo.todo_i18n import t
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
await pilot.press("v")
await pilot.pause()
status = app.query_one("#status", Static)
self.assertIn(t("mail_layout_split"), str(status.content))
class TestLayoutPersistence(LayoutCase):
async def test_the_choice_is_written_to_preferences(self):
from script.todo import todo_prefs
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
await pilot.press("v")
await pilot.pause()
self.assertEqual(todo_prefs.get("mail_layout"), "split")
async def test_a_fresh_mount_reads_back_the_stored_layout(self):
from script.todo import todo_prefs
todo_prefs.set("mail_layout", "stacked")
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
self.assertEqual(app.mail_layout, "stacked")
self.assertTrue(
app.query_one("#panes").has_class("layout-stacked")
)
async def test_a_corrupt_stored_value_falls_back_to_columns(self):
from script.todo import todo_prefs
todo_prefs.set("mail_layout", "not-a-real-layout")
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
self.assertEqual(app.mail_layout, "columns")
self.assertTrue(
app.query_one("#panes").has_class("layout-columns")
)
class TestLayoutSwitchPreservesState(LayoutCase):
"""La propriété qui compte le plus : rien de ce que l'utilisateur
regarde ne doit bouger quand la disposition change — seule la classe
CSS de `#panes` doit changer.
"""
async def test_folder_selection_survives_a_switch(self):
self._seed_two_messages()
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
ref_before = app.current_ref
await pilot.press("v")
await pilot.pause()
self.assertIs(app.current_ref, ref_before)
async def test_the_highlighted_message_survives_a_switch(self):
from textual.widgets import DataTable
self._seed_two_messages()
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
table = app.query_one("#list", DataTable)
table.move_cursor(row=1)
await pilot.pause()
highlighted_before = app.current_meta()
self.assertIsNotNone(highlighted_before)
await pilot.press("v")
await pilot.pause()
self.assertEqual(app.current_meta().uid, highlighted_before.uid)
self.assertEqual(table.cursor_row, 1)
async def test_the_search_filter_survives_a_switch(self):
self._seed_two_messages()
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
app.query = "Deux"
app.refresh_list()
from textual.widgets import DataTable
table = app.query_one("#list", DataTable)
self.assertEqual(table.row_count, 1)
await pilot.press("v")
await pilot.pause()
self.assertEqual(app.query, "Deux")
table = app.query_one("#list", DataTable)
self.assertEqual(table.row_count, 1)
async def test_fullscreen_state_survives_a_switch(self):
self._seed_two_messages()
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
panes = app.query_one("#panes")
panes.add_class("fullscreen")
await pilot.press("v")
await pilot.pause()
self.assertTrue(panes.has_class("fullscreen"))
self.assertTrue(panes.has_class("layout-split"))
async def test_fullscreen_toggle_still_works_in_every_layout(self):
"""`enter` lui-même n'atteint `MailApp` que lorsque le focus n'est
ni sur `#folders` (Tree) ni sur `#list` (DataTable) — les deux lient
déjà `enter` à `select_cursor`, et Textual donne priorité à la
liaison la plus proche du focus (voir le commentaire de
`_SearchInput`) — un fait préexistant, sans rapport avec les
dispositions. On passe donc par l'action elle-même pour entrer en
plein écran, symétriquement à `escape` (jamais intercepté par ces
deux widgets), qui lui reste testé au clavier.
"""
self._seed_two_messages()
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
await pilot.press("v") # split
await pilot.pause()
app.action_toggle_fullscreen()
await pilot.pause()
panes = app.query_one("#panes")
self.assertTrue(panes.has_class("fullscreen"))
self.assertTrue(panes.has_class("layout-split"))
await pilot.press("escape")
await pilot.pause()
self.assertFalse(panes.has_class("fullscreen"))
class TestFullscreenFillsThePanes(LayoutCase):
"""Ce que la revue a trouvé, et qu'aucune assertion sur la classe
`fullscreen` ne peut voir : `.fullscreen #folders, .fullscreen #list`
n'effaçait que les WIDGETS, pas leurs conteneurs enveloppants
(`#list_pane`, `#right`), qui gardaient la taille que leur donne le
bloc CSS de la disposition active — l'aperçu ne prenait donc jamais
tout l'écran. En `columns` cela laissait ~40 % de l'écran vide (le
conteneur de liste, toujours large de 2fr) ; en `split`/`stacked`,
~49 % (le conteneur de liste, toujours haut de 1fr). Il faut donc
MESURER la région réellement occupée par `#preview`, pas seulement
lire une classe CSS.
"""
async def test_the_preview_fills_the_panes_area_in_every_layout(self):
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
panes = app.query_one("#panes")
preview = app.query_one("#preview")
list_pane = app.query_one("#list_pane")
folders = app.query_one("#folders")
for layout_id, _ in MAIL_LAYOUTS:
while app.mail_layout != layout_id:
await pilot.press("v")
await pilot.pause()
panes.add_class("fullscreen")
await pilot.pause()
# La preuve qui compte : l'aperçu occupe TOUTE la région de
# `#panes`, pas seulement une fraction — c'est le contraire
# exact de ce qui a été mesuré avant le correctif (largeur
# ou hauteur de l'aperçu strictement inférieure à celle de
# `#panes`).
self.assertEqual(
preview.region,
panes.region,
f"disposition {layout_id!r} : aperçu {preview.region},"
f" attendu {panes.region}",
)
# Les deux conteneurs masqués n'occupent plus rien du tout
# (et pas seulement leur CONTENU) — c'est précisément ce que
# `#list_pane` corrige par rapport à l'ancien `#list`.
self.assertEqual(list_pane.region.width, 0)
self.assertEqual(list_pane.region.height, 0)
self.assertEqual(folders.region.width, 0)
self.assertEqual(folders.region.height, 0)
panes.remove_class("fullscreen")
await pilot.pause()
if __name__ == "__main__":
unittest.main()

514
test/test_mail_tui_log.py Normal file
View file

@ -0,0 +1,514 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""La fenêtre de diagnostic, touche `l` : la fin de `~/.erplibre/mail.log`,
et les erreurs de synchronisation de la session en cours — sans quitter le
client pour aller les lire dans un fichier.
Le piège que ce fichier vérifie explicitement : une fenêtre qui s'ouvre VIDE
reproduit exactement la plainte qui justifie son existence (« j'ai une
erreur, mais aucun log »). Chaque état — absent, vide, illisible, aucune
erreur de session — doit se dire en toutes lettres.
"""
import os
import tempfile
import unittest
from pathlib import Path
from script.todo.mail.accounts import account_from_preset
from script.todo.mail.store import Store
from script.todo.mail.tui import Session, read_log_tail
class TestReadLogTail(unittest.TestCase):
"""`read_log_tail` est une fonction pure (aucun import Textual) : elle
se teste seule, sans monter d'écran."""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.base = Path(self.tmp.name)
def tearDown(self):
# Un fichier laissé à 0o000 empêcherait parfois le nettoyage du
# dossier temporaire selon le système de fichiers : on restaure les
# droits avant de nettoyer, plutôt que de dépendre de ce détail.
for entry in self.base.glob("*"):
entry.chmod(0o644)
self.tmp.cleanup()
def test_missing_file_returns_explicit_message(self):
from script.todo.todo_i18n import t
lines, message = read_log_tail(self.base / "absent.log")
self.assertEqual(lines, [])
self.assertEqual(message, t("mail_log_missing"))
def test_empty_file_returns_explicit_message(self):
from script.todo.todo_i18n import t
path = self.base / "mail.log"
path.write_bytes(b"")
lines, message = read_log_tail(path)
self.assertEqual(lines, [])
self.assertEqual(message, t("mail_log_empty"))
def test_unreadable_file_returns_explicit_message(self):
from script.todo.todo_i18n import t
if os.geteuid() == 0:
self.skipTest(
"racine : les permissions de fichier ne bloquent rien"
)
path = self.base / "mail.log"
path.write_text("2026-08-04 boum\n")
path.chmod(0o000)
lines, message = read_log_tail(path)
self.assertEqual(lines, [])
self.assertTrue(message.startswith(t("mail_log_unreadable")))
def test_tail_shows_only_the_last_lines(self):
path = self.base / "mail.log"
path.write_text("".join(f"ligne {i}\n" for i in range(1, 501)))
lines, message = read_log_tail(path, max_lines=50)
self.assertEqual(message, "")
self.assertEqual(len(lines), 50)
self.assertEqual(lines[0], "ligne 451")
self.assertEqual(lines[-1], "ligne 500")
def test_does_not_read_the_whole_file(self):
"""La contrainte centrale : un journal qui grossit sans borne ne
doit jamais être chargé en entier pour n'en montrer que la fin.
Vérifié en espionnant les octets RÉELLEMENT lus sur le fichier
cible, pas en devinant depuis le résultat."""
import builtins
path = self.base / "big.log"
line = ("x" * 100) + "\n"
with open(path, "w", encoding="utf-8") as handle:
for _ in range(50_000):
handle.write(line)
total_size = path.stat().st_size
self.assertGreater(total_size, 4_000_000)
read_sizes = []
orig_open = builtins.open
def spying_open(*args, **kwargs):
handle = orig_open(*args, **kwargs)
if args and args[0] == path:
orig_read = handle.read
def spying_read(n=-1, *a, **k):
data = orig_read(n, *a, **k)
read_sizes.append(len(data))
return data
handle.read = spying_read
return handle
builtins.open = spying_open
try:
lines, message = read_log_tail(path, max_lines=20)
finally:
builtins.open = orig_open
self.assertEqual(message, "")
self.assertEqual(len(lines), 20)
self.assertLess(sum(read_sizes), total_size)
def test_survives_non_utf8_bytes(self):
"""Un vrai journal peut contenir des octets qui ne sont pas de
l'UTF-8 valide (encodage local du serveur distant reproduit tel
quel dans un message d'exception, par exemple) : ça ne doit jamais
faire lever la lecture, seulement remplacer ce qui ne se décode
pas."""
path = self.base / "mail.log"
with open(path, "wb") as handle:
handle.write(b"2026-08-04 avant\n")
handle.write(b"\xff\xfe pas de l'utf-8 valide\n")
handle.write(b"2026-08-04 apres\n")
lines, message = read_log_tail(path)
self.assertEqual(message, "")
self.assertEqual(lines[0], "2026-08-04 avant")
self.assertEqual(lines[-1], "2026-08-04 apres")
def test_survives_a_multiline_traceback(self):
"""Une vraie panne écrit une trace Python sur plusieurs lignes, pas
une ligne bien propre : la dernière ligne de la trace doit rester
visible dans la fin du journal."""
traceback_text = (
"2026-08-04 12:00:00 script.todo.mail.imap_sync ERROR sync a échoué\n"
"Traceback (most recent call last):\n"
' File "imap_sync.py", line 140, in sync\n'
" folder = self._sync_folder(info)\n"
"OSError: 530 refus du serveur\n"
)
path = self.base / "mail.log"
path.write_text(traceback_text)
lines, message = read_log_tail(path, max_lines=10)
self.assertEqual(message, "")
self.assertEqual(lines[-1], "OSError: 530 refus du serveur")
self.assertIn("Traceback (most recent call last):", lines)
class _FakeStatResult:
def __init__(self, size):
self.st_size = size
class _ShrunkAfterStatPath:
"""Simule une rotation de journal : `stat()` rapporte encore
l'ANCIENNE taille (non nulle), mais le fichier est déjà vide au
moment où `open()` puis `read()` s'exécutent — la lecture par blocs
rend alors `b""`, sans qu'aucune des deux gardes précédentes
(`exists()`, `size == 0`) ne l'ait vu venir."""
def exists(self):
return True
def stat(self):
return TestReadLogTail._FakeStatResult(1000)
def test_a_file_truncated_between_stat_and_read_is_treated_as_empty(self):
"""`size > 0` au moment du `stat()` ne garantit RIEN sur ce que la
lecture rendra ensuite : un journal peut être tronqué entre les deux
(rotation de journal, notamment) — exactement le cas qu'une revue a
retrouvé après qu'une passe précédente eut, à tort, jugé ce garde-fou
mort."""
import builtins
from script.todo.todo_i18n import t
fake_path = self._ShrunkAfterStatPath()
orig_open = builtins.open
def spying_open(file, *args, **kwargs):
if file is fake_path:
import io
return io.BytesIO(b"")
return orig_open(file, *args, **kwargs)
builtins.open = spying_open
try:
lines, message = read_log_tail(fake_path)
finally:
builtins.open = orig_open
self.assertEqual(lines, [])
self.assertEqual(message, t("mail_log_empty"))
class FakeTransport:
def list_folders(self):
return []
def logout(self):
pass
class LogScreenCase(unittest.IsolatedAsyncioTestCase):
"""Monte `MailApp` pour de vrai, `$HOME` détourné vers un dossier
jetable — comme `test_mail_tui_account.py` : `on_mount` lit
`todo_prefs`, qui crée `~/.erplibre` s'il est absent, et le chemin du
journal (`menu.mail_log_path()`) EST sous `~/.erplibre` : sans ce
détournement, monter l'écran toucherait la vraie machine ET risquerait
de lire le vrai journal de l'utilisateur, qui contient les réponses de
son serveur de courriel.
"""
def setUp(self):
self.fake_home = tempfile.TemporaryDirectory()
self._old_home = os.environ.get("HOME")
os.environ["HOME"] = self.fake_home.name
self.cache_dir = tempfile.TemporaryDirectory()
self.account = account_from_preset("perso", "moi@x.ca", "generic")
self.account.cache_mode = "clear"
self.store = Store(
self.account, mode="clear", base=Path(self.cache_dir.name)
)
self.store.open()
def tearDown(self):
self.store.close()
if self._old_home is None:
os.environ.pop("HOME", None)
else:
os.environ["HOME"] = self._old_home
self.fake_home.cleanup()
self.cache_dir.cleanup()
def _log_path(self) -> Path:
from script.todo.mail.menu import mail_log_path
return mail_log_path()
class _FakeSyncer:
def __init__(self, transport, errors=None):
self.transport = transport
self._errors = errors or []
def sync(self, progress=None):
from types import SimpleNamespace
return SimpleNamespace(
new_messages=0, errors=list(self._errors), purged=[]
)
def sync_one(self, folder_name):
from types import SimpleNamespace
return SimpleNamespace(new_messages=0, errors=[], folders=1)
def fetch_body(self, folder, uid):
return None
async def _mounted_app(self, session=None):
import textual.app
from script.todo.mail.tui import run_tui
sessions = [session] if session is not None else []
captured = []
orig_init = textual.app.App.__init__
def capturing_init(app_self, *a, **kw):
orig_init(app_self, *a, **kw)
captured.append(app_self)
textual.app.App.__init__ = capturing_init
try:
run_tui(run_app=False, sessions=sessions)
finally:
textual.app.App.__init__ = orig_init
return captured[-1]
class TestLogScreenOpensAndCloses(LogScreenCase):
async def test_l_opens_the_log_screen(self):
from textual.screen import ModalScreen
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.press("l")
await pilot.pause()
self.assertIsInstance(app.screen, ModalScreen)
async def test_escape_closes_it_without_disturbing_the_mail_list(self):
from textual.screen import ModalScreen
from textual.widgets import DataTable
session = Session(
self.account,
self.store,
self._FakeSyncer(FakeTransport()),
password="hunter2",
)
self.store.upsert_folder("INBOX", "INBOX", "inbox")
app = await self._mounted_app(session)
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
table_before = app.query_one("#list", DataTable)
rows_before = table_before.row_count
await pilot.press("l")
await pilot.pause()
self.assertIsInstance(app.screen, ModalScreen)
await pilot.press("escape")
await pilot.pause()
self.assertNotIsInstance(app.screen, ModalScreen)
table_after = app.query_one("#list", DataTable)
self.assertEqual(table_after.row_count, rows_before)
async def test_binding_is_translated(self):
from script.todo.todo_i18n import t
app = await self._mounted_app()
binding = next(b for key, b in app._bindings if key == "l")
self.assertEqual(binding.description, t("mail_log_binding"))
class TestLogScreenTailContent(LogScreenCase):
async def test_missing_log_file_says_so_explicitly(self):
from textual.widgets import Log
from script.todo.todo_i18n import t
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.press("l")
await pilot.pause()
log_widget = app.screen.query_one("#log_tail", Log)
text = "\n".join(str(line) for line in log_widget.lines)
self.assertIn(t("mail_log_missing"), text)
async def test_tail_of_a_real_log_file_is_shown(self):
from textual.widgets import Log
log_path = self._log_path()
log_path.parent.mkdir(parents=True, exist_ok=True)
log_path.write_text("".join(f"ligne {i}\n" for i in range(1, 301)))
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.press("l")
await pilot.pause()
log_widget = app.screen.query_one("#log_tail", Log)
text = "\n".join(str(line) for line in log_widget.lines)
self.assertIn("ligne 300", text)
self.assertNotIn("ligne 1\n", text + "\n")
class TestLogScreenSessionErrors(LogScreenCase):
async def test_errors_from_the_last_sync_are_shown(self):
from textual.widgets import Static
session = Session(
self.account,
self.store,
self._FakeSyncer(
FakeTransport(), errors=["Archives : 501 refus du serveur"]
),
password="hunter2",
)
app = await self._mounted_app(session)
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
app._sync([session])
await pilot.pause()
await pilot.press("l")
await pilot.pause()
errors_widget = app.screen.query_one("#log_errors", Static)
self.assertIn(
"Archives : 501 refus du serveur",
str(errors_widget.render()),
)
async def test_no_session_errors_says_so_explicitly(self):
from textual.widgets import Static
from script.todo.todo_i18n import t
session = Session(
self.account,
self.store,
self._FakeSyncer(FakeTransport(), errors=[]),
password="hunter2",
)
app = await self._mounted_app(session)
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
app._sync([session])
await pilot.pause()
await pilot.press("l")
await pilot.pause()
errors_widget = app.screen.query_one("#log_errors", Static)
self.assertIn(t("mail_log_no_errors"), str(errors_widget.render()))
async def test_a_later_clean_sync_clears_a_previous_error(self):
"""« la dernière synchronisation » : une erreur d'il y a deux
passes ne doit pas rester affichée comme si elle était toujours
d'actualité."""
from textual.widgets import Static
from script.todo.todo_i18n import t
session = Session(
self.account,
self.store,
self._FakeSyncer(FakeTransport(), errors=["INBOX : 501 refus"]),
password="hunter2",
)
app = await self._mounted_app(session)
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
app._sync([session])
await pilot.pause()
# Deuxième synchronisation, propre cette fois.
session.syncer = self._FakeSyncer(FakeTransport(), errors=[])
app._sync([session])
await pilot.pause()
await pilot.press("l")
await pilot.pause()
errors_widget = app.screen.query_one("#log_errors", Static)
text = str(errors_widget.render())
self.assertIn(t("mail_log_no_errors"), text)
self.assertNotIn("INBOX : 501 refus", text)
class TestLogScreenTotalSyncFailure(LogScreenCase):
"""`session.sync()` peut lever DIRECTEMENT (connexion totalement
perdue), pas seulement rendre un `report.errors` : c'est la panne la
plus grave, celle qu'un utilisateur ouvrirait précisément cette fenêtre
pour diagnostiquer — un vrai `imaplib.IMAP4.abort ... Broken pipe` a été
trouvé dans un journal réel après une telle panne. Le statut affiché au
moment de la panne est éphémère (le prochain message l'efface) ; `l`
existe pour regarder APRÈS coup, donc cette panne doit rester lisible
dans la fenêtre, pas seulement dans la barre de statut du moment."""
class _FakeSyncerThatRaises:
def __init__(self, transport):
self.transport = transport
def sync(self, progress=None):
raise OSError("imaplib.IMAP4.abort ... Broken pipe")
def sync_one(self, folder_name):
from types import SimpleNamespace
return SimpleNamespace(new_messages=0, errors=[], folders=1)
def fetch_body(self, folder, uid):
return None
async def test_total_failure_is_shown_in_the_window(self):
from textual.widgets import Static
session = Session(
self.account,
self.store,
self._FakeSyncerThatRaises(FakeTransport()),
password="hunter2",
)
app = await self._mounted_app(session)
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
app._sync([session])
await pilot.pause()
await pilot.press("l")
await pilot.pause()
errors_widget = app.screen.query_one("#log_errors", Static)
self.assertIn(
"imaplib.IMAP4.abort ... Broken pipe",
str(errors_widget.render()),
)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,389 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Un message livré par une synchronisation ou un envoi doit apparaître dans
la liste SANS redémarrer le client — le bug réel qui a mené à cette tâche.
L'APPEND et la synchronisation fonctionnaient déjà : après un redémarrage, le
message est là. C'était donc l'ÉCRAN qui restait périmé — `reload_folders()`
ne rafraîchissait la liste de messages que quand AUCUN dossier n'était
sélectionné, or un dossier est toujours déjà ouvert en pratique. Chaque test
ci-dessous fait tourner le VRAI chemin (`_sync`, ou l'écran de composition
via `ctrl+s`) plutôt que d'appeler `refresh_current_folder()` directement :
un test qui ne franchit pas ce seuil ne prouverait rien sur le bug observé.
"""
import os
import tempfile
import unittest
from pathlib import Path
from script.todo.mail.accounts import account_from_preset
from script.todo.mail.imap_sync import (
FolderInfo,
HeaderInfo,
SelectInfo,
Syncer,
)
from script.todo.mail.store import MessageMeta, Store
from script.todo.mail.tui import Session
class FakeImapTransport:
"""Assez de protocole IMAP en mémoire pour faire tourner le VRAI
`Syncer` (copie allégée de celle de `test_mail_sync.py`) : la garantie
que ces tests prouvent qu'une synchronisation RÉELLE rafraîchit l'écran,
pas une simulation qui écrirait directement dans le store."""
def __init__(self):
self.folders = {}
self.selected = None
self.appended = []
def add(self, folder, uid, subject="Sujet", date=None, flags=""):
f = self.folders.setdefault(folder, {"uidvalidity": 1, "messages": {}})
f["messages"][uid] = HeaderInfo(
uid=uid,
date=date if date is not None else 1_700_000_000 + uid,
size=100,
flags=flags,
msgid=f"<{uid}@x.ca>",
frm="eux@x.ca",
to="moi@x.ca",
subject=subject,
)
def list_folders(self):
return [FolderInfo(name=n) for n in sorted(self.folders)]
def select(self, folder):
self.selected = folder
f = self.folders.setdefault(folder, {"uidvalidity": 1, "messages": {}})
uids = list(f["messages"])
return SelectInfo(
uidvalidity=f["uidvalidity"],
uidnext=(max(uids) + 1) if uids else 1,
exists=len(uids),
)
def search_uids(self, since_uid):
f = self.folders[self.selected]
return sorted(u for u in f["messages"] if u >= since_uid)
def fetch_headers(self, uids):
f = self.folders[self.selected]
return [f["messages"][u] for u in uids if u in f["messages"]]
def fetch_flags(self, uids):
f = self.folders[self.selected]
return [
(u, f["messages"][u].flags) for u in uids if u in f["messages"]
]
def append(self, folder, raw, flags):
self.appended.append((folder, raw, flags))
def logout(self):
pass
class RefreshCase(unittest.IsolatedAsyncioTestCase):
"""Monte `MailApp` pour de vrai, `$HOME` détourné — comme
`test_mail_tui_log.py` : `on_mount` lit `todo_prefs`, qui crée
`~/.erplibre` s'il est absent."""
def setUp(self):
self.fake_home = tempfile.TemporaryDirectory()
self._old_home = os.environ.get("HOME")
os.environ["HOME"] = self.fake_home.name
self.cache_dir = tempfile.TemporaryDirectory()
self.account = account_from_preset("perso", "moi@x.ca", "generic")
self.account.cache_mode = "clear"
self.store = Store(
self.account, mode="clear", base=Path(self.cache_dir.name)
)
self.store.open()
self.imap = FakeImapTransport()
self.syncer = Syncer(self.store, self.imap)
self.session = Session(
self.account, self.store, self.syncer, password="hunter2"
)
def tearDown(self):
self.store.close()
if self._old_home is None:
os.environ.pop("HOME", None)
else:
os.environ["HOME"] = self._old_home
self.fake_home.cleanup()
self.cache_dir.cleanup()
async def _mounted_app(self, sessions=None):
import textual.app
from script.todo.mail.tui import run_tui
sessions = sessions if sessions is not None else [self.session]
captured = []
orig_init = textual.app.App.__init__
def capturing_init(app_self, *a, **kw):
orig_init(app_self, *a, **kw)
captured.append(app_self)
textual.app.App.__init__ = capturing_init
try:
run_tui(run_app=False, sessions=sessions)
finally:
textual.app.App.__init__ = orig_init
return captured[-1]
class TestSyncRefreshesTheOpenFolder(RefreshCase):
async def test_a_message_that_arrives_during_sync_appears_without_restart(
self,
):
from textual.widgets import DataTable
self.imap.add("INBOX", 1, subject="Ancien")
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
table = app.query_one("#list", DataTable)
self.assertEqual(table.row_count, 1)
# Le dossier est déjà ouvert — l'état exact où
# `reload_folders()` ratait le rafraîchissement de la liste.
self.assertIsNotNone(app.current_ref)
self.imap.add("INBOX", 2, subject="Nouveau", date=1_800_000_000)
app._sync([self.session])
await pilot.pause()
table = app.query_one("#list", DataTable)
self.assertEqual(table.row_count, 2)
subjects = [table.get_row_at(i)[2] for i in range(table.row_count)]
self.assertIn("Nouveau", subjects)
class TestSyncPreservesCursor(RefreshCase):
async def test_cursor_follows_the_same_message_across_a_refresh(self):
from textual.widgets import DataTable
self.imap.add("INBOX", 1, subject="Un", date=1_000)
self.imap.add("INBOX", 2, subject="Deux", date=2_000)
self.imap.add("INBOX", 3, subject="Trois", date=3_000)
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
table = app.query_one("#list", DataTable)
self.assertEqual(table.row_count, 3)
# Trié par date décroissante : Trois, Deux, Un.
table.move_cursor(row=1)
await pilot.pause()
highlighted = app.current_meta()
self.assertEqual(highlighted.subject, "Deux")
# Un message plus récent arrive : il se glisse EN TÊTE de
# liste — sans un suivi par UID, l'index 1 resterait
# sélectionné mais pointerait sur un autre message.
self.imap.add("INBOX", 4, subject="Quatre", date=4_000)
app._sync([self.session])
await pilot.pause()
self.assertEqual(table.row_count, 4)
still_highlighted = app.current_meta()
self.assertEqual(still_highlighted.subject, "Deux")
async def test_cursor_falls_back_sensibly_when_the_message_is_gone(self):
from textual.widgets import DataTable
self.imap.add("INBOX", 1, subject="Un", date=1_000)
self.imap.add("INBOX", 2, subject="Deux", date=2_000)
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
table = app.query_one("#list", DataTable)
table.move_cursor(row=0)
await pilot.pause()
self.assertEqual(app.current_meta().subject, "Deux")
# UIDVALIDITY change côté serveur (boîte recréée/renumérotée) :
# le VRAI `Syncer` vide alors le dossier (`purge_folder`) avant
# de le repeupler — le seul mécanisme réel par lequel un
# message connu peut disparaître du cache. « Deux » ne revient
# pas ; « Trois » prend sa place.
self.imap.folders["INBOX"]["uidvalidity"] = 2
del self.imap.folders["INBOX"]["messages"][2]
self.imap.add("INBOX", 3, subject="Trois", date=3_000)
app._sync([self.session])
await pilot.pause()
# Ne doit pas lever, et doit retomber sur un état affichable —
# celui que `DataTable.clear()` laisse déjà : en tête de liste.
# Vérifié par le CONTENU, pas seulement le compte de lignes :
# sans rafraîchissement du tout, la liste resterait Un + Deux
# (même compte de lignes, même index 0) — seul le contenu
# trahit une liste réellement rechargée.
table = app.query_one("#list", DataTable)
self.assertEqual(table.row_count, 2) # Un + Trois
self.assertEqual(table.cursor_row, 0)
self.assertEqual(table.get_row_at(0)[2], "Trois")
class TestSyncPreservesSearchFilter(RefreshCase):
async def test_filtered_out_messages_stay_hidden_after_a_refresh(self):
from textual.widgets import DataTable
self.imap.add("INBOX", 1, subject="Alpha")
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
app.query = "Alpha"
app.refresh_list()
table = app.query_one("#list", DataTable)
self.assertEqual(table.row_count, 1)
self.imap.add("INBOX", 2, subject="Beta")
app._sync([self.session])
await pilot.pause()
# Le cache SOUS le filtre doit avoir bougé — sinon ce test ne
# prouverait rien sur le rafraîchissement lui-même, seulement
# que « Beta » reste caché, vrai aussi bien quand rien ne se
# rafraîchit du tout.
self.assertEqual(len(app.metas), 2)
table = app.query_one("#list", DataTable)
self.assertEqual(table.row_count, 1)
self.assertEqual(table.get_row_at(0)[2], "Alpha")
class TestRefreshWithNoFolderSelected(RefreshCase):
async def test_does_not_raise_when_nothing_is_selected(self):
app = await self._mounted_app(sessions=[])
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
self.assertIsNone(app.current_ref)
app.refresh_current_folder() # ne doit pas lever
class TestSendRefreshesTheOpenFolder(RefreshCase):
"""`deliver()` classe une copie dans Envoyés via `sync_one` après un
envoi réussi (tâche 19) — sans le correctif de cette tâche, l'écran
reste périmé si Envoyés est déjà ouvert au moment d'envoyer, exactement
comme pour `_sync`."""
class _FakeSentSyncOne:
"""`sync_one` réel écrit dans le store après l'APPEND — cette
version fait la même écriture, sans re-simuler tout un serveur
IMAP : seul le point sous test compte ici, le rafraîchissement de
l'écran une fois le store à jour."""
def __init__(self, store, transport):
self.store = store
self.transport = transport
self.sync_one_calls = []
def sync(self, progress=None):
from types import SimpleNamespace
return SimpleNamespace(new_messages=0, errors=[], purged=[])
def sync_one(self, folder_name):
from types import SimpleNamespace
self.sync_one_calls.append(folder_name)
fid = self.store.upsert_folder(folder_name, folder_name, "sent")
self.store.upsert_messages(
fid,
[
MessageMeta(
uid=1,
date=1_700_000_000,
size=10,
flags="\\Seen",
msgid="<sent@x.ca>",
frm="moi@x.ca",
to="dest@example.com",
subject="Sujet",
snippet="",
)
],
)
return SimpleNamespace(new_messages=1, errors=[], folders=1)
def fetch_body(self, folder, uid):
return None
class _FakeSMTPTransport:
def quit(self):
pass
async def test_the_sent_folder_refreshes_after_sending(self):
from textual.screen import ModalScreen
from textual.widgets import DataTable, Input, TextArea
import script.todo.mail.smtp_send as smtp_send_mod
# Envoyés déjà connu du cache, comme après une synchronisation
# antérieure — le scénario du rapport : l'utilisateur regarde déjà
# ce dossier au moment d'envoyer.
self.store.upsert_folder(
self.account.sent_folder, self.account.sent_folder, "sent"
)
fake_syncer = self._FakeSentSyncOne(self.store, self.imap)
session = Session(
self.account, self.store, fake_syncer, password="hunter2"
)
app = await self._mounted_app(sessions=[session])
orig_connect, orig_send = smtp_send_mod.connect, smtp_send_mod.send
smtp_send_mod.connect = (
lambda account, password: self._FakeSMTPTransport()
)
smtp_send_mod.send = lambda account, msg, transport: [
"dest@example.com"
]
try:
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
self.assertEqual(
app.current_ref.folder_name, self.account.sent_folder
)
table = app.query_one("#list", DataTable)
self.assertEqual(table.row_count, 0)
await pilot.press("c")
await pilot.pause()
app.screen.query_one("#to", Input).value = "dest@example.com"
app.screen.query_one("#subject", Input).value = "Sujet"
app.screen.query_one("#body", TextArea).text = "Corps"
await pilot.press("ctrl+s")
await pilot.pause()
self.assertNotIsInstance(app.screen, ModalScreen)
self.assertEqual(
fake_syncer.sync_one_calls, [self.account.sent_folder]
)
table = app.query_one("#list", DataTable)
self.assertEqual(table.row_count, 1)
finally:
smtp_send_mod.connect = orig_connect
smtp_send_mod.send = orig_send
if __name__ == "__main__":
unittest.main()

1048
test/test_mail_tui_resize.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,566 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Barres de partage glissables à la souris (tâche 25) : un bouton par
frontière ajustable (`#folders_splitter`, `#list_splitter`), qui redimensionne
en direct pendant le glissement et persiste au relâchement — par le MÊME
`_store_pane_size` que `+`/`-`/`0` au clavier (tâche 24), jamais un second
magasin.
Comme `test_mail_tui_resize.py` : tout ce qui compte ici n'a de sens que sur
l'application montée pour de vrai — les RÉGIONS mesurées avant/après un
glissement, jamais un attribut interne de `MailApp`. `Pilot.mouse_down`/
`hover`/`mouse_up` composent le glissement ; `hover`/`mouse_up` visent une
coordonnée ÉCRAN absolue (`widget=None`), pas la barre elle-même, parce que
la barre se déplace pendant le glissement (son voisin redimensionné la
pousse) — cibler à nouveau la barre par sélecteur dériverait.
"""
import os
import tempfile
import unittest
from pathlib import Path
from script.todo.mail.accounts import account_from_preset
from script.todo.mail.store import Store
from script.todo.mail.tui import PANE_SIZE_MIN, Session
class SplitterCase(unittest.IsolatedAsyncioTestCase):
"""Monte `MailApp` pour de vrai, `$HOME` détourné — même motif que
`test_mail_tui_resize.py`.
"""
def setUp(self):
self.fake_home = tempfile.TemporaryDirectory()
self._old_home = os.environ.get("HOME")
os.environ["HOME"] = self.fake_home.name
self.cache_dir = tempfile.TemporaryDirectory()
self.account = account_from_preset("perso", "moi@x.ca", "generic")
self.account.cache_mode = "clear"
self.store = Store(
self.account, mode="clear", base=Path(self.cache_dir.name)
)
self.store.open()
self.session = Session(self.account, self.store, None, password="x")
def tearDown(self):
self.store.close()
if self._old_home is None:
os.environ.pop("HOME", None)
else:
os.environ["HOME"] = self._old_home
self.fake_home.cleanup()
self.cache_dir.cleanup()
async def _mounted_app(self):
import textual.app
from script.todo.mail.tui import run_tui
captured = []
orig_init = textual.app.App.__init__
def capturing_init(app_self, *a, **kw):
orig_init(app_self, *a, **kw)
captured.append(app_self)
textual.app.App.__init__ = capturing_init
try:
run_tui(run_app=False, sessions=[self.session])
finally:
textual.app.App.__init__ = orig_init
return captured[-1]
async def _press_down(self, pilot, splitter_id: str):
"""`MouseDown` sur la barre `splitter_id`, à sa propre position
(offset (0, 0) relatif à la barre) — rend son point de départ, en
coordonnées ÉCRAN, pour que les étapes suivantes du glissement
(`_move_to`/`_release_at`) ciblent une coordonnée ABSOLUE plutôt que
la barre elle-même, qui se déplace pendant le glissement.
"""
splitter = pilot.app.query_one(f"#{splitter_id}")
start = splitter.region.offset
await pilot.mouse_down(f"#{splitter_id}", offset=(0, 0))
await pilot.pause()
return start
async def _move_to(self, pilot, offset):
await pilot.hover(offset=offset)
await pilot.pause()
async def _release_at(self, pilot, offset):
await pilot.mouse_up(offset=offset)
await pilot.pause()
async def _drag(self, pilot, splitter_id: str, delta_x=0, delta_y=0):
"""Glissement complet (presser, déplacer, relâcher) de `delta_x`/
`delta_y` cellules ÉCRAN, à partir de la position actuelle de la
barre.
"""
start = await self._press_down(pilot, splitter_id)
target = (start.x + delta_x, start.y + delta_y)
await self._move_to(pilot, target)
await self._release_at(pilot, target)
class TestSplitterWidgetsPresent(SplitterCase):
async def test_both_splitters_exist_and_are_not_focusable(self):
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
folders_splitter = app.query_one("#folders_splitter")
list_splitter = app.query_one("#list_splitter")
self.assertFalse(folders_splitter.can_focus)
self.assertFalse(list_splitter.can_focus)
# Ni l'une ni l'autre ne doit jamais recevoir le focus par
# défaut (`Screen.AUTO_FOCUS`) à la place de `#folders`.
from textual.widgets import Tree
self.assertIsInstance(app.focused, Tree)
class TestDragResizesColumns(SplitterCase):
"""Disposition par défaut (`columns`) : les deux barres sont
verticales — glisser HORIZONTALEMENT redimensionne.
"""
async def test_dragging_folders_splitter_widens_folders_and_narrows_right(
self,
):
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
folders = app.query_one("#folders")
right = app.query_one("#right")
width_before = folders.region.width
right_before = right.region.width
await self._drag(pilot, "folders_splitter", delta_x=6)
self.assertEqual(folders.region.width, width_before + 6)
self.assertEqual(right.region.width, right_before - 6)
async def test_dragging_list_splitter_widens_list_and_narrows_preview(
self,
):
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
list_pane = app.query_one("#list_pane")
preview = app.query_one("#preview")
width_before = list_pane.region.width
preview_before = preview.region.width
await self._drag(pilot, "list_splitter", delta_x=5)
self.assertEqual(list_pane.region.width, width_before + 5)
self.assertEqual(preview.region.width, preview_before - 5)
async def test_dragging_left_narrows_folders_and_widens_right(self):
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
folders = app.query_one("#folders")
right = app.query_one("#right")
width_before = folders.region.width
right_before = right.region.width
await self._drag(pilot, "folders_splitter", delta_x=-6)
self.assertEqual(folders.region.width, width_before - 6)
self.assertEqual(right.region.width, right_before + 6)
class TestDragIsLive(SplitterCase):
async def test_the_pane_resizes_before_release_not_only_after(self):
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
folders = app.query_one("#folders")
width_before = folders.region.width
start = await self._press_down(pilot, "folders_splitter")
target = (start.x + 6, start.y)
await self._move_to(pilot, target)
# Toujours en cours de glissement : le volet a DÉJÀ bougé.
self.assertEqual(folders.region.width, width_before + 6)
await self._release_at(pilot, target)
self.assertEqual(folders.region.width, width_before + 6)
class TestDragPersistenceAndSharedStore(SplitterCase):
async def test_nothing_is_written_to_disk_before_release(self):
from script.todo import todo_prefs
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
start = await self._press_down(pilot, "folders_splitter")
target = (start.x + 6, start.y)
await self._move_to(pilot, target)
# `_store_pane_size` fait un aller-retour disque à chaque appel
# -- il ne doit tourner qu'À LA LEVÉE, jamais pendant le
# glissement lui-même.
self.assertEqual(todo_prefs.get("mail_pane_sizes"), {})
await self._release_at(pilot, target)
self.assertIn("columns", todo_prefs.get("mail_pane_sizes"))
async def test_the_released_size_is_stored_under_the_same_key_only(self):
from script.todo import todo_prefs
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
folders = app.query_one("#folders")
await self._drag(pilot, "folders_splitter", delta_x=6)
width_after = folders.region.width
sizes = todo_prefs.get("mail_pane_sizes")
# AUCUNE autre clé de premier niveau : un seul magasin, celui
# que la tâche 24 a créé -- jamais un second, parallèle.
self.assertEqual(set(sizes.keys()), {"columns"})
self.assertEqual(set(sizes["columns"].keys()), {"folders"})
self.assertEqual(sizes["columns"]["folders"], width_after)
async def test_the_keyboard_sees_the_size_the_mouse_just_set(self):
from textual.widgets import DataTable
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
list_pane = app.query_one("#list_pane")
await self._drag(pilot, "list_splitter", delta_x=5)
width_after_drag = list_pane.region.width
app.query_one("#list", DataTable).focus()
await pilot.pause()
await pilot.press("+")
await pilot.pause()
# Le clavier reprend EXACTEMENT où la souris a laissé la
# taille -- la preuve qu'il n'y a qu'un seul magasin.
from script.todo.mail.tui import PANE_SIZE_STEP
self.assertEqual(
list_pane.region.width, width_after_drag + PANE_SIZE_STEP
)
class TestDragStopsAtTheMinimum(SplitterCase):
async def test_dragging_far_left_stops_folders_at_the_minimum(self):
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
folders = app.query_one("#folders")
await self._press_down(pilot, "folders_splitter")
# Bord gauche de l'écran : un delta négatif bien au-delà de ce
# qu'aucun terminal ne pourrait fournir, mais une coordonnée
# ÉCRAN toujours VALIDE (donc jamais `OutOfBounds`).
await self._move_to(pilot, (0, 0))
await self._release_at(pilot, (0, 0))
self.assertEqual(folders.region.width, PANE_SIZE_MIN)
async def test_dragging_far_right_leaves_rights_own_children_above_the_minimum(
self,
):
"""`#right` n'est pas une feuille : il héberge à son tour
`list_pane`/`list_splitter`/`preview` (voir `_PANE_SIBLING_MIN`,
tâche 24). Pousser `#folders` jusqu'à son plafond ne doit donc PAS
écraser `#right` à `PANE_SIZE_MIN` -- ce plancher est celui de
`list_pane`/`preview` eux-mêmes, chacun encore mesuré ICI plutôt que
supposé, exactement l'invariant que la tâche 24 a fini par tester
après avoir été mordue une première fois par un nombre figé plutôt
que par l'invariant réel (voir son rapport, « round 2 »).
"""
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
right = app.query_one("#right")
list_pane = app.query_one("#list_pane")
list_splitter = app.query_one("#list_splitter")
preview = app.query_one("#preview")
screen_width = app.screen.size.width
await self._press_down(pilot, "folders_splitter")
edge = (screen_width - 1, 0)
await self._move_to(pilot, edge)
await self._release_at(pilot, edge)
self.assertGreaterEqual(list_pane.region.width, PANE_SIZE_MIN)
self.assertGreaterEqual(preview.region.width, PANE_SIZE_MIN)
self.assertEqual(
right.region.width,
list_pane.region.width
+ list_splitter.region.width
+ preview.region.width,
)
async def test_dragging_far_left_stops_list_pane_at_the_minimum(self):
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
list_pane = app.query_one("#list_pane")
await self._press_down(pilot, "list_splitter")
await self._move_to(pilot, (0, 0))
await self._release_at(pilot, (0, 0))
self.assertEqual(list_pane.region.width, PANE_SIZE_MIN)
async def test_dragging_far_right_stops_preview_at_the_minimum(self):
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
preview = app.query_one("#preview")
screen_width = app.screen.size.width
await self._press_down(pilot, "list_splitter")
edge = (screen_width - 1, 0)
await self._move_to(pilot, edge)
await self._release_at(pilot, edge)
self.assertEqual(preview.region.width, PANE_SIZE_MIN)
class TestDragOrientationFollowsLayout(SplitterCase):
"""Les deux barres suivent l'orientation RÉELLE du conteneur qu'elles
jouxtent (`MailApp._pane_dimension`), jamais une table par disposition.
"""
async def test_dragging_in_stacked_resizes_by_height_not_width(self):
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
await pilot.press("v") # split
await pilot.pause()
await pilot.press("v") # stacked
await pilot.pause()
self.assertTrue(
app.query_one("#panes").has_class("layout-stacked")
)
folders = app.query_one("#folders")
right = app.query_one("#right")
width_before = folders.region.width
height_before = folders.region.height
right_height_before = right.region.height
# `delta_y=1`, pas davantage : en `stacked`, `#panes` ne fait que
# 21 lignes de haut (écran 80x24, moins l'en-tête/le pied) --
# `#right` doit en garder au moins 9 (`list_pane` + la barre +
# `preview`, chacun `>= PANE_SIZE_MIN`), donc `folders` ne peut
# grandir que de 1 avant de buter sur ce plafond ; un delta plus
# grand serait borné et ce test mesurerait le bornage, pas le
# suivi d'axe qu'il vérifie ici (voir `TestDragStopsAtTheMinimum`
# pour le bornage lui-même).
await self._drag(pilot, "folders_splitter", delta_y=1)
self.assertEqual(folders.region.height, height_before + 1)
self.assertEqual(right.region.height, right_height_before - 1)
# La largeur, elle, ne bouge pas -- ce n'est plus l'axe partagé.
self.assertEqual(folders.region.width, width_before)
async def test_dragging_in_split_list_splitter_resizes_by_height(self):
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
await pilot.press("v") # split
await pilot.pause()
self.assertTrue(app.query_one("#panes").has_class("layout-split"))
list_pane = app.query_one("#list_pane")
preview = app.query_one("#preview")
height_before = list_pane.region.height
preview_height_before = preview.region.height
await self._drag(pilot, "list_splitter", delta_y=3)
self.assertEqual(list_pane.region.height, height_before + 3)
self.assertEqual(preview.region.height, preview_height_before - 3)
class TestInterruptedDragDoesNotStick(SplitterCase):
async def test_release_captures_only_via_the_bar_not_the_release_point(
self,
):
"""Le relâchement arrive loin de la barre (une seule cellule de
large) -- la capture de souris doit tout de même router le
`MouseUp` vers elle (voir `Screen._forward_event`), et la libérer :
rien de coincé après.
"""
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
await self._drag(pilot, "folders_splitter", delta_x=6)
self.assertIsNone(app.mouse_captured)
# L'app reste utilisable : un second glissement, ailleurs,
# fonctionne normalement -- la preuve qu'aucun état ne traîne.
list_pane = app.query_one("#list_pane")
width_before = list_pane.region.width
await self._drag(pilot, "list_splitter", delta_x=3)
self.assertEqual(list_pane.region.width, width_before + 3)
async def test_app_blur_mid_drag_ends_it_and_persists_the_last_value(
self,
):
from textual import events
from script.todo import todo_prefs
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
start = await self._press_down(pilot, "folders_splitter")
target = (start.x + 6, start.y)
await self._move_to(pilot, target)
self.assertIsNotNone(app.mouse_captured)
app.post_message(events.AppBlur())
await pilot.pause()
self.assertIsNone(app.mouse_captured)
self.assertIn(
"folders", todo_prefs.get("mail_pane_sizes").get("columns", {})
)
async def test_fullscreen_mid_drag_ends_it_without_getting_stuck(self):
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
start = await self._press_down(pilot, "folders_splitter")
target = (start.x + 6, start.y)
await self._move_to(pilot, target)
self.assertIsNotNone(app.mouse_captured)
app.action_toggle_fullscreen()
await pilot.pause()
self.assertIsNone(app.mouse_captured)
self.assertTrue(app.query_one("#panes").has_class("fullscreen"))
class TestSplittersHiddenInFullscreen(SplitterCase):
async def test_both_splitters_vanish_in_fullscreen_in_every_layout(self):
from script.todo.mail.tui import MAIL_LAYOUTS
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
panes = app.query_one("#panes")
folders_splitter = app.query_one("#folders_splitter")
list_splitter = app.query_one("#list_splitter")
for layout_id, _ in MAIL_LAYOUTS:
while app.mail_layout != layout_id:
await pilot.press("v")
await pilot.pause()
panes.add_class("fullscreen")
await pilot.pause()
self.assertEqual(folders_splitter.region.width, 0)
self.assertEqual(folders_splitter.region.height, 0)
self.assertEqual(list_splitter.region.width, 0)
self.assertEqual(list_splitter.region.height, 0)
panes.remove_class("fullscreen")
await pilot.pause()
class TestModalPushEndsAnyPendingDrag(SplitterCase):
"""`App.push_screen` (`app.py:2937`) revokes mouse capture out from
under a drag that hasn't been released yet -- `App.capture_mouse`
(`app.py:3222`) posts `MouseRelease` to whatever WAS captured whenever
capture changes, including to `None`. `_PaneSplitter` must react to
that (`on_mouse_release`), or `MailApp`'s drag state stays pointed at
the ABANDONED slot: since capture is now cleared, the very first
(synthetic, pre-`MouseDown`) `MouseMove` of the NEXT, entirely
unrelated drag is routed by ordinary hit-testing to whatever's under
the pointer, and gets misapplied to the STALE slot before that new
drag's own `MouseDown` has a chance to reset the state.
"""
async def test_pushing_a_modal_mid_drag_does_not_corrupt_the_next_drag(
self,
):
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
folders = app.query_one("#folders")
list_pane = app.query_one("#list_pane")
preview = app.query_one("#preview")
# Glissement de `folders` JAMAIS relâché : le bouton de la
# souris est toujours, conceptuellement, enfoncé au moment où
# le modal ci-dessous est poussé.
start = await self._press_down(pilot, "folders_splitter")
target = (start.x + 5, start.y)
await self._move_to(pilot, target)
width_mid_drag = folders.region.width
await pilot.press("l") # LogScreen (push_screen)
await pilot.pause()
await pilot.press("escape") # ferme LogScreen (dismiss)
await pilot.pause()
list_width_before = list_pane.region.width
preview_width_before = preview.region.width
# Glissement SUIVANT, SANS RAPPORT, sur l'AUTRE barre.
await self._drag(pilot, "list_splitter", delta_x=3)
# `folders` n'a plus bougé depuis le modal -- rien de périmé ne
# devait plus le toucher.
self.assertEqual(folders.region.width, width_mid_drag)
# Le glissement de `list_splitter` a atterri exactement là où
# il atterrirait sans aucun modal impliqué.
self.assertEqual(list_pane.region.width, list_width_before + 3)
self.assertEqual(preview.region.width, preview_width_before - 3)
if __name__ == "__main__":
unittest.main()

298
test/test_mail_tui_text.py Normal file
View file

@ -0,0 +1,298 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
import datetime
import unittest
from script.todo.mail.store import MessageMeta
from script.todo.mail.tui_text import (
extract_body,
filter_messages,
format_date,
format_date_full,
format_size,
html_to_text,
is_unread,
short_addr,
truncate,
)
# 2026-08-01 10:41:00 UTC
NOW = 1785580860
def meta(
uid=1, subject="Devis", frm="Alice <alice@y.ca>", snippet="", flags=""
):
return MessageMeta(
uid=uid,
date=NOW,
size=100,
flags=flags,
msgid=f"<{uid}@x.ca>",
frm=frm,
to="moi@x.ca",
subject=subject,
snippet=snippet,
)
class TestHtmlToText(unittest.TestCase):
def test_strips_tags(self):
self.assertEqual(html_to_text("<p>Bonjour</p>"), "Bonjour")
def test_decodes_entities(self):
self.assertEqual(
html_to_text("<p>caf&eacute; &amp; th&eacute;</p>"), "café & thé"
)
def test_drops_script_and_style(self):
out = html_to_text(
"<style>p{color:red}</style><script>alert(1)</script><p>Salut</p>"
)
self.assertEqual(out, "Salut")
def test_br_becomes_newline(self):
self.assertEqual(html_to_text("a<br>b"), "a\nb")
def test_block_tags_separate_lines(self):
self.assertIn("\n", html_to_text("<div>a</div><div>b</div>"))
def test_collapses_blank_runs(self):
self.assertNotIn("\n\n\n", html_to_text("<p>a</p>\n\n\n\n\n<p>b</p>"))
def test_empty_input(self):
self.assertEqual(html_to_text(""), "")
class TestExtractBody(unittest.TestCase):
def test_plain_text(self):
text, atts = extract_body(b"Subject: S\r\n\r\nBonjour Alice")
self.assertEqual(text.strip(), "Bonjour Alice")
self.assertEqual(atts, [])
def test_prefers_plain_over_html(self):
raw = (
b'Content-Type: multipart/alternative; boundary="B"\r\n\r\n'
b"--B\r\nContent-Type: text/plain\r\n\r\nversion texte\r\n"
b"--B\r\nContent-Type: text/html\r\n\r\n<p>version html</p>\r\n"
b"--B--\r\n"
)
text, _ = extract_body(raw)
self.assertIn("version texte", text)
self.assertNotIn("html", text)
def test_falls_back_to_html(self):
raw = b"Content-Type: text/html\r\n\r\n<p>Bonjour <b>Alice</b></p>"
text, _ = extract_body(raw)
self.assertEqual(text.strip(), "Bonjour Alice")
def test_lists_attachments(self):
raw = (
b'Content-Type: multipart/mixed; boundary="B"\r\n\r\n'
b"--B\r\nContent-Type: text/plain\r\n\r\ncorps\r\n"
b"--B\r\nContent-Type: application/pdf\r\n"
b'Content-Disposition: attachment; filename="devis.pdf"\r\n\r\n'
b"%PDF\r\n--B--\r\n"
)
_, atts = extract_body(raw)
self.assertEqual([a.filename for a in atts], ["devis.pdf"])
self.assertEqual(atts[0].content_type, "application/pdf")
def test_attachment_without_filename_gets_one(self):
raw = (
b'Content-Type: multipart/mixed; boundary="B"\r\n\r\n'
b"--B\r\nContent-Type: text/plain\r\n\r\ncorps\r\n"
b"--B\r\nContent-Type: application/pdf\r\n"
b"Content-Disposition: attachment\r\n\r\n%PDF\r\n--B--\r\n"
)
_, atts = extract_body(raw)
self.assertTrue(atts[0].filename)
def test_broken_message_does_not_raise(self):
text, atts = extract_body(b"\x00\x01\x02 pas un courriel")
self.assertIsInstance(text, str)
self.assertIsInstance(atts, list)
def test_decodes_charset(self):
raw = (
b"Content-Type: text/plain; charset=iso-8859-1\r\n"
b"Content-Transfer-Encoding: 8bit\r\n\r\nCaf\xe9"
)
text, _ = extract_body(raw)
self.assertIn("Café", text)
def test_survives_unknown_8bit(self):
"""Étiquette réelle observée en usage, pas seulement un charset
inventé (voir `script/todo/mail/charset.py`)."""
raw = (
b"Content-Type: text/plain; charset=unknown-8bit\r\n\r\n"
b"Bonjour"
)
text, _ = extract_body(raw)
self.assertIn("Bonjour", text)
class TestShortAddr(unittest.TestCase):
def test_display_name_wins(self):
self.assertEqual(
short_addr("Alice Tremblay <a@y.ca>"), "Alice Tremblay"
)
def test_bare_address(self):
self.assertEqual(short_addr("a@y.ca"), "a@y.ca")
def test_quoted_display_name(self):
self.assertEqual(
short_addr('"Tremblay, Alice" <a@y.ca>'), "Tremblay, Alice"
)
def test_empty(self):
self.assertEqual(short_addr(""), "")
def test_first_of_several(self):
self.assertEqual(short_addr("a@y.ca, b@y.ca"), "a@y.ca")
class TestTruncate(unittest.TestCase):
def test_short_text_untouched(self):
self.assertEqual(truncate("abc", 10), "abc")
def test_long_text_gets_ellipsis(self):
self.assertEqual(truncate("abcdefghij", 5), "abcd…")
def test_result_never_exceeds_width(self):
self.assertEqual(len(truncate("abcdefghij", 5)), 5)
def test_width_of_one(self):
self.assertEqual(truncate("abcdef", 1), "…")
def test_zero_width(self):
self.assertEqual(truncate("abc", 0), "")
class TestFormatDate(unittest.TestCase):
def test_today_shows_time(self):
self.assertRegex(format_date(NOW, NOW), r"^\d{2}:\d{2}$")
def test_this_year_shows_day_and_month(self):
self.assertRegex(format_date(NOW - 90 * 86400, NOW), r"^\d{2}-\d{2}$")
def test_older_shows_the_year(self):
self.assertRegex(
format_date(NOW - 800 * 86400, NOW), r"^\d{4}-\d{2}-\d{2}$"
)
def test_zero_is_blank(self):
self.assertEqual(format_date(0, NOW), "")
def test_absurd_epoch_is_blank_and_does_not_raise(self):
"""Le contrat du module : une date d'en-tête aberrante ne lève jamais."""
for hostile in (10**18, 2**63, -(10**18)):
self.assertEqual(format_date(hostile, NOW), "")
class TestFormatDateFull(unittest.TestCase):
"""`format_date` reste volontairement compact pour la liste ; l'aperçu
d'un message a besoin de la date COMPLÈTE, sans ambiguïté à elle seule.
Ne réutilise pas `format_date` : sa compacité est une propriété
voulue de la colonne, pas un raccourci disponible ailleurs.
"""
def test_known_epoch_renders_in_full(self):
# Calculé de la même façon que l'implémentation (heure locale) :
# un `assertEqual` sur une chaîne littérale dépendrait du fuseau
# horaire de la machine qui exécute le test.
expected = datetime.datetime.fromtimestamp(NOW).strftime(
"%Y-%m-%d %H:%M"
)
self.assertEqual(format_date_full(NOW), expected)
def test_zero_is_blank(self):
self.assertEqual(format_date_full(0), "")
def test_absurd_epoch_is_blank_and_does_not_raise(self):
"""Mêmes trois valeurs que `test_absurd_epoch_is_blank_and_does_not_raise`
de `TestFormatDate` : ce sont elles qui ont fait lever `format_date`
avant l'ajout de sa garde — `format_date_full` doit tenir la même
promesse."""
for hostile in (10**18, 2**63, -(10**18)):
self.assertEqual(format_date_full(hostile), "")
class TestFormatSize(unittest.TestCase):
def test_bytes(self):
self.assertEqual(format_size(512), "512 o")
def test_kilobytes(self):
self.assertEqual(format_size(2048), "2.0 ko")
def test_megabytes(self):
self.assertEqual(format_size(5 * 1024 * 1024), "5.0 Mo")
def test_zero(self):
self.assertEqual(format_size(0), "0 o")
class TestIsUnread(unittest.TestCase):
def test_no_flags_is_unread(self):
self.assertTrue(is_unread(""))
def test_seen_is_read(self):
self.assertFalse(is_unread("\\Seen"))
def test_seen_among_others(self):
self.assertFalse(is_unread("\\Answered \\Seen"))
def test_none_is_unread(self):
self.assertTrue(is_unread(None))
class TestFilterMessages(unittest.TestCase):
def setUp(self):
self.metas = [
meta(1, subject="Devis révisé", frm="Alice <a@y.ca>"),
meta(
2,
subject="CR réunion",
frm="Bob <b@y.ca>",
snippet="ordre du jour",
),
]
def test_empty_query_returns_all(self):
self.assertEqual(len(filter_messages(self.metas, "")), 2)
def test_matches_subject(self):
self.assertEqual(
[m.uid for m in filter_messages(self.metas, "devis")], [1]
)
def test_is_case_insensitive(self):
self.assertEqual(
[m.uid for m in filter_messages(self.metas, "DEVIS")], [1]
)
def test_matches_sender(self):
self.assertEqual(
[m.uid for m in filter_messages(self.metas, "bob")], [2]
)
def test_matches_snippet(self):
self.assertEqual(
[m.uid for m in filter_messages(self.metas, "ordre")], [2]
)
def test_accent_insensitive(self):
self.assertEqual(
[m.uid for m in filter_messages(self.metas, "revise")], [1]
)
def test_no_match(self):
self.assertEqual(filter_messages(self.metas, "zzz"), [])
if __name__ == "__main__":
unittest.main()

View file

@ -4,9 +4,11 @@
import json
import os
import subprocess
import tempfile
import unittest
from unittest.mock import MagicMock, patch
from pathlib import Path
from unittest.mock import MagicMock, mock_open, patch
from script.todo.todo import (
ANDROID_DIR,
@ -190,6 +192,42 @@ class TestExecuteFromConfiguration(unittest.TestCase):
todo.execute_from_configuration(dct)
todo.execute.exec_command_live.assert_called()
def test_every_command_entry_of_the_real_config_is_reachable(self):
"""Le dict synthétique du test précédent ne suffisait pas.
`4fc15c3` a renommé la clé cherchée par le code en « Command: »,
le libellé affiché. Plus aucune entrée de todo.json ne
correspondait, et « Open ERPLibre with TODO 🤖 » ne faisait plus
rien — sans erreur, le `if` étant simplement faux. Seule la VRAIE
configuration relie les deux côtés.
"""
with open(CONFIG_FILE) as fh:
config = json.load(fh)
entrees = []
def parcourir(noeud):
if isinstance(noeud, dict):
if "command" in noeud:
entrees.append(noeud)
for valeur in noeud.values():
parcourir(valeur)
elif isinstance(noeud, list):
for element in noeud:
parcourir(element)
parcourir(config)
self.assertTrue(entrees, "todo.json n'a plus d'entrée `command`")
for entree in entrees:
todo = TODO()
todo.execute = MagicMock()
todo.execute_from_configuration(entree)
self.assertTrue(
todo.execute.exec_command_live.called,
f"entrée ignorée en silence : {entree.get('command')}",
)
def test_with_makefile_cmd(self):
todo = TODO()
todo.execute = MagicMock()
@ -297,6 +335,72 @@ class TestExecuteUnitTests(unittest.TestCase):
todo.execute_unit_tests()
# Verify it was called - error handling path
def test_stdout_is_unbuffered_so_the_verdict_lands_last(self):
"""Signalé à l'usage : « pas clair si les tests ont passé ».
unittest écrit son verdict sur stderr et les tests impriment sur
stdout ; capturés ensemble, le stdout tamponné se déversait après
le « OK ». Le lecteur voyait donc du bruit en dernier, pas le
résultat.
"""
todo = TODO()
todo.execute = MagicMock()
todo.execute.exec_command_live.return_value = (0, ["OK"])
with patch("builtins.print"):
todo.execute_unit_tests()
cmd = todo.execute.exec_command_live.call_args[0][0]
self.assertIn("python -u -m unittest", cmd)
def test_the_pattern_reaches_the_command(self):
todo = TODO()
todo.execute = MagicMock()
todo.execute.exec_command_live.return_value = (0, ["OK"])
with patch("builtins.print"):
todo.execute_unit_tests("test_mail*.py")
cmd = todo.execute.exec_command_live.call_args[0][0]
self.assertIn("-p 'test_mail*.py'", cmd)
def test_the_default_pattern_is_still_the_whole_suite(self):
"""La signature a gagné un paramètre : l'entrée [3] ne doit pas
s'être mise à ne lancer qu'un sous-ensemble en silence."""
todo = TODO()
todo.execute = MagicMock()
todo.execute.exec_command_live.return_value = (0, ["OK"])
with patch("builtins.print"):
todo.execute_unit_tests()
cmd = todo.execute.exec_command_live.call_args[0][0]
self.assertIn("-p 'test_*.py'", cmd)
class TestTestMenuDispatch(unittest.TestCase):
"""Le câblage des entrées, pas leur contenu.
Un `elif` qui pointe le mauvais motif lancerait une suite verte sans
rien tester de ce que l'utilisateur a demandé — panne silencieuse que
seul ce test attrape.
"""
def _choose(self, entry):
todo = TODO()
with patch.object(
todo, "execute_unit_tests"
) as mock_run, patch.object(todo, "execute_test_module"), patch(
"click.prompt", side_effect=[entry, "0"]
), patch(
"builtins.print"
):
todo.prompt_execute_test()
return mock_run
def test_entry_4_runs_the_mail_tests(self):
self.assertEqual(self._choose("4").call_args[0], ("test_mail*.py",))
def test_entry_5_runs_the_analyse_tests(self):
self.assertEqual(self._choose("5").call_args[0], ("test_analyse*.py",))
def test_entry_3_still_runs_everything(self):
self.assertEqual(self._choose("3").call_args[0], ())
class TestKdbxGetExtraCommandUser(unittest.TestCase):
def test_empty_kdbx_key(self):
@ -317,13 +421,47 @@ class TestKdbxGetExtraCommandUser(unittest.TestCase):
class TestSetupClaudeCommit(unittest.TestCase):
def test_existing_file_skips(self):
"""Le déploiement d'une commande `/…` dans ~/.claude/commands.
La méthode a été généralisée depuis : elle prend le nom de la commande
et son gabarit, et quand la cible existe elle DEMANDE confirmation au
lieu de passer son tour. Le test ne détournait pas `input` — il aurait
bloqué si l'appel n'avait pas échoué avant.
"""
def test_existing_file_and_refusal_writes_nothing(self):
todo = TODO()
with patch("os.path.exists", return_value=True), patch(
"builtins.input", return_value="n"
), patch("builtins.open") as mock_open, patch(
"os.makedirs"
) as mock_makedirs, patch(
"builtins.print"
) as mock_print:
todo._setup_claude_commit()
# Should print exists message without asking for input
):
todo._setup_claude_command(
"commit", "template_claude_commands_commit.md"
)
# Un refus doit sortir AVANT toute écriture : ni lecture du gabarit,
# ni création du dossier. Sans ces deux assertions, le test passait
# aussi bien si la méthode écrasait le fichier.
mock_open.assert_not_called()
mock_makedirs.assert_not_called()
def test_existing_file_and_acceptance_writes(self):
"""Le pendant : sans lui, la méthode pourrait ne JAMAIS écrire et
le test ci-dessus resterait vert."""
todo = TODO()
with patch("os.path.exists", return_value=True), patch(
"builtins.input", return_value="y"
), patch("builtins.open", mock_open(read_data="gabarit")), patch(
"os.makedirs"
) as mock_makedirs, patch(
"builtins.print"
):
todo._setup_claude_command(
"commit", "template_claude_commands_commit.md"
)
mock_makedirs.assert_called_once()
class TestSelectDatabase(unittest.TestCase):
@ -407,5 +545,48 @@ class TestCreateBackupFromDatabase(unittest.TestCase):
self.assertIn("test_db", cmd)
class TestModuleLevelAbortExit(unittest.TestCase):
"""`click.exceptions.Abort` (raised by `click.prompt` on both Ctrl+C and
Ctrl+D/EOF - see click's own `termui.prompt_func`) is NOT a
`KeyboardInterrupt` subclass. Only the top-level menu's `click.prompt`
call is wrapped locally, inside `run()` (todo.py around line 149) -
every submenu (`prompt_assistant`, etc.) lets `Abort` propagate
uncaught. These tests drive the real script end to end (not a mock of
the dispatch chain) to prove the module-level guard around
`todo.run()` (todo.py around line 7159) now catches it too.
"""
def _run_todo(self, stdin_text):
repo_root = Path(__file__).resolve().parent.parent
python_bin = repo_root / ".venv.erplibre" / "bin" / "python3"
env = os.environ.copy()
with tempfile.TemporaryDirectory() as home_dir:
env["HOME"] = home_dir
return subprocess.run(
[str(python_bin), "script/todo/todo.py"],
cwd=repo_root,
input=stdin_text,
capture_output=True,
text=True,
env=env,
timeout=30,
)
def test_ctrl_d_in_a_submenu_exits_cleanly(self):
# "3" enters the Assistant submenu; the immediate EOF that follows
# raises Abort from a click.prompt() call that run() does not wrap.
result = self._run_todo("3\n")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertNotIn("Traceback", result.stderr)
self.assertNotIn("click.exceptions.Abort", result.stderr)
def test_ctrl_d_on_the_top_menu_still_exits_cleanly(self):
# Regression guard: the pre-existing local handler in run() must
# keep working once the module-level guard is added alongside it.
result = self._run_todo("")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertNotIn("Traceback", result.stderr)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,126 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Le sélecteur de fichier : choisir doit fermer l'écran, annuler doit exister.
Le défaut que ces tests verrouillent : `select_file` appelait bien le callback
mais ne quittait pas la boucle urwid. Le choix était donc enregistré et
l'écran restait ouvert, sans aucune touche pour le fermer — le sélecteur
paraissait figé au moment précis où il venait de faire son travail. Seul
Ctrl+C en sortait, ce qui abandonnait l'opération.
`ExitMainLoop` est ce qui termine une boucle urwid : la lever EST la
fermeture. Ces tests l'attendent donc comme un succès, pas comme une erreur.
"""
import os
import tempfile
import unittest
import urwid
from script.todo.todo_file_browser import FileBrowser
class BrowserCase(unittest.TestCase):
def setUp(self):
self.directory = tempfile.mkdtemp()
open(os.path.join(self.directory, "sauvegarde.zip"), "w").close()
os.mkdir(os.path.join(self.directory, "sous_dossier"))
self.chosen = []
def browser(self, **kwargs):
return FileBrowser(self.directory, self.chosen.append, **kwargs)
def button(self, browser, label):
for widget in browser.list_walker:
if isinstance(widget, urwid.Button) and widget.label == label:
return widget
raise AssertionError(f"bouton '{label}' absent")
class TestChoosingClosesTheBrowser(BrowserCase):
def test_selecting_a_file_reports_it_and_ends_the_loop(self):
browser = self.browser()
with self.assertRaises(urwid.ExitMainLoop):
browser.select_file(self.button(browser, "sauvegarde.zip"))
self.assertEqual(
self.chosen, [os.path.join(self.directory, "sauvegarde.zip")]
)
def test_selecting_a_directory_reports_it_and_ends_the_loop(self):
browser = self.browser(open_dir=True)
with self.assertRaises(urwid.ExitMainLoop):
browser.select_directory(self.button(browser, "."))
self.assertEqual(self.chosen, [self.directory])
class TestNavigatingDoesNotClose(BrowserCase):
"""Se déplacer n'est pas choisir : la boucle doit continuer."""
def test_entering_a_directory(self):
browser = self.browser()
browser.open_directory(self.button(browser, "sous_dossier/"))
self.assertEqual(
browser.current_path, os.path.join(self.directory, "sous_dossier")
)
self.assertEqual(self.chosen, [])
def test_going_up(self):
browser = self.browser()
browser.go_up_directory(None)
self.assertEqual(browser.current_path, os.path.dirname(self.directory))
self.assertEqual(self.chosen, [])
def test_arrow_keys_do_not_quit(self):
browser = self.browser()
for key in ("up", "down", "enter", "a"):
browser.unhandled_input(key)
self.assertEqual(self.chosen, [])
class TestCancelling(BrowserCase):
"""Sortir sans choisir doit être possible.
Toutes les autres sorties sélectionnent quelque chose. Un appelant qui
propose une solution de rechange — taper un chemin — ne devient
atteignable que si l'on peut renoncer.
"""
def test_q_and_escape_quit_without_choosing(self):
for key in ("q", "Q", "esc"):
browser = self.browser()
with self.assertRaises(urwid.ExitMainLoop):
browser.unhandled_input(key)
self.assertEqual(self.chosen, [], key)
class TestListing(BrowserCase):
def test_files_are_offered_when_picking_a_file(self):
labels = [
w.label
for w in self.browser().list_walker
if isinstance(w, urwid.Button)
]
self.assertIn("sauvegarde.zip", labels)
self.assertIn("sous_dossier/", labels)
def test_files_are_hidden_when_picking_a_directory(self):
labels = [
w.label
for w in self.browser(open_dir=True).list_walker
if isinstance(w, urwid.Button)
]
self.assertNotIn("sauvegarde.zip", labels)
self.assertIn(".", labels)
def test_an_unreadable_directory_does_not_crash(self):
browser = self.browser()
browser.current_path = os.path.join(self.directory, "nowhere")
browser.refresh_list()
self.assertTrue(len(browser.list_walker) >= 1)
if __name__ == "__main__":
unittest.main()

View file

@ -40,23 +40,23 @@ class TestT(unittest.TestCase):
todo_i18n._current_lang = None
def test_returns_french_when_lang_fr(self):
todo_i18n.set_lang("fr")
result = todo_i18n.t("menu_quit")
todo_i18n._current_lang = "fr"
result = todo_i18n.t("Quit")
self.assertEqual(result, "Quitter")
def test_returns_english_when_lang_en(self):
todo_i18n.set_lang("en")
result = todo_i18n.t("menu_quit")
todo_i18n._current_lang = "en"
result = todo_i18n.t("Quit")
self.assertEqual(result, "Quit")
def test_unknown_key_returns_key(self):
todo_i18n.set_lang("fr")
todo_i18n._current_lang = "fr"
result = todo_i18n.t("nonexistent_key_xyz")
self.assertEqual(result, "nonexistent_key_xyz")
def test_fallback_to_fr_if_lang_missing(self):
todo_i18n.set_lang("de")
result = todo_i18n.t("menu_quit")
todo_i18n._current_lang = "de"
result = todo_i18n.t("Quit")
self.assertEqual(result, "Quitter")
@ -81,9 +81,7 @@ class TestGetLang(unittest.TestCase):
f.write('EL_LANG="en"\n')
f.flush()
try:
with patch.object(
todo_i18n, "ENV_VAR_FILE", f.name
):
with patch.object(todo_i18n, "ENV_VAR_FILE", f.name):
result = todo_i18n.get_lang()
self.assertEqual(result, "en")
finally:
@ -96,9 +94,7 @@ class TestGetLang(unittest.TestCase):
f.write("EL_LANG=fr\n")
f.flush()
try:
with patch.object(
todo_i18n, "ENV_VAR_FILE", f.name
):
with patch.object(todo_i18n, "ENV_VAR_FILE", f.name):
result = todo_i18n.get_lang()
self.assertEqual(result, "fr")
finally:
@ -148,7 +144,11 @@ class TestSetLang(unittest.TestCase):
todo_i18n._current_lang = None
def test_sets_current_lang(self):
todo_i18n.set_lang("en")
# Détourner ENV_VAR_FILE comme le font les trois tests suivants :
# `set_lang()` PERSISTE, et sans ce détournement celui-ci écrivait
# dans le ./env_var.sh du dépôt, suivi par git.
with patch.object(todo_i18n, "ENV_VAR_FILE", "/nonexistent/path"):
todo_i18n.set_lang("en")
self.assertEqual(todo_i18n._current_lang, "en")
def test_persists_to_file_update(self):
@ -158,9 +158,7 @@ class TestSetLang(unittest.TestCase):
f.write('EL_LANG="fr"\nOTHER=value\n')
f.flush()
try:
with patch.object(
todo_i18n, "ENV_VAR_FILE", f.name
):
with patch.object(todo_i18n, "ENV_VAR_FILE", f.name):
todo_i18n.set_lang("en")
with open(f.name) as rf:
content = rf.read()
@ -176,9 +174,7 @@ class TestSetLang(unittest.TestCase):
f.write("SOME_VAR=123\n")
f.flush()
try:
with patch.object(
todo_i18n, "ENV_VAR_FILE", f.name
):
with patch.object(todo_i18n, "ENV_VAR_FILE", f.name):
todo_i18n.set_lang("en")
with open(f.name) as rf:
content = rf.read()
@ -207,9 +203,7 @@ class TestLangIsConfigured(unittest.TestCase):
f.write('EL_LANG="fr"\n')
f.flush()
try:
with patch.object(
todo_i18n, "ENV_VAR_FILE", f.name
):
with patch.object(todo_i18n, "ENV_VAR_FILE", f.name):
result = todo_i18n.lang_is_configured()
self.assertTrue(result)
finally:
@ -222,9 +216,7 @@ class TestLangIsConfigured(unittest.TestCase):
f.write("SOME_VAR=123\n")
f.flush()
try:
with patch.object(
todo_i18n, "ENV_VAR_FILE", f.name
):
with patch.object(todo_i18n, "ENV_VAR_FILE", f.name):
result = todo_i18n.lang_is_configured()
self.assertFalse(result)
finally:

199
test/test_todo_menu.py Normal file
View file

@ -0,0 +1,199 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Le menu Execute : les numéros affichés mènent-ils où ils le disent ?
Le menu est écrit deux fois — une f-string qui affiche « [7] … », et une
chaîne d'`elif status == "7"` qui dispatche. Rien ne les reliait : insérer une
entrée au milieu oblige à décaler les deux à la main, et une seule erreur
envoie l'utilisateur dans le mauvais écran sans que rien ne proteste.
Ce test relit les deux et les apparie. Il ne juge pas le contenu du menu :
ajouter, retirer ou réordonner reste libre, tant que l'affichage et le
dispatch racontent la même histoire.
"""
import ast
import re
import unittest
from pathlib import Path
TODO_PY = (
Path(__file__).resolve().parent.parent / "script" / "todo" / "todo.py"
)
# « [12] {t("Deploy - …")} » en début de ligne, dans la f-string du menu.
RE_SHOWN = re.compile(r'^\[(\d+)\] \{t\("([^"]+)"\)\}', re.M)
# « elif status == "12": » suivi de « status = self.prompt_execute_deploy() »
RE_DISPATCH = re.compile(
r'elif status == "(\d+)":\s*\n\s*status = self\.(\w+)\(\)'
)
def prompt_execute_source():
"""Le corps de prompt_execute(), affichage et dispatch compris."""
source = TODO_PY.read_text(encoding="utf-8")
start = source.index("def prompt_execute(self):")
end = source.index("def prompt_install(self):", start)
return source[start:end]
class TestExecuteMenuNumbering(unittest.TestCase):
def setUp(self):
self.body = prompt_execute_source()
# [0] Retour est traité par le « if status == "0" » qui précède la
# chaîne d'elif : il s'affiche mais n'a pas de branche de dispatch.
self.shown = [
(int(num), label)
for num, label in RE_SHOWN.findall(self.body)
if num != "0"
]
self.dispatch = [
(int(num), method)
for num, method in RE_DISPATCH.findall(self.body)
]
def test_the_menu_was_actually_parsed(self):
# Si la forme du menu change, ce test doit tomber ici plutôt que de
# déclarer « tout va bien » sur une liste vide.
self.assertGreater(len(self.shown), 5)
self.assertEqual(len(self.shown), len(self.dispatch))
def test_zero_is_handled_before_the_elif_chain(self):
self.assertIn('if status == "0":', self.body)
def test_numbering_is_contiguous_from_one(self):
numbers = [num for num, _ in self.shown]
self.assertEqual(numbers, list(range(1, len(numbers) + 1)))
def test_every_shown_entry_has_the_matching_dispatch(self):
self.assertEqual(
[num for num, _ in self.shown],
[num for num, _ in self.dispatch],
)
def test_no_dispatch_branch_is_unreachable(self):
shown = {num for num, _ in self.shown}
for num, method in self.dispatch:
self.assertIn(
num,
shown,
f"la branche [{num}] -> {method} n'est affichée nulle part",
)
# Chaque entrée du menu et la méthode qu'elle DOIT atteindre, par le début
# de son libellé. Sans cette table, le test ne vérifie que l'alignement des
# numéros — et laisse passer le défaut même qu'une renumérotation produit :
# une entrée qui garde son rang mais atterrit dans le mauvais écran.
#
# Une renumérotation, l'opération risquée, ne touche PAS cette table. Ajouter
# ou retirer une entrée demande d'y toucher, et c'est voulu : c'est le seul
# moment où quelqu'un doit dire où mène la nouvelle entrée.
EXPECTED = {
"Code": "prompt_execute_code",
"Config": "prompt_execute_config",
"Run": "prompt_execute_instance",
"Test": "prompt_execute_test",
"Process": "prompt_execute_process",
"Database": "prompt_execute_database",
"Analyse": "prompt_execute_analyse",
"Git": "prompt_execute_git",
"Doc": "prompt_execute_doc",
"GPT code": "prompt_execute_gpt_code",
"Automation": "prompt_execute_function",
"Deploy": "prompt_execute_deploy",
"Network": "prompt_execute_network",
"Security": "prompt_execute_security",
"Language": "_change_language",
}
def _entry_key(self, label):
"""« Doc - Documentation search » -> « Doc »."""
return label.split(" - ", 1)[0].strip()
def test_every_entry_reaches_the_method_it_names(self):
dct_dispatch = dict(self.dispatch)
for num, label in self.shown:
key = self._entry_key(label)
self.assertIn(
key,
self.EXPECTED,
f"entrée [{num}] « {label} » absente de EXPECTED :"
" déclarez où elle mène",
)
self.assertEqual(
dct_dispatch.get(num),
self.EXPECTED[key],
f"[{num}] « {label} » mène à"
f" {dct_dispatch.get(num)} au lieu de {self.EXPECTED[key]}",
)
def test_expected_table_has_no_stale_entry(self):
# Une entrée retirée du menu doit sortir d'EXPECTED, sinon la table
# devient un cimetière qui ne protège plus rien.
shown_keys = {self._entry_key(label) for _, label in self.shown}
self.assertEqual(set(self.EXPECTED) - shown_keys, set())
class TestMenuLabels(unittest.TestCase):
"""Toute méthode de menu doit avoir son étiquette de fil d'Ariane.
Sans elle, `_menu_header` n'affiche pas le segment et
`todo_telemetry.build_code_tree` traite le menu comme une COMMANDE :
il apparaît en feuille, sous son nom de méthode brut. Trois menus en
souffrent déjà — la liste est figée ici pour que le nombre ne grandisse
pas, pas pour bénir ce qu'elle contient.
"""
KNOWN_MISSING = {
"prompt_execute_test",
"prompt_execute_network",
"prompt_execute_security",
}
def setUp(self):
source = TODO_PY.read_text(encoding="utf-8")
tree = ast.parse(source)
cls = next(n for n in ast.walk(tree) if isinstance(n, ast.ClassDef))
self.labels = set()
for node in cls.body:
if not isinstance(node, ast.Assign):
continue
if not any(
isinstance(tg, ast.Name) and tg.id == "_MENU_LABELS"
for tg in node.targets
):
continue
self.labels = {
key.value
for key in node.value.keys
if isinstance(key, ast.Constant)
}
self.dispatched = {
method
for _, method in RE_DISPATCH.findall(prompt_execute_source())
}
def test_menu_labels_was_parsed(self):
self.assertIn("prompt_execute", self.labels)
def test_analyse_menu_has_a_breadcrumb_label(self):
self.assertIn("prompt_execute_analyse", self.labels)
def test_no_new_menu_forgets_its_label(self):
submenus = {
method
for method in self.dispatched
if method.startswith("prompt_execute_")
}
missing = submenus - self.labels - self.KNOWN_MISSING
self.assertEqual(
missing,
set(),
f"menus sans étiquette dans _MENU_LABELS : {sorted(missing)}",
)
if __name__ == "__main__":
unittest.main()