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:
commit
3a68003430
92 changed files with 29547 additions and 288 deletions
|
|
@ -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] -->
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
69
CHANGELOG.md
69
CHANGELOG.md
|
|
@ -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
678
doc/EMAIL.base.md
Normal 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
340
doc/EMAIL.fr.md
Normal 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
318
doc/EMAIL.md
Normal 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.
|
||||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
@ -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
5
private/.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
572
script/analyse/analyse_custom_field.py
Normal file
572
script/analyse/analyse_custom_field.py
Normal 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())
|
||||
189
script/analyse/analyse_diff_tui.py
Normal file
189
script/analyse/analyse_diff_tui.py
Normal 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
|
||||
562
script/analyse/analyse_schema_size.py
Normal file
562
script/analyse/analyse_schema_size.py
Normal 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())
|
||||
858
script/analyse/analyse_view_custom.py
Normal file
858
script/analyse/analyse_view_custom.py
Normal 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())
|
||||
872
script/analyse/lib_analyse.py
Normal file
872
script/analyse/lib_analyse.py
Normal 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()
|
||||
70
script/analyse/shell/view_file_arch.py
Normal file
70
script/analyse/shell/view_file_arch.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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
|
||||
```
|
||||
347
script/odoo/migration/check_cow_views.py
Executable file
347
script/odoo/migration/check_cow_views.py
Executable file
|
|
@ -0,0 +1,347 @@
|
|||
#!/usr/bin/env python3
|
||||
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
|
||||
"""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())
|
||||
39
script/odoo/migration/fix_migration_odoo130_to_odoo140.sql
Normal file
39
script/odoo/migration/fix_migration_odoo130_to_odoo140.sql
Normal 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'
|
||||
);
|
||||
158
script/odoo/migration/neutralize_cow_views.py
Executable file
158
script/odoo/migration/neutralize_cow_views.py
Executable 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())
|
||||
365
script/odoo/migration/reset_stale_cow_views.py
Executable file
365
script/odoo/migration/reset_stale_cow_views.py
Executable 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())
|
||||
213
script/odoo/migration/snapshot_cow_views.py
Executable file
213
script/odoo/migration/snapshot_cow_views.py
Executable 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())
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
11
script/todo/mail/__init__.py
Normal file
11
script/todo/mail/__init__.py
Normal 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`.
|
||||
"""
|
||||
62
script/todo/mail/account_setup.py
Normal file
62
script/todo/mail/account_setup.py
Normal 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)
|
||||
285
script/todo/mail/accounts.py
Normal file
285
script/todo/mail/accounts.py
Normal 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
|
||||
28
script/todo/mail/charset.py
Normal file
28
script/todo/mail/charset.py
Normal 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
119
script/todo/mail/crypto.py
Normal 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}")
|
||||
256
script/todo/mail/imap_sync.py
Normal file
256
script/todo/mail/imap_sync.py
Normal 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
|
||||
339
script/todo/mail/imap_transport.py
Normal file
339
script/todo/mail/imap_transport.py
Normal 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
550
script/todo/mail/menu.py
Normal 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
178
script/todo/mail/secrets.py
Normal 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)
|
||||
293
script/todo/mail/smtp_send.py
Normal file
293
script/todo/mail/smtp_send.py
Normal 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
624
script/todo/mail/store.py
Normal 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
2701
script/todo/mail/tui.py
Normal file
File diff suppressed because it is too large
Load diff
218
script/todo/mail/tui_text.py
Normal file
218
script/todo/mail/tui_text.py
Normal 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}")
|
||||
]
|
||||
186
script/todo/migration_form.py
Normal file
186
script/todo/migration_form.py
Normal 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"]
|
||||
201
script/todo/migration_stats.py
Normal file
201
script/todo/migration_stats.py
Normal 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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
832
test/mail_sandbox.py
Normal 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
|
||||
212
test/test_analyse_custom_field.py
Normal file
212
test/test_analyse_custom_field.py
Normal 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
522
test/test_analyse_lib.py
Normal 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({}, {}))
|
||||
225
test/test_analyse_schema_size.py
Normal file
225
test/test_analyse_schema_size.py
Normal 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()
|
||||
270
test/test_analyse_view_custom.py
Normal file
270
test/test_analyse_view_custom.py
Normal 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()
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
117
test/test_kdbx_manager.py
Normal 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()
|
||||
149
test/test_mail_account_setup.py
Normal file
149
test/test_mail_account_setup.py
Normal 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
233
test/test_mail_accounts.py
Normal 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
44
test/test_mail_charset.py
Normal 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
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
120
test/test_mail_crypto.py
Normal 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()
|
||||
436
test/test_mail_imap_transport.py
Normal file
436
test/test_mail_imap_transport.py
Normal 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")
|
||||
670
test/test_mail_live_server.py
Normal file
670
test/test_mail_live_server.py
Normal 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
821
test/test_mail_menu.py
Normal 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
229
test/test_mail_secrets.py
Normal 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
511
test/test_mail_send.py
Normal 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
636
test/test_mail_store.py
Normal 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
418
test/test_mail_sync.py
Normal 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
431
test/test_mail_tui.py
Normal 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()
|
||||
879
test/test_mail_tui_account.py
Normal file
879
test/test_mail_tui_account.py
Normal 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
627
test/test_mail_tui_help.py
Normal 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()
|
||||
405
test/test_mail_tui_layout.py
Normal file
405
test/test_mail_tui_layout.py
Normal 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
514
test/test_mail_tui_log.py
Normal 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()
|
||||
389
test/test_mail_tui_refresh.py
Normal file
389
test/test_mail_tui_refresh.py
Normal 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
1048
test/test_mail_tui_resize.py
Normal file
File diff suppressed because it is too large
Load diff
566
test/test_mail_tui_splitter.py
Normal file
566
test/test_mail_tui_splitter.py
Normal 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
298
test/test_mail_tui_text.py
Normal 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é & thé</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()
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
126
test/test_todo_file_browser.py
Normal file
126
test/test_todo_file_browser.py
Normal 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()
|
||||
|
|
@ -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
199
test/test_todo_menu.py
Normal 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()
|
||||
Loading…
Reference in a new issue