🧠 Obsidian · & le vault autoportant

Chapitre 09
L'outillage : apply, pin, doctor, export

Objectifs du chapitre

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 :

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 :

  1. 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.
  2. L'état est hors périmÚtre. workspace.json n'est jamais comparé.
  3. Les fichiers préservés sont hors périmÚtre. Sinon, installer un thÚme condamnerait doctor à sortir en erreur pour toujours.
  4. Les plugins non gĂ©rĂ©s sont hors pĂ©rimĂštre. C'est la rĂšgle du chapitre 8 : sans elle, le data.json d'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

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.