From f7d3e76fb3d1fa5aabe339251e4a930610643822 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 30 May 2021 22:31:14 -0400 Subject: [PATCH 0001/1250] Facets now execute ignoring ?_col and ?_nocol, fixes #1345 --- datasette/views/table.py | 30 ++++++++++++++++++++++-------- tests/test_api.py | 15 +++++++++++++++ 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/datasette/views/table.py b/datasette/views/table.py index b54a908a..c5703292 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -358,16 +358,21 @@ class TableView(RowTableShared): ) pks = await db.primary_keys(table) - table_columns = await self.columns_to_select(db, table, request) - select_clause = ", ".join(escape_sqlite(t) for t in table_columns) + table_columns = await db.table_columns(table) + + specified_columns = await self.columns_to_select(db, table, request) + select_specified_columns = ", ".join( + escape_sqlite(t) for t in specified_columns + ) + select_all_columns = ", ".join(escape_sqlite(t) for t in table_columns) use_rowid = not pks and not is_view if use_rowid: - select = f"rowid, {select_clause}" + select_specified_columns = f"rowid, {select_specified_columns}" + select_all_columns = f"rowid, {select_all_columns}" order_by = "rowid" order_by_pks = "rowid" else: - select = select_clause order_by_pks = ", ".join([escape_sqlite(pk) for pk in pks]) order_by = order_by_pks @@ -633,7 +638,7 @@ class TableView(RowTableShared): where_clause = f"where {' and '.join(where_clauses)} " if order_by: - order_by = f"order by {order_by} " + order_by = f"order by {order_by}" extra_args = {} # Handle ?_size=500 @@ -656,13 +661,22 @@ class TableView(RowTableShared): else: page_size = self.ds.page_size - sql_no_limit = "select {select} from {table_name} {where}{order_by}".format( - select=select, + sql_no_limit = ( + "select {select_all_columns} from {table_name} {where}{order_by}".format( + select_all_columns=select_all_columns, + table_name=escape_sqlite(table), + where=where_clause, + order_by=order_by, + ) + ) + sql = "select {select_specified_columns} from {table_name} {where}{order_by} limit {page_size}{offset}".format( + select_specified_columns=select_specified_columns, table_name=escape_sqlite(table), where=where_clause, order_by=order_by, + page_size=page_size + 1, + offset=offset, ) - sql = f"{sql_no_limit.rstrip()} limit {page_size + 1}{offset}" if request.args.get("_timelimit"): extra_args["custom_time_limit"] = int(request.args.get("_timelimit")) diff --git a/tests/test_api.py b/tests/test_api.py index 00de84e6..2c5d7516 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -2041,6 +2041,21 @@ def test_http_options_request(app_client): "/fixtures/facetable.json?_col=state&_col=created&_nocol=created", ["pk", "state"], ), + ( + # Ensure faceting doesn't break, https://github.com/simonw/datasette/issues/1345 + "/fixtures/facetable.json?_nocol=state&_facet=state", + [ + "pk", + "created", + "planet_int", + "on_earth", + "city_id", + "neighborhood", + "tags", + "complex_array", + "distinct_some_null", + ], + ), ( "/fixtures/simple_view.json?_nocol=content", ["upper_content"], From c5ae1197a208e1b034c88882e3ac865813a40980 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 30 May 2021 22:39:14 -0400 Subject: [PATCH 0002/1250] ?_nofacets=1 option, closes #1350 --- datasette/views/table.py | 16 +++++++++------- docs/facets.rst | 2 ++ docs/json_api.rst | 3 +++ tests/test_api.py | 14 ++++++++++++++ 4 files changed, 28 insertions(+), 7 deletions(-) diff --git a/datasette/views/table.py b/datasette/views/table.py index c5703292..83c2b922 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -731,13 +731,14 @@ class TableView(RowTableShared): ) ) - for facet in facet_instances: - ( - instance_facet_results, - instance_facets_timed_out, - ) = await facet.facet_results() - facet_results.update(instance_facet_results) - facets_timed_out.extend(instance_facets_timed_out) + if not request.args.get("_nofacets"): + for facet in facet_instances: + ( + instance_facet_results, + instance_facets_timed_out, + ) = await facet.facet_results() + facet_results.update(instance_facet_results) + facets_timed_out.extend(instance_facets_timed_out) # Figure out columns and rows for the query columns = [r[0] for r in results.description] @@ -828,6 +829,7 @@ class TableView(RowTableShared): self.ds.setting("suggest_facets") and self.ds.setting("allow_facet") and not _next + and not request.args.get("_nofacets") ): for facet in facet_instances: suggested_facets.extend(await facet.suggest()) diff --git a/docs/facets.rst b/docs/facets.rst index 5061d11c..7730e4ac 100644 --- a/docs/facets.rst +++ b/docs/facets.rst @@ -86,6 +86,8 @@ If Datasette detects that a column is a foreign key, the ``"label"`` property wi The default number of facet results returned is 30, controlled by the :ref:`setting_default_facet_size` setting. You can increase this on an individual page by adding ``?_facet_size=100`` to the query string, up to a maximum of :ref:`setting_max_returned_rows` (which defaults to 1000). +.. _facets_metadata: + Facets in metadata.json ----------------------- diff --git a/docs/json_api.rst b/docs/json_api.rst index e48ec514..62c208a2 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -383,6 +383,9 @@ Special table arguments ``?_facet_size=100`` Increase the number of facet results returned for each facet. Use ``?_facet_size=max`` for the maximum available size, determined by :ref:`setting_max_returned_rows`. +``?_nofacets=1`` + Disable all facets and facet suggestions for this page, including any defined by :ref:`facets_metadata`. + ``?_trace=1`` Turns on tracing for this page: SQL queries executed during the request will be gathered and included in the response, either in a new ``"_traces"`` key diff --git a/tests/test_api.py b/tests/test_api.py index 2c5d7516..3d6d0330 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1669,6 +1669,20 @@ def test_suggest_facets_off(): assert [] == client.get("/fixtures/facetable.json").json["suggested_facets"] +@pytest.mark.parametrize("nofacets", (True, False)) +def test_nofacets(app_client, nofacets): + path = "/fixtures/facetable.json?_facet=state" + if nofacets: + path += "&_nofacets=1" + response = app_client.get(path) + if nofacets: + assert response.json["suggested_facets"] == [] + assert response.json["facet_results"] == {} + else: + assert response.json["suggested_facets"] != [] + assert response.json["facet_results"] != {} + + def test_expand_labels(app_client): response = app_client.get( "/fixtures/facetable.json?_shape=object&_labels=1&_size=2" From d1d06ace49606da790a765689b4fbffa4c6deecb Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 1 Jun 2021 08:49:50 -0700 Subject: [PATCH 0003/1250] ?_trac=1 for CSV, plus ?_nofacets=1 when rendering CSV Closes #1351, closes #1350 --- datasette/utils/__init__.py | 9 +++++++++ datasette/views/base.py | 38 +++++++++++++++++++++++++++++++++---- tests/test_csv.py | 24 ++++++++++++++++++++--- 3 files changed, 64 insertions(+), 7 deletions(-) diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py index 1fedb69c..dd47771f 100644 --- a/datasette/utils/__init__.py +++ b/datasette/utils/__init__.py @@ -7,6 +7,7 @@ import hashlib import inspect import itertools import json +import markupsafe import mergedeep import os import re @@ -777,6 +778,14 @@ class LimitedWriter: await self.writer.write(bytes) +class EscapeHtmlWriter: + def __init__(self, writer): + self.writer = writer + + async def write(self, content): + await self.writer.write(markupsafe.escape(content)) + + _infinities = {float("inf"), float("-inf")} diff --git a/datasette/views/base.py b/datasette/views/base.py index ba0f7d4c..aefaec6c 100644 --- a/datasette/views/base.py +++ b/datasette/views/base.py @@ -13,6 +13,7 @@ from datasette.plugins import pm from datasette.database import QueryInterrupted from datasette.utils import ( await_me_maybe, + EscapeHtmlWriter, InvalidSql, LimitedWriter, call_with_supported_arguments, @@ -262,6 +263,16 @@ class DataView(BaseView): async def as_csv(self, request, database, hash, **kwargs): stream = request.args.get("_stream") + # Do not calculate facets: + if not request.args.get("_nofacets"): + if not request.query_string: + new_query_string = "_nofacets=1" + else: + new_query_string = request.query_string + "&_nofacets=1" + new_scope = dict( + request.scope, query_string=new_query_string.encode("latin-1") + ) + request.scope = new_scope if stream: # Some quick sanity checks if not self.ds.setting("allow_csv_stream"): @@ -298,9 +309,27 @@ class DataView(BaseView): if column in expanded_columns: headings.append(f"{column}_label") + content_type = "text/plain; charset=utf-8" + preamble = "" + postamble = "" + + trace = request.args.get("_trace") + if trace: + content_type = "text/html; charset=utf-8" + preamble = ( + "CSV debug" + '" + async def stream_fn(r): - nonlocal data - writer = csv.writer(LimitedWriter(r, self.ds.setting("max_csv_mb"))) + nonlocal data, trace + limited_writer = LimitedWriter(r, self.ds.setting("max_csv_mb")) + if trace: + await limited_writer.write(preamble) + writer = csv.writer(EscapeHtmlWriter(limited_writer)) + else: + writer = csv.writer(limited_writer) first = True next = None while first or (next and stream): @@ -371,13 +400,14 @@ class DataView(BaseView): sys.stderr.flush() await r.write(str(e)) return + await limited_writer.write(postamble) - content_type = "text/plain; charset=utf-8" headers = {} if self.ds.cors: headers["Access-Control-Allow-Origin"] = "*" if request.args.get("_dl", None): - content_type = "text/csv; charset=utf-8" + if not trace: + content_type = "text/csv; charset=utf-8" disposition = 'attachment; filename="{}.csv"'.format( kwargs.get("table", database) ) diff --git a/tests/test_csv.py b/tests/test_csv.py index 6b17033c..30afbd9e 100644 --- a/tests/test_csv.py +++ b/tests/test_csv.py @@ -1,3 +1,4 @@ +from bs4 import BeautifulSoup as Soup from .fixtures import ( # noqa app_client, app_client_csv_max_mb_one, @@ -51,7 +52,7 @@ pk,foreign_key_with_label,foreign_key_with_label_label,foreign_key_with_blank_la def test_table_csv(app_client): - response = app_client.get("/fixtures/simple_primary_key.csv") + response = app_client.get("/fixtures/simple_primary_key.csv?_oh=1") assert response.status == 200 assert not response.headers.get("Access-Control-Allow-Origin") assert "text/plain; charset=utf-8" == response.headers["content-type"] @@ -104,8 +105,8 @@ def test_custom_sql_csv_blob_columns(app_client): assert "text/plain; charset=utf-8" == response.headers["content-type"] assert response.text == ( "rowid,data\r\n" - '1,"http://localhost/fixtures.blob?sql=select+rowid,+data+from+binary_data&_blob_column=data&_blob_hash=f3088978da8f9aea479ffc7f631370b968d2e855eeb172bea7f6c7a04262bb6d"\r\n' - '2,"http://localhost/fixtures.blob?sql=select+rowid,+data+from+binary_data&_blob_column=data&_blob_hash=b835b0483cedb86130b9a2c280880bf5fadc5318ddf8c18d0df5204d40df1724"\r\n' + '1,"http://localhost/fixtures.blob?sql=select+rowid,+data+from+binary_data&_nofacets=1&_blob_column=data&_blob_hash=f3088978da8f9aea479ffc7f631370b968d2e855eeb172bea7f6c7a04262bb6d"\r\n' + '2,"http://localhost/fixtures.blob?sql=select+rowid,+data+from+binary_data&_nofacets=1&_blob_column=data&_blob_hash=b835b0483cedb86130b9a2c280880bf5fadc5318ddf8c18d0df5204d40df1724"\r\n' "3,\r\n" ) @@ -157,3 +158,20 @@ def test_table_csv_stream(app_client): # With _stream=1 should return header + 1001 rows response = app_client.get("/fixtures/compound_three_primary_keys.csv?_stream=1") assert 1002 == len([b for b in response.body.split(b"\r\n") if b]) + + +def test_csv_trace(app_client): + response = app_client.get("/fixtures/simple_primary_key.csv?_trace=1") + assert response.headers["content-type"] == "text/html; charset=utf-8" + soup = Soup(response.text, "html.parser") + assert ( + soup.find("textarea").text + == "id,content\r\n1,hello\r\n2,world\r\n3,\r\n4,RENDER_CELL_DEMO\r\n" + ) + assert "select id, content from simple_primary_key" in soup.find("pre").text + + +def test_table_csv_stream_does_not_calculate_facets(app_client): + response = app_client.get("/fixtures/simple_primary_key.csv?_trace=1") + soup = Soup(response.text, "html.parser") + assert "select content, count(*) as n" not in soup.find("pre").text From 8bde6c54615af529e81de559cbb3bf3ee5fe17cb Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 1 Jun 2021 08:55:28 -0700 Subject: [PATCH 0004/1250] Rename ?_nofacets=1 to ?_nofacet=1, refs #1353 --- datasette/views/base.py | 6 +++--- datasette/views/table.py | 4 ++-- docs/json_api.rst | 2 +- tests/test_api.py | 10 +++++----- tests/test_csv.py | 4 ++-- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/datasette/views/base.py b/datasette/views/base.py index aefaec6c..b8c581fc 100644 --- a/datasette/views/base.py +++ b/datasette/views/base.py @@ -264,11 +264,11 @@ class DataView(BaseView): async def as_csv(self, request, database, hash, **kwargs): stream = request.args.get("_stream") # Do not calculate facets: - if not request.args.get("_nofacets"): + if not request.args.get("_nofacet"): if not request.query_string: - new_query_string = "_nofacets=1" + new_query_string = "_nofacet=1" else: - new_query_string = request.query_string + "&_nofacets=1" + new_query_string = request.query_string + "&_nofacet=1" new_scope = dict( request.scope, query_string=new_query_string.encode("latin-1") ) diff --git a/datasette/views/table.py b/datasette/views/table.py index 83c2b922..7fbf670b 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -731,7 +731,7 @@ class TableView(RowTableShared): ) ) - if not request.args.get("_nofacets"): + if not request.args.get("_nofacet"): for facet in facet_instances: ( instance_facet_results, @@ -829,7 +829,7 @@ class TableView(RowTableShared): self.ds.setting("suggest_facets") and self.ds.setting("allow_facet") and not _next - and not request.args.get("_nofacets") + and not request.args.get("_nofacet") ): for facet in facet_instances: suggested_facets.extend(await facet.suggest()) diff --git a/docs/json_api.rst b/docs/json_api.rst index 62c208a2..f1c347b7 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -383,7 +383,7 @@ Special table arguments ``?_facet_size=100`` Increase the number of facet results returned for each facet. Use ``?_facet_size=max`` for the maximum available size, determined by :ref:`setting_max_returned_rows`. -``?_nofacets=1`` +``?_nofacet=1`` Disable all facets and facet suggestions for this page, including any defined by :ref:`facets_metadata`. ``?_trace=1`` diff --git a/tests/test_api.py b/tests/test_api.py index 3d6d0330..5e639133 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1669,13 +1669,13 @@ def test_suggest_facets_off(): assert [] == client.get("/fixtures/facetable.json").json["suggested_facets"] -@pytest.mark.parametrize("nofacets", (True, False)) -def test_nofacets(app_client, nofacets): +@pytest.mark.parametrize("nofacet", (True, False)) +def test_nofacet(app_client, nofacet): path = "/fixtures/facetable.json?_facet=state" - if nofacets: - path += "&_nofacets=1" + if nofacet: + path += "&_nofacet=1" response = app_client.get(path) - if nofacets: + if nofacet: assert response.json["suggested_facets"] == [] assert response.json["facet_results"] == {} else: diff --git a/tests/test_csv.py b/tests/test_csv.py index 30afbd9e..40549fd8 100644 --- a/tests/test_csv.py +++ b/tests/test_csv.py @@ -105,8 +105,8 @@ def test_custom_sql_csv_blob_columns(app_client): assert "text/plain; charset=utf-8" == response.headers["content-type"] assert response.text == ( "rowid,data\r\n" - '1,"http://localhost/fixtures.blob?sql=select+rowid,+data+from+binary_data&_nofacets=1&_blob_column=data&_blob_hash=f3088978da8f9aea479ffc7f631370b968d2e855eeb172bea7f6c7a04262bb6d"\r\n' - '2,"http://localhost/fixtures.blob?sql=select+rowid,+data+from+binary_data&_nofacets=1&_blob_column=data&_blob_hash=b835b0483cedb86130b9a2c280880bf5fadc5318ddf8c18d0df5204d40df1724"\r\n' + '1,"http://localhost/fixtures.blob?sql=select+rowid,+data+from+binary_data&_nofacet=1&_blob_column=data&_blob_hash=f3088978da8f9aea479ffc7f631370b968d2e855eeb172bea7f6c7a04262bb6d"\r\n' + '2,"http://localhost/fixtures.blob?sql=select+rowid,+data+from+binary_data&_nofacet=1&_blob_column=data&_blob_hash=b835b0483cedb86130b9a2c280880bf5fadc5318ddf8c18d0df5204d40df1724"\r\n' "3,\r\n" ) From fd368d3b2c5a5d9c3e10a21638f6ea9a71471b52 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 1 Jun 2021 09:12:32 -0700 Subject: [PATCH 0005/1250] New _nocount=1 option, used to speed up CSVs - closes #1353 --- datasette/views/base.py | 15 +++++++++++---- datasette/views/table.py | 6 +++++- docs/json_api.rst | 3 +++ tests/test_api.py | 9 +++++++++ tests/test_csv.py | 6 ++++++ 5 files changed, 34 insertions(+), 5 deletions(-) diff --git a/datasette/views/base.py b/datasette/views/base.py index b8c581fc..26edfde5 100644 --- a/datasette/views/base.py +++ b/datasette/views/base.py @@ -263,12 +263,19 @@ class DataView(BaseView): async def as_csv(self, request, database, hash, **kwargs): stream = request.args.get("_stream") - # Do not calculate facets: - if not request.args.get("_nofacet"): + # Do not calculate facets or counts: + extra_parameters = [ + "{}=1".format(key) + for key in ("_nofacet", "_nocount") + if not request.args.get(key) + ] + if extra_parameters: if not request.query_string: - new_query_string = "_nofacet=1" + new_query_string = "&".join(extra_parameters) else: - new_query_string = request.query_string + "&_nofacet=1" + new_query_string = ( + request.query_string + "&" + "&".join(extra_parameters) + ) new_scope = dict( request.scope, query_string=new_query_string.encode("latin-1") ) diff --git a/datasette/views/table.py b/datasette/views/table.py index 7fbf670b..d47865f0 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -697,7 +697,11 @@ class TableView(RowTableShared): except KeyError: pass - if count_sql and filtered_table_rows_count is None: + if ( + count_sql + and filtered_table_rows_count is None + and not request.args.get("_nocount") + ): try: count_rows = list(await db.execute(count_sql, from_sql_params)) filtered_table_rows_count = count_rows[0][0] diff --git a/docs/json_api.rst b/docs/json_api.rst index f1c347b7..660fbc1c 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -386,6 +386,9 @@ Special table arguments ``?_nofacet=1`` Disable all facets and facet suggestions for this page, including any defined by :ref:`facets_metadata`. +``?_nocount=1`` + Disable the ``select count(*)`` query used on this page - a count of ``None`` will be returned instead. + ``?_trace=1`` Turns on tracing for this page: SQL queries executed during the request will be gathered and included in the response, either in a new ``"_traces"`` key diff --git a/tests/test_api.py b/tests/test_api.py index 5e639133..49b3bbe9 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1683,6 +1683,15 @@ def test_nofacet(app_client, nofacet): assert response.json["facet_results"] != {} +@pytest.mark.parametrize("nocount,expected_count", ((True, None), (False, 15))) +def test_nocount(app_client, nocount, expected_count): + path = "/fixtures/facetable.json" + if nocount: + path += "?_nocount=1" + response = app_client.get(path) + assert response.json["filtered_table_rows_count"] == expected_count + + def test_expand_labels(app_client): response = app_client.get( "/fixtures/facetable.json?_shape=object&_labels=1&_size=2" diff --git a/tests/test_csv.py b/tests/test_csv.py index 40549fd8..02fe5766 100644 --- a/tests/test_csv.py +++ b/tests/test_csv.py @@ -175,3 +175,9 @@ def test_table_csv_stream_does_not_calculate_facets(app_client): response = app_client.get("/fixtures/simple_primary_key.csv?_trace=1") soup = Soup(response.text, "html.parser") assert "select content, count(*) as n" not in soup.find("pre").text + + +def test_table_csv_stream_does_not_calculate_counts(app_client): + response = app_client.get("/fixtures/simple_primary_key.csv?_trace=1") + soup = Soup(response.text, "html.parser") + assert "select count(*)" not in soup.find("pre").text From ff45ed0ce5e1f151f24f089c6b78ab7f7a5cd0dc Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 1 Jun 2021 09:16:58 -0700 Subject: [PATCH 0006/1250] Updated --help output for latest Click, closes #1354 --- docs/datasette-package-help.txt | 4 +--- docs/datasette-publish-cloudrun-help.txt | 2 -- docs/datasette-publish-heroku-help.txt | 3 --- docs/datasette-serve-help.txt | 8 +------- 4 files changed, 2 insertions(+), 15 deletions(-) diff --git a/docs/datasette-package-help.txt b/docs/datasette-package-help.txt index 5f5ce070..7cfac1b1 100644 --- a/docs/datasette-package-help.txt +++ b/docs/datasette-package-help.txt @@ -7,7 +7,6 @@ Usage: datasette package [OPTIONS] FILES... Options: -t, --tag TEXT Name for the resulting Docker container, can optionally use name:tag format - -m, --metadata FILENAME Path to JSON/YAML file containing metadata to publish --extra-options TEXT Extra options to pass to datasette serve --branch TEXT Install datasette from a GitHub branch e.g. main @@ -19,8 +18,7 @@ Options: --version-note TEXT Additional note to show on /-/versions --secret TEXT Secret used for signing secure values, such as signed cookies - - -p, --port INTEGER RANGE Port to run the server on, defaults to 8001 + -p, --port INTEGER RANGE Port to run the server on, defaults to 8001 [1<=x<=65535] --title TEXT Title for metadata --license TEXT License label for metadata --license_url TEXT License URL for metadata diff --git a/docs/datasette-publish-cloudrun-help.txt b/docs/datasette-publish-cloudrun-help.txt index c706d921..3d05efb6 100644 --- a/docs/datasette-publish-cloudrun-help.txt +++ b/docs/datasette-publish-cloudrun-help.txt @@ -13,11 +13,9 @@ Options: --plugin-secret ... Secrets to pass to plugins, e.g. --plugin-secret datasette-auth-github client_id xxx - --version-note TEXT Additional note to show on /-/versions --secret TEXT Secret used for signing secure values, such as signed cookies - --title TEXT Title for metadata --license TEXT License label for metadata --license_url TEXT License URL for metadata diff --git a/docs/datasette-publish-heroku-help.txt b/docs/datasette-publish-heroku-help.txt index c4b852de..9d633e95 100644 --- a/docs/datasette-publish-heroku-help.txt +++ b/docs/datasette-publish-heroku-help.txt @@ -13,11 +13,9 @@ Options: --plugin-secret ... Secrets to pass to plugins, e.g. --plugin-secret datasette-auth-github client_id xxx - --version-note TEXT Additional note to show on /-/versions --secret TEXT Secret used for signing secure values, such as signed cookies - --title TEXT Title for metadata --license TEXT License label for metadata --license_url TEXT License URL for metadata @@ -28,5 +26,4 @@ Options: -n, --name TEXT Application name to use when deploying --tar TEXT --tar option to pass to Heroku, e.g. --tar=/usr/local/bin/gtar - --help Show this message and exit. diff --git a/docs/datasette-serve-help.txt b/docs/datasette-serve-help.txt index 8f770afb..db51dd80 100644 --- a/docs/datasette-serve-help.txt +++ b/docs/datasette-serve-help.txt @@ -10,13 +10,10 @@ Options: connections from the local machine will be allowed. Use 0.0.0.0 to listen to all IPs and allow access from other machines. - -p, --port INTEGER RANGE Port for server, defaults to 8001. Use -p 0 to automatically - assign an available port. - + assign an available port. [0<=x<=65535] --reload Automatically reload if code or metadata change detected - useful for development - --cors Enable CORS by serving Access-Control-Allow-Origin: * --load-extension TEXT Path to a SQLite extension to load --inspect-file TEXT Path to JSON file created using "datasette inspect" @@ -27,15 +24,12 @@ Options: --memory Make /_memory database available --config CONFIG Deprecated: set config option using configname:value. Use --setting instead. - --setting SETTING... Setting, see docs.datasette.io/en/stable/config.html --secret TEXT Secret used for signing secure values, such as signed cookies - --root Output URL that sets a cookie authenticating the root user --get TEXT Run an HTTP GET request against this path, print results and exit - --version-note TEXT Additional note to show on /-/versions --help-config Show available config options --pdb Launch debugger on any errors From a18e8641bc33e51b265855bc6e8a1939597b3a76 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 1 Jun 2021 15:35:33 -0700 Subject: [PATCH 0007/1250] Don't reflect nofacet=1 and nocount=1 in BLOB URLs, refs #1353 --- datasette/views/base.py | 5 ++++- tests/test_csv.py | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/datasette/views/base.py b/datasette/views/base.py index 26edfde5..e2583034 100644 --- a/datasette/views/base.py +++ b/datasette/views/base.py @@ -369,7 +369,7 @@ class DataView(BaseView): ) else: # Otherwise generate URL for this query - cell = self.ds.absolute_url( + url = self.ds.absolute_url( request, path_with_format( request=request, @@ -383,6 +383,9 @@ class DataView(BaseView): replace_format="csv", ), ) + cell = url.replace("&_nocount=1", "").replace( + "&_nofacet=1", "" + ) new_row.append(cell) row = new_row if not expanded_columns: diff --git a/tests/test_csv.py b/tests/test_csv.py index 02fe5766..01f739e2 100644 --- a/tests/test_csv.py +++ b/tests/test_csv.py @@ -105,8 +105,8 @@ def test_custom_sql_csv_blob_columns(app_client): assert "text/plain; charset=utf-8" == response.headers["content-type"] assert response.text == ( "rowid,data\r\n" - '1,"http://localhost/fixtures.blob?sql=select+rowid,+data+from+binary_data&_nofacet=1&_blob_column=data&_blob_hash=f3088978da8f9aea479ffc7f631370b968d2e855eeb172bea7f6c7a04262bb6d"\r\n' - '2,"http://localhost/fixtures.blob?sql=select+rowid,+data+from+binary_data&_nofacet=1&_blob_column=data&_blob_hash=b835b0483cedb86130b9a2c280880bf5fadc5318ddf8c18d0df5204d40df1724"\r\n' + '1,"http://localhost/fixtures.blob?sql=select+rowid,+data+from+binary_data&_blob_column=data&_blob_hash=f3088978da8f9aea479ffc7f631370b968d2e855eeb172bea7f6c7a04262bb6d"\r\n' + '2,"http://localhost/fixtures.blob?sql=select+rowid,+data+from+binary_data&_blob_column=data&_blob_hash=b835b0483cedb86130b9a2c280880bf5fadc5318ddf8c18d0df5204d40df1724"\r\n' "3,\r\n" ) From 0539bf0816b58c7f0ba769331f1509656bff3619 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 1 Jun 2021 19:53:00 -0700 Subject: [PATCH 0008/1250] Don't execute facets/counts for _shape=array or object, closes #263 --- datasette/views/table.py | 17 ++++++++++------- tests/test_api.py | 5 +++++ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/datasette/views/table.py b/datasette/views/table.py index d47865f0..b51d5e5e 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -379,6 +379,13 @@ class TableView(RowTableShared): if is_view: order_by = "" + nocount = request.args.get("_nocount") + nofacet = request.args.get("_nofacet") + + if request.args.get("_shape") in ("array", "object"): + nocount = True + nofacet = True + # Ensure we don't drop anything with an empty value e.g. ?name__exact= args = MultiParams( urllib.parse.parse_qs(request.query_string, keep_blank_values=True) @@ -697,11 +704,7 @@ class TableView(RowTableShared): except KeyError: pass - if ( - count_sql - and filtered_table_rows_count is None - and not request.args.get("_nocount") - ): + if count_sql and filtered_table_rows_count is None and not nocount: try: count_rows = list(await db.execute(count_sql, from_sql_params)) filtered_table_rows_count = count_rows[0][0] @@ -735,7 +738,7 @@ class TableView(RowTableShared): ) ) - if not request.args.get("_nofacet"): + if not nofacet: for facet in facet_instances: ( instance_facet_results, @@ -833,7 +836,7 @@ class TableView(RowTableShared): self.ds.setting("suggest_facets") and self.ds.setting("allow_facet") and not _next - and not request.args.get("_nofacet") + and not nofacet ): for facet in facet_instances: suggested_facets.extend(await facet.suggest()) diff --git a/tests/test_api.py b/tests/test_api.py index 49b3bbe9..078aad35 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1692,6 +1692,11 @@ def test_nocount(app_client, nocount, expected_count): assert response.json["filtered_table_rows_count"] == expected_count +def test_nocount_nofacet_if_shape_is_object(app_client): + response = app_client.get("/fixtures/facetable.json?_trace=1&_shape=object") + assert "count(*)" not in response.text + + def test_expand_labels(app_client): response = app_client.get( "/fixtures/facetable.json?_shape=object&_labels=1&_size=2" From 03b35d70e281ea48bd9b8058738ed87b13cea2de Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 1 Jun 2021 19:56:44 -0700 Subject: [PATCH 0009/1250] Bump black from 21.5b1 to 21.5b2 (#1352) Bumps [black](https://github.com/psf/black) from 21.5b1 to 21.5b2. - [Release notes](https://github.com/psf/black/releases) - [Changelog](https://github.com/psf/black/blob/main/CHANGES.md) - [Commits](https://github.com/psf/black/commits) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 60a94a5e..e66fefc3 100644 --- a/setup.py +++ b/setup.py @@ -73,7 +73,7 @@ setup( "pytest-xdist>=2.2.1,<2.3", "pytest-asyncio>=0.10,<0.16", "beautifulsoup4>=4.8.1,<4.10.0", - "black==21.5b1", + "black==21.5b2", "pytest-timeout>=1.4.2,<1.5", "trustme>=0.7,<0.8", ], From 807de378d08752a0f05bb1b980a0a62620a70520 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 1 Jun 2021 20:03:07 -0700 Subject: [PATCH 0010/1250] /-/databases and homepage maintain connection order, closes #1216 --- datasette/app.py | 2 +- tests/fixtures.py | 3 ++- tests/test_api.py | 2 +- tests/test_html.py | 6 +++--- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 957ced7c..018a8d5b 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -646,7 +646,7 @@ class Datasette: "is_memory": d.is_memory, "hash": d.hash, } - for name, d in sorted(self.databases.items(), key=lambda p: p[1].name) + for name, d in self.databases.items() if name != "_internal" ] diff --git a/tests/fixtures.py b/tests/fixtures.py index 5730c1bf..2690052a 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -126,7 +126,8 @@ def make_app_client( for extra_filename, extra_sql in extra_databases.items(): extra_filepath = os.path.join(tmpdir, extra_filename) sqlite3.connect(extra_filepath).executescript(extra_sql) - files.append(extra_filepath) + # Insert at start to help test /-/databases ordering: + files.insert(0, extra_filepath) os.chdir(os.path.dirname(filepath)) config = config or {} for key, value in { diff --git a/tests/test_api.py b/tests/test_api.py index 078aad35..3b789bb7 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1918,7 +1918,7 @@ def test_database_with_space_in_name(app_client_two_attached_databases, path): def test_common_prefix_database_names(app_client_conflicting_database_names): # https://github.com/simonw/datasette/issues/597 - assert ["fixtures", "foo", "foo-bar"] == [ + assert ["foo-bar", "foo", "fixtures"] == [ d["name"] for d in app_client_conflicting_database_names.get("/-/databases.json").json ] diff --git a/tests/test_html.py b/tests/test_html.py index 4f2cc8ad..fd60cdc9 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -29,11 +29,11 @@ def test_homepage(app_client_two_attached_databases): ) # Should be two attached databases assert [ - {"href": "/fixtures", "text": "fixtures"}, {"href": r"/extra%20database", "text": "extra database"}, + {"href": "/fixtures", "text": "fixtures"}, ] == [{"href": a["href"], "text": a.text.strip()} for a in soup.select("h2 a")] - # The first attached database should show count text and attached tables - h2 = soup.select("h2")[1] + # Database should show count text and attached tables + h2 = soup.select("h2")[0] assert "extra database" == h2.text.strip() counts_p, links_p = h2.find_all_next("p")[:2] assert ( From 0f1e47287cf2185e140bd87a03c985c2a7afb450 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 1 Jun 2021 20:27:04 -0700 Subject: [PATCH 0011/1250] Fixed bug with detect_fts for table with single quote in name, closes #1257 --- datasette/utils/__init__.py | 2 +- tests/test_utils.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py index dd47771f..73122976 100644 --- a/datasette/utils/__init__.py +++ b/datasette/utils/__init__.py @@ -547,7 +547,7 @@ def detect_fts_sql(table): ) ) """.format( - table=table + table=table.replace("'", "''") ) diff --git a/tests/test_utils.py b/tests/test_utils.py index ecef6f7a..be3daf2e 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -200,6 +200,22 @@ def test_detect_fts(open_quote, close_quote): assert "Street_Tree_List_fts" == utils.detect_fts(conn, "Street_Tree_List") +@pytest.mark.parametrize("table", ("regular", "has'single quote")) +def test_detect_fts_different_table_names(table): + sql = """ + CREATE TABLE [{table}] ( + "TreeID" INTEGER, + "qSpecies" TEXT + ); + CREATE VIRTUAL TABLE [{table}_fts] USING FTS4 ("qSpecies", content="{table}"); + """.format( + table=table + ) + conn = utils.sqlite3.connect(":memory:") + conn.executescript(sql) + assert "{table}_fts".format(table=table) == utils.detect_fts(conn, table) + + @pytest.mark.parametrize( "url,expected", [ From 9552414e1f968c6fc704031cec349c05e6bc2371 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 1 Jun 2021 23:46:20 -0400 Subject: [PATCH 0012/1250] Re-display user's query with an error message if an error occurs (#1346) * Ignore _shape when returning errors --- datasette/renderer.py | 4 ++++ datasette/templates/query.html | 5 ++++- datasette/views/base.py | 21 +++++++++++++++++---- datasette/views/database.py | 25 ++++++++++++++++++------- tests/test_canned_queries.py | 2 +- 5 files changed, 44 insertions(+), 13 deletions(-) diff --git a/datasette/renderer.py b/datasette/renderer.py index 66ac169b..45089498 100644 --- a/datasette/renderer.py +++ b/datasette/renderer.py @@ -29,6 +29,7 @@ def convert_specific_columns_to_json(rows, columns, json_cols): def json_renderer(args, data, view_name): """Render a response as JSON""" status_code = 200 + # Handle the _json= parameter which may modify data["rows"] json_cols = [] if "_json" in args: @@ -44,6 +45,9 @@ def json_renderer(args, data, view_name): # Deal with the _shape option shape = args.get("_shape", "arrays") + # if there's an error, ignore the shape entirely + if data.get("error"): + shape = "arrays" next_url = data.get("next_url") diff --git a/datasette/templates/query.html b/datasette/templates/query.html index 9b3fff25..633e53b4 100644 --- a/datasette/templates/query.html +++ b/datasette/templates/query.html @@ -33,7 +33,10 @@ {% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
-

Custom SQL query{% if display_rows %} returning {% if truncated %}more than {% endif %}{{ "{:,}".format(display_rows|length) }} row{% if display_rows|length == 1 %}{% else %}s{% endif %}{% endif %} {% if hide_sql %}(show){% else %}(hide){% endif %}

+

Custom SQL query{% if display_rows %} returning {% if truncated %}more than {% endif %}{{ "{:,}".format(display_rows|length) }} row{% if display_rows|length == 1 %}{% else %}s{% endif %}{% endif %}{% if not query_error %} {% if hide_sql %}(show){% else %}(hide){% endif %}{% endif %}

+ {% if query_error %} +

{{ query_error }}

+ {% endif %} {% if not hide_sql %} {% if editable and allow_execute_sql %}

diff --git a/datasette/views/base.py b/datasette/views/base.py index e2583034..94f54787 100644 --- a/datasette/views/base.py +++ b/datasette/views/base.py @@ -294,6 +294,8 @@ class DataView(BaseView): ) if isinstance(response_or_template_contexts, Response): return response_or_template_contexts + elif len(response_or_template_contexts) == 4: + data, _, _, _ = response_or_template_contexts else: data, _, _ = response_or_template_contexts except (sqlite3.OperationalError, InvalidSql) as e: @@ -467,7 +469,7 @@ class DataView(BaseView): extra_template_data = {} start = time.perf_counter() - status_code = 200 + status_code = None templates = [] try: response_or_template_contexts = await self.data( @@ -475,7 +477,14 @@ class DataView(BaseView): ) if isinstance(response_or_template_contexts, Response): return response_or_template_contexts - + # If it has four items, it includes an HTTP status code + if len(response_or_template_contexts) == 4: + ( + data, + extra_template_data, + templates, + status_code, + ) = response_or_template_contexts else: data, extra_template_data, templates = response_or_template_contexts except QueryInterrupted: @@ -542,12 +551,15 @@ class DataView(BaseView): if isinstance(result, dict): r = Response( body=result.get("body"), - status=result.get("status_code", 200), + status=result.get("status_code", status_code or 200), content_type=result.get("content_type", "text/plain"), headers=result.get("headers"), ) elif isinstance(result, Response): r = result + if status_code is not None: + # Over-ride the status code + r.status = status_code else: assert False, f"{result} should be dict or Response" else: @@ -607,7 +619,8 @@ class DataView(BaseView): if "metadata" not in context: context["metadata"] = self.ds.metadata r = await self.render(templates, request=request, context=context) - r.status = status_code + if status_code is not None: + r.status = status_code ttl = request.args.get("_ttl", None) if ttl is None or not ttl.isdigit(): diff --git a/datasette/views/database.py b/datasette/views/database.py index 96b2ca91..58168ed7 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -14,6 +14,7 @@ from datasette.utils import ( path_with_added_args, path_with_format, path_with_removed_args, + sqlite3, InvalidSql, ) from datasette.utils.asgi import AsgiFileDownload, Response, Forbidden @@ -239,6 +240,8 @@ class QueryView(DataView): templates = [f"query-{to_css_class(database)}.html", "query.html"] + query_error = None + # Execute query - as write or as read if write: if request.method == "POST": @@ -320,10 +323,15 @@ class QueryView(DataView): params_for_query = MagicParameters(params, request, self.ds) else: params_for_query = params - results = await self.ds.execute( - database, sql, params_for_query, truncate=True, **extra_args - ) - columns = [r[0] for r in results.description] + try: + results = await self.ds.execute( + database, sql, params_for_query, truncate=True, **extra_args + ) + columns = [r[0] for r in results.description] + except sqlite3.DatabaseError as e: + query_error = e + results = None + columns = [] if canned_query: templates.insert( @@ -337,7 +345,7 @@ class QueryView(DataView): async def extra_template(): display_rows = [] - for row in results.rows: + for row in results.rows if results else []: display_row = [] for column, value in zip(results.columns, row): display_value = value @@ -423,17 +431,20 @@ class QueryView(DataView): return ( { + "ok": not query_error, "database": database, "query_name": canned_query, - "rows": results.rows, - "truncated": results.truncated, + "rows": results.rows if results else [], + "truncated": results.truncated if results else False, "columns": columns, "query": {"sql": sql, "params": params}, + "error": str(query_error) if query_error else None, "private": private, "allow_execute_sql": allow_execute_sql, }, extra_template, templates, + 400 if query_error else 200, ) diff --git a/tests/test_canned_queries.py b/tests/test_canned_queries.py index 65f23cc7..4186a97c 100644 --- a/tests/test_canned_queries.py +++ b/tests/test_canned_queries.py @@ -352,5 +352,5 @@ def test_magic_parameters_cannot_be_used_in_arbitrary_queries(magic_parameters_c response = magic_parameters_client.get( "/data.json?sql=select+:_header_host&_shape=array" ) - assert 500 == response.status + assert 400 == response.status assert "You did not supply a value for binding 1." == response.json["error"] From ea5b2378007ef524f7a17989c8df54a76a001e49 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 1 Jun 2021 20:59:29 -0700 Subject: [PATCH 0013/1250] Show error message on bad query, closes #619 --- datasette/templates/query.html | 4 ++-- tests/test_html.py | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/datasette/templates/query.html b/datasette/templates/query.html index 633e53b4..8b6ad138 100644 --- a/datasette/templates/query.html +++ b/datasette/templates/query.html @@ -34,8 +34,8 @@

Custom SQL query{% if display_rows %} returning {% if truncated %}more than {% endif %}{{ "{:,}".format(display_rows|length) }} row{% if display_rows|length == 1 %}{% else %}s{% endif %}{% endif %}{% if not query_error %} {% if hide_sql %}(show){% else %}(hide){% endif %}{% endif %}

- {% if query_error %} -

{{ query_error }}

+ {% if error %} +

{{ error }}

{% endif %} {% if not hide_sql %} {% if editable and allow_execute_sql %} diff --git a/tests/test_html.py b/tests/test_html.py index fd60cdc9..5fca76c3 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -1402,6 +1402,16 @@ def test_zero_results(app_client, path): assert 1 == len(soup.select("p.zero-results")) +def test_query_error(app_client): + response = app_client.get("/fixtures?sql=select+*+from+notatable") + html = response.text + assert '

no such table: notatable

' in html + assert ( + '' + in html + ) + + def test_config_template_debug_on(): with make_app_client(config={"template_debug": True}) as client: response = client.get("/fixtures/facetable?_context=1") From f40d1b99d67b0da4f3aff5b3483f4e09db7e8e6b Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 1 Jun 2021 21:09:10 -0700 Subject: [PATCH 0014/1250] Don't show '0 results' on error page, refs #619 --- datasette/templates/query.html | 2 +- tests/test_html.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/datasette/templates/query.html b/datasette/templates/query.html index 8b6ad138..b6c74883 100644 --- a/datasette/templates/query.html +++ b/datasette/templates/query.html @@ -80,7 +80,7 @@ {% else %} - {% if not canned_write %} + {% if not canned_write and not error %}

0 results

{% endif %} {% endif %} diff --git a/tests/test_html.py b/tests/test_html.py index 5fca76c3..90373c28 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -1410,6 +1410,7 @@ def test_query_error(app_client): '' in html ) + assert "0 results" not in html def test_config_template_debug_on(): From 0f41db1ba8a8a49a4adc1046a25ccf32790e863f Mon Sep 17 00:00:00 2001 From: Guy Freeman Date: Wed, 2 Jun 2021 07:25:27 +0300 Subject: [PATCH 0015/1250] Avoid error sorting by relationships if related tables are not allowed Refs #1306 --- datasette/views/index.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/datasette/views/index.py b/datasette/views/index.py index b6b8cbe5..8ac117a6 100644 --- a/datasette/views/index.py +++ b/datasette/views/index.py @@ -78,8 +78,9 @@ class IndexView(BaseView): # We will be sorting by number of relationships, so populate that field all_foreign_keys = await db.get_all_foreign_keys() for table, foreign_keys in all_foreign_keys.items(): - count = len(foreign_keys["incoming"] + foreign_keys["outgoing"]) - tables[table]["num_relationships_for_sorting"] = count + if table in tables.keys(): + count = len(foreign_keys["incoming"] + foreign_keys["outgoing"]) + tables[table]["num_relationships_for_sorting"] = count hidden_tables = [t for t in tables.values() if t["hidden"]] visible_tables = [t for t in tables.values() if not t["hidden"]] From 80d8b0eb415faf5caadd7cc7036407e6ee55bd44 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 1 Jun 2021 21:26:25 -0700 Subject: [PATCH 0016/1250] Test demonstrating fixed #1305, refs #1306 --- tests/test_html.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/test_html.py b/tests/test_html.py index 90373c28..8bc53339 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -1685,3 +1685,22 @@ def test_facet_more_links( assert facet_truncated.find("a")["href"] == expected_ellipses_url else: assert facet_truncated.find("a") is None + + +def test_unavailable_table_does_not_break_sort_relationships(): + # https://github.com/simonw/datasette/issues/1305 + with make_app_client( + metadata={ + "databases": { + "fixtures": { + "tables": { + "foreign_key_references": { + "allow": False + } + } + } + } + } + ) as client: + response = client.get("/?_sort=relationships") + assert response.status == 200 From d5d387abfe68ea546c53698ebb2b8eeeb4d32c3f Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 1 Jun 2021 21:30:44 -0700 Subject: [PATCH 0017/1250] Applied Black, refs #1305 --- tests/test_html.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/tests/test_html.py b/tests/test_html.py index 8bc53339..31bb6667 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -1692,13 +1692,7 @@ def test_unavailable_table_does_not_break_sort_relationships(): with make_app_client( metadata={ "databases": { - "fixtures": { - "tables": { - "foreign_key_references": { - "allow": False - } - } - } + "fixtures": {"tables": {"foreign_key_references": {"allow": False}}} } } ) as client: From f78ebdc04537a6102316d6dbbf6c887565806078 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 2 Jun 2021 10:00:30 -0700 Subject: [PATCH 0018/1250] Better "uploading and publishing your own CSV data" link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4f3c9a94..5682f59e 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Datasette is a tool for exploring and publishing data. It helps people take data Datasette is aimed at data journalists, museum curators, archivists, local governments and anyone else who has data that they wish to share with the world. -[Explore a demo](https://global-power-plants.datasettes.com/global-power-plants/global-power-plants), watch [a video about the project](https://simonwillison.net/2021/Feb/7/video/) or try it out by [uploading and publishing your own CSV data](https://simonwillison.net/2019/Apr/23/datasette-glitch/). +[Explore a demo](https://global-power-plants.datasettes.com/global-power-plants/global-power-plants), watch [a video about the project](https://simonwillison.net/2021/Feb/7/video/) or try it out by [uploading and publishing your own CSV data](https://docs.datasette.io/en/stable/getting_started.html#try-datasette-without-installing-anything-using-glitch). * [datasette.io](https://datasette.io/) is the official project website * Latest [Datasette News](https://datasette.io/news) From 6e9b07be92905011211d8df7a872fb7c1f2737b2 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 2 Jun 2021 21:45:03 -0700 Subject: [PATCH 0019/1250] More inclusive language --- datasette/cli.py | 2 +- datasette/facets.py | 2 +- datasette/views/base.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/datasette/cli.py b/datasette/cli.py index 71bbc353..12ee92c3 100644 --- a/datasette/cli.py +++ b/datasette/cli.py @@ -540,7 +540,7 @@ def serve( # Run the "startup" plugin hooks asyncio.get_event_loop().run_until_complete(ds.invoke_startup()) - # Run async sanity checks - but only if we're not under pytest + # Run async soundness checks - but only if we're not under pytest asyncio.get_event_loop().run_until_complete(check_databases(ds)) if get: diff --git a/datasette/facets.py b/datasette/facets.py index 9d95d0f3..250734fd 100644 --- a/datasette/facets.py +++ b/datasette/facets.py @@ -304,7 +304,7 @@ class ArrayFacet(Facet): ) types = tuple(r[0] for r in results.rows) if types in (("array",), ("array", None)): - # Now sanity check that first 100 arrays contain only strings + # Now check that first 100 arrays contain only strings first_100 = [ v[0] for v in await self.ds.execute( diff --git a/datasette/views/base.py b/datasette/views/base.py index 94f54787..1a03b97f 100644 --- a/datasette/views/base.py +++ b/datasette/views/base.py @@ -281,7 +281,7 @@ class DataView(BaseView): ) request.scope = new_scope if stream: - # Some quick sanity checks + # Some quick soundness checks if not self.ds.setting("allow_csv_stream"): raise BadRequest("CSV streaming is disabled") if request.args.get("_next"): From a63412152518581c6a3d4e142b937e27dabdbfdb Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 5 Jun 2021 11:59:54 -0700 Subject: [PATCH 0020/1250] Make custom pages compatible with base_url setting Closes #1238 - base_url no longer causes custom page routing to fail - new route_path key in request.scope storing the path that was used for routing with the base_url prefix stripped - TestClient used by tests now avoids accidentally double processing of the base_url prefix --- datasette/app.py | 17 ++++++++++------- datasette/utils/testing.py | 1 + tests/test_custom_pages.py | 16 +++++++++++++++- tests/test_html.py | 1 + 4 files changed, 27 insertions(+), 8 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 018a8d5b..c0e8ad01 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -1089,6 +1089,7 @@ class DatasetteRouter: base_url = self.ds.setting("base_url") if base_url != "/" and path.startswith(base_url): path = "/" + path[len(base_url) :] + scope = dict(scope, route_path=path) request = Request(scope, receive) # Populate request_messages if ds_messages cookie is present try: @@ -1143,9 +1144,8 @@ class DatasetteRouter: await asgi_send_redirect(send, path.decode("latin1")) else: # Is there a pages/* template matching this path? - template_path = ( - os.path.join("pages", *request.scope["path"].split("/")) + ".html" - ) + route_path = request.scope.get("route_path", request.scope["path"]) + template_path = os.path.join("pages", *route_path.split("/")) + ".html" try: template = self.ds.jinja_env.select_template([template_path]) except TemplateNotFound: @@ -1153,7 +1153,7 @@ class DatasetteRouter: if template is None: # Try for a pages/blah/{name}.html template match for regex, wildcard_template in self.page_routes: - match = regex.match(request.scope["path"]) + match = regex.match(route_path) if match is not None: context.update(match.groupdict()) template = wildcard_template @@ -1356,8 +1356,8 @@ class DatasetteClient: self.ds = ds self.app = ds.app() - def _fix(self, path): - if not isinstance(path, PrefixedUrlString): + def _fix(self, path, avoid_path_rewrites=False): + if not isinstance(path, PrefixedUrlString) and not avoid_path_rewrites: path = self.ds.urls.path(path) if path.startswith("/"): path = f"http://localhost{path}" @@ -1392,5 +1392,8 @@ class DatasetteClient: return await client.delete(self._fix(path), **kwargs) async def request(self, method, path, **kwargs): + avoid_path_rewrites = kwargs.pop("avoid_path_rewrites", None) async with httpx.AsyncClient(app=self.app) as client: - return await client.request(method, self._fix(path), **kwargs) + return await client.request( + method, self._fix(path, avoid_path_rewrites), **kwargs + ) diff --git a/datasette/utils/testing.py b/datasette/utils/testing.py index 57b19ea5..a169a83d 100644 --- a/datasette/utils/testing.py +++ b/datasette/utils/testing.py @@ -140,6 +140,7 @@ class TestClient: method, path, allow_redirects=allow_redirects, + avoid_path_rewrites=True, cookies=cookies, headers=headers, content=post_body, diff --git a/tests/test_custom_pages.py b/tests/test_custom_pages.py index 6a231920..5a71f56d 100644 --- a/tests/test_custom_pages.py +++ b/tests/test_custom_pages.py @@ -2,11 +2,19 @@ import pathlib import pytest from .fixtures import make_app_client +TEST_TEMPLATE_DIRS = str(pathlib.Path(__file__).parent / "test_templates") + @pytest.fixture(scope="session") def custom_pages_client(): + with make_app_client(template_dir=TEST_TEMPLATE_DIRS) as client: + yield client + + +@pytest.fixture(scope="session") +def custom_pages_client_with_base_url(): with make_app_client( - template_dir=str(pathlib.Path(__file__).parent / "test_templates") + template_dir=TEST_TEMPLATE_DIRS, config={"base_url": "/prefix/"} ) as client: yield client @@ -23,6 +31,12 @@ def test_request_is_available(custom_pages_client): assert "path:/request" == response.text +def test_custom_pages_with_base_url(custom_pages_client_with_base_url): + response = custom_pages_client_with_base_url.get("/prefix/request") + assert 200 == response.status + assert "path:/prefix/request" == response.text + + def test_custom_pages_nested(custom_pages_client): response = custom_pages_client.get("/nested/nest") assert 200 == response.status diff --git a/tests/test_html.py b/tests/test_html.py index 31bb6667..f1d4bd70 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -1523,6 +1523,7 @@ def test_base_url_config(app_client_base_url_prefix, path): and href not in { "https://datasette.io/", + "https://github.com/simonw/datasette", "https://github.com/simonw/datasette/blob/main/LICENSE", "https://github.com/simonw/datasette/blob/main/tests/fixtures.py", "/login-as-root", # Only used for the latest.datasette.io demo From 368aa5f1b16ca35f82d90ff747023b9a2bfa27c1 Mon Sep 17 00:00:00 2001 From: louispotok Date: Sun, 6 Jun 2021 02:48:51 +0700 Subject: [PATCH 0021/1250] Update docs: explain allow_download setting (#1291) * Update docs: explain allow_download setting This fixes one possible source of confusion seen in #502 and clarifies when database downloads will be shown and allowed. --- docs/settings.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/settings.rst b/docs/settings.rst index af8e4406..db17a45e 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -163,7 +163,7 @@ Should Datasette calculate suggested facets? On by default, turn this off like s allow_download ~~~~~~~~~~~~~~ -Should users be able to download the original SQLite database using a link on the database index page? This is turned on by default - to disable database downloads, use the following:: +Should users be able to download the original SQLite database using a link on the database index page? This is turned on by default. However, databases can only be downloaded if they are served in immutable mode and not in-memory. If downloading is unavailable for either of these reasons, the download link is hidden even if ``allow_download`` is on. To disable database downloads, use the following:: datasette mydatabase.db --setting allow_download off From ff29dd55fafd7c3d27bd30f40945847aa4278309 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 5 Jun 2021 13:15:58 -0700 Subject: [PATCH 0022/1250] ?_trace=1 now depends on trace_debug setting, closes #1359 --- .github/workflows/deploy-latest.yml | 2 +- datasette/app.py | 20 +++++++++++++------- docs/json_api.rst | 18 ++++++++++-------- docs/settings.rst | 16 ++++++++++++++++ tests/fixtures.py | 6 ++++++ tests/test_api.py | 20 ++++++++++++++++---- tests/test_csv.py | 13 +++++++------ 7 files changed, 69 insertions(+), 26 deletions(-) diff --git a/.github/workflows/deploy-latest.yml b/.github/workflows/deploy-latest.yml index 43e46fb4..d9f23f7d 100644 --- a/.github/workflows/deploy-latest.yml +++ b/.github/workflows/deploy-latest.yml @@ -53,7 +53,7 @@ jobs: --plugins-dir=plugins \ --branch=$GITHUB_SHA \ --version-note=$GITHUB_SHA \ - --extra-options="--setting template_debug 1 --crossdb" \ + --extra-options="--setting template_debug 1 --setting trace_debug 1 --crossdb" \ --install=pysqlite3-binary \ --service=datasette-latest # Deploy docs.db to a different service diff --git a/datasette/app.py b/datasette/app.py index c0e8ad01..d85517e6 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -162,6 +162,11 @@ SETTINGS = ( False, "Allow display of template debug information with ?_context=1", ), + Setting( + "trace_debug", + False, + "Allow display of SQL trace debug information with ?_trace=1", + ), Setting("base_url", "/", "Datasette URLs should use this base path"), ) @@ -1041,14 +1046,15 @@ class Datasette: if not database.is_mutable: await database.table_counts(limit=60 * 60 * 1000) + asgi = asgi_csrf.asgi_csrf( + DatasetteRouter(self, routes), + signing_secret=self._secret, + cookie_name="ds_csrftoken", + ) + if self.setting("trace_debug"): + asgi = AsgiTracer(asgi) asgi = AsgiLifespan( - AsgiTracer( - asgi_csrf.asgi_csrf( - DatasetteRouter(self, routes), - signing_secret=self._secret, - cookie_name="ds_csrftoken", - ) - ), + asgi, on_startup=setup_db, ) for wrapper in pm.hook.asgi_wrapper(datasette=self): diff --git a/docs/json_api.rst b/docs/json_api.rst index 660fbc1c..09cac1f9 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -206,6 +206,16 @@ query string arguments: For how many seconds should this response be cached by HTTP proxies? Use ``?_ttl=0`` to disable HTTP caching entirely for this request. +``?_trace=1`` + Turns on tracing for this page: SQL queries executed during the request will + be gathered and included in the response, either in a new ``"_traces"`` key + for JSON responses or at the bottom of the page if the response is in HTML. + + The structure of the data returned here should be considered highly unstable + and very likely to change. + + Only available if the :ref:`setting_trace_debug` setting is enabled. + .. _table_arguments: Table arguments @@ -389,14 +399,6 @@ Special table arguments ``?_nocount=1`` Disable the ``select count(*)`` query used on this page - a count of ``None`` will be returned instead. -``?_trace=1`` - Turns on tracing for this page: SQL queries executed during the request will - be gathered and included in the response, either in a new ``"_traces"`` key - for JSON responses or at the bottom of the page if the response is in HTML. - - The structure of the data returned here should be considered highly unstable - and very likely to change. - .. _expand_foreign_keys: Expanding foreign key references diff --git a/docs/settings.rst b/docs/settings.rst index db17a45e..c246d33a 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -286,6 +286,22 @@ Some examples: * https://latest.datasette.io/fixtures?_context=1 * https://latest.datasette.io/fixtures/roadside_attractions?_context=1 +.. _setting_trace_debug: + +trace_debug +~~~~~~~~~~~ + +This setting enables appending ``?_trace=1`` to any page in order to see the SQL queries and other trace information that was used to generate that page. + +Enable it like this:: + + datasette mydatabase.db --setting trace_debug 1 + +Some examples: + +* https://latest.datasette.io/?_trace=1 +* https://latest.datasette.io/fixtures/roadside_attractions?_trace=1 + .. _setting_base_url: base_url diff --git a/tests/fixtures.py b/tests/fixtures.py index 2690052a..cdd2e987 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -214,6 +214,12 @@ def app_client_with_hash(): yield client +@pytest.fixture(scope="session") +def app_client_with_trace(): + with make_app_client(config={"trace_debug": True}, is_immutable=True) as client: + yield client + + @pytest.fixture(scope="session") def app_client_shorter_time_limit(): with make_app_client(20) as client: diff --git a/tests/test_api.py b/tests/test_api.py index 3b789bb7..e5e609d6 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -15,6 +15,7 @@ from .fixtures import ( # noqa app_client_conflicting_database_names, app_client_with_cors, app_client_with_dot, + app_client_with_trace, app_client_immutable_and_inspect_file, generate_compound_rows, generate_sortable_rows, @@ -1422,6 +1423,7 @@ def test_settings_json(app_client): "force_https_urls": False, "hash_urls": False, "template_debug": False, + "trace_debug": False, "base_url": "/", } == response.json @@ -1692,8 +1694,10 @@ def test_nocount(app_client, nocount, expected_count): assert response.json["filtered_table_rows_count"] == expected_count -def test_nocount_nofacet_if_shape_is_object(app_client): - response = app_client.get("/fixtures/facetable.json?_trace=1&_shape=object") +def test_nocount_nofacet_if_shape_is_object(app_client_with_trace): + response = app_client_with_trace.get( + "/fixtures/facetable.json?_trace=1&_shape=object" + ) assert "count(*)" not in response.text @@ -1863,9 +1867,17 @@ def test_custom_query_with_unicode_characters(app_client): assert [{"id": 1, "name": "San Francisco"}] == response.json -def test_trace(app_client): - response = app_client.get("/fixtures/simple_primary_key.json?_trace=1") +@pytest.mark.parametrize("trace_debug", (True, False)) +def test_trace(trace_debug): + with make_app_client(config={"trace_debug": trace_debug}) as client: + response = client.get("/fixtures/simple_primary_key.json?_trace=1") + assert response.status == 200 + data = response.json + if not trace_debug: + assert "_trace" not in data + return + assert "_trace" in data trace_info = data["_trace"] assert isinstance(trace_info["request_duration_ms"], float) diff --git a/tests/test_csv.py b/tests/test_csv.py index 01f739e2..3debf320 100644 --- a/tests/test_csv.py +++ b/tests/test_csv.py @@ -3,6 +3,7 @@ from .fixtures import ( # noqa app_client, app_client_csv_max_mb_one, app_client_with_cors, + app_client_with_trace, ) EXPECTED_TABLE_CSV = """id,content @@ -160,8 +161,8 @@ def test_table_csv_stream(app_client): assert 1002 == len([b for b in response.body.split(b"\r\n") if b]) -def test_csv_trace(app_client): - response = app_client.get("/fixtures/simple_primary_key.csv?_trace=1") +def test_csv_trace(app_client_with_trace): + response = app_client_with_trace.get("/fixtures/simple_primary_key.csv?_trace=1") assert response.headers["content-type"] == "text/html; charset=utf-8" soup = Soup(response.text, "html.parser") assert ( @@ -171,13 +172,13 @@ def test_csv_trace(app_client): assert "select id, content from simple_primary_key" in soup.find("pre").text -def test_table_csv_stream_does_not_calculate_facets(app_client): - response = app_client.get("/fixtures/simple_primary_key.csv?_trace=1") +def test_table_csv_stream_does_not_calculate_facets(app_client_with_trace): + response = app_client_with_trace.get("/fixtures/simple_primary_key.csv?_trace=1") soup = Soup(response.text, "html.parser") assert "select content, count(*) as n" not in soup.find("pre").text -def test_table_csv_stream_does_not_calculate_counts(app_client): - response = app_client.get("/fixtures/simple_primary_key.csv?_trace=1") +def test_table_csv_stream_does_not_calculate_counts(app_client_with_trace): + response = app_client_with_trace.get("/fixtures/simple_primary_key.csv?_trace=1") soup = Soup(response.text, "html.parser") assert "select count(*)" not in soup.find("pre").text From 8f311d6c1d9f73f4ec643009767749c17b5ca5dd Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 5 Jun 2021 14:49:16 -0700 Subject: [PATCH 0023/1250] Correctly escape output of ?_trace, refs #1360 --- datasette/tracer.py | 3 ++- tests/test_html.py | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/datasette/tracer.py b/datasette/tracer.py index 772f0405..62c3c90c 100644 --- a/datasette/tracer.py +++ b/datasette/tracer.py @@ -1,5 +1,6 @@ import asyncio from contextlib import contextmanager +from markupsafe import escape import time import json import traceback @@ -123,7 +124,7 @@ class AsgiTracer: except IndexError: content_type = "" if "text/html" in content_type and b"" in accumulated_body: - extra = json.dumps(trace_info, indent=2) + extra = escape(json.dumps(trace_info, indent=2)) extra_html = f"
{extra}
".encode("utf8") accumulated_body = accumulated_body.replace(b"", extra_html) elif "json" in content_type and accumulated_body.startswith(b"{"): diff --git a/tests/test_html.py b/tests/test_html.py index f1d4bd70..8714d254 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -1699,3 +1699,9 @@ def test_unavailable_table_does_not_break_sort_relationships(): ) as client: response = client.get("/?_sort=relationships") assert response.status == 200 + + +def test_trace_correctly_escaped(app_client): + response = app_client.get("/fixtures?sql=select+'

Hello'&_trace=1") + assert "select '

Hello" not in response.text + assert "select '<h1>Hello" in response.text From 58746d3c514004f504223a724e948469a0d4abb3 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 5 Jun 2021 15:06:52 -0700 Subject: [PATCH 0024/1250] Release 0.57 Refs #263, #615, #619, #1238, #1257, #1305, #1308, #1320, #1332, #1337, #1349, #1353, #1359, #1360 --- datasette/version.py | 2 +- docs/changelog.rst | 43 ++++++++++++++++++++++++++++++++++++------- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/datasette/version.py b/datasette/version.py index cc98e271..93af8b3b 100644 --- a/datasette/version.py +++ b/datasette/version.py @@ -1,2 +1,2 @@ -__version__ = "0.57a1" +__version__ = "0.57" __version_info__ = tuple(__version__.split(".")) diff --git a/docs/changelog.rst b/docs/changelog.rst index e00791f8..842ca839 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,16 +4,45 @@ Changelog ========= -.. _v0_57_a0: +.. _v0_57: -0.57a0 (2021-05-22) +0.57 (2021-06-05) +----------------- + +.. warning:: + This release fixes a `reflected cross-site scripting `__ security hole with the ``?_trace=1`` feature. You should upgrade to this version, or to Datasette 0.56.1, as soon as possible. (:issue:`1360`) + +In addition to the security fix, this release includes ``?_col=`` and ``?_nocol=`` options for controlling which columns are displayed for a table, ``?_facet_size=`` for increasing the number of facet results returned, re-display of your SQL query should an error occur and numerous bug fixes. + +New features +~~~~~~~~~~~~ + +- If an error occurs while executing a user-provided SQL query, that query is now re-displayed in an editable form along with the error message. (:issue:`619`) +- New ``?_col=`` and ``?_nocol=`` parameters to show and hide columns in a table, plus an interface for hiding and showing columns in the column cog menu. (:issue:`615`) +- A new ``?_facet_size=`` parameter for customizing the number of facet results returned on a table or view page. (:issue:`1332`) +- ``?_facet_size=max`` sets that to the maximum, which defaults to 1,000 and is controlled by the the :ref:`setting_max_returned_rows` setting. If facet results are truncated the … at the bottom of the facet list now links to this parameter. (:issue:`1337`) +- ``?_nofacet=1`` option to disable all facet calculations on a page, used as a performance optimization for CSV exports and ``?_shape=array/object``. (:issue:`1349`, :issue:`263`) +- ``?_nocount=1`` option to disable full query result counts. (:issue:`1353`) +- ``?_trace=1`` debugging option is now controlled by the new :ref:`setting_trace_debug` setting, which is turned off by default. (:issue:`1359`) + +Bug fixes and other improvements +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +- :ref:`custom_pages` now work correctly when combined with the :ref:`setting_base_url` setting. (:issue:`1238`) +- Fixed intermittent error displaying the index page when the user did not have permission to access one of the tables. Thanks, Guy Freeman. (:issue:`1305`) +- Columns with the name "Link" are no longer incorrectly displayed in bold. (:issue:`1308`) +- Fixed error caused by tables with a single quote in their names. (:issue:`1257`) +- Updated dependencies: ``pytest-asyncio``, ``Black``, ``jinja2``, ``aiofiles``, ``click``, and ``itsdangerous``. +- The official Datasette Docker image now supports ``apt-get install``. (:issue:`1320`) +- The Heroku runtime used by ``datasette publish heroku`` is now ``python-3.8.10``. + +.. _v0_56_1: + +0.56.1 (2021-06-05) ------------------- -Mainly dependency bumps, plus a new ``?_facet_size=`` argument. - -- Updated dependencies: pytest-asyncio, Black, jinja2, aiofiles, itsdangerous -- Fixed bug where columns called "Link" were incorrectly displayed in bold. (:issue:`1308`) -- New ``?_facet_size=`` argument for customizing the number of facet results returned on a page. (:issue:`1332`) +.. warning:: + This release fixes a `reflected cross-site scripting `__ security hole with the ``?_trace=1`` feature. You should upgrade to this version, or to Datasette 0.57, as soon as possible. (:issue:`1360`) .. _v0_56: From 0dfb9241718139f8ad626d22aac25bcebd3a9c9c Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 5 Jun 2021 15:55:07 -0700 Subject: [PATCH 0025/1250] Temporarily reverting buildx support I need to push a container for 0.57 using this action, and I'm not ready to ship other architecture builds until we have tested them in #1344. --- .github/workflows/push_docker_tag.yml | 34 +++++++-------------------- 1 file changed, 9 insertions(+), 25 deletions(-) diff --git a/.github/workflows/push_docker_tag.yml b/.github/workflows/push_docker_tag.yml index e61150a5..9a3969f0 100644 --- a/.github/workflows/push_docker_tag.yml +++ b/.github/workflows/push_docker_tag.yml @@ -11,31 +11,15 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v1 - - - name: Set up Docker Buildx - id: buildx - uses: docker/setup-buildx-action@v1 - - - name: Available platforms - run: echo ${{ steps.buildx.outputs.platforms }} - - - name: Login to DockerHub - uses: docker/login-action@v1 - with: - username: ${{ secrets.DOCKER_USER }} - password: ${{ secrets.DOCKER_PASS }} - - name: Build and push to Docker Hub - run: | - docker buildx build \ - --file Dockerfile . \ - --tag $REPO:${VERSION_TAG} \ - --build-arg VERSION=${VERSION_TAG} \ - --platform linux/386,linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x \ - --push env: - REPO: datasetteproject/datasette + DOCKER_USER: ${{ secrets.DOCKER_USER }} + DOCKER_PASS: ${{ secrets.DOCKER_PASS }} VERSION_TAG: ${{ github.event.inputs.version_tag }} + run: |- + docker login -u $DOCKER_USER -p $DOCKER_PASS + export REPO=datasetteproject/datasette + docker build -f Dockerfile \ + -t $REPO:${VERSION_TAG} \ + --build-arg VERSION=${VERSION_TAG} . + docker push $REPO:${VERSION_TAG} From 030deb4b25cda842ff7129ab7c18550c44dd8379 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 5 Jun 2021 16:01:34 -0700 Subject: [PATCH 0026/1250] Try to handle intermittent FileNotFoundError in tests Refs #1361 --- tests/conftest.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index ad3eb9f1..c6a3eee6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -61,13 +61,18 @@ def move_to_front(items, test_name): @pytest.fixture def restore_working_directory(tmpdir, request): - previous_cwd = os.getcwd() + try: + previous_cwd = os.getcwd() + except OSError: + # https://github.com/simonw/datasette/issues/1361 + previous_cwd = None tmpdir.chdir() def return_to_previous(): os.chdir(previous_cwd) - request.addfinalizer(return_to_previous) + if previous_cwd is not None: + request.addfinalizer(return_to_previous) @pytest.fixture(scope="session", autouse=True) From 03ec71193b9545536898a4bc7493274fec48bdd7 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 6 Jun 2021 15:07:45 -0700 Subject: [PATCH 0027/1250] Don't truncate list of columns on /db page, closes #1364 --- datasette/templates/database.html | 2 +- tests/test_html.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/datasette/templates/database.html b/datasette/templates/database.html index 3fe7c891..2d182d1b 100644 --- a/datasette/templates/database.html +++ b/datasette/templates/database.html @@ -71,7 +71,7 @@ {% if show_hidden or not table.hidden %}

{{ table.name }}{% if table.private %} 🔒{% endif %}{% if table.hidden %} (hidden){% endif %}

-

{% for column in table.columns[:9] %}{{ column }}{% if not loop.last %}, {% endif %}{% endfor %}{% if table.columns|length > 9 %}...{% endif %}

+

{% for column in table.columns %}{{ column }}{% if not loop.last %}, {% endif %}{% endfor %}

{% if table.count is none %}Many rows{% else %}{{ "{:,}".format(table.count) }} row{% if table.count == 1 %}{% else %}s{% endif %}{% endif %}

{% endif %} diff --git a/tests/test_html.py b/tests/test_html.py index 8714d254..ccee8b7e 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -101,6 +101,11 @@ def test_database_page_redirects_with_url_hash(app_client_with_hash): def test_database_page(app_client): response = app_client.get("/fixtures") + assert ( + b"

pk, foreign_key_with_label, foreign_key_with_blank_label, " + b"foreign_key_with_no_label, foreign_key_compound_pk1, " + b"foreign_key_compound_pk2

" + ) in response.body soup = Soup(response.body, "html.parser") queries_ul = soup.find("h2", text="Queries").find_next_sibling("ul") assert queries_ul is not None From f4c5777c7e4ed406313583de09a3bf746552167f Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 7 Jun 2021 11:24:14 -0700 Subject: [PATCH 0028/1250] Fix visual glitch in nav menu, closes #1367 --- datasette/static/app.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/datasette/static/app.css b/datasette/static/app.css index 7f04a162..ad517c98 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -452,7 +452,8 @@ table a:link { margin-left: -10%; font-size: 0.8em; } -.rows-and-columns td ol,ul { +.rows-and-columns td ol, +.rows-and-columns td ul { list-style: initial; list-style-position: inside; } From a3faf378834cc9793adeb22dee19ef57c417457e Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 8 Jun 2021 09:26:45 -0700 Subject: [PATCH 0029/1250] Release 0.57.1 Refs #1364, #1367 --- datasette/version.py | 2 +- docs/changelog.rst | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/datasette/version.py b/datasette/version.py index 93af8b3b..14a7be17 100644 --- a/datasette/version.py +++ b/datasette/version.py @@ -1,2 +1,2 @@ -__version__ = "0.57" +__version__ = "0.57.1" __version_info__ = tuple(__version__.split(".")) diff --git a/docs/changelog.rst b/docs/changelog.rst index 842ca839..89b8fcf5 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,14 @@ Changelog ========= +.. _v0_57_1: + +0.57.1 (2021-06-08) +------------------- + +- Fixed visual display glitch with global navigation menu. (:issue:`1367`) +- No longer truncates the list of table columns displayed on the ``/database`` page. (:issue:`1364`) + .. _v0_57: 0.57 (2021-06-05) From d23a2671386187f61872b9f6b58e0f80ac61f8fe Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 9 Jun 2021 21:45:24 -0700 Subject: [PATCH 0030/1250] Make request available to menu plugin hooks, closes #1371 --- datasette/app.py | 4 +++- datasette/hookspecs.py | 6 +++--- datasette/views/database.py | 1 + datasette/views/table.py | 1 + docs/plugin_hooks.rst | 22 +++++++++++++++------- tests/plugins/my_plugin.py | 14 ++++++++++---- tests/plugins/my_plugin_2.py | 7 +++++-- tests/test_plugins.py | 12 ++++++------ 8 files changed, 44 insertions(+), 23 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index d85517e6..fc5b7d9d 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -833,7 +833,9 @@ class Datasette: async def menu_links(): links = [] for hook in pm.hook.menu_links( - datasette=self, actor=request.actor if request else None + datasette=self, + actor=request.actor if request else None, + request=request or None, ): extra_links = await await_me_maybe(hook) if extra_links: diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index 13a10680..579787a2 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -100,15 +100,15 @@ def forbidden(datasette, request, message): @hookspec -def menu_links(datasette, actor): +def menu_links(datasette, actor, request): """Links for the navigation menu""" @hookspec -def table_actions(datasette, actor, database, table): +def table_actions(datasette, actor, database, table, request): """Links for the table actions menu""" @hookspec -def database_actions(datasette, actor, database): +def database_actions(datasette, actor, database, request): """Links for the database actions menu""" diff --git a/datasette/views/database.py b/datasette/views/database.py index 58168ed7..53bdceed 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -110,6 +110,7 @@ class DatabaseView(DataView): datasette=self.ds, database=database, actor=request.actor, + request=request, ): extra_links = await await_me_maybe(hook) if extra_links: diff --git a/datasette/views/table.py b/datasette/views/table.py index b51d5e5e..81d4d721 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -894,6 +894,7 @@ class TableView(RowTableShared): table=table, database=database, actor=request.actor, + request=request, ): extra_links = await await_me_maybe(hook) if extra_links: diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 688eaa61..2c31e6f4 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -1015,8 +1015,8 @@ The function can alternatively return an awaitable function if it needs to make .. _plugin_hook_menu_links: -menu_links(datasette, actor) ----------------------------- +menu_links(datasette, actor, request) +------------------------------------- ``datasette`` - :ref:`internals_datasette` You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries. @@ -1024,6 +1024,9 @@ menu_links(datasette, actor) ``actor`` - dictionary or None The currently authenticated :ref:`actor `. +``request`` - object or None + The current HTTP :ref:`internals_request`. This can be ``None`` if the request object is not available. + This hook allows additional items to be included in the menu displayed by Datasette's top right menu icon. The hook should return a list of ``{"href": "...", "label": "..."}`` menu items. These will be added to the menu. @@ -1045,11 +1048,10 @@ This example adds a new menu item but only if the signed in user is ``"root"``: Using :ref:`internals_datasette_urls` here ensures that links in the menu will take the :ref:`setting_base_url` setting into account. - .. _plugin_hook_table_actions: -table_actions(datasette, actor, database, table) ------------------------------------------------- +table_actions(datasette, actor, database, table, request) +--------------------------------------------------------- ``datasette`` - :ref:`internals_datasette` You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries. @@ -1063,6 +1065,9 @@ table_actions(datasette, actor, database, table) ``table`` - string The name of the table. +``request`` - object + The current HTTP :ref:`internals_request`. This can be ``None`` if the request object is not available. + This hook allows table actions to be displayed in a menu accessed via an action icon at the top of the table page. It should return a list of ``{"href": "...", "label": "..."}`` menu items. It can alternatively return an ``async def`` awaitable function which returns a list of menu items. @@ -1083,8 +1088,8 @@ This example adds a new table action if the signed in user is ``"root"``: .. _plugin_hook_database_actions: -database_actions(datasette, actor, database) --------------------------------------------- +database_actions(datasette, actor, database, request) +----------------------------------------------------- ``datasette`` - :ref:`internals_datasette` You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries. @@ -1095,4 +1100,7 @@ database_actions(datasette, actor, database) ``database`` - string The name of the database. +``request`` - object + The current HTTP :ref:`internals_request`. + This hook is similar to :ref:`plugin_hook_table_actions` but populates an actions menu on the database page. diff --git a/tests/plugins/my_plugin.py b/tests/plugins/my_plugin.py index 26d06091..85a7467d 100644 --- a/tests/plugins/my_plugin.py +++ b/tests/plugins/my_plugin.py @@ -316,9 +316,12 @@ def forbidden(datasette, request, message): @hookimpl -def menu_links(datasette, actor): +def menu_links(datasette, actor, request): if actor: - return [{"href": datasette.urls.instance(), "label": "Hello"}] + label = "Hello" + if request.args.get("_hello"): + label += ", " + request.args["_hello"] + return [{"href": datasette.urls.instance(), "label": label}] @hookimpl @@ -334,11 +337,14 @@ def table_actions(datasette, database, table, actor): @hookimpl -def database_actions(datasette, database, actor): +def database_actions(datasette, database, actor, request): if actor: + label = f"Database: {database}" + if request.args.get("_hello"): + label += " - " + request.args["_hello"] return [ { "href": datasette.urls.instance(), - "label": f"Database: {database}", + "label": label, } ] diff --git a/tests/plugins/my_plugin_2.py b/tests/plugins/my_plugin_2.py index f3b794cf..b70372f3 100644 --- a/tests/plugins/my_plugin_2.py +++ b/tests/plugins/my_plugin_2.py @@ -158,9 +158,12 @@ def menu_links(datasette, actor): @hookimpl -def table_actions(datasette, database, table, actor): +def table_actions(datasette, database, table, actor, request): async def inner(): if actor: - return [{"href": datasette.urls.instance(), "label": "From async"}] + label = "From async" + if request.args.get("_hello"): + label += " " + request.args["_hello"] + return [{"href": datasette.urls.instance(), "label": label}] return inner diff --git a/tests/test_plugins.py b/tests/test_plugins.py index ee6f1efa..b3561dd5 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -781,9 +781,9 @@ def test_hook_menu_links(app_client): response = app_client.get("/") assert get_menu_links(response.text) == [] - response_2 = app_client.get("/?_bot=1") + response_2 = app_client.get("/?_bot=1&_hello=BOB") assert get_menu_links(response_2.text) == [ - {"label": "Hello", "href": "/"}, + {"label": "Hello, BOB", "href": "/"}, {"label": "Hello 2", "href": "/"}, ] @@ -800,12 +800,12 @@ def test_hook_table_actions(app_client, table_or_view): response = app_client.get(f"/fixtures/{table_or_view}") assert get_table_actions_links(response.text) == [] - response_2 = app_client.get(f"/fixtures/{table_or_view}?_bot=1") + response_2 = app_client.get(f"/fixtures/{table_or_view}?_bot=1&_hello=BOB") assert sorted( get_table_actions_links(response_2.text), key=lambda l: l["label"] ) == [ {"label": "Database: fixtures", "href": "/"}, - {"label": "From async", "href": "/"}, + {"label": "From async BOB", "href": "/"}, {"label": f"Table: {table_or_view}", "href": "/"}, ] @@ -821,7 +821,7 @@ def test_hook_database_actions(app_client): response = app_client.get("/fixtures") assert get_table_actions_links(response.text) == [] - response_2 = app_client.get("/fixtures?_bot=1") + response_2 = app_client.get("/fixtures?_bot=1&_hello=BOB") assert get_table_actions_links(response_2.text) == [ - {"label": "Database: fixtures", "href": "/"}, + {"label": "Database: fixtures - BOB", "href": "/"}, ] From cd7678fde65319d7b6955ce9f4678ba4b9e64b66 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 9 Jun 2021 21:51:14 -0700 Subject: [PATCH 0031/1250] Release 0.58a0 Refs #1371 --- datasette/version.py | 2 +- docs/changelog.rst | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/datasette/version.py b/datasette/version.py index 14a7be17..a46b4706 100644 --- a/datasette/version.py +++ b/datasette/version.py @@ -1,2 +1,2 @@ -__version__ = "0.57.1" +__version__ = "0.58a0" __version_info__ = tuple(__version__.split(".")) diff --git a/docs/changelog.rst b/docs/changelog.rst index 89b8fcf5..99fc5ea5 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,13 @@ Changelog ========= +.. _v0_58a0: + +0.58a0 (2021-06-09) +------------------- + +- The :ref:`menu_links() `, :ref:`table_actions() ` and :ref:`database_actions() ` plugin hooks all gained a new optional ``request`` argument providing access to the current request. (:issue:`1371`) + .. _v0_57_1: 0.57.1 (2021-06-08) From e7975657656ce02717f03703bb8ec17f2fe9b717 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 13 Jun 2021 08:33:22 -0700 Subject: [PATCH 0032/1250] Bump black from 21.5b2 to 21.6b0 (#1374) Bumps [black](https://github.com/psf/black) from 21.5b2 to 21.6b0. - [Release notes](https://github.com/psf/black/releases) - [Changelog](https://github.com/psf/black/blob/main/CHANGES.md) - [Commits](https://github.com/psf/black/commits) --- updated-dependencies: - dependency-name: black dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index e66fefc3..767148ea 100644 --- a/setup.py +++ b/setup.py @@ -73,7 +73,7 @@ setup( "pytest-xdist>=2.2.1,<2.3", "pytest-asyncio>=0.10,<0.16", "beautifulsoup4>=4.8.1,<4.10.0", - "black==21.5b2", + "black==21.6b0", "pytest-timeout>=1.4.2,<1.5", "trustme>=0.7,<0.8", ], From 83e9c8bc7585dcc62f200e37c2daefcd669ee05e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 13 Jun 2021 08:38:47 -0700 Subject: [PATCH 0033/1250] Update trustme requirement from <0.8,>=0.7 to >=0.7,<0.9 (#1373) Updates the requirements on [trustme](https://github.com/python-trio/trustme) to permit the latest version. - [Release notes](https://github.com/python-trio/trustme/releases) - [Commits](https://github.com/python-trio/trustme/compare/v0.7.0...v0.8.0) --- updated-dependencies: - dependency-name: trustme dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 767148ea..e9d4c8d1 100644 --- a/setup.py +++ b/setup.py @@ -75,7 +75,7 @@ setup( "beautifulsoup4>=4.8.1,<4.10.0", "black==21.6b0", "pytest-timeout>=1.4.2,<1.5", - "trustme>=0.7,<0.8", + "trustme>=0.7,<0.9", ], }, tests_require=["datasette[test]"], From 5335f360f4d57d70cab3694b08f15729c4ca2fe2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 19 Jun 2021 17:17:06 -0700 Subject: [PATCH 0034/1250] Update pytest-xdist requirement from <2.3,>=2.2.1 to >=2.2.1,<2.4 (#1378) Updates the requirements on [pytest-xdist](https://github.com/pytest-dev/pytest-xdist) to permit the latest version. - [Release notes](https://github.com/pytest-dev/pytest-xdist/releases) - [Changelog](https://github.com/pytest-dev/pytest-xdist/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest-xdist/compare/v2.2.1...v2.3.0) --- updated-dependencies: - dependency-name: pytest-xdist dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index e9d4c8d1..4f095f29 100644 --- a/setup.py +++ b/setup.py @@ -70,7 +70,7 @@ setup( "docs": ["sphinx_rtd_theme", "sphinx-autobuild"], "test": [ "pytest>=5.2.2,<6.3.0", - "pytest-xdist>=2.2.1,<2.3", + "pytest-xdist>=2.2.1,<2.4", "pytest-asyncio>=0.10,<0.16", "beautifulsoup4>=4.8.1,<4.10.0", "black==21.6b0", From a6c55afe8c82ead8deb32f90c9324022fd422324 Mon Sep 17 00:00:00 2001 From: Chris Amico Date: Mon, 21 Jun 2021 11:57:38 -0400 Subject: [PATCH 0035/1250] Ensure db.path is a string before trying to insert into internal database (#1370) Thanks, @eyeseast --- datasette/app.py | 2 +- tests/test_api.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/datasette/app.py b/datasette/app.py index fc5b7d9d..ce59ef54 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -354,7 +354,7 @@ class Datasette: INSERT OR REPLACE INTO databases (database_name, path, is_memory, schema_version) VALUES (?, ?, ?, ?) """, - [database_name, db.path, db.is_memory, schema_version], + [database_name, str(db.path), db.is_memory, schema_version], block=True, ) await populate_schema_tables(internal_db, db) diff --git a/tests/test_api.py b/tests/test_api.py index e5e609d6..2d891aae 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -25,6 +25,7 @@ from .fixtures import ( # noqa METADATA, ) import json +import pathlib import pytest import sys import urllib @@ -2123,3 +2124,16 @@ def test_col_nocol_errors(app_client, path, expected_error): response = app_client.get(path) assert response.status == 400 assert response.json["error"] == expected_error + + +@pytest.mark.asyncio +async def test_db_path(app_client): + db = app_client.ds.get_database() + path = pathlib.Path(db.path) + + assert path.exists() + + datasette = Datasette([path]) + + # this will break with a path + await datasette.refresh_schemas() From 7bc85b26d6b9c865caf949ff4660d855526c346e Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 23 Jun 2021 12:30:03 -0700 Subject: [PATCH 0036/1250] Deploy stable-docs.datasette.io on publish Refs https://github.com/simonw/datasette.io/issues/67 --- .github/workflows/publish.yml | 42 +++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 90fa4505..8e4c2d02 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -29,6 +29,7 @@ jobs: - name: Run tests run: | pytest + deploy: runs-on: ubuntu-latest needs: [test] @@ -55,6 +56,47 @@ jobs: run: | python setup.py sdist bdist_wheel twine upload dist/* + + deploy_static_docs: + runs-on: ubuntu-latest + needs: [deploy] + # if: "!github.event.release.prerelease" + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.9' + - uses: actions/cache@v2 + name: Configure pip caching + with: + path: ~/.cache/pip + key: ${{ runner.os }}-publish-pip-${{ hashFiles('**/setup.py') }} + restore-keys: | + ${{ runner.os }}-publish-pip- + - name: Install dependencies + run: | + python -m pip install -e .[docs] + python -m pip install sphinx-to-sqlite==0.1a1 + - name: Build docs.db + run: |- + cd docs + sphinx-build -b xml . _build + sphinx-to-sqlite ../docs.db _build + cd .. + - name: Set up Cloud Run + uses: google-github-actions/setup-gcloud@master + with: + version: '275.0.0' + service_account_email: ${{ secrets.GCP_SA_EMAIL }} + service_account_key: ${{ secrets.GCP_SA_KEY }} + - name: Deploy stable-docs.datasette.io to Cloud Run + run: |- + gcloud config set run/region us-central1 + gcloud config set project datasette-222320 + datasette publish cloudrun docs.db \ + --service=datasette-docs-stable + deploy_docker: runs-on: ubuntu-latest needs: [deploy] From 403e370e5a3649333812edbbcba8467e6134cc16 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 23 Jun 2021 12:50:19 -0700 Subject: [PATCH 0037/1250] Fixed reference to default publish implementation --- docs/plugin_hooks.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 2c31e6f4..331f8061 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -302,7 +302,7 @@ publish_subcommand(publish) The Click command group for the ``datasette publish`` subcommand This hook allows you to create new providers for the ``datasette publish`` -command. Datasette uses this hook internally to implement the default ``now`` +command. Datasette uses this hook internally to implement the default ``cloudrun`` and ``heroku`` subcommands, so you can read `their source `_ to see examples of this hook in action. From 3a500155663a07720a8a7baa04acda8c4c937692 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 23 Jun 2021 12:51:19 -0700 Subject: [PATCH 0038/1250] datasette-publish-now is now called datasette-publish-vercel --- docs/plugin_hooks.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 331f8061..8b2a691a 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -348,7 +348,7 @@ Let's say you want to build a plugin that adds a ``datasette publish my_hosting_ ): # Your implementation goes here -Examples: `datasette-publish-fly `_, `datasette-publish-now `_ +Examples: `datasette-publish-fly `_, `datasette-publish-vercel `_ .. _plugin_hook_render_cell: From 4a3e8561ab109f3f171726bc2a7ebac1f23b72a6 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 23 Jun 2021 15:27:30 -0700 Subject: [PATCH 0039/1250] Default 405 for POST, plus tests --- datasette/views/base.py | 3 +++ tests/test_html.py | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/datasette/views/base.py b/datasette/views/base.py index 1a03b97f..a87a0e77 100644 --- a/datasette/views/base.py +++ b/datasette/views/base.py @@ -106,6 +106,9 @@ class BaseView: async def options(self, request, *args, **kwargs): return Response.text("Method not allowed", status=405) + async def post(self, request, *args, **kwargs): + return Response.text("Method not allowed", status=405) + async def put(self, request, *args, **kwargs): return Response.text("Method not allowed", status=405) diff --git a/tests/test_html.py b/tests/test_html.py index ccee8b7e..aee6bce1 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -92,6 +92,13 @@ def test_memory_database_page(): assert response.status == 200 +def test_not_allowed_methods(): + with make_app_client(memory=True) as client: + for method in ("post", "put", "patch", "delete"): + response = client.request(path="/_memory", method=method.upper()) + assert response.status == 405 + + def test_database_page_redirects_with_url_hash(app_client_with_hash): response = app_client_with_hash.get("/fixtures", allow_redirects=False) assert response.status == 302 From b1fd24ac9f9035464af0a8ce92391c166a783253 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 23 Jun 2021 15:39:52 -0700 Subject: [PATCH 0040/1250] skip_csrf(datasette, scope) plugin hook, refs #1377 --- datasette/app.py | 3 +++ datasette/hookspecs.py | 5 +++++ docs/internals.rst | 2 ++ docs/plugin_hooks.rst | 25 +++++++++++++++++++++++++ setup.py | 2 +- tests/fixtures.py | 2 ++ tests/plugins/my_plugin.py | 5 +++++ tests/test_plugins.py | 25 +++++++++++++++++++++++++ 8 files changed, 68 insertions(+), 1 deletion(-) diff --git a/datasette/app.py b/datasette/app.py index ce59ef54..e11c12eb 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -1052,6 +1052,9 @@ class Datasette: DatasetteRouter(self, routes), signing_secret=self._secret, cookie_name="ds_csrftoken", + skip_if_scope=lambda scope: any( + pm.hook.skip_csrf(datasette=self, scope=scope) + ), ) if self.setting("trace_debug"): asgi = AsgiTracer(asgi) diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index 579787a2..63b06097 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -112,3 +112,8 @@ def table_actions(datasette, actor, database, table, request): @hookspec def database_actions(datasette, actor, database, request): """Links for the database actions menu""" + + +@hookspec +def skip_csrf(datasette, scope): + """Mechanism for skipping CSRF checks for certain requests""" diff --git a/docs/internals.rst b/docs/internals.rst index 72c86083..98df998a 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -778,6 +778,8 @@ If your plugin implements a ```` anywhere you will need to i +You can selectively disable CSRF protection using the :ref:`plugin_hook_skip_csrf` hook. + .. _internals_internal: The _internal database diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 8b2a691a..5af601b4 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -1104,3 +1104,28 @@ database_actions(datasette, actor, database, request) The current HTTP :ref:`internals_request`. This hook is similar to :ref:`plugin_hook_table_actions` but populates an actions menu on the database page. + +.. _plugin_hook_skip_csrf: + +skip_csrf(datasette, scope) +--------------------------- + +``datasette`` - :ref:`internals_datasette` + You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries. + +``scope`` - dictionary + The `ASGI scope `__ for the incoming HTTP request. + +This hook can be used to skip :ref:`internals_csrf` for a specific incoming request. For example, you might have a custom path at ``/submit-comment`` which is designed to accept comments from anywhere, whether or not the incoming request originated on the site and has an accompanying CSRF token. + +This example will disable CSRF protection for that specific URL path: + +.. code-block:: python + + from datasette import hookimpl + + @hookimpl + def skip_csrf(scope): + return scope["path"] == "/submit-comment" + +If any of the currently active ``skip_csrf()`` plugin hooks return ``True``, CSRF protection will be skipped for the request. diff --git a/setup.py b/setup.py index 4f095f29..8a651d32 100644 --- a/setup.py +++ b/setup.py @@ -55,7 +55,7 @@ setup( "uvicorn~=0.11", "aiofiles>=0.4,<0.8", "janus>=0.4,<0.7", - "asgi-csrf>=0.6", + "asgi-csrf>=0.9", "PyYAML~=5.3", "mergedeep>=1.1.1,<1.4.0", "itsdangerous>=1.1,<3.0", diff --git a/tests/fixtures.py b/tests/fixtures.py index cdd2e987..a79fc246 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -52,6 +52,7 @@ EXPECTED_PLUGINS = [ "register_magic_parameters", "register_routes", "render_cell", + "skip_csrf", "startup", "table_actions", ], @@ -152,6 +153,7 @@ def make_app_client( static_mounts=static_mounts, template_dir=template_dir, crossdb=crossdb, + pdb=True, ) ds.sqlite_functions.append(("sleep", 1, lambda n: time.sleep(float(n)))) yield TestClient(ds) diff --git a/tests/plugins/my_plugin.py b/tests/plugins/my_plugin.py index 85a7467d..0e625623 100644 --- a/tests/plugins/my_plugin.py +++ b/tests/plugins/my_plugin.py @@ -348,3 +348,8 @@ def database_actions(datasette, database, actor, request): "label": label, } ] + + +@hookimpl +def skip_csrf(scope): + return scope["path"] == "/skip-csrf" diff --git a/tests/test_plugins.py b/tests/test_plugins.py index b3561dd5..14273282 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -825,3 +825,28 @@ def test_hook_database_actions(app_client): assert get_table_actions_links(response_2.text) == [ {"label": "Database: fixtures - BOB", "href": "/"}, ] + + +def test_hook_skip_csrf(app_client): + cookie = app_client.actor_cookie({"id": "test"}) + csrf_response = app_client.post( + "/post/", + post_data={"this is": "post data"}, + csrftoken_from=True, + cookies={"ds_actor": cookie}, + ) + assert csrf_response.status == 200 + missing_csrf_response = app_client.post( + "/post/", post_data={"this is": "post data"}, cookies={"ds_actor": cookie} + ) + assert missing_csrf_response.status == 403 + # But "/skip-csrf" should allow + allow_csrf_response = app_client.post( + "/skip-csrf", post_data={"this is": "post data"}, cookies={"ds_actor": cookie} + ) + assert allow_csrf_response.status == 405 # Method not allowed + # /skip-csrf-2 should not + second_missing_csrf_response = app_client.post( + "/skip-csrf-2", post_data={"this is": "post data"}, cookies={"ds_actor": cookie} + ) + assert second_missing_csrf_response.status == 403 From 02b19c7a9afd328f22040ab33b5c1911cd904c7c Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 23 Jun 2021 15:50:48 -0700 Subject: [PATCH 0041/1250] Removed rogue pdb=True, refs #1377 --- tests/fixtures.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/fixtures.py b/tests/fixtures.py index a79fc246..1fb52bf9 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -153,7 +153,6 @@ def make_app_client( static_mounts=static_mounts, template_dir=template_dir, crossdb=crossdb, - pdb=True, ) ds.sqlite_functions.append(("sleep", 1, lambda n: time.sleep(float(n)))) yield TestClient(ds) From ff17970ed4988a80b699d417bbeec07d63400e24 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 24 Jun 2021 09:24:59 -0700 Subject: [PATCH 0042/1250] Release 0.58a1 Refs #1365, #1377 --- datasette/version.py | 2 +- docs/changelog.rst | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/datasette/version.py b/datasette/version.py index a46b4706..e5a29931 100644 --- a/datasette/version.py +++ b/datasette/version.py @@ -1,2 +1,2 @@ -__version__ = "0.58a0" +__version__ = "0.58a1" __version_info__ = tuple(__version__.split(".")) diff --git a/docs/changelog.rst b/docs/changelog.rst index 99fc5ea5..bcd8b987 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,15 @@ Changelog ========= +.. _v0_58a1: + +0.58a1 (2021-06-24) +------------------- + +- New plugin hook: :ref:`plugin_hook_skip_csrf`, for opting out of CSRF protection based on the incoming request. (:issue:`1377`) +- ``POST`` requests to endpoints that do not support that HTTP verb now return a 405 error. +- ``db.path`` can now be provided as a ``pathlib.Path`` object, useful when writing unit tests for plugins. Thanks, Chris Amico. (:issue:`1365`) + .. _v0_58a0: 0.58a0 (2021-06-09) From 953a64467d78bca29fe6cc18bdb2baa7848e53ff Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 24 Jun 2021 09:42:02 -0700 Subject: [PATCH 0043/1250] Only publish stable docs on non-preview release Refs https://github.com/simonw/datasette.io/issues/67 --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8e4c2d02..727f9933 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -60,7 +60,7 @@ jobs: deploy_static_docs: runs-on: ubuntu-latest needs: [deploy] - # if: "!github.event.release.prerelease" + if: "!github.event.release.prerelease" steps: - uses: actions/checkout@v2 - name: Set up Python From baf986c871708c01ca183be760995cf306ba21bf Mon Sep 17 00:00:00 2001 From: Brandon Roberts Date: Sat, 26 Jun 2021 15:24:54 -0700 Subject: [PATCH 0044/1250] New get_metadata() plugin hook for dynamic metadata The following hook is added: get_metadata( datasette=self, key=key, database=database, table=table, fallback=fallback ) This gets called when we're building our metdata for the rest of the system to use. We merge whatever the plugins return with any local metadata (from metadata.yml/yaml/json) allowing for a live-editable dynamic Datasette. As a security precation, local meta is *not* overwritable by plugin hooks. The workflow for transitioning to live-meta would be to load the plugin with the full metadata.yaml and save. Then remove the parts of the metadata that you want to be able to change from the file. * Avoid race condition: don't mutate databases list This avoids the nasty "RuntimeError: OrderedDict mutated during iteration" error that randomly happens when a plugin adds a new database to Datasette, using `add_database`. This change makes the add and remove database functions more expensive, but it prevents the random explosion race conditions that make for confusing user experience when importing live databases. Thanks, @brandonrobertz --- .gitignore | 1 + datasette/app.py | 47 ++++++++++++++++++++++++++++++++----- datasette/hookspecs.py | 5 ++++ datasette/utils/__init__.py | 1 - docs/plugin_hooks.rst | 35 +++++++++++++++++++++++++++ tests/test_permissions.py | 6 ++--- tests/test_plugins.py | 29 +++++++++++++++++++++++ 7 files changed, 114 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 29ac176f..066009f0 100644 --- a/.gitignore +++ b/.gitignore @@ -117,3 +117,4 @@ ENV/ # macOS files .DS_Store node_modules +.*.swp diff --git a/datasette/app.py b/datasette/app.py index e11c12eb..05ad5a8d 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -251,7 +251,7 @@ class Datasette: if config_dir and metadata_files and not metadata: with metadata_files[0].open() as fp: metadata = parse_metadata(fp.read()) - self._metadata = metadata or {} + self._metadata_local = metadata or {} self.sqlite_functions = [] self.sqlite_extensions = [] for extension in sqlite_extensions or []: @@ -380,6 +380,7 @@ class Datasette: return self.databases[name] def add_database(self, db, name=None): + new_databases = self.databases.copy() if name is None: # Pick a unique name for this database suggestion = db.suggest_name() @@ -391,14 +392,18 @@ class Datasette: name = "{}_{}".format(suggestion, i) i += 1 db.name = name - self.databases[name] = db + new_databases[name] = db + # don't mutate! that causes race conditions with live import + self.databases = new_databases return db def add_memory_database(self, memory_name): return self.add_database(Database(self, memory_name=memory_name)) def remove_database(self, name): - self.databases.pop(name) + new_databases = self.databases.copy() + new_databases.pop(name) + self.databases = new_databases def setting(self, key): return self._settings.get(key, None) @@ -407,6 +412,17 @@ class Datasette: # Returns a fully resolved config dictionary, useful for templates return {option.name: self.setting(option.name) for option in SETTINGS} + def _metadata_recursive_update(self, orig, updated): + if not isinstance(orig, dict) or not isinstance(updated, dict): + return orig + + for key, upd_value in updated.items(): + if isinstance(upd_value, dict) and isinstance(orig.get(key), dict): + orig[key] = self._metadata_recursive_update(orig[key], upd_value) + else: + orig[key] = upd_value + return orig + def metadata(self, key=None, database=None, table=None, fallback=True): """ Looks up metadata, cascading backwards from specified level. @@ -415,7 +431,21 @@ class Datasette: assert not ( database is None and table is not None ), "Cannot call metadata() with table= specified but not database=" - databases = self._metadata.get("databases") or {} + metadata = {} + + for hook_dbs in pm.hook.get_metadata( + datasette=self, key=key, database=database, table=table, fallback=fallback + ): + metadata = self._metadata_recursive_update(metadata, hook_dbs) + + # security precaution!! don't allow anything in the local config + # to be overwritten. this is a temporary measure, not sure if this + # is a good idea long term or maybe if it should just be a concern + # of the plugin's implemtnation + metadata = self._metadata_recursive_update(metadata, self._metadata_local) + + databases = metadata.get("databases") or {} + search_list = [] if database is not None: search_list.append(databases.get(database) or {}) @@ -424,7 +454,8 @@ class Datasette: table ) or {} search_list.insert(0, table_metadata) - search_list.append(self._metadata) + + search_list.append(metadata) if not fallback: # No fallback allowed, so just use the first one in the list search_list = search_list[:1] @@ -440,6 +471,10 @@ class Datasette: m.update(item) return m + @property + def _metadata(self): + return self.metadata() + def plugin_config(self, plugin_name, database=None, table=None, fallback=True): """Return config for plugin, falling back from specified database/table""" plugins = self.metadata( @@ -960,7 +995,7 @@ class Datasette: r"/:memory:(?P.*)$", ) add_route( - JsonDataView.as_view(self, "metadata.json", lambda: self._metadata), + JsonDataView.as_view(self, "metadata.json", lambda: self.metadata()), r"/-/metadata(?P(\.json)?)$", ) add_route( diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index 63b06097..c40b3148 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -10,6 +10,11 @@ def startup(datasette): """Fires directly after Datasette first starts running""" +@hookspec +def get_metadata(datasette, key, database, table, fallback): + """Get configuration""" + + @hookspec def asgi_wrapper(datasette): """Returns an ASGI middleware callable to wrap our ASGI application with""" diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py index 73122976..1e193862 100644 --- a/datasette/utils/__init__.py +++ b/datasette/utils/__init__.py @@ -21,7 +21,6 @@ import numbers import yaml from .shutil_backport import copytree from .sqlite import sqlite3, sqlite_version, supports_table_xinfo -from ..plugins import pm # From https://www.sqlite.org/lang_keywords.html diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 5af601b4..9ec75f34 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -1129,3 +1129,38 @@ This example will disable CSRF protection for that specific URL path: return scope["path"] == "/submit-comment" If any of the currently active ``skip_csrf()`` plugin hooks return ``True``, CSRF protection will be skipped for the request. + +get_metadata(datasette, key, database, table, fallback) +------------------------------------------------------- + +``datasette`` - :ref:`internals_datasette` + You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries. + +``actor`` - dictionary or None + The currently authenticated :ref:`actor `. + +``database`` - string or None + The name of the database metadata is being asked for. + +``table`` - string or None + The name of the table. + +``key`` - string or None + The name of the key for which data is being asked for. + +This hook is responsible for returning a dictionary corresponding to Datasette :ref:`metadata`. This function is passed the `database`, `table` and `key` which were passed to the upstream internal request for metadata. Regardless, it is important to return a global metadata object, where `"databases": []` would be a top-level key. The dictionary returned here, will be merged with, and overwritten by, the contents of the physical `metadata.yaml` if one is present. + +.. code-block:: python + + @hookimpl + def get_metadata(datasette, key, database, table, fallback): + metadata = { + "title": "This will be the Datasette landing page title!", + "description": get_instance_description(datasette), + "databases": [], + } + for db_name, db_data_dict in get_my_database_meta(datasette, database, table, key): + metadata["databases"][db_name] = db_data_dict + # whatever we return here will be merged with any other plugins using this hook and + # will be overwritten by a local metadata.yaml if one exists! + return metadata diff --git a/tests/test_permissions.py b/tests/test_permissions.py index 9317c0d9..788523b0 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -440,7 +440,7 @@ def test_permissions_cascade(cascade_app_client, path, permissions, expected_sta """Test that e.g. having view-table but NOT view-database lets you view table page, etc""" allow = {"id": "*"} deny = {} - previous_metadata = cascade_app_client.ds._metadata + previous_metadata = cascade_app_client.ds.metadata() updated_metadata = copy.deepcopy(previous_metadata) actor = {"id": "test"} if "download" in permissions: @@ -457,11 +457,11 @@ def test_permissions_cascade(cascade_app_client, path, permissions, expected_sta updated_metadata["databases"]["fixtures"]["queries"]["magic_parameters"][ "allow" ] = (allow if "query" in permissions else deny) - cascade_app_client.ds._metadata = updated_metadata + cascade_app_client.ds._metadata_local = updated_metadata response = cascade_app_client.get( path, cookies={"ds_actor": cascade_app_client.actor_cookie(actor)}, ) assert expected_status == response.status finally: - cascade_app_client.ds._metadata = previous_metadata + cascade_app_client.ds._metadata_local = previous_metadata diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 14273282..3b9c06b9 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -850,3 +850,32 @@ def test_hook_skip_csrf(app_client): "/skip-csrf-2", post_data={"this is": "post data"}, cookies={"ds_actor": cookie} ) assert second_missing_csrf_response.status == 403 + + +def test_hook_get_metadata(app_client): + app_client.ds._metadata_local = { + "title": "Testing get_metadata hook!", + "databases": { + "from-local": { + "title": "Hello from local metadata" + } + } + } + og_pm_hook_get_metadata = pm.hook.get_metadata + def get_metadata_mock(*args, **kwargs): + return [{ + "databases": { + "from-hook": { + "title": "Hello from the plugin hook" + }, + "from-local": { + "title": "This will be overwritten!" + } + } + }] + pm.hook.get_metadata = get_metadata_mock + meta = app_client.ds.metadata() + assert "Testing get_metadata hook!" == meta["title"] + assert "Hello from local metadata" == meta["databases"]["from-local"]["title"] + assert "Hello from the plugin hook" == meta["databases"]["from-hook"]["title"] + pm.hook.get_metadata = og_pm_hook_get_metadata From 05a312caf3debb51aa1069939923a49e21cd2bd1 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 26 Jun 2021 15:25:28 -0700 Subject: [PATCH 0045/1250] Applied Black, refs #1368 --- tests/test_plugins.py | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 3b9c06b9..7a626ce5 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -855,24 +855,20 @@ def test_hook_skip_csrf(app_client): def test_hook_get_metadata(app_client): app_client.ds._metadata_local = { "title": "Testing get_metadata hook!", - "databases": { - "from-local": { - "title": "Hello from local metadata" - } - } + "databases": {"from-local": {"title": "Hello from local metadata"}}, } og_pm_hook_get_metadata = pm.hook.get_metadata + def get_metadata_mock(*args, **kwargs): - return [{ - "databases": { - "from-hook": { - "title": "Hello from the plugin hook" - }, - "from-local": { - "title": "This will be overwritten!" + return [ + { + "databases": { + "from-hook": {"title": "Hello from the plugin hook"}, + "from-local": {"title": "This will be overwritten!"}, } } - }] + ] + pm.hook.get_metadata = get_metadata_mock meta = app_client.ds.metadata() assert "Testing get_metadata hook!" == meta["title"] From 089278b8dbe0cb3d41f27666d97b0096b750fbe2 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 26 Jun 2021 15:49:07 -0700 Subject: [PATCH 0046/1250] rST fix, refs #1384 --- docs/plugin_hooks.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 9ec75f34..d3b55747 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -1148,7 +1148,7 @@ get_metadata(datasette, key, database, table, fallback) ``key`` - string or None The name of the key for which data is being asked for. -This hook is responsible for returning a dictionary corresponding to Datasette :ref:`metadata`. This function is passed the `database`, `table` and `key` which were passed to the upstream internal request for metadata. Regardless, it is important to return a global metadata object, where `"databases": []` would be a top-level key. The dictionary returned here, will be merged with, and overwritten by, the contents of the physical `metadata.yaml` if one is present. +This hook is responsible for returning a dictionary corresponding to Datasette :ref:`metadata`. This function is passed the ``database``, ``table`` and ``key`` which were passed to the upstream internal request for metadata. Regardless, it is important to return a global metadata object, where ``"databases": []`` would be a top-level key. The dictionary returned here, will be merged with, and overwritten by, the contents of the physical ``metadata.yaml`` if one is present. .. code-block:: python From 0d339a4897c808903e34fa6be228cdaaa5a29c55 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 26 Jun 2021 16:04:39 -0700 Subject: [PATCH 0047/1250] Removed text about executing SQL, refs #1384 --- docs/plugin_hooks.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index d3b55747..d71037d9 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -1134,7 +1134,7 @@ get_metadata(datasette, key, database, table, fallback) ------------------------------------------------------- ``datasette`` - :ref:`internals_datasette` - You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries. + You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``. ``actor`` - dictionary or None The currently authenticated :ref:`actor `. From ea627baccf980d7d8ebc9e1ffff1fe34d556e56f Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 26 Jun 2021 17:02:42 -0700 Subject: [PATCH 0048/1250] Removed fallback parameter from get_metadata, refs #1384 --- datasette/app.py | 2 +- datasette/hookspecs.py | 4 ++-- docs/plugin_hooks.rst | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 05ad5a8d..0b909968 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -434,7 +434,7 @@ class Datasette: metadata = {} for hook_dbs in pm.hook.get_metadata( - datasette=self, key=key, database=database, table=table, fallback=fallback + datasette=self, key=key, database=database, table=table ): metadata = self._metadata_recursive_update(metadata, hook_dbs) diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index c40b3148..07b2f5ba 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -11,8 +11,8 @@ def startup(datasette): @hookspec -def get_metadata(datasette, key, database, table, fallback): - """Get configuration""" +def get_metadata(datasette, key, database, table): + """Return metadata to be merged into Datasette's metadata dictionary""" @hookspec diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index d71037d9..b687a6e7 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -1130,8 +1130,8 @@ This example will disable CSRF protection for that specific URL path: If any of the currently active ``skip_csrf()`` plugin hooks return ``True``, CSRF protection will be skipped for the request. -get_metadata(datasette, key, database, table, fallback) -------------------------------------------------------- +get_metadata(datasette, key, database, table) +--------------------------------------------- ``datasette`` - :ref:`internals_datasette` You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``. @@ -1153,7 +1153,7 @@ This hook is responsible for returning a dictionary corresponding to Datasette : .. code-block:: python @hookimpl - def get_metadata(datasette, key, database, table, fallback): + def get_metadata(datasette, key, database, table): metadata = { "title": "This will be the Datasette landing page title!", "description": get_instance_description(datasette), From dbc61a1fd343e4660b6220f60c4ce79341245048 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 2 Jul 2021 10:33:03 -0700 Subject: [PATCH 0049/1250] Documented ProxyPreserveHost On for Apache, closes #1387 --- docs/deploying.rst | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/deploying.rst b/docs/deploying.rst index 48261b59..47dff73d 100644 --- a/docs/deploying.rst +++ b/docs/deploying.rst @@ -161,6 +161,9 @@ For `Apache `__, you can use the ``ProxyPass`` direct LoadModule proxy_module lib/httpd/modules/mod_proxy.so LoadModule proxy_http_module lib/httpd/modules/mod_proxy_http.so -Then add this directive to proxy traffic:: +Then add these directives to proxy traffic:: - ProxyPass /datasette-prefix/ http://127.0.0.1:8009/datasette-prefix/ + ProxyPass /datasette-prefix/ http://127.0.0.1:8009/datasette-prefix/ + ProxyPreserveHost On + +The `ProxyPreserveHost On `__ directive ensures that the original ``Host:`` header from the incoming request is passed through to Datasette. Datasette needs this to correctly assemble links to other pages using the :ref:`datasette_absolute_url` method. From c8feaf0b628ddb1f98b2a4b89691d3d1b939ed8e Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 9 Jul 2021 09:32:32 -0700 Subject: [PATCH 0050/1250] systemctl restart datasette.service, closes #1390 --- docs/deploying.rst | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/deploying.rst b/docs/deploying.rst index 47dff73d..44ddd07b 100644 --- a/docs/deploying.rst +++ b/docs/deploying.rst @@ -65,7 +65,11 @@ You can start the Datasette process running using the following:: sudo systemctl daemon-reload sudo systemctl start datasette.service -You can confirm that Datasette is running on port 8000 like so:: +You may need to restart the Datasette service after making changes to its ``metadata.json`` configuration or the ``datasette.service`` file. You can do that using:: + + sudo systemctl restart datasette.service + +Once the service has started you can confirm that Datasette is running on port 8000 like so:: curl 127.0.0.1:8000/-/versions.json # Should output JSON showing the installed version From 83f6799a96f48b5acef4911c0273973f15efdf05 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 10 Jul 2021 11:30:48 -0700 Subject: [PATCH 0051/1250] searchmode: raw table metadata property, closes #1389 --- datasette/views/table.py | 8 +++++++- docs/full_text_search.rst | 29 ++++++++++++++++++---------- tests/test_api.py | 40 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 11 deletions(-) diff --git a/datasette/views/table.py b/datasette/views/table.py index 81d4d721..1bda7496 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -495,7 +495,13 @@ class TableView(RowTableShared): if pair[0].startswith("_search") and pair[0] != "_searchmode" ) search = "" - search_mode_raw = special_args.get("_searchmode") == "raw" + search_mode_raw = table_metadata.get("searchmode") == "raw" + # Or set it from the querystring + qs_searchmode = special_args.get("_searchmode") + if qs_searchmode == "escaped": + search_mode_raw = False + if qs_searchmode == "raw": + search_mode_raw = True if fts_table and search_args: if "_search" in search_args: # Simple ?_search=xxx diff --git a/docs/full_text_search.rst b/docs/full_text_search.rst index b414ff37..f549296f 100644 --- a/docs/full_text_search.rst +++ b/docs/full_text_search.rst @@ -36,7 +36,11 @@ Advanced SQLite search queries SQLite full-text search includes support for `a variety of advanced queries `__, including ``AND``, ``OR``, ``NOT`` and ``NEAR``. -By default Datasette disables these features to ensure they do not cause any confusion for users who are not aware of them. You can disable this escaping and use the advanced queries by adding ``?_searchmode=raw`` to the table page query string. +By default Datasette disables these features to ensure they do not cause errors or confusion for users who are not aware of them. You can disable this escaping and use the advanced queries by adding ``&_searchmode=raw`` to the table page query string. + +If you want to enable these operators by default for a specific table, you can do so by adding ``"searchmode": "raw"`` to the metadata configuration for that table, see :ref:`full_text_search_table_or_view`. + +If that option has been specified in the table metadata but you want to over-ride it and return to the default behavior you can append ``&_searchmode=escaped`` to the query string. .. _full_text_search_table_or_view: @@ -53,19 +57,24 @@ https://latest.datasette.io/fixtures/searchable_view?_fts_table=searchable_fts&_ The ``fts_table`` metadata property can be used to specify an associated FTS table. If the primary key column in your table which was used to populate the FTS table is something other than ``rowid``, you can specify the column to use with the ``fts_pk`` property. -Here is an example which enables full-text search for a ``display_ads`` view which is defined against the ``ads`` table and hence needs to run FTS against the ``ads_fts`` table, using the ``id`` as the primary key:: +The ``"searchmode": "raw"`` property can be used to default the table to accepting SQLite advanced search operators, as described in :ref:`full_text_search_advanced_queries`. + +Here is an example which enables full-text search (with SQLite advanced search operators) for a ``display_ads`` view which is defined against the ``ads`` table and hence needs to run FTS against the ``ads_fts`` table, using the ``id`` as the primary key: + +.. code-block:: json { - "databases": { - "russian-ads": { - "tables": { - "display_ads": { - "fts_table": "ads_fts", - "fts_pk": "id" + "databases": { + "russian-ads": { + "tables": { + "display_ads": { + "fts_table": "ads_fts", + "fts_pk": "id", + "search_mode": "raw" + } + } } - } } - } } .. _full_text_search_custom_sql: diff --git a/tests/test_api.py b/tests/test_api.py index 2d891aae..cb3c255d 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1078,6 +1078,46 @@ def test_searchable(app_client, path, expected_rows): assert expected_rows == response.json["rows"] +_SEARCHMODE_RAW_RESULTS = [ + [1, "barry cat", "terry dog", "panther"], + [2, "terry dog", "sara weasel", "puma"], +] + + +@pytest.mark.parametrize( + "table_metadata,querystring,expected_rows", + [ + ( + {}, + "_search=te*+AND+do*", + [], + ), + ( + {"searchmode": "raw"}, + "_search=te*+AND+do*", + _SEARCHMODE_RAW_RESULTS, + ), + ( + {}, + "_search=te*+AND+do*&_searchmode=raw", + _SEARCHMODE_RAW_RESULTS, + ), + # Can be over-ridden with _searchmode=escaped + ( + {"searchmode": "raw"}, + "_search=te*+AND+do*&_searchmode=escaped", + [], + ), + ], +) +def test_searchmode(table_metadata, querystring, expected_rows): + with make_app_client( + metadata={"databases": {"fixtures": {"tables": {"searchable": table_metadata}}}} + ) as client: + response = client.get("/fixtures/searchable.json?" + querystring) + assert expected_rows == response.json["rows"] + + @pytest.mark.parametrize( "path,expected_rows", [ From 2e8d924cdc2274eb31fb76332bc5269f65c0ad90 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 10 Jul 2021 12:03:19 -0700 Subject: [PATCH 0052/1250] Refactored generated_columns test, no longer in fixtures.db - refs #1391 --- tests/fixtures.py | 19 +-------- tests/test_api.py | 52 ++++++++++++------------ tests/test_internals_database.py | 70 +++++++++++++++----------------- 3 files changed, 59 insertions(+), 82 deletions(-) diff --git a/tests/fixtures.py b/tests/fixtures.py index 1fb52bf9..dce94876 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -1,5 +1,5 @@ from datasette.app import Datasette -from datasette.utils.sqlite import sqlite3, sqlite_version, supports_generated_columns +from datasette.utils.sqlite import sqlite3, sqlite_version from datasette.utils.testing import TestClient import click import contextlib @@ -118,8 +118,6 @@ def make_app_client( immutables = [] conn = sqlite3.connect(filepath) conn.executescript(TABLES) - if supports_generated_columns(): - conn.executescript(GENERATED_COLUMNS_SQL) for sql, params in TABLE_PARAMETERIZED_SQL: with conn: conn.execute(sql, params) @@ -720,18 +718,6 @@ INSERT INTO "searchable_fts" (rowid, text1, text2) SELECT rowid, text1, text2 FROM searchable; """ -GENERATED_COLUMNS_SQL = """ -CREATE TABLE generated_columns ( - body TEXT, - id INT GENERATED ALWAYS AS (json_extract(body, '$.number')) STORED, - consideration INT GENERATED ALWAYS AS (json_extract(body, '$.string')) STORED -); -INSERT INTO generated_columns (body) VALUES ('{ - "number": 1, - "string": "This is a string" -}'); -""" - def assert_permissions_checked(datasette, actions): # actions is a list of "action" or (action, resource) tuples @@ -792,9 +778,6 @@ def cli(db_filename, metadata, plugins_path, recreate, extra_db_filename): for sql, params in TABLE_PARAMETERIZED_SQL: with conn: conn.execute(sql, params) - if supports_generated_columns(): - with conn: - conn.executescript(GENERATED_COLUMNS_SQL) print(f"Test tables written to {db_filename}") if metadata: with open(metadata, "w") as fp: diff --git a/tests/test_api.py b/tests/test_api.py index cb3c255d..3e8d02c8 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -20,7 +20,6 @@ from .fixtures import ( # noqa generate_compound_rows, generate_sortable_rows, make_app_client, - supports_generated_columns, EXPECTED_PLUGINS, METADATA, ) @@ -38,7 +37,7 @@ def test_homepage(app_client): assert response.json.keys() == {"fixtures": 0}.keys() d = response.json["fixtures"] assert d["name"] == "fixtures" - assert d["tables_count"] == 25 if supports_generated_columns() else 24 + assert d["tables_count"] == 24 assert len(d["tables_and_views_truncated"]) == 5 assert d["tables_and_views_more"] is True # 4 hidden FTS tables + no_primary_key (hidden in metadata) @@ -271,22 +270,7 @@ def test_database_page(app_client): }, "private": False, }, - ] + ( - [ - { - "columns": ["body", "id", "consideration"], - "count": 1, - "foreign_keys": {"incoming": [], "outgoing": []}, - "fts_table": None, - "hidden": False, - "name": "generated_columns", - "primary_keys": [], - "private": False, - } - ] - if supports_generated_columns() - else [] - ) + [ + ] + [ { "name": "infinity", "columns": ["value"], @@ -2074,16 +2058,30 @@ def test_paginate_using_link_header(app_client, qs): sqlite_version() < (3, 31, 0), reason="generated columns were added in SQLite 3.31.0", ) -def test_generated_columns_are_visible_in_datasette(app_client): - response = app_client.get("/fixtures/generated_columns.json?_shape=array") - assert response.json == [ - { - "rowid": 1, - "body": '{\n "number": 1,\n "string": "This is a string"\n}', - "id": 1, - "consideration": "This is a string", +def test_generated_columns_are_visible_in_datasette(): + with make_app_client( + extra_databases={ + "generated.db": """ + CREATE TABLE generated_columns ( + body TEXT, + id INT GENERATED ALWAYS AS (json_extract(body, '$.number')) STORED, + consideration INT GENERATED ALWAYS AS (json_extract(body, '$.string')) STORED + ); + INSERT INTO generated_columns (body) VALUES ('{ + "number": 1, + "string": "This is a string" + }');""" } - ] + ) as client: + response = app_client.get("/generated/generated_columns.json?_shape=array") + assert response.json == [ + { + "rowid": 1, + "body": '{\n "number": 1,\n "string": "This is a string"\n}', + "id": 1, + "consideration": "This is a string", + } + ] def test_http_options_request(app_client): diff --git a/tests/test_internals_database.py b/tests/test_internals_database.py index b60aaa8e..ad829751 100644 --- a/tests/test_internals_database.py +++ b/tests/test_internals_database.py @@ -2,7 +2,7 @@ Tests for the datasette.database.Database class """ from datasette.database import Database, Results, MultipleValues -from datasette.utils.sqlite import sqlite3, supports_generated_columns +from datasette.utils.sqlite import sqlite3 from datasette.utils import Column from .fixtures import app_client, app_client_two_attached_databases_crossdb_enabled import pytest @@ -340,42 +340,38 @@ async def test_get_all_foreign_keys(db): @pytest.mark.asyncio async def test_table_names(db): table_names = await db.table_names() - assert ( - table_names - == [ - "simple_primary_key", - "primary_key_multiple_columns", - "primary_key_multiple_columns_explicit_label", - "compound_primary_key", - "compound_three_primary_keys", - "foreign_key_references", - "sortable", - "no_primary_key", - "123_starts_with_digits", - "Table With Space In Name", - "table/with/slashes.csv", - "complex_foreign_keys", - "custom_foreign_key_label", - "units", - "tags", - "searchable", - "searchable_tags", - "searchable_fts", - "searchable_fts_segments", - "searchable_fts_segdir", - "searchable_fts_docsize", - "searchable_fts_stat", - "select", - "infinity", - "facet_cities", - "facetable", - "binary_data", - "roadside_attractions", - "attraction_characteristic", - "roadside_attraction_characteristics", - ] - + (["generated_columns"] if supports_generated_columns() else []) - ) + assert table_names == [ + "simple_primary_key", + "primary_key_multiple_columns", + "primary_key_multiple_columns_explicit_label", + "compound_primary_key", + "compound_three_primary_keys", + "foreign_key_references", + "sortable", + "no_primary_key", + "123_starts_with_digits", + "Table With Space In Name", + "table/with/slashes.csv", + "complex_foreign_keys", + "custom_foreign_key_label", + "units", + "tags", + "searchable", + "searchable_tags", + "searchable_fts", + "searchable_fts_segments", + "searchable_fts_segdir", + "searchable_fts_docsize", + "searchable_fts_stat", + "select", + "infinity", + "facet_cities", + "facetable", + "binary_data", + "roadside_attractions", + "attraction_characteristic", + "roadside_attraction_characteristics", + ] @pytest.mark.asyncio From e0064ba7b06973eae70e6222a6208d9fed5bd170 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 10 Jul 2021 12:14:14 -0700 Subject: [PATCH 0053/1250] Fixes for test_generated_columns_are_visible_in_datasette, refs #1391 --- tests/test_api.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/test_api.py b/tests/test_api.py index 3e8d02c8..0049d76d 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -2067,17 +2067,16 @@ def test_generated_columns_are_visible_in_datasette(): id INT GENERATED ALWAYS AS (json_extract(body, '$.number')) STORED, consideration INT GENERATED ALWAYS AS (json_extract(body, '$.string')) STORED ); - INSERT INTO generated_columns (body) VALUES ('{ - "number": 1, - "string": "This is a string" - }');""" + INSERT INTO generated_columns (body) VALUES ( + '{"number": 1, "string": "This is a string"}' + );""" } ) as client: - response = app_client.get("/generated/generated_columns.json?_shape=array") + response = client.get("/generated/generated_columns.json?_shape=array") assert response.json == [ { "rowid": 1, - "body": '{\n "number": 1,\n "string": "This is a string"\n}', + "body": '{"number": 1, "string": "This is a string"}', "id": 1, "consideration": "This is a string", } From 180c7a5328457aefdf847ada366e296fef4744f1 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 10 Jul 2021 16:37:30 -0700 Subject: [PATCH 0054/1250] --uds option for binding to Unix domain socket, closes #1388 --- datasette/cli.py | 7 +++++++ docs/datasette-serve-help.txt | 1 + docs/deploying.rst | 23 ++++++++++++++++++++++- tests/conftest.py | 20 +++++++++++++++++++- tests/test_cli.py | 1 + tests/test_cli_serve_server.py | 15 +++++++++++++++ 6 files changed, 65 insertions(+), 2 deletions(-) diff --git a/datasette/cli.py b/datasette/cli.py index 12ee92c3..09aebcc8 100644 --- a/datasette/cli.py +++ b/datasette/cli.py @@ -333,6 +333,10 @@ def uninstall(packages, yes): type=click.IntRange(0, 65535), help="Port for server, defaults to 8001. Use -p 0 to automatically assign an available port.", ) +@click.option( + "--uds", + help="Bind to a Unix domain socket", +) @click.option( "--reload", is_flag=True, @@ -428,6 +432,7 @@ def serve( immutable, host, port, + uds, reload, cors, sqlite_extensions, @@ -569,6 +574,8 @@ def serve( uvicorn_kwargs = dict( host=host, port=port, log_level="info", lifespan="on", workers=1 ) + if uds: + uvicorn_kwargs["uds"] = uds if ssl_keyfile: uvicorn_kwargs["ssl_keyfile"] = ssl_keyfile if ssl_certfile: diff --git a/docs/datasette-serve-help.txt b/docs/datasette-serve-help.txt index db51dd80..ec3f41a0 100644 --- a/docs/datasette-serve-help.txt +++ b/docs/datasette-serve-help.txt @@ -12,6 +12,7 @@ Options: machines. -p, --port INTEGER RANGE Port for server, defaults to 8001. Use -p 0 to automatically assign an available port. [0<=x<=65535] + --uds TEXT Bind to a Unix domain socket --reload Automatically reload if code or metadata change detected - useful for development --cors Enable CORS by serving Access-Control-Allow-Origin: * diff --git a/docs/deploying.rst b/docs/deploying.rst index 44ddd07b..f3680034 100644 --- a/docs/deploying.rst +++ b/docs/deploying.rst @@ -148,7 +148,6 @@ Here is an example of an `nginx `__ configuration file that http { server { listen 80; - location /my-datasette { proxy_pass http://127.0.0.1:8009/my-datasette; proxy_set_header X-Real-IP $remote_addr; @@ -157,6 +156,28 @@ Here is an example of an `nginx `__ configuration file that } } +You can also use the ``--uds`` option to Datasette to listen on a Unix domain socket instead of a port, configuring the nginx upstream proxy like this:: + + daemon off; + events { + worker_connections 1024; + } + http { + server { + listen 80; + location / { + proxy_pass http://datasette; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + } + upstream datasette { + server unix:/tmp/datasette.sock; + } + } + +Then run Datasette with ``datasette --uds /tmp/datasette.sock path/to/database.db``. + Apache proxy configuration -------------------------- diff --git a/tests/conftest.py b/tests/conftest.py index c6a3eee6..34a64efc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -131,7 +131,6 @@ def ds_localhost_https_server(tmp_path_factory): for blob in server_cert.cert_chain_pems: blob.write_to_path(path=certfile, append=True) ca.cert_pem.write_to_path(path=client_cert) - ds_proc = subprocess.Popen( [ "datasette", @@ -154,3 +153,22 @@ def ds_localhost_https_server(tmp_path_factory): yield ds_proc, client_cert # Shut it down at the end of the pytest session ds_proc.terminate() + + +@pytest.fixture(scope="session") +def ds_unix_domain_socket_server(tmp_path_factory): + socket_folder = tmp_path_factory.mktemp("uds") + uds = str(socket_folder / "datasette.sock") + ds_proc = subprocess.Popen( + ["datasette", "--memory", "--uds", uds], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + cwd=tempfile.gettempdir(), + ) + # Give the server time to start + time.sleep(1.5) + # Check it started successfully + assert not ds_proc.poll(), ds_proc.stdout.read().decode("utf-8") + yield ds_proc, uds + # Shut it down at the end of the pytest session + ds_proc.terminate() diff --git a/tests/test_cli.py b/tests/test_cli.py index e094ccb6..e31a305e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -132,6 +132,7 @@ def test_metadata_yaml(): immutable=[], host="127.0.0.1", port=8001, + uds=None, reload=False, cors=False, sqlite_extensions=[], diff --git a/tests/test_cli_serve_server.py b/tests/test_cli_serve_server.py index 6f5366d1..73439125 100644 --- a/tests/test_cli_serve_server.py +++ b/tests/test_cli_serve_server.py @@ -1,5 +1,6 @@ import httpx import pytest +import socket @pytest.mark.serial @@ -21,3 +22,17 @@ def test_serve_localhost_https(ds_localhost_https_server): "path": "/_memory", "tables": [], }.items() <= response.json().items() + + +@pytest.mark.serial +@pytest.mark.skipif(not hasattr(socket, "AF_UNIX"), reason="Requires socket.AF_UNIX support") +def test_serve_unix_domain_socket(ds_unix_domain_socket_server): + _, uds = ds_unix_domain_socket_server + transport = httpx.HTTPTransport(uds=uds) + client = httpx.Client(transport=transport) + response = client.get("http://localhost/_memory.json") + assert { + "database": "_memory", + "path": "/_memory", + "tables": [], + }.items() <= response.json().items() From de2a1063284834ff86cb8d7c693717609d0d647e Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 10 Jul 2021 16:46:49 -0700 Subject: [PATCH 0055/1250] Ran Black, refs #1388 --- tests/test_cli_serve_server.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_cli_serve_server.py b/tests/test_cli_serve_server.py index 73439125..1c31e2a3 100644 --- a/tests/test_cli_serve_server.py +++ b/tests/test_cli_serve_server.py @@ -25,7 +25,9 @@ def test_serve_localhost_https(ds_localhost_https_server): @pytest.mark.serial -@pytest.mark.skipif(not hasattr(socket, "AF_UNIX"), reason="Requires socket.AF_UNIX support") +@pytest.mark.skipif( + not hasattr(socket, "AF_UNIX"), reason="Requires socket.AF_UNIX support" +) def test_serve_unix_domain_socket(ds_unix_domain_socket_server): _, uds = ds_unix_domain_socket_server transport = httpx.HTTPTransport(uds=uds) From d792fc7cf5fde8fa748168e48c3183266a3a419f Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 10 Jul 2021 17:29:42 -0700 Subject: [PATCH 0056/1250] Simplified nginx config examples --- docs/deploying.rst | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/docs/deploying.rst b/docs/deploying.rst index f3680034..ce4acc9d 100644 --- a/docs/deploying.rst +++ b/docs/deploying.rst @@ -144,14 +144,12 @@ Here is an example of an `nginx `__ configuration file that events { worker_connections 1024; } - http { server { listen 80; location /my-datasette { - proxy_pass http://127.0.0.1:8009/my-datasette; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_pass http://127.0.0.1:8009/my-datasette; + proxy_set_header Host $host; } } } @@ -166,9 +164,8 @@ You can also use the ``--uds`` option to Datasette to listen on a Unix domain so server { listen 80; location / { - proxy_pass http://datasette; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_pass http://datasette; + proxy_set_header Host $host; } } upstream datasette { From f83c84fd51d144036924ae77d99f12b0a69e7e6f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 10 Jul 2021 18:36:18 -0700 Subject: [PATCH 0057/1250] Update asgiref requirement from <3.4.0,>=3.2.10 to >=3.2.10,<3.5.0 (#1386) Updates the requirements on [asgiref](https://github.com/django/asgiref) to permit the latest version. - [Release notes](https://github.com/django/asgiref/releases) - [Changelog](https://github.com/django/asgiref/blob/main/CHANGELOG.txt) - [Commits](https://github.com/django/asgiref/commits) --- updated-dependencies: - dependency-name: asgiref dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 8a651d32..2541be1f 100644 --- a/setup.py +++ b/setup.py @@ -44,7 +44,7 @@ setup( include_package_data=True, python_requires=">=3.6", install_requires=[ - "asgiref>=3.2.10,<3.4.0", + "asgiref>=3.2.10,<3.5.0", "click>=7.1.1,<8.1.0", "click-default-group~=1.2.2", "Jinja2>=2.10.3,<3.1.0", From 4054e96a3914e821d0880a40a7284aaa9db1eaaa Mon Sep 17 00:00:00 2001 From: Aslak Raanes Date: Tue, 13 Jul 2021 19:42:27 +0200 Subject: [PATCH 0058/1250] Update deploying.rst (#1392) Use same base url for Apache as in the example --- docs/deploying.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/deploying.rst b/docs/deploying.rst index ce4acc9d..3be36df4 100644 --- a/docs/deploying.rst +++ b/docs/deploying.rst @@ -185,7 +185,7 @@ For `Apache `__, you can use the ``ProxyPass`` direct Then add these directives to proxy traffic:: - ProxyPass /datasette-prefix/ http://127.0.0.1:8009/datasette-prefix/ + ProxyPass /my-datasette/ http://127.0.0.1:8009/my-datasette/ ProxyPreserveHost On The `ProxyPreserveHost On `__ directive ensures that the original ``Host:`` header from the incoming request is passed through to Datasette. Datasette needs this to correctly assemble links to other pages using the :ref:`datasette_absolute_url` method. From d71cac498138ddd86f18607b9043e70286ea884a Mon Sep 17 00:00:00 2001 From: Aslak Raanes Date: Tue, 13 Jul 2021 20:32:49 +0200 Subject: [PATCH 0059/1250] How to configure Unix domain sockets with Apache Example on how to use Unix domain socket option on Apache. Not testet. (Usually I would have used [`ProxyPassReverse`](https://httpd.apache.org/docs/current/mod/mod_proxy.html#proxypassreverse) in combination with `ProxyPass` , i.e. ```apache ProxyPass /my-datasette/ http://127.0.0.1:8009/my-datasette/ ProxyPassReverse /my-datasette/ http://127.0.0.1:8009/my-datasette/ ``` and ```apache ProxyPass /my-datasette/ unix:/tmp/datasette.sock|http://localhost/my-datasette/ ProxyPassReverse /my-datasette/ unix:/tmp/datasette.sock|http://localhost/my-datasette/ ``` --- docs/deploying.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/deploying.rst b/docs/deploying.rst index 3be36df4..c471fad6 100644 --- a/docs/deploying.rst +++ b/docs/deploying.rst @@ -188,4 +188,8 @@ Then add these directives to proxy traffic:: ProxyPass /my-datasette/ http://127.0.0.1:8009/my-datasette/ ProxyPreserveHost On +Using ``--uds`` you can use Unix domain sockets similiar to the Nginx example: + + ProxyPass /my-datasette/ unix:/tmp/datasette.sock|http://localhost/my-datasette/ + The `ProxyPreserveHost On `__ directive ensures that the original ``Host:`` header from the incoming request is passed through to Datasette. Datasette needs this to correctly assemble links to other pages using the :ref:`datasette_absolute_url` method. From 7f4c854db1ed8c15338e9cf42d2a3f0c92e3b7b2 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 13 Jul 2021 11:45:32 -0700 Subject: [PATCH 0060/1250] rST fix --- docs/deploying.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/deploying.rst b/docs/deploying.rst index c471fad6..366c9d61 100644 --- a/docs/deploying.rst +++ b/docs/deploying.rst @@ -188,7 +188,7 @@ Then add these directives to proxy traffic:: ProxyPass /my-datasette/ http://127.0.0.1:8009/my-datasette/ ProxyPreserveHost On -Using ``--uds`` you can use Unix domain sockets similiar to the Nginx example: +Using ``--uds`` you can use Unix domain sockets similiar to the nginx example:: ProxyPass /my-datasette/ unix:/tmp/datasette.sock|http://localhost/my-datasette/ From 2c4cd7141abb5115eff00ed7aef002af39d51989 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 13 Jul 2021 16:15:48 -0700 Subject: [PATCH 0061/1250] Consistently use /my-datasette in examples --- docs/deploying.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/deploying.rst b/docs/deploying.rst index 366c9d61..c3e3e123 100644 --- a/docs/deploying.rst +++ b/docs/deploying.rst @@ -163,8 +163,8 @@ You can also use the ``--uds`` option to Datasette to listen on a Unix domain so http { server { listen 80; - location / { - proxy_pass http://datasette; + location /my-datasette { + proxy_pass http://datasette/my-datasette; proxy_set_header Host $host; } } @@ -173,7 +173,7 @@ You can also use the ``--uds`` option to Datasette to listen on a Unix domain so } } -Then run Datasette with ``datasette --uds /tmp/datasette.sock path/to/database.db``. +Then run Datasette with ``datasette --uds /tmp/datasette.sock path/to/database.db --setting base_url /my-datasette/``. Apache proxy configuration -------------------------- From ba11ef27edd6981eeb26d7ecf5aa236707f5f8ce Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 13 Jul 2021 22:43:13 -0700 Subject: [PATCH 0062/1250] Clarify when to use systemd restart --- docs/deploying.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/deploying.rst b/docs/deploying.rst index c3e3e123..31d123e9 100644 --- a/docs/deploying.rst +++ b/docs/deploying.rst @@ -65,7 +65,7 @@ You can start the Datasette process running using the following:: sudo systemctl daemon-reload sudo systemctl start datasette.service -You may need to restart the Datasette service after making changes to its ``metadata.json`` configuration or the ``datasette.service`` file. You can do that using:: +You will need to restart the Datasette service after making changes to its ``metadata.json`` configuration or adding a new database file to that directory. You can do that using:: sudo systemctl restart datasette.service From a6c8e7fa4cffdeff84e9e755dcff4788fd6154b8 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 14 Jul 2021 17:05:18 -0700 Subject: [PATCH 0063/1250] Big performance boost for faceting, closes #1394 --- datasette/views/table.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/datasette/views/table.py b/datasette/views/table.py index 1bda7496..876a0c81 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -674,12 +674,11 @@ class TableView(RowTableShared): else: page_size = self.ds.page_size - sql_no_limit = ( - "select {select_all_columns} from {table_name} {where}{order_by}".format( + sql_no_order_no_limit = ( + "select {select_all_columns} from {table_name} {where}".format( select_all_columns=select_all_columns, table_name=escape_sqlite(table), where=where_clause, - order_by=order_by, ) ) sql = "select {select_specified_columns} from {table_name} {where}{order_by} limit {page_size}{offset}".format( @@ -736,7 +735,7 @@ class TableView(RowTableShared): self.ds, request, database, - sql=sql_no_limit, + sql=sql_no_order_no_limit, params=params, table=table, metadata=table_metadata, From 7ea678db228504004b8d32f813c838b1dcfd317a Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 14 Jul 2021 17:19:31 -0700 Subject: [PATCH 0064/1250] Warn about potential changes to get_metadata hook, refs #1384 --- docs/plugin_hooks.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index b687a6e7..6c2ad1e5 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -1150,6 +1150,9 @@ get_metadata(datasette, key, database, table) This hook is responsible for returning a dictionary corresponding to Datasette :ref:`metadata`. This function is passed the ``database``, ``table`` and ``key`` which were passed to the upstream internal request for metadata. Regardless, it is important to return a global metadata object, where ``"databases": []`` would be a top-level key. The dictionary returned here, will be merged with, and overwritten by, the contents of the physical ``metadata.yaml`` if one is present. +.. warning:: + The design of this plugin hook does not currently provide a mechanism for interacting with async code, and may change in the future. See `issue 1384 `__. + .. code-block:: python @hookimpl From e27dd7c12c2a6977560dbc0005e32c55d9d759f4 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 14 Jul 2021 17:32:33 -0700 Subject: [PATCH 0065/1250] Release 0.58 Refs #1365, #1371, #1377, #1384, #1387, #1388, #1389, #1394 --- datasette/version.py | 2 +- docs/changelog.rst | 19 +++++++++---------- docs/plugin_hooks.rst | 2 ++ 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/datasette/version.py b/datasette/version.py index e5a29931..0f94b605 100644 --- a/datasette/version.py +++ b/datasette/version.py @@ -1,2 +1,2 @@ -__version__ = "0.58a1" +__version__ = "0.58" __version_info__ = tuple(__version__.split(".")) diff --git a/docs/changelog.rst b/docs/changelog.rst index bcd8b987..201cf4b7 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,22 +4,21 @@ Changelog ========= -.. _v0_58a1: +.. _v0_58: -0.58a1 (2021-06-24) -------------------- +0.58 (2021-07-14) +----------------- +- New ``datasette --uds /tmp/datasette.sock`` option for binding Datasette to a Unix domain socket, see :ref:`proxy documentation ` (:issue:`1388`) +- ``"searchmode": "raw"`` table metadata option for defaulting a table to executing SQLite full-text search syntax without first escaping it, see :ref:`full_text_search_advanced_queries`. (:issue:`1389`) +- New plugin hook: :ref:`plugin_hook_get_metadata`, for returning custom metadata for an instance, database or table. Thanks, Brandon Roberts! (:issue:`1384`) - New plugin hook: :ref:`plugin_hook_skip_csrf`, for opting out of CSRF protection based on the incoming request. (:issue:`1377`) +- The :ref:`menu_links() `, :ref:`table_actions() ` and :ref:`database_actions() ` plugin hooks all gained a new optional ``request`` argument providing access to the current request. (:issue:`1371`) +- Major performance improvement for Datasette faceting. (:issue:`1394`) +- Improved documentation for :ref:`deploying_proxy` to recommend using ``ProxyPreservehost On`` with Apache. (:issue:`1387`) - ``POST`` requests to endpoints that do not support that HTTP verb now return a 405 error. - ``db.path`` can now be provided as a ``pathlib.Path`` object, useful when writing unit tests for plugins. Thanks, Chris Amico. (:issue:`1365`) -.. _v0_58a0: - -0.58a0 (2021-06-09) -------------------- - -- The :ref:`menu_links() `, :ref:`table_actions() ` and :ref:`database_actions() ` plugin hooks all gained a new optional ``request`` argument providing access to the current request. (:issue:`1371`) - .. _v0_57_1: 0.57.1 (2021-06-08) diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 6c2ad1e5..63258e2f 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -1130,6 +1130,8 @@ This example will disable CSRF protection for that specific URL path: If any of the currently active ``skip_csrf()`` plugin hooks return ``True``, CSRF protection will be skipped for the request. +.. _plugin_hook_get_metadata: + get_metadata(datasette, key, database, table) --------------------------------------------- From 084cfe1e00e1a4c0515390a513aca286eeea20c2 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 14 Jul 2021 18:00:39 -0700 Subject: [PATCH 0066/1250] Removed out-of-date datasette serve help from README --- README.md | 33 --------------------------------- 1 file changed, 33 deletions(-) diff --git a/README.md b/README.md index 5682f59e..55160afe 100644 --- a/README.md +++ b/README.md @@ -53,39 +53,6 @@ Now visiting http://localhost:8001/History/downloads will show you a web interfa ![Downloads table rendered by datasette](https://static.simonwillison.net/static/2017/datasette-downloads.png) -## datasette serve options - - Usage: datasette serve [OPTIONS] [FILES]... - - Serve up specified SQLite database files with a web UI - - Options: - -i, --immutable PATH Database files to open in immutable mode - -h, --host TEXT Host for server. Defaults to 127.0.0.1 which means - only connections from the local machine will be - allowed. Use 0.0.0.0 to listen to all IPs and - allow access from other machines. - -p, --port INTEGER Port for server, defaults to 8001 - --reload Automatically reload if code or metadata change - detected - useful for development - --cors Enable CORS by serving Access-Control-Allow- - Origin: * - --load-extension PATH Path to a SQLite extension to load - --inspect-file TEXT Path to JSON file created using "datasette - inspect" - -m, --metadata FILENAME Path to JSON file containing license/source - metadata - --template-dir DIRECTORY Path to directory containing custom templates - --plugins-dir DIRECTORY Path to directory containing custom plugins - --static STATIC MOUNT mountpoint:path-to-directory for serving static - files - --memory Make /_memory database available - --config CONFIG Set config option using configname:value - docs.datasette.io/en/stable/config.html - --version-note TEXT Additional note to show on /-/versions - --help-config Show available config options - --help Show this message and exit. - ## metadata.json If you want to include licensing and source information in the generated datasette website you can do so using a JSON file that looks something like this: From 721a8d3cd4937f888efd2b52d5a61f0e25b484e1 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 14 Jul 2021 18:51:36 -0700 Subject: [PATCH 0067/1250] Hopeful fix for publish problem in #1396 --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 727f9933..54e582f0 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -115,5 +115,5 @@ jobs: -t $REPO:${GITHUB_REF#refs/tags/} \ --build-arg VERSION=${GITHUB_REF#refs/tags/} . docker tag $REPO:${GITHUB_REF#refs/tags/} $REPO:latest - docker push $REPO:${VERSION_TAG} + docker push $REPO:${GITHUB_REF#refs/tags/} docker push $REPO:latest From dd5ee8e66882c94343cd3f71920878c6cfd0da41 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 15 Jul 2021 23:26:06 -0700 Subject: [PATCH 0068/1250] Removed some unused imports I found these with: flake8 datasette | grep unus --- datasette/app.py | 1 - datasette/default_magic_parameters.py | 1 - datasette/facets.py | 2 -- datasette/utils/__init__.py | 4 +--- datasette/utils/asgi.py | 2 -- datasette/views/base.py | 1 - datasette/views/index.py | 2 +- setup.py | 2 -- 8 files changed, 2 insertions(+), 13 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 0b909968..5976d8b8 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -58,7 +58,6 @@ from .utils import ( parse_metadata, resolve_env_secrets, to_css_class, - HASH_LENGTH, ) from .utils.asgi import ( AsgiLifespan, diff --git a/datasette/default_magic_parameters.py b/datasette/default_magic_parameters.py index 0f8f397e..19382207 100644 --- a/datasette/default_magic_parameters.py +++ b/datasette/default_magic_parameters.py @@ -1,5 +1,4 @@ from datasette import hookimpl -from datasette.utils import escape_fts import datetime import os import time diff --git a/datasette/facets.py b/datasette/facets.py index 250734fd..f74e2d01 100644 --- a/datasette/facets.py +++ b/datasette/facets.py @@ -1,6 +1,5 @@ import json import urllib -import re from datasette import hookimpl from datasette.database import QueryInterrupted from datasette.utils import ( @@ -8,7 +7,6 @@ from datasette.utils import ( path_with_added_args, path_with_removed_args, detect_json1, - InvalidSql, sqlite3, ) diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py index 1e193862..aec5a55b 100644 --- a/datasette/utils/__init__.py +++ b/datasette/utils/__init__.py @@ -5,7 +5,6 @@ from collections import OrderedDict, namedtuple, Counter import base64 import hashlib import inspect -import itertools import json import markupsafe import mergedeep @@ -17,10 +16,9 @@ import time import types import shutil import urllib -import numbers import yaml from .shutil_backport import copytree -from .sqlite import sqlite3, sqlite_version, supports_table_xinfo +from .sqlite import sqlite3, supports_table_xinfo # From https://www.sqlite.org/lang_keywords.html diff --git a/datasette/utils/asgi.py b/datasette/utils/asgi.py index 63bf4926..5fa03b0a 100644 --- a/datasette/utils/asgi.py +++ b/datasette/utils/asgi.py @@ -3,9 +3,7 @@ from datasette.utils import MultiParams from mimetypes import guess_type from urllib.parse import parse_qs, urlunparse, parse_qsl from pathlib import Path -from html import escape from http.cookies import SimpleCookie, Morsel -import re import aiofiles import aiofiles.os diff --git a/datasette/views/base.py b/datasette/views/base.py index a87a0e77..cd584899 100644 --- a/datasette/views/base.py +++ b/datasette/views/base.py @@ -9,7 +9,6 @@ import urllib import pint from datasette import __version__ -from datasette.plugins import pm from datasette.database import QueryInterrupted from datasette.utils import ( await_me_maybe, diff --git a/datasette/views/index.py b/datasette/views/index.py index 8ac117a6..e37643f9 100644 --- a/datasette/views/index.py +++ b/datasette/views/index.py @@ -2,7 +2,7 @@ import hashlib import json from datasette.utils import check_visibility, CustomJSONEncoder -from datasette.utils.asgi import Response, Forbidden +from datasette.utils.asgi import Response from datasette.version import __version__ from .base import BaseView diff --git a/setup.py b/setup.py index 2541be1f..cfc1e484 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,5 @@ -from re import VERBOSE from setuptools import setup, find_packages import os -import sys def get_long_description(): From c00f29affcafce8314366852ba1a0f5a7dd25690 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 16 Jul 2021 12:44:58 -0700 Subject: [PATCH 0069/1250] Fix for race condition in refresh_schemas(), closes #1231 --- datasette/app.py | 7 +++++++ datasette/utils/internal_db.py | 10 +++++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 5976d8b8..5f348cb5 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -224,6 +224,7 @@ class Datasette: self.inspect_data = inspect_data self.immutables = set(immutables or []) self.databases = collections.OrderedDict() + self._refresh_schemas_lock = asyncio.Lock() self.crossdb = crossdb if memory or crossdb or not self.files: self.add_database(Database(self, is_memory=True), name="_memory") @@ -332,6 +333,12 @@ class Datasette: self.client = DatasetteClient(self) async def refresh_schemas(self): + if self._refresh_schemas_lock.locked(): + return + async with self._refresh_schemas_lock: + await self._refresh_schemas() + + async def _refresh_schemas(self): internal_db = self.databases["_internal"] if not self.internal_db_created: await init_internal_db(internal_db) diff --git a/datasette/utils/internal_db.py b/datasette/utils/internal_db.py index e92625d5..40fe719e 100644 --- a/datasette/utils/internal_db.py +++ b/datasette/utils/internal_db.py @@ -5,7 +5,7 @@ async def init_internal_db(db): await db.execute_write( textwrap.dedent( """ - CREATE TABLE databases ( + CREATE TABLE IF NOT EXISTS databases ( database_name TEXT PRIMARY KEY, path TEXT, is_memory INTEGER, @@ -18,7 +18,7 @@ async def init_internal_db(db): await db.execute_write( textwrap.dedent( """ - CREATE TABLE tables ( + CREATE TABLE IF NOT EXISTS tables ( database_name TEXT, table_name TEXT, rootpage INTEGER, @@ -33,7 +33,7 @@ async def init_internal_db(db): await db.execute_write( textwrap.dedent( """ - CREATE TABLE columns ( + CREATE TABLE IF NOT EXISTS columns ( database_name TEXT, table_name TEXT, cid INTEGER, @@ -54,7 +54,7 @@ async def init_internal_db(db): await db.execute_write( textwrap.dedent( """ - CREATE TABLE indexes ( + CREATE TABLE IF NOT EXISTS indexes ( database_name TEXT, table_name TEXT, seq INTEGER, @@ -73,7 +73,7 @@ async def init_internal_db(db): await db.execute_write( textwrap.dedent( """ - CREATE TABLE foreign_keys ( + CREATE TABLE IF NOT EXISTS foreign_keys ( database_name TEXT, table_name TEXT, id INTEGER, From c73af5dd72305f6a01ea94a2c76d52e5e26de38b Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 16 Jul 2021 12:46:13 -0700 Subject: [PATCH 0070/1250] Release 0.58.1 Refs #1231, #1396 --- datasette/version.py | 2 +- docs/changelog.rst | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/datasette/version.py b/datasette/version.py index 0f94b605..1b7b7350 100644 --- a/datasette/version.py +++ b/datasette/version.py @@ -1,2 +1,2 @@ -__version__ = "0.58" +__version__ = "0.58.1" __version_info__ = tuple(__version__.split(".")) diff --git a/docs/changelog.rst b/docs/changelog.rst index 201cf4b7..6a951935 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,13 @@ Changelog ========= +.. _v0_58_1: + +0.58.1 (2021-07-16) +------------------- + +- Fix for an intermittent race condition caused by the ``refresh_schemas()`` internal function. (:issue:`1231`) + .. _v0_58: 0.58 (2021-07-14) From 6f1731f3055a5119cc393c118937d749405a1617 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 23 Jul 2021 12:38:09 -0700 Subject: [PATCH 0071/1250] Updated cookiecutter installation link --- docs/writing_plugins.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/writing_plugins.rst b/docs/writing_plugins.rst index 6afee1c3..bd60a4b6 100644 --- a/docs/writing_plugins.rst +++ b/docs/writing_plugins.rst @@ -41,7 +41,7 @@ Plugins that can be installed should be written as Python packages using a ``set The quickest way to start writing one an installable plugin is to use the `datasette-plugin `__ cookiecutter template. This creates a new plugin structure for you complete with an example test and GitHub Actions workflows for testing and publishing your plugin. -`Install cookiecutter `__ and then run this command to start building a plugin using the template:: +`Install cookiecutter `__ and then run this command to start building a plugin using the template:: cookiecutter gh:simonw/datasette-plugin From eccfeb0871dd4bc27870faf64f80ac68e5b6bc0d Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 26 Jul 2021 16:16:46 -0700 Subject: [PATCH 0072/1250] register_routes() plugin hook datasette argument, closes #1404 --- datasette/app.py | 2 +- datasette/hookspecs.py | 2 +- docs/plugin_hooks.rst | 7 +++++-- tests/fixtures.py | 1 + tests/plugins/my_plugin_2.py | 10 ++++++++++ tests/test_plugins.py | 19 +++++++++++++++++++ 6 files changed, 37 insertions(+), 4 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 5f348cb5..2596ca50 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -960,7 +960,7 @@ class Datasette: """Returns an ASGI app function that serves the whole of Datasette""" routes = [] - for routes_to_add in pm.hook.register_routes(): + for routes_to_add in pm.hook.register_routes(datasette=self): for regex, view_fn in routes_to_add: routes.append((regex, wrap_view(view_fn, self))) diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index 07b2f5ba..3ef0d4f5 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -75,7 +75,7 @@ def register_facet_classes(): @hookspec -def register_routes(): +def register_routes(datasette): """Register URL routes: return a list of (regex, view_function) pairs""" diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 63258e2f..4700763c 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -529,8 +529,11 @@ Examples: `datasette-atom `_, `dataset .. _plugin_register_routes: -register_routes() ------------------ +register_routes(datasette) +-------------------------- + +``datasette`` - :ref:`internals_datasette` + You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)`` Register additional view functions to execute for specified URL routes. diff --git a/tests/fixtures.py b/tests/fixtures.py index dce94876..93b7dce2 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -70,6 +70,7 @@ EXPECTED_PLUGINS = [ "extra_template_vars", "menu_links", "permission_allowed", + "register_routes", "render_cell", "startup", "table_actions", diff --git a/tests/plugins/my_plugin_2.py b/tests/plugins/my_plugin_2.py index b70372f3..f7a3f1c0 100644 --- a/tests/plugins/my_plugin_2.py +++ b/tests/plugins/my_plugin_2.py @@ -1,4 +1,5 @@ from datasette import hookimpl +from datasette.utils.asgi import Response from functools import wraps import markupsafe import json @@ -167,3 +168,12 @@ def table_actions(datasette, database, table, actor, request): return [{"href": datasette.urls.instance(), "label": label}] return inner + + +@hookimpl +def register_routes(datasette): + config = datasette.plugin_config("register-route-demo") + if not config: + return + path = config["path"] + return [(r"/{}/$".format(path), lambda: Response.text(path.upper()))] diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 7a626ce5..0c01b7ae 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -648,6 +648,25 @@ def test_hook_register_routes(app_client, path, body): assert body == response.text +@pytest.mark.parametrize("configured_path", ("path1", "path2")) +def test_hook_register_routes_with_datasette(configured_path): + with make_app_client( + metadata={ + "plugins": { + "register-route-demo": { + "path": configured_path, + } + } + } + ) as client: + response = client.get(f"/{configured_path}/") + assert response.status == 200 + assert configured_path.upper() == response.text + # Other one should 404 + other_path = [p for p in ("path1", "path2") if configured_path != p][0] + assert client.get(f"/{other_path}/").status == 404 + + def test_hook_register_routes_post(app_client): response = app_client.post("/post/", {"this is": "post data"}, csrftoken_from=True) assert 200 == response.status From 121e10c29c5b412fddf0326939f1fe46c3ad9d4a Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 29 Jul 2021 16:30:12 -0700 Subject: [PATCH 0073/1250] Doumentation and test for utils.parse_metadata(), closes #1405 --- docs/internals.rst | 18 ++++++++++++++++++ tests/test_utils.py | 16 ++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/docs/internals.rst b/docs/internals.rst index 98df998a..1e41cacd 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -795,3 +795,21 @@ By default all actors are denied access to the ``view-database`` permission for Plugins can access this database by calling ``db = datasette.get_database("_internal")`` and then executing queries using the :ref:`Database API `. You can explore an example of this database by `signing in as root `__ to the ``latest.datasette.io`` demo instance and then navigating to `latest.datasette.io/_internal `__. + +.. _internals_utils: + +The datasette.utils module +========================== + +The ``datasette.utils`` module contains various utility functions used by Datasette. As a general rule you should consider anything in this module to be unstable - functions and classes here could change without warning or be removed entirely between Datasette releases, without being mentioned in the release notes. + +The exception to this rule is anythang that is documented here. If you find a need for an undocumented utility function in your own work, consider `opening an issue `__ requesting that the function you are using be upgraded to documented and supported status. + +.. _internals_utils_parse_metadata: + +parse_metadata(content) +----------------------- + +This function accepts a string containing either JSON or YAML, expected to be of the format described in :ref:`metadata`. It returns a nested Python dictionary representing the parsed data from that string. + +If the metadata cannot be parsed as either JSON or YAML the function will raise a ``utils.BadMetadataError`` exception. diff --git a/tests/test_utils.py b/tests/test_utils.py index be3daf2e..97b70ee5 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -610,3 +610,19 @@ async def test_initial_path_for_datasette(tmp_path_factory, dbs, expected_path): ) path = await utils.initial_path_for_datasette(datasette) assert path == expected_path + + +@pytest.mark.parametrize( + "content,expected", + ( + ("title: Hello", {"title": "Hello"}), + ('{"title": "Hello"}', {"title": "Hello"}), + ("{{ this }} is {{ bad }}", None), + ), +) +def test_parse_metadata(content, expected): + if expected is None: + with pytest.raises(utils.BadMetadataError): + utils.parse_metadata(content) + else: + assert utils.parse_metadata(content) == expected From 2b1c535c128984cc0ee2a097ecaa3ab638ae2a5b Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 29 Jul 2021 17:44:16 -0700 Subject: [PATCH 0074/1250] pytest.mark.serial for any test using isolated_filesystem(), refs #1406 --- tests/test_package.py | 3 ++- tests/test_publish_cloudrun.py | 7 +++++++ tests/test_publish_heroku.py | 5 +++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/test_package.py b/tests/test_package.py index bb939643..76693d2f 100644 --- a/tests/test_package.py +++ b/tests/test_package.py @@ -2,7 +2,7 @@ from click.testing import CliRunner from datasette import cli from unittest import mock import pathlib -import json +import pytest class CaptureDockerfile: @@ -24,6 +24,7 @@ CMD datasette serve --host 0.0.0.0 -i test.db --cors --inspect-file inspect-data """.strip() +@pytest.mark.serial @mock.patch("shutil.which") @mock.patch("datasette.cli.call") def test_package(mock_call, mock_which): diff --git a/tests/test_publish_cloudrun.py b/tests/test_publish_cloudrun.py index 7881ebae..826860d7 100644 --- a/tests/test_publish_cloudrun.py +++ b/tests/test_publish_cloudrun.py @@ -6,6 +6,7 @@ import pytest import textwrap +@pytest.mark.serial @mock.patch("shutil.which") def test_publish_cloudrun_requires_gcloud(mock_which): mock_which.return_value = False @@ -27,6 +28,7 @@ def test_publish_cloudrun_invalid_database(mock_which): assert "Path 'woop.db' does not exist" in result.output +@pytest.mark.serial @mock.patch("shutil.which") @mock.patch("datasette.publish.cloudrun.check_output") @mock.patch("datasette.publish.cloudrun.check_call") @@ -75,6 +77,7 @@ Service name: input-service ) +@pytest.mark.serial @mock.patch("shutil.which") @mock.patch("datasette.publish.cloudrun.check_output") @mock.patch("datasette.publish.cloudrun.check_call") @@ -103,6 +106,7 @@ def test_publish_cloudrun(mock_call, mock_output, mock_which): ) +@pytest.mark.serial @mock.patch("shutil.which") @mock.patch("datasette.publish.cloudrun.check_output") @mock.patch("datasette.publish.cloudrun.check_call") @@ -147,6 +151,7 @@ def test_publish_cloudrun_memory( ) +@pytest.mark.serial @mock.patch("shutil.which") @mock.patch("datasette.publish.cloudrun.check_output") @mock.patch("datasette.publish.cloudrun.check_call") @@ -225,6 +230,7 @@ def test_publish_cloudrun_plugin_secrets(mock_call, mock_output, mock_which): } == json.loads(metadata) +@pytest.mark.serial @mock.patch("shutil.which") @mock.patch("datasette.publish.cloudrun.check_output") @mock.patch("datasette.publish.cloudrun.check_call") @@ -280,6 +286,7 @@ def test_publish_cloudrun_apt_get_install(mock_call, mock_output, mock_which): assert expected == dockerfile +@pytest.mark.serial @mock.patch("shutil.which") @mock.patch("datasette.publish.cloudrun.check_output") @mock.patch("datasette.publish.cloudrun.check_call") diff --git a/tests/test_publish_heroku.py b/tests/test_publish_heroku.py index c011ab43..acbdafeb 100644 --- a/tests/test_publish_heroku.py +++ b/tests/test_publish_heroku.py @@ -1,8 +1,10 @@ from click.testing import CliRunner from datasette import cli from unittest import mock +import pytest +@pytest.mark.serial @mock.patch("shutil.which") def test_publish_heroku_requires_heroku(mock_which): mock_which.return_value = False @@ -15,6 +17,7 @@ def test_publish_heroku_requires_heroku(mock_which): assert "Publishing to Heroku requires heroku" in result.output +@pytest.mark.serial @mock.patch("shutil.which") @mock.patch("datasette.publish.heroku.check_output") @mock.patch("datasette.publish.heroku.call") @@ -44,6 +47,7 @@ def test_publish_heroku_invalid_database(mock_which): assert "Path 'woop.db' does not exist" in result.output +@pytest.mark.serial @mock.patch("shutil.which") @mock.patch("datasette.publish.heroku.check_output") @mock.patch("datasette.publish.heroku.call") @@ -79,6 +83,7 @@ def test_publish_heroku(mock_call, mock_check_output, mock_which): ) +@pytest.mark.serial @mock.patch("shutil.which") @mock.patch("datasette.publish.heroku.check_output") @mock.patch("datasette.publish.heroku.call") From 74b775e20f870de921ca3c09a75fe69e1c199fc7 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 29 Jul 2021 17:50:45 -0700 Subject: [PATCH 0075/1250] Use consistent pattern for test before deploy, refs #1406 --- .github/workflows/deploy-latest.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy-latest.yml b/.github/workflows/deploy-latest.yml index d9f23f7d..849adb40 100644 --- a/.github/workflows/deploy-latest.yml +++ b/.github/workflows/deploy-latest.yml @@ -29,7 +29,9 @@ jobs: python -m pip install -e .[docs] python -m pip install sphinx-to-sqlite==0.1a1 - name: Run tests - run: pytest + run: | + pytest -n auto -m "not serial" + pytest -m "serial" - name: Build fixtures.db run: python tests/fixtures.py fixtures.db fixtures.json plugins --extra-db-filename extra_database.db - name: Build docs.db From e55cd9dc3f2d920d5cf6d8581ce49937a6ccc44d Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 29 Jul 2021 18:16:58 -0700 Subject: [PATCH 0076/1250] Try passing a directory to isolated_filesystem(), refs #1406 --- tests/test_package.py | 10 ++++----- tests/test_publish_cloudrun.py | 39 ++++++++++++++++------------------ tests/test_publish_heroku.py | 25 +++++++++++----------- 3 files changed, 34 insertions(+), 40 deletions(-) diff --git a/tests/test_package.py b/tests/test_package.py index 76693d2f..a72eef94 100644 --- a/tests/test_package.py +++ b/tests/test_package.py @@ -2,7 +2,6 @@ from click.testing import CliRunner from datasette import cli from unittest import mock import pathlib -import pytest class CaptureDockerfile: @@ -24,15 +23,14 @@ CMD datasette serve --host 0.0.0.0 -i test.db --cors --inspect-file inspect-data """.strip() -@pytest.mark.serial @mock.patch("shutil.which") @mock.patch("datasette.cli.call") -def test_package(mock_call, mock_which): +def test_package(mock_call, mock_which, tmp_path_factory): mock_which.return_value = True runner = CliRunner() capture = CaptureDockerfile() mock_call.side_effect = capture - with runner.isolated_filesystem(): + with runner.isolated_filesystem(tmp_path_factory.mktemp("runner")): with open("test.db", "w") as fp: fp.write("data") result = runner.invoke(cli.cli, ["package", "test.db", "--secret", "sekrit"]) @@ -43,12 +41,12 @@ def test_package(mock_call, mock_which): @mock.patch("shutil.which") @mock.patch("datasette.cli.call") -def test_package_with_port(mock_call, mock_which): +def test_package_with_port(mock_call, mock_which, tmp_path_factory): mock_which.return_value = True capture = CaptureDockerfile() mock_call.side_effect = capture runner = CliRunner() - with runner.isolated_filesystem(): + with runner.isolated_filesystem(tmp_path_factory.mktemp("runner")): with open("test.db", "w") as fp: fp.write("data") result = runner.invoke( diff --git a/tests/test_publish_cloudrun.py b/tests/test_publish_cloudrun.py index 826860d7..d91b7646 100644 --- a/tests/test_publish_cloudrun.py +++ b/tests/test_publish_cloudrun.py @@ -6,12 +6,11 @@ import pytest import textwrap -@pytest.mark.serial @mock.patch("shutil.which") -def test_publish_cloudrun_requires_gcloud(mock_which): +def test_publish_cloudrun_requires_gcloud(mock_which, tmp_path_factory): mock_which.return_value = False runner = CliRunner() - with runner.isolated_filesystem(): + with runner.isolated_filesystem(tmp_path_factory.mktemp("runner")): with open("test.db", "w") as fp: fp.write("data") result = runner.invoke(cli.cli, ["publish", "cloudrun", "test.db"]) @@ -28,13 +27,12 @@ def test_publish_cloudrun_invalid_database(mock_which): assert "Path 'woop.db' does not exist" in result.output -@pytest.mark.serial @mock.patch("shutil.which") @mock.patch("datasette.publish.cloudrun.check_output") @mock.patch("datasette.publish.cloudrun.check_call") @mock.patch("datasette.publish.cloudrun.get_existing_services") def test_publish_cloudrun_prompts_for_service( - mock_get_existing_services, mock_call, mock_output, mock_which + mock_get_existing_services, mock_call, mock_output, mock_which, tmp_path_factory ): mock_get_existing_services.return_value = [ {"name": "existing", "created": "2019-01-01", "url": "http://www.example.com/"} @@ -42,7 +40,7 @@ def test_publish_cloudrun_prompts_for_service( mock_output.return_value = "myproject" mock_which.return_value = True runner = CliRunner() - with runner.isolated_filesystem(): + with runner.isolated_filesystem(tmp_path_factory.mktemp("runner")): with open("test.db", "w") as fp: fp.write("data") result = runner.invoke( @@ -77,15 +75,14 @@ Service name: input-service ) -@pytest.mark.serial @mock.patch("shutil.which") @mock.patch("datasette.publish.cloudrun.check_output") @mock.patch("datasette.publish.cloudrun.check_call") -def test_publish_cloudrun(mock_call, mock_output, mock_which): +def test_publish_cloudrun(mock_call, mock_output, mock_which, tmp_path_factory): mock_output.return_value = "myproject" mock_which.return_value = True runner = CliRunner() - with runner.isolated_filesystem(): + with runner.isolated_filesystem(tmp_path_factory.mktemp("runner")): with open("test.db", "w") as fp: fp.write("data") result = runner.invoke( @@ -106,7 +103,6 @@ def test_publish_cloudrun(mock_call, mock_output, mock_which): ) -@pytest.mark.serial @mock.patch("shutil.which") @mock.patch("datasette.publish.cloudrun.check_output") @mock.patch("datasette.publish.cloudrun.check_call") @@ -121,12 +117,12 @@ def test_publish_cloudrun(mock_call, mock_output, mock_which): ], ) def test_publish_cloudrun_memory( - mock_call, mock_output, mock_which, memory, should_fail + mock_call, mock_output, mock_which, memory, should_fail, tmp_path_factory ): mock_output.return_value = "myproject" mock_which.return_value = True runner = CliRunner() - with runner.isolated_filesystem(): + with runner.isolated_filesystem(tmp_path_factory.mktemp("runner")): with open("test.db", "w") as fp: fp.write("data") result = runner.invoke( @@ -151,16 +147,17 @@ def test_publish_cloudrun_memory( ) -@pytest.mark.serial @mock.patch("shutil.which") @mock.patch("datasette.publish.cloudrun.check_output") @mock.patch("datasette.publish.cloudrun.check_call") -def test_publish_cloudrun_plugin_secrets(mock_call, mock_output, mock_which): +def test_publish_cloudrun_plugin_secrets( + mock_call, mock_output, mock_which, tmp_path_factory +): mock_which.return_value = True mock_output.return_value = "myproject" runner = CliRunner() - with runner.isolated_filesystem(): + with runner.isolated_filesystem(tmp_path_factory.mktemp("runner")): with open("test.db", "w") as fp: fp.write("data") with open("metadata.yml", "w") as fp: @@ -230,16 +227,17 @@ def test_publish_cloudrun_plugin_secrets(mock_call, mock_output, mock_which): } == json.loads(metadata) -@pytest.mark.serial @mock.patch("shutil.which") @mock.patch("datasette.publish.cloudrun.check_output") @mock.patch("datasette.publish.cloudrun.check_call") -def test_publish_cloudrun_apt_get_install(mock_call, mock_output, mock_which): +def test_publish_cloudrun_apt_get_install( + mock_call, mock_output, mock_which, tmp_path_factory +): mock_which.return_value = True mock_output.return_value = "myproject" runner = CliRunner() - with runner.isolated_filesystem(): + with runner.isolated_filesystem(tmp_path_factory.mktemp("runner")): with open("test.db", "w") as fp: fp.write("data") result = runner.invoke( @@ -286,7 +284,6 @@ def test_publish_cloudrun_apt_get_install(mock_call, mock_output, mock_which): assert expected == dockerfile -@pytest.mark.serial @mock.patch("shutil.which") @mock.patch("datasette.publish.cloudrun.check_output") @mock.patch("datasette.publish.cloudrun.check_call") @@ -302,13 +299,13 @@ def test_publish_cloudrun_apt_get_install(mock_call, mock_output, mock_which): ], ) def test_publish_cloudrun_extra_options( - mock_call, mock_output, mock_which, extra_options, expected + mock_call, mock_output, mock_which, extra_options, expected, tmp_path_factory ): mock_which.return_value = True mock_output.return_value = "myproject" runner = CliRunner() - with runner.isolated_filesystem(): + with runner.isolated_filesystem(tmp_path_factory.mktemp("runner")): with open("test.db", "w") as fp: fp.write("data") result = runner.invoke( diff --git a/tests/test_publish_heroku.py b/tests/test_publish_heroku.py index acbdafeb..a591bcf8 100644 --- a/tests/test_publish_heroku.py +++ b/tests/test_publish_heroku.py @@ -1,15 +1,13 @@ from click.testing import CliRunner from datasette import cli from unittest import mock -import pytest -@pytest.mark.serial @mock.patch("shutil.which") -def test_publish_heroku_requires_heroku(mock_which): +def test_publish_heroku_requires_heroku(mock_which, tmp_path_factory): mock_which.return_value = False runner = CliRunner() - with runner.isolated_filesystem(): + with runner.isolated_filesystem(tmp_path_factory.mktemp("runner")): with open("test.db", "w") as fp: fp.write("data") result = runner.invoke(cli.cli, ["publish", "heroku", "test.db"]) @@ -17,15 +15,16 @@ def test_publish_heroku_requires_heroku(mock_which): assert "Publishing to Heroku requires heroku" in result.output -@pytest.mark.serial @mock.patch("shutil.which") @mock.patch("datasette.publish.heroku.check_output") @mock.patch("datasette.publish.heroku.call") -def test_publish_heroku_installs_plugin(mock_call, mock_check_output, mock_which): +def test_publish_heroku_installs_plugin( + mock_call, mock_check_output, mock_which, tmp_path_factory +): mock_which.return_value = True mock_check_output.side_effect = lambda s: {"['heroku', 'plugins']": b""}[repr(s)] runner = CliRunner() - with runner.isolated_filesystem(): + with runner.isolated_filesystem(tmp_path_factory.mktemp("runner")): with open("t.db", "w") as fp: fp.write("data") result = runner.invoke(cli.cli, ["publish", "heroku", "t.db"], input="y\n") @@ -47,11 +46,10 @@ def test_publish_heroku_invalid_database(mock_which): assert "Path 'woop.db' does not exist" in result.output -@pytest.mark.serial @mock.patch("shutil.which") @mock.patch("datasette.publish.heroku.check_output") @mock.patch("datasette.publish.heroku.call") -def test_publish_heroku(mock_call, mock_check_output, mock_which): +def test_publish_heroku(mock_call, mock_check_output, mock_which, tmp_path_factory): mock_which.return_value = True mock_check_output.side_effect = lambda s: { "['heroku', 'plugins']": b"heroku-builds", @@ -59,7 +57,7 @@ def test_publish_heroku(mock_call, mock_check_output, mock_which): "['heroku', 'apps:create', 'datasette', '--json']": b'{"name": "f"}', }[repr(s)] runner = CliRunner() - with runner.isolated_filesystem(): + with runner.isolated_filesystem(tmp_path_factory.mktemp("runner")): with open("test.db", "w") as fp: fp.write("data") result = runner.invoke( @@ -83,11 +81,12 @@ def test_publish_heroku(mock_call, mock_check_output, mock_which): ) -@pytest.mark.serial @mock.patch("shutil.which") @mock.patch("datasette.publish.heroku.check_output") @mock.patch("datasette.publish.heroku.call") -def test_publish_heroku_plugin_secrets(mock_call, mock_check_output, mock_which): +def test_publish_heroku_plugin_secrets( + mock_call, mock_check_output, mock_which, tmp_path_factory +): mock_which.return_value = True mock_check_output.side_effect = lambda s: { "['heroku', 'plugins']": b"heroku-builds", @@ -95,7 +94,7 @@ def test_publish_heroku_plugin_secrets(mock_call, mock_check_output, mock_which) "['heroku', 'apps:create', 'datasette', '--json']": b'{"name": "f"}', }[repr(s)] runner = CliRunner() - with runner.isolated_filesystem(): + with runner.isolated_filesystem(tmp_path_factory.mktemp("runner")): with open("test.db", "w") as fp: fp.write("data") result = runner.invoke( From b46856391de5a819a85d1dd970428cbc702be94a Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 29 Jul 2021 17:44:16 -0700 Subject: [PATCH 0077/1250] pytest.mark.serial for any test using isolated_filesystem(), refs #1406 --- tests/test_package.py | 2 ++ tests/test_publish_cloudrun.py | 7 +++++++ tests/test_publish_heroku.py | 5 +++++ 3 files changed, 14 insertions(+) diff --git a/tests/test_package.py b/tests/test_package.py index a72eef94..98e701bf 100644 --- a/tests/test_package.py +++ b/tests/test_package.py @@ -2,6 +2,7 @@ from click.testing import CliRunner from datasette import cli from unittest import mock import pathlib +import pytest class CaptureDockerfile: @@ -23,6 +24,7 @@ CMD datasette serve --host 0.0.0.0 -i test.db --cors --inspect-file inspect-data """.strip() +@pytest.mark.serial @mock.patch("shutil.which") @mock.patch("datasette.cli.call") def test_package(mock_call, mock_which, tmp_path_factory): diff --git a/tests/test_publish_cloudrun.py b/tests/test_publish_cloudrun.py index d91b7646..ee0c9c95 100644 --- a/tests/test_publish_cloudrun.py +++ b/tests/test_publish_cloudrun.py @@ -6,6 +6,7 @@ import pytest import textwrap +@pytest.mark.serial @mock.patch("shutil.which") def test_publish_cloudrun_requires_gcloud(mock_which, tmp_path_factory): mock_which.return_value = False @@ -27,6 +28,7 @@ def test_publish_cloudrun_invalid_database(mock_which): assert "Path 'woop.db' does not exist" in result.output +@pytest.mark.serial @mock.patch("shutil.which") @mock.patch("datasette.publish.cloudrun.check_output") @mock.patch("datasette.publish.cloudrun.check_call") @@ -75,6 +77,7 @@ Service name: input-service ) +@pytest.mark.serial @mock.patch("shutil.which") @mock.patch("datasette.publish.cloudrun.check_output") @mock.patch("datasette.publish.cloudrun.check_call") @@ -103,6 +106,7 @@ def test_publish_cloudrun(mock_call, mock_output, mock_which, tmp_path_factory): ) +@pytest.mark.serial @mock.patch("shutil.which") @mock.patch("datasette.publish.cloudrun.check_output") @mock.patch("datasette.publish.cloudrun.check_call") @@ -147,6 +151,7 @@ def test_publish_cloudrun_memory( ) +@pytest.mark.serial @mock.patch("shutil.which") @mock.patch("datasette.publish.cloudrun.check_output") @mock.patch("datasette.publish.cloudrun.check_call") @@ -227,6 +232,7 @@ def test_publish_cloudrun_plugin_secrets( } == json.loads(metadata) +@pytest.mark.serial @mock.patch("shutil.which") @mock.patch("datasette.publish.cloudrun.check_output") @mock.patch("datasette.publish.cloudrun.check_call") @@ -284,6 +290,7 @@ def test_publish_cloudrun_apt_get_install( assert expected == dockerfile +@pytest.mark.serial @mock.patch("shutil.which") @mock.patch("datasette.publish.cloudrun.check_output") @mock.patch("datasette.publish.cloudrun.check_call") diff --git a/tests/test_publish_heroku.py b/tests/test_publish_heroku.py index a591bcf8..1fe02e08 100644 --- a/tests/test_publish_heroku.py +++ b/tests/test_publish_heroku.py @@ -1,8 +1,10 @@ from click.testing import CliRunner from datasette import cli from unittest import mock +import pytest +@pytest.mark.serial @mock.patch("shutil.which") def test_publish_heroku_requires_heroku(mock_which, tmp_path_factory): mock_which.return_value = False @@ -15,6 +17,7 @@ def test_publish_heroku_requires_heroku(mock_which, tmp_path_factory): assert "Publishing to Heroku requires heroku" in result.output +@pytest.mark.serial @mock.patch("shutil.which") @mock.patch("datasette.publish.heroku.check_output") @mock.patch("datasette.publish.heroku.call") @@ -46,6 +49,7 @@ def test_publish_heroku_invalid_database(mock_which): assert "Path 'woop.db' does not exist" in result.output +@pytest.mark.serial @mock.patch("shutil.which") @mock.patch("datasette.publish.heroku.check_output") @mock.patch("datasette.publish.heroku.call") @@ -81,6 +85,7 @@ def test_publish_heroku(mock_call, mock_check_output, mock_which, tmp_path_facto ) +@pytest.mark.serial @mock.patch("shutil.which") @mock.patch("datasette.publish.heroku.check_output") @mock.patch("datasette.publish.heroku.call") From 96b1d0b7b42928e657b1aebcc95d55e4685690e0 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 31 Jul 2021 11:48:33 -0700 Subject: [PATCH 0078/1250] Attempted fix for too-long UDS bug in #1407 --- tests/conftest.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 34a64efc..215853b3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -157,8 +157,11 @@ def ds_localhost_https_server(tmp_path_factory): @pytest.fixture(scope="session") def ds_unix_domain_socket_server(tmp_path_factory): - socket_folder = tmp_path_factory.mktemp("uds") - uds = str(socket_folder / "datasette.sock") + # This used to use tmp_path_factory.mktemp("uds") but that turned out to + # produce paths that were too long to use as UDS on macOS, see + # https://github.com/simonw/datasette/issues/1407 - so I switched to + # using tempfile.gettempdir() + uds = str(pathlib.Path(tempfile.gettempdir()) / "datasette.sock") ds_proc = subprocess.Popen( ["datasette", "--memory", "--uds", uds], stdout=subprocess.PIPE, From ff253f5242e4b0b5d85d29d38b8461feb5ea997a Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 31 Jul 2021 11:49:08 -0700 Subject: [PATCH 0079/1250] Replace all uses of runner.isolated_filesystem, refs #1406 --- tests/test_package.py | 27 ++- tests/test_publish_cloudrun.py | 422 ++++++++++++++++----------------- tests/test_publish_heroku.py | 127 +++++----- 3 files changed, 284 insertions(+), 292 deletions(-) diff --git a/tests/test_package.py b/tests/test_package.py index 98e701bf..02ed1775 100644 --- a/tests/test_package.py +++ b/tests/test_package.py @@ -1,6 +1,7 @@ from click.testing import CliRunner from datasette import cli from unittest import mock +import os import pathlib import pytest @@ -32,12 +33,12 @@ def test_package(mock_call, mock_which, tmp_path_factory): runner = CliRunner() capture = CaptureDockerfile() mock_call.side_effect = capture - with runner.isolated_filesystem(tmp_path_factory.mktemp("runner")): - with open("test.db", "w") as fp: - fp.write("data") - result = runner.invoke(cli.cli, ["package", "test.db", "--secret", "sekrit"]) - assert 0 == result.exit_code - mock_call.assert_has_calls([mock.call(["docker", "build", "."])]) + os.chdir(tmp_path_factory.mktemp("runner")) + with open("test.db", "w") as fp: + fp.write("data") + result = runner.invoke(cli.cli, ["package", "test.db", "--secret", "sekrit"]) + assert 0 == result.exit_code + mock_call.assert_has_calls([mock.call(["docker", "build", "."])]) assert EXPECTED_DOCKERFILE.format(port=8001) == capture.captured @@ -48,11 +49,11 @@ def test_package_with_port(mock_call, mock_which, tmp_path_factory): capture = CaptureDockerfile() mock_call.side_effect = capture runner = CliRunner() - with runner.isolated_filesystem(tmp_path_factory.mktemp("runner")): - with open("test.db", "w") as fp: - fp.write("data") - result = runner.invoke( - cli.cli, ["package", "test.db", "-p", "8080", "--secret", "sekrit"] - ) - assert 0 == result.exit_code + os.chdir(tmp_path_factory.mktemp("runner")) + with open("test.db", "w") as fp: + fp.write("data") + result = runner.invoke( + cli.cli, ["package", "test.db", "-p", "8080", "--secret", "sekrit"] + ) + assert 0 == result.exit_code assert EXPECTED_DOCKERFILE.format(port=8080) == capture.captured diff --git a/tests/test_publish_cloudrun.py b/tests/test_publish_cloudrun.py index ee0c9c95..47f59d72 100644 --- a/tests/test_publish_cloudrun.py +++ b/tests/test_publish_cloudrun.py @@ -2,6 +2,7 @@ from click.testing import CliRunner from datasette import cli from unittest import mock import json +import os import pytest import textwrap @@ -11,12 +12,12 @@ import textwrap def test_publish_cloudrun_requires_gcloud(mock_which, tmp_path_factory): mock_which.return_value = False runner = CliRunner() - with runner.isolated_filesystem(tmp_path_factory.mktemp("runner")): - with open("test.db", "w") as fp: - fp.write("data") - result = runner.invoke(cli.cli, ["publish", "cloudrun", "test.db"]) - assert result.exit_code == 1 - assert "Publishing to Google Cloud requires gcloud" in result.output + os.chdir(tmp_path_factory.mktemp("runner")) + with open("test.db", "w") as fp: + fp.write("data") + result = runner.invoke(cli.cli, ["publish", "cloudrun", "test.db"]) + assert result.exit_code == 1 + assert "Publishing to Google Cloud requires gcloud" in result.output @mock.patch("shutil.which") @@ -42,39 +43,32 @@ def test_publish_cloudrun_prompts_for_service( mock_output.return_value = "myproject" mock_which.return_value = True runner = CliRunner() - with runner.isolated_filesystem(tmp_path_factory.mktemp("runner")): - with open("test.db", "w") as fp: - fp.write("data") - result = runner.invoke( - cli.cli, ["publish", "cloudrun", "test.db"], input="input-service" - ) - assert ( - """ -Please provide a service name for this deployment - -Using an existing service name will over-write it - -Your existing services: - - existing - created 2019-01-01 - http://www.example.com/ - -Service name: input-service -""".strip() - == result.output.strip() - ) - assert 0 == result.exit_code - tag = "gcr.io/myproject/datasette" - mock_call.assert_has_calls( - [ - mock.call(f"gcloud builds submit --tag {tag}", shell=True), - mock.call( - "gcloud run deploy --allow-unauthenticated --platform=managed --image {} input-service".format( - tag - ), - shell=True, + os.chdir(tmp_path_factory.mktemp("runner")) + with open("test.db", "w") as fp: + fp.write("data") + result = runner.invoke( + cli.cli, ["publish", "cloudrun", "test.db"], input="input-service" + ) + assert ( + "Please provide a service name for this deployment\n\n" + "Using an existing service name will over-write it\n\n" + "Your existing services:\n\n" + " existing - created 2019-01-01 - http://www.example.com/\n\n" + "Service name: input-service" + ) == result.output.strip() + assert 0 == result.exit_code + tag = "gcr.io/myproject/datasette" + mock_call.assert_has_calls( + [ + mock.call(f"gcloud builds submit --tag {tag}", shell=True), + mock.call( + "gcloud run deploy --allow-unauthenticated --platform=managed --image {} input-service".format( + tag ), - ] - ) + shell=True, + ), + ] + ) @pytest.mark.serial @@ -85,25 +79,25 @@ def test_publish_cloudrun(mock_call, mock_output, mock_which, tmp_path_factory): mock_output.return_value = "myproject" mock_which.return_value = True runner = CliRunner() - with runner.isolated_filesystem(tmp_path_factory.mktemp("runner")): - with open("test.db", "w") as fp: - fp.write("data") - result = runner.invoke( - cli.cli, ["publish", "cloudrun", "test.db", "--service", "test"] - ) - assert 0 == result.exit_code - tag = f"gcr.io/{mock_output.return_value}/datasette" - mock_call.assert_has_calls( - [ - mock.call(f"gcloud builds submit --tag {tag}", shell=True), - mock.call( - "gcloud run deploy --allow-unauthenticated --platform=managed --image {} test".format( - tag - ), - shell=True, + os.chdir(tmp_path_factory.mktemp("runner")) + with open("test.db", "w") as fp: + fp.write("data") + result = runner.invoke( + cli.cli, ["publish", "cloudrun", "test.db", "--service", "test"] + ) + assert 0 == result.exit_code + tag = f"gcr.io/{mock_output.return_value}/datasette" + mock_call.assert_has_calls( + [ + mock.call(f"gcloud builds submit --tag {tag}", shell=True), + mock.call( + "gcloud run deploy --allow-unauthenticated --platform=managed --image {} test".format( + tag ), - ] - ) + shell=True, + ), + ] + ) @pytest.mark.serial @@ -126,29 +120,29 @@ def test_publish_cloudrun_memory( mock_output.return_value = "myproject" mock_which.return_value = True runner = CliRunner() - with runner.isolated_filesystem(tmp_path_factory.mktemp("runner")): - with open("test.db", "w") as fp: - fp.write("data") - result = runner.invoke( - cli.cli, - ["publish", "cloudrun", "test.db", "--service", "test", "--memory", memory], - ) - if should_fail: - assert 2 == result.exit_code - return - assert 0 == result.exit_code - tag = f"gcr.io/{mock_output.return_value}/datasette" - mock_call.assert_has_calls( - [ - mock.call(f"gcloud builds submit --tag {tag}", shell=True), - mock.call( - "gcloud run deploy --allow-unauthenticated --platform=managed --image {} test --memory {}".format( - tag, memory - ), - shell=True, + os.chdir(tmp_path_factory.mktemp("runner")) + with open("test.db", "w") as fp: + fp.write("data") + result = runner.invoke( + cli.cli, + ["publish", "cloudrun", "test.db", "--service", "test", "--memory", memory], + ) + if should_fail: + assert 2 == result.exit_code + return + assert 0 == result.exit_code + tag = f"gcr.io/{mock_output.return_value}/datasette" + mock_call.assert_has_calls( + [ + mock.call(f"gcloud builds submit --tag {tag}", shell=True), + mock.call( + "gcloud run deploy --allow-unauthenticated --platform=managed --image {} test --memory {}".format( + tag, memory ), - ] - ) + shell=True, + ), + ] + ) @pytest.mark.serial @@ -162,74 +156,74 @@ def test_publish_cloudrun_plugin_secrets( mock_output.return_value = "myproject" runner = CliRunner() - with runner.isolated_filesystem(tmp_path_factory.mktemp("runner")): - with open("test.db", "w") as fp: - fp.write("data") - with open("metadata.yml", "w") as fp: - fp.write( - textwrap.dedent( - """ - title: Hello from metadata YAML - plugins: - datasette-auth-github: - foo: bar + os.chdir(tmp_path_factory.mktemp("runner")) + with open("test.db", "w") as fp: + fp.write("data") + with open("metadata.yml", "w") as fp: + fp.write( + textwrap.dedent( """ - ).strip() - ) - result = runner.invoke( - cli.cli, - [ - "publish", - "cloudrun", - "test.db", - "--metadata", - "metadata.yml", - "--service", - "datasette", - "--plugin-secret", - "datasette-auth-github", - "client_id", - "x-client-id", - "--show-files", - "--secret", - "x-secret", - ], + title: Hello from metadata YAML + plugins: + datasette-auth-github: + foo: bar + """ + ).strip() ) - assert result.exit_code == 0 - dockerfile = ( - result.output.split("==== Dockerfile ====\n")[1] - .split("\n====================\n")[0] - .strip() - ) - expected = textwrap.dedent( - r""" - FROM python:3.8 - COPY . /app - WORKDIR /app + result = runner.invoke( + cli.cli, + [ + "publish", + "cloudrun", + "test.db", + "--metadata", + "metadata.yml", + "--service", + "datasette", + "--plugin-secret", + "datasette-auth-github", + "client_id", + "x-client-id", + "--show-files", + "--secret", + "x-secret", + ], + ) + assert result.exit_code == 0 + dockerfile = ( + result.output.split("==== Dockerfile ====\n")[1] + .split("\n====================\n")[0] + .strip() + ) + expected = textwrap.dedent( + r""" + FROM python:3.8 + COPY . /app + WORKDIR /app - ENV DATASETTE_AUTH_GITHUB_CLIENT_ID 'x-client-id' - ENV DATASETTE_SECRET 'x-secret' - RUN pip install -U datasette - RUN datasette inspect test.db --inspect-file inspect-data.json - ENV PORT 8001 - EXPOSE 8001 - CMD datasette serve --host 0.0.0.0 -i test.db --cors --inspect-file inspect-data.json --metadata metadata.json --setting force_https_urls on --port $PORT""" - ).strip() - assert expected == dockerfile - metadata = ( - result.output.split("=== metadata.json ===\n")[1] - .split("\n==== Dockerfile ====\n")[0] - .strip() - ) - assert { - "title": "Hello from metadata YAML", - "plugins": { - "datasette-auth-github": { - "foo": "bar", - "client_id": {"$env": "DATASETTE_AUTH_GITHUB_CLIENT_ID"}, - } + ENV DATASETTE_AUTH_GITHUB_CLIENT_ID 'x-client-id' + ENV DATASETTE_SECRET 'x-secret' + RUN pip install -U datasette + RUN datasette inspect test.db --inspect-file inspect-data.json + ENV PORT 8001 + EXPOSE 8001 + CMD datasette serve --host 0.0.0.0 -i test.db --cors --inspect-file inspect-data.json --metadata metadata.json --setting force_https_urls on --port $PORT""" + ).strip() + assert expected == dockerfile + metadata = ( + result.output.split("=== metadata.json ===\n")[1] + .split("\n==== Dockerfile ====\n")[0] + .strip() + ) + assert { + "title": "Hello from metadata YAML", + "plugins": { + "datasette-auth-github": { + "client_id": {"$env": "DATASETTE_AUTH_GITHUB_CLIENT_ID"}, + "foo": "bar", }, - } == json.loads(metadata) + }, + } == json.loads(metadata) @pytest.mark.serial @@ -243,51 +237,51 @@ def test_publish_cloudrun_apt_get_install( mock_output.return_value = "myproject" runner = CliRunner() - with runner.isolated_filesystem(tmp_path_factory.mktemp("runner")): - with open("test.db", "w") as fp: - fp.write("data") - result = runner.invoke( - cli.cli, - [ - "publish", - "cloudrun", - "test.db", - "--service", - "datasette", - "--show-files", - "--secret", - "x-secret", - "--apt-get-install", - "ripgrep", - "--spatialite", - ], - ) - assert result.exit_code == 0 - dockerfile = ( - result.output.split("==== Dockerfile ====\n")[1] - .split("\n====================\n")[0] - .strip() - ) - expected = textwrap.dedent( - r""" - FROM python:3.8 - COPY . /app - WORKDIR /app + os.chdir(tmp_path_factory.mktemp("runner")) + with open("test.db", "w") as fp: + fp.write("data") + result = runner.invoke( + cli.cli, + [ + "publish", + "cloudrun", + "test.db", + "--service", + "datasette", + "--show-files", + "--secret", + "x-secret", + "--apt-get-install", + "ripgrep", + "--spatialite", + ], + ) + assert result.exit_code == 0 + dockerfile = ( + result.output.split("==== Dockerfile ====\n")[1] + .split("\n====================\n")[0] + .strip() + ) + expected = textwrap.dedent( + r""" + FROM python:3.8 + COPY . /app + WORKDIR /app - RUN apt-get update && \ - apt-get install -y ripgrep python3-dev gcc libsqlite3-mod-spatialite && \ - rm -rf /var/lib/apt/lists/* + RUN apt-get update && \ + apt-get install -y ripgrep python3-dev gcc libsqlite3-mod-spatialite && \ + rm -rf /var/lib/apt/lists/* - ENV DATASETTE_SECRET 'x-secret' - ENV SQLITE_EXTENSIONS '/usr/lib/x86_64-linux-gnu/mod_spatialite.so' - RUN pip install -U datasette - RUN datasette inspect test.db --inspect-file inspect-data.json - ENV PORT 8001 - EXPOSE 8001 - CMD datasette serve --host 0.0.0.0 -i test.db --cors --inspect-file inspect-data.json --setting force_https_urls on --port $PORT - """ - ).strip() - assert expected == dockerfile + ENV DATASETTE_SECRET 'x-secret' + ENV SQLITE_EXTENSIONS '/usr/lib/x86_64-linux-gnu/mod_spatialite.so' + RUN pip install -U datasette + RUN datasette inspect test.db --inspect-file inspect-data.json + ENV PORT 8001 + EXPOSE 8001 + CMD datasette serve --host 0.0.0.0 -i test.db --cors --inspect-file inspect-data.json --setting force_https_urls on --port $PORT + """ + ).strip() + assert expected == dockerfile @pytest.mark.serial @@ -312,32 +306,32 @@ def test_publish_cloudrun_extra_options( mock_output.return_value = "myproject" runner = CliRunner() - with runner.isolated_filesystem(tmp_path_factory.mktemp("runner")): - with open("test.db", "w") as fp: - fp.write("data") - result = runner.invoke( - cli.cli, - [ - "publish", - "cloudrun", - "test.db", - "--service", - "datasette", - "--show-files", - "--extra-options", - extra_options, - ], - ) - assert result.exit_code == 0 - dockerfile = ( - result.output.split("==== Dockerfile ====\n")[1] - .split("\n====================\n")[0] - .strip() - ) - last_line = dockerfile.split("\n")[-1] - extra_options = ( - last_line.split("--inspect-file inspect-data.json")[1] - .split("--port")[0] - .strip() - ) - assert extra_options == expected + os.chdir(tmp_path_factory.mktemp("runner")) + with open("test.db", "w") as fp: + fp.write("data") + result = runner.invoke( + cli.cli, + [ + "publish", + "cloudrun", + "test.db", + "--service", + "datasette", + "--show-files", + "--extra-options", + extra_options, + ], + ) + assert result.exit_code == 0 + dockerfile = ( + result.output.split("==== Dockerfile ====\n")[1] + .split("\n====================\n")[0] + .strip() + ) + last_line = dockerfile.split("\n")[-1] + extra_options = ( + last_line.split("--inspect-file inspect-data.json")[1] + .split("--port")[0] + .strip() + ) + assert extra_options == expected diff --git a/tests/test_publish_heroku.py b/tests/test_publish_heroku.py index 1fe02e08..b5a8af73 100644 --- a/tests/test_publish_heroku.py +++ b/tests/test_publish_heroku.py @@ -1,6 +1,7 @@ from click.testing import CliRunner from datasette import cli from unittest import mock +import os import pytest @@ -9,12 +10,12 @@ import pytest def test_publish_heroku_requires_heroku(mock_which, tmp_path_factory): mock_which.return_value = False runner = CliRunner() - with runner.isolated_filesystem(tmp_path_factory.mktemp("runner")): - with open("test.db", "w") as fp: - fp.write("data") - result = runner.invoke(cli.cli, ["publish", "heroku", "test.db"]) - assert result.exit_code == 1 - assert "Publishing to Heroku requires heroku" in result.output + os.chdir(tmp_path_factory.mktemp("runner")) + with open("test.db", "w") as fp: + fp.write("data") + result = runner.invoke(cli.cli, ["publish", "heroku", "test.db"]) + assert result.exit_code == 1 + assert "Publishing to Heroku requires heroku" in result.output @pytest.mark.serial @@ -27,11 +28,11 @@ def test_publish_heroku_installs_plugin( mock_which.return_value = True mock_check_output.side_effect = lambda s: {"['heroku', 'plugins']": b""}[repr(s)] runner = CliRunner() - with runner.isolated_filesystem(tmp_path_factory.mktemp("runner")): - with open("t.db", "w") as fp: - fp.write("data") - result = runner.invoke(cli.cli, ["publish", "heroku", "t.db"], input="y\n") - assert 0 != result.exit_code + os.chdir(tmp_path_factory.mktemp("runner")) + with open("t.db", "w") as fp: + fp.write("data") + result = runner.invoke(cli.cli, ["publish", "heroku", "t.db"], input="y\n") + assert 0 != result.exit_code mock_check_output.assert_has_calls( [mock.call(["heroku", "plugins"]), mock.call(["heroku", "apps:list", "--json"])] ) @@ -61,28 +62,26 @@ def test_publish_heroku(mock_call, mock_check_output, mock_which, tmp_path_facto "['heroku', 'apps:create', 'datasette', '--json']": b'{"name": "f"}', }[repr(s)] runner = CliRunner() - with runner.isolated_filesystem(tmp_path_factory.mktemp("runner")): - with open("test.db", "w") as fp: - fp.write("data") - result = runner.invoke( - cli.cli, ["publish", "heroku", "test.db", "--tar", "gtar"] - ) - assert 0 == result.exit_code, result.output - mock_call.assert_has_calls( - [ - mock.call( - [ - "heroku", - "builds:create", - "-a", - "f", - "--include-vcs-ignore", - "--tar", - "gtar", - ] - ), - ] - ) + os.chdir(tmp_path_factory.mktemp("runner")) + with open("test.db", "w") as fp: + fp.write("data") + result = runner.invoke(cli.cli, ["publish", "heroku", "test.db", "--tar", "gtar"]) + assert 0 == result.exit_code, result.output + mock_call.assert_has_calls( + [ + mock.call( + [ + "heroku", + "builds:create", + "-a", + "f", + "--include-vcs-ignore", + "--tar", + "gtar", + ] + ), + ] + ) @pytest.mark.serial @@ -99,35 +98,33 @@ def test_publish_heroku_plugin_secrets( "['heroku', 'apps:create', 'datasette', '--json']": b'{"name": "f"}', }[repr(s)] runner = CliRunner() - with runner.isolated_filesystem(tmp_path_factory.mktemp("runner")): - with open("test.db", "w") as fp: - fp.write("data") - result = runner.invoke( - cli.cli, - [ - "publish", - "heroku", - "test.db", - "--plugin-secret", - "datasette-auth-github", - "client_id", - "x-client-id", - ], - ) - assert 0 == result.exit_code, result.output - mock_call.assert_has_calls( - [ - mock.call( - [ - "heroku", - "config:set", - "-a", - "f", - "DATASETTE_AUTH_GITHUB_CLIENT_ID=x-client-id", - ] - ), - mock.call( - ["heroku", "builds:create", "-a", "f", "--include-vcs-ignore"] - ), - ] - ) + os.chdir(tmp_path_factory.mktemp("runner")) + with open("test.db", "w") as fp: + fp.write("data") + result = runner.invoke( + cli.cli, + [ + "publish", + "heroku", + "test.db", + "--plugin-secret", + "datasette-auth-github", + "client_id", + "x-client-id", + ], + ) + assert 0 == result.exit_code, result.output + mock_call.assert_has_calls( + [ + mock.call( + [ + "heroku", + "config:set", + "-a", + "f", + "DATASETTE_AUTH_GITHUB_CLIENT_ID=x-client-id", + ] + ), + mock.call(["heroku", "builds:create", "-a", "f", "--include-vcs-ignore"]), + ] + ) From 4adca0d85077fe504e98cd7487343e76ccf25be5 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 31 Jul 2021 17:58:11 -0700 Subject: [PATCH 0080/1250] No hidden SQL on canned query pages, closes #1411 --- datasette/templates/query.html | 2 +- tests/test_html.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/datasette/templates/query.html b/datasette/templates/query.html index b6c74883..543561d8 100644 --- a/datasette/templates/query.html +++ b/datasette/templates/query.html @@ -44,7 +44,7 @@
{% if query %}{{ query.sql }}{% endif %}
{% endif %} {% else %} - + {% if not canned_query %}{% endif %} {% endif %} {% if named_parameter_values %} diff --git a/tests/test_html.py b/tests/test_html.py index aee6bce1..9f5b99e3 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -1238,6 +1238,17 @@ def test_show_hide_sql_query(app_client): ] == [(hidden["name"], hidden["value"]) for hidden in hiddens] +def test_canned_query_with_hide_has_no_hidden_sql(app_client): + # For a canned query the show/hide should NOT have a hidden SQL field + # https://github.com/simonw/datasette/issues/1411 + response = app_client.get("/fixtures/neighborhood_search?_hide_sql=1") + soup = Soup(response.body, "html.parser") + hiddens = soup.find("form").select("input[type=hidden]") + assert [ + ("_hide_sql", "1"), + ] == [(hidden["name"], hidden["value"]) for hidden in hiddens] + + def test_extra_where_clauses(app_client): response = app_client.get( "/fixtures/facetable?_where=neighborhood='Dogpatch'&_where=city_id=1" From a679d0de87031e3de9013fc299ba2cbd75808684 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 3 Aug 2021 09:11:18 -0700 Subject: [PATCH 0081/1250] Fixed spelling of 'receive' in a bunch of places --- docs/internals.rst | 2 +- docs/plugin_hooks.rst | 4 ++-- tests/plugins/my_plugin.py | 4 ++-- tests/plugins/my_plugin_2.py | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/internals.rst b/docs/internals.rst index 1e41cacd..cfc4f6d5 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -149,7 +149,7 @@ Create a ``Response`` object and then use ``await response.asgi_send(send)``, pa .. code-block:: python - async def require_authorization(scope, recieve, send): + async def require_authorization(scope, receive, send): response = Response.text( "401 Authorization Required", headers={ diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 4700763c..269cb1c9 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -678,7 +678,7 @@ This example plugin adds a ``x-databases`` HTTP header listing the currently att def asgi_wrapper(datasette): def wrap_with_databases_header(app): @wraps(app) - async def add_x_databases_header(scope, recieve, send): + async def add_x_databases_header(scope, receive, send): async def wrapped_send(event): if event["type"] == "http.response.start": original_headers = event.get("headers") or [] @@ -691,7 +691,7 @@ This example plugin adds a ``x-databases`` HTTP header listing the currently att ], } await send(event) - await app(scope, recieve, wrapped_send) + await app(scope, receive, wrapped_send) return add_x_databases_header return wrap_with_databases_header diff --git a/tests/plugins/my_plugin.py b/tests/plugins/my_plugin.py index 0e625623..59ac8add 100644 --- a/tests/plugins/my_plugin.py +++ b/tests/plugins/my_plugin.py @@ -178,11 +178,11 @@ def actor_from_request(datasette, request): @hookimpl def asgi_wrapper(): def wrap(app): - async def maybe_set_actor_in_scope(scope, recieve, send): + async def maybe_set_actor_in_scope(scope, receive, send): if b"_actor_in_scope" in scope.get("query_string", b""): scope = dict(scope, actor={"id": "from-scope"}) print(scope) - await app(scope, recieve, send) + await app(scope, receive, send) return maybe_set_actor_in_scope diff --git a/tests/plugins/my_plugin_2.py b/tests/plugins/my_plugin_2.py index f7a3f1c0..ba298fd4 100644 --- a/tests/plugins/my_plugin_2.py +++ b/tests/plugins/my_plugin_2.py @@ -77,7 +77,7 @@ def extra_template_vars(template, database, table, view_name, request, datasette def asgi_wrapper(datasette): def wrap_with_databases_header(app): @wraps(app) - async def add_x_databases_header(scope, recieve, send): + async def add_x_databases_header(scope, receive, send): async def wrapped_send(event): if event["type"] == "http.response.start": original_headers = event.get("headers") or [] @@ -94,7 +94,7 @@ def asgi_wrapper(datasette): } await send(event) - await app(scope, recieve, wrapped_send) + await app(scope, receive, wrapped_send) return add_x_databases_header From 54b6e96ee8aa553b6671e341a1944f93f3fb89c3 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 3 Aug 2021 09:12:48 -0700 Subject: [PATCH 0082/1250] Use optional rich dependency to render tracebacks, closes #1416 --- datasette/app.py | 8 ++++++++ datasette/cli.py | 8 ++++++++ setup.py | 1 + 3 files changed, 17 insertions(+) diff --git a/datasette/app.py b/datasette/app.py index 2596ca50..edd5ab87 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -81,6 +81,11 @@ from .tracer import AsgiTracer from .plugins import pm, DEFAULT_PLUGINS, get_plugins from .version import __version__ +try: + import rich +except ImportError: + rich = None + app_root = Path(__file__).parent.parent # https://github.com/simonw/datasette/issues/283#issuecomment-781591015 @@ -1270,6 +1275,9 @@ class DatasetteRouter: pdb.post_mortem(exception.__traceback__) + if rich is not None: + rich.console.Console().print_exception(show_locals=True) + title = None if isinstance(exception, Forbidden): status = 403 diff --git a/datasette/cli.py b/datasette/cli.py index 09aebcc8..e53f3d8e 100644 --- a/datasette/cli.py +++ b/datasette/cli.py @@ -31,6 +31,14 @@ from .utils.sqlite import sqlite3 from .utils.testing import TestClient from .version import __version__ +# Use Rich for tracebacks if it is installed +try: + from rich.traceback import install + + install(show_locals=True) +except ImportError: + pass + class Config(click.ParamType): # This will be removed in Datasette 1.0 in favour of class Setting diff --git a/setup.py b/setup.py index cfc1e484..c69b9b00 100644 --- a/setup.py +++ b/setup.py @@ -75,6 +75,7 @@ setup( "pytest-timeout>=1.4.2,<1.5", "trustme>=0.7,<0.9", ], + "rich": ["rich"], }, tests_require=["datasette[test]"], classifiers=[ From 2208c3c68e552d343e6a2872ff6e559fca9d1b38 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 3 Aug 2021 09:36:38 -0700 Subject: [PATCH 0083/1250] Spelling corrections plus CI job for codespell * Use codespell to check spelling in documentation, refs #1417 * Fixed spelling errors spotted by codespell, closes #1417 * Make codespell a docs dependency See also this TIL: https://til.simonwillison.net/python/codespell --- .github/workflows/spellcheck.yml | 25 +++++++++++++++++++++++++ docs/authentication.rst | 4 ++-- docs/changelog.rst | 8 ++++---- docs/codespell-ignore-words.txt | 1 + docs/deploying.rst | 2 +- docs/internals.rst | 6 +++--- docs/performance.rst | 2 +- docs/plugin_hooks.rst | 2 +- docs/publish.rst | 2 +- docs/settings.rst | 2 +- docs/sql_queries.rst | 2 +- setup.py | 2 +- 12 files changed, 42 insertions(+), 16 deletions(-) create mode 100644 .github/workflows/spellcheck.yml create mode 100644 docs/codespell-ignore-words.txt diff --git a/.github/workflows/spellcheck.yml b/.github/workflows/spellcheck.yml new file mode 100644 index 00000000..d498e173 --- /dev/null +++ b/.github/workflows/spellcheck.yml @@ -0,0 +1,25 @@ +name: Check spelling in documentation + +on: [push, pull_request] + +jobs: + spellcheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: 3.9 + - uses: actions/cache@v2 + name: Configure pip caching + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }} + restore-keys: | + ${{ runner.os }}-pip- + - name: Install dependencies + run: | + pip install -e '.[docs]' + - name: Check spelling + run: codespell docs/*.rst --ignore-words docs/codespell-ignore-words.txt diff --git a/docs/authentication.rst b/docs/authentication.rst index 62ed7e8b..0d98cf82 100644 --- a/docs/authentication.rst +++ b/docs/authentication.rst @@ -60,7 +60,7 @@ The key question the permissions system answers is this: **Actors** are :ref:`described above `. -An **action** is a string describing the action the actor would like to perfom. A full list is :ref:`provided below ` - examples include ``view-table`` and ``execute-sql``. +An **action** is a string describing the action the actor would like to perform. A full list is :ref:`provided below ` - examples include ``view-table`` and ``execute-sql``. A **resource** is the item the actor wishes to interact with - for example a specific database or table. Some actions, such as ``permissions-debug``, are not associated with a particular resource. @@ -73,7 +73,7 @@ Permissions with potentially harmful effects should default to *deny*. Plugin au Defining permissions with "allow" blocks ---------------------------------------- -The standard way to define permissions in Datasette is to use an ``"allow"`` block. This is a JSON document describing which actors are allowed to perfom a permission. +The standard way to define permissions in Datasette is to use an ``"allow"`` block. This is a JSON document describing which actors are allowed to perform a permission. The most basic form of allow block is this (`allow demo `__, `deny demo `__): diff --git a/docs/changelog.rst b/docs/changelog.rst index 6a951935..883cb3eb 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -426,7 +426,7 @@ See also `Datasette 0.49: The annotated release notes `__ for conversations about the project that go beyond just bug reports and issues. - Datasette can now be installed on macOS using Homebrew! Run ``brew install simonw/datasette/datasette``. See :ref:`installation_homebrew`. (:issue:`335`) - Two new commands: ``datasette install name-of-plugin`` and ``datasette uninstall name-of-plugin``. These are equivalent to ``pip install`` and ``pip uninstall`` but automatically run in the same virtual environment as Datasette, so users don't have to figure out where that virtual environment is - useful for installations created using Homebrew or ``pipx``. See :ref:`plugins_installing`. (:issue:`925`) -- A new command-line option, ``datasette --get``, accepts a path to a URL within the Datasette instance. It will run that request through Datasette (without starting a web server) and print out the repsonse. See :ref:`getting_started_datasette_get` for an example. (:issue:`926`) +- A new command-line option, ``datasette --get``, accepts a path to a URL within the Datasette instance. It will run that request through Datasette (without starting a web server) and print out the response. See :ref:`getting_started_datasette_get` for an example. (:issue:`926`) .. _v0_46: @@ -500,7 +500,7 @@ New plugin hooks Smaller changes ~~~~~~~~~~~~~~~ -- Cascading view permissons - so if a user has ``view-table`` they can view the table page even if they do not have ``view-database`` or ``view-instance``. (:issue:`832`) +- Cascading view permissions - so if a user has ``view-table`` they can view the table page even if they do not have ``view-database`` or ``view-instance``. (:issue:`832`) - CSRF protection no longer applies to ``Authentication: Bearer token`` requests or requests without cookies. (:issue:`835`) - ``datasette.add_message()`` now works inside plugins. (:issue:`864`) - Workaround for "Too many open files" error in test runs. (:issue:`846`) @@ -714,7 +714,7 @@ Also in this release: * Datasette now has a *pattern portfolio* at ``/-/patterns`` - e.g. https://latest.datasette.io/-/patterns. This is a page that shows every Datasette user interface component in one place, to aid core development and people building custom CSS themes. (:issue:`151`) * SQLite `PRAGMA functions `__ such as ``pragma_table_info(tablename)`` are now allowed in Datasette SQL queries. (:issue:`761`) * Datasette pages now consistently return a ``content-type`` of ``text/html; charset=utf-8"``. (:issue:`752`) -* Datasette now handles an ASGI ``raw_path`` value of ``None``, which should allow compatibilty with the `Mangum `__ adapter for running ASGI apps on AWS Lambda. Thanks, Colin Dellow. (`#719 `__) +* Datasette now handles an ASGI ``raw_path`` value of ``None``, which should allow compatibility with the `Mangum `__ adapter for running ASGI apps on AWS Lambda. Thanks, Colin Dellow. (`#719 `__) * Installation documentation now covers how to :ref:`installation_pipx`. (:issue:`756`) * Improved the documentation for :ref:`full_text_search`. (:issue:`748`) @@ -1169,7 +1169,7 @@ Documentation improvements plus a fix for publishing to Zeit Now. New plugin hooks, improved database view support and an easier way to use more recent versions of SQLite. - New ``publish_subcommand`` plugin hook. A plugin can now add additional ``datasette publish`` publishers in addition to the default ``now`` and ``heroku``, both of which have been refactored into default plugins. :ref:`publish_subcommand documentation `. Closes :issue:`349` -- New ``render_cell`` plugin hook. Plugins can now customize how values are displayed in the HTML tables produced by Datasette's browseable interface. `datasette-json-html `__ and `datasette-render-images `__ are two new plugins that use this hook. :ref:`render_cell documentation `. Closes :issue:`352` +- New ``render_cell`` plugin hook. Plugins can now customize how values are displayed in the HTML tables produced by Datasette's browsable interface. `datasette-json-html `__ and `datasette-render-images `__ are two new plugins that use this hook. :ref:`render_cell documentation `. Closes :issue:`352` - New ``extra_body_script`` plugin hook, enabling plugins to provide additional JavaScript that should be added to the page footer. :ref:`extra_body_script documentation `. - ``extra_css_urls`` and ``extra_js_urls`` hooks now take additional optional parameters, allowing them to be more selective about which pages they apply to. :ref:`Documentation `. - You can now use the :ref:`sortable_columns metadata setting ` to explicitly enable sort-by-column in the interface for database views, as well as for specific tables. diff --git a/docs/codespell-ignore-words.txt b/docs/codespell-ignore-words.txt new file mode 100644 index 00000000..a625cde5 --- /dev/null +++ b/docs/codespell-ignore-words.txt @@ -0,0 +1 @@ +AddWordsToIgnoreHere diff --git a/docs/deploying.rst b/docs/deploying.rst index 31d123e9..83d9e4dd 100644 --- a/docs/deploying.rst +++ b/docs/deploying.rst @@ -188,7 +188,7 @@ Then add these directives to proxy traffic:: ProxyPass /my-datasette/ http://127.0.0.1:8009/my-datasette/ ProxyPreserveHost On -Using ``--uds`` you can use Unix domain sockets similiar to the nginx example:: +Using ``--uds`` you can use Unix domain sockets similar to the nginx example:: ProxyPass /my-datasette/ unix:/tmp/datasette.sock|http://localhost/my-datasette/ diff --git a/docs/internals.rst b/docs/internals.rst index cfc4f6d5..058a8969 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -431,13 +431,13 @@ It offers the following methods: ``await datasette.client.get(path, **kwargs)`` - returns HTTPX Response Execute an internal GET request against that path. -``await datasette.client.post(path, **kwargs)`` - returns HTTPX Respons +``await datasette.client.post(path, **kwargs)`` - returns HTTPX Response Execute an internal POST request. Use ``data={"name": "value"}`` to pass form parameters. ``await datasette.client.options(path, **kwargs)`` - returns HTTPX Response Execute an internal OPTIONS request. -``await datasette.client.head(path, **kwargs)`` - returns HTTPX Respons +``await datasette.client.head(path, **kwargs)`` - returns HTTPX Response Execute an internal HEAD request. ``await datasette.client.put(path, **kwargs)`` - returns HTTPX Response @@ -714,7 +714,7 @@ The ``Database`` class also provides properties and methods for introspecting th List of names of tables in the database. ``await db.view_names()`` - list of strings - List of names of views in tha database. + List of names of views in the database. ``await db.table_columns(table)`` - list of strings Names of columns in a specific table. diff --git a/docs/performance.rst b/docs/performance.rst index b9e38e2f..bcf3208e 100644 --- a/docs/performance.rst +++ b/docs/performance.rst @@ -39,7 +39,7 @@ Then later you can start Datasette against the ``counts.json`` file and use it t datasette -i data.db --inspect-file=counts.json -You need to use the ``-i`` immutable mode against the databse file here or the counts from the JSON file will be ignored. +You need to use the ``-i`` immutable mode against the database file here or the counts from the JSON file will be ignored. You will rarely need to use this optimization in every-day use, but several of the ``datasette publish`` commands described in :ref:`publishing` use this optimization for better performance when deploying a database file to a hosting provider. diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 269cb1c9..10ec2cf1 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -664,7 +664,7 @@ Return an `ASGI `__ middleware wrapper function th This is a very powerful hook. You can use it to manipulate the entire Datasette response, or even to configure new URL routes that will be handled by your own custom code. -You can write your ASGI code directly against the low-level specification, or you can use the middleware utilites provided by an ASGI framework such as `Starlette `__. +You can write your ASGI code directly against the low-level specification, or you can use the middleware utilities provided by an ASGI framework such as `Starlette `__. This example plugin adds a ``x-databases`` HTTP header listing the currently attached databases: diff --git a/docs/publish.rst b/docs/publish.rst index cbd18a00..f6895f53 100644 --- a/docs/publish.rst +++ b/docs/publish.rst @@ -165,7 +165,7 @@ You can now run the resulting container like so:: This exposes port 8001 inside the container as port 8081 on your host machine, so you can access the application at ``http://localhost:8081/`` -You can customize the port that is exposed by the countainer using the ``--port`` option:: +You can customize the port that is exposed by the container using the ``--port`` option:: datasette package mydatabase.db --port 8080 diff --git a/docs/settings.rst b/docs/settings.rst index c246d33a..7cc4bae0 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -349,7 +349,7 @@ Using secrets with datasette publish The :ref:`cli_publish` and :ref:`cli_package` commands both generate a secret for you automatically when Datasette is deployed. -This means that every time you deploy a new version of a Datasette project, a new secret will be generated. This will cause signed cookies to become inalid on every fresh deploy. +This means that every time you deploy a new version of a Datasette project, a new secret will be generated. This will cause signed cookies to become invalid on every fresh deploy. You can fix this by creating a secret that will be used for multiple deploys and passing it using the ``--secret`` option:: diff --git a/docs/sql_queries.rst b/docs/sql_queries.rst index e9077f70..3049593d 100644 --- a/docs/sql_queries.rst +++ b/docs/sql_queries.rst @@ -403,7 +403,7 @@ Datasette can execute joins across multiple databases if it is started with the If it is started in this way, the ``/_memory`` page can be used to execute queries that join across multiple databases. -References to tables in attached databases should be preceeded by the database name and a period. +References to tables in attached databases should be preceded by the database name and a period. For example, this query will show a list of tables across both of the above databases: diff --git a/setup.py b/setup.py index c69b9b00..65e99848 100644 --- a/setup.py +++ b/setup.py @@ -65,7 +65,7 @@ setup( """, setup_requires=["pytest-runner"], extras_require={ - "docs": ["sphinx_rtd_theme", "sphinx-autobuild"], + "docs": ["sphinx_rtd_theme", "sphinx-autobuild", "codespell"], "test": [ "pytest>=5.2.2,<6.3.0", "pytest-xdist>=2.2.1,<2.4", From cd8b7bee8fb5c1cdce7c8dbfeb0166011abc72c6 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 3 Aug 2021 10:03:08 -0700 Subject: [PATCH 0084/1250] Run codespell against datasette source code too, refs #1417 --- .github/workflows/spellcheck.yml | 4 +++- datasette/hookspecs.py | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/spellcheck.yml b/.github/workflows/spellcheck.yml index d498e173..2e24d3eb 100644 --- a/.github/workflows/spellcheck.yml +++ b/.github/workflows/spellcheck.yml @@ -22,4 +22,6 @@ jobs: run: | pip install -e '.[docs]' - name: Check spelling - run: codespell docs/*.rst --ignore-words docs/codespell-ignore-words.txt + run: | + codespell docs/*.rst --ignore-words docs/codespell-ignore-words.txt + codespell datasette -S datasette/static --ignore-words docs/codespell-ignore-words.txt diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index 3ef0d4f5..f31ce538 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -86,12 +86,12 @@ def actor_from_request(datasette, request): @hookspec def permission_allowed(datasette, actor, action, resource): - """Check if actor is allowed to perfom this action - return True, False or None""" + """Check if actor is allowed to perform this action - return True, False or None""" @hookspec def canned_queries(datasette, database, actor): - """Return a dictonary of canned query definitions or an awaitable function that returns them""" + """Return a dictionary of canned query definitions or an awaitable function that returns them""" @hookspec From a1f383035698da8bf188659390af6e53ffeec940 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 3 Aug 2021 22:20:50 -0700 Subject: [PATCH 0085/1250] --cpu option for datasette publish cloudrun, closes #1420 --- datasette/publish/cloudrun.py | 13 +++++- docs/datasette-publish-cloudrun-help.txt | 1 + tests/test_docs.py | 2 +- tests/test_publish_cloudrun.py | 51 ++++++++++++++++-------- 4 files changed, 48 insertions(+), 19 deletions(-) diff --git a/datasette/publish/cloudrun.py b/datasette/publish/cloudrun.py index bad223a1..1fabcafd 100644 --- a/datasette/publish/cloudrun.py +++ b/datasette/publish/cloudrun.py @@ -36,6 +36,11 @@ def publish_subcommand(publish): callback=_validate_memory, help="Memory to allocate in Cloud Run, e.g. 1Gi", ) + @click.option( + "--cpu", + type=click.Choice(["1", "2", "4"]), + help="Number of vCPUs to allocate in Cloud Run", + ) @click.option( "--apt-get-install", "apt_get_extras", @@ -66,6 +71,7 @@ def publish_subcommand(publish): spatialite, show_files, memory, + cpu, apt_get_extras, ): fail_if_publish_binary_not_installed( @@ -151,8 +157,11 @@ def publish_subcommand(publish): image_id = f"gcr.io/{project}/{name}" check_call(f"gcloud builds submit --tag {image_id}", shell=True) check_call( - "gcloud run deploy --allow-unauthenticated --platform=managed --image {} {}{}".format( - image_id, service, " --memory {}".format(memory) if memory else "" + "gcloud run deploy --allow-unauthenticated --platform=managed --image {} {}{}{}".format( + image_id, + service, + " --memory {}".format(memory) if memory else "", + " --cpu {}".format(cpu) if cpu else "", ), shell=True, ) diff --git a/docs/datasette-publish-cloudrun-help.txt b/docs/datasette-publish-cloudrun-help.txt index 3d05efb6..34481b40 100644 --- a/docs/datasette-publish-cloudrun-help.txt +++ b/docs/datasette-publish-cloudrun-help.txt @@ -28,5 +28,6 @@ Options: --spatialite Enable SpatialLite extension --show-files Output the generated Dockerfile and metadata.json --memory TEXT Memory to allocate in Cloud Run, e.g. 1Gi + --cpu [1|2|4] Number of vCPUs to allocate in Cloud Run --apt-get-install TEXT Additional packages to apt-get install --help Show this message and exit. diff --git a/tests/test_docs.py b/tests/test_docs.py index efd267b9..d0cb036d 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -50,7 +50,7 @@ def test_help_includes(name, filename): # actual has "Usage: cli package [OPTIONS] FILES" # because it doesn't know that cli will be aliased to datasette expected = expected.replace("Usage: datasette", "Usage: cli") - assert expected == actual + assert expected == actual, "Run python update-docs-help.py to fix this" @pytest.fixture(scope="session") diff --git a/tests/test_publish_cloudrun.py b/tests/test_publish_cloudrun.py index 47f59d72..9c8c38cf 100644 --- a/tests/test_publish_cloudrun.py +++ b/tests/test_publish_cloudrun.py @@ -105,17 +105,28 @@ def test_publish_cloudrun(mock_call, mock_output, mock_which, tmp_path_factory): @mock.patch("datasette.publish.cloudrun.check_output") @mock.patch("datasette.publish.cloudrun.check_call") @pytest.mark.parametrize( - "memory,should_fail", + "memory,cpu,expected_gcloud_args", [ - ["1Gi", False], - ["2G", False], - ["256Mi", False], - ["4", True], - ["GB", True], + ["1Gi", None, "--memory 1Gi"], + ["2G", None, "--memory 2G"], + ["256Mi", None, "--memory 256Mi"], + ["4", None, None], + ["GB", None, None], + [None, 1, "--cpu 1"], + [None, 2, "--cpu 2"], + [None, 3, None], + [None, 4, "--cpu 4"], + ["2G", 4, "--memory 2G --cpu 4"], ], ) -def test_publish_cloudrun_memory( - mock_call, mock_output, mock_which, memory, should_fail, tmp_path_factory +def test_publish_cloudrun_memory_cpu( + mock_call, + mock_output, + mock_which, + memory, + cpu, + expected_gcloud_args, + tmp_path_factory, ): mock_output.return_value = "myproject" mock_which.return_value = True @@ -123,22 +134,30 @@ def test_publish_cloudrun_memory( os.chdir(tmp_path_factory.mktemp("runner")) with open("test.db", "w") as fp: fp.write("data") - result = runner.invoke( - cli.cli, - ["publish", "cloudrun", "test.db", "--service", "test", "--memory", memory], - ) - if should_fail: + args = ["publish", "cloudrun", "test.db", "--service", "test"] + if memory: + args.extend(["--memory", memory]) + if cpu: + args.extend(["--cpu", str(cpu)]) + result = runner.invoke(cli.cli, args) + if expected_gcloud_args is None: assert 2 == result.exit_code return assert 0 == result.exit_code tag = f"gcr.io/{mock_output.return_value}/datasette" + expected_call = ( + "gcloud run deploy --allow-unauthenticated --platform=managed" + " --image {} test".format(tag) + ) + if memory: + expected_call += " --memory {}".format(memory) + if cpu: + expected_call += " --cpu {}".format(cpu) mock_call.assert_has_calls( [ mock.call(f"gcloud builds submit --tag {tag}", shell=True), mock.call( - "gcloud run deploy --allow-unauthenticated --platform=managed --image {} test --memory {}".format( - tag, memory - ), + expected_call, shell=True, ), ] From acc22436622ff8476c30acf45ed60f54b4aaa5d9 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 5 Aug 2021 08:47:18 -0700 Subject: [PATCH 0086/1250] Quotes around '.[test]' for zsh --- docs/contributing.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/contributing.rst b/docs/contributing.rst index c3d0989a..8a638e0b 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -39,7 +39,7 @@ The next step is to create a virtual environment for your project and use it to # Now activate the virtual environment, so pip can install into it source venv/bin/activate # Install Datasette and its testing dependencies - python3 -m pip install -e .[test] + python3 -m pip install -e '.[test]' That last line does most of the work: ``pip install -e`` means "install this package in a way that allows me to edit the source code in place". The ``.[test]`` option means "use the setup.py in this directory and install the optional testing dependencies as well". From b7037f5ecea40dc5343250d08d741504b6dcb28f Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 4 Aug 2021 19:58:09 -0700 Subject: [PATCH 0087/1250] Bit of breathing space on https://latest.datasette.io/fixtures/pragma_cache_size --- datasette/static/app.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/datasette/static/app.css b/datasette/static/app.css index ad517c98..c6be1e97 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -497,6 +497,9 @@ label.sort_by_desc { width: auto; padding-right: 1em; } +pre#sql-query { + margin-bottom: 1em; +} form input[type=text], form input[type=search] { border: 1px solid #ccc; From 66e143c76e90f643dc11b6ced5433130c90a2455 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 6 Aug 2021 22:09:00 -0700 Subject: [PATCH 0088/1250] New hide_sql canned query option, refs #1422 --- datasette/templates/query.html | 14 +++++++--- datasette/views/database.py | 32 +++++++++++++++++++-- docs/changelog.rst | 2 +- docs/sql_queries.rst | 25 +++++++++++++---- tests/fixtures.py | 1 + tests/test_html.py | 51 +++++++++++++++++++++++++++++++++- 6 files changed, 111 insertions(+), 14 deletions(-) diff --git a/datasette/templates/query.html b/datasette/templates/query.html index 543561d8..75f7f1b1 100644 --- a/datasette/templates/query.html +++ b/datasette/templates/query.html @@ -33,7 +33,9 @@ {% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %} -

Custom SQL query{% if display_rows %} returning {% if truncated %}more than {% endif %}{{ "{:,}".format(display_rows|length) }} row{% if display_rows|length == 1 %}{% else %}s{% endif %}{% endif %}{% if not query_error %} {% if hide_sql %}(show){% else %}(hide){% endif %}{% endif %}

+

Custom SQL query{% if display_rows %} returning {% if truncated %}more than {% endif %}{{ "{:,}".format(display_rows|length) }} row{% if display_rows|length == 1 %}{% else %}s{% endif %}{% endif %}{% if not query_error %} + ({{ show_hide_text }}) + {% endif %}

{% if error %}

{{ error }}

{% endif %} @@ -44,8 +46,11 @@
{% if query %}{{ query.sql }}{% endif %}
{% endif %} {% else %} - {% if not canned_query %}{% endif %} - + {% if not canned_query %} + + {% endif %} {% endif %} {% if named_parameter_values %}

Query parameters

@@ -54,9 +59,10 @@ {% endfor %} {% endif %}

- + {% if not hide_sql %}{% endif %} {% if canned_write %}{% endif %} + {{ show_hide_hidden }} {% if canned_query and edit_sql_url %}Edit SQL{% endif %}

diff --git a/datasette/views/database.py b/datasette/views/database.py index 53bdceed..d9fe2b49 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -5,6 +5,8 @@ import json from markupsafe import Markup, escape from urllib.parse import parse_qsl, urlencode +import markupsafe + from datasette.utils import ( await_me_maybe, check_visibility, @@ -415,6 +417,29 @@ class QueryView(DataView): } ) ) + + show_hide_hidden = "" + if metadata.get("hide_sql"): + if bool(params.get("_show_sql")): + show_hide_link = path_with_removed_args(request, {"_show_sql"}) + show_hide_text = "hide" + show_hide_hidden = ( + '' + ) + else: + show_hide_link = path_with_added_args(request, {"_show_sql": 1}) + show_hide_text = "show" + else: + if bool(params.get("_hide_sql")): + show_hide_link = path_with_removed_args(request, {"_hide_sql"}) + show_hide_text = "show" + show_hide_hidden = ( + '' + ) + else: + show_hide_link = path_with_added_args(request, {"_hide_sql": 1}) + show_hide_text = "hide" + hide_sql = show_hide_text == "show" return { "display_rows": display_rows, "custom_sql": True, @@ -425,9 +450,10 @@ class QueryView(DataView): "metadata": metadata, "config": self.ds.config_dict(), "request": request, - "path_with_added_args": path_with_added_args, - "path_with_removed_args": path_with_removed_args, - "hide_sql": "_hide_sql" in params, + "show_hide_link": show_hide_link, + "show_hide_text": show_hide_text, + "show_hide_hidden": markupsafe.Markup(show_hide_hidden), + "hide_sql": hide_sql, } return ( diff --git a/docs/changelog.rst b/docs/changelog.rst index 883cb3eb..d0fee19b 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -674,7 +674,7 @@ The main focus of this release is a major upgrade to the :ref:`plugin_register_o * Visually distinguish float and integer columns - useful for figuring out why order-by-column might be returning unexpected results. (:issue:`729`) * The :ref:`internals_request`, which is passed to several plugin hooks, is now documented. (:issue:`706`) * New ``metadata.json`` option for setting a custom default page size for specific tables and views, see :ref:`metadata_page_size`. (:issue:`751`) -* Canned queries can now be configured with a default URL fragment hash, useful when working with plugins such as `datasette-vega `__, see :ref:`canned_queries_default_fragment`. (:issue:`706`) +* Canned queries can now be configured with a default URL fragment hash, useful when working with plugins such as `datasette-vega `__, see :ref:`canned_queries_options`. (:issue:`706`) * Fixed a bug in ``datasette publish`` when running on operating systems where the ``/tmp`` directory lives in a different volume, using a backport of the Python 3.8 ``shutil.copytree()`` function. (:issue:`744`) * Every plugin hook is now covered by the unit tests, and a new unit test checks that each plugin hook has at least one corresponding test. (:issue:`771`, :issue:`773`) diff --git a/docs/sql_queries.rst b/docs/sql_queries.rst index 3049593d..407e4ba2 100644 --- a/docs/sql_queries.rst +++ b/docs/sql_queries.rst @@ -187,14 +187,28 @@ You can alternatively provide an explicit list of named parameters using the ``" order by neighborhood title: Search neighborhoods -.. _canned_queries_default_fragment: +.. _canned_queries_options: -Setting a default fragment -~~~~~~~~~~~~~~~~~~~~~~~~~~ +Additional canned query options +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Additional options can be specified for canned queries in the YAML or JSON configuration. + +hide_sql +++++++++ + +Canned queries default to displaying their SQL query at the top of the page. If the query is extremely long you may want to hide it by default, with a "show" link that can be used to make it visible. + +Add the ``"hide_sql": true`` option to hide the SQL query by default. + +fragment +++++++++ Some plugins, such as `datasette-vega `__, can be configured by including additional data in the fragment hash of the URL - the bit that comes after a ``#`` symbol. -You can set a default fragment hash that will be included in the link to the canned query from the database index page using the ``"fragment"`` key: +You can set a default fragment hash that will be included in the link to the canned query from the database index page using the ``"fragment"`` key. + +This example demonstrates both ``fragment`` and ``hide_sql``: .. code-block:: json @@ -204,7 +218,8 @@ You can set a default fragment hash that will be included in the link to the can "queries": { "neighborhood_search": { "sql": "select neighborhood, facet_cities.name, state\nfrom facetable join facet_cities on facetable.city_id = facet_cities.id\nwhere neighborhood like '%' || :text || '%' order by neighborhood;", - "fragment": "fragment-goes-here" + "fragment": "fragment-goes-here", + "hide_sql": true } } } diff --git a/tests/fixtures.py b/tests/fixtures.py index 93b7dce2..873f9d55 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -360,6 +360,7 @@ METADATA = { "title": "Search neighborhoods", "description_html": "Demonstrating simple like search", "fragment": "fragment-goes-here", + "hide_sql": True, }, }, } diff --git a/tests/test_html.py b/tests/test_html.py index 9f5b99e3..b1b6c1f3 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -1241,7 +1241,7 @@ def test_show_hide_sql_query(app_client): def test_canned_query_with_hide_has_no_hidden_sql(app_client): # For a canned query the show/hide should NOT have a hidden SQL field # https://github.com/simonw/datasette/issues/1411 - response = app_client.get("/fixtures/neighborhood_search?_hide_sql=1") + response = app_client.get("/fixtures/pragma_cache_size?_hide_sql=1") soup = Soup(response.body, "html.parser") hiddens = soup.find("form").select("input[type=hidden]") assert [ @@ -1249,6 +1249,55 @@ def test_canned_query_with_hide_has_no_hidden_sql(app_client): ] == [(hidden["name"], hidden["value"]) for hidden in hiddens] +@pytest.mark.parametrize( + "hide_sql,querystring,expected_hidden,expected_show_hide_link,expected_show_hide_text", + ( + (False, "", None, "/_memory/one?_hide_sql=1", "hide"), + (False, "?_hide_sql=1", "_hide_sql", "/_memory/one", "show"), + (True, "", None, "/_memory/one?_show_sql=1", "show"), + (True, "?_show_sql=1", "_show_sql", "/_memory/one", "hide"), + ), +) +def test_canned_query_show_hide_metadata_option( + hide_sql, + querystring, + expected_hidden, + expected_show_hide_link, + expected_show_hide_text, +): + with make_app_client( + metadata={ + "databases": { + "_memory": { + "queries": { + "one": { + "sql": "select 1 + 1", + "hide_sql": hide_sql, + } + } + } + } + }, + memory=True, + ) as client: + expected_show_hide_fragment = '({})'.format( + expected_show_hide_link, expected_show_hide_text + ) + response = client.get("/_memory/one" + querystring) + html = response.text + show_hide_fragment = html.split('')[1].split( + "" + )[0] + assert show_hide_fragment == expected_show_hide_fragment + if expected_hidden: + assert ( + ''.format(expected_hidden) + in html + ) + else: + assert ' Date: Fri, 6 Aug 2021 22:14:44 -0700 Subject: [PATCH 0089/1250] Fix for rich.console sometimes not being available, refs #1416 --- datasette/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/datasette/app.py b/datasette/app.py index edd5ab87..f2f75884 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -1276,7 +1276,7 @@ class DatasetteRouter: pdb.post_mortem(exception.__traceback__) if rich is not None: - rich.console.Console().print_exception(show_locals=True) + rich.get_console().print_exception(show_locals=True) title = None if isinstance(exception, Forbidden): From 6dd14a1221d0324f9e3d6cfa10d2281d1eba4806 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 6 Aug 2021 22:38:47 -0700 Subject: [PATCH 0090/1250] Improved links to example plugins --- docs/plugin_hooks.rst | 36 ++++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 10ec2cf1..200e0305 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -53,7 +53,7 @@ arguments and can be called like this:: select random_integer(1, 10); -Examples: `datasette-jellyfish `__, `datasette-jq `__, `datasette-haversine `__, `datasette-rure `__ +Examples: `datasette-jellyfish `__, `datasette-jq `__, `datasette-haversine `__, `datasette-rure `__ .. _plugin_hook_prepare_jinja2_environment: @@ -161,7 +161,7 @@ You can then use the new function in a template like so:: SQLite version: {{ sql_first("select sqlite_version()") }} -Examples: `datasette-search-all `_, `datasette-template-sql `_ +Examples: `datasette-search-all `_, `datasette-template-sql `_ .. _plugin_hook_extra_css_urls: @@ -210,7 +210,7 @@ This function can also return an awaitable function, useful if it needs to run a return inner -Examples: `datasette-cluster-map `_, `datasette-vega `_ +Examples: `datasette-cluster-map `_, `datasette-vega `_ .. _plugin_hook_extra_js_urls: @@ -257,7 +257,7 @@ If your code uses `JavaScript modules `_, `datasette-vega `_ +Examples: `datasette-cluster-map `_, `datasette-vega `_ .. _plugin_hook_extra_body_script: @@ -291,7 +291,7 @@ This will add the following to the end of your page: -Example: `datasette-cluster-map `_ +Example: `datasette-cluster-map `_ .. _plugin_hook_publish_subcommand: @@ -348,7 +348,7 @@ Let's say you want to build a plugin that adds a ``datasette publish my_hosting_ ): # Your implementation goes here -Examples: `datasette-publish-fly `_, `datasette-publish-vercel `_ +Examples: `datasette-publish-fly `_, `datasette-publish-vercel `_ .. _plugin_hook_render_cell: @@ -420,7 +420,7 @@ If the value matches that pattern, the plugin returns an HTML link element: label=markupsafe.escape(data["label"] or "") or " " )) -Examples: `datasette-render-binary `_, `datasette-render-markdown `__, `datasette-json-html `__ +Examples: `datasette-render-binary `_, `datasette-render-markdown `__, `datasette-json-html `__ .. _plugin_register_output_renderer: @@ -525,7 +525,7 @@ And here is an example ``can_render`` function which returns ``True`` only if th def can_render_demo(columns): return {"atom_id", "atom_title", "atom_updated"}.issubset(columns) -Examples: `datasette-atom `_, `datasette-ics `_ +Examples: `datasette-atom `_, `datasette-ics `_ .. _plugin_register_routes: @@ -583,7 +583,7 @@ The function can either return a :ref:`internals_response` or it can return noth See :ref:`writing_plugins_designing_urls` for tips on designing the URL routes used by your plugin. -Examples: `datasette-auth-github `__, `datasette-psutil `__ +Examples: `datasette-auth-github `__, `datasette-psutil `__ .. _plugin_register_facet_classes: @@ -695,7 +695,7 @@ This example plugin adds a ``x-databases`` HTTP header listing the currently att return add_x_databases_header return wrap_with_databases_header -Example: `datasette-cors `_ +Example: `datasette-cors `_ .. _plugin_hook_startup: @@ -743,7 +743,7 @@ Potential use-cases: await ds.invoke_startup() # Rest of test goes here -Examples: `datasette-saved-queries `__, `datasette-init `__ +Examples: `datasette-saved-queries `__, `datasette-init `__ .. _plugin_hook_canned_queries: @@ -812,7 +812,7 @@ The actor parameter can be used to include the currently authenticated actor in } for result in results} return inner -Example: `datasette-saved-queries `__ +Example: `datasette-saved-queries `__ .. _plugin_hook_actor_from_request: @@ -873,7 +873,7 @@ Instead of returning a dictionary, this function can return an awaitable functio return inner -Example: `datasette-auth-tokens `_ +Example: `datasette-auth-tokens `_ .. _plugin_hook_permission_allowed: @@ -932,7 +932,7 @@ Here's an example that allows users to view the ``admin_log`` table only if thei See :ref:`built-in permissions ` for a full list of permissions that are included in Datasette core. -Example: `datasette-permissions-sql `_ +Example: `datasette-permissions-sql `_ .. _plugin_hook_register_magic_parameters: @@ -1051,6 +1051,8 @@ This example adds a new menu item but only if the signed in user is ``"root"``: Using :ref:`internals_datasette_urls` here ensures that links in the menu will take the :ref:`setting_base_url` setting into account. +Examples: `datasette-search-all `_, `datasette-graphql `_ + .. _plugin_hook_table_actions: table_actions(datasette, actor, database, table, request) @@ -1089,6 +1091,8 @@ This example adds a new table action if the signed in user is ``"root"``: "label": "Edit schema for this table", }] +Example: `datasette-graphql `_ + .. _plugin_hook_database_actions: database_actions(datasette, actor, database, request) @@ -1108,6 +1112,8 @@ database_actions(datasette, actor, database, request) This hook is similar to :ref:`plugin_hook_table_actions` but populates an actions menu on the database page. +Example: `datasette-graphql `_ + .. _plugin_hook_skip_csrf: skip_csrf(datasette, scope) @@ -1172,3 +1178,5 @@ This hook is responsible for returning a dictionary corresponding to Datasette : # whatever we return here will be merged with any other plugins using this hook and # will be overwritten by a local metadata.yaml if one exists! return metadata + +Example: `datasette-remote-metadata plugin `__ From 61505dd0c6717cecdb73897e8613de9e9b7b6c42 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 6 Aug 2021 22:40:07 -0700 Subject: [PATCH 0091/1250] Release 0.59a0 Refs #1404, #1405, #1416, #1420, #1422 --- datasette/version.py | 2 +- docs/changelog.rst | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/datasette/version.py b/datasette/version.py index 1b7b7350..05704728 100644 --- a/datasette/version.py +++ b/datasette/version.py @@ -1,2 +1,2 @@ -__version__ = "0.58.1" +__version__ = "0.59a0" __version_info__ = tuple(__version__.split(".")) diff --git a/docs/changelog.rst b/docs/changelog.rst index d0fee19b..2cffef0f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,17 @@ Changelog ========= +.. _v0_59a0: + +0.59a0 (2021-08-06) +------------------- + +- :ref:`plugin_register_routes` plugin hook now accepts an optional ``datasette`` argument. (:issue:`1404`) +- New ``hide_sql`` canned query option for defaulting to hiding the SQL quey used by a canned query, see :ref:`canned_queries_options`. (:issue:`1422`) +- New ``--cpu`` option for :ref:`datasette publish cloudrun `. (:issue:`1420`) +- If `Rich `__ is installed in the same virtual environment as Datasette, it will be used to provide enhanced display of error tracebacks on the console. (:issue:`1416`) +- ``datasette.utils`` :ref:`internals_utils_parse_metadata` function, used by the new `datasette-remote-metadata plugin `__, is now a documented API. (:issue:`1405`) + .. _v0_58_1: 0.58.1 (2021-07-16) From de5ce2e56339ad8966f417a4758f7c210c017dec Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 8 Aug 2021 10:37:51 -0700 Subject: [PATCH 0092/1250] datasette-pyinstrument --- docs/plugin_hooks.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 200e0305..64c56309 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -695,7 +695,7 @@ This example plugin adds a ``x-databases`` HTTP header listing the currently att return add_x_databases_header return wrap_with_databases_header -Example: `datasette-cors `_ +Examples: `datasette-cors `__, `datasette-pyinstrument `__ .. _plugin_hook_startup: From 3bb6409a6cb8eaee32eb572423d9c0485a1dd917 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 8 Aug 2021 16:04:42 -0700 Subject: [PATCH 0093/1250] render_cell() can now return an awaitable, refs --- datasette/views/database.py | 1 + datasette/views/table.py | 1 + docs/plugin_hooks.rst | 4 +++- tests/fixtures.py | 1 + tests/plugins/my_plugin.py | 38 ++++++++++++++++++++++--------------- tests/test_api.py | 37 +++++++++++++++++++++++++++++++----- tests/test_plugins.py | 5 +++++ 7 files changed, 66 insertions(+), 21 deletions(-) diff --git a/datasette/views/database.py b/datasette/views/database.py index d9fe2b49..f835dfac 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -361,6 +361,7 @@ class QueryView(DataView): database=database, datasette=self.ds, ) + plugin_value = await await_me_maybe(plugin_value) if plugin_value is not None: display_value = plugin_value else: diff --git a/datasette/views/table.py b/datasette/views/table.py index 876a0c81..3d25a1a5 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -198,6 +198,7 @@ class RowTableShared(DataView): database=database, datasette=self.ds, ) + plugin_display_value = await await_me_maybe(plugin_display_value) if plugin_display_value is not None: display_value = plugin_display_value elif isinstance(value, bytes): diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 64c56309..5cdb1623 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -370,7 +370,7 @@ Lets you customize the display of values within table cells in the HTML table vi The name of the database ``datasette`` - :ref:`internals_datasette` - You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)`` + You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries. If your hook returns ``None``, it will be ignored. Use this to indicate that your hook is not able to custom render this particular value. @@ -378,6 +378,8 @@ If the hook returns a string, that string will be rendered in the table cell. If you want to return HTML markup you can do so by returning a ``jinja2.Markup`` object. +You can also return an awaitable function which returns a value. + Datasette will loop through all available ``render_cell`` hooks and display the value returned by the first one that does not return ``None``. Here is an example of a custom ``render_cell()`` plugin which looks for values that are a JSON string matching the following format:: diff --git a/tests/fixtures.py b/tests/fixtures.py index 873f9d55..880e4347 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -644,6 +644,7 @@ INSERT INTO simple_primary_key VALUES (1, 'hello'); INSERT INTO simple_primary_key VALUES (2, 'world'); INSERT INTO simple_primary_key VALUES (3, ''); INSERT INTO simple_primary_key VALUES (4, 'RENDER_CELL_DEMO'); +INSERT INTO simple_primary_key VALUES (5, 'RENDER_CELL_ASYNC'); INSERT INTO primary_key_multiple_columns VALUES (1, 'hey', 'world'); INSERT INTO primary_key_multiple_columns_explicit_label VALUES (1, 'hey', 'world2'); diff --git a/tests/plugins/my_plugin.py b/tests/plugins/my_plugin.py index 59ac8add..75c76ea8 100644 --- a/tests/plugins/my_plugin.py +++ b/tests/plugins/my_plugin.py @@ -97,21 +97,29 @@ def extra_body_script( @hookimpl def render_cell(value, column, table, database, datasette): - # Render some debug output in cell with value RENDER_CELL_DEMO - if value != "RENDER_CELL_DEMO": - return None - return json.dumps( - { - "column": column, - "table": table, - "database": database, - "config": datasette.plugin_config( - "name-of-plugin", - database=database, - table=table, - ), - } - ) + async def inner(): + # Render some debug output in cell with value RENDER_CELL_DEMO + if value == "RENDER_CELL_DEMO": + return json.dumps( + { + "column": column, + "table": table, + "database": database, + "config": datasette.plugin_config( + "name-of-plugin", + database=database, + table=table, + ), + } + ) + elif value == "RENDER_CELL_ASYNC": + return ( + await datasette.get_database(database).execute( + "select 'RENDER_CELL_ASYNC_RESULT'" + ) + ).single_value() + + return inner @hookimpl diff --git a/tests/test_api.py b/tests/test_api.py index 0049d76d..83cca521 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -415,7 +415,7 @@ def test_database_page(app_client): "name": "simple_primary_key", "columns": ["id", "content"], "primary_keys": ["id"], - "count": 4, + "count": 5, "hidden": False, "fts_table": None, "foreign_keys": { @@ -652,6 +652,7 @@ def test_custom_sql(app_client): {"content": "world"}, {"content": ""}, {"content": "RENDER_CELL_DEMO"}, + {"content": "RENDER_CELL_ASYNC"}, ] == data["rows"] assert ["content"] == data["columns"] assert "fixtures" == data["database"] @@ -693,6 +694,7 @@ def test_table_json(app_client): {"id": "2", "content": "world"}, {"id": "3", "content": ""}, {"id": "4", "content": "RENDER_CELL_DEMO"}, + {"id": "5", "content": "RENDER_CELL_ASYNC"}, ] @@ -723,6 +725,7 @@ def test_table_shape_arrays(app_client): ["2", "world"], ["3", ""], ["4", "RENDER_CELL_DEMO"], + ["5", "RENDER_CELL_ASYNC"], ] == response.json["rows"] @@ -736,7 +739,13 @@ def test_table_shape_arrayfirst(app_client): } ) ) - assert ["hello", "world", "", "RENDER_CELL_DEMO"] == response.json + assert [ + "hello", + "world", + "", + "RENDER_CELL_DEMO", + "RENDER_CELL_ASYNC", + ] == response.json def test_table_shape_objects(app_client): @@ -746,6 +755,7 @@ def test_table_shape_objects(app_client): {"id": "2", "content": "world"}, {"id": "3", "content": ""}, {"id": "4", "content": "RENDER_CELL_DEMO"}, + {"id": "5", "content": "RENDER_CELL_ASYNC"}, ] == response.json["rows"] @@ -756,6 +766,7 @@ def test_table_shape_array(app_client): {"id": "2", "content": "world"}, {"id": "3", "content": ""}, {"id": "4", "content": "RENDER_CELL_DEMO"}, + {"id": "5", "content": "RENDER_CELL_ASYNC"}, ] == response.json @@ -768,6 +779,7 @@ def test_table_shape_array_nl(app_client): {"id": "2", "content": "world"}, {"id": "3", "content": ""}, {"id": "4", "content": "RENDER_CELL_DEMO"}, + {"id": "5", "content": "RENDER_CELL_ASYNC"}, ] == results @@ -788,6 +800,7 @@ def test_table_shape_object(app_client): "2": {"id": "2", "content": "world"}, "3": {"id": "3", "content": ""}, "4": {"id": "4", "content": "RENDER_CELL_DEMO"}, + "5": {"id": "5", "content": "RENDER_CELL_ASYNC"}, } == response.json @@ -1145,12 +1158,21 @@ def test_searchable_invalid_column(app_client): ("/fixtures/simple_primary_key.json?content=hello", [["1", "hello"]]), ( "/fixtures/simple_primary_key.json?content__contains=o", - [["1", "hello"], ["2", "world"], ["4", "RENDER_CELL_DEMO"]], + [ + ["1", "hello"], + ["2", "world"], + ["4", "RENDER_CELL_DEMO"], + ], ), ("/fixtures/simple_primary_key.json?content__exact=", [["3", ""]]), ( "/fixtures/simple_primary_key.json?content__not=world", - [["1", "hello"], ["3", ""], ["4", "RENDER_CELL_DEMO"]], + [ + ["1", "hello"], + ["3", ""], + ["4", "RENDER_CELL_DEMO"], + ["5", "RENDER_CELL_ASYNC"], + ], ), ], ) @@ -1163,7 +1185,11 @@ def test_table_filter_queries_multiple_of_same_type(app_client): response = app_client.get( "/fixtures/simple_primary_key.json?content__not=world&content__not=hello" ) - assert [["3", ""], ["4", "RENDER_CELL_DEMO"]] == response.json["rows"] + assert [ + ["3", ""], + ["4", "RENDER_CELL_DEMO"], + ["5", "RENDER_CELL_ASYNC"], + ] == response.json["rows"] @pytest.mark.skipif(not detect_json1(), reason="Requires the SQLite json1 module") @@ -1293,6 +1319,7 @@ def test_view(app_client): {"upper_content": "WORLD", "content": "world"}, {"upper_content": "", "content": ""}, {"upper_content": "RENDER_CELL_DEMO", "content": "RENDER_CELL_DEMO"}, + {"upper_content": "RENDER_CELL_ASYNC", "content": "RENDER_CELL_ASYNC"}, ] diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 0c01b7ae..9bda7420 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -185,6 +185,11 @@ def test_hook_render_cell_demo(app_client): } == json.loads(td.string) +def test_hook_render_cell_async(app_client): + response = app_client.get("/fixtures?sql=select+'RENDER_CELL_ASYNC'") + assert b"RENDER_CELL_ASYNC_RESULT" in response.body + + def test_plugin_config(app_client): assert {"depth": "table"} == app_client.ds.plugin_config( "name-of-plugin", database="fixtures", table="sortable" From 818b0b76a2d58f7c2d850570efcdc22d345b4059 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 8 Aug 2021 16:07:52 -0700 Subject: [PATCH 0094/1250] Test table render_cell async as well as query results, refs #1425 --- tests/test_plugins.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 9bda7420..ec8ff0c5 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -185,8 +185,11 @@ def test_hook_render_cell_demo(app_client): } == json.loads(td.string) -def test_hook_render_cell_async(app_client): - response = app_client.get("/fixtures?sql=select+'RENDER_CELL_ASYNC'") +@pytest.mark.parametrize( + "path", ("/fixtures?sql=select+'RENDER_CELL_ASYNC'", "/fixtures/simple_primary_key") +) +def test_hook_render_cell_async(app_client, path): + response = app_client.get(path) assert b"RENDER_CELL_ASYNC_RESULT" in response.body From f3c9edb376a13c09b5ecf97c7390f4e49efaadf2 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 8 Aug 2021 16:11:40 -0700 Subject: [PATCH 0095/1250] Fixed some tests I broke in #1425 --- tests/test_csv.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_csv.py b/tests/test_csv.py index 3debf320..5e9406e7 100644 --- a/tests/test_csv.py +++ b/tests/test_csv.py @@ -11,6 +11,7 @@ EXPECTED_TABLE_CSV = """id,content 2,world 3, 4,RENDER_CELL_DEMO +5,RENDER_CELL_ASYNC """.replace( "\n", "\r\n" ) @@ -167,7 +168,7 @@ def test_csv_trace(app_client_with_trace): soup = Soup(response.text, "html.parser") assert ( soup.find("textarea").text - == "id,content\r\n1,hello\r\n2,world\r\n3,\r\n4,RENDER_CELL_DEMO\r\n" + == "id,content\r\n1,hello\r\n2,world\r\n3,\r\n4,RENDER_CELL_DEMO\r\n5,RENDER_CELL_ASYNC\r\n" ) assert "select id, content from simple_primary_key" in soup.find("pre").text From a390bdf9cef01d8723d025fc3348e81345ff4856 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 8 Aug 2021 17:38:42 -0700 Subject: [PATCH 0096/1250] Stop using firstresult=True on render_cell, refs #1425 See https://github.com/simonw/datasette/issues/1425#issuecomment-894883664 --- datasette/hookspecs.py | 2 +- datasette/views/database.py | 14 +++++++++----- datasette/views/table.py | 12 ++++++++---- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index f31ce538..56c79d23 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -59,7 +59,7 @@ def publish_subcommand(publish): """Subcommands for 'datasette publish'""" -@hookspec(firstresult=True) +@hookspec def render_cell(value, column, table, database, datasette): """Customize rendering of HTML table cell values""" diff --git a/datasette/views/database.py b/datasette/views/database.py index f835dfac..29600659 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -354,16 +354,20 @@ class QueryView(DataView): display_value = value # Let the plugins have a go # pylint: disable=no-member - plugin_value = pm.hook.render_cell( + plugin_display_value = None + for candidate in pm.hook.render_cell( value=value, column=column, table=None, database=database, datasette=self.ds, - ) - plugin_value = await await_me_maybe(plugin_value) - if plugin_value is not None: - display_value = plugin_value + ): + candidate = await await_me_maybe(candidate) + if candidate is not None: + plugin_display_value = candidate + break + if plugin_display_value is not None: + display_value = plugin_display_value else: if value in ("", None): display_value = Markup(" ") diff --git a/datasette/views/table.py b/datasette/views/table.py index 3d25a1a5..456d8069 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -191,15 +191,19 @@ class RowTableShared(DataView): # First let the plugins have a go # pylint: disable=no-member - plugin_display_value = pm.hook.render_cell( + plugin_display_value = None + for candidate in pm.hook.render_cell( value=value, column=column, table=table, database=database, datasette=self.ds, - ) - plugin_display_value = await await_me_maybe(plugin_display_value) - if plugin_display_value is not None: + ): + candidate = await await_me_maybe(candidate) + if candidate is not None: + plugin_display_value = candidate + break + if plugin_display_value: display_value = plugin_display_value elif isinstance(value, bytes): display_value = markupsafe.Markup( From ad90a72afa21b737b162e2bbdddc301a97d575cd Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 8 Aug 2021 18:13:03 -0700 Subject: [PATCH 0097/1250] Release 0.59a1 Refs #1425 --- datasette/version.py | 2 +- docs/changelog.rst | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/datasette/version.py b/datasette/version.py index 05704728..f5fbfb3f 100644 --- a/datasette/version.py +++ b/datasette/version.py @@ -1,2 +1,2 @@ -__version__ = "0.59a0" +__version__ = "0.59a1" __version_info__ = tuple(__version__.split(".")) diff --git a/docs/changelog.rst b/docs/changelog.rst index 2cffef0f..1406a7ca 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,13 @@ Changelog ========= +.. _v0_59a1: + +0.59a1 (2021-08-08) +------------------- + +- The :ref:`render_cell() ` plugin hook can now return an awaitable function. This means the hook can execute SQL queries. (:issue:`1425`) + .. _v0_59a0: 0.59a0 (2021-08-06) From fc4846850fffd54561bc125332dfe97bb41ff42e Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 8 Aug 2021 20:21:13 -0700 Subject: [PATCH 0098/1250] New way of deriving named parameters using explain, refs #1421 --- datasette/utils/__init__.py | 12 ++++++++++++ datasette/views/base.py | 1 - datasette/views/database.py | 5 ++++- tests/test_utils.py | 15 +++++++++++++++ 4 files changed, 31 insertions(+), 2 deletions(-) diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py index aec5a55b..44641a87 100644 --- a/datasette/utils/__init__.py +++ b/datasette/utils/__init__.py @@ -1076,3 +1076,15 @@ class PrefixedUrlString(str): class StartupError(Exception): pass + + +_re_named_parameter = re.compile(":([a-zA-Z0-9_]+)") + +async def derive_named_parameters(db, sql): + explain = 'explain {}'.format(sql.strip().rstrip(";")) + possible_params = _re_named_parameter.findall(sql) + try: + results = await db.execute(explain, {p: None for p in possible_params}) + return [row["p4"].lstrip(":") for row in results if row["opcode"] == "Variable"] + except sqlite3.DatabaseError: + return [] diff --git a/datasette/views/base.py b/datasette/views/base.py index cd584899..1cea1386 100644 --- a/datasette/views/base.py +++ b/datasette/views/base.py @@ -159,7 +159,6 @@ class BaseView: class DataView(BaseView): name = "" - re_named_parameter = re.compile(":([a-zA-Z0-9_]+)") async def options(self, request, *args, **kwargs): r = Response.text("ok") diff --git a/datasette/views/database.py b/datasette/views/database.py index 29600659..7c36034c 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -10,6 +10,7 @@ import markupsafe from datasette.utils import ( await_me_maybe, check_visibility, + derive_named_parameters, to_css_class, validate_sql_select, is_url, @@ -223,7 +224,9 @@ class QueryView(DataView): await self.check_permission(request, "execute-sql", database) # Extract any :named parameters - named_parameters = named_parameters or self.re_named_parameter.findall(sql) + named_parameters = named_parameters or await derive_named_parameters( + self.ds.get_database(database), sql + ) named_parameter_values = { named_parameter: params.get(named_parameter) or "" for named_parameter in named_parameters diff --git a/tests/test_utils.py b/tests/test_utils.py index 97b70ee5..e04efb4b 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -626,3 +626,18 @@ def test_parse_metadata(content, expected): utils.parse_metadata(content) else: assert utils.parse_metadata(content) == expected + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sql,expected", ( + ("select 1", []), + ("select 1 + :one", ["one"]), + ("select 1 + :one + :two", ["one", "two"]), + ("select 'bob' || '0:00' || :cat", ["cat"]), + ("select this is invalid", []), +)) +async def test_derive_named_parameters(sql, expected): + ds = Datasette([], memory=True) + db = ds.get_database("_memory") + params = await utils.derive_named_parameters(db, sql) + assert params == expected From b1fed48a95516ae84c0f020582303ab50ab817e2 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 8 Aug 2021 20:26:08 -0700 Subject: [PATCH 0099/1250] derive_named_parameters falls back to regex on SQL error, refs #1421 --- datasette/utils/__init__.py | 5 +++-- tests/test_utils.py | 17 ++++++++++------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py index 44641a87..70ac8976 100644 --- a/datasette/utils/__init__.py +++ b/datasette/utils/__init__.py @@ -1080,11 +1080,12 @@ class StartupError(Exception): _re_named_parameter = re.compile(":([a-zA-Z0-9_]+)") + async def derive_named_parameters(db, sql): - explain = 'explain {}'.format(sql.strip().rstrip(";")) + explain = "explain {}".format(sql.strip().rstrip(";")) possible_params = _re_named_parameter.findall(sql) try: results = await db.execute(explain, {p: None for p in possible_params}) return [row["p4"].lstrip(":") for row in results if row["opcode"] == "Variable"] except sqlite3.DatabaseError: - return [] + return possible_params diff --git a/tests/test_utils.py b/tests/test_utils.py index e04efb4b..e1b61072 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -629,13 +629,16 @@ def test_parse_metadata(content, expected): @pytest.mark.asyncio -@pytest.mark.parametrize("sql,expected", ( - ("select 1", []), - ("select 1 + :one", ["one"]), - ("select 1 + :one + :two", ["one", "two"]), - ("select 'bob' || '0:00' || :cat", ["cat"]), - ("select this is invalid", []), -)) +@pytest.mark.parametrize( + "sql,expected", + ( + ("select 1", []), + ("select 1 + :one", ["one"]), + ("select 1 + :one + :two", ["one", "two"]), + ("select 'bob' || '0:00' || :cat", ["cat"]), + ("select this is invalid :one, :two, :three", ["one", "two", "three"]), + ), +) async def test_derive_named_parameters(sql, expected): ds = Datasette([], memory=True) db = ds.get_database("_memory") From e837095ef35ae155b4c78cc9a8b7133a48c94f03 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 12 Aug 2021 16:53:23 -0700 Subject: [PATCH 0100/1250] Column metadata, closes #942 --- datasette/static/app.css | 17 ++++++++++++++++- datasette/static/table.js | 9 +++++++++ datasette/templates/_table.html | 2 +- datasette/templates/table.html | 8 ++++++++ datasette/views/table.py | 2 ++ docs/metadata.rst | 28 ++++++++++++++++++++++++++++ tests/fixtures.py | 6 ++++++ tests/test_html.py | 18 ++++++++++++++++++ 8 files changed, 88 insertions(+), 2 deletions(-) diff --git a/datasette/static/app.css b/datasette/static/app.css index c6be1e97..bf068fdf 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -784,9 +784,14 @@ svg.dropdown-menu-icon { font-size: 0.7em; color: #666; margin: 0; - padding: 0; padding: 4px 8px 4px 8px; } +.dropdown-menu .dropdown-column-description { + margin: 0; + color: #666; + padding: 4px 8px 4px 8px; + max-width: 20em; +} .dropdown-menu li { border-bottom: 1px solid #ccc; } @@ -836,6 +841,16 @@ svg.dropdown-menu-icon { background-repeat: no-repeat; } +dl.column-descriptions dt { + font-weight: bold; +} +dl.column-descriptions dd { + padding-left: 1.5em; + white-space: pre-wrap; + line-height: 1.1em; + color: #666; +} + .anim-scale-in { animation-name: scale-in; animation-duration: 0.15s; diff --git a/datasette/static/table.js b/datasette/static/table.js index 991346df..85bf073f 100644 --- a/datasette/static/table.js +++ b/datasette/static/table.js @@ -9,6 +9,7 @@ var DROPDOWN_HTML = ``; var DROPDOWN_ICON_SVG = ` @@ -166,6 +167,14 @@ var DROPDOWN_ICON_SVG = `
{% for column in display_columns %} - + {% if not column.sortable %} {{ column.name }} {% else %} diff --git a/datasette/templates/table.html b/datasette/templates/table.html index 211352b5..466e8a47 100644 --- a/datasette/templates/table.html +++ b/datasette/templates/table.html @@ -51,6 +51,14 @@ {% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %} +{% if metadata.columns %} +
+ {% for column_name, column_description in metadata.columns.items() %} +
{{ column_name }}
{{ column_description }}
+ {% endfor %} +
+{% endif %} + {% if filtered_table_rows_count or human_description_en %}

{% if filtered_table_rows_count or filtered_table_rows_count == 0 %}{{ "{:,}".format(filtered_table_rows_count) }} row{% if filtered_table_rows_count == 1 %}{% else %}s{% endif %}{% endif %} {% if human_description_en %}{{ human_description_en }}{% endif %} diff --git a/datasette/views/table.py b/datasette/views/table.py index 456d8069..486a6131 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -125,6 +125,7 @@ class RowTableShared(DataView): """Returns columns, rows for specified table - including fancy foreign key treatment""" db = self.ds.databases[database] table_metadata = self.ds.table_metadata(database, table) + column_descriptions = table_metadata.get("columns") or {} column_details = {col.name: col for col in await db.table_column_details(table)} sortable_columns = await self.sortable_columns_for_table(database, table, True) pks = await db.primary_keys(table) @@ -147,6 +148,7 @@ class RowTableShared(DataView): "is_pk": r[0] in pks_for_display, "type": type_, "notnull": notnull, + "description": column_descriptions.get(r[0]), } ) diff --git a/docs/metadata.rst b/docs/metadata.rst index dad5adca..35b8aede 100644 --- a/docs/metadata.rst +++ b/docs/metadata.rst @@ -78,6 +78,34 @@ The three visible metadata fields you can apply to everything, specific database For each of these you can provide just the ``*_url`` field and Datasette will treat that as the default link label text and display the URL directly on the page. +.. _metadata_column_descriptions: + +Column descriptions +------------------- + +You can include descriptions for your columns by adding a ``"columns": {"name-of-column": "description-of-column"}`` block to your table metadata: + +.. code-block:: json + + { + "databases": { + "database1": { + "tables": { + "example_table": { + "columns": { + "column1": "Description of column 1", + "column2": "Description of column 2" + } + } + } + } + } + } + +These will be displayed at the top of the table page, and will also show in the cog menu for each column. + +You can see an example of how these look at `latest.datasette.io/fixtures/roadside_attractions `__. + Specifying units for a column ----------------------------- diff --git a/tests/fixtures.py b/tests/fixtures.py index 880e4347..4a420e4b 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -336,6 +336,12 @@ METADATA = { "fts_table": "searchable_fts", "fts_pk": "pk", }, + "roadside_attractions": { + "columns": { + "name": "The name of the attraction", + "address": "The street address for the attraction", + } + }, "attraction_characteristic": {"sort_desc": "pk"}, "facet_cities": {"sort": "name"}, "paginated_view": {"size": 25}, diff --git a/tests/test_html.py b/tests/test_html.py index b1b6c1f3..f12f89cd 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -1777,3 +1777,21 @@ def test_trace_correctly_escaped(app_client): response = app_client.get("/fixtures?sql=select+'

Hello'&_trace=1") assert "select '

Hello" not in response.text assert "select '<h1>Hello" in response.text + + +def test_column_metadata(app_client): + response = app_client.get("/fixtures/roadside_attractions") + soup = Soup(response.body, "html.parser") + dl = soup.find("dl") + assert [(dt.text, dt.nextSibling.text) for dt in dl.findAll("dt")] == [ + ("name", "The name of the attraction"), + ("address", "The street address for the attraction"), + ] + assert ( + soup.select("th[data-column=name]")[0]["data-column-description"] + == "The name of the attraction" + ) + assert ( + soup.select("th[data-column=address]")[0]["data-column-description"] + == "The street address for the attraction" + ) From 77f46297a88ac7e49dad2139410b01ee56d5f99c Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 12 Aug 2021 18:01:57 -0700 Subject: [PATCH 0101/1250] Rename --help-config to --help-settings, closes #1431 --- datasette/cli.py | 12 ++++++------ docs/datasette-serve-help.txt | 2 +- tests/test_cli.py | 10 +++++++++- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/datasette/cli.py b/datasette/cli.py index e53f3d8e..d4e23c70 100644 --- a/datasette/cli.py +++ b/datasette/cli.py @@ -51,7 +51,7 @@ class Config(click.ParamType): name, value = config.split(":", 1) if name not in DEFAULT_SETTINGS: self.fail( - f"{name} is not a valid option (--help-config to see all)", + f"{name} is not a valid option (--help-settings to see all)", param, ctx, ) @@ -84,7 +84,7 @@ class Setting(CompositeParamType): name, value = config if name not in DEFAULT_SETTINGS: self.fail( - f"{name} is not a valid option (--help-config to see all)", + f"{name} is not a valid option (--help-settings to see all)", param, ctx, ) @@ -408,7 +408,7 @@ def uninstall(packages, yes): help="Run an HTTP GET request against this path, print results and exit", ) @click.option("--version-note", help="Additional note to show on /-/versions") -@click.option("--help-config", is_flag=True, help="Show available config options") +@click.option("--help-settings", is_flag=True, help="Show available settings") @click.option("--pdb", is_flag=True, help="Launch debugger on any errors") @click.option( "-o", @@ -456,7 +456,7 @@ def serve( root, get, version_note, - help_config, + help_settings, pdb, open_browser, create, @@ -466,9 +466,9 @@ def serve( return_instance=False, ): """Serve up specified SQLite database files with a web UI""" - if help_config: + if help_settings: formatter = formatting.HelpFormatter() - with formatter.section("Config options"): + with formatter.section("Settings"): formatter.write_dl( [ (option.name, f"{option.help} (default={option.default})") diff --git a/docs/datasette-serve-help.txt b/docs/datasette-serve-help.txt index ec3f41a0..2911977a 100644 --- a/docs/datasette-serve-help.txt +++ b/docs/datasette-serve-help.txt @@ -32,7 +32,7 @@ Options: --get TEXT Run an HTTP GET request against this path, print results and exit --version-note TEXT Additional note to show on /-/versions - --help-config Show available config options + --help-settings Show available settings --pdb Launch debugger on any errors -o, --open Open Datasette in your web browser --create Create database files if they do not exist diff --git a/tests/test_cli.py b/tests/test_cli.py index e31a305e..763fe2e7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -5,6 +5,7 @@ from .fixtures import ( EXPECTED_PLUGINS, ) import asyncio +from datasette.app import SETTINGS from datasette.plugins import DEFAULT_PLUGINS from datasette.cli import cli, serve from datasette.version import __version__ @@ -147,7 +148,7 @@ def test_metadata_yaml(): root=False, version_note=None, get=None, - help_config=False, + help_settings=False, pdb=False, crossdb=False, open_browser=False, @@ -291,3 +292,10 @@ def test_weird_database_names(ensure_eventloop, tmpdir, filename): cli, [db_path, "--get", "/{}".format(urllib.parse.quote(filename_no_stem))] ) assert result2.exit_code == 0, result2.output + + +def test_help_settings(): + runner = CliRunner() + result = runner.invoke(cli, ["--help-settings"]) + for setting in SETTINGS: + assert setting.name in result.output From ca4f83dc7b1d573b92a8921fca96d3ed490614c3 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 12 Aug 2021 18:10:36 -0700 Subject: [PATCH 0102/1250] Rename config= to settings=, refs #1432 --- datasette/app.py | 8 ++++---- datasette/cli.py | 8 ++++---- datasette/templates/table.html | 2 +- datasette/views/base.py | 2 +- datasette/views/database.py | 2 +- tests/fixtures.py | 20 ++++++++++---------- tests/test_api.py | 8 ++++---- tests/test_custom_pages.py | 2 +- tests/test_facets.py | 2 +- tests/test_html.py | 14 ++++++++------ 10 files changed, 35 insertions(+), 33 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index f2f75884..8cbaaf9f 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -200,7 +200,7 @@ class Datasette: plugins_dir=None, static_mounts=None, memory=False, - config=None, + settings=None, secret=None, version_note=None, config_dir=None, @@ -279,7 +279,7 @@ class Datasette: raise StartupError("config.json should be renamed to settings.json") if config_dir and (config_dir / "settings.json").exists() and not config: config = json.loads((config_dir / "settings.json").read_text()) - self._settings = dict(DEFAULT_SETTINGS, **(config or {})) + self._settings = dict(DEFAULT_SETTINGS, **(settings or {})) self.renderers = {} # File extension -> (renderer, can_render) functions self.version_note = version_note self.executor = futures.ThreadPoolExecutor( @@ -419,8 +419,8 @@ class Datasette: def setting(self, key): return self._settings.get(key, None) - def config_dict(self): - # Returns a fully resolved config dictionary, useful for templates + def settings_dict(self): + # Returns a fully resolved settings dictionary, useful for templates return {option.name: self.setting(option.name) for option in SETTINGS} def _metadata_recursive_update(self, orig, updated): diff --git a/datasette/cli.py b/datasette/cli.py index d4e23c70..ea6da748 100644 --- a/datasette/cli.py +++ b/datasette/cli.py @@ -495,14 +495,14 @@ def serve( if metadata: metadata_data = parse_metadata(metadata.read()) - combined_config = {} + combined_settings = {} if config: click.echo( "--config name:value will be deprecated in Datasette 1.0, use --setting name value instead", err=True, ) - combined_config.update(config) - combined_config.update(settings) + combined_settings.update(config) + combined_settings.update(settings) kwargs = dict( immutables=immutable, @@ -514,7 +514,7 @@ def serve( template_dir=template_dir, plugins_dir=plugins_dir, static_mounts=static, - config=combined_config, + settings=combined_settings, memory=memory, secret=secret, version_note=version_note, diff --git a/datasette/templates/table.html b/datasette/templates/table.html index 466e8a47..a28945ad 100644 --- a/datasette/templates/table.html +++ b/datasette/templates/table.html @@ -201,7 +201,7 @@ CSV options: {% if expandable_columns %}{% endif %} - {% if next_url and config.allow_csv_stream %}{% endif %} + {% if next_url and settings.allow_csv_stream %}{% endif %} {% for key, value in url_csv_hidden_args %} diff --git a/datasette/views/base.py b/datasette/views/base.py index 1cea1386..3333781c 100644 --- a/datasette/views/base.py +++ b/datasette/views/base.py @@ -614,7 +614,7 @@ class DataView(BaseView): ] + [("_size", "max")], "datasette_version": __version__, - "config": self.ds.config_dict(), + "settings": self.ds.settings_dict(), }, } if "metadata" not in context: diff --git a/datasette/views/database.py b/datasette/views/database.py index 7c36034c..e3070ce6 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -456,7 +456,7 @@ class QueryView(DataView): "canned_query": canned_query, "edit_sql_url": edit_sql_url, "metadata": metadata, - "config": self.ds.config_dict(), + "settings": self.ds.settings_dict(), "request": request, "show_hide_link": show_hide_link, "show_hide_text": show_hide_text, diff --git a/tests/fixtures.py b/tests/fixtures.py index 4a420e4b..dc22c609 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -99,7 +99,7 @@ def make_app_client( max_returned_rows=None, cors=False, memory=False, - config=None, + settings=None, filename="fixtures.db", is_immutable=False, extra_databases=None, @@ -129,7 +129,7 @@ def make_app_client( # Insert at start to help test /-/databases ordering: files.insert(0, extra_filepath) os.chdir(os.path.dirname(filepath)) - config = config or {} + settings = settings or {} for key, value in { "default_page_size": 50, "max_returned_rows": max_returned_rows or 100, @@ -138,8 +138,8 @@ def make_app_client( # errors when running the full test suite: "num_sql_threads": 1, }.items(): - if key not in config: - config[key] = value + if key not in settings: + settings[key] = value ds = Datasette( files, immutables=immutables, @@ -147,7 +147,7 @@ def make_app_client( cors=cors, metadata=metadata or METADATA, plugins_dir=PLUGINS_DIR, - config=config, + settings=settings, inspect_data=inspect_data, static_mounts=static_mounts, template_dir=template_dir, @@ -171,7 +171,7 @@ def app_client_no_files(): @pytest.fixture(scope="session") def app_client_base_url_prefix(): - with make_app_client(config={"base_url": "/prefix/"}) as client: + with make_app_client(settings={"base_url": "/prefix/"}) as client: yield client @@ -210,13 +210,13 @@ def app_client_two_attached_databases_one_immutable(): @pytest.fixture(scope="session") def app_client_with_hash(): - with make_app_client(config={"hash_urls": True}, is_immutable=True) as client: + with make_app_client(settings={"hash_urls": True}, is_immutable=True) as client: yield client @pytest.fixture(scope="session") def app_client_with_trace(): - with make_app_client(config={"trace_debug": True}, is_immutable=True) as client: + with make_app_client(settings={"trace_debug": True}, is_immutable=True) as client: yield client @@ -234,13 +234,13 @@ def app_client_returned_rows_matches_page_size(): @pytest.fixture(scope="session") def app_client_larger_cache_size(): - with make_app_client(config={"cache_size_kb": 2500}) as client: + with make_app_client(settings={"cache_size_kb": 2500}) as client: yield client @pytest.fixture(scope="session") def app_client_csv_max_mb_one(): - with make_app_client(config={"max_csv_mb": 1}) as client: + with make_app_client(settings={"max_csv_mb": 1}) as client: yield client diff --git a/tests/test_api.py b/tests/test_api.py index 83cca521..1e93c62e 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1711,14 +1711,14 @@ def test_suggested_facets(app_client): def test_allow_facet_off(): - with make_app_client(config={"allow_facet": False}) as client: + with make_app_client(settings={"allow_facet": False}) as client: assert 400 == client.get("/fixtures/facetable.json?_facet=planet_int").status # Should not suggest any facets either: assert [] == client.get("/fixtures/facetable.json").json["suggested_facets"] def test_suggest_facets_off(): - with make_app_client(config={"suggest_facets": False}) as client: + with make_app_client(settings={"suggest_facets": False}) as client: # Now suggested_facets should be [] assert [] == client.get("/fixtures/facetable.json").json["suggested_facets"] @@ -1883,7 +1883,7 @@ def test_config_cache_size(app_client_larger_cache_size): def test_config_force_https_urls(): - with make_app_client(config={"force_https_urls": True}) as client: + with make_app_client(settings={"force_https_urls": True}) as client: response = client.get("/fixtures/facetable.json?_size=3&_facet=state") assert response.json["next_url"].startswith("https://") assert response.json["facet_results"]["state"]["results"][0][ @@ -1921,7 +1921,7 @@ def test_custom_query_with_unicode_characters(app_client): @pytest.mark.parametrize("trace_debug", (True, False)) def test_trace(trace_debug): - with make_app_client(config={"trace_debug": trace_debug}) as client: + with make_app_client(settings={"trace_debug": trace_debug}) as client: response = client.get("/fixtures/simple_primary_key.json?_trace=1") assert response.status == 200 diff --git a/tests/test_custom_pages.py b/tests/test_custom_pages.py index 5a71f56d..76c67397 100644 --- a/tests/test_custom_pages.py +++ b/tests/test_custom_pages.py @@ -14,7 +14,7 @@ def custom_pages_client(): @pytest.fixture(scope="session") def custom_pages_client_with_base_url(): with make_app_client( - template_dir=TEST_TEMPLATE_DIRS, config={"base_url": "/prefix/"} + template_dir=TEST_TEMPLATE_DIRS, settings={"base_url": "/prefix/"} ) as client: yield client diff --git a/tests/test_facets.py b/tests/test_facets.py index 18fb8c3b..22927512 100644 --- a/tests/test_facets.py +++ b/tests/test_facets.py @@ -351,7 +351,7 @@ async def test_json_array_with_blanks_and_nulls(): @pytest.mark.asyncio async def test_facet_size(): - ds = Datasette([], memory=True, config={"max_returned_rows": 50}) + ds = Datasette([], memory=True, settings={"max_returned_rows": 50}) db = ds.add_database(Database(ds, memory_name="test_facet_size")) await db.execute_write( "create table neighbourhoods(city text, neighbourhood text)", block=True diff --git a/tests/test_html.py b/tests/test_html.py index f12f89cd..90fcdae7 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -214,7 +214,7 @@ def test_definition_sql(path, expected_definition_sql, app_client): def test_table_cell_truncation(): - with make_app_client(config={"truncate_cells_html": 5}) as client: + with make_app_client(settings={"truncate_cells_html": 5}) as client: response = client.get("/fixtures/facetable") assert response.status == 200 table = Soup(response.body, "html.parser").find("table") @@ -239,7 +239,7 @@ def test_table_cell_truncation(): def test_row_page_does_not_truncate(): - with make_app_client(config={"truncate_cells_html": 5}) as client: + with make_app_client(settings={"truncate_cells_html": 5}) as client: response = client.get("/fixtures/facetable/1") assert response.status == 200 table = Soup(response.body, "html.parser").find("table") @@ -1072,7 +1072,9 @@ def test_database_download_disallowed_for_memory(): def test_allow_download_off(): - with make_app_client(is_immutable=True, config={"allow_download": False}) as client: + with make_app_client( + is_immutable=True, settings={"allow_download": False} + ) as client: response = client.get("/fixtures") soup = Soup(response.body, "html.parser") assert not len(soup.findAll("a", {"href": re.compile(r"\.db$")})) @@ -1486,7 +1488,7 @@ def test_query_error(app_client): def test_config_template_debug_on(): - with make_app_client(config={"template_debug": True}) as client: + with make_app_client(settings={"template_debug": True}) as client: response = client.get("/fixtures/facetable?_context=1") assert response.status == 200 assert response.text.startswith("
{")
@@ -1500,7 +1502,7 @@ def test_config_template_debug_off(app_client):
 
 def test_debug_context_includes_extra_template_vars():
     # https://github.com/simonw/datasette/issues/693
-    with make_app_client(config={"template_debug": True}) as client:
+    with make_app_client(settings={"template_debug": True}) as client:
         response = client.get("/fixtures/facetable?_context=1")
         # scope_path is added by PLUGIN1
         assert "scope_path" in response.text
@@ -1744,7 +1746,7 @@ def test_facet_more_links(
     expected_ellipses_url,
 ):
     with make_app_client(
-        config={"max_returned_rows": max_returned_rows, "default_facet_size": 2}
+        settings={"max_returned_rows": max_returned_rows, "default_facet_size": 2}
     ) as client:
         response = client.get(path)
         soup = Soup(response.body, "html.parser")

From bbc4756f9e8180c7a40c57f8a35e39dee7be7807 Mon Sep 17 00:00:00 2001
From: Simon Willison 
Date: Thu, 12 Aug 2021 20:54:25 -0700
Subject: [PATCH 0103/1250] Settings fix, refs #1433

---
 datasette/app.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/datasette/app.py b/datasette/app.py
index 8cbaaf9f..adc543ef 100644
--- a/datasette/app.py
+++ b/datasette/app.py
@@ -277,7 +277,7 @@ class Datasette:
         self.static_mounts = static_mounts or []
         if config_dir and (config_dir / "config.json").exists():
             raise StartupError("config.json should be renamed to settings.json")
-        if config_dir and (config_dir / "settings.json").exists() and not config:
+        if config_dir and (config_dir / "settings.json").exists() and not settings:
             config = json.loads((config_dir / "settings.json").read_text())
         self._settings = dict(DEFAULT_SETTINGS, **(settings or {}))
         self.renderers = {}  # File extension -> (renderer, can_render) functions

From 2883098770fc66e50183b2b231edbde20848d4d6 Mon Sep 17 00:00:00 2001
From: Simon Willison 
Date: Thu, 12 Aug 2021 22:10:07 -0700
Subject: [PATCH 0104/1250] Fixed config_dir mode, refs #1432

---
 datasette/app.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/datasette/app.py b/datasette/app.py
index adc543ef..06db740e 100644
--- a/datasette/app.py
+++ b/datasette/app.py
@@ -278,7 +278,7 @@ class Datasette:
         if config_dir and (config_dir / "config.json").exists():
             raise StartupError("config.json should be renamed to settings.json")
         if config_dir and (config_dir / "settings.json").exists() and not settings:
-            config = json.loads((config_dir / "settings.json").read_text())
+            settings = json.loads((config_dir / "settings.json").read_text())
         self._settings = dict(DEFAULT_SETTINGS, **(settings or {}))
         self.renderers = {}  # File extension -> (renderer, can_render) functions
         self.version_note = version_note

From adb5b70de5cec3c3dd37184defe606a082c232cf Mon Sep 17 00:00:00 2001
From: Simon Willison 
Date: Mon, 16 Aug 2021 11:56:32 -0700
Subject: [PATCH 0105/1250] Show count of facet values if ?_facet_size=max,
 closes #1423

---
 datasette/static/app.css       |  5 +++++
 datasette/templates/table.html |  4 +++-
 datasette/views/table.py       |  1 +
 tests/test_html.py             | 22 +++++++++++++++++++++-
 4 files changed, 30 insertions(+), 2 deletions(-)

diff --git a/datasette/static/app.css b/datasette/static/app.css
index bf068fdf..af3e14d5 100644
--- a/datasette/static/app.css
+++ b/datasette/static/app.css
@@ -633,6 +633,11 @@ form button[type=button] {
     width: 250px;
     margin-right: 15px;
 }
+.facet-info-total {
+    font-size: 0.8em;
+    color: #666;
+    padding-right: 0.25em;
+}
 .facet-info li,
 .facet-info ul {
     margin: 0;
diff --git a/datasette/templates/table.html b/datasette/templates/table.html
index a28945ad..6ba301b5 100644
--- a/datasette/templates/table.html
+++ b/datasette/templates/table.html
@@ -156,7 +156,9 @@
         {% for facet_info in sorted_facet_results %}
             

- {{ facet_info.name }}{% if facet_info.type != "column" %} ({{ facet_info.type }}){% endif %} + {{ facet_info.name }}{% if facet_info.type != "column" %} ({{ facet_info.type }}){% endif %} + {% if show_facet_counts %} {% if facet_info.truncated %}>{% endif %}{{ facet_info.results|length }}{% endif %} + {% if facet_info.hideable %} {% endif %} diff --git a/datasette/views/table.py b/datasette/views/table.py index 486a6131..83f7c7cb 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -928,6 +928,7 @@ class TableView(RowTableShared): key=lambda f: (len(f["results"]), f["name"]), reverse=True, ), + "show_facet_counts": special_args.get("_facet_size") == "max", "extra_wheres_for_ui": extra_wheres_for_ui, "form_hidden_args": form_hidden_args, "is_sortable": any(c["sortable"] for c in display_columns), diff --git a/tests/test_html.py b/tests/test_html.py index 90fcdae7..e73ccd2f 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -479,7 +479,7 @@ def test_facet_display(app_client): for div in divs: actual.append( { - "name": div.find("strong").text, + "name": div.find("strong").text.split()[0], "items": [ { "name": a.text, @@ -1797,3 +1797,23 @@ def test_column_metadata(app_client): soup.select("th[data-column=address]")[0]["data-column-description"] == "The street address for the attraction" ) + + +@pytest.mark.parametrize("use_facet_size_max", (True, False)) +def test_facet_total_shown_if_facet_max_size(use_facet_size_max): + # https://github.com/simonw/datasette/issues/1423 + with make_app_client(settings={"max_returned_rows": 100}) as client: + path = "/fixtures/sortable?_facet=content&_facet=pk1" + if use_facet_size_max: + path += "&_facet_size=max" + response = client.get(path) + assert response.status == 200 + fragments = ( + '>100', + '8', + ) + for fragment in fragments: + if use_facet_size_max: + assert fragment in response.text + else: + assert fragment not in response.text From d84e574e59c51ddcd6cf60a6f9b3d45182daf824 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 19 Aug 2021 14:09:38 -0700 Subject: [PATCH 0106/1250] Ability to deploy demos of branches * Ability to deploy additional branch demos, closes #1442 * Only run tests before deploy on main branch * Documentation for continuous deployment --- .github/workflows/deploy-latest.yml | 8 +++++++- docs/contributing.rst | 11 +++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy-latest.yml b/.github/workflows/deploy-latest.yml index 849adb40..1a07503a 100644 --- a/.github/workflows/deploy-latest.yml +++ b/.github/workflows/deploy-latest.yml @@ -29,6 +29,7 @@ jobs: python -m pip install -e .[docs] python -m pip install sphinx-to-sqlite==0.1a1 - name: Run tests + if: ${{ github.ref == 'refs/heads/main' }} run: | pytest -n auto -m "not serial" pytest -m "serial" @@ -50,6 +51,8 @@ jobs: run: |- gcloud config set run/region us-central1 gcloud config set project datasette-222320 + export SUFFIX="-${GITHUB_REF#refs/heads/}" + export SUFFIX=${SUFFIX#-main} datasette publish cloudrun fixtures.db extra_database.db \ -m fixtures.json \ --plugins-dir=plugins \ @@ -57,7 +60,10 @@ jobs: --version-note=$GITHUB_SHA \ --extra-options="--setting template_debug 1 --setting trace_debug 1 --crossdb" \ --install=pysqlite3-binary \ - --service=datasette-latest + --service "datasette-latest$SUFFIX" + - name: Deploy to docs as well (only for main) + if: ${{ github.ref == 'refs/heads/main' }} + run: |- # Deploy docs.db to a different service datasette publish cloudrun docs.db \ --branch=$GITHUB_SHA \ diff --git a/docs/contributing.rst b/docs/contributing.rst index 8a638e0b..07f2a0e4 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -202,6 +202,17 @@ For added productivity, you can use use `sphinx-autobuild `__ is re-deployed automatically to Google Cloud Run for every push to ``main`` that passes the test suite. This is implemented by the GitHub Actions workflow at `.github/workflows/deploy-latest.yml `__. + +Specific branches can also be set to automatically deploy by adding them to the ``on: push: branches`` block at the top of the workflow YAML file. Branches configured in this way will be deployed to a new Cloud Run service whether or not their tests pass. + +The Cloud Run URL for a branch demo can be found in the GitHub Actions logs. + .. _contributing_release: Release process From 4eb3ae40fb223a66ae574fb84fac99e96183b08d Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 19 Aug 2021 14:17:44 -0700 Subject: [PATCH 0107/1250] Don't bother building docs if not on main Refs ##1442 --- .github/workflows/deploy-latest.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/deploy-latest.yml b/.github/workflows/deploy-latest.yml index 1a07503a..1ae96e89 100644 --- a/.github/workflows/deploy-latest.yml +++ b/.github/workflows/deploy-latest.yml @@ -36,6 +36,7 @@ jobs: - name: Build fixtures.db run: python tests/fixtures.py fixtures.db fixtures.json plugins --extra-db-filename extra_database.db - name: Build docs.db + if: ${{ github.ref == 'refs/heads/main' }} run: |- cd docs sphinx-build -b xml . _build From 7e15422aacfa9e9735cb9f9beaa32250edbf4905 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 19 Aug 2021 14:23:43 -0700 Subject: [PATCH 0108/1250] Documentation for datasette.databases property, closes #1443 --- docs/internals.rst | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/internals.rst b/docs/internals.rst index 058a8969..d5db7ffa 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -196,6 +196,17 @@ Datasette class This object is an instance of the ``Datasette`` class, passed to many plugin hooks as an argument called ``datasette``. +.. _datasette_databases: + +.databases +---------- + +Property exposing an ordered dictionary of databases currently connected to Datasette. + +The dictionary keys are the name of the database that is used in the URL - e.g. ``/fixtures`` would have a key of ``"fixtures"``. The values are :ref:`internals_database` instances. + +All databases are listed, irrespective of user permissions. This means that the ``_internal`` database will always be listed here. + .. _datasette_plugin_config: .plugin_config(plugin_name, database=None, table=None) From 92a99d969c01633dba14cceebeda65daaedaec17 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 24 Aug 2021 11:13:42 -0700 Subject: [PATCH 0109/1250] Added not-footer wrapper div, refs #1446 --- datasette/templates/base.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/datasette/templates/base.html b/datasette/templates/base.html index e61edc4f..c9aa7e31 100644 --- a/datasette/templates/base.html +++ b/datasette/templates/base.html @@ -13,6 +13,7 @@ {% block extra_head %}{% endblock %} +

{% block footer %}{% include "_footer.html" %}{% endblock %}
{% include "_close_open_menus.html" %} From 93c3a7ffbfb3378f743ebce87d033cf1ce7689e0 Mon Sep 17 00:00:00 2001 From: Tim Sherratt Date: Wed, 25 Aug 2021 11:28:58 +1000 Subject: [PATCH 0110/1250] Remove underscore from search mode parameter name (#1447) The text refers to the parameter as `searchmode` but the `metadata.json` example uses `search_mode`. The latter doesn't actually seem to work. --- docs/full_text_search.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/full_text_search.rst b/docs/full_text_search.rst index f549296f..90b2e8c1 100644 --- a/docs/full_text_search.rst +++ b/docs/full_text_search.rst @@ -70,7 +70,7 @@ Here is an example which enables full-text search (with SQLite advanced search o "display_ads": { "fts_table": "ads_fts", "fts_pk": "id", - "search_mode": "raw" + "searchmode": "raw" } } } From 5161422b7fa249c6b7d6dc47ec6f483d3fdbd170 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 24 Aug 2021 18:29:26 -0700 Subject: [PATCH 0111/1250] Update trustme requirement from <0.9,>=0.7 to >=0.7,<0.10 (#1433) Updates the requirements on [trustme](https://github.com/python-trio/trustme) to permit the latest version. - [Release notes](https://github.com/python-trio/trustme/releases) - [Commits](https://github.com/python-trio/trustme/compare/v0.7.0...v0.9.0) --- updated-dependencies: - dependency-name: trustme dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 65e99848..a3866515 100644 --- a/setup.py +++ b/setup.py @@ -73,7 +73,7 @@ setup( "beautifulsoup4>=4.8.1,<4.10.0", "black==21.6b0", "pytest-timeout>=1.4.2,<1.5", - "trustme>=0.7,<0.9", + "trustme>=0.7,<0.10", ], "rich": ["rich"], }, From a1a33bb5822214be1cebd98cd858b2058d91a4aa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 24 Aug 2021 18:29:55 -0700 Subject: [PATCH 0112/1250] Bump black from 21.6b0 to 21.7b0 (#1400) Bumps [black](https://github.com/psf/black) from 21.6b0 to 21.7b0. - [Release notes](https://github.com/psf/black/releases) - [Changelog](https://github.com/psf/black/blob/main/CHANGES.md) - [Commits](https://github.com/psf/black/commits) --- updated-dependencies: - dependency-name: black dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index a3866515..84f32087 100644 --- a/setup.py +++ b/setup.py @@ -71,7 +71,7 @@ setup( "pytest-xdist>=2.2.1,<2.4", "pytest-asyncio>=0.10,<0.16", "beautifulsoup4>=4.8.1,<4.10.0", - "black==21.6b0", + "black==21.7b0", "pytest-timeout>=1.4.2,<1.5", "trustme>=0.7,<0.10", ], From 3655bb49a464bcc8004e491cc4d4de292f1acd62 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 27 Aug 2021 17:48:54 -0700 Subject: [PATCH 0113/1250] Better default help text, closes #1450 --- datasette/cli.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/datasette/cli.py b/datasette/cli.py index ea6da748..65da5613 100644 --- a/datasette/cli.py +++ b/datasette/cli.py @@ -123,7 +123,11 @@ def sqlite_extensions(fn): @click.version_option(version=__version__) def cli(): """ - Datasette! + Datasette is an open source multi-tool for exploring and publishing data + + \b + About Datasette: https://datasette.io/ + Full documentation: https://docs.datasette.io/ """ From 30c18576d603366dc3bd83ba50de1b7e70844430 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 27 Aug 2021 18:39:42 -0700 Subject: [PATCH 0114/1250] register_commands() plugin hook, closes #1449 --- datasette/cli.py | 3 +++ datasette/hookspecs.py | 5 ++++ docs/plugin_hooks.rst | 45 +++++++++++++++++++++++++++++++++ tests/test_plugins.py | 57 +++++++++++++++++++++++++++++++++++++++++- 4 files changed, 109 insertions(+), 1 deletion(-) diff --git a/datasette/cli.py b/datasette/cli.py index 65da5613..22e2338a 100644 --- a/datasette/cli.py +++ b/datasette/cli.py @@ -595,6 +595,9 @@ def serve( uvicorn.run(ds.app(), **uvicorn_kwargs) +pm.hook.register_commands(cli=cli) + + async def check_databases(ds): # Run check_connection against every connected database # to confirm they are all usable diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index 56c79d23..1d4e3b27 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -79,6 +79,11 @@ def register_routes(datasette): """Register URL routes: return a list of (regex, view_function) pairs""" +@hookspec +def register_commands(cli): + """Register additional CLI commands, e.g. 'datasette mycommand ...'""" + + @hookspec def actor_from_request(datasette, request): """Return an actor dictionary based on the incoming request""" diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 5cdb1623..a6fe1071 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -587,6 +587,51 @@ See :ref:`writing_plugins_designing_urls` for tips on designing the URL routes u Examples: `datasette-auth-github `__, `datasette-psutil `__ +.. _plugin_register_commands: + +register_commands(cli) +---------------------- + +``cli`` - the root Datasette `Click command group `__ + Use this to register additional CLI commands + +Register additional CLI commands that can be run using ``datsette yourcommand ...``. This provides a mechanism by which plugins can add new CLI commands to Datasette. + +This example registers a new ``datasette verify file1.db file2.db`` command that checks if the provided file paths are valid SQLite databases: + +.. code-block:: python + + from datasette import hookimpl + import click + import sqlite3 + + @hookimpl + def register_commands(cli): + @cli.command() + @click.argument("files", type=click.Path(exists=True), nargs=-1) + def verify(files): + "Verify that files can be opened by Datasette" + for file in files: + conn = sqlite3.connect(str(file)) + try: + conn.execute("select * from sqlite_master") + except sqlite3.DatabaseError: + raise click.ClickException("Invalid database: {}".format(file)) + +The new command can then be executed like so:: + + datasette verify fixtures.db + +Help text (from the docstring for the function plus any defined Click arguments or options) will become available using:: + + datasette verify --help + +Plugins can register multiple commands by making multiple calls to the ``@cli.command()`` decorator.Consult the `Click documentation `__ for full details on how to build a CLI command, including how to define arguments and options. + +Note that ``register_commands()`` plugins cannot used with the :ref:`--plugins-dir mechanism ` - they need to be installed into the same virtual environment as Datasette using ``pip install``. Provided it has a ``setup.py`` file (see :ref:`writing_plugins_packaging`) you can run ``pip install`` directly against the directory in which you are developing your plugin like so:: + + pip install -e path/to/my/datasette-plugin + .. _plugin_register_facet_classes: register_facet_classes() diff --git a/tests/test_plugins.py b/tests/test_plugins.py index ec8ff0c5..a024c39b 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -6,13 +6,15 @@ from .fixtures import ( TEMP_PLUGIN_SECRET_FILE, TestClient as _TestClient, ) # noqa +from click.testing import CliRunner from datasette.app import Datasette -from datasette import cli +from datasette import cli, hookimpl from datasette.plugins import get_plugins, DEFAULT_PLUGINS, pm from datasette.utils.sqlite import sqlite3 from datasette.utils import CustomRow from jinja2.environment import Template import base64 +import importlib import json import os import pathlib @@ -902,3 +904,56 @@ def test_hook_get_metadata(app_client): assert "Hello from local metadata" == meta["databases"]["from-local"]["title"] assert "Hello from the plugin hook" == meta["databases"]["from-hook"]["title"] pm.hook.get_metadata = og_pm_hook_get_metadata + + +def _extract_commands(output): + lines = output.split("Commands:\n", 1)[1].split("\n") + return {line.split()[0].replace("*", "") for line in lines if line.strip()} + + +def test_hook_register_commands(): + # Without the plugin should have seven commands + runner = CliRunner() + result = runner.invoke(cli.cli, "--help") + commands = _extract_commands(result.output) + assert commands == { + "serve", + "inspect", + "install", + "package", + "plugins", + "publish", + "uninstall", + } + + # Now install a plugin + class VerifyPlugin: + __name__ = "VerifyPlugin" + + @hookimpl + def register_commands(self, cli): + @cli.command() + def verify(): + pass + + @cli.command() + def unverify(): + pass + + pm.register(VerifyPlugin(), name="verify") + importlib.reload(cli) + result2 = runner.invoke(cli.cli, "--help") + commands2 = _extract_commands(result2.output) + assert commands2 == { + "serve", + "inspect", + "install", + "package", + "plugins", + "publish", + "uninstall", + "verify", + "unverify", + } + pm.unregister(name="verify") + importlib.reload(cli) From d3ea36713194e3d92ed4c066337400146c921d0e Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 27 Aug 2021 18:55:54 -0700 Subject: [PATCH 0115/1250] Release 0.59a2 Refs #942, #1421, #1423, #1431, #1443, #1446, #1449 --- datasette/version.py | 2 +- docs/changelog.rst | 13 +++++++++++++ docs/plugin_hooks.rst | 2 +- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/datasette/version.py b/datasette/version.py index f5fbfb3f..87b18fab 100644 --- a/datasette/version.py +++ b/datasette/version.py @@ -1,2 +1,2 @@ -__version__ = "0.59a1" +__version__ = "0.59a2" __version_info__ = tuple(__version__.split(".")) diff --git a/docs/changelog.rst b/docs/changelog.rst index 1406a7ca..737a151b 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,19 @@ Changelog ========= +.. _v0_59a2: + +0.59a2 (2021-08-27) +------------------- + +- Columns can now have associated metadata descriptions in ``metadata.json``, see :ref:`metadata_column_descriptions`. (:issue:`942`) +- New :ref:`register_commands() ` plugin hook allows plugins to register additional Datasette CLI commands, e.g. ``datasette mycommand file.db``. (:issue:`1449`) +- Adding ``?_facet_size=max`` to a table page now shows the number of unique values in each facet. (:issue:`1423`) +- Code that figures out which named parameters a SQL query takes in order to display form fields for them is no longer confused by strings that contain colon characters. (:issue:`1421`) +- Renamed ``--help-config`` option to ``--help-settings``. (:issue:`1431`) +- ``datasette.databases`` property is now a documented API. (:issue:`1443`) +- Datasette base template now wraps everything other than the ``
`` in a ``

" in response.text + assert ">Table With Space In Name 🔒

" in response.text + # Queries + assert ">from_async_hook 🔒" in response.text + assert ">query_two" in response.text + # Views + assert ">paginated_view 🔒" in response.text + assert ">simple_view" in response.text + finally: + cascade_app_client.ds._metadata_local = previous_metadata From 602c0888ce633000cfae42be00de474ef681bda7 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 23 Oct 2022 20:07:09 -0700 Subject: [PATCH 0469/1250] Release 0.63a1 Refs #1646, #1819, #1825, #1829, #1831, #1832, #1834, #1844, #1848 --- datasette/version.py | 2 +- docs/changelog.rst | 16 +++++++++++++++- docs/internals.rst | 2 +- docs/performance.rst | 2 ++ 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/datasette/version.py b/datasette/version.py index e5ad585f..eb36da45 100644 --- a/datasette/version.py +++ b/datasette/version.py @@ -1,2 +1,2 @@ -__version__ = "0.63a0" +__version__ = "0.63a1" __version_info__ = tuple(__version__.split(".")) diff --git a/docs/changelog.rst b/docs/changelog.rst index f5cf03e8..dd4c20b7 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,20 @@ Changelog ========= +.. _v0_63a1: + +0.63a1 (2022-10-23) +------------------- + +- SQL query is now re-displayed when terminated with a time limit error. (:issue:`1819`) +- New documentation on :ref:`deploying_openrc` - thanks, Adam Simpson. (`#1825 `__) +- The :ref:`inspect data ` mechanism is now used to speed up server startup - thanks, Forest Gregg. (:issue:`1834`) +- In :ref:`config_dir` databases with filenames ending in ``.sqlite`` or ``.sqlite3`` are now automatically added to the Datasette instance. (:issue:`1646`) +- Breadcrumb navigation display now respects the current user's permissions. (:issue:`1831`) +- Screenshots in the documentation are now maintained using `shot-scraper `__, as described in `Automating screenshots for the Datasette documentation using shot-scraper `__. (:issue:`1844`) +- The :ref:`datasette.check_visibility() ` method now accepts an optional ``permissions=`` list, allowing it to take multiple permissions into account at once when deciding if something should be shown as public or private. This has been used to correctly display padlock icons in more places in the Datasette interface. (:issue:`1829`) + + .. _v0_63a0: 0.63a0 (2022-09-26) @@ -91,7 +105,7 @@ Datasette also now requires Python 3.7 or higher. - Python 3.6 is no longer supported. (:issue:`1577`) - Tests now run against Python 3.11-dev. (:issue:`1621`) - New :ref:`datasette.ensure_permissions(actor, permissions) ` internal method for checking multiple permissions at once. (:issue:`1675`) -- New :ref:`datasette.check_visibility(actor, action, resource=None) ` internal method for checking if a user can see a resource that would otherwise be invisible to unauthenticated users. (:issue:`1678`) +- New :ref:`datasette.check_visibility(actor, action, resource=None) ` internal method for checking if a user can see a resource that would otherwise be invisible to unauthenticated users. (:issue:`1678`) - Table and row HTML pages now include a ```` element and return a ``Link: URL; rel="alternate"; type="application/json+datasette"`` HTTP header pointing to the JSON version of those pages. (:issue:`1533`) - ``Access-Control-Expose-Headers: Link`` is now added to the CORS headers, allowing remote JavaScript to access that header. - Canned queries are now shown at the top of the database page, directly below the SQL editor. Previously they were shown at the bottom, below the list of tables. (:issue:`1612`) diff --git a/docs/internals.rst b/docs/internals.rst index 92f4efee..c3892a7c 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -364,7 +364,7 @@ This is useful when you need to check multiple permissions at once. For example, ], ) -.. _datasette_check_visibilty: +.. _datasette_check_visibility: await .check_visibility(actor, action=None, resource=None, permissions=None) ---------------------------------------------------------------------------- diff --git a/docs/performance.rst b/docs/performance.rst index 89bbf5ae..4427757c 100644 --- a/docs/performance.rst +++ b/docs/performance.rst @@ -24,6 +24,8 @@ To open a file in immutable mode pass it to the datasette command using the ``-i When you open a file in immutable mode like this Datasette will also calculate and cache the row counts for each table in that database when it first starts up, further improving performance. +.. _performance_inspect: + Using "datasette inspect" ------------------------- From a0dd5fa02fb1e6d5477b962a2062f1a4be3354a5 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 23 Oct 2022 20:14:49 -0700 Subject: [PATCH 0470/1250] Fixed 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 dd4c20b7..2255dcce 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -31,7 +31,7 @@ Changelog - ``Database(is_mutable=)`` now defaults to ``True``. (:issue:`1808`) - Non-JavaScript textarea now increases height to fit the SQL query. (:issue:`1786`) - More detailed command descriptions on the :ref:`CLI reference ` page. (:issue:`1787`) -- Datasette no longer enforces upper bounds on its depenedencies. (:issue:`1800`) +- Datasette no longer enforces upper bounds on its dependencies. (:issue:`1800`) - Facets are now displayed with better line-breaks in long values. Thanks, Daniel Rech. (`#1794 `__) - The ``settings.json`` file used in :ref:`config_dir` is now validated on startup. (:issue:`1816`) From 83adf55b2da83fd9a227f7e4c8506d72def72294 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 23 Oct 2022 20:28:15 -0700 Subject: [PATCH 0471/1250] Deploy one-dot-zero branch preview --- .github/workflows/deploy-latest.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy-latest.yml b/.github/workflows/deploy-latest.yml index 2b94a7f1..43a843ed 100644 --- a/.github/workflows/deploy-latest.yml +++ b/.github/workflows/deploy-latest.yml @@ -3,7 +3,8 @@ name: Deploy latest.datasette.io on: push: branches: - - main + - main + - 1.0-dev permissions: contents: read @@ -68,6 +69,8 @@ jobs: gcloud config set project datasette-222320 export SUFFIX="-${GITHUB_REF#refs/heads/}" export SUFFIX=${SUFFIX#-main} + # Replace 1.0 with one-dot-zero in SUFFIX + export SUFFIX=${SUFFIX//1.0/one-dot-zero} datasette publish cloudrun fixtures.db fixtures2.db extra_database.db \ -m fixtures.json \ --plugins-dir=plugins \ From e135da8efe8fccecf9a137a941cc1f1db0db583a Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 25 Oct 2022 07:13:43 -0700 Subject: [PATCH 0472/1250] Python 3.11 in CI --- .github/workflows/publish.yml | 16 ++++++++-------- .github/workflows/test.yml | 8 ++++---- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9ef09d2e..fa608055 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -12,14 +12,14 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.7", "3.8", "3.9", "3.10"] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - 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 + - uses: actions/cache@v3 name: Configure pip caching with: path: ~/.cache/pip @@ -37,12 +37,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.10' - - 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 e38d5ee9..886f649a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,14 +10,14 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11-dev"] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - 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 + - uses: actions/cache@v3 name: Configure pip caching with: path: ~/.cache/pip From 02ae1a002918eb91f794e912c32742559da34cf5 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 25 Oct 2022 11:59:03 -0700 Subject: [PATCH 0473/1250] Upgrade Docker images to Python 3.11, closes #1853 --- Dockerfile | 2 +- datasette/utils/__init__.py | 2 +- demos/apache-proxy/Dockerfile | 2 +- docs/publish.rst | 2 +- tests/test_package.py | 2 +- tests/test_publish_cloudrun.py | 4 ++-- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index ee7ed957..9a8f06cf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.10.6-slim-bullseye as build +FROM python:3.11.0-slim-bullseye as build # Version of Datasette to install, e.g. 0.55 # docker build . -t datasette --build-arg VERSION=0.55 diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py index 2bdea673..803ba96d 100644 --- a/datasette/utils/__init__.py +++ b/datasette/utils/__init__.py @@ -390,7 +390,7 @@ def make_dockerfile( "SQLITE_EXTENSIONS" ] = "/usr/lib/x86_64-linux-gnu/mod_spatialite.so" return """ -FROM python:3.10.6-slim-bullseye +FROM python:3.11.0-slim-bullseye COPY . /app WORKDIR /app {apt_get_extras} diff --git a/demos/apache-proxy/Dockerfile b/demos/apache-proxy/Dockerfile index 70b33bec..9a8448da 100644 --- a/demos/apache-proxy/Dockerfile +++ b/demos/apache-proxy/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.10.6-slim-bullseye +FROM python:3.11.0-slim-bullseye RUN apt-get update && \ apt-get install -y apache2 supervisor && \ diff --git a/docs/publish.rst b/docs/publish.rst index d817ed31..4ba94792 100644 --- a/docs/publish.rst +++ b/docs/publish.rst @@ -146,7 +146,7 @@ Here's example output for the package command:: $ datasette package parlgov.db --extra-options="--setting sql_time_limit_ms 2500" Sending build context to Docker daemon 4.459MB - Step 1/7 : FROM python:3.10.6-slim-bullseye + Step 1/7 : FROM python:3.11.0-slim-bullseye ---> 79e1dc9af1c1 Step 2/7 : COPY . /app ---> Using cache diff --git a/tests/test_package.py b/tests/test_package.py index ac15e61e..f05f3ece 100644 --- a/tests/test_package.py +++ b/tests/test_package.py @@ -12,7 +12,7 @@ class CaptureDockerfile: EXPECTED_DOCKERFILE = """ -FROM python:3.10.6-slim-bullseye +FROM python:3.11.0-slim-bullseye COPY . /app WORKDIR /app diff --git a/tests/test_publish_cloudrun.py b/tests/test_publish_cloudrun.py index e64534d2..158a090e 100644 --- a/tests/test_publish_cloudrun.py +++ b/tests/test_publish_cloudrun.py @@ -242,7 +242,7 @@ def test_publish_cloudrun_plugin_secrets( ) expected = textwrap.dedent( r""" - FROM python:3.10.6-slim-bullseye + FROM python:3.11.0-slim-bullseye COPY . /app WORKDIR /app @@ -309,7 +309,7 @@ def test_publish_cloudrun_apt_get_install( ) expected = textwrap.dedent( r""" - FROM python:3.10.6-slim-bullseye + FROM python:3.11.0-slim-bullseye COPY . /app WORKDIR /app From 9676b2deb07cff20247ba91dad3e84a4ab0b00d1 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 25 Oct 2022 11:59:03 -0700 Subject: [PATCH 0474/1250] Upgrade Docker images to Python 3.11, closes #1853 --- Dockerfile | 2 +- datasette/utils/__init__.py | 2 +- demos/apache-proxy/Dockerfile | 2 +- docs/publish.rst | 2 +- tests/test_package.py | 2 +- tests/test_publish_cloudrun.py | 4 ++-- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index ee7ed957..9a8f06cf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.10.6-slim-bullseye as build +FROM python:3.11.0-slim-bullseye as build # Version of Datasette to install, e.g. 0.55 # docker build . -t datasette --build-arg VERSION=0.55 diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py index 2bdea673..803ba96d 100644 --- a/datasette/utils/__init__.py +++ b/datasette/utils/__init__.py @@ -390,7 +390,7 @@ def make_dockerfile( "SQLITE_EXTENSIONS" ] = "/usr/lib/x86_64-linux-gnu/mod_spatialite.so" return """ -FROM python:3.10.6-slim-bullseye +FROM python:3.11.0-slim-bullseye COPY . /app WORKDIR /app {apt_get_extras} diff --git a/demos/apache-proxy/Dockerfile b/demos/apache-proxy/Dockerfile index 70b33bec..9a8448da 100644 --- a/demos/apache-proxy/Dockerfile +++ b/demos/apache-proxy/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.10.6-slim-bullseye +FROM python:3.11.0-slim-bullseye RUN apt-get update && \ apt-get install -y apache2 supervisor && \ diff --git a/docs/publish.rst b/docs/publish.rst index d817ed31..4ba94792 100644 --- a/docs/publish.rst +++ b/docs/publish.rst @@ -146,7 +146,7 @@ Here's example output for the package command:: $ datasette package parlgov.db --extra-options="--setting sql_time_limit_ms 2500" Sending build context to Docker daemon 4.459MB - Step 1/7 : FROM python:3.10.6-slim-bullseye + Step 1/7 : FROM python:3.11.0-slim-bullseye ---> 79e1dc9af1c1 Step 2/7 : COPY . /app ---> Using cache diff --git a/tests/test_package.py b/tests/test_package.py index ac15e61e..f05f3ece 100644 --- a/tests/test_package.py +++ b/tests/test_package.py @@ -12,7 +12,7 @@ class CaptureDockerfile: EXPECTED_DOCKERFILE = """ -FROM python:3.10.6-slim-bullseye +FROM python:3.11.0-slim-bullseye COPY . /app WORKDIR /app diff --git a/tests/test_publish_cloudrun.py b/tests/test_publish_cloudrun.py index e64534d2..158a090e 100644 --- a/tests/test_publish_cloudrun.py +++ b/tests/test_publish_cloudrun.py @@ -242,7 +242,7 @@ def test_publish_cloudrun_plugin_secrets( ) expected = textwrap.dedent( r""" - FROM python:3.10.6-slim-bullseye + FROM python:3.11.0-slim-bullseye COPY . /app WORKDIR /app @@ -309,7 +309,7 @@ def test_publish_cloudrun_apt_get_install( ) expected = textwrap.dedent( r""" - FROM python:3.10.6-slim-bullseye + FROM python:3.11.0-slim-bullseye COPY . /app WORKDIR /app From 613ad05c095f92653221db267ef53d54d00cdfbb Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 25 Oct 2022 12:16:48 -0700 Subject: [PATCH 0475/1250] Don't need pysqlite3-binary any more, refs #1853 --- .github/workflows/deploy-latest.yml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/deploy-latest.yml b/.github/workflows/deploy-latest.yml index 2b94a7f1..e423b8fa 100644 --- a/.github/workflows/deploy-latest.yml +++ b/.github/workflows/deploy-latest.yml @@ -13,12 +13,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out datasette - 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.10" - - uses: actions/cache@v2 + python-version: "3.11" + - uses: actions/cache@v3 name: Configure pip caching with: path: ~/.cache/pip @@ -74,7 +74,6 @@ jobs: --branch=$GITHUB_SHA \ --version-note=$GITHUB_SHA \ --extra-options="--setting template_debug 1 --setting trace_debug 1 --crossdb" \ - --install=pysqlite3-binary \ --service "datasette-latest$SUFFIX" - name: Deploy to docs as well (only for main) if: ${{ github.ref == 'refs/heads/main' }} From c7dd76c26257ded5bcdfd0570e12412531b8b88f Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 25 Oct 2022 12:42:21 -0700 Subject: [PATCH 0476/1250] Poll until servers start, refs #1854 --- tests/conftest.py | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 215853b3..f4638a14 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +import httpx import os import pathlib import pytest @@ -110,8 +111,13 @@ def ds_localhost_http_server(): # Avoid FileNotFoundError: [Errno 2] No such file or directory: cwd=tempfile.gettempdir(), ) - # Give the server time to start - time.sleep(1.5) + # Loop until port 8041 serves traffic + while True: + try: + httpx.get("http://localhost:8041/") + break + except httpx.ConnectError: + time.sleep(0.1) # Check it started successfully assert not ds_proc.poll(), ds_proc.stdout.read().decode("utf-8") yield ds_proc @@ -146,8 +152,12 @@ def ds_localhost_https_server(tmp_path_factory): stderr=subprocess.STDOUT, cwd=tempfile.gettempdir(), ) - # Give the server time to start - time.sleep(1.5) + while True: + try: + httpx.get("https://localhost:8042/", verify=client_cert) + break + except httpx.ConnectError: + time.sleep(0.1) # Check it started successfully assert not ds_proc.poll(), ds_proc.stdout.read().decode("utf-8") yield ds_proc, client_cert @@ -168,8 +178,15 @@ def ds_unix_domain_socket_server(tmp_path_factory): stderr=subprocess.STDOUT, cwd=tempfile.gettempdir(), ) - # Give the server time to start - time.sleep(1.5) + # Poll until available + transport = httpx.HTTPTransport(uds=uds) + client = httpx.Client(transport=transport) + while True: + try: + client.get("http://localhost/_memory.json") + break + except httpx.ConnectError: + time.sleep(0.1) # Check it started successfully assert not ds_proc.poll(), ds_proc.stdout.read().decode("utf-8") yield ds_proc, uds From 6d085af28c63c28ecda388fc0552c91f756be0c6 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 25 Oct 2022 07:13:43 -0700 Subject: [PATCH 0477/1250] Python 3.11 in CI --- .github/workflows/publish.yml | 16 ++++++++-------- .github/workflows/test.yml | 8 ++++---- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9ef09d2e..fa608055 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -12,14 +12,14 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.7", "3.8", "3.9", "3.10"] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - 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 + - uses: actions/cache@v3 name: Configure pip caching with: path: ~/.cache/pip @@ -37,12 +37,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.10' - - 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 e38d5ee9..886f649a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,14 +10,14 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11-dev"] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - 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 + - uses: actions/cache@v3 name: Configure pip caching with: path: ~/.cache/pip From 05b479224fa57af3ab2d03769edd5081dad62a19 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 25 Oct 2022 12:16:48 -0700 Subject: [PATCH 0478/1250] Don't need pysqlite3-binary any more, refs #1853 --- .github/workflows/deploy-latest.yml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/deploy-latest.yml b/.github/workflows/deploy-latest.yml index 43a843ed..5598dc12 100644 --- a/.github/workflows/deploy-latest.yml +++ b/.github/workflows/deploy-latest.yml @@ -14,12 +14,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out datasette - 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.10" - - uses: actions/cache@v2 + python-version: "3.11" + - uses: actions/cache@v3 name: Configure pip caching with: path: ~/.cache/pip @@ -77,7 +77,6 @@ jobs: --branch=$GITHUB_SHA \ --version-note=$GITHUB_SHA \ --extra-options="--setting template_debug 1 --setting trace_debug 1 --crossdb" \ - --install=pysqlite3-binary \ --service "datasette-latest$SUFFIX" - name: Deploy to docs as well (only for main) if: ${{ github.ref == 'refs/heads/main' }} From f9ae92b37796f7f559d57b1ee9718aa4d43547e8 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 25 Oct 2022 12:42:21 -0700 Subject: [PATCH 0479/1250] Poll until servers start, refs #1854 --- tests/conftest.py | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 215853b3..f4638a14 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +import httpx import os import pathlib import pytest @@ -110,8 +111,13 @@ def ds_localhost_http_server(): # Avoid FileNotFoundError: [Errno 2] No such file or directory: cwd=tempfile.gettempdir(), ) - # Give the server time to start - time.sleep(1.5) + # Loop until port 8041 serves traffic + while True: + try: + httpx.get("http://localhost:8041/") + break + except httpx.ConnectError: + time.sleep(0.1) # Check it started successfully assert not ds_proc.poll(), ds_proc.stdout.read().decode("utf-8") yield ds_proc @@ -146,8 +152,12 @@ def ds_localhost_https_server(tmp_path_factory): stderr=subprocess.STDOUT, cwd=tempfile.gettempdir(), ) - # Give the server time to start - time.sleep(1.5) + while True: + try: + httpx.get("https://localhost:8042/", verify=client_cert) + break + except httpx.ConnectError: + time.sleep(0.1) # Check it started successfully assert not ds_proc.poll(), ds_proc.stdout.read().decode("utf-8") yield ds_proc, client_cert @@ -168,8 +178,15 @@ def ds_unix_domain_socket_server(tmp_path_factory): stderr=subprocess.STDOUT, cwd=tempfile.gettempdir(), ) - # Give the server time to start - time.sleep(1.5) + # Poll until available + transport = httpx.HTTPTransport(uds=uds) + client = httpx.Client(transport=transport) + while True: + try: + client.get("http://localhost/_memory.json") + break + except httpx.ConnectError: + time.sleep(0.1) # Check it started successfully assert not ds_proc.poll(), ds_proc.stdout.read().decode("utf-8") yield ds_proc, uds From 42f8b402e6aa56af4bbe921e346af8df42acd50f Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 25 Oct 2022 17:07:58 -0700 Subject: [PATCH 0480/1250] Initial prototype of create API token page, refs #1852 --- datasette/app.py | 5 ++ datasette/templates/create_token.html | 83 +++++++++++++++++++++++++++ datasette/views/special.py | 54 +++++++++++++++++ 3 files changed, 142 insertions(+) create mode 100644 datasette/templates/create_token.html diff --git a/datasette/app.py b/datasette/app.py index 9df16558..cab9d142 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -33,6 +33,7 @@ from .views.special import ( JsonDataView, PatternPortfolioView, AuthTokenView, + CreateTokenView, LogoutView, AllowDebugView, PermissionsDebugView, @@ -1212,6 +1213,10 @@ class Datasette: AuthTokenView.as_view(self), r"/-/auth-token$", ) + add_route( + CreateTokenView.as_view(self), + r"/-/create-token$", + ) add_route( LogoutView.as_view(self), r"/-/logout$", diff --git a/datasette/templates/create_token.html b/datasette/templates/create_token.html new file mode 100644 index 00000000..a94881ed --- /dev/null +++ b/datasette/templates/create_token.html @@ -0,0 +1,83 @@ +{% extends "base.html" %} + +{% block title %}Create an API token{% endblock %} + +{% block content %} + +

Create an API token

+ +

This token will allow API access with the same abilities as your current user.

+ +{% if errors %} + {% for error in errors %} +

{{ error }}

+ {% endfor %} +{% endif %} + + +
+
+ +
+ + + +
+ + +{% if token %} +
+

Your API token

+
+ + +
+ +
+ Token details +
{{ token_bits|tojson }}
+
+
+ {% endif %} + + + +{% endblock %} diff --git a/datasette/views/special.py b/datasette/views/special.py index dd834528..f2e69412 100644 --- a/datasette/views/special.py +++ b/datasette/views/special.py @@ -3,6 +3,7 @@ from datasette.utils.asgi import Response, Forbidden from datasette.utils import actor_matches_allow, add_cors_headers from .base import BaseView import secrets +import time class JsonDataView(BaseView): @@ -163,3 +164,56 @@ class MessagesDebugView(BaseView): else: datasette.add_message(request, message, getattr(datasette, message_type)) return Response.redirect(self.ds.urls.instance()) + + +class CreateTokenView(BaseView): + name = "create_token" + has_json_alternate = False + + async def get(self, request): + if not request.actor: + raise Forbidden("You must be logged in to create a token") + return await self.render( + ["create_token.html"], + request, + {"actor": request.actor}, + ) + + async def post(self, request): + if not request.actor: + raise Forbidden("You must be logged in to create a token") + post = await request.post_vars() + expires = None + errors = [] + if post.get("expire_type"): + duration = post.get("expire_duration") + if not duration or not duration.isdigit() or not int(duration) > 0: + errors.append("Invalid expire duration") + else: + unit = post["expire_type"] + if unit == "minutes": + expires = int(duration) * 60 + elif unit == "hours": + expires = int(duration) * 60 * 60 + elif unit == "days": + expires = int(duration) * 60 * 60 * 24 + else: + errors.append("Invalid expire duration unit") + token_bits = None + token = None + if not errors: + token_bits = { + "a": request.actor, + "e": (int(time.time()) + expires) if expires else None, + } + token = self.ds.sign(token_bits, "token") + return await self.render( + ["create_token.html"], + request, + { + "actor": request.actor, + "errors": errors, + "token": token, + "token_bits": token_bits, + }, + ) From 68ccb7578b5d3bf68b86fb2f5cf8753098dfe075 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 25 Oct 2022 18:40:07 -0700 Subject: [PATCH 0481/1250] dstoke_ prefix for tokens Refs https://github.com/simonw/datasette/issues/1852#issuecomment-1291290451 --- datasette/views/special.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/datasette/views/special.py b/datasette/views/special.py index f2e69412..d3f202f4 100644 --- a/datasette/views/special.py +++ b/datasette/views/special.py @@ -206,7 +206,7 @@ class CreateTokenView(BaseView): "a": request.actor, "e": (int(time.time()) + expires) if expires else None, } - token = self.ds.sign(token_bits, "token") + token = "dstok_{}".format(self.ds.sign(token_bits, "token")) return await self.render( ["create_token.html"], request, From 7ab091e8ef8d3af1e23b5a81ffad2bd8c96cc47c Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 25 Oct 2022 19:04:05 -0700 Subject: [PATCH 0482/1250] Tests and docs for /-/create-token, refs #1852 --- datasette/views/special.py | 14 +++++--- docs/authentication.rst | 15 +++++++++ tests/test_auth.py | 68 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 4 deletions(-) diff --git a/datasette/views/special.py b/datasette/views/special.py index d3f202f4..7f70eb1f 100644 --- a/datasette/views/special.py +++ b/datasette/views/special.py @@ -170,9 +170,16 @@ class CreateTokenView(BaseView): name = "create_token" has_json_alternate = False - async def get(self, request): + def check_permission(self, request): if not request.actor: raise Forbidden("You must be logged in to create a token") + if not request.actor.get("id"): + raise Forbidden( + "You must be logged in as an actor with an ID to create a token" + ) + + async def get(self, request): + self.check_permission(request) return await self.render( ["create_token.html"], request, @@ -180,8 +187,7 @@ class CreateTokenView(BaseView): ) async def post(self, request): - if not request.actor: - raise Forbidden("You must be logged in to create a token") + self.check_permission(request) post = await request.post_vars() expires = None errors = [] @@ -203,7 +209,7 @@ class CreateTokenView(BaseView): token = None if not errors: token_bits = { - "a": request.actor, + "a": request.actor["id"], "e": (int(time.time()) + expires) if expires else None, } token = "dstok_{}".format(self.ds.sign(token_bits, "token")) diff --git a/docs/authentication.rst b/docs/authentication.rst index 685dab15..fc903fbb 100644 --- a/docs/authentication.rst +++ b/docs/authentication.rst @@ -333,6 +333,21 @@ To limit this ability for just one specific database, use this: } } +.. _CreateTokenView: + +API Tokens +========== + +Datasette includes a default mechanism for generating API tokens that can be used to authenticate requests. + +Authenticated users can create new API tokens using a form on the ``/-/create-token`` page. + +Created tokens can then be passed in the ``Authorization: Bearer token_here`` header of HTTP requests to Datasette. + +A token created by a user will include that user's ``"id"`` in the token payload, so any permissions granted to that user based on their ID will be made available to the token as well. + +Coming soon: a mechanism for creating tokens that can only perform a subset of the actions available to the user who created them. + .. _permissions_plugins: Checking permissions in plugins diff --git a/tests/test_auth.py b/tests/test_auth.py index 4ef35a76..3aaab50d 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -110,3 +110,71 @@ def test_no_logout_button_in_navigation_if_no_ds_actor_cookie(app_client, path): response = app_client.get(path + "?_bot=1") assert "bot" in response.text assert '
' not in response.text + + +@pytest.mark.parametrize( + "post_data,errors,expected_duration", + ( + ({"expire_type": ""}, [], None), + ({"expire_type": "x"}, ["Invalid expire duration"], None), + ({"expire_type": "minutes"}, ["Invalid expire duration"], None), + ( + {"expire_type": "minutes", "expire_duration": "x"}, + ["Invalid expire duration"], + None, + ), + ( + {"expire_type": "minutes", "expire_duration": "-1"}, + ["Invalid expire duration"], + None, + ), + ( + {"expire_type": "minutes", "expire_duration": "0"}, + ["Invalid expire duration"], + None, + ), + ( + {"expire_type": "minutes", "expire_duration": "10"}, + [], + 600, + ), + ( + {"expire_type": "hours", "expire_duration": "10"}, + [], + 10 * 60 * 60, + ), + ( + {"expire_type": "days", "expire_duration": "3"}, + [], + 60 * 60 * 24 * 3, + ), + ), +) +def test_auth_create_token(app_client, post_data, errors, expected_duration): + assert app_client.get("/-/create-token").status == 403 + ds_actor = app_client.actor_cookie({"id": "test"}) + response = app_client.get("/-/create-token", cookies={"ds_actor": ds_actor}) + assert response.status == 200 + assert ">Create an API token<" in response.text + # Now try actually creating one + response2 = app_client.post( + "/-/create-token", + post_data, + csrftoken_from=True, + cookies={"ds_actor": ds_actor}, + ) + assert response2.status == 200 + if errors: + for error in errors: + assert '

{}

'.format(error) in response2.text + else: + # Extract token from page + token = response2.text.split('value="dstok_')[1].split('"')[0] + details = app_client.ds.unsign(token, "token") + assert details.keys() == {"a", "e"} + assert details["a"] == "test" + if expected_duration is None: + assert details["e"] is None + else: + about_right = int(time.time()) + expected_duration + assert about_right - 2 < details["e"] < about_right + 2 From b29e487bc3fde6418bf45bda7cfed2e081ff03fb Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 25 Oct 2022 19:18:41 -0700 Subject: [PATCH 0483/1250] actor_from_request for dstok_ tokens, refs #1852 --- datasette/default_permissions.py | 25 +++++++++++++++++++++++++ datasette/utils/testing.py | 2 ++ tests/test_auth.py | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+) diff --git a/datasette/default_permissions.py b/datasette/default_permissions.py index b58d8d1b..4d836ddc 100644 --- a/datasette/default_permissions.py +++ b/datasette/default_permissions.py @@ -1,5 +1,7 @@ from datasette import hookimpl from datasette.utils import actor_matches_allow +import itsdangerous +import time @hookimpl(tryfirst=True) @@ -45,3 +47,26 @@ def permission_allowed(datasette, actor, action, resource): return actor_matches_allow(actor, database_allow_sql) return inner + + +@hookimpl +def actor_from_request(datasette, request): + prefix = "dstok_" + authorization = request.headers.get("authorization") + if not authorization: + return None + if not authorization.startswith("Bearer "): + return None + token = authorization[len("Bearer ") :] + if not token.startswith(prefix): + return None + token = token[len(prefix) :] + try: + decoded = datasette.unsign(token, namespace="token") + except itsdangerous.BadSignature: + return None + expires_at = decoded.get("e") + if expires_at is not None: + if expires_at < time.time(): + return None + return {"id": decoded["a"], "dstok": True} diff --git a/datasette/utils/testing.py b/datasette/utils/testing.py index b28fc575..4f76a799 100644 --- a/datasette/utils/testing.py +++ b/datasette/utils/testing.py @@ -62,6 +62,7 @@ class TestClient: method="GET", cookies=None, if_none_match=None, + headers=None, ): return await self._request( path=path, @@ -70,6 +71,7 @@ class TestClient: method=method, cookies=cookies, if_none_match=if_none_match, + headers=headers, ) @async_to_sync diff --git a/tests/test_auth.py b/tests/test_auth.py index 3aaab50d..be21d6a5 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -178,3 +178,35 @@ def test_auth_create_token(app_client, post_data, errors, expected_duration): else: about_right = int(time.time()) + expected_duration assert about_right - 2 < details["e"] < about_right + 2 + + +@pytest.mark.parametrize( + "scenario,should_work", + ( + ("no_token", False), + ("invalid_token", False), + ("expired_token", False), + ("valid_unlimited_token", True), + ("valid_expiring_token", True), + ), +) +def test_auth_with_dstok_token(app_client, scenario, should_work): + token = None + if scenario == "valid_unlimited_token": + token = app_client.ds.sign({"a": "test"}, "token") + elif scenario == "valid_expiring_token": + token = app_client.ds.sign({"a": "test", "e": int(time.time()) + 1000}, "token") + elif scenario == "expired_token": + token = app_client.ds.sign({"a": "test", "e": int(time.time()) - 1000}, "token") + elif scenario == "invalid_token": + token = "invalid" + if token: + token = "dstok_{}".format(token) + headers = {} + if token: + headers["Authorization"] = "Bearer {}".format(token) + response = app_client.get("/-/actor.json", headers=headers) + if should_work: + assert response.json == {"actor": {"id": "test", "dstok": True}} + else: + assert response.json == {"actor": None} From 0f013ff497df62e1dd2075777b9817555646010e Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 25 Oct 2022 19:43:55 -0700 Subject: [PATCH 0484/1250] Mechanism to prevent tokens creating tokens, closes #1857 --- datasette/default_permissions.py | 2 +- datasette/views/special.py | 4 ++++ docs/authentication.rst | 2 ++ tests/test_auth.py | 11 ++++++++++- 4 files changed, 17 insertions(+), 2 deletions(-) diff --git a/datasette/default_permissions.py b/datasette/default_permissions.py index 4d836ddc..d908af7a 100644 --- a/datasette/default_permissions.py +++ b/datasette/default_permissions.py @@ -69,4 +69,4 @@ def actor_from_request(datasette, request): if expires_at is not None: if expires_at < time.time(): return None - return {"id": decoded["a"], "dstok": True} + return {"id": decoded["a"], "token": "dstok"} diff --git a/datasette/views/special.py b/datasette/views/special.py index 7f70eb1f..91130353 100644 --- a/datasette/views/special.py +++ b/datasette/views/special.py @@ -177,6 +177,10 @@ class CreateTokenView(BaseView): raise Forbidden( "You must be logged in as an actor with an ID to create a token" ) + if request.actor.get("token"): + raise Forbidden( + "Token authentication cannot be used to create additional tokens" + ) async def get(self, request): self.check_permission(request) diff --git a/docs/authentication.rst b/docs/authentication.rst index fc903fbb..cbecd296 100644 --- a/docs/authentication.rst +++ b/docs/authentication.rst @@ -348,6 +348,8 @@ A token created by a user will include that user's ``"id"`` in the token payload Coming soon: a mechanism for creating tokens that can only perform a subset of the actions available to the user who created them. +This page cannot be accessed by actors with a ``"token": "some-value"`` property. This is to prevent API tokens from being used to automatically create more tokens. Datasette plugins that implement their own form of API token authentication should follow this convention. + .. _permissions_plugins: Checking permissions in plugins diff --git a/tests/test_auth.py b/tests/test_auth.py index be21d6a5..397d51d7 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -180,6 +180,15 @@ def test_auth_create_token(app_client, post_data, errors, expected_duration): assert about_right - 2 < details["e"] < about_right + 2 +def test_auth_create_token_not_allowed_for_tokens(app_client): + ds_tok = app_client.ds.sign({"a": "test", "token": "dstok"}, "token") + response = app_client.get( + "/-/create-token", + headers={"Authorization": "Bearer dstok_{}".format(ds_tok)}, + ) + assert response.status == 403 + + @pytest.mark.parametrize( "scenario,should_work", ( @@ -207,6 +216,6 @@ def test_auth_with_dstok_token(app_client, scenario, should_work): headers["Authorization"] = "Bearer {}".format(token) response = app_client.get("/-/actor.json", headers=headers) if should_work: - assert response.json == {"actor": {"id": "test", "dstok": True}} + assert response.json == {"actor": {"id": "test", "token": "dstok"}} else: assert response.json == {"actor": None} From c23fa850e7f21977e367e3467656055216978e8a Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 25 Oct 2022 19:55:47 -0700 Subject: [PATCH 0485/1250] allow_signed_tokens setting, closes #1856 --- datasette/app.py | 5 +++++ datasette/default_permissions.py | 2 ++ datasette/views/special.py | 2 ++ docs/authentication.rst | 2 ++ docs/cli-reference.rst | 2 ++ docs/plugins.rst | 1 + docs/settings.rst | 13 +++++++++++++ tests/test_auth.py | 26 +++++++++++++++++++++----- 8 files changed, 48 insertions(+), 5 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index cab9d142..c868f8d3 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -124,6 +124,11 @@ SETTINGS = ( True, "Allow users to download the original SQLite database files", ), + Setting( + "allow_signed_tokens", + True, + "Allow users to create and use signed API tokens", + ), Setting("suggest_facets", True, "Calculate and display suggested facets"), Setting( "default_cache_ttl", diff --git a/datasette/default_permissions.py b/datasette/default_permissions.py index d908af7a..49ca8851 100644 --- a/datasette/default_permissions.py +++ b/datasette/default_permissions.py @@ -52,6 +52,8 @@ def permission_allowed(datasette, actor, action, resource): @hookimpl def actor_from_request(datasette, request): prefix = "dstok_" + if not datasette.setting("allow_signed_tokens"): + return None authorization = request.headers.get("authorization") if not authorization: return None diff --git a/datasette/views/special.py b/datasette/views/special.py index 91130353..89015958 100644 --- a/datasette/views/special.py +++ b/datasette/views/special.py @@ -171,6 +171,8 @@ class CreateTokenView(BaseView): has_json_alternate = False def check_permission(self, request): + if not self.ds.setting("allow_signed_tokens"): + raise Forbidden("Signed tokens are not enabled for this Datasette instance") if not request.actor: raise Forbidden("You must be logged in to create a token") if not request.actor.get("id"): diff --git a/docs/authentication.rst b/docs/authentication.rst index cbecd296..50304ec5 100644 --- a/docs/authentication.rst +++ b/docs/authentication.rst @@ -350,6 +350,8 @@ Coming soon: a mechanism for creating tokens that can only perform a subset of t This page cannot be accessed by actors with a ``"token": "some-value"`` property. This is to prevent API tokens from being used to automatically create more tokens. Datasette plugins that implement their own form of API token authentication should follow this convention. +You can disable this feature using the :ref:`allow_signed_tokens ` setting. + .. _permissions_plugins: Checking permissions in plugins diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index 4a8465cb..fd5e2404 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -226,6 +226,8 @@ These can be passed to ``datasette serve`` using ``datasette serve --setting nam ?_facet= parameter (default=True) allow_download Allow users to download the original SQLite database files (default=True) + allow_signed_tokens Allow users to create and use signed API tokens + (default=True) suggest_facets Calculate and display suggested facets (default=True) default_cache_ttl Default HTTP cache TTL (used in Cache-Control: diff --git a/docs/plugins.rst b/docs/plugins.rst index 29078054..9efef32f 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -151,6 +151,7 @@ If you run ``datasette plugins --all`` it will include default plugins that ship "templates": false, "version": null, "hooks": [ + "actor_from_request", "permission_allowed" ] }, diff --git a/docs/settings.rst b/docs/settings.rst index a6d50543..be640b21 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -169,6 +169,19 @@ Should users be able to download the original SQLite database using a link on th datasette mydatabase.db --setting allow_download off +.. _setting_allow_signed_tokens: + +allow_signed_tokens +~~~~~~~~~~~~~~~~~~~ + +Should users be able to create signed API tokens to access Datasette? + +This is turned on by default. Use the following to turn it off:: + + datasette mydatabase.db --setting allow_signed_tokens off + +Turning this setting off will disable the ``/-/create-token`` page, :ref:`described here `. It will also cause any incoming ``Authorization: Bearer dstok_...`` API tokens to be ignored. + .. _setting_default_cache_ttl: default_cache_ttl diff --git a/tests/test_auth.py b/tests/test_auth.py index 397d51d7..a79dafd8 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -189,9 +189,20 @@ def test_auth_create_token_not_allowed_for_tokens(app_client): assert response.status == 403 +def test_auth_create_token_not_allowed_if_allow_signed_tokens_off(app_client): + app_client.ds._settings["allow_signed_tokens"] = False + try: + ds_actor = app_client.actor_cookie({"id": "test"}) + response = app_client.get("/-/create-token", cookies={"ds_actor": ds_actor}) + assert response.status == 403 + finally: + app_client.ds._settings["allow_signed_tokens"] = True + + @pytest.mark.parametrize( "scenario,should_work", ( + ("allow_signed_tokens_off", False), ("no_token", False), ("invalid_token", False), ("expired_token", False), @@ -201,7 +212,7 @@ def test_auth_create_token_not_allowed_for_tokens(app_client): ) def test_auth_with_dstok_token(app_client, scenario, should_work): token = None - if scenario == "valid_unlimited_token": + if scenario in ("valid_unlimited_token", "allow_signed_tokens_off"): token = app_client.ds.sign({"a": "test"}, "token") elif scenario == "valid_expiring_token": token = app_client.ds.sign({"a": "test", "e": int(time.time()) + 1000}, "token") @@ -211,11 +222,16 @@ def test_auth_with_dstok_token(app_client, scenario, should_work): token = "invalid" if token: token = "dstok_{}".format(token) + if scenario == "allow_signed_tokens_off": + app_client.ds._settings["allow_signed_tokens"] = False headers = {} if token: headers["Authorization"] = "Bearer {}".format(token) response = app_client.get("/-/actor.json", headers=headers) - if should_work: - assert response.json == {"actor": {"id": "test", "token": "dstok"}} - else: - assert response.json == {"actor": None} + try: + if should_work: + assert response.json == {"actor": {"id": "test", "token": "dstok"}} + else: + assert response.json == {"actor": None} + finally: + app_client.ds._settings["allow_signed_tokens"] = True From c36a74ece1e475291af326d493d8db9ff3afdd30 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 25 Oct 2022 21:04:39 -0700 Subject: [PATCH 0486/1250] Try shutting down executor in tests to free up thread local SQLite connections, refs #1843 --- tests/fixtures.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/fixtures.py b/tests/fixtures.py index 13a3dffa..d1afd2f3 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -166,6 +166,7 @@ def make_app_client( # Close the connection to avoid "too many open files" errors conn.close() os.remove(filepath) + ds.executor.shutdown() @pytest.fixture(scope="session") From c556fad65d8a45ce85027678796a12ac9107d9ed Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 25 Oct 2022 21:25:47 -0700 Subject: [PATCH 0487/1250] Try to address too many files error again, refs #1843 --- tests/fixtures.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/fixtures.py b/tests/fixtures.py index d1afd2f3..92a10da6 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -131,10 +131,14 @@ def make_app_client( for sql, params in TABLE_PARAMETERIZED_SQL: with conn: conn.execute(sql, params) + # Close the connection to avoid "too many open files" errors + conn.close() if extra_databases is not None: for extra_filename, extra_sql in extra_databases.items(): extra_filepath = os.path.join(tmpdir, extra_filename) - sqlite3.connect(extra_filepath).executescript(extra_sql) + c2 = sqlite3.connect(extra_filepath) + c2.executescript(extra_sql) + c2.close() # Insert at start to help test /-/databases ordering: files.insert(0, extra_filepath) os.chdir(os.path.dirname(filepath)) @@ -163,10 +167,7 @@ def make_app_client( crossdb=crossdb, ) yield TestClient(ds) - # Close the connection to avoid "too many open files" errors - conn.close() os.remove(filepath) - ds.executor.shutdown() @pytest.fixture(scope="session") From c7956eed7777c62653b4d508570c5d77cfead7d9 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 25 Oct 2022 21:26:12 -0700 Subject: [PATCH 0488/1250] datasette create-token command, refs #1859 --- datasette/default_permissions.py | 38 ++++++++++++++++++++++++++++ docs/authentication.rst | 23 +++++++++++++++++ docs/cli-reference.rst | 43 ++++++++++++++++++++++++++------ docs/plugins.rst | 3 ++- tests/test_api.py | 1 + tests/test_auth.py | 28 +++++++++++++++++++++ tests/test_plugins.py | 2 ++ 7 files changed, 130 insertions(+), 8 deletions(-) diff --git a/datasette/default_permissions.py b/datasette/default_permissions.py index 49ca8851..12499c16 100644 --- a/datasette/default_permissions.py +++ b/datasette/default_permissions.py @@ -1,6 +1,8 @@ from datasette import hookimpl from datasette.utils import actor_matches_allow +import click import itsdangerous +import json import time @@ -72,3 +74,39 @@ def actor_from_request(datasette, request): if expires_at < time.time(): return None return {"id": decoded["a"], "token": "dstok"} + + +@hookimpl +def register_commands(cli): + from datasette.app import Datasette + + @cli.command() + @click.argument("id") + @click.option( + "--secret", + help="Secret used for signing the API tokens", + envvar="DATASETTE_SECRET", + required=True, + ) + @click.option( + "-e", + "--expires-after", + help="Token should expire after this many seconds", + type=int, + ) + @click.option( + "--debug", + help="Show decoded token", + is_flag=True, + ) + def create_token(id, secret, expires_after, debug): + "Create a signed API token for the specified actor ID" + ds = Datasette(secret=secret) + bits = {"a": id, "token": "dstok"} + if expires_after: + bits["e"] = int(time.time()) + expires_after + token = ds.sign(bits, namespace="token") + click.echo("dstok_{}".format(token)) + if debug: + click.echo("\nDecoded:\n") + click.echo(json.dumps(ds.unsign(token, namespace="token"), indent=2)) diff --git a/docs/authentication.rst b/docs/authentication.rst index 50304ec5..0835e17c 100644 --- a/docs/authentication.rst +++ b/docs/authentication.rst @@ -352,6 +352,29 @@ This page cannot be accessed by actors with a ``"token": "some-value"`` property You can disable this feature using the :ref:`allow_signed_tokens ` setting. +.. _authentication_cli_create_token: + +datasette create-token +---------------------- + +You can also create tokens on the command line using the ``datasette create-token`` command. + +This command takes one required argument - the ID of the actor to be associated with the created token. + +You can specify an ``--expires-after`` option in seconds. If omitted, the token will never expire. + +The command will sign the token using the ``DATASETTE_SECRET`` environment variable, if available. You can also pass the secret using the ``--secret`` option. + +This means you can run the command locally to create tokens for use with a deployed Datasette instance, provided you know that instance's secret. + +To create a token for the ``root`` actor that will expire in one hour:: + + datasette create-token root --expires-after 3600 + +To create a secret that never expires using a specific secret:: + + datasette create-token root --secret my-secret-goes-here + .. _permissions_plugins: Checking permissions in plugins diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index fd5e2404..b40c6b2c 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -47,13 +47,14 @@ Running ``datasette --help`` shows a list of all of the available commands. --help Show this message and exit. Commands: - serve* Serve up specified SQLite database files with a web UI - inspect Generate JSON summary of provided database files - install Install plugins and packages from PyPI into the same... - package Package SQLite files into a Datasette Docker container - plugins List currently installed plugins - publish Publish specified SQLite database files to the internet along... - uninstall Uninstall plugins and Python packages from the Datasette... + serve* Serve up specified SQLite database files with a web UI + create-token Create a signed API token for the specified actor ID + inspect Generate JSON summary of provided database files + install Install plugins and packages from PyPI into the same... + package Package SQLite files into a Datasette Docker container + plugins List currently installed plugins + publish Publish specified SQLite database files to the internet... + uninstall Uninstall plugins and Python packages from the Datasette... .. [[[end]]] @@ -591,3 +592,31 @@ This performance optimization is used automatically by some of the ``datasette p .. [[[end]]] + + +.. _cli_help_create_token___help: + +datasette create-token +====================== + +Create a signed API token, see :ref:`authentication_cli_create_token`. + +.. [[[cog + help(["create-token", "--help"]) +.. ]]] + +:: + + Usage: datasette create-token [OPTIONS] ID + + Create a signed API token for the specified actor ID + + Options: + --secret TEXT Secret used for signing the API tokens + [required] + -e, --expires-after INTEGER Token should expire after this many seconds + --debug Show decoded token + --help Show this message and exit. + + +.. [[[end]]] diff --git a/docs/plugins.rst b/docs/plugins.rst index 9efef32f..3ae42293 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -152,7 +152,8 @@ If you run ``datasette plugins --all`` it will include default plugins that ship "version": null, "hooks": [ "actor_from_request", - "permission_allowed" + "permission_allowed", + "register_commands" ] }, { diff --git a/tests/test_api.py b/tests/test_api.py index ad74d16e..f7cbe950 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -806,6 +806,7 @@ def test_settings_json(app_client): "max_returned_rows": 100, "sql_time_limit_ms": 200, "allow_download": True, + "allow_signed_tokens": True, "allow_facet": True, "suggest_facets": True, "default_cache_ttl": 5, diff --git a/tests/test_auth.py b/tests/test_auth.py index a79dafd8..f2d82107 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,5 +1,7 @@ from .fixtures import app_client +from click.testing import CliRunner from datasette.utils import baseconv +from datasette.cli import cli import pytest import time @@ -235,3 +237,29 @@ def test_auth_with_dstok_token(app_client, scenario, should_work): assert response.json == {"actor": None} finally: app_client.ds._settings["allow_signed_tokens"] = True + + +@pytest.mark.parametrize("expires", (None, 1000, -1000)) +def test_cli_create_token(app_client, expires): + secret = app_client.ds._secret + runner = CliRunner(mix_stderr=False) + args = ["create-token", "--secret", secret, "test"] + if expires: + args += ["--expires-after", str(expires)] + result = runner.invoke(cli, args) + assert result.exit_code == 0 + token = result.output.strip() + assert token.startswith("dstok_") + details = app_client.ds.unsign(token[len("dstok_") :], "token") + expected_keys = {"a", "token"} + if expires: + expected_keys.add("e") + assert details.keys() == expected_keys + assert details["a"] == "test" + response = app_client.get( + "/-/actor.json", headers={"Authorization": "Bearer {}".format(token)} + ) + if expires is None or expires > 0: + assert response.json == {"actor": {"id": "test", "token": "dstok"}} + else: + assert response.json == {"actor": None} diff --git a/tests/test_plugins.py b/tests/test_plugins.py index e0a7bc76..de3fde8e 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -971,6 +971,7 @@ def test_hook_register_commands(): "plugins", "publish", "uninstall", + "create-token", } # Now install a plugin @@ -1001,6 +1002,7 @@ def test_hook_register_commands(): "uninstall", "verify", "unverify", + "create-token", } pm.unregister(name="verify") importlib.reload(cli) From df7bf0b2fc262f0b025b3cdd283ff8ce60653175 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 26 Oct 2022 14:13:31 -0700 Subject: [PATCH 0489/1250] Fix bug with breadcrumbs and request=None, closes #1849 --- datasette/app.py | 9 ++++++--- tests/test_internals_datasette.py | 9 +++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 9df16558..246269f3 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -633,15 +633,18 @@ class Datasette: async def _crumb_items(self, request, table=None, database=None): crumbs = [] + actor = None + if request: + actor = request.actor # Top-level link if await self.permission_allowed( - actor=request.actor, action="view-instance", default=True + actor=actor, action="view-instance", default=True ): crumbs.append({"href": self.urls.instance(), "label": "home"}) # Database link if database: if await self.permission_allowed( - actor=request.actor, + actor=actor, action="view-database", resource=database, default=True, @@ -656,7 +659,7 @@ class Datasette: if table: assert database, "table= requires database=" if await self.permission_allowed( - actor=request.actor, + actor=actor, action="view-table", resource=(database, table), default=True, diff --git a/tests/test_internals_datasette.py b/tests/test_internals_datasette.py index c82cafb3..1b4732af 100644 --- a/tests/test_internals_datasette.py +++ b/tests/test_internals_datasette.py @@ -125,3 +125,12 @@ async def test_datasette_ensure_permissions_check_visibility( visible, private = await ds.check_visibility(actor, permissions=permissions) assert visible == should_allow assert private == expected_private + + +@pytest.mark.asyncio +async def test_datasette_render_template_no_request(): + # https://github.com/simonw/datasette/issues/1849 + ds = Datasette([], memory=True) + await ds.invoke_startup() + rendered = await ds.render_template("error.html") + assert "Error " in rendered From 55a709c480a1e7401b4ff6208f37a2cf7c682183 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 26 Oct 2022 14:34:33 -0700 Subject: [PATCH 0490/1250] Allow leading comments on SQL queries, refs #1860 --- datasette/utils/__init__.py | 27 +++++++++++++++++++++------ tests/test_utils.py | 7 +++++++ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py index 803ba96d..977a66d6 100644 --- a/datasette/utils/__init__.py +++ b/datasette/utils/__init__.py @@ -205,13 +205,28 @@ class InvalidSql(Exception): pass +# Allow SQL to start with a /* */ or -- comment +comment_re = ( + # Start of string, then any amount of whitespace + r"^(\s*" + + + # Comment that starts with -- and ends at a newline + r"(?:\-\-.*?\n\s*)" + + + # Comment that starts with /* and ends with */ + r"|(?:/\*[\s\S]*?\*/)" + + + # Whitespace + r")*\s*" +) + allowed_sql_res = [ - re.compile(r"^select\b"), - re.compile(r"^explain\s+select\b"), - re.compile(r"^explain\s+query\s+plan\s+select\b"), - re.compile(r"^with\b"), - re.compile(r"^explain\s+with\b"), - re.compile(r"^explain\s+query\s+plan\s+with\b"), + re.compile(comment_re + r"select\b"), + re.compile(comment_re + r"explain\s+select\b"), + re.compile(comment_re + r"explain\s+query\s+plan\s+select\b"), + re.compile(comment_re + r"with\b"), + re.compile(comment_re + r"explain\s+with\b"), + re.compile(comment_re + r"explain\s+query\s+plan\s+with\b"), ] allowed_pragmas = ( "database_list", diff --git a/tests/test_utils.py b/tests/test_utils.py index d71a612d..e89f1e6b 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -141,6 +141,7 @@ def test_custom_json_encoder(obj, expected): "update blah set some_column='# Hello there\n\n* This is a list\n* of items\n--\n[And a link](https://github.com/simonw/datasette-render-markdown).'\nas demo_markdown", "PRAGMA case_sensitive_like = true", "SELECT * FROM pragma_not_on_allow_list('idx52')", + "/* This comment is not valid. select 1", ], ) def test_validate_sql_select_bad(bad_sql): @@ -166,6 +167,12 @@ def test_validate_sql_select_bad(bad_sql): "explain query plan WITH RECURSIVE cnt(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM cnt LIMIT 10) SELECT x FROM cnt;", "SELECT * FROM pragma_index_info('idx52')", "select * from pragma_table_xinfo('table')", + # Various types of comment + "-- comment\nselect 1", + "-- one line\n -- two line\nselect 1", + " /* comment */\nselect 1", + " /* comment */select 1", + "/* comment */\n -- another\n /* one more */ select 1", ], ) def test_validate_sql_select_good(good_sql): From 55f860c304aea813cb7ed740cc5625560a0722a0 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 26 Oct 2022 14:13:31 -0700 Subject: [PATCH 0491/1250] Fix bug with breadcrumbs and request=None, closes #1849 --- datasette/app.py | 9 ++++++--- tests/test_internals_datasette.py | 9 +++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index c868f8d3..596ff44d 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -639,15 +639,18 @@ class Datasette: async def _crumb_items(self, request, table=None, database=None): crumbs = [] + actor = None + if request: + actor = request.actor # Top-level link if await self.permission_allowed( - actor=request.actor, action="view-instance", default=True + actor=actor, action="view-instance", default=True ): crumbs.append({"href": self.urls.instance(), "label": "home"}) # Database link if database: if await self.permission_allowed( - actor=request.actor, + actor=actor, action="view-database", resource=database, default=True, @@ -662,7 +665,7 @@ class Datasette: if table: assert database, "table= requires database=" if await self.permission_allowed( - actor=request.actor, + actor=actor, action="view-table", resource=(database, table), default=True, diff --git a/tests/test_internals_datasette.py b/tests/test_internals_datasette.py index c82cafb3..1b4732af 100644 --- a/tests/test_internals_datasette.py +++ b/tests/test_internals_datasette.py @@ -125,3 +125,12 @@ async def test_datasette_ensure_permissions_check_visibility( visible, private = await ds.check_visibility(actor, permissions=permissions) assert visible == should_allow assert private == expected_private + + +@pytest.mark.asyncio +async def test_datasette_render_template_no_request(): + # https://github.com/simonw/datasette/issues/1849 + ds = Datasette([], memory=True) + await ds.invoke_startup() + rendered = await ds.render_template("error.html") + assert "Error " in rendered From af5d5d0243631562ad83f2c318bff31a077feb5d Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 26 Oct 2022 14:34:33 -0700 Subject: [PATCH 0492/1250] Allow leading comments on SQL queries, refs #1860 --- datasette/utils/__init__.py | 27 +++++++++++++++++++++------ tests/test_utils.py | 7 +++++++ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py index 803ba96d..977a66d6 100644 --- a/datasette/utils/__init__.py +++ b/datasette/utils/__init__.py @@ -205,13 +205,28 @@ class InvalidSql(Exception): pass +# Allow SQL to start with a /* */ or -- comment +comment_re = ( + # Start of string, then any amount of whitespace + r"^(\s*" + + + # Comment that starts with -- and ends at a newline + r"(?:\-\-.*?\n\s*)" + + + # Comment that starts with /* and ends with */ + r"|(?:/\*[\s\S]*?\*/)" + + + # Whitespace + r")*\s*" +) + allowed_sql_res = [ - re.compile(r"^select\b"), - re.compile(r"^explain\s+select\b"), - re.compile(r"^explain\s+query\s+plan\s+select\b"), - re.compile(r"^with\b"), - re.compile(r"^explain\s+with\b"), - re.compile(r"^explain\s+query\s+plan\s+with\b"), + re.compile(comment_re + r"select\b"), + re.compile(comment_re + r"explain\s+select\b"), + re.compile(comment_re + r"explain\s+query\s+plan\s+select\b"), + re.compile(comment_re + r"with\b"), + re.compile(comment_re + r"explain\s+with\b"), + re.compile(comment_re + r"explain\s+query\s+plan\s+with\b"), ] allowed_pragmas = ( "database_list", diff --git a/tests/test_utils.py b/tests/test_utils.py index d71a612d..e89f1e6b 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -141,6 +141,7 @@ def test_custom_json_encoder(obj, expected): "update blah set some_column='# Hello there\n\n* This is a list\n* of items\n--\n[And a link](https://github.com/simonw/datasette-render-markdown).'\nas demo_markdown", "PRAGMA case_sensitive_like = true", "SELECT * FROM pragma_not_on_allow_list('idx52')", + "/* This comment is not valid. select 1", ], ) def test_validate_sql_select_bad(bad_sql): @@ -166,6 +167,12 @@ def test_validate_sql_select_bad(bad_sql): "explain query plan WITH RECURSIVE cnt(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM cnt LIMIT 10) SELECT x FROM cnt;", "SELECT * FROM pragma_index_info('idx52')", "select * from pragma_table_xinfo('table')", + # Various types of comment + "-- comment\nselect 1", + "-- one line\n -- two line\nselect 1", + " /* comment */\nselect 1", + " /* comment */select 1", + "/* comment */\n -- another\n /* one more */ select 1", ], ) def test_validate_sql_select_good(good_sql): From 382a87158337540f991c6dc887080f7b37c7c26e Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 26 Oct 2022 14:13:31 -0700 Subject: [PATCH 0493/1250] max_signed_tokens_ttl setting, closes #1858 Also redesigned token format to include creation time and optional duration. --- datasette/app.py | 5 ++++ datasette/default_permissions.py | 33 +++++++++++++++++---- datasette/views/special.py | 20 ++++++++----- docs/settings.rst | 15 ++++++++++ tests/test_api.py | 1 + tests/test_auth.py | 50 ++++++++++++++++++++++++-------- 6 files changed, 99 insertions(+), 25 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 596ff44d..894d7f0f 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -129,6 +129,11 @@ SETTINGS = ( True, "Allow users to create and use signed API tokens", ), + Setting( + "max_signed_tokens_ttl", + 0, + "Maximum allowed expiry time for signed API tokens", + ), Setting("suggest_facets", True, "Calculate and display suggested facets"), Setting( "default_cache_ttl", diff --git a/datasette/default_permissions.py b/datasette/default_permissions.py index 12499c16..c502dd70 100644 --- a/datasette/default_permissions.py +++ b/datasette/default_permissions.py @@ -56,6 +56,7 @@ def actor_from_request(datasette, request): prefix = "dstok_" if not datasette.setting("allow_signed_tokens"): return None + max_signed_tokens_ttl = datasette.setting("max_signed_tokens_ttl") authorization = request.headers.get("authorization") if not authorization: return None @@ -69,11 +70,31 @@ def actor_from_request(datasette, request): decoded = datasette.unsign(token, namespace="token") except itsdangerous.BadSignature: return None - expires_at = decoded.get("e") - if expires_at is not None: - if expires_at < time.time(): + if "t" not in decoded: + # Missing timestamp + return None + created = decoded["t"] + if not isinstance(created, int): + # Invalid timestamp + return None + duration = decoded.get("d") + if duration is not None and not isinstance(duration, int): + # Invalid duration + return None + if (duration is None and max_signed_tokens_ttl) or ( + duration is not None + and max_signed_tokens_ttl + and duration > max_signed_tokens_ttl + ): + duration = max_signed_tokens_ttl + if duration: + if time.time() - created > duration: + # Expired return None - return {"id": decoded["a"], "token": "dstok"} + actor = {"id": decoded["a"], "token": "dstok"} + if duration: + actor["token_expires"] = created + duration + return actor @hookimpl @@ -102,9 +123,9 @@ def register_commands(cli): def create_token(id, secret, expires_after, debug): "Create a signed API token for the specified actor ID" ds = Datasette(secret=secret) - bits = {"a": id, "token": "dstok"} + bits = {"a": id, "token": "dstok", "t": int(time.time())} if expires_after: - bits["e"] = int(time.time()) + expires_after + bits["d"] = expires_after token = ds.sign(bits, namespace="token") click.echo("dstok_{}".format(token)) if debug: diff --git a/datasette/views/special.py b/datasette/views/special.py index 89015958..b754a2f0 100644 --- a/datasette/views/special.py +++ b/datasette/views/special.py @@ -195,20 +195,24 @@ class CreateTokenView(BaseView): async def post(self, request): self.check_permission(request) post = await request.post_vars() - expires = None errors = [] + duration = None if post.get("expire_type"): - duration = post.get("expire_duration") - if not duration or not duration.isdigit() or not int(duration) > 0: + duration_string = post.get("expire_duration") + if ( + not duration_string + or not duration_string.isdigit() + or not int(duration_string) > 0 + ): errors.append("Invalid expire duration") else: unit = post["expire_type"] if unit == "minutes": - expires = int(duration) * 60 + duration = int(duration_string) * 60 elif unit == "hours": - expires = int(duration) * 60 * 60 + duration = int(duration_string) * 60 * 60 elif unit == "days": - expires = int(duration) * 60 * 60 * 24 + duration = int(duration_string) * 60 * 60 * 24 else: errors.append("Invalid expire duration unit") token_bits = None @@ -216,8 +220,10 @@ class CreateTokenView(BaseView): if not errors: token_bits = { "a": request.actor["id"], - "e": (int(time.time()) + expires) if expires else None, + "t": int(time.time()), } + if duration: + token_bits["d"] = duration token = "dstok_{}".format(self.ds.sign(token_bits, "token")) return await self.render( ["create_token.html"], diff --git a/docs/settings.rst b/docs/settings.rst index be640b21..a990c78c 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -182,6 +182,21 @@ This is turned on by default. Use the following to turn it off:: Turning this setting off will disable the ``/-/create-token`` page, :ref:`described here `. It will also cause any incoming ``Authorization: Bearer dstok_...`` API tokens to be ignored. +.. _setting_max_signed_tokens_ttl: + +max_signed_tokens_ttl +~~~~~~~~~~~~~~~~~~~~~ + +Maximum allowed expiry time for signed API tokens created by users. + +Defaults to ``0`` which means no limit - tokens can be created that will never expire. + +Set this to a value in seconds to limit the maximum expiry time. For example, to set that limit to 24 hours you would use:: + + datasette mydatabase.db --setting max_signed_tokens_ttl 86400 + +This setting is enforced when incoming tokens are processed. + .. _setting_default_cache_ttl: default_cache_ttl diff --git a/tests/test_api.py b/tests/test_api.py index f7cbe950..fc171421 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -807,6 +807,7 @@ def test_settings_json(app_client): "sql_time_limit_ms": 200, "allow_download": True, "allow_signed_tokens": True, + "max_signed_tokens_ttl": 0, "allow_facet": True, "suggest_facets": True, "default_cache_ttl": 5, diff --git a/tests/test_auth.py b/tests/test_auth.py index f2d82107..fa1b2e46 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -173,13 +173,19 @@ def test_auth_create_token(app_client, post_data, errors, expected_duration): # Extract token from page token = response2.text.split('value="dstok_')[1].split('"')[0] details = app_client.ds.unsign(token, "token") - assert details.keys() == {"a", "e"} + assert details.keys() == {"a", "t", "d"} or details.keys() == {"a", "t"} assert details["a"] == "test" if expected_duration is None: - assert details["e"] is None + assert "d" not in details else: - about_right = int(time.time()) + expected_duration - assert about_right - 2 < details["e"] < about_right + 2 + assert details["d"] == expected_duration + # And test that token + response3 = app_client.get( + "/-/actor.json", + headers={"Authorization": "Bearer {}".format("dstok_{}".format(token))}, + ) + assert response3.status == 200 + assert response3.json["actor"]["id"] == "test" def test_auth_create_token_not_allowed_for_tokens(app_client): @@ -206,6 +212,7 @@ def test_auth_create_token_not_allowed_if_allow_signed_tokens_off(app_client): ( ("allow_signed_tokens_off", False), ("no_token", False), + ("no_timestamp", False), ("invalid_token", False), ("expired_token", False), ("valid_unlimited_token", True), @@ -214,12 +221,15 @@ def test_auth_create_token_not_allowed_if_allow_signed_tokens_off(app_client): ) def test_auth_with_dstok_token(app_client, scenario, should_work): token = None + _time = int(time.time()) if scenario in ("valid_unlimited_token", "allow_signed_tokens_off"): - token = app_client.ds.sign({"a": "test"}, "token") + token = app_client.ds.sign({"a": "test", "t": _time}, "token") elif scenario == "valid_expiring_token": - token = app_client.ds.sign({"a": "test", "e": int(time.time()) + 1000}, "token") + token = app_client.ds.sign({"a": "test", "t": _time - 50, "d": 1000}, "token") elif scenario == "expired_token": - token = app_client.ds.sign({"a": "test", "e": int(time.time()) - 1000}, "token") + token = app_client.ds.sign({"a": "test", "t": _time - 2000, "d": 1000}, "token") + elif scenario == "no_timestamp": + token = app_client.ds.sign({"a": "test"}, "token") elif scenario == "invalid_token": token = "invalid" if token: @@ -232,7 +242,16 @@ def test_auth_with_dstok_token(app_client, scenario, should_work): response = app_client.get("/-/actor.json", headers=headers) try: if should_work: - assert response.json == {"actor": {"id": "test", "token": "dstok"}} + assert response.json.keys() == {"actor"} + actor = response.json["actor"] + expected_keys = {"id", "token"} + if scenario != "valid_unlimited_token": + expected_keys.add("token_expires") + assert actor.keys() == expected_keys + assert actor["id"] == "test" + assert actor["token"] == "dstok" + if scenario != "valid_unlimited_token": + assert isinstance(actor["token_expires"], int) else: assert response.json == {"actor": None} finally: @@ -251,15 +270,22 @@ def test_cli_create_token(app_client, expires): token = result.output.strip() assert token.startswith("dstok_") details = app_client.ds.unsign(token[len("dstok_") :], "token") - expected_keys = {"a", "token"} + expected_keys = {"a", "token", "t"} if expires: - expected_keys.add("e") + expected_keys.add("d") assert details.keys() == expected_keys assert details["a"] == "test" response = app_client.get( "/-/actor.json", headers={"Authorization": "Bearer {}".format(token)} ) if expires is None or expires > 0: - assert response.json == {"actor": {"id": "test", "token": "dstok"}} + expected_actor = { + "id": "test", + "token": "dstok", + } + if expires and expires > 0: + expected_actor["token_expires"] = details["t"] + expires + assert response.json == {"actor": expected_actor} else: - assert response.json == {"actor": None} + expected_actor = None + assert response.json == {"actor": expected_actor} From 51c436fed29205721dcf17fa31d7e7090d34ebb8 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 26 Oct 2022 20:57:02 -0700 Subject: [PATCH 0494/1250] First draft of insert row write API, refs #1851 --- datasette/default_permissions.py | 2 +- datasette/views/table.py | 76 +++++++++++++++++++++++++++----- docs/authentication.rst | 12 +++++ docs/cli-reference.rst | 2 + docs/json_api.rst | 38 ++++++++++++++++ 5 files changed, 119 insertions(+), 11 deletions(-) diff --git a/datasette/default_permissions.py b/datasette/default_permissions.py index c502dd70..87684e2a 100644 --- a/datasette/default_permissions.py +++ b/datasette/default_permissions.py @@ -9,7 +9,7 @@ import time @hookimpl(tryfirst=True) def permission_allowed(datasette, actor, action, resource): async def inner(): - if action in ("permissions-debug", "debug-menu"): + if action in ("permissions-debug", "debug-menu", "insert-row"): if actor and actor.get("id") == "root": return True elif action == "view-instance": diff --git a/datasette/views/table.py b/datasette/views/table.py index f73b0957..74d1c532 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -28,7 +28,7 @@ from datasette.utils import ( urlsafe_components, value_as_boolean, ) -from datasette.utils.asgi import BadRequest, Forbidden, NotFound +from datasette.utils.asgi import BadRequest, Forbidden, NotFound, Response from datasette.filters import Filters from .base import DataView, DatasetteError, ureg from .database import QueryView @@ -103,15 +103,71 @@ class TableView(DataView): canned_query = await self.ds.get_canned_query( database_name, table_name, request.actor ) - assert canned_query, "You may only POST to a canned query" - return await QueryView(self.ds).data( - request, - canned_query["sql"], - metadata=canned_query, - editable=False, - canned_query=table_name, - named_parameters=canned_query.get("params"), - write=bool(canned_query.get("write")), + if canned_query: + return await QueryView(self.ds).data( + request, + canned_query["sql"], + metadata=canned_query, + editable=False, + canned_query=table_name, + named_parameters=canned_query.get("params"), + write=bool(canned_query.get("write")), + ) + else: + # Handle POST to a table + return await self.table_post(request, database_name, table_name) + + async def table_post(self, request, database_name, table_name): + # Table must exist (may handle table creation in the future) + db = self.ds.get_database(database_name) + if not await db.table_exists(table_name): + raise NotFound("Table not found: {}".format(table_name)) + # Must have insert-row permission + if not await self.ds.permission_allowed( + request.actor, "insert-row", resource=(database_name, table_name) + ): + raise Forbidden("Permission denied") + if request.headers.get("content-type") != "application/json": + # TODO: handle form-encoded data + raise BadRequest("Must send JSON data") + data = json.loads(await request.post_body()) + if "row" not in data: + raise BadRequest('Must send "row" data') + row = data["row"] + if not isinstance(row, dict): + raise BadRequest("row must be a dictionary") + # Verify all columns exist + columns = await db.table_columns(table_name) + pks = await db.primary_keys(table_name) + for key in row: + if key not in columns: + raise BadRequest("Column not found: {}".format(key)) + if key in pks: + raise BadRequest( + "Cannot insert into primary key column: {}".format(key) + ) + # Perform the insert + sql = "INSERT INTO [{table}] ({columns}) VALUES ({values})".format( + table=escape_sqlite(table_name), + columns=", ".join(escape_sqlite(c) for c in row), + values=", ".join("?" for c in row), + ) + cursor = await db.execute_write(sql, list(row.values())) + # Return the new row + rowid = cursor.lastrowid + new_row = ( + await db.execute( + "SELECT * FROM [{table}] WHERE rowid = ?".format( + table=escape_sqlite(table_name) + ), + [rowid], + ) + ).first() + return Response.json( + { + "row": dict(new_row), + }, + status=201, ) async def columns_to_select(self, table_columns, pks, request): diff --git a/docs/authentication.rst b/docs/authentication.rst index 0835e17c..233a50d2 100644 --- a/docs/authentication.rst +++ b/docs/authentication.rst @@ -547,6 +547,18 @@ Actor is allowed to view (and execute) a :ref:`canned query ` pa Default *allow*. +.. _permissions_insert_row: + +insert-row +---------- + +Actor is allowed to insert rows into a table. + +``resource`` - tuple: (string, string) + The name of the database, then the name of the table + +Default *deny*. + .. _permissions_execute_sql: execute-sql diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index b40c6b2c..56156568 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -229,6 +229,8 @@ These can be passed to ``datasette serve`` using ``datasette serve --setting nam database files (default=True) allow_signed_tokens Allow users to create and use signed API tokens (default=True) + max_signed_tokens_ttl Maximum allowed expiry time for signed API tokens + (default=0) suggest_facets Calculate and display suggested facets (default=True) default_cache_ttl Default HTTP cache TTL (used in Cache-Control: diff --git a/docs/json_api.rst b/docs/json_api.rst index d3fdb1e4..b339a738 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -455,3 +455,41 @@ You can find this near the top of the source code of those pages, looking like t The JSON URL is also made available in a ``Link`` HTTP header for the page:: Link: https://latest.datasette.io/fixtures/sortable.json; rel="alternate"; type="application/json+datasette" + +.. _json_api_write: + +The JSON write API +------------------ + +Datasette provides a write API for JSON data. This is a POST-only API that requires an authenticated API token, see :ref:`CreateTokenView`. + +.. _json_api_write_insert_row: + +Inserting a single row +~~~~~~~~~~~~~~~~~~~~~~ + +This requires the :ref:`permissions_insert_row` permission. + +:: + + POST // + Content-Type: application/json + Authorization: Bearer dstok_ + { + "row": { + "column1": "value1", + "column2": "value2" + } + } + +If successful, this will return a ``201`` status code and the newly inserted row, for example: + +.. code-block:: json + + { + "row": { + "id": 1, + "column1": "value1", + "column2": "value2" + } + } From f6ca86987ba9d7d48eccf2cfe0bfc94942003844 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 27 Oct 2022 06:56:11 -0700 Subject: [PATCH 0495/1250] Delete mirror-master-and-main.yml Closes #1865 --- .github/workflows/mirror-master-and-main.yml | 21 -------------------- 1 file changed, 21 deletions(-) delete mode 100644 .github/workflows/mirror-master-and-main.yml diff --git a/.github/workflows/mirror-master-and-main.yml b/.github/workflows/mirror-master-and-main.yml deleted file mode 100644 index 8418df40..00000000 --- a/.github/workflows/mirror-master-and-main.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: Mirror "master" and "main" branches -on: - push: - branches: - - master - - main - -jobs: - mirror: - runs-on: ubuntu-latest - steps: - - name: Mirror to "master" - uses: zofrex/mirror-branch@ea152f124954fa4eb26eea3fe0dbe313a3a08d94 - with: - target-branch: master - force: false - - name: Mirror to "main" - uses: zofrex/mirror-branch@ea152f124954fa4eb26eea3fe0dbe313a3a08d94 - with: - target-branch: main - force: false From 5f6be3c48b661f74198b8fc85361d3ad6657880e Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 27 Oct 2022 11:47:41 -0700 Subject: [PATCH 0496/1250] Better comment handling in SQL regex, refs #1860 --- datasette/utils/__init__.py | 9 +++++---- tests/test_utils.py | 1 + 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py index 977a66d6..5acfb8b4 100644 --- a/datasette/utils/__init__.py +++ b/datasette/utils/__init__.py @@ -208,16 +208,16 @@ class InvalidSql(Exception): # Allow SQL to start with a /* */ or -- comment comment_re = ( # Start of string, then any amount of whitespace - r"^(\s*" + r"^\s*(" + # Comment that starts with -- and ends at a newline r"(?:\-\-.*?\n\s*)" + - # Comment that starts with /* and ends with */ - r"|(?:/\*[\s\S]*?\*/)" + # Comment that starts with /* and ends with */ - but does not have */ in it + r"|(?:\/\*((?!\*\/)[\s\S])*\*\/)" + # Whitespace - r")*\s*" + r"\s*)*\s*" ) allowed_sql_res = [ @@ -228,6 +228,7 @@ allowed_sql_res = [ re.compile(comment_re + r"explain\s+with\b"), re.compile(comment_re + r"explain\s+query\s+plan\s+with\b"), ] + allowed_pragmas = ( "database_list", "foreign_key_list", diff --git a/tests/test_utils.py b/tests/test_utils.py index e89f1e6b..c1589107 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -142,6 +142,7 @@ def test_custom_json_encoder(obj, expected): "PRAGMA case_sensitive_like = true", "SELECT * FROM pragma_not_on_allow_list('idx52')", "/* This comment is not valid. select 1", + "/**/\nupdate foo set bar = 1\n/* test */ select 1", ], ) def test_validate_sql_select_bad(bad_sql): From d2ca13b699d441a201c55cb72ff96919d3cd22bf Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 27 Oct 2022 11:50:54 -0700 Subject: [PATCH 0497/1250] Add test for /* multi line */ comment, refs #1860 --- tests/test_utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_utils.py b/tests/test_utils.py index c1589107..8b64f865 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -174,6 +174,7 @@ def test_validate_sql_select_bad(bad_sql): " /* comment */\nselect 1", " /* comment */select 1", "/* comment */\n -- another\n /* one more */ select 1", + "/* This comment \n has multiple lines */\nselect 1", ], ) def test_validate_sql_select_good(good_sql): From 918f3561208ee58c44773d30e21bace7d7c7cf3b Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 27 Oct 2022 06:56:11 -0700 Subject: [PATCH 0498/1250] Delete mirror-master-and-main.yml Closes #1865 --- .github/workflows/mirror-master-and-main.yml | 21 -------------------- 1 file changed, 21 deletions(-) delete mode 100644 .github/workflows/mirror-master-and-main.yml diff --git a/.github/workflows/mirror-master-and-main.yml b/.github/workflows/mirror-master-and-main.yml deleted file mode 100644 index 8418df40..00000000 --- a/.github/workflows/mirror-master-and-main.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: Mirror "master" and "main" branches -on: - push: - branches: - - master - - main - -jobs: - mirror: - runs-on: ubuntu-latest - steps: - - name: Mirror to "master" - uses: zofrex/mirror-branch@ea152f124954fa4eb26eea3fe0dbe313a3a08d94 - with: - target-branch: master - force: false - - name: Mirror to "main" - uses: zofrex/mirror-branch@ea152f124954fa4eb26eea3fe0dbe313a3a08d94 - with: - target-branch: main - force: false From b597bb6b3e7c4b449654bbfa5b01ceff3eb3cb33 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 27 Oct 2022 11:47:41 -0700 Subject: [PATCH 0499/1250] Better comment handling in SQL regex, refs #1860 --- datasette/utils/__init__.py | 9 +++++---- tests/test_utils.py | 1 + 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py index 977a66d6..5acfb8b4 100644 --- a/datasette/utils/__init__.py +++ b/datasette/utils/__init__.py @@ -208,16 +208,16 @@ class InvalidSql(Exception): # Allow SQL to start with a /* */ or -- comment comment_re = ( # Start of string, then any amount of whitespace - r"^(\s*" + r"^\s*(" + # Comment that starts with -- and ends at a newline r"(?:\-\-.*?\n\s*)" + - # Comment that starts with /* and ends with */ - r"|(?:/\*[\s\S]*?\*/)" + # Comment that starts with /* and ends with */ - but does not have */ in it + r"|(?:\/\*((?!\*\/)[\s\S])*\*\/)" + # Whitespace - r")*\s*" + r"\s*)*\s*" ) allowed_sql_res = [ @@ -228,6 +228,7 @@ allowed_sql_res = [ re.compile(comment_re + r"explain\s+with\b"), re.compile(comment_re + r"explain\s+query\s+plan\s+with\b"), ] + allowed_pragmas = ( "database_list", "foreign_key_list", diff --git a/tests/test_utils.py b/tests/test_utils.py index e89f1e6b..c1589107 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -142,6 +142,7 @@ def test_custom_json_encoder(obj, expected): "PRAGMA case_sensitive_like = true", "SELECT * FROM pragma_not_on_allow_list('idx52')", "/* This comment is not valid. select 1", + "/**/\nupdate foo set bar = 1\n/* test */ select 1", ], ) def test_validate_sql_select_bad(bad_sql): From 6958e21b5c2012adf5655d2512cb4106490d10f2 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 27 Oct 2022 11:50:54 -0700 Subject: [PATCH 0500/1250] Add test for /* multi line */ comment, refs #1860 --- tests/test_utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_utils.py b/tests/test_utils.py index c1589107..8b64f865 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -174,6 +174,7 @@ def test_validate_sql_select_bad(bad_sql): " /* comment */\nselect 1", " /* comment */select 1", "/* comment */\n -- another\n /* one more */ select 1", + "/* This comment \n has multiple lines */\nselect 1", ], ) def test_validate_sql_select_good(good_sql): From a51608090b5ee37593078f71d18b33767ef3af79 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 27 Oct 2022 12:06:18 -0700 Subject: [PATCH 0501/1250] Slight tweak to insert row API design, refs #1851 https://github.com/simonw/datasette/issues/1851#issuecomment-1292997608 --- datasette/views/table.py | 10 +++++----- docs/json_api.rst | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/datasette/views/table.py b/datasette/views/table.py index 74d1c532..056b7b04 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -131,11 +131,11 @@ class TableView(DataView): # TODO: handle form-encoded data raise BadRequest("Must send JSON data") data = json.loads(await request.post_body()) - if "row" not in data: - raise BadRequest('Must send "row" data') - row = data["row"] + if "insert" not in data: + raise BadRequest('Must send a "insert" key containing a dictionary') + row = data["insert"] if not isinstance(row, dict): - raise BadRequest("row must be a dictionary") + raise BadRequest("insert must be a dictionary") # Verify all columns exist columns = await db.table_columns(table_name) pks = await db.primary_keys(table_name) @@ -165,7 +165,7 @@ class TableView(DataView): ).first() return Response.json( { - "row": dict(new_row), + "inserted_row": dict(new_row), }, status=201, ) diff --git a/docs/json_api.rst b/docs/json_api.rst index b339a738..2ed8a354 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -476,7 +476,7 @@ This requires the :ref:`permissions_insert_row` permission. Content-Type: application/json Authorization: Bearer dstok_ { - "row": { + "insert": { "column1": "value1", "column2": "value2" } @@ -487,7 +487,7 @@ If successful, this will return a ``201`` status code and the newly inserted row .. code-block:: json { - "row": { + "inserted_row": { "id": 1, "column1": "value1", "column2": "value2" From a2a5dff709c6f1676ac30b5e734c2763002562cf Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 27 Oct 2022 12:08:26 -0700 Subject: [PATCH 0502/1250] Missing tests for insert row API, refs #1851 --- tests/test_api_write.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 tests/test_api_write.py diff --git a/tests/test_api_write.py b/tests/test_api_write.py new file mode 100644 index 00000000..86c221d0 --- /dev/null +++ b/tests/test_api_write.py @@ -0,0 +1,38 @@ +from datasette.app import Datasette +from datasette.utils import sqlite3 +import pytest +import time + + +@pytest.fixture +def ds_write(tmp_path_factory): + db_directory = tmp_path_factory.mktemp("dbs") + db_path = str(db_directory / "data.db") + db = sqlite3.connect(str(db_path)) + db.execute("vacuum") + db.execute("create table docs (id integer primary key, title text, score float)") + ds = Datasette([db_path]) + yield ds + db.close() + + +@pytest.mark.asyncio +async def test_write_row(ds_write): + token = "dstok_{}".format( + ds_write.sign( + {"a": "root", "token": "dstok", "t": int(time.time())}, namespace="token" + ) + ) + response = await ds_write.client.post( + "/data/docs", + json={"insert": {"title": "Test", "score": 1.0}}, + headers={ + "Authorization": "Bearer {}".format(token), + "Content-Type": "application/json", + }, + ) + expected_row = {"id": 1, "title": "Test", "score": 1.0} + assert response.status_code == 201 + assert response.json()["inserted_row"] == expected_row + rows = (await ds_write.get_database("data").execute("select * from docs")).rows + assert dict(rows[0]) == expected_row From 6e788b49edf4f842c0817f006eb9d865778eea5e Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 27 Oct 2022 13:17:18 -0700 Subject: [PATCH 0503/1250] New URL design /db/table/-/insert, refs #1851 --- datasette/app.py | 6 +++- datasette/views/table.py | 69 +++++++++++++++++++++++++++++++++++++++- docs/json_api.rst | 18 ++++++----- tests/test_api_write.py | 6 ++-- 4 files changed, 86 insertions(+), 13 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 894d7f0f..8bc5fe36 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -39,7 +39,7 @@ from .views.special import ( PermissionsDebugView, MessagesDebugView, ) -from .views.table import TableView +from .views.table import TableView, TableInsertView from .views.row import RowView from .renderer import json_renderer from .url_builder import Urls @@ -1262,6 +1262,10 @@ class Datasette: RowView.as_view(self), r"/(?P[^\/\.]+)/(?P
[^/]+?)/(?P[^/]+?)(\.(?P\w+))?$", ) + add_route( + TableInsertView.as_view(self), + r"/(?P[^\/\.]+)/(?P
[^\/\.]+)/-/insert$", + ) return [ # Compile any strings to regular expressions ((re.compile(pattern) if isinstance(pattern, str) else pattern), view) diff --git a/datasette/views/table.py b/datasette/views/table.py index 056b7b04..be3d4f93 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -30,7 +30,7 @@ from datasette.utils import ( ) from datasette.utils.asgi import BadRequest, Forbidden, NotFound, Response from datasette.filters import Filters -from .base import DataView, DatasetteError, ureg +from .base import BaseView, DataView, DatasetteError, ureg from .database import QueryView LINK_WITH_LABEL = ( @@ -1077,3 +1077,70 @@ async def display_columns_and_rows( } columns = [first_column] + columns return columns, cell_rows + + +class TableInsertView(BaseView): + name = "table-insert" + + def __init__(self, datasette): + self.ds = datasette + + async def post(self, request): + database_route = tilde_decode(request.url_vars["database"]) + try: + db = self.ds.get_database(route=database_route) + except KeyError: + raise NotFound("Database not found: {}".format(database_route)) + database_name = db.name + table_name = tilde_decode(request.url_vars["table"]) + # Table must exist (may handle table creation in the future) + db = self.ds.get_database(database_name) + if not await db.table_exists(table_name): + raise NotFound("Table not found: {}".format(table_name)) + # Must have insert-row permission + if not await self.ds.permission_allowed( + request.actor, "insert-row", resource=(database_name, table_name) + ): + raise Forbidden("Permission denied") + if request.headers.get("content-type") != "application/json": + # TODO: handle form-encoded data + raise BadRequest("Must send JSON data") + data = json.loads(await request.post_body()) + if "row" not in data: + raise BadRequest('Must send a "row" key containing a dictionary') + row = data["row"] + if not isinstance(row, dict): + raise BadRequest("row must be a dictionary") + # Verify all columns exist + columns = await db.table_columns(table_name) + pks = await db.primary_keys(table_name) + for key in row: + if key not in columns: + raise BadRequest("Column not found: {}".format(key)) + if key in pks: + raise BadRequest( + "Cannot insert into primary key column: {}".format(key) + ) + # Perform the insert + sql = "INSERT INTO [{table}] ({columns}) VALUES ({values})".format( + table=escape_sqlite(table_name), + columns=", ".join(escape_sqlite(c) for c in row), + values=", ".join("?" for c in row), + ) + cursor = await db.execute_write(sql, list(row.values())) + # Return the new row + rowid = cursor.lastrowid + new_row = ( + await db.execute( + "SELECT * FROM [{table}] WHERE rowid = ?".format( + table=escape_sqlite(table_name) + ), + [rowid], + ) + ).first() + return Response.json( + { + "inserted": [dict(new_row)], + }, + status=201, + ) diff --git a/docs/json_api.rst b/docs/json_api.rst index 2ed8a354..4a7961f2 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -463,7 +463,7 @@ The JSON write API Datasette provides a write API for JSON data. This is a POST-only API that requires an authenticated API token, see :ref:`CreateTokenView`. -.. _json_api_write_insert_row: +.. _TableInsertView: Inserting a single row ~~~~~~~~~~~~~~~~~~~~~~ @@ -472,11 +472,11 @@ This requires the :ref:`permissions_insert_row` permission. :: - POST //
+ POST //
/-/insert Content-Type: application/json Authorization: Bearer dstok_ { - "insert": { + "row": { "column1": "value1", "column2": "value2" } @@ -487,9 +487,11 @@ If successful, this will return a ``201`` status code and the newly inserted row .. code-block:: json { - "inserted_row": { - "id": 1, - "column1": "value1", - "column2": "value2" - } + "inserted": [ + { + "id": 1, + "column1": "value1", + "column2": "value2" + } + ] } diff --git a/tests/test_api_write.py b/tests/test_api_write.py index 86c221d0..e8222e43 100644 --- a/tests/test_api_write.py +++ b/tests/test_api_write.py @@ -24,8 +24,8 @@ async def test_write_row(ds_write): ) ) response = await ds_write.client.post( - "/data/docs", - json={"insert": {"title": "Test", "score": 1.0}}, + "/data/docs/-/insert", + json={"row": {"title": "Test", "score": 1.0}}, headers={ "Authorization": "Bearer {}".format(token), "Content-Type": "application/json", @@ -33,6 +33,6 @@ async def test_write_row(ds_write): ) expected_row = {"id": 1, "title": "Test", "score": 1.0} assert response.status_code == 201 - assert response.json()["inserted_row"] == expected_row + assert response.json()["inserted"] == [expected_row] rows = (await ds_write.get_database("data").execute("select * from docs")).rows assert dict(rows[0]) == expected_row From b912d92b651c4f0b5137da924d135654511f0fe0 Mon Sep 17 00:00:00 2001 From: Forest Gregg Date: Thu, 27 Oct 2022 16:51:20 -0400 Subject: [PATCH 0504/1250] Make hash and size a lazy property (#1837) * use inspect data for hash and file size * make hash and cached_size lazy properties * move hash property near size --- datasette/database.py | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/datasette/database.py b/datasette/database.py index d75bd70c..af1df0a8 100644 --- a/datasette/database.py +++ b/datasette/database.py @@ -39,7 +39,7 @@ class Database: self.memory_name = memory_name if memory_name is not None: self.is_memory = True - self.hash = None + self.cached_hash = None self.cached_size = None self._cached_table_counts = None self._write_thread = None @@ -47,14 +47,6 @@ class Database: # These are used when in non-threaded mode: self._read_connection = None self._write_connection = None - if not self.is_mutable and not self.is_memory: - if self.ds.inspect_data and self.ds.inspect_data.get(self.name): - self.hash = self.ds.inspect_data[self.name]["hash"] - self.cached_size = self.ds.inspect_data[self.name]["size"] - else: - p = Path(path) - self.hash = inspect_hash(p) - self.cached_size = p.stat().st_size @property def cached_table_counts(self): @@ -266,14 +258,34 @@ class Database: results = await self.execute_fn(sql_operation_in_thread) return results + @property + def hash(self): + if self.cached_hash is not None: + return self.cached_hash + elif self.is_mutable or self.is_memory: + return None + elif self.ds.inspect_data and self.ds.inspect_data.get(self.name): + self.cached_hash = self.ds.inspect_data[self.name]["hash"] + return self.cached_hash + else: + p = Path(self.path) + self.cached_hash = inspect_hash(p) + return self.cached_hash + @property def size(self): - if self.is_memory: - return 0 if self.cached_size is not None: return self.cached_size - else: + elif self.is_memory: + return 0 + elif self.is_mutable: return Path(self.path).stat().st_size + elif self.ds.inspect_data and self.ds.inspect_data.get(self.name): + self.cached_size = self.ds.inspect_data[self.name]["size"] + return self.cached_size + else: + self.cached_size = Path(self.path).stat().st_size + return self.cached_size async def table_counts(self, limit=10): if not self.is_mutable and self.cached_table_counts is not None: From 2c36e45447494cd7505440943367e29ec57c8e72 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 27 Oct 2022 13:51:45 -0700 Subject: [PATCH 0505/1250] Bump black from 22.8.0 to 22.10.0 (#1839) Bumps [black](https://github.com/psf/black) from 22.8.0 to 22.10.0. - [Release notes](https://github.com/psf/black/releases) - [Changelog](https://github.com/psf/black/blob/main/CHANGES.md) - [Commits](https://github.com/psf/black/compare/22.8.0...22.10.0) --- updated-dependencies: - dependency-name: black dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index fe258adb..625557ae 100644 --- a/setup.py +++ b/setup.py @@ -76,7 +76,7 @@ setup( "pytest-xdist>=2.2.1", "pytest-asyncio>=0.17", "beautifulsoup4>=4.8.1", - "black==22.8.0", + "black==22.10.0", "blacken-docs==1.12.1", "pytest-timeout>=1.4.2", "trustme>=0.7", From e5e0459a0b60608cb5e9ff83f6b41f59e6cafdfd Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 27 Oct 2022 13:58:00 -0700 Subject: [PATCH 0506/1250] Release notes for 0.63, refs #1869 --- docs/changelog.rst | 44 +++++++++++++++++++++++++------------------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 2255dcce..01957e4f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,36 +4,42 @@ Changelog ========= -.. _v0_63a1: +.. _v0_63: -0.63a1 (2022-10-23) -------------------- +0.63 (2022-10-27) +----------------- +Features +~~~~~~~~ + +- Now tested against Python 3.11. Docker containers used by ``datasette publish`` and ``datasette package`` both now use that version of Python. (:issue:`1853`) +- ``--load-extension`` option now supports entrypoints. Thanks, Alex Garcia. (`#1789 `__) +- Facet size can now be set per-table with the new ``facet_size`` table metadata option. (:issue:`1804`) +- The :ref:`setting_truncate_cells_html` setting now also affects long URLs in columns. (:issue:`1805`) +- The non-JavaScript SQL editor textarea now increases height to fit the SQL query. (:issue:`1786`) +- Facets are now displayed with better line-breaks in long values. Thanks, Daniel Rech. (`#1794 `__) +- The ``settings.json`` file used in :ref:`config_dir` is now validated on startup. (:issue:`1816`) +- SQL queries can now include leading SQL comments, using ``/* ... */`` or ``-- ...`` syntax. Thanks, Charles Nepote. (:issue:`1860`) - SQL query is now re-displayed when terminated with a time limit error. (:issue:`1819`) -- New documentation on :ref:`deploying_openrc` - thanks, Adam Simpson. (`#1825 `__) - The :ref:`inspect data ` mechanism is now used to speed up server startup - thanks, Forest Gregg. (:issue:`1834`) - In :ref:`config_dir` databases with filenames ending in ``.sqlite`` or ``.sqlite3`` are now automatically added to the Datasette instance. (:issue:`1646`) - Breadcrumb navigation display now respects the current user's permissions. (:issue:`1831`) -- Screenshots in the documentation are now maintained using `shot-scraper `__, as described in `Automating screenshots for the Datasette documentation using shot-scraper `__. (:issue:`1844`) -- The :ref:`datasette.check_visibility() ` method now accepts an optional ``permissions=`` list, allowing it to take multiple permissions into account at once when deciding if something should be shown as public or private. This has been used to correctly display padlock icons in more places in the Datasette interface. (:issue:`1829`) - -.. _v0_63a0: - -0.63a0 (2022-09-26) -------------------- +Plugin hooks and internals +~~~~~~~~~~~~~~~~~~~~~~~~~~ - The :ref:`plugin_hook_prepare_jinja2_environment` plugin hook now accepts an optional ``datasette`` argument. Hook implementations can also now return an ``async`` function which will be awaited automatically. (:issue:`1809`) -- ``--load-extension`` option now supports entrypoints. Thanks, Alex Garcia. (`#1789 `__) -- New tutorial: `Cleaning data with sqlite-utils and Datasette `__. -- Facet size can now be set per-table with the new ``facet_size`` table metadata option. (:issue:`1804`) -- ``truncate_cells_html`` setting now also affects long URLs in columns. (:issue:`1805`) - ``Database(is_mutable=)`` now defaults to ``True``. (:issue:`1808`) -- Non-JavaScript textarea now increases height to fit the SQL query. (:issue:`1786`) -- More detailed command descriptions on the :ref:`CLI reference ` page. (:issue:`1787`) +- The :ref:`datasette.check_visibility() ` method now accepts an optional ``permissions=`` list, allowing it to take multiple permissions into account at once when deciding if something should be shown as public or private. This has been used to correctly display padlock icons in more places in the Datasette interface. (:issue:`1829`) - Datasette no longer enforces upper bounds on its dependencies. (:issue:`1800`) -- Facets are now displayed with better line-breaks in long values. Thanks, Daniel Rech. (`#1794 `__) -- The ``settings.json`` file used in :ref:`config_dir` is now validated on startup. (:issue:`1816`) + +Documentation +~~~~~~~~~~~~~ + +- New tutorial: `Cleaning data with sqlite-utils and Datasette `__. +- Screenshots in the documentation are now maintained using `shot-scraper `__, as described in `Automating screenshots for the Datasette documentation using shot-scraper `__. (:issue:`1844`) +- More detailed command descriptions on the :ref:`CLI reference ` page. (:issue:`1787`) +- New documentation on :ref:`deploying_openrc` - thanks, Adam Simpson. (`#1825 `__) .. _v0_62: From bf00b0b59b6692bdec597ac9db4e0b497c5a47b4 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 27 Oct 2022 15:11:26 -0700 Subject: [PATCH 0507/1250] Release 0.63 Refs #1646, #1786, #1787, #1789, #1794, #1800, #1804, #1805, #1808, #1809, #1816, #1819, #1825, #1829, #1831, #1834, #1844, #1853, #1860 Closes #1869 --- datasette/version.py | 2 +- docs/changelog.rst | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/datasette/version.py b/datasette/version.py index eb36da45..ac012640 100644 --- a/datasette/version.py +++ b/datasette/version.py @@ -1,2 +1,2 @@ -__version__ = "0.63a1" +__version__ = "0.63" __version_info__ = tuple(__version__.split(".")) diff --git a/docs/changelog.rst b/docs/changelog.rst index 01957e4f..f573afb3 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -9,6 +9,8 @@ Changelog 0.63 (2022-10-27) ----------------- +See `Datasette 0.63: The annotated release notes `__ for more background on the changes in this release. + Features ~~~~~~~~ From 2ea60e12d90b7cec03ebab728854d3ec4d553f54 Mon Sep 17 00:00:00 2001 From: Forest Gregg Date: Thu, 27 Oct 2022 16:51:20 -0400 Subject: [PATCH 0508/1250] Make hash and size a lazy property (#1837) * use inspect data for hash and file size * make hash and cached_size lazy properties * move hash property near size --- datasette/database.py | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/datasette/database.py b/datasette/database.py index d75bd70c..af1df0a8 100644 --- a/datasette/database.py +++ b/datasette/database.py @@ -39,7 +39,7 @@ class Database: self.memory_name = memory_name if memory_name is not None: self.is_memory = True - self.hash = None + self.cached_hash = None self.cached_size = None self._cached_table_counts = None self._write_thread = None @@ -47,14 +47,6 @@ class Database: # These are used when in non-threaded mode: self._read_connection = None self._write_connection = None - if not self.is_mutable and not self.is_memory: - if self.ds.inspect_data and self.ds.inspect_data.get(self.name): - self.hash = self.ds.inspect_data[self.name]["hash"] - self.cached_size = self.ds.inspect_data[self.name]["size"] - else: - p = Path(path) - self.hash = inspect_hash(p) - self.cached_size = p.stat().st_size @property def cached_table_counts(self): @@ -266,14 +258,34 @@ class Database: results = await self.execute_fn(sql_operation_in_thread) return results + @property + def hash(self): + if self.cached_hash is not None: + return self.cached_hash + elif self.is_mutable or self.is_memory: + return None + elif self.ds.inspect_data and self.ds.inspect_data.get(self.name): + self.cached_hash = self.ds.inspect_data[self.name]["hash"] + return self.cached_hash + else: + p = Path(self.path) + self.cached_hash = inspect_hash(p) + return self.cached_hash + @property def size(self): - if self.is_memory: - return 0 if self.cached_size is not None: return self.cached_size - else: + elif self.is_memory: + return 0 + elif self.is_mutable: return Path(self.path).stat().st_size + elif self.ds.inspect_data and self.ds.inspect_data.get(self.name): + self.cached_size = self.ds.inspect_data[self.name]["size"] + return self.cached_size + else: + self.cached_size = Path(self.path).stat().st_size + return self.cached_size async def table_counts(self, limit=10): if not self.is_mutable and self.cached_table_counts is not None: From 641bc4453b5ef1dff0b2fc7dfad0b692be7aa61c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 27 Oct 2022 13:51:45 -0700 Subject: [PATCH 0509/1250] Bump black from 22.8.0 to 22.10.0 (#1839) Bumps [black](https://github.com/psf/black) from 22.8.0 to 22.10.0. - [Release notes](https://github.com/psf/black/releases) - [Changelog](https://github.com/psf/black/blob/main/CHANGES.md) - [Commits](https://github.com/psf/black/compare/22.8.0...22.10.0) --- updated-dependencies: - dependency-name: black dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index fe258adb..625557ae 100644 --- a/setup.py +++ b/setup.py @@ -76,7 +76,7 @@ setup( "pytest-xdist>=2.2.1", "pytest-asyncio>=0.17", "beautifulsoup4>=4.8.1", - "black==22.8.0", + "black==22.10.0", "blacken-docs==1.12.1", "pytest-timeout>=1.4.2", "trustme>=0.7", From 26af9b9c4a6c62ee15870caa1c7bc455165d3b11 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 27 Oct 2022 13:58:00 -0700 Subject: [PATCH 0510/1250] Release notes for 0.63, refs #1869 --- docs/changelog.rst | 44 +++++++++++++++++++++++++------------------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 2255dcce..01957e4f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,36 +4,42 @@ Changelog ========= -.. _v0_63a1: +.. _v0_63: -0.63a1 (2022-10-23) -------------------- +0.63 (2022-10-27) +----------------- +Features +~~~~~~~~ + +- Now tested against Python 3.11. Docker containers used by ``datasette publish`` and ``datasette package`` both now use that version of Python. (:issue:`1853`) +- ``--load-extension`` option now supports entrypoints. Thanks, Alex Garcia. (`#1789 `__) +- Facet size can now be set per-table with the new ``facet_size`` table metadata option. (:issue:`1804`) +- The :ref:`setting_truncate_cells_html` setting now also affects long URLs in columns. (:issue:`1805`) +- The non-JavaScript SQL editor textarea now increases height to fit the SQL query. (:issue:`1786`) +- Facets are now displayed with better line-breaks in long values. Thanks, Daniel Rech. (`#1794 `__) +- The ``settings.json`` file used in :ref:`config_dir` is now validated on startup. (:issue:`1816`) +- SQL queries can now include leading SQL comments, using ``/* ... */`` or ``-- ...`` syntax. Thanks, Charles Nepote. (:issue:`1860`) - SQL query is now re-displayed when terminated with a time limit error. (:issue:`1819`) -- New documentation on :ref:`deploying_openrc` - thanks, Adam Simpson. (`#1825 `__) - The :ref:`inspect data ` mechanism is now used to speed up server startup - thanks, Forest Gregg. (:issue:`1834`) - In :ref:`config_dir` databases with filenames ending in ``.sqlite`` or ``.sqlite3`` are now automatically added to the Datasette instance. (:issue:`1646`) - Breadcrumb navigation display now respects the current user's permissions. (:issue:`1831`) -- Screenshots in the documentation are now maintained using `shot-scraper `__, as described in `Automating screenshots for the Datasette documentation using shot-scraper `__. (:issue:`1844`) -- The :ref:`datasette.check_visibility() ` method now accepts an optional ``permissions=`` list, allowing it to take multiple permissions into account at once when deciding if something should be shown as public or private. This has been used to correctly display padlock icons in more places in the Datasette interface. (:issue:`1829`) - -.. _v0_63a0: - -0.63a0 (2022-09-26) -------------------- +Plugin hooks and internals +~~~~~~~~~~~~~~~~~~~~~~~~~~ - The :ref:`plugin_hook_prepare_jinja2_environment` plugin hook now accepts an optional ``datasette`` argument. Hook implementations can also now return an ``async`` function which will be awaited automatically. (:issue:`1809`) -- ``--load-extension`` option now supports entrypoints. Thanks, Alex Garcia. (`#1789 `__) -- New tutorial: `Cleaning data with sqlite-utils and Datasette `__. -- Facet size can now be set per-table with the new ``facet_size`` table metadata option. (:issue:`1804`) -- ``truncate_cells_html`` setting now also affects long URLs in columns. (:issue:`1805`) - ``Database(is_mutable=)`` now defaults to ``True``. (:issue:`1808`) -- Non-JavaScript textarea now increases height to fit the SQL query. (:issue:`1786`) -- More detailed command descriptions on the :ref:`CLI reference ` page. (:issue:`1787`) +- The :ref:`datasette.check_visibility() ` method now accepts an optional ``permissions=`` list, allowing it to take multiple permissions into account at once when deciding if something should be shown as public or private. This has been used to correctly display padlock icons in more places in the Datasette interface. (:issue:`1829`) - Datasette no longer enforces upper bounds on its dependencies. (:issue:`1800`) -- Facets are now displayed with better line-breaks in long values. Thanks, Daniel Rech. (`#1794 `__) -- The ``settings.json`` file used in :ref:`config_dir` is now validated on startup. (:issue:`1816`) + +Documentation +~~~~~~~~~~~~~ + +- New tutorial: `Cleaning data with sqlite-utils and Datasette `__. +- Screenshots in the documentation are now maintained using `shot-scraper `__, as described in `Automating screenshots for the Datasette documentation using shot-scraper `__. (:issue:`1844`) +- More detailed command descriptions on the :ref:`CLI reference ` page. (:issue:`1787`) +- New documentation on :ref:`deploying_openrc` - thanks, Adam Simpson. (`#1825 `__) .. _v0_62: From 61171f01549549e5fb25c72b13280d941d96dbf1 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 27 Oct 2022 15:11:26 -0700 Subject: [PATCH 0511/1250] Release 0.63 Refs #1646, #1786, #1787, #1789, #1794, #1800, #1804, #1805, #1808, #1809, #1816, #1819, #1825, #1829, #1831, #1834, #1844, #1853, #1860 Closes #1869 --- datasette/version.py | 2 +- docs/changelog.rst | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/datasette/version.py b/datasette/version.py index eb36da45..ac012640 100644 --- a/datasette/version.py +++ b/datasette/version.py @@ -1,2 +1,2 @@ -__version__ = "0.63a1" +__version__ = "0.63" __version_info__ = tuple(__version__.split(".")) diff --git a/docs/changelog.rst b/docs/changelog.rst index 01957e4f..f573afb3 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -9,6 +9,8 @@ Changelog 0.63 (2022-10-27) ----------------- +See `Datasette 0.63: The annotated release notes `__ for more background on the changes in this release. + Features ~~~~~~~~ From c9b5f5d598e7f85cd3e1ce020351a27da334408b Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 27 Oct 2022 17:58:36 -0700 Subject: [PATCH 0512/1250] Depend on sqlite-utils>=3.30 Decided to use the most recent version in case I decide later to use the flatten() utility function. Refs #1850 --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 625557ae..99e2a4ad 100644 --- a/setup.py +++ b/setup.py @@ -57,6 +57,7 @@ setup( "PyYAML>=5.3", "mergedeep>=1.1.1", "itsdangerous>=1.1", + "sqlite-utils>=3.30", ], entry_points=""" [console_scripts] From c35859ae3df163406f1a1895ccf9803e933b2d8e Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 29 Oct 2022 23:03:45 -0700 Subject: [PATCH 0513/1250] API for bulk inserts, closes #1866 --- datasette/app.py | 5 ++ datasette/views/table.py | 136 +++++++++++++++++++++---------- docs/cli-reference.rst | 2 + docs/json_api.rst | 48 ++++++++++- docs/settings.rst | 11 +++ tests/test_api.py | 1 + tests/test_api_write.py | 168 +++++++++++++++++++++++++++++++++++++-- 7 files changed, 320 insertions(+), 51 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 8bc5fe36..f80d3792 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -99,6 +99,11 @@ SETTINGS = ( 1000, "Maximum rows that can be returned from a table or custom query", ), + Setting( + "max_insert_rows", + 100, + "Maximum rows that can be inserted at a time using the bulk insert API", + ), Setting( "num_sql_threads", 3, diff --git a/datasette/views/table.py b/datasette/views/table.py index be3d4f93..fd203036 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -30,6 +30,7 @@ from datasette.utils import ( ) from datasette.utils.asgi import BadRequest, Forbidden, NotFound, Response from datasette.filters import Filters +import sqlite_utils from .base import BaseView, DataView, DatasetteError, ureg from .database import QueryView @@ -1085,62 +1086,109 @@ class TableInsertView(BaseView): def __init__(self, datasette): self.ds = datasette + async def _validate_data(self, request, db, table_name): + errors = [] + + def _errors(errors): + return None, errors, {} + + if request.headers.get("content-type") != "application/json": + # TODO: handle form-encoded data + return _errors(["Invalid content-type, must be application/json"]) + body = await request.post_body() + try: + data = json.loads(body) + except json.JSONDecodeError as e: + return _errors(["Invalid JSON: {}".format(e)]) + if not isinstance(data, dict): + return _errors(["JSON must be a dictionary"]) + keys = data.keys() + # keys must contain "row" or "rows" + if "row" not in keys and "rows" not in keys: + return _errors(['JSON must have one or other of "row" or "rows"']) + rows = [] + if "row" in keys: + if "rows" in keys: + return _errors(['Cannot use "row" and "rows" at the same time']) + row = data["row"] + if not isinstance(row, dict): + return _errors(['"row" must be a dictionary']) + rows = [row] + data["return_rows"] = True + else: + rows = data["rows"] + if not isinstance(rows, list): + return _errors(['"rows" must be a list']) + for row in rows: + if not isinstance(row, dict): + return _errors(['"rows" must be a list of dictionaries']) + # Does this exceed max_insert_rows? + max_insert_rows = self.ds.setting("max_insert_rows") + if len(rows) > max_insert_rows: + return _errors( + ["Too many rows, maximum allowed is {}".format(max_insert_rows)] + ) + # Validate columns of each row + columns = await db.table_columns(table_name) + # TODO: There are cases where pks are OK, if not using auto-incrementing pk + pks = await db.primary_keys(table_name) + allowed_columns = set(columns) - set(pks) + for i, row in enumerate(rows): + invalid_columns = set(row.keys()) - allowed_columns + if invalid_columns: + errors.append( + "Row {} has invalid columns: {}".format( + i, ", ".join(sorted(invalid_columns)) + ) + ) + if errors: + return _errors(errors) + extra = {key: data[key] for key in data if key not in ("rows", "row")} + return rows, errors, extra + async def post(self, request): + def _error(messages, status=400): + return Response.json({"ok": False, "errors": messages}, status=status) + database_route = tilde_decode(request.url_vars["database"]) try: db = self.ds.get_database(route=database_route) except KeyError: - raise NotFound("Database not found: {}".format(database_route)) + return _error(["Database not found: {}".format(database_route)], 404) database_name = db.name table_name = tilde_decode(request.url_vars["table"]) + # Table must exist (may handle table creation in the future) db = self.ds.get_database(database_name) if not await db.table_exists(table_name): - raise NotFound("Table not found: {}".format(table_name)) + return _error(["Table not found: {}".format(table_name)], 404) # Must have insert-row permission if not await self.ds.permission_allowed( request.actor, "insert-row", resource=(database_name, table_name) ): - raise Forbidden("Permission denied") - if request.headers.get("content-type") != "application/json": - # TODO: handle form-encoded data - raise BadRequest("Must send JSON data") - data = json.loads(await request.post_body()) - if "row" not in data: - raise BadRequest('Must send a "row" key containing a dictionary') - row = data["row"] - if not isinstance(row, dict): - raise BadRequest("row must be a dictionary") - # Verify all columns exist - columns = await db.table_columns(table_name) - pks = await db.primary_keys(table_name) - for key in row: - if key not in columns: - raise BadRequest("Column not found: {}".format(key)) - if key in pks: - raise BadRequest( - "Cannot insert into primary key column: {}".format(key) + return _error(["Permission denied"], 403) + rows, errors, extra = await self._validate_data(request, db, table_name) + if errors: + return _error(errors, 400) + + should_return = bool(extra.get("return_rows", False)) + # Insert rows + def insert_rows(conn): + table = sqlite_utils.Database(conn)[table_name] + if should_return: + rowids = [] + for row in rows: + rowids.append(table.insert(row).last_rowid) + return list( + table.rows_where( + "rowid in ({})".format(",".join("?" for _ in rowids)), rowids + ) ) - # Perform the insert - sql = "INSERT INTO [{table}] ({columns}) VALUES ({values})".format( - table=escape_sqlite(table_name), - columns=", ".join(escape_sqlite(c) for c in row), - values=", ".join("?" for c in row), - ) - cursor = await db.execute_write(sql, list(row.values())) - # Return the new row - rowid = cursor.lastrowid - new_row = ( - await db.execute( - "SELECT * FROM [{table}] WHERE rowid = ?".format( - table=escape_sqlite(table_name) - ), - [rowid], - ) - ).first() - return Response.json( - { - "inserted": [dict(new_row)], - }, - status=201, - ) + else: + table.insert_all(rows) + + rows = await db.execute_write_fn(insert_rows) + result = {"ok": True} + if should_return: + result["inserted"] = rows + return Response.json(result, status=201) diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index 56156568..649a3dcd 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -213,6 +213,8 @@ These can be passed to ``datasette serve`` using ``datasette serve --setting nam (default=100) max_returned_rows Maximum rows that can be returned from a table or custom query (default=1000) + max_insert_rows Maximum rows that can be inserted at a time using + the bulk insert API (default=1000) num_sql_threads Number of threads in the thread pool for executing SQLite queries (default=3) sql_time_limit_ms Time limit for a SQL query in milliseconds diff --git a/docs/json_api.rst b/docs/json_api.rst index 4a7961f2..01558c23 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -465,11 +465,13 @@ Datasette provides a write API for JSON data. This is a POST-only API that requi .. _TableInsertView: -Inserting a single row -~~~~~~~~~~~~~~~~~~~~~~ +Inserting rows +~~~~~~~~~~~~~~ This requires the :ref:`permissions_insert_row` permission. +A single row can be inserted using the ``"row"`` key: + :: POST //
/-/insert @@ -495,3 +497,45 @@ If successful, this will return a ``201`` status code and the newly inserted row } ] } + +To insert multiple rows at a time, use the same API method but send a list of dictionaries as the ``"rows"`` key: + +:: + + POST //
/-/insert + Content-Type: application/json + Authorization: Bearer dstok_ + { + "rows": [ + { + "column1": "value1", + "column2": "value2" + }, + { + "column1": "value3", + "column2": "value4" + } + ] + } + +If successful, this will return a ``201`` status code and an empty ``{}`` response body. + +To return the newly inserted rows, add the ``"return_rows": true`` key to the request body: + +.. code-block:: json + + { + "rows": [ + { + "column1": "value1", + "column2": "value2" + }, + { + "column1": "value3", + "column2": "value4" + } + ], + "return_rows": true + } + +This will return the same ``"inserted"`` key as the single row example above. There is a small performance penalty for using this option. diff --git a/docs/settings.rst b/docs/settings.rst index a990c78c..b86b18bd 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -96,6 +96,17 @@ You can increase or decrease this limit like so:: datasette mydatabase.db --setting max_returned_rows 2000 +.. _setting_max_insert_rows: + +max_insert_rows +~~~~~~~~~~~~~~~ + +Maximum rows that can be inserted at a time using the bulk insert API, see :ref:`TableInsertView`. Defaults to 100. + +You can increase or decrease this limit like so:: + + datasette mydatabase.db --setting max_insert_rows 1000 + .. _setting_num_sql_threads: num_sql_threads diff --git a/tests/test_api.py b/tests/test_api.py index fc171421..ebd675b9 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -804,6 +804,7 @@ def test_settings_json(app_client): "facet_suggest_time_limit_ms": 50, "facet_time_limit_ms": 200, "max_returned_rows": 100, + "max_insert_rows": 100, "sql_time_limit_ms": 200, "allow_download": True, "allow_signed_tokens": True, diff --git a/tests/test_api_write.py b/tests/test_api_write.py index e8222e43..4a5a58aa 100644 --- a/tests/test_api_write.py +++ b/tests/test_api_write.py @@ -18,11 +18,7 @@ def ds_write(tmp_path_factory): @pytest.mark.asyncio async def test_write_row(ds_write): - token = "dstok_{}".format( - ds_write.sign( - {"a": "root", "token": "dstok", "t": int(time.time())}, namespace="token" - ) - ) + token = write_token(ds_write) response = await ds_write.client.post( "/data/docs/-/insert", json={"row": {"title": "Test", "score": 1.0}}, @@ -36,3 +32,165 @@ async def test_write_row(ds_write): assert response.json()["inserted"] == [expected_row] rows = (await ds_write.get_database("data").execute("select * from docs")).rows assert dict(rows[0]) == expected_row + + +@pytest.mark.asyncio +@pytest.mark.parametrize("return_rows", (True, False)) +async def test_write_rows(ds_write, return_rows): + token = write_token(ds_write) + data = {"rows": [{"title": "Test {}".format(i), "score": 1.0} for i in range(20)]} + if return_rows: + data["return_rows"] = True + response = await ds_write.client.post( + "/data/docs/-/insert", + json=data, + headers={ + "Authorization": "Bearer {}".format(token), + "Content-Type": "application/json", + }, + ) + assert response.status_code == 201 + actual_rows = [ + dict(r) + for r in ( + await ds_write.get_database("data").execute("select * from docs") + ).rows + ] + assert len(actual_rows) == 20 + assert actual_rows == [ + {"id": i + 1, "title": "Test {}".format(i), "score": 1.0} for i in range(20) + ] + assert response.json()["ok"] is True + if return_rows: + assert response.json()["inserted"] == actual_rows + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "path,input,special_case,expected_status,expected_errors", + ( + ( + "/data2/docs/-/insert", + {}, + None, + 404, + ["Database not found: data2"], + ), + ( + "/data/docs2/-/insert", + {}, + None, + 404, + ["Table not found: docs2"], + ), + ( + "/data/docs/-/insert", + {"rows": [{"title": "Test"} for i in range(10)]}, + "bad_token", + 403, + ["Permission denied"], + ), + ( + "/data/docs/-/insert", + {}, + "invalid_json", + 400, + [ + "Invalid JSON: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)" + ], + ), + ( + "/data/docs/-/insert", + {}, + "invalid_content_type", + 400, + ["Invalid content-type, must be application/json"], + ), + ( + "/data/docs/-/insert", + [], + None, + 400, + ["JSON must be a dictionary"], + ), + ( + "/data/docs/-/insert", + {"row": "blah"}, + None, + 400, + ['"row" must be a dictionary'], + ), + ( + "/data/docs/-/insert", + {"blah": "blah"}, + None, + 400, + ['JSON must have one or other of "row" or "rows"'], + ), + ( + "/data/docs/-/insert", + {"rows": "blah"}, + None, + 400, + ['"rows" must be a list'], + ), + ( + "/data/docs/-/insert", + {"rows": ["blah"]}, + None, + 400, + ['"rows" must be a list of dictionaries'], + ), + ( + "/data/docs/-/insert", + {"rows": [{"title": "Test"} for i in range(101)]}, + None, + 400, + ["Too many rows, maximum allowed is 100"], + ), + # Validate columns of each row + ( + "/data/docs/-/insert", + {"rows": [{"title": "Test", "bad": 1, "worse": 2} for i in range(2)]}, + None, + 400, + [ + "Row 0 has invalid columns: bad, worse", + "Row 1 has invalid columns: bad, worse", + ], + ), + ), +) +async def test_write_row_errors( + ds_write, path, input, special_case, expected_status, expected_errors +): + token = write_token(ds_write) + if special_case == "bad_token": + token += "bad" + kwargs = dict( + json=input, + headers={ + "Authorization": "Bearer {}".format(token), + "Content-Type": "text/plain" + if special_case == "invalid_content_type" + else "application/json", + }, + ) + if special_case == "invalid_json": + del kwargs["json"] + kwargs["content"] = "{bad json" + response = await ds_write.client.post( + path, + **kwargs, + ) + assert response.status_code == expected_status + assert response.json()["ok"] is False + assert response.json()["errors"] == expected_errors + + +def write_token(ds): + return "dstok_{}".format( + ds.sign( + {"a": "root", "token": "dstok", "t": int(time.time())}, namespace="token" + ) + ) From f6bf2d8045cc239fe34357342bff1440561c8909 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 29 Oct 2022 23:20:11 -0700 Subject: [PATCH 0514/1250] Initial prototype of API explorer at /-/api, refs #1871 --- datasette/app.py | 5 ++ datasette/templates/api_explorer.html | 73 +++++++++++++++++++++++++++ datasette/views/special.py | 8 +++ tests/test_docs.py | 2 +- 4 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 datasette/templates/api_explorer.html diff --git a/datasette/app.py b/datasette/app.py index f80d3792..c3d802a4 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -33,6 +33,7 @@ from .views.special import ( JsonDataView, PatternPortfolioView, AuthTokenView, + ApiExplorerView, CreateTokenView, LogoutView, AllowDebugView, @@ -1235,6 +1236,10 @@ class Datasette: CreateTokenView.as_view(self), r"/-/create-token$", ) + add_route( + ApiExplorerView.as_view(self), + r"/-/api$", + ) add_route( LogoutView.as_view(self), r"/-/logout$", diff --git a/datasette/templates/api_explorer.html b/datasette/templates/api_explorer.html new file mode 100644 index 00000000..034bee60 --- /dev/null +++ b/datasette/templates/api_explorer.html @@ -0,0 +1,73 @@ +{% extends "base.html" %} + +{% block title %}API Explorer{% endblock %} + +{% block content %} + +

API Explorer

+ +

Use this tool to try out the Datasette write API.

+ +{% if errors %} + {% for error in errors %} +

{{ error }}

+ {% endfor %} +{% endif %} + + +
+ + +
+
+ + +
+
+ +
+

+ + + + +{% endblock %} diff --git a/datasette/views/special.py b/datasette/views/special.py index b754a2f0..9922a621 100644 --- a/datasette/views/special.py +++ b/datasette/views/special.py @@ -235,3 +235,11 @@ class CreateTokenView(BaseView): "token_bits": token_bits, }, ) + + +class ApiExplorerView(BaseView): + name = "api_explorer" + has_json_alternate = False + + async def get(self, request): + return await self.render(["api_explorer.html"], request) diff --git a/tests/test_docs.py b/tests/test_docs.py index cd5a6c13..e9b813fe 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -62,7 +62,7 @@ def documented_views(): if first_word.endswith("View"): view_labels.add(first_word) # We deliberately don't document these: - view_labels.update(("PatternPortfolioView", "AuthTokenView")) + view_labels.update(("PatternPortfolioView", "AuthTokenView", "ApiExplorerView")) return view_labels From 9eb9ffae3ddd4e8ff0b713bf6fd6a0afed3368d7 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 30 Oct 2022 13:09:55 -0700 Subject: [PATCH 0515/1250] Drop API token requirement from API explorer, refs #1871 --- datasette/default_permissions.py | 9 +++++++++ datasette/templates/api_explorer.html | 13 ++++--------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/datasette/default_permissions.py b/datasette/default_permissions.py index 87684e2a..151ba2b5 100644 --- a/datasette/default_permissions.py +++ b/datasette/default_permissions.py @@ -131,3 +131,12 @@ def register_commands(cli): if debug: click.echo("\nDecoded:\n") click.echo(json.dumps(ds.unsign(token, namespace="token"), indent=2)) + + +@hookimpl +def skip_csrf(scope): + # Skip CSRF check for requests with content-type: application/json + if scope["type"] == "http": + headers = scope.get("headers") or {} + if dict(headers).get(b"content-type") == b"application/json": + return True diff --git a/datasette/templates/api_explorer.html b/datasette/templates/api_explorer.html index 034bee60..01b182d8 100644 --- a/datasette/templates/api_explorer.html +++ b/datasette/templates/api_explorer.html @@ -15,16 +15,13 @@ {% endif %}
-
- - -
- +
-
- +
+ +

@@ -46,7 +43,6 @@ form.addEventListener("submit", (ev) => { var formData = new FormData(form); var json = formData.get('json'); var path = formData.get('path'); - var token = formData.get('token'); // Validate JSON try { var data = JSON.parse(json); @@ -60,7 +56,6 @@ form.addEventListener("submit", (ev) => { body: json, headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` } }).then(r => r.json()).then(r => { alert(JSON.stringify(r, null, 2)); From fedbfcc36873366143195d8fe124e1859bf88346 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 30 Oct 2022 14:49:07 -0700 Subject: [PATCH 0516/1250] Neater display of output and errors in API explorer, refs #1871 --- datasette/templates/api_explorer.html | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/datasette/templates/api_explorer.html b/datasette/templates/api_explorer.html index 01b182d8..38fdb7bc 100644 --- a/datasette/templates/api_explorer.html +++ b/datasette/templates/api_explorer.html @@ -26,6 +26,12 @@

+ + """.format( escape(ex.sql) ) diff --git a/tests/test_api.py b/tests/test_api.py index ad74d16e..4027a7a5 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -662,7 +662,11 @@ def test_sql_time_limit(app_client_shorter_time_limit): "

SQL query took too long. The time limit is controlled by the\n" 'sql_time_limit_ms\n' "configuration option.

\n" - "
select sleep(0.5)
" + '\n' + "" ), "status": 400, "title": "SQL Interrupted", diff --git a/tests/test_html.py b/tests/test_html.py index 4b394199..7cfe9d90 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -172,7 +172,7 @@ def test_sql_time_limit(app_client_shorter_time_limit): """ sql_time_limit_ms """.strip(), - "
select sleep(0.5)
", + '', ] for expected_html_fragment in expected_html_fragments: assert expected_html_fragment in response.text From 93a02281dad2f23da84210f6ae9c63777ad8af5e Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 1 Nov 2022 10:22:26 -0700 Subject: [PATCH 0521/1250] Show interrupted query in resizing textarea, closes #1876 --- datasette/views/base.py | 6 +++++- tests/test_api.py | 6 +++++- tests/test_html.py | 2 +- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/datasette/views/base.py b/datasette/views/base.py index 67aa3a42..6b01fdd2 100644 --- a/datasette/views/base.py +++ b/datasette/views/base.py @@ -378,7 +378,11 @@ class DataView(BaseView):

SQL query took too long. The time limit is controlled by the sql_time_limit_ms configuration option.

-
{}
+ + """.format( escape(ex.sql) ) diff --git a/tests/test_api.py b/tests/test_api.py index ebd675b9..de0223e2 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -662,7 +662,11 @@ def test_sql_time_limit(app_client_shorter_time_limit): "

SQL query took too long. The time limit is controlled by the\n" 'sql_time_limit_ms\n' "configuration option.

\n" - "
select sleep(0.5)
" + '\n' + "" ), "status": 400, "title": "SQL Interrupted", diff --git a/tests/test_html.py b/tests/test_html.py index 4b394199..7cfe9d90 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -172,7 +172,7 @@ def test_sql_time_limit(app_client_shorter_time_limit): """ sql_time_limit_ms """.strip(), - "
select sleep(0.5)
", + '', ] for expected_html_fragment in expected_html_fragments: assert expected_html_fragment in response.text From 9bec7c38eb93cde5afb16df9bdd96aea2a5b0459 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 1 Nov 2022 11:07:59 -0700 Subject: [PATCH 0522/1250] ignore and replace options for bulk inserts, refs #1873 Also removed the rule that you cannot include primary keys in the rows you insert. And added validation that catches invalid parameters in the incoming JSON. And renamed "inserted" to "rows" in the returned JSON for return_rows: true --- datasette/views/table.py | 41 ++++++++++++++------ docs/json_api.rst | 4 +- tests/test_api_write.py | 83 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 111 insertions(+), 17 deletions(-) diff --git a/datasette/views/table.py b/datasette/views/table.py index 1e3d566e..7692a4e3 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -1107,6 +1107,7 @@ class TableInsertView(BaseView): if not isinstance(data, dict): return _errors(["JSON must be a dictionary"]) keys = data.keys() + # keys must contain "row" or "rows" if "row" not in keys and "rows" not in keys: return _errors(['JSON must have one or other of "row" or "rows"']) @@ -1126,19 +1127,31 @@ class TableInsertView(BaseView): for row in rows: if not isinstance(row, dict): return _errors(['"rows" must be a list of dictionaries']) + # Does this exceed max_insert_rows? max_insert_rows = self.ds.setting("max_insert_rows") if len(rows) > max_insert_rows: return _errors( ["Too many rows, maximum allowed is {}".format(max_insert_rows)] ) + + # Validate other parameters + extras = { + key: value for key, value in data.items() if key not in ("row", "rows") + } + valid_extras = {"return_rows", "ignore", "replace"} + invalid_extras = extras.keys() - valid_extras + if invalid_extras: + return _errors( + ['Invalid parameter: "{}"'.format('", "'.join(sorted(invalid_extras)))] + ) + if extras.get("ignore") and extras.get("replace"): + return _errors(['Cannot use "ignore" and "replace" at the same time']) + # Validate columns of each row - columns = await db.table_columns(table_name) - # TODO: There are cases where pks are OK, if not using auto-incrementing pk - pks = await db.primary_keys(table_name) - allowed_columns = set(columns) - set(pks) + columns = set(await db.table_columns(table_name)) for i, row in enumerate(rows): - invalid_columns = set(row.keys()) - allowed_columns + invalid_columns = set(row.keys()) - columns if invalid_columns: errors.append( "Row {} has invalid columns: {}".format( @@ -1147,8 +1160,7 @@ class TableInsertView(BaseView): ) if errors: return _errors(errors) - extra = {key: data[key] for key in data if key not in ("rows", "row")} - return rows, errors, extra + return rows, errors, extras async def post(self, request): database_route = tilde_decode(request.url_vars["database"]) @@ -1168,18 +1180,23 @@ class TableInsertView(BaseView): request.actor, "insert-row", resource=(database_name, table_name) ): return _error(["Permission denied"], 403) - rows, errors, extra = await self._validate_data(request, db, table_name) + rows, errors, extras = await self._validate_data(request, db, table_name) if errors: return _error(errors, 400) - should_return = bool(extra.get("return_rows", False)) + ignore = extras.get("ignore") + replace = extras.get("replace") + + should_return = bool(extras.get("return_rows", False)) # Insert rows def insert_rows(conn): table = sqlite_utils.Database(conn)[table_name] if should_return: rowids = [] for row in rows: - rowids.append(table.insert(row).last_rowid) + rowids.append( + table.insert(row, ignore=ignore, replace=replace).last_rowid + ) return list( table.rows_where( "rowid in ({})".format(",".join("?" for _ in rowids)), @@ -1187,12 +1204,12 @@ class TableInsertView(BaseView): ) ) else: - table.insert_all(rows) + table.insert_all(rows, ignore=ignore, replace=replace) rows = await db.execute_write_fn(insert_rows) result = {"ok": True} if should_return: - result["inserted"] = rows + result["rows"] = rows return Response.json(result, status=201) diff --git a/docs/json_api.rst b/docs/json_api.rst index da4500ab..34c13211 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -489,7 +489,7 @@ If successful, this will return a ``201`` status code and the newly inserted row .. code-block:: json { - "inserted": [ + "rows": [ { "id": 1, "column1": "value1", @@ -538,7 +538,7 @@ To return the newly inserted rows, add the ``"return_rows": true`` key to the re "return_rows": true } -This will return the same ``"inserted"`` key as the single row example above. There is a small performance penalty for using this option. +This will return the same ``"rows"`` key as the single row example above. There is a small performance penalty for using this option. .. _RowDeleteView: diff --git a/tests/test_api_write.py b/tests/test_api_write.py index 1cfba104..d0b0f324 100644 --- a/tests/test_api_write.py +++ b/tests/test_api_write.py @@ -37,7 +37,7 @@ async def test_write_row(ds_write): ) expected_row = {"id": 1, "title": "Test", "score": 1.0} assert response.status_code == 201 - assert response.json()["inserted"] == [expected_row] + assert response.json()["rows"] == [expected_row] rows = (await ds_write.get_database("data").execute("select * from docs")).rows assert dict(rows[0]) == expected_row @@ -70,7 +70,7 @@ async def test_write_rows(ds_write, return_rows): ] assert response.json()["ok"] is True if return_rows: - assert response.json()["inserted"] == actual_rows + assert response.json()["rows"] == actual_rows @pytest.mark.asyncio @@ -156,6 +156,27 @@ async def test_write_rows(ds_write, return_rows): 400, ["Too many rows, maximum allowed is 100"], ), + ( + "/data/docs/-/insert", + {"rows": [{"title": "Test"}], "ignore": True, "replace": True}, + None, + 400, + ['Cannot use "ignore" and "replace" at the same time'], + ), + ( + "/data/docs/-/insert", + {"rows": [{"title": "Test"}], "invalid_param": True}, + None, + 400, + ['Invalid parameter: "invalid_param"'], + ), + ( + "/data/docs/-/insert", + {"rows": [{"title": "Test"}], "one": True, "two": True}, + None, + 400, + ['Invalid parameter: "one", "two"'], + ), # Validate columns of each row ( "/data/docs/-/insert", @@ -196,6 +217,62 @@ async def test_write_row_errors( assert response.json()["errors"] == expected_errors +@pytest.mark.asyncio +@pytest.mark.parametrize( + "ignore,replace,expected_rows", + ( + ( + True, + False, + [ + {"id": 1, "title": "Exists", "score": None}, + ], + ), + ( + False, + True, + [ + {"id": 1, "title": "One", "score": None}, + ], + ), + ), +) +@pytest.mark.parametrize("should_return", (True, False)) +async def test_insert_ignore_replace( + ds_write, ignore, replace, expected_rows, should_return +): + await ds_write.get_database("data").execute_write( + "insert into docs (id, title) values (1, 'Exists')" + ) + token = write_token(ds_write) + data = {"rows": [{"id": 1, "title": "One"}]} + if ignore: + data["ignore"] = True + if replace: + data["replace"] = True + if should_return: + data["return_rows"] = True + response = await ds_write.client.post( + "/data/docs/-/insert", + json=data, + headers={ + "Authorization": "Bearer {}".format(token), + "Content-Type": "application/json", + }, + ) + assert response.status_code == 201 + actual_rows = [ + dict(r) + for r in ( + await ds_write.get_database("data").execute("select * from docs") + ).rows + ] + assert actual_rows == expected_rows + assert response.json()["ok"] is True + if should_return: + assert response.json()["rows"] == expected_rows + + @pytest.mark.asyncio @pytest.mark.parametrize("scenario", ("no_token", "no_perm", "bad_table", "has_perm")) async def test_delete_row(ds_write, scenario): @@ -217,7 +294,7 @@ async def test_delete_row(ds_write, scenario): }, ) assert insert_response.status_code == 201 - pk = insert_response.json()["inserted"][0]["id"] + pk = insert_response.json()["rows"][0]["id"] path = "/data/{}/{}/-/delete".format( "docs" if scenario != "bad_table" else "bad_table", pk From 497290beaf32e6b779f9683ef15f1c5bc142a41a Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 1 Nov 2022 12:59:17 -0700 Subject: [PATCH 0523/1250] Handle database errors in /-/insert, refs #1866, #1873 Also improved API explorer to show HTTP status of response, refs #1871 --- datasette/templates/api_explorer.html | 14 +++++++++----- datasette/views/table.py | 5 ++++- tests/test_api_write.py | 11 +++++++++++ 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/datasette/templates/api_explorer.html b/datasette/templates/api_explorer.html index 38fdb7bc..93bacde3 100644 --- a/datasette/templates/api_explorer.html +++ b/datasette/templates/api_explorer.html @@ -27,7 +27,8 @@ @@ -64,12 +65,15 @@ form.addEventListener("submit", (ev) => { headers: { 'Content-Type': 'application/json', } - }).then(r => r.json()).then(r => { + }).then(r => { + document.getElementById('response-status').textContent = r.status; + return r.json(); + }).then(data => { var errorList = output.querySelector('.errors'); - if (r.errors) { + if (data.errors) { errorList.style.display = 'block'; errorList.innerHTML = ''; - r.errors.forEach(error => { + data.errors.forEach(error => { var li = document.createElement('li'); li.textContent = error; errorList.appendChild(li); @@ -77,7 +81,7 @@ form.addEventListener("submit", (ev) => { } else { errorList.style.display = 'none'; } - output.querySelector('pre').innerText = JSON.stringify(r, null, 2); + output.querySelector('pre').innerText = JSON.stringify(data, null, 2); output.style.display = 'block'; }).catch(err => { alert("Error: " + err); diff --git a/datasette/views/table.py b/datasette/views/table.py index 7692a4e3..61227206 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -1206,7 +1206,10 @@ class TableInsertView(BaseView): else: table.insert_all(rows, ignore=ignore, replace=replace) - rows = await db.execute_write_fn(insert_rows) + try: + rows = await db.execute_write_fn(insert_rows) + except Exception as e: + return _error([str(e)]) result = {"ok": True} if should_return: result["rows"] = rows diff --git a/tests/test_api_write.py b/tests/test_api_write.py index d0b0f324..0b567f48 100644 --- a/tests/test_api_write.py +++ b/tests/test_api_write.py @@ -156,6 +156,13 @@ async def test_write_rows(ds_write, return_rows): 400, ["Too many rows, maximum allowed is 100"], ), + ( + "/data/docs/-/insert", + {"rows": [{"id": 1, "title": "Test"}]}, + "duplicate_id", + 400, + ["UNIQUE constraint failed: docs.id"], + ), ( "/data/docs/-/insert", {"rows": [{"title": "Test"}], "ignore": True, "replace": True}, @@ -194,6 +201,10 @@ async def test_write_row_errors( ds_write, path, input, special_case, expected_status, expected_errors ): token = write_token(ds_write) + if special_case == "duplicate_id": + await ds_write.get_database("data").execute_write( + "insert into docs (id) values (1)" + ) if special_case == "bad_token": token += "bad" kwargs = dict( From 0b166befc0096fca30d71e19608a928d59c331a4 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 1 Nov 2022 17:31:22 -0700 Subject: [PATCH 0524/1250] API explorer can now do GET, has JSON syntax highlighting Refs #1871 --- .../static/json-format-highlight-1.0.1.js | 43 +++++++++++ datasette/templates/api_explorer.html | 77 +++++++++++++++---- 2 files changed, 103 insertions(+), 17 deletions(-) create mode 100644 datasette/static/json-format-highlight-1.0.1.js diff --git a/datasette/static/json-format-highlight-1.0.1.js b/datasette/static/json-format-highlight-1.0.1.js new file mode 100644 index 00000000..e87c76e1 --- /dev/null +++ b/datasette/static/json-format-highlight-1.0.1.js @@ -0,0 +1,43 @@ +/* +https://github.com/luyilin/json-format-highlight +From https://unpkg.com/json-format-highlight@1.0.1/dist/json-format-highlight.js +MIT Licensed +*/ +(function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : + typeof define === 'function' && define.amd ? define(factory) : + (global.jsonFormatHighlight = factory()); +}(this, (function () { 'use strict'; + +var defaultColors = { + keyColor: 'dimgray', + numberColor: 'lightskyblue', + stringColor: 'lightcoral', + trueColor: 'lightseagreen', + falseColor: '#f66578', + nullColor: 'cornflowerblue' +}; + +function index (json, colorOptions) { + if ( colorOptions === void 0 ) colorOptions = {}; + + if (!json) { return; } + if (typeof json !== 'string') { + json = JSON.stringify(json, null, 2); + } + var colors = Object.assign({}, defaultColors, colorOptions); + json = json.replace(/&/g, '&').replace(//g, '>'); + return json.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+]?\d+)?)/g, function (match) { + var color = colors.numberColor; + if (/^"/.test(match)) { + color = /:$/.test(match) ? colors.keyColor : colors.stringColor; + } else { + color = /true/.test(match) ? colors.trueColor : /false/.test(match) ? colors.falseColor : /null/.test(match) ? colors.nullColor : color; + } + return ("" + match + ""); + }); +} + +return index; + +}))); diff --git a/datasette/templates/api_explorer.html b/datasette/templates/api_explorer.html index 93bacde3..de5337e3 100644 --- a/datasette/templates/api_explorer.html +++ b/datasette/templates/api_explorer.html @@ -2,6 +2,10 @@ {% block title %}API Explorer{% endblock %} +{% block extra_head %} + +{% endblock %} + {% block content %}

API Explorer

@@ -14,17 +18,30 @@ {% endfor %} {% endif %} -
-
- - -
-
- - -
-

- +
+ GET +
+
+ + + +
+ +
+
+ POST +
+
+ + +
+
+ + +
+

+ +
{% else %} - {% if not canned_write and not error %} + {% if not canned_query_write and not error %}

0 results

{% endif %} {% endif %} diff --git a/datasette/views/database.py b/datasette/views/database.py index 0770a380..658c35e6 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -1,4 +1,3 @@ -from asyncinject import Registry from dataclasses import dataclass, field from typing import Callable from urllib.parse import parse_qsl, urlencode @@ -33,7 +32,7 @@ from datasette.utils import ( from datasette.utils.asgi import AsgiFileDownload, NotFound, Response, Forbidden from datasette.plugins import pm -from .base import BaseView, DatasetteError, DataView, View, _error, stream_csv +from .base import BaseView, DatasetteError, View, _error, stream_csv class DatabaseView(View): @@ -57,7 +56,7 @@ class DatabaseView(View): sql = (request.args.get("sql") or "").strip() if sql: - return await query_view(request, datasette) + return await QueryView()(request, datasette) if format_ not in ("html", "json"): raise NotFound("Invalid format: {}".format(format_)) @@ -65,10 +64,6 @@ class DatabaseView(View): metadata = (datasette.metadata("databases") or {}).get(database, {}) datasette.update_with_inherited_metadata(metadata) - table_counts = await db.table_counts(5) - hidden_table_names = set(await db.hidden_table_names()) - all_foreign_keys = await db.get_all_foreign_keys() - sql_views = [] for view_name in await db.view_names(): view_visible, view_private = await datasette.check_visibility( @@ -196,8 +191,13 @@ class QueryContext: # urls: dict = field( # metadata={"help": "Object containing URL helpers like `database()`"} # ) - canned_write: bool = field( - metadata={"help": "Boolean indicating if this canned query allows writes"} + canned_query_write: bool = field( + metadata={ + "help": "Boolean indicating if this is a canned query that allows writes" + } + ) + metadata: dict = field( + metadata={"help": "Metadata about the database or the canned query"} ) db_is_immutable: bool = field( metadata={"help": "Boolean indicating if this database is immutable"} @@ -232,7 +232,6 @@ class QueryContext: show_hide_hidden: str = field( metadata={"help": "Hidden input field for the _show_sql parameter"} ) - metadata: dict = field(metadata={"help": "Metadata about the query/database"}) database_color: Callable = field( metadata={"help": "Function that returns a color for a given database name"} ) @@ -242,6 +241,12 @@ class QueryContext: alternate_url_json: str = field( metadata={"help": "URL for alternate JSON version of this page"} ) + # TODO: refactor this to somewhere else, probably ds.render_template() + select_templates: list = field( + metadata={ + "help": "List of templates that were considered for rendering this page" + } + ) async def get_tables(datasette, request, db): @@ -320,287 +325,105 @@ async def database_download(request, datasette): ) -async def query_view( - request, - datasette, - # canned_query=None, - # _size=None, - # named_parameters=None, - # write=False, -): - db = await datasette.resolve_database(request) - database = db.name - # Flattened because of ?sql=&name1=value1&name2=value2 feature - params = {key: request.args.get(key) for key in request.args} - sql = None - if "sql" in params: - sql = params.pop("sql") - if "_shape" in params: - params.pop("_shape") +class QueryView(View): + async def post(self, request, datasette): + from datasette.app import TableNotFound - # extras come from original request.args to avoid being flattened - extras = request.args.getlist("_extra") + db = await datasette.resolve_database(request) - # TODO: Behave differently for canned query here: - await datasette.ensure_permissions(request.actor, [("execute-sql", database)]) - - _, private = await datasette.check_visibility( - request.actor, - permissions=[ - ("view-database", database), - "view-instance", - ], - ) - - extra_args = {} - if params.get("_timelimit"): - extra_args["custom_time_limit"] = int(params["_timelimit"]) - - format_ = request.url_vars.get("format") or "html" - query_error = None - try: - validate_sql_select(sql) - results = await datasette.execute( - database, sql, params, truncate=True, **extra_args - ) - columns = results.columns - rows = results.rows - except QueryInterrupted as ex: - raise DatasetteError( - textwrap.dedent( - """ -

SQL query took too long. The time limit is controlled by the - sql_time_limit_ms - configuration option.

- - - """.format( - markupsafe.escape(ex.sql) - ) - ).strip(), - title="SQL Interrupted", - status=400, - message_is_html=True, - ) - except sqlite3.DatabaseError as ex: - query_error = str(ex) - results = None - rows = [] - columns = [] - except (sqlite3.OperationalError, InvalidSql) as ex: - raise DatasetteError(str(ex), title="Invalid SQL", status=400) - except sqlite3.OperationalError as ex: - raise DatasetteError(str(ex)) - except DatasetteError: - raise - - # Handle formats from plugins - if format_ == "csv": - - async def fetch_data_for_csv(request, _next=None): - results = await db.execute(sql, params, truncate=True) - data = {"rows": results.rows, "columns": results.columns} - return data, None, None - - return await stream_csv(datasette, fetch_data_for_csv, request, db.name) - elif format_ in datasette.renderers.keys(): - # Dispatch request to the correct output format renderer - # (CSV is not handled here due to streaming) - result = call_with_supported_arguments( - datasette.renderers[format_][0], - datasette=datasette, - columns=columns, - rows=rows, - sql=sql, - query_name=None, - database=database, - table=None, - request=request, - view_name="table", - truncated=results.truncated if results else False, - error=query_error, - # These will be deprecated in Datasette 1.0: - args=request.args, - data={"rows": rows, "columns": columns}, - ) - if asyncio.iscoroutine(result): - result = await result - if result is None: - raise NotFound("No data") - if isinstance(result, dict): - r = Response( - body=result.get("body"), - status=result.get("status_code") or 200, - content_type=result.get("content_type", "text/plain"), - headers=result.get("headers"), + # We must be a canned query + table_found = False + try: + await datasette.resolve_table(request) + table_found = True + except TableNotFound as table_not_found: + canned_query = await datasette.get_canned_query( + table_not_found.database_name, table_not_found.table, request.actor ) - elif isinstance(result, Response): - r = result - # if status_code is not None: - # # Over-ride the status code - # r.status = status_code - else: - assert False, f"{result} should be dict or Response" - elif format_ == "html": - headers = {} - templates = [f"query-{to_css_class(database)}.html", "query.html"] - template = datasette.jinja_env.select_template(templates) - alternate_url_json = datasette.absolute_url( - request, - datasette.urls.path(path_with_format(request=request, format="json")), - ) - data = {} - headers.update( - { - "Link": '{}; rel="alternate"; type="application/json+datasette"'.format( - alternate_url_json - ) - } - ) - metadata = (datasette.metadata("databases") or {}).get(database, {}) - datasette.update_with_inherited_metadata(metadata) + if canned_query is None: + raise + if table_found: + # That should not have happened + raise DatasetteError("Unexpected table found on POST", status=404) - renderers = {} - for key, (_, can_render) in datasette.renderers.items(): - it_can_render = call_with_supported_arguments( - can_render, - datasette=datasette, - columns=data.get("columns") or [], - rows=data.get("rows") or [], - sql=data.get("query", {}).get("sql", None), - query_name=data.get("query_name"), - database=database, - table=data.get("table"), - request=request, - view_name="database", + # If database is immutable, return an error + if not db.is_mutable: + raise Forbidden("Database is immutable") + + # Process the POST + body = await request.post_body() + body = body.decode("utf-8").strip() + if body.startswith("{") and body.endswith("}"): + params = json.loads(body) + # But we want key=value strings + for key, value in params.items(): + params[key] = str(value) + else: + params = dict(parse_qsl(body, keep_blank_values=True)) + # Should we return JSON? + should_return_json = ( + request.headers.get("accept") == "application/json" + or request.args.get("_json") + or params.get("_json") + ) + params_for_query = MagicParameters(params, request, datasette) + ok = None + redirect_url = None + try: + cursor = await db.execute_write(canned_query["sql"], params_for_query) + message = canned_query.get( + "on_success_message" + ) or "Query executed, {} row{} affected".format( + cursor.rowcount, "" if cursor.rowcount == 1 else "s" + ) + message_type = datasette.INFO + redirect_url = canned_query.get("on_success_redirect") + ok = True + except Exception as ex: + message = canned_query.get("on_error_message") or str(ex) + message_type = datasette.ERROR + redirect_url = canned_query.get("on_error_redirect") + ok = False + if should_return_json: + return Response.json( + { + "ok": ok, + "message": message, + "redirect": redirect_url, + } ) - it_can_render = await await_me_maybe(it_can_render) - if it_can_render: - renderers[key] = datasette.urls.path( - path_with_format(request=request, format=key) - ) - - allow_execute_sql = await datasette.permission_allowed( - request.actor, "execute-sql", database - ) - - show_hide_hidden = "" - if metadata.get("hide_sql"): - if bool(params.get("_show_sql")): - show_hide_link = path_with_removed_args(request, {"_show_sql"}) - show_hide_text = "hide" - show_hide_hidden = '' - else: - show_hide_link = path_with_added_args(request, {"_show_sql": 1}) - show_hide_text = "show" else: - if bool(params.get("_hide_sql")): - show_hide_link = path_with_removed_args(request, {"_hide_sql"}) - show_hide_text = "show" - show_hide_hidden = '' - else: - show_hide_link = path_with_added_args(request, {"_hide_sql": 1}) - show_hide_text = "hide" - hide_sql = show_hide_text == "show" + datasette.add_message(request, message, message_type) + return Response.redirect(redirect_url or request.path) - # Extract any :named parameters - named_parameters = await derive_named_parameters( - datasette.get_database(database), sql - ) - named_parameter_values = { - named_parameter: params.get(named_parameter) or "" - for named_parameter in named_parameters - if not named_parameter.startswith("_") - } + async def get(self, request, datasette): + from datasette.app import TableNotFound - # Set to blank string if missing from params - for named_parameter in named_parameters: - if named_parameter not in params and not named_parameter.startswith("_"): - params[named_parameter] = "" - - r = Response.html( - await datasette.render_template( - template, - QueryContext( - database=database, - query={ - "sql": sql, - "params": params, - }, - canned_query=None, - private=private, - canned_write=False, - db_is_immutable=not db.is_mutable, - error=query_error, - hide_sql=hide_sql, - show_hide_link=datasette.urls.path(show_hide_link), - show_hide_text=show_hide_text, - editable=True, # TODO - allow_execute_sql=allow_execute_sql, - tables=await get_tables(datasette, request, db), - named_parameter_values=named_parameter_values, - edit_sql_url="todo", - display_rows=await display_rows( - datasette, database, request, rows, columns - ), - table_columns=await _table_columns(datasette, database) - if allow_execute_sql - else {}, - columns=columns, - renderers=renderers, - url_csv=datasette.urls.path( - path_with_format( - request=request, format="csv", extra_qs={"_size": "max"} - ) - ), - show_hide_hidden=markupsafe.Markup(show_hide_hidden), - metadata=metadata, - database_color=lambda _: "#ff0000", - alternate_url_json=alternate_url_json, - ), - request=request, - view_name="database", - ), - headers=headers, - ) - else: - assert False, "Invalid format: {}".format(format_) - if datasette.cors: - add_cors_headers(r.headers) - return r - - -class QueryView(DataView): - async def data( - self, - request, - sql, - editable=True, - canned_query=None, - metadata=None, - _size=None, - named_parameters=None, - write=False, - default_labels=None, - ): - db = await self.ds.resolve_database(request) + db = await datasette.resolve_database(request) database = db.name - params = {key: request.args.get(key) for key in request.args} - if "sql" in params: - params.pop("sql") - if "_shape" in params: - params.pop("_shape") + + # Are we a canned query? + canned_query = None + canned_query_write = False + if "table" in request.url_vars: + try: + await datasette.resolve_table(request) + except TableNotFound as table_not_found: + # Was this actually a canned query? + canned_query = await datasette.get_canned_query( + table_not_found.database_name, table_not_found.table, request.actor + ) + if canned_query is None: + raise + canned_query_write = bool(canned_query.get("write")) private = False if canned_query: # Respect canned query permissions - visible, private = await self.ds.check_visibility( + visible, private = await datasette.check_visibility( request.actor, permissions=[ - ("view-query", (database, canned_query)), + ("view-query", (database, canned_query["name"])), ("view-database", database), "view-instance", ], @@ -609,18 +432,32 @@ class QueryView(DataView): raise Forbidden("You do not have permission to view this query") else: - await self.ds.ensure_permissions(request.actor, [("execute-sql", database)]) + await datasette.ensure_permissions( + request.actor, [("execute-sql", database)] + ) + + # Flattened because of ?sql=&name1=value1&name2=value2 feature + params = {key: request.args.get(key) for key in request.args} + sql = None + + if canned_query: + sql = canned_query["sql"] + elif "sql" in params: + sql = params.pop("sql") # Extract any :named parameters - named_parameters = named_parameters or await derive_named_parameters( - self.ds.get_database(database), sql - ) + named_parameters = [] + if canned_query and canned_query.get("params"): + named_parameters = canned_query["params"] + if not named_parameters: + named_parameters = await derive_named_parameters( + datasette.get_database(database), sql + ) named_parameter_values = { named_parameter: params.get(named_parameter) or "" for named_parameter in named_parameters if not named_parameter.startswith("_") } - # Set to blank string if missing from params for named_parameter in named_parameters: if named_parameter not in params and not named_parameter.startswith("_"): @@ -629,212 +466,159 @@ class QueryView(DataView): extra_args = {} if params.get("_timelimit"): extra_args["custom_time_limit"] = int(params["_timelimit"]) - if _size: - extra_args["page_size"] = _size - templates = [f"query-{to_css_class(database)}.html", "query.html"] - if canned_query: - templates.insert( - 0, - f"query-{to_css_class(database)}-{to_css_class(canned_query)}.html", - ) + format_ = request.url_vars.get("format") or "html" query_error = None + results = None + rows = [] + columns = [] - # Execute query - as write or as read - if write: - if request.method == "POST": - # If database is immutable, return an error - if not db.is_mutable: - raise Forbidden("Database is immutable") - body = await request.post_body() - body = body.decode("utf-8").strip() - if body.startswith("{") and body.endswith("}"): - params = json.loads(body) - # But we want key=value strings - for key, value in params.items(): - params[key] = str(value) - else: - params = dict(parse_qsl(body, keep_blank_values=True)) - # Should we return JSON? - should_return_json = ( - request.headers.get("accept") == "application/json" - or request.args.get("_json") - or params.get("_json") - ) - if canned_query: - params_for_query = MagicParameters(params, request, self.ds) - else: - params_for_query = params - ok = None - try: - cursor = await self.ds.databases[database].execute_write( - sql, params_for_query - ) - message = metadata.get( - "on_success_message" - ) or "Query executed, {} row{} affected".format( - cursor.rowcount, "" if cursor.rowcount == 1 else "s" - ) - message_type = self.ds.INFO - redirect_url = metadata.get("on_success_redirect") - ok = True - except Exception as e: - message = metadata.get("on_error_message") or str(e) - message_type = self.ds.ERROR - redirect_url = metadata.get("on_error_redirect") - ok = False - if should_return_json: - return Response.json( - { - "ok": ok, - "message": message, - "redirect": redirect_url, - } - ) - else: - self.ds.add_message(request, message, message_type) - return self.redirect(request, redirect_url or request.path) - else: + params_for_query = params - async def extra_template(): - return { - "request": request, - "db_is_immutable": not db.is_mutable, - "path_with_added_args": path_with_added_args, - "path_with_removed_args": path_with_removed_args, - "named_parameter_values": named_parameter_values, - "canned_query": canned_query, - "success_message": request.args.get("_success") or "", - "canned_write": True, - } - - return ( - { - "database": database, - "rows": [], - "truncated": False, - "columns": [], - "query": {"sql": sql, "params": params}, - "private": private, - }, - extra_template, - templates, - ) - else: # Not a write - if canned_query: - params_for_query = MagicParameters(params, request, self.ds) - else: - params_for_query = params + if not canned_query_write: try: - results = await self.ds.execute( + if not canned_query: + # For regular queries we only allow SELECT, plus other rules + validate_sql_select(sql) + else: + # Canned queries can run magic parameters + params_for_query = MagicParameters(params, request, datasette) + results = await datasette.execute( database, sql, params_for_query, truncate=True, **extra_args ) - columns = [r[0] for r in results.description] - except sqlite3.DatabaseError as e: - query_error = e + columns = results.columns + rows = results.rows + except QueryInterrupted as ex: + raise DatasetteError( + textwrap.dedent( + """ +

SQL query took too long. The time limit is controlled by the + sql_time_limit_ms + configuration option.

+ + + """.format( + markupsafe.escape(ex.sql) + ) + ).strip(), + title="SQL Interrupted", + status=400, + message_is_html=True, + ) + except sqlite3.DatabaseError as ex: + query_error = str(ex) results = None + rows = [] columns = [] + except (sqlite3.OperationalError, InvalidSql) as ex: + raise DatasetteError(str(ex), title="Invalid SQL", status=400) + except sqlite3.OperationalError as ex: + raise DatasetteError(str(ex)) + except DatasetteError: + raise - allow_execute_sql = await self.ds.permission_allowed( - request.actor, "execute-sql", database - ) + # Handle formats from plugins + if format_ == "csv": - async def extra_template(): - display_rows = [] - truncate_cells = self.ds.setting("truncate_cells_html") - for row in results.rows if results else []: - display_row = [] - for column, value in zip(results.columns, row): - display_value = value - # Let the plugins have a go - # pylint: disable=no-member - plugin_display_value = None - for candidate in pm.hook.render_cell( - row=row, - value=value, - column=column, - table=None, - database=database, - datasette=self.ds, - request=request, - ): - candidate = await await_me_maybe(candidate) - if candidate is not None: - plugin_display_value = candidate - break - if plugin_display_value is not None: - display_value = plugin_display_value - else: - if value in ("", None): - display_value = markupsafe.Markup(" ") - elif is_url(str(display_value).strip()): - display_value = markupsafe.Markup( - '{truncated_url}'.format( - url=markupsafe.escape(value.strip()), - truncated_url=markupsafe.escape( - truncate_url(value.strip(), truncate_cells) - ), - ) - ) - elif isinstance(display_value, bytes): - blob_url = path_with_format( - request=request, - format="blob", - extra_qs={ - "_blob_column": column, - "_blob_hash": hashlib.sha256( - display_value - ).hexdigest(), - }, - ) - formatted = format_bytes(len(value)) - display_value = markupsafe.Markup( - '<Binary: {:,} byte{}>'.format( - blob_url, - ' title="{}"'.format(formatted) - if "bytes" not in formatted - else "", - len(value), - "" if len(value) == 1 else "s", - ) - ) - else: - display_value = str(value) - if truncate_cells and len(display_value) > truncate_cells: - display_value = ( - display_value[:truncate_cells] + "\u2026" - ) - display_row.append(display_value) - display_rows.append(display_row) + async def fetch_data_for_csv(request, _next=None): + results = await db.execute(sql, params, truncate=True) + data = {"rows": results.rows, "columns": results.columns} + return data, None, None - # Show 'Edit SQL' button only if: - # - User is allowed to execute SQL - # - SQL is an approved SELECT statement - # - No magic parameters, so no :_ in the SQL string - edit_sql_url = None - is_validated_sql = False - try: - validate_sql_select(sql) - is_validated_sql = True - except InvalidSql: - pass - if allow_execute_sql and is_validated_sql and ":_" not in sql: - edit_sql_url = ( - self.ds.urls.database(database) - + "?" - + urlencode( - { - **{ - "sql": sql, - }, - **named_parameter_values, - } - ) + return await stream_csv(datasette, fetch_data_for_csv, request, db.name) + elif format_ in datasette.renderers.keys(): + # Dispatch request to the correct output format renderer + # (CSV is not handled here due to streaming) + result = call_with_supported_arguments( + datasette.renderers[format_][0], + datasette=datasette, + columns=columns, + rows=rows, + sql=sql, + query_name=canned_query["name"] if canned_query else None, + database=database, + table=None, + request=request, + view_name="table", + truncated=results.truncated if results else False, + error=query_error, + # These will be deprecated in Datasette 1.0: + args=request.args, + data={"rows": rows, "columns": columns}, + ) + if asyncio.iscoroutine(result): + result = await result + if result is None: + raise NotFound("No data") + if isinstance(result, dict): + r = Response( + body=result.get("body"), + status=result.get("status_code") or 200, + content_type=result.get("content_type", "text/plain"), + headers=result.get("headers"), + ) + elif isinstance(result, Response): + r = result + # if status_code is not None: + # # Over-ride the status code + # r.status = status_code + else: + assert False, f"{result} should be dict or Response" + elif format_ == "html": + headers = {} + templates = [f"query-{to_css_class(database)}.html", "query.html"] + if canned_query: + templates.insert( + 0, + f"query-{to_css_class(database)}-{to_css_class(canned_query['name'])}.html", ) + template = datasette.jinja_env.select_template(templates) + alternate_url_json = datasette.absolute_url( + request, + datasette.urls.path(path_with_format(request=request, format="json")), + ) + data = {} + headers.update( + { + "Link": '{}; rel="alternate"; type="application/json+datasette"'.format( + alternate_url_json + ) + } + ) + metadata = (datasette.metadata("databases") or {}).get(database, {}) + datasette.update_with_inherited_metadata(metadata) + + renderers = {} + for key, (_, can_render) in datasette.renderers.items(): + it_can_render = call_with_supported_arguments( + can_render, + datasette=datasette, + columns=data.get("columns") or [], + rows=data.get("rows") or [], + sql=data.get("query", {}).get("sql", None), + query_name=data.get("query_name"), + database=database, + table=data.get("table"), + request=request, + view_name="database", + ) + it_can_render = await await_me_maybe(it_can_render) + if it_can_render: + renderers[key] = datasette.urls.path( + path_with_format(request=request, format=key) + ) + + allow_execute_sql = await datasette.permission_allowed( + request.actor, "execute-sql", database + ) + show_hide_hidden = "" - if metadata.get("hide_sql"): + if canned_query and canned_query.get("hide_sql"): if bool(params.get("_show_sql")): show_hide_link = path_with_removed_args(request, {"_show_sql"}) show_hide_text = "hide" @@ -855,42 +639,86 @@ class QueryView(DataView): show_hide_link = path_with_added_args(request, {"_hide_sql": 1}) show_hide_text = "hide" hide_sql = show_hide_text == "show" - return { - "display_rows": display_rows, - "custom_sql": True, - "named_parameter_values": named_parameter_values, - "editable": editable, - "canned_query": canned_query, - "edit_sql_url": edit_sql_url, - "metadata": metadata, - "settings": self.ds.settings_dict(), - "request": request, - "show_hide_link": self.ds.urls.path(show_hide_link), - "show_hide_text": show_hide_text, - "show_hide_hidden": markupsafe.Markup(show_hide_hidden), - "hide_sql": hide_sql, - "table_columns": await _table_columns(self.ds, database) - if allow_execute_sql - else {}, - } - return ( - { - "ok": not query_error, - "database": database, - "query_name": canned_query, - "rows": results.rows if results else [], - "truncated": results.truncated if results else False, - "columns": columns, - "query": {"sql": sql, "params": params}, - "error": str(query_error) if query_error else None, - "private": private, - "allow_execute_sql": allow_execute_sql, - }, - extra_template, - templates, - 400 if query_error else 200, - ) + # Show 'Edit SQL' button only if: + # - User is allowed to execute SQL + # - SQL is an approved SELECT statement + # - No magic parameters, so no :_ in the SQL string + edit_sql_url = None + is_validated_sql = False + try: + validate_sql_select(sql) + is_validated_sql = True + except InvalidSql: + pass + if allow_execute_sql and is_validated_sql and ":_" not in sql: + edit_sql_url = ( + datasette.urls.database(database) + + "?" + + urlencode( + { + **{ + "sql": sql, + }, + **named_parameter_values, + } + ) + ) + + r = Response.html( + await datasette.render_template( + template, + QueryContext( + database=database, + query={ + "sql": sql, + "params": params, + }, + canned_query=canned_query["name"] if canned_query else None, + private=private, + canned_query_write=canned_query_write, + db_is_immutable=not db.is_mutable, + error=query_error, + hide_sql=hide_sql, + show_hide_link=datasette.urls.path(show_hide_link), + show_hide_text=show_hide_text, + editable=not canned_query, + allow_execute_sql=allow_execute_sql, + tables=await get_tables(datasette, request, db), + named_parameter_values=named_parameter_values, + edit_sql_url=edit_sql_url, + display_rows=await display_rows( + datasette, database, request, rows, columns + ), + table_columns=await _table_columns(datasette, database) + if allow_execute_sql + else {}, + columns=columns, + renderers=renderers, + url_csv=datasette.urls.path( + path_with_format( + request=request, format="csv", extra_qs={"_size": "max"} + ) + ), + show_hide_hidden=markupsafe.Markup(show_hide_hidden), + metadata=canned_query or metadata, + database_color=lambda _: "#ff0000", + alternate_url_json=alternate_url_json, + select_templates=[ + f"{'*' if template_name == template.name else ''}{template_name}" + for template_name in templates + ], + ), + request=request, + view_name="database", + ), + headers=headers, + ) + else: + assert False, "Invalid format: {}".format(format_) + if datasette.cors: + add_cors_headers(r.headers) + return r class MagicParameters(dict): diff --git a/datasette/views/table.py b/datasette/views/table.py index 77acfd95..28264e92 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -9,7 +9,6 @@ import markupsafe from datasette.plugins import pm from datasette.database import QueryInterrupted from datasette import tracer -from datasette.renderer import json_renderer from datasette.utils import ( add_cors_headers, await_me_maybe, @@ -21,7 +20,6 @@ from datasette.utils import ( tilde_encode, escape_sqlite, filters_should_redirect, - format_bytes, is_url, path_from_row_pks, path_with_added_args, @@ -38,7 +36,7 @@ from datasette.utils import ( from datasette.utils.asgi import BadRequest, Forbidden, NotFound, Response from datasette.filters import Filters import sqlite_utils -from .base import BaseView, DataView, DatasetteError, ureg, _error, stream_csv +from .base import BaseView, DatasetteError, ureg, _error, stream_csv from .database import QueryView LINK_WITH_LABEL = ( @@ -698,57 +696,6 @@ async def table_view(datasette, request): return response -class CannedQueryView(DataView): - def __init__(self, datasette): - self.ds = datasette - - async def post(self, request): - from datasette.app import TableNotFound - - try: - await self.ds.resolve_table(request) - except TableNotFound as e: - # Was this actually a canned query? - canned_query = await self.ds.get_canned_query( - e.database_name, e.table, request.actor - ) - if canned_query: - # Handle POST to a canned query - return await QueryView(self.ds).data( - request, - canned_query["sql"], - metadata=canned_query, - editable=False, - canned_query=e.table, - named_parameters=canned_query.get("params"), - write=bool(canned_query.get("write")), - ) - - return Response.text("Method not allowed", status=405) - - async def data(self, request, **kwargs): - from datasette.app import TableNotFound - - try: - await self.ds.resolve_table(request) - except TableNotFound as not_found: - canned_query = await self.ds.get_canned_query( - not_found.database_name, not_found.table, request.actor - ) - if canned_query: - return await QueryView(self.ds).data( - request, - canned_query["sql"], - metadata=canned_query, - editable=False, - canned_query=not_found.table, - named_parameters=canned_query.get("params"), - write=bool(canned_query.get("write")), - ) - else: - raise - - async def table_view_traced(datasette, request): from datasette.app import TableNotFound @@ -761,10 +708,7 @@ async def table_view_traced(datasette, request): ) # If this is a canned query, not a table, then dispatch to QueryView instead if canned_query: - if request.method == "POST": - return await CannedQueryView(datasette).post(request) - else: - return await CannedQueryView(datasette).get(request) + return await QueryView()(request, datasette) else: raise diff --git a/tests/test_canned_queries.py b/tests/test_canned_queries.py index d6a88733..e9ad3239 100644 --- a/tests/test_canned_queries.py +++ b/tests/test_canned_queries.py @@ -95,12 +95,12 @@ def test_insert(canned_write_client): csrftoken_from=True, cookies={"foo": "bar"}, ) - assert 302 == response.status - assert "/data/add_name?success" == response.headers["Location"] messages = canned_write_client.ds.unsign( response.cookies["ds_messages"], "messages" ) - assert [["Query executed, 1 row affected", 1]] == messages + assert messages == [["Query executed, 1 row affected", 1]] + assert response.status == 302 + assert response.headers["Location"] == "/data/add_name?success" @pytest.mark.parametrize( @@ -382,11 +382,11 @@ def test_magic_parameters_cannot_be_used_in_arbitrary_queries(magic_parameters_c def test_canned_write_custom_template(canned_write_client): response = canned_write_client.get("/data/update_name") assert response.status == 200 + assert "!!!CUSTOM_UPDATE_NAME_TEMPLATE!!!" in response.text assert ( "" in response.text ) - assert "!!!CUSTOM_UPDATE_NAME_TEMPLATE!!!" in response.text # And test for link rel=alternate while we're here: assert ( '' From 8920d425f4d417cfd998b61016c5ff3530cd34e1 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 9 Aug 2023 10:20:58 -0700 Subject: [PATCH 0766/1250] 1.0a3 release notes, smaller changes section - refs #2135 --- docs/changelog.rst | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index ee48d075..b4416f94 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,25 @@ Changelog ========= +.. _v1_0_a3: + +1.0a3 (2023-08-09) +------------------ + +This alpha release previews the updated design for Datasette's default JSON API. + +Smaller changes +~~~~~~~~~~~~~~~ + +- Datasette documentation now shows YAML examples for :ref:`metadata` by default, with a tab interface for switching to JSON. (:issue:`1153`) +- :ref:`plugin_register_output_renderer` plugins now have access to ``error`` and ``truncated`` arguments, allowing them to display error messages and take into account truncated results. (:issue:`2130`) +- ``render_cell()`` plugin hook now also supports an optional ``request`` argument. (:issue:`2007`) +- New ``Justfile`` to support development workflows for Datasette using `Just `__. +- ``datasette.render_template()`` can now accepts a ``datasette.views.Context`` subclass as an alternative to a dictionary. (:issue:`2127`) +- ``datasette install -e path`` option for editable installations, useful while developing plugins. (:issue:`2106`) +- When started with the ``--cors`` option Datasette now serves an ``Access-Control-Max-Age: 3600`` header, ensuring CORS OPTIONS requests are repeated no more than once an hour. (:issue:`2079`) +- Fixed a bug where the ``_internal`` database could display ``None`` instead of ``null`` for in-memory databases. (:issue:`1970`) + .. _v0_64_2: 0.64.2 (2023-03-08) From e34d09c6ec16ff5e7717e112afdad67f7c05a62a Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 9 Aug 2023 12:01:59 -0700 Subject: [PATCH 0767/1250] Don't include columns in query JSON, refs #2136 --- datasette/renderer.py | 8 +++++++- datasette/views/database.py | 2 +- tests/test_api.py | 1 - tests/test_cli_serve_get.py | 11 ++++++----- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/datasette/renderer.py b/datasette/renderer.py index 0bd74e81..224031a7 100644 --- a/datasette/renderer.py +++ b/datasette/renderer.py @@ -27,7 +27,7 @@ def convert_specific_columns_to_json(rows, columns, json_cols): return new_rows -def json_renderer(args, data, error, truncated=None): +def json_renderer(request, args, data, error, truncated=None): """Render a response as JSON""" status_code = 200 @@ -106,6 +106,12 @@ def json_renderer(args, data, error, truncated=None): "status": 400, "title": None, } + + # Don't include "columns" in output + # https://github.com/simonw/datasette/issues/2136 + if isinstance(data, dict) and "columns" not in request.args.getlist("_extra"): + data.pop("columns", None) + # Handle _nl option for _shape=array nl = args.get("_nl", "") if nl and shape == "array": diff --git a/datasette/views/database.py b/datasette/views/database.py index 658c35e6..cf76f3c2 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -548,7 +548,7 @@ class QueryView(View): error=query_error, # These will be deprecated in Datasette 1.0: args=request.args, - data={"rows": rows, "columns": columns}, + data={"ok": True, "rows": rows, "columns": columns}, ) if asyncio.iscoroutine(result): result = await result diff --git a/tests/test_api.py b/tests/test_api.py index 28415a0b..f96f571e 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -649,7 +649,6 @@ async def test_custom_sql(ds_client): {"content": "RENDER_CELL_DEMO"}, {"content": "RENDER_CELL_ASYNC"}, ], - "columns": ["content"], "ok": True, "truncated": False, } diff --git a/tests/test_cli_serve_get.py b/tests/test_cli_serve_get.py index 2e0390bb..dc7fc1e2 100644 --- a/tests/test_cli_serve_get.py +++ b/tests/test_cli_serve_get.py @@ -34,11 +34,12 @@ def test_serve_with_get(tmp_path_factory): "/_memory.json?sql=select+sqlite_version()", ], ) - assert 0 == result.exit_code, result.output - assert { - "truncated": False, - "columns": ["sqlite_version()"], - }.items() <= json.loads(result.output).items() + assert result.exit_code == 0, result.output + data = json.loads(result.output) + # Should have a single row with a single column + assert len(data["rows"]) == 1 + assert list(data["rows"][0].keys()) == ["sqlite_version()"] + assert set(data.keys()) == {"rows", "ok", "truncated"} # The plugin should have created hello.txt assert (plugins_dir / "hello.txt").read_text() == "hello" From 856ca68d94708c6e94673cb6bc28bf3e3ca17845 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 9 Aug 2023 12:04:40 -0700 Subject: [PATCH 0768/1250] Update default JSON representation docs, refs #2135 --- docs/json_api.rst | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/docs/json_api.rst b/docs/json_api.rst index c273c2a8..16b997eb 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -9,10 +9,10 @@ through the Datasette user interface can also be accessed as JSON via the API. To access the API for a page, either click on the ``.json`` link on that page or edit the URL and add a ``.json`` extension to it. -.. _json_api_shapes: +.. _json_api_default: -Different shapes ----------------- +Default representation +---------------------- The default JSON representation of data from a SQLite table or custom query looks like this: @@ -21,7 +21,6 @@ looks like this: { "ok": true, - "next": null, "rows": [ { "id": 3, @@ -39,13 +38,22 @@ looks like this: "id": 1, "name": "San Francisco" } - ] + ], + "truncated": false } -The ``rows`` key is a list of objects, each one representing a row. ``next`` indicates if -there is another page, and ``ok`` is always ``true`` if an error did not occur. +``"ok"`` is always ``true`` if an error did not occur. -If ``next`` is present then the next page in the pagination set can be retrieved using ``?_next=VALUE``. +The ``"rows"`` key is a list of objects, each one representing a row. + +The ``"truncated"`` key lets you know if the query was truncated. This can happen if a SQL query returns more than 1,000 results (or the :ref:`setting_max_returned_rows` setting). + +For table pages, an additional key ``"next"`` may be present. This indicates that the next page in the pagination set can be retrieved using ``?_next=VALUE``. + +.. _json_api_shapes: + +Different shapes +---------------- The ``_shape`` parameter can be used to access alternative formats for the ``rows`` key which may be more convenient for your application. There are three From 90cb9ca58d910f49e8f117bbdd94df6f0855cf99 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 9 Aug 2023 12:11:16 -0700 Subject: [PATCH 0769/1250] JSON changes in release notes, refs #2135 --- docs/changelog.rst | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index b4416f94..4c70855b 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -9,7 +9,40 @@ Changelog 1.0a3 (2023-08-09) ------------------ -This alpha release previews the updated design for Datasette's default JSON API. +This alpha release previews the updated design for Datasette's default JSON API. (:issue:`782`) + +The new :ref:`default JSON representation ` for both table pages (``/dbname/table.json``) and arbitrary SQL queries (``/dbname.json?sql=...``) is now shaped like this: + +.. code-block:: json + + { + "ok": true, + "rows": [ + { + "id": 3, + "name": "Detroit" + }, + { + "id": 2, + "name": "Los Angeles" + }, + { + "id": 4, + "name": "Memnonia" + }, + { + "id": 1, + "name": "San Francisco" + } + ], + "truncated": false + } + +Tables will include an additional ``"next"`` key for pagination, which can be passed to ``?_next=`` to fetch the next page of results. + +The various ``?_shape=`` options continue to work as before - see :ref:`json_api_shapes` for details. + +A new ``?_extra=`` mechanism is available for tables, but has not yet been stabilized or documented. Details on that are available in :issue:`262`. Smaller changes ~~~~~~~~~~~~~~~ From 19ab4552e212c9845a59461cc73e82d5ae8c278a Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 9 Aug 2023 12:13:11 -0700 Subject: [PATCH 0770/1250] Release 1.0a3 Closes #2135 Refs #262, #782, #1153, #1970, #2007, #2079, #2106, #2127, #2130 --- datasette/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/datasette/version.py b/datasette/version.py index 3b81ab21..61dee464 100644 --- a/datasette/version.py +++ b/datasette/version.py @@ -1,2 +1,2 @@ -__version__ = "1.0a2" +__version__ = "1.0a3" __version_info__ = tuple(__version__.split(".")) From 4a42476bb7ce4c5ed941f944115dedd9bce34656 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 9 Aug 2023 15:04:16 -0700 Subject: [PATCH 0771/1250] datasette plugins --requirements, closes #2133 --- datasette/cli.py | 12 ++++++++++-- docs/cli-reference.rst | 1 + docs/plugins.rst | 32 ++++++++++++++++++++++++++++---- tests/test_cli.py | 3 +++ 4 files changed, 42 insertions(+), 6 deletions(-) diff --git a/datasette/cli.py b/datasette/cli.py index 32266888..21fd25d6 100644 --- a/datasette/cli.py +++ b/datasette/cli.py @@ -223,15 +223,23 @@ pm.hook.publish_subcommand(publish=publish) @cli.command() @click.option("--all", help="Include built-in default plugins", is_flag=True) +@click.option( + "--requirements", help="Output requirements.txt of installed plugins", is_flag=True +) @click.option( "--plugins-dir", type=click.Path(exists=True, file_okay=False, dir_okay=True), help="Path to directory containing custom plugins", ) -def plugins(all, plugins_dir): +def plugins(all, requirements, plugins_dir): """List currently installed plugins""" app = Datasette([], plugins_dir=plugins_dir) - click.echo(json.dumps(app._plugins(all=all), indent=4)) + if requirements: + for plugin in app._plugins(): + if plugin["version"]: + click.echo("{}=={}".format(plugin["name"], plugin["version"])) + else: + click.echo(json.dumps(app._plugins(all=all), indent=4)) @cli.command() diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index 2177fc9e..7a96d311 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -282,6 +282,7 @@ Output JSON showing all currently installed plugins, their versions, whether the Options: --all Include built-in default plugins + --requirements Output requirements.txt of installed plugins --plugins-dir DIRECTORY Path to directory containing custom plugins --help Show this message and exit. diff --git a/docs/plugins.rst b/docs/plugins.rst index 979f94dd..19bfdd0c 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -90,7 +90,12 @@ You can see a list of installed plugins by navigating to the ``/-/plugins`` page You can also use the ``datasette plugins`` command:: - $ datasette plugins + datasette plugins + +Which outputs: + +.. code-block:: json + [ { "name": "datasette_json_html", @@ -107,7 +112,8 @@ You can also use the ``datasette plugins`` command:: cog.out("\n") result = CliRunner().invoke(cli.cli, ["plugins", "--all"]) # cog.out() with text containing newlines was unindenting for some reason - cog.outl("If you run ``datasette plugins --all`` it will include default plugins that ship as part of Datasette::\n") + cog.outl("If you run ``datasette plugins --all`` it will include default plugins that ship as part of Datasette:\n") + cog.outl(".. code-block:: json\n") plugins = [p for p in json.loads(result.output) if p["name"].startswith("datasette.")] indented = textwrap.indent(json.dumps(plugins, indent=4), " ") for line in indented.split("\n"): @@ -115,7 +121,9 @@ You can also use the ``datasette plugins`` command:: cog.out("\n\n") .. ]]] -If you run ``datasette plugins --all`` it will include default plugins that ship as part of Datasette:: +If you run ``datasette plugins --all`` it will include default plugins that ship as part of Datasette: + +.. code-block:: json [ { @@ -236,6 +244,22 @@ If you run ``datasette plugins --all`` it will include default plugins that ship You can add the ``--plugins-dir=`` option to include any plugins found in that directory. +Add ``--requirements`` to output a list of installed plugins that can then be installed in another Datasette instance using ``datasette install -r requirements.txt``:: + + datasette plugins --requirements + +The output will look something like this:: + + datasette-codespaces==0.1.1 + datasette-graphql==2.2 + datasette-json-html==1.0.1 + datasette-pretty-json==0.2.2 + datasette-x-forwarded-host==0.1 + +To write that to a ``requirements.txt`` file, run this:: + + datasette plugins --requirements > requirements.txt + .. _plugins_configuration: Plugin configuration @@ -390,7 +414,7 @@ Any values embedded in ``metadata.yaml`` will be visible to anyone who views the If you are publishing your data using the :ref:`datasette publish ` family of commands, you can use the ``--plugin-secret`` option to set these secrets at publish time. For example, using Heroku you might run the following command:: - $ datasette publish heroku my_database.db \ + datasette publish heroku my_database.db \ --name my-heroku-app-demo \ --install=datasette-auth-github \ --plugin-secret datasette-auth-github client_id your_client_id \ diff --git a/tests/test_cli.py b/tests/test_cli.py index 75724f61..056e2821 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -108,6 +108,9 @@ def test_plugins_cli(app_client): assert set(names).issuperset({p["name"] for p in EXPECTED_PLUGINS}) # And the following too: assert set(names).issuperset(DEFAULT_PLUGINS) + # --requirements should be empty because there are no installed non-plugins-dir plugins + result3 = runner.invoke(cli, ["plugins", "--requirements"]) + assert result3.output == "" def test_metadata_yaml(): From a3593c901580ea50854c3e0774b0ba0126e8a76f Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 9 Aug 2023 17:32:07 -0700 Subject: [PATCH 0772/1250] on_success_message_sql, closes #2138 --- datasette/views/database.py | 29 ++++++++++++++++---- docs/sql_queries.rst | 21 ++++++++++---- tests/test_canned_queries.py | 53 +++++++++++++++++++++++++++++++----- 3 files changed, 85 insertions(+), 18 deletions(-) diff --git a/datasette/views/database.py b/datasette/views/database.py index cf76f3c2..79b3f88d 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -360,6 +360,10 @@ class QueryView(View): params[key] = str(value) else: params = dict(parse_qsl(body, keep_blank_values=True)) + + # Don't ever send csrftoken as a SQL parameter + params.pop("csrftoken", None) + # Should we return JSON? should_return_json = ( request.headers.get("accept") == "application/json" @@ -371,12 +375,27 @@ class QueryView(View): redirect_url = None try: cursor = await db.execute_write(canned_query["sql"], params_for_query) - message = canned_query.get( - "on_success_message" - ) or "Query executed, {} row{} affected".format( - cursor.rowcount, "" if cursor.rowcount == 1 else "s" - ) + # success message can come from on_success_message or on_success_message_sql + message = None message_type = datasette.INFO + on_success_message_sql = canned_query.get("on_success_message_sql") + if on_success_message_sql: + try: + message_result = ( + await db.execute(on_success_message_sql, params_for_query) + ).first() + if message_result: + message = message_result[0] + except Exception as ex: + message = "Error running on_success_message_sql: {}".format(ex) + message_type = datasette.ERROR + if not message: + message = canned_query.get( + "on_success_message" + ) or "Query executed, {} row{} affected".format( + cursor.rowcount, "" if cursor.rowcount == 1 else "s" + ) + redirect_url = canned_query.get("on_success_redirect") ok = True except Exception as ex: diff --git a/docs/sql_queries.rst b/docs/sql_queries.rst index 3c2cb228..1ae07e1f 100644 --- a/docs/sql_queries.rst +++ b/docs/sql_queries.rst @@ -392,6 +392,7 @@ This configuration will create a page at ``/mydatabase/add_name`` displaying a f You can customize how Datasette represents success and errors using the following optional properties: - ``on_success_message`` - the message shown when a query is successful +- ``on_success_message_sql`` - alternative to ``on_success_message``: a SQL query that should be executed to generate the message - ``on_success_redirect`` - the path or URL the user is redirected to on success - ``on_error_message`` - the message shown when a query throws an error - ``on_error_redirect`` - the path or URL the user is redirected to on error @@ -405,11 +406,12 @@ For example: "queries": { "add_name": { "sql": "INSERT INTO names (name) VALUES (:name)", + "params": ["name"], "write": True, - "on_success_message": "Name inserted", + "on_success_message_sql": "select 'Name inserted: ' || :name", "on_success_redirect": "/mydatabase/names", "on_error_message": "Name insert failed", - "on_error_redirect": "/mydatabase" + "on_error_redirect": "/mydatabase", } } } @@ -426,8 +428,10 @@ For example: queries: add_name: sql: INSERT INTO names (name) VALUES (:name) + params: + - name write: true - on_success_message: Name inserted + on_success_message_sql: 'select ''Name inserted: '' || :name' on_success_redirect: /mydatabase/names on_error_message: Name insert failed on_error_redirect: /mydatabase @@ -443,8 +447,11 @@ For example: "queries": { "add_name": { "sql": "INSERT INTO names (name) VALUES (:name)", + "params": [ + "name" + ], "write": true, - "on_success_message": "Name inserted", + "on_success_message_sql": "select 'Name inserted: ' || :name", "on_success_redirect": "/mydatabase/names", "on_error_message": "Name insert failed", "on_error_redirect": "/mydatabase" @@ -455,10 +462,12 @@ For example: } .. [[[end]]] -You can use ``"params"`` to explicitly list the named parameters that should be displayed as form fields - otherwise they will be automatically detected. +You can use ``"params"`` to explicitly list the named parameters that should be displayed as form fields - otherwise they will be automatically detected. ``"params"`` is not necessary in the above example, since without it ``"name"`` would be automatically detected from the query. You can pre-populate form fields when the page first loads using a query string, e.g. ``/mydatabase/add_name?name=Prepopulated``. The user will have to submit the form to execute the query. +If you specify a query in ``"on_success_message_sql"``, that query will be executed after the main query. The first column of the first row return by that query will be displayed as a success message. Named parameters from the main query will be made available to the success message query as well. + .. _canned_queries_magic_parameters: Magic parameters @@ -589,7 +598,7 @@ The JSON response will look like this: "redirect": "/data/add_name" } -The ``"message"`` and ``"redirect"`` values here will take into account ``on_success_message``, ``on_success_redirect``, ``on_error_message`` and ``on_error_redirect``, if they have been set. +The ``"message"`` and ``"redirect"`` values here will take into account ``on_success_message``, ``on_success_message_sql``, ``on_success_redirect``, ``on_error_message`` and ``on_error_redirect``, if they have been set. .. _pagination: diff --git a/tests/test_canned_queries.py b/tests/test_canned_queries.py index e9ad3239..5256c24c 100644 --- a/tests/test_canned_queries.py +++ b/tests/test_canned_queries.py @@ -31,9 +31,15 @@ def canned_write_client(tmpdir): }, "add_name_specify_id": { "sql": "insert into names (rowid, name) values (:rowid, :name)", + "on_success_message_sql": "select 'Name added: ' || :name || ' with rowid ' || :rowid", "write": True, "on_error_redirect": "/data/add_name_specify_id?error", }, + "add_name_specify_id_with_error_in_on_success_message_sql": { + "sql": "insert into names (rowid, name) values (:rowid, :name)", + "on_success_message_sql": "select this is bad SQL", + "write": True, + }, "delete_name": { "sql": "delete from names where rowid = :rowid", "write": True, @@ -179,6 +185,34 @@ def test_insert_error(canned_write_client): ) +def test_on_success_message_sql(canned_write_client): + response = canned_write_client.post( + "/data/add_name_specify_id", + {"rowid": 5, "name": "Should be OK"}, + csrftoken_from=True, + ) + assert response.status == 302 + assert response.headers["Location"] == "/data/add_name_specify_id" + messages = canned_write_client.ds.unsign( + response.cookies["ds_messages"], "messages" + ) + assert messages == [["Name added: Should be OK with rowid 5", 1]] + + +def test_error_in_on_success_message_sql(canned_write_client): + response = canned_write_client.post( + "/data/add_name_specify_id_with_error_in_on_success_message_sql", + {"rowid": 1, "name": "Should fail"}, + csrftoken_from=True, + ) + messages = canned_write_client.ds.unsign( + response.cookies["ds_messages"], "messages" + ) + assert messages == [ + ["Error running on_success_message_sql: no such column: bad", 3] + ] + + def test_custom_params(canned_write_client): response = canned_write_client.get("/data/update_name?extra=foo") assert '' in response.text @@ -232,21 +266,22 @@ def test_canned_query_permissions_on_database_page(canned_write_client): query_names = { q["name"] for q in canned_write_client.get("/data.json").json["queries"] } - assert { + assert query_names == { + "add_name_specify_id_with_error_in_on_success_message_sql", + "from_hook", + "update_name", + "add_name_specify_id", + "from_async_hook", "canned_read", "add_name", - "add_name_specify_id", - "update_name", - "from_async_hook", - "from_hook", - } == query_names + } # With auth shows four response = canned_write_client.get( "/data.json", cookies={"ds_actor": canned_write_client.actor_cookie({"id": "root"})}, ) - assert 200 == response.status + assert response.status == 200 query_names_and_private = sorted( [ {"name": q["name"], "private": q["private"]} @@ -257,6 +292,10 @@ def test_canned_query_permissions_on_database_page(canned_write_client): assert query_names_and_private == [ {"name": "add_name", "private": False}, {"name": "add_name_specify_id", "private": False}, + { + "name": "add_name_specify_id_with_error_in_on_success_message_sql", + "private": False, + }, {"name": "canned_read", "private": False}, {"name": "delete_name", "private": True}, {"name": "from_async_hook", "private": False}, From 33251d04e78d575cca62bb59069bb43a7d924746 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 9 Aug 2023 17:56:27 -0700 Subject: [PATCH 0773/1250] Canned query write counters demo, refs #2134 --- .github/workflows/deploy-latest.yml | 30 +++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/.github/workflows/deploy-latest.yml b/.github/workflows/deploy-latest.yml index ed60376c..4746aa07 100644 --- a/.github/workflows/deploy-latest.yml +++ b/.github/workflows/deploy-latest.yml @@ -57,6 +57,36 @@ jobs: db.route = "alternative-route" ' > plugins/alternative_route.py cp fixtures.db fixtures2.db + - name: And the counters writable canned query demo + run: | + cat > plugins/counters.py < Date: Thu, 10 Aug 2023 22:16:19 -0700 Subject: [PATCH 0774/1250] Fixed display of database color Closes #2139, closes #2119 --- datasette/database.py | 7 +++++++ datasette/templates/database.html | 2 +- datasette/templates/query.html | 2 +- datasette/templates/row.html | 2 +- datasette/templates/table.html | 2 +- datasette/views/base.py | 4 ---- datasette/views/database.py | 8 +++----- datasette/views/index.py | 4 +--- datasette/views/row.py | 4 +++- datasette/views/table.py | 2 +- tests/test_html.py | 20 ++++++++++++++++++++ 11 files changed, 39 insertions(+), 18 deletions(-) diff --git a/datasette/database.py b/datasette/database.py index d8043c24..af39ac9e 100644 --- a/datasette/database.py +++ b/datasette/database.py @@ -1,6 +1,7 @@ import asyncio from collections import namedtuple from pathlib import Path +import hashlib import janus import queue import sys @@ -62,6 +63,12 @@ class Database: } return self._cached_table_counts + @property + def color(self): + if self.hash: + return self.hash[:6] + return hashlib.md5(self.name.encode("utf8")).hexdigest()[:6] + def suggest_name(self): if self.path: return Path(self.path).stem diff --git a/datasette/templates/database.html b/datasette/templates/database.html index 7acf0369..3d4dae07 100644 --- a/datasette/templates/database.html +++ b/datasette/templates/database.html @@ -10,7 +10,7 @@ {% block body_class %}db db-{{ database|to_css_class }}{% endblock %} {% block content %} -