Chapitre 09
L'outillage : apply, pin, doctor, export
Objectifs du chapitre
- Comprendre la propriété centrale d'
apply: une reconstruction qui échoue ne laisse jamais un vault à moitié configuré. - Savoir pourquoi les empreintes se calculent et ne s'écrivent pas.
- Utiliser le couple
doctor/exportcomme cycle de vie de la configuration. - Reconnaßtre les décisions de conception qui font qu'un outil de ce genre est fiable, ou pas.
1. La structure
L'outillage vit dans le vault, sous 90-meta/tooling/. Deux points
d'entrée, un paquet Python découpé par responsabilité :
90-meta/
âââ manifest.yaml # la source de vĂ©ritĂ©
âââ conventions.md # le contrat de front-matter, lisible dans Obsidian
âââ templates/ vues/
âââ tooling/
âââ bootstrap.py # apply | pin | doctor | export
âââ vault.py # doctor (contenu des notes)
âââ vaulttool/
â âââ manifest.py # charger et valider
â âââ render.py # manifeste â contenus de fichiers
â âââ plugins.py # tĂ©lĂ©charger, vĂ©rifier, installer
â âââ apply.py # Ă©crire, avec bascule atomique
â âââ pin.py # calculer versions et empreintes
â âââ drift.py # comparer, et proposer l'inverse
â âââ notes.py # valider le front-matter
âââ tests/
Un détail qui n'en est pas un : render.py est une fonction pure. Elle prend un
manifeste et rend un dictionnaire « chemin â contenu », sans toucher au disque. C'est ce qui
permet de la tester sans systÚme de fichiers, et de réutiliser sa sortie pour comparer plutÎt
que pour écrire.
2. apply â et l'atomicitĂ©
$ uv run --project 90-meta/tooling 90-meta/tooling/bootstrap.py apply
40 fichiers ecrits dans .obsidian/
DerriĂšre cette ligne, la propriĂ©tĂ© qui fait tout l'intĂ©rĂȘt de la commande : si quoi
que ce soit échoue, le .obsidian/ précédent est intact. Un vault à moitié
configurĂ© est pire qu'un vault pĂ©rimĂ© â il ne marche plus et on ne sait pas pourquoi.
Deux mécanismes superposés l'assurent.
Vérifier tout avant d'écrire quoi que ce soit
def preparer(specs, destination, telecharger=telechargeur_http):
"""Télécharge et vérifie tous les plugins épinglés, puis écrit."""
contenus = {}
for spec in epingles: # phase 1 : rien n'est écrit
for active in _actifs_a_installer(spec):
donnees = telecharger(url_actif(spec, active))
reelle = empreinte(donnees)
if reelle != spec.sha256[active]:
raise ChecksumError(...) # sortie ici : destination intacte
contenus[spec.id][active] = donnees
for identifiant, actifs in contenus.items(): # phase 2 : que des écritures
...
Tout est téléchargé et vérifié en mémoire avant la premiÚre écriture. Une empreinte fausse sur le neuviÚme plugin annule aussi les huit premiers.
Le test qui pin cette propriĂ©tĂ© ne vĂ©rifie pas qu'une exception est levĂ©e â n'importe quel code cassĂ© lĂšverait une exception. Il vĂ©rifie que le dossier de destination est restĂ© vide :
with pytest.raises(ChecksumError, match="calendar"):
preparer([bon, mauvais], tmp_path, faux_telechargeur(table))
assert list(tmp_path.iterdir()) == []
C'est la différence entre tester le symptÎme et tester la garantie.
Basculer par renommage
La nouvelle configuration se construit dans .obsidian.new/, puis on échange :
try:
if actuel.exists():
actuel.rename(ancien) # .obsidian â .obsidian.old
neuf.rename(actuel) # .obsidian.new â .obsidian
except OSError as exc:
if ancien.exists() and not actuel.exists():
ancien.rename(actuel) # restauration
shutil.rmtree(neuf, ignore_errors=True)
raise
shutil.rmtree(ancien, ignore_errors=True)
Le renommage est atomique au niveau du systĂšme de fichiers : Ă aucun instant
.obsidian/ n'est un mélange des deux versions. Et si le second renommage échoue, on
remet l'ancien en place avant de propager l'erreur.
Ce cas n'est pas théorique. Sur Windows, Obsidian tient des handles ouverts dans
.obsidian/, et les relĂąche avec un peu de retard aprĂšs sa fermeture. Un
apply lancé juste aprÚs avoir quitté Obsidian donne :
echec : [Errno 13] Permission denied: '.obsidian.new' -> '.obsidian'
La restauration a fonctionnĂ©, .obsidian/ Ă©tait intact, aucun rĂ©sidu â et la
mĂȘme commande trente secondes plus tard est passĂ©e. Sans le bloc de restauration, le vault
serait resté sans configuration du tout.
Ce qui survit Ă la bascule
Trois choses sont recopiĂ©es dans la nouvelle configuration au lieu d'ĂȘtre rĂ©gĂ©nĂ©rĂ©es :
PRESERVES = ("workspace.json", "themes", "snippets")
workspace.json parce que c'est de l'état, pas de la configuration.
themes/ et snippets/ parce que ce sont tes fichiers : le
manifeste ne les décrit pas, et les supprimer à chaque reconstruction serait une perte
silencieuse.
3. pin â les empreintes ne s'Ă©crivent pas
$ uv run --project 90-meta/tooling 90-meta/tooling/bootstrap.py pin
dataview epingle en 0.5.70
templater-obsidian epingle en 2.24.3
obsidian-git epingle en 2.38.6
quickadd epingle en 2.21.0
...
Pour chaque plugin qui n'est pas encore épinglé, pin interroge l'API GitHub pour
la derniÚre version, télécharge les fichiers, calcule leurs empreintes, et réécrit le manifeste
en place.
Trois dĂ©cisions valent d'ĂȘtre notĂ©es :
- Un plugin déjà épinglé n'est pas retouché. Relancer
pinne produit aucun changement â sinon la commande deviendrait un gĂ©nĂ©rateur de bruit dans l'historique git. - Ăcriture tout ou rien. Le manifeste est réécrit une fois, aprĂšs la boucle. Si le cinquiĂšme plugin Ă©choue, le fichier sur disque est inchangĂ©.
- Les actifs optionnels sont optionnels.
styles.cssn'existe pas chez tous les plugins ; son absence n'est pas une erreur, contrairement Ămain.jsoumanifest.json.
Corollaire du premier point : pour mettre Ă jour un plugin, il ne suffit
pas de changer le numéro de version. pin saute l'entrée puisqu'elle a déjà une
version et des empreintes, et apply échouera ensuite sur une empreinte qui ne
correspond plus.
La procédure est : supprimer version et sha256, puis
relancer pin. Non dĂ©couvrable â d'oĂč sa prĂ©sence dans le README du
vault.
4. doctor â mesurer la dĂ©rive
$ uv run --project 90-meta/tooling 90-meta/tooling/bootstrap.py doctor
hotkeys.json : different â manifeste {'quickadd:choice:61a7âŠ': [...]},
vault {'quickadd:capture': [...], 'quickadd:choice:61a7âŠ': [...]}
1 ecart(s)
La commande compare ce que render produirait avec ce qui est réellement sur
disque, et sort en code 1 s'il y a au moins un Ă©cart â de quoi la mettre dans un script.
Quatre choix de conception la rendent utilisable au quotidien :
- La comparaison porte sur le JSON analysé, pas sur le texte. Une différence d'indentation ou d'ordre de clés n'est pas une dérive. Sans ça, la commande hurlerait au premier passage d'Obsidian, qui réécrit les fichiers à sa façon.
- L'état est hors périmÚtre.
workspace.jsonn'est jamais comparé. - Les fichiers préservés sont hors périmÚtre. Sinon, installer un thÚme
condamnerait
doctorà sortir en erreur pour toujours. - Les plugins non gérés sont hors périmÚtre. C'est la rÚgle du
chapitre 8 : sans elle, le
data.jsond'un plugin non géré serait signalé « en trop » à chaque exécution.
Un outil de détection qui crie tout le temps ne détecte plus rien : on cesse de le lire.
La valeur de doctor tient entiĂšrement Ă ce qu'il puisse revenir Ă
zéro. Chacun des quatre choix ci-dessus existe pour rendre le zéro atteignable.
5. export â le chemin de retour
doctor signale la dérive ; export la promeut en décision. Il lit
.obsidian/ et propose le fragment de manifeste correspondant :
$ uv run --project 90-meta/tooling 90-meta/tooling/bootstrap.py export
appearance:
theme: obsidian
base_font_size: 16
plugins:
- id: dataview
settings:
enableDataviewJs: true
...
non exportable : plugins/table-editor-obsidian/data.json : réglages non gérés
Les lignes « non exportable » partent sur la sortie d'erreur. Elles matĂ©rialisent une rĂšgle simple : ce que l'outil ne sait pas exprimer, il le dit â il ne le recopie pas tel quel, et il ne le tait pas.
Sont signalés de cette façon : les réglages des plugins non gérés, les champs d'apparence
qu'Obsidian écrit et que le manifeste ne connaßt pas (interfaceFontFamily,
translucencyâŠ), les commandes dont le raccourci a Ă©tĂ© retirĂ©, et celles qui portent
plusieurs liaisons â le manifeste ne sait en exprimer qu'une.
La propriĂ©tĂ© qui compte : le fragment doit ĂȘtre rechargeable
Une commande export peut sembler fonctionner tout en produisant un fragment
que le manifeste refuse. C'est arrivé au vault de référence, et de trois façons à la
fois : plugins Ă©tait Ă©mis comme dictionnaire lĂ oĂč le schĂ©ma attend une
liste ; un raccourci délié faisait planter la commande sur un accÚs à un tableau vide ; et
les champs d'apparence inconnus étaient recopiés, ce qui faisait échouer le
render suivant.
Trois symptĂŽmes, une seule cause : aucun test ne rejouait le fragment. Les
tests vérifiaient la forme du dictionnaire produit, jamais qu'il repassait par
load_manifest puis render.
Le test qui ferme les trois d'un coup est un aller-retour complet :
fragment, ignores = patch_et_ignores(racine, non_geres)
# on écrit le fragment dans un manifeste, puis on le relit et on le rend
manifeste = load_manifest(ecrire_manifeste(fragment))
rendu = render(manifeste)
assert set(rendu) == set(OBSERVE) # mĂȘmes fichiers
for nom in OBSERVE:
assert json.loads(rendu[nom]) == json.loads(OBSERVE[nom]) # mĂȘme contenu
Leçon gĂ©nĂ©ralisable : quand deux fonctions sont censĂ©es ĂȘtre l'inverse l'une de l'autre, le seul test qui vaut est celui qui les compose. Tester chacune sĂ©parĂ©ment laisse passer exactement les incompatibilitĂ©s qu'on cherche Ă Ă©viter.
6. Les erreurs, et leur sortie
Toutes les commandes convergent vers un seul traitement d'erreur :
except (ManifestError, ChecksumError, DownloadError, OSError) as exc:
print(f"echec : {exc}", file=sys.stderr)
return 1
Ce qui suppose que toute panne prévisible se présente sous l'une de ces quatre formes. C'est
un travail en soi : un manifeste absent doit lever ManifestError et non
FileNotFoundError ; un YAML mal formé aussi ; une coupure réseau doit devenir
DownloadError et non ConnectionResetError.
Le critĂšre : un utilisateur ne doit jamais voir une trace d'appels Python. Une trace signifie que l'outil n'avait pas prĂ©vu le cas â et l'utilisateur, lui, ne sait pas si son vault est intact.
Récapitulatif
applyvĂ©rifie tout avant d'Ă©crire, puis bascule par renommage avec restauration : un Ă©chec laisse le vault dans son Ă©tat prĂ©cĂ©dent.PRESERVESgardeworkspace.json,themes/etsnippets/d'une reconstruction Ă l'autre.pincalcule les empreintes, ne retouche pas l'existant, et Ă©crit en tout ou rien. Mettre Ă jour un plugin exige d'effacerversionetsha256.doctorcompare du JSON analysĂ©, ignore l'Ă©tat, les fichiers prĂ©servĂ©s et les plugins non gĂ©rĂ©s â pour que le zĂ©ro reste atteignable.exportpropose le fragment inverse et signale ce qu'il ne sait pas exprimer.- Deux fonctions inverses se testent en les composant, jamais sĂ©parĂ©ment.
Exercices
Exercice 1 â PrĂ©dire l'Ă©tat aprĂšs un Ă©chec
Le manifeste déclare neuf plugins. Le réseau tombe pendant le téléchargement du sixiÚme.
Décris l'état du vault aprÚs l'échec : contenu de .obsidian/, présence de
dossiers temporaires, code de sortie.
Voir la solution
.obsidian/ est strictement inchangé : la panne survient en
phase 1 de preparer, oĂč rien n'est Ă©crit. Les cinq plugins dĂ©jĂ
téléchargés étaient en mémoire et sont perdus, sans conséquence.
Aucun dossier temporaire ne subsiste : .obsidian.new/ avait
été créé pour recevoir les plugins, et le bloc de nettoyage le supprime avant de propager
l'erreur. MĂȘme s'il en restait un, l'exĂ©cution suivante commence par les effacer.
Code de sortie 1, avec un message de la forme
echec : <url> : <erreur> sur la sortie d'erreur â et non une trace
Python, à condition que l'erreur réseau ait bien été convertie en
DownloadError.
Question subsidiaire utile : est-ce reprenable ? Non, et c'est voulu. apply
retélécharge tout à chaque exécution. Un cache accélérerait et permettrait un amorçage hors
ligne â c'Ă©tait prĂ©vu par la spĂ©cification initiale sous la forme d'une option
--from-cache, et ça n'a jamais été construit.
Exercice 2 â Un doctor qui ne revient pas Ă zĂ©ro
AprĂšs avoir ouvert Obsidian pour la premiĂšre fois sur un vault fraĂźchement reconstruit,
doctor signale sept écarts, dont plusieurs « en trop » :
app.json, types.json, trois data.json de plugins.
Comment reviens-tu Ă zĂ©ro, et lesquels de ces Ă©carts ne devraient pas ĂȘtre lĂ ?
Voir la solution
Revenir à zéro : lancer export, reporter les blocs
proposĂ©s dans le manifeste, relancer doctor. C'est le cycle normal â Obsidian
Ă©crit de lui-mĂȘme des fichiers que le manifeste ne connaissait pas encore.
Ceux qui devraient ĂȘtre lĂ : app.json et
types.json sont de vraies configurations, écrites par Obsidian au premier
lancement. Ils rejoignent core_plugins.settings. Les data.json de
plugins gérés aussi.
Ceux qui ne devraient pas y ĂȘtre : les data.json de plugins
non gérés. S'ils apparaissent, c'est que la rÚgle du chapitre 8 n'est pas
appliquĂ©e dans doctor â et alors la commande ne pourra jamais revenir Ă zĂ©ro,
puisque ces fichiers réapparaßtront à chaque démarrage du plugin.
Le vrai enseignement : un écart persistant n'est pas toujours une dérive de configuration, c'est parfois un défaut de l'outil de détection.
Exercice 3 â Concevoir un garde-fou
apply installe les plugins qui ont repo, version et
sha256. Que se passe-t-il si le manifeste déclare un plugin avec seulement son
id ? OĂč placerais-tu le contrĂŽle ?
Voir la solution
Sans contrÎle : l'entrée est ignorée à l'installation, mais son
identifiant est quand mĂȘme Ă©crit dans community-plugins.json. Obsidian
démarre, lit la liste, cherche plugins/<id>/main.js, ne le trouve pas, et
affiche un plugin cassé. Le pire est que doctor retourne
0 ecart(s) : le fichier attendu par le manifeste est bien lĂ , le fichier absent
n'est attendu par personne. La commande censée détecter les problÚmes certifie un vault
cassé.
OĂč placer le contrĂŽle : dans apply, avant tout
téléchargement, avec une erreur nommant les plugins fautifs. C'est là que l'invariant a un
sens : « je ne construis pas un vault que je ne peux pas rendre complet ».
OĂč ne pas le placer : dans load_manifest, en rendant
repo obligatoire. Ce serait tentant, mais un plugin sans repo est
un Ă©tat lĂ©gitime â c'est exactement l'Ă©tat d'un plugin qu'on vient d'ajouter et que
pin n'a pas encore traité. Interdire cet état casserait le flux de travail
normal.
Leçon : un invariant se place lĂ oĂč il doit tenir, pas lĂ oĂč il est le plus facile Ă Ă©crire.