Coverage for pytest_recap/storage.py: 72%
72 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-06-16 00:19 -0600
« prev ^ index » next coverage.py v7.8.0, created at 2025-06-16 00:19 -0600
1import json
2import os
3import threading
4from pathlib import Path
5from typing import List, Optional
7from filelock import FileLock
10class JSONStorage:
11 """
12 Stores test sessions in a local JSON file, supporting both single-session (dict) and multi-session (list) modes.
13 - Single-session mode (used by the pytest plugin): writes a single session as a dict, overwriting the file.
14 - Multi-session/archive mode: appends sessions to a list, allowing for archival of multiple sessions in one file.
15 - Thread/process-safe via file locking.
17 Args:
18 file_path (Optional[str]): Path to the JSON file. Defaults to ~/.pytest_recap/sessions.json
20 Methods:
21 save_session(session_data: dict, single: bool = False):
22 Appends session_data to the file as a list (default), or overwrites as a dict if single=True.
23 save_single_session(session_data: dict):
24 Overwrites the file with a single session dict (for plugin recap output).
25 load_sessions(lock: bool = True) -> List[dict]:
26 Loads all sessions as a list (returns [] if file is a dict or empty).
28 Example usage:
29 storage = JSONStorage(file_path="sessions.json")
30 storage.save_session(session_dict) # archive mode
31 storage.save_single_session(session_dict) # single recap file
32 """
34 def __init__(self, file_path: Optional[str] = None):
35 self.file_path = Path(file_path) if file_path else Path.home() / ".pytest_recap" / "sessions.json"
36 self.file_path.parent.mkdir(parents=True, exist_ok=True)
37 self.lock_path = f"{self.file_path}.lock"
38 self._thread_lock = threading.RLock()
39 if not self.file_path.exists():
40 # Only lock here if other processes could create at the same time
41 with FileLock(self.lock_path):
42 self._write_json([])
44 def save_session(self, session_data: dict, single: bool = False, indent=2) -> None:
45 """
46 Save a session. If single=True, write as a dict (overwrite file). If False (default), append to list (archive mode).
47 In archive mode, always writes a list. If the file is a dict or empty, starts a new list.
48 Propagates PermissionError if file is not writable.
49 """
50 with self._thread_lock:
51 with FileLock(self.lock_path):
52 if single:
53 self._write_json(session_data, indent=indent)
54 else:
55 try:
56 sessions = self.load_sessions(lock=False)
57 if not isinstance(sessions, list):
58 sessions = []
59 except Exception:
60 sessions = []
61 sessions.append(session_data)
62 self._write_json(sessions, indent=indent)
63 # Only clean up lock files if the above succeeded
64 self._cleanup_zero_byte_lock_files()
66 def save_single_session(self, session_data: dict, indent=2) -> None:
67 """
68 Save a single session as a dict (overwrite file). For plugin recap output.
69 """
70 with self._thread_lock:
71 self.save_session(session_data, single=True, indent=indent)
72 self._cleanup_zero_byte_lock_files()
74 def load_sessions(self, lock: bool = True) -> List[dict]:
75 with self._thread_lock:
76 if lock:
77 with FileLock(self.lock_path):
78 return self._load_sessions_unlocked()
79 else:
80 return self._load_sessions_unlocked()
82 def _load_sessions_unlocked(self) -> List[dict]:
83 """
84 Load sessions from the JSON file. Returns a list of sessions, or [] if file missing/corrupt.
85 Uses filelock for safety in load_sessions().
86 """
87 try:
88 with open(self.file_path, "r", encoding="utf-8") as f:
89 data = json.load(f)
90 # Accept both list and dict (legacy), but always return a list
91 if isinstance(data, list):
92 return data
93 elif isinstance(data, dict):
94 # Only accept dicts with a 'sessions' key containing a list
95 if "sessions" in data and isinstance(data["sessions"], list):
96 return data["sessions"]
97 else:
98 return []
99 else:
100 return []
101 except (FileNotFoundError, json.JSONDecodeError):
102 return []
104 def _write_json(self, data, indent=2) -> None:
105 """
106 Atomically write JSON data to self.file_path using a temp file and os.replace for durability and crash-safety.
107 Raises PermissionError if the file is not writable.
108 """
109 tmp_path = self.file_path.with_suffix(".tmp")
110 try:
111 with open(tmp_path, "w", encoding="utf-8") as f:
112 json.dump(data, f, indent=indent)
113 f.flush()
114 os.fsync(f.fileno())
115 os.replace(str(tmp_path), str(self.file_path))
116 except PermissionError:
117 # Explicitly propagate PermissionError for test expectations
118 raise
120 def _cleanup_zero_byte_lock_files(self):
121 """
122 Delete all 0-byte .lock files in the session directory. This prevents accumulation of orphaned lock files.
123 Only 0-byte files are deleted; non-empty lock files are left untouched for safety.
124 """
125 session_dir = self.file_path.parent
126 for fname in os.listdir(session_dir):
127 if fname.endswith(".lock"):
128 fpath = session_dir / fname
129 try:
130 if os.path.isfile(fpath) and os.path.getsize(fpath) == 0:
131 os.remove(fpath)
132 except Exception:
133 pass # Ignore errors during cleanup