From 397183debd9329a2ddbcabe7a181278f042952ad Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 20 May 2022 14:51:30 -0700 Subject: [PATCH 001/416] 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 002/416] 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 003/416] 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 004/416] 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 005/416] 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 006/416] 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 007/416] 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 008/416] 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 009/416] 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 010/416] 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 011/416] 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 012/416] 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 013/416] 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 014/416] 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 015/416] 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 016/416] 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 017/416] 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 018/416] 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 019/416] 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 020/416] 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 021/416] 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 022/416] 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 023/416] 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 024/416] 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 025/416] 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 026/416] 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 027/416] 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 028/416] 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 029/416] 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 030/416] 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 031/416] 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 032/416] 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 033/416] 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 034/416] 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 035/416] 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 036/416] 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 037/416] 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 038/416] 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 039/416] 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 040/416] 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 041/416] 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 042/416] 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 043/416] 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 044/416] 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 045/416] 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 046/416] 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 047/416] 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 048/416] 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 049/416] 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 050/416] 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 051/416] 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 052/416] 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 053/416] 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 054/416] 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 055/416] 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 056/416] 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 057/416] 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 058/416] 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 059/416] 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 060/416] 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 061/416] 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 062/416] 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 063/416] 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 064/416] 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 065/416] 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 066/416] 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 067/416] 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 068/416] 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 069/416] 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 070/416] 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 071/416] 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 072/416] 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 073/416] 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 074/416] 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 075/416] 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 076/416] 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 077/416] '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 078/416] 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 079/416] 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 080/416] 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 081/416] 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 082/416] 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 083/416] 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 084/416] 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 085/416] 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 086/416] 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 087/416] 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 088/416] 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 089/416] 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 090/416] 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 091/416] 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 092/416] 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 093/416] 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 094/416] 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 095/416] 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 096/416] 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 097/416] 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 098/416] 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 099/416] 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 100/416] 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 101/416] 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 102/416] 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 103/416] 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 104/416] 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 105/416] 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 106/416] 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 107/416] 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 108/416] 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 109/416] 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 110/416] --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 111/416] 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 112/416] 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 113/416] 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 114/416] 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 115/416] 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 116/416] 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 117/416] .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 118/416] --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 119/416] 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 120/416] 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 121/416] 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 122/416] 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 123/416] 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 124/416] 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 125/416] 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 126/416] 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 127/416] 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 128/416] 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 129/416] 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 130/416] 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 131/416] 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 132/416] 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 133/416] 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 134/416] 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 135/416] 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() }} +