From b0caac871a36107ce8119e49bcfde32168edaa67 Mon Sep 17 00:00:00 2001 From: ThinkChaos Date: Fri, 9 Aug 2024 18:36:11 -0400 Subject: [PATCH] fix: enable tracebacks for "user"/custom sqlite functions A bit niche but I tried setting my bareasc prefix to an empty string, and was getting an obtuse error. This should help make clearer what is happening when queries fail. The exception is not properly raised up the stack in the first place because it happens across 2 FFI boundaries: the DB query (Python -> SQLite), and the custom DB function (SQLite -> Python). Thus Python cannot forwarded it back to itself through SQLite, and it's treated as an "unraisable" exception. We could override `sys.unraisablehook` to not print anything for the original exception, and store it in a global for the outer Python interpreter to fetch and raise properly, but that's pretty hacky, limited to a single DB instance and query at once, and risks swallowing other "unraisable" exceptions. Instead we just tell the user to look above for what Python prints. Sample output: ``` Exception ignored in: Traceback (most recent call last): File "site-packages/unidecode/__init__.py", line 60, in unidecode_expect_ascii bytestring = string.encode('ASCII') ^^^^^^^^^^^^^ AttributeError: 'bytes' object has no attribute 'encode' Traceback (most recent call last): File "site-packages/beets/dbcore/db.py", line 988, in query cursor = self.db._connection().execute(statement, subvals) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ sqlite3.OperationalError: user-defined function raised exception During handling of the above exception, another exception occurred: Traceback (most recent call last): File "site-packages/beets/__main__.py", line 9, in sys.exit(main()) ^^^^^^ File "site-packages/beets/ui/__init__.py", line 1865, in main _raw_main(args) File "site-packages/beets/ui/__init__.py", line 1852, in _raw_main subcommand.func(lib, suboptions, subargs) File "site-packages/beets/ui/commands.py", line 1599, in list_func list_items(lib, decargs(args), opts.album) File "site-packages/beets/ui/commands.py", line 1594, in list_items for item in lib.items(query): ^^^^^^^^^^^^^^^^ File "site-packages/beets/library.py", line 1695, in items return self._fetch(Item, query, sort or self.get_default_item_sort()) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "site-packages/beets/library.py", line 1673, in _fetch return super()._fetch(model_cls, query, sort) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "site-packages/beets/dbcore/db.py", line 1301, in _fetch rows = tx.query(sql, subvals) ^^^^^^^^^^^^^^^^^^^^^^ File "site-packages/beets/dbcore/db.py", line 991, in query raise DBCustomFunctionError() beets.dbcore.db.DBCustomFunctionError: beets defined SQLite function failed; see the other errors above for details ``` --- beets/dbcore/db.py | 23 +++++++++++++++++++++++ test/test_dbcore.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index 8cd89111e..b3a6c7dd8 100755 --- a/beets/dbcore/db.py +++ b/beets/dbcore/db.py @@ -64,6 +64,16 @@ class DBAccessError(Exception): """ +class DBCustomFunctionError(Exception): + """A sqlite function registered by beets failed.""" + + def __init__(self): + super().__init__( + "beets defined SQLite function failed; " + "see the other errors above for details" + ) + + class FormattedMapping(Mapping[str, str]): """A `dict`-like formatted view of a model. @@ -947,6 +957,12 @@ class Transaction: self._mutated = False self.db._db_lock.release() + if ( + isinstance(exc_value, sqlite3.OperationalError) + and exc_value.args[0] == "user-defined function raised exception" + ): + raise DBCustomFunctionError() + def query( self, statement: str, subvals: Sequence[SQLiteType] = () ) -> list[sqlite3.Row]: @@ -1007,6 +1023,13 @@ class Database: "sqlite3 must be compiled with multi-threading support" ) + # Print tracebacks for exceptions in user defined functions + # See also `self.add_functions` and `DBCustomFunctionError`. + # + # `if`: use feature detection because PyPy doesn't support this. + if hasattr(sqlite3, "enable_callback_tracebacks"): + sqlite3.enable_callback_tracebacks(True) + self.path = path self.timeout = timeout diff --git a/test/test_dbcore.py b/test/test_dbcore.py index b2ec2e968..d2c76d852 100644 --- a/test/test_dbcore.py +++ b/test/test_dbcore.py @@ -23,6 +23,7 @@ from tempfile import mkstemp import pytest from beets import dbcore +from beets.dbcore.db import DBCustomFunctionError from beets.library import LibModel from beets.test import _common from beets.util import cached_classproperty @@ -31,6 +32,13 @@ from beets.util import cached_classproperty # have multiple models with different numbers of fields. +@pytest.fixture +def db(model): + db = model(":memory:") + yield db + db._connection().close() + + class SortFixture(dbcore.query.FieldSort): pass @@ -784,3 +792,25 @@ class ResultsIteratorTest(unittest.TestCase): self.db._fetch(ModelFixture1, dbcore.query.FalseQuery()).get() is None ) + + +class TestException: + @pytest.mark.parametrize("model", [DatabaseFixture1]) + @pytest.mark.filterwarnings( + "ignore: .*plz_raise.*: pytest.PytestUnraisableExceptionWarning" + ) + @pytest.mark.filterwarnings( + "error: .*: pytest.PytestUnraisableExceptionWarning" + ) + def test_custom_function_error(self, db: DatabaseFixture1): + def plz_raise(): + raise Exception("i haz raized") + + db._connection().create_function("plz_raise", 0, plz_raise) + + with db.transaction() as tx: + tx.mutate("insert into test (field_one) values (1)") + + with pytest.raises(DBCustomFunctionError): + with db.transaction() as tx: + tx.query("select * from test where plz_raise()")