From ddfdff657f34126c0b4c6f8361c2ca9e5d30c336 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 18 Aug 2021 16:01:00 -0700 Subject: [PATCH 001/563] Fixed incorrecte output example --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 9890e61..cfb2582 100644 --- a/README.md +++ b/README.md @@ -38,8 +38,7 @@ Now you can do things with the CLI utility like this: {"id": 2, "age": 2, "name": "Pancakes"}] $ sqlite-utils insert dogs.db dogs dogs.csv --csv - [{"id": 1, "age": 4, "name": "Cleo"}, - {"id": 2, "age": 2, "name": "Pancakes"}] + [####################################] 100% $ sqlite-utils tables dogs.db --counts [{"table": "dogs", "count": 2}] From b30f725d982309eb26ef0b985aadc0064df8e8f9 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 18 Aug 2021 16:02:07 -0700 Subject: [PATCH 002/563] Small improvement to example --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index cfb2582..6b88947 100644 --- a/README.md +++ b/README.md @@ -43,9 +43,9 @@ Now you can do things with the CLI utility like this: $ sqlite-utils tables dogs.db --counts [{"table": "dogs", "count": 2}] - $ sqlite-utils dogs.db "select * from dogs" - [{"id": 1, "age": 4, "name": "Cleo"}, - {"id": 2, "age": 2, "name": "Pancakes"}] + $ sqlite-utils dogs.db "select id, name from dogs" + [{"id": 1, "name": "Cleo"}, + {"id": 2, "name": "Pancakes"}] $ sqlite-utils dogs.db "select * from dogs" --csv id,age,name From d7b1024d3a9e092c030237410219a8ae376a4799 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 18 Aug 2021 16:02:55 -0700 Subject: [PATCH 003/563] Corrected stdin example --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6b88947..9345286 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ Or for data in a CSV file: `sqlite-utils memory` lets you import CSV or JSON data into an in-memory database and run SQL queries against it in a single command: - $ cat dogs.csv | sqlite-utils memory - "select name, age from dogs" + $ cat dogs.csv | sqlite-utils memory - "select name, age from stdin" See the [full CLI documentation](https://sqlite-utils.datasette.io/en/stable/cli.html) for comprehensive coverage of many more commands. From 9258f4bd8450c951900de998a7bf81ca9b45a014 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 22 Aug 2021 08:44:25 -0700 Subject: [PATCH 004/563] sqlite-utils memory --analyze, closes #320 --- docs/cli.rst | 33 ++++++++++++++++++++++++++++++--- sqlite_utils/cli.py | 18 ++++++++++++++++-- tests/test_cli_memory.py | 21 +++++++++++++++++++++ 3 files changed, 67 insertions(+), 5 deletions(-) diff --git a/docs/cli.rst b/docs/cli.rst index 6a96a82..3c06b81 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -328,10 +328,10 @@ The CSV data that was piped into the script is available in the ``stdin`` table, .. _cli_memory_schema_dump_save: -\-\-schema, \-\-dump and \-\-save ---------------------------------- +\-\-schema, \-\-analyze, \-\-dump and \-\-save +---------------------------------------------- -To see the schema that will be created for a file or multiple files, use ``--schema``:: +To see the in-memory datbase schema that would be used for a file or for multiple files, use ``--schema``:: % sqlite-utils memory dogs.csv --schema CREATE TABLE [dogs] ( @@ -342,6 +342,33 @@ To see the schema that will be created for a file or multiple files, use ``--sch CREATE VIEW t1 AS select * from [dogs]; CREATE VIEW t AS select * from [dogs]; +You can run the equivalent of the :ref:`analyze-tables ` command using ``--analyze``:: + + % sqlite-utils memory dogs.csv --analyze + dogs.id: (1/3) + + Total rows: 2 + Null rows: 0 + Blank rows: 0 + + Distinct values: 2 + + dogs.name: (2/3) + + Total rows: 2 + Null rows: 0 + Blank rows: 0 + + Distinct values: 2 + + dogs.age: (3/3) + + Total rows: 2 + Null rows: 0 + Blank rows: 0 + + Distinct values: 2 + You can output SQL that will both create the tables and insert the full data used to populate the in-memory database using ``--dump``:: % sqlite-utils memory dogs.csv --dump diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 476c08a..58cda49 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -1216,6 +1216,11 @@ def query( type=click.Path(file_okay=True, dir_okay=False, allow_dash=False), help="Save in-memory database to this file", ) +@click.option( + "--analyze", + is_flag=True, + help="Analyze resulting tables and output results", +) @load_extension_option def memory( paths, @@ -1236,6 +1241,7 @@ def memory( schema, dump, save, + analyze, load_extension, ): """Execute SQL query against an in-memory database, optionally populated by imported data @@ -1265,8 +1271,8 @@ def memory( sqlite-utils memory animals.csv --schema """ db = sqlite_utils.Database(memory=True) - # If --dump or --save used but no paths detected, assume SQL query is a path: - if (dump or save or schema) and not paths: + # If --dump or --save or --analyze used but no paths detected, assume SQL query is a path: + if (dump or save or schema or analyze) and not paths: paths = [sql] sql = None for i, path in enumerate(paths): @@ -1299,6 +1305,10 @@ def memory( if not db[view_name].exists(): db.create_view(view_name, "select * from [{}]".format(csv_table)) + if analyze: + _analyze(db, tables=None, columns=None, save=False) + return + if dump: for line in db.conn.iterdump(): click.echo(line) @@ -1922,6 +1932,10 @@ def analyze_tables( "Analyze the columns in one or more tables" db = sqlite_utils.Database(path) _load_extensions(db, load_extension) + _analyze(db, tables, columns, save) + + +def _analyze(db, tables, columns, save): if not tables: tables = db.table_names() todo = [] diff --git a/tests/test_cli_memory.py b/tests/test_cli_memory.py index e465927..08566e3 100644 --- a/tests/test_cli_memory.py +++ b/tests/test_cli_memory.py @@ -220,3 +220,24 @@ def test_memory_no_detect_types(option): {"id": "1", "name": "Cleo", "weight": "45.5"}, {"id": "2", "name": "Bants", "weight": "3.5"}, ] + + +def test_memory_analyze(): + result = CliRunner().invoke( + cli.cli, + ["memory", "-", "--analyze"], + input="id,name\n1,Cleo\n2,Bants", + ) + assert result.exit_code == 0 + assert result.output == ( + "stdin.id: (1/2)\n\n" + " Total rows: 2\n" + " Null rows: 0\n" + " Blank rows: 0\n\n" + " Distinct values: 2\n\n" + "stdin.name: (2/2)\n\n" + " Total rows: 2\n" + " Null rows: 0\n" + " Blank rows: 0\n\n" + " Distinct values: 2\n\n" + ) From 49a010c93d90bc68ce1c6fff7639927248912b54 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 24 Aug 2021 16:31:13 -0700 Subject: [PATCH 005/563] Ability to insert file contents as text, in addition to blob (#321) --- docs/cli.rst | 18 ++++++++----- sqlite_utils/cli.py | 45 +++++++++++++++++++++++++++---- tests/test_insert_files.py | 54 +++++++++++++++++++++++++++++++++++--- 3 files changed, 101 insertions(+), 16 deletions(-) diff --git a/docs/cli.rst b/docs/cli.rst index 3c06b81..0cc20dd 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -331,7 +331,7 @@ The CSV data that was piped into the script is available in the ``stdin`` table, \-\-schema, \-\-analyze, \-\-dump and \-\-save ---------------------------------------------- -To see the in-memory datbase schema that would be used for a file or for multiple files, use ``--schema``:: +To see the in-memory database schema that would be used for a file or for multiple files, use ``--schema``:: % sqlite-utils memory dogs.csv --schema CREATE TABLE [dogs] ( @@ -909,12 +909,10 @@ The command will fail if you reference columns that do not exist on the table. T .. _cli_insert_files: -Inserting binary data from files -================================ +Inserting data from files +========================= -SQLite ``BLOB`` columns can be used to store binary content. It can be useful to insert the contents of files into a SQLite table. - -The ``insert-files`` command can be used to insert the content of files, along with their metadata. +The ``insert-files`` command can be used to insert the content of files, along with their metadata, into a SQLite table. Here's an example that inserts all of the GIF files in the current directory into a ``gifs.db`` database, placing the file contents in an ``images`` table:: @@ -932,6 +930,8 @@ By default this command will create a table with the following schema:: [size] INTEGER ); +Content will be treated as binary by default and stored in a ``BLOB`` column. You can use the ``--text`` option to store that content in a ``TEXT`` column instead. + You can customize the schema using one or more ``-c`` options. For a table schema that includes just the path, MD5 hash and last modification time of the file, you would use this:: $ sqlite-utils insert-files gifs.db images *.gif -c path -c md5 -c mtime --pk=path @@ -944,6 +944,8 @@ This will result in the following schema:: [mtime] FLOAT ); +Note that there's no ``content`` column here at all - if you specify custom columns using ``-c`` you need to include ``-c content`` to create that column. + You can change the name of one of these columns using a ``-c colname:coldef`` parameter. To rename the ``mtime`` column to ``last_modified`` you would use this:: $ sqlite-utils insert-files gifs.db images *.gif \ @@ -967,6 +969,8 @@ The full list of column definitions you can use is as follows: The permission bits of the file, as an integer - you may want to convert this to octal ``content`` The binary file contents, which will be stored as a BLOB +``content_text`` + The text file contents, which will be stored as TEXT ``mtime`` The modification time of the file, as floating point seconds since the Unix epoch ``ctime`` @@ -988,7 +992,7 @@ You can insert data piped from standard input like this:: The ``-`` argument indicates data should be read from standard input. The string passed using the ``--name`` option will be used for the file name and path values. -When inserting data from standard input only the following column definitions are supported: ``name``, ``path``, ``content``, ``sha256``, ``md5`` and ``size``. +When inserting data from standard input only the following column definitions are supported: ``name``, ``path``, ``content``, ``content_text``, ``sha256``, ``md5`` and ``size``. .. _cli_convert: diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 58cda49..10a11c6 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -1811,6 +1811,11 @@ def extract( @click.option("--replace", is_flag=True, help="Replace files with matching primary key") @click.option("--upsert", is_flag=True, help="Upsert files with matching primary key") @click.option("--name", type=str, help="File name to use") +@click.option("--text", is_flag=True, help="Store file content as TEXT, not BLOB") +@click.option( + "--encoding", + help="Character encoding for input, defaults to utf-8", +) @click.option("-s", "--silent", is_flag=True, help="Don't show a progress bar") @load_extension_option def insert_files( @@ -1823,6 +1828,8 @@ def insert_files( replace, upsert, name, + text, + encoding, silent, load_extension, ): @@ -1842,7 +1849,10 @@ def insert_files( --pk name """ if not column: - column = ["path:path", "content:content", "size:size"] + if text: + column = ["path:path", "content_text:content_text", "size:size"] + else: + column = ["path:path", "content:content", "size:size"] if not pk: pk = "path" @@ -1866,7 +1876,16 @@ def insert_files( def to_insert(): for path, relative_path in bar: row = {} - lookups = FILE_COLUMNS + # content_text is special case as it considers 'encoding' + + def _content_text(p): + resolved = p.resolve() + try: + return resolved.read_text(encoding=encoding) + except UnicodeDecodeError as e: + raise UnicodeDecodeErrorForPath(e, resolved) + + lookups = dict(FILE_COLUMNS, content_text=_content_text) if path == "-": stdin_data = sys.stdin.buffer.read() # We only support a subset of columns for this case @@ -1874,6 +1893,9 @@ def insert_files( "name": lambda p: name or "-", "path": lambda p: name or "-", "content": lambda p: stdin_data, + "content_text": lambda p: stdin_data.decode( + encoding or "utf-8" + ), "sha256": lambda p: hashlib.sha256(stdin_data).hexdigest(), "md5": lambda p: hashlib.md5(stdin_data).hexdigest(), "size": lambda p: len(stdin_data), @@ -1899,9 +1921,16 @@ def insert_files( db = sqlite_utils.Database(path) _load_extensions(db, load_extension) - with db.conn: - db[table].insert_all( - to_insert(), pk=pk, alter=alter, replace=replace, upsert=upsert + try: + with db.conn: + db[table].insert_all( + to_insert(), pk=pk, alter=alter, replace=replace, upsert=upsert + ) + except UnicodeDecodeErrorForPath as e: + raise click.ClickException( + UNICODE_ERROR.format( + "Could not read file '{}' as text\n\n{}".format(e.path, e.exception) + ) ) @@ -2149,6 +2178,12 @@ def _render_common(title, values): return "\n".join(lines) +class UnicodeDecodeErrorForPath(Exception): + def __init__(self, exception, path): + self.exception = exception + self.path = path + + FILE_COLUMNS = { "name": lambda p: p.name, "path": lambda p: str(p), diff --git a/tests/test_insert_files.py b/tests/test_insert_files.py index 1e30a8d..86ee4e3 100644 --- a/tests/test_insert_files.py +++ b/tests/test_insert_files.py @@ -3,6 +3,7 @@ from click.testing import CliRunner import os import pathlib import pytest +import sys @pytest.mark.parametrize("silent", (False, True)) @@ -23,6 +24,7 @@ def test_insert_files(silent): "md5", "mode", "content", + "content_text", "mtime", "ctime", "mtime_int", @@ -52,6 +54,7 @@ def test_insert_files(silent): ) assert { "content": b"This is file one", + "content_text": "This is file one", "md5": "556dfb57fce9ca301f914e2273adf354", "name": "one.txt", "path": "one.txt", @@ -60,6 +63,7 @@ def test_insert_files(silent): }.items() <= one.items() assert { "content": b"Two is shorter", + "content_text": "Two is shorter", "md5": "f86f067b083af1911043eb215e74ac70", "name": "two.txt", "path": "two.txt", @@ -68,6 +72,7 @@ def test_insert_files(silent): }.items() <= two.items() assert { "content": b"Three is nested", + "content_text": "Three is nested", "md5": "12580f341781f5a5b589164d3cd39523", "name": "three.txt", "path": os.path.join("nested", "three.txt"), @@ -84,24 +89,65 @@ def test_insert_files(silent): "mtime_iso": str, "mode": int, "fullpath": str, + "content": bytes, + "content_text": str, } for colname, expected_type in expected_types.items(): for row in (one, two, three): assert isinstance(row[colname], expected_type) -def test_insert_files_stdin(): +@pytest.mark.parametrize( + "use_text,encoding,input,expected", + ( + (False, None, "hello world", b"hello world"), + (True, None, "hello world", "hello world"), + (False, None, b"S\xe3o Paulo", b"S\xe3o Paulo"), + (True, "latin-1", b"S\xe3o Paulo", "S\xe3o Paulo"), + ), +) +def test_insert_files_stdin(use_text, encoding, input, expected): runner = CliRunner() with runner.isolated_filesystem(): tmpdir = pathlib.Path(".") db_path = str(tmpdir / "files.db") + args = ["insert-files", db_path, "files", "-", "--name", "stdin-name"] + if use_text: + args += ["--text"] + if encoding is not None: + args += ["--encoding", encoding] result = runner.invoke( cli.cli, - ["insert-files", db_path, "files", "-", "--name", "stdin-name"], + args, catch_exceptions=False, - input="hello world", + input=input, ) assert result.exit_code == 0, result.stdout db = Database(db_path) row = list(db["files"].rows)[0] - assert {"path": "stdin-name", "content": b"hello world", "size": 11} == row + key = "content" + if use_text: + key = "content_text" + assert {"path": "stdin-name", key: expected}.items() <= row.items() + + +@pytest.mark.skipif( + sys.platform.startswith("win"), + reason="Windows has a different way of handling default encodings", +) +def test_insert_files_bad_text_encoding_error(): + runner = CliRunner() + with runner.isolated_filesystem(): + tmpdir = pathlib.Path(".") + latin = tmpdir / "latin.txt" + latin.write_bytes(b"S\xe3o Paulo") + db_path = str(tmpdir / "files.db") + result = runner.invoke( + cli.cli, + ["insert-files", db_path, "files", str(latin), "--text"], + catch_exceptions=False, + ) + assert result.exit_code == 1, result.output + assert result.output.strip().startswith( + "Error: Could not read file '{}' as text".format(str(latin.resolve())) + ) From 77c240df56068341561e95e4a412cbfa24dc5bc7 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 24 Aug 2021 16:39:49 -0700 Subject: [PATCH 006/563] Release 3.17 Refs #319, #320 --- docs/changelog.rst | 8 ++++++++ setup.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 7ff467d..6144b33 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -2,6 +2,14 @@ Changelog =========== +.. _v3_17: + +3.17 (2021-08-24) +----------------- + +- The :ref:`sqlite-utils memory ` command has a new ``--analyze`` option, which runs the equivalent of the :ref:`analyze-tables ` command directly against the in-memory database created from the incoming CSV or JSON data. (:issue:`320`) +- :ref:`sqlite-utils insert-files ` now has the ability to insert file contents in to ``TEXT`` columns in addition to the default ``BLOB``. Pass the ``--text`` option or use ``content_text`` as a column specifier. (:issue:`319`) + .. _v3_16: 3.16 (2021-08-18) diff --git a/setup.py b/setup.py index 6d138b4..e12f494 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages import io import os -VERSION = "3.16" +VERSION = "3.17" def get_long_description(): From 7427a9137f60de961b6331d0922a3f03da0d1890 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 22 Sep 2021 13:20:04 -0700 Subject: [PATCH 007/563] Output [] in JSON mode if no rows, closes #328 --- sqlite_utils/cli.py | 3 +++ tests/test_cli.py | 13 ++++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 10a11c6..5217965 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -2225,6 +2225,9 @@ def output_rows(iterator, headers, nl, arrays, json_cols): ) yield line first = False + if first: + # We didn't output any rows, so yield the empty list + yield "[]" def maybe_json(value): diff --git a/tests/test_cli.py b/tests/test_cli.py index a6ddca0..47920c6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -994,6 +994,13 @@ def test_query_json(db_path, sql, args, expected): assert expected == result.output.strip() +def test_query_json_empty(db_path): + result = CliRunner().invoke( + cli.cli, [db_path, "select * from sqlite_master where 0"] + ) + assert result.output.strip() == "[]" + + LOREM_IPSUM_COMPRESSED = ( b"x\x9c\xed\xd1\xcdq\x03!\x0c\x05\xe0\xbb\xabP\x01\x1eW\x91\xdc|M\x01\n\xc8\x8e" b"f\xf83H\x1e\x97\x1f\x91M\x8e\xe9\xe0\xdd\x96\x05\x84\xf4\xbek\x9fRI\xc7\xf2J" @@ -2098,7 +2105,11 @@ _TRIGGERS_EXPECTED = ( @pytest.mark.parametrize( "extra_args,expected", - [([], _TRIGGERS_EXPECTED), (["articles"], _TRIGGERS_EXPECTED), (["counter"], "")], + [ + ([], _TRIGGERS_EXPECTED), + (["articles"], _TRIGGERS_EXPECTED), + (["counter"], "[]\n"), + ], ) def test_triggers(tmpdir, extra_args, expected): db_path = str(tmpdir / "test.db") From c1b26eed03f60c3e317550053a3832b7ad62e588 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 22 Sep 2021 13:45:37 -0700 Subject: [PATCH 008/563] sqlite-utils memory handles files with same filename, closes #325 --- docs/cli.rst | 4 ++++ sqlite_utils/cli.py | 8 +++++++- tests/test_cli_memory.py | 24 ++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/docs/cli.rst b/docs/cli.rst index 0cc20dd..d2a4d84 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -281,6 +281,10 @@ The in-memory tables will be named after the files without their extensions. The $ sqlite-utils memory example.csv "select * from t" +If two files have the same name they will be assigned a numeric suffix:: + + $ sqlite-utils memory foo/data.csv bar/data.csv "select * from data_2" + To read from standard input, use either ``-`` or ``stdin`` as the filename - then use ``stdin`` or ``t`` or ``t1`` as the table name:: $ cat example.csv | sqlite-utils memory - "select * from stdin" diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 5217965..b29314e 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -1275,6 +1275,7 @@ def memory( if (dump or save or schema or analyze) and not paths: paths = [sql] sql = None + stem_counts = {} for i, path in enumerate(paths): # Path may have a :format suffix if ":" in path and path.rsplit(":", 1)[-1].upper() in Format.__members__: @@ -1287,7 +1288,12 @@ def memory( csv_table = "stdin" else: csv_path = pathlib.Path(path) - csv_table = csv_path.stem + stem = csv_path.stem + if stem_counts.get(stem): + csv_table = "{}_{}".format(stem, stem_counts[stem]) + else: + csv_table = stem + stem_counts[stem] = stem_counts.get(stem, 1) + 1 csv_fp = csv_path.open("rb") rows, format_used = rows_from_file(csv_fp, format=format, encoding=encoding) tracker = None diff --git a/tests/test_cli_memory.py b/tests/test_cli_memory.py index 08566e3..a8b3a18 100644 --- a/tests/test_cli_memory.py +++ b/tests/test_cli_memory.py @@ -241,3 +241,27 @@ def test_memory_analyze(): " Blank rows: 0\n\n" " Distinct values: 2\n\n" ) + + +def test_memory_two_files_with_same_stem(tmpdir): + (tmpdir / "one").mkdir() + (tmpdir / "two").mkdir() + one = tmpdir / "one" / "data.csv" + two = tmpdir / "two" / "data.csv" + one.write_text("id,name\n1,Cleo\n2,Bants", encoding="utf-8") + two.write_text("id,name\n3,Blue\n4,Lila", encoding="utf-8") + result = CliRunner().invoke(cli.cli, ["memory", str(one), str(two), "", "--schema"]) + assert result.exit_code == 0 + assert result.output == ( + 'CREATE TABLE "data" (\n' + " [id] INTEGER,\n" + " [name] TEXT\n" + ");\n" + "CREATE VIEW t1 AS select * from [data];\n" + "CREATE VIEW t AS select * from [data];\n" + 'CREATE TABLE "data_2" (\n' + " [id] INTEGER,\n" + " [name] TEXT\n" + ");\n" + "CREATE VIEW t2 AS select * from [data_2];\n" + ) From 54191d4dc114d7dc21e849b48a4d5ae4f9e601ca Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 22 Sep 2021 13:49:36 -0700 Subject: [PATCH 009/563] Release 3.17.1 Refs #325, #328 --- docs/changelog.rst | 8 ++++++++ setup.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 6144b33..0eadaa1 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -2,6 +2,14 @@ Changelog =========== +.. _v3_17.1: + +3.17.1 (2021-09-22) +------------------- + +- :ref:`sqlite-utils memory ` now works if files passed to it share the same file name. (:issue:`325`) +- :ref:`sqlite-utils query ` now returns ``[]`` in JSON mode if no rows are returned. (:issue:`328`) + .. _v3_17: 3.17 (2021-08-24) diff --git a/setup.py b/setup.py index e12f494..f1bb056 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages import io import os -VERSION = "3.17" +VERSION = "3.17.1" def get_long_description(): From 718a8f61bcaed39c04d5d223104056213f8c8516 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 2 Oct 2021 09:54:39 -0700 Subject: [PATCH 010/563] Clarified description of --quote --- docs/cli.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cli.rst b/docs/cli.rst index d2a4d84..7a127fe 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -1554,7 +1554,7 @@ By default it shows the most relevant matches first. You can specify a different # Sort by created in descending order $ sqlite-utils search mydb.db documents searchterm -o 'created desc' -SQLite `advanced search syntax `__ is enabled by default. To run a search with automatic quoting use the ``--quote`` option. +SQLite `advanced search syntax `__ is enabled by default. To run a search with automatic quoting applied to the terms to avoid them being potentially interpreted as advanced search syntax use the ``--quote`` option. You can specify a subset of columns to be returned using the ``-c`` option one or more times:: From fda4dad23a0494890267fbe8baf179e2b56ee914 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 13 Oct 2021 15:25:05 -0700 Subject: [PATCH 011/563] Test against Python 3.10 (#330) * Test against Python 3.10 * Added 3.10 to classifiers * Test on Python 3.10 before publish --- .github/workflows/publish.yml | 2 +- .github/workflows/test.yml | 2 +- setup.py | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c0bd779..59c8a23 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -9,7 +9,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - python-version: [3.6, 3.7, 3.8, 3.9] + python-version: ["3.6", "3.7", "3.8", "3.9", "3.10"] os: [ubuntu-latest, windows-latest, macos-latest] steps: - uses: actions/checkout@v2 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 32be2e0..86b2531 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,7 +7,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - python-version: [3.6, 3.7, 3.8, 3.9] + python-version: ["3.6", "3.7", "3.8", "3.9", "3.10"] numpy: [0, 1] os: [ubuntu-latest, macos-latest, windows-latest] steps: diff --git a/setup.py b/setup.py index f1bb056..091c94a 100644 --- a/setup.py +++ b/setup.py @@ -67,5 +67,6 @@ setup( "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", ], ) From 92aa5c9c5d26b0889c8c3d97c76a908d5f8af211 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 11 Nov 2021 12:50:22 -0800 Subject: [PATCH 012/563] Fixed typo --- sqlite_utils/db.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index 93d25b2..ab7252a 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -2694,7 +2694,7 @@ class Table(Queryable): set up a unique constraint on the ``name`` column to guarantee it will not contain duplicate rows. - It well then inserts a new row with the ``name`` set to ``Palm`` and return the + It will then insert a new row with the ``name`` set to ``Palm`` and return the new integer primary key value. See :ref:`python_api_lookup_tables` for more details. From e8d958109ee290cfa1b44ef7a39629bb50ab673e Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 14 Nov 2021 14:49:19 -0800 Subject: [PATCH 013/563] create_index(..., find_unique_name=True) option, refs #335 --- docs/python-api.rst | 4 ++- sqlite_utils/db.py | 59 +++++++++++++++++++++++++++++++------------- tests/test_create.py | 17 +++++++++++++ 3 files changed, 62 insertions(+), 18 deletions(-) diff --git a/docs/python-api.rst b/docs/python-api.rst index 20d080d..8932656 100644 --- a/docs/python-api.rst +++ b/docs/python-api.rst @@ -2141,7 +2141,9 @@ You can create an index on a table using the ``.create_index(columns)`` method. db["dogs"].create_index(["is_good_dog"]) -By default the index will be named ``idx_{table-name}_{columns}`` - if you want to customize the name of the created index you can pass the ``index_name`` parameter: +By default the index will be named ``idx_{table-name}_{columns}``. If you pass ``find_unique_name=True`` and the automatically derived name already exists, an available name will be found by incrementing a suffix number, for example ``idx_items_title_2``. + +You can customize the name of the created index by passing the ``index_name`` parameter: .. code-block:: python diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index ab7252a..1e75861 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -899,7 +899,7 @@ class Database: } for fk in table.foreign_keys: if fk.column not in existing_indexes: - table.create_index([fk.column]) + table.create_index([fk.column], find_unique_name=True) def vacuum(self): "Run a SQLite ``VACUUM`` against the database." @@ -1521,6 +1521,7 @@ class Table(Queryable): index_name: Optional[str] = None, unique: bool = False, if_not_exists: bool = False, + find_unique_name: bool = False, ): """ Create an index on this table. @@ -1530,6 +1531,8 @@ class Table(Queryable): - ``index_name`` - the name to use for the new index. Defaults to the column names joined on ``_``. - ``unique`` - should the index be marked as unique, forcing unique values? - ``if_not_exists`` - only create the index if one with that name does not already exist. + - ``find_unique_name`` - if ``index_name`` is not provided and the automatically derived name + already exists, keep incrementing a suffix number to find an available name. See :ref:`python_api_create_index`. """ @@ -1544,23 +1547,45 @@ class Table(Queryable): else: fmt = "[{}]" columns_sql.append(fmt.format(column)) - sql = ( - textwrap.dedent( - """ - CREATE {unique}INDEX {if_not_exists}[{index_name}] - ON [{table_name}] ({columns}); - """ + + suffix = None + while True: + sql = ( + textwrap.dedent( + """ + CREATE {unique}INDEX {if_not_exists}[{index_name}] + ON [{table_name}] ({columns}); + """ + ) + .strip() + .format( + index_name="{}_{}".format(index_name, suffix) + if suffix + else index_name, + table_name=self.name, + columns=", ".join(columns_sql), + unique="UNIQUE " if unique else "", + if_not_exists="IF NOT EXISTS " if if_not_exists else "", + ) ) - .strip() - .format( - index_name=index_name, - table_name=self.name, - columns=", ".join(columns_sql), - unique="UNIQUE " if unique else "", - if_not_exists="IF NOT EXISTS " if if_not_exists else "", - ) - ) - self.db.execute(sql) + try: + self.db.execute(sql) + break + except OperationalError as e: + # find_unique_name=True - try again if 'index ... already exists' + arg = e.args[0] + if ( + find_unique_name + and arg.startswith("index ") + and arg.endswith(" already exists") + ): + if suffix is None: + suffix = 2 + else: + suffix += 1 + continue + else: + raise e return self def add_column( diff --git a/tests/test_create.py b/tests/test_create.py index 58dda95..8d5b023 100644 --- a/tests/test_create.py +++ b/tests/test_create.py @@ -4,6 +4,7 @@ from sqlite_utils.db import ( DescIndex, AlterError, NoObviousTable, + OperationalError, ForeignKey, Table, View, @@ -752,6 +753,22 @@ def test_create_index_desc(fresh_db): ) +def test_create_index_find_unique_name(): + db = Database(memory=True) + table = db["t"] + table.insert({"id": 1}) + table.create_index(["id"]) + # Without find_unique_name should error + with pytest.raises(OperationalError, match="index idx_t_id already exists"): + table.create_index(["id"]) + # With find_unique_name=True it should work + table.create_index(["id"], find_unique_name=True) + table.create_index(["id"], find_unique_name=True) + # Should have three now + index_names = {idx.name for idx in table.indexes} + assert index_names == {"idx_t_id", "idx_t_id_2", "idx_t_id_3"} + + @pytest.mark.parametrize( "data_structure", ( From 13195d8747764df3952ed1117e0fd2152f1899e7 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 14 Nov 2021 14:55:42 -0800 Subject: [PATCH 014/563] Test demonstrating fix for #335 --- tests/test_create.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/tests/test_create.py b/tests/test_create.py index 8d5b023..acaeaee 100644 --- a/tests/test_create.py +++ b/tests/test_create.py @@ -475,6 +475,18 @@ def test_index_foreign_keys(fresh_db): assert [["breed_id"]] == [i.columns for i in fresh_db["dogs"].indexes] +def test_index_foreign_keys_if_index_name_is_already_used(fresh_db): + # https://github.com/simonw/sqlite-utils/issues/335 + test_add_foreign_key_guess_table(fresh_db) + # Add index with a name that will conflict with index_foreign_keys() + fresh_db["dogs"].create_index(["name"], index_name="idx_dogs_breed_id") + fresh_db.index_foreign_keys() + assert {(idx.name, tuple(idx.columns)) for idx in fresh_db["dogs"].indexes} == { + ("idx_dogs_breed_id_2", ("breed_id",)), + ("idx_dogs_breed_id", ("name",)), + } + + @pytest.mark.parametrize( "extra_data,expected_new_columns", [ @@ -753,9 +765,8 @@ def test_create_index_desc(fresh_db): ) -def test_create_index_find_unique_name(): - db = Database(memory=True) - table = db["t"] +def test_create_index_find_unique_name(fresh_db): + table = fresh_db["t"] table.insert({"id": 1}) table.create_index(["id"]) # Without find_unique_name should error From 12b8c9de256ba907d4fa8e134bf9ce9bc012302e Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 14 Nov 2021 15:05:00 -0800 Subject: [PATCH 015/563] sqlite-utils memory --flatten, closes #332 --- docs/cli.rst | 2 +- sqlite_utils/cli.py | 4 ++++ tests/test_cli_memory.py | 24 ++++++++++++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/docs/cli.rst b/docs/cli.rst index 7a127fe..a096fde 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -758,7 +758,7 @@ This also means you pipe ``sqlite-utils`` together to easily create a new SQLite Flattening nested JSON objects ------------------------------ -``sqlite-utils insert`` expects incoming data to consist of an array of JSON objects, where the top-level keys of each object will become columns in the created database table. +``sqlite-utils insert`` and ``sqlite-utils memory`` both expect incoming JSON data to consist of an array of JSON objects, where the top-level keys of each object will become columns in the created database table. If your data is nested you can use the ``--flatten`` option to create columns that are derived from the nested data. diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index b29314e..cd0be82 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -1190,6 +1190,7 @@ def query( multiple=True, help="Additional databases to attach - specify alias and filepath", ) +@click.option("--flatten", is_flag=True, help="Flatten nested JSON objects") @output_options @click.option("-r", "--raw", is_flag=True, help="Raw output, first column of first row") @click.option( @@ -1226,6 +1227,7 @@ def memory( paths, sql, attach, + flatten, nl, arrays, csv, @@ -1300,6 +1302,8 @@ def memory( if format_used in (Format.CSV, Format.TSV) and not no_detect_types: tracker = TypeTracker() rows = tracker.wrap(rows) + if flatten: + rows = (dict(_flatten(row)) for row in rows) db[csv_table].insert_all(rows, alter=True) if tracker is not None: db[csv_table].transform(types=tracker.types) diff --git a/tests/test_cli_memory.py b/tests/test_cli_memory.py index a8b3a18..aee74e7 100644 --- a/tests/test_cli_memory.py +++ b/tests/test_cli_memory.py @@ -222,6 +222,30 @@ def test_memory_no_detect_types(option): ] +def test_memory_flatten(): + result = CliRunner().invoke( + cli.cli, + ["memory", "-", "select * from stdin", "--flatten"], + input=json.dumps( + { + "httpRequest": { + "latency": "0.112114537s", + "requestMethod": "GET", + }, + "insertId": "6111722f000b5b4c4d4071e2", + } + ), + ) + assert result.exit_code == 0, result.output + assert json.loads(result.output.strip()) == [ + { + "httpRequest_latency": "0.112114537s", + "httpRequest_requestMethod": "GET", + "insertId": "6111722f000b5b4c4d4071e2", + } + ] + + def test_memory_analyze(): result = CliRunner().invoke( cli.cli, From 73e214a9760c5dc32ed3c5429cb04d4d471ce014 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 14 Nov 2021 15:21:04 -0800 Subject: [PATCH 016/563] py.typed file so mypy picks up the types, closes #331 --- setup.py | 3 ++- sqlite_utils/py.typed | 0 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 sqlite_utils/py.typed diff --git a/setup.py b/setup.py index 091c94a..47c927d 100644 --- a/setup.py +++ b/setup.py @@ -22,6 +22,7 @@ setup( version=VERSION, license="Apache License, Version 2.0", packages=find_packages(exclude=["tests", "tests.*"]), + package_data={"sqlite_utils": ["py.typed"]}, install_requires=[ "sqlite-fts4", "click", @@ -46,7 +47,6 @@ setup( [console_scripts] sqlite-utils=sqlite_utils.cli:cli """, - tests_require=["sqlite-utils[test]"], url="https://github.com/simonw/sqlite-utils", project_urls={ "Documentation": "https://sqlite-utils.datasette.io/en/stable/", @@ -69,4 +69,5 @@ setup( "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", ], + zip_safe=False, # For mypy and py.typed ) diff --git a/sqlite_utils/py.typed b/sqlite_utils/py.typed new file mode 100644 index 0000000..e69de29 From adea5bc3965c80684f219b12299f708f2f422ca1 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 14 Nov 2021 15:24:15 -0800 Subject: [PATCH 017/563] flake8 fix, refs #331 --- setup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 47c927d..ff36a13 100644 --- a/setup.py +++ b/setup.py @@ -69,5 +69,6 @@ setup( "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", ], - zip_safe=False, # For mypy and py.typed + # Needed to bundle py.typed so mypy can see it: + zip_safe=False, ) From bc4c42d68879c710c851dba3c98deda96ca6caa8 Mon Sep 17 00:00:00 2001 From: Denys Pavlov Date: Sun, 14 Nov 2021 18:25:40 -0500 Subject: [PATCH 018/563] Use python-dateutil package instead of dateutils (#324) --- docs/tutorial.ipynb | 6 ++---- setup.py | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/docs/tutorial.ipynb b/docs/tutorial.ipynb index aa22461..179f010 100644 --- a/docs/tutorial.ipynb +++ b/docs/tutorial.ipynb @@ -39,10 +39,8 @@ "Requirement already satisfied: sqlite-fts4 in /usr/local/lib/python3.9/site-packages (from sqlite_utils) (1.0.1)\n", "Requirement already satisfied: click in /Users/simon/Library/Python/3.9/lib/python/site-packages (from sqlite_utils) (7.1.2)\n", "Requirement already satisfied: tabulate in /usr/local/lib/python3.9/site-packages (from sqlite_utils) (0.8.7)\n", - "Requirement already satisfied: dateutils in /usr/local/Cellar/jupyterlab/3.0.16_1/libexec/lib/python3.9/site-packages (from sqlite_utils) (0.6.12)\n", - "Requirement already satisfied: python-dateutil in /usr/local/Cellar/jupyterlab/3.0.16_1/libexec/lib/python3.9/site-packages (from dateutils->sqlite_utils) (2.8.1)\n", - "Requirement already satisfied: pytz in /usr/local/Cellar/jupyterlab/3.0.16_1/libexec/lib/python3.9/site-packages (from dateutils->sqlite_utils) (2021.1)\n", - "Requirement already satisfied: six>=1.5 in /usr/local/Cellar/jupyterlab/3.0.16_1/libexec/lib/python3.9/site-packages (from python-dateutil->dateutils->sqlite_utils) (1.16.0)\n", + "Requirement already satisfied: python-dateutil in /usr/local/Cellar/jupyterlab/3.0.16_1/libexec/lib/python3.9/site-package (from sqlite-utils) (2.8.1)\n", + "Requirement already satisfied: six>=1.5 in /usr/local/Cellar/jupyterlab/3.0.16_1/libexec/lib/python3.9/site-package (from python-dateutil->sqlite-utils) (1.16.0)\n", "\u001b[33mWARNING: You are using pip version 21.1.1; however, version 21.2.2 is available.\n", "You should consider upgrading via the '/usr/local/Cellar/jupyterlab/3.0.16_1/libexec/bin/python3.9 -m pip install --upgrade pip' command.\u001b[0m\n", "Note: you may need to restart the kernel to use updated packages.\n" diff --git a/setup.py b/setup.py index ff36a13..f40dcfe 100644 --- a/setup.py +++ b/setup.py @@ -28,7 +28,7 @@ setup( "click", "click-default-group", "tabulate", - "dateutils", + "python-dateutil", ], setup_requires=["pytest-runner"], extras_require={ From 271b894af52eb6437ae6cd84eba9867ad8dd43f6 Mon Sep 17 00:00:00 2001 From: Mina Rizk Date: Mon, 15 Nov 2021 02:27:40 +0200 Subject: [PATCH 019/563] Map dict to TEXT Thanks, @minaeid90 --- sqlite_utils/db.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index 1e75861..591a286 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -167,6 +167,7 @@ COLUMN_TYPE_MAPPING = { int: "INTEGER", bool: "INTEGER", str: "TEXT", + dict: "TEXT", bytes.__class__: "BLOB", bytes: "BLOB", memoryview: "BLOB", @@ -185,6 +186,7 @@ COLUMN_TYPE_MAPPING = { "integer": "INTEGER", "float": "FLOAT", "blob": "BLOB", + } # If numpy is available, add more types if np: From 84007dffa8fd2fcdf4ff24abe6ee90c01c3d08ae Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 14 Nov 2021 16:28:53 -0800 Subject: [PATCH 020/563] Applied Black, refs #322, #328 --- sqlite_utils/db.py | 1 - 1 file changed, 1 deletion(-) diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index 591a286..9667641 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -186,7 +186,6 @@ COLUMN_TYPE_MAPPING = { "integer": "INTEGER", "float": "FLOAT", "blob": "BLOB", - } # If numpy is available, add more types if np: From 9cda5b070f885a7995f0c307bcc4f45f0812994a Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 14 Nov 2021 16:36:00 -0800 Subject: [PATCH 021/563] Handle dict/tuple/list mapping to TEXT, closes #338 --- sqlite_utils/db.py | 2 ++ tests/test_create.py | 27 +++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index 9667641..828ef68 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -168,6 +168,8 @@ COLUMN_TYPE_MAPPING = { bool: "INTEGER", str: "TEXT", dict: "TEXT", + tuple: "TEXT", + list: "TEXT", bytes.__class__: "BLOB", bytes: "BLOB", memoryview: "BLOB", diff --git a/tests/test_create.py b/tests/test_create.py index acaeaee..c9568d9 100644 --- a/tests/test_create.py +++ b/tests/test_create.py @@ -1040,3 +1040,30 @@ def test_create_with_nested_bytes(fresh_db): ) def test_quote(fresh_db, input, expected): assert fresh_db.quote(input) == expected + + +@pytest.mark.parametrize( + "columns,expected_sql_middle", + ( + ( + {"id": int}, + "[id] INTEGER", + ), + ( + {"col": dict}, + "[col] TEXT", + ), + ( + {"col": tuple}, + "[col] TEXT", + ), + ( + {"col": list}, + "[col] TEXT", + ), + ), +) +def test_create_table_sql(fresh_db, columns, expected_sql_middle): + sql = fresh_db.create_table_sql("t", columns) + middle = sql.split("(")[1].split(")")[0].strip() + assert middle == expected_sql_middle From 54a2269e91ce72b059618662ed133a85f3d42e4a Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 14 Nov 2021 18:01:56 -0800 Subject: [PATCH 022/563] Optional second argument to .lookup() to populate extra columns, closes #339 --- docs/python-api.rst | 10 ++++++++++ sqlite_utils/db.py | 34 +++++++++++++++++++++++----------- tests/test_lookup.py | 20 ++++++++++++++++++++ 3 files changed, 53 insertions(+), 11 deletions(-) diff --git a/docs/python-api.rst b/docs/python-api.rst index 8932656..cc236a2 100644 --- a/docs/python-api.rst +++ b/docs/python-api.rst @@ -824,6 +824,16 @@ If you pass in a dictionary with multiple values, both values will be used to in }) }) +The ``.lookup()`` method has an optional second argument which can be used to populate other columns in the table but only if the row does not exist yet. These columns will not be included in the unique index. + +To create a species record with a note on when it was first seen, you can use this: + +.. code-block:: python + + db["Species"].lookup({"name": "Palm"}, {"first_seen": "2021-03-04"}) + +The first time this is called the record will be created for ``name="Palm"``. Any subsequent calls with that name will ignore the second argument, even if it includes different values. + .. _python_api_extracts: Populating lookup tables automatically during insert/upsert diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index 828ef68..ecf14be 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -2713,7 +2713,11 @@ class Table(Queryable): self.add_column(col_name, col_type) return self - def lookup(self, column_values: Dict[str, Any]): + def lookup( + self, + lookup_values: Dict[str, Any], + extra_values: Optional[Dict[str, Any]] = None, + ): """ Create or populate a lookup table with the specified values. @@ -2725,28 +2729,36 @@ class Table(Queryable): It will then insert a new row with the ``name`` set to ``Palm`` and return the new integer primary key value. + An optional second argument can be provided with more ``name: value`` pairs to + be included only if the record is being created for the first time. These will + be ignored on subsequent lookup calls for records that already exist. + See :ref:`python_api_lookup_tables` for more details. """ - # lookups is a dictionary - all columns will be used for a unique index - assert isinstance(column_values, dict) + assert isinstance(lookup_values, dict) + if extra_values is not None: + assert isinstance(extra_values, dict) + combined_values = dict(lookup_values) + if extra_values is not None: + combined_values.update(extra_values) if self.exists(): - self.add_missing_columns([column_values]) + self.add_missing_columns([combined_values]) unique_column_sets = [set(i.columns) for i in self.indexes] - if set(column_values.keys()) not in unique_column_sets: - self.create_index(column_values.keys(), unique=True) - wheres = ["[{}] = ?".format(column) for column in column_values] + if set(lookup_values.keys()) not in unique_column_sets: + self.create_index(lookup_values.keys(), unique=True) + wheres = ["[{}] = ?".format(column) for column in lookup_values] rows = list( self.rows_where( - " and ".join(wheres), [value for _, value in column_values.items()] + " and ".join(wheres), [value for _, value in lookup_values.items()] ) ) try: return rows[0]["id"] except IndexError: - return self.insert(column_values, pk="id").last_pk + return self.insert(combined_values, pk="id").last_pk else: - pk = self.insert(column_values, pk="id").last_pk - self.create_index(column_values.keys(), unique=True) + pk = self.insert(combined_values, pk="id").last_pk + self.create_index(lookup_values.keys(), unique=True) return pk def m2m( diff --git a/tests/test_lookup.py b/tests/test_lookup.py index ac8e1c2..348ef92 100644 --- a/tests/test_lookup.py +++ b/tests/test_lookup.py @@ -66,3 +66,23 @@ def test_lookup_fails_if_constraint_cannot_be_added(fresh_db): # This will fail because the name column is not unique with pytest.raises(Exception, match="UNIQUE constraint failed"): species.lookup({"name": "Palm"}) + + +def test_lookup_with_extra_values(fresh_db): + species = fresh_db["species"] + id = species.lookup({"name": "Palm", "type": "Tree"}, {"first_seen": "2020-01-01"}) + assert species.get(id) == { + "id": 1, + "name": "Palm", + "type": "Tree", + "first_seen": "2020-01-01", + } + # A subsequent lookup() should ignore the second dictionary + id2 = species.lookup({"name": "Palm", "type": "Tree"}, {"first_seen": "2021-02-02"}) + assert id2 == id + assert species.get(id2) == { + "id": 1, + "name": "Palm", + "type": "Tree", + "first_seen": "2020-01-01", + } From fb9d61754a9088a4efafce490db01e2999dea2d2 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 14 Nov 2021 18:19:28 -0800 Subject: [PATCH 023/563] Better type signature for hash_id, closes #341 --- sqlite_utils/db.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index ecf14be..039f9da 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -639,7 +639,7 @@ class Database: column_order: Optional[List[str]] = None, not_null: Iterable[str] = None, defaults: Optional[Dict[str, Any]] = None, - hash_id: Optional[Any] = None, + hash_id: Optional[str] = None, extracts: Optional[Union[Dict[str, str], List[str]]] = None, ) -> str: "Returns the SQL ``CREATE TABLE`` statement for creating the specified table." @@ -748,7 +748,7 @@ class Database: column_order: Optional[List[str]] = None, not_null: Iterable[str] = None, defaults: Optional[Dict[str, Any]] = None, - hash_id: Optional[Any] = None, + hash_id: Optional[str] = None, extracts: Optional[Union[Dict[str, str], List[str]]] = None, ) -> "Table": """ @@ -1046,7 +1046,7 @@ class Table(Queryable): not_null: Iterable[str] = None, defaults: Optional[Dict[str, Any]] = None, batch_size: int = 100, - hash_id: Optional[Any] = None, + hash_id: Optional[str] = None, alter: bool = False, ignore: bool = False, replace: bool = False, @@ -1227,7 +1227,7 @@ class Table(Queryable): column_order: Optional[List[str]] = None, not_null: Iterable[str] = None, defaults: Optional[Dict[str, Any]] = None, - hash_id: Optional[Any] = None, + hash_id: Optional[str] = None, extracts: Optional[Union[Dict[str, str], List[str]]] = None, ) -> "Table": """ From ffb54427d3c5944ea4ed83d138d3917309cc5242 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 14 Nov 2021 18:56:35 -0800 Subject: [PATCH 024/563] insert now replaces square braces in column name with underscore, closes #341 --- sqlite_utils/db.py | 14 +++++++++++++- tests/test_create.py | 8 +------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index 039f9da..fd58c40 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -2571,6 +2571,8 @@ class Table(Queryable): all_columns = [] first = True num_records_processed = 0 + # Fix up any records with square braces in the column names + records = fix_square_braces(records) # We can only handle a max of 999 variables in a SQL insert, so # we need to adjust the batch_size down if we have too many cols records = iter(records) @@ -2618,7 +2620,6 @@ class Table(Queryable): column for column in record if column not in all_columns ] - validate_column_names(all_columns) first = False self.insert_chunk( @@ -2986,3 +2987,14 @@ def validate_column_names(columns): assert ( "[" not in column and "]" not in column ), "'[' and ']' cannot be used in column names" + + +def fix_square_braces(records: Iterable[Dict[str, Any]]): + for record in records: + if any("[" in key or "]" in key for key in record.keys()): + yield { + key.replace("[", "_").replace("]", "_"): value + for key, value in record.items() + } + else: + yield record diff --git a/tests/test_create.py b/tests/test_create.py index c9568d9..187d20a 100644 --- a/tests/test_create.py +++ b/tests/test_create.py @@ -144,6 +144,7 @@ def test_create_table_with_not_null(fresh_db): [{"name": "memoryview", "type": "BLOB"}], ), ({"uuid": uuid.uuid4()}, [{"name": "uuid", "type": "TEXT"}]), + ({"foo[bar]": 1}, [{"name": "foo_bar_", "type": "INTEGER"}]), ), ) def test_create_table_from_example(fresh_db, example, expected_columns): @@ -525,13 +526,6 @@ def test_insert_row_alter_table( ] -def test_insert_row_alter_table_invalid_column_characters(fresh_db): - table = fresh_db["table"] - table.insert({"foo": "bar"}).last_pk - with pytest.raises(AssertionError): - table.insert({"foo": "baz", "new_col[abc]": 1.2}, alter=True) - - def test_add_missing_columns_case_insensitive(fresh_db): table = fresh_db["foo"] table.insert({"id": 1, "name": "Cleo"}, pk="id") From 3b8abe608796e99e4ffc5f3f4597a85e605c0e9b Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 14 Nov 2021 19:15:23 -0800 Subject: [PATCH 025/563] Release 3.18 Refs #324, #329, #330, #331, #332, #335, #338, #339 --- docs/changelog.rst | 14 ++++++++++++++ setup.py | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 0eadaa1..0c8f901 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -2,6 +2,20 @@ Changelog =========== +.. _v3_18: + +3.18 (2021-11-14) +----------------- + +- The ``table.lookup()`` method now has an optional second argument which can be used to populate columns only the first time the record is created, see :ref:`python_api_lookup_tables`. (:issue:`339`) +- ``sqlite-utils memory`` now has a ``--flatten`` option for :ref:`flattening nested JSON objects ` into separate columns, consistent with ``sqlite-utils insert``. (:issue:`332`) +- ``table.create_index(..., find_unique_name=True)`` parameter, which finds an available name for the created index even if the default name has already been taken. This means that ``index-foreign-keys`` will work even if one of the indexes it tries to create clashes with an existing index name. (:issue:`335`) +- Added ``py.typed`` to the module, so `mypy `__ should now correctly pick up the type annotations. Thanks, Andreas Longo. (:issue:`331`) +- Now depends on ``python-dateutil`` instead of depending on ``dateutils``. Thanks, Denys Pavlov. (:issue:`324`) +- ``table.create()`` (see :ref:`python_api_explicit_create`) now handles ``dict``, ``list`` and ``tuple`` types, mapping them to ``TEXT`` columns in SQLite so that they can be stored encoded as JSON. (:issue:`338`) +- Inserted data with square braces in the column names (for example a CSV file containing a ``item[price]``) column now have the braces converted to underscores: ``item_price_``. Previously such columns would be rejected with an error. (:issue:`329`) +- Now also tested against Python 3.10. (`#330 `__) + .. _v3_17.1: 3.17.1 (2021-09-22) diff --git a/setup.py b/setup.py index f40dcfe..2147697 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages import io import os -VERSION = "3.17.1" +VERSION = "3.18" def get_long_description(): From 93b21c230a6ae33e5a4904f042fa513796689bce Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 18 Nov 2021 23:26:50 -0800 Subject: [PATCH 026/563] Extra parameters for .lookup(), passed to .insert() - closes #342 --- docs/python-api.rst | 11 ++++++++ sqlite_utils/db.py | 38 +++++++++++++++++++++++--- tests/test_lookup.py | 65 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 4 deletions(-) diff --git a/docs/python-api.rst b/docs/python-api.rst index cc236a2..1c0dc60 100644 --- a/docs/python-api.rst +++ b/docs/python-api.rst @@ -834,6 +834,17 @@ To create a species record with a note on when it was first seen, you can use th The first time this is called the record will be created for ``name="Palm"``. Any subsequent calls with that name will ignore the second argument, even if it includes different values. +``.lookup()`` also accepts keyword arguments, which are passed through to the :ref:`insert() method ` and can be used to influence the shape of the created table. Supported parameters are: + +- ``pk`` - which defaults to ``id`` +- ``foreign_keys`` +- ``column_order`` +- ``not_null`` +- ``defaults`` +- ``extracts`` +- ``conversions`` +- ``columns`` + .. _python_api_extracts: Populating lookup tables automatically during insert/upsert diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index fd58c40..aebca89 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -2556,7 +2556,7 @@ class Table(Queryable): ignore = self.value_or_default("ignore", ignore) replace = self.value_or_default("replace", replace) extracts = self.value_or_default("extracts", extracts) - conversions = self.value_or_default("conversions", conversions) + conversions = self.value_or_default("conversions", conversions) or {} columns = self.value_or_default("columns", columns) if upsert and (not pk and not hash_id): @@ -2718,6 +2718,14 @@ class Table(Queryable): self, lookup_values: Dict[str, Any], extra_values: Optional[Dict[str, Any]] = None, + pk: Optional[str] = "id", + foreign_keys: Optional[ForeignKeysType] = None, + column_order: Optional[List[str]] = None, + not_null: Optional[Set[str]] = None, + defaults: Optional[Dict[str, Any]] = None, + extracts: Optional[Union[Dict[str, str], List[str]]] = None, + conversions: Optional[Dict[str, str]] = None, + columns: Optional[Dict[str, Any]] = None, ): """ Create or populate a lookup table with the specified values. @@ -2735,6 +2743,8 @@ class Table(Queryable): be ignored on subsequent lookup calls for records that already exist. See :ref:`python_api_lookup_tables` for more details. + + All other keyword arguments are passed through to ``.insert()``. """ assert isinstance(lookup_values, dict) if extra_values is not None: @@ -2754,11 +2764,31 @@ class Table(Queryable): ) ) try: - return rows[0]["id"] + return rows[0][pk] except IndexError: - return self.insert(combined_values, pk="id").last_pk + return self.insert( + combined_values, + pk=pk, + foreign_keys=foreign_keys, + column_order=column_order, + not_null=not_null, + defaults=defaults, + extracts=extracts, + conversions=conversions, + columns=columns, + ).last_pk else: - pk = self.insert(combined_values, pk="id").last_pk + pk = self.insert( + combined_values, + pk=pk, + foreign_keys=foreign_keys, + column_order=column_order, + not_null=not_null, + defaults=defaults, + extracts=extracts, + conversions=conversions, + columns=columns, + ).last_pk self.create_index(lookup_values.keys(), unique=True) return pk diff --git a/tests/test_lookup.py b/tests/test_lookup.py index 348ef92..31be414 100644 --- a/tests/test_lookup.py +++ b/tests/test_lookup.py @@ -86,3 +86,68 @@ def test_lookup_with_extra_values(fresh_db): "type": "Tree", "first_seen": "2020-01-01", } + + +def test_lookup_with_extra_insert_parameters(fresh_db): + other_table = fresh_db["other_table"] + other_table.insert({"id": 1, "name": "Name"}, pk="id") + species = fresh_db["species"] + id = species.lookup( + {"name": "Palm", "type": "Tree"}, + { + "first_seen": "2020-01-01", + "make_not_null": 1, + "fk_to_other": 1, + "default_is_dog": "cat", + "extract_this": "This is extracted", + "convert_to_upper": "upper", + "make_this_integer": "2", + "this_at_front": 1, + }, + pk="renamed_id", + foreign_keys=(("fk_to_other", "other_table", "id"),), + column_order=("this_at_front",), + not_null={"make_not_null"}, + defaults={"default_is_dog": "dog"}, + extracts=["extract_this"], + conversions={"convert_to_upper": "upper(?)"}, + columns={"make_this_integer": int}, + ) + assert species.schema == ( + "CREATE TABLE [species] (\n" + " [renamed_id] INTEGER PRIMARY KEY,\n" + " [this_at_front] INTEGER,\n" + " [name] TEXT,\n" + " [type] TEXT,\n" + " [first_seen] TEXT,\n" + " [make_not_null] INTEGER NOT NULL,\n" + " [fk_to_other] INTEGER REFERENCES [other_table]([id]),\n" + " [default_is_dog] TEXT DEFAULT 'dog',\n" + " [extract_this] INTEGER REFERENCES [extract_this]([id]),\n" + " [convert_to_upper] TEXT,\n" + " [make_this_integer] INTEGER\n" + ")" + ) + assert species.get(id) == { + "renamed_id": id, + "this_at_front": 1, + "name": "Palm", + "type": "Tree", + "first_seen": "2020-01-01", + "make_not_null": 1, + "fk_to_other": 1, + "default_is_dog": "cat", + "extract_this": 1, + "convert_to_upper": "UPPER", + "make_this_integer": 2, + } + assert species.indexes == [ + Index( + seq=0, + name="idx_species_name_type", + unique=1, + origin="c", + partial=0, + columns=["name", "type"], + ) + ] From 8f386a0d300d1b1c76132bb75972b755049fb742 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 18 Nov 2021 23:27:41 -0800 Subject: [PATCH 027/563] Release 3.19a0 Refs #342 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 2147697..820c871 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages import io import os -VERSION = "3.18" +VERSION = "3.19a0" def get_long_description(): From 33176ad47b9757f40ea016e7b8ec328229e60a74 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 19 Nov 2021 00:09:16 -0800 Subject: [PATCH 028/563] Run pytest with colors Tip from https://twitter.com/cjolowicz/status/1461266663681187841 --- .github/workflows/test.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 86b2531..fec8cfa 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,6 +2,9 @@ name: Test on: [push] +env: + FORCE_COLOR: 1 + jobs: test: runs-on: ${{ matrix.os }} @@ -31,7 +34,7 @@ jobs: run: pip install numpy - name: Run tests run: | - pytest + pytest -v - name: run mypy run: mypy sqlite_utils tests - name: run flake8 From 126703706ea153f63e6134ad14e5712e4bbcb8ae Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 20 Nov 2021 20:40:47 -0800 Subject: [PATCH 029/563] Release 3.19 Refs #342 --- docs/changelog.rst | 7 +++++++ setup.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 0c8f901..a30e7cf 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -2,6 +2,13 @@ Changelog =========== +.. _v3_19: + +3.19 (2021-11-20) +----------------- + +- The :ref:`table.lookup() method ` now accepts keyword arguments that match those on the underlying ``table.insert()`` method: ``foreign_keys=``, ``column_order=``, ``not_null=``, ``defaults=``, ``extracts=``, ``conversions=`` and ``columns=``. You can also now pass ``pk=`` to specify a different column name to use for the primary key. (:issue:`342`) + .. _v3_18: 3.18 (2021-11-14) diff --git a/setup.py b/setup.py index 820c871..7198027 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages import io import os -VERSION = "3.19a0" +VERSION = "3.19" def get_long_description(): From e3f108e0f339e3d87ce48541bbca8f891bfaf040 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 29 Nov 2021 14:19:30 -0800 Subject: [PATCH 030/563] db.supports_strict and table.strict properties, refs #344 --- docs/python-api.rst | 16 ++++++++++++++-- sqlite_utils/db.py | 21 +++++++++++++++++++++ tests/test_introspect.py | 38 ++++++++++++++++++++++++++++---------- 3 files changed, 63 insertions(+), 12 deletions(-) diff --git a/docs/python-api.rst b/docs/python-api.rst index 1c0dc60..5d391ee 100644 --- a/docs/python-api.rst +++ b/docs/python-api.rst @@ -1609,8 +1609,8 @@ A more useful example: if you are working with `SpatiaLite `__. + +:: + + >>> db["ny_times_us_counties"].strict + False + .. _python_api_introspection_indexes: .indexes diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index aebca89..fea1051 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -18,6 +18,7 @@ import json import os import pathlib import re +import secrets from sqlite_fts4 import rank_bm25 # type: ignore import sys import textwrap @@ -521,6 +522,19 @@ class Database: sqls.append(sql) return "\n".join(sqls) + @property + def supports_strict(self): + try: + table_name = "t{}".format(secrets.token_hex(16)) + with self.conn: + self.conn.execute( + "create table {} (name text) strict".format(table_name) + ) + self.conn.execute("drop table {}".format(table_name)) + return True + except Exception as e: + return False + @property def journal_mode(self) -> str: "Current ``journal_mode`` of this database." @@ -1219,6 +1233,13 @@ class Table(Queryable): "``{trigger_name: sql}`` dictionary of triggers defined on this table." return {trigger.name: trigger.sql for trigger in self.triggers} + @property + def strict(self) -> bool: + "Is this a STRICT table?" + table_suffix = self.schema.split(")")[-1].strip().upper() + table_options = [bit.strip() for bit in table_suffix.split(",")] + return "STRICT" in table_options + def create( self, columns: Dict[str, Any], diff --git a/tests/test_introspect.py b/tests/test_introspect.py index dce8afc..28a5009 100644 --- a/tests/test_introspect.py +++ b/tests/test_introspect.py @@ -252,15 +252,33 @@ def test_has_counts_triggers(fresh_db): ), ], ) -def test_virtual_table_using(sql, expected_name, expected_using): - db = Database(memory=True) - db.execute(sql) - assert db[expected_name].virtual_table_using == expected_using +def test_virtual_table_using(fresh_db, sql, expected_name, expected_using): + fresh_db.execute(sql) + assert fresh_db[expected_name].virtual_table_using == expected_using -def test_use_rowid(): - db = Database(memory=True) - db["rowid_table"].insert({"name": "Cleo"}) - db["regular_table"].insert({"id": 1, "name": "Cleo"}, pk="id") - assert db["rowid_table"].use_rowid - assert not db["regular_table"].use_rowid +def test_use_rowid(fresh_db): + fresh_db["rowid_table"].insert({"name": "Cleo"}) + fresh_db["regular_table"].insert({"id": 1, "name": "Cleo"}, pk="id") + assert fresh_db["rowid_table"].use_rowid + assert not fresh_db["regular_table"].use_rowid + + +@pytest.mark.skipif( + not Database(memory=True).supports_strict, + reason="Needs SQLite version that supports strict", +) +@pytest.mark.parametrize( + "create_table,expected_strict", + ( + ("create table t (id integer) strict", True), + ("create table t (id integer) STRICT", True), + ("create table t (id integer primary key) StriCt, WITHOUT ROWID", True), + ("create table t (id integer primary key) WITHOUT ROWID", False), + ("create table t (id integer)", False), + ), +) +def test_table_strict(fresh_db, create_table, expected_strict): + fresh_db.execute(create_table) + table = fresh_db["t"] + assert table.strict == expected_strict From 1f8178f7e41f64965195c1320d310032d783a8b1 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 29 Nov 2021 14:29:46 -0800 Subject: [PATCH 031/563] Fix flake8 error, refs #344, #345 --- sqlite_utils/db.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index fea1051..a2c40e1 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -532,7 +532,7 @@ class Database: ) self.conn.execute("drop table {}".format(table_name)) return True - except Exception as e: + except: return False @property From 213a0ff177f23a35f3b235386366ff132eb879f1 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 29 Nov 2021 14:34:40 -0800 Subject: [PATCH 032/563] Really fix flake8 error, refs #344, #345 --- sqlite_utils/db.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index a2c40e1..0ff056c 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -532,7 +532,7 @@ class Database: ) self.conn.execute("drop table {}".format(table_name)) return True - except: + except Exception: return False @property From e328db8eba1fbf29a69eda95dfec861954f9e771 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 10 Dec 2021 13:12:38 -0800 Subject: [PATCH 033/563] Improved schema example for sqlite-utils extract --- docs/cli.rst | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/cli.rst b/docs/cli.rst index a096fde..1e3b330 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -1288,12 +1288,13 @@ This would produce the following schema: [TreeAddress] TEXT, [species_id] INTEGER, FOREIGN KEY(species_id) REFERENCES species(id) - ) - + ); CREATE TABLE [species] ( [id] INTEGER PRIMARY KEY, [species] TEXT - ) + ); + CREATE UNIQUE INDEX [idx_species_species] + ON [species] ([species]); The command takes the following options: From a3df483c803ea6e45cf878025aa8a59d2c62f67e Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 10 Dec 2021 16:01:02 -0800 Subject: [PATCH 034/563] sqlite-utils convert db table column -, refs #353 --- docs/cli.rst | 10 ++++++++++ sqlite_utils/cli.py | 5 +++++ tests/test_cli_convert.py | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/docs/cli.rst b/docs/cli.rst index 1e3b330..1185c1a 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -1023,6 +1023,16 @@ You can specify Python modules that should be imported and made available to you '"\n".join(textwrap.wrap(value, 100))' \ --import=textwrap +Use a CODE value of `-` to read from standard input: + + $ cat mycode.py | sqlite-utils convert content.db articles headline - + +Where `mycode.py` contains a fragment of Python code that looks like this: + +```python +return value.upper() +``` + The transformation will be applied to every row in the specified table. You can limit that to just rows that match a ``WHERE`` clause using ``--where``:: $ sqlite-utils convert content.db articles headline 'value.upper()' \ diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index cd0be82..0345d55 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -2038,6 +2038,8 @@ def _generate_convert_help(): "value" is a variable with the column value to be converted. + Use "-" for CODE to read Python code from standard input. + The following common operations are available as recipe functions: """ ).strip() @@ -2120,6 +2122,9 @@ def convert( raise click.ClickException("Cannot use --multi with more than one column") if drop and not (output or multi): raise click.ClickException("--drop can only be used with --output or --multi") + if code == "-": + # Read code from standard input + code = sys.stdin.read() # If single line and no 'return', add the return if "\n" not in code and not code.strip().startswith("return "): code = "return {}".format(code) diff --git a/tests/test_cli_convert.py b/tests/test_cli_convert.py index 8ce5203..2c77c0c 100644 --- a/tests/test_cli_convert.py +++ b/tests/test_cli_convert.py @@ -515,3 +515,36 @@ def test_convert_where_multi(fresh_db_and_path): {"id": 1, "name": "Cleo", "upper": None}, {"id": 2, "name": "Bants", "upper": "BANTS"}, ] + + +def test_convert_code_standard_input(fresh_db_and_path): + db, db_path = fresh_db_and_path + db["names"].insert_all([{"id": 1, "name": "Cleo"}], pk="id") + result = CliRunner().invoke( + cli.cli, + [ + "convert", + db_path, + "names", + "name", + "-", + ], + input="value.upper()", + ) + assert 0 == result.exit_code, result.output + assert list(db["names"].rows) == [ + {"id": 1, "name": "CLEO"}, + ] + + +def test_convert_hyphen_workaround(fresh_db_and_path): + db, db_path = fresh_db_and_path + db["names"].insert_all([{"id": 1, "name": "Cleo"}], pk="id") + result = CliRunner().invoke( + cli.cli, + ["convert", db_path, "names", "name", '"-"'], + ) + assert 0 == result.exit_code, result.output + assert list(db["names"].rows) == [ + {"id": 1, "name": "-"}, + ] From 7a43af232e4bc00bd227307665163614e225948b Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 10 Dec 2021 16:11:22 -0800 Subject: [PATCH 035/563] Support nested imports, closes #351 --- docs/cli.rst | 6 ++++++ sqlite_utils/cli.py | 2 +- tests/test_cli_convert.py | 21 +++++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/docs/cli.rst b/docs/cli.rst index 1185c1a..6a39c1e 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -1023,6 +1023,12 @@ You can specify Python modules that should be imported and made available to you '"\n".join(textwrap.wrap(value, 100))' \ --import=textwrap +This supports nested imports as well, for example to use `ElementTree `__:: + + $ sqlite-utils convert content.db articles content \ + 'xml.etree.ElementTree.fromstring(value).attrib["title"]' \ + --import=xml.etree.ElementTree + Use a CODE value of `-` to read from standard input: $ cat mycode.py | sqlite-utils convert content.db articles headline - diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 0345d55..c19339d 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -2137,7 +2137,7 @@ def convert( locals = {} globals = {"r": recipes, "recipes": recipes} for import_ in imports: - globals[import_] = __import__(import_) + globals[import_.split(".")[0]] = __import__(import_) exec(code_o, globals, locals) fn = locals["fn"] if dry_run: diff --git a/tests/test_cli_convert.py b/tests/test_cli_convert.py index 2c77c0c..8755600 100644 --- a/tests/test_cli_convert.py +++ b/tests/test_cli_convert.py @@ -93,6 +93,27 @@ def test_convert_import(test_db_and_path): ] == list(db["example"].rows) +def test_convert_import_nested(fresh_db_and_path): + db, db_path = fresh_db_and_path + db["example"].insert({"xml": ''}) + result = CliRunner().invoke( + cli.cli, + [ + "convert", + db_path, + "example", + "xml", + 'xml.etree.ElementTree.fromstring(value).attrib["name"]', + "--import", + "xml.etree.ElementTree", + ], + ) + assert 0 == result.exit_code, result.output + assert [ + {"xml": "Cleo"}, + ] == list(db["example"].rows) + + def test_convert_dryrun(test_db_and_path): db, db_path = test_db_and_path result = CliRunner().invoke( From 500a35ad4d91c8a6232134ce9406efec11bedff8 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 10 Dec 2021 16:49:28 -0800 Subject: [PATCH 036/563] Also support def convert(value), closes #355 Plus added custom syntax error display --- docs/cli.rst | 19 +++++++---- sqlite_utils/cli.py | 22 +++++-------- sqlite_utils/utils.py | 28 ++++++++++++++++ tests/test_cli_convert.py | 69 +++++++++++++++++++++++---------------- 4 files changed, 91 insertions(+), 47 deletions(-) diff --git a/docs/cli.rst b/docs/cli.rst index 6a39c1e..aa11286 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -1029,17 +1029,24 @@ This supports nested imports as well, for example to use `ElementTree ", "exec") - locals = {} - globals = {"r": recipes, "recipes": recipes} - for import_ in imports: - globals[import_.split(".")[0]] = __import__(import_) - exec(code_o, globals, locals) - fn = locals["fn"] + try: + fn = _compile_code(code, imports) + except SyntaxError as e: + raise click.ClickException( + "Syntax error in code:\n\n{}\n\n{}".format( + textwrap.indent(e.text.strip(), " "), e.msg + ) + ) if dry_run: # Pull first 20 values for first column and preview them db.conn.create_function("preview_transform", 1, lambda v: fn(v) if v else v) diff --git a/sqlite_utils/utils.py b/sqlite_utils/utils.py index 00a3c02..fe71b73 100644 --- a/sqlite_utils/utils.py +++ b/sqlite_utils/utils.py @@ -5,6 +5,7 @@ import enum import io import json import os +from . import recipes from typing import cast, BinaryIO, Iterable, Optional, Tuple, Type import click @@ -278,3 +279,30 @@ def progressbar(*args, **kwargs): else: with click.progressbar(*args, **kwargs) as bar: yield bar + + +def _compile_code(code, imports): + locals = {} + globals = {"r": recipes, "recipes": recipes} + # If user defined a convert() function, return that + try: + exec(code, globals, locals) + return locals["convert"] + except (SyntaxError, NameError, KeyError, TypeError): + pass + + # Try compiling their code as a function instead + + # If single line and no 'return', add the return + if "\n" not in code and not code.strip().startswith("return "): + code = "return {}".format(code) + + new_code = ["def fn(value):"] + for line in code.split("\n"): + new_code.append(" {}".format(line)) + code_o = compile("\n".join(new_code), "", "exec") + + for import_ in imports: + globals[import_.split(".")[0]] = __import__(import_) + exec(code_o, globals, locals) + return locals["fn"] diff --git a/tests/test_cli_convert.py b/tests/test_cli_convert.py index 8755600..481bebd 100644 --- a/tests/test_cli_convert.py +++ b/tests/test_cli_convert.py @@ -35,39 +35,52 @@ def fresh_db_and_path(tmpdir): "return value.replace('October', 'Spooktober')", # Return is optional: "value.replace('October', 'Spooktober')", + # Multiple lines are supported: + "v = value.replace('October', 'Spooktober')\nreturn v", + # Can also define a convert() function + "def convert(value): return value.replace('October', 'Spooktober')", + # ... with imports + "import re\n\ndef convert(value): return value.replace('October', 'Spooktober')", ], ) -def test_convert_single_line(test_db_and_path, code): - db, db_path = test_db_and_path - result = CliRunner().invoke(cli.cli, ["convert", db_path, "example", "dt", code]) - assert 0 == result.exit_code, result.output - assert [ - {"id": 1, "dt": "5th Spooktober 2019 12:04"}, - {"id": 2, "dt": "6th Spooktober 2019 00:05:06"}, - {"id": 3, "dt": ""}, - {"id": 4, "dt": None}, - ] == list(db["example"].rows) - - -def test_convert_multiple_lines(test_db_and_path): - db, db_path = test_db_and_path +def test_convert_code(fresh_db_and_path, code): + db, db_path = fresh_db_and_path + db["t"].insert({"text": "October"}) result = CliRunner().invoke( - cli.cli, - [ - "convert", - db_path, - "example", - "dt", - "v = value.replace('October', 'Spooktober')\nreturn v.upper()", - ], + cli.cli, ["convert", db_path, "t", "text", code], catch_exceptions=False ) assert 0 == result.exit_code, result.output - assert [ - {"id": 1, "dt": "5TH SPOOKTOBER 2019 12:04"}, - {"id": 2, "dt": "6TH SPOOKTOBER 2019 00:05:06"}, - {"id": 3, "dt": ""}, - {"id": 4, "dt": None}, - ] == list(db["example"].rows) + value = list(db["t"].rows)[0]["text"] + assert value == "Spooktober" + + +@pytest.mark.parametrize( + "bad_code,expected_error", + [ + ( + "def foo(value)", + """Error: Syntax error in code: + + return def foo(value) + +invalid syntax""", + ), + ( + "$", + """Error: Syntax error in code: + + return $ + +invalid syntax""", + ), + ], +) +def test_convert_code_errors(fresh_db_and_path, bad_code, expected_error): + db, db_path = fresh_db_and_path + db["t"].insert({"text": "October"}) + result = CliRunner().invoke(cli.cli, ["convert", db_path, "t", "text", bad_code]) + assert 1 == result.exit_code + assert result.output.strip() == expected_error.strip() def test_convert_import(test_db_and_path): From 3b2a7c0e5bfc05b18eddb40dabb71dee9a333a15 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 10 Dec 2021 16:56:50 -0800 Subject: [PATCH 037/563] Refactor test for #149, spitting it from other rebuild test Also refs #354 --- tests/test_cli.py | 30 ++++-------------------------- 1 file changed, 4 insertions(+), 26 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 47920c6..26ef004 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -556,8 +556,7 @@ def test_optimize(db_path, tables): assert 0 == result.exit_code -@pytest.mark.parametrize("tables", ([], ["fts4_table"], ["fts5_table"])) -def test_rebuild_fts(db_path, tables): +def test_rebuild_fts_fixes_docsize_error(db_path): db = Database(db_path, recursive_triggers=False) records = [ { @@ -568,43 +567,22 @@ def test_rebuild_fts(db_path, tables): for i in range(10000) ] with db.conn: - db["fts4_table"].insert_all(records, pk="c1") db["fts5_table"].insert_all(records, pk="c1") - db["fts4_table"].enable_fts( - ["c1", "c2", "c3"], fts_version="FTS4", create_triggers=True - ) db["fts5_table"].enable_fts( ["c1", "c2", "c3"], fts_version="FTS5", create_triggers=True ) # Search should work - assert list(db["fts4_table"].search("verb1")) assert list(db["fts5_table"].search("verb1")) - # Deleting _fts_segments to break FTS4 - with db.conn: - db["fts4_table_fts_segments"].delete_where() - # Now this should error: - with pytest.raises(sqlite3.DatabaseError): - list(db["fts4_table"].search("verb1")) # Replicate docsize error from this issue for FTS5 # https://github.com/simonw/sqlite-utils/issues/149 assert db["fts5_table_fts_docsize"].count == 10000 db["fts5_table"].insert_all(records, replace=True) assert db["fts5_table"].count == 10000 assert db["fts5_table_fts_docsize"].count == 20000 - # Running rebuild-fts should fix both issues - print(["rebuild-fts", db_path] + tables) - result = CliRunner().invoke(cli.cli, ["rebuild-fts", db_path] + tables) + # Running rebuild-fts should fix this + result = CliRunner().invoke(cli.cli, ["rebuild-fts", db_path, "fts5_table"]) assert 0 == result.exit_code - fixed_tables = tables or ["fts4_table", "fts5_table"] - if "fts4_table" in fixed_tables: - assert list(db["fts4_table"].search("verb1")) - else: - with pytest.raises(sqlite3.DatabaseError): - list(db["fts4_table"].search("verb1")) - if "fts5_table" in fixed_tables: - assert db["fts5_table_fts_docsize"].count == 10000 - else: - assert db["fts5_table_fts_docsize"].count == 20000 + assert db["fts5_table_fts_docsize"].count == 10000 def test_insert_simple(tmpdir): From ee13f98c2c7ca3b819bd0fc55da3108cb6a6434a Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 10 Dec 2021 16:59:37 -0800 Subject: [PATCH 038/563] Better test for rebuild, refs #354 --- tests/test_fts.py | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/tests/test_fts.py b/tests/test_fts.py index 6aa6fbe..0ae2a52 100644 --- a/tests/test_fts.py +++ b/tests/test_fts.py @@ -254,13 +254,12 @@ def test_disable_fts(fresh_db, create_triggers): assert ["searchable"] == fresh_db.table_names() -@pytest.mark.parametrize("table_to_fix", ["searchable", "searchable_fts"]) -def test_rebuild_fts(fresh_db, table_to_fix): +def test_rebuild_fts(fresh_db): table = fresh_db["searchable"] table.insert(search_records[0]) table.enable_fts(["text", "country"]) # Run a search - rows = list(table.search("tanuki")) + rows = list(table.search("are")) assert len(rows) == 1 assert { "rowid": 1, @@ -268,21 +267,14 @@ def test_rebuild_fts(fresh_db, table_to_fix): "country": "Japan", "not_searchable": "foo", }.items() <= rows[0].items() - # Delete from searchable_fts_data - fresh_db["searchable_fts_data"].delete_where() - # This should have broken the index - with pytest.raises(sqlite3.DatabaseError): - list(table.search("tanuki")) + # Insert another record + table.insert(search_records[1]) + # This should NOT show up in a searchs + assert len(list(table.search("are"))) == 1 # Running rebuild_fts() should fix it - fresh_db[table_to_fix].rebuild_fts() - rows2 = list(table.search("tanuki")) - assert len(rows2) == 1 - assert { - "rowid": 1, - "text": "tanuki are running tricksters", - "country": "Japan", - "not_searchable": "foo", - }.items() <= rows2[0].items() + table.rebuild_fts() + rows2 = list(table.search("are")) + assert len(rows2) == 2 @pytest.mark.parametrize("invalid_table", ["does_not_exist", "not_searchable"]) From f3fd8613113d21d44238a6ec54b375f5aa72c4e0 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 16 Dec 2021 12:43:12 -0800 Subject: [PATCH 039/563] Removed unneccessary pytest-runner, closes #357 --- setup.py | 1 - 1 file changed, 1 deletion(-) diff --git a/setup.py b/setup.py index 7198027..3cc7b0e 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,6 @@ setup( "tabulate", "python-dateutil", ], - setup_requires=["pytest-runner"], extras_require={ "test": ["pytest", "black", "hypothesis"], "docs": ["sphinx_rtd_theme", "sphinx-autobuild", "codespell"], From 9e286cc6d2edc14ee7f7263450b11cfdc8f72157 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 5 Jan 2022 17:32:40 -0800 Subject: [PATCH 040/563] New help for --lines and --all and --convert and --import, refs #356 --- docs/cli.rst | 2 +- sqlite_utils/cli.py | 98 ++++++++++++++++++++++++++++++++++++--------- 2 files changed, 81 insertions(+), 19 deletions(-) diff --git a/docs/cli.rst b/docs/cli.rst index aa11286..6482c46 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -1029,7 +1029,7 @@ This supports nested imports as well, for example to use `ElementTree Date: Wed, 5 Jan 2022 17:57:03 -0800 Subject: [PATCH 041/563] Refactored sqlite-utils insert tests into test_cli_insert.py --- tests/conftest.py | 14 ++ tests/test_cli.py | 334 --------------------------------------- tests/test_cli_insert.py | 324 +++++++++++++++++++++++++++++++++++++ 3 files changed, 338 insertions(+), 334 deletions(-) create mode 100644 tests/test_cli_insert.py diff --git a/tests/conftest.py b/tests/conftest.py index 4541689..621d67e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,12 @@ from sqlite_utils import Database +from sqlite_utils.utils import sqlite3 import pytest +CREATE_TABLES = """ +create table Gosh (c1 text, c2 text, c3 text); +create table Gosh2 (c1 text, c2 text, c3 text); +""" + @pytest.fixture def fresh_db(): @@ -19,3 +25,11 @@ def existing_db(): """ ) return database + + +@pytest.fixture +def db_path(tmpdir): + path = str(tmpdir / "test.db") + db = sqlite3.connect(path) + db.executescript(CREATE_TABLES) + return path diff --git a/tests/test_cli.py b/tests/test_cli.py index 26ef004..c4c50a9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -11,20 +11,6 @@ import textwrap from .utils import collapse_whitespace -CREATE_TABLES = """ -create table Gosh (c1 text, c2 text, c3 text); -create table Gosh2 (c1 text, c2 text, c3 text); -""" - - -@pytest.fixture -def db_path(tmpdir): - path = str(tmpdir / "test.db") - db = sqlite3.connect(path) - db.executescript(CREATE_TABLES) - return path - - @pytest.mark.parametrize( "options", ( @@ -585,326 +571,6 @@ def test_rebuild_fts_fixes_docsize_error(db_path): assert db["fts5_table_fts_docsize"].count == 10000 -def test_insert_simple(tmpdir): - json_path = str(tmpdir / "dog.json") - db_path = str(tmpdir / "dogs.db") - open(json_path, "w").write(json.dumps({"name": "Cleo", "age": 4})) - result = CliRunner().invoke(cli.cli, ["insert", db_path, "dogs", json_path]) - assert 0 == result.exit_code - assert [{"age": 4, "name": "Cleo"}] == list( - Database(db_path).query("select * from dogs") - ) - db = Database(db_path) - assert ["dogs"] == db.table_names() - assert [] == db["dogs"].indexes - - -def test_insert_from_stdin(tmpdir): - db_path = str(tmpdir / "dogs.db") - result = CliRunner().invoke( - cli.cli, - ["insert", db_path, "dogs", "-"], - input=json.dumps({"name": "Cleo", "age": 4}), - ) - assert 0 == result.exit_code - assert [{"age": 4, "name": "Cleo"}] == list( - Database(db_path).query("select * from dogs") - ) - - -def test_insert_invalid_json_error(tmpdir): - db_path = str(tmpdir / "dogs.db") - result = CliRunner().invoke( - cli.cli, - ["insert", db_path, "dogs", "-"], - input="name,age\nCleo,4", - ) - assert result.exit_code == 1 - assert ( - result.output - == "Error: Invalid JSON - use --csv for CSV or --tsv for TSV files\n" - ) - - -def test_insert_json_flatten(tmpdir): - db_path = str(tmpdir / "flat.db") - result = CliRunner().invoke( - cli.cli, - ["insert", db_path, "items", "-", "--flatten"], - input=json.dumps({"nested": {"data": 4}}), - ) - assert result.exit_code == 0 - assert list(Database(db_path).query("select * from items")) == [{"nested_data": 4}] - - -def test_insert_json_flatten_nl(tmpdir): - db_path = str(tmpdir / "flat.db") - result = CliRunner().invoke( - cli.cli, - ["insert", db_path, "items", "-", "--flatten", "--nl"], - input="\n".join( - json.dumps(item) - for item in [{"nested": {"data": 4}}, {"nested": {"other": 3}}] - ), - ) - assert result.exit_code == 0 - assert list(Database(db_path).query("select * from items")) == [ - {"nested_data": 4, "nested_other": None}, - {"nested_data": None, "nested_other": 3}, - ] - - -def test_insert_with_primary_key(db_path, tmpdir): - json_path = str(tmpdir / "dog.json") - open(json_path, "w").write(json.dumps({"id": 1, "name": "Cleo", "age": 4})) - result = CliRunner().invoke( - cli.cli, ["insert", db_path, "dogs", json_path, "--pk", "id"] - ) - assert 0 == result.exit_code - assert [{"id": 1, "age": 4, "name": "Cleo"}] == list( - Database(db_path).query("select * from dogs") - ) - db = Database(db_path) - assert ["id"] == db["dogs"].pks - - -def test_insert_multiple_with_primary_key(db_path, tmpdir): - json_path = str(tmpdir / "dogs.json") - dogs = [{"id": i, "name": "Cleo {}".format(i), "age": i + 3} for i in range(1, 21)] - open(json_path, "w").write(json.dumps(dogs)) - result = CliRunner().invoke( - cli.cli, ["insert", db_path, "dogs", json_path, "--pk", "id"] - ) - assert 0 == result.exit_code - db = Database(db_path) - assert dogs == list(db.query("select * from dogs order by id")) - assert ["id"] == db["dogs"].pks - - -def test_insert_multiple_with_compound_primary_key(db_path, tmpdir): - json_path = str(tmpdir / "dogs.json") - dogs = [ - {"breed": "mixed", "id": i, "name": "Cleo {}".format(i), "age": i + 3} - for i in range(1, 21) - ] - open(json_path, "w").write(json.dumps(dogs)) - result = CliRunner().invoke( - cli.cli, ["insert", db_path, "dogs", json_path, "--pk", "id", "--pk", "breed"] - ) - assert 0 == result.exit_code - db = Database(db_path) - assert dogs == list(db.query("select * from dogs order by breed, id")) - assert {"breed", "id"} == set(db["dogs"].pks) - assert ( - "CREATE TABLE [dogs] (\n" - " [breed] TEXT,\n" - " [id] INTEGER,\n" - " [name] TEXT,\n" - " [age] INTEGER,\n" - " PRIMARY KEY ([id], [breed])\n" - ")" - ) == db["dogs"].schema - - -def test_insert_not_null_default(db_path, tmpdir): - json_path = str(tmpdir / "dogs.json") - dogs = [ - {"id": i, "name": "Cleo {}".format(i), "age": i + 3, "score": 10} - for i in range(1, 21) - ] - open(json_path, "w").write(json.dumps(dogs)) - result = CliRunner().invoke( - cli.cli, - ["insert", db_path, "dogs", json_path, "--pk", "id"] - + ["--not-null", "name", "--not-null", "age"] - + ["--default", "score", "5", "--default", "age", "1"], - ) - assert 0 == result.exit_code - db = Database(db_path) - assert ( - "CREATE TABLE [dogs] (\n" - " [id] INTEGER PRIMARY KEY,\n" - " [name] TEXT NOT NULL,\n" - " [age] INTEGER NOT NULL DEFAULT '1',\n" - " [score] INTEGER DEFAULT '5'\n)" - ) == db["dogs"].schema - - -def test_insert_binary_base64(db_path): - result = CliRunner().invoke( - cli.cli, - ["insert", db_path, "files", "-"], - input=r'{"content": {"$base64": true, "encoded": "aGVsbG8="}}', - ) - assert 0 == result.exit_code, result.output - db = Database(db_path) - actual = list(db.query("select content from files")) - assert actual == [{"content": b"hello"}] - - -def test_insert_newline_delimited(db_path): - result = CliRunner().invoke( - cli.cli, - ["insert", db_path, "from_json_nl", "-", "--nl"], - input='{"foo": "bar", "n": 1}\n{"foo": "baz", "n": 2}', - ) - assert 0 == result.exit_code, result.output - db = Database(db_path) - assert [ - {"foo": "bar", "n": 1}, - {"foo": "baz", "n": 2}, - ] == list(db.query("select foo, n from from_json_nl")) - - -def test_insert_ignore(db_path, tmpdir): - db = Database(db_path) - db["dogs"].insert({"id": 1, "name": "Cleo"}, pk="id") - json_path = str(tmpdir / "dogs.json") - open(json_path, "w").write(json.dumps([{"id": 1, "name": "Bailey"}])) - # Should raise error without --ignore - result = CliRunner().invoke( - cli.cli, ["insert", db_path, "dogs", json_path, "--pk", "id"] - ) - assert 0 != result.exit_code, result.output - # If we use --ignore it should run OK - result = CliRunner().invoke( - cli.cli, ["insert", db_path, "dogs", json_path, "--pk", "id", "--ignore"] - ) - assert 0 == result.exit_code, result.output - # ... but it should actually have no effect - assert [{"id": 1, "name": "Cleo"}] == list(db.query("select * from dogs")) - - -@pytest.mark.parametrize( - "content,options", - [ - ("foo\tbar\tbaz\n1\t2\tcat,dog", ["--tsv"]), - ('foo,bar,baz\n1,2,"cat,dog"', ["--csv"]), - ('foo;bar;baz\n1;2;"cat,dog"', ["--csv", "--delimiter", ";"]), - # --delimiter implies --csv: - ('foo;bar;baz\n1;2;"cat,dog"', ["--delimiter", ";"]), - ("foo,bar,baz\n1,2,|cat,dog|", ["--csv", "--quotechar", "|"]), - ("foo,bar,baz\n1,2,|cat,dog|", ["--quotechar", "|"]), - ], -) -def test_insert_csv_tsv(content, options, db_path, tmpdir): - db = Database(db_path) - file_path = str(tmpdir / "insert.csv-tsv") - open(file_path, "w").write(content) - result = CliRunner().invoke( - cli.cli, - ["insert", db_path, "data", file_path] + options, - catch_exceptions=False, - ) - assert 0 == result.exit_code - assert [{"foo": "1", "bar": "2", "baz": "cat,dog"}] == list(db["data"].rows) - - -@pytest.mark.parametrize( - "options", - ( - ["--tsv", "--nl"], - ["--tsv", "--csv"], - ["--csv", "--nl"], - ["--csv", "--nl", "--tsv"], - ), -) -def test_only_allow_one_of_nl_tsv_csv(options, db_path, tmpdir): - file_path = str(tmpdir / "insert.csv-tsv") - open(file_path, "w").write("foo") - result = CliRunner().invoke( - cli.cli, ["insert", db_path, "data", file_path] + options - ) - assert 0 != result.exit_code - assert "Error: Use just one of --nl, --csv or --tsv" == result.output.strip() - - -def test_insert_replace(db_path, tmpdir): - test_insert_multiple_with_primary_key(db_path, tmpdir) - json_path = str(tmpdir / "insert-replace.json") - db = Database(db_path) - assert 20 == db["dogs"].count - insert_replace_dogs = [ - {"id": 1, "name": "Insert replaced 1", "age": 4}, - {"id": 2, "name": "Insert replaced 2", "age": 4}, - {"id": 21, "name": "Fresh insert 21", "age": 6}, - ] - open(json_path, "w").write(json.dumps(insert_replace_dogs)) - result = CliRunner().invoke( - cli.cli, ["insert", db_path, "dogs", json_path, "--pk", "id", "--replace"] - ) - assert 0 == result.exit_code, result.output - assert 21 == db["dogs"].count - assert ( - list(db.query("select * from dogs where id in (1, 2, 21) order by id")) - == insert_replace_dogs - ) - - -def test_insert_truncate(db_path): - result = CliRunner().invoke( - cli.cli, - ["insert", db_path, "from_json_nl", "-", "--nl", "--batch-size=1"], - input='{"foo": "bar", "n": 1}\n{"foo": "baz", "n": 2}', - ) - assert 0 == result.exit_code, result.output - db = Database(db_path) - assert [ - {"foo": "bar", "n": 1}, - {"foo": "baz", "n": 2}, - ] == list(db.query("select foo, n from from_json_nl")) - # Truncate and insert new rows - result = CliRunner().invoke( - cli.cli, - [ - "insert", - db_path, - "from_json_nl", - "-", - "--nl", - "--truncate", - "--batch-size=1", - ], - input='{"foo": "bam", "n": 3}\n{"foo": "bat", "n": 4}', - ) - assert 0 == result.exit_code, result.output - assert [ - {"foo": "bam", "n": 3}, - {"foo": "bat", "n": 4}, - ] == list(db.query("select foo, n from from_json_nl")) - - -def test_insert_alter(db_path, tmpdir): - result = CliRunner().invoke( - cli.cli, - ["insert", db_path, "from_json_nl", "-", "--nl"], - input='{"foo": "bar", "n": 1}\n{"foo": "baz", "n": 2}', - ) - assert 0 == result.exit_code, result.output - # Should get an error with incorrect shaped additional data - result = CliRunner().invoke( - cli.cli, - ["insert", db_path, "from_json_nl", "-", "--nl"], - input='{"foo": "bar", "baz": 5}', - ) - assert 0 != result.exit_code, result.output - # If we run it again with --alter it should work correctly - result = CliRunner().invoke( - cli.cli, - ["insert", db_path, "from_json_nl", "-", "--nl", "--alter"], - input='{"foo": "bar", "baz": 5}', - ) - assert 0 == result.exit_code, result.output - # Soundness check the database itself - db = Database(db_path) - assert {"foo": str, "n": int, "baz": int} == db["from_json_nl"].columns_dict - assert [ - {"foo": "bar", "n": 1, "baz": None}, - {"foo": "baz", "n": 2, "baz": None}, - {"foo": "bar", "baz": 5, "n": None}, - ] == list(db.query("select foo, n, baz from from_json_nl")) - - @pytest.mark.parametrize( "format,expected", [ diff --git a/tests/test_cli_insert.py b/tests/test_cli_insert.py new file mode 100644 index 0000000..b01b2cf --- /dev/null +++ b/tests/test_cli_insert.py @@ -0,0 +1,324 @@ +from sqlite_utils import cli, Database +from click.testing import CliRunner +import json +import pytest + + +def test_insert_simple(tmpdir): + json_path = str(tmpdir / "dog.json") + db_path = str(tmpdir / "dogs.db") + open(json_path, "w").write(json.dumps({"name": "Cleo", "age": 4})) + result = CliRunner().invoke(cli.cli, ["insert", db_path, "dogs", json_path]) + assert 0 == result.exit_code + assert [{"age": 4, "name": "Cleo"}] == list( + Database(db_path).query("select * from dogs") + ) + db = Database(db_path) + assert ["dogs"] == db.table_names() + assert [] == db["dogs"].indexes + + +def test_insert_from_stdin(tmpdir): + db_path = str(tmpdir / "dogs.db") + result = CliRunner().invoke( + cli.cli, + ["insert", db_path, "dogs", "-"], + input=json.dumps({"name": "Cleo", "age": 4}), + ) + assert 0 == result.exit_code + assert [{"age": 4, "name": "Cleo"}] == list( + Database(db_path).query("select * from dogs") + ) + + +def test_insert_invalid_json_error(tmpdir): + db_path = str(tmpdir / "dogs.db") + result = CliRunner().invoke( + cli.cli, + ["insert", db_path, "dogs", "-"], + input="name,age\nCleo,4", + ) + assert result.exit_code == 1 + assert ( + result.output + == "Error: Invalid JSON - use --csv for CSV or --tsv for TSV files\n" + ) + + +def test_insert_json_flatten(tmpdir): + db_path = str(tmpdir / "flat.db") + result = CliRunner().invoke( + cli.cli, + ["insert", db_path, "items", "-", "--flatten"], + input=json.dumps({"nested": {"data": 4}}), + ) + assert result.exit_code == 0 + assert list(Database(db_path).query("select * from items")) == [{"nested_data": 4}] + + +def test_insert_json_flatten_nl(tmpdir): + db_path = str(tmpdir / "flat.db") + result = CliRunner().invoke( + cli.cli, + ["insert", db_path, "items", "-", "--flatten", "--nl"], + input="\n".join( + json.dumps(item) + for item in [{"nested": {"data": 4}}, {"nested": {"other": 3}}] + ), + ) + assert result.exit_code == 0 + assert list(Database(db_path).query("select * from items")) == [ + {"nested_data": 4, "nested_other": None}, + {"nested_data": None, "nested_other": 3}, + ] + + +def test_insert_with_primary_key(db_path, tmpdir): + json_path = str(tmpdir / "dog.json") + open(json_path, "w").write(json.dumps({"id": 1, "name": "Cleo", "age": 4})) + result = CliRunner().invoke( + cli.cli, ["insert", db_path, "dogs", json_path, "--pk", "id"] + ) + assert 0 == result.exit_code + assert [{"id": 1, "age": 4, "name": "Cleo"}] == list( + Database(db_path).query("select * from dogs") + ) + db = Database(db_path) + assert ["id"] == db["dogs"].pks + + +def test_insert_multiple_with_primary_key(db_path, tmpdir): + json_path = str(tmpdir / "dogs.json") + dogs = [{"id": i, "name": "Cleo {}".format(i), "age": i + 3} for i in range(1, 21)] + open(json_path, "w").write(json.dumps(dogs)) + result = CliRunner().invoke( + cli.cli, ["insert", db_path, "dogs", json_path, "--pk", "id"] + ) + assert 0 == result.exit_code + db = Database(db_path) + assert dogs == list(db.query("select * from dogs order by id")) + assert ["id"] == db["dogs"].pks + + +def test_insert_multiple_with_compound_primary_key(db_path, tmpdir): + json_path = str(tmpdir / "dogs.json") + dogs = [ + {"breed": "mixed", "id": i, "name": "Cleo {}".format(i), "age": i + 3} + for i in range(1, 21) + ] + open(json_path, "w").write(json.dumps(dogs)) + result = CliRunner().invoke( + cli.cli, ["insert", db_path, "dogs", json_path, "--pk", "id", "--pk", "breed"] + ) + assert 0 == result.exit_code + db = Database(db_path) + assert dogs == list(db.query("select * from dogs order by breed, id")) + assert {"breed", "id"} == set(db["dogs"].pks) + assert ( + "CREATE TABLE [dogs] (\n" + " [breed] TEXT,\n" + " [id] INTEGER,\n" + " [name] TEXT,\n" + " [age] INTEGER,\n" + " PRIMARY KEY ([id], [breed])\n" + ")" + ) == db["dogs"].schema + + +def test_insert_not_null_default(db_path, tmpdir): + json_path = str(tmpdir / "dogs.json") + dogs = [ + {"id": i, "name": "Cleo {}".format(i), "age": i + 3, "score": 10} + for i in range(1, 21) + ] + open(json_path, "w").write(json.dumps(dogs)) + result = CliRunner().invoke( + cli.cli, + ["insert", db_path, "dogs", json_path, "--pk", "id"] + + ["--not-null", "name", "--not-null", "age"] + + ["--default", "score", "5", "--default", "age", "1"], + ) + assert 0 == result.exit_code + db = Database(db_path) + assert ( + "CREATE TABLE [dogs] (\n" + " [id] INTEGER PRIMARY KEY,\n" + " [name] TEXT NOT NULL,\n" + " [age] INTEGER NOT NULL DEFAULT '1',\n" + " [score] INTEGER DEFAULT '5'\n)" + ) == db["dogs"].schema + + +def test_insert_binary_base64(db_path): + result = CliRunner().invoke( + cli.cli, + ["insert", db_path, "files", "-"], + input=r'{"content": {"$base64": true, "encoded": "aGVsbG8="}}', + ) + assert 0 == result.exit_code, result.output + db = Database(db_path) + actual = list(db.query("select content from files")) + assert actual == [{"content": b"hello"}] + + +def test_insert_newline_delimited(db_path): + result = CliRunner().invoke( + cli.cli, + ["insert", db_path, "from_json_nl", "-", "--nl"], + input='{"foo": "bar", "n": 1}\n{"foo": "baz", "n": 2}', + ) + assert 0 == result.exit_code, result.output + db = Database(db_path) + assert [ + {"foo": "bar", "n": 1}, + {"foo": "baz", "n": 2}, + ] == list(db.query("select foo, n from from_json_nl")) + + +def test_insert_ignore(db_path, tmpdir): + db = Database(db_path) + db["dogs"].insert({"id": 1, "name": "Cleo"}, pk="id") + json_path = str(tmpdir / "dogs.json") + open(json_path, "w").write(json.dumps([{"id": 1, "name": "Bailey"}])) + # Should raise error without --ignore + result = CliRunner().invoke( + cli.cli, ["insert", db_path, "dogs", json_path, "--pk", "id"] + ) + assert 0 != result.exit_code, result.output + # If we use --ignore it should run OK + result = CliRunner().invoke( + cli.cli, ["insert", db_path, "dogs", json_path, "--pk", "id", "--ignore"] + ) + assert 0 == result.exit_code, result.output + # ... but it should actually have no effect + assert [{"id": 1, "name": "Cleo"}] == list(db.query("select * from dogs")) + + +@pytest.mark.parametrize( + "content,options", + [ + ("foo\tbar\tbaz\n1\t2\tcat,dog", ["--tsv"]), + ('foo,bar,baz\n1,2,"cat,dog"', ["--csv"]), + ('foo;bar;baz\n1;2;"cat,dog"', ["--csv", "--delimiter", ";"]), + # --delimiter implies --csv: + ('foo;bar;baz\n1;2;"cat,dog"', ["--delimiter", ";"]), + ("foo,bar,baz\n1,2,|cat,dog|", ["--csv", "--quotechar", "|"]), + ("foo,bar,baz\n1,2,|cat,dog|", ["--quotechar", "|"]), + ], +) +def test_insert_csv_tsv(content, options, db_path, tmpdir): + db = Database(db_path) + file_path = str(tmpdir / "insert.csv-tsv") + open(file_path, "w").write(content) + result = CliRunner().invoke( + cli.cli, + ["insert", db_path, "data", file_path] + options, + catch_exceptions=False, + ) + assert 0 == result.exit_code + assert [{"foo": "1", "bar": "2", "baz": "cat,dog"}] == list(db["data"].rows) + + +@pytest.mark.parametrize( + "options", + ( + ["--tsv", "--nl"], + ["--tsv", "--csv"], + ["--csv", "--nl"], + ["--csv", "--nl", "--tsv"], + ), +) +def test_only_allow_one_of_nl_tsv_csv(options, db_path, tmpdir): + file_path = str(tmpdir / "insert.csv-tsv") + open(file_path, "w").write("foo") + result = CliRunner().invoke( + cli.cli, ["insert", db_path, "data", file_path] + options + ) + assert 0 != result.exit_code + assert "Error: Use just one of --nl, --csv or --tsv" == result.output.strip() + + +def test_insert_replace(db_path, tmpdir): + test_insert_multiple_with_primary_key(db_path, tmpdir) + json_path = str(tmpdir / "insert-replace.json") + db = Database(db_path) + assert 20 == db["dogs"].count + insert_replace_dogs = [ + {"id": 1, "name": "Insert replaced 1", "age": 4}, + {"id": 2, "name": "Insert replaced 2", "age": 4}, + {"id": 21, "name": "Fresh insert 21", "age": 6}, + ] + open(json_path, "w").write(json.dumps(insert_replace_dogs)) + result = CliRunner().invoke( + cli.cli, ["insert", db_path, "dogs", json_path, "--pk", "id", "--replace"] + ) + assert 0 == result.exit_code, result.output + assert 21 == db["dogs"].count + assert ( + list(db.query("select * from dogs where id in (1, 2, 21) order by id")) + == insert_replace_dogs + ) + + +def test_insert_truncate(db_path): + result = CliRunner().invoke( + cli.cli, + ["insert", db_path, "from_json_nl", "-", "--nl", "--batch-size=1"], + input='{"foo": "bar", "n": 1}\n{"foo": "baz", "n": 2}', + ) + assert 0 == result.exit_code, result.output + db = Database(db_path) + assert [ + {"foo": "bar", "n": 1}, + {"foo": "baz", "n": 2}, + ] == list(db.query("select foo, n from from_json_nl")) + # Truncate and insert new rows + result = CliRunner().invoke( + cli.cli, + [ + "insert", + db_path, + "from_json_nl", + "-", + "--nl", + "--truncate", + "--batch-size=1", + ], + input='{"foo": "bam", "n": 3}\n{"foo": "bat", "n": 4}', + ) + assert 0 == result.exit_code, result.output + assert [ + {"foo": "bam", "n": 3}, + {"foo": "bat", "n": 4}, + ] == list(db.query("select foo, n from from_json_nl")) + + +def test_insert_alter(db_path, tmpdir): + result = CliRunner().invoke( + cli.cli, + ["insert", db_path, "from_json_nl", "-", "--nl"], + input='{"foo": "bar", "n": 1}\n{"foo": "baz", "n": 2}', + ) + assert 0 == result.exit_code, result.output + # Should get an error with incorrect shaped additional data + result = CliRunner().invoke( + cli.cli, + ["insert", db_path, "from_json_nl", "-", "--nl"], + input='{"foo": "bar", "baz": 5}', + ) + assert 0 != result.exit_code, result.output + # If we run it again with --alter it should work correctly + result = CliRunner().invoke( + cli.cli, + ["insert", db_path, "from_json_nl", "-", "--nl", "--alter"], + input='{"foo": "bar", "baz": 5}', + ) + assert 0 == result.exit_code, result.output + # Soundness check the database itself + db = Database(db_path) + assert {"foo": str, "n": int, "baz": int} == db["from_json_nl"].columns_dict + assert [ + {"foo": "bar", "n": 1, "baz": None}, + {"foo": "baz", "n": 2, "baz": None}, + {"foo": "bar", "baz": 5, "n": None}, + ] == list(db.query("select foo, n, baz from from_json_nl")) From f1569c9f7fc063ddf2f1ca91d5f1798afa9d0262 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 5 Jan 2022 18:10:10 -0800 Subject: [PATCH 042/563] Implemented sqlite-utils insert --lines --- docs/cli.rst | 9 +++++++++ sqlite_utils/cli.py | 2 ++ tests/test_cli_insert.py | 15 +++++++++++++++ 3 files changed, 26 insertions(+) diff --git a/docs/cli.rst b/docs/cli.rst index 6482c46..f93fc57 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -876,6 +876,15 @@ If your file does not include this row, you can use the ``--no-headers`` option If you do this, the table will be created with column names called ``untitled_1`` and ``untitled_2`` and so on. You can then rename them using the ``sqlite-utils transform ... --rename`` command, see :ref:`cli_transform_table`. +.. _cli_insert_lines: + +Inserting newline-delimited data +================================ + +If you have an unstructured file you can insert its contents into a table with a single ``line`` column containing each line from the file using ``--lines``. This can be useful if you intend to further analyze those lines using SQL string functions or :ref:`sqlite-utils convert `:: + + $ sqlite-utils insert logs.db loglines logfile.log --lines + .. _cli_insert_replace: Insert-replacing data diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 319dee1..6d3f8eb 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -783,6 +783,8 @@ def insert_upsert_implementation( if detect_types: tracker = TypeTracker() docs = tracker.wrap(docs) + elif lines: + docs = ({"line": line.strip()} for line in decoded) elif convert: fn = _compile_code(convert, imports) docs = (fn(line) for line in decoded) diff --git a/tests/test_cli_insert.py b/tests/test_cli_insert.py index b01b2cf..2922a2f 100644 --- a/tests/test_cli_insert.py +++ b/tests/test_cli_insert.py @@ -322,3 +322,18 @@ def test_insert_alter(db_path, tmpdir): {"foo": "baz", "n": 2, "baz": None}, {"foo": "bar", "baz": 5, "n": None}, ] == list(db.query("select foo, n, baz from from_json_nl")) + + +def test_insert_lines(db_path): + result = CliRunner().invoke( + cli.cli, + ["insert", db_path, "from_lines", "-", "--lines"], + input='First line\nSecond line\n{"foo": "baz"}', + ) + assert 0 == result.exit_code, result.output + db = Database(db_path) + assert [ + {"line": "First line"}, + {"line": "Second line"}, + {"line": '{"foo": "baz"}'}, + ] == list(db.query("select line from from_lines")) From e66299c6eda3091557504526aaf0f64fb321cb35 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 5 Jan 2022 18:16:51 -0800 Subject: [PATCH 043/563] Implemented and documented sqlite-utils insert --all --- docs/cli.rst | 26 +++++++++++++++++++++++--- sqlite_utils/cli.py | 2 ++ tests/test_cli_insert.py | 13 +++++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/docs/cli.rst b/docs/cli.rst index f93fc57..5bf02d2 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -876,15 +876,35 @@ If your file does not include this row, you can use the ``--no-headers`` option If you do this, the table will be created with column names called ``untitled_1`` and ``untitled_2`` and so on. You can then rename them using the ``sqlite-utils transform ... --rename`` command, see :ref:`cli_transform_table`. -.. _cli_insert_lines: +.. _cli_insert_unstructured: -Inserting newline-delimited data -================================ +Inserting unstructured data with \-\-lines and \-\-all +====================================================== If you have an unstructured file you can insert its contents into a table with a single ``line`` column containing each line from the file using ``--lines``. This can be useful if you intend to further analyze those lines using SQL string functions or :ref:`sqlite-utils convert `:: $ sqlite-utils insert logs.db loglines logfile.log --lines +This will produce the following schema: + +.. code-block:: sql + + CREATE TABLE [loglines] ( + [line] TEXT + ); + +You can also insert the entire contents of the file into a single column called ``all`` using ``--all``:: + + $ sqlite-utils insert content.db content file.txt --all + +The schema here will be: + +.. code-block:: sql + + CREATE TABLE [content] ( + [all] TEXT + ); + .. _cli_insert_replace: Insert-replacing data diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 6d3f8eb..a5b979f 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -785,6 +785,8 @@ def insert_upsert_implementation( docs = tracker.wrap(docs) elif lines: docs = ({"line": line.strip()} for line in decoded) + elif all: + docs = ({"all": decoded.read()},) elif convert: fn = _compile_code(convert, imports) docs = (fn(line) for line in decoded) diff --git a/tests/test_cli_insert.py b/tests/test_cli_insert.py index 2922a2f..063c43f 100644 --- a/tests/test_cli_insert.py +++ b/tests/test_cli_insert.py @@ -337,3 +337,16 @@ def test_insert_lines(db_path): {"line": "Second line"}, {"line": '{"foo": "baz"}'}, ] == list(db.query("select line from from_lines")) + + +def test_insert_all(db_path): + result = CliRunner().invoke( + cli.cli, + ["insert", db_path, "from_all", "-", "--all"], + input='First line\nSecond line\n{"foo": "baz"}', + ) + assert 0 == result.exit_code, result.output + db = Database(db_path) + assert [{"all": 'First line\nSecond line\n{"foo": "baz"}'}] == list( + db.query("select [all] from from_all") + ) From 2e4847e493a03d95f827ddfaa698c052e3b231a8 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 5 Jan 2022 21:44:04 -0800 Subject: [PATCH 044/563] Implemented --convert for different things, renamed --all to --text --- docs/cli.rst | 10 +++--- sqlite_utils/cli.py | 41 +++++++++++++++-------- sqlite_utils/utils.py | 6 ++-- tests/test_cli_insert.py | 72 +++++++++++++++++++++++++++++++++++++--- 4 files changed, 104 insertions(+), 25 deletions(-) diff --git a/docs/cli.rst b/docs/cli.rst index 5bf02d2..8ce2215 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -878,8 +878,8 @@ If you do this, the table will be created with column names called ``untitled_1` .. _cli_insert_unstructured: -Inserting unstructured data with \-\-lines and \-\-all -====================================================== +Inserting unstructured data with \-\-lines and \-\-text +======================================================= If you have an unstructured file you can insert its contents into a table with a single ``line`` column containing each line from the file using ``--lines``. This can be useful if you intend to further analyze those lines using SQL string functions or :ref:`sqlite-utils convert `:: @@ -893,16 +893,16 @@ This will produce the following schema: [line] TEXT ); -You can also insert the entire contents of the file into a single column called ``all`` using ``--all``:: +You can also insert the entire contents of the file into a single column called ``text`` using ``--text``:: - $ sqlite-utils insert content.db content file.txt --all + $ sqlite-utils insert content.db content file.txt --text The schema here will be: .. code-block:: sql CREATE TABLE [content] ( - [all] TEXT + [text] TEXT ); .. _cli_insert_replace: diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index a5b979f..5b83dc5 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -657,7 +657,9 @@ def insert_upsert_options(fn): help="Treat each line as a single value called 'line'", ), click.option( - "--all", is_flag=True, help="Treat input as a single value called 'all'" + "--text", + is_flag=True, + help="Treat input as a single value called 'text'", ), click.option("--convert", help="Python code to convert each item"), click.option( @@ -723,7 +725,7 @@ def insert_upsert_implementation( csv, tsv, lines, - all, + text, convert, imports, delimiter, @@ -785,11 +787,8 @@ def insert_upsert_implementation( docs = tracker.wrap(docs) elif lines: docs = ({"line": line.strip()} for line in decoded) - elif all: - docs = ({"all": decoded.read()},) - elif convert: - fn = _compile_code(convert, imports) - docs = (fn(line) for line in decoded) + elif text: + docs = ({"text": decoded.read()},) else: try: if nl: @@ -805,6 +804,20 @@ def insert_upsert_implementation( if flatten: docs = (dict(_flatten(doc)) for doc in docs) + if convert: + variable = "row" + if lines: + variable = "line" + elif text: + variable = "text" + fn = _compile_code(convert, imports, variable=variable) + if lines: + docs = (fn(doc["line"]) for doc in docs) + elif text: + docs = (fn(doc["text"]) for doc in docs) + else: + docs = (fn(doc) for doc in docs) + extra_kwargs = {"ignore": ignore, "replace": replace, "truncate": truncate} if not_null: extra_kwargs["not_null"] = set(not_null) @@ -812,8 +825,10 @@ def insert_upsert_implementation( extra_kwargs["defaults"] = dict(default) if upsert: extra_kwargs["upsert"] = upsert + # Apply {"$base64": true, ...} decoding, if needed docs = (decode_base64_values(doc) for doc in docs) + try: db[table].insert_all( docs, pk=pk, batch_size=batch_size, alter=alter, **extra_kwargs @@ -889,7 +904,7 @@ def insert( csv, tsv, lines, - all, + text, convert, imports, delimiter, @@ -918,7 +933,7 @@ def insert( - Use --nl for newline-delimited JSON objects - Use --csv or --tsv for comma-separated or tab-separated input - Use --lines to write each incoming line to a column called "line" - - Use --all to write the entire input to a column called "all" + - Use --text to write the entire input to a column called "text" You can also use --convert to pass a fragment of Python code that will be used to convert each input. @@ -927,7 +942,7 @@ def insert( imported row, and can return a modified row. If you are using --lines your code will be passed a "line" variable, - and for --all an "all" variable. + and for --text an "text" variable. """ try: insert_upsert_implementation( @@ -940,7 +955,7 @@ def insert( csv, tsv, lines, - all, + text, convert, imports, delimiter, @@ -976,7 +991,7 @@ def upsert( csv, tsv, lines, - all, + text, convert, imports, batch_size, @@ -1008,7 +1023,7 @@ def upsert( csv, tsv, lines, - all, + text, convert, imports, delimiter, diff --git a/sqlite_utils/utils.py b/sqlite_utils/utils.py index fe71b73..caec976 100644 --- a/sqlite_utils/utils.py +++ b/sqlite_utils/utils.py @@ -281,14 +281,14 @@ def progressbar(*args, **kwargs): yield bar -def _compile_code(code, imports): +def _compile_code(code, imports, variable="value"): locals = {} globals = {"r": recipes, "recipes": recipes} # If user defined a convert() function, return that try: exec(code, globals, locals) return locals["convert"] - except (SyntaxError, NameError, KeyError, TypeError): + except (AttributeError, SyntaxError, NameError, KeyError, TypeError): pass # Try compiling their code as a function instead @@ -297,7 +297,7 @@ def _compile_code(code, imports): if "\n" not in code and not code.strip().startswith("return "): code = "return {}".format(code) - new_code = ["def fn(value):"] + new_code = ["def fn({}):".format(variable)] for line in code.split("\n"): new_code.append(" {}".format(line)) code_o = compile("\n".join(new_code), "", "exec") diff --git a/tests/test_cli_insert.py b/tests/test_cli_insert.py index 063c43f..bb9aa9b 100644 --- a/tests/test_cli_insert.py +++ b/tests/test_cli_insert.py @@ -339,14 +339,78 @@ def test_insert_lines(db_path): ] == list(db.query("select line from from_lines")) -def test_insert_all(db_path): +def test_insert_text(db_path): result = CliRunner().invoke( cli.cli, - ["insert", db_path, "from_all", "-", "--all"], + ["insert", db_path, "from_text", "-", "--text"], input='First line\nSecond line\n{"foo": "baz"}', ) assert 0 == result.exit_code, result.output db = Database(db_path) - assert [{"all": 'First line\nSecond line\n{"foo": "baz"}'}] == list( - db.query("select [all] from from_all") + assert [{"text": 'First line\nSecond line\n{"foo": "baz"}'}] == list( + db.query("select text from from_text") ) + + +@pytest.mark.parametrize( + "options,input", + ( + ([], '[{"id": "1", "name": "Bob"}, {"id": "2", "name": "Cat"}]'), + (["--csv"], "id,name\n1,Bob\n2,Cat"), + (["--nl"], '{"id": "1", "name": "Bob"}\n{"id": "2", "name": "Cat"}'), + ), +) +def test_insert_convert_json_csv_jsonnl(db_path, options, input): + result = CliRunner().invoke( + cli.cli, + ["insert", db_path, "rows", "-", "--convert", '{**row, **{"extra": 1}}'] + + options, + input=input, + ) + assert result.exit_code == 0, result.output + db = Database(db_path) + rows = list(db.query("select id, name, extra from rows")) + assert rows == [ + {"id": "1", "name": "Bob", "extra": 1}, + {"id": "2", "name": "Cat", "extra": 1}, + ] + + +def test_insert_convert_text(db_path): + result = CliRunner().invoke( + cli.cli, + [ + "insert", + db_path, + "text", + "-", + "--text", + "--convert", + '{"text": text.upper()}', + ], + input="This is text\nwill be upper now", + ) + assert result.exit_code == 0, result.output + db = Database(db_path) + rows = list(db.query("select [text] from [text]")) + assert rows == [{"text": "THIS IS TEXT\nWILL BE UPPER NOW"}] + + +def test_insert_convert_lines(db_path): + result = CliRunner().invoke( + cli.cli, + [ + "insert", + db_path, + "all", + "-", + "--lines", + "--convert", + '{"line": line.upper()}', + ], + input="This is text\nwill be upper now", + ) + assert result.exit_code == 0, result.output + db = Database(db_path) + rows = list(db.query("select [line] from [all]")) + assert rows == [{"line": "THIS IS TEXT"}, {"line": "WILL BE UPPER NOW"}] From 413f8ed754e38d7b190de888c85fe8438336cb11 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 5 Jan 2022 22:19:52 -0800 Subject: [PATCH 045/563] --convert --text for iterators, docs for --convert --- docs/cli.rst | 118 +++++++++++++++++++++++++++++++++++---- sqlite_utils/cli.py | 11 +++- tests/test_cli_insert.py | 20 +++++++ 3 files changed, 136 insertions(+), 13 deletions(-) diff --git a/docs/cli.rst b/docs/cli.rst index 8ce2215..6d687ec 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -905,6 +905,95 @@ The schema here will be: [text] TEXT ); +.. _cli_insert_convert: + +Applying conversions while inserting data +========================================= + +The ``--convert`` option can be used to apply a Python conversion function to imported data before it is inserted into the database. It works in a similar way to :ref:`sqlite-utils convert `. + +Your Python function will be passed a dictionary called ``row`` for each item that is being imported. You can modify that dictionary and return it - or return a fresh dictionary - to change the data that will be inserted. + +Given a JSON file called ``dogs.json`` containing this: + +.. code-block:: json + + [ + {"id": 1, "name": "Cleo"}, + {"id": 2, "name": "Pancakes"} + ] + +The following command will insert that data and add an ``is_good`` column set to ``1`` for each dog:: + + $ sqlite-utils insert dogs.db dogs dogs.json --convert ' + row["is_good"] = 1 + return row' + +The ``--convert`` option also works with the ``--csv``, ``--tsv`` and ``--nl`` insert options. + +As with ``sqlite-utils convert`` you can use ``--import`` to import additional Python modules, see :ref:`cli_convert_import` for details. + +.. _cli_insert_convert_lines: + +\-\-convert with \-\-lines +-------------------------- + +Things work slightly differently when combined with the ``--lines`` or ``--text`` options. + +With ``--lines``, instead of being passed a ``row`` dictionary your function will be passed a ``line`` string representing each line of the input. Given a file called ``access.log`` containing the following:: + + INFO: 127.0.0.1:60581 - GET / HTTP/1.1 200 OK + INFO: 127.0.0.1:60581 - GET /foo/-/static/app.css?cead5a HTTP/1.1 200 OK + +You could convert it into structured data like so:: + + $ sqlite-utils insert logs.db loglines access.log --convert ' + type, ip, _, verb, path, _, status, _ = line.split() + return { + "type": type, + "ip": ip, + "verb": verb, + "path": path, + "status": status, + }' --lines + +The resulting table would look like this: + +====== =============== ====== ============================ ======== +type ip verb path status +====== =============== ====== ============================ ======== +INFO: 127.0.0.1:60581 GET / 200 +INFO: 127.0.0.1:60581 GET /foo/-/static/app.css?cead5a 200 +====== =============== ====== ============================ ======== + +.. _cli_insert_convert_text: + +\-\-convert with \-\-text +------------------------- + +With ``--text`` the entire input to the command will be made available to the function as a variable called ``text``. + +The function can return a single dictionary which will be inserted as a single row, or it can return a list or iterator of dictionaries, each of which will be inserted. + +Here's how to use ``--convert`` and ``--text`` to insert one record per word in the input:: + + $ echo 'A bunch of words' | sqlite-utils insert words.db words - \ + --text --convert '({"word": w} for w in text.split())' + +The result looks like this:: + + $ sqlite-utils dump words.db + BEGIN TRANSACTION; + CREATE TABLE [words] ( + [word] TEXT + ); + INSERT INTO "words" VALUES('A'); + INSERT INTO "words" VALUES('bunch'); + INSERT INTO "words" VALUES('of'); + INSERT INTO "words" VALUES('words'); + COMMIT; + + .. _cli_insert_replace: Insert-replacing data @@ -1046,18 +1135,6 @@ The code you provide will be compiled into a function that takes ``value`` as a value = str(value) return value.upper()' -You can specify Python modules that should be imported and made available to your code using one or more ``--import`` options. This example uses the ``textwrap`` module to wrap the ``content`` column at 100 characters:: - - $ sqlite-utils convert content.db articles content \ - '"\n".join(textwrap.wrap(value, 100))' \ - --import=textwrap - -This supports nested imports as well, for example to use `ElementTree `__:: - - $ sqlite-utils convert content.db articles content \ - 'xml.etree.ElementTree.fromstring(value).attrib["title"]' \ - --import=xml.etree.ElementTree - Your code will be automatically wrapped in a function, but you can also define a function called ``convert(value)`` which will be called, if available:: $ sqlite-utils convert content.db articles headline ' @@ -1088,6 +1165,23 @@ You can include named parameters in your where clause and populate them using on The ``--dry-run`` option will output a preview of the conversion against the first ten rows, without modifying the database. +.. _cli_convert_import: + +Importing additional modules +---------------------------- + +You can specify Python modules that should be imported and made available to your code using one or more ``--import`` options. This example uses the ``textwrap`` module to wrap the ``content`` column at 100 characters:: + + $ sqlite-utils convert content.db articles content \ + '"\n".join(textwrap.wrap(value, 100))' \ + --import=textwrap + +This supports nested imports as well, for example to use `ElementTree `__:: + + $ sqlite-utils convert content.db articles content \ + 'xml.etree.ElementTree.fromstring(value).attrib["title"]' \ + --import=xml.etree.ElementTree + .. _cli_convert_recipes: sqlite-utils convert recipes diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 5b83dc5..ab27761 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -814,7 +814,16 @@ def insert_upsert_implementation( if lines: docs = (fn(doc["line"]) for doc in docs) elif text: - docs = (fn(doc["text"]) for doc in docs) + # Special case: this is allowed to be an iterable + text_value = list(docs)[0]["text"] + fn_return = fn(text_value) + if isinstance(fn_return, dict): + docs = [fn_return] + else: + try: + docs = iter(fn_return) + except TypeError: + raise click.ClickException("--convert must return dict or iterator") else: docs = (fn(doc) for doc in docs) diff --git a/tests/test_cli_insert.py b/tests/test_cli_insert.py index bb9aa9b..085f322 100644 --- a/tests/test_cli_insert.py +++ b/tests/test_cli_insert.py @@ -396,6 +396,26 @@ def test_insert_convert_text(db_path): assert rows == [{"text": "THIS IS TEXT\nWILL BE UPPER NOW"}] +def test_insert_convert_text_returning_iterator(db_path): + result = CliRunner().invoke( + cli.cli, + [ + "insert", + db_path, + "text", + "-", + "--text", + "--convert", + '({"word": w} for w in text.split())', + ], + input="A bunch of words", + ) + assert result.exit_code == 0, result.output + db = Database(db_path) + rows = list(db.query("select [word] from [text]")) + assert rows == [{"word": "A"}, {"word": "bunch"}, {"word": "of"}, {"word": "words"}] + + def test_insert_convert_lines(db_path): result = CliRunner().invoke( cli.cli, From a7b29bfaa99c34dc40414b4ad12bff9b78d70427 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 5 Jan 2022 22:28:29 -0800 Subject: [PATCH 046/563] Fixed bug with sqlite-utils upsert --detect-types, closes #362 --- sqlite_utils/cli.py | 1 + tests/test_cli.py | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index ab27761..624f47e 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -1045,6 +1045,7 @@ def upsert( not_null=not_null, default=default, encoding=encoding, + detect_types=detect_types, load_extension=load_extension, silent=silent, ) diff --git a/tests/test_cli.py b/tests/test_cli.py index c4c50a9..b789b1e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1974,6 +1974,24 @@ def test_insert_detect_types(tmpdir, option_or_env_var): _test() +@pytest.mark.parametrize("option", ("-d", "--detect-types")) +def test_upsert_detect_types(tmpdir, option): + db_path = str(tmpdir / "test.db") + data = "id,name,age,weight\n1,Cleo,6,45.5\n2,Dori,1,3.5" + result = CliRunner().invoke( + cli.cli, + ["upsert", db_path, "creatures", "-", "--csv", "--pk", "id"] + [option], + catch_exceptions=False, + input=data, + ) + assert result.exit_code == 0 + db = Database(db_path) + assert list(db["creatures"].rows) == [ + {"id": 1, "name": "Cleo", "age": 6, "weight": 45.5}, + {"id": 2, "name": "Dori", "age": 1, "weight": 3.5}, + ] + + @pytest.mark.parametrize( "input,expected", ( From 3d464893ee442c179a8e5015ffd7577f34f01adc Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 5 Jan 2022 22:55:35 -0800 Subject: [PATCH 047/563] Release 3.20 Refs #344, #353, #356, #362 --- docs/changelog.rst | 14 ++++++++++++++ setup.py | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index a30e7cf..f14870b 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -2,6 +2,20 @@ Changelog =========== +.. _v3_20: + +3.20 (2022-01-05) +----------------- + +- ``sqlite-utils insert ... --lines`` to insert the lines from a file into a table with a single ``line`` column, see :ref:`cli_insert_unstructured`. +- ``sqlite-utils insert ... --text`` to insert the contents of the file into a table with a single ``text`` column and a single row. +- ``sqlite-utils insert ... --convert`` allows a Python function to be provided that will be used to convert each row that is being inserted into the database. See :ref:`cli_insert_convert`, including details on special behavior when combined with ``--lines`` and ``--text``. (:issue:`356`) +- ``sqlite-utils convert`` now accepts a code value of ``-`` to read code from standard input. (:issue:`353`) +- ``sqlite-utils convert`` also now accepts code that defines a named ``convert(value)`` function, see :ref:`cli_convert`. +- ``db.supports_strict`` property showing if the database connection supports `SQLite strict tables `__. +- ``table.strict`` property (see :ref:`python_api_introspection_strict`) indicating if the table uses strict mode. (:issue:`344`) +- Fixed bug where ``sqlite-utils upsert ... --detect-types`` ignored the ``--detect-types`` option. (:issue:`362`) + .. _v3_19: 3.19 (2021-11-20) diff --git a/setup.py b/setup.py index 3cc7b0e..1c05f3e 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages import io import os -VERSION = "3.19" +VERSION = "3.20" def get_long_description(): From 6e46b9913411682f3a3ec66f4d58886c1db8654b Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 6 Jan 2022 10:01:35 -0800 Subject: [PATCH 048/563] Renamed ip to source in example code --- docs/cli.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/cli.rst b/docs/cli.rst index 6d687ec..0fe6669 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -948,10 +948,10 @@ With ``--lines``, instead of being passed a ``row`` dictionary your function wil You could convert it into structured data like so:: $ sqlite-utils insert logs.db loglines access.log --convert ' - type, ip, _, verb, path, _, status, _ = line.split() + type, source, _, verb, path, _, status, _ = line.split() return { "type": type, - "ip": ip, + "source": source, "verb": verb, "path": path, "status": status, @@ -960,7 +960,7 @@ You could convert it into structured data like so:: The resulting table would look like this: ====== =============== ====== ============================ ======== -type ip verb path status +type source verb path status ====== =============== ====== ============================ ======== INFO: 127.0.0.1:60581 GET / 200 INFO: 127.0.0.1:60581 GET /foo/-/static/app.css?cead5a 200 From a8f9cc6f64f299830834428509940d448b82b4ed Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 8 Jan 2022 13:16:34 -0800 Subject: [PATCH 049/563] Add test for chunks(), refs #364 --- sqlite_utils/db.py | 7 +------ sqlite_utils/utils.py | 7 +++++++ tests/test_utils.py | 15 +++++++++++++++ 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index 0ff056c..dfc4723 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -1,4 +1,5 @@ from .utils import ( + chunks, sqlite3, OperationalError, suggest_column_types, @@ -2995,12 +2996,6 @@ class View(Queryable): ) -def chunks(sequence, size): - iterator = iter(sequence) - for item in iterator: - yield itertools.chain([item], itertools.islice(iterator, size - 1)) - - def jsonify_if_needed(value): if isinstance(value, decimal.Decimal): return float(value) diff --git a/sqlite_utils/utils.py b/sqlite_utils/utils.py index caec976..3b4769a 100644 --- a/sqlite_utils/utils.py +++ b/sqlite_utils/utils.py @@ -3,6 +3,7 @@ import contextlib import csv import enum import io +import itertools import json import os from . import recipes @@ -306,3 +307,9 @@ def _compile_code(code, imports, variable="value"): globals[import_.split(".")[0]] = __import__(import_) exec(code_o, globals, locals) return locals["fn"] + + +def chunks(sequence, size): + iterator = iter(sequence) + for item in iterator: + yield itertools.chain([item], itertools.islice(iterator, size - 1)) diff --git a/tests/test_utils.py b/tests/test_utils.py index a44b2e0..8630e28 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -25,3 +25,18 @@ def test_decode_base64_values(input, expected, should_be_is): def test_find_spatialite(): spatialite = utils.find_spatialite() assert spatialite is None or isinstance(spatialite, str) + + +@pytest.mark.parametrize( + "size,expected", + ( + (1, [["a"], ["b"], ["c"], ["d"]]), + (2, [["a", "b"], ["c", "d"]]), + (3, [["a", "b", "c"], ["d"]]), + (4, [["a", "b", "c", "d"]]), + ), +) +def test_chunks(size, expected): + input = ["a", "b", "c", "d"] + chunks = list(map(list, utils.chunks(input, size))) + assert chunks == expected From 539f5ccd90371fa87f946018f8b77d55929e06db Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 8 Jan 2022 18:33:00 -0800 Subject: [PATCH 050/563] Support 'python -m sqlite_utils', closes #368 Refs #364 --- docs/cli.rst | 2 ++ sqlite_utils/__main__.py | 4 ++++ tests/test_cli.py | 11 +++++++++++ 3 files changed, 17 insertions(+) create mode 100644 sqlite_utils/__main__.py diff --git a/docs/cli.rst b/docs/cli.rst index 0fe6669..e9d2218 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -6,6 +6,8 @@ The ``sqlite-utils`` command-line tool can be used to manipulate SQLite databases in a number of different ways. +Once installed, the tool should be available as ``sqlite-utils``. It can also be run using ``python -m sqlite_utils``. + .. contents:: :local: .. _cli_query: diff --git a/sqlite_utils/__main__.py b/sqlite_utils/__main__.py new file mode 100644 index 0000000..98dcca0 --- /dev/null +++ b/sqlite_utils/__main__.py @@ -0,0 +1,4 @@ +from .cli import cli + +if __name__ == "__main__": + cli() diff --git a/tests/test_cli.py b/tests/test_cli.py index b789b1e..336ce7b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,6 +1,8 @@ from sqlite_utils import cli, Database from sqlite_utils.db import Index, ForeignKey from click.testing import CliRunner +import subprocess +import sys from unittest import mock import json import os @@ -2017,3 +2019,12 @@ def test_integer_overflow_error(tmpdir): "sql = INSERT INTO [items] ([bignumber]) VALUES (?);\n" "parameters = [34223049823094832094802398430298048240]\n" ) + + +def test_python_dash_m(): + "Tool can be run using python -m sqlite_utils" + result = subprocess.run( + [sys.executable, "-m", "sqlite_utils", "--help"], capture_output=True + ) + assert result.returncode == 0 + assert b"Commands for interacting with a SQLite database" in result.stdout From e0c476bc380744680c8b7675c24fb0e9f5ec6dcd Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 8 Jan 2022 18:37:53 -0800 Subject: [PATCH 051/563] Fix test for Python 3.6, refs #368 --- tests/test_cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 336ce7b..7afe9ca 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2024,7 +2024,7 @@ def test_integer_overflow_error(tmpdir): def test_python_dash_m(): "Tool can be run using python -m sqlite_utils" result = subprocess.run( - [sys.executable, "-m", "sqlite_utils", "--help"], capture_output=True + [sys.executable, "-m", "sqlite_utils", "--help"], stdout=subprocess.PIPE ) assert result.returncode == 0 assert b"Commands for interacting with a SQLite database" in result.stdout From 148e9c7aeea2486b0562814b82f152506bfb0dd5 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 9 Jan 2022 09:48:48 -0800 Subject: [PATCH 052/563] Use cog to maintain --fmt list, closes #373 --- .github/workflows/test.yml | 3 +++ docs/cli.rst | 36 +++++++++++++++++++++++++++++++++++- docs/contributing.rst | 4 ++++ setup.py | 2 +- 4 files changed, 43 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fec8cfa..1f92538 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,3 +41,6 @@ jobs: run: flake8 - name: Check formatting run: black . --check + - name: Check if cog needs to be run + run: | + cog --check README.md docs/*.rst diff --git a/docs/cli.rst b/docs/cli.rst index e9d2218..497e00e 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -176,7 +176,41 @@ You can use the ``--fmt`` option to specify different table formats, for example 2 2 Pancakes ==== ===== ======== -For a full list of table format options, run ``sqlite-utils query --help``. +Available ``--fmt`` options are: + +.. [[[cog + import tabulate + cog.out("\n" + "\n".join('- ``{}``'.format(t) for t in tabulate.tabulate_formats) + "\n\n") +.. ]]] + +- ``fancy_grid`` +- ``fancy_outline`` +- ``github`` +- ``grid`` +- ``html`` +- ``jira`` +- ``latex`` +- ``latex_booktabs`` +- ``latex_longtable`` +- ``latex_raw`` +- ``mediawiki`` +- ``moinmoin`` +- ``orgtbl`` +- ``pipe`` +- ``plain`` +- ``presto`` +- ``pretty`` +- ``psql`` +- ``rst`` +- ``simple`` +- ``textile`` +- ``tsv`` +- ``unsafehtml`` +- ``youtrack`` + +.. [[[end]]] + +This list can also be found by running ``sqlite-utils query --help``. .. _cli_query_raw: diff --git a/docs/contributing.rst b/docs/contributing.rst index ec1a4a7..c2d20ba 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -44,6 +44,10 @@ Then run ``make livehtml`` from the ``docs/`` directory to start a server on por cd docs make livehtml +The `cog `__ tool is used to maintain portions of the documentation. You can run it like so:: + + cog -r docs/*.rst + .. _contributing_linting: Linting and formatting diff --git a/setup.py b/setup.py index 1c05f3e..901725d 100644 --- a/setup.py +++ b/setup.py @@ -31,7 +31,7 @@ setup( "python-dateutil", ], extras_require={ - "test": ["pytest", "black", "hypothesis"], + "test": ["pytest", "black", "hypothesis", "cogapp"], "docs": ["sphinx_rtd_theme", "sphinx-autobuild", "codespell"], "mypy": [ "mypy", From b8c134059e89f0fa040b84fb7d0bda25b9a52759 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 9 Jan 2022 10:07:48 -0800 Subject: [PATCH 053/563] --fmt now implies --table, closes #374 --- docs/cli.rst | 6 ++---- sqlite_utils/cli.py | 13 ++++++++----- tests/test_cli.py | 21 ++++++++++++++++----- 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/docs/cli.rst b/docs/cli.rst index 497e00e..f56e382 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -168,7 +168,7 @@ You can use the ``--table`` option (or ``-t`` shortcut) to output query results You can use the ``--fmt`` option to specify different table formats, for example ``rst`` for reStructuredText:: - $ sqlite-utils dogs.db "select * from dogs" --table --fmt rst + $ sqlite-utils dogs.db "select * from dogs" --fmt rst ==== ===== ======== id age name ==== ===== ======== @@ -182,7 +182,6 @@ Available ``--fmt`` options are: import tabulate cog.out("\n" + "\n".join('- ``{}``'.format(t) for t in tabulate.tabulate_formats) + "\n\n") .. ]]] - - ``fancy_grid`` - ``fancy_outline`` - ``github`` @@ -207,7 +206,6 @@ Available ``--fmt`` options are: - ``tsv`` - ``unsafehtml`` - ``youtrack`` - .. [[[end]]] This list can also be found by running ``sqlite-utils query --help``. @@ -289,7 +287,7 @@ It takes all of the same output formatting options as :ref:`sqlite-utils query < $ sqlite-utils memory 'select sqlite_version()' --csv sqlite_version() 3.35.5 - $ sqlite-utils memory 'select sqlite_version()' --table --fmt grid + $ sqlite-utils memory 'select sqlite_version()' --fmt grid +--------------------+ | sqlite_version() | +====================+ diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 624f47e..2bd0278 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -81,7 +81,6 @@ def output_options(fn): help="Table format - one of {}".format( ", ".join(tabulate.tabulate_formats) ), - default="simple", ), click.option( "--json-cols", @@ -192,8 +191,8 @@ def tables( row.append(db[name].schema) yield row - if table: - print(tabulate.tabulate(_iter(), headers=headers, tablefmt=fmt)) + if table or fmt: + print(tabulate.tabulate(_iter(), headers=headers, tablefmt=fmt or "simple")) elif csv or tsv: writer = csv_std.writer(sys.stdout, dialect="excel-tab" if tsv else "excel") if not no_headers: @@ -1456,8 +1455,12 @@ def _execute_query( sys.stdout.buffer.write(data) else: sys.stdout.write(str(data)) - elif table: - print(tabulate.tabulate(list(cursor), headers=headers, tablefmt=fmt)) + elif fmt or table: + print( + tabulate.tabulate( + list(cursor), headers=headers, tablefmt=fmt or "simple" + ) + ) elif csv or tsv: writer = csv_std.writer(sys.stdout, dialect="excel-tab" if tsv else "excel") if not no_headers: diff --git a/tests/test_cli.py b/tests/test_cli.py index 7afe9ca..5a9ca65 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -114,10 +114,10 @@ def test_tables_schema(db_path): @pytest.mark.parametrize( - "fmt,expected", + "options,expected", [ ( - "simple", + ["--fmt", "simple"], ( "c1 c2 c3\n" "----- ----- ----------\n" @@ -128,7 +128,18 @@ def test_tables_schema(db_path): ), ), ( - "rst", + ["-t"], + ( + "c1 c2 c3\n" + "----- ----- ----------\n" + "verb0 noun0 adjective0\n" + "verb1 noun1 adjective1\n" + "verb2 noun2 adjective2\n" + "verb3 noun3 adjective3" + ), + ), + ( + ["--fmt", "rst"], ( "===== ===== ==========\n" "c1 c2 c3\n" @@ -142,7 +153,7 @@ def test_tables_schema(db_path): ), ], ) -def test_output_table(db_path, fmt, expected): +def test_output_table(db_path, options, expected): db = Database(db_path) with db.conn: db["rows"].insert_all( @@ -155,7 +166,7 @@ def test_output_table(db_path, fmt, expected): for i in range(4) ] ) - result = CliRunner().invoke(cli.cli, ["rows", db_path, "rows", "-t", "--fmt", fmt]) + result = CliRunner().invoke(cli.cli, ["rows", db_path, "rows"] + options) assert 0 == result.exit_code assert expected == result.output.strip() From 22c8d10dd343476e8b7b9af3366fae4c8353dd2c Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 9 Jan 2022 12:06:02 -0800 Subject: [PATCH 054/563] --convert function can now modify row in place, closes #371 --- docs/cli.rst | 4 +--- sqlite_utils/cli.py | 8 ++------ sqlite_utils/utils.py | 24 +++++++++++++++++------- tests/test_cli_convert.py | 32 ++++++++++---------------------- tests/test_cli_insert.py | 19 +++++++++++++++++++ 5 files changed, 49 insertions(+), 38 deletions(-) diff --git a/docs/cli.rst b/docs/cli.rst index f56e382..233a4c0 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -959,9 +959,7 @@ Given a JSON file called ``dogs.json`` containing this: The following command will insert that data and add an ``is_good`` column set to ``1`` for each dog:: - $ sqlite-utils insert dogs.db dogs dogs.json --convert ' - row["is_good"] = 1 - return row' + $ sqlite-utils insert dogs.db dogs dogs.json --convert 'row["is_good"] = 1` The ``--convert`` option also works with the ``--csv``, ``--tsv`` and ``--nl`` insert options. diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 2bd0278..212a039 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -824,7 +824,7 @@ def insert_upsert_implementation( except TypeError: raise click.ClickException("--convert must return dict or iterator") else: - docs = (fn(doc) for doc in docs) + docs = (fn(doc) or doc for doc in docs) extra_kwargs = {"ignore": ignore, "replace": replace, "truncate": truncate} if not_null: @@ -2225,11 +2225,7 @@ def convert( try: fn = _compile_code(code, imports) except SyntaxError as e: - raise click.ClickException( - "Syntax error in code:\n\n{}\n\n{}".format( - textwrap.indent(e.text.strip(), " "), e.msg - ) - ) + raise click.ClickException(str(e)) if dry_run: # Pull first 20 values for first column and preview them db.conn.create_function("preview_transform", 1, lambda v: fn(v) if v else v) diff --git a/sqlite_utils/utils.py b/sqlite_utils/utils.py index 3b4769a..0777f30 100644 --- a/sqlite_utils/utils.py +++ b/sqlite_utils/utils.py @@ -293,15 +293,25 @@ def _compile_code(code, imports, variable="value"): pass # Try compiling their code as a function instead - - # If single line and no 'return', add the return + body_variants = [code] + # If single line and no 'return', try adding the return if "\n" not in code and not code.strip().startswith("return "): - code = "return {}".format(code) + body_variants.insert(0, "return {}".format(code)) - new_code = ["def fn({}):".format(variable)] - for line in code.split("\n"): - new_code.append(" {}".format(line)) - code_o = compile("\n".join(new_code), "", "exec") + code_o = None + for variant in body_variants: + new_code = ["def fn({}):".format(variable)] + for line in variant.split("\n"): + new_code.append(" {}".format(line)) + try: + code_o = compile("\n".join(new_code), "", "exec") + break + except SyntaxError: + # Try another variant, e.g. for 'return row["column"] = 1' + continue + + if code_o is None: + raise SyntaxError("Could not compile code") for import_ in imports: globals[import_.split(".")[0]] = __import__(import_) diff --git a/tests/test_cli_convert.py b/tests/test_cli_convert.py index 481bebd..ec8110f 100644 --- a/tests/test_cli_convert.py +++ b/tests/test_cli_convert.py @@ -55,32 +55,20 @@ def test_convert_code(fresh_db_and_path, code): @pytest.mark.parametrize( - "bad_code,expected_error", - [ - ( - "def foo(value)", - """Error: Syntax error in code: - - return def foo(value) - -invalid syntax""", - ), - ( - "$", - """Error: Syntax error in code: - - return $ - -invalid syntax""", - ), - ], + "bad_code", + ( + "def foo(value)", + "$", + ), ) -def test_convert_code_errors(fresh_db_and_path, bad_code, expected_error): +def test_convert_code_errors(fresh_db_and_path, bad_code): db, db_path = fresh_db_and_path db["t"].insert({"text": "October"}) - result = CliRunner().invoke(cli.cli, ["convert", db_path, "t", "text", bad_code]) + result = CliRunner().invoke( + cli.cli, ["convert", db_path, "t", "text", bad_code], catch_exceptions=False + ) assert 1 == result.exit_code - assert result.output.strip() == expected_error.strip() + assert result.output == "Error: Could not compile code\n" def test_convert_import(test_db_and_path): diff --git a/tests/test_cli_insert.py b/tests/test_cli_insert.py index 085f322..9c218cc 100644 --- a/tests/test_cli_insert.py +++ b/tests/test_cli_insert.py @@ -434,3 +434,22 @@ def test_insert_convert_lines(db_path): db = Database(db_path) rows = list(db.query("select [line] from [all]")) assert rows == [{"line": "THIS IS TEXT"}, {"line": "WILL BE UPPER NOW"}] + + +def test_insert_convert_row_modifying_in_place(db_path): + result = CliRunner().invoke( + cli.cli, + [ + "insert", + db_path, + "rows", + "-", + "--convert", + 'row["is_chicken"] = True', + ], + input='{"name": "Azi"}', + ) + assert result.exit_code == 0, result.output + db = Database(db_path) + rows = list(db.query("select name, is_chicken from rows")) + assert rows == [{"name": "Azi", "is_chicken": 1}] From 49a54ffb2fb6d3b73522c96c2bf9fc722e99d036 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 9 Jan 2022 12:08:03 -0800 Subject: [PATCH 055/563] Fix for cog error Should help tests pass for #374, #371 --- docs/cli.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/cli.rst b/docs/cli.rst index 233a4c0..0346d99 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -182,6 +182,7 @@ Available ``--fmt`` options are: import tabulate cog.out("\n" + "\n".join('- ``{}``'.format(t) for t in tabulate.tabulate_formats) + "\n\n") .. ]]] + - ``fancy_grid`` - ``fancy_outline`` - ``github`` @@ -206,6 +207,7 @@ Available ``--fmt`` options are: - ``tsv`` - ``unsafehtml`` - ``youtrack`` + .. [[[end]]] This list can also be found by running ``sqlite-utils query --help``. From c9ecd0d6a32d4518c9b92bcc08183a10268d52d7 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 9 Jan 2022 12:16:52 -0800 Subject: [PATCH 056/563] stem and suffix columns for insert-files, closes #372 --- docs/cli.rst | 4 ++++ sqlite_utils/cli.py | 2 ++ tests/test_insert_files.py | 18 ++++++++++++++---- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/docs/cli.rst b/docs/cli.rst index 0346d99..b92277b 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -1141,6 +1141,10 @@ The full list of column definitions you can use is as follows: The creation time is an ISO timestamp ``size`` The integer size of the file in bytes +``stem`` + The filename without the extension - for ``file.txt.gz`` this would be ``file.txt`` +``extension`` + The file extension - for ``file.txt.gz`` this would be ``.gz`` You can insert data piped from standard input like this:: diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 212a039..1bc797e 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -2300,6 +2300,8 @@ FILE_COLUMNS = { "mtime_iso": lambda p: datetime.utcfromtimestamp(p.stat().st_mtime).isoformat(), "ctime_iso": lambda p: datetime.utcfromtimestamp(p.stat().st_ctime).isoformat(), "size": lambda p: p.stat().st_size, + "stem": lambda p: p.stem, + "suffix": lambda p: p.suffix, } diff --git a/tests/test_insert_files.py b/tests/test_insert_files.py index 86ee4e3..d7af28e 100644 --- a/tests/test_insert_files.py +++ b/tests/test_insert_files.py @@ -15,7 +15,7 @@ def test_insert_files(silent): (tmpdir / "one.txt").write_text("This is file one", "utf-8") (tmpdir / "two.txt").write_text("Two is shorter", "utf-8") (tmpdir / "nested").mkdir() - (tmpdir / "nested" / "three.txt").write_text("Three is nested", "utf-8") + (tmpdir / "nested" / "three.zz.txt").write_text("Three is nested", "utf-8") coltypes = ( "name", "path", @@ -32,6 +32,8 @@ def test_insert_files(silent): "mtime_iso", "ctime_iso", "size", + "suffix", + "stem", ) cols = [] for coltype in coltypes: @@ -50,7 +52,7 @@ def test_insert_files(silent): one, two, three = ( rows_by_path["one.txt"], rows_by_path["two.txt"], - rows_by_path[os.path.join("nested", "three.txt")], + rows_by_path[os.path.join("nested", "three.zz.txt")], ) assert { "content": b"This is file one", @@ -60,6 +62,8 @@ def test_insert_files(silent): "path": "one.txt", "sha256": "e34138f26b5f7368f298b4e736fea0aad87ddec69fbd04dc183b20f4d844bad5", "size": 16, + "stem": "one", + "suffix": ".txt", }.items() <= one.items() assert { "content": b"Two is shorter", @@ -69,15 +73,19 @@ def test_insert_files(silent): "path": "two.txt", "sha256": "9368988ed16d4a2da0af9db9b686d385b942cb3ffd4e013f43aed2ec041183d9", "size": 14, + "stem": "two", + "suffix": ".txt", }.items() <= two.items() assert { "content": b"Three is nested", "content_text": "Three is nested", "md5": "12580f341781f5a5b589164d3cd39523", - "name": "three.txt", - "path": os.path.join("nested", "three.txt"), + "name": "three.zz.txt", + "path": os.path.join("nested", "three.zz.txt"), "sha256": "6dd45aaaaa6b9f96af19363a92c8fca5d34791d3c35c44eb19468a6a862cc8cd", "size": 15, + "stem": "three.zz", + "suffix": ".txt", }.items() <= three.items() # Assert the other int/str/float columns exist and are of the right types expected_types = { @@ -91,6 +99,8 @@ def test_insert_files(silent): "fullpath": str, "content": bytes, "content_text": str, + "stem": str, + "suffix": str, } for colname, expected_type in expected_types.items(): for row in (one, two, three): From f08fe6fd4d5df4fe1e638118707c98e1add80caf Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 9 Jan 2022 12:18:31 -0800 Subject: [PATCH 057/563] Fixed error in docs: it's suffix not extension, refs #372 --- docs/cli.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cli.rst b/docs/cli.rst index b92277b..e07c73d 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -1143,7 +1143,7 @@ The full list of column definitions you can use is as follows: The integer size of the file in bytes ``stem`` The filename without the extension - for ``file.txt.gz`` this would be ``file.txt`` -``extension`` +``suffix`` The file extension - for ``file.txt.gz`` this would be ``.gz`` You can insert data piped from standard input like this:: From 1d64cd2e5b402ff957f9be2d9bb490d313c73989 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 9 Jan 2022 12:33:16 -0800 Subject: [PATCH 058/563] sqlite-utils create-database command, closes #348 --- docs/cli.rst | 13 +++++++++++++ sqlite_utils/cli.py | 17 +++++++++++++++++ tests/test_cli.py | 18 ++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/docs/cli.rst b/docs/cli.rst index e07c73d..36ff029 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -699,6 +699,19 @@ The ``most_common`` and ``least_common`` columns will contain nested JSON arrays ["Condarco", 4288] ] +.. _cli_create_database: + +Creating an empty database +========================== + +You can create a new empty database file using the ``create-database`` command:: + + $ sqlite-utils create-database empty.db + +To enable :ref:`cli_wal` on the newly created database add the ``--enable-wal`` option:: + + $ sqlite-utils create-database empty.db --enable-wal + .. _cli_inserting_data: Inserting JSON data diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 1bc797e..315b162 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -1052,6 +1052,23 @@ def upsert( raise click.ClickException(UNICODE_ERROR.format(ex)) +@cli.command(name="create-database") +@click.argument( + "path", + type=click.Path(file_okay=True, dir_okay=False, allow_dash=False), + required=True, +) +@click.option( + "--enable-wal", is_flag=True, help="Enable WAL mode on the created database" +) +def create_table(path, enable_wal): + "Create a new empty database file." + db = sqlite_utils.Database(path) + if enable_wal: + db.enable_wal() + db.vacuum() + + @cli.command(name="create-table") @click.argument( "path", diff --git a/tests/test_cli.py b/tests/test_cli.py index 5a9ca65..ca5c820 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2039,3 +2039,21 @@ def test_python_dash_m(): ) assert result.returncode == 0 assert b"Commands for interacting with a SQLite database" in result.stdout + + +@pytest.mark.parametrize("enable_wal", (False, True)) +def test_create_database(tmpdir, enable_wal): + db_path = tmpdir / "test.db" + assert not db_path.exists() + args = ["create-database", str(db_path)] + if enable_wal: + args.append("--enable-wal") + result = CliRunner().invoke(cli.cli, args) + assert result.exit_code == 0, result.output + assert db_path.exists() + assert db_path.read_binary()[:16] == b"SQLite format 3\x00" + db = Database(str(db_path)) + if enable_wal: + assert db.journal_mode == "wal" + else: + assert db.journal_mode == "delete" From 2f8879235afc6a06a8ae25ded1b2fe289ad8c3a6 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 9 Jan 2022 12:39:14 -0800 Subject: [PATCH 059/563] Renamed function to fix lint error, refs #348 --- sqlite_utils/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 315b162..1e31088 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -1061,7 +1061,7 @@ def upsert( @click.option( "--enable-wal", is_flag=True, help="Enable WAL mode on the created database" ) -def create_table(path, enable_wal): +def create_database(path, enable_wal): "Create a new empty database file." db = sqlite_utils.Database(path) if enable_wal: From d2a79d200f9071a86027365fa2a576865b71064f Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 9 Jan 2022 20:12:39 -0800 Subject: [PATCH 060/563] --nl now ignores blank lines, closes #376 --- sqlite_utils/cli.py | 2 +- tests/test_cli_insert.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 1e31088..b235068 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -791,7 +791,7 @@ def insert_upsert_implementation( else: try: if nl: - docs = (json.loads(line) for line in decoded) + docs = (json.loads(line) for line in decoded if line.strip()) else: docs = json.load(decoded) if isinstance(docs, dict): diff --git a/tests/test_cli_insert.py b/tests/test_cli_insert.py index 9c218cc..c40dd85 100644 --- a/tests/test_cli_insert.py +++ b/tests/test_cli_insert.py @@ -165,7 +165,7 @@ def test_insert_newline_delimited(db_path): result = CliRunner().invoke( cli.cli, ["insert", db_path, "from_json_nl", "-", "--nl"], - input='{"foo": "bar", "n": 1}\n{"foo": "baz", "n": 2}', + input='{"foo": "bar", "n": 1}\n\n{"foo": "baz", "n": 2}', ) assert 0 == result.exit_code, result.output db = Database(db_path) From cfb3f1235848d000ba8609bf84e634bf56ac8291 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 9 Jan 2022 20:39:58 -0800 Subject: [PATCH 061/563] Only buffer input if --sniff, closes #364 --- sqlite_utils/cli.py | 13 ++++++++++--- tests/test_cli_insert.py | 41 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index b235068..05a7bcc 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -757,13 +757,20 @@ def insert_upsert_implementation( if pk and len(pk) == 1: pk = pk[0] encoding = encoding or "utf-8-sig" - buffered = io.BufferedReader(file, buffer_size=4096) - decoded = io.TextIOWrapper(buffered, encoding=encoding) + + # The --sniff option needs us to buffer the file to peek ahead + sniff_buffer = None + if sniff: + sniff_buffer = io.BufferedReader(file, buffer_size=4096) + decoded = io.TextIOWrapper(sniff_buffer, encoding=encoding) + else: + decoded = io.TextIOWrapper(file, encoding=encoding) + tracker = None if csv or tsv: if sniff: # Read first 2048 bytes and use that to detect - first_bytes = buffered.peek(2048) + first_bytes = sniff_buffer.peek(2048) dialect = csv_std.Sniffer().sniff(first_bytes.decode(encoding, "ignore")) else: dialect = "excel-tab" if tsv else "excel" diff --git a/tests/test_cli_insert.py b/tests/test_cli_insert.py index c40dd85..675e9eb 100644 --- a/tests/test_cli_insert.py +++ b/tests/test_cli_insert.py @@ -2,6 +2,9 @@ from sqlite_utils import cli, Database from click.testing import CliRunner import json import pytest +import subprocess +import sys +import time def test_insert_simple(tmpdir): @@ -453,3 +456,41 @@ def test_insert_convert_row_modifying_in_place(db_path): db = Database(db_path) rows = list(db.query("select name, is_chicken from rows")) assert rows == [{"name": "Azi", "is_chicken": 1}] + + +def test_insert_streaming_batch_size_1(db_path): + # https://github.com/simonw/sqlite-utils/issues/364 + # Streaming with --batch-size 1 should commit on each record + # Can't use CliRunner().invoke() here bacuse we need to + # run assertions in between writing to process stdin + # First, create the DB with WAL mode enabled + CliRunner().invoke(cli.cli, ["create-database", db_path, "--enable-wal"]) + # Now start streaming rows to insert --nl + proc = subprocess.Popen( + [ + sys.executable, + "-m", + "sqlite_utils", + "insert", + db_path, + "rows", + "-", + "--nl", + "--batch-size", + "1", + ], + stdin=subprocess.PIPE, + stdout=sys.stdout, + ) + proc.stdin.write(b'{"name": "Azi"}\n') + proc.stdin.flush() + # Without this delay the data wasn't yet visible + time.sleep(0.2) + assert list(Database(db_path)["rows"].rows) == [{"name": "Azi"}] + proc.stdin.write(b'{"name": "Suna"}\n') + proc.stdin.flush() + time.sleep(0.2) + assert list(Database(db_path)["rows"].rows) == [{"name": "Azi"}, {"name": "Suna"}] + proc.stdin.close() + proc.wait() + assert proc.returncode == 0 From e6ae643497803e51379f82881f4df2c734ef97f3 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 9 Jan 2022 20:41:00 -0800 Subject: [PATCH 062/563] Did not need WAL after all, refs #364 --- tests/test_cli_insert.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/test_cli_insert.py b/tests/test_cli_insert.py index 675e9eb..3ef6ffe 100644 --- a/tests/test_cli_insert.py +++ b/tests/test_cli_insert.py @@ -463,9 +463,6 @@ def test_insert_streaming_batch_size_1(db_path): # Streaming with --batch-size 1 should commit on each record # Can't use CliRunner().invoke() here bacuse we need to # run assertions in between writing to process stdin - # First, create the DB with WAL mode enabled - CliRunner().invoke(cli.cli, ["create-database", db_path, "--enable-wal"]) - # Now start streaming rows to insert --nl proc = subprocess.Popen( [ sys.executable, From 046e5246c9698a6fc9901ca265ae47c68fcf5d13 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 9 Jan 2022 20:51:07 -0800 Subject: [PATCH 063/563] Longer delay to hopefully get test to pass, refs #364 --- tests/test_cli_insert.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_cli_insert.py b/tests/test_cli_insert.py index 3ef6ffe..346bb38 100644 --- a/tests/test_cli_insert.py +++ b/tests/test_cli_insert.py @@ -482,11 +482,11 @@ def test_insert_streaming_batch_size_1(db_path): proc.stdin.write(b'{"name": "Azi"}\n') proc.stdin.flush() # Without this delay the data wasn't yet visible - time.sleep(0.2) + time.sleep(0.4) assert list(Database(db_path)["rows"].rows) == [{"name": "Azi"}] proc.stdin.write(b'{"name": "Suna"}\n') proc.stdin.flush() - time.sleep(0.2) + time.sleep(0.4) assert list(Database(db_path)["rows"].rows) == [{"name": "Azi"}, {"name": "Suna"}] proc.stdin.close() proc.wait() From b6dad08a8389736b7e960cfe9bc719cfc21a98f5 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 9 Jan 2022 21:04:51 -0800 Subject: [PATCH 064/563] Keep trying up to ten times, refs #364 --- tests/test_cli_insert.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/tests/test_cli_insert.py b/tests/test_cli_insert.py index 346bb38..efa55bd 100644 --- a/tests/test_cli_insert.py +++ b/tests/test_cli_insert.py @@ -481,13 +481,22 @@ def test_insert_streaming_batch_size_1(db_path): ) proc.stdin.write(b'{"name": "Azi"}\n') proc.stdin.flush() - # Without this delay the data wasn't yet visible - time.sleep(0.4) - assert list(Database(db_path)["rows"].rows) == [{"name": "Azi"}] + + def try_until(expected): + tries = 0 + while True: + rows = list(Database(db_path)["rows"].rows) + if rows == expected: + return + tries += 1 + if tries > 10: + assert False, "Expected {}, got {}".format(expected, rows) + time.sleep(tries * 0.1) + + try_until([{"name": "Azi"}]) proc.stdin.write(b'{"name": "Suna"}\n') proc.stdin.flush() - time.sleep(0.4) - assert list(Database(db_path)["rows"].rows) == [{"name": "Azi"}, {"name": "Suna"}] + try_until([{"name": "Azi"}, {"name": "Suna"}]) proc.stdin.close() proc.wait() assert proc.returncode == 0 From 541f64ddb0513cd8fe7a84abc8ee218e36ef9ca6 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 10 Jan 2022 11:48:38 -0800 Subject: [PATCH 065/563] db.analyze() and table.analyze() methods, refs #366 --- docs/python-api.rst | 27 ++++++++++++++++++++++++++ sqlite_utils/db.py | 11 +++++++++++ tests/test_analyze.py | 45 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+) create mode 100644 tests/test_analyze.py diff --git a/docs/python-api.rst b/docs/python-api.rst index 5d391ee..791b9c5 100644 --- a/docs/python-api.rst +++ b/docs/python-api.rst @@ -2204,6 +2204,33 @@ You can create a unique index by passing ``unique=True``: Use ``if_not_exists=True`` to do nothing if an index with that name already exists. +.. _python_api_analyze: + +Optimizing index usage with ANALYZE +=================================== + +The `SQLite ANALYZE command `__ can be used to build a table of statistics which the query planner can then use to make better decisions about which indexes to use for a given query. + +You should run ``ANALYZE`` if your database is large and you do not think your indexes are being efficiently used. + +To run ``ANALYZE`` against every index in a database, use this: + +.. code-block:: python + + db.analyze() + +To run it just against a specific named index, pass the name of the index to that method: + +.. code-block:: python + + db.analyze("idx_countries_country_name") + +To run against all indexes attached to a specific table, you can either pass the table name to ``db.analyze(...)`` or you can call the method directly on the table, like this: + +.. code-block:: python + + db["dogs"].analyze() + .. _python_api_vacuum: Vacuum diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index dfc4723..1348b4a 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -923,6 +923,13 @@ class Database: "Run a SQLite ``VACUUM`` against the database." self.execute("VACUUM;") + def analyze(self, name=None): + "Run ``ANALYZE`` against the entire database or a named table or index." + sql = "ANALYZE" + if name is not None: + sql += " [{}]".format(name) + self.execute(sql) + class Queryable: def exists(self) -> bool: @@ -2902,6 +2909,10 @@ class Table(Queryable): ) return self + def analyze(self): + "Run ANALYZE against this table" + self.db.analyze(self.name) + def analyze_column( self, column: str, common_limit: int = 10, value_truncate=None, total_rows=None ) -> "ColumnDetails": diff --git a/tests/test_analyze.py b/tests/test_analyze.py new file mode 100644 index 0000000..a47c8be --- /dev/null +++ b/tests/test_analyze.py @@ -0,0 +1,45 @@ +import pytest + + +@pytest.fixture +def db(fresh_db): + fresh_db["one_index"].insert({"id": 1, "name": "Cleo"}, pk="id") + fresh_db["one_index"].create_index(["name"]) + fresh_db["two_indexes"].insert({"id": 1, "name": "Cleo", "species": "dog"}, pk="id") + fresh_db["two_indexes"].create_index(["name"]) + fresh_db["two_indexes"].create_index(["species"]) + return fresh_db + + +def test_analyze_whole_database(db): + assert set(db.table_names()) == {"one_index", "two_indexes"} + db.analyze() + assert set(db.table_names()) == {"one_index", "two_indexes", "sqlite_stat1"} + assert list(db["sqlite_stat1"].rows) == [ + {"tbl": "two_indexes", "idx": "idx_two_indexes_species", "stat": "1 1"}, + {"tbl": "two_indexes", "idx": "idx_two_indexes_name", "stat": "1 1"}, + {"tbl": "one_index", "idx": "idx_one_index_name", "stat": "1 1"}, + ] + + +@pytest.mark.parametrize("method", ("db_method_with_name", "table_method")) +def test_analyze_one_table(db, method): + assert set(db.table_names()) == {"one_index", "two_indexes"} + if method == "db_method_with_name": + db.analyze("one_index") + elif method == "table_method": + db["one_index"].analyze() + + assert set(db.table_names()) == {"one_index", "two_indexes", "sqlite_stat1"} + assert list(db["sqlite_stat1"].rows) == [ + {"tbl": "one_index", "idx": "idx_one_index_name", "stat": "1 1"} + ] + + +def test_analyze_index_by_name(db): + assert set(db.table_names()) == {"one_index", "two_indexes"} + db.analyze("idx_two_indexes_species") + assert set(db.table_names()) == {"one_index", "two_indexes", "sqlite_stat1"} + assert list(db["sqlite_stat1"].rows) == [ + {"tbl": "two_indexes", "idx": "idx_two_indexes_species", "stat": "1 1"}, + ] From 0d10402f7b0428c6bb275a106b628298c6d0201d Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 10 Jan 2022 12:00:24 -0800 Subject: [PATCH 066/563] table.create_index(..., analyze=True), refs #378 --- docs/python-api.rst | 2 ++ sqlite_utils/db.py | 12 +++++++++--- tests/test_create.py | 8 ++++++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/docs/python-api.rst b/docs/python-api.rst index 791b9c5..66e6100 100644 --- a/docs/python-api.rst +++ b/docs/python-api.rst @@ -2204,6 +2204,8 @@ You can create a unique index by passing ``unique=True``: Use ``if_not_exists=True`` to do nothing if an index with that name already exists. +Pass ``analyze=True`` to run ``ANALYZE`` against the new index after creating it. + .. _python_api_analyze: Optimizing index usage with ANALYZE diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index 1348b4a..6a68c1b 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -1554,6 +1554,7 @@ class Table(Queryable): unique: bool = False, if_not_exists: bool = False, find_unique_name: bool = False, + analyze: bool = False, ): """ Create an index on this table. @@ -1565,6 +1566,7 @@ class Table(Queryable): - ``if_not_exists`` - only create the index if one with that name does not already exist. - ``find_unique_name`` - if ``index_name`` is not provided and the automatically derived name already exists, keep incrementing a suffix number to find an available name. + - ``analyze`` - run ``ANALYZE`` against this index after creating it. See :ref:`python_api_create_index`. """ @@ -1581,7 +1583,11 @@ class Table(Queryable): columns_sql.append(fmt.format(column)) suffix = None + created_index_name = None while True: + created_index_name = ( + "{}_{}".format(index_name, suffix) if suffix else index_name + ) sql = ( textwrap.dedent( """ @@ -1591,9 +1597,7 @@ class Table(Queryable): ) .strip() .format( - index_name="{}_{}".format(index_name, suffix) - if suffix - else index_name, + index_name=created_index_name, table_name=self.name, columns=", ".join(columns_sql), unique="UNIQUE " if unique else "", @@ -1618,6 +1622,8 @@ class Table(Queryable): continue else: raise e + if analyze: + self.db.analyze(created_index_name) return self def add_column( diff --git a/tests/test_create.py b/tests/test_create.py index 187d20a..f5d9a12 100644 --- a/tests/test_create.py +++ b/tests/test_create.py @@ -774,6 +774,14 @@ def test_create_index_find_unique_name(fresh_db): assert index_names == {"idx_t_id", "idx_t_id_2", "idx_t_id_3"} +def test_create_index_analyze(fresh_db): + dogs = fresh_db["dogs"] + assert "sqlite_stat1" not in fresh_db.table_names() + dogs.insert({"name": "Cleo", "twitter": "cleopaws"}) + dogs.create_index(["name"], analyze=True) + assert "sqlite_stat1" in fresh_db.table_names() + + @pytest.mark.parametrize( "data_structure", ( From 0142c2a3c2772cc370c734e7e6049e8cc2343a5f Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 10 Jan 2022 12:02:08 -0800 Subject: [PATCH 067/563] Improved test_create_index_analyze test, refs #378 --- tests/test_create.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_create.py b/tests/test_create.py index f5d9a12..1906f10 100644 --- a/tests/test_create.py +++ b/tests/test_create.py @@ -780,6 +780,9 @@ def test_create_index_analyze(fresh_db): dogs.insert({"name": "Cleo", "twitter": "cleopaws"}) dogs.create_index(["name"], analyze=True) assert "sqlite_stat1" in fresh_db.table_names() + assert list(fresh_db["sqlite_stat1"].rows) == [ + {"tbl": "dogs", "idx": "idx_dogs_name", "stat": "1 1"} + ] @pytest.mark.parametrize( From ab392157f7c89e1596b480649e2f7195f838da29 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 10 Jan 2022 17:00:34 -0800 Subject: [PATCH 068/563] analyze=True for insert_all/upsert_all, refs #378 --- docs/python-api.rst | 5 +++-- sqlite_utils/db.py | 8 ++++++++ tests/test_create.py | 17 +++++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/docs/python-api.rst b/docs/python-api.rst index 66e6100..a8fd786 100644 --- a/docs/python-api.rst +++ b/docs/python-api.rst @@ -638,8 +638,9 @@ The function can accept an iterator or generator of rows and will commit them ac You can skip inserting any records that have a primary key that already exists using ``ignore=True``. This works with both ``.insert({...}, ignore=True)`` and ``.insert_all([...], ignore=True)``. -You can delete all the existing rows in the table before inserting the new -records using ``truncate=True``. This is useful if you want to replace the data in the table. +You can delete all the existing rows in the table before inserting the new records using ``truncate=True``. This is useful if you want to replace the data in the table. + +Pass ``analyze=True`` to run ``ANALYZE`` against the table after inserting the new records. .. _python_api_insert_replace: diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index 6a68c1b..6d3bcf9 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -2575,10 +2575,13 @@ class Table(Queryable): conversions=DEFAULT, columns=DEFAULT, upsert=False, + analyze=False, ) -> "Table": """ Like ``.insert()`` but takes a list of records and ensures that the table that it creates (if table does not exist) has columns for ALL of that data. + + Use ``analyze=True`` to run ``ANALYZE`` after the insert has completed. """ pk = self.value_or_default("pk", pk) foreign_keys = self.value_or_default("foreign_keys", foreign_keys) @@ -2671,6 +2674,9 @@ class Table(Queryable): ignore, ) + if analyze: + self.analyze() + return self def upsert( @@ -2721,6 +2727,7 @@ class Table(Queryable): extracts=DEFAULT, conversions=DEFAULT, columns=DEFAULT, + analyze=False, ) -> "Table": """ Like ``.upsert()`` but can be applied to a list of records. @@ -2739,6 +2746,7 @@ class Table(Queryable): conversions=conversions, columns=columns, upsert=True, + analyze=analyze, ) def add_missing_columns(self, records: Iterable[Dict[str, Any]]) -> "Table": diff --git a/tests/test_create.py b/tests/test_create.py index 1906f10..82c2c72 100644 --- a/tests/test_create.py +++ b/tests/test_create.py @@ -1028,6 +1028,23 @@ def test_insert_all_single_column(fresh_db): assert table.pks == ["name"] +@pytest.mark.parametrize("method_name", ("insert_all", "upsert_all")) +def test_insert_all_analyze(fresh_db, method_name): + table = fresh_db["table"] + table.insert_all([{"id": 1, "name": "Cleo"}], pk="id") + assert "sqlite_stat1" not in fresh_db.table_names() + table.create_index(["name"], analyze=True) + assert list(fresh_db["sqlite_stat1"].rows) == [ + {"tbl": "table", "idx": "idx_table_name", "stat": "1 1"} + ] + method = getattr(table, method_name) + method([{"id": 2, "name": "Suna"}], pk="id", analyze=True) + assert "sqlite_stat1" in fresh_db.table_names() + assert list(fresh_db["sqlite_stat1"].rows) == [ + {"tbl": "table", "idx": "idx_table_name", "stat": "2 1"} + ] + + def test_create_with_a_null_column(fresh_db): record = {"name": "Name", "description": None} fresh_db["t"].insert(record) From 389cbd57924da5886a7700c6802d55a934523a29 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 10 Jan 2022 17:08:05 -0800 Subject: [PATCH 069/563] delete_where(analyze=True), closes #378 --- docs/python-api.rst | 2 ++ sqlite_utils/db.py | 17 +++++++++++++++-- tests/test_delete.py | 14 ++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/docs/python-api.rst b/docs/python-api.rst index a8fd786..c79e5bb 100644 --- a/docs/python-api.rst +++ b/docs/python-api.rst @@ -717,6 +717,8 @@ You can delete all records in a table that match a specific WHERE statement usin Calling ``table.delete_where()`` with no other arguments will delete every row in the table. +Pass ``analyze=True`` to run ``ANALYZE`` against the table after deleting the rows. + .. _python_api_upsert: Upserting data diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index 6d3bcf9..e40eb12 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -2119,15 +2119,28 @@ class Table(Queryable): return self def delete_where( - self, where: str = None, where_args: Optional[Union[Iterable, dict]] = None + self, + where: str = None, + where_args: Optional[Union[Iterable, dict]] = None, + analyze: bool = False, ) -> "Table": - "Delete rows matching specified where clause, or delete all rows in the table." + """ + Delete rows matching the specified where clause, or delete all rows in the table. + + - ``where`` - a SQL fragment to use as a ``WHERE`` clause, for example ``age > ?`` or ``age > :age``. + - ``where_args`` - a list of arguments (if using ``?``) or a dictionary (if using ``:age``). + - ``analyze`` - set to ``True`` to run ``ANALYZE`` after the rows have been deleted. + + See :ref:`python_api_delete_where`. + """ if not self.exists(): return self sql = "delete from [{}]".format(self.name) if where is not None: sql += " where " + where self.db.execute(sql, where_args or []) + if analyze: + self.analyze() return self def update( diff --git a/tests/test_delete.py b/tests/test_delete.py index 1198d06..f057749 100644 --- a/tests/test_delete.py +++ b/tests/test_delete.py @@ -30,3 +30,17 @@ def test_delete_where_all(fresh_db): assert 10 == table.count table.delete_where() assert 0 == table.count + + +def test_delete_where_analyze(fresh_db): + table = fresh_db["table"] + table.insert_all(({"id": i, "i": i} for i in range(10)), pk="id") + table.create_index(["i"], analyze=True) + assert "sqlite_stat1" in fresh_db.table_names() + assert list(fresh_db["sqlite_stat1"].rows) == [ + {"tbl": "table", "idx": "idx_table_i", "stat": "10 1"} + ] + table.delete_where("id > ?", [5], analyze=True) + assert list(fresh_db["sqlite_stat1"].rows) == [ + {"tbl": "table", "idx": "idx_table_i", "stat": "6 1"} + ] From e0ef9288fede5cba5698c5206f55c98363ca456e Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 10 Jan 2022 17:24:17 -0800 Subject: [PATCH 070/563] sqlite-utils analyze command, refs #379 --- docs/cli.rst | 17 +++++++++++++++++ docs/python-api.rst | 2 +- sqlite_utils/cli.py | 20 ++++++++++++++++++++ tests/test_cli.py | 38 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 1 deletion(-) diff --git a/docs/cli.rst b/docs/cli.rst index 36ff029..da6a562 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -1800,6 +1800,23 @@ If the ``_counts`` table ever becomes out-of-sync with the actual table counts y $ sqlite-utils reset-counts mydb.db +.. _cli_analyze: + +Optimizing index usage with ANALYZE +=================================== + +The `SQLite ANALYZE command `__ builds a table of statistics which the query planner can use to make better decisions about which indexes to use for a given query. + +You should run ``ANALYZE`` if your database is large and you do not think your indexes are being efficiently used. + +To run ``ANALYZE`` against every index in a database, use this:: + + $ sqlite-utils analyze mydb.db + +You can run it against specific tables, or against specific named indexes, by passing them as optional arguments:: + + $ sqlite-utils analyze mydb.db mytable idx_mytable_name + .. _cli_vacuum: Vacuum diff --git a/docs/python-api.rst b/docs/python-api.rst index c79e5bb..6b0d542 100644 --- a/docs/python-api.rst +++ b/docs/python-api.rst @@ -2214,7 +2214,7 @@ Pass ``analyze=True`` to run ``ANALYZE`` against the new index after creating it Optimizing index usage with ANALYZE =================================== -The `SQLite ANALYZE command `__ can be used to build a table of statistics which the query planner can then use to make better decisions about which indexes to use for a given query. +The `SQLite ANALYZE command `__ builds a table of statistics which the query planner can use to make better decisions about which indexes to use for a given query. You should run ``ANALYZE`` if your database is large and you do not think your indexes are being efficiently used. diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 05a7bcc..cbc091c 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -304,6 +304,26 @@ def rebuild_fts(path, tables, load_extension): db[table].rebuild_fts() +@cli.command() +@click.argument( + "path", + type=click.Path(exists=True, file_okay=True, dir_okay=False, allow_dash=False), + required=True, +) +@click.argument("names", nargs=-1) +def analyze(path, names): + """Run ANALYZE against the whole database, or against specific named indexes and tables""" + db = sqlite_utils.Database(path) + try: + if names: + for name in names: + db.analyze(name) + else: + db.analyze() + except sqlite3.OperationalError as e: + raise click.ClickException(e) + + @cli.command() @click.argument( "path", diff --git a/tests/test_cli.py b/tests/test_cli.py index ca5c820..b4163c4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2057,3 +2057,41 @@ def test_create_database(tmpdir, enable_wal): assert db.journal_mode == "wal" else: assert db.journal_mode == "delete" + + +@pytest.mark.parametrize( + "options,expected", + ( + ( + [], + [ + {"tbl": "two_indexes", "idx": "idx_two_indexes_species", "stat": "1 1"}, + {"tbl": "two_indexes", "idx": "idx_two_indexes_name", "stat": "1 1"}, + {"tbl": "one_index", "idx": "idx_one_index_name", "stat": "1 1"}, + ], + ), + ( + ["one_index"], + [ + {"tbl": "one_index", "idx": "idx_one_index_name", "stat": "1 1"}, + ], + ), + ( + ["idx_two_indexes_name"], + [ + {"tbl": "two_indexes", "idx": "idx_two_indexes_name", "stat": "1 1"}, + ], + ), + ), +) +def test_analyze(tmpdir, options, expected): + db_path = str(tmpdir / "test.db") + db = Database(db_path) + db["one_index"].insert({"id": 1, "name": "Cleo"}, pk="id") + db["one_index"].create_index(["name"]) + db["two_indexes"].insert({"id": 1, "name": "Cleo", "species": "dog"}, pk="id") + db["two_indexes"].create_index(["name"]) + db["two_indexes"].create_index(["species"]) + result = CliRunner().invoke(cli.cli, ["analyze", db_path] + options) + assert result.exit_code == 0 + assert list(db["sqlite_stat1"].rows) == expected From 1b84c175b455ece931c728e25f3df859c1ad2fdc Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 10 Jan 2022 17:36:41 -0800 Subject: [PATCH 071/563] --analyze option for create-index, insert, update commands, closes #379, closes #365 --- docs/cli.rst | 6 ++++++ sqlite_utils/cli.py | 32 +++++++++++++++++++++++++++++--- tests/test_cli.py | 25 +++++++++++++++++++++++++ tests/test_cli_insert.py | 14 ++++++++++++++ 4 files changed, 74 insertions(+), 3 deletions(-) diff --git a/docs/cli.rst b/docs/cli.rst index da6a562..8b33206 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -761,6 +761,8 @@ You can delete all the existing rows in the table before inserting the new recor $ sqlite-utils insert dogs.db dogs dogs.json --truncate +You can add the ``--analyze`` option to run ``ANALYZE`` against the table after the rows have been inserted. + .. _cli_inserting_data_binary: Inserting binary data @@ -1697,6 +1699,8 @@ This will create an index on that table on ``(col1, col2 desc, col3)``. If your column names are already prefixed with a hyphen you'll need to manually execute a ``CREATE INDEX`` SQL statement to add indexes to them rather than using this tool. +Add the ``--analyze`` option to run ``ANALYZE`` against the index after it has been created. + .. _cli_fts: Configuring full-text search @@ -1817,6 +1821,8 @@ You can run it against specific tables, or against specific named indexes, by pa $ sqlite-utils analyze mydb.db mytable idx_mytable_name +You can also run ``ANALYZE`` as part of another command using the ``--analyze`` option. This is supported by the ``create-index``, ``insert`` and ``upsert`` commands. + .. _cli_vacuum: Vacuum diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index cbc091c..187795b 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -490,8 +490,15 @@ def index_foreign_keys(path, load_extension): default=False, is_flag=True, ) +@click.option( + "--analyze", + help="Run ANALYZE after creating the index", + is_flag=True, +) @load_extension_option -def create_index(path, table, column, name, unique, if_not_exists, load_extension): +def create_index( + path, table, column, name, unique, if_not_exists, analyze, load_extension +): """ Add an index to the specified table covering the specified columns. Use "sqlite-utils create-index mydb -- -column" to specify descending @@ -506,7 +513,11 @@ def create_index(path, table, column, name, unique, if_not_exists, load_extensio col = DescIndex(col[1:]) columns.append(col) db[table].create_index( - columns, index_name=name, unique=unique, if_not_exists=if_not_exists + columns, + index_name=name, + unique=unique, + if_not_exists=if_not_exists, + analyze=analyze, ) @@ -726,6 +737,11 @@ def insert_upsert_options(fn): envvar="SQLITE_UTILS_DETECT_TYPES", help="Detect types for columns in CSV/TSV data", ), + click.option( + "--analyze", + is_flag=True, + help="Run ANALYZE at the end of this operation", + ), load_extension_option, click.option("--silent", is_flag=True, help="Do not show progress bar"), ) @@ -761,6 +777,7 @@ def insert_upsert_implementation( default=None, encoding=None, detect_types=None, + analyze=False, load_extension=None, silent=False, ): @@ -853,7 +870,12 @@ def insert_upsert_implementation( else: docs = (fn(doc) or doc for doc in docs) - extra_kwargs = {"ignore": ignore, "replace": replace, "truncate": truncate} + extra_kwargs = { + "ignore": ignore, + "replace": replace, + "truncate": truncate, + "analyze": analyze, + } if not_null: extra_kwargs["not_null"] = set(not_null) if default: @@ -950,6 +972,7 @@ def insert( alter, encoding, detect_types, + analyze, load_extension, silent, ignore, @@ -1005,6 +1028,7 @@ def insert( truncate=truncate, encoding=encoding, detect_types=detect_types, + analyze=analyze, load_extension=load_extension, silent=silent, not_null=not_null, @@ -1039,6 +1063,7 @@ def upsert( default, encoding, detect_types, + analyze, load_extension, silent, ): @@ -1072,6 +1097,7 @@ def upsert( default=default, encoding=encoding, detect_types=detect_types, + analyze=analyze, load_extension=load_extension, silent=silent, ) diff --git a/tests/test_cli.py b/tests/test_cli.py index b4163c4..5f095a6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -224,6 +224,17 @@ def test_create_index(db_path): ) +def test_create_index_analyze(db_path): + db = Database(db_path) + assert "sqlite_stat1" not in db.table_names() + assert [] == db["Gosh"].indexes + result = CliRunner().invoke( + cli.cli, ["create-index", db_path, "Gosh", "c1", "--analyze"] + ) + assert result.exit_code == 0 + assert "sqlite_stat1" in db.table_names() + + def test_create_index_desc(db_path): db = Database(db_path) assert [] == db["Gosh"].indexes @@ -889,6 +900,20 @@ def test_upsert(db_path, tmpdir): ] +def test_upsert_analyze(db_path, tmpdir): + db = Database(db_path) + db["rows"].insert({"id": 1, "foo": "x", "n": 3}, pk="id") + db["rows"].create_index(["n"]) + assert "sqlite_stat1" not in db.table_names() + result = CliRunner().invoke( + cli.cli, + ["upsert", db_path, "rows", "-", "--nl", "--analyze", "--pk", "id"], + input='{"id": 2, "foo": "bar", "n": 1}', + ) + assert 0 == result.exit_code, result.output + assert "sqlite_stat1" in db.table_names() + + def test_upsert_flatten(tmpdir): db_path = str(tmpdir / "flat.db") db = Database(db_path) diff --git a/tests/test_cli_insert.py b/tests/test_cli_insert.py index efa55bd..0d6b482 100644 --- a/tests/test_cli_insert.py +++ b/tests/test_cli_insert.py @@ -327,6 +327,20 @@ def test_insert_alter(db_path, tmpdir): ] == list(db.query("select foo, n, baz from from_json_nl")) +def test_insert_analyze(db_path): + db = Database(db_path) + db["rows"].insert({"foo": "x", "n": 3}) + db["rows"].create_index(["n"]) + assert "sqlite_stat1" not in db.table_names() + result = CliRunner().invoke( + cli.cli, + ["insert", db_path, "rows", "-", "--nl", "--analyze"], + input='{"foo": "bar", "n": 1}\n{"foo": "baz", "n": 2}', + ) + assert 0 == result.exit_code, result.output + assert "sqlite_stat1" in db.table_names() + + def test_insert_lines(db_path): result = CliRunner().invoke( cli.cli, From 129141572f249ea290e2a075437e2ebaad215859 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 10 Jan 2022 18:10:54 -0800 Subject: [PATCH 072/563] `sqlite-utils bulk` command * sqlite-utils bulk command, closes #375 * Refactor import_options and insert_upsert_options, refs #377 * Tests for sqlite-utils bulk, refs #377 * Documentation for sqlite-utils bulk, refs #377 --- docs/cli.rst | 30 ++++++++ sqlite_utils/cli.py | 167 ++++++++++++++++++++++++++++++----------- tests/test_cli_bulk.py | 57 ++++++++++++++ 3 files changed, 211 insertions(+), 43 deletions(-) create mode 100644 tests/test_cli_bulk.py diff --git a/docs/cli.rst b/docs/cli.rst index 8b33206..913a9ed 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -1078,6 +1078,36 @@ The command will fail if you reference columns that do not exist on the table. T .. note:: ``upsert`` in sqlite-utils 1.x worked like ``insert ... --replace`` does in 2.x. See `issue #66 `__ for details of this change. + +.. _cli_bulk: + +Executing SQL in bulk +===================== + +If you have a JSON, newline-delimited JSON, CSV or TSV file you can execute a bulk SQL query using each of the records in that file using the ``sqlite-utils bulk`` command. + +The command takes the database file, the SQL to be executed and the file containing records to be used when evaluating the SQL query. + +The SQL query should include ``:named`` parameters that match the keys in the records. + +For example, given a ``chickens.csv`` CSV file containing the following:: + + id,name + 1,Blue + 2,Snowy + 3,Azi + 4,Lila + 5,Suna + 6,Cardi + +You could insert those rows into a pre-created ``chickens`` table like so:: + + $ sqlite-utils bulk chickens.db \ + 'insert into chickens (id, name) values (:id, :name)' \ + chickens.csv --csv + +This command takes the same options as the ``sqlite-utils insert`` command - so it defaults to expecting JSON but can accept other formats using ``--csv`` or ``--tsv`` or ``--nl`` or other options described above. + .. _cli_insert_files: Inserting data from files diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 187795b..0dd7f71 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -660,6 +660,50 @@ def reset_counts(path, load_extension): db.reset_counts() +_import_options = ( + click.option( + "--flatten", + is_flag=True, + help='Flatten nested JSON objects, so {"a": {"b": 1}} becomes {"a_b": 1}', + ), + click.option("--nl", is_flag=True, help="Expect newline-delimited JSON"), + click.option("-c", "--csv", is_flag=True, help="Expect CSV input"), + click.option("--tsv", is_flag=True, help="Expect TSV input"), + click.option( + "--lines", + is_flag=True, + help="Treat each line as a single value called 'line'", + ), + click.option( + "--text", + is_flag=True, + help="Treat input as a single value called 'text'", + ), + click.option("--convert", help="Python code to convert each item"), + click.option( + "--import", + "imports", + type=str, + multiple=True, + help="Python modules to import", + ), + click.option("--delimiter", help="Delimiter to use for CSV files"), + click.option("--quotechar", help="Quote character to use for CSV/TSV"), + click.option("--sniff", is_flag=True, help="Detect delimiter and quote character"), + click.option("--no-headers", is_flag=True, help="CSV file has no header row"), + click.option( + "--encoding", + help="Character encoding for input, defaults to utf-8", + ), +) + + +def import_options(fn): + for decorator in reversed(_import_options): + fn = decorator(fn) + return fn + + def insert_upsert_options(fn): for decorator in reversed( ( @@ -673,40 +717,9 @@ def insert_upsert_options(fn): click.option( "--pk", help="Columns to use as the primary key, e.g. id", multiple=True ), - click.option( - "--flatten", - is_flag=True, - help='Flatten nested JSON objects, so {"a": {"b": 1}} becomes {"a_b": 1}', - ), - click.option("--nl", is_flag=True, help="Expect newline-delimited JSON"), - click.option("-c", "--csv", is_flag=True, help="Expect CSV input"), - click.option("--tsv", is_flag=True, help="Expect TSV input"), - click.option( - "--lines", - is_flag=True, - help="Treat each line as a single value called 'line'", - ), - click.option( - "--text", - is_flag=True, - help="Treat input as a single value called 'text'", - ), - click.option("--convert", help="Python code to convert each item"), - click.option( - "--import", - "imports", - type=str, - multiple=True, - help="Python modules to import", - ), - click.option("--delimiter", help="Delimiter to use for CSV files"), - click.option("--quotechar", help="Quote character to use for CSV/TSV"), - click.option( - "--sniff", is_flag=True, help="Detect delimiter and quote character" - ), - click.option( - "--no-headers", is_flag=True, help="CSV file has no header row" - ), + ) + + _import_options + + ( click.option( "--batch-size", type=int, default=100, help="Commit every X records" ), @@ -726,10 +739,6 @@ def insert_upsert_options(fn): type=(str, str), help="Default value that should be set for a column", ), - click.option( - "--encoding", - help="Character encoding for input, defaults to utf-8", - ), click.option( "-d", "--detect-types", @@ -767,6 +776,7 @@ def insert_upsert_implementation( quotechar, sniff, no_headers, + encoding, batch_size, alter, upsert, @@ -775,11 +785,11 @@ def insert_upsert_implementation( truncate=False, not_null=None, default=None, - encoding=None, detect_types=None, analyze=False, load_extension=None, silent=False, + bulk_sql=None, ): db = sqlite_utils.Database(path) _load_extensions(db, load_extension) @@ -886,6 +896,12 @@ def insert_upsert_implementation( # Apply {"$base64": true, ...} decoding, if needed docs = (decode_base64_values(doc) for doc in docs) + # For bulk_sql= we use cursor.executemany() instead + if bulk_sql: + with db.conn: + db.conn.cursor().executemany(bulk_sql, docs) + return + try: db[table].insert_all( docs, pk=pk, batch_size=batch_size, alter=alter, **extra_kwargs @@ -968,9 +984,9 @@ def insert( quotechar, sniff, no_headers, + encoding, batch_size, alter, - encoding, detect_types, analyze, load_extension, @@ -1020,13 +1036,13 @@ def insert( quotechar, sniff, no_headers, + encoding, batch_size, alter=alter, upsert=False, ignore=ignore, replace=replace, truncate=truncate, - encoding=encoding, detect_types=detect_types, analyze=analyze, load_extension=load_extension, @@ -1058,10 +1074,10 @@ def upsert( quotechar, sniff, no_headers, + encoding, alter, not_null, default, - encoding, detect_types, analyze, load_extension, @@ -1090,12 +1106,12 @@ def upsert( quotechar, sniff, no_headers, + encoding, batch_size, alter=alter, upsert=True, not_null=not_null, default=default, - encoding=encoding, detect_types=detect_types, analyze=analyze, load_extension=load_extension, @@ -1105,6 +1121,71 @@ def upsert( raise click.ClickException(UNICODE_ERROR.format(ex)) +@cli.command() +@click.argument( + "path", + type=click.Path(file_okay=True, dir_okay=False, allow_dash=False), + required=True, +) +@click.argument("sql") +@click.argument("file", type=click.File("rb"), required=True) +@import_options +@load_extension_option +def bulk( + path, + file, + sql, + flatten, + nl, + csv, + tsv, + lines, + text, + convert, + imports, + delimiter, + quotechar, + sniff, + no_headers, + encoding, + load_extension, +): + """ + Execute parameterized SQL against the provided list of documents. + """ + try: + insert_upsert_implementation( + path=path, + table=None, + file=file, + pk=None, + flatten=flatten, + nl=nl, + csv=csv, + tsv=tsv, + lines=lines, + text=text, + convert=convert, + imports=imports, + delimiter=delimiter, + quotechar=quotechar, + sniff=sniff, + no_headers=no_headers, + encoding=encoding, + batch_size=1, + alter=False, + upsert=False, + not_null=set(), + default={}, + detect_types=False, + load_extension=load_extension, + silent=False, + bulk_sql=sql, + ) + except (sqlite3.OperationalError, sqlite3.IntegrityError) as e: + raise click.ClickException(str(e)) + + @cli.command(name="create-database") @click.argument( "path", diff --git a/tests/test_cli_bulk.py b/tests/test_cli_bulk.py new file mode 100644 index 0000000..37cf60b --- /dev/null +++ b/tests/test_cli_bulk.py @@ -0,0 +1,57 @@ +from click.testing import CliRunner +from sqlite_utils import cli, Database +import pathlib +import pytest + + +@pytest.fixture +def test_db_and_path(tmpdir): + db_path = str(pathlib.Path(tmpdir) / "data.db") + db = Database(db_path) + db["example"].insert_all( + [ + {"id": 1, "name": "One"}, + {"id": 2, "name": "Two"}, + ], + pk="id", + ) + return db, db_path + + +def test_cli_bulk(test_db_and_path): + db, db_path = test_db_and_path + result = CliRunner().invoke( + cli.cli, + [ + "bulk", + db_path, + "insert into example (id, name) values (:id, :name)", + "-", + "--nl", + ], + input='{"id": 3, "name": "Three"}\n{"id": 4, "name": "Four"}\n', + ) + assert result.exit_code == 0, result.output + assert [ + {"id": 1, "name": "One"}, + {"id": 2, "name": "Two"}, + {"id": 3, "name": "Three"}, + {"id": 4, "name": "Four"}, + ] == list(db["example"].rows) + + +def test_cli_bulk_error(test_db_and_path): + _, db_path = test_db_and_path + result = CliRunner().invoke( + cli.cli, + [ + "bulk", + db_path, + "insert into example (id, name) value (:id, :name)", + "-", + "--nl", + ], + input='{"id": 3, "name": "Three"}', + ) + assert result.exit_code == 1 + assert result.output == 'Error: near "value": syntax error\n' From 7c637b11805adc3d3970076a7ba6afe8e34b371e Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 10 Jan 2022 18:33:48 -0800 Subject: [PATCH 073/563] Release 3.21 Refs #348, #364, #366, #368, #371, #372, #374, #375, #376, #379 Closes #380 --- docs/changelog.rst | 21 +++++++++++++++++++++ setup.py | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index f14870b..9b6b303 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -2,6 +2,27 @@ Changelog =========== +.. _v3_21: + +3.21 (2022-01-10) +----------------- + +CLI and Python library improvements to help run `ANALYZE `__ after creating indexes or inserting rows, to gain better performance from the SQLite query planner when it runs against indexes. + +Three new CLI commands: ``create-database``, ``analyze`` and ``bulk``. + +- New ``sqlite-utils create-database`` command for creating new empty database files. (:issue:`348`) +- New Python methods for running ``ANALYZE`` against a database, table or index: ``db.analyze()`` and ``table.analyze()``, see :ref:`python_api_analyze`. (:issue:`366`) +- New :ref:`sqlite-utils analyze command ` for running ``ANALYZE`` using the CLI. (:issue:`379`) +- The ``create-index``, ``insert`` and ``update`` commands now have a new ``--analyze`` option for running ``ANALYZE`` after the command has completed. (:issue:`379`) +- New :ref:`sqlite-utils bulk command ` which can import records in the same way as ``sqlite-utils insert`` (from JSON, CSV or TSV) and use them to bulk execute a parametrized SQL query. (:issue:`375`) +- The CLI tool can now also be run using ``python -m sqlite_utils``. (:issue:`368`) +- Using ``--fmt`` now implies ``--table``, so you don't need to pass both options. (:issue:`374`) +- The ``--convert`` function applied to rows can now modify the row in place. (:issue:`371`) +- The :ref:`insert-files command ` supports two new columns: ``stem`` and ``suffix``. (:issue:`372`) +- The ``--nl`` import option now ignores blank lines in the input. (:issue:`376`) +- Fixed bug where streaming input to the ``insert`` command with ``--batch-size 1`` would appear to only commit after several rows had been ingested, due to unnecessary input buffering. (:issue:`364`) + .. _v3_20: 3.20 (2022-01-05) diff --git a/setup.py b/setup.py index 901725d..4c514e0 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages import io import os -VERSION = "3.20" +VERSION = "3.21" def get_long_description(): From 2448e45ddbc039a8acad49ea2af6f72dc14bcb3e Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 11 Jan 2022 10:06:50 -0800 Subject: [PATCH 074/563] upsert command, not update command --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 9b6b303..af3a1e8 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -14,7 +14,7 @@ Three new CLI commands: ``create-database``, ``analyze`` and ``bulk``. - New ``sqlite-utils create-database`` command for creating new empty database files. (:issue:`348`) - New Python methods for running ``ANALYZE`` against a database, table or index: ``db.analyze()`` and ``table.analyze()``, see :ref:`python_api_analyze`. (:issue:`366`) - New :ref:`sqlite-utils analyze command ` for running ``ANALYZE`` using the CLI. (:issue:`379`) -- The ``create-index``, ``insert`` and ``update`` commands now have a new ``--analyze`` option for running ``ANALYZE`` after the command has completed. (:issue:`379`) +- The ``create-index``, ``insert`` and ``upsert`` commands now have a new ``--analyze`` option for running ``ANALYZE`` after the command has completed. (:issue:`379`) - New :ref:`sqlite-utils bulk command ` which can import records in the same way as ``sqlite-utils insert`` (from JSON, CSV or TSV) and use them to bulk execute a parametrized SQL query. (:issue:`375`) - The CLI tool can now also be run using ``python -m sqlite_utils``. (:issue:`368`) - Using ``--fmt`` now implies ``--table``, so you don't need to pass both options. (:issue:`374`) From 5737a3aab4c32cabc05583a552905489eb76294c Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 11 Jan 2022 11:18:35 -0800 Subject: [PATCH 075/563] Link to annotated release notes --- docs/changelog.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index af3a1e8..615dc7c 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -11,6 +11,8 @@ CLI and Python library improvements to help run `ANALYZE `__. + - New ``sqlite-utils create-database`` command for creating new empty database files. (:issue:`348`) - New Python methods for running ``ANALYZE`` against a database, table or index: ``db.analyze()`` and ``table.analyze()``, see :ref:`python_api_analyze`. (:issue:`366`) - New :ref:`sqlite-utils analyze command ` for running ``ANALYZE`` using the CLI. (:issue:`379`) From 5f38c8160138702810698249be27a3c71023b9e4 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 11 Jan 2022 11:20:34 -0800 Subject: [PATCH 076/563] Fixed typo --- docs/cli.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cli.rst b/docs/cli.rst index 913a9ed..d2fd9e7 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -976,7 +976,7 @@ Given a JSON file called ``dogs.json`` containing this: The following command will insert that data and add an ``is_good`` column set to ``1`` for each dog:: - $ sqlite-utils insert dogs.db dogs dogs.json --convert 'row["is_good"] = 1` + $ sqlite-utils insert dogs.db dogs dogs.json --convert 'row["is_good"] = 1' The ``--convert`` option also works with the ``--csv``, ``--tsv`` and ``--nl`` insert options. From 1d44b0cc2784c94aed1bcf350225cd86ee1aa7e5 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 11 Jan 2022 13:43:39 -0800 Subject: [PATCH 077/563] CLI reference page, maintained by cog, closes #383 --- docs/cli-reference.rst | 1051 ++++++++++++++++++++++++++++++++++++++++ docs/index.rst | 1 + 2 files changed, 1052 insertions(+) create mode 100644 docs/cli-reference.rst diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst new file mode 100644 index 0000000..76630bb --- /dev/null +++ b/docs/cli-reference.rst @@ -0,0 +1,1051 @@ +.. _cli_reference: + +=============== + CLI reference +=============== + +This page lists the ``--help`` for every ``sqlite-utils`` CLI sub-command. + +.. contents:: :local: + +.. [[[cog + from sqlite_utils import cli + from click.testing import CliRunner + import textwrap + commands = list(cli.cli.commands.keys()) + go_first = [ + "query", "memory", "insert", "upsert", "bulk", "search", "transform", "extract", + "schema", "insert-files", "analyze-tables", "convert", "tables", "views", "rows", + "triggers", "indexes", "create-database", "create-table", "create-index", + "enable-fts", "populate-fts", "rebuild-fts", "disable-fts" + ] + refs = { + "query": "cli_query", + "memory": "cli_memory", + "insert": ["cli_inserting_data", "cli_insert_csv_tsv"], + "upsert": "cli_upsert", + "tables": "cli_tables", + "views": "cli_views", + "optimize": "cli_optimize", + "rows": "cli_rows", + "triggers": "cli_triggers", + "indexes": "cli_indexes", + "enable-fts": "cli_fts", + "analyze": "cli_analyze", + "vacuum": "cli_vacuum", + "dump": "cli_dump", + "add-column": "cli_add_column", + "add-foreign-key": "cli_add_foreign_key", + "add-foreign-keys": "cli_add_foreign_keys", + "index-foreign-keys": "cli_index_foreign_keys", + "create-index": "cli_create_index", + "enable-wal": "cli_wal", + "enable-counts": "cli_enable_counts", + "bulk": "cli_bulk", + "create-database": "cli_create_database", + "create-table": "cli_create_table", + "drop-table": "cli_drop_table", + "create-view": "cli_create_view", + "drop-view": "cli_drop_view", + "search": "cli_search", + "transform": "cli_transform_table", + "extract": "cli_extract", + "schema": "cli_schema", + "insert-files": "cli_insert_files", + "analyze-tables": "cli_analyze_tables", + "convert": "cli_convert", + } + commands.sort(key = lambda command: go_first.index(command) if command in go_first else 999) + for command in commands: + cog.out(command + "\n") + cog.out(("=" * len(command)) + "\n\n") + if command in refs: + command_refs = refs[command] + if isinstance(command_refs, str): + command_refs = [command_refs] + cog.out( + "See {}.\n\n".format( + ", ".join(":ref:`{}`".format(c) for c in command_refs) + ) + ) + cog.out("::\n\n") + result = CliRunner().invoke(cli.cli, [command, "--help"]) + output = result.output.replace("Usage: cli ", "Usage: sqlite-utils ") + cog.out(textwrap.indent(output, ' ')) + cog.out("\n\n") +.. ]]] +query +===== + +See :ref:`cli_query`. + +:: + + Usage: sqlite-utils query [OPTIONS] PATH SQL + + Execute SQL query and return the results as JSON + + Options: + --attach ... Additional databases to attach - specify alias and + filepath + --nl Output newline-delimited JSON + --arrays Output rows as arrays instead of objects + --csv Output CSV + --tsv Output TSV + --no-headers Omit CSV headers + -t, --table Output as a table + --fmt TEXT Table format - one of fancy_grid, fancy_outline, + github, grid, html, jira, latex, latex_booktabs, + latex_longtable, latex_raw, mediawiki, moinmoin, + orgtbl, pipe, plain, presto, pretty, psql, rst, + simple, textile, tsv, unsafehtml, youtrack + --json-cols Detect JSON cols and output them as JSON, not + escaped strings + -r, --raw Raw output, first column of first row + -p, --param ... Named :parameters for SQL query + --load-extension TEXT SQLite extensions to load + -h, --help Show this message and exit. + + +memory +====== + +See :ref:`cli_memory`. + +:: + + Usage: sqlite-utils memory [OPTIONS] [PATHS]... SQL + + Execute SQL query against an in-memory database, optionally populated by + imported data + + To import data from CSV, TSV or JSON files pass them on the command-line: + + sqlite-utils memory one.csv two.json \ + "select * from one join two on one.two_id = two.id" + + For data piped into the tool from standard input, use "-" or "stdin": + + cat animals.csv | sqlite-utils memory - \ + "select * from stdin where species = 'dog'" + + The format of the data will be automatically detected. You can specify the + format explicitly using :json, :csv, :tsv or :nl (for newline-delimited JSON) + - for example: + + cat animals.csv | sqlite-utils memory stdin:csv places.dat:nl \ + "select * from stdin where place_id in (select id from places)" + + Use --schema to view the SQL schema of any imported files: + + sqlite-utils memory animals.csv --schema + + Options: + --attach ... Additional databases to attach - specify alias and + filepath + --flatten Flatten nested JSON objects, so {"foo": {"bar": + 1}} becomes {"foo_bar": 1} + --nl Output newline-delimited JSON + --arrays Output rows as arrays instead of objects + --csv Output CSV + --tsv Output TSV + --no-headers Omit CSV headers + -t, --table Output as a table + --fmt TEXT Table format - one of fancy_grid, fancy_outline, + github, grid, html, jira, latex, latex_booktabs, + latex_longtable, latex_raw, mediawiki, moinmoin, + orgtbl, pipe, plain, presto, pretty, psql, rst, + simple, textile, tsv, unsafehtml, youtrack + --json-cols Detect JSON cols and output them as JSON, not + escaped strings + -r, --raw Raw output, first column of first row + -p, --param ... Named :parameters for SQL query + --encoding TEXT Character encoding for CSV input, defaults to + utf-8 + -n, --no-detect-types Treat all CSV/TSV columns as TEXT + --schema Show SQL schema for in-memory database + --dump Dump SQL for in-memory database + --save FILE Save in-memory database to this file + --analyze Analyze resulting tables and output results + --load-extension TEXT SQLite extensions to load + -h, --help Show this message and exit. + + +insert +====== + +See :ref:`cli_inserting_data`, :ref:`cli_insert_csv_tsv`. + +:: + + Usage: sqlite-utils insert [OPTIONS] PATH TABLE FILE + + Insert records from FILE into a table, creating the table if it does not + already exist. + + By default the input is expected to be a JSON array of objects. Or: + + - Use --nl for newline-delimited JSON objects + - Use --csv or --tsv for comma-separated or tab-separated input + - Use --lines to write each incoming line to a column called "line" + - Use --text to write the entire input to a column called "text" + + You can also use --convert to pass a fragment of Python code that will be used + to convert each input. + + Your Python code will be passed a "row" variable representing the imported + row, and can return a modified row. + + If you are using --lines your code will be passed a "line" variable, and for + --text an "text" variable. + + Options: + --pk TEXT Columns to use as the primary key, e.g. id + --flatten Flatten nested JSON objects, so {"a": {"b": 1}} + becomes {"a_b": 1} + --nl Expect newline-delimited JSON + -c, --csv Expect CSV input + --tsv Expect TSV input + --lines Treat each line as a single value called 'line' + --text Treat input as a single value called 'text' + --convert TEXT Python code to convert each item + --import TEXT Python modules to import + --delimiter TEXT Delimiter to use for CSV files + --quotechar TEXT Quote character to use for CSV/TSV + --sniff Detect delimiter and quote character + --no-headers CSV file has no header row + --encoding TEXT Character encoding for input, defaults to utf-8 + --batch-size INTEGER Commit every X records + --alter Alter existing table to add any missing columns + --not-null TEXT Columns that should be created as NOT NULL + --default ... Default value that should be set for a column + -d, --detect-types Detect types for columns in CSV/TSV data + --analyze Run ANALYZE at the end of this operation + --load-extension TEXT SQLite extensions to load + --silent Do not show progress bar + --ignore Ignore records if pk already exists + --replace Replace records if pk already exists + --truncate Truncate table before inserting records, if table + already exists + -h, --help Show this message and exit. + + +upsert +====== + +See :ref:`cli_upsert`. + +:: + + Usage: sqlite-utils upsert [OPTIONS] PATH TABLE FILE + + Upsert records based on their primary key. Works like 'insert' but if an + incoming record has a primary key that matches an existing record the existing + record will be updated. + + Options: + --pk TEXT Columns to use as the primary key, e.g. id + --flatten Flatten nested JSON objects, so {"a": {"b": 1}} + becomes {"a_b": 1} + --nl Expect newline-delimited JSON + -c, --csv Expect CSV input + --tsv Expect TSV input + --lines Treat each line as a single value called 'line' + --text Treat input as a single value called 'text' + --convert TEXT Python code to convert each item + --import TEXT Python modules to import + --delimiter TEXT Delimiter to use for CSV files + --quotechar TEXT Quote character to use for CSV/TSV + --sniff Detect delimiter and quote character + --no-headers CSV file has no header row + --encoding TEXT Character encoding for input, defaults to utf-8 + --batch-size INTEGER Commit every X records + --alter Alter existing table to add any missing columns + --not-null TEXT Columns that should be created as NOT NULL + --default ... Default value that should be set for a column + -d, --detect-types Detect types for columns in CSV/TSV data + --analyze Run ANALYZE at the end of this operation + --load-extension TEXT SQLite extensions to load + --silent Do not show progress bar + -h, --help Show this message and exit. + + +bulk +==== + +See :ref:`cli_bulk`. + +:: + + Usage: sqlite-utils bulk [OPTIONS] PATH SQL FILE + + Execute parameterized SQL against the provided list of documents. + + Options: + --flatten Flatten nested JSON objects, so {"a": {"b": 1}} becomes + {"a_b": 1} + --nl Expect newline-delimited JSON + -c, --csv Expect CSV input + --tsv Expect TSV input + --lines Treat each line as a single value called 'line' + --text Treat input as a single value called 'text' + --convert TEXT Python code to convert each item + --import TEXT Python modules to import + --delimiter TEXT Delimiter to use for CSV files + --quotechar TEXT Quote character to use for CSV/TSV + --sniff Detect delimiter and quote character + --no-headers CSV file has no header row + --encoding TEXT Character encoding for input, defaults to utf-8 + --load-extension TEXT SQLite extensions to load + -h, --help Show this message and exit. + + +search +====== + +See :ref:`cli_search`. + +:: + + Usage: sqlite-utils search [OPTIONS] PATH DBTABLE Q + + Execute a full-text search against this table + + Options: + -o, --order TEXT Order by ('column' or 'column desc') + -c, --column TEXT Columns to return + --limit INTEGER Number of rows to return - defaults to everything + --sql Show SQL query that would be run + --quote Apply FTS quoting rules to search term + --nl Output newline-delimited JSON + --arrays Output rows as arrays instead of objects + --csv Output CSV + --tsv Output TSV + --no-headers Omit CSV headers + -t, --table Output as a table + --fmt TEXT Table format - one of fancy_grid, fancy_outline, + github, grid, html, jira, latex, latex_booktabs, + latex_longtable, latex_raw, mediawiki, moinmoin, + orgtbl, pipe, plain, presto, pretty, psql, rst, simple, + textile, tsv, unsafehtml, youtrack + --json-cols Detect JSON cols and output them as JSON, not escaped + strings + --load-extension TEXT SQLite extensions to load + -h, --help Show this message and exit. + + +transform +========= + +See :ref:`cli_transform_table`. + +:: + + Usage: sqlite-utils transform [OPTIONS] PATH TABLE + + Transform a table beyond the capabilities of ALTER TABLE + + Options: + --type ... Change column type to INTEGER, TEXT, FLOAT or BLOB + --drop TEXT Drop this column + --rename ... Rename this column to X + -o, --column-order TEXT Reorder columns + --not-null TEXT Set this column to NOT NULL + --not-null-false TEXT Remove NOT NULL from this column + --pk TEXT Make this column the primary key + --pk-none Remove primary key (convert to rowid table) + --default ... Set default value for this column + --default-none TEXT Remove default from this column + --drop-foreign-key TEXT Drop this foreign key constraint + --sql Output SQL without executing it + --load-extension TEXT SQLite extensions to load + -h, --help Show this message and exit. + + +extract +======= + +See :ref:`cli_extract`. + +:: + + Usage: sqlite-utils extract [OPTIONS] PATH TABLE COLUMNS... + + Extract one or more columns into a separate table + + Options: + --table TEXT Name of the other table to extract columns to + --fk-column TEXT Name of the foreign key column to add to the table + --rename ... Rename this column in extracted table + --load-extension TEXT SQLite extensions to load + -h, --help Show this message and exit. + + +schema +====== + +See :ref:`cli_schema`. + +:: + + Usage: sqlite-utils schema [OPTIONS] PATH [TABLES]... + + Show full schema for this database or for specified tables + + Options: + --load-extension TEXT SQLite extensions to load + -h, --help Show this message and exit. + + +insert-files +============ + +See :ref:`cli_insert_files`. + +:: + + Usage: sqlite-utils insert-files [OPTIONS] PATH TABLE FILE_OR_DIR... + + Insert one or more files using BLOB columns in the specified table + + Example usage: + + sqlite-utils insert-files pics.db images *.gif \ + -c name:name \ + -c content:content \ + -c content_hash:sha256 \ + -c created:ctime_iso \ + -c modified:mtime_iso \ + -c size:size \ + --pk name + + Options: + -c, --column TEXT Column definitions for the table + --pk TEXT Column to use as primary key + --alter Alter table to add missing columns + --replace Replace files with matching primary key + --upsert Upsert files with matching primary key + --name TEXT File name to use + --text Store file content as TEXT, not BLOB + --encoding TEXT Character encoding for input, defaults to utf-8 + -s, --silent Don't show a progress bar + --load-extension TEXT SQLite extensions to load + -h, --help Show this message and exit. + + +analyze-tables +============== + +See :ref:`cli_analyze_tables`. + +:: + + Usage: sqlite-utils analyze-tables [OPTIONS] PATH [TABLES]... + + Analyze the columns in one or more tables + + Options: + -c, --column TEXT Specific columns to analyze + --save Save results to _analyze_tables table + --load-extension TEXT SQLite extensions to load + -h, --help Show this message and exit. + + +convert +======= + +See :ref:`cli_convert`. + +:: + + Usage: sqlite-utils convert [OPTIONS] DB_PATH TABLE COLUMNS... CODE + + Convert columns using Python code you supply. For example: + + $ sqlite-utils convert my.db mytable mycolumn \ + '"\n".join(textwrap.wrap(value, 10))' \ + --import=textwrap + + "value" is a variable with the column value to be converted. + + Use "-" for CODE to read Python code from standard input. + + The following common operations are available as recipe functions: + + r.jsonsplit(value, delimiter=',', type=) + + Convert a string like a,b,c into a JSON array ["a", "b", "c"] + + r.parsedate(value, dayfirst=False, yearfirst=False) + + Parse a date and convert it to ISO date format: yyyy-mm-dd + + r.parsedatetime(value, dayfirst=False, yearfirst=False) + + Parse a datetime and convert it to ISO datetime format: yyyy-mm-ddTHH:MM:SS + + You can use these recipes like so: + + $ sqlite-utils convert my.db mytable mycolumn \ + 'r.jsonsplit(value, delimiter=":")' + + Options: + --import TEXT Python modules to import + --dry-run Show results of running this against first 10 + rows + --multi Populate columns for keys in returned + dictionary + --where TEXT Optional where clause + -p, --param ... Named :parameters for where clause + --output TEXT Optional separate column to populate with the + output + --output-type [integer|float|blob|text] + Column type to use for the output column + --drop Drop original column afterwards + -s, --silent Don't show a progress bar + -h, --help Show this message and exit. + + +tables +====== + +See :ref:`cli_tables`. + +:: + + Usage: sqlite-utils tables [OPTIONS] PATH + + List the tables in the database + + Options: + --fts4 Just show FTS4 enabled tables + --fts5 Just show FTS5 enabled tables + --counts Include row counts per table + --nl Output newline-delimited JSON + --arrays Output rows as arrays instead of objects + --csv Output CSV + --tsv Output TSV + --no-headers Omit CSV headers + -t, --table Output as a table + --fmt TEXT Table format - one of fancy_grid, fancy_outline, + github, grid, html, jira, latex, latex_booktabs, + latex_longtable, latex_raw, mediawiki, moinmoin, + orgtbl, pipe, plain, presto, pretty, psql, rst, simple, + textile, tsv, unsafehtml, youtrack + --json-cols Detect JSON cols and output them as JSON, not escaped + strings + --columns Include list of columns for each table + --schema Include schema for each table + --load-extension TEXT SQLite extensions to load + -h, --help Show this message and exit. + + +views +===== + +See :ref:`cli_views`. + +:: + + Usage: sqlite-utils views [OPTIONS] PATH + + List the views in the database + + Options: + --counts Include row counts per view + --nl Output newline-delimited JSON + --arrays Output rows as arrays instead of objects + --csv Output CSV + --tsv Output TSV + --no-headers Omit CSV headers + -t, --table Output as a table + --fmt TEXT Table format - one of fancy_grid, fancy_outline, + github, grid, html, jira, latex, latex_booktabs, + latex_longtable, latex_raw, mediawiki, moinmoin, + orgtbl, pipe, plain, presto, pretty, psql, rst, simple, + textile, tsv, unsafehtml, youtrack + --json-cols Detect JSON cols and output them as JSON, not escaped + strings + --columns Include list of columns for each view + --schema Include schema for each view + --load-extension TEXT SQLite extensions to load + -h, --help Show this message and exit. + + +rows +==== + +See :ref:`cli_rows`. + +:: + + Usage: sqlite-utils rows [OPTIONS] PATH DBTABLE + + Output all rows in the specified table + + Options: + -c, --column TEXT Columns to return + --nl Output newline-delimited JSON + --arrays Output rows as arrays instead of objects + --csv Output CSV + --tsv Output TSV + --no-headers Omit CSV headers + -t, --table Output as a table + --fmt TEXT Table format - one of fancy_grid, fancy_outline, + github, grid, html, jira, latex, latex_booktabs, + latex_longtable, latex_raw, mediawiki, moinmoin, + orgtbl, pipe, plain, presto, pretty, psql, rst, simple, + textile, tsv, unsafehtml, youtrack + --json-cols Detect JSON cols and output them as JSON, not escaped + strings + --load-extension TEXT SQLite extensions to load + -h, --help Show this message and exit. + + +triggers +======== + +See :ref:`cli_triggers`. + +:: + + Usage: sqlite-utils triggers [OPTIONS] PATH [TABLES]... + + Show triggers configured in this database + + Options: + --nl Output newline-delimited JSON + --arrays Output rows as arrays instead of objects + --csv Output CSV + --tsv Output TSV + --no-headers Omit CSV headers + -t, --table Output as a table + --fmt TEXT Table format - one of fancy_grid, fancy_outline, + github, grid, html, jira, latex, latex_booktabs, + latex_longtable, latex_raw, mediawiki, moinmoin, + orgtbl, pipe, plain, presto, pretty, psql, rst, simple, + textile, tsv, unsafehtml, youtrack + --json-cols Detect JSON cols and output them as JSON, not escaped + strings + --load-extension TEXT SQLite extensions to load + -h, --help Show this message and exit. + + +indexes +======= + +See :ref:`cli_indexes`. + +:: + + Usage: sqlite-utils indexes [OPTIONS] PATH [TABLES]... + + Show indexes for this database + + Options: + --aux Include auxiliary columns + --nl Output newline-delimited JSON + --arrays Output rows as arrays instead of objects + --csv Output CSV + --tsv Output TSV + --no-headers Omit CSV headers + -t, --table Output as a table + --fmt TEXT Table format - one of fancy_grid, fancy_outline, + github, grid, html, jira, latex, latex_booktabs, + latex_longtable, latex_raw, mediawiki, moinmoin, + orgtbl, pipe, plain, presto, pretty, psql, rst, simple, + textile, tsv, unsafehtml, youtrack + --json-cols Detect JSON cols and output them as JSON, not escaped + strings + --load-extension TEXT SQLite extensions to load + -h, --help Show this message and exit. + + +create-database +=============== + +See :ref:`cli_create_database`. + +:: + + Usage: sqlite-utils create-database [OPTIONS] PATH + + Create a new empty database file. + + Options: + --enable-wal Enable WAL mode on the created database + -h, --help Show this message and exit. + + +create-table +============ + +See :ref:`cli_create_table`. + +:: + + Usage: sqlite-utils create-table [OPTIONS] PATH TABLE COLUMNS... + + Add a table with the specified columns. Columns should be specified using + name, type pairs, for example: + + sqlite-utils create-table my.db people \ + id integer \ + name text \ + height float \ + photo blob --pk id + + Options: + --pk TEXT Column to use as primary key + --not-null TEXT Columns that should be created as NOT NULL + --default ... Default value that should be set for a column + --fk ... Column, other table, other column to set as a + foreign key + --ignore If table already exists, do nothing + --replace If table already exists, replace it + --load-extension TEXT SQLite extensions to load + -h, --help Show this message and exit. + + +create-index +============ + +See :ref:`cli_create_index`. + +:: + + Usage: sqlite-utils create-index [OPTIONS] PATH TABLE COLUMN... + + Add an index to the specified table covering the specified columns. Use + "sqlite-utils create-index mydb -- -column" to specify descending order for a + column. + + Options: + --name TEXT Explicit name for the new index + --unique Make this a unique index + --if-not-exists Ignore if index already exists + --analyze Run ANALYZE after creating the index + --load-extension TEXT SQLite extensions to load + -h, --help Show this message and exit. + + +enable-fts +========== + +See :ref:`cli_fts`. + +:: + + Usage: sqlite-utils enable-fts [OPTIONS] PATH TABLE COLUMN... + + Enable full-text search for specific table and columns + + Options: + --fts4 Use FTS4 + --fts5 Use FTS5 + --tokenize TEXT Tokenizer to use, e.g. porter + --create-triggers Create triggers to update the FTS tables when the + parent table changes. + --load-extension TEXT SQLite extensions to load + -h, --help Show this message and exit. + + +populate-fts +============ + +:: + + Usage: sqlite-utils populate-fts [OPTIONS] PATH TABLE COLUMN... + + Re-populate full-text search for specific table and columns + + Options: + --load-extension TEXT SQLite extensions to load + -h, --help Show this message and exit. + + +rebuild-fts +=========== + +:: + + Usage: sqlite-utils rebuild-fts [OPTIONS] PATH [TABLES]... + + Rebuild all or specific full-text search tables + + Options: + --load-extension TEXT SQLite extensions to load + -h, --help Show this message and exit. + + +disable-fts +=========== + +:: + + Usage: sqlite-utils disable-fts [OPTIONS] PATH TABLE + + Disable full-text search for specific table + + Options: + --load-extension TEXT SQLite extensions to load + -h, --help Show this message and exit. + + +optimize +======== + +See :ref:`cli_optimize`. + +:: + + Usage: sqlite-utils optimize [OPTIONS] PATH [TABLES]... + + Optimize all full-text search tables and then run VACUUM - should shrink the + database file + + Options: + --no-vacuum Don't run VACUUM + --load-extension TEXT SQLite extensions to load + -h, --help Show this message and exit. + + +analyze +======= + +See :ref:`cli_analyze`. + +:: + + Usage: sqlite-utils analyze [OPTIONS] PATH [NAMES]... + + Run ANALYZE against the whole database, or against specific named indexes and + tables + + Options: + -h, --help Show this message and exit. + + +vacuum +====== + +See :ref:`cli_vacuum`. + +:: + + Usage: sqlite-utils vacuum [OPTIONS] PATH + + Run VACUUM against the database + + Options: + -h, --help Show this message and exit. + + +dump +==== + +See :ref:`cli_dump`. + +:: + + Usage: sqlite-utils dump [OPTIONS] PATH + + Output a SQL dump of the schema and full contents of the database + + Options: + --load-extension TEXT SQLite extensions to load + -h, --help Show this message and exit. + + +add-column +========== + +See :ref:`cli_add_column`. + +:: + + Usage: sqlite-utils add-column [OPTIONS] PATH TABLE COL_NAME + [[integer|float|blob|text|INTEGER|FLOAT|BLOB|TEXT]] + + Add a column to the specified table + + Options: + --fk TEXT Table to reference as a foreign key + --fk-col TEXT Referenced column on that foreign key table - if + omitted will automatically use the primary key + --not-null-default TEXT Add NOT NULL DEFAULT 'TEXT' constraint + --load-extension TEXT SQLite extensions to load + -h, --help Show this message and exit. + + +add-foreign-key +=============== + +See :ref:`cli_add_foreign_key`. + +:: + + Usage: sqlite-utils add-foreign-key [OPTIONS] PATH TABLE COLUMN [OTHER_TABLE] + [OTHER_COLUMN] + + Add a new foreign key constraint to an existing table. Example usage: + + $ sqlite-utils add-foreign-key my.db books author_id authors id + + WARNING: Could corrupt your database! Back up your database file first. + + Options: + --ignore If foreign key already exists, do nothing + --load-extension TEXT SQLite extensions to load + -h, --help Show this message and exit. + + +add-foreign-keys +================ + +See :ref:`cli_add_foreign_keys`. + +:: + + Usage: sqlite-utils add-foreign-keys [OPTIONS] PATH [FOREIGN_KEY]... + + Add multiple new foreign key constraints to a database. Example usage: + + sqlite-utils add-foreign-keys my.db \ + books author_id authors id \ + authors country_id countries id + + Options: + --load-extension TEXT SQLite extensions to load + -h, --help Show this message and exit. + + +index-foreign-keys +================== + +See :ref:`cli_index_foreign_keys`. + +:: + + Usage: sqlite-utils index-foreign-keys [OPTIONS] PATH + + Ensure every foreign key column has an index on it. + + Options: + --load-extension TEXT SQLite extensions to load + -h, --help Show this message and exit. + + +enable-wal +========== + +See :ref:`cli_wal`. + +:: + + Usage: sqlite-utils enable-wal [OPTIONS] PATH... + + Enable WAL for database files + + Options: + --load-extension TEXT SQLite extensions to load + -h, --help Show this message and exit. + + +disable-wal +=========== + +:: + + Usage: sqlite-utils disable-wal [OPTIONS] PATH... + + Disable WAL for database files + + Options: + --load-extension TEXT SQLite extensions to load + -h, --help Show this message and exit. + + +enable-counts +============= + +See :ref:`cli_enable_counts`. + +:: + + Usage: sqlite-utils enable-counts [OPTIONS] PATH [TABLES]... + + Configure triggers to update a _counts table with row counts + + Options: + --load-extension TEXT SQLite extensions to load + -h, --help Show this message and exit. + + +reset-counts +============ + +:: + + Usage: sqlite-utils reset-counts [OPTIONS] PATH + + Reset calculated counts in the _counts table + + Options: + --load-extension TEXT SQLite extensions to load + -h, --help Show this message and exit. + + +drop-table +========== + +See :ref:`cli_drop_table`. + +:: + + Usage: sqlite-utils drop-table [OPTIONS] PATH TABLE + + Drop the specified table + + Options: + --ignore + --load-extension TEXT SQLite extensions to load + -h, --help Show this message and exit. + + +create-view +=========== + +See :ref:`cli_create_view`. + +:: + + Usage: sqlite-utils create-view [OPTIONS] PATH VIEW SELECT + + Create a view for the provided SELECT query + + Options: + --ignore If view already exists, do nothing + --replace If view already exists, replace it + --load-extension TEXT SQLite extensions to load + -h, --help Show this message and exit. + + +drop-view +========= + +See :ref:`cli_drop_view`. + +:: + + Usage: sqlite-utils drop-view [OPTIONS] PATH VIEW + + Drop the specified view + + Options: + --ignore + --load-extension TEXT SQLite extensions to load + -h, --help Show this message and exit. + + +.. [[[end]]] diff --git a/docs/index.rst b/docs/index.rst index 0629e0e..49bf8b1 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -33,5 +33,6 @@ Contents cli python-api reference + cli-reference contributing changelog From 324ebc31308752004fe5f7e4941fc83706c5539c Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 11 Jan 2022 15:19:29 -0800 Subject: [PATCH 078/563] sqlite-utils rows --limit and --offset options, closes #381 --- docs/cli-reference.rst | 2 ++ docs/cli.rst | 2 ++ sqlite_utils/cli.py | 19 ++++++++++++++++++- tests/test_cli.py | 8 ++++++++ 4 files changed, 30 insertions(+), 1 deletion(-) diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index 76630bb..90e3f7d 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -585,6 +585,8 @@ See :ref:`cli_rows`. Options: -c, --column TEXT Columns to return + --limit INTEGER Number of rows to return - defaults to everything + --offset INTEGER SQL offset to use --nl Output newline-delimited JSON --arrays Output rows as arrays instead of objects --csv Output CSV diff --git a/docs/cli.rst b/docs/cli.rst index d2fd9e7..dda1007 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -449,6 +449,8 @@ You can use the ``-c`` option to specify a subset of columns to return:: [{"age": 4, "name": "Cleo"}, {"age": 2, "name": "Pancakes"}] +Use ``--limit N`` to only return the first ``N`` rows. Use ``--offset N`` to return rows starting from the specified offset. + .. _cli_tables: Listing tables diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 0dd7f71..9c768b0 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -1723,6 +1723,16 @@ def search( ) @click.argument("dbtable") @click.option("-c", "--column", type=str, multiple=True, help="Columns to return") +@click.option( + "--limit", + type=int, + help="Number of rows to return - defaults to everything", +) +@click.option( + "--offset", + type=int, + help="SQL offset to use", +) @output_options @load_extension_option @click.pass_context @@ -1731,6 +1741,8 @@ def rows( path, dbtable, column, + limit, + offset, nl, arrays, csv, @@ -1745,10 +1757,15 @@ def rows( columns = "*" if column: columns = ", ".join("[{}]".format(c) for c in column) + sql = "select {} from [{}]".format(columns, dbtable) + if limit: + sql += " limit {}".format(limit) + if offset: + sql += " offset {}".format(offset) ctx.invoke( query, path=path, - sql="select {} from [{}]".format(columns, dbtable), + sql=sql, nl=nl, arrays=arrays, csv=csv, diff --git a/tests/test_cli.py b/tests/test_cli.py index 5f095a6..1db0849 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -851,6 +851,14 @@ def test_query_memory_does_not_create_file(tmpdir): ["--nl", "-c", "age", "-c", "name"], '{"age": 4, "name": "Cleo"}\n{"age": 2, "name": "Pancakes"}', ), + ( + ["-c", "name", "--limit", "1"], + '[{"name": "Cleo"}]', + ), + ( + ["-c", "name", "--limit", "1", "--offset", "1"], + '[{"name": "Pancakes"}]', + ), ], ) def test_rows(db_path, args, expected): From 3b632f0a7eda0aff444ea67a78f5003797b286c5 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 11 Jan 2022 15:32:43 -0800 Subject: [PATCH 079/563] sqlite-utils rows --where and -p options, closes #382 --- docs/cli-reference.rst | 38 ++++++++++++++++++++------------------ docs/cli.rst | 10 ++++++++++ sqlite_utils/cli.py | 13 +++++++++++++ tests/test_cli.py | 14 ++++++++++++++ 4 files changed, 57 insertions(+), 18 deletions(-) diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index 90e3f7d..d4acc6a 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -584,24 +584,26 @@ See :ref:`cli_rows`. Output all rows in the specified table Options: - -c, --column TEXT Columns to return - --limit INTEGER Number of rows to return - defaults to everything - --offset INTEGER SQL offset to use - --nl Output newline-delimited JSON - --arrays Output rows as arrays instead of objects - --csv Output CSV - --tsv Output TSV - --no-headers Omit CSV headers - -t, --table Output as a table - --fmt TEXT Table format - one of fancy_grid, fancy_outline, - github, grid, html, jira, latex, latex_booktabs, - latex_longtable, latex_raw, mediawiki, moinmoin, - orgtbl, pipe, plain, presto, pretty, psql, rst, simple, - textile, tsv, unsafehtml, youtrack - --json-cols Detect JSON cols and output them as JSON, not escaped - strings - --load-extension TEXT SQLite extensions to load - -h, --help Show this message and exit. + -c, --column TEXT Columns to return + --where TEXT Optional where clause + -p, --param ... Named :parameters for where clause + --limit INTEGER Number of rows to return - defaults to everything + --offset INTEGER SQL offset to use + --nl Output newline-delimited JSON + --arrays Output rows as arrays instead of objects + --csv Output CSV + --tsv Output TSV + --no-headers Omit CSV headers + -t, --table Output as a table + --fmt TEXT Table format - one of fancy_grid, fancy_outline, + github, grid, html, jira, latex, latex_booktabs, + latex_longtable, latex_raw, mediawiki, moinmoin, + orgtbl, pipe, plain, presto, pretty, psql, rst, + simple, textile, tsv, unsafehtml, youtrack + --json-cols Detect JSON cols and output them as JSON, not + escaped strings + --load-extension TEXT SQLite extensions to load + -h, --help Show this message and exit. triggers diff --git a/docs/cli.rst b/docs/cli.rst index dda1007..733eee8 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -449,6 +449,16 @@ You can use the ``-c`` option to specify a subset of columns to return:: [{"age": 4, "name": "Cleo"}, {"age": 2, "name": "Pancakes"}] +You can filter rows using a where clause with the ``--where`` option:: + + $ sqlite-utils rows dogs.db dogs -c name --where 'name = "Cleo"' + [{"name": "Cleo"}] + +Or pass named parameters using ``--where`` in combination with ``-p``:: + + $ sqlite-utils rows dogs.db dogs -c name --where 'name = :name' -p name Cleo + [{"name": "Cleo"}] + Use ``--limit N`` to only return the first ``N`` rows. Use ``--offset N`` to return rows starting from the specified offset. .. _cli_tables: diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 9c768b0..9f1331d 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -1723,6 +1723,14 @@ def search( ) @click.argument("dbtable") @click.option("-c", "--column", type=str, multiple=True, help="Columns to return") +@click.option("--where", help="Optional where clause") +@click.option( + "-p", + "--param", + multiple=True, + type=(str, str), + help="Named :parameters for where clause", +) @click.option( "--limit", type=int, @@ -1741,6 +1749,8 @@ def rows( path, dbtable, column, + where, + param, limit, offset, nl, @@ -1758,6 +1768,8 @@ def rows( if column: columns = ", ".join("[{}]".format(c) for c in column) sql = "select {} from [{}]".format(columns, dbtable) + if where: + sql += " where " + where if limit: sql += " limit {}".format(limit) if offset: @@ -1773,6 +1785,7 @@ def rows( no_headers=no_headers, table=table, fmt=fmt, + param=param, json_cols=json_cols, load_extension=load_extension, ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 1db0849..60c95b0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -851,6 +851,7 @@ def test_query_memory_does_not_create_file(tmpdir): ["--nl", "-c", "age", "-c", "name"], '{"age": 4, "name": "Cleo"}\n{"age": 2, "name": "Pancakes"}', ), + # --limit and --offset ( ["-c", "name", "--limit", "1"], '[{"name": "Cleo"}]', @@ -859,6 +860,19 @@ def test_query_memory_does_not_create_file(tmpdir): ["-c", "name", "--limit", "1", "--offset", "1"], '[{"name": "Pancakes"}]', ), + # --where + ( + ["-c", "name", "--where", "id = 1"], + '[{"name": "Cleo"}]', + ), + ( + ["-c", "name", "--where", "id = :id", "-p", "id", "1"], + '[{"name": "Cleo"}]', + ), + ( + ["-c", "name", "--where", "id = :id", "--param", "id", "1"], + '[{"name": "Cleo"}]', + ), ], ) def test_rows(db_path, args, expected): From 74586d3cb26fa3cc3412721985ecdc1864c2a31d Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 11 Jan 2022 15:44:48 -0800 Subject: [PATCH 080/563] Release 3.22 Refs #381, #382, #383 --- docs/changelog.rst | 9 +++++++++ setup.py | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 615dc7c..0af3c04 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -2,6 +2,15 @@ Changelog =========== +.. _v3_22: + +3.22 (2022-01-11) +----------------- + +- New :ref:`cli_reference` documentation page, listing the output of ``--help`` for every one of the CLI commands. (:issue:`383`) +- ``sqlite-utils rows`` now has ``--limit`` and ``--offset`` options for paginating through data. (:issue:`381`) +- ``sqlite-utils rows`` now has ``--where`` and ``-p`` options for filtering the table using a ``WHERE`` query, see :ref:`cli_rows`. (:issue:`382`) + .. _v3_21: 3.21 (2022-01-10) diff --git a/setup.py b/setup.py index 4c514e0..e0aa54b 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages import io import os -VERSION = "3.21" +VERSION = "3.22" def get_long_description(): From 7fdff5019d7c9d609fb00b5c7fd64bcde029e4c8 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 18 Jan 2022 18:15:21 -0800 Subject: [PATCH 081/563] Link to article from contributing, closes #386 --- docs/contributing.rst | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/contributing.rst b/docs/contributing.rst index c2d20ba..7ac1ad8 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -4,6 +4,15 @@ Contributing ============== +Development of ``sqlite-utils`` takes place in the `sqlite-utils GitHub repository `__. + +All improvements to the software should start with an issue. Read `How I build a feature `__ for a detailed description of the recommended process for building bug fixes or enhancements. + +.. _contributing_checkout: + +Obtaining the code +================== + To work on this library locally, first checkout the code. Then create a new virtual environment:: git clone git@github.com:simonw/sqlite-utils From 3091e6b6e9befe306310d2e5a484ffd88c0200bf Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 24 Jan 2022 20:06:40 -0800 Subject: [PATCH 082/563] Clearer help for --drop-foreign-key --- docs/cli-reference.rst | 2 +- sqlite_utils/cli.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index d4acc6a..546be1b 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -356,7 +356,7 @@ See :ref:`cli_transform_table`. --pk-none Remove primary key (convert to rowid table) --default ... Set default value for this column --default-none TEXT Remove default from this column - --drop-foreign-key TEXT Drop this foreign key constraint + --drop-foreign-key TEXT Drop foreign key constraint for this column --sql Output SQL without executing it --load-extension TEXT SQLite extensions to load -h, --help Show this message and exit. diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 9f1331d..4f950ad 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -1964,7 +1964,7 @@ def schema( "--drop-foreign-key", type=str, multiple=True, - help="Drop this foreign key constraint", + help="Drop foreign key constraint for this column", ) @click.option("--sql", is_flag=True, help="Output SQL without executing it") @load_extension_option From 82ea42ffeedd5c80570b1e6f16124dd80f8f4a1b Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 24 Jan 2022 20:12:32 -0800 Subject: [PATCH 083/563] Added missing docstring for db.supports_strict --- sqlite_utils/db.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index e40eb12..06e7557 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -524,7 +524,8 @@ class Database: return "\n".join(sqls) @property - def supports_strict(self): + def supports_strict(self) -> bool: + "Does this database support STRICT mode?" try: table_name = "t{}".format(secrets.token_hex(16)) with self.conn: From 8d51ae48ab084284681d597b436be2112650a3b9 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 25 Jan 2022 17:35:26 -0800 Subject: [PATCH 084/563] Getting started section for Python library, closes #387 --- docs/python-api.rst | 59 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/docs/python-api.rst b/docs/python-api.rst index 6b0d542..42dfe94 100644 --- a/docs/python-api.rst +++ b/docs/python-api.rst @@ -6,6 +6,65 @@ .. contents:: :local: +.. _python_api_getting_started: + +Getting started +=============== + +Here's how to create a new SQLite database file containing a new ``chickens`` table, populated with four records: + +.. code-block:: python + + from sqlite_utils import Database + + db = Database("chickens.db") + db["chickens"].insert_all([{ + "name": "Azi", + "color": "blue", + }, { + "name": "Lila", + "color": "blue", + }, { + "name": "Suna", + "color": "gold", + }, { + "name": "Cardi", + "color": "black", + }]) + +You can loop through those rows like this: + +.. code-block:: python + + for row in db["chickens"].rows: + print(row) + +Which outputs the following:: + + {'name': 'Azi', 'color': 'blue'} + {'name': 'Lila', 'color': 'blue'} + {'name': 'Suna', 'color': 'gold'} + {'name': 'Cardi', 'color': 'black'} + +To run a SQL query, use :ref:`db.query() `: + +.. code-block:: python + + for row in db.query(""" + select color, count(*) + from chickens group by color + order by count(*) desc + """): + print(row) + +Which outputs:: + + {'color': 'blue', 'count(*)': 2} + {'color': 'gold', 'count(*)': 1} + {'color': 'black', 'count(*)': 1} + +.. _python_api_connect: + Connecting to or creating a database ==================================== From b3efb292127036d710ae3cb63daa36cd7a4d7d0c Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 25 Jan 2022 17:48:57 -0800 Subject: [PATCH 085/563] SQLite can drop columns now It gained that ability in 3.35.0 in 2021-03-12: https://www.sqlite.org/changes.html#version_3_35_0 --- README.md | 2 +- docs/python-api.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9345286..9debfcd 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Python CLI utility and library for manipulating SQLite databases. - [Pipe JSON](https://sqlite-utils.datasette.io/en/stable/cli.html#inserting-json-data) (or [CSV or TSV](https://sqlite-utils.datasette.io/en/stable/cli.html#inserting-csv-or-tsv-data)) directly into a new SQLite database file, automatically creating a table with the appropriate schema - [Run in-memory SQL queries](https://sqlite-utils.datasette.io/en/stable/cli.html#querying-data-directly-using-an-in-memory-database), including joins, directly against data in CSV, TSV or JSON files and view the results. - [Configure SQLite full-text search](https://sqlite-utils.datasette.io/en/stable/cli.html#configuring-full-text-search) against your database tables and run search queries against them, ordered by relevance -- Run [transformations against your tables](https://sqlite-utils.datasette.io/en/stable/cli.html#transforming-tables) to make schema changes that SQLite `ALTER TABLE` does not directly support, such as dropping columns +- Run [transformations against your tables](https://sqlite-utils.datasette.io/en/stable/cli.html#transforming-tables) to make schema changes that SQLite `ALTER TABLE` does not directly support, such as changing the type of a column - [Extract columns](https://sqlite-utils.datasette.io/en/stable/cli.html#extracting-columns-into-a-separate-table) into separate tables to better normalize your existing data Read more on my blog: [ diff --git a/docs/python-api.rst b/docs/python-api.rst index 42dfe94..6b703fe 100644 --- a/docs/python-api.rst +++ b/docs/python-api.rst @@ -1247,7 +1247,7 @@ Pass ``ignore=True`` if you want to ignore the error caused by the table or view Transforming a table ==================== -The SQLite ``ALTER TABLE`` statement is limited. It can add columns and rename tables, but it cannot drop columns, change column types, change ``NOT NULL`` status or change the primary key for a table. +The SQLite ``ALTER TABLE`` statement is limited. It can add and drop columns and rename tables, but it cannot change column types, change ``NOT NULL`` status or change the primary key for a table. The ``table.transform()`` method can do all of these things, by implementing a multi-step pattern `described in the SQLite documentation `__: From 89c01103ec0b684b6f871694f77fc49d0cb57f98 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 25 Jan 2022 18:00:16 -0800 Subject: [PATCH 086/563] Custom layout template for docs Adds plausible analytics, closes #389 Implements banner on latest page, closes #388 --- docs/_templates/layout.html | 39 +++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 docs/_templates/layout.html diff --git a/docs/_templates/layout.html b/docs/_templates/layout.html new file mode 100644 index 0000000..150edd0 --- /dev/null +++ b/docs/_templates/layout.html @@ -0,0 +1,39 @@ +{%- extends "!layout.html" %} + +{% block htmltitle %} +{{ super() }} + +{% endblock %} + +{% block footer %} +{{ super() }} + +{% endblock %} From 25d8c820de195039106b736440a5c4e3f72cd8b6 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 25 Jan 2022 18:06:02 -0800 Subject: [PATCH 087/563] Correct domain for Plausible, refs #389 --- docs/_templates/layout.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/_templates/layout.html b/docs/_templates/layout.html index 150edd0..cecf66e 100644 --- a/docs/_templates/layout.html +++ b/docs/_templates/layout.html @@ -2,7 +2,7 @@ {% block htmltitle %} {{ super() }} - + {% endblock %} {% block footer %} From 6e85a4bbbefced11501a8e215d0847addc159199 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 25 Jan 2022 18:56:44 -0800 Subject: [PATCH 088/563] Added examples to more --help output, refs #384 --- docs/cli-reference.rst | 100 +++++++++++++++++++++++++++-------- sqlite_utils/cli.py | 117 +++++++++++++++++++++++++++++++++-------- 2 files changed, 171 insertions(+), 46 deletions(-) diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index 546be1b..868cc3b 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -85,6 +85,12 @@ See :ref:`cli_query`. Execute SQL query and return the results as JSON + Example: + + sqlite-utils data.db \ + "select * from chickens where age > :age" \ + -p age 1 + Options: --attach ... Additional databases to attach - specify alias and filepath @@ -93,7 +99,7 @@ See :ref:`cli_query`. --csv Output CSV --tsv Output TSV --no-headers Omit CSV headers - -t, --table Output as a table + -t, --table Output as a formatted table --fmt TEXT Table format - one of fancy_grid, fancy_outline, github, grid, html, jira, latex, latex_booktabs, latex_longtable, latex_raw, mediawiki, moinmoin, @@ -150,7 +156,7 @@ See :ref:`cli_memory`. --csv Output CSV --tsv Output TSV --no-headers Omit CSV headers - -t, --table Output as a table + -t, --table Output as a formatted table --fmt TEXT Table format - one of fancy_grid, fancy_outline, github, grid, html, jira, latex, latex_booktabs, latex_longtable, latex_raw, mediawiki, moinmoin, @@ -183,7 +189,11 @@ See :ref:`cli_inserting_data`, :ref:`cli_insert_csv_tsv`. Insert records from FILE into a table, creating the table if it does not already exist. - By default the input is expected to be a JSON array of objects. Or: + Example: + + echo '{"name": "Lila"}' | sqlite-utils insert data.db chickens - + + By default the input is expected to be a JSON object or array of objects. - Use --nl for newline-delimited JSON objects - Use --csv or --tsv for comma-separated or tab-separated input @@ -243,6 +253,15 @@ See :ref:`cli_upsert`. incoming record has a primary key that matches an existing record the existing record will be updated. + The --pk option is required. + + Example: + + echo '[ + {"id": 1, "name": "Lila"}, + {"id": 2, "name": "Suna"} + ]' | sqlite-utils upsert data.db chickens - --pk id + Options: --pk TEXT Columns to use as the primary key, e.g. id --flatten Flatten nested JSON objects, so {"a": {"b": 1}} @@ -281,6 +300,15 @@ See :ref:`cli_bulk`. Execute parameterized SQL against the provided list of documents. + Example: + + echo '[ + {"id": 1, "name": "Lila2"}, + {"id": 2, "name": "Suna2"} + ]' | sqlite-utils bulk data.db ' + update chickens set name = :name where id = :id + ' - + Options: --flatten Flatten nested JSON objects, so {"a": {"b": 1}} becomes {"a_b": 1} @@ -311,6 +339,10 @@ See :ref:`cli_search`. Execute a full-text search against this table + Example: + + sqlite-utils search data.db chickens lila + Options: -o, --order TEXT Order by ('column' or 'column desc') -c, --column TEXT Columns to return @@ -322,7 +354,7 @@ See :ref:`cli_search`. --csv Output CSV --tsv Output TSV --no-headers Omit CSV headers - -t, --table Output as a table + -t, --table Output as a formatted table --fmt TEXT Table format - one of fancy_grid, fancy_outline, github, grid, html, jira, latex, latex_booktabs, latex_longtable, latex_raw, mediawiki, moinmoin, @@ -345,6 +377,12 @@ See :ref:`cli_transform_table`. Transform a table beyond the capabilities of ALTER TABLE + Example: + + sqlite-utils transform mydb.db mytable \ + --drop column1 \ + --rename column2 column_renamed + Options: --type ... Change column type to INTEGER, TEXT, FLOAT or BLOB --drop TEXT Drop this column @@ -373,6 +411,10 @@ See :ref:`cli_extract`. Extract one or more columns into a separate table + Example: + + sqlite-utils extract trees.db Street_Trees species + Options: --table TEXT Name of the other table to extract columns to --fk-column TEXT Name of the foreign key column to add to the table @@ -392,6 +434,10 @@ See :ref:`cli_schema`. Show full schema for this database or for specified tables + Example: + + sqlite-utils schema trees.db + Options: --load-extension TEXT SQLite extensions to load -h, --help Show this message and exit. @@ -408,16 +454,16 @@ See :ref:`cli_insert_files`. Insert one or more files using BLOB columns in the specified table - Example usage: + Example: - sqlite-utils insert-files pics.db images *.gif \ - -c name:name \ - -c content:content \ - -c content_hash:sha256 \ - -c created:ctime_iso \ - -c modified:mtime_iso \ - -c size:size \ - --pk name + sqlite-utils insert-files pics.db images *.gif \ + -c name:name \ + -c content:content \ + -c content_hash:sha256 \ + -c created:ctime_iso \ + -c modified:mtime_iso \ + -c size:size \ + --pk name Options: -c, --column TEXT Column definitions for the table @@ -444,6 +490,10 @@ See :ref:`cli_analyze_tables`. Analyze the columns in one or more tables + Example: + + sqlite-utils analyze-tables data.db trees + Options: -c, --column TEXT Specific columns to analyze --save Save results to _analyze_tables table @@ -462,9 +512,9 @@ See :ref:`cli_convert`. Convert columns using Python code you supply. For example: - $ sqlite-utils convert my.db mytable mycolumn \ - '"\n".join(textwrap.wrap(value, 10))' \ - --import=textwrap + sqlite-utils convert my.db mytable mycolumn \ + '"\n".join(textwrap.wrap(value, 10))' \ + --import=textwrap "value" is a variable with the column value to be converted. @@ -486,8 +536,8 @@ See :ref:`cli_convert`. You can use these recipes like so: - $ sqlite-utils convert my.db mytable mycolumn \ - 'r.jsonsplit(value, delimiter=":")' + sqlite-utils convert my.db mytable mycolumn \ + 'r.jsonsplit(value, delimiter=":")' Options: --import TEXT Python modules to import @@ -517,6 +567,10 @@ See :ref:`cli_tables`. List the tables in the database + Example: + + sqlite-utils tables trees.db + Options: --fts4 Just show FTS4 enabled tables --fts5 Just show FTS5 enabled tables @@ -526,7 +580,7 @@ See :ref:`cli_tables`. --csv Output CSV --tsv Output TSV --no-headers Omit CSV headers - -t, --table Output as a table + -t, --table Output as a formatted table --fmt TEXT Table format - one of fancy_grid, fancy_outline, github, grid, html, jira, latex, latex_booktabs, latex_longtable, latex_raw, mediawiki, moinmoin, @@ -558,7 +612,7 @@ See :ref:`cli_views`. --csv Output CSV --tsv Output TSV --no-headers Omit CSV headers - -t, --table Output as a table + -t, --table Output as a formatted table --fmt TEXT Table format - one of fancy_grid, fancy_outline, github, grid, html, jira, latex, latex_booktabs, latex_longtable, latex_raw, mediawiki, moinmoin, @@ -594,7 +648,7 @@ See :ref:`cli_rows`. --csv Output CSV --tsv Output TSV --no-headers Omit CSV headers - -t, --table Output as a table + -t, --table Output as a formatted table --fmt TEXT Table format - one of fancy_grid, fancy_outline, github, grid, html, jira, latex, latex_booktabs, latex_longtable, latex_raw, mediawiki, moinmoin, @@ -623,7 +677,7 @@ See :ref:`cli_triggers`. --csv Output CSV --tsv Output TSV --no-headers Omit CSV headers - -t, --table Output as a table + -t, --table Output as a formatted table --fmt TEXT Table format - one of fancy_grid, fancy_outline, github, grid, html, jira, latex, latex_booktabs, latex_longtable, latex_raw, mediawiki, moinmoin, @@ -653,7 +707,7 @@ See :ref:`cli_indexes`. --csv Output CSV --tsv Output TSV --no-headers Omit CSV headers - -t, --table Output as a table + -t, --table Output as a formatted table --fmt TEXT Table format - one of fancy_grid, fancy_outline, github, grid, html, jira, latex, latex_booktabs, latex_longtable, latex_raw, mediawiki, moinmoin, diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 4f950ad..42fc8e8 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -75,7 +75,9 @@ def output_options(fn): click.option("--csv", is_flag=True, help="Output CSV"), click.option("--tsv", is_flag=True, help="Output TSV"), click.option("--no-headers", is_flag=True, help="Omit CSV headers"), - click.option("-t", "--table", is_flag=True, help="Output as a table"), + click.option( + "-t", "--table", is_flag=True, help="Output as a formatted table" + ), click.option( "--fmt", help="Table format - one of {}".format( @@ -161,7 +163,13 @@ def tables( load_extension, views=False, ): - """List the tables in the database""" + """List the tables in the database + + Example: + + \b + sqlite-utils tables trees.db + """ db = sqlite_utils.Database(path) _load_extensions(db, load_extension) headers = ["view" if views else "table"] @@ -1001,7 +1009,11 @@ def insert( Insert records from FILE into a table, creating the table if it does not already exist. - By default the input is expected to be a JSON array of objects. Or: + Example: + + echo '{"name": "Lila"}' | sqlite-utils insert data.db chickens - + + By default the input is expected to be a JSON object or array of objects. \b - Use --nl for newline-delimited JSON objects @@ -1087,6 +1099,16 @@ def upsert( Upsert records based on their primary key. Works like 'insert' but if an incoming record has a primary key that matches an existing record the existing record will be updated. + + The --pk option is required. + + Example: + + \b + echo '[ + {"id": 1, "name": "Lila"}, + {"id": 2, "name": "Suna"} + ]' | sqlite-utils upsert data.db chickens - --pk id """ try: insert_upsert_implementation( @@ -1152,6 +1174,16 @@ def bulk( ): """ Execute parameterized SQL against the provided list of documents. + + Example: + + \b + echo '[ + {"id": 1, "name": "Lila2"}, + {"id": 2, "name": "Suna2"} + ]' | sqlite-utils bulk data.db ' + update chickens set name = :name where id = :id + ' - """ try: insert_upsert_implementation( @@ -1402,7 +1434,15 @@ def query( param, load_extension, ): - "Execute SQL query and return the results as JSON" + """Execute SQL query and return the results as JSON + + Example: + + \b + sqlite-utils data.db \\ + "select * from chickens where age > :age" \\ + -p age 1 + """ db = sqlite_utils.Database(path) for alias, attach_path in attach: db.attach(alias, attach_path) @@ -1665,7 +1705,12 @@ def search( json_cols, load_extension, ): - "Execute a full-text search against this table" + """Execute a full-text search against this table + + Example: + + sqlite-utils search data.db chickens lila + """ db = sqlite_utils.Database(path) _load_extensions(db, load_extension) # Check table exists @@ -1912,7 +1957,13 @@ def schema( tables, load_extension, ): - "Show full schema for this database or for specified tables" + """Show full schema for this database or for specified tables + + Example: + + \b + sqlite-utils schema trees.db + """ db = sqlite_utils.Database(path) _load_extensions(db, load_extension) if tables: @@ -1985,7 +2036,15 @@ def transform( sql, load_extension, ): - "Transform a table beyond the capabilities of ALTER TABLE" + """Transform a table beyond the capabilities of ALTER TABLE + + Example: + + \b + sqlite-utils transform mydb.db mytable \\ + --drop column1 \\ + --rename column2 column_renamed + """ db = sqlite_utils.Database(path) _load_extensions(db, load_extension) types = {} @@ -2060,7 +2119,13 @@ def extract( rename, load_extension, ): - "Extract one or more columns into a separate table" + """Extract one or more columns into a separate table + + Example: + + \b + sqlite-utils extract trees.db Street_Trees species + """ db = sqlite_utils.Database(path) _load_extensions(db, load_extension) kwargs = dict( @@ -2122,17 +2187,17 @@ def insert_files( """ Insert one or more files using BLOB columns in the specified table - Example usage: + Example: \b - sqlite-utils insert-files pics.db images *.gif \\ - -c name:name \\ - -c content:content \\ - -c content_hash:sha256 \\ - -c created:ctime_iso \\ - -c modified:mtime_iso \\ - -c size:size \\ - --pk name + sqlite-utils insert-files pics.db images *.gif \\ + -c name:name \\ + -c content:content \\ + -c content_hash:sha256 \\ + -c created:ctime_iso \\ + -c modified:mtime_iso \\ + -c size:size \\ + --pk name """ if not column: if text: @@ -2244,7 +2309,13 @@ def analyze_tables( save, load_extension, ): - "Analyze the columns in one or more tables" + """Analyze the columns in one or more tables + + Example: + + \b + sqlite-utils analyze-tables data.db trees + """ db = sqlite_utils.Database(path) _load_extensions(db, load_extension) _analyze(db, tables, columns, save) @@ -2308,9 +2379,9 @@ def _generate_convert_help(): Convert columns using Python code you supply. For example: \b - $ sqlite-utils convert my.db mytable mycolumn \\ - '"\\n".join(textwrap.wrap(value, 10))' \\ - --import=textwrap + sqlite-utils convert my.db mytable mycolumn \\ + '"\\n".join(textwrap.wrap(value, 10))' \\ + --import=textwrap "value" is a variable with the column value to be converted. @@ -2333,8 +2404,8 @@ def _generate_convert_help(): You can use these recipes like so: \b - $ sqlite-utils convert my.db mytable mycolumn \\ - 'r.jsonsplit(value, delimiter=":")' + sqlite-utils convert my.db mytable mycolumn \\ + 'r.jsonsplit(value, delimiter=":")' """ ).strip() return help From 4c6023452cbfc0c112cfc2a940ed40d22e8d36c9 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 25 Jan 2022 19:14:59 -0800 Subject: [PATCH 089/563] Examples in --help for remaining commands, closes #384 --- docs/cli-reference.rst | 135 ++++++++++++++++++++++---- sqlite_utils/cli.py | 216 ++++++++++++++++++++++++++++++++++------- 2 files changed, 297 insertions(+), 54 deletions(-) diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index 868cc3b..583f7e2 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -605,6 +605,10 @@ See :ref:`cli_views`. List the views in the database + Example: + + sqlite-utils views trees.db + Options: --counts Include row counts per view --nl Output newline-delimited JSON @@ -637,6 +641,10 @@ See :ref:`cli_rows`. Output all rows in the specified table + Example: + + sqlite-utils rows trees.db Trees + Options: -c, --column TEXT Columns to return --where TEXT Optional where clause @@ -671,6 +679,10 @@ See :ref:`cli_triggers`. Show triggers configured in this database + Example: + + sqlite-utils triggers trees.db + Options: --nl Output newline-delimited JSON --arrays Output rows as arrays instead of objects @@ -698,7 +710,11 @@ See :ref:`cli_indexes`. Usage: sqlite-utils indexes [OPTIONS] PATH [TABLES]... - Show indexes for this database + Show indexes for the whole database or specific tables + + Example: + + sqlite-utils indexes trees.db Trees Options: --aux Include auxiliary columns @@ -728,7 +744,11 @@ See :ref:`cli_create_database`. Usage: sqlite-utils create-database [OPTIONS] PATH - Create a new empty database file. + Create a new empty database file + + Example: + + sqlite-utils create-database trees.db Options: --enable-wal Enable WAL mode on the created database @@ -747,11 +767,11 @@ See :ref:`cli_create_table`. Add a table with the specified columns. Columns should be specified using name, type pairs, for example: - sqlite-utils create-table my.db people \ - id integer \ - name text \ - height float \ - photo blob --pk id + sqlite-utils create-table my.db people \ + id integer \ + name text \ + height float \ + photo blob --pk id Options: --pk TEXT Column to use as primary key @@ -774,9 +794,15 @@ See :ref:`cli_create_index`. Usage: sqlite-utils create-index [OPTIONS] PATH TABLE COLUMN... - Add an index to the specified table covering the specified columns. Use - "sqlite-utils create-index mydb -- -column" to specify descending order for a - column. + Add an index to the specified table for the specified columns + + Example: + + sqlite-utils create-index chickens.db chickens name + + To create an index in descending order: + + sqlite-utils create-index chickens.db chickens -- -name Options: --name TEXT Explicit name for the new index @@ -796,7 +822,11 @@ See :ref:`cli_fts`. Usage: sqlite-utils enable-fts [OPTIONS] PATH TABLE COLUMN... - Enable full-text search for specific table and columns + Enable full-text search for specific table and columns" + + Example: + + sqlite-utils enable-fts chickens.db chickens name Options: --fts4 Use FTS4 @@ -817,6 +847,10 @@ populate-fts Re-populate full-text search for specific table and columns + Example: + + sqlite-utils populate-fts chickens.db chickens name + Options: --load-extension TEXT SQLite extensions to load -h, --help Show this message and exit. @@ -831,6 +865,10 @@ rebuild-fts Rebuild all or specific full-text search tables + Example: + + sqlite-utils rebuild-fts chickens.db chickens + Options: --load-extension TEXT SQLite extensions to load -h, --help Show this message and exit. @@ -845,6 +883,10 @@ disable-fts Disable full-text search for specific table + Example: + + sqlite-utils disable-fts chickens.db chickens + Options: --load-extension TEXT SQLite extensions to load -h, --help Show this message and exit. @@ -862,6 +904,10 @@ See :ref:`cli_optimize`. Optimize all full-text search tables and then run VACUUM - should shrink the database file + Example: + + sqlite-utils optimize chickens.db + Options: --no-vacuum Don't run VACUUM --load-extension TEXT SQLite extensions to load @@ -880,6 +926,10 @@ See :ref:`cli_analyze`. Run ANALYZE against the whole database, or against specific named indexes and tables + Example: + + sqlite-utils analyze chickens.db + Options: -h, --help Show this message and exit. @@ -895,6 +945,10 @@ See :ref:`cli_vacuum`. Run VACUUM against the database + Example: + + sqlite-utils vacuum chickens.db + Options: -h, --help Show this message and exit. @@ -910,6 +964,10 @@ See :ref:`cli_dump`. Output a SQL dump of the schema and full contents of the database + Example: + + sqlite-utils dump chickens.db + Options: --load-extension TEXT SQLite extensions to load -h, --help Show this message and exit. @@ -927,6 +985,10 @@ See :ref:`cli_add_column`. Add a column to the specified table + Example: + + sqlite-utils add-column chickens.db chickens weight float + Options: --fk TEXT Table to reference as a foreign key --fk-col TEXT Referenced column on that foreign key table - if @@ -946,9 +1008,11 @@ See :ref:`cli_add_foreign_key`. Usage: sqlite-utils add-foreign-key [OPTIONS] PATH TABLE COLUMN [OTHER_TABLE] [OTHER_COLUMN] - Add a new foreign key constraint to an existing table. Example usage: + Add a new foreign key constraint to an existing table - $ sqlite-utils add-foreign-key my.db books author_id authors id + Example: + + sqlite-utils add-foreign-key my.db books author_id authors id WARNING: Could corrupt your database! Back up your database file first. @@ -967,11 +1031,13 @@ See :ref:`cli_add_foreign_keys`. Usage: sqlite-utils add-foreign-keys [OPTIONS] PATH [FOREIGN_KEY]... - Add multiple new foreign key constraints to a database. Example usage: + Add multiple new foreign key constraints to a database - sqlite-utils add-foreign-keys my.db \ - books author_id authors id \ - authors country_id countries id + Example: + + sqlite-utils add-foreign-keys my.db \ + books author_id authors id \ + authors country_id countries id Options: --load-extension TEXT SQLite extensions to load @@ -987,7 +1053,11 @@ See :ref:`cli_index_foreign_keys`. Usage: sqlite-utils index-foreign-keys [OPTIONS] PATH - Ensure every foreign key column has an index on it. + Ensure every foreign key column has an index on it + + Example: + + sqlite-utils index-foreign-keys chickens.db Options: --load-extension TEXT SQLite extensions to load @@ -1005,6 +1075,10 @@ See :ref:`cli_wal`. Enable WAL for database files + Example: + + sqlite-utils enable-wal chickens.db + Options: --load-extension TEXT SQLite extensions to load -h, --help Show this message and exit. @@ -1019,6 +1093,10 @@ disable-wal Disable WAL for database files + Example: + + sqlite-utils disable-wal chickens.db + Options: --load-extension TEXT SQLite extensions to load -h, --help Show this message and exit. @@ -1035,6 +1113,10 @@ See :ref:`cli_enable_counts`. Configure triggers to update a _counts table with row counts + Example: + + sqlite-utils enable-counts chickens.db + Options: --load-extension TEXT SQLite extensions to load -h, --help Show this message and exit. @@ -1049,6 +1131,10 @@ reset-counts Reset calculated counts in the _counts table + Example: + + sqlite-utils reset-counts chickens.db + Options: --load-extension TEXT SQLite extensions to load -h, --help Show this message and exit. @@ -1065,6 +1151,10 @@ See :ref:`cli_drop_table`. Drop the specified table + Example: + + sqlite-utils drop-table chickens.db chickens + Options: --ignore --load-extension TEXT SQLite extensions to load @@ -1082,6 +1172,11 @@ See :ref:`cli_create_view`. Create a view for the provided SELECT query + Example: + + sqlite-utils create-view chickens.db heavy_chickens \ + 'select * from chickens where weight > 3' + Options: --ignore If view already exists, do nothing --replace If view already exists, replace it @@ -1100,6 +1195,10 @@ See :ref:`cli_drop_view`. Drop the specified view + Example: + + sqlite-utils drop-view chickens.db heavy_chickens + Options: --ignore --load-extension TEXT SQLite extensions to load diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 42fc8e8..350f9e3 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -250,7 +250,13 @@ def views( schema, load_extension, ): - """List the views in the database""" + """List the views in the database + + Example: + + \b + sqlite-utils views trees.db + """ tables.callback( path=path, fts4=False, @@ -281,7 +287,13 @@ def views( @click.option("--no-vacuum", help="Don't run VACUUM", default=False, is_flag=True) @load_extension_option def optimize(path, tables, no_vacuum, load_extension): - """Optimize all full-text search tables and then run VACUUM - should shrink the database file""" + """Optimize all full-text search tables and then run VACUUM - should shrink the database file + + Example: + + \b + sqlite-utils optimize chickens.db + """ db = sqlite_utils.Database(path) _load_extensions(db, load_extension) if not tables: @@ -302,7 +314,13 @@ def optimize(path, tables, no_vacuum, load_extension): @click.argument("tables", nargs=-1) @load_extension_option def rebuild_fts(path, tables, load_extension): - """Rebuild all or specific full-text search tables""" + """Rebuild all or specific full-text search tables + + Example: + + \b + sqlite-utils rebuild-fts chickens.db chickens + """ db = sqlite_utils.Database(path) _load_extensions(db, load_extension) if not tables: @@ -320,7 +338,13 @@ def rebuild_fts(path, tables, load_extension): ) @click.argument("names", nargs=-1) def analyze(path, names): - """Run ANALYZE against the whole database, or against specific named indexes and tables""" + """Run ANALYZE against the whole database, or against specific named indexes and tables + + Example: + + \b + sqlite-utils analyze chickens.db + """ db = sqlite_utils.Database(path) try: if names: @@ -339,7 +363,13 @@ def analyze(path, names): required=True, ) def vacuum(path): - """Run VACUUM against the database""" + """Run VACUUM against the database + + Example: + + \b + sqlite-utils vacuum chickens.db + """ sqlite_utils.Database(path).vacuum() @@ -351,7 +381,13 @@ def vacuum(path): ) @load_extension_option def dump(path, load_extension): - """Output a SQL dump of the schema and full contents of the database""" + """Output a SQL dump of the schema and full contents of the database + + Example: + + \b + sqlite-utils dump chickens.db + """ db = sqlite_utils.Database(path) _load_extensions(db, load_extension) for line in db.conn.iterdump(): @@ -392,7 +428,13 @@ def dump(path, load_extension): def add_column( path, table, col_name, col_type, fk, fk_col, not_null_default, load_extension ): - "Add a column to the specified table" + """Add a column to the specified table + + Example: + + \b + sqlite-utils add-column chickens.db chickens weight float + """ db = sqlite_utils.Database(path) _load_extensions(db, load_extension) db[table].add_column( @@ -420,9 +462,11 @@ def add_foreign_key( path, table, column, other_table, other_column, ignore, load_extension ): """ - Add a new foreign key constraint to an existing table. Example usage: + Add a new foreign key constraint to an existing table - $ sqlite-utils add-foreign-key my.db books author_id authors id + Example: + + sqlite-utils add-foreign-key my.db books author_id authors id WARNING: Could corrupt your database! Back up your database file first. """ @@ -444,12 +488,14 @@ def add_foreign_key( @load_extension_option def add_foreign_keys(path, foreign_key, load_extension): """ - Add multiple new foreign key constraints to a database. Example usage: + Add multiple new foreign key constraints to a database + + Example: \b - sqlite-utils add-foreign-keys my.db \\ - books author_id authors id \\ - authors country_id countries id + sqlite-utils add-foreign-keys my.db \\ + books author_id authors id \\ + authors country_id countries id """ db = sqlite_utils.Database(path) _load_extensions(db, load_extension) @@ -475,7 +521,12 @@ def add_foreign_keys(path, foreign_key, load_extension): @load_extension_option def index_foreign_keys(path, load_extension): """ - Ensure every foreign key column has an index on it. + Ensure every foreign key column has an index on it + + Example: + + \b + sqlite-utils index-foreign-keys chickens.db """ db = sqlite_utils.Database(path) _load_extensions(db, load_extension) @@ -508,9 +559,17 @@ def create_index( path, table, column, name, unique, if_not_exists, analyze, load_extension ): """ - Add an index to the specified table covering the specified columns. - Use "sqlite-utils create-index mydb -- -column" to specify descending - order for a column. + Add an index to the specified table for the specified columns + + Example: + + \b + sqlite-utils create-index chickens.db chickens name + + To create an index in descending order: + + \b + sqlite-utils create-index chickens.db chickens -- -name """ db = sqlite_utils.Database(path) _load_extensions(db, load_extension) @@ -550,7 +609,13 @@ def create_index( def enable_fts( path, table, column, fts4, fts5, tokenize, create_triggers, load_extension ): - "Enable full-text search for specific table and columns" + """Enable full-text search for specific table and columns" + + Example: + + \b + sqlite-utils enable-fts chickens.db chickens name + """ fts_version = "FTS5" if fts4 and fts5: click.echo("Can only use one of --fts4 or --fts5", err=True) @@ -578,7 +643,13 @@ def enable_fts( @click.argument("column", nargs=-1, required=True) @load_extension_option def populate_fts(path, table, column, load_extension): - "Re-populate full-text search for specific table and columns" + """Re-populate full-text search for specific table and columns + + Example: + + \b + sqlite-utils populate-fts chickens.db chickens name + """ db = sqlite_utils.Database(path) _load_extensions(db, load_extension) db[table].populate_fts(column) @@ -593,7 +664,13 @@ def populate_fts(path, table, column, load_extension): @click.argument("table") @load_extension_option def disable_fts(path, table, load_extension): - "Disable full-text search for specific table" + """Disable full-text search for specific table + + Example: + + \b + sqlite-utils disable-fts chickens.db chickens + """ db = sqlite_utils.Database(path) _load_extensions(db, load_extension) db[table].disable_fts() @@ -608,7 +685,13 @@ def disable_fts(path, table, load_extension): ) @load_extension_option def enable_wal(path, load_extension): - "Enable WAL for database files" + """Enable WAL for database files + + Example: + + \b + sqlite-utils enable-wal chickens.db + """ for path_ in path: db = sqlite_utils.Database(path_) _load_extensions(db, load_extension) @@ -624,7 +707,13 @@ def enable_wal(path, load_extension): ) @load_extension_option def disable_wal(path, load_extension): - "Disable WAL for database files" + """Disable WAL for database files + + Example: + + \b + sqlite-utils disable-wal chickens.db + """ for path_ in path: db = sqlite_utils.Database(path_) _load_extensions(db, load_extension) @@ -640,7 +729,13 @@ def disable_wal(path, load_extension): @click.argument("tables", nargs=-1) @load_extension_option def enable_counts(path, tables, load_extension): - "Configure triggers to update a _counts table with row counts" + """Configure triggers to update a _counts table with row counts + + Example: + + \b + sqlite-utils enable-counts chickens.db + """ db = sqlite_utils.Database(path) _load_extensions(db, load_extension) if not tables: @@ -662,7 +757,13 @@ def enable_counts(path, tables, load_extension): ) @load_extension_option def reset_counts(path, load_extension): - "Reset calculated counts in the _counts table" + """Reset calculated counts in the _counts table + + Example: + + \b + sqlite-utils reset-counts chickens.db + """ db = sqlite_utils.Database(path) _load_extensions(db, load_extension) db.reset_counts() @@ -1228,7 +1329,13 @@ def bulk( "--enable-wal", is_flag=True, help="Enable WAL mode on the created database" ) def create_database(path, enable_wal): - "Create a new empty database file." + """Create a new empty database file + + Example: + + \b + sqlite-utils create-database trees.db + """ db = sqlite_utils.Database(path) if enable_wal: db.enable_wal() @@ -1280,11 +1387,11 @@ def create_table( name, type pairs, for example: \b - sqlite-utils create-table my.db people \\ - id integer \\ - name text \\ - height float \\ - photo blob --pk id + sqlite-utils create-table my.db people \\ + id integer \\ + name text \\ + height float \\ + photo blob --pk id """ db = sqlite_utils.Database(path) _load_extensions(db, load_extension) @@ -1329,7 +1436,13 @@ def create_table( @click.option("--ignore", is_flag=True) @load_extension_option def drop_table(path, table, ignore, load_extension): - "Drop the specified table" + """Drop the specified table + + Example: + + \b + sqlite-utils drop-table chickens.db chickens + """ db = sqlite_utils.Database(path) _load_extensions(db, load_extension) try: @@ -1358,7 +1471,14 @@ def drop_table(path, table, ignore, load_extension): ) @load_extension_option def create_view(path, view, select, ignore, replace, load_extension): - "Create a view for the provided SELECT query" + """Create a view for the provided SELECT query + + Example: + + \b + sqlite-utils create-view chickens.db heavy_chickens \\ + 'select * from chickens where weight > 3' + """ db = sqlite_utils.Database(path) _load_extensions(db, load_extension) # Does view already exist? @@ -1386,7 +1506,13 @@ def create_view(path, view, select, ignore, replace, load_extension): @click.option("--ignore", is_flag=True) @load_extension_option def drop_view(path, view, ignore, load_extension): - "Drop the specified view" + """Drop the specified view + + Example: + + \b + sqlite-utils drop-view chickens.db heavy_chickens + """ db = sqlite_utils.Database(path) _load_extensions(db, load_extension) try: @@ -1808,7 +1934,13 @@ def rows( json_cols, load_extension, ): - "Output all rows in the specified table" + """Output all rows in the specified table + + Example: + + \b + sqlite-utils rows trees.db Trees + """ columns = "*" if column: columns = ", ".join("[{}]".format(c) for c in column) @@ -1860,7 +1992,13 @@ def triggers( json_cols, load_extension, ): - "Show triggers configured in this database" + """Show triggers configured in this database + + Example: + + \b + sqlite-utils triggers trees.db + """ sql = "select name, tbl_name as [table], sql from sqlite_master where type = 'trigger'" if tables: quote = sqlite_utils.Database(memory=True).quote @@ -1909,7 +2047,13 @@ def indexes( json_cols, load_extension, ): - "Show indexes for this database" + """Show indexes for the whole database or specific tables + + Example: + + \b + sqlite-utils indexes trees.db Trees + """ sql = """ select sqlite_master.name as "table", From 2b20957b1869b5a2213960d2e3a67188f42d2a2f Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 25 Jan 2022 19:19:39 -0800 Subject: [PATCH 090/563] Better validation for upsert --pk, closes #390 --- docs/cli-reference.rst | 3 +- sqlite_utils/cli.py | 118 +++++++++++++++++++++-------------------- tests/test_cli.py | 17 ++++++ 3 files changed, 79 insertions(+), 59 deletions(-) diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index 583f7e2..eee907e 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -253,8 +253,6 @@ See :ref:`cli_upsert`. incoming record has a primary key that matches an existing record the existing record will be updated. - The --pk option is required. - Example: echo '[ @@ -264,6 +262,7 @@ See :ref:`cli_upsert`. Options: --pk TEXT Columns to use as the primary key, e.g. id + [required] --flatten Flatten nested JSON objects, so {"a": {"b": 1}} becomes {"a_b": 1} --nl Expect newline-delimited JSON diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 350f9e3..65a108f 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -813,59 +813,65 @@ def import_options(fn): return fn -def insert_upsert_options(fn): - for decorator in reversed( - ( - click.argument( - "path", - type=click.Path(file_okay=True, dir_okay=False, allow_dash=False), - required=True, - ), - click.argument("table"), - click.argument("file", type=click.File("rb"), required=True), - click.option( - "--pk", help="Columns to use as the primary key, e.g. id", multiple=True - ), - ) - + _import_options - + ( - click.option( - "--batch-size", type=int, default=100, help="Commit every X records" - ), - click.option( - "--alter", - is_flag=True, - help="Alter existing table to add any missing columns", - ), - click.option( - "--not-null", - multiple=True, - help="Columns that should be created as NOT NULL", - ), - click.option( - "--default", - multiple=True, - type=(str, str), - help="Default value that should be set for a column", - ), - click.option( - "-d", - "--detect-types", - is_flag=True, - envvar="SQLITE_UTILS_DETECT_TYPES", - help="Detect types for columns in CSV/TSV data", - ), - click.option( - "--analyze", - is_flag=True, - help="Run ANALYZE at the end of this operation", - ), - load_extension_option, - click.option("--silent", is_flag=True, help="Do not show progress bar"), - ) - ): - fn = decorator(fn) - return fn +def insert_upsert_options(*, require_pk=False): + def inner(fn): + for decorator in reversed( + ( + click.argument( + "path", + type=click.Path(file_okay=True, dir_okay=False, allow_dash=False), + required=True, + ), + click.argument("table"), + click.argument("file", type=click.File("rb"), required=True), + click.option( + "--pk", + help="Columns to use as the primary key, e.g. id", + multiple=True, + required=require_pk, + ), + ) + + _import_options + + ( + click.option( + "--batch-size", type=int, default=100, help="Commit every X records" + ), + click.option( + "--alter", + is_flag=True, + help="Alter existing table to add any missing columns", + ), + click.option( + "--not-null", + multiple=True, + help="Columns that should be created as NOT NULL", + ), + click.option( + "--default", + multiple=True, + type=(str, str), + help="Default value that should be set for a column", + ), + click.option( + "-d", + "--detect-types", + is_flag=True, + envvar="SQLITE_UTILS_DETECT_TYPES", + help="Detect types for columns in CSV/TSV data", + ), + click.option( + "--analyze", + is_flag=True, + help="Run ANALYZE at the end of this operation", + ), + load_extension_option, + click.option("--silent", is_flag=True, help="Do not show progress bar"), + ) + ): + fn = decorator(fn) + return fn + + return inner def insert_upsert_implementation( @@ -1060,7 +1066,7 @@ def _find_variables(tb, vars): @cli.command() -@insert_upsert_options +@insert_upsert_options() @click.option( "--ignore", is_flag=True, default=False, help="Ignore records if pk already exists" ) @@ -1168,7 +1174,7 @@ def insert( @cli.command() -@insert_upsert_options +@insert_upsert_options(require_pk=True) def upsert( path, table, @@ -1201,8 +1207,6 @@ def upsert( an incoming record has a primary key that matches an existing record the existing record will be updated. - The --pk option is required. - Example: \b diff --git a/tests/test_cli.py b/tests/test_cli.py index 60c95b0..21b7945 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -922,6 +922,23 @@ def test_upsert(db_path, tmpdir): ] +def test_upsert_pk_required(db_path, tmpdir): + json_path = str(tmpdir / "dogs.json") + db = Database(db_path) + insert_dogs = [ + {"id": 1, "name": "Cleo", "age": 4}, + {"id": 2, "name": "Nixie", "age": 4}, + ] + open(json_path, "w").write(json.dumps(insert_dogs)) + result = CliRunner().invoke( + cli.cli, + ["upsert", db_path, "dogs", json_path], + catch_exceptions=False, + ) + assert result.exit_code == 2 + assert "Error: Missing option '--pk'" in result.output + + def test_upsert_analyze(db_path, tmpdir): db = Database(db_path) db["rows"].insert({"id": 1, "foo": "x", "n": 3}, pk="id") From be1e89da5fa6f28b8910610aa9f2b95f1fe3168b Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 25 Jan 2022 19:22:06 -0800 Subject: [PATCH 091/563] Fixed flake8 errors --- sqlite_utils/cli.py | 2 +- tests/test_cli.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 65a108f..07a55b3 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -489,7 +489,7 @@ def add_foreign_key( def add_foreign_keys(path, foreign_key, load_extension): """ Add multiple new foreign key constraints to a database - + Example: \b diff --git a/tests/test_cli.py b/tests/test_cli.py index 21b7945..244029d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -924,7 +924,6 @@ def test_upsert(db_path, tmpdir): def test_upsert_pk_required(db_path, tmpdir): json_path = str(tmpdir / "dogs.json") - db = Database(db_path) insert_dogs = [ {"id": 1, "name": "Cleo", "age": 4}, {"id": 2, "name": "Nixie", "age": 4}, From a9fca7efa4184fbb2a65ca1275c326950ed9d3c1 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 25 Jan 2022 19:28:30 -0800 Subject: [PATCH 092/563] Release 3.22.1 Refs #384, #387, #389 --- docs/changelog.rst | 9 +++++++++ setup.py | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 0af3c04..f9cca0c 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -2,6 +2,15 @@ Changelog =========== +.. _v3_22_1: + +3.22.1 (2022-01-25) +------------------- + +- All commands now include example usage in their ``--help`` - see :ref:`cli_reference`. (:issue:`384`) +- Python library documentation has a new :ref:`python_api_getting_started` section. (:issue:`387`) +- Documentation now uses `Plausible analytics `__. (:issue:`389`) + .. _v3_22: 3.22 (2022-01-11) diff --git a/setup.py b/setup.py index e0aa54b..e3a31d4 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages import io import os -VERSION = "3.22" +VERSION = "3.22.1" def get_long_description(): From d1d2a8e6fa95d8daf11973f747578602d08e4962 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 26 Jan 2022 10:15:23 -0800 Subject: [PATCH 093/563] sqlite-utils bulk --batch-size option, closes #392 --- docs/cli-reference.rst | 1 + docs/cli.rst | 2 ++ sqlite_utils/cli.py | 16 ++++++++++++---- tests/test_cli_bulk.py | 39 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 54 insertions(+), 4 deletions(-) diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index eee907e..89318d7 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -309,6 +309,7 @@ See :ref:`cli_bulk`. ' - Options: + --batch-size INTEGER Commit every X records --flatten Flatten nested JSON objects, so {"a": {"b": 1}} becomes {"a_b": 1} --nl Expect newline-delimited JSON diff --git a/docs/cli.rst b/docs/cli.rst index 733eee8..3ceffce 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -1120,6 +1120,8 @@ You could insert those rows into a pre-created ``chickens`` table like so:: This command takes the same options as the ``sqlite-utils insert`` command - so it defaults to expecting JSON but can accept other formats using ``--csv`` or ``--tsv`` or ``--nl`` or other options described above. +By default all of the SQL queries will be executed in a single transaction. To commit every 20 records, use ``--batch-size 20``. + .. _cli_insert_files: Inserting data from files diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 07a55b3..771d432 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -18,6 +18,7 @@ import csv as csv_std import tabulate from .utils import ( _compile_code, + chunks, file_progress, find_spatialite, sqlite3, @@ -1013,8 +1014,13 @@ def insert_upsert_implementation( # For bulk_sql= we use cursor.executemany() instead if bulk_sql: - with db.conn: - db.conn.cursor().executemany(bulk_sql, docs) + if batch_size: + doc_chunks = chunks(docs, batch_size) + else: + doc_chunks = [docs] + for doc_chunk in doc_chunks: + with db.conn: + db.conn.cursor().executemany(bulk_sql, doc_chunk) return try: @@ -1256,12 +1262,14 @@ def upsert( ) @click.argument("sql") @click.argument("file", type=click.File("rb"), required=True) +@click.option("--batch-size", type=int, default=100, help="Commit every X records") @import_options @load_extension_option def bulk( path, - file, sql, + file, + batch_size, flatten, nl, csv, @@ -1309,7 +1317,7 @@ def bulk( sniff=sniff, no_headers=no_headers, encoding=encoding, - batch_size=1, + batch_size=batch_size, alter=False, upsert=False, not_null=set(), diff --git a/tests/test_cli_bulk.py b/tests/test_cli_bulk.py index 37cf60b..c5dc9da 100644 --- a/tests/test_cli_bulk.py +++ b/tests/test_cli_bulk.py @@ -1,7 +1,11 @@ +from itertools import count from click.testing import CliRunner from sqlite_utils import cli, Database import pathlib import pytest +import subprocess +import sys +import time @pytest.fixture @@ -40,6 +44,41 @@ def test_cli_bulk(test_db_and_path): ] == list(db["example"].rows) +def test_cli_bulk_batch_size(test_db_and_path): + db, db_path = test_db_and_path + proc = subprocess.Popen( + [ + sys.executable, + "-m", + "sqlite_utils", + "bulk", + db_path, + "insert into example (id, name) values (:id, :name)", + "-", + "--nl", + "--batch-size", + "2", + ], + stdin=subprocess.PIPE, + stdout=sys.stdout, + ) + # Writing one record should not commit + proc.stdin.write(b'{"id": 3, "name": "Three"}\n\n') + proc.stdin.flush() + time.sleep(1) + assert db["example"].count == 2 + + # Writing another should trigger a commit: + proc.stdin.write(b'{"id": 4, "name": "Four"}\n\n') + proc.stdin.flush() + time.sleep(1) + assert db["example"].count == 4 + + proc.stdin.close() + proc.wait() + assert proc.returncode == 0 + + def test_cli_bulk_error(test_db_and_path): _, db_path = test_db_and_path result = CliRunner().invoke( From d1e9f09c06f29f27eca5c1a06a75072e28a79f0d Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 26 Jan 2022 10:23:48 -0800 Subject: [PATCH 094/563] Removed unneccessary import, refs #392 --- tests/test_cli_bulk.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_cli_bulk.py b/tests/test_cli_bulk.py index c5dc9da..6d01952 100644 --- a/tests/test_cli_bulk.py +++ b/tests/test_cli_bulk.py @@ -1,4 +1,3 @@ -from itertools import count from click.testing import CliRunner from sqlite_utils import cli, Database import pathlib From 6663d28952491aca2c8dcf586a301fb4791b5f69 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 30 Jan 2022 07:17:20 -0800 Subject: [PATCH 095/563] SQL injection, not XSS --- docs/python-api.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/python-api.rst b/docs/python-api.rst index 6b703fe..ac835d4 100644 --- a/docs/python-api.rst +++ b/docs/python-api.rst @@ -206,7 +206,7 @@ Passing parameters Both ``db.query()`` and ``db.execute()`` accept an optional second argument for parameters to be passed to the SQL query. -This can take the form of either a tuple/list or a dictionary, depending on the type of parameters used in the query. Values passed in this way will be correctly quoted and escaped, helping avoid XSS vulnerabilities. +This can take the form of either a tuple/list or a dictionary, depending on the type of parameters used in the query. Values passed in this way will be correctly quoted and escaped, helping avoid SQL injection vulnerabilities. ``?`` parameters in the SQL query can be filled in using a list: From feb01c1ddd2ba0a3c01518b6856520470d649bae Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 30 Jan 2022 07:22:39 -0800 Subject: [PATCH 096/563] Fixed duplicated example --- docs/python-api.rst | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/python-api.rst b/docs/python-api.rst index ac835d4..6efec95 100644 --- a/docs/python-api.rst +++ b/docs/python-api.rst @@ -424,7 +424,6 @@ The ``db.schema`` property returns the full SQL schema for the database as a str >>> db = sqlite_utils.Database("dogs.db") >>> print(db.schema) - >>> print(db.schema) CREATE TABLE "dogs" ( [id] INTEGER PRIMARY KEY, [name] TEXT From a6da26a856c966598b2275b12558e65d3e61a682 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 30 Jan 2022 07:24:13 -0800 Subject: [PATCH 097/563] Simplified example --- docs/python-api.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/python-api.rst b/docs/python-api.rst index 6efec95..72177c8 100644 --- a/docs/python-api.rst +++ b/docs/python-api.rst @@ -441,7 +441,7 @@ The easiest way to create a new table is to insert a record into it: from sqlite_utils import Database import sqlite3 - db = Database(sqlite3.connect("/tmp/dogs.db")) + db = Database("dogs.db") dogs = db["dogs"] dogs.insert({ "name": "Cleo", From 44cbddff8ab6526f20f608e4d76592422af757bd Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 2 Feb 2022 14:21:38 -0800 Subject: [PATCH 098/563] Run tests against Python 3.11-dev Refs #394 --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1f92538..59f7532 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,7 +10,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - python-version: ["3.6", "3.7", "3.8", "3.9", "3.10"] + python-version: ["3.6", "3.7", "3.8", "3.9", "3.10", "3.11-dev"] numpy: [0, 1] os: [ubuntu-latest, macos-latest, windows-latest] steps: From b2ab08e048228c3938b973dee12adb18729ebe39 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 3 Feb 2022 13:07:00 -0800 Subject: [PATCH 099/563] Don't test main against 3.11-dev yet It breaks on Windows. Refs #394 --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 59f7532..1f92538 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,7 +10,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - python-version: ["3.6", "3.7", "3.8", "3.9", "3.10", "3.11-dev"] + python-version: ["3.6", "3.7", "3.8", "3.9", "3.10"] numpy: [0, 1] os: [ubuntu-latest, macos-latest, windows-latest] steps: From 813b6d07ab97209435924311fda94a7fd377bd73 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 3 Feb 2022 14:07:32 -0800 Subject: [PATCH 100/563] Much improved insert-replace documentation, refs #393 --- docs/python-api.rst | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/docs/python-api.rst b/docs/python-api.rst index 72177c8..df9449f 100644 --- a/docs/python-api.rst +++ b/docs/python-api.rst @@ -705,7 +705,31 @@ Pass ``analyze=True`` to run ``ANALYZE`` against the table after inserting the n Insert-replacing data ===================== -If you want to insert a record or replace an existing record with the same primary key, using the ``replace=True`` argument to ``.insert()`` or ``.insert_all()``:: +If you try to insert data using a primary key that already exists, the ``.insert()`` or ``.insert_all()`` method will raise a ``sqlite3.IntegrityError`` exception. + +This example that catches that exception: + +.. code-block:: python + + from sqlite_utils.utils import sqlite3 + + try: + db["dogs"].insert({"id": 1, "name": "Cleo"}, pk="id") + except sqlite3.IntegrityError: + print("Record already exists with that primary key") + +Importing from ``sqlite_utils.utils.sqlite3`` ensures your code continues to work even if you are using the ``pysqlite3`` library instead of the Python standard library ``sqlite3`` module. + +Use the ``ignore=True`` parameter to ignore this error: + +.. code-block:: python + + # This fails silently if a record with id=1 already exists + db["dogs"].insert({"id": 1, "name": "Cleo"}, pk="id", ignore=True) + +To replace any existing records that have a matching primary key, use the ``replace=True`` parameter to ``.insert()`` or ``.insert_all()``: + +.. code-block:: python db["dogs"].insert_all([{ "id": 1, @@ -722,7 +746,7 @@ If you want to insert a record or replace an existing record with the same prima }], pk="id", replace=True) .. note:: - Prior to sqlite-utils 2.x the ``.upsert()`` and ``.upsert_all()`` methods did this. See :ref:`python_api_upsert` for the new behaviour of those methods in 2.x. + Prior to sqlite-utils 2.0 the ``.upsert()`` and ``.upsert_all()`` methods worked the same way as ``.insert(replace=True)`` does today. See :ref:`python_api_upsert` for the new behaviour of those methods introduced in 2.0. .. _python_api_update: From 7d928f83085fb285f294dbdaeb93fd94a44d5d44 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 3 Feb 2022 14:11:25 -0800 Subject: [PATCH 101/563] Better insert-replace CLI documentation, refs #393 --- docs/cli.rst | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/docs/cli.rst b/docs/cli.rst index 3ceffce..d8aed91 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -1060,15 +1060,13 @@ The result looks like this:: Insert-replacing data ===================== -Insert-replacing works exactly like inserting, with the exception that if your data has a primary key that matches an already existing record that record will be replaced with the new data. +The ``--replace`` option to ``insert`` causes any existing records with the same primary key to be replaced entirely by the new records. -After running the above ``dogs.json`` example, try running this:: +To replace a dog with in ID of 2 with a new record, run the following:: $ echo '{"id": 2, "name": "Pancakes", "age": 3}' | \ sqlite-utils insert dogs.db dogs - --pk=id --replace -This will replace the record for id=2 (Pancakes) with a new record with an updated age. - .. _cli_upsert: Upserting data @@ -1083,7 +1081,7 @@ For example:: $ echo '{"id": 2, "age": 4}' | \ sqlite-utils upsert dogs.db dogs - --pk=id -This will update the dog with id=2 to have an age of 4, creating a new record (with a null name) if one does not exist. If a row DOES exist the name will be left as-is. +This will update the dog with an ID of 2 to have an age of 4, creating a new record (with a null name) if one does not exist. If a row DOES exist the name will be left as-is. The command will fail if you reference columns that do not exist on the table. To automatically create missing columns, use the ``--alter`` option. From 9dcb099905c4a2246e3487be3289642161991864 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 3 Feb 2022 14:51:25 -0800 Subject: [PATCH 102/563] Better error messages for --convert, closes #363 --- sqlite_utils/cli.py | 11 +++++++++++ tests/test_cli_insert.py | 26 ++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 771d432..8b1b5a4 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -1009,6 +1009,9 @@ def insert_upsert_implementation( if upsert: extra_kwargs["upsert"] = upsert + # docs should all be dictionaries + docs = (verify_is_dict(doc) for doc in docs) + # Apply {"$base64": true, ...} decoding, if needed docs = (decode_base64_values(doc) for doc in docs) @@ -2760,6 +2763,14 @@ def json_binary(value): raise TypeError +def verify_is_dict(doc): + if not isinstance(doc, dict): + raise click.ClickException( + "Rows must all be dictionaries, got: {}".format(repr(doc)[:1000]) + ) + return doc + + def _load_extensions(db, load_extension): if load_extension: db.conn.enable_load_extension(True) diff --git a/tests/test_cli_insert.py b/tests/test_cli_insert.py index 0d6b482..45ad362 100644 --- a/tests/test_cli_insert.py +++ b/tests/test_cli_insert.py @@ -472,6 +472,32 @@ def test_insert_convert_row_modifying_in_place(db_path): assert rows == [{"name": "Azi", "is_chicken": 1}] +@pytest.mark.parametrize( + "options,expected_error", + ( + ( + ["--text", "--convert", "1"], + "Error: --convert must return dict or iterator\n", + ), + (["--convert", "1"], "Error: Rows must all be dictionaries, got: 1\n"), + ), +) +def test_insert_convert_error_messages(db_path, options, expected_error): + result = CliRunner().invoke( + cli.cli, + [ + "insert", + db_path, + "rows", + "-", + ] + + options, + input='{"name": "Azi"}', + ) + assert result.exit_code == 1 + assert result.output == expected_error + + def test_insert_streaming_batch_size_1(db_path): # https://github.com/simonw/sqlite-utils/issues/364 # Streaming with --batch-size 1 should commit on each record From ee11274fcb1c00f32c95f2ef2924d5349538eb4d Mon Sep 17 00:00:00 2001 From: Chris Amico Date: Fri, 4 Feb 2022 00:55:09 -0500 Subject: [PATCH 103/563] New spatialite helper methods, closes #79 - db.init_spatialite() - table.add_geometry_column() - table.create_spatial_index() Co-authored-by: Simon Willison --- docs/python-api.rst | 57 ++++++++++++-------- sqlite_utils/cli.py | 1 + sqlite_utils/db.py | 119 ++++++++++++++++++++++++++++++++++++++++++ sqlite_utils/utils.py | 35 ++++++++++--- tests/test_gis.py | 83 +++++++++++++++++++++++++++++ tests/test_utils.py | 5 -- 6 files changed, 267 insertions(+), 33 deletions(-) create mode 100644 tests/test_gis.py diff --git a/docs/python-api.rst b/docs/python-api.rst index df9449f..4da350b 100644 --- a/docs/python-api.rst +++ b/docs/python-api.rst @@ -352,7 +352,7 @@ Counting rows To count the number of rows that would be returned by a where filter, use ``.count_where(where, where_args)``: - >>> db["dogs"].count_where("age > ?", [1]): + >>> db["dogs"].count_where("age > ?", [1]) 2 .. _python_api_pks_and_rows_where: @@ -2422,26 +2422,6 @@ For example: # [thumbnail] BLOB # ) -.. _find_spatialite: - -Finding SpatiaLite -================== - -The ``find_spatialite()`` function searches for the `SpatiaLite `__ SQLite extension in some common places. It returns a string path to the location, or ``None`` if SpatiaLite was not found. - -You can use it in code like this: - -.. code-block:: python - - from sqlite_utils import Database - from sqlite_utils.utils import find_spatialite - - db = Database("mydb.db") - spatialite = find_spatialite() - if spatialite: - db.conn.enable_load_extension(True) - db.conn.load_extension(spatialite) - .. _python_api_register_function: Registering custom SQL functions @@ -2522,3 +2502,38 @@ If that option isn't relevant to your use-case you can to quote a string for use "'hello'" >>> db.quote("hello'this'has'quotes") "'hello''this''has''quotes'" + +.. _python_api_gis: + +Spatialite helpers +================== + +`SpatiaLite `__ is a geographic extension to SQLite (similar to PostgreSQL + PostGIS). Using requires finding, loading and initializing the extension, adding geometry columns to existing tables and optionally creating spatial indexes. The utilities here help streamline that setup. + +.. _python_api_gis_init_spatialite: + +Initialize Spatialite +--------------------- + +.. automethod:: sqlite_utils.db.Database.init_spatialite + +.. _python_api_gis_find_spatialite: + +Finding Spatialite +------------------ + +.. autofunction:: sqlite_utils.utils.find_spatialite + +.. _python_api_gis_add_geometry_column: + +Adding geometry columns +----------------------- + +.. automethod:: sqlite_utils.db.Table.add_geometry_column + +.. _python_api_gis_create_spatial_index: + +Creating a spatial index +------------------------ + +.. automethod:: sqlite_utils.db.Table.create_spatial_index diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 8b1b5a4..b137d9b 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -29,6 +29,7 @@ from .utils import ( TypeTracker, ) + CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) VALID_COLUMN_TYPES = ("INTEGER", "TEXT", "FLOAT", "BLOB") diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index 06e7557..4ab8862 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -6,6 +6,7 @@ from .utils import ( types_for_column_types, column_affinity, progressbar, + find_spatialite, ) from collections import namedtuple from collections.abc import Mapping @@ -931,6 +932,46 @@ class Database: sql += " [{}]".format(name) self.execute(sql) + def init_spatialite(self, path: str = None) -> bool: + """ + The ``init_spatialite`` method will load and initialize the SpatiaLite extension. + The ``path`` argument should be an absolute path to the compiled extension, which + can be found using ``find_spatialite``. + + Returns true if SpatiaLite was successfully initialized. + + .. code-block:: python + + from sqlite_utils.db import Database + from sqlite_utils.utils import find_spatialite + + db = Database("mydb.db") + db.init_spatialite(find_spatialite()) + + If you've installed SpatiaLite somewhere unexpected (for testing an alternate version, for example) + you can pass in an absolute path: + + .. code-block:: python + + from sqlite_utils.db import Database + from sqlite_utils.utils import find_spatialite + + db = Database("mydb.db") + db.init_spatialite("./local/mod_spatialite.dylib") + + """ + if path is None: + path = find_spatialite() + + self.conn.enable_load_extension(True) + self.conn.load_extension(path) + # Initialize SpatiaLite if not yet initialized + if "spatial_ref_sys" in self.table_names(): + return False + cursor = self.execute("select InitSpatialMetadata(1)") + result = cursor.fetchone() + return result and bool(result[0]) + class Queryable: def exists(self) -> bool: @@ -3012,6 +3053,84 @@ class Table(Queryable): least_common, ) + def add_geometry_column( + self, + column_name: str, + geometry_type: str, + srid: int = 4326, + coord_dimension: str = "XY", + not_null: bool = False, + ) -> bool: + """ + In SpatiaLite, a geometry column can only be added to an existing table. + To do so, use ``table.add_geometry_column``, passing in a geometry type. + + By default, this will add a nullable column using + `SRID 4326 `__. This can + be customized using the ``column_name``, ``srid`` and ``not_null`` arguments. + + Returns True if the column was successfully added, False if not. + + .. code-block:: python + + from sqlite_utils.db import Database + from sqlite_utils.utils import find_spatialite + + db = Database("mydb.db") + db.init_spatialite(find_spatialite()) + + # the table must exist before adding a geometry column + table = db["locations"].create({"name": str}) + table.add_geometry_column("geometry", "POINT") + + """ + cursor = self.db.execute( + "SELECT AddGeometryColumn(?, ?, ?, ?, ?, ?);", + [ + self.name, + column_name, + srid, + geometry_type, + coord_dimension, + int(not_null), + ], + ) + + result = cursor.fetchone() + return result and bool(result[0]) + + def create_spatial_index(self, column_name) -> bool: + """ + A spatial index allows for significantly faster bounding box queries. + To create one, use ``create_spatial_index`` with the name of an existing geometry column. + + Returns ``True`` if the index was successfully created, ``False`` if not. Calling this + function if an index already exists is a no-op. + + .. code-block:: python + + # assuming SpatiaLite is loaded, create the table, add the column + table = db["locations"].create({"name": str}) + table.add_geometry_column("geometry", "POINT") + + # now we can index it + table.create_spatial_index("geometry") + + # the spatial index is a virtual table, which we can inspect + print(db["idx_locations_geometry"].schema) + # outputs: + # CREATE VIRTUAL TABLE "idx_locations_geometry" USING rtree(pkid, xmin, xmax, ymin, ymax) + + """ + if f"idx_{self.name}_{column_name}" in self.db.table_names(): + return False + + cursor = self.db.execute( + "select CreateSpatialIndex(?, ?)", [self.name, column_name] + ) + result = cursor.fetchone() + return result and bool(result[0]) + class View(Queryable): def exists(self): diff --git a/sqlite_utils/utils.py b/sqlite_utils/utils.py index 0777f30..c2a7c91 100644 --- a/sqlite_utils/utils.py +++ b/sqlite_utils/utils.py @@ -22,12 +22,40 @@ except ImportError: OperationalError = sqlite3.OperationalError + SPATIALITE_PATHS = ( "/usr/lib/x86_64-linux-gnu/mod_spatialite.so", "/usr/local/lib/mod_spatialite.dylib", ) +def find_spatialite() -> str: + """ + The ``find_spatialite()`` function searches for the `SpatiaLite `__ SQLite extension in some common places. It returns a string path to the location, or ``None`` if SpatiaLite was not found. + + You can use it in code like this: + + .. code-block:: python + + from sqlite_utils import Database + from sqlite_utils.utils import find_spatialite + + db = Database("mydb.db") + spatialite = find_spatialite() + if spatialite: + db.conn.enable_load_extension(True) + db.conn.load_extension(spatialite) + + # or use with db.init_spatialite like this + db.init_spatialite(find_spatialite()) + + """ + for path in SPATIALITE_PATHS: + if os.path.exists(path): + return path + return None + + def suggest_column_types(records): all_column_types = {} for record in records: @@ -96,13 +124,6 @@ def decode_base64_values(doc): return dict(doc, **{k: base64.b64decode(doc[k]["encoded"]) for k in to_fix}) -def find_spatialite(): - for path in SPATIALITE_PATHS: - if os.path.exists(path): - return path - return None - - class UpdateWrapper: def __init__(self, wrapped, update): self._wrapped = wrapped diff --git a/tests/test_gis.py b/tests/test_gis.py new file mode 100644 index 0000000..3bf0d5f --- /dev/null +++ b/tests/test_gis.py @@ -0,0 +1,83 @@ +import pytest +from sqlite_utils.utils import find_spatialite +from sqlite_utils.db import Database +from sqlite_utils.utils import sqlite3 + +pytestmark = [ + pytest.mark.skipif( + not find_spatialite(), reason="Could not find SpatiaLite extension" + ), + pytest.mark.skipif( + not hasattr(sqlite3.Connection, "enable_load_extension"), + reason="sqlite3.Connection missing enable_load_extension", + ), +] + + +def test_find_spatialite(): + spatialite = find_spatialite() + assert spatialite is None or isinstance(spatialite, str) + + +def test_init_spatialite(): + db = Database(memory=True) + spatialite = find_spatialite() + db.init_spatialite(spatialite) + assert "spatial_ref_sys" in db.table_names() + + +def test_add_geometry_column(): + db = Database(memory=True) + spatialite = find_spatialite() + db.init_spatialite(spatialite) + + # create a table first + table = db.create_table("locations", {"id": str, "properties": str}) + table.add_geometry_column( + column_name="geometry", + geometry_type="Point", + srid=4326, + coord_dimension=2, + ) + + assert db["geometry_columns"].get(["locations", "geometry"]) == { + "f_table_name": "locations", + "f_geometry_column": "geometry", + "geometry_type": 1, # point + "coord_dimension": 2, + "srid": 4326, + "spatial_index_enabled": 0, + } + + +def test_create_spatial_index(): + db = Database(memory=True) + spatialite = find_spatialite() + assert db.init_spatialite(spatialite) + + # create a table, add a geometry column with default values + table = db.create_table("locations", {"id": str, "properties": str}) + assert table.add_geometry_column("geometry", "Point") + + # index it + assert table.create_spatial_index("geometry") + + assert "idx_locations_geometry" in db.table_names() + + +def test_double_create_spatial_index(): + db = Database(memory=True) + spatialite = find_spatialite() + db.init_spatialite(spatialite) + + # create a table, add a geometry column with default values + table = db.create_table("locations", {"id": str, "properties": str}) + table.add_geometry_column("geometry", "Point") + + # index it, return True + assert table.create_spatial_index("geometry") + + assert "idx_locations_geometry" in db.table_names() + + # call it again, return False + assert not table.create_spatial_index("geometry") diff --git a/tests/test_utils.py b/tests/test_utils.py index 8630e28..66ce413 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -22,11 +22,6 @@ def test_decode_base64_values(input, expected, should_be_is): assert actual == expected -def test_find_spatialite(): - spatialite = utils.find_spatialite() - assert spatialite is None or isinstance(spatialite, str) - - @pytest.mark.parametrize( "size,expected", ( From 4a2a3e2fd0d5534f446b3f1fee34cb165e4d86d2 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 3 Feb 2022 21:56:34 -0800 Subject: [PATCH 104/563] Install SpatiaLite in tests To run tests for #79, #385 --- .github/workflows/test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1f92538..2d66dbb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,6 +32,8 @@ jobs: - name: Optionally install numpy if: matrix.numpy == 1 run: pip install numpy + - name: Install SpatiaLite + run: sudo apt-get install libsqlite3-mod-spatialite - name: Run tests run: | pytest -v From cea25c28bad0bd30cf375e2d0d5113f23ab84e0c Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 3 Feb 2022 21:59:59 -0800 Subject: [PATCH 105/563] Capitalization of SpatiaLite --- docs/python-api.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/python-api.rst b/docs/python-api.rst index 4da350b..5106307 100644 --- a/docs/python-api.rst +++ b/docs/python-api.rst @@ -2512,14 +2512,14 @@ Spatialite helpers .. _python_api_gis_init_spatialite: -Initialize Spatialite +Initialize SpatiaLite --------------------- .. automethod:: sqlite_utils.db.Database.init_spatialite .. _python_api_gis_find_spatialite: -Finding Spatialite +Finding SpatiaLite ------------------ .. autofunction:: sqlite_utils.utils.find_spatialite From 749418728448abbbfa6305ad18152951a6721670 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 3 Feb 2022 22:05:12 -0800 Subject: [PATCH 106/563] Only install SpatiaLite on Ubuntu, refs #395 For tests added to #79 --- .github/workflows/test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2d66dbb..3bd21f1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,6 +33,7 @@ jobs: if: matrix.numpy == 1 run: pip install numpy - name: Install SpatiaLite + if: matrix.os == "ubuntu-latest" run: sudo apt-get install libsqlite3-mod-spatialite - name: Run tests run: | From e46798959e10e4674b2a58a9c2f227c0a2deca1d Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 3 Feb 2022 22:06:23 -0800 Subject: [PATCH 107/563] Looks like Actions if: clauses prefer single quotes Refs #395, #79 --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3bd21f1..a8274e2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,7 +33,7 @@ jobs: if: matrix.numpy == 1 run: pip install numpy - name: Install SpatiaLite - if: matrix.os == "ubuntu-latest" + if: matrix.os == 'ubuntu-latest' run: sudo apt-get install libsqlite3-mod-spatialite - name: Run tests run: | From 0fe0f476a73ddbb3fea879bdb6bfef3ba4b97768 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 3 Feb 2022 22:10:09 -0800 Subject: [PATCH 108/563] Fix for mypy error, closes #396 Should help tests pass for #395 and #79 --- sqlite_utils/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sqlite_utils/utils.py b/sqlite_utils/utils.py index c2a7c91..b719b01 100644 --- a/sqlite_utils/utils.py +++ b/sqlite_utils/utils.py @@ -29,7 +29,7 @@ SPATIALITE_PATHS = ( ) -def find_spatialite() -> str: +def find_spatialite() -> Optional[str]: """ The ``find_spatialite()`` function searches for the `SpatiaLite `__ SQLite extension in some common places. It returns a string path to the location, or ``None`` if SpatiaLite was not found. From 482fcc0da7c5127ce5bc6765b63663b9c5a87f91 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 3 Feb 2022 22:13:17 -0800 Subject: [PATCH 109/563] Fix for flake8, refs #79 --- sqlite_utils/utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sqlite_utils/utils.py b/sqlite_utils/utils.py index b719b01..e3386d8 100644 --- a/sqlite_utils/utils.py +++ b/sqlite_utils/utils.py @@ -31,7 +31,8 @@ SPATIALITE_PATHS = ( def find_spatialite() -> Optional[str]: """ - The ``find_spatialite()`` function searches for the `SpatiaLite `__ SQLite extension in some common places. It returns a string path to the location, or ``None`` if SpatiaLite was not found. + The ``find_spatialite()`` function searches for the `SpatiaLite `__ + SQLite extension in some common places. It returns a string path to the location, or ``None`` if SpatiaLite was not found. You can use it in code like this: From 44894c6f6c854bb8d5c79cb349aa39526cf56ee2 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 3 Feb 2022 22:31:13 -0800 Subject: [PATCH 110/563] Fix warning about duplicate object description --- docs/python-api.rst | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/python-api.rst b/docs/python-api.rst index 5106307..cc34505 100644 --- a/docs/python-api.rst +++ b/docs/python-api.rst @@ -2505,7 +2505,7 @@ If that option isn't relevant to your use-case you can to quote a string for use .. _python_api_gis: -Spatialite helpers +SpatiaLite helpers ================== `SpatiaLite `__ is a geographic extension to SQLite (similar to PostgreSQL + PostGIS). Using requires finding, loading and initializing the extension, adding geometry columns to existing tables and optionally creating spatial indexes. The utilities here help streamline that setup. @@ -2516,6 +2516,7 @@ Initialize SpatiaLite --------------------- .. automethod:: sqlite_utils.db.Database.init_spatialite + :noindex: .. _python_api_gis_find_spatialite: @@ -2530,6 +2531,7 @@ Adding geometry columns ----------------------- .. automethod:: sqlite_utils.db.Table.add_geometry_column + :noindex: .. _python_api_gis_create_spatial_index: @@ -2537,3 +2539,4 @@ Creating a spatial index ------------------------ .. automethod:: sqlite_utils.db.Table.create_spatial_index + :noindex: From 20fe3b8abf49f8f7d73ad0f5610d2a62541fd907 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 3 Feb 2022 22:32:57 -0800 Subject: [PATCH 111/563] Fixed RST warning about empty line --- docs/cli-reference.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index 89318d7..15b7b8c 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -56,6 +56,7 @@ This page lists the ``--help`` for every ``sqlite-utils`` CLI sub-command. "convert": "cli_convert", } commands.sort(key = lambda command: go_first.index(command) if command in go_first else 999) + cog.out("\n") for command in commands: cog.out(command + "\n") cog.out(("=" * len(command)) + "\n\n") @@ -74,6 +75,7 @@ This page lists the ``--help`` for every ``sqlite-utils`` CLI sub-command. cog.out(textwrap.indent(output, ' ')) cog.out("\n\n") .. ]]] + query ===== From 088d89982299f8136e608fa2b6c30e9529adc714 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 3 Feb 2022 22:41:46 -0800 Subject: [PATCH 112/563] Release 3.23 Refs #79, #363, #392, #393, #395, #396 --- docs/changelog.rst | 13 +++++++++++++ setup.py | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index f9cca0c..7f62c62 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -2,6 +2,19 @@ Changelog =========== +.. _v3_23: + +3.23 (2022-02-03) +----------------- + +This release introduces four new utility methods for working with `SpatiaLite `__. Thanks, Chris Amico. (`#330 `__) + +- ``sqlite_utils.utils.find_spatialite()`` :ref:`finds the location of the SpatiaLite module ` on disk. +- ``db.init_spatialite()`` :ref:`initializes SpatiaLite ` for the given database. +- ``table.add_geometry_column(...)`` :ref:`adds a geometry column ` to an existing table. +- ``table.create_spatial_index(...)`` :ref:`creates a spatial index ` for a column. +- ``sqlite-utils batch`` now accepts a ``--batch-size`` option. (:issue:`392`) + .. _v3_22_1: 3.22.1 (2022-01-25) diff --git a/setup.py b/setup.py index e3a31d4..6d96533 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages import io import os -VERSION = "3.22.1" +VERSION = "3.23" def get_long_description(): From 79b5b58354c35823ebf63cc19ffdfa603ee88d65 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 5 Feb 2022 17:19:39 -0800 Subject: [PATCH 113/563] Basic test for db[t].create(...), refs #397 --- tests/test_create.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/test_create.py b/tests/test_create.py index 82c2c72..d946d99 100644 --- a/tests/test_create.py +++ b/tests/test_create.py @@ -1089,3 +1089,28 @@ def test_create_table_sql(fresh_db, columns, expected_sql_middle): sql = fresh_db.create_table_sql("t", columns) middle = sql.split("(")[1].split(")")[0].strip() assert middle == expected_sql_middle + + +def test_create(fresh_db): + fresh_db["t"].create( + { + "id": int, + "text": str, + "float": float, + "integer": int, + "bytes": bytes, + }, + pk="id", + column_order=("id", "float"), + not_null=("float", "integer"), + defaults={"integer": 0}, + ) + assert fresh_db["t"].schema == ( + "CREATE TABLE [t] (\n" + " [id] INTEGER PRIMARY KEY,\n" + " [float] FLOAT NOT NULL,\n" + " [text] TEXT,\n" + " [integer] INTEGER NOT NULL DEFAULT 0,\n" + " [bytes] BLOB\n" + ")" + ) From aa2490311369697adbdbef4185b334e6730c762e Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 5 Feb 2022 17:28:53 -0800 Subject: [PATCH 114/563] Create table if_not_exists=True argument, closes #397 --- docs/python-api.rst | 15 ++++++++++++++- sqlite_utils/db.py | 12 ++++++++++-- tests/test_create.py | 9 +++++++++ 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/docs/python-api.rst b/docs/python-api.rst index cc34505..039a00a 100644 --- a/docs/python-api.rst +++ b/docs/python-api.rst @@ -522,7 +522,9 @@ This will create a table with the following schema: Explicitly creating a table --------------------------- -You can directly create a new table without inserting any data into it using the ``.create()`` method:: +You can directly create a new table without inserting any data into it using the ``.create()`` method: + +.. code-block:: python db["cats"].create({ "id": int, @@ -534,6 +536,17 @@ The first argument here is a dictionary specifying the columns you would like to This method takes optional arguments ``pk=``, ``column_order=``, ``foreign_keys=``, ``not_null=set()`` and ``defaults=dict()`` - explained below. +A ``sqlite_utils.utils.sqlite3.OperationalError`` will be raised if a table of that name already exists. + +To do nothing if the table already exists, add ``if_not_exists=True``: + +.. code-block:: python + + db["cats"].create({ + "id": int, + "name": str, + }, pk="id", if_not_exists=True) + .. _python_api_compound_primary_keys: Compound primary keys diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index 4ab8862..1927f41 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -658,6 +658,7 @@ class Database: defaults: Optional[Dict[str, Any]] = None, hash_id: Optional[str] = None, extracts: Optional[Union[Dict[str, str], List[str]]] = None, + if_not_exists: bool = False, ) -> str: "Returns the SQL ``CREATE TABLE`` statement for creating the specified table." foreign_keys = self.resolve_foreign_keys(name, foreign_keys or []) @@ -748,11 +749,14 @@ class Database: pks=", ".join(["[{}]".format(p) for p in pk]) ) columns_sql = ",\n".join(column_defs) - sql = """CREATE TABLE [{table}] ( + sql = """CREATE TABLE {if_not_exists}[{table}] ( {columns_sql}{extra_pk} ); """.format( - table=name, columns_sql=columns_sql, extra_pk=extra_pk + if_not_exists="IF NOT EXISTS " if if_not_exists else "", + table=name, + columns_sql=columns_sql, + extra_pk=extra_pk, ) return sql @@ -767,6 +771,7 @@ class Database: defaults: Optional[Dict[str, Any]] = None, hash_id: Optional[str] = None, extracts: Optional[Union[Dict[str, str], List[str]]] = None, + if_not_exists: bool = False, ) -> "Table": """ Create a table with the specified name and the specified ``{column_name: type}`` columns. @@ -783,6 +788,7 @@ class Database: defaults=defaults, hash_id=hash_id, extracts=extracts, + if_not_exists=if_not_exists, ) self.execute(sql) table = self.table( @@ -1300,6 +1306,7 @@ class Table(Queryable): defaults: Optional[Dict[str, Any]] = None, hash_id: Optional[str] = None, extracts: Optional[Union[Dict[str, str], List[str]]] = None, + if_not_exists: bool = False, ) -> "Table": """ Create a table with the specified columns. @@ -1318,6 +1325,7 @@ class Table(Queryable): defaults=defaults, hash_id=hash_id, extracts=extracts, + if_not_exists=if_not_exists, ) return self diff --git a/tests/test_create.py b/tests/test_create.py index d946d99..35844a1 100644 --- a/tests/test_create.py +++ b/tests/test_create.py @@ -1114,3 +1114,12 @@ def test_create(fresh_db): " [bytes] BLOB\n" ")" ) + + +def test_create_if_not_exists(fresh_db): + fresh_db["t"].create({"id": int}) + # This should error + with pytest.raises(sqlite3.OperationalError): + fresh_db["t"].create({"id": int}) + # This should not + fresh_db["t"].create({"id": int}, if_not_exists=True) From fea8c9bcc509bcae75e99ae8870f520103b9aa58 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 5 Feb 2022 18:03:21 -0800 Subject: [PATCH 115/563] Improved SpatiaLite example, closes #401 --- docs/python-api.rst | 31 +++++++++++++------------------ 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/docs/python-api.rst b/docs/python-api.rst index 039a00a..149c3a2 100644 --- a/docs/python-api.rst +++ b/docs/python-api.rst @@ -1672,28 +1672,21 @@ A more useful example: if you are working with `SpatiaLite Date: Tue, 8 Feb 2022 11:33:41 -0800 Subject: [PATCH 116/563] Adding a primary key to a rowid table, closes #403 --- docs/cli.rst | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/docs/cli.rst b/docs/cli.rst index d8aed91..e75adec 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -1507,6 +1507,43 @@ If you want to see the SQL that will be executed to make the change without actu DROP TABLE [roadside_attractions]; ALTER TABLE [roadside_attractions_new_4033a60276b9] RENAME TO [roadside_attractions]; +.. _cli_transform_table_add_primary_key_to_rowid: + +Adding a primary key to a rowid table +------------------------------------- + +SQLite tables that are created without an explicit primary key are created as `rowid tables `__. They still have a numeric primary key which is available in the ``rowid`` column, but that column is not included in the output of ``select *``. Here's an example:: + + % echo '[{"name": "Azi"}, {"name": "Suna"}]' | \ + sqlite-utils insert chickens.db chickens - + % sqlite-utils schema chickens.db + CREATE TABLE [chickens] ( + [name] TEXT + ); + % sqlite-utils chickens.db 'select * from chickens' + [{"name": "Azi"}, + {"name": "Suna"}] + % sqlite-utils chickens.db 'select rowid, * from chickens' + [{"rowid": 1, "name": "Azi"}, + {"rowid": 2, "name": "Suna"}] + +You can use ``sqlite-utils transform ... --pk id`` to add a primary key column called ``id`` to the table. The primary key will be created as an ``INTEGER PRIMARY KEY`` and the existing ``rowid`` values will be copied across to it. It will automatically increment as new rows are added to the table:: + + % sqlite-utils transform chickens.db chickens --pk id + % sqlite-utils schema chickens.db + CREATE TABLE "chickens" ( + [id] INTEGER PRIMARY KEY, + [name] TEXT + ); + % sqlite-utils chickens.db 'select * from chickens' + [{"id": 1, "name": "Azi"}, + {"id": 2, "name": "Suna"}] + % echo '{"name": "Cardi"}' | sqlite-utils insert chickens.db chickens - + % sqlite-utils chickens.db 'select * from chickens' + [{"id": 1, "name": "Azi"}, + {"id": 2, "name": "Suna"}, + {"id": 3, "name": "Cardi"}] + .. _cli_extract: Extracting columns into a separate table From 79a5ece62ecfad5fb64da42c54ad110e822350d4 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 8 Feb 2022 22:54:40 -0800 Subject: [PATCH 117/563] Add --convert example to sqlite-utils insert --help, closes #404 --- docs/cli-reference.rst | 11 +++++++++++ sqlite_utils/cli.py | 12 ++++++++++++ 2 files changed, 23 insertions(+) diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index 15b7b8c..5b7c6fe 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -208,6 +208,17 @@ See :ref:`cli_inserting_data`, :ref:`cli_insert_csv_tsv`. Your Python code will be passed a "row" variable representing the imported row, and can return a modified row. + This example uses just the name, latitude and longitude columns from a CSV + file, converting name to upper case and latitude and longitude to floating + point numbers: + + sqlite-utils insert plants.db plants plants.csv --csv --convert ' + return { + "name": row["name"].upper(), + "latitude": float(row["latitude"]), + "longitude": float(row["longitude"]), + }' + If you are using --lines your code will be passed a "line" variable, and for --text an "text" variable. diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index b137d9b..2ad7aff 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -1144,6 +1144,18 @@ def insert( Your Python code will be passed a "row" variable representing the imported row, and can return a modified row. + This example uses just the name, latitude and longitude columns from + a CSV file, converting name to upper case and latitude and longitude + to floating point numbers: + + \b + sqlite-utils insert plants.db plants plants.csv --csv --convert ' + return { + "name": row["name"].upper(), + "latitude": float(row["latitude"]), + "longitude": float(row["longitude"]), + }' + If you are using --lines your code will be passed a "line" variable, and for --text an "text" variable. """ From 7142dbd58d25d54720c8396bd35990fd1387ba77 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 8 Feb 2022 22:57:21 -0800 Subject: [PATCH 118/563] Fixed typo in --help --- docs/cli-reference.rst | 2 +- sqlite_utils/cli.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index 5b7c6fe..abfacc2 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -220,7 +220,7 @@ See :ref:`cli_inserting_data`, :ref:`cli_insert_csv_tsv`. }' If you are using --lines your code will be passed a "line" variable, and for - --text an "text" variable. + --text a "text" variable. Options: --pk TEXT Columns to use as the primary key, e.g. id diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 2ad7aff..0da3817 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -1157,7 +1157,7 @@ def insert( }' If you are using --lines your code will be passed a "line" variable, - and for --text an "text" variable. + and for --text a "text" variable. """ try: insert_upsert_implementation( From e7f040106b5f5a892ebd984f19b21c605e87c142 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 8 Feb 2022 23:03:04 -0800 Subject: [PATCH 119/563] Add an example of --text too, refs #404 --- docs/cli-reference.rst | 12 ++++++++++-- sqlite_utils/cli.py | 7 +++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index abfacc2..3359d5b 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -22,7 +22,9 @@ This page lists the ``--help`` for every ``sqlite-utils`` CLI sub-command. refs = { "query": "cli_query", "memory": "cli_memory", - "insert": ["cli_inserting_data", "cli_insert_csv_tsv"], + "insert": [ + "cli_inserting_data", "cli_insert_csv_tsv", "cli_insert_unstructured", "cli_insert_convert" + ], "upsert": "cli_upsert", "tables": "cli_tables", "views": "cli_views", @@ -182,7 +184,7 @@ See :ref:`cli_memory`. insert ====== -See :ref:`cli_inserting_data`, :ref:`cli_insert_csv_tsv`. +See :ref:`cli_inserting_data`, :ref:`cli_insert_csv_tsv`, :ref:`cli_insert_unstructured`, :ref:`cli_insert_convert`. :: @@ -222,6 +224,12 @@ See :ref:`cli_inserting_data`, :ref:`cli_insert_csv_tsv`. If you are using --lines your code will be passed a "line" variable, and for --text a "text" variable. + When using --text your function can return an iterator of rows to insert. This + example inserts one record per word in the input: + + echo 'A bunch of words' | sqlite-utils insert words.db words - \ + --text --convert '({"word": w} for w in text.split())' + Options: --pk TEXT Columns to use as the primary key, e.g. id --flatten Flatten nested JSON objects, so {"a": {"b": 1}} diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 0da3817..9e0289f 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -1158,6 +1158,13 @@ def insert( If you are using --lines your code will be passed a "line" variable, and for --text a "text" variable. + + When using --text your function can return an iterator of rows to + insert. This example inserts one record per word in the input: + + \b + echo 'A bunch of words' | sqlite-utils insert words.db words - \\ + --text --convert '({"word": w} for w in text.split())' """ try: insert_upsert_implementation( From a692c56659c3563b26dcdc9e3534d63ecc26e180 Mon Sep 17 00:00:00 2001 From: Chris Amico Date: Tue, 15 Feb 2022 19:58:07 -0500 Subject: [PATCH 120/563] Add SpatiaLite helpers to CLI (#407) * Add SpatiaLite CLI helpers * Add docs for spaitalite helpers * Fix flake8 issues and add more detail on spatial types * Run cog and add some help text. * Use SpatiaLite when calculating coverage, refs #407 Co-authored-by: Simon Willison --- .github/workflows/test-coverage.yml | 2 + docs/cli-reference.rst | 51 ++++++++- docs/cli.rst | 39 +++++++ sqlite_utils/cli.py | 127 +++++++++++++++++++++- tests/test_cli.py | 29 ----- tests/test_gis.py | 157 +++++++++++++++++++++++++++- 6 files changed, 370 insertions(+), 35 deletions(-) diff --git a/.github/workflows/test-coverage.yml b/.github/workflows/test-coverage.yml index 4b99cc5..0681f66 100644 --- a/.github/workflows/test-coverage.yml +++ b/.github/workflows/test-coverage.yml @@ -24,6 +24,8 @@ jobs: key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }} restore-keys: | ${{ runner.os }}-pip- + - name: Install SpatiaLite + run: sudo apt-get install libsqlite3-mod-spatialite - name: Install Python dependencies run: | python -m pip install --upgrade pip diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index 3359d5b..5497259 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -772,8 +772,10 @@ See :ref:`cli_create_database`. sqlite-utils create-database trees.db Options: - --enable-wal Enable WAL mode on the created database - -h, --help Show this message and exit. + --enable-wal Enable WAL mode on the created database + --init-spatialite Enable SpatiaLite on the created database + --load-extension TEXT SQLite extensions to load + -h, --help Show this message and exit. create-table @@ -1226,4 +1228,49 @@ See :ref:`cli_drop_view`. -h, --help Show this message and exit. +add-geometry-column +=================== + +:: + + Usage: sqlite-utils add-geometry-column [OPTIONS] DB_PATH TABLE COLUMN_NAME + + Add a SpatiaLite geometry column to an existing table. Requires SpatiaLite + extension. + + By default, this command will try to load the SpatiaLite extension from usual + paths. To load it from a specific path, use --load-extension. + + Options: + -t, --type [POINT|LINESTRING|POLYGON|MULTIPOINT|MULTILINESTRING|MULTIPOLYGON|GEOMETRYCOLLECTION|GEOMETRY] + Specify a geometry type for this column. + [default: GEOMETRY] + --srid INTEGER Spatial Reference ID. See + https://spatialreference.org for details on + specific projections. [default: 4326] + --dimensions TEXT Coordinate dimensions. Use XYZ for three- + dimensional geometries. + --not-null Add a NOT NULL constraint. + --load-extension TEXT SQLite extensions to load + -h, --help Show this message and exit. + + +create-spatial-index +==================== + +:: + + Usage: sqlite-utils create-spatial-index [OPTIONS] DB_PATH TABLE COLUMN_NAME + + Create a spatial index on a SpatiaLite geometry column. The table and geometry + column must already exist before trying to add a spatial index. + + By default, this command will try to load the SpatiaLite extension from usual + paths. To load it from a specific path, use --load-extension. + + Options: + --load-extension TEXT SQLite extensions to load + -h, --help Show this message and exit. + + .. [[[end]]] diff --git a/docs/cli.rst b/docs/cli.rst index e75adec..cadcbf1 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -724,6 +724,14 @@ To enable :ref:`cli_wal` on the newly created database add the ``--enable-wal`` $ sqlite-utils create-database empty.db --enable-wal +To enable SpatiaLite metadata on a newly created database, add the ``--init-spatialite`` flag:: + + $ sqlite-utils create-database empty.db --init-spatialite + +That will look for SpatiaLite in a set of predictable locations. To load it from somewhere else, use the ``--load-extension`` option:: + + $ sqlite-utils create-database empty.db --init-spatialite --load-extension /path/to/spatialite.so + .. _cli_inserting_data: Inserting JSON data @@ -1975,3 +1983,34 @@ Since `SpatiaLite `__ is com $ sqlite-utils memory "select spatialite_version()" --load-extension=spatialite [{"spatialite_version()": "4.3.0a"}] + + +SpatiaLite helpers +================== + +`SpatiaLite `_ adds geographic capability to SQLite (similar to how PostGIS builds on PostgreSQL). The `SpatiaLite cookbook `_ is a good resource for learning what's possible with it. + +You can convert an existing table to a geographic table by adding a geometry column, use the `sqlite-utils add-geometry-column` command:: + + $ sqlite-utils add-geometry-column spatial.db locations geometry --type POLYGON --srid 4326 + +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. + +Eight (case-insensitive) types are allowed: + + * POINT + * LINESTRING + * POLYGON + * MULTIPOINT + * MULTILINESTRING + * MULTIPOLYGON + * GEOMETRYCOLLECTION + * GEOMETRY + +Once you have a geometry column, you can speed up bounding box queries by adding a spatial index:: + + $ sqlite-utils create-spatial-index spatial.db locations geometry + +See the `SpatiaLite Cookbook `_ for examples of how to use a spatial index. diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 9e0289f..8255b56 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -1363,7 +1363,11 @@ def bulk( @click.option( "--enable-wal", is_flag=True, help="Enable WAL mode on the created database" ) -def create_database(path, enable_wal): +@click.option( + "--init-spatialite", is_flag=True, help="Enable SpatiaLite on the created database" +) +@load_extension_option +def create_database(path, enable_wal, init_spatialite, load_extension): """Create a new empty database file Example: @@ -1374,6 +1378,15 @@ def create_database(path, enable_wal): db = sqlite_utils.Database(path) if enable_wal: db.enable_wal() + + # load spatialite or another extension from a custom location + if load_extension: + _load_extensions(db, load_extension) + + # load spatialite from expected locations and initialize metadata + if init_spatialite: + db.init_spatialite() + db.vacuum() @@ -2544,7 +2557,7 @@ def _analyze(db, tables, columns, save): total=len(todo), most_common_rendered=most_common_rendered, least_common_rendered=least_common_rendered, - **column_details._asdict() + **column_details._asdict(), ) ) + "\n" @@ -2701,6 +2714,116 @@ def convert( ) +@cli.command("add-geometry-column") +@click.argument( + "db_path", + type=click.Path(file_okay=True, dir_okay=False, allow_dash=False), + required=True, +) +@click.argument("table", type=str) +@click.argument("column_name", type=str) +@click.option( + "-t", + "--type", + "geometry_type", + type=click.Choice( + [ + "POINT", + "LINESTRING", + "POLYGON", + "MULTIPOINT", + "MULTILINESTRING", + "MULTIPOLYGON", + "GEOMETRYCOLLECTION", + "GEOMETRY", + ], + case_sensitive=False, + ), + default="GEOMETRY", + help="Specify a geometry type for this column.", + show_default=True, +) +@click.option( + "--srid", + type=int, + default=4326, + show_default=True, + help="Spatial Reference ID. See https://spatialreference.org for details on specific projections.", +) +@click.option( + "--dimensions", + "coord_dimension", + type=str, + default="XY", + help="Coordinate dimensions. Use XYZ for three-dimensional geometries.", +) +@click.option("--not-null", "not_null", is_flag=True, help="Add a NOT NULL constraint.") +@load_extension_option +def add_geometry_column( + db_path, + table, + column_name, + geometry_type, + srid, + coord_dimension, + not_null, + load_extension, +): + """Add a SpatiaLite geometry column to an existing table. Requires SpatiaLite extension. + \n\n + By default, this command will try to load the SpatiaLite extension from usual paths. + To load it from a specific path, use --load-extension.""" + db = sqlite_utils.Database(db_path) + if not db[table].exists(): + raise click.ClickException( + "You must create a table before adding a geometry column" + ) + + # load spatialite, one way or another + if load_extension: + _load_extensions(db, load_extension) + db.init_spatialite() + + if db[table].add_geometry_column( + column_name, geometry_type, srid, coord_dimension, not_null + ): + click.echo(f"Added {geometry_type} column {column_name} to {table}") + + +@cli.command("create-spatial-index") +@click.argument( + "db_path", + type=click.Path(file_okay=True, dir_okay=False, allow_dash=False), + required=True, +) +@click.argument("table", type=str) +@click.argument("column_name", type=str) +@load_extension_option +def create_spatial_index(db_path, table, column_name, load_extension): + """Create a spatial index on a SpatiaLite geometry column. + The table and geometry column must already exist before trying to add a spatial index. + \n\n + By default, this command will try to load the SpatiaLite extension from usual paths. + To load it from a specific path, use --load-extension.""" + db = sqlite_utils.Database(db_path) + if not db[table].exists(): + raise click.ClickException( + "You must create a table and add a geometry column before creating a spatial index" + ) + + # load spatialite + if load_extension: + _load_extensions(db, load_extension) + db.init_spatialite() + + if column_name not in db[table].columns_dict: + raise click.ClickException( + "You must add a geometry column before creating a spatial index" + ) + + db[table].create_spatial_index(column_name) + + def _render_common(title, values): if values is None: return "" diff --git a/tests/test_cli.py b/tests/test_cli.py index 244029d..1922941 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -7,7 +7,6 @@ from unittest import mock import json import os import pytest -from sqlite_utils.utils import sqlite3, find_spatialite import textwrap from .utils import collapse_whitespace @@ -792,34 +791,6 @@ def test_query_raw(db_path, content, is_binary): assert result.output == str(content) -@pytest.mark.skipif(not find_spatialite(), reason="Could not find SpatiaLite extension") -@pytest.mark.skipif( - not hasattr(sqlite3.Connection, "enable_load_extension"), - reason="sqlite3.Connection missing enable_load_extension", -) -@pytest.mark.parametrize("use_spatialite_shortcut", [True, False]) -def test_query_load_extension(use_spatialite_shortcut): - # Without --load-extension: - result = CliRunner().invoke(cli.cli, [":memory:", "select spatialite_version()"]) - assert result.exit_code == 1 - assert "no such function: spatialite_version" in result.output - # With --load-extension: - if use_spatialite_shortcut: - load_extension = "spatialite" - else: - load_extension = find_spatialite() - result = CliRunner().invoke( - cli.cli, - [ - ":memory:", - "select spatialite_version()", - "--load-extension={}".format(load_extension), - ], - ) - assert result.exit_code == 0, result.stdout - assert ["spatialite_version()"] == list(json.loads(result.output)[0].keys()) - - def test_query_memory_does_not_create_file(tmpdir): owd = os.getcwd() try: diff --git a/tests/test_gis.py b/tests/test_gis.py index 3bf0d5f..3b1fbf1 100644 --- a/tests/test_gis.py +++ b/tests/test_gis.py @@ -1,7 +1,10 @@ +import json import pytest -from sqlite_utils.utils import find_spatialite + +from click.testing import CliRunner +from sqlite_utils.cli import cli from sqlite_utils.db import Database -from sqlite_utils.utils import sqlite3 +from sqlite_utils.utils import find_spatialite, sqlite3 pytestmark = [ pytest.mark.skipif( @@ -14,6 +17,7 @@ pytestmark = [ ] +# python API tests def test_find_spatialite(): spatialite = find_spatialite() assert spatialite is None or isinstance(spatialite, str) @@ -81,3 +85,152 @@ def test_double_create_spatial_index(): # call it again, return False assert not table.create_spatial_index("geometry") + + +# cli tests +@pytest.mark.parametrize("use_spatialite_shortcut", [True, False]) +def test_query_load_extension(use_spatialite_shortcut): + # Without --load-extension: + result = CliRunner().invoke(cli, [":memory:", "select spatialite_version()"]) + assert result.exit_code == 1 + assert "no such function: spatialite_version" in result.output + # With --load-extension: + if use_spatialite_shortcut: + load_extension = "spatialite" + else: + load_extension = find_spatialite() + result = CliRunner().invoke( + cli, + [ + ":memory:", + "select spatialite_version()", + "--load-extension={}".format(load_extension), + ], + ) + assert result.exit_code == 0, result.stdout + assert ["spatialite_version()"] == list(json.loads(result.output)[0].keys()) + + +def test_cli_create_spatialite(tmpdir): + # sqlite-utils create test.db --init-spatialite + db_path = tmpdir / "created.db" + result = CliRunner().invoke( + cli, ["create-database", str(db_path), "--init-spatialite"] + ) + + assert 0 == result.exit_code + assert db_path.exists() + assert db_path.read_binary()[:16] == b"SQLite format 3\x00" + + db = Database(str(db_path)) + assert "spatial_ref_sys" in db.table_names() + + +def test_cli_add_geometry_column(tmpdir): + # create a rowid table with one column + db_path = tmpdir / "spatial.db" + db = Database(str(db_path)) + db.init_spatialite() + + table = db["locations"].create({"name": str}) + + result = CliRunner().invoke( + cli, + [ + "add-geometry-column", + str(db_path), + table.name, + "geometry", + "--type", + "POINT", + ], + ) + + assert 0 == result.exit_code + + assert db["geometry_columns"].get(["locations", "geometry"]) == { + "f_table_name": "locations", + "f_geometry_column": "geometry", + "geometry_type": 1, # point + "coord_dimension": 2, + "srid": 4326, + "spatial_index_enabled": 0, + } + + +def test_cli_add_geometry_column_options(tmpdir): + # create a rowid table with one column + db_path = tmpdir / "spatial.db" + db = Database(str(db_path)) + db.init_spatialite() + table = db["locations"].create({"name": str}) + + result = CliRunner().invoke( + cli, + [ + "add-geometry-column", + str(db_path), + table.name, + "geometry", + "-t", + "POLYGON", + "--srid", + "3857", # https://epsg.io/3857 + "--not-null", + ], + ) + + assert 0 == result.exit_code + + assert db["geometry_columns"].get(["locations", "geometry"]) == { + "f_table_name": "locations", + "f_geometry_column": "geometry", + "geometry_type": 3, # polygon + "coord_dimension": 2, + "srid": 3857, + "spatial_index_enabled": 0, + } + + column = table.columns[1] + assert column.notnull + + +def test_cli_add_geometry_column_invalid_type(tmpdir): + # create a rowid table with one column + db_path = tmpdir / "spatial.db" + db = Database(str(db_path)) + db.init_spatialite() + + table = db["locations"].create({"name": str}) + + result = CliRunner().invoke( + cli, + [ + "add-geometry-column", + str(db_path), + table.name, + "geometry", + "--type", + "NOT-A-TYPE", + ], + ) + + assert 2 == result.exit_code + + +def test_cli_create_spatial_index(tmpdir): + # create a rowid table with one column + db_path = tmpdir / "spatial.db" + db = Database(str(db_path)) + db.init_spatialite() + + table = db["locations"].create({"name": str}) + table.add_geometry_column("geometry", "POINT") + + result = CliRunner().invoke( + cli, ["create-spatial-index", str(db_path), table.name, "geometry"] + ) + + assert 0 == result.exit_code + + assert "idx_locations_geometry" in db.table_names() From 3e5a4f60cc07e38113e522e5f1d09db35626affc Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 15 Feb 2022 17:06:49 -0800 Subject: [PATCH 121/563] Tweaked SpatiaLite CLI docs, refs #398 --- docs/cli-reference.rst | 6 ++++++ docs/cli.rst | 8 +++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index 5497259..19b714b 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -56,6 +56,8 @@ This page lists the ``--help`` for every ``sqlite-utils`` CLI sub-command. "insert-files": "cli_insert_files", "analyze-tables": "cli_analyze_tables", "convert": "cli_convert", + "add-geometry-column": "cli_spatialite", + "create-spatial-index": "cli_spatialite_indexes", } commands.sort(key = lambda command: go_first.index(command) if command in go_first else 999) cog.out("\n") @@ -1231,6 +1233,8 @@ See :ref:`cli_drop_view`. add-geometry-column =================== +See :ref:`cli_spatialite`. + :: Usage: sqlite-utils add-geometry-column [OPTIONS] DB_PATH TABLE COLUMN_NAME @@ -1258,6 +1262,8 @@ add-geometry-column create-spatial-index ==================== +See :ref:`cli_spatialite_indexes`. + :: Usage: sqlite-utils create-spatial-index [OPTIONS] DB_PATH TABLE COLUMN_NAME diff --git a/docs/cli.rst b/docs/cli.rst index cadcbf1..8ec3f9e 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -1984,13 +1984,14 @@ Since `SpatiaLite `__ is com $ sqlite-utils memory "select spatialite_version()" --load-extension=spatialite [{"spatialite_version()": "4.3.0a"}] +.. _cli_spatialite: SpatiaLite helpers ================== `SpatiaLite `_ adds geographic capability to SQLite (similar to how PostGIS builds on PostgreSQL). The `SpatiaLite cookbook `_ is a good resource for learning what's possible with it. -You can convert an existing table to a geographic table by adding a geometry column, use the `sqlite-utils add-geometry-column` command:: +You can convert an existing table to a geographic table by adding a geometry column, use the ``sqlite-utils add-geometry-column`` command:: $ sqlite-utils add-geometry-column spatial.db locations geometry --type POLYGON --srid 4326 @@ -2009,6 +2010,11 @@ Eight (case-insensitive) types are allowed: * GEOMETRYCOLLECTION * GEOMETRY +.. _cli_spatialite_indexes: + +Adding spatial indexes +---------------------- + Once you have a geometry column, you can speed up bounding box queries by adding a spatial index:: $ sqlite-utils create-spatial-index spatial.db locations geometry From 8f528ed2b13c309c9efb1ee6e1150ab3fce11d89 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 15 Feb 2022 17:21:07 -0800 Subject: [PATCH 122/563] Fix ReST warning --- docs/cli.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/cli.rst b/docs/cli.rst index 8ec3f9e..03689c1 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -1989,7 +1989,7 @@ Since `SpatiaLite `__ is com SpatiaLite helpers ================== -`SpatiaLite `_ adds geographic capability to SQLite (similar to how PostGIS builds on PostgreSQL). The `SpatiaLite cookbook `_ is a good resource for learning what's possible with it. +`SpatiaLite `_ adds geographic capability to SQLite (similar to how PostGIS builds on PostgreSQL). The `SpatiaLite cookbook `__ is a good resource for learning what's possible with it. You can convert an existing table to a geographic table by adding a geometry column, use the ``sqlite-utils add-geometry-column`` command:: @@ -2019,4 +2019,4 @@ Once you have a geometry column, you can speed up bounding box queries by adding $ sqlite-utils create-spatial-index spatial.db locations geometry -See the `SpatiaLite Cookbook `_ for examples of how to use a spatial index. +See this `SpatiaLite Cookbook recipe `__ for examples of how to use a spatial index. From 4bc06a243774ca8d8e04ad6592e895d3a7a0300b Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 15 Feb 2022 17:21:25 -0800 Subject: [PATCH 123/563] memory_name= feature, closes #405 --- docs/python-api.rst | 6 ++++++ sqlite_utils/db.py | 15 ++++++++++++--- tests/test_constructor.py | 7 +++++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/docs/python-api.rst b/docs/python-api.rst index 149c3a2..fa814b3 100644 --- a/docs/python-api.rst +++ b/docs/python-api.rst @@ -98,6 +98,12 @@ If you want to create an in-memory database, you can do so like this: db = Database(memory=True) +You can also create a named in-memory database. Unlike regular memory databases these can be accessed by multiple threads, provided at least one reference to the database still exists. `del db` will clear the database from memory. + +.. code-block:: python + + db = Database(memory_name="my_shared_database") + Connections use ``PRAGMA recursive_triggers=on`` by default. If you don't want to use `recursive triggers `__ you can turn them off using: .. code-block:: python diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index 1927f41..1bae5ad 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -278,6 +278,7 @@ class Database: - ``filename_or_conn`` - String path to a file, or a ``pathlib.Path`` object, or a ``sqlite3`` connection - ``memory`` - set to ``True`` to create an in-memory database + - ``memory_name`` - creates a named in-memory database that can be shared across multiple connections. - ``recreate`` - set to ``True`` to delete and recreate a file database (**dangerous**) - ``recursive_triggers`` - defaults to ``True``, which sets ``PRAGMA recursive_triggers=on;`` - set to ``False`` to avoid setting this pragma @@ -294,15 +295,23 @@ class Database: self, filename_or_conn: Union[str, pathlib.Path, sqlite3.Connection] = None, memory: bool = False, + memory_name: str = None, recreate: bool = False, recursive_triggers: bool = True, tracer: Callable = None, use_counts_table: bool = False, ): - assert (filename_or_conn is not None and not memory) or ( - filename_or_conn is None and memory + assert (filename_or_conn is not None and (not memory and not memory_name)) or ( + filename_or_conn is None and (memory or memory_name) ), "Either specify a filename_or_conn or pass memory=True" - if memory or filename_or_conn == ":memory:": + if memory_name: + uri = "file:{}?mode=memory&cache=shared".format(memory_name) + self.conn = sqlite3.connect( + uri, + uri=True, + check_same_thread=False, + ) + elif memory or filename_or_conn == ":memory:": self.conn = sqlite3.connect(":memory:") elif isinstance(filename_or_conn, (str, pathlib.Path)): if recreate and os.path.exists(filename_or_conn): diff --git a/tests/test_constructor.py b/tests/test_constructor.py index 924df66..36ab1bc 100644 --- a/tests/test_constructor.py +++ b/tests/test_constructor.py @@ -9,3 +9,10 @@ def test_recursive_triggers(): def test_recursive_triggers_off(): db = Database(memory=True, recursive_triggers=False) assert not db.execute("PRAGMA recursive_triggers").fetchone()[0] + + +def test_memory_name(): + db1 = Database(memory_name="shared") + db2 = Database(memory_name="shared") + db1["dogs"].insert({"name": "Cleo"}) + assert list(db2["dogs"].rows) == [{"name": "Cleo"}] From 757f103ae2a8b3803ceea89a412cf78b269f9e75 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 15 Feb 2022 17:39:13 -0800 Subject: [PATCH 124/563] Release 3.24 Refs ##397, #398, #401, #403, #404, #405, #407 --- docs/changelog.rst | 15 +++++++++++++++ setup.py | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 7f62c62..a2a024b 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -2,6 +2,21 @@ Changelog =========== +.. _v3_24: + +3.24 (2022-02-15) +----------------- + +- SpatiaLite helpers for the ``sqlite-utils`` command-line tool - thanks, Chris Amico. (:issue:`398`) + + - :ref:`sqlite-utils create-database ` ``--init-spatialite`` option for initializing SpatiaLite on a newly created database. + - :ref:`sqlite-utils add-geometry-column ` command for adding geometry columns. + - :ref:`sqlite-utils create-spatial-index ` command for adding spatial indexes. + +- ``db[table].create(..., if_not_exists=True)`` option for :ref:`creating a table ` only if it does not already exist. (:issue:`397`) +- ``Database(memory_name="my_shared_database")`` parameter for creating a :ref:`named in-memory database ` that can be shared between multiple connections. (:issue:`405`) +- Documentation now describes :ref:`how to add a primary key to a rowid table ` using ``sqlite-utils transform``. (:issue:`403`) + .. _v3_23: 3.23 (2022-02-03) diff --git a/setup.py b/setup.py index 6d96533..9db7db1 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages import io import os -VERSION = "3.23" +VERSION = "3.24" def get_long_description(): From 7a098aa0c5e8beef6ccc55c866cf7792af5fcf43 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 17 Feb 2022 07:39:54 -0800 Subject: [PATCH 125/563] Link to my blog series --- README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9debfcd..6f81fc1 100644 --- a/README.md +++ b/README.md @@ -13,13 +13,12 @@ Python CLI utility and library for manipulating SQLite databases. ## Some feature highlights - [Pipe JSON](https://sqlite-utils.datasette.io/en/stable/cli.html#inserting-json-data) (or [CSV or TSV](https://sqlite-utils.datasette.io/en/stable/cli.html#inserting-csv-or-tsv-data)) directly into a new SQLite database file, automatically creating a table with the appropriate schema -- [Run in-memory SQL queries](https://sqlite-utils.datasette.io/en/stable/cli.html#querying-data-directly-using-an-in-memory-database), including joins, directly against data in CSV, TSV or JSON files and view the results. +- [Run in-memory SQL queries](https://sqlite-utils.datasette.io/en/stable/cli.html#querying-data-directly-using-an-in-memory-database), including joins, directly against data in CSV, TSV or JSON files and view the results - [Configure SQLite full-text search](https://sqlite-utils.datasette.io/en/stable/cli.html#configuring-full-text-search) against your database tables and run search queries against them, ordered by relevance - Run [transformations against your tables](https://sqlite-utils.datasette.io/en/stable/cli.html#transforming-tables) to make schema changes that SQLite `ALTER TABLE` does not directly support, such as changing the type of a column - [Extract columns](https://sqlite-utils.datasette.io/en/stable/cli.html#extracting-columns-into-a-separate-table) into separate tables to better normalize your existing data -Read more on my blog: [ -sqlite-utils: a Python library and CLI tool for building SQLite databases](https://simonwillison.net/2019/Feb/25/sqlite-utils/) and other [entries tagged sqliteutils](https://simonwillison.net/tags/sqliteutils/). +Read more on my blog, in this series of posts on [New features in sqlite-utils](https://simonwillison.net/series/sqlite-utils-features/) and other [entries tagged sqliteutils](https://simonwillison.net/tags/sqliteutils/). ## Installation From b6c9dfce0ba27eb5fb6bc2221044798420f861c4 Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Tue, 1 Mar 2022 21:05:29 +0000 Subject: [PATCH 126/563] Correct spelling mistakes (found with codespell) (#410) --- tests/test_cli.py | 6 +++--- tests/test_fts.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 1922941..e2e4aa2 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1218,7 +1218,7 @@ def test_drop_table_error(): ) assert 1 == result.exit_code assert 'Error: Table "t2" does not exist' == result.output.strip() - # Using --ignore supresses that error + # Using --ignore suppresses that error result = runner.invoke( cli.cli, ["drop-table", "test.db", "t2", "--ignore"], @@ -1259,7 +1259,7 @@ def test_drop_view_error(): ) assert 1 == result.exit_code assert 'Error: View "t2" does not exist' == result.output.strip() - # Using --ignore supresses that error + # Using --ignore suppresses that error result = runner.invoke( cli.cli, ["drop-view", "test.db", "t2", "--ignore"], @@ -2014,7 +2014,7 @@ def test_insert_detect_types(tmpdir, option_or_env_var): ] if option_or_env_var is None: - # Use environemnt variable instead of option + # Use environment variable instead of option with mock.patch.dict(os.environ, {"SQLITE_UTILS_DETECT_TYPES": "1"}): _test() else: diff --git a/tests/test_fts.py b/tests/test_fts.py index 0ae2a52..24980f7 100644 --- a/tests/test_fts.py +++ b/tests/test_fts.py @@ -269,7 +269,7 @@ def test_rebuild_fts(fresh_db): }.items() <= rows[0].items() # Insert another record table.insert(search_records[1]) - # This should NOT show up in a searchs + # This should NOT show up in searches assert len(list(table.search("are"))) == 1 # Running rebuild_fts() should fix it table.rebuild_fts() From 931b1e151320535acf0a899c7d403d71b5199f6a Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 1 Mar 2022 16:00:51 -0800 Subject: [PATCH 127/563] .insert(hash_id_columns=) parameter, closes #343 --- docs/python-api.rst | 13 ++++++++++- docs/reference.rst | 10 ++++++++ sqlite_utils/db.py | 53 +++++++++++++++++++++++++++++++++---------- sqlite_utils/utils.py | 31 ++++++++++++++++++++++++- tests/test_create.py | 28 ++++++++++++++++++++++- tests/test_upsert.py | 24 ++++++++++++++++++++ tests/test_utils.py | 14 ++++++++++++ 7 files changed, 158 insertions(+), 15 deletions(-) diff --git a/docs/python-api.rst b/docs/python-api.rst index fa814b3..e8c4bda 100644 --- a/docs/python-api.rst +++ b/docs/python-api.rst @@ -633,7 +633,7 @@ You can set default values for these methods by accessing the table through the # Now you can call .insert() like so: table.insert({"id": 1, "name": "Tracy", "score": 5}) -The configuration options that can be specified in this way are ``pk``, ``foreign_keys``, ``column_order``, ``not_null``, ``defaults``, ``batch_size``, ``hash_id``, ``alter``, ``ignore``, ``replace``, ``extracts``, ``conversions``, ``columns``. These are all documented below. +The configuration options that can be specified in this way are ``pk``, ``foreign_keys``, ``column_order``, ``not_null``, ``defaults``, ``batch_size``, ``hash_id``, ``hash_id_columns``, ``alter``, ``ignore``, ``replace``, ``extracts``, ``conversions``, ``columns``. These are all documented below. .. _python_api_defaults_not_null: @@ -1594,6 +1594,17 @@ If you are going to use that ID straight away, you can access it using ``last_pk }, hash_id="id").last_pk # dog_id is now "f501265970505d9825d8d9f590bfab3519fb20b1" +The hash will be created using all of the column values. To create a hash using a subset of the columns, pass the ``hash_id_columns=`` parameter:: + + db["dogs"].upsert( + {"name": "Cleo", "twitter": "cleopaws", "age": 7}, + hash_id_columns=("name", "twitter") + ) + +The `hash_id=` parameter is optional if you specify ``hash_id_columns=`` - it will default to putting the hash in a column called ``id``. + +You can manually calculate these hashes using the :ref:`hash_record(record, keys=...) ` utility function. + .. _python_api_create_view: Creating views diff --git a/docs/reference.rst b/docs/reference.rst index 4638026..5bd609f 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -68,3 +68,13 @@ sqlite_utils.db.ColumnDetails ----------------------------- .. autoclass:: sqlite_utils.db.ColumnDetails + +sqlite_utils.utils +================== + +.. _reference_utils_hash_record: + +sqlite_utils.utils.hash_record +------------------------------ + +.. autofunction:: sqlite_utils.utils.hash_record diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index 1bae5ad..70b452c 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -1,5 +1,6 @@ from .utils import ( chunks, + hash_record, sqlite3, OperationalError, suggest_column_types, @@ -13,7 +14,6 @@ from collections.abc import Mapping import contextlib import datetime import decimal -import hashlib import inspect import itertools import json @@ -663,13 +663,16 @@ class Database: pk: Optional[Any] = None, foreign_keys: Optional[ForeignKeysType] = None, column_order: Optional[List[str]] = None, - not_null: Iterable[str] = None, + not_null: Optional[Iterable[str]] = None, defaults: Optional[Dict[str, Any]] = None, hash_id: Optional[str] = None, + hash_id_columns: Optional[Iterable[str]] = None, extracts: Optional[Union[Dict[str, str], List[str]]] = None, if_not_exists: bool = False, ) -> str: "Returns the SQL ``CREATE TABLE`` statement for creating the specified table." + if hash_id_columns and (hash_id is None): + hash_id = "id" foreign_keys = self.resolve_foreign_keys(name, foreign_keys or []) foreign_keys_by_column = {fk.column: fk for fk in foreign_keys} # any extracts will be treated as integer columns with a foreign key @@ -779,6 +782,7 @@ class Database: not_null: Iterable[str] = None, defaults: Optional[Dict[str, Any]] = None, hash_id: Optional[str] = None, + hash_id_columns: Optional[Iterable[str]] = None, extracts: Optional[Union[Dict[str, str], List[str]]] = None, if_not_exists: bool = False, ) -> "Table": @@ -796,6 +800,7 @@ class Database: not_null=not_null, defaults=defaults, hash_id=hash_id, + hash_id_columns=hash_id_columns, extracts=extracts, if_not_exists=if_not_exists, ) @@ -808,6 +813,7 @@ class Database: not_null=not_null, defaults=defaults, hash_id=hash_id, + hash_id_columns=hash_id_columns, ) return cast(Table, table) @@ -1126,12 +1132,13 @@ class Table(Queryable): defaults: Optional[Dict[str, Any]] = None, batch_size: int = 100, hash_id: Optional[str] = None, + hash_id_columns: Optional[Iterable[str]] = None, alter: bool = False, ignore: bool = False, replace: bool = False, extracts: Optional[Union[Dict[str, str], List[str]]] = None, conversions: Optional[dict] = None, - columns: Optional[Union[Dict[str, Any]]] = None, + columns: Optional[Dict[str, Any]] = None, ): super().__init__(db, name) self._defaults = dict( @@ -1142,6 +1149,7 @@ class Table(Queryable): defaults=defaults, batch_size=batch_size, hash_id=hash_id, + hash_id_columns=hash_id_columns, alter=alter, ignore=ignore, replace=replace, @@ -1314,6 +1322,7 @@ class Table(Queryable): not_null: Iterable[str] = None, defaults: Optional[Dict[str, Any]] = None, hash_id: Optional[str] = None, + hash_id_columns: Optional[Iterable[str]] = None, extracts: Optional[Union[Dict[str, str], List[str]]] = None, if_not_exists: bool = False, ) -> "Table": @@ -1333,6 +1342,7 @@ class Table(Queryable): not_null=not_null, defaults=defaults, hash_id=hash_id, + hash_id_columns=hash_id_columns, extracts=extracts, if_not_exists=if_not_exists, ) @@ -2389,6 +2399,7 @@ class Table(Queryable): chunk, all_columns, hash_id, + hash_id_columns, upsert, pk, conversions, @@ -2400,12 +2411,19 @@ class Table(Queryable): # .execute() method - but some of them may be replaced by # new primary keys if we are extracting any columns. values = [] + if hash_id_columns and hash_id is None: + hash_id = "id" extracts = resolve_extracts(extracts) for record in chunk: record_values = [] for key in all_columns: value = jsonify_if_needed( - record.get(key, None if key != hash_id else _hash(record)) + record.get( + key, + None + if key != hash_id + else hash_record(record, hash_id_columns), + ) ) if key in extracts: extract_table = extracts[key] @@ -2486,6 +2504,7 @@ class Table(Queryable): chunk, all_columns, hash_id, + hash_id_columns, upsert, pk, conversions, @@ -2498,6 +2517,7 @@ class Table(Queryable): chunk, all_columns, hash_id, + hash_id_columns, upsert, pk, conversions, @@ -2527,6 +2547,7 @@ class Table(Queryable): first_half, all_columns, hash_id, + hash_id_columns, upsert, pk, conversions, @@ -2541,6 +2562,7 @@ class Table(Queryable): second_half, all_columns, hash_id, + hash_id_columns, upsert, pk, conversions, @@ -2575,6 +2597,7 @@ class Table(Queryable): not_null: Optional[Union[Set[str], Default]] = DEFAULT, defaults: Optional[Union[Dict[str, Any], Default]] = DEFAULT, hash_id: Optional[Union[str, Default]] = DEFAULT, + hash_id_columns: Optional[Iterable[str]] = DEFAULT, alter: Optional[Union[bool, Default]] = DEFAULT, ignore: Optional[Union[bool, Default]] = DEFAULT, replace: Optional[Union[bool, Default]] = DEFAULT, @@ -2621,6 +2644,7 @@ class Table(Queryable): not_null=not_null, defaults=defaults, hash_id=hash_id, + hash_id_columns=hash_id_columns, alter=alter, ignore=ignore, replace=replace, @@ -2639,6 +2663,7 @@ class Table(Queryable): defaults=DEFAULT, batch_size=DEFAULT, hash_id=DEFAULT, + hash_id_columns=DEFAULT, alter=DEFAULT, ignore=DEFAULT, replace=DEFAULT, @@ -2662,6 +2687,7 @@ class Table(Queryable): defaults = self.value_or_default("defaults", defaults) batch_size = self.value_or_default("batch_size", batch_size) hash_id = self.value_or_default("hash_id", hash_id) + hash_id_columns = self.value_or_default("hash_id_columns", hash_id_columns) alter = self.value_or_default("alter", alter) ignore = self.value_or_default("ignore", ignore) replace = self.value_or_default("replace", replace) @@ -2669,9 +2695,14 @@ class Table(Queryable): conversions = self.value_or_default("conversions", conversions) or {} columns = self.value_or_default("columns", columns) + if hash_id_columns and hash_id is None: + hash_id = "id" + if upsert and (not pk and not hash_id): raise PrimaryKeyRequired("upsert() requires a pk") assert not (hash_id and pk), "Use either pk= or hash_id=" + if hash_id_columns and (hash_id is None): + hash_id = "id" if hash_id: pk = hash_id @@ -2716,6 +2747,7 @@ class Table(Queryable): not_null=not_null, defaults=defaults, hash_id=hash_id, + hash_id_columns=hash_id_columns, extracts=extracts, ) all_columns_set = set() @@ -2738,6 +2770,7 @@ class Table(Queryable): chunk, all_columns, hash_id, + hash_id_columns, upsert, pk, conversions, @@ -2760,6 +2793,7 @@ class Table(Queryable): not_null=DEFAULT, defaults=DEFAULT, hash_id=DEFAULT, + hash_id_columns=DEFAULT, alter=DEFAULT, extracts=DEFAULT, conversions=DEFAULT, @@ -2779,6 +2813,7 @@ class Table(Queryable): not_null=not_null, defaults=defaults, hash_id=hash_id, + hash_id_columns=hash_id_columns, alter=alter, extracts=extracts, conversions=conversions, @@ -2795,6 +2830,7 @@ class Table(Queryable): defaults=DEFAULT, batch_size=DEFAULT, hash_id=DEFAULT, + hash_id_columns=DEFAULT, alter=DEFAULT, extracts=DEFAULT, conversions=DEFAULT, @@ -2813,6 +2849,7 @@ class Table(Queryable): defaults=defaults, batch_size=batch_size, hash_id=hash_id, + hash_id_columns=hash_id_columns, alter=alter, extracts=extracts, conversions=conversions, @@ -3184,14 +3221,6 @@ def jsonify_if_needed(value): return value -def _hash(record): - return hashlib.sha1( - json.dumps(record, separators=(",", ":"), sort_keys=True, default=repr).encode( - "utf8" - ) - ).hexdigest() - - def resolve_extracts( extracts: Optional[Union[Dict[str, str], List[str], Tuple[str]]] ) -> dict: diff --git a/sqlite_utils/utils.py b/sqlite_utils/utils.py index e3386d8..4ec8b9f 100644 --- a/sqlite_utils/utils.py +++ b/sqlite_utils/utils.py @@ -2,12 +2,13 @@ import base64 import contextlib import csv import enum +import hashlib import io import itertools import json import os from . import recipes -from typing import cast, BinaryIO, Iterable, Optional, Tuple, Type +from typing import Dict, cast, BinaryIO, Iterable, Optional, Tuple, Type import click @@ -345,3 +346,31 @@ def chunks(sequence, size): iterator = iter(sequence) for item in iterator: yield itertools.chain([item], itertools.islice(iterator, size - 1)) + + +def hash_record(record: Dict, keys: Optional[Iterable[str]] = None): + """ + ``record`` should be a Python dictionary. Returns a sha1 hash of the + keys and values in that record. + + If ``keys=`` is provided, uses just those keys to generate the hash. + + Example usage:: + + from sqlite_utils.utils import hash_record + + hashed = hash_record({"name": "Cleo", "twitter": "CleoPaws"}) + # Or with the keys= option: + hashed = hash_record( + {"name": "Cleo", "twitter": "CleoPaws", "age": 7}, + keys=("name", "twitter") + ) + """ + to_hash = record + if keys is not None: + to_hash = {key: record[key] for key in keys} + return hashlib.sha1( + json.dumps(to_hash, separators=(",", ":"), sort_keys=True, default=repr).encode( + "utf8" + ) + ).hexdigest() diff --git a/tests/test_create.py b/tests/test_create.py index 35844a1..c3871e1 100644 --- a/tests/test_create.py +++ b/tests/test_create.py @@ -9,7 +9,7 @@ from sqlite_utils.db import ( Table, View, ) -from sqlite_utils.utils import sqlite3 +from sqlite_utils.utils import hash_record, sqlite3 import collections import datetime import decimal @@ -888,6 +888,32 @@ def test_insert_hash_id(fresh_db): assert 1 == dogs.count +@pytest.mark.parametrize("use_table_factory", [True, False]) +def test_insert_hash_id_columns(fresh_db, use_table_factory): + if use_table_factory: + dogs = fresh_db.table("dogs", hash_id_columns=("name", "twitter")) + insert_kwargs = {} + else: + dogs = fresh_db["dogs"] + insert_kwargs = dict(hash_id_columns=("name", "twitter")) + + id = dogs.insert( + {"name": "Cleo", "twitter": "cleopaws", "age": 5}, + **insert_kwargs, + ).last_pk + expected_hash = hash_record({"name": "Cleo", "twitter": "cleopaws"}) + assert id == expected_hash + assert dogs.count == 1 + # Insert replacing a second time should not create a new row + id2 = dogs.insert( + {"name": "Cleo", "twitter": "cleopaws", "age": 6}, + **insert_kwargs, + replace=True, + ).last_pk + assert id2 == expected_hash + assert dogs.count == 1 + + def test_vacuum(fresh_db): fresh_db["data"].insert({"foo": "foo", "bar": "bar"}) fresh_db.vacuum() diff --git a/tests/test_upsert.py b/tests/test_upsert.py index 09bdacc..cef8af0 100644 --- a/tests/test_upsert.py +++ b/tests/test_upsert.py @@ -45,6 +45,30 @@ def test_upsert_with_hash_id(fresh_db): assert "a5e744d0164540d33b1d7ea616c28f2fa97e754a" == table.last_pk +@pytest.mark.parametrize("hash_id", (None, "custom_id")) +def test_upsert_with_hash_id_columns(fresh_db, hash_id): + table = fresh_db["table"] + table.upsert({"a": 1, "b": 2, "c": 3}, hash_id=hash_id, hash_id_columns=("a", "b")) + assert list(table.rows) == [ + { + hash_id or "id": "4acc71e0547112eb432f0a36fb1924c4a738cb49", + "a": 1, + "b": 2, + "c": 3, + } + ] + assert table.last_pk == "4acc71e0547112eb432f0a36fb1924c4a738cb49" + table.upsert({"a": 1, "b": 2, "c": 4}, hash_id=hash_id, hash_id_columns=("a", "b")) + assert list(table.rows) == [ + { + hash_id or "id": "4acc71e0547112eb432f0a36fb1924c4a738cb49", + "a": 1, + "b": 2, + "c": 4, + } + ] + + def test_upsert_compound_primary_key(fresh_db): table = fresh_db["table"] table.upsert_all( diff --git a/tests/test_utils.py b/tests/test_utils.py index 66ce413..e76e97a 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -35,3 +35,17 @@ def test_chunks(size, expected): input = ["a", "b", "c", "d"] chunks = list(map(list, utils.chunks(input, size))) assert chunks == expected + + +def test_hash_record(): + expected = "d383e7c0ba88f5ffcdd09be660de164b3847401a" + assert utils.hash_record({"name": "Cleo", "twitter": "CleoPaws"}) == expected + assert ( + utils.hash_record( + {"name": "Cleo", "twitter": "CleoPaws", "age": 7}, keys=("name", "twitter") + ) + == expected + ) + assert ( + utils.hash_record({"name": "Cleo", "twitter": "CleoPaws", "age": 7}) != expected + ) From 521921b849003ed3742338f76f9d47ff3d95eaf3 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 1 Mar 2022 16:05:11 -0800 Subject: [PATCH 128/563] Fixed mypy error, refs #343 --- sqlite_utils/db.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index 70b452c..9e00366 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -2597,7 +2597,7 @@ class Table(Queryable): not_null: Optional[Union[Set[str], Default]] = DEFAULT, defaults: Optional[Union[Dict[str, Any], Default]] = DEFAULT, hash_id: Optional[Union[str, Default]] = DEFAULT, - hash_id_columns: Optional[Iterable[str]] = DEFAULT, + hash_id_columns: Optional[Union[Iterable[str], Default]] = DEFAULT, alter: Optional[Union[bool, Default]] = DEFAULT, ignore: Optional[Union[bool, Default]] = DEFAULT, replace: Optional[Union[bool, Default]] = DEFAULT, From d25cdd37a3b7d1b277b399106473fa368b72635a Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 1 Mar 2022 16:24:27 -0800 Subject: [PATCH 129/563] db.sqlite_version property and fix for deterministic=True on SQLite 3.8.3 Closes #408 --- docs/python-api.rst | 10 ++++++++++ sqlite_utils/db.py | 12 +++++++++++- tests/test_constructor.py | 9 +++++++++ tests/test_register_function.py | 19 +++++++++++++++++-- 4 files changed, 47 insertions(+), 3 deletions(-) diff --git a/docs/python-api.rst b/docs/python-api.rst index e8c4bda..6c35ef3 100644 --- a/docs/python-api.rst +++ b/docs/python-api.rst @@ -1717,6 +1717,16 @@ A more useful example: if you are working with `SpatiaLite >> db.sqlite_version + (3, 36, 0) + .. _python_api_introspection: Introspecting tables and views diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index 9e00366..3bc528f 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -387,7 +387,11 @@ class Database: if not replace and (name, arity) in self._registered_functions: return fn kwargs = {} - if deterministic and sys.version_info >= (3, 8): + if ( + deterministic + and sys.version_info >= (3, 8) + and self.sqlite_version >= (3, 8, 3) + ): kwargs["deterministic"] = True self.conn.create_function(name, arity, fn, **kwargs) self._registered_functions.add((name, arity)) @@ -547,6 +551,12 @@ class Database: except Exception: return False + @property + def sqlite_version(self) -> Tuple[int, ...]: + "Version of SQLite, as a tuple of integers e.g. (3, 36, 0)" + row = self.execute("select sqlite_version()").fetchall()[0] + return tuple(map(int, row[0].split("."))) + @property def journal_mode(self) -> str: "Current ``journal_mode`` of this database." diff --git a/tests/test_constructor.py b/tests/test_constructor.py index 36ab1bc..d8c8d72 100644 --- a/tests/test_constructor.py +++ b/tests/test_constructor.py @@ -16,3 +16,12 @@ def test_memory_name(): db2 = Database(memory_name="shared") db1["dogs"].insert({"name": "Cleo"}) assert list(db2["dogs"].rows) == [{"name": "Cleo"}] + + +def test_sqlite_version(): + db = Database(memory=True) + version = db.sqlite_version + assert isinstance(version, tuple) + as_string = ".".join(map(str, version)) + actual = next(db.query("select sqlite_version() as v"))["v"] + assert actual == as_string diff --git a/tests/test_register_function.py b/tests/test_register_function.py index 19af0b6..d86c7b6 100644 --- a/tests/test_register_function.py +++ b/tests/test_register_function.py @@ -34,16 +34,31 @@ def test_register_function_deterministic(fresh_db): @pytest.mark.skipif( sys.version_info < (3, 8), reason="deterministic=True was added in Python 3.8" ) -def test_register_function_deterministic_registered(fresh_db): +@pytest.mark.parametrize( + "fake_sqlite_version,should_use_deterministic", + ( + ("3.36.0", True), + ("3.8.3", True), + ("3.8.2", False), + ), +) +def test_register_function_deterministic_registered( + fresh_db, fake_sqlite_version, should_use_deterministic +): fresh_db.conn = MagicMock() fresh_db.conn.create_function = MagicMock() + fresh_db.conn.execute().fetchall.return_value = [(fake_sqlite_version,)] @fresh_db.register_function(deterministic=True) def to_lower_2(s): return s.lower() + expected_kwargs = {} + if should_use_deterministic: + expected_kwargs = dict(deterministic=True) + fresh_db.conn.create_function.assert_called_with( - "to_lower_2", 1, to_lower_2, deterministic=True + "to_lower_2", 1, to_lower_2, **expected_kwargs ) From b2b04aec0119a07f796652565966e6c910062eeb Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 1 Mar 2022 22:34:34 -0800 Subject: [PATCH 130/563] Release 3.25 Refs #343, #408 --- docs/changelog.rst | 10 ++++++++++ setup.py | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index a2a024b..5d2667e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -2,6 +2,16 @@ Changelog =========== +.. _v3_25: + +3.25 (2022-03-01) +----------------- + +- New ``hash_id_columns=`` parameter for creating a primary key that's a hash of the content of specific columns - see :ref:`python_api_hash` for details. (:issue:`343`) +- New :ref:`db.sqlite_version ` property, returning a tuple of integers representing the version of SQLite, for example ``(3, 38, 0)``. +- Fixed a bug where :ref:`register_function(deterministic=True) ` caused errors on versions of SQLite prior to 3.8.3. (:issue:`408`) +- New documented :ref:`hash_record(record, keys=...) ` function. + .. _v3_24: 3.24 (2022-02-15) diff --git a/setup.py b/setup.py index 9db7db1..62f8f2a 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages import io import os -VERSION = "3.24" +VERSION = "3.25" def get_long_description(): From 7f56f90d3030a4cf1d57a73e21e06843d4855e63 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 1 Mar 2022 23:01:07 -0800 Subject: [PATCH 131/563] Fixed rST mistake --- docs/python-api.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/python-api.rst b/docs/python-api.rst index 6c35ef3..bd0265b 100644 --- a/docs/python-api.rst +++ b/docs/python-api.rst @@ -1601,7 +1601,7 @@ The hash will be created using all of the column values. To create a hash using hash_id_columns=("name", "twitter") ) -The `hash_id=` parameter is optional if you specify ``hash_id_columns=`` - it will default to putting the hash in a column called ``id``. +The ``hash_id=`` parameter is optional if you specify ``hash_id_columns=`` - it will default to putting the hash in a column called ``id``. You can manually calculate these hashes using the :ref:`hash_record(record, keys=...) ` utility function. From 26e6d2622c57460a24ffdd0128bbaac051d51a5f Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 11 Mar 2022 09:38:34 -0800 Subject: [PATCH 132/563] Use :param x: for docstring comments, refs #413 --- docs/conf.py | 1 + sqlite_utils/db.py | 452 ++++++++++++++++++++++++++++++------------ sqlite_utils/utils.py | 3 + 3 files changed, 327 insertions(+), 129 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 1f5a158..03c13d6 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -32,6 +32,7 @@ from subprocess import Popen, PIPE # ones. extensions = ["sphinx.ext.extlinks", "sphinx.ext.autodoc"] autodoc_member_order = "bysource" +autodoc_typehints = "description" extlinks = { "issue": ("https://github.com/simonw/sqlite-utils/issues/%s", "#"), diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index 3bc528f..5f83ca1 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -275,17 +275,17 @@ class Database: # Create an in-memory database: dB = Database(memory=True) - - ``filename_or_conn`` - String path to a file, or a ``pathlib.Path`` object, or a + :param filename_or_conn: String path to a file, or a ``pathlib.Path`` object, or a ``sqlite3`` connection - - ``memory`` - set to ``True`` to create an in-memory database - - ``memory_name`` - creates a named in-memory database that can be shared across multiple connections. - - ``recreate`` - set to ``True`` to delete and recreate a file database (**dangerous**) - - ``recursive_triggers`` - defaults to ``True``, which sets ``PRAGMA recursive_triggers=on;`` - + :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**) + :param recursive_triggers: defaults to ``True``, which sets ``PRAGMA recursive_triggers=on;`` - set to ``False`` to avoid setting this pragma - - ``tracer`` - set a tracer function (``print`` works for this) which will be called with + :param tracer: set a tracer function (``print`` works for this) which will be called with ``sql, parameters`` every time a SQL query is executed - - ``use_counts_table`` - set to ``True`` to use a cached counts table, if available. See - :ref:`python_api_cached_table_counts`. + :param use_counts_table: set to ``True`` to use a cached counts table, if available. See + :ref:`python_api_cached_table_counts` """ _counts_table_name = "_counts" @@ -340,6 +340,8 @@ class Database: db["creatures"].insert({"name": "Cleo"}) See :ref:`python_api_tracing`. + + :param tracer: Callable accepting ``sql`` and ``parameters`` arguments """ prev_tracer = self._tracer self._tracer = tracer or print @@ -352,6 +354,8 @@ class Database: """ ``db[table_name]`` returns a :class:`.Table` object for the table with the specified name. If the table does not exist yet it will be created the first time data is inserted into it. + + :param table_name: The name of the table """ return self.table(table_name) @@ -375,10 +379,11 @@ class Database: def upper(value): return str(value).upper() - - ``deterministic`` - set ``True`` for functions that always returns the same output for a given input - - ``replace`` - set ``True`` to replace an existing function with the same name - otherwise throw an error - See :ref:`python_api_register_function`. + + :param fn: Function to register + :param deterministic: set ``True`` for functions that always returns the same output for a given input + :param replace: set ``True`` to replace an existing function with the same name - otherwise throw an error """ def register(fn): @@ -411,6 +416,9 @@ class Database: Attach another SQLite database file to this connection with the specified alias, equivalent to:: ATTACH DATABASE 'filepath.db' AS alias + + :param alias: Alias name to use + :param filepath: Path to SQLite database file on disk """ attach_sql = """ ATTACH DATABASE '{}' AS [{}]; @@ -422,7 +430,13 @@ class Database: def query( self, sql: str, params: Optional[Union[Iterable, dict]] = None ) -> Generator[dict, None, None]: - "Execute ``sql`` and return an iterable of dictionaries representing each row." + """ + Execute ``sql`` and return an iterable of dictionaries representing each row. + + :param sql: SQL query to execute + :param params: Parameters to use in that query - an iterable for ``where id = ?`` + parameters, or a dictionary for ``where id = :id`` + """ cursor = self.execute(sql, params or tuple()) keys = [d[0] for d in cursor.description] for row in cursor: @@ -431,7 +445,13 @@ class Database: def execute( self, sql: str, parameters: Optional[Union[Iterable, dict]] = None ) -> sqlite3.Cursor: - "Execute SQL query and return a ``sqlite3.Cursor``." + """ + Execute SQL query and return a ``sqlite3.Cursor``. + + :param sql: SQL query to execute + :param parameters: Parameters to use in that query - an iterable for ``where id = ?`` + parameters, or a dictionary for ``where id = :id`` + """ if self._tracer: self._tracer(sql, parameters) if parameters is not None: @@ -440,18 +460,30 @@ class Database: return self.conn.execute(sql) def executescript(self, sql: str) -> sqlite3.Cursor: - "Execute multiple SQL statements separated by ; and return the ``sqlite3.Cursor``." + """ + Execute multiple SQL statements separated by ; and return the ``sqlite3.Cursor``. + + :param sql: SQL to execute + """ if self._tracer: self._tracer(sql, None) return self.conn.executescript(sql) def table(self, table_name: str, **kwargs) -> Union["Table", "View"]: - "Return a table object, optionally configured with default options." + """ + Return a table object, optionally configured with default options. + + :param table_name: Name of the table + """ klass = View if table_name in self.view_names() else Table return klass(self, table_name, **kwargs) def quote(self, value: str) -> str: - "Apply SQLite string quoting to a value, including wrappping it in single quotes." + """ + Apply SQLite string quoting to a value, including wrappping it in single quotes. + + :param value: String to quote + """ # Normally we would use .execute(sql, [params]) for escaping, but # occasionally that isn't available - most notable when we need # to include a "... DEFAULT 'value'" in a column definition. @@ -462,16 +494,19 @@ class Database: ).fetchone()[0] def quote_fts(self, query: str) -> str: - "Escape special characters in a SQLite full-text search query" - # NOTE: This is not a query validator for FTS. Sqlite has - # a well defined query syntax here: - # https://www2.sqlite.org/fts5.html#full_text_query_syntax - # but this function just aggressively quotes strings - # to ensure that they are valid. In particular, passing - # queries which make use of the query syntax will be incorrect, - # e.g 'NEAR(one, two, 3)'. + """ + Escape special characters in a SQLite full-text search query. - # If query has unbalanced ", add one at end + This works by surrounding each token within the query with double + quotes, in order to avoid words like ``NOT`` and ``OR`` having + special meaning as defined by the FTS query syntax here: + + https://www.sqlite.org/fts5.html#full_text_query_syntax + + If the query has unbalanced ``"`` characters, adds one at end. + + :param query: String to escape + """ if query.count('"') % 2: query += '"' bits = _quote_fts_re.split(query) @@ -481,7 +516,12 @@ class Database: ) def table_names(self, fts4: bool = False, fts5: bool = False) -> List[str]: - "A list of string table names in this database." + """ + List of string table names in this database. + + :param fts4: Only return tables that are part of FTS4 indexes + :param fts5: Only return tables that are part of FTS5 indexes + """ where = ["type = 'table'"] if fts4: where.append("sql like '%USING FTS4%'") @@ -491,7 +531,7 @@ class Database: return [r[0] for r in self.execute(sql).fetchall()] def view_names(self) -> List[str]: - "A list of string view names in this database." + "List of string view names in this database." return [ r[0] for r in self.execute( @@ -501,17 +541,17 @@ class Database: @property def tables(self) -> List["Table"]: - "A list of Table objects in this database." + "List of Table objects in this database." return cast(List["Table"], [self[name] for name in self.table_names()]) @property def views(self) -> List["View"]: - "A list of View objects in this database." + "List of View objects in this database." return cast(List["View"], [self[name] for name in self.view_names()]) @property def triggers(self) -> List[Trigger]: - "A list of ``(name, table_name, sql)`` tuples representing triggers in this database." + "List of ``(name, table_name, sql)`` tuples representing triggers in this database." return [ Trigger(*r) for r in self.execute( @@ -526,7 +566,7 @@ class Database: @property def schema(self) -> str: - "SQL schema for this database" + "SQL schema for this database." sqls = [] for row in self.execute( "select sql from sqlite_master where sql is not null" @@ -553,22 +593,28 @@ class Database: @property def sqlite_version(self) -> Tuple[int, ...]: - "Version of SQLite, as a tuple of integers e.g. (3, 36, 0)" + "Version of SQLite, as a tuple of integers for example ``(3, 36, 0)``." row = self.execute("select sqlite_version()").fetchall()[0] return tuple(map(int, row[0].split("."))) @property def journal_mode(self) -> str: - "Current ``journal_mode`` of this database." + """ + Current ``journal_mode`` of this database. + + https://www.sqlite.org/pragma.html#pragma_journal_mode + """ return self.execute("PRAGMA journal_mode;").fetchone()[0] def enable_wal(self): - "Set ``journal_mode`` to ``'wal'`` to enable Write-Ahead Log mode." + """ + Sets ``journal_mode`` to ``'wal'`` to enable Write-Ahead Log mode. + """ if self.journal_mode != "wal": self.execute("PRAGMA journal_mode=wal;") def disable_wal(self): - "Set ``journal_mode`` back to ``'delete'`` to disable Write-Ahead Log mode." + "Sets ``journal_mode`` back to ``'delete'`` to disable Write-Ahead Log mode." if self.journal_mode != "delete": self.execute("PRAGMA journal_mode=delete;") @@ -594,6 +640,8 @@ class Database: """ Return ``{table_name: count}`` dictionary of cached counts for specified tables, or all tables if ``tables`` not provided. + + :param tables: Subset list of tables to return counts for. """ sql = "select [table], count from {}".format(self._counts_table_name) if tables: @@ -680,7 +728,21 @@ class Database: extracts: Optional[Union[Dict[str, str], List[str]]] = None, if_not_exists: bool = False, ) -> str: - "Returns the SQL ``CREATE TABLE`` statement for creating the specified table." + """ + Returns the SQL ``CREATE TABLE`` statement for creating the specified table. + + :param name: Name of table + :param columns: Dictionary mapping column names to their types, for example ``{"name": str, "age": int}`` + :param pk: String name of column to use as a primary key, or a tuple of strings for a compound primary key covering multiple columns + :param foreign_keys: List of foreign key definitions for this table + :param column_order: List specifying which columns should come first + :param not_null: List of columns that should be created as ``NOT NULL`` + :param defaults: Dictionary specifying default values for columns + :param hash_id: Name of column to be used as a primary key containing a hash of the other columns + :param hash_id_columns: List of columns to be used when calculating the hash ID for a row + :param extracts: List or dictionary of columns to be extracted during inserts, see :ref:`python_api_extracts` + :param if_not_exists: Use ``CREATE TABLE IF NOT EXISTS`` + """ if hash_id_columns and (hash_id is None): hash_id = "id" foreign_keys = self.resolve_foreign_keys(name, foreign_keys or []) @@ -800,6 +862,18 @@ class Database: Create a table with the specified name and the specified ``{column_name: type}`` columns. See :ref:`python_api_explicit_create`. + + :param name: Name of table + :param columns: Dictionary mapping column names to their types, for example ``{"name": str, "age": int}`` + :param pk: String name of column to use as a primary key, or a tuple of strings for a compound primary key covering multiple columns + :param foreign_keys: List of foreign key definitions for this table + :param column_order: List specifying which columns should come first + :param not_null: List of columns that should be created as ``NOT NULL`` + :param defaults: Dictionary specifying default values for columns + :param hash_id: Name of column to be used as a primary key containing a hash of the other columns + :param hash_id_columns: List of columns to be used when calculating the hash ID for a row + :param extracts: List or dictionary of columns to be extracted during inserts, see :ref:`python_api_extracts` + :param if_not_exists: Use ``CREATE TABLE IF NOT EXISTS`` """ sql = self.create_table_sql( name=name, @@ -833,8 +907,10 @@ class Database: """ Create a new SQL view with the specified name - ``sql`` should start with ``SELECT ...``. - - ``ignore`` - set to ``True`` to do nothing if a view with this name already exists - - ``replace`` - set to ``True`` to replace the view if one with this name already exists + :param name: Name of the view + :param sql: SQL ``SELECT`` query to use for this view. + :param ignore: Set to ``True`` to do nothing if a view with this name already exists + :param replace: Set to ``True`` to replace the view if one with this name already exists """ assert not ( ignore and replace @@ -858,6 +934,9 @@ class Database: Given two table names returns the name of tables that could define a many-to-many relationship between those two tables, based on having foreign keys to both of the provided tables. + + :param table: Table name + :param other_table: Other table name """ candidates = [] tables = {table, other_table} @@ -872,8 +951,8 @@ class Database: """ See :ref:`python_api_add_foreign_keys`. - ``foreign_keys`` should be a list of ``(table, column, other_table, other_column)`` - tuples, see :ref:`python_api_add_foreign_keys`. + :param foreign_keys: A list of ``(table, column, other_table, other_column)`` + tuples """ # foreign_keys is a list of explicit 4-tuples assert all( @@ -957,7 +1036,11 @@ class Database: self.execute("VACUUM;") def analyze(self, name=None): - "Run ``ANALYZE`` against the entire database or a named table or index." + """ + Run ``ANALYZE`` against the entire database or a named table or index. + + :param name: Run ``ANALYZE`` against this specific named table or index + """ sql = "ANALYZE" if name is not None: sql += " [{}]".format(name) @@ -969,7 +1052,7 @@ class Database: The ``path`` argument should be an absolute path to the compiled extension, which can be found using ``find_spatialite``. - Returns true if SpatiaLite was successfully initialized. + Returns ``True`` if SpatiaLite was successfully initialized. .. code-block:: python @@ -990,6 +1073,7 @@ class Database: db = Database("mydb.db") db.init_spatialite("./local/mod_spatialite.dylib") + :param path: Path to SpatiaLite module on disk """ if path is None: path = find_spatialite() @@ -1018,7 +1102,13 @@ class Queryable: where: str = None, where_args: Optional[Union[Iterable, dict]] = None, ) -> int: - "Executes ``SELECT count(*) FROM table WHERE ...`` and returns a count." + """ + Executes ``SELECT count(*) FROM table WHERE ...`` and returns a count. + + :param where: SQL where fragment to use, for example ``id > ?`` + :param where_args: Parameters to use with that fragment - an iterable for ``id > ?`` + parameters, or a dictionary for ``id > :id`` + """ sql = "select count(*) from [{}]".format(self.name) if where is not None: sql += " where " + where @@ -1050,14 +1140,15 @@ class Queryable: """ Iterate over every row in this table or view that matches the specified where clause. - - ``where`` - a SQL fragment to use as a ``WHERE`` clause, for example ``age > ?`` or ``age > :age``. - - ``where_args`` - a list of arguments (if using ``?``) or a dictionary (if using ``:age``). - - ``order_by`` - optional column or fragment of SQL to order by. - - ``select`` - optional comma-separated list of columns to select. - - ``limit`` - optional integer number of rows to limit to. - - ``offset`` - optional integer for SQL offset. - Returns each row as a dictionary. See :ref:`python_api_rows` for more details. + + :param where: SQL where fragment to use, for example ``id > ?`` + :param where_args: Parameters to use with that fragment - an iterable for ``id > ?`` + parameters, or a dictionary for ``id > :id`` + :param order_by: Column or fragment of SQL to order by + :param select: Comma-separated list of columns to select - defaults to ``*`` + :param limit: Integer number of rows to limit to + :param offset: Integer for SQL offset """ if not self.exists(): return @@ -1083,7 +1174,17 @@ class Queryable: limit: int = None, offset: int = None, ) -> Generator[Tuple[Any, Dict], None, None]: - "Like ``.rows_where()`` but returns ``(pk, row)`` pairs - ``pk`` can be a single value or tuple." + """ + Like ``.rows_where()`` but returns ``(pk, row)`` pairs - ``pk`` can be a single value or tuple. + + :param where: SQL where fragment to use, for example ``id > ?`` + :param where_args: Parameters to use with that fragment - an iterable for ``id > ?`` + parameters, or a dictionary for ``id > :id`` + :param order_by: Column or fragment of SQL to order by + :param select: Comma-separated list of columns to select - defaults to ``*`` + :param limit: Integer number of rows to limit to + :param offset: Integer for SQL offset + """ column_names = [column.name for column in self.columns] pks = [column.name for column in self.columns if column.is_pk] if not pks: @@ -1205,9 +1306,9 @@ class Table(Queryable): """ Return row (as dictionary) for the specified primary key. - Primary key can be a single value, or a tuple for tables with a compound primary key. + Raises ``sqlite_utils.db.NotFoundError`` if a matching row cannot be found. - Raises ``NotFoundError`` if a matching row cannot be found. + :param pk_values: A single value, or a tuple of values for tables that have a compound primary key """ if not isinstance(pk_values, (list, tuple)): pk_values = [pk_values] @@ -1340,6 +1441,17 @@ class Table(Queryable): Create a table with the specified columns. See :ref:`python_api_explicit_create` for full details. + + :param columns: Dictionary mapping column names to their types, for example ``{"name": str, "age": int}`` + :param pk: String name of column to use as a primary key, or a tuple of strings for a compound primary key covering multiple columns + :param foreign_keys: List of foreign key definitions for this table + :param column_order: List specifying which columns should come first + :param not_null: List of columns that should be created as ``NOT NULL`` + :param defaults: Dictionary specifying default values for columns + :param hash_id: Name of column to be used as a primary key containing a hash of the other columns + :param hash_id_columns: List of columns to be used when calculating the hash ID for a row + :param extracts: List or dictionary of columns to be extracted during inserts, see :ref:`python_api_extracts` + :param if_not_exists: Use ``CREATE TABLE IF NOT EXISTS`` """ columns = {name: value for (name, value) in columns.items()} with self.db.conn: @@ -1361,20 +1473,30 @@ class Table(Queryable): def transform( self, *, - types=None, - rename=None, - drop=None, - pk=DEFAULT, - not_null=None, - defaults=None, - drop_foreign_keys=None, - column_order=None, + types: Optional[dict] = None, + rename: Optional[dict] = None, + drop: Optional[Iterable] = None, + pk: Optional[Any] = None, + not_null: Optional[Set[str]] = None, + defaults: Optional[Dict[str, Any]] = None, + drop_foreign_keys: Optional[Iterable] = None, + column_order: Optional[List[str]] = None, ) -> "Table": """ Apply an advanced alter table, including operations that are not supported by ``ALTER TABLE`` in SQLite itself. See :ref:`python_api_transform` for full details. + + :param types: Columns that should have their type changed, for example ``{"weight": float}`` + :param rename: Columns to rename, for example ``{"headline": "title"}`` + :param drop: Columns to drop + :param pk: New primary key for the table + :param not_null: Columns to set as ``NOT NULL`` + :param defaults: Default values for columns + :param drop_foreign_keys: Names of columns that should have their foreign key constraints removed + :param column_order: List of strings specifying a full or partial column order + to use when creating the table. """ assert self.exists(), "Cannot transform a table that doesn't exist yet" sqls = self.transform_sql( @@ -1417,7 +1539,19 @@ class Table(Queryable): column_order=None, tmp_suffix=None, ) -> List[str]: - "Returns a list of SQL statements that would be executed in order to apply this transformation." + """ + Return a list of SQL statements that should be executed in order to apply this transformation. + + :param types: Columns that should have their type changed, for example ``{"weight": float}`` + :param rename: Columns to rename, for example ``{"headline": "title"}`` + :param drop: Columns to drop + :param pk: New primary key for the table + :param not_null: Columns to set as ``NOT NULL`` + :param defaults: Default values for columns + :param drop_foreign_keys: Names of columns that should have their foreign key constraints removed + :param column_order: List of strings specifying a full or partial column order + to use when creating the table. + """ types = types or {} rename = rename or {} drop = drop or set() @@ -1534,6 +1668,11 @@ class Table(Queryable): Extract specified columns into a separate table. See :ref:`python_api_extract` for details. + + :param columns: Single column or list of columns that should be extracted + :param table: Name of table in which the new records should be created + :param fk_column: Name of the foreign key column to populate in the original table + :param rename: Dictionary of columns that should be renamed when populating the new table """ rename = rename or {} if isinstance(columns, str): @@ -1638,14 +1777,14 @@ class Table(Queryable): """ Create an index on this table. - - ``columns`` - a single columns or list of columns to index. These can be strings or, + :param columns: A single columns or list of columns to index. These can be strings or, to create an index using the column in descending order, ``db.DescIndex(column_name)`` objects. - - ``index_name`` - the name to use for the new index. Defaults to the column names joined on ``_``. - - ``unique`` - should the index be marked as unique, forcing unique values? - - ``if_not_exists`` - only create the index if one with that name does not already exist. - - ``find_unique_name`` - if ``index_name`` is not provided and the automatically derived name + :param index_name: The name to use for the new index. Defaults to the column names joined on ``_``. + :param unique: Should the index be marked as unique, forcing unique values? + :param if_not_exists: Only create the index if one with that name does not already exist. + :param find_unique_name: If ``index_name`` is not provided and the automatically derived name already exists, keep incrementing a suffix number to find an available name. - - ``analyze`` - run ``ANALYZE`` against this index after creating it. + :param analyze: Run ``ANALYZE`` against this index after creating it. See :ref:`python_api_create_index`. """ @@ -1706,9 +1845,22 @@ class Table(Queryable): return self def add_column( - self, col_name: str, col_type=None, fk=None, fk_col=None, not_null_default=None + self, + col_name: str, + col_type: Optional[Any] = None, + fk: Optional[str] = None, + fk_col: Optional[str] = None, + not_null_default: Optional[Any] = None, ): - "Add a column to this table. See :ref:`python_api_add_column`." + """ + Add a column to this table. See :ref:`python_api_add_column`. + + :param col_name: Name of the new column + :param col_type: Column type - a Python type such as ``str`` or a SQLite type string such as ``"BLOB"`` + :param fk: Name of a table that this column should be a foreign key reference to + :param fk_col: Column in the foreign key table that this should reference + :param not_null_default: Set this column to ``not null`` and give it this default value + """ fk_col_type = None if fk is not None: # fk must be a valid table @@ -1744,7 +1896,11 @@ class Table(Queryable): return self def drop(self, ignore: bool = False): - "Drop this table. ``ignore=True`` means errors will be ignored." + """ + Drop this table. + + :param ignore: Set to ``True`` to ignore the error if the table does not exist + """ try: self.db.execute("DROP TABLE [{}]".format(self.name)) except sqlite3.OperationalError: @@ -1760,6 +1916,8 @@ class Table(Queryable): a ``tag`` table, if one exists. If no candidates can be found, raises a ``NoObviousTable`` exception. + + :param column: Name of column """ column = column.lower() possibilities = [column] @@ -1800,10 +1958,10 @@ class Table(Queryable): """ Alter the schema to mark the specified column as a foreign key to another table. - - ``column`` - the column to mark as a foreign key. - - ``other_table`` - the table it refers to - if omitted, will be guessed based on the column name. - - ``other_column`` - the column on the other table it - if omitted, will be guessed. - - ``ignore`` - set this to ``True`` to ignore an existing foreign key - otherwise a ``AlterError`` will be raised. + :param column: The column to mark as a foreign key. + :param other_table: The table it refers to - if omitted, will be guessed based on the column name. + :param other_column: The column on the other table it - if omitted, will be guessed. + :param ignore: Set this to ``True`` to ignore an existing foreign key - otherwise a ``AlterError`` will be raised. """ # Ensure column exists if column not in self.columns_dict: @@ -1911,13 +2069,13 @@ class Table(Queryable): """ Enable SQLite full-text search against the specified columns. - - ``columns`` - list of column names to include in the search index. - - ``fts_version`` - FTS version to use - defaults to ``FTS5`` but you may want ``FTS4`` for older SQLite versions. - - ``create_triggers`` - should triggers be created to keep the search index up-to-date? Defaults to ``False``. - - ``tokenize`` - custom SQLite tokenizer to use, for example ``"porter"`` to enable Porter stemming. - - ``replace`` - should any existing FTS index for this table be replaced by the new one? - See :ref:`python_api_fts` for more details. + + :param columns: List of column names to include in the search index. + :param fts_version: FTS version to use - defaults to ``FTS5`` but you may want ``FTS4`` for older SQLite versions. + :param create_triggers: Should triggers be created to keep the search index up-to-date? Defaults to ``False``. + :param tokenize: Custom SQLite tokenizer to use, for example ``"porter"`` to enable Porter stemming. + :param replace: Should any existing FTS index for this table be replaced by the new one? """ create_fts_sql = ( textwrap.dedent( @@ -1990,6 +2148,8 @@ class Table(Queryable): """ Update the associated SQLite full-text search index with the latest data from the table for the specified columns. + + :param columns: Columns to populate the data for """ sql = ( textwrap.dedent( @@ -2089,7 +2249,14 @@ class Table(Queryable): limit: Optional[int] = None, offset: Optional[int] = None, ) -> str: - "Return SQL string that can be used to execute searches against this table." + """ " + Return SQL string that can be used to execute searches against this table. + + :param columns: Columns to search against + :param order_by: Column or SQL expression to sort by + :param limit: SQL limit + :param offset: SQL offset + """ # Pick names for table and rank column that don't clash original = "original_" if self.name == "original" else "original" columns_sql = "*" @@ -2159,12 +2326,12 @@ class Table(Queryable): Execute a search against this table using SQLite full-text search, returning a sequence of dictionaries for each row. - - ``q`` - terms to search for - - ``order_by`` - defaults to order by rank, or specify a column here. - - ``columns`` - list of columns to return, defaults to all columns. - - ``limit`` - optional integer limit for returned rows. - - ``offset`` - optional integer SQL offset. - - ``quote`` - apply quoting to disable any special characters in the search query + :param q: Terms to search for + :param order_by: Defaults to order by rank, or specify a column here. + :param columns: List of columns to return, defaults to all columns. + :param limit: Optional integer limit for returned rows. + :param offset: Optional integer SQL offset. + :param quote: Apply quoting to disable any special characters in the search query See :ref:`python_api_fts_search`. """ @@ -2185,7 +2352,11 @@ class Table(Queryable): return self._defaults[key] if value is DEFAULT else value def delete(self, pk_values: Union[list, tuple, str, int, float]) -> "Table": - "Delete row matching the specified primary key." + """ + Delete row matching the specified primary key. + + :param pk_values: A single value, or a tuple of values for tables that have a compound primary key + """ if not isinstance(pk_values, (list, tuple)): pk_values = [pk_values] self.get(pk_values) @@ -2206,11 +2377,12 @@ class Table(Queryable): """ Delete rows matching the specified where clause, or delete all rows in the table. - - ``where`` - a SQL fragment to use as a ``WHERE`` clause, for example ``age > ?`` or ``age > :age``. - - ``where_args`` - a list of arguments (if using ``?``) or a dictionary (if using ``:age``). - - ``analyze`` - set to ``True`` to run ``ANALYZE`` after the rows have been deleted. - See :ref:`python_api_delete_where`. + + :param where: SQL where fragment to use, for example ``id > ?`` + :param where_args: Parameters to use with that fragment - an iterable for ``id > ?`` + parameters, or a dictionary for ``id > :id`` + :param analyze: Set to ``True`` to run ``ANALYZE`` after the rows have been deleted. """ if not self.exists(): return self @@ -2232,14 +2404,14 @@ class Table(Queryable): """ Execute a SQL ``UPDATE`` against the specified row. - - ``pk_values`` - the primary key of an individual record - can be a tuple if the - table has a compound primary key. - - ``updates`` - a dictionary mapping columns to their updated values. - - ``alter`` - set to ``True`` to add any missing columns. - - ``conversions`` - optional dictionary of SQL functions to apply during the update, for example - ``{"mycolumn": "upper(?)"}``. - See :ref:`python_api_update`. + + :param pk_values: The primary key of an individual record - can be a tuple if the + table has a compound primary key. + :param updates: A dictionary mapping columns to their updated values. + :param alter: Set to ``True`` to add any missing columns. + :param conversions: Optional dictionary of SQL functions to apply during the update, for example + ``{"mycolumn": "upper(?)"}``. """ updates = updates or {} conversions = conversions or {} @@ -2293,18 +2465,18 @@ class Table(Queryable): """ Apply conversion function ``fn`` to every value in the specified columns. - - ``columns`` - a single column or list of string column names to convert. - - ``fn`` - a callable that takes a single argument, ``value``, and returns it converted. - - ``output`` - optional string column name to write the results to (defaults to the input column). - - ``output_type`` - if the output column needs to be created, this is the type that will be used + :param columns: A single column or list of string column names to convert. + :param fn: A callable that takes a single argument, ``value``, and returns it converted. + :param output: Optional string column name to write the results to (defaults to the input column). + :param output_type: If the output column needs to be created, this is the type that will be used for the new column. - - ``drop`` - boolean, should the original column be dropped once the conversion is complete? - - ``multi`` - boolean, if ``True`` the return value of ``fn(value)`` will be expected to be a + :param drop: Should the original column be dropped once the conversion is complete? + :param multi: If ``True`` the return value of ``fn(value)`` will be expected to be a dictionary, and new columns will be created for each key of that dictionary. - - ``where`` - a SQL fragment to use as a ``WHERE`` clause to limit the rows to which the conversion + :param where: SQL fragment to use as a ``WHERE`` clause to limit the rows to which the conversion is applied, for example ``age > ?`` or ``age > :age``. - - ``where_args`` - a list of arguments (if using ``?``) or a dictionary (if using ``:age``). - - ``show_progress`` - boolean, should a progress bar be displayed? + :param where_args: List of arguments (if using ``?``) or a dictionary (if using ``:age``). + :param show_progress: Should a progress bar be displayed? See :ref:`python_api_convert`. """ @@ -2627,23 +2799,24 @@ class Table(Queryable): Each of them defaults to ``DEFAULT``, which indicates that the default setting for the current ``Table`` object (specified in the table constructor) should be used. - - ``pk`` - if creating the table, which column should be the primary key. - - ``foreign_keys`` - see :ref:`python_api_foreign_keys`. - - ``column_order`` - optional list of strings specifying a full or partial column order + :param record: Dictionary record to be inserted + :param pk: If creating the table, which column should be the primary key. + :param foreign_keys: See :ref:`python_api_foreign_keys`. + :param column_order: List of strings specifying a full or partial column order to use when creating the table. - - ``not_null`` - optional set of strings specifying columns that should be ``NOT NULL``. - - ``defaults`` - optional dictionary specifying default values for specific columns. - - ``hash_id`` - optional name of a column to create and use as a primary key, where the + :param not_null: Set of strings specifying columns that should be ``NOT NULL``. + :param defaults: Dictionary specifying default values for specific columns. + :param hash_id: Name of a column to create and use as a primary key, where the value of thet primary key will be derived as a SHA1 hash of the other column values in the record. ``hash_id="id"`` is a common column name used for this. - - ``alter`` - boolean, should any missing columns be added automatically? - - ``ignore`` - boolean, if a record already exists with this primary key, ignore this insert. - - ``replace`` - boolean, if a record already exists with this primary key, replace it with this new record. - - ``extracts`` - a list of columns to extract to other tables, or a dictionary that maps + :param alter: Boolean, should any missing columns be added automatically? + :param ignore: Boolean, if a record already exists with this primary key, ignore this insert. + :param replace: Boolean, if a record already exists with this primary key, replace it with this new record. + :param extracts: A list of columns to extract to other tables, or a dictionary that maps ``{column_name: other_table_name}``. See :ref:`python_api_extracts`. - - ``conversions`` - dictionary specifying SQL conversion functions to be applied to the data while it + :param conversions: Dictionary specifying SQL conversion functions to be applied to the data while it is being inserted, for example ``{"name": "upper(?)"}``. See :ref:`python_api_conversions`. - - ``columns`` - dictionary over-riding the detected types used for the columns, for example + :param columns: Dictionary over-riding the detected types used for the columns, for example ``{"age": int, "weight": float}``. """ return self.insert_all( @@ -2904,9 +3077,12 @@ class Table(Queryable): be included only if the record is being created for the first time. These will be ignored on subsequent lookup calls for records that already exist. + All other keyword arguments are passed through to ``.insert()``. + See :ref:`python_api_lookup_tables` for more details. - All other keyword arguments are passed through to ``.insert()``. + :param lookup_values: Dictionary specifying column names and values to use for the lookup + :param extra_values: Additional column values to be used only if creating a new record """ assert isinstance(lookup_values, dict) if extra_values is not None: @@ -2978,13 +3154,13 @@ class Table(Queryable): See :ref:`python_api_m2m` for details. - - ``other_table`` - the name of the table to insert the new records into. - - ``record_or_iterable`` - a single dictionary record to insert, or a list of records. - - ``pk`` - the primary key to use if creating ``other_table``. - - ``lookup`` - same dictionary as for ``.lookup()``, to create a many-to-many lookup table. - - ``m2m_table`` - the string name to use for the many-to-many table, defaults to creating + :param other_table: The name of the table to insert the new records into. + :param record_or_iterable: A single dictionary record to insert, or a list of records. + :param pk: The primary key to use if creating ``other_table``. + :param lookup: Same dictionary as for ``.lookup()``, to create a many-to-many lookup table. + :param m2m_table: The string name to use for the many-to-many table, defaults to creating this automatically based on the names of the two tables. - - ``alter`` - set to ``True`` to add any missing columns on ``other_table`` if that table + :param alter: Set to ``True`` to add any missing columns on ``other_table`` if that table already exists. """ if isinstance(other_table, str): @@ -3053,6 +3229,11 @@ class Table(Queryable): Return statistics about the specified column. See :ref:`python_api_analyze_column`. + + :param column: Column to analyze + :param common_limit: Show this many column values + :param value_truncate: Truncate display of common values to this many characters + :param total_rows: Optimization - pass the total number of rows in the table to save running a fresh ``count(*)`` query """ db = self.db table = self.name @@ -3133,7 +3314,7 @@ class Table(Queryable): `SRID 4326 `__. This can be customized using the ``column_name``, ``srid`` and ``not_null`` arguments. - Returns True if the column was successfully added, False if not. + Returns ``True`` if the column was successfully added, ``False`` if not. .. code-block:: python @@ -3147,6 +3328,11 @@ class Table(Queryable): table = db["locations"].create({"name": str}) table.add_geometry_column("geometry", "POINT") + :param column_name: Name of column to add + :param geometry_type: Type of geometry column, for example ``"GEOMETRY"`` or ``"POINT" or ``"POLYGON"`` + :param srid: Integer SRID, defaults to 4326 for WGS84 + :param coord_dimension: Dimensions to use, defaults to ``"XY"`` - set to ``"XYZ"`` to work in three dimensions + :param not_null: Should the column be ``NOT NULL`` """ cursor = self.db.execute( "SELECT AddGeometryColumn(?, ?, ?, ?, ?, ?);", @@ -3185,6 +3371,7 @@ class Table(Queryable): # outputs: # CREATE VIRTUAL TABLE "idx_locations_geometry" USING rtree(pkid, xmin, xmax, ymin, ymax) + :param column_name: Geometry column to create the spatial index against """ if f"idx_{self.name}_{column_name}" in self.db.table_names(): return False @@ -3206,6 +3393,12 @@ class View(Queryable): ) def drop(self, ignore=False): + """ + Drop this view. + + :param ignore: Set to ``True`` to ignore the error if the view does not exist + """ + try: self.db.execute("DROP VIEW [{}]".format(self.name)) except sqlite3.OperationalError: @@ -3213,6 +3406,7 @@ class View(Queryable): raise def enable_fts(self, *args, **kwargs): + "``enable_fts()`` is supported on tables but not on views." raise NotImplementedError( "enable_fts() is supported on tables but not on views" ) diff --git a/sqlite_utils/utils.py b/sqlite_utils/utils.py index 4ec8b9f..d35cc05 100644 --- a/sqlite_utils/utils.py +++ b/sqlite_utils/utils.py @@ -365,6 +365,9 @@ def hash_record(record: Dict, keys: Optional[Iterable[str]] = None): {"name": "Cleo", "twitter": "CleoPaws", "age": 7}, keys=("name", "twitter") ) + + :param record: Record to generate a hash for + :param keys: Subset of keys to use for that hash """ to_hash = record if keys is not None: From 6fd7c138e2dd76cc91f99f6fe2f80636642652de Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 11 Mar 2022 09:54:17 -0800 Subject: [PATCH 133/563] Fixed .transform() method which I broke in #413 --- sqlite_utils/db.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index 5f83ca1..4739fc9 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -1476,7 +1476,7 @@ class Table(Queryable): types: Optional[dict] = None, rename: Optional[dict] = None, drop: Optional[Iterable] = None, - pk: Optional[Any] = None, + pk: Optional[Any] = DEFAULT, not_null: Optional[Set[str]] = None, defaults: Optional[Dict[str, Any]] = None, drop_foreign_keys: Optional[Iterable] = None, From 40b76f6f56e4a00da023396999a25989c83d91a6 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 11 Mar 2022 10:08:20 -0800 Subject: [PATCH 134/563] Release 3.25.1 Refs #413 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 62f8f2a..d562d40 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages import io import os -VERSION = "3.25" +VERSION = "3.25.1" def get_long_description(): From 9388edf57aa15719095e3cf0952c1653cd070c9b Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 11 Mar 2022 10:34:37 -0800 Subject: [PATCH 135/563] Changelog item for 3.25.1 Refs #413, #414 --- docs/changelog.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 5d2667e..916b9df 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -2,6 +2,13 @@ Changelog =========== +.. _v3_25_1: + +3.25.1 (2022-03-11) +------------------- + +- Improved display of type information and parameters in the :ref:`API reference documentation `. (:issue:`413`) + .. _v3_25: 3.25 (2022-03-01) From 433813612ff9b4b501739fd7543bef0040dd51fe Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 11 Mar 2022 13:44:07 -0800 Subject: [PATCH 136/563] Move sqls=[] closer to where it is populated --- sqlite_utils/db.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index 4739fc9..3ffed9f 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -1570,7 +1570,6 @@ class Table(Queryable): new_column_pairs.append((new_name, type_)) copy_from_to[name] = new_name - sqls = [] if pk is DEFAULT: pks_renamed = tuple( rename.get(p.name) or p.name for p in self.columns if p.is_pk @@ -1624,6 +1623,7 @@ class Table(Queryable): if column_order is not None: column_order = [rename.get(col) or col for col in column_order] + sqls = [] sqls.append( self.db.create_table_sql( new_table_name, From 878d5f5cea3455b4d135a9664ccad6b673354812 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 20 Mar 2022 21:01:35 -0700 Subject: [PATCH 137/563] errors=r.SET_NULL/r.IGNORE options for parsedate/parsedatetime, closes #416 --- docs/cli-reference.rst | 20 +++++++++++---- docs/cli.rst | 12 +++++++-- sqlite_utils/cli.py | 10 +++++--- sqlite_utils/recipes.py | 57 +++++++++++++++++++++++++++++++++-------- tests/test_docs.py | 8 +++++- tests/test_recipes.py | 23 +++++++++++++++++ 6 files changed, 109 insertions(+), 21 deletions(-) diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index 19b714b..c3f7a24 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -547,15 +547,25 @@ See :ref:`cli_convert`. r.jsonsplit(value, delimiter=',', type=) - Convert a string like a,b,c into a JSON array ["a", "b", "c"] + Convert a string like a,b,c into a JSON array ["a", "b", "c"] - r.parsedate(value, dayfirst=False, yearfirst=False) + r.parsedate(value, dayfirst=False, yearfirst=False, errors=None) - Parse a date and convert it to ISO date format: yyyy-mm-dd + Parse a date and convert it to ISO date format: yyyy-mm-dd +  + - dayfirst=True: treat xx as the day in xx/yy/zz + - yearfirst=True: treat xx as the year in xx/yy/zz + - errors=r.IGNORE to ignore values that cannot be parsed + - errors=r.SET_NULL to set values that cannot be parsed to null - r.parsedatetime(value, dayfirst=False, yearfirst=False) + r.parsedatetime(value, dayfirst=False, yearfirst=False, errors=None) - Parse a datetime and convert it to ISO datetime format: yyyy-mm-ddTHH:MM:SS + Parse a datetime and convert it to ISO datetime format: yyyy-mm-ddTHH:MM:SS +  + - dayfirst=True: treat xx as the day in xx/yy/zz + - yearfirst=True: treat xx as the year in xx/yy/zz + - errors=r.IGNORE to ignore values that cannot be parsed + - errors=r.SET_NULL to set values that cannot be parsed to null You can use these recipes like so: diff --git a/docs/cli.rst b/docs/cli.rst index 03689c1..8bd3650 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -1303,12 +1303,20 @@ Various built-in recipe functions are available for common operations. These are Would produce an array like this: ``[1.2, 3.0, 4.5]`` -``r.parsedate(value, dayfirst=False, yearfirst=False)`` +``r.parsedate(value, dayfirst=False, yearfirst=False, errors=None)`` Parse a date and convert it to ISO date format: ``yyyy-mm-dd`` In the case of dates such as ``03/04/05`` U.S. ``MM/DD/YY`` format is assumed - you can use ``dayfirst=True`` or ``yearfirst=True`` to change how these ambiguous dates are interpreted. -``r.parsedatetime(value, dayfirst=False, yearfirst=False)`` + Use the ``errors=`` parameter to specify what should happen if a value cannot be parsed. + + By default, if any value cannot be parsed an error will be occurred and all values will be left as they were. + + Set ``errors=r.IGNORE`` to ignore any values that cannot be parsed, leaving them unchanged. + + Set ``errors=r.SET_NULL`` to set any values that cannot be parsed to ``null``. + +``r.parsedatetime(value, dayfirst=False, yearfirst=False, errors=None)`` Parse a datetime and convert it to ISO datetime format: ``yyyy-mm-ddTHH:MM:SS`` These recipes can be used in the code passed to ``sqlite-utils convert`` like this:: diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 8255b56..0cf0468 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -2583,12 +2583,16 @@ def _generate_convert_help(): """ ).strip() recipe_names = [ - n for n in dir(recipes) if not n.startswith("_") and n not in ("json", "parser") + n + for n in dir(recipes) + if not n.startswith("_") + and n not in ("json", "parser") + and callable(getattr(recipes, n)) ] for name in recipe_names: fn = getattr(recipes, name) - help += "\n\nr.{}{}\n\n {}".format( - name, str(inspect.signature(fn)), fn.__doc__ + help += "\n\nr.{}{}\n\n\b{}".format( + name, str(inspect.signature(fn)), fn.__doc__.rstrip() ) help += "\n\n" help += textwrap.dedent( diff --git a/sqlite_utils/recipes.py b/sqlite_utils/recipes.py index 6918661..ac41954 100644 --- a/sqlite_utils/recipes.py +++ b/sqlite_utils/recipes.py @@ -1,19 +1,56 @@ from dateutil import parser import json - -def parsedate(value, dayfirst=False, yearfirst=False): - "Parse a date and convert it to ISO date format: yyyy-mm-dd" - return ( - parser.parse(value, dayfirst=dayfirst, yearfirst=yearfirst).date().isoformat() - ) +IGNORE = object() +SET_NULL = object() -def parsedatetime(value, dayfirst=False, yearfirst=False): - "Parse a datetime and convert it to ISO datetime format: yyyy-mm-ddTHH:MM:SS" - return parser.parse(value, dayfirst=dayfirst, yearfirst=yearfirst).isoformat() +def parsedate(value, dayfirst=False, yearfirst=False, errors=None): + """ + Parse a date and convert it to ISO date format: yyyy-mm-dd + \b + - dayfirst=True: treat xx as the day in xx/yy/zz + - yearfirst=True: treat xx as the year in xx/yy/zz + - errors=r.IGNORE to ignore values that cannot be parsed + - errors=r.SET_NULL to set values that cannot be parsed to null + """ + try: + return ( + parser.parse(value, dayfirst=dayfirst, yearfirst=yearfirst) + .date() + .isoformat() + ) + except parser.ParserError: + if errors is IGNORE: + return value + elif errors is SET_NULL: + return None + else: + raise + + +def parsedatetime(value, dayfirst=False, yearfirst=False, errors=None): + """ + Parse a datetime and convert it to ISO datetime format: yyyy-mm-ddTHH:MM:SS + \b + - dayfirst=True: treat xx as the day in xx/yy/zz + - yearfirst=True: treat xx as the year in xx/yy/zz + - errors=r.IGNORE to ignore values that cannot be parsed + - errors=r.SET_NULL to set values that cannot be parsed to null + """ + try: + return parser.parse(value, dayfirst=dayfirst, yearfirst=yearfirst).isoformat() + except parser.ParserError: + if errors is IGNORE: + return value + elif errors is SET_NULL: + return None + else: + raise def jsonsplit(value, delimiter=",", type=str): - 'Convert a string like a,b,c into a JSON array ["a", "b", "c"]' + """ + Convert a string like a,b,c into a JSON array ["a", "b", "c"] + """ return json.dumps([type(s.strip()) for s in value.split(delimiter)]) diff --git a/tests/test_docs.py b/tests/test_docs.py index d760629..7f2e3ed 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -48,7 +48,13 @@ def test_convert_help(): @pytest.mark.parametrize( "recipe", - [n for n in dir(recipes) if not n.startswith("_") and n not in ("json", "parser")], + [ + n + for n in dir(recipes) + if not n.startswith("_") + and n not in ("json", "parser") + and callable(getattr(recipes, n)) + ], ) def test_recipes_are_documented(documented_recipes, recipe): assert recipe in documented_recipes diff --git a/tests/test_recipes.py b/tests/test_recipes.py index 89240a2..afb3e1a 100644 --- a/tests/test_recipes.py +++ b/tests/test_recipes.py @@ -1,4 +1,5 @@ from sqlite_utils import recipes +from sqlite_utils.utils import sqlite3 import json import pytest @@ -61,6 +62,28 @@ def test_dayfirst_yearfirst(fresh_db, recipe, kwargs, expected): ] +@pytest.mark.parametrize("fn", ("parsedate", "parsedatetime")) +@pytest.mark.parametrize("errors", (None, recipes.SET_NULL, recipes.IGNORE)) +def test_dateparse_errors(fresh_db, fn, errors): + fresh_db["example"].insert_all( + [ + {"id": 1, "dt": "invalid"}, + ], + pk="id", + ) + if errors is None: + # Should raise an error + with pytest.raises(sqlite3.OperationalError): + fresh_db["example"].convert("dt", lambda value: getattr(recipes, fn)(value)) + else: + fresh_db["example"].convert( + "dt", lambda value: getattr(recipes, fn)(value, errors=errors) + ) + rows = list(fresh_db["example"].rows) + expected = [{"id": 1, "dt": None if errors is recipes.SET_NULL else "invalid"}] + assert rows == expected + + @pytest.mark.parametrize("delimiter", [None, ";", "-"]) def test_jsonsplit(fresh_db, delimiter): fresh_db["example"].insert_all( From 751ab205ac1f6bcd1b31449d2aca4734abca16c8 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 20 Mar 2022 21:18:18 -0700 Subject: [PATCH 138/563] Fix for --multi combined with --dry-run, closes #415 --- sqlite_utils/cli.py | 5 ++++- tests/test_cli_convert.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 0cf0468..b2a0440 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -2676,7 +2676,10 @@ def convert( raise click.ClickException(str(e)) if dry_run: # Pull first 20 values for first column and preview them - db.conn.create_function("preview_transform", 1, lambda v: fn(v) if v else v) + preview = lambda v: fn(v) if v else v + if multi: + preview = lambda v: json.dumps(fn(v), default=repr) if v else v + db.conn.create_function("preview_transform", 1, preview) sql = """ select [{column}] as value, diff --git a/tests/test_cli_convert.py b/tests/test_cli_convert.py index ec8110f..10b4563 100644 --- a/tests/test_cli_convert.py +++ b/tests/test_cli_convert.py @@ -179,6 +179,42 @@ def test_convert_dryrun(test_db_and_path): assert result.output.strip().split("\n")[-1] == "Would affect 1 row" +def test_convert_multi_dryrun(test_db_and_path): + db_path = test_db_and_path[1] + result = CliRunner().invoke( + cli.cli, + [ + "convert", + db_path, + "example", + "dt", + "{'foo': 'bar', 'baz': 1}", + "--dry-run", + "--multi", + ], + ) + assert result.exit_code == 0 + assert result.output.strip() == ( + "5th October 2019 12:04\n" + " --- becomes:\n" + '{"foo": "bar", "baz": 1}\n' + "\n" + "6th October 2019 00:05:06\n" + " --- becomes:\n" + '{"foo": "bar", "baz": 1}\n' + "\n" + "\n" + " --- becomes:\n" + "\n" + "\n" + "None\n" + " --- becomes:\n" + "None\n" + "\n" + "Would affect 4 rows" + ) + + @pytest.mark.parametrize("drop", (True, False)) def test_convert_output_column(test_db_and_path, drop): db, db_path = test_db_and_path From 93fa79d30b1531bea281d0eb6b925c4e61bc1aa6 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 20 Mar 2022 21:22:09 -0700 Subject: [PATCH 139/563] Ignore flake8 lambda errors, refs #415 --- sqlite_utils/cli.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index b2a0440..05ff9d3 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -2676,9 +2676,11 @@ def convert( raise click.ClickException(str(e)) if dry_run: # Pull first 20 values for first column and preview them - preview = lambda v: fn(v) if v else v + preview = lambda v: fn(v) if v else v # noqa: E731 if multi: - preview = lambda v: json.dumps(fn(v), default=repr) if v else v + preview = ( + lambda v: json.dumps(fn(v), default=repr) if v else v + ) # noqa: E731 db.conn.create_function("preview_transform", 1, preview) sql = """ select From 396f80fcc60da8dd844577114f7920830a2e5403 Mon Sep 17 00:00:00 2001 From: Chris Amico Date: Thu, 24 Mar 2022 17:01:43 -0400 Subject: [PATCH 140/563] Ignore common generated files (#419) Thanks, @eyeseast --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 75fa28e..a224f01 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,7 @@ venv .coverage .schema .vscode +.hypothesis +Pipfile +Pipfile.lock +pyproject.toml From 0b7b80bd40fe86e4d66a04c9f607d94991c45c0b Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 25 Mar 2022 13:07:29 -0700 Subject: [PATCH 141/563] Document the convert() with initialization pattern, closes #420 --- docs/cli.rst | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/cli.rst b/docs/cli.rst index 8bd3650..5a03402 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -1002,6 +1002,8 @@ The ``--convert`` option also works with the ``--csv``, ``--tsv`` and ``--nl`` i As with ``sqlite-utils convert`` you can use ``--import`` to import additional Python modules, see :ref:`cli_convert_import` for details. +You can also pass code that runs some initialization steps and defines a ``convert(value)`` function, see :ref:`cli_convert_complex`. + .. _cli_insert_convert_lines: \-\-convert with \-\-lines @@ -1285,6 +1287,27 @@ This supports nested imports as well, for example to use `ElementTree Date: Fri, 25 Mar 2022 14:17:10 -0700 Subject: [PATCH 142/563] Clarified support for newline-delimited JSON, closes #417 --- docs/cli.rst | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/cli.rst b/docs/cli.rst index 5a03402..190d285 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -805,12 +805,21 @@ You can insert binary data into a BLOB column by first encoding it using base64 Inserting newline-delimited JSON -------------------------------- -You can also import newline-delimited JSON using the ``--nl`` option. Since `Datasette `__ can export newline-delimited JSON, you can combine the two tools like so:: +You can also import `newline-delimited JSON `__ using the ``--nl`` option:: + + $ echo '{"id": 1, "name": "Cleo"} + {"id": 2, "name": "Suna"}' | sqlite-utils insert creatures.db creatures - --nl + +Newline-delimited JSON consists of full JSON objects separated by newlines. + +If you are processing data using ``jq`` you can use the ``jq -c`` option to output valid newline-delimited JSON. + +Since `Datasette `__ can export newline-delimited JSON, you can combine the Datasette and ``sqlite-utils`` like so:: $ curl -L "https://latest.datasette.io/fixtures/facetable.json?_shape=array&_nl=on" \ | sqlite-utils insert nl-demo.db facetable - --pk=id --nl -This also means you pipe ``sqlite-utils`` together to easily create a new SQLite database file containing the results of a SQL query against another database:: +You can also pipe ``sqlite-utils`` together to create a new SQLite database file containing the results of a SQL query against another database:: $ sqlite-utils sf-trees.db \ "select TreeID, qAddress, Latitude, Longitude from Street_Tree_List" --nl \ From 6f3ae864f1a521caa1b2a48d714d627ab8e9e188 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 13 Apr 2022 15:31:37 -0700 Subject: [PATCH 143/563] Better support check for deterministic=True, closes #425 Bug first discovered in #421 --- sqlite_utils/db.py | 19 ++++++++----- tests/test_register_function.py | 48 ++++++++++++++++++--------------- 2 files changed, 39 insertions(+), 28 deletions(-) diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index 3ffed9f..ac1022b 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -392,13 +392,18 @@ class Database: if not replace and (name, arity) in self._registered_functions: return fn kwargs = {} - if ( - deterministic - and sys.version_info >= (3, 8) - and self.sqlite_version >= (3, 8, 3) - ): - kwargs["deterministic"] = True - self.conn.create_function(name, arity, fn, **kwargs) + registered = False + if deterministic: + # Try this, but fall back if sqlite3.NotSupportedError + try: + self.conn.create_function( + name, arity, fn, **dict(kwargs, deterministic=True) + ) + registered = True + except sqlite3.NotSupportedError: + pass + if not registered: + self.conn.create_function(name, arity, fn, **kwargs) self._registered_functions.add((name, arity)) return fn diff --git a/tests/test_register_function.py b/tests/test_register_function.py index d86c7b6..615f38f 100644 --- a/tests/test_register_function.py +++ b/tests/test_register_function.py @@ -1,7 +1,8 @@ # flake8: noqa import pytest import sys -from unittest.mock import MagicMock +from unittest.mock import MagicMock, call +from sqlite_utils.utils import sqlite3 def test_register_function(fresh_db): @@ -31,36 +32,41 @@ def test_register_function_deterministic(fresh_db): assert result == "bob" -@pytest.mark.skipif( - sys.version_info < (3, 8), reason="deterministic=True was added in Python 3.8" -) -@pytest.mark.parametrize( - "fake_sqlite_version,should_use_deterministic", - ( - ("3.36.0", True), - ("3.8.3", True), - ("3.8.2", False), - ), -) -def test_register_function_deterministic_registered( - fresh_db, fake_sqlite_version, should_use_deterministic -): +def test_register_function_deterministic_tries_again_if_exception_raised(fresh_db): fresh_db.conn = MagicMock() fresh_db.conn.create_function = MagicMock() - fresh_db.conn.execute().fetchall.return_value = [(fake_sqlite_version,)] @fresh_db.register_function(deterministic=True) def to_lower_2(s): return s.lower() - expected_kwargs = {} - if should_use_deterministic: - expected_kwargs = dict(deterministic=True) - fresh_db.conn.create_function.assert_called_with( - "to_lower_2", 1, to_lower_2, **expected_kwargs + "to_lower_2", 1, to_lower_2, deterministic=True ) + first = True + + def side_effect(*args, **kwargs): + # Raise exception only first time this is called + nonlocal first + if first: + first = False + raise sqlite3.NotSupportedError() + + # But if sqlite3.NotSupportedError is raised, it tries again + fresh_db.conn.create_function.reset_mock() + fresh_db.conn.create_function.side_effect = side_effect + + @fresh_db.register_function(deterministic=True) + def to_lower_3(s): + return s.lower() + + # Should have been called once with deterministic=True and once without + assert fresh_db.conn.create_function.call_args_list == [ + call("to_lower_3", 1, to_lower_3, deterministic=True), + call("to_lower_3", 1, to_lower_3), + ] + def test_register_function_replace(fresh_db): @fresh_db.register_function() From 4433eafff7a09bcf6e9752e86bb5ffec23d6db25 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 13 Apr 2022 15:35:57 -0700 Subject: [PATCH 144/563] Fix for register() on Python 3.7, refs #425 --- sqlite_utils/db.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index ac1022b..dcbc2b6 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -400,7 +400,8 @@ class Database: name, arity, fn, **dict(kwargs, deterministic=True) ) registered = True - except sqlite3.NotSupportedError: + except (sqlite3.NotSupportedError, TypeError): + # TypeError is Python 3.7 "function takes at most 3 arguments" pass if not registered: self.conn.create_function(name, arity, fn, **kwargs) From 0e60f3c80cd6df5c177d8405afc54d014addebd0 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 13 Apr 2022 15:39:48 -0700 Subject: [PATCH 145/563] Better error message if table has no columns, closes #424 --- sqlite_utils/db.py | 1 + tests/test_create.py | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index dcbc2b6..82156b9 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -768,6 +768,7 @@ class Database: # Soundness check not_null, and defaults if provided not_null = not_null or set() defaults = defaults or {} + assert columns, "Tables must have at least one column" assert all( n in columns for n in not_null ), "not_null set {} includes items not in columns {}".format( diff --git a/tests/test_create.py b/tests/test_create.py index c3871e1..60181de 100644 --- a/tests/test_create.py +++ b/tests/test_create.py @@ -1149,3 +1149,9 @@ def test_create_if_not_exists(fresh_db): fresh_db["t"].create({"id": int}) # This should not fresh_db["t"].create({"id": int}, if_not_exists=True) + + +def test_create_if_no_columns(fresh_db): + with pytest.raises(AssertionError) as error: + fresh_db["t"].create({}) + assert error.value.args[0] == "Tables must have at least one column" From cbaad1f153b7a2be50223576afd61fb4e68de2f7 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 13 Apr 2022 15:40:57 -0700 Subject: [PATCH 146/563] Removed unused sys import, refs #425 --- sqlite_utils/db.py | 1 - 1 file changed, 1 deletion(-) diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index 82156b9..cbf59d6 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -22,7 +22,6 @@ import pathlib import re import secrets from sqlite_fts4 import rank_bm25 # type: ignore -import sys import textwrap from typing import ( cast, From 6915fbcce24d03d7e7fbcb901c18be84b5568a9d Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 13 Apr 2022 15:51:48 -0700 Subject: [PATCH 147/563] Release 3.26 Refs #415, #416, #420, #421, #425 --- docs/changelog.rst | 10 ++++++++++ setup.py | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 916b9df..ecccfb4 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -2,6 +2,16 @@ Changelog =========== +.. _v3_26: + +3.26 (2022-04-13) +----------------- + +- New ``errors=r.IGNORE/r.SET_NULL`` parameter for the ``r.parsedatetime()`` and ``r.parsedate()`` :ref:`convert recipes `. (:issue:`416`) +- Fixed a bug where ``--multi`` could not be used in combination with ``--dry-run`` for the :ref:`convert ` command. (:issue:`415`) +- New documentation: :ref:`cli_convert_complex`. (:issue:`420`) +- More robust detection for whether or not ``deterministic=True`` is supported. (:issue:`425`) + .. _v3_25_1: 3.25.1 (2022-03-11) diff --git a/setup.py b/setup.py index d562d40..5152d54 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages import io import os -VERSION = "3.25.1" +VERSION = "3.26" def get_long_description(): From e3a14c33a033b0c2fc00f2470666caaf9027e446 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 19 Apr 2022 17:21:04 -0700 Subject: [PATCH 148/563] Run tests against pull requests --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a8274e2..75ef594 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,6 +1,6 @@ name: Test -on: [push] +on: [push, pull_request] env: FORCE_COLOR: 1 From ed6fd516082e8cc83b199798f62dd67728a6974f Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 2 May 2022 11:05:00 -0700 Subject: [PATCH 149/563] Depend on click-default-group-wheel (#429) To get this to work with Pyodide. Refs: https://github.com/simonw/click-default-group-wheel/issues/3 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 5152d54..218cc49 100644 --- a/setup.py +++ b/setup.py @@ -26,7 +26,7 @@ setup( install_requires=[ "sqlite-fts4", "click", - "click-default-group", + "click-default-group-wheel", "tabulate", "python-dateutil", ], From 56571775a15159d66bf6e5af971a45757e841f96 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 2 May 2022 11:14:29 -0700 Subject: [PATCH 150/563] Release 3.26.1 Refs #429 --- docs/changelog.rst | 20 +++++++++++++++++++- setup.py | 2 +- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index ecccfb4..8733e7c 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -2,6 +2,24 @@ Changelog =========== +.. _v3_26_1: + +3.26.1 (2022-05-02) +------------------- + +- Now depends on `click-default-group-wheel `__, a pure Python wheel package. This means you can install and use this package with `Pyodide `__, which can run Python entirely in your broswer using WebAssembly. (`#429 `__) + + Try that out using the `Pyodide REPL `__: + + .. code-block:: python + + >>> import micropip + >>> await micropip.install("sqlite-utils") + >>> import sqlite_utils + >>> db = sqlite_utils.Database(memory=True) + >>> list(db.query("select 3 * 5")) + [{'3 * 5': 15}] + .. _v3_26: 3.26 (2022-04-13) @@ -49,7 +67,7 @@ 3.23 (2022-02-03) ----------------- -This release introduces four new utility methods for working with `SpatiaLite `__. Thanks, Chris Amico. (`#330 `__) +This release introduces four new utility methods for working with `SpatiaLite `__. Thanks, Chris Amico. (`#385 `__) - ``sqlite_utils.utils.find_spatialite()`` :ref:`finds the location of the SpatiaLite module ` on disk. - ``db.init_spatialite()`` :ref:`initializes SpatiaLite ` for the given database. diff --git a/setup.py b/setup.py index 218cc49..8c5ed1b 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages import io import os -VERSION = "3.26" +VERSION = "3.26.1" def get_long_description(): From 841ad44bacaff05ec79ef78166d12e80c82ba6d7 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 2 May 2022 11:17:19 -0700 Subject: [PATCH 151/563] Fixed typo --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 8733e7c..f2f97a7 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -7,7 +7,7 @@ 3.26.1 (2022-05-02) ------------------- -- Now depends on `click-default-group-wheel `__, a pure Python wheel package. This means you can install and use this package with `Pyodide `__, which can run Python entirely in your broswer using WebAssembly. (`#429 `__) +- Now depends on `click-default-group-wheel `__, a pure Python wheel package. This means you can install and use this package with `Pyodide `__, which can run Python entirely in your browser using WebAssembly. (`#429 `__) Try that out using the `Pyodide REPL `__: From 397183debd9329a2ddbcabe7a181278f042952ad Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 20 May 2022 14:51:30 -0700 Subject: [PATCH 152/563] Switch docs theme to Furo, refs #435 --- .../layout.html => _static/js/custom.js} | 18 +----------------- docs/_templates/base.html | 6 ++++++ docs/cli-reference.rst | 1 + docs/cli.rst | 1 + docs/conf.py | 16 +++------------- docs/python-api.rst | 1 + docs/reference.rst | 1 + setup.py | 2 +- 8 files changed, 15 insertions(+), 31 deletions(-) rename docs/{_templates/layout.html => _static/js/custom.js} (67%) create mode 100644 docs/_templates/base.html diff --git a/docs/_templates/layout.html b/docs/_static/js/custom.js similarity index 67% rename from docs/_templates/layout.html rename to docs/_static/js/custom.js index cecf66e..8786865 100644 --- a/docs/_templates/layout.html +++ b/docs/_static/js/custom.js @@ -1,13 +1,3 @@ -{%- extends "!layout.html" %} - -{% block htmltitle %} -{{ super() }} - -{% endblock %} - -{% block footer %} -{{ super() }} - -{% endblock %} diff --git a/docs/_templates/base.html b/docs/_templates/base.html new file mode 100644 index 0000000..a9e670e --- /dev/null +++ b/docs/_templates/base.html @@ -0,0 +1,6 @@ +{%- extends "!base.html" %} + +{% block site_meta %} +{{ super() }} + +{% endblock %} \ No newline at end of file diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index c3f7a24..7d73e12 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -7,6 +7,7 @@ This page lists the ``--help`` for every ``sqlite-utils`` CLI sub-command. .. contents:: :local: + :class: this-will-duplicate-information-and-it-is-still-useful-here .. [[[cog from sqlite_utils import cli diff --git a/docs/cli.rst b/docs/cli.rst index 190d285..2d2ea76 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -9,6 +9,7 @@ The ``sqlite-utils`` command-line tool can be used to manipulate SQLite database Once installed, the tool should be available as ``sqlite-utils``. It can also be run using ``python -m sqlite_utils``. .. contents:: :local: + :class: this-will-duplicate-information-and-it-is-still-useful-here .. _cli_query: diff --git a/docs/conf.py b/docs/conf.py index 03c13d6..7244d2d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -94,7 +94,8 @@ todo_include_todos = False # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # -html_theme = "sphinx_rtd_theme" +html_theme = "furo" +html_title = "sqlite-utils" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the @@ -107,18 +108,7 @@ html_theme = "sphinx_rtd_theme" # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ["_static"] -# Custom sidebar templates, must be a dictionary that maps document names -# to template names. -# -# This is required for the alabaster theme -# refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars -html_sidebars = { - "**": [ - "relations.html", # needs 'show_related': True theme option to display - "searchbox.html", - ] -} - +html_js_files = ["js/custom.js"] # -- Options for HTMLHelp output ------------------------------------------ diff --git a/docs/python-api.rst b/docs/python-api.rst index bd0265b..82ec11c 100644 --- a/docs/python-api.rst +++ b/docs/python-api.rst @@ -5,6 +5,7 @@ ============================= .. contents:: :local: + :class: this-will-duplicate-information-and-it-is-still-useful-here .. _python_api_getting_started: diff --git a/docs/reference.rst b/docs/reference.rst index 5bd609f..2896bbf 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -5,6 +5,7 @@ =============== .. contents:: :local: + :class: this-will-duplicate-information-and-it-is-still-useful-here .. _reference_db_database: diff --git a/setup.py b/setup.py index 8c5ed1b..51830e3 100644 --- a/setup.py +++ b/setup.py @@ -32,7 +32,7 @@ setup( ], extras_require={ "test": ["pytest", "black", "hypothesis", "cogapp"], - "docs": ["sphinx_rtd_theme", "sphinx-autobuild", "codespell"], + "docs": ["furo", "sphinx-autobuild", "codespell"], "mypy": [ "mypy", "types-click", From 2238e9baf94d1d2af794d6cb064dbac098abd3f3 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 20 May 2022 14:57:26 -0700 Subject: [PATCH 153/563] sphinx-copybutton extension, closes #436 --- docs/conf.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 7244d2d..996a499 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -30,7 +30,7 @@ from subprocess import Popen, PIPE # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. -extensions = ["sphinx.ext.extlinks", "sphinx.ext.autodoc"] +extensions = ["sphinx.ext.extlinks", "sphinx.ext.autodoc", "sphinx_copybutton"] autodoc_member_order = "bysource" autodoc_typehints = "description" diff --git a/setup.py b/setup.py index 51830e3..eafb1ce 100644 --- a/setup.py +++ b/setup.py @@ -32,7 +32,7 @@ setup( ], extras_require={ "test": ["pytest", "black", "hypothesis", "cogapp"], - "docs": ["furo", "sphinx-autobuild", "codespell"], + "docs": ["furo", "sphinx-autobuild", "codespell", "sphinx-copybutton"], "mypy": [ "mypy", "types-click", From 59be60c471fd7a2c4be7f75e8911163e618ff5ca Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 20 May 2022 14:57:49 -0700 Subject: [PATCH 154/563] Update copyright dates on docs --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 996a499..1b1c4f1 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -52,7 +52,7 @@ master_doc = "index" # General information about the project. project = "sqlite-utils" -copyright = "2018-2021, Simon Willison" +copyright = "2018-2022, Simon Willison" author = "Simon Willison" # The version info for the project you're documenting, acts as replacement for From 9fedfc69d7239ac49900051e1c48ee9cdd470d9e Mon Sep 17 00:00:00 2001 From: Yuri Date: Mon, 30 May 2022 17:32:41 -0400 Subject: [PATCH 155/563] docs to dogs (#437) --- docs/cli.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cli.rst b/docs/cli.rst index 2d2ea76..1fc5a00 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -884,7 +884,7 @@ Inserting CSV or TSV data If your data is in CSV format, you can insert it using the ``--csv`` option:: - $ sqlite-utils insert dogs.db dogs docs.csv --csv + $ sqlite-utils insert dogs.db dogs dogs.csv --csv For tab-delimited data, use ``--tsv``:: From 7ddf5300886a32d6daf60cf1d71efe492b65c87e Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 13 Jun 2022 08:22:59 -0700 Subject: [PATCH 156/563] A less potentially confusing parameter name --- docs/cli.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/cli.rst b/docs/cli.rst index 1fc5a00..9169fba 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -1275,8 +1275,8 @@ The conversion will be applied to every row in the specified table. You can limi You can include named parameters in your where clause and populate them using one or more ``--param`` options:: $ sqlite-utils convert content.db articles headline 'value.upper()' \ - --where "headline like :like" \ - --param like '%cat%' + --where "headline like :query" \ + --param query '%cat%' The ``--dry-run`` option will output a preview of the conversion against the first ten rows, without modifying the database. From d379f430f8bc00e5177d38097c9ab2919152ee76 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 14 Jun 2022 08:14:02 -0700 Subject: [PATCH 157/563] rows_from_file(... ignore_extras: bool, restkey: str), refs #440 --- sqlite_utils/utils.py | 45 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/sqlite_utils/utils.py b/sqlite_utils/utils.py index d35cc05..611ff30 100644 --- a/sqlite_utils/utils.py +++ b/sqlite_utils/utils.py @@ -171,12 +171,43 @@ class RowsFromFileBadJSON(RowsFromFileError): pass +class RowError(Exception): + pass + + +def _extra_key_strategy( + reader: Iterable[dict], + ignore_extras: Optional[bool] = False, + restkey: Optional[str] = None, +): + # Logic for handling CSV rows with more values than there are headings + for row in reader: + # DictReader adds a 'None' key with extra row values + if None not in row: + yield row + elif ignore_extras: + row.pop(None) + yield row + elif not restkey: + extras = row.pop(None) + raise RowError( + "Row {} contained these extra values: {}".format(row, extras) + ) + else: + row[restkey] = row.pop(None) + yield row + + def rows_from_file( fp: BinaryIO, format: Optional[Format] = None, dialect: Optional[Type[csv.Dialect]] = None, encoding: Optional[str] = None, + ignore_extras: Optional[bool] = False, + restkey: Optional[str] = None, ) -> Tuple[Iterable[dict], Format]: + if ignore_extras and restkey: + raise ValueError("Cannot use ignore_extras= and restkey= together") if format == Format.JSON: decoded = json.load(fp) if isinstance(decoded, dict): @@ -193,12 +224,13 @@ def rows_from_file( reader = csv.DictReader(decoded_fp, dialect=dialect) else: reader = csv.DictReader(decoded_fp) - return reader, Format.CSV + return _extra_key_strategy(reader, ignore_extras, restkey), Format.CSV elif format == Format.TSV: + reader = rows_from_file( + fp, format=Format.CSV, dialect=csv.excel_tab, encoding=encoding + )[0] return ( - rows_from_file( - fp, format=Format.CSV, dialect=csv.excel_tab, encoding=encoding - )[0], + _extra_key_strategy(reader, ignore_extras, restkey), Format.TSV, ) elif format is None: @@ -212,9 +244,12 @@ def rows_from_file( dialect = csv.Sniffer().sniff( first_bytes.decode(encoding or "utf-8-sig", "ignore") ) - return rows_from_file( + rows, _ = rows_from_file( buffered, format=Format.CSV, dialect=dialect, encoding=encoding ) + # Make sure we return the format we detected + format = Format.TSV if dialect.delimiter == "\t" else Format.CSV + return _extra_key_strategy(rows, ignore_extras, restkey), format else: raise RowsFromFileError("Bad format") From ad96bd18c37a789b0505dbef0057557c7415b133 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 14 Jun 2022 08:14:38 -0700 Subject: [PATCH 158/563] Tests for rows_from_file, refs #440 --- tests/test_rows_from_file.py | 46 ++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 tests/test_rows_from_file.py diff --git a/tests/test_rows_from_file.py b/tests/test_rows_from_file.py new file mode 100644 index 0000000..5575b34 --- /dev/null +++ b/tests/test_rows_from_file.py @@ -0,0 +1,46 @@ +from sqlite_utils.utils import rows_from_file, Format, RowError +from io import BytesIO +import pytest + + +@pytest.mark.parametrize( + "input,expected_format", + ( + (b"id,name\n1,Cleo", Format.CSV), + (b"id\tname\n1\tCleo", Format.TSV), + (b'[{"id": "1", "name": "Cleo"}]', Format.JSON), + ), +) +def test_rows_from_file_detect_format(input, expected_format): + rows, format = rows_from_file(BytesIO(input)) + assert format == expected_format + rows_list = list(rows) + assert rows_list == [{"id": "1", "name": "Cleo"}] + + +@pytest.mark.parametrize( + "ignore_extras,restkey,expected", + ( + (True, None, [{"id": "1", "name": "Cleo"}]), + (False, "_rest", [{"id": "1", "name": "Cleo", "_rest": ["oops"]}]), + # expected of None means expect an error: + (False, False, None), + ), +) +def test_rows_from_file_extra_fields_strategies(ignore_extras, restkey, expected): + try: + rows, format = rows_from_file( + BytesIO(b"id,name\r\n1,Cleo,oops"), + format=Format.CSV, + ignore_extras=ignore_extras, + restkey=restkey, + ) + list_rows = list(rows) + except RowError: + if expected is None: + # This is fine, + return + else: + # We did not expect an error + raise + assert list_rows == expected From 19efee2746d4afdb67a7225b6972aa5aa7bbb1b7 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 14 Jun 2022 08:39:08 -0700 Subject: [PATCH 159/563] mypy fixes, refs #440 --- sqlite_utils/utils.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/sqlite_utils/utils.py b/sqlite_utils/utils.py index 611ff30..6eb6712 100644 --- a/sqlite_utils/utils.py +++ b/sqlite_utils/utils.py @@ -8,7 +8,7 @@ import itertools import json import os from . import recipes -from typing import Dict, cast, BinaryIO, Iterable, Optional, Tuple, Type +from typing import Dict, cast, BinaryIO, Iterable, Optional, Tuple, Type, Union import click @@ -176,25 +176,27 @@ class RowError(Exception): def _extra_key_strategy( - reader: Iterable[dict], + reader: Union[Iterable[dict], csv.DictReader[str]], ignore_extras: Optional[bool] = False, restkey: Optional[str] = None, -): +) -> Iterable[dict]: # Logic for handling CSV rows with more values than there are headings for row in reader: # DictReader adds a 'None' key with extra row values if None not in row: yield row elif ignore_extras: - row.pop(None) + # ignoring row.pop(none) because of this issue: + # https://github.com/simonw/sqlite-utils/issues/440#issuecomment-1155358637 + row.pop(None) # type: ignore yield row elif not restkey: - extras = row.pop(None) + extras = row.pop(None) # type: ignore raise RowError( "Row {} contained these extra values: {}".format(row, extras) ) else: - row[restkey] = row.pop(None) + row[restkey] = row.pop(None) # type: ignore yield row @@ -226,11 +228,11 @@ def rows_from_file( reader = csv.DictReader(decoded_fp) return _extra_key_strategy(reader, ignore_extras, restkey), Format.CSV elif format == Format.TSV: - reader = rows_from_file( + rows = rows_from_file( fp, format=Format.CSV, dialect=csv.excel_tab, encoding=encoding )[0] return ( - _extra_key_strategy(reader, ignore_extras, restkey), + _extra_key_strategy(rows, ignore_extras, restkey), Format.TSV, ) elif format is None: From d9c715a2fc0e4ccc0dd8e50ae68686a09f92eda8 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 14 Jun 2022 09:07:57 -0700 Subject: [PATCH 160/563] One more typing fix, refs #440 --- sqlite_utils/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sqlite_utils/utils.py b/sqlite_utils/utils.py index 6eb6712..eb80293 100644 --- a/sqlite_utils/utils.py +++ b/sqlite_utils/utils.py @@ -176,7 +176,7 @@ class RowError(Exception): def _extra_key_strategy( - reader: Union[Iterable[dict], csv.DictReader[str]], + reader: Iterable[dict], ignore_extras: Optional[bool] = False, restkey: Optional[str] = None, ) -> Iterable[dict]: From f142bb1212f98c1cb9ff72a3161351c5c8d1d281 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 14 Jun 2022 09:14:57 -0700 Subject: [PATCH 161/563] flake8 fix, refs #440 --- sqlite_utils/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sqlite_utils/utils.py b/sqlite_utils/utils.py index eb80293..8bbb1d2 100644 --- a/sqlite_utils/utils.py +++ b/sqlite_utils/utils.py @@ -8,7 +8,7 @@ import itertools import json import os from . import recipes -from typing import Dict, cast, BinaryIO, Iterable, Optional, Tuple, Type, Union +from typing import Dict, cast, BinaryIO, Iterable, Optional, Tuple, Type import click From ce670e2d44327f9e73452aba30da632902f6a937 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 14 Jun 2022 13:12:32 -0700 Subject: [PATCH 162/563] Docs for rows_from_file Closes #440, closes #443 --- docs/python-api.rst | 10 ++++++++ sqlite_utils/utils.py | 58 ++++++++++++++++++++++++++++++++++++------- 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/docs/python-api.rst b/docs/python-api.rst index 82ec11c..9fe6d67 100644 --- a/docs/python-api.rst +++ b/docs/python-api.rst @@ -2539,6 +2539,16 @@ If that option isn't relevant to your use-case you can to quote a string for use >>> db.quote("hello'this'has'quotes") "'hello''this''has''quotes'" +.. _python_api_rows_from_file: + +Reading rows from a file +======================== + +The ``sqlite_utils.utils.rows_from_file()`` helper function can read rows (a sequence of dictionaries) from CSV, TSV, JSON or newline-delimited JSON files. + +.. autofunction:: sqlite_utils.utils.rows_from_file + :noindex: + .. _python_api_gis: SpatiaLite helpers diff --git a/sqlite_utils/utils.py b/sqlite_utils/utils.py index 8bbb1d2..141356a 100644 --- a/sqlite_utils/utils.py +++ b/sqlite_utils/utils.py @@ -178,7 +178,7 @@ class RowError(Exception): def _extra_key_strategy( reader: Iterable[dict], ignore_extras: Optional[bool] = False, - restkey: Optional[str] = None, + extras_key: Optional[str] = None, ) -> Iterable[dict]: # Logic for handling CSV rows with more values than there are headings for row in reader: @@ -190,13 +190,13 @@ def _extra_key_strategy( # https://github.com/simonw/sqlite-utils/issues/440#issuecomment-1155358637 row.pop(None) # type: ignore yield row - elif not restkey: + elif not extras_key: extras = row.pop(None) # type: ignore raise RowError( "Row {} contained these extra values: {}".format(row, extras) ) else: - row[restkey] = row.pop(None) # type: ignore + row[extras_key] = row.pop(None) # type: ignore yield row @@ -206,10 +206,50 @@ def rows_from_file( dialect: Optional[Type[csv.Dialect]] = None, encoding: Optional[str] = None, ignore_extras: Optional[bool] = False, - restkey: Optional[str] = None, + extras_key: Optional[str] = None, ) -> Tuple[Iterable[dict], Format]: - if ignore_extras and restkey: - raise ValueError("Cannot use ignore_extras= and restkey= together") + """ + Load a sequence of dictionaries from a file-like object containing one of four different formats. + + .. code-block:: python + + from sqlite_utils.utils import rows_from_file + import io + + rows, format = rows_from_file(io.StringIO("id,name\\n1,Cleo"))) + print(list(rows), format) + # Outputs [{'id': '1', 'name': 'Cleo'}] Format.CSV + + This defaults to attempting to automatically detect the format of the data, or you can pass in an + explicit format using the format= option. + + Returns a tuple of ``(rows_generator, format_used)`` where ``rows_generator`` can be iterated over + to return dictionaries, while ``format_used`` is a value from the ``sqlite_utils.utils.Format`` enum: + + .. code-block:: python + + class Format(enum.Enum): + CSV = 1 + TSV = 2 + JSON = 3 + NL = 4 + + If a CSV or TSV file includes rows with more fields than are declared in the header a + ``sqlite_utils.utils.RowError`` exception will be raised when you loop over the generator. + + You can instead ignore the extra data by passing ``ignore_extras=True``. + + Or pass ``extras_key="rest"`` to put those additional values in a list in a key called ``rest``. + + :param fp: a file-like object containing binary data + :param format: the format to use - omit this to detect the format + :param dialect: the CSV dialect to use - omit this to detect the dialect + :param encoding: the character encoding to use when reading CSV/TSV data + :param ignore_extras: ignore any extra fields on rows + :param extras_key: put any extra fields in a list with this key + """ + if ignore_extras and extras_key: + raise ValueError("Cannot use ignore_extras= and extras_key= together") if format == Format.JSON: decoded = json.load(fp) if isinstance(decoded, dict): @@ -226,13 +266,13 @@ def rows_from_file( reader = csv.DictReader(decoded_fp, dialect=dialect) else: reader = csv.DictReader(decoded_fp) - return _extra_key_strategy(reader, ignore_extras, restkey), Format.CSV + return _extra_key_strategy(reader, ignore_extras, extras_key), Format.CSV elif format == Format.TSV: rows = rows_from_file( fp, format=Format.CSV, dialect=csv.excel_tab, encoding=encoding )[0] return ( - _extra_key_strategy(rows, ignore_extras, restkey), + _extra_key_strategy(rows, ignore_extras, extras_key), Format.TSV, ) elif format is None: @@ -251,7 +291,7 @@ def rows_from_file( ) # Make sure we return the format we detected format = Format.TSV if dialect.delimiter == "\t" else Format.CSV - return _extra_key_strategy(rows, ignore_extras, restkey), format + return _extra_key_strategy(rows, ignore_extras, extras_key), format else: raise RowsFromFileError("Bad format") From 0cee77b1764f7dff029eb0ea1e857e5b69c591ee Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 14 Jun 2022 14:14:10 -0700 Subject: [PATCH 163/563] Update test for renamed restkey, refs #440, #443 --- sqlite_utils/cli.py | 13 ++----------- sqlite_utils/utils.py | 19 +++++++++++++++++++ tests/test_rows_from_file.py | 6 +++--- 3 files changed, 24 insertions(+), 14 deletions(-) diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 05ff9d3..86eddfb 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -6,6 +6,7 @@ import hashlib import pathlib import sqlite_utils from sqlite_utils.db import AlterError, BadMultiValues, DescIndex +from sqlite_utils.utils import maximize_csv_field_size_limit from sqlite_utils import recipes import textwrap import inspect @@ -46,17 +47,7 @@ If you do not know the encoding, running 'file filename.csv' may tell you. It's often worth trying: --encoding=latin-1 """.strip() - -# Increase CSV field size limit to maximum possible -# https://stackoverflow.com/a/15063941 -field_size_limit = sys.maxsize - -while True: - try: - csv_std.field_size_limit(field_size_limit) - break - except OverflowError: - field_size_limit = int(field_size_limit / 10) +maximize_csv_field_size_limit() def output_options(fn): diff --git a/sqlite_utils/utils.py b/sqlite_utils/utils.py index 141356a..d2ccc5f 100644 --- a/sqlite_utils/utils.py +++ b/sqlite_utils/utils.py @@ -7,6 +7,7 @@ import io import itertools import json import os +import sys from . import recipes from typing import Dict, cast, BinaryIO, Iterable, Optional, Tuple, Type @@ -29,6 +30,24 @@ SPATIALITE_PATHS = ( "/usr/local/lib/mod_spatialite.dylib", ) +# Mainly so we can restore it if needed in the tests: +ORIGINAL_CSV_FIELD_SIZE_LIMIT = csv.field_size_limit() + + +def maximize_csv_field_size_limit(): + """ + Increase the CSV field size limit to the maximum possible. + """ + # https://stackoverflow.com/a/15063941 + field_size_limit = sys.maxsize + + while True: + try: + csv.field_size_limit(field_size_limit) + break + except OverflowError: + field_size_limit = int(field_size_limit / 10) + def find_spatialite() -> Optional[str]: """ diff --git a/tests/test_rows_from_file.py b/tests/test_rows_from_file.py index 5575b34..fdc7a03 100644 --- a/tests/test_rows_from_file.py +++ b/tests/test_rows_from_file.py @@ -19,7 +19,7 @@ def test_rows_from_file_detect_format(input, expected_format): @pytest.mark.parametrize( - "ignore_extras,restkey,expected", + "ignore_extras,extras_key,expected", ( (True, None, [{"id": "1", "name": "Cleo"}]), (False, "_rest", [{"id": "1", "name": "Cleo", "_rest": ["oops"]}]), @@ -27,13 +27,13 @@ def test_rows_from_file_detect_format(input, expected_format): (False, False, None), ), ) -def test_rows_from_file_extra_fields_strategies(ignore_extras, restkey, expected): +def test_rows_from_file_extra_fields_strategies(ignore_extras, extras_key, expected): try: rows, format = rows_from_file( BytesIO(b"id,name\r\n1,Cleo,oops"), format=Format.CSV, ignore_extras=ignore_extras, - restkey=restkey, + extras_key=extras_key, ) list_rows = list(rows) except RowError: From 9d1bac4a99fd7f9ef2a6cb44242a00e1faa408e4 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 14 Jun 2022 14:23:54 -0700 Subject: [PATCH 164/563] Test for maximize_csv_field_size_limit, refs #442 --- tests/test_utils.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_utils.py b/tests/test_utils.py index e76e97a..d397176 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,4 +1,6 @@ from sqlite_utils import utils +import csv +import io import pytest @@ -49,3 +51,23 @@ def test_hash_record(): assert ( utils.hash_record({"name": "Cleo", "twitter": "CleoPaws", "age": 7}) != expected ) + + +def test_maximize_csv_field_size_limit(): + # Reset to default in case other tests have changed it + csv.field_size_limit(utils.ORIGINAL_CSV_FIELD_SIZE_LIMIT) + long_value = "a" * 131073 + long_csv = "id,text\n1,{}".format(long_value) + fp = io.BytesIO(long_csv.encode("utf-8")) + # Using rows_from_file should error + with pytest.raises(csv.Error): + rows, _ = utils.rows_from_file(fp, utils.Format.CSV) + list(rows) + # But if we call maximize_csv_field_size_limit() first it should be OK: + utils.maximize_csv_field_size_limit() + fp2 = io.BytesIO(long_csv.encode("utf-8")) + rows2, _ = utils.rows_from_file(fp2, utils.Format.CSV) + rows_list2 = list(rows2) + assert len(rows_list2) == 1 + assert rows_list2[0]["id"] == "1" + assert rows_list2[0]["text"] == long_value From 0b6aba696dd07814d97f563de6ad1d5daab01fd9 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 14 Jun 2022 14:31:45 -0700 Subject: [PATCH 165/563] Documentation for maximize_csv_field_size_limit, closes #442 --- docs/python-api.rst | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/docs/python-api.rst b/docs/python-api.rst index 9fe6d67..ca98317 100644 --- a/docs/python-api.rst +++ b/docs/python-api.rst @@ -2549,6 +2549,37 @@ The ``sqlite_utils.utils.rows_from_file()`` helper function can read rows (a seq .. autofunction:: sqlite_utils.utils.rows_from_file :noindex: +.. _python_api_maximize_csv_field_size_limit: + +Setting the maximum CSV field size limit +======================================== + +Sometimes when working with CSV files that include extremely long fields you may see an error that looks like this:: + + _csv.Error: field larger than field limit (131072) + +The Python standard library ``csv`` module enforces a field size limit. You can increase that limit using the ``csv.field_size_limit(new_limit)`` method (`documented here `__) but if you don't want to pick a new level you may instead want to increase it to the maximum possible. + +The maximum possible value for this is not documented, and varies between systems. + +Calling ``sqlite_utils.utils.maximize_csv_field_size_limit()`` will set the value to the highest possible for the current system: + +.. code-block:: python + + from sqlite_utils.utils import maximize_csv_field_size_limit + + maximize_csv_field_size_limit() + + +If you need to reset to the original value after calling this function you can do so like this: + +.. code-block:: python + + from sqlite_utils.utils import ORIGINAL_CSV_FIELD_SIZE_LIMIT + import csv + + csv.field_size_limit(ORIGINAL_CSV_FIELD_SIZE_LIMIT) + .. _python_api_gis: SpatiaLite helpers From 1b09538bc6c1fda773590f3e600993ef06591041 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 14 Jun 2022 14:54:35 -0700 Subject: [PATCH 166/563] where= and where_args= parameters to search() and search_sql() Closes #441 --- docs/python-api.rst | 12 +++++-- sqlite_utils/db.py | 20 ++++++++++-- tests/test_fts.py | 76 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 4 deletions(-) diff --git a/docs/python-api.rst b/docs/python-api.rst index ca98317..1a8c989 100644 --- a/docs/python-api.rst +++ b/docs/python-api.rst @@ -2108,10 +2108,16 @@ The ``.search()`` method also accepts the following optional parameters: ``offset`` integer Offset to use along side the limit parameter. +``where`` string + Extra SQL fragment for the WHERE clause + +``where_args`` dictionary + Arguments to use for ``:param`` placeholders in the extra WHERE clause + ``quote`` bool Apply :ref:`FTS quoting rules ` to the search query, disabling advanced query syntax in a way that avoids surprising errors. -To return just the title and published columns for three matches for ``"dog"`` ordered by ``published`` with the most recent first, use the following: +To return just the title and published columns for three matches for ``"dog"`` where the ``id`` is greater than 10 ordered by ``published`` with the most recent first, use the following: .. code-block:: python @@ -2119,6 +2125,8 @@ To return just the title and published columns for three matches for ``"dog"`` o "dog", order_by="published desc", limit=3, + where="id > :min_id", + where_args={"min_id": 10}, columns=["title", "published"] ): print(article) @@ -2128,7 +2136,7 @@ To return just the title and published columns for three matches for ``"dog"`` o Building SQL queries with table.search_sql() -------------------------------------------- -You can generate the SQL query that would be used for a search using the ``table.search_sql()`` method. It takes the same arguments as ``table.search()`` with the exception of the search query itself, since the returned SQL includes a parameter that can be used for the search. +You can generate the SQL query that would be used for a search using the ``table.search_sql()`` method. It takes the same arguments as ``table.search()``, with the exception of the search query and the ``where_args`` parameter, since those should be provided when the returned SQL is executed. .. code-block:: python diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index cbf59d6..7a06304 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -2254,6 +2254,7 @@ class Table(Queryable): order_by: Optional[str] = None, limit: Optional[int] = None, offset: Optional[int] = None, + where: Optional[str] = None, ) -> str: """ " Return SQL string that can be used to execute searches against this table. @@ -2262,6 +2263,7 @@ class Table(Queryable): :param order_by: Column or SQL expression to sort by :param limit: SQL limit :param offset: SQL offset + :param where: Extra SQL fragment for the WHERE clause """ # Pick names for table and rank column that don't clash original = "original_" if self.name == "original" else "original" @@ -2283,7 +2285,7 @@ class Table(Queryable): select rowid, {columns} - from [{dbtable}] + from [{dbtable}]{where_clause} ) select {columns_with_prefix} @@ -2311,6 +2313,7 @@ class Table(Queryable): limit_offset += " offset {}".format(offset) return sql.format( dbtable=self.name, + where_clause="\n where {}".format(where) if where else "", original=original, columns=columns_sql, columns_with_prefix=columns_with_prefix_sql, @@ -2326,6 +2329,8 @@ class Table(Queryable): columns: Optional[Iterable[str]] = None, limit: Optional[int] = None, offset: Optional[int] = None, + where: str = None, + where_args: Optional[Union[Iterable, dict]] = None, quote: bool = False, ) -> Generator[dict, None, None]: """ @@ -2337,18 +2342,29 @@ class Table(Queryable): :param columns: List of columns to return, defaults to all columns. :param limit: Optional integer limit for returned rows. :param offset: Optional integer SQL offset. + :param where: Extra SQL fragment for the WHERE clause + :param where_args: Arguments to use for :param placeholders in the extra WHERE clause :param quote: Apply quoting to disable any special characters in the search query See :ref:`python_api_fts_search`. """ + args = {"query": self.db.quote_fts(q) if quote else q} + if where_args and "query" in where_args: + raise ValueError( + "'query' is a reserved key and cannot be passed to where_args for .search()" + ) + if where_args: + args.update(where_args) + cursor = self.db.execute( self.search_sql( order_by=order_by, columns=columns, limit=limit, offset=offset, + where=where, ), - {"query": self.db.quote_fts(q) if quote else q}, + args, ) columns = [c[0] for c in cursor.description] for row in cursor: diff --git a/tests/test_fts.py b/tests/test_fts.py index 24980f7..53a7bc2 100644 --- a/tests/test_fts.py +++ b/tests/test_fts.py @@ -94,6 +94,38 @@ def test_search_limit_offset(fresh_db): ) +@pytest.mark.parametrize("fts_version", ("FTS4", "FTS5")) +def test_search_where(fresh_db, fts_version): + table = fresh_db["t"] + table.insert_all(search_records) + table.enable_fts(["text", "country"], fts_version=fts_version) + results = list( + table.search("are", where="country = :country", where_args={"country": "Japan"}) + ) + assert results == [ + { + "rowid": 1, + "text": "tanuki are running tricksters", + "country": "Japan", + "not_searchable": "foo", + } + ] + + +def test_search_where_args_disallows_query(fresh_db): + table = fresh_db["t"] + with pytest.raises(ValueError) as ex: + list( + table.search( + "x", where="author = :query", where_args={"query": "not allowed"} + ) + ) + assert ( + ex.value.args[0] + == "'query' is a reserved key and cannot be passed to where_args for .search()" + ) + + def test_enable_fts_table_names_containing_spaces(fresh_db): table = fresh_db["test"] table.insert({"column with spaces": "in its name"}) @@ -415,6 +447,28 @@ def test_enable_fts_error_message_on_views(): "limit 10" ), ), + ( + {"where": "author = :author"}, + "FTS5", + ( + "with original as (\n" + " select\n" + " rowid,\n" + " *\n" + " from [books]\n" + " where author = :author\n" + ")\n" + "select\n" + " [original].*\n" + "from\n" + " [original]\n" + " join [books_fts] on [original].rowid = [books_fts].rowid\n" + "where\n" + " [books_fts] match :query\n" + "order by\n" + " [books_fts].rank" + ), + ), ( {"columns": ["title"]}, "FTS4", @@ -480,6 +534,28 @@ def test_enable_fts_error_message_on_views(): "limit 2" ), ), + ( + {"where": "author = :author"}, + "FTS4", + ( + "with original as (\n" + " select\n" + " rowid,\n" + " *\n" + " from [books]\n" + " where author = :author\n" + ")\n" + "select\n" + " [original].*\n" + "from\n" + " [original]\n" + " join [books_fts] on [original].rowid = [books_fts].rowid\n" + "where\n" + " [books_fts] match :query\n" + "order by\n" + " rank_bm25(matchinfo([books_fts], 'pcnalx'))" + ), + ), ], ) def test_search_sql(kwargs, fts, expected): From b8af3b96f5c72317cc8783dc296a94f6719987d9 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 14 Jun 2022 16:24:06 -0700 Subject: [PATCH 167/563] Fix bug with detect_fts() and similar table names, closes #434 --- sqlite_utils/db.py | 22 ++++++++++++---------- tests/test_introspect.py | 17 +++++++++++++++++ tests/test_tracer.py | 15 ++++++++++----- 3 files changed, 39 insertions(+), 15 deletions(-) diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index 7a06304..c835fbd 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -2212,24 +2212,26 @@ class Table(Queryable): def detect_fts(self) -> Optional[str]: "Detect if table has a corresponding FTS virtual table and return it" - sql = ( - textwrap.dedent( - """ + sql = textwrap.dedent( + """ SELECT name FROM sqlite_master WHERE rootpage = 0 AND ( - sql LIKE '%VIRTUAL TABLE%USING FTS%content=%{table}%' + sql LIKE :like + OR sql LIKE :like2 OR ( - tbl_name = "{table}" + tbl_name = :table AND sql LIKE '%VIRTUAL TABLE%USING FTS%' ) ) """ - ) - .strip() - .format(table=self.name) - ) - rows = self.db.execute(sql).fetchall() + ).strip() + args = { + "like": "%VIRTUAL TABLE%USING FTS%content=[{}]%".format(self.name), + "like2": '%VIRTUAL TABLE%USING FTS%content="{}"%'.format(self.name), + "table": self.name, + } + rows = self.db.execute(sql, args).fetchall() if len(rows) == 0: return None else: diff --git a/tests/test_introspect.py b/tests/test_introspect.py index 28a5009..fe9542b 100644 --- a/tests/test_introspect.py +++ b/tests/test_introspect.py @@ -36,6 +36,23 @@ def test_detect_fts(existing_db): assert existing_db["foo"].detect_fts() is None +@pytest.mark.parametrize("reverse_order", (True, False)) +def test_detect_fts_similar_tables(fresh_db, reverse_order): + # https://github.com/simonw/sqlite-utils/issues/434 + table1, table2 = ("demo", "demo2") + if reverse_order: + table1, table2 = table2, table1 + + fresh_db[table1].insert({"title": "Hello"}).enable_fts( + ["title"], fts_version="FTS4" + ) + fresh_db[table2].insert({"title": "Hello"}).enable_fts( + ["title"], fts_version="FTS4" + ) + assert fresh_db[table1].detect_fts() == "{}_fts".format(table1) + assert fresh_db[table2].detect_fts() == "{}_fts".format(table2) + + def test_tables(existing_db): assert 1 == len(existing_db.tables) assert "foo" == existing_db.tables[0].name diff --git a/tests/test_tracer.py b/tests/test_tracer.py index 618d1e6..2d76c74 100644 --- a/tests/test_tracer.py +++ b/tests/test_tracer.py @@ -54,14 +54,19 @@ def test_with_tracer(): "SELECT name FROM sqlite_master\n" " WHERE rootpage = 0\n" " AND (\n" - " sql LIKE '%VIRTUAL TABLE%USING FTS%content=%dogs%'\n" + " sql LIKE :like\n" + " OR sql LIKE :like2\n" " OR (\n" - ' tbl_name = "dogs"\n' + " tbl_name = :table\n" " AND sql LIKE '%VIRTUAL TABLE%USING FTS%'\n" " )\n" - " )" - ), - None, + " )", + { + "like": "%VIRTUAL TABLE%USING FTS%content=[dogs]%", + "like2": '%VIRTUAL TABLE%USING FTS%content="dogs"%', + "table": "dogs", + }, + ) ), ("select name from sqlite_master where type = 'view'", None), ("select sql from sqlite_master where name = ?", ("dogs_fts",)), From 679d6081198a2b658d6578f24bc3446750395ecf Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 14 Jun 2022 21:29:34 -0700 Subject: [PATCH 168/563] Release 3.27 Refs #434, #435, #436, #440, #441, #442, #443 --- docs/changelog.rst | 13 +++++++++++++ setup.py | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index f2f97a7..5c9bc18 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -2,6 +2,19 @@ Changelog =========== +.. _v3_27: + +3.27 (2022-06-14) +----------------- + +- Documentation now uses the `Furo `__ Sphinx theme. (:issue:`435`) +- Code examples in documentation now have a "copy to clipboard" button. (:issue:`436`) +- ``sqlite_utils.utils.utils.rows_from_file()`` is now a documented API, see :ref:`python_api_rows_from_file`. (:issue:`443`) +- ``rows_from_file()`` has two new parameters to help handle CSV files with rows that contain more values than are listed in that CSV file's headings: ``ignore_extrasTrue`` and ``extras_key="name-of-key"``. (:issue:`440`) +- ``sqlite_utils.utils.maximize_csv_field_size_limit()`` helper function for increasing the field size limit for reading CSV files to its maximum, see :ref:`python_api_maximize_csv_field_size_limit`. (:issue:`442`) +- ``table.search(where=, where_args=)`` parameters for adding additional ``WHERE`` clauses to a search query. The ``where=`` parameter is available on ``table.search_sql(...)`` as well. See :ref:`python_api_fts_search`. (:issue:`441`) +- Fixed bug where ``table.detect_fts()`` and other search-related functions could fail if two FTS-enabled tables had names that were prefixes of each other. (:issue:`434`) + .. _v3_26_1: 3.26.1 (2022-05-02) diff --git a/setup.py b/setup.py index eafb1ce..a416570 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages import io import os -VERSION = "3.26.1" +VERSION = "3.27" def get_long_description(): From 2d84577202e227670ad434fcf3dd9786a2df0819 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 14 Jun 2022 21:41:16 -0700 Subject: [PATCH 169/563] Fix tiny typo in 3.27 release notes --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 5c9bc18..d7b48f1 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -10,7 +10,7 @@ - Documentation now uses the `Furo `__ Sphinx theme. (:issue:`435`) - Code examples in documentation now have a "copy to clipboard" button. (:issue:`436`) - ``sqlite_utils.utils.utils.rows_from_file()`` is now a documented API, see :ref:`python_api_rows_from_file`. (:issue:`443`) -- ``rows_from_file()`` has two new parameters to help handle CSV files with rows that contain more values than are listed in that CSV file's headings: ``ignore_extrasTrue`` and ``extras_key="name-of-key"``. (:issue:`440`) +- ``rows_from_file()`` has two new parameters to help handle CSV files with rows that contain more values than are listed in that CSV file's headings: ``ignore_extras=True`` and ``extras_key="name-of-key"``. (:issue:`440`) - ``sqlite_utils.utils.maximize_csv_field_size_limit()`` helper function for increasing the field size limit for reading CSV files to its maximum, see :ref:`python_api_maximize_csv_field_size_limit`. (:issue:`442`) - ``table.search(where=, where_args=)`` parameters for adding additional ``WHERE`` clauses to a search query. The ``where=`` parameter is available on ``table.search_sql(...)`` as well. See :ref:`python_api_fts_search`. (:issue:`441`) - Fixed bug where ``table.detect_fts()`` and other search-related functions could fail if two FTS-enabled tables had names that were prefixes of each other. (:issue:`434`) From 3fbe8a784cc2f3fa0bfa8612fec9752ff9068a2b Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 18 Jun 2022 20:30:24 -0700 Subject: [PATCH 170/563] Link to annotated release notes for 3.27 --- docs/changelog.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index d7b48f1..b284eb8 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -7,6 +7,8 @@ 3.27 (2022-06-14) ----------------- +See also `the annotated release notes `__ for this release. + - Documentation now uses the `Furo `__ Sphinx theme. (:issue:`435`) - Code examples in documentation now have a "copy to clipboard" button. (:issue:`436`) - ``sqlite_utils.utils.utils.rows_from_file()`` is now a documented API, see :ref:`python_api_rows_from_file`. (:issue:`443`) From c80971d28ab03c703f2d39cfaa6123ea8a249711 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 20 Jun 2022 12:22:27 -0700 Subject: [PATCH 171/563] sqlite_utils.utils.rows_from_file in reference.html, refs #443 --- docs/reference.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/reference.rst b/docs/reference.rst index 2896bbf..6603d99 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -79,3 +79,10 @@ sqlite_utils.utils.hash_record ------------------------------ .. autofunction:: sqlite_utils.utils.hash_record + +.. _reference_utils_rows_from_file: + +sqlite_utils.utils.rows_from_file +--------------------------------- + +.. autofunction:: sqlite_utils.utils.rows_from_file From 773f2b6b20622bb986984a1c3161d5b3aaa1046b Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 20 Jun 2022 12:46:49 -0700 Subject: [PATCH 172/563] Documented TypeTracker, closes #445 --- docs/python-api.rst | 59 +++++++++++++++++++++++++++++++++++++++++++ docs/reference.rst | 8 ++++++ sqlite_utils/utils.py | 33 ++++++++++++++++++++++-- 3 files changed, 98 insertions(+), 2 deletions(-) diff --git a/docs/python-api.rst b/docs/python-api.rst index 1a8c989..2a87610 100644 --- a/docs/python-api.rst +++ b/docs/python-api.rst @@ -2588,6 +2588,65 @@ If you need to reset to the original value after calling this function you can d csv.field_size_limit(ORIGINAL_CSV_FIELD_SIZE_LIMIT) +.. _python_api_typetracker: + +Detecting column types using TypeTracker +======================================== + +Sometimes you may find yourself working with data that lacks type information - data from a CSV file for example. + +The ``TypeTracker`` class can be used to try to automatically identify the most likely types for data that is initially represented as strings. + +Consider this example: + +.. code-block:: python + + import csv, io + + csv_file = io.StringIO("id,name\n1,Cleo\n2,Cardi") + rows = list(csv.DictReader(csv_file)) + + # rows is now this: + # [{'id': '1', 'name': 'Cleo'}, {'id': '2', 'name': 'Cardi'}] + +If we insert this data directly into a table we will get a schema that is entirely ``TEXT`` columns: + +.. code-block:: python + + from sqlite_utils import Database + + db = Database(memory=True) + db["creatures"].insert_all(rows) + print(db.schema) + # Outputs: + # CREATE TABLE [creatures] ( + # [id] TEXT, + # [name] TEXT + # ); + +We can detect the best column types using a ``TypeTracker`` instance: + +.. code-block:: python + + from sqlite_utils.utils import TypeTracker + + tracker = TypeTracker() + db["creatures2"].insert_all(tracker.wrap(rows)) + print(tracker.types) + # Outputs {'id': 'integer', 'name': 'text'} + +We can then apply those types to our new table using the :ref:`table.transform() ` method: + +.. code-block:: python + + db["creatures2"].transform(types=tracker.types) + print(db["creatures2"].schema) + # Outputs: + # CREATE TABLE [creatures2] ( + # [id] INTEGER, + # [name] TEXT + # ); + .. _python_api_gis: SpatiaLite helpers diff --git a/docs/reference.rst b/docs/reference.rst index 6603d99..abd918a 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -86,3 +86,11 @@ sqlite_utils.utils.rows_from_file --------------------------------- .. autofunction:: sqlite_utils.utils.rows_from_file + +.. _reference_utils_typetracker: + +sqlite_utils.utils.TypeTracker +------------------------------ + +.. autoclass:: sqlite_utils.utils.TypeTracker + :members: wrap, types diff --git a/sqlite_utils/utils.py b/sqlite_utils/utils.py index d2ccc5f..98ceffd 100644 --- a/sqlite_utils/utils.py +++ b/sqlite_utils/utils.py @@ -316,10 +316,35 @@ def rows_from_file( class TypeTracker: + """ + Wrap an iterator of dictionaries and keep track of which SQLite column + types are the most likely fit for each of their keys. + + Example usage: + + .. code-block:: python + + from sqlite_utils.utils import TypeTracker + import sqlite_utils + + db = sqlite_utils.Database(memory=True) + tracker = TypeTracker() + rows = [{"id": "1", "name": "Cleo", "id": "2", "name": "Cardi"}] + db["creatures"].insert_all(tracker.wrap(rows)) + print(tracker.types) + # Outputs {'id': 'integer', 'name': 'text'} + db["creatures"].transform(types=tracker.types) + """ def __init__(self): self.trackers = {} - def wrap(self, iterator): + def wrap(self, iterator: Iterable[dict]) -> Iterable[dict]: + """ + Use this to loop through an existing iterator, tracking the column types + as part of the iteration. + + :param iterator: The iterator to wrap + """ for row in iterator: for key, value in row.items(): tracker = self.trackers.setdefault(key, ValueTracker()) @@ -327,7 +352,11 @@ class TypeTracker: yield row @property - def types(self): + def types(self) -> Dict[str, str]: + """ + A dictionary mapping column names to their detected types. This can be passed + to the ``db[table_name].transform(types=tracker.types)`` method. + """ return {key: tracker.guessed_type for key, tracker in self.trackers.items()} From 8a9fe6498faf783a1fdeb1793e661ad194a05267 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 20 Jun 2022 12:50:15 -0700 Subject: [PATCH 173/563] Applied Black, refs #445 --- sqlite_utils/utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/sqlite_utils/utils.py b/sqlite_utils/utils.py index 98ceffd..377f82a 100644 --- a/sqlite_utils/utils.py +++ b/sqlite_utils/utils.py @@ -335,6 +335,7 @@ class TypeTracker: # Outputs {'id': 'integer', 'name': 'text'} db["creatures"].transform(types=tracker.types) """ + def __init__(self): self.trackers = {} From 3ddacb7bdc5a949786e341151e34c5fcf9050033 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 20 Jun 2022 12:54:46 -0700 Subject: [PATCH 174/563] Justfile for tests and linting, closes #446 --- Justfile | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 Justfile diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..b069682 --- /dev/null +++ b/Justfile @@ -0,0 +1,12 @@ +@default: test lint + +@test *options: + pipenv run pytest {{options}} + +@lint: + pipenv run black . --check + pipenv run flake8 + pipenv run mypy sqlite_utils tests + +@black: + pipenv run black . From 575431149400fcccb87d69ac7325d81d97686ef6 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 21 Jun 2022 08:00:17 -0700 Subject: [PATCH 175/563] Only syntax highlight if a code-block is used Refs #447 --- docs/conf.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index 1b1c4f1..d9ebc4b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -85,6 +85,9 @@ exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] # The name of the Pygments (syntax highlighting) style to use. pygments_style = "sphinx" +# Only syntax highlight of code-block is used: +highlight_language = "none" + # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = False From 2768effe078285982fbaae81d2884444b5a682ad Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 21 Jun 2022 12:24:23 -0700 Subject: [PATCH 176/563] Run cog using just as well, refs #446 --- Justfile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Justfile b/Justfile index b069682..aec0f44 100644 --- a/Justfile +++ b/Justfile @@ -7,6 +7,10 @@ pipenv run black . --check pipenv run flake8 pipenv run mypy sqlite_utils tests + cog --check README.md docs/*.rst + +@cog: + cog -r README.md docs/*.rst @black: pipenv run black . From ba8cf54908bc90f0775c4b15efe5e18327796402 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 21 Jun 2022 12:28:01 -0700 Subject: [PATCH 177/563] Add documentation strings to Justfile, ref #446 --- Justfile | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Justfile b/Justfile index aec0f44..52e6190 100644 --- a/Justfile +++ b/Justfile @@ -1,16 +1,21 @@ +# Run tests and linters @default: test lint +# Run pytest with supplied options @test *options: pipenv run pytest {{options}} +# Run linters: black, flake8, mypy, cog @lint: pipenv run black . --check pipenv run flake8 pipenv run mypy sqlite_utils tests cog --check README.md docs/*.rst +# Rebuild docs with cog @cog: cog -r README.md docs/*.rst +# Apply Black @black: pipenv run black . From 9a6495fbef897247551de733fbee204b19ea9385 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 27 Jun 2022 23:38:45 +0000 Subject: [PATCH 178/563] Configure GitPod --- .gitpod.yml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .gitpod.yml diff --git a/.gitpod.yml b/.gitpod.yml new file mode 100644 index 0000000..fe8ba1e --- /dev/null +++ b/.gitpod.yml @@ -0,0 +1,2 @@ +tasks: +- init: pip install '.[test]' \ No newline at end of file From 2dca2210d9bbaa990c8f1243e98b8c2bfc961a4c Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 27 Jun 2022 23:39:23 +0000 Subject: [PATCH 179/563] Ignore build in .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index a224f01..32032d5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .venv +build *.db __pycache__/ *.py[cod] From 42440d6345c242ee39778045e29143fb550bd2c2 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 27 Jun 2022 23:41:13 +0000 Subject: [PATCH 180/563] Use parametrize for FTS test --- tests/test_fts.py | 52 +++++++++++++++++++++++------------------------ 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/tests/test_fts.py b/tests/test_fts.py index 53a7bc2..477d599 100644 --- a/tests/test_fts.py +++ b/tests/test_fts.py @@ -180,32 +180,32 @@ def test_populate_fts_escape_table_names(fresh_db): ] == list(table.search("usa")) -def test_fts_tokenize(fresh_db): - for fts_version in ("4", "5"): - table_name = "searchable_{}".format(fts_version) - table = fresh_db[table_name] - table.insert_all(search_records) - # Test without porter stemming - table.enable_fts( - ["text", "country"], - fts_version="FTS{}".format(fts_version), - ) - assert [] == list(table.search("bite")) - # Test WITH stemming - table.disable_fts() - table.enable_fts( - ["text", "country"], - fts_version="FTS{}".format(fts_version), - tokenize="porter", - ) - rows = list(table.search("bite", order_by="rowid")) - assert len(rows) == 1 - assert { - "rowid": 2, - "text": "racoons are biting trash pandas", - "country": "USA", - "not_searchable": "bar", - }.items() <= rows[0].items() +@pytest.mark.parametrize("fts_version", ("4", "5")) +def test_fts_tokenize(fresh_db, fts_version): + table_name = "searchable_{}".format(fts_version) + table = fresh_db[table_name] + table.insert_all(search_records) + # Test without porter stemming + table.enable_fts( + ["text", "country"], + fts_version="FTS{}".format(fts_version), + ) + assert [] == list(table.search("bite")) + # Test WITH stemming + table.disable_fts() + table.enable_fts( + ["text", "country"], + fts_version="FTS{}".format(fts_version), + tokenize="porter", + ) + rows = list(table.search("bite", order_by="rowid")) + assert len(rows) == 1 + assert { + "rowid": 2, + "text": "racoons are biting trash pandas", + "country": "USA", + "not_searchable": "bar", + }.items() <= rows[0].items() def test_optimize_fts(fresh_db): From b366e68deb0780048a23610c279552f8529d4726 Mon Sep 17 00:00:00 2001 From: David <1690072+davidleejy@users.noreply.github.com> Date: Sat, 16 Jul 2022 05:21:36 +0800 Subject: [PATCH 181/563] table.duplicate(new_table_name) feature, closes #449 Thanks, @davidleejy --- sqlite_utils/db.py | 15 +++++++++++++++ tests/test_duplicate.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 tests/test_duplicate.py diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index c835fbd..5d4d979 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -1476,6 +1476,21 @@ class Table(Queryable): ) return self + def duplicate(self, name_new: str) -> "Table": + """ + Duplicate this table in this database. + + :param name_new: Name of new table. + """ + assert self.exists() + with self.db.conn: + sql = "CREATE TABLE [{new_table}] AS SELECT * FROM [{table}];".format( + new_table=name_new, + table=self.name, + ) + self.db.execute(sql) + return self.db[name_new] + def transform( self, *, diff --git a/tests/test_duplicate.py b/tests/test_duplicate.py new file mode 100644 index 0000000..0324b00 --- /dev/null +++ b/tests/test_duplicate.py @@ -0,0 +1,36 @@ +import datetime + + +def test_duplicate(fresh_db): + # Create table using native Sqlite statement: + fresh_db.execute( + """CREATE TABLE [table1] ( + [text_col] TEXT, + [real_col] REAL, + [int_col] INTEGER, + [bool_col] INTEGER, + [datetime_col] TEXT)""" + ) + # Insert one row of mock data: + dt = datetime.datetime.now() + data = { + "text_col": "Cleo", + "real_col": 3.14, + "int_col": -255, + "bool_col": True, + "datetime_col": str(dt), + } + table1 = fresh_db["table1"] + row_id = table1.insert(data).last_rowid + # Duplicate table: + table2 = table1.duplicate("table2") + # Ensure data integrity: + assert data == table2.get(row_id) + # Ensure schema integrity: + assert [ + {"name": "text_col", "type": "TEXT"}, + {"name": "real_col", "type": "REAL"}, + {"name": "int_col", "type": "INT"}, + {"name": "bool_col", "type": "INT"}, + {"name": "datetime_col", "type": "TEXT"}, + ] == [{"name": col.name, "type": col.type} for col in table2.columns] From 4af4762521fe3e1ced7fcade67eaeabf41213aab Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 15 Jul 2022 14:23:57 -0700 Subject: [PATCH 182/563] test_duplicate_fails_if_table_does_not_exist, refs #449 --- tests/test_duplicate.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_duplicate.py b/tests/test_duplicate.py index 0324b00..f56556d 100644 --- a/tests/test_duplicate.py +++ b/tests/test_duplicate.py @@ -1,4 +1,5 @@ import datetime +import pytest def test_duplicate(fresh_db): @@ -34,3 +35,8 @@ def test_duplicate(fresh_db): {"name": "bool_col", "type": "INT"}, {"name": "datetime_col", "type": "TEXT"}, ] == [{"name": col.name, "type": col.type} for col in table2.columns] + + +def test_duplicate_fails_if_table_does_not_exist(fresh_db): + with pytest.raises(AssertionError): + fresh_db["not_a_table"].duplicate("duplicated") From da030d49fd12bbae931871c54a49caccc604f558 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 15 Jul 2022 14:29:52 -0700 Subject: [PATCH 183/563] Documentation for .duplicate(), refs #449 --- docs/python-api.rst | 13 +++++++++++++ sqlite_utils/db.py | 10 +++++----- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/docs/python-api.rst b/docs/python-api.rst index 2a87610..4d6676a 100644 --- a/docs/python-api.rst +++ b/docs/python-api.rst @@ -678,6 +678,19 @@ Here's an example that uses these features: # [score] INTEGER NOT NULL DEFAULT 1 # ) +.. _python_api_duplicate: + +Duplicating tables +================== + +The ``table.duplicate()`` method creates a copy of the table, copying both the table schema and all of the rows in that table: + +.. code-block:: python + + db["authors"].duplicate("authors_copy") + +The new ``authors_copy`` table will now contain a duplicate copy of the data from ``authors``. + .. _python_api_bulk_inserts: Bulk inserts diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index 5d4d979..3a27cbe 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -1476,20 +1476,20 @@ class Table(Queryable): ) return self - def duplicate(self, name_new: str) -> "Table": + def duplicate(self, new_name: str) -> "Table": """ - Duplicate this table in this database. + Create a duplicate of this table, copying across the schema and all row data. - :param name_new: Name of new table. + :param new_name: Name of the new table """ assert self.exists() with self.db.conn: sql = "CREATE TABLE [{new_table}] AS SELECT * FROM [{table}];".format( - new_table=name_new, + new_table=new_name, table=self.name, ) self.db.execute(sql) - return self.db[name_new] + return self.db[new_name] def transform( self, From c710ade6443d45ca2f581cdf975c7990436ab8fc Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 15 Jul 2022 14:45:14 -0700 Subject: [PATCH 184/563] sqlite-utils duplicate command, closes #454, refs #449 Also made it so .duplicate() raises new NoTable exception rather than raising an AssertionError if the source table does not exist. --- docs/cli-reference.rst | 14 ++++++++++++++ docs/cli.rst | 9 +++++++++ docs/python-api.rst | 2 ++ sqlite_utils/cli.py | 23 ++++++++++++++++++++++- sqlite_utils/db.py | 9 ++++++++- tests/test_cli.py | 23 +++++++++++++++++++++++ tests/test_duplicate.py | 3 ++- 7 files changed, 80 insertions(+), 3 deletions(-) diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index 7d73e12..b1a652e 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -1176,6 +1176,20 @@ reset-counts -h, --help Show this message and exit. +duplicate +========= + +:: + + Usage: sqlite-utils duplicate [OPTIONS] PATH TABLE NEW_TABLE + + Create a duplicate of this table, copying across the schema and all row data. + + Options: + --load-extension TEXT SQLite extensions to load + -h, --help Show this message and exit. + + drop-table ========== diff --git a/docs/cli.rst b/docs/cli.rst index 9169fba..e126881 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -1476,6 +1476,15 @@ You can specify foreign key relationships between the tables you are creating us If a table with the same name already exists, you will get an error. You can choose to silently ignore this error with ``--ignore``, or you can replace the existing table with a new, empty table using ``--replace``. +.. _cli_duplicate_table: + +Duplicating tables +================== + +The ``duplicate`` command duplicates a table - creating a new table with the same schema and a copy of all of the rows:: + + $ sqlite-utils duplicate books.db authors authors_copy + .. _cli_drop_table: Dropping tables diff --git a/docs/python-api.rst b/docs/python-api.rst index 4d6676a..464afb2 100644 --- a/docs/python-api.rst +++ b/docs/python-api.rst @@ -691,6 +691,8 @@ The ``table.duplicate()`` method creates a copy of the table, copying both the t The new ``authors_copy`` table will now contain a duplicate copy of the data from ``authors``. +This method raises ``sqlite_utils.db.NoTable`` if the table does not exist. + .. _python_api_bulk_inserts: Bulk inserts diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 86eddfb..0af947d 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -5,7 +5,7 @@ from datetime import datetime import hashlib import pathlib import sqlite_utils -from sqlite_utils.db import AlterError, BadMultiValues, DescIndex +from sqlite_utils.db import AlterError, BadMultiValues, DescIndex, NoTable from sqlite_utils.utils import maximize_csv_field_size_limit from sqlite_utils import recipes import textwrap @@ -1465,6 +1465,27 @@ def create_table( ) +@cli.command(name="duplicate") +@click.argument( + "path", + type=click.Path(file_okay=True, dir_okay=False, allow_dash=False), + required=True, +) +@click.argument("table") +@click.argument("new_table") +@load_extension_option +def create_table(path, table, new_table, load_extension): + """ + Create a duplicate of this table, copying across the schema and all row data. + """ + db = sqlite_utils.Database(path) + _load_extensions(db, load_extension) + try: + db[table].duplicate(new_table) + except NoTable: + raise click.ClickException('Table "{}" does not exist'.format(table)) + + @cli.command(name="drop-table") @click.argument( "path", diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index 3a27cbe..4755ea2 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -1,3 +1,4 @@ +from ast import Not from .utils import ( chunks, hash_record, @@ -224,6 +225,11 @@ class NoObviousTable(Exception): pass +class NoTable(Exception): + "Specified table does not exist" + pass + + class BadPrimaryKey(Exception): "Table does not have a single obvious primary key" pass @@ -1482,7 +1488,8 @@ class Table(Queryable): :param new_name: Name of the new table """ - assert self.exists() + if not self.exists(): + raise NoTable(f"Table {self.name} does not exist") with self.db.conn: sql = "CREATE TABLE [{new_table}] AS SELECT * FROM [{table}];".format( new_table=new_name, diff --git a/tests/test_cli.py b/tests/test_cli.py index e2e4aa2..a2ce739 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2129,3 +2129,26 @@ def test_analyze(tmpdir, options, expected): result = CliRunner().invoke(cli.cli, ["analyze", db_path] + options) assert result.exit_code == 0 assert list(db["sqlite_stat1"].rows) == expected + + +def test_duplicate_table(tmpdir): + db_path = str(tmpdir / "test.db") + db = Database(db_path) + db["one"].insert({"id": 1, "name": "Cleo"}, pk="id") + # First try a non-existent table + result_error = CliRunner().invoke( + cli.cli, + ["duplicate", db_path, "missing", "two"], + catch_exceptions=False, + ) + assert result_error.exit_code == 1 + assert result_error.output == 'Error: Table "missing" does not exist\n' + # Now try for a table that exists + result = CliRunner().invoke( + cli.cli, + ["duplicate", db_path, "one", "two"], + catch_exceptions=False, + ) + assert result.exit_code == 0 + assert db["one"].columns_dict == db["two"].columns_dict + assert list(db["one"].rows) == list(db["two"].rows) diff --git a/tests/test_duplicate.py b/tests/test_duplicate.py index f56556d..87996ef 100644 --- a/tests/test_duplicate.py +++ b/tests/test_duplicate.py @@ -1,3 +1,4 @@ +from sqlite_utils.db import NoTable import datetime import pytest @@ -38,5 +39,5 @@ def test_duplicate(fresh_db): def test_duplicate_fails_if_table_does_not_exist(fresh_db): - with pytest.raises(AssertionError): + with pytest.raises(NoTable): fresh_db["not_a_table"].duplicate("duplicated") From 015c6634644a11e5a3ca9d7dafe22ba62b87f2dd Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 15 Jul 2022 14:59:06 -0700 Subject: [PATCH 185/563] Fixed lint errors, refs #454 --- sqlite_utils/cli.py | 2 +- sqlite_utils/db.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 0af947d..c940b30 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -1474,7 +1474,7 @@ def create_table( @click.argument("table") @click.argument("new_table") @load_extension_option -def create_table(path, table, new_table, load_extension): +def duplicate(path, table, new_table, load_extension): """ Create a duplicate of this table, copying across the schema and all row data. """ diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index 4755ea2..a2a30f9 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -1,4 +1,3 @@ -from ast import Not from .utils import ( chunks, hash_record, From e10536c7f59abbb785f092bf83c4ab94c00e31a3 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 15 Jul 2022 14:59:30 -0700 Subject: [PATCH 186/563] utils.chunks() is now a documented API, closes #451 --- docs/reference.rst | 7 +++++++ sqlite_utils/utils.py | 8 +++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/reference.rst b/docs/reference.rst index abd918a..66b9d38 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -94,3 +94,10 @@ sqlite_utils.utils.TypeTracker .. autoclass:: sqlite_utils.utils.TypeTracker :members: wrap, types + +.. _reference_utils_chunks: + +sqlite_utils.utils.chunks +------------------------- + +.. autofunction:: sqlite_utils.utils.chunks diff --git a/sqlite_utils/utils.py b/sqlite_utils/utils.py index 377f82a..64bba50 100644 --- a/sqlite_utils/utils.py +++ b/sqlite_utils/utils.py @@ -468,7 +468,13 @@ def _compile_code(code, imports, variable="value"): return locals["fn"] -def chunks(sequence, size): +def chunks(sequence: Iterable, size: int) -> Iterable[Iterable]: + """ + Iterate over chunks of the sequence of the given size. + + :param sequence: Any Python iterator + :param size: The size of each chunk + """ iterator = iter(sequence) for item in iterator: yield itertools.chain([item], itertools.islice(iterator, size - 1)) From 9dd4cf891d9f4565019e030ddc712507ac87b998 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 15 Jul 2022 15:09:07 -0700 Subject: [PATCH 187/563] Improve CLI help for drop-table and drop-view, refs #450 --- docs/cli-reference.rst | 4 ++-- sqlite_utils/cli.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index b1a652e..6ef7052 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -1206,7 +1206,7 @@ See :ref:`cli_drop_table`. sqlite-utils drop-table chickens.db chickens Options: - --ignore + --ignore If table does not exist, do nothing --load-extension TEXT SQLite extensions to load -h, --help Show this message and exit. @@ -1250,7 +1250,7 @@ See :ref:`cli_drop_view`. sqlite-utils drop-view chickens.db heavy_chickens Options: - --ignore + --ignore If view does not exist, do nothing --load-extension TEXT SQLite extensions to load -h, --help Show this message and exit. diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index c940b30..09e441c 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -1493,7 +1493,7 @@ def duplicate(path, table, new_table, load_extension): required=True, ) @click.argument("table") -@click.option("--ignore", is_flag=True) +@click.option("--ignore", is_flag=True, help="If table does not exist, do nothing") @load_extension_option def drop_table(path, table, ignore, load_extension): """Drop the specified table @@ -1563,7 +1563,7 @@ def create_view(path, view, select, ignore, replace, load_extension): required=True, ) @click.argument("view") -@click.option("--ignore", is_flag=True) +@click.option("--ignore", is_flag=True, help="If view does not exist, do nothing") @load_extension_option def drop_view(path, view, ignore, load_extension): """Drop the specified view From 40b6947255540b8cf0639b87824ea8568ec6863c Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 15 Jul 2022 15:20:26 -0700 Subject: [PATCH 188/563] enable-fts --replace option, refs #450 Also fixed up some sqlite3.OperationalError imports. --- docs/cli-reference.rst | 1 + sqlite_utils/cli.py | 36 +++++++++++++++++++++++------------- tests/test_cli.py | 25 +++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 13 deletions(-) diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index 6ef7052..09eb0be 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -870,6 +870,7 @@ See :ref:`cli_fts`. --tokenize TEXT Tokenizer to use, e.g. porter --create-triggers Create triggers to update the FTS tables when the parent table changes. + --replace Replace existing FTS configuration if it exists --load-extension TEXT SQLite extensions to load -h, --help Show this message and exit. diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 09e441c..cf00898 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -18,6 +18,7 @@ import sys import csv as csv_std import tabulate from .utils import ( + OperationalError, _compile_code, chunks, file_progress, @@ -345,7 +346,7 @@ def analyze(path, names): db.analyze(name) else: db.analyze() - except sqlite3.OperationalError as e: + except OperationalError as e: raise click.ClickException(e) @@ -598,9 +599,14 @@ def create_index( default=False, is_flag=True, ) +@click.option( + "--replace", + is_flag=True, + help="Replace existing FTS configuration if it exists", +) @load_extension_option def enable_fts( - path, table, column, fts4, fts5, tokenize, create_triggers, load_extension + path, table, column, fts4, fts5, tokenize, create_triggers, replace, load_extension ): """Enable full-text search for specific table and columns" @@ -618,12 +624,16 @@ def enable_fts( db = sqlite_utils.Database(path) _load_extensions(db, load_extension) - db[table].enable_fts( - column, - fts_version=fts_version, - tokenize=tokenize, - create_triggers=create_triggers, - ) + try: + db[table].enable_fts( + column, + fts_version=fts_version, + tokenize=tokenize, + create_triggers=create_triggers, + replace=replace, + ) + except OperationalError as ex: + raise click.ClickException(ex) @cli.command(name="populate-fts") @@ -1024,7 +1034,7 @@ def insert_upsert_implementation( ) except Exception as e: if ( - isinstance(e, sqlite3.OperationalError) + isinstance(e, OperationalError) and e.args and "has no column named" in e.args[0] ): @@ -1341,7 +1351,7 @@ def bulk( silent=False, bulk_sql=sql, ) - except (sqlite3.OperationalError, sqlite3.IntegrityError) as e: + except (OperationalError, sqlite3.IntegrityError) as e: raise click.ClickException(str(e)) @@ -1507,7 +1517,7 @@ def drop_table(path, table, ignore, load_extension): _load_extensions(db, load_extension) try: db[table].drop(ignore=ignore) - except sqlite3.OperationalError: + except OperationalError: raise click.ClickException('Table "{}" does not exist'.format(table)) @@ -1577,7 +1587,7 @@ def drop_view(path, view, ignore, load_extension): _load_extensions(db, load_extension) try: db[view].drop(ignore=ignore) - except sqlite3.OperationalError: + except OperationalError: raise click.ClickException('View "{}" does not exist'.format(view)) @@ -1818,7 +1828,7 @@ def _execute_query( with db.conn: try: cursor = db.execute(sql, dict(param)) - except sqlite3.OperationalError as e: + except OperationalError as e: raise click.ClickException(str(e)) if cursor.description is None: # This was an update/insert diff --git a/tests/test_cli.py b/tests/test_cli.py index a2ce739..01cc9bb 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -455,6 +455,31 @@ def test_enable_fts(db_path): db["http://example.com"].drop() +def test_enable_fts_replace(db_path): + db = Database(db_path) + assert db["Gosh"].detect_fts() is None + result = CliRunner().invoke( + cli.cli, ["enable-fts", db_path, "Gosh", "c1", "--fts4"] + ) + assert result.exit_code == 0 + assert "Gosh_fts" == db["Gosh"].detect_fts() + assert db["Gosh_fts"].columns_dict == {"c1": str} + + # This should throw an error + result2 = CliRunner().invoke( + cli.cli, ["enable-fts", db_path, "Gosh", "c1", "--fts4"] + ) + assert result2.exit_code == 1 + assert result2.output == "Error: table [Gosh_fts] already exists\n" + + # This should work + result3 = CliRunner().invoke( + cli.cli, ["enable-fts", db_path, "Gosh", "c2", "--fts4", "--replace"] + ) + assert result3.exit_code == 0 + assert db["Gosh_fts"].columns_dict == {"c2": str} + + def test_enable_fts_with_triggers(db_path): Database(db_path)["Gosh"].insert_all([{"c1": "baz"}]) exit_code = ( From 2c77f4467efcd540ccd57d438cdebece541b90ac Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 15 Jul 2022 15:25:49 -0700 Subject: [PATCH 189/563] Add --ignore to create-index as alias of --if-not-exists, refs #450 --- docs/cli-reference.rst | 12 ++++++------ sqlite_utils/cli.py | 1 + tests/test_cli.py | 13 ++++++------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index 09eb0be..f03348f 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -841,12 +841,12 @@ See :ref:`cli_create_index`. sqlite-utils create-index chickens.db chickens -- -name Options: - --name TEXT Explicit name for the new index - --unique Make this a unique index - --if-not-exists Ignore if index already exists - --analyze Run ANALYZE after creating the index - --load-extension TEXT SQLite extensions to load - -h, --help Show this message and exit. + --name TEXT Explicit name for the new index + --unique Make this a unique index + --if-not-exists, --ignore Ignore if index already exists + --analyze Run ANALYZE after creating the index + --load-extension TEXT SQLite extensions to load + -h, --help Show this message and exit. enable-fts diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index cf00898..999658d 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -539,6 +539,7 @@ def index_foreign_keys(path, load_extension): @click.option("--unique", help="Make this a unique index", default=False, is_flag=True) @click.option( "--if-not-exists", + "--ignore", help="Ignore if index already exists", default=False, is_flag=True, diff --git a/tests/test_cli.py b/tests/test_cli.py index 01cc9bb..e98ffc1 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -214,13 +214,12 @@ def test_create_index(db_path): ] == db["Gosh2"].indexes # Trying to create the same index should fail assert 0 != CliRunner().invoke(cli.cli, create_index_unique_args).exit_code - # ... unless we use --if-not-exists - assert ( - 0 - == CliRunner() - .invoke(cli.cli, create_index_unique_args + ["--if-not-exists"]) - .exit_code - ) + # ... unless we use --if-not-exists or --ignore + for option in ("--if-not-exists", "--ignore"): + assert ( + CliRunner().invoke(cli.cli, create_index_unique_args + [option]).exit_code + == 0 + ) def test_create_index_analyze(db_path): From 5fa823f03ff2117020ae7fd56198ca7d50130574 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 15 Jul 2022 15:31:37 -0700 Subject: [PATCH 190/563] add-column --ignore option, refs #450 --- docs/cli-reference.rst | 1 + sqlite_utils/cli.py | 25 +++++++++++++++++++++---- tests/test_cli.py | 13 +++++++++++++ 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index f03348f..6d6f837 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -1031,6 +1031,7 @@ See :ref:`cli_add_column`. --fk-col TEXT Referenced column on that foreign key table - if omitted will automatically use the primary key --not-null-default TEXT Add NOT NULL DEFAULT 'TEXT' constraint + --ignore If column already exists, do nothing --load-extension TEXT SQLite extensions to load -h, --help Show this message and exit. diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 999658d..de223af 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -418,9 +418,22 @@ def dump(path, load_extension): required=False, help="Add NOT NULL DEFAULT 'TEXT' constraint", ) +@click.option( + "--ignore", + is_flag=True, + help="If column already exists, do nothing", +) @load_extension_option def add_column( - path, table, col_name, col_type, fk, fk_col, not_null_default, load_extension + path, + table, + col_name, + col_type, + fk, + fk_col, + not_null_default, + ignore, + load_extension, ): """Add a column to the specified table @@ -431,9 +444,13 @@ def add_column( """ db = sqlite_utils.Database(path) _load_extensions(db, load_extension) - db[table].add_column( - col_name, col_type, fk=fk, fk_col=fk_col, not_null_default=not_null_default - ) + try: + db[table].add_column( + col_name, col_type, fk=fk, fk_col=fk_col, not_null_default=not_null_default + ) + except OperationalError as ex: + if not ignore: + raise click.ClickException(str(ex)) @cli.command(name="add-foreign-key") diff --git a/tests/test_cli.py b/tests/test_cli.py index e98ffc1..3694fd7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -271,6 +271,19 @@ def test_add_column(db_path, col_name, col_type, expected_schema): assert expected_schema == collapse_whitespace(db["dogs"].schema) +@pytest.mark.parametrize("ignore", (True, False)) +def test_add_column_ignore(db_path, ignore): + db = Database(db_path) + db.create_table("dogs", {"name": str}) + args = ["add-column", db_path, "dogs", "name"] + (["--ignore"] if ignore else []) + result = CliRunner().invoke(cli.cli, args) + if ignore: + assert result.exit_code == 0 + else: + assert result.exit_code == 1 + assert result.output == "Error: duplicate column name: name\n" + + def test_add_column_not_null_default(db_path): db = Database(db_path) db.create_table("dogs", {"name": str}) From b9a89a0f2c3559989efe65f25a6e1f8fa76fe8b0 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 15 Jul 2022 15:35:58 -0700 Subject: [PATCH 191/563] duplicate --ignore option, refs #450 --- docs/cli-reference.rst | 1 + sqlite_utils/cli.py | 6 ++++-- tests/test_cli.py | 7 +++++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index 6d6f837..86225aa 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -1188,6 +1188,7 @@ duplicate Create a duplicate of this table, copying across the schema and all row data. Options: + --ignore If table does not exist, do nothing --load-extension TEXT SQLite extensions to load -h, --help Show this message and exit. diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index de223af..fe57fbe 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -1501,8 +1501,9 @@ def create_table( ) @click.argument("table") @click.argument("new_table") +@click.option("--ignore", is_flag=True, help="If table does not exist, do nothing") @load_extension_option -def duplicate(path, table, new_table, load_extension): +def duplicate(path, table, new_table, ignore, load_extension): """ Create a duplicate of this table, copying across the schema and all row data. """ @@ -1511,7 +1512,8 @@ def duplicate(path, table, new_table, load_extension): try: db[table].duplicate(new_table) except NoTable: - raise click.ClickException('Table "{}" does not exist'.format(table)) + if not ignore: + raise click.ClickException('Table "{}" does not exist'.format(table)) @cli.command(name="drop-table") diff --git a/tests/test_cli.py b/tests/test_cli.py index 3694fd7..061a578 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2180,6 +2180,13 @@ def test_duplicate_table(tmpdir): ) assert result_error.exit_code == 1 assert result_error.output == 'Error: Table "missing" does not exist\n' + # And check --ignore works + result_error2 = CliRunner().invoke( + cli.cli, + ["duplicate", db_path, "missing", "two", "--ignore"], + catch_exceptions=False, + ) + assert result_error2.exit_code == 0 # Now try for a table that exists result = CliRunner().invoke( cli.cli, From 855bce8c3823718def13e0b8928c58bf857e41b2 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 15 Jul 2022 15:56:01 -0700 Subject: [PATCH 192/563] Release 3.28 Refs #441, #443, #445, #449, #450, #454 --- docs/changelog.rst | 13 +++++++++++++ setup.py | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index b284eb8..fc2b4e9 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -2,6 +2,19 @@ Changelog =========== +.. _v3_28: + +3.28 (2022-07-15) +----------------- + +- New :ref:`table.duplicate(new_name) ` method for creating a copy of a table with a matching schema and row contents. Thanks, `David `__. (:issue:`449`) +- New ``sqlite-utils duplicate data.db table_name new_name`` CLI command for :ref:`cli_duplicate_table`. (:issue:`454`) +- ``sqlite_utils.utils.rows_from_file()`` is now a :ref:`documented API `. It can be used to read a sequence of dictionaries from a file-like object containing CSV, TSV, JSON or newline-delimited JSON. It can be passed an explicit format or can attempt to detect the format automatically. (:issue:`443`) +- ``sqlite_utils.utils.TypeTracker`` is now a documented API for detecting the likely column types for a sequence of string rows, see :ref:`python_api_typetracker`. (:issue:`445`) +- ``sqlite_utils.utils.chunks()`` is now a documented API for :ref:`splitting an iterator into chunks `. (:issue:`441`) +- ``sqlite-utils enable-fts`` now has a ``--replace`` option for replacing the existing FTS configuration for a table. (:issue:`450`) +- The ``create-index``, ``add-column`` and ``duplicate`` commands all now take a ``--ignore`` option for ignoring errors should the database not be in the right state for them to operate. (:issue:`450`) + .. _v3_27: 3.27 (2022-06-14) diff --git a/setup.py b/setup.py index a416570..019d44e 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages import io import os -VERSION = "3.27" +VERSION = "3.28" def get_long_description(): From 9e6cceac1c0e086429e2d308b700e59cc53a1991 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 20 Jul 2022 16:09:53 -0700 Subject: [PATCH 193/563] Fixed incorrect issue number --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index fc2b4e9..b8f35c5 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -11,7 +11,7 @@ - New ``sqlite-utils duplicate data.db table_name new_name`` CLI command for :ref:`cli_duplicate_table`. (:issue:`454`) - ``sqlite_utils.utils.rows_from_file()`` is now a :ref:`documented API `. It can be used to read a sequence of dictionaries from a file-like object containing CSV, TSV, JSON or newline-delimited JSON. It can be passed an explicit format or can attempt to detect the format automatically. (:issue:`443`) - ``sqlite_utils.utils.TypeTracker`` is now a documented API for detecting the likely column types for a sequence of string rows, see :ref:`python_api_typetracker`. (:issue:`445`) -- ``sqlite_utils.utils.chunks()`` is now a documented API for :ref:`splitting an iterator into chunks `. (:issue:`441`) +- ``sqlite_utils.utils.chunks()`` is now a documented API for :ref:`splitting an iterator into chunks `. (:issue:`451`) - ``sqlite-utils enable-fts`` now has a ``--replace`` option for replacing the existing FTS configuration for a table. (:issue:`450`) - The ``create-index``, ``add-column`` and ``duplicate`` commands all now take a ``--ignore`` option for ignoring errors should the database not be in the right state for them to operate. (:issue:`450`) From 77ca051d4f5ddbd42fd6250749efac7ea85ea094 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 27 Jul 2022 10:57:50 -0700 Subject: [PATCH 194/563] Link to installation instructions (#457) --- docs/cli.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cli.rst b/docs/cli.rst index e126881..e3b1294 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -6,7 +6,7 @@ The ``sqlite-utils`` command-line tool can be used to manipulate SQLite databases in a number of different ways. -Once installed, the tool should be available as ``sqlite-utils``. It can also be run using ``python -m sqlite_utils``. +Once :ref:`installed ` the tool should be available as ``sqlite-utils``. It can also be run using ``python -m sqlite_utils``. .. contents:: :local: :class: this-will-duplicate-information-and-it-is-still-useful-here From bfbe69646e67322dde328b07109f77c68463dc71 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 27 Jul 2022 17:07:23 -0700 Subject: [PATCH 195/563] Fixed incorrect register_function code example --- sqlite_utils/db.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index a2a30f9..ca1f90e 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -373,13 +373,13 @@ class Database: ``fn`` will be made available as a function within SQL, with the same name and number of arguments. Can be used as a decorator:: - @db.register + @db.register_function def upper(value): return str(value).upper() The decorator can take arguments:: - @db.register(deterministic=True, replace=True) + @db.register_function(deterministic=True, replace=True) def upper(value): return str(value).upper() From 1491b66dd7439dd87cd5cd4c4684f46eb3c5751b Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 27 Jul 2022 17:13:49 -0700 Subject: [PATCH 196/563] register_function(name=...) argument, closes #458 --- docs/python-api.rst | 10 ++++++++++ sqlite_utils/db.py | 17 +++++++++++------ tests/test_register_function.py | 9 +++++++++ 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/docs/python-api.rst b/docs/python-api.rst index 464afb2..07f2bf6 100644 --- a/docs/python-api.rst +++ b/docs/python-api.rst @@ -2513,6 +2513,16 @@ You can also use the method as a function decorator like so: print(db.execute('select reverse_string("hello")').fetchone()[0]) +By default, the name of the Python function will be used as the name of the SQL function. You can customize this with the ``name=`` keyword argument: + +.. code-block:: python + + @db.register_function(name="rev") + def reverse_string(s): + return "".join(reversed(list(s))) + + print(db.execute('select rev("hello")').fetchone()[0]) + Python 3.8 added the ability to register `deterministic SQLite functions `__, allowing you to indicate that a function will return the exact same result for any given inputs and hence allowing SQLite to apply some performance optimizations. You can mark a function as deterministic using ``deterministic=True``, like this: .. code-block:: python diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index ca1f90e..0533bd1 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -367,7 +367,11 @@ class Database: return "".format(self.conn) def register_function( - self, fn: Callable = None, deterministic: bool = False, replace: bool = False + self, + fn: Callable = None, + deterministic: bool = False, + replace: bool = False, + name: Optional[str] = None, ): """ ``fn`` will be made available as a function within SQL, with the same name and number @@ -388,12 +392,13 @@ class Database: :param fn: Function to register :param deterministic: set ``True`` for functions that always returns the same output for a given input :param replace: set ``True`` to replace an existing function with the same name - otherwise throw an error + :param name: name of the SQLite function - if not specified, the Python function name will be used """ def register(fn): - name = fn.__name__ + fn_name = name or fn.__name__ arity = len(inspect.signature(fn).parameters) - if not replace and (name, arity) in self._registered_functions: + if not replace and (fn_name, arity) in self._registered_functions: return fn kwargs = {} registered = False @@ -401,15 +406,15 @@ class Database: # Try this, but fall back if sqlite3.NotSupportedError try: self.conn.create_function( - name, arity, fn, **dict(kwargs, deterministic=True) + fn_name, arity, fn, **dict(kwargs, deterministic=True) ) registered = True except (sqlite3.NotSupportedError, TypeError): # TypeError is Python 3.7 "function takes at most 3 arguments" pass if not registered: - self.conn.create_function(name, arity, fn, **kwargs) - self._registered_functions.add((name, arity)) + self.conn.create_function(fn_name, arity, fn, **kwargs) + self._registered_functions.add((fn_name, arity)) return fn if fn is None: diff --git a/tests/test_register_function.py b/tests/test_register_function.py index 615f38f..5169a67 100644 --- a/tests/test_register_function.py +++ b/tests/test_register_function.py @@ -14,6 +14,15 @@ def test_register_function(fresh_db): assert result == "olleh" +def test_register_function_custom_name(fresh_db): + @fresh_db.register_function(name="revstr") + def reverse_string(s): + return "".join(reversed(list(s))) + + result = fresh_db.execute('select revstr("hello")').fetchone()[0] + assert result == "olleh" + + def test_register_function_multiple_arguments(fresh_db): @fresh_db.register_function def a_times_b_plus_c(a, b, c): From 573de14ab6f4cb23528b97d85578f21eb1ae04d0 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 27 Jul 2022 17:28:46 -0700 Subject: [PATCH 197/563] Improved docstring comments for Table class and db.table() --- sqlite_utils/db.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index 0533bd1..1a15d1c 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -488,6 +488,8 @@ class Database: """ Return a table object, optionally configured with default options. + See :ref:`reference_db_table` for option details. + :param table_name: Name of the table """ klass = View if table_name in self.view_names() else Table @@ -1242,7 +1244,29 @@ class Queryable: class Table(Queryable): - "Tables should usually be initialized using the ``db.table(table_name)`` or ``db[table_name]`` methods." + """ + Tables should usually be initialized using the ``db.table(table_name)`` or + ``db[table_name]`` methods. + + The following optional parameters can be passed to ``db.table(table_name, ...)``: + + :param db: Provided by ``db.table(table_name)`` + :param name: Provided by ``db.table(table_name)`` + :param pk: Name of the primary key column, or tuple of columns + :param foreign_keys: List of foreign key definitions + :param column_order: List of column names in the order they should be in the table + :param not_null: List of columns that cannot be null + :param defaults: Dictionary of column names and default values + :param batch_size: Integer number of rows to insert at a time + :param hash_id: If True, use a hash of the row values as the primary key + :param hash_id_columns: List of columns to use for the hash_id + :param alter: If True, automatically alter the table if it doesn't match the schema + :param ignore: If True, ignore rows that already exist when inserting + :param replace: If True, replace rows that already exist when inserting + :param extracts: Dictionary or list of column names to extract into a separate table on inserts + :param conversions: Dictionary of column names and conversion functions + :param columns: Dictionary of column names to column types + """ #: The ``rowid`` of the last inserted, updated or selected row. last_rowid: Optional[int] = None #: The primary key of the last inserted, updated or selected row. From b5e902fcb02953f0f1fc4c652a24c262559a37d7 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 28 Jul 2022 08:13:47 -0700 Subject: [PATCH 198/563] Applied Black --- sqlite_utils/db.py | 1 + 1 file changed, 1 insertion(+) diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index 1a15d1c..18a442a 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -1267,6 +1267,7 @@ class Table(Queryable): :param conversions: Dictionary of column names and conversion functions :param columns: Dictionary of column names to column types """ + #: The ``rowid`` of the last inserted, updated or selected row. last_rowid: Optional[int] = None #: The primary key of the last inserted, updated or selected row. From 1acc04c07124b17da0ca0cfbf34f38664d29fb7f Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 31 Jul 2022 12:12:37 -0700 Subject: [PATCH 199/563] Link to new tutorial Refs https://github.com/simonw/datasette.io/issues/108 --- docs/index.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/index.rst b/docs/index.rst index 49bf8b1..430244e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -23,6 +23,8 @@ sqlite-utils is not intended to be a full ORM: the focus is utility helpers to m It is designed as a useful complement to `Datasette `_. +`Cleaning data with sqlite-utils and Datasette `_ provides a tutorial introduction (and accompanying ten minute video) about using this tool. + Contents -------- From 1856002e3c0fcc9f09f72ab7d97ad8c75f6de7df Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 2 Aug 2022 09:02:43 -0700 Subject: [PATCH 200/563] readthedocs/readthedocs-preview Tip from https://twitter.com/readthedocs/status/1552354156056395778 --- .github/workflows/documentation-links.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .github/workflows/documentation-links.yml diff --git a/.github/workflows/documentation-links.yml b/.github/workflows/documentation-links.yml new file mode 100644 index 0000000..4010a44 --- /dev/null +++ b/.github/workflows/documentation-links.yml @@ -0,0 +1,16 @@ +name: Read the Docs Pull Request Preview +on: + pull_request_target: + types: + - opened + +permissions: + pull-requests: write + +jobs: + documentation-links: + runs-on: ubuntu-latest + steps: + - uses: readthedocs/readthedocs-preview@main + with: + project-slug: "readthedocs-preview" From 98a28cbfe6cea67f6334b42b74f35b0ddd309561 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 2 Aug 2022 13:35:56 -0700 Subject: [PATCH 201/563] Oops, fixed project slug Refs: - https://github.com/readthedocs/readthedocs-preview/issues/10 - https://github.com/simonw/sqlite-utils/pull/460 --- .github/workflows/documentation-links.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/documentation-links.yml b/.github/workflows/documentation-links.yml index 4010a44..0069e4c 100644 --- a/.github/workflows/documentation-links.yml +++ b/.github/workflows/documentation-links.yml @@ -13,4 +13,4 @@ jobs: steps: - uses: readthedocs/readthedocs-preview@main with: - project-slug: "readthedocs-preview" + project-slug: "sqlite-utils" From 271433fdd18e436b0a527ab899cb6f6fa67f23d0 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 2 Aug 2022 14:15:52 -0700 Subject: [PATCH 202/563] Discord badge (#462) --- README.md | 1 + docs/index.rst | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6f81fc1..49a26ed 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ [![Documentation Status](https://readthedocs.org/projects/sqlite-utils/badge/?version=stable)](http://sqlite-utils.datasette.io/en/stable/?badge=stable) [![codecov](https://codecov.io/gh/simonw/sqlite-utils/branch/main/graph/badge.svg)](https://codecov.io/gh/simonw/sqlite-utils) [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/simonw/sqlite-utils/blob/main/LICENSE) +[![discord](https://img.shields.io/discord/823971286308356157?label=discord)](https://discord.gg/Ass7bCAMDw) Python CLI utility and library for manipulating SQLite databases. diff --git a/docs/index.rst b/docs/index.rst index 430244e..6793255 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -2,7 +2,7 @@ sqlite-utils |version| ======================= -|PyPI| |Changelog| |CI| |License| +|PyPI| |Changelog| |CI| |License| |discord| .. |PyPI| image:: https://img.shields.io/pypi/v/sqlite-utils.svg :target: https://pypi.org/project/sqlite-utils/ @@ -12,6 +12,8 @@ :target: https://github.com/simonw/sqlite-utils/actions .. |License| image:: https://img.shields.io/badge/license-Apache%202.0-blue.svg :target: https://github.com/simonw/sqlite-utils/blob/main/LICENSE +.. |discord| image:: https://img.shields.io/discord/823971286308356157?label=discord + :target: https://discord.gg/Ass7bCAMDw *CLI tool and Python utility functions for manipulating SQLite databases* From 45e24deffea042b5db7ab84cd1eb63b3ed9bb9da Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 13 Aug 2022 09:24:02 -0700 Subject: [PATCH 203/563] Link API docs to GitHub source code, refs #464 --- docs/conf.py | 20 +++++++++++++++++++- setup.py | 8 +++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index d9ebc4b..1339477 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -2,6 +2,7 @@ # -*- coding: utf-8 -*- from subprocess import Popen, PIPE +from beanbag_docutils.sphinx.ext.github import github_linkcode_resolve # This file is execfile()d with the current directory set to its # containing dir. @@ -30,7 +31,12 @@ from subprocess import Popen, PIPE # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. -extensions = ["sphinx.ext.extlinks", "sphinx.ext.autodoc", "sphinx_copybutton"] +extensions = [ + "sphinx.ext.extlinks", + "sphinx.ext.autodoc", + "sphinx_copybutton", + "sphinx.ext.linkcode", +] autodoc_member_order = "bysource" autodoc_typehints = "description" @@ -38,6 +44,18 @@ extlinks = { "issue": ("https://github.com/simonw/sqlite-utils/issues/%s", "#"), } + +def linkcode_resolve(domain, info): + return github_linkcode_resolve( + domain=domain, + info=info, + allowed_module_names=["sqlite_utils"], + github_org_id="simonw", + github_repo_id="sqlite-utils", + branch="main", + ) + + # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] diff --git a/setup.py b/setup.py index 019d44e..8b8434a 100644 --- a/setup.py +++ b/setup.py @@ -32,7 +32,13 @@ setup( ], extras_require={ "test": ["pytest", "black", "hypothesis", "cogapp"], - "docs": ["furo", "sphinx-autobuild", "codespell", "sphinx-copybutton"], + "docs": [ + "furo", + "sphinx-autobuild", + "codespell", + "sphinx-copybutton", + "beanbag-docutils @ https://github.com/simonw/beanbag-docutils/archive/refs/heads/bytes-in-url.zip", + ], "mypy": [ "mypy", "types-click", From 83e7339255e811c62e6db8498c483c44a84d0f28 Mon Sep 17 00:00:00 2001 From: Manuel Kaufmann Date: Thu, 18 Aug 2022 01:11:15 +0200 Subject: [PATCH 204/563] Use Read the Docs action v1 (#463) Read the Docs repository was renamed from `readthedocs/readthedocs-preview` to `readthedocs/actions/`. Now, the `preview` action is under `readthedocs/actions/preview` and is tagged as `v1` --- .github/workflows/documentation-links.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/documentation-links.yml b/.github/workflows/documentation-links.yml index 0069e4c..1aab04e 100644 --- a/.github/workflows/documentation-links.yml +++ b/.github/workflows/documentation-links.yml @@ -11,6 +11,6 @@ jobs: documentation-links: runs-on: ubuntu-latest steps: - - uses: readthedocs/readthedocs-preview@main + - uses: readthedocs/actions/preview@v1 with: project-slug: "sqlite-utils" From f8ffac8787e299a62c99ed1ce914cd5ace84ad94 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 17 Aug 2022 16:38:02 -0700 Subject: [PATCH 205/563] beanbag-docutils>=2.0 (#465) * beanbag-docutils>=2.0 Closes #464 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 8b8434a..ead463b 100644 --- a/setup.py +++ b/setup.py @@ -37,7 +37,7 @@ setup( "sphinx-autobuild", "codespell", "sphinx-copybutton", - "beanbag-docutils @ https://github.com/simonw/beanbag-docutils/archive/refs/heads/bytes-in-url.zip", + "beanbag-docutils>=2.0", ], "mypy": [ "mypy", From f4fb78fa95057fbc86c734020835a3155695297f Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 18 Aug 2022 14:58:07 -0700 Subject: [PATCH 206/563] Cross-link CLI to Python docs (#460) * Start cross-linking CLI to Python docs, refs #426 * More links to Python from CLI page, refs #426 --- docs/cli-reference.rst | 85 ++++++++++++++++++++++++++++++++++++++++++ docs/cli.rst | 25 +++++++++++++ 2 files changed, 110 insertions(+) diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index 86225aa..2590595 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -63,6 +63,7 @@ This page lists the ``--help`` for every ``sqlite-utils`` CLI sub-command. commands.sort(key = lambda command: go_first.index(command) if command in go_first else 999) cog.out("\n") for command in commands: + cog.out(".. _cli_ref_" + command.replace("-", "_") + ":\n\n") cog.out(command + "\n") cog.out(("=" * len(command)) + "\n\n") if command in refs: @@ -81,6 +82,8 @@ This page lists the ``--help`` for every ``sqlite-utils`` CLI sub-command. cog.out("\n\n") .. ]]] +.. _cli_ref_query: + query ===== @@ -120,6 +123,8 @@ See :ref:`cli_query`. -h, --help Show this message and exit. +.. _cli_ref_memory: + memory ====== @@ -184,6 +189,8 @@ See :ref:`cli_memory`. -h, --help Show this message and exit. +.. _cli_ref_insert: + insert ====== @@ -264,6 +271,8 @@ See :ref:`cli_inserting_data`, :ref:`cli_insert_csv_tsv`, :ref:`cli_insert_unstr -h, --help Show this message and exit. +.. _cli_ref_upsert: + upsert ====== @@ -312,6 +321,8 @@ See :ref:`cli_upsert`. -h, --help Show this message and exit. +.. _cli_ref_bulk: + bulk ==== @@ -352,6 +363,8 @@ See :ref:`cli_bulk`. -h, --help Show this message and exit. +.. _cli_ref_search: + search ====== @@ -390,6 +403,8 @@ See :ref:`cli_search`. -h, --help Show this message and exit. +.. _cli_ref_transform: + transform ========= @@ -424,6 +439,8 @@ See :ref:`cli_transform_table`. -h, --help Show this message and exit. +.. _cli_ref_extract: + extract ======= @@ -447,6 +464,8 @@ See :ref:`cli_extract`. -h, --help Show this message and exit. +.. _cli_ref_schema: + schema ====== @@ -467,6 +486,8 @@ See :ref:`cli_schema`. -h, --help Show this message and exit. +.. _cli_ref_insert_files: + insert-files ============ @@ -503,6 +524,8 @@ See :ref:`cli_insert_files`. -h, --help Show this message and exit. +.. _cli_ref_analyze_tables: + analyze-tables ============== @@ -525,6 +548,8 @@ See :ref:`cli_analyze_tables`. -h, --help Show this message and exit. +.. _cli_ref_convert: + convert ======= @@ -590,6 +615,8 @@ See :ref:`cli_convert`. -h, --help Show this message and exit. +.. _cli_ref_tables: + tables ====== @@ -628,6 +655,8 @@ See :ref:`cli_tables`. -h, --help Show this message and exit. +.. _cli_ref_views: + views ===== @@ -664,6 +693,8 @@ See :ref:`cli_views`. -h, --help Show this message and exit. +.. _cli_ref_rows: + rows ==== @@ -702,6 +733,8 @@ See :ref:`cli_rows`. -h, --help Show this message and exit. +.. _cli_ref_triggers: + triggers ======== @@ -735,6 +768,8 @@ See :ref:`cli_triggers`. -h, --help Show this message and exit. +.. _cli_ref_indexes: + indexes ======= @@ -769,6 +804,8 @@ See :ref:`cli_indexes`. -h, --help Show this message and exit. +.. _cli_ref_create_database: + create-database =============== @@ -791,6 +828,8 @@ See :ref:`cli_create_database`. -h, --help Show this message and exit. +.. _cli_ref_create_table: + create-table ============ @@ -821,6 +860,8 @@ See :ref:`cli_create_table`. -h, --help Show this message and exit. +.. _cli_ref_create_index: + create-index ============ @@ -849,6 +890,8 @@ See :ref:`cli_create_index`. -h, --help Show this message and exit. +.. _cli_ref_enable_fts: + enable-fts ========== @@ -875,6 +918,8 @@ See :ref:`cli_fts`. -h, --help Show this message and exit. +.. _cli_ref_populate_fts: + populate-fts ============ @@ -893,6 +938,8 @@ populate-fts -h, --help Show this message and exit. +.. _cli_ref_rebuild_fts: + rebuild-fts =========== @@ -911,6 +958,8 @@ rebuild-fts -h, --help Show this message and exit. +.. _cli_ref_disable_fts: + disable-fts =========== @@ -929,6 +978,8 @@ disable-fts -h, --help Show this message and exit. +.. _cli_ref_optimize: + optimize ======== @@ -951,6 +1002,8 @@ See :ref:`cli_optimize`. -h, --help Show this message and exit. +.. _cli_ref_analyze: + analyze ======= @@ -971,6 +1024,8 @@ See :ref:`cli_analyze`. -h, --help Show this message and exit. +.. _cli_ref_vacuum: + vacuum ====== @@ -990,6 +1045,8 @@ See :ref:`cli_vacuum`. -h, --help Show this message and exit. +.. _cli_ref_dump: + dump ==== @@ -1010,6 +1067,8 @@ See :ref:`cli_dump`. -h, --help Show this message and exit. +.. _cli_ref_add_column: + add-column ========== @@ -1036,6 +1095,8 @@ See :ref:`cli_add_column`. -h, --help Show this message and exit. +.. _cli_ref_add_foreign_key: + add-foreign-key =============== @@ -1060,6 +1121,8 @@ See :ref:`cli_add_foreign_key`. -h, --help Show this message and exit. +.. _cli_ref_add_foreign_keys: + add-foreign-keys ================ @@ -1082,6 +1145,8 @@ See :ref:`cli_add_foreign_keys`. -h, --help Show this message and exit. +.. _cli_ref_index_foreign_keys: + index-foreign-keys ================== @@ -1102,6 +1167,8 @@ See :ref:`cli_index_foreign_keys`. -h, --help Show this message and exit. +.. _cli_ref_enable_wal: + enable-wal ========== @@ -1122,6 +1189,8 @@ See :ref:`cli_wal`. -h, --help Show this message and exit. +.. _cli_ref_disable_wal: + disable-wal =========== @@ -1140,6 +1209,8 @@ disable-wal -h, --help Show this message and exit. +.. _cli_ref_enable_counts: + enable-counts ============= @@ -1160,6 +1231,8 @@ See :ref:`cli_enable_counts`. -h, --help Show this message and exit. +.. _cli_ref_reset_counts: + reset-counts ============ @@ -1178,6 +1251,8 @@ reset-counts -h, --help Show this message and exit. +.. _cli_ref_duplicate: + duplicate ========= @@ -1193,6 +1268,8 @@ duplicate -h, --help Show this message and exit. +.. _cli_ref_drop_table: + drop-table ========== @@ -1214,6 +1291,8 @@ See :ref:`cli_drop_table`. -h, --help Show this message and exit. +.. _cli_ref_create_view: + create-view =========== @@ -1237,6 +1316,8 @@ See :ref:`cli_create_view`. -h, --help Show this message and exit. +.. _cli_ref_drop_view: + drop-view ========= @@ -1258,6 +1339,8 @@ See :ref:`cli_drop_view`. -h, --help Show this message and exit. +.. _cli_ref_add_geometry_column: + add-geometry-column =================== @@ -1287,6 +1370,8 @@ See :ref:`cli_spatialite`. -h, --help Show this message and exit. +.. _cli_ref_create_spatial_index: + create-spatial-index ==================== diff --git a/docs/cli.rst b/docs/cli.rst index e3b1294..d2d1812 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -21,6 +21,9 @@ The ``sqlite-utils query`` command lets you run queries directly against a SQLit $ sqlite-utils query dogs.db "select * from dogs" $ sqlite-utils dogs.db "select * from dogs" +.. note:: + In Python: :ref:`db.query() ` CLI reference: :ref:`sqlite-utils query ` + .. _cli_query_json: Returning JSON @@ -257,6 +260,7 @@ You can load SQLite extension modules using the ``--load-extension`` option, see $ sqlite-utils dogs.db "select spatialite_version()" --load-extension=spatialite [{"spatialite_version()": "4.3.0a"}] + .. _cli_query_attach: Attaching additional databases @@ -271,6 +275,9 @@ This example attaches the ``books.db`` database under the alias ``books`` and th sqlite-utils dogs.db --attach books books.db \ 'select * from sqlite_master union all select * from books.sqlite_master' +.. note:: + In Python: :ref:`db.attach() ` + .. _cli_memory: Querying data directly using an in-memory database @@ -462,6 +469,9 @@ Or pass named parameters using ``--where`` in combination with ``-p``:: Use ``--limit N`` to only return the first ``N`` rows. Use ``--offset N`` to return rows starting from the specified offset. +.. note:: + In Python: :ref:`table.rows ` CLI reference: :ref:`sqlite-utils rows ` + .. _cli_tables: Listing tables @@ -514,6 +524,9 @@ Use ``--schema`` to include the schema of each table:: The ``--nl``, ``--csv``, ``--tsv``, ``--table`` and ``--fmt`` options are also available. +.. note:: + In Python: :ref:`db.tables or db.table_names() ` CLI reference: :ref:`sqlite-utils tables ` + .. _cli_views: Listing views @@ -537,6 +550,9 @@ It takes the same options as the ``tables`` command: * ``--tsv`` * ``--table`` +.. note:: + In Python: :ref:`db.views or db.view_names() ` CLI reference: :ref:`sqlite-utils views ` + .. _cli_indexes: Listing indexes @@ -564,6 +580,9 @@ The command defaults to only showing the columns that are explicitly part of the The command takes the same format options as the ``tables`` and ``views`` commands. +.. note:: + In Python: :ref:`table.indexes ` CLI reference: :ref:`sqlite-utils indexes ` + .. _cli_triggers: Listing triggers @@ -592,6 +611,9 @@ It defaults to showing triggers for all tables. To see triggers for one or more The command takes the same format options as the ``tables`` and ``views`` commands. +.. note:: + In Python: :ref:`table.triggers or db.triggers ` CLI reference: :ref:`sqlite-utils triggers ` + .. _cli_schema: Showing the schema @@ -610,6 +632,9 @@ This will show the schema for every table and index in the database. To view the $ sqlite-utils schema dogs.db dogs chickens ... +.. note:: + In Python: :ref:`table.schema ` or :ref:`db.schema ` CLI reference: :ref:`sqlite-utils schema ` + .. _cli_analyze_tables: Analyzing tables From 7a9a6363ffc1b4f1a9444a22999addabfa756c54 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 26 Aug 2022 21:10:20 -0700 Subject: [PATCH 207/563] sqlite-utils rows --order option, closes #469 --- docs/cli-reference.rst | 1 + docs/cli.rst | 2 ++ sqlite_utils/cli.py | 4 ++++ tests/test_cli.py | 9 +++++++++ 4 files changed, 16 insertions(+) diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index 2590595..c0f794c 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -713,6 +713,7 @@ See :ref:`cli_rows`. Options: -c, --column TEXT Columns to return --where TEXT Optional where clause + -o, --order TEXT Order by ('column' or 'column desc') -p, --param ... Named :parameters for where clause --limit INTEGER Number of rows to return - defaults to everything --offset INTEGER SQL offset to use diff --git a/docs/cli.rst b/docs/cli.rst index d2d1812..8cb2c56 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -467,6 +467,8 @@ Or pass named parameters using ``--where`` in combination with ``-p``:: $ sqlite-utils rows dogs.db dogs -c name --where 'name = :name' -p name Cleo [{"name": "Cleo"}] +You can define a sort order using ``--order column`` or ``--order 'column desc'``. + Use ``--limit N`` to only return the first ``N`` rows. Use ``--offset N`` to return rows starting from the specified offset. .. note:: diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index fe57fbe..43e76fa 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -1985,6 +1985,7 @@ def search( @click.argument("dbtable") @click.option("-c", "--column", type=str, multiple=True, help="Columns to return") @click.option("--where", help="Optional where clause") +@click.option("-o", "--order", type=str, help="Order by ('column' or 'column desc')") @click.option( "-p", "--param", @@ -2011,6 +2012,7 @@ def rows( dbtable, column, where, + order, param, limit, offset, @@ -2037,6 +2039,8 @@ def rows( sql = "select {} from [{}]".format(columns, dbtable) if where: sql += " where " + where + if order: + sql += " order by " + order if limit: sql += " limit {}".format(limit) if offset: diff --git a/tests/test_cli.py b/tests/test_cli.py index 061a578..4eadb88 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -881,6 +881,15 @@ def test_query_memory_does_not_create_file(tmpdir): ["-c", "name", "--where", "id = :id", "--param", "id", "1"], '[{"name": "Cleo"}]', ), + # --order + ( + ["-c", "id", "--order", "id desc", "--limit", "1"], + '[{"id": 2}]', + ), + ( + ["-c", "id", "--order", "id", "--limit", "1"], + '[{"id": 1}]', + ), ], ) def test_rows(db_path, args, expected): From 31f062d4a7e6457bfbe94b2e45a7b80028f1e95c Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 26 Aug 2022 21:53:55 -0700 Subject: [PATCH 208/563] sqlite-utils query --functions option, refs #471 --- docs/cli-reference.rst | 2 ++ docs/cli.rst | 20 +++++++++++++ sqlite_utils/cli.py | 17 +++++++++++ tests/test_cli.py | 68 +++++++++++++++++++++++++++++++++++++++++- 4 files changed, 106 insertions(+), 1 deletion(-) diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index c0f794c..af059f1 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -119,6 +119,8 @@ See :ref:`cli_query`. escaped strings -r, --raw Raw output, first column of first row -p, --param ... Named :parameters for SQL query + --functions TEXT Python code defining one or more custom SQL + functions --load-extension TEXT SQLite extensions to load -h, --help Show this message and exit. diff --git a/docs/cli.rst b/docs/cli.rst index 8cb2c56..1632e2f 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -250,6 +250,26 @@ If you execute an ``UPDATE``, ``INSERT`` or ``DELETE`` query the command will re $ sqlite-utils dogs.db "update dogs set age = 5 where name = 'Cleo'" [{"rows_affected": 1}] +.. _cli_query_functions: + +Defining custom SQL functions +----------------------------- + +You can use the ``--functions`` option to pass a block of Python code that defines additional functions which can then be called by your SQL query. + +This example defines a function which extracts the domain from a URL:: + + $ sqlite-utils query dogs.db "select url, domain(url) from urls" --functions ' + from urllib.parse import urlparse + + def domain(url): + return urlparse(url).netloc + ' + +Every callable object defined in the block will be registered as a SQL function with the same name, with the exception of functions with names that begin with an underscore. + +.. _cli_query_extensions: + SQLite extensions ----------------- diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 43e76fa..1ac8d78 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -1633,6 +1633,9 @@ def drop_view(path, view, ignore, load_extension): type=(str, str), help="Named :parameters for SQL query", ) +@click.option( + "--functions", help="Python code defining one or more custom SQL functions" +) @load_extension_option def query( path, @@ -1649,6 +1652,7 @@ def query( raw, param, load_extension, + functions, ): """Execute SQL query and return the results as JSON @@ -1665,6 +1669,19 @@ def query( _load_extensions(db, load_extension) db.register_fts4_bm25() + # Register any Python functions as SQL functions: + if functions: + sqlite3.enable_callback_tracebacks(True) + globals = {} + try: + exec(functions, globals) + except SyntaxError as ex: + raise click.ClickException("Error in functions definition: {}".format(ex)) + # Register all callables in the locals dict: + for name, value in globals.items(): + if callable(value) and not name.startswith("_"): + db.register_function(value, name=name) + _execute_query( db, sql, param, raw, table, csv, tsv, no_headers, fmt, nl, arrays, json_cols ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 4eadb88..057f129 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -683,6 +683,11 @@ _one_query = "select id, name, age from dogs where id = 1" (_one_query, ["--nl"], '{"id": 1, "name": "Cleo", "age": 4}'), (_one_query, ["--arrays"], '[[1, "Cleo", 4]]'), (_one_query, ["--arrays", "--nl"], '[1, "Cleo", 4]'), + ( + "select id, dog(age) from dogs", + ["--functions", "def dog(i):\n return i * 7"], + '[{"id": 1, "dog(age)": 28},\n {"id": 2, "dog(age)": 14}]', + ), ], ) def test_query_json(db_path, sql, args, expected): @@ -700,11 +705,72 @@ def test_query_json(db_path, sql, args, expected): def test_query_json_empty(db_path): result = CliRunner().invoke( - cli.cli, [db_path, "select * from sqlite_master where 0"] + cli.cli, + [db_path, "select * from sqlite_master where 0"], ) assert result.output.strip() == "[]" +def test_query_invalid_function(db_path): + result = CliRunner().invoke( + cli.cli, [db_path, "select bad()", "--functions", "def invalid_python"] + ) + assert result.exit_code == 1 + assert ( + result.output.strip() + == "Error: Error in functions definition: invalid syntax (, line 1)" + ) + + +TEST_FUNCTIONS = """ +def zero(): + return 0 + +def one(a): + return a + +def _two(a, b): + return a + b + +def two(a, b): + return _two(a, b) +""" + + +def test_query_complex_function(db_path): + result = CliRunner().invoke( + cli.cli, + [ + db_path, + "select zero(), one(1), two(1, 2)", + "--functions", + TEST_FUNCTIONS, + ], + ) + assert result.exit_code == 0 + assert json.loads(result.output.strip()) == [ + {"zero()": 0, "one(1)": 1, "two(1, 2)": 3} + ] + + +def test_hidden_functions_are_hidden(db_path): + result = CliRunner().invoke( + cli.cli, + [ + db_path, + "select name from pragma_function_list()", + "--functions", + TEST_FUNCTIONS, + ], + ) + assert result.exit_code == 0 + functions = {r["name"] for r in json.loads(result.output.strip())} + assert "zero" in functions + assert "one" in functions + assert "two" in functions + assert "_two" not in functions + + LOREM_IPSUM_COMPRESSED = ( b"x\x9c\xed\xd1\xcdq\x03!\x0c\x05\xe0\xbb\xabP\x01\x1eW\x91\xdc|M\x01\n\xc8\x8e" b"f\xf83H\x1e\x97\x1f\x91M\x8e\xe9\xe0\xdd\x96\x05\x84\xf4\xbek\x9fRI\xc7\xf2J" From 85e7411bbd2884e42c65c3e93330f0ddd986be38 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 26 Aug 2022 22:01:58 -0700 Subject: [PATCH 209/563] Skip test if pragma_function_list not supported, refs #471 --- tests/test_cli.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_cli.py b/tests/test_cli.py index 057f129..9b43cd5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -12,6 +12,15 @@ import textwrap from .utils import collapse_whitespace +def _supports_pragma_function_list(): + db = Database(memory=True) + try: + db.execute("select * from pragma_function_list()") + except Exception: + return False + return True + + @pytest.mark.parametrize( "options", ( @@ -753,6 +762,10 @@ def test_query_complex_function(db_path): ] +@pytest.mark.skipif( + not _supports_pragma_function_list(), + reason="Needs SQLite version that supports pragma_function_list()", +) def test_hidden_functions_are_hidden(db_path): result = CliRunner().invoke( cli.cli, From 59e2cfbdc12082bac03e8ac6f99c8c41a4bc72ba Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 26 Aug 2022 22:03:53 -0700 Subject: [PATCH 210/563] sqlite-utils memory --functions, refs #471 --- docs/cli-reference.rst | 2 ++ sqlite_utils/cli.py | 33 ++++++++++++++++++++++----------- tests/test_cli_memory.py | 9 +++++++++ 3 files changed, 33 insertions(+), 11 deletions(-) diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index af059f1..9a025ba 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -161,6 +161,8 @@ See :ref:`cli_memory`. sqlite-utils memory animals.csv --schema Options: + --functions TEXT Python code defining one or more custom SQL + functions --attach ... Additional databases to attach - specify alias and filepath --flatten Flatten nested JSON objects, so {"foo": {"bar": diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 1ac8d78..24c8c03 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -1669,18 +1669,8 @@ def query( _load_extensions(db, load_extension) db.register_fts4_bm25() - # Register any Python functions as SQL functions: if functions: - sqlite3.enable_callback_tracebacks(True) - globals = {} - try: - exec(functions, globals) - except SyntaxError as ex: - raise click.ClickException("Error in functions definition: {}".format(ex)) - # Register all callables in the locals dict: - for name, value in globals.items(): - if callable(value) and not name.startswith("_"): - db.register_function(value, name=name) + _register_functions(db, functions) _execute_query( db, sql, param, raw, table, csv, tsv, no_headers, fmt, nl, arrays, json_cols @@ -1695,6 +1685,9 @@ def query( nargs=-1, ) @click.argument("sql") +@click.option( + "--functions", help="Python code defining one or more custom SQL functions" +) @click.option( "--attach", type=(str, click.Path(file_okay=True, dir_okay=False, allow_dash=False)), @@ -1741,6 +1734,7 @@ def query( def memory( paths, sql, + functions, attach, flatten, nl, @@ -1854,6 +1848,9 @@ def memory( _load_extensions(db, load_extension) db.register_fts4_bm25() + if functions: + _register_functions(db, functions) + _execute_query( db, sql, param, raw, table, csv, tsv, no_headers, fmt, nl, arrays, json_cols ) @@ -2993,3 +2990,17 @@ def _load_extensions(db, load_extension): if ext == "spatialite" and not os.path.exists(ext): ext = find_spatialite() db.conn.load_extension(ext) + + +def _register_functions(db, functions): + # Register any Python functions as SQL functions: + sqlite3.enable_callback_tracebacks(True) + globals = {} + try: + exec(functions, globals) + except SyntaxError as ex: + raise click.ClickException("Error in functions definition: {}".format(ex)) + # Register all callables in the locals dict: + for name, value in globals.items(): + if callable(value) and not name.startswith("_"): + db.register_function(value, name=name) diff --git a/tests/test_cli_memory.py b/tests/test_cli_memory.py index aee74e7..bb50d23 100644 --- a/tests/test_cli_memory.py +++ b/tests/test_cli_memory.py @@ -289,3 +289,12 @@ def test_memory_two_files_with_same_stem(tmpdir): ");\n" "CREATE VIEW t2 AS select * from [data_2];\n" ) + + +def test_memory_functions(): + result = CliRunner().invoke( + cli.cli, + ["memory", "select hello()", "--functions", "hello = lambda: 'Hello'"], + ) + assert result.exit_code == 0 + assert result.output.strip() == '[{"hello()": "Hello"}]' From 23ef1d6c20f6a8ef0db508b9711ae0d8ed6a4156 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 26 Aug 2022 22:10:43 -0700 Subject: [PATCH 211/563] bulk --functions, closes #471 --- docs/cli-reference.rst | 1 + sqlite_utils/cli.py | 8 ++++++++ tests/test_cli_bulk.py | 8 +++++--- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index 9a025ba..c194967 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -349,6 +349,7 @@ See :ref:`cli_bulk`. Options: --batch-size INTEGER Commit every X records + --functions TEXT Python code defining one or more custom SQL functions --flatten Flatten nested JSON objects, so {"a": {"b": 1}} becomes {"a_b": 1} --nl Expect newline-delimited JSON diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 24c8c03..16a99cc 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -926,9 +926,12 @@ def insert_upsert_implementation( load_extension=None, silent=False, bulk_sql=None, + functions=None, ): db = sqlite_utils.Database(path) _load_extensions(db, load_extension) + if functions: + _register_functions(db, functions) if (delimiter or quotechar or sniff or no_headers) and not tsv: csv = True if (nl + csv + tsv) >= 2: @@ -1305,6 +1308,9 @@ def upsert( @click.argument("sql") @click.argument("file", type=click.File("rb"), required=True) @click.option("--batch-size", type=int, default=100, help="Commit every X records") +@click.option( + "--functions", help="Python code defining one or more custom SQL functions" +) @import_options @load_extension_option def bulk( @@ -1312,6 +1318,7 @@ def bulk( sql, file, batch_size, + functions, flatten, nl, csv, @@ -1368,6 +1375,7 @@ def bulk( load_extension=load_extension, silent=False, bulk_sql=sql, + functions=functions, ) except (OperationalError, sqlite3.IntegrityError) as e: raise click.ClickException(str(e)) diff --git a/tests/test_cli_bulk.py b/tests/test_cli_bulk.py index 6d01952..909ed09 100644 --- a/tests/test_cli_bulk.py +++ b/tests/test_cli_bulk.py @@ -28,9 +28,11 @@ def test_cli_bulk(test_db_and_path): [ "bulk", db_path, - "insert into example (id, name) values (:id, :name)", + "insert into example (id, name) values (:id, myupper(:name))", "-", "--nl", + "--functions", + "myupper = lambda s: s.upper()", ], input='{"id": 3, "name": "Three"}\n{"id": 4, "name": "Four"}\n', ) @@ -38,8 +40,8 @@ def test_cli_bulk(test_db_and_path): assert [ {"id": 1, "name": "One"}, {"id": 2, "name": "Two"}, - {"id": 3, "name": "Three"}, - {"id": 4, "name": "Four"}, + {"id": 3, "name": "THREE"}, + {"id": 4, "name": "FOUR"}, ] == list(db["example"].rows) From a46a5e3a9e03dcdd8c84a92e4a5dbfa02ba461fa Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 26 Aug 2022 22:20:09 -0700 Subject: [PATCH 212/563] Improved code compilation pattern, closes #472 --- docs/cli.rst | 3 --- sqlite_utils/utils.py | 9 ++++----- tests/test_cli_convert.py | 20 ++++++++++++++++++++ 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/docs/cli.rst b/docs/cli.rst index 1632e2f..3c5c595 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -1359,12 +1359,9 @@ The following example adds a new ``score`` column, then updates it to list a ran random.seed(10) def convert(value): - global random return random.random() ' -Note the ``global random`` line here. Due to the way the tool compiles Python code, this is necessary to ensure the ``random`` module is available within the ``convert()`` function. If you were to omit this you would see a ``NameError: name 'random' is not defined`` error. - .. _cli_convert_recipes: sqlite-utils convert recipes diff --git a/sqlite_utils/utils.py b/sqlite_utils/utils.py index 64bba50..c0b7bf1 100644 --- a/sqlite_utils/utils.py +++ b/sqlite_utils/utils.py @@ -432,12 +432,11 @@ def progressbar(*args, **kwargs): def _compile_code(code, imports, variable="value"): - locals = {} globals = {"r": recipes, "recipes": recipes} # If user defined a convert() function, return that try: - exec(code, globals, locals) - return locals["convert"] + exec(code, globals) + return globals["convert"] except (AttributeError, SyntaxError, NameError, KeyError, TypeError): pass @@ -464,8 +463,8 @@ def _compile_code(code, imports, variable="value"): for import_ in imports: globals[import_.split(".")[0]] = __import__(import_) - exec(code_o, globals, locals) - return locals["fn"] + exec(code_o, globals) + return globals["fn"] def chunks(sequence: Iterable, size: int) -> Iterable[Iterable]: diff --git a/tests/test_cli_convert.py b/tests/test_cli_convert.py index 10b4563..d26ab8c 100644 --- a/tests/test_cli_convert.py +++ b/tests/test_cli_convert.py @@ -606,3 +606,23 @@ def test_convert_hyphen_workaround(fresh_db_and_path): assert list(db["names"].rows) == [ {"id": 1, "name": "-"}, ] + + +def test_convert_initialization_pattern(fresh_db_and_path): + db, db_path = fresh_db_and_path + db["names"].insert_all([{"id": 1, "name": "Cleo"}], pk="id") + result = CliRunner().invoke( + cli.cli, + [ + "convert", + db_path, + "names", + "name", + "-", + ], + input="import random\nrandom.seed(1)\ndef convert(value): return random.randint(0, 100)", + ) + assert 0 == result.exit_code, result.output + assert list(db["names"].rows) == [ + {"id": 1, "name": "17"}, + ] From 19dd077944429c1365b513d80cc71c605ae3bed3 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 26 Aug 2022 22:55:47 -0700 Subject: [PATCH 213/563] Support entrypoints for `--load-extension` (#473) * Entrypoint support, closes #470 --- .github/workflows/test.yml | 4 ++ .gitignore | 3 ++ docs/cli-reference.rst | 82 ++++++++++++++++++++------------------ sqlite_utils/cli.py | 8 +++- tests/ext.c | 48 ++++++++++++++++++++++ tests/test_cli.py | 41 +++++++++++++++++++ 6 files changed, 145 insertions(+), 41 deletions(-) create mode 100644 tests/ext.c diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 75ef594..9303965 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,6 +35,10 @@ jobs: - name: Install SpatiaLite if: matrix.os == 'ubuntu-latest' run: sudo apt-get install libsqlite3-mod-spatialite + - name: Build extension for --load-extension test + if: matrix.os == 'ubuntu-latest' + run: |- + (cd tests && gcc ext.c -fPIC -shared -o ext.so && ls -lah) - name: Run tests run: | pytest -v diff --git a/.gitignore b/.gitignore index 32032d5..7947f33 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ venv Pipfile Pipfile.lock pyproject.toml +tests/*.dylib +tests/*.so +tests/*.dll diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index c194967..d3951fd 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -121,7 +121,8 @@ See :ref:`cli_query`. -p, --param ... Named :parameters for SQL query --functions TEXT Python code defining one or more custom SQL functions - --load-extension TEXT SQLite extensions to load + --load-extension TEXT Path to SQLite extension, with optional + :entrypoint -h, --help Show this message and exit. @@ -189,7 +190,8 @@ See :ref:`cli_memory`. --dump Dump SQL for in-memory database --save FILE Save in-memory database to this file --analyze Analyze resulting tables and output results - --load-extension TEXT SQLite extensions to load + --load-extension TEXT Path to SQLite extension, with optional + :entrypoint -h, --help Show this message and exit. @@ -266,7 +268,7 @@ See :ref:`cli_inserting_data`, :ref:`cli_insert_csv_tsv`, :ref:`cli_insert_unstr --default ... Default value that should be set for a column -d, --detect-types Detect types for columns in CSV/TSV data --analyze Run ANALYZE at the end of this operation - --load-extension TEXT SQLite extensions to load + --load-extension TEXT Path to SQLite extension, with optional :entrypoint --silent Do not show progress bar --ignore Ignore records if pk already exists --replace Replace records if pk already exists @@ -320,7 +322,7 @@ See :ref:`cli_upsert`. --default ... Default value that should be set for a column -d, --detect-types Detect types for columns in CSV/TSV data --analyze Run ANALYZE at the end of this operation - --load-extension TEXT SQLite extensions to load + --load-extension TEXT Path to SQLite extension, with optional :entrypoint --silent Do not show progress bar -h, --help Show this message and exit. @@ -364,7 +366,7 @@ See :ref:`cli_bulk`. --sniff Detect delimiter and quote character --no-headers CSV file has no header row --encoding TEXT Character encoding for input, defaults to utf-8 - --load-extension TEXT SQLite extensions to load + --load-extension TEXT Path to SQLite extension, with optional :entrypoint -h, --help Show this message and exit. @@ -404,7 +406,7 @@ See :ref:`cli_search`. textile, tsv, unsafehtml, youtrack --json-cols Detect JSON cols and output them as JSON, not escaped strings - --load-extension TEXT SQLite extensions to load + --load-extension TEXT Path to SQLite extension, with optional :entrypoint -h, --help Show this message and exit. @@ -440,7 +442,7 @@ See :ref:`cli_transform_table`. --default-none TEXT Remove default from this column --drop-foreign-key TEXT Drop foreign key constraint for this column --sql Output SQL without executing it - --load-extension TEXT SQLite extensions to load + --load-extension TEXT Path to SQLite extension, with optional :entrypoint -h, --help Show this message and exit. @@ -465,7 +467,7 @@ See :ref:`cli_extract`. --table TEXT Name of the other table to extract columns to --fk-column TEXT Name of the foreign key column to add to the table --rename ... Rename this column in extracted table - --load-extension TEXT SQLite extensions to load + --load-extension TEXT Path to SQLite extension, with optional :entrypoint -h, --help Show this message and exit. @@ -487,7 +489,7 @@ See :ref:`cli_schema`. sqlite-utils schema trees.db Options: - --load-extension TEXT SQLite extensions to load + --load-extension TEXT Path to SQLite extension, with optional :entrypoint -h, --help Show this message and exit. @@ -525,7 +527,7 @@ See :ref:`cli_insert_files`. --text Store file content as TEXT, not BLOB --encoding TEXT Character encoding for input, defaults to utf-8 -s, --silent Don't show a progress bar - --load-extension TEXT SQLite extensions to load + --load-extension TEXT Path to SQLite extension, with optional :entrypoint -h, --help Show this message and exit. @@ -549,7 +551,7 @@ See :ref:`cli_analyze_tables`. Options: -c, --column TEXT Specific columns to analyze --save Save results to _analyze_tables table - --load-extension TEXT SQLite extensions to load + --load-extension TEXT Path to SQLite extension, with optional :entrypoint -h, --help Show this message and exit. @@ -656,7 +658,7 @@ See :ref:`cli_tables`. strings --columns Include list of columns for each table --schema Include schema for each table - --load-extension TEXT SQLite extensions to load + --load-extension TEXT Path to SQLite extension, with optional :entrypoint -h, --help Show this message and exit. @@ -694,7 +696,7 @@ See :ref:`cli_views`. strings --columns Include list of columns for each view --schema Include schema for each view - --load-extension TEXT SQLite extensions to load + --load-extension TEXT Path to SQLite extension, with optional :entrypoint -h, --help Show this message and exit. @@ -735,7 +737,8 @@ See :ref:`cli_rows`. simple, textile, tsv, unsafehtml, youtrack --json-cols Detect JSON cols and output them as JSON, not escaped strings - --load-extension TEXT SQLite extensions to load + --load-extension TEXT Path to SQLite extension, with optional + :entrypoint -h, --help Show this message and exit. @@ -770,7 +773,7 @@ See :ref:`cli_triggers`. textile, tsv, unsafehtml, youtrack --json-cols Detect JSON cols and output them as JSON, not escaped strings - --load-extension TEXT SQLite extensions to load + --load-extension TEXT Path to SQLite extension, with optional :entrypoint -h, --help Show this message and exit. @@ -806,7 +809,7 @@ See :ref:`cli_indexes`. textile, tsv, unsafehtml, youtrack --json-cols Detect JSON cols and output them as JSON, not escaped strings - --load-extension TEXT SQLite extensions to load + --load-extension TEXT Path to SQLite extension, with optional :entrypoint -h, --help Show this message and exit. @@ -830,7 +833,7 @@ See :ref:`cli_create_database`. Options: --enable-wal Enable WAL mode on the created database --init-spatialite Enable SpatiaLite on the created database - --load-extension TEXT SQLite extensions to load + --load-extension TEXT Path to SQLite extension, with optional :entrypoint -h, --help Show this message and exit. @@ -862,7 +865,7 @@ See :ref:`cli_create_table`. foreign key --ignore If table already exists, do nothing --replace If table already exists, replace it - --load-extension TEXT SQLite extensions to load + --load-extension TEXT Path to SQLite extension, with optional :entrypoint -h, --help Show this message and exit. @@ -892,7 +895,7 @@ See :ref:`cli_create_index`. --unique Make this a unique index --if-not-exists, --ignore Ignore if index already exists --analyze Run ANALYZE after creating the index - --load-extension TEXT SQLite extensions to load + --load-extension TEXT Path to SQLite extension, with optional :entrypoint -h, --help Show this message and exit. @@ -920,7 +923,7 @@ See :ref:`cli_fts`. --create-triggers Create triggers to update the FTS tables when the parent table changes. --replace Replace existing FTS configuration if it exists - --load-extension TEXT SQLite extensions to load + --load-extension TEXT Path to SQLite extension, with optional :entrypoint -h, --help Show this message and exit. @@ -940,7 +943,7 @@ populate-fts sqlite-utils populate-fts chickens.db chickens name Options: - --load-extension TEXT SQLite extensions to load + --load-extension TEXT Path to SQLite extension, with optional :entrypoint -h, --help Show this message and exit. @@ -960,7 +963,7 @@ rebuild-fts sqlite-utils rebuild-fts chickens.db chickens Options: - --load-extension TEXT SQLite extensions to load + --load-extension TEXT Path to SQLite extension, with optional :entrypoint -h, --help Show this message and exit. @@ -980,7 +983,7 @@ disable-fts sqlite-utils disable-fts chickens.db chickens Options: - --load-extension TEXT SQLite extensions to load + --load-extension TEXT Path to SQLite extension, with optional :entrypoint -h, --help Show this message and exit. @@ -1004,7 +1007,7 @@ See :ref:`cli_optimize`. Options: --no-vacuum Don't run VACUUM - --load-extension TEXT SQLite extensions to load + --load-extension TEXT Path to SQLite extension, with optional :entrypoint -h, --help Show this message and exit. @@ -1069,7 +1072,7 @@ See :ref:`cli_dump`. sqlite-utils dump chickens.db Options: - --load-extension TEXT SQLite extensions to load + --load-extension TEXT Path to SQLite extension, with optional :entrypoint -h, --help Show this message and exit. @@ -1097,7 +1100,7 @@ See :ref:`cli_add_column`. omitted will automatically use the primary key --not-null-default TEXT Add NOT NULL DEFAULT 'TEXT' constraint --ignore If column already exists, do nothing - --load-extension TEXT SQLite extensions to load + --load-extension TEXT Path to SQLite extension, with optional :entrypoint -h, --help Show this message and exit. @@ -1123,7 +1126,7 @@ See :ref:`cli_add_foreign_key`. Options: --ignore If foreign key already exists, do nothing - --load-extension TEXT SQLite extensions to load + --load-extension TEXT Path to SQLite extension, with optional :entrypoint -h, --help Show this message and exit. @@ -1147,7 +1150,7 @@ See :ref:`cli_add_foreign_keys`. authors country_id countries id Options: - --load-extension TEXT SQLite extensions to load + --load-extension TEXT Path to SQLite extension, with optional :entrypoint -h, --help Show this message and exit. @@ -1169,7 +1172,7 @@ See :ref:`cli_index_foreign_keys`. sqlite-utils index-foreign-keys chickens.db Options: - --load-extension TEXT SQLite extensions to load + --load-extension TEXT Path to SQLite extension, with optional :entrypoint -h, --help Show this message and exit. @@ -1191,7 +1194,7 @@ See :ref:`cli_wal`. sqlite-utils enable-wal chickens.db Options: - --load-extension TEXT SQLite extensions to load + --load-extension TEXT Path to SQLite extension, with optional :entrypoint -h, --help Show this message and exit. @@ -1211,7 +1214,7 @@ disable-wal sqlite-utils disable-wal chickens.db Options: - --load-extension TEXT SQLite extensions to load + --load-extension TEXT Path to SQLite extension, with optional :entrypoint -h, --help Show this message and exit. @@ -1233,7 +1236,7 @@ See :ref:`cli_enable_counts`. sqlite-utils enable-counts chickens.db Options: - --load-extension TEXT SQLite extensions to load + --load-extension TEXT Path to SQLite extension, with optional :entrypoint -h, --help Show this message and exit. @@ -1253,7 +1256,7 @@ reset-counts sqlite-utils reset-counts chickens.db Options: - --load-extension TEXT SQLite extensions to load + --load-extension TEXT Path to SQLite extension, with optional :entrypoint -h, --help Show this message and exit. @@ -1270,7 +1273,7 @@ duplicate Options: --ignore If table does not exist, do nothing - --load-extension TEXT SQLite extensions to load + --load-extension TEXT Path to SQLite extension, with optional :entrypoint -h, --help Show this message and exit. @@ -1293,7 +1296,7 @@ See :ref:`cli_drop_table`. Options: --ignore If table does not exist, do nothing - --load-extension TEXT SQLite extensions to load + --load-extension TEXT Path to SQLite extension, with optional :entrypoint -h, --help Show this message and exit. @@ -1318,7 +1321,7 @@ See :ref:`cli_create_view`. Options: --ignore If view already exists, do nothing --replace If view already exists, replace it - --load-extension TEXT SQLite extensions to load + --load-extension TEXT Path to SQLite extension, with optional :entrypoint -h, --help Show this message and exit. @@ -1341,7 +1344,7 @@ See :ref:`cli_drop_view`. Options: --ignore If view does not exist, do nothing - --load-extension TEXT SQLite extensions to load + --load-extension TEXT Path to SQLite extension, with optional :entrypoint -h, --help Show this message and exit. @@ -1372,7 +1375,8 @@ See :ref:`cli_spatialite`. --dimensions TEXT Coordinate dimensions. Use XYZ for three- dimensional geometries. --not-null Add a NOT NULL constraint. - --load-extension TEXT SQLite extensions to load + --load-extension TEXT Path to SQLite extension, with optional + :entrypoint -h, --help Show this message and exit. @@ -1394,7 +1398,7 @@ See :ref:`cli_spatialite_indexes`. paths. To load it from a specific path, use --load-extension. Options: - --load-extension TEXT SQLite extensions to load + --load-extension TEXT Path to SQLite extension, with optional :entrypoint -h, --help Show this message and exit. diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 16a99cc..c09273d 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -94,7 +94,7 @@ def load_extension_option(fn): return click.option( "--load-extension", multiple=True, - help="SQLite extensions to load", + help="Path to SQLite extension, with optional :entrypoint", )(fn) @@ -2997,7 +2997,11 @@ def _load_extensions(db, load_extension): for ext in load_extension: if ext == "spatialite" and not os.path.exists(ext): ext = find_spatialite() - db.conn.load_extension(ext) + if ":" in ext: + path, _, entrypoint = ext.partition(":") + db.conn.execute("SELECT load_extension(?, ?)", [path, entrypoint]) + else: + db.conn.load_extension(ext) def _register_functions(db, functions): diff --git a/tests/ext.c b/tests/ext.c new file mode 100644 index 0000000..f5b3276 --- /dev/null +++ b/tests/ext.c @@ -0,0 +1,48 @@ +/* +** This file implements a SQLite extension with multiple entrypoints. +** +** The default entrypoint, sqlite3_ext_init, has a single function "a". +** The 1st alternate entrypoint, sqlite3_ext_b_init, has a single function "b". +** The 2nd alternate entrypoint, sqlite3_ext_c_init, has a single function "c". +** +** Compiling instructions: +** https://www.sqlite.org/loadext.html#compiling_a_loadable_extension +** +*/ + +#include "sqlite3ext.h" + +SQLITE_EXTENSION_INIT1 + +// SQL function that returns back the value supplied during sqlite3_create_function() +static void func(sqlite3_context *context, int argc, sqlite3_value **argv) { + sqlite3_result_text(context, (char *) sqlite3_user_data(context), -1, SQLITE_STATIC); +} + + +// The default entrypoint, since it matches the "ext.dylib"/"ext.so" name +#ifdef _WIN32 +__declspec(dllexport) +#endif +int sqlite3_ext_init(sqlite3 *db, char **pzErrMsg, const sqlite3_api_routines *pApi) { + SQLITE_EXTENSION_INIT2(pApi); + return sqlite3_create_function(db, "a", 0, 0, "a", func, 0, 0); +} + +// Alternate entrypoint #1 +#ifdef _WIN32 +__declspec(dllexport) +#endif +int sqlite3_ext_b_init(sqlite3 *db, char **pzErrMsg, const sqlite3_api_routines *pApi) { + SQLITE_EXTENSION_INIT2(pApi); + return sqlite3_create_function(db, "b", 0, 0, "b", func, 0, 0); +} + +// Alternate entrypoint #2 +#ifdef _WIN32 +__declspec(dllexport) +#endif +int sqlite3_ext_c_init(sqlite3 *db, char **pzErrMsg, const sqlite3_api_routines *pApi) { + SQLITE_EXTENSION_INIT2(pApi); + return sqlite3_create_function(db, "c", 0, 0, "c", func, 0, 0); +} diff --git a/tests/test_cli.py b/tests/test_cli.py index 9b43cd5..24f36ed 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,6 +1,7 @@ from sqlite_utils import cli, Database from sqlite_utils.db import Index, ForeignKey from click.testing import CliRunner +from pathlib import Path import subprocess import sys from unittest import mock @@ -21,6 +22,17 @@ def _supports_pragma_function_list(): return True +def _has_compiled_ext(): + for ext in ["dylib", "so", "dll"]: + path = Path(__file__).parent / f"ext.{ext}" + if path.is_file(): + return True + return False + + +COMPILED_EXTENSION_PATH = str(Path(__file__).parent / "ext") + + @pytest.mark.parametrize( "options", ( @@ -2284,3 +2296,32 @@ def test_duplicate_table(tmpdir): assert result.exit_code == 0 assert db["one"].columns_dict == db["two"].columns_dict assert list(db["one"].rows) == list(db["two"].rows) + + +@pytest.mark.skipif(not _has_compiled_ext(), reason="Requires compiled ext.c") +@pytest.mark.parametrize( + "entrypoint,should_pass,should_fail", + ( + (None, ("a",), ("b", "c")), + ("sqlite3_ext_b_init", ("b"), ("a", "c")), + ("sqlite3_ext_c_init", ("c"), ("a", "b")), + ), +) +def test_load_extension(entrypoint, should_pass, should_fail): + ext = COMPILED_EXTENSION_PATH + if entrypoint: + ext += ":" + entrypoint + for func in should_pass: + result = CliRunner().invoke( + cli.cli, + ["memory", "select {}()".format(func), "--load-extension", ext], + catch_exceptions=False, + ) + assert result.exit_code == 0 + for func in should_fail: + result = CliRunner().invoke( + cli.cli, + ["memory", "select {}()".format(func), "--load-extension", ext], + catch_exceptions=False, + ) + assert result.exit_code == 1 From c5f8a2eb1a81a18b52825cc649112f71fe419b12 Mon Sep 17 00:00:00 2001 From: Forest Gregg Date: Sat, 27 Aug 2022 10:45:03 -0400 Subject: [PATCH 214/563] in extract code, check equality witH IS instead of = for nulls (#455) sqlite "IS" is equivalent to SQL "IS NOT DISTINCT FROM" close #423 --- sqlite_utils/db.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index 18a442a..25c0a70 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -1791,7 +1791,7 @@ class Table(Queryable): magic_lookup_column=magic_lookup_column, lookup_table=table, where=" AND ".join( - "[{table}].[{column}] = [{lookup_table}].[{lookup_column}]".format( + "[{table}].[{column}] IS [{lookup_table}].[{lookup_column}]".format( table=self.name, lookup_table=table, column=column, From 36ffcafb1a0f94c134cdedeb626012bc8e2c1d8a Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 27 Aug 2022 15:41:10 -0700 Subject: [PATCH 215/563] table.default_values property, closes #475 Refs #468 --- docs/python-api.rst | 10 ++++++++++ sqlite_utils/db.py | 29 +++++++++++++++++++++++++++++ tests/test_introspect.py | 18 ++++++++++++++++++ 3 files changed, 57 insertions(+) diff --git a/docs/python-api.rst b/docs/python-api.rst index 07f2bf6..c68611c 100644 --- a/docs/python-api.rst +++ b/docs/python-api.rst @@ -1802,6 +1802,16 @@ The ``.columns_dict`` property returns a dictionary version of the columns with >>> db["PlantType"].columns_dict {'id': , 'value': } +.. _python_api_introspection_default_values: + +.default_values +--------------- + +The ``.default_values`` property returns a dictionary of default values for each column that has a default:: + + >>> db["table_with_defaults"].default_values + {'score': 5} + .. _python_api_introspection_pks: .pks diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index 25c0a70..3f9d400 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -9,6 +9,7 @@ from .utils import ( progressbar, find_spatialite, ) +import binascii from collections import namedtuple from collections.abc import Mapping import contextlib @@ -1458,6 +1459,15 @@ class Table(Queryable): "``{trigger_name: sql}`` dictionary of triggers defined on this table." return {trigger.name: trigger.sql for trigger in self.triggers} + @property + def default_values(self) -> Dict[str, Any]: + "``{column_name: default_value}`` dictionary of default values for columns in this table." + return { + column.name: _decode_default_value(column.default_value) + for column in self.columns + if column.default_value is not None + } + @property def strict(self) -> bool: "Is this a STRICT table?" @@ -3527,3 +3537,22 @@ def fix_square_braces(records: Iterable[Dict[str, Any]]): } else: yield record + + +def _decode_default_value(value): + if value.startswith("'") and value.endswith("'"): + # It's a string + return value[1:-1] + if value.isdigit(): + # It's an integer + return int(value) + if value.startswith("X'") and value.endswith("'"): + # It's a binary string, stored as hex + to_decode = value[2:-1] + return binascii.unhexlify(to_decode) + # If it is a string containing a floating point number: + try: + return float(value) + except ValueError: + pass + return value diff --git a/tests/test_introspect.py b/tests/test_introspect.py index fe9542b..c3d1b80 100644 --- a/tests/test_introspect.py +++ b/tests/test_introspect.py @@ -299,3 +299,21 @@ def test_table_strict(fresh_db, create_table, expected_strict): fresh_db.execute(create_table) table = fresh_db["t"] assert table.strict == expected_strict + + +@pytest.mark.parametrize( + "value", + ( + 1, + 1.3, + "foo", + True, + b"binary", + ), +) +def test_table_default_values(fresh_db, value): + fresh_db["default_values"].insert( + {"nodefault": 1, "value": value}, defaults={"value": value} + ) + default_values = fresh_db["default_values"].default_values + assert default_values == {"value": value} From 104f37fa4d2e7e5999c1d829267b62c737f74d3e Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 27 Aug 2022 16:17:55 -0700 Subject: [PATCH 216/563] db[table].create(..., transform=True) and create-table --transform Closes #467 --- docs/cli-reference.rst | 1 + docs/cli.rst | 2 ++ docs/python-api.rst | 19 +++++++++++ sqlite_utils/cli.py | 26 +++++++++++++-- sqlite_utils/db.py | 74 +++++++++++++++++++++++++++++++++++----- tests/test_create.py | 76 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 188 insertions(+), 10 deletions(-) diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index d3951fd..d74855e 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -865,6 +865,7 @@ See :ref:`cli_create_table`. foreign key --ignore If table already exists, do nothing --replace If table already exists, replace it + --transform If table already exists, try to transform the schema --load-extension TEXT Path to SQLite extension, with optional :entrypoint -h, --help Show this message and exit. diff --git a/docs/cli.rst b/docs/cli.rst index 3c5c595..d071158 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -1520,6 +1520,8 @@ You can specify foreign key relationships between the tables you are creating us If a table with the same name already exists, you will get an error. You can choose to silently ignore this error with ``--ignore``, or you can replace the existing table with a new, empty table using ``--replace``. +You can also pass ``--transform`` to transform the existing table to match the new schema. See :ref:`python_api_explicit_create` in the Python library documentation for details of how this option works. + .. _cli_duplicate_table: Duplicating tables diff --git a/docs/python-api.rst b/docs/python-api.rst index c68611c..b8794f2 100644 --- a/docs/python-api.rst +++ b/docs/python-api.rst @@ -554,6 +554,25 @@ To do nothing if the table already exists, add ``if_not_exists=True``: "name": str, }, pk="id", if_not_exists=True) +You can also pass ``transform=True`` to have any existing tables :ref:`transformed ` to match your new table specification. This is a **dangerous operation** as it may drop columns that are no longer listed in your call to ``.create()``, so be careful when running this. + +.. code-block:: python + + db["cats"].create({ + "id": int, + "name": str, + "weight": float, + }, pk="id", transform=True) + +The ``transform=True`` option will update the table schema if any of the following have changed: + +- The specified columns or their types +- The specified primary key +- The order of the columns, defined using ``column_order=`` +- The ``not_null=`` or ``defaults=`` arguments + +Changes to ``foreign_keys=`` are not currently detected and applied by ``transform=True``. + .. _python_api_compound_primary_keys: Compound primary keys diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index c09273d..c51b101 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -1453,9 +1453,24 @@ def create_database(path, enable_wal, init_spatialite, load_extension): is_flag=True, help="If table already exists, replace it", ) +@click.option( + "--transform", + is_flag=True, + help="If table already exists, try to transform the schema", +) @load_extension_option def create_table( - path, table, columns, pk, not_null, default, fk, ignore, replace, load_extension + path, + table, + columns, + pk, + not_null, + default, + fk, + ignore, + replace, + transform, + load_extension, ): """ Add a table with the specified columns. Columns should be specified using @@ -1490,6 +1505,8 @@ def create_table( return elif replace: db[table].drop() + elif transform: + pass else: raise click.ClickException( 'Table "{}" already exists. Use --replace to delete and replace it.'.format( @@ -1497,7 +1514,12 @@ def create_table( ) ) db[table].create( - coltypes, pk=pk, not_null=not_null, defaults=dict(default), foreign_keys=fk + coltypes, + pk=pk, + not_null=not_null, + defaults=dict(default), + foreign_keys=fk, + transform=transform, ) diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index 3f9d400..b653cc4 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -34,7 +34,6 @@ from typing import ( Union, Optional, List, - Set, Tuple, ) import uuid @@ -876,6 +875,7 @@ class Database: hash_id_columns: Optional[Iterable[str]] = None, extracts: Optional[Union[Dict[str, str], List[str]]] = None, if_not_exists: bool = False, + transform: bool = False, ) -> "Table": """ Create a table with the specified name and the specified ``{column_name: type}`` columns. @@ -893,7 +893,61 @@ class Database: :param hash_id_columns: List of columns to be used when calculating the hash ID for a row :param extracts: List or dictionary of columns to be extracted during inserts, see :ref:`python_api_extracts` :param if_not_exists: Use ``CREATE TABLE IF NOT EXISTS`` + :param transform: If table already exists transform it to fit the specified schema """ + # Transform table to match the new definition if table already exists: + if transform and self[name].exists(): + table = cast(Table, self[name]) + should_transform = False + # First add missing columns and figure out columns to drop + existing_columns = table.columns_dict + missing_columns = dict( + (col_name, col_type) + for col_name, col_type in columns.items() + if col_name not in existing_columns + ) + columns_to_drop = [ + column for column in existing_columns if column not in columns + ] + if missing_columns: + for col_name, col_type in missing_columns.items(): + table.add_column(col_name, col_type) + if missing_columns or columns_to_drop or columns != existing_columns: + should_transform = True + # Do we need to change the column order? + if ( + column_order + and list(existing_columns)[: len(column_order)] != column_order + ): + should_transform = True + # Has the primary key changed? + current_pks = table.pks + desired_pk = None + if isinstance(pk, str): + desired_pk = [pk] + elif pk: + desired_pk = list(pk) + if desired_pk and current_pks != desired_pk: + should_transform = True + # Any not-null changes? + current_not_null = {c.name for c in table.columns if c.notnull} + desired_not_null = set(not_null) if not_null else set() + if current_not_null != desired_not_null: + should_transform = True + # How about defaults? + if defaults and defaults != table.default_values: + should_transform = True + # Only run .transform() if there is something to do + if should_transform: + table.transform( + types=columns, + drop=columns_to_drop, + column_order=column_order, + not_null=not_null, + defaults=defaults, + pk=pk, + ) + return table sql = self.create_table_sql( name=name, columns=columns, @@ -908,7 +962,7 @@ class Database: if_not_exists=if_not_exists, ) self.execute(sql) - table = self.table( + created_table = self.table( name, pk=pk, foreign_keys=foreign_keys, @@ -918,7 +972,7 @@ class Database: hash_id=hash_id, hash_id_columns=hash_id_columns, ) - return cast(Table, table) + return cast(Table, created_table) def create_view( self, name: str, sql: str, ignore: bool = False, replace: bool = False @@ -1487,6 +1541,7 @@ class Table(Queryable): hash_id_columns: Optional[Iterable[str]] = None, extracts: Optional[Union[Dict[str, str], List[str]]] = None, if_not_exists: bool = False, + transform: bool = False, ) -> "Table": """ Create a table with the specified columns. @@ -1518,6 +1573,7 @@ class Table(Queryable): hash_id_columns=hash_id_columns, extracts=extracts, if_not_exists=if_not_exists, + transform=transform, ) return self @@ -1544,7 +1600,7 @@ class Table(Queryable): rename: Optional[dict] = None, drop: Optional[Iterable] = None, pk: Optional[Any] = DEFAULT, - not_null: Optional[Set[str]] = None, + not_null: Optional[Iterable[str]] = None, defaults: Optional[Dict[str, Any]] = None, drop_foreign_keys: Optional[Iterable] = None, column_order: Optional[List[str]] = None, @@ -1664,10 +1720,12 @@ class Table(Queryable): create_table_not_null.add(key) elif isinstance(not_null, set): create_table_not_null.update((rename.get(k) or k) for k in not_null) - elif not_null is None: + elif not not_null: pass else: - assert False, "not_null must be a dict or a set or None" + assert False, "not_null must be a dict or a set or None, it was {}".format( + repr(not_null) + ) # defaults= create_table_defaults = { (rename.get(c.name) or c.name): c.default_value @@ -2861,7 +2919,7 @@ class Table(Queryable): pk=DEFAULT, foreign_keys=DEFAULT, column_order: Optional[Union[List[str], Default]] = DEFAULT, - not_null: Optional[Union[Set[str], Default]] = DEFAULT, + not_null: Optional[Union[Iterable[str], Default]] = DEFAULT, defaults: Optional[Union[Dict[str, Any], Default]] = DEFAULT, hash_id: Optional[Union[str, Default]] = DEFAULT, hash_id_columns: Optional[Union[Iterable[str], Default]] = DEFAULT, @@ -3141,7 +3199,7 @@ class Table(Queryable): pk: Optional[str] = "id", foreign_keys: Optional[ForeignKeysType] = None, column_order: Optional[List[str]] = None, - not_null: Optional[Set[str]] = None, + not_null: Optional[Iterable[str]] = None, defaults: Optional[Dict[str, Any]] = None, extracts: Optional[Union[Dict[str, str], List[str]]] = None, conversions: Optional[Dict[str, str]] = None, diff --git a/tests/test_create.py b/tests/test_create.py index 60181de..7252e48 100644 --- a/tests/test_create.py +++ b/tests/test_create.py @@ -1155,3 +1155,79 @@ def test_create_if_no_columns(fresh_db): with pytest.raises(AssertionError) as error: fresh_db["t"].create({}) assert error.value.args[0] == "Tables must have at least one column" + + +@pytest.mark.parametrize( + "cols,kwargs,expected_schema,should_transform", + ( + # Change nothing + ( + {"id": int, "name": str}, + {"pk": "id"}, + "CREATE TABLE [demo] (\n [id] INTEGER PRIMARY KEY,\n [name] TEXT\n)", + False, + ), + # Drop name column, remove primary key + ({"id": int}, {}, 'CREATE TABLE "demo" (\n [id] INTEGER\n)', True), + # Add a new column + ( + {"id": int, "name": str, "age": int}, + {"pk": "id"}, + 'CREATE TABLE "demo" (\n [id] INTEGER PRIMARY KEY,\n [name] TEXT,\n [age] INTEGER\n)', + True, + ), + # Change a column type + ( + {"id": int, "name": bytes}, + {"pk": "id"}, + 'CREATE TABLE "demo" (\n [id] INTEGER PRIMARY KEY,\n [name] BLOB\n)', + True, + ), + # Change the primary key + ( + {"id": int, "name": str}, + {"pk": "name"}, + 'CREATE TABLE "demo" (\n [id] INTEGER,\n [name] TEXT PRIMARY KEY\n)', + True, + ), + # Change in column order + ( + {"id": int, "name": str}, + {"pk": "id", "column_order": ["name"]}, + 'CREATE TABLE "demo" (\n [name] TEXT,\n [id] INTEGER PRIMARY KEY\n)', + True, + ), + # Same column order is ignored + ( + {"id": int, "name": str}, + {"pk": "id", "column_order": ["id", "name"]}, + "CREATE TABLE [demo] (\n [id] INTEGER PRIMARY KEY,\n [name] TEXT\n)", + False, + ), + # Change not null + ( + {"id": int, "name": str}, + {"pk": "id", "not_null": {"name"}}, + 'CREATE TABLE "demo" (\n [id] INTEGER PRIMARY KEY,\n [name] TEXT NOT NULL\n)', + True, + ), + # Change default values + ( + {"id": int, "name": str}, + {"pk": "id", "defaults": {"id": 0, "name": "Bob"}}, + "CREATE TABLE \"demo\" (\n [id] INTEGER PRIMARY KEY DEFAULT 0,\n [name] TEXT DEFAULT 'Bob'\n)", + True, + ), + ), +) +def test_create_transform(fresh_db, cols, kwargs, expected_schema, should_transform): + fresh_db.create_table("demo", {"id": int, "name": str}, pk="id") + fresh_db["demo"].insert({"id": 1, "name": "Cleo"}) + traces = [] + with fresh_db.tracer(lambda sql, parameters: traces.append((sql, parameters))): + fresh_db["demo"].create(cols, **kwargs, transform=True) + at_least_one_create_table = any(sql.startswith("CREATE TABLE") for sql, _ in traces) + assert should_transform == at_least_one_create_table + new_schema = fresh_db["demo"].schema + assert new_schema == expected_schema, repr(new_schema) + assert fresh_db["demo"].count == 1 From 365f62520fa080bc363ab3820b0c800c5096abff Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 27 Aug 2022 16:20:35 -0700 Subject: [PATCH 217/563] will, not may - refs #468 --- docs/python-api.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/python-api.rst b/docs/python-api.rst index b8794f2..206e5e6 100644 --- a/docs/python-api.rst +++ b/docs/python-api.rst @@ -554,7 +554,7 @@ To do nothing if the table already exists, add ``if_not_exists=True``: "name": str, }, pk="id", if_not_exists=True) -You can also pass ``transform=True`` to have any existing tables :ref:`transformed ` to match your new table specification. This is a **dangerous operation** as it may drop columns that are no longer listed in your call to ``.create()``, so be careful when running this. +You can also pass ``transform=True`` to have any existing tables :ref:`transformed ` to match your new table specification. This is a **dangerous operation** as it will drop columns that are no longer listed in your call to ``.create()``, so be careful when running this. .. code-block:: python From 165bc5fcb0600a1898249e48b03ce798010e07f9 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 27 Aug 2022 20:32:01 -0700 Subject: [PATCH 218/563] test_extract_works_with_null_values, refs #423, #455 --- tests/test_extract.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/test_extract.py b/tests/test_extract.py index 10b5b09..70ad0cf 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -186,3 +186,24 @@ def test_extract_error_on_incompatible_existing_lookup_table(fresh_db): fresh_db["species2"].insert({"id": 1, "common_name": 3.5}) with pytest.raises(InvalidColumns): fresh_db["tree"].extract("common_name", table="species2") + + +def test_extract_works_with_null_values(fresh_db): + fresh_db["listens"].insert_all( + [ + {"id": 1, "track_title": "foo", "album_title": "bar"}, + {"id": 2, "track_title": "baz", "album_title": None}, + ], + pk="id", + ) + fresh_db["listens"].extract( + columns=["album_title"], table="albums", fk_column="album_id" + ) + assert list(fresh_db["listens"].rows) == [ + {"id": 1, "track_title": "foo", "album_id": 1}, + {"id": 2, "track_title": "baz", "album_id": 2}, + ] + assert list(fresh_db["albums"].rows) == [ + {"id": 1, "album_title": "bar"}, + {"id": 2, "album_title": None}, + ] From b491f22d817836829965516983a3f4c3c72c05fc Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 27 Aug 2022 20:48:36 -0700 Subject: [PATCH 219/563] Release 3.29 Refs #423, #458, #467, #469, #470, #471, #472, #475 Closes #487 --- docs/changelog.rst | 17 +++++++++++++++++ setup.py | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index b8f35c5..6ac1c18 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -2,6 +2,23 @@ Changelog =========== +.. _v3_29: + +3.29 (2022-08-27) +----------------- + +- The ``sqlite-utils query``, ``memory`` and ``bulk`` commands now all accept a new ``--functions`` option. This can be passed a string of Python code, and any callable objects defined in that code will be made available to SQL queries as custom SQL functions. See :ref:`cli_query_functions` for details. (:issue:`471`) +- ``db[table].create(...)`` method now accepts a new ``transform=True`` parameter. If the table already exists it will be :ref:`transform ` to match the schema configuration options passed to the function. This may result in columns being added or dropped, column types being changed, column order being updated or not null and default values for columns being set. (:issue:`467`) +- Related to the above, the ``sqlite-utils create-table`` command now accepts a ``--transform`` option. +- New introspection property: ``table.default_values`` returns a dictionary mapping each column name with a default value to the configured default value. (:issue:`475`) +- The ``--load-extension`` option can now be provided a path to a compiled SQLite extension module accompanied by the name of an entrypoint, separated by a colon - for example ``--load-extension ./lines0:sqlite3_lines0_noread_init``. This feature is modelled on code first `contributed to Datasette `__ by Alex Garcia. (:issue:`470`) +- Functions registered using the :ref:`db.register_function() ` method can now have a custom name specified using the new ``db.register_function(fn, name=...)`` parameter. (:issue:`458`) +- :ref:`sqlite-utils rows ` has a new ``--order`` option for specifying the sort order for the returned rows. (:issue:`469`) +- All of the CLI options that accept Python code blocks can now all be used to define functions that can access modules imported in that same block of code without needing to use the ``global`` keyword. (:issue:`472`) +- Fixed bug where ``table.extract()`` would not behave correctly for columns containing null values. Thanks, Forest Gregg. (:issue:`423`) +- New tutorial: `Cleaning data with sqlite-utils and Datasette `__ shows how to use ``sqlite-utils`` to import and clean an example CSV file. +- Datasette and ``sqlite-utils`` now have a Discord community. `Join the Discord here `__. + .. _v3_28: 3.28 (2022-07-15) diff --git a/setup.py b/setup.py index ead463b..88b186b 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages import io import os -VERSION = "3.28" +VERSION = "3.29" def get_long_description(): From 087753cd42c406f1e060c1822dcd9b5fda3d60f4 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 27 Aug 2022 21:01:55 -0700 Subject: [PATCH 220/563] sites.db is better name than dogs.db in this example --- docs/cli.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cli.rst b/docs/cli.rst index d071158..ed089ac 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -259,7 +259,7 @@ You can use the ``--functions`` option to pass a block of Python code that defin This example defines a function which extracts the domain from a URL:: - $ sqlite-utils query dogs.db "select url, domain(url) from urls" --functions ' + $ sqlite-utils query sites.db "select url, domain(url) from urls" --functions ' from urllib.parse import urlparse def domain(url): From ecf1d40112e52a8f4e509c39b98caae996b7bc36 Mon Sep 17 00:00:00 2001 From: Jacob Chapman <7908073+chapmanjacobd@users.noreply.github.com> Date: Tue, 30 Aug 2022 22:40:35 -0500 Subject: [PATCH 221/563] table.search_sql(include_rank=True) option (#480) * search_sql add include_rank option * add test * add FTS4 test * Apply Black Thanks, @chapmanjacobd --- sqlite_utils/db.py | 4 ++++ tests/test_fts.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index b653cc4..27c46b0 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -2376,6 +2376,7 @@ class Table(Queryable): limit: Optional[int] = None, offset: Optional[int] = None, where: Optional[str] = None, + include_rank: bool = False, ) -> str: """ " Return SQL string that can be used to execute searches against this table. @@ -2385,6 +2386,7 @@ class Table(Queryable): :param limit: SQL limit :param offset: SQL offset :param where: Extra SQL fragment for the WHERE clause + :param include_rank: Select the search rank column in the final query """ # Pick names for table and rank column that don't clash original = "original_" if self.name == "original" else "original" @@ -2427,6 +2429,8 @@ class Table(Queryable): rank_implementation = "rank_bm25(matchinfo([{}], 'pcnalx'))".format( fts_table ) + if include_rank: + columns_with_prefix_sql += ",\n " + rank_implementation + " rank" limit_offset = "" if limit is not None: limit_offset += " limit {}".format(limit) diff --git a/tests/test_fts.py b/tests/test_fts.py index 477d599..ecfcff9 100644 --- a/tests/test_fts.py +++ b/tests/test_fts.py @@ -556,6 +556,50 @@ def test_enable_fts_error_message_on_views(): " rank_bm25(matchinfo([books_fts], 'pcnalx'))" ), ), + ( + {"include_rank": True}, + "FTS5", + ( + "with original as (\n" + " select\n" + " rowid,\n" + " *\n" + " from [books]\n" + ")\n" + "select\n" + " [original].*,\n" + " [books_fts].rank rank\n" + "from\n" + " [original]\n" + " join [books_fts] on [original].rowid = [books_fts].rowid\n" + "where\n" + " [books_fts] match :query\n" + "order by\n" + " [books_fts].rank" + ), + ), + ( + {"include_rank": True}, + "FTS4", + ( + "with original as (\n" + " select\n" + " rowid,\n" + " *\n" + " from [books]\n" + ")\n" + "select\n" + " [original].*,\n" + " rank_bm25(matchinfo([books_fts], 'pcnalx')) rank\n" + "from\n" + " [original]\n" + " join [books_fts] on [original].rowid = [books_fts].rowid\n" + "where\n" + " [books_fts] match :query\n" + "order by\n" + " rank_bm25(matchinfo([books_fts], 'pcnalx'))" + ), + ), ], ) def test_search_sql(kwargs, fts, expected): From 686eed9a49faf87b0f2d3eba5fb12caa0250988f Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 1 Sep 2022 18:37:13 -0700 Subject: [PATCH 222/563] Typo in release notes --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 6ac1c18..1245014 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -8,7 +8,7 @@ ----------------- - The ``sqlite-utils query``, ``memory`` and ``bulk`` commands now all accept a new ``--functions`` option. This can be passed a string of Python code, and any callable objects defined in that code will be made available to SQL queries as custom SQL functions. See :ref:`cli_query_functions` for details. (:issue:`471`) -- ``db[table].create(...)`` method now accepts a new ``transform=True`` parameter. If the table already exists it will be :ref:`transform ` to match the schema configuration options passed to the function. This may result in columns being added or dropped, column types being changed, column order being updated or not null and default values for columns being set. (:issue:`467`) +- ``db[table].create(...)`` method now accepts a new ``transform=True`` parameter. If the table already exists it will be :ref:`transformed ` to match the schema configuration options passed to the function. This may result in columns being added or dropped, column types being changed, column order being updated or not null and default values for columns being set. (:issue:`467`) - Related to the above, the ``sqlite-utils create-table`` command now accepts a ``--transform`` option. - New introspection property: ``table.default_values`` returns a dictionary mapping each column name with a default value to the configured default value. (:issue:`475`) - The ``--load-extension`` option can now be provided a path to a compiled SQLite extension module accompanied by the name of an entrypoint, separated by a colon - for example ``--load-extension ./lines0:sqlite3_lines0_noread_init``. This feature is modelled on code first `contributed to Datasette `__ by Alex Garcia. (:issue:`470`) From 5b969273f1244b1bcf3e4dc071cdf17dab35d5f8 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 1 Sep 2022 18:44:56 -0700 Subject: [PATCH 223/563] Markup tweak --- docs/cli.rst | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/cli.rst b/docs/cli.rst index ed089ac..3adec26 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -2097,14 +2097,14 @@ Use the ``--type`` option to specify a geometry type. By default, ``add-geometry Eight (case-insensitive) types are allowed: - * POINT - * LINESTRING - * POLYGON - * MULTIPOINT - * MULTILINESTRING - * MULTIPOLYGON - * GEOMETRYCOLLECTION - * GEOMETRY +* POINT +* LINESTRING +* POLYGON +* MULTIPOINT +* MULTILINESTRING +* MULTIPOLYGON +* GEOMETRYCOLLECTION +* GEOMETRY .. _cli_spatialite_indexes: From d9b9e075f07a20f1137cd2e34ed5d3f1a3db4ad8 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 6 Sep 2022 20:45:36 -0700 Subject: [PATCH 224/563] Documented the release process --- docs/changelog.rst | 2 ++ docs/contributing.rst | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 1245014..adeb6b1 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,3 +1,5 @@ +.. _changelog: + =========== Changelog =========== diff --git a/docs/contributing.rst b/docs/contributing.rst index 7ac1ad8..a1041f5 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -78,3 +78,40 @@ Both commands can then be run in the root of the project like this:: mypy sqlite_utils All three of these tools are run by our CI mechanism against every commit and pull request. + +.. _release_process: + +Release process +=============== + +Releases are performed using tags. When a new release is published on GitHub, a `GitHub Action workflow `__ will perform the following: + +* Run the unit tests against all supported Python versions. If the tests pass... +* Build a wheel bundle of the underlying Python source code +* Push that new wheel up to PyPI: https://pypi.org/project/sqlite-utils/ + +To deploy new releases you will need to have push access to the GitHub repository. + +``sqlite-utils`` follows `Semantic Versioning `__:: + + major.minor.patch + +We increment ``major`` for backwards-incompatible releases. + +We increment ``minor`` for new features. + +We increment ``patch`` for bugfix releass. + +To release a new version, first create a commit that updates the version number in ``setup.py`` and the :ref:`the changelog ` with highlights of the new version. An example `commit can be seen here `__:: + + # Update changelog + git commit -m " Release 3.29 + + Refs #423, #458, #467, #469, #470, #471, #472, #475" -a + git push + +Referencing the issues that are part of the release in the commit message ensures the name of the release shows up on those issue pages, e.g. `here `__. + +You can generate the list of issue references for a specific release by copying and pasting text from the release notes or GitHub changes-since-last-release view into this `Extract issue numbers from pasted text `__ tool. + +To create the tag for the release, create `a new release `__ on GitHub matching the new version number. You can convert the release notes to Markdown by copying and pasting the rendered HTML into this `Paste to Markdown tool `__. From 0b315d3fa83c1584eaeec32f24912898621e437a Mon Sep 17 00:00:00 2001 From: Mischa Untaga <99098079+MischaU8@users.noreply.github.com> Date: Thu, 15 Sep 2022 22:37:51 +0200 Subject: [PATCH 225/563] progressbar for inserts/upserts of other file formats * progressbar for inserts/upserts of other file formats, closes #485 * Pin to Python 3.10.6 for the moment as workaround for mypy error Co-authored-by: Simon Willison --- .github/workflows/publish.yml | 4 +-- .github/workflows/test.yml | 4 +-- sqlite_utils/cli.py | 54 ++++++++++++++++++----------------- sqlite_utils/utils.py | 5 ++++ 4 files changed, 37 insertions(+), 30 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 59c8a23..355f271 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -9,12 +9,12 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - python-version: ["3.6", "3.7", "3.8", "3.9", "3.10"] + python-version: ["3.6", "3.7", "3.8", "3.9", "3.10.6"] os: [ubuntu-latest, windows-latest, macos-latest] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - uses: actions/cache@v2 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9303965..788df25 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,13 +10,13 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - python-version: ["3.6", "3.7", "3.8", "3.9", "3.10"] + python-version: ["3.6", "3.7", "3.8", "3.9", "3.10.6"] numpy: [0, 1] os: [ubuntu-latest, macos-latest, windows-latest] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - uses: actions/cache@v2 diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index c51b101..767b170 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -953,14 +953,16 @@ def insert_upsert_implementation( decoded = io.TextIOWrapper(file, encoding=encoding) tracker = None - if csv or tsv: - if sniff: - # Read first 2048 bytes and use that to detect - first_bytes = sniff_buffer.peek(2048) - dialect = csv_std.Sniffer().sniff(first_bytes.decode(encoding, "ignore")) - else: - dialect = "excel-tab" if tsv else "excel" - with file_progress(decoded, silent=silent) as decoded: + with file_progress(decoded, silent=silent) as decoded: + if csv or tsv: + if sniff: + # Read first 2048 bytes and use that to detect + first_bytes = sniff_buffer.peek(2048) + dialect = csv_std.Sniffer().sniff( + first_bytes.decode(encoding, "ignore") + ) + else: + dialect = "excel-tab" if tsv else "excel" csv_reader_args = {"dialect": dialect} if delimiter: csv_reader_args["delimiter"] = delimiter @@ -977,24 +979,24 @@ def insert_upsert_implementation( if detect_types: tracker = TypeTracker() docs = tracker.wrap(docs) - elif lines: - docs = ({"line": line.strip()} for line in decoded) - elif text: - docs = ({"text": decoded.read()},) - else: - try: - if nl: - docs = (json.loads(line) for line in decoded if line.strip()) - else: - docs = json.load(decoded) - if isinstance(docs, dict): - docs = [docs] - except json.decoder.JSONDecodeError: - raise click.ClickException( - "Invalid JSON - use --csv for CSV or --tsv for TSV files" - ) - if flatten: - docs = (dict(_flatten(doc)) for doc in docs) + elif lines: + docs = ({"line": line.strip()} for line in decoded) + elif text: + docs = ({"text": decoded.read()},) + else: + try: + if nl: + docs = (json.loads(line) for line in decoded if line.strip()) + else: + docs = json.load(decoded) + if isinstance(docs, dict): + docs = [docs] + except json.decoder.JSONDecodeError: + raise click.ClickException( + "Invalid JSON - use --csv for CSV or --tsv for TSV files" + ) + if flatten: + docs = (dict(_flatten(doc)) for doc in docs) if convert: variable = "row" diff --git a/sqlite_utils/utils.py b/sqlite_utils/utils.py index c0b7bf1..8754554 100644 --- a/sqlite_utils/utils.py +++ b/sqlite_utils/utils.py @@ -155,6 +155,11 @@ class UpdateWrapper: self._update(len(line)) yield line + def read(self, size=-1): + data = self._wrapped.read(size) + self._update(len(data)) + return data + @contextlib.contextmanager def file_progress(file, silent=False, **kwargs): From 85247038f70d7eb2f3e272cfeaa4c44459cafba8 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 26 Sep 2022 11:57:11 -0700 Subject: [PATCH 226/563] install and uninstall commands, closes #483 --- docs/cli-reference.rst | 38 ++++++++++++++++++++++++++++++++++++++ docs/cli.rst | 26 ++++++++++++++++++++++++++ sqlite_utils/cli.py | 25 +++++++++++++++++++++++++ 3 files changed, 89 insertions(+) diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index d74855e..b88e38a 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -59,6 +59,8 @@ This page lists the ``--help`` for every ``sqlite-utils`` CLI sub-command. "convert": "cli_convert", "add-geometry-column": "cli_spatialite", "create-spatial-index": "cli_spatialite_indexes", + "install": "cli_install", + "uninstall": "cli_uninstall", } commands.sort(key = lambda command: go_first.index(command) if command in go_first else 999) cog.out("\n") @@ -1349,6 +1351,42 @@ See :ref:`cli_drop_view`. -h, --help Show this message and exit. +.. _cli_ref_install: + +install +======= + +See :ref:`cli_install`. + +:: + + Usage: sqlite-utils install [OPTIONS] PACKAGES... + + Install packages from PyPI into the same environment as sqlite-utils + + Options: + -U, --upgrade Upgrade packages to latest version + -h, --help Show this message and exit. + + +.. _cli_ref_uninstall: + +uninstall +========= + +See :ref:`cli_uninstall`. + +:: + + Usage: sqlite-utils uninstall [OPTIONS] PACKAGES... + + Uninstall Python packages from the sqlite-utils environment + + Options: + -y, --yes Don't ask for confirmation + -h, --help Show this message and exit. + + .. _cli_ref_add_geometry_column: add-geometry-column diff --git a/docs/cli.rst b/docs/cli.rst index 3adec26..5f85e55 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -2116,3 +2116,29 @@ Once you have a geometry column, you can speed up bounding box queries by adding $ sqlite-utils create-spatial-index spatial.db locations geometry See this `SpatiaLite Cookbook recipe `__ for examples of how to use a spatial index. + +.. _cli_install: + +Installing packages +------------------- + +The :ref:`insert -\\-convert ` and :ref:`query -\\-functions ` options can be provided with a Python script that imports additional modules from the ``sqlite-utils`` environment. + +You can install packages from PyPI directly into the correct environment using ``sqlite-utils install ``. This is a wrapper around ``pip install``. + +:: + + $ sqlite-utils install beautifulsoup4 + +Use ``-U`` to upgrade an existing package. + +.. _cli_uninstall: + +Uninstalling packages +--------------------- + +You can uninstall packages that were installed using ``sqlite-utils install`` with ``sqlite-utils uninstall ``:: + + $ sqlite-utils uninstall beautifulsoup4 + +Use ``-y`` to skip the request for confirmation. diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 767b170..bf7fae4 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -4,6 +4,7 @@ from click_default_group import DefaultGroup # type: ignore from datetime import datetime import hashlib import pathlib +from runpy import run_module import sqlite_utils from sqlite_utils.db import AlterError, BadMultiValues, DescIndex, NoTable from sqlite_utils.utils import maximize_csv_field_size_limit @@ -2657,6 +2658,30 @@ def _analyze(db, tables, columns, save): click.echo(details) +@cli.command() +@click.argument("packages", nargs=-1, required=True) +@click.option( + "-U", "--upgrade", is_flag=True, help="Upgrade packages to latest version" +) +def install(packages, upgrade): + """Install packages from PyPI into the same environment as sqlite-utils""" + args = ["pip", "install"] + if upgrade: + args += ["--upgrade"] + args += list(packages) + sys.argv = args + run_module("pip", run_name="__main__") + + +@cli.command() +@click.argument("packages", nargs=-1, required=True) +@click.option("-y", "--yes", is_flag=True, help="Don't ask for confirmation") +def uninstall(packages, yes): + """Uninstall Python packages from the sqlite-utils environment""" + sys.argv = ["pip", "uninstall"] + list(packages) + (["-y"] if yes else []) + run_module("pip", run_name="__main__") + + def _generate_convert_help(): help = textwrap.dedent( """ From ee74bd5f8149b5e4403a4b56c74e9b94dbda2a32 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 26 Sep 2022 12:03:54 -0700 Subject: [PATCH 227/563] Fix heading levels, refs #483 --- docs/cli.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/cli.rst b/docs/cli.rst index 5f85e55..ecac91b 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -2120,7 +2120,7 @@ See this `SpatiaLite Cookbook recipe ` and :ref:`query -\\-functions ` options can be provided with a Python script that imports additional modules from the ``sqlite-utils`` environment. @@ -2135,7 +2135,7 @@ Use ``-U`` to upgrade an existing package. .. _cli_uninstall: Uninstalling packages ---------------------- +===================== You can uninstall packages that were installed using ``sqlite-utils install`` with ``sqlite-utils uninstall ``:: From 9a5add659d87738a658d8610ee461b038e28d268 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 26 Sep 2022 12:09:00 -0700 Subject: [PATCH 228/563] 'just docs' command for running the livehtml docs server --- Justfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Justfile b/Justfile index 52e6190..4fe3fb9 100644 --- a/Justfile +++ b/Justfile @@ -16,6 +16,9 @@ @cog: cog -r README.md docs/*.rst +@docs: cog + cd docs && pipenv run make livehtml + # Apply Black @black: pipenv run black . From afbd2b2cba45cccb305c3d4638d18db4dd3d4bbd Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 26 Sep 2022 12:09:32 -0700 Subject: [PATCH 229/563] Link to convert command too, refs #483 --- docs/cli.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cli.rst b/docs/cli.rst index ecac91b..8bc4176 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -2122,7 +2122,7 @@ See this `SpatiaLite Cookbook recipe ` and :ref:`query -\\-functions ` options can be provided with a Python script that imports additional modules from the ``sqlite-utils`` environment. +The :ref:`convert command ` and the :ref:`insert -\\-convert ` and :ref:`query -\\-functions ` options can be provided with a Python script that imports additional modules from the ``sqlite-utils`` environment. You can install packages from PyPI directly into the correct environment using ``sqlite-utils install ``. This is a wrapper around ``pip install``. From 6b268a1b3664784ed2267482d8c1d021a597d2b2 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 26 Sep 2022 12:26:04 -0700 Subject: [PATCH 230/563] language = "en" to fix Sphinx warning --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 1339477..62d5ab1 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -93,7 +93,7 @@ else: # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = None +language = "en" # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. From c7cad6fc257c178b24b3f574b8c6992002c6f072 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 26 Sep 2022 12:26:31 -0700 Subject: [PATCH 231/563] Documentation for Just, closes #494 Also added 'just init' and fixed a couple of missing pipenv run calls. --- Justfile | 8 ++++++-- docs/contributing.rst | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/Justfile b/Justfile index 4fe3fb9..a479717 100644 --- a/Justfile +++ b/Justfile @@ -1,6 +1,10 @@ # Run tests and linters @default: test lint +# Setup project +@init: + pipenv run pip install -e '.[test,docs,mypy,flake8]' + # Run pytest with supplied options @test *options: pipenv run pytest {{options}} @@ -10,11 +14,11 @@ pipenv run black . --check pipenv run flake8 pipenv run mypy sqlite_utils tests - cog --check README.md docs/*.rst + pipenv run cog --check README.md docs/*.rst # Rebuild docs with cog @cog: - cog -r README.md docs/*.rst + pipenv run cog -r README.md docs/*.rst @docs: cog cd docs && pipenv run make livehtml diff --git a/docs/contributing.rst b/docs/contributing.rst index a1041f5..ef5a875 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -79,6 +79,46 @@ Both commands can then be run in the root of the project like this:: All three of these tools are run by our CI mechanism against every commit and pull request. +Using Just and pipenv +===================== + +If you install `Just `__ and `pipenv `__ you can use them to manage your local development environment. + +To create a virtual environment and install all development dependencies, run:: + + cd sqlite-utils + just init + +To run all of the tests and linters:: + + just + +To run tests, or run a specific test module or test by name:: + + just test # All tests + just test tests/test_cli_memory.py # Just this module + just test -k test_memory_no_detect_types # Just this test + +To run just the linters:: + + just lint + +To apply Black to your code:: + + just black + +To update documentation using Cog:: + + just cog + +To run the live documentation server (after building with Cog first):: + + just docs + +And to list all available commands:: + + just -l + .. _release_process: Release process From cf9861216b8f2200535482f37d2f83f25a934493 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 26 Sep 2022 12:36:09 -0700 Subject: [PATCH 232/563] Comment for 'just docs' command This shows up in 'just -l' --- Justfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Justfile b/Justfile index a479717..66863f6 100644 --- a/Justfile +++ b/Justfile @@ -20,6 +20,7 @@ @cog: pipenv run cog -r README.md docs/*.rst +# Serve live docs on localhost:8000 @docs: cog cd docs && pipenv run make livehtml From cbed0807822dd3ba0e51b99c6b28125422f690f0 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 26 Sep 2022 17:10:59 -0700 Subject: [PATCH 233/563] Typo --- docs/contributing.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/contributing.rst b/docs/contributing.rst index ef5a875..3a68b40 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -124,7 +124,7 @@ And to list all available commands:: Release process =============== -Releases are performed using tags. When a new release is published on GitHub, a `GitHub Action workflow `__ will perform the following: +Releases are performed using tags. When a new release is published on GitHub, a `GitHub Actions workflow `__ will perform the following: * Run the unit tests against all supported Python versions. If the tests pass... * Build a wheel bundle of the underlying Python source code From d792dad1cf5f16525da81b1e162fb71d469995f3 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 26 Sep 2022 19:23:17 -0700 Subject: [PATCH 234/563] Clarify wording --- docs/contributing.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/contributing.rst b/docs/contributing.rst index 3a68b40..4a13725 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -111,7 +111,7 @@ To update documentation using Cog:: just cog -To run the live documentation server (after building with Cog first):: +To run the live documentation server (this will run Cog first):: just docs From 34e75ed0dd3091a6f94d6bd70150caa70660736d Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 18 Oct 2022 11:00:25 -0700 Subject: [PATCH 235/563] sqlite_utils.utils.flatten() function, closes #500 --- docs/reference.rst | 7 +++++++ sqlite_utils/cli.py | 14 +++----------- sqlite_utils/utils.py | 18 ++++++++++++++++++ tests/test_cli.py | 12 ------------ tests/test_utils.py | 12 ++++++++++++ 5 files changed, 40 insertions(+), 23 deletions(-) diff --git a/docs/reference.rst b/docs/reference.rst index 66b9d38..5b5fd25 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -101,3 +101,10 @@ sqlite_utils.utils.chunks ------------------------- .. autofunction:: sqlite_utils.utils.chunks + +.. _reference_utils_flatten: + +sqlite_utils.utils.flatten +-------------------------- + +.. autofunction:: sqlite_utils.utils.flatten diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index bf7fae4..5fcc95a 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -24,6 +24,7 @@ from .utils import ( chunks, file_progress, find_spatialite, + flatten as _flatten, sqlite3, decode_base64_values, progressbar, @@ -997,7 +998,7 @@ def insert_upsert_implementation( "Invalid JSON - use --csv for CSV or --tsv for TSV files" ) if flatten: - docs = (dict(_flatten(doc)) for doc in docs) + docs = (_flatten(doc) for doc in docs) if convert: variable = "row" @@ -1079,15 +1080,6 @@ def insert_upsert_implementation( db[table].transform(types=tracker.types) -def _flatten(d): - for key, value in d.items(): - if isinstance(value, dict): - for key2, value2 in _flatten(value): - yield key + "_" + key2, value2 - else: - yield key, value - - def _find_variables(tb, vars): to_find = list(vars) found = {} @@ -1845,7 +1837,7 @@ def memory( tracker = TypeTracker() rows = tracker.wrap(rows) if flatten: - rows = (dict(_flatten(row)) for row in rows) + rows = (_flatten(row) for row in rows) db[csv_table].insert_all(rows, alter=True) if tracker is not None: db[csv_table].transform(types=tracker.types) diff --git a/sqlite_utils/utils.py b/sqlite_utils/utils.py index 8754554..8916c46 100644 --- a/sqlite_utils/utils.py +++ b/sqlite_utils/utils.py @@ -513,3 +513,21 @@ def hash_record(record: Dict, keys: Optional[Iterable[str]] = None): "utf8" ) ).hexdigest() + + +def _flatten(d): + for key, value in d.items(): + if isinstance(value, dict): + for key2, value2 in _flatten(value): + yield key + "_" + key2, value2 + else: + yield key, value + + +def flatten(row: dict) -> dict: + """ + Turn a nested dict e.g. ``{"a": {"b": 1}}`` into a flat dict: ``{"a_b": 1}`` + + :param row: A Python dictionary, optionally with nested dictionaries + """ + return dict(_flatten(row)) diff --git a/tests/test_cli.py b/tests/test_cli.py index 24f36ed..b8df8d6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2176,18 +2176,6 @@ def test_upsert_detect_types(tmpdir, option): ] -@pytest.mark.parametrize( - "input,expected", - ( - ({"foo": {"bar": 1}}, {"foo_bar": 1}), - ({"foo": {"bar": [1, 2, {"baz": 3}]}}, {"foo_bar": [1, 2, {"baz": 3}]}), - ({"foo": {"bar": 1, "baz": {"three": 3}}}, {"foo_bar": 1, "foo_baz_three": 3}), - ), -) -def test_flatten_helper(input, expected): - assert dict(cli._flatten(input)) == expected - - def test_integer_overflow_error(tmpdir): db_path = str(tmpdir / "test.db") result = CliRunner().invoke( diff --git a/tests/test_utils.py b/tests/test_utils.py index d397176..f728bcd 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -71,3 +71,15 @@ def test_maximize_csv_field_size_limit(): assert len(rows_list2) == 1 assert rows_list2[0]["id"] == "1" assert rows_list2[0]["text"] == long_value + + +@pytest.mark.parametrize( + "input,expected", + ( + ({"foo": {"bar": 1}}, {"foo_bar": 1}), + ({"foo": {"bar": [1, 2, {"baz": 3}]}}, {"foo_bar": [1, 2, {"baz": 3}]}), + ({"foo": {"bar": 1, "baz": {"three": 3}}}, {"foo_bar": 1, "foo_baz_three": 3}), + ), +) +def test_flatten(input, expected): + assert utils.flatten(input) == expected From eb67fc69a227276b8ea635b885e5e4baecc43180 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 18 Oct 2022 11:08:34 -0700 Subject: [PATCH 236/563] Run cog -r against latest tabulate, refs #501 --- docs/cli-reference.rst | 107 ++++++++++++++++++++++++++--------------- docs/cli.rst | 12 +++++ 2 files changed, 79 insertions(+), 40 deletions(-) diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index b88e38a..82b4b6c 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -112,11 +112,15 @@ See :ref:`cli_query`. --tsv Output TSV --no-headers Omit CSV headers -t, --table Output as a formatted table - --fmt TEXT Table format - one of fancy_grid, fancy_outline, - github, grid, html, jira, latex, latex_booktabs, - latex_longtable, latex_raw, mediawiki, moinmoin, - orgtbl, pipe, plain, presto, pretty, psql, rst, - simple, textile, tsv, unsafehtml, youtrack + --fmt TEXT Table format - one of asciidoc, double_grid, + double_outline, fancy_grid, fancy_outline, github, + grid, heavy_grid, heavy_outline, html, jira, + latex, latex_booktabs, latex_longtable, latex_raw, + mediawiki, mixed_grid, mixed_outline, moinmoin, + orgtbl, outline, pipe, plain, presto, pretty, + psql, rounded_grid, rounded_outline, rst, simple, + simple_grid, simple_outline, textile, tsv, + unsafehtml, youtrack --json-cols Detect JSON cols and output them as JSON, not escaped strings -r, --raw Raw output, first column of first row @@ -176,11 +180,15 @@ See :ref:`cli_memory`. --tsv Output TSV --no-headers Omit CSV headers -t, --table Output as a formatted table - --fmt TEXT Table format - one of fancy_grid, fancy_outline, - github, grid, html, jira, latex, latex_booktabs, - latex_longtable, latex_raw, mediawiki, moinmoin, - orgtbl, pipe, plain, presto, pretty, psql, rst, - simple, textile, tsv, unsafehtml, youtrack + --fmt TEXT Table format - one of asciidoc, double_grid, + double_outline, fancy_grid, fancy_outline, github, + grid, heavy_grid, heavy_outline, html, jira, + latex, latex_booktabs, latex_longtable, latex_raw, + mediawiki, mixed_grid, mixed_outline, moinmoin, + orgtbl, outline, pipe, plain, presto, pretty, + psql, rounded_grid, rounded_outline, rst, simple, + simple_grid, simple_outline, textile, tsv, + unsafehtml, youtrack --json-cols Detect JSON cols and output them as JSON, not escaped strings -r, --raw Raw output, first column of first row @@ -401,11 +409,14 @@ See :ref:`cli_search`. --tsv Output TSV --no-headers Omit CSV headers -t, --table Output as a formatted table - --fmt TEXT Table format - one of fancy_grid, fancy_outline, - github, grid, html, jira, latex, latex_booktabs, - latex_longtable, latex_raw, mediawiki, moinmoin, - orgtbl, pipe, plain, presto, pretty, psql, rst, simple, - textile, tsv, unsafehtml, youtrack + --fmt TEXT Table format - one of asciidoc, double_grid, + double_outline, fancy_grid, fancy_outline, github, + grid, heavy_grid, heavy_outline, html, jira, latex, + latex_booktabs, latex_longtable, latex_raw, mediawiki, + mixed_grid, mixed_outline, moinmoin, orgtbl, outline, + pipe, plain, presto, pretty, psql, rounded_grid, + rounded_outline, rst, simple, simple_grid, + simple_outline, textile, tsv, unsafehtml, youtrack --json-cols Detect JSON cols and output them as JSON, not escaped strings --load-extension TEXT Path to SQLite extension, with optional :entrypoint @@ -651,11 +662,14 @@ See :ref:`cli_tables`. --tsv Output TSV --no-headers Omit CSV headers -t, --table Output as a formatted table - --fmt TEXT Table format - one of fancy_grid, fancy_outline, - github, grid, html, jira, latex, latex_booktabs, - latex_longtable, latex_raw, mediawiki, moinmoin, - orgtbl, pipe, plain, presto, pretty, psql, rst, simple, - textile, tsv, unsafehtml, youtrack + --fmt TEXT Table format - one of asciidoc, double_grid, + double_outline, fancy_grid, fancy_outline, github, + grid, heavy_grid, heavy_outline, html, jira, latex, + latex_booktabs, latex_longtable, latex_raw, mediawiki, + mixed_grid, mixed_outline, moinmoin, orgtbl, outline, + pipe, plain, presto, pretty, psql, rounded_grid, + rounded_outline, rst, simple, simple_grid, + simple_outline, textile, tsv, unsafehtml, youtrack --json-cols Detect JSON cols and output them as JSON, not escaped strings --columns Include list of columns for each table @@ -689,11 +703,14 @@ See :ref:`cli_views`. --tsv Output TSV --no-headers Omit CSV headers -t, --table Output as a formatted table - --fmt TEXT Table format - one of fancy_grid, fancy_outline, - github, grid, html, jira, latex, latex_booktabs, - latex_longtable, latex_raw, mediawiki, moinmoin, - orgtbl, pipe, plain, presto, pretty, psql, rst, simple, - textile, tsv, unsafehtml, youtrack + --fmt TEXT Table format - one of asciidoc, double_grid, + double_outline, fancy_grid, fancy_outline, github, + grid, heavy_grid, heavy_outline, html, jira, latex, + latex_booktabs, latex_longtable, latex_raw, mediawiki, + mixed_grid, mixed_outline, moinmoin, orgtbl, outline, + pipe, plain, presto, pretty, psql, rounded_grid, + rounded_outline, rst, simple, simple_grid, + simple_outline, textile, tsv, unsafehtml, youtrack --json-cols Detect JSON cols and output them as JSON, not escaped strings --columns Include list of columns for each view @@ -732,11 +749,15 @@ See :ref:`cli_rows`. --tsv Output TSV --no-headers Omit CSV headers -t, --table Output as a formatted table - --fmt TEXT Table format - one of fancy_grid, fancy_outline, - github, grid, html, jira, latex, latex_booktabs, - latex_longtable, latex_raw, mediawiki, moinmoin, - orgtbl, pipe, plain, presto, pretty, psql, rst, - simple, textile, tsv, unsafehtml, youtrack + --fmt TEXT Table format - one of asciidoc, double_grid, + double_outline, fancy_grid, fancy_outline, github, + grid, heavy_grid, heavy_outline, html, jira, + latex, latex_booktabs, latex_longtable, latex_raw, + mediawiki, mixed_grid, mixed_outline, moinmoin, + orgtbl, outline, pipe, plain, presto, pretty, + psql, rounded_grid, rounded_outline, rst, simple, + simple_grid, simple_outline, textile, tsv, + unsafehtml, youtrack --json-cols Detect JSON cols and output them as JSON, not escaped strings --load-extension TEXT Path to SQLite extension, with optional @@ -768,11 +789,14 @@ See :ref:`cli_triggers`. --tsv Output TSV --no-headers Omit CSV headers -t, --table Output as a formatted table - --fmt TEXT Table format - one of fancy_grid, fancy_outline, - github, grid, html, jira, latex, latex_booktabs, - latex_longtable, latex_raw, mediawiki, moinmoin, - orgtbl, pipe, plain, presto, pretty, psql, rst, simple, - textile, tsv, unsafehtml, youtrack + --fmt TEXT Table format - one of asciidoc, double_grid, + double_outline, fancy_grid, fancy_outline, github, + grid, heavy_grid, heavy_outline, html, jira, latex, + latex_booktabs, latex_longtable, latex_raw, mediawiki, + mixed_grid, mixed_outline, moinmoin, orgtbl, outline, + pipe, plain, presto, pretty, psql, rounded_grid, + rounded_outline, rst, simple, simple_grid, + simple_outline, textile, tsv, unsafehtml, youtrack --json-cols Detect JSON cols and output them as JSON, not escaped strings --load-extension TEXT Path to SQLite extension, with optional :entrypoint @@ -804,11 +828,14 @@ See :ref:`cli_indexes`. --tsv Output TSV --no-headers Omit CSV headers -t, --table Output as a formatted table - --fmt TEXT Table format - one of fancy_grid, fancy_outline, - github, grid, html, jira, latex, latex_booktabs, - latex_longtable, latex_raw, mediawiki, moinmoin, - orgtbl, pipe, plain, presto, pretty, psql, rst, simple, - textile, tsv, unsafehtml, youtrack + --fmt TEXT Table format - one of asciidoc, double_grid, + double_outline, fancy_grid, fancy_outline, github, + grid, heavy_grid, heavy_outline, html, jira, latex, + latex_booktabs, latex_longtable, latex_raw, mediawiki, + mixed_grid, mixed_outline, moinmoin, orgtbl, outline, + pipe, plain, presto, pretty, psql, rounded_grid, + rounded_outline, rst, simple, simple_grid, + simple_outline, textile, tsv, unsafehtml, youtrack --json-cols Detect JSON cols and output them as JSON, not escaped strings --load-extension TEXT Path to SQLite extension, with optional :entrypoint diff --git a/docs/cli.rst b/docs/cli.rst index 8bc4176..1d67e88 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -187,10 +187,15 @@ Available ``--fmt`` options are: cog.out("\n" + "\n".join('- ``{}``'.format(t) for t in tabulate.tabulate_formats) + "\n\n") .. ]]] +- ``asciidoc`` +- ``double_grid`` +- ``double_outline`` - ``fancy_grid`` - ``fancy_outline`` - ``github`` - ``grid`` +- ``heavy_grid`` +- ``heavy_outline`` - ``html`` - ``jira`` - ``latex`` @@ -198,15 +203,22 @@ Available ``--fmt`` options are: - ``latex_longtable`` - ``latex_raw`` - ``mediawiki`` +- ``mixed_grid`` +- ``mixed_outline`` - ``moinmoin`` - ``orgtbl`` +- ``outline`` - ``pipe`` - ``plain`` - ``presto`` - ``pretty`` - ``psql`` +- ``rounded_grid`` +- ``rounded_outline`` - ``rst`` - ``simple`` +- ``simple_grid`` +- ``simple_outline`` - ``textile`` - ``tsv`` - ``unsafehtml`` From 9cbe19ac0547031f3b626d9d18ef05c0d193bf79 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 18 Oct 2022 11:16:43 -0700 Subject: [PATCH 237/563] Skip cog check on Python 3.6, refs #501 --- .github/workflows/test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 788df25..e994c25 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -49,5 +49,6 @@ jobs: - name: Check formatting run: black . --check - name: Check if cog needs to be run + if: matrix.python-version != '3.6' run: | cog --check README.md docs/*.rst From b8526c434a3d6aafb4102f9d9f5da14dfc4e3002 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 25 Oct 2022 07:17:49 -0700 Subject: [PATCH 238/563] Test against Python 3.11 --- .github/workflows/publish.yml | 14 +++++++------- .github/workflows/test.yml | 6 +++--- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 355f271..f3627d3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -9,15 +9,15 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - python-version: ["3.6", "3.7", "3.8", "3.9", "3.10.6"] + python-version: ["3.6", "3.7", "3.8", "3.9", "3.10", "3.11"] os: [ubuntu-latest, windows-latest, macos-latest] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - - uses: actions/cache@v2 + - uses: actions/cache@v3 name: Configure pip caching with: path: ~/.cache/pip @@ -34,12 +34,12 @@ jobs: runs-on: ubuntu-latest needs: [test] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: - python-version: '3.9' - - uses: actions/cache@v2 + python-version: '3.11' + - uses: actions/cache@v3 name: Configure pip caching with: path: ~/.cache/pip diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e994c25..98729c9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,16 +10,16 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - python-version: ["3.6", "3.7", "3.8", "3.9", "3.10.6"] + python-version: ["3.6", "3.7", "3.8", "3.9", "3.10", "3.11"] numpy: [0, 1] os: [ubuntu-latest, macos-latest, windows-latest] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - - uses: actions/cache@v2 + - uses: actions/cache@v3 name: Configure pip caching with: path: ~/.cache/pip From 5133339d00252cb258a4217eda830ac60f43ee1f Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 25 Oct 2022 12:08:58 -0700 Subject: [PATCH 239/563] Skip macos-latest Python 3.11 for the moment Refs https://github.com/actions/setup-python/issues/531 --- .github/workflows/test.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 98729c9..ba88451 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,6 +13,10 @@ jobs: python-version: ["3.6", "3.7", "3.8", "3.9", "3.10", "3.11"] numpy: [0, 1] os: [ubuntu-latest, macos-latest, windows-latest] + exclude: + # https://github.com/actions/setup-python/issues/531 + - os: macos-latest + python-version: "3.11" steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} From 7b2d1c0ffd0b874e280292b926f328a61cb31e2c Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 25 Oct 2022 12:23:20 -0700 Subject: [PATCH 240/563] Update tests for Python 3.11, closes #502 --- tests/test_cli.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index b8df8d6..20fe99e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -737,10 +737,7 @@ def test_query_invalid_function(db_path): cli.cli, [db_path, "select bad()", "--functions", "def invalid_python"] ) assert result.exit_code == 1 - assert ( - result.output.strip() - == "Error: Error in functions definition: invalid syntax (, line 1)" - ) + assert result.output.startswith("Error: Error in functions definition:") TEST_FUNCTIONS = """ From 079bf1f4dc8540f834adae68c7feeeffcbc1d4f2 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 25 Oct 2022 13:06:58 -0700 Subject: [PATCH 241/563] Use tmp_path fixture in test_recreate, refs #503 --- tests/test_recreate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_recreate.py b/tests/test_recreate.py index 504a0b8..91f1ba5 100644 --- a/tests/test_recreate.py +++ b/tests/test_recreate.py @@ -21,8 +21,8 @@ def test_recreate_not_allowed_for_connection(): @pytest.mark.parametrize( "use_path,file_exists", [(True, True), (True, False), (False, True), (False, False)] ) -def test_recreate(tmpdir, use_path, file_exists): - filepath = str(tmpdir / "data.db") +def test_recreate(tmp_path, use_path, file_exists): + filepath = str(tmp_path / "data.db") if use_path: filepath = pathlib.Path(filepath) if file_exists: From 7ca497a8f5be24c127946813e3052a19b48be1b3 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 25 Oct 2022 13:14:41 -0700 Subject: [PATCH 242/563] repr improvements, refs #503 --- sqlite_utils/db.py | 3 ++- sqlite_utils/utils.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index 27c46b0..0d9eaed 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -308,6 +308,7 @@ class Database: assert (filename_or_conn is not None and (not memory and not memory_name)) or ( filename_or_conn is None and (memory or memory_name) ), "Either specify a filename_or_conn or pass memory=True" + self.conn = None if memory_name: uri = "file:{}?mode=memory&cache=shared".format(memory_name) self.conn = sqlite3.connect( @@ -3534,7 +3535,7 @@ class View(Queryable): def exists(self): return True - def __repr__(self): + def __repr__(self) -> str: return "".format( self.name, ", ".join(c.name for c in self.columns) ) diff --git a/sqlite_utils/utils.py b/sqlite_utils/utils.py index 8916c46..4e5bbcc 100644 --- a/sqlite_utils/utils.py +++ b/sqlite_utils/utils.py @@ -392,7 +392,7 @@ class ValueTracker: except (ValueError, TypeError): return False - def __repr__(self): + def __repr__(self) -> str: return self.guessed_type + ": possibilities = " + repr(self.couldbe) @property From c5d7ec1dd71fa1dce829bc8bb82b639018befd63 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 25 Oct 2022 13:21:37 -0700 Subject: [PATCH 243/563] Fix for mypy issue, refs #503 --- sqlite_utils/db.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index 0d9eaed..80ad026 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -308,7 +308,6 @@ class Database: assert (filename_or_conn is not None and (not memory and not memory_name)) or ( filename_or_conn is None and (memory or memory_name) ), "Either specify a filename_or_conn or pass memory=True" - self.conn = None if memory_name: uri = "file:{}?mode=memory&cache=shared".format(memory_name) self.conn = sqlite3.connect( @@ -320,7 +319,13 @@ class Database: self.conn = sqlite3.connect(":memory:") elif isinstance(filename_or_conn, (str, pathlib.Path)): if recreate and os.path.exists(filename_or_conn): - os.remove(filename_or_conn) + try: + os.remove(filename_or_conn) + except OSError: + # Avoid mypy and __repr__ errors, see: + # https://github.com/simonw/sqlite-utils/issues/503 + self.conn = sqlite3.connect(":memory:") + raise self.conn = sqlite3.connect(str(filename_or_conn)) else: assert not recreate, "recreate cannot be used with connections, only paths" From f6b796277f783fcb613136e5a230b8657ef6c090 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 25 Oct 2022 13:27:18 -0700 Subject: [PATCH 244/563] Try a 0.1s sleep, refs #503 --- tests/test_recreate.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_recreate.py b/tests/test_recreate.py index 91f1ba5..be161ff 100644 --- a/tests/test_recreate.py +++ b/tests/test_recreate.py @@ -1,5 +1,6 @@ from sqlite_utils import Database import sqlite3 +import time import pathlib import pytest @@ -19,14 +20,16 @@ def test_recreate_not_allowed_for_connection(): @pytest.mark.parametrize( - "use_path,file_exists", [(True, True), (True, False), (False, True), (False, False)] + "use_path,create_file_first", + [(True, True), (True, False), (False, True), (False, False)], ) -def test_recreate(tmp_path, use_path, file_exists): +def test_recreate(tmp_path, use_path, create_file_first): filepath = str(tmp_path / "data.db") if use_path: filepath = pathlib.Path(filepath) - if file_exists: + if create_file_first: Database(filepath)["t1"].insert({"foo": "bar"}) assert ["t1"] == Database(filepath).table_names() + time.sleep(0.1) Database(filepath, recreate=True)["t2"].insert({"foo": "bar"}) assert ["t2"] == Database(filepath).table_names() From ba7242b1f231a9707944a4c11a8dfede9ff9cad0 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 25 Oct 2022 13:36:17 -0700 Subject: [PATCH 245/563] Try closing the database before recreating it, refs #503 --- tests/test_recreate.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_recreate.py b/tests/test_recreate.py index be161ff..0b98967 100644 --- a/tests/test_recreate.py +++ b/tests/test_recreate.py @@ -1,6 +1,5 @@ from sqlite_utils import Database import sqlite3 -import time import pathlib import pytest @@ -28,8 +27,9 @@ def test_recreate(tmp_path, use_path, create_file_first): if use_path: filepath = pathlib.Path(filepath) if create_file_first: - Database(filepath)["t1"].insert({"foo": "bar"}) - assert ["t1"] == Database(filepath).table_names() - time.sleep(0.1) + db = Database(filepath) + db["t1"].insert({"foo": "bar"}) + assert ["t1"] == db.table_names() + db.conn.close() Database(filepath, recreate=True)["t2"].insert({"foo": "bar"}) assert ["t2"] == Database(filepath).table_names() From 05e2bb85fcd11729db40c6452f2f7287232e2f1a Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 25 Oct 2022 13:57:43 -0700 Subject: [PATCH 246/563] db.close() method, closes #504 --- sqlite_utils/db.py | 4 ++++ tests/test_constructor.py | 14 ++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index 80ad026..b8e8d4b 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -336,6 +336,10 @@ class Database: self._registered_functions: set = set() self.use_counts_table = use_counts_table + def close(self): + "Close the SQLite connection, and the underlying database file" + self.conn.close() + @contextlib.contextmanager def tracer(self, tracer: Callable = None): """ diff --git a/tests/test_constructor.py b/tests/test_constructor.py index d8c8d72..8743911 100644 --- a/tests/test_constructor.py +++ b/tests/test_constructor.py @@ -1,4 +1,6 @@ from sqlite_utils import Database +from sqlite_utils.utils import sqlite3 +import pytest def test_recursive_triggers(): @@ -25,3 +27,15 @@ def test_sqlite_version(): as_string = ".".join(map(str, version)) actual = next(db.query("select sqlite_version() as v"))["v"] assert actual == as_string + + +@pytest.mark.parametrize("memory", [True, False]) +def test_database_close(tmpdir, memory): + if memory: + db = Database(memory=True) + else: + db = Database(str(tmpdir / "test.db")) + assert db.execute("select 1 + 1").fetchone()[0] == 2 + db.close() + with pytest.raises(sqlite3.ProgrammingError): + db.execute("select 1 + 1") From c7e4308e6f49d929704163531632e558f9646e4a Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 25 Oct 2022 14:00:53 -0700 Subject: [PATCH 247/563] Use new db.close() method, refs #504 --- tests/test_recreate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_recreate.py b/tests/test_recreate.py index 0b98967..49dba2b 100644 --- a/tests/test_recreate.py +++ b/tests/test_recreate.py @@ -30,6 +30,6 @@ def test_recreate(tmp_path, use_path, create_file_first): db = Database(filepath) db["t1"].insert({"foo": "bar"}) assert ["t1"] == db.table_names() - db.conn.close() + db.close() Database(filepath, recreate=True)["t2"].insert({"foo": "bar"}) assert ["t2"] == Database(filepath).table_names() From defa2974c6d3abc19be28d6b319649b8028dc966 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 25 Oct 2022 14:23:24 -0700 Subject: [PATCH 248/563] jsonify_if_needed output of convert() functions, closes #495 --- sqlite_utils/db.py | 2 +- tests/test_convert.py | 26 ++++++++++++++++++++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index b8e8d4b..a06f4b7 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -2662,7 +2662,7 @@ class Table(Queryable): bar.update(1) if not v: return v - return fn(v) + return jsonify_if_needed(fn(v)) self.db.register_function(convert_value) sql = "update [{table}] set {sets}{where};".format( diff --git a/tests/test_convert.py b/tests/test_convert.py index 796a08f..31a9d28 100644 --- a/tests/test_convert.py +++ b/tests/test_convert.py @@ -15,6 +15,14 @@ import pytest lambda value: value.upper(), {"title": "MIXED CASE", "abstract": "ABSTRACT"}, ), + ( + "title", + lambda value: {"upper": value.upper(), "lower": value.lower()}, + { + "title": '{"upper": "MIXED CASE", "lower": "mixed case"}', + "abstract": "Abstract", + }, + ), ), ) def test_convert(fresh_db, columns, fn, expected): @@ -81,10 +89,24 @@ def test_convert_multi(fresh_db): table = fresh_db["table"] table.insert({"title": "Mixed Case"}) table.convert( - "title", lambda v: {"upper": v.upper(), "lower": v.lower()}, multi=True + "title", + lambda v: { + "upper": v.upper(), + "lower": v.lower(), + "both": { + "upper": v.upper(), + "lower": v.lower(), + }, + }, + multi=True, ) assert list(table.rows) == [ - {"title": "Mixed Case", "upper": "MIXED CASE", "lower": "mixed case"} + { + "title": "Mixed Case", + "upper": "MIXED CASE", + "lower": "mixed case", + "both": '{"upper": "MIXED CASE", "lower": "mixed case"}', + } ] From 0d45ee11027700f184383d5c8c25a26770fcf471 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 25 Oct 2022 15:21:34 -0700 Subject: [PATCH 249/563] Release 3.30 Refs #480, #483, #485, #495, #500, #502, #504 --- docs/changelog.rst | 15 +++++++++++++++ docs/contributing.rst | 2 ++ setup.py | 2 +- 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index adeb6b1..c3c12f2 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,21 @@ Changelog =========== +.. _v3_30: + +3.30 (2022-10-25) +----------------- + +- Now tested against Python 3.11. (:issue:`502`) +- New ``table.search_sql(include_rank=True)`` option, which adds a ``rank`` column to the generated SQL. Thanks, Jacob Chapman. (`#480 `__) +- Progress bars now display for newline-delimited JSON files using the ``--nl`` option. Thanks, Mischa Untaga. (:issue:`485`) +- New ``db.close()`` method. (:issue:`504`) +- Conversion functions passed to :ref:`table.convert(...) ` can now return lists or dictionaries, which will be inserted into the database as JSON strings. (:issue:`495`) +- ``sqlite-utils install`` and ``sqlite-utils uninstall`` commands for installing packages into the same virtual environment as ``sqlite-utils``, :ref:`described here `. (:issue:`483`) +- New :ref:`sqlite_utils.utils.flatten() ` utility function. (:issue:`500`) +- Documentation on :ref:`using Just ` to run tests, linters and build documentation. +- Documentation now covers the :ref:`release_process` for this package. + .. _v3_29: 3.29 (2022-08-27) diff --git a/docs/contributing.rst b/docs/contributing.rst index 4a13725..2e883ee 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -79,6 +79,8 @@ Both commands can then be run in the root of the project like this:: All three of these tools are run by our CI mechanism against every commit and pull request. +.. _contributing_just: + Using Just and pipenv ===================== diff --git a/setup.py b/setup.py index 88b186b..2cf7af2 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages import io import os -VERSION = "3.29" +VERSION = "3.30" def get_long_description(): From fb8f495582f68d8d49f57b42d12a66802f9ac238 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 25 Oct 2022 15:34:30 -0700 Subject: [PATCH 250/563] Skip macOS 3.11 test when publishing Refs #505 --- .github/workflows/publish.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f3627d3..424e7ff 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -11,6 +11,10 @@ jobs: matrix: python-version: ["3.6", "3.7", "3.8", "3.9", "3.10", "3.11"] os: [ubuntu-latest, windows-latest, macos-latest] + exclude: + # https://github.com/actions/setup-python/issues/531 + - os: macos-latest + python-version: "3.11" steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} From 529110e7d8c4a6b1bbf5fb61f2e29d72aa95a611 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 26 Oct 2022 12:27:32 -0700 Subject: [PATCH 251/563] GitHub Actions has Python 3.11 on macOS now Refs https://github.com/actions/setup-python/issues/531 --- .github/workflows/publish.yml | 4 ---- .github/workflows/test.yml | 4 ---- 2 files changed, 8 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 424e7ff..f3627d3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -11,10 +11,6 @@ jobs: matrix: python-version: ["3.6", "3.7", "3.8", "3.9", "3.10", "3.11"] os: [ubuntu-latest, windows-latest, macos-latest] - exclude: - # https://github.com/actions/setup-python/issues/531 - - os: macos-latest - python-version: "3.11" steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ba88451..98729c9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,10 +13,6 @@ jobs: python-version: ["3.6", "3.7", "3.8", "3.9", "3.10", "3.11"] numpy: [0, 1] os: [ubuntu-latest, macos-latest, windows-latest] - exclude: - # https://github.com/actions/setup-python/issues/531 - - os: macos-latest - python-version: "3.11" steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} From 52ddb0b9ffa5284be668da088b7600b6ff64a2f9 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 15 Nov 2022 07:53:38 -0800 Subject: [PATCH 252/563] Rename utility functions to library --- docs/conf.py | 2 +- docs/index.rst | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 62d5ab1..8d49b81 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -187,7 +187,7 @@ texinfo_documents = [ "sqlite-utils documentation", author, "sqlite-utils", - "Python utility functions for manipulating SQLite databases", + "Python library for manipulating SQLite databases", "Miscellaneous", ) ] diff --git a/docs/index.rst b/docs/index.rst index 6793255..2bca2c8 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -15,7 +15,7 @@ .. |discord| image:: https://img.shields.io/discord/823971286308356157?label=discord :target: https://discord.gg/Ass7bCAMDw -*CLI tool and Python utility functions for manipulating SQLite databases* +*CLI tool and Python library for manipulating SQLite databases* This library and command-line utility helps create SQLite databases from an existing collection of data. diff --git a/setup.py b/setup.py index 2cf7af2..faa822b 100644 --- a/setup.py +++ b/setup.py @@ -15,7 +15,7 @@ def get_long_description(): setup( name="sqlite-utils", - description="CLI tool and Python utility functions for manipulating SQLite databases", + description="CLI tool and Python library for manipulating SQLite databases", long_description=get_long_description(), long_description_content_type="text/markdown", author="Simon Willison", From 965ca0d5f5bffe06cc02cd7741344d1ddddf9d56 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 15 Nov 2022 22:25:26 -0800 Subject: [PATCH 253/563] Apply no_implicit_optional codemod, closes #512 --- sqlite_utils/db.py | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index a06f4b7..e819d17 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -297,12 +297,12 @@ class Database: def __init__( self, - filename_or_conn: Union[str, pathlib.Path, sqlite3.Connection] = None, + filename_or_conn: Optional[Union[str, pathlib.Path, sqlite3.Connection]] = None, memory: bool = False, - memory_name: str = None, + memory_name: Optional[str] = None, recreate: bool = False, recursive_triggers: bool = True, - tracer: Callable = None, + tracer: Optional[Callable] = None, use_counts_table: bool = False, ): assert (filename_or_conn is not None and (not memory and not memory_name)) or ( @@ -341,7 +341,7 @@ class Database: self.conn.close() @contextlib.contextmanager - def tracer(self, tracer: Callable = None): + def tracer(self, tracer: Optional[Callable] = None): """ Context manager to temporarily set a tracer function - all executed SQL queries will be passed to this. @@ -378,7 +378,7 @@ class Database: def register_function( self, - fn: Callable = None, + fn: Optional[Callable] = None, deterministic: bool = False, replace: bool = False, name: Optional[str] = None, @@ -879,7 +879,7 @@ class Database: pk: Optional[Any] = None, foreign_keys: Optional[ForeignKeysType] = None, column_order: Optional[List[str]] = None, - not_null: Iterable[str] = None, + not_null: Optional[Iterable[str]] = None, defaults: Optional[Dict[str, Any]] = None, hash_id: Optional[str] = None, hash_id_columns: Optional[Iterable[str]] = None, @@ -1129,7 +1129,7 @@ class Database: sql += " [{}]".format(name) self.execute(sql) - def init_spatialite(self, path: str = None) -> bool: + def init_spatialite(self, path: Optional[str] = None) -> bool: """ The ``init_spatialite`` method will load and initialize the SpatiaLite extension. The ``path`` argument should be an absolute path to the compiled extension, which @@ -1182,7 +1182,7 @@ class Queryable: def count_where( self, - where: str = None, + where: Optional[str] = None, where_args: Optional[Union[Iterable, dict]] = None, ) -> int: """ @@ -1213,12 +1213,12 @@ class Queryable: def rows_where( self, - where: str = None, + where: Optional[str] = None, where_args: Optional[Union[Iterable, dict]] = None, - order_by: str = None, + order_by: Optional[str] = None, select: str = "*", - limit: int = None, - offset: int = None, + limit: Optional[int] = None, + offset: Optional[int] = None, ) -> Generator[dict, None, None]: """ Iterate over every row in this table or view that matches the specified where clause. @@ -1251,11 +1251,11 @@ class Queryable: def pks_and_rows_where( self, - where: str = None, + where: Optional[str] = None, where_args: Optional[Union[Iterable, dict]] = None, - order_by: str = None, - limit: int = None, - offset: int = None, + order_by: Optional[str] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, ) -> Generator[Tuple[Any, Dict], None, None]: """ Like ``.rows_where()`` but returns ``(pk, row)`` pairs - ``pk`` can be a single value or tuple. @@ -1345,7 +1345,7 @@ class Table(Queryable): pk: Optional[Any] = None, foreign_keys: Optional[ForeignKeysType] = None, column_order: Optional[List[str]] = None, - not_null: Iterable[str] = None, + not_null: Optional[Iterable[str]] = None, defaults: Optional[Dict[str, Any]] = None, batch_size: int = 100, hash_id: Optional[str] = None, @@ -1545,7 +1545,7 @@ class Table(Queryable): pk: Optional[Any] = None, foreign_keys: Optional[ForeignKeysType] = None, column_order: Optional[List[str]] = None, - not_null: Iterable[str] = None, + not_null: Optional[Iterable[str]] = None, defaults: Optional[Dict[str, Any]] = None, hash_id: Optional[str] = None, hash_id_columns: Optional[Iterable[str]] = None, @@ -2464,7 +2464,7 @@ class Table(Queryable): columns: Optional[Iterable[str]] = None, limit: Optional[int] = None, offset: Optional[int] = None, - where: str = None, + where: Optional[str] = None, where_args: Optional[Union[Iterable, dict]] = None, quote: bool = False, ) -> Generator[dict, None, None]: @@ -2527,7 +2527,7 @@ class Table(Queryable): def delete_where( self, - where: str = None, + where: Optional[str] = None, where_args: Optional[Union[Iterable, dict]] = None, analyze: bool = False, ) -> "Table": From ebe504ab2103b0426b21162fc30691e42e8abaa0 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 29 Nov 2022 09:03:35 -0800 Subject: [PATCH 254/563] Clarify column types in create-table help --- docs/cli-reference.rst | 2 ++ sqlite_utils/cli.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index 82b4b6c..153e5f9 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -886,6 +886,8 @@ See :ref:`cli_create_table`. height float \ photo blob --pk id + Valid column types are text, integer, float and blob. + Options: --pk TEXT Column to use as primary key --not-null TEXT Columns that should be created as NOT NULL diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 5fcc95a..d25b1df 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -1477,6 +1477,8 @@ def create_table( name text \\ height float \\ photo blob --pk id + + Valid column types are text, integer, float and blob. """ db = sqlite_utils.Database(path) _load_extensions(db, load_extension) From e660635cea6c32f4022818380b1e1ee88e7c93a6 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 9 Dec 2022 17:25:23 -0800 Subject: [PATCH 255/563] Drop support for Python 3.6, refs #517 --- .github/workflows/publish.yml | 2 +- .github/workflows/test.yml | 3 +-- setup.py | 4 ++-- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f3627d3..a37907a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -9,7 +9,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - python-version: ["3.6", "3.7", "3.8", "3.9", "3.10", "3.11"] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] os: [ubuntu-latest, windows-latest, macos-latest] steps: - uses: actions/checkout@v3 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 98729c9..2e706df 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,7 +10,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - python-version: ["3.6", "3.7", "3.8", "3.9", "3.10", "3.11"] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] numpy: [0, 1] os: [ubuntu-latest, macos-latest, windows-latest] steps: @@ -49,6 +49,5 @@ jobs: - name: Check formatting run: black . --check - name: Check if cog needs to be run - if: matrix.python-version != '3.6' run: | cog --check README.md docs/*.rst diff --git a/setup.py b/setup.py index faa822b..4098a33 100644 --- a/setup.py +++ b/setup.py @@ -60,7 +60,7 @@ setup( "Issues": "https://github.com/simonw/sqlite-utils/issues", "CI": "https://github.com/simonw/sqlite-utils/actions", }, - python_requires=">=3.6", + python_requires=">=3.7", classifiers=[ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", @@ -68,11 +68,11 @@ setup( "Intended Audience :: End Users/Desktop", "Topic :: Database", "License :: OSI Approved :: Apache Software License", - "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", ], # Needed to bundle py.typed so mypy can see it: zip_safe=False, From fc221f9b62ed8624b1d2098e564f525c84497969 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 9 Dec 2022 17:30:45 -0800 Subject: [PATCH 256/563] Try this fix for flake8 error, refs #518 --- setup.cfg | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 7a88d6f..6b9c885 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,3 +1,4 @@ [flake8] max-line-length = 160 -extend-ignore = E203 # for Black +# Black compatibility, E203 whitespace before ':': +extend-ignore = E203 \ No newline at end of file From 6cd0fd2b4c5116889e40245f84a9786fb19f4c40 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 12 Mar 2023 14:25:26 -0700 Subject: [PATCH 257/563] Fix for Sphinx bug, closes #533, refs #531 --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 8d49b81..859c6b9 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -41,7 +41,7 @@ autodoc_member_order = "bysource" autodoc_typehints = "description" extlinks = { - "issue": ("https://github.com/simonw/sqlite-utils/issues/%s", "#"), + "issue": ("https://github.com/simonw/sqlite-utils/issues/%s", "#%s"), } From 92f77c32620d282f8e15de860bead40563b48dcb Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 12 Mar 2023 14:28:43 -0700 Subject: [PATCH 258/563] Ran against updated Black --- sqlite_utils/db.py | 1 - 1 file changed, 1 deletion(-) diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index e819d17..c06e6a0 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -2876,7 +2876,6 @@ class Table(Queryable): self.add_missing_columns(chunk) result = self.db.execute(query, params) elif e.args[0] == "too many SQL variables": - first_half = chunk[: len(chunk) // 2] second_half = chunk[len(chunk) // 2 :] From c0251cc9271260de73b4227859a51fab9b4cb745 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 26 Mar 2023 16:42:01 -0700 Subject: [PATCH 259/563] Link /latest/ to /stable/ - refs #388 --- docs/_templates/base.html | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/docs/_templates/base.html b/docs/_templates/base.html index a9e670e..43c2003 100644 --- a/docs/_templates/base.html +++ b/docs/_templates/base.html @@ -3,4 +3,35 @@ {% block site_meta %} {{ super() }} -{% endblock %} \ No newline at end of file +{% endblock %} + +{% block scripts %} +{{ super() }} + +{% endblock %} From 8f9a729e8aff972cb18de25b40f4113e26bbc758 Mon Sep 17 00:00:00 2001 From: Chris Amico Date: Wed, 12 Apr 2023 21:44:43 -0400 Subject: [PATCH 260/563] Add paths for homebrew on Apple silicon (#536) --- sqlite_utils/utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sqlite_utils/utils.py b/sqlite_utils/utils.py index 4e5bbcc..3bd5db1 100644 --- a/sqlite_utils/utils.py +++ b/sqlite_utils/utils.py @@ -24,10 +24,11 @@ except ImportError: OperationalError = sqlite3.OperationalError - SPATIALITE_PATHS = ( "/usr/lib/x86_64-linux-gnu/mod_spatialite.so", "/usr/local/lib/mod_spatialite.dylib", + "/usr/local/lib/mod_spatialite.so", + "/opt/homebrew/lib/mod_spatialite.dylib", ) # Mainly so we can restore it if needed in the tests: From 373b7886d26902f54d72f1a414f988f79f0ffacd Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 7 May 2023 11:26:03 -0700 Subject: [PATCH 261/563] --raw-lines option, closes #539 --- docs/cli-reference.rst | 2 ++ docs/cli.rst | 3 +++ sqlite_utils/cli.py | 53 +++++++++++++++++++++++++++++++++++++++--- tests/test_cli.py | 15 ++++++++++++ 4 files changed, 70 insertions(+), 3 deletions(-) diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index 153e5f9..c830518 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -124,6 +124,7 @@ See :ref:`cli_query`. --json-cols Detect JSON cols and output them as JSON, not escaped strings -r, --raw Raw output, first column of first row + --raw-lines Raw output, first column of each row -p, --param ... Named :parameters for SQL query --functions TEXT Python code defining one or more custom SQL functions @@ -192,6 +193,7 @@ See :ref:`cli_memory`. --json-cols Detect JSON cols and output them as JSON, not escaped strings -r, --raw Raw output, first column of first row + --raw-lines Raw output, first column of each row -p, --param ... Named :parameters for SQL query --encoding TEXT Character encoding for CSV input, defaults to utf-8 diff --git a/docs/cli.rst b/docs/cli.rst index 1d67e88..dc0c072 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -239,6 +239,9 @@ For example, to retrieve a binary image from a ``BLOB`` column and store it in a $ sqlite-utils photos.db "select contents from photos where id=1" --raw > myphoto.jpg +To return the first column of each result as raw data, separated by newlines, use ``--raw-lines``:: + + $ sqlite-utils photos.db "select caption from photos" --raw-lines > captions.txt .. _cli_query_parameters: diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index d25b1df..da0e4b6 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -1653,6 +1653,7 @@ def drop_view(path, view, ignore, load_extension): ) @output_options @click.option("-r", "--raw", is_flag=True, help="Raw output, first column of first row") +@click.option("--raw-lines", is_flag=True, help="Raw output, first column of each row") @click.option( "-p", "--param", @@ -1677,6 +1678,7 @@ def query( fmt, json_cols, raw, + raw_lines, param, load_extension, functions, @@ -1700,7 +1702,19 @@ def query( _register_functions(db, functions) _execute_query( - db, sql, param, raw, table, csv, tsv, no_headers, fmt, nl, arrays, json_cols + db, + sql, + param, + raw, + raw_lines, + table, + csv, + tsv, + no_headers, + fmt, + nl, + arrays, + json_cols, ) @@ -1728,6 +1742,7 @@ def query( ) @output_options @click.option("-r", "--raw", is_flag=True, help="Raw output, first column of first row") +@click.option("--raw-lines", is_flag=True, help="Raw output, first column of each row") @click.option( "-p", "--param", @@ -1773,6 +1788,7 @@ def memory( fmt, json_cols, raw, + raw_lines, param, encoding, no_detect_types, @@ -1879,12 +1895,36 @@ def memory( _register_functions(db, functions) _execute_query( - db, sql, param, raw, table, csv, tsv, no_headers, fmt, nl, arrays, json_cols + db, + sql, + param, + raw, + raw_lines, + table, + csv, + tsv, + no_headers, + fmt, + nl, + arrays, + json_cols, ) def _execute_query( - db, sql, param, raw, table, csv, tsv, no_headers, fmt, nl, arrays, json_cols + db, + sql, + param, + raw, + raw_lines, + table, + csv, + tsv, + no_headers, + fmt, + nl, + arrays, + json_cols, ): with db.conn: try: @@ -1903,6 +1943,13 @@ def _execute_query( sys.stdout.buffer.write(data) else: sys.stdout.write(str(data)) + elif raw_lines: + for row in cursor: + data = row[0] + if isinstance(data, bytes): + sys.stdout.buffer.write(data + b"\n") + else: + sys.stdout.write(str(data) + "\n") elif fmt or table: print( tabulate.tabulate( diff --git a/tests/test_cli.py b/tests/test_cli.py index 20fe99e..c3ba682 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -916,6 +916,21 @@ def test_query_raw(db_path, content, is_binary): assert result.output == str(content) +@pytest.mark.parametrize( + "content,is_binary", + [(b"\x00\x0Fbinary", True), ("this is text", False), (1, False), (1.5, False)], +) +def test_query_raw_lines(db_path, content, is_binary): + Database(db_path)["files"].insert_all({"content": content} for _ in range(3)) + result = CliRunner().invoke( + cli.cli, [db_path, "select content from files", "--raw-lines"] + ) + if is_binary: + assert result.stdout_bytes == b"\n".join(content for _ in range(3)) + b"\n" + else: + assert result.output == "\n".join(str(content) for _ in range(3)) + "\n" + + def test_query_memory_does_not_create_file(tmpdir): owd = os.getcwd() try: From 963518bb16dc933694955309e7c9559e551b6a8e Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 7 May 2023 11:38:54 -0700 Subject: [PATCH 262/563] Build with 3.11 on ReadTheDocs Refs #540 --- .readthedocs.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index ce66cbe..b092ec7 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -4,7 +4,7 @@ sphinx: configuration: docs/conf.py python: - version: "3.8" + version: "3.11" install: - method: pip path: . From 80763edaa2bdaf1113717378b8d62075c4dcbcfb Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 7 May 2023 11:40:47 -0700 Subject: [PATCH 263/563] Different approach for Python 3.11 on ReadTheDocs Refs #540 --- .readthedocs.yaml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index b092ec7..aa15c7c 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -3,8 +3,12 @@ version: 2 sphinx: configuration: docs/conf.py +build: + os: ubuntu-22.04 + tools: + python: "3.11" + python: - version: "3.11" install: - method: pip path: . From 2376c452a56b0c3e75e7ca698273434e32945304 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 8 May 2023 12:24:10 -0700 Subject: [PATCH 264/563] upsert_all() now works with not_null - refs #538 --- sqlite_utils/db.py | 22 +++++++++++++++++----- tests/test_upsert.py | 10 ++++++++++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index c06e6a0..44d59da 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -2741,6 +2741,7 @@ class Table(Queryable): hash_id_columns, upsert, pk, + not_null, conversions, num_records_processed, replace, @@ -2778,14 +2779,20 @@ class Table(Queryable): pks = pk self.last_pk = None for record_values in values: - # TODO: make more efficient: record = dict(zip(all_columns, record_values)) - sql = "INSERT OR IGNORE INTO [{table}]({pks}) VALUES({pk_placeholders});".format( + placeholders = list(pks) + # Need to populate not-null columns too, or INSERT OR IGNORE ignores + # them since it ignores the resulting integrity errors + if not_null: + placeholders.extend(not_null) + sql = "INSERT OR IGNORE INTO [{table}]({cols}) VALUES({placeholders});".format( table=self.name, - pks=", ".join(["[{}]".format(p) for p in pks]), - pk_placeholders=", ".join(["?" for p in pks]), + cols=", ".join(["[{}]".format(p) for p in placeholders]), + placeholders=", ".join(["?" for p in placeholders]), + ) + queries_and_params.append( + (sql, [record[col] for col in pks] + ["" for _ in (not_null or [])]) ) - queries_and_params.append((sql, [record[col] for col in pks])) # UPDATE [book] SET [name] = 'Programming' WHERE [id] = 1001; set_cols = [col for col in all_columns if col not in pks] if set_cols: @@ -2846,6 +2853,7 @@ class Table(Queryable): hash_id_columns, upsert, pk, + not_null, conversions, num_records_processed, replace, @@ -2859,6 +2867,7 @@ class Table(Queryable): hash_id_columns, upsert, pk, + not_null, conversions, num_records_processed, replace, @@ -2888,6 +2897,7 @@ class Table(Queryable): hash_id_columns, upsert, pk, + not_null, conversions, num_records_processed, replace, @@ -2903,6 +2913,7 @@ class Table(Queryable): hash_id_columns, upsert, pk, + not_null, conversions, num_records_processed, replace, @@ -3112,6 +3123,7 @@ class Table(Queryable): hash_id_columns, upsert, pk, + not_null, conversions, num_records_processed, replace, diff --git a/tests/test_upsert.py b/tests/test_upsert.py index cef8af0..b7267ea 100644 --- a/tests/test_upsert.py +++ b/tests/test_upsert.py @@ -28,6 +28,16 @@ def test_upsert_all_single_column(fresh_db): assert table.pks == ["name"] +def test_upsert_all_not_null(fresh_db): + # https://github.com/simonw/sqlite-utils/issues/538 + fresh_db["comments"].upsert_all( + [{"id": 1, "name": "Cleo"}], + pk="id", + not_null=["name"], + ) + assert list(fresh_db["comments"].rows) == [{"id": 1, "name": "Cleo"}] + + def test_upsert_error_if_no_pk(fresh_db): table = fresh_db["table"] with pytest.raises(PrimaryKeyRequired): From 4fc2f12c88054a4bcc29004e8e9cad39e5b66664 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 8 May 2023 12:39:06 -0700 Subject: [PATCH 265/563] Fix ResourceWarning in sqlite-utils insert, refs #534 --- sqlite_utils/cli.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index da0e4b6..9f708f0 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -948,14 +948,15 @@ def insert_upsert_implementation( # The --sniff option needs us to buffer the file to peek ahead sniff_buffer = None + decoded_buffer = None if sniff: sniff_buffer = io.BufferedReader(file, buffer_size=4096) - decoded = io.TextIOWrapper(sniff_buffer, encoding=encoding) + decoded_buffer = io.TextIOWrapper(sniff_buffer, encoding=encoding) else: - decoded = io.TextIOWrapper(file, encoding=encoding) + decoded_buffer = io.TextIOWrapper(file, encoding=encoding) tracker = None - with file_progress(decoded, silent=silent) as decoded: + with file_progress(decoded_buffer, silent=silent) as decoded: if csv or tsv: if sniff: # Read first 2048 bytes and use that to detect @@ -1079,6 +1080,12 @@ def insert_upsert_implementation( if tracker is not None: db[table].transform(types=tracker.types) + # Clean up open file-like objects + if sniff_buffer: + sniff_buffer.close() + if decoded_buffer: + decoded_buffer.close() + def _find_variables(tb, vars): to_find = list(vars) From a256d7de9887d8476400bbe3753439f2e406134b Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 8 May 2023 12:57:43 -0700 Subject: [PATCH 266/563] Fix a bunch of warnings in the tests, refs #541 --- sqlite_utils/cli.py | 25 ++++++++++++++----------- tests/test_cli.py | 18 ++++++++++++------ tests/test_cli_insert.py | 27 ++++++++++++++++++--------- tests/test_cli_memory.py | 12 ++++++++---- 4 files changed, 52 insertions(+), 30 deletions(-) diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 9f708f0..ba0a416 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -1839,40 +1839,43 @@ def memory( stem_counts = {} for i, path in enumerate(paths): # Path may have a :format suffix + fp = None if ":" in path and path.rsplit(":", 1)[-1].upper() in Format.__members__: path, suffix = path.rsplit(":", 1) format = Format[suffix.upper()] else: format = None if path in ("-", "stdin"): - csv_fp = sys.stdin.buffer - csv_table = "stdin" + fp = sys.stdin.buffer + file_table = "stdin" else: - csv_path = pathlib.Path(path) - stem = csv_path.stem + file_path = pathlib.Path(path) + stem = file_path.stem if stem_counts.get(stem): - csv_table = "{}_{}".format(stem, stem_counts[stem]) + file_table = "{}_{}".format(stem, stem_counts[stem]) else: - csv_table = stem + file_table = stem stem_counts[stem] = stem_counts.get(stem, 1) + 1 - csv_fp = csv_path.open("rb") - rows, format_used = rows_from_file(csv_fp, format=format, encoding=encoding) + fp = file_path.open("rb") + rows, format_used = rows_from_file(fp, format=format, encoding=encoding) tracker = None if format_used in (Format.CSV, Format.TSV) and not no_detect_types: tracker = TypeTracker() rows = tracker.wrap(rows) if flatten: rows = (_flatten(row) for row in rows) - db[csv_table].insert_all(rows, alter=True) + db[file_table].insert_all(rows, alter=True) if tracker is not None: - db[csv_table].transform(types=tracker.types) + db[file_table].transform(types=tracker.types) # Add convenient t / t1 / t2 views view_names = ["t{}".format(i + 1)] if i == 0: view_names.append("t") for view_name in view_names: if not db[view_name].exists(): - db.create_view(view_name, "select * from [{}]".format(csv_table)) + db.create_view(view_name, "select * from [{}]".format(file_table)) + if fp: + fp.close() if analyze: _analyze(db, tables=None, columns=None, save=False) diff --git a/tests/test_cli.py b/tests/test_cli.py index c3ba682..5360e56 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -13,6 +13,11 @@ import textwrap from .utils import collapse_whitespace +def write_json(file_path, data): + with open(file_path, "w") as fp: + json.dump(data, fp) + + def _supports_pragma_function_list(): db = Database(memory=True) try: @@ -1016,7 +1021,7 @@ def test_upsert(db_path, tmpdir): {"id": 1, "name": "Cleo", "age": 4}, {"id": 2, "name": "Nixie", "age": 4}, ] - open(json_path, "w").write(json.dumps(insert_dogs)) + write_json(json_path, insert_dogs) result = CliRunner().invoke( cli.cli, ["insert", db_path, "dogs", json_path, "--pk", "id"], @@ -1029,7 +1034,7 @@ def test_upsert(db_path, tmpdir): {"id": 1, "age": 5}, {"id": 2, "age": 5}, ] - open(json_path, "w").write(json.dumps(upsert_dogs)) + write_json(json_path, upsert_dogs) result = CliRunner().invoke( cli.cli, ["upsert", db_path, "dogs", json_path, "--pk", "id"], @@ -1048,7 +1053,7 @@ def test_upsert_pk_required(db_path, tmpdir): {"id": 1, "name": "Cleo", "age": 4}, {"id": 2, "name": "Nixie", "age": 4}, ] - open(json_path, "w").write(json.dumps(insert_dogs)) + write_json(json_path, insert_dogs) result = CliRunner().invoke( cli.cli, ["upsert", db_path, "dogs", json_path], @@ -1091,14 +1096,14 @@ def test_upsert_alter(db_path, tmpdir): json_path = str(tmpdir / "dogs.json") db = Database(db_path) insert_dogs = [{"id": 1, "name": "Cleo"}] - open(json_path, "w").write(json.dumps(insert_dogs)) + write_json(json_path, insert_dogs) result = CliRunner().invoke( cli.cli, ["insert", db_path, "dogs", json_path, "--pk", "id"] ) assert 0 == result.exit_code, result.output # Should fail with error code if no --alter upsert_dogs = [{"id": 1, "age": 5}] - open(json_path, "w").write(json.dumps(upsert_dogs)) + write_json(json_path, upsert_dogs) result = CliRunner().invoke( cli.cli, ["upsert", db_path, "dogs", json_path, "--pk", "id"] ) @@ -1767,7 +1772,8 @@ def test_insert_encoding(tmpdir): ) assert latin1_csv.decode("latin-1").split("\n")[2].split(",")[1] == "São Paulo" csv_path = str(tmpdir / "test.csv") - open(csv_path, "wb").write(latin1_csv) + with open(csv_path, "wb") as fp: + fp.write(latin1_csv) # First attempt should error: bad_result = CliRunner().invoke( cli.cli, diff --git a/tests/test_cli_insert.py b/tests/test_cli_insert.py index 45ad362..3c3b88d 100644 --- a/tests/test_cli_insert.py +++ b/tests/test_cli_insert.py @@ -10,7 +10,8 @@ import time def test_insert_simple(tmpdir): json_path = str(tmpdir / "dog.json") db_path = str(tmpdir / "dogs.db") - open(json_path, "w").write(json.dumps({"name": "Cleo", "age": 4})) + with open(json_path, "w") as fp: + fp.write(json.dumps({"name": "Cleo", "age": 4})) result = CliRunner().invoke(cli.cli, ["insert", db_path, "dogs", json_path]) assert 0 == result.exit_code assert [{"age": 4, "name": "Cleo"}] == list( @@ -78,7 +79,8 @@ def test_insert_json_flatten_nl(tmpdir): def test_insert_with_primary_key(db_path, tmpdir): json_path = str(tmpdir / "dog.json") - open(json_path, "w").write(json.dumps({"id": 1, "name": "Cleo", "age": 4})) + with open(json_path, "w") as fp: + fp.write(json.dumps({"id": 1, "name": "Cleo", "age": 4})) result = CliRunner().invoke( cli.cli, ["insert", db_path, "dogs", json_path, "--pk", "id"] ) @@ -93,7 +95,8 @@ def test_insert_with_primary_key(db_path, tmpdir): def test_insert_multiple_with_primary_key(db_path, tmpdir): json_path = str(tmpdir / "dogs.json") dogs = [{"id": i, "name": "Cleo {}".format(i), "age": i + 3} for i in range(1, 21)] - open(json_path, "w").write(json.dumps(dogs)) + with open(json_path, "w") as fp: + fp.write(json.dumps(dogs)) result = CliRunner().invoke( cli.cli, ["insert", db_path, "dogs", json_path, "--pk", "id"] ) @@ -109,7 +112,8 @@ def test_insert_multiple_with_compound_primary_key(db_path, tmpdir): {"breed": "mixed", "id": i, "name": "Cleo {}".format(i), "age": i + 3} for i in range(1, 21) ] - open(json_path, "w").write(json.dumps(dogs)) + with open(json_path, "w") as fp: + fp.write(json.dumps(dogs)) result = CliRunner().invoke( cli.cli, ["insert", db_path, "dogs", json_path, "--pk", "id", "--pk", "breed"] ) @@ -134,7 +138,8 @@ def test_insert_not_null_default(db_path, tmpdir): {"id": i, "name": "Cleo {}".format(i), "age": i + 3, "score": 10} for i in range(1, 21) ] - open(json_path, "w").write(json.dumps(dogs)) + with open(json_path, "w") as fp: + fp.write(json.dumps(dogs)) result = CliRunner().invoke( cli.cli, ["insert", db_path, "dogs", json_path, "--pk", "id"] @@ -182,7 +187,8 @@ def test_insert_ignore(db_path, tmpdir): db = Database(db_path) db["dogs"].insert({"id": 1, "name": "Cleo"}, pk="id") json_path = str(tmpdir / "dogs.json") - open(json_path, "w").write(json.dumps([{"id": 1, "name": "Bailey"}])) + with open(json_path, "w") as fp: + fp.write(json.dumps([{"id": 1, "name": "Bailey"}])) # Should raise error without --ignore result = CliRunner().invoke( cli.cli, ["insert", db_path, "dogs", json_path, "--pk", "id"] @@ -212,7 +218,8 @@ def test_insert_ignore(db_path, tmpdir): def test_insert_csv_tsv(content, options, db_path, tmpdir): db = Database(db_path) file_path = str(tmpdir / "insert.csv-tsv") - open(file_path, "w").write(content) + with open(file_path, "w") as fp: + fp.write(content) result = CliRunner().invoke( cli.cli, ["insert", db_path, "data", file_path] + options, @@ -233,7 +240,8 @@ def test_insert_csv_tsv(content, options, db_path, tmpdir): ) def test_only_allow_one_of_nl_tsv_csv(options, db_path, tmpdir): file_path = str(tmpdir / "insert.csv-tsv") - open(file_path, "w").write("foo") + with open(file_path, "w") as fp: + fp.write("foo") result = CliRunner().invoke( cli.cli, ["insert", db_path, "data", file_path] + options ) @@ -251,7 +259,8 @@ def test_insert_replace(db_path, tmpdir): {"id": 2, "name": "Insert replaced 2", "age": 4}, {"id": 21, "name": "Fresh insert 21", "age": 6}, ] - open(json_path, "w").write(json.dumps(insert_replace_dogs)) + with open(json_path, "w") as fp: + fp.write(json.dumps(insert_replace_dogs)) result = CliRunner().invoke( cli.cli, ["insert", db_path, "dogs", json_path, "--pk", "id", "--replace"] ) diff --git a/tests/test_cli_memory.py b/tests/test_cli_memory.py index bb50d23..41bb00a 100644 --- a/tests/test_cli_memory.py +++ b/tests/test_cli_memory.py @@ -24,7 +24,8 @@ def test_memory_csv(tmpdir, sql_from, use_stdin): sql_from = "stdin" else: csv_path = str(tmpdir / "test.csv") - open(csv_path, "w").write(content) + with open(csv_path, "w") as fp: + fp.write(content) result = CliRunner().invoke( cli.cli, ["memory", csv_path, "select * from {}".format(sql_from), "--nl"], @@ -46,7 +47,8 @@ def test_memory_tsv(tmpdir, use_stdin): else: input = None path = str(tmpdir / "chickens.tsv") - open(path, "w").write(data) + with open(path, "w") as fp: + fp.write(data) path = path + ":tsv" sql_from = "chickens" result = CliRunner().invoke( @@ -71,7 +73,8 @@ def test_memory_json(tmpdir, use_stdin): else: input = None path = str(tmpdir / "chickens.json") - open(path, "w").write(data) + with open(path, "w") as fp: + fp.write(data) path = path + ":json" sql_from = "chickens" result = CliRunner().invoke( @@ -96,7 +99,8 @@ def test_memory_json_nl(tmpdir, use_stdin): else: input = None path = str(tmpdir / "chickens.json") - open(path, "w").write(data) + with open(path, "w") as fp: + fp.write(data) path = path + ":nl" sql_from = "chickens" result = CliRunner().invoke( From e4ed37251746b25ca69b5ace0c8c7992024556df Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 8 May 2023 13:31:56 -0700 Subject: [PATCH 267/563] Show more detailed error on invalid JSON, closes #532 --- sqlite_utils/cli.py | 6 ++++-- tests/test_cli_insert.py | 6 +++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index ba0a416..c3c55e2 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -994,9 +994,11 @@ def insert_upsert_implementation( docs = json.load(decoded) if isinstance(docs, dict): docs = [docs] - except json.decoder.JSONDecodeError: + except json.decoder.JSONDecodeError as ex: raise click.ClickException( - "Invalid JSON - use --csv for CSV or --tsv for TSV files" + "Invalid JSON - use --csv for CSV or --tsv for TSV files\n\nJSON error: {}".format( + ex + ) ) if flatten: docs = (_flatten(doc) for doc in docs) diff --git a/tests/test_cli_insert.py b/tests/test_cli_insert.py index 3c3b88d..f403328 100644 --- a/tests/test_cli_insert.py +++ b/tests/test_cli_insert.py @@ -43,9 +43,9 @@ def test_insert_invalid_json_error(tmpdir): input="name,age\nCleo,4", ) assert result.exit_code == 1 - assert ( - result.output - == "Error: Invalid JSON - use --csv for CSV or --tsv for TSV files\n" + assert result.output == ( + "Error: Invalid JSON - use --csv for CSV or --tsv for TSV files\n\n" + "JSON error: Expecting value: line 1 column 1 (char 0)\n" ) From 455c35b512895c19bf922c2b804d750d27cb8dbd Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 8 May 2023 13:52:21 -0700 Subject: [PATCH 268/563] .convert(skip_false) option, refs #527 --- sqlite_utils/db.py | 3 ++- tests/test_convert.py | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index 44d59da..3c5d91a 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -2618,6 +2618,7 @@ class Table(Queryable): where: Optional[str] = None, where_args: Optional[Union[Iterable, dict]] = None, show_progress: bool = False, + skip_false: bool = True, ): """ Apply conversion function ``fn`` to every value in the specified columns. @@ -2660,7 +2661,7 @@ class Table(Queryable): def convert_value(v): bar.update(1) - if not v: + if skip_false and not v: return v return jsonify_if_needed(fn(v)) diff --git a/tests/test_convert.py b/tests/test_convert.py index 31a9d28..9fd29c3 100644 --- a/tests/test_convert.py +++ b/tests/test_convert.py @@ -50,6 +50,16 @@ def test_convert_where(fresh_db, where, where_args): assert list(table.rows) == [{"id": 1, "title": "One"}, {"id": 2, "title": "TWO"}] +def test_convert_skip_false(fresh_db): + table = fresh_db["table"] + table.insert_all([{"x": 0}, {"x": 1}]) + assert table.get(1)["x"] == 0 + assert table.get(2)["x"] == 1 + table.convert("x", lambda x: x + 1, skip_false=False) + assert table.get(1)["x"] == 1 + assert table.get(2)["x"] == 2 + + @pytest.mark.parametrize( "drop,expected", ( From e0ec4c345129996011951e400388fd74865f65a2 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 8 May 2023 14:03:20 -0700 Subject: [PATCH 269/563] --no-skip-false option, plus docs - closes #527 --- docs/cli.rst | 2 ++ docs/python-api.rst | 2 ++ sqlite_utils/cli.py | 3 +++ tests/test_cli_convert.py | 27 +++++++++++++++++++++++++++ 4 files changed, 34 insertions(+) diff --git a/docs/cli.rst b/docs/cli.rst index dc0c072..572b0e5 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -1342,6 +1342,8 @@ You can include named parameters in your where clause and populate them using on The ``--dry-run`` option will output a preview of the conversion against the first ten rows, without modifying the database. +By default any rows with a falsey value for the column - such as ``0`` or ``null`` - will be skipped. Use the ``--no-skip-false`` option to disable this behaviour. + .. _cli_convert_import: Importing additional modules diff --git a/docs/python-api.rst b/docs/python-api.rst index 206e5e6..4d883db 100644 --- a/docs/python-api.rst +++ b/docs/python-api.rst @@ -916,6 +916,8 @@ This will add the new column, if it does not already exist. You can pass ``outpu If you want to drop the original column after saving the results in a separate output column, pass ``drop=True``. +By default any rows with a falsey value for the column - such as ``0`` or ``None`` - will be skipped. Pass ``skip_false=False`` to disable this behaviour. + You can create multiple new columns from a single input column by passing ``multi=True`` and a conversion function that returns a Python dictionary. This example creates new ``upper`` and ``lower`` columns populated from the single ``title`` column: .. code-block:: python diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index c3c55e2..ce354e0 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -2811,6 +2811,7 @@ def _generate_convert_help(): type=click.Choice(["integer", "float", "blob", "text"]), ) @click.option("--drop", is_flag=True, help="Drop original column afterwards") +@click.option("--no-skip-false", is_flag=True, help="Don't skip falsey values") @click.option("-s", "--silent", is_flag=True, help="Don't show a progress bar") def convert( db_path, @@ -2825,6 +2826,7 @@ def convert( output, output_type, drop, + no_skip_false, silent, ): sqlite3.enable_callback_tracebacks(True) @@ -2882,6 +2884,7 @@ def convert( output=output, output_type=output_type, drop=drop, + skip_false=not no_skip_false, multi=multi, show_progress=not silent, ) diff --git a/tests/test_cli_convert.py b/tests/test_cli_convert.py index d26ab8c..97988b9 100644 --- a/tests/test_cli_convert.py +++ b/tests/test_cli_convert.py @@ -626,3 +626,30 @@ def test_convert_initialization_pattern(fresh_db_and_path): assert list(db["names"].rows) == [ {"id": 1, "name": "17"}, ] + + +@pytest.mark.parametrize( + "no_skip_false,expected", + ( + (True, 1), + (False, 0), + ), +) +def test_convert_no_skip_false(fresh_db_and_path, no_skip_false, expected): + db, db_path = fresh_db_and_path + args = [ + "convert", + db_path, + "t", + "x", + "-", + ] + if no_skip_false: + args.append("--no-skip-false") + db["t"].insert_all([{"x": 0}, {"x": 1}]) + assert db["t"].get(1)["x"] == 0 + assert db["t"].get(2)["x"] == 1 + result = CliRunner().invoke(cli.cli, args, input="value + 1") + assert 0 == result.exit_code, result.output + assert db["t"].get(1)["x"] == expected + assert db["t"].get(2)["x"] == 2 From 9662d4ce267accdc8f5301b20a4c7cd82b5ccf34 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 8 May 2023 14:05:30 -0700 Subject: [PATCH 270/563] Updated cog, refs #527 --- docs/cli-reference.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index c830518..8993213 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -633,6 +633,7 @@ See :ref:`cli_convert`. --output-type [integer|float|blob|text] Column type to use for the output column --drop Drop original column afterwards + --no-skip-false Don't skip falsey values -s, --silent Don't show a progress bar -h, --help Show this message and exit. From 39ef137e6760d385dc48d03eccf9b89943636fc7 Mon Sep 17 00:00:00 2001 From: Scott Perry Date: Mon, 8 May 2023 14:10:00 -0700 Subject: [PATCH 271/563] Support self-referencing FKs in `create` (#537) --- sqlite_utils/db.py | 2 ++ tests/test_create.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index 3c5d91a..19896f8 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -813,6 +813,8 @@ class Database: pk = hash_id # Soundness check foreign_keys point to existing tables for fk in foreign_keys: + if fk.other_table == name and columns.get(fk.other_column): + continue if not any( c for c in self[fk.other_table].columns if c.name == fk.other_column ): diff --git a/tests/test_create.py b/tests/test_create.py index 7252e48..54d125c 100644 --- a/tests/test_create.py +++ b/tests/test_create.py @@ -288,6 +288,25 @@ def test_create_table_works_for_m2m_with_only_foreign_keys( ) +def test_self_referential_foreign_key(fresh_db): + assert [] == fresh_db.table_names() + table = fresh_db.create_table( + "test_table", + columns={ + "id": int, + "ref": int, + }, + pk="id", + foreign_keys=(("ref", "test_table", "id"),), + ) + assert ( + "CREATE TABLE [test_table] (\n" + " [id] INTEGER PRIMARY KEY,\n" + " [ref] INTEGER REFERENCES [test_table]([id])\n" + ")" + ) == table.schema + + def test_create_error_if_invalid_foreign_keys(fresh_db): with pytest.raises(AlterError): fresh_db["one"].insert( @@ -297,6 +316,15 @@ def test_create_error_if_invalid_foreign_keys(fresh_db): ) +def test_create_error_if_invalid_self_referential_foreign_keys(fresh_db): + with pytest.raises(AlterError): + fresh_db["one"].insert( + {"id": 1, "ref_id": 3}, + pk="id", + foreign_keys=(("ref_id", "one", "bad_column"),), + ) + + @pytest.mark.parametrize( "col_name,col_type,not_null_default,expected_schema", ( From 923768db2ee15f521fe49ce75002cdd02c82e2bc Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 8 May 2023 14:11:48 -0700 Subject: [PATCH 272/563] Assert on exact error message, refs #537 --- tests/test_create.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_create.py b/tests/test_create.py index 54d125c..6931f0e 100644 --- a/tests/test_create.py +++ b/tests/test_create.py @@ -317,12 +317,13 @@ def test_create_error_if_invalid_foreign_keys(fresh_db): def test_create_error_if_invalid_self_referential_foreign_keys(fresh_db): - with pytest.raises(AlterError): + with pytest.raises(AlterError) as ex: fresh_db["one"].insert( {"id": 1, "ref_id": 3}, pk="id", foreign_keys=(("ref_id", "one", "bad_column"),), ) + assert ex.value.args == ("No such column: one.bad_column",) @pytest.mark.parametrize( From 6500fed8b2085869b9714ce3a08c30f61dc829ad Mon Sep 17 00:00:00 2001 From: rhoboro Date: Tue, 9 May 2023 06:13:36 +0900 Subject: [PATCH 273/563] Transform no longer breaks non-string default values Closes #509 --- sqlite_utils/db.py | 24 ++++++++++++++++++++++-- tests/test_default_value.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 tests/test_default_value.py diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index 19896f8..dcd3b5f 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -542,6 +542,24 @@ class Database: '"{}"'.format(bit) if not bit.startswith('"') else bit for bit in bits ) + def quote_default_value(self, value: str) -> str: + if any( + [ + str(value).startswith("'") and str(value).endswith("'"), + str(value).startswith('"') and str(value).endswith('"'), + ] + ): + return value + + if str(value).upper() in ("CURRENT_TIME", "CURRENT_DATE", "CURRENT_TIMESTAMP"): + return value + + if str(value).endswith(")"): + # Expr + return "({})".format(value) + + return self.quote(value) + def table_names(self, fts4: bool = False, fts5: bool = False) -> List[str]: """ List of string table names in this database. @@ -839,7 +857,7 @@ class Database: column_extras.append("NOT NULL") if column_name in defaults and defaults[column_name] is not None: column_extras.append( - "DEFAULT {}".format(self.quote(defaults[column_name])) + "DEFAULT {}".format(self.quote_default_value(defaults[column_name])) ) if column_name in foreign_keys_by_column: column_extras.append( @@ -2020,7 +2038,9 @@ class Table(Queryable): col_type = str not_null_sql = None if not_null_default is not None: - not_null_sql = "NOT NULL DEFAULT {}".format(self.db.quote(not_null_default)) + not_null_sql = "NOT NULL DEFAULT {}".format( + self.db.quote_default_value(not_null_default) + ) sql = "ALTER TABLE [{table}] ADD COLUMN [{col_name}] {col_type}{not_null_default};".format( table=self.name, col_name=col_name, diff --git a/tests/test_default_value.py b/tests/test_default_value.py new file mode 100644 index 0000000..9ffdb14 --- /dev/null +++ b/tests/test_default_value.py @@ -0,0 +1,34 @@ +import pytest + + +EXAMPLES = [ + ("TEXT DEFAULT 'foo'", "'foo'", "'foo'"), + ("TEXT DEFAULT 'foo)'", "'foo)'", "'foo)'"), + ("INTEGER DEFAULT '1'", "'1'", "'1'"), + ("INTEGER DEFAULT 1", "1", "'1'"), + ("INTEGER DEFAULT (1)", "1", "'1'"), + # Expressions + ( + "TEXT DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW'))", + "STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')", + "(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW'))", + ), + # Special values + ("TEXT DEFAULT CURRENT_TIME", "CURRENT_TIME", "CURRENT_TIME"), + ("TEXT DEFAULT CURRENT_DATE", "CURRENT_DATE", "CURRENT_DATE"), + ("TEXT DEFAULT CURRENT_TIMESTAMP", "CURRENT_TIMESTAMP", "CURRENT_TIMESTAMP"), + ("TEXT DEFAULT current_timestamp", "current_timestamp", "current_timestamp"), + ("TEXT DEFAULT (CURRENT_TIMESTAMP)", "CURRENT_TIMESTAMP", "CURRENT_TIMESTAMP"), + # Strings + ("TEXT DEFAULT 'CURRENT_TIMESTAMP'", "'CURRENT_TIMESTAMP'", "'CURRENT_TIMESTAMP'"), + ('TEXT DEFAULT "CURRENT_TIMESTAMP"', '"CURRENT_TIMESTAMP"', '"CURRENT_TIMESTAMP"'), +] + + +@pytest.mark.parametrize("column_def,initial_value,expected_value", EXAMPLES) +def test_quote_default_value(fresh_db, column_def, initial_value, expected_value): + fresh_db.execute("create table foo (col {})".format(column_def)) + assert initial_value == fresh_db["foo"].columns[0].default_value + assert expected_value == fresh_db.quote_default_value( + fresh_db["foo"].columns[0].default_value + ) From 02f5c4d69d7b4baebde015c56e5bc62923f33314 Mon Sep 17 00:00:00 2001 From: Martin Carpenter Date: Mon, 8 May 2023 23:53:58 +0200 Subject: [PATCH 274/563] Support repeated calls to Table.convert() * Test repeated calls to Table.convert() * Register Table.convert() functions under their own `lambda_hash` name * Raise exception on registering identical function names Refs #525 --- sqlite_utils/db.py | 18 +++++++++++++----- tests/test_convert.py | 9 +++++++++ tests/test_register_function.py | 17 +++++++++++++---- 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index dcd3b5f..5972032 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -219,6 +219,11 @@ class AlterError(Exception): pass +class FunctionAlreadyRegistered(Exception): + "A function with this name and arity was already registered" + pass + + class NoObviousTable(Exception): "Could not tell which table this operation refers to" pass @@ -409,7 +414,7 @@ class Database: fn_name = name or fn.__name__ arity = len(inspect.signature(fn).parameters) if not replace and (fn_name, arity) in self._registered_functions: - return fn + raise FunctionAlreadyRegistered(f'Already registered function with name "{fn_name}" and identical arity') kwargs = {} registered = False if deterministic: @@ -434,7 +439,7 @@ class Database: def register_fts4_bm25(self): "Register the ``rank_bm25(match_info)`` function used for calculating relevance with SQLite FTS4." - self.register_function(rank_bm25, deterministic=True) + self.register_function(rank_bm25, deterministic=True, replace=True) def attach(self, alias: str, filepath: Union[str, pathlib.Path]): """ @@ -2687,13 +2692,16 @@ class Table(Queryable): return v return jsonify_if_needed(fn(v)) - self.db.register_function(convert_value) + fn_name = fn.__name__ + if fn_name == '': + fn_name = f'lambda_{hash(fn)}' + self.db.register_function(convert_value, name=fn_name) sql = "update [{table}] set {sets}{where};".format( table=self.name, sets=", ".join( [ - "[{output_column}] = convert_value([{column}])".format( - output_column=output or column, column=column + "[{output_column}] = {fn_name}([{column}])".format( + output_column=output or column, column=column, fn_name=fn_name ) for column in columns ] diff --git a/tests/test_convert.py b/tests/test_convert.py index 9fd29c3..8fffb4a 100644 --- a/tests/test_convert.py +++ b/tests/test_convert.py @@ -147,3 +147,12 @@ def test_convert_multi_exception(fresh_db): table.insert({"title": "Mixed Case"}) with pytest.raises(BadMultiValues): table.convert("title", lambda v: v.upper(), multi=True) + + +def test_convert_repeated(fresh_db): + table = fresh_db["table"] + col = "num" + table.insert({col: 1}) + table.convert(col, lambda x: x*2) + table.convert(col, lambda _x: 0) + assert table.get(1) == {col: 0} diff --git a/tests/test_register_function.py b/tests/test_register_function.py index 5169a67..05721ea 100644 --- a/tests/test_register_function.py +++ b/tests/test_register_function.py @@ -3,7 +3,7 @@ import pytest import sys from unittest.mock import MagicMock, call from sqlite_utils.utils import sqlite3 - +from sqlite_utils.db import FunctionAlreadyRegistered def test_register_function(fresh_db): @fresh_db.register_function @@ -85,9 +85,10 @@ def test_register_function_replace(fresh_db): assert "one" == fresh_db.execute("select one()").fetchone()[0] # This will fail to replace the function: - @fresh_db.register_function() - def one(): # noqa - return "two" + with pytest.raises(FunctionAlreadyRegistered): + @fresh_db.register_function() + def one(): # noqa + return "two" assert "one" == fresh_db.execute("select one()").fetchone()[0] @@ -97,3 +98,11 @@ def test_register_function_replace(fresh_db): return "two" assert "two" == fresh_db.execute("select one()").fetchone()[0] + + +def test_register_function_duplicate(fresh_db): + def to_lower(s): + return s.lower() + fresh_db.register_function(to_lower) + with pytest.raises(FunctionAlreadyRegistered): + fresh_db.register_function(to_lower) From fca3ef8cf2a68b7a5fa1d740c4439adc7f83e431 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 8 May 2023 14:54:24 -0700 Subject: [PATCH 275/563] Applied Black, refs #526, #525 --- sqlite_utils/db.py | 12 ++++++++---- tests/test_convert.py | 2 +- tests/test_register_function.py | 3 +++ 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index 5972032..7cc1c99 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -414,7 +414,9 @@ class Database: fn_name = name or fn.__name__ arity = len(inspect.signature(fn).parameters) if not replace and (fn_name, arity) in self._registered_functions: - raise FunctionAlreadyRegistered(f'Already registered function with name "{fn_name}" and identical arity') + raise FunctionAlreadyRegistered( + f'Already registered function with name "{fn_name}" and identical arity' + ) kwargs = {} registered = False if deterministic: @@ -2693,15 +2695,17 @@ class Table(Queryable): return jsonify_if_needed(fn(v)) fn_name = fn.__name__ - if fn_name == '': - fn_name = f'lambda_{hash(fn)}' + if fn_name == "": + fn_name = f"lambda_{hash(fn)}" self.db.register_function(convert_value, name=fn_name) sql = "update [{table}] set {sets}{where};".format( table=self.name, sets=", ".join( [ "[{output_column}] = {fn_name}([{column}])".format( - output_column=output or column, column=column, fn_name=fn_name + output_column=output or column, + column=column, + fn_name=fn_name, ) for column in columns ] diff --git a/tests/test_convert.py b/tests/test_convert.py index 8fffb4a..5279bc5 100644 --- a/tests/test_convert.py +++ b/tests/test_convert.py @@ -153,6 +153,6 @@ def test_convert_repeated(fresh_db): table = fresh_db["table"] col = "num" table.insert({col: 1}) - table.convert(col, lambda x: x*2) + table.convert(col, lambda x: x * 2) table.convert(col, lambda _x: 0) assert table.get(1) == {col: 0} diff --git a/tests/test_register_function.py b/tests/test_register_function.py index 05721ea..5b92631 100644 --- a/tests/test_register_function.py +++ b/tests/test_register_function.py @@ -5,6 +5,7 @@ from unittest.mock import MagicMock, call from sqlite_utils.utils import sqlite3 from sqlite_utils.db import FunctionAlreadyRegistered + def test_register_function(fresh_db): @fresh_db.register_function def reverse_string(s): @@ -86,6 +87,7 @@ def test_register_function_replace(fresh_db): # This will fail to replace the function: with pytest.raises(FunctionAlreadyRegistered): + @fresh_db.register_function() def one(): # noqa return "two" @@ -103,6 +105,7 @@ def test_register_function_replace(fresh_db): def test_register_function_duplicate(fresh_db): def to_lower(s): return s.lower() + fresh_db.register_function(to_lower) with pytest.raises(FunctionAlreadyRegistered): fresh_db.register_function(to_lower) From eebd1a26ae626cdaba6e568bf11f32c76b60ad09 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 8 May 2023 14:58:28 -0700 Subject: [PATCH 276/563] Removed FunctionAlreadyRegistered error, refs #526, #525 --- sqlite_utils/db.py | 9 +-------- tests/test_register_function.py | 20 ++++---------------- 2 files changed, 5 insertions(+), 24 deletions(-) diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index 7cc1c99..2b2091a 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -219,11 +219,6 @@ class AlterError(Exception): pass -class FunctionAlreadyRegistered(Exception): - "A function with this name and arity was already registered" - pass - - class NoObviousTable(Exception): "Could not tell which table this operation refers to" pass @@ -414,9 +409,7 @@ class Database: fn_name = name or fn.__name__ arity = len(inspect.signature(fn).parameters) if not replace and (fn_name, arity) in self._registered_functions: - raise FunctionAlreadyRegistered( - f'Already registered function with name "{fn_name}" and identical arity' - ) + return fn kwargs = {} registered = False if deterministic: diff --git a/tests/test_register_function.py b/tests/test_register_function.py index 5b92631..e2591f4 100644 --- a/tests/test_register_function.py +++ b/tests/test_register_function.py @@ -3,7 +3,6 @@ import pytest import sys from unittest.mock import MagicMock, call from sqlite_utils.utils import sqlite3 -from sqlite_utils.db import FunctionAlreadyRegistered def test_register_function(fresh_db): @@ -85,12 +84,10 @@ def test_register_function_replace(fresh_db): assert "one" == fresh_db.execute("select one()").fetchone()[0] - # This will fail to replace the function: - with pytest.raises(FunctionAlreadyRegistered): - - @fresh_db.register_function() - def one(): # noqa - return "two" + # This will silently fail to replaec the function + @fresh_db.register_function() + def one(): # noqa + return "two" assert "one" == fresh_db.execute("select one()").fetchone()[0] @@ -100,12 +97,3 @@ def test_register_function_replace(fresh_db): return "two" assert "two" == fresh_db.execute("select one()").fetchone()[0] - - -def test_register_function_duplicate(fresh_db): - def to_lower(s): - return s.lower() - - fresh_db.register_function(to_lower) - with pytest.raises(FunctionAlreadyRegistered): - fresh_db.register_function(to_lower) From dab23884ae49f1497acd70d855105bf9701f4e36 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 8 May 2023 15:08:02 -0700 Subject: [PATCH 277/563] Better error message if rows_from_file called with StringIO, closes #520 Refs #448 --- sqlite_utils/utils.py | 8 +++++++- tests/test_rows_from_file.py | 10 +++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/sqlite_utils/utils.py b/sqlite_utils/utils.py index 3bd5db1..06c1a4c 100644 --- a/sqlite_utils/utils.py +++ b/sqlite_utils/utils.py @@ -303,7 +303,13 @@ def rows_from_file( elif format is None: # Detect the format, then call this recursively buffered = io.BufferedReader(cast(io.RawIOBase, fp), buffer_size=4096) - first_bytes = buffered.peek(2048).strip() + try: + first_bytes = buffered.peek(2048).strip() + except AttributeError: + # Likely the user passed a TextIO when this needs a BytesIO + raise TypeError( + "rows_from_file() requires a file-like object that supports peek(), such as io.BytesIO" + ) if first_bytes.startswith(b"[") or first_bytes.startswith(b"{"): # TODO: Detect newline-JSON return rows_from_file(buffered, format=Format.JSON) diff --git a/tests/test_rows_from_file.py b/tests/test_rows_from_file.py index fdc7a03..5316b86 100644 --- a/tests/test_rows_from_file.py +++ b/tests/test_rows_from_file.py @@ -1,5 +1,5 @@ from sqlite_utils.utils import rows_from_file, Format, RowError -from io import BytesIO +from io import BytesIO, StringIO import pytest @@ -44,3 +44,11 @@ def test_rows_from_file_extra_fields_strategies(ignore_extras, extras_key, expec # We did not expect an error raise assert list_rows == expected + + +def test_rows_from_file_error_on_string_io(): + with pytest.raises(TypeError) as ex: + rows_from_file(StringIO("id,name\r\n1,Cleo")) + assert ex.value.args == ( + "rows_from_file() requires a file-like object that supports peek(), such as io.BytesIO", + ) From c764a9ee8fdb2c55785cf1f538aa5a462cbb292b Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 8 May 2023 15:12:39 -0700 Subject: [PATCH 278/563] Avoid negative hashes in lambda names, refs #543 --- sqlite_utils/db.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index 2b2091a..ec4bbfc 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -2689,7 +2689,7 @@ class Table(Queryable): fn_name = fn.__name__ if fn_name == "": - fn_name = f"lambda_{hash(fn)}" + fn_name = f"lambda_{abs(hash(fn))}" self.db.register_function(convert_value, name=fn_name) sql = "update [{table}] set {sets}{where};".format( table=self.name, From b3b100d7f5b2a76ccd4bfe8b0301a29e321d0375 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 8 May 2023 15:33:57 -0700 Subject: [PATCH 279/563] Release 3.31 Refs #509, #517, #520, #525, #527, #532, #534, #536, #537, #538, #539 --- docs/changelog.rst | 17 +++++++++++++++++ setup.py | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index c3c12f2..9c104f1 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,23 @@ Changelog =========== +.. _v3_31: + +3.31 (2023-05-08) +----------------- + +- Dropped support for Python 3.6. Tests now ensure compatibility with Python 3.11. (:issue:`517`) +- Automatically locates the SpatiaLite extension on Apple Silicon. Thanks, Chris Amico. (`#536 `__) +- New ``--raw-lines`` option for the ``sqlite-utils query`` and ``sqlite-utils memory`` commands, which outputs just the raw value of the first column of every row. (:issue:`539`) +- Fixed a bug where ``table.upsert_all()`` failed if the ``not_null=`` option was passed. (:issue:`538`) +- Fixed a ``ResourceWarning`` when using ``sqlite-utils insert``. (:issue:`534`) +- Now shows a more detailed error message when ``sqlite-utils insert`` is called with invalid JSON. (:issue:`532`) +- ``table.convert(..., skip_false=False)`` and ``sqlite-utils convert --no-skip-false`` options, for avoiding a misfeature where the :ref:`convert() ` mechanism skips rows in the database with a falsey value for the specified column. Fixing this by default would be a backwards-compatible change and is under consideration for a 4.0 release in the future. (:issue:`527`) +- Tables can now be created with self-referential foreign keys. Thanks, Scott Perry. (`#537 `__) +- ``sqlite-utils transform`` no longer breaks if a table defines default values for columns. Thanks, Kenny Song. (:issue:`509`) +- Fixed a bug where repeated calls to ``table.transform()`` did not work correctly. Thanks, Martin Carpenter. (:issue:`525`) +- Improved error message if ``rows_from_file()`` is passed a non-binary-mode file-like object. (:issue:`520`) + .. _v3_30: 3.30 (2022-10-25) diff --git a/setup.py b/setup.py index 4098a33..3964b3a 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages import io import os -VERSION = "3.30" +VERSION = "3.31" def get_long_description(): From e047cc32e9d5de7025d4d3c16554d4290f4bd3d1 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 9 May 2023 14:08:31 -0700 Subject: [PATCH 280/563] backwards-incompatible, not compatible --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 9c104f1..347601a 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -15,7 +15,7 @@ - Fixed a bug where ``table.upsert_all()`` failed if the ``not_null=`` option was passed. (:issue:`538`) - Fixed a ``ResourceWarning`` when using ``sqlite-utils insert``. (:issue:`534`) - Now shows a more detailed error message when ``sqlite-utils insert`` is called with invalid JSON. (:issue:`532`) -- ``table.convert(..., skip_false=False)`` and ``sqlite-utils convert --no-skip-false`` options, for avoiding a misfeature where the :ref:`convert() ` mechanism skips rows in the database with a falsey value for the specified column. Fixing this by default would be a backwards-compatible change and is under consideration for a 4.0 release in the future. (:issue:`527`) +- ``table.convert(..., skip_false=False)`` and ``sqlite-utils convert --no-skip-false`` options, for avoiding a misfeature where the :ref:`convert() ` mechanism skips rows in the database with a falsey value for the specified column. Fixing this by default would be a backwards-incompatible change and is under consideration for a 4.0 release in the future. (:issue:`527`) - Tables can now be created with self-referential foreign keys. Thanks, Scott Perry. (`#537 `__) - ``sqlite-utils transform`` no longer breaks if a table defines default values for columns. Thanks, Kenny Song. (:issue:`509`) - Fixed a bug where repeated calls to ``table.transform()`` did not work correctly. Thanks, Martin Carpenter. (:issue:`525`) From d2a7b15b2b930fe384e1f1715fc4af23386f4935 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 21 May 2023 09:19:30 -0700 Subject: [PATCH 281/563] Analyze tables options: --common-limit, --no-most, --no-least Closes #544 --- docs/cli-reference.rst | 11 ++-- docs/cli.rst | 10 ++-- docs/python-api.rst | 25 +++++++-- sqlite_utils/cli.py | 19 +++++-- sqlite_utils/db.py | 54 ++++++++++++-------- tests/test_analyze_tables.py | 99 ++++++++++++++++++++++++++++++++++-- 6 files changed, 178 insertions(+), 40 deletions(-) diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index 8993213..c108138 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -564,10 +564,13 @@ See :ref:`cli_analyze_tables`. sqlite-utils analyze-tables data.db trees Options: - -c, --column TEXT Specific columns to analyze - --save Save results to _analyze_tables table - --load-extension TEXT Path to SQLite extension, with optional :entrypoint - -h, --help Show this message and exit. + -c, --column TEXT Specific columns to analyze + --save Save results to _analyze_tables table + --common-limit INTEGER How many common values + --no-most Skip most common values + --no-least Skip least common values + --load-extension TEXT Path to SQLite extension, with optional :entrypoint + -h, --help Show this message and exit. .. _cli_ref_convert: diff --git a/docs/cli.rst b/docs/cli.rst index 572b0e5..d567cba 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -730,11 +730,15 @@ For each column this tool displays the number of null rows, the number of blank If you do not specify any tables every table in the database will be analyzed:: - $ sqlite-utils analyze-tables github.db + sqlite-utils analyze-tables github.db If you wish to analyze one or more specific columns, use the ``-c`` option:: - $ sqlite-utils analyze-tables github.db tags -c sha + sqlite-utils analyze-tables github.db tags -c sha + +To show more than 10 common values, use ``--common-limit 20``. To skip the most common or least common value analysis, use ``--no-most`` or ``--no-least``:: + + sqlite-utils analyze-tables github.db tags --common-limit 20 --no-least .. _cli_analyze_tables_save: @@ -743,7 +747,7 @@ Saving the analyzed table details ``analyze-tables`` can take quite a while to run for large database files. You can save the results of the analysis to a database table called ``_analyze_tables_`` using the ``--save`` option:: - $ sqlite-utils analyze-tables github.db --save + sqlite-utils analyze-tables github.db --save The ``_analyze_tables_`` table has the following schema:: diff --git a/docs/python-api.rst b/docs/python-api.rst index 4d883db..4b143c3 100644 --- a/docs/python-api.rst +++ b/docs/python-api.rst @@ -1115,7 +1115,26 @@ You can inspect the database to see the results like this:: Analyzing a column ================== -The ``table.analyze_column(column, common_limit=10, value_truncate=None)`` method is used by the :ref:`analyze-tables ` CLI command. It returns a ``ColumnDetails`` named tuple with the following fields: +The ``table.analyze_column(column)`` method is used by the :ref:`analyze-tables ` CLI command. + +It takes the following arguments and options: + +``column`` - required + The name of the column to analyze + +``common_limit`` + The number of most common values to return. Defaults to 10. + +``value_truncate`` + If set to an integer, values longer than this will be truncated to this length. Defaults to None. + +``most_common`` + If set to False, the ``most_common`` field of the returned ``ColumnDetails`` will be set to None. Defaults to True. + +``least_common`` + If set to False, the ``least_common`` field of the returned ``ColumnDetails`` will be set to None. Defaults to True. + +And returns a ``ColumnDetails`` named tuple with the following fields: ``table`` The name of the table @@ -1141,10 +1160,6 @@ The ``table.analyze_column(column, common_limit=10, value_truncate=None)`` metho ``least_common`` The ``N`` least common values as a list of ``(value, count)`` tuples`, or ``None`` if the table is entirely distinct or if the number of distinct values is less than N (since they will already have been returned in ``most_common``) -``N`` defaults to 10, or you can pass a custom ``N`` using the ``common_limit`` parameter. - -You can use the ``value_truncate`` parameter to truncate values in the ``most_common`` and ``least_common`` lists to a specified number of characters. - .. _python_api_add_column: Adding columns diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index ce354e0..0c91a8f 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -2639,12 +2639,20 @@ def insert_files( help="Specific columns to analyze", ) @click.option("--save", is_flag=True, help="Save results to _analyze_tables table") +@click.option("--common-limit", type=int, default=10, help="How many common values") +@click.option("--no-most", is_flag=True, default=False, help="Skip most common values") +@click.option( + "--no-least", is_flag=True, default=False, help="Skip least common values" +) @load_extension_option def analyze_tables( path, tables, columns, save, + common_limit, + no_most, + no_least, load_extension, ): """Analyze the columns in one or more tables @@ -2656,10 +2664,10 @@ def analyze_tables( """ db = sqlite_utils.Database(path) _load_extensions(db, load_extension) - _analyze(db, tables, columns, save) + _analyze(db, tables, columns, save, common_limit, no_most, no_least) -def _analyze(db, tables, columns, save): +def _analyze(db, tables, columns, save, common_limit=10, no_most=False, no_least=False): if not tables: tables = db.table_names() todo = [] @@ -2672,7 +2680,12 @@ def _analyze(db, tables, columns, save): # Now we now how many we need to do for i, (table, column) in enumerate(todo): column_details = db[table].analyze_column( - column, total_rows=table_counts[table], value_truncate=80 + column, + common_limit=common_limit, + total_rows=table_counts[table], + value_truncate=80, + most_common=not no_most, + least_common=not no_least, ) if save: db["_analyze_tables_"].insert( diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index ec4bbfc..850a3ae 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -3419,7 +3419,13 @@ class Table(Queryable): self.db.analyze(self.name) def analyze_column( - self, column: str, common_limit: int = 10, value_truncate=None, total_rows=None + self, + column: str, + common_limit: int = 10, + value_truncate=None, + total_rows=None, + most_common: bool = True, + least_common: bool = True, ) -> "ColumnDetails": """ Return statistics about the specified column. @@ -3430,6 +3436,8 @@ class Table(Queryable): :param common_limit: Show this many column values :param value_truncate: Truncate display of common values to this many characters :param total_rows: Optimization - pass the total number of rows in the table to save running a fresh ``count(*)`` query + :param most_common: If ``True``, calculate the most common values + :param least_common: If ``True``, calculate the least common values """ db = self.db table = self.name @@ -3453,36 +3461,38 @@ class Table(Queryable): num_distinct = db.execute( "select count(distinct [{}]) from [{}]".format(column, table) ).fetchone()[0] - most_common = None - least_common = None + most_common_results = None + least_common_results = None if num_distinct == 1: value = db.execute( "select [{}] from [{}] limit 1".format(column, table) ).fetchone()[0] - most_common = [(truncate(value), total_rows)] + most_common_results = [(truncate(value), total_rows)] elif num_distinct != total_rows: - most_common = [ - (truncate(r[0]), r[1]) - for r in db.execute( - "select [{}], count(*) from [{}] group by [{}] order by count(*) desc, [{}] limit {}".format( - column, table, column, column, common_limit - ) - ).fetchall() - ] - most_common.sort(key=lambda p: (p[1], p[0]), reverse=True) - if num_distinct <= common_limit: - # No need to run the query if it will just return the results in revers order - least_common = None - else: - least_common = [ + if most_common: + most_common_results = [ (truncate(r[0]), r[1]) for r in db.execute( - "select [{}], count(*) from [{}] group by [{}] order by count(*), [{}] desc limit {}".format( + "select [{}], count(*) from [{}] group by [{}] order by count(*) desc, [{}] limit {}".format( column, table, column, column, common_limit ) ).fetchall() ] - least_common.sort(key=lambda p: (p[1], p[0])) + most_common_results.sort(key=lambda p: (p[1], p[0]), reverse=True) + if least_common: + if num_distinct <= common_limit: + # No need to run the query if it will just return the results in revers order + least_common_results = None + else: + least_common_results = [ + (truncate(r[0]), r[1]) + for r in db.execute( + "select [{}], count(*) from [{}] group by [{}] order by count(*), [{}] desc limit {}".format( + column, table, column, column, common_limit + ) + ).fetchall() + ] + least_common_results.sort(key=lambda p: (p[1], p[0])) return ColumnDetails( self.name, column, @@ -3490,8 +3500,8 @@ class Table(Queryable): num_null, num_blank, num_distinct, - most_common, - least_common, + most_common_results, + least_common_results, ) def add_geometry_column( diff --git a/tests/test_analyze_tables.py b/tests/test_analyze_tables.py index 5795a7a..a3e4e2f 100644 --- a/tests/test_analyze_tables.py +++ b/tests/test_analyze_tables.py @@ -24,11 +24,34 @@ def db_to_analyze(fresh_db): return fresh_db +@pytest.fixture +def big_db_to_analyze_path(tmpdir): + path = str(tmpdir / "test.db") + db = Database(path) + categories = { + "A": 40, + "B": 30, + "C": 20, + "D": 10, + } + to_insert = [] + for category, count in categories.items(): + for _ in range(count): + to_insert.append( + { + "category": category, + } + ) + db["stuff"].insert_all(to_insert) + return path + + @pytest.mark.parametrize( - "column,expected", + "column,extra_kwargs,expected", [ ( "id", + {}, ColumnDetails( table="stuff", column="id", @@ -42,6 +65,7 @@ def db_to_analyze(fresh_db): ), ( "owner", + {}, ColumnDetails( table="stuff", column="owner", @@ -55,6 +79,7 @@ def db_to_analyze(fresh_db): ), ( "size", + {}, ColumnDetails( table="stuff", column="size", @@ -66,11 +91,41 @@ def db_to_analyze(fresh_db): least_common=None, ), ), + ( + "owner", + {"most_common": False}, + ColumnDetails( + table="stuff", + column="owner", + total_rows=8, + num_null=0, + num_blank=0, + num_distinct=4, + most_common=None, + least_common=[("Anne", 1), ("Terry...", 2)], + ), + ), + ( + "owner", + {"least_common": False}, + ColumnDetails( + table="stuff", + column="owner", + total_rows=8, + num_null=0, + num_blank=0, + num_distinct=4, + most_common=[("Joan", 3), ("Kumar", 2)], + least_common=None, + ), + ), ], ) -def test_analyze_column(db_to_analyze, column, expected): +def test_analyze_column(db_to_analyze, column, extra_kwargs, expected): assert ( - db_to_analyze["stuff"].analyze_column(column, common_limit=2, value_truncate=5) + db_to_analyze["stuff"].analyze_column( + column, common_limit=2, value_truncate=5, **extra_kwargs + ) == expected ) @@ -164,3 +219,41 @@ def test_analyze_table_save(db_to_analyze_path): "least_common": None, }, ] + + +@pytest.mark.parametrize( + "no_most,no_least", + ( + (False, False), + (True, False), + (False, True), + (True, True), + ), +) +def test_analyze_table_save_no_most_no_least_options( + no_most, no_least, big_db_to_analyze_path +): + args = ["analyze-tables", big_db_to_analyze_path, "--save", "--common-limit", "2"] + if no_most: + args.append("--no-most") + if no_least: + args.append("--no-least") + result = CliRunner().invoke(cli.cli, args) + assert result.exit_code == 0 + rows = list(Database(big_db_to_analyze_path)["_analyze_tables_"].rows) + expected = { + "table": "stuff", + "column": "category", + "total_rows": 100, + "num_null": 0, + "num_blank": 0, + "num_distinct": 4, + "most_common": None, + "least_common": None, + } + if not no_most: + expected["most_common"] = '[["A", 40], ["B", 30]]' + if not no_least: + expected["least_common"] = '[["D", 10], ["C", 20]]' + + assert rows == [expected] From 6027f3ea6939a399aeef2578fca17efec0e539df Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 21 May 2023 10:19:16 -0700 Subject: [PATCH 282/563] No need to show common values if everything is null Closes #547 --- sqlite_utils/cli.py | 8 +++++--- sqlite_utils/db.py | 24 ++++++++++++++---------- tests/test_analyze_tables.py | 26 +++++++++++++++++++++++++- 3 files changed, 44 insertions(+), 14 deletions(-) diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 0c91a8f..46af7ae 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -2691,9 +2691,11 @@ def _analyze(db, tables, columns, save, common_limit=10, no_most=False, no_least db["_analyze_tables_"].insert( column_details._asdict(), pk=("table", "column"), replace=True ) - most_common_rendered = _render_common( - "\n\n Most common:", column_details.most_common - ) + most_common_rendered = "" + if column_details.num_null != column_details.total_rows: + most_common_rendered = _render_common( + "\n\n Most common:", column_details.most_common + ) least_common_rendered = _render_common( "\n\n Least common:", column_details.least_common ) diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index 850a3ae..997c2a5 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -3470,18 +3470,22 @@ class Table(Queryable): most_common_results = [(truncate(value), total_rows)] elif num_distinct != total_rows: if most_common: - most_common_results = [ - (truncate(r[0]), r[1]) - for r in db.execute( - "select [{}], count(*) from [{}] group by [{}] order by count(*) desc, [{}] limit {}".format( - column, table, column, column, common_limit - ) - ).fetchall() - ] - most_common_results.sort(key=lambda p: (p[1], p[0]), reverse=True) + # Optimization - if all rows are null, don't run this query + if num_null == total_rows: + most_common_results = [(None, total_rows)] + else: + most_common_results = [ + (truncate(r[0]), r[1]) + for r in db.execute( + "select [{}], count(*) from [{}] group by [{}] order by count(*) desc, [{}] limit {}".format( + column, table, column, column, common_limit + ) + ).fetchall() + ] + most_common_results.sort(key=lambda p: (p[1], p[0]), reverse=True) if least_common: if num_distinct <= common_limit: - # No need to run the query if it will just return the results in revers order + # No need to run the query if it will just return the results in reverse order least_common_results = None else: least_common_results = [ diff --git a/tests/test_analyze_tables.py b/tests/test_analyze_tables.py index a3e4e2f..f4559ff 100644 --- a/tests/test_analyze_tables.py +++ b/tests/test_analyze_tables.py @@ -40,6 +40,7 @@ def big_db_to_analyze_path(tmpdir): to_insert.append( { "category": category, + "all_null": None, } ) db["stuff"].insert_all(to_insert) @@ -233,7 +234,15 @@ def test_analyze_table_save(db_to_analyze_path): def test_analyze_table_save_no_most_no_least_options( no_most, no_least, big_db_to_analyze_path ): - args = ["analyze-tables", big_db_to_analyze_path, "--save", "--common-limit", "2"] + args = [ + "analyze-tables", + big_db_to_analyze_path, + "--save", + "--common-limit", + "2", + "--column", + "category", + ] if no_most: args.append("--no-most") if no_least: @@ -257,3 +266,18 @@ def test_analyze_table_save_no_most_no_least_options( expected["least_common"] = '[["D", 10], ["C", 20]]' assert rows == [expected] + + +def test_analyze_table_column_all_nulls(big_db_to_analyze_path): + result = CliRunner().invoke( + cli.cli, + ["analyze-tables", big_db_to_analyze_path, "stuff", "--column", "all_null"], + ) + assert result.exit_code == 0 + assert result.output == ( + "stuff.all_null: (1/1)\n\n Total rows: 100\n" + " Null rows: 100\n" + " Blank rows: 0\n" + "\n" + " Distinct values: 0\n\n" + ) From e8c5b042e49c627aefd620c8d4b1c84eb8677f73 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 21 May 2023 10:35:48 -0700 Subject: [PATCH 283/563] Validate column names in analyze-columns, closes #548 --- sqlite_utils/cli.py | 9 +++++++++ tests/test_analyze_tables.py | 37 ++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 46af7ae..5a88bd5 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -2672,11 +2672,20 @@ def _analyze(db, tables, columns, save, common_limit=10, no_most=False, no_least tables = db.table_names() todo = [] table_counts = {} + seen_columns = set() for table in tables: table_counts[table] = db[table].count for column in db[table].columns: if not columns or column.name in columns: todo.append((table, column.name)) + seen_columns.add(column.name) + # Check the user didn't specify a column that doesn't exist + if columns and (set(columns) - seen_columns): + raise click.ClickException( + "These columns were not found: {}".format( + ", ".join(sorted(set(columns) - seen_columns)) + ) + ) # Now we now how many we need to do for i, (table, column) in enumerate(todo): column_details = db[table].analyze_column( diff --git a/tests/test_analyze_tables.py b/tests/test_analyze_tables.py index f4559ff..dc90325 100644 --- a/tests/test_analyze_tables.py +++ b/tests/test_analyze_tables.py @@ -281,3 +281,40 @@ def test_analyze_table_column_all_nulls(big_db_to_analyze_path): "\n" " Distinct values: 0\n\n" ) + + +@pytest.mark.parametrize( + "args,expected_error", + ( + (["-c", "bad_column"], "These columns were not found: bad_column\n"), + (["one", "-c", "age"], "These columns were not found: age\n"), + (["two", "-c", "age"], None), + ( + ["one", "-c", "age", "--column", "bad"], + "These columns were not found: age, bad\n", + ), + ), +) +def test_analyze_table_validate_columns(tmpdir, args, expected_error): + path = str(tmpdir / "test_validate_columns.db") + db = Database(path) + db["one"].insert( + { + "id": 1, + "name": "one", + } + ) + db["two"].insert( + { + "id": 1, + "age": 5, + } + ) + result = CliRunner().invoke( + cli.cli, + ["analyze-tables", path] + args, + catch_exceptions=False, + ) + assert result.exit_code == (1 if expected_error else 0) + if expected_error: + assert expected_error in result.output From 718b0cba9b32d97a41bcf9757c97fe1d058da81c Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 21 May 2023 11:41:56 -0700 Subject: [PATCH 284/563] Experimental TUI powered by Trogon * sqlite-utils tui command if Trogon is installed, closes #545 * Documentation for trogon TUI * Screenshot of TUI * Ignore trogon mypy error * only run flake8 on Python 3.8 or higher, closes #550 --- .github/workflows/publish.yml | 2 +- .github/workflows/test.yml | 5 +++-- docs/_static/img/tui.png | Bin 0 -> 238641 bytes docs/cli-reference.rst | 18 ++++++++++++++++++ docs/cli.rst | 22 ++++++++++++++++++++++ setup.py | 1 + sqlite_utils/cli.py | 9 +++++++++ tests/test_docs.py | 2 +- 8 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 docs/_static/img/tui.png diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a37907a..d5a0b69 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -26,7 +26,7 @@ jobs: ${{ runner.os }}-pip- - name: Install dependencies run: | - pip install -e '.[test]' + pip install -e '.[test,tui]' - name: Run tests run: | pytest diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2e706df..ab30b2d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,7 +28,7 @@ jobs: ${{ runner.os }}-pip- - name: Install dependencies run: | - pip install -e '.[test,mypy,flake8]' + pip install -e '.[test,mypy,flake8,tui]' - name: Optionally install numpy if: matrix.numpy == 1 run: pip install numpy @@ -44,7 +44,8 @@ jobs: pytest -v - name: run mypy run: mypy sqlite_utils tests - - name: run flake8 + - name: run flake8 if Python 3.8 or higher + if: matrix.python-version >= 3.8 run: flake8 - name: Check formatting run: black . --check diff --git a/docs/_static/img/tui.png b/docs/_static/img/tui.png new file mode 100644 index 0000000000000000000000000000000000000000..04cbeb1ed36b4e4155708dd04194f62a1f52c795 GIT binary patch literal 238641 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%0Amn6rzH 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ȌoRICv8*~Rf z0Z`PSIf3|q)(t(Oh=HP|K%A~{g6A#Gb=!fZMD&F0>x;PEh!!3{_Q<>V3_pQz0(uw= z+aV;|;W3E^;$wUYUQg*dFAS4yZi6j6Adn+o^RBEeFe#)#GEY7K-VbNO3DkUr6)fj(1nebV0oKEWIblK#zGaiGU{Bg4 zjY&@c34j^K0&+W&C16{-Y=;)Le}2k3C-iOM1hF32cCae`rL8};UvK|jNvqoLpt*x3 z>j&!u)?2NEH|rvMvV)!N&=QdIMHvm67!)Uf4}fhkP?8IND<^~@G=$NDbp-8gOqnAt zN9HnNW1e=mz1~?k?DYyxASBAi_!N@;;0=1BCnOu07?SV=0<=Q-YQD?W1SNc%w1fj1eL)K`LAc% z#|Y#t63By@8f5&pjYDFFCV3IwL9$80xq4FuGhrQfkI125_Tgi!6+TA03vIzPZE_)W z@su?^!DvuJX&SH{q$kq0bgRb^tCnm4ctD50k)#x`G*3Wc!P2EHS^77K!i|<|DM|lU zaVTBK;%2F(&dE`o(@$B~4)Sx>x&*QOd9@_9D%G#tzVtV={?BcfKJU(R`hE51)iuCT zOO}?lJg;;ePV9J!=7e;E5(qknEUnsh_#%1&mh-LtggBW?#3}F4%5zS@bYu@jU*u9r zaT+au6Y(X=15*>}iX4Iy(B;USK*mCk`y#{a^aTA4y#e<-P*>zx7KHJ42my~(i$1JP zCPBLhB9>JH;%OetGT)yE^Q&9p1R^<1ZH;T=*-SG+94kr)Zaxzwln~v;Nw%%0A&&F7 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%0D$fDcLrU;ka?1!IA}|6g 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Ǝ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%0D%om=v8jihPzajD7B0Uym`r{Y> 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 literal 0 HcmV?d00001 diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index c108138..e38d08d 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -61,6 +61,7 @@ This page lists the ``--help`` for every ``sqlite-utils`` CLI sub-command. "create-spatial-index": "cli_spatialite_indexes", "install": "cli_install", "uninstall": "cli_uninstall", + "tui": "cli_tui", } commands.sort(key = lambda command: go_first.index(command) if command in go_first else 999) cog.out("\n") @@ -1025,6 +1026,23 @@ disable-fts -h, --help Show this message and exit. +.. _cli_ref_tui: + +tui +=== + +See :ref:`cli_tui`. + +:: + + Usage: sqlite-utils tui [OPTIONS] + + Open Textual TUI. + + Options: + -h, --help Show this message and exit. + + .. _cli_ref_optimize: optimize diff --git a/docs/cli.rst b/docs/cli.rst index d567cba..3b9ac31 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -2163,3 +2163,25 @@ You can uninstall packages that were installed using ``sqlite-utils install`` wi $ sqlite-utils uninstall beautifulsoup4 Use ``-y`` to skip the request for confirmation. + +.. _cli_tui: + +Experimental TUI +================ + +A TUI is a "text user interface" (or "terminal user interface") - a keyboard and mouse driven graphical interface running in your terminal. + +``sqlite-utils`` has experimental support for a TUI for building command-line invocations, built on top of the `Trogon `__ TUI library. + +To enable this feature you will need to install the ``trogon`` dependency. You can do that like so:: + + sqite-utils install trogon + +Once installed, running the ``sqlite-utils tui`` command will launch the TUI interface:: + + sqlite-utils tui + +You can then construct a command by selecting options from the menus, and execute it using ``Ctrl+R``. + +.. image:: _static/img/tui.png + :alt: A TUI interface for sqlite-utils - the left column shows a list of commands, while the right panel has a form for constructing arguments to the add-column command. diff --git a/setup.py b/setup.py index 3964b3a..5898ffa 100644 --- a/setup.py +++ b/setup.py @@ -47,6 +47,7 @@ setup( "data-science-types", ], "flake8": ["flake8"], + "tui": ["trogon"], }, entry_points=""" [console_scripts] diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 5a88bd5..ab9c524 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -33,6 +33,11 @@ from .utils import ( TypeTracker, ) +try: + import trogon # type: ignore +except ImportError: + trogon = None + CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) @@ -112,6 +117,10 @@ def cli(): pass +if trogon is not None: + cli = trogon.tui()(cli) + + @cli.command() @click.argument( "path", diff --git a/tests/test_docs.py b/tests/test_docs.py index 7f2e3ed..c305c7a 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -5,7 +5,7 @@ import pytest import re docs_path = Path(__file__).parent.parent / "docs" -commands_re = re.compile(r"(?:\$ | )sqlite-utils (\S+) ") +commands_re = re.compile(r"(?:\$ | )sqlite-utils (\S+)") recipes_re = re.compile(r"r\.(\w+)\(") From e240133b11588d31dc22c632f7a7ca636c72978d Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 21 May 2023 11:53:33 -0700 Subject: [PATCH 285/563] Release 3.32 Refs #544, #545, #547, #548 --- docs/changelog.rst | 12 ++++++++++++ setup.py | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 347601a..ee8713f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,18 @@ Changelog =========== +.. _v3_32: + +3.32 (2023-05-21) +----------------- + +- New experimental ``sqlite-utils tui`` interface for interactively building command-line invocations, powered by `Trogon `__. This requires an optional dependency, installed using ``sqlite-utils install trogon``. There is a screenshot :ref:`in the documentation `. (:issue:`545`) +- ``sqlite-utils analyze-tables`` command (:ref:`documentation `) now has a ``--common-limit 20`` option for changing the number of common/least-common values shown for each column. (:issue:`544`) +- ``sqlite-utils analyze-tables --no-most`` and ``--no-least`` options for disabling calculation of most-common and least-common values. +- If a column contains only ``null`` values, ``analyze-tables`` will no longer attempt to calculate the most common and least common values for that column. (:issue:`547`) +- Calling ``sqlite-utils analyze-tables`` with non-existent columns in the ``-c/--column`` option now results in an error message. (:issue:`548`) +- The ``table.analyze_column()`` method (:ref:`documented here `) now accepts ``most_common=False`` and ``least_common=False`` options for disabling calculation of those values. + .. _v3_31: 3.31 (2023-05-08) diff --git a/setup.py b/setup.py index 5898ffa..0d05c5c 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages import io import os -VERSION = "3.31" +VERSION = "3.32" def get_long_description(): From d8fe1b0d899faaaa3d4714a39328f4c24932278f Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 21 May 2023 13:57:22 -0700 Subject: [PATCH 286/563] Reformatted CLI examples in docs Closes #551 --- docs/_templates/base.html | 5 + docs/cli.rst | 1201 ++++++++++++++++++++++++++----------- 2 files changed, 863 insertions(+), 343 deletions(-) diff --git a/docs/_templates/base.html b/docs/_templates/base.html index 43c2003..a253a46 100644 --- a/docs/_templates/base.html +++ b/docs/_templates/base.html @@ -7,6 +7,11 @@ {% block scripts %} {{ super() }} +