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

1import json 

2import os 

3import threading 

4from pathlib import Path 

5from typing import List, Optional 

6 

7from filelock import FileLock 

8 

9 

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. 

16 

17 Args: 

18 file_path (Optional[str]): Path to the JSON file. Defaults to ~/.pytest_recap/sessions.json 

19 

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). 

27 

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 """ 

33 

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([]) 

43 

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() 

65 

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() 

73 

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() 

81 

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 [] 

103 

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 

119 

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