From b2c8a01036c9e2d9e673f4074fae5f87296b7473 Mon Sep 17 00:00:00 2001 From: Chojan Shang Date: Mon, 5 Oct 2026 10:59:48 +0800 Subject: [PATCH 1/3] fix(sqlite): close tape store at framework shutdown --- .../src/bub_tapestore_sqlite/plugin.py | 10 +- .../tests/test_lifecycle.py | 106 ++++++++++++++++++ .../bub-tapestore-sqlite/tests/test_plugin.py | 10 -- 3 files changed, 113 insertions(+), 13 deletions(-) create mode 100644 packages/bub-tapestore-sqlite/tests/test_lifecycle.py diff --git a/packages/bub-tapestore-sqlite/src/bub_tapestore_sqlite/plugin.py b/packages/bub-tapestore-sqlite/src/bub_tapestore_sqlite/plugin.py index 3d60dad..3d5f58c 100644 --- a/packages/bub-tapestore-sqlite/src/bub_tapestore_sqlite/plugin.py +++ b/packages/bub-tapestore-sqlite/src/bub_tapestore_sqlite/plugin.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import AsyncIterator, Callable from functools import lru_cache from pathlib import Path from typing import Any @@ -78,8 +78,12 @@ def tape_store_from_env() -> SQLiteTapeStore: @hookimpl -def provide_tape_store() -> SQLiteTapeStore: - return _store() +async def provide_tape_store() -> AsyncIterator[SQLiteTapeStore]: + store = _store() + try: + yield store + finally: + await store.close() @hookimpl diff --git a/packages/bub-tapestore-sqlite/tests/test_lifecycle.py b/packages/bub-tapestore-sqlite/tests/test_lifecycle.py new file mode 100644 index 0000000..62d61cc --- /dev/null +++ b/packages/bub-tapestore-sqlite/tests/test_lifecycle.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import os +import sqlite3 +import subprocess +import sys +import textwrap +from pathlib import Path + +import pytest + + +@pytest.mark.parametrize("missing_anchor", [False, True]) +def test_framework_process_exits_after_using_sqlite_store( + tmp_path: Path, missing_anchor: bool +) -> None: + script = textwrap.dedent("""\ + import asyncio + import sys + from pathlib import Path + from bub.framework import BubFramework + from bub.tape import TapeEntry, TapeQuery + from bub_tapestore_sqlite import plugin + from bub_tapestore_sqlite.store import SQLiteTapeStore + + framework = BubFramework(config_file=Path('config.yml')) + framework.plugin_manager.register(plugin, name='tapestore-sqlite') + + async def main(): + async with framework.running(): + store = framework.get_tape_store() + assert isinstance(store, SQLiteTapeStore) + assert store is framework.get_tape_store() + await store.append('lifecycle', TapeEntry.system('first')) + if sys.argv[1] == 'missing-anchor': + await TapeQuery('lifecycle', store).after_anchor('missing').all() + + async with framework.running(): + reused = framework.get_tape_store() + assert reused is store + assert await reused.list_tapes() == ['lifecycle'] + await reused.append('lifecycle', TapeEntry.system('second')) + entries = list(await TapeQuery('lifecycle', reused).all()) + assert [entry.payload['content'] for entry in entries] == ['first', 'second'] + + asyncio.run(main()) + print('framework_completed', flush=True) + """) + result = subprocess.run( + [ + sys.executable, + "-c", + script, + "missing-anchor" if missing_anchor else "normal", + ], + cwd=tmp_path, + env=os.environ + | { + "BUB_HOME": str(tmp_path), + "BUB_SQLITE_PATH": str(tmp_path / "tapes.sqlite3"), + "BUB_SQLITE_EMBEDDING_MODEL": "", + }, + capture_output=True, + text=True, + timeout=10, + ) + + if missing_anchor: + assert result.returncode == 1, result.stderr + assert "Anchor 'missing' was not found." in result.stderr + else: + assert result.returncode == 0, result.stderr + assert "framework_completed" in result.stdout + + +def test_cli_process_exits_after_using_sqlite_plugin(tmp_path: Path) -> None: + script = textwrap.dedent("""\ + from pathlib import Path + from bub.framework import BubFramework + from bub_tapestore_sqlite import plugin + + framework = BubFramework(config_file=Path('config.yml')) + framework.load_builtin_hooks() + framework.plugin_manager.register(plugin, name='tapestore-sqlite') + framework.create_cli_app()(args=['run', ',tape.info']) + """) + database = tmp_path / "tapes.sqlite3" + result = subprocess.run( + [sys.executable, "-c", script], + cwd=tmp_path, + env=os.environ + | { + "BUB_HOME": str(tmp_path), + "BUB_SQLITE_PATH": str(database), + "BUB_SQLITE_EMBEDDING_MODEL": "", + }, + capture_output=True, + text=True, + timeout=10, + ) + + assert result.returncode == 0, result.stderr + assert "[cli:local]" in result.stdout + assert "entries:" in result.stdout + with sqlite3.connect(database) as connection: + assert connection.execute("SELECT COUNT(*) FROM tape_entries").fetchone()[0] > 0 diff --git a/packages/bub-tapestore-sqlite/tests/test_plugin.py b/packages/bub-tapestore-sqlite/tests/test_plugin.py index 148ecac..7d32bf4 100644 --- a/packages/bub-tapestore-sqlite/tests/test_plugin.py +++ b/packages/bub-tapestore-sqlite/tests/test_plugin.py @@ -21,16 +21,6 @@ def test_config_defaults_to_bub_home(monkeypatch, tmp_path: Path) -> None: assert plugin._build_store(lambda: config)._path == tmp_path / "tapes.sqlite3" -def test_plugin_provides_singleton_store(monkeypatch, tmp_path: Path) -> None: - monkeypatch.setenv("BUB_SQLITE_PATH", str(tmp_path / "custom.sqlite3")) - plugin._store.cache_clear() - - store = plugin.provide_tape_store() - - assert isinstance(store, SQLiteTapeStore) - assert store is plugin.provide_tape_store() - - def test_invalid_journal_mode_raises(monkeypatch) -> None: monkeypatch.setenv("BUB_SQLITE_JOURNAL_MODE", "INVALID") From 8e95f1a09e61eb77d89acdcd26432260c919f333 Mon Sep 17 00:00:00 2001 From: Chojan Shang Date: Mon, 5 Oct 2026 11:01:45 +0800 Subject: [PATCH 2/3] test(sqlite): keep only the public CLI exit regression --- .../tests/test_lifecycle.py | 106 ------------------ .../bub-tapestore-sqlite/tests/test_plugin.py | 32 ++++++ 2 files changed, 32 insertions(+), 106 deletions(-) delete mode 100644 packages/bub-tapestore-sqlite/tests/test_lifecycle.py diff --git a/packages/bub-tapestore-sqlite/tests/test_lifecycle.py b/packages/bub-tapestore-sqlite/tests/test_lifecycle.py deleted file mode 100644 index 62d61cc..0000000 --- a/packages/bub-tapestore-sqlite/tests/test_lifecycle.py +++ /dev/null @@ -1,106 +0,0 @@ -from __future__ import annotations - -import os -import sqlite3 -import subprocess -import sys -import textwrap -from pathlib import Path - -import pytest - - -@pytest.mark.parametrize("missing_anchor", [False, True]) -def test_framework_process_exits_after_using_sqlite_store( - tmp_path: Path, missing_anchor: bool -) -> None: - script = textwrap.dedent("""\ - import asyncio - import sys - from pathlib import Path - from bub.framework import BubFramework - from bub.tape import TapeEntry, TapeQuery - from bub_tapestore_sqlite import plugin - from bub_tapestore_sqlite.store import SQLiteTapeStore - - framework = BubFramework(config_file=Path('config.yml')) - framework.plugin_manager.register(plugin, name='tapestore-sqlite') - - async def main(): - async with framework.running(): - store = framework.get_tape_store() - assert isinstance(store, SQLiteTapeStore) - assert store is framework.get_tape_store() - await store.append('lifecycle', TapeEntry.system('first')) - if sys.argv[1] == 'missing-anchor': - await TapeQuery('lifecycle', store).after_anchor('missing').all() - - async with framework.running(): - reused = framework.get_tape_store() - assert reused is store - assert await reused.list_tapes() == ['lifecycle'] - await reused.append('lifecycle', TapeEntry.system('second')) - entries = list(await TapeQuery('lifecycle', reused).all()) - assert [entry.payload['content'] for entry in entries] == ['first', 'second'] - - asyncio.run(main()) - print('framework_completed', flush=True) - """) - result = subprocess.run( - [ - sys.executable, - "-c", - script, - "missing-anchor" if missing_anchor else "normal", - ], - cwd=tmp_path, - env=os.environ - | { - "BUB_HOME": str(tmp_path), - "BUB_SQLITE_PATH": str(tmp_path / "tapes.sqlite3"), - "BUB_SQLITE_EMBEDDING_MODEL": "", - }, - capture_output=True, - text=True, - timeout=10, - ) - - if missing_anchor: - assert result.returncode == 1, result.stderr - assert "Anchor 'missing' was not found." in result.stderr - else: - assert result.returncode == 0, result.stderr - assert "framework_completed" in result.stdout - - -def test_cli_process_exits_after_using_sqlite_plugin(tmp_path: Path) -> None: - script = textwrap.dedent("""\ - from pathlib import Path - from bub.framework import BubFramework - from bub_tapestore_sqlite import plugin - - framework = BubFramework(config_file=Path('config.yml')) - framework.load_builtin_hooks() - framework.plugin_manager.register(plugin, name='tapestore-sqlite') - framework.create_cli_app()(args=['run', ',tape.info']) - """) - database = tmp_path / "tapes.sqlite3" - result = subprocess.run( - [sys.executable, "-c", script], - cwd=tmp_path, - env=os.environ - | { - "BUB_HOME": str(tmp_path), - "BUB_SQLITE_PATH": str(database), - "BUB_SQLITE_EMBEDDING_MODEL": "", - }, - capture_output=True, - text=True, - timeout=10, - ) - - assert result.returncode == 0, result.stderr - assert "[cli:local]" in result.stdout - assert "entries:" in result.stdout - with sqlite3.connect(database) as connection: - assert connection.execute("SELECT COUNT(*) FROM tape_entries").fetchone()[0] > 0 diff --git a/packages/bub-tapestore-sqlite/tests/test_plugin.py b/packages/bub-tapestore-sqlite/tests/test_plugin.py index 7d32bf4..5ec2908 100644 --- a/packages/bub-tapestore-sqlite/tests/test_plugin.py +++ b/packages/bub-tapestore-sqlite/tests/test_plugin.py @@ -1,5 +1,9 @@ from __future__ import annotations +import os +import subprocess +import sys +import textwrap from pathlib import Path import pytest @@ -21,6 +25,34 @@ def test_config_defaults_to_bub_home(monkeypatch, tmp_path: Path) -> None: assert plugin._build_store(lambda: config)._path == tmp_path / "tapes.sqlite3" +def test_cli_exits_after_using_sqlite_store(tmp_path: Path) -> None: + script = textwrap.dedent("""\ + from pathlib import Path + from bub.framework import BubFramework + from bub_tapestore_sqlite import plugin + + framework = BubFramework(config_file=Path('config.yml')) + framework.load_builtin_hooks() + framework.plugin_manager.register(plugin, name='tapestore-sqlite') + app = framework.create_cli_app() + for _ in range(2): + app(args=['run', ',tape.info'], standalone_mode=False) + """) + database = tmp_path / "tapes.sqlite3" + result = subprocess.run( + [sys.executable, "-c", script], + cwd=tmp_path, + env=os.environ | {"BUB_HOME": str(tmp_path), "BUB_SQLITE_PATH": str(database)}, + capture_output=True, + text=True, + timeout=10, + ) + + assert result.returncode == 0, result.stderr + assert result.stdout.count("[cli:local]") == 2 + assert database.exists() + + def test_invalid_journal_mode_raises(monkeypatch) -> None: monkeypatch.setenv("BUB_SQLITE_JOURNAL_MODE", "INVALID") From 5ab9a39beabf0f9e75fee906d8810db3d62e8692 Mon Sep 17 00:00:00 2001 From: Chojan Shang Date: Mon, 5 Oct 2026 11:09:33 +0800 Subject: [PATCH 3/3] test(sqlite): cover query errors in the exit regression --- .../bub-tapestore-sqlite/tests/test_plugin.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/bub-tapestore-sqlite/tests/test_plugin.py b/packages/bub-tapestore-sqlite/tests/test_plugin.py index 5ec2908..2549b4d 100644 --- a/packages/bub-tapestore-sqlite/tests/test_plugin.py +++ b/packages/bub-tapestore-sqlite/tests/test_plugin.py @@ -27,8 +27,11 @@ def test_config_defaults_to_bub_home(monkeypatch, tmp_path: Path) -> None: def test_cli_exits_after_using_sqlite_store(tmp_path: Path) -> None: script = textwrap.dedent("""\ + import asyncio from pathlib import Path + from bub.errors import BubError from bub.framework import BubFramework + from bub.tape import TapeQuery from bub_tapestore_sqlite import plugin framework = BubFramework(config_file=Path('config.yml')) @@ -37,6 +40,15 @@ def test_cli_exits_after_using_sqlite_store(tmp_path: Path) -> None: app = framework.create_cli_app() for _ in range(2): app(args=['run', ',tape.info'], standalone_mode=False) + + async def query_missing_anchor(): + async with framework.running(): + await TapeQuery('missing', framework.get_tape_store()).after_anchor('missing').all() + + try: + asyncio.run(query_missing_anchor()) + except BubError as error: + print(error, flush=True) """) database = tmp_path / "tapes.sqlite3" result = subprocess.run( @@ -49,7 +61,8 @@ def test_cli_exits_after_using_sqlite_store(tmp_path: Path) -> None: ) assert result.returncode == 0, result.stderr - assert result.stdout.count("[cli:local]") == 2 + assert result.stdout.count("entries:") == 2 + assert "Anchor 'missing' was not found." in result.stdout assert database.exists()