feat: add async sqlite connection wrapper with executor-backed queries
This commit is contained in:
@@ -10,6 +10,8 @@ from homeassistant.config_entries import ConfigEntry
|
|||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
from .const import DOMAIN, LOGGER
|
from .const import DOMAIN, LOGGER
|
||||||
|
from .db import NestQuestDatabase
|
||||||
|
from .store import async_get_db_path
|
||||||
|
|
||||||
_LOGGER = LOGGER
|
_LOGGER = LOGGER
|
||||||
|
|
||||||
@@ -20,6 +22,7 @@ class NestQuestRuntimeData:
|
|||||||
|
|
||||||
entry_id: str
|
entry_id: str
|
||||||
options: dict[str, Any]
|
options: dict[str, Any]
|
||||||
|
database: NestQuestDatabase
|
||||||
remove_update_listener: Callable[[], Any]
|
remove_update_listener: Callable[[], Any]
|
||||||
|
|
||||||
|
|
||||||
@@ -31,6 +34,14 @@ async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
|
|||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Set up NestQuest from a config entry."""
|
"""Set up NestQuest from a config entry."""
|
||||||
hass.data.setdefault(DOMAIN, {})
|
hass.data.setdefault(DOMAIN, {})
|
||||||
|
existing = hass.data[DOMAIN].get(entry.entry_id)
|
||||||
|
if existing is not None:
|
||||||
|
db = getattr(existing, "database", None)
|
||||||
|
if db is None:
|
||||||
|
db = await NestQuestDatabase(hass).open(await async_get_db_path(hass))
|
||||||
|
existing.database = db
|
||||||
|
entry.runtime_data = existing
|
||||||
|
return True
|
||||||
|
|
||||||
async def _async_update_listener(
|
async def _async_update_listener(
|
||||||
listener_hass: HomeAssistant, listener_entry: ConfigEntry
|
listener_hass: HomeAssistant, listener_entry: ConfigEntry
|
||||||
@@ -38,9 +49,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
"""Reload the entry when its options change."""
|
"""Reload the entry when its options change."""
|
||||||
await listener_hass.config_entries.async_reload(listener_entry.entry_id)
|
await listener_hass.config_entries.async_reload(listener_entry.entry_id)
|
||||||
|
|
||||||
|
database = await NestQuestDatabase(hass).open(await async_get_db_path(hass))
|
||||||
runtime_data = NestQuestRuntimeData(
|
runtime_data = NestQuestRuntimeData(
|
||||||
entry_id=entry.entry_id,
|
entry_id=entry.entry_id,
|
||||||
options=dict(entry.options),
|
options=dict(entry.options),
|
||||||
|
database=database,
|
||||||
remove_update_listener=entry.add_update_listener(_async_update_listener),
|
remove_update_listener=entry.add_update_listener(_async_update_listener),
|
||||||
)
|
)
|
||||||
entry.runtime_data = runtime_data
|
entry.runtime_data = runtime_data
|
||||||
@@ -62,6 +75,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
result = remove_update_listener()
|
result = remove_update_listener()
|
||||||
if inspect.isawaitable(result):
|
if inspect.isawaitable(result):
|
||||||
await result
|
await result
|
||||||
|
database = getattr(runtime_data, "database", None)
|
||||||
|
if database is not None:
|
||||||
|
await database.close()
|
||||||
if getattr(entry, "runtime_data", None) is not None:
|
if getattr(entry, "runtime_data", None) is not None:
|
||||||
entry.runtime_data = None
|
entry.runtime_data = None
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
"""Async SQLite connection wrapper for the NestQuest integration."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from collections.abc import AsyncIterator, Sequence
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
|
||||||
|
class NestQuestDatabase:
|
||||||
|
"""Async wrapper around a single SQLite connection.
|
||||||
|
|
||||||
|
Every sqlite3 call is delegated to the Home Assistant executor through
|
||||||
|
``hass.async_add_executor_job`` so the event loop is never blocked.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, hass: HomeAssistant) -> None:
|
||||||
|
"""Initialize the wrapper around a Home Assistant instance."""
|
||||||
|
self._hass = hass
|
||||||
|
self._conn: sqlite3.Connection | None = None
|
||||||
|
self._cursor: sqlite3.Cursor | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def connected(self) -> bool:
|
||||||
|
"""Return True while the underlying connection is open."""
|
||||||
|
return self._conn is not None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def rowcount(self) -> int:
|
||||||
|
"""Row count of the most recent statement, or -1 when unset."""
|
||||||
|
return self._cursor.rowcount if self._cursor is not None else -1
|
||||||
|
|
||||||
|
@property
|
||||||
|
def lastrowid(self) -> int | None:
|
||||||
|
"""Row id of the most recent insert, or None when unset."""
|
||||||
|
return self._cursor.lastrowid if self._cursor is not None else None
|
||||||
|
|
||||||
|
async def open(self, path: Path | str) -> NestQuestDatabase:
|
||||||
|
"""Open the SQLite file with WAL journal mode and foreign keys on."""
|
||||||
|
if self._conn is not None:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def _open() -> sqlite3.Connection:
|
||||||
|
conn = sqlite3.connect(
|
||||||
|
path, check_same_thread=False, isolation_level=None
|
||||||
|
)
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA foreign_keys=ON")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
self._conn = await self._hass.async_add_executor_job(_open)
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def execute(
|
||||||
|
self, sql: str, parameters: Sequence[Any] = ()
|
||||||
|
) -> sqlite3.Cursor:
|
||||||
|
"""Run one statement in the executor and return its cursor."""
|
||||||
|
conn = self._require_conn()
|
||||||
|
|
||||||
|
def _execute() -> sqlite3.Cursor:
|
||||||
|
return conn.execute(sql, parameters)
|
||||||
|
|
||||||
|
self._cursor = await self._hass.async_add_executor_job(_execute)
|
||||||
|
return self._cursor
|
||||||
|
|
||||||
|
async def execute_many(
|
||||||
|
self, sql: str, parameters: Sequence[Sequence[Any]]
|
||||||
|
) -> sqlite3.Cursor:
|
||||||
|
"""Run executemany in the executor and return its cursor."""
|
||||||
|
conn = self._require_conn()
|
||||||
|
|
||||||
|
def _execute_many() -> sqlite3.Cursor:
|
||||||
|
return conn.executemany(sql, parameters)
|
||||||
|
|
||||||
|
self._cursor = await self._hass.async_add_executor_job(_execute_many)
|
||||||
|
return self._cursor
|
||||||
|
|
||||||
|
async def fetch_one(
|
||||||
|
self, sql: str, parameters: Sequence[Any] = ()
|
||||||
|
) -> tuple | None:
|
||||||
|
"""Run a query in the executor and return its first row, or None."""
|
||||||
|
cursor = await self.execute(sql, parameters)
|
||||||
|
|
||||||
|
def _fetch_one() -> tuple | None:
|
||||||
|
return cursor.fetchone()
|
||||||
|
|
||||||
|
return await self._hass.async_add_executor_job(_fetch_one)
|
||||||
|
|
||||||
|
async def fetch_all(
|
||||||
|
self, sql: str, parameters: Sequence[Any] = ()
|
||||||
|
) -> list[tuple]:
|
||||||
|
"""Run a query in the executor and return all of its rows."""
|
||||||
|
cursor = await self.execute(sql, parameters)
|
||||||
|
|
||||||
|
def _fetch_all() -> list[tuple]:
|
||||||
|
return cursor.fetchall()
|
||||||
|
|
||||||
|
return await self._hass.async_add_executor_job(_fetch_all)
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def transaction(self) -> AsyncIterator[None]:
|
||||||
|
"""Run a block inside BEGIN/COMMIT, rolling back on any exception."""
|
||||||
|
conn = self._require_conn()
|
||||||
|
|
||||||
|
def _begin() -> None:
|
||||||
|
conn.execute("BEGIN")
|
||||||
|
|
||||||
|
await self._hass.async_add_executor_job(_begin)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
except BaseException:
|
||||||
|
|
||||||
|
def _rollback() -> None:
|
||||||
|
conn.execute("ROLLBACK")
|
||||||
|
|
||||||
|
await self._hass.async_add_executor_job(_rollback)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _commit() -> None:
|
||||||
|
conn.execute("COMMIT")
|
||||||
|
|
||||||
|
await self._hass.async_add_executor_job(_commit)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Close the connection in the executor; safe to call repeatedly."""
|
||||||
|
conn, self._conn, self._cursor = self._conn, None, None
|
||||||
|
if conn is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
def _close() -> None:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
await self._hass.async_add_executor_job(_close)
|
||||||
|
|
||||||
|
def _require_conn(self) -> sqlite3.Connection:
|
||||||
|
if self._conn is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"The NestQuest database connection is closed; call open() first"
|
||||||
|
)
|
||||||
|
return self._conn
|
||||||
+26
-1
@@ -25,6 +25,9 @@ import asyncio
|
|||||||
import importlib.util
|
import importlib.util
|
||||||
import inspect
|
import inspect
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import weakref
|
||||||
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
@@ -218,13 +221,35 @@ def wire_entry_to_registry(entry, registry: ListenerRegistry):
|
|||||||
return entry
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
class _HassNamespace(SimpleNamespace):
|
||||||
|
"""SimpleNamespace subclass so the hass stub can hold weak references."""
|
||||||
|
|
||||||
|
|
||||||
def make_hass() -> tuple:
|
def make_hass() -> tuple:
|
||||||
"""Create a hass stub plus its listener registry, mirroring HA's shape."""
|
"""Create a hass stub plus its listener registry, mirroring HA's shape."""
|
||||||
hass = SimpleNamespace(
|
config_dir = Path(tempfile.mkdtemp(prefix="nestquest-hass-"))
|
||||||
|
hass = _HassNamespace(
|
||||||
data={},
|
data={},
|
||||||
config=MagicMock(),
|
config=MagicMock(),
|
||||||
config_entries=SimpleNamespace(async_reload=None),
|
config_entries=SimpleNamespace(async_reload=None),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _cleanup_config_dir():
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
shutil.rmtree(config_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
weakref.finalize(hass, _cleanup_config_dir)
|
||||||
|
hass.config.path = MagicMock(
|
||||||
|
side_effect=lambda name: str(config_dir / (name or "config"))
|
||||||
|
)
|
||||||
|
|
||||||
|
def _run_executor_job(fn, *args, **kwargs):
|
||||||
|
return asyncio.get_running_loop().run_in_executor(
|
||||||
|
None, lambda: fn(*args, **kwargs)
|
||||||
|
)
|
||||||
|
|
||||||
|
hass.async_add_executor_job = _run_executor_job
|
||||||
registry = ListenerRegistry(hass)
|
registry = ListenerRegistry(hass)
|
||||||
|
|
||||||
async def _async_reload(entry_id: str) -> None:
|
async def _async_reload(entry_id: str) -> None:
|
||||||
|
|||||||
@@ -0,0 +1,381 @@
|
|||||||
|
"""Tests for db.py, the async SQLite connection wrapper of NestQuest."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from conftest import make_hass
|
||||||
|
|
||||||
|
from custom_components.nestquest import db as db_module
|
||||||
|
from custom_components.nestquest.db import NestQuestDatabase
|
||||||
|
|
||||||
|
|
||||||
|
def _run(coro):
|
||||||
|
return asyncio.new_event_loop().run_until_complete(coro)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_hass_mock():
|
||||||
|
hass = MagicMock()
|
||||||
|
hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a)))
|
||||||
|
return hass
|
||||||
|
|
||||||
|
|
||||||
|
def _open_db(path) -> tuple[NestQuestDatabase, MagicMock]:
|
||||||
|
hass = _make_hass_mock()
|
||||||
|
database = NestQuestDatabase(hass)
|
||||||
|
_run(database.open(path))
|
||||||
|
return database, hass
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# open(): pragmas, executor delegation, idempotence
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_enables_wal_journal_mode(tmp_path) -> None:
|
||||||
|
database, _hass = _open_db(tmp_path / "wal.db")
|
||||||
|
row = _run(database.fetch_one("PRAGMA journal_mode"))
|
||||||
|
assert row is not None and str(row[0]).lower() == "wal"
|
||||||
|
_run(database.close())
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_enables_foreign_key_enforcement(tmp_path) -> None:
|
||||||
|
database, _hass = _open_db(tmp_path / "fk.db")
|
||||||
|
row = _run(database.fetch_one("PRAGMA foreign_keys"))
|
||||||
|
assert row is not None and int(row[0]) == 1
|
||||||
|
_run(database.close())
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_delegates_connection_to_executor(tmp_path) -> None:
|
||||||
|
hass = _make_hass_mock()
|
||||||
|
database = NestQuestDatabase(hass)
|
||||||
|
_run(database.open(tmp_path / "exec.db"))
|
||||||
|
first_call = hass.async_add_executor_job.call_args_list[0]
|
||||||
|
assert callable(first_call.args[0])
|
||||||
|
_run(database.close())
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_twice_returns_same_wrapper_without_second_connect(tmp_path) -> None:
|
||||||
|
path = tmp_path / "once.db"
|
||||||
|
hass = _make_hass_mock()
|
||||||
|
database = NestQuestDatabase(hass)
|
||||||
|
first = _run(database.open(path))
|
||||||
|
second = _run(database.open(path))
|
||||||
|
assert first is database
|
||||||
|
assert second is database
|
||||||
|
assert database.connected is True
|
||||||
|
opens = [
|
||||||
|
c
|
||||||
|
for c in hass.async_add_executor_job.call_args_list
|
||||||
|
if getattr(c.args[0], "__name__", "") == "_open"
|
||||||
|
]
|
||||||
|
assert len(opens) == 1
|
||||||
|
_run(database.close())
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_accepts_str_path(tmp_path) -> None:
|
||||||
|
database, _hass = _open_db(str(tmp_path / "str.db"))
|
||||||
|
assert database.connected is True
|
||||||
|
_run(database.close())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Executor delegation: every sqlite3 call happens off the event loop
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_method_routes_sqlite3_through_executor(tmp_path) -> None:
|
||||||
|
"""Each wrapper call must go via hass.async_add_executor_job."""
|
||||||
|
database, hass = _open_db(tmp_path / "route.db")
|
||||||
|
calls_before = hass.async_add_executor_job.await_count
|
||||||
|
|
||||||
|
_run(database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)"))
|
||||||
|
_run(database.execute("INSERT INTO t (name) VALUES (?)", ("a",)))
|
||||||
|
_run(database.execute_many("INSERT INTO t (name) VALUES (?)", [("b",), ("c",)]))
|
||||||
|
row = _run(database.fetch_one("SELECT name FROM t WHERE id = 1"))
|
||||||
|
rows = _run(database.fetch_all("SELECT name FROM t ORDER BY id"))
|
||||||
|
|
||||||
|
assert row == ("a",)
|
||||||
|
assert rows == [("a",), ("b",), ("c",)]
|
||||||
|
assert hass.async_add_executor_job.await_count == calls_before + 7
|
||||||
|
_run(database.close())
|
||||||
|
|
||||||
|
|
||||||
|
async def test_every_wrapper_call_awaits_executor_job(tmp_path) -> None:
|
||||||
|
"""The executor surface is awaited per wrapper call on a live loop."""
|
||||||
|
hass = _make_hass_mock()
|
||||||
|
database = NestQuestDatabase(hass)
|
||||||
|
await database.open(tmp_path / "loop.db")
|
||||||
|
|
||||||
|
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
|
||||||
|
await database.fetch_one("SELECT 1")
|
||||||
|
await database.fetch_all("SELECT 1")
|
||||||
|
await database.execute_many("INSERT INTO t (id) VALUES (?)", [(1,)])
|
||||||
|
async with database.transaction():
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert hass.async_add_executor_job.await_count == 9
|
||||||
|
assert all(callable(c.args[0]) for c in hass.async_add_executor_job.call_args_list)
|
||||||
|
await database.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_no_sqlite3_call_on_event_loop_with_real_executor(tmp_path) -> None:
|
||||||
|
"""With the conftest hass (real run_in_executor), queries still succeed."""
|
||||||
|
hass, _registry = make_hass()
|
||||||
|
database = NestQuestDatabase(hass)
|
||||||
|
await database.open(tmp_path / "real.db")
|
||||||
|
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
|
||||||
|
assert await database.fetch_one("SELECT 1") == (1,)
|
||||||
|
await database.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Source scan: sqlite3.* only inside executor-delegated functions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_with_scope(node, chain):
|
||||||
|
"""Yield (node, enclosing-function chain), depth-first."""
|
||||||
|
for child in ast.iter_child_nodes(node):
|
||||||
|
yield child, chain
|
||||||
|
next_chain = chain + (child,) if isinstance(
|
||||||
|
child, (ast.FunctionDef, ast.AsyncFunctionDef)
|
||||||
|
) else chain
|
||||||
|
yield from _iter_with_scope(child, next_chain)
|
||||||
|
|
||||||
|
|
||||||
|
def test_db_sqlite3_calls_only_inside_executor_delegated_functions() -> None:
|
||||||
|
"""Every sqlite3.* call in db.py sits inside an executor-delegating function.
|
||||||
|
|
||||||
|
A function is executor-delegating when it (or, for nested closures, any of
|
||||||
|
its enclosing functions) passes work to ``hass.async_add_executor_job``.
|
||||||
|
"""
|
||||||
|
source = Path(db_module.__file__).read_text(encoding="utf-8")
|
||||||
|
tree = ast.parse(source, filename=str(db_module.__file__))
|
||||||
|
|
||||||
|
delegated = {
|
||||||
|
node.name
|
||||||
|
for node in ast.walk(tree)
|
||||||
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||||
|
and any(
|
||||||
|
isinstance(sub, ast.Call)
|
||||||
|
and isinstance(sub.func, ast.Attribute)
|
||||||
|
and sub.func.attr == "async_add_executor_job"
|
||||||
|
for sub in ast.walk(node)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assert delegated, "expected executor-delegated functions in db.py"
|
||||||
|
|
||||||
|
violations = []
|
||||||
|
for node, chain in _iter_with_scope(tree, ()):
|
||||||
|
if (
|
||||||
|
isinstance(node, ast.Call)
|
||||||
|
and isinstance(node.func, ast.Attribute)
|
||||||
|
and isinstance(node.func.value, ast.Name)
|
||||||
|
and node.func.value.id == "sqlite3"
|
||||||
|
and not any(f.name in delegated for f in chain)
|
||||||
|
):
|
||||||
|
violations.append((node.lineno, node.func.attr))
|
||||||
|
assert violations == [], (
|
||||||
|
f"Direct sqlite3 calls outside executor-delegated functions: {violations}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_package_wide_sqlite3_scan_excludes_only_db_module() -> None:
|
||||||
|
"""The package-wide store scan exempts exactly the db module."""
|
||||||
|
pkg_dir = Path(db_module.__file__).parent
|
||||||
|
scanned = [p.name for p in pkg_dir.glob("*.py") if p.name != "db.py"]
|
||||||
|
assert scanned, "expected other package modules to be scanned"
|
||||||
|
assert "db.py" not in scanned
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# close(): idempotence and post-close errors
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_close_is_idempotent(tmp_path) -> None:
|
||||||
|
database, hass = _open_db(tmp_path / "idem.db")
|
||||||
|
_run(database.close())
|
||||||
|
assert database.connected is False
|
||||||
|
_run(database.close())
|
||||||
|
assert database.connected is False
|
||||||
|
assert hass.async_add_executor_job.await_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_operations_after_close_raise_clear_error(tmp_path) -> None:
|
||||||
|
database, _hass = _open_db(tmp_path / "closed.db")
|
||||||
|
_run(database.close())
|
||||||
|
|
||||||
|
async def _txn():
|
||||||
|
async with database.transaction():
|
||||||
|
pass
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="closed"):
|
||||||
|
_run(database.execute("SELECT 1"))
|
||||||
|
with pytest.raises(RuntimeError, match="closed"):
|
||||||
|
_run(database.fetch_one("SELECT 1"))
|
||||||
|
with pytest.raises(RuntimeError, match="closed"):
|
||||||
|
_run(database.fetch_all("SELECT 1"))
|
||||||
|
with pytest.raises(RuntimeError, match="closed"):
|
||||||
|
_run(database.execute_many("SELECT 1", []))
|
||||||
|
with pytest.raises(RuntimeError, match="closed"):
|
||||||
|
_run(_txn())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Transactions: commit on success, rollback on exception
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_transaction_commits_on_success(tmp_path) -> None:
|
||||||
|
database, _hass = _open_db(tmp_path / "commit.db")
|
||||||
|
_run(database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)"))
|
||||||
|
|
||||||
|
async def _block():
|
||||||
|
async with database.transaction():
|
||||||
|
await database.execute("INSERT INTO t (name) VALUES (?)", ("kept",))
|
||||||
|
|
||||||
|
_run(_block())
|
||||||
|
assert _run(database.fetch_all("SELECT name FROM t")) == [("kept",)]
|
||||||
|
_run(database.close())
|
||||||
|
|
||||||
|
|
||||||
|
def test_transaction_rolls_back_on_exception(tmp_path) -> None:
|
||||||
|
database, _hass = _open_db(tmp_path / "rollback.db")
|
||||||
|
_run(database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)"))
|
||||||
|
_run(database.execute("INSERT INTO t (name) VALUES (?)", ("before",)))
|
||||||
|
|
||||||
|
async def _block():
|
||||||
|
with pytest.raises(ValueError, match="boom"):
|
||||||
|
async with database.transaction():
|
||||||
|
await database.execute(
|
||||||
|
"INSERT INTO t (name) VALUES (?)", ("dropped",)
|
||||||
|
)
|
||||||
|
raise ValueError("boom")
|
||||||
|
|
||||||
|
_run(_block())
|
||||||
|
assert _run(database.fetch_all("SELECT name FROM t")) == [("before",)]
|
||||||
|
_run(database.close())
|
||||||
|
|
||||||
|
|
||||||
|
async def test_transaction_persists_across_connections(tmp_path) -> None:
|
||||||
|
"""A committed transaction is visible to a brand-new connection."""
|
||||||
|
hass, _registry = make_hass()
|
||||||
|
database = NestQuestDatabase(hass)
|
||||||
|
await database.open(tmp_path / "persist.db")
|
||||||
|
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
|
||||||
|
async with database.transaction():
|
||||||
|
await database.execute("INSERT INTO t (name) VALUES (?)", ("kept",))
|
||||||
|
await database.close()
|
||||||
|
|
||||||
|
other = NestQuestDatabase(hass)
|
||||||
|
await other.open(tmp_path / "persist.db")
|
||||||
|
assert await other.fetch_all("SELECT name FROM t") == [("kept",)]
|
||||||
|
await other.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# rowcount / lastrowid surface
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_rowcount_and_lastrowid_before_any_operation(tmp_path) -> None:
|
||||||
|
database, _hass = _open_db(tmp_path / "unset.db")
|
||||||
|
assert database.rowcount == -1
|
||||||
|
assert database.lastrowid is None
|
||||||
|
_run(database.close())
|
||||||
|
|
||||||
|
|
||||||
|
def test_execute_exposes_rowcount_and_lastrowid(tmp_path) -> None:
|
||||||
|
database, _hass = _open_db(tmp_path / "counts.db")
|
||||||
|
_run(database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)"))
|
||||||
|
_run(database.execute("INSERT INTO t (name) VALUES (?)", ("x",)))
|
||||||
|
assert database.rowcount == 1
|
||||||
|
assert database.lastrowid == 1
|
||||||
|
_run(database.execute("INSERT INTO t (name) VALUES (?)", ("y",)))
|
||||||
|
assert database.rowcount == 1
|
||||||
|
assert database.lastrowid == 2
|
||||||
|
_run(database.close())
|
||||||
|
|
||||||
|
|
||||||
|
def test_execute_many_exposes_rowcount(tmp_path) -> None:
|
||||||
|
database, _hass = _open_db(tmp_path / "many.db")
|
||||||
|
_run(database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)"))
|
||||||
|
_run(database.execute_many("INSERT INTO t (name) VALUES (?)", [("a",), ("b",)]))
|
||||||
|
assert database.rowcount == 2
|
||||||
|
_run(database.execute("INSERT INTO t (name) VALUES (?)", ("c",)))
|
||||||
|
assert database.lastrowid == 3
|
||||||
|
_run(database.close())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lifecycle wiring: opened once per entry, closed on unload
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_setup_entry_opens_database_in_runtime_data(hass, make_entry) -> None:
|
||||||
|
from conftest import wire_entry_to_registry
|
||||||
|
|
||||||
|
from custom_components.nestquest import async_setup_entry, async_unload_entry
|
||||||
|
|
||||||
|
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
||||||
|
assert await async_setup_entry(hass, entry) is True
|
||||||
|
runtime = entry.runtime_data
|
||||||
|
assert runtime.database is not None
|
||||||
|
assert runtime.database.connected is True
|
||||||
|
await async_unload_entry(hass, entry)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unload_entry_closes_database(hass, make_entry) -> None:
|
||||||
|
from conftest import wire_entry_to_registry
|
||||||
|
|
||||||
|
from custom_components.nestquest import async_setup_entry, async_unload_entry
|
||||||
|
|
||||||
|
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
||||||
|
await async_setup_entry(hass, entry)
|
||||||
|
database = entry.runtime_data.database
|
||||||
|
assert database.connected is True
|
||||||
|
assert await async_unload_entry(hass, entry) is True
|
||||||
|
assert database.connected is False
|
||||||
|
with pytest.raises(RuntimeError, match="closed"):
|
||||||
|
await database.fetch_one("SELECT 1")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_second_setup_does_not_open_second_connection(hass, make_entry) -> None:
|
||||||
|
from conftest import wire_entry_to_registry
|
||||||
|
|
||||||
|
from custom_components.nestquest import async_setup_entry, async_unload_entry
|
||||||
|
|
||||||
|
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
||||||
|
await async_setup_entry(hass, entry)
|
||||||
|
first = entry.runtime_data.database
|
||||||
|
|
||||||
|
await async_setup_entry(hass, entry)
|
||||||
|
second = entry.runtime_data.database
|
||||||
|
|
||||||
|
assert first is second
|
||||||
|
assert first.connected is True
|
||||||
|
await async_unload_entry(hass, entry)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_setup_unload_cycles_reopen_fresh_connection(hass, make_entry) -> None:
|
||||||
|
from conftest import wire_entry_to_registry
|
||||||
|
|
||||||
|
from custom_components.nestquest import async_setup_entry, async_unload_entry
|
||||||
|
|
||||||
|
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
||||||
|
seen: list[NestQuestDatabase] = []
|
||||||
|
for _ in range(3):
|
||||||
|
await async_setup_entry(hass, entry)
|
||||||
|
runtime = entry.runtime_data
|
||||||
|
assert runtime.database.connected is True
|
||||||
|
seen.append(runtime.database)
|
||||||
|
await async_unload_entry(hass, entry)
|
||||||
|
assert runtime.database.connected is False
|
||||||
|
assert len(set(seen)) == 3
|
||||||
|
assert hass.data == {}
|
||||||
+4
-1
@@ -100,11 +100,14 @@ def test_executor_helper_creates_directory(tmp_path) -> None:
|
|||||||
assert nested.is_dir()
|
assert nested.is_dir()
|
||||||
|
|
||||||
|
|
||||||
def test_no_sqlite3_import_in_package() -> None:
|
def test_no_sqlite3_import_outside_db_module() -> None:
|
||||||
|
"""sqlite3 is banned package-wide except db.py, the connection wrapper."""
|
||||||
pkg_dir = Path(store.__file__).parent
|
pkg_dir = Path(store.__file__).parent
|
||||||
py_files = list(pkg_dir.glob("*.py"))
|
py_files = list(pkg_dir.glob("*.py"))
|
||||||
assert py_files
|
assert py_files
|
||||||
for py_file in py_files:
|
for py_file in py_files:
|
||||||
|
if py_file.name == "db.py":
|
||||||
|
continue
|
||||||
tree = ast.parse(py_file.read_text(encoding="utf-8"), filename=str(py_file))
|
tree = ast.parse(py_file.read_text(encoding="utf-8"), filename=str(py_file))
|
||||||
for node in ast.walk(tree):
|
for node in ast.walk(tree):
|
||||||
if isinstance(node, ast.Import):
|
if isinstance(node, ast.Import):
|
||||||
|
|||||||
Reference in New Issue
Block a user