Coverage for src/local_deep_research/database/sqlcipher_compat.py: 92%

86 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1""" 

2SQLCipher compatibility module for cross-platform support. 

3 

4Provides a unified interface for importing SQLCipher on different platforms: 

5- x86_64 Linux: Uses sqlcipher3-binary (pre-compiled wheel) 

6- ARM64 Linux: Uses sqlcipher3 (builds from source) 

7- Other platforms: Uses sqlcipher3 

8""" 

9 

10from functools import lru_cache 

11import os 

12import threading 

13 

14 

15_lifecycle_lock = threading.RLock() 

16 

17 

18def _reset_lifecycle_lock(): 

19 """A fork must not inherit a lock owned by a vanished parent thread.""" 

20 global _lifecycle_lock 

21 _lifecycle_lock = threading.RLock() 

22 

23 

24if hasattr(os, "register_at_fork"): 24 ↛ 30line 24 didn't jump to line 30 because the condition on line 24 was always true

25 os.register_at_fork(after_in_child=_reset_lifecycle_lock) 

26 

27 

28# Driver entry points that refuse a foreign thread when a connection is 

29# opened with check_same_thread=True (pysqlite_check_thread in sqlcipher3). 

30_CONNECTION_THREAD_CHECKED = ( 

31 "__call__", 

32 "commit", 

33 "create_aggregate", 

34 "create_collation", 

35 "create_function", 

36 "create_window_function", 

37 "enable_load_extension", 

38 "execute", 

39 "executemany", 

40 "executescript", 

41 "load_extension", 

42 "open_blob", 

43 "rollback", 

44 "set_authorizer", 

45 "set_busy_handler", 

46 "set_busy_timeout", 

47 "set_progress_handler", 

48 "set_trace_callback", 

49) 

50_CURSOR_THREAD_CHECKED = ( 

51 "__next__", 

52 "close", 

53 "execute", 

54 "executemany", 

55 "executescript", 

56 "fetchall", 

57 "fetchmany", 

58 "fetchone", 

59) 

60 

61 

62def _check_owner_thread(connection): 

63 owner = getattr(connection, "_ldr_owner_thread", None) 

64 current = threading.get_ident() 

65 if owner is not None and owner != current: 

66 raise connection.ProgrammingError( 

67 "SQLite objects created in a thread can only be used in that " 

68 f"same thread. The object was created in thread id {owner} " 

69 f"and this is thread id {current}." 

70 ) 

71 

72 

73def _owner_checked(native, connection_of): 

74 def method(self, *args, **kwargs): 

75 _check_owner_thread(connection_of(self)) 

76 return native(self, *args, **kwargs) 

77 

78 method.__name__ = native.__name__ 

79 method.__doc__ = native.__doc__ 

80 return method 

81 

82 

83def _same_object(connection): 

84 return connection 

85 

86 

87def _cursor_connection(cursor): 

88 return cursor.connection 

89 

90 

91@lru_cache(maxsize=8) 

92def _guarded_connection_type(connection_type, cursor_type, check_same_thread): 

93 class LifecycleConnection(connection_type): 

94 def __init__(self, *args, **kwargs): 

95 self._ldr_initialized = False 

96 self._ldr_owner_thread = ( 

97 threading.get_ident() if check_same_thread else None 

98 ) 

99 # The driver's close() refuses a foreign thread when its own 

100 # check_same_thread is on. A connection finalised on another 

101 # thread would then fall through to the native deallocator, 

102 # which closes it with no guard held. Open without the native 

103 # check so __del__ can always close under the guard; the 

104 # caller's requested check is enforced by this wrapper instead. 

105 if len(args) > 4: 105 ↛ 106line 105 didn't jump to line 106 because the condition on line 105 was never true

106 args = (*args[:4], False, *args[5:]) 

107 else: 

108 kwargs["check_same_thread"] = False 

109 with _lifecycle_lock: 

110 super().__init__(*args, **kwargs) 

111 self._ldr_initialized = True 

112 

113 def close(self): 

114 _check_owner_thread(self) 

115 return self._ldr_close() 

116 

117 def _ldr_close(self): 

118 with _lifecycle_lock: 

119 return connection_type.close(self) 

120 

121 def __del__(self): 

122 # The native deallocator also closes an abandoned connection. 

123 # Close it under the same guard first, from whichever thread 

124 # finalises it; the native close has no thread check here. 

125 # A failed constructor has no complete statement/blob lists, so 

126 # calling the driver's close() on it is unsafe. 

127 if getattr(self, "_ldr_initialized", False): 

128 try: 

129 self._ldr_close() 

130 except Exception: # allow: silent-exception 

131 pass # Finalization must not raise during interpreter exit. 

132 

133 if check_same_thread: 

134 

135 class ThreadCheckedCursor(cursor_type): 

136 pass 

137 

138 for name in _CURSOR_THREAD_CHECKED: 

139 if hasattr(cursor_type, name): 139 ↛ 138line 139 didn't jump to line 138 because the condition on line 139 was always true

140 # bearer:disable python_lang_code_injection 

141 # ``name`` only ever comes from the hardcoded 

142 # _CURSOR_THREAD_CHECKED tuple above, never from input. 

143 setattr( 

144 ThreadCheckedCursor, 

145 name, 

146 _owner_checked( 

147 getattr(cursor_type, name), _cursor_connection 

148 ), 

149 ) 

150 for name in _CONNECTION_THREAD_CHECKED: 

151 if hasattr(connection_type, name): 151 ↛ 150line 151 didn't jump to line 150 because the condition on line 151 was always true

152 # bearer:disable python_lang_code_injection 

153 # ``name`` only ever comes from the hardcoded 

154 # _CONNECTION_THREAD_CHECKED tuple above, never from input. 

155 setattr( 

156 LifecycleConnection, 

157 name, 

158 _owner_checked( 

159 getattr(connection_type, name), _same_object 

160 ), 

161 ) 

162 

163 def cursor(self, *args, **kwargs): 

164 _check_owner_thread(self) 

165 if not args and "factory" not in kwargs: 165 ↛ 167line 165 didn't jump to line 167 because the condition on line 165 was always true

166 kwargs["factory"] = ThreadCheckedCursor 

167 return connection_type.cursor(self, *args, **kwargs) 

168 

169 LifecycleConnection.cursor = cursor 

170 

171 # The only property setter the driver thread-checks: setting 

172 # isolation_level to None commits through the native commit, which 

173 # bypasses the wrapped commit() above. Other values and the getter 

174 # stay unchecked, as in the driver. 

175 native_isolation_level = connection_type.isolation_level 

176 

177 def set_isolation_level(self, value): 

178 if value is None: 

179 _check_owner_thread(self) 

180 native_isolation_level.__set__(self, value) 

181 

182 LifecycleConnection.isolation_level = property( 

183 native_isolation_level.__get__, 

184 set_isolation_level, 

185 native_isolation_level.__delete__, 

186 native_isolation_level.__doc__, 

187 ) 

188 

189 return LifecycleConnection 

190 

191 

192def wal_lifecycle_guard(): 

193 """Return the lock that serializes the affected native WAL transitions. 

194 

195 Hold it around statements that close a WAL inside SQLite's pager, such 

196 as ``PRAGMA journal_mode`` leaving WAL mode. Taking it is harmless on 

197 unaffected builds, where nothing else contends for it. 

198 """ 

199 return _lifecycle_lock 

200 

201 

202def connect_sqlcipher(driver, database, **kwargs): 

203 """Open an application SQLCipher connection with native lifecycle safety. 

204 

205 SQLite 3.51.0/3.51.1 can invert its inode and global VFS mutexes when a 

206 WAL connection closes while another connection opens. In sqlcipher3, 

207 close() also holds the GIL, so the whole interpreter can stop. Serialize 

208 these two native entry points for the affected POSIX builds; statements 

209 and transactions continue to run concurrently. SQLite fixed the mutex 

210 order in 3.51.2 (see the official 3.51.2 release notes). Leaving WAL 

211 mode closes the WAL the same way; see ``wal_lifecycle_guard``. 

212 

213 A guarded connection closes under the guard even when another thread 

214 finalises it. To make that possible it is opened without the driver's 

215 own thread check. When the caller asks for ``check_same_thread`` (the 

216 driver default), the wrapper raises the driver's ProgrammingError for 

217 the connection methods the driver checks, for setting 

218 ``isolation_level`` to None (which commits), and for cursors from the 

219 default cursor factory. A blob is checked only when opened. 

220 

221 Only opens and closes made through this function are serialized. 

222 ``ATTACH DATABASE`` opens a file and ``DETACH DATABASE`` closes it inside 

223 a statement, outside the guard. On the affected builds, attaching or 

224 detaching a file while another thread opens or closes the same file 

225 through this function can still deadlock; attaching a different file 

226 does not contend. The application attaches only its temporary backup 

227 file, which no other connection opens while it is attached. 

228 

229 Explicit custom factories retain their own construction/cleanup behavior. 

230 Application connections use the default driver Connection factory. 

231 """ 

232 if os.name == "posix" and getattr(driver, "sqlite_version_info", None) in ( 

233 (3, 51, 0), 

234 (3, 51, 1), 

235 ): 

236 connection_type = driver.Connection 

237 if kwargs.get("factory", connection_type) is connection_type: 

238 kwargs["factory"] = _guarded_connection_type( 

239 connection_type, 

240 driver.Cursor, 

241 bool(kwargs.get("check_same_thread", True)), 

242 ) 

243 return driver.connect(database, **kwargs) 

244 

245 

246def get_sqlcipher_module(): 

247 """ 

248 Get the appropriate SQLCipher module for the current platform. 

249 

250 Returns the sqlcipher3 module (either sqlcipher3-binary or sqlcipher3 

251 depending on platform and what's installed). 

252 

253 Returns: 

254 module: The sqlcipher module with dbapi2 attribute 

255 

256 Raises: 

257 ImportError: If sqlcipher3 is not available 

258 """ 

259 try: 

260 import sqlcipher3 

261 

262 return sqlcipher3 

263 except ImportError: 

264 raise ImportError( 

265 "sqlcipher3 is not installed. " 

266 "Ensure SQLCipher system library is installed, then run: pdm install" 

267 )