From 59a5d336bd4336bc53103922ada4bf726f4336c9 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 22 Aug 2023 19:07:10 -0700 Subject: [PATCH 001/474] Configured and applied isort, refs #516 --- .isort.cfg | 6 +- datasette/__init__.py | 5 +- datasette/actor_auth_cookie.py | 8 ++- datasette/app.py | 87 ++++++++++------------- datasette/blob_renderer.py | 7 +- datasette/cli.py | 36 +++++----- datasette/database.py | 13 ++-- datasette/default_magic_parameters.py | 3 +- datasette/default_permissions.py | 8 ++- datasette/facets.py | 3 +- datasette/filters.py | 8 ++- datasette/forbidden.py | 3 +- datasette/handle_exception.py | 16 ++--- datasette/hookspecs.py | 3 +- datasette/inspect.py | 5 +- datasette/plugins.py | 6 +- datasette/publish/cloudrun.py | 8 ++- datasette/publish/common.py | 6 +- datasette/publish/heroku.py | 12 ++-- datasette/renderer.py | 5 +- datasette/tracer.py | 7 +- datasette/url_builder.py | 3 +- datasette/utils/__init__.py | 19 ++--- datasette/utils/asgi.py | 8 ++- datasette/utils/check_callable.py | 2 +- datasette/utils/internal_db.py | 1 + datasette/utils/shutil_backport.py | 2 +- datasette/utils/testing.py | 5 +- datasette/views/base.py | 17 ++--- datasette/views/database.py | 23 +++--- datasette/views/index.py | 3 +- datasette/views/row.py | 19 ++--- datasette/views/special.py | 10 +-- datasette/views/table.py | 25 +++---- docs/metadata_doc.py | 3 +- setup.py | 3 +- tests/conftest.py | 11 +-- tests/fixtures.py | 11 +-- tests/plugins/my_plugin.py | 19 +++-- tests/plugins/my_plugin_2.py | 8 ++- tests/plugins/register_output_renderer.py | 3 +- tests/plugins/sleep_sql_function.py | 3 +- tests/test_api.py | 33 +++++---- tests/test_api_write.py | 6 +- tests/test_auth.py | 13 ++-- tests/test_base_view.py | 8 ++- tests/test_black.py | 9 +-- tests/test_canned_queries.py | 8 ++- tests/test_cli.py | 30 ++++---- tests/test_cli_serve_get.py | 8 ++- tests/test_cli_serve_server.py | 3 +- tests/test_config_dir.py | 6 +- tests/test_crossdb.py | 9 ++- tests/test_csv.py | 6 +- tests/test_custom_pages.py | 2 + tests/test_docs.py | 8 ++- tests/test_facets.py | 11 +-- tests/test_filters.py | 5 +- tests/test_html.py | 15 ++-- tests/test_internals_database.py | 13 ++-- tests/test_internals_datasette.py | 10 +-- tests/test_internals_request.py | 4 +- tests/test_internals_response.py | 3 +- tests/test_internals_urls.py | 3 +- tests/test_load_extensions.py | 6 +- tests/test_messages.py | 3 +- tests/test_package.py | 8 ++- tests/test_permissions.py | 19 ++--- tests/test_plugins.py | 35 +++++---- tests/test_publish_cloudrun.py | 10 +-- tests/test_publish_heroku.py | 8 ++- tests/test_routes.py | 5 +- tests/test_spatialite.py | 8 ++- tests/test_table_api.py | 11 +-- tests/test_table_html.py | 14 ++-- tests/test_tracer.py | 1 + tests/test_utils.py | 12 ++-- tests/test_utils_check_callable.py | 3 +- 78 files changed, 446 insertions(+), 363 deletions(-) diff --git a/.isort.cfg b/.isort.cfg index 0cece53b..e3955e56 100644 --- a/.isort.cfg +++ b/.isort.cfg @@ -1,3 +1,7 @@ [settings] multi_line_output=3 - +include_trailing_comma=True +force_grid_wrap=0 +use_parentheses=True +line_length=88 +known_first_party=datasette diff --git a/datasette/__init__.py b/datasette/__init__.py index 271e09ad..8f57d6da 100644 --- a/datasette/__init__.py +++ b/datasette/__init__.py @@ -1,7 +1,8 @@ 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.utils.asgi import Forbidden, NotFound, Request, Response # noqa +from datasette.version import __version__, __version_info__ # noqa from datasette.views import Context # noqa + from .hookspecs import hookimpl # noqa from .hookspecs import hookspec # noqa diff --git a/datasette/actor_auth_cookie.py b/datasette/actor_auth_cookie.py index 368213af..7503f1d5 100644 --- a/datasette/actor_auth_cookie.py +++ b/datasette/actor_auth_cookie.py @@ -1,8 +1,10 @@ -from datasette import hookimpl -from itsdangerous import BadSignature -from datasette.utils import baseconv import time +from itsdangerous import BadSignature + +from datasette import hookimpl +from datasette.utils import baseconv + @hookimpl def actor_from_request(datasette, request): diff --git a/datasette/app.py b/datasette/app.py index 1871aeb1..149caccc 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -1,18 +1,13 @@ import asyncio -from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Union -import asgi_csrf import collections import dataclasses import datetime import functools import glob import hashlib -import httpx import inspect -from itsdangerous import BadSignature import json import os -import pkg_resources import re import secrets import sys @@ -22,47 +17,25 @@ import types import urllib.parse from concurrent import futures from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Union -from markupsafe import Markup, escape -from itsdangerous import URLSafeSerializer -from jinja2 import ( - ChoiceLoader, - Environment, - FileSystemLoader, - PrefixLoader, -) +import asgi_csrf +import httpx +import pkg_resources +from itsdangerous import BadSignature, URLSafeSerializer +from jinja2 import ChoiceLoader, Environment, FileSystemLoader, PrefixLoader from jinja2.environment import Template from jinja2.exceptions import TemplateNotFound +from markupsafe import Markup, escape -from .views import Context -from .views.base import ureg -from .views.database import database_download, DatabaseView, TableCreateView -from .views.index import IndexView -from .views.special import ( - JsonDataView, - PatternPortfolioView, - AuthTokenView, - ApiExplorerView, - CreateTokenView, - LogoutView, - AllowDebugView, - PermissionsDebugView, - MessagesDebugView, -) -from .views.table import ( - TableInsertView, - TableUpsertView, - TableDropView, - table_view, -) -from .views.row import RowView, RowDeleteView, RowUpdateView -from .renderer import json_renderer -from .url_builder import Urls from .database import Database, QueryInterrupted - +from .plugins import DEFAULT_PLUGINS, get_plugins, pm +from .renderer import json_renderer +from .tracer import AsgiTracer +from .url_builder import Urls from .utils import ( - PrefixedUrlString, SPATIALITE_FUNCTIONS, + PrefixedUrlString, StartupError, async_call_with_supported_arguments, await_me_maybe, @@ -76,34 +49,46 @@ from .utils import ( parse_metadata, resolve_env_secrets, resolve_routes, + row_sql_params_pks, tilde_decode, to_css_class, urlsafe_components, - row_sql_params_pks, ) from .utils.asgi import ( AsgiLifespan, + AsgiRunOnFirstRequest, + DatabaseNotFound, Forbidden, NotFound, - DatabaseNotFound, - TableNotFound, - RowNotFound, Request, Response, - AsgiRunOnFirstRequest, - asgi_static, + RowNotFound, + TableNotFound, asgi_send, asgi_send_file, asgi_send_redirect, + asgi_static, ) from .utils.internal_db import init_internal_db, populate_schema_tables -from .utils.sqlite import ( - sqlite3, - using_pysqlite3, -) -from .tracer import AsgiTracer -from .plugins import pm, DEFAULT_PLUGINS, get_plugins +from .utils.sqlite import sqlite3, using_pysqlite3 from .version import __version__ +from .views import Context +from .views.base import ureg +from .views.database import DatabaseView, TableCreateView, database_download +from .views.index import IndexView +from .views.row import RowDeleteView, RowUpdateView, RowView +from .views.special import ( + AllowDebugView, + ApiExplorerView, + AuthTokenView, + CreateTokenView, + JsonDataView, + LogoutView, + MessagesDebugView, + PatternPortfolioView, + PermissionsDebugView, +) +from .views.table import TableDropView, TableInsertView, TableUpsertView, table_view app_root = Path(__file__).parent.parent diff --git a/datasette/blob_renderer.py b/datasette/blob_renderer.py index 4d8c6bea..b6c8b77f 100644 --- a/datasette/blob_renderer.py +++ b/datasette/blob_renderer.py @@ -1,8 +1,9 @@ -from datasette import hookimpl -from datasette.utils.asgi import Response, BadRequest -from datasette.utils import to_css_class import hashlib +from datasette import hookimpl +from datasette.utils import to_css_class +from datasette.utils.asgi import BadRequest, Response + _BLOB_COLUMN = "_blob_column" _BLOB_HASH = "_blob_hash" diff --git a/datasette/cli.py b/datasette/cli.py index dbbfaba7..1ebfb044 100644 --- a/datasette/cli.py +++ b/datasette/cli.py @@ -1,41 +1,43 @@ import asyncio -import uvicorn -import click -from click import formatting -from click.types import CompositeParamType -from click_default_group import DefaultGroup import functools import json import os import pathlib -from runpy import run_module import shutil -from subprocess import call import sys import textwrap import webbrowser +from runpy import run_module +from subprocess import call + +import click +import uvicorn +from click import formatting +from click.types import CompositeParamType +from click_default_group import DefaultGroup + from .app import ( - OBSOLETE_SETTINGS, - Datasette, DEFAULT_SETTINGS, + OBSOLETE_SETTINGS, SETTINGS, SQLITE_LIMIT_ATTACHED, + Datasette, pm, ) from .utils import ( - LoadExtension, - StartupError, - check_connection, - find_spatialite, - parse_metadata, ConnectionProblem, + LoadExtension, SpatialiteConnectionProblem, - initial_path_for_datasette, - temporary_docker_directory, - value_as_boolean, SpatialiteNotFound, + StartupError, StaticMount, ValueAsBooleanError, + check_connection, + find_spatialite, + initial_path_for_datasette, + parse_metadata, + temporary_docker_directory, + value_as_boolean, ) from .utils.sqlite import sqlite3 from .utils.testing import TestClient diff --git a/datasette/database.py b/datasette/database.py index af39ac9e..bd17427d 100644 --- a/datasette/database.py +++ b/datasette/database.py @@ -1,13 +1,15 @@ import asyncio -from collections import namedtuple -from pathlib import Path import hashlib -import janus import queue import sys import threading import uuid +from collections import namedtuple +from pathlib import Path +import janus + +from .inspect import inspect_hash from .tracer import trace from .utils import ( detect_fts, @@ -15,12 +17,11 @@ from .utils import ( detect_spatialite, get_all_foreign_keys, get_outbound_foreign_keys, - sqlite_timelimit, sqlite3, - table_columns, + sqlite_timelimit, table_column_details, + table_columns, ) -from .inspect import inspect_hash connections = threading.local() diff --git a/datasette/default_magic_parameters.py b/datasette/default_magic_parameters.py index 19382207..edce6f3d 100644 --- a/datasette/default_magic_parameters.py +++ b/datasette/default_magic_parameters.py @@ -1,8 +1,9 @@ -from datasette import hookimpl import datetime import os import time +from datasette import hookimpl + def header(key, request): key = key.replace("_", "-").encode("utf-8") diff --git a/datasette/default_permissions.py b/datasette/default_permissions.py index 63a66c3c..a6a667bc 100644 --- a/datasette/default_permissions.py +++ b/datasette/default_permissions.py @@ -1,8 +1,10 @@ -from datasette import hookimpl, Permission -from datasette.utils import actor_matches_allow -import itsdangerous import time +import itsdangerous + +from datasette import Permission, hookimpl +from datasette.utils import actor_matches_allow + @hookimpl def register_permissions(): diff --git a/datasette/facets.py b/datasette/facets.py index 7fb0c68b..4420832c 100644 --- a/datasette/facets.py +++ b/datasette/facets.py @@ -1,12 +1,13 @@ import json import urllib + from datasette import hookimpl from datasette.database import QueryInterrupted from datasette.utils import ( + detect_json1, escape_sqlite, path_with_added_args, path_with_removed_args, - detect_json1, sqlite3, ) diff --git a/datasette/filters.py b/datasette/filters.py index 5ea3488b..0132b003 100644 --- a/datasette/filters.py +++ b/datasette/filters.py @@ -1,8 +1,10 @@ -from datasette import hookimpl -from datasette.views.base import DatasetteError -from datasette.utils.asgi import BadRequest import json import numbers + +from datasette import hookimpl +from datasette.utils.asgi import BadRequest +from datasette.views.base import DatasetteError + from .utils import detect_json1, escape_sqlite, path_with_removed_args diff --git a/datasette/forbidden.py b/datasette/forbidden.py index 156a44d4..de1edb5b 100644 --- a/datasette/forbidden.py +++ b/datasette/forbidden.py @@ -1,5 +1,6 @@ from os import stat -from datasette import hookimpl, Response + +from datasette import Response, hookimpl @hookimpl(trylast=True) diff --git a/datasette/handle_exception.py b/datasette/handle_exception.py index 8b7e83e3..13fa23b1 100644 --- a/datasette/handle_exception.py +++ b/datasette/handle_exception.py @@ -1,14 +1,14 @@ -from datasette import hookimpl, Response -from .utils import await_me_maybe, add_cors_headers -from .utils.asgi import ( - Base400, - Forbidden, -) -from .views.base import DatasetteError -from markupsafe import Markup import pdb import traceback + +from markupsafe import Markup + +from datasette import Response, hookimpl + from .plugins import pm +from .utils import add_cors_headers, await_me_maybe +from .utils.asgi import Base400, Forbidden +from .views.base import DatasetteError try: import rich diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index 801073fc..500d1b33 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -1,5 +1,4 @@ -from pluggy import HookimplMarker -from pluggy import HookspecMarker +from pluggy import HookimplMarker, HookspecMarker hookspec = HookspecMarker("datasette") hookimpl = HookimplMarker("datasette") diff --git a/datasette/inspect.py b/datasette/inspect.py index ede142d0..18bbf860 100644 --- a/datasette/inspect.py +++ b/datasette/inspect.py @@ -1,16 +1,15 @@ import hashlib from .utils import ( - detect_spatialite, detect_fts, detect_primary_keys, + detect_spatialite, escape_sqlite, get_all_foreign_keys, - table_columns, sqlite3, + table_columns, ) - HASH_BLOCK_SIZE = 1024 * 1024 diff --git a/datasette/plugins.py b/datasette/plugins.py index fef0c8e9..bc4aade5 100644 --- a/datasette/plugins.py +++ b/datasette/plugins.py @@ -1,7 +1,9 @@ import importlib -import pluggy -import pkg_resources import sys + +import pkg_resources +import pluggy + from . import hookspecs DEFAULT_PLUGINS = ( diff --git a/datasette/publish/cloudrun.py b/datasette/publish/cloudrun.py index 760ff0d1..caecee93 100644 --- a/datasette/publish/cloudrun.py +++ b/datasette/publish/cloudrun.py @@ -1,15 +1,17 @@ -from datasette import hookimpl -import click import json import os import re from subprocess import check_call, check_output +import click + +from datasette import hookimpl + +from ..utils import temporary_docker_directory from .common import ( add_common_publish_arguments_and_options, fail_if_publish_binary_not_installed, ) -from ..utils import temporary_docker_directory @hookimpl diff --git a/datasette/publish/common.py b/datasette/publish/common.py index 29665eb3..63c66a3f 100644 --- a/datasette/publish/common.py +++ b/datasette/publish/common.py @@ -1,9 +1,11 @@ -from ..utils import StaticMount -import click import os import shutil import sys +import click + +from ..utils import StaticMount + def add_common_publish_arguments_and_options(subcommand): for decorator in reversed( diff --git a/datasette/publish/heroku.py b/datasette/publish/heroku.py index f576a346..cc24be31 100644 --- a/datasette/publish/heroku.py +++ b/datasette/publish/heroku.py @@ -1,19 +1,21 @@ -from contextlib import contextmanager -from datasette import hookimpl -import click import json import os import pathlib import shlex import shutil -from subprocess import call, check_output import tempfile +from contextlib import contextmanager +from subprocess import call, check_output + +import click + +from datasette import hookimpl +from datasette.utils import link_or_copy, link_or_copy_directory, parse_metadata from .common import ( add_common_publish_arguments_and_options, fail_if_publish_binary_not_installed, ) -from datasette.utils import link_or_copy, link_or_copy_directory, parse_metadata @hookimpl diff --git a/datasette/renderer.py b/datasette/renderer.py index 224031a7..f743b46e 100644 --- a/datasette/renderer.py +++ b/datasette/renderer.py @@ -1,10 +1,11 @@ import json + from datasette.utils import ( - value_as_boolean, - remove_infinites, CustomJSONEncoder, path_from_row_pks, + remove_infinites, sqlite3, + value_as_boolean, ) from datasette.utils.asgi import Response diff --git a/datasette/tracer.py b/datasette/tracer.py index fc7338b0..0ad1b556 100644 --- a/datasette/tracer.py +++ b/datasette/tracer.py @@ -1,10 +1,11 @@ import asyncio +import json +import time +import traceback from contextlib import contextmanager from contextvars import ContextVar + from markupsafe import escape -import time -import json -import traceback tracers = {} diff --git a/datasette/url_builder.py b/datasette/url_builder.py index 574bf3c1..dc2108a2 100644 --- a/datasette/url_builder.py +++ b/datasette/url_builder.py @@ -1,6 +1,7 @@ -from .utils import tilde_encode, path_with_format, HASH_LENGTH, PrefixedUrlString import urllib +from .utils import HASH_LENGTH, PrefixedUrlString, path_with_format, tilde_encode + class Urls: def __init__(self, ds): diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py index c388673d..2ea4f07d 100644 --- a/datasette/utils/__init__.py +++ b/datasette/utils/__init__.py @@ -1,28 +1,29 @@ import asyncio -from contextlib import contextmanager -import click -from collections import OrderedDict, namedtuple, Counter import base64 import hashlib import inspect import json -import markupsafe -import mergedeep import os import re +import secrets import shlex +import shutil import tempfile -import typing import time import types -import secrets -import shutil +import typing import urllib +from collections import Counter, OrderedDict, namedtuple +from contextlib import contextmanager + +import click +import markupsafe +import mergedeep import yaml + from .shutil_backport import copytree from .sqlite import sqlite3, supports_table_xinfo - # From https://www.sqlite.org/lang_keywords.html reserved_words = set( ( diff --git a/datasette/utils/asgi.py b/datasette/utils/asgi.py index b2c6f3ab..41de474a 100644 --- a/datasette/utils/asgi.py +++ b/datasette/utils/asgi.py @@ -1,12 +1,14 @@ import json -from datasette.utils import MultiParams +from http.cookies import Morsel, SimpleCookie from mimetypes import guess_type -from urllib.parse import parse_qs, urlunparse, parse_qsl from pathlib import Path -from http.cookies import SimpleCookie, Morsel +from urllib.parse import parse_qs, parse_qsl, urlunparse + import aiofiles import aiofiles.os +from datasette.utils import MultiParams + # Workaround for adding samesite support to pre 3.8 python Morsel._reserved["samesite"] = "SameSite" # Thanks, Starlette: diff --git a/datasette/utils/check_callable.py b/datasette/utils/check_callable.py index 5b8a30ac..7e0875ab 100644 --- a/datasette/utils/check_callable.py +++ b/datasette/utils/check_callable.py @@ -1,6 +1,6 @@ import asyncio import types -from typing import NamedTuple, Any +from typing import Any, NamedTuple class CallableStatus(NamedTuple): diff --git a/datasette/utils/internal_db.py b/datasette/utils/internal_db.py index e4b49e80..cd40e385 100644 --- a/datasette/utils/internal_db.py +++ b/datasette/utils/internal_db.py @@ -1,4 +1,5 @@ import textwrap + from datasette.utils import table_column_details diff --git a/datasette/utils/shutil_backport.py b/datasette/utils/shutil_backport.py index dbe22404..7fe82709 100644 --- a/datasette/utils/shutil_backport.py +++ b/datasette/utils/shutil_backport.py @@ -5,7 +5,7 @@ This code is licensed under the Python License: https://github.com/python/cpython/blob/v3.8.3/LICENSE """ import os -from shutil import copy, copy2, copystat, Error +from shutil import Error, copy, copy2, copystat def _copytree( diff --git a/datasette/utils/testing.py b/datasette/utils/testing.py index d4990784..50b1f60d 100644 --- a/datasette/utils/testing.py +++ b/datasette/utils/testing.py @@ -1,6 +1,7 @@ -from asgiref.sync import async_to_sync -from urllib.parse import urlencode import json +from urllib.parse import urlencode + +from asgiref.sync import async_to_sync # These wrapper classes pre-date the introduction of # datasette.client and httpx to Datasette. They could diff --git a/datasette/views/base.py b/datasette/views/base.py index 0080b33c..c23c7417 100644 --- a/datasette/views/base.py +++ b/datasette/views/base.py @@ -5,33 +5,26 @@ import sys import textwrap import time import urllib -from markupsafe import escape - import pint +from markupsafe import escape from datasette import __version__ from datasette.database import QueryInterrupted -from datasette.utils.asgi import Request from datasette.utils import ( - add_cors_headers, - await_me_maybe, EscapeHtmlWriter, InvalidSql, LimitedWriter, + add_cors_headers, + await_me_maybe, call_with_supported_arguments, path_from_row_pks, path_with_added_args, - path_with_removed_args, path_with_format, + path_with_removed_args, sqlite3, ) -from datasette.utils.asgi import ( - AsgiStream, - NotFound, - Response, - BadRequest, -) +from datasette.utils.asgi import AsgiStream, BadRequest, NotFound, Request, Response ureg = pint.UnitRegistry() diff --git a/datasette/views/database.py b/datasette/views/database.py index d9abc38a..8c7d5643 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -1,36 +1,37 @@ -from dataclasses import dataclass, field -from typing import Callable -from urllib.parse import parse_qsl, urlencode import asyncio import hashlib import itertools import json -import markupsafe import os import re -import sqlite_utils import textwrap +from dataclasses import dataclass, field +from typing import Callable +from urllib.parse import parse_qsl, urlencode + +import markupsafe +import sqlite_utils from datasette.database import QueryInterrupted +from datasette.plugins import pm from datasette.utils import ( + InvalidSql, add_cors_headers, await_me_maybe, call_with_supported_arguments, derive_named_parameters, format_bytes, - tilde_decode, - to_css_class, - validate_sql_select, is_url, path_with_added_args, path_with_format, path_with_removed_args, sqlite3, + tilde_decode, + to_css_class, truncate_url, - InvalidSql, + validate_sql_select, ) -from datasette.utils.asgi import AsgiFileDownload, NotFound, Response, Forbidden -from datasette.plugins import pm +from datasette.utils.asgi import AsgiFileDownload, Forbidden, NotFound, Response from .base import BaseView, DatasetteError, View, _error, stream_csv diff --git a/datasette/views/index.py b/datasette/views/index.py index 95b29302..1fb44d68 100644 --- a/datasette/views/index.py +++ b/datasette/views/index.py @@ -1,13 +1,12 @@ import hashlib import json -from datasette.utils import add_cors_headers, CustomJSONEncoder +from datasette.utils import CustomJSONEncoder, add_cors_headers from datasette.utils.asgi import Response from datasette.version import __version__ from .base import BaseView - # Truncate table list on homepage at: TRUNCATE_AT = 5 diff --git a/datasette/views/row.py b/datasette/views/row.py index 8f07a662..2d437fab 100644 --- a/datasette/views/row.py +++ b/datasette/views/row.py @@ -1,15 +1,18 @@ -from datasette.utils.asgi import NotFound, Forbidden, Response +import json + +import sqlite_utils + from datasette.database import QueryInterrupted -from .base import DataView, BaseView, _error from datasette.utils import ( - tilde_decode, - urlsafe_components, - to_css_class, escape_sqlite, row_sql_params_pks, + tilde_decode, + to_css_class, + urlsafe_components, ) -import json -import sqlite_utils +from datasette.utils.asgi import Forbidden, NotFound, Response + +from .base import BaseView, DataView, _error from .table import display_columns_and_rows @@ -152,7 +155,7 @@ class RowError(Exception): async def _resolve_row_and_check_permission(datasette, request, permission): - from datasette.app import DatabaseNotFound, TableNotFound, RowNotFound + from datasette.app import DatabaseNotFound, RowNotFound, TableNotFound try: resolved = await datasette.resolve_row(request) diff --git a/datasette/views/special.py b/datasette/views/special.py index c45a3eca..74e9b2cd 100644 --- a/datasette/views/special.py +++ b/datasette/views/special.py @@ -1,14 +1,16 @@ import json -from datasette.utils.asgi import Response, Forbidden +import secrets +import urllib + from datasette.utils import ( actor_matches_allow, add_cors_headers, - tilde_encode, tilde_decode, + tilde_encode, ) +from datasette.utils.asgi import Forbidden, Response + from .base import BaseView, View -import secrets -import urllib class JsonDataView(BaseView): diff --git a/datasette/views/table.py b/datasette/views/table.py index 6df8b915..0bdf1298 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -3,40 +3,41 @@ import itertools import json import urllib -from asyncinject import Registry import markupsafe +import sqlite_utils +from asyncinject import Registry -from datasette.plugins import pm -from datasette.database import QueryInterrupted from datasette import tracer +from datasette.database import QueryInterrupted +from datasette.filters import Filters +from datasette.plugins import pm from datasette.utils import ( + CustomRow, + InvalidSql, add_cors_headers, + append_querystring, await_me_maybe, call_with_supported_arguments, - CustomRow, - append_querystring, compound_keys_after_sql, - format_bytes, - tilde_encode, escape_sqlite, filters_should_redirect, + format_bytes, is_url, path_from_row_pks, path_with_added_args, path_with_format, path_with_removed_args, path_with_replaced_args, + sqlite3, + tilde_encode, to_css_class, truncate_url, urlsafe_components, value_as_boolean, - InvalidSql, - sqlite3, ) from datasette.utils.asgi import BadRequest, Forbidden, NotFound, Response -from datasette.filters import Filters -import sqlite_utils -from .base import BaseView, DatasetteError, ureg, _error, stream_csv + +from .base import BaseView, DatasetteError, _error, stream_csv, ureg from .database import QueryView LINK_WITH_LABEL = ( diff --git a/docs/metadata_doc.py b/docs/metadata_doc.py index 537830ca..a51de509 100644 --- a/docs/metadata_doc.py +++ b/docs/metadata_doc.py @@ -1,7 +1,8 @@ import json import textwrap -from yaml import safe_dump + from ruamel.yaml import round_trip_load +from yaml import safe_dump def metadata_example(cog, data=None, yaml=None): diff --git a/setup.py b/setup.py index 3a105523..332d91ec 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,7 @@ -from setuptools import setup, find_packages import os +from setuptools import find_packages, setup + def get_long_description(): with open( diff --git a/tests/conftest.py b/tests/conftest.py index fb7f768e..1fdce671 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,15 +1,15 @@ import asyncio -import httpx import os import pathlib -import pytest -import pytest_asyncio import re import subprocess import tempfile import time -import trustme +import httpx +import pytest +import pytest_asyncio +import trustme try: import pysqlite3 as sqlite3 @@ -41,6 +41,7 @@ def wait_until_responds(url, timeout=5.0, client=httpx, **kwargs): @pytest_asyncio.fixture async def ds_client(): from datasette.app import Datasette + from .fixtures import METADATA, PLUGINS_DIR global _ds_client @@ -59,7 +60,7 @@ async def ds_client(): "num_sql_threads": 1, }, ) - from .fixtures import TABLES, TABLE_PARAMETERIZED_SQL + from .fixtures import TABLE_PARAMETERIZED_SQL, TABLES db = ds.add_memory_database("fixtures") ds.remove_database("_memory") diff --git a/tests/fixtures.py b/tests/fixtures.py index a6700239..8b5ffe4e 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -1,18 +1,19 @@ -from datasette.app import Datasette -from datasette.utils.sqlite import sqlite3 -from datasette.utils.testing import TestClient -import click import contextlib import itertools import json import os import pathlib -import pytest import random import string import tempfile import textwrap +import click +import pytest + +from datasette.app import Datasette +from datasette.utils.sqlite import sqlite3 +from datasette.utils.testing import TestClient # This temp file is used by one of the plugin config tests TEMP_PLUGIN_SECRET_FILE = os.path.join(tempfile.gettempdir(), "plugin-secret") diff --git a/tests/plugins/my_plugin.py b/tests/plugins/my_plugin.py index eb70d9bd..ce57f9f8 100644 --- a/tests/plugins/my_plugin.py +++ b/tests/plugins/my_plugin.py @@ -1,13 +1,14 @@ import asyncio -from datasette import hookimpl, Permission -from datasette.facets import Facet -from datasette import tracer -from datasette.utils import path_with_added_args -from datasette.utils.asgi import asgi_send_json, Response import base64 -import pint import json +import pint + +from datasette import Permission, hookimpl, tracer +from datasette.facets import Facet +from datasette.utils import path_with_added_args +from datasette.utils.asgi import Response, asgi_send_json + ureg = pint.UnitRegistry() @@ -326,11 +327,7 @@ def startup(datasette): datasette._startup_hook_fired = True # And test some import shortcuts too - from datasette import Response - from datasette import Forbidden - from datasette import NotFound - from datasette import hookimpl - from datasette import actor_matches_allow + from datasette import Forbidden, NotFound, Response, actor_matches_allow, hookimpl _ = (Response, Forbidden, NotFound, hookimpl, actor_matches_allow) diff --git a/tests/plugins/my_plugin_2.py b/tests/plugins/my_plugin_2.py index d588342c..31467b8e 100644 --- a/tests/plugins/my_plugin_2.py +++ b/tests/plugins/my_plugin_2.py @@ -1,8 +1,10 @@ +import json +from functools import wraps + +import markupsafe + from datasette import hookimpl from datasette.utils.asgi import Response -from functools import wraps -import markupsafe -import json @hookimpl diff --git a/tests/plugins/register_output_renderer.py b/tests/plugins/register_output_renderer.py index cfe15215..1bb66003 100644 --- a/tests/plugins/register_output_renderer.py +++ b/tests/plugins/register_output_renderer.py @@ -1,6 +1,7 @@ +import json + from datasette import hookimpl from datasette.utils.asgi import Response -import json async def can_render( diff --git a/tests/plugins/sleep_sql_function.py b/tests/plugins/sleep_sql_function.py index d4b32a09..2fca1d66 100644 --- a/tests/plugins/sleep_sql_function.py +++ b/tests/plugins/sleep_sql_function.py @@ -1,6 +1,7 @@ -from datasette import hookimpl import time +from datasette import hookimpl + @hookimpl def prepare_connection(conn): diff --git a/tests/test_api.py b/tests/test_api.py index f96f571e..cfd937c3 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,26 +1,29 @@ +import pathlib +import sys +import urllib + +import pytest + from datasette.app import Datasette from datasette.plugins import DEFAULT_PLUGINS from datasette.utils.sqlite import supports_table_xinfo from datasette.version import __version__ + from .fixtures import ( # noqa - app_client, - app_client_no_files, - app_client_with_dot, - app_client_shorter_time_limit, - app_client_two_attached_databases_one_immutable, - app_client_larger_cache_size, - app_client_with_cors, - app_client_two_attached_databases, - app_client_conflicting_database_names, - app_client_immutable_and_inspect_file, - make_app_client, EXPECTED_PLUGINS, METADATA, + app_client, + app_client_conflicting_database_names, + app_client_immutable_and_inspect_file, + app_client_larger_cache_size, + app_client_no_files, + app_client_shorter_time_limit, + app_client_two_attached_databases, + app_client_two_attached_databases_one_immutable, + app_client_with_cors, + app_client_with_dot, + make_app_client, ) -import pathlib -import pytest -import sys -import urllib @pytest.mark.asyncio diff --git a/tests/test_api_write.py b/tests/test_api_write.py index f27d143f..4202c80b 100644 --- a/tests/test_api_write.py +++ b/tests/test_api_write.py @@ -1,7 +1,9 @@ +import time + +import pytest + from datasette.app import Datasette from datasette.utils import sqlite3 -import pytest -import time @pytest.fixture diff --git a/tests/test_auth.py b/tests/test_auth.py index 33cf9b35..4a0d0475 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,11 +1,14 @@ +import time + +import pytest from bs4 import BeautifulSoup as Soup +from click.testing import CliRunner + +from datasette.cli import cli +from datasette.utils import baseconv + from .fixtures import app_client from .utils import cookie_was_deleted -from click.testing import CliRunner -from datasette.utils import baseconv -from datasette.cli import cli -import pytest -import time @pytest.mark.asyncio diff --git a/tests/test_base_view.py b/tests/test_base_view.py index 2cd4d601..0ce17123 100644 --- a/tests/test_base_view.py +++ b/tests/test_base_view.py @@ -1,8 +1,10 @@ -from datasette.views.base import View +import json + +import pytest + from datasette import Request, Response from datasette.app import Datasette -import json -import pytest +from datasette.views.base import View class GetView(View): diff --git a/tests/test_black.py b/tests/test_black.py index d09b2514..6a534724 100644 --- a/tests/test_black.py +++ b/tests/test_black.py @@ -1,8 +1,9 @@ -import black -from click.testing import CliRunner -from pathlib import Path -import pytest import sys +from pathlib import Path + +import black +import pytest +from click.testing import CliRunner code_root = Path(__file__).parent.parent diff --git a/tests/test_canned_queries.py b/tests/test_canned_queries.py index 5256c24c..89aa2333 100644 --- a/tests/test_canned_queries.py +++ b/tests/test_canned_queries.py @@ -1,8 +1,10 @@ -from bs4 import BeautifulSoup as Soup import json -import pytest import re -from .fixtures import make_app_client, app_client + +import pytest +from bs4 import BeautifulSoup as Soup + +from .fixtures import app_client, make_app_client @pytest.fixture diff --git a/tests/test_cli.py b/tests/test_cli.py index 71f0bbe3..82220e98 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,25 +1,25 @@ -from .fixtures import ( - app_client, - make_app_client, - TestClient as _TestClient, - EXPECTED_PLUGINS, -) import asyncio -from datasette.app import SETTINGS -from datasette.plugins import DEFAULT_PLUGINS -from datasette.cli import cli, serve -from datasette.version import __version__ -from datasette.utils import tilde_encode -from datasette.utils.sqlite import sqlite3 -from click.testing import CliRunner import io import json import pathlib -import pytest import sys import textwrap -from unittest import mock import urllib +from unittest import mock + +import pytest +from click.testing import CliRunner + +from datasette.app import SETTINGS +from datasette.cli import cli, serve +from datasette.plugins import DEFAULT_PLUGINS +from datasette.utils import tilde_encode +from datasette.utils.sqlite import sqlite3 +from datasette.version import __version__ + +from .fixtures import EXPECTED_PLUGINS +from .fixtures import TestClient as _TestClient +from .fixtures import app_client, make_app_client def test_inspect_cli(app_client): diff --git a/tests/test_cli_serve_get.py b/tests/test_cli_serve_get.py index dc7fc1e2..1a9316f6 100644 --- a/tests/test_cli_serve_get.py +++ b/tests/test_cli_serve_get.py @@ -1,8 +1,10 @@ +import json +import textwrap + +from click.testing import CliRunner + from datasette.cli import cli, serve from datasette.plugins import pm -from click.testing import CliRunner -import textwrap -import json def test_serve_with_get(tmp_path_factory): diff --git a/tests/test_cli_serve_server.py b/tests/test_cli_serve_server.py index 47f23c08..b7604bb8 100644 --- a/tests/test_cli_serve_server.py +++ b/tests/test_cli_serve_server.py @@ -1,6 +1,7 @@ +import socket + import httpx import pytest -import socket @pytest.mark.serial diff --git a/tests/test_config_dir.py b/tests/test_config_dir.py index 748412c3..eb5a4cf9 100644 --- a/tests/test_config_dir.py +++ b/tests/test_config_dir.py @@ -1,13 +1,15 @@ import json import pathlib + import pytest +from click.testing import CliRunner from datasette.app import Datasette from datasette.cli import cli -from datasette.utils.sqlite import sqlite3 from datasette.utils import StartupError +from datasette.utils.sqlite import sqlite3 + from .fixtures import TestClient as _TestClient -from click.testing import CliRunner PLUGIN = """ from datasette import hookimpl diff --git a/tests/test_crossdb.py b/tests/test_crossdb.py index 01c51130..4b7a10c3 100644 --- a/tests/test_crossdb.py +++ b/tests/test_crossdb.py @@ -1,7 +1,10 @@ -from datasette.cli import cli -from click.testing import CliRunner -import urllib import sqlite3 +import urllib + +from click.testing import CliRunner + +from datasette.cli import cli + from .fixtures import app_client_two_attached_databases_crossdb_enabled diff --git a/tests/test_csv.py b/tests/test_csv.py index ed83d685..e2504cc8 100644 --- a/tests/test_csv.py +++ b/tests/test_csv.py @@ -1,12 +1,14 @@ -from bs4 import BeautifulSoup as Soup +import urllib.parse + import pytest +from bs4 import BeautifulSoup as Soup + from .fixtures import ( # noqa app_client, app_client_csv_max_mb_one, app_client_with_cors, app_client_with_trace, ) -import urllib.parse EXPECTED_TABLE_CSV = """id,content 1,hello diff --git a/tests/test_custom_pages.py b/tests/test_custom_pages.py index f2cfe394..a1ffe3b1 100644 --- a/tests/test_custom_pages.py +++ b/tests/test_custom_pages.py @@ -1,5 +1,7 @@ import pathlib + import pytest + from .fixtures import make_app_client TEST_TEMPLATE_DIRS = str(pathlib.Path(__file__).parent / "test_templates") diff --git a/tests/test_docs.py b/tests/test_docs.py index e9b813fe..911597e5 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -1,13 +1,15 @@ """ Tests to ensure certain things are documented. """ +import re +from pathlib import Path + +import pytest from click.testing import CliRunner + from datasette import app, utils from datasette.cli import cli from datasette.filters import Filters -from pathlib import Path -import pytest -import re docs_path = Path(__file__).parent.parent / "docs" label_re = re.compile(r"\.\. _([^\s:]+):") diff --git a/tests/test_facets.py b/tests/test_facets.py index 48cc0ff2..04f11fb4 100644 --- a/tests/test_facets.py +++ b/tests/test_facets.py @@ -1,11 +1,14 @@ +import json + +import pytest + from datasette.app import Datasette from datasette.database import Database -from datasette.facets import ColumnFacet, ArrayFacet, DateFacet -from datasette.utils.asgi import Request +from datasette.facets import ArrayFacet, ColumnFacet, DateFacet from datasette.utils import detect_json1 +from datasette.utils.asgi import Request + from .fixtures import make_app_client -import json -import pytest @pytest.mark.asyncio diff --git a/tests/test_filters.py b/tests/test_filters.py index 5b2e9636..586a7a68 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -1,7 +1,8 @@ -from datasette.filters import Filters, through_filters, where_filters, search_filters -from datasette.utils.asgi import Request import pytest +from datasette.filters import Filters, search_filters, through_filters, where_filters +from datasette.utils.asgi import Request + @pytest.mark.parametrize( "args,expected_where,expected_params", diff --git a/tests/test_html.py b/tests/test_html.py index ffc2aef1..09bb6963 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -1,19 +1,22 @@ +import json +import pathlib +import re +import urllib.parse + +import pytest from bs4 import BeautifulSoup as Soup + from datasette.utils import allowed_pragmas + from .fixtures import ( # noqa + METADATA, app_client, app_client_base_url_prefix, app_client_shorter_time_limit, app_client_two_attached_databases, make_app_client, - METADATA, ) from .utils import assert_footer_links, inner_html -import json -import pathlib -import pytest -import re -import urllib.parse def test_homepage(app_client_two_attached_databases): diff --git a/tests/test_internals_database.py b/tests/test_internals_database.py index 647ae7bd..eb270251 100644 --- a/tests/test_internals_database.py +++ b/tests/test_internals_database.py @@ -1,14 +1,17 @@ """ Tests for the datasette.database.Database class """ -from datasette.database import Database, Results, MultipleValues -from datasette.utils.sqlite import sqlite3 -from datasette.utils import Column -from .fixtures import app_client, app_client_two_attached_databases_crossdb_enabled -import pytest import time import uuid +import pytest + +from datasette.database import Database, MultipleValues, Results +from datasette.utils import Column +from datasette.utils.sqlite import sqlite3 + +from .fixtures import app_client, app_client_two_attached_databases_crossdb_enabled + @pytest.fixture def db(app_client): diff --git a/tests/test_internals_datasette.py b/tests/test_internals_datasette.py index d59ff729..460df557 100644 --- a/tests/test_internals_datasette.py +++ b/tests/test_internals_datasette.py @@ -2,12 +2,14 @@ Tests for the datasette.app.Datasette class """ import dataclasses -from datasette import Forbidden, Context -from datasette.app import Datasette, Database -from itsdangerous import BadSignature -import pytest from typing import Optional +import pytest +from itsdangerous import BadSignature + +from datasette import Context, Forbidden +from datasette.app import Database, Datasette + @pytest.fixture def datasette(ds_client): diff --git a/tests/test_internals_request.py b/tests/test_internals_request.py index d1ca1f46..ce29bbd1 100644 --- a/tests/test_internals_request.py +++ b/tests/test_internals_request.py @@ -1,7 +1,9 @@ -from datasette.utils.asgi import Request import json + import pytest +from datasette.utils.asgi import Request + @pytest.mark.asyncio async def test_request_post_vars(): diff --git a/tests/test_internals_response.py b/tests/test_internals_response.py index 820b20b2..358aeb38 100644 --- a/tests/test_internals_response.py +++ b/tests/test_internals_response.py @@ -1,6 +1,7 @@ -from datasette.utils.asgi import Response import pytest +from datasette.utils.asgi import Response + def test_response_html(): response = Response.html("Hello from HTML") diff --git a/tests/test_internals_urls.py b/tests/test_internals_urls.py index d60aafcf..549230e8 100644 --- a/tests/test_internals_urls.py +++ b/tests/test_internals_urls.py @@ -1,6 +1,7 @@ +import pytest + from datasette.app import Datasette from datasette.utils import PrefixedUrlString -import pytest @pytest.fixture(scope="module") diff --git a/tests/test_load_extensions.py b/tests/test_load_extensions.py index 4007e0be..f9ff644b 100644 --- a/tests/test_load_extensions.py +++ b/tests/test_load_extensions.py @@ -1,7 +1,9 @@ -from datasette.app import Datasette -import pytest from pathlib import Path +import pytest + +from datasette.app import Datasette + # not necessarily a full path - the full compiled path looks like "ext.dylib" # or another suffix, but sqlite will, under the hood, decide which file # extension to use based on the operating system (apple=dylib, windows=dll etc) diff --git a/tests/test_messages.py b/tests/test_messages.py index a7e4d046..9078b1cc 100644 --- a/tests/test_messages.py +++ b/tests/test_messages.py @@ -1,6 +1,7 @@ -from .utils import cookie_was_deleted import pytest +from .utils import cookie_was_deleted + @pytest.mark.asyncio @pytest.mark.parametrize( diff --git a/tests/test_package.py b/tests/test_package.py index f05f3ece..43b20589 100644 --- a/tests/test_package.py +++ b/tests/test_package.py @@ -1,9 +1,11 @@ -from click.testing import CliRunner -from datasette import cli -from unittest import mock import os import pathlib +from unittest import mock + import pytest +from click.testing import CliRunner + +from datasette import cli class CaptureDockerfile: diff --git a/tests/test_permissions.py b/tests/test_permissions.py index f940d486..21ba29cc 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -1,17 +1,20 @@ import collections -from datasette.app import Datasette -from datasette.cli import cli -from .fixtures import app_client, assert_permissions_checked, make_app_client -from click.testing import CliRunner -from bs4 import BeautifulSoup as Soup import copy import json -from pprint import pprint -import pytest_asyncio -import pytest import re import time import urllib +from pprint import pprint + +import pytest +import pytest_asyncio +from bs4 import BeautifulSoup as Soup +from click.testing import CliRunner + +from datasette.app import Datasette +from datasette.cli import cli + +from .fixtures import app_client, assert_permissions_checked, make_app_client @pytest.fixture(scope="module") diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 28fe720f..348c4c13 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -1,21 +1,3 @@ -from bs4 import BeautifulSoup as Soup -from .fixtures import ( - app_client, - app_client, - make_app_client, - TABLES, - TEMP_PLUGIN_SECRET_FILE, - PLUGINS_DIR, - TestClient as _TestClient, -) # noqa -from click.testing import CliRunner -from datasette.app import Datasette -from datasette import cli, hookimpl, Permission -from datasette.filters import FilterArguments -from datasette.plugins import get_plugins, DEFAULT_PLUGINS, pm -from datasette.utils.sqlite import sqlite3 -from datasette.utils import CustomRow, StartupError -from jinja2.environment import Template import base64 import importlib import json @@ -23,9 +5,24 @@ import os import pathlib import re import textwrap -import pytest import urllib +import pytest +from bs4 import BeautifulSoup as Soup +from click.testing import CliRunner +from jinja2.environment import Template + +from datasette import Permission, cli, hookimpl +from datasette.app import Datasette +from datasette.filters import FilterArguments +from datasette.plugins import DEFAULT_PLUGINS, get_plugins, pm +from datasette.utils import CustomRow, StartupError +from datasette.utils.sqlite import sqlite3 + +from .fixtures import PLUGINS_DIR, TABLES, TEMP_PLUGIN_SECRET_FILE +from .fixtures import TestClient as _TestClient # noqa +from .fixtures import app_client, make_app_client + at_memory_re = re.compile(r" at 0x\w+") diff --git a/tests/test_publish_cloudrun.py b/tests/test_publish_cloudrun.py index 818fa2d3..12796f1f 100644 --- a/tests/test_publish_cloudrun.py +++ b/tests/test_publish_cloudrun.py @@ -1,10 +1,12 @@ -from click.testing import CliRunner -from datasette import cli -from unittest import mock import json import os -import pytest import textwrap +from unittest import mock + +import pytest +from click.testing import CliRunner + +from datasette import cli @pytest.mark.serial diff --git a/tests/test_publish_heroku.py b/tests/test_publish_heroku.py index cab83654..4302ed94 100644 --- a/tests/test_publish_heroku.py +++ b/tests/test_publish_heroku.py @@ -1,9 +1,11 @@ -from click.testing import CliRunner -from datasette import cli -from unittest import mock import os import pathlib +from unittest import mock + import pytest +from click.testing import CliRunner + +from datasette import cli @pytest.mark.serial diff --git a/tests/test_routes.py b/tests/test_routes.py index 85945dec..8238a171 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -1,8 +1,9 @@ -from datasette.app import Datasette, Database -from datasette.utils import resolve_routes import pytest import pytest_asyncio +from datasette.app import Database, Datasette +from datasette.utils import resolve_routes + @pytest.fixture(scope="session") def routes(): diff --git a/tests/test_spatialite.py b/tests/test_spatialite.py index c07a30e8..0d11737e 100644 --- a/tests/test_spatialite.py +++ b/tests/test_spatialite.py @@ -1,8 +1,10 @@ -from datasette.app import Datasette -from datasette.utils import find_spatialite, SpatialiteNotFound, SPATIALITE_FUNCTIONS -from .utils import has_load_extension import pytest +from datasette.app import Datasette +from datasette.utils import SPATIALITE_FUNCTIONS, SpatialiteNotFound, find_spatialite + +from .utils import has_load_extension + def has_spatialite(): try: diff --git a/tests/test_table_api.py b/tests/test_table_api.py index 46d1c9b8..245b2d3f 100644 --- a/tests/test_table_api.py +++ b/tests/test_table_api.py @@ -1,16 +1,19 @@ +import json +import urllib + +import pytest + from datasette.utils import detect_json1 from datasette.utils.sqlite import sqlite_version + from .fixtures import ( # noqa app_client, - app_client_with_trace, app_client_returned_rows_matches_page_size, + app_client_with_trace, generate_compound_rows, generate_sortable_rows, make_app_client, ) -import json -import pytest -import urllib @pytest.mark.asyncio diff --git a/tests/test_table_html.py b/tests/test_table_html.py index c4c7878c..2342a007 100644 --- a/tests/test_table_html.py +++ b/tests/test_table_html.py @@ -1,12 +1,12 @@ -from datasette.app import Datasette, Database -from bs4 import BeautifulSoup as Soup -from .fixtures import ( # noqa - app_client, - make_app_client, -) import pathlib -import pytest import urllib.parse + +import pytest +from bs4 import BeautifulSoup as Soup + +from datasette.app import Database, Datasette + +from .fixtures import app_client, make_app_client # noqa from .utils import assert_footer_links, inner_html diff --git a/tests/test_tracer.py b/tests/test_tracer.py index ceadee50..9cda418f 100644 --- a/tests/test_tracer.py +++ b/tests/test_tracer.py @@ -1,4 +1,5 @@ import pytest + from .fixtures import make_app_client diff --git a/tests/test_utils.py b/tests/test_utils.py index 8b64f865..46ad7d6d 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,17 +1,19 @@ """ Tests for various datasette helper functions. """ -from datasette.app import Datasette -from datasette import utils -from datasette.utils.asgi import Request -from datasette.utils.sqlite import sqlite3 import json import os import pathlib -import pytest import tempfile from unittest.mock import patch +import pytest + +from datasette import utils +from datasette.app import Datasette +from datasette.utils.asgi import Request +from datasette.utils.sqlite import sqlite3 + @pytest.mark.parametrize( "path,expected", diff --git a/tests/test_utils_check_callable.py b/tests/test_utils_check_callable.py index 4f72f9ff..857b73cd 100644 --- a/tests/test_utils_check_callable.py +++ b/tests/test_utils_check_callable.py @@ -1,6 +1,7 @@ -from datasette.utils.check_callable import check_callable import pytest +from datasette.utils.check_callable import check_callable + class AsyncClass: async def __call__(self): From 2ce7872e3ba8d07248c194ef554bbdc1df510f32 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 22 Aug 2023 19:33:26 -0700 Subject: [PATCH 002/474] -c shortcut for --config - refs #2143, #2149 --- datasette/cli.py | 1 + tests/test_cli.py | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/datasette/cli.py b/datasette/cli.py index dbbfaba7..58f89c1c 100644 --- a/datasette/cli.py +++ b/datasette/cli.py @@ -415,6 +415,7 @@ def uninstall(packages, yes): ) @click.option("--memory", is_flag=True, help="Make /_memory database available") @click.option( + "-c", "--config", type=click.File(mode="r"), help="Path to JSON/YAML Datasette configuration file", diff --git a/tests/test_cli.py b/tests/test_cli.py index 71f0bbe3..e72b0a30 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -283,6 +283,30 @@ def test_serve_create(tmpdir): assert db_path.exists() +@pytest.mark.parametrize("argument", ("-c", "--config")) +@pytest.mark.parametrize("format_", ("json", "yaml")) +def test_serve_config(tmpdir, argument, format_): + config_path = tmpdir / "datasette.{}".format(format_) + config_path.write_text( + "settings:\n default_page_size: 5\n" + if format_ == "yaml" + else '{"settings": {"default_page_size": 5}}', + "utf-8", + ) + runner = CliRunner() + result = runner.invoke( + cli, + [ + argument, + str(config_path), + "--get", + "/-/settings.json", + ], + ) + assert result.exit_code == 0, result.output + assert json.loads(result.output)["default_page_size"] == 5 + + def test_serve_duplicate_database_names(tmpdir): "'datasette db.db nested/db.db' should attach two databases, /db and /db_2" runner = CliRunner() From 64fd1d788eeed2624f107ac699f2370590ae1aa3 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 22 Aug 2023 19:57:46 -0700 Subject: [PATCH 003/474] Applied Cog, refs #2143, #2149 --- docs/cli-reference.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index 6598de93..4fbd68d5 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -112,7 +112,7 @@ Once started you can access it at ``http://localhost:8001`` --static MOUNT:DIRECTORY Serve static files from this directory at /MOUNT/... --memory Make /_memory database available - --config FILENAME Path to JSON/YAML Datasette configuration file + -c, --config FILENAME Path to JSON/YAML Datasette configuration file --setting SETTING... Setting, see docs.datasette.io/en/stable/settings.html --secret TEXT Secret used for signing secure values, such as From bdf59eb7db42559e538a637bacfe86d39e5d17ca Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 23 Aug 2023 11:35:42 -0700 Subject: [PATCH 004/474] No more default to 15% on labels, closes #2150 --- datasette/static/app.css | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/datasette/static/app.css b/datasette/static/app.css index 71437bd4..80dfc677 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -482,20 +482,18 @@ form.sql textarea { font-family: monospace; font-size: 1.3em; } +form.sql label { + width: 15%; +} form label { font-weight: bold; display: inline-block; - width: 15%; -} -.advanced-export form label { - width: auto; } .advanced-export input[type=submit] { font-size: 0.6em; margin-left: 1em; } label.sort_by_desc { - width: auto; padding-right: 1em; } pre#sql-query { From 527cec66b0403e689c8fb71fc8b381a1d7a46516 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 24 Aug 2023 11:21:15 -0700 Subject: [PATCH 005/474] utils.pairs_to_nested_config(), refs #2156, #2143 --- datasette/cli.py | 1 + datasette/utils/__init__.py | 49 ++++++++++++++++++++++++++++++++++++ tests/test_utils.py | 50 +++++++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+) diff --git a/datasette/cli.py b/datasette/cli.py index 58f89c1c..7576a589 100644 --- a/datasette/cli.py +++ b/datasette/cli.py @@ -421,6 +421,7 @@ def uninstall(packages, yes): help="Path to JSON/YAML Datasette configuration file", ) @click.option( + "-s", "--setting", "settings", type=Setting(), diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py index c388673d..18d18641 100644 --- a/datasette/utils/__init__.py +++ b/datasette/utils/__init__.py @@ -1219,3 +1219,52 @@ async def row_sql_params_pks(db, table, pk_values): for i, pk_value in enumerate(pk_values): params[f"p{i}"] = pk_value return sql, params, pks + + +def _handle_pair(key: str, value: str) -> dict: + """ + Turn a key-value pair into a nested dictionary. + foo, bar => {'foo': 'bar'} + foo.bar, baz => {'foo': {'bar': 'baz'}} + foo.bar, [1, 2, 3] => {'foo': {'bar': [1, 2, 3]}} + foo.bar, "baz" => {'foo': {'bar': 'baz'}} + foo.bar, '{"baz": "qux"}' => {'foo': {'bar': "{'baz': 'qux'}"}} + """ + try: + value = json.loads(value) + except json.JSONDecodeError: + # If it doesn't parse as JSON, treat it as a string + pass + + keys = key.split(".") + result = current_dict = {} + + for k in keys[:-1]: + current_dict[k] = {} + current_dict = current_dict[k] + + current_dict[keys[-1]] = value + return result + + +def _combine(base: dict, update: dict) -> dict: + """ + Recursively merge two dictionaries. + """ + for key, value in update.items(): + if isinstance(value, dict) and key in base and isinstance(base[key], dict): + base[key] = _combine(base[key], value) + else: + base[key] = value + return base + + +def pairs_to_nested_config(pairs: typing.List[typing.Tuple[str, typing.Any]]) -> dict: + """ + Parse a list of key-value pairs into a nested dictionary. + """ + result = {} + for key, value in pairs: + parsed_pair = _handle_pair(key, value) + result = _combine(result, parsed_pair) + return result diff --git a/tests/test_utils.py b/tests/test_utils.py index 8b64f865..61392b8b 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -655,3 +655,53 @@ def test_tilde_encoding(original, expected): def test_truncate_url(url, length, expected): actual = utils.truncate_url(url, length) assert actual == expected + + +@pytest.mark.parametrize( + "pairs,expected", + ( + # Simple nested objects + ([("a", "b")], {"a": "b"}), + ([("a.b", "c")], {"a": {"b": "c"}}), + # JSON literals + ([("a.b", "true")], {"a": {"b": True}}), + ([("a.b", "false")], {"a": {"b": False}}), + ([("a.b", "null")], {"a": {"b": None}}), + ([("a.b", "1")], {"a": {"b": 1}}), + ([("a.b", "1.1")], {"a": {"b": 1.1}}), + # Nested JSON literals + ([("a.b", '{"foo": "bar"}')], {"a": {"b": {"foo": "bar"}}}), + ([("a.b", "[1, 2, 3]")], {"a": {"b": [1, 2, 3]}}), + # JSON strings are preserved + ([("a.b", '"true"')], {"a": {"b": "true"}}), + ([("a.b", '"[1, 2, 3]"')], {"a": {"b": "[1, 2, 3]"}}), + # Later keys over-ride the previous + ( + [ + ("a", "b"), + ("a.b", "c"), + ], + {"a": {"b": "c"}}, + ), + ( + [ + ("settings.trace_debug", "true"), + ("plugins.datasette-ripgrep.path", "/etc"), + ("settings.trace_debug", "false"), + ], + { + "settings": { + "trace_debug": False, + }, + "plugins": { + "datasette-ripgrep": { + "path": "/etc", + } + }, + }, + ), + ), +) +def test_pairs_to_nested_config(pairs, expected): + actual = utils.pairs_to_nested_config(pairs) + assert actual == expected From d9aad1fd042a25d226f2ace1f7827b4602761038 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 28 Aug 2023 13:06:14 -0700 Subject: [PATCH 006/474] -s/--setting x y gets merged into datasette.yml, refs #2143, #2156 This change updates the `-s/--setting` option to `datasette serve` to allow it to be used to set arbitrarily complex nested settings in a way that is compatible with the new `-c datasette.yml` work happening in: - #2143 It will enable things like this: ``` datasette data.db --setting plugins.datasette-ripgrep.path "/home/simon/code" ``` For the moment though it just affects [settings](https://docs.datasette.io/en/1.0a4/settings.html) - so you can do this: ``` datasette data.db --setting settings.sql_time_limit_ms 3500 ``` I've also implemented a backwards compatibility mechanism, so if you use it this way (the old way): ``` datasette data.db --setting sql_time_limit_ms 3500 ``` It will notice that the setting you passed is one of Datasette's core settings, and will treat that as if you said `settings.sql_time_limit_ms` instead. --- datasette/cli.py | 62 +++++++++++++++++++++--------------------- docs/cli-reference.rst | 4 +-- tests/test_cli.py | 27 +++++++++--------- 3 files changed, 46 insertions(+), 47 deletions(-) diff --git a/datasette/cli.py b/datasette/cli.py index 7576a589..139ccf6e 100644 --- a/datasette/cli.py +++ b/datasette/cli.py @@ -31,6 +31,7 @@ from .utils import ( ConnectionProblem, SpatialiteConnectionProblem, initial_path_for_datasette, + pairs_to_nested_config, temporary_docker_directory, value_as_boolean, SpatialiteNotFound, @@ -56,35 +57,27 @@ class Setting(CompositeParamType): def convert(self, config, param, ctx): name, value = config - if name not in DEFAULT_SETTINGS: - msg = ( - OBSOLETE_SETTINGS.get(name) - or f"{name} is not a valid option (--help-settings to see all)" - ) - self.fail( - msg, - param, - ctx, - ) - return - # Type checking - default = DEFAULT_SETTINGS[name] - if isinstance(default, bool): - try: - return name, value_as_boolean(value) - except ValueAsBooleanError: - self.fail(f'"{name}" should be on/off/true/false/1/0', param, ctx) - return - elif isinstance(default, int): - if not value.isdigit(): - self.fail(f'"{name}" should be an integer', param, ctx) - return - return name, int(value) - elif isinstance(default, str): - return name, value - else: - # Should never happen: - self.fail("Invalid option") + if name in DEFAULT_SETTINGS: + # For backwards compatibility with how this worked prior to + # Datasette 1.0, we turn bare setting names into setting.name + # Type checking for those older settings + default = DEFAULT_SETTINGS[name] + name = "settings.{}".format(name) + if isinstance(default, bool): + try: + return name, "true" if value_as_boolean(value) else "false" + except ValueAsBooleanError: + self.fail(f'"{name}" should be on/off/true/false/1/0', param, ctx) + elif isinstance(default, int): + if not value.isdigit(): + self.fail(f'"{name}" should be an integer', param, ctx) + return name, value + elif isinstance(default, str): + return name, value + else: + # Should never happen: + self.fail("Invalid option") + return name, value def sqlite_extensions(fn): @@ -425,7 +418,7 @@ def uninstall(packages, yes): "--setting", "settings", type=Setting(), - help="Setting, see docs.datasette.io/en/stable/settings.html", + help="nested.key, value setting to use in Datasette configuration", multiple=True, ) @click.option( @@ -547,6 +540,13 @@ def serve( if config: config_data = parse_metadata(config.read()) + config_data = config_data or {} + + # Merge in settings from -s/--setting + if settings: + settings_updates = pairs_to_nested_config(settings) + config_data.update(settings_updates) + kwargs = dict( immutables=immutable, cache_headers=not reload, @@ -558,7 +558,7 @@ def serve( template_dir=template_dir, plugins_dir=plugins_dir, static_mounts=static, - settings=dict(settings), + settings=None, # These are passed in config= now memory=memory, secret=secret, version_note=version_note, diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index 4fbd68d5..5131c567 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -113,8 +113,8 @@ Once started you can access it at ``http://localhost:8001`` /MOUNT/... --memory Make /_memory database available -c, --config FILENAME Path to JSON/YAML Datasette configuration file - --setting SETTING... Setting, see - docs.datasette.io/en/stable/settings.html + -s, --setting SETTING... nested.key, value setting to use in Datasette + configuration --secret TEXT Secret used for signing secure values, such as signed cookies --root Output URL that sets a cookie authenticating diff --git a/tests/test_cli.py b/tests/test_cli.py index e72b0a30..cf31f214 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -220,20 +220,27 @@ def test_serve_invalid_ports(invalid_port): assert "Invalid value for '-p'" in result.stderr -def test_setting(): +@pytest.mark.parametrize( + "args", + ( + ["--setting", "default_page_size", "5"], + ["--setting", "settings.default_page_size", "5"], + ["-s", "settings.default_page_size", "5"], + ), +) +def test_setting(args): runner = CliRunner() - result = runner.invoke( - cli, ["--setting", "default_page_size", "5", "--get", "/-/settings.json"] - ) + result = runner.invoke(cli, ["--get", "/-/settings.json"] + args) assert result.exit_code == 0, result.output - assert json.loads(result.output)["default_page_size"] == 5 + settings = json.loads(result.output) + assert settings["default_page_size"] == 5 def test_setting_type_validation(): runner = CliRunner(mix_stderr=False) result = runner.invoke(cli, ["--setting", "default_page_size", "dog"]) assert result.exit_code == 2 - assert '"default_page_size" should be an integer' in result.stderr + assert '"settings.default_page_size" should be an integer' in result.stderr @pytest.mark.parametrize("default_allow_sql", (True, False)) @@ -360,11 +367,3 @@ def test_help_settings(): result = runner.invoke(cli, ["--help-settings"]) for setting in SETTINGS: assert setting.name in result.output - - -@pytest.mark.parametrize("setting", ("hash_urls", "default_cache_ttl_hashed")) -def test_help_error_on_hash_urls_setting(setting): - runner = CliRunner() - result = runner.invoke(cli, ["--setting", setting, 1]) - assert result.exit_code == 2 - assert "The hash_urls setting has been removed" in result.output From d8351b08edb08484f5505f509c6101c56a8bba4a Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 28 Aug 2023 13:14:48 -0700 Subject: [PATCH 007/474] datasette --get --actor 'JSON' option, closes #2153 Refs #2154 --- datasette/cli.py | 10 +++++++++- docs/cli-reference.rst | 13 +++++++++++-- tests/test_cli.py | 1 + 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/datasette/cli.py b/datasette/cli.py index 139ccf6e..6ebb1985 100644 --- a/datasette/cli.py +++ b/datasette/cli.py @@ -439,6 +439,10 @@ def uninstall(packages, yes): "--token", help="API token to send with --get requests", ) +@click.option( + "--actor", + help="Actor to use for --get requests (JSON string)", +) @click.option("--version-note", help="Additional note to show on /-/versions") @click.option("--help-settings", is_flag=True, help="Show available settings") @click.option("--pdb", is_flag=True, help="Launch debugger on any errors") @@ -493,6 +497,7 @@ def serve( root, get, token, + actor, version_note, help_settings, pdb, @@ -612,7 +617,10 @@ def serve( headers = {} if token: headers["Authorization"] = "Bearer {}".format(token) - response = client.get(get, headers=headers) + cookies = {} + if actor: + cookies["ds_actor"] = client.actor_cookie(json.loads(actor)) + response = client.get(get, headers=headers, cookies=cookies) click.echo(response.text) exit_code = 0 if response.status == 200 else 1 sys.exit(exit_code) diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index 5131c567..5657f480 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -122,6 +122,7 @@ Once started you can access it at ``http://localhost:8001`` --get TEXT Run an HTTP GET request against this path, print results and exit --token TEXT API token to send with --get requests + --actor TEXT Actor to use for --get requests (JSON string) --version-note TEXT Additional note to show on /-/versions --help-settings Show available settings --pdb Launch debugger on any errors @@ -148,7 +149,9 @@ The ``--get`` option to ``datasette serve`` (or just ``datasette``) specifies th This means that all of Datasette's functionality can be accessed directly from the command-line. -For example:: +For example: + +.. code-block:: bash datasette --get '/-/versions.json' | jq . @@ -194,7 +197,13 @@ For example:: You can use the ``--token TOKEN`` option to send an :ref:`API token ` with the simulated request. -The exit code will be 0 if the request succeeds and 1 if the request produced an HTTP status code other than 200 - e.g. a 404 or 500 error. +Or you can make a request as a specific actor by passing a JSON representation of that actor to ``--actor``: + +.. code-block:: bash + + datasette --memory --actor '{"id": "root"}' --get '/-/actor.json' + +The exit code of ``datasette --get`` will be 0 if the request succeeds and 1 if the request produced an HTTP status code other than 200 - e.g. a 404 or 500 error. This lets you use ``datasette --get /`` to run tests against a Datasette application in a continuous integration environment such as GitHub Actions. diff --git a/tests/test_cli.py b/tests/test_cli.py index cf31f214..d9a10f22 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -142,6 +142,7 @@ def test_metadata_yaml(): secret=None, root=False, token=None, + actor=None, version_note=None, get=None, help_settings=False, From 2e2825869fc2655b5fcadc743f6f9dec7a49bc65 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 28 Aug 2023 13:18:24 -0700 Subject: [PATCH 008/474] Test for --get --actor, refs #2153 --- tests/test_cli_serve_get.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/tests/test_cli_serve_get.py b/tests/test_cli_serve_get.py index dc7fc1e2..ff2429c6 100644 --- a/tests/test_cli_serve_get.py +++ b/tests/test_cli_serve_get.py @@ -80,7 +80,7 @@ def test_serve_with_get_and_token(): assert json.loads(result2.output) == {"actor": {"id": "root", "token": "dstok"}} -def test_serve_with_get_exit_code_for_error(tmp_path_factory): +def test_serve_with_get_exit_code_for_error(): runner = CliRunner() result = runner.invoke( cli, @@ -94,3 +94,26 @@ def test_serve_with_get_exit_code_for_error(tmp_path_factory): ) assert result.exit_code == 1 assert "404" in result.output + + +def test_serve_get_actor(): + runner = CliRunner() + result = runner.invoke( + cli, + [ + "serve", + "--memory", + "--get", + "/-/actor.json", + "--actor", + '{"id": "root", "extra": "x"}', + ], + catch_exceptions=False, + ) + assert result.exit_code == 0 + assert json.loads(result.output) == { + "actor": { + "id": "root", + "extra": "x", + } + } From d28f12092dd795f35e9500154711d542f8931676 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 28 Aug 2023 17:38:32 -0700 Subject: [PATCH 009/474] Bump sphinx, furo, blacken-docs dependencies (#2160) * Bump the python-packages group with 3 updates 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 7.1.2 to 7.2.4 - [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/v7.1.2...v7.2.4) Updates `furo` from 2023.7.26 to 2023.8.19 - [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.07.26...2023.08.19) Updates `blacken-docs` from 1.15.0 to 1.16.0 - [Changelog](https://github.com/adamchainz/blacken-docs/blob/main/CHANGELOG.rst) - [Commits](https://github.com/asottile/blacken-docs/compare/1.15.0...1.16.0) --- updated-dependencies: - dependency-name: sphinx dependency-type: direct:development update-type: version-update:semver-minor 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] --------- Signed-off-by: dependabot[bot] Co-authored-by: Simon Willison --- .github/workflows/deploy-latest.yml | 2 +- .github/workflows/spellcheck.yml | 2 +- .github/workflows/test.yml | 8 +++++++- setup.py | 6 +++--- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.github/workflows/deploy-latest.yml b/.github/workflows/deploy-latest.yml index 4746aa07..8cd9dcda 100644 --- a/.github/workflows/deploy-latest.yml +++ b/.github/workflows/deploy-latest.yml @@ -18,7 +18,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: "3.9" + python-version: "3.11" - uses: actions/cache@v3 name: Configure pip caching with: diff --git a/.github/workflows/spellcheck.yml b/.github/workflows/spellcheck.yml index 6bf72f9d..722e5c68 100644 --- a/.github/workflows/spellcheck.yml +++ b/.github/workflows/spellcheck.yml @@ -13,7 +13,7 @@ jobs: - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 with: - python-version: 3.9 + python-version: 3.11 - uses: actions/cache@v2 name: Configure pip caching with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4eab1fdb..8cbbb572 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,docs]' + pip install -e '.[test]' pip freeze - name: Run tests run: | @@ -37,10 +37,16 @@ jobs: pytest -m "serial" # And the test that exceeds a localhost HTTPS server tests/test_datasette_https_server.sh + - name: Install docs dependencies on Python 3.9+ + if: matrix.python-version != '3.8' + run: | + pip install -e '.[docs]' - name: Check if cog needs to be run + if: matrix.python-version != '3.8' run: | cog --check docs/*.rst - name: Check if blacken-docs needs to be run + if: matrix.python-version != '3.8' run: | # This fails on syntax errors, or a diff was applied blacken-docs -l 60 docs/*.rst diff --git a/setup.py b/setup.py index 3a105523..35c9b68b 100644 --- a/setup.py +++ b/setup.py @@ -69,8 +69,8 @@ setup( setup_requires=["pytest-runner"], extras_require={ "docs": [ - "Sphinx==7.1.2", - "furo==2023.7.26", + "Sphinx==7.2.4", + "furo==2023.8.19", "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.15.0", + "blacken-docs==1.16.0", "pytest-timeout>=1.4.2", "trustme>=0.7", "cogapp>=3.3.0", From 92b8bf38c02465f624ce3f48dcabb0b100c4645d Mon Sep 17 00:00:00 2001 From: Alex Garcia Date: Mon, 28 Aug 2023 20:24:23 -0700 Subject: [PATCH 010/474] Add new `--internal internal.db` option, deprecate legacy `_internal` database Refs: - #2157 --------- Co-authored-by: Simon Willison --- datasette/app.py | 30 +++++++++-------- datasette/cli.py | 10 ++++-- datasette/database.py | 12 ++++++- datasette/default_permissions.py | 2 -- datasette/utils/internal_db.py | 30 ++++++++++------- datasette/views/database.py | 6 ++-- datasette/views/special.py | 2 +- docs/cli-reference.rst | 2 ++ docs/internals.rst | 27 ++++++++++----- tests/plugins/my_plugin_2.py | 5 +-- tests/test_cli.py | 12 +++++++ tests/test_internal_db.py | 58 ++++++++++---------------------- tests/test_plugins.py | 2 +- 13 files changed, 108 insertions(+), 90 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 1871aeb1..4deb8697 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -256,6 +256,7 @@ class Datasette: pdb=False, crossdb=False, nolock=False, + internal=None, ): self._startup_invoked = False assert config_dir is None or isinstance( @@ -304,17 +305,18 @@ class Datasette: self.add_database( Database(self, is_mutable=False, is_memory=True), name="_memory" ) - # memory_name is a random string so that each Datasette instance gets its own - # unique in-memory named database - otherwise unit tests can fail with weird - # errors when different instances accidentally share an in-memory database - self.add_database( - Database(self, memory_name=secrets.token_hex()), name="_internal" - ) - self.internal_db_created = False for file in self.files: self.add_database( Database(self, file, is_mutable=file not in self.immutables) ) + + self.internal_db_created = False + if internal is None: + self._internal_database = Database(self, memory_name=secrets.token_hex()) + else: + self._internal_database = Database(self, path=internal, mode="rwc") + self._internal_database.name = "__INTERNAL__" + self.cache_headers = cache_headers self.cors = cors config_files = [] @@ -436,15 +438,14 @@ class Datasette: await self._refresh_schemas() async def _refresh_schemas(self): - internal_db = self.databases["_internal"] + internal_db = self.get_internal_database() if not self.internal_db_created: await init_internal_db(internal_db) self.internal_db_created = True - current_schema_versions = { row["database_name"]: row["schema_version"] for row in await internal_db.execute( - "select database_name, schema_version from databases" + "select database_name, schema_version from core_databases" ) } for database_name, db in self.databases.items(): @@ -459,7 +460,7 @@ class Datasette: values = [database_name, db.is_memory, schema_version] await internal_db.execute_write( """ - INSERT OR REPLACE INTO databases (database_name, path, is_memory, schema_version) + INSERT OR REPLACE INTO core_databases (database_name, path, is_memory, schema_version) VALUES {} """.format( placeholders @@ -554,8 +555,7 @@ class Datasette: raise KeyError return matches[0] if name is None: - # Return first database that isn't "_internal" - name = [key for key in self.databases.keys() if key != "_internal"][0] + name = [key for key in self.databases.keys()][0] return self.databases[name] def add_database(self, db, name=None, route=None): @@ -655,6 +655,9 @@ class Datasette: def _metadata(self): return self.metadata() + def get_internal_database(self): + return self._internal_database + def plugin_config(self, plugin_name, database=None, table=None, fallback=True): """Return config for plugin, falling back from specified database/table""" plugins = self.metadata( @@ -978,7 +981,6 @@ class Datasette: "hash": d.hash, } for name, d in self.databases.items() - if name != "_internal" ] def _versions(self): diff --git a/datasette/cli.py b/datasette/cli.py index 6ebb1985..1a5a8af3 100644 --- a/datasette/cli.py +++ b/datasette/cli.py @@ -148,9 +148,6 @@ async def inspect_(files, sqlite_extensions): app = Datasette([], immutables=files, sqlite_extensions=sqlite_extensions) data = {} for name, database in app.databases.items(): - if name == "_internal": - # Don't include the in-memory _internal database - continue counts = await database.table_counts(limit=3600 * 1000) data[name] = { "hash": database.hash, @@ -476,6 +473,11 @@ def uninstall(packages, yes): "--ssl-certfile", help="SSL certificate file", ) +@click.option( + "--internal", + type=click.Path(), + help="Path to a persistent Datasette internal SQLite database", +) def serve( files, immutable, @@ -507,6 +509,7 @@ def serve( nolock, ssl_keyfile, ssl_certfile, + internal, return_instance=False, ): """Serve up specified SQLite database files with a web UI""" @@ -570,6 +573,7 @@ def serve( pdb=pdb, crossdb=crossdb, nolock=nolock, + internal=internal, ) # if files is a single directory, use that as config_dir= diff --git a/datasette/database.py b/datasette/database.py index af39ac9e..cb01301e 100644 --- a/datasette/database.py +++ b/datasette/database.py @@ -29,7 +29,13 @@ AttachedDatabase = namedtuple("AttachedDatabase", ("seq", "name", "file")) class Database: def __init__( - self, ds, path=None, is_mutable=True, is_memory=False, memory_name=None + self, + ds, + path=None, + is_mutable=True, + is_memory=False, + memory_name=None, + mode=None, ): self.name = None self.route = None @@ -50,6 +56,7 @@ class Database: self._write_connection = None # This is used to track all file connections so they can be closed self._all_file_connections = [] + self.mode = mode @property def cached_table_counts(self): @@ -90,6 +97,7 @@ class Database: return conn if self.is_memory: return sqlite3.connect(":memory:", uri=True) + # mode=ro or immutable=1? if self.is_mutable: qs = "?mode=ro" @@ -100,6 +108,8 @@ class Database: assert not (write and not self.is_mutable) if write: qs = "" + if self.mode is not None: + qs = f"?mode={self.mode}" conn = sqlite3.connect( f"file:{self.path}{qs}", uri=True, check_same_thread=False ) diff --git a/datasette/default_permissions.py b/datasette/default_permissions.py index 63a66c3c..f0b086e9 100644 --- a/datasette/default_permissions.py +++ b/datasette/default_permissions.py @@ -146,8 +146,6 @@ async def _resolve_metadata_view_permissions(datasette, actor, action, resource) if allow is not None: return actor_matches_allow(actor, allow) elif action == "view-database": - if resource == "_internal" and (actor is None or actor.get("id") != "root"): - return False database_allow = datasette.metadata("allow", database=resource) if database_allow is None: return None diff --git a/datasette/utils/internal_db.py b/datasette/utils/internal_db.py index e4b49e80..215695ca 100644 --- a/datasette/utils/internal_db.py +++ b/datasette/utils/internal_db.py @@ -5,13 +5,13 @@ from datasette.utils import table_column_details async def init_internal_db(db): create_tables_sql = textwrap.dedent( """ - CREATE TABLE IF NOT EXISTS databases ( + CREATE TABLE IF NOT EXISTS core_databases ( database_name TEXT PRIMARY KEY, path TEXT, is_memory INTEGER, schema_version INTEGER ); - CREATE TABLE IF NOT EXISTS tables ( + CREATE TABLE IF NOT EXISTS core_tables ( database_name TEXT, table_name TEXT, rootpage INTEGER, @@ -19,7 +19,7 @@ async def init_internal_db(db): PRIMARY KEY (database_name, table_name), FOREIGN KEY (database_name) REFERENCES databases(database_name) ); - CREATE TABLE IF NOT EXISTS columns ( + CREATE TABLE IF NOT EXISTS core_columns ( database_name TEXT, table_name TEXT, cid INTEGER, @@ -33,7 +33,7 @@ async def init_internal_db(db): FOREIGN KEY (database_name) REFERENCES databases(database_name), FOREIGN KEY (database_name, table_name) REFERENCES tables(database_name, table_name) ); - CREATE TABLE IF NOT EXISTS indexes ( + CREATE TABLE IF NOT EXISTS core_indexes ( database_name TEXT, table_name TEXT, seq INTEGER, @@ -45,7 +45,7 @@ async def init_internal_db(db): FOREIGN KEY (database_name) REFERENCES databases(database_name), FOREIGN KEY (database_name, table_name) REFERENCES tables(database_name, table_name) ); - CREATE TABLE IF NOT EXISTS foreign_keys ( + CREATE TABLE IF NOT EXISTS core_foreign_keys ( database_name TEXT, table_name TEXT, id INTEGER, @@ -69,12 +69,16 @@ async def populate_schema_tables(internal_db, db): database_name = db.name def delete_everything(conn): - conn.execute("DELETE FROM tables WHERE database_name = ?", [database_name]) - conn.execute("DELETE FROM columns WHERE database_name = ?", [database_name]) + conn.execute("DELETE FROM core_tables WHERE database_name = ?", [database_name]) conn.execute( - "DELETE FROM foreign_keys WHERE database_name = ?", [database_name] + "DELETE FROM core_columns WHERE database_name = ?", [database_name] + ) + conn.execute( + "DELETE FROM core_foreign_keys WHERE database_name = ?", [database_name] + ) + conn.execute( + "DELETE FROM core_indexes WHERE database_name = ?", [database_name] ) - conn.execute("DELETE FROM indexes WHERE database_name = ?", [database_name]) await internal_db.execute_write_fn(delete_everything) @@ -133,14 +137,14 @@ async def populate_schema_tables(internal_db, db): await internal_db.execute_write_many( """ - INSERT INTO tables (database_name, table_name, rootpage, sql) + INSERT INTO core_tables (database_name, table_name, rootpage, sql) values (?, ?, ?, ?) """, tables_to_insert, ) await internal_db.execute_write_many( """ - INSERT INTO columns ( + INSERT INTO core_columns ( database_name, table_name, cid, name, type, "notnull", default_value, is_pk, hidden ) VALUES ( :database_name, :table_name, :cid, :name, :type, :notnull, :default_value, :is_pk, :hidden @@ -150,7 +154,7 @@ async def populate_schema_tables(internal_db, db): ) await internal_db.execute_write_many( """ - INSERT INTO foreign_keys ( + INSERT INTO core_foreign_keys ( database_name, table_name, "id", seq, "table", "from", "to", on_update, on_delete, match ) VALUES ( :database_name, :table_name, :id, :seq, :table, :from, :to, :on_update, :on_delete, :match @@ -160,7 +164,7 @@ async def populate_schema_tables(internal_db, db): ) await internal_db.execute_write_many( """ - INSERT INTO indexes ( + INSERT INTO core_indexes ( database_name, table_name, seq, name, "unique", origin, partial ) VALUES ( :database_name, :table_name, :seq, :name, :unique, :origin, :partial diff --git a/datasette/views/database.py b/datasette/views/database.py index d9abc38a..4647bedc 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -950,9 +950,9 @@ class TableCreateView(BaseView): async def _table_columns(datasette, database_name): - internal = datasette.get_database("_internal") - result = await internal.execute( - "select table_name, name from columns where database_name = ?", + internal_db = datasette.get_internal_database() + result = await internal_db.execute( + "select table_name, name from core_columns where database_name = ?", [database_name], ) table_columns = {} diff --git a/datasette/views/special.py b/datasette/views/special.py index c45a3eca..c1b84f8f 100644 --- a/datasette/views/special.py +++ b/datasette/views/special.py @@ -238,7 +238,7 @@ class CreateTokenView(BaseView): # Build list of databases and tables the user has permission to view database_with_tables = [] for database in self.ds.databases.values(): - if database.name in ("_internal", "_memory"): + if database.name == "_memory": continue if not await self.ds.permission_allowed( request.actor, "view-database", database.name diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index 5657f480..8e333447 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -134,6 +134,8 @@ Once started you can access it at ``http://localhost:8001`` mode --ssl-keyfile TEXT SSL key file --ssl-certfile TEXT SSL certificate file + --internal PATH Path to a persistent Datasette internal SQLite + database --help Show this message and exit. diff --git a/docs/internals.rst b/docs/internals.rst index 4b82e11c..fe9a2fa7 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -271,7 +271,7 @@ Property exposing a ``collections.OrderedDict`` of databases currently connected The dictionary keys are the name of the database that is used in the URL - e.g. ``/fixtures`` would have a key of ``"fixtures"``. The values are :ref:`internals_database` instances. -All databases are listed, irrespective of user permissions. This means that the ``_internal`` database will always be listed here. +All databases are listed, irrespective of user permissions. .. _datasette_permissions: @@ -479,6 +479,13 @@ The following example creates a token that can access ``view-instance`` and ``vi Returns the specified database object. Raises a ``KeyError`` if the database does not exist. Call this method without an argument to return the first connected database. +.. _get_internal_database: + +.get_internal_database() +------------------------ + +Returns a database object for reading and writing to the private :ref:`internal database `. + .. _datasette_add_database: .add_database(db, name=None, route=None) @@ -1127,19 +1134,21 @@ You can selectively disable CSRF protection using the :ref:`plugin_hook_skip_csr .. _internals_internal: -The _internal database -====================== +Datasette's internal database +============================= -.. warning:: - This API should be considered unstable - the structure of these tables may change prior to the release of Datasette 1.0. +Datasette maintains an "internal" SQLite database used for configuration, caching, and storage. Plugins can store configuration, settings, and other data inside this database. By default, Datasette will use a temporary in-memory SQLite database as the internal database, which is created at startup and destroyed at shutdown. Users of Datasette can optionally pass in a `--internal` flag to specify the path to a SQLite database to use as the internal database, which will persist internal data across Datasette instances. -Datasette maintains an in-memory SQLite database with details of the the databases, tables and columns for all of the attached databases. +The internal database is not exposed in the Datasette application by default, which means private data can safely be stored without worry of accidentally leaking information through the default Datasette interface and API. However, other plugins do have full read and write access to the internal database. -By default all actors are denied access to the ``view-database`` permission for the ``_internal`` database, so the database is not visible to anyone unless they :ref:`sign in as root `. +Plugins can access this database by calling ``internal_db = datasette.get_internal_database()`` and then executing queries using the :ref:`Database API `. -Plugins can access this database by calling ``db = datasette.get_database("_internal")`` and then executing queries using the :ref:`Database API `. +Plugin authors are asked to practice good etiquette when using the internal database, as all plugins use the same database to store data. For example: -You can explore an example of this database by `signing in as root `__ to the ``latest.datasette.io`` demo instance and then navigating to `latest.datasette.io/_internal `__. +1. Use a unique prefix when creating tables, indices, and triggera in the internal database. If your plugin is called `datasette-xyz`, then prefix names with `datasette_xyz_*`. +2. Avoid long-running write statements that may stall or block other plugins that are trying to write at the same time. +3. Use temporary tables or shared in-memory attached databases when possible. +4. Avoid implementing features that could expose private data stored in the internal database by other plugins. .. _internals_utils: diff --git a/tests/plugins/my_plugin_2.py b/tests/plugins/my_plugin_2.py index d588342c..bb82b8c1 100644 --- a/tests/plugins/my_plugin_2.py +++ b/tests/plugins/my_plugin_2.py @@ -120,7 +120,7 @@ def permission_allowed(datasette, actor, action): assert ( 2 == ( - await datasette.get_database("_internal").execute("select 1 + 1") + await datasette.get_internal_database().execute("select 1 + 1") ).first()[0] ) if action == "this_is_allowed_async": @@ -142,7 +142,8 @@ def startup(datasette): async def inner(): # Run against _internal so tests that use the ds_client fixture # (which has no databases yet on startup) do not fail: - result = await datasette.get_database("_internal").execute("select 1 + 1") + internal_db = datasette.get_internal_database() + result = await internal_db.execute("select 1 + 1") datasette._startup_hook_calculation = result.first()[0] return inner diff --git a/tests/test_cli.py b/tests/test_cli.py index d9a10f22..e85bcef1 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -154,6 +154,7 @@ def test_metadata_yaml(): ssl_keyfile=None, ssl_certfile=None, return_instance=True, + internal=None, ) client = _TestClient(ds) response = client.get("/-/metadata.json") @@ -368,3 +369,14 @@ def test_help_settings(): result = runner.invoke(cli, ["--help-settings"]) for setting in SETTINGS: assert setting.name in result.output + + +def test_internal_db(tmpdir): + runner = CliRunner() + internal_path = tmpdir / "internal.db" + assert not internal_path.exists() + result = runner.invoke( + cli, ["--memory", "--internal", str(internal_path), "--get", "/"] + ) + assert result.exit_code == 0 + assert internal_path.exists() diff --git a/tests/test_internal_db.py b/tests/test_internal_db.py index a666dd72..5276dc99 100644 --- a/tests/test_internal_db.py +++ b/tests/test_internal_db.py @@ -1,55 +1,35 @@ import pytest -@pytest.mark.asyncio -async def test_internal_only_available_to_root(ds_client): - cookie = ds_client.actor_cookie({"id": "root"}) - assert (await ds_client.get("/_internal")).status_code == 403 - assert ( - await ds_client.get("/_internal", cookies={"ds_actor": cookie}) - ).status_code == 200 +# ensure refresh_schemas() gets called before interacting with internal_db +async def ensure_internal(ds_client): + await ds_client.get("/fixtures.json?sql=select+1") + return ds_client.ds.get_internal_database() @pytest.mark.asyncio async def test_internal_databases(ds_client): - cookie = ds_client.actor_cookie({"id": "root"}) - databases = ( - await ds_client.get( - "/_internal/databases.json?_shape=array", cookies={"ds_actor": cookie} - ) - ).json() - assert len(databases) == 2 - internal, fixtures = databases - assert internal["database_name"] == "_internal" - assert internal["is_memory"] == 1 - assert internal["path"] is None - assert isinstance(internal["schema_version"], int) - assert fixtures["database_name"] == "fixtures" + internal_db = await ensure_internal(ds_client) + databases = await internal_db.execute("select * from core_databases") + assert len(databases) == 1 + assert databases.rows[0]["database_name"] == "fixtures" @pytest.mark.asyncio async def test_internal_tables(ds_client): - cookie = ds_client.actor_cookie({"id": "root"}) - tables = ( - await ds_client.get( - "/_internal/tables.json?_shape=array", cookies={"ds_actor": cookie} - ) - ).json() + internal_db = await ensure_internal(ds_client) + tables = await internal_db.execute("select * from core_tables") assert len(tables) > 5 - table = tables[0] + table = tables.rows[0] assert set(table.keys()) == {"rootpage", "table_name", "database_name", "sql"} @pytest.mark.asyncio async def test_internal_indexes(ds_client): - cookie = ds_client.actor_cookie({"id": "root"}) - indexes = ( - await ds_client.get( - "/_internal/indexes.json?_shape=array", cookies={"ds_actor": cookie} - ) - ).json() + internal_db = await ensure_internal(ds_client) + indexes = await internal_db.execute("select * from core_indexes") assert len(indexes) > 5 - index = indexes[0] + index = indexes.rows[0] assert set(index.keys()) == { "partial", "name", @@ -63,14 +43,10 @@ async def test_internal_indexes(ds_client): @pytest.mark.asyncio async def test_internal_foreign_keys(ds_client): - cookie = ds_client.actor_cookie({"id": "root"}) - foreign_keys = ( - await ds_client.get( - "/_internal/foreign_keys.json?_shape=array", cookies={"ds_actor": cookie} - ) - ).json() + internal_db = await ensure_internal(ds_client) + foreign_keys = await internal_db.execute("select * from core_foreign_keys") assert len(foreign_keys) > 5 - foreign_key = foreign_keys[0] + foreign_key = foreign_keys.rows[0] assert set(foreign_key.keys()) == { "table", "seq", diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 28fe720f..9761fa53 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -329,7 +329,7 @@ def test_hook_extra_body_script(app_client, path, expected_extra_body_script): @pytest.mark.asyncio async def test_hook_asgi_wrapper(ds_client): response = await ds_client.get("/fixtures") - assert "_internal, fixtures" == response.headers["x-databases"] + assert "fixtures" == response.headers["x-databases"] def test_hook_extra_template_vars(restore_working_directory): From a1f3d75a527b222cf1df51c41e1c424b38428a99 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 28 Aug 2023 20:46:12 -0700 Subject: [PATCH 011/474] Need to stick to Python 3.9 for gcloud --- .github/workflows/deploy-latest.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy-latest.yml b/.github/workflows/deploy-latest.yml index 8cd9dcda..0dfa5a60 100644 --- a/.github/workflows/deploy-latest.yml +++ b/.github/workflows/deploy-latest.yml @@ -17,8 +17,9 @@ jobs: uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v4 + # gcloud commmand breaks on higher Python versions, so stick with 3.9: with: - python-version: "3.11" + python-version: "3.9" - uses: actions/cache@v3 name: Configure pip caching with: From 50da908213a0fc405ecd7a40090dfea7a2e7395c Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 29 Aug 2023 09:32:34 -0700 Subject: [PATCH 012/474] Cascade for restricted token view-table/view-database/view-instance operations (#2154) Closes #2102 * Permission is now a dataclass, not a namedtuple - refs https://github.com/simonw/datasette/pull/2154/#discussion_r1308087800 * datasette.get_permission() method --- datasette/app.py | 14 ++ datasette/default_permissions.py | 241 +++++++++++++++++++++++------- datasette/permissions.py | 20 ++- datasette/views/special.py | 12 +- docs/internals.rst | 12 +- docs/plugin_hooks.rst | 14 +- tests/test_internals_datasette.py | 14 ++ tests/test_permissions.py | 194 +++++++++++++++++++++++- 8 files changed, 449 insertions(+), 72 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 4deb8697..d95ec2bf 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -431,6 +431,20 @@ class Datasette: self._root_token = secrets.token_hex(32) self.client = DatasetteClient(self) + def get_permission(self, name_or_abbr: str) -> "Permission": + """ + Returns a Permission object for the given name or abbreviation. Raises KeyError if not found. + """ + if name_or_abbr in self.permissions: + return self.permissions[name_or_abbr] + # Try abbreviation + for permission in self.permissions.values(): + if permission.abbr == name_or_abbr: + return permission + raise KeyError( + "No permission found with name or abbreviation {}".format(name_or_abbr) + ) + async def refresh_schemas(self): if self._refresh_schemas_lock.locked(): return diff --git a/datasette/default_permissions.py b/datasette/default_permissions.py index f0b086e9..960429fc 100644 --- a/datasette/default_permissions.py +++ b/datasette/default_permissions.py @@ -2,6 +2,7 @@ from datasette import hookimpl, Permission from datasette.utils import actor_matches_allow import itsdangerous import time +from typing import Union, Tuple @hookimpl @@ -9,32 +10,112 @@ def register_permissions(): return ( # name, abbr, description, takes_database, takes_resource, default Permission( - "view-instance", "vi", "View Datasette instance", False, False, True - ), - Permission("view-database", "vd", "View database", True, False, True), - Permission( - "view-database-download", "vdd", "Download database file", True, False, True - ), - Permission("view-table", "vt", "View table", True, True, True), - Permission("view-query", "vq", "View named query results", True, True, True), - Permission( - "execute-sql", "es", "Execute read-only SQL queries", True, False, True + name="view-instance", + abbr="vi", + description="View Datasette instance", + takes_database=False, + takes_resource=False, + default=True, ), Permission( - "permissions-debug", - "pd", - "Access permission debug tool", - False, - False, - False, + name="view-database", + abbr="vd", + description="View database", + takes_database=True, + takes_resource=False, + default=True, + implies_can_view=True, + ), + Permission( + name="view-database-download", + abbr="vdd", + description="Download database file", + takes_database=True, + takes_resource=False, + default=True, + ), + Permission( + name="view-table", + abbr="vt", + description="View table", + takes_database=True, + takes_resource=True, + default=True, + implies_can_view=True, + ), + Permission( + name="view-query", + abbr="vq", + description="View named query results", + takes_database=True, + takes_resource=True, + default=True, + implies_can_view=True, + ), + Permission( + name="execute-sql", + abbr="es", + description="Execute read-only SQL queries", + takes_database=True, + takes_resource=False, + default=True, + ), + Permission( + name="permissions-debug", + abbr="pd", + description="Access permission debug tool", + takes_database=False, + takes_resource=False, + default=False, + ), + Permission( + name="debug-menu", + abbr="dm", + description="View debug menu items", + takes_database=False, + takes_resource=False, + default=False, + ), + Permission( + name="insert-row", + abbr="ir", + description="Insert rows", + takes_database=True, + takes_resource=True, + default=False, + ), + Permission( + name="delete-row", + abbr="dr", + description="Delete rows", + takes_database=True, + takes_resource=True, + default=False, + ), + Permission( + name="update-row", + abbr="ur", + description="Update rows", + takes_database=True, + takes_resource=True, + default=False, + ), + Permission( + name="create-table", + abbr="ct", + description="Create tables", + takes_database=True, + takes_resource=False, + default=False, + ), + Permission( + name="drop-table", + abbr="dt", + description="Drop tables", + takes_database=True, + takes_resource=True, + default=False, ), - Permission("debug-menu", "dm", "View debug menu items", False, False, False), - # Write API permissions - Permission("insert-row", "ir", "Insert rows", True, True, False), - Permission("delete-row", "dr", "Delete rows", True, True, False), - Permission("update-row", "ur", "Update rows", True, True, False), - Permission("create-table", "ct", "Create tables", True, False, False), - Permission("drop-table", "dt", "Drop tables", True, True, False), ) @@ -176,6 +257,80 @@ async def _resolve_metadata_view_permissions(datasette, actor, action, resource) return actor_matches_allow(actor, database_allow_sql) +def restrictions_allow_action( + datasette: "Datasette", + restrictions: dict, + action: str, + resource: Union[str, Tuple[str, str]], +): + "Do these restrictions allow the requested action against the requested resource?" + if action == "view-instance": + # Special case for view-instance: it's allowed if the restrictions include any + # permissions that have the implies_can_view=True flag set + all_rules = restrictions.get("a") or [] + for database_rules in (restrictions.get("d") or {}).values(): + all_rules += database_rules + for database_resource_rules in (restrictions.get("r") or {}).values(): + for resource_rules in database_resource_rules.values(): + all_rules += resource_rules + permissions = [datasette.get_permission(action) for action in all_rules] + if any(p for p in permissions if p.implies_can_view): + return True + + if action == "view-database": + # Special case for view-database: it's allowed if the restrictions include any + # permissions that have the implies_can_view=True flag set AND takes_database + all_rules = restrictions.get("a") or [] + database_rules = list((restrictions.get("d") or {}).get(resource) or []) + all_rules += database_rules + resource_rules = ((restrictions.get("r") or {}).get(resource) or {}).values() + for resource_rules in (restrictions.get("r") or {}).values(): + for table_rules in resource_rules.values(): + all_rules += table_rules + permissions = [datasette.get_permission(action) for action in all_rules] + if any(p for p in permissions if p.implies_can_view and p.takes_database): + return True + + # Does this action have an abbreviation? + to_check = {action} + permission = datasette.permissions.get(action) + if permission and permission.abbr: + to_check.add(permission.abbr) + + # If restrictions is defined then we use those to further restrict the actor + # Crucially, we only use this to say NO (return False) - we never + # use it to return YES (True) because that might over-ride other + # restrictions placed on this actor + all_allowed = restrictions.get("a") + if all_allowed is not None: + assert isinstance(all_allowed, list) + if to_check.intersection(all_allowed): + return True + # How about for the current database? + if resource: + if isinstance(resource, str): + database_name = resource + else: + database_name = resource[0] + database_allowed = restrictions.get("d", {}).get(database_name) + if database_allowed is not None: + assert isinstance(database_allowed, list) + if to_check.intersection(database_allowed): + return True + # Or the current table? That's any time the resource is (database, table) + if resource is not None and not isinstance(resource, str) and len(resource) == 2: + database, table = resource + table_allowed = restrictions.get("r", {}).get(database, {}).get(table) + # TODO: What should this do for canned queries? + if table_allowed is not None: + assert isinstance(table_allowed, list) + if to_check.intersection(table_allowed): + return True + + # This action is not specifically allowed, so reject it + return False + + @hookimpl(specname="permission_allowed") def permission_allowed_actor_restrictions(datasette, actor, action, resource): if actor is None: @@ -184,40 +339,12 @@ def permission_allowed_actor_restrictions(datasette, actor, action, resource): # No restrictions, so we have no opinion return None _r = actor.get("_r") - - # Does this action have an abbreviation? - to_check = {action} - permission = datasette.permissions.get(action) - if permission and permission.abbr: - to_check.add(permission.abbr) - - # If _r is defined then we use those to further restrict the actor - # Crucially, we only use this to say NO (return False) - we never - # use it to return YES (True) because that might over-ride other - # restrictions placed on this actor - all_allowed = _r.get("a") - if all_allowed is not None: - assert isinstance(all_allowed, list) - if to_check.intersection(all_allowed): - return None - # How about for the current database? - if isinstance(resource, str): - database_allowed = _r.get("d", {}).get(resource) - if database_allowed is not None: - assert isinstance(database_allowed, list) - if to_check.intersection(database_allowed): - return None - # Or the current table? That's any time the resource is (database, table) - if resource is not None and not isinstance(resource, str) and len(resource) == 2: - database, table = resource - table_allowed = _r.get("r", {}).get(database, {}).get(table) - # TODO: What should this do for canned queries? - if table_allowed is not None: - assert isinstance(table_allowed, list) - if to_check.intersection(table_allowed): - return None - # This action is not specifically allowed, so reject it - return False + if restrictions_allow_action(datasette, _r, action, resource): + # Return None because we do not have an opinion here + return None + else: + # Block this permission check + return False @hookimpl diff --git a/datasette/permissions.py b/datasette/permissions.py index 1cd3474d..152f1721 100644 --- a/datasette/permissions.py +++ b/datasette/permissions.py @@ -1,6 +1,16 @@ -import collections +from dataclasses import dataclass, fields +from typing import Optional -Permission = collections.namedtuple( - "Permission", - ("name", "abbr", "description", "takes_database", "takes_resource", "default"), -) + +@dataclass +class Permission: + name: str + abbr: Optional[str] + description: Optional[str] + takes_database: bool + takes_resource: bool + default: bool + # This is deliberately undocumented: it's considered an internal + # implementation detail for view-table/view-database and should + # not be used by plugins as it may change in the future. + implies_can_view: bool = False diff --git a/datasette/views/special.py b/datasette/views/special.py index c1b84f8f..849750bf 100644 --- a/datasette/views/special.py +++ b/datasette/views/special.py @@ -122,7 +122,17 @@ class PermissionsDebugView(BaseView): # list() avoids error if check is performed during template render: { "permission_checks": list(reversed(self.ds._permission_checks)), - "permissions": list(self.ds.permissions.values()), + "permissions": [ + ( + p.name, + p.abbr, + p.description, + p.takes_database, + p.takes_resource, + p.default, + ) + for p in self.ds.permissions.values() + ], }, ) diff --git a/docs/internals.rst b/docs/internals.rst index fe9a2fa7..474e3328 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -280,7 +280,7 @@ All databases are listed, irrespective of user permissions. Property exposing a dictionary of permissions that have been registered using the :ref:`plugin_register_permissions` plugin hook. -The dictionary keys are the permission names - e.g. ``view-instance`` - and the values are ``Permission()`` named tuples describing the permission. Here is a :ref:`description of that tuple `. +The dictionary keys are the permission names - e.g. ``view-instance`` - and the values are ``Permission()`` objects describing the permission. Here is a :ref:`description of that object `. .. _datasette_plugin_config: @@ -469,6 +469,16 @@ The following example creates a token that can access ``view-instance`` and ``vi }, ) +.. _datasette_get_permission: + +.get_permission(name_or_abbr) +----------------------------- + +``name_or_abbr`` - string + The name or abbreviation of the permission to look up, e.g. ``view-table`` or ``vt``. + +Returns a :ref:`Permission object ` representing the permission, or raises a ``KeyError`` if one is not found. + .. _datasette_get_database: .get_database(name) diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 497508ae..f8bb203d 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -794,24 +794,24 @@ If your plugin needs to register additional permissions unique to that plugin - ) ] -The fields of the ``Permission`` named tuple are as follows: +The fields of the ``Permission`` class are as follows: -``name`` +``name`` - string The name of the permission, e.g. ``upload-csvs``. This should be unique across all plugins that the user might have installed, so choose carefully. -``abbr`` +``abbr`` - string or None An abbreviation of the permission, e.g. ``uc``. This is optional - you can set it to ``None`` if you do not want to pick an abbreviation. Since this needs to be unique across all installed plugins it's best not to specify an abbreviation at all. If an abbreviation is provided it will be used when creating restricted signed API tokens. -``description`` +``description`` - string or None A human-readable description of what the permission lets you do. Should make sense as the second part of a sentence that starts "A user with this permission can ...". -``takes_database`` +``takes_database`` - boolean ``True`` if this permission can be granted on a per-database basis, ``False`` if it is only valid at the overall Datasette instance level. -``takes_resource`` +``takes_resource`` - boolean ``True`` if this permission can be granted on a per-resource basis. A resource is a database table, SQL view or :ref:`canned query `. -``default`` +``default`` - boolean The default value for this permission if it is not explicitly granted to a user. ``True`` means the permission is granted by default, ``False`` means it is not. This should only be ``True`` if you want anonymous users to be able to take this action. diff --git a/tests/test_internals_datasette.py b/tests/test_internals_datasette.py index d59ff729..c11e840c 100644 --- a/tests/test_internals_datasette.py +++ b/tests/test_internals_datasette.py @@ -159,3 +159,17 @@ def test_datasette_error_if_string_not_list(tmpdir): db_path = str(tmpdir / "data.db") with pytest.raises(ValueError): ds = Datasette(db_path) + + +@pytest.mark.asyncio +async def test_get_permission(ds_client): + ds = ds_client.ds + for name_or_abbr in ("vi", "view-instance", "vt", "view-table"): + permission = ds.get_permission(name_or_abbr) + if "-" in name_or_abbr: + assert permission.name == name_or_abbr + else: + assert permission.abbr == name_or_abbr + # And test KeyError + with pytest.raises(KeyError): + ds.get_permission("missing-permission") diff --git a/tests/test_permissions.py b/tests/test_permissions.py index f940d486..cad0525f 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -1,6 +1,7 @@ import collections from datasette.app import Datasette from datasette.cli import cli +from datasette.default_permissions import restrictions_allow_action from .fixtures import app_client, assert_permissions_checked, make_app_client from click.testing import CliRunner from bs4 import BeautifulSoup as Soup @@ -35,6 +36,8 @@ async def perms_ds(): one = ds.add_memory_database("perms_ds_one") two = ds.add_memory_database("perms_ds_two") await one.execute_write("create table if not exists t1 (id integer primary key)") + await one.execute_write("insert or ignore into t1 (id) values (1)") + await one.execute_write("create view if not exists v1 as select * from t1") await one.execute_write("create table if not exists t2 (id integer primary key)") await two.execute_write("create table if not exists t1 (id integer primary key)") return ds @@ -585,7 +588,6 @@ DEF = "USE_DEFAULT" ({"id": "t", "_r": {"a": ["vd"]}}, "view-database", "one", None, DEF), ({"id": "t", "_r": {"a": ["vt"]}}, "view-table", "one", "t1", DEF), # But not if it's the wrong permission - ({"id": "t", "_r": {"a": ["vd"]}}, "view-instance", None, None, False), ({"id": "t", "_r": {"a": ["vi"]}}, "view-database", "one", None, False), ({"id": "t", "_r": {"a": ["vd"]}}, "view-table", "one", "t1", False), # Works at the "d" for database level: @@ -629,11 +631,14 @@ DEF = "USE_DEFAULT" "t1", DEF, ), + # view-instance is granted if you have view-database + ({"id": "t", "_r": {"a": ["vd"]}}, "view-instance", None, None, DEF), ), ) async def test_actor_restricted_permissions( perms_ds, actor, permission, resource_1, resource_2, expected_result ): + perms_ds.pdb = True cookies = {"ds_actor": perms_ds.sign({"a": {"id": "root"}}, "actor")} csrftoken = (await perms_ds.client.get("/-/permissions", cookies=cookies)).cookies[ "ds_csrftoken" @@ -1018,3 +1023,190 @@ async def test_api_explorer_visibility( assert response.status_code == 403 finally: perms_ds._metadata_local = prev_metadata + + +@pytest.mark.asyncio +async def test_view_table_token_can_access_table(perms_ds): + actor = { + "id": "restricted-token", + "token": "dstok", + # Restricted to just view-table on perms_ds_two/t1 + "_r": {"r": {"perms_ds_two": {"t1": ["vt"]}}}, + } + cookies = {"ds_actor": perms_ds.client.actor_cookie(actor)} + response = await perms_ds.client.get("/perms_ds_two/t1.json", cookies=cookies) + assert response.status_code == 200 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "restrictions,verb,path,body,expected_status", + ( + # No restrictions + (None, "get", "/.json", None, 200), + (None, "get", "/perms_ds_one.json", None, 200), + (None, "get", "/perms_ds_one/t1.json", None, 200), + (None, "get", "/perms_ds_one/t1/1.json", None, 200), + (None, "get", "/perms_ds_one/v1.json", None, 200), + # Restricted to just view-instance + ({"a": ["vi"]}, "get", "/.json", None, 200), + ({"a": ["vi"]}, "get", "/perms_ds_one.json", None, 403), + ({"a": ["vi"]}, "get", "/perms_ds_one/t1.json", None, 403), + ({"a": ["vi"]}, "get", "/perms_ds_one/t1/1.json", None, 403), + ({"a": ["vi"]}, "get", "/perms_ds_one/v1.json", None, 403), + # Restricted to just view-database + ({"a": ["vd"]}, "get", "/.json", None, 200), # Can see instance too + ({"a": ["vd"]}, "get", "/perms_ds_one.json", None, 200), + ({"a": ["vd"]}, "get", "/perms_ds_one/t1.json", None, 403), + ({"a": ["vd"]}, "get", "/perms_ds_one/t1/1.json", None, 403), + ({"a": ["vd"]}, "get", "/perms_ds_one/v1.json", None, 403), + # Restricted to just view-table for specific database + ( + {"d": {"perms_ds_one": ["vt"]}}, + "get", + "/.json", + None, + 200, + ), # Can see instance + ( + {"d": {"perms_ds_one": ["vt"]}}, + "get", + "/perms_ds_one.json", + None, + 200, + ), # and this database + ( + {"d": {"perms_ds_one": ["vt"]}}, + "get", + "/perms_ds_two.json", + None, + 403, + ), # But not this one + ( + # Can see the table + {"d": {"perms_ds_one": ["vt"]}}, + "get", + "/perms_ds_one/t1.json", + None, + 200, + ), + ( + # And the view + {"d": {"perms_ds_one": ["vt"]}}, + "get", + "/perms_ds_one/v1.json", + None, + 200, + ), + # view-table access to a specific table + ( + {"r": {"perms_ds_one": {"t1": ["vt"]}}}, + "get", + "/.json", + None, + 200, + ), + ( + {"r": {"perms_ds_one": {"t1": ["vt"]}}}, + "get", + "/perms_ds_one.json", + None, + 200, + ), + ( + {"r": {"perms_ds_one": {"t1": ["vt"]}}}, + "get", + "/perms_ds_one/t1.json", + None, + 200, + ), + # But cannot see the other table + ( + {"r": {"perms_ds_one": {"t1": ["vt"]}}}, + "get", + "/perms_ds_one/t2.json", + None, + 403, + ), + # Or the view + ( + {"r": {"perms_ds_one": {"t1": ["vt"]}}}, + "get", + "/perms_ds_one/v1.json", + None, + 403, + ), + ), +) +async def test_actor_restrictions( + perms_ds, restrictions, verb, path, body, expected_status +): + actor = {"id": "user"} + if restrictions: + actor["_r"] = restrictions + method = getattr(perms_ds.client, verb) + kwargs = {"cookies": {"ds_actor": perms_ds.client.actor_cookie(actor)}} + if body: + kwargs["json"] = body + perms_ds._permission_checks.clear() + response = await method(path, **kwargs) + assert response.status_code == expected_status, json.dumps( + { + "verb": verb, + "path": path, + "body": body, + "restrictions": restrictions, + "expected_status": expected_status, + "response_status": response.status_code, + "checks": [ + { + "action": check["action"], + "resource": check["resource"], + "result": check["result"], + } + for check in perms_ds._permission_checks + ], + }, + indent=2, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "restrictions,action,resource,expected", + ( + ({"a": ["view-instance"]}, "view-instance", None, True), + # view-table and view-database implies view-instance + ({"a": ["view-table"]}, "view-instance", None, True), + ({"a": ["view-database"]}, "view-instance", None, True), + # update-row does not imply view-instance + ({"a": ["update-row"]}, "view-instance", None, False), + # view-table on a resource implies view-instance + ({"r": {"db1": {"t1": ["view-table"]}}}, "view-instance", None, True), + # update-row on a resource does not imply view-instance + ({"r": {"db1": {"t1": ["update-row"]}}}, "view-instance", None, False), + # view-database on a resource implies view-instance + ({"d": {"db1": ["view-database"]}}, "view-instance", None, True), + # Having view-table on "a" allows access to any specific table + ({"a": ["view-table"]}, "view-table", ("dbname", "tablename"), True), + # Ditto for on the database + ( + {"d": {"dbname": ["view-table"]}}, + "view-table", + ("dbname", "tablename"), + True, + ), + # But not if it's allowed on a different database + ( + {"d": {"dbname": ["view-table"]}}, + "view-table", + ("dbname2", "tablename"), + False, + ), + ), +) +async def test_restrictions_allow_action(restrictions, action, resource, expected): + ds = Datasette() + await ds.invoke_startup() + actual = restrictions_allow_action(ds, restrictions, action, resource) + assert actual == expected From bb12229794655abaa21a9aa691d1f85d34b6c45a Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 29 Aug 2023 10:01:28 -0700 Subject: [PATCH 013/474] Rename core_ to catalog_, closes #2163 --- datasette/app.py | 4 ++-- datasette/utils/internal_db.py | 28 +++++++++++++++------------- datasette/views/database.py | 2 +- docs/internals.rst | 2 ++ tests/test_internal_db.py | 8 ++++---- 5 files changed, 24 insertions(+), 20 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index d95ec2bf..0227f627 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -459,7 +459,7 @@ class Datasette: current_schema_versions = { row["database_name"]: row["schema_version"] for row in await internal_db.execute( - "select database_name, schema_version from core_databases" + "select database_name, schema_version from catalog_databases" ) } for database_name, db in self.databases.items(): @@ -474,7 +474,7 @@ class Datasette: values = [database_name, db.is_memory, schema_version] await internal_db.execute_write( """ - INSERT OR REPLACE INTO core_databases (database_name, path, is_memory, schema_version) + INSERT OR REPLACE INTO catalog_databases (database_name, path, is_memory, schema_version) VALUES {} """.format( placeholders diff --git a/datasette/utils/internal_db.py b/datasette/utils/internal_db.py index 215695ca..2e5ac53b 100644 --- a/datasette/utils/internal_db.py +++ b/datasette/utils/internal_db.py @@ -5,13 +5,13 @@ from datasette.utils import table_column_details async def init_internal_db(db): create_tables_sql = textwrap.dedent( """ - CREATE TABLE IF NOT EXISTS core_databases ( + CREATE TABLE IF NOT EXISTS catalog_databases ( database_name TEXT PRIMARY KEY, path TEXT, is_memory INTEGER, schema_version INTEGER ); - CREATE TABLE IF NOT EXISTS core_tables ( + CREATE TABLE IF NOT EXISTS catalog_tables ( database_name TEXT, table_name TEXT, rootpage INTEGER, @@ -19,7 +19,7 @@ async def init_internal_db(db): PRIMARY KEY (database_name, table_name), FOREIGN KEY (database_name) REFERENCES databases(database_name) ); - CREATE TABLE IF NOT EXISTS core_columns ( + CREATE TABLE IF NOT EXISTS catalog_columns ( database_name TEXT, table_name TEXT, cid INTEGER, @@ -33,7 +33,7 @@ async def init_internal_db(db): FOREIGN KEY (database_name) REFERENCES databases(database_name), FOREIGN KEY (database_name, table_name) REFERENCES tables(database_name, table_name) ); - CREATE TABLE IF NOT EXISTS core_indexes ( + CREATE TABLE IF NOT EXISTS catalog_indexes ( database_name TEXT, table_name TEXT, seq INTEGER, @@ -45,7 +45,7 @@ async def init_internal_db(db): FOREIGN KEY (database_name) REFERENCES databases(database_name), FOREIGN KEY (database_name, table_name) REFERENCES tables(database_name, table_name) ); - CREATE TABLE IF NOT EXISTS core_foreign_keys ( + CREATE TABLE IF NOT EXISTS catalog_foreign_keys ( database_name TEXT, table_name TEXT, id INTEGER, @@ -69,15 +69,17 @@ async def populate_schema_tables(internal_db, db): database_name = db.name def delete_everything(conn): - conn.execute("DELETE FROM core_tables WHERE database_name = ?", [database_name]) conn.execute( - "DELETE FROM core_columns WHERE database_name = ?", [database_name] + "DELETE FROM catalog_tables WHERE database_name = ?", [database_name] ) conn.execute( - "DELETE FROM core_foreign_keys WHERE database_name = ?", [database_name] + "DELETE FROM catalog_columns WHERE database_name = ?", [database_name] ) conn.execute( - "DELETE FROM core_indexes WHERE database_name = ?", [database_name] + "DELETE FROM catalog_foreign_keys WHERE database_name = ?", [database_name] + ) + conn.execute( + "DELETE FROM catalog_indexes WHERE database_name = ?", [database_name] ) await internal_db.execute_write_fn(delete_everything) @@ -137,14 +139,14 @@ async def populate_schema_tables(internal_db, db): await internal_db.execute_write_many( """ - INSERT INTO core_tables (database_name, table_name, rootpage, sql) + INSERT INTO catalog_tables (database_name, table_name, rootpage, sql) values (?, ?, ?, ?) """, tables_to_insert, ) await internal_db.execute_write_many( """ - INSERT INTO core_columns ( + INSERT INTO catalog_columns ( database_name, table_name, cid, name, type, "notnull", default_value, is_pk, hidden ) VALUES ( :database_name, :table_name, :cid, :name, :type, :notnull, :default_value, :is_pk, :hidden @@ -154,7 +156,7 @@ async def populate_schema_tables(internal_db, db): ) await internal_db.execute_write_many( """ - INSERT INTO core_foreign_keys ( + INSERT INTO catalog_foreign_keys ( database_name, table_name, "id", seq, "table", "from", "to", on_update, on_delete, match ) VALUES ( :database_name, :table_name, :id, :seq, :table, :from, :to, :on_update, :on_delete, :match @@ -164,7 +166,7 @@ async def populate_schema_tables(internal_db, db): ) await internal_db.execute_write_many( """ - INSERT INTO core_indexes ( + INSERT INTO catalog_indexes ( database_name, table_name, seq, name, "unique", origin, partial ) VALUES ( :database_name, :table_name, :seq, :name, :unique, :origin, :partial diff --git a/datasette/views/database.py b/datasette/views/database.py index 4647bedc..9ba5ce94 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -952,7 +952,7 @@ class TableCreateView(BaseView): async def _table_columns(datasette, database_name): internal_db = datasette.get_internal_database() result = await internal_db.execute( - "select table_name, name from core_columns where database_name = ?", + "select table_name, name from catalog_columns where database_name = ?", [database_name], ) table_columns = {} diff --git a/docs/internals.rst b/docs/internals.rst index 474e3328..743f5972 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -1149,6 +1149,8 @@ Datasette's internal database Datasette maintains an "internal" SQLite database used for configuration, caching, and storage. Plugins can store configuration, settings, and other data inside this database. By default, Datasette will use a temporary in-memory SQLite database as the internal database, which is created at startup and destroyed at shutdown. Users of Datasette can optionally pass in a `--internal` flag to specify the path to a SQLite database to use as the internal database, which will persist internal data across Datasette instances. +Datasette maintains tables called ``catalog_databases``, ``catalog_tables``, ``catalog_columns``, ``catalog_indexes``, ``catalog_foreign_keys`` with details of the attached databases and their schemas. These tables should not be considered a stable API - they may change between Datasette releases. + The internal database is not exposed in the Datasette application by default, which means private data can safely be stored without worry of accidentally leaking information through the default Datasette interface and API. However, other plugins do have full read and write access to the internal database. Plugins can access this database by calling ``internal_db = datasette.get_internal_database()`` and then executing queries using the :ref:`Database API `. diff --git a/tests/test_internal_db.py b/tests/test_internal_db.py index 5276dc99..b41cabb4 100644 --- a/tests/test_internal_db.py +++ b/tests/test_internal_db.py @@ -10,7 +10,7 @@ async def ensure_internal(ds_client): @pytest.mark.asyncio async def test_internal_databases(ds_client): internal_db = await ensure_internal(ds_client) - databases = await internal_db.execute("select * from core_databases") + databases = await internal_db.execute("select * from catalog_databases") assert len(databases) == 1 assert databases.rows[0]["database_name"] == "fixtures" @@ -18,7 +18,7 @@ async def test_internal_databases(ds_client): @pytest.mark.asyncio async def test_internal_tables(ds_client): internal_db = await ensure_internal(ds_client) - tables = await internal_db.execute("select * from core_tables") + tables = await internal_db.execute("select * from catalog_tables") assert len(tables) > 5 table = tables.rows[0] assert set(table.keys()) == {"rootpage", "table_name", "database_name", "sql"} @@ -27,7 +27,7 @@ async def test_internal_tables(ds_client): @pytest.mark.asyncio async def test_internal_indexes(ds_client): internal_db = await ensure_internal(ds_client) - indexes = await internal_db.execute("select * from core_indexes") + indexes = await internal_db.execute("select * from catalog_indexes") assert len(indexes) > 5 index = indexes.rows[0] assert set(index.keys()) == { @@ -44,7 +44,7 @@ async def test_internal_indexes(ds_client): @pytest.mark.asyncio async def test_internal_foreign_keys(ds_client): internal_db = await ensure_internal(ds_client) - foreign_keys = await internal_db.execute("select * from core_foreign_keys") + foreign_keys = await internal_db.execute("select * from catalog_foreign_keys") assert len(foreign_keys) > 5 foreign_key = foreign_keys.rows[0] assert set(foreign_key.keys()) == { From 30b28c8367a9c6870386ea10a202705b40862457 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 29 Aug 2023 10:17:54 -0700 Subject: [PATCH 014/474] Release 1.0a5 Refs #2093, #2102, #2153, #2156, #2157 --- datasette/version.py | 2 +- docs/changelog.rst | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/datasette/version.py b/datasette/version.py index 1d003352..b99c212b 100644 --- a/datasette/version.py +++ b/datasette/version.py @@ -1,2 +1,2 @@ -__version__ = "1.0a4" +__version__ = "1.0a5" __version_info__ = tuple(__version__.split(".")) diff --git a/docs/changelog.rst b/docs/changelog.rst index 937610bd..019d6c68 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,17 @@ Changelog ========= +.. _v1_0_a5: + +1.0a5 (2023-08-29) +------------------ + +- When restrictions are applied to :ref:`API tokens `, those restrictions now behave slightly differently: applying the ``view-table`` restriction will imply the ability to ``view-database`` for the database containing that table, and both ``view-table`` and ``view-database`` will imply ``view-instance``. Previously you needed to create a token with restrictions that explicitly listed ``view-instance`` and ``view-database`` and ``view-table`` in order to view a table without getting a permission denied error. (:issue:`2102`) +- New ``datasette.yaml`` (or ``.json``) configuration file, which can be specified using ``datasette -c path-to-file``. The goal here to consolidate settings, plugin configuration, permissions, canned queries, and other Datasette configuration into a single single file, separate from ``metadata.yaml``. The legacy ``settings.json`` config file used for :ref:`config_dir` has been removed, and ``datasette.yaml`` has a ``"settings"`` section where the same settings key/value pairs can be included. In the next future alpha release, more configuration such as plugins/permissions/canned queries will be moved to the ``datasette.yaml`` file. See :issue:`2093` for more details. Thanks, Alex Garcia. +- The ``-s/--setting`` option can now take dotted paths to nested settings. These will then be used to set or over-ride the same options as are present in the new configuration file. (:issue:`2156`) +- New ``--actor '{"id": "json-goes-here"}'`` option for use with ``datasette --get`` to treat the simulated request as being made by a specific actor, see :ref:`cli_datasette_get`. (:issue:`2153`) +- The Datasette ``_internal`` database has had some changes. It no longer shows up in the ``datasette.databases`` list by default, and is now instead available to plugins using the ``datasette.get_internal_database()``. Plugins are invited to use this as a private database to store configuration and settings and secrets that should not be made visible through the default Datasette interface. Users can pass the new ``--internal internal.db`` option to persist that internal database to disk. Thanks, Alex Garcia. (:issue:`2157`). + .. _v1_0_a4: 1.0a4 (2023-08-21) From 6bfe104d47b888c70bfb7781f8f48ff11452b2b5 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 30 Aug 2023 15:12:24 -0700 Subject: [PATCH 015/474] DATASETTE_LOAD_PLUGINS environment variable for loading specific plugins Closes #2164 * Load only specified plugins for DATASETTE_LOAD_PLUGINS=datasette-one,datasette-two * Load no plugins if DATASETTE_LOAD_PLUGINS='' * Automated tests in a Bash script for DATASETTE_LOAD_PLUGINS --- .github/workflows/test.yml | 4 +++ datasette/plugins.py | 23 +++++++++++- docs/plugins.rst | 54 ++++++++++++++++++++++++++++ tests/test-datasette-load-plugins.sh | 29 +++++++++++++++ 4 files changed, 109 insertions(+), 1 deletion(-) create mode 100755 tests/test-datasette-load-plugins.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8cbbb572..2784db86 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -50,3 +50,7 @@ jobs: run: | # This fails on syntax errors, or a diff was applied blacken-docs -l 60 docs/*.rst + - name: Test DATASETTE_LOAD_PLUGINS + run: | + pip install datasette-init datasette-json-html + tests/test-datasette-load-plugins.sh diff --git a/datasette/plugins.py b/datasette/plugins.py index fef0c8e9..6ec08a81 100644 --- a/datasette/plugins.py +++ b/datasette/plugins.py @@ -1,4 +1,5 @@ import importlib +import os import pluggy import pkg_resources import sys @@ -22,10 +23,30 @@ DEFAULT_PLUGINS = ( pm = pluggy.PluginManager("datasette") pm.add_hookspecs(hookspecs) -if not hasattr(sys, "_called_from_test"): +DATASETTE_LOAD_PLUGINS = os.environ.get("DATASETTE_LOAD_PLUGINS", None) + +if not hasattr(sys, "_called_from_test") and DATASETTE_LOAD_PLUGINS is None: # Only load plugins if not running tests pm.load_setuptools_entrypoints("datasette") +# Load any plugins specified in DATASETTE_LOAD_PLUGINS") +if DATASETTE_LOAD_PLUGINS is not None: + for package_name in [ + name for name in DATASETTE_LOAD_PLUGINS.split(",") if name.strip() + ]: + try: + distribution = pkg_resources.get_distribution(package_name) + entry_map = distribution.get_entry_map() + if "datasette" in entry_map: + for plugin_name, entry_point in entry_map["datasette"].items(): + mod = entry_point.load() + pm.register(mod, name=entry_point.name) + # Ensure name can be found in plugin_to_distinfo later: + pm._plugin_distinfo.append((mod, distribution)) + except pkg_resources.DistributionNotFound: + sys.stderr.write("Plugin {} could not be found\n".format(package_name)) + + # Load default plugins for plugin in DEFAULT_PLUGINS: mod = importlib.import_module(plugin) diff --git a/docs/plugins.rst b/docs/plugins.rst index 19bfdd0c..11db40af 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -81,6 +81,60 @@ You can use the name of a package on PyPI or any of the other valid arguments to datasette publish cloudrun mydb.db \ --install=https://url-to-my-package.zip + +.. _plugins_datasette_load_plugins: + +Controlling which plugins are loaded +------------------------------------ + +Datasette defaults to loading every plugin that is installed in the same virtual environment as Datasette itself. + +You can set the ``DATASETTE_LOAD_PLUGINS`` environment variable to a comma-separated list of plugin names to load a controlled subset of plugins instead. + +For example, to load just the ``datasette-vega`` and ``datasette-cluster-map`` plugins, set ``DATASETTE_LOAD_PLUGINS`` to ``datasette-vega,datasette-cluster-map``: + +.. code-block:: bash + + export DATASETTE_LOAD_PLUGINS='datasette-vega,datasette-cluster-map' + datasette mydb.db + +Or: + +.. code-block:: bash + + DATASETTE_LOAD_PLUGINS='datasette-vega,datasette-cluster-map' \ + datasette mydb.db + +To disable the loading of all additional plugins, set ``DATASETTE_LOAD_PLUGINS`` to an empty string: + +.. code-block:: bash + + export DATASETTE_LOAD_PLUGINS='' + datasette mydb.db + +A quick way to test this setting is to use it with the ``datasette plugins`` command: + +.. code-block:: bash + + DATASETTE_LOAD_PLUGINS='datasette-vega' datasette plugins + +This should output the following: + +.. code-block:: json + + [ + { + "name": "datasette-vega", + "static": true, + "templates": false, + "version": "0.6.2", + "hooks": [ + "extra_css_urls", + "extra_js_urls" + ] + } + ] + .. _plugins_installed: Seeing what plugins are installed diff --git a/tests/test-datasette-load-plugins.sh b/tests/test-datasette-load-plugins.sh new file mode 100755 index 00000000..e26d8377 --- /dev/null +++ b/tests/test-datasette-load-plugins.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# This should only run in environemnts where both +# datasette-init and datasette-json-html are installed + +PLUGINS=$(datasette plugins) +echo "$PLUGINS" | jq 'any(.[]; .name == "datasette-json-html")' | \ + grep -q true || ( \ + echo "Test failed: datasette-json-html not found" && \ + exit 1 \ + ) +# With the DATASETTE_LOAD_PLUGINS we should not see that +PLUGINS2=$(DATASETTE_LOAD_PLUGINS=datasette-init datasette plugins) +echo "$PLUGINS2" | jq 'any(.[]; .name == "datasette-json-html")' | \ + grep -q false || ( \ + echo "Test failed: datasette-json-html should not have been loaded" && \ + exit 1 \ + ) +echo "$PLUGINS2" | jq 'any(.[]; .name == "datasette-init")' | \ + grep -q true || ( \ + echo "Test failed: datasette-init should have been loaded" && \ + exit 1 \ + ) +# With DATASETTE_LOAD_PLUGINS='' we should see no plugins +PLUGINS3=$(DATASETTE_LOAD_PLUGINS='' datasette plugins) +echo "$PLUGINS3"| \ + grep -q '\[\]' || ( \ + echo "Test failed: datasette plugins should have returned []" && \ + exit 1 \ + ) From 2caa53a52a37e53f83e3a854fc721c7e26c5e9ff Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 30 Aug 2023 16:19:24 -0700 Subject: [PATCH 016/474] ReST fix --- docs/internals.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/internals.rst b/docs/internals.rst index 743f5972..d136ad5a 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -1147,7 +1147,7 @@ You can selectively disable CSRF protection using the :ref:`plugin_hook_skip_csr Datasette's internal database ============================= -Datasette maintains an "internal" SQLite database used for configuration, caching, and storage. Plugins can store configuration, settings, and other data inside this database. By default, Datasette will use a temporary in-memory SQLite database as the internal database, which is created at startup and destroyed at shutdown. Users of Datasette can optionally pass in a `--internal` flag to specify the path to a SQLite database to use as the internal database, which will persist internal data across Datasette instances. +Datasette maintains an "internal" SQLite database used for configuration, caching, and storage. Plugins can store configuration, settings, and other data inside this database. By default, Datasette will use a temporary in-memory SQLite database as the internal database, which is created at startup and destroyed at shutdown. Users of Datasette can optionally pass in a ``--internal`` flag to specify the path to a SQLite database to use as the internal database, which will persist internal data across Datasette instances. Datasette maintains tables called ``catalog_databases``, ``catalog_tables``, ``catalog_columns``, ``catalog_indexes``, ``catalog_foreign_keys`` with details of the attached databases and their schemas. These tables should not be considered a stable API - they may change between Datasette releases. From 4c3ef033110407f3b3dbce501659d523724985e0 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 30 Aug 2023 16:19:59 -0700 Subject: [PATCH 017/474] Another ReST fix --- docs/internals.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/internals.rst b/docs/internals.rst index d136ad5a..540e7058 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -1157,7 +1157,7 @@ Plugins can access this database by calling ``internal_db = datasette.get_intern Plugin authors are asked to practice good etiquette when using the internal database, as all plugins use the same database to store data. For example: -1. Use a unique prefix when creating tables, indices, and triggera in the internal database. If your plugin is called `datasette-xyz`, then prefix names with `datasette_xyz_*`. +1. Use a unique prefix when creating tables, indices, and triggera in the internal database. If your plugin is called ``datasette-xyz``, then prefix names with ``datasette_xyz_*``. 2. Avoid long-running write statements that may stall or block other plugins that are trying to write at the same time. 3. Use temporary tables or shared in-memory attached databases when possible. 4. Avoid implementing features that could expose private data stored in the internal database by other plugins. From 9cead33fb9c8704996181f1ab67c7376dee97f15 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 31 Aug 2023 10:46:07 -0700 Subject: [PATCH 018/474] OperationalError: database table is locked fix See also: - https://til.simonwillison.net/datasette/remember-to-commit --- docs/internals.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/internals.rst b/docs/internals.rst index 540e7058..6b7d3df8 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -1012,6 +1012,7 @@ For example: def delete_and_return_count(conn): conn.execute("delete from some_table where id > 5") + conn.commit() return conn.execute( "select count(*) from some_table" ).fetchone()[0] @@ -1028,6 +1029,8 @@ The value returned from ``await database.execute_write_fn(...)`` will be the ret If your function raises an exception that exception will be propagated up to the ``await`` line. +If you see ``OperationalError: database table is locked`` errors you should check that you remembered to explicitly call ``conn.commit()`` in your write function. + If you specify ``block=False`` the method becomes fire-and-forget, queueing your function to be executed and then allowing your code after the call to ``.execute_write_fn()`` to continue running while the underlying thread waits for an opportunity to run your function. A UUID representing the queued task will be returned. Any exceptions in your code will be silently swallowed. .. _database_close: From 98ffad9aed15a300e61fb712fa12f177844739b3 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 31 Aug 2023 15:46:18 -0700 Subject: [PATCH 019/474] execute-sql now implies can view instance/database, closes #2169 --- datasette/default_permissions.py | 1 + tests/test_permissions.py | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/datasette/default_permissions.py b/datasette/default_permissions.py index 960429fc..5a99d0d8 100644 --- a/datasette/default_permissions.py +++ b/datasette/default_permissions.py @@ -59,6 +59,7 @@ def register_permissions(): takes_database=True, takes_resource=False, default=True, + implies_can_view=True, ), Permission( name="permissions-debug", diff --git a/tests/test_permissions.py b/tests/test_permissions.py index cad0525f..b3987cff 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -1183,6 +1183,10 @@ async def test_actor_restrictions( ({"a": ["update-row"]}, "view-instance", None, False), # view-table on a resource implies view-instance ({"r": {"db1": {"t1": ["view-table"]}}}, "view-instance", None, True), + # execute-sql on a database implies view-instance, view-database + ({"d": {"db1": ["es"]}}, "view-instance", None, True), + ({"d": {"db1": ["es"]}}, "view-database", "db1", True), + ({"d": {"db1": ["es"]}}, "view-database", "db2", False), # update-row on a resource does not imply view-instance ({"r": {"db1": {"t1": ["update-row"]}}}, "view-instance", None, False), # view-database on a resource implies view-instance From fd083e37ec53e7e625111168d324a572344a3b19 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 31 Aug 2023 16:06:30 -0700 Subject: [PATCH 020/474] Docs for plugins that define more plugin hooks, closes #1765 --- docs/writing_plugins.rst | 57 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/docs/writing_plugins.rst b/docs/writing_plugins.rst index a84789b5..a4c96011 100644 --- a/docs/writing_plugins.rst +++ b/docs/writing_plugins.rst @@ -325,3 +325,60 @@ This object is exposed in templates as the ``urls`` variable, which can be used Back to the Homepage See :ref:`internals_datasette_urls` for full details on this object. + +.. _writing_plugins_extra_hooks: + +Plugins that define new plugin hooks +------------------------------------ + +Plugins can define new plugin hooks that other plugins can use to further extend their functionality. + +`datasette-graphql `__ is one example of a plugin that does this. It defines a new hook called ``graphql_extra_fields``, `described here `__, which other plugins can use to define additional fields that should be included in the GraphQL schema. + +To define additional hooks, add a file to the plugin called ``datasette_your_plugin/hookspecs.py`` with content that looks like this: + +.. code-block:: python + + from pluggy import HookspecMarker + + hookspec = HookspecMarker("datasette") + + @hookspec + def name_of_your_hook_goes_here(datasette): + "Description of your hook." + +You should define your own hook name and arguments here, following the documentation for `Pluggy specifications `__. Make sure to pick a name that is unlikely to clash with hooks provided by any other plugins. + +Then, to register your plugin hooks, add the following code to your ``datasette_your_plugin/__init__.py`` file: + +.. code-block:: python + + from datasette.plugins import pm + from . import hookspecs + + pm.add_hookspecs(hookspecs) + +This will register your plugin hooks as part of the ``datasette`` plugin hook namespace. + +Within your plugin code you can trigger the hook using this pattern: + +.. code-block:: python + + from datasette.plugins import pm + + for plugin_return_value in pm.hook.name_of_your_hook_goes_here( + datasette=datasette + ): + # Do something with plugin_return_value + +Other plugins will then be able to register their own implementations of your hook using this syntax: + +.. code-block:: python + + from datasette import hookimpl + + @hookimpl + def name_of_your_hook_goes_here(datasette): + return "Response from this plugin hook" + +These plugin implementations can accept 0 or more of the named arguments that you defined in your hook specification. From 31d5c4ec05e27165283f0f0004c32227d8b78df8 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 5 Sep 2023 19:43:01 -0700 Subject: [PATCH 021/474] Contraction - Google and Microsoft styleguides like it I was trying out https://github.com/errata-ai/vale --- docs/authentication.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/authentication.rst b/docs/authentication.rst index 814d2e67..1a444d0c 100644 --- a/docs/authentication.rst +++ b/docs/authentication.rst @@ -4,7 +4,7 @@ Authentication and permissions ================================ -Datasette does not require authentication by default. Any visitor to a Datasette instance can explore the full data and execute read-only SQL queries. +Datasette doesn't require authentication by default. Any visitor to a Datasette instance can explore the full data and execute read-only SQL queries. Datasette's plugin system can be used to add many different styles of authentication, such as user accounts, single sign-on or API keys. From 05707aa16b5c6c39fbe48b3176b85a8ffe493938 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 5 Sep 2023 19:50:09 -0700 Subject: [PATCH 022/474] click-default-group>=1.2.3 (#2173) * click-default-group>=1.2.3 Now available as a wheel: - https://github.com/click-contrib/click-default-group/issues/21 * Fix for blacken-docs --- docs/writing_plugins.rst | 7 ++++++- setup.py | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/writing_plugins.rst b/docs/writing_plugins.rst index a4c96011..d0dd8f36 100644 --- a/docs/writing_plugins.rst +++ b/docs/writing_plugins.rst @@ -343,6 +343,7 @@ To define additional hooks, add a file to the plugin called ``datasette_your_plu hookspec = HookspecMarker("datasette") + @hookspec def name_of_your_hook_goes_here(datasette): "Description of your hook." @@ -366,10 +367,13 @@ Within your plugin code you can trigger the hook using this pattern: from datasette.plugins import pm - for plugin_return_value in pm.hook.name_of_your_hook_goes_here( + for ( + plugin_return_value + ) in pm.hook.name_of_your_hook_goes_here( datasette=datasette ): # Do something with plugin_return_value + pass Other plugins will then be able to register their own implementations of your hook using this syntax: @@ -377,6 +381,7 @@ Other plugins will then be able to register their own implementations of your ho from datasette import hookimpl + @hookimpl def name_of_your_hook_goes_here(datasette): return "Response from this plugin hook" diff --git a/setup.py b/setup.py index 35c9b68b..7d8c1ebc 100644 --- a/setup.py +++ b/setup.py @@ -44,7 +44,7 @@ setup( install_requires=[ "asgiref>=3.2.10", "click>=7.1.1", - "click-default-group-wheel>=1.2.2", + "click-default-group>=1.2.3", "Jinja2>=2.10.3", "hupper>=1.9", "httpx>=0.20", From e86eaaa4f371512689e973c18879298dab51f80a Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 6 Sep 2023 09:16:27 -0700 Subject: [PATCH 023/474] Test against Python 3.12 preview (#2175) https://dev.to/hugovk/help-test-python-312-beta-1508/ --- .github/workflows/test.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2784db86..656b0b1c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,13 +10,14 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - uses: actions/cache@v3 name: Configure pip caching with: From e4abae3fd7a828625d00c35c316852ffbaa5ef2f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 6 Sep 2023 09:34:31 -0700 Subject: [PATCH 024/474] Bump Sphinx (#2166) Bumps the python-packages group with 1 update: [sphinx](https://github.com/sphinx-doc/sphinx). - [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/v7.2.4...v7.2.5) --- updated-dependencies: - dependency-name: sphinx dependency-type: direct:development update-type: version-update:semver-patch dependency-group: python-packages ... 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 7d8c1ebc..c718086b 100644 --- a/setup.py +++ b/setup.py @@ -69,7 +69,7 @@ setup( setup_requires=["pytest-runner"], extras_require={ "docs": [ - "Sphinx==7.2.4", + "Sphinx==7.2.5", "furo==2023.8.19", "sphinx-autobuild", "codespell>=2.2.5", From fbcb103c0cb6668018ace539a01a6a1f156e8d6a Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 7 Sep 2023 07:47:24 -0700 Subject: [PATCH 025/474] Added example code to database_actions hook documentation --- docs/plugin_hooks.rst | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index f8bb203d..84a045b0 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -1439,7 +1439,32 @@ database_actions(datasette, actor, database, request) This hook is similar to :ref:`plugin_hook_table_actions` but populates an actions menu on the database page. -Example: `datasette-graphql `_ +This example adds a new database action for creating a table, if the user has the ``edit-schema`` permission: + +.. code-block:: python + + from datasette import hookimpl + + + @hookimpl + def database_actions(datasette, actor, database): + async def inner(): + if not await datasette.permission_allowed( + actor, "edit-schema", resource=database, default=False + ): + return [] + return [ + { + "href": datasette.urls.path( + "/-/edit-schema/{}/-/create".format(database) + ), + "label": "Create a table", + } + ] + + return inner + +Example: `datasette-graphql `_, `datasette-edit-schema `_ .. _plugin_hook_skip_csrf: From 2200abfa17f72b1cb741a36b44dc40a04b8ea001 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 7 Sep 2023 15:49:50 -0700 Subject: [PATCH 026/474] Fix for flaky test_hidden_sqlite_stat1_table, closes #2179 --- tests/test_api.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_api.py b/tests/test_api.py index f96f571e..93ca43eb 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1017,7 +1017,10 @@ async def test_hidden_sqlite_stat1_table(): await db.execute_write("analyze") data = (await ds.client.get("/db.json?_show_hidden=1")).json() tables = [(t["name"], t["hidden"]) for t in data["tables"]] - assert tables == [("normal", False), ("sqlite_stat1", True)] + assert tables in ( + [("normal", False), ("sqlite_stat1", True)], + [("normal", False), ("sqlite_stat1", True), ("sqlite_stat4", True)], + ) @pytest.mark.asyncio From dbfad6d2201bc65a0c73e699a10c479c1e199e11 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 7 Sep 2023 15:51:09 -0700 Subject: [PATCH 027/474] Foreign key label expanding respects table permissions, closes #2178 --- datasette/app.py | 9 ++++++- datasette/facets.py | 2 +- datasette/views/table.py | 2 +- tests/test_table_html.py | 53 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 63 insertions(+), 3 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 0227f627..618c0ecc 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -935,7 +935,7 @@ class Datasette: log_sql_errors=log_sql_errors, ) - async def expand_foreign_keys(self, database, table, column, values): + async def expand_foreign_keys(self, actor, database, table, column, values): """Returns dict mapping (column, value) -> label""" labeled_fks = {} db = self.databases[database] @@ -949,6 +949,13 @@ class Datasette: ][0] except IndexError: return {} + # Ensure user has permission to view the referenced table + if not await self.permission_allowed( + actor=actor, + action="view-table", + resource=(database, fk["other_table"]), + ): + return {} label_column = await db.label_column_for_table(fk["other_table"]) if not label_column: return {(fk["column"], value): str(value) for value in values} diff --git a/datasette/facets.py b/datasette/facets.py index 7fb0c68b..b23615fe 100644 --- a/datasette/facets.py +++ b/datasette/facets.py @@ -253,7 +253,7 @@ class ColumnFacet(Facet): # Attempt to expand foreign keys into labels values = [row["value"] for row in facet_rows] expanded = await self.ds.expand_foreign_keys( - self.database, self.table, column, values + self.request.actor, self.database, self.table, column, values ) else: expanded = {} diff --git a/datasette/views/table.py b/datasette/views/table.py index 6df8b915..50ba2b78 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -1144,7 +1144,7 @@ async def table_view_data( # Expand them expanded_labels.update( await datasette.expand_foreign_keys( - database_name, table_name, column, values + request.actor, database_name, table_name, column, values ) ) if expanded_labels: diff --git a/tests/test_table_html.py b/tests/test_table_html.py index c4c7878c..e66eb6f0 100644 --- a/tests/test_table_html.py +++ b/tests/test_table_html.py @@ -1204,3 +1204,56 @@ async def test_format_of_binary_links(size, title, length_bytes): sql_response = await ds.client.get("/{}".format(db_name), params={"sql": sql}) assert sql_response.status_code == 200 assert expected in sql_response.text + + +@pytest.mark.asyncio +async def test_foreign_key_labels_obey_permissions(): + ds = Datasette( + metadata={ + "databases": { + "foreign_key_labels": { + "tables": { + # Table a is only visible to root + "a": {"allow": {"id": "root"}}, + } + } + } + } + ) + db = ds.add_memory_database("foreign_key_labels") + await db.execute_write("create table a(id integer primary key, name text)") + await db.execute_write("insert into a (id, name) values (1, 'hello')") + await db.execute_write( + "create table b(id integer primary key, name text, a_id integer references a(id))" + ) + await db.execute_write("insert into b (id, name, a_id) values (1, 'world', 1)") + # Anonymous user can see table b but not table a + blah = await ds.client.get("/foreign_key_labels.json") + anon_a = await ds.client.get("/foreign_key_labels/a.json?_labels=on") + assert anon_a.status_code == 403 + anon_b = await ds.client.get("/foreign_key_labels/b.json?_labels=on") + assert anon_b.status_code == 200 + # root user can see both + cookies = {"ds_actor": ds.sign({"a": {"id": "root"}}, "actor")} + root_a = await ds.client.get( + "/foreign_key_labels/a.json?_labels=on", cookies=cookies + ) + assert root_a.status_code == 200 + root_b = await ds.client.get( + "/foreign_key_labels/b.json?_labels=on", cookies=cookies + ) + assert root_b.status_code == 200 + # Labels should have been expanded for root + assert root_b.json() == { + "ok": True, + "next": None, + "rows": [{"id": 1, "name": "world", "a_id": {"value": 1, "label": "hello"}}], + "truncated": False, + } + # But not for anon + assert anon_b.json() == { + "ok": True, + "next": None, + "rows": [{"id": 1, "name": "world", "a_id": 1}], + "truncated": False, + } From ab040470e2b191c0de48b213193da71e48cd66ed Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 7 Sep 2023 15:57:27 -0700 Subject: [PATCH 028/474] Applied blacken-docs --- docs/plugin_hooks.rst | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 84a045b0..04fb24ce 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -1450,13 +1450,18 @@ This example adds a new database action for creating a table, if the user has th def database_actions(datasette, actor, database): async def inner(): if not await datasette.permission_allowed( - actor, "edit-schema", resource=database, default=False + actor, + "edit-schema", + resource=database, + default=False, ): return [] return [ { "href": datasette.urls.path( - "/-/edit-schema/{}/-/create".format(database) + "/-/edit-schema/{}/-/create".format( + database + ) ), "label": "Create a table", } From c26370485a4fd4bf130da051be9163d92c57f24f Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 7 Sep 2023 16:28:30 -0700 Subject: [PATCH 029/474] Label expand permission check respects cascade, closes #2178 --- datasette/app.py | 22 ++++++++++------- tests/test_table_html.py | 52 +++++++++++++++++++++++++++++++++------- 2 files changed, 57 insertions(+), 17 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 618c0ecc..ea9739f0 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -950,13 +950,19 @@ class Datasette: except IndexError: return {} # Ensure user has permission to view the referenced table - if not await self.permission_allowed( - actor=actor, - action="view-table", - resource=(database, fk["other_table"]), - ): + other_table = fk["other_table"] + other_column = fk["other_column"] + visible, _ = await self.check_visibility( + actor, + permissions=[ + ("view-table", (database, other_table)), + ("view-database", database), + "view-instance", + ], + ) + if not visible: return {} - label_column = await db.label_column_for_table(fk["other_table"]) + label_column = await db.label_column_for_table(other_table) if not label_column: return {(fk["column"], value): str(value) for value in values} labeled_fks = {} @@ -965,9 +971,9 @@ class Datasette: from {other_table} where {other_column} in ({placeholders}) """.format( - other_column=escape_sqlite(fk["other_column"]), + other_column=escape_sqlite(other_column), label_column=escape_sqlite(label_column), - other_table=escape_sqlite(fk["other_table"]), + other_table=escape_sqlite(other_table), placeholders=", ".join(["?"] * len(set(values))), ) try: diff --git a/tests/test_table_html.py b/tests/test_table_html.py index e66eb6f0..6707665d 100644 --- a/tests/test_table_html.py +++ b/tests/test_table_html.py @@ -1207,9 +1207,11 @@ async def test_format_of_binary_links(size, title, length_bytes): @pytest.mark.asyncio -async def test_foreign_key_labels_obey_permissions(): - ds = Datasette( - metadata={ +@pytest.mark.parametrize( + "metadata", + ( + # Blocked at table level + { "databases": { "foreign_key_labels": { "tables": { @@ -1218,15 +1220,47 @@ async def test_foreign_key_labels_obey_permissions(): } } } - } - ) + }, + # Blocked at database level + { + "databases": { + "foreign_key_labels": { + # Only root can view this database + "allow": {"id": "root"}, + "tables": { + # But table b is visible to everyone + "b": {"allow": True}, + }, + } + } + }, + # Blocked at the instance level + { + "allow": {"id": "root"}, + "databases": { + "foreign_key_labels": { + "tables": { + # Table b is visible to everyone + "b": {"allow": True}, + } + } + }, + }, + ), +) +async def test_foreign_key_labels_obey_permissions(metadata): + ds = Datasette(metadata=metadata) db = ds.add_memory_database("foreign_key_labels") - await db.execute_write("create table a(id integer primary key, name text)") - await db.execute_write("insert into a (id, name) values (1, 'hello')") await db.execute_write( - "create table b(id integer primary key, name text, a_id integer references a(id))" + "create table if not exists a(id integer primary key, name text)" + ) + await db.execute_write("insert or replace into a (id, name) values (1, 'hello')") + await db.execute_write( + "create table if not exists b(id integer primary key, name text, a_id integer references a(id))" + ) + await db.execute_write( + "insert or replace into b (id, name, a_id) values (1, 'world', 1)" ) - await db.execute_write("insert into b (id, name, a_id) values (1, 'world', 1)") # Anonymous user can see table b but not table a blah = await ds.client.get("/foreign_key_labels.json") anon_a = await ds.client.get("/foreign_key_labels/a.json?_labels=on") From b645174271aa08e8ca83b27ff83ce078ecd15da2 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 7 Sep 2023 21:23:59 -0700 Subject: [PATCH 030/474] actors_from_ids plugin hook and datasette.actors_from_ids() method (#2181) * Prototype of actors_from_ids plugin hook, refs #2180 * datasette-remote-actors example plugin, refs #2180 --- datasette/app.py | 10 +++++++ datasette/hookspecs.py | 5 ++++ docs/internals.rst | 21 ++++++++++++++ docs/plugin_hooks.rst | 57 ++++++++++++++++++++++++++++++++++++++ tests/test_plugins.py | 62 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 155 insertions(+) diff --git a/datasette/app.py b/datasette/app.py index ea9739f0..fdec2c86 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -819,6 +819,16 @@ class Datasette: ) return crumbs + async def actors_from_ids( + self, actor_ids: Iterable[Union[str, int]] + ) -> Dict[Union[id, str], Dict]: + result = pm.hook.actors_from_ids(datasette=self, actor_ids=actor_ids) + if result is None: + # Do the default thing + return {actor_id: {"id": actor_id} for actor_id in actor_ids} + result = await await_me_maybe(result) + return result + async def permission_allowed( self, actor, action, resource=None, default=DEFAULT_NOT_SET ): diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index 801073fc..9069927b 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -94,6 +94,11 @@ def actor_from_request(datasette, request): """Return an actor dictionary based on the incoming request""" +@hookspec(firstresult=True) +def actors_from_ids(datasette, actor_ids): + """Returns a dictionary mapping those IDs to actor dictionaries""" + + @hookspec def filters_from_request(request, database, table, datasette): """ diff --git a/docs/internals.rst b/docs/internals.rst index 6b7d3df8..13f1d4a1 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -322,6 +322,27 @@ await .render_template(template, context=None, request=None) Renders a `Jinja template `__ using Datasette's preconfigured instance of Jinja and returns the resulting string. The template will have access to Datasette's default template functions and any functions that have been made available by other plugins. +.. _datasette_actors_from_ids: + +await .actors_from_ids(actor_ids) +--------------------------------- + +``actor_ids`` - list of strings or integers + A list of actor IDs to look up. + +Returns a dictionary, where the keys are the IDs passed to it and the values are the corresponding actor dictionaries. + +This method is mainly designed to be used with plugins. See the :ref:`plugin_hook_actors_from_ids` documentation for details. + +If no plugins that implement that hook are installed, the default return value looks like this: + +.. code-block:: json + + { + "1": {"id": "1"}, + "2": {"id": "2"} + } + .. _datasette_permission_allowed: await .permission_allowed(actor, action, resource=None, default=...) diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 04fb24ce..e966919b 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -1071,6 +1071,63 @@ Instead of returning a dictionary, this function can return an awaitable functio Examples: `datasette-auth-tokens `_, `datasette-auth-passwords `_ +.. _plugin_hook_actors_from_ids: + +actors_from_ids(datasette, actor_ids) +------------------------------------- + +``datasette`` - :ref:`internals_datasette` + You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries. + +``actor_ids`` - list of strings or integers + The actor IDs to look up. + +The hook must return a dictionary that maps the incoming actor IDs to their full dictionary representation. + +Some plugins that implement social features may store the ID of the :ref:`actor ` that performed an action - added a comment, bookmarked a table or similar - and then need a way to resolve those IDs into display-friendly actor dictionaries later on. + +Unlike other plugin hooks, this only uses the first implementation of the hook to return a result. You can expect users to only have a single plugin installed that implements this hook. + +If no plugin is installed, Datasette defaults to returning actors that are just ``{"id": actor_id}``. + +The hook can return a dictionary or an awaitable function that then returns a dictionary. + +This example implementation returns actors from a database table: + +.. code-block:: python + + from datasette import hookimpl + + + @hookimpl + def actors_from_ids(datasette, actor_ids): + db = datasette.get_database("actors") + + async def inner(): + sql = "select id, name from actors where id in ({})".format( + ", ".join("?" for _ in actor_ids) + ) + actors = {} + for row in (await db.execute(sql, actor_ids)).rows: + actor = dict(row) + actors[actor["id"]] = actor + return actors + + return inner + +The returned dictionary from this example looks like this: + +.. code-block:: json + + { + "1": {"id": "1", "name": "Tony"}, + "2": {"id": "2", "name": "Tina"}, + } + +These IDs could be integers or strings, depending on how the actors used by the Datasette instance are configured. + +Example: `datasette-remote-actors `_ + .. _plugin_hook_filters_from_request: filters_from_request(request, database, table, datasette) diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 9761fa53..625ae635 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -1215,3 +1215,65 @@ async def test_hook_register_permissions_allows_identical_duplicates(): await ds.invoke_startup() # Check that ds.permissions has only one of each assert len([p for p in ds.permissions.values() if p.abbr == "abbr1"]) == 1 + + +@pytest.mark.asyncio +async def test_hook_actors_from_ids(): + # Without the hook should return default {"id": id} list + ds = Datasette() + await ds.invoke_startup() + db = ds.add_memory_database("actors_from_ids") + await db.execute_write( + "create table actors (id text primary key, name text, age int)" + ) + await db.execute_write( + "insert into actors (id, name, age) values ('3', 'Cate Blanchett', 52)" + ) + await db.execute_write( + "insert into actors (id, name, age) values ('5', 'Rooney Mara', 36)" + ) + await db.execute_write( + "insert into actors (id, name, age) values ('7', 'Sarah Paulson', 46)" + ) + await db.execute_write( + "insert into actors (id, name, age) values ('9', 'Helena Bonham Carter', 55)" + ) + table_names = await db.table_names() + assert table_names == ["actors"] + actors1 = await ds.actors_from_ids(["3", "5", "7"]) + assert actors1 == { + "3": {"id": "3"}, + "5": {"id": "5"}, + "7": {"id": "7"}, + } + + class ActorsFromIdsPlugin: + __name__ = "ActorsFromIdsPlugin" + + @hookimpl + def actors_from_ids(self, datasette, actor_ids): + db = datasette.get_database("actors_from_ids") + + async def inner(): + sql = "select id, name from actors where id in ({})".format( + ", ".join("?" for _ in actor_ids) + ) + actors = {} + result = await db.execute(sql, actor_ids) + for row in result.rows: + actor = dict(row) + actors[actor["id"]] = actor + return actors + + return inner + + try: + pm.register(ActorsFromIdsPlugin(), name="ActorsFromIdsPlugin") + actors2 = await ds.actors_from_ids(["3", "5", "7"]) + assert actors2 == { + "3": {"id": "3", "name": "Cate Blanchett"}, + "5": {"id": "5", "name": "Rooney Mara"}, + "7": {"id": "7", "name": "Sarah Paulson"}, + } + finally: + pm.unregister(name="ReturnNothingPlugin") From a4c96d01b27ce7cd06662a024da3547132a7c412 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 7 Sep 2023 21:44:08 -0700 Subject: [PATCH 031/474] Release 1.0a6 Refs #1765, #2164, #2169, #2175, #2178, #2181 --- datasette/version.py | 2 +- docs/changelog.rst | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/datasette/version.py b/datasette/version.py index b99c212b..4b65999d 100644 --- a/datasette/version.py +++ b/datasette/version.py @@ -1,2 +1,2 @@ -__version__ = "1.0a5" +__version__ = "1.0a6" __version_info__ = tuple(__version__.split(".")) diff --git a/docs/changelog.rst b/docs/changelog.rst index 019d6c68..81554f83 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,18 @@ Changelog ========= +.. _v1_0_a6: + +1.0a6 (2023-09-07) +------------------ + +- New plugin hook: :ref:`plugin_hook_actors_from_ids` and an internal method to accompany it, :ref:`datasette_actors_from_ids`. This mechanism is intended to be used by plugins that may need to display the actor who was responsible for something managed by that plugin: they can now resolve the recorded IDs of actors into the full actor objects. (:issue:`2181`) +- ``DATASETTE_LOAD_PLUGINS`` environment variable for :ref:`controlling which plugins ` are loaded by Datasette. (:issue:`2164`) +- Datasette now checks if the user has permission to view a table linked to by a foreign key before turning that foreign key into a clickable link. (:issue:`2178`) +- The ``execute-sql`` permission now implies that the actor can also view the database and instance. (:issue:`2169`) +- Documentation describing a pattern for building plugins that themselves :ref:`define further hooks ` for other plugins. (:issue:`1765`) +- Datasette is now tested against the Python 3.12 preview. (`#2175 `__) + .. _v1_0_a5: 1.0a5 (2023-08-29) From b2ec8717c3619260a1b535eea20e618bf95aa30b Mon Sep 17 00:00:00 2001 From: Alex Garcia Date: Wed, 13 Sep 2023 14:06:25 -0700 Subject: [PATCH 032/474] Plugin configuration now lives in datasette.yaml/json * Checkpoint, moving top-level plugin config to datasette.json * Support database-level and table-level plugin configuration in datasette.yaml Refs #2093 --- datasette/app.py | 48 +++++++++++++++----- docs/configuration.rst | 97 ++++++++++++++++++++++++++++++++++++++-- docs/index.rst | 1 + docs/internals.rst | 2 +- docs/plugin_hooks.rst | 2 +- docs/writing_plugins.rst | 7 +-- tests/conftest.py | 3 +- tests/fixtures.py | 50 ++++++++++++++------- tests/test_cli.py | 38 ++++++++++++++++ tests/test_plugins.py | 23 +++------- 10 files changed, 217 insertions(+), 54 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index fdec2c86..53486007 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -368,7 +368,7 @@ class Datasette: for key in config_settings: if key not in DEFAULT_SETTINGS: raise StartupError("Invalid setting '{}' in datasette.json".format(key)) - + self.config = config # CLI settings should overwrite datasette.json settings self._settings = dict(DEFAULT_SETTINGS, **(config_settings), **(settings or {})) self.renderers = {} # File extension -> (renderer, can_render) functions @@ -674,15 +674,43 @@ class Datasette: def plugin_config(self, plugin_name, database=None, table=None, fallback=True): """Return config for plugin, falling back from specified database/table""" - plugins = self.metadata( - "plugins", database=database, table=table, fallback=fallback - ) - if plugins is None: - return None - plugin_config = plugins.get(plugin_name) - # Resolve any $file and $env keys - plugin_config = resolve_env_secrets(plugin_config, os.environ) - return plugin_config + if database is None and table is None: + config = self._plugin_config_top(plugin_name) + else: + config = self._plugin_config_nested(plugin_name, database, table, fallback) + + return resolve_env_secrets(config, os.environ) + + def _plugin_config_top(self, plugin_name): + """Returns any top-level plugin configuration for the specified plugin.""" + return ((self.config or {}).get("plugins") or {}).get(plugin_name) + + def _plugin_config_nested(self, plugin_name, database, table=None, fallback=True): + """Returns any database or table-level plugin configuration for the specified plugin.""" + db_config = ((self.config or {}).get("databases") or {}).get(database) + + # if there's no db-level configuration, then return early, falling back to top-level if needed + if not db_config: + return self._plugin_config_top(plugin_name) if fallback else None + + db_plugin_config = (db_config.get("plugins") or {}).get(plugin_name) + + if table: + table_plugin_config = ( + ((db_config.get("tables") or {}).get(table) or {}).get("plugins") or {} + ).get(plugin_name) + + # fallback to db_config or top-level config, in that order, if needed + if table_plugin_config is None and fallback: + return db_plugin_config or self._plugin_config_top(plugin_name) + + return table_plugin_config + + # fallback to top-level if needed + if db_plugin_config is None and fallback: + self._plugin_config_top(plugin_name) + + return db_plugin_config def app_css_hash(self): if not hasattr(self, "_app_css_hash"): diff --git a/docs/configuration.rst b/docs/configuration.rst index ed9975ac..214e9044 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -1,10 +1,101 @@ .. _configuration: Configuration -======== +============= -Datasette offers many way to configure your Datasette instances: server settings, plugin configuration, authentication, and more. +Datasette offers several ways to configure your Datasette instances: server settings, plugin configuration, authentication, and more. -To facilitate this, You can provide a `datasette.yaml` configuration file to datasette with the ``--config``/ ``-c`` flag: +To facilitate this, You can provide a ``datasette.yaml`` configuration file to datasette with the ``--config``/ ``-c`` flag: + +.. code-block:: bash datasette mydatabase.db --config datasette.yaml + +.. _configuration_reference: + +``datasette.yaml`` reference +---------------------------- + +Here's a full example of all the valid configuration options that can exist inside ``datasette.yaml``. + +.. tab:: YAML + + .. code-block:: yaml + + # Datasette settings block + settings: + default_page_size: 50 + sql_time_limit_ms: 3500 + max_returned_rows: 2000 + + # top-level plugin configuration + plugins: + datasette-my-plugin: + key: valueA + + # Database and table-level configuration + databases: + your_db_name: + # plugin configuration for the your_db_name database + plugins: + datasette-my-plugin: + key: valueA + tables: + your_table_name: + # plugin configuration for the your_table_name table + # inside your_db_name database + plugins: + datasette-my-plugin: + key: valueB + +.. _configuration_reference_settings: +Settings configuration +~~~~~~~~~~~~~~~~~~~~~~ + +:ref:`settings` can be configured in ``datasette.yaml`` with the ``settings`` key. + +.. tab:: YAML + + .. code-block:: yaml + + # inside datasette.yaml + settings: + default_allow_sql: off + default_page_size: 50 + + +.. _configuration_reference_plugins: +Plugin configuration +~~~~~~~~~~~~~~~~~~~~ + +Configuration for plugins can be defined inside ``datasette.yaml``. For top-level plugin configuration, use the ``plugins`` key. + +.. tab:: YAML + + .. code-block:: yaml + + # inside datasette.yaml + plugins: + datasette-my-plugin: + key: my_value + +For database level or table level plugin configuration, nest it under the appropriate place under ``databases``. + +.. tab:: YAML + + .. code-block:: yaml + + # inside datasette.yaml + databases: + my_database: + # plugin configuration for the my_database database + plugins: + datasette-my-plugin: + key: my_value + my_other_database: + tables: + my_table: + # plugin configuration for the my_table table inside the my_other_database database + plugins: + datasette-my-plugin: + key: my_value diff --git a/docs/index.rst b/docs/index.rst index f5c1f232..cfa3443c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -39,6 +39,7 @@ Contents getting_started installation + configuration ecosystem cli-reference pages diff --git a/docs/internals.rst b/docs/internals.rst index 13f1d4a1..7fc7948c 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -296,7 +296,7 @@ The dictionary keys are the permission names - e.g. ``view-instance`` - and the ``table`` - None or string The table the user is interacting with. -This method lets you read plugin configuration values that were set in ``metadata.json``. See :ref:`writing_plugins_configuration` for full details of how this method should be used. +This method lets you read plugin configuration values that were set in ``datasette.yaml``. See :ref:`writing_plugins_configuration` for full details of how this method should be used. The return value will be the value from the configuration file - usually a dictionary. diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index e966919b..1816d48c 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -909,7 +909,7 @@ Potential use-cases: * Run some initialization code for the plugin * Create database tables that a plugin needs on startup -* Validate the metadata configuration for a plugin on startup, and raise an error if it is invalid +* Validate the configuration for a plugin on startup, and raise an error if it is invalid .. note:: diff --git a/docs/writing_plugins.rst b/docs/writing_plugins.rst index d0dd8f36..c028b4ff 100644 --- a/docs/writing_plugins.rst +++ b/docs/writing_plugins.rst @@ -184,7 +184,7 @@ 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 inside ``datasette.yaml`` like so: .. [[[cog from metadata_doc import metadata_example @@ -234,11 +234,10 @@ If it cannot find the requested configuration at the table layer, it will fall b 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.yaml``: +The plugin configuration could also be set at the top level of ``datasette.yaml``: .. [[[cog metadata_example(cog, { - "title": "This is the top-level title in metadata.json", "plugins": { "datasette-cluster-map": { "latitude_column": "xlat", @@ -252,7 +251,6 @@ The plugin configuration could also be set at the top level of ``metadata.yaml`` .. code-block:: yaml - title: This is the top-level title in metadata.json plugins: datasette-cluster-map: latitude_column: xlat @@ -264,7 +262,6 @@ The plugin configuration could also be set at the top level of ``metadata.yaml`` .. code-block:: json { - "title": "This is the top-level title in metadata.json", "plugins": { "datasette-cluster-map": { "latitude_column": "xlat", diff --git a/tests/conftest.py b/tests/conftest.py index fb7f768e..31336aea 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -41,7 +41,7 @@ def wait_until_responds(url, timeout=5.0, client=httpx, **kwargs): @pytest_asyncio.fixture async def ds_client(): from datasette.app import Datasette - from .fixtures import METADATA, PLUGINS_DIR + from .fixtures import CONFIG, METADATA, PLUGINS_DIR global _ds_client if _ds_client is not None: @@ -49,6 +49,7 @@ async def ds_client(): ds = Datasette( metadata=METADATA, + config=CONFIG, plugins_dir=PLUGINS_DIR, settings={ "default_page_size": 50, diff --git a/tests/fixtures.py b/tests/fixtures.py index a6700239..9cf6b605 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -114,6 +114,7 @@ def make_app_client( inspect_data=None, static_mounts=None, template_dir=None, + config=None, metadata=None, crossdb=False, ): @@ -158,6 +159,7 @@ def make_app_client( memory=memory, cors=cors, metadata=metadata or METADATA, + config=config or CONFIG, plugins_dir=PLUGINS_DIR, settings=settings, inspect_data=inspect_data, @@ -296,6 +298,33 @@ def generate_sortable_rows(num): } +CONFIG = { + "plugins": { + "name-of-plugin": {"depth": "root"}, + "env-plugin": {"foo": {"$env": "FOO_ENV"}}, + "env-plugin-list": [{"in_a_list": {"$env": "FOO_ENV"}}], + "file-plugin": {"foo": {"$file": TEMP_PLUGIN_SECRET_FILE}}, + }, + "databases": { + "fixtures": { + "plugins": {"name-of-plugin": {"depth": "database"}}, + "tables": { + "simple_primary_key": { + "plugins": { + "name-of-plugin": { + "depth": "table", + "special": "this-is-simple_primary_key", + } + }, + }, + "sortable": { + "plugins": {"name-of-plugin": {"depth": "table"}}, + }, + }, + } + }, +} + METADATA = { "title": "Datasette Fixtures", "description_html": 'An example SQLite database demonstrating Datasette. Sign in as root user', @@ -306,26 +335,13 @@ METADATA = { "about": "About Datasette", "about_url": "https://github.com/simonw/datasette", "extra_css_urls": ["/static/extra-css-urls.css"], - "plugins": { - "name-of-plugin": {"depth": "root"}, - "env-plugin": {"foo": {"$env": "FOO_ENV"}}, - "env-plugin-list": [{"in_a_list": {"$env": "FOO_ENV"}}], - "file-plugin": {"foo": {"$file": TEMP_PLUGIN_SECRET_FILE}}, - }, "databases": { "fixtures": { "description": "Test tables description", - "plugins": {"name-of-plugin": {"depth": "database"}}, "tables": { "simple_primary_key": { "description_html": "Simple primary key", "title": "This HTML is escaped", - "plugins": { - "name-of-plugin": { - "depth": "table", - "special": "this-is-simple_primary_key", - } - }, }, "sortable": { "sortable_columns": [ @@ -334,7 +350,6 @@ METADATA = { "sortable_with_nulls_2", "text", ], - "plugins": {"name-of-plugin": {"depth": "table"}}, }, "no_primary_key": {"sortable_columns": [], "hidden": True}, "units": {"units": {"distance": "m", "frequency": "Hz"}}, @@ -768,6 +783,7 @@ def assert_permissions_checked(datasette, actions): type=click.Path(file_okay=True, dir_okay=False), ) @click.argument("metadata", required=False) +@click.argument("config", required=False) @click.argument( "plugins_path", type=click.Path(file_okay=False, dir_okay=True), required=False ) @@ -782,7 +798,7 @@ def assert_permissions_checked(datasette, actions): type=click.Path(file_okay=True, dir_okay=False), help="Write out second test DB to this file", ) -def cli(db_filename, metadata, plugins_path, recreate, extra_db_filename): +def cli(db_filename, config, metadata, plugins_path, recreate, extra_db_filename): """Write out the fixtures database used by Datasette's test suite""" if metadata and not metadata.endswith(".json"): raise click.ClickException("Metadata should end with .json") @@ -805,6 +821,10 @@ def cli(db_filename, metadata, plugins_path, recreate, extra_db_filename): with open(metadata, "w") as fp: fp.write(json.dumps(METADATA, indent=4)) print(f"- metadata written to {metadata}") + if config: + with open(config, "w") as fp: + fp.write(json.dumps(CONFIG, indent=4)) + print(f"- config written to {config}") if plugins_path: path = pathlib.Path(plugins_path) if not path.exists(): diff --git a/tests/test_cli.py b/tests/test_cli.py index e85bcef1..213db416 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -238,6 +238,44 @@ def test_setting(args): assert settings["default_page_size"] == 5 +def test_plugin_s_overwrite(): + runner = CliRunner() + plugins_dir = str(pathlib.Path(__file__).parent / "plugins") + + result = runner.invoke( + cli, + [ + "--plugins-dir", + plugins_dir, + "--get", + "/_memory.json?sql=select+prepare_connection_args()", + ], + ) + assert result.exit_code == 0, result.output + assert ( + json.loads(result.output).get("rows")[0].get("prepare_connection_args()") + == 'database=_memory, datasette.plugin_config("name-of-plugin")=None' + ) + + result = runner.invoke( + cli, + [ + "--plugins-dir", + plugins_dir, + "--get", + "/_memory.json?sql=select+prepare_connection_args()", + "-s", + "plugins.name-of-plugin", + "OVERRIDE", + ], + ) + assert result.exit_code == 0, result.output + assert ( + json.loads(result.output).get("rows")[0].get("prepare_connection_args()") + == 'database=_memory, datasette.plugin_config("name-of-plugin")=OVERRIDE' + ) + + def test_setting_type_validation(): runner = CliRunner(mix_stderr=False) result = runner.invoke(cli, ["--setting", "default_page_size", "dog"]) diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 625ae635..37530991 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -234,9 +234,6 @@ async def test_plugin_config(ds_client): async def test_plugin_config_env(ds_client): os.environ["FOO_ENV"] = "FROM_ENVIRONMENT" assert {"foo": "FROM_ENVIRONMENT"} == ds_client.ds.plugin_config("env-plugin") - # Ensure secrets aren't visible in /-/metadata.json - metadata = await ds_client.get("/-/metadata.json") - assert {"foo": {"$env": "FOO_ENV"}} == metadata.json()["plugins"]["env-plugin"] del os.environ["FOO_ENV"] @@ -246,11 +243,6 @@ async def test_plugin_config_env_from_list(ds_client): assert [{"in_a_list": "FROM_ENVIRONMENT"}] == ds_client.ds.plugin_config( "env-plugin-list" ) - # Ensure secrets aren't visible in /-/metadata.json - metadata = await ds_client.get("/-/metadata.json") - assert [{"in_a_list": {"$env": "FOO_ENV"}}] == metadata.json()["plugins"][ - "env-plugin-list" - ] del os.environ["FOO_ENV"] @@ -259,11 +251,6 @@ async def test_plugin_config_file(ds_client): with open(TEMP_PLUGIN_SECRET_FILE, "w") as fp: fp.write("FROM_FILE") assert {"foo": "FROM_FILE"} == ds_client.ds.plugin_config("file-plugin") - # Ensure secrets aren't visible in /-/metadata.json - metadata = await ds_client.get("/-/metadata.json") - assert {"foo": {"$file": TEMP_PLUGIN_SECRET_FILE}} == metadata.json()["plugins"][ - "file-plugin" - ] os.remove(TEMP_PLUGIN_SECRET_FILE) @@ -722,7 +709,7 @@ async def test_hook_register_routes(ds_client, path, body): @pytest.mark.parametrize("configured_path", ("path1", "path2")) def test_hook_register_routes_with_datasette(configured_path): with make_app_client( - metadata={ + config={ "plugins": { "register-route-demo": { "path": configured_path, @@ -741,7 +728,7 @@ def test_hook_register_routes_with_datasette(configured_path): def test_hook_register_routes_override(): "Plugins can over-ride default paths such as /db/table" with make_app_client( - metadata={ + config={ "plugins": { "register-route-demo": { "path": "blah", @@ -1099,7 +1086,7 @@ async def test_hook_filters_from_request(ds_client): @pytest.mark.parametrize("extra_metadata", (False, True)) async def test_hook_register_permissions(extra_metadata): ds = Datasette( - metadata={ + config={ "plugins": { "datasette-register-permissions": { "permissions": [ @@ -1151,7 +1138,7 @@ async def test_hook_register_permissions_no_duplicates(duplicate): if duplicate == "abbr": abbr2 = "abbr1" ds = Datasette( - metadata={ + config={ "plugins": { "datasette-register-permissions": { "permissions": [ @@ -1186,7 +1173,7 @@ async def test_hook_register_permissions_no_duplicates(duplicate): @pytest.mark.asyncio async def test_hook_register_permissions_allows_identical_duplicates(): ds = Datasette( - metadata={ + config={ "plugins": { "datasette-register-permissions": { "permissions": [ From 16f0b6d8222d06682a31b904d0a402c391ae1c1c Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 13 Sep 2023 14:15:32 -0700 Subject: [PATCH 033/474] JSON/YAML tabs on configuration docs page --- docs/configuration.rst | 171 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) diff --git a/docs/configuration.rst b/docs/configuration.rst index 214e9044..4a7258b9 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -18,6 +18,40 @@ To facilitate this, You can provide a ``datasette.yaml`` configuration file to d Here's a full example of all the valid configuration options that can exist inside ``datasette.yaml``. +.. [[[cog + from metadata_doc import metadata_example + import textwrap + metadata_example(cog, yaml=textwrap.dedent( + """ + # Datasette settings block + settings: + default_page_size: 50 + sql_time_limit_ms: 3500 + max_returned_rows: 2000 + + # top-level plugin configuration + plugins: + datasette-my-plugin: + key: valueA + + # Database and table-level configuration + databases: + your_db_name: + # plugin configuration for the your_db_name database + plugins: + datasette-my-plugin: + key: valueA + tables: + your_table_name: + # plugin configuration for the your_table_name table + # inside your_db_name database + plugins: + datasette-my-plugin: + key: valueB + """) + ) +.. ]]] + .. tab:: YAML .. code-block:: yaml @@ -48,12 +82,61 @@ Here's a full example of all the valid configuration options that can exist insi datasette-my-plugin: key: valueB +.. tab:: JSON + + .. code-block:: json + + { + "settings": { + "default_page_size": 50, + "sql_time_limit_ms": 3500, + "max_returned_rows": 2000 + }, + "plugins": { + "datasette-my-plugin": { + "key": "valueA" + } + }, + "databases": { + "your_db_name": { + "plugins": { + "datasette-my-plugin": { + "key": "valueA" + } + }, + "tables": { + "your_table_name": { + "plugins": { + "datasette-my-plugin": { + "key": "valueB" + } + } + } + } + } + } + } +.. [[[end]]] + .. _configuration_reference_settings: Settings configuration ~~~~~~~~~~~~~~~~~~~~~~ :ref:`settings` can be configured in ``datasette.yaml`` with the ``settings`` key. +.. [[[cog + from metadata_doc import metadata_example + import textwrap + metadata_example(cog, yaml=textwrap.dedent( + """ + # inside datasette.yaml + settings: + default_allow_sql: off + default_page_size: 50 + """).strip() + ) +.. ]]] + .. tab:: YAML .. code-block:: yaml @@ -63,6 +146,17 @@ Settings configuration default_allow_sql: off default_page_size: 50 +.. tab:: JSON + + .. code-block:: json + + { + "settings": { + "default_allow_sql": "off", + "default_page_size": 50 + } + } +.. [[[end]]] .. _configuration_reference_plugins: Plugin configuration @@ -70,6 +164,19 @@ Plugin configuration Configuration for plugins can be defined inside ``datasette.yaml``. For top-level plugin configuration, use the ``plugins`` key. +.. [[[cog + from metadata_doc import metadata_example + import textwrap + metadata_example(cog, yaml=textwrap.dedent( + """ + # inside datasette.yaml + plugins: + datasette-my-plugin: + key: my_value + """).strip() + ) +.. ]]] + .. tab:: YAML .. code-block:: yaml @@ -79,8 +186,44 @@ Configuration for plugins can be defined inside ``datasette.yaml``. For top-leve datasette-my-plugin: key: my_value +.. tab:: JSON + + .. code-block:: json + + { + "plugins": { + "datasette-my-plugin": { + "key": "my_value" + } + } + } +.. [[[end]]] + For database level or table level plugin configuration, nest it under the appropriate place under ``databases``. +.. [[[cog + from metadata_doc import metadata_example + import textwrap + metadata_example(cog, yaml=textwrap.dedent( + """ + # inside datasette.yaml + databases: + my_database: + # plugin configuration for the my_database database + plugins: + datasette-my-plugin: + key: my_value + my_other_database: + tables: + my_table: + # plugin configuration for the my_table table inside the my_other_database database + plugins: + datasette-my-plugin: + key: my_value + """).strip() + ) +.. ]]] + .. tab:: YAML .. code-block:: yaml @@ -99,3 +242,31 @@ For database level or table level plugin configuration, nest it under the approp plugins: datasette-my-plugin: key: my_value + +.. tab:: JSON + + .. code-block:: json + + { + "databases": { + "my_database": { + "plugins": { + "datasette-my-plugin": { + "key": "my_value" + } + } + }, + "my_other_database": { + "tables": { + "my_table": { + "plugins": { + "datasette-my-plugin": { + "key": "my_value" + } + } + } + } + } + } + } +.. [[[end]]] \ No newline at end of file From 852f5014853943fa27f43ddaa2d442545b3259fb Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 16 Sep 2023 09:35:18 -0700 Subject: [PATCH 034/474] Switch from pkg_resources to importlib.metadata in app.py, refs #2057 --- datasette/app.py | 6 +++--- tests/test_plugins.py | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 53486007..c0e80700 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -8,11 +8,11 @@ import functools import glob import hashlib import httpx +import importlib.metadata import inspect from itsdangerous import BadSignature import json import os -import pkg_resources import re import secrets import sys @@ -1118,9 +1118,9 @@ class Datasette: if using_pysqlite3: for package in ("pysqlite3", "pysqlite3-binary"): try: - info["pysqlite3"] = pkg_resources.get_distribution(package).version + info["pysqlite3"] = importlib.metadata.version(package) break - except pkg_resources.DistributionNotFound: + except importlib.metadata.PackageNotFoundError: pass return info diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 37530991..3bc117f3 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -1264,3 +1264,25 @@ async def test_hook_actors_from_ids(): } finally: pm.unregister(name="ReturnNothingPlugin") + + +@pytest.mark.asyncio +async def test_plugin_is_installed(): + datasette = Datasette(memory=True) + + class DummyPlugin: + __name__ = "DummyPlugin" + + @hookimpl + def actors_from_ids(self, datasette, actor_ids): + return {} + + try: + pm.register(DummyPlugin(), name="DummyPlugin") + response = await datasette.client.get("/-/plugins.json") + assert response.status_code == 200 + installed_plugins = {p["name"] for p in response.json()} + assert "DummyPlugin" in installed_plugins + + finally: + pm.unregister(name="DummyPlugin") From f56e043747bde4faa1d78588636df6c0dadebc65 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 18 Sep 2023 10:39:11 -0700 Subject: [PATCH 035/474] test_facet_against_in_memory_database, refs #2189 This is meant to illustrate a crashing bug but it does not trigger it. --- tests/test_facets.py | 47 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/test_facets.py b/tests/test_facets.py index 48cc0ff2..a68347f0 100644 --- a/tests/test_facets.py +++ b/tests/test_facets.py @@ -643,3 +643,50 @@ async def test_conflicting_facet_names_json(ds_client): "created_2", "tags_2", } + + +@pytest.mark.asyncio +async def test_facet_against_in_memory_database(): + ds = Datasette() + db = ds.add_memory_database("mem") + await db.execute_write("create table t (id integer primary key, name text)") + to_insert = [["one"] for _ in range(800)] + [["two"] for _ in range(300)] + await db.execute_write_many("insert into t (name) values (?)", to_insert) + response1 = await ds.client.get("/mem/t.json") + assert response1.status_code == 200 + response2 = await ds.client.get("/mem/t.json?_facet=name&_size=0") + assert response2.status_code == 200 + assert response2.json() == { + "ok": True, + "next": None, + "facet_results": { + "results": { + "name": { + "name": "name", + "type": "column", + "hideable": True, + "toggle_url": "/mem/t.json?_size=0", + "results": [ + { + "value": "one", + "label": "one", + "count": 800, + "toggle_url": "http://localhost/mem/t.json?_facet=name&_size=0&name=one", + "selected": False, + }, + { + "value": "two", + "label": "two", + "count": 300, + "toggle_url": "http://localhost/mem/t.json?_facet=name&_size=0&name=two", + "selected": False, + }, + ], + "truncated": False, + } + }, + "timed_out": [], + }, + "rows": [], + "truncated": False, + } From 6ed7908580fa2ba9297c3225d85c56f8b08b9937 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 18 Sep 2023 10:44:13 -0700 Subject: [PATCH 036/474] Simplified test for #2189 This now executes two facets, in the hope that parallel facet execution would illustrate the bug - but it did not illustrate the bug. --- tests/test_facets.py | 51 +++++++++++--------------------------------- 1 file changed, 12 insertions(+), 39 deletions(-) diff --git a/tests/test_facets.py b/tests/test_facets.py index a68347f0..85c8f85b 100644 --- a/tests/test_facets.py +++ b/tests/test_facets.py @@ -649,44 +649,17 @@ async def test_conflicting_facet_names_json(ds_client): async def test_facet_against_in_memory_database(): ds = Datasette() db = ds.add_memory_database("mem") - await db.execute_write("create table t (id integer primary key, name text)") - to_insert = [["one"] for _ in range(800)] + [["two"] for _ in range(300)] - await db.execute_write_many("insert into t (name) values (?)", to_insert) - response1 = await ds.client.get("/mem/t.json") + await db.execute_write( + "create table t (id integer primary key, name text, name2 text)" + ) + to_insert = [{"name": "one", "name2": "1"} for _ in range(800)] + [ + {"name": "two", "name2": "2"} for _ in range(300) + ] + print(to_insert) + await db.execute_write_many( + "insert into t (name, name2) values (:name, :name2)", to_insert + ) + response1 = await ds.client.get("/mem/t") assert response1.status_code == 200 - response2 = await ds.client.get("/mem/t.json?_facet=name&_size=0") + response2 = await ds.client.get("/mem/t?_facet=name&_facet=name2") assert response2.status_code == 200 - assert response2.json() == { - "ok": True, - "next": None, - "facet_results": { - "results": { - "name": { - "name": "name", - "type": "column", - "hideable": True, - "toggle_url": "/mem/t.json?_size=0", - "results": [ - { - "value": "one", - "label": "one", - "count": 800, - "toggle_url": "http://localhost/mem/t.json?_facet=name&_size=0&name=one", - "selected": False, - }, - { - "value": "two", - "label": "two", - "count": 300, - "toggle_url": "http://localhost/mem/t.json?_facet=name&_size=0&name=two", - "selected": False, - }, - ], - "truncated": False, - } - }, - "timed_out": [], - }, - "rows": [], - "truncated": False, - } From b0e5d8afa308759f4ee9f3ecdf61101dffc4a037 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 20 Sep 2023 15:10:55 -0700 Subject: [PATCH 037/474] Stop using parallel SQL queries for tables Refs: - #2189 --- datasette/views/table.py | 16 ++++++---------- docs/internals.rst | 1 + 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/datasette/views/table.py b/datasette/views/table.py index 50ba2b78..4f4baeed 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -74,11 +74,10 @@ class Row: return json.dumps(d, default=repr, indent=2) -async def _gather_parallel(*args): - return await asyncio.gather(*args) - - -async def _gather_sequential(*args): +async def run_sequential(*args): + # This used to be swappable for asyncio.gather() to run things in + # parallel, but this lead to hard-to-debug locking issues with + # in-memory databases: https://github.com/simonw/datasette/issues/2189 results = [] for fn in args: results.append(await fn) @@ -1183,9 +1182,6 @@ async def table_view_data( ) rows = rows[:page_size] - # For performance profiling purposes, ?_noparallel=1 turns off asyncio.gather - gather = _gather_sequential if request.args.get("_noparallel") else _gather_parallel - # Resolve extras extras = _get_extras(request) if any(k for k in request.args.keys() if k == "_facet" or k.startswith("_facet_")): @@ -1249,7 +1245,7 @@ async def table_view_data( if not nofacet: # Run them in parallel facet_awaitables = [facet.facet_results() for facet in facet_instances] - facet_awaitable_results = await gather(*facet_awaitables) + facet_awaitable_results = await run_sequential(*facet_awaitables) for ( instance_facet_results, instance_facets_timed_out, @@ -1282,7 +1278,7 @@ async def table_view_data( ): # Run them in parallel facet_suggest_awaitables = [facet.suggest() for facet in facet_instances] - for suggest_result in await gather(*facet_suggest_awaitables): + for suggest_result in await run_sequential(*facet_suggest_awaitables): suggested_facets.extend(suggest_result) return suggested_facets diff --git a/docs/internals.rst b/docs/internals.rst index 7fc7948c..4e9a6747 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -1317,6 +1317,7 @@ This example uses the :ref:`register_routes() ` plugin h (r"/parallel-queries$", parallel_queries), ] +Note that running parallel SQL queries in this way has `been known to cause problems in the past `__, so treat this example with caution. Adding ``?_trace=1`` will show that the trace covers both of those child tasks. From 6763572948ffd047a89a3bbf7c300e91f51ae98f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 20 Sep 2023 15:11:24 -0700 Subject: [PATCH 038/474] Bump sphinx, furo, black Bumps the python-packages group with 3 updates: [sphinx](https://github.com/sphinx-doc/sphinx), [furo](https://github.com/pradyunsg/furo) and [black](https://github.com/psf/black). Updates `sphinx` from 7.2.5 to 7.2.6 - [Release notes](https://github.com/sphinx-doc/sphinx/releases) - [Changelog](https://github.com/sphinx-doc/sphinx/blob/master/CHANGES.rst) - [Commits](https://github.com/sphinx-doc/sphinx/compare/v7.2.5...v7.2.6) Updates `furo` from 2023.8.19 to 2023.9.10 - [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.08.19...2023.09.10) Updates `black` from 23.7.0 to 23.9.1 - [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.7.0...23.9.1) --- updated-dependencies: - dependency-name: sphinx dependency-type: direct:development update-type: version-update:semver-patch dependency-group: python-packages - dependency-name: furo dependency-type: direct:development update-type: version-update:semver-minor dependency-group: python-packages - dependency-name: black 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 c718086b..415fd27c 100644 --- a/setup.py +++ b/setup.py @@ -69,8 +69,8 @@ setup( setup_requires=["pytest-runner"], extras_require={ "docs": [ - "Sphinx==7.2.5", - "furo==2023.8.19", + "Sphinx==7.2.6", + "furo==2023.9.10", "sphinx-autobuild", "codespell>=2.2.5", "blacken-docs", @@ -83,7 +83,7 @@ setup( "pytest-xdist>=2.2.1", "pytest-asyncio>=0.17", "beautifulsoup4>=4.8.1", - "black==23.7.0", + "black==23.9.1", "blacken-docs==1.16.0", "pytest-timeout>=1.4.2", "trustme>=0.7", From 10bc80547330e826a749ce710da21ae29f7e6048 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 21 Sep 2023 12:11:35 -0700 Subject: [PATCH 039/474] Finish removing pkg_resources, closes #2057 --- datasette/plugins.py | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/datasette/plugins.py b/datasette/plugins.py index 6ec08a81..017f3b9d 100644 --- a/datasette/plugins.py +++ b/datasette/plugins.py @@ -1,7 +1,7 @@ -import importlib +import importlib.metadata +import importlib.resources import os import pluggy -import pkg_resources import sys from . import hookspecs @@ -35,15 +35,15 @@ if DATASETTE_LOAD_PLUGINS is not None: name for name in DATASETTE_LOAD_PLUGINS.split(",") if name.strip() ]: try: - distribution = pkg_resources.get_distribution(package_name) - entry_map = distribution.get_entry_map() - if "datasette" in entry_map: - for plugin_name, entry_point in entry_map["datasette"].items(): + distribution = importlib.metadata.distribution(package_name) + entry_points = distribution.entry_points + for entry_point in entry_points: + if entry_point.group == "datasette": mod = entry_point.load() pm.register(mod, name=entry_point.name) # Ensure name can be found in plugin_to_distinfo later: pm._plugin_distinfo.append((mod, distribution)) - except pkg_resources.DistributionNotFound: + except importlib.metadata.PackageNotFoundError: sys.stderr.write("Plugin {} could not be found\n".format(package_name)) @@ -61,16 +61,16 @@ def get_plugins(): templates_path = None if plugin.__name__ not in DEFAULT_PLUGINS: try: - if pkg_resources.resource_isdir(plugin.__name__, "static"): - static_path = pkg_resources.resource_filename( - plugin.__name__, "static" + if (importlib.resources.files(plugin.__name__) / "static").is_dir(): + static_path = str( + importlib.resources.files(plugin.__name__) / "static" ) - if pkg_resources.resource_isdir(plugin.__name__, "templates"): - templates_path = pkg_resources.resource_filename( - plugin.__name__, "templates" + if (importlib.resources.files(plugin.__name__) / "templates").is_dir(): + templates_path = str( + importlib.resources.files(plugin.__name__) / "templates" ) - except (KeyError, ImportError): - # Caused by --plugins_dir= plugins - KeyError/ImportError thrown in Py3.5 + except (TypeError, ModuleNotFoundError): + # Caused by --plugins_dir= plugins pass plugin_info = { "name": plugin.__name__, @@ -81,6 +81,6 @@ def get_plugins(): distinfo = plugin_to_distinfo.get(plugin) if distinfo: plugin_info["version"] = distinfo.version - plugin_info["name"] = distinfo.project_name + plugin_info["name"] = distinfo.name plugins.append(plugin_info) return plugins From 947520c1fe940de79f5db856dd693330f1bbf547 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 21 Sep 2023 12:31:32 -0700 Subject: [PATCH 040/474] Release notes for 0.64.4 on main --- docs/changelog.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 81554f83..52e1db3b 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,13 @@ Changelog ========= +.. _v0_64_4: + +0.64.4 (2023-09-21) +------------------- + +- Fix for a crashing bug caused by viewing the table page for a named in-memory database. (:issue:`2189`) + .. _v1_0_a6: 1.0a6 (2023-09-07) From b0d0a0e5de8bb5b9b6c253e8af451a532266bcf1 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 21 Sep 2023 12:42:15 -0700 Subject: [PATCH 041/474] importlib_resources for Python < 3.9, refs #2057 --- datasette/plugins.py | 15 ++++++++++----- setup.py | 1 + 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/datasette/plugins.py b/datasette/plugins.py index 017f3b9d..a93145cf 100644 --- a/datasette/plugins.py +++ b/datasette/plugins.py @@ -1,10 +1,15 @@ import importlib.metadata -import importlib.resources import os import pluggy import sys from . import hookspecs +if sys.version_info >= (3, 9): + import importlib.resources as importlib_resources +else: + import importlib_resources + + DEFAULT_PLUGINS = ( "datasette.publish.heroku", "datasette.publish.cloudrun", @@ -61,13 +66,13 @@ def get_plugins(): templates_path = None if plugin.__name__ not in DEFAULT_PLUGINS: try: - if (importlib.resources.files(plugin.__name__) / "static").is_dir(): + if (importlib_resources.files(plugin.__name__) / "static").is_dir(): static_path = str( - importlib.resources.files(plugin.__name__) / "static" + importlib_resources.files(plugin.__name__) / "static" ) - if (importlib.resources.files(plugin.__name__) / "templates").is_dir(): + if (importlib_resources.files(plugin.__name__) / "templates").is_dir(): templates_path = str( - importlib.resources.files(plugin.__name__) / "templates" + importlib_resources.files(plugin.__name__) / "templates" ) except (TypeError, ModuleNotFoundError): # Caused by --plugins_dir= plugins diff --git a/setup.py b/setup.py index 415fd27c..a2728f6b 100644 --- a/setup.py +++ b/setup.py @@ -48,6 +48,7 @@ setup( "Jinja2>=2.10.3", "hupper>=1.9", "httpx>=0.20", + 'importlib_resources>=1.3.1; python_version < "3.9"', "pint>=0.9", "pluggy>=1.0", "uvicorn>=0.11", From 80a9cd9620fddf2695d12d8386a91e7c6b145ef2 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 21 Sep 2023 12:55:50 -0700 Subject: [PATCH 042/474] test-datasette-load-plugins now fails correctly, refs #2193 --- tests/test-datasette-load-plugins.sh | 41 +++++++++++++--------------- 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/tests/test-datasette-load-plugins.sh b/tests/test-datasette-load-plugins.sh index e26d8377..03e08bb1 100755 --- a/tests/test-datasette-load-plugins.sh +++ b/tests/test-datasette-load-plugins.sh @@ -3,27 +3,24 @@ # datasette-init and datasette-json-html are installed PLUGINS=$(datasette plugins) -echo "$PLUGINS" | jq 'any(.[]; .name == "datasette-json-html")' | \ - grep -q true || ( \ - echo "Test failed: datasette-json-html not found" && \ - exit 1 \ - ) -# With the DATASETTE_LOAD_PLUGINS we should not see that +if ! echo "$PLUGINS" | jq 'any(.[]; .name == "datasette-json-html")' | grep -q true; then + echo "Test failed: datasette-json-html not found" + exit 1 +fi + PLUGINS2=$(DATASETTE_LOAD_PLUGINS=datasette-init datasette plugins) -echo "$PLUGINS2" | jq 'any(.[]; .name == "datasette-json-html")' | \ - grep -q false || ( \ - echo "Test failed: datasette-json-html should not have been loaded" && \ - exit 1 \ - ) -echo "$PLUGINS2" | jq 'any(.[]; .name == "datasette-init")' | \ - grep -q true || ( \ - echo "Test failed: datasette-init should have been loaded" && \ - exit 1 \ - ) -# With DATASETTE_LOAD_PLUGINS='' we should see no plugins +if ! echo "$PLUGINS2" | jq 'any(.[]; .name == "datasette-json-html")' | grep -q false; then + echo "Test failed: datasette-json-html should not have been loaded" + exit 1 +fi + +if ! echo "$PLUGINS2" | jq 'any(.[]; .name == "datasette-init")' | grep -q true; then + echo "Test failed: datasette-init should have been loaded" + exit 1 +fi + PLUGINS3=$(DATASETTE_LOAD_PLUGINS='' datasette plugins) -echo "$PLUGINS3"| \ - grep -q '\[\]' || ( \ - echo "Test failed: datasette plugins should have returned []" && \ - exit 1 \ - ) +if ! echo "$PLUGINS3" | grep -q '\[\]'; then + echo "Test failed: datasette plugins should have returned []" + exit 1 +fi From b7cf0200e21796a6ff653c6f94a4ee5fcfde0346 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 21 Sep 2023 13:22:40 -0700 Subject: [PATCH 043/474] Swap order of config and metadata options, refs #2194 --- tests/fixtures.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/fixtures.py b/tests/fixtures.py index 9cf6b605..16aa234e 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -782,8 +782,8 @@ def assert_permissions_checked(datasette, actions): default="fixtures.db", type=click.Path(file_okay=True, dir_okay=False), ) -@click.argument("metadata", required=False) @click.argument("config", required=False) +@click.argument("metadata", required=False) @click.argument( "plugins_path", type=click.Path(file_okay=False, dir_okay=True), required=False ) From 2da1a6acec915b81a16127008fd739c7d6075681 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 21 Sep 2023 13:26:13 -0700 Subject: [PATCH 044/474] Use importlib_metadata for Python 3.8, refs #2057 --- datasette/plugins.py | 10 ++++++---- setup.py | 1 + 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/datasette/plugins.py b/datasette/plugins.py index a93145cf..f23f5cfb 100644 --- a/datasette/plugins.py +++ b/datasette/plugins.py @@ -1,4 +1,4 @@ -import importlib.metadata +import importlib import os import pluggy import sys @@ -6,8 +6,10 @@ from . import hookspecs if sys.version_info >= (3, 9): import importlib.resources as importlib_resources + import importlib.metadata as importlib_metadata else: import importlib_resources + import importlib_metadata DEFAULT_PLUGINS = ( @@ -40,7 +42,7 @@ if DATASETTE_LOAD_PLUGINS is not None: name for name in DATASETTE_LOAD_PLUGINS.split(",") if name.strip() ]: try: - distribution = importlib.metadata.distribution(package_name) + distribution = importlib_metadata.distribution(package_name) entry_points = distribution.entry_points for entry_point in entry_points: if entry_point.group == "datasette": @@ -48,7 +50,7 @@ if DATASETTE_LOAD_PLUGINS is not None: pm.register(mod, name=entry_point.name) # Ensure name can be found in plugin_to_distinfo later: pm._plugin_distinfo.append((mod, distribution)) - except importlib.metadata.PackageNotFoundError: + except importlib_metadata.PackageNotFoundError: sys.stderr.write("Plugin {} could not be found\n".format(package_name)) @@ -86,6 +88,6 @@ def get_plugins(): distinfo = plugin_to_distinfo.get(plugin) if distinfo: plugin_info["version"] = distinfo.version - plugin_info["name"] = distinfo.name + plugin_info["name"] = distinfo.name or distinfo.project_name plugins.append(plugin_info) return plugins diff --git a/setup.py b/setup.py index a2728f6b..65a3b335 100644 --- a/setup.py +++ b/setup.py @@ -49,6 +49,7 @@ setup( "hupper>=1.9", "httpx>=0.20", 'importlib_resources>=1.3.1; python_version < "3.9"', + 'importlib_metadata>=4.6; python_version < "3.9"', "pint>=0.9", "pluggy>=1.0", "uvicorn>=0.11", From f130c7c0a88e50cea4121ea18d1f6db2431b6fab Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 21 Sep 2023 14:09:57 -0700 Subject: [PATCH 045/474] Deploy with fixtures-metadata.json, refs #2194, #2195 --- .github/workflows/deploy-latest.yml | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/.github/workflows/deploy-latest.yml b/.github/workflows/deploy-latest.yml index 0dfa5a60..e0405440 100644 --- a/.github/workflows/deploy-latest.yml +++ b/.github/workflows/deploy-latest.yml @@ -38,8 +38,14 @@ jobs: run: | pytest -n auto -m "not serial" pytest -m "serial" - - name: Build fixtures.db - run: python tests/fixtures.py fixtures.db fixtures.json plugins --extra-db-filename extra_database.db + - name: Build fixtures.db and other files needed to deploy the demo + run: |- + python tests/fixtures.py \ + fixtures.db \ + fixtures-config.json \ + fixtures-metadata.json \ + plugins \ + --extra-db-filename extra_database.db - name: Build docs.db if: ${{ github.ref == 'refs/heads/main' }} run: |- @@ -88,13 +94,13 @@ jobs: } return queries EOF - - name: Make some modifications to metadata.json - run: | - cat fixtures.json | \ - jq '.databases |= . + {"ephemeral": {"allow": {"id": "*"}}}' | \ - jq '.plugins |= . + {"datasette-ephemeral-tables": {"table_ttl": 900}}' \ - > metadata.json - cat metadata.json + # - name: Make some modifications to metadata.json + # run: | + # cat fixtures.json | \ + # jq '.databases |= . + {"ephemeral": {"allow": {"id": "*"}}}' | \ + # jq '.plugins |= . + {"datasette-ephemeral-tables": {"table_ttl": 900}}' \ + # > metadata.json + # cat metadata.json - name: Set up Cloud Run uses: google-github-actions/setup-gcloud@v0 with: @@ -112,7 +118,7 @@ jobs: # Replace 1.0 with one-dot-zero in SUFFIX export SUFFIX=${SUFFIX//1.0/one-dot-zero} datasette publish cloudrun fixtures.db fixtures2.db extra_database.db \ - -m metadata.json \ + -m fixtures-metadata.json \ --plugins-dir=plugins \ --branch=$GITHUB_SHA \ --version-note=$GITHUB_SHA \ From e4f868801a6633400045f59584cfe650961c3fa6 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 21 Sep 2023 14:58:39 -0700 Subject: [PATCH 046/474] Use importlib_metadata for 3.9 as well, refs #2057 --- datasette/plugins.py | 4 +++- setup.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/datasette/plugins.py b/datasette/plugins.py index f23f5cfb..1ed3747f 100644 --- a/datasette/plugins.py +++ b/datasette/plugins.py @@ -6,9 +6,11 @@ from . import hookspecs if sys.version_info >= (3, 9): import importlib.resources as importlib_resources - import importlib.metadata as importlib_metadata else: import importlib_resources +if sys.version_info >= (3, 10): + import importlib.metadata as importlib_metadata +else: import importlib_metadata diff --git a/setup.py b/setup.py index 65a3b335..d09a9e3d 100644 --- a/setup.py +++ b/setup.py @@ -49,7 +49,7 @@ setup( "hupper>=1.9", "httpx>=0.20", 'importlib_resources>=1.3.1; python_version < "3.9"', - 'importlib_metadata>=4.6; python_version < "3.9"', + 'importlib_metadata>=4.6; python_version < "3.10"', "pint>=0.9", "pluggy>=1.0", "uvicorn>=0.11", From 836b1587f08800658c63679d850f0149003c5311 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 21 Sep 2023 15:06:19 -0700 Subject: [PATCH 047/474] Release notes for 1.0a7 Refs #2189 --- datasette/version.py | 2 +- docs/changelog.rst | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/datasette/version.py b/datasette/version.py index 4b65999d..55e2cd42 100644 --- a/datasette/version.py +++ b/datasette/version.py @@ -1,2 +1,2 @@ -__version__ = "1.0a6" +__version__ = "1.0a7" __version_info__ = tuple(__version__.split(".")) diff --git a/docs/changelog.rst b/docs/changelog.rst index 52e1db3b..9a5290c0 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,13 @@ Changelog ========= +.. _v1_0_a7: + +1.0a7 (2023-09-21) +------------------ + +- Fix for a crashing bug caused by viewing the table page for a named in-memory database. (:issue:`2189`) + .. _v0_64_4: 0.64.4 (2023-09-21) From d51e63d3bb3e32f80d1c0f04adff7c1dd5a7b0c0 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 8 Oct 2023 09:03:37 -0700 Subject: [PATCH 048/474] Release notes for 0.64.5, refs #2197 --- docs/changelog.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 9a5290c0..48bf9ef5 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,13 @@ Changelog ========= +.. _v0_64_5: + +0.64.5 (2023-10-08) +------------------- + +- Dropped dependency on ``click-default-group-wheel``, which could cause a dependency conflict. (:issue:`2197`) + .. _v1_0_a7: 1.0a7 (2023-09-21) From 85a41987c7753c3af92ba6b8b6007211eb46602f Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 8 Oct 2023 09:07:11 -0700 Subject: [PATCH 049/474] Fixed typo acepts -> accepts --- docs/plugin_hooks.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 1816d48c..eb6bf4ae 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -488,7 +488,7 @@ This will register ``render_demo`` to be called when paths with the extension `` ``render_demo`` is a Python function. It can be a regular function or an ``async def render_demo()`` awaitable function, depending on if it needs to make any asynchronous calls. -``can_render_demo`` is a Python function (or ``async def`` function) which acepts the same arguments as ``render_demo`` but just returns ``True`` or ``False``. It lets Datasette know if the current SQL query can be represented by the plugin - and hence influnce if a link to this output format is displayed in the user interface. If you omit the ``"can_render"`` key from the dictionary every query will be treated as being supported by the plugin. +``can_render_demo`` is a Python function (or ``async def`` function) which accepts the same arguments as ``render_demo`` but just returns ``True`` or ``False``. It lets Datasette know if the current SQL query can be represented by the plugin - and hence influnce if a link to this output format is displayed in the user interface. If you omit the ``"can_render"`` key from the dictionary every query will be treated as being supported by the plugin. When a request is received, the ``"render"`` callback function is called with zero or more of the following arguments. Datasette will inspect your callback function and pass arguments that match its function signature. From 4e1188f60f8b4f90c32a372f3f70a26a3ebb88ef Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 8 Oct 2023 09:09:45 -0700 Subject: [PATCH 050/474] Upgrade spellcheck.yml workflow --- .github/workflows/spellcheck.yml | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/.github/workflows/spellcheck.yml b/.github/workflows/spellcheck.yml index 722e5c68..0ce9e10c 100644 --- a/.github/workflows/spellcheck.yml +++ b/.github/workflows/spellcheck.yml @@ -9,18 +9,13 @@ jobs: spellcheck: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v4 with: - python-version: 3.11 - - uses: actions/cache@v2 - name: Configure pip caching - with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }} - restore-keys: | - ${{ runner.os }}-pip- + python-version: '3.11' + cache: 'pip' + cache-dependency-path: '**/setup.py' - name: Install dependencies run: | pip install -e '.[docs]' From 35deaabcb105903790d18710a26e77545f6852ce Mon Sep 17 00:00:00 2001 From: Alex Garcia Date: Thu, 12 Oct 2023 09:16:37 -0700 Subject: [PATCH 051/474] Move non-metadata configuration from metadata.yaml to datasette.yaml * Allow and permission blocks moved to datasette.yaml * Documentation updates, initial framework for configuration reference --- datasette/app.py | 6 +- datasette/default_permissions.py | 37 ++-- docs/authentication.rst | 338 ++++++++++++++---------------- docs/configuration.rst | 64 ++++-- docs/custom_templates.rst | 137 ++++++------ docs/facets.rst | 12 +- docs/full_text_search.rst | 4 +- docs/internals.rst | 2 +- docs/metadata.rst | 126 ++++++++--- docs/metadata_doc.py | 21 +- docs/plugins.rst | 30 +-- docs/settings.rst | 5 +- docs/sql_queries.rst | 56 ++--- docs/writing_plugins.rst | 8 +- tests/fixtures.py | 48 ++--- tests/test_canned_queries.py | 14 +- tests/test_html.py | 44 ++-- tests/test_internals_datasette.py | 6 +- tests/test_permissions.py | 138 ++++++------ tests/test_plugins.py | 4 +- tests/test_table_api.py | 2 +- tests/test_table_html.py | 8 +- 22 files changed, 606 insertions(+), 504 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index c0e80700..7dfc63c6 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -721,7 +721,9 @@ class Datasette: return self._app_css_hash async def get_canned_queries(self, database_name, actor): - queries = self.metadata("queries", database=database_name, fallback=False) or {} + queries = ( + ((self.config or {}).get("databases") or {}).get(database_name) or {} + ).get("queries") or {} for more_queries in pm.hook.canned_queries( datasette=self, database=database_name, @@ -1315,7 +1317,7 @@ class Datasette: ): hook = await await_me_maybe(hook) collected.extend(hook) - collected.extend(self.metadata(key) or []) + collected.extend((self.config or {}).get(key) or []) output = [] for url_or_dict in collected: if isinstance(url_or_dict, dict): diff --git a/datasette/default_permissions.py b/datasette/default_permissions.py index 5a99d0d8..d29dbe84 100644 --- a/datasette/default_permissions.py +++ b/datasette/default_permissions.py @@ -144,14 +144,14 @@ def permission_allowed_default(datasette, actor, action, resource): "view-query", "execute-sql", ): - result = await _resolve_metadata_view_permissions( + result = await _resolve_config_view_permissions( datasette, actor, action, resource ) if result is not None: return result # Check custom permissions: blocks - result = await _resolve_metadata_permissions_blocks( + result = await _resolve_config_permissions_blocks( datasette, actor, action, resource ) if result is not None: @@ -164,10 +164,10 @@ def permission_allowed_default(datasette, actor, action, resource): return inner -async def _resolve_metadata_permissions_blocks(datasette, actor, action, resource): +async def _resolve_config_permissions_blocks(datasette, actor, action, resource): # Check custom permissions: blocks - metadata = datasette.metadata() - root_block = (metadata.get("permissions", None) or {}).get(action) + config = datasette.config or {} + root_block = (config.get("permissions", None) or {}).get(action) if root_block: root_result = actor_matches_allow(actor, root_block) if root_result is not None: @@ -180,7 +180,7 @@ async def _resolve_metadata_permissions_blocks(datasette, actor, action, resourc else: database = resource[0] database_block = ( - (metadata.get("databases", {}).get(database, {}).get("permissions", None)) or {} + (config.get("databases", {}).get(database, {}).get("permissions", None)) or {} ).get(action) if database_block: database_result = actor_matches_allow(actor, database_block) @@ -192,7 +192,7 @@ async def _resolve_metadata_permissions_blocks(datasette, actor, action, resourc database, table_or_query = resource table_block = ( ( - metadata.get("databases", {}) + config.get("databases", {}) .get(database, {}) .get("tables", {}) .get(table_or_query, {}) @@ -207,7 +207,7 @@ async def _resolve_metadata_permissions_blocks(datasette, actor, action, resourc # Finally the canned queries query_block = ( ( - metadata.get("databases", {}) + config.get("databases", {}) .get(database, {}) .get("queries", {}) .get(table_or_query, {}) @@ -222,25 +222,30 @@ async def _resolve_metadata_permissions_blocks(datasette, actor, action, resourc return None -async def _resolve_metadata_view_permissions(datasette, actor, action, resource): +async def _resolve_config_view_permissions(datasette, actor, action, resource): + config = datasette.config or {} if action == "view-instance": - allow = datasette.metadata("allow") + allow = config.get("allow") if allow is not None: return actor_matches_allow(actor, allow) elif action == "view-database": - database_allow = datasette.metadata("allow", database=resource) + database_allow = ((config.get("databases") or {}).get(resource) or {}).get( + "allow" + ) if database_allow is None: return None return actor_matches_allow(actor, database_allow) elif action == "view-table": database, table = resource - tables = datasette.metadata("tables", database=database) or {} + tables = ((config.get("databases") or {}).get(database) or {}).get( + "tables" + ) or {} table_allow = (tables.get(table) or {}).get("allow") if table_allow is None: return None return actor_matches_allow(actor, table_allow) elif action == "view-query": - # Check if this query has a "allow" block in metadata + # Check if this query has a "allow" block in config database, query_name = resource query = await datasette.get_canned_query(database, query_name, actor) assert query is not None @@ -250,9 +255,11 @@ async def _resolve_metadata_view_permissions(datasette, actor, action, resource) return actor_matches_allow(actor, allow) elif action == "execute-sql": # Use allow_sql block from database block, or from top-level - database_allow_sql = datasette.metadata("allow_sql", database=resource) + database_allow_sql = ((config.get("databases") or {}).get(resource) or {}).get( + "allow_sql" + ) if database_allow_sql is None: - database_allow_sql = datasette.metadata("allow_sql") + database_allow_sql = config.get("allow_sql") if database_allow_sql is None: return None return actor_matches_allow(actor, database_allow_sql) diff --git a/docs/authentication.rst b/docs/authentication.rst index 1a444d0c..a301113a 100644 --- a/docs/authentication.rst +++ b/docs/authentication.rst @@ -67,7 +67,7 @@ An **action** is a string describing the action the actor would like to perform. A **resource** is the item the actor wishes to interact with - for example a specific database or table. Some actions, such as ``permissions-debug``, are not associated with a particular resource. -Datasette's built-in view permissions (``view-database``, ``view-table`` etc) default to *allow* - unless you :ref:`configure additional permission rules ` unauthenticated users will be allowed to access content. +Datasette's built-in view permissions (``view-database``, ``view-table`` etc) default to *allow* - unless you :ref:`configure additional permission rules ` unauthenticated users will be allowed to access content. Permissions with potentially harmful effects should default to *deny*. Plugin authors should account for this when designing new plugins - for example, the `datasette-upload-csvs `__ plugin defaults to deny so that installations don't accidentally allow unauthenticated users to create new tables by uploading a CSV file. @@ -76,7 +76,7 @@ Permissions with potentially harmful effects should default to *deny*. Plugin au Defining permissions with "allow" blocks ---------------------------------------- -The standard way to define permissions in Datasette is to use an ``"allow"`` block. This is a JSON document describing which actors are allowed to perform a permission. +The standard way to define permissions in Datasette is to use an ``"allow"`` block :ref:`in the datasette.yaml file `. This is a JSON document describing which actors are allowed to perform a permission. The most basic form of allow block is this (`allow demo `__, `deny demo `__): @@ -186,18 +186,18 @@ The /-/allow-debug tool The ``/-/allow-debug`` tool lets you try out different ``"action"`` blocks against different ``"actor"`` JSON objects. You can try that out here: https://latest.datasette.io/-/allow-debug -.. _authentication_permissions_metadata: +.. _authentication_permissions_config: -Access permissions in metadata -============================== +Access permissions in ``datasette.yaml`` +======================================== -There are two ways to configure permissions using ``metadata.json`` (or ``metadata.yaml``). +There are two ways to configure permissions using ``datasette.yaml`` (or ``datasette.json``). For simple visibility permissions you can use ``"allow"`` blocks in the root, database, table and query sections. For other permissions you can use a ``"permissions"`` block, described :ref:`in the next section `. -You can limit who is allowed to view different parts of your Datasette instance using ``"allow"`` keys in your :ref:`metadata` configuration. +You can limit who is allowed to view different parts of your Datasette instance using ``"allow"`` keys in your :ref:`configuration`. You can control the following: @@ -216,25 +216,25 @@ Access to an instance Here's how to restrict access to your entire Datasette instance to just the ``"id": "root"`` user: .. [[[cog - from metadata_doc import metadata_example - metadata_example(cog, { - "title": "My private Datasette instance", - "allow": { - "id": "root" - } - }) -.. ]]] - -.. tab:: YAML - - .. code-block:: yaml - + from metadata_doc import config_example + config_example(cog, """ title: My private Datasette instance allow: id: root + """) +.. ]]] + +.. tab:: datasette.yaml + + .. code-block:: yaml -.. tab:: JSON + title: My private Datasette instance + allow: + id: root + + +.. tab:: datasette.json .. code-block:: json @@ -249,21 +249,22 @@ Here's how to restrict access to your entire Datasette instance to just the ``"i To deny access to all users, you can use ``"allow": false``: .. [[[cog - metadata_example(cog, { - "title": "My entirely inaccessible instance", - "allow": False - }) + config_example(cog, """ + title: My entirely inaccessible instance + allow: false + """) .. ]]] -.. tab:: YAML +.. tab:: datasette.yaml .. code-block:: yaml - title: My entirely inaccessible instance - allow: false + + title: My entirely inaccessible instance + allow: false -.. tab:: JSON +.. tab:: datasette.json .. code-block:: json @@ -283,28 +284,26 @@ Access to specific databases To limit access to a specific ``private.db`` database to just authenticated users, use the ``"allow"`` block like this: .. [[[cog - metadata_example(cog, { - "databases": { - "private": { - "allow": { - "id": "*" - } - } - } - }) -.. ]]] - -.. tab:: YAML - - .. code-block:: yaml - + config_example(cog, """ databases: private: allow: - id: '*' + id: "*" + """) +.. ]]] + +.. tab:: datasette.yaml + + .. code-block:: yaml -.. tab:: JSON + databases: + private: + allow: + id: "*" + + +.. tab:: datasette.json .. code-block:: json @@ -327,34 +326,30 @@ Access to specific tables and views To limit access to the ``users`` table in your ``bakery.db`` database: .. [[[cog - metadata_example(cog, { - "databases": { - "bakery": { - "tables": { - "users": { - "allow": { - "id": "*" - } - } - } - } - } - }) -.. ]]] - -.. tab:: YAML - - .. code-block:: yaml - + config_example(cog, """ databases: bakery: tables: users: allow: id: '*' + """) +.. ]]] + +.. tab:: datasette.yaml + + .. code-block:: yaml -.. tab:: JSON + databases: + bakery: + tables: + users: + allow: + id: '*' + + +.. tab:: datasette.json .. code-block:: json @@ -385,32 +380,12 @@ This works for SQL views as well - you can list their names in the ``"tables"`` Access to specific canned queries --------------------------------- -:ref:`canned_queries` allow you to configure named SQL queries in your ``metadata.json`` that can be executed by users. These queries can be set up to both read and write to the database, so controlling who can execute them can be important. +:ref:`canned_queries` allow you to configure named SQL queries in your ``datasette.yaml`` that can be executed by users. These queries can be set up to both read and write to the database, so controlling who can execute them can be important. To limit access to the ``add_name`` canned query in your ``dogs.db`` database to just the :ref:`root user`: .. [[[cog - metadata_example(cog, { - "databases": { - "dogs": { - "queries": { - "add_name": { - "sql": "INSERT INTO names (name) VALUES (:name)", - "write": True, - "allow": { - "id": ["root"] - } - } - } - } - } - }) -.. ]]] - -.. tab:: YAML - - .. code-block:: yaml - + config_example(cog, """ databases: dogs: queries: @@ -420,9 +395,26 @@ To limit access to the ``add_name`` canned query in your ``dogs.db`` database to allow: id: - root + """) +.. ]]] + +.. tab:: datasette.yaml + + .. code-block:: yaml -.. tab:: JSON + databases: + dogs: + queries: + add_name: + sql: INSERT INTO names (name) VALUES (:name) + write: true + allow: + id: + - root + + +.. tab:: datasette.json .. code-block:: json @@ -461,19 +453,20 @@ 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: .. [[[cog - metadata_example(cog, { - "allow_sql": False - }) + config_example(cog, """ + allow_sql: false + """) .. ]]] -.. tab:: YAML +.. tab:: datasette.yaml .. code-block:: yaml - allow_sql: false + + allow_sql: false -.. tab:: JSON +.. tab:: datasette.json .. code-block:: json @@ -485,22 +478,22 @@ To prevent any user from executing arbitrary SQL queries, use this: To enable just the :ref:`root user` to execute SQL for all databases in your instance, use the following: .. [[[cog - metadata_example(cog, { - "allow_sql": { - "id": "root" - } - }) + config_example(cog, """ + allow_sql: + id: root + """) .. ]]] -.. tab:: YAML +.. tab:: datasette.yaml .. code-block:: yaml - allow_sql: - id: root + + allow_sql: + id: root -.. tab:: JSON +.. tab:: datasette.json .. code-block:: json @@ -514,28 +507,26 @@ To enable just the :ref:`root user` to execute SQL for all To limit this ability for just one specific database, use this: .. [[[cog - metadata_example(cog, { - "databases": { - "mydatabase": { - "allow_sql": { - "id": "root" - } - } - } - }) -.. ]]] - -.. tab:: YAML - - .. code-block:: yaml - + config_example(cog, """ databases: mydatabase: allow_sql: id: root + """) +.. ]]] + +.. tab:: datasette.yaml + + .. code-block:: yaml -.. tab:: JSON + databases: + mydatabase: + allow_sql: + id: root + + +.. tab:: datasette.json .. code-block:: json @@ -552,33 +543,32 @@ To limit this ability for just one specific database, use this: .. _authentication_permissions_other: -Other permissions in metadata -============================= +Other permissions in ``datasette.yaml`` +======================================= -For all other permissions, you can use one or more ``"permissions"`` blocks in your metadata. +For all other permissions, you can use one or more ``"permissions"`` blocks in your ``datasette.yaml`` configuration file. -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: +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 configuration: .. [[[cog - metadata_example(cog, { - "permissions": { - "debug-menu": { - "id": "*" - } - } - }) -.. ]]] - -.. tab:: YAML - - .. code-block:: yaml - + config_example(cog, """ permissions: debug-menu: id: '*' + """) +.. ]]] + +.. tab:: datasette.yaml + + .. code-block:: yaml -.. tab:: JSON + permissions: + debug-menu: + id: '*' + + +.. tab:: datasette.json .. code-block:: json @@ -594,31 +584,28 @@ To grant access to the :ref:`permissions debug tool ` to a To grant ``create-table`` to the user with ``id`` of ``editor`` for the ``docs`` database: .. [[[cog - metadata_example(cog, { - "databases": { - "docs": { - "permissions": { - "create-table": { - "id": "editor" - } - } - } - } - }) -.. ]]] - -.. tab:: YAML - - .. code-block:: yaml - + config_example(cog, """ databases: docs: permissions: create-table: id: editor + """) +.. ]]] + +.. tab:: datasette.yaml + + .. code-block:: yaml -.. tab:: JSON + databases: + docs: + permissions: + create-table: + id: editor + + +.. tab:: datasette.json .. code-block:: json @@ -638,27 +625,7 @@ To grant ``create-table`` to the user with ``id`` of ``editor`` for the ``docs`` And for ``insert-row`` against the ``reports`` table in that ``docs`` database: .. [[[cog - metadata_example(cog, { - "databases": { - "docs": { - "tables": { - "reports": { - "permissions": { - "insert-row": { - "id": "editor" - } - } - } - } - } - } - }) -.. ]]] - -.. tab:: YAML - - .. code-block:: yaml - + config_example(cog, """ databases: docs: tables: @@ -666,9 +633,24 @@ And for ``insert-row`` against the ``reports`` table in that ``docs`` database: permissions: insert-row: id: editor + """) +.. ]]] + +.. tab:: datasette.yaml + + .. code-block:: yaml -.. tab:: JSON + databases: + docs: + tables: + reports: + permissions: + insert-row: + id: editor + + +.. tab:: datasette.json .. code-block:: json diff --git a/docs/configuration.rst b/docs/configuration.rst index 4a7258b9..4e108602 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -13,15 +13,15 @@ To facilitate this, You can provide a ``datasette.yaml`` configuration file to d .. _configuration_reference: -``datasette.yaml`` reference +``datasette.yaml`` Reference ---------------------------- Here's a full example of all the valid configuration options that can exist inside ``datasette.yaml``. .. [[[cog - from metadata_doc import metadata_example + from metadata_doc import config_example import textwrap - metadata_example(cog, yaml=textwrap.dedent( + config_example(cog, textwrap.dedent( """ # Datasette settings block settings: @@ -52,10 +52,11 @@ Here's a full example of all the valid configuration options that can exist insi ) .. ]]] -.. tab:: YAML +.. tab:: datasette.yaml .. code-block:: yaml + # Datasette settings block settings: default_page_size: 50 @@ -82,7 +83,8 @@ Here's a full example of all the valid configuration options that can exist insi datasette-my-plugin: key: valueB -.. tab:: JSON + +.. tab:: datasette.json .. code-block:: json @@ -125,9 +127,9 @@ Settings configuration :ref:`settings` can be configured in ``datasette.yaml`` with the ``settings`` key. .. [[[cog - from metadata_doc import metadata_example + from metadata_doc import config_example import textwrap - metadata_example(cog, yaml=textwrap.dedent( + config_example(cog, textwrap.dedent( """ # inside datasette.yaml settings: @@ -137,7 +139,7 @@ Settings configuration ) .. ]]] -.. tab:: YAML +.. tab:: datasette.yaml .. code-block:: yaml @@ -146,7 +148,7 @@ Settings configuration default_allow_sql: off default_page_size: 50 -.. tab:: JSON +.. tab:: datasette.json .. code-block:: json @@ -165,9 +167,9 @@ Plugin configuration Configuration for plugins can be defined inside ``datasette.yaml``. For top-level plugin configuration, use the ``plugins`` key. .. [[[cog - from metadata_doc import metadata_example + from metadata_doc import config_example import textwrap - metadata_example(cog, yaml=textwrap.dedent( + config_example(cog, textwrap.dedent( """ # inside datasette.yaml plugins: @@ -177,7 +179,7 @@ Configuration for plugins can be defined inside ``datasette.yaml``. For top-leve ) .. ]]] -.. tab:: YAML +.. tab:: datasette.yaml .. code-block:: yaml @@ -186,7 +188,7 @@ Configuration for plugins can be defined inside ``datasette.yaml``. For top-leve datasette-my-plugin: key: my_value -.. tab:: JSON +.. tab:: datasette.json .. code-block:: json @@ -202,9 +204,9 @@ Configuration for plugins can be defined inside ``datasette.yaml``. For top-leve For database level or table level plugin configuration, nest it under the appropriate place under ``databases``. .. [[[cog - from metadata_doc import metadata_example + from metadata_doc import config_example import textwrap - metadata_example(cog, yaml=textwrap.dedent( + config_example(cog, textwrap.dedent( """ # inside datasette.yaml databases: @@ -224,7 +226,7 @@ For database level or table level plugin configuration, nest it under the approp ) .. ]]] -.. tab:: YAML +.. tab:: datasette.yaml .. code-block:: yaml @@ -243,7 +245,7 @@ For database level or table level plugin configuration, nest it under the approp datasette-my-plugin: key: my_value -.. tab:: JSON +.. tab:: datasette.json .. code-block:: json @@ -269,4 +271,30 @@ For database level or table level plugin configuration, nest it under the approp } } } -.. [[[end]]] \ No newline at end of file +.. [[[end]]] + + +.. _configuration_reference_permissions: +Permissions Configuration +~~~~~~~~~~~~~~~~~~~~ + +TODO + + +.. _configuration_reference_authentication: +Authentication Configuration +~~~~~~~~~~~~~~~~~~~~ + +TODO + +.. _configuration_reference_canned_queries: +Canned Queries Configuration +~~~~~~~~~~~~~~~~~~~~ + +TODO + +.. _configuration_reference_css_js: +Extra CSS and JS Configuration +~~~~~~~~~~~~~~~~~~~~ + +TODO diff --git a/docs/custom_templates.rst b/docs/custom_templates.rst index c0f64cb5..d8e4ac96 100644 --- a/docs/custom_templates.rst +++ b/docs/custom_templates.rst @@ -10,35 +10,34 @@ Datasette provides a number of ways of customizing the way data is displayed. Custom CSS and JavaScript ------------------------- -When you launch Datasette, you can specify a custom metadata file like this:: +When you launch Datasette, you can specify a custom configuration file like this:: - datasette mydb.db --metadata metadata.yaml + datasette mydb.db --config datasette.yaml -Your ``metadata.yaml`` file can include links that look like this: +Your ``datasette.yaml`` file can include links that look like this: .. [[[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 - + from metadata_doc import config_example + config_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:: datasette.yaml + + .. code-block:: yaml -.. tab:: 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 + + +.. tab:: datasette.json .. code-block:: json @@ -62,35 +61,30 @@ 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: .. [[[cog - metadata_example(cog, { - "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:: YAML - - .. code-block:: yaml - + config_example(cog, """ 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:: datasette.yaml + + .. code-block:: yaml -.. tab:: 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= + + +.. tab:: datasette.json .. code-block:: json @@ -115,7 +109,7 @@ This will produce: .. code-block:: html + {% for url in extra_js_urls %} {% endfor %} diff --git a/demos/plugins/example_js_manager_plugins.py b/demos/plugins/example_js_manager_plugins.py new file mode 100644 index 00000000..7db45464 --- /dev/null +++ b/demos/plugins/example_js_manager_plugins.py @@ -0,0 +1,21 @@ +from datasette import hookimpl + +# Test command: +# datasette fixtures.db \ --plugins-dir=demos/plugins/ +# \ --static static:demos/plugins/static + +# Create a set with view names that qualify for this JS, since plugins won't do anything on other pages +# Same pattern as in Nteract data explorer +# https://github.com/hydrosquall/datasette-nteract-data-explorer/blob/main/datasette_nteract_data_explorer/__init__.py#L77 +PERMITTED_VIEWS = {"table", "query", "database"} + + +@hookimpl +def extra_js_urls(view_name): + print(view_name) + if view_name in PERMITTED_VIEWS: + return [ + { + "url": f"/static/table-example-plugins.js", + } + ] diff --git a/demos/plugins/static/table-example-plugins.js b/demos/plugins/static/table-example-plugins.js new file mode 100644 index 00000000..8c19d9a6 --- /dev/null +++ b/demos/plugins/static/table-example-plugins.js @@ -0,0 +1,100 @@ +/** + * Example usage of Datasette JS Manager API + */ + +document.addEventListener("datasette_init", function (evt) { + const { detail: manager } = evt; + // === Demo plugins: remove before merge=== + addPlugins(manager); +}); + +/** + * Examples for to test datasette JS api + */ +const addPlugins = (manager) => { + + manager.registerPlugin("column-name-plugin", { + version: 0.1, + makeColumnActions: (columnMeta) => { + const { column } = columnMeta; + + return [ + { + label: "Copy name to clipboard", + onClick: (evt) => copyToClipboard(column), + }, + { + label: "Log column metadata to console", + onClick: (evt) => console.log(column), + }, + ]; + }, + }); + + manager.registerPlugin("panel-plugin-graphs", { + version: 0.1, + makeAboveTablePanelConfigs: () => { + return [ + { + id: 'first-panel', + label: "First", + render: node => { + const description = document.createElement('p'); + description.innerText = 'Hello world'; + node.appendChild(description); + } + }, + { + id: 'second-panel', + label: "Second", + render: node => { + const iframe = document.createElement('iframe'); + iframe.src = "https://observablehq.com/embed/@d3/sortable-bar-chart?cell=viewof+order&cell=chart"; + iframe.width = 800; + iframe.height = 635; + iframe.frameborder = '0'; + node.appendChild(iframe); + } + }, + ]; + }, + }); + + manager.registerPlugin("panel-plugin-maps", { + version: 0.1, + makeAboveTablePanelConfigs: () => { + return [ + { + // ID only has to be unique within a plugin, manager namespaces for you + id: 'first-map-panel', + label: "Map plugin", + // datasette-vega, leafleft can provide a "render" function + render: node => node.innerHTML = "Here sits a map", + }, + { + id: 'second-panel', + label: "Image plugin", + render: node => { + const img = document.createElement('img'); + img.src = 'https://datasette.io/static/datasette-logo.svg' + node.appendChild(img); + }, + } + ]; + }, + }); + + // Future: dispatch message to some other part of the page with CustomEvent API + // Could use to drive filter/sort query builder actions without page refresh. +} + + + +async function copyToClipboard(str) { + try { + await navigator.clipboard.writeText(str); + } catch (err) { + /** Rejected - text failed to copy to the clipboard. Browsers didn't give permission */ + console.error('Failed to copy: ', err); + } +} From 067cc75dfa01612f9a47815b33804361e18bf5c3 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 12 Dec 2023 09:49:04 -0800 Subject: [PATCH 057/474] Fixed broken example links in row page documentation --- docs/pages.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/pages.rst b/docs/pages.rst index 0ae72351..2ce05428 100644 --- a/docs/pages.rst +++ b/docs/pages.rst @@ -70,10 +70,10 @@ Table cells with extremely long text contents are truncated on the table view ac Rows which are the targets of foreign key references from other tables will show a link to a filtered search for all records that reference that row. Here's an example from the Registers of Members Interests database: -`../people/uk.org.publicwhip%2Fperson%2F10001 `_ +`../people/uk~2Eorg~2Epublicwhip~2Fperson~2F10001 `_ Note that this URL includes the encoded primary key of the record. Here's that same page as JSON: -`../people/uk.org.publicwhip%2Fperson%2F10001.json `_ +`../people/uk~2Eorg~2Epublicwhip~2Fperson~2F10001.json `_ From 89c8ca0f3ff51fcbf5f710c529bc7a3552da0731 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 19 Dec 2023 10:32:55 -0800 Subject: [PATCH 058/474] Fix for round_trip_load() YAML error, refs #2219 --- docs/metadata_doc.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/docs/metadata_doc.py b/docs/metadata_doc.py index 3dc5b5f8..a8f13414 100644 --- a/docs/metadata_doc.py +++ b/docs/metadata_doc.py @@ -1,7 +1,7 @@ import json import textwrap from yaml import safe_dump -from ruamel.yaml import round_trip_load +from ruamel.yaml import YAML def metadata_example(cog, data=None, yaml=None): @@ -11,8 +11,7 @@ def metadata_example(cog, data=None, yaml=None): if yaml: # dedent it first yaml = textwrap.dedent(yaml).strip() - # round_trip_load to preserve key order: - data = round_trip_load(yaml) + data = YAML().load(yaml) output_yaml = yaml else: output_yaml = safe_dump(data, sort_keys=False) @@ -27,8 +26,7 @@ def metadata_example(cog, data=None, yaml=None): def config_example(cog, input): if type(input) is str: - # round_trip_load to preserve key order: - data = round_trip_load(input) + data = YAML().load(input) output_yaml = input else: data = input From 4284c74bc133ab494bf4b6dcd4a20b97b05ebb83 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 19 Dec 2023 10:51:03 -0800 Subject: [PATCH 059/474] db.execute_isolated_fn() method (#2220) Closes #2218 --- datasette/database.py | 61 ++++++++++++++++++++++++------ docs/internals.rst | 19 +++++++++- tests/test_internals_database.py | 65 ++++++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 12 deletions(-) diff --git a/datasette/database.py b/datasette/database.py index cb01301e..f2c980d7 100644 --- a/datasette/database.py +++ b/datasette/database.py @@ -159,6 +159,26 @@ class Database: kwargs["count"] = count return results + async def execute_isolated_fn(self, fn): + # Open a new connection just for the duration of this function + # blocking the write queue to avoid any writes occurring during it + if self.ds.executor is None: + # non-threaded mode + isolated_connection = self.connect(write=True) + try: + result = fn(isolated_connection) + finally: + isolated_connection.close() + try: + self._all_file_connections.remove(isolated_connection) + except ValueError: + # Was probably a memory connection + pass + return result + else: + # Threaded mode - send to write thread + return await self._send_to_write_thread(fn, isolated_connection=True) + async def execute_write_fn(self, fn, block=True): if self.ds.executor is None: # non-threaded mode @@ -166,9 +186,10 @@ class Database: self._write_connection = self.connect(write=True) self.ds._prepare_connection(self._write_connection, self.name) return fn(self._write_connection) + else: + return await self._send_to_write_thread(fn, block) - # threaded mode - task_id = uuid.uuid5(uuid.NAMESPACE_DNS, "datasette.io") + async def _send_to_write_thread(self, fn, block=True, isolated_connection=False): if self._write_queue is None: self._write_queue = queue.Queue() if self._write_thread is None: @@ -176,8 +197,9 @@ class Database: target=self._execute_writes, daemon=True ) self._write_thread.start() + task_id = uuid.uuid5(uuid.NAMESPACE_DNS, "datasette.io") reply_queue = janus.Queue() - self._write_queue.put(WriteTask(fn, task_id, reply_queue)) + self._write_queue.put(WriteTask(fn, task_id, reply_queue, isolated_connection)) if block: result = await reply_queue.async_q.get() if isinstance(result, Exception): @@ -202,12 +224,28 @@ class Database: if conn_exception is not None: result = conn_exception else: - try: - result = task.fn(conn) - except Exception as e: - sys.stderr.write("{}\n".format(e)) - sys.stderr.flush() - result = e + if task.isolated_connection: + isolated_connection = self.connect(write=True) + try: + result = task.fn(isolated_connection) + except Exception as e: + sys.stderr.write("{}\n".format(e)) + sys.stderr.flush() + result = e + finally: + isolated_connection.close() + try: + self._all_file_connections.remove(isolated_connection) + except ValueError: + # Was probably a memory connection + pass + else: + try: + result = task.fn(conn) + except Exception as e: + sys.stderr.write("{}\n".format(e)) + sys.stderr.flush() + result = e task.reply_queue.sync_q.put(result) async def execute_fn(self, fn): @@ -515,12 +553,13 @@ class Database: class WriteTask: - __slots__ = ("fn", "task_id", "reply_queue") + __slots__ = ("fn", "task_id", "reply_queue", "isolated_connection") - def __init__(self, fn, task_id, reply_queue): + def __init__(self, fn, task_id, reply_queue, isolated_connection): self.fn = fn self.task_id = task_id self.reply_queue = reply_queue + self.isolated_connection = isolated_connection class QueryInterrupted(Exception): diff --git a/docs/internals.rst b/docs/internals.rst index 649ca35d..d269bc7d 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -1017,7 +1017,7 @@ Like ``execute_write()`` but uses the ``sqlite3`` `conn.executemany() ` but executes the provided function in an entirely isolated SQLite connection, which is opened, used and then closed again in a single call to this method. + +The :ref:`prepare_connection() ` plugin hook is not executed against this connection. + +This allows plugins to execute database operations that might conflict with how database connections are usually configured. For example, running a ``VACUUM`` operation while bypassing any restrictions placed by the `datasette-sqlite-authorizer `__ plugin. + +Plugins can also use this method to load potentially dangerous SQLite extensions, use them to perform an operation and then have them safely unloaded at the end of the call, without risk of exposing them to other connections. + +Functions run using ``execute_isolated_fn()`` share the same queue as ``execute_write_fn()``, which guarantees that no writes can be executed at the same time as the isolated function is executing. + +The return value of the function will be returned by this method. Any exceptions raised by the function will be raised out of the ``await`` line as well. + .. _database_close: db.close() diff --git a/tests/test_internals_database.py b/tests/test_internals_database.py index 647ae7bd..e0511100 100644 --- a/tests/test_internals_database.py +++ b/tests/test_internals_database.py @@ -1,6 +1,7 @@ """ Tests for the datasette.database.Database class """ +from datasette.app import Datasette from datasette.database import Database, Results, MultipleValues from datasette.utils.sqlite import sqlite3 from datasette.utils import Column @@ -519,6 +520,70 @@ async def test_execute_write_fn_connection_exception(tmpdir, app_client): app_client.ds.remove_database("immutable-db") +def table_exists(conn, name): + return bool( + conn.execute( + """ + with all_tables as ( + select name from sqlite_master where type = 'table' + union all + select name from temp.sqlite_master where type = 'table' + ) + select 1 from all_tables where name = ? + """, + (name,), + ).fetchall(), + ) + + +def table_exists_checker(name): + def inner(conn): + return table_exists(conn, name) + + return inner + + +@pytest.mark.asyncio +@pytest.mark.parametrize("disable_threads", (False, True)) +async def test_execute_isolated(db, disable_threads): + if disable_threads: + ds = Datasette(memory=True, settings={"num_sql_threads": 0}) + db = ds.add_database(Database(ds, memory_name="test_num_sql_threads_zero")) + + # Create temporary table in write + await db.execute_write( + "create temporary table created_by_write (id integer primary key)" + ) + # Should stay visible to write connection + assert await db.execute_write_fn(table_exists_checker("created_by_write")) + + def create_shared_table(conn): + conn.execute("create table shared (id integer primary key)") + # And a temporary table that should not continue to exist + conn.execute( + "create temporary table created_by_isolated (id integer primary key)" + ) + assert table_exists(conn, "created_by_isolated") + # Also confirm that created_by_write does not exist + return table_exists(conn, "created_by_write") + + # shared should not exist + assert not await db.execute_fn(table_exists_checker("shared")) + + # Create it using isolated + created_by_write_exists = await db.execute_isolated_fn(create_shared_table) + assert not created_by_write_exists + + # shared SHOULD exist now + assert await db.execute_fn(table_exists_checker("shared")) + + # created_by_isolated should not exist, even in write connection + assert not await db.execute_write_fn(table_exists_checker("created_by_isolated")) + + # ... and a second call to isolated should not see that connection either + assert not await db.execute_isolated_fn(table_exists_checker("created_by_isolated")) + + @pytest.mark.asyncio async def test_mtime_ns(db): assert isinstance(db.mtime_ns, int) From 978249beda1a3e7185f61000b0dd57018541c511 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 22 Dec 2023 15:07:42 -0800 Subject: [PATCH 060/474] Removed rogue print("max_csv_mb") Found this while working on #2214 --- datasette/views/base.py | 1 - 1 file changed, 1 deletion(-) diff --git a/datasette/views/base.py b/datasette/views/base.py index 0080b33c..db08557e 100644 --- a/datasette/views/base.py +++ b/datasette/views/base.py @@ -484,7 +484,6 @@ async def stream_csv(datasette, fetch_data, request, database): async def stream_fn(r): nonlocal data, trace - print("max_csv_mb", datasette.setting("max_csv_mb")) limited_writer = LimitedWriter(r, datasette.setting("max_csv_mb")) if trace: await limited_writer.write(preamble) From 872dae1e1a1511e2edfb9d7ddf6ea5096c11d5c3 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 22 Dec 2023 15:08:11 -0800 Subject: [PATCH 061/474] Fix for CSV labels=on missing foreign key bug, closes #2214 --- datasette/views/base.py | 14 ++++++++------ tests/test_csv.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/datasette/views/base.py b/datasette/views/base.py index db08557e..e59fd683 100644 --- a/datasette/views/base.py +++ b/datasette/views/base.py @@ -553,16 +553,18 @@ async def stream_csv(datasette, fetch_data, request, database): if cell is None: new_row.extend(("", "")) else: - assert isinstance(cell, dict) - new_row.append(cell["value"]) - new_row.append(cell["label"]) + if not isinstance(cell, dict): + new_row.extend((cell, "")) + else: + new_row.append(cell["value"]) + new_row.append(cell["label"]) else: new_row.append(cell) await writer.writerow(new_row) - except Exception as e: - sys.stderr.write("Caught this error: {}\n".format(e)) + except Exception as ex: + sys.stderr.write("Caught this error: {}\n".format(ex)) sys.stderr.flush() - await r.write(str(e)) + await r.write(str(ex)) return await limited_writer.write(postamble) diff --git a/tests/test_csv.py b/tests/test_csv.py index ed83d685..9f772f89 100644 --- a/tests/test_csv.py +++ b/tests/test_csv.py @@ -1,3 +1,4 @@ +from datasette.app import Datasette from bs4 import BeautifulSoup as Soup import pytest from .fixtures import ( # noqa @@ -95,6 +96,40 @@ async def test_table_csv_with_nullable_labels(ds_client): assert response.text == EXPECTED_TABLE_WITH_NULLABLE_LABELS_CSV +@pytest.mark.asyncio +async def test_table_csv_with_invalid_labels(): + # https://github.com/simonw/datasette/issues/2214 + ds = Datasette() + await ds.invoke_startup() + db = ds.add_memory_database("db_2214") + await db.execute_write_script( + """ + create table t1 (id integer primary key, name text); + insert into t1 (id, name) values (1, 'one'); + insert into t1 (id, name) values (2, 'two'); + create table t2 (textid text primary key, name text); + insert into t2 (textid, name) values ('a', 'alpha'); + insert into t2 (textid, name) values ('b', 'beta'); + create table if not exists maintable ( + id integer primary key, + fk_integer integer references t1(id), + fk_text text references t2(textid) + ); + insert into maintable (id, fk_integer, fk_text) values (1, 1, 'a'); + insert into maintable (id, fk_integer, fk_text) values (2, 3, 'b'); -- invalid fk_integer + insert into maintable (id, fk_integer, fk_text) values (3, 2, 'c'); -- invalid fk_text + """ + ) + response = await ds.client.get("/db_2214/maintable.csv?_labels=1") + assert response.status_code == 200 + assert response.text == ( + "id,fk_integer,fk_integer_label,fk_text,fk_text_label\r\n" + "1,1,one,a,alpha\r\n" + "2,3,,b,beta\r\n" + "3,2,two,c,\r\n" + ) + + @pytest.mark.asyncio async def test_table_csv_blob_columns(ds_client): response = await ds_client.get("/fixtures/binary_data.csv") From 45b88f2056e0a4da204b50f5e17ba953fcb51865 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 22 Dec 2023 15:14:50 -0800 Subject: [PATCH 062/474] Release notes from 0.64.6, refs #2214 --- docs/changelog.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index f2f17a50..af3d2a0b 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,13 @@ Changelog ========= +.. _v0_64_6: + +0.64.6 (2023-12-22) +------------------- + +- Fixed a bug where CSV export with expanded labels could fail if a foreign key reference did not correctly resolve. (:issue:`2214`) + .. _v0_64_5: 0.64.5 (2023-10-08) From c7a4706bcc0d6736533b91437e54a8af9226a10a Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 5 Jan 2024 14:33:23 -0800 Subject: [PATCH 063/474] jinja2_environment_from_request() plugin hook Closes #2225 --- datasette/app.py | 49 +++++++++++++++++++++-------------- datasette/handle_exception.py | 3 ++- datasette/hookspecs.py | 5 ++++ datasette/views/base.py | 3 ++- datasette/views/database.py | 6 +++-- datasette/views/table.py | 3 ++- docs/plugin_hooks.rst | 42 ++++++++++++++++++++++++++++++ tests/test_plugins.py | 42 +++++++++++++++++++++++++++++- 8 files changed, 128 insertions(+), 25 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index f33865e4..482cebb4 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -420,21 +420,31 @@ class Datasette: ), ] ) - self.jinja_env = Environment( + environment = Environment( loader=template_loader, autoescape=True, enable_async=True, # undefined=StrictUndefined, ) - self.jinja_env.filters["escape_css_string"] = escape_css_string - self.jinja_env.filters["quote_plus"] = urllib.parse.quote_plus - self.jinja_env.filters["escape_sqlite"] = escape_sqlite - self.jinja_env.filters["to_css_class"] = to_css_class + environment.filters["escape_css_string"] = escape_css_string + environment.filters["quote_plus"] = urllib.parse.quote_plus + self._jinja_env = environment + environment.filters["escape_sqlite"] = escape_sqlite + environment.filters["to_css_class"] = to_css_class self._register_renderers() self._permission_checks = collections.deque(maxlen=200) self._root_token = secrets.token_hex(32) self.client = DatasetteClient(self) + def get_jinja_environment(self, request: Request = None) -> Environment: + environment = self._jinja_env + if request: + for environment in pm.hook.jinja2_environment_from_request( + datasette=self, request=request, env=environment + ): + pass + return environment + def get_permission(self, name_or_abbr: str) -> "Permission": """ Returns a Permission object for the given name or abbreviation. Raises KeyError if not found. @@ -514,7 +524,7 @@ class Datasette: abbrs[p.abbr] = p self.permissions[p.name] = p for hook in pm.hook.prepare_jinja2_environment( - env=self.jinja_env, datasette=self + env=self._jinja_env, datasette=self ): await await_me_maybe(hook) for hook in pm.hook.startup(datasette=self): @@ -1218,7 +1228,7 @@ class Datasette: else: if isinstance(templates, str): templates = [templates] - template = self.jinja_env.select_template(templates) + template = self.get_jinja_environment(request).select_template(templates) if dataclasses.is_dataclass(context): context = dataclasses.asdict(context) body_scripts = [] @@ -1568,16 +1578,6 @@ class DatasetteRouter: def __init__(self, datasette, routes): self.ds = datasette self.routes = routes or [] - # Build a list of pages/blah/{name}.html matching expressions - pattern_templates = [ - filepath - for filepath in self.ds.jinja_env.list_templates() - if "{" in filepath and filepath.startswith("pages/") - ] - self.page_routes = [ - (route_pattern_from_filepath(filepath[len("pages/") :]), filepath) - for filepath in pattern_templates - ] async def __call__(self, scope, receive, send): # Because we care about "foo/bar" v.s. "foo%2Fbar" we decode raw_path ourselves @@ -1677,13 +1677,24 @@ class DatasetteRouter: route_path = request.scope.get("route_path", request.scope["path"]) # Jinja requires template names to use "/" even on Windows template_name = "pages" + route_path + ".html" + # Build a list of pages/blah/{name}.html matching expressions + environment = self.ds.get_jinja_environment(request) + pattern_templates = [ + filepath + for filepath in environment.list_templates() + if "{" in filepath and filepath.startswith("pages/") + ] + page_routes = [ + (route_pattern_from_filepath(filepath[len("pages/") :]), filepath) + for filepath in pattern_templates + ] try: - template = self.ds.jinja_env.select_template([template_name]) + template = environment.select_template([template_name]) except TemplateNotFound: template = None if template is None: # Try for a pages/blah/{name}.html template match - for regex, wildcard_template in self.page_routes: + for regex, wildcard_template in page_routes: match = regex.match(route_path) if match is not None: context.update(match.groupdict()) diff --git a/datasette/handle_exception.py b/datasette/handle_exception.py index 8b7e83e3..bef6b4ee 100644 --- a/datasette/handle_exception.py +++ b/datasette/handle_exception.py @@ -57,7 +57,8 @@ def handle_exception(datasette, request, exception): if request.path.split("?")[0].endswith(".json"): return Response.json(info, status=status, headers=headers) else: - template = datasette.jinja_env.select_template(templates) + environment = datasette.get_jinja_environment(request) + template = environment.select_template(templates) return Response.html( await template.render_async( dict( diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index 9069927b..b6975dce 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -99,6 +99,11 @@ def actors_from_ids(datasette, actor_ids): """Returns a dictionary mapping those IDs to actor dictionaries""" +@hookspec +def jinja2_environment_from_request(datasette, request, env): + """Return a Jinja2 environment based on the incoming request""" + + @hookspec def filters_from_request(request, database, table, datasette): """ diff --git a/datasette/views/base.py b/datasette/views/base.py index e59fd683..bdc1e9cf 100644 --- a/datasette/views/base.py +++ b/datasette/views/base.py @@ -143,7 +143,8 @@ class BaseView: async def render(self, templates, request, context=None): context = context or {} - template = self.ds.jinja_env.select_template(templates) + environment = self.ds.get_jinja_environment(request) + template = environment.select_template(templates) template_context = { **context, **{ diff --git a/datasette/views/database.py b/datasette/views/database.py index 9ba5ce94..03e70379 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -143,7 +143,8 @@ class DatabaseView(View): 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) + environment = datasette.get_jinja_environment(request) + template = environment.select_template(templates) context = { **json_data, "database_color": db.color, @@ -594,7 +595,8 @@ class QueryView(View): f"query-{to_css_class(database)}-{to_css_class(canned_query['name'])}.html", ) - template = datasette.jinja_env.select_template(templates) + environment = datasette.get_jinja_environment(request) + template = environment.select_template(templates) alternate_url_json = datasette.absolute_url( request, datasette.urls.path(path_with_format(request=request, format="json")), diff --git a/datasette/views/table.py b/datasette/views/table.py index 4f4baeed..7ee5d6bf 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -806,7 +806,8 @@ async def table_view_traced(datasette, request): f"table-{to_css_class(resolved.db.name)}-{to_css_class(resolved.table)}.html", "table.html", ] - template = datasette.jinja_env.select_template(templates) + environment = datasette.get_jinja_environment(request) + template = environment.select_template(templates) alternate_url_json = datasette.absolute_url( request, datasette.urls.path(path_with_format(request=request, format="json")), diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index eb6bf4ae..f67d15d6 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -1128,6 +1128,48 @@ These IDs could be integers or strings, depending on how the actors used by the Example: `datasette-remote-actors `_ +.. _plugin_hook_jinja2_environment_from_request: + +jinja2_environment_from_request(datasette, request, env) +-------------------------------------------------------- + +``datasette`` - :ref:`internals_datasette` + A Datasette instance. + +``request`` - :ref:`internals_request` or ``None`` + The current HTTP request, if one is available. + +``env`` - ``Environment`` + The Jinja2 environment that will be used to render the current page. + +This hook can be used to return a customized `Jinja environment `__ based on the incoming request. + +If you want to run a single Datasette instance that serves different content for different domains, you can do so like this: + +.. code-block:: python + + from datasette import hookimpl + from jinja2 import ChoiceLoader, FileSystemLoader + + + @hookimpl + def jinja2_environment_from_request(request, env): + if request and request.host == "www.niche-museums.com": + return env.overlay( + loader=ChoiceLoader( + [ + FileSystemLoader( + "/mnt/niche-museums/templates" + ), + env.loader, + ] + ), + enable_async=True, + ) + return env + +This uses the Jinja `overlay() method `__ to create a new environment identical to the default environment except for having a different template loader, which first looks in the ``/mnt/niche-museums/templates`` directory before falling back on the default loader. + .. _plugin_hook_filters_from_request: filters_from_request(request, database, table, datasette) diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 82e2f7f1..bdd4ba49 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -16,6 +16,7 @@ from datasette.plugins import get_plugins, DEFAULT_PLUGINS, pm from datasette.utils.sqlite import sqlite3 from datasette.utils import CustomRow, StartupError from jinja2.environment import Template +from jinja2 import ChoiceLoader, FileSystemLoader import base64 import importlib import json @@ -563,7 +564,8 @@ async def test_hook_register_output_renderer_can_render(ds_client): async def test_hook_prepare_jinja2_environment(ds_client): ds_client.ds._HELLO = "HI" await ds_client.ds.invoke_startup() - template = ds_client.ds.jinja_env.from_string( + environment = ds_client.ds.get_jinja_environment(None) + template = environment.from_string( "Hello there, {{ a|format_numeric }}, {{ a|to_hello }}, {{ b|select_times_three }}", {"a": 3412341, "b": 5}, ) @@ -1294,3 +1296,41 @@ async def test_plugin_is_installed(): finally: pm.unregister(name="DummyPlugin") + + +@pytest.mark.asyncio +async def test_hook_jinja2_environment_from_request(tmpdir): + templates = pathlib.Path(tmpdir / "templates") + templates.mkdir() + (templates / "index.html").write_text("Hello museums!", "utf-8") + + class EnvironmentPlugin: + @hookimpl + def jinja2_environment_from_request(self, request, env): + if request and request.host == "www.niche-museums.com": + return env.overlay( + loader=ChoiceLoader( + [ + FileSystemLoader(str(templates)), + env.loader, + ] + ), + enable_async=True, + ) + return env + + datasette = Datasette(memory=True) + + try: + pm.register(EnvironmentPlugin(), name="EnvironmentPlugin") + response = await datasette.client.get("/") + assert response.status_code == 200 + assert "Hello museums!" not in response.text + # Try again with the hostname + response2 = await datasette.client.get( + "/", headers={"host": "www.niche-museums.com"} + ) + assert response2.status_code == 200 + assert "Hello museums!" in response2.text + finally: + pm.unregister(name="EnvironmentPlugin") From 1fc76fee6268c21003c0fe730cc8e93210ce6bb8 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 5 Jan 2024 16:59:25 -0800 Subject: [PATCH 064/474] 1.0a8.dev1 version number Not going to release this to PyPI but I will build my own wheel of it --- datasette/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/datasette/version.py b/datasette/version.py index 55e2cd42..75d44727 100644 --- a/datasette/version.py +++ b/datasette/version.py @@ -1,2 +1,2 @@ -__version__ = "1.0a7" +__version__ = "1.0a8.dev1" __version_info__ = tuple(__version__.split(".")) From 0b2c6a7ebd4fd540d9bdfb169c621452d280e608 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 8 Jan 2024 13:12:57 -0800 Subject: [PATCH 065/474] Fix for ?_extra=columns bug, closes #2230 Also refs #262 - started a test suite for extras. --- datasette/renderer.py | 2 +- tests/test_table_api.py | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/datasette/renderer.py b/datasette/renderer.py index 224031a7..a446e69d 100644 --- a/datasette/renderer.py +++ b/datasette/renderer.py @@ -68,7 +68,7 @@ def json_renderer(request, args, data, error, truncated=None): elif shape in ("objects", "object", "array"): columns = data.get("columns") rows = data.get("rows") - if rows and columns: + if rows and columns and not isinstance(rows[0], dict): data["rows"] = [dict(zip(columns, row)) for row in rows] if shape == "object": shape_error = None diff --git a/tests/test_table_api.py b/tests/test_table_api.py index 5dbb8b8f..ae4fdb17 100644 --- a/tests/test_table_api.py +++ b/tests/test_table_api.py @@ -1362,3 +1362,27 @@ async def test_col_nocol_errors(ds_client, path, expected_error): response = await ds_client.get(path) assert response.status_code == 400 assert response.json()["error"] == expected_error + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "extra,expected_json", + ( + ( + "columns", + { + "ok": True, + "next": None, + "columns": ["id", "content", "content2"], + "rows": [{"id": "1", "content": "hey", "content2": "world"}], + "truncated": False, + }, + ), + ), +) +async def test_table_extras(ds_client, extra, expected_json): + response = await ds_client.get( + "/fixtures/primary_key_multiple_columns.json?_extra=" + extra + ) + assert response.status_code == 200 + assert response.json() == expected_json From 2ff4d4a60a348c143f79d63c48c329ffd0c1f02f Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 8 Jan 2024 13:13:53 -0800 Subject: [PATCH 066/474] Test for ?_extra=count, refs #262 --- tests/test_table_api.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/test_table_api.py b/tests/test_table_api.py index ae4fdb17..bde7a38e 100644 --- a/tests/test_table_api.py +++ b/tests/test_table_api.py @@ -1378,6 +1378,16 @@ async def test_col_nocol_errors(ds_client, path, expected_error): "truncated": False, }, ), + ( + "count", + { + "ok": True, + "next": None, + "rows": [{"id": "1", "content": "hey", "content2": "world"}], + "truncated": False, + "count": 1, + }, + ), ), ) async def test_table_extras(ds_client, extra, expected_json): From 48148e66a846d585e08ec6ab4ae3da8e60d55ab5 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 10 Jan 2024 10:42:36 -0800 Subject: [PATCH 067/474] Link from actors_from_ids plugin hook docs to datasette.actors_from_ids() --- docs/plugin_hooks.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index f67d15d6..9115c3df 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -1086,6 +1086,8 @@ The hook must return a dictionary that maps the incoming actor IDs to their full Some plugins that implement social features may store the ID of the :ref:`actor ` that performed an action - added a comment, bookmarked a table or similar - and then need a way to resolve those IDs into display-friendly actor dictionaries later on. +The :ref:`await datasette.actors_from_ids(actor_ids) ` internal method can be used to look up actors from their IDs. It will dispatch to the first plugin that implements this hook. + Unlike other plugin hooks, this only uses the first implementation of the hook to return a result. You can expect users to only have a single plugin installed that implements this hook. If no plugin is installed, Datasette defaults to returning actors that are just ``{"id": actor_id}``. From 7506a89be0d1c97632bed47635eb90f92815d6c7 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 10 Jan 2024 13:04:34 -0800 Subject: [PATCH 068/474] Docs on datasette.client for tests, closes #1830 Also covers ds.client.actor_cookie() helper --- docs/testing_plugins.rst | 28 +++++++++++++++++++++++++++ tests/test_docs.py | 41 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/docs/testing_plugins.rst b/docs/testing_plugins.rst index 6d2097ad..e10514c6 100644 --- a/docs/testing_plugins.rst +++ b/docs/testing_plugins.rst @@ -82,6 +82,34 @@ This method registers any :ref:`plugin_hook_startup` or :ref:`plugin_hook_prepar If you are using ``await datasette.client.get()`` and similar methods then you don't need to worry about this - Datasette automatically calls ``invoke_startup()`` the first time it handles a request. +.. _testing_datasette_client: + +Using datasette.client in tests +------------------------------- + +The :ref:`internals_datasette_client` mechanism is designed for use in tests. It provides access to a pre-configured `HTTPX async client `__ instance that can make GET, POST and other HTTP requests against a Datasette instance from inside a test. + +I simple test looks like this: + +.. literalinclude:: ../tests/test_docs.py + :language: python + :start-after: # -- start test_homepage -- + :end-before: # -- end test_homepage -- + +Or for a JSON API: + +.. literalinclude:: ../tests/test_docs.py + :language: python + :start-after: # -- start test_actor_is_null -- + :end-before: # -- end test_actor_is_null -- + +To make requests as an authenticated actor, create a signed ``ds_cookie`` using the ``datasette.client.actor_cookie()`` helper function and pass it in ``cookies=`` like this: + +.. literalinclude:: ../tests/test_docs.py + :language: python + :start-after: # -- start test_signed_cookie_actor -- + :end-before: # -- end test_signed_cookie_actor -- + .. _testing_plugins_pdb: Using pdb for errors thrown inside Datasette diff --git a/tests/test_docs.py b/tests/test_docs.py index e9b813fe..fdd44788 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -1,9 +1,8 @@ """ Tests to ensure certain things are documented. """ -from click.testing import CliRunner from datasette import app, utils -from datasette.cli import cli +from datasette.app import Datasette from datasette.filters import Filters from pathlib import Path import pytest @@ -102,3 +101,41 @@ def documented_fns(): @pytest.mark.parametrize("fn", utils.functions_marked_as_documented) def test_functions_marked_with_documented_are_documented(documented_fns, fn): assert fn.__name__ in documented_fns + + +# Tests for testing_plugins.rst documentation + + +# -- start test_homepage -- +@pytest.mark.asyncio +async def test_homepage(): + ds = Datasette(memory=True) + response = await ds.client.get("/") + html = response.text + assert "

" in html + + +# -- end test_homepage -- + + +# -- start test_actor_is_null -- +@pytest.mark.asyncio +async def test_actor_is_null(): + ds = Datasette(memory=True) + response = await ds.client.get("/-/actor.json") + assert response.json() == {"actor": None} + + +# -- end test_actor_is_null -- + + +# -- start test_signed_cookie_actor -- +@pytest.mark.asyncio +async def test_signed_cookie_actor(): + ds = Datasette(memory=True) + cookies = {"ds_actor": ds.client.actor_cookie({"id": "root"})} + response = await ds.client.get("/-/actor.json", cookies=cookies) + assert response.json() == {"actor": {"id": "root"}} + + +# -- end test_signed_cookie_actor -- From 0f63cb83ed31753a9bd9ec5cc71de16906767337 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 10 Jan 2024 13:08:52 -0800 Subject: [PATCH 069/474] Typo fix --- docs/testing_plugins.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/testing_plugins.rst b/docs/testing_plugins.rst index e10514c6..33ac4b22 100644 --- a/docs/testing_plugins.rst +++ b/docs/testing_plugins.rst @@ -89,7 +89,7 @@ Using datasette.client in tests The :ref:`internals_datasette_client` mechanism is designed for use in tests. It provides access to a pre-configured `HTTPX async client `__ instance that can make GET, POST and other HTTP requests against a Datasette instance from inside a test. -I simple test looks like this: +A simple test looks like this: .. literalinclude:: ../tests/test_docs.py :language: python From a25bf6bea789c409580386f77b7440ff525d09b2 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 10 Jan 2024 14:10:40 -0800 Subject: [PATCH 070/474] fmt: off to fix problem with Black, closes #2231 --- tests/test_docs.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/tests/test_docs.py b/tests/test_docs.py index fdd44788..17c01a0b 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -105,7 +105,7 @@ def test_functions_marked_with_documented_are_documented(documented_fns, fn): # Tests for testing_plugins.rst documentation - +# fmt: off # -- start test_homepage -- @pytest.mark.asyncio async def test_homepage(): @@ -113,8 +113,6 @@ async def test_homepage(): response = await ds.client.get("/") html = response.text assert "

" in html - - # -- end test_homepage -- @@ -124,8 +122,6 @@ async def test_actor_is_null(): ds = Datasette(memory=True) response = await ds.client.get("/-/actor.json") assert response.json() == {"actor": None} - - # -- end test_actor_is_null -- @@ -136,6 +132,4 @@ async def test_signed_cookie_actor(): cookies = {"ds_actor": ds.client.actor_cookie({"id": "root"})} response = await ds.client.get("/-/actor.json", cookies=cookies) assert response.json() == {"actor": {"id": "root"}} - - # -- end test_signed_cookie_actor -- From 7a5adb592ae6674a2058639c66e85eb1b49448fb Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 12 Jan 2024 14:12:14 -0800 Subject: [PATCH 071/474] Docs on temporary plugins in fixtures, closes #2234 --- docs/testing_plugins.rst | 16 ++++++++++++++++ tests/test_docs_plugins.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 tests/test_docs_plugins.py diff --git a/docs/testing_plugins.rst b/docs/testing_plugins.rst index 33ac4b22..f1363fb4 100644 --- a/docs/testing_plugins.rst +++ b/docs/testing_plugins.rst @@ -313,3 +313,19 @@ When writing tests for plugins you may find it useful to register a test plugin assert response.status_code == 500 finally: pm.unregister(name="undo") + +To reuse the same temporary plugin in multiple tests, you can register it inside a fixture in your ``conftest.py`` file like this: + +.. literalinclude:: ../tests/test_docs_plugins.py + :language: python + :start-after: # -- start datasette_with_plugin_fixture -- + :end-before: # -- end datasette_with_plugin_fixture -- + +Note the ``yield`` statement here - this ensures that the ``finally:`` block that unregisters the plugin is executed only after the test function itself has completed. + +Then in a test: + +.. literalinclude:: ../tests/test_docs_plugins.py + :language: python + :start-after: # -- start datasette_with_plugin_test -- + :end-before: # -- end datasette_with_plugin_test -- diff --git a/tests/test_docs_plugins.py b/tests/test_docs_plugins.py new file mode 100644 index 00000000..92b4514c --- /dev/null +++ b/tests/test_docs_plugins.py @@ -0,0 +1,34 @@ +# fmt: off +# -- start datasette_with_plugin_fixture -- +from datasette import hookimpl +from datasette.app import Datasette +from datasette.plugins import pm +import pytest +import pytest_asyncio + + +@pytest_asyncio.fixture +async def datasette_with_plugin(): + class TestPlugin: + __name__ = "TestPlugin" + + @hookimpl + def register_routes(self): + return [ + (r"^/error$", lambda: 1 / 0), + ] + + pm.register(TestPlugin(), name="undo") + try: + yield Datasette() + finally: + pm.unregister(name="undo") +# -- end datasette_with_plugin_fixture -- + + +# -- start datasette_with_plugin_test -- +@pytest.mark.asyncio +async def test_error(datasette_with_plugin): + response = await datasette_with_plugin.client.get("/error") + assert response.status_code == 500 +# -- end datasette_with_plugin_test -- From c3caf36af7db79336a5c8e697b2374e90e34ff5d Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 30 Jan 2024 19:54:03 -0800 Subject: [PATCH 072/474] Template slot family of plugin hooks - top_homepage() and others New plugin hooks: top_homepage top_database top_table top_row top_query top_canned_query New datasette.utils.make_slot_function() Closes #1191 --- datasette/hookspecs.py | 30 ++++++++ datasette/templates/database.html | 2 + datasette/templates/index.html | 2 + datasette/templates/query.html | 2 + datasette/templates/row.html | 2 + datasette/templates/table.html | 2 + datasette/utils/__init__.py | 17 +++++ datasette/views/database.py | 21 +++++- datasette/views/index.py | 9 ++- datasette/views/row.py | 12 ++- datasette/views/table.py | 8 ++ docs/plugin_hooks.rst | 119 ++++++++++++++++++++++++++++++ tests/test_docs.py | 4 +- tests/test_plugins.py | 101 +++++++++++++++++++++++++ 14 files changed, 324 insertions(+), 7 deletions(-) diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index b6975dce..2f4c6027 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -158,3 +158,33 @@ def skip_csrf(datasette, scope): @hookspec def handle_exception(datasette, request, exception): """Handle an uncaught exception. Can return a Response or None.""" + + +@hookspec +def top_homepage(datasette, request): + """HTML to include at the top of the homepage""" + + +@hookspec +def top_database(datasette, request, database): + """HTML to include at the top of the database page""" + + +@hookspec +def top_table(datasette, request, database, table): + """HTML to include at the top of the table page""" + + +@hookspec +def top_row(datasette, request, database, table, row): + """HTML to include at the top of the row page""" + + +@hookspec +def top_query(datasette, request, database, sql): + """HTML to include at the top of the query results page""" + + +@hookspec +def top_canned_query(datasette, request, database, query_name): + """HTML to include at the top of the canned query page""" diff --git a/datasette/templates/database.html b/datasette/templates/database.html index 3d4dae07..4b125a44 100644 --- a/datasette/templates/database.html +++ b/datasette/templates/database.html @@ -34,6 +34,8 @@ {% endif %} +{{ top_database() }} + {% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %} {% if allow_execute_sql %} diff --git a/datasette/templates/index.html b/datasette/templates/index.html index 06e09635..203abca8 100644 --- a/datasette/templates/index.html +++ b/datasette/templates/index.html @@ -7,6 +7,8 @@ {% block content %}

{{ metadata.title or "Datasette" }}{% if private %} 🔒{% endif %}

+{{ top_homepage() }} + {% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %} {% for database in databases %} diff --git a/datasette/templates/query.html b/datasette/templates/query.html index b8f06f84..1815e592 100644 --- a/datasette/templates/query.html +++ b/datasette/templates/query.html @@ -30,6 +30,8 @@

{{ metadata.title or database }}{% if canned_query and not metadata.title %}: {{ canned_query }}{% endif %}{% if private %} 🔒{% endif %}

+{% if canned_query %}{{ top_canned_query() }}{% else %}{{ top_query() }}{% endif %} + {% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
diff --git a/datasette/templates/row.html b/datasette/templates/row.html index 4d179a85..6d4b996e 100644 --- a/datasette/templates/row.html +++ b/datasette/templates/row.html @@ -22,6 +22,8 @@ {% block content %}

{{ table }}: {{ ', '.join(primary_key_values) }}{% if private %} 🔒{% endif %}

+{{ top_row() }} + {% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}

This data as {% for name, url in renderers.items() %}{{ name }}{{ ", " if not loop.last }}{% endfor %}

diff --git a/datasette/templates/table.html b/datasette/templates/table.html index 88580e52..5aee6319 100644 --- a/datasette/templates/table.html +++ b/datasette/templates/table.html @@ -45,6 +45,8 @@ {% endif %} +{{ top_table() }} + {% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %} {% if metadata.get("columns") %} diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py index 0f449b89..8914c043 100644 --- a/datasette/utils/__init__.py +++ b/datasette/utils/__init__.py @@ -1283,3 +1283,20 @@ def fail_if_plugins_in_metadata(metadata: dict, filename=None): f'Datasette no longer accepts plugin configuration in --metadata. Move your "plugins" configuration blocks to a separate file - we suggest calling that datasette.{suggested_extension} - and start Datasette with datasette -c datasette.{suggested_extension}. See https://docs.datasette.io/en/latest/configuration.html for more details.' ) return metadata + + +def make_slot_function(name, datasette, request, **kwargs): + from datasette.plugins import pm + + method = getattr(pm.hook, name, None) + assert method is not None, "No hook found for {}".format(name) + + async def inner(): + html_bits = [] + for hook in method(datasette=datasette, request=request, **kwargs): + html = await await_me_maybe(hook) + if html is not None: + html_bits.append(html) + return markupsafe.Markup("".join(html_bits)) + + return inner diff --git a/datasette/views/database.py b/datasette/views/database.py index 03e70379..caeb4e46 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -1,5 +1,4 @@ from dataclasses import dataclass, field -from typing import Callable from urllib.parse import parse_qsl, urlencode import asyncio import hashlib @@ -18,6 +17,7 @@ from datasette.utils import ( call_with_supported_arguments, derive_named_parameters, format_bytes, + make_slot_function, tilde_decode, to_css_class, validate_sql_select, @@ -161,6 +161,9 @@ class DatabaseView(View): f"{'*' if template_name == template.name else ''}{template_name}" for template_name in templates ], + "top_database": make_slot_function( + "top_database", datasette, request, database=database + ), } return Response.html( await datasette.render_template( @@ -246,6 +249,12 @@ class QueryContext: "help": "List of templates that were considered for rendering this page" } ) + top_query: callable = field( + metadata={"help": "Callable to render the top_query slot"} + ) + top_canned_query: callable = field( + metadata={"help": "Callable to render the top_canned_query slot"} + ) async def get_tables(datasette, request, db): @@ -727,6 +736,16 @@ class QueryView(View): f"{'*' if template_name == template.name else ''}{template_name}" for template_name in templates ], + top_query=make_slot_function( + "top_query", datasette, request, database=database, sql=sql + ), + top_canned_query=make_slot_function( + "top_canned_query", + datasette, + request, + database=database, + query_name=canned_query["name"] if canned_query else None, + ), ), request=request, view_name="database", diff --git a/datasette/views/index.py b/datasette/views/index.py index 95b29302..595cf234 100644 --- a/datasette/views/index.py +++ b/datasette/views/index.py @@ -1,10 +1,12 @@ -import hashlib import json -from datasette.utils import add_cors_headers, CustomJSONEncoder +from datasette.plugins import pm +from datasette.utils import add_cors_headers, make_slot_function, CustomJSONEncoder from datasette.utils.asgi import Response from datasette.version import __version__ +from markupsafe import Markup + from .base import BaseView @@ -142,5 +144,8 @@ class IndexView(BaseView): "private": not await self.ds.permission_allowed( None, "view-instance" ), + "top_homepage": make_slot_function( + "top_homepage", self.ds, request + ), }, ) diff --git a/datasette/views/row.py b/datasette/views/row.py index 8f07a662..ce877753 100644 --- a/datasette/views/row.py +++ b/datasette/views/row.py @@ -2,11 +2,9 @@ from datasette.utils.asgi import NotFound, Forbidden, Response from datasette.database import QueryInterrupted from .base import DataView, BaseView, _error from datasette.utils import ( - tilde_decode, - urlsafe_components, + make_slot_function, to_css_class, escape_sqlite, - row_sql_params_pks, ) import json import sqlite_utils @@ -73,6 +71,14 @@ class RowView(DataView): .get(database, {}) .get("tables", {}) .get(table, {}), + "top_row": make_slot_function( + "top_row", + self.ds, + request, + database=resolved.db.name, + table=resolved.table, + row=rows[0], + ), } data = { diff --git a/datasette/views/table.py b/datasette/views/table.py index 7ee5d6bf..be7479f8 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -17,6 +17,7 @@ from datasette.utils import ( append_querystring, compound_keys_after_sql, format_bytes, + make_slot_function, tilde_encode, escape_sqlite, filters_should_redirect, @@ -842,6 +843,13 @@ async def table_view_traced(datasette, request): f"{'*' if template_name == template.name else ''}{template_name}" for template_name in templates ], + top_table=make_slot_function( + "top_table", + datasette, + request, + database=resolved.db.name, + table=resolved.table, + ), ), request=request, view_name="table", diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 9115c3df..ce648ba7 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -1641,3 +1641,122 @@ This hook is responsible for returning a dictionary corresponding to Datasette : return metadata Example: `datasette-remote-metadata plugin `__ + +.. _plugin_hook_slots: + +Template slots +-------------- + +The following set of plugin hooks can be used to return extra HTML content that will be inserted into the corresponding page, directly below the ``

`` heading. + +Multiple plugins can contribute content here. The order in which it is displayed can be controlled using Pluggy's `call time order options `__. + +Each of these plugin hooks can return either a string or an awaitable function that returns a string. + +.. _plugin_hook_top_homepage: + +top_homepage(datasette, request) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``datasette`` - :ref:`internals_datasette` + You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``. + +``request`` - :ref:`internals_request` + The current HTTP request. + +Returns HTML to be displayed at the top of the Datasette homepage. + +.. _plugin_hook_top_database: + +top_database(datasette, request, database) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``datasette`` - :ref:`internals_datasette` + You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``. + +``request`` - :ref:`internals_request` + The current HTTP request. + +``database`` - string + The name of the database. + +Returns HTML to be displayed at the top of the database page. + +.. _plugin_hook_top_table: + +top_table(datasette, request, database, table) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``datasette`` - :ref:`internals_datasette` + You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``. + +``request`` - :ref:`internals_request` + The current HTTP request. + +``database`` - string + The name of the database. + +``table`` - string + The name of the table. + +Returns HTML to be displayed at the top of the table page. + +.. _plugin_hook_top_row: + +top_row(datasette, request, database, table, row) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``datasette`` - :ref:`internals_datasette` + You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``. + +``request`` - :ref:`internals_request` + The current HTTP request. + +``database`` - string + The name of the database. + +``table`` - string + The name of the table. + +``row`` - ``sqlite.Row`` + The SQLite row object being displayed. + +Returns HTML to be displayed at the top of the row page. + +.. _plugin_hook_top_query: + +top_query(datasette, request, database, sql) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``datasette`` - :ref:`internals_datasette` + You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``. + +``request`` - :ref:`internals_request` + The current HTTP request. + +``database`` - string + The name of the database. + +``sql`` - string + The SQL query. + +Returns HTML to be displayed at the top of the query results page. + +.. _plugin_hook_top_canned_query: + +top_canned_query(datasette, request, database, query_name) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``datasette`` - :ref:`internals_datasette` + You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``. + +``request`` - :ref:`internals_request` + The current HTTP request. + +``database`` - string + The name of the database. + +``query_name`` - string + The name of the canned query. + +Returns HTML to be displayed at the top of the canned query page. diff --git a/tests/test_docs.py b/tests/test_docs.py index 17c01a0b..0a803861 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -41,7 +41,9 @@ def plugin_hooks_content(): "plugin", [name for name in dir(app.pm.hook) if not name.startswith("_")] ) def test_plugin_hooks_are_documented(plugin, plugin_hooks_content): - headings = get_headings(plugin_hooks_content, "-") + headings = set() + headings.update(get_headings(plugin_hooks_content, "-")) + headings.update(get_headings(plugin_hooks_content, "~")) assert plugin in headings hook_caller = getattr(app.pm.hook, plugin) arg_names = [a for a in hook_caller.spec.argnames if a != "__multicall__"] diff --git a/tests/test_plugins.py b/tests/test_plugins.py index bdd4ba49..784c460a 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -1334,3 +1334,104 @@ async def test_hook_jinja2_environment_from_request(tmpdir): assert "Hello museums!" in response2.text finally: pm.unregister(name="EnvironmentPlugin") + + +class SlotPlugin: + __name__ = "SlotPlugin" + + @hookimpl + def top_homepage(self, request): + return "Xtop_homepage:" + request.args["z"] + + @hookimpl + def top_database(self, request, database): + async def inner(): + return "Xtop_database:{}:{}".format(database, request.args["z"]) + + return inner + + @hookimpl + def top_table(self, request, database, table): + return "Xtop_table:{}:{}:{}".format(database, table, request.args["z"]) + + @hookimpl + def top_row(self, request, database, table, row): + return "Xtop_row:{}:{}:{}:{}".format( + database, table, row["name"], request.args["z"] + ) + + @hookimpl + def top_query(self, request, database, sql): + return "Xtop_query:{}:{}:{}".format(database, sql, request.args["z"]) + + @hookimpl + def top_canned_query(self, request, database, query_name): + return "Xtop_query:{}:{}:{}".format(database, query_name, request.args["z"]) + + +@pytest.mark.asyncio +async def test_hook_top_homepage(): + try: + pm.register(SlotPlugin(), name="SlotPlugin") + datasette = Datasette(memory=True) + response = await datasette.client.get("/?z=foo") + assert response.status_code == 200 + assert "Xtop_homepage:foo" in response.text + finally: + pm.unregister(name="SlotPlugin") + + +@pytest.mark.asyncio +async def test_hook_top_database(): + try: + pm.register(SlotPlugin(), name="SlotPlugin") + datasette = Datasette(memory=True) + response = await datasette.client.get("/_memory?z=bar") + assert response.status_code == 200 + assert "Xtop_database:_memory:bar" in response.text + finally: + pm.unregister(name="SlotPlugin") + + +@pytest.mark.asyncio +async def test_hook_top_table(ds_client): + try: + pm.register(SlotPlugin(), name="SlotPlugin") + response = await ds_client.get("/fixtures/facetable?z=baz") + assert response.status_code == 200 + assert "Xtop_table:fixtures:facetable:baz" in response.text + finally: + pm.unregister(name="SlotPlugin") + + +@pytest.mark.asyncio +async def test_hook_top_row(ds_client): + try: + pm.register(SlotPlugin(), name="SlotPlugin") + response = await ds_client.get("/fixtures/facet_cities/1?z=bax") + assert response.status_code == 200 + assert "Xtop_row:fixtures:facet_cities:San Francisco:bax" in response.text + finally: + pm.unregister(name="SlotPlugin") + + +@pytest.mark.asyncio +async def test_hook_top_query(ds_client): + try: + pm.register(SlotPlugin(), name="SlotPlugin") + response = await ds_client.get("/fixtures?sql=select+1&z=x") + assert response.status_code == 200 + assert "Xtop_query:fixtures:select 1:x" in response.text + finally: + pm.unregister(name="SlotPlugin") + + +@pytest.mark.asyncio +async def test_hook_top_canned_query(ds_client): + try: + pm.register(SlotPlugin(), name="SlotPlugin") + response = await ds_client.get("/fixtures/from_hook?z=xyz") + assert response.status_code == 200 + assert "Xtop_query:fixtures:from_hook:xyz" in response.text + finally: + pm.unregister(name="SlotPlugin") From 5c64af69363100a3c35e6b131efe1f741bbde661 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 30 Jan 2024 19:55:26 -0800 Subject: [PATCH 073/474] Upgrade to latest Black, closes #2239 --- datasette/filters.py | 14 ++++++------ datasette/utils/__init__.py | 14 ++++++------ datasette/utils/shutil_backport.py | 1 + datasette/views/database.py | 22 +++++++++++-------- datasette/views/table.py | 30 ++++++++++++++------------ setup.py | 2 +- tests/plugins/my_plugin.py | 6 +++--- tests/test_api_write.py | 8 ++++--- tests/test_cli.py | 8 ++++--- tests/test_docs.py | 1 + tests/test_internals_database.py | 1 + tests/test_internals_datasette.py | 1 + tests/test_permissions.py | 8 ++++--- tests/test_plugins.py | 34 ++++++++++++++++-------------- tests/test_table_api.py | 8 ++++--- tests/test_utils.py | 1 + 16 files changed, 93 insertions(+), 66 deletions(-) diff --git a/datasette/filters.py b/datasette/filters.py index 5ea3488b..73eea857 100644 --- a/datasette/filters.py +++ b/datasette/filters.py @@ -80,9 +80,9 @@ def search_filters(request, database, table, datasette): "{fts_pk} in (select rowid from {fts_table} where {fts_table} match {match_clause})".format( fts_table=escape_sqlite(fts_table), fts_pk=escape_sqlite(fts_pk), - match_clause=":search" - if search_mode_raw - else "escape_fts(:search)", + match_clause=( + ":search" if search_mode_raw else "escape_fts(:search)" + ), ) ) human_descriptions.append(f'search matches "{search}"') @@ -99,9 +99,11 @@ def search_filters(request, database, table, datasette): "rowid in (select rowid from {fts_table} where {search_col} match {match_clause})".format( fts_table=escape_sqlite(fts_table), search_col=escape_sqlite(search_col), - match_clause=":search_{}".format(i) - if search_mode_raw - else "escape_fts(:search_{})".format(i), + match_clause=( + ":search_{}".format(i) + if search_mode_raw + else "escape_fts(:search_{})".format(i) + ), ) ) human_descriptions.append( diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py index 8914c043..196e1682 100644 --- a/datasette/utils/__init__.py +++ b/datasette/utils/__init__.py @@ -402,9 +402,9 @@ def make_dockerfile( apt_get_extras = apt_get_extras_ if spatialite: apt_get_extras.extend(["python3-dev", "gcc", "libsqlite3-mod-spatialite"]) - environment_variables[ - "SQLITE_EXTENSIONS" - ] = "/usr/lib/x86_64-linux-gnu/mod_spatialite.so" + environment_variables["SQLITE_EXTENSIONS"] = ( + "/usr/lib/x86_64-linux-gnu/mod_spatialite.so" + ) return """ FROM python:3.11.0-slim-bullseye COPY . /app @@ -416,9 +416,11 @@ RUN datasette inspect {files} --inspect-file inspect-data.json ENV PORT {port} EXPOSE {port} CMD {cmd}""".format( - apt_get_extras=APT_GET_DOCKERFILE_EXTRAS.format(" ".join(apt_get_extras)) - if apt_get_extras - else "", + apt_get_extras=( + APT_GET_DOCKERFILE_EXTRAS.format(" ".join(apt_get_extras)) + if apt_get_extras + else "" + ), environment_variables="\n".join( [ "ENV {} '{}'".format(key, value) diff --git a/datasette/utils/shutil_backport.py b/datasette/utils/shutil_backport.py index dbe22404..d1fd1bd7 100644 --- a/datasette/utils/shutil_backport.py +++ b/datasette/utils/shutil_backport.py @@ -4,6 +4,7 @@ Backported from Python 3.8. This code is licensed under the Python License: https://github.com/python/cpython/blob/v3.8.3/LICENSE """ + import os from shutil import copy, copy2, copystat, Error diff --git a/datasette/views/database.py b/datasette/views/database.py index caeb4e46..eac01ab6 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -126,9 +126,9 @@ class DatabaseView(View): "views": sql_views, "queries": canned_queries, "allow_execute_sql": allow_execute_sql, - "table_columns": await _table_columns(datasette, database) - if allow_execute_sql - else {}, + "table_columns": ( + await _table_columns(datasette, database) if allow_execute_sql else {} + ), } if format_ == "json": @@ -719,9 +719,11 @@ class QueryView(View): display_rows=await display_rows( datasette, database, request, rows, columns ), - table_columns=await _table_columns(datasette, database) - if allow_execute_sql - else {}, + table_columns=( + await _table_columns(datasette, database) + if allow_execute_sql + else {} + ), columns=columns, renderers=renderers, url_csv=datasette.urls.path( @@ -1036,9 +1038,11 @@ async def display_rows(datasette, database, request, rows, columns): display_value = markupsafe.Markup( '<Binary: {:,} byte{}>'.format( blob_url, - ' title="{}"'.format(formatted) - if "bytes" not in formatted - else "", + ( + ' title="{}"'.format(formatted) + if "bytes" not in formatted + else "" + ), len(value), "" if len(value) == 1 else "s", ) diff --git a/datasette/views/table.py b/datasette/views/table.py index be7479f8..2c5e3e13 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -236,9 +236,11 @@ async def display_columns_and_rows( path_from_row_pks(row, pks, not pks), column, ), - ' title="{}"'.format(formatted) - if "bytes" not in formatted - else "", + ( + ' title="{}"'.format(formatted) + if "bytes" not in formatted + else "" + ), len(value), "" if len(value) == 1 else "s", ) @@ -289,9 +291,9 @@ async def display_columns_and_rows( "column": column, "value": display_value, "raw": value, - "value_type": "none" - if value is None - else str(type(value).__name__), + "value_type": ( + "none" if value is None else str(type(value).__name__) + ), } ) cell_rows.append(Row(cells)) @@ -974,9 +976,9 @@ async def table_view_data( from_sql = "from {table_name} {where}".format( table_name=escape_sqlite(table_name), - where=("where {} ".format(" and ".join(where_clauses))) - if where_clauses - else "", + where=( + ("where {} ".format(" and ".join(where_clauses))) if where_clauses else "" + ), ) # Copy of params so we can mutate them later: from_sql_params = dict(**params) @@ -1040,10 +1042,12 @@ async def table_view_data( column=escape_sqlite(sort or sort_desc), op=">" if sort else "<", p=len(params), - extra_desc_only="" - if sort - else " or {column2} is null".format( - column2=escape_sqlite(sort or sort_desc) + extra_desc_only=( + "" + if sort + else " or {column2} is null".format( + column2=escape_sqlite(sort or sort_desc) + ) ), next_clauses=" and ".join(next_by_pk_clauses), ) diff --git a/setup.py b/setup.py index d09a9e3d..cd393368 100644 --- a/setup.py +++ b/setup.py @@ -85,7 +85,7 @@ setup( "pytest-xdist>=2.2.1", "pytest-asyncio>=0.17", "beautifulsoup4>=4.8.1", - "black==23.9.1", + "black==24.1.1", "blacken-docs==1.16.0", "pytest-timeout>=1.4.2", "trustme>=0.7", diff --git a/tests/plugins/my_plugin.py b/tests/plugins/my_plugin.py index eb70d9bd..9d1f86bc 100644 --- a/tests/plugins/my_plugin.py +++ b/tests/plugins/my_plugin.py @@ -39,9 +39,9 @@ def extra_css_urls(template, database, table, view_name, columns, request, datas "database": database, "table": table, "view_name": view_name, - "request_path": request.path - if request is not None - else None, + "request_path": ( + request.path if request is not None else None + ), "added": ( await datasette.get_database().execute("select 3 * 5") ).first()[0], diff --git a/tests/test_api_write.py b/tests/test_api_write.py index f27d143f..1787e06f 100644 --- a/tests/test_api_write.py +++ b/tests/test_api_write.py @@ -279,9 +279,11 @@ async def test_insert_or_upsert_row_errors( json=input, headers={ "Authorization": "Bearer {}".format(token), - "Content-Type": "text/plain" - if special_case == "invalid_content_type" - else "application/json", + "Content-Type": ( + "text/plain" + if special_case == "invalid_content_type" + else "application/json" + ), }, ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 213db416..080e8353 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -335,9 +335,11 @@ def test_serve_create(tmpdir): def test_serve_config(tmpdir, argument, format_): config_path = tmpdir / "datasette.{}".format(format_) config_path.write_text( - "settings:\n default_page_size: 5\n" - if format_ == "yaml" - else '{"settings": {"default_page_size": 5}}', + ( + "settings:\n default_page_size: 5\n" + if format_ == "yaml" + else '{"settings": {"default_page_size": 5}}' + ), "utf-8", ) runner = CliRunner() diff --git a/tests/test_docs.py b/tests/test_docs.py index 0a803861..2a58d954 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -1,6 +1,7 @@ """ Tests to ensure certain things are documented. """ + from datasette import app, utils from datasette.app import Datasette from datasette.filters import Filters diff --git a/tests/test_internals_database.py b/tests/test_internals_database.py index e0511100..dd68a6cb 100644 --- a/tests/test_internals_database.py +++ b/tests/test_internals_database.py @@ -1,6 +1,7 @@ """ Tests for the datasette.database.Database class """ + from datasette.app import Datasette from datasette.database import Database, Results, MultipleValues from datasette.utils.sqlite import sqlite3 diff --git a/tests/test_internals_datasette.py b/tests/test_internals_datasette.py index 428b259d..c30bb748 100644 --- a/tests/test_internals_datasette.py +++ b/tests/test_internals_datasette.py @@ -1,6 +1,7 @@ """ Tests for the datasette.app.Datasette class """ + import dataclasses from datasette import Forbidden, Context from datasette.app import Datasette, Database diff --git a/tests/test_permissions.py b/tests/test_permissions.py index 933aa07b..9917b749 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -381,9 +381,11 @@ async def test_permissions_debug(ds_client): { "action": div.select_one(".check-action").text, # True = green tick, False = red cross, None = gray None - "result": None - if div.select(".check-result-no-opinion") - else bool(div.select(".check-result-true")), + "result": ( + None + if div.select(".check-result-no-opinion") + else bool(div.select(".check-result-true")) + ), "used_default": bool(div.select(".check-used-default")), } for div in check_divs diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 784c460a..5bfb6132 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -1096,24 +1096,26 @@ async def test_hook_filters_from_request(ds_client): @pytest.mark.parametrize("extra_metadata", (False, True)) async def test_hook_register_permissions(extra_metadata): ds = Datasette( - config={ - "plugins": { - "datasette-register-permissions": { - "permissions": [ - { - "name": "extra-from-metadata", - "abbr": "efm", - "description": "Extra from metadata", - "takes_database": False, - "takes_resource": False, - "default": True, - } - ] + config=( + { + "plugins": { + "datasette-register-permissions": { + "permissions": [ + { + "name": "extra-from-metadata", + "abbr": "efm", + "description": "Extra from metadata", + "takes_database": False, + "takes_resource": False, + "default": True, + } + ] + } } } - } - if extra_metadata - else None, + if extra_metadata + else None + ), plugins_dir=PLUGINS_DIR, ) await ds.invoke_startup() diff --git a/tests/test_table_api.py b/tests/test_table_api.py index bde7a38e..58930950 100644 --- a/tests/test_table_api.py +++ b/tests/test_table_api.py @@ -305,9 +305,11 @@ async def test_paginate_compound_keys_with_extra_filters(ds_client): "_sort_desc=sortable_with_nulls", lambda row: ( 1 if row["sortable_with_nulls"] is None else 0, - -row["sortable_with_nulls"] - if row["sortable_with_nulls"] is not None - else 0, + ( + -row["sortable_with_nulls"] + if row["sortable_with_nulls"] is not None + else 0 + ), row["content"], ), "sorted by sortable_with_nulls descending", diff --git a/tests/test_utils.py b/tests/test_utils.py index 61392b8b..51577615 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,6 +1,7 @@ """ Tests for various datasette helper functions. """ + from datasette.app import Datasette from datasette import utils from datasette.utils.asgi import Request From b8230694ff90f9a6cd4f5b7c47fd8a71c831ee1d Mon Sep 17 00:00:00 2001 From: Forest Gregg Date: Tue, 30 Jan 2024 22:56:05 -0500 Subject: [PATCH 074/474] Set link to download db to nofollow --- datasette/templates/database.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/datasette/templates/database.html b/datasette/templates/database.html index 4b125a44..ee4dd705 100644 --- a/datasette/templates/database.html +++ b/datasette/templates/database.html @@ -97,7 +97,7 @@ {% endif %} {% if allow_download %} -

Download SQLite DB: {{ database }}.db {{ format_bytes(size) }}

+

Download SQLite DB: {{ database }}.db {{ format_bytes(size) }}

{% endif %} {% include "_codemirror_foot.html" %} From 04e8835297760416b50cc669ac6f45a7fd68170b Mon Sep 17 00:00:00 2001 From: gerrymanoim Date: Tue, 30 Jan 2024 22:56:32 -0500 Subject: [PATCH 075/474] Remove deprecated/unused args from setup.py (#2222) --- setup.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/setup.py b/setup.py index cd393368..53206eae 100644 --- a/setup.py +++ b/setup.py @@ -68,7 +68,6 @@ setup( [console_scripts] datasette=datasette.cli:cli """, - setup_requires=["pytest-runner"], extras_require={ "docs": [ "Sphinx==7.2.6", @@ -93,7 +92,6 @@ setup( ], "rich": ["rich"], }, - tests_require=["datasette[test]"], classifiers=[ "Development Status :: 4 - Beta", "Framework :: Datasette", From 959e0202972f4d95088c4c1a9df6274108af8bfb Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 30 Jan 2024 20:40:18 -0800 Subject: [PATCH 076/474] Ran blacken-docs --- docs/internals.rst | 3 +-- docs/plugin_hooks.rst | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/internals.rst b/docs/internals.rst index d269bc7d..d8f86251 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -210,8 +210,7 @@ To set cookies on the response, use the ``response.set_cookie(...)`` method. The secure=False, httponly=False, samesite="lax", - ): - ... + ): ... You can use this with :ref:`datasette.sign() ` to set signed cookies. Here's how you would set the :ref:`ds_actor cookie ` for use with Datasette :ref:`authentication `: diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index ce648ba7..da69c6c9 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -373,8 +373,7 @@ Let's say you want to build a plugin that adds a ``datasette publish my_hosting_ about, about_url, api_key, - ): - ... + ): ... Examples: `datasette-publish-fly `_, `datasette-publish-vercel `_ From 890615b3f29dcf82a792f1a145b02dba784a5b63 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 31 Jan 2024 10:53:57 -0800 Subject: [PATCH 077/474] Bump the python-packages group with 1 update (#2241) Bumps the python-packages group with 1 update: [furo](https://github.com/pradyunsg/furo). Updates `furo` from 2023.9.10 to 2024.1.29 - [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.09.10...2024.01.29) --- updated-dependencies: - dependency-name: furo dependency-type: direct:development update-type: version-update:semver-major dependency-group: python-packages ... 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 53206eae..b3915c42 100644 --- a/setup.py +++ b/setup.py @@ -71,7 +71,7 @@ setup( extras_require={ "docs": [ "Sphinx==7.2.6", - "furo==2023.9.10", + "furo==2024.1.29", "sphinx-autobuild", "codespell>=2.2.5", "blacken-docs", From bcc4f6bf1f14be6ef693f0b3fc9aa8a027977920 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 31 Jan 2024 15:21:40 -0800 Subject: [PATCH 078/474] track_event() mechanism for analytics and plugins * Closes #2240 * Documentation for event plugin hooks, refs #2240 * Include example track_event plugin in docs, refs #2240 * Tests for track_event() and register_events() hooks, refs #2240 * Initial documentation for core events, refs #2240 * Internals documentation for datasette.track_event() --- datasette/__init__.py | 1 + datasette/app.py | 16 +++ datasette/events.py | 211 ++++++++++++++++++++++++++++++++++++ datasette/hookspecs.py | 10 ++ datasette/plugins.py | 1 + datasette/views/database.py | 6 + datasette/views/row.py | 20 ++++ datasette/views/special.py | 17 ++- datasette/views/table.py | 31 ++++++ docs/conf.py | 2 + docs/events.rst | 14 +++ docs/index.rst | 1 + docs/internals.rst | 20 ++++ docs/plugin_hooks.rst | 100 +++++++++++++++++ docs/plugins.rst | 9 ++ tests/conftest.py | 33 +++++- tests/test_api.py | 7 +- tests/test_api_write.py | 64 +++++++++++ tests/test_auth.py | 19 +++- tests/test_cli.py | 6 +- tests/test_plugins.py | 31 +++++- tests/utils.py | 5 + 22 files changed, 614 insertions(+), 10 deletions(-) create mode 100644 datasette/events.py create mode 100644 docs/events.rst diff --git a/datasette/__init__.py b/datasette/__init__.py index 271e09ad..47d2b4f6 100644 --- a/datasette/__init__.py +++ b/datasette/__init__.py @@ -1,5 +1,6 @@ from datasette.permissions import Permission # noqa from datasette.version import __version_info__, __version__ # noqa +from datasette.events import Event # 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 diff --git a/datasette/app.py b/datasette/app.py index 482cebb4..530f79bc 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -34,6 +34,7 @@ from jinja2 import ( from jinja2.environment import Template from jinja2.exceptions import TemplateNotFound +from .events import Event from .views import Context from .views.base import ureg from .views.database import database_download, DatabaseView, TableCreateView @@ -505,6 +506,14 @@ class Datasette: # This must be called for Datasette to be in a usable state if self._startup_invoked: return + # Register event classes + event_classes = [] + for hook in pm.hook.register_events(datasette=self): + extra_classes = await await_me_maybe(hook) + if extra_classes: + event_classes.extend(extra_classes) + self.event_classes = tuple(event_classes) + # Register permissions, but watch out for duplicate name/abbr names = {} abbrs = {} @@ -873,6 +882,13 @@ class Datasette: result = await await_me_maybe(result) return result + async def track_event(self, event: Event): + assert isinstance(event, self.event_classes), "Invalid event type: {}".format( + type(event) + ) + for hook in pm.hook.track_event(datasette=self, event=event): + await await_me_maybe(hook) + async def permission_allowed( self, actor, action, resource=None, default=DEFAULT_NOT_SET ): diff --git a/datasette/events.py b/datasette/events.py new file mode 100644 index 00000000..96244779 --- /dev/null +++ b/datasette/events.py @@ -0,0 +1,211 @@ +from abc import ABC, abstractproperty +from dataclasses import asdict, dataclass, field +from datasette.hookspecs import hookimpl +from datetime import datetime, timezone +from typing import Optional + + +@dataclass +class Event(ABC): + @abstractproperty + def name(self): + pass + + created: datetime = field( + init=False, default_factory=lambda: datetime.now(timezone.utc) + ) + actor: Optional[dict] + + def properties(self): + properties = asdict(self) + properties.pop("actor", None) + properties.pop("created", None) + return properties + + +@dataclass +class LoginEvent(Event): + """ + Event name: ``login`` + + A user (represented by ``event.actor``) has logged in. + """ + + name = "login" + + +@dataclass +class LogoutEvent(Event): + """ + Event name: ``logout`` + + A user (represented by ``event.actor``) has logged out. + """ + + name = "logout" + + +@dataclass +class CreateTokenEvent(Event): + """ + Event name: ``create-token`` + + A user created an API token. + + :ivar expires_after: Number of seconds after which this token will expire. + :type expires_after: int or None + :ivar restrict_all: Restricted permissions for this token. + :type restrict_all: list + :ivar restrict_database: Restricted database permissions for this token. + :type restrict_database: dict + :ivar restrict_resource: Restricted resource permissions for this token. + :type restrict_resource: dict + """ + + name = "create-token" + expires_after: Optional[int] + restrict_all: list + restrict_database: dict + restrict_resource: dict + + +@dataclass +class CreateTableEvent(Event): + """ + Event name: ``create-table`` + + A new table has been created in the database. + + :ivar database: The name of the database where the table was created. + :type database: str + :ivar table: The name of the table that was created + :type table: str + :ivar schema: The SQL schema definition for the new table. + :type schema: str + """ + + name = "create-table" + database: str + table: str + schema: str + + +@dataclass +class DropTableEvent(Event): + """ + Event name: ``drop-table`` + + A table has been dropped from the database. + + :ivar database: The name of the database where the table was dropped. + :type database: str + :ivar table: The name of the table that was dropped + :type table: str + """ + + name = "drop-table" + database: str + table: str + + +@dataclass +class InsertRowsEvent(Event): + """ + Event name: ``insert-rows`` + + Rows were inserted into a table. + + :ivar database: The name of the database where the rows were inserted. + :type database: str + :ivar table: The name of the table where the rows were inserted. + :type table: str + :ivar num_rows: The number of rows that were requested to be inserted. + :type num_rows: int + :ivar ignore: Was ignore set? + :type ignore: bool + :ivar replace: Was replace set? + :type replace: bool + """ + + name = "insert-rows" + database: str + table: str + num_rows: int + ignore: bool + replace: bool + + +@dataclass +class UpsertRowsEvent(Event): + """ + Event name: ``upsert-rows`` + + Rows were upserted into a table. + + :ivar database: The name of the database where the rows were inserted. + :type database: str + :ivar table: The name of the table where the rows were inserted. + :type table: str + :ivar num_rows: The number of rows that were requested to be inserted. + :type num_rows: int + """ + + name = "upsert-rows" + database: str + table: str + num_rows: int + + +@dataclass +class UpdateRowEvent(Event): + """ + Event name: ``update-row`` + + A row was updated in a table. + + :ivar database: The name of the database where the row was updated. + :type database: str + :ivar table: The name of the table where the row was updated. + :type table: str + :ivar pks: The primary key values of the updated row. + """ + + name = "update-row" + database: str + table: str + pks: list + + +@dataclass +class DeleteRowEvent(Event): + """ + Event name: ``delete-row`` + + A row was deleted from a table. + + :ivar database: The name of the database where the row was deleted. + :type database: str + :ivar table: The name of the table where the row was deleted. + :type table: str + :ivar pks: The primary key values of the deleted row. + """ + + name = "delete-row" + database: str + table: str + pks: list + + +@hookimpl +def register_events(): + return [ + LoginEvent, + LogoutEvent, + CreateTableEvent, + CreateTokenEvent, + DropTableEvent, + InsertRowsEvent, + UpsertRowsEvent, + UpdateRowEvent, + DeleteRowEvent, + ] diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index 2f4c6027..b473f398 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -160,6 +160,16 @@ def handle_exception(datasette, request, exception): """Handle an uncaught exception. Can return a Response or None.""" +@hookspec +def track_event(datasette, event): + """Respond to an event tracked by Datasette""" + + +@hookspec +def register_events(datasette): + """Return a list of Event subclasses to use with track_event()""" + + @hookspec def top_homepage(datasette, request): """HTML to include at the top of the homepage""" diff --git a/datasette/plugins.py b/datasette/plugins.py index 1ed3747f..f7a1905f 100644 --- a/datasette/plugins.py +++ b/datasette/plugins.py @@ -27,6 +27,7 @@ DEFAULT_PLUGINS = ( "datasette.default_menu_links", "datasette.handle_exception", "datasette.forbidden", + "datasette.events", ) pm = pluggy.PluginManager("datasette") diff --git a/datasette/views/database.py b/datasette/views/database.py index eac01ab6..6d17b16c 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -10,6 +10,7 @@ import re import sqlite_utils import textwrap +from datasette.events import CreateTableEvent from datasette.database import QueryInterrupted from datasette.utils import ( add_cors_headers, @@ -969,6 +970,11 @@ class TableCreateView(BaseView): } if rows: details["row_count"] = len(rows) + await self.ds.track_event( + CreateTableEvent( + request.actor, database=db.name, table=table_name, schema=schema + ) + ) return Response.json(details, status=201) diff --git a/datasette/views/row.py b/datasette/views/row.py index ce877753..7b646641 100644 --- a/datasette/views/row.py +++ b/datasette/views/row.py @@ -1,5 +1,6 @@ from datasette.utils.asgi import NotFound, Forbidden, Response from datasette.database import QueryInterrupted +from datasette.events import UpdateRowEvent, DeleteRowEvent from .base import DataView, BaseView, _error from datasette.utils import ( make_slot_function, @@ -200,6 +201,15 @@ class RowDeleteView(BaseView): except Exception as e: return _error([str(e)], 500) + await self.ds.track_event( + DeleteRowEvent( + actor=request.actor, + database=resolved.db.name, + table=resolved.table, + pks=resolved.pk_values, + ) + ) + return Response.json({"ok": True}, status=200) @@ -246,4 +256,14 @@ class RowUpdateView(BaseView): ) rows = list(results.rows) result["row"] = dict(rows[0]) + + await self.ds.track_event( + UpdateRowEvent( + actor=request.actor, + database=resolved.db.name, + table=resolved.table, + pks=resolved.pk_values, + ) + ) + return Response.json(result, status=200) diff --git a/datasette/views/special.py b/datasette/views/special.py index 849750bf..4088a1f9 100644 --- a/datasette/views/special.py +++ b/datasette/views/special.py @@ -1,4 +1,5 @@ import json +from datasette.events import LogoutEvent, LoginEvent, CreateTokenEvent from datasette.utils.asgi import Response, Forbidden from datasette.utils import ( actor_matches_allow, @@ -80,9 +81,9 @@ class AuthTokenView(BaseView): if secrets.compare_digest(token, self.ds._root_token): self.ds._root_token = None response = Response.redirect(self.ds.urls.instance()) - response.set_cookie( - "ds_actor", self.ds.sign({"a": {"id": "root"}}, "actor") - ) + root_actor = {"id": "root"} + response.set_cookie("ds_actor", self.ds.sign({"a": root_actor}, "actor")) + await self.ds.track_event(LoginEvent(actor=root_actor)) return response else: raise Forbidden("Invalid token") @@ -105,6 +106,7 @@ class LogoutView(BaseView): response = Response.redirect(self.ds.urls.instance()) response.set_cookie("ds_actor", "", expires=0, max_age=0) self.ds.add_message(request, "You are now logged out", self.ds.WARNING) + await self.ds.track_event(LogoutEvent(actor=request.actor)) return response @@ -349,6 +351,15 @@ class CreateTokenView(BaseView): restrict_resource=restrict_resource, ) token_bits = self.ds.unsign(token[len("dstok_") :], namespace="token") + await self.ds.track_event( + CreateTokenEvent( + actor=request.actor, + expires_after=expires_after, + restrict_all=restrict_all, + restrict_database=restrict_database, + restrict_resource=restrict_resource, + ) + ) context = await self.shared(request) context.update({"errors": errors, "token": token, "token_bits": token_bits}) return await self.render(["create_token.html"], request, context) diff --git a/datasette/views/table.py b/datasette/views/table.py index 2c5e3e13..3b812c01 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -8,6 +8,7 @@ import markupsafe from datasette.plugins import pm from datasette.database import QueryInterrupted +from datasette.events import DropTableEvent, InsertRowsEvent, UpsertRowsEvent from datasette import tracer from datasette.utils import ( add_cors_headers, @@ -467,6 +468,8 @@ class TableInsertView(BaseView): if errors: return _error(errors, 400) + num_rows = len(rows) + # No that we've passed pks to _validate_data it's safe to # fix the rowids case: if not pks: @@ -527,6 +530,29 @@ class TableInsertView(BaseView): result["rows"] = [dict(r) for r in fetched_rows.rows] else: result["rows"] = rows + # We track the number of rows requested, but do not attempt to show which were actually + # inserted or upserted v.s. ignored + if upsert: + await self.ds.track_event( + UpsertRowsEvent( + actor=request.actor, + database=database_name, + table=table_name, + num_rows=num_rows, + ) + ) + else: + await self.ds.track_event( + InsertRowsEvent( + actor=request.actor, + database=database_name, + table=table_name, + num_rows=num_rows, + ignore=bool(ignore), + replace=bool(replace), + ) + ) + return Response.json(result, status=200 if upsert else 201) @@ -587,6 +613,11 @@ class TableDropView(BaseView): sqlite_utils.Database(conn)[table_name].drop() await db.execute_write_fn(drop_table) + await self.ds.track_event( + DropTableEvent( + actor=request.actor, database=database_name, table=table_name + ) + ) return Response.json({"ok": True}, status=200) diff --git a/docs/conf.py b/docs/conf.py index ca0eb986..e13882b2 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -40,6 +40,8 @@ extensions = [ if not os.environ.get("DISABLE_SPHINX_INLINE_TABS"): extensions += ["sphinx_inline_tabs"] +autodoc_member_order = "bysource" + extlinks = { "issue": ("https://github.com/simonw/datasette/issues/%s", "#%s"), } diff --git a/docs/events.rst b/docs/events.rst new file mode 100644 index 00000000..f150ac02 --- /dev/null +++ b/docs/events.rst @@ -0,0 +1,14 @@ +.. _events: + +Events +====== + +Datasette includes a mechanism for tracking events that occur while the software is running. This is primarily intended to be used by plugins, which can both trigger events and listen for events. + +The core Datasette application triggers events when certain things happen. This page describes those events. + +Plugins can listen for events using the :ref:`plugin_hook_track_event` plugin hook, which will be called with instances of the following classes (or additional classes registered by other plugins): + +.. automodule:: datasette.events + :members: + :exclude-members: Event diff --git a/docs/index.rst b/docs/index.rst index 66bbd5a4..ce1ed2eb 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -63,5 +63,6 @@ Contents plugin_hooks testing_plugins internals + events contributing changelog diff --git a/docs/internals.rst b/docs/internals.rst index d8f86251..bd7a70b5 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -593,6 +593,26 @@ Using either of these pattern will result in the in-memory database being served This removes a database that has been previously added. ``name=`` is the unique name of that database. +.. _datasette_track_event: + +await .track_event(event) +------------------------- + +``event`` - ``Event`` + An instance of a subclass of ``datasette.events.Event``. + +Plugins can call this to track events, using classes they have previously registered. See :ref:`plugin_event_tracking` for details. + +The event will then be passed to all plugins that have registered to receive events using the :ref:`plugin_hook_track_event` hook. + +Example usage, assuming the plugin has previously registered the ``BanUserEvent`` class: + +.. code-block:: python + + await datasette.track_event( + BanUserEvent(user={"id": 1, "username": "cleverbot"}) + ) + .. _datasette_sign: .sign(value, namespace="default") diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index da69c6c9..1a88cd31 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -1759,3 +1759,103 @@ top_canned_query(datasette, request, database, query_name) The name of the canned query. Returns HTML to be displayed at the top of the canned query page. + +.. _plugin_event_tracking: + +Event tracking +-------------- + +Datasette includes an internal mechanism for tracking analytical events. This can be used for analytics, but can also be used by plugins that want to listen out for when key events occur (such as a table being created) and take action in response. + +Plugins can register to receive events using the ``track_event`` plugin hook. + +They can also define their own events for other plugins to receive using the ``register_events`` plugin hook, combined with calls to the ``datasette.track_event(...)`` internal method. + +.. _plugin_hook_track_event: + +track_event(datasette, event) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``datasette`` - :ref:`internals_datasette` + You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``. + +``event`` - ``Event`` + Information about the event, represented as an instance of a subclass of the ``Event`` base class. + +This hook will be called any time an event is tracked by code that calls the :ref:`datasette.track_event(...) ` internal method. + +The ``event`` object will always have the following properties: + +- ``name``: a string representing the name of the event, for example ``logout`` or ``create-table``. +- ``actor``: a dictionary representing the actor that triggered the event, or ``None`` if the event was not triggered by an actor. +- ``created``: a ``datatime.datetime`` object in the ``timezone.utc`` timezone representing the time the event object was created. + +Other properties on the event will be available depending on the type of event. You can also access those as a dictionary using ``event.properties()``. + +The events fired by Datasette core are :ref:`documented here `. + +This example plugin logs details of all events to standard error: + +.. code-block:: python + + from datasette import hookimpl + import json + import sys + + + @hookimpl + def track_event(event): + name = event.name + actor = event.actor + properties = event.properties() + msg = json.dumps( + { + "name": name, + "actor": actor, + "properties": properties, + } + ) + print(msg, file=sys.stderr, flush=True) + + +.. _plugin_hook_register_events: + +register_events(datasette) +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``datasette`` - :ref:`internals_datasette` + You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``. + +This hook should return a list of ``Event`` subclasses that represent custom events that the plugin might send to the ``datasette.track_event()`` method. + +This example registers event subclasses for ``ban-user`` and ``unban-user`` events: + +.. code-block:: python + + from dataclasses import dataclass + from datasette import hookimpl, Event + + + @dataclass + class BanUserEvent(Event): + name = "ban-user" + user: dict + + + @dataclass + class UnbanUserEvent(Event): + name = "unban-user" + user: dict + + + @hookimpl + def register_events(): + return [BanUserEvent, UnbanUserEvent] + +The plugin can then call ``datasette.track_event(...)`` to send a ``ban-user`` event: + +.. code-block:: python + + await datasette.track_event( + BanUserEvent(user={"id": 1, "username": "cleverbot"}) + ) diff --git a/docs/plugins.rst b/docs/plugins.rst index 2ec03701..1a72af95 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -228,6 +228,15 @@ If you run ``datasette plugins --all`` it will include default plugins that ship "skip_csrf" ] }, + { + "name": "datasette.events", + "static": false, + "templates": false, + "version": null, + "hooks": [ + "register_events" + ] + }, { "name": "datasette.facets", "static": false, diff --git a/tests/conftest.py b/tests/conftest.py index 31336aea..445de057 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,3 @@ -import asyncio import httpx import os import pathlib @@ -8,7 +7,8 @@ import re import subprocess import tempfile import time -import trustme +from dataclasses import dataclass, field +from datasette import Event, hookimpl try: @@ -164,6 +164,35 @@ def check_permission_actions_are_documented(): ) +class TrackEventPlugin: + __name__ = "TrackEventPlugin" + + @dataclass + class OneEvent(Event): + name = "one" + + extra: str + + @hookimpl + def register_events(self, datasette): + async def inner(): + return [self.OneEvent] + + return inner + + @hookimpl + def track_event(self, datasette, event): + datasette._tracked_events = getattr(datasette, "_tracked_events", []) + datasette._tracked_events.append(event) + + +@pytest.fixture(scope="session", autouse=True) +def install_event_tracking_plugin(): + from datasette.plugins import pm + + pm.register(TrackEventPlugin(), name="TrackEventPlugin") + + @pytest.fixture(scope="session") def ds_localhost_http_server(): ds_proc = subprocess.Popen( diff --git a/tests/test_api.py b/tests/test_api.py index 93ca43eb..177dc95c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -786,7 +786,12 @@ async def test_threads_json(ds_client): @pytest.mark.asyncio async def test_plugins_json(ds_client): response = await ds_client.get("/-/plugins.json") - assert EXPECTED_PLUGINS == sorted(response.json(), key=lambda p: p["name"]) + # Filter out TrackEventPlugin + actual_plugins = sorted( + [p for p in response.json() if p["name"] != "TrackEventPlugin"], + key=lambda p: p["name"], + ) + assert EXPECTED_PLUGINS == actual_plugins # Try with ?all=1 response = await ds_client.get("/-/plugins.json?all=1") names = {p["name"] for p in response.json()} diff --git a/tests/test_api_write.py b/tests/test_api_write.py index 1787e06f..9caf9fdf 100644 --- a/tests/test_api_write.py +++ b/tests/test_api_write.py @@ -1,5 +1,6 @@ from datasette.app import Datasette from datasette.utils import sqlite3 +from .utils import last_event import pytest import time @@ -49,6 +50,14 @@ async def test_insert_row(ds_write): assert response.json()["rows"] == [expected_row] rows = (await ds_write.get_database("data").execute("select * from docs")).rows assert dict(rows[0]) == expected_row + # Analytics event + event = last_event(ds_write) + assert event.name == "insert-rows" + assert event.num_rows == 1 + assert event.database == "data" + assert event.table == "docs" + assert not event.ignore + assert not event.replace @pytest.mark.asyncio @@ -68,6 +77,16 @@ async def test_insert_rows(ds_write, return_rows): headers=_headers(token), ) assert response.status_code == 201 + + # Analytics event + event = last_event(ds_write) + assert event.name == "insert-rows" + assert event.num_rows == 20 + assert event.database == "data" + assert event.table == "docs" + assert not event.ignore + assert not event.replace + actual_rows = [ dict(r) for r in ( @@ -353,6 +372,16 @@ async def test_insert_ignore_replace( headers=_headers(token), ) assert response.status_code == 201 + + # Analytics event + event = last_event(ds_write) + assert event.name == "insert-rows" + assert event.num_rows == 1 + assert event.database == "data" + assert event.table == "docs" + assert event.ignore == ignore + assert event.replace == replace + actual_rows = [ dict(r) for r in ( @@ -427,6 +456,14 @@ async def test_upsert(ds_write, initial, input, expected_rows, should_return): ) assert response.status_code == 200 assert response.json()["ok"] is True + + # Analytics event + event = last_event(ds_write) + assert event.name == "upsert-rows" + assert event.num_rows == 1 + assert event.database == "data" + assert event.table == "upsert_test" + if should_return: # We only expect it to return rows corresponding to those we sent expected_returned_rows = expected_rows[: len(input["rows"])] @@ -530,6 +567,13 @@ async def test_delete_row(ds_write, table, row_for_create, pks, delete_path): headers=_headers(write_token(ds_write)), ) assert delete_response.status_code == 200 + + # Analytics event + event = last_event(ds_write) + assert event.name == "delete-row" + assert event.database == "data" + assert event.table == table + assert event.pks == str(delete_path).split(",") assert ( await ds_write.client.get( "/data.json?_shape=arrayfirst&sql=select+count(*)+from+{}".format(table) @@ -610,6 +654,13 @@ async def test_update_row(ds_write, input, expected_errors, use_return): for k, v in input.items(): assert returned_row[k] == v + # Analytics event + event = last_event(ds_write) + assert event.actor == {"id": "root", "token": "dstok"} + assert event.database == "data" + assert event.table == "docs" + assert event.pks == [str(pk)] + # And fetch the row to check it's updated response = await ds_write.client.get( "/data/docs/{}.json?_shape=array".format(pk), @@ -676,6 +727,13 @@ async def test_drop_table(ds_write, scenario): headers=_headers(token), ) assert response2.json() == {"ok": True} + # Check event + event = last_event(ds_write) + assert event.name == "drop-table" + assert event.actor == {"id": "root", "token": "dstok"} + assert event.table == "docs" + assert event.database == "data" + # Table should 404 assert (await ds_write.client.get("/data/docs")).status_code == 404 @@ -1096,6 +1154,12 @@ async def test_create_table(ds_write, input, expected_status, expected_response) assert response.status_code == expected_status data = response.json() assert data == expected_response + # create-table event + if expected_status == 201: + event = last_event(ds_write) + assert event.name == "create-table" + assert event.actor == {"id": "root", "token": "dstok"} + assert event.schema.startswith("CREATE TABLE ") @pytest.mark.asyncio diff --git a/tests/test_auth.py b/tests/test_auth.py index 33cf9b35..f2359df7 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,6 +1,6 @@ from bs4 import BeautifulSoup as Soup from .fixtures import app_client -from .utils import cookie_was_deleted +from .utils import cookie_was_deleted, last_event from click.testing import CliRunner from datasette.utils import baseconv from datasette.cli import cli @@ -19,6 +19,10 @@ async def test_auth_token(ds_client): assert {"a": {"id": "root"}} == ds_client.ds.unsign( response.cookies["ds_actor"], "actor" ) + # Should have recorded a login event + event = last_event(ds_client.ds) + assert event.name == "login" + assert event.actor == {"id": "root"} # Check that a second with same token fails assert ds_client.ds._root_token is None assert (await ds_client.get(path)).status_code == 403 @@ -57,7 +61,7 @@ async def test_actor_cookie_that_expires(ds_client, offset, expected): cookie = ds_client.ds.sign( {"a": {"id": "test"}, "e": baseconv.base62.encode(expires_at)}, "actor" ) - response = await ds_client.get("/", cookies={"ds_actor": cookie}) + await ds_client.get("/", cookies={"ds_actor": cookie}) assert ds_client.ds._last_request.scope["actor"] == expected @@ -86,6 +90,10 @@ def test_logout(app_client): csrftoken_from=True, cookies={"ds_actor": app_client.actor_cookie({"id": "test"})}, ) + # Should have recorded a logout event + event = last_event(app_client.ds) + assert event.name == "logout" + assert event.actor == {"id": "test"} # The ds_actor cookie should have been unset assert cookie_was_deleted(response4, "ds_actor") # Should also have set a message @@ -185,6 +193,13 @@ def test_auth_create_token( for error in errors: assert '

{}

'.format(error) in response2.text else: + # Check create-token event + event = last_event(app_client.ds) + assert event.name == "create-token" + assert event.expires_after == expected_duration + assert isinstance(event.restrict_all, list) + assert isinstance(event.restrict_database, dict) + assert isinstance(event.restrict_resource, dict) # Extract token from page token = response2.text.split('value="dstok_')[1].split('"')[0] details = app_client.ds.unsign(token, "token") diff --git a/tests/test_cli.py b/tests/test_cli.py index 080e8353..9cc18c6e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -100,7 +100,11 @@ def test_spatialite_error_if_cannot_find_load_extension_spatialite(): def test_plugins_cli(app_client): runner = CliRunner() result1 = runner.invoke(cli, ["plugins"]) - assert json.loads(result1.output) == EXPECTED_PLUGINS + actual_plugins = sorted( + [p for p in json.loads(result1.output) if p["name"] != "TrackEventPlugin"], + key=lambda p: p["name"], + ) + assert actual_plugins == EXPECTED_PLUGINS # Try with --all result2 = runner.invoke(cli, ["plugins", "--all"]) names = [p["name"] for p in json.loads(result2.output)] diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 5bfb6132..dad4f2ca 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -9,8 +9,9 @@ from .fixtures import ( TestClient as _TestClient, ) # noqa from click.testing import CliRunner +from dataclasses import dataclass from datasette.app import Datasette -from datasette import cli, hookimpl, Permission +from datasette import cli, hookimpl, Event, Permission from datasette.filters import FilterArguments from datasette.plugins import get_plugins, DEFAULT_PLUGINS, pm from datasette.utils.sqlite import sqlite3 @@ -18,6 +19,7 @@ from datasette.utils import CustomRow, StartupError from jinja2.environment import Template from jinja2 import ChoiceLoader, FileSystemLoader import base64 +import datetime import importlib import json import os @@ -1437,3 +1439,30 @@ async def test_hook_top_canned_query(ds_client): assert "Xtop_query:fixtures:from_hook:xyz" in response.text finally: pm.unregister(name="SlotPlugin") + + +@pytest.mark.asyncio +async def test_hook_track_event(): + datasette = Datasette(memory=True) + from .conftest import TrackEventPlugin + + await datasette.invoke_startup() + await datasette.track_event( + TrackEventPlugin.OneEvent(actor=None, extra="extra extra") + ) + assert len(datasette._tracked_events) == 1 + assert isinstance(datasette._tracked_events[0], TrackEventPlugin.OneEvent) + event = datasette._tracked_events[0] + assert event.name == "one" + assert event.properties() == {"extra": "extra extra"} + # Should have a recent created as well + created = event.created + assert isinstance(created, datetime.datetime) + assert created.tzinfo == datetime.timezone.utc + + +@pytest.mark.asyncio +async def test_hook_register_events(): + datasette = Datasette(memory=True) + await datasette.invoke_startup() + assert any(k.__name__ == "OneEvent" for k in datasette.event_classes) diff --git a/tests/utils.py b/tests/utils.py index 84d5b1df..9b31abde 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,6 +1,11 @@ from datasette.utils.sqlite import sqlite3 +def last_event(datasette): + events = getattr(datasette, "_tracked_events", []) + return events[-1] if events else None + + def assert_footer_links(soup): footer_links = soup.find("footer").findAll("a") assert 4 == len(footer_links) From 2e4a03b2c461ca20ff789146a006ddd126013ee7 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 31 Jan 2024 15:31:26 -0800 Subject: [PATCH 079/474] Run coverage on Python 3.12 - #2245 I hoped this would run slightly faster than 3.9 but there doesn't appear to be a performance improvement. --- .github/workflows/test-coverage.yml | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/.github/workflows/test-coverage.yml b/.github/workflows/test-coverage.yml index bd720664..7a08e401 100644 --- a/.github/workflows/test-coverage.yml +++ b/.github/workflows/test-coverage.yml @@ -15,18 +15,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out datasette - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: - python-version: 3.9 - - uses: actions/cache@v2 - name: Configure pip caching - with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }} - restore-keys: | - ${{ runner.os }}-pip- + python-version: '3.12' + cache: 'pip' + cache-dependency-path: '**/setup.py' - name: Install Python dependencies run: | python -m pip install --upgrade pip From bcf7ef963f6e1eb0a64b2a0bb4af0ae7a197d1d1 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 31 Jan 2024 19:45:05 -0800 Subject: [PATCH 080/474] YAML/JSON examples for allow blocks --- docs/authentication.rst | 270 ++++++++++++++++++++++++++++++++++------ 1 file changed, 231 insertions(+), 39 deletions(-) diff --git a/docs/authentication.rst b/docs/authentication.rst index a301113a..8758765d 100644 --- a/docs/authentication.rst +++ b/docs/authentication.rst @@ -80,13 +80,35 @@ The standard way to define permissions in Datasette is to use an ``"allow"`` blo The most basic form of allow block is this (`allow demo `__, `deny demo `__): -.. code-block:: json +.. [[[cog + from metadata_doc import config_example + import textwrap + config_example(cog, textwrap.dedent( + """ + allow: + id: root + """).strip(), + "YAML", "JSON" + ) +.. ]]] - { - "allow": { +.. tab:: YAML + + .. code-block:: yaml + + allow: + id: root + +.. tab:: JSON + + .. code-block:: json + + { + "allow": { "id": "root" + } } - } +.. [[[end]]] This will match any actors with an ``"id"`` property of ``"root"`` - for example, an actor that looks like this: @@ -99,29 +121,98 @@ This will match any actors with an ``"id"`` property of ``"root"`` - for example An allow block can specify "deny all" using ``false`` (`demo `__): -.. code-block:: json +.. [[[cog + from metadata_doc import config_example + import textwrap + config_example(cog, textwrap.dedent( + """ + allow: false + """).strip(), + "YAML", "JSON" + ) +.. ]]] - { - "allow": false - } +.. tab:: YAML + + .. code-block:: yaml + + allow: false + +.. tab:: JSON + + .. code-block:: json + + { + "allow": false + } +.. [[[end]]] An ``"allow"`` of ``true`` allows all access (`demo `__): -.. code-block:: json +.. [[[cog + from metadata_doc import config_example + import textwrap + config_example(cog, textwrap.dedent( + """ + allow: true + """).strip(), + "YAML", "JSON" + ) +.. ]]] - { - "allow": true - } +.. tab:: YAML + + .. code-block:: yaml + + allow: true + +.. tab:: JSON + + .. code-block:: json + + { + "allow": true + } +.. [[[end]]] Allow keys can provide a list of values. These will match any actor that has any of those values (`allow demo `__, `deny demo `__): -.. code-block:: json +.. [[[cog + from metadata_doc import config_example + import textwrap + config_example(cog, textwrap.dedent( + """ + allow: + id: + - simon + - cleopaws + """).strip(), + "YAML", "JSON" + ) +.. ]]] - { - "allow": { - "id": ["simon", "cleopaws"] +.. tab:: YAML + + .. code-block:: yaml + + allow: + id: + - simon + - cleopaws + +.. tab:: JSON + + .. code-block:: json + + { + "allow": { + "id": [ + "simon", + "cleopaws" + ] + } } - } +.. [[[end]]] This will match any actor with an ``"id"`` of either ``"simon"`` or ``"cleopaws"``. @@ -129,53 +220,154 @@ Actors can have properties that feature a list of values. These will be matched .. code-block:: json - { - "id": "simon", - "roles": ["staff", "developer"] - } + { + "id": "simon", + "roles": ["staff", "developer"] + } This allow block will provide access to any actor that has ``"developer"`` as one of their roles (`allow demo `__, `deny demo `__): -.. code-block:: json +.. [[[cog + from metadata_doc import config_example + import textwrap + config_example(cog, textwrap.dedent( + """ + allow: + roles: + - developer + """).strip(), + "YAML", "JSON" + ) +.. ]]] - { - "allow": { - "roles": ["developer"] +.. tab:: YAML + + .. code-block:: yaml + + allow: + roles: + - developer + +.. tab:: JSON + + .. code-block:: json + + { + "allow": { + "roles": [ + "developer" + ] + } } - } +.. [[[end]]] Note that "roles" is not a concept that is baked into Datasette - it's a convention that plugins can choose to implement and act on. If you want to provide access to any actor with a value for a specific key, use ``"*"``. For example, to match any logged-in user specify the following (`allow demo `__, `deny demo `__): -.. code-block:: json +.. [[[cog + from metadata_doc import config_example + import textwrap + config_example(cog, textwrap.dedent( + """ + allow: + id: "*" + """).strip(), + "YAML", "JSON" + ) +.. ]]] - { - "allow": { +.. tab:: YAML + + .. code-block:: yaml + + allow: + id: "*" + +.. tab:: JSON + + .. code-block:: json + + { + "allow": { "id": "*" + } } - } +.. [[[end]]] You can specify that only unauthenticated actors (from anynomous HTTP requests) should be allowed access using the special ``"unauthenticated": true`` key in an allow block (`allow demo `__, `deny demo `__): -.. code-block:: json +.. [[[cog + from metadata_doc import config_example + import textwrap + config_example(cog, textwrap.dedent( + """ + allow: + unauthenticated: true + """).strip(), + "YAML", "JSON" + ) +.. ]]] - { - "allow": { +.. tab:: YAML + + .. code-block:: yaml + + allow: + unauthenticated: true + +.. tab:: JSON + + .. code-block:: json + + { + "allow": { "unauthenticated": true + } } - } +.. [[[end]]] Allow keys act as an "or" mechanism. An actor will be able to execute the query if any of their JSON properties match any of the values in the corresponding lists in the ``allow`` block. The following block will allow users with either a ``role`` of ``"ops"`` OR users who have an ``id`` of ``"simon"`` or ``"cleopaws"``: -.. code-block:: json +.. [[[cog + from metadata_doc import config_example + import textwrap + config_example(cog, textwrap.dedent( + """ + allow: + id: + - simon + - cleopaws + role: ops + """).strip(), + "YAML", "JSON" + ) +.. ]]] - { - "allow": { - "id": ["simon", "cleopaws"], +.. tab:: YAML + + .. code-block:: yaml + + allow: + id: + - simon + - cleopaws + role: ops + +.. tab:: JSON + + .. code-block:: json + + { + "allow": { + "id": [ + "simon", + "cleopaws" + ], "role": "ops" + } } - } +.. [[[end]]] `Demo for cleopaws `__, `demo for ops role `__, `demo for an actor matching neither rule `__. From b466749e88b2ffbd925b6b3e777c8527ebc54e78 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 31 Jan 2024 20:03:19 -0800 Subject: [PATCH 081/474] Filled out docs/configuration.rst, closes #2246 --- docs/changelog.rst | 2 +- docs/configuration.rst | 297 ++++++++++++++++++++++++++++++++++++-- docs/custom_templates.rst | 156 +------------------- docs/metadata_doc.py | 8 +- docs/plugin_hooks.rst | 2 +- docs/plugins.rst | 2 +- 6 files changed, 294 insertions(+), 173 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index af3d2a0b..04ce9583 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -610,7 +610,7 @@ JavaScript modules To use modules, JavaScript needs to be included in `` + +You can also specify a SRI (subresource integrity hash) for these assets: + +.. [[[cog + config_example(cog, """ + 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:: datasette.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:: datasette.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: + +.. code-block:: html + + + + +Modern browsers will only execute the stylesheet or JavaScript if the SRI hash +matches the content served. You can generate hashes using `www.srihash.org `_ + +Items in ``"extra_js_urls"`` can specify ``"module": true`` if they reference JavaScript that uses `JavaScript modules `__. This configuration: + +.. [[[cog + config_example(cog, """ + extra_js_urls: + - url: https://example.datasette.io/module.js + module: true + """) +.. ]]] + +.. tab:: datasette.yaml + + .. code-block:: yaml + + + extra_js_urls: + - url: https://example.datasette.io/module.js + module: true + + +.. tab:: datasette.json + + .. code-block:: json + + { + "extra_js_urls": [ + { + "url": "https://example.datasette.io/module.js", + "module": true + } + ] + } +.. [[[end]]] + +Will produce this HTML: + +.. code-block:: html + + + + + diff --git a/docs/custom_templates.rst b/docs/custom_templates.rst index d8e4ac96..534d8b33 100644 --- a/docs/custom_templates.rst +++ b/docs/custom_templates.rst @@ -5,159 +5,6 @@ Custom pages and templates Datasette provides a number of ways of customizing the way data is displayed. -.. _customization_css_and_javascript: - -Custom CSS and JavaScript -------------------------- - -When you launch Datasette, you can specify a custom configuration file like this:: - - datasette mydb.db --config datasette.yaml - -Your ``datasette.yaml`` file can include links that look like this: - -.. [[[cog - from metadata_doc import config_example - config_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:: datasette.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:: datasette.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: - -.. code-block:: html - - - - -You can also specify a SRI (subresource integrity hash) for these assets: - -.. [[[cog - config_example(cog, """ - 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:: datasette.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:: datasette.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: - -.. code-block:: html - - - - -Modern browsers will only execute the stylesheet or JavaScript if the SRI hash -matches the content served. You can generate hashes using `www.srihash.org `_ - -Items in ``"extra_js_urls"`` can specify ``"module": true`` if they reference JavaScript that uses `JavaScript modules `__. This configuration: - -.. [[[cog - config_example(cog, """ - extra_js_urls: - - url: https://example.datasette.io/module.js - module: true - """) -.. ]]] - -.. tab:: datasette.yaml - - .. code-block:: yaml - - - extra_js_urls: - - url: https://example.datasette.io/module.js - module: true - - -.. tab:: datasette.json - - .. code-block:: json - - { - "extra_js_urls": [ - { - "url": "https://example.datasette.io/module.js", - "module": true - } - ] - } -.. [[[end]]] - -Will produce this HTML: - -.. code-block:: html - - - CSS classes on the ~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -258,9 +105,10 @@ The following URLs will now serve the content from those CSS and JS files:: http://localhost:8001/assets/styles.css http://localhost:8001/assets/app.js -You can reference those files from ``datasette.yaml`` like so: +You can reference those files from ``datasette.yaml`` like this, see :ref:`custom CSS and JavaScript ` for more details: .. [[[cog + from metadata_doc import config_example config_example(cog, """ extra_css_urls: - /assets/styles.css diff --git a/docs/metadata_doc.py b/docs/metadata_doc.py index a8f13414..ad85bf52 100644 --- a/docs/metadata_doc.py +++ b/docs/metadata_doc.py @@ -24,17 +24,19 @@ def metadata_example(cog, data=None, yaml=None): cog.out("\n") -def config_example(cog, input): +def config_example( + cog, input, yaml_title="datasette.yaml", json_title="datasette.json" +): if type(input) is str: data = YAML().load(input) output_yaml = input else: data = input output_yaml = safe_dump(input, sort_keys=False) - cog.out("\n.. tab:: datasette.yaml\n\n") + cog.out("\n.. tab:: {}\n\n".format(yaml_title)) cog.out(" .. code-block:: yaml\n\n") cog.out(textwrap.indent(output_yaml, " ")) - cog.out("\n\n.. tab:: datasette.json\n\n") + cog.out("\n\n.. tab:: {}\n\n".format(json_title)) cog.out(" .. code-block:: json\n\n") cog.out(textwrap.indent(json.dumps(data, indent=2), " ")) cog.out("\n") diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 1a88cd31..d9d135e5 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -270,7 +270,7 @@ you have one: Note that ``your-plugin`` here should be the hyphenated plugin name - the name that is displayed in the list on the ``/-/plugins`` debug page. -If your code uses `JavaScript modules `__ you should include the ``"module": True`` key. See :ref:`customization_css_and_javascript` for more details. +If your code uses `JavaScript modules `__ you should include the ``"module": True`` key. See :ref:`configuration_reference_css_js` for more details. .. code-block:: python diff --git a/docs/plugins.rst b/docs/plugins.rst index 1a72af95..03ddf8f0 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -328,7 +328,7 @@ To write that to a ``requirements.txt`` file, run this:: Plugin configuration -------------------- -Plugins can have their own configuration, embedded in a :ref:`configuration` file. Configuration options for plugins live within a ``"plugins"`` key in that file, which can be included at the root, database or table level. +Plugins can have their own configuration, embedded in a :ref:`configuration file `. Configuration options for plugins live within a ``"plugins"`` key in that file, which can be included at the root, database or table level. Here is an example of some plugin configuration for a specific table: From 4da581d09bbed2377682630c147e05d78c48f7e0 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 1 Feb 2024 14:40:49 -0800 Subject: [PATCH 082/474] Link to config reference --- docs/settings.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/settings.rst b/docs/settings.rst index 1d4baf90..d1553703 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -15,6 +15,8 @@ You can set multiple settings at once like this:: --setting sql_time_limit_ms 3500 \ --setting max_returned_rows 2000 +Settings can also be specified :ref:`in the database.yaml configuration file `. + .. _config_dir: Configuration directory mode From d4bc2b2dfc728017c8f669c1714f20b89655557c Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 1 Feb 2024 14:44:16 -0800 Subject: [PATCH 083/474] Remove fail_if_plugins_in_metadata, part of #2248 --- datasette/app.py | 8 ++------ datasette/cli.py | 3 +-- datasette/utils/__init__.py | 15 --------------- tests/test_plugins.py | 8 -------- 4 files changed, 3 insertions(+), 31 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 530f79bc..0143223a 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -77,7 +77,6 @@ from .utils import ( parse_metadata, resolve_env_secrets, resolve_routes, - fail_if_plugins_in_metadata, tilde_decode, to_css_class, urlsafe_components, @@ -336,16 +335,13 @@ class Datasette: ] if config_dir and metadata_files and not metadata: with metadata_files[0].open() as fp: - metadata = fail_if_plugins_in_metadata( - parse_metadata(fp.read()), metadata_files[0].name - ) + metadata = parse_metadata(fp.read()) if config_dir and config_files and not config: with config_files[0].open() as fp: config = parse_metadata(fp.read()) - self._metadata_local = fail_if_plugins_in_metadata(metadata or {}) - + self._metadata_local = metadata or {} self.sqlite_extensions = [] for extension in sqlite_extensions or []: # Resolve spatialite, if requested diff --git a/datasette/cli.py b/datasette/cli.py index 91f38f69..1a5a8af3 100644 --- a/datasette/cli.py +++ b/datasette/cli.py @@ -33,7 +33,6 @@ from .utils import ( initial_path_for_datasette, pairs_to_nested_config, temporary_docker_directory, - fail_if_plugins_in_metadata, value_as_boolean, SpatialiteNotFound, StaticMount, @@ -543,7 +542,7 @@ def serve( metadata_data = None if metadata: - metadata_data = fail_if_plugins_in_metadata(parse_metadata(metadata.read())) + metadata_data = parse_metadata(metadata.read()) config_data = None if config: diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py index 196e1682..75f1c2f4 100644 --- a/datasette/utils/__init__.py +++ b/datasette/utils/__init__.py @@ -1272,21 +1272,6 @@ def pairs_to_nested_config(pairs: typing.List[typing.Tuple[str, typing.Any]]) -> return result -def fail_if_plugins_in_metadata(metadata: dict, filename=None): - """If plugin config is inside metadata, raise an Exception""" - if metadata is not None and metadata.get("plugins") is not None: - suggested_extension = ( - ".yaml" - if filename is not None - and (filename.endswith(".yaml") or filename.endswith(".yml")) - else ".json" - ) - raise Exception( - f'Datasette no longer accepts plugin configuration in --metadata. Move your "plugins" configuration blocks to a separate file - we suggest calling that datasette.{suggested_extension} - and start Datasette with datasette -c datasette.{suggested_extension}. See https://docs.datasette.io/en/latest/configuration.html for more details.' - ) - return metadata - - def make_slot_function(name, datasette, request, **kwargs): from datasette.plugins import pm diff --git a/tests/test_plugins.py b/tests/test_plugins.py index dad4f2ca..f26e3652 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -883,14 +883,6 @@ def test_hook_forbidden(restore_working_directory): ) -def test_plugin_config_in_metadata(): - with pytest.raises( - Exception, - match="Datasette no longer accepts plugin configuration in --metadata", - ): - Datasette(memory=True, metadata={"plugins": {}}) - - @pytest.mark.asyncio async def test_hook_handle_exception(ds_client): await ds_client.get("/trigger-error?x=123") From be4f02335fb35d40a763d07b2d4e880b90083e53 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 1 Feb 2024 15:33:33 -0800 Subject: [PATCH 084/474] Treat plugins in metadata as if they were in config, closes #2248 --- datasette/app.py | 6 +++++ datasette/utils/__init__.py | 40 +++++++++++++++++++++++++++++ tests/test_plugins.py | 51 +++++++++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+) diff --git a/datasette/app.py b/datasette/app.py index 0143223a..634283ff 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -74,6 +74,7 @@ from .utils import ( find_spatialite, format_bytes, module_from_path, + move_plugins, parse_metadata, resolve_env_secrets, resolve_routes, @@ -341,6 +342,11 @@ class Datasette: with config_files[0].open() as fp: config = parse_metadata(fp.read()) + # Move any "plugins" settings from metadata to config - updates them in place + metadata = metadata or {} + config = config or {} + move_plugins(metadata, config) + self._metadata_local = metadata or {} self.sqlite_extensions = [] for extension in sqlite_extensions or []: diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py index 75f1c2f4..cc175b01 100644 --- a/datasette/utils/__init__.py +++ b/datasette/utils/__init__.py @@ -1287,3 +1287,43 @@ def make_slot_function(name, datasette, request, **kwargs): return markupsafe.Markup("".join(html_bits)) return inner + + +def move_plugins(source, destination): + """ + Move 'plugins' keys from source to destination dictionary. Creates hierarchy in destination if needed. + After moving, recursively remove any keys in the source that are left empty. + """ + + def recursive_move(src, dest, path=None): + if path is None: + path = [] + for key, value in list(src.items()): + new_path = path + [key] + if key == "plugins": + # Navigate and create the hierarchy in destination if needed + d = dest + for step in path: + d = d.setdefault(step, {}) + # Move the plugins + d[key] = value + # Remove the plugins from source + src.pop(key, None) + elif isinstance(value, dict): + recursive_move(value, dest, new_path) + # After moving, check if the current dictionary is empty and remove it if so + if not value: + src.pop(key, None) + + def prune_empty_dicts(d): + """ + Recursively prune all empty dictionaries from a given dictionary. + """ + for key, value in list(d.items()): + if isinstance(value, dict): + prune_empty_dicts(value) + if value == {}: + d.pop(key, None) + + recursive_move(source, destination) + prune_empty_dicts(source) diff --git a/tests/test_plugins.py b/tests/test_plugins.py index f26e3652..a53fc118 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -1458,3 +1458,54 @@ async def test_hook_register_events(): datasette = Datasette(memory=True) await datasette.invoke_startup() assert any(k.__name__ == "OneEvent" for k in datasette.event_classes) + + +@pytest.mark.parametrize( + "metadata,config,expected_metadata,expected_config", + ( + ( + # Instance level + {"plugins": {"datasette-foo": "bar"}}, + {}, + {}, + {"plugins": {"datasette-foo": "bar"}}, + ), + ( + # Database level + {"databases": {"foo": {"plugins": {"datasette-foo": "bar"}}}}, + {}, + {}, + {"databases": {"foo": {"plugins": {"datasette-foo": "bar"}}}}, + ), + ( + # Table level + { + "databases": { + "foo": {"tables": {"bar": {"plugins": {"datasette-foo": "bar"}}}} + } + }, + {}, + {}, + { + "databases": { + "foo": {"tables": {"bar": {"plugins": {"datasette-foo": "bar"}}}} + } + }, + ), + ( + # Keep other keys + {"plugins": {"datasette-foo": "bar"}, "other": "key"}, + {"original_config": "original"}, + {"other": "key"}, + {"original_config": "original", "plugins": {"datasette-foo": "bar"}}, + ), + ), +) +def test_metadata_plugin_config_treated_as_config( + metadata, config, expected_metadata, expected_config +): + ds = Datasette(metadata=metadata, config=config) + actual_metadata = ds.metadata() + assert "plugins" not in actual_metadata + assert actual_metadata == expected_metadata + assert ds.config == expected_config From 6ccef35cc92cc2357c2b2a9aa003b7334b2459eb Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 1 Feb 2024 15:42:45 -0800 Subject: [PATCH 085/474] More links between events documentation --- docs/events.rst | 2 +- docs/plugin_hooks.rst | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/events.rst b/docs/events.rst index f150ac02..b86c8025 100644 --- a/docs/events.rst +++ b/docs/events.rst @@ -7,7 +7,7 @@ Datasette includes a mechanism for tracking events that occur while the software The core Datasette application triggers events when certain things happen. This page describes those events. -Plugins can listen for events using the :ref:`plugin_hook_track_event` plugin hook, which will be called with instances of the following classes (or additional classes registered by other plugins): +Plugins can listen for events using the :ref:`plugin_hook_track_event` plugin hook, which will be called with instances of the following classes - or additional classes :ref:`registered by other plugins `. .. automodule:: datasette.events :members: diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index d9d135e5..16f5cebb 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -1769,7 +1769,7 @@ Datasette includes an internal mechanism for tracking analytical events. This ca Plugins can register to receive events using the ``track_event`` plugin hook. -They can also define their own events for other plugins to receive using the ``register_events`` plugin hook, combined with calls to the ``datasette.track_event(...)`` internal method. +They can also define their own events for other plugins to receive using the :ref:`register_events() plugin hook `, combined with calls to the :ref:`datasette.track_event() internal method `. .. _plugin_hook_track_event: @@ -1826,7 +1826,7 @@ register_events(datasette) ``datasette`` - :ref:`internals_datasette` You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``. -This hook should return a list of ``Event`` subclasses that represent custom events that the plugin might send to the ``datasette.track_event()`` method. +This hook should return a list of ``Event`` subclasses that represent custom events that the plugin might send to the :ref:`datasette.track_event() ` method. This example registers event subclasses for ``ban-user`` and ``unban-user`` events: From 4ea109ac4dd17392c85ca7d5934009f9a9488a9d Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 1 Feb 2024 15:47:41 -0800 Subject: [PATCH 086/474] Two spaces is aesthetically more pleasing here --- docs/settings.rst | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/settings.rst b/docs/settings.rst index d1553703..c4b4ba82 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -11,9 +11,9 @@ Datasette supports a number of settings. These can be set using the ``--setting You can set multiple settings at once like this:: datasette mydatabase.db \ - --setting default_page_size 50 \ - --setting sql_time_limit_ms 3500 \ - --setting max_returned_rows 2000 + --setting default_page_size 50 \ + --setting sql_time_limit_ms 3500 \ + --setting max_returned_rows 2000 Settings can also be specified :ref:`in the database.yaml configuration file `. @@ -25,10 +25,10 @@ Configuration directory mode Normally you configure Datasette using command-line options. For a Datasette instance with custom templates, custom plugins, a static directory and several databases this can get quite verbose:: datasette one.db two.db \ - --metadata=metadata.json \ - --template-dir=templates/ \ - --plugins-dir=plugins \ - --static css:css + --metadata=metadata.json \ + --template-dir=templates/ \ + --plugins-dir=plugins \ + --static css:css As an alternative to this, you can run Datasette in *configuration directory* mode. Create a directory with the following structure:: From 5ea7098e4da5fe8576f6452dbfac86e6aedba397 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 4 Feb 2024 10:15:21 -0800 Subject: [PATCH 087/474] Fixed an unnecessary f-string --- demos/plugins/example_js_manager_plugins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/demos/plugins/example_js_manager_plugins.py b/demos/plugins/example_js_manager_plugins.py index 7db45464..2705f2c5 100644 --- a/demos/plugins/example_js_manager_plugins.py +++ b/demos/plugins/example_js_manager_plugins.py @@ -16,6 +16,6 @@ def extra_js_urls(view_name): if view_name in PERMITTED_VIEWS: return [ { - "url": f"/static/table-example-plugins.js", + "url": "/static/table-example-plugins.js", } ] From 7219a56d1e8b5d076037aeeec2583ad4fc3cacb3 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 5 Feb 2024 10:34:10 -0800 Subject: [PATCH 088/474] 3 space indent, not 2 --- docs/configuration.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/configuration.rst b/docs/configuration.rst index a835ace9..79e2a1ca 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -145,8 +145,8 @@ Settings """ # inside datasette.yaml settings: - default_allow_sql: off - default_page_size: 50 + default_allow_sql: off + default_page_size: 50 """).strip() ) .. ]]] @@ -157,8 +157,8 @@ Settings # inside datasette.yaml settings: - default_allow_sql: off - default_page_size: 50 + default_allow_sql: off + default_page_size: 50 .. tab:: datasette.json From 503545b20363ac15d3664bec7e6c4522ff271668 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 5 Feb 2024 11:47:17 -0800 Subject: [PATCH 089/474] JavaScript plugins documentation, closes #2250 --- docs/index.rst | 1 + docs/javascript_plugins.rst | 159 ++++++++++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 docs/javascript_plugins.rst diff --git a/docs/index.rst b/docs/index.rst index ce1ed2eb..e3036618 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -60,6 +60,7 @@ Contents custom_templates plugins writing_plugins + javascript_plugins plugin_hooks testing_plugins internals diff --git a/docs/javascript_plugins.rst b/docs/javascript_plugins.rst new file mode 100644 index 00000000..e7ee6817 --- /dev/null +++ b/docs/javascript_plugins.rst @@ -0,0 +1,159 @@ +.. _javascript_plugins: + +JavaScript plugins +================== + +Datasette can run custom JavaScript in several different ways: + +- Datasette plugins written in Python can use the :ref:`extra_js_urls() ` or :ref:`extra_body_script() ` plugin hooks to inject JavaScript into a page +- Datasette instances with :ref:`custom templates ` can include additional JavaScript in those templates +- The ``extra_js_urls`` key in ``datasette.yaml`` :ref:`can be used to include extra JavaScript ` + +There are no limitations on what this JavaScript can do. It is executed directly by the browser, so it can manipulate the DOM, fetch additional data and do anything else that JavaScript is capable of. + +.. warning:: + Custom JavaScript has security implications, especially for authenticated Datasette instances where the JavaScript might run in the context of the authenticated user. It's important to carefully review any JavaScript you run in your Datasette instance. + +.. _javascript_datasette_init: + +The datasette_init event +------------------------ + +Datasette emits a custom event called ``datasette_init`` when the page is loaded. This event is dispatched on the ``document`` object, and includes a ``detail`` object with a reference to the :ref:`datasetteManager ` object. + +Your JavaScript code can listen out for this event using ``document.addEventListener()`` like this: + +.. code-block:: javascript + + document.addEventListener("datasette_init", function (evt) { + const manager = evt.detail; + console.log("Datasette version:", manager.VERSION); + }); + +.. _javascript_datasette_manager: + +datasetteManager +---------------- + +The ``datasetteManager`` object + +``VERSION`` - string + The version of Datasette + +``plugins`` - ``Map()`` + A Map of currently loaded plugin names to plugin implementations + +``registerPlugin(name, implementation)`` + Call this to register a plugin, passing its name and implementation + +``selectors`` - object + An object providing named aliases to useful CSS selectors, :ref:`listed below ` + +.. _javascript_plugin_objects: + +JavaScript plugin objects +------------------------- + +JavaScript plugins are blocks of code that can be registered with Datasette using the ``registerPlugin()`` method on the :ref:`datasetteManager ` object. + +The ``implementation`` object passed to this method should include a ``version`` key defining the plugin version, and one or more of the following named functions providing the implementation of the plugin: + +.. _javascript_plugins_makeAboveTablePanelConfigs: + +makeAboveTablePanelConfigs() +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This method should return a JavaScript array of objects defining additional panels to be added to the top of the table page. Each object should have the following: + +``id`` - string + A unique string ID for the panel, for example ``map-panel`` +``label`` - string + A human-readable label for the panel +``render(node)`` - function + A function that will be called with a DOM node to render the panel into + +This example shows how a plugin might define a single panel: + +.. code-block:: javascript + + document.addEventListener('datasette_init', function(ev) { + ev.detail.registerPlugin('panel-plugin', { + version: 0.1, + makeAboveTablePanelConfigs: () => { + return [ + { + id: 'first-panel', + label: 'First panel', + render: node => { + node.innerHTML = '

My custom panel

This is a custom panel that I added using a JavaScript plugin

'; + } + } + ] + } + }); + }); + +When a page with a table loads, all registered plugins that implement ``makeAboveTablePanelConfigs()`` will be called and panels they return will be added to the top of the table page. + +.. _javascript_plugins_makeColumnActions: + +makeColumnActions(columnDetails) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This method, if present, will be called when Datasette is rendering the cog action menu icons that appear at the top of the table view. By default these include options like "Sort ascending/descending" and "Facet by this", but plugins can return additional actions to be included in this menu. + +The method will be called with a ``columnDetails`` object with the following keys: + +``columnName`` - string + The name of the column +``columnNotNull`` - boolean + True if the column is defined as NOT NULL +``columnType`` - string + The SQLite data type of the column +``isPk`` - boolean + True if the column is part of the primary key + +It should return a JavaScript array of objects each with a ``label`` and ``onClick`` property: + +``label`` - string + The human-readable label for the action +``onClick(evt)`` - function + A function that will be called when the action is clicked + +The ``evt`` object passed to the ``onClick`` is the standard browser event object that triggered the click. + +This example plugin adds two menu items - one to copy the column name to the clipboard and another that displays the column metadata in an ``alert()`` window: + +.. code-block:: javascript + + document.addEventListener('datasette_init', function(ev) { + ev.detail.registerPlugin('column-name-plugin', { + version: 0.1, + makeColumnActions: (columnDetails) => { + return [ + { + label: 'Copy column to clipboard', + onClick: async (evt) => { + await navigator.clipboard.writeText(columnDetails.columnName) + } + }, + { + label: 'Alert column metadata', + onClick: () => alert(JSON.stringify(columnDetails, null, 2)) + } + ]; + } + }); + }); + +.. _javascript_datasette_manager_selectors: + +Selectors +--------- + +These are available on the ``selectors`` property of the :ref:`javascript_datasette_manager` object. + +.. literalinclude:: ../datasette/static/datasette-manager.js + :language: javascript + :start-at: const DOM_SELECTORS = { + :end-at: }; From efc73575548b0bbaca4bdc8de40fc6939bb88428 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 5 Feb 2024 13:01:03 -0800 Subject: [PATCH 090/474] Remove Using YAML for metadata section No longer necessary now we show YAML and JSON examples everywhere. --- docs/changelog.rst | 2 +- docs/metadata.rst | 29 +---------------------------- 2 files changed, 2 insertions(+), 29 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 04ce9583..f4b928e3 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1190,7 +1190,7 @@ Also in this release: 0.40 (2020-04-21) ----------------- -* Datasette :ref:`metadata` can now be provided as a YAML file as an optional alternative to JSON. See :ref:`metadata_yaml`. (:issue:`713`) +* Datasette :ref:`metadata` can now be provided as a YAML file as an optional alternative to JSON. (:issue:`713`) * Removed support for ``datasette publish now``, which used the the now-retired Zeit Now v1 hosting platform. A new plugin, `datasette-publish-now `__, can be installed to publish data to Zeit (`now Vercel `__) Now v2. (:issue:`710`) * Fixed a bug where the ``extra_template_vars(request, view_name)`` plugin hook was not receiving the correct ``view_name``. (:issue:`716`) * Variables added to the template context by the ``extra_template_vars()`` plugin hook are now shown in the ``?_context=1`` debugging mode (see :ref:`setting_template_debug`). (:issue:`693`) diff --git a/docs/metadata.rst b/docs/metadata.rst index b4dc90f9..f3ca68ac 100644 --- a/docs/metadata.rst +++ b/docs/metadata.rst @@ -53,7 +53,7 @@ Your ``metadata.yaml`` file can look something like this: .. [[[end]]] -Choosing YAML over JSON adds support for multi-line strings and comments, see :ref:`metadata_yaml`. +Choosing YAML over JSON adds support for multi-line strings and comments. 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 @@ -664,33 +664,6 @@ SpatiaLite tables are automatically hidden) using ``"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, plus inline comments. - -Here's an example of a ``metadata.yml`` file, re-using an example from :ref:`canned_queries`. - -.. code-block:: yaml - - title: Demonstrating Metadata from YAML - description_html: |- -

This description includes a long HTML string

-
    -
  • YAML is better for embedding HTML strings than JSON!
  • -
- license: ODbL - license_url: https://opendatacommons.org/licenses/odbl/ - databases: - fixtures: - tables: - no_primary_key: - hidden: true - - .. _metadata_reference: Metadata reference From 85a1dfe6e07fcdd7ec8f83cb5b3a8f023659d064 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 5 Feb 2024 13:43:50 -0800 Subject: [PATCH 091/474] Configuration via the command-line section Closes #2252 Closes #2156 --- docs/configuration.rst | 78 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 76 insertions(+), 2 deletions(-) diff --git a/docs/configuration.rst b/docs/configuration.rst index 79e2a1ca..425024da 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -5,7 +5,7 @@ Configuration Datasette offers several ways to configure your Datasette instances: server settings, plugin configuration, authentication, and more. -Most configuration can be handled using a ``datasette.yaml`` configuration file, passed to datasette using the ``--config``/ ``-c`` flag: +Most configuration can be handled using a ``datasette.yaml`` configuration file, passed to datasette using the ``-c/--config`` flag: .. code-block:: bash @@ -13,12 +13,86 @@ Most configuration can be handled using a ``datasette.yaml`` configuration file, This file can also use JSON, as ``datasette.json``. YAML is recommended over JSON due to its support for comments and multi-line strings. +.. _configuration_cli: + +Configuration via the command-line +---------------------------------- + +The recommended way to configure Datasette is using a ``datasette.yaml`` file passed to ``-c/--config``. You can also pass individual settings to Datasette using the ``-s/--setting`` option, which can be used multiple times: + +.. code-block:: bash + + datasette mydatabase.db \ + --setting settings.default_page_size 50 \ + --setting settings.sql_time_limit_ms 3500 + +This option takes dotted-notation for the first argument and a value for the second argument. This means you can use it to set any configuration value that would be valid in a ``datasette.yaml`` file. + +It also works for plugin configuration, for example for `datasette-cluster-map `_: + +.. code-block:: bash + + datasette mydatabase.db \ + --setting plugins.datasette-cluster-map.latitude_column xlat \ + --setting plugins.datasette-cluster-map.longitude_column xlon + +If the value you provide is a valid JSON object or list it will be treated as nested data, allowing you to configure plugins that accept lists such as `datasette-proxy-url `_: + +.. code-block:: bash + + datasette mydatabase.db \ + -s plugins.datasette-proxy-url.paths '[{"path": "/proxy", "backend": "http://example.com/"}]' + +This is equivalent to a ``datasette.yaml`` file containing the following: + +.. [[[cog + from metadata_doc import config_example + import textwrap + config_example(cog, textwrap.dedent( + """ + plugins: + datasette-proxy-url: + paths: + - path: /proxy + backend: http://example.com/ + """).strip() + ) +.. ]]] + +.. tab:: datasette.yaml + + .. code-block:: yaml + + plugins: + datasette-proxy-url: + paths: + - path: /proxy + backend: http://example.com/ + +.. tab:: datasette.json + + .. code-block:: json + + { + "plugins": { + "datasette-proxy-url": { + "paths": [ + { + "path": "/proxy", + "backend": "http://example.com/" + } + ] + } + } + } +.. [[[end]]] + .. _configuration_reference: ``datasette.yaml`` reference ---------------------------- -This example shows many of the valid configuration options that can exist inside ``datasette.yaml``. +The following example shows some of the valid configuration options that can exist inside ``datasette.yaml``. .. [[[cog from metadata_doc import config_example From 1e901aa690211db36f02cc1b25246d0f56cd8720 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 6 Feb 2024 12:33:46 -0800 Subject: [PATCH 092/474] /-/config page, closes #2254 --- datasette/app.py | 14 ++++++----- datasette/utils/__init__.py | 28 +++++++++++++++++++++ datasette/views/special.py | 4 +-- docs/introspection.rst | 11 +++++++- tests/test_api.py | 50 ++++++++++++++++++++++++++----------- 5 files changed, 84 insertions(+), 23 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 634283ff..2e20d402 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -81,6 +81,7 @@ from .utils import ( tilde_decode, to_css_class, urlsafe_components, + redact_keys, row_sql_params_pks, ) from .utils.asgi import ( @@ -1374,6 +1375,11 @@ class Datasette: output.append(script) return output + def _config(self): + return redact_keys( + self.config, ("secret", "key", "password", "token", "hash", "dsn") + ) + def _routes(self): routes = [] @@ -1433,12 +1439,8 @@ class Datasette: r"/-/settings(\.(?Pjson))?$", ) add_route( - permanent_redirect("/-/settings.json"), - r"/-/config.json", - ) - add_route( - permanent_redirect("/-/settings"), - r"/-/config", + JsonDataView.as_view(self, "config.json", lambda: self._config()), + r"/-/config(\.(?Pjson))?$", ) add_route( JsonDataView.as_view(self, "threads.json", self._threads), diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py index cc175b01..4c940645 100644 --- a/datasette/utils/__init__.py +++ b/datasette/utils/__init__.py @@ -17,6 +17,7 @@ import time import types import secrets import shutil +from typing import Iterable import urllib import yaml from .shutil_backport import copytree @@ -1327,3 +1328,30 @@ def move_plugins(source, destination): recursive_move(source, destination) prune_empty_dicts(source) + + +def redact_keys(original: dict, key_patterns: Iterable) -> dict: + """ + Recursively redact sensitive keys in a dictionary based on given patterns + + :param original: The original dictionary + :param key_patterns: A list of substring patterns to redact + :return: A copy of the original dictionary with sensitive values redacted + """ + + def redact(data): + if isinstance(data, dict): + return { + k: ( + redact(v) + if not any(pattern in k for pattern in key_patterns) + else "***" + ) + for k, v in data.items() + } + elif isinstance(data, list): + return [redact(item) for item in data] + else: + return data + + return redact(original) diff --git a/datasette/views/special.py b/datasette/views/special.py index 4088a1f9..296652d0 100644 --- a/datasette/views/special.py +++ b/datasette/views/special.py @@ -42,7 +42,7 @@ class JsonDataView(BaseView): if self.ds.cors: add_cors_headers(headers) return Response( - json.dumps(data), + json.dumps(data, default=repr), content_type="application/json; charset=utf-8", headers=headers, ) @@ -53,7 +53,7 @@ class JsonDataView(BaseView): request=request, context={ "filename": self.filename, - "data_json": json.dumps(data, indent=4), + "data_json": json.dumps(data, indent=4, default=repr), }, ) diff --git a/docs/introspection.rst b/docs/introspection.rst index e08ca911..b62197ea 100644 --- a/docs/introspection.rst +++ b/docs/introspection.rst @@ -87,7 +87,7 @@ Shows a list of currently installed plugins and their versions. `Plugins example Add ``?all=1`` to include details of the default plugins baked into Datasette. -.. _JsonDataView_config: +.. _JsonDataView_settings: /-/settings ----------- @@ -105,6 +105,15 @@ Shows the :ref:`settings` for this instance of Datasette. `Settings example ` for this instance of Datasette. This is generally the contents of the :ref:`datasette.yaml or datasette.json ` file, which can include plugin configuration as well. + +Any keys that include the one of the following substrings in their names will be returned as redacted ``***`` output, to help avoid accidentally leaking private configuration information: ``secret``, ``key``, ``password``, ``token``, ``hash``, ``dsn``. + .. _JsonDataView_databases: /-/databases diff --git a/tests/test_api.py b/tests/test_api.py index 177dc95c..0a1f3725 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -846,20 +846,6 @@ async def test_settings_json(ds_client): } -@pytest.mark.asyncio -@pytest.mark.parametrize( - "path,expected_redirect", - ( - ("/-/config.json", "/-/settings.json"), - ("/-/config", "/-/settings"), - ), -) -async def test_config_redirects_to_settings(ds_client, path, expected_redirect): - response = await ds_client.get(path) - assert response.status_code == 301 - assert response.headers["Location"] == expected_redirect - - test_json_columns_default_expected = [ {"intval": 1, "strval": "s", "floatval": 0.5, "jsonval": '{"foo": "bar"}'} ] @@ -1039,3 +1025,39 @@ async def test_tilde_encoded_database_names(db_name): # And the JSON for that database response2 = await ds.client.get(path + ".json") assert response2.status_code == 200 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "config,expected", + ( + ({}, {}), + ({"plugins": {"datasette-foo": "bar"}}, {"plugins": {"datasette-foo": "bar"}}), + # Test redaction + ( + { + "plugins": { + "datasette-auth": {"secret_key": "key"}, + "datasette-foo": "bar", + "datasette-auth2": {"password": "password"}, + "datasette-sentry": { + "dsn": "sentry:///foo", + }, + } + }, + { + "plugins": { + "datasette-auth": {"secret_key": "***"}, + "datasette-foo": "bar", + "datasette-auth2": {"password": "***"}, + "datasette-sentry": {"dsn": "***"}, + } + }, + ), + ), +) +async def test_config_json(config, expected): + "/-/config.json should return redacted configuration" + ds = Datasette(config=config) + response = await ds.client.get("/-/config.json") + assert response.json() == expected From 5a63ecc5577d070f28bf5daa34aaaf3dadfd2e4d Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 6 Feb 2024 15:03:19 -0800 Subject: [PATCH 093/474] Rename metadata= to table_config= in facet code, refs #2247 --- datasette/facets.py | 41 ++++++++++++++++++++-------------------- datasette/views/table.py | 2 +- tests/test_facets.py | 4 ++-- 3 files changed, 23 insertions(+), 24 deletions(-) diff --git a/datasette/facets.py b/datasette/facets.py index b23615fe..f1cfc68f 100644 --- a/datasette/facets.py +++ b/datasette/facets.py @@ -11,8 +11,8 @@ from datasette.utils import ( ) -def load_facet_configs(request, table_metadata): - # Given a request and the metadata configuration for a table, return +def load_facet_configs(request, table_config): + # Given a request and the configuration for a table, return # a dictionary of selected facets, their lists of configs and for each # config whether it came from the request or the metadata. # @@ -20,21 +20,21 @@ def load_facet_configs(request, table_metadata): # {"source": "metadata", "config": config1}, # {"source": "request", "config": config2}]} facet_configs = {} - table_metadata = table_metadata or {} - metadata_facets = table_metadata.get("facets", []) - for metadata_config in metadata_facets: - if isinstance(metadata_config, str): + table_config = table_config or {} + table_facet_configs = table_config.get("facets", []) + for facet_config in table_facet_configs: + if isinstance(facet_config, str): type = "column" - metadata_config = {"simple": metadata_config} + facet_config = {"simple": facet_config} else: assert ( - len(metadata_config.values()) == 1 + len(facet_config.values()) == 1 ), "Metadata config dicts should be {type: config}" - type, metadata_config = list(metadata_config.items())[0] - if isinstance(metadata_config, str): - metadata_config = {"simple": metadata_config} + type, facet_config = list(facet_config.items())[0] + if isinstance(facet_config, str): + facet_config = {"simple": facet_config} facet_configs.setdefault(type, []).append( - {"source": "metadata", "config": metadata_config} + {"source": "metadata", "config": facet_config} ) qs_pairs = urllib.parse.parse_qs(request.query_string, keep_blank_values=True) for key, values in qs_pairs.items(): @@ -45,13 +45,12 @@ def load_facet_configs(request, table_metadata): elif key.startswith("_facet_"): type = key[len("_facet_") :] for value in values: - # The value is the config - either JSON or not - if value.startswith("{"): - config = json.loads(value) - else: - config = {"simple": value} + # The value is the facet_config - either JSON or not + facet_config = ( + json.loads(value) if value.startswith("{") else {"simple": value} + ) facet_configs.setdefault(type, []).append( - {"source": "request", "config": config} + {"source": "request", "config": facet_config} ) return facet_configs @@ -75,7 +74,7 @@ class Facet: sql=None, table=None, params=None, - metadata=None, + table_config=None, row_count=None, ): assert table or sql, "Must provide either table= or sql=" @@ -86,12 +85,12 @@ class Facet: self.table = table self.sql = sql or f"select * from [{table}]" self.params = params or [] - self.metadata = metadata + self.table_config = table_config # row_count can be None, in which case we calculate it ourselves: self.row_count = row_count def get_configs(self): - configs = load_facet_configs(self.request, self.metadata) + configs = load_facet_configs(self.request, self.table_config) return configs.get(self.type) or [] def get_querystring_pairs(self): diff --git a/datasette/views/table.py b/datasette/views/table.py index 3b812c01..22722847 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -1275,7 +1275,7 @@ async def table_view_data( sql=sql_no_order_no_limit, params=params, table=table_name, - metadata=table_metadata, + table_config=table_metadata, row_count=extra_count, ) ) diff --git a/tests/test_facets.py b/tests/test_facets.py index 85c8f85b..76344108 100644 --- a/tests/test_facets.py +++ b/tests/test_facets.py @@ -82,7 +82,7 @@ async def test_column_facet_suggest_skip_if_enabled_by_metadata(ds_client): database="fixtures", sql="select * from facetable", table="facetable", - metadata={"facets": ["_city_id"]}, + table_config={"facets": ["_city_id"]}, ) suggestions = [s["name"] for s in await facet.suggest()] assert [ @@ -278,7 +278,7 @@ async def test_column_facet_from_metadata_cannot_be_hidden(ds_client): database="fixtures", sql="select * from facetable", table="facetable", - metadata={"facets": ["_city_id"]}, + table_config={"facets": ["_city_id"]}, ) buckets, timed_out = await facet.facet_results() assert [] == timed_out From 5d21057cf1e4171bc2741d3d8d9da83aee1165b5 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 6 Feb 2024 15:22:03 -0800 Subject: [PATCH 094/474] /-/config example, refs #2254 --- docs/introspection.rst | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/introspection.rst b/docs/introspection.rst index b62197ea..ff78ec78 100644 --- a/docs/introspection.rst +++ b/docs/introspection.rst @@ -110,7 +110,17 @@ Shows the :ref:`settings` for this instance of Datasette. `Settings example ` for this instance of Datasette. This is generally the contents of the :ref:`datasette.yaml or datasette.json ` file, which can include plugin configuration as well. +Shows the :ref:`configuration ` for this instance of Datasette. This is generally the contents of the :ref:`datasette.yaml or datasette.json ` file, which can include plugin configuration as well. `Config example `_: + +.. code-block:: json + + { + "settings": { + "template_debug": true, + "trace_debug": true, + "force_https_urls": true + } + } Any keys that include the one of the following substrings in their names will be returned as redacted ``***`` output, to help avoid accidentally leaking private configuration information: ``secret``, ``key``, ``password``, ``token``, ``hash``, ``dsn``. From 69c6e953231078ab18ba0807e5fe2a4e20e84093 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 6 Feb 2024 17:27:20 -0800 Subject: [PATCH 095/474] Fixed a bunch of unused imports spotted with ruff --- datasette/cli.py | 1 - datasette/forbidden.py | 1 - datasette/handle_exception.py | 4 +--- datasette/permissions.py | 2 +- datasette/url_builder.py | 2 +- datasette/views/base.py | 1 - datasette/views/index.py | 3 --- ruff.toml | 1 + tests/conftest.py | 2 +- tests/test_black.py | 2 -- tests/test_cli.py | 2 -- tests/test_cli_serve_get.py | 2 +- tests/test_config_dir.py | 2 -- tests/test_internals_datasette.py | 1 - tests/test_plugins.py | 7 ++----- tests/test_table_html.py | 2 +- 16 files changed, 9 insertions(+), 26 deletions(-) create mode 100644 ruff.toml diff --git a/datasette/cli.py b/datasette/cli.py index 1a5a8af3..0c8a8541 100644 --- a/datasette/cli.py +++ b/datasette/cli.py @@ -15,7 +15,6 @@ import sys import textwrap import webbrowser from .app import ( - OBSOLETE_SETTINGS, Datasette, DEFAULT_SETTINGS, SETTINGS, diff --git a/datasette/forbidden.py b/datasette/forbidden.py index 156a44d4..41c48396 100644 --- a/datasette/forbidden.py +++ b/datasette/forbidden.py @@ -1,4 +1,3 @@ -from os import stat from datasette import hookimpl, Response diff --git a/datasette/handle_exception.py b/datasette/handle_exception.py index bef6b4ee..1a0ac979 100644 --- a/datasette/handle_exception.py +++ b/datasette/handle_exception.py @@ -1,14 +1,12 @@ from datasette import hookimpl, Response -from .utils import await_me_maybe, add_cors_headers +from .utils import add_cors_headers from .utils.asgi import ( Base400, - Forbidden, ) from .views.base import DatasetteError from markupsafe import Markup import pdb import traceback -from .plugins import pm try: import rich diff --git a/datasette/permissions.py b/datasette/permissions.py index 152f1721..bd42158e 100644 --- a/datasette/permissions.py +++ b/datasette/permissions.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass, fields +from dataclasses import dataclass from typing import Optional diff --git a/datasette/url_builder.py b/datasette/url_builder.py index 574bf3c1..9c6bbde0 100644 --- a/datasette/url_builder.py +++ b/datasette/url_builder.py @@ -1,4 +1,4 @@ -from .utils import tilde_encode, path_with_format, HASH_LENGTH, PrefixedUrlString +from .utils import tilde_encode, path_with_format, PrefixedUrlString import urllib diff --git a/datasette/views/base.py b/datasette/views/base.py index bdc1e9cf..9d7a854c 100644 --- a/datasette/views/base.py +++ b/datasette/views/base.py @@ -10,7 +10,6 @@ from markupsafe import escape import pint -from datasette import __version__ from datasette.database import QueryInterrupted from datasette.utils.asgi import Request from datasette.utils import ( diff --git a/datasette/views/index.py b/datasette/views/index.py index 595cf234..2cb18b1c 100644 --- a/datasette/views/index.py +++ b/datasette/views/index.py @@ -1,12 +1,9 @@ import json -from datasette.plugins import pm from datasette.utils import add_cors_headers, make_slot_function, CustomJSONEncoder from datasette.utils.asgi import Response from datasette.version import __version__ -from markupsafe import Markup - from .base import BaseView diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 00000000..0deb884c --- /dev/null +++ b/ruff.toml @@ -0,0 +1 @@ +line-length = 160 \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 445de057..168194d2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,7 +7,7 @@ import re import subprocess import tempfile import time -from dataclasses import dataclass, field +from dataclasses import dataclass from datasette import Event, hookimpl diff --git a/tests/test_black.py b/tests/test_black.py index d09b2514..ccf51171 100644 --- a/tests/test_black.py +++ b/tests/test_black.py @@ -1,8 +1,6 @@ import black from click.testing import CliRunner from pathlib import Path -import pytest -import sys code_root = Path(__file__).parent.parent diff --git a/tests/test_cli.py b/tests/test_cli.py index 9cc18c6e..bda17eed 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -4,7 +4,6 @@ from .fixtures import ( TestClient as _TestClient, EXPECTED_PLUGINS, ) -import asyncio from datasette.app import SETTINGS from datasette.plugins import DEFAULT_PLUGINS from datasette.cli import cli, serve @@ -19,7 +18,6 @@ import pytest import sys import textwrap from unittest import mock -import urllib def test_inspect_cli(app_client): diff --git a/tests/test_cli_serve_get.py b/tests/test_cli_serve_get.py index ff2429c6..1088d906 100644 --- a/tests/test_cli_serve_get.py +++ b/tests/test_cli_serve_get.py @@ -1,4 +1,4 @@ -from datasette.cli import cli, serve +from datasette.cli import cli from datasette.plugins import pm from click.testing import CliRunner import textwrap diff --git a/tests/test_config_dir.py b/tests/test_config_dir.py index 748412c3..66114a27 100644 --- a/tests/test_config_dir.py +++ b/tests/test_config_dir.py @@ -3,11 +3,9 @@ import pathlib import pytest from datasette.app import Datasette -from datasette.cli import cli from datasette.utils.sqlite import sqlite3 from datasette.utils import StartupError from .fixtures import TestClient as _TestClient -from click.testing import CliRunner PLUGIN = """ from datasette import hookimpl diff --git a/tests/test_internals_datasette.py b/tests/test_internals_datasette.py index c30bb748..2614e02e 100644 --- a/tests/test_internals_datasette.py +++ b/tests/test_internals_datasette.py @@ -7,7 +7,6 @@ from datasette import Forbidden, Context from datasette.app import Datasette, Database from itsdangerous import BadSignature import pytest -from typing import Optional @pytest.fixture diff --git a/tests/test_plugins.py b/tests/test_plugins.py index a53fc118..c02c94cc 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -1,6 +1,5 @@ from bs4 import BeautifulSoup as Soup from .fixtures import ( - app_client, app_client, make_app_client, TABLES, @@ -9,14 +8,12 @@ from .fixtures import ( TestClient as _TestClient, ) # noqa from click.testing import CliRunner -from dataclasses import dataclass from datasette.app import Datasette -from datasette import cli, hookimpl, Event, Permission +from datasette import cli, hookimpl, Permission from datasette.filters import FilterArguments from datasette.plugins import get_plugins, DEFAULT_PLUGINS, pm from datasette.utils.sqlite import sqlite3 -from datasette.utils import CustomRow, StartupError -from jinja2.environment import Template +from datasette.utils import StartupError from jinja2 import ChoiceLoader, FileSystemLoader import base64 import datetime diff --git a/tests/test_table_html.py b/tests/test_table_html.py index 0604d34c..2a658663 100644 --- a/tests/test_table_html.py +++ b/tests/test_table_html.py @@ -1,4 +1,4 @@ -from datasette.app import Datasette, Database +from datasette.app import Datasette from bs4 import BeautifulSoup as Soup from .fixtures import ( # noqa app_client, From f0491038523e000b97a18d2e9a23faee62208083 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 6 Feb 2024 17:33:18 -0800 Subject: [PATCH 096/474] datasette.table_metadata() is now await datasette.table_config(), refs #2247 --- datasette/app.py | 2 +- datasette/database.py | 2 +- datasette/filters.py | 2 +- datasette/views/row.py | 2 +- datasette/views/table.py | 6 +++--- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 2e20d402..b0b8f041 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -1202,7 +1202,7 @@ class Datasette: def _actor(self, request): return {"actor": request.actor} - def table_metadata(self, database, table): + async def table_config(self, database, table): """Fetch table-specific metadata.""" return ( (self.metadata("databases") or {}) diff --git a/datasette/database.py b/datasette/database.py index f2c980d7..1c1b3e1b 100644 --- a/datasette/database.py +++ b/datasette/database.py @@ -418,7 +418,7 @@ class Database: return await self.execute_fn(lambda conn: detect_fts(conn, table)) async def label_column_for_table(self, table): - explicit_label_column = self.ds.table_metadata(self.name, table).get( + explicit_label_column = (await self.ds.table_config(self.name, table)).get( "label_column" ) if explicit_label_column: diff --git a/datasette/filters.py b/datasette/filters.py index 73eea857..4d9580d8 100644 --- a/datasette/filters.py +++ b/datasette/filters.py @@ -50,7 +50,7 @@ def search_filters(request, database, table, datasette): extra_context = {} # Figure out which fts_table to use - table_metadata = datasette.table_metadata(database, table) + table_metadata = await datasette.table_config(database, table) db = datasette.get_database(database) fts_table = request.args.get("_fts_table") fts_table = fts_table or table_metadata.get("fts_table") diff --git a/datasette/views/row.py b/datasette/views/row.py index 7b646641..7b43b893 100644 --- a/datasette/views/row.py +++ b/datasette/views/row.py @@ -89,7 +89,7 @@ class RowView(DataView): "columns": columns, "primary_keys": resolved.pks, "primary_key_values": pk_values, - "units": self.ds.table_metadata(database, table).get("units", {}), + "units": (await self.ds.table_config(database, table)).get("units", {}), } if "foreign_key_tables" in (request.args.get("_extras") or "").split(","): diff --git a/datasette/views/table.py b/datasette/views/table.py index 22722847..2b5b7c24 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -142,7 +142,7 @@ async def display_columns_and_rows( """Returns columns, rows for specified table - including fancy foreign key treatment""" sortable_columns = sortable_columns or set() db = datasette.databases[database_name] - table_metadata = datasette.table_metadata(database_name, table_name) + table_metadata = await datasette.table_config(database_name, table_name) column_descriptions = table_metadata.get("columns") or {} column_details = { col.name: col for col in await db.table_column_details(table_name) @@ -663,7 +663,7 @@ async def _columns_to_select(table_columns, pks, request): async def _sortable_columns_for_table(datasette, database_name, table_name, use_rowid): db = datasette.databases[database_name] - table_metadata = datasette.table_metadata(database_name, table_name) + table_metadata = await datasette.table_config(database_name, table_name) if "sortable_columns" in table_metadata: sortable_columns = set(table_metadata["sortable_columns"]) else: @@ -962,7 +962,7 @@ async def table_view_data( nocount = True nofacet = True - table_metadata = datasette.table_metadata(database_name, table_name) + table_metadata = await datasette.table_config(database_name, table_name) units = table_metadata.get("units", {}) # Arguments that start with _ and don't contain a __ are From 52a1dac5d2bac7e106c8d6ce8e0c6f1dc0141a7e Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 6 Feb 2024 21:00:55 -0800 Subject: [PATCH 097/474] Test proving $env works for datasette.yml, closes #2255 --- tests/test_plugins.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/test_plugins.py b/tests/test_plugins.py index c02c94cc..40d01c71 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -231,10 +231,18 @@ async def test_plugin_config(ds_client): @pytest.mark.asyncio -async def test_plugin_config_env(ds_client): - os.environ["FOO_ENV"] = "FROM_ENVIRONMENT" - assert {"foo": "FROM_ENVIRONMENT"} == ds_client.ds.plugin_config("env-plugin") - del os.environ["FOO_ENV"] +async def test_plugin_config_env(ds_client, monkeypatch): + monkeypatch.setenv("FOO_ENV", "FROM_ENVIRONMENT") + assert ds_client.ds.plugin_config("env-plugin") == {"foo": "FROM_ENVIRONMENT"} + + +@pytest.mark.asyncio +async def test_plugin_config_env_from_config(monkeypatch): + monkeypatch.setenv("FOO_ENV", "FROM_ENVIRONMENT_2") + datasette = Datasette( + config={"plugins": {"env-plugin": {"setting": {"$env": "FOO_ENV"}}}} + ) + assert datasette.plugin_config("env-plugin") == {"setting": "FROM_ENVIRONMENT_2"} @pytest.mark.asyncio From 60c6692f6802dfae6a433f648f287be30ef52325 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 6 Feb 2024 21:57:09 -0800 Subject: [PATCH 098/474] table_config instead of table_metadata (#2257) Table configuration that was incorrectly placed in metadata is now treated as if it was in config. New await datasette.table_config() method. Closes #2247 --- datasette/app.py | 12 +++-- datasette/database.py | 10 ++-- datasette/utils/__init__.py | 69 +++++++++++++++++++----- datasette/views/table.py | 11 ++-- tests/test_api.py | 101 +++++++++++++++++++++++++++++++++++- tests/test_html.py | 2 +- 6 files changed, 175 insertions(+), 30 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index b0b8f041..373b3e95 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -75,6 +75,7 @@ from .utils import ( format_bytes, module_from_path, move_plugins, + move_table_config, parse_metadata, resolve_env_secrets, resolve_routes, @@ -346,7 +347,9 @@ class Datasette: # Move any "plugins" settings from metadata to config - updates them in place metadata = metadata or {} config = config or {} - move_plugins(metadata, config) + metadata, config = move_plugins(metadata, config) + # Now migrate any known table configuration settings over as well + metadata, config = move_table_config(metadata, config) self._metadata_local = metadata or {} self.sqlite_extensions = [] @@ -1202,10 +1205,11 @@ class Datasette: def _actor(self, request): return {"actor": request.actor} - async def table_config(self, database, table): - """Fetch table-specific metadata.""" + async def table_config(self, database: str, table: str) -> dict: + """Return dictionary of configuration for specified table""" return ( - (self.metadata("databases") or {}) + (self.config or {}) + .get("databases", {}) .get(database, {}) .get("tables", {}) .get(table, {}) diff --git a/datasette/database.py b/datasette/database.py index 1c1b3e1b..fba81496 100644 --- a/datasette/database.py +++ b/datasette/database.py @@ -487,13 +487,11 @@ class Database: ) ).rows ] - # Add any from metadata.json - db_metadata = self.ds.metadata(database=self.name) - if "tables" in db_metadata: + # Add any tables marked as hidden in config + db_config = self.ds.config.get("databases", {}).get(self.name, {}) + if "tables" in db_config: hidden_tables += [ - t - for t in db_metadata["tables"] - if db_metadata["tables"][t].get("hidden") + t for t in db_config["tables"] if db_config["tables"][t].get("hidden") ] # Also mark as hidden any tables which start with the name of a hidden table # e.g. "searchable_fts" implies "searchable_fts_content" should be hidden diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py index 4c940645..fcaebe3f 100644 --- a/datasette/utils/__init__.py +++ b/datasette/utils/__init__.py @@ -2,6 +2,7 @@ import asyncio from contextlib import contextmanager import click from collections import OrderedDict, namedtuple, Counter +import copy import base64 import hashlib import inspect @@ -17,7 +18,7 @@ import time import types import secrets import shutil -from typing import Iterable +from typing import Iterable, Tuple import urllib import yaml from .shutil_backport import copytree @@ -1290,11 +1291,24 @@ def make_slot_function(name, datasette, request, **kwargs): return inner -def move_plugins(source, destination): +def prune_empty_dicts(d: dict): + """ + Recursively prune all empty dictionaries from a given dictionary. + """ + for key, value in list(d.items()): + if isinstance(value, dict): + prune_empty_dicts(value) + if value == {}: + d.pop(key, None) + + +def move_plugins(source: dict, destination: dict) -> Tuple[dict, dict]: """ Move 'plugins' keys from source to destination dictionary. Creates hierarchy in destination if needed. After moving, recursively remove any keys in the source that are left empty. """ + source = copy.deepcopy(source) + destination = copy.deepcopy(destination) def recursive_move(src, dest, path=None): if path is None: @@ -1316,18 +1330,49 @@ def move_plugins(source, destination): if not value: src.pop(key, None) - def prune_empty_dicts(d): - """ - Recursively prune all empty dictionaries from a given dictionary. - """ - for key, value in list(d.items()): - if isinstance(value, dict): - prune_empty_dicts(value) - if value == {}: - d.pop(key, None) - recursive_move(source, destination) prune_empty_dicts(source) + return source, destination + + +_table_config_keys = ( + "hidden", + "sort", + "sort_desc", + "size", + "sortable_columns", + "label_column", + "facets", + "fts_table", + "fts_pk", + "searchmode", + "units", +) + + +def move_table_config(metadata: dict, config: dict): + """ + Move all known table configuration keys from metadata to config. + """ + if "databases" not in metadata: + return metadata, config + metadata = copy.deepcopy(metadata) + config = copy.deepcopy(config) + for database_name, database in metadata["databases"].items(): + if "tables" not in database: + continue + for table_name, table in database["tables"].items(): + for key in _table_config_keys: + if key in table: + config.setdefault("databases", {}).setdefault( + database_name, {} + ).setdefault("tables", {}).setdefault(table_name, {})[ + key + ] = table.pop( + key + ) + prune_empty_dicts(metadata) + return metadata, config def redact_keys(original: dict, key_patterns: Iterable) -> dict: diff --git a/datasette/views/table.py b/datasette/views/table.py index 2b5b7c24..50d2b3c2 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -142,11 +142,11 @@ async def display_columns_and_rows( """Returns columns, rows for specified table - including fancy foreign key treatment""" sortable_columns = sortable_columns or set() db = datasette.databases[database_name] - table_metadata = await datasette.table_config(database_name, table_name) - column_descriptions = table_metadata.get("columns") or {} + column_descriptions = datasette.metadata("columns", database_name, table_name) or {} column_details = { col.name: col for col in await db.table_column_details(table_name) } + table_config = await datasette.table_config(database_name, table_name) pks = await db.primary_keys(table_name) pks_for_display = pks if not pks_for_display: @@ -193,7 +193,6 @@ async def display_columns_and_rows( "raw": pk_path, "value": markupsafe.Markup( '{flat_pks}'.format( - base_url=base_url, table_path=datasette.urls.table(database_name, table_name), flat_pks=str(markupsafe.escape(pk_path)), flat_pks_quoted=path_from_row_pks(row, pks, not pks), @@ -274,9 +273,9 @@ async def display_columns_and_rows( ), ) ) - elif column in table_metadata.get("units", {}) and value != "": + elif column in table_config.get("units", {}) and value != "": # Interpret units using pint - value = value * ureg(table_metadata["units"][column]) + value = value * ureg(table_config["units"][column]) # Pint uses floating point which sometimes introduces errors in the compact # representation, which we have to round off to avoid ugliness. In the vast # majority of cases this rounding will be inconsequential. I hope. @@ -591,7 +590,7 @@ class TableDropView(BaseView): try: data = json.loads(await request.post_body()) confirm = data.get("confirm") - except json.JSONDecodeError as e: + except json.JSONDecodeError: pass if not confirm: diff --git a/tests/test_api.py b/tests/test_api.py index 0a1f3725..8cb73dbb 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -771,7 +771,7 @@ def test_databases_json(app_client_two_attached_databases_one_immutable): @pytest.mark.asyncio async def test_metadata_json(ds_client): response = await ds_client.get("/-/metadata.json") - assert response.json() == METADATA + assert response.json() == ds_client.ds.metadata() @pytest.mark.asyncio @@ -1061,3 +1061,102 @@ async def test_config_json(config, expected): ds = Datasette(config=config) response = await ds.client.get("/-/config.json") assert response.json() == expected + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "metadata,expected_config,expected_metadata", + ( + ({}, {}, {}), + ( + # Metadata input + { + "title": "Datasette Fixtures", + "databases": { + "fixtures": { + "tables": { + "sortable": { + "sortable_columns": [ + "sortable", + "sortable_with_nulls", + "sortable_with_nulls_2", + "text", + ], + }, + "no_primary_key": {"sortable_columns": [], "hidden": True}, + "units": {"units": {"distance": "m", "frequency": "Hz"}}, + "primary_key_multiple_columns_explicit_label": { + "label_column": "content2" + }, + "simple_view": {"sortable_columns": ["content"]}, + "searchable_view_configured_by_metadata": { + "fts_table": "searchable_fts", + "fts_pk": "pk", + }, + "roadside_attractions": { + "columns": { + "name": "The name of the attraction", + "address": "The street address for the attraction", + } + }, + "attraction_characteristic": {"sort_desc": "pk"}, + "facet_cities": {"sort": "name"}, + "paginated_view": {"size": 25}, + }, + } + }, + }, + # Should produce a config with just the table configuration keys + { + "databases": { + "fixtures": { + "tables": { + "sortable": { + "sortable_columns": [ + "sortable", + "sortable_with_nulls", + "sortable_with_nulls_2", + "text", + ] + }, + "units": {"units": {"distance": "m", "frequency": "Hz"}}, + # These one get redacted: + "no_primary_key": "***", + "primary_key_multiple_columns_explicit_label": "***", + "simple_view": {"sortable_columns": ["content"]}, + "searchable_view_configured_by_metadata": { + "fts_table": "searchable_fts", + "fts_pk": "pk", + }, + "attraction_characteristic": {"sort_desc": "pk"}, + "facet_cities": {"sort": "name"}, + "paginated_view": {"size": 25}, + } + } + } + }, + # And metadata with everything else + { + "title": "Datasette Fixtures", + "databases": { + "fixtures": { + "tables": { + "roadside_attractions": { + "columns": { + "name": "The name of the attraction", + "address": "The street address for the attraction", + } + }, + } + } + }, + }, + ), + ), +) +async def test_upgrade_metadata(metadata, expected_config, expected_metadata): + ds = Datasette(metadata=metadata) + response = await ds.client.get("/-/config.json") + assert response.json() == expected_config + response2 = await ds.client.get("/-/metadata.json") + assert response2.json() == expected_metadata diff --git a/tests/test_html.py b/tests/test_html.py index 86895844..8229b166 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -753,7 +753,7 @@ async def test_metadata_json_html(ds_client): response = await ds_client.get("/-/metadata") assert response.status_code == 200 pre = Soup(response.content, "html.parser").find("pre") - assert METADATA == json.loads(pre.text) + assert ds_client.ds.metadata() == json.loads(pre.text) @pytest.mark.asyncio From 9ac9f0152f1d3396d01ceddfcaf07fd1cf3f7168 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 6 Feb 2024 22:18:38 -0800 Subject: [PATCH 099/474] Migrate allow from metadata to config if necessary, closes #2249 --- datasette/app.py | 6 +++--- datasette/utils/__init__.py | 9 +++++---- tests/test_permissions.py | 20 ++++++++++++-------- 3 files changed, 20 insertions(+), 15 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 373b3e95..af8cfeab 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -74,7 +74,7 @@ from .utils import ( find_spatialite, format_bytes, module_from_path, - move_plugins, + move_plugins_and_allow, move_table_config, parse_metadata, resolve_env_secrets, @@ -344,10 +344,10 @@ class Datasette: with config_files[0].open() as fp: config = parse_metadata(fp.read()) - # Move any "plugins" settings from metadata to config - updates them in place + # Move any "plugins" and "allow" settings from metadata to config - updates them in place metadata = metadata or {} config = config or {} - metadata, config = move_plugins(metadata, config) + metadata, config = move_plugins_and_allow(metadata, config) # Now migrate any known table configuration settings over as well metadata, config = move_table_config(metadata, config) diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py index fcaebe3f..f2cd7eb0 100644 --- a/datasette/utils/__init__.py +++ b/datasette/utils/__init__.py @@ -1302,10 +1302,11 @@ def prune_empty_dicts(d: dict): d.pop(key, None) -def move_plugins(source: dict, destination: dict) -> Tuple[dict, dict]: +def move_plugins_and_allow(source: dict, destination: dict) -> Tuple[dict, dict]: """ - Move 'plugins' keys from source to destination dictionary. Creates hierarchy in destination if needed. - After moving, recursively remove any keys in the source that are left empty. + Move 'plugins' and 'allow' keys from source to destination dictionary. Creates + hierarchy in destination if needed. After moving, recursively remove any keys + in the source that are left empty. """ source = copy.deepcopy(source) destination = copy.deepcopy(destination) @@ -1315,7 +1316,7 @@ def move_plugins(source: dict, destination: dict) -> Tuple[dict, dict]: path = [] for key, value in list(src.items()): new_path = path + [key] - if key == "plugins": + if key in ("plugins", "allow"): # Navigate and create the hierarchy in destination if needed d = dest for step in path: diff --git a/tests/test_permissions.py b/tests/test_permissions.py index 9917b749..6713b850 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -89,10 +89,11 @@ def test_view_padlock(allow, expected_anon, expected_auth, path, padlock_client) ({"id": "root"}, 403, 200), ], ) -def test_view_database(allow, expected_anon, expected_auth): - with make_app_client( - config={"databases": {"fixtures": {"allow": allow}}} - ) as client: +@pytest.mark.parametrize("use_metadata", (True, False)) +def test_view_database(allow, expected_anon, expected_auth, use_metadata): + key = "metadata" if use_metadata else "config" + kwargs = {key: {"databases": {"fixtures": {"allow": allow}}}} + with make_app_client(**kwargs) as client: for path in ( "/fixtures", "/fixtures/compound_three_primary_keys", @@ -173,16 +174,19 @@ def test_database_list_respects_view_table(): ({"id": "root"}, 403, 200), ], ) -def test_view_table(allow, expected_anon, expected_auth): - with make_app_client( - config={ +@pytest.mark.parametrize("use_metadata", (True, False)) +def test_view_table(allow, expected_anon, expected_auth, use_metadata): + key = "metadata" if use_metadata else "config" + kwargs = { + key: { "databases": { "fixtures": { "tables": {"compound_three_primary_keys": {"allow": allow}} } } } - ) as client: + } + with make_app_client(**kwargs) as client: anon_response = client.get("/fixtures/compound_three_primary_keys") assert expected_anon == anon_response.status if allow and anon_response.status == 200: From ad01f9d3217f2447b0a321ee7731900dac7e3e6d Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 6 Feb 2024 22:24:24 -0800 Subject: [PATCH 100/474] 1.0a8 release notes Closes #2243 * Changelog for jinja2_environment_from_request and plugin_hook_slots * track_event() in changelog * Remove Using YAML for metadata section - no longer necessary now we show YAML and JSON examples everywhere. * Configuration via the command-line section - #2252 * JavaScript plugins in release notes, refs #2052 * /-/config in changelog, refs #2254 Refs #2052, #2156, #2243, #2247, #2249, #2252, #2254 --- docs/changelog.rst | 77 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index f4b928e3..1f47b429 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,83 @@ Changelog ========= +.. _v1_0_a8: + +1.0a8 (2024-02-01) +------------------ + +This alpha release continues the migration of Datasette's configuration from ``metadata.yaml`` to the new ``datasette.yaml`` configuration file, and adds several new plugin hooks. + +Configuration +~~~~~~~~~~~~~ + +- Plugin configuration now lives in the :ref:`datasette.yaml configuration file `, passed to Datasette using the ``-c/--config`` option. Thanks, Alex Garcia. (:issue:`2093`) + + .. code-block:: bash + + datasette -c datasette.yaml + + Where ``datasette.yaml`` contains configuration that looks like this: + + .. code-block:: yaml + + plugins: + datasette-cluster-map: + latitude_column: xlat + longitude_column: xlon + + Previously plugins were configured in ``metadata.yaml``, which was confusing as plugin settings were unrelated to database and table metadata. +- The ``-s/--setting`` option can now be used to set plugin configuration as well. See :ref:`configuration_cli` for details. (:issue:`2252`) + + The above YAML configuration example using ``-s/--setting`` looks like this: + + .. code-block:: bash + + datasette mydatabase.db \ + -s plugins.datasette-cluster-map.latitude_column xlat \ + -s plugins.datasette-cluster-map.longitude_column xlon + +- The new ``/-/config`` page shows the current instance configuration, after redacting keys that could contain sensitive data such as API keys or passwords. (:issue:`2254`) + +- Existing Datasette installations may already have configuration set in ``metadata.yaml`` that should be migrated to ``datasette.yaml``. To avoid breaking these installations, Datasette will silently treat table configuration, plugin configuration and allow blocks in metadata as if they had been specified in configuration instead. (:issue:`2247`) (:issue:`2248`) (:issue:`2249`) + +JavaScript plugins +~~~~~~~~~~~~~~~~~~ + +Datasette now includes a :ref:`JavaScript plugins mechanism `, allowing JavaScript to customize Datasette in a way that can collaborate with other plugins. + +This provides two initial hooks, with more to come in the future: + +- :ref:`makeAboveTablePanelConfigs() ` can add additional panels to the top of the table page. +- :ref:`makeColumnActions() ` can add additional actions to the column menu. + +Thanks `Cameron Yick `__ for contributing this feature. (`#2052 `__) + +Plugin hooks +~~~~~~~~~~~~ + +- New :ref:`plugin_hook_jinja2_environment_from_request` plugin hook, which can be used to customize the current Jinja environment based on the incoming request. This can be used to modify the template lookup path based on the incoming request hostname, among other things. (:issue:`2225`) +- New :ref:`family of template slot plugin hooks `: ``top_homepage``, ``top_database``, ``top_table``, ``top_row``, ``top_query``, ``top_canned_query``. Plugins can use these to provide additional HTML to be injected at the top of the corresponding pages. (:issue:`1191`) +- New :ref:`track_event() mechanism ` for plugins to emit and receive events when certain events occur within Datasette. (:issue:`2240`) + - Plugins can register additional event classes using :ref:`plugin_hook_register_events`. + - They can then trigger those events with the :ref:`datasette.track_event(event) ` internal method. + - Plugins can subscribe to notifications of events using the :ref:`plugin_hook_track_event` plugin hook. +- New internal function for plugin authors: :ref:`database_execute_isolated_fn`, for creating a new SQLite connection, executing code and then closing that connection, all while preventing other code from writing to that particular database. This connection will not have the :ref:`prepare_connection() ` plugin hook executed against it, allowing plugins to perform actions that might otherwise be blocked by existing connection configuration. (:issue:`2218`) + +Documentation +~~~~~~~~~~~~~ + +- Documentation describing :ref:`how to write tests that use signed actor cookies ` using ``datasette.client.actor_cookie()``. (:issue:`1830`) +- Documentation on how to :ref:`register a plugin for the duration of a test `. (:issue:`2234`) +- The :ref:`configuration documentation ` now shows examples of both YAML and JSON for each setting. + +Minor fixes +~~~~~~~~~~~ + +- Datasette no longer attempts to run SQL queries in parallel when rendering a table page, as this was leading to some rare crashing bugs. (:issue:`2189`) +- Fixed warning: ``DeprecationWarning: pkg_resources is deprecated as an API`` (:issue:`2057`) +- Fixed bug where ``?_extra=columns`` parameter returned an incorrectly shaped response. (:issue:`2230`) + .. _v0_64_6: 0.64.6 (2023-12-22) From c64453a4a137ea815f4d94a99cdc4c7709734839 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 6 Feb 2024 22:28:22 -0800 Subject: [PATCH 101/474] Fix the date on the 1.0a8 release (due to go tomorrow) Refs #2258 --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 1f47b429..5e9e3ba2 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -6,7 +6,7 @@ Changelog .. _v1_0_a8: -1.0a8 (2024-02-01) +1.0a8 (2024-02-07) ------------------ This alpha release continues the migration of Datasette's configuration from ``metadata.yaml`` to the new ``datasette.yaml`` configuration file, and adds several new plugin hooks. From d0089ba7769d58b97c5dc1e07969246502d97544 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 6 Feb 2024 22:30:30 -0800 Subject: [PATCH 102/474] Note in changelog about datasette publish, refs #2195 --- docs/changelog.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 5e9e3ba2..e17dc2f8 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -44,6 +44,8 @@ Configuration - Existing Datasette installations may already have configuration set in ``metadata.yaml`` that should be migrated to ``datasette.yaml``. To avoid breaking these installations, Datasette will silently treat table configuration, plugin configuration and allow blocks in metadata as if they had been specified in configuration instead. (:issue:`2247`) (:issue:`2248`) (:issue:`2249`) +Note that the ``datasette publish`` command has not yet been updated to accept a ``datasette.yaml`` configuration file. This will be addressed in :issue:`2195` but for the moment you can include those settings in ``metadata.yaml`` instead. + JavaScript plugins ~~~~~~~~~~~~~~~~~~ From df8d1c055a48a36693d9caec3915eb93c1acefc0 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 6 Feb 2024 22:59:58 -0800 Subject: [PATCH 103/474] Mention JS plugins in release intro --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index e17dc2f8..a73635a8 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -9,7 +9,7 @@ Changelog 1.0a8 (2024-02-07) ------------------ -This alpha release continues the migration of Datasette's configuration from ``metadata.yaml`` to the new ``datasette.yaml`` configuration file, and adds several new plugin hooks. +This alpha release continues the migration of Datasette's configuration from ``metadata.yaml`` to the new ``datasette.yaml`` configuration file, introduces a new system for JavaScript plugins and adds several new plugin hooks. Configuration ~~~~~~~~~~~~~ From 1e31821d9ff2bc81c62bcd442214e9098cf785a4 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 7 Feb 2024 08:25:47 -0800 Subject: [PATCH 104/474] Link to events docs from changelog --- docs/changelog.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index a73635a8..dfcf492f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -67,6 +67,7 @@ Plugin hooks - Plugins can register additional event classes using :ref:`plugin_hook_register_events`. - They can then trigger those events with the :ref:`datasette.track_event(event) ` internal method. - Plugins can subscribe to notifications of events using the :ref:`plugin_hook_track_event` plugin hook. + - Datasette core now emits ``login``, ``logout``, ``create-token``, ``create-table``, ``drop-table``, ``insert-rows``, ``upsert-rows``, ``update-row``, ``delete-row`` events, :ref:`documented here `. - New internal function for plugin authors: :ref:`database_execute_isolated_fn`, for creating a new SQLite connection, executing code and then closing that connection, all while preventing other code from writing to that particular database. This connection will not have the :ref:`prepare_connection() ` plugin hook executed against it, allowing plugins to perform actions that might otherwise be blocked by existing connection configuration. (:issue:`2218`) Documentation From e0794ddd52697812848c0b59f68e49a2e9361693 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 7 Feb 2024 08:32:47 -0800 Subject: [PATCH 105/474] Link to annotated release notes blog post --- docs/changelog.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index dfcf492f..d164f71d 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -11,6 +11,8 @@ Changelog This alpha release continues the migration of Datasette's configuration from ``metadata.yaml`` to the new ``datasette.yaml`` configuration file, introduces a new system for JavaScript plugins and adds several new plugin hooks. +See `Datasette 1.0a8: JavaScript plugins, new plugin hooks and plugin configuration in datasette.yaml `__ for an annotated version of these release notes. + Configuration ~~~~~~~~~~~~~ From 9989f257094daaf26e0cb0cebe31f17f19d4cad2 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 7 Feb 2024 08:34:05 -0800 Subject: [PATCH 106/474] Release 1.0a8 Refs Refs #2052, #2156, #2243, #2247, #2249, #2252, #2254, #2258 --- datasette/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/datasette/version.py b/datasette/version.py index 75d44727..e43b9918 100644 --- a/datasette/version.py +++ b/datasette/version.py @@ -1,2 +1,2 @@ -__version__ = "1.0a8.dev1" +__version__ = "1.0a8" __version_info__ = tuple(__version__.split(".")) From 569aacd39bcc5529b2f463c89c616e3ada21c560 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 7 Feb 2024 22:53:14 -0800 Subject: [PATCH 107/474] Link to /en/latest/ changelog --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 57f17a5c..662f2a11 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ Datasette [![PyPI](https://img.shields.io/pypi/v/datasette.svg)](https://pypi.org/project/datasette/) -[![Changelog](https://img.shields.io/github/v/release/simonw/datasette?label=changelog)](https://docs.datasette.io/en/stable/changelog.html) +[![Changelog](https://img.shields.io/github/v/release/simonw/datasette?label=changelog)](https://docs.datasette.io/en/latest/changelog.html) [![Python 3.x](https://img.shields.io/pypi/pyversions/datasette.svg?logo=python&logoColor=white)](https://pypi.org/project/datasette/) [![Tests](https://github.com/simonw/datasette/workflows/Test/badge.svg)](https://github.com/simonw/datasette/actions?query=workflow%3ATest) [![Documentation Status](https://readthedocs.org/projects/datasette/badge/?version=latest)](https://docs.datasette.io/en/latest/?badge=latest) From 900d15bcb81c90d26cfebc3fe463c4b0465832c2 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 8 Feb 2024 12:21:13 -0800 Subject: [PATCH 108/474] alter table support for /db/-/create API, refs #2101 --- datasette/default_permissions.py | 10 ++++- datasette/events.py | 25 +++++++++++ datasette/views/database.py | 61 +++++++++++++++++++++++--- docs/authentication.rst | 12 ++++++ tests/test_api_write.py | 74 ++++++++++++++++++++++++++++++++ 5 files changed, 174 insertions(+), 8 deletions(-) diff --git a/datasette/default_permissions.py b/datasette/default_permissions.py index d29dbe84..c13f2ed2 100644 --- a/datasette/default_permissions.py +++ b/datasette/default_permissions.py @@ -8,7 +8,6 @@ from typing import Union, Tuple @hookimpl def register_permissions(): return ( - # name, abbr, description, takes_database, takes_resource, default Permission( name="view-instance", abbr="vi", @@ -109,6 +108,14 @@ def register_permissions(): takes_resource=False, default=False, ), + Permission( + name="alter-table", + abbr="at", + description="Alter tables", + takes_database=True, + takes_resource=True, + default=False, + ), Permission( name="drop-table", abbr="dt", @@ -129,6 +136,7 @@ def permission_allowed_default(datasette, actor, action, resource): "debug-menu", "insert-row", "create-table", + "alter-table", "drop-table", "delete-row", "update-row", diff --git a/datasette/events.py b/datasette/events.py index 96244779..ae90972d 100644 --- a/datasette/events.py +++ b/datasette/events.py @@ -108,6 +108,30 @@ class DropTableEvent(Event): table: str +@dataclass +class AlterTableEvent(Event): + """ + Event name: ``alter-table`` + + A table has been altered. + + :ivar database: The name of the database where the table was altered + :type database: str + :ivar table: The name of the table that was altered + :type table: str + :ivar before_schema: The table's SQL schema before the alteration + :type before_schema: str + :ivar after_schema: The table's SQL schema after the alteration + :type after_schema: str + """ + + name = "alter-table" + database: str + table: str + before_schema: str + after_schema: str + + @dataclass class InsertRowsEvent(Event): """ @@ -203,6 +227,7 @@ def register_events(): LogoutEvent, CreateTableEvent, CreateTokenEvent, + AlterTableEvent, DropTableEvent, InsertRowsEvent, UpsertRowsEvent, diff --git a/datasette/views/database.py b/datasette/views/database.py index 6d17b16c..bd55064f 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -10,7 +10,7 @@ import re import sqlite_utils import textwrap -from datasette.events import CreateTableEvent +from datasette.events import AlterTableEvent, CreateTableEvent from datasette.database import QueryInterrupted from datasette.utils import ( add_cors_headers, @@ -792,7 +792,17 @@ class MagicParameters(dict): class TableCreateView(BaseView): name = "table-create" - _valid_keys = {"table", "rows", "row", "columns", "pk", "pks", "ignore", "replace"} + _valid_keys = { + "table", + "rows", + "row", + "columns", + "pk", + "pks", + "ignore", + "replace", + "alter", + } _supported_column_types = { "text", "integer", @@ -876,6 +886,20 @@ class TableCreateView(BaseView): ): return _error(["Permission denied - need insert-row"], 403) + alter = False + if rows or row: + if not table_exists: + # if table is being created for the first time, alter=True + alter = True + else: + # alter=True only if they request it AND they have permission + if data.get("alter"): + if not await self.ds.permission_allowed( + request.actor, "alter-table", resource=database_name + ): + return _error(["Permission denied - need alter-table"], 403) + alter = True + if columns: if rows or row: return _error(["Cannot specify columns with rows or row"]) @@ -939,10 +963,18 @@ class TableCreateView(BaseView): return _error(["pk cannot be changed for existing table"]) pks = actual_pks + initial_schema = None + if table_exists: + initial_schema = await db.execute_fn( + lambda conn: sqlite_utils.Database(conn)[table_name].schema + ) + def create_table(conn): table = sqlite_utils.Database(conn)[table_name] if rows: - table.insert_all(rows, pk=pks or pk, ignore=ignore, replace=replace) + table.insert_all( + rows, pk=pks or pk, ignore=ignore, replace=replace, alter=alter + ) else: table.create( {c["name"]: c["type"] for c in columns}, @@ -954,6 +986,18 @@ class TableCreateView(BaseView): schema = await db.execute_write_fn(create_table) except Exception as e: return _error([str(e)]) + + if initial_schema is not None and initial_schema != schema: + await self.ds.track_event( + AlterTableEvent( + request.actor, + database=database_name, + table=table_name, + before_schema=initial_schema, + after_schema=schema, + ) + ) + table_url = self.ds.absolute_url( request, self.ds.urls.table(db.name, table_name) ) @@ -970,11 +1014,14 @@ class TableCreateView(BaseView): } if rows: details["row_count"] = len(rows) - await self.ds.track_event( - CreateTableEvent( - request.actor, database=db.name, table=table_name, schema=schema + + if not table_exists: + # Only log creation if we created a table + await self.ds.track_event( + CreateTableEvent( + request.actor, database=db.name, table=table_name, schema=schema + ) ) - ) return Response.json(details, status=201) diff --git a/docs/authentication.rst b/docs/authentication.rst index 8758765d..87ee6385 100644 --- a/docs/authentication.rst +++ b/docs/authentication.rst @@ -1217,6 +1217,18 @@ Actor is allowed to create a database table. Default *deny*. +.. _permissions_alter_table: + +alter-table +----------- + +Actor is allowed to alter a database table. + +``resource`` - tuple: (string, string) + The name of the database, then the name of the table + +Default *deny*. + .. _permissions_drop_table: drop-table diff --git a/tests/test_api_write.py b/tests/test_api_write.py index 9caf9fdf..30cbfbab 100644 --- a/tests/test_api_write.py +++ b/tests/test_api_write.py @@ -1349,3 +1349,77 @@ async def test_method_not_allowed(ds_write, path): "ok": False, "error": "Method not allowed", } + + +@pytest.mark.asyncio +async def test_create_uses_alter_by_default_for_new_table(ds_write): + token = write_token(ds_write) + response = await ds_write.client.post( + "/data/-/create", + json={ + "table": "new_table", + "rows": [ + { + "name": "Row 1", + } + ] + * 100 + + [ + {"name": "Row 2", "extra": "Extra"}, + ], + "pk": "id", + }, + headers=_headers(token), + ) + assert response.status_code == 201 + event = last_event(ds_write) + assert event.name == "create-table" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("has_alter_permission", (True,)) # False)) +async def test_create_using_alter_against_existing_table( + ds_write, has_alter_permission +): + token = write_token( + ds_write, permissions=["ir", "ct"] + (["at"] if has_alter_permission else []) + ) + # First create the table + response = await ds_write.client.post( + "/data/-/create", + json={ + "table": "new_table", + "rows": [ + { + "name": "Row 1", + } + ], + "pk": "id", + }, + headers=_headers(token), + ) + assert response.status_code == 201 + # Now try to insert more rows using /-/create with alter=True + response2 = await ds_write.client.post( + "/data/-/create", + json={ + "table": "new_table", + "rows": [{"name": "Row 2", "extra": "extra"}], + "pk": "id", + "alter": True, + }, + headers=_headers(token), + ) + if not has_alter_permission: + assert response2.status_code == 403 + assert response2.json() == { + "ok": False, + "errors": ["Permission denied - need alter-table"], + } + else: + assert response2.status_code == 201 + # It should have altered the table + event = last_event(ds_write) + assert event.name == "alter-table" + assert "extra" not in event.before_schema + assert "extra" in event.after_schema From 574687834f4bd8e73281731b8ff01bfe093fecb5 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 8 Feb 2024 12:33:41 -0800 Subject: [PATCH 109/474] Docs for /db/-/create alter: true option, refs #2101 --- docs/json_api.rst | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/docs/json_api.rst b/docs/json_api.rst index 16b997eb..68a0c984 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -834,19 +834,22 @@ To create a table, make a ``POST`` to ``//-/create``. This requires th The JSON here describes the table that will be created: -* ``table`` is the name of the table to create. This field is required. -* ``columns`` is a list of columns to create. Each column is a dictionary with ``name`` and ``type`` keys. +* ``table`` is the name of the table to create. This field is required. +* ``columns`` is a list of columns to create. Each column is a dictionary with ``name`` and ``type`` keys. - - ``name`` is the name of the column. This is required. - - ``type`` is the type of the column. This is optional - if not provided, ``text`` will be assumed. The valid types are ``text``, ``integer``, ``float`` and ``blob``. + - ``name`` is the name of the column. This is required. + - ``type`` is the type of the column. This is optional - if not provided, ``text`` will be assumed. The valid types are ``text``, ``integer``, ``float`` and ``blob``. -* ``pk`` is the primary key for the table. This is optional - if not provided, Datasette will create a SQLite table with a hidden ``rowid`` column. +* ``pk`` is the primary key for the table. This is optional - if not provided, Datasette will create a SQLite table with a hidden ``rowid`` column. - If the primary key is an integer column, it will be configured to automatically increment for each new record. + If the primary key is an integer column, it will be configured to automatically increment for each new record. - If you set this to ``id`` without including an ``id`` column in the list of ``columns``, Datasette will create an integer ID column for you. + If you set this to ``id`` without including an ``id`` column in the list of ``columns``, Datasette will create an auto-incrementing integer ID column for you. -* ``pks`` can be used instead of ``pk`` to create a compound primary key. It should be a JSON list of column names to use in that primary key. +* ``pks`` can be used instead of ``pk`` to create a compound primary key. It should be a JSON list of column names to use in that primary key. +* ``ignore`` can be set to ``true`` to ignore existing rows by primary key if the table already exists. +* ``replace`` can be set to ``true`` to replace existing rows by primary key if the table already exists. +* ``alter`` can be set to ``true`` if you want to automatically add any missing columns to the table. This requires the :ref:`permissions_alter_table` permission. If the table is successfully created this will return a ``201`` status code and the following response: @@ -925,6 +928,8 @@ You can avoid this error by passing the same ``"ignore": true`` or ``"replace": To use the ``"replace": true`` option you will also need the :ref:`permissions_update_row` permission. +Pass ``"alter": true`` to automatically add any missing columns to the existing table that are present in the rows you are submitting. This requires the :ref:`permissions_alter_table` permission. + .. _TableDropView: Dropping tables From b5ccc4d60844a24fdf91c3f317d8cda4a285a58d Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 8 Feb 2024 12:35:12 -0800 Subject: [PATCH 110/474] Test for Permission denied - need alter-table --- tests/test_api_write.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_api_write.py b/tests/test_api_write.py index 30cbfbab..abf9a88a 100644 --- a/tests/test_api_write.py +++ b/tests/test_api_write.py @@ -1377,7 +1377,7 @@ async def test_create_uses_alter_by_default_for_new_table(ds_write): @pytest.mark.asyncio -@pytest.mark.parametrize("has_alter_permission", (True,)) # False)) +@pytest.mark.parametrize("has_alter_permission", (True, False)) async def test_create_using_alter_against_existing_table( ds_write, has_alter_permission ): From 528d89d1a3d6ff85047a7eef9a7623efdd2fb19f Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 8 Feb 2024 13:14:12 -0800 Subject: [PATCH 111/474] alter: true support for /-/insert and /-/upsert, refs #2101 --- datasette/views/table.py | 48 +++++++++++++++++++++++++++++++++++----- docs/json_api.rst | 6 ++++- tests/test_api_write.py | 48 ++++++++++++++++++++++++++++++++++++---- 3 files changed, 91 insertions(+), 11 deletions(-) diff --git a/datasette/views/table.py b/datasette/views/table.py index 50d2b3c2..fcbe253d 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -8,7 +8,12 @@ import markupsafe from datasette.plugins import pm from datasette.database import QueryInterrupted -from datasette.events import DropTableEvent, InsertRowsEvent, UpsertRowsEvent +from datasette.events import ( + AlterTableEvent, + DropTableEvent, + InsertRowsEvent, + UpsertRowsEvent, +) from datasette import tracer from datasette.utils import ( add_cors_headers, @@ -388,7 +393,7 @@ class TableInsertView(BaseView): extras = { key: value for key, value in data.items() if key not in ("row", "rows") } - valid_extras = {"return", "ignore", "replace"} + valid_extras = {"return", "ignore", "replace", "alter"} invalid_extras = extras.keys() - valid_extras if invalid_extras: return _errors( @@ -397,7 +402,6 @@ class TableInsertView(BaseView): if extras.get("ignore") and extras.get("replace"): return _errors(['Cannot use "ignore" and "replace" at the same time']) - # Validate columns of each row columns = set(await db.table_columns(table_name)) columns.update(pks_list) @@ -412,7 +416,7 @@ class TableInsertView(BaseView): ) ) invalid_columns = set(row.keys()) - columns - if invalid_columns: + if invalid_columns and not extras.get("alter"): errors.append( "Row {} has invalid columns: {}".format( i, ", ".join(sorted(invalid_columns)) @@ -476,10 +480,23 @@ class TableInsertView(BaseView): ignore = extras.get("ignore") replace = extras.get("replace") + alter = extras.get("alter") if upsert and (ignore or replace): return _error(["Upsert does not support ignore or replace"], 400) + initial_schema = None + if alter: + # Must have alter-table permission + if not await self.ds.permission_allowed( + request.actor, "alter-table", resource=(database_name, table_name) + ): + return _error(["Permission denied for alter-table"], 403) + # Track initial schema to check if it changed later + initial_schema = await db.execute_fn( + lambda conn: sqlite_utils.Database(conn)[table_name].schema + ) + should_return = bool(extras.get("return", False)) row_pk_values_for_later = [] if should_return and upsert: @@ -489,9 +506,13 @@ class TableInsertView(BaseView): table = sqlite_utils.Database(conn)[table_name] kwargs = {} if upsert: - kwargs["pk"] = pks[0] if len(pks) == 1 else pks + kwargs = { + "pk": pks[0] if len(pks) == 1 else pks, + "alter": alter, + } else: - kwargs = {"ignore": ignore, "replace": replace} + # Insert + kwargs = {"ignore": ignore, "replace": replace, "alter": alter} if should_return and not upsert: rowids = [] method = table.upsert if upsert else table.insert @@ -552,6 +573,21 @@ class TableInsertView(BaseView): ) ) + if initial_schema is not None: + after_schema = await db.execute_fn( + lambda conn: sqlite_utils.Database(conn)[table_name].schema + ) + if initial_schema != after_schema: + await self.ds.track_event( + AlterTableEvent( + request.actor, + database=database_name, + table=table_name, + before_schema=initial_schema, + after_schema=after_schema, + ) + ) + return Response.json(result, status=200 if upsert else 201) diff --git a/docs/json_api.rst b/docs/json_api.rst index 68a0c984..000f532d 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -618,6 +618,8 @@ Pass ``"ignore": true`` to ignore these errors and insert the other rows: Or you can pass ``"replace": true`` to replace any rows with conflicting primary keys with the new values. +Pass ``"alter: true`` to automatically add any missing columns to the table. This requires the :ref:`permissions_alter_table` permission. + .. _TableUpsertView: Upserting rows @@ -728,6 +730,8 @@ When using upsert you must provide the primary key column (or columns if the tab If your table does not have an explicit primary key you should pass the SQLite ``rowid`` key instead. +Pass ``"alter: true`` to automatically add any missing columns to the table. This requires the :ref:`permissions_alter_table` permission. + .. _RowUpdateView: Updating a row @@ -849,7 +853,7 @@ The JSON here describes the table that will be created: * ``pks`` can be used instead of ``pk`` to create a compound primary key. It should be a JSON list of column names to use in that primary key. * ``ignore`` can be set to ``true`` to ignore existing rows by primary key if the table already exists. * ``replace`` can be set to ``true`` to replace existing rows by primary key if the table already exists. -* ``alter`` can be set to ``true`` if you want to automatically add any missing columns to the table. This requires the :ref:`permissions_alter_table` permission. +* ``alter`` can be set to ``true`` if you want to automatically add any missing columns to the table. This requires the :ref:`permissions_alter_table` permission. If the table is successfully created this will return a ``201`` status code and the following response: diff --git a/tests/test_api_write.py b/tests/test_api_write.py index abf9a88a..9e1d73e0 100644 --- a/tests/test_api_write.py +++ b/tests/test_api_write.py @@ -60,6 +60,27 @@ async def test_insert_row(ds_write): assert not event.replace +@pytest.mark.asyncio +async def test_insert_row_alter(ds_write): + token = write_token(ds_write) + response = await ds_write.client.post( + "/data/docs/-/insert", + json={ + "row": {"title": "Test", "score": 1.2, "age": 5, "extra": "extra"}, + "alter": True, + }, + headers=_headers(token), + ) + assert response.status_code == 201 + assert response.json()["ok"] is True + assert response.json()["rows"][0]["extra"] == "extra" + # Analytics event + event = last_event(ds_write) + assert event.name == "alter-table" + assert "extra" not in event.before_schema + assert "extra" in event.after_schema + + @pytest.mark.asyncio @pytest.mark.parametrize("return_rows", (True, False)) async def test_insert_rows(ds_write, return_rows): @@ -278,16 +299,27 @@ async def test_insert_rows(ds_write, return_rows): 403, ["Permission denied: need both insert-row and update-row"], ), + # Alter table forbidden without alter permission + ( + "/data/docs/-/upsert", + {"rows": [{"id": 1, "title": "One", "extra": "extra"}], "alter": True}, + "update-and-insert-but-no-alter", + 403, + ["Permission denied for alter-table"], + ), ), ) async def test_insert_or_upsert_row_errors( ds_write, path, input, special_case, expected_status, expected_errors ): - token = write_token(ds_write) + token_permissions = [] if special_case == "insert-but-not-update": - token = write_token(ds_write, permissions=["ir", "vi"]) + token_permissions = ["ir", "vi"] if special_case == "update-but-not-insert": - token = write_token(ds_write, permissions=["ur", "vi"]) + token_permissions = ["ur", "vi"] + if special_case == "update-and-insert-but-no-alter": + token_permissions = ["ur", "ir"] + token = write_token(ds_write, permissions=token_permissions) if special_case == "duplicate_id": await ds_write.get_database("data").execute_write( "insert into docs (id) values (1)" @@ -309,7 +341,9 @@ async def test_insert_or_upsert_row_errors( actor_response = ( await ds_write.client.get("/-/actor.json", headers=kwargs["headers"]) ).json() - print(actor_response) + assert set((actor_response["actor"] or {}).get("_r", {}).get("a") or []) == set( + token_permissions + ) if special_case == "invalid_json": del kwargs["json"] @@ -434,6 +468,12 @@ async def test_insert_ignore_replace( {"id": 1, "title": "Two", "score": 1}, ], ), + ( + # Upsert with an alter + {"rows": [{"id": 1, "title": "One"}], "pk": "id"}, + {"rows": [{"id": 1, "title": "Two", "extra": "extra"}], "alter": True}, + [{"id": 1, "title": "Two", "extra": "extra"}], + ), ), ) @pytest.mark.parametrize("should_return", (False, True)) From 4e944c29e4a208f173f15ac2df6253ff90f6466f Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 8 Feb 2024 13:19:47 -0800 Subject: [PATCH 112/474] Corrected path used in test_update_row_check_permission --- tests/test_api_write.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_api_write.py b/tests/test_api_write.py index 9e1d73e0..b43ee5a6 100644 --- a/tests/test_api_write.py +++ b/tests/test_api_write.py @@ -633,7 +633,7 @@ async def test_update_row_check_permission(ds_write, scenario): pk = await _insert_row(ds_write) - path = "/data/{}/{}/-/delete".format( + path = "/data/{}/{}/-/update".format( "docs" if scenario != "bad_table" else "bad_table", pk ) From c954795f9af9007e7c04d9b472bfd2faef647a87 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 8 Feb 2024 13:30:48 -0800 Subject: [PATCH 113/474] alter: true for row/-/update, refs #2101 --- datasette/views/row.py | 12 +++++++++++- docs/json_api.rst | 2 ++ tests/test_api_write.py | 43 +++++++++++++++++++++++++++++++++++++++-- 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/datasette/views/row.py b/datasette/views/row.py index 7b43b893..4d20e41a 100644 --- a/datasette/views/row.py +++ b/datasette/views/row.py @@ -237,11 +237,21 @@ class RowUpdateView(BaseView): if not "update" in data or not isinstance(data["update"], dict): return _error(["JSON must contain an update dictionary"]) + invalid_keys = set(data.keys()) - {"update", "return", "alter"} + if invalid_keys: + return _error(["Invalid keys: {}".format(", ".join(invalid_keys))]) + update = data["update"] + alter = data.get("alter") + if alter and not await self.ds.permission_allowed( + request.actor, "alter-table", resource=(resolved.db.name, resolved.table) + ): + return _error(["Permission denied for alter-table"], 403) + def update_row(conn): sqlite_utils.Database(conn)[resolved.table].update( - resolved.pk_values, update + resolved.pk_values, update, alter=alter ) try: diff --git a/docs/json_api.rst b/docs/json_api.rst index 000f532d..c401d97e 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -787,6 +787,8 @@ The returned JSON will look like this: Any errors will return ``{"errors": ["... descriptive message ..."], "ok": false}``, and a ``400`` status code for a bad input or a ``403`` status code for an authentication or permission error. +Pass ``"alter: true`` to automatically add any missing columns to the table. This requires the :ref:`permissions_alter_table` permission. + .. _RowDeleteView: Deleting a row diff --git a/tests/test_api_write.py b/tests/test_api_write.py index b43ee5a6..7cc38674 100644 --- a/tests/test_api_write.py +++ b/tests/test_api_write.py @@ -622,12 +622,17 @@ async def test_delete_row(ds_write, table, row_for_create, pks, delete_path): @pytest.mark.asyncio -@pytest.mark.parametrize("scenario", ("no_token", "no_perm", "bad_table")) +@pytest.mark.parametrize( + "scenario", ("no_token", "no_perm", "bad_table", "cannot_alter") +) async def test_update_row_check_permission(ds_write, scenario): if scenario == "no_token": token = "bad_token" elif scenario == "no_perm": token = write_token(ds_write, actor_id="not-root") + elif scenario == "cannot_alter": + # update-row but no alter-table: + token = write_token(ds_write, permissions=["ur"]) else: token = write_token(ds_write) @@ -637,9 +642,13 @@ async def test_update_row_check_permission(ds_write, scenario): "docs" if scenario != "bad_table" else "bad_table", pk ) + json_body = {"update": {"title": "New title"}} + if scenario == "cannot_alter": + json_body["alter"] = True + response = await ds_write.client.post( path, - json={"update": {"title": "New title"}}, + json=json_body, headers=_headers(token), ) assert response.status_code == 403 if scenario in ("no_token", "bad_token") else 404 @@ -651,6 +660,36 @@ async def test_update_row_check_permission(ds_write, scenario): ) +@pytest.mark.asyncio +async def test_update_row_invalid_key(ds_write): + token = write_token(ds_write) + + pk = await _insert_row(ds_write) + + path = "/data/docs/{}/-/update".format(pk) + response = await ds_write.client.post( + path, + json={"update": {"title": "New title"}, "bad_key": 1}, + headers=_headers(token), + ) + assert response.status_code == 400 + assert response.json() == {"ok": False, "errors": ["Invalid keys: bad_key"]} + + +@pytest.mark.asyncio +async def test_update_row_alter(ds_write): + token = write_token(ds_write, permissions=["ur", "at"]) + pk = await _insert_row(ds_write) + path = "/data/docs/{}/-/update".format(pk) + response = await ds_write.client.post( + path, + json={"update": {"title": "New title", "extra": "extra"}, "alter": True}, + headers=_headers(token), + ) + assert response.status_code == 200 + assert response.json() == {"ok": True} + + @pytest.mark.asyncio @pytest.mark.parametrize( "input,expected_errors", From c62cfa6de836667834b5b9a7fef2b861307ac998 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 8 Feb 2024 13:32:36 -0800 Subject: [PATCH 114/474] Fix upsert test to detect new alter-table event --- tests/test_api_write.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/test_api_write.py b/tests/test_api_write.py index 7cc38674..2d127e1a 100644 --- a/tests/test_api_write.py +++ b/tests/test_api_write.py @@ -499,10 +499,14 @@ async def test_upsert(ds_write, initial, input, expected_rows, should_return): # Analytics event event = last_event(ds_write) - assert event.name == "upsert-rows" - assert event.num_rows == 1 assert event.database == "data" assert event.table == "upsert_test" + if input.get("alter"): + assert event.name == "alter-table" + assert "extra" in event.after_schema + else: + assert event.name == "upsert-rows" + assert event.num_rows == 1 if should_return: # We only expect it to return rows corresponding to those we sent From dcd9ea3622520c99a1f921766dc36ca4c0e3b796 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 8 Feb 2024 14:14:58 -0800 Subject: [PATCH 115/474] datasette-events-db as an example of track_events() --- docs/plugin_hooks.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 16f5cebb..960dc9b6 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -1817,6 +1817,7 @@ This example plugin logs details of all events to standard error: ) print(msg, file=sys.stderr, flush=True) +Example: `datasette-events-db `_ .. _plugin_hook_register_events: From bd9ed62e5d8821f9dc9e035b195452980c900b3c Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 8 Feb 2024 18:58:12 -0800 Subject: [PATCH 116/474] Make ds.pemrission_allawed(..., default=) a keyword-only argument, refs #2262 --- datasette/app.py | 2 +- datasette/views/table.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index af8cfeab..d943b97b 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -896,7 +896,7 @@ class Datasette: await await_me_maybe(hook) async def permission_allowed( - self, actor, action, resource=None, default=DEFAULT_NOT_SET + self, actor, action, resource=None, *, default=DEFAULT_NOT_SET ): """Check permissions using the permissions_allowed plugin hook""" result = None diff --git a/datasette/views/table.py b/datasette/views/table.py index fcbe253d..1c187692 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -444,10 +444,10 @@ class TableInsertView(BaseView): # Must have insert-row AND upsert-row permissions if not ( await self.ds.permission_allowed( - request.actor, "insert-row", database_name, table_name + request.actor, "insert-row", resource=(database_name, table_name) ) and await self.ds.permission_allowed( - request.actor, "update-row", database_name, table_name + request.actor, "update-row", resource=(database_name, table_name) ) ): return _error( From 398a92cf1e54f868ff80f01634d6a814d1c61998 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 8 Feb 2024 20:12:22 -0800 Subject: [PATCH 117/474] Include database in name of _execute_writes thread, closes #2265 --- datasette/database.py | 3 +++ tests/test_api.py | 6 +++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/datasette/database.py b/datasette/database.py index fba81496..becb552c 100644 --- a/datasette/database.py +++ b/datasette/database.py @@ -196,6 +196,9 @@ class Database: self._write_thread = threading.Thread( target=self._execute_writes, daemon=True ) + self._write_thread.name = "_execute_writes for database {}".format( + self.name + ) self._write_thread.start() task_id = uuid.uuid5(uuid.NAMESPACE_DNS, "datasette.io") reply_queue = janus.Queue() diff --git a/tests/test_api.py b/tests/test_api.py index 8cb73dbb..7a25b55e 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -780,7 +780,11 @@ async def test_threads_json(ds_client): expected_keys = {"threads", "num_threads"} if sys.version_info >= (3, 7, 0): expected_keys.update({"tasks", "num_tasks"}) - assert set(response.json().keys()) == expected_keys + data = response.json() + assert set(data.keys()) == expected_keys + # Should be at least one _execute_writes thread for __INTERNAL__ + thread_names = [thread["name"] for thread in data["threads"]] + assert "_execute_writes for database __INTERNAL__" in thread_names @pytest.mark.asyncio From 5d7997418664bcdfdba714c16bd5a67c241e8740 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 10 Feb 2024 07:19:47 -0800 Subject: [PATCH 118/474] Call them "notable events" --- docs/plugin_hooks.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 960dc9b6..5372ea5e 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -1765,7 +1765,7 @@ Returns HTML to be displayed at the top of the canned query page. Event tracking -------------- -Datasette includes an internal mechanism for tracking analytical events. This can be used for analytics, but can also be used by plugins that want to listen out for when key events occur (such as a table being created) and take action in response. +Datasette includes an internal mechanism for tracking notable events. This can be used for analytics, but can also be used by plugins that want to listen out for when key events occur (such as a table being created) and take action in response. Plugins can register to receive events using the ``track_event`` plugin hook. From b89cac3b6a63929325c067d0cf2d5748e4bf4d2e Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 13 Feb 2024 18:23:54 -0800 Subject: [PATCH 119/474] Use MD5 usedforsecurity=False on Python 3.9 and higher to pass FIPS Closes #2270 --- datasette/database.py | 4 ++-- datasette/utils/__init__.py | 10 +++++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/datasette/database.py b/datasette/database.py index becb552c..707d8f85 100644 --- a/datasette/database.py +++ b/datasette/database.py @@ -1,7 +1,6 @@ import asyncio from collections import namedtuple from pathlib import Path -import hashlib import janus import queue import sys @@ -15,6 +14,7 @@ from .utils import ( detect_spatialite, get_all_foreign_keys, get_outbound_foreign_keys, + md5_not_usedforsecurity, sqlite_timelimit, sqlite3, table_columns, @@ -74,7 +74,7 @@ class Database: def color(self): if self.hash: return self.hash[:6] - return hashlib.md5(self.name.encode("utf8")).hexdigest()[:6] + return md5_not_usedforsecurity(self.name)[:6] def suggest_name(self): if self.path: diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py index f2cd7eb0..e3637f7a 100644 --- a/datasette/utils/__init__.py +++ b/datasette/utils/__init__.py @@ -713,7 +713,7 @@ def to_css_class(s): """ if css_class_re.match(s): return s - md5_suffix = hashlib.md5(s.encode("utf8")).hexdigest()[:6] + md5_suffix = md5_not_usedforsecurity(s)[:6] # Strip leading _, - s = s.lstrip("_").lstrip("-") # Replace any whitespace with hyphens @@ -1401,3 +1401,11 @@ def redact_keys(original: dict, key_patterns: Iterable) -> dict: return data return redact(original) + + +def md5_not_usedforsecurity(s): + try: + return hashlib.md5(s.encode("utf8"), usedforsecurity=False).hexdigest() + except TypeError: + # For Python 3.8 which does not support usedforsecurity=False + return hashlib.md5(s.encode("utf8")).hexdigest() From 97de4d6362ce5a6c1e3520ecdc73b305ab269910 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 15 Feb 2024 21:35:49 -0800 Subject: [PATCH 120/474] Use transaction in delete_everything(), closes #2273 --- datasette/utils/internal_db.py | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/datasette/utils/internal_db.py b/datasette/utils/internal_db.py index 2e5ac53b..dd0d3a9d 100644 --- a/datasette/utils/internal_db.py +++ b/datasette/utils/internal_db.py @@ -69,18 +69,20 @@ async def populate_schema_tables(internal_db, db): database_name = db.name def delete_everything(conn): - conn.execute( - "DELETE FROM catalog_tables WHERE database_name = ?", [database_name] - ) - conn.execute( - "DELETE FROM catalog_columns WHERE database_name = ?", [database_name] - ) - conn.execute( - "DELETE FROM catalog_foreign_keys WHERE database_name = ?", [database_name] - ) - conn.execute( - "DELETE FROM catalog_indexes WHERE database_name = ?", [database_name] - ) + with conn: + conn.execute( + "DELETE FROM catalog_tables WHERE database_name = ?", [database_name] + ) + conn.execute( + "DELETE FROM catalog_columns WHERE database_name = ?", [database_name] + ) + conn.execute( + "DELETE FROM catalog_foreign_keys WHERE database_name = ?", + [database_name], + ) + conn.execute( + "DELETE FROM catalog_indexes WHERE database_name = ?", [database_name] + ) await internal_db.execute_write_fn(delete_everything) From 47e29e948b26e8c003a03b4fc46cb635134a3958 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 16 Feb 2024 10:05:18 -0800 Subject: [PATCH 121/474] Better comments in permission_allowed_default() --- datasette/default_permissions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/datasette/default_permissions.py b/datasette/default_permissions.py index c13f2ed2..757b3a46 100644 --- a/datasette/default_permissions.py +++ b/datasette/default_permissions.py @@ -144,7 +144,7 @@ def permission_allowed_default(datasette, actor, action, resource): if actor and actor.get("id") == "root": return True - # Resolve metadata view permissions + # Resolve view permissions in allow blocks in configuration if action in ( "view-instance", "view-database", @@ -158,7 +158,7 @@ def permission_allowed_default(datasette, actor, action, resource): if result is not None: return result - # Check custom permissions: blocks + # Resolve custom permissions: blocks in configuration result = await _resolve_config_permissions_blocks( datasette, actor, action, resource ) From 232a30459babebece653795d136fb6516444ecf0 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 16 Feb 2024 12:56:39 -0800 Subject: [PATCH 122/474] DATASETTE_TRACE_PLUGINS setting, closes #2274 --- datasette/plugins.py | 24 ++++++++++++++++++++++++ docs/writing_plugins.rst | 24 ++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/datasette/plugins.py b/datasette/plugins.py index f7a1905f..3769a209 100644 --- a/datasette/plugins.py +++ b/datasette/plugins.py @@ -1,6 +1,7 @@ import importlib import os import pluggy +from pprint import pprint import sys from . import hookspecs @@ -33,6 +34,29 @@ DEFAULT_PLUGINS = ( pm = pluggy.PluginManager("datasette") pm.add_hookspecs(hookspecs) +DATASETTE_TRACE_PLUGINS = os.environ.get("DATASETTE_TRACE_PLUGINS", None) + + +def before(hook_name, hook_impls, kwargs): + print(file=sys.stderr) + print(f"{hook_name}:", file=sys.stderr) + pprint(kwargs, width=40, indent=4, stream=sys.stderr) + print("Hook implementations:", file=sys.stderr) + pprint(hook_impls, width=40, indent=4, stream=sys.stderr) + + +def after(outcome, hook_name, hook_impls, kwargs): + results = outcome.get_result() + if not isinstance(results, list): + results = [results] + print(f"Results:", file=sys.stderr) + pprint(results, width=40, indent=4, stream=sys.stderr) + + +if DATASETTE_TRACE_PLUGINS: + pm.add_hookcall_monitoring(before, after) + + DATASETTE_LOAD_PLUGINS = os.environ.get("DATASETTE_LOAD_PLUGINS", None) if not hasattr(sys, "_called_from_test") and DATASETTE_LOAD_PLUGINS is None: diff --git a/docs/writing_plugins.rst b/docs/writing_plugins.rst index 5c8bc4c6..2bc6bd24 100644 --- a/docs/writing_plugins.rst +++ b/docs/writing_plugins.rst @@ -7,6 +7,30 @@ You can write one-off plugins that apply to just one Datasette instance, or you Want to start by looking at an example? The `Datasette plugins directory `__ lists more than 90 open source plugins with code you can explore. The :ref:`plugin hooks ` page includes links to example plugins for each of the documented hooks. +.. _writing_plugins_tracing: + +Tracing plugin hooks +-------------------- + +The ``DATASETTE_TRACE_PLUGINS`` environment variable turns on detailed tracing showing exactly which hooks are being run. This can be useful for understanding how Datasette is using your plugin. + +.. code-block:: bash + + DATASETTE_TRACE_PLUGINS=1 datasette mydb.db + +Example output:: + + actor_from_request: + { 'datasette': , + 'request': } + Hook implementations: + [ >, + >, + >] + Results: + [{'id': 'root'}] + + .. _writing_plugins_one_off: Writing one-off plugins From 8bfa3a51c222d653f45fb48ebcb6957a85f9ea6c Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 16 Feb 2024 13:29:39 -0800 Subject: [PATCH 123/474] Consider every plugins opinion in datasette.permission_allowed() Closes #2275, refs #2262 --- datasette/app.py | 14 +++++++++++++- docs/authentication.rst | 17 +++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/datasette/app.py b/datasette/app.py index d943b97b..8591af6a 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -903,6 +903,8 @@ class Datasette: # Use default from registered permission, if available if default is DEFAULT_NOT_SET and action in self.permissions: default = self.permissions[action].default + opinions = [] + # Every plugin is consulted for their opinion for check in pm.hook.permission_allowed( datasette=self, actor=actor, @@ -911,9 +913,19 @@ class Datasette: ): check = await await_me_maybe(check) if check is not None: - result = check + opinions.append(check) + + result = None + # If any plugin said False it's false - the veto rule + if any(not r for r in opinions): + result = False + elif any(r for r in opinions): + # Otherwise, if any plugin said True it's true + result = True + used_default = False if result is None: + # No plugin expressed an opinion, so use the default result = default used_default = True self._permission_checks.append( diff --git a/docs/authentication.rst b/docs/authentication.rst index 87ee6385..a8dc5637 100644 --- a/docs/authentication.rst +++ b/docs/authentication.rst @@ -71,6 +71,23 @@ Datasette's built-in view permissions (``view-database``, ``view-table`` etc) de Permissions with potentially harmful effects should default to *deny*. Plugin authors should account for this when designing new plugins - for example, the `datasette-upload-csvs `__ plugin defaults to deny so that installations don't accidentally allow unauthenticated users to create new tables by uploading a CSV file. +.. _authentication_permissions_explained: + +How permissions are resolved +---------------------------- + +The :ref:`datasette.permission_allowed(actor, action, resource=None, default=...)` method is called to check if an actor is allowed to perform a specific action. + +This method asks every plugin that implements the :ref:`plugin_hook_permission_allowed` hook if the actor is allowed to perform the action. + +Each plugin can return ``True`` to indicate that the actor is allowed to perform the action, ``False`` if they are not allowed and ``None`` if the plugin has no opinion on the matter. + +``False`` acts as a veto - if any plugin returns ``False`` then the permission check is denied. Otherwise, if any plugin returns ``True`` then the permission check is allowed. + +The ``resource`` argument can be used to specify a specific resource that the action is being performed against. Some permissions, such as ``view-instance``, do not involve a resource. Others such as ``view-database`` have a resource that is a string naming the database. Permissions that take both a database name and the name of a table, view or canned query within that database use a resource that is a tuple of two strings, ``(database_name, resource_name)``. + +Plugins that implement the ``permission_allowed()`` hook can decide if they are going to consider the provided resource or not. + .. _authentication_permissions_allow: Defining permissions with "allow" blocks From 244f3ff83aac19e96fab85a95ddde349079a9827 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 16 Feb 2024 13:39:57 -0800 Subject: [PATCH 124/474] Test demonstrating fix for permisisons bug in #2262 --- tests/test_api_write.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/test_api_write.py b/tests/test_api_write.py index 2d127e1a..2aea699b 100644 --- a/tests/test_api_write.py +++ b/tests/test_api_write.py @@ -365,6 +365,41 @@ async def test_insert_or_upsert_row_errors( assert before_count == after_count +@pytest.mark.asyncio +@pytest.mark.parametrize("allowed", (True, False)) +async def test_upsert_permissions_per_table(ds_write, allowed): + # https://github.com/simonw/datasette/issues/2262 + token = "dstok_{}".format( + ds_write.sign( + { + "a": "root", + "token": "dstok", + "t": int(time.time()), + "_r": { + "r": { + "data": { + "docs" if allowed else "other": ["ir", "ur"], + } + } + }, + }, + namespace="token", + ) + ) + response = await ds_write.client.post( + "/data/docs/-/upsert", + json={"rows": [{"id": 1, "title": "One"}]}, + headers={ + "Authorization": "Bearer {}".format(token), + }, + ) + if allowed: + assert response.status_code == 200 + assert response.json()["ok"] is True + else: + assert response.status_code == 403 + + @pytest.mark.asyncio @pytest.mark.parametrize( "ignore,replace,expected_rows", From 3a999a85fb431594ccee1adf38721de03de19500 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 16 Feb 2024 13:58:33 -0800 Subject: [PATCH 125/474] Fire insert-rows on /db/-/create if rows were inserted, refs #2260 --- datasette/views/database.py | 13 ++++++- tests/test_api_write.py | 71 +++++++++++++++++++++++++++++-------- 2 files changed, 69 insertions(+), 15 deletions(-) diff --git a/datasette/views/database.py b/datasette/views/database.py index bd55064f..2a8b40cc 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -10,7 +10,7 @@ import re import sqlite_utils import textwrap -from datasette.events import AlterTableEvent, CreateTableEvent +from datasette.events import AlterTableEvent, CreateTableEvent, InsertRowsEvent from datasette.database import QueryInterrupted from datasette.utils import ( add_cors_headers, @@ -1022,6 +1022,17 @@ class TableCreateView(BaseView): request.actor, database=db.name, table=table_name, schema=schema ) ) + if rows: + await self.ds.track_event( + InsertRowsEvent( + request.actor, + database=db.name, + table=table_name, + num_rows=len(rows), + ignore=ignore, + replace=replace, + ) + ) return Response.json(details, status=201) diff --git a/tests/test_api_write.py b/tests/test_api_write.py index 2aea699b..0eb915ba 100644 --- a/tests/test_api_write.py +++ b/tests/test_api_write.py @@ -857,13 +857,14 @@ async def test_drop_table(ds_write, scenario): @pytest.mark.asyncio @pytest.mark.parametrize( - "input,expected_status,expected_response", + "input,expected_status,expected_response,expected_events", ( # Permission error with a bad token ( {"table": "bad", "row": {"id": 1}}, 403, {"ok": False, "errors": ["Permission denied"]}, + [], ), # Successful creation with columns: ( @@ -910,6 +911,7 @@ async def test_drop_table(ds_write, scenario): ")" ), }, + ["create-table"], ), # Successful creation with rows: ( @@ -945,6 +947,7 @@ async def test_drop_table(ds_write, scenario): ), "row_count": 2, }, + ["create-table", "insert-rows"], ), # Successful creation with row: ( @@ -973,6 +976,7 @@ async def test_drop_table(ds_write, scenario): ), "row_count": 1, }, + ["create-table", "insert-rows"], ), # Create with row and no primary key ( @@ -992,6 +996,7 @@ async def test_drop_table(ds_write, scenario): "schema": ("CREATE TABLE [four] (\n" " [name] TEXT\n" ")"), "row_count": 1, }, + ["create-table", "insert-rows"], ), # Create table with compound primary key ( @@ -1013,6 +1018,7 @@ async def test_drop_table(ds_write, scenario): ), "row_count": 1, }, + ["create-table", "insert-rows"], ), # Error: Table is required ( @@ -1024,6 +1030,7 @@ async def test_drop_table(ds_write, scenario): "ok": False, "errors": ["Table is required"], }, + [], ), # Error: Invalid table name ( @@ -1036,6 +1043,7 @@ async def test_drop_table(ds_write, scenario): "ok": False, "errors": ["Invalid table name"], }, + [], ), # Error: JSON must be an object ( @@ -1045,6 +1053,7 @@ async def test_drop_table(ds_write, scenario): "ok": False, "errors": ["JSON must be an object"], }, + [], ), # Error: Cannot specify columns with rows or row ( @@ -1058,6 +1067,7 @@ async def test_drop_table(ds_write, scenario): "ok": False, "errors": ["Cannot specify columns with rows or row"], }, + [], ), # Error: columns, rows or row is required ( @@ -1069,6 +1079,7 @@ async def test_drop_table(ds_write, scenario): "ok": False, "errors": ["columns, rows or row is required"], }, + [], ), # Error: columns must be a list ( @@ -1081,6 +1092,7 @@ async def test_drop_table(ds_write, scenario): "ok": False, "errors": ["columns must be a list"], }, + [], ), # Error: columns must be a list of objects ( @@ -1093,6 +1105,7 @@ async def test_drop_table(ds_write, scenario): "ok": False, "errors": ["columns must be a list of objects"], }, + [], ), # Error: Column name is required ( @@ -1105,6 +1118,7 @@ async def test_drop_table(ds_write, scenario): "ok": False, "errors": ["Column name is required"], }, + [], ), # Error: Unsupported column type ( @@ -1117,6 +1131,7 @@ async def test_drop_table(ds_write, scenario): "ok": False, "errors": ["Unsupported column type: bad"], }, + [], ), # Error: Duplicate column name ( @@ -1132,6 +1147,7 @@ async def test_drop_table(ds_write, scenario): "ok": False, "errors": ["Duplicate column name: id"], }, + [], ), # Error: rows must be a list ( @@ -1144,6 +1160,7 @@ async def test_drop_table(ds_write, scenario): "ok": False, "errors": ["rows must be a list"], }, + [], ), # Error: rows must be a list of objects ( @@ -1156,6 +1173,7 @@ async def test_drop_table(ds_write, scenario): "ok": False, "errors": ["rows must be a list of objects"], }, + [], ), # Error: pk must be a string ( @@ -1169,6 +1187,7 @@ async def test_drop_table(ds_write, scenario): "ok": False, "errors": ["pk must be a string"], }, + [], ), # Error: Cannot specify both pk and pks ( @@ -1183,6 +1202,7 @@ async def test_drop_table(ds_write, scenario): "ok": False, "errors": ["Cannot specify both pk and pks"], }, + [], ), # Error: pks must be a list ( @@ -1196,12 +1216,14 @@ async def test_drop_table(ds_write, scenario): "ok": False, "errors": ["pks must be a list"], }, + [], ), # Error: pks must be a list of strings ( {"table": "bad", "row": {"id": 1, "name": "Row 1"}, "pks": [1, 2]}, 400, {"ok": False, "errors": ["pks must be a list of strings"]}, + [], ), # Error: ignore and replace are mutually exclusive ( @@ -1217,6 +1239,7 @@ async def test_drop_table(ds_write, scenario): "ok": False, "errors": ["ignore and replace are mutually exclusive"], }, + [], ), # ignore and replace require row or rows ( @@ -1230,6 +1253,7 @@ async def test_drop_table(ds_write, scenario): "ok": False, "errors": ["ignore and replace require row or rows"], }, + [], ), # ignore and replace require pk or pks ( @@ -1243,6 +1267,7 @@ async def test_drop_table(ds_write, scenario): "ok": False, "errors": ["ignore and replace require pk or pks"], }, + [], ), ( { @@ -1255,10 +1280,14 @@ async def test_drop_table(ds_write, scenario): "ok": False, "errors": ["ignore and replace require pk or pks"], }, + [], ), ), ) -async def test_create_table(ds_write, input, expected_status, expected_response): +async def test_create_table( + ds_write, input, expected_status, expected_response, expected_events +): + ds_write._tracked_events = [] # Special case for expected status of 403 if expected_status == 403: token = "bad_token" @@ -1272,12 +1301,9 @@ async def test_create_table(ds_write, input, expected_status, expected_response) assert response.status_code == expected_status data = response.json() assert data == expected_response - # create-table event - if expected_status == 201: - event = last_event(ds_write) - assert event.name == "create-table" - assert event.actor == {"id": "root", "token": "dstok"} - assert event.schema.startswith("CREATE TABLE ") + # Should have tracked the expected events + events = ds_write._tracked_events + assert [e.name for e in events] == expected_events @pytest.mark.asyncio @@ -1376,6 +1402,8 @@ async def test_create_table_ignore_replace(ds_write, input, expected_rows_after) ) assert first_response.status_code == 201 + ds_write._tracked_events = [] + # Try a second time second_response = await ds_write.client.post( "/data/-/create", @@ -1387,6 +1415,10 @@ async def test_create_table_ignore_replace(ds_write, input, expected_rows_after) rows = await ds_write.client.get("/data/test_insert_replace.json?_shape=array") assert rows.json() == expected_rows_after + # Check it fired the right events + event_names = [e.name for e in ds_write._tracked_events] + assert event_names == ["insert-rows"] + @pytest.mark.asyncio async def test_create_table_error_if_pk_changed(ds_write): @@ -1471,6 +1503,7 @@ async def test_method_not_allowed(ds_write, path): @pytest.mark.asyncio async def test_create_uses_alter_by_default_for_new_table(ds_write): + ds_write._tracked_events = [] token = write_token(ds_write) response = await ds_write.client.post( "/data/-/create", @@ -1490,8 +1523,8 @@ async def test_create_uses_alter_by_default_for_new_table(ds_write): headers=_headers(token), ) assert response.status_code == 201 - event = last_event(ds_write) - assert event.name == "create-table" + event_names = [e.name for e in ds_write._tracked_events] + assert event_names == ["create-table", "insert-rows"] @pytest.mark.asyncio @@ -1517,6 +1550,8 @@ async def test_create_using_alter_against_existing_table( headers=_headers(token), ) assert response.status_code == 201 + + ds_write._tracked_events = [] # Now try to insert more rows using /-/create with alter=True response2 = await ds_write.client.post( "/data/-/create", @@ -1536,8 +1571,16 @@ async def test_create_using_alter_against_existing_table( } else: assert response2.status_code == 201 + + event_names = [e.name for e in ds_write._tracked_events] + assert event_names == ["alter-table", "insert-rows"] + # It should have altered the table - event = last_event(ds_write) - assert event.name == "alter-table" - assert "extra" not in event.before_schema - assert "extra" in event.after_schema + alter_event = ds_write._tracked_events[0] + assert alter_event.name == "alter-table" + assert "extra" not in alter_event.before_schema + assert "extra" in alter_event.after_schema + + insert_rows_event = ds_write._tracked_events[1] + assert insert_rows_event.name == "insert-rows" + assert insert_rows_event.num_rows == 1 From 9906f937d92c79dcc457cb057d7222ed70aef0e0 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 16 Feb 2024 14:32:47 -0800 Subject: [PATCH 126/474] Release 1.0a9 Refs #2101, #2260, #2262, #2265, #2270, #2273, #2274, #2275 Closes #2276 --- datasette/version.py | 2 +- docs/changelog.rst | 45 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/datasette/version.py b/datasette/version.py index e43b9918..f5e07ac8 100644 --- a/datasette/version.py +++ b/datasette/version.py @@ -1,2 +1,2 @@ -__version__ = "1.0a8" +__version__ = "1.0a9" __version_info__ = tuple(__version__.split(".")) diff --git a/docs/changelog.rst b/docs/changelog.rst index d164f71d..e567f422 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,51 @@ Changelog ========= +.. _v1_0_a9: + +1.0a9 (2024-02-16) +------------------ + +This alpha release adds basic alter table support to the Datasette Write API and fixes a permissions bug relating to the ``/upsert`` API endpoint. + +Alter table support for create, insert, upsert and update +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The :ref:`JSON write API ` can now be used to apply simple alter table schema changes, provided the acting actor has the new :ref:`permissions_alter_table` permission. (:issue:`2101`) + +The only alter operation supported so far is adding new columns to an existing table. + +* The :ref:`/db/-/create ` API now adds new columns during large operations to create a table based on incoming example ``"rows"``, in the case where one of the later rows includes columns that were not present in the earlier batches. This requires the ``create-table`` but not the ``alter-table`` permission. +* When ``/db/-/create`` is called with rows in a situation where the table may have been already created, an ``"alter": true`` key can be included to indicate that any missing columns from the new rows should be added to the table. This requires the ``alter-table`` permission. +* :ref:`/db/table/-/insert ` and :ref:`/db/table/-/upsert ` and :ref:`/db/table/row-pks/-/update ` all now also accept ``"alter": true``, depending on the ``alter-table`` permission. + +Operations that alter a table now fire the new :ref:`alter-table event `. + +Permissions fix for the upsert API +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The :ref:`/database/table/-/upsert API ` had a minor permissions bug, only affecting Datasette instances that had configured the ``insert-row`` and ``update-row`` permissions to apply to a specific table rather than the database or instance as a whole. Full details in issue :issue:`2262`. + +To avoid similar mistakes in the future the :ref:`datasette.permission_allowed() ` method now specifies ``default=`` as a keyword-only argument. + +Permission checks now consider opinions from every plugin +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The :ref:`datasette.permission_allowed() ` method previously consulted every plugin that implemented the :ref:`permission_allowed() ` plugin hook and obeyed the opinion of the last plugin to return a value. (:issue:`2275`) + +Datasette now consults every plugin and checks to see if any of them returned ``False`` (the veto rule), and if none of them did, it then checks to see if any of them returned ``True``. + +This is explained at length in the new documentation covering :ref:`authentication_permissions_explained`. + +Other changes +~~~~~~~~~~~~~ + +- The new :ref:`DATASETTE_TRACE_PLUGINS=1 environment variable ` turns on detailed trace output for every executed plugin hook, useful for debugging and understanding how the plugin system works at a low level. (:issue:`2274`) +- Datasette on Python 3.9 or above marks its non-cryptographic uses of the MD5 hash function as ``usedforsecurity=False``, for compatibility with FIPS systems. (:issue:`2270`) +- SQL relating to :ref:`internals_internal` now executes inside a transaction, avoiding a potential database locked error. (:issue:`2273`) +- The ``/-/threads`` debug page now identifies the database in the name associated with each dedicated write thread. (:issue:`2265`) +- The ``/db/-/create`` API now fires a ``insert-rows`` event if rows were inserted after the table was created. (:issue:`2260`) + .. _v1_0_a8: 1.0a8 (2024-02-07) From e1c80efff8f4b0a53619546bb03e6dfd6cb42a32 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 16 Feb 2024 14:43:36 -0800 Subject: [PATCH 127/474] Note about activating alpha documentation versions on ReadTheDocs --- docs/contributing.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/contributing.rst b/docs/contributing.rst index ef022a4d..b678e637 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -254,6 +254,7 @@ Datasette releases are performed using tags. When a new release is published on * Re-point the "latest" tag on Docker Hub to the new image * Build a wheel bundle of the underlying Python source code * Push that new wheel up to PyPI: https://pypi.org/project/datasette/ +* If the release is an alpha, navigate to https://readthedocs.org/projects/datasette/versions/ and search for the tag name in the "Activate a version" filter, then mark that version as "active" to ensure it will appear on the public ReadTheDocs documentation site. To deploy new releases you will need to have push access to the main Datasette GitHub repository. From 5e0e440f2c8a0771b761b02801456e55e95e2a04 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 17 Feb 2024 20:28:15 -0800 Subject: [PATCH 128/474] database.execute_write_fn(transaction=True) parameter, closes #2277 --- datasette/database.py | 31 +++++++++++++++++++++++-------- docs/internals.rst | 15 ++++++++++----- tests/test_internals_database.py | 27 +++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 13 deletions(-) diff --git a/datasette/database.py b/datasette/database.py index 707d8f85..d34aac73 100644 --- a/datasette/database.py +++ b/datasette/database.py @@ -179,17 +179,25 @@ class Database: # Threaded mode - send to write thread return await self._send_to_write_thread(fn, isolated_connection=True) - async def execute_write_fn(self, fn, block=True): + async def execute_write_fn(self, fn, block=True, transaction=True): if self.ds.executor is None: # non-threaded mode if self._write_connection is None: self._write_connection = self.connect(write=True) self.ds._prepare_connection(self._write_connection, self.name) - return fn(self._write_connection) + if transaction: + with self._write_connection: + return fn(self._write_connection) + else: + return fn(self._write_connection) else: - return await self._send_to_write_thread(fn, block) + return await self._send_to_write_thread( + fn, block=block, transaction=transaction + ) - async def _send_to_write_thread(self, fn, block=True, isolated_connection=False): + async def _send_to_write_thread( + self, fn, block=True, isolated_connection=False, transaction=True + ): if self._write_queue is None: self._write_queue = queue.Queue() if self._write_thread is None: @@ -202,7 +210,9 @@ class Database: self._write_thread.start() task_id = uuid.uuid5(uuid.NAMESPACE_DNS, "datasette.io") reply_queue = janus.Queue() - self._write_queue.put(WriteTask(fn, task_id, reply_queue, isolated_connection)) + self._write_queue.put( + WriteTask(fn, task_id, reply_queue, isolated_connection, transaction) + ) if block: result = await reply_queue.async_q.get() if isinstance(result, Exception): @@ -244,7 +254,11 @@ class Database: pass else: try: - result = task.fn(conn) + if task.transaction: + with conn: + result = task.fn(conn) + else: + result = task.fn(conn) except Exception as e: sys.stderr.write("{}\n".format(e)) sys.stderr.flush() @@ -554,13 +568,14 @@ class Database: class WriteTask: - __slots__ = ("fn", "task_id", "reply_queue", "isolated_connection") + __slots__ = ("fn", "task_id", "reply_queue", "isolated_connection", "transaction") - def __init__(self, fn, task_id, reply_queue, isolated_connection): + def __init__(self, fn, task_id, reply_queue, isolated_connection, transaction): self.fn = fn self.task_id = task_id self.reply_queue = reply_queue self.isolated_connection = isolated_connection + self.transaction = transaction class QueryInterrupted(Exception): diff --git a/docs/internals.rst b/docs/internals.rst index bd7a70b5..6ca62423 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -1010,7 +1010,9 @@ You can pass additional SQL parameters as a tuple or dictionary. The method will block until the operation is completed, and the return value will be the return from calling ``conn.execute(...)`` using the underlying ``sqlite3`` Python library. -If you pass ``block=False`` this behaviour changes to "fire and forget" - queries will be added to the write queue and executed in a separate thread while your code can continue to do other things. The method will return a UUID representing the queued task. +If you pass ``block=False`` this behavior changes to "fire and forget" - queries will be added to the write queue and executed in a separate thread while your code can continue to do other things. The method will return a UUID representing the queued task. + +Each call to ``execute_write()`` will be executed inside a transaction. .. _database_execute_write_script: @@ -1019,6 +1021,8 @@ await db.execute_write_script(sql, block=True) Like ``execute_write()`` but can be used to send multiple SQL statements in a single string separated by semicolons, using the ``sqlite3`` `conn.executescript() `__ method. +Each call to ``execute_write_script()`` will be executed inside a transaction. + .. _database_execute_write_many: await db.execute_write_many(sql, params_seq, block=True) @@ -1033,10 +1037,12 @@ Like ``execute_write()`` but uses the ``sqlite3`` `conn.executemany() 5") - conn.commit() return conn.execute( "select count(*) from some_table" ).fetchone()[0] @@ -1069,7 +1074,7 @@ The value returned from ``await database.execute_write_fn(...)`` will be the ret If your function raises an exception that exception will be propagated up to the ``await`` line. -If you see ``OperationalError: database table is locked`` errors you should check that you remembered to explicitly call ``conn.commit()`` in your write function. +By default your function will be executed inside a transaction. You can pass ``transaction=False`` to disable this behavior, though if you do that you should be careful to manually apply transactions - ideally using the ``with conn:`` pattern, or you may see ``OperationalError: database table is locked`` errors. If you specify ``block=False`` the method becomes fire-and-forget, queueing your function to be executed and then allowing your code after the call to ``.execute_write_fn()`` to continue running while the underlying thread waits for an opportunity to run your function. A UUID representing the queued task will be returned. Any exceptions in your code will be silently swallowed. diff --git a/tests/test_internals_database.py b/tests/test_internals_database.py index dd68a6cb..57e75046 100644 --- a/tests/test_internals_database.py +++ b/tests/test_internals_database.py @@ -66,6 +66,33 @@ async def test_execute_fn(db): assert 2 == await db.execute_fn(get_1_plus_1) +@pytest.mark.asyncio +async def test_execute_fn_transaction_false(): + datasette = Datasette(memory=True) + db = datasette.add_memory_database("test_execute_fn_transaction_false") + + def run(conn): + try: + with conn: + conn.execute("create table foo (id integer primary key)") + conn.execute("insert into foo (id) values (44)") + # Table should exist + assert ( + conn.execute( + 'select count(*) from sqlite_master where name = "foo"' + ).fetchone()[0] + == 1 + ) + assert conn.execute("select id from foo").fetchall()[0][0] == 44 + raise ValueError("Cancel commit") + except ValueError: + pass + # Row should NOT exist + assert conn.execute("select count(*) from foo").fetchone()[0] == 0 + + await db.execute_write_fn(run, transaction=False) + + @pytest.mark.parametrize( "tables,exists", ( From 10f9ba1a0050724ba47a089861606bef58a4087f Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 17 Feb 2024 20:51:19 -0800 Subject: [PATCH 129/474] Take advantage of execute_write_fn(transaction=True) A bunch of places no longer need to do manual transaction handling thanks to this change. Refs #2277 --- datasette/database.py | 9 +++------ datasette/utils/internal_db.py | 27 +++++++++++++-------------- tests/test_internals_database.py | 10 ++++------ 3 files changed, 20 insertions(+), 26 deletions(-) diff --git a/datasette/database.py b/datasette/database.py index d34aac73..4e590d3a 100644 --- a/datasette/database.py +++ b/datasette/database.py @@ -123,8 +123,7 @@ class Database: async def execute_write(self, sql, params=None, block=True): def _inner(conn): - with conn: - return conn.execute(sql, params or []) + return conn.execute(sql, params or []) with trace("sql", database=self.name, sql=sql.strip(), params=params): results = await self.execute_write_fn(_inner, block=block) @@ -132,8 +131,7 @@ class Database: async def execute_write_script(self, sql, block=True): def _inner(conn): - with conn: - return conn.executescript(sql) + return conn.executescript(sql) with trace("sql", database=self.name, sql=sql.strip(), executescript=True): results = await self.execute_write_fn(_inner, block=block) @@ -149,8 +147,7 @@ class Database: count += 1 yield param - with conn: - return conn.executemany(sql, count_params(params_seq)), count + return conn.executemany(sql, count_params(params_seq)), count with trace( "sql", database=self.name, sql=sql.strip(), executemany=True diff --git a/datasette/utils/internal_db.py b/datasette/utils/internal_db.py index dd0d3a9d..dbfcceb4 100644 --- a/datasette/utils/internal_db.py +++ b/datasette/utils/internal_db.py @@ -69,20 +69,19 @@ async def populate_schema_tables(internal_db, db): database_name = db.name def delete_everything(conn): - with conn: - conn.execute( - "DELETE FROM catalog_tables WHERE database_name = ?", [database_name] - ) - conn.execute( - "DELETE FROM catalog_columns WHERE database_name = ?", [database_name] - ) - conn.execute( - "DELETE FROM catalog_foreign_keys WHERE database_name = ?", - [database_name], - ) - conn.execute( - "DELETE FROM catalog_indexes WHERE database_name = ?", [database_name] - ) + conn.execute( + "DELETE FROM catalog_tables WHERE database_name = ?", [database_name] + ) + conn.execute( + "DELETE FROM catalog_columns WHERE database_name = ?", [database_name] + ) + conn.execute( + "DELETE FROM catalog_foreign_keys WHERE database_name = ?", + [database_name], + ) + conn.execute( + "DELETE FROM catalog_indexes WHERE database_name = ?", [database_name] + ) await internal_db.execute_write_fn(delete_everything) diff --git a/tests/test_internals_database.py b/tests/test_internals_database.py index 57e75046..1c155cf3 100644 --- a/tests/test_internals_database.py +++ b/tests/test_internals_database.py @@ -501,9 +501,8 @@ async def test_execute_write_has_correctly_prepared_connection(db): @pytest.mark.asyncio async def test_execute_write_fn_block_false(db): def write_fn(conn): - with conn: - conn.execute("delete from roadside_attractions where pk = 1;") - row = conn.execute("select count(*) from roadside_attractions").fetchone() + conn.execute("delete from roadside_attractions where pk = 1;") + row = conn.execute("select count(*) from roadside_attractions").fetchone() return row[0] task_id = await db.execute_write_fn(write_fn, block=False) @@ -513,9 +512,8 @@ async def test_execute_write_fn_block_false(db): @pytest.mark.asyncio async def test_execute_write_fn_block_true(db): def write_fn(conn): - with conn: - conn.execute("delete from roadside_attractions where pk = 1;") - row = conn.execute("select count(*) from roadside_attractions").fetchone() + conn.execute("delete from roadside_attractions where pk = 1;") + row = conn.execute("select count(*) from roadside_attractions").fetchone() return row[0] new_count = await db.execute_write_fn(write_fn) From a4fa1ef3bd6a6117118b5cdd64aca2308c21604b Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 17 Feb 2024 20:56:15 -0800 Subject: [PATCH 130/474] Release 1.0a10 Refs #2277 --- datasette/version.py | 2 +- docs/changelog.rst | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/datasette/version.py b/datasette/version.py index f5e07ac8..809c434f 100644 --- a/datasette/version.py +++ b/datasette/version.py @@ -1,2 +1,2 @@ -__version__ = "1.0a9" +__version__ = "1.0a10" __version_info__ = tuple(__version__.split(".")) diff --git a/docs/changelog.rst b/docs/changelog.rst index e567f422..92f198af 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,17 @@ Changelog ========= +.. _v1_0_a10: + +1.0a10 (2024-02-17) +------------------- + +The only changes in this alpha correspond to the way Datasette handles database transactions. (:issue:`2277`) + +- The :ref:`database.execute_write_fn() ` method has a new ``transaction=True`` parameter. This defaults to ``True`` which means all functions executed using this method are now automatically wrapped in a transaction - previously the functions needed to roll transaction handling on their own, and many did not. +- Pass ``transaction=False`` to ``execute_write_fn()`` if you want to manually handle transactions in your function. +- Several internal Datasette features, including parts of the :ref:`JSON write API `, had been failing to wrap their operations in a transaction. This has been fixed by the new ``transaction=True`` default. + .. _v1_0_a9: 1.0a9 (2024-02-16) From 81629dbeffb5cee9086bc956ce3a9ab7d051f4d1 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 17 Feb 2024 21:03:41 -0800 Subject: [PATCH 131/474] Upgrade GitHub Actions, including PyPI publishing --- .github/workflows/publish.yml | 60 ++++++++++++++--------------------- .github/workflows/test.yml | 13 +++----- 2 files changed, 27 insertions(+), 46 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 64a03a77..55fc0eb9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -12,20 +12,15 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - uses: actions/cache@v3 - name: Configure pip caching - with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }} - restore-keys: | - ${{ runner.os }}-pip- + cache: pip + cache-dependency-path: setup.py - name: Install dependencies run: | pip install -e '.[test]' @@ -36,47 +31,38 @@ jobs: deploy: runs-on: ubuntu-latest needs: [test] + environment: release + permissions: + id-token: write steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: - python-version: '3.11' - - uses: actions/cache@v3 - name: Configure pip caching - with: - path: ~/.cache/pip - key: ${{ runner.os }}-publish-pip-${{ hashFiles('**/setup.py') }} - restore-keys: | - ${{ runner.os }}-publish-pip- + python-version: '3.12' + cache: pip + cache-dependency-path: setup.py - name: Install dependencies run: | - pip install setuptools wheel twine - - name: Publish - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} + pip install setuptools wheel build + - name: Build run: | - python setup.py sdist bdist_wheel - twine upload dist/* + python -m build + - name: Publish + uses: pypa/gh-action-pypi-publish@release/v1 deploy_static_docs: runs-on: ubuntu-latest needs: [deploy] if: "!github.event.release.prerelease" steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: '3.9' - - uses: actions/cache@v2 - name: Configure pip caching - with: - path: ~/.cache/pip - key: ${{ runner.os }}-publish-pip-${{ hashFiles('**/setup.py') }} - restore-keys: | - ${{ runner.os }}-publish-pip- + cache: pip + cache-dependency-path: setup.py - name: Install dependencies run: | python -m pip install -e .[docs] @@ -105,7 +91,7 @@ jobs: needs: [deploy] if: "!github.event.release.prerelease" steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Build and push to Docker Hub env: DOCKER_USER: ${{ secrets.DOCKER_USER }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 656b0b1c..3ac8756d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,19 +12,14 @@ jobs: matrix: python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} allow-prereleases: true - - uses: actions/cache@v3 - name: Configure pip caching - with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }} - restore-keys: | - ${{ runner.os }}-pip- + cache: pip + cache-dependency-path: setup.py - name: Build extension for --load-extension test run: |- (cd tests && gcc ext.c -fPIC -shared -o ext.so) From 3856a8cb244f1338d2c4bceb76a510022d88ade5 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 19 Feb 2024 12:51:14 -0800 Subject: [PATCH 132/474] Consistent Permission denied:, refs #2279 --- datasette/views/database.py | 6 +++--- tests/test_api_write.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/datasette/views/database.py b/datasette/views/database.py index 2a8b40cc..56fc6f8c 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -860,7 +860,7 @@ class TableCreateView(BaseView): if not await self.ds.permission_allowed( request.actor, "update-row", resource=database_name ): - return _error(["Permission denied - need update-row"], 403) + return _error(["Permission denied: need update-row"], 403) table_name = data.get("table") if not table_name: @@ -884,7 +884,7 @@ class TableCreateView(BaseView): if not await self.ds.permission_allowed( request.actor, "insert-row", resource=database_name ): - return _error(["Permission denied - need insert-row"], 403) + return _error(["Permission denied: need insert-row"], 403) alter = False if rows or row: @@ -897,7 +897,7 @@ class TableCreateView(BaseView): if not await self.ds.permission_allowed( request.actor, "alter-table", resource=database_name ): - return _error(["Permission denied - need alter-table"], 403) + return _error(["Permission denied: need alter-table"], 403) alter = True if columns: diff --git a/tests/test_api_write.py b/tests/test_api_write.py index 0eb915ba..634f5ee9 100644 --- a/tests/test_api_write.py +++ b/tests/test_api_write.py @@ -1316,7 +1316,7 @@ async def test_create_table( ["create-table"], {"table": "t", "rows": [{"name": "c"}]}, 403, - ["Permission denied - need insert-row"], + ["Permission denied: need insert-row"], ), # This should work: ( @@ -1330,7 +1330,7 @@ async def test_create_table( ["create-table", "insert-row"], {"table": "t", "rows": [{"id": 1}], "pk": "id", "replace": True}, 403, - ["Permission denied - need update-row"], + ["Permission denied: need update-row"], ), ), ) @@ -1567,7 +1567,7 @@ async def test_create_using_alter_against_existing_table( assert response2.status_code == 403 assert response2.json() == { "ok": False, - "errors": ["Permission denied - need alter-table"], + "errors": ["Permission denied: need alter-table"], } else: assert response2.status_code == 201 From b36a2d8f4b566a3a4902cdaa7a549241e0e8c881 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 19 Feb 2024 12:55:51 -0800 Subject: [PATCH 133/474] Require update-row to use insert replace, closes #2279 --- datasette/views/table.py | 5 +++++ docs/json_api.rst | 4 ++-- tests/test_api_write.py | 8 ++++++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/datasette/views/table.py b/datasette/views/table.py index 1c187692..6d0d9885 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -485,6 +485,11 @@ class TableInsertView(BaseView): if upsert and (ignore or replace): return _error(["Upsert does not support ignore or replace"], 400) + if replace and not await self.ds.permission_allowed( + request.actor, "update-row", resource=(database_name, table_name) + ): + return _error(['Permission denied: need update-row to use "replace"'], 403) + initial_schema = None if alter: # Must have alter-table permission diff --git a/docs/json_api.rst b/docs/json_api.rst index c401d97e..366f74b2 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -616,7 +616,7 @@ Pass ``"ignore": true`` to ignore these errors and insert the other rows: "ignore": true } -Or you can pass ``"replace": true`` to replace any rows with conflicting primary keys with the new values. +Or you can pass ``"replace": true`` to replace any rows with conflicting primary keys with the new values. This requires the :ref:`permissions_update_row` permission. Pass ``"alter: true`` to automatically add any missing columns to the table. This requires the :ref:`permissions_alter_table` permission. @@ -854,7 +854,7 @@ The JSON here describes the table that will be created: * ``pks`` can be used instead of ``pk`` to create a compound primary key. It should be a JSON list of column names to use in that primary key. * ``ignore`` can be set to ``true`` to ignore existing rows by primary key if the table already exists. -* ``replace`` can be set to ``true`` to replace existing rows by primary key if the table already exists. +* ``replace`` can be set to ``true`` to replace existing rows by primary key if the table already exists. This requires the :ref:`permissions_update_row` permission. * ``alter`` can be set to ``true`` if you want to automatically add any missing columns to the table. This requires the :ref:`permissions_alter_table` permission. If the table is successfully created this will return a ``201`` status code and the following response: diff --git a/tests/test_api_write.py b/tests/test_api_write.py index 634f5ee9..6a7ddeb6 100644 --- a/tests/test_api_write.py +++ b/tests/test_api_write.py @@ -221,6 +221,14 @@ async def test_insert_rows(ds_write, return_rows): 400, ['Cannot use "ignore" and "replace" at the same time'], ), + ( + # Replace is not allowed if you don't have update-row + "/data/docs/-/insert", + {"rows": [{"title": "Test"}], "replace": True}, + "insert-but-not-update", + 403, + ['Permission denied: need update-row to use "replace"'], + ), ( "/data/docs/-/insert", {"rows": [{"title": "Test"}], "invalid_param": True}, From 392ca2e24cc93a3918d07718f40524857d626d14 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 19 Feb 2024 13:40:48 -0800 Subject: [PATCH 134/474] Improvements to table column cog menu display, closes #2263 - Repositions if menu would cause a horizontal scrollbar - Arrow tip on menu now attempts to align with cog icon on column --- datasette/static/table.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/datasette/static/table.js b/datasette/static/table.js index 778457c5..0c54a472 100644 --- a/datasette/static/table.js +++ b/datasette/static/table.js @@ -217,6 +217,17 @@ const initDatasetteTable = function (manager) { menuList.appendChild(menuItem); }); + // Measure width of menu and adjust position if too far right + const menuWidth = menu.offsetWidth; + const windowWidth = window.innerWidth; + if (menuLeft + menuWidth > windowWidth) { + menu.style.left = windowWidth - menuWidth - 20 + "px"; + } + // Align menu .hook arrow with the column cog icon + const hook = menu.querySelector('.hook'); + const icon = th.querySelector('.dropdown-menu-icon'); + const iconRect = icon.getBoundingClientRect(); + hook.style.left = (iconRect.left - menuLeft + 1) + 'px'; } var svg = document.createElement("div"); From 27409a78929b4baa017cce2cc0ca636603ed6d37 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 19 Feb 2024 14:01:55 -0800 Subject: [PATCH 135/474] Fix for hook position in wide column names, refs #2263 --- datasette/static/table.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/datasette/static/table.js b/datasette/static/table.js index 0c54a472..4f81b2e5 100644 --- a/datasette/static/table.js +++ b/datasette/static/table.js @@ -227,7 +227,15 @@ const initDatasetteTable = function (manager) { const hook = menu.querySelector('.hook'); const icon = th.querySelector('.dropdown-menu-icon'); const iconRect = icon.getBoundingClientRect(); - hook.style.left = (iconRect.left - menuLeft + 1) + 'px'; + const hookLeft = (iconRect.left - menuLeft + 1) + 'px'; + hook.style.left = hookLeft; + // Move the whole menu right if the hook is too far right + const menuRect = menu.getBoundingClientRect(); + if (iconRect.right > menuRect.right) { + menu.style.left = (iconRect.right - menuWidth) + 'px'; + // And move hook tip as well + hook.style.left = (menuWidth - 13) + 'px'; + } } var svg = document.createElement("div"); From 26300738e3c6e7ad515bd513063f57249a05000a Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 19 Feb 2024 14:17:37 -0800 Subject: [PATCH 136/474] Fixes for permissions debug page, closes #2278 --- datasette/templates/permissions_debug.html | 10 +++++----- datasette/views/special.py | 17 +++++++++-------- tests/test_permissions.py | 8 ++++++++ 3 files changed, 22 insertions(+), 13 deletions(-) diff --git a/datasette/templates/permissions_debug.html b/datasette/templates/permissions_debug.html index 36a12acc..5a5c1aa6 100644 --- a/datasette/templates/permissions_debug.html +++ b/datasette/templates/permissions_debug.html @@ -57,7 +57,7 @@ textarea {

@@ -71,19 +71,19 @@ textarea { +{% endif %} + {% endblock %} diff --git a/datasette/url_builder.py b/datasette/url_builder.py index 9c6bbde0..16b3d42b 100644 --- a/datasette/url_builder.py +++ b/datasette/url_builder.py @@ -31,6 +31,12 @@ class Urls: db = self.ds.get_database(database) return self.path(tilde_encode(db.route), format=format) + def database_query(self, database, sql, format=None): + path = f"{self.database(database)}/-/query?" + urllib.parse.urlencode( + {"sql": sql} + ) + return self.path(path, format=format) + def table(self, database, table, format=None): path = f"{self.database(database)}/{tilde_encode(table)}" if format is not None: diff --git a/datasette/views/table.py b/datasette/views/table.py index d71efeb0..ea044b36 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -929,6 +929,7 @@ async def table_view_traced(datasette, request): database=resolved.db.name, table=resolved.table, ), + count_limit=resolved.db.count_limit, ), request=request, view_name="table", @@ -1280,6 +1281,9 @@ async def table_view_data( if extra_extras: extras.update(extra_extras) + async def extra_count_sql(): + return count_sql + async def extra_count(): "Total count of rows matching these filters" # Calculate the total count for this query @@ -1299,8 +1303,11 @@ async def table_view_data( # Otherwise run a select count(*) ... if count_sql and count is None and not nocount: + count_sql_limited = ( + f"select count(*) from (select * {from_sql} limit 10001)" + ) try: - count_rows = list(await db.execute(count_sql, from_sql_params)) + count_rows = list(await db.execute(count_sql_limited, from_sql_params)) count = count_rows[0][0] except QueryInterrupted: pass @@ -1615,6 +1622,7 @@ async def table_view_data( "facet_results", "facets_timed_out", "count", + "count_sql", "human_description_en", "next_url", "metadata", @@ -1647,6 +1655,7 @@ async def table_view_data( registry = Registry( extra_count, + extra_count_sql, extra_facet_results, extra_facets_timed_out, extra_suggested_facets, From dc288056b81a3635bdb02a6d0121887db2720e5e Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 21 Aug 2024 19:56:02 -0700 Subject: [PATCH 235/474] Better handling of errors for count all button, refs #2408 --- datasette/templates/table.html | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/datasette/templates/table.html b/datasette/templates/table.html index 187f0143..7246ff5d 100644 --- a/datasette/templates/table.html +++ b/datasette/templates/table.html @@ -42,7 +42,7 @@ {% if count or human_description_en %}

{% if count == count_limit + 1 %}>{{ "{:,}".format(count_limit) }} rows - {% if allow_execute_sql and query.sql %} count all rows{% endif %} + {% if allow_execute_sql and query.sql %} count all{% endif %} {% elif count or count == 0 %}{{ "{:,}".format(count) }} row{% if count == 1 %}{% else %}s{% endif %}{% endif %} {% if human_description_en %}{{ human_description_en }}{% endif %}

@@ -180,7 +180,7 @@ document.addEventListener('DOMContentLoaded', function() { const countLink = document.querySelector('a.count-sql'); if (countLink) { - countLink.addEventListener('click', function(ev) { + countLink.addEventListener('click', async function(ev) { ev.preventDefault(); // Replace countLink with span with same style attribute const span = document.createElement('span'); @@ -189,14 +189,23 @@ document.addEventListener('DOMContentLoaded', function() { countLink.replaceWith(span); countLink.setAttribute('disabled', 'disabled'); let url = countLink.href.replace(/(\?|$)/, '.json$1'); - fetch(url) - .then(response => response.json()) - .then(data => { - const count = data['rows'][0]['count(*)']; - const formattedCount = count.toLocaleString(); - span.closest('h3').textContent = formattedCount + ' rows'; - }) - .catch(error => countLink.textContent = 'error'); + try { + const response = await fetch(url); + console.log({response}); + const data = await response.json(); + console.log({data}); + if (!response.ok) { + console.log('throw error'); + throw new Error(data.title || data.error); + } + const count = data['rows'][0]['count(*)']; + const formattedCount = count.toLocaleString(); + span.closest('h3').textContent = formattedCount + ' rows'; + } catch (error) { + console.log('Update', span, 'with error message', error); + span.textContent = error.message; + span.style.color = 'red'; + } }); } }); From 92c4d41ca605e0837a2711ee52fde9cf1eea74d0 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 1 Sep 2024 17:20:41 -0700 Subject: [PATCH 236/474] results.dicts() method, closes #2414 --- datasette/database.py | 3 +++ datasette/views/row.py | 3 +-- datasette/views/table.py | 2 +- docs/internals.rst | 3 +++ tests/test_api_write.py | 23 +++++++++-------------- tests/test_internals_database.py | 11 +++++++++++ 6 files changed, 28 insertions(+), 17 deletions(-) diff --git a/datasette/database.py b/datasette/database.py index da0ab1de..a2e899bc 100644 --- a/datasette/database.py +++ b/datasette/database.py @@ -677,6 +677,9 @@ class Results: else: raise MultipleValues + def dicts(self): + return [dict(row) for row in self.rows] + def __iter__(self): return iter(self.rows) diff --git a/datasette/views/row.py b/datasette/views/row.py index d802994e..f374fd94 100644 --- a/datasette/views/row.py +++ b/datasette/views/row.py @@ -277,8 +277,7 @@ class RowUpdateView(BaseView): results = await resolved.db.execute( resolved.sql, resolved.params, truncate=True ) - rows = list(results.rows) - result["row"] = dict(rows[0]) + result["row"] = results.dicts()[0] await self.ds.track_event( UpdateRowEvent( diff --git a/datasette/views/table.py b/datasette/views/table.py index ea044b36..82dab613 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -558,7 +558,7 @@ class TableInsertView(BaseView): ), args, ) - result["rows"] = [dict(r) for r in fetched_rows.rows] + result["rows"] = fetched_rows.dicts() else: result["rows"] = rows # We track the number of rows requested, but do not attempt to show which were actually diff --git a/docs/internals.rst b/docs/internals.rst index 4289c815..facbc224 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -1093,6 +1093,9 @@ The ``Results`` object also has the following properties and methods: ``.rows`` - list of ``sqlite3.Row`` This property provides direct access to the list of rows returned by the database. You can access specific rows by index using ``results.rows[0]``. +``.dicts()`` - list of ``dict`` + This method returns a list of Python dictionaries, one for each row. + ``.first()`` - row or None Returns the first row in the results, or ``None`` if no rows were returned. diff --git a/tests/test_api_write.py b/tests/test_api_write.py index 9c2b9b45..04e61261 100644 --- a/tests/test_api_write.py +++ b/tests/test_api_write.py @@ -58,8 +58,8 @@ async def test_insert_row(ds_write, content_type): assert response.status_code == 201 assert response.json()["ok"] is True assert response.json()["rows"] == [expected_row] - rows = (await ds_write.get_database("data").execute("select * from docs")).rows - assert dict(rows[0]) == expected_row + rows = (await ds_write.get_database("data").execute("select * from docs")).dicts() + assert rows[0] == expected_row # Analytics event event = last_event(ds_write) assert event.name == "insert-rows" @@ -118,12 +118,9 @@ async def test_insert_rows(ds_write, return_rows): assert not event.ignore assert not event.replace - actual_rows = [ - dict(r) - for r in ( - await ds_write.get_database("data").execute("select * from docs") - ).rows - ] + actual_rows = ( + await ds_write.get_database("data").execute("select * from docs") + ).dicts() assert len(actual_rows) == 20 assert actual_rows == [ {"id": i + 1, "title": "Test {}".format(i), "score": 1.0, "age": 5} @@ -469,12 +466,10 @@ async def test_insert_ignore_replace( assert event.ignore == ignore assert event.replace == replace - actual_rows = [ - dict(r) - for r in ( - await ds_write.get_database("data").execute("select * from docs") - ).rows - ] + actual_rows = ( + await ds_write.get_database("data").execute("select * from docs") + ).dicts() + assert actual_rows == expected_rows assert response.json()["ok"] is True if should_return: diff --git a/tests/test_internals_database.py b/tests/test_internals_database.py index 0020668a..edfc6bc7 100644 --- a/tests/test_internals_database.py +++ b/tests/test_internals_database.py @@ -40,6 +40,17 @@ async def test_results_bool(db, expected): assert bool(results) is expected +@pytest.mark.asyncio +async def test_results_dicts(db): + results = await db.execute("select pk, name from roadside_attractions") + assert results.dicts() == [ + {"pk": 1, "name": "The Mystery Spot"}, + {"pk": 2, "name": "Winchester Mystery House"}, + {"pk": 3, "name": "Burlingame Museum of PEZ Memorabilia"}, + {"pk": 4, "name": "Bigfoot Discovery Museum"}, + ] + + @pytest.mark.parametrize( "query,expected", [ From 2170269258d1de38f4e518aa3e55e6b3ed202841 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 3 Sep 2024 08:37:26 -0700 Subject: [PATCH 237/474] New .core CSS class for inputs and buttons * Initial .core input/button classes, refs #2415 * Docs for the new .core CSS class, refs #2415 * Applied .core class everywhere that needs it, closes #2415 --- datasette/static/app.css | 33 +++++++++++++++------- datasette/templates/allow_debug.html | 2 +- datasette/templates/api_explorer.html | 4 +-- datasette/templates/create_token.html | 2 +- datasette/templates/database.html | 2 +- datasette/templates/logout.html | 2 +- datasette/templates/messages_debug.html | 2 +- datasette/templates/permissions_debug.html | 2 +- datasette/templates/query.html | 2 +- datasette/templates/table.html | 4 +-- docs/custom_templates.rst | 9 ++++++ docs/writing_plugins.rst | 3 +- tests/test_permissions.py | 2 +- 13 files changed, 46 insertions(+), 23 deletions(-) diff --git a/datasette/static/app.css b/datasette/static/app.css index 562d6adb..f975f0ad 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -528,8 +528,11 @@ label.sort_by_desc { pre#sql-query { margin-bottom: 1em; } -form input[type=text], -form input[type=search] { + +.core input[type=text], +input.core[type=text], +.core input[type=search], +input.core[type=search] { border: 1px solid #ccc; border-radius: 3px; width: 60%; @@ -540,17 +543,25 @@ form input[type=search] { } /* Stop Webkit from styling search boxes in an inconsistent way */ /* https://css-tricks.com/webkit-html5-search-inputs/ comments */ -input[type=search] { +.core input[type=search], +input.core[type=search] { -webkit-appearance: textfield; } -input[type="search"]::-webkit-search-decoration, -input[type="search"]::-webkit-search-cancel-button, -input[type="search"]::-webkit-search-results-button, -input[type="search"]::-webkit-search-results-decoration { +.core input[type="search"]::-webkit-search-decoration, +input.core[type="search"]::-webkit-search-decoration, +.core input[type="search"]::-webkit-search-cancel-button, +input.core[type="search"]::-webkit-search-cancel-button, +.core input[type="search"]::-webkit-search-results-button, +input.core[type="search"]::-webkit-search-results-button, +.core input[type="search"]::-webkit-search-results-decoration, +input.core[type="search"]::-webkit-search-results-decoration { display: none; } -form input[type=submit], form button[type=button] { +.core input[type=submit], +.core button[type=button], +input.core[type=submit], +button.core[type=button] { font-weight: 400; cursor: pointer; text-align: center; @@ -563,14 +574,16 @@ form input[type=submit], form button[type=button] { border-radius: .25rem; } -form input[type=submit] { +.core input[type=submit], +input.core[type=submit] { color: #fff; background: linear-gradient(180deg, #007bff 0%, #4E79C7 100%); border-color: #007bff; -webkit-appearance: button; } -form button[type=button] { +.core button[type=button], +button.core[type=button] { color: #007bff; background-color: #fff; border-color: #007bff; diff --git a/datasette/templates/allow_debug.html b/datasette/templates/allow_debug.html index 04181531..610417d2 100644 --- a/datasette/templates/allow_debug.html +++ b/datasette/templates/allow_debug.html @@ -35,7 +35,7 @@ p.message-warning {

Use this tool to try out different actor and allow combinations. See Defining permissions with "allow" blocks for documentation.

- +

diff --git a/datasette/templates/api_explorer.html b/datasette/templates/api_explorer.html index 109fb1e9..dc393c20 100644 --- a/datasette/templates/api_explorer.html +++ b/datasette/templates/api_explorer.html @@ -19,7 +19,7 @@

GET - +
@@ -29,7 +29,7 @@
POST - +
diff --git a/datasette/templates/create_token.html b/datasette/templates/create_token.html index 2be98d38..409fb8a9 100644 --- a/datasette/templates/create_token.html +++ b/datasette/templates/create_token.html @@ -39,7 +39,7 @@ {% endfor %} {% endif %} - +

diff --git a/datasette/templates/logout.html b/datasette/templates/logout.html index 4c4a7d11..c8fc642a 100644 --- a/datasette/templates/logout.html +++ b/datasette/templates/logout.html @@ -8,7 +8,7 @@

You are logged in as {{ display_actor(actor) }}

- +
diff --git a/datasette/templates/messages_debug.html b/datasette/templates/messages_debug.html index e0ab9a40..2940cd69 100644 --- a/datasette/templates/messages_debug.html +++ b/datasette/templates/messages_debug.html @@ -8,7 +8,7 @@

Set a message:

- +
diff --git a/datasette/templates/permissions_debug.html b/datasette/templates/permissions_debug.html index 5a5c1aa6..83891181 100644 --- a/datasette/templates/permissions_debug.html +++ b/datasette/templates/permissions_debug.html @@ -47,7 +47,7 @@ textarea {

This tool lets you simulate an actor and a permission check for that actor.

- +

diff --git a/datasette/templates/query.html b/datasette/templates/query.html index f7c8d0a3..a6e9a3aa 100644 --- a/datasette/templates/query.html +++ b/datasette/templates/query.html @@ -36,7 +36,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 %}

diff --git a/datasette/templates/table.html b/datasette/templates/table.html index 7246ff5d..c9e0e87b 100644 --- a/datasette/templates/table.html +++ b/datasette/templates/table.html @@ -48,7 +48,7 @@

{% endif %} - + {% if supports_search %}
{% endif %} @@ -152,7 +152,7 @@ object {% endif %}

- +

CSV options: diff --git a/docs/custom_templates.rst b/docs/custom_templates.rst index 534d8b33..8cc40f0f 100644 --- a/docs/custom_templates.rst +++ b/docs/custom_templates.rst @@ -83,6 +83,15 @@ database column they are representing, for example: +.. _customization_css: + +Writing custom CSS +~~~~~~~~~~~~~~~~~~ + +Custom templates need to take Datasette's default CSS into account. The pattern portfolio at ``/-/patterns`` (`example here `__) is a useful reference for understanding the available CSS classes. + +The ``core`` class is particularly useful - you can apply this directly to a ```` or ``