TTY OV  1
A cross platform python terminal
Loading...
Searching...
No Matches
sqlite_handler.py
Go to the documentation of this file.
1"""
2# +==== BEGIN polyguard =================+
3# LOGO:
4# input
5#
6# @#$%! hello
7# | |
8# +--+--+
9# |
10# v
11# +------------+
12# | POLY GUARD |
13# +------------+
14# | |
15# v v
16# BLOCKED PASSED
17# KO OK
18# /STOP
19# PROJECT: polyguard
20# FILE: sqlite_handler.py
21# CREATION DATE: 21-03-2026
22# LAST Modified: 19:51:7 21-03-2026
23# DESCRIPTION:
24# A module that provides a set of swearwords to listen to when filtering while allowing to toggle on and off different languages.
25# /STOP
26# COPYRIGHT: (c) Henry Letellier
27# PURPOSE: SQLite handler for PolyGuard word-lists.
28# This module provides a small, explicit handler to manage a read-only or read-write SQLite database containing (lang, word) rows. It keeps a connection open, exposes simple helpers for lookups and bulk inserts, and avoids inline comprehensions to favour legibility.
29# // AR
30# +==== END polyguard =================+
31"""
32
33import os
34import sqlite3
35from threading import Lock
36from typing import Optional, Iterable, Set, Dict
37
38from display_tty import Disp, initialise_logger
39
40from . import constants as POLY_CONST
41
42
44 """Manage a connection to an SQLite database storing language word-lists.
45
46 The expected schema is a table `words(lang TEXT, word TEXT, PRIMARY KEY(lang, word))`.
47 """
48 _instance: Optional["SQLiteHandler"] = None
49 _class_lock: Lock = Lock()
50 disp: Disp = initialise_logger(__qualname__, False)
51
52 def __new__(cls, *args, **kwargs) -> "SQLiteHandler":
53 with cls._class_lock:
54 if cls._instance is None:
55 cls._instance = super().__new__(cls)
56 return cls._instance
57
58 def __init__(self, db_path: str, readonly: bool = True, *, log: bool = True, debug: bool = False) -> None:
59 """Initialize SQLite connection handler.
60
61 Sets up connection parameters but does not open the connection until
62 connect() is called. Supports both read-only and read-write modes.
63
64 Args:
65 db_path: Path to SQLite database file.
66 readonly: If True, opens database in read-only mode. Defaults to True.
67 log: If True, enables logging of database operations. Defaults to True.
68 debug: If True, enables debug-level logging. Defaults to False.
69 """
70 self.db_path = db_path
71 self.readonlyreadonly = readonly
72 # Caller controllable logging flag
73 self.loglog = bool(log)
74 self._conn_conn: Optional[sqlite3.Connection] = None
75 # Per-instance lock to guard connection and operations.
76 self._lock_lock: Lock = Lock()
77 self.disp.update_disp_debug(debug)
78
79 def connect(self) -> None:
80 """Open SQLite database connection.
81
82 If readonly mode is enabled, uses SQLite URI syntax to open in read-only
83 mode. Enables check_same_thread=False to allow multithreaded access
84 (concurrency is controlled by instance lock). If already connected,
85 this method is a no-op.
86
87 Raises:
88 sqlite3.DatabaseError: When connection fails (e.g., file not found in readonly mode).
89 """
90 with self._lock_lock:
91 if self._conn_conn is not None:
92 return
93
94 if self.loglog:
95 self.disp.log_debug(
96 f"Opening SQLite connection (readonly={self.readonly}) to {self.db_path}"
97 )
98
100 uri = f"file:{os.path.abspath(self.db_path)}?mode=ro"
101 # Allow connections to be used from different threads; guard
102 # concurrency with the instance lock instead of SQLite's
103 # thread-checking to support multithreaded callers.
104 self._conn_conn = sqlite3.connect(
105 uri,
106 uri=True,
107 check_same_thread=False
108 )
109 else:
110 self._conn_conn = sqlite3.connect(
111 self.db_path,
112 check_same_thread=False
113 )
114
115 # Use default row factory (tuples) to keep behaviour explicit
116 self._conn_conn.row_factory = None
117 if self.loglog:
118 self.disp.log_info("SQLite connection opened")
119
120 def close(self) -> None:
121 """Close SQLite database connection.
122
123 If not connected, this method is a no-op. Safe to call multiple times.
124 """
125 with self._lock_lock:
126 if self._conn_conn is None:
127 return
128
129 try:
130 if self.loglog:
131 self.disp.log_debug("Closing SQLite connection")
132 self._conn_conn.close()
133 finally:
134 self._conn_conn = None
135 if self.loglog:
136 self.disp.log_info("SQLite connection closed")
137
138 def __enter__(self) -> "SQLiteHandler":
139 """Context manager entry: open database connection.
140
141 Returns:
142 SQLiteHandler: This instance with connection open.
143 """
144 self.connect()
145 return self
146
147 def __exit__(self, exc_type, exc, tb) -> None:
148 """Context manager exit: close database connection.
149
150 Closes connection regardless of whether an exception occurred.
151 """
152 self.close()
153
154 def create_schema(self) -> None:
155 """Create words table if not already present.
156
157 Creates a table with columns (lang TEXT, word TEXT) and a composite
158 primary key on (lang, word). Idempotent — safe to call on existing tables.
159 Requires a writable database connection.
160
161 Raises:
162 RuntimeError: When connection is not open.
163 sqlite3.Error: When table creation fails.
164 """
165 with self._lock_lock:
166 if self._conn_conn is None:
167 raise RuntimeError("Connection is not open")
168
169 if self.loglog:
170 self.disp.log_debug("Creating schema if missing")
171 cur = self._conn_conn.cursor()
172
173 cur.execute(
174 """
175 CREATE TABLE IF NOT EXISTS words (
176 lang TEXT NOT NULL,
177 word TEXT NOT NULL,
178 PRIMARY KEY(lang, word)
179 )
180 """
181 )
182
183 self._conn_conn.commit()
184 if self.loglog:
185 self.disp.log_info("Schema ensured")
186
187 def bulk_insert(self, mapping: Dict[POLY_CONST.Langs, Iterable[str]]) -> int:
188 """Bulk insert words from language to words mapping.
189
190 Normalizes and inserts words from each language. Uses INSERT OR IGNORE
191 to skip duplicate (lang, word) pairs. Returns cumulative row count
192 across all languages provided (sum of per-language totals, not insert count).
193
194 Args:
195 mapping: Dictionary mapping Langs enums to iterables of words.
196
197 Returns:
198 int: Sum of word counts across all languages in mapping after insert.
199
200 Raises:
201 RuntimeError: When connection is not open.
202 sqlite3.Error: When database operation fails (transaction is rolled back).
203 """
204 with self._lock_lock:
205 if self._conn_conn is None:
206 raise RuntimeError("Connection is not open")
207
208 cur = self._conn_conn.cursor()
209
210 inserted = 0
211
212 try:
213 if self.loglog:
214 self.disp.log_debug("Beginning bulk insert")
215 cur.execute("BEGIN")
216
217 for lang_key, words in mapping.items():
218 # Only accept known Langs values for clarity
219 if not isinstance(lang_key, POLY_CONST.Langs):
220 continue
221
222 lang_text = lang_key.value
223
224 for w in words:
225 if w is None:
226 continue
227
228 word = w.strip().lower()
229
230 if not word:
231 continue
232
233 # Use INSERT OR IGNORE to avoid duplicate key errors
234 cur.execute(
235 "INSERT OR IGNORE INTO words (lang, word) VALUES (?, ?)",
236 (lang_text, word),
237 )
238
239 # rowcount is not reliable for executemany with OR IGNORE; commit and
240 # compute delta using a simple count query if needed.
241 self._conn_conn.commit()
242
243 # Compute inserted by counting rows for provided langs
244 for lang_key in mapping.keys():
245 if not isinstance(lang_key, POLY_CONST.Langs):
246 continue
247
248 cur.execute(
249 "SELECT COUNT(1) FROM words WHERE lang = ?",
250 (lang_key.value,)
251 )
252 row = cur.fetchone()
253
254 if row is None:
255 continue
256
257 count = int(row[0])
258 inserted += count
259
260 if self.loglog:
261 self.disp.log_info(
262 f"Bulk insert complete; total rows (per-lang sum)={inserted}"
263 )
264
265 except sqlite3.Error as exc:
266 if self.loglog:
267 self.disp.log_error(f"Bulk insert failed: {exc}")
268 self._conn_conn.rollback()
269 raise
270
271 return inserted
272
273 def get_words(self, lang: POLY_CONST.Langs) -> Set[str]:
274 """Retrieve all words for a specific language from database.
275
276 Returns an empty set if the database is empty or the language has no entries.
277
278 Args:
279 lang: Langs enum member specifying which language to retrieve.
280
281 Returns:
282 Set[str]: Set of all words for the language.
283
284 Raises:
285 RuntimeError: When connection is not open.
286 """
287 with self._lock_lock:
288 if self._conn_conn is None:
289 raise RuntimeError("Connection is not open")
290
291 if self.loglog:
292 self.disp.log_debug(f"Fetching words for lang={lang.value}")
293 cur = self._conn_conn.cursor()
294
295 cur.execute("SELECT word FROM words WHERE lang = ?", (lang.value,))
296
297 rows = cur.fetchall()
298
299 result: Set[str] = set()
300
301 for row in rows:
302 if not row:
303 continue
304
305 word = row[0]
306
307 if word is None:
308 continue
309
310 result.add(word)
311
312 if self.loglog:
313 self.disp.log_info(
314 f"Found {len(result)} words for lang={lang.value}"
315 )
316 return result
317
318 def list_languages(self) -> Dict[str, int]:
319 """List all languages and their word counts in database.
320
321 Queries all distinct language codes and returns a mapping of code to word count.
322 Returns an empty dict if the database is empty or the words table is missing.
323
324 Returns:
325 Dict[str, int]: Mapping of language code strings to word counts.
326
327 Raises:
328 RuntimeError: When connection is not open.
329 """
330 with self._lock_lock:
331 if self._conn_conn is None:
332 raise RuntimeError("Connection is not open")
333
334 cur = self._conn_conn.cursor()
335 cur.execute("SELECT lang, COUNT(1) FROM words GROUP BY lang")
336 rows = cur.fetchall()
337
338 result: Dict[str, int] = {}
339
340 for row in rows:
341 if not row:
342 continue
343
344 lang = row[0]
345 count = 0
346 try:
347 count = 0
348 if row[1] is not None:
349 count = int(row[1])
350 except (ValueError, TypeError):
351 count = 0
352
353 result[lang] = count
354
355 if self.loglog:
356 self.disp.log_info(f"Languages in DB: {len(result)} found")
357
358 return result
359
360 def has_word(self, lang: POLY_CONST.Langs, word: str) -> bool:
361 """Check if a word exists in a specific language.
362
363 Normalizes the input word (lowercase, strip whitespace) before checking.
364 Returns False for empty or whitespace-only inputs.
365
366 Args:
367 lang: Langs enum member specifying which language to search.
368 word: Word string to check.
369
370 Returns:
371 bool: True if word is present for the language, False otherwise.
372
373 Raises:
374 RuntimeError: When connection is not open.
375 """
376 with self._lock_lock:
377 if self._conn_conn is None:
378 raise RuntimeError("Connection is not open")
379
380 cur = self._conn_conn.cursor()
381
382 text = word.strip().lower()
383
384 if not text:
385 return False
386
387 if self.loglog:
388 self.disp.log_debug(
389 f"Checking existence for word={text!r} in lang={lang.value}"
390 )
391 cur.execute(
392 "SELECT 1 FROM words WHERE lang = ? AND word = ? LIMIT 1",
393 (lang.value, text)
394 )
395
396 row = cur.fetchone()
397
398 found = row is not None
399 if found and self.loglog:
400 self.disp.log_info(
401 f"Word matched: {text!r} (lang={lang.value})"
402 )
403
404 return found
None __exit__(self, exc_type, exc, tb)
None __init__(self, str db_path, bool readonly=True, *, bool log=True, bool debug=False)
"SQLiteHandler" __new__(cls, *args, **kwargs)
bool has_word(self, POLY_CONST.Langs lang, str word)
int bulk_insert(self, Dict[POLY_CONST.Langs, Iterable[str]] mapping)
Set[str] get_words(self, POLY_CONST.Langs lang)