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
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
1"""
2SQLCipher compatibility module for cross-platform support.
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"""
10from functools import lru_cache
11import os
12import threading
15_lifecycle_lock = threading.RLock()
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()
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)
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)
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 )
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)
78 method.__name__ = native.__name__
79 method.__doc__ = native.__doc__
80 return method
83def _same_object(connection):
84 return connection
87def _cursor_connection(cursor):
88 return cursor.connection
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
113 def close(self):
114 _check_owner_thread(self)
115 return self._ldr_close()
117 def _ldr_close(self):
118 with _lifecycle_lock:
119 return connection_type.close(self)
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.
133 if check_same_thread:
135 class ThreadCheckedCursor(cursor_type):
136 pass
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 )
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)
169 LifecycleConnection.cursor = cursor
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
177 def set_isolation_level(self, value):
178 if value is None:
179 _check_owner_thread(self)
180 native_isolation_level.__set__(self, value)
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 )
189 return LifecycleConnection
192def wal_lifecycle_guard():
193 """Return the lock that serializes the affected native WAL transitions.
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
202def connect_sqlcipher(driver, database, **kwargs):
203 """Open an application SQLCipher connection with native lifecycle safety.
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``.
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.
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.
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)
246def get_sqlcipher_module():
247 """
248 Get the appropriate SQLCipher module for the current platform.
250 Returns the sqlcipher3 module (either sqlcipher3-binary or sqlcipher3
251 depending on platform and what's installed).
253 Returns:
254 module: The sqlcipher module with dbapi2 attribute
256 Raises:
257 ImportError: If sqlcipher3 is not available
258 """
259 try:
260 import sqlcipher3
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 )