bouquin 0.1.0__tar.gz → 0.1.9__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,8 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: bouquin
3
- Version: 0.1.0
3
+ Version: 0.1.9
4
4
  Summary: Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher.
5
+ Home-page: https://git.mig5.net/mig5/bouquin
5
6
  License: GPL-3.0-or-later
6
7
  Author: Miguel Jacq
7
8
  Author-email: mig@mig5.net
@@ -14,6 +15,7 @@ Classifier: Programming Language :: Python :: 3.11
14
15
  Classifier: Programming Language :: Python :: 3.12
15
16
  Requires-Dist: pyside6 (>=6.8.1,<7.0.0)
16
17
  Requires-Dist: sqlcipher3-wheels (>=0.5.5.post0,<0.6.0)
18
+ Project-URL: Repository, https://git.mig5.net/mig5/bouquin
17
19
  Description-Content-Type: text/markdown
18
20
 
19
21
  # Bouquin
@@ -27,7 +29,7 @@ It uses [SQLCipher bindings](https://pypi.org/project/sqlcipher3-wheels) as a dr
27
29
  for SQLite3. This means that the underlying database for the notebook is encrypted at rest.
28
30
 
29
31
  To increase security, the SQLCipher key is requested when the app is opened, and is not written
30
- to disk.
32
+ to disk unless the user configures it to be in the settings.
31
33
 
32
34
  There is deliberately no network connectivity or syncing intended.
33
35
 
@@ -37,23 +39,26 @@ There is deliberately no network connectivity or syncing intended.
37
39
 
38
40
  ## Features
39
41
 
42
+ * Data is encrypted at rest
43
+ * Encryption key is prompted for and never stored, unless user chooses to via Settings
40
44
  * Every 'page' is linked to the calendar day
41
- * Basic markdown
45
+ * All changes are version controlled, with ability to view/diff versions and revert
46
+ * Text is HTML with basic styling
47
+ * Search
42
48
  * Automatic periodic saving (or explicitly save)
43
- * Navigating from one day to the next automatically saves
44
- * Basic keyboard shortcuts
45
49
  * Transparent integrity checking of the database when it opens
50
+ * Automatic locking of the app after a period of inactivity (default 15 min)
51
+ * Rekey the database (change the password)
52
+ * Export the database to json, txt, html or csv
46
53
 
47
54
 
48
- ## Yet to do
55
+ ## How to install
49
56
 
50
- * Search
51
- * Taxonomy/tagging
52
- * Ability to change the SQLCipher key
53
- * Export to other formats (plaintext, json, sql etc)
57
+ Make sure you have `libxcb-cursor0` installed (it may be called something else on non-Debian distributions).
54
58
 
59
+ ### From PyPi/pip
55
60
 
56
- ## How to install
61
+ * `pip install bouquin`
57
62
 
58
63
  ### From source
59
64
 
@@ -66,15 +71,10 @@ There is deliberately no network connectivity or syncing intended.
66
71
 
67
72
  * Download the whl and run it
68
73
 
69
- ### From PyPi
70
-
71
- * `pip install bouquin`
72
-
73
-
74
74
  ## How to run the tests
75
75
 
76
76
  * Clone the repo
77
77
  * Ensure you have poetry installed
78
78
  * Run `poetry install --with test`
79
- * Run `poetry run pytest -vvv`
79
+ * Run `poetry run pytest -vvvv --cov=bouquin`
80
80
 
@@ -9,7 +9,7 @@ It uses [SQLCipher bindings](https://pypi.org/project/sqlcipher3-wheels) as a dr
9
9
  for SQLite3. This means that the underlying database for the notebook is encrypted at rest.
10
10
 
11
11
  To increase security, the SQLCipher key is requested when the app is opened, and is not written
12
- to disk.
12
+ to disk unless the user configures it to be in the settings.
13
13
 
14
14
  There is deliberately no network connectivity or syncing intended.
15
15
 
@@ -19,23 +19,26 @@ There is deliberately no network connectivity or syncing intended.
19
19
 
20
20
  ## Features
21
21
 
22
+ * Data is encrypted at rest
23
+ * Encryption key is prompted for and never stored, unless user chooses to via Settings
22
24
  * Every 'page' is linked to the calendar day
23
- * Basic markdown
25
+ * All changes are version controlled, with ability to view/diff versions and revert
26
+ * Text is HTML with basic styling
27
+ * Search
24
28
  * Automatic periodic saving (or explicitly save)
25
- * Navigating from one day to the next automatically saves
26
- * Basic keyboard shortcuts
27
29
  * Transparent integrity checking of the database when it opens
30
+ * Automatic locking of the app after a period of inactivity (default 15 min)
31
+ * Rekey the database (change the password)
32
+ * Export the database to json, txt, html or csv
28
33
 
29
34
 
30
- ## Yet to do
35
+ ## How to install
31
36
 
32
- * Search
33
- * Taxonomy/tagging
34
- * Ability to change the SQLCipher key
35
- * Export to other formats (plaintext, json, sql etc)
37
+ Make sure you have `libxcb-cursor0` installed (it may be called something else on non-Debian distributions).
36
38
 
39
+ ### From PyPi/pip
37
40
 
38
- ## How to install
41
+ * `pip install bouquin`
39
42
 
40
43
  ### From source
41
44
 
@@ -48,14 +51,9 @@ There is deliberately no network connectivity or syncing intended.
48
51
 
49
52
  * Download the whl and run it
50
53
 
51
- ### From PyPi
52
-
53
- * `pip install bouquin`
54
-
55
-
56
54
  ## How to run the tests
57
55
 
58
56
  * Clone the repo
59
57
  * Ensure you have poetry installed
60
58
  * Run `poetry install --with test`
61
- * Run `poetry run pytest -vvv`
59
+ * Run `poetry run pytest -vvvv --cov=bouquin`
@@ -0,0 +1,480 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import html
5
+ import json
6
+ import os
7
+
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from sqlcipher3 import dbapi2 as sqlite
11
+ from typing import List, Sequence, Tuple
12
+
13
+ Entry = Tuple[str, str]
14
+
15
+
16
+ @dataclass
17
+ class DBConfig:
18
+ path: Path
19
+ key: str
20
+ idle_minutes: int = 15 # 0 = never lock
21
+
22
+
23
+ class DBManager:
24
+ def __init__(self, cfg: DBConfig):
25
+ self.cfg = cfg
26
+ self.conn: sqlite.Connection | None = None
27
+
28
+ def connect(self) -> bool:
29
+ """
30
+ Open, decrypt and install schema on the database.
31
+ """
32
+ # Ensure parent dir exists
33
+ self.cfg.path.parent.mkdir(parents=True, exist_ok=True)
34
+ self.conn = sqlite.connect(str(self.cfg.path))
35
+ self.conn.row_factory = sqlite.Row
36
+ cur = self.conn.cursor()
37
+ cur.execute(f"PRAGMA key = '{self.cfg.key}';")
38
+ cur.execute("PRAGMA foreign_keys = ON;")
39
+ cur.execute("PRAGMA journal_mode = WAL;").fetchone()
40
+ try:
41
+ self._integrity_ok()
42
+ except Exception:
43
+ self.conn.close()
44
+ self.conn = None
45
+ return False
46
+ self._ensure_schema()
47
+ return True
48
+
49
+ def _integrity_ok(self) -> bool:
50
+ """
51
+ Runs the cipher_integrity_check PRAGMA on the database.
52
+ """
53
+ cur = self.conn.cursor()
54
+ cur.execute("PRAGMA cipher_integrity_check;")
55
+ rows = cur.fetchall()
56
+
57
+ # OK: nothing returned
58
+ if not rows:
59
+ return
60
+
61
+ # Not OK: rows of problems returned
62
+ details = "; ".join(str(r[0]) for r in rows if r and r[0] is not None)
63
+ raise sqlite.IntegrityError(
64
+ "SQLCipher integrity check failed"
65
+ + (f": {details}" if details else f" ({len(rows)} issue(s) reported)")
66
+ )
67
+
68
+ def _ensure_schema(self) -> None:
69
+ """
70
+ Install the expected schema on the database.
71
+ We also handle upgrades here.
72
+ """
73
+ cur = self.conn.cursor()
74
+ # Always keep FKs on
75
+ cur.execute("PRAGMA foreign_keys = ON;")
76
+
77
+ # Create new versioned schema if missing (< 0.1.5)
78
+ cur.executescript(
79
+ """
80
+ CREATE TABLE IF NOT EXISTS pages (
81
+ date TEXT PRIMARY KEY, -- yyyy-MM-dd
82
+ current_version_id INTEGER,
83
+ FOREIGN KEY(current_version_id) REFERENCES versions(id) ON DELETE SET NULL
84
+ );
85
+
86
+ CREATE TABLE IF NOT EXISTS versions (
87
+ id INTEGER PRIMARY KEY,
88
+ date TEXT NOT NULL, -- FK to pages.date
89
+ version_no INTEGER NOT NULL, -- 1,2,3… per date
90
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
91
+ note TEXT,
92
+ content TEXT NOT NULL,
93
+ FOREIGN KEY(date) REFERENCES pages(date) ON DELETE CASCADE
94
+ );
95
+
96
+ CREATE UNIQUE INDEX IF NOT EXISTS ux_versions_date_ver ON versions(date, version_no);
97
+ CREATE INDEX IF NOT EXISTS ix_versions_date_created ON versions(date, created_at);
98
+ """
99
+ )
100
+
101
+ # If < 0.1.5 'entries' table exists and nothing has been migrated yet, try to migrate.
102
+ pre_0_1_5 = cur.execute(
103
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name='entries';"
104
+ ).fetchone()
105
+ pages_empty = cur.execute("SELECT 1 FROM pages LIMIT 1;").fetchone() is None
106
+
107
+ if pre_0_1_5 and pages_empty:
108
+ # Seed pages and versions (all as version 1)
109
+ cur.execute("INSERT OR IGNORE INTO pages(date) SELECT date FROM entries;")
110
+ cur.execute(
111
+ "INSERT INTO versions(date, version_no, content) "
112
+ "SELECT date, 1, content FROM entries;"
113
+ )
114
+ # Point head to v1 for each page
115
+ cur.execute(
116
+ """
117
+ UPDATE pages
118
+ SET current_version_id = (
119
+ SELECT v.id FROM versions v
120
+ WHERE v.date = pages.date AND v.version_no = 1
121
+ );
122
+ """
123
+ )
124
+ cur.execute("DROP TABLE IF EXISTS entries;")
125
+ self.conn.commit()
126
+
127
+ def rekey(self, new_key: str) -> None:
128
+ """
129
+ Change the SQLCipher passphrase in-place, then reopen the connection
130
+ with the new key to verify.
131
+ """
132
+ if self.conn is None:
133
+ raise RuntimeError("Database is not connected")
134
+ cur = self.conn.cursor()
135
+ # Change the encryption key of the currently open database
136
+ cur.execute(f"PRAGMA rekey = '{new_key}';").fetchone()
137
+ self.conn.commit()
138
+
139
+ # Close and reopen with the new key to verify and restore PRAGMAs
140
+ self.conn.close()
141
+ self.conn = None
142
+ self.cfg.key = new_key
143
+ if not self.connect():
144
+ raise sqlite.Error("Re-open failed after rekey")
145
+
146
+ def get_entry(self, date_iso: str) -> str:
147
+ """
148
+ Get a single entry by its date.
149
+ """
150
+ cur = self.conn.cursor()
151
+ row = cur.execute(
152
+ """
153
+ SELECT v.content
154
+ FROM pages p
155
+ JOIN versions v ON v.id = p.current_version_id
156
+ WHERE p.date = ?;
157
+ """,
158
+ (date_iso,),
159
+ ).fetchone()
160
+ return row[0] if row else ""
161
+
162
+ def upsert_entry(self, date_iso: str, content: str) -> None:
163
+ """
164
+ Insert or update an entry.
165
+ """
166
+ # Make a new version and set it as current
167
+ self.save_new_version(date_iso, content, note=None, set_current=True)
168
+
169
+ def search_entries(self, text: str) -> list[str]:
170
+ """
171
+ Search for entries by term. This only works against the latest
172
+ version of the page.
173
+ """
174
+ cur = self.conn.cursor()
175
+ pattern = f"%{text}%"
176
+ rows = cur.execute(
177
+ """
178
+ SELECT p.date, v.content
179
+ FROM pages AS p
180
+ JOIN versions AS v
181
+ ON v.id = p.current_version_id
182
+ WHERE TRIM(v.content) <> ''
183
+ AND v.content LIKE LOWER(?) ESCAPE '\\'
184
+ ORDER BY p.date DESC;
185
+ """,
186
+ (pattern,),
187
+ ).fetchall()
188
+ return [(r[0], r[1]) for r in rows]
189
+
190
+ def dates_with_content(self) -> list[str]:
191
+ """
192
+ Find all entries and return the dates of them.
193
+ This is used to mark the calendar days in bold if they contain entries.
194
+ """
195
+ cur = self.conn.cursor()
196
+ rows = cur.execute(
197
+ """
198
+ SELECT p.date
199
+ FROM pages p
200
+ JOIN versions v ON v.id = p.current_version_id
201
+ WHERE TRIM(v.content) <> ''
202
+ ORDER BY p.date;
203
+ """
204
+ ).fetchall()
205
+ return [r[0] for r in rows]
206
+
207
+ # ------------------------- Versioning logic here ------------------------#
208
+ def save_new_version(
209
+ self,
210
+ date_iso: str,
211
+ content: str,
212
+ note: str | None = None,
213
+ set_current: bool = True,
214
+ ) -> tuple[int, int]:
215
+ """
216
+ Append a new version for this date. Returns (version_id, version_no).
217
+ If set_current=True, flips the page head to this new version.
218
+ """
219
+ if self.conn is None:
220
+ raise RuntimeError("Database is not connected")
221
+ with self.conn: # transaction
222
+ cur = self.conn.cursor()
223
+ # Ensure page row exists
224
+ cur.execute("INSERT OR IGNORE INTO pages(date) VALUES (?);", (date_iso,))
225
+ # Next version number
226
+ row = cur.execute(
227
+ "SELECT COALESCE(MAX(version_no), 0) AS maxv FROM versions WHERE date=?;",
228
+ (date_iso,),
229
+ ).fetchone()
230
+ next_ver = int(row["maxv"]) + 1
231
+ # Insert the version
232
+ cur.execute(
233
+ "INSERT INTO versions(date, version_no, content, note) "
234
+ "VALUES (?,?,?,?);",
235
+ (date_iso, next_ver, content, note),
236
+ )
237
+ ver_id = cur.lastrowid
238
+ if set_current:
239
+ cur.execute(
240
+ "UPDATE pages SET current_version_id=? WHERE date=?;",
241
+ (ver_id, date_iso),
242
+ )
243
+ return ver_id, next_ver
244
+
245
+ def list_versions(self, date_iso: str) -> list[dict]:
246
+ """
247
+ Returns history for a given date (newest first), including which one is current.
248
+ Each item: {id, version_no, created_at, note, is_current}
249
+ """
250
+ cur = self.conn.cursor()
251
+ rows = cur.execute(
252
+ """
253
+ SELECT v.id, v.version_no, v.created_at, v.note,
254
+ CASE WHEN v.id = p.current_version_id THEN 1 ELSE 0 END AS is_current
255
+ FROM versions v
256
+ LEFT JOIN pages p ON p.date = v.date
257
+ WHERE v.date = ?
258
+ ORDER BY v.version_no DESC;
259
+ """,
260
+ (date_iso,),
261
+ ).fetchall()
262
+ return [dict(r) for r in rows]
263
+
264
+ def get_version(
265
+ self,
266
+ *,
267
+ date_iso: str | None = None,
268
+ version_no: int | None = None,
269
+ version_id: int | None = None,
270
+ ) -> dict | None:
271
+ """
272
+ Fetch a specific version by (date, version_no) OR by version_id.
273
+ Returns a dict with keys: id, date, version_no, created_at, note, content.
274
+ """
275
+ cur = self.conn.cursor()
276
+ if version_id is not None:
277
+ row = cur.execute(
278
+ "SELECT id, date, version_no, created_at, note, content "
279
+ "FROM versions WHERE id=?;",
280
+ (version_id,),
281
+ ).fetchone()
282
+ else:
283
+ if date_iso is None or version_no is None:
284
+ raise ValueError(
285
+ "Provide either version_id OR (date_iso and version_no)"
286
+ )
287
+ row = cur.execute(
288
+ "SELECT id, date, version_no, created_at, note, content "
289
+ "FROM versions WHERE date=? AND version_no=?;",
290
+ (date_iso, version_no),
291
+ ).fetchone()
292
+ return dict(row) if row else None
293
+
294
+ def revert_to_version(
295
+ self,
296
+ date_iso: str,
297
+ *,
298
+ version_no: int | None = None,
299
+ version_id: int | None = None,
300
+ ) -> None:
301
+ """
302
+ Point the page head (pages.current_version_id) to an existing version.
303
+ Fast revert: no content is rewritten.
304
+ """
305
+ if self.conn is None:
306
+ raise RuntimeError("Database is not connected")
307
+ cur = self.conn.cursor()
308
+
309
+ if version_id is None:
310
+ if version_no is None:
311
+ raise ValueError("Provide version_no or version_id")
312
+ row = cur.execute(
313
+ "SELECT id FROM versions WHERE date=? AND version_no=?;",
314
+ (date_iso, version_no),
315
+ ).fetchone()
316
+ if row is None:
317
+ raise ValueError("Version not found for this date")
318
+ version_id = int(row["id"])
319
+ else:
320
+ # Ensure that version_id belongs to the given date
321
+ row = cur.execute(
322
+ "SELECT date FROM versions WHERE id=?;", (version_id,)
323
+ ).fetchone()
324
+ if row is None or row["date"] != date_iso:
325
+ raise ValueError("version_id does not belong to the given date")
326
+
327
+ with self.conn:
328
+ cur.execute(
329
+ "UPDATE pages SET current_version_id=? WHERE date=?;",
330
+ (version_id, date_iso),
331
+ )
332
+
333
+ # ------------------------- Export logic here ------------------------#
334
+ def get_all_entries(self) -> List[Entry]:
335
+ """
336
+ Get all entries. Used for exports.
337
+ """
338
+ cur = self.conn.cursor()
339
+ rows = cur.execute(
340
+ """
341
+ SELECT p.date, v.content
342
+ FROM pages p
343
+ JOIN versions v ON v.id = p.current_version_id
344
+ ORDER BY p.date;
345
+ """
346
+ ).fetchall()
347
+ return [(r[0], r[1]) for r in rows]
348
+
349
+ def export_json(
350
+ self, entries: Sequence[Entry], file_path: str, pretty: bool = True
351
+ ) -> None:
352
+ """
353
+ Export to json.
354
+ """
355
+ data = [{"date": d, "content": c} for d, c in entries]
356
+ with open(file_path, "w", encoding="utf-8") as f:
357
+ if pretty:
358
+ json.dump(data, f, ensure_ascii=False, indent=2)
359
+ else:
360
+ json.dump(data, f, ensure_ascii=False, separators=(",", ":"))
361
+
362
+ def export_csv(self, entries: Sequence[Entry], file_path: str) -> None:
363
+ # utf-8-sig adds a BOM so Excel opens as UTF-8 by default.
364
+ with open(file_path, "w", encoding="utf-8-sig", newline="") as f:
365
+ writer = csv.writer(f)
366
+ writer.writerow(["date", "content"]) # header
367
+ writer.writerows(entries)
368
+
369
+ def export_txt(
370
+ self,
371
+ entries: Sequence[Entry],
372
+ file_path: str,
373
+ separator: str = "\n\n— — — — —\n\n",
374
+ strip_html: bool = True,
375
+ ) -> None:
376
+ import re, html as _html
377
+
378
+ # Precompiled patterns
379
+ STYLE_SCRIPT_RE = re.compile(r"(?is)<(script|style)[^>]*>.*?</\1>")
380
+ COMMENT_RE = re.compile(r"<!--.*?-->", re.S)
381
+ BR_RE = re.compile(r"(?i)<br\\s*/?>")
382
+ BLOCK_END_RE = re.compile(r"(?i)</(p|div|section|article|li|h[1-6])\\s*>")
383
+ TAG_RE = re.compile(r"<[^>]+>")
384
+ WS_ENDS_RE = re.compile(r"[ \\t]+\\n")
385
+ MULTINEWLINE_RE = re.compile(r"\\n{3,}")
386
+
387
+ def _strip(s: str) -> str:
388
+ # 1) Remove <style> and <script> blocks *including their contents*
389
+ s = STYLE_SCRIPT_RE.sub("", s)
390
+ # 2) Remove HTML comments
391
+ s = COMMENT_RE.sub("", s)
392
+ # 3) Turn some block-ish boundaries into newlines before removing tags
393
+ s = BR_RE.sub("\n", s)
394
+ s = BLOCK_END_RE.sub("\n", s)
395
+ # 4) Drop remaining tags
396
+ s = TAG_RE.sub("", s)
397
+ # 5) Unescape entities (&nbsp; etc.)
398
+ s = _html.unescape(s)
399
+ # 6) Tidy whitespace
400
+ s = WS_ENDS_RE.sub("\n", s)
401
+ s = MULTINEWLINE_RE.sub("\n\n", s)
402
+ return s.strip()
403
+
404
+ with open(file_path, "w", encoding="utf-8") as f:
405
+ for i, (d, c) in enumerate(entries):
406
+ body = _strip(c) if strip_html else c
407
+ f.write(f"{d}\n{body}\n")
408
+ if i < len(entries) - 1:
409
+ f.write(separator)
410
+
411
+ def export_html(
412
+ self, entries: Sequence[Entry], file_path: str, title: str = "Bouquin export"
413
+ ) -> None:
414
+ parts = [
415
+ "<!doctype html>",
416
+ '<html lang="en">',
417
+ '<meta charset="utf-8">',
418
+ f"<title>{html.escape(title)}</title>",
419
+ "<style>body{font:16px/1.5 system-ui,Segoe UI,Roboto,Helvetica,Arial,sans-serif;padding:24px;max-width:900px;margin:auto;}",
420
+ "article{padding:16px 0;border-bottom:1px solid #ddd;} time{font-weight:600;color:#333;} section{margin-top:8px;}</style>",
421
+ "<body>",
422
+ f"<h1>{html.escape(title)}</h1>",
423
+ ]
424
+ for d, c in entries:
425
+ parts.append(
426
+ f"<article><header><time>{html.escape(d)}</time></header><section>{c}</section></article>"
427
+ )
428
+ parts.append("</body></html>")
429
+
430
+ with open(file_path, "w", encoding="utf-8") as f:
431
+ f.write("\n".join(parts))
432
+
433
+ def export_sql(self, file_path: str) -> None:
434
+ """
435
+ Exports the encrypted database as plaintext SQL.
436
+ """
437
+ cur = self.conn.cursor()
438
+ cur.execute(f"ATTACH DATABASE '{file_path}' AS plaintext KEY '';")
439
+ cur.execute("SELECT sqlcipher_export('plaintext')")
440
+ cur.execute("DETACH DATABASE plaintext")
441
+
442
+ def export_sqlcipher(self, file_path: str) -> None:
443
+ """
444
+ Exports the encrypted database as an encrypted database with the same key.
445
+ Intended for Bouquin-compatible backups.
446
+ """
447
+ cur = self.conn.cursor()
448
+ cur.execute(f"ATTACH DATABASE '{file_path}' AS backup KEY '{self.cfg.key}'")
449
+ cur.execute("SELECT sqlcipher_export('backup')")
450
+ cur.execute("DETACH DATABASE backup")
451
+
452
+ def export_by_extension(self, file_path: str) -> None:
453
+ entries = self.get_all_entries()
454
+ ext = os.path.splitext(file_path)[1].lower()
455
+
456
+ if ext == ".json":
457
+ self.export_json(entries, file_path)
458
+ elif ext == ".csv":
459
+ self.export_csv(entries, file_path)
460
+ elif ext == ".txt":
461
+ self.export_txt(entries, file_path)
462
+ elif ext in {".html", ".htm"}:
463
+ self.export_html(entries, file_path)
464
+ else:
465
+ raise ValueError(f"Unsupported extension: {ext}")
466
+
467
+ def compact(self) -> None:
468
+ """
469
+ Runs VACUUM on the db.
470
+ """
471
+ try:
472
+ cur = self.conn.cursor()
473
+ cur.execute("VACUUM")
474
+ except Exception as e:
475
+ print(f"Error: {e}")
476
+
477
+ def close(self) -> None:
478
+ if self.conn is not None:
479
+ self.conn.close()
480
+ self.conn = None