|
38 | 38 | import uuid |
39 | 39 | import warnings |
40 | 40 | from collections.abc import Collection, Iterable, Iterator |
41 | | -from typing import Any |
| 41 | +from typing import Any, Protocol, cast, runtime_checkable |
42 | 42 |
|
43 | 43 | from kopf._cogs.structs import bodies, patches |
44 | 44 |
|
@@ -347,3 +347,104 @@ def _temp_filename(self, path: pathlib.Path) -> Iterator[pathlib.Path]: |
347 | 347 | raise |
348 | 348 | else: |
349 | 349 | temp.rename(path) # atomic overwrite |
| 350 | + |
| 351 | + |
| 352 | +# Sqlite3 is sometimes broken, so we import only on demand, so we cannot use it in annotations. |
| 353 | +# Add more methods as needed. Only actually used methods are listed here. There can be more. |
| 354 | +class sqlite3_Cursor(Protocol): |
| 355 | + def fetchone(self) -> list[Any]: ... |
| 356 | + |
| 357 | + |
| 358 | +@runtime_checkable |
| 359 | +class sqlite3_Connection(Protocol): |
| 360 | + def execute(self, sql: str, parameters: tuple[Any, ...] = ()) -> sqlite3_Cursor: ... |
| 361 | + def close(self) -> None: ... |
| 362 | + def __enter__(self) -> Any: ... |
| 363 | + def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: ... |
| 364 | + |
| 365 | + |
| 366 | +class SQLiteConvention: |
| 367 | + """ |
| 368 | + A mixin for SQLite-based storages with optimistic table creation. |
| 369 | +
|
| 370 | + Operations are tried first; if the table does not exist, it is created |
| 371 | + and the operation is retried. This avoids upfront schema management |
| 372 | + and lets both storage types share the same database file when pointed |
| 373 | + to the same path. |
| 374 | + """ |
| 375 | + |
| 376 | + _create_sql: str # to be defined by subclasses |
| 377 | + |
| 378 | + # If path is None, we do not own the connection, someone else does, we just use it. |
| 379 | + # If path is set, we own the connection, create and close it as needed. |
| 380 | + _path: pathlib.Path | None |
| 381 | + _conn: sqlite3_Connection | None |
| 382 | + |
| 383 | + def __init__( |
| 384 | + self, |
| 385 | + path_or_conn: sqlite3_Connection | str | pathlib.Path, |
| 386 | + /, |
| 387 | + **kwargs: Any, |
| 388 | + ) -> None: |
| 389 | + super().__init__(**kwargs) |
| 390 | + import sqlite3 # sometimes broken, so import only on demand |
| 391 | + if isinstance(path_or_conn, (sqlite3_Connection, sqlite3.Connection)): |
| 392 | + self._conn = path_or_conn |
| 393 | + self._path = None |
| 394 | + else: |
| 395 | + self._conn = None |
| 396 | + self._path = pathlib.Path(path_or_conn) |
| 397 | + |
| 398 | + async def __aenter__(self) -> None: |
| 399 | + if self._path is None: |
| 400 | + return |
| 401 | + import sqlite3 # sometimes broken, so import only on demand |
| 402 | + self._path.parent.mkdir(parents=True, exist_ok=True) |
| 403 | + self._conn = cast(sqlite3_Connection, sqlite3.connect(str(self._path))) |
| 404 | + |
| 405 | + async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: |
| 406 | + if self._path is not None and self._conn is not None: |
| 407 | + self._conn.close() |
| 408 | + self._conn = None |
| 409 | + |
| 410 | + @staticmethod |
| 411 | + def _extract_keys(body: bodies.Body) -> tuple[str, str, str]: |
| 412 | + # Primary keys cannot store nulls, so we use empty strings as markers. |
| 413 | + # K8s does not allow empty names/uids, so we are safe from collisions. |
| 414 | + namespace = body.get('metadata', {}).get('namespace', '') |
| 415 | + name = body.get('metadata', {}).get('name', '') |
| 416 | + uid = body.get('metadata', {}).get('uid', '') |
| 417 | + return namespace, name, uid |
| 418 | + |
| 419 | + def _execute( |
| 420 | + self, |
| 421 | + sql: str, |
| 422 | + params: tuple[Any, ...] = (), |
| 423 | + ) -> sqlite3_Cursor: |
| 424 | + """Execute SQL, creating the table optimistically if absent.""" |
| 425 | + import sqlite3 # sometimes broken, so import only on demand |
| 426 | + |
| 427 | + assert self._conn is not None |
| 428 | + try: |
| 429 | + return self._conn.execute(sql, params) |
| 430 | + except sqlite3.OperationalError as e: |
| 431 | + if 'no such table' not in str(e): |
| 432 | + raise |
| 433 | + self._conn.execute(self._create_sql) |
| 434 | + return self._conn.execute(sql, params) |
| 435 | + |
| 436 | + def _try_execute( |
| 437 | + self, |
| 438 | + sql: str, |
| 439 | + params: tuple[Any, ...] = (), |
| 440 | + ) -> sqlite3_Cursor | None: |
| 441 | + """Execute SQL, returning None if the table does not exist.""" |
| 442 | + import sqlite3 # sometimes broken, so import only on demand |
| 443 | + |
| 444 | + assert self._conn is not None |
| 445 | + try: |
| 446 | + return self._conn.execute(sql, params) |
| 447 | + except sqlite3.OperationalError as e: |
| 448 | + if 'no such table' not in str(e): |
| 449 | + raise |
| 450 | + return None |
0 commit comments