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.
Files changed (166) hide show
  1. kstlib/__init__.py +266 -1
  2. kstlib/__main__.py +16 -0
  3. kstlib/alerts/__init__.py +110 -0
  4. kstlib/alerts/channels/__init__.py +36 -0
  5. kstlib/alerts/channels/base.py +197 -0
  6. kstlib/alerts/channels/email.py +227 -0
  7. kstlib/alerts/channels/slack.py +389 -0
  8. kstlib/alerts/exceptions.py +72 -0
  9. kstlib/alerts/manager.py +651 -0
  10. kstlib/alerts/models.py +142 -0
  11. kstlib/alerts/throttle.py +263 -0
  12. kstlib/auth/__init__.py +139 -0
  13. kstlib/auth/callback.py +399 -0
  14. kstlib/auth/config.py +502 -0
  15. kstlib/auth/errors.py +127 -0
  16. kstlib/auth/models.py +316 -0
  17. kstlib/auth/providers/__init__.py +14 -0
  18. kstlib/auth/providers/base.py +393 -0
  19. kstlib/auth/providers/oauth2.py +645 -0
  20. kstlib/auth/providers/oidc.py +821 -0
  21. kstlib/auth/session.py +338 -0
  22. kstlib/auth/token.py +482 -0
  23. kstlib/cache/__init__.py +50 -0
  24. kstlib/cache/decorator.py +261 -0
  25. kstlib/cache/strategies.py +516 -0
  26. kstlib/cli/__init__.py +8 -0
  27. kstlib/cli/app.py +195 -0
  28. kstlib/cli/commands/__init__.py +5 -0
  29. kstlib/cli/commands/auth/__init__.py +39 -0
  30. kstlib/cli/commands/auth/common.py +122 -0
  31. kstlib/cli/commands/auth/login.py +325 -0
  32. kstlib/cli/commands/auth/logout.py +74 -0
  33. kstlib/cli/commands/auth/providers.py +57 -0
  34. kstlib/cli/commands/auth/status.py +291 -0
  35. kstlib/cli/commands/auth/token.py +199 -0
  36. kstlib/cli/commands/auth/whoami.py +106 -0
  37. kstlib/cli/commands/config.py +89 -0
  38. kstlib/cli/commands/ops/__init__.py +39 -0
  39. kstlib/cli/commands/ops/attach.py +49 -0
  40. kstlib/cli/commands/ops/common.py +269 -0
  41. kstlib/cli/commands/ops/list_sessions.py +252 -0
  42. kstlib/cli/commands/ops/logs.py +49 -0
  43. kstlib/cli/commands/ops/start.py +98 -0
  44. kstlib/cli/commands/ops/status.py +138 -0
  45. kstlib/cli/commands/ops/stop.py +60 -0
  46. kstlib/cli/commands/rapi/__init__.py +60 -0
  47. kstlib/cli/commands/rapi/call.py +341 -0
  48. kstlib/cli/commands/rapi/list.py +99 -0
  49. kstlib/cli/commands/rapi/show.py +206 -0
  50. kstlib/cli/commands/secrets/__init__.py +35 -0
  51. kstlib/cli/commands/secrets/common.py +425 -0
  52. kstlib/cli/commands/secrets/decrypt.py +88 -0
  53. kstlib/cli/commands/secrets/doctor.py +743 -0
  54. kstlib/cli/commands/secrets/encrypt.py +242 -0
  55. kstlib/cli/commands/secrets/shred.py +96 -0
  56. kstlib/cli/common.py +86 -0
  57. kstlib/config/__init__.py +76 -0
  58. kstlib/config/exceptions.py +110 -0
  59. kstlib/config/export.py +225 -0
  60. kstlib/config/loader.py +963 -0
  61. kstlib/config/sops.py +287 -0
  62. kstlib/db/__init__.py +54 -0
  63. kstlib/db/aiosqlcipher.py +137 -0
  64. kstlib/db/cipher.py +112 -0
  65. kstlib/db/database.py +367 -0
  66. kstlib/db/exceptions.py +25 -0
  67. kstlib/db/pool.py +302 -0
  68. kstlib/helpers/__init__.py +35 -0
  69. kstlib/helpers/exceptions.py +11 -0
  70. kstlib/helpers/time_trigger.py +396 -0
  71. kstlib/kstlib.conf.yml +890 -0
  72. kstlib/limits.py +963 -0
  73. kstlib/logging/__init__.py +108 -0
  74. kstlib/logging/manager.py +633 -0
  75. kstlib/mail/__init__.py +42 -0
  76. kstlib/mail/builder.py +626 -0
  77. kstlib/mail/exceptions.py +27 -0
  78. kstlib/mail/filesystem.py +248 -0
  79. kstlib/mail/transport.py +224 -0
  80. kstlib/mail/transports/__init__.py +19 -0
  81. kstlib/mail/transports/gmail.py +268 -0
  82. kstlib/mail/transports/resend.py +324 -0
  83. kstlib/mail/transports/smtp.py +326 -0
  84. kstlib/meta.py +72 -0
  85. kstlib/metrics/__init__.py +88 -0
  86. kstlib/metrics/decorators.py +1090 -0
  87. kstlib/metrics/exceptions.py +14 -0
  88. kstlib/monitoring/__init__.py +116 -0
  89. kstlib/monitoring/_styles.py +163 -0
  90. kstlib/monitoring/cell.py +57 -0
  91. kstlib/monitoring/config.py +424 -0
  92. kstlib/monitoring/delivery.py +579 -0
  93. kstlib/monitoring/exceptions.py +63 -0
  94. kstlib/monitoring/image.py +220 -0
  95. kstlib/monitoring/kv.py +79 -0
  96. kstlib/monitoring/list.py +69 -0
  97. kstlib/monitoring/metric.py +88 -0
  98. kstlib/monitoring/monitoring.py +341 -0
  99. kstlib/monitoring/renderer.py +139 -0
  100. kstlib/monitoring/service.py +392 -0
  101. kstlib/monitoring/table.py +129 -0
  102. kstlib/monitoring/types.py +56 -0
  103. kstlib/ops/__init__.py +86 -0
  104. kstlib/ops/base.py +148 -0
  105. kstlib/ops/container.py +577 -0
  106. kstlib/ops/exceptions.py +209 -0
  107. kstlib/ops/manager.py +407 -0
  108. kstlib/ops/models.py +176 -0
  109. kstlib/ops/tmux.py +372 -0
  110. kstlib/ops/validators.py +287 -0
  111. kstlib/py.typed +0 -0
  112. kstlib/rapi/__init__.py +118 -0
  113. kstlib/rapi/client.py +875 -0
  114. kstlib/rapi/config.py +861 -0
  115. kstlib/rapi/credentials.py +887 -0
  116. kstlib/rapi/exceptions.py +213 -0
  117. kstlib/resilience/__init__.py +101 -0
  118. kstlib/resilience/circuit_breaker.py +440 -0
  119. kstlib/resilience/exceptions.py +95 -0
  120. kstlib/resilience/heartbeat.py +491 -0
  121. kstlib/resilience/rate_limiter.py +506 -0
  122. kstlib/resilience/shutdown.py +417 -0
  123. kstlib/resilience/watchdog.py +637 -0
  124. kstlib/secrets/__init__.py +29 -0
  125. kstlib/secrets/exceptions.py +19 -0
  126. kstlib/secrets/models.py +62 -0
  127. kstlib/secrets/providers/__init__.py +79 -0
  128. kstlib/secrets/providers/base.py +58 -0
  129. kstlib/secrets/providers/environment.py +66 -0
  130. kstlib/secrets/providers/keyring.py +107 -0
  131. kstlib/secrets/providers/kms.py +223 -0
  132. kstlib/secrets/providers/kwargs.py +101 -0
  133. kstlib/secrets/providers/sops.py +209 -0
  134. kstlib/secrets/resolver.py +221 -0
  135. kstlib/secrets/sensitive.py +130 -0
  136. kstlib/secure/__init__.py +23 -0
  137. kstlib/secure/fs.py +194 -0
  138. kstlib/secure/permissions.py +70 -0
  139. kstlib/ssl.py +347 -0
  140. kstlib/ui/__init__.py +23 -0
  141. kstlib/ui/exceptions.py +26 -0
  142. kstlib/ui/panels.py +484 -0
  143. kstlib/ui/spinner.py +864 -0
  144. kstlib/ui/tables.py +382 -0
  145. kstlib/utils/__init__.py +48 -0
  146. kstlib/utils/dict.py +36 -0
  147. kstlib/utils/formatting.py +338 -0
  148. kstlib/utils/http_trace.py +237 -0
  149. kstlib/utils/lazy.py +49 -0
  150. kstlib/utils/secure_delete.py +205 -0
  151. kstlib/utils/serialization.py +247 -0
  152. kstlib/utils/text.py +56 -0
  153. kstlib/utils/validators.py +124 -0
  154. kstlib/websocket/__init__.py +97 -0
  155. kstlib/websocket/exceptions.py +214 -0
  156. kstlib/websocket/manager.py +1102 -0
  157. kstlib/websocket/models.py +361 -0
  158. kstlib-1.0.0.dist-info/METADATA +201 -0
  159. kstlib-1.0.0.dist-info/RECORD +163 -0
  160. {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/WHEEL +1 -1
  161. kstlib-1.0.0.dist-info/entry_points.txt +2 -0
  162. kstlib-1.0.0.dist-info/licenses/LICENSE.md +9 -0
  163. kstlib-0.0.1a0.dist-info/METADATA +0 -29
  164. kstlib-0.0.1a0.dist-info/RECORD +0 -6
  165. kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
  166. {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"]
@@ -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"]