"""Ghi JSON kiểu không-hỏng-file — R02-T01. Vấn đề đang có: ``config.py::save()`` gọi thẳng ``path.write_text(...)``. Hàm đó mở file, cắt cụt về 0 byte, rồi mới ghi nội dung mới. Mất điện, tắt máy, hay process bị kill đúng khoảng giữa thì file cấu hình còn lại **rỗng hoặc ghi dở** — và người dùng mất toàn bộ cấu hình. Cách làm ở đây theo đúng thứ tự bắt buộc: 1. Ghi vào file tạm cùng thư mục (phải cùng ổ đĩa thì bước 3 mới nguyên tử) 2. ``flush()`` + ``os.fsync()`` — ép dữ liệu xuống đĩa thật, không nằm trong bộ đệm của hệ điều hành 3. ``os.replace()`` — nguyên tử trên cả Windows lẫn POSIX Bất kỳ lúc nào chết giữa chừng, file đích vẫn là **bản cũ nguyên vẹn**. Không bao giờ có trạng thái ghi dở. Phần đọc có chính sách phục hồi: file hỏng thì giữ lại thành ``.bad`` để còn cứu tay, rồi trả về giá trị mặc định — hỏng cấu hình không được chặn khởi động, đúng như ``config.py`` hiện tại đang làm. """ from __future__ import annotations import json import os import tempfile from datetime import datetime from pathlib import Path from typing import Any class AtomicJsonFile: """Một file JSON, đọc ghi an toàn. >>> f = AtomicJsonFile(Path("cau_hinh.json")) >>> f.write({"theme": "dark"}) >>> f.read(default={}) {'theme': 'dark'} """ def __init__(self, path: Path, *, indent: int = 2): self.path = Path(path) self.indent = indent # ---- đọc ------------------------------------------------------------ def read(self, default: Any = None) -> Any: """Nội dung file, hoặc ``default`` nếu chưa có / hỏng. Không ném lỗi. File hỏng được đổi tên thành ``.bad-`` rồi mới trả mặc định — hỏng thì cứu được, chứ đừng ghi đè im lặng. """ if not self.path.exists(): return default try: return json.loads(self.path.read_text(encoding="utf-8")) except (json.JSONDecodeError, UnicodeDecodeError): self._quarantine() return default except OSError: # Không đọc được (khoá file, mất quyền) — KHÔNG cách ly, vì file # có thể vẫn tốt nguyên. return default def _quarantine(self) -> Path | None: stamp = datetime.now().strftime("%Y%m%d-%H%M%S") target = self.path.with_suffix(self.path.suffix + f".bad-{stamp}") try: os.replace(self.path, target) return target except OSError: return None # ---- ghi ------------------------------------------------------------ def write(self, data: Any) -> None: """Ghi ``data``. Hoặc thành công trọn vẹn, hoặc file cũ còn nguyên.""" self.path.parent.mkdir(parents=True, exist_ok=True) text = json.dumps(data, indent=self.indent, ensure_ascii=False) # File tạm phải nằm CÙNG thư mục: os.replace chỉ nguyên tử trong cùng # một hệ thống tệp. Để ở %TEMP% là có thể rơi sang ổ khác và biến # thành copy + delete — mất luôn tính nguyên tử. fd, tmp_name = tempfile.mkstemp( dir=str(self.path.parent), prefix=f".{self.path.name}.", suffix=".tmp") tmp = Path(tmp_name) try: with os.fdopen(fd, "w", encoding="utf-8") as f: f.write(text) f.flush() os.fsync(f.fileno()) # xuống đĩa thật, không chỉ vào bộ đệm os.replace(tmp, self.path) # nguyên tử except BaseException: # Kể cả KeyboardInterrupt/SystemExit cũng phải dọn file tạm, đừng # để rác .tmp nằm lại cạnh file cấu hình. tmp.unlink(missing_ok=True) raise # ---- tiện ích ------------------------------------------------------- def exists(self) -> bool: return self.path.exists() def __repr__(self) -> str: return f"AtomicJsonFile({self.path})"