dploydb 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,136 @@
1
+ """Bounded, read-only SQLite safety checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sqlite3
7
+ import stat
8
+ import time
9
+ from collections.abc import Callable
10
+ from pathlib import Path
11
+ from typing import Final, Literal
12
+
13
+ from dploydb.errors import SafetyCheckError
14
+ from dploydb.models import SQLiteVerification, utc_now
15
+
16
+ DEFAULT_SQLITE_TIMEOUT_SECONDS: Final[float] = 10.0
17
+ PROGRESS_INSTRUCTIONS: Final[int] = 1_000
18
+
19
+
20
+ def verify_sqlite_database(
21
+ path: Path,
22
+ *,
23
+ deep: bool = False,
24
+ timeout_seconds: float = DEFAULT_SQLITE_TIMEOUT_SECONDS,
25
+ monotonic: Callable[[], float] = time.monotonic,
26
+ progress_instructions: int = PROGRESS_INSTRUCTIONS,
27
+ ) -> SQLiteVerification:
28
+ """Open one database read-only and require all configured integrity checks."""
29
+ if timeout_seconds <= 0:
30
+ raise ValueError("SQLite verification timeout must be positive")
31
+ if progress_instructions <= 0:
32
+ raise ValueError("SQLite progress interval must be positive")
33
+
34
+ started = monotonic()
35
+ deadline = started + timeout_seconds
36
+ before = _validate_database_file(path)
37
+ connection: sqlite3.Connection | None = None
38
+ timed_out = False
39
+
40
+ def progress() -> int:
41
+ nonlocal timed_out
42
+ timed_out = monotonic() >= deadline
43
+ return 1 if timed_out else 0
44
+
45
+ try:
46
+ remaining = deadline - monotonic()
47
+ if remaining <= 0:
48
+ raise TimeoutError
49
+ connection = sqlite3.connect(
50
+ f"{path.as_uri()}?mode=ro",
51
+ uri=True,
52
+ timeout=remaining,
53
+ isolation_level=None,
54
+ )
55
+ busy_milliseconds = max(1, min(2_147_483_647, int(remaining * 1_000)))
56
+ connection.execute(f"PRAGMA busy_timeout = {busy_milliseconds}")
57
+ connection.set_progress_handler(progress, progress_instructions)
58
+
59
+ quick_rows = connection.execute("PRAGMA quick_check(1)").fetchall()
60
+ if quick_rows != [("ok",)]:
61
+ detail = _bounded_check_detail(quick_rows)
62
+ raise _check_error(path, f"SQLite quick_check failed: {detail}")
63
+
64
+ foreign_key_row = connection.execute("PRAGMA foreign_key_check").fetchone()
65
+ if foreign_key_row is not None:
66
+ detail = _bounded_check_detail([foreign_key_row])
67
+ raise _check_error(path, f"SQLite foreign_key_check failed: {detail}")
68
+
69
+ integrity_passed: Literal[True] | None = None
70
+ if deep:
71
+ integrity_rows = connection.execute("PRAGMA integrity_check(1)").fetchall()
72
+ if integrity_rows != [("ok",)]:
73
+ detail = _bounded_check_detail(integrity_rows)
74
+ raise _check_error(path, f"SQLite integrity_check failed: {detail}")
75
+ integrity_passed = True
76
+ except TimeoutError:
77
+ raise _timeout_error(path, timeout_seconds) from None
78
+ except sqlite3.Error as exc:
79
+ if timed_out or monotonic() >= deadline:
80
+ raise _timeout_error(path, timeout_seconds) from None
81
+ raise _check_error(path, f"SQLite verification could not complete: {exc}") from None
82
+ finally:
83
+ if connection is not None:
84
+ connection.set_progress_handler(None, 0)
85
+ connection.close()
86
+
87
+ after = _validate_database_file(path)
88
+ if (before.st_dev, before.st_ino) != (
89
+ after.st_dev,
90
+ after.st_ino,
91
+ ):
92
+ raise _check_error(path, "database changed identity during verification")
93
+
94
+ return SQLiteVerification(
95
+ quick_check_passed=True,
96
+ foreign_key_check_passed=True,
97
+ integrity_check_passed=integrity_passed,
98
+ checked_at=utc_now(),
99
+ duration_seconds=max(0.0, monotonic() - started),
100
+ )
101
+
102
+
103
+ def _validate_database_file(path: Path) -> os.stat_result:
104
+ try:
105
+ details = path.lstat()
106
+ except OSError as exc:
107
+ raise _check_error(path, f"database file could not be inspected: {exc}") from None
108
+ if path.is_symlink() or not stat.S_ISREG(details.st_mode):
109
+ raise _check_error(path, "database path must be a regular non-symlink file")
110
+ if not os.access(path, os.R_OK):
111
+ raise _check_error(path, "database file is not readable")
112
+ return details
113
+
114
+
115
+ def _bounded_check_detail(rows: list[tuple[object, ...]]) -> str:
116
+ if not rows:
117
+ return "check returned no result"
118
+ text = repr(rows[0])
119
+ return text if len(text) <= 512 else f"{text[:512]}..."
120
+
121
+
122
+ def _check_error(path: Path, detail: str) -> SafetyCheckError:
123
+ return SafetyCheckError(
124
+ detail,
125
+ production_changed=False,
126
+ previous_application_running=None,
127
+ log_path=path,
128
+ next_safe_action="Repair or replace the database from a verified backup, then retry.",
129
+ )
130
+
131
+
132
+ def _timeout_error(path: Path, timeout_seconds: float) -> SafetyCheckError:
133
+ return _check_error(
134
+ path,
135
+ f"SQLite verification timed out after {timeout_seconds:g} seconds",
136
+ )