diff --git a/docs/cli.rst b/docs/cli.rst index 53a9a5f8..18318a02 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -11,6 +11,41 @@ Once :ref:`installed ` the tool should be available as ``sqlite-ut .. contents:: :local: :class: this-will-duplicate-information-and-it-is-still-useful-here +.. _cli_uri_filenames: + +URI filenames +============= + +``sqlite-utils`` supports `SQLite URI filenames `__, which allow you to open databases with special parameters. + +A URI filename starts with ``file:`` and can include query parameters to control database behavior: + +.. code-block:: bash + + sqlite-utils tables 'file:data.db?mode=ro' + +Common URI parameters include: + +- ``mode=ro`` - Open database read-only +- ``mode=rw`` - Open read-write (default) +- ``mode=rwc`` - Open read-write and create if it doesn't exist +- ``mode=memory`` - Open as an in-memory database +- ``immutable=1`` - Open as immutable (SQLite will not try to modify the file) + +Example opening a database as read-only and immutable: + +.. code-block:: bash + + sqlite-utils query 'file:data.db?mode=ro&immutable=1' "select * from items" + +This is particularly useful for: + +- Safely querying databases without risk of modification +- Opening databases on read-only filesystems +- Sharing databases between multiple processes with ``immutable=1`` + +URI filenames work with all ``sqlite-utils`` commands that accept database paths. + .. _cli_query: Running SQL queries @@ -504,7 +539,7 @@ By default, ``sqlite-utils memory`` will attempt to detect the incoming data for You can instead specify an explicit format by adding a ``:csv``, ``:tsv``, ``:json`` or ``:nl`` (for newline-delimited JSON) suffix to the filename. For example: .. code-block:: bash - + sqlite-utils memory one.dat:csv two.dat:nl \ "select * from one union select * from two" @@ -1153,7 +1188,7 @@ You can also pipe ``sqlite-utils`` together to create a new SQLite database file sqlite-utils sf-trees.db \ "select TreeID, qAddress, Latitude, Longitude from Street_Tree_List" --nl \ | sqlite-utils insert saved.db trees - --nl - + .. code-block:: bash sqlite-utils saved.db "select * from trees limit 5" --csv @@ -2729,7 +2764,7 @@ You can convert an existing table to a geographic table by adding a geometry col The table (``locations`` in the example above) must already exist before adding a geometry column. Use ``sqlite-utils create-table`` first, then ``add-geometry-column``. -Use the ``--type`` option to specify a geometry type. By default, ``add-geometry-column`` uses a generic ``GEOMETRY``, which will work with any type, though it may not be supported by some desktop GIS applications. +Use the ``--type`` option to specify a geometry type. By default, ``add-geometry-column`` uses a generic ``GEOMETRY``, which will work with any type, though it may not be supported by some desktop GIS applications. Eight (case-insensitive) types are allowed: diff --git a/docs/python-api.rst b/docs/python-api.rst index 47cb30b1..8990d74b 100644 --- a/docs/python-api.rst +++ b/docs/python-api.rst @@ -93,6 +93,16 @@ Instead of a file path you can pass in an existing SQLite connection: db = Database(sqlite3.connect("my_database.db")) +You can also use `SQLite URI filenames `__ to open databases with special parameters: + +.. code-block:: python + + # Open database in read-only mode + db = Database("file:data.db?mode=ro") + + # Open as read-only and immutable + db = Database("file:data.db?mode=ro&immutable=1") + If you want to create an in-memory database, you can do so like this: .. code-block:: python diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 6b7ebd97..43a6c6f3 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -55,6 +55,37 @@ maximize_csv_field_size_limit() +class DatabasePath(click.Path): + """ + Custom Click parameter type for database paths that supports SQLite URI filenames. + + URIs (starting with 'file:') skip file existence validation and are passed + directly to sqlite3.connect() with uri=True. + + See: https://www.sqlite.org/uri.html + """ + def __init__(self, exists=False, **kwargs): + # Store original exists parameter for URI detection + self._check_exists = exists + # Always pass exists=False to parent to skip validation + # We'll do our own validation for non-URI paths + super().__init__(exists=False, **kwargs) + + def convert(self, value, param, ctx): + # If it's a URI (starts with "file:"), skip existence check + if isinstance(value, str) and value.startswith("file:"): + return value + + # For non-URI paths, do normal path validation + if self._check_exists: + # Create a temporary Path validator with exists=True + validator = click.Path(exists=True, file_okay=self.file_okay, + dir_okay=self.dir_okay, allow_dash=self.allow_dash) + return validator.convert(value, param, ctx) + + return super().convert(value, param, ctx) + + class CaseInsensitiveChoice(click.Choice): def __init__(self, choices): super().__init__([choice.lower() for choice in choices]) @@ -125,7 +156,7 @@ def cli(): @cli.command() @click.argument( "path", - type=click.Path(exists=True, file_okay=True, dir_okay=False, allow_dash=False), + type=DatabasePath(exists=True, file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.option( @@ -223,7 +254,7 @@ def _iter(): @cli.command() @click.argument( "path", - type=click.Path(exists=True, file_okay=True, dir_okay=False, allow_dash=False), + type=DatabasePath(exists=True, file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.option( @@ -288,7 +319,7 @@ def views( @cli.command() @click.argument( "path", - type=click.Path(exists=True, file_okay=True, dir_okay=False, allow_dash=False), + type=DatabasePath(exists=True, file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("tables", nargs=-1) @@ -316,7 +347,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=DatabasePath(exists=True, file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("tables", nargs=-1) @@ -341,7 +372,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=DatabasePath(exists=True, file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("names", nargs=-1) @@ -367,7 +398,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=DatabasePath(exists=True, file_okay=True, dir_okay=False, allow_dash=False), required=True, ) def vacuum(path): @@ -384,7 +415,7 @@ def vacuum(path): @cli.command() @click.argument( "path", - type=click.Path(exists=True, file_okay=True, dir_okay=False, allow_dash=False), + type=DatabasePath(exists=True, file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @load_extension_option @@ -405,7 +436,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=DatabasePath(exists=True, file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("table") @@ -471,7 +502,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=DatabasePath(exists=True, file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("table") @@ -505,7 +536,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=DatabasePath(exists=True, file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("foreign_key", nargs=-1) @@ -539,7 +570,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=DatabasePath(exists=True, file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @load_extension_option @@ -560,7 +591,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=DatabasePath(exists=True, file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("table") @@ -616,7 +647,7 @@ def create_index( @cli.command(name="enable-fts") @click.argument( "path", - type=click.Path(exists=True, file_okay=True, dir_okay=False, allow_dash=False), + type=DatabasePath(exists=True, file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("table") @@ -670,7 +701,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=DatabasePath(exists=True, file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("table") @@ -692,7 +723,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=DatabasePath(exists=True, file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("table") @@ -714,7 +745,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=DatabasePath(exists=True, file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @load_extension_option @@ -736,7 +767,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=DatabasePath(exists=True, file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @load_extension_option @@ -757,7 +788,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=DatabasePath(exists=True, file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("tables", nargs=-1) @@ -786,7 +817,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=DatabasePath(exists=True, file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @load_extension_option @@ -854,7 +885,7 @@ def inner(fn): ( click.argument( "path", - type=click.Path(file_okay=True, dir_okay=False, allow_dash=False), + type=DatabasePath(file_okay=True, dir_okay=False, allow_dash=False), required=True, ), click.argument("table"), @@ -1362,7 +1393,7 @@ def upsert( @cli.command() @click.argument( "path", - type=click.Path(file_okay=True, dir_okay=False, allow_dash=False), + type=DatabasePath(file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("sql") @@ -1447,7 +1478,7 @@ def bulk( @cli.command(name="create-database") @click.argument( "path", - type=click.Path(file_okay=True, dir_okay=False, allow_dash=False), + type=DatabasePath(file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.option( @@ -1483,7 +1514,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=DatabasePath(file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("table") @@ -1594,7 +1625,7 @@ def create_table( @cli.command(name="duplicate") @click.argument( "path", - type=click.Path(file_okay=True, dir_okay=False, allow_dash=False), + type=DatabasePath(file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("table") @@ -1617,7 +1648,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=DatabasePath(file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("table") @@ -1642,7 +1673,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=DatabasePath(file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("table") @@ -1667,7 +1698,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=DatabasePath(file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("view") @@ -1712,7 +1743,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=DatabasePath(file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("view") @@ -1737,13 +1768,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=DatabasePath(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, DatabasePath(file_okay=True, dir_okay=False, allow_dash=False)), multiple=True, help="Additional databases to attach - specify alias and filepath", ) @@ -1826,7 +1857,7 @@ def query( ) @click.option( "--attach", - type=(str, click.Path(file_okay=True, dir_okay=False, allow_dash=False)), + type=(str, DatabasePath(file_okay=True, dir_okay=False, allow_dash=False)), multiple=True, help="Additional databases to attach - specify alias and filepath", ) @@ -1859,7 +1890,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=DatabasePath(file_okay=True, dir_okay=False, allow_dash=False), help="Save in-memory database to this file", ) @click.option( @@ -2077,7 +2108,7 @@ def _execute_query( @cli.command() @click.argument( "path", - type=click.Path(file_okay=True, dir_okay=False, allow_dash=False), + type=DatabasePath(file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("dbtable") @@ -2174,7 +2205,7 @@ def search( @cli.command() @click.argument( "path", - type=click.Path(file_okay=True, dir_okay=False, allow_dash=False), + type=DatabasePath(file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("dbtable") @@ -2260,7 +2291,7 @@ def rows( @cli.command() @click.argument( "path", - type=click.Path(file_okay=True, dir_okay=False, allow_dash=False), + type=DatabasePath(file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("tables", nargs=-1) @@ -2313,7 +2344,7 @@ def triggers( @cli.command() @click.argument( "path", - type=click.Path(file_okay=True, dir_okay=False, allow_dash=False), + type=DatabasePath(file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("tables", nargs=-1) @@ -2380,7 +2411,7 @@ def indexes( @cli.command() @click.argument( "path", - type=click.Path(file_okay=True, dir_okay=False, allow_dash=False), + type=DatabasePath(file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("tables", nargs=-1, required=False) @@ -2409,7 +2440,7 @@ def schema( @cli.command() @click.argument( "path", - type=click.Path(file_okay=True, dir_okay=False, allow_dash=False), + type=DatabasePath(file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("table") @@ -2538,7 +2569,7 @@ def transform( @cli.command() @click.argument( "path", - type=click.Path(file_okay=True, dir_okay=False, allow_dash=False), + type=DatabasePath(file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("table") @@ -2584,7 +2615,7 @@ def extract( @cli.command(name="insert-files") @click.argument( "path", - type=click.Path(file_okay=True, dir_okay=False, allow_dash=False), + type=DatabasePath(file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("table") @@ -2921,7 +2952,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=DatabasePath(file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("table", type=str) @@ -3060,7 +3091,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=DatabasePath(file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("table", type=str) @@ -3136,7 +3167,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=DatabasePath(file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("table", type=str) diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index 2be7a6d4..6161fcf1 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -348,7 +348,10 @@ def __init__( self.conn = sqlite3.connect(":memory:") self.memory = True elif isinstance(filename_or_conn, (str, pathlib.Path)): - if recreate and os.path.exists(filename_or_conn): + filename_str = str(filename_or_conn) + # Check if this is a URI filename (starts with "file:") + is_uri = filename_str.startswith("file:") + if recreate and not is_uri and os.path.exists(filename_or_conn): try: os.remove(filename_or_conn) except OSError: @@ -356,7 +359,11 @@ def __init__( # https://github.com/simonw/sqlite-utils/issues/503 self.conn = sqlite3.connect(":memory:") raise - self.conn = sqlite3.connect(str(filename_or_conn)) + if is_uri: + # URI filenames need uri=True parameter + self.conn = sqlite3.connect(filename_str, uri=True) + else: + self.conn = sqlite3.connect(filename_str) else: assert not recreate, "recreate cannot be used with connections, only paths" self.conn = filename_or_conn diff --git a/tests/test_uri.py b/tests/test_uri.py new file mode 100644 index 00000000..10f4b6b2 --- /dev/null +++ b/tests/test_uri.py @@ -0,0 +1,177 @@ +""" +Tests for SQLite URI filename support +https://github.com/simonw/sqlite-utils/issues/650 +""" +import pytest +import sqlite_utils +from sqlite_utils import Database +from click.testing import CliRunner +from sqlite_utils import cli +import pathlib +import tempfile +import os + + +@pytest.fixture +def test_db_file(tmp_path): + """Create a test database file with some data""" + db_path = tmp_path / "test.db" + db = Database(str(db_path)) + db["test_table"].insert_all([ + {"id": 1, "name": "Alice"}, + {"id": 2, "name": "Bob"}, + {"id": 3, "name": "Charlie"}, + ], pk="id") + db.close() + return db_path + + +def test_database_class_with_uri(test_db_file): + """Test that Database class can open a URI""" + # Test read-only mode + uri = f"file:{test_db_file}?mode=ro" + db = Database(uri) + rows = list(db["test_table"].rows) + assert len(rows) == 3 + assert rows[0]["name"] == "Alice" + db.close() + + +def test_database_class_with_uri_immutable(test_db_file): + """Test that Database class can open a URI with immutable flag""" + uri = f"file:{test_db_file}?immutable=1" + db = Database(uri) + rows = list(db["test_table"].rows) + assert len(rows) == 3 + db.close() + + +def test_database_class_with_uri_multiple_params(test_db_file): + """Test URI with multiple query parameters""" + uri = f"file:{test_db_file}?mode=ro&immutable=1" + db = Database(uri) + rows = list(db["test_table"].rows) + assert len(rows) == 3 + db.close() + + +def test_cli_tables_with_uri(test_db_file): + """Test that tables command works with URI""" + runner = CliRunner() + uri = f"file:{test_db_file}?mode=ro" + result = runner.invoke(cli.cli, ["tables", uri, "--csv"]) + assert result.exit_code == 0 + assert "test_table" in result.output + + +def test_cli_tables_with_uri_immutable(test_db_file): + """Test that tables command works with URI and immutable flag""" + runner = CliRunner() + uri = f"file:{test_db_file}?mode=ro&immutable=1" + result = runner.invoke(cli.cli, ["tables", uri]) + assert result.exit_code == 0 + assert "test_table" in result.output + + +def test_cli_query_with_uri(test_db_file): + """Test that query command works with URI""" + runner = CliRunner() + uri = f"file:{test_db_file}?mode=ro" + result = runner.invoke(cli.cli, ["query", uri, "SELECT * FROM test_table"]) + assert result.exit_code == 0 + assert "Alice" in result.output + assert "Bob" in result.output + + +def test_cli_query_with_uri_multiple_params(test_db_file): + """Test that query command works with URI with multiple parameters""" + runner = CliRunner() + uri = f"file:{test_db_file}?mode=ro&immutable=1" + result = runner.invoke(cli.cli, ["query", uri, "SELECT name FROM test_table WHERE id = 1"]) + assert result.exit_code == 0 + assert "Alice" in result.output + + +def test_cli_rows_with_uri(test_db_file): + """Test that rows command works with URI""" + runner = CliRunner() + uri = f"file:{test_db_file}?mode=ro" + result = runner.invoke(cli.cli, ["rows", uri, "test_table"]) + assert result.exit_code == 0 + assert "Alice" in result.output + + +def test_uri_with_relative_path(tmp_path): + """Test URI with relative path""" + # Create a database in a subdirectory + db_path = tmp_path / "test.db" + db = Database(str(db_path)) + db["test_table"].insert({"id": 1, "name": "Test"}) + db.close() + + # Test with relative path in URI + old_cwd = os.getcwd() + try: + os.chdir(tmp_path) + uri = "file:test.db?mode=ro" + db = Database(uri) + rows = list(db["test_table"].rows) + assert len(rows) == 1 + db.close() + finally: + os.chdir(old_cwd) + + +def test_uri_with_absolute_path(test_db_file): + """Test URI with absolute path""" + # Use triple slash for absolute path + uri = f"file:///{test_db_file}?mode=ro" + db = Database(uri) + rows = list(db["test_table"].rows) + assert len(rows) == 3 + db.close() + + +def test_regular_path_still_works(test_db_file): + """Ensure regular file paths still work after URI changes""" + # Test Database class + db = Database(str(test_db_file)) + rows = list(db["test_table"].rows) + assert len(rows) == 3 + db.close() + + # Test CLI + runner = CliRunner() + result = runner.invoke(cli.cli, ["tables", str(test_db_file)]) + assert result.exit_code == 0 + assert "test_table" in result.output + + +def test_cli_insert_with_uri_fails_readonly(): + """Test that insert fails with read-only URI""" + runner = CliRunner() + with runner.isolated_filesystem(): + # Create a database first + db = Database("test.db") + db["test_table"].insert({"id": 1, "name": "Test"}) + db.close() + + # Try to insert with read-only URI + uri = "file:test.db?mode=ro" + result = runner.invoke(cli.cli, ["insert", uri, "test_table", "-"], + input='{"id": 2, "name": "Another"}') + assert result.exit_code != 0 + assert "readonly" in result.output.lower() or "read-only" in result.output.lower() or "attempt to write" in result.output.lower() + + +def test_nonexistent_file_with_uri_mode_rwc(): + """Test that URI with mode=rwc can create new database""" + runner = CliRunner() + with runner.isolated_filesystem(): + uri = "file:newdb.db?mode=rwc" + # This should create the database and table + result = runner.invoke(cli.cli, ["insert", uri, "test_table", "-"], + input='{"id": 1, "name": "Test"}') + assert result.exit_code == 0 + # Verify the database was created + assert os.path.exists("newdb.db")