From 7b41521b338304bd4065a9ec0db9b4c651ec6fed Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 5 Apr 2023 16:25:29 -0700 Subject: [PATCH 001/540] WIP new JSON for queries, refs #2049 --- datasette/app.py | 13 +- datasette/views/database.py | 329 ++++++++++++++++++++++++++++++++++++ 2 files changed, 340 insertions(+), 2 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index d7dace67..966a6faf 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -33,7 +33,12 @@ from jinja2.environment import Template from jinja2.exceptions import TemplateNotFound from .views.base import ureg -from .views.database import DatabaseDownload, DatabaseView, TableCreateView +from .views.database import ( + DatabaseDownload, + DatabaseView, + TableCreateView, + database_view, +) from .views.index import IndexView from .views.special import ( JsonDataView, @@ -1365,8 +1370,12 @@ class Datasette: r"/-/patterns$", ) add_route(DatabaseDownload.as_view(self), r"/(?P[^\/\.]+)\.db$") + # add_route( + # DatabaseView.as_view(self), r"/(?P[^\/\.]+)(\.(?P\w+))?$" + # ) add_route( - DatabaseView.as_view(self), r"/(?P[^\/\.]+)(\.(?P\w+))?$" + wrap_view(database_view, self), + r"/(?P[^\/\.]+)(\.(?P\w+))?$", ) add_route(TableCreateView.as_view(self), r"/(?P[^\/\.]+)/-/create$") add_route( diff --git a/datasette/views/database.py b/datasette/views/database.py index dda82510..7b90a2db 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -757,3 +757,332 @@ async def _table_columns(datasette, database_name): for view_name in await db.view_names(): table_columns[view_name] = [] return table_columns + + +async def database_view(request, datasette): + return await database_view_impl(request, datasette) + + +async def database_view_impl( + request, + datasette, + canned_query=None, + _size=None, + named_parameters=None, + write=False, +): + db = await datasette.resolve_database(request) + database = db.name + params = {key: request.args.get(key) for key in request.args} + sql = "" + if "sql" in params: + sql = params.pop("sql") + _shape = None + if "_shape" in params: + _shape = params.pop("_shape") + + private = False + if canned_query: + # Respect canned query permissions + visible, private = await datasette.check_visibility( + request.actor, + permissions=[ + ("view-query", (database, canned_query)), + ("view-database", database), + "view-instance", + ], + ) + if not visible: + raise Forbidden("You do not have permission to view this query") + + else: + await datasette.ensure_permissions(request.actor, [("execute-sql", database)]) + + # Extract any :named parameters + named_parameters = named_parameters or await derive_named_parameters(db, 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("_"): + params[named_parameter] = "" + + 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", + ) + + query_error = None + + # Execute query - as write or as read + if write: + raise NotImplementedError("Write queries not yet implemented") + # 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: + + # 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, + # ) + + # Not a write + if canned_query: + params_for_query = MagicParameters(params, request, datasette) + else: + params_for_query = params + try: + 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 + results = None + columns = [] + + allow_execute_sql = await datasette.permission_allowed( + request.actor, "execute-sql", database + ) + + return Response.json( + { + "ok": True, + "rows": [dict(r) for r in results], + # "columns": columns, + # "database": database, + # "params": params, + # "sql": sql, + # "_shape": _shape, + # "named_parameters": named_parameters, + # "named_parameter_values": named_parameter_values, + # "extra_args": extra_args, + # "templates": templates, + } + ) + + async def extra_template(): + display_rows = [] + truncate_cells = datasette.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 = 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) + + # 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, + } + ) + ) + + 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, + "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, + ) From 40dc5f5c501c4c32120148d94ec7c9d130141571 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 12 Apr 2023 17:04:26 -0700 Subject: [PATCH 002/540] WIP --- datasette/views/database.py | 227 +++++++++++++++++++++++++++++++++--- datasette/views/table.py | 1 - 2 files changed, 212 insertions(+), 16 deletions(-) diff --git a/datasette/views/database.py b/datasette/views/database.py index 7b90a2db..d097c933 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -12,6 +12,7 @@ import markupsafe from datasette.utils import ( add_cors_headers, await_me_maybe, + call_with_supported_arguments, derive_named_parameters, format_bytes, tilde_decode, @@ -763,6 +764,119 @@ async def database_view(request, datasette): return await database_view_impl(request, datasette) +async def database_index_view(request, datasette, db): + database = db.name + visible, private = await datasette.check_visibility( + request.actor, + permissions=[ + ("view-database", database), + "view-instance", + ], + ) + if not visible: + raise Forbidden("You do not have permission to view this database") + + 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() + + views = [] + for view_name in await db.view_names(): + view_visible, view_private = await datasette.check_visibility( + request.actor, + permissions=[ + ("view-table", (database, view_name)), + ("view-database", database), + "view-instance", + ], + ) + if view_visible: + views.append( + { + "name": view_name, + "private": view_private, + } + ) + + tables = [] + for table in table_counts: + table_visible, table_private = await datasette.check_visibility( + request.actor, + permissions=[ + ("view-table", (database, table)), + ("view-database", database), + "view-instance", + ], + ) + if not table_visible: + continue + table_columns = await db.table_columns(table) + tables.append( + { + "name": table, + "columns": table_columns, + "primary_keys": await db.primary_keys(table), + "count": table_counts[table], + "hidden": table in hidden_table_names, + "fts_table": await db.fts_table(table), + "foreign_keys": all_foreign_keys[table], + "private": table_private, + } + ) + + tables.sort(key=lambda t: (t["hidden"], t["name"])) + canned_queries = [] + for query in (await datasette.get_canned_queries(database, request.actor)).values(): + query_visible, query_private = await datasette.check_visibility( + request.actor, + permissions=[ + ("view-query", (database, query["name"])), + ("view-database", database), + "view-instance", + ], + ) + if query_visible: + canned_queries.append(dict(query, private=query_private)) + + async def database_actions(): + links = [] + for hook in pm.hook.database_actions( + datasette=datasette, + database=database, + actor=request.actor, + request=request, + ): + extra_links = await await_me_maybe(hook) + if extra_links: + links.extend(extra_links) + return links + + attached_databases = [d.name for d in await db.attached_databases()] + + allow_execute_sql = await datasette.permission_allowed( + request.actor, "execute-sql", database + ) + return Response.json( + { + "database": db.name, + "private": private, + "path": datasette.urls.database(database), + "size": db.size, + "tables": tables, + "hidden_count": len([t for t in tables if t["hidden"]]), + "views": views, + "queries": canned_queries, + "allow_execute_sql": allow_execute_sql, + "table_columns": await _table_columns(datasette, database) + if allow_execute_sql + else {}, + } + ) + + async def database_view_impl( request, datasette, @@ -798,6 +912,12 @@ async def database_view_impl( else: await datasette.ensure_permissions(request.actor, [("execute-sql", database)]) + # If there's no sql, show the database index page + if not sql: + return await database_index_view(request, datasette, db) + + validate_sql_select(sql) + # Extract any :named parameters named_parameters = named_parameters or await derive_named_parameters(db, sql) named_parameter_values = { @@ -909,6 +1029,7 @@ async def database_view_impl( # ) # Not a write + rows = [] if canned_query: params_for_query = MagicParameters(params, request, datasette) else: @@ -918,6 +1039,7 @@ async def database_view_impl( database, sql, params_for_query, truncate=True, **extra_args ) columns = [r[0] for r in results.description] + rows = list(results.rows) except sqlite3.DatabaseError as e: query_error = e results = None @@ -927,21 +1049,96 @@ async def database_view_impl( request.actor, "execute-sql", database ) - return Response.json( - { - "ok": True, - "rows": [dict(r) for r in results], - # "columns": columns, - # "database": database, - # "params": params, - # "sql": sql, - # "_shape": _shape, - # "named_parameters": named_parameters, - # "named_parameter_values": named_parameter_values, - # "extra_args": extra_args, - # "templates": templates, - } - ) + format_ = request.url_vars.get("format") or "html" + + if format_ == "csv": + raise NotImplementedError("CSV format not yet implemented") + 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=db.name, + table=None, + request=request, + view_name="table", # TODO: should this be "query"? + # These will be deprecated in Datasette 1.0: + args=request.args, + data={ + "rows": rows, + }, # TODO what should this be? + ) + result = await await_me_maybe(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"] + template = datasette.jinja_env.select_template(templates) + alternate_url_json = datasette.absolute_url( + request, + datasette.urls.path(path_with_format(request=request, format="json")), + ) + headers.update( + { + "Link": '{}; rel="alternate"; type="application/json+datasette"'.format( + alternate_url_json + ) + } + ) + r = Response.html( + await datasette.render_template( + template, + dict( + data, + append_querystring=append_querystring, + path_with_replaced_args=path_with_replaced_args, + fix_path=datasette.urls.path, + settings=datasette.settings_dict(), + # TODO: review up all of these hacks: + alternate_url_json=alternate_url_json, + datasette_allow_facet=( + "true" if datasette.setting("allow_facet") else "false" + ), + is_sortable=any(c["sortable"] for c in data["display_columns"]), + allow_execute_sql=await datasette.permission_allowed( + request.actor, "execute-sql", resolved.db.name + ), + query_ms=1.2, + select_templates=[ + f"{'*' if template_name == template.name else ''}{template_name}" + for template_name in templates + ], + ), + request=request, + view_name="table", + ), + headers=headers, + ) + else: + assert False, "Invalid format: {}".format(format_) + # if next_url: + # r.headers["link"] = f'<{next_url}>; rel="next"' + return r async def extra_template(): display_rows = [] diff --git a/datasette/views/table.py b/datasette/views/table.py index c102c103..e367a075 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, From 026429fadd7a1f4f85ebfda1bbfe882938f455f2 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 26 Apr 2023 20:47:03 -0700 Subject: [PATCH 003/540] Work in progress on query view, refs #2049 --- datasette/views/database.py | 420 ++++++++++++++++++++++++++++++++++++ setup.py | 2 +- tests/test_cli_serve_get.py | 9 +- 3 files changed, 426 insertions(+), 5 deletions(-) diff --git a/datasette/views/database.py b/datasette/views/database.py index d097c933..33b6702b 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -1,3 +1,4 @@ +from asyncinject import Registry import os import hashlib import itertools @@ -877,6 +878,416 @@ async def database_index_view(request, datasette, db): ) +async def query_view( + request, + datasette, + canned_query=None, + _size=None, + named_parameters=None, + write=False, +): + print("query_view") + db = await datasette.resolve_database(request) + database = db.name + # TODO: Why do I do this? Is it to eliminate multi-args? + # It's going to break ?_extra=...&_extra=... + params = {key: request.args.get(key) for key in request.args} + sql = "" + if "sql" in params: + sql = params.pop("sql") + + # TODO: Behave differently for canned query here: + await datasette.ensure_permissions(request.actor, [("execute-sql", database)]) + + _shape = None + if "_shape" in params: + _shape = params.pop("_shape") + + # ?_shape=arrays - "rows" is the default option, shown above + # ?_shape=objects - "rows" is a list of JSON key/value objects + # ?_shape=array - an JSON array of objects + # ?_shape=array&_nl=on - a newline-separated list of JSON objects + # ?_shape=arrayfirst - a flat JSON array containing just the first value from each row + # ?_shape=object - a JSON object keyed using the primary keys of the rows + async def _results(_sql, _params): + # Returns (results, error (can be None)) + try: + return await db.execute(_sql, _params, truncate=True), None + except Exception as e: + return None, e + + async def shape_arrays(_results): + results, error = _results + if error: + return {"ok": False, "error": str(error)} + return { + "ok": True, + "rows": [list(r) for r in results.rows], + "truncated": results.truncated, + } + + async def shape_objects(_results): + results, error = _results + if error: + return {"ok": False, "error": str(error)} + return { + "ok": True, + "rows": [dict(r) for r in results.rows], + "truncated": results.truncated, + } + + async def shape_array(_results): + results, error = _results + if error: + return {"ok": False, "error": str(error)} + return [dict(r) for r in results.rows] + + shape_fn = { + "arrays": shape_arrays, + "objects": shape_objects, + "array": shape_array, + # "arrayfirst": shape_arrayfirst, + # "object": shape_object, + }[_shape or "objects"] + + registry = Registry.from_dict( + { + "_results": _results, + "_shape": shape_fn, + }, + parallel=False, + ) + + results = await registry.resolve_multi( + ["_shape"], + results={ + "_sql": sql, + "_params": params, + }, + ) + + # If "shape" does not include "rows" we return that as the response + if "rows" not in results["_shape"]: + return Response.json(results["_shape"]) + + output = results["_shape"] + output.update(dict((k, v) for k, v in results.items() if not k.startswith("_"))) + + response = Response.json(output) + + assert False + + import pdb + + pdb.set_trace() + + if isinstance(output, dict) and output.get("ok") is False: + # TODO: Other error codes? + + response.status_code = 400 + + if datasette.cors: + add_cors_headers(response.headers) + + return response + + # registry = Registry( + # extra_count, + # extra_facet_results, + # extra_facets_timed_out, + # extra_suggested_facets, + # facet_instances, + # extra_human_description_en, + # extra_next_url, + # extra_columns, + # extra_primary_keys, + # run_display_columns_and_rows, + # extra_display_columns, + # extra_display_rows, + # extra_debug, + # extra_request, + # extra_query, + # extra_metadata, + # extra_extras, + # extra_database, + # extra_table, + # extra_database_color, + # extra_table_actions, + # extra_filters, + # extra_renderers, + # extra_custom_table_templates, + # extra_sorted_facet_results, + # extra_table_definition, + # extra_view_definition, + # extra_is_view, + # extra_private, + # extra_expandable_columns, + # extra_form_hidden_args, + # ) + + results = await registry.resolve_multi( + ["extra_{}".format(extra) for extra in extras] + ) + data = { + "ok": True, + "next": next_value and str(next_value) or None, + } + data.update( + { + key.replace("extra_", ""): value + for key, value in results.items() + if key.startswith("extra_") and key.replace("extra_", "") in extras + } + ) + raw_sqlite_rows = rows[:page_size] + data["rows"] = [dict(r) for r in raw_sqlite_rows] + + private = False + if canned_query: + # Respect canned query permissions + visible, private = await datasette.check_visibility( + request.actor, + permissions=[ + ("view-query", (database, canned_query)), + ("view-database", database), + "view-instance", + ], + ) + if not visible: + raise Forbidden("You do not have permission to view this query") + + else: + await datasette.ensure_permissions(request.actor, [("execute-sql", database)]) + + # If there's no sql, show the database index page + if not sql: + return await database_index_view(request, datasette, db) + + validate_sql_select(sql) + + # Extract any :named parameters + named_parameters = named_parameters or await derive_named_parameters(db, 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("_"): + params[named_parameter] = "" + + 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", + ) + + query_error = None + + # Execute query - as write or as read + if write: + raise NotImplementedError("Write queries not yet implemented") + # 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: + + # 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, + # ) + + # Not a write + rows = [] + if canned_query: + params_for_query = MagicParameters(params, request, datasette) + else: + params_for_query = params + try: + results = await datasette.execute( + database, sql, params_for_query, truncate=True, **extra_args + ) + columns = [r[0] for r in results.description] + rows = list(results.rows) + except sqlite3.DatabaseError as e: + query_error = e + results = None + columns = [] + + allow_execute_sql = await datasette.permission_allowed( + request.actor, "execute-sql", database + ) + + format_ = request.url_vars.get("format") or "html" + + if format_ == "csv": + raise NotImplementedError("CSV format not yet implemented") + 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=db.name, + table=None, + request=request, + view_name="table", # TODO: should this be "query"? + # These will be deprecated in Datasette 1.0: + args=request.args, + data={ + "rows": rows, + }, # TODO what should this be? + ) + result = await await_me_maybe(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"] + template = datasette.jinja_env.select_template(templates) + alternate_url_json = datasette.absolute_url( + request, + datasette.urls.path(path_with_format(request=request, format="json")), + ) + headers.update( + { + "Link": '{}; rel="alternate"; type="application/json+datasette"'.format( + alternate_url_json + ) + } + ) + r = Response.html( + await datasette.render_template( + template, + dict( + data, + append_querystring=append_querystring, + path_with_replaced_args=path_with_replaced_args, + fix_path=datasette.urls.path, + settings=datasette.settings_dict(), + # TODO: review up all of these hacks: + alternate_url_json=alternate_url_json, + datasette_allow_facet=( + "true" if datasette.setting("allow_facet") else "false" + ), + is_sortable=any(c["sortable"] for c in data["display_columns"]), + allow_execute_sql=await datasette.permission_allowed( + request.actor, "execute-sql", resolved.db.name + ), + query_ms=1.2, + select_templates=[ + f"{'*' if template_name == template.name else ''}{template_name}" + for template_name in templates + ], + ), + request=request, + view_name="table", + ), + headers=headers, + ) + else: + assert False, "Invalid format: {}".format(format_) + # if next_url: + # r.headers["link"] = f'<{next_url}>; rel="next"' + return r + + async def database_view_impl( request, datasette, @@ -887,10 +1298,19 @@ async def database_view_impl( ): db = await datasette.resolve_database(request) database = db.name + + if request.args.get("sql", "").strip(): + return await query_view( + request, datasette, canned_query, _size, named_parameters, write + ) + + # Index page shows the tables/views/canned queries for this database + params = {key: request.args.get(key) for key in request.args} sql = "" if "sql" in params: sql = params.pop("sql") + _shape = None if "_shape" in params: _shape = params.pop("_shape") diff --git a/setup.py b/setup.py index d41e428a..b591869e 100644 --- a/setup.py +++ b/setup.py @@ -58,7 +58,7 @@ setup( "mergedeep>=1.1.1", "itsdangerous>=1.1", "sqlite-utils>=3.30", - "asyncinject>=0.5", + "asyncinject>=0.6", ], entry_points=""" [console_scripts] diff --git a/tests/test_cli_serve_get.py b/tests/test_cli_serve_get.py index ac44e1e2..e484a6db 100644 --- a/tests/test_cli_serve_get.py +++ b/tests/test_cli_serve_get.py @@ -1,6 +1,7 @@ from datasette.cli import cli, serve from datasette.plugins import pm from click.testing import CliRunner +from unittest.mock import ANY import textwrap import json @@ -35,11 +36,11 @@ def test_serve_with_get(tmp_path_factory): ], ) assert 0 == result.exit_code, result.output - assert { - "database": "_memory", + assert json.loads(result.output) == { + "ok": True, + "rows": [{"sqlite_version()": ANY}], "truncated": False, - "columns": ["sqlite_version()"], - }.items() <= json.loads(result.output).items() + } # The plugin should have created hello.txt assert (plugins_dir / "hello.txt").read_text() == "hello" From a706f34b923c5c503c1fa8e6a260925c61cec3fb Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 26 Apr 2023 22:07:05 -0700 Subject: [PATCH 004/540] Remove debug lines --- datasette/views/database.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/datasette/views/database.py b/datasette/views/database.py index 33b6702b..cb31a762 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -975,12 +975,6 @@ async def query_view( response = Response.json(output) - assert False - - import pdb - - pdb.set_trace() - if isinstance(output, dict) and output.get("ok") is False: # TODO: Other error codes? From 8b86fb7fb4840b0351c25cf6ff40ca9f17a8fee7 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 8 May 2023 17:50:12 -0700 Subject: [PATCH 005/540] Better debugging --- datasette/utils/testing.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/datasette/utils/testing.py b/datasette/utils/testing.py index d4990784..7747f7ce 100644 --- a/datasette/utils/testing.py +++ b/datasette/utils/testing.py @@ -16,6 +16,9 @@ class TestResponse: def status(self): return self.httpx_response.status_code + def __repr__(self): + return "".format(self.httpx_response.url, self.status) + # Supports both for test-writing convenience @property def status_code(self): From 3304fd43a2492739e9803f513c4954a5fd1e374d Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 8 May 2023 17:51:05 -0700 Subject: [PATCH 006/540] refresh_schemas() on database view --- datasette/views/database.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/datasette/views/database.py b/datasette/views/database.py index cb31a762..30016188 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -886,7 +886,6 @@ async def query_view( named_parameters=None, write=False, ): - print("query_view") db = await datasette.resolve_database(request) database = db.name # TODO: Why do I do this? Is it to eliminate multi-args? @@ -1290,6 +1289,7 @@ async def database_view_impl( named_parameters=None, write=False, ): + await datasette.refresh_schemas() db = await datasette.resolve_database(request) database = db.name From 6f903d5a98c59f8a602ef7de776ea631b7145ba8 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 8 May 2023 17:51:19 -0700 Subject: [PATCH 007/540] Fixed a test --- tests/test_api.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/test_api.py b/tests/test_api.py index 780e9fa5..e700ad7b 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -643,9 +643,6 @@ async def test_custom_sql(ds_client): "/fixtures.json?sql=select+content+from+simple_primary_key&_shape=objects" ) data = response.json() - assert {"sql": "select content from simple_primary_key", "params": {}} == data[ - "query" - ] assert [ {"content": "hello"}, {"content": "world"}, @@ -653,8 +650,6 @@ async def test_custom_sql(ds_client): {"content": "RENDER_CELL_DEMO"}, {"content": "RENDER_CELL_ASYNC"}, ] == data["rows"] - assert ["content"] == data["columns"] - assert "fixtures" == data["database"] assert not data["truncated"] From fdb141f6225ddf7209dc6abff0c1691bbb675465 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 8 May 2023 17:51:29 -0700 Subject: [PATCH 008/540] shape_arrayfirst for query view --- datasette/views/database.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/datasette/views/database.py b/datasette/views/database.py index 30016188..eac57cd3 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -941,11 +941,17 @@ async def query_view( return {"ok": False, "error": str(error)} return [dict(r) for r in results.rows] + async def shape_arrayfirst(_results): + results, error = _results + if error: + return {"ok": False, "error": str(error)} + return [r[0] for r in results.rows] + shape_fn = { "arrays": shape_arrays, "objects": shape_objects, "array": shape_array, - # "arrayfirst": shape_arrayfirst, + "arrayfirst": shape_arrayfirst, # "object": shape_object, }[_shape or "objects"] From 6ae5312158540aa12c4fc714404c0f3c1a72e4d4 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 22 May 2023 18:44:07 -0700 Subject: [PATCH 009/540] ?sql=... now displays HTML --- datasette/views/database.py | 158 +++++++++++++++++++++++++++++++++--- 1 file changed, 145 insertions(+), 13 deletions(-) diff --git a/datasette/views/database.py b/datasette/views/database.py index eac57cd3..455ebd1f 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -12,10 +12,12 @@ import markupsafe from datasette.utils import ( add_cors_headers, + append_querystring, await_me_maybe, call_with_supported_arguments, derive_named_parameters, format_bytes, + path_with_replaced_args, tilde_decode, to_css_class, validate_sql_select, @@ -887,6 +889,145 @@ async def query_view( write=False, ): db = await datasette.resolve_database(request) + + format_ = request.url_vars.get("format") or "html" + force_shape = None + if format_ == "html": + force_shape = "arrays" + + data = await query_view_data( + request, + datasette, + canned_query=canned_query, + _size=_size, + named_parameters=named_parameters, + write=write, + force_shape=force_shape, + ) + if format_ == "csv": + raise NotImplementedError("CSV format not yet implemented") + 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=db.name, + table=None, + request=request, + view_name="table", # TODO: should this be "query"? + # These will be deprecated in Datasette 1.0: + args=request.args, + data={ + "rows": rows, + }, # TODO what should this be? + ) + result = await await_me_maybe(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(db.name)}.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")), + ) + headers.update( + { + "Link": '{}; rel="alternate"; type="application/json+datasette"'.format( + alternate_url_json + ) + } + ) + metadata = (datasette.metadata("databases") or {}).get(db.name, {}) + datasette.update_with_inherited_metadata(metadata) + + r = Response.html( + await datasette.render_template( + template, + dict( + data, + database=db.name, + database_color=lambda database: "ff0000", + metadata=metadata, + display_rows=data["rows"], + renderers={}, + query={ + "sql": request.args.get("sql"), + }, + editable=True, + append_querystring=append_querystring, + path_with_replaced_args=path_with_replaced_args, + fix_path=datasette.urls.path, + settings=datasette.settings_dict(), + # TODO: review up all of these hacks: + alternate_url_json=alternate_url_json, + datasette_allow_facet=( + "true" if datasette.setting("allow_facet") else "false" + ), + is_sortable=False, + allow_execute_sql=await datasette.permission_allowed( + request.actor, "execute-sql", db.name + ), + query_ms=1.2, + select_templates=[ + f"{'*' if template_name == template.name else ''}{template_name}" + for template_name in templates + ], + ), + request=request, + view_name="table", + ), + headers=headers, + ) + else: + assert False, "Invalid format: {}".format(format_) + # if next_url: + # r.headers["link"] = f'<{next_url}>; rel="next"' + return r + + response = Response.json(data) + + if isinstance(data, dict) and data.get("ok") is False: + # TODO: Other error codes? + + response.status_code = 400 + + if datasette.cors: + add_cors_headers(response.headers) + + return response + + +async def query_view_data( + request, + datasette, + canned_query=None, + _size=None, + named_parameters=None, + write=False, + force_shape=None, +): + db = await datasette.resolve_database(request) database = db.name # TODO: Why do I do this? Is it to eliminate multi-args? # It's going to break ?_extra=...&_extra=... @@ -898,11 +1039,11 @@ async def query_view( # TODO: Behave differently for canned query here: await datasette.ensure_permissions(request.actor, [("execute-sql", database)]) - _shape = None + _shape = force_shape if "_shape" in params: _shape = params.pop("_shape") - # ?_shape=arrays - "rows" is the default option, shown above + # ?_shape=arrays # ?_shape=objects - "rows" is a list of JSON key/value objects # ?_shape=array - an JSON array of objects # ?_shape=array&_nl=on - a newline-separated list of JSON objects @@ -921,6 +1062,7 @@ async def query_view( return {"ok": False, "error": str(error)} return { "ok": True, + "columns": [r[0] for r in results.description], "rows": [list(r) for r in results.rows], "truncated": results.truncated, } @@ -978,17 +1120,7 @@ async def query_view( output = results["_shape"] output.update(dict((k, v) for k, v in results.items() if not k.startswith("_"))) - response = Response.json(output) - - if isinstance(output, dict) and output.get("ok") is False: - # TODO: Other error codes? - - response.status_code = 400 - - if datasette.cors: - add_cors_headers(response.headers) - - return response + return output # registry = Registry( # extra_count, From bbbfdb034ca02c1aa3f02de0e8edf12d8190175c Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 26 Apr 2023 20:36:10 -0700 Subject: [PATCH 010/540] Add setuptools to dependencies Refs #2065 --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index b591869e..1139ff31 100644 --- a/setup.py +++ b/setup.py @@ -59,6 +59,7 @@ setup( "itsdangerous>=1.1", "sqlite-utils>=3.30", "asyncinject>=0.6", + "setuptools", ], entry_points=""" [console_scripts] From 3a7be0c5b1911554b7f7298ef459259f24a1a9d4 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 26 Apr 2023 21:20:38 -0700 Subject: [PATCH 011/540] Hopeful fix for Python 3.7 httpx failure, refs #2066 --- tests/test_csv.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/tests/test_csv.py b/tests/test_csv.py index c43e528b..ed83d685 100644 --- a/tests/test_csv.py +++ b/tests/test_csv.py @@ -6,6 +6,7 @@ from .fixtures import ( # noqa app_client_with_cors, app_client_with_trace, ) +import urllib.parse EXPECTED_TABLE_CSV = """id,content 1,hello @@ -154,11 +155,24 @@ async def test_csv_with_non_ascii_characters(ds_client): def test_max_csv_mb(app_client_csv_max_mb_one): + # This query deliberately generates a really long string + # should be 100*100*100*2 = roughly 2MB response = app_client_csv_max_mb_one.get( - ( - "/fixtures.csv?sql=select+'{}'+" - "from+compound_three_primary_keys&_stream=1&_size=max" - ).format("abcdefg" * 10000) + "/fixtures.csv?" + + urllib.parse.urlencode( + { + "sql": """ + select group_concat('ab', '') + from json_each(json_array({lots})), + json_each(json_array({lots})), + json_each(json_array({lots})) + """.format( + lots=", ".join(str(i) for i in range(100)) + ), + "_stream": 1, + "_size": "max", + } + ), ) # It's a 200 because we started streaming before we knew the error assert response.status == 200 From 305655c816a70fdfa729b37529d8c51490d4f4d5 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 26 Apr 2023 22:07:35 -0700 Subject: [PATCH 012/540] Add pip as a dependency too, for Rye - refs #2065 --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 1139ff31..0eedadb2 100644 --- a/setup.py +++ b/setup.py @@ -60,6 +60,7 @@ setup( "sqlite-utils>=3.30", "asyncinject>=0.6", "setuptools", + "pip", ], entry_points=""" [console_scripts] From 01353c7ee83ba2c58b42bdc35f3b26dfc06159ac Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 7 May 2023 11:44:27 -0700 Subject: [PATCH 013/540] Build docs with 3.11 on ReadTheDocs Inspired by https://github.com/simonw/sqlite-utils/issues/540 --- .readthedocs.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index e157fb9c..5b30e75a 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -3,7 +3,7 @@ version: 2 build: os: ubuntu-20.04 tools: - python: "3.9" + python: "3.11" sphinx: configuration: docs/conf.py From 59c52b5874045d884bac8bac35a4ecbfdde4e73c Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 9 May 2023 09:24:28 -0700 Subject: [PATCH 014/540] Action: Deploy a Datasette branch preview to Vercel Closes #2070 --- .github/workflows/deploy-branch-preview.yml | 35 +++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/deploy-branch-preview.yml diff --git a/.github/workflows/deploy-branch-preview.yml b/.github/workflows/deploy-branch-preview.yml new file mode 100644 index 00000000..872aff71 --- /dev/null +++ b/.github/workflows/deploy-branch-preview.yml @@ -0,0 +1,35 @@ +name: Deploy a Datasette branch preview to Vercel + +on: + workflow_dispatch: + inputs: + branch: + description: "Branch to deploy" + required: true + type: string + +jobs: + deploy-branch-preview: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 + with: + python-version: "3.11" + - name: Install dependencies + run: | + pip install datasette-publish-vercel + - name: Deploy the preview + env: + VERCEL_TOKEN: ${{ secrets.BRANCH_PREVIEW_VERCEL_TOKEN }} + run: | + export BRANCH="${{ github.event.inputs.branch }}" + wget https://latest.datasette.io/fixtures.db + datasette publish vercel fixtures.db \ + --branch $BRANCH \ + --project "datasette-preview-$BRANCH" \ + --token $VERCEL_TOKEN \ + --scope datasette \ + --about "Preview of $BRANCH" \ + --about_url "https://github.com/simonw/datasette/tree/$BRANCH" From 3f39fba7ea74e54673cce8a71d6c1baeaa84c513 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 25 May 2023 11:35:34 -0700 Subject: [PATCH 015/540] datasette.utils.check_callable(obj) - refs #2078 --- datasette/utils/callable.py | 25 ++++++++++++++++++++ tests/test_utils_callable.py | 46 ++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 datasette/utils/callable.py create mode 100644 tests/test_utils_callable.py diff --git a/datasette/utils/callable.py b/datasette/utils/callable.py new file mode 100644 index 00000000..5b8a30ac --- /dev/null +++ b/datasette/utils/callable.py @@ -0,0 +1,25 @@ +import asyncio +import types +from typing import NamedTuple, Any + + +class CallableStatus(NamedTuple): + is_callable: bool + is_async_callable: bool + + +def check_callable(obj: Any) -> CallableStatus: + if not callable(obj): + return CallableStatus(False, False) + + if isinstance(obj, type): + # It's a class + return CallableStatus(True, False) + + if isinstance(obj, types.FunctionType): + return CallableStatus(True, asyncio.iscoroutinefunction(obj)) + + if hasattr(obj, "__call__"): + return CallableStatus(True, asyncio.iscoroutinefunction(obj.__call__)) + + assert False, "obj {} is somehow callable with no __call__ method".format(repr(obj)) diff --git a/tests/test_utils_callable.py b/tests/test_utils_callable.py new file mode 100644 index 00000000..d1d0aac5 --- /dev/null +++ b/tests/test_utils_callable.py @@ -0,0 +1,46 @@ +from datasette.utils.callable import check_callable +import pytest + + +class AsyncClass: + async def __call__(self): + pass + + +class NotAsyncClass: + def __call__(self): + pass + + +class ClassNoCall: + pass + + +async def async_func(): + pass + + +def non_async_func(): + pass + + +@pytest.mark.parametrize( + "obj,expected_is_callable,expected_is_async_callable", + ( + (async_func, True, True), + (non_async_func, True, False), + (AsyncClass(), True, True), + (NotAsyncClass(), True, False), + (ClassNoCall(), False, False), + (AsyncClass, True, False), + (NotAsyncClass, True, False), + (ClassNoCall, True, False), + ("", False, False), + (1, False, False), + (str, True, False), + ), +) +def test_check_callable(obj, expected_is_callable, expected_is_async_callable): + status = check_callable(obj) + assert status.is_callable == expected_is_callable + assert status.is_async_callable == expected_is_async_callable From 080577106167046428c946c9978631dd59e10181 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 25 May 2023 11:49:40 -0700 Subject: [PATCH 016/540] Rename callable.py to check_callable.py, refs #2078 --- datasette/utils/{callable.py => check_callable.py} | 0 tests/{test_utils_callable.py => test_utils_check_callable.py} | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename datasette/utils/{callable.py => check_callable.py} (100%) rename tests/{test_utils_callable.py => test_utils_check_callable.py} (94%) diff --git a/datasette/utils/callable.py b/datasette/utils/check_callable.py similarity index 100% rename from datasette/utils/callable.py rename to datasette/utils/check_callable.py diff --git a/tests/test_utils_callable.py b/tests/test_utils_check_callable.py similarity index 94% rename from tests/test_utils_callable.py rename to tests/test_utils_check_callable.py index d1d0aac5..4f72f9ff 100644 --- a/tests/test_utils_callable.py +++ b/tests/test_utils_check_callable.py @@ -1,4 +1,4 @@ -from datasette.utils.callable import check_callable +from datasette.utils.check_callable import check_callable import pytest From 94882aa72b5ef4afb267447de27f0ec2140400d8 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 25 May 2023 15:05:58 -0700 Subject: [PATCH 017/540] --cors Access-Control-Max-Age: 3600, closes #2079 --- datasette/utils/__init__.py | 1 + docs/json_api.rst | 18 +++++++++++++++++- tests/test_api.py | 2 ++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py index 925c6560..c388673d 100644 --- a/datasette/utils/__init__.py +++ b/datasette/utils/__init__.py @@ -1141,6 +1141,7 @@ def add_cors_headers(headers): headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type" headers["Access-Control-Expose-Headers"] = "Link" headers["Access-Control-Allow-Methods"] = "GET, POST, HEAD, OPTIONS" + headers["Access-Control-Max-Age"] = "3600" _TILDE_ENCODING_SAFE = frozenset( diff --git a/docs/json_api.rst b/docs/json_api.rst index 7b130c58..c273c2a8 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -454,12 +454,28 @@ Enabling CORS ------------- If you start Datasette with the ``--cors`` option, each JSON endpoint will be -served with the following additional HTTP headers:: +served with the following additional HTTP headers: + +.. [[[cog + from datasette.utils import add_cors_headers + import textwrap + headers = {} + add_cors_headers(headers) + output = "\n".join("{}: {}".format(k, v) for k, v in headers.items()) + cog.out("\n::\n\n") + cog.out(textwrap.indent(output, ' ')) + cog.out("\n\n") +.. ]]] + +:: Access-Control-Allow-Origin: * Access-Control-Allow-Headers: Authorization, Content-Type Access-Control-Expose-Headers: Link Access-Control-Allow-Methods: GET, POST, HEAD, OPTIONS + Access-Control-Max-Age: 3600 + +.. [[[end]]] This allows JavaScript running on any domain to make cross-origin requests to interact with the Datasette API. diff --git a/tests/test_api.py b/tests/test_api.py index e700ad7b..e7d8d849 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -936,6 +936,7 @@ def test_cors( assert ( response.headers["Access-Control-Allow-Methods"] == "GET, POST, HEAD, OPTIONS" ) + assert response.headers["Access-Control-Max-Age"] == "3600" # Same request to app_client_two_attached_databases_one_immutable # should not have those headers - I'm using that fixture because # regular app_client doesn't have immutable fixtures.db which means @@ -946,6 +947,7 @@ def test_cors( assert "Access-Control-Allow-Headers" not in response.headers assert "Access-Control-Expose-Headers" not in response.headers assert "Access-Control-Allow-Methods" not in response.headers + assert "Access-Control-Max-Age" not in response.headers @pytest.mark.parametrize( From 8ea00e038d7d43d173cebdca21a31e774ff33f1c Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 25 May 2023 17:18:43 -0700 Subject: [PATCH 018/540] New View base class (#2080) * New View base class, closes #2078 * Use new View subclass for PatternPortfolioView --- datasette/app.py | 40 +++++++++++++++++- datasette/views/base.py | 37 +++++++++++++++++ datasette/views/special.py | 19 +++++---- tests/test_base_view.py | 84 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 170 insertions(+), 10 deletions(-) create mode 100644 tests/test_base_view.py diff --git a/datasette/app.py b/datasette/app.py index 966a6faf..8bc6518f 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -17,6 +17,7 @@ import secrets import sys import threading import time +import types import urllib.parse from concurrent import futures from pathlib import Path @@ -1366,7 +1367,7 @@ class Datasette: r"/-/allow-debug$", ) add_route( - PatternPortfolioView.as_view(self), + wrap_view(PatternPortfolioView, self), r"/-/patterns$", ) add_route(DatabaseDownload.as_view(self), r"/(?P[^\/\.]+)\.db$") @@ -1682,7 +1683,42 @@ def _cleaner_task_str(task): return _cleaner_task_str_re.sub("", s) -def wrap_view(view_fn, datasette): +def wrap_view(view_fn_or_class, datasette): + is_function = isinstance(view_fn_or_class, types.FunctionType) + if is_function: + return wrap_view_function(view_fn_or_class, datasette) + else: + if not isinstance(view_fn_or_class, type): + raise ValueError("view_fn_or_class must be a function or a class") + return wrap_view_class(view_fn_or_class, datasette) + + +def wrap_view_class(view_class, datasette): + async def async_view_for_class(request, send): + instance = view_class() + if inspect.iscoroutinefunction(instance.__call__): + return await async_call_with_supported_arguments( + instance.__call__, + scope=request.scope, + receive=request.receive, + send=send, + request=request, + datasette=datasette, + ) + else: + return call_with_supported_arguments( + instance.__call__, + scope=request.scope, + receive=request.receive, + send=send, + request=request, + datasette=datasette, + ) + + return async_view_for_class + + +def wrap_view_function(view_fn, datasette): @functools.wraps(view_fn) async def async_view_fn(request, send): if inspect.iscoroutinefunction(view_fn): diff --git a/datasette/views/base.py b/datasette/views/base.py index 927d1aff..94645cd8 100644 --- a/datasette/views/base.py +++ b/datasette/views/base.py @@ -53,6 +53,43 @@ class DatasetteError(Exception): self.message_is_html = message_is_html +class View: + async def head(self, request, datasette): + if not hasattr(self, "get"): + return await self.method_not_allowed(request) + response = await self.get(request, datasette) + response.body = "" + return response + + async def method_not_allowed(self, request): + if ( + request.path.endswith(".json") + or request.headers.get("content-type") == "application/json" + ): + response = Response.json( + {"ok": False, "error": "Method not allowed"}, status=405 + ) + else: + response = Response.text("Method not allowed", status=405) + return response + + async def options(self, request, datasette): + response = Response.text("ok") + response.headers["allow"] = ", ".join( + method.upper() + for method in ("head", "get", "post", "put", "patch", "delete") + if hasattr(self, method) + ) + return response + + async def __call__(self, request, datasette): + try: + handler = getattr(self, request.method.lower()) + except AttributeError: + return await self.method_not_allowed(request) + return await handler(request, datasette) + + class BaseView: ds = None has_json_alternate = True diff --git a/datasette/views/special.py b/datasette/views/special.py index 1aeb4be6..03e085d6 100644 --- a/datasette/views/special.py +++ b/datasette/views/special.py @@ -6,7 +6,7 @@ from datasette.utils import ( tilde_encode, tilde_decode, ) -from .base import BaseView +from .base import BaseView, View import secrets import urllib @@ -57,13 +57,16 @@ class JsonDataView(BaseView): ) -class PatternPortfolioView(BaseView): - name = "patterns" - has_json_alternate = False - - async def get(self, request): - await self.ds.ensure_permissions(request.actor, ["view-instance"]) - return await self.render(["patterns.html"], request=request) +class PatternPortfolioView(View): + async def get(self, request, datasette): + await datasette.ensure_permissions(request.actor, ["view-instance"]) + return Response.html( + await datasette.render_template( + "patterns.html", + request=request, + view_name="patterns", + ) + ) class AuthTokenView(BaseView): diff --git a/tests/test_base_view.py b/tests/test_base_view.py new file mode 100644 index 00000000..2cd4d601 --- /dev/null +++ b/tests/test_base_view.py @@ -0,0 +1,84 @@ +from datasette.views.base import View +from datasette import Request, Response +from datasette.app import Datasette +import json +import pytest + + +class GetView(View): + async def get(self, request, datasette): + return Response.json( + { + "absolute_url": datasette.absolute_url(request, "/"), + "request_path": request.path, + } + ) + + +class GetAndPostView(GetView): + async def post(self, request, datasette): + return Response.json( + { + "method": request.method, + "absolute_url": datasette.absolute_url(request, "/"), + "request_path": request.path, + } + ) + + +@pytest.mark.asyncio +async def test_get_view(): + v = GetView() + datasette = Datasette() + response = await v(Request.fake("/foo"), datasette) + assert json.loads(response.body) == { + "absolute_url": "http://localhost/", + "request_path": "/foo", + } + # Try a HEAD request + head_response = await v(Request.fake("/foo", method="HEAD"), datasette) + assert head_response.body == "" + assert head_response.status == 200 + # And OPTIONS + options_response = await v(Request.fake("/foo", method="OPTIONS"), datasette) + assert options_response.body == "ok" + assert options_response.status == 200 + assert options_response.headers["allow"] == "HEAD, GET" + # And POST + post_response = await v(Request.fake("/foo", method="POST"), datasette) + assert post_response.body == "Method not allowed" + assert post_response.status == 405 + # And POST with .json extension + post_json_response = await v(Request.fake("/foo.json", method="POST"), datasette) + assert json.loads(post_json_response.body) == { + "ok": False, + "error": "Method not allowed", + } + assert post_json_response.status == 405 + + +@pytest.mark.asyncio +async def test_post_view(): + v = GetAndPostView() + datasette = Datasette() + response = await v(Request.fake("/foo"), datasette) + assert json.loads(response.body) == { + "absolute_url": "http://localhost/", + "request_path": "/foo", + } + # Try a HEAD request + head_response = await v(Request.fake("/foo", method="HEAD"), datasette) + assert head_response.body == "" + assert head_response.status == 200 + # And OPTIONS + options_response = await v(Request.fake("/foo", method="OPTIONS"), datasette) + assert options_response.body == "ok" + assert options_response.status == 200 + assert options_response.headers["allow"] == "HEAD, GET, POST" + # And POST + post_response = await v(Request.fake("/foo", method="POST"), datasette) + assert json.loads(post_response.body) == { + "method": "POST", + "absolute_url": "http://localhost/", + "request_path": "/foo", + } From d1d78ec0ebe34463eb643ac1eae1dee4c62a2031 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 23 Jun 2023 13:06:35 -0700 Subject: [PATCH 019/540] Better docs for startup() hook --- docs/plugin_hooks.rst | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index a4c9d98f..97306529 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -869,7 +869,9 @@ Examples: `datasette-cors `__, `dat startup(datasette) ------------------ -This hook fires when the Datasette application server first starts up. You can implement a regular function, for example to validate required plugin configuration: +This hook fires when the Datasette application server first starts up. + +Here is an example that validates required plugin configuration. The server will fail to start and show an error if the validation check fails: .. code-block:: python @@ -880,7 +882,7 @@ This hook fires when the Datasette application server first starts up. You can i "required-setting" in config ), "my-plugin requires setting required-setting" -Or you can return an async function which will be awaited on startup. Use this option if you need to make any database queries: +You can also return an async function, which will be awaited on startup. Use this option if you need to execute any database queries, for example this function which creates the ``my_table`` database table if it does not yet exist: .. code-block:: python From ede62036180993dbd9d4e5d280fc21c183cda1c3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 29 Jun 2023 07:31:54 -0700 Subject: [PATCH 020/540] Bump blacken-docs from 1.13.0 to 1.14.0 (#2083) Bumps [blacken-docs](https://github.com/asottile/blacken-docs) from 1.13.0 to 1.14.0. - [Changelog](https://github.com/adamchainz/blacken-docs/blob/main/CHANGELOG.rst) - [Commits](https://github.com/asottile/blacken-docs/compare/1.13.0...1.14.0) --- updated-dependencies: - dependency-name: blacken-docs dependency-type: direct:development update-type: version-update:semver-minor ... 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 d6824255..3874d892 100644 --- a/setup.py +++ b/setup.py @@ -82,7 +82,7 @@ setup( "pytest-asyncio>=0.17", "beautifulsoup4>=4.8.1", "black==23.3.0", - "blacken-docs==1.13.0", + "blacken-docs==1.14.0", "pytest-timeout>=1.4.2", "trustme>=0.7", "cogapp>=3.3.0", From d45a7213eddd48ea75d1c021377e5a237b095833 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 29 Jun 2023 07:43:01 -0700 Subject: [PATCH 021/540] codespell>=2.5.5, also spellcheck README - refs #2089 --- .github/workflows/spellcheck.yml | 1 + setup.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/spellcheck.yml b/.github/workflows/spellcheck.yml index a2621ecc..6bf72f9d 100644 --- a/.github/workflows/spellcheck.yml +++ b/.github/workflows/spellcheck.yml @@ -26,5 +26,6 @@ jobs: pip install -e '.[docs]' - name: Check spelling run: | + codespell README.md --ignore-words docs/codespell-ignore-words.txt 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/setup.py b/setup.py index 3874d892..aadae4d3 100644 --- a/setup.py +++ b/setup.py @@ -72,7 +72,7 @@ setup( "Sphinx==6.1.3", "furo==2023.3.27", "sphinx-autobuild", - "codespell", + "codespell>=2.2.5", "blacken-docs", "sphinx-copybutton", ], From 84b32b447ae93d4d9cc589f568af16fa519b8863 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 29 Jun 2023 07:44:10 -0700 Subject: [PATCH 022/540] Justfile I use for local development Now with codespell, refs #2089 --- Justfile | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 Justfile diff --git a/Justfile b/Justfile new file mode 100644 index 00000000..e595a266 --- /dev/null +++ b/Justfile @@ -0,0 +1,41 @@ +export DATASETTE_SECRET := "not_a_secret" + +# Run tests and linters +@default: test lint + +# Setup project +@init: + pipenv run pip install -e '.[test,docs]' + +# Run pytest with supplied options +@test *options: + pipenv run pytest {{options}} + +@codespell: + pipenv run codespell README.md --ignore-words docs/codespell-ignore-words.txt + pipenv run codespell docs/*.rst --ignore-words docs/codespell-ignore-words.txt + pipenv run codespell datasette -S datasette/static --ignore-words docs/codespell-ignore-words.txt + +# Run linters: black, flake8, mypy, cog +@lint: + pipenv run black . --check + pipenv run flake8 + pipenv run cog --check README.md docs/*.rst + +# Rebuild docs with cog +@cog: + pipenv run cog -r README.md docs/*.rst + +# Serve live docs on localhost:8000 +@docs: cog + pipenv run blacken-docs -l 60 docs/*.rst + cd docs && pipenv run make livehtml + +# Apply Black +@black: + pipenv run black . + +@serve: + pipenv run sqlite-utils create-database data.db + pipenv run sqlite-utils create-table data.db docs id integer title text --pk id --ignore + pipenv run python -m datasette data.db --root --reload From 99ba05118891db9dc30f1dca22ad6709775560de Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 29 Jun 2023 07:46:22 -0700 Subject: [PATCH 023/540] Fixed spelling error, refs #2089 Also ensure codespell runs as part of just lint --- Justfile | 2 +- docs/metadata.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Justfile b/Justfile index e595a266..d349ec51 100644 --- a/Justfile +++ b/Justfile @@ -17,7 +17,7 @@ export DATASETTE_SECRET := "not_a_secret" pipenv run codespell datasette -S datasette/static --ignore-words docs/codespell-ignore-words.txt # Run linters: black, flake8, mypy, cog -@lint: +@lint: codespell pipenv run black . --check pipenv run flake8 pipenv run cog --check README.md docs/*.rst diff --git a/docs/metadata.rst b/docs/metadata.rst index 35b8aede..5932cc3a 100644 --- a/docs/metadata.rst +++ b/docs/metadata.rst @@ -189,7 +189,7 @@ Or use ``"sort_desc"`` to sort in descending order: Setting a custom page size -------------------------- -Datasette defaults to displaing 100 rows per page, for both tables and views. You can change this default page size on a per-table or per-view basis using the ``"size"`` key in ``metadata.json``: +Datasette defaults to displaying 100 rows per page, for both tables and views. You can change this default page size on a per-table or per-view basis using the ``"size"`` key in ``metadata.json``: .. code-block:: json From c39d600aef5c085e891917db04fc97511d2e2259 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 29 Jun 2023 08:05:24 -0700 Subject: [PATCH 024/540] Fix all E741 Ambiguous variable name warnings, refs #2090 --- tests/test_plugins.py | 8 +++++--- tests/test_table_html.py | 4 ++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 71b710f9..6971bbf7 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -115,7 +115,9 @@ async def test_hook_extra_css_urls(ds_client, path, expected_decoded_object): assert response.status_code == 200 links = Soup(response.text, "html.parser").findAll("link") special_href = [ - l for l in links if l.attrs["href"].endswith("/extra-css-urls-demo.css") + link + for link in links + if link.attrs["href"].endswith("/extra-css-urls-demo.css") ][0]["href"] # This link has a base64-encoded JSON blob in it encoded = special_href.split("/")[3] @@ -543,7 +545,7 @@ async def test_hook_register_output_renderer_can_render(ds_client): .find("p", {"class": "export-links"}) .findAll("a") ) - actual = [l["href"] for l in links] + actual = [link["href"] for link in links] # Should not be present because we sent ?_no_can_render=1 assert "/fixtures/facetable.testall?_labels=on" not in actual # Check that it was passed the values we expected @@ -940,7 +942,7 @@ async def test_hook_table_actions(ds_client, table_or_view): response_2 = await ds_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"] + get_table_actions_links(response_2.text), key=lambda link: link["label"] ) == [ {"label": "Database: fixtures", "href": "/"}, {"label": "From async BOB", "href": "/"}, diff --git a/tests/test_table_html.py b/tests/test_table_html.py index e1886dab..c4c7878c 100644 --- a/tests/test_table_html.py +++ b/tests/test_table_html.py @@ -481,7 +481,7 @@ async def test_table_csv_json_export_interface(ds_client): .find("p", {"class": "export-links"}) .findAll("a") ) - actual = [l["href"] for l in links] + actual = [link["href"] for link in links] expected = [ "/fixtures/simple_primary_key.json?id__gt=2", "/fixtures/simple_primary_key.testall?id__gt=2", @@ -521,7 +521,7 @@ async def test_csv_json_export_links_include_labels_if_foreign_keys(ds_client): .find("p", {"class": "export-links"}) .findAll("a") ) - actual = [l["href"] for l in links] + actual = [link["href"] for link in links] expected = [ "/fixtures/facetable.json?_labels=on", "/fixtures/facetable.testall?_labels=on", From 8cd60fd1d899952f1153460469b3175465f33f80 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 29 Jun 2023 08:24:09 -0700 Subject: [PATCH 025/540] Homepage test now just asserts isinstance(x, int) - closes #2092 --- tests/test_api.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/tests/test_api.py b/tests/test_api.py index 247fdd5c..40a3e2b8 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -32,14 +32,12 @@ async def test_homepage(ds_client): assert data.keys() == {"fixtures": 0}.keys() d = data["fixtures"] assert d["name"] == "fixtures" - assert d["tables_count"] == 24 - assert len(d["tables_and_views_truncated"]) == 5 + assert isinstance(d["tables_count"], int) + assert isinstance(len(d["tables_and_views_truncated"]), int) assert d["tables_and_views_more"] is True - # 4 hidden FTS tables + no_primary_key (hidden in metadata) - assert d["hidden_tables_count"] == 6 - # 201 in no_primary_key, plus 6 in other hidden tables: - assert d["hidden_table_rows_sum"] == 207, data - assert d["views_count"] == 4 + assert isinstance(d["hidden_tables_count"], int) + assert isinstance(d["hidden_table_rows_sum"], int) + assert isinstance(d["views_count"], int) @pytest.mark.asyncio From d7b21a862335d3765247d84e5afe778f83eb69ee Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 8 Jul 2023 09:37:01 -0700 Subject: [PATCH 026/540] metadata.yaml now treated as default in docs Added sphinx-inline-tabs to provide JSON and YAML tabs to show examples. Refs #1153 --- docs/conf.py | 7 +- docs/index.rst | 14 +- docs/metadata.rst | 483 ++++++++++++++++++++++++++++++++++++++++------ setup.py | 1 + 4 files changed, 445 insertions(+), 60 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index c25d8a95..67f7fd11 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -31,7 +31,12 @@ # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. -extensions = ["sphinx.ext.extlinks", "sphinx.ext.autodoc", "sphinx_copybutton"] +extensions = [ + "sphinx.ext.extlinks", + "sphinx.ext.autodoc", + "sphinx_copybutton", + "sphinx_inline_tabs", +] extlinks = { "issue": ("https://github.com/simonw/datasette/issues/%s", "#%s"), diff --git a/docs/index.rst b/docs/index.rst index 5a9cc7ed..c10ed5a1 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -29,7 +29,19 @@ Datasette is aimed at data journalists, museum curators, archivists, local gover Interested in learning Datasette? Start with `the official tutorials `__. -Support questions, feedback? Join our `GitHub Discussions forum `__. +Support questions, feedback? Join the `Datasette Discord `__. + +.. tab:: macOS/Linux/other Unix + + .. code-block:: shell + + python -m pip install webcolors + +.. tab:: Windows + + .. code-block:: shell + + py -m pip install webcolors Contents -------- diff --git a/docs/metadata.rst b/docs/metadata.rst index 5932cc3a..6f17056f 100644 --- a/docs/metadata.rst +++ b/docs/metadata.rst @@ -4,27 +4,56 @@ Metadata ======== Data loves metadata. Any time you run Datasette you can optionally include a -JSON file with metadata about your databases and tables. Datasette will then +YAML or JSON file with metadata about your databases and tables. Datasette will then display that information in the web UI. Run Datasette like this:: - datasette database1.db database2.db --metadata metadata.json + datasette database1.db database2.db --metadata metadata.yaml -Your ``metadata.json`` file can look something like this: +Your ``metadata.yaml`` file can look something like this: -.. code-block:: json - { +.. [[[cog + from metadata_doc import metadata_example + metadata_example(cog, { "title": "Custom title for your index page", "description": "Some description text can go here", "license": "ODbL", "license_url": "https://opendatacommons.org/licenses/odbl/", "source": "Original Data Source", "source_url": "http://example.com/" - } + }) +.. ]]] -You can optionally use YAML instead of JSON, see :ref:`metadata_yaml`. +.. tab:: YAML + + .. code-block:: yaml + + description: Some description text can go here + license: ODbL + license_url: https://opendatacommons.org/licenses/odbl/ + source: Original Data Source + source_url: http://example.com/ + title: Custom title for your index page + + +.. tab:: JSON + + .. code-block:: json + + { + "title": "Custom title for your index page", + "description": "Some description text can go here", + "license": "ODbL", + "license_url": "https://opendatacommons.org/licenses/odbl/", + "source": "Original Data Source", + "source_url": "http://example.com/" + } +.. [[[end]]] + + +Choosing YAML over JSON adds support for multi-line strings and comments, see :ref:`metadata_yaml`. The above metadata will be displayed on the index page of your Datasette-powered site. The source and license information will also be included in the footer of @@ -37,15 +66,15 @@ instead. Per-database and per-table metadata ----------------------------------- -Metadata at the top level of the JSON will be shown on the index page and in the +Metadata at the top level of the file will be shown on the index page and in the footer on every page of the site. The license and source is expected to apply to all of your data. You can also provide metadata at the per-database or per-table level, like this: -.. code-block:: json - - { +.. [[[cog + from metadata_doc import metadata_example + metadata_example(cog, { "databases": { "database1": { "source": "Alternative source", @@ -59,7 +88,45 @@ You can also provide metadata at the per-database or per-table level, like this: } } } - } + }) +.. ]]] + +.. tab:: YAML + + .. code-block:: yaml + + databases: + database1: + source: Alternative source + source_url: http://example.com/ + tables: + example_table: + description_html: Custom table description + license: CC BY 3.0 US + license_url: https://creativecommons.org/licenses/by/3.0/us/ + + +.. tab:: JSON + + .. code-block:: json + + { + "databases": { + "database1": { + "source": "Alternative source", + "source_url": "http://example.com/", + "tables": { + "example_table": { + "description_html": "Custom table description", + "license": "CC BY 3.0 US", + "license_url": "https://creativecommons.org/licenses/by/3.0/us/" + } + } + } + } + } +.. [[[end]]] + Each of the top-level metadata fields can be used at the database and table level. @@ -85,9 +152,9 @@ 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 - - { +.. [[[cog + from metadata_doc import metadata_example + metadata_example(cog, { "databases": { "database1": { "tables": { @@ -100,7 +167,41 @@ You can include descriptions for your columns by adding a ``"columns": {"name-of } } } - } + }) +.. ]]] + +.. tab:: YAML + + .. code-block:: yaml + + databases: + database1: + tables: + example_table: + columns: + column1: Description of column 1 + column2: Description of column 2 + + +.. tab:: JSON + + .. code-block:: json + + { + "databases": { + "database1": { + "tables": { + "example_table": { + "columns": { + "column1": "Description of column 1", + "column2": "Description of column 2" + } + } + } + } + } + } +.. [[[end]]] These will be displayed at the top of the table page, and will also show in the cog menu for each column. @@ -114,9 +215,9 @@ values from that column. SI prefixes will be used where appropriate. Column units are configured in the metadata like so: -.. code-block:: json - - { +.. [[[cog + from metadata_doc import metadata_example + metadata_example(cog, { "databases": { "database1": { "tables": { @@ -129,19 +230,74 @@ Column units are configured in the metadata like so: } } } - } + }) +.. ]]] + +.. tab:: YAML + + .. code-block:: yaml + + databases: + database1: + tables: + example_table: + units: + column1: metres + column2: Hz + + +.. tab:: JSON + + .. code-block:: json + + { + "databases": { + "database1": { + "tables": { + "example_table": { + "units": { + "column1": "metres", + "column2": "Hz" + } + } + } + } + } + } +.. [[[end]]] + Units are interpreted using Pint_, and you can see the full list of available units in Pint's `unit registry`_. You can also add `custom units`_ to the metadata, which will be registered with Pint: -.. code-block:: json - - { +.. [[[cog + from metadata_doc import metadata_example + metadata_example(cog, { "custom_units": [ "decibel = [] = dB" ] - } + }) +.. ]]] + +.. tab:: YAML + + .. code-block:: yaml + + custom_units: + - decibel = [] = dB + + +.. tab:: JSON + + .. code-block:: json + + { + "custom_units": [ + "decibel = [] = dB" + ] + } +.. [[[end]]] .. _Pint: https://pint.readthedocs.io/ .. _unit registry: https://github.com/hgrecco/pint/blob/master/pint/default_en.txt @@ -154,9 +310,9 @@ Setting a default sort order By default Datasette tables are sorted by primary key. You can over-ride this default for a specific table using the ``"sort"`` or ``"sort_desc"`` metadata properties: -.. code-block:: json - - { +.. [[[cog + from metadata_doc import metadata_example + metadata_example(cog, { "databases": { "mydatabase": { "tables": { @@ -166,13 +322,42 @@ By default Datasette tables are sorted by primary key. You can over-ride this de } } } - } + }) +.. ]]] + +.. tab:: YAML + + .. code-block:: yaml + + databases: + mydatabase: + tables: + example_table: + sort: created + + +.. tab:: JSON + + .. code-block:: json + + { + "databases": { + "mydatabase": { + "tables": { + "example_table": { + "sort": "created" + } + } + } + } + } +.. [[[end]]] Or use ``"sort_desc"`` to sort in descending order: -.. code-block:: json - - { +.. [[[cog + from metadata_doc import metadata_example + metadata_example(cog, { "databases": { "mydatabase": { "tables": { @@ -182,7 +367,36 @@ Or use ``"sort_desc"`` to sort in descending order: } } } - } + }) +.. ]]] + +.. tab:: YAML + + .. code-block:: yaml + + databases: + mydatabase: + tables: + example_table: + sort_desc: created + + +.. tab:: JSON + + .. code-block:: json + + { + "databases": { + "mydatabase": { + "tables": { + "example_table": { + "sort_desc": "created" + } + } + } + } + } +.. [[[end]]] .. _metadata_page_size: @@ -191,9 +405,9 @@ Setting a custom page size Datasette defaults to displaying 100 rows per page, for both tables and views. You can change this default page size on a per-table or per-view basis using the ``"size"`` key in ``metadata.json``: -.. code-block:: json - - { +.. [[[cog + from metadata_doc import metadata_example + metadata_example(cog, { "databases": { "mydatabase": { "tables": { @@ -203,7 +417,36 @@ Datasette defaults to displaying 100 rows per page, for both tables and views. Y } } } - } + }) +.. ]]] + +.. tab:: YAML + + .. code-block:: yaml + + databases: + mydatabase: + tables: + example_table: + size: 10 + + +.. tab:: JSON + + .. code-block:: json + + { + "databases": { + "mydatabase": { + "tables": { + "example_table": { + "size": 10 + } + } + } + } + } +.. [[[end]]] This size can still be over-ridden by passing e.g. ``?_size=50`` in the query string. @@ -216,9 +459,9 @@ Datasette allows any column to be used for sorting by default. If you need to control which columns are available for sorting you can do so using the optional ``sortable_columns`` key: -.. code-block:: json - - { +.. [[[cog + from metadata_doc import metadata_example + metadata_example(cog, { "databases": { "database1": { "tables": { @@ -231,7 +474,41 @@ control which columns are available for sorting you can do so using the optional } } } - } + }) +.. ]]] + +.. tab:: YAML + + .. code-block:: yaml + + databases: + database1: + tables: + example_table: + sortable_columns: + - height + - weight + + +.. tab:: JSON + + .. code-block:: json + + { + "databases": { + "database1": { + "tables": { + "example_table": { + "sortable_columns": [ + "height", + "weight" + ] + } + } + } + } + } +.. [[[end]]] This will restrict sorting of ``example_table`` to just the ``height`` and ``weight`` columns. @@ -240,9 +517,9 @@ You can also disable sorting entirely by setting ``"sortable_columns": []`` You can use ``sortable_columns`` to enable specific sort orders for a view called ``name_of_view`` in the database ``my_database`` like so: -.. code-block:: json - - { +.. [[[cog + from metadata_doc import metadata_example + metadata_example(cog, { "databases": { "my_database": { "tables": { @@ -255,7 +532,41 @@ You can use ``sortable_columns`` to enable specific sort orders for a view calle } } } - } + }) +.. ]]] + +.. tab:: YAML + + .. code-block:: yaml + + databases: + my_database: + tables: + name_of_view: + sortable_columns: + - clicks + - impressions + + +.. tab:: JSON + + .. code-block:: json + + { + "databases": { + "my_database": { + "tables": { + "name_of_view": { + "sortable_columns": [ + "clicks", + "impressions" + ] + } + } + } + } + } +.. [[[end]]] .. _label_columns: @@ -270,9 +581,9 @@ column should be used as the link label. If your table has more than two columns you can specify which column should be used for the link label with the ``label_column`` property: -.. code-block:: json - - { +.. [[[cog + from metadata_doc import metadata_example + metadata_example(cog, { "databases": { "database1": { "tables": { @@ -282,7 +593,36 @@ used for the link label with the ``label_column`` property: } } } - } + }) +.. ]]] + +.. tab:: YAML + + .. code-block:: yaml + + databases: + database1: + tables: + example_table: + label_column: title + + +.. tab:: JSON + + .. code-block:: json + + { + "databases": { + "database1": { + "tables": { + "example_table": { + "label_column": "title" + } + } + } + } + } +.. [[[end]]] .. _metadata_hiding_tables: @@ -292,26 +632,56 @@ Hiding tables You can hide tables from the database listing view (in the same way that FTS and SpatiaLite tables are automatically hidden) using ``"hidden": true``: -.. code-block:: json - - { +.. [[[cog + from metadata_doc import metadata_example + metadata_example(cog, { "databases": { "database1": { "tables": { "example_table": { - "hidden": true + "hidden": True } } } } - } + }) +.. ]]] + +.. tab:: YAML + + .. code-block:: yaml + + databases: + database1: + tables: + example_table: + hidden: true + + +.. tab:: JSON + + .. code-block:: json + + { + "databases": { + "database1": { + "tables": { + "example_table": { + "hidden": true + } + } + } + } + } +.. [[[end]]] .. _metadata_yaml: Using YAML for metadata ----------------------- -Datasette accepts YAML as an alternative to JSON for your metadata configuration file. YAML is particularly useful for including multiline HTML and SQL strings. +Datasette accepts YAML as an alternative to JSON for your metadata configuration file. +YAML is particularly useful for including multiline HTML and SQL strings, plus inline comments. Here's an example of a ``metadata.yml`` file, re-using an example from :ref:`canned_queries`. @@ -331,6 +701,7 @@ Here's an example of a ``metadata.yml`` file, re-using an example from :ref:`can no_primary_key: hidden: true queries: + # This query provides LIKE-based search neighborhood_search: sql: |- select neighborhood, facet_cities.name, state @@ -339,7 +710,3 @@ Here's an example of a ``metadata.yml`` file, re-using an example from :ref:`can title: Search neighborhoods description_html: |-

This demonstrates basic LIKE search - -The ``metadata.yml`` file is passed to Datasette using the same ``--metadata`` option:: - - datasette fixtures.db --metadata metadata.yml diff --git a/setup.py b/setup.py index aadae4d3..be9c7fde 100644 --- a/setup.py +++ b/setup.py @@ -75,6 +75,7 @@ setup( "codespell>=2.2.5", "blacken-docs", "sphinx-copybutton", + "sphinx-inline-tabs", ], "test": [ "pytest>=5.2.2", From 3b336d8071fb5707bd006de1d614f701d20246a3 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 8 Jul 2023 09:37:47 -0700 Subject: [PATCH 027/540] Utility function for cog for generating YAML/JSON tabs, refs #1153 --- docs/metadata_doc.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 docs/metadata_doc.py diff --git a/docs/metadata_doc.py b/docs/metadata_doc.py new file mode 100644 index 00000000..e6c84e3b --- /dev/null +++ b/docs/metadata_doc.py @@ -0,0 +1,13 @@ +import json +import textwrap +import yaml + + +def metadata_example(cog, example): + cog.out("\n.. tab:: YAML\n\n") + cog.out(" .. code-block:: yaml\n\n") + cog.out(textwrap.indent(yaml.dump(example), " ")) + cog.out("\n\n.. tab:: JSON\n\n") + cog.out(" .. code-block:: json\n\n") + cog.out(textwrap.indent(json.dumps(example, indent=2), " ")) + cog.out("\n") From 38fcc96e67f8a77cd09d13c96f2abf7cd822ebb4 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 8 Jul 2023 10:09:26 -0700 Subject: [PATCH 028/540] Removed duplicate imports, refs #1153 --- docs/metadata.rst | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/docs/metadata.rst b/docs/metadata.rst index 6f17056f..7aff7c15 100644 --- a/docs/metadata.rst +++ b/docs/metadata.rst @@ -73,7 +73,6 @@ all of your data. You can also provide metadata at the per-database or per-table level, like this: .. [[[cog - from metadata_doc import metadata_example metadata_example(cog, { "databases": { "database1": { @@ -153,7 +152,6 @@ Column descriptions You can include descriptions for your columns by adding a ``"columns": {"name-of-column": "description-of-column"}`` block to your table metadata: .. [[[cog - from metadata_doc import metadata_example metadata_example(cog, { "databases": { "database1": { @@ -216,7 +214,6 @@ values from that column. SI prefixes will be used where appropriate. Column units are configured in the metadata like so: .. [[[cog - from metadata_doc import metadata_example metadata_example(cog, { "databases": { "database1": { @@ -272,7 +269,6 @@ Pint's `unit registry`_. You can also add `custom units`_ to the metadata, which registered with Pint: .. [[[cog - from metadata_doc import metadata_example metadata_example(cog, { "custom_units": [ "decibel = [] = dB" @@ -311,7 +307,6 @@ Setting a default sort order By default Datasette tables are sorted by primary key. You can over-ride this default for a specific table using the ``"sort"`` or ``"sort_desc"`` metadata properties: .. [[[cog - from metadata_doc import metadata_example metadata_example(cog, { "databases": { "mydatabase": { @@ -356,7 +351,6 @@ By default Datasette tables are sorted by primary key. You can over-ride this de Or use ``"sort_desc"`` to sort in descending order: .. [[[cog - from metadata_doc import metadata_example metadata_example(cog, { "databases": { "mydatabase": { @@ -406,7 +400,6 @@ Setting a custom page size Datasette defaults to displaying 100 rows per page, for both tables and views. You can change this default page size on a per-table or per-view basis using the ``"size"`` key in ``metadata.json``: .. [[[cog - from metadata_doc import metadata_example metadata_example(cog, { "databases": { "mydatabase": { @@ -460,7 +453,6 @@ control which columns are available for sorting you can do so using the optional ``sortable_columns`` key: .. [[[cog - from metadata_doc import metadata_example metadata_example(cog, { "databases": { "database1": { @@ -518,7 +510,6 @@ You can also disable sorting entirely by setting ``"sortable_columns": []`` You can use ``sortable_columns`` to enable specific sort orders for a view called ``name_of_view`` in the database ``my_database`` like so: .. [[[cog - from metadata_doc import metadata_example metadata_example(cog, { "databases": { "my_database": { @@ -582,7 +573,6 @@ If your table has more than two columns you can specify which column should be used for the link label with the ``label_column`` property: .. [[[cog - from metadata_doc import metadata_example metadata_example(cog, { "databases": { "database1": { @@ -633,7 +623,6 @@ You can hide tables from the database listing view (in the same way that FTS and SpatiaLite tables are automatically hidden) using ``"hidden": true``: .. [[[cog - from metadata_doc import metadata_example metadata_example(cog, { "databases": { "database1": { From 0183e1a72d4d93b1d9a9363f4d47fcc0b5d5849c Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 8 Jul 2023 10:26:50 -0700 Subject: [PATCH 029/540] Preserve JSON key order in YAML, refs #1153 --- docs/metadata.rst | 2 +- docs/metadata_doc.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/metadata.rst b/docs/metadata.rst index 7aff7c15..76b785fb 100644 --- a/docs/metadata.rst +++ b/docs/metadata.rst @@ -30,12 +30,12 @@ Your ``metadata.yaml`` file can look something like this: .. code-block:: yaml + title: Custom title for your index page description: Some description text can go here license: ODbL license_url: https://opendatacommons.org/licenses/odbl/ source: Original Data Source source_url: http://example.com/ - title: Custom title for your index page .. tab:: JSON diff --git a/docs/metadata_doc.py b/docs/metadata_doc.py index e6c84e3b..19ddef89 100644 --- a/docs/metadata_doc.py +++ b/docs/metadata_doc.py @@ -6,7 +6,7 @@ import yaml def metadata_example(cog, example): cog.out("\n.. tab:: YAML\n\n") cog.out(" .. code-block:: yaml\n\n") - cog.out(textwrap.indent(yaml.dump(example), " ")) + cog.out(textwrap.indent(yaml.safe_dump(example, sort_keys=False), " ")) cog.out("\n\n.. tab:: JSON\n\n") cog.out(" .. code-block:: json\n\n") cog.out(textwrap.indent(json.dumps(example, indent=2), " ")) From c076fb65e07a7957b8a45804dc8d8cb92020f0ec Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 8 Jul 2023 11:00:08 -0700 Subject: [PATCH 030/540] Applied sphinx-inline-tabs to remaining examples, refs #1153 --- docs/authentication.rst | 370 +++++++++++++++++++++++++++++++++----- docs/custom_templates.rst | 142 +++++++++++++-- docs/facets.rst | 124 +++++++++++-- docs/full_text_search.rst | 41 ++++- docs/metadata_doc.py | 20 ++- docs/plugins.rst | 153 ++++++++++++++-- docs/sql_queries.rst | 320 ++++++++++++++++++++++++++++----- setup.py | 1 + 8 files changed, 1019 insertions(+), 152 deletions(-) diff --git a/docs/authentication.rst b/docs/authentication.rst index 3878b2c9..8864086f 100644 --- a/docs/authentication.rst +++ b/docs/authentication.rst @@ -212,23 +212,63 @@ Access to an instance Here's how to restrict access to your entire Datasette instance to just the ``"id": "root"`` user: -.. code-block:: json - - { +.. [[[cog + from metadata_doc import metadata_example + metadata_example(cog, { "title": "My private Datasette instance", "allow": { "id": "root" } - } + }) +.. ]]] + +.. tab:: YAML + + .. code-block:: yaml + + title: My private Datasette instance + allow: + id: root + + +.. tab:: JSON + + .. code-block:: json + + { + "title": "My private Datasette instance", + "allow": { + "id": "root" + } + } +.. [[[end]]] To deny access to all users, you can use ``"allow": false``: -.. code-block:: json - - { +.. [[[cog + metadata_example(cog, { "title": "My entirely inaccessible instance", - "allow": false - } + "allow": False + }) +.. ]]] + +.. tab:: YAML + + .. code-block:: yaml + + title: My entirely inaccessible instance + allow: false + + +.. tab:: JSON + + .. code-block:: json + + { + "title": "My entirely inaccessible instance", + "allow": false + } +.. [[[end]]] One reason to do this is if you are using a Datasette plugin - such as `datasette-permissions-sql `__ - to control permissions instead. @@ -239,9 +279,8 @@ Access to specific databases To limit access to a specific ``private.db`` database to just authenticated users, use the ``"allow"`` block like this: -.. code-block:: json - - { +.. [[[cog + metadata_example(cog, { "databases": { "private": { "allow": { @@ -249,7 +288,33 @@ To limit access to a specific ``private.db`` database to just authenticated user } } } - } + }) +.. ]]] + +.. tab:: YAML + + .. code-block:: yaml + + databases: + private: + allow: + id: '*' + + +.. tab:: JSON + + .. code-block:: json + + { + "databases": { + "private": { + "allow": { + "id": "*" + } + } + } + } +.. [[[end]]] .. _authentication_permissions_table: @@ -258,9 +323,8 @@ Access to specific tables and views To limit access to the ``users`` table in your ``bakery.db`` database: -.. code-block:: json - - { +.. [[[cog + metadata_example(cog, { "databases": { "bakery": { "tables": { @@ -272,7 +336,39 @@ To limit access to the ``users`` table in your ``bakery.db`` database: } } } - } + }) +.. ]]] + +.. tab:: YAML + + .. code-block:: yaml + + databases: + bakery: + tables: + users: + allow: + id: '*' + + +.. tab:: JSON + + .. code-block:: json + + { + "databases": { + "bakery": { + "tables": { + "users": { + "allow": { + "id": "*" + } + } + } + } + } + } +.. [[[end]]] This works for SQL views as well - you can list their names in the ``"tables"`` block above in the same way as regular tables. @@ -290,15 +386,14 @@ Access to specific canned queries To limit access to the ``add_name`` canned query in your ``dogs.db`` database to just the :ref:`root user`: -.. code-block:: json - - { +.. [[[cog + metadata_example(cog, { "databases": { "dogs": { "queries": { "add_name": { "sql": "INSERT INTO names (name) VALUES (:name)", - "write": true, + "write": True, "allow": { "id": ["root"] } @@ -306,7 +401,46 @@ To limit access to the ``add_name`` canned query in your ``dogs.db`` database to } } } - } + }) +.. ]]] + +.. tab:: YAML + + .. code-block:: yaml + + databases: + dogs: + queries: + add_name: + sql: INSERT INTO names (name) VALUES (:name) + write: true + allow: + id: + - root + + +.. tab:: JSON + + .. code-block:: json + + { + "databases": { + "dogs": { + "queries": { + "add_name": { + "sql": "INSERT INTO names (name) VALUES (:name)", + "write": true, + "allow": { + "id": [ + "root" + ] + } + } + } + } + } + } +.. [[[end]]] .. _authentication_permissions_execute_sql: @@ -323,27 +457,61 @@ You can alternatively use an ``"allow_sql"`` block to control who is allowed to To prevent any user from executing arbitrary SQL queries, use this: -.. code-block:: json +.. [[[cog + metadata_example(cog, { + "allow_sql": False + }) +.. ]]] - { - "allow_sql": false - } +.. tab:: YAML + + .. code-block:: yaml + + allow_sql: false + + +.. tab:: JSON + + .. code-block:: json + + { + "allow_sql": false + } +.. [[[end]]] To enable just the :ref:`root user` to execute SQL for all databases in your instance, use the following: -.. code-block:: json - - { +.. [[[cog + metadata_example(cog, { "allow_sql": { "id": "root" } - } + }) +.. ]]] + +.. tab:: YAML + + .. code-block:: yaml + + allow_sql: + id: root + + +.. tab:: JSON + + .. code-block:: json + + { + "allow_sql": { + "id": "root" + } + } +.. [[[end]]] To limit this ability for just one specific database, use this: -.. code-block:: json - - { +.. [[[cog + metadata_example(cog, { "databases": { "mydatabase": { "allow_sql": { @@ -351,7 +519,33 @@ To limit this ability for just one specific database, use this: } } } - } + }) +.. ]]] + +.. tab:: YAML + + .. code-block:: yaml + + databases: + mydatabase: + allow_sql: + id: root + + +.. tab:: JSON + + .. code-block:: json + + { + "databases": { + "mydatabase": { + "allow_sql": { + "id": "root" + } + } + } + } +.. [[[end]]] .. _authentication_permissions_other: @@ -362,21 +556,42 @@ For all other permissions, you can use one or more ``"permissions"`` blocks in y To grant access to the :ref:`permissions debug tool ` to all signed in users you can grant ``permissions-debug`` to any actor with an ``id`` matching the wildcard ``*`` by adding this a the root of your metadata: -.. code-block:: json - - { +.. [[[cog + metadata_example(cog, { "permissions": { "debug-menu": { "id": "*" } } - } + }) +.. ]]] + +.. tab:: YAML + + .. code-block:: yaml + + permissions: + debug-menu: + id: '*' + + +.. tab:: JSON + + .. code-block:: json + + { + "permissions": { + "debug-menu": { + "id": "*" + } + } + } +.. [[[end]]] To grant ``create-table`` to the user with ``id`` of ``editor`` for the ``docs`` database: -.. code-block:: json - - { +.. [[[cog + metadata_example(cog, { "databases": { "docs": { "permissions": { @@ -386,13 +601,41 @@ To grant ``create-table`` to the user with ``id`` of ``editor`` for the ``docs`` } } } - } + }) +.. ]]] + +.. tab:: YAML + + .. code-block:: yaml + + databases: + docs: + permissions: + create-table: + id: editor + + +.. tab:: JSON + + .. code-block:: json + + { + "databases": { + "docs": { + "permissions": { + "create-table": { + "id": "editor" + } + } + } + } + } +.. [[[end]]] And for ``insert-row`` against the ``reports`` table in that ``docs`` database: -.. code-block:: json - - { +.. [[[cog + metadata_example(cog, { "databases": { "docs": { "tables": { @@ -406,7 +649,42 @@ And for ``insert-row`` against the ``reports`` table in that ``docs`` database: } } } - } + }) +.. ]]] + +.. tab:: YAML + + .. code-block:: yaml + + databases: + docs: + tables: + reports: + permissions: + insert-row: + id: editor + + +.. tab:: JSON + + .. code-block:: json + + { + "databases": { + "docs": { + "tables": { + "reports": { + "permissions": { + "insert-row": { + "id": "editor" + } + } + } + } + } + } + } +.. [[[end]]] The :ref:`permissions debug tool ` can be useful for helping test permissions that you have configured in this way. diff --git a/docs/custom_templates.rst b/docs/custom_templates.rst index 97dea2af..f9d0b5f5 100644 --- a/docs/custom_templates.rst +++ b/docs/custom_templates.rst @@ -12,20 +12,45 @@ Custom CSS and JavaScript When you launch Datasette, you can specify a custom metadata file like this:: - datasette mydb.db --metadata metadata.json + datasette mydb.db --metadata metadata.yaml -Your ``metadata.json`` file can include links that look like this: +Your ``metadata.yaml`` file can include links that look like this: -.. code-block:: json - - { +.. [[[cog + from metadata_doc import metadata_example + metadata_example(cog, { "extra_css_urls": [ "https://simonwillison.net/static/css/all.bf8cd891642c.css" ], "extra_js_urls": [ "https://code.jquery.com/jquery-3.2.1.slim.min.js" ] - } + }) +.. ]]] + +.. tab:: YAML + + .. code-block:: yaml + + extra_css_urls: + - https://simonwillison.net/static/css/all.bf8cd891642c.css + extra_js_urls: + - https://code.jquery.com/jquery-3.2.1.slim.min.js + + +.. tab:: JSON + + .. code-block:: json + + { + "extra_css_urls": [ + "https://simonwillison.net/static/css/all.bf8cd891642c.css" + ], + "extra_js_urls": [ + "https://code.jquery.com/jquery-3.2.1.slim.min.js" + ] + } +.. [[[end]]] The extra CSS and JavaScript files will be linked in the ```` of every page: @@ -36,9 +61,8 @@ The extra CSS and JavaScript files will be linked in the ```` of every pag You can also specify a SRI (subresource integrity hash) for these assets: -.. code-block:: json - - { +.. [[[cog + metadata_example(cog, { "extra_css_urls": [ { "url": "https://simonwillison.net/static/css/all.bf8cd891642c.css", @@ -51,7 +75,40 @@ You can also specify a SRI (subresource integrity hash) for these assets: "sri": "sha256-k2WSCIexGzOj3Euiig+TlR8gA0EmPjuc79OEeY5L45g=" } ] - } + }) +.. ]]] + +.. tab:: YAML + + .. code-block:: yaml + + extra_css_urls: + - url: https://simonwillison.net/static/css/all.bf8cd891642c.css + sri: sha384-9qIZekWUyjCyDIf2YK1FRoKiPJq4PHt6tp/ulnuuyRBvazd0hG7pWbE99zvwSznI + extra_js_urls: + - url: https://code.jquery.com/jquery-3.2.1.slim.min.js + sri: sha256-k2WSCIexGzOj3Euiig+TlR8gA0EmPjuc79OEeY5L45g= + + +.. tab:: JSON + + .. code-block:: json + + { + "extra_css_urls": [ + { + "url": "https://simonwillison.net/static/css/all.bf8cd891642c.css", + "sri": "sha384-9qIZekWUyjCyDIf2YK1FRoKiPJq4PHt6tp/ulnuuyRBvazd0hG7pWbE99zvwSznI" + } + ], + "extra_js_urls": [ + { + "url": "https://code.jquery.com/jquery-3.2.1.slim.min.js", + "sri": "sha256-k2WSCIexGzOj3Euiig+TlR8gA0EmPjuc79OEeY5L45g=" + } + ] + } +.. [[[end]]] This will produce: @@ -69,16 +126,39 @@ matches the content served. You can generate hashes using `www.srihash.org `__. This configuration: -.. code-block:: json - - { +.. [[[cog + metadata_example(cog, { "extra_js_urls": [ { "url": "https://example.datasette.io/module.js", - "module": true + "module": True } ] - } + }) +.. ]]] + +.. tab:: YAML + + .. code-block:: yaml + + extra_js_urls: + - url: https://example.datasette.io/module.js + module: true + + +.. tab:: JSON + + .. code-block:: json + + { + "extra_js_urls": [ + { + "url": "https://example.datasette.io/module.js", + "module": true + } + ] + } +.. [[[end]]] Will produce this HTML: @@ -188,16 +268,40 @@ The following URLs will now serve the content from those CSS and JS files:: You can reference those files from ``metadata.json`` like so: -.. code-block:: json - - { +.. [[[cog + metadata_example(cog, { "extra_css_urls": [ "/assets/styles.css" ], "extra_js_urls": [ "/assets/app.js" ] - } + }) +.. ]]] + +.. tab:: YAML + + .. code-block:: yaml + + extra_css_urls: + - /assets/styles.css + extra_js_urls: + - /assets/app.js + + +.. tab:: JSON + + .. code-block:: json + + { + "extra_css_urls": [ + "/assets/styles.css" + ], + "extra_js_urls": [ + "/assets/app.js" + ] + } +.. [[[end]]] Publishing static assets ~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/facets.rst b/docs/facets.rst index 5df4dbb4..dba232bf 100644 --- a/docs/facets.rst +++ b/docs/facets.rst @@ -98,16 +98,16 @@ You can increase this on an individual page by adding ``?_facet_size=100`` to th .. _facets_metadata: -Facets in metadata.json ------------------------ +Facets in metadata +------------------ You can turn facets on by default for specific tables by adding them to a ``"facets"`` key in a Datasette :ref:`metadata` file. Here's an example that turns on faceting by default for the ``qLegalStatus`` column in the ``Street_Tree_List`` table in the ``sf-trees`` database: -.. code-block:: json - - { +.. [[[cog + from metadata_doc import metadata_example + metadata_example(cog, { "databases": { "sf-trees": { "tables": { @@ -117,26 +117,82 @@ Here's an example that turns on faceting by default for the ``qLegalStatus`` col } } } - } + }) +.. ]]] + +.. tab:: YAML + + .. code-block:: yaml + + databases: + sf-trees: + tables: + Street_Tree_List: + facets: + - qLegalStatus + + +.. tab:: JSON + + .. code-block:: json + + { + "databases": { + "sf-trees": { + "tables": { + "Street_Tree_List": { + "facets": [ + "qLegalStatus" + ] + } + } + } + } + } +.. [[[end]]] Facets defined in this way will always be shown in the interface and returned in the API, regardless of the ``_facet`` arguments passed to the view. You can specify :ref:`array ` or :ref:`date ` facets in metadata using JSON objects with a single key of ``array`` or ``date`` and a value specifying the column, like this: -.. code-block:: json +.. [[[cog + metadata_example(cog, { + "facets": [ + {"array": "tags"}, + {"date": "created"} + ] + }) +.. ]]] - { - "facets": [ - {"array": "tags"}, - {"date": "created"} - ] - } +.. tab:: YAML + + .. code-block:: yaml + + facets: + - array: tags + - date: created + + +.. tab:: JSON + + .. code-block:: json + + { + "facets": [ + { + "array": "tags" + }, + { + "date": "created" + } + ] + } +.. [[[end]]] You can change the default facet size (the number of results shown for each facet) for a table using ``facet_size``: -.. code-block:: json - - { +.. [[[cog + metadata_example(cog, { "databases": { "sf-trees": { "tables": { @@ -147,7 +203,41 @@ You can change the default facet size (the number of results shown for each face } } } - } + }) +.. ]]] + +.. tab:: YAML + + .. code-block:: yaml + + databases: + sf-trees: + tables: + Street_Tree_List: + facets: + - qLegalStatus + facet_size: 10 + + +.. tab:: JSON + + .. code-block:: json + + { + "databases": { + "sf-trees": { + "tables": { + "Street_Tree_List": { + "facets": [ + "qLegalStatus" + ], + "facet_size": 10 + } + } + } + } + } +.. [[[end]]] Suggested facets ---------------- diff --git a/docs/full_text_search.rst b/docs/full_text_search.rst index 1162a7f9..c956865b 100644 --- a/docs/full_text_search.rst +++ b/docs/full_text_search.rst @@ -64,9 +64,9 @@ The ``"searchmode": "raw"`` property can be used to default the table to accepti 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 - - { +.. [[[cog + from metadata_doc import metadata_example + metadata_example(cog, { "databases": { "russian-ads": { "tables": { @@ -78,7 +78,40 @@ Here is an example which enables full-text search (with SQLite advanced search o } } } - } + }) +.. ]]] + +.. tab:: YAML + + .. code-block:: yaml + + databases: + russian-ads: + tables: + display_ads: + fts_table: ads_fts + fts_pk: id + searchmode: raw + + +.. tab:: JSON + + .. code-block:: json + + { + "databases": { + "russian-ads": { + "tables": { + "display_ads": { + "fts_table": "ads_fts", + "fts_pk": "id", + "searchmode": "raw" + } + } + } + } + } +.. [[[end]]] .. _full_text_search_custom_sql: diff --git a/docs/metadata_doc.py b/docs/metadata_doc.py index 19ddef89..537830ca 100644 --- a/docs/metadata_doc.py +++ b/docs/metadata_doc.py @@ -1,13 +1,25 @@ import json import textwrap -import yaml +from yaml import safe_dump +from ruamel.yaml import round_trip_load -def metadata_example(cog, example): +def metadata_example(cog, data=None, yaml=None): + assert data or yaml, "Must provide data= or yaml=" + assert not (data and yaml), "Cannot use data= and yaml=" + output_yaml = None + if yaml: + # dedent it first + yaml = textwrap.dedent(yaml).strip() + # round_trip_load to preserve key order: + data = round_trip_load(yaml) + output_yaml = yaml + else: + output_yaml = safe_dump(data, sort_keys=False) cog.out("\n.. tab:: YAML\n\n") cog.out(" .. code-block:: yaml\n\n") - cog.out(textwrap.indent(yaml.safe_dump(example, sort_keys=False), " ")) + cog.out(textwrap.indent(output_yaml, " ")) cog.out("\n\n.. tab:: JSON\n\n") cog.out(" .. code-block:: json\n\n") - cog.out(textwrap.indent(json.dumps(example, indent=2), " ")) + cog.out(textwrap.indent(json.dumps(data, indent=2), " ")) cog.out("\n") diff --git a/docs/plugins.rst b/docs/plugins.rst index 5e81e202..979f94dd 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -245,9 +245,9 @@ Plugins can have their own configuration, embedded in a :ref:`metadata` file. Co Here is an example of some plugin configuration for a specific table: -.. code-block:: json - - { +.. [[[cog + from metadata_doc import metadata_example + metadata_example(cog, { "databases": { "sf-trees": { "tables": { @@ -262,7 +262,44 @@ Here is an example of some plugin configuration for a specific table: } } } - } + }) +.. ]]] + +.. tab:: YAML + + .. code-block:: yaml + + databases: + sf-trees: + tables: + Street_Tree_List: + plugins: + datasette-cluster-map: + latitude_column: lat + longitude_column: lng + + +.. tab:: JSON + + .. code-block:: json + + { + "databases": { + "sf-trees": { + "tables": { + "Street_Tree_List": { + "plugins": { + "datasette-cluster-map": { + "latitude_column": "lat", + "longitude_column": "lng" + } + } + } + } + } + } + } +.. [[[end]]] This tells the ``datasette-cluster-map`` column which latitude and longitude columns should be used for a table called ``Street_Tree_List`` inside a database file called ``sf-trees.db``. @@ -271,13 +308,12 @@ This tells the ``datasette-cluster-map`` column which latitude and longitude col Secret configuration values ~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Any values embedded in ``metadata.json`` will be visible to anyone who views the ``/-/metadata`` page of your Datasette instance. Some plugins may need configuration that should stay secret - API keys for example. There are two ways in which you can store secret configuration values. +Any values embedded in ``metadata.yaml`` will be visible to anyone who views the ``/-/metadata`` page of your Datasette instance. Some plugins may need configuration that should stay secret - API keys for example. There are two ways in which you can store secret configuration values. **As environment variables**. If your secret lives in an environment variable that is available to the Datasette process, you can indicate that the configuration value should be read from that environment variable like so: -.. code-block:: json - - { +.. [[[cog + metadata_example(cog, { "plugins": { "datasette-auth-github": { "client_secret": { @@ -285,13 +321,38 @@ Any values embedded in ``metadata.json`` will be visible to anyone who views the } } } - } + }) +.. ]]] + +.. tab:: YAML + + .. code-block:: yaml + + plugins: + datasette-auth-github: + client_secret: + $env: GITHUB_CLIENT_SECRET + + +.. tab:: JSON + + .. code-block:: json + + { + "plugins": { + "datasette-auth-github": { + "client_secret": { + "$env": "GITHUB_CLIENT_SECRET" + } + } + } + } +.. [[[end]]] **As values in separate files**. Your secrets can also live in files on disk. To specify a secret should be read from a file, provide the full file path like this: -.. code-block:: json - - { +.. [[[cog + metadata_example(cog, { "plugins": { "datasette-auth-github": { "client_secret": { @@ -299,7 +360,33 @@ Any values embedded in ``metadata.json`` will be visible to anyone who views the } } } - } + }) +.. ]]] + +.. tab:: YAML + + .. code-block:: yaml + + plugins: + datasette-auth-github: + client_secret: + $file: /secrets/client-secret + + +.. tab:: JSON + + .. code-block:: json + + { + "plugins": { + "datasette-auth-github": { + "client_secret": { + "$file": "/secrets/client-secret" + } + } + } + } +.. [[[end]]] 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:: @@ -309,11 +396,10 @@ If you are publishing your data using the :ref:`datasette publish ` --plugin-secret datasette-auth-github client_id your_client_id \ --plugin-secret datasette-auth-github client_secret your_client_secret -This will set the necessary environment variables and add the following to the deployed ``metadata.json``: +This will set the necessary environment variables and add the following to the deployed ``metadata.yaml``: -.. code-block:: json - - { +.. [[[cog + metadata_example(cog, { "plugins": { "datasette-auth-github": { "client_id": { @@ -324,4 +410,35 @@ This will set the necessary environment variables and add the following to the d } } } - } + }) +.. ]]] + +.. tab:: YAML + + .. code-block:: yaml + + plugins: + datasette-auth-github: + client_id: + $env: DATASETTE_AUTH_GITHUB_CLIENT_ID + client_secret: + $env: DATASETTE_AUTH_GITHUB_CLIENT_SECRET + + +.. tab:: JSON + + .. code-block:: json + + { + "plugins": { + "datasette-auth-github": { + "client_id": { + "$env": "DATASETTE_AUTH_GITHUB_CLIENT_ID" + }, + "client_secret": { + "$env": "DATASETTE_AUTH_GITHUB_CLIENT_SECRET" + } + } + } + } +.. [[[end]]] diff --git a/docs/sql_queries.rst b/docs/sql_queries.rst index 5f53a6d7..3c2cb228 100644 --- a/docs/sql_queries.rst +++ b/docs/sql_queries.rst @@ -64,11 +64,11 @@ The quickest way to create views is with the SQLite command-line interface:: Canned queries -------------- -As an alternative to adding views to your database, you can define canned queries inside your ``metadata.json`` file. Here's an example: +As an alternative to adding views to your database, you can define canned queries inside your ``metadata.yaml`` file. Here's an example: -.. code-block:: json - - { +.. [[[cog + from metadata_doc import metadata_example + metadata_example(cog, { "databases": { "sf-trees": { "queries": { @@ -78,7 +78,36 @@ As an alternative to adding views to your database, you can define canned querie } } } - } + }) +.. ]]] + +.. tab:: YAML + + .. code-block:: yaml + + databases: + sf-trees: + queries: + just_species: + sql: select qSpecies from Street_Tree_List + + +.. tab:: JSON + + .. code-block:: json + + { + "databases": { + "sf-trees": { + "queries": { + "just_species": { + "sql": "select qSpecies from Street_Tree_List" + } + } + } + } + } +.. [[[end]]] Then run Datasette like this:: @@ -111,38 +140,58 @@ Here's an example of a canned query with a named parameter: where neighborhood like '%' || :text || '%' order by neighborhood; -In the canned query metadata (here :ref:`metadata_yaml` as ``metadata.yaml``) it looks like this: +In the canned query metadata looks like this: -.. code-block:: yaml +.. [[[cog + metadata_example(cog, yaml=""" databases: fixtures: queries: neighborhood_search: + title: Search neighborhoods sql: |- select neighborhood, facet_cities.name, state from facetable join facet_cities on facetable.city_id = facet_cities.id where neighborhood like '%' || :text || '%' order by neighborhood - title: Search neighborhoods + """) +.. ]]] -Here's the equivalent using JSON (as ``metadata.json``): +.. tab:: YAML -.. code-block:: json + .. code-block:: yaml - { - "databases": { + databases: + fixtures: + queries: + neighborhood_search: + title: Search neighborhoods + sql: |- + select neighborhood, facet_cities.name, state + from facetable + join facet_cities on facetable.city_id = facet_cities.id + where neighborhood like '%' || :text || '%' + order by neighborhood + +.. tab:: JSON + + .. code-block:: json + + { + "databases": { "fixtures": { - "queries": { - "neighborhood_search": { - "sql": "select neighborhood, facet_cities.name, state\nfrom facetable\n join facet_cities on facetable.city_id = facet_cities.id\nwhere neighborhood like '%' || :text || '%'\norder by neighborhood", - "title": "Search neighborhoods" - } + "queries": { + "neighborhood_search": { + "title": "Search neighborhoods", + "sql": "select neighborhood, facet_cities.name, state\nfrom facetable\n join facet_cities on facetable.city_id = facet_cities.id\nwhere neighborhood like '%' || :text || '%'\norder by neighborhood" } + } } + } } - } +.. [[[end]]] Note that we are using SQLite string concatenation here - the ``||`` operator - to add wildcard ``%`` characters to the string provided by the user. @@ -153,12 +202,13 @@ In this example the ``:text`` named parameter is automatically extracted from th You can alternatively provide an explicit list of named parameters using the ``"params"`` key, like this: -.. code-block:: yaml - +.. [[[cog + metadata_example(cog, yaml=""" databases: fixtures: queries: neighborhood_search: + title: Search neighborhoods params: - text sql: |- @@ -167,7 +217,47 @@ You can alternatively provide an explicit list of named parameters using the ``" join facet_cities on facetable.city_id = facet_cities.id where neighborhood like '%' || :text || '%' order by neighborhood - title: Search neighborhoods + """) +.. ]]] + +.. tab:: YAML + + .. code-block:: yaml + + databases: + fixtures: + queries: + neighborhood_search: + title: Search neighborhoods + params: + - text + sql: |- + select neighborhood, facet_cities.name, state + from facetable + join facet_cities on facetable.city_id = facet_cities.id + where neighborhood like '%' || :text || '%' + order by neighborhood + +.. tab:: JSON + + .. code-block:: json + + { + "databases": { + "fixtures": { + "queries": { + "neighborhood_search": { + "title": "Search neighborhoods", + "params": [ + "text" + ], + "sql": "select neighborhood, facet_cities.name, state\nfrom facetable\n join facet_cities on facetable.city_id = facet_cities.id\nwhere neighborhood like '%' || :text || '%'\norder by neighborhood" + } + } + } + } + } +.. [[[end]]] .. _canned_queries_options: @@ -192,21 +282,54 @@ You can set a default fragment hash that will be included in the link to the can This example demonstrates both ``fragment`` and ``hide_sql``: -.. code-block:: json +.. [[[cog + metadata_example(cog, yaml=""" + databases: + fixtures: + queries: + neighborhood_search: + fragment: fragment-goes-here + hide_sql: true + sql: |- + select neighborhood, facet_cities.name, state + from facetable join facet_cities on facetable.city_id = facet_cities.id + where neighborhood like '%' || :text || '%' order by neighborhood; + """) +.. ]]] - { - "databases": { +.. tab:: YAML + + .. code-block:: yaml + + databases: + fixtures: + queries: + neighborhood_search: + fragment: fragment-goes-here + hide_sql: true + sql: |- + select neighborhood, facet_cities.name, state + from facetable join facet_cities on facetable.city_id = facet_cities.id + where neighborhood like '%' || :text || '%' order by neighborhood; + +.. tab:: JSON + + .. code-block:: json + + { + "databases": { "fixtures": { - "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", - "hide_sql": true - } + "queries": { + "neighborhood_search": { + "fragment": "fragment-goes-here", + "hide_sql": true, + "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;" } + } } + } } - } +.. [[[end]]] `See here `__ for a demo of this in action. @@ -219,20 +342,50 @@ Canned queries by default are read-only. You can use the ``"write": true`` key t See :ref:`authentication_permissions_query` for details on how to add permission checks to canned queries, using the ``"allow"`` key. -.. code-block:: json - - { +.. [[[cog + metadata_example(cog, { "databases": { "mydatabase": { "queries": { "add_name": { "sql": "INSERT INTO names (name) VALUES (:name)", - "write": true + "write": True } } } } - } + }) +.. ]]] + +.. tab:: YAML + + .. code-block:: yaml + + databases: + mydatabase: + queries: + add_name: + sql: INSERT INTO names (name) VALUES (:name) + write: true + + +.. tab:: JSON + + .. code-block:: json + + { + "databases": { + "mydatabase": { + "queries": { + "add_name": { + "sql": "INSERT INTO names (name) VALUES (:name)", + "write": true + } + } + } + } + } +.. [[[end]]] This configuration will create a page at ``/mydatabase/add_name`` displaying a form with a ``name`` field. Submitting that form will execute the configured ``INSERT`` query. @@ -245,15 +398,14 @@ You can customize how Datasette represents success and errors using the followin For example: -.. code-block:: json - - { +.. [[[cog + metadata_example(cog, { "databases": { "mydatabase": { "queries": { "add_name": { "sql": "INSERT INTO names (name) VALUES (:name)", - "write": true, + "write": True, "on_success_message": "Name inserted", "on_success_redirect": "/mydatabase/names", "on_error_message": "Name insert failed", @@ -262,7 +414,46 @@ For example: } } } - } + }) +.. ]]] + +.. tab:: YAML + + .. code-block:: yaml + + databases: + mydatabase: + queries: + add_name: + sql: INSERT INTO names (name) VALUES (:name) + write: true + on_success_message: Name inserted + on_success_redirect: /mydatabase/names + on_error_message: Name insert failed + on_error_redirect: /mydatabase + + +.. tab:: JSON + + .. code-block:: json + + { + "databases": { + "mydatabase": { + "queries": { + "add_name": { + "sql": "INSERT INTO names (name) VALUES (:name)", + "write": true, + "on_success_message": "Name inserted", + "on_success_redirect": "/mydatabase/names", + "on_error_message": "Name insert failed", + "on_error_redirect": "/mydatabase" + } + } + } + } + } +.. [[[end]]] You can use ``"params"`` to explicitly list the named parameters that should be displayed as form fields - otherwise they will be automatically detected. @@ -300,10 +491,10 @@ Available magic parameters are: ``_random_chars_*`` - e.g. ``_random_chars_128`` A random string of characters of the specified length. -Here's an example configuration (this time using ``metadata.yaml`` since that provides better support for multi-line SQL queries) that adds a message from the authenticated user, storing various pieces of additional metadata using magic parameters: - -.. code-block:: yaml +Here's an example configuration that adds a message from the authenticated user, storing various pieces of additional metadata using magic parameters: +.. [[[cog + metadata_example(cog, yaml=""" databases: mydatabase: queries: @@ -317,6 +508,47 @@ Here's an example configuration (this time using ``metadata.yaml`` since that pr :_actor_id, :message, :_now_datetime_utc ) write: true + """) +.. ]]] + +.. tab:: YAML + + .. code-block:: yaml + + databases: + mydatabase: + queries: + add_message: + allow: + id: "*" + sql: |- + INSERT INTO messages ( + user_id, message, datetime + ) VALUES ( + :_actor_id, :message, :_now_datetime_utc + ) + write: true + +.. tab:: JSON + + .. code-block:: json + + { + "databases": { + "mydatabase": { + "queries": { + "add_message": { + "allow": { + "id": "*" + }, + "sql": "INSERT INTO messages (\n user_id, message, datetime\n) VALUES (\n :_actor_id, :message, :_now_datetime_utc\n)", + "write": true + } + } + } + } + } +.. [[[end]]] The form presented at ``/mydatabase/add_message`` will have just a field for ``message`` - the other parameters will be populated by the magic parameter mechanism. diff --git a/setup.py b/setup.py index be9c7fde..61860f43 100644 --- a/setup.py +++ b/setup.py @@ -76,6 +76,7 @@ setup( "blacken-docs", "sphinx-copybutton", "sphinx-inline-tabs", + "ruamel.yaml", ], "test": [ "pytest>=5.2.2", From 50a6355c081a79c63625d96d3d350603298da42b Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 8 Jul 2023 11:22:21 -0700 Subject: [PATCH 031/540] Workaround to get sphinx-build working again, refs 1153 --- .github/workflows/deploy-latest.yml | 2 +- .github/workflows/publish.yml | 2 +- docs/conf.py | 6 ++++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deploy-latest.yml b/.github/workflows/deploy-latest.yml index d6baa2f6..ed60376c 100644 --- a/.github/workflows/deploy-latest.yml +++ b/.github/workflows/deploy-latest.yml @@ -43,7 +43,7 @@ jobs: if: ${{ github.ref == 'refs/heads/main' }} run: |- cd docs - sphinx-build -b xml . _build + DISABLE_SPHINX_INLINE_TABS=1 sphinx-build -b xml . _build sphinx-to-sqlite ../docs.db _build cd .. - name: Set up the alternate-route demo diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b555a1eb..60f32ed6 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -84,7 +84,7 @@ jobs: - name: Build docs.db run: |- cd docs - sphinx-build -b xml . _build + DISABLE_SPHINX_INLINE_TABS=1 sphinx-build -b xml . _build sphinx-to-sqlite ../docs.db _build cd .. - name: Set up Cloud Run diff --git a/docs/conf.py b/docs/conf.py index 67f7fd11..ca0eb986 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -17,7 +17,8 @@ # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # -# import os +import os + # import sys # sys.path.insert(0, os.path.abspath('.')) @@ -35,8 +36,9 @@ extensions = [ "sphinx.ext.extlinks", "sphinx.ext.autodoc", "sphinx_copybutton", - "sphinx_inline_tabs", ] +if not os.environ.get("DISABLE_SPHINX_INLINE_TABS"): + extensions += ["sphinx_inline_tabs"] extlinks = { "issue": ("https://github.com/simonw/datasette/issues/%s", "#%s"), From 45e6d370ce3becc17ce8d06a8257bef691a3cc10 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 8 Jul 2023 11:35:15 -0700 Subject: [PATCH 032/540] Install docs dependencies for tests, refs #1153 --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 39aa8b13..d44a5751 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,7 +29,7 @@ jobs: (cd tests && gcc ext.c -fPIC -shared -o ext.so) - name: Install dependencies run: | - pip install -e '.[test]' + pip install -e '.[test,docs]' pip freeze - name: Run tests run: | From 2fd871a9063fd0e1bc60ddb11756c6954460dfc0 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 8 Jul 2023 11:40:19 -0700 Subject: [PATCH 033/540] Drop support for Python 3.7, refs #2097 --- .github/workflows/publish.yml | 2 +- .github/workflows/test.yml | 2 +- README.md | 2 +- datasette/app.py | 16 +++++++--------- docs/contributing.rst | 2 +- docs/installation.rst | 2 +- pytest.ini | 2 -- setup.py | 3 +-- 8 files changed, 13 insertions(+), 18 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 60f32ed6..64a03a77 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] + python-version: ["3.8", "3.9", "3.10", "3.11"] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d44a5751..4eab1fdb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] + python-version: ["3.8", "3.9", "3.10", "3.11"] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} diff --git a/README.md b/README.md index af95b85e..93d81ae6 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ You can also install it using `pip` or `pipx`: pip install datasette -Datasette requires Python 3.7 or higher. We also have [detailed installation instructions](https://docs.datasette.io/en/stable/installation.html) covering other options such as Docker. +Datasette requires Python 3.8 or higher. We also have [detailed installation instructions](https://docs.datasette.io/en/stable/installation.html) covering other options such as Docker. ## Basic usage diff --git a/datasette/app.py b/datasette/app.py index 1f80c5a9..977839e0 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -1072,15 +1072,13 @@ class Datasette: {"name": t.name, "ident": t.ident, "daemon": t.daemon} for t in threads ], } - # Only available in Python 3.7+ - if hasattr(asyncio, "all_tasks"): - tasks = asyncio.all_tasks() - d.update( - { - "num_tasks": len(tasks), - "tasks": [_cleaner_task_str(t) for t in tasks], - } - ) + tasks = asyncio.all_tasks() + d.update( + { + "num_tasks": len(tasks), + "tasks": [_cleaner_task_str(t) for t in tasks], + } + ) return d def _actor(self, request): diff --git a/docs/contributing.rst b/docs/contributing.rst index 2d5e1655..697002a8 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -19,7 +19,7 @@ General guidelines Setting up a development environment ------------------------------------ -If you have Python 3.7 or higher installed on your computer (on OS X the quickest way to do this `is using homebrew `__) you can install an editable copy of Datasette using the following steps. +If you have Python 3.8 or higher installed on your computer (on OS X the quickest way to do this `is using homebrew `__) you can install an editable copy of Datasette using the following steps. If you want to use GitHub to publish your changes, first `create a fork of datasette `__ under your own GitHub account. diff --git a/docs/installation.rst b/docs/installation.rst index e1de0ae2..52f87863 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -57,7 +57,7 @@ If the latest packaged release of Datasette has not yet been made available thro Using pip --------- -Datasette requires Python 3.7 or higher. The `Python.org Python For Beginners `__ page has instructions for getting started. +Datasette requires Python 3.8 or higher. The `Python.org Python For Beginners `__ page has instructions for getting started. You can install Datasette and its dependencies using ``pip``:: diff --git a/pytest.ini b/pytest.ini index 559e518c..e4fcd380 100644 --- a/pytest.ini +++ b/pytest.ini @@ -4,8 +4,6 @@ filterwarnings= ignore:Using or importing the ABCs::jinja2 # https://bugs.launchpad.net/beautifulsoup/+bug/1778909 ignore:Using or importing the ABCs::bs4.element - # Python 3.7 PendingDeprecationWarning: Task.current_task() - ignore:.*current_task.*:PendingDeprecationWarning markers = serial: tests to avoid using with pytest-xdist asyncio_mode = strict diff --git a/setup.py b/setup.py index 61860f43..50c70b5f 100644 --- a/setup.py +++ b/setup.py @@ -40,7 +40,7 @@ setup( packages=find_packages(exclude=("tests",)), package_data={"datasette": ["templates/*.html"]}, include_package_data=True, - python_requires=">=3.7", + python_requires=">=3.8", install_requires=[ "asgiref>=3.2.10", "click>=7.1.1", @@ -104,6 +104,5 @@ setup( "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.7", ], ) From 42ca574720cca4c1451a3cbf0e1aa85df174153f Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 8 Jul 2023 12:50:22 -0700 Subject: [PATCH 034/540] Removed accidental test code I added, refs #1153 --- docs/index.rst | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index c10ed5a1..f5c1f232 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -31,18 +31,6 @@ Interested in learning Datasette? Start with `the official tutorials `__. -.. tab:: macOS/Linux/other Unix - - .. code-block:: shell - - python -m pip install webcolors - -.. tab:: Windows - - .. code-block:: shell - - py -m pip install webcolors - Contents -------- From 0f7192b6154edb576c41b55bd3f2a3f53e5f436a Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 8 Jul 2023 13:08:09 -0700 Subject: [PATCH 035/540] One last YAML/JSON change, closes #1153 --- docs/writing_plugins.rst | 77 +++++++++++++++++++++++++++++++++++----- 1 file changed, 69 insertions(+), 8 deletions(-) diff --git a/docs/writing_plugins.rst b/docs/writing_plugins.rst index a3fc88ec..a84789b5 100644 --- a/docs/writing_plugins.rst +++ b/docs/writing_plugins.rst @@ -184,10 +184,12 @@ This will return the ``{"latitude_column": "lat", "longitude_column": "lng"}`` i If there is no configuration for that plugin, the method will return ``None``. -If it cannot find the requested configuration at the table layer, it will fall back to the database layer and then the root layer. For example, a user may have set the plugin configuration option like so:: +If it cannot find the requested configuration at the table layer, it will fall back to the database layer and then the root layer. For example, a user may have set the plugin configuration option like so: - { - "databases: { +.. [[[cog + from metadata_doc import metadata_example + metadata_example(cog, { + "databases": { "sf-trees": { "plugins": { "datasette-cluster-map": { @@ -197,13 +199,45 @@ If it cannot find the requested configuration at the table layer, it will fall b } } } - } + }) +.. ]]] + +.. tab:: YAML + + .. code-block:: yaml + + databases: + sf-trees: + plugins: + datasette-cluster-map: + latitude_column: xlat + longitude_column: xlng + + +.. tab:: JSON + + .. code-block:: json + + { + "databases": { + "sf-trees": { + "plugins": { + "datasette-cluster-map": { + "latitude_column": "xlat", + "longitude_column": "xlng" + } + } + } + } + } +.. [[[end]]] In this case, the above code would return that configuration for ANY table within the ``sf-trees`` database. -The plugin configuration could also be set at the top level of ``metadata.json``:: +The plugin configuration could also be set at the top level of ``metadata.yaml``: - { +.. [[[cog + metadata_example(cog, { "title": "This is the top-level title in metadata.json", "plugins": { "datasette-cluster-map": { @@ -211,7 +245,34 @@ The plugin configuration could also be set at the top level of ``metadata.json`` "longitude_column": "xlng" } } - } + }) +.. ]]] + +.. tab:: YAML + + .. code-block:: yaml + + title: This is the top-level title in metadata.json + plugins: + datasette-cluster-map: + latitude_column: xlat + longitude_column: xlng + + +.. tab:: JSON + + .. code-block:: json + + { + "title": "This is the top-level title in metadata.json", + "plugins": { + "datasette-cluster-map": { + "latitude_column": "xlat", + "longitude_column": "xlng" + } + } + } +.. [[[end]]] Now that ``datasette-cluster-map`` plugin configuration will apply to every table in every database. @@ -234,7 +295,7 @@ To avoid accidentally conflicting with a database file that may be loaded into D - ``/-/upload-excel`` -Try to avoid registering URLs that clash with other plugins that your users might have installed. There is no central repository of reserved URL paths (yet) but you can review existing plugins by browsing the `plugins directory `. +Try to avoid registering URLs that clash with other plugins that your users might have installed. There is no central repository of reserved URL paths (yet) but you can review existing plugins by browsing the `plugins directory `__. If your plugin includes functionality that relates to a specific database you could also register a URL route like this: From 3a51ca901427ac93b0ed0638fd1f82fb647a299a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 21 Jul 2023 14:19:24 -0700 Subject: [PATCH 036/540] Bump black from 23.3.0 to 23.7.0 (#2099) Bumps [black](https://github.com/psf/black) from 23.3.0 to 23.7.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/23.3.0...23.7.0) --- updated-dependencies: - dependency-name: black dependency-type: direct:development update-type: version-update:semver-minor ... 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 50c70b5f..4ecf9a0f 100644 --- a/setup.py +++ b/setup.py @@ -83,7 +83,7 @@ setup( "pytest-xdist>=2.2.1", "pytest-asyncio>=0.17", "beautifulsoup4>=4.8.1", - "black==23.3.0", + "black==23.7.0", "blacken-docs==1.14.0", "pytest-timeout>=1.4.2", "trustme>=0.7", From 278ac91a4d68da333a6ed74a2c75b3ec9db8bd19 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 22 Jul 2023 11:42:46 -0700 Subject: [PATCH 037/540] datasette install -e option, closes #2106 --- datasette/cli.py | 12 ++++++++++-- docs/cli-reference.rst | 7 ++++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/datasette/cli.py b/datasette/cli.py index a6de9e6d..4ccf1963 100644 --- a/datasette/cli.py +++ b/datasette/cli.py @@ -351,13 +351,21 @@ def package( type=click.Path(exists=True), help="Install from requirements file", ) -def install(packages, upgrade, requirement): +@click.option( + "-e", + "--editable", + type=click.Path(readable=True, exists=True, dir_okay=True, file_okay=False), + help="Install a project in editable mode from this path", +) +def install(packages, upgrade, requirement, editable): """Install plugins and packages from PyPI into the same environment as Datasette""" - if not packages and not requirement: + if not packages and not requirement and not editable: raise click.UsageError("Please specify at least one package to install") args = ["pip", "install"] if upgrade: args += ["--upgrade"] + if editable: + args += ["--editable", str(editable)] if requirement: args += ["-r", requirement] args += list(packages) diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index af747ab4..c6d94303 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -350,9 +350,10 @@ Would install the `datasette-cluster-map Date: Fri, 23 Jun 2023 13:06:35 -0700 Subject: [PATCH 038/540] Better docs for startup() hook --- docs/plugin_hooks.rst | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index a4c9d98f..97306529 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -869,7 +869,9 @@ Examples: `datasette-cors `__, `dat startup(datasette) ------------------ -This hook fires when the Datasette application server first starts up. You can implement a regular function, for example to validate required plugin configuration: +This hook fires when the Datasette application server first starts up. + +Here is an example that validates required plugin configuration. The server will fail to start and show an error if the validation check fails: .. code-block:: python @@ -880,7 +882,7 @@ This hook fires when the Datasette application server first starts up. You can i "required-setting" in config ), "my-plugin requires setting required-setting" -Or you can return an async function which will be awaited on startup. Use this option if you need to make any database queries: +You can also return an async function, which will be awaited on startup. Use this option if you need to execute any database queries, for example this function which creates the ``my_table`` database table if it does not yet exist: .. code-block:: python From a88cd45ae55db50d433a1123ed50681f77e06c04 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 29 Jun 2023 07:31:54 -0700 Subject: [PATCH 039/540] Bump blacken-docs from 1.13.0 to 1.14.0 (#2083) Bumps [blacken-docs](https://github.com/asottile/blacken-docs) from 1.13.0 to 1.14.0. - [Changelog](https://github.com/adamchainz/blacken-docs/blob/main/CHANGELOG.rst) - [Commits](https://github.com/asottile/blacken-docs/compare/1.13.0...1.14.0) --- updated-dependencies: - dependency-name: blacken-docs dependency-type: direct:development update-type: version-update:semver-minor ... 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 0eedadb2..678ec5be 100644 --- a/setup.py +++ b/setup.py @@ -82,7 +82,7 @@ setup( "pytest-asyncio>=0.17", "beautifulsoup4>=4.8.1", "black==23.3.0", - "blacken-docs==1.13.0", + "blacken-docs==1.14.0", "pytest-timeout>=1.4.2", "trustme>=0.7", "cogapp>=3.3.0", From 71491551e077571065553d19bef4f7cc14a0a87c Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 29 Jun 2023 07:43:01 -0700 Subject: [PATCH 040/540] codespell>=2.5.5, also spellcheck README - refs #2089 --- .github/workflows/spellcheck.yml | 1 + setup.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/spellcheck.yml b/.github/workflows/spellcheck.yml index a2621ecc..6bf72f9d 100644 --- a/.github/workflows/spellcheck.yml +++ b/.github/workflows/spellcheck.yml @@ -26,5 +26,6 @@ jobs: pip install -e '.[docs]' - name: Check spelling run: | + codespell README.md --ignore-words docs/codespell-ignore-words.txt 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/setup.py b/setup.py index 678ec5be..b8da61d0 100644 --- a/setup.py +++ b/setup.py @@ -72,7 +72,7 @@ setup( "Sphinx==6.1.3", "furo==2023.3.27", "sphinx-autobuild", - "codespell", + "codespell>=2.2.5", "blacken-docs", "sphinx-copybutton", ], From c0c764727fecedf1a53b0e3389ab25fc7379a579 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 29 Jun 2023 07:44:10 -0700 Subject: [PATCH 041/540] Justfile I use for local development Now with codespell, refs #2089 --- Justfile | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 Justfile diff --git a/Justfile b/Justfile new file mode 100644 index 00000000..e595a266 --- /dev/null +++ b/Justfile @@ -0,0 +1,41 @@ +export DATASETTE_SECRET := "not_a_secret" + +# Run tests and linters +@default: test lint + +# Setup project +@init: + pipenv run pip install -e '.[test,docs]' + +# Run pytest with supplied options +@test *options: + pipenv run pytest {{options}} + +@codespell: + pipenv run codespell README.md --ignore-words docs/codespell-ignore-words.txt + pipenv run codespell docs/*.rst --ignore-words docs/codespell-ignore-words.txt + pipenv run codespell datasette -S datasette/static --ignore-words docs/codespell-ignore-words.txt + +# Run linters: black, flake8, mypy, cog +@lint: + pipenv run black . --check + pipenv run flake8 + pipenv run cog --check README.md docs/*.rst + +# Rebuild docs with cog +@cog: + pipenv run cog -r README.md docs/*.rst + +# Serve live docs on localhost:8000 +@docs: cog + pipenv run blacken-docs -l 60 docs/*.rst + cd docs && pipenv run make livehtml + +# Apply Black +@black: + pipenv run black . + +@serve: + pipenv run sqlite-utils create-database data.db + pipenv run sqlite-utils create-table data.db docs id integer title text --pk id --ignore + pipenv run python -m datasette data.db --root --reload From 737a1a7fd2f31a4948dd386ae603e16fcea65617 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 29 Jun 2023 07:46:22 -0700 Subject: [PATCH 042/540] Fixed spelling error, refs #2089 Also ensure codespell runs as part of just lint --- Justfile | 2 +- docs/metadata.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Justfile b/Justfile index e595a266..d349ec51 100644 --- a/Justfile +++ b/Justfile @@ -17,7 +17,7 @@ export DATASETTE_SECRET := "not_a_secret" pipenv run codespell datasette -S datasette/static --ignore-words docs/codespell-ignore-words.txt # Run linters: black, flake8, mypy, cog -@lint: +@lint: codespell pipenv run black . --check pipenv run flake8 pipenv run cog --check README.md docs/*.rst diff --git a/docs/metadata.rst b/docs/metadata.rst index 35b8aede..5932cc3a 100644 --- a/docs/metadata.rst +++ b/docs/metadata.rst @@ -189,7 +189,7 @@ Or use ``"sort_desc"`` to sort in descending order: Setting a custom page size -------------------------- -Datasette defaults to displaing 100 rows per page, for both tables and views. You can change this default page size on a per-table or per-view basis using the ``"size"`` key in ``metadata.json``: +Datasette defaults to displaying 100 rows per page, for both tables and views. You can change this default page size on a per-table or per-view basis using the ``"size"`` key in ``metadata.json``: .. code-block:: json From b5647ebd53a2c51160c0a1cc440fd5365836b4e9 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 29 Jun 2023 08:05:24 -0700 Subject: [PATCH 043/540] Fix all E741 Ambiguous variable name warnings, refs #2090 --- tests/test_plugins.py | 8 +++++--- tests/test_table_html.py | 4 ++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 71b710f9..6971bbf7 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -115,7 +115,9 @@ async def test_hook_extra_css_urls(ds_client, path, expected_decoded_object): assert response.status_code == 200 links = Soup(response.text, "html.parser").findAll("link") special_href = [ - l for l in links if l.attrs["href"].endswith("/extra-css-urls-demo.css") + link + for link in links + if link.attrs["href"].endswith("/extra-css-urls-demo.css") ][0]["href"] # This link has a base64-encoded JSON blob in it encoded = special_href.split("/")[3] @@ -543,7 +545,7 @@ async def test_hook_register_output_renderer_can_render(ds_client): .find("p", {"class": "export-links"}) .findAll("a") ) - actual = [l["href"] for l in links] + actual = [link["href"] for link in links] # Should not be present because we sent ?_no_can_render=1 assert "/fixtures/facetable.testall?_labels=on" not in actual # Check that it was passed the values we expected @@ -940,7 +942,7 @@ async def test_hook_table_actions(ds_client, table_or_view): response_2 = await ds_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"] + get_table_actions_links(response_2.text), key=lambda link: link["label"] ) == [ {"label": "Database: fixtures", "href": "/"}, {"label": "From async BOB", "href": "/"}, diff --git a/tests/test_table_html.py b/tests/test_table_html.py index e1886dab..c4c7878c 100644 --- a/tests/test_table_html.py +++ b/tests/test_table_html.py @@ -481,7 +481,7 @@ async def test_table_csv_json_export_interface(ds_client): .find("p", {"class": "export-links"}) .findAll("a") ) - actual = [l["href"] for l in links] + actual = [link["href"] for link in links] expected = [ "/fixtures/simple_primary_key.json?id__gt=2", "/fixtures/simple_primary_key.testall?id__gt=2", @@ -521,7 +521,7 @@ async def test_csv_json_export_links_include_labels_if_foreign_keys(ds_client): .find("p", {"class": "export-links"}) .findAll("a") ) - actual = [l["href"] for l in links] + actual = [link["href"] for link in links] expected = [ "/fixtures/facetable.json?_labels=on", "/fixtures/facetable.testall?_labels=on", From d7aa14b17ff47b136dca67cdbbea7e99ea8b6b20 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 29 Jun 2023 08:24:09 -0700 Subject: [PATCH 044/540] Homepage test now just asserts isinstance(x, int) - closes #2092 --- tests/test_api.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/tests/test_api.py b/tests/test_api.py index e7d8d849..bdee3b98 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -32,14 +32,12 @@ async def test_homepage(ds_client): assert data.keys() == {"fixtures": 0}.keys() d = data["fixtures"] assert d["name"] == "fixtures" - assert d["tables_count"] == 24 - assert len(d["tables_and_views_truncated"]) == 5 + assert isinstance(d["tables_count"], int) + assert isinstance(len(d["tables_and_views_truncated"]), int) assert d["tables_and_views_more"] is True - # 4 hidden FTS tables + no_primary_key (hidden in metadata) - assert d["hidden_tables_count"] == 6 - # 201 in no_primary_key, plus 6 in other hidden tables: - assert d["hidden_table_rows_sum"] == 207, data - assert d["views_count"] == 4 + assert isinstance(d["hidden_tables_count"], int) + assert isinstance(d["hidden_table_rows_sum"], int) + assert isinstance(d["views_count"], int) @pytest.mark.asyncio From e8ac498e24cc046d4e13fa15f37c0d5fce2d5e9b Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 26 Apr 2023 20:47:03 -0700 Subject: [PATCH 045/540] Work in progress on query view, refs #2049 --- datasette/views/database.py | 404 ++++++++++-------------------------- setup.py | 2 +- 2 files changed, 108 insertions(+), 298 deletions(-) diff --git a/datasette/views/database.py b/datasette/views/database.py index 455ebd1f..a2f6c021 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -887,6 +887,105 @@ async def query_view( _size=None, named_parameters=None, write=False, +): + print("query_view") + db = await datasette.resolve_database(request) + database = db.name + # TODO: Why do I do this? Is it to eliminate multi-args? + # It's going to break ?_extra=...&_extra=... + params = {key: request.args.get(key) for key in request.args} + sql = "" + if "sql" in params: + sql = params.pop("sql") + + # TODO: Behave differently for canned query here: + await datasette.ensure_permissions(request.actor, [("execute-sql", database)]) + + _shape = None + if "_shape" in params: + _shape = params.pop("_shape") + + # ?_shape=arrays - "rows" is the default option, shown above + # ?_shape=objects - "rows" is a list of JSON key/value objects + # ?_shape=array - an JSON array of objects + # ?_shape=array&_nl=on - a newline-separated list of JSON objects + # ?_shape=arrayfirst - a flat JSON array containing just the first value from each row + # ?_shape=object - a JSON object keyed using the primary keys of the rows + async def _results(_sql, _params): + # Returns (results, error (can be None)) + try: + return await db.execute(_sql, _params, truncate=True), None + except Exception as e: + return None, e + + async def shape_arrays(_results): + results, error = _results + if error: + return {"ok": False, "error": str(error)} + return { + "ok": True, + "rows": [list(r) for r in results.rows], + "truncated": results.truncated, + } + + async def shape_objects(_results): + results, error = _results + if error: + return {"ok": False, "error": str(error)} + return { + "ok": True, + "rows": [dict(r) for r in results.rows], + "truncated": results.truncated, + } + + async def shape_array(_results): + results, error = _results + if error: + return {"ok": False, "error": str(error)} + return [dict(r) for r in results.rows] + + shape_fn = { + "arrays": shape_arrays, + "objects": shape_objects, + "array": shape_array, + # "arrayfirst": shape_arrayfirst, + # "object": shape_object, + }[_shape or "objects"] + + registry = Registry.from_dict( + { + "_results": _results, + "_shape": shape_fn, + }, + parallel=False, + ) + + results = await registry.resolve_multi( + ["_shape"], + results={ + "_sql": sql, + "_params": params, + }, + ) + + # If "shape" does not include "rows" we return that as the response + # because it's likely [{...}] or similar, with no room to attach extras + if "rows" not in results["_shape"]: + return Response.json(results["_shape"]) + + output = results["_shape"] + # Include the extras: + output.update(dict((k, v) for k, v in results.items() if not k.startswith("_"))) + return Response.json(output) + + +async def database_view_impl( + request, + datasette, + canned_query=None, + _size=None, + named_parameters=None, + write=False, ): db = await datasette.resolve_database(request) @@ -1031,6 +1130,14 @@ async def query_view_data( database = db.name # TODO: Why do I do this? Is it to eliminate multi-args? # It's going to break ?_extra=...&_extra=... + + if request.args.get("sql", "").strip(): + return await query_view( + request, datasette, canned_query, _size, named_parameters, write + ) + + # Index page shows the tables/views/canned queries for this database + params = {key: request.args.get(key) for key in request.args} sql = "" if "sql" in params: @@ -1119,305 +1226,8 @@ async def query_view_data( output = results["_shape"] output.update(dict((k, v) for k, v in results.items() if not k.startswith("_"))) - return output - # registry = Registry( - # extra_count, - # extra_facet_results, - # extra_facets_timed_out, - # extra_suggested_facets, - # facet_instances, - # extra_human_description_en, - # extra_next_url, - # extra_columns, - # extra_primary_keys, - # run_display_columns_and_rows, - # extra_display_columns, - # extra_display_rows, - # extra_debug, - # extra_request, - # extra_query, - # extra_metadata, - # extra_extras, - # extra_database, - # extra_table, - # extra_database_color, - # extra_table_actions, - # extra_filters, - # extra_renderers, - # extra_custom_table_templates, - # extra_sorted_facet_results, - # extra_table_definition, - # extra_view_definition, - # extra_is_view, - # extra_private, - # extra_expandable_columns, - # extra_form_hidden_args, - # ) - - results = await registry.resolve_multi( - ["extra_{}".format(extra) for extra in extras] - ) - data = { - "ok": True, - "next": next_value and str(next_value) or None, - } - data.update( - { - key.replace("extra_", ""): value - for key, value in results.items() - if key.startswith("extra_") and key.replace("extra_", "") in extras - } - ) - raw_sqlite_rows = rows[:page_size] - data["rows"] = [dict(r) for r in raw_sqlite_rows] - - private = False - if canned_query: - # Respect canned query permissions - visible, private = await datasette.check_visibility( - request.actor, - permissions=[ - ("view-query", (database, canned_query)), - ("view-database", database), - "view-instance", - ], - ) - if not visible: - raise Forbidden("You do not have permission to view this query") - - else: - await datasette.ensure_permissions(request.actor, [("execute-sql", database)]) - - # If there's no sql, show the database index page - if not sql: - return await database_index_view(request, datasette, db) - - validate_sql_select(sql) - - # Extract any :named parameters - named_parameters = named_parameters or await derive_named_parameters(db, 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("_"): - params[named_parameter] = "" - - 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", - ) - - query_error = None - - # Execute query - as write or as read - if write: - raise NotImplementedError("Write queries not yet implemented") - # 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: - - # 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, - # ) - - # Not a write - rows = [] - if canned_query: - params_for_query = MagicParameters(params, request, datasette) - else: - params_for_query = params - try: - results = await datasette.execute( - database, sql, params_for_query, truncate=True, **extra_args - ) - columns = [r[0] for r in results.description] - rows = list(results.rows) - except sqlite3.DatabaseError as e: - query_error = e - results = None - columns = [] - - allow_execute_sql = await datasette.permission_allowed( - request.actor, "execute-sql", database - ) - - format_ = request.url_vars.get("format") or "html" - - if format_ == "csv": - raise NotImplementedError("CSV format not yet implemented") - 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=db.name, - table=None, - request=request, - view_name="table", # TODO: should this be "query"? - # These will be deprecated in Datasette 1.0: - args=request.args, - data={ - "rows": rows, - }, # TODO what should this be? - ) - result = await await_me_maybe(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"] - template = datasette.jinja_env.select_template(templates) - alternate_url_json = datasette.absolute_url( - request, - datasette.urls.path(path_with_format(request=request, format="json")), - ) - headers.update( - { - "Link": '{}; rel="alternate"; type="application/json+datasette"'.format( - alternate_url_json - ) - } - ) - r = Response.html( - await datasette.render_template( - template, - dict( - data, - append_querystring=append_querystring, - path_with_replaced_args=path_with_replaced_args, - fix_path=datasette.urls.path, - settings=datasette.settings_dict(), - # TODO: review up all of these hacks: - alternate_url_json=alternate_url_json, - datasette_allow_facet=( - "true" if datasette.setting("allow_facet") else "false" - ), - is_sortable=any(c["sortable"] for c in data["display_columns"]), - allow_execute_sql=await datasette.permission_allowed( - request.actor, "execute-sql", resolved.db.name - ), - query_ms=1.2, - select_templates=[ - f"{'*' if template_name == template.name else ''}{template_name}" - for template_name in templates - ], - ), - request=request, - view_name="table", - ), - headers=headers, - ) - else: - assert False, "Invalid format: {}".format(format_) - # if next_url: - # r.headers["link"] = f'<{next_url}>; rel="next"' - return r - async def database_view_impl( request, diff --git a/setup.py b/setup.py index b8da61d0..e1856f27 100644 --- a/setup.py +++ b/setup.py @@ -58,9 +58,9 @@ setup( "mergedeep>=1.1.1", "itsdangerous>=1.1", "sqlite-utils>=3.30", - "asyncinject>=0.6", "setuptools", "pip", + "asyncinject>=0.6", ], entry_points=""" [console_scripts] From e17a1373b32f2c5c04e59a875157cc60accb02da Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 26 Apr 2023 20:36:10 -0700 Subject: [PATCH 046/540] Add setuptools to dependencies Refs #2065 --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index e1856f27..a185cb94 100644 --- a/setup.py +++ b/setup.py @@ -61,6 +61,7 @@ setup( "setuptools", "pip", "asyncinject>=0.6", + "setuptools", ], entry_points=""" [console_scripts] From ee24ea94525ace221f1b4d141d01cf56410c2c6d Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 26 Apr 2023 22:07:35 -0700 Subject: [PATCH 047/540] Add pip as a dependency too, for Rye - refs #2065 --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index a185cb94..f52bd440 100644 --- a/setup.py +++ b/setup.py @@ -62,6 +62,7 @@ setup( "pip", "asyncinject>=0.6", "setuptools", + "pip", ], entry_points=""" [console_scripts] From dc5171eb1b1d9f1d55e367f8a4d93edb55a43351 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 26 Jul 2023 11:28:03 -0700 Subject: [PATCH 048/540] Make editable work with -e '.[test]', refs #2106 --- datasette/cli.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/datasette/cli.py b/datasette/cli.py index 4ccf1963..32266888 100644 --- a/datasette/cli.py +++ b/datasette/cli.py @@ -354,7 +354,6 @@ def package( @click.option( "-e", "--editable", - type=click.Path(readable=True, exists=True, dir_okay=True, file_okay=False), help="Install a project in editable mode from this path", ) def install(packages, upgrade, requirement, editable): @@ -365,7 +364,7 @@ def install(packages, upgrade, requirement, editable): if upgrade: args += ["--upgrade"] if editable: - args += ["--editable", str(editable)] + args += ["--editable", editable] if requirement: args += ["-r", requirement] args += list(packages) From 18dd88ee4d78fe9d760e9da96028ae06d938a85c Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 26 Jul 2023 11:43:55 -0700 Subject: [PATCH 049/540] Refactored DatabaseDownload to database_download, closes #2116 --- datasette/app.py | 7 +++- datasette/views/database.py | 78 ++++++++++++++++++------------------- 2 files changed, 43 insertions(+), 42 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 977839e0..b8b84168 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -34,7 +34,7 @@ from jinja2.environment import Template from jinja2.exceptions import TemplateNotFound from .views.base import ureg -from .views.database import DatabaseDownload, DatabaseView, TableCreateView +from .views.database import database_download, DatabaseView, TableCreateView from .views.index import IndexView from .views.special import ( JsonDataView, @@ -1363,7 +1363,10 @@ class Datasette: wrap_view(PatternPortfolioView, self), r"/-/patterns$", ) - add_route(DatabaseDownload.as_view(self), r"/(?P[^\/\.]+)\.db$") + add_route( + wrap_view(database_download, self), + r"/(?P[^\/\.]+)\.db$", + ) add_route( DatabaseView.as_view(self), r"/(?P[^\/\.]+)(\.(?P\w+))?$" ) diff --git a/datasette/views/database.py b/datasette/views/database.py index dda82510..ffa79e96 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -169,47 +169,45 @@ class DatabaseView(DataView): ) -class DatabaseDownload(DataView): - name = "database_download" +async def database_download(request, datasette): + database = tilde_decode(request.url_vars["database"]) + await datasette.ensure_permissions( + request.actor, + [ + ("view-database-download", database), + ("view-database", database), + "view-instance", + ], + ) + try: + db = datasette.get_database(route=database) + except KeyError: + raise DatasetteError("Invalid database", status=404) - async def get(self, request): - database = tilde_decode(request.url_vars["database"]) - await self.ds.ensure_permissions( - request.actor, - [ - ("view-database-download", database), - ("view-database", database), - "view-instance", - ], - ) - try: - db = self.ds.get_database(route=database) - except KeyError: - raise DatasetteError("Invalid database", status=404) - if db.is_memory: - raise DatasetteError("Cannot download in-memory databases", status=404) - if not self.ds.setting("allow_download") or db.is_mutable: - raise Forbidden("Database download is forbidden") - if not db.path: - raise DatasetteError("Cannot download database", status=404) - filepath = db.path - headers = {} - if self.ds.cors: - add_cors_headers(headers) - if db.hash: - etag = '"{}"'.format(db.hash) - headers["Etag"] = etag - # Has user seen this already? - if_none_match = request.headers.get("if-none-match") - if if_none_match and if_none_match == etag: - return Response("", status=304) - headers["Transfer-Encoding"] = "chunked" - return AsgiFileDownload( - filepath, - filename=os.path.basename(filepath), - content_type="application/octet-stream", - headers=headers, - ) + if db.is_memory: + raise DatasetteError("Cannot download in-memory databases", status=404) + if not datasette.setting("allow_download") or db.is_mutable: + raise Forbidden("Database download is forbidden") + if not db.path: + raise DatasetteError("Cannot download database", status=404) + filepath = db.path + headers = {} + if datasette.cors: + add_cors_headers(headers) + if db.hash: + etag = '"{}"'.format(db.hash) + headers["Etag"] = etag + # Has user seen this already? + if_none_match = request.headers.get("if-none-match") + if if_none_match and if_none_match == etag: + return Response("", status=304) + headers["Transfer-Encoding"] = "chunked" + return AsgiFileDownload( + filepath, + filename=os.path.basename(filepath), + content_type="application/octet-stream", + headers=headers, + ) class QueryView(DataView): From 08181823990a71ffa5a1b57b37259198eaa43e06 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 26 Jul 2023 11:52:37 -0700 Subject: [PATCH 050/540] Update cli-reference for editable change, refs #2106 --- docs/cli-reference.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index c6d94303..2177fc9e 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -350,10 +350,10 @@ Would install the `datasette-cluster-map Date: Mon, 7 Aug 2023 08:45:10 -0700 Subject: [PATCH 051/540] Use dependabot grouped updates --- .github/dependabot.yml | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b969c4c1..88bb03b1 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,9 +5,7 @@ updates: schedule: interval: daily time: "13:00" - open-pull-requests-limit: 10 - ignore: - - dependency-name: black - versions: - - 21.4b0 - - 21.4b1 + groups: + python-packages: + patterns: + - "*" From 5139c0886a7f6bb94d317ba0665aa6e728716028 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 7 Aug 2023 09:19:23 -0700 Subject: [PATCH 052/540] Bump the python-packages group with 3 updates (#2128) Bumps the python-packages group with 3 updates: [sphinx](https://github.com/sphinx-doc/sphinx), [furo](https://github.com/pradyunsg/furo) and [blacken-docs](https://github.com/asottile/blacken-docs). Updates `sphinx` from 6.1.3 to 7.1.2 - [Release notes](https://github.com/sphinx-doc/sphinx/releases) - [Changelog](https://github.com/sphinx-doc/sphinx/blob/master/CHANGES) - [Commits](https://github.com/sphinx-doc/sphinx/compare/v6.1.3...v7.1.2) Updates `furo` from 2023.3.27 to 2023.7.26 - [Release notes](https://github.com/pradyunsg/furo/releases) - [Changelog](https://github.com/pradyunsg/furo/blob/main/docs/changelog.md) - [Commits](https://github.com/pradyunsg/furo/compare/2023.03.27...2023.07.26) Updates `blacken-docs` from 1.14.0 to 1.15.0 - [Changelog](https://github.com/adamchainz/blacken-docs/blob/main/CHANGELOG.rst) - [Commits](https://github.com/asottile/blacken-docs/compare/1.14.0...1.15.0) --- updated-dependencies: - dependency-name: sphinx dependency-type: direct:development update-type: version-update:semver-major dependency-group: python-packages - dependency-name: furo dependency-type: direct:development update-type: version-update:semver-minor dependency-group: python-packages - dependency-name: blacken-docs dependency-type: direct:development update-type: version-update:semver-minor dependency-group: python-packages ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- setup.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index 4ecf9a0f..3a105523 100644 --- a/setup.py +++ b/setup.py @@ -69,8 +69,8 @@ setup( setup_requires=["pytest-runner"], extras_require={ "docs": [ - "Sphinx==6.1.3", - "furo==2023.3.27", + "Sphinx==7.1.2", + "furo==2023.7.26", "sphinx-autobuild", "codespell>=2.2.5", "blacken-docs", @@ -84,7 +84,7 @@ setup( "pytest-asyncio>=0.17", "beautifulsoup4>=4.8.1", "black==23.7.0", - "blacken-docs==1.14.0", + "blacken-docs==1.15.0", "pytest-timeout>=1.4.2", "trustme>=0.7", "cogapp>=3.3.0", From 1377a290cd85ba8d3338b1da47c4665ed4c6c625 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 7 Aug 2023 18:47:39 -0700 Subject: [PATCH 053/540] New JSON design for query views (#2118) * Refs #2111, closes #2110 * New Context dataclass/subclass mechanism, refs #2127 * Define QueryContext and extract get_tables() method, refs #2127 * Fix OPTIONS bug by porting DaatbaseView to be a View subclass * Expose async_view_for_class.view_class for test_routes test * Error/truncated aruments for renderers, closes #2130 --- datasette/__init__.py | 3 +- datasette/app.py | 16 +- datasette/renderer.py | 21 +- datasette/views/__init__.py | 3 + datasette/views/base.py | 2 + datasette/views/database.py | 574 +++++++++++++++++++++++++----- datasette/views/table.py | 2 + docs/plugin_hooks.rst | 6 + tests/test_api.py | 27 +- tests/test_cli_serve_get.py | 1 - tests/test_html.py | 8 +- tests/test_internals_datasette.py | 20 +- tests/test_messages.py | 6 +- tests/test_plugins.py | 5 +- tests/test_table_api.py | 1 - 15 files changed, 581 insertions(+), 114 deletions(-) diff --git a/datasette/__init__.py b/datasette/__init__.py index 64fb4ff7..271e09ad 100644 --- a/datasette/__init__.py +++ b/datasette/__init__.py @@ -1,6 +1,7 @@ -from datasette.permissions import Permission +from datasette.permissions import Permission # noqa from datasette.version import __version_info__, __version__ # noqa from datasette.utils.asgi import Forbidden, NotFound, Request, Response # noqa from datasette.utils import actor_matches_allow # noqa +from datasette.views import Context # noqa from .hookspecs import hookimpl # noqa from .hookspecs import hookspec # noqa diff --git a/datasette/app.py b/datasette/app.py index b8b84168..b2644ace 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -1,7 +1,8 @@ import asyncio -from typing import Sequence, Union, Tuple, Optional, Dict, Iterable +from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Union import asgi_csrf import collections +import dataclasses import datetime import functools import glob @@ -33,6 +34,7 @@ from jinja2 import ( from jinja2.environment import Template from jinja2.exceptions import TemplateNotFound +from .views import Context from .views.base import ureg from .views.database import database_download, DatabaseView, TableCreateView from .views.index import IndexView @@ -1115,7 +1117,11 @@ class Datasette: ) async def render_template( - self, templates, context=None, request=None, view_name=None + self, + templates: Union[List[str], str, Template], + context: Optional[Union[Dict[str, Any], Context]] = None, + request: Optional[Request] = None, + view_name: Optional[str] = None, ): if not self._startup_invoked: raise Exception("render_template() called before await ds.invoke_startup()") @@ -1126,6 +1132,8 @@ class Datasette: if isinstance(templates, str): templates = [templates] template = self.jinja_env.select_template(templates) + if dataclasses.is_dataclass(context): + context = dataclasses.asdict(context) body_scripts = [] # pylint: disable=no-member for extra_script in pm.hook.extra_body_script( @@ -1368,7 +1376,8 @@ class Datasette: r"/(?P[^\/\.]+)\.db$", ) add_route( - DatabaseView.as_view(self), r"/(?P[^\/\.]+)(\.(?P\w+))?$" + wrap_view(DatabaseView, self), + r"/(?P[^\/\.]+)(\.(?P\w+))?$", ) add_route(TableCreateView.as_view(self), r"/(?P[^\/\.]+)/-/create$") add_route( @@ -1707,6 +1716,7 @@ def wrap_view_class(view_class, datasette): datasette=datasette, ) + async_view_for_class.view_class = view_class return async_view_for_class diff --git a/datasette/renderer.py b/datasette/renderer.py index 5354f348..0bd74e81 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, view_name): +def json_renderer(args, data, error, truncated=None): """Render a response as JSON""" status_code = 200 @@ -47,8 +47,15 @@ def json_renderer(args, data, view_name): # Deal with the _shape option shape = args.get("_shape", "objects") # if there's an error, ignore the shape entirely - if data.get("error"): + data["ok"] = True + if error: shape = "objects" + status_code = 400 + data["error"] = error + data["ok"] = False + + if truncated is not None: + data["truncated"] = truncated if shape == "arrayfirst": if not data["rows"]: @@ -64,13 +71,13 @@ def json_renderer(args, data, view_name): if rows and columns: data["rows"] = [dict(zip(columns, row)) for row in rows] if shape == "object": - error = None + shape_error = None if "primary_keys" not in data: - error = "_shape=object is only available on tables" + shape_error = "_shape=object is only available on tables" else: pks = data["primary_keys"] if not pks: - error = ( + shape_error = ( "_shape=object not available for tables with no primary keys" ) else: @@ -79,8 +86,8 @@ def json_renderer(args, data, view_name): pk_string = path_from_row_pks(row, pks, not pks) object_rows[pk_string] = row data = object_rows - if error: - data = {"ok": False, "error": error} + if shape_error: + data = {"ok": False, "error": shape_error} elif shape == "array": data = data["rows"] diff --git a/datasette/views/__init__.py b/datasette/views/__init__.py index e69de29b..e3b1b7f4 100644 --- a/datasette/views/__init__.py +++ b/datasette/views/__init__.py @@ -0,0 +1,3 @@ +class Context: + "Base class for all documented contexts" + pass diff --git a/datasette/views/base.py b/datasette/views/base.py index 94645cd8..da5c55ad 100644 --- a/datasette/views/base.py +++ b/datasette/views/base.py @@ -309,6 +309,8 @@ class DataView(BaseView): table=data.get("table"), request=request, view_name=self.name, + truncated=False, # TODO: support this + error=data.get("error"), # These will be deprecated in Datasette 1.0: args=request.args, data=data, diff --git a/datasette/views/database.py b/datasette/views/database.py index ffa79e96..77f3f5b0 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -1,17 +1,22 @@ -import os +from asyncinject import Registry +from dataclasses import dataclass, field +from typing import Callable +from urllib.parse import parse_qsl, urlencode +import asyncio import hashlib import itertools import json -from markupsafe import Markup, escape -from urllib.parse import parse_qsl, urlencode +import markupsafe +import os import re import sqlite_utils +import textwrap -import markupsafe - +from datasette.database import QueryInterrupted from datasette.utils import ( add_cors_headers, await_me_maybe, + call_with_supported_arguments, derive_named_parameters, format_bytes, tilde_decode, @@ -28,17 +33,19 @@ from datasette.utils import ( from datasette.utils.asgi import AsgiFileDownload, NotFound, Response, Forbidden from datasette.plugins import pm -from .base import BaseView, DatasetteError, DataView, _error +from .base import BaseView, DatasetteError, DataView, View, _error, stream_csv -class DatabaseView(DataView): - name = "database" +class DatabaseView(View): + async def get(self, request, datasette): + format_ = request.url_vars.get("format") or "html" - async def data(self, request, default_labels=False, _size=None): - db = await self.ds.resolve_database(request) + await datasette.refresh_schemas() + + db = await datasette.resolve_database(request) database = db.name - visible, private = await self.ds.check_visibility( + visible, private = await datasette.check_visibility( request.actor, permissions=[ ("view-database", database), @@ -48,23 +55,23 @@ class DatabaseView(DataView): if not visible: raise Forbidden("You do not have permission to view this database") - metadata = (self.ds.metadata("databases") or {}).get(database, {}) - self.ds.update_with_inherited_metadata(metadata) + sql = (request.args.get("sql") or "").strip() + if sql: + return await query_view(request, datasette) - if request.args.get("sql"): - sql = request.args.get("sql") - validate_sql_select(sql) - return await QueryView(self.ds).data( - request, sql, _size=_size, metadata=metadata - ) + if format_ not in ("html", "json"): + raise NotFound("Invalid format: {}".format(format_)) + + 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() - views = [] + sql_views = [] for view_name in await db.view_names(): - view_visible, view_private = await self.ds.check_visibility( + view_visible, view_private = await datasette.check_visibility( request.actor, permissions=[ ("view-table", (database, view_name)), @@ -73,45 +80,19 @@ class DatabaseView(DataView): ], ) if view_visible: - views.append( + sql_views.append( { "name": view_name, "private": view_private, } ) - tables = [] - for table in table_counts: - table_visible, table_private = await self.ds.check_visibility( - request.actor, - permissions=[ - ("view-table", (database, table)), - ("view-database", database), - "view-instance", - ], - ) - if not table_visible: - continue - table_columns = await db.table_columns(table) - tables.append( - { - "name": table, - "columns": table_columns, - "primary_keys": await db.primary_keys(table), - "count": table_counts[table], - "hidden": table in hidden_table_names, - "fts_table": await db.fts_table(table), - "foreign_keys": all_foreign_keys[table], - "private": table_private, - } - ) - - tables.sort(key=lambda t: (t["hidden"], t["name"])) + tables = await get_tables(datasette, request, db) canned_queries = [] for query in ( - await self.ds.get_canned_queries(database, request.actor) + await datasette.get_canned_queries(database, request.actor) ).values(): - query_visible, query_private = await self.ds.check_visibility( + query_visible, query_private = await datasette.check_visibility( request.actor, permissions=[ ("view-query", (database, query["name"])), @@ -125,7 +106,7 @@ class DatabaseView(DataView): async def database_actions(): links = [] for hook in pm.hook.database_actions( - datasette=self.ds, + datasette=datasette, database=database, actor=request.actor, request=request, @@ -137,36 +118,165 @@ class DatabaseView(DataView): attached_databases = [d.name for d in await db.attached_databases()] - allow_execute_sql = await self.ds.permission_allowed( + allow_execute_sql = await datasette.permission_allowed( request.actor, "execute-sql", database ) - return ( - { - "database": database, - "private": private, - "path": self.ds.urls.database(database), - "size": db.size, - "tables": tables, - "hidden_count": len([t for t in tables if t["hidden"]]), - "views": views, - "queries": canned_queries, - "allow_execute_sql": allow_execute_sql, - "table_columns": await _table_columns(self.ds, database) - if allow_execute_sql - else {}, - }, - { - "database_actions": database_actions, - "show_hidden": request.args.get("_show_hidden"), - "editable": True, - "metadata": metadata, - "allow_download": self.ds.setting("allow_download") - and not db.is_mutable - and not db.is_memory, - "attached_databases": attached_databases, - }, - (f"database-{to_css_class(database)}.html", "database.html"), + json_data = { + "database": database, + "private": private, + "path": datasette.urls.database(database), + "size": db.size, + "tables": tables, + "hidden_count": len([t for t in tables if t["hidden"]]), + "views": sql_views, + "queries": canned_queries, + "allow_execute_sql": allow_execute_sql, + "table_columns": await _table_columns(datasette, database) + if allow_execute_sql + else {}, + } + + if format_ == "json": + response = Response.json(json_data) + if datasette.cors: + add_cors_headers(response.headers) + return response + + assert format_ == "html" + alternate_url_json = datasette.absolute_url( + request, + datasette.urls.path(path_with_format(request=request, format="json")), ) + templates = (f"database-{to_css_class(database)}.html", "database.html") + template = datasette.jinja_env.select_template(templates) + context = { + **json_data, + "database_actions": database_actions, + "show_hidden": request.args.get("_show_hidden"), + "editable": True, + "metadata": metadata, + "allow_download": datasette.setting("allow_download") + and not db.is_mutable + and not db.is_memory, + "attached_databases": attached_databases, + "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 + ], + } + return Response.html( + await datasette.render_template( + templates, + context, + request=request, + view_name="database", + ), + headers={ + "Link": '{}; rel="alternate"; type="application/json+datasette"'.format( + alternate_url_json + ) + }, + ) + + +@dataclass +class QueryContext: + database: str = field(metadata={"help": "The name of the database being queried"}) + query: dict = field( + metadata={"help": "The SQL query object containing the `sql` string"} + ) + canned_query: str = field( + metadata={"help": "The name of the canned query if this is a canned query"} + ) + private: bool = field( + metadata={"help": "Boolean indicating if this is a private database"} + ) + # 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"} + ) + db_is_immutable: bool = field( + metadata={"help": "Boolean indicating if this database is immutable"} + ) + error: str = field(metadata={"help": "Any query error message"}) + hide_sql: bool = field( + metadata={"help": "Boolean indicating if the SQL should be hidden"} + ) + show_hide_link: str = field( + metadata={"help": "The URL to toggle showing/hiding the SQL"} + ) + show_hide_text: str = field( + metadata={"help": "The text for the show/hide SQL link"} + ) + editable: bool = field( + metadata={"help": "Boolean indicating if the SQL can be edited"} + ) + allow_execute_sql: bool = field( + metadata={"help": "Boolean indicating if custom SQL can be executed"} + ) + tables: list = field(metadata={"help": "List of table objects in the database"}) + named_parameter_values: dict = field( + metadata={"help": "Dictionary of parameter names/values"} + ) + edit_sql_url: str = field( + metadata={"help": "URL to edit the SQL for a canned query"} + ) + display_rows: list = field(metadata={"help": "List of result rows to display"}) + columns: list = field(metadata={"help": "List of column names"}) + renderers: dict = field(metadata={"help": "Dictionary of renderer name to URL"}) + url_csv: str = field(metadata={"help": "URL for CSV export"}) + 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"} + ) + table_columns: dict = field( + metadata={"help": "Dictionary of table name to list of column names"} + ) + alternate_url_json: str = field( + metadata={"help": "URL for alternate JSON version of this page"} + ) + + +async def get_tables(datasette, request, db): + tables = [] + database = db.name + 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() + + for table in table_counts: + table_visible, table_private = await datasette.check_visibility( + request.actor, + permissions=[ + ("view-table", (database, table)), + ("view-database", database), + "view-instance", + ], + ) + if not table_visible: + continue + table_columns = await db.table_columns(table) + tables.append( + { + "name": table, + "columns": table_columns, + "primary_keys": await db.primary_keys(table), + "count": table_counts[table], + "hidden": table in hidden_table_names, + "fts_table": await db.fts_table(table), + "foreign_keys": all_foreign_keys[table], + "private": table_private, + } + ) + tables.sort(key=lambda t: (t["hidden"], t["name"])) + return tables async def database_download(request, datasette): @@ -210,6 +320,244 @@ 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") + + # extras come from original request.args to avoid being flattened + extras = request.args.getlist("_extra") + + # 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"), + ) + 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) + + 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 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" + + r = Response.html( + await datasette.render_template( + template, + QueryContext( + database=database, + query={ + "sql": sql, + # TODO: 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={}, # TODO + 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, @@ -404,7 +752,7 @@ class QueryView(DataView): display_value = plugin_display_value else: if value in ("", None): - display_value = Markup(" ") + display_value = markupsafe.Markup(" ") elif is_url(str(display_value).strip()): display_value = markupsafe.Markup( '{truncated_url}'.format( @@ -755,3 +1103,69 @@ async def _table_columns(datasette, database_name): for view_name in await db.view_names(): table_columns[view_name] = [] return table_columns + + +async def display_rows(datasette, database, request, rows, columns): + display_rows = [] + truncate_cells = datasette.setting("truncate_cells_html") + for row in rows: + display_row = [] + for column, value in zip(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=datasette, + 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) + return display_rows diff --git a/datasette/views/table.py b/datasette/views/table.py index c102c103..77acfd95 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -833,6 +833,8 @@ async def table_view_traced(datasette, request): table=resolved.table, request=request, view_name="table", + truncated=False, + error=None, # These will be deprecated in Datasette 1.0: args=request.args, data=data, diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 97306529..9bbe6fc6 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -516,6 +516,12 @@ When a request is received, the ``"render"`` callback function is called with ze ``request`` - :ref:`internals_request` The current HTTP request. +``error`` - string or None + If an error occurred this string will contain the error message. + +``truncated`` - bool or None + If the query response was truncated - for example a SQL query returning more than 1,000 results where pagination is not available - this will be ``True``. + ``view_name`` - string The name of the current view being called. ``index``, ``database``, ``table``, and ``row`` are the most important ones. diff --git a/tests/test_api.py b/tests/test_api.py index 40a3e2b8..28415a0b 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -638,22 +638,21 @@ def test_database_page_for_database_with_dot_in_name(app_client_with_dot): @pytest.mark.asyncio async def test_custom_sql(ds_client): response = await ds_client.get( - "/fixtures.json?sql=select+content+from+simple_primary_key&_shape=objects" + "/fixtures.json?sql=select+content+from+simple_primary_key" ) data = response.json() - assert {"sql": "select content from simple_primary_key", "params": {}} == data[ - "query" - ] - assert [ - {"content": "hello"}, - {"content": "world"}, - {"content": ""}, - {"content": "RENDER_CELL_DEMO"}, - {"content": "RENDER_CELL_ASYNC"}, - ] == data["rows"] - assert ["content"] == data["columns"] - assert "fixtures" == data["database"] - assert not data["truncated"] + assert data == { + "rows": [ + {"content": "hello"}, + {"content": "world"}, + {"content": ""}, + {"content": "RENDER_CELL_DEMO"}, + {"content": "RENDER_CELL_ASYNC"}, + ], + "columns": ["content"], + "ok": True, + "truncated": False, + } def test_sql_time_limit(app_client_shorter_time_limit): diff --git a/tests/test_cli_serve_get.py b/tests/test_cli_serve_get.py index ac44e1e2..2e0390bb 100644 --- a/tests/test_cli_serve_get.py +++ b/tests/test_cli_serve_get.py @@ -36,7 +36,6 @@ def test_serve_with_get(tmp_path_factory): ) assert 0 == result.exit_code, result.output assert { - "database": "_memory", "truncated": False, "columns": ["sqlite_version()"], }.items() <= json.loads(result.output).items() diff --git a/tests/test_html.py b/tests/test_html.py index eadbd720..6c3860d7 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -248,6 +248,9 @@ async def test_css_classes_on_body(ds_client, path, expected_classes): assert classes == expected_classes +templates_considered_re = re.compile(r"") + + @pytest.mark.asyncio @pytest.mark.parametrize( "path,expected_considered", @@ -271,7 +274,10 @@ async def test_css_classes_on_body(ds_client, path, expected_classes): async def test_templates_considered(ds_client, path, expected_considered): response = await ds_client.get(path) assert response.status_code == 200 - assert f"" in response.text + match = templates_considered_re.search(response.text) + assert match, "No templates considered comment found" + actual_considered = match.group(1) + assert actual_considered == expected_considered @pytest.mark.asyncio diff --git a/tests/test_internals_datasette.py b/tests/test_internals_datasette.py index 3d5bb2da..d59ff729 100644 --- a/tests/test_internals_datasette.py +++ b/tests/test_internals_datasette.py @@ -1,10 +1,12 @@ """ Tests for the datasette.app.Datasette class """ -from datasette import Forbidden +import dataclasses +from datasette import Forbidden, Context from datasette.app import Datasette, Database from itsdangerous import BadSignature import pytest +from typing import Optional @pytest.fixture @@ -136,6 +138,22 @@ async def test_datasette_render_template_no_request(): assert "Error " in rendered +@pytest.mark.asyncio +async def test_datasette_render_template_with_dataclass(): + @dataclasses.dataclass + class ExampleContext(Context): + title: str + status: int + error: str + + context = ExampleContext(title="Hello", status=200, error="Error message") + ds = Datasette(memory=True) + await ds.invoke_startup() + rendered = await ds.render_template("error.html", context) + assert "

Hello

" in rendered + assert "Error message" in rendered + + def test_datasette_error_if_string_not_list(tmpdir): # https://github.com/simonw/datasette/issues/1985 db_path = str(tmpdir / "data.db") diff --git a/tests/test_messages.py b/tests/test_messages.py index 8417b9ae..a7e4d046 100644 --- a/tests/test_messages.py +++ b/tests/test_messages.py @@ -12,7 +12,7 @@ import pytest ], ) async def test_add_message_sets_cookie(ds_client, qs, expected): - response = await ds_client.get(f"/fixtures.message?{qs}") + response = await ds_client.get(f"/fixtures.message?sql=select+1&{qs}") signed = response.cookies["ds_messages"] decoded = ds_client.ds.unsign(signed, "messages") assert expected == decoded @@ -21,7 +21,9 @@ async def test_add_message_sets_cookie(ds_client, qs, expected): @pytest.mark.asyncio async def test_messages_are_displayed_and_cleared(ds_client): # First set the message cookie - set_msg_response = await ds_client.get("/fixtures.message?add_msg=xmessagex") + set_msg_response = await ds_client.get( + "/fixtures.message?sql=select+1&add_msg=xmessagex" + ) # Now access a page that displays messages response = await ds_client.get("/", cookies=set_msg_response.cookies) # Messages should be in that HTML diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 6971bbf7..28fe720f 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -121,9 +121,8 @@ async def test_hook_extra_css_urls(ds_client, path, expected_decoded_object): ][0]["href"] # This link has a base64-encoded JSON blob in it encoded = special_href.split("/")[3] - assert expected_decoded_object == json.loads( - base64.b64decode(encoded).decode("utf8") - ) + actual_decoded_object = json.loads(base64.b64decode(encoded).decode("utf8")) + assert expected_decoded_object == actual_decoded_object @pytest.mark.asyncio diff --git a/tests/test_table_api.py b/tests/test_table_api.py index cd664ffb..46d1c9b8 100644 --- a/tests/test_table_api.py +++ b/tests/test_table_api.py @@ -700,7 +700,6 @@ async def test_max_returned_rows(ds_client): "/fixtures.json?sql=select+content+from+no_primary_key" ) data = response.json() - assert {"sql": "select content from no_primary_key", "params": {}} == data["query"] assert data["truncated"] assert 100 == len(data["rows"]) From cd57b0f71234273156cb1eba3f9153b9e27ac14d Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 8 Aug 2023 06:45:04 -0700 Subject: [PATCH 054/540] Brought back parameter fields, closes #2132 --- datasette/views/database.py | 19 +++++++++++++++++-- tests/test_html.py | 16 ++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/datasette/views/database.py b/datasette/views/database.py index 77f3f5b0..0770a380 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -506,6 +506,21 @@ async def query_view( show_hide_text = "hide" hide_sql = show_hide_text == "show" + # 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("_") + } + + # 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, @@ -513,7 +528,7 @@ async def query_view( database=database, query={ "sql": sql, - # TODO: Params? + "params": params, }, canned_query=None, private=private, @@ -526,7 +541,7 @@ async def query_view( editable=True, # TODO allow_execute_sql=allow_execute_sql, tables=await get_tables(datasette, request, db), - named_parameter_values={}, # TODO + named_parameter_values=named_parameter_values, edit_sql_url="todo", display_rows=await display_rows( datasette, database, request, rows, columns diff --git a/tests/test_html.py b/tests/test_html.py index 6c3860d7..7856bc27 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -295,6 +295,22 @@ async def test_query_json_csv_export_links(ds_client): assert 'CSV' in response.text +@pytest.mark.asyncio +async def test_query_parameter_form_fields(ds_client): + response = await ds_client.get("/fixtures?sql=select+:name") + assert response.status_code == 200 + assert ( + ' ' + in response.text + ) + response2 = await ds_client.get("/fixtures?sql=select+:name&name=hello") + assert response2.status_code == 200 + assert ( + ' ' + in response2.text + ) + + @pytest.mark.asyncio async def test_row_html_simple_primary_key(ds_client): response = await ds_client.get("/fixtures/simple_primary_key/1") From 26be9f0445b753fb84c802c356b0791a72269f25 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 9 Aug 2023 08:26:52 -0700 Subject: [PATCH 055/540] Refactored canned query code, replaced old QueryView, closes #2114 --- datasette/templates/query.html | 10 +- datasette/views/database.py | 840 +++++++++++++-------------------- datasette/views/table.py | 60 +-- tests/test_canned_queries.py | 8 +- 4 files changed, 345 insertions(+), 573 deletions(-) diff --git a/datasette/templates/query.html b/datasette/templates/query.html index 7ffc250a..fc3b8527 100644 --- a/datasette/templates/query.html +++ b/datasette/templates/query.html @@ -24,7 +24,7 @@ {% block content %} -{% if canned_write and db_is_immutable %} +{% if canned_query_write and db_is_immutable %}

This query cannot be executed because the database is immutable.

{% endif %} @@ -32,7 +32,7 @@ {% 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 %} ({{ show_hide_text }}) {% endif %}

@@ -61,8 +61,8 @@ {% endif %}

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

@@ -87,7 +87,7 @@ {% 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 056/540] 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 057/540] 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 058/540] 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 059/540] 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 060/540] 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 061/540] 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 062/540] 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 063/540] 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 064/540] 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 %} -