瀏覽代碼

Mémoire : écriture des fichiers avec fsync, chemin « memories/… » accepté

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet 2 天之前
父節點
當前提交
4abb36a673
共有 6 個文件被更改,包括 86 次插入 和 17 次删除
  1. 2 0
      README.md
  2. 25 0
      bot/files.py
  3. 3 9
      bot/members.py
  4. 5 8
      bot/memory.py
  5. 5 0
      changelog.txt
  6. 46 0
      tests/test_files.py

+ 2 - 0
README.md

@@ -185,4 +185,6 @@ bot/images.py            préparation des images (taille, format)
 bot/pdf_files.py         vérification des PDF joints (pages, mot de passe)
 bot/text_files.py        lecture des fichiers texte joints
 bot/text_utils.py        découpage des longues réponses, réflexion affichée retirée
+bot/files.py             écriture des fichiers sur le disque, sans fichier à moitié écrit
+tests/                   tests automatisés : python -m unittest
 ```

+ 25 - 0
bot/files.py

@@ -0,0 +1,25 @@
+"""Écriture des fichiers gardés sur le disque."""
+
+from __future__ import annotations
+
+import contextlib
+import os
+from pathlib import Path
+
+
+def write_atomic(path: Path, text: str) -> None:
+    """Écrit text dans path sans jamais laisser de fichier à moitié écrit : dans un fichier temporaire,
+    envoyé jusqu'au disque (fsync) avant d'être renommé, sinon une coupure de courant juste après le
+    renommage pourrait laisser un fichier vide. Le fichier temporaire est retiré si l'écriture échoue."""
+    path.parent.mkdir(parents=True, exist_ok=True)
+    tmp = path.with_suffix(path.suffix + ".tmp")
+    try:
+        with open(tmp, "w", encoding="utf-8") as file:
+            file.write(text)
+            file.flush()
+            os.fsync(file.fileno())
+        os.replace(tmp, path)
+    except OSError:
+        with contextlib.suppress(OSError):
+            tmp.unlink(missing_ok=True)
+        raise

+ 3 - 9
bot/members.py

@@ -5,10 +5,11 @@ from __future__ import annotations
 import asyncio
 import json
 import logging
-import os
 from datetime import datetime
 from pathlib import Path
 
+from .files import write_atomic
+
 logger = logging.getLogger("chatbot.members")
 
 FILE_VERSION = 1
@@ -135,17 +136,10 @@ class MemberRegistry:
         text = json.dumps(payload, ensure_ascii=False, indent=2)
         async with self._save_lock:
             try:
-                await asyncio.to_thread(self._write_atomic, text)
+                await asyncio.to_thread(write_atomic, self.file_path, text)
             except OSError as exc:
                 logger.error("Impossible de sauvegarder le registre des membres : %s", exc)
 
-    def _write_atomic(self, text: str) -> None:
-        # Écrit dans un fichier temporaire puis le renomme : jamais de fichier à moitié écrit.
-        self.file_path.parent.mkdir(parents=True, exist_ok=True)
-        tmp = self.file_path.with_suffix(self.file_path.suffix + ".tmp")
-        tmp.write_text(text, encoding="utf-8")
-        os.replace(tmp, self.file_path)
-
 
 def display_name(entry: dict) -> str:
     """Le nom affiché sur le serveur : pseudo, sinon nom d'affichage, sinon nom d'utilisateur."""

+ 5 - 8
bot/memory.py

@@ -8,11 +8,11 @@ from __future__ import annotations
 
 import asyncio
 import logging
-import os
 import re
 from dataclasses import dataclass
 from pathlib import Path
 
+from .files import write_atomic
 from .members import NAME_KEYS, MemberRegistry, display_name, with_username
 
 logger = logging.getLogger("chatbot.memory")
@@ -373,8 +373,9 @@ class MemoryStore:
 
     @staticmethod
     def _normalize(name: str) -> str:
-        """Accepte aussi « /memories/membres/123.md » ou « membres/123 » pour « membres/123.md »."""
-        name = name.strip().replace("\\", "/").removeprefix("/memories/").lstrip("/")
+        """Accepte aussi « /memories/membres/123.md », « memories/membres/123.md » ou « membres/123 »
+        pour « membres/123.md »."""
+        name = name.strip().replace("\\", "/").lstrip("/").removeprefix("memories/").lstrip("/")
         if name.startswith("membres/") and not name.endswith(".md"):
             name += ".md"
         return name
@@ -400,8 +401,4 @@ class MemoryStore:
 
     @staticmethod
     def _write_atomic(path: Path, text: str) -> None:
-        # Écrit dans un fichier temporaire puis le renomme : jamais de fichier à moitié écrit.
-        path.parent.mkdir(parents=True, exist_ok=True)
-        tmp = path.with_suffix(path.suffix + ".tmp")
-        tmp.write_text(text + "\n", encoding="utf-8")
-        os.replace(tmp, path)
+        write_atomic(path, text + "\n")

+ 5 - 0
changelog.txt

@@ -73,6 +73,8 @@ Corrections
   n'est pas la première de sa fiche (ajoutée à la fin, ou fiche
   modifiée à la main), avec ou sans puce ; plusieurs lignes « Surnoms »
   sont regroupées. Avant, elles étaient ignorées sans rien dire.
+- Mémoire : le chemin « memories/membres/… » (sans « / » au début) est
+  accepté par l'outil, comme « /memories/membres/… ».
 
 Divers
 - Le token Discord et les clés Anthropic et Steam n'apparaissent plus
@@ -80,6 +82,9 @@ Divers
   l'affiche aujourd'hui).
 - Premiers tests automatisés (mémoire des membres) : lancer
   « python -m unittest » à la racine du projet.
+- Fiches et registre des membres envoyés jusqu'au disque avant d'être
+  remplacés : une coupure de courant ne laisse plus de fichier vide.
+  Une écriture ratée ne laisse plus de fichier « .tmp » derrière elle.
 
 
 Version 5.10

+ 46 - 0
tests/test_files.py

@@ -0,0 +1,46 @@
+"""Tests de l'écriture des fichiers. Lancer depuis la racine du projet : python -m unittest"""
+
+import tempfile
+import unittest
+from pathlib import Path
+
+from bot.files import write_atomic
+from bot.memory import MemoryStore
+
+
+class WriteAtomicTest(unittest.TestCase):
+    def setUp(self):
+        self._tmp = tempfile.TemporaryDirectory()
+        self.root = Path(self._tmp.name)
+
+    def tearDown(self):
+        self._tmp.cleanup()
+
+    def test_writes_and_creates_directory(self):
+        path = self.root / "a" / "b.md"
+        write_atomic(path, "habite à Lyon\n")
+        self.assertEqual(path.read_text(encoding="utf-8"), "habite à Lyon\n")
+        self.assertEqual(list(path.parent.iterdir()), [path])
+
+    def test_temporary_file_removed_on_failure(self):
+        path = self.root / "b.md"
+        path.mkdir()  # un dossier à ce nom : le renommage échoue
+        with self.assertRaises(OSError):
+            write_atomic(path, "habite à Lyon")
+        self.assertEqual(list(self.root.iterdir()), [path])
+
+
+class NormalizeTest(unittest.TestCase):
+    def test_accepted_spellings(self):
+        for name in ("membres/123.md", "membres/123", "/memories/membres/123.md", "memories/membres/123.md",
+                     "memories\\membres\\123.md"):
+            self.assertEqual(MemoryStore._normalize(name), "membres/123.md", name)
+        self.assertEqual(MemoryStore._normalize("memories/serveur.md"), "serveur.md")
+
+    def test_no_way_out(self):
+        # Le reste est refusé par _check : seuls serveur.md et membres/<id>.md existent.
+        self.assertEqual(MemoryStore._normalize("memories/../registre.json"), "../registre.json")
+
+
+if __name__ == "__main__":
+    unittest.main()