diff --git a/create_demo_db.py b/create_demo_db.py new file mode 100644 index 000000000..2f3306d8a --- /dev/null +++ b/create_demo_db.py @@ -0,0 +1,23 @@ +import os +import sqlite3 + +# Remove any existing demo.db so we start fresh +if os.path.exists("demo.db"): + os.remove("demo.db") + +conn = sqlite3.connect("demo.db") +conn.executescript(""" +CREATE TABLE IF NOT EXISTS movies ( + id INTEGER PRIMARY KEY, + title TEXT, + year INTEGER, + genre TEXT +); +INSERT INTO movies VALUES (1, 'Inception', 2010, 'Sci-Fi'); +INSERT INTO movies VALUES (2, 'The Matrix', 1999, 'Sci-Fi'); +INSERT INTO movies VALUES (3, 'Interstellar', 2014, 'Sci-Fi'); +""") +conn.commit() +conn.close() +print("demo.db created successfully") + diff --git a/docs/cli.rst b/docs/cli.rst index 78c33b8ae..05368eba7 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -8,6 +8,18 @@ The ``sqlite-utils`` command-line tool can be used to manipulate SQLite database Once :ref:`installed ` the tool should be available as ``sqlite-utils``. It can also be run using ``python -m sqlite_utils``. +All CLI arguments that refer to a database accept `SQLite URI filenames +`__ as well as regular paths. For example, to +query a database that will not change while it is open: + +.. code-block:: bash + + sqlite-utils query 'file:foo.db?immutable=1' "select * from bar" + +SQLite URI options such as ``mode=ro`` are interpreted by SQLite. In particular, +``immutable=1`` tells SQLite that the database file cannot change; do not use it +for files that may be modified while the connection is open. + .. contents:: :local: :class: this-will-duplicate-information-and-it-is-still-useful-here diff --git a/docs/python-api.rst b/docs/python-api.rst index d515642a5..7bcc0efbe 100644 --- a/docs/python-api.rst +++ b/docs/python-api.rst @@ -81,6 +81,12 @@ Database objects are constructed by passing in either a path to a file on disk o This will create ``my_database.db`` if it does not already exist. +SQLite URI filenames can also be passed in place of a regular path. For example, +``Database("file:my_database.db?mode=ro")`` opens a database read-only. See the +`SQLite URI documentation `__ for supported +options. ``immutable=1`` should only be used when the database cannot change +while the connection is open. + If you want to recreate a database from scratch (first removing the existing file from disk if it already exists) you can use the ``recreate=True`` argument: .. code-block:: python diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index c23090283..546147d99 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -101,6 +101,13 @@ def convert(self, value, param, ctx): return super().convert(value.lower(), param, ctx) +class SQLiteDatabasePath(click.Path): + def convert(self, value, param, ctx): + if isinstance(value, str) and value.startswith("file:"): + return value + return super().convert(value, param, ctx) + + def output_options(fn): for decorator in reversed( ( @@ -184,7 +191,7 @@ def cli(): @cli.command() @click.argument( "path", - type=click.Path(exists=True, file_okay=True, dir_okay=False, allow_dash=False), + type=SQLiteDatabasePath(exists=True, file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.option( @@ -290,7 +297,7 @@ def _iter(): @cli.command() @click.argument( "path", - type=click.Path(exists=True, file_okay=True, dir_okay=False, allow_dash=False), + type=SQLiteDatabasePath(exists=True, file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.option( @@ -358,7 +365,7 @@ def views( @cli.command() @click.argument( "path", - type=click.Path(exists=True, file_okay=True, dir_okay=False, allow_dash=False), + type=SQLiteDatabasePath(exists=True, file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("tables", nargs=-1) @@ -387,7 +394,7 @@ def optimize(path, tables, no_vacuum, load_extension): @cli.command(name="rebuild-fts") @click.argument( "path", - type=click.Path(exists=True, file_okay=True, dir_okay=False, allow_dash=False), + type=SQLiteDatabasePath(exists=True, file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("tables", nargs=-1) @@ -413,7 +420,7 @@ def rebuild_fts(path, tables, load_extension): @cli.command() @click.argument( "path", - type=click.Path(exists=True, file_okay=True, dir_okay=False, allow_dash=False), + type=SQLiteDatabasePath(exists=True, file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("names", nargs=-1) @@ -440,7 +447,7 @@ def analyze(path, names): @cli.command() @click.argument( "path", - type=click.Path(exists=True, file_okay=True, dir_okay=False, allow_dash=False), + type=SQLiteDatabasePath(exists=True, file_okay=True, dir_okay=False, allow_dash=False), required=True, ) def vacuum(path): @@ -459,7 +466,7 @@ def vacuum(path): @cli.command() @click.argument( "path", - type=click.Path(exists=True, file_okay=True, dir_okay=False, allow_dash=False), + type=SQLiteDatabasePath(exists=True, file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @load_extension_option @@ -481,7 +488,7 @@ def dump(path, load_extension): @cli.command(name="add-column") @click.argument( "path", - type=click.Path(exists=True, file_okay=True, dir_okay=False, allow_dash=False), + type=SQLiteDatabasePath(exists=True, file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("table") @@ -558,7 +565,7 @@ def add_column( @cli.command(name="add-foreign-key") @click.argument( "path", - type=click.Path(exists=True, file_okay=True, dir_okay=False, allow_dash=False), + type=SQLiteDatabasePath(exists=True, file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("table") @@ -595,7 +602,7 @@ def add_foreign_key( @cli.command(name="add-foreign-keys") @click.argument( "path", - type=click.Path(exists=True, file_okay=True, dir_okay=False, allow_dash=False), + type=SQLiteDatabasePath(exists=True, file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("foreign_key", nargs=-1) @@ -630,7 +637,7 @@ def add_foreign_keys(path, foreign_key, load_extension): @cli.command(name="index-foreign-keys") @click.argument( "path", - type=click.Path(exists=True, file_okay=True, dir_okay=False, allow_dash=False), + type=SQLiteDatabasePath(exists=True, file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @load_extension_option @@ -652,7 +659,7 @@ def index_foreign_keys(path, load_extension): @cli.command(name="create-index") @click.argument( "path", - type=click.Path(exists=True, file_okay=True, dir_okay=False, allow_dash=False), + type=SQLiteDatabasePath(exists=True, file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("table") @@ -709,7 +716,7 @@ def create_index( @cli.command(name="drop-index") @click.argument( "path", - type=click.Path(exists=True, file_okay=True, dir_okay=False, allow_dash=False), + type=SQLiteDatabasePath(exists=True, file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("table") @@ -737,7 +744,7 @@ def drop_index(path, table, index, ignore, load_extension): @cli.command(name="enable-fts") @click.argument( "path", - type=click.Path(exists=True, file_okay=True, dir_okay=False, allow_dash=False), + type=SQLiteDatabasePath(exists=True, file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("table") @@ -792,7 +799,7 @@ def enable_fts( @cli.command(name="populate-fts") @click.argument( "path", - type=click.Path(exists=True, file_okay=True, dir_okay=False, allow_dash=False), + type=SQLiteDatabasePath(exists=True, file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("table") @@ -815,7 +822,7 @@ def populate_fts(path, table, column, load_extension): @cli.command(name="disable-fts") @click.argument( "path", - type=click.Path(exists=True, file_okay=True, dir_okay=False, allow_dash=False), + type=SQLiteDatabasePath(exists=True, file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("table") @@ -838,7 +845,7 @@ def disable_fts(path, table, load_extension): @click.argument( "path", nargs=-1, - type=click.Path(exists=True, file_okay=True, dir_okay=False, allow_dash=False), + type=SQLiteDatabasePath(exists=True, file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @load_extension_option @@ -861,7 +868,7 @@ def enable_wal(path, load_extension): @click.argument( "path", nargs=-1, - type=click.Path(exists=True, file_okay=True, dir_okay=False, allow_dash=False), + type=SQLiteDatabasePath(exists=True, file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @load_extension_option @@ -883,7 +890,7 @@ def disable_wal(path, load_extension): @cli.command(name="enable-counts") @click.argument( "path", - type=click.Path(exists=True, file_okay=True, dir_okay=False, allow_dash=False), + type=SQLiteDatabasePath(exists=True, file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("tables", nargs=-1) @@ -913,7 +920,7 @@ def enable_counts(path, tables, load_extension): @cli.command(name="reset-counts") @click.argument( "path", - type=click.Path(exists=True, file_okay=True, dir_okay=False, allow_dash=False), + type=SQLiteDatabasePath(exists=True, file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @load_extension_option @@ -982,7 +989,7 @@ def inner(fn): ( click.argument( "path", - type=click.Path(file_okay=True, dir_okay=False, allow_dash=False), + type=SQLiteDatabasePath(file_okay=True, dir_okay=False, allow_dash=False), required=True, ), click.argument("table"), @@ -1579,7 +1586,7 @@ def upsert( @cli.command() @click.argument( "path", - type=click.Path(file_okay=True, dir_okay=False, allow_dash=False), + type=SQLiteDatabasePath(file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("sql") @@ -1662,7 +1669,7 @@ def bulk( @cli.command(name="create-database") @click.argument( "path", - type=click.Path(file_okay=True, dir_okay=False, allow_dash=False), + type=SQLiteDatabasePath(file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.option( @@ -1699,7 +1706,7 @@ def create_database(path, enable_wal, init_spatialite, load_extension): @cli.command(name="create-table") @click.argument( "path", - type=click.Path(file_okay=True, dir_okay=False, allow_dash=False), + type=SQLiteDatabasePath(file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("table") @@ -1808,7 +1815,7 @@ def create_table( @cli.command(name="duplicate") @click.argument( "path", - type=click.Path(file_okay=True, dir_okay=False, allow_dash=False), + type=SQLiteDatabasePath(file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("table") @@ -1832,7 +1839,7 @@ def duplicate(path, table, new_table, ignore, load_extension): @cli.command(name="rename-table") @click.argument( "path", - type=click.Path(file_okay=True, dir_okay=False, allow_dash=False), + type=SQLiteDatabasePath(file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("table") @@ -1856,7 +1863,7 @@ def rename_table(path, table, new_name, ignore, load_extension): @cli.command(name="drop-table") @click.argument( "path", - type=click.Path(file_okay=True, dir_okay=False, allow_dash=False), + type=SQLiteDatabasePath(file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("table") @@ -1888,7 +1895,7 @@ def drop_table(path, table, ignore, load_extension): @cli.command(name="create-view") @click.argument( "path", - type=click.Path(file_okay=True, dir_okay=False, allow_dash=False), + type=SQLiteDatabasePath(file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("view") @@ -1932,7 +1939,7 @@ def create_view(path, view, select, ignore, replace, load_extension): @cli.command(name="drop-view") @click.argument( "path", - type=click.Path(file_okay=True, dir_okay=False, allow_dash=False), + type=SQLiteDatabasePath(file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("view") @@ -1964,13 +1971,13 @@ def drop_view(path, view, ignore, load_extension): @cli.command() @click.argument( "path", - type=click.Path(file_okay=True, dir_okay=False, allow_dash=False), + type=SQLiteDatabasePath(file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("sql") @click.option( "--attach", - type=(str, click.Path(file_okay=True, dir_okay=False, allow_dash=False)), + type=(str, SQLiteDatabasePath(file_okay=True, dir_okay=False, allow_dash=False)), multiple=True, help="Additional databases to attach - specify alias and filepath", ) @@ -2060,7 +2067,7 @@ def query( @functions_option @click.option( "--attach", - type=(str, click.Path(file_okay=True, dir_okay=False, allow_dash=False)), + type=(str, SQLiteDatabasePath(file_okay=True, dir_okay=False, allow_dash=False)), multiple=True, help="Additional databases to attach - specify alias and filepath", ) @@ -2093,7 +2100,7 @@ def query( @click.option("--dump", is_flag=True, help="Dump SQL for in-memory database") @click.option( "--save", - type=click.Path(file_okay=True, dir_okay=False, allow_dash=False), + type=SQLiteDatabasePath(file_okay=True, dir_okay=False, allow_dash=False), help="Save in-memory database to this file", ) @click.option( @@ -2324,7 +2331,7 @@ def _execute_query( @cli.command() @click.argument( "path", - type=click.Path(file_okay=True, dir_okay=False, allow_dash=False), + type=SQLiteDatabasePath(file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("dbtable") @@ -2422,7 +2429,7 @@ def search( @cli.command() @click.argument( "path", - type=click.Path(file_okay=True, dir_okay=False, allow_dash=False), + type=SQLiteDatabasePath(file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("dbtable") @@ -2512,7 +2519,7 @@ def rows( @cli.command() @click.argument( "path", - type=click.Path(file_okay=True, dir_okay=False, allow_dash=False), + type=SQLiteDatabasePath(file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("tables", nargs=-1) @@ -2569,7 +2576,7 @@ def triggers( @cli.command() @click.argument( "path", - type=click.Path(file_okay=True, dir_okay=False, allow_dash=False), + type=SQLiteDatabasePath(file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("tables", nargs=-1) @@ -2640,7 +2647,7 @@ def indexes( @cli.command() @click.argument( "path", - type=click.Path(file_okay=True, dir_okay=False, allow_dash=False), + type=SQLiteDatabasePath(file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("tables", nargs=-1, required=False) @@ -2670,7 +2677,7 @@ def schema( @cli.command() @click.argument( "path", - type=click.Path(file_okay=True, dir_okay=False, allow_dash=False), + type=SQLiteDatabasePath(file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("table") @@ -2824,7 +2831,7 @@ def transform( @cli.command() @click.argument( "path", - type=click.Path(file_okay=True, dir_okay=False, allow_dash=False), + type=SQLiteDatabasePath(file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("table") @@ -2874,7 +2881,7 @@ def extract( @cli.command(name="insert-files") @click.argument( "path", - type=click.Path(file_okay=True, dir_okay=False, allow_dash=False), + type=SQLiteDatabasePath(file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("table") @@ -3029,7 +3036,7 @@ def _content_text(p): @cli.command(name="analyze-tables") @click.argument( "path", - type=click.Path(file_okay=True, dir_okay=False, allow_dash=False, exists=True), + type=SQLiteDatabasePath(file_okay=True, dir_okay=False, allow_dash=False, exists=True), required=True, ) @click.argument("tables", nargs=-1) @@ -3214,7 +3221,7 @@ def _generate_convert_help(): @cli.command(help=_generate_convert_help()) @click.argument( "db_path", - type=click.Path(file_okay=True, dir_okay=False, allow_dash=False), + type=SQLiteDatabasePath(file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("table", type=str) @@ -3344,7 +3351,7 @@ def wrapped_fn(value): @cli.command("add-geometry-column") @click.argument( "db_path", - type=click.Path(file_okay=True, dir_okay=False, allow_dash=False), + type=SQLiteDatabasePath(file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("table", type=str) @@ -3421,7 +3428,7 @@ def add_geometry_column( @cli.command("create-spatial-index") @click.argument( "db_path", - type=click.Path(file_okay=True, dir_okay=False, allow_dash=False), + type=SQLiteDatabasePath(file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("table", type=str) @@ -3519,7 +3526,7 @@ def _stop_before_for_migration_set(stop_before, migration_set_name): @click.command() @click.argument( - "db_path", type=click.Path(dir_okay=False, readable=True, writable=True) + "db_path", type=SQLiteDatabasePath(dir_okay=False, readable=True, writable=True) ) @click.argument("migrations", type=click.Path(dir_okay=True, exists=True), nargs=-1) @click.option( diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index c011d9bb0..47ffebb45 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -546,8 +546,8 @@ class Database: # Create an in-memory database: dB = Database(memory=True) - :param filename_or_conn: String path to a file, or a ``pathlib.Path`` object, or a - ``sqlite3`` connection + :param filename_or_conn: String path to a file or SQLite URI, a ``pathlib.Path`` + object, or a ``sqlite3`` connection :param memory: set to ``True`` to create an in-memory database :param memory_name: creates a named in-memory database that can be shared across multiple connections :param recreate: set to ``True`` to delete and recreate a file database (**dangerous**) @@ -588,9 +588,9 @@ def __init__( ): raise ValueError("Either specify a filename_or_conn or pass memory=True") if memory_name: - uri = f"file:{memory_name}?mode=memory&cache=shared" + connection_uri = f"file:{memory_name}?mode=memory&cache=shared" self.conn = sqlite3.connect( - uri, + connection_uri, uri=True, check_same_thread=False, ) @@ -600,6 +600,10 @@ def __init__( self.conn = sqlite3.connect(":memory:") self.memory = True elif isinstance(filename_or_conn, (str, pathlib.Path)): + filename = str(filename_or_conn) + is_uri = filename.startswith("file:") + if recreate and is_uri: + raise ValueError("recreate cannot be used with SQLite URIs") if recreate and os.path.exists(filename_or_conn): try: os.remove(filename_or_conn) @@ -608,7 +612,7 @@ def __init__( # https://github.com/simonw/sqlite-utils/issues/503 self.conn = sqlite3.connect(":memory:") raise - self.conn = sqlite3.connect(str(filename_or_conn)) + self.conn = sqlite3.connect(filename, uri=True) else: if recreate: raise ValueError("recreate cannot be used with connections, only paths") @@ -857,12 +861,16 @@ def attach(self, alias: str, filepath: str | pathlib.Path) -> None: ATTACH DATABASE 'filepath.db' AS alias :param alias: Alias name to use - :param filepath: Path to SQLite database file on disk + :param filepath: Path or SQLite URI for a database file on disk """ - attach_sql = f""" - ATTACH DATABASE '{pathlib.Path(filepath).resolve()!s}' AS {quote_identifier(alias)}; - """.strip() - self.execute(attach_sql) + if isinstance(filepath, str) and filepath.startswith("file:"): + database = filepath + else: + database = str(pathlib.Path(filepath).resolve()) + self.execute( + f"ATTACH DATABASE ? AS {quote_identifier(alias)}", + (database,), + ) def query( self, sql: str, params: Sequence | dict[str, Any] | None = None diff --git a/tests/test_cli.py b/tests/test_cli.py index 064026a0b..87f4d6f1c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -61,6 +61,22 @@ def test_tables(db_path): assert '[{"table": "Gosh"},\n {"table": "Gosh2"}]' == result.output.strip() +def test_tables_sqlite_uri(db_path): + uri = Path(db_path).as_uri() + "?mode=ro" + result = CliRunner().invoke(cli.cli, ["tables", uri]) + assert result.exit_code == 0, result.output + assert json.loads(result.output) == [{"table": "Gosh"}, {"table": "Gosh2"}] + + +def test_create_table_sqlite_uri(db_path): + uri = Path(db_path).as_uri() + "?mode=rw" + result = CliRunner().invoke( + cli.cli, ["create-table", uri, "created_via_uri", "name", "text"] + ) + assert result.exit_code == 0, result.output + assert Database(db_path).table("created_via_uri").exists() + + def test_views(db_path): Database(db_path).create_view("hello", "select sqlite_version()") result = CliRunner().invoke(cli.cli, ["views", db_path, "--table", "--schema"]) @@ -823,6 +839,23 @@ def test_query_sql_from_stdin(db_path): assert json.loads(result.output) == [{"name": "Cleo"}, {"name": "Pancakes"}] +def test_query_sqlite_uri(db_path): + uri = Path(db_path).as_uri() + "?mode=ro&immutable=1" + result = CliRunner().invoke( + cli.cli, + ["query", uri, "select name from sqlite_master where name = 'Gosh'"], + ) + assert result.exit_code == 0, result.output + assert json.loads(result.output) == [{"name": "Gosh"}] + + write_result = CliRunner().invoke( + cli.cli, + ["query", uri, "insert into Gosh values ('one', 'two', 'three')"], + ) + assert write_result.exit_code != 0 + assert "readonly" in write_result.output.lower() + + def test_query_json_empty(db_path): result = CliRunner().invoke( cli.cli, @@ -2534,6 +2567,20 @@ def test_attach(tmpdir): ] +def test_attach_sqlite_uri(tmpdir): + foo_path = str(tmpdir / "foo.db") + bar_path = str(tmpdir / "bar.db") + Database(foo_path).table("foo").insert({"text": "foo"}) + Database(bar_path).table("bar").insert({"text": "bar"}) + bar_uri = Path(bar_path).as_uri() + "?mode=ro" + result = CliRunner().invoke( + cli.cli, + [foo_path, "--attach", "bar", bar_uri, "select text from bar.bar"], + ) + assert result.exit_code == 0, result.output + assert json.loads(result.output) == [{"text": "bar"}] + + def test_csv_insert_bom(tmpdir): db_path = str(tmpdir / "test.db") bom_csv_path = str(tmpdir / "bom.csv") diff --git a/tests/test_constructor.py b/tests/test_constructor.py index 412b66c2c..09a75e22e 100644 --- a/tests/test_constructor.py +++ b/tests/test_constructor.py @@ -1,3 +1,4 @@ +import pathlib import sys import pytest @@ -114,3 +115,20 @@ def test_memory_attribute_for_file_path(tmpdir): db = Database(str(tmpdir / "file.db")) assert db.memory is False assert db.memory_name is None + + +def test_sqlite_uri(tmpdir): + path = tmpdir / "file.db" + Database(str(path)).close() + uri = pathlib.Path(str(path)).as_uri() + "?mode=ro" + db = Database(uri) + assert db.execute("select 1").fetchone()[0] == 1 + with pytest.raises(sqlite3.OperationalError, match="readonly"): + db.execute("create table test (id integer)") + db.close() + + +def test_recreate_rejects_sqlite_uri(tmpdir): + uri = pathlib.Path(str(tmpdir / "file.db")).as_uri() + "?mode=rwc" + with pytest.raises(ValueError, match="SQLite URIs"): + Database(uri, recreate=True)