kstlib 0.0.1a0__py3-none-any.whl → 1.0.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.
- kstlib/__init__.py +266 -1
- kstlib/__main__.py +16 -0
- kstlib/alerts/__init__.py +110 -0
- kstlib/alerts/channels/__init__.py +36 -0
- kstlib/alerts/channels/base.py +197 -0
- kstlib/alerts/channels/email.py +227 -0
- kstlib/alerts/channels/slack.py +389 -0
- kstlib/alerts/exceptions.py +72 -0
- kstlib/alerts/manager.py +651 -0
- kstlib/alerts/models.py +142 -0
- kstlib/alerts/throttle.py +263 -0
- kstlib/auth/__init__.py +139 -0
- kstlib/auth/callback.py +399 -0
- kstlib/auth/config.py +502 -0
- kstlib/auth/errors.py +127 -0
- kstlib/auth/models.py +316 -0
- kstlib/auth/providers/__init__.py +14 -0
- kstlib/auth/providers/base.py +393 -0
- kstlib/auth/providers/oauth2.py +645 -0
- kstlib/auth/providers/oidc.py +821 -0
- kstlib/auth/session.py +338 -0
- kstlib/auth/token.py +482 -0
- kstlib/cache/__init__.py +50 -0
- kstlib/cache/decorator.py +261 -0
- kstlib/cache/strategies.py +516 -0
- kstlib/cli/__init__.py +8 -0
- kstlib/cli/app.py +195 -0
- kstlib/cli/commands/__init__.py +5 -0
- kstlib/cli/commands/auth/__init__.py +39 -0
- kstlib/cli/commands/auth/common.py +122 -0
- kstlib/cli/commands/auth/login.py +325 -0
- kstlib/cli/commands/auth/logout.py +74 -0
- kstlib/cli/commands/auth/providers.py +57 -0
- kstlib/cli/commands/auth/status.py +291 -0
- kstlib/cli/commands/auth/token.py +199 -0
- kstlib/cli/commands/auth/whoami.py +106 -0
- kstlib/cli/commands/config.py +89 -0
- kstlib/cli/commands/ops/__init__.py +39 -0
- kstlib/cli/commands/ops/attach.py +49 -0
- kstlib/cli/commands/ops/common.py +269 -0
- kstlib/cli/commands/ops/list_sessions.py +252 -0
- kstlib/cli/commands/ops/logs.py +49 -0
- kstlib/cli/commands/ops/start.py +98 -0
- kstlib/cli/commands/ops/status.py +138 -0
- kstlib/cli/commands/ops/stop.py +60 -0
- kstlib/cli/commands/rapi/__init__.py +60 -0
- kstlib/cli/commands/rapi/call.py +341 -0
- kstlib/cli/commands/rapi/list.py +99 -0
- kstlib/cli/commands/rapi/show.py +206 -0
- kstlib/cli/commands/secrets/__init__.py +35 -0
- kstlib/cli/commands/secrets/common.py +425 -0
- kstlib/cli/commands/secrets/decrypt.py +88 -0
- kstlib/cli/commands/secrets/doctor.py +743 -0
- kstlib/cli/commands/secrets/encrypt.py +242 -0
- kstlib/cli/commands/secrets/shred.py +96 -0
- kstlib/cli/common.py +86 -0
- kstlib/config/__init__.py +76 -0
- kstlib/config/exceptions.py +110 -0
- kstlib/config/export.py +225 -0
- kstlib/config/loader.py +963 -0
- kstlib/config/sops.py +287 -0
- kstlib/db/__init__.py +54 -0
- kstlib/db/aiosqlcipher.py +137 -0
- kstlib/db/cipher.py +112 -0
- kstlib/db/database.py +367 -0
- kstlib/db/exceptions.py +25 -0
- kstlib/db/pool.py +302 -0
- kstlib/helpers/__init__.py +35 -0
- kstlib/helpers/exceptions.py +11 -0
- kstlib/helpers/time_trigger.py +396 -0
- kstlib/kstlib.conf.yml +890 -0
- kstlib/limits.py +963 -0
- kstlib/logging/__init__.py +108 -0
- kstlib/logging/manager.py +633 -0
- kstlib/mail/__init__.py +42 -0
- kstlib/mail/builder.py +626 -0
- kstlib/mail/exceptions.py +27 -0
- kstlib/mail/filesystem.py +248 -0
- kstlib/mail/transport.py +224 -0
- kstlib/mail/transports/__init__.py +19 -0
- kstlib/mail/transports/gmail.py +268 -0
- kstlib/mail/transports/resend.py +324 -0
- kstlib/mail/transports/smtp.py +326 -0
- kstlib/meta.py +72 -0
- kstlib/metrics/__init__.py +88 -0
- kstlib/metrics/decorators.py +1090 -0
- kstlib/metrics/exceptions.py +14 -0
- kstlib/monitoring/__init__.py +116 -0
- kstlib/monitoring/_styles.py +163 -0
- kstlib/monitoring/cell.py +57 -0
- kstlib/monitoring/config.py +424 -0
- kstlib/monitoring/delivery.py +579 -0
- kstlib/monitoring/exceptions.py +63 -0
- kstlib/monitoring/image.py +220 -0
- kstlib/monitoring/kv.py +79 -0
- kstlib/monitoring/list.py +69 -0
- kstlib/monitoring/metric.py +88 -0
- kstlib/monitoring/monitoring.py +341 -0
- kstlib/monitoring/renderer.py +139 -0
- kstlib/monitoring/service.py +392 -0
- kstlib/monitoring/table.py +129 -0
- kstlib/monitoring/types.py +56 -0
- kstlib/ops/__init__.py +86 -0
- kstlib/ops/base.py +148 -0
- kstlib/ops/container.py +577 -0
- kstlib/ops/exceptions.py +209 -0
- kstlib/ops/manager.py +407 -0
- kstlib/ops/models.py +176 -0
- kstlib/ops/tmux.py +372 -0
- kstlib/ops/validators.py +287 -0
- kstlib/py.typed +0 -0
- kstlib/rapi/__init__.py +118 -0
- kstlib/rapi/client.py +875 -0
- kstlib/rapi/config.py +861 -0
- kstlib/rapi/credentials.py +887 -0
- kstlib/rapi/exceptions.py +213 -0
- kstlib/resilience/__init__.py +101 -0
- kstlib/resilience/circuit_breaker.py +440 -0
- kstlib/resilience/exceptions.py +95 -0
- kstlib/resilience/heartbeat.py +491 -0
- kstlib/resilience/rate_limiter.py +506 -0
- kstlib/resilience/shutdown.py +417 -0
- kstlib/resilience/watchdog.py +637 -0
- kstlib/secrets/__init__.py +29 -0
- kstlib/secrets/exceptions.py +19 -0
- kstlib/secrets/models.py +62 -0
- kstlib/secrets/providers/__init__.py +79 -0
- kstlib/secrets/providers/base.py +58 -0
- kstlib/secrets/providers/environment.py +66 -0
- kstlib/secrets/providers/keyring.py +107 -0
- kstlib/secrets/providers/kms.py +223 -0
- kstlib/secrets/providers/kwargs.py +101 -0
- kstlib/secrets/providers/sops.py +209 -0
- kstlib/secrets/resolver.py +221 -0
- kstlib/secrets/sensitive.py +130 -0
- kstlib/secure/__init__.py +23 -0
- kstlib/secure/fs.py +194 -0
- kstlib/secure/permissions.py +70 -0
- kstlib/ssl.py +347 -0
- kstlib/ui/__init__.py +23 -0
- kstlib/ui/exceptions.py +26 -0
- kstlib/ui/panels.py +484 -0
- kstlib/ui/spinner.py +864 -0
- kstlib/ui/tables.py +382 -0
- kstlib/utils/__init__.py +48 -0
- kstlib/utils/dict.py +36 -0
- kstlib/utils/formatting.py +338 -0
- kstlib/utils/http_trace.py +237 -0
- kstlib/utils/lazy.py +49 -0
- kstlib/utils/secure_delete.py +205 -0
- kstlib/utils/serialization.py +247 -0
- kstlib/utils/text.py +56 -0
- kstlib/utils/validators.py +124 -0
- kstlib/websocket/__init__.py +97 -0
- kstlib/websocket/exceptions.py +214 -0
- kstlib/websocket/manager.py +1102 -0
- kstlib/websocket/models.py +361 -0
- kstlib-1.0.0.dist-info/METADATA +201 -0
- kstlib-1.0.0.dist-info/RECORD +163 -0
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/WHEEL +1 -1
- kstlib-1.0.0.dist-info/entry_points.txt +2 -0
- kstlib-1.0.0.dist-info/licenses/LICENSE.md +9 -0
- kstlib-0.0.1a0.dist-info/METADATA +0 -29
- kstlib-0.0.1a0.dist-info/RECORD +0 -6
- kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/top_level.txt +0 -0
kstlib/db/database.py
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""Async database wrapper for SQLite/SQLCipher.
|
|
2
|
+
|
|
3
|
+
Provides a high-level async interface for database operations with:
|
|
4
|
+
- Connection pooling
|
|
5
|
+
- Automatic retry on transient failures
|
|
6
|
+
- SQLCipher encryption support
|
|
7
|
+
- Transaction management
|
|
8
|
+
- Query helpers
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
from contextlib import asynccontextmanager
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
17
|
+
|
|
18
|
+
from typing_extensions import Self
|
|
19
|
+
|
|
20
|
+
from kstlib.db.exceptions import TransactionError
|
|
21
|
+
from kstlib.db.pool import ConnectionPool, PoolStats
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from collections.abc import AsyncGenerator, Sequence
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
import aiosqlite
|
|
28
|
+
|
|
29
|
+
log = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class AsyncDatabase:
|
|
34
|
+
"""Async database interface for SQLite/SQLCipher.
|
|
35
|
+
|
|
36
|
+
Provides connection pooling, encryption, and query helpers
|
|
37
|
+
for async database operations.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
path: Path to database file (or ":memory:" for in-memory).
|
|
41
|
+
cipher_key: Direct encryption key for SQLCipher.
|
|
42
|
+
cipher_env: Environment variable containing cipher key.
|
|
43
|
+
cipher_sops: Path to SOPS file containing cipher key.
|
|
44
|
+
cipher_sops_key: Key name in SOPS file (default: "db_key").
|
|
45
|
+
pool_min: Minimum pool connections.
|
|
46
|
+
pool_max: Maximum pool connections.
|
|
47
|
+
pool_timeout: Acquire timeout in seconds.
|
|
48
|
+
max_retries: Retry attempts on failure.
|
|
49
|
+
retry_delay: Delay between retries.
|
|
50
|
+
|
|
51
|
+
Examples:
|
|
52
|
+
Basic usage:
|
|
53
|
+
|
|
54
|
+
>>> db = AsyncDatabase(":memory:")
|
|
55
|
+
>>> db.path
|
|
56
|
+
':memory:'
|
|
57
|
+
|
|
58
|
+
With encryption:
|
|
59
|
+
|
|
60
|
+
>>> db = AsyncDatabase("app.db", cipher_key="secret") # doctest: +SKIP
|
|
61
|
+
|
|
62
|
+
With SOPS:
|
|
63
|
+
|
|
64
|
+
>>> db = AsyncDatabase("app.db", cipher_sops="secrets.yml") # doctest: +SKIP
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
path: str | Path
|
|
68
|
+
cipher_key: str | None = None
|
|
69
|
+
cipher_env: str | None = None
|
|
70
|
+
cipher_sops: str | Path | None = None
|
|
71
|
+
cipher_sops_key: str = "db_key"
|
|
72
|
+
pool_min: int = 1
|
|
73
|
+
pool_max: int = 10
|
|
74
|
+
pool_timeout: float = 30.0
|
|
75
|
+
max_retries: int = 3
|
|
76
|
+
retry_delay: float = 0.5
|
|
77
|
+
|
|
78
|
+
_pool: ConnectionPool | None = field(default=None, repr=False)
|
|
79
|
+
_resolved_key: str | None = field(default=None, repr=False)
|
|
80
|
+
|
|
81
|
+
def __post_init__(self) -> None:
|
|
82
|
+
"""Resolve cipher key and apply config defaults with hard limits."""
|
|
83
|
+
from kstlib.limits import get_db_limits
|
|
84
|
+
|
|
85
|
+
self.path = str(self.path)
|
|
86
|
+
|
|
87
|
+
# Load config defaults for any unset pool/retry params
|
|
88
|
+
limits = get_db_limits()
|
|
89
|
+
|
|
90
|
+
# Apply config defaults if using dataclass defaults (sentinel check)
|
|
91
|
+
# Use object.__setattr__ since dataclass fields are set
|
|
92
|
+
if self.pool_min == 1:
|
|
93
|
+
object.__setattr__(self, "pool_min", limits.pool_min_size)
|
|
94
|
+
if self.pool_max == 10:
|
|
95
|
+
object.__setattr__(self, "pool_max", limits.pool_max_size)
|
|
96
|
+
if self.pool_timeout == 30.0:
|
|
97
|
+
object.__setattr__(self, "pool_timeout", limits.pool_acquire_timeout)
|
|
98
|
+
if self.max_retries == 3:
|
|
99
|
+
object.__setattr__(self, "max_retries", limits.max_retries)
|
|
100
|
+
if self.retry_delay == 0.5:
|
|
101
|
+
object.__setattr__(self, "retry_delay", limits.retry_delay)
|
|
102
|
+
|
|
103
|
+
# Resolve encryption key if any source provided
|
|
104
|
+
if self.cipher_key or self.cipher_env or self.cipher_sops:
|
|
105
|
+
from kstlib.db.cipher import resolve_cipher_key
|
|
106
|
+
|
|
107
|
+
self._resolved_key = resolve_cipher_key(
|
|
108
|
+
passphrase=self.cipher_key,
|
|
109
|
+
env_var=self.cipher_env,
|
|
110
|
+
sops_path=self.cipher_sops,
|
|
111
|
+
sops_key=self.cipher_sops_key,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def _ensure_pool(self) -> ConnectionPool:
|
|
115
|
+
"""Ensure connection pool is initialized."""
|
|
116
|
+
if self._pool is None:
|
|
117
|
+
# path is already converted to str in __post_init__
|
|
118
|
+
db_path = self.path if isinstance(self.path, str) else str(self.path)
|
|
119
|
+
self._pool = ConnectionPool(
|
|
120
|
+
db_path=db_path,
|
|
121
|
+
min_size=self.pool_min,
|
|
122
|
+
max_size=self.pool_max,
|
|
123
|
+
acquire_timeout=self.pool_timeout,
|
|
124
|
+
max_retries=self.max_retries,
|
|
125
|
+
retry_delay=self.retry_delay,
|
|
126
|
+
cipher_key=self._resolved_key,
|
|
127
|
+
)
|
|
128
|
+
return self._pool
|
|
129
|
+
|
|
130
|
+
async def connect(self) -> None:
|
|
131
|
+
"""Initialize the connection pool.
|
|
132
|
+
|
|
133
|
+
Called automatically on first operation, but can be
|
|
134
|
+
called explicitly for eager initialization.
|
|
135
|
+
"""
|
|
136
|
+
pool = self._ensure_pool()
|
|
137
|
+
await pool._init_pool()
|
|
138
|
+
log.info("Database connected: %s", self.path)
|
|
139
|
+
|
|
140
|
+
async def close(self) -> None:
|
|
141
|
+
"""Close all connections and shutdown the pool."""
|
|
142
|
+
if self._pool:
|
|
143
|
+
await self._pool.close()
|
|
144
|
+
self._pool = None
|
|
145
|
+
# Scrub resolved key from memory
|
|
146
|
+
self._resolved_key = None
|
|
147
|
+
log.info("Database closed: %s", self.path)
|
|
148
|
+
|
|
149
|
+
async def __aenter__(self) -> Self:
|
|
150
|
+
"""Async context manager entry."""
|
|
151
|
+
await self.connect()
|
|
152
|
+
return self
|
|
153
|
+
|
|
154
|
+
async def __aexit__(
|
|
155
|
+
self,
|
|
156
|
+
exc_type: type[BaseException] | None,
|
|
157
|
+
exc_val: BaseException | None,
|
|
158
|
+
exc_tb: object,
|
|
159
|
+
) -> None:
|
|
160
|
+
"""Async context manager exit."""
|
|
161
|
+
await self.close()
|
|
162
|
+
|
|
163
|
+
@asynccontextmanager
|
|
164
|
+
async def connection(self) -> AsyncGenerator[aiosqlite.Connection, None]:
|
|
165
|
+
"""Get a connection from the pool.
|
|
166
|
+
|
|
167
|
+
Yields:
|
|
168
|
+
Database connection.
|
|
169
|
+
|
|
170
|
+
Examples:
|
|
171
|
+
>>> async with db.connection() as conn: # doctest: +SKIP
|
|
172
|
+
... await conn.execute("SELECT 1")
|
|
173
|
+
"""
|
|
174
|
+
pool = self._ensure_pool()
|
|
175
|
+
async with pool.connection() as conn:
|
|
176
|
+
yield conn
|
|
177
|
+
|
|
178
|
+
@asynccontextmanager
|
|
179
|
+
async def transaction(self) -> AsyncGenerator[aiosqlite.Connection, None]:
|
|
180
|
+
"""Execute operations within a transaction.
|
|
181
|
+
|
|
182
|
+
Automatically commits on success, rolls back on error.
|
|
183
|
+
|
|
184
|
+
Yields:
|
|
185
|
+
Database connection within transaction.
|
|
186
|
+
|
|
187
|
+
Raises:
|
|
188
|
+
TransactionError: If transaction fails.
|
|
189
|
+
|
|
190
|
+
Examples:
|
|
191
|
+
>>> async with db.transaction() as conn: # doctest: +SKIP
|
|
192
|
+
... await conn.execute("INSERT INTO users VALUES (?)", ("alice",))
|
|
193
|
+
... await conn.execute("INSERT INTO users VALUES (?)", ("bob",))
|
|
194
|
+
"""
|
|
195
|
+
pool = self._ensure_pool()
|
|
196
|
+
conn = await pool.acquire()
|
|
197
|
+
try:
|
|
198
|
+
await conn.execute("BEGIN")
|
|
199
|
+
yield conn
|
|
200
|
+
await conn.commit()
|
|
201
|
+
except Exception as e:
|
|
202
|
+
try:
|
|
203
|
+
await conn.rollback()
|
|
204
|
+
except Exception:
|
|
205
|
+
# Rollback may fail on closed connection - intentional silent catch
|
|
206
|
+
log.debug("Rollback failed (connection may be closed)", exc_info=True)
|
|
207
|
+
raise TransactionError(f"Transaction failed: {e}") from e
|
|
208
|
+
finally:
|
|
209
|
+
await pool.release(conn)
|
|
210
|
+
|
|
211
|
+
async def execute(
|
|
212
|
+
self,
|
|
213
|
+
sql: str,
|
|
214
|
+
parameters: Sequence[Any] | None = None,
|
|
215
|
+
) -> aiosqlite.Cursor:
|
|
216
|
+
"""Execute a single SQL statement.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
sql: SQL statement to execute.
|
|
220
|
+
parameters: Query parameters.
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
Cursor with results.
|
|
224
|
+
|
|
225
|
+
Examples:
|
|
226
|
+
>>> await db.execute("CREATE TABLE test (id INTEGER)") # doctest: +SKIP
|
|
227
|
+
"""
|
|
228
|
+
pool = self._ensure_pool()
|
|
229
|
+
async with pool.connection() as conn:
|
|
230
|
+
if parameters:
|
|
231
|
+
return await conn.execute(sql, parameters)
|
|
232
|
+
return await conn.execute(sql)
|
|
233
|
+
|
|
234
|
+
async def executemany(
|
|
235
|
+
self,
|
|
236
|
+
sql: str,
|
|
237
|
+
parameters: Sequence[Sequence[Any]],
|
|
238
|
+
) -> aiosqlite.Cursor:
|
|
239
|
+
"""Execute SQL statement for multiple parameter sets.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
sql: SQL statement to execute.
|
|
243
|
+
parameters: Sequence of parameter tuples.
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
Cursor with results.
|
|
247
|
+
|
|
248
|
+
Examples:
|
|
249
|
+
>>> await db.executemany( # doctest: +SKIP
|
|
250
|
+
... "INSERT INTO test VALUES (?)",
|
|
251
|
+
... [(1,), (2,), (3,)]
|
|
252
|
+
... )
|
|
253
|
+
"""
|
|
254
|
+
pool = self._ensure_pool()
|
|
255
|
+
async with pool.connection() as conn:
|
|
256
|
+
return await conn.executemany(sql, parameters)
|
|
257
|
+
|
|
258
|
+
async def fetch_one(
|
|
259
|
+
self,
|
|
260
|
+
sql: str,
|
|
261
|
+
parameters: Sequence[Any] | None = None,
|
|
262
|
+
) -> tuple[Any, ...] | None:
|
|
263
|
+
"""Fetch a single row.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
sql: SQL query.
|
|
267
|
+
parameters: Query parameters.
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
Row tuple or None if no results.
|
|
271
|
+
|
|
272
|
+
Examples:
|
|
273
|
+
>>> row = await db.fetch_one("SELECT * FROM test WHERE id=?", (1,)) # doctest: +SKIP
|
|
274
|
+
"""
|
|
275
|
+
pool = self._ensure_pool()
|
|
276
|
+
async with pool.connection() as conn:
|
|
277
|
+
if parameters:
|
|
278
|
+
cursor = await conn.execute(sql, parameters)
|
|
279
|
+
else:
|
|
280
|
+
cursor = await conn.execute(sql)
|
|
281
|
+
row = await cursor.fetchone()
|
|
282
|
+
return cast("tuple[Any, ...] | None", row)
|
|
283
|
+
|
|
284
|
+
async def fetch_all(
|
|
285
|
+
self,
|
|
286
|
+
sql: str,
|
|
287
|
+
parameters: Sequence[Any] | None = None,
|
|
288
|
+
) -> list[tuple[Any, ...]]:
|
|
289
|
+
"""Fetch all rows.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
sql: SQL query.
|
|
293
|
+
parameters: Query parameters.
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
List of row tuples.
|
|
297
|
+
|
|
298
|
+
Examples:
|
|
299
|
+
>>> rows = await db.fetch_all("SELECT * FROM test") # doctest: +SKIP
|
|
300
|
+
"""
|
|
301
|
+
pool = self._ensure_pool()
|
|
302
|
+
async with pool.connection() as conn:
|
|
303
|
+
if parameters:
|
|
304
|
+
cursor = await conn.execute(sql, parameters)
|
|
305
|
+
else:
|
|
306
|
+
cursor = await conn.execute(sql)
|
|
307
|
+
rows = await cursor.fetchall()
|
|
308
|
+
return cast("list[tuple[Any, ...]]", rows)
|
|
309
|
+
|
|
310
|
+
async def fetch_value(
|
|
311
|
+
self,
|
|
312
|
+
sql: str,
|
|
313
|
+
parameters: Sequence[Any] | None = None,
|
|
314
|
+
) -> Any:
|
|
315
|
+
"""Fetch a single value (first column of first row).
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
sql: SQL query.
|
|
319
|
+
parameters: Query parameters.
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
Single value or None.
|
|
323
|
+
|
|
324
|
+
Examples:
|
|
325
|
+
>>> count = await db.fetch_value("SELECT count(*) FROM test") # doctest: +SKIP
|
|
326
|
+
"""
|
|
327
|
+
row = await self.fetch_one(sql, parameters)
|
|
328
|
+
return row[0] if row else None
|
|
329
|
+
|
|
330
|
+
async def table_exists(self, table_name: str) -> bool:
|
|
331
|
+
"""Check if a table exists.
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
table_name: Name of the table.
|
|
335
|
+
|
|
336
|
+
Returns:
|
|
337
|
+
True if table exists.
|
|
338
|
+
|
|
339
|
+
Examples:
|
|
340
|
+
>>> await db.table_exists("users") # doctest: +SKIP
|
|
341
|
+
False
|
|
342
|
+
"""
|
|
343
|
+
sql = "SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?"
|
|
344
|
+
count = await self.fetch_value(sql, (table_name,))
|
|
345
|
+
return bool(count)
|
|
346
|
+
|
|
347
|
+
@property
|
|
348
|
+
def stats(self) -> PoolStats:
|
|
349
|
+
"""Get connection pool statistics."""
|
|
350
|
+
if self._pool:
|
|
351
|
+
return self._pool.stats
|
|
352
|
+
return PoolStats()
|
|
353
|
+
|
|
354
|
+
@property
|
|
355
|
+
def is_encrypted(self) -> bool:
|
|
356
|
+
"""Whether database is configured for encryption."""
|
|
357
|
+
return self._resolved_key is not None
|
|
358
|
+
|
|
359
|
+
@property
|
|
360
|
+
def pool_size(self) -> int:
|
|
361
|
+
"""Current number of connections in pool."""
|
|
362
|
+
if self._pool:
|
|
363
|
+
return self._pool.size
|
|
364
|
+
return 0
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
__all__ = ["AsyncDatabase"]
|
kstlib/db/exceptions.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Database module exceptions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from kstlib.config.exceptions import KstlibError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DatabaseError(KstlibError):
|
|
9
|
+
"""Base exception for database operations."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DatabaseConnectionError(DatabaseError):
|
|
13
|
+
"""Failed to establish database connection."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class EncryptionError(DatabaseError):
|
|
17
|
+
"""Failed to decrypt or access encrypted database."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PoolExhaustedError(DatabaseError):
|
|
21
|
+
"""Connection pool exhausted, no connections available."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TransactionError(DatabaseError):
|
|
25
|
+
"""Transaction operation failed."""
|
kstlib/db/pool.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Connection pool with retry support for async SQLite.
|
|
2
|
+
|
|
3
|
+
Provides connection pooling with:
|
|
4
|
+
- Configurable pool size
|
|
5
|
+
- Connection health checks
|
|
6
|
+
- Automatic retry on transient failures
|
|
7
|
+
- Graceful shutdown
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import logging
|
|
14
|
+
from contextlib import asynccontextmanager
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import TYPE_CHECKING, Any
|
|
17
|
+
|
|
18
|
+
from kstlib.db.exceptions import DatabaseConnectionError, PoolExhaustedError
|
|
19
|
+
from kstlib.limits import (
|
|
20
|
+
HARD_MAX_DB_MAX_RETRIES,
|
|
21
|
+
HARD_MAX_DB_RETRY_DELAY,
|
|
22
|
+
HARD_MAX_POOL_ACQUIRE_TIMEOUT,
|
|
23
|
+
HARD_MAX_POOL_MAX_SIZE,
|
|
24
|
+
HARD_MAX_POOL_MIN_SIZE,
|
|
25
|
+
HARD_MIN_DB_MAX_RETRIES,
|
|
26
|
+
HARD_MIN_DB_RETRY_DELAY,
|
|
27
|
+
HARD_MIN_POOL_ACQUIRE_TIMEOUT,
|
|
28
|
+
HARD_MIN_POOL_MAX_SIZE,
|
|
29
|
+
HARD_MIN_POOL_MIN_SIZE,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from collections.abc import AsyncGenerator
|
|
34
|
+
|
|
35
|
+
import aiosqlite
|
|
36
|
+
|
|
37
|
+
log = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class PoolStats:
|
|
42
|
+
"""Statistics for connection pool monitoring.
|
|
43
|
+
|
|
44
|
+
Attributes:
|
|
45
|
+
total_connections: Total connections created.
|
|
46
|
+
active_connections: Currently in-use connections.
|
|
47
|
+
idle_connections: Available connections in pool.
|
|
48
|
+
total_acquired: Total acquire operations.
|
|
49
|
+
total_released: Total release operations.
|
|
50
|
+
total_timeouts: Acquire operations that timed out.
|
|
51
|
+
total_errors: Connection errors encountered.
|
|
52
|
+
|
|
53
|
+
Examples:
|
|
54
|
+
>>> stats = PoolStats()
|
|
55
|
+
>>> stats.total_connections
|
|
56
|
+
0
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
total_connections: int = 0
|
|
60
|
+
active_connections: int = 0
|
|
61
|
+
idle_connections: int = 0
|
|
62
|
+
total_acquired: int = 0
|
|
63
|
+
total_released: int = 0
|
|
64
|
+
total_timeouts: int = 0
|
|
65
|
+
total_errors: int = 0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class ConnectionPool:
|
|
70
|
+
"""Async connection pool for SQLite/SQLCipher databases.
|
|
71
|
+
|
|
72
|
+
Manages a pool of database connections with health checks
|
|
73
|
+
and automatic retry on failures.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
db_path: Path to database file.
|
|
77
|
+
min_size: Minimum connections to maintain.
|
|
78
|
+
max_size: Maximum connections allowed.
|
|
79
|
+
acquire_timeout: Timeout for acquiring connection.
|
|
80
|
+
max_retries: Retry attempts on failure.
|
|
81
|
+
retry_delay: Delay between retries.
|
|
82
|
+
cipher_key: Optional encryption key for SQLCipher.
|
|
83
|
+
on_connect: Callback after connection established.
|
|
84
|
+
|
|
85
|
+
Examples:
|
|
86
|
+
>>> pool = ConnectionPool(":memory:", min_size=1, max_size=5)
|
|
87
|
+
>>> pool.max_size
|
|
88
|
+
5
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
db_path: str
|
|
92
|
+
min_size: int = 1
|
|
93
|
+
max_size: int = 10
|
|
94
|
+
acquire_timeout: float = 30.0
|
|
95
|
+
max_retries: int = 3
|
|
96
|
+
retry_delay: float = 0.5
|
|
97
|
+
cipher_key: str | None = None
|
|
98
|
+
on_connect: Any | None = None # Callable[[aiosqlite.Connection], Awaitable[None]]
|
|
99
|
+
|
|
100
|
+
_pool: asyncio.Queue[aiosqlite.Connection] = field(default_factory=lambda: asyncio.Queue(), repr=False)
|
|
101
|
+
_connections: set[aiosqlite.Connection] = field(default_factory=set, repr=False)
|
|
102
|
+
_lock: asyncio.Lock = field(default_factory=asyncio.Lock, repr=False)
|
|
103
|
+
_closed: bool = field(default=False, repr=False)
|
|
104
|
+
_stats: PoolStats = field(default_factory=PoolStats, repr=False)
|
|
105
|
+
|
|
106
|
+
def __post_init__(self) -> None:
|
|
107
|
+
"""Validate and clamp configuration values to hard limits."""
|
|
108
|
+
# Clamp pool sizes
|
|
109
|
+
self.min_size = max(HARD_MIN_POOL_MIN_SIZE, min(HARD_MAX_POOL_MIN_SIZE, self.min_size))
|
|
110
|
+
self.max_size = max(HARD_MIN_POOL_MAX_SIZE, min(HARD_MAX_POOL_MAX_SIZE, self.max_size))
|
|
111
|
+
|
|
112
|
+
# Ensure min_size <= max_size
|
|
113
|
+
self.min_size = min(self.min_size, self.max_size)
|
|
114
|
+
|
|
115
|
+
# Clamp timeouts and delays
|
|
116
|
+
self.acquire_timeout = max(
|
|
117
|
+
HARD_MIN_POOL_ACQUIRE_TIMEOUT, min(HARD_MAX_POOL_ACQUIRE_TIMEOUT, self.acquire_timeout)
|
|
118
|
+
)
|
|
119
|
+
self.max_retries = max(HARD_MIN_DB_MAX_RETRIES, min(HARD_MAX_DB_MAX_RETRIES, self.max_retries))
|
|
120
|
+
self.retry_delay = max(HARD_MIN_DB_RETRY_DELAY, min(HARD_MAX_DB_RETRY_DELAY, self.retry_delay))
|
|
121
|
+
|
|
122
|
+
async def _create_connection(self) -> aiosqlite.Connection:
|
|
123
|
+
"""Create a new database connection.
|
|
124
|
+
|
|
125
|
+
Uses aiosqlcipher for encrypted connections (when cipher_key is set),
|
|
126
|
+
or standard aiosqlite for unencrypted connections.
|
|
127
|
+
"""
|
|
128
|
+
if self.cipher_key:
|
|
129
|
+
# Use SQLCipher for encrypted database
|
|
130
|
+
from kstlib.db.aiosqlcipher import connect as aiosqlcipher_connect
|
|
131
|
+
|
|
132
|
+
conn = await aiosqlcipher_connect(self.db_path, cipher_key=self.cipher_key)
|
|
133
|
+
else:
|
|
134
|
+
# Use standard sqlite3 for unencrypted database
|
|
135
|
+
import aiosqlite
|
|
136
|
+
|
|
137
|
+
conn = await aiosqlite.connect(self.db_path)
|
|
138
|
+
|
|
139
|
+
# Enable WAL mode for better concurrency (works with both)
|
|
140
|
+
await conn.execute("PRAGMA journal_mode=WAL")
|
|
141
|
+
await conn.execute("PRAGMA foreign_keys=ON")
|
|
142
|
+
|
|
143
|
+
# Call custom on_connect handler
|
|
144
|
+
if self.on_connect:
|
|
145
|
+
await self.on_connect(conn)
|
|
146
|
+
|
|
147
|
+
self._stats.total_connections += 1
|
|
148
|
+
log.debug("Created new database connection (total: %d)", self._stats.total_connections)
|
|
149
|
+
return conn
|
|
150
|
+
|
|
151
|
+
async def _init_pool(self) -> None:
|
|
152
|
+
"""Initialize the connection pool with min_size connections."""
|
|
153
|
+
async with self._lock:
|
|
154
|
+
for _ in range(self.min_size):
|
|
155
|
+
conn = await self._create_connection()
|
|
156
|
+
self._connections.add(conn)
|
|
157
|
+
await self._pool.put(conn)
|
|
158
|
+
self._stats.idle_connections = self.min_size
|
|
159
|
+
|
|
160
|
+
async def acquire(self) -> aiosqlite.Connection:
|
|
161
|
+
"""Acquire a connection from the pool.
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
Database connection.
|
|
165
|
+
|
|
166
|
+
Raises:
|
|
167
|
+
PoolExhaustedError: If no connection available within timeout.
|
|
168
|
+
DatabaseConnectionError: If connection creation fails after retries.
|
|
169
|
+
"""
|
|
170
|
+
if self._closed:
|
|
171
|
+
raise DatabaseConnectionError("Pool is closed")
|
|
172
|
+
|
|
173
|
+
# Initialize pool on first acquire
|
|
174
|
+
if not self._connections:
|
|
175
|
+
await self._init_pool()
|
|
176
|
+
|
|
177
|
+
for attempt in range(self.max_retries):
|
|
178
|
+
try:
|
|
179
|
+
# Try to get from pool with timeout
|
|
180
|
+
try:
|
|
181
|
+
conn = await asyncio.wait_for(self._pool.get(), timeout=self.acquire_timeout)
|
|
182
|
+
self._stats.idle_connections -= 1
|
|
183
|
+
except asyncio.TimeoutError:
|
|
184
|
+
# Pool empty, try to create new if under max
|
|
185
|
+
async with self._lock:
|
|
186
|
+
if len(self._connections) < self.max_size:
|
|
187
|
+
conn = await self._create_connection()
|
|
188
|
+
self._connections.add(conn)
|
|
189
|
+
else:
|
|
190
|
+
self._stats.total_timeouts += 1
|
|
191
|
+
raise PoolExhaustedError(
|
|
192
|
+
f"Pool exhausted (max={self.max_size}), timeout after {self.acquire_timeout}s"
|
|
193
|
+
) from None
|
|
194
|
+
|
|
195
|
+
# Verify connection is alive
|
|
196
|
+
try:
|
|
197
|
+
await conn.execute("SELECT 1")
|
|
198
|
+
except Exception:
|
|
199
|
+
# Connection dead, remove and retry
|
|
200
|
+
self._connections.discard(conn)
|
|
201
|
+
await conn.close()
|
|
202
|
+
self._stats.total_errors += 1
|
|
203
|
+
continue
|
|
204
|
+
|
|
205
|
+
self._stats.active_connections += 1
|
|
206
|
+
self._stats.total_acquired += 1
|
|
207
|
+
return conn
|
|
208
|
+
|
|
209
|
+
except PoolExhaustedError:
|
|
210
|
+
raise
|
|
211
|
+
except Exception as e:
|
|
212
|
+
self._stats.total_errors += 1
|
|
213
|
+
if attempt < self.max_retries - 1:
|
|
214
|
+
log.warning(
|
|
215
|
+
"Connection attempt %d failed: %s, retrying...",
|
|
216
|
+
attempt + 1,
|
|
217
|
+
e,
|
|
218
|
+
)
|
|
219
|
+
await asyncio.sleep(self.retry_delay)
|
|
220
|
+
else:
|
|
221
|
+
raise DatabaseConnectionError(
|
|
222
|
+
f"Failed to acquire connection after {self.max_retries} attempts"
|
|
223
|
+
) from e
|
|
224
|
+
|
|
225
|
+
raise DatabaseConnectionError("Failed to acquire connection")
|
|
226
|
+
|
|
227
|
+
async def release(self, conn: aiosqlite.Connection) -> None:
|
|
228
|
+
"""Release a connection back to the pool.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
conn: Connection to release.
|
|
232
|
+
"""
|
|
233
|
+
if self._closed:
|
|
234
|
+
await conn.close()
|
|
235
|
+
return
|
|
236
|
+
|
|
237
|
+
self._stats.active_connections -= 1
|
|
238
|
+
self._stats.total_released += 1
|
|
239
|
+
|
|
240
|
+
# Return to pool
|
|
241
|
+
await self._pool.put(conn)
|
|
242
|
+
self._stats.idle_connections += 1
|
|
243
|
+
|
|
244
|
+
@asynccontextmanager
|
|
245
|
+
async def connection(self) -> AsyncGenerator[aiosqlite.Connection, None]:
|
|
246
|
+
"""Context manager for acquiring and releasing connections.
|
|
247
|
+
|
|
248
|
+
Yields:
|
|
249
|
+
Database connection.
|
|
250
|
+
|
|
251
|
+
Examples:
|
|
252
|
+
>>> async with pool.connection() as conn: # doctest: +SKIP
|
|
253
|
+
... await conn.execute("SELECT 1")
|
|
254
|
+
"""
|
|
255
|
+
conn = await self.acquire()
|
|
256
|
+
try:
|
|
257
|
+
yield conn
|
|
258
|
+
finally:
|
|
259
|
+
await self.release(conn)
|
|
260
|
+
|
|
261
|
+
async def close(self) -> None:
|
|
262
|
+
"""Close all connections and shutdown the pool."""
|
|
263
|
+
self._closed = True
|
|
264
|
+
|
|
265
|
+
async with self._lock:
|
|
266
|
+
# Close all connections
|
|
267
|
+
for conn in self._connections:
|
|
268
|
+
try:
|
|
269
|
+
await conn.close()
|
|
270
|
+
except Exception:
|
|
271
|
+
# Connection may be already closed - intentional silent catch
|
|
272
|
+
log.debug("Failed to close connection (may be already closed)", exc_info=True)
|
|
273
|
+
self._connections.clear()
|
|
274
|
+
|
|
275
|
+
# Empty the queue
|
|
276
|
+
while not self._pool.empty():
|
|
277
|
+
try:
|
|
278
|
+
self._pool.get_nowait()
|
|
279
|
+
except asyncio.QueueEmpty:
|
|
280
|
+
break
|
|
281
|
+
|
|
282
|
+
self._stats.active_connections = 0
|
|
283
|
+
self._stats.idle_connections = 0
|
|
284
|
+
log.debug("Connection pool closed")
|
|
285
|
+
|
|
286
|
+
@property
|
|
287
|
+
def stats(self) -> PoolStats:
|
|
288
|
+
"""Get pool statistics."""
|
|
289
|
+
return self._stats
|
|
290
|
+
|
|
291
|
+
@property
|
|
292
|
+
def size(self) -> int:
|
|
293
|
+
"""Current number of connections in pool."""
|
|
294
|
+
return len(self._connections)
|
|
295
|
+
|
|
296
|
+
@property
|
|
297
|
+
def is_closed(self) -> bool:
|
|
298
|
+
"""Whether the pool is closed."""
|
|
299
|
+
return self._closed
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
__all__ = ["ConnectionPool", "PoolStats"]
|