snapshot_manager.py 3.0 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. Context snapshot manager.
  5. """
  6. from __future__ import annotations
  7. import json
  8. from dataclasses import dataclass
  9. from datetime import datetime, timezone
  10. from filelock import FileLock
  11. from pathlib import Path
  12. from typing import Any, Dict, Optional
  13. from .config import get_config
  14. try:
  15. # 当 scripts 目录在 sys.path 中
  16. from security_utils import atomic_write_json
  17. except ImportError: # pragma: no cover
  18. # 当以 python -m scripts.data_modules... 形式运行
  19. from scripts.security_utils import atomic_write_json
  20. SNAPSHOT_VERSION = "1.1"
  21. class SnapshotVersionMismatch(RuntimeError):
  22. def __init__(self, expected: str, actual: str) -> None:
  23. super().__init__(f"snapshot version mismatch: expected {expected}, got {actual}")
  24. self.expected = expected
  25. self.actual = actual
  26. @dataclass
  27. class SnapshotMeta:
  28. chapter: int
  29. version: str
  30. saved_at: str
  31. class SnapshotManager:
  32. def __init__(self, config=None, version: str = SNAPSHOT_VERSION):
  33. self.config = config or get_config()
  34. self.version = version
  35. self.snapshot_dir = self.config.webnovel_dir / "context_snapshots"
  36. self.snapshot_dir.mkdir(parents=True, exist_ok=True)
  37. def _snapshot_path(self, chapter: int) -> Path:
  38. return self.snapshot_dir / f"ch{chapter:04d}.json"
  39. def _snapshot_lock_path(self, chapter: int) -> Path:
  40. return self._snapshot_path(chapter).with_suffix(".json.lock")
  41. def save_snapshot(self, chapter: int, payload: Dict[str, Any], meta: Optional[Dict[str, Any]] = None) -> Path:
  42. data: Dict[str, Any] = {
  43. "version": self.version,
  44. "chapter": chapter,
  45. "saved_at": datetime.now(timezone.utc).isoformat(),
  46. "payload": payload,
  47. }
  48. if meta:
  49. data["meta"] = meta
  50. path = self._snapshot_path(chapter)
  51. lock = FileLock(str(self._snapshot_lock_path(chapter)), timeout=10)
  52. with lock:
  53. atomic_write_json(path, data, use_lock=False, backup=False)
  54. return path
  55. def load_snapshot(self, chapter: int) -> Optional[Dict[str, Any]]:
  56. path = self._snapshot_path(chapter)
  57. lock = FileLock(str(self._snapshot_lock_path(chapter)), timeout=10)
  58. with lock:
  59. if not path.exists():
  60. return None
  61. data = json.loads(path.read_text(encoding="utf-8"))
  62. version = str(data.get("version", ""))
  63. if version != self.version:
  64. raise SnapshotVersionMismatch(self.version, version)
  65. return data
  66. def delete_snapshot(self, chapter: int) -> bool:
  67. path = self._snapshot_path(chapter)
  68. lock = FileLock(str(self._snapshot_lock_path(chapter)), timeout=10)
  69. with lock:
  70. if path.exists():
  71. path.unlink()
  72. return True
  73. return False
  74. def list_snapshots(self) -> list[str]:
  75. return sorted(p.name for p in self.snapshot_dir.glob("ch*.json"))